Skip to content

fix(gpsl1ca): Student-t small-sample threshold in CFAR bit-edge detector#208

Open
giove-a wants to merge 1 commit into
masterfrom
sc/cfar-t-quantile
Open

fix(gpsl1ca): Student-t small-sample threshold in CFAR bit-edge detector#208
giove-a wants to merge 1 commit into
masterfrom
sc/cfar-t-quantile

Conversation

@giove-a

@giove-a giove-a commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Summary

The GPS L1 C/A soft-decision CFAR bit-edge detector (added in #124) can declare
a confident lock on as few as 2 bins during a fast/marginal (re)acquisition,
landing a few code-periods off the true bit boundary. Because a lock < 10 blocks
off still decodes the correct nav bits (12-vs-8 majority), it passes every sanity
check (TOW, parity, health, IODC) yet leaves the ranging code-phase anchored to
the wrong boundary — a constant integer-millisecond pseudorange bias that
persists until the satellite loses lock.

This PR thresholds the detector with a Student-t quantile at a nominal
peak_bin_count − 1 d.o.f.
, used as a small-sample penalty that forces enough
integration before a lock.

Root cause

A lock is accepted when z = energy_gap / standard_error ≥ Φ⁻¹(…), but
standard_error divides by a variance estimated over peak_bin_count bins.
Treating that estimate as exact (the normal quantile) is badly overconfident at
the 2-bin minimum:

  • the 2-sample variance can collapse (sum_sq_dev → 0 ⇒ SE → 0 ⇒ z → ∞), and
  • the normal threshold ≈ 3.9 is a ~8 % per-phase false alarm at 1 nominal
    d.o.f., not the intended ~1e-4.

Observed on real data (TEX-CUP ntlab.bin)

GPS PRN 9 reacquired and its bit-sync fired at ~2.6 bins with z ≈ 40 (vs the
clean acquisitions at ~70/160 bits, z ≈ 3.9), ~8 code-periods off the true edge:

before after
PRN 9 pseudorange bias +2,398,370 m (8 ms) — (no false lock)
worst residual over the full 4957 s run 785,699 m 78.9 m
worst horizontal PVT error 552 km 3.6 km (rover travel)

The bias survived ~24 s until PRN 9 next dropped lock.

The fix

  • Threshold with _t_quantile(quantile_argument, peak_bin_count − 1) — an exact
    Student-t quantile via SpecialFunctions.beta_inc_inv (returning the median
    directly and the lower tail by symmetry, which also avoids a 1 − 0.5
    self-recursion). It demands z ≈ 6000 at 1 d.o.f. and relaxes to the normal
    quantile as bins accumulate (no cutoff), so mature clean locks are unchanged.
  • It is documented honestly as a heuristic penalty, not an exact distribution.
    The per-bin values are χ² (non-central) energies, not Gaussian, and the competing
    phases share blocks (correlated), so peak_bin_count − 1 is a nominal d.o.f.
    and the realised false-alarm rate is only approximately the nominal one.
  • Removes the now-unused _norm_quantile and its sole-purpose erfinv import.

Scope

GPS L1 C/A only — the sole signal with uses_soft_bit_edge_detection
(multi-block bit, no secondary code). The secondary-code and immediate-lock
detectors are untouched. The change can only ever raise the threshold, so a
clean lock is never advanced or corrupted, only (slightly) delayed.

Validation

  • All visible GPS L1 C/A satellites still lock, self-pacing by C/N₀ (~8–149 bits;
    PRN 9 clean lock 74 vs 70 bits).
  • Full ~4957 s TEX-CUP recording (44,792 PVT epochs): zero gross outliers.
  • Full Tracking test suite passes, incl. Aqua and the format check.

Tests

  • New _t_quantile testset (median/recursion regression, dof=1 Cauchy values,
    symmetry, tail monotonicity, convergence to a tabulated normal limit);
    _norm_quantile testset dropped.
  • Lock-latency expectations re-baselined to the higher threshold: the two
    bit_buffer CFAR-latency streams are lengthened, and the two
    bit_integration_test L1 C/A testsets now expect the near-noiseless signal to
    lock at block 60 (3 bins) rather than 40 (2 bins) — still a true bit boundary
    (the L1CA bit-edge detector locks one block early at default tolerance — permanent 1 ms bit-grid misalignment #124 "never one block early" property), just one bit later.

Notes / possible follow-ups (out of scope)

Complementary hardening worth considering separately: a hard minimum-bins floor,
and PVT-level RAIM/residual screening (which would reject a k × 300 km outlier
regardless of the tracking cause).

Follow-up to #124.

@codecov

codecov Bot commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.24%. Comparing base (18aee64) to head (49e8808).

Additional details and impacted files
@@           Coverage Diff           @@
##           master     #208   +/-   ##
=======================================
  Coverage   98.24%   98.24%           
=======================================
  Files          32       32           
  Lines        3365     3369    +4     
=======================================
+ Hits         3306     3310    +4     
  Misses         59       59           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

Copy link
Copy Markdown
Contributor

Benchmark Results (minimum time) — macos-14

Reporting the minimum over all samples (robust to shared-runner contention), not the median.

Alternative backends vs Float32 (track!, PR head)

Legend — backends: F32 Float32 (default) · I16 Int16 · 1b OneBit · 2b TwoBit (2-bit measurement + 2-bit carrier). Time columns are the minimum track! time; ×B = F32 / B (so >1 ⇒ backend B is faster than Float32), ✅ ≥ 5 % faster, ⚠️ ≥ 5 % slower. 1b/2b are BPSK-only, so their cells are blank for CBOC (Galileo E1B) scenarios.

Scenario F32 I16 1b 2b ×I16 ×1b ×2b
4-antenna @ 5 MHz 12.3 μs 6.3 μs 4.31 μs 9.04 μs 1.95 ✅ 2.85 ✅ 1.36 ✅
GPS L1CA, 8 sats @ 40 MHz 395.0 μs 124.0 μs 65.5 μs 121.0 μs 3.17 ✅ 6.03 ✅ 3.25 ✅
GPS L1CA, 8 sats @ 5 MHz 56.7 μs 22.1 μs 15.9 μs 25.3 μs 2.56 ✅ 3.56 ✅ 2.24 ✅
Galileo E1B, 4 sats @ 25 MHz 151.0 μs 63.0 μs 2.4 ✅
dynamic taps @ 5 MHz (kernel) 7.16 μs 2.45 μs 1.64 μs 2.73 μs 2.92 ✅ 4.37 ✅ 2.62 ✅
multi-signal N=3 @ 5 MHz 11.2 μs 5.13 μs 3.93 μs 6.4 μs 2.19 ✅ 2.86 ✅ 1.76 ✅
Time benchmarks (base vs PR head)

Ratio = 8f3b080… / dd88497…: >1 means the PR is faster. ✅ ≥ 5 % faster, ⚠️ ≥ 5 % slower. A blank cell means the benchmark exists on only one revision (🆕 = new on the PR, 🗑 = removed).

8f3b080 dd88497 8f3b080… / dd88497
downconvert and correlate/CPU/Float32 2.54 μs 2.66 μs 0.953
downconvert and correlate/CPU/Float32 4ant 4.95 μs 5.23 μs 0.947 ⚠️
downconvert and correlate/CPU/Float64 2.91 μs 2.9 μs 1.0
downconvert and correlate/CPU/Int16 2.76 μs 2.82 μs 0.979
downconvert and correlate/CPU/Int16 4ant 4.93 μs 5.18 μs 0.952
downconvert and correlate/CPU/Int32 2.62 μs 2.77 μs 0.947 ⚠️
fused kernel/1-ant dynamic taps 2.74 μs 2.74 μs 1.0
fused kernel/1-ant static taps 2.2 μs 2.21 μs 0.998
fused kernel/4-ant dynamic taps 8.33 μs 8.33 μs 1.0
fused kernel/4-ant static taps 4.75 μs 4.76 μs 0.999
fused tuple kernel/1-ant N=2 3.51 μs 3.51 μs 1.0
fused tuple kernel/1-ant N=3 3.65 μs 3.65 μs 1.0
fused tuple kernel/2-ant N=2 6.76 μs 6.76 μs 1.0
fused tuple kernel/2-ant N=3 6.02 μs 5.98 μs 1.01
fused tuple kernel/4-ant N=2 12.4 μs 11.8 μs 1.05
fused tuple kernel/4-ant N=3 10.8 μs 10.8 μs 0.996
track/1. Float32/2K – track 3.03 μs 2.85 μs 1.06 ✅
track/1. Float32/2K – track! 3.12 μs 3.63 μs 0.861 ⚠️
track/2. L1 8sat/5K – track 53.8 μs 53.2 μs 1.01
track/2. L1 8sat/5K – track! 54.2 μs 52.9 μs 1.02
track/2. L1 8sat/5K – track! Int16 23.5 μs 22.2 μs 1.06 ✅
track/2. L1 8sat/5K – track! OneBit 16.1 μs 16.3 μs 0.985
track/2. L1 8sat/5K – track!-threaded 54.2 μs 53.4 μs 1.02
track/2. L1 8sat/5K – track-threaded 52.0 μs 53.7 μs 0.969
track/3. E1B 4sat/25K – track 142.0 μs 146.0 μs 0.976
track/3. E1B 4sat/25K – track! 143.0 μs 145.0 μs 0.982
track/3. E1B 4sat/25K – track! Int16 63.6 μs 62.2 μs 1.02
track/3. E1B 4sat/25K – track!-threaded 141.0 μs 145.0 μs 0.97
track/3. E1B 4sat/25K – track-threaded 146.0 μs 143.0 μs 1.02
track/4. 8L1+8E1B/25K – track 528.0 μs 526.0 μs 1.0
track/4. 8L1+8E1B/25K – track! 510.0 μs 524.0 μs 0.973
track/4. 8L1+8E1B/25K – track!-threaded 514.0 μs 511.0 μs 1.0
track/4. 8L1+8E1B/25K – track-threaded 528.0 μs 526.0 μs 1.0
track/5. multi-signal N=1/5K – track 7.09 μs 6.89 μs 1.03
track/5. multi-signal N=1/5K – track! 7.06 μs 9.01 μs 0.784 ⚠️
track/6. multi-signal N=2/5K – track 11.5 μs 11.1 μs 1.03
track/6. multi-signal N=2/5K – track! 11.0 μs 15.2 μs 0.719 ⚠️
track/7. multi-signal N=3/5K – track 12.3 μs 12.3 μs 1.0
track/7. multi-signal N=3/5K – track! 12.8 μs 18.5 μs 0.688 ⚠️
track/8. L1CA presync 2 blk – track! 13.2 μs 13.3 μs 0.994
track/8. L1CA presync 20 blk – track! 132.0 μs 129.0 μs 1.02
track/8. L1CA synced 2 blk – track! 13.5 μs 13.2 μs 1.03
track/8. L1CA synced 20 blk – track! 129.0 μs 128.0 μs 1.01
time_to_load 198.0 μs 219.0 μs 0.906 ⚠️
Memory benchmarks (base vs PR head)

Ratio = 8f3b080… / dd88497… (bytes allocated): >1 means the PR allocates less. ✅ ≥ 5 % less, ⚠️ ≥ 5 % more. /0 mark a benchmark that drops to / picks up allocations, means both revisions allocate nothing. A blank cell means the benchmark exists on only one revision (🆕 = new on the PR, 🗑 = removed).

8f3b080 dd88497 8f3b080… / dd88497
downconvert and correlate/CPU/Float32 2 allocs: 576 B 2 allocs: 576 B 1.0
downconvert and correlate/CPU/Float32 4ant 2 allocs: 928 B 2 allocs: 848 B 1.09 ✅
downconvert and correlate/CPU/Float64 2 allocs: 576 B 2 allocs: 576 B 1.0
downconvert and correlate/CPU/Int16 2 allocs: 576 B 2 allocs: 576 B 1.0
downconvert and correlate/CPU/Int16 4ant 2 allocs: 928 B 2 allocs: 848 B 1.09 ✅
downconvert and correlate/CPU/Int32 2 allocs: 576 B 2 allocs: 576 B 1.0
fused kernel/1-ant dynamic taps 0 allocs: 0 B 0 allocs: 0 B
fused kernel/1-ant static taps 0 allocs: 0 B 0 allocs: 0 B
fused kernel/4-ant dynamic taps 0 allocs: 0 B 0 allocs: 0 B
fused kernel/4-ant static taps 0 allocs: 0 B 0 allocs: 0 B
fused tuple kernel/1-ant N=2 0 allocs: 0 B 0 allocs: 0 B
fused tuple kernel/1-ant N=3 0 allocs: 0 B 0 allocs: 0 B
fused tuple kernel/2-ant N=2 0 allocs: 0 B 0 allocs: 0 B
fused tuple kernel/2-ant N=3 0 allocs: 0 B 0 allocs: 0 B
fused tuple kernel/4-ant N=2 0 allocs: 0 B 0 allocs: 0 B
fused tuple kernel/4-ant N=3 0 allocs: 0 B 0 allocs: 0 B
track/1. Float32/2K – track 9 allocs: 944 B 9 allocs: 944 B 1.0
track/1. Float32/2K – track! 0 allocs: 0 B 8 allocs: 1216 B 0 ⚠️
track/2. L1 8sat/5K – track 10 allocs: 4656 B 10 allocs: 4656 B 1.0
track/2. L1 8sat/5K – track! 0 allocs: 0 B 0 allocs: 0 B
track/2. L1 8sat/5K – track! Int16 13 allocs: 1056 B 13 allocs: 1056 B 1.0
track/2. L1 8sat/5K – track! OneBit 45 allocs: 2736 B 45 allocs: 2736 B 1.0
track/2. L1 8sat/5K – track!-threaded 0 allocs: 0 B 0 allocs: 0 B
track/2. L1 8sat/5K – track-threaded 10 allocs: 4656 B 10 allocs: 4656 B 1.0
track/3. E1B 4sat/25K – track 10 allocs: 3056 B 10 allocs: 3056 B 1.0
track/3. E1B 4sat/25K – track! 0 allocs: 0 B 0 allocs: 0 B
track/3. E1B 4sat/25K – track! Int16 5 allocs: 416 B 5 allocs: 416 B 1.0
track/3. E1B 4sat/25K – track!-threaded 0 allocs: 0 B 0 allocs: 0 B
track/3. E1B 4sat/25K – track-threaded 10 allocs: 3056 B 10 allocs: 3056 B 1.0
track/4. 8L1+8E1B/25K – track 26 allocs: 10976 B 26 allocs: 10464 B 1.05
track/4. 8L1+8E1B/25K – track! 0 allocs: 0 B 0 allocs: 0 B
track/4. 8L1+8E1B/25K – track!-threaded 0 allocs: 0 B 0 allocs: 0 B
track/4. 8L1+8E1B/25K – track-threaded 26 allocs: 10976 B 26 allocs: 10464 B 1.05
track/5. multi-signal N=1/5K – track 9 allocs: 944 B 9 allocs: 944 B 1.0
track/5. multi-signal N=1/5K – track! 0 allocs: 0 B 24 allocs: 3648 B 0 ⚠️
track/6. multi-signal N=2/5K – track 9 allocs: 1408 B 9 allocs: 1408 B 1.0
track/6. multi-signal N=2/5K – track! 0 allocs: 0 B 48 allocs: 7296 B 0 ⚠️
track/7. multi-signal N=3/5K – track 9 allocs: 1760 B 9 allocs: 1760 B 1.0
track/7. multi-signal N=3/5K – track! 0 allocs: 0 B 72 allocs: 10944 B 0 ⚠️
track/8. L1CA presync 2 blk – track! 7 allocs: 320 B 7 allocs: 320 B 1.0
track/8. L1CA presync 20 blk – track! 7 allocs: 320 B 7 allocs: 320 B 1.0
track/8. L1CA synced 2 blk – track! 7 allocs: 320 B 7 allocs: 320 B 1.0
track/8. L1CA synced 20 blk – track! 7 allocs: 320 B 7 allocs: 320 B 1.0
time_to_load 200 allocs: 14176 B 200 allocs: 14176 B 1.0

@github-actions

Copy link
Copy Markdown
Contributor

Benchmark Results (minimum time) — ubuntu-latest

Reporting the minimum over all samples (robust to shared-runner contention), not the median.

Alternative backends vs Float32 (track!, PR head)

Legend — backends: F32 Float32 (default) · I16 Int16 · 1b OneBit · 2b TwoBit (2-bit measurement + 2-bit carrier). Time columns are the minimum track! time; ×B = F32 / B (so >1 ⇒ backend B is faster than Float32), ✅ ≥ 5 % faster, ⚠️ ≥ 5 % slower. 1b/2b are BPSK-only, so their cells are blank for CBOC (Galileo E1B) scenarios.

Scenario F32 I16 1b 2b ×I16 ×1b ×2b
4-antenna @ 5 MHz 12.2 μs 6.09 μs 5.21 μs 9.83 μs 2.0 ✅ 2.34 ✅ 1.24 ✅
GPS L1CA, 8 sats @ 40 MHz 331.0 μs 131.0 μs 70.4 μs 118.0 μs 2.53 ✅ 4.7 ✅ 2.79 ✅
GPS L1CA, 8 sats @ 5 MHz 49.2 μs 27.9 μs 20.1 μs 27.6 μs 1.76 ✅ 2.44 ✅ 1.78 ✅
Galileo E1B, 4 sats @ 25 MHz 122.0 μs 54.5 μs 2.24 ✅
dynamic taps @ 5 MHz (kernel) 5.77 μs 3.41 μs 2.02 μs 2.79 μs 1.69 ✅ 2.85 ✅ 2.07 ✅
multi-signal N=3 @ 5 MHz 9.56 μs 5.89 μs 5.04 μs 7.26 μs 1.62 ✅ 1.9 ✅ 1.32 ✅
Time benchmarks (base vs PR head)

Ratio = 8f3b080… / dd88497…: >1 means the PR is faster. ✅ ≥ 5 % faster, ⚠️ ≥ 5 % slower. A blank cell means the benchmark exists on only one revision (🆕 = new on the PR, 🗑 = removed).

8f3b080 dd88497 8f3b080… / dd88497
downconvert and correlate/CPU/Float32 2.38 μs 2.33 μs 1.02
downconvert and correlate/CPU/Float32 4ant 4.75 μs 4.71 μs 1.01
downconvert and correlate/CPU/Float64 2.78 μs 2.75 μs 1.01
downconvert and correlate/CPU/Int16 2.45 μs 2.42 μs 1.01
downconvert and correlate/CPU/Int16 4ant 5.26 μs 5.17 μs 1.02
downconvert and correlate/CPU/Int32 2.43 μs 2.4 μs 1.01
fused kernel/1-ant dynamic taps 2.16 μs 2.16 μs 1.0
fused kernel/1-ant static taps 1.88 μs 1.88 μs 1.0
fused kernel/4-ant dynamic taps 5.38 μs 5.31 μs 1.01
fused kernel/4-ant static taps 4.15 μs 4.15 μs 1.0
fused tuple kernel/1-ant N=2 2.35 μs 2.35 μs 1.0
fused tuple kernel/1-ant N=3 2.82 μs 2.88 μs 0.982
fused tuple kernel/2-ant N=2 3.62 μs 3.59 μs 1.01
fused tuple kernel/2-ant N=3 4.58 μs 4.53 μs 1.01
fused tuple kernel/4-ant N=2 6.05 μs 6.04 μs 1.0
fused tuple kernel/4-ant N=3 9.74 μs 9.72 μs 1.0
track/1. Float32/2K – track 2.76 μs 2.55 μs 1.08 ✅
track/1. Float32/2K – track! 2.94 μs 3.5 μs 0.84 ⚠️
track/2. L1 8sat/5K – track 49.3 μs 47.6 μs 1.04
track/2. L1 8sat/5K – track! 48.6 μs 47.2 μs 1.03
track/2. L1 8sat/5K – track! Int16 29.6 μs 28.9 μs 1.02
track/2. L1 8sat/5K – track! OneBit 20.8 μs 20.1 μs 1.03
track/2. L1 8sat/5K – track!-threaded 48.7 μs 47.0 μs 1.04
track/2. L1 8sat/5K – track-threaded 50.3 μs 47.7 μs 1.05 ✅
track/3. E1B 4sat/25K – track 120.0 μs 119.0 μs 1.01
track/3. E1B 4sat/25K – track! 119.0 μs 119.0 μs 1.0
track/3. E1B 4sat/25K – track! Int16 55.6 μs 54.6 μs 1.02
track/3. E1B 4sat/25K – track!-threaded 119.0 μs 118.0 μs 1.01
track/3. E1B 4sat/25K – track-threaded 120.0 μs 119.0 μs 1.01
track/4. 8L1+8E1B/25K – track 438.0 μs 436.0 μs 1.01
track/4. 8L1+8E1B/25K – track! 437.0 μs 436.0 μs 1.0
track/4. 8L1+8E1B/25K – track!-threaded 437.0 μs 434.0 μs 1.01
track/4. 8L1+8E1B/25K – track-threaded 439.0 μs 436.0 μs 1.01
track/5. multi-signal N=1/5K – track 6.32 μs 6.12 μs 1.03
track/5. multi-signal N=1/5K – track! 6.29 μs 8.62 μs 0.73 ⚠️
track/6. multi-signal N=2/5K – track 8.84 μs 8.38 μs 1.06 ✅
track/6. multi-signal N=2/5K – track! 8.93 μs 13.7 μs 0.654 ⚠️
track/7. multi-signal N=3/5K – track 11.4 μs 10.8 μs 1.06 ✅
track/7. multi-signal N=3/5K – track! 11.6 μs 18.5 μs 0.625 ⚠️
track/8. L1CA presync 2 blk – track! 11.9 μs 11.6 μs 1.03
track/8. L1CA presync 20 blk – track! 116.0 μs 113.0 μs 1.02
track/8. L1CA synced 2 blk – track! 11.8 μs 11.5 μs 1.03
track/8. L1CA synced 20 blk – track! 114.0 μs 111.0 μs 1.02
time_to_load 124.0 μs 110.0 μs 1.12 ✅
Memory benchmarks (base vs PR head)

Ratio = 8f3b080… / dd88497… (bytes allocated): >1 means the PR allocates less. ✅ ≥ 5 % less, ⚠️ ≥ 5 % more. /0 mark a benchmark that drops to / picks up allocations, means both revisions allocate nothing. A blank cell means the benchmark exists on only one revision (🆕 = new on the PR, 🗑 = removed).

8f3b080 dd88497 8f3b080… / dd88497
downconvert and correlate/CPU/Float32 2 allocs: 576 B 2 allocs: 576 B 1.0
downconvert and correlate/CPU/Float32 4ant 2 allocs: 928 B 2 allocs: 848 B 1.09 ✅
downconvert and correlate/CPU/Float64 2 allocs: 576 B 2 allocs: 576 B 1.0
downconvert and correlate/CPU/Int16 2 allocs: 576 B 2 allocs: 576 B 1.0
downconvert and correlate/CPU/Int16 4ant 2 allocs: 928 B 2 allocs: 848 B 1.09 ✅
downconvert and correlate/CPU/Int32 2 allocs: 576 B 2 allocs: 576 B 1.0
fused kernel/1-ant dynamic taps 0 allocs: 0 B 0 allocs: 0 B
fused kernel/1-ant static taps 0 allocs: 0 B 0 allocs: 0 B
fused kernel/4-ant dynamic taps 0 allocs: 0 B 0 allocs: 0 B
fused kernel/4-ant static taps 0 allocs: 0 B 0 allocs: 0 B
fused tuple kernel/1-ant N=2 0 allocs: 0 B 0 allocs: 0 B
fused tuple kernel/1-ant N=3 0 allocs: 0 B 0 allocs: 0 B
fused tuple kernel/2-ant N=2 0 allocs: 0 B 0 allocs: 0 B
fused tuple kernel/2-ant N=3 0 allocs: 0 B 0 allocs: 0 B
fused tuple kernel/4-ant N=2 0 allocs: 0 B 0 allocs: 0 B
fused tuple kernel/4-ant N=3 0 allocs: 0 B 0 allocs: 0 B
track/1. Float32/2K – track 9 allocs: 944 B 9 allocs: 944 B 1.0
track/1. Float32/2K – track! 0 allocs: 0 B 8 allocs: 1216 B 0 ⚠️
track/2. L1 8sat/5K – track 10 allocs: 4664 B 10 allocs: 4536 B 1.03
track/2. L1 8sat/5K – track! 0 allocs: 0 B 0 allocs: 0 B
track/2. L1 8sat/5K – track! Int16 13 allocs: 1056 B 13 allocs: 1056 B 1.0
track/2. L1 8sat/5K – track! OneBit 45 allocs: 2736 B 45 allocs: 2736 B 1.0
track/2. L1 8sat/5K – track!-threaded 0 allocs: 0 B 0 allocs: 0 B
track/2. L1 8sat/5K – track-threaded 10 allocs: 4664 B 10 allocs: 4536 B 1.03
track/3. E1B 4sat/25K – track 10 allocs: 2872 B 10 allocs: 2808 B 1.02
track/3. E1B 4sat/25K – track! 0 allocs: 0 B 0 allocs: 0 B
track/3. E1B 4sat/25K – track! Int16 5 allocs: 416 B 5 allocs: 416 B 1.0
track/3. E1B 4sat/25K – track!-threaded 0 allocs: 0 B 0 allocs: 0 B
track/3. E1B 4sat/25K – track-threaded 10 allocs: 2872 B 10 allocs: 2808 B 1.02
track/4. 8L1+8E1B/25K – track 26 allocs: 10608 B 26 allocs: 10352 B 1.02
track/4. 8L1+8E1B/25K – track! 0 allocs: 0 B 0 allocs: 0 B
track/4. 8L1+8E1B/25K – track!-threaded 0 allocs: 0 B 0 allocs: 0 B
track/4. 8L1+8E1B/25K – track-threaded 26 allocs: 10608 B 26 allocs: 10352 B 1.02
track/5. multi-signal N=1/5K – track 9 allocs: 944 B 9 allocs: 944 B 1.0
track/5. multi-signal N=1/5K – track! 0 allocs: 0 B 24 allocs: 3648 B 0 ⚠️
track/6. multi-signal N=2/5K – track 9 allocs: 1408 B 9 allocs: 1408 B 1.0
track/6. multi-signal N=2/5K – track! 0 allocs: 0 B 48 allocs: 7296 B 0 ⚠️
track/7. multi-signal N=3/5K – track 9 allocs: 1760 B 9 allocs: 1760 B 1.0
track/7. multi-signal N=3/5K – track! 0 allocs: 0 B 72 allocs: 10944 B 0 ⚠️
track/8. L1CA presync 2 blk – track! 7 allocs: 320 B 7 allocs: 320 B 1.0
track/8. L1CA presync 20 blk – track! 7 allocs: 320 B 7 allocs: 320 B 1.0
track/8. L1CA synced 2 blk – track! 7 allocs: 320 B 7 allocs: 320 B 1.0
track/8. L1CA synced 20 blk – track! 7 allocs: 320 B 7 allocs: 320 B 1.0
time_to_load 145 allocs: 11216 B 145 allocs: 11216 B 1.0

The soft-decision CFAR bit-edge detector (#124) scores the peak-vs-runner-up
mean-bin-energy gap against a standard-normal quantile, but the standard error
divides by a variance ESTIMATED over `peak_bin_count` bins, so a normal
threshold treats that estimate as exact and is overconfident when the count is
tiny.

At the 2-bin minimum the sample variance can collapse (two similar bins ->
`sum_sq_dev ~ 0` -> `standard_error -> 0` -> `z -> Inf`), and the normal
quantile's ~3.88 value is a ~8% per-phase false alarm at 1 nominal d.o.f. (not
the intended ~1e-4). On a fast reacquisition this let a noise-driven 2-bin
fluctuation lock ~8 code-periods off the true bit edge. Because an edge lock
fewer than 10 blocks off still decodes the correct data bits by 12-vs-8
majority, it passes silently (TOW, parity, IODC all fine), while the code-phase
snap anchors index 0 to the wrong boundary -> a constant ~8 ms (~2400 km)
pseudorange bias that survives until the satellite loses lock. Observed on
TEX-CUP GPS PRN 9 (lock at ~2.6 bins, z=40, C/N0 ~37 dBHz -> a 552 km PVT
excursion); the clean acquisitions locked at ~70/160 bits with z~=3.9.

Threshold `_detect_bit_edge_cfar` with a Student-t quantile at a nominal
`dof = peak_bin_count - 1` as a SMALL-SAMPLE PENALTY -- not a claim that the
z-score is exactly Student-t. Add `_t_quantile`, an exact Student-t quantile via
`SpecialFunctions.beta_inc_inv` (x = beta_inc_inv(dof/2, 1/2, 2*(1-p));
t = sqrt(dof*(1-x)/x)), returning the median 0 directly and reflecting the lower
tail by symmetry (which also avoids a 1-0.5 self-recursion / stack overflow).
It is the right SHAPE of correction -- steep as dof -> 1, relaxing to the normal
quantile as dof -> inf (no cutoff; mature many-bin locks unchanged) -- but the
nominal d.o.f. is a heuristic: the per-bin values are (non-central) chi-square
energies rather than Gaussian and the competing phases share blocks
(correlated), so the exact sampling distribution is neither normal nor
Student-t and the realised false-alarm rate is only approximately the nominal
one. Used as a conservative, integration-forcing penalty, not an exact
calibration: it demands z ~ 6000 at 1 d.o.f. and ~4.1 at 70 bins. It can only
ever raise the threshold, so a clean lock is never advanced or corrupted, only
(slightly) delayed. The now-unused `_norm_quantile` (and its sole-purpose
`erfinv` import) is removed -- the CFAR was its only caller.

Scope: affects GPS L1 C/A only -- the sole signal with
`uses_soft_bit_edge_detection` (multi-block bit, no secondary code). The
secondary-code and immediate-lock detectors are untouched.

Tests: add a `_t_quantile` testset (median/recursion regression, dof=1 Cauchy
values, symmetry, tail monotonicity, convergence to a tabulated normal limit)
and drop the `_norm_quantile` testset. Re-baseline the lock-latency expectations
to the higher threshold: lengthen the two `bit_buffer` CFAR-latency streams, and
update the two `bit_integration_test` L1 C/A testsets, where the near-noiseless
signal now locks at block 60 (3 bins) rather than 40 (2 bins) -- still a true
bit boundary (the #124 property), just one bit later.

Validated on the TEX-CUP ntlab.bin capture: all visible GPS L1 C/A satellites
still lock (self-pacing by C/N0, ~8-149 bits; PRN 9 clean lock 74 vs 70 bits),
and the full ~4957 s recording (44,792 PVT epochs) runs with zero gross
outliers (max pseudorange residual 78.9 m) where the unfixed receiver produced
the 785,699 m / 552 km PRN 9 outlier. Full Tracking test suite passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@zsoerenm
zsoerenm force-pushed the sc/cfar-t-quantile branch from dd88497 to 49e8808 Compare July 24, 2026 07:06
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.

3 participants