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

github-actions Bot commented Jul 24, 2026

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 11.8 μs 6.3 μs 4.71 μs 10.1 μs 1.88 ✅ 2.51 ✅ 1.17 ✅
GPS L1CA, 8 sats @ 40 MHz 370.0 μs 117.0 μs 58.9 μs 110.0 μs 3.15 ✅ 6.27 ✅ 3.35 ✅
GPS L1CA, 8 sats @ 5 MHz, 100 ms buffer 5.1 ms 1.88 ms 1.21 ms 1.95 ms 2.71 ✅ 4.22 ✅ 2.62 ✅
GPS L1CA, 8 sats @ 5 MHz 54.7 μs 22.2 μs 16.3 μs 24.5 μs 2.47 ✅ 3.36 ✅ 2.23 ✅
Galileo E1B, 4 sats @ 25 MHz 142.0 μs 59.6 μs 2.38 ✅
dynamic taps @ 5 MHz (kernel) 6.68 μs 2.28 μs 1.51 μs 2.64 μs 2.92 ✅ 4.41 ✅ 2.53 ✅
multi-signal N=3 @ 5 MHz 11.2 μs 5.32 μs 4.43 μs 7.18 μs 2.11 ✅ 2.53 ✅ 1.56 ✅
Time benchmarks (base vs PR head)

Ratio = 18aee64… / 49e8808…: >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).

18aee64 49e8808 18aee64… / 49e8808
downconvert and correlate/CPU/Float32 2.62 μs 2.53 μs 1.04
downconvert and correlate/CPU/Float32 4ant 4.93 μs 4.93 μs 1.0
downconvert and correlate/CPU/Float64 2.83 μs 2.73 μs 1.04
downconvert and correlate/CPU/Int16 2.75 μs 2.66 μs 1.03
downconvert and correlate/CPU/Int16 4ant 4.88 μs 4.89 μs 0.999
downconvert and correlate/CPU/Int32 2.76 μs 2.59 μs 1.06 ✅
fused kernel/1-ant dynamic taps 2.56 μs 2.56 μs 1.0
fused kernel/1-ant static taps 2.06 μs 2.06 μs 1.0
fused kernel/4-ant dynamic taps 7.77 μs 7.78 μs 0.999
fused kernel/4-ant static taps 4.43 μs 4.43 μs 1.0
fused tuple kernel/1-ant N=2 3.27 μs 3.27 μs 1.0
fused tuple kernel/1-ant N=3 3.4 μs 3.4 μs 1.0
fused tuple kernel/2-ant N=2 6.29 μs 6.3 μs 0.999
fused tuple kernel/2-ant N=3 5.62 μs 5.61 μs 1.0
fused tuple kernel/4-ant N=2 11.6 μs 11.5 μs 1.01
fused tuple kernel/4-ant N=3 10.1 μs 10.0 μs 1.0
track/1. Float32/2K – track 2.83 μs 2.85 μs 0.993
track/1. Float32/2K – track! 3.08 μs 3.71 μs 0.83 ⚠️
track/2. L1 8sat/5K – track 51.5 μs 51.0 μs 1.01
track/2. L1 8sat/5K – track! 52.7 μs 50.7 μs 1.04
track/2. L1 8sat/5K – track! Int16 21.9 μs 22.0 μs 0.994
track/2. L1 8sat/5K – track! OneBit 16.8 μs 16.2 μs 1.04
track/2. L1 8sat/5K – track!-threaded 52.5 μs 50.8 μs 1.03
track/2. L1 8sat/5K – track-threaded 51.1 μs 50.9 μs 1.0
track/3. E1B 4sat/25K – track 142.0 μs 136.0 μs 1.04
track/3. E1B 4sat/25K – track! 142.0 μs 136.0 μs 1.04
track/3. E1B 4sat/25K – track! Int16 59.2 μs 59.4 μs 0.997
track/3. E1B 4sat/25K – track!-threaded 136.0 μs 136.0 μs 0.999
track/3. E1B 4sat/25K – track-threaded 136.0 μs 137.0 μs 0.999
track/4. 8L1+8E1B/25K – track 492.0 μs 492.0 μs 1.0
track/4. 8L1+8E1B/25K – track! 491.0 μs 490.0 μs 1.0
track/4. 8L1+8E1B/25K – track!-threaded 525.0 μs 491.0 μs 1.07 ✅
track/4. 8L1+8E1B/25K – track-threaded 492.0 μs 492.0 μs 1.0
track/5. multi-signal N=1/5K – track 7.1 μs 6.62 μs 1.07 ✅
track/5. multi-signal N=1/5K – track! 6.62 μs 8.58 μs 0.771 ⚠️
track/6. multi-signal N=2/5K – track 11.6 μs 10.8 μs 1.07 ✅
track/6. multi-signal N=2/5K – track! 11.2 μs 14.5 μs 0.771 ⚠️
track/7. L1 8sat/500K – track! Int16 1.88 ms 1.88 ms 1.0
track/7. L1 8sat/500K – track! OneBit 1.3 ms 1.21 ms 1.07 ✅
track/7. L1 8sat/500K – track! TwoBit 1.95 ms 1.99 ms 0.982
track/7. multi-signal N=3/5K – track 12.5 μs 12.0 μs 1.05
track/7. multi-signal N=3/5K – track! 13.1 μs 17.8 μs 0.737 ⚠️
track/8. L1CA presync 2 blk – track! 12.8 μs 12.9 μs 0.997
track/8. L1CA presync 20 blk – track! 123.0 μs 123.0 μs 0.999
track/8. L1CA synced 2 blk – track! 13.2 μs 12.7 μs 1.04
track/8. L1CA synced 20 blk – track! 122.0 μs 122.0 μs 1.0
time_to_load 175.0 μs 166.0 μs 1.05 ✅
Memory benchmarks (base vs PR head)

