Skip to content

fix(bpsk): BPSK's SNR estimate reads the channel at the lock it actually gets (#1438 PR1) - #1447

Merged
dc0sk merged 3 commits into
mainfrom
fix/1438-snr-estimator
Sep 25, 2026
Merged

dc0sk merged 3 commits into
mainfrom
fix/1438-snr-estimator

Conversation

@dc0sk

@dc0sk dc0sk commented Sep 25, 2026

Copy link
Copy Markdown
Owner

#1438 PR1 — BPSK's SNR estimate reads the channel at the lock it actually gets

The defect. BPSK's timing search locks a quarter symbol early whenever the frame starts that far
into the slice, which is nearly every real burst. The SNR estimator read the crossfade-CANCELLED
stream, which is correct only on the exact symbol boundary. At the production lock it read ≈ 3 dB at
every true SNR from 10 to 30 dB (BPSK250, −0.28 of a symbol: slope 0.09 dB/dB on AWGN and on
moderate_f1). The rate controller's ClimbOnSnr path therefore could not fire on hpx_hf
SL2–SL5 on air.

The change.

  • estimate_snr_db reads the uncancelled stream, the arm that is best sampled at that early lock.
  • It removes one frame-global residual carrier frequency, since the AFC discards sub-2 Hz
    corrections by design.
  • It fits each 8-symbol window as a·d_{k−1} + b·d_k + c·d_{k+1}, so the neighbour taps stop counting
    as noise.
  • MATCHED_FILTER_LOSS_DB is refitted from 7.1 to 4.4.
  • The new primitives live in openpulse_dsp::constellation (remove_residual_frequency,
    isi_aware_snr_db_windowed).
  • The timing search is unchanged. Reachability is PR2.

What it changes on air: ClimbOnSnr for decoded frames — every rung on AWGN, SL5 on
moderate_f1 from ≈ 11 dB. A failed decode still passes no reading (engine.rs:3195), so the
fast-downshift is untouched in the daemon. The linksim passed a reading on failures too, so its
fast-downshift could move where the daemon's cannot — fixed in the second commit, below. last_rx_snr_db (panel, ADIF RST, QSY scan) changes
from a phase-dependent constant to a tracking reading.

