Skip to content

feat(track): per-chunk Doppler update with collected correlator outputs#205

Merged
zsoerenm merged 14 commits into
masterfrom
estimate-doppler-timing
Jul 23, 2026
Merged

feat(track): per-chunk Doppler update with collected correlator outputs#205
zsoerenm merged 14 commits into
masterfrom
estimate-doppler-timing

Conversation

@zsoerenm

@zsoerenm zsoerenm commented Jul 21, 2026

Copy link
Copy Markdown
Member

Summary

Decouples the Doppler-estimation (NCO-update) rate from the code period. track / track! now walk each measurement in fixed-size time chunks; every completed correlator output is collected — tagged with the sample index at which it was taken — into a per-signal buffer. After the chunk's completions the estimator folds over those outputs in order and updates every satellite's NCO once, at a common epoch; the chunk's trailing partial (the head of the next integration) is then integrated with the freshly updated Doppler.

Motivation:

  • Trade Doppler-estimation compute for update rate via a new doppler_update_interval kwarg (a time; default nothing ⇒ smallest code period across all signals).
  • Give vector tracking a batch of sample-indexed correlator outputs on a common time grid.
  • Synchronize NCO updates across satellites (previously staggered per code period).

What changed

  • Data model — new CorrelatorOutput{C} (correlator, integrated_samples, sample_index); TrackedSignal gains a preallocated, reused correlator_outputs buffer. Both exported. Removed the now-unused is_integration_completed field.
  • Correlate phase_update_tracked_sat_correlator inner-loops over the chunk; update/_build_new_signals snapshot a record and reset the accumulator on each completion. Chunk end is a per-band, drift-free time grid; replica sizing still uses the true buffer length. The per-sat chunk loop lives once in the generic AbstractDownconvertAndCorrelator method — the backend boundary is _correlate_signals (CPU-fused, Int16, one-bit, two-bit; single + threaded); the @generated kernels are untouched.
  • Two passes per chunktrack! first correlates only the completions (stop_before_partial = true, stopping at the last code-block boundary), runs the estimator, then integrates the residue with the updated NCO Doppler. Every completed integration is therefore produced by a single Doppler and each correction takes effect right at its completing boundary — the classic per-completion loop timing — while the NCO is still written once per chunk at a common epoch. A trailing fold after the last chunk catches the rare completion the updated code Doppler pulls back inside the final residue. For direct downconvert_and_correlate(!) callers stop_before_partial defaults to false (consume the whole window, as before).
  • Estimate phase — folds over correlator_outputs, threading loop-filter state + FLL previous_prompt per record (per-record integration_time / 1/N), writing the NCO once. Preserves the QPSK driver_carrier_phase de-rotation. The sync phase-snap also resets the in-flight accumulator (fixes a 0/0 → NaN at NH-chip sync), and records that follow a mid-fold sync detection are applied with skip_bit_buffer = true — they were correlated with pre-sync replicas (no secondary-code wipe-off), so they stay out of the bit buffer and re-enter cleanly next chunk (matters only for enlarged doppler_update_intervals).
  • track!/track — new doppler_update_interval kwarg, chunked two-pass loop, validated one-sample floor, and a clear ArgumentError when doppler_update_interval is passed without time units.
  • Docs — design plan under docs/plans/, plus track.md / custom_doppler_estimator.md / correlator.md; the track docstring documents that correlator_outputs (like filtered_prompts) is a shared scratch buffer between input and returned state.

Behavioral compatibility

With the two-pass chunk, edge behavior matches the previous per-code-period loop: the FLL-assisted pull-in edge sits at its historical 240 Hz (converges) / 250 Hz (fails), and the 1800-block L1C-P overlay sync locks after exactly one overlay cycle — the test suite keeps its original assertions. (An earlier single-pass design integrated the chunk residue at the pre-update Doppler, which tightened the FLL edge to ~210 Hz and could land the overlay sync one block late; the two-pass split removed both deltas.)

