Files
ewsdr/BeaconLock.pas
T
ew8bakandClaude Opus 4.8 42610b304e feat: QO-100 beacon lock — stabilise LNB drift to NB transponder beacon
Click the beacon on the spectrum; the controller tracks it and continuously
trims the transverter LO so the whole downlink stays put as the LNB drifts.

- BeaconLock.pas: TBeaconTracker scaffold (narrowband NCO/FLL) — kept but no
  longer in the control path (FLL diverged on the suppressed-carrier BPSK
  beacon + Pluto DC offset). Measurement now uses the proven display-FFT path.
- RadioController: FBeaconLockHz folded into XvtrTranslate (on top of LOError).
  MeasureBeaconFFT finds the beacon by POWER-WEIGHTED CENTROID of the lobe
  (BPSK has a suppressed carrier → broad symmetric ~2.4 kHz lobe, not a line;
  a peak-finder jitters, the centroid sits on the carrier). Track position in
  ABSOLUTE display Hz (FBeaconTrackHz) so scrolling the waterfall/centre never
  moves the search window off the beacon. Closed loop: e = tracked - ref,
  d(e)/d(LockHz) = -1 -> LockHz += gain*e; settle cooldown after each correction
  (display-FFT lags the LO retune -> avoids overshoot); deadband 75 Hz +
  centroid/prominence smoothing + lock hysteresis for a steady lock.
  Correction applied only after the user clicks (manual seed), so a crowded
  band can't pull a neighbour to the reference.
- WDSPEngine: SetBeaconTracker + RX-IQ tap (tracker idle now, ~1 check/sample).
- SpectrumView + GL: beacon markers — green = reference (10489.750), orange =
  tracked centroid.
- MainForm: BEACON button (RX block, XVTR-only). Flow: BEACON -> "CLICK BCN"
  -> click beacon -> "L<corr> /<prom>". ServiceBeaconLock driven by MeterTimer.

Reference defaults to the middle BPSK beacon 10489.750. Tested on-air against
Es'hail-2: locks and holds, survives waterfall drag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 11:04:14 +03:00

