Merge feature/qo100-beacon-decode: QO-100 central beacon decoder (BPSK 400 / AO-40 FEC → bulletin text)

M1: stable BPSK demod. M2: AO-40 FEC decode end-to-end (libfec). Phase C:
decode 256-byte frame as ASCII bulletin (CRC), display in BeaconScopeForm.
This commit is contained in:
2026-06-22 11:26:38 +03:00
11 changed files with 1787 additions and 11 deletions
+848
View File
@@ -0,0 +1,848 @@
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, BeaconFEC;
const
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 в символах (полу-)
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; // холдовер-счётчик лока
ManPhase: Integer; // выбранная фаза Manchester (0/1)
ManM0, ManM1: Single; // EMA энергии двух фаз (диагностика)
Frames: Int64; // декодировано кадров AO-40 FEC
HasFrame: Boolean; // есть хотя бы один кадр
LastRSErr: Integer; // ошибок RS в последнем кадре
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;
// --- ML signal×slope symbol timing ---
FMuAcc: 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;
// --- 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; // линия несущей видна
// --- one-shot захват частоты (squaring-FFT → FResidInc) ---
// На чип-рейте 800 бод снос ~400 Гц = π/чип: decision-directed Costas его НЕ
// тянет, а несущая в π/чип вырождает Manchester (chips одинаковы → 0.5(c0c1)≈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; // холдовер-счётчик лока (анти-мерцание)
// --- 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 (нормировка петель)
// --- 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;
// --- констелляция (кольцо) ---
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 ProcessChip(const ci, cq: Double); // Manchester + дифф-декод → FEC
procedure FECFrame(const Frame: TBeaconFrame; RSErrors: Integer);
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);
function GetLastFrame(out Frame: TBeaconFrame; out RSErrors: Integer): Boolean;
end;
implementation
constructor TBeaconDecoder.Create;
begin
inherited Create;
FLock := TCriticalSection.Create;
FFEC := TBeaconFEC.Create;
FFEC.OnFrame := FECFrame;
FFs := 0;
FEnabled := False;
FConstScale := 1.0;
Configure(192000);
end;
destructor TBeaconDecoder.Destroy;
begin
FFEC.Free;
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), возводим в квадрат
// (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, pkBin: Integer;
mr, mi, re, im, mag, pk, sumMag, meanMag, fHz, fcHz, avgHz: 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; 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 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;
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;
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;
FDecRMS := 0; FSymMag := 0; FRrcAmp := 0;
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);
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_CHIP_BAUD; // отсчётов на ЧИП (Manchester), таймер на 800
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, 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);
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-выхода (4 отсчёта) для ML-TED: центр/early/late + задержка
// на 1 отсчёт, чтобы «late» (FYd0) был доступен в момент строба.
FYd3I := FYd2I; FYd3Q := FYd2Q;
FYd2I := FYd1I; FYd2Q := FYd1Q;
FYd1I := FYd0I; FYd1Q := FYd0Q;
FYd0I := yi; FYd0Q := yq;
// Таймер 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]
if frac < 0 then frac := 0;
// центр (между 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;
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.
// 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); // для лога/диагностики
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);
// скорректированный (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_{n1})) (знак 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_{n1}))
FFEC.PushSoftSymbol(soft);
end;
FSymPrevI := di_; FSymPrevQ := dq_; FSymHave := True;
end;
end;
FChipPrevI := ci; FChipPrevQ := cq; FChipHave := True;
Inc(FChipCount);
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);
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_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
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;
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.
+341
View File
@@ -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.
+234
View File
@@ -0,0 +1,234 @@
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, StdCtrls,
AppTheme, BeaconDecoder, BeaconFEC, RadioController;
type
TBeaconScopeForm = class(TForm)
private
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);
end;
implementation
constructor TBeaconScopeForm.CreateWith(AOwner: TComponent; ACtl: TRadioController);
begin
inherited CreateNew(AOwner);
FCtl := ACtl;
FTheme := DarkTheme;
Caption := 'QO-100 Beacon — Constellation + Bulletin';
BorderStyle := bsSizeable;
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;
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 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 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);
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;
if r > 110 then r := 110; // кап: оставляем место метрикам над memo
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]));
// ----- 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.
+49 -1
View File
@@ -39,7 +39,7 @@ uses
WinFirewall, WinFirewall,
BoardUtils, WisdomBuilder, UISync, BoardUtils, WisdomBuilder, UISync,
FlatEdit, FMRepeater, FlatEdit, FMRepeater,
ChannelStore, ChannelsForm, ChannelStore, ChannelsForm, BeaconScopeForm,
PowerInhibit, PowerInhibit,
DeviceStore, DeviceStore,
RadioBackend, PlutoBackend, RadioBackend, PlutoBackend,
@@ -135,6 +135,7 @@ type
FSpecDragX0: Integer; // X при нажатии FSpecDragX0: Integer; // X при нажатии
FSpecDragFreq: Double; // FCenterFreq при нажатии FSpecDragFreq: Double; // FCenterFreq при нажатии
FBeaconArming: Boolean; // ждём клик по маяку (после включения BEACON) FBeaconArming: Boolean; // ждём клик по маяку (после включения BEACON)
FBeaconDecArming: Boolean; // ждём клик: навести узкий фильтр декодера на маяк
FSpectrumDirty: Boolean; // таймер должен перерисовать спектр/водопад FSpectrumDirty: Boolean; // таймер должен перерисовать спектр/водопад
// TX DUC-очередь (FDUCPending*) переехала в TRadioController (OnTXIQ). // TX DUC-очередь (FDUCPending*) переехала в TRadioController (OnTXIQ).
FSMeterPeak: Double; // верхняя граница светлой зоны FSMeterPeak: Double; // верхняя граница светлой зоны
@@ -287,6 +288,7 @@ type
BtnChannels: TFlatButton; BtnChannels: TFlatButton;
FChannelsDropDown: TFlatDropDown; FChannelsDropDown: TFlatDropDown;
FChannelsForm: TObject; // TChannelsForm (cast при использовании) FChannelsForm: TObject; // TChannelsForm (cast при использовании)
FBeaconScopeForm: TBeaconScopeForm; // окно констелляции маяка (ПКМ по BEACON)
// ---- Right panel ---- // ---- Right panel ----
PanelRight: TPanel; PanelRight: TPanel;
@@ -384,6 +386,8 @@ type
procedure BtnFilterClick(Sender: TObject); procedure BtnFilterClick(Sender: TObject);
procedure BtnCTunClick(Sender: TObject); procedure BtnCTunClick(Sender: TObject);
procedure BtnBeaconClick(Sender: TObject); procedure BtnBeaconClick(Sender: TObject);
procedure BtnBeaconMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure UpdateBeaconButton; procedure UpdateBeaconButton;
procedure BtnDUPClick(Sender: TObject); procedure BtnDUPClick(Sender: TObject);
procedure ApplyDUP(Active: Boolean); procedure ApplyDUP(Active: Boolean);
@@ -795,6 +799,7 @@ begin
FController.FTXSpecGridStep := 10.0; FController.FTXSpecGridStep := 10.0;
FSettingsForm := nil; FSettingsForm := nil;
FChannelsForm := nil; FChannelsForm := nil;
FBeaconScopeForm := nil;
FillChar(FController.FDevMAC, SizeOf(FController.FDevMAC), 0); FillChar(FController.FDevMAC, SizeOf(FController.FDevMAC), 0);
// Инициализируем кэш диапазонов умолчаниями // Инициализируем кэш диапазонов умолчаниями
@@ -1752,6 +1757,7 @@ begin
LeftPanelButtonWidth(LEFT_W, 3, 2), LeftPanelButtonWidth(LEFT_W, 3, 2),
BTN_H, BtnBeaconClick); BTN_H, BtnBeaconClick);
BtnBeacon.Visible := False; BtnBeacon.Visible := False;
BtnBeacon.OnMouseDown := BtnBeaconMouseDown; // ПКМ → декодер + окно констелляции
StyleButton(BtnBeacon, False); StyleButton(BtnBeacon, False);
MakeLbl(PanelRXBlock, 'VOL', 4, 50); MakeLbl(PanelRXBlock, 'VOL', 4, 50);
@@ -3272,6 +3278,9 @@ function TMainForm.BeaconStatusText: string;
// Компактный статус QO-100 beacon lock для поля 7 статус-бара (Pluto). Courier 8pt, // Компактный статус QO-100 beacon lock для поля 7 статус-бара (Pluto). Courier 8pt,
// поле ~200px → держим ≤ ~24 символов. // поле ~200px → держим ≤ ~24 символов.
begin 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 not FController.BeaconLockEnabled then Exit('BCN: off');
if FBeaconArming then Exit('BCN: click beacon'); if FBeaconArming then Exit('BCN: click beacon');
case FController.BeaconState of case FController.BeaconState of
@@ -3988,6 +3997,28 @@ begin
UpdateBeaconButton; UpdateBeaconButton;
end; 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; procedure TMainForm.UpdateBeaconButton;
// Beacon-маркеры + кнопка BEACON. Кнопка видима только на Pluto в трансвертере // Beacon-маркеры + кнопка BEACON. Кнопка видима только на Pluto в трансвертере
// (см. rfXvtr) — это ПРОСТО кнопка: подпись 'BEACON', подсветка = лок включён. // (см. rfXvtr) — это ПРОСТО кнопка: подпись 'BEACON', подсветка = лок включён.
@@ -3996,9 +4027,15 @@ procedure TMainForm.UpdateBeaconButton;
begin begin
// Маркеры на спектре: опорная частота (зелёная) + отслеживаемый центроид (оранж). // Маркеры на спектре: опорная частота (зелёная) + отслеживаемый центроид (оранж).
if FSpecView <> nil then if FSpecView <> nil then
begin
FSpecView.SetBeaconMarkers(FController.BeaconLockEnabled, FSpecView.SetBeaconMarkers(FController.BeaconLockEnabled,
FController.BeaconRefHz, FController.BeaconRefHz,
FController.BeaconTrackedFreqHz); FController.BeaconTrackedFreqHz);
// Узкий фильтр-маркер декодера (±450 Гц ≈ полоса BPSK-маяка).
FSpecView.SetBeaconDecMarker(
FController.BeaconDecodeEnabled and (FController.BeaconDecodeFreqHz > 0),
FController.BeaconDecodeFreqHz, 450.0);
end;
UpdateStatusField7; // моментальный апдейт статус-бара (не ждём MeterTimer) UpdateStatusField7; // моментальный апдейт статус-бара (не ждём MeterTimer)
if BtnBeacon = nil then Exit; if BtnBeacon = nil then Exit;
BtnBeacon.Caption := 'BEACON'; BtnBeacon.Caption := 'BEACON';
@@ -4186,6 +4223,17 @@ begin
if Button = mbLeft then if Button = mbLeft then
begin 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). // Наведение beacon-lock: ЛКМ указывает на маяк (вместо перестройки VFO).
if FBeaconArming and (PbSpectrum.Width > 0) then if FBeaconArming and (PbSpectrum.Width > 0) then
begin begin
+120 -2
View File
@@ -49,7 +49,7 @@ interface
uses uses
Classes, SysUtils, Math, Classes, SysUtils, Math,
HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings, HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings,
WDSPEngine, AudioOutput, AudioInput, WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, BeaconFEC,
Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore; Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore;
type type
@@ -196,6 +196,12 @@ type
FBeaconLocked: Boolean; // пик уверенно виден FBeaconLocked: Boolean; // пик уверенно виден
FBeaconCorrCooldown: Integer; // тиков паузы после коррекции (осадка пайплайна) FBeaconCorrCooldown: Integer; // тиков паузы после коррекции (осадка пайплайна)
FBeaconSaveCooldown: Integer; // троттлинг записи LOError на диск (тиков) 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
// ---- Аудио / TX ---- // ---- Аудио / TX ----
FVolume: Integer; FVolume: Integer;
@@ -486,6 +492,16 @@ type
function BeaconRefHz: Double; // опорная частота маяка (для маркера) function BeaconRefHz: Double; // опорная частота маяка (для маркера)
function BeaconTrackedFreqHz: Double;// отслеживаемая display-частота маяка (центроид) 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;
function GetBeaconFrame(out Frame: TBeaconFrame; out RSErrors: Integer): Boolean;
// Отображение / приём // Отображение / приём
procedure SetCTun(On_: Boolean); procedure SetCTun(On_: Boolean);
procedure SetDuplex(On_: Boolean); procedure SetDuplex(On_: Boolean);
@@ -596,6 +612,16 @@ begin
FBeaconLockHz := 0.0; FBeaconLockHz := 0.0;
FBeaconLockOn := False; 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 вызывается позже (после показа // Audio out/in — объекты создаём сейчас, Open вызывается позже (после показа
// формы / при подключении устройства). // формы / при подключении устройства).
FAudioOut := TAudioOutput.Create(48000); FAudioOut := TAudioOutput.Create(48000);
@@ -612,7 +638,13 @@ begin
end; end;
if Assigned(FAudioOut) then begin FAudioOut.Close; FreeAndNil(FAudioOut); end; if Assigned(FAudioOut) then begin FAudioOut.Close; FreeAndNil(FAudioOut); end;
if Assigned(FAudioIn) then begin FAudioIn.Close; FreeAndNil(FAudioIn); 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; end;
destructor TRadioController.Destroy; destructor TRadioController.Destroy;
@@ -1200,6 +1232,25 @@ procedure TRadioController.ServiceBeaconLock;
// лок не теряется; во время settle позицию не двигаем (FFT в транзиенте). // лок не теряется; во время settle позицию не двигаем (FFT в транзиенте).
var win, pk, prom, cand, e, dLock: Double; var win, pk, prom, cand, e, dLock: Double;
begin begin
// Beacon-декодер: ЦЕЛЬ — маяк на DC IQ-тапа (= центр RX-спектра, тот же сырой
// DDC-IQ). По умолчанию offset=0: пользователь ставит маяк в центр спектра
// (CTUN/перестройка), squaring-FFT acquisition доводит остаток. ТОЛЬКО когда
// beacon-lock реально захвачен (зелёный) — доверяем его оценке и сносим
// FBeaconTrackHzFCenterFreq (авто-центровка). Развязано от сырого 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
end;
if not FBeaconLockOn then Exit; if not FBeaconLockOn then Exit;
if FCurrentXvtr < 0 then Exit; // только в трансвертер-режиме if FCurrentXvtr < 0 then Exit; // только в трансвертер-режиме
if FBeaconSaveCooldown > 0 then Dec(FBeaconSaveCooldown); if FBeaconSaveCooldown > 0 then Dec(FBeaconSaveCooldown);
@@ -1310,6 +1361,73 @@ begin
Result := FBeaconTrackHz; Result := FBeaconTrackHz;
end; 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;
Changed(rfBeaconLock);
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;
function TRadioController.GetBeaconFrame(out Frame: TBeaconFrame; out RSErrors: Integer): Boolean;
begin
Result := Assigned(FBeaconDec) and FBeaconDec.GetLastFrame(Frame, RSErrors);
end;
procedure TRadioController.ApplyModeFilter; procedure TRadioController.ApplyModeFilter;
var var
Lo, Hi, Half: Integer; Lo, Hi, Half: Integer;
+30 -3
View File
@@ -77,6 +77,10 @@ type
FBeaconMarkActive: Boolean; FBeaconMarkActive: Boolean;
FBeaconRefFreq: Double; // опорная частота маяка (где ДОЛЖЕН быть) FBeaconRefFreq: Double; // опорная частота маяка (где ДОЛЖЕН быть)
FBeaconTrkFreq: Double; // отслеживаемый центроид маяка сейчас FBeaconTrkFreq: Double; // отслеживаемый центроид маяка сейчас
// узкий фильтр-маркер декодера (наведение пользователем)
FBeaconDecActive: Boolean;
FBeaconDecFreq: Double;
FBeaconDecHalf: Double; // полуширина фильтра, Гц
// ── Spectrum buffer ─────────────────────────────────────────────────────── // ── Spectrum buffer ───────────────────────────────────────────────────────
FSpectrumBuf: array[0..1023] of Single; FSpectrumBuf: array[0..1023] of Single;
FSpectrumBufCount: Integer; FSpectrumBufCount: Integer;
@@ -190,6 +194,7 @@ type
// QO-100 beacon-маркеры: опорная частота (зелёная) + позиция трекера (оранж). // QO-100 beacon-маркеры: опорная частота (зелёная) + позиция трекера (оранж).
// Спектр перерисовывается каждый кадр, поэтому отдельный invalidate не нужен. // Спектр перерисовывается каждый кадр, поэтому отдельный invalidate не нужен.
procedure SetBeaconMarkers(Active: Boolean; RefHz, TrkHz: Double); procedure SetBeaconMarkers(Active: Boolean; RefHz, TrkHz: Double);
procedure SetBeaconDecMarker(Active: Boolean; FreqHz, HalfHz: Double);
// ── S-метр ──────────────────────────────────────────────────────────────── // ── S-метр ────────────────────────────────────────────────────────────────
property LastSMeter: Double read GetLastSMeter write SetLastSMeter; property LastSMeter: Double read GetLastSMeter write SetLastSMeter;
@@ -501,6 +506,13 @@ begin
FBeaconTrkFreq := TrkHz; FBeaconTrkFreq := TrkHz;
end; 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); 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.Pen.Color := Col; C.Pen.Width := 1; C.Pen.Style := Style;
C.MoveTo(x, 0); C.LineTo(x, H); C.MoveTo(x, 0); C.LineTo(x, H);
end; end;
var
xc, x1, x2: Integer;
begin begin
Vline(FBeaconRefFreq, clLime, psDot); // где маяк ДОЛЖЕН быть if FBeaconMarkActive then
Vline(FBeaconTrkFreq, TColor($000AA5FF), psSolid); // отслеживаемый центроид (оранж) 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; C.Pen.Style := psSolid;
end; end;
@@ -975,7 +1002,7 @@ begin
end; end;
if FMarkerActive then DrawMarkerLine(C, W, H); 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); DrawADCOverloadOverlay(C, W, H);
if Assigned(FSampleRateOverlay) then if Assigned(FSampleRateOverlay) then
FSampleRateOverlay.DrawOverlay(FSpectrumBitmap, C, W, H); FSampleRateOverlay.DrawOverlay(FSpectrumBitmap, C, W, H);
+12 -3
View File
@@ -668,8 +668,17 @@ procedure TSpectrumViewOpenGL.DrawBeaconMarkersGL(W, H: Integer);
DrawLine(x, 0, x, H, Col, 1, False); DrawLine(x, 0, x, H, Col, 1, False);
end; end;
begin begin
Vline(FBeaconRefFreq, clLime); // где маяк ДОЛЖЕН быть if FBeaconMarkActive then
Vline(FBeaconTrkFreq, TColor($000AA5FF)); // отслеживаемый центроид (оранж) 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; end;
procedure TSpectrumViewOpenGL.DrawADCOverlay(W, H: Integer); procedure TSpectrumViewOpenGL.DrawADCOverlay(W, H: Integer);
@@ -797,7 +806,7 @@ begin
DrawAGCLines(W, H, DBmax, InvRange); DrawAGCLines(W, H, DBmax, InvRange);
DrawSpectrumCurve(W, H, DBmax, InvRange); DrawSpectrumCurve(W, H, DBmax, InvRange);
DrawMarker(W, H); DrawMarker(W, H);
if FBeaconMarkActive then DrawBeaconMarkersGL(W, H); if FBeaconMarkActive or FBeaconDecActive then DrawBeaconMarkersGL(W, H);
DrawADCOverlay(W, H); DrawADCOverlay(W, H);
DrawCachedOverlays(W, H); DrawCachedOverlays(W, H);
+16
View File
@@ -25,6 +25,7 @@ interface
uses uses
Classes, SysUtils, Math, SyncObjs, Classes, SysUtils, Math, SyncObjs,
BeaconDecoder,
WDSP; WDSP;
const const
@@ -292,6 +293,7 @@ type
FMuted: Boolean; FMuted: Boolean;
FVolume: Double; FVolume: Double;
FLastError: string; FLastError: string;
FBeaconDec: TBeaconDecoder; // не владеет; тап маяка в PushIQItemToDSP
function ModeToWDSP(Mode: Integer): Integer; function ModeToWDSP(Mode: Integer): Integer;
procedure ApplyDefaultFilter; procedure ApplyDefaultFilter;
@@ -331,6 +333,9 @@ type
procedure Close; procedure Close;
procedure ChangeSampleRate(NewRate: Integer); procedure ChangeSampleRate(NewRate: Integer);
// QO-100 beacon-декодер: тап RX-IQ для демодуляции маяка (не владеет).
procedure SetBeaconDecoder(D: TBeaconDecoder);
// Подача 24-bit big-endian IQ из DDC пакета // Подача 24-bit big-endian IQ из DDC пакета
// Buf — массив байт, DataOffset — смещение до IQ данных внутри Buf // Buf — массив байт, DataOffset — смещение до IQ данных внутри Buf
procedure PushDDCPacket(const Buf: array of Byte; procedure PushDDCPacket(const Buf: array of Byte;
@@ -1131,6 +1136,15 @@ begin
// Переинициализируем WDSP с новым rate // Переинициализируем WDSP с новым rate
Open; 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; end;
procedure TWDSPEngine.PushDDCPacket(const Buf: array of Byte; procedure TWDSPEngine.PushDDCPacket(const Buf: array of Byte;
@@ -1213,6 +1227,8 @@ begin
FeedDisplaySample(IR * SCALE, QR * SCALE); FeedDisplaySample(IR * SCALE, QR * SCALE);
if FBeaconDec <> nil then FBeaconDec.Feed(IR * SCALE, QR * SCALE);
FRXAccI[FRXAccPos] := IR * SCALE; FRXAccI[FRXAccPos] := IR * SCALE;
FRXAccQ[FRXAccPos] := QR * SCALE; FRXAccQ[FRXAccPos] := QR * SCALE;
Inc(FRXAccPos); Inc(FRXAccPos);
+15 -2
View File
@@ -17,9 +17,9 @@
<UseVersionInfo Value="True"/> <UseVersionInfo Value="True"/>
<AutoIncrementBuild Value="True"/> <AutoIncrementBuild Value="True"/>
<MinorVersionNr Value="9"/> <MinorVersionNr Value="9"/>
<BuildNr Value="213"/> <BuildNr Value="214"/>
</VersionInfo> </VersionInfo>
<MacroValues Count="25"> <MacroValues Count="26">
<Macro1 Name="LCLWidgetType" Value="qt6"/> <Macro1 Name="LCLWidgetType" Value="qt6"/>
<Macro2 Name="LCLWidgetType" Value="qt6"/> <Macro2 Name="LCLWidgetType" Value="qt6"/>
<Macro3 Name="LCLWidgetType" Value="qt6"/> <Macro3 Name="LCLWidgetType" Value="qt6"/>
@@ -45,6 +45,7 @@
<Macro23 Name="LCLWidgetType" Value="qt6"/> <Macro23 Name="LCLWidgetType" Value="qt6"/>
<Macro24 Name="LCLWidgetType" Value="qt6"/> <Macro24 Name="LCLWidgetType" Value="qt6"/>
<Macro25 Name="LCLWidgetType" Value="qt6"/> <Macro25 Name="LCLWidgetType" Value="qt6"/>
<Macro26 Name="LCLWidgetType" Value="qt6"/>
</MacroValues> </MacroValues>
<BuildModes> <BuildModes>
<Item Name="Debug" Default="True"/> <Item Name="Debug" Default="True"/>
@@ -130,6 +131,18 @@
<Filename Value="WDSPEngine.pas"/> <Filename Value="WDSPEngine.pas"/>
<IsPartOfProject Value="True"/> <IsPartOfProject Value="True"/>
</Unit> </Unit>
<Unit>
<Filename Value="BeaconDecoder.pas"/>
<IsPartOfProject Value="True"/>
</Unit>
<Unit>
<Filename Value="BeaconFEC.pas"/>
<IsPartOfProject Value="True"/>
</Unit>
<Unit>
<Filename Value="BeaconScopeForm.pas"/>
<IsPartOfProject Value="True"/>
</Unit>
<Unit> <Unit>
<Filename Value="VfoOverlay.pas"/> <Filename Value="VfoOverlay.pas"/>
<IsPartOfProject Value="True"/> <IsPartOfProject Value="True"/>
+18
View File
@@ -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.
+104
View File
@@ -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.