Testing

  • Full Pkg.test() passes; docs build cleanly; JuliaFormatter 2.8.5 clean.
  • New test/doppler_update_interval.jl: chunk-grid arithmetic, default resolution, validation (including the units error message), multi-record collection with sample indices, output clearing, end-to-end convergence with an enlarged interval.
  • The strict full-track! allocation test (measure_track_alloc) passes at 0 bytes with chunking.

🤖 Generated with Claude Code

@zsoerenm
zsoerenm force-pushed the estimate-doppler-timing branch from b596735 to 428f038 Compare July 21, 2026 13:48
@codecov

codecov Bot commented Jul 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.09910% with 1 line in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (master@fb38c1b). Learn more about missing BASE report.

Files with missing lines Patch % Lines
src/track.jl 96.77% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff            @@
##             master     #205   +/-   ##
=========================================
  Coverage          ?   98.24%           
=========================================
  Files             ?       32           
  Lines             ?     3353           
  Branches          ?        0           
=========================================
  Hits              ?     3294           
  Misses            ?       59           
  Partials          ?        0           

☔ 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 21, 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 12.7 μs 6.26 μs 5.08 μs 10.5 μs 2.03 ✅ 2.5 ✅ 1.22 ✅
GPS L1CA, 8 sats @ 40 MHz 369.0 μs 117.0 μs 57.3 μs 108.0 μs 3.14 ✅ 6.43 ✅ 3.4 ✅
GPS L1CA, 8 sats @ 5 MHz, 100 ms buffer 5.07 ms 1.87 ms 1.2 ms 1.95 ms 2.71 ✅ 4.23 ✅ 2.6 ✅
GPS L1CA, 8 sats @ 5 MHz 56.1 μs 21.9 μs 16.2 μs 24.4 μs 2.56 ✅ 3.45 ✅ 2.3 ✅
Galileo E1B, 4 sats @ 25 MHz 142.0 μs 59.8 μs 2.37 ✅
dynamic taps @ 5 MHz (kernel) 6.82 μs 2.45 μs 1.57 μs 2.61 μs 2.78 ✅ 4.34 ✅ 2.62 ✅
multi-signal N=3 @ 5 MHz 11.2 μs 5.33 μs 4.48 μs 7.71 μs 2.1 ✅ 2.5 ✅ 1.45 ✅
Time benchmarks (base vs PR head)

Ratio = fb38c1b… / e0a2097…: >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).