340 lines
14 KiB
ObjectPascal
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
unit BeaconLock;
{$IFDEF FPC}
{$MODE Delphi}
{$ENDIF}
{
QO-100 Beacon Lock — выделенный узкополосный трекер несущей опорного маяка.
Задача: LNB-гетеродин (≈9750 МГц) дрейфует с температурой на единицы–десятки
кГц → весь downlink плывёт. Маяк (нижний CW 10489.500 МГц) имеет ИЗВЕСТНУЮ
частоту. Меряя, где он реально стоит, получаем ошибку LO и компенсируем её.
Двухступенчатая схема:
1. ГРУБЫЙ захват — снаружи (по display-FFT) даёт начальную оценку смещения
маяка в пределах ~±300 Гц; передаётся через Seed.
2. ТОЧНЫЙ лок (этот модуль) — комплексный NCO сносит ожидаемую частоту маяка
к DC, boxcar-дециматор сужает полосу до ~2 кГц (большой processing gain),
затем FLL по фазовому дискриминатору доводит NCO ровно на несущую.
Лок-метрика — SNR через carrier-EMA / noise-EMA на децимированном потоке.
Точность: единицы Гц при достаточном SNR (узкая полоса + длинное усреднение
фазы), что и есть «железный» лок (в духе SDR Console).
Потоки: Feed() зовётся из DSP-потока (дёшево, на сэмпл). Результаты читаются
из управляющего потока через снапшот под критической секцией.
}
interface
uses
SysUtils, Math, SyncObjs;
type
TBeaconState = (bsIdle, // выключен / нет данных
bsSearch, // идёт захват (SNR ниже порога)
bsLock); // несущая захвачена и стабильна
TBeaconTracker = class
private
FLock: TCriticalSection;
// --- конфигурация ---
FSampleRate: Double; // вход (Hz)
FDecim: Integer; // коэффициент децимации (Fs/FDecim ≈ FDecimRate)
FDecimRate: Double; // частота децимированного потока (Hz)
FLoopGain: Double; // гейн FLL (0..1, малый)
FLockSNRdB: Double; // порог объявления лока
FTargetHz: Double; // ожидаемое смещение маяка от центра (Hz)
// --- состояние NCO/децимации (DSP-поток) ---
FNcoFreq: Double; // текущая частота NCO (быстрая, дёрганая)
FNcoSmooth: Double; // сглаженная оценка смещения маяка (для коррекции/маркера)
FSeedHz: Double; // якорь clamp: NCO не уходит дальше ±CLAMP от сида
FPhCos, FPhSin: Double; // текущий фазор NCO (для сноса к DC)
FStepCos, FStepSin: Double; // инкремент фазора за сэмпл (Δ = −2π·FNcoFreq/Fs)
FNcoNorm: Integer; // счётчик до ренормализации фазора
FAccI, FAccQ: Double; // аккумулятор boxcar-дециматора
FAccN: Integer;
FPrevI, FPrevQ: Double; // предыдущий децимированный сэмпл (для дискриминатора)
FHasPrev: Boolean;
FMeanI, FMeanQ: Double; // EMA комплексного децим. потока → амплитуда несущей
FNoisePow: Double; // EMA мощности шума |z-mean|^2
FResAvg: Double; // EMA остаточной частоты (для контроля стабильности)
FActive: Boolean; // трекинг включён
// --- снапшот результата (под FLock) ---
FResMeasHz: Double; // измеренное смещение маяка = FNcoFreq на момент снапшота
FResSNRdB: Double;
FResState: TBeaconState;
procedure RecomputeStep; // пересчёт шага фазора по FNcoFreq (фазу НЕ трогает)
procedure ResetPhasor; // сброс фазора в 0 + пересчёт шага
procedure ProcessDecimated(const zi, zq: Double);
procedure Snapshot;
public
constructor Create;
destructor Destroy; override;
// Настройка под текущий sample rate. DecimRateHz — целевая полоса
// децимированного потока (по умолчанию ~2 кГц: ±1 кГц захват FLL).
procedure Configure(ASampleRate: Double; ADecimRateHz: Double = 4000.0);
// Ожидаемое смещение маяка от DDC-центра (Hz). Меняется при перестройке.
procedure SetTarget(AOffsetHz: Double);
// Грубая оценка от FFT — переинициализирует NCO (захват/переакквизиция).
procedure Seed(AOffsetHz: Double);
procedure Start;
procedure Stop;
procedure Reset;
// DSP-поток: один комплексный сэмпл @ FSampleRate.
procedure Feed(const I, Q: Double); inline;
// Управляющий поток: снапшот результата.
function MeasuredOffsetHz: Double; // измеренное смещение маяка от центра
function ErrorHz: Double; // MeasuredOffset Target (ошибка LO)
function SNRdB: Double;
function State: TBeaconState;
function Locked: Boolean;
property Active: Boolean read FActive;
end;
implementation
const
NCO_RENORM = 2048; // ренормализация фазора каждые N сэмплов
EMA_MEAN = 0.002; // постоянная EMA несущей (медленная — узкая полоса)
EMA_NOISE = 0.002;
EMA_RES = 0.01;
EMA_NCO = 0.001; // сглаживание оценки частоты (TC ~0.25 с @4кГц)
CLAMP_HZ = 1500.0; // NCO держим в ±CLAMP от сида (без runaway на край)
NOISE_FLOOR = 1e-30; // ТОЛЬКО защита от деления на ноль (не маскировать сигнал!)
SNR_MAX = 40.0;
constructor TBeaconTracker.Create;
begin
inherited Create;
FLock := TCriticalSection.Create;
FSampleRate := 1536000.0;
FDecimRate := 4000.0; // квадрат-домен → захват ±FDecimRate/4 = ±1 кГц
FLoopGain := 0.02;
FLockSNRdB := 3.0;
Reset;
Configure(FSampleRate, FDecimRate);
end;
destructor TBeaconTracker.Destroy;
begin
FLock.Free;
inherited Destroy;
end;
procedure TBeaconTracker.Configure(ASampleRate, ADecimRateHz: Double);
begin
FLock.Enter;
try
if ASampleRate < 1.0 then ASampleRate := 1536000.0;
if ADecimRateHz < 100.0 then ADecimRateHz := 4000.0;
FSampleRate := ASampleRate;
FDecim := Max(1, Round(ASampleRate / ADecimRateHz));
FDecimRate := ASampleRate / FDecim;
// сброс рабочих аккумуляторов (NCO частоту сохраняем — это оценка маяка)
FAccI := 0; FAccQ := 0; FAccN := 0;
FHasPrev := False;
FMeanI := 0; FMeanQ := 0; FNoisePow := 0; FResAvg := 0;
ResetPhasor;
finally
FLock.Leave;
end;
end;
procedure TBeaconTracker.RecomputeStep;
// Пересчёт шага фазора под текущую FNcoFreq (снос на FNcoFreq, маяк → DC).
// Фазу НЕ трогаем — смена частоты непрерывна по фазе.
var w: Double;
begin
w := -2.0 * Pi * FNcoFreq / FSampleRate;
FStepCos := Cos(w);
FStepSin := Sin(w);
end;
procedure TBeaconTracker.ResetPhasor;
begin
FPhCos := 1.0;
FPhSin := 0.0;
FNcoNorm := 0;
RecomputeStep;
end;
procedure TBeaconTracker.SetTarget(AOffsetHz: Double);
begin
FLock.Enter;
try
FTargetHz := AOffsetHz;
finally
FLock.Leave;
end;
end;
procedure TBeaconTracker.Seed(AOffsetHz: Double);
begin
FLock.Enter;
try
FNcoFreq := AOffsetHz;
FNcoSmooth := AOffsetHz;
FSeedHz := AOffsetHz; // якорь clamp
ResetPhasor;
FHasPrev := False;
FMeanI := 0; FMeanQ := 0; FNoisePow := 0; FResAvg := 0;
finally
FLock.Leave;
end;
end;
procedure TBeaconTracker.Start;
begin
FActive := True;
end;
procedure TBeaconTracker.Stop;
begin
FActive := False;
end;
procedure TBeaconTracker.Reset;
begin
FLock.Enter;
try
FNcoFreq := 0; FNcoSmooth := 0; FSeedHz := 0; FTargetHz := 0;
FAccI := 0; FAccQ := 0; FAccN := 0;
FHasPrev := False;
FMeanI := 0; FMeanQ := 0; FNoisePow := 0; FResAvg := 0;
FResMeasHz := 0; FResSNRdB := -99.0; FResState := bsIdle;
FActive := False;
ResetPhasor;
finally
FLock.Leave;
end;
end;
procedure TBeaconTracker.Feed(const I, Q: Double);
var
mi, mq, nc, ns: Double;
begin
if not FActive then Exit;
// снос к DC: z * conj(nco) (nco = exp(+jωt), шаг отрицательной частоты)
mi := I * FPhCos - Q * FPhSin;
mq := I * FPhSin + Q * FPhCos;
// продвинуть фазор: phase *= step
nc := FPhCos * FStepCos - FPhSin * FStepSin;
ns := FPhCos * FStepSin + FPhSin * FStepCos;
FPhCos := nc; FPhSin := ns;
Inc(FNcoNorm);
if FNcoNorm >= NCO_RENORM then
begin
// ренормализация (борьба с накоплением ошибки округления)
nc := 1.5 - 0.5 * (FPhCos * FPhCos + FPhSin * FPhSin);
FPhCos := FPhCos * nc;
FPhSin := FPhSin * nc;
FNcoNorm := 0;
end;
// boxcar-дециматор
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 TBeaconTracker.ProcessDecimated(const zi, zq: Double);
// Работаем в КВАДРАТЕ сигнала w = z²: для BPSK (средний маяк) это снимает
// модуляцию ±180° (b²=1), оставляя чистую несущую на 2× частоте; для чистого
// CW (нижний/верхний) — тоже корректно. Дискриминатор по w даёт 2·остаток,
// делим на 2. Захват ±FDecimRate/4. carrier/noise-EMA считаем в w-домене —
// для BPSK среднее по z = 0, а по w = z² несущая когерентна.
var
wi, wq, pr, pi, phase, res2, resHz, di, dq, np: Double;
begin
wi := zi * zi - zq * zq; // Re(z²)
wq := 2.0 * zi * zq; // Im(z²)
if FHasPrev then
begin
// фазовый дискриминатор по квадрату: arg(w[n] * conj(w[n-1]))
pr := wi * FPrevI + wq * FPrevQ;
pi := wq * FPrevI - wi * FPrevQ;
phase := ArcTan2(pi, pr);
res2 := phase * FDecimRate / (2.0 * Pi); // = 2 · остаточная частота
resHz := 0.5 * res2;
// FLL: подтянуть NCO на остаточную частоту, но удержать в окне ±CLAMP_HZ
// вокруг сида — иначе на BPSK/шуме петля убегает на край спектра.
FNcoFreq := FNcoFreq + FLoopGain * resHz;
if FNcoFreq > FSeedHz + CLAMP_HZ then FNcoFreq := FSeedHz + CLAMP_HZ;
if FNcoFreq < FSeedHz - CLAMP_HZ then FNcoFreq := FSeedHz - CLAMP_HZ;
FResAvg := FResAvg + EMA_RES * (Abs(resHz) - FResAvg);
FNcoSmooth := FNcoSmooth + EMA_NCO * (FNcoFreq - FNcoSmooth); // стаб. оценка
RecomputeStep; // обновить шаг фазора под новую FNcoFreq (фаза непрерывна)
end;
FPrevI := wi; FPrevQ := wq; FHasPrev := True; // FPrev* = предыдущий w
// carrier-EMA в w-домене (несущая² у DC) и шум как отклонение от среднего
FMeanI := FMeanI + EMA_MEAN * (wi - FMeanI);
FMeanQ := FMeanQ + EMA_MEAN * (wq - FMeanQ);
di := wi - FMeanI; dq := wq - FMeanQ;
np := di * di + dq * dq;
FNoisePow := FNoisePow + EMA_NOISE * (np - FNoisePow);
Snapshot;
end;
procedure TBeaconTracker.Snapshot;
var
sigPow, snr: Double;
st: TBeaconState;
begin
sigPow := FMeanI * FMeanI + FMeanQ * FMeanQ;
snr := 10.0 * Log10((sigPow + 1e-30) / (FNoisePow + NOISE_FLOOR));
if snr > SNR_MAX then snr := SNR_MAX;
// Лок по когерентности несущей (SNR в квадрат-домене). Гейт по остаточной
// частоте убран — на BPSK он давал ложный «не-лок».
if snr >= FLockSNRdB then
st := bsLock
else
st := bsSearch;
FLock.Enter;
try
FResMeasHz := FNcoSmooth; // сглаженная оценка → стабильная коррекция/маркер
FResSNRdB := snr;
FResState := st;
finally
FLock.Leave;
end;
end;
function TBeaconTracker.MeasuredOffsetHz: Double;
begin
FLock.Enter; try Result := FResMeasHz; finally FLock.Leave; end;
end;
function TBeaconTracker.ErrorHz: Double;
begin
FLock.Enter; try Result := FResMeasHz - FTargetHz; finally FLock.Leave; end;
end;
function TBeaconTracker.SNRdB: Double;
begin
FLock.Enter; try Result := FResSNRdB; finally FLock.Leave; end;
end;
function TBeaconTracker.State: TBeaconState;
begin
if not FActive then Exit(bsIdle);
FLock.Enter; try Result := FResState; finally FLock.Leave; end;
end;
function TBeaconTracker.Locked: Boolean;
begin
Result := State = bsLock;
end;
end.