Evidence (details and every number: the 2026-09-25 entry in docs/dev/project/traceability.md).

  • New gates, each watched failing under its own sabotage:
    • the DSP tap and frequency primitives;
    • the stream pin (inverted);
    • forced-phase AWGN tracking: 4 rungs × 5 phases × 0/1/2 Hz × 5/10/20 dB;
    • the BPSK250 fade slope (the gate's run reads 0.68, against 0.09 before);
    • the one-decision-error frequency pin, plus a flip-heavy fixture proving the frequency estimate
      uses the decisions (the first version of that test passed with the decision index sabotaged);
    • a controller harness with the frame placed off the symbol grid. The three fixtures found that
      assert on the controller at a non-zero onset all sat on a symbol-period multiple, the one phase
      where the old estimator was right. There a 15 dB frame reads 12.5–15.1 dB and climbs; with the old
      estimator the first such frame reads 7.81 dB and holds.
  • Lead-0 SNR gates, before → after: all pass both times; the fade gate's spread goes from 2.7 dB
    to 11.4 dB.
  • Two deviations from the pre-registered criteria:
    • ±1 dB at 20 dB missed 9 of the 60 cells at that SNR (180 in the gate): slow rungs, 1–2 Hz
      residual, up to 1.74 dB low. It is not the frequency estimate (an exact derotation reads within
      0.6 dB), and the mechanism is not established. The bound there is one-sided, reasoned in the test.
    • 0 dB was dropped from the pre-registered 0…30 dB range.
  • On a fade the change enables the SNR climb on SL5 only. BPSK100's fade plateau (≈ 4.6 dB) sits
    below SL4's 7.0 dB ceiling. BPSK31 and BPSK63 read flat on moderate_f1 (≈ −10.2 and −2.2 … −1.3 dB
    across true 5–30 dB), better than the old estimator's −13.6 and ≈ −6.9 but still floored by the fade
    (hpx_hf is pinned at the entry rung on a fade (~5 bps vs ~300-1200): the rate ladder can only climb on an SNR estimate, and no estimator is informative at 31 baud through a 1 Hz fade #934's low-baud limit). The daemon cannot demote on a decoded frame, but the panel and ADIF will
    show those low numbers.

Second commit: the link simulator is aligned with the daemon. The daemon's rate controller gets
no SNR reading on a failed decode (#1142). The linksim, which says it mirrors the real software,
still passed one, and the new estimate exposed it:
psk_ladder_climbs_off_the_entry_rung_on_a_fade ended at SL2. A decision trace shows ClimbOnSnr
from SL5 into SL6, then fast-downshifts driven by the QPSK estimator's failure readings, then an
SL1↔SL2 loop in which every BPSK31 frame after an MFSK16 frame failed (19/19, unexplained, filed).
With decode_ok.then_some(snr) the test passes and the linksim suite is 19/19. main passed without
this: at the linksim's lead-0 lock the old estimate on the fade (5.3–5.6 dB at true 15–25) sits under
SL5's 9 dB ceiling, so SL6 was reached by evidence only, and less often. That is inferred from the
lead-0 gate readings; main's run was not traced.
The controller's FastDownshift branch now notes it has no on-air consumer.

  • scripts/slow-tests.sh ota (CAP-33) on this HEAD: SLOW-TESTS: PASS — 3 passed, 0 failed, 2763 s.
  • Workspace gate on this HEAD: GATE: PASS 0f092df72038899efa7d84cd5f9550b4f930e90b clean 20260925T084909Z — 2596 passed, 0 failed. (The first gate, on an earlier commit, failed six steps —
    clippy ×3 on one lint, the reachability ratchet, the requirements-trailer lint, and the linksim test above; all
    are addressed here.)

Corrections made in this PR. #1439's pin labels and its ledger entry: lead 32 → 24 is the
objective's early lock, not a range defect; the reachability defect shows at lead 0. review-1435
gains a banner scoping its round-1 estimator remark.

Out of scope, to be filed:

  • a pre-existing BPSK31/63 decode failure near m·baud/32 residual offsets (the coherent timing metric's
    nulls, inside the AFC's 2 Hz deadband);
  • receive_with_ack_hint estimating on the whole buffer;
  • whole-frame timing refinement;
  • the fade gross-lock tail;
  • a pre-trigger ring;
  • the QPSK/8PSK/64QAM twins;
  • scripts/slow-tests.sh ignoring CARGO_TARGET_DIR;
  • in the linksim, BPSK31 failing every frame after an MFSK16 frame.

Implements: REQ-FUN-06
Review: docs/dev/reviews/review-1438-snr-estimator.md

Refs #1438.

🤖 Generated with Claude Code

https://claude.ai/code/session_0188ATCj6DZ9aRVQ2vSirua6

dc0sk and others added 3 commits September 25, 2026 10:35
…O-aware fit (#1438)

BPSK's timing search locks a quarter symbol early whenever the frame starts that far into the
slice, and the SNR estimator read the crossfade-cancelled stream, correct only on the exact
boundary: at the production lock it read a near-constant (slope 0.09 dB/dB), so ClimbOnSnr could
not fire on hpx_hf SL2-SL5 on air.

estimate_snr_db now reads the uncancelled stream (the arm best sampled at that lock), removes one
frame-global residual carrier frequency (the AFC discards sub-2 Hz corrections by design), and fits
each 8-symbol window with the two neighbour taps (openpulse_dsp::constellation::
{remove_residual_frequency, isi_aware_snr_db_windowed}); MATCHED_FILTER_LOSS_DB 7.1 -> 4.4. The
timing search is unchanged (reachability is PR2).

Gates: DSP primitives, the inverted stream pin, forced-phase AWGN tracking over 4 rungs x 5
phases x 0/1/2 Hz, the BPSK250 fade slope, the one-decision-error and flip-heavy frequency pins, and
a controller harness with the frame placed off the symbol grid; each watched failing under its own
sabotage. The #1439 pin and ledger entry are relabelled; review-1435 gets a scoping banner;
additive_snr_db_windowed is recorded DORMANT. Chain and results: docs/dev/project/traceability.md,
2026-09-25. Review: docs/dev/reviews/review-1438-snr-estimator.md.

Implements: REQ-FUN-06
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188ATCj6DZ9aRVQ2vSirua6
The daemon's only feed of the OTA rate controller (ota_decode_and_ack_inner) passes None on every
failed decode since #1142: the estimate is taken only on a decoded span. The link simulator, whose
comment says it mirrors the real software, still passed the whole-buffer reading on a failure, so
its FastDownshift fired where the daemon's cannot. #1438's livelier estimate exposed it:
psk_ladder_climbs_off_the_entry_rung_on_a_fade ended at avg_level 2.8 / SL2. A decision trace
shows the downshifts fired on SL6 (QPSK250-D) failures using the QPSK estimator's readings, and in
an SL1<->SL2 loop where every BPSK31 frame after an MFSK16 frame failed (19/19, unexplained,
filed). With the failure-path reading removed the test passes and the linksim suite is 19/19.

The FastDownshift branch in ota_rate.rs gets a note: it has no on-air consumer.

Verification-objective: the link simulator feeds the rate controller what the daemon feeds it
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188ATCj6DZ9aRVQ2vSirua6
…eadings measured

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188ATCj6DZ9aRVQ2vSirua6
@dc0sk
dc0sk merged commit ed8ea64 into main Sep 25, 2026
11 checks passed
@dc0sk
dc0sk deleted the fix/1438-snr-estimator branch September 25, 2026 18:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant