fix(beacon): контур принимал за маяк любую узкую несущую

Наводишься на соседнюю станцию — и контур честно берёт её за маяк и тащит
LOError, подтягивая чужой сигнал к 10489.750.

Решение принималось по CarrierLock и SNR, а CarrierLock = FCarPresent and
FFreqLocked. FCarPresent — prominence сильнейшей линии в squaring-FFT: возведение
в квадрат схлопывает модуляцию BPSK ±180° в одну линию на 2·fc, но такую же линию
даёт любой сигнал с узкой составляющей — телеграф, пораженка, остаток несущей у
цифры. FFreqLocked — Costas держит фазу, а на немодулированную несущую он садится
идеально: ровный тон это BPSK без переходов. SNR считается как (E|I|)²/E[Q²], и
захваченная Costas'ом несущая лежит целиком на оси I — Q пустой, SNR выходит
отличный. То есть чужой сигнал не просто проходил порог, он выглядел лучше маяка.

Различаем по констелляции: у настоящего BPSK точки ходят между двумя сгустками
±I (данные после свёрточного кодера равновероятны), у несущей все в одном.
BeaconIQBalance считает долю меньшинства по знаку I, EMA сглаживает, порог 0.15
на взятие лока и 0.08 на сброс — гистерезис, как у SNR. Кольцо в 256 точек при
400 Bd набирается за ~0.6 с, EMA добавляет столько же: чужой сигнал отваливается
примерно за секунду, а не за десяток, как ждать кадр FEC.

FBeaconBal обнуляется вместе с FBeaconLocked при каждом наведении и при включении
лока — после клика BPSK-ность доказывается заново, унаследовать её от прошлой
цели нельзя.

Скорость символов признаком служить не может: FWsym зажат в ±0.02, поэтому
S.SymRate конструктивно всегда 392..408 Bd, что бы декодер ни слушал.
Непрерывный цифровой сигнал с настоящей модуляцией баланс пройдёт — от него
защитит только декодированный кадр AO-40.

