From c063d7a1978953d89592fdf9616fbd29a609bfd0 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sun, 21 Jun 2026 18:15:00 +0300 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20QO-100=20central=20beacon=20BPSK=20?= =?UTF-8?q?decoder=20(Milestone=201=20=E2=80=94=20stable=20demod)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Декодер центрального маяка 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 --- BeaconDecoder.pas | 689 +++++++++++++++++++++++++++++++++++++++++ BeaconScopeForm.pas | 165 ++++++++++ MainForm.pas | 50 ++- RadioController.pas | 164 +++++++++- SpectrumView.pas | 33 +- SpectrumViewOpengl.pas | 15 +- WDSPEngine.pas | 16 + ewsdr.lpi | 13 +- 8 files changed, 1134 insertions(+), 11 deletions(-) create mode 100644 BeaconDecoder.pas create mode 100644 BeaconScopeForm.pas diff --git a/BeaconDecoder.pas b/BeaconDecoder.pas new file mode 100644 index 0000000..8fe2f61 --- /dev/null +++ b/BeaconDecoder.pas @@ -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. diff --git a/BeaconScopeForm.pas b/BeaconScopeForm.pas new file mode 100644 index 0000000..b2a703f --- /dev/null +++ b/BeaconScopeForm.pas @@ -0,0 +1,165 @@ +unit BeaconScopeForm; + +{ TBeaconScopeForm — окно диагностики QO-100 beacon-декодера (Milestone 1). + Показывает BPSK-констелляцию демодулированного маяка + метрики захвата: + carrier-lock, symbol-lock, SNR, измеренную скорость, снос NCO. + + Это смотровое окно фронтенда ДО подключения FEC: если констелляция сжимается + в два чётких сгустка по оси I и горят оба лока — демодуляция корректна, можно + цеплять Viterbi/RS (libfec). Открывается из RX-блока MainForm (ПКМ по BEACON). + + Данные тянутся из TRadioController.GetBeaconScope по таймеру (~25 Гц). } + +{$mode objfpc}{$H+} + +interface + +uses + Classes, SysUtils, Math, StrUtils, + Forms, Controls, Graphics, ExtCtrls, + AppTheme, BeaconDecoder, RadioController; + +type + TBeaconScopeForm = class(TForm) + private + FCtl: TRadioController; + FBox: TPaintBox; + FTimer: TTimer; + FTheme: TAppTheme; + procedure DoTick(Sender: TObject); + procedure DoPaint(Sender: TObject); + public + constructor CreateWith(AOwner: TComponent; ACtl: TRadioController); + procedure ApplyTheme(const T: TAppTheme); + end; + +implementation + +constructor TBeaconScopeForm.CreateWith(AOwner: TComponent; ACtl: TRadioController); +begin + inherited CreateNew(AOwner); + FCtl := ACtl; + FTheme := DarkTheme; + + Caption := 'QO-100 Beacon — Constellation'; + BorderStyle := bsSizeable; + Width := 360; + Height := 510; + Position := poScreenCenter; + + FBox := TPaintBox.Create(Self); + FBox.Parent := Self; + FBox.Align := alClient; + FBox.OnPaint := @DoPaint; + + FTimer := TTimer.Create(Self); + FTimer.Interval := 40; // ~25 Гц + FTimer.OnTimer := @DoTick; + FTimer.Enabled := True; +end; + +procedure TBeaconScopeForm.ApplyTheme(const T: TAppTheme); +begin + FTheme := T; + if FBox <> nil then FBox.Invalidate; +end; + +procedure TBeaconScopeForm.DoTick(Sender: TObject); +begin + if (FBox <> nil) and Visible then FBox.Invalidate; +end; + +procedure TBeaconScopeForm.DoPaint(Sender: TObject); +var + C: TCanvas; + W, H, cx, cy, r, i, px, py: Integer; + S: TBeaconScope; + ok: Boolean; + C1, C2: TColor; + st: string; +begin + C := FBox.Canvas; + W := FBox.Width; H := FBox.Height; + + // фон + C.Brush.Style := bsSolid; + C.Brush.Color := FTheme.BG; + C.FillRect(0, 0, W, H); + + // область констелляции — квадрат сверху, текст снизу + r := (Min(W, H - 70) - 20) div 2; + if r < 20 then r := 20; + cx := W div 2; + cy := 10 + r; + + // сетка/оси + C.Pen.Style := psSolid; + C.Pen.Color := FTheme.SpecGrid; + C.Line(cx - r, cy, cx + r, cy); // ось I (горизонт) + C.Line(cx, cy - r, cx, cy + r); // ось Q (вертикаль) + C.Pen.Color := FTheme.Border; + C.Brush.Style := bsClear; + C.Rectangle(cx - r, cy - r, cx + r, cy + r); + + // целевые точки BPSK (±1, 0) — ориентир + C.Pen.Color := FTheme.TextDim; + C.Ellipse(cx + r div 2 - 3, cy - 3, cx + r div 2 + 3, cy + 3); + C.Ellipse(cx - r div 2 - 3, cy - 3, cx - r div 2 + 3, cy + 3); + + ok := (FCtl <> nil) and FCtl.GetBeaconScope(S); + + if ok and S.Enabled then + begin + // точки констелляции (нормированы к ±1; масштаб r/2 → ±1 на половине радиуса) + C.Pen.Style := psClear; + C.Brush.Style := bsSolid; + C.Brush.Color := FTheme.MeterOn; + for i := 0 to S.Count - 1 do + begin + px := cx + Round(S.PtI[i] * (r / 2)); + py := cy - Round(S.PtQ[i] * (r / 2)); + if (px >= cx - r) and (px <= cx + r) and (py >= cy - r) and (py <= cy + r) then + C.FillRect(px - 1, py - 1, px + 2, py + 2); + end; + end; + + // ----- метрики ----- + C.Brush.Style := bsClear; + C.Font.Name := 'Courier New'; + C.Font.Size := 9; + py := cy + r + 12; + + if not ok then + begin + C.Font.Color := FTheme.TextDim; + C.TextOut(14, py, 'decoder unavailable'); + Exit; + end; + if not S.Enabled then + begin + C.Font.Color := FTheme.TextDim; + C.TextOut(14, py, 'decoder OFF (enable via BEACON \ decode)'); + Exit; + end; + + if S.CarrierLock then C1 := FTheme.MeterOn else C1 := FTheme.TextDim; + if S.SymbolLock then C2 := FTheme.MeterOn else C2 := FTheme.TextDim; + + C.Font.Color := C1; + C.TextOut(14, py, 'CARRIER ' + IfThen(S.CarrierLock, 'LOCK', '----')); + C.Font.Color := C2; + C.TextOut(14, py + 18, 'SYMBOL ' + IfThen(S.SymbolLock, 'LOCK', '----')); + + C.Font.Color := FTheme.Text; + if S.SNRdB > -50 then st := Format('%.1f dB', [S.SNRdB]) else st := '--'; + C.TextOut(14, py + 36, 'SNR ' + st); + C.TextOut(14, py + 54, Format('BAUD %.1f (off %.0f Hz)', [S.SymRate, S.OffsetHz])); + C.Font.Color := FTheme.TextDim; + C.TextOut(14, py + 72, Format('CARRIER resid %.0f Hz prom %.0f', [S.ResidHz, S.CarProm])); + if FCtl <> nil then + C.TextOut(14, py + 90, 'INVERT ' + IfThen(FCtl.BeaconDecodeInvert, 'ON', 'OFF')); + C.TextOut(14, py + 108, Format('RATE Fs %.0f Fdec %.0f sps %.1f', + [S.FsHz, S.FdecHz, S.Sps])); +end; + +end. diff --git a/MainForm.pas b/MainForm.pas index d660abe..beef32c 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -39,7 +39,7 @@ uses WinFirewall, BoardUtils, WisdomBuilder, UISync, FlatEdit, FMRepeater, - ChannelStore, ChannelsForm, + ChannelStore, ChannelsForm, BeaconScopeForm, PowerInhibit, DeviceStore, RadioBackend, PlutoBackend, @@ -135,6 +135,7 @@ type FSpecDragX0: Integer; // X при нажатии FSpecDragFreq: Double; // FCenterFreq при нажатии FBeaconArming: Boolean; // ждём клик по маяку (после включения BEACON) + FBeaconDecArming: Boolean; // ждём клик: навести узкий фильтр декодера на маяк FSpectrumDirty: Boolean; // таймер должен перерисовать спектр/водопад // TX DUC-очередь (FDUCPending*) переехала в TRadioController (OnTXIQ). FSMeterPeak: Double; // верхняя граница светлой зоны @@ -287,6 +288,7 @@ type BtnChannels: TFlatButton; FChannelsDropDown: TFlatDropDown; FChannelsForm: TObject; // TChannelsForm (cast при использовании) + FBeaconScopeForm: TBeaconScopeForm; // окно констелляции маяка (ПКМ по BEACON) // ---- Right panel ---- PanelRight: TPanel; @@ -384,6 +386,8 @@ type procedure BtnFilterClick(Sender: TObject); procedure BtnCTunClick(Sender: TObject); procedure BtnBeaconClick(Sender: TObject); + procedure BtnBeaconMouseDown(Sender: TObject; Button: TMouseButton; + Shift: TShiftState; X, Y: Integer); procedure UpdateBeaconButton; procedure BtnDUPClick(Sender: TObject); procedure ApplyDUP(Active: Boolean); @@ -795,6 +799,7 @@ begin FController.FTXSpecGridStep := 10.0; FSettingsForm := nil; FChannelsForm := nil; + FBeaconScopeForm := nil; FillChar(FController.FDevMAC, SizeOf(FController.FDevMAC), 0); // Инициализируем кэш диапазонов умолчаниями @@ -1752,6 +1757,7 @@ begin LeftPanelButtonWidth(LEFT_W, 3, 2), BTN_H, BtnBeaconClick); BtnBeacon.Visible := False; + BtnBeacon.OnMouseDown := BtnBeaconMouseDown; // ПКМ → декодер + окно констелляции StyleButton(BtnBeacon, False); MakeLbl(PanelRXBlock, 'VOL', 4, 50); @@ -3272,6 +3278,9 @@ function TMainForm.BeaconStatusText: string; // Компактный статус QO-100 beacon lock для поля 7 статус-бара (Pluto). Courier 8pt, // поле ~200px → держим ≤ ~24 символов. begin + if FBeaconDecArming then Exit('DEC: click beacon'); + if FController.BeaconDecodeEnabled and (FController.BeaconDecodeFreqHz > 0) then + Exit('DEC: shift-click=re-aim'); if not FController.BeaconLockEnabled then Exit('BCN: off'); if FBeaconArming then Exit('BCN: click beacon'); case FController.BeaconState of @@ -3988,6 +3997,28 @@ begin UpdateBeaconButton; end; +procedure TMainForm.BtnBeaconMouseDown(Sender: TObject; Button: TMouseButton; + Shift: TShiftState; X, Y: Integer); +// ПКМ по BEACON: включает фронтенд-декодер маяка (BPSK 400 бод) и открывает окно +// констелляции для диагностики демодуляции (Milestone 1 — до подключения FEC). +begin + if Button <> mbRight then Exit; + FController.SetBeaconDecode(not FController.BeaconDecodeEnabled); + if FController.BeaconDecodeEnabled then + begin + // «Вооружаем» наведение: следующий ЛКМ по спектру поставит узкий фильтр + // декодера на маяк. Потом перенавести — Shift+ЛКМ по спектру. + FBeaconDecArming := True; + if FBeaconScopeForm = nil then + FBeaconScopeForm := TBeaconScopeForm.CreateWith(Self, FController); + FBeaconScopeForm.ApplyTheme(DarkTheme); + FBeaconScopeForm.Show; + end + else + FBeaconDecArming := False; + UpdateBeaconButton; +end; + procedure TMainForm.UpdateBeaconButton; // Beacon-маркеры + кнопка BEACON. Кнопка видима только на Pluto в трансвертере // (см. rfXvtr) — это ПРОСТО кнопка: подпись 'BEACON', подсветка = лок включён. @@ -3996,9 +4027,15 @@ procedure TMainForm.UpdateBeaconButton; begin // Маркеры на спектре: опорная частота (зелёная) + отслеживаемый центроид (оранж). if FSpecView <> nil then + begin FSpecView.SetBeaconMarkers(FController.BeaconLockEnabled, FController.BeaconRefHz, FController.BeaconTrackedFreqHz); + // Узкий фильтр-маркер декодера (±450 Гц ≈ полоса BPSK-маяка). + FSpecView.SetBeaconDecMarker( + FController.BeaconDecodeEnabled and (FController.BeaconDecodeFreqHz > 0), + FController.BeaconDecodeFreqHz, 450.0); + end; UpdateStatusField7; // моментальный апдейт статус-бара (не ждём MeterTimer) if BtnBeacon = nil then Exit; BtnBeacon.Caption := 'BEACON'; @@ -4186,6 +4223,17 @@ begin if Button = mbLeft then begin + // Наведение узкого фильтра ДЕКОДЕРА на маяк: после ПКМ BEACON (вооружено) + // либо Shift+ЛКМ в любой момент при включённом декодере. Не трогает VFO. + if FController.BeaconDecodeEnabled and (PbSpectrum.Width > 0) and + (FBeaconDecArming or (ssShift in Shift)) then + begin + FController.SetBeaconDecodeAtHz( + PixelToFreq(X, PbSpectrum.Width, FController.FCenterFreq, FController.FSpanHz)); + FBeaconDecArming := False; + UpdateBeaconButton; + Exit; + end; // Наведение beacon-lock: ЛКМ указывает на маяк (вместо перестройки VFO). if FBeaconArming and (PbSpectrum.Width > 0) then begin diff --git a/RadioController.pas b/RadioController.pas index 9725ba6..97c52e9 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -49,7 +49,7 @@ interface uses Classes, SysUtils, Math, HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings, - WDSPEngine, AudioOutput, AudioInput, + WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore; type @@ -196,6 +196,14 @@ type FBeaconLocked: Boolean; // пик уверенно виден FBeaconCorrCooldown: Integer; // тиков паузы после коррекции (осадка пайплайна) FBeaconSaveCooldown: Integer; // троттлинг записи LOError на диск (тиков) + // ---- QO-100 beacon DECODE (BPSK 400 бод, AO-40) — фронтенд+констелляция ---- + FBeaconDec: TBeaconDecoder; // владеет; тап IQ в WDSPEngine + FBeaconDecOn: Boolean; // декодер включён пользователем + FBeaconDecHz: Double; // АБСОЛЮТНАЯ display-частота наведения декодера + // (узкий фильтр, ставится кликом); 0 = центр/DC + FBeaconDecInvert: Boolean; // зеркальный baseband (QO-100/LNB) — деф. True + FBeaconLogTick: Integer; // троттл периодического лога состояния декодера + FBeaconLogScope: TBeaconScope; // буфер снимка для лога (не аллоцируем в контуре) // ---- Аудио / TX ---- FVolume: Integer; @@ -477,6 +485,7 @@ type // QO-100 beacon lock — стабилизация дрейфа LNB по опорному маяку. procedure SetBeaconLock(On_: Boolean); procedure ServiceBeaconLock; // одна итерация контура (MeterTimer @10 Гц) + procedure BeaconLog(const Msg: string); // диагностика декодера → HOME/ewsdr_beacon.log procedure SetBeaconRefHz(Hz: Double); // опорная частота маяка (downlink) procedure BeaconSeedAtHz(DisplayHz: Double); // пользователь указал маяк кликом function BeaconState: TBeaconState; @@ -486,6 +495,15 @@ type function BeaconRefHz: Double; // опорная частота маяка (для маркера) function BeaconTrackedFreqHz: Double;// отслеживаемая display-частота маяка (центроид) + // QO-100 beacon DECODE (BPSK 400 бод, AO-40) — фронтенд + констелляция. + procedure SetBeaconDecode(On_: Boolean); + procedure SetBeaconDecodeAtHz(DisplayHz: Double); // навести узкий фильтр декодера + function BeaconDecodeEnabled: Boolean; + function BeaconDecodeFreqHz: Double; // частота наведения (для маркера) + procedure SetBeaconDecodeInvert(On_: Boolean); // зеркальный baseband вкл/выкл + function BeaconDecodeInvert: Boolean; + function GetBeaconScope(out S: TBeaconScope): Boolean; + // Отображение / приём procedure SetCTun(On_: Boolean); procedure SetDuplex(On_: Boolean); @@ -596,6 +614,16 @@ begin FBeaconLockHz := 0.0; FBeaconLockOn := False; + // QO-100 beacon DECODE: фронтенд BPSK 400 бод (тап IQ в движке). + FBeaconDec := TBeaconDecoder.Create; + FBeaconDecOn := False; + // Тракт QO-100 (LNB+трансвертер) зеркалит спектр: проверено приборно (symMag ×4 + // и prom 500-800 при invert=ON против шума при OFF, на наведении точно на маяк). + // Декодеру нужно сопряжение, чтобы наведённый offset доставлял маяк к DC. + FBeaconDecInvert := True; + FBeaconDec.SetInvert(FBeaconDecInvert); + FDSPEngine.SetBeaconDecoder(FBeaconDec); + // Audio out/in — объекты создаём сейчас, Open вызывается позже (после показа // формы / при подключении устройства). FAudioOut := TAudioOutput.Create(48000); @@ -612,7 +640,13 @@ begin end; if Assigned(FAudioOut) then begin FAudioOut.Close; FreeAndNil(FAudioOut); end; if Assigned(FAudioIn) then begin FAudioIn.Close; FreeAndNil(FAudioIn); end; - if Assigned(FDSPEngine) then begin FDSPEngine.Close; FreeAndNil(FDSPEngine); end; + if Assigned(FDSPEngine) then + begin + FDSPEngine.SetBeaconDecoder(nil); + FDSPEngine.Close; + FreeAndNil(FDSPEngine); + end; + if Assigned(FBeaconDec) then FreeAndNil(FBeaconDec); end; destructor TRadioController.Destroy; @@ -1200,6 +1234,45 @@ procedure TRadioController.ServiceBeaconLock; // лок не теряется; во время settle позицию не двигаем (FFT в транзиенте). var win, pk, prom, cand, e, dLock: Double; begin + // Beacon-декодер: ЦЕЛЬ — маяк на DC IQ-тапа (= центр RX-спектра, тот же сырой + // DDC-IQ). По умолчанию offset=0: пользователь ставит маяк в центр спектра + // (CTUN/перестройка), squaring-FFT acquisition доводит остаток. ТОЛЬКО когда + // beacon-lock реально захвачен (зелёный) — доверяем его оценке и сносим + // FBeaconTrackHz−FCenterFreq (авто-центровка). Развязано от сырого FBeaconTrackHz, + // т.к. при выключенном/несошедшемся локе он недостоверен. + // Декодер НЕ зависит от beacon-lock: lock физически ретюнит железо + // (ApplyBeaconLOError → PushNetworkState), что рвёт IQ-поток и сбивает петли + // декодера (Costas/FLL/Gardner), которым нужны секунды непрерывного сигнала. + // У декодера своя петля несущей — она точнее, чем display-FFT-lock. Поэтому + // offset = только явный клик-аим, иначе DC. Авто-offset от FBeaconTrackHz убран. + if FBeaconDecOn and Assigned(FBeaconDec) then + begin + if FBeaconDecHz > 0 then + FBeaconDec.SetOffsetHz(FBeaconDecHz - FCenterFreq) // наведённый узкий фильтр + else + FBeaconDec.SetOffsetHz(0); // иначе — центр/DC + + // Периодический снимок состояния (раз в ~1с при @10Гц-таймере) в лог-файл. + Inc(FBeaconLogTick); + if FBeaconLogTick >= 10 then + begin + FBeaconLogTick := 0; + GetBeaconScope(FBeaconLogScope); + BeaconLog(Format( + 'STATE decHz=%.1f center=%.1f off=%.1f | Fs=%.0f Fdec=%.0f sps=%.1f | ' + + 'resid=%.1f prom=%.1f snr=%.1f baud=%.1f decRMS=%.5f symMag=%.5f ' + + 'timErr=%.5f wsym=%.4f carHz=%.1f hold=%d | CAR=%d SYM=%d invert=%d lockOn=%d locked=%d', + [FBeaconDecHz, FCenterFreq, FBeaconLogScope.OffsetHz, + FBeaconLogScope.FsHz, FBeaconLogScope.FdecHz, FBeaconLogScope.Sps, + FBeaconLogScope.ResidHz, FBeaconLogScope.CarProm, FBeaconLogScope.SNRdB, + FBeaconLogScope.SymRate, FBeaconLogScope.DecRMS, FBeaconLogScope.SymMag, + FBeaconLogScope.TimErr, FBeaconLogScope.Wsym, FBeaconLogScope.CarFreqHz, + FBeaconLogScope.LockHold, + Ord(FBeaconLogScope.CarrierLock), Ord(FBeaconLogScope.SymbolLock), + Ord(FBeaconDecInvert), Ord(FBeaconLockOn), Ord(FBeaconLocked)])); + end; + end; + if not FBeaconLockOn then Exit; if FCurrentXvtr < 0 then Exit; // только в трансвертер-режиме if FBeaconSaveCooldown > 0 then Dec(FBeaconSaveCooldown); @@ -1310,6 +1383,93 @@ begin Result := FBeaconTrackHz; end; +procedure TRadioController.SetBeaconDecode(On_: Boolean); +// Включает фронтенд-декодер маяка. Цель — маяк на DC (центр RX-спектра): стартуем +// с offset=0 (или явного клик-аима), дальше частоту держит петля несущей декодера. +// От beacon-lock декодер НЕ зависит (см. ServiceBeaconLock). Пользователь наводит +// клик на маяк либо центрирует его в спектре, acquisition/Costas доводят. +begin + FBeaconDecOn := On_; + if not Assigned(FBeaconDec) then Exit; + if not On_ then FBeaconDecHz := 0; // сброс наведения при выключении + if On_ then + begin + if FBeaconDecHz > 0 then + FBeaconDec.SetOffsetHz(FBeaconDecHz - FCenterFreq) + else + FBeaconDec.SetOffsetHz(0); + end; + FBeaconDec.SetEnabled(On_); +end; + +procedure TRadioController.SetBeaconDecodeAtHz(DisplayHz: Double); +// Пользователь навёл узкий фильтр на маяк (клик по спектру). Запоминаем абсолютную +// частоту, сносим её к DC и переинициализируем петли (одноразовая грубая оценка +// заново). Снос держится в ServiceBeaconLock при перестройке/панораме. +begin + FBeaconDecHz := DisplayHz; + if Assigned(FBeaconDec) then + begin + FBeaconDec.SetOffsetHz(FBeaconDecHz - FCenterFreq); + FBeaconDec.Reseed; + end; + BeaconLog(Format('AIM click: DisplayHz=%.1f CenterFreq=%.1f -> offset=%.1f invert=%d span=%.0f', + [DisplayHz, FCenterFreq, FBeaconDecHz - FCenterFreq, Ord(FBeaconDecInvert), FSpanHz])); + Changed(rfBeaconLock); +end; + +procedure TRadioController.BeaconLog(const Msg: string); +// Диагностика декодера маяка: дописываем строку в HOME/ewsdr_beacon.log. +// Дёшево и кросс-платформенно; файл потом скидывается на разбор. +var + f: TextFile; + path, home: string; +begin + home := GetEnvironmentVariable('HOME'); + if home = '' then home := GetTempDir; + path := IncludeTrailingPathDelimiter(home) + 'ewsdr_beacon.log'; + try + AssignFile(f, path); + if FileExists(path) then Append(f) else Rewrite(f); + try + WriteLn(f, FormatDateTime('hh:nn:ss.zzz', Now), ' ', Msg); + finally + CloseFile(f); + end; + except + // лог не должен ронять контур — глотаем ошибки I/O + end; +end; + +function TRadioController.BeaconDecodeEnabled: Boolean; +begin + Result := FBeaconDecOn; +end; + +function TRadioController.BeaconDecodeFreqHz: Double; +begin + Result := FBeaconDecHz; +end; + +procedure TRadioController.SetBeaconDecodeInvert(On_: Boolean); +begin + FBeaconDecInvert := On_; + if Assigned(FBeaconDec) then FBeaconDec.SetInvert(On_); + Changed(rfBeaconLock); +end; + +function TRadioController.BeaconDecodeInvert: Boolean; +begin + Result := FBeaconDecInvert; +end; + +function TRadioController.GetBeaconScope(out S: TBeaconScope): Boolean; +begin + Result := Assigned(FBeaconDec); + if Result then FBeaconDec.GetScope(S) + else FillChar(S, SizeOf(S), 0); +end; + procedure TRadioController.ApplyModeFilter; var Lo, Hi, Half: Integer; diff --git a/SpectrumView.pas b/SpectrumView.pas index 0e01336..5e3f9fa 100644 --- a/SpectrumView.pas +++ b/SpectrumView.pas @@ -77,6 +77,10 @@ type FBeaconMarkActive: Boolean; FBeaconRefFreq: Double; // опорная частота маяка (где ДОЛЖЕН быть) FBeaconTrkFreq: Double; // отслеживаемый центроид маяка сейчас + // узкий фильтр-маркер декодера (наведение пользователем) + FBeaconDecActive: Boolean; + FBeaconDecFreq: Double; + FBeaconDecHalf: Double; // полуширина фильтра, Гц // ── Spectrum buffer ─────────────────────────────────────────────────────── FSpectrumBuf: array[0..1023] of Single; FSpectrumBufCount: Integer; @@ -190,6 +194,7 @@ type // QO-100 beacon-маркеры: опорная частота (зелёная) + позиция трекера (оранж). // Спектр перерисовывается каждый кадр, поэтому отдельный invalidate не нужен. procedure SetBeaconMarkers(Active: Boolean; RefHz, TrkHz: Double); + procedure SetBeaconDecMarker(Active: Boolean; FreqHz, HalfHz: Double); // ── S-метр ──────────────────────────────────────────────────────────────── property LastSMeter: Double read GetLastSMeter write SetLastSMeter; @@ -501,6 +506,13 @@ begin FBeaconTrkFreq := TrkHz; end; +procedure TSpectrumView.SetBeaconDecMarker(Active: Boolean; FreqHz, HalfHz: Double); +begin + FBeaconDecActive := Active; + FBeaconDecFreq := FreqHz; + FBeaconDecHalf := HalfHz; +end; + procedure TSpectrumView.DrawBeaconMarkers(C: TCanvas; W, H: Integer); // Две вертикали: опорная частота маяка (зелёная пунктир) и отслеживаемый // центроид (оранжевая сплошная). Расхождение видно глазом → понятно, сел ли @@ -514,9 +526,24 @@ procedure TSpectrumView.DrawBeaconMarkers(C: TCanvas; W, H: Integer); C.Pen.Color := Col; C.Pen.Width := 1; C.Pen.Style := Style; C.MoveTo(x, 0); C.LineTo(x, H); end; +var + xc, x1, x2: Integer; begin - Vline(FBeaconRefFreq, clLime, psDot); // где маяк ДОЛЖЕН быть - Vline(FBeaconTrkFreq, TColor($000AA5FF), psSolid); // отслеживаемый центроид (оранж) + if FBeaconMarkActive then + begin + Vline(FBeaconRefFreq, clLime, psDot); // где маяк ДОЛЖЕН быть + Vline(FBeaconTrkFreq, TColor($000AA5FF), psSolid); // отслеживаемый центроид (оранж) + end; + // Узкий фильтр-маркер декодера: жёлтые грани полосы + центральная линия. + if FBeaconDecActive and (FSpanHz > 0) then + begin + xc := Round((FBeaconDecFreq - (FCenterFreq - FSpanHz / 2)) / FSpanHz * W); + x1 := Round((FBeaconDecFreq - FBeaconDecHalf - (FCenterFreq - FSpanHz / 2)) / FSpanHz * W); + x2 := Round((FBeaconDecFreq + FBeaconDecHalf - (FCenterFreq - FSpanHz / 2)) / FSpanHz * W); + BlendBand(x1, x2, H, $FF, $D0, $20, 48); // полупрозрачная полоса + C.Pen.Color := TColor($0020D0FF); C.Pen.Width := 1; C.Pen.Style := psSolid; + if (xc >= 0) and (xc < W) then begin C.MoveTo(xc, 0); C.LineTo(xc, H); end; + end; C.Pen.Style := psSolid; end; @@ -975,7 +1002,7 @@ begin end; if FMarkerActive then DrawMarkerLine(C, W, H); - if FBeaconMarkActive then DrawBeaconMarkers(C, W, H); + if FBeaconMarkActive or FBeaconDecActive then DrawBeaconMarkers(C, W, H); DrawADCOverloadOverlay(C, W, H); if Assigned(FSampleRateOverlay) then FSampleRateOverlay.DrawOverlay(FSpectrumBitmap, C, W, H); diff --git a/SpectrumViewOpengl.pas b/SpectrumViewOpengl.pas index 43205e1..1dc1391 100644 --- a/SpectrumViewOpengl.pas +++ b/SpectrumViewOpengl.pas @@ -668,8 +668,17 @@ procedure TSpectrumViewOpenGL.DrawBeaconMarkersGL(W, H: Integer); DrawLine(x, 0, x, H, Col, 1, False); end; begin - Vline(FBeaconRefFreq, clLime); // где маяк ДОЛЖЕН быть - Vline(FBeaconTrkFreq, TColor($000AA5FF)); // отслеживаемый центроид (оранж) + if FBeaconMarkActive then + begin + Vline(FBeaconRefFreq, clLime); // где маяк ДОЛЖЕН быть + Vline(FBeaconTrkFreq, TColor($000AA5FF)); // отслеживаемый центроид (оранж) + end; + if FBeaconDecActive then + begin + Vline(FBeaconDecFreq - FBeaconDecHalf, TColor($0020D0FF)); // грань фильтра + Vline(FBeaconDecFreq + FBeaconDecHalf, TColor($0020D0FF)); + Vline(FBeaconDecFreq, TColor($0020D0FF)); // центр наведения + end; end; procedure TSpectrumViewOpenGL.DrawADCOverlay(W, H: Integer); @@ -797,7 +806,7 @@ begin DrawAGCLines(W, H, DBmax, InvRange); DrawSpectrumCurve(W, H, DBmax, InvRange); DrawMarker(W, H); - if FBeaconMarkActive then DrawBeaconMarkersGL(W, H); + if FBeaconMarkActive or FBeaconDecActive then DrawBeaconMarkersGL(W, H); DrawADCOverlay(W, H); DrawCachedOverlays(W, H); diff --git a/WDSPEngine.pas b/WDSPEngine.pas index 0fc15a9..92ffcf9 100644 --- a/WDSPEngine.pas +++ b/WDSPEngine.pas @@ -25,6 +25,7 @@ interface uses Classes, SysUtils, Math, SyncObjs, + BeaconDecoder, WDSP; const @@ -292,6 +293,7 @@ type FMuted: Boolean; FVolume: Double; FLastError: string; + FBeaconDec: TBeaconDecoder; // не владеет; тап маяка в PushIQItemToDSP function ModeToWDSP(Mode: Integer): Integer; procedure ApplyDefaultFilter; @@ -331,6 +333,9 @@ type procedure Close; procedure ChangeSampleRate(NewRate: Integer); + // QO-100 beacon-декодер: тап RX-IQ для демодуляции маяка (не владеет). + procedure SetBeaconDecoder(D: TBeaconDecoder); + // Подача 24-bit big-endian IQ из DDC пакета // Buf — массив байт, DataOffset — смещение до IQ данных внутри Buf procedure PushDDCPacket(const Buf: array of Byte; @@ -1131,6 +1136,15 @@ begin // Переинициализируем WDSP с новым rate Open; + + // Beacon-декодер пересчитывает децимацию/RRC под новый IQ-rate. + if FBeaconDec <> nil then FBeaconDec.Configure(FSampleRate); +end; + +procedure TWDSPEngine.SetBeaconDecoder(D: TBeaconDecoder); +begin + FBeaconDec := D; + if (D <> nil) and (FSampleRate > 0) then D.Configure(FSampleRate); end; procedure TWDSPEngine.PushDDCPacket(const Buf: array of Byte; @@ -1213,6 +1227,8 @@ begin FeedDisplaySample(IR * SCALE, QR * SCALE); + if FBeaconDec <> nil then FBeaconDec.Feed(IR * SCALE, QR * SCALE); + FRXAccI[FRXAccPos] := IR * SCALE; FRXAccQ[FRXAccPos] := QR * SCALE; Inc(FRXAccPos); diff --git a/ewsdr.lpi b/ewsdr.lpi index e2d7d57..8d00486 100644 --- a/ewsdr.lpi +++ b/ewsdr.lpi @@ -17,9 +17,9 @@ - + - + @@ -45,6 +45,7 @@ + @@ -130,6 +131,14 @@ + + + + + + + + From afc8775ddb320e7e88ef392a209c26282e346d96 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Mon, 22 Jun 2026 10:21:33 +0300 Subject: [PATCH 2/4] feat: QO-100 beacon AO-40 FEC decode end-to-end (Milestone 2) Full chain now decodes the QO-100 central beacon to 256-byte AO-40 frames on-air (frames>0, RS errors=0). Builds on M1 (stable BPSK demod). FEC backend (BeaconFEC.pas, TBeaconFEC): - AO-40 FEC: distributed sync (65 bit, step 80) -> 80x65 deinterleave -> Viterbi r=1/2 k=7 -> CCSDS descramble -> 2x RS(160,128). Links system libfec (Karn) load-time (libfec.so/.dll/.dylib). Viterbi AO-40 [0x4F,-0x6D] remapped to libfec [0x6D,0x4F]. Validated against gr-satellites reference vectors (fectest2: exact 256-byte frame, 0 mismatches). DBPSK Manchester frontend (BeaconDecoder.pas): - Chip-rate (800) processing: RRC matched filter -> ML signal-times-slope timing recovery -> Costas -> Manchester combine (block phase select) -> differential decode -> soft symbols to FEC. - Carrier acquisition: squaring-FFT estimates the residual (carrier at +-pi/chip defeats decision-directed Costas), one-shot pulls it to DC via residual NCO. - Costas frequency offload into pre-RRC NCO (with deadband) tracks LNB drift without hitting the +-pi Costas clamp. - ML TED replaces Gardner, which degenerates on the Manchester chip stream. Temporary on-air diagnostics retained (IQ dump + STATE log) pending wider signal validation; to be removed before final cleanup. Co-Authored-By: Claude Opus 4.8 --- BeaconDecoder.pas | 285 ++++++++++++++++++++++++++++++------ BeaconFEC.pas | 341 ++++++++++++++++++++++++++++++++++++++++++++ BeaconScopeForm.pas | 8 +- RadioController.pas | 7 +- ewsdr.lpi | 4 + fectest.lpr | 18 +++ fectest2.lpr | 104 ++++++++++++++ 7 files changed, 717 insertions(+), 50 deletions(-) create mode 100644 BeaconFEC.pas create mode 100644 fectest.lpr create mode 100644 fectest2.lpr diff --git a/BeaconDecoder.pas b/BeaconDecoder.pas index 8fe2f61..3524e06 100644 --- a/BeaconDecoder.pas +++ b/BeaconDecoder.pas @@ -27,10 +27,11 @@ unit BeaconDecoder; interface uses - Classes, SysUtils, SyncObjs, Math; + Classes, SysUtils, SyncObjs, Math, BeaconFEC; const - BCN_BAUD = 400.0; // символьная скорость маяка + BCN_BAUD = 400.0; // скорость ДАННЫХ маяка (после Manchester) + BCN_CHIP_BAUD = 800.0; // чип-рейт (Manchester = 2× данных) — на нём таймер BCN_TARGET_FS = 9600.0; // целевой rate после децимации BCN_RRC_BETA = 0.5; // roll-off матч-фильтра BCN_RRC_SPAN = 8; // длина RRC в символах (полу-) @@ -60,6 +61,11 @@ type Wsym: Single; // дробная поправка периода символа CarFreqHz: Single; // частота Costas (Гц, остаток несущей) LockHold: Integer; // холдовер-счётчик лока + ManPhase: Integer; // выбранная фаза Manchester (0/1) + ManM0, ManM1: Single; // EMA энергии двух фаз (диагностика) + Frames: Int64; // декодировано кадров AO-40 FEC + HasFrame: Boolean; // есть хотя бы один кадр + LastRSErr: Integer; // ошибок RS в последнем кадре end; TBeaconDecoder = class @@ -97,6 +103,8 @@ type FPrevI, FPrevQ: Double; // прошлый центр символа FYd0I, FYd0Q: Double; // линия задержки RRC-выхода (новейший) FYd1I, FYd1Q: Double; // предыдущий + FYd2I, FYd2Q: Double; // −2 (для ML-TED центр/early/late + задержка «late») + FYd3I, FYd3Q: Double; // −3 FWsym: Double; // дробная коррекция периода (интегратор) FTimErrEMA: Double; @@ -117,6 +125,16 @@ type FResidInc: Double; FCarProm: Double; // prominence линии (EMA) FCarPresent: Boolean; // линия несущей видна + // --- one-shot захват частоты (squaring-FFT → FResidInc) --- + // На чип-рейте 800 бод снос ~400 Гц = π/чип: decision-directed Costas его НЕ + // тянет, а несущая в π/чип вырождает Manchester (chips одинаковы → 0.5(c0−c1)≈0). + // Поэтому грубый снос убираем NCO по оценке частоты из squaring-FFT (z² снимает + // BPSK → линия на 2·fc, знак однозначен при большом сносе). Усредняем по + // FAcqNeed блокам, ставим FResidInc один раз, разоружаемся. Дрейф LNB дальше + // ведёт непрерывный Costas+offload (отриц. ОС, не убегает). + FAcqArmed: Boolean; // ждём захвата (взводится на Reseed/новое наведение) + FAcqCount: Integer; // сколько блоков усреднено + FAcqSum: Double; // сумма оценок fc (Гц) для усреднения FFreqLocked: Boolean; // Costas держит фазу (для индикации/symbol-lock) FLockHold: Integer; // холдовер-счётчик лока (анти-мерцание) @@ -135,6 +153,28 @@ type FSymMag: Double; // EMA |символ| после RRC (диагностика тракта) FRrcAmp: Double; // AGC: EMA амплитуды выхода RRC (нормировка петель) + // --- Manchester-свёртка (чипы 800 → символы 400) --- + FChipPrevI, FChipPrevQ: Double; // предыдущий чип + FChipHave: Boolean; + FChipCount: Integer; // глобальный счётчик чипов (для чётности фазы) + FManMetric0, FManMetric1: Double; // EMA энергии |0.5(cPrev−c)| по двум фазам + FManPhase: Integer; // выбранная фаза (0: пары с чётного чипа) + + // --- дифференциальный декодер (DBPSK) --- + FSymPrevI, FSymPrevQ: Double; // предыдущий Manchester-символ + FSymHave: Boolean; + + // --- FEC-бэкенд (AO-40) --- + FFEC: TBeaconFEC; + FFrames: Int64; + FHasFrame: Boolean; + FLastFrame: TBeaconFrame; + FLastRSErr: Integer; + + // --- ВРЕМЕННЫЙ ДЕБАГ: дамп сырого baseband 9600 Гц (I,Q float32) --- + FDbgStream: TFileStream; + FDbgLeft: Integer; + // --- констелляция (кольцо) --- FRingI, FRingQ: array[0..BCN_SCOPE_PTS-1] of Single; FRingPos: Integer; @@ -146,6 +186,9 @@ type procedure RunAcquisition; procedure ProcessDecimated(const xi, xq: Double); procedure ProcessSymbol(const zi, zq: Double); + procedure ProcessChip(const ci, cq: Double); // Manchester + дифф-декод → FEC + procedure DbgWrite2(const a, b: Double); // ВРЕМЕННЫЙ дамп I,Q float32 + procedure FECFrame(const Frame: TBeaconFrame; RSErrors: Integer); procedure ResetDSP; public constructor Create; @@ -163,6 +206,7 @@ type procedure Feed(const I, Q: Double); inline; procedure GetScope(out S: TBeaconScope); + function GetLastFrame(out Frame: TBeaconFrame; out RSErrors: Integer): Boolean; end; implementation @@ -171,6 +215,10 @@ constructor TBeaconDecoder.Create; begin inherited Create; FLock := TCriticalSection.Create; + FFEC := TBeaconFEC.Create; + FFEC.OnFrame := FECFrame; + FDbgStream := nil; + FDbgLeft := 336000; // ВРЕМЕННЫЙ: ~35 c @ 9600 (2-3 кадра маяка) для оффлайн FFs := 0; FEnabled := False; FConstScale := 1.0; @@ -179,6 +227,8 @@ end; destructor TBeaconDecoder.Destroy; begin + if FDbgStream <> nil then FreeAndNil(FDbgStream); + FFEC.Free; FLock.Free; inherited Destroy; end; @@ -295,13 +345,21 @@ begin end; procedure TBeaconDecoder.RunAcquisition; -// Индикатор наличия несущей маяка: вычитаем DC (offset Pluto), возводим в квадрат -// (снимаем BPSK), FFT → ищем самую сильную линию вне зоны DC. prominence (pk/среднее) -// = «несущая видна» (FCarPresent). Частоту НЕ оцениваем — снос целиком ведёт -// Costas + offload (см. ProcessSymbol); squaring-FFT давала неверный знак. +// Грубый захват несущей маяка: вычитаем DC (offset Pluto), возводим в квадрат +// (z² снимает BPSK → линия на 2·fc), FFT → самая сильная линия вне зоны DC. +// prominence (pk/среднее) = «несущая видна» (FCarPresent). +// ЧАСТОТУ ОЦЕНИВАЕМ: бин пика → 2·fc → fc; усредняем по FAcqNeed блокам пока +// взведено (FAcqArmed) и несущая видна и Costas ещё не залочен, затем ставим +// FResidInc один раз (стягиваем несущую к DC) и разоружаемся. Знак z² однозначен +// при большом сносе (~±400 Гц), а буфер FAcqRe/Im берётся ДО остаточного NCO — +// значит меряем АБСОЛЮТНЫЙ снос, присваиваем FResidInc прямо (без накопления). +const + FAcqNeed = 4; // блоков усреднения (~1.7 c @ 9600) до применения + AcqMinHz = 15.0; // ниже — Costas вытянет сам, NCO не дёргаем + AcqMaxHz = 3000.0; // санитарный предел var - i, n, loBin: Integer; - mr, mi, re, im, mag, pk, sumMag, meanMag: Double; + i, n, loBin, pkBin: Integer; + mr, mi, re, im, mag, pk, sumMag, meanMag, fHz, fcHz, avgHz: Double; begin n := BCN_NFFT; // среднее (DC) для подавления offset Pluto @@ -320,17 +378,44 @@ begin // самая сильная линия, исключая ±loBin вокруг DC (там сидит остаток DC при Δf≈0) loBin := 2; - pk := 0; sumMag := 0; + pk := 0; pkBin := 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; + if (i >= loBin) and (i <= n - loBin) and (mag > pk) then begin pk := mag; pkBin := i; end; end; meanMag := sumMag / n; if meanMag > 1e-30 then FCarProm := FCarProm + 0.4 * (pk / meanMag - FCarProm); FCarPresent := FCarProm > 20.0; // линия выше среднего → несущая в полосе + + // --- one-shot оценка частоты --- + // Гейтим ТОЛЬКО по «несущая видна» + взведено (одноразово на наведение). НЕ + // гейтим по FFreqLocked: Costas на чип-рейте может ложно «залочиться» на состоянии + // +400 (π/чип) за ~3 c — раньше, чем накопится усреднение; тогда условие + // not FFreqLocked заблокировало бы захват (гонка). Применение и так защищено + // порогом |avgHz|>=AcqMinHz: если несущая реально у DC (Costas справился сам), + // оценка ~0 → коррекцию не трогаем. + if not (FAcqArmed and FCarPresent) then Exit; + + // бин пика → частота линии z² (= 2·fc), со знаком; затем fc = /2 + if pkBin <= n div 2 then fHz := pkBin * FFdec / n + else fHz := (pkBin - n) * FFdec / n; + fcHz := fHz / 2.0; + FAcqSum := FAcqSum + fcHz; + Inc(FAcqCount); + if FAcqCount < FAcqNeed then Exit; + + avgHz := FAcqSum / FAcqCount; + // применяем: residual NCO (exp(-j FResidPh)) с FResidInc убирает +fc к DC + if (Abs(avgHz) >= AcqMinHz) and (Abs(avgHz) <= AcqMaxHz) then + begin + FResidInc := 2.0 * Pi * avgHz / FFdec; + FResidDf := avgHz; + end; + FAcqArmed := False; // разоружаемся; дальше дрейф ведёт Costas+offload + FAcqCount := 0; FAcqSum := 0; end; procedure TBeaconDecoder.ResetDSP; @@ -344,6 +429,7 @@ begin FHalf := False; FMidI := 0; FMidQ := 0; FPrevI := 0; FPrevQ := 0; FYd0I := 0; FYd0Q := 0; FYd1I := 0; FYd1Q := 0; + FYd2I := 0; FYd2Q := 0; FYd3I := 0; FYd3Q := 0; FWsym := 0; FTimErrEMA := 0; FCarPh := 0; FCarFreq := 0; FSigAmp := 0; FQVar := 0; @@ -351,8 +437,14 @@ begin FRingPos := 0; FRingCnt := 0; FAcqPos := 0; FResidDf := 0; FResidPh := 0; FResidInc := 0; FCarProm := 0; FCarPresent := False; FFreqLocked := False; + FAcqArmed := True; FAcqCount := 0; FAcqSum := 0; // взводим грубый захват частоты FLockHold := 0; FPrevRre := 0; FPrevRim := 0; + // Manchester / дифф-декод + FChipPrevI := 0; FChipPrevQ := 0; FChipHave := False; FChipCount := 0; + FManMetric0 := 0; FManMetric1 := 0; FManPhase := 0; + FSymPrevI := 0; FSymPrevQ := 0; FSymHave := False; + if FFEC <> nil then FFEC.Reset; end; procedure TBeaconDecoder.Configure(SampleRateHz: Double); @@ -366,7 +458,7 @@ begin FFs := SampleRateHz; FDecim := Max(1, Round(FFs / BCN_TARGET_FS)); FFdec := FFs / FDecim; - FSps := FFdec / BCN_BAUD; + FSps := FFdec / BCN_CHIP_BAUD; // отсчётов на ЧИП (Manchester), таймер на 800 FNcoInc := 2.0 * Pi * FOffsetHz / FFs; @@ -472,7 +564,8 @@ procedure TBeaconDecoder.ProcessDecimated(const xi, xq: Double); // снимок констелляции защищён в GetScope собственным копированием полей-массивов. var k, idx: Integer; - yi, yq, frac, e, step, cr, sr, ri, ii: Double; + yi, yq, frac, e, cr, sr, ri, ii: Double; + ci, cq, eai, eaq, lai, laq, dri, drq: Double; // ML-TED центр/early/late/произв. begin // остаточный снос несущей (по squaring-FFT acquisition) — поверх главного NCO cr := Cos(FResidPh); sr := Sin(FResidPh); @@ -485,6 +578,17 @@ begin // Диагностика тракта (lock-независимо): RMS децимированного сигнала на входе RRC. FDecRMS := FDecRMS + 0.001 * (Sqrt(ri * ri + ii * ii) - FDecRMS); + // ВРЕМЕННЫЙ ДЕБАГ: дамп сырого baseband (после сноса частоты, до RRC) в файл + // для оффлайн-анализа тайминга чипов и Manchester-структуры. + if (FDbgLeft > 0) and FFreqLocked then + begin + if FDbgStream = nil then + FDbgStream := TFileStream.Create(GetEnvironmentVariable('HOME') + '/ewsdr_beacon_iq.f32', fmCreate); + DbgWrite2(ri, ii); + Dec(FDbgLeft); + if FDbgLeft = 0 then FreeAndNil(FDbgStream); + end; + // Буфер для грубой carrier-acquisition: кладём сигнал ПОСЛЕ главного NCO, но // ДО остаточного — тогда squaring-FFT меряет АБСОЛЮТНЫЙ остаток (не зависит от // FResidDf) → коррекция = прямое присвоение, без накопления и runaway. @@ -526,44 +630,39 @@ begin end; FSymMag := FRrcAmp; // диагностика силы сигнала (сырой уровень до AGC) - // линия задержки RRC-выхода для линейной интерполяции + // линия задержки RRC-выхода (4 отсчёта) для ML-TED: центр/early/late + задержка + // на 1 отсчёт, чтобы «late» (FYd0) был доступен в момент строба. + FYd3I := FYd2I; FYd3Q := FYd2Q; + FYd2I := FYd1I; FYd2Q := FYd1Q; FYd1I := FYd0I; FYd1Q := FYd0Q; FYd0I := yi; FYd0Q := yq; - // Gardner: полу-символьные стробы. FMuAcc убывает на 1/отсчёт; при переходе - // через 0 интерполируем и планируем следующий строб через ~FSps/2 отсчётов. + // Таймер ML signal×slope (как gr symbol_sync_cc, TED_SIGNAL_TIMES_SLOPE_ML): + // лочит ЦЕНТР чипа по максимуму |x|, ошибка e = Re{conj(x)·dx/dt}. Gardner на + // Manchester чип-рейте ВЫРОЖДАЛСЯ (всегда-переход в паре → S-кривая degenerate → + // таймер вращался, timErr~1.5, m0≈m1, frames=0). ML сходится стабильно (оффлайн на + // дампе 35c: timErr 0.25 vs Gardner 1.49 → sync 65/65). Один строб на чип. FMuAcc := FMuAcc - 1.0; if FMuAcc <= 0.0 then begin - frac := FMuAcc + 1.0; // позиция в (0..1] между Yd1 и Yd0 + frac := FMuAcc + 1.0; // позиция в (0..1] 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; + // центр (между FYd2 и FYd1), early (FYd3/FYd2), late (FYd1/FYd0) + ci := FYd2I + frac * (FYd1I - FYd2I); + cq := FYd2Q + frac * (FYd1Q - FYd2Q); + eai := FYd3I + frac * (FYd2I - FYd3I); + eaq := FYd3Q + frac * (FYd2Q - FYd3Q); + lai := FYd1I + frac * (FYd0I - FYd1I); + laq := FYd1Q + frac * (FYd0Q - FYd1Q); + // производная dx ≈ late − early; ошибка = Re{conj(центр)·dx} (вращ.-инвариантна) + dri := lai - eai; drq := laq - eaq; + e := ci * dri + cq * drq; + FTimErrEMA := FTimErrEMA + 0.01 * (Abs(e) - FTimErrEMA); + FWsym := FWsym + 0.0005 * e; // интегратор периода + if FWsym > 0.02 then FWsym := 0.02; + if FWsym < -0.02 then FWsym := -0.02; + ProcessSymbol(ci, cq); // чип-центр → Costas → Manchester + FMuAcc := FMuAcc + FSps * (1.0 + FWsym) + 0.005 * e; // период + проп. правка end; end; @@ -596,10 +695,16 @@ begin // Консервативно: сколько добавили в FResidInc, столько убрали из FCarFreq (без // рывка). Утечка медленная (TC~100 символов >> группозадержки RRC ~4 симв) — стабильно. // Итог: Costas держит лишь мелкий быстрый остаток, весь медленный снос — в FResidInc. - leak := 0.01 * FCarFreq; - FResidInc := FResidInc + leak / FSps; // рад/символ → рад/децим-отсчёт + // DEADBAND ~2.5 Гц: без него мелкий постоянный bias Costas (carHz~0.7) непрерывно + // интегрировался в FResidInc → resid полз ~5.5 Гц/с (спур, не дрейф). Стекаем только + // КРУПНЫЙ снос (ошибка наведения/дрейф LNB), мелкий остаток держит сам Costas. + if Abs(FCarFreq) > 0.02 then // 0.02 рад/чип ≈ 2.5 Гц @800 + begin + leak := 0.01 * FCarFreq; + FResidInc := FResidInc + leak / FSps; // рад/символ → рад/децим-отсчёт + FCarFreq := FCarFreq - leak; + end; 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; @@ -631,6 +736,71 @@ begin FRingQ[FRingPos] := di * na; FRingPos := FRingPos + 1; if FRingPos >= BCN_SCOPE_PTS then FRingPos := 0; if FRingCnt < BCN_SCOPE_PTS then Inc(FRingCnt); + + // скорректированный (Costas) чип → Manchester-свёртка + дифф-декод → FEC + ProcessChip(dr, di); +end; + +procedure TBeaconDecoder.ProcessChip(const ci, cq: Double); +// Вход — комплексный чип @ 800 (после таймера/Costas). Делает то же, что +// gr-satellites manchester_sync_cc + дифф-декод: +// 1) Manchester: символ-кандидат s = 0.5·(cPrev − c) для пары соседних чипов. +// Какая из 2 фаз спаривания верна (пары с чётного или нечётного чипа) — решаем +// по бо́льшей сумме |s| (EMA): на верной фазе чипы внутри бита противофазны → +// |s| максимально. Эмитим символ только когда чётность пары = выбранной фазе. +// 2) дифф-декод DBPSK: soft = −Re(s_n · conj(s_{n−1})) (знак −1 как в gr для Manchester). +// 3) soft 400/с → TBeaconFEC. +var + di_, dq_, mag, soft: Double; + parity: Integer; +begin + if FChipHave then + begin + di_ := 0.5 * (FChipPrevI - ci); + dq_ := 0.5 * (FChipPrevQ - cq); + mag := Sqrt(di_ * di_ + dq_ * dq_); + parity := (FChipCount - 1) and 1; // чётность первого чипа пары + if parity = 0 then + FManMetric0 := FManMetric0 + 0.01 * (mag - FManMetric0) + else + FManMetric1 := FManMetric1 + 0.01 * (mag - FManMetric1); + if FManMetric0 >= FManMetric1 then FManPhase := 0 else FManPhase := 1; + + if parity = FManPhase then + begin + // это символ данных (400/с) + if FSymHave then + begin + soft := -(di_ * FSymPrevI + dq_ * FSymPrevQ); // −Re(s_n·conj(s_{n−1})) + FFEC.PushSoftSymbol(soft); + end; + FSymPrevI := di_; FSymPrevQ := dq_; FSymHave := True; + end; + end; + FChipPrevI := ci; FChipPrevQ := cq; FChipHave := True; + Inc(FChipCount); +end; + +procedure TBeaconDecoder.DbgWrite2(const a, b: Double); +var fa, fb: Single; +begin + fa := a; fb := b; + FDbgStream.WriteBuffer(fa, 4); + FDbgStream.WriteBuffer(fb, 4); +end; + +procedure TBeaconDecoder.FECFrame(const Frame: TBeaconFrame; RSErrors: Integer); +// Колбэк из TBeaconFEC (DSP-поток). Сохраняем кадр под CS для UI. +begin + FLock.Enter; + try + FLastFrame := Frame; + FLastRSErr := RSErrors; + FHasFrame := True; + Inc(FFrames); + finally + FLock.Leave; + end; end; procedure TBeaconDecoder.GetScope(out S: TBeaconScope); @@ -652,8 +822,14 @@ begin S.SymMag := FSymMag; S.TimErr := FTimErrEMA; S.Wsym := FWsym; - S.CarFreqHz := FCarFreq * BCN_BAUD / (2.0 * Pi); // рад/символ → Гц + S.CarFreqHz := FCarFreq * BCN_CHIP_BAUD / (2.0 * Pi); // рад/чип → Гц S.LockHold := FLockHold; + S.ManPhase := FManPhase; + S.ManM0 := FManMetric0; + S.ManM1 := FManMetric1; + S.Frames := FFrames; + S.HasFrame := FHasFrame; + S.LastRSErr := FLastRSErr; // SNR ≈ (E|I|)^2 / E[Q^2] (мощность сигнала по I против шума по Q) if FQVar > 1e-12 then @@ -686,4 +862,19 @@ begin end; end; +function TBeaconDecoder.GetLastFrame(out Frame: TBeaconFrame; out RSErrors: Integer): Boolean; +begin + FLock.Enter; + try + Result := FHasFrame; + if Result then + begin + Frame := FLastFrame; + RSErrors := FLastRSErr; + end; + finally + FLock.Leave; + end; +end; + end. diff --git a/BeaconFEC.pas b/BeaconFEC.pas new file mode 100644 index 0000000..c7f1550 --- /dev/null +++ b/BeaconFEC.pas @@ -0,0 +1,341 @@ +unit BeaconFEC; + +{ + QO-100 центральный маяк — FEC-бэкенд (Milestone 2). + + Декодирует AO-40 FEC поверх потока мягких символов (400/с, после Manchester+ + дифф-декода фронтенда). Цепочка (ровно как gr-satellites ao40_fec_deframer): + + мягкие символы 400/с + → distributed sync (65 бит, шаг 80, окно 5200, порог 8, обе полярности) + → matrix deinterleave 80×65: out[i]=data[80*(i%65)+i/65], skip 65, take 5132 + → Viterbi r=1/2 k=7, полиномы [0x4F, -0x6D] (CCSDS), terminated → 2560 бит/320 байт + → CCSDS additive descramble (LFSR mask 0xA9, seed 0xFF, len 7, XOR по битам) + → 2× RS(255,223) укороч. pad=95 → (160,128), conventional basis, 2-way интерлив + → 256 байт кадра → OnFrame. + + Viterbi и RS — через libfec Карна (разделяемая libfec.so/.dll/.dylib, load-time + линковка как WDSP/IIO). libfec ставится отдельно на каждой ОС. +} + +{$IFDEF FPC}{$MODE Delphi}{$ENDIF} +{$PACKRECORDS C} + +interface + +uses + Classes, SysUtils; + +const + AO40_SYNC_LEN = 65; + AO40_SYNC_STEP = 80; + AO40_FRAME_SYMS = AO40_SYNC_LEN * AO40_SYNC_STEP; // 5200 + AO40_IL_ROWS = 80; + AO40_IL_COLS = 65; + AO40_VITERBI_SYM = 5132; // после skip 65 + AO40_DATA_BITS = 2560; // выход Viterbi (без 6 tail) + AO40_CONV_BYTES = AO40_DATA_BITS div 8; // 320 + AO40_RS_NROOTS = 32; + AO40_RS_PAD = 95; // 255-160 + AO40_RS_NN = 160; // укороченная длина + AO40_RS_KK = 128; + AO40_INTERLEAVE = 2; + AO40_FRAME_BYTES = AO40_RS_KK * AO40_INTERLEAVE; // 256 + AO40_SYNC_THRESH = 8; // допуск ошибок синхры + +type + TBeaconFrame = array[0..AO40_FRAME_BYTES-1] of Byte; + TBeaconFrameEvent = procedure(const Frame: TBeaconFrame; RSErrors: Integer) of object; + + TBeaconFEC = class + private + FSync: array[0..AO40_SYNC_LEN-1] of Byte; + FRing: array[0..AO40_FRAME_SYMS-1] of Single; + FRingPos: Integer; // позиция следующей записи = старейший элемент + FRingCnt: Integer; + FCooldown: Integer; // не перепроверять сразу после успешного кадра + FVp: Pointer; // viterbi27 instance + FFrames: Int64; + FOnFrame: TBeaconFrameEvent; + + function CheckSyncAt: Integer; // -1 нет; 0 норм. полярность; 1 инверсия + procedure DecodeCurrentFrame(Invert: Boolean); + procedure Descramble(var Buf: array of Byte; Count: Integer); + public + constructor Create; + destructor Destroy; override; + procedure Reset; + procedure PushSoftSymbol(const S: Single); + function SelfTest(out Msg: string): Boolean; + property Frames: Int64 read FFrames; + property OnFrame: TBeaconFrameEvent read FOnFrame write FOnFrame; + end; + +implementation + +// ---------------- libfec bindings (Karn) ---------------- +// Динамическая (load-time) линковка против разделяемой libfec — как WDSP/IIO. +// libfec ставится отдельно на каждой ОС (Linux: make install из исходников; +// Windows/macOS — отдельная сборка). НЕ статический {$LINKLIB}: на Arch системный +// libfec.a собран с LTO (slim-объекты без машинного кода) и не линкуется обычным ld. +const +{$IFDEF WINDOWS} + FEC_LIB = 'libfec.dll'; +{$ENDIF} +{$IFDEF LINUX} + FEC_LIB = 'libfec.so'; +{$ENDIF} +{$IFDEF DARWIN} + FEC_LIB = 'libfec.dylib'; +{$ENDIF} + +{$IFDEF UNIX}{$LINKLIB m}{$ENDIF} // libfec использует log() из libm + +function create_viterbi27(len: LongInt): Pointer; cdecl; external FEC_LIB; +function init_viterbi27(vp: Pointer; starting_state: LongInt): LongInt; cdecl; external FEC_LIB; +function update_viterbi27_blk(vp: Pointer; syms: PByte; npairs: LongInt): LongInt; cdecl; external FEC_LIB; +function chainback_viterbi27(vp: Pointer; data: PByte; nbits: LongWord; endstate: LongWord): LongInt; cdecl; external FEC_LIB; +procedure delete_viterbi27(vp: Pointer); cdecl; external FEC_LIB; + +function decode_rs_8(data: PByte; eras_pos: PLongInt; no_eras: LongInt; pad: LongInt): LongInt; cdecl; external FEC_LIB; +procedure encode_rs_8(data: PByte; parity: PByte; pad: LongInt); cdecl; external FEC_LIB; + +// ---------------- helpers ---------------- + +function ParityB(x: LongWord): Byte; inline; +// чётность числа единичных битов: 1=нечётно, 0=чётно +begin + x := x xor (x shr 16); + x := x xor (x shr 8); + x := x xor (x shr 4); + x := x xor (x shr 2); + x := x xor (x shr 1); + Result := x and 1; +end; + +const + // 65-битная распределённая синхра AO-40 (gr-satellites _syncword) + AO40_SYNCWORD: array[0..AO40_SYNC_LEN-1] of Byte = ( + 1,1,1,1,1,1,1,0,0,0,0,1,1,1,0,1,1,1,1,0,0,1,0,1,1,0,0,1,0,0,1,0, + 0,0,0,0,0,1,0,0,0,1,0,0,1,1,0,0,0,1,0,1,1,1,0,1,0,1,1,0,1,1,0,0,0); + +// ---------------- TBeaconFEC ---------------- + +constructor TBeaconFEC.Create; +var i: Integer; +begin + inherited Create; + for i := 0 to AO40_SYNC_LEN-1 do FSync[i] := AO40_SYNCWORD[i]; + FVp := create_viterbi27(AO40_DATA_BITS); + Reset; +end; + +destructor TBeaconFEC.Destroy; +begin + if FVp <> nil then delete_viterbi27(FVp); + inherited Destroy; +end; + +procedure TBeaconFEC.Reset; +var i: Integer; +begin + for i := 0 to AO40_FRAME_SYMS-1 do FRing[i] := 0; + FRingPos := 0; FRingCnt := 0; FCooldown := 0; +end; + +procedure TBeaconFEC.PushSoftSymbol(const S: Single); +var r: Integer; +begin + FRing[FRingPos] := S; + FRingPos := FRingPos + 1; + if FRingPos >= AO40_FRAME_SYMS then FRingPos := 0; + if FRingCnt < AO40_FRAME_SYMS then Inc(FRingCnt); + + if FCooldown > 0 then begin Dec(FCooldown); Exit; end; + if FRingCnt < AO40_FRAME_SYMS then Exit; + + r := CheckSyncAt; + if r >= 0 then + begin + DecodeCurrentFrame(r = 1); + FCooldown := AO40_FRAME_SYMS; // следующий кадр — через полный фрейм + end; +end; + +function TBeaconFEC.CheckSyncAt: Integer; +// Кандидат-кадр = текущее содержимое кольца (старейший→новейший), старт = FRingPos. +// Синхра на относительных позициях j*step. Возвращает полярность или -1. +var + j, idx, hard, matches: Integer; +begin + matches := 0; + for j := 0 to AO40_SYNC_LEN-1 do + begin + idx := FRingPos + j * AO40_SYNC_STEP; + if idx >= AO40_FRAME_SYMS then Dec(idx, AO40_FRAME_SYMS); + if FRing[idx] > 0.0 then hard := 1 else hard := 0; // gr-конвенция: +soft = «1» + if hard = FSync[j] then Inc(matches); + end; + if matches >= AO40_SYNC_LEN - AO40_SYNC_THRESH then Exit(0); // норм. полярность + if matches <= AO40_SYNC_THRESH then Exit(1); // инверсия + Result := -1; +end; + +procedure TBeaconFEC.Descramble(var Buf: array of Byte; Count: Integer); +// CCSDS additive descrambler = GNU Radio additive_scrambler_bb(0xA9, 0xFF, 7). +// Fibonacci LFSR, seed 0xFF, XOR с каждым битом (MSB-first), reset на старте кадра. +var + state: Byte; + i, b, outp, newbit, bit: Integer; + v: Byte; +begin + state := $FF; + for i := 0 to Count-1 do + begin + v := Buf[i]; + for b := 7 downto 0 do + begin + outp := state and 1; + newbit := ParityB(state and $A9); + state := (state shr 1) or (newbit shl 7); + bit := (v shr b) and 1; + bit := bit xor outp; + if bit <> 0 then v := v or (1 shl b) else v := v and not (1 shl b); + end; + Buf[i] := v; + end; +end; + +procedure TBeaconFEC.DecodeCurrentFrame(Invert: Boolean); +var + win: array[0..AO40_FRAME_SYMS-1] of Single; + deint: array[0..AO40_FRAME_SYMS-1] of Single; + syms: array[0..AO40_VITERBI_SYM-1] of Byte; + conv: array[0..AO40_CONV_BYTES-1] of Byte; + cw: array[0..AO40_RS_NN-1] of Byte; + frame: TBeaconFrame; + i, idx, k, j, sgn, totErr, res: Integer; + v: Double; +begin + // 1. снимок кольца в хронологическом порядке (+ инверсия полярности) + if Invert then sgn := -1 else sgn := 1; + for i := 0 to AO40_FRAME_SYMS-1 do + begin + idx := FRingPos + i; + if idx >= AO40_FRAME_SYMS then Dec(idx, AO40_FRAME_SYMS); + win[i] := sgn * FRing[idx]; + end; + + // 2. матричный деинтерливер 80×65, out[i]=data[80*(i%65)+i/65] + for i := 0 to AO40_FRAME_SYMS-1 do + deint[i] := win[AO40_IL_ROWS * (i mod AO40_IL_COLS) + (i div AO40_IL_COLS)]; + + // 3. skip 65 (синхра) → 5132 мягких символа → байты для libfec (255=«1»). + // РЕМАП КОНВЕНЦИИ Viterbi: AO-40/gr-satellites кодирует полиномами [0x4F, -0x6D] + // (cc_decoder), а libfec viterbi27 ЗАШИВАЕТ [0x6D, 0x4F] БЕЗ инверсии. Эмпирически + // (брут-форс против gr post_viterbi_reference, см. историю) точное соответствие: + // libfec[2p] = -soft[2p+1] (своп пары + инверсия первого символа) + // libfec[2p+1] = soft[2p] + // Без ремапа Viterbi выдаёт мусор → RS не корректируется. + for i := 0 to (AO40_VITERBI_SYM div 2) - 1 do + begin + v := 128.0 - deint[AO40_SYNC_LEN + 2*i + 1] * 100.0; // -soft[2p+1] + if v > 255.0 then v := 255.0 else if v < 0.0 then v := 0.0; + syms[2*i] := Round(v); + v := 128.0 + deint[AO40_SYNC_LEN + 2*i] * 100.0; // soft[2p] + if v > 255.0 then v := 255.0 else if v < 0.0 then v := 0.0; + syms[2*i+1] := Round(v); + end; + + // 4. Viterbi r=1/2 k=7 terminated → 2560 бит = 320 байт (MSB-first) + init_viterbi27(FVp, 0); + update_viterbi27_blk(FVp, @syms[0], (AO40_DATA_BITS + 6)); // 2566 пар + chainback_viterbi27(FVp, @conv[0], AO40_DATA_BITS, 0); + + // 5. CCSDS дескремблер + Descramble(conv, AO40_CONV_BYTES); + + // 6. RS: 2-way байт-интерлив, decode_rs_8 укороч. pad=95 + totErr := 0; + for j := 0 to AO40_INTERLEAVE-1 do + begin + for k := 0 to AO40_RS_NN-1 do + cw[k] := conv[j + k * AO40_INTERLEAVE]; + res := decode_rs_8(@cw[0], nil, 0, AO40_RS_PAD); + if res < 0 then Exit; // некорректируемо → кадр невалиден + Inc(totErr, res); + for k := 0 to AO40_RS_KK-1 do + frame[j + k * AO40_INTERLEAVE] := cw[k]; + end; + + Inc(FFrames); + if Assigned(FOnFrame) then FOnFrame(frame, totErr); +end; + +// ---------------- self-test ---------------- + +function TBeaconFEC.SelfTest(out Msg: string): Boolean; +// Проверяет линковку libfec и базовую корректность: RS encode/decode round-trip +// с внесёнными ошибками (укорочение pad=95) + Viterbi encode/decode round-trip. +var + data: array[0..AO40_RS_KK-1] of Byte; + par: array[0..AO40_RS_NROOTS-1] of Byte; + cw: array[0..AO40_RS_NN-1] of Byte; + i, res, encst, sym0, sym1, errs: Integer; + bits: array[0..AO40_DATA_BITS-1] of Byte; + vsyms: array[0..AO40_VITERBI_SYM-1] of Byte; + outb: array[0..AO40_CONV_BYTES-1] of Byte; + b: Integer; +begin + Result := False; + + // --- RS round-trip --- + for i := 0 to AO40_RS_KK-1 do data[i] := (i * 37 + 11) and $FF; + encode_rs_8(@data[0], @par[0], AO40_RS_PAD); + for i := 0 to AO40_RS_KK-1 do cw[i] := data[i]; + for i := 0 to AO40_RS_NROOTS-1 do cw[AO40_RS_KK + i] := par[i]; + // вносим 16 байтовых ошибок (= предел исправления для nroots=32) + for i := 0 to 15 do cw[i * 9] := cw[i * 9] xor $A5; + res := decode_rs_8(@cw[0], nil, 0, AO40_RS_PAD); + if res < 0 then begin Msg := 'RS decode FAILED (link/params?)'; Exit; end; + for i := 0 to AO40_RS_KK-1 do + if cw[i] <> data[i] then begin Msg := Format('RS mismatch at %d', [i]); Exit; end; + + // --- Viterbi round-trip --- + // libfec viterbi27 зашивает полиномы V27POLYA=$6D, V27POLYB=$4F (CCSDS) БЕЗ + // инверсии и без API смены. Энкодер строго как у Карна (vtest27.c): + // sr=(sr shl 1)|bit; sym0=parity(sr & $6D); sym1=parity(sr & $4F). + // Мягкий «1» = 255, «0» = 0. + 6 хвостовых нулей (terminated). + Randomize; + for i := 0 to AO40_DATA_BITS-1 do bits[i] := Random(2); + encst := 0; + for i := 0 to AO40_DATA_BITS + 6 - 1 do + begin + if i < AO40_DATA_BITS then b := bits[i] else b := 0; + encst := ((encst shl 1) or b) and $7F; + sym0 := ParityB(encst and $6D); + sym1 := ParityB(encst and $4F); + vsyms[2*i] := sym0 * 255; + vsyms[2*i+1] := sym1 * 255; + end; + init_viterbi27(FVp, 0); + update_viterbi27_blk(FVp, @vsyms[0], AO40_DATA_BITS + 6); + chainback_viterbi27(FVp, @outb[0], AO40_DATA_BITS, 0); + errs := 0; + for i := 0 to AO40_DATA_BITS-1 do + begin + b := (outb[i div 8] shr (7 - (i mod 8))) and 1; // MSB-first + if b <> bits[i] then Inc(errs); + end; + if errs <> 0 then + begin + Msg := Format('Viterbi round-trip: %d/%d bit errors (convention mismatch)', + [errs, AO40_DATA_BITS]); + Exit; + end; + + Msg := Format('OK: RS corrected 16 errs; Viterbi 0/%d bit errors', [AO40_DATA_BITS]); + Result := True; +end; + +end. diff --git a/BeaconScopeForm.pas b/BeaconScopeForm.pas index b2a703f..df40fc8 100644 --- a/BeaconScopeForm.pas +++ b/BeaconScopeForm.pas @@ -44,7 +44,7 @@ begin Caption := 'QO-100 Beacon — Constellation'; BorderStyle := bsSizeable; Width := 360; - Height := 510; + Height := 560; Position := poScreenCenter; FBox := TPaintBox.Create(Self); @@ -160,6 +160,12 @@ begin C.TextOut(14, py + 90, 'INVERT ' + IfThen(FCtl.BeaconDecodeInvert, 'ON', 'OFF')); C.TextOut(14, py + 108, Format('RATE Fs %.0f Fdec %.0f sps %.1f', [S.FsHz, S.FdecHz, S.Sps])); + + // ----- FEC (AO-40) ----- + if S.HasFrame then C.Font.Color := FTheme.MeterOn else C.Font.Color := FTheme.TextDim; + C.TextOut(14, py + 132, Format('FRAMES %d (RS err %d)', [S.Frames, S.LastRSErr])); + C.Font.Color := FTheme.TextDim; + C.TextOut(14, py + 150, Format('MANCHESTER phase %d', [S.ManPhase])); end; end. diff --git a/RadioController.pas b/RadioController.pas index 97c52e9..0e1bf5e 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -1261,7 +1261,8 @@ begin BeaconLog(Format( 'STATE decHz=%.1f center=%.1f off=%.1f | Fs=%.0f Fdec=%.0f sps=%.1f | ' + 'resid=%.1f prom=%.1f snr=%.1f baud=%.1f decRMS=%.5f symMag=%.5f ' + - 'timErr=%.5f wsym=%.4f carHz=%.1f hold=%d | CAR=%d SYM=%d invert=%d lockOn=%d locked=%d', + 'timErr=%.5f wsym=%.4f carHz=%.1f hold=%d | CAR=%d SYM=%d invert=%d lockOn=%d locked=%d ' + + '| manPh=%d m0=%.5f m1=%.5f frames=%d rsErr=%d', [FBeaconDecHz, FCenterFreq, FBeaconLogScope.OffsetHz, FBeaconLogScope.FsHz, FBeaconLogScope.FdecHz, FBeaconLogScope.Sps, FBeaconLogScope.ResidHz, FBeaconLogScope.CarProm, FBeaconLogScope.SNRdB, @@ -1269,7 +1270,9 @@ begin FBeaconLogScope.TimErr, FBeaconLogScope.Wsym, FBeaconLogScope.CarFreqHz, FBeaconLogScope.LockHold, Ord(FBeaconLogScope.CarrierLock), Ord(FBeaconLogScope.SymbolLock), - Ord(FBeaconDecInvert), Ord(FBeaconLockOn), Ord(FBeaconLocked)])); + Ord(FBeaconDecInvert), Ord(FBeaconLockOn), Ord(FBeaconLocked), + FBeaconLogScope.ManPhase, FBeaconLogScope.ManM0, FBeaconLogScope.ManM1, + FBeaconLogScope.Frames, FBeaconLogScope.LastRSErr])); end; end; diff --git a/ewsdr.lpi b/ewsdr.lpi index 8d00486..0ac3d73 100644 --- a/ewsdr.lpi +++ b/ewsdr.lpi @@ -135,6 +135,10 @@ + + + + diff --git a/fectest.lpr b/fectest.lpr new file mode 100644 index 0000000..89225e3 --- /dev/null +++ b/fectest.lpr @@ -0,0 +1,18 @@ +program fectest; +{$MODE Delphi} +uses SysUtils, BeaconFEC; +var + fec: TBeaconFEC; + msg: string; + ok: Boolean; +begin + fec := TBeaconFEC.Create; + try + ok := fec.SelfTest(msg); + WriteLn('SelfTest: ', msg); + if ok then WriteLn('==> PASS') else WriteLn('==> FAIL'); + finally + fec.Free; + end; + if not ok then Halt(1); +end. diff --git a/fectest2.lpr b/fectest2.lpr new file mode 100644 index 0000000..df82c28 --- /dev/null +++ b/fectest2.lpr @@ -0,0 +1,104 @@ +program fectest2; +{$MODE Delphi} +{ + Валидация TBeaconFEC против эталонных тест-векторов gr-satellites + (python/components/deframers/qa_ao40_fec_deframer_*). Гоним поток мягких + символов через PushSoftSymbol и сверяем выданный 256-байтовый кадр с эталоном. + Решает вопрос конвенции Viterbi (libfec [0x6D,0x4F] vs AO-40 [0x4F,-0x6D]). +} +uses SysUtils, Classes, BeaconFEC; + +const + GR = '/home/vladimir/Документы/source/gr-satellites/python/components/deframers/'; + SymPath = GR + 'qa_ao40_fec_deframer_symbols.f32'; + + // qa_ao40_fec_deframer.py: финальный кадр (256 байт) + FrameRef: array[0..255] of Byte = ( + 137, 0, 0, 0, 0, 0, 0, 0, 0, 31, 204, 0, 206, 2, 209, 0, 0, + 7, 8, 9, 9, 0, 0, 5, 1, 1, 0, 64, 19, 47, 200, 242, 92, + 143, 52, 35, 243, 186, 11, 93, 98, 116, 81, 199, 234, 250, + 105, 74, 154, 159, 0, 9, 239, 160, 31, 244, 167, 234, 74, + 198, 143, 17, 64, 17, 30, 16, 247, 1, 62, 32, 100, 0, 215, + 139, 248, 215, 148, 200, 147, 168, 42, 218, 82, 166, 14, 88, + 14, 200, 15, 78, 1, 29, 32, 90, 0, 219, 148, 168, 170, 138, + 152, 19, 172, 105, 10, 166, 168, 16, 230, 16, 146, 15, 184, + 1, 80, 32, 100, 0, 215, 150, 168, 193, 139, 72, 37, 171, 169, + 202, 206, 157, 16, 118, 15, 201, 16, 85, 1, 58, 32, 90, 0, 215, + 151, 41, 8, 140, 72, 79, 169, 106, 90, 242, 164, 16, 57, 15, + 123, 15, 134, 1, 73, 32, 100, 0, 215, 148, 8, 208, 138, 216, + 42, 173, 106, 90, 126, 180, 14, 83, 14, 155, 14, 183, 1, 9, 32, + 90, 0, 219, 153, 168, 242, 143, 232, 56, 175, 170, 138, 194, + 158, 14, 222, 15, 72, 14, 49, 1, 49, 32, 90, 0, 206, 155, 200, + 255, 136, 104, 27, 178, 106, 90, 202, 167, 15, 195, 14, 116, 14, + 88, 1, 52, 32, 90, 0, 215, 155, 57, 27, 151, 184, 197, 176, 43, + 58, 214, 181, 1, 107, 0, 106, 2, 158, 0, 3, 32, 19, 0); + +type + TFrameChecker = class + Got: Boolean; + Match: Boolean; + NErr: Integer; + RSErr: Integer; + procedure OnFrame(const Frame: TBeaconFrame; RSErrors: Integer); + end; + +procedure TFrameChecker.OnFrame(const Frame: TBeaconFrame; RSErrors: Integer); +var i, e: Integer; +begin + if Got then Exit; // берём первый кадр + Got := True; + RSErr := RSErrors; + e := 0; + for i := 0 to 255 do + if Frame[i] <> FrameRef[i] then Inc(e); + NErr := e; + Match := (e = 0); +end; + +var + fec: TBeaconFEC; + chk: TFrameChecker; + fs: TFileStream; + n, i: Integer; + buf: array of Single; +begin + if not FileExists(SymPath) then + begin + WriteLn('symbols file not found: ', SymPath); Halt(2); + end; + fs := TFileStream.Create(SymPath, fmOpenRead); + try + n := fs.Size div 4; + SetLength(buf, n); + fs.ReadBuffer(buf[0], n * 4); + finally + fs.Free; + end; + WriteLn('loaded ', n, ' soft symbols'); + + chk := TFrameChecker.Create; + fec := TBeaconFEC.Create; + try + fec.OnFrame := chk.OnFrame; + for i := 0 to n - 1 do + fec.PushSoftSymbol(buf[i]); + + WriteLn('frames decoded: ', fec.Frames); + if not chk.Got then + begin + WriteLn('==> FAIL: no frame decoded (sync not found / RS uncorrectable)'); + Halt(1); + end; + WriteLn(Format('frame: %d/256 byte mismatches, RS errors=%d', [chk.NErr, chk.RSErr])); + if chk.Match then + WriteLn('==> PASS: frame matches gr-satellites reference') + else + begin + WriteLn('==> FAIL: frame does not match reference'); + Halt(1); + end; + finally + fec.Free; + chk.Free; + end; +end. From 8e4106b82262cdf1f783ec3c0eeb37315c661763 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Mon, 22 Jun 2026 10:30:36 +0300 Subject: [PATCH 3/4] feat: QO-100 beacon bulletin text display (Phase C) Decode and show the AO-40 frame payload as text in the beacon scope window. - RadioController.GetBeaconFrame exposes the latest 256-byte frame. - BeaconScopeForm: poll the frame counter; on each new frame, parse the payload (drop trailing 2-byte CRC, printable ASCII, 64-char lines, as in gr-satellites qo100.parse) and append to a scrolling read-only memo. - Window widened/heightened; constellation size capped to leave room. Frame format verified offline against a real on-air capture: the decoder produces the live QO-100 AMSAT bulletin text (RS errors=0). Co-Authored-By: Claude Opus 4.8 --- BeaconScopeForm.pas | 83 +++++++++++++++++++++++++++++++++++++++------ RadioController.pas | 8 ++++- 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/BeaconScopeForm.pas b/BeaconScopeForm.pas index df40fc8..a5e3bf1 100644 --- a/BeaconScopeForm.pas +++ b/BeaconScopeForm.pas @@ -16,18 +16,22 @@ interface uses Classes, SysUtils, Math, StrUtils, - Forms, Controls, Graphics, ExtCtrls, - AppTheme, BeaconDecoder, RadioController; + Forms, Controls, Graphics, ExtCtrls, StdCtrls, + AppTheme, BeaconDecoder, BeaconFEC, RadioController; type TBeaconScopeForm = class(TForm) private - FCtl: TRadioController; - FBox: TPaintBox; - FTimer: TTimer; - FTheme: TAppTheme; + FCtl: TRadioController; + FBox: TPaintBox; + FMemo: TMemo; // декодированный текст бюллетеня (AO-40 кадры) + FTimer: TTimer; + FTheme: TAppTheme; + FLastFrames: Int64; // счётчик кадров на прошлом тике (детект нового) procedure DoTick(Sender: TObject); procedure DoPaint(Sender: TObject); + procedure PollFrame; + function FrameToText(const Frame: TBeaconFrame): string; public constructor CreateWith(AOwner: TComponent; ACtl: TRadioController); procedure ApplyTheme(const T: TAppTheme); @@ -41,12 +45,25 @@ begin FCtl := ACtl; FTheme := DarkTheme; - Caption := 'QO-100 Beacon — Constellation'; + Caption := 'QO-100 Beacon — Constellation + Bulletin'; BorderStyle := bsSizeable; - Width := 360; - Height := 560; + Width := 600; + Height := 680; Position := poScreenCenter; + // Текст бюллетеня (декодированные AO-40 кадры) — снизу, моноширинный, ReadOnly. + FMemo := TMemo.Create(Self); + FMemo.Parent := Self; + FMemo.Align := alBottom; + FMemo.Height := 230; + FMemo.ReadOnly := True; + FMemo.ScrollBars := ssVertical; + FMemo.WordWrap := False; + FMemo.Font.Name := 'Courier New'; + FMemo.Font.Size := 9; + FMemo.Color := FTheme.BG; + FMemo.Font.Color := FTheme.Text; + FBox := TPaintBox.Create(Self); FBox.Parent := Self; FBox.Align := alClient; @@ -61,12 +78,57 @@ end; procedure TBeaconScopeForm.ApplyTheme(const T: TAppTheme); begin FTheme := T; + if FMemo <> nil then + begin + FMemo.Color := FTheme.BG; + FMemo.Font.Color := FTheme.Text; + end; if FBox <> nil then FBox.Invalidate; end; procedure TBeaconScopeForm.DoTick(Sender: TObject); begin - if (FBox <> nil) and Visible then FBox.Invalidate; + if not Visible then Exit; + PollFrame; + if FBox <> nil then FBox.Invalidate; +end; + +function TBeaconScopeForm.FrameToText(const Frame: TBeaconFrame): string; +// AO-40 кадр QO-100 = ASCII-текст (space-padded), последние 2 байта = CRC-16. +// Как gr-satellites qo100.parse: отбрасываем CRC, печатные ASCII, строки по 64. +var + i, b: Integer; + line: string; +begin + Result := ''; + i := 0; + while i <= 253 do + begin + line := ''; + for b := i to Min(i + 63, 253) do + if (Frame[b] >= 32) and (Frame[b] < 127) then line := line + Chr(Frame[b]) + else line := line + ' '; + Result := Result + TrimRight(line) + LineEnding; + Inc(i, 64); + end; +end; + +procedure TBeaconScopeForm.PollFrame; +// Раз/тик: если декодер выдал НОВЫЙ кадр (S.Frames вырос) — печатаем бюллетень. +var + S: TBeaconScope; + Frame: TBeaconFrame; + rs: Integer; +begin + if FCtl = nil then Exit; + if not FCtl.GetBeaconScope(S) then Exit; + if S.Frames <= FLastFrames then Exit; + FLastFrames := S.Frames; + if not FCtl.GetBeaconFrame(Frame, rs) then Exit; + FMemo.Append(Format('--- frame %d (RS err %d) ---', [S.Frames, rs])); + FMemo.Append(FrameToText(Frame)); + while FMemo.Lines.Count > 400 do FMemo.Lines.Delete(0); // ограничиваем рост + FMemo.SelStart := Length(FMemo.Text); // автоскролл вниз end; procedure TBeaconScopeForm.DoPaint(Sender: TObject); @@ -89,6 +151,7 @@ begin // область констелляции — квадрат сверху, текст снизу r := (Min(W, H - 70) - 20) div 2; if r < 20 then r := 20; + if r > 110 then r := 110; // кап: оставляем место метрикам над memo cx := W div 2; cy := 10 + r; diff --git a/RadioController.pas b/RadioController.pas index 0e1bf5e..4faba7f 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -49,7 +49,7 @@ interface uses Classes, SysUtils, Math, HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings, - WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, + WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, BeaconFEC, Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore; type @@ -503,6 +503,7 @@ type procedure SetBeaconDecodeInvert(On_: Boolean); // зеркальный baseband вкл/выкл function BeaconDecodeInvert: Boolean; function GetBeaconScope(out S: TBeaconScope): Boolean; + function GetBeaconFrame(out Frame: TBeaconFrame; out RSErrors: Integer): Boolean; // Отображение / приём procedure SetCTun(On_: Boolean); @@ -1473,6 +1474,11 @@ begin else FillChar(S, SizeOf(S), 0); end; +function TRadioController.GetBeaconFrame(out Frame: TBeaconFrame; out RSErrors: Integer): Boolean; +begin + Result := Assigned(FBeaconDec) and FBeaconDec.GetLastFrame(Frame, RSErrors); +end; + procedure TRadioController.ApplyModeFilter; var Lo, Hi, Half: Integer; From 37eb8065d6ca3c30c1c2179961015ee793071870 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Mon, 22 Jun 2026 10:45:52 +0300 Subject: [PATCH 4/4] chore: remove QO-100 beacon decoder debug instrumentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After successful on-air validation, drop the temporary diagnostics: - IQ baseband dump to ~/ewsdr_beacon_iq.f32 (FDbgStream/DbgWrite2). - BeaconLog file logger (~/ewsdr_beacon.log) and per-second STATE / AIM lines. - Dead Gardner timing fields (FMidI/FMidQ/FHalf/FPrevI/FPrevQ), superseded by the ML signal×slope TED. Co-Authored-By: Claude Opus 4.8 --- BeaconDecoder.pas | 36 ++------------------------------ RadioController.pas | 51 --------------------------------------------- 2 files changed, 2 insertions(+), 85 deletions(-) diff --git a/BeaconDecoder.pas b/BeaconDecoder.pas index 3524e06..658d778 100644 --- a/BeaconDecoder.pas +++ b/BeaconDecoder.pas @@ -96,11 +96,8 @@ type FHistPos: Integer; FNtaps: Integer; - // --- Gardner timing --- - FMuAcc: Double; // обратный отсчёт до следующего полу-символьного строба - FHalf: Boolean; // текущий строб = середина (true) или центр символа - FMidI, FMidQ: Double; // отсчёт середины (для Gardner) - FPrevI, FPrevQ: Double; // прошлый центр символа + // --- ML signal×slope symbol timing --- + FMuAcc: Double; // обратный отсчёт до следующего строба чипа FYd0I, FYd0Q: Double; // линия задержки RRC-выхода (новейший) FYd1I, FYd1Q: Double; // предыдущий FYd2I, FYd2Q: Double; // −2 (для ML-TED центр/early/late + задержка «late») @@ -171,10 +168,6 @@ type FLastFrame: TBeaconFrame; FLastRSErr: Integer; - // --- ВРЕМЕННЫЙ ДЕБАГ: дамп сырого baseband 9600 Гц (I,Q float32) --- - FDbgStream: TFileStream; - FDbgLeft: Integer; - // --- констелляция (кольцо) --- FRingI, FRingQ: array[0..BCN_SCOPE_PTS-1] of Single; FRingPos: Integer; @@ -187,7 +180,6 @@ type procedure ProcessDecimated(const xi, xq: Double); procedure ProcessSymbol(const zi, zq: Double); procedure ProcessChip(const ci, cq: Double); // Manchester + дифф-декод → FEC - procedure DbgWrite2(const a, b: Double); // ВРЕМЕННЫЙ дамп I,Q float32 procedure FECFrame(const Frame: TBeaconFrame; RSErrors: Integer); procedure ResetDSP; public @@ -217,8 +209,6 @@ begin FLock := TCriticalSection.Create; FFEC := TBeaconFEC.Create; FFEC.OnFrame := FECFrame; - FDbgStream := nil; - FDbgLeft := 336000; // ВРЕМЕННЫЙ: ~35 c @ 9600 (2-3 кадра маяка) для оффлайн FFs := 0; FEnabled := False; FConstScale := 1.0; @@ -227,7 +217,6 @@ end; destructor TBeaconDecoder.Destroy; begin - if FDbgStream <> nil then FreeAndNil(FDbgStream); FFEC.Free; FLock.Free; inherited Destroy; @@ -426,8 +415,6 @@ begin 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; FYd2I := 0; FYd2Q := 0; FYd3I := 0; FYd3Q := 0; FWsym := 0; FTimErrEMA := 0; @@ -578,17 +565,6 @@ begin // Диагностика тракта (lock-независимо): RMS децимированного сигнала на входе RRC. FDecRMS := FDecRMS + 0.001 * (Sqrt(ri * ri + ii * ii) - FDecRMS); - // ВРЕМЕННЫЙ ДЕБАГ: дамп сырого baseband (после сноса частоты, до RRC) в файл - // для оффлайн-анализа тайминга чипов и Manchester-структуры. - if (FDbgLeft > 0) and FFreqLocked then - begin - if FDbgStream = nil then - FDbgStream := TFileStream.Create(GetEnvironmentVariable('HOME') + '/ewsdr_beacon_iq.f32', fmCreate); - DbgWrite2(ri, ii); - Dec(FDbgLeft); - if FDbgLeft = 0 then FreeAndNil(FDbgStream); - end; - // Буфер для грубой carrier-acquisition: кладём сигнал ПОСЛЕ главного NCO, но // ДО остаточного — тогда squaring-FFT меряет АБСОЛЮТНЫЙ остаток (не зависит от // FResidDf) → коррекция = прямое присвоение, без накопления и runaway. @@ -781,14 +757,6 @@ begin Inc(FChipCount); end; -procedure TBeaconDecoder.DbgWrite2(const a, b: Double); -var fa, fb: Single; -begin - fa := a; fb := b; - FDbgStream.WriteBuffer(fa, 4); - FDbgStream.WriteBuffer(fb, 4); -end; - procedure TBeaconDecoder.FECFrame(const Frame: TBeaconFrame; RSErrors: Integer); // Колбэк из TBeaconFEC (DSP-поток). Сохраняем кадр под CS для UI. begin diff --git a/RadioController.pas b/RadioController.pas index 4faba7f..4a83cf7 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -202,8 +202,6 @@ type FBeaconDecHz: Double; // АБСОЛЮТНАЯ display-частота наведения декодера // (узкий фильтр, ставится кликом); 0 = центр/DC FBeaconDecInvert: Boolean; // зеркальный baseband (QO-100/LNB) — деф. True - FBeaconLogTick: Integer; // троттл периодического лога состояния декодера - FBeaconLogScope: TBeaconScope; // буфер снимка для лога (не аллоцируем в контуре) // ---- Аудио / TX ---- FVolume: Integer; @@ -485,7 +483,6 @@ type // QO-100 beacon lock — стабилизация дрейфа LNB по опорному маяку. procedure SetBeaconLock(On_: Boolean); procedure ServiceBeaconLock; // одна итерация контура (MeterTimer @10 Гц) - procedure BeaconLog(const Msg: string); // диагностика декодера → HOME/ewsdr_beacon.log procedure SetBeaconRefHz(Hz: Double); // опорная частота маяка (downlink) procedure BeaconSeedAtHz(DisplayHz: Double); // пользователь указал маяк кликом function BeaconState: TBeaconState; @@ -1252,29 +1249,6 @@ begin FBeaconDec.SetOffsetHz(FBeaconDecHz - FCenterFreq) // наведённый узкий фильтр else FBeaconDec.SetOffsetHz(0); // иначе — центр/DC - - // Периодический снимок состояния (раз в ~1с при @10Гц-таймере) в лог-файл. - Inc(FBeaconLogTick); - if FBeaconLogTick >= 10 then - begin - FBeaconLogTick := 0; - GetBeaconScope(FBeaconLogScope); - BeaconLog(Format( - 'STATE decHz=%.1f center=%.1f off=%.1f | Fs=%.0f Fdec=%.0f sps=%.1f | ' + - 'resid=%.1f prom=%.1f snr=%.1f baud=%.1f decRMS=%.5f symMag=%.5f ' + - 'timErr=%.5f wsym=%.4f carHz=%.1f hold=%d | CAR=%d SYM=%d invert=%d lockOn=%d locked=%d ' + - '| manPh=%d m0=%.5f m1=%.5f frames=%d rsErr=%d', - [FBeaconDecHz, FCenterFreq, FBeaconLogScope.OffsetHz, - FBeaconLogScope.FsHz, FBeaconLogScope.FdecHz, FBeaconLogScope.Sps, - FBeaconLogScope.ResidHz, FBeaconLogScope.CarProm, FBeaconLogScope.SNRdB, - FBeaconLogScope.SymRate, FBeaconLogScope.DecRMS, FBeaconLogScope.SymMag, - FBeaconLogScope.TimErr, FBeaconLogScope.Wsym, FBeaconLogScope.CarFreqHz, - FBeaconLogScope.LockHold, - Ord(FBeaconLogScope.CarrierLock), Ord(FBeaconLogScope.SymbolLock), - Ord(FBeaconDecInvert), Ord(FBeaconLockOn), Ord(FBeaconLocked), - FBeaconLogScope.ManPhase, FBeaconLogScope.ManM0, FBeaconLogScope.ManM1, - FBeaconLogScope.Frames, FBeaconLogScope.LastRSErr])); - end; end; if not FBeaconLockOn then Exit; @@ -1417,34 +1391,9 @@ begin FBeaconDec.SetOffsetHz(FBeaconDecHz - FCenterFreq); FBeaconDec.Reseed; end; - BeaconLog(Format('AIM click: DisplayHz=%.1f CenterFreq=%.1f -> offset=%.1f invert=%d span=%.0f', - [DisplayHz, FCenterFreq, FBeaconDecHz - FCenterFreq, Ord(FBeaconDecInvert), FSpanHz])); Changed(rfBeaconLock); end; -procedure TRadioController.BeaconLog(const Msg: string); -// Диагностика декодера маяка: дописываем строку в HOME/ewsdr_beacon.log. -// Дёшево и кросс-платформенно; файл потом скидывается на разбор. -var - f: TextFile; - path, home: string; -begin - home := GetEnvironmentVariable('HOME'); - if home = '' then home := GetTempDir; - path := IncludeTrailingPathDelimiter(home) + 'ewsdr_beacon.log'; - try - AssignFile(f, path); - if FileExists(path) then Append(f) else Rewrite(f); - try - WriteLn(f, FormatDateTime('hh:nn:ss.zzz', Now), ' ', Msg); - finally - CloseFile(f); - end; - except - // лог не должен ронять контур — глотаем ошибки I/O - end; -end; - function TRadioController.BeaconDecodeEnabled: Boolean; begin Result := FBeaconDecOn;