Skip to content

BPSK's timing search locks 8 samples early whenever a lead makes that reachable — the SNR estimate is capped near 3.5 dB and BPSK's fast climb can never fire on the OTA path #1438

Description

@dc0sk

The defect — two, in one function

BPSK's timing search, find_timing_offset_with_expected (plugins/bpsk/src/demodulate.rs), picks
the sub-symbol offset that maximises the preamble correlation of symbols produced by
demodulate_iq's half-Hann window. It is wrong in two independent ways.

1. Its objective peaks before the symbol boundary. The window is not matched to the modulator's
pulse (modulate.rs: each symbol is a full 64-sample Hann spanning two slots). Computed from those two
window definitions, the current symbol's gain against where the window starts, relative to the
boundary:

window start d (samples) −12 −9 −8 −4 −1 0 +2 +4
gain of the current symbol 1.166 1.190 1.188 1.130 1.038 1.000 0.914 0.818
coefficient of the next symbol 0.041 0.078 0.094 0.184 0.282 0.320 0.406 0.502
coefficient of the previous one 0.113 0.052 0.037 0.006 0.000 0.000 0.000 0.000

(0.320 is the discrete sum; the code's cancellation uses β = 1/3.) The model peaks at d = −9;
measured, the search locks at d = −8 wherever that is reachable — lead 16 locks at offset 8,
though offset 16 is in range.

2. Its range is one symbol. It scans offsets 0..n from the start of the buffer. For a lead of
n or more the boundary is never visited — at lead 32 it locks at 24, and a correct objective alone
would lock at 31.

At lead 0 neither defect can show: the early optimum is unreachable and the boundary is offset 0.
Every buffer-is-the-frame test hands the search lead 0, which is why none of them could see this.

What it costs — measured

The SNR estimate is capped at the d = −8 lock, and lower at other misalignments. BPSK250, 200 B,
AWGN, 8 seeds, plugin level (a 16- or 32-sample lead reads identically):

true SNR 10 dB 20 dB 30 dB 40 dB
estimate, lead 0 11.10 19.29 22.81 23.38
estimate, 16- or 32-sample lead 3.05 3.50 3.55 3.55

(Lead 0 flattening near 23 dB is the estimator's own documented EVM floor, not this defect.) The
cap's attribution is derived, not ablated: at d = −8 the next-symbol coefficient is 0.09 while
cancel_crossfade_isi subtracts 1/3, leaving ~0.24 of the next symbol as injected ISI.

It reaches production. Through ota_decode_burst on a clean 10 dB channel, frame at 4032 + j in
AWGN, 6 seeds, every frame decoded: the SNR fed to the rate controller was 3.0 dB at best and
−1.3 dB at j = 16, identically in a variant-0-only build — so the cap predates the #1428 union.

Logged spans show why: the onset scan steps 32 samples from the burst start, and its first successful
onset is typically one symbol BEFORE the frame (4000), giving the estimator a lead of 32 + j — the
range defect, where the boundary is never reachable. At j = 20 the cancelled arm no longer decodes
at 4000: the variant-0 build decodes at 4032 and reads 3.0 dB, while the union's second arm decodes
at 4000 and reports −6.0 dB. The union extends the early lock's reach; it did not create it.

The decode runs mis-locked too — a code read. decode_attempt →
receive_from_samples_with_fec_inner → stage_demodulate_variants → demodulate_variants →
symbol_stream_parts_with_expected reaches the same search on the same slice, with no onset
refinement between (refine_onset runs only on the receive_with_timeout path). On a clean 10 dB
channel the cancelled arm (with Rs) still decodes from a span up to 48 samples before the frame and
not from 52 — so a decode is not evidence of a correct lock. That RS is what absorbs it is inferred,
not ablated.

Blast radius

  • The fast SNR climb cannot fire for BPSK on the OTA path. hpx_hf's BPSK rungs have SNR
    ceilings of 6.0 / 6.5 / 7.0 / 9.0 dB (SL2–SL5); a reading capped near 3.5 dB clears none of them.
    BPSK climbs on decode evidence only. Demotion is unaffected: it runs on Failed, where no span
    exists and the SNR is None.
  • Operators see it in the panel's rate-decision log line (OtaRateDecision.snr_db); the TUI
    drops the field.
  • Where frames decode, on fades. On moderate_f1, 8 of BPSK's SNR estimate reads low on frames the FEC had to correct — decision-directed on the losing arm's decisions #1435's 96 frames failed the cancelled arm
    at the early lock and decoded at the boundary; with the union they are delivered from the early lock
    instead, with the capped reading. Whether the second arm's 17 early-lock decodes of frames the
    variant-0 build lost would also decode at the boundary is unmeasured.
  • AFC, unmeasured. After a phase-1 success the scan commits an AFC estimate on the winning slice
    (update_afc_estimate), and estimate_afc_hz uses the same timing search, so the estimate is taken
    from the early lock; rx_snr_db then runs at a centre frequency the decode did not use. There is one
    logged instance of the reading moving with AFC state: seed 22 below. Seed 94 (−19.3 dB in production,
    +0.27 dB on the same span on a fresh engine, no settle) is consistent with it but not yet shown.

What this is not

Direction — a design decision, for review before code

Both defects need fixing. A review of these measurements suggested (i) correlating the preamble
against a filter matched to the actual pulse — which should peak at the boundary, by symmetry;
untested — while keeping the half-Hann window and β cancellation for the data demod, and (ii) widening
the search to ≥ 2n offsets so the boundary is reachable for leads in [n, 2n). Wire-neutral and
plugin-local, but it changes the numbers of the #1062 vetting column (the same function, with a
candidate expected) and has a GPU twin with its own timing search (timing_offset_search_gpu; #1083,
and #1433's shape). Cost to measure: every mode makes ~129 scan attempts per burst (1 + 4 × 32);
BPSK31's per-attempt cost is the driver, at 2n = 512 offsets over 32 symbols of 256 samples.

Pinned

plugins/bpsk/src/demodulate.rs → the_timing_search_locks_early_when_a_lead_makes_it_reachable, a
default-run characterisation test at a strong 30 dB, one deterministic seed: lead 0 locks at 0 and
reads > 20 dB (22.89 in its own run); lead 16 locks at 8 (the objective defect) and lead 32 at 24 (the
range defect), both reading < 5 dB (3.55 and 3.53). Each assertion names the defect it sees and is expected to fail when that one is
fixed. Sabotage-verified.

Refs #1435, #1437, #1142, #1062, #1083, #1433.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions