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 | 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,
| 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, ∞/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 |
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.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,
| 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, ∞/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>
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.