Ratio = 18aee64… / 49e8808… (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).

18aee64 49e8808 18aee64… / 49e8808
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: 928 B 1.0
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: 928 B 1.0
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 9 allocs: 1368 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: 10976 B 1.0
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: 10976 B 1.0
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. L1 8sat/500K – track! Int16 61 allocs: 28704 B 61 allocs: 28704 B 1.0
track/7. L1 8sat/500K – track! OneBit 1677 allocs: 119088 B 1677 allocs: 119088 B 1.0
track/7. L1 8sat/500K – track! TwoBit 1677 allocs: 119136 B 1677 allocs: 119136 B 1.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 196 allocs: 13984 B 196 allocs: 13984 B 1.0

@github-actions

github-actions Bot commented Jul 24, 2026

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.0 μs 7.26 μs 5.84 μs 10.9 μs 1.66 ✅ 2.06 ✅ 1.1 ✅
GPS L1CA, 8 sats @ 40 MHz 338.0 μs 146.0 μs 64.0 μs 116.0 μs 2.33 ✅ 5.29 ✅ 2.92 ✅
GPS L1CA, 8 sats @ 5 MHz, 100 ms buffer 4.78 ms 2.58 ms 1.47 ms 2.2 ms 1.85 ✅ 3.25 ✅ 2.17 ✅
GPS L1CA, 8 sats @ 5 MHz 51.7 μs 29.2 μs 19.4 μs 27.3 μs 1.77 ✅ 2.67 ✅ 1.89 ✅
Galileo E1B, 4 sats @ 25 MHz 127.0 μs 59.0 μs 2.15 ✅
dynamic taps @ 5 MHz (kernel) 6.23 μs 3.51 μs 1.77 μs 2.69 μs 1.77 ✅ 3.52 ✅ 2.32 ✅
multi-signal N=3 @ 5 MHz 11.0 μs 6.67 μs 5.72 μs 8.44 μs 1.64 ✅ 1.92 ✅ 1.3 ✅
Time benchmarks (base vs PR head)

Ratio = 18aee64… / 49e8808…: >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).

