feat: QO-100 central beacon BPSK decoder (Milestone 1 — stable demod)

Декодер центрального маяка QO-100 (10489.750, AO-40 BPSK 400 бод): фронтенд
до мягких символов + констелляция, с устойчивым захватом несущей/символа.

Цепочка (BeaconDecoder.pas): NCO-снос смещения → boxcar-децим до 9600 →
RRC матч-фильтр → AGC-нормировка → Gardner timing → Costas BPSK с frequency
offload в пред-RRC NCO. squaring-FFT оставлена индикатором несущей (prom).

Ключевые решения по итогам эфирной отладки:
- invert=True для тракта QO-100 (LNB+трансвертер зеркалит спектр).
- Декодер развязан от beacon-lock (тот ретюнил железо, рвал IQ).
- AGC-нормировка петель: сигнал ~-60 dBFS замораживал Costas/Gardner.
- Frequency offload: накопл. снос Costas стекает в FResidInc (без потолка
  ±π/символ) → держит ошибку наведения (1пкс≈300Гц) + дрейф LNB.

Интеграция: тап IQ в WDSPEngine.PushIQItemToDSP; RadioController владеет
декодером, клик-наведение (ПКМ BEACON + ЛКМ/Shift+ЛКМ по спектру); узкий
фильтр-маркер в SpectrumView/GL; окно констелляции BeaconScopeForm; файловый
лог HOME/ewsdr_beacon.log для диагностики.

