{
  Copyright (C)
  2026 - Uladzimir Karpenka, EW8BAK

  This program is free software; you can redistribute it and/or
  modify it under the terms of the GNU General Public License
  as published by the Free Software Foundation; either version 2
  of the License, or (at your option) any later version.

  This program is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  GNU General Public License for more details.

  You should have received a copy of the GNU General Public License
  along with this program; if not, write to the Free Software
  Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
}

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(c0−c1)≈0).
    // Поэтому грубый снос убираем NCO по оценке частоты из squaring-FFT (z² снимает
    // BPSK → линия на 2·fc, знак однозначен при большом сносе). Усредняем по
    // FAcqNeed блокам, ставим FResidInc один раз, разоружаемся. Дрейф LNB дальше
    // ведёт непрерывный Costas+offload (отриц. ОС, не убегает).
    FAcqArmed: Boolean;          // ждём захвата (взводится на Reseed/новое наведение)
    FAcqCount: Integer;          // сколько блоков усреднено
    FAcqSum:   Double;           // сумма оценок fc (Гц) для усреднения
    FFreqLocked: Boolean;        // Costas держит фазу (для индикации/symbol-lock)
    FLockHold:   Integer;        // холдовер-счётчик лока (анти-мерцание)

    // --- 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_{n−1})) (знак −1 как в gr для Manchester).
//  3) soft 400/с → TBeaconFEC.
var
  di_, dq_, mag, soft: Double;
  parity: Integer;
begin
  if FChipHave then
  begin
    di_ := 0.5 * (FChipPrevI - ci);
    dq_ := 0.5 * (FChipPrevQ - cq);
    mag := Sqrt(di_ * di_ + dq_ * dq_);
    parity := (FChipCount - 1) and 1;     // чётность первого чипа пары
    if parity = 0 then
      FManMetric0 := FManMetric0 + 0.01 * (mag - FManMetric0)
    else
      FManMetric1 := FManMetric1 + 0.01 * (mag - FManMetric1);
    if FManMetric0 >= FManMetric1 then FManPhase := 0 else FManPhase := 1;

    if parity = FManPhase then
    begin
      // это символ данных (400/с)
      if FSymHave then
      begin
        soft := -(di_ * FSymPrevI + dq_ * FSymPrevQ);   // −Re(s_n·conj(s_{n−1}))
        FFEC.PushSoftSymbol(soft);
      end;
      FSymPrevI := di_; FSymPrevQ := dq_; FSymHave := True;
    end;
  end;
  FChipPrevI := ci; FChipPrevQ := cq; FChipHave := True;
  Inc(FChipCount);
end;

procedure TBeaconDecoder.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.