fb38c1b e0a2097 fb38c1b… / e0a2097
downconvert and correlate/CPU/Float32 2.69 μs 2.59 μs 1.04
downconvert and correlate/CPU/Float32 4ant 5.23 μs 5.12 μs 1.02
downconvert and correlate/CPU/Float64 2.91 μs 2.78 μs 1.04
downconvert and correlate/CPU/Int16 2.74 μs 2.72 μs 1.01
downconvert and correlate/CPU/Int16 4ant 4.83 μs 4.88 μs 0.99
downconvert and correlate/CPU/Int32 2.58 μs 2.64 μs 0.977
fused kernel/1-ant dynamic taps 2.56 μs 2.61 μs 0.98
fused kernel/1-ant static taps 2.06 μs 2.13 μs 0.965
fused kernel/4-ant dynamic taps 7.81 μs 8.05 μs 0.97
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 0.998
fused tuple kernel/1-ant N=3 3.4 μs 3.41 μs 0.998
fused tuple kernel/2-ant N=2 6.31 μs 6.31 μs 1.0
fused tuple kernel/2-ant N=3 5.61 μs 5.62 μs 0.998
fused tuple kernel/4-ant N=2 11.5 μs 12.0 μs 0.965
fused tuple kernel/4-ant N=3 10.1 μs 10.1 μs 0.996
track/1. Float32/2K – track 2.67 μs 2.83 μs 0.943 ⚠️
track/1. Float32/2K – track! 2.83 μs 3.0 μs 0.943 ⚠️
track/2. L1 8sat/5K – track 51.3 μs 54.8 μs 0.938 ⚠️
track/2. L1 8sat/5K – track! 49.4 μs 54.5 μs 0.907 ⚠️
track/2. L1 8sat/5K – track! Int16 23.2 μs 23.2 μs 1.0
track/2. L1 8sat/5K – track! OneBit 16.2 μs 17.8 μs 0.911 ⚠️
track/2. L1 8sat/5K – track!-threaded 49.8 μs 54.5 μs 0.914 ⚠️
track/2. L1 8sat/5K – track-threaded 50.1 μs 54.6 μs 0.918 ⚠️
track/3. E1B 4sat/25K – track 140.0 μs 141.0 μs 0.994
track/3. E1B 4sat/25K – track! 140.0 μs 141.0 μs 0.994
track/3. E1B 4sat/25K – track! Int16 58.2 μs 59.5 μs 0.978
track/3. E1B 4sat/25K – track!-threaded 135.0 μs 146.0 μs 0.927 ⚠️
track/3. E1B 4sat/25K – track-threaded 138.0 μs 139.0 μs 0.991
track/4. 8L1+8E1B/25K – track 507.0 μs 510.0 μs 0.994
track/4. 8L1+8E1B/25K – track! 489.0 μs 490.0 μs 0.997
track/4. 8L1+8E1B/25K – track!-threaded 489.0 μs 526.0 μs 0.928 ⚠️
track/4. 8L1+8E1B/25K – track-threaded 490.0 μs 509.0 μs 0.962
track/5. multi-signal N=1/5K – track 6.41 μs 6.61 μs 0.97
track/5. multi-signal N=1/5K – track! 6.39 μs 6.82 μs 0.936 ⚠️
track/6. multi-signal N=2/5K – track 10.5 μs 10.7 μs 0.98
track/6. multi-signal N=2/5K – track! 11.2 μs 11.6 μs 0.968
track/7. L1 8sat/500K – track! Int16 1.96 ms 2.06 ms 0.949 ⚠️
track/7. L1 8sat/500K – track! OneBit 1.16 ms 1.33 ms 0.876 ⚠️
track/7. L1 8sat/500K – track! TwoBit 1.92 ms 2.07 ms 0.928 ⚠️
track/7. multi-signal N=3/5K – track 12.0 μs 12.9 μs 0.929 ⚠️
track/7. multi-signal N=3/5K – track! 12.0 μs 12.6 μs 0.954
track/8. L1CA presync 2 blk – track! 12.5 μs 13.7 μs 0.912 ⚠️
track/8. L1CA presync 20 blk – track! 121.0 μs 132.0 μs 0.918 ⚠️
track/8. L1CA synced 2 blk – track! 12.8 μs 13.6 μs 0.936 ⚠️
track/8. L1CA synced 20 blk – track! 119.0 μs 131.0 μs 0.911 ⚠️
time_to_load 3.73 ms 156.0 μs 24.0 ✅
Memory benchmarks (base vs PR head)

Ratio = fb38c1b… / e0a2097… (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).