18aee64 49e8808 18aee64… / 49e8808
downconvert and correlate/CPU/Float32 2.4 μs 2.52 μs 0.955
downconvert and correlate/CPU/Float32 4ant 4.43 μs 4.44 μs 0.998
downconvert and correlate/CPU/Float64 2.79 μs 2.8 μs 0.997
downconvert and correlate/CPU/Int16 2.53 μs 2.51 μs 1.01
downconvert and correlate/CPU/Int16 4ant 4.9 μs 4.96 μs 0.989
downconvert and correlate/CPU/Int32 2.44 μs 2.56 μs 0.954
fused kernel/1-ant dynamic taps 2.29 μs 2.29 μs 1.0
fused kernel/1-ant static taps 1.91 μs 1.91 μs 0.998
fused kernel/4-ant dynamic taps 5.62 μs 6.0 μs 0.937 ⚠️
fused kernel/4-ant static taps 3.87 μs 3.87 μs 0.999
fused tuple kernel/1-ant N=2 2.57 μs 2.57 μs 1.0
fused tuple kernel/1-ant N=3 3.09 μs 3.07 μs 1.01
fused tuple kernel/2-ant N=2 3.92 μs 3.93 μs 0.997
fused tuple kernel/2-ant N=3 4.97 μs 4.99 μs 0.997
fused tuple kernel/4-ant N=2 6.46 μs 6.49 μs 0.996
fused tuple kernel/4-ant N=3 10.2 μs 10.1 μs 1.01
track/1. Float32/2K – track 2.84 μs 2.84 μs 1.0
track/1. Float32/2K – track! 2.91 μs 3.68 μs 0.792 ⚠️
track/2. L1 8sat/5K – track 49.7 μs 50.1 μs 0.992
track/2. L1 8sat/5K – track! 48.8 μs 49.1 μs 0.993
track/2. L1 8sat/5K – track! Int16 29.2 μs 28.7 μs 1.02
track/2. L1 8sat/5K – track! OneBit 18.8 μs 19.0 μs 0.993
track/2. L1 8sat/5K – track!-threaded 49.0 μs 49.3 μs 0.993
track/2. L1 8sat/5K – track-threaded 49.6 μs 49.9 μs 0.995
track/3. E1B 4sat/25K – track 122.0 μs 122.0 μs 1.0
track/3. E1B 4sat/25K – track! 122.0 μs 121.0 μs 1.0
track/3. E1B 4sat/25K – track! Int16 60.6 μs 59.1 μs 1.02
track/3. E1B 4sat/25K – track!-threaded 121.0 μs 122.0 μs 0.998
track/3. E1B 4sat/25K – track-threaded 122.0 μs 122.0 μs 1.0
track/4. 8L1+8E1B/25K – track 446.0 μs 446.0 μs 1.0
track/4. 8L1+8E1B/25K – track! 444.0 μs 443.0 μs 1.0
track/4. 8L1+8E1B/25K – track!-threaded 442.0 μs 443.0 μs 0.997
track/4. 8L1+8E1B/25K – track-threaded 446.0 μs 446.0 μs 0.999
track/5. multi-signal N=1/5K – track 6.42 μs 6.4 μs 1.0
track/5. multi-signal N=1/5K – track! 6.39 μs 8.88 μs 0.719 ⚠️
track/6. multi-signal N=2/5K – track 9.51 μs 9.59 μs 0.992
track/6. multi-signal N=2/5K – track! 9.52 μs 14.8 μs 0.644 ⚠️
track/7. L1 8sat/500K – track! Int16 2.38 ms 2.43 ms 0.981
track/7. L1 8sat/500K – track! OneBit 1.45 ms 1.45 ms 1.0
track/7. L1 8sat/500K – track! TwoBit 2.21 ms 2.24 ms 0.99
track/7. multi-signal N=3/5K – track 12.1 μs 12.2 μs 0.989
track/7. multi-signal N=3/5K – track! 12.3 μs 20.6 μs 0.597 ⚠️
track/8. L1CA presync 2 blk – track! 12.0 μs 12.1 μs 0.992
track/8. L1CA presync 20 blk – track! 115.0 μs 117.0 μs 0.986
track/8. L1CA synced 2 blk – track! 11.9 μs 12.0 μs 0.993
track/8. L1CA synced 20 blk – track! 113.0 μs 115.0 μs 0.987
time_to_load 121.0 μs 125.0 μs 0.969
Memory benchmarks (base vs PR head)

Ratio = 18aee64… / 49e8808… (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).

18aee64 49e8808 18aee64… / 49e8808
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: 928 B 1.0
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: 928 B 1.0
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: 4664 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: 4664 B 10 allocs: 4664 B 1.0
track/3. E1B 4sat/25K – track 10 allocs: 2872 B 10 allocs: 2872 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: 2872 B 10 allocs: 2872 B 1.0
track/4. 8L1+8E1B/25K – track 26 allocs: 10608 B 26 allocs: 10608 B 1.0
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: 10608 B 1.0
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. L1 8sat/500K – track! Int16 61 allocs: 25696 B 61 allocs: 25696 B 1.0
track/7. L1 8sat/500K – track! OneBit 1677 allocs: 116080 B 1677 allocs: 116080 B 1.0
track/7. L1 8sat/500K – track! TwoBit 1677 allocs: 116128 B 1677 allocs: 116128 B 1.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