Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Agent instructions

## Commit messages: Conventional Commits (drive the releases)

Releases are fully automated: every push to `master` runs semantic-release
(`.github/workflows/Release.yml`), which derives the version bump, the
changelog, and the JuliaRegistrator call from the commit messages. Rebase
merge is enforced, so **every commit in a PR lands verbatim on `master`** and
must follow [Conventional Commits](https://www.conventionalcommits.org):

```
type(scope): short imperative summary

optional body explaining what and why
```

- `feat` → minor release, `fix` / `perf` → patch release; `refactor`, `test`,
`docs`, `build`, `chore` → no release.
- Pick the scope from the area touched (e.g. `track`, `dc`, `onebit`,
`bench`); look at `git log --oneline` for precedent.

### Breaking changes must be marked and explained

A breaking change **must** carry both:

1. a `!` after the type/scope — e.g. `feat(track)!: …`, and
2. a `BREAKING CHANGE:` footer paragraph that spells out *what* breaks and
*how to migrate* (removed/renamed exported types, functions, fields, or
kwargs; changed constructor signatures; changed observable behavior of
public API).

The footer is what triggers the major version bump and becomes the release
notes — an unmarked breaking change silently ships as a minor/patch release.
When in doubt: it is breaking if released user code — including custom
`AbstractDopplerEstimator` implementations and direct
`downconvert_and_correlate(!)` callers — could stop compiling or change
behavior after the release.

Breaking is always judged **against the latest release**, not against earlier
commits of the same PR: renaming or removing API that was itself introduced in
the same (unmerged) PR is an ordinary `refactor`/`fix` commit, not a breaking
change — either fold it into the introducing commit or say in the body that
the touched API is unreleased. Reserve `!` + `BREAKING CHANGE:` for changes
that affect users of a published version.

### Do not hand-maintain release artifacts

The changelog and the version are produced by CI: on `master`,
semantic-release generates `CHANGELOG.md` entries and the release notes from
the commit messages and bumps `Project.toml`'s `version` accordingly. Never
edit `CHANGELOG.md` or bump `version` manually in a PR — write the commit
message well instead, since that text *is* the changelog.

## Formatting

CI enforces JuliaFormatter with the exact version pinned in
`.github/workflows/format.yml` (currently 2.8.5) against
`.JuliaFormatter.toml`. Before committing:

```julia
using JuliaFormatter; format(["src", "test"])
```
151 changes: 151 additions & 0 deletions docs/plans/2026-07-21-chunked-doppler-update.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# Chunked Doppler-update tracking loop

**Date:** 2026-07-21
**Status:** Implemented

## Goal

Decouple the Doppler-estimation (NCO-update) rate from the code period. Process
each measurement in fixed-size **time chunks**; within a chunk the NCO Doppler
is held fixed and every correlator output that completes is collected — tagged
with the sample index at which it was taken — into a per-signal array. After the
chunk, the estimator folds over those outputs in order and updates every
satellite's NCO once, at a common epoch.

## Motivation

Previously `track!` advanced each satellite to its *next code-block boundary*
and immediately re-estimated and rewrote that satellite's NCO Doppler. Three
limitations:

- The update rate was welded to the code period — no way to trade
Doppler-estimation compute for a slower update rate.
- Correlator outputs were consumed one at a time and never collected; the
estimator could only ever see the single just-completed correlator. Vector
tracking needs a *batch* of outputs across channels, each tagged with its
sample epoch, on a common time grid.
- NCO updates were staggered — each satellite updated its NCO the instant *it*
finished a code period, so different satellites updated at different times.

## Design

### Chunk grid

The chunk length is the Doppler-update interval, a **time** (`doppler_update_interval`
kwarg on [`track`](@ref) / [`track!`](@ref)). Default `nothing` ⇒ auto = the
smallest primary-code period across all tracked signals (`_smallest_code_period`;
e.g. 1 ms for GPS L1 C/A, or for a mixed L1 C/A + Galileo E1B state). For band
`b` at sampling frequency `fs_b`, chunk `k` (0-based) ends at sample
`min(round((k+1)·Δt·fs_b), num_samples_b)` (`_chunk_last_sample`). The boundary
is re-anchored to the absolute chunk index each call (not accumulated), so
rounding never drifts and different bands stay time-aligned to within a sample.
A one-sample floor is validated up front (`_validate_doppler_update_interval`) so the
loop always makes progress.

### Correlate phase (records outputs, two passes per chunk)

`_update_tracked_sat_correlator` runs an inner loop over the chunk. Each
sub-step integrates to the next code-block boundary of any signal (or the chunk
end), reusing `_calc_min_samples_and_completed` with the chunk end substituted
for the buffer end (the true buffer length is still used for replica sizing).
When a signal's integration completes, `update` / `_build_new_signals` snapshots
its raw accumulator into a [`CorrelatorOutput`](@ref) — `(correlator,
integrated_samples, sample_index)` — appended to the reused
per-signal `correlator_outputs` buffer, and resets the accumulator so the next
integration in the chunk starts fresh. A partial integration at the chunk (or
buffer) boundary carries in the accumulator. The NCO Doppler is untouched here.
A chunk yields 0, 1, or several outputs per signal depending on code phase and
Doppler.

`track!` runs this correlate step **once per chunk** with
`stop_before_partial = true`: integrate every completion inside the chunk, but
stop at the last code-block boundary instead of integrating the trailing
chunk-clamped partial (a sub-step that completes no signal is exactly that
partial). The estimator then writes the new NCO Doppler, and the **next**
chunk's pass picks up from the boundary — so each integration runs boundary →
boundary in a single kernel window, entirely at the just-updated Doppler.
After the last chunk a final pass without the flag drains the buffer's
trailing partial into each satellite's live accumulator (at the final chunk's
Doppler) so it carries into the next `track!` call, followed by one trailing
fold for the rare completion landing exactly on the buffer end.

This keeps every completed integration on a single NCO Doppler and applies each
Doppler correction right at the boundary where its integration completed — the
same loop timing as the classic per-completion update — while still estimating
once per chunk at a common epoch. (Integrating the residue at the pre-update
Doppler instead measurably tightened the FLL pull-in edge; an interim design
that integrated the residue in a separate per-chunk pass restored the edge but
split each code period into two kernel invocations, costing ~20 % throughput
on long buffers.) The outer loop walks the chunk grid itself (`_chunks_left`),
not per-sat progress: satellites lagging behind a chunk boundary — e.g. an
E1B sat whose 4 ms integration spans several 1 ms chunks — are caught up by
whichever later pass contains their boundary, and finally by the drain.

### Estimate phase (folds, one NCO write)

`_apply_correlator_output` applies one record: normalize by its sample count,
post-corr filter, push the filtered prompt, advance CN0 and bit buffer, move the
record to `last_fully_integrated_*` (it no longer resets the accumulator — the
correlate phase already did). The driver processor
`_process_estimator_driver_signal` folds over `correlator_outputs` in order,
threading the loop-filter state and the FLL `previous_prompt` across records
(per-record `integration_time` and `1/N` bandwidth scaling), computing a Doppler
each record and keeping the **last**; the NCO is written once. Passenger signals
apply the per-record advance only. Each signal's `correlator_outputs` is
`empty!`-ed after folding. With no outputs in a chunk the Doppler holds.

### Phase-snap at sync