fb38c1b e0a2097 fb38c1b… / e0a2097
downconvert and correlate/CPU/Float32 2 allocs: 576 B 2 allocs: 576 B 1.0
downconvert and correlate/CPU/Float32 4ant 2 allocs: 848 B 2 allocs: 928 B 0.914 ⚠️
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: 848 B 2 allocs: 928 B 0.914 ⚠️
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 0 allocs: 0 B
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: 10464 B 26 allocs: 10976 B 0.953
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: 10464 B 26 allocs: 10976 B 0.953
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 0 allocs: 0 B
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 0 allocs: 0 B
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 0 allocs: 0 B
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 21, 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.34 μs 5.91 μs 10.8 μs 1.64 ✅ 2.04 ✅ 1.11 ✅
GPS L1CA, 8 sats @ 40 MHz 338.0 μs 136.0 μs 63.8 μs 116.0 μs 2.49 ✅ 5.3 ✅ 2.92 ✅
GPS L1CA, 8 sats @ 5 MHz, 100 ms buffer 4.78 ms 2.41 ms 1.45 ms 2.41 ms 1.98 ✅ 3.31 ✅ 1.98 ✅
GPS L1CA, 8 sats @ 5 MHz 51.7 μs 28.8 μs 18.8 μs 27.5 μs 1.79 ✅ 2.75 ✅ 1.88 ✅
Galileo E1B, 4 sats @ 25 MHz 126.0 μs 59.9 μs 2.11 ✅
dynamic taps @ 5 MHz (kernel) 6.31 μs 3.55 μs 1.77 μs 2.66 μs 1.78 ✅ 3.55 ✅ 2.37 ✅
multi-signal N=3 @ 5 MHz 10.8 μs 6.54 μs 5.73 μs 8.35 μs 1.66 ✅ 1.89 ✅ 1.3 ✅
Time benchmarks (base vs PR head)

Ratio = fb38c1b… / e0a2097…: >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).

fb38c1b e0a2097 fb38c1b… / e0a2097
downconvert and correlate/CPU/Float32 2.37 μs 2.41 μs 0.982
downconvert and correlate/CPU/Float32 4ant 4.41 μs 4.43 μs 0.996
downconvert and correlate/CPU/Float64 2.77 μs 2.8 μs 0.991
downconvert and correlate/CPU/Int16 2.47 μs 2.5 μs 0.987
downconvert and correlate/CPU/Int16 4ant 4.86 μs 4.92 μs 0.989
downconvert and correlate/CPU/Int32 2.4 μs 2.43 μs 0.989
fused kernel/1-ant dynamic taps 2.29 μs 2.3 μs 0.997
fused kernel/1-ant static taps 1.91 μs 1.91 μs 0.999
fused kernel/4-ant dynamic taps 5.73 μs 5.48 μs 1.04
fused kernel/4-ant static taps 3.88 μs 3.87 μs 1.0
fused tuple kernel/1-ant N=2 2.56 μs 2.63 μs 0.976
fused tuple kernel/1-ant N=3 3.07 μs 3.13 μs 0.98
fused tuple kernel/2-ant N=2 3.94 μs 3.92 μs 1.0
fused tuple kernel/2-ant N=3 4.97 μs 5.0 μs 0.995
fused tuple kernel/4-ant N=2 6.43 μs 6.46 μs 0.994
fused tuple kernel/4-ant N=3 10.2 μs 10.2 μs 1.0
track/1. Float32/2K – track 2.57 μs 2.81 μs 0.916 ⚠️
track/1. Float32/2K – track! 2.68 μs 2.88 μs 0.933 ⚠️
track/2. L1 8sat/5K – track 48.2 μs 49.8 μs 0.967
track/2. L1 8sat/5K – track! 47.3 μs 48.9 μs 0.968
track/2. L1 8sat/5K – track! Int16 26.6 μs 28.4 μs 0.938 ⚠️
track/2. L1 8sat/5K – track! OneBit 17.4 μs 19.2 μs 0.903 ⚠️
track/2. L1 8sat/5K – track!-threaded 47.5 μs 49.1 μs 0.967
track/2. L1 8sat/5K – track-threaded 48.5 μs 49.7 μs 0.976
track/3. E1B 4sat/25K – track 121.0 μs 122.0 μs 0.992
track/3. E1B 4sat/25K – track! 121.0 μs 121.0 μs 0.998
track/3. E1B 4sat/25K – track! Int16 59.0 μs 59.5 μs 0.992
track/3. E1B 4sat/25K – track!-threaded 121.0 μs 122.0 μs 0.991
track/3. E1B 4sat/25K – track-threaded 121.0 μs 122.0 μs 0.992
track/4. 8L1+8E1B/25K – track 444.0 μs 445.0 μs 0.998
track/4. 8L1+8E1B/25K – track! 440.0 μs 443.0 μs 0.994
track/4. 8L1+8E1B/25K – track!-threaded 441.0 μs 444.0 μs 0.993
track/4. 8L1+8E1B/25K – track-threaded 443.0 μs 445.0 μs 0.995
track/5. multi-signal N=1/5K – track 6.16 μs 6.38 μs 0.966
track/5. multi-signal N=1/5K – track! 6.16 μs 6.35 μs 0.971
track/6. multi-signal N=2/5K – track 8.9 μs 9.43 μs 0.943 ⚠️
track/6. multi-signal N=2/5K – track! 9.06 μs 9.51 μs 0.953
track/7. L1 8sat/500K – track! Int16 2.23 ms 2.47 ms 0.904 ⚠️
track/7. L1 8sat/500K – track! OneBit 1.33 ms 1.46 ms 0.905 ⚠️
track/7. L1 8sat/500K – track! TwoBit 2.09 ms 2.38 ms 0.879 ⚠️
track/7. multi-signal N=3/5K – track 11.3 μs 12.2 μs 0.925 ⚠️
track/7. multi-signal N=3/5K – track! 11.7 μs 12.3 μs 0.947 ⚠️
track/8. L1CA presync 2 blk – track! 11.6 μs 12.0 μs 0.968
track/8. L1CA presync 20 blk – track! 112.0 μs 116.0 μs 0.966
track/8. L1CA synced 2 blk – track! 11.5 μs 11.9 μs 0.965
track/8. L1CA synced 20 blk – track! 111.0 μs 115.0 μs 0.966
time_to_load 122.0 μs 121.0 μs 1.01
Memory benchmarks (base vs PR head)

