fix(gpsl1ca): Student-t small-sample threshold in CFAR bit-edge detector#208
fix(gpsl1ca): Student-t small-sample threshold in CFAR bit-edge detector#208giove-a wants to merge 1 commit into
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
Benchmark Results (minimum time) — macos-14Reporting the minimum over all samples (robust to shared-runner contention), not the median. Alternative backends vs Float32 (
|
| 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,
| 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, ∞/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 |
Benchmark Results (minimum time) — ubuntu-latestReporting the minimum over all samples (robust to shared-runner contention), not the median. Alternative backends vs Float32 (
|
| 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,
| 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, ∞/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>
dd88497 to
49e8808
Compare
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 − 1d.o.f., used as a small-sample penalty that forces enoughintegration before a lock.
Root cause
A lock is accepted when
z = energy_gap / standard_error ≥ Φ⁻¹(…), butstandard_errordivides by a variance estimated overpeak_bin_countbins.Treating that estimate as exact (the normal quantile) is badly overconfident at
the 2-bin minimum:
sum_sq_dev → 0 ⇒ SE → 0 ⇒ z → ∞), and≈ 3.9is a ~8 % per-phase false alarm at 1 nominald.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:
The bias survived ~24 s until PRN 9 next dropped lock.
The fix
_t_quantile(quantile_argument, peak_bin_count − 1)— an exactStudent-t quantile via
SpecialFunctions.beta_inc_inv(returning the mediandirectly and the lower tail by symmetry, which also avoids a
1 − 0.5self-recursion). It demands
z ≈ 6000at 1 d.o.f. and relaxes to the normalquantile as bins accumulate (no cutoff), so mature clean locks are unchanged.
The per-bin values are χ² (non-central) energies, not Gaussian, and the competing
phases share blocks (correlated), so
peak_bin_count − 1is a nominal d.o.f.and the realised false-alarm rate is only approximately the nominal one.
_norm_quantileand its sole-purposeerfinvimport.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
PRN 9 clean lock 74 vs 70 bits).
Trackingtest suite passes, incl. Aqua and the format check.Tests
_t_quantiletestset (median/recursion regression, dof=1 Cauchy values,symmetry, tail monotonicity, convergence to a tabulated normal limit);
_norm_quantiletestset dropped.bit_bufferCFAR-latency streams are lengthened, and the twobit_integration_testL1 C/A testsets now expect the near-noiseless signal tolock 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.