Sync is detected during the estimate pass, *after* the whole chunk was
correlated. The one-time secondary-code phase snap
(`_snap_code_phase_from_synced_signal`) still runs against the end-of-chunk
`code_phase`, preserving the within-primary-block phase (issue #117). Because any
in-flight partial for the sync chunk was accumulated at the *pre-snap* phase (and
pre-sync without a secondary overlay), the snap now also resets every signal's
in-flight accumulator (`_reset_inflight_integration`): the phase bookkeeping is
kept, but the next chunk re-integrates cleanly from the snapped phase. Otherwise
a flipped overlay chip can cancel the partial to zero and feed a 0/0 into the
discriminators. (With the two-pass chunk the estimate usually runs exactly on a
boundary, so the reset is a no-op there; it still matters for signals whose
integration spans multiple chunks and thus carry a partial at estimate time.)

Records that *follow* the syncing record within the same fold were correlated
with pre-sync replicas (no secondary-code wipe-off), so accumulating them into
the bit buffer as if wiped would feed sign-corrupted prompts into the first
post-sync bits. The folds detect the mid-fold `found` transition and apply
those records with `skip_bit_buffer = true` (`_apply_correlator_output`):
prompt filter, CN0, and loop-filter processing still run, only the bit-buffer
update is skipped. Irrelevant at the default interval (a chunk then holds at
most one record past the sync), it protects enlarged `doppler_update_interval`s.

### Data model

- [`CorrelatorOutput`](@ref)`{C}` in `src/correlators/correlator.jl`.
- [`TrackedSignal`](@ref) gains `correlator_outputs::Vector{CorrelatorOutput{C}}`,
preallocated and reused like `filtered_prompts` (`sizehint!`-ed, `empty!`-ed at
each `track` start and after each chunk's fold), so steady-state tracking stays
allocation-free.

## Backends

All new logic lives in the shared `_update_tracked_sat_correlator` / `update` /
estimator layer, above the backend boundary `_correlate_signals`. The CPU-fused
and Int16 backends (single + threaded) need no changes; the per-`_correlate_signals`
call count is unchanged from before.

## Behavior compatibility

With the two-pass chunk, every completed integration is produced by a single
NCO Doppler and each correction takes effect at its completing boundary — the
same loop timing as the previous per-completion update. The suite's edge
probes match the pre-chunking behavior exactly: the FLL 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. (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.)

## Deferred follow-ups

- A global (cross-`track!`-call) sample index for streamed real-time chunks
(currently `sample_index` is relative to the current measurement buffer).
- The vector-tracking consumer of the batched, sample-indexed correlator outputs.
11 changes: 11 additions & 0 deletions docs/src/correlator.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,17 @@ get_num_accumulators
get_num_ants(::AbstractCorrelator)
```

## Correlator outputs

Each completed integration within a processing chunk is recorded as a
`CorrelatorOutput`, collected per signal and consumed by the Doppler estimator
after the chunk (see [Chunked Doppler updates](track.md#Chunked-Doppler-updates)).

```@docs
CorrelatorOutput
get_correlator_outputs
```

## Sample shifts

The downconvert/correlate inner loop walks the incoming sample buffer once
Expand Down
19 changes: 15 additions & 4 deletions docs/src/custom_doppler_estimator.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,23 @@ per-sat fields directly and rewraps `doppler_estimator_state` unchanged.

5. **An `estimate_dopplers_and_filter_prompt` method** dispatched on
`TrackState{<:Any, <:MyEstimator}`. This is where the actual update
logic runs, once per integration completion. It walks each group in
`track_state.groups`, reads the band's `BandMeasurement` from the
`measurements::BandMeasurements` NamedTuple via `get_band_id(group.band)`,
and produces new `TrackedSat`s with updated
logic runs, once per **chunk** (see [Chunked Doppler updates](track.md#Chunked-Doppler-updates)).
It walks each group in `track_state.groups`, reads the band's
`BandMeasurement` from the `measurements::BandMeasurements` NamedTuple via
`get_band_id(group.band)`, and produces new `TrackedSat`s with updated
`carrier_doppler`/`code_doppler` and updated per-sat estimator state.

Each signal's correlator outputs completed during the chunk are in its
`correlator_outputs::Vector{`[`CorrelatorOutput`](@ref)`}` (a chunk may hold
zero, one, or several per signal), each carrying the raw correlator, its
integrated-sample count, and the end sample index.
Fold over them in order — threading whatever filter state you carry — and
write the NCO Doppler once (typically from the last output). Empty each
signal's `correlator_outputs` when done, and remember the NCO Doppler is held
fixed across the whole chunk, so a discriminator that needs the replica's
Doppler should read the satellite's `code_doppler`/`carrier_doppler` (the
value that generated the chunk), not an intermediate per-output estimate.

The matching mutating method
`estimate_dopplers_and_filter_prompt!(track_state, measurements, prefer)`
is what [`track!`](@ref) calls. To support real-time loops, define
Expand Down
15 changes: 15 additions & 0 deletions docs/src/track.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,27 @@ track!

- `downconvert_and_correlator` — the downconversion and correlation implementation. Defaults to `CPUThreadedDownconvertAndCorrelator()`. **For real-time loops, hoist this outside the loop** (see below).
- `intermediate_frequency` — the IF of the signal. Defaults to `0.0Hz`. Only accepted on the bare-buffer form `track!(buf, state, fs; intermediate_frequency = ...)`; on the [`BandMeasurement`](@ref) and multi-band forms the IF lives on each `BandMeasurement`.
- `doppler_update_interval` — the Doppler-estimation / NCO-update interval, a time (e.g. `1u"ms"`). Defaults to `nothing` ⇒ auto = the smallest primary-code period across all tracked signals (1 ms for GPS L1 C/A). Each measurement is processed in fixed-size chunks of this length: within a chunk the NCO Doppler is held fixed and every correlator output that completes is collected, then the estimator processes them in order and updates **every** satellite's NCO once, at a common epoch (see [Chunked Doppler updates](#Chunked-Doppler-updates)). Pick a longer interval to reduce Doppler-estimation cost at the expense of update rate.

The **coherent-integration length** is not a `track!` argument — it is a per-signal setting on each [`TrackedSignal`](@ref) (its `preferred_num_code_blocks_to_integrate` field), changed with [`set_preferred_num_code_blocks_to_integrate!`](@ref). It defaults to `1`, is capped per integration by the signal's bit/secondary-code period, and only takes effect once bit/secondary-code synchronization has been achieved. For data-bearing signals the length must evenly divide the number of code blocks that form one bit (e.g. a divisor of 20 for GPS L1 C/A, of 10 for GPS L5I) so integrations stay aligned to bit boundaries; other values throw an `ArgumentError`. With the conventional estimator the loop bandwidth auto-scales by `1/N` so longer integration stays stable without re-tuning.

```@docs
set_preferred_num_code_blocks_to_integrate!
```

## Chunked Doppler updates

`track` / `track!` walk each measurement in fixed-size time chunks of length `doppler_update_interval` (default: the smallest code period across all signals). Each chunk runs one correlate pass and one estimate:

1. **Correlate to the last completed boundary** — each satellite integrates from wherever it stands up to its last coherent-integration boundary inside the chunk; every completed integration is collected into that signal's `correlator_outputs` buffer, tagged with the sample index at which it ended (a [`CorrelatorOutput`](@ref); the sample index is important for vector tracking). A 1 ms-code signal in a 1 ms chunk yields 0, 1, or 2 outputs; a signal whose coherent integration is longer than the chunk yields outputs only on the chunks where it completes.
2. **Estimate** — the Doppler estimator processes the collected outputs **in order** (threading the loop-filter state across them) and writes the resulting Doppler to the NCO **once per chunk** — all satellites' NCOs update at the same point in the processing, a common epoch.

The chunk's trailing partial — from each satellite's last completed boundary to the chunk end — is *not* integrated separately: the **next** chunk's pass starts right at that boundary, so each integration runs boundary → boundary in one kernel window, entirely at the freshly updated Doppler. Every completed integration is therefore produced by a single NCO Doppler and each correction takes effect right at the boundary where its integration completed — the same loop timing as a classic per-code-period update. A final pass after the last chunk drains the buffer's trailing partial into each satellite's live accumulator so it carries into the next `track!` call.

Read the collected outputs for the most recent chunk with [`get_correlator_outputs`](@ref) (they are cleared after each chunk's estimate).

Choosing a larger `doppler_update_interval` batches more correlator outputs per NCO update, trading Doppler-tracking bandwidth for lower estimation cost. At the default interval the behavior is numerically very close to updating the NCO once per code period.

## Real-time loops

A typical receiver loop builds the `TrackState` once, hoists the correlator outside the loop, and calls `track!` per chunk:
Expand Down
4 changes: 3 additions & 1 deletion src/Tracking.jl
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ using Polyester
# sync-detection-redesign plan in docs/plans for the comparison).
BitIntegers.@define_integers 1800

using Unitful: upreferred, uconvert, Hz, dBHz, ms, s
using Unitful: upreferred, uconvert, dimension, NoUnits, Hz, dBHz, ms, s
import Base.zero, Base.length, Base.resize!

export get_early,
Expand All @@ -40,6 +40,8 @@ export get_early,
get_last_fully_integrated_correlator,
get_last_fully_integrated_filtered_prompt,
get_filtered_prompts,
get_correlator_outputs,
CorrelatorOutput,
get_bit_buffer,
get_bits,
get_num_bits,
Expand Down
Loading
Loading