Ratio = fb38c1b… / e0a2097… (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).

fb38c1b e0a2097 fb38c1b… / e0a2097
downconvert and correlate/CPU/Float32 2 allocs: 576 B 2 allocs: 576 B 1.0
downconvert and correlate/CPU/Float32 4ant 2 allocs: 848 B 2 allocs: 928 B 0.914 ⚠️
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: 848 B 2 allocs: 928 B 0.914 ⚠️
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 0 allocs: 0 B
track/2. L1 8sat/5K – track 10 allocs: 4536 B 10 allocs: 4664 B 0.973
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: 4536 B 10 allocs: 4664 B 0.973
track/3. E1B 4sat/25K – track 10 allocs: 2808 B 10 allocs: 2872 B 0.978
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: 2808 B 10 allocs: 2872 B 0.978
track/4. 8L1+8E1B/25K – track 26 allocs: 10352 B 26 allocs: 10608 B 0.976
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: 10352 B 26 allocs: 10608 B 0.976
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 0 allocs: 0 B
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 0 allocs: 0 B
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 0 allocs: 0 B
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

zsoerenm and others added 11 commits July 22, 2026 13:17
Process each measurement in fixed-size time chunks. Within a chunk the NCO
Doppler is held fixed and every completed correlator output is collected into a
per-signal buffer, each tagged with the sample index at which it was taken; the
estimator then folds over them in order and updates every satellite's NCO once,
at a common epoch.

- New `CorrelatorOutput{C}` and a reused `TrackedSignal.correlator_outputs`
  buffer; drop the now-unused `is_integration_completed` field.
- New `update_interval` kwarg on track/track! (time; default = smallest code
  period across all signals). Chunk boundaries lie on a drift-free per-band time
  grid.
- Correlate phase inner-loops over the chunk and snapshots/records completions;
  estimator fold threads loop-filter state + FLL previous_prompt per record and
  writes the NCO once. Sync phase-snap resets the in-flight accumulator.
- Applied across all four correlate backends (cpu/int16/onebit/twobit); the
  @generated kernels are untouched.