Сборка ewsdr (--ws=qt6) и ewsdrd — ОК. На эфире не проверено.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 12:59:46 +03:00
co-authored by Claude Opus 5
parent 504265f8ad
commit 51599b2457
+41 -4
View File
@@ -324,6 +324,7 @@ type
FBeaconTrackHz: Double; // измеренная АБСОЛЮТНАЯ display-частота маяка
// (наведение + остаток декодера) — для оранж. маркера
FBeaconSNRdB: Double; // SNR из последнего снимка декодера (индикация лока)
FBeaconBal: Double; // EMA доли меньшинства знаков I (BPSK-ность)
FBeaconLocked: Boolean; // декодер солидно залочен (гистерезис по SNR)
FBeaconCorrCooldown: Integer; // тиков паузы после коррекции (осадка ретюна LO)
FBeaconSaveCooldown: Integer; // троттлинг записи LOError на диск (тиков)
@@ -3300,6 +3301,17 @@ const
// --- метрика лока (берём из снимка декодера: CarrierLock + SNR) ---
BEACON_LOCK_SNR_DB = 3.0; // солидный лок: декодер несёт несущую и SNR выше этого
BEACON_UNLOCK_SNR_DB = 0.0; // гистерезис: лок гаснет ниже этого
// --- «это точно BPSK, а не голая несущая» ---
// CarrierLock+SNR подтверждают лишь то, что в полосе есть узкая линия и Costas
// держит фазу. Ровная несущая (телеграф, пораженка, остаток несущей у цифры) даёт
// ровно это, причём с ОТЛИЧНЫМ SNR: захваченная Costas'ом, она лежит целиком на
// оси I, Q пустой. Контур тогда честно уводил LOError, подтягивая чужой сигнал к
// опорной частоте маяка. Различаем по констелляции: у настоящего BPSK точки ходят
// между двумя сгустками ±I (данные после свёрточного кодера равновероятны), у
// несущей все точки в одном. Меряем долю меньшинства по знаку I.
BEACON_BAL_MIN = 0.15; // ниже — на BPSK не похоже, лок не даём
BEACON_BAL_DROP = 0.08; // гистерезис: уже взятый лок гаснет ниже этого
BEACON_BAL_MIN_PTS = 64; // меньше точек в кольце — судить рано
procedure TRadioController.SetBeaconRefHz(Hz: Double);
begin
@@ -3319,6 +3331,8 @@ begin
FBeaconDec.SetOffsetHz(DisplayHz - FCenterFreq);
FBeaconDec.Reseed; // одноразовый грубый захват частоты заново
end;
FBeaconBal := 0.0; // новое наведение — BPSK-ность доказывается заново
FBeaconLocked := False;
FBeaconCorrCooldown := BEACON_SETTLE_TICKS; // дать декодеру захватиться до 1-й коррекции
Changed(rfBeaconLock);
end;
@@ -3335,6 +3349,7 @@ begin
FBeaconCorrCooldown := 0;
FBeaconSaveCooldown := 0;
FBeaconLocked := False;
FBeaconBal := 0.0;
FBeaconSNRdB := -99.0;
FBeaconLockHz := 0.0; // сессионный аккумулятор индикации (LOError несёт калибровку)
FBeaconDecHz := 0.0; // ещё не наведён — ждём клик по маяку
@@ -3354,6 +3369,20 @@ begin
Changed(rfBeaconLock);
end;
function BeaconIQBalance(const S: TBeaconScope): Double;
// Доля меньшинства среди знаков I в кольце констелляции: 0.5 — идеально
// сбалансированный BPSK, 0 — все точки в одном сгустке (немодулированная
// несущая). -1 = точек мало, судить рано.
var
i, Neg: Integer;
begin
if S.Count < BEACON_BAL_MIN_PTS then Exit(-1.0);
Neg := 0;
for i := 0 to S.Count - 1 do
if S.PtI[i] < 0 then Inc(Neg);
Result := Min(Neg, S.Count - Neg) / S.Count;
end;
procedure TRadioController.ServiceBeaconLock;
// Одна итерация контура (UI-таймер @10 Гц, MeterTimer). Эталон частоты — ДЕКОДЕР:
// он точно ведёт несущую маяка своим NCO (RunAcquisition + Costas-offload), и его
@@ -3365,7 +3394,7 @@ procedure TRadioController.ServiceBeaconLock;
// baseband-смещение ≈ 0: маяк сдвинулся на −dLock железом, наведение тоже на −dLock).
var
S: TBeaconScope;
residHz, measured, e, dLock: Double;
residHz, measured, e, dLock, Bal: Double;
begin
if not FBeaconLockOn then Exit;
if not Assigned(FBeaconDec) then Exit;
@@ -3382,9 +3411,17 @@ begin
FBeaconDec.GetScope(S);
FBeaconSNRdB := FBeaconSNRdB + 0.2 * (S.SNRdB - FBeaconSNRdB);
// Солидный лок: декодер несёт несущую И SNR выше порога (гистерезис против мерцания).
if S.CarrierLock and (FBeaconSNRdB >= BEACON_LOCK_SNR_DB) then FBeaconLocked := True
else if FBeaconSNRdB < BEACON_UNLOCK_SNR_DB then FBeaconLocked := False;
// Доля меньшинства по знаку I: у BPSK ≈0.5, у голой несущей ≈0. Кольцо в 256
// точек при 400 Bd набирается за ~0.6 с, EMA добавляет ещё столько же — то есть
// чужой сигнал отваливается примерно за секунду, а не за десяток (кадр FEC).
Bal := BeaconIQBalance(S);
if Bal >= 0 then FBeaconBal := FBeaconBal + 0.2 * (Bal - FBeaconBal);
// Солидный лок: декодер несёт несущую, SNR выше порога И картинка похожа на
// BPSK (гистерезис по обоим — против мерцания).
if S.CarrierLock and (FBeaconSNRdB >= BEACON_LOCK_SNR_DB)
and (FBeaconBal >= BEACON_BAL_MIN) then FBeaconLocked := True
else if (FBeaconSNRdB < BEACON_UNLOCK_SNR_DB)
or (FBeaconBal < BEACON_BAL_DROP) then FBeaconLocked := False;
// Остаток несущей декодера (Гц, относительно наведения). invert выравнивает ось
// декодера с дисплеем → display_маяка = наведение + остаток (знак BEACON_RESID_SIGN).