Milestone 2 (далее): FEC — Viterbi k=7 + RS(160,128) + интерливер + дескремблер.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 18:15:00 +03:00
co-authored by Claude Opus 4.8
parent 28adf9508a
commit c063d7a197
8 changed files with 1134 additions and 11 deletions
+689
View File
@@ -0,0 +1,689 @@
unit BeaconDecoder;
{
QO-100 central beacon — DSP front-end (Milestone 1).
Средний маяк QO-100 (10489.750) = AMSAT AO-40 FEC телеметрия: BPSK 400 бод.
Этот юнит делает ТОЛЬКО демодуляцию до мягких символов и снимает констелляцию
для визуализации/диагностики. FEC-бэкенд (Viterbi k=7 + RS(160,128) + интерливер
+ дескремблер) подключается отдельно (libfec Карна) на следующем этапе.
Цепочка (вход — комплексный baseband IQ на FSampleRate из DDC, после beacon-lock):
NCO-снос смещения маяка к DC
→ boxcar-дециматор до ~9600 Гц (целое D)
→ RRC матч-фильтр (beta 0.5)
→ Gardner TED + линейный интерполятор (символьная синхра 400 бод)
→ Costas (BPSK, 2-й порядок) (фаза/несущая)
→ мягкий символ sign(I), констелляция, метрики (SNR, lock).
Feed() зовётся из DSP-потока (WDSPEngine.PushIQItemToDSP), снимок читается из UI —
обмен под критической секцией, как в прежнем BeaconLock.
}
{$IFDEF FPC}
{$MODE Delphi}
{$ENDIF}
interface
uses
Classes, SysUtils, SyncObjs, Math;
const
BCN_BAUD = 400.0; // символьная скорость маяка
BCN_TARGET_FS = 9600.0; // целевой rate после децимации
BCN_RRC_BETA = 0.5; // roll-off матч-фильтра
BCN_RRC_SPAN = 8; // длина RRC в символах (полу-)
BCN_SCOPE_PTS = 256; // точек констелляции в снимке
BCN_NFFT = 4096; // FFT грубой carrier-acquisition (~0.43 c @ 9600)
BCN_NFFT_LOG2 = 12;
type
// Снимок состояния для UI (констелляция + метрики). Копируется под CS.
TBeaconScope = record
Enabled: Boolean;
Count: Integer; // заполнено точек [0..BCN_SCOPE_PTS]
PtI, PtQ: array[0..BCN_SCOPE_PTS-1] of Single; // нормированные символы
SNRdB: Single;
SymRate: Single; // измеренная скорость, ~400
CarrierLock: Boolean;
SymbolLock: Boolean;
OffsetHz: Double; // текущий снос NCO
ResidHz: Single; // найденный остаточный снос несущей
CarProm: Single; // prominence линии несущей (squaring-FFT)
FsHz: Single; // rate IQ-тапа (диагностика)
FdecHz: Single; // rate после децимации
Sps: Single; // отсчётов на символ
DecRMS: Single; // RMS децимированного сигнала (до RRC)
SymMag: Single; // |символ| после RRC (lock-независимо)
TimErr: Single; // EMA |ошибки Gardner| (таймер-петля)
Wsym: Single; // дробная поправка периода символа
CarFreqHz: Single; // частота Costas (Гц, остаток несущей)
LockHold: Integer; // холдовер-счётчик лока
end;
TBeaconDecoder = class
private
FLock: TCriticalSection;
FEnabled: Boolean;
// --- параметры rate ---
FFs: Double; // вход (DDC) Гц
FDecim: Integer; // целая децимация
FFdec: Double; // rate после децимации
FSps: Double; // отсчётов на символ после децимации
// --- NCO сноса ---
FOffsetHz: Double;
FNcoPh: Double;
FNcoInc: Double;
FInvert: Boolean; // зеркальный baseband (QO-100/LNB): сопряжение IQ
// --- boxcar дециматор ---
FAccI, FAccQ: Double;
FAccN: Integer;
// --- RRC матч-фильтр ---
FRRC: array of Double;
FHistI: array of Double; // кольцевой буфер decimated I
FHistQ: array of Double;
FHistPos: Integer;
FNtaps: Integer;
// --- Gardner timing ---
FMuAcc: Double; // обратный отсчёт до следующего полу-символьного строба
FHalf: Boolean; // текущий строб = середина (true) или центр символа
FMidI, FMidQ: Double; // отсчёт середины (для Gardner)
FPrevI, FPrevQ: Double; // прошлый центр символа
FYd0I, FYd0Q: Double; // линия задержки RRC-выхода (новейший)
FYd1I, FYd1Q: Double; // предыдущий
FWsym: Double; // дробная коррекция периода (интегратор)
FTimErrEMA: Double;
// --- Costas (BPSK) ---
FCarPh: Double;
FCarFreq: Double;
FCarAlpha: Double; // proportional
FCarBeta: Double; // integral
// --- грубая carrier-acquisition (squaring-FFT) ---
// BPSK² снимает модуляцию → линия на 2·Δf. Находит подавленную несущую
// независимо от знака сноса (алиасинг сворачивает её в полосу), игнорируя
// DC-offset Pluto (вычитаем среднее перед возведением в квадрат).
FAcqRe, FAcqIm: array of Double; // буфер декадированных отсчётов (NFFT)
FAcqPos: Integer;
FResidDf: Double; // накопленный остаточный снос несущей (Гц)
FResidPh: Double;
FResidInc: Double;
FCarProm: Double; // prominence линии (EMA)
FCarPresent: Boolean; // линия несущей видна
FFreqLocked: Boolean; // Costas держит фазу (для индикации/symbol-lock)
FLockHold: Integer; // холдовер-счётчик лока (анти-мерцание)
// --- FLL (частотная петля Costas, decision-directed) ---
FFllGain: Double;
FPrevRre, FPrevRim: Double; // прошлый символ с убранной модуляцией
FBitRev: array of Integer; // бит-реверс FFT
FTwRe, FTwIm: array of Double;// твидлы
FFre, FFim: array of Double; // рабочие буферы FFT
// --- метрики ---
FSigAmp: Double; // EMA |I|
FQVar: Double; // EMA Q^2
FConstScale: Double;
FDecRMS: Double; // EMA RMS децимированного сигнала (диагностика тракта)
FSymMag: Double; // EMA |символ| после RRC (диагностика тракта)
FRrcAmp: Double; // AGC: EMA амплитуды выхода RRC (нормировка петель)
// --- констелляция (кольцо) ---
FRingI, FRingQ: array[0..BCN_SCOPE_PTS-1] of Single;
FRingPos: Integer;
FRingCnt: Integer;
procedure BuildRRC;
procedure BuildFFT;
procedure FFTInplace;
procedure RunAcquisition;
procedure ProcessDecimated(const xi, xq: Double);
procedure ProcessSymbol(const zi, zq: Double);
procedure ResetDSP;
public
constructor Create;
destructor Destroy; override;
procedure Configure(SampleRateHz: Double); // при смене rate
procedure SetOffsetHz(Hz: Double); // снос маяка к DC (display center)
procedure SetInvert(On_: Boolean); // зеркальный baseband (инверсия спектра)
function Inverted: Boolean;
procedure Reseed; // переинициализация петель (новое наведение)
procedure SetEnabled(On_: Boolean);
property Enabled: Boolean read FEnabled;
// Из DSP-потока, по одному комплексному отсчёту на FSampleRate.
procedure Feed(const I, Q: Double); inline;
procedure GetScope(out S: TBeaconScope);
end;
implementation
constructor TBeaconDecoder.Create;
begin
inherited Create;
FLock := TCriticalSection.Create;
FFs := 0;
FEnabled := False;
FConstScale := 1.0;
Configure(192000);
end;
destructor TBeaconDecoder.Destroy;
begin
FLock.Free;
inherited Destroy;
end;
procedure TBeaconDecoder.BuildRRC;
// Root-raised-cosine матч-фильтр, длина BCN_RRC_SPAN символов, нормировка по энергии.
var
i, mid: Integer;
t, beta, num, den, h, ssum: Double;
begin
beta := BCN_RRC_BETA;
FNtaps := Round(BCN_RRC_SPAN * FSps);
if FNtaps < 8 then FNtaps := 8;
if (FNtaps and 1) = 0 then Inc(FNtaps); // нечётное → симметрично
SetLength(FRRC, FNtaps);
mid := FNtaps div 2;
ssum := 0;
for i := 0 to FNtaps - 1 do
begin
t := (i - mid) / FSps;
if Abs(t) < 1e-9 then
h := 1.0 - beta + 4.0 * beta / Pi
else if Abs(Abs(4.0 * beta * t) - 1.0) < 1e-6 then
h := (beta / Sqrt(2.0)) *
((1.0 + 2.0 / Pi) * Sin(Pi / (4.0 * beta)) +
(1.0 - 2.0 / Pi) * Cos(Pi / (4.0 * beta)))
else
begin
num := Sin(Pi * t * (1.0 - beta)) +
4.0 * beta * t * Cos(Pi * t * (1.0 + beta));
den := Pi * t * (1.0 - Sqr(4.0 * beta * t));
h := num / den;
end;
FRRC[i] := h;
ssum := ssum + h * h;
end;
if ssum > 0 then
for i := 0 to FNtaps - 1 do
FRRC[i] := FRRC[i] / Sqrt(ssum);
SetLength(FHistI, FNtaps);
SetLength(FHistQ, FNtaps);
end;
procedure TBeaconDecoder.BuildFFT;
// Таблицы бит-реверса и твидлов для радикс-2 FFT длины BCN_NFFT.
var
i, j, bit, n: Integer;
begin
n := BCN_NFFT;
SetLength(FBitRev, n);
for i := 0 to n - 1 do
begin
j := 0;
for bit := 0 to BCN_NFFT_LOG2 - 1 do
if (i and (1 shl bit)) <> 0 then
j := j or (1 shl (BCN_NFFT_LOG2 - 1 - bit));
FBitRev[i] := j;
end;
SetLength(FTwRe, n div 2);
SetLength(FTwIm, n div 2);
for i := 0 to (n div 2) - 1 do
begin
FTwRe[i] := Cos(-2.0 * Pi * i / n);
FTwIm[i] := Sin(-2.0 * Pi * i / n);
end;
SetLength(FFre, n);
SetLength(FFim, n);
SetLength(FAcqRe, n);
SetLength(FAcqIm, n);
end;
procedure TBeaconDecoder.FFTInplace;
// Итеративный радикс-2 DIT FFT над FFre/FFim (длина BCN_NFFT), in-place.
var
n, half, step, i, j, k, twi: Integer;
ur, ui, tr, ti: Double;
begin
n := BCN_NFFT;
// бит-реверс перестановка
for i := 0 to n - 1 do
begin
j := FBitRev[i];
if j > i then
begin
tr := FFre[i]; FFre[i] := FFre[j]; FFre[j] := tr;
ti := FFim[i]; FFim[i] := FFim[j]; FFim[j] := ti;
end;
end;
half := 1;
while half < n do
begin
step := half * 2;
i := 0;
while i < n do
begin
k := 0;
while k < half do
begin
twi := (k * (n div step));
ur := FTwRe[twi]; ui := FTwIm[twi];
tr := ur * FFre[i + k + half] - ui * FFim[i + k + half];
ti := ur * FFim[i + k + half] + ui * FFre[i + k + half];
FFre[i + k + half] := FFre[i + k] - tr;
FFim[i + k + half] := FFim[i + k] - ti;
FFre[i + k] := FFre[i + k] + tr;
FFim[i + k] := FFim[i + k] + ti;
Inc(k);
end;
Inc(i, step);
end;
half := step;
end;
end;
procedure TBeaconDecoder.RunAcquisition;
// Индикатор наличия несущей маяка: вычитаем DC (offset Pluto), возводим в квадрат
// (снимаем BPSK), FFT → ищем самую сильную линию вне зоны DC. prominence (pk/среднее)
// = «несущая видна» (FCarPresent). Частоту НЕ оцениваем — снос целиком ведёт
// Costas + offload (см. ProcessSymbol); squaring-FFT давала неверный знак.
var
i, n, loBin: Integer;
mr, mi, re, im, mag, pk, sumMag, meanMag: Double;
begin
n := BCN_NFFT;
// среднее (DC) для подавления offset Pluto
mr := 0; mi := 0;
for i := 0 to n - 1 do begin mr := mr + FAcqRe[i]; mi := mi + FAcqIm[i]; end;
mr := mr / n; mi := mi / n;
// z² с вычтенным DC
for i := 0 to n - 1 do
begin
re := FAcqRe[i] - mr; im := FAcqIm[i] - mi;
FFre[i] := (re * re - im * im);
FFim[i] := (2.0 * re * im);
end;
FFTInplace;
// самая сильная линия, исключая ±loBin вокруг DC (там сидит остаток DC при Δf≈0)
loBin := 2;
pk := 0; sumMag := 0;
for i := 0 to n - 1 do
begin
mag := FFre[i] * FFre[i] + FFim[i] * FFim[i];
sumMag := sumMag + mag;
if (i >= loBin) and (i <= n - loBin) and (mag > pk) then pk := mag;
end;
meanMag := sumMag / n;
if meanMag > 1e-30 then
FCarProm := FCarProm + 0.4 * (pk / meanMag - FCarProm);
FCarPresent := FCarProm > 20.0; // линия выше среднего → несущая в полосе
end;
procedure TBeaconDecoder.ResetDSP;
var i: Integer;
begin
FNcoPh := 0;
FAccI := 0; FAccQ := 0; FAccN := 0;
for i := 0 to High(FHistI) do begin FHistI[i] := 0; FHistQ[i] := 0; end;
FHistPos := 0;
FMuAcc := FSps / 2.0;
FHalf := False;
FMidI := 0; FMidQ := 0; FPrevI := 0; FPrevQ := 0;
FYd0I := 0; FYd0Q := 0; FYd1I := 0; FYd1Q := 0;
FWsym := 0; FTimErrEMA := 0;
FCarPh := 0; FCarFreq := 0;
FSigAmp := 0; FQVar := 0;
FDecRMS := 0; FSymMag := 0; FRrcAmp := 0;
FRingPos := 0; FRingCnt := 0;
FAcqPos := 0; FResidDf := 0; FResidPh := 0; FResidInc := 0;
FCarProm := 0; FCarPresent := False; FFreqLocked := False;
FLockHold := 0;
FPrevRre := 0; FPrevRim := 0;
end;
procedure TBeaconDecoder.Configure(SampleRateHz: Double);
var
bn, zeta, theta, d: Double;
begin
if SampleRateHz < 1000 then Exit;
FLock.Enter;
try
if SameValue(SampleRateHz, FFs) and (FNtaps > 0) then Exit;
FFs := SampleRateHz;
FDecim := Max(1, Round(FFs / BCN_TARGET_FS));
FFdec := FFs / FDecim;
FSps := FFdec / BCN_BAUD;
FNcoInc := 2.0 * Pi * FOffsetHz / FFs;
BuildRRC;
if Length(FBitRev) <> BCN_NFFT then BuildFFT;
// Costas: 2-й порядок, нормированный на символ. Bn ~ 1.5% символьной.
bn := 0.02;
zeta := 0.707;
theta := bn / (zeta + 1.0 / (4.0 * zeta));
d := 1.0 + 2.0 * zeta * theta + theta * theta;
FCarAlpha := (4.0 * zeta * theta) / d;
FCarBeta := (4.0 * theta * theta) / d;
FFllGain := 0.002; // частотная петля (decision-directed), мягкая
ResetDSP;
finally
FLock.Leave;
end;
end;
procedure TBeaconDecoder.SetOffsetHz(Hz: Double);
begin
FLock.Enter;
try
FOffsetHz := Hz;
if FFs > 0 then FNcoInc := 2.0 * Pi * FOffsetHz / FFs;
finally
FLock.Leave;
end;
end;
procedure TBeaconDecoder.SetInvert(On_: Boolean);
begin
FLock.Enter;
try
if On_ = FInvert then Exit;
FInvert := On_;
ResetDSP; // смена ориентации → перезахват
finally
FLock.Leave;
end;
end;
function TBeaconDecoder.Inverted: Boolean;
begin
Result := FInvert;
end;
procedure TBeaconDecoder.Reseed;
begin
FLock.Enter;
try
ResetDSP;
finally
FLock.Leave;
end;
end;
procedure TBeaconDecoder.SetEnabled(On_: Boolean);
begin
FLock.Enter;
try
if On_ = FEnabled then Exit;
FEnabled := On_;
if On_ then ResetDSP;
finally
FLock.Leave;
end;
end;
procedure TBeaconDecoder.Feed(const I, Q: Double);
// Один комплексный отсчёт @ FFs. Снос маяка к DC + boxcar-децимация.
var
c, s, mi, mq, qq: Double;
begin
if not FEnabled then Exit;
// Зеркальный baseband (QO-100/LNB): сопряжение IQ переворачивает ось частот,
// тогда снос совпадает с осью дисплея.
if FInvert then qq := -Q else qq := Q;
// exp(-j*ph): сносим +OffsetHz к DC.
c := Cos(FNcoPh); s := Sin(FNcoPh);
mi := I * c + qq * s;
mq := qq * c - I * s;
FNcoPh := FNcoPh + FNcoInc;
if FNcoPh > Pi then FNcoPh := FNcoPh - 2.0 * Pi
else if FNcoPh < -Pi then FNcoPh := FNcoPh + 2.0 * Pi;
FAccI := FAccI + mi;
FAccQ := FAccQ + mq;
Inc(FAccN);
if FAccN >= FDecim then
begin
ProcessDecimated(FAccI / FDecim, FAccQ / FDecim);
FAccI := 0; FAccQ := 0; FAccN := 0;
end;
end;
procedure TBeaconDecoder.ProcessDecimated(const xi, xq: Double);
// RRC матч-фильтр + Gardner-таймер @ FFdec. Без CS (зовётся из Feed, под потоком DSP);
// снимок констелляции защищён в GetScope собственным копированием полей-массивов.
var
k, idx: Integer;
yi, yq, frac, e, step, cr, sr, ri, ii: Double;
begin
// остаточный снос несущей (по squaring-FFT acquisition) — поверх главного NCO
cr := Cos(FResidPh); sr := Sin(FResidPh);
ri := xi * cr + xq * sr;
ii := xq * cr - xi * sr;
FResidPh := FResidPh + FResidInc;
if FResidPh > Pi then FResidPh := FResidPh - 2.0 * Pi
else if FResidPh < -Pi then FResidPh := FResidPh + 2.0 * Pi;
// Диагностика тракта (lock-независимо): RMS децимированного сигнала на входе RRC.
FDecRMS := FDecRMS + 0.001 * (Sqrt(ri * ri + ii * ii) - FDecRMS);
// Буфер для грубой carrier-acquisition: кладём сигнал ПОСЛЕ главного NCO, но
// ДО остаточного — тогда squaring-FFT меряет АБСОЛЮТНЫЙ остаток (не зависит от
// FResidDf) → коррекция = прямое присвоение, без накопления и runaway.
FAcqRe[FAcqPos] := xi; FAcqIm[FAcqPos] := xq;
Inc(FAcqPos);
if FAcqPos >= BCN_NFFT then
begin
RunAcquisition;
FAcqPos := 0;
end;
// вставляем в кольцо истории (уже с остаточной коррекцией)
FHistI[FHistPos] := ri;
FHistQ[FHistPos] := ii;
// свёртка RRC
yi := 0; yq := 0;
idx := FHistPos;
for k := 0 to FNtaps - 1 do
begin
yi := yi + FRRC[k] * FHistI[idx];
yq := yq + FRRC[k] * FHistQ[idx];
Dec(idx); if idx < 0 then idx := FNtaps - 1;
end;
FHistPos := FHistPos + 1; if FHistPos >= FNtaps then FHistPos := 0;
// AGC: нормируем выход RRC к ~1. Без этого при слабом сигнале (~−60 dBFS) ошибки
// петель (Costas ∝ амплитуде, Gardner ∝ амплитуде²) на 3 порядка меньше калибровки
// усилений → петли практически заморожены, лока нет. Нормировка делает динамику
// петель независимой от абсолютного уровня. FRrcAmp медленный — это и метрика силы.
if FRrcAmp <= 0 then
FRrcAmp := Sqrt(yi * yi + yq * yq) + 1e-12 // сид на первом отсчёте (без скачка)
else
FRrcAmp := FRrcAmp + 0.0005 * (Sqrt(yi * yi + yq * yq) - FRrcAmp);
if FRrcAmp > 1e-12 then
begin
yi := yi / FRrcAmp;
yq := yq / FRrcAmp;
end;
FSymMag := FRrcAmp; // диагностика силы сигнала (сырой уровень до AGC)
// линия задержки RRC-выхода для линейной интерполяции
FYd1I := FYd0I; FYd1Q := FYd0Q;
FYd0I := yi; FYd0Q := yq;
// Gardner: полу-символьные стробы. FMuAcc убывает на 1/отсчёт; при переходе
// через 0 интерполируем и планируем следующий строб через ~FSps/2 отсчётов.
FMuAcc := FMuAcc - 1.0;
if FMuAcc <= 0.0 then
begin
frac := FMuAcc + 1.0; // позиция в (0..1] между Yd1 и Yd0
if frac < 0 then frac := 0;
yi := FYd1I + frac * (FYd0I - FYd1I);
yq := FYd1Q + frac * (FYd0Q - FYd1Q);
step := (FSps / 2.0) * (1.0 + FWsym); // базовый интервал до след. полу-символа
if FHalf then
begin
// середина символа — запоминаем для Gardner
FMidI := yi; FMidQ := yq;
end
else
begin
// центр символа → ошибка Gardner (вращательно-инвариантна):
// e = Re{ conj(mid) * (curr - prev) } по компонентам
e := FMidI * (yi - FPrevI) + FMidQ * (yq - FPrevQ);
FTimErrEMA := FTimErrEMA + 0.01 * (Abs(e) - FTimErrEMA);
// После AGC вход ~единичный, ошибка Gardner шумная (по логам wsym бился об
// ±0.05). Снижаем усиления интегратора/пропорц. и поджимаем кламп: маяк 400 бод
// стабилен (доли %), большой диапазон не нужен, а шум перестаёт расшатывать.
FWsym := FWsym + 0.0003 * e; // интегратор периода (медленнее)
if FWsym > 0.01 then FWsym := 0.01;
if FWsym < -0.01 then FWsym := -0.01;
FPrevI := yi; FPrevQ := yq;
ProcessSymbol(yi, yq); // символ → Costas (вход уже нормирован AGC)
step := step + 0.003 * e; // пропорциональная правка таймера (мягче)
end;
FMuAcc := FMuAcc + step;
FHalf := not FHalf;
end;
end;
procedure TBeaconDecoder.ProcessSymbol(const zi, zq: Double);
// Costas BPSK: дероворот по FCarPh, решающе-направленный детектор фазы, обновление
// контура; нормированный символ → констелляция + метрики.
var
c, s, dr, di, ce, na, absI, ef, leak: Double;
begin
c := Cos(FCarPh); s := Sin(FCarPh);
dr := zi * c + zq * s; // умножение на exp(-j FCarPh)
di := zq * c - zi * s;
if dr >= 0 then ce := di else ce := -di; // Costas: фазовый детектор sign(I)*Q
absI := Abs(dr);
// FLL (decision-directed): убираем модуляцию (rk = |I| + j·ce), частотная ошибка
// = Im(rk·conj(rk-1)). Расширяет диапазон захвата и держит частоту устойчиво
// (отрицательная ОС) — в отличие от open-loop squaring-FFT, не «убегает».
ef := ce * FPrevRre - absI * FPrevRim;
FPrevRre := absI; FPrevRim := ce;
FCarFreq := FCarFreq + FCarBeta * ce + FFllGain * ef;
if FCarFreq > 3.0 then FCarFreq := 3.0;
if FCarFreq < -3.0 then FCarFreq := -3.0;
// OFFLOAD частоты: медленно «стекаем» накопленный снос Costas в пред-RRC NCO
// (FResidInc), у которого НЕТ предела ±π/символ. По логам carHz упирался в кламп
// (±127/±191) из-за ошибки наведения (1 пиксель≈300Гц) + дрейфа LNB и срывал лок.
// Консервативно: сколько добавили в FResidInc, столько убрали из FCarFreq (без
// рывка). Утечка медленная (TC~100 символов >> группозадержки RRC ~4 симв) — стабильно.
// Итог: Costas держит лишь мелкий быстрый остаток, весь медленный снос — в FResidInc.
leak := 0.01 * FCarFreq;
FResidInc := FResidInc + leak / FSps; // рад/символ → рад/децим-отсчёт
FResidDf := FResidInc * FFdec / (2.0 * Pi); // для лога/диагностики
FCarFreq := FCarFreq - leak;
FCarPh := FCarPh + FCarFreq + FCarAlpha * ce;
if FCarPh > Pi then FCarPh := FCarPh - 2.0 * Pi
else if FCarPh < -Pi then FCarPh := FCarPh + 2.0 * Pi;
// метрики — медленные EMA (~0.8с): SNR по логам скачет −8…+6 за секунду, на быстрой
// EMA порог лока дребезжит. Замедляем, чтобы оценка была устойчивой.
FSigAmp := FSigAmp + 0.003 * (Abs(dr) - FSigAmp);
FQVar := FQVar + 0.003 * (di * di - FQVar);
// Холдовер-лок (анти-мерцание): копим счётчик при хорошем SNR, тратим при плохом.
// Лок держится сквозь кратковременные провалы (~секунды), не отваливаясь на каждом.
if FQVar > 1e-12 then
begin
if (FSigAmp * FSigAmp) / FQVar > 2.0 then
begin
if FLockHold < 2400 then Inc(FLockHold, 3); // захват быстрее, чем потеря
end
else
if FLockHold > 0 then Dec(FLockHold);
end;
if FLockHold >= 600 then FFreqLocked := True
else if FLockHold <= 150 then FFreqLocked := False;
// нормировка констелляции к ±1 по амплитуде сигнала
if FSigAmp > 1e-9 then FConstScale := 1.0 / FSigAmp;
na := FConstScale;
FRingI[FRingPos] := dr * na;
FRingQ[FRingPos] := di * na;
FRingPos := FRingPos + 1; if FRingPos >= BCN_SCOPE_PTS then FRingPos := 0;
if FRingCnt < BCN_SCOPE_PTS then Inc(FRingCnt);
end;
procedure TBeaconDecoder.GetScope(out S: TBeaconScope);
var
i, idx: Integer;
snr: Double;
begin
FLock.Enter;
try
S.Enabled := FEnabled;
S.OffsetHz := FOffsetHz;
S.SymRate := BCN_BAUD * (1.0 + FWsym);
S.ResidHz := FResidDf;
S.CarProm := FCarProm;
S.FsHz := FFs;
S.FdecHz := FFdec;
S.Sps := FSps;
S.DecRMS := FDecRMS;
S.SymMag := FSymMag;
S.TimErr := FTimErrEMA;
S.Wsym := FWsym;
S.CarFreqHz := FCarFreq * BCN_BAUD / (2.0 * Pi); // рад/символ → Гц
S.LockHold := FLockHold;
// SNR ≈ (E|I|)^2 / E[Q^2] (мощность сигнала по I против шума по Q)
if FQVar > 1e-12 then
snr := (FSigAmp * FSigAmp) / FQVar
else
snr := 0;
if snr > 1e-6 then S.SNRdB := 10.0 * Log10(snr) else S.SNRdB := -99;
// CARRIER = линия несущей видна (squaring-FFT) И Costas держит фазу.
S.CarrierLock := FCarPresent and FFreqLocked;
S.SymbolLock := FFreqLocked and
(FTimErrEMA < 0.25 * Max(FSigAmp, 1e-9)) and (FRingCnt >= BCN_SCOPE_PTS);
S.Count := FRingCnt;
// выгружаем кольцо в хронологическом порядке (старейший → новейший)
for i := 0 to FRingCnt - 1 do
begin
if FRingCnt < BCN_SCOPE_PTS then
idx := i
else
begin
idx := FRingPos + i;
if idx >= BCN_SCOPE_PTS then Dec(idx, BCN_SCOPE_PTS);
end;
S.PtI[i] := FRingI[idx];
S.PtQ[i] := FRingQ[idx];
end;
finally
FLock.Leave;
end;
end;
end.