Default behavior is numerically very close but not bit-identical to the old
per-code-period update; the FLL pull-in edge and a long overlay-sync test are
recalibrated. (Both are restored to their historical values by the follow-up
two-pass-chunk commit in this PR.) New test/update_interval.jl covers the
feature. Applied JuliaFormatter 2.8.5.

BREAKING CHANGE: Custom `AbstractDopplerEstimator` implementations must now
fold over each signal's `correlator_outputs` (a `Vector{CorrelatorOutput}`,
to be `empty!`-ed when consumed) instead of checking the removed
`TrackedSignal.is_integration_completed` flag and reading the live
correlator. `TrackedSignal` lost the `is_integration_completed` field and
gained `correlator_outputs`, so its positional constructor arity and
copy-constructor kwargs changed. A bare `downconvert_and_correlate(!)` call
now consumes the whole sample buffer, moving each completed accumulator into
`correlator_outputs` and leaving only the trailing partial integration in the
live correlator — previously one call advanced a single integration step
whose completed accumulator remained readable via `get_correlator`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The field was documented as anchoring the sync phase-snap, but the snap
reads the end-of-chunk sat.code_phase and never consumed it. Remove it
until an actual consumer (e.g. vector tracking) needs it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…al error

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The one-bit and two-bit _update_tracked_sat_correlator overloads were
textually identical to the generic AbstractDownconvertAndCorrelator
method — the backend boundary is _correlate_signals, which already
dispatches on the dc type. Delete the copies so the chunk loop exists
once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ent buffer sharing

- _validate_update_interval now throws an ArgumentError explaining that
  update_interval must be a Unitful time (e.g. 1u"ms") instead of a
  cryptic Unitful conversion error on plain numbers.
