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>
This commit is contained in:
2026-06-18 11:04:14 +03:00
co-authored by Claude Opus 4.8
parent 40dca01c40
commit 42610b304e
6 changed files with 764 additions and 5 deletions
+269 -3
View File
@@ -50,7 +50,7 @@ uses
Classes, SysUtils, Math,
HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings,
WDSPEngine, AudioOutput, AudioInput,
Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore;
Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore, BeaconLock;
type
// Поле, которое изменилось — для гранулярных уведомлений наружу.
@@ -71,6 +71,7 @@ type
rfChannel,
rfSMeter, rfPower, rfSWR, rfSupply, rfPLL, // телеметрия
rfADCOverload, // ADC overload (рендер индикатора)
rfBeaconLock, // QO-100 beacon lock: статус/коррекция
rfDevice, // подключённое устройство сменилось
rfDeviceList // список discovered устройств обновился
);
@@ -176,6 +177,20 @@ type
FCurrentBand: Integer;
FCurrentXvtr: Integer;
// ---- QO-100 beacon lock ----
FBeaconTracker: TBeaconTracker; // владеем (Free в FreeEngines)
FBeaconLockOn: Boolean; // лок включён пользователем
FBeaconLockHz: Double; // текущая динамич. коррекция LO (Hz), поверх LOError
FBeaconRefHz: Double; // опорная частота маяка (Hz, downlink), деф. 10489.500e6
FBeaconSearchTicks: Integer; // тиков без лока (троттлинг переакквизиции)
FBeaconManualSeed: Boolean; // пользователь указал маяк кликом → не автопоиск
// FFT-трекинг маяка (робастный, по display-FFT — без NCO/FLL):
FBeaconTrackHz: Double; // отслеживаемая АБСОЛЮТНАЯ display-частота маяка
// (не baseband-offset — иначе сдвиг центра/водопада ломает окно)
FBeaconPromDB: Double; // «высота» пика над шумом окна (лок-метрика)
FBeaconLocked: Boolean; // пик уверенно виден
FBeaconCorrCooldown: Integer; // тиков паузы после коррекции (осадка пайплайна)
// ---- Аудио / TX ----
FVolume: Integer;
FMuted: Boolean;
@@ -321,6 +336,12 @@ type
// ActiveTXFreqHz — текущая TX-частота (split/active VFO + FM-репитер сдвиг).
function XvtrTranslate(VisibleHz: Double): Double;
function ActiveTXFreqHz: Double;
// Грубый поиск пика маяка в display-FFT около ожидаемой позиции → baseband
// offset (Hz). False если маяк вне span / слишком слаб. Сид для трекера.
function CoarseBeaconOffset(ExpectOff: Double; out OffHz: Double): Boolean;
// Измерение пика маяка по display-FFT в окне вокруг ExpectOff: возвращает
// уточнённое смещение (parabolic) и «высоту» пика над средним окна (для лока).
function MeasureBeaconFFT(ExpectOff: Double; out PeakOff, PromDB: Double): Boolean;
// Применяет текущие FMode/FFilterBW/FM-параметры к DSP (RX/TX фильтр,
// FM-девиация, squelch). Вызывается командами и UI-путями смены режима.
@@ -425,6 +446,18 @@ type
procedure SetSpan(Hz: Integer);
procedure SetSampleRate(Hz: Integer);
// QO-100 beacon lock — стабилизация дрейфа LNB по опорному маяку.
procedure SetBeaconLock(On_: Boolean);
procedure ServiceBeaconLock; // одна итерация контура (зовётся UI-таймером ~4 Гц)
procedure SetBeaconRefHz(Hz: Double); // опорная частота маяка (downlink)
procedure BeaconSeedAtHz(DisplayHz: Double); // пользователь указал маяк кликом
function BeaconState: TBeaconState;
function BeaconCorrectionHz: Double; // текущая коррекция LO (FBeaconLockHz)
function BeaconSNRdB: Double;
function BeaconLockEnabled: Boolean;
function BeaconRefHz: Double; // опорная частота маяка (для маркера)
function BeaconTrackedFreqHz: Double;// где сидит трекер: центр + measured offset
// Отображение / приём
procedure SetCTun(On_: Boolean);
procedure SetDuplex(On_: Boolean);
@@ -520,6 +553,13 @@ begin
// буфер вдвое сокращает построение downsampler в OpenChannel RXA.
FDSPEngine := TWDSPEngine.Create(ASampleRate, 48000, 512);
// QO-100 beacon lock: трекер кормится RX-IQ из движка (тап в PushIQItemToDSP).
FBeaconTracker := TBeaconTracker.Create;
FBeaconRefHz := 10489750000.0; // средний BPSK-маяк NB-транспондера (downlink)
FBeaconLockHz := 0.0;
FBeaconLockOn := False;
FDSPEngine.SetBeaconTracker(FBeaconTracker);
// Audio out/in — объекты создаём сейчас, Open вызывается позже (после показа
// формы / при подключении устройства).
FAudioOut := TAudioOutput.Create(48000);
@@ -537,6 +577,9 @@ begin
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;
// Трекер освобождаем ПОСЛЕ движка — движок (Close остановил DSP-поток) больше
// не вызовет Feed. Указатель в движке уже мёртв вместе с движком.
if Assigned(FBeaconTracker) then FreeAndNil(FBeaconTracker);
end;
destructor TRadioController.Destroy;
@@ -899,10 +942,13 @@ begin
Result := VisibleHz;
if (FCurrentXvtr < 0) or (FCurrentXvtr >= CFG_XVTR_COUNT) then Exit;
if not FXvtrSettings.Entries[FCurrentXvtr].Enabled then Exit;
// f_IF = f_visible - LOOffset + LOError
// f_IF = f_visible - LOOffset + LOError + BeaconLock
// FBeaconLockHz — динамическая коррекция дрейфа LNB (QO-100 beacon lock),
// поверх статичной ручной LOError. 0 когда лок выключен.
Result := VisibleHz
- FXvtrSettings.Entries[FCurrentXvtr].LOOffset
+ FXvtrSettings.Entries[FCurrentXvtr].LOError;
+ FXvtrSettings.Entries[FCurrentXvtr].LOError
+ FBeaconLockHz;
end;
function TRadioController.ActiveTXFreqHz: Double;
@@ -919,6 +965,226 @@ begin
end;
end;
// ---------------------------------------------------------------------------
// QO-100 beacon lock
// ---------------------------------------------------------------------------
const
BEACON_GAIN = 0.4; // гейн контроллера (доля ошибки за коррекцию)
BEACON_DEADBAND_HZ = 75.0; // внутри — коррекции стоп (без вечного подкручивания)
BEACON_SETTLE_TICKS = 8; // пауза (~0.8 с @10Гц) после коррекции — осадка FFT-пайплайна
BEACON_UNLOCK_PROM_DB = 3.0; // гистерезис: лок снимается ниже этого (lock при LOCK_PROM)
BEACON_SEARCH_HZ = 6000.0; // окно поиска пика вокруг клика/ожидаемой позиции
BEACON_REACQ_TICKS = 30; // тиков (≈3 с @10Гц) до переакквизиции по FFT
BEACON_LOCK_PROM_DB = 6.0; // пик над средним окна (dB) для статуса LOCK
BEACON_LOBE_DB = 10.0; // ширина лепестка для центроида (dB ниже пика)
function TRadioController.CoarseBeaconOffset(ExpectOff: Double; out OffHz: Double): Boolean;
// Ищет максимум display-FFT в окне ±BEACON_SEARCH_HZ вокруг ExpectOff (baseband
// offset). Пиксели покрывают анализаторную полосу = FSampleRate, центр (пиксель
// n/2) = baseband 0, рост вправо — та же конвенция, что и маркеры (проверено:
// зелёная садится на маяк). Возвращает уточнённый baseband offset пика.
var
pix: array[0..SPECTRUM_PIXELS - 1] of Single;
n, i, best: Integer;
hzPerPix, lo, hi, off, denom, delta: Double;
bestv: Single;
begin
Result := False; OffHz := 0;
if not FWDSPReady or not Assigned(FDSPEngine) then Exit;
n := 0;
FDSPEngine.GetSpectrumData(pix, n);
if n < 8 then Exit;
hzPerPix := FSpanHz / n;
lo := ExpectOff - BEACON_SEARCH_HZ;
hi := ExpectOff + BEACON_SEARCH_HZ;
best := -1; bestv := -1e30;
for i := 0 to n - 1 do
begin
off := (i - n / 2) * hzPerPix; // baseband offset пикселя i
if (off < lo) or (off > hi) then Continue;
if pix[i] > bestv then begin bestv := pix[i]; best := i; end;
end;
if best < 0 then Exit; // окно вне span
delta := 0.0;
// Параболическая интерполяция пика (пиксели в dB ≈ гауссов пик) — субдискретная
// точность, чтобы сид попал в ±1 кГц окно захвата FLL даже при крупном бине.
if (best > 0) and (best < n - 1) then
begin
denom := pix[best - 1] - 2.0 * pix[best] + pix[best + 1];
if Abs(denom) > 1e-12 then
delta := 0.5 * (pix[best - 1] - pix[best + 1]) / denom;
if delta > 0.5 then delta := 0.5;
if delta < -0.5 then delta := -0.5;
end;
OffHz := (best + delta - n / 2) * hzPerPix;
Result := True;
end;
function TRadioController.MeasureBeaconFFT(ExpectOff: Double;
out PeakOff, PromDB: Double): Boolean;
// Маяк по display-FFT в окне ±BEACON_SEARCH_HZ вокруг ExpectOff. Средний маяк —
// BPSK с ПОДАВЛЕННОЙ несущей: в спектре не линия, а широкая симметричная «полка»
// (~2.4 кГц), peak-finder по ней скачет. Поэтому позицию берём как
// POWER-ВЗВЕШЕННЫЙ ЦЕНТРОИД лепестка (пиксели в пределах BEACON_LOBE_DB от пика):
// центр симметричной полки = несущая, стабилен. PromDB — пик над средним окна.
var
pix: array[0..SPECTRUM_PIXELS - 1] of Single;
n, i, best, cnt: Integer;
hzPerPix, lo, hi, off, sum, thr, p, wsum, fsum: Double;
bestv: Single;
begin
Result := False; PeakOff := ExpectOff; PromDB := -99.0;
if not FWDSPReady or not Assigned(FDSPEngine) then Exit;
n := 0;
FDSPEngine.GetSpectrumData(pix, n);
if n < 8 then Exit;
hzPerPix := FSpanHz / n;
lo := ExpectOff - BEACON_SEARCH_HZ;
hi := ExpectOff + BEACON_SEARCH_HZ;
best := -1; bestv := -1e30; sum := 0; cnt := 0;
for i := 0 to n - 1 do
begin
off := (i - n / 2) * hzPerPix;
if (off < lo) or (off > hi) then Continue;
sum := sum + pix[i]; Inc(cnt);
if pix[i] > bestv then begin bestv := pix[i]; best := i; end;
end;
if (best < 0) or (cnt < 4) then Exit;
// Центроид лепестка: пиксели в пределах BEACON_LOBE_DB ниже пика, вес —
// линейная мощность над порогом (шум за порогом не входит).
thr := bestv - BEACON_LOBE_DB;
wsum := 0; fsum := 0;
for i := 0 to n - 1 do
begin
off := (i - n / 2) * hzPerPix;
if (off < lo) or (off > hi) then Continue;
if pix[i] < thr then Continue;
p := Power(10.0, (pix[i] - thr) / 10.0); // линейная мощность над порогом
wsum := wsum + p;
fsum := fsum + p * off;
end;
if wsum > 0 then PeakOff := fsum / wsum
else PeakOff := (best - n / 2) * hzPerPix;
PromDB := bestv - (sum / cnt); // пик над средним окна (dB)
Result := True;
end;
procedure TRadioController.SetBeaconRefHz(Hz: Double);
begin
FBeaconRefHz := Hz;
end;
procedure TRadioController.BeaconSeedAtHz(DisplayHz: Double);
// Пользователь кликнул по маяку → задаём область отслеживания (уточняем пик по
// display-FFT рядом с кликом) и включаем ручной режим (контур ведёт ИМЕННО этот
// пик, без автопоиска, который в забитой полосе хватает соседей).
var clickOff, refined, prom: Double;
begin
FBeaconManualSeed := True;
FBeaconSearchTicks := 0;
FBeaconCorrCooldown := BEACON_SETTLE_TICKS; // дать FFT устаканиться после сида
clickOff := DisplayHz - FCenterFreq;
if MeasureBeaconFFT(clickOff, refined, prom) then
FBeaconTrackHz := FCenterFreq + refined
else
FBeaconTrackHz := DisplayHz;
if not FBeaconLockOn then SetBeaconLock(True);
end;
procedure TRadioController.SetBeaconLock(On_: Boolean);
var refined, prom: Double;
begin
if On_ = FBeaconLockOn then Exit;
FBeaconLockOn := On_;
if On_ then
begin
FBeaconSearchTicks := 0;
FBeaconCorrCooldown := 0;
FBeaconManualSeed := False; // авто до первого клика пользователя
FBeaconLocked := False;
// Начальная область — у опорной частоты (уточняем пик, если виден).
if MeasureBeaconFFT(FBeaconRefHz - FCenterFreq, refined, prom) then
FBeaconTrackHz := FCenterFreq + refined
else
FBeaconTrackHz := FBeaconRefHz;
end;
// При выключении FBeaconLockHz замораживаем (как калибровка LNB).
Changed(rfBeaconLock);
end;
procedure TRadioController.ServiceBeaconLock;
// Одна итерация контура (UI-таймер @10 Гц). Меряет пик маяка по display-FFT,
// сглаживает, и (после ручного клика) тянет FBeaconLockHz так, чтобы маяк
// оставался на опорной частоте → весь downlink стабилен. Без NCO/FLL.
var pk, prom, measDisp, e: Double;
begin
if not FBeaconLockOn then Exit;
if FCurrentXvtr < 0 then Exit; // только в трансвертер-режиме
// Окно поиска — вокруг ТЕКУЩЕЙ display-позиции маяка, пересчитанной в baseband
// через актуальный FCenterFreq. Поэтому сдвиг водопада/центра окно не ломает.
if not MeasureBeaconFFT(FBeaconTrackHz - FCenterFreq, pk, prom) then Exit;
// Сопровождаем центроид плавно (в display-частоте; стабильность важнее скорости).
FBeaconTrackHz := FBeaconTrackHz + 0.12 * ((FCenterFreq + pk) - FBeaconTrackHz);
FBeaconPromDB := FBeaconPromDB + 0.2 * (prom - FBeaconPromDB);
// Гистерезис лока: загорается при LOCK_PROM, гаснет ниже UNLOCK_PROM (без мерцания).
if FBeaconPromDB >= BEACON_LOCK_PROM_DB then FBeaconLocked := True
else if FBeaconPromDB < BEACON_UNLOCK_PROM_DB then FBeaconLocked := False;
// Коррекцию НЕ применяем каждый тик: display-FFT отстаёт от ретюна LO на
// задержку пайплайна, и спам-коррекции дают перелёт (маяк улетает из окна).
// После каждой коррекции ждём BEACON_SETTLE_TICKS, давая FFT отразить новый LO.
// Слежение за пиком (выше) идёт каждый тик — окно едет за маяком.
if FBeaconCorrCooldown > 0 then
Dec(FBeaconCorrCooldown)
else if FBeaconLocked and FBeaconManualSeed then
begin
// e = видимое_смещение_маяка − опорное. d(e)/d(FBeaconLockHz) = 1,
// поэтому FBeaconLockHz += e подтягивает маяк к опорной (см. вывод знака).
measDisp := FBeaconTrackHz;
e := measDisp - FBeaconRefHz;
if Abs(e) > BEACON_DEADBAND_HZ then
begin
FBeaconLockHz := FBeaconLockHz + BEACON_GAIN * e;
PushNetworkState; // ретюн LNB-IF: новый LO = XvtrTranslate(центр)
FBeaconCorrCooldown := BEACON_SETTLE_TICKS;
end;
end;
Changed(rfBeaconLock);
end;
function TRadioController.BeaconState: TBeaconState;
begin
if not FBeaconLockOn then Exit(bsIdle);
if FBeaconLocked then Result := bsLock else Result := bsSearch;
end;
function TRadioController.BeaconCorrectionHz: Double;
begin
Result := FBeaconLockHz;
end;
function TRadioController.BeaconSNRdB: Double;
begin
Result := FBeaconPromDB; // «высота» пика над шумом окна (dB)
end;
function TRadioController.BeaconLockEnabled: Boolean;
begin
Result := FBeaconLockOn;
end;
function TRadioController.BeaconRefHz: Double;
begin
Result := FBeaconRefHz;
end;
function TRadioController.BeaconTrackedFreqHz: Double;
begin
Result := FBeaconTrackHz;
end;
procedure TRadioController.ApplyModeFilter;
var
Lo, Hi, Half: Integer;