- The track docstring's shared-scratch list now includes each signal's
  correlator_outputs buffer (aliased between input and returned state,
  also for bare downconvert_and_correlate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Records that follow the syncing record within one chunk's fold were
correlated with pre-sync replicas (no secondary-code wipe-off);
accumulating them as if wiped feeds sign-corrupted prompts into the
first post-sync bits. Detect the found-transition inside the driver and
passenger folds and apply such records with skip_bit_buffer = true:
prompt filter, CN0 and loop-filter processing still run, only the
bit-buffer update is skipped. Irrelevant at the default interval; it
protects enlarged update_intervals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Split each chunk into two correlate passes around the estimate:

1. stop_before_partial = true — integrate every completion inside the
   chunk, stopping at the last code-block boundary instead of the
   chunk-clamped trailing partial;
2. after the NCO write — integrate the residue (last boundary to chunk
   end), the head of the NEXT integration, with the updated Doppler.

Every completed integration is thus produced by a single NCO Doppler
and each correction takes effect right at its completing boundary — the
classic per-completion loop timing — while the estimator still runs
once per chunk at a common epoch. A trailing fold after the last chunk
catches the rare completion the updated code Doppler pulls back inside
the final residue.

This removes both behavioral deltas of the single-pass design: the FLL
pull-in edge is back at its historical 240 Hz (fails at 250 Hz) and the
1800-block L1C-P overlay sync locks after exactly one overlay cycle —
both test recalibrations are reverted. Also de-links the
downconvert_and_correlate mention in the track docstring (no @ref
target; fixes the docs CI build).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Documents that the repo follows Conventional Commits with rebase merge,
that breaking changes must carry a ! marker plus an explanatory
BREAKING CHANGE footer (it drives the major bump and the release
notes), and that CHANGELOG.md and Project.toml's version are generated
by CI (semantic-release) and must not be edited manually. Also notes
the pinned JuliaFormatter check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
'update' alone could mean anything; the kwarg paces the Doppler
estimation / NCO update, so name it that. Internal helpers, tests, and
docs follow suit. Not a breaking change: the kwarg was introduced
earlier in this PR and has never been released.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Renaming or removing API introduced in the same unmerged PR is an
ordinary refactor/fix commit; reserve ! + BREAKING CHANGE for changes
affecting users of a published version.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The one-/two-bit backends re-packed the WHOLE measurement buffer's
shared band planes on every downconvert_and_correlate! call (>1 sat),
i.e. twice per chunk under the two-pass loop — O(chunks x buffer)
work for an O(buffer) job. New samples_unchanged kwarg (default false)
promises the buffers hold the same content as the previous call with
this dc, letting backends reuse sample-derived caches; track! passes it
on every pass after the very first, so the pack now happens exactly
once per call. The float backends ignore it.

Benchmark (100 ms Complex{Int16} capture, 8 sats, GPS L1CA, 5 MHz,
default 1 ms chunks, single-threaded backends, best of 10):

    one-bit: 3.65 ms -> 0.63 ms   (~5.8x)
    two-bit: 5.16 ms -> 0.84 ms   (~6.1x)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zsoerenm
zsoerenm force-pushed the estimate-doppler-timing branch from 15e3f0f to 6434c8f Compare July 22, 2026 13:18
zsoerenm and others added 3 commits July 22, 2026 14:07
The two-pass chunk correlated each code period in two kernel windows
(completions up to the boundary, then the residue to the chunk end),
doubling per-window fixed costs — the new 100 ms benchmark rows showed
~20-25 % regressions vs master across Int16/OneBit/TwoBit. But the
residue and the next chunk's completions are contiguous samples at the
SAME (just-updated) NCO Doppler, so the residue pass is unnecessary:
run only the stop_before_partial pass each chunk and let the next
chunk's pass start at the boundary — each integration is again one
boundary-to-boundary kernel window, at the classic per-completion loop
timing. The outer loop now walks the chunk grid itself (_chunks_left,
replacing the per-sat _all_groups_reached_end), and a final pass after
the last chunk drains the buffer's trailing partial into the live
accumulator so it carries into the next track! call, followed by one
trailing fold for a completion landing exactly on the buffer end.

Semantics are unchanged — identical samples integrated at identical
Dopplers: the FLL pull-in edge stays at 240/250 Hz and the strict
1800-block L1C-P overlay-sync assertions hold. Local 8-sat 100 ms
capture: OneBit 2.0 -> 0.60 ms, TwoBit 2.8 -> 0.79 ms — back to within
~7 % of the (non-chunked) master loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…list rendering

- Document the chunk_index / chunk_duration / stop_before_partial kwargs in
  the downconvert_and_correlate! docstring (they were added without docs and
  are exercised by tests and the custom-estimator guide).
- Drop the stale 'boundary code phase' mention from the correlator_outputs
  field comment — the CorrelatorOutput.code_phase field was removed earlier
  in this PR.
- Add the missing blank line after the doppler_update_interval bullet in
  docs/src/track.md so the coherent-integration paragraph no longer
  lazy-continues into the list item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ength

The 1/N loop-bandwidth scaling exists to hold the loop's BL·Δt stability
product at its single-code-period value, so N must be the number of code
blocks a correlator-output record ACTUALLY covered — recovered from its
sample count, like the bit-buffer credit (issue #125) — not the intended
integration length from calc_num_code_blocks_to_integrate. Scaling by the
intent under-gained the loop wherever the two disagree:

- records folded after a mid-fold sync detection: correlated pre-sync as
  single blocks, but the live bit buffer already reports the post-sync
  length (an enlarged doppler_update_interval with
  preferred_num_code_blocks_to_integrate > 1 could divide the bandwidth by
  N for a one-block record);
- the first post-sync integration, truncated to land on the data-bit
  boundary (bandwidth divided by the full N for a k < N record).

The divisor is floored at 1 for the fractional-block record right after a
sync phase-snap accumulator reset, whose rounded block count can be 0. For
records whose actual length matches the intent — every record at the
default preferred length of 1 — the update is bit-identical to before.

The new test asserts the update depends only on the record itself: a
synced one-block record with preferred = 20 matches the plain single-block
baseline, and a 20-block record is scaled identically whatever the
preferred length.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant