Track
track is the main entry point: it takes an incoming measurement and a TrackState, runs downconversion + correlation for every tracked satellite, drives the Doppler estimator, and returns the updated TrackState. track! is the in-place counterpart for hard real-time loops where GC pauses must be avoided — after one warmup call (to seat the filtered_prompts buffer's capacity), the single-threaded track! path is fully allocation-free; the threaded path keeps a small irreducible per-call residual from Polyester's @batch closure capture (160 B per GNSS system).
Tracking.track — Function
track(measurements, track_state; kwargs...)
Main tracking function that processes one or more BandMeasurements and updates the tracking state. Performs downconversion, correlation, and Doppler estimation for all satellites in the track state. Returns an updated TrackState with new phase/Doppler estimates and decoded bits.
Three input shapes for the first positional argument:
| Argument | Meaning |
|---|---|
AbstractVecOrMat | Bare sample buffer. Single-band TrackState only. |
BandMeasurement | One band's bundled buffer + sample rate. Single-band TrackState. |
NamedTuple{...} of BandMeasurements | Multi-band: one BandMeasurement per band key (see band_key). |
The bare-buffer form track(buf, state, fs; intermediate_frequency = ...) is preserved as a thin wrapper that builds a single-entry NamedTuple{(band_key(state),)} internally. The two-phase inner loop (downconvert+correlate across all groups, then estimate across the whole TrackState) is the same shape regardless of how many measurements are passed.
The returned TrackState is structurally detached from the input: each group's key set and slot vector are copied, so add_satellite! / remove_satellite! and tracking itself on either state never affect the other's satellites. The copy is shallow, however — per-satellite scratch vectors (each signal's filtered_prompts, the soft-bit buffer, and the CN0 estimator's prompt buffer) are shared with the input and are overwritten by the next track call on either state. Treat the input as a stale handle after the call; deepcopy it first if you need to snapshot those buffers.
For real-time loops processing many chunks of signal in sequence, construct the correlator once outside the loop and pass it via the downconvert_and_correlator keyword argument:
dc = CPUThreadedDownconvertAndCorrelator()
while got_chunk(rx)
chunk = read_chunk!(rx)
track_state =
track(chunk, track_state, sampling_frequency; downconvert_and_correlator = dc)
endThe default kwarg value builds a fresh correlator (with fresh per-thread scratch buffers) on every call, which is fine for one-shot use but defeats the allocation-free design in tight loops. See also track! for the in-place variant that avoids rebuilding track_state per call.
The coherent-integration length is a per-signal setting that lives on each TrackedSignal (its preferred_num_code_blocks_to_integrate field, addressed by (group, prn, signal)), not a track! argument. Set it with set_preferred_num_code_blocks_to_integrate!; the actual length is capped per integration by the signal's bit/secondary-code period and held at 1 until bit/secondary sync. Defaults to 1 (1 ms for GPS L5I / L1 C/A). Different satellites — and different signals on one satellite — can therefore integrate for different lengths:
set_preferred_num_code_blocks_to_integrate!(track_state, :gps_l5, 1, GPSL5I, 10) # PRN 1 L5I: 10 msThe conventional estimator auto-scales each signal's loop bandwidth by 1/N for its integration length N, so longer integration stays stable without re-tuning (see ConventionalPLLAndDLL).
Tracking.track! — Function
track!(
measurements,
track_state;
downconvert_and_correlator
)
In-place version of track. Mutates track_state by overwriting the Vector{TrackedSat} slots inside each group instead of rebuilding new immutable wrappers. Returns the same track_state object.
After one warmup call (which seats each satellite's filtered_prompts buffer capacity), the single-threaded path is fully allocation-free; the threaded path keeps an irreducible Polyester @batch closure-capture allocation of about 160 B per GNSS system per call.
For real-time loops, construct the correlator once outside the loop and pass it via the downconvert_and_correlator keyword argument:
track_state = TrackState(signal, initial_sats)
dc = CPUThreadedDownconvertAndCorrelator() # hoist!
while got_chunk(rx)
chunk = read_chunk!(rx)
track!(chunk, track_state, sampling_frequency; downconvert_and_correlator = dc)
endThe correlator holds long-lived per-thread scratch buffers that grow on first use; rebuilding it via the default kwarg value would re-grow them every call.
Optional parameters
downconvert_and_correlator— the downconversion and correlation implementation. Defaults toCPUThreadedDownconvertAndCorrelator(). For real-time loops, hoist this outside the loop (see below).intermediate_frequency— the IF of the signal. Defaults to0.0Hz. Only accepted on the bare-buffer formtrack!(buf, state, fs; intermediate_frequency = ...); on theBandMeasurementand multi-band forms the IF lives on eachBandMeasurement.
The coherent-integration length is not a track! argument — it is a per-signal setting on each TrackedSignal (its preferred_num_code_blocks_to_integrate field), changed with set_preferred_num_code_blocks_to_integrate!. 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.
Tracking.set_preferred_num_code_blocks_to_integrate! — Function
set_preferred_num_code_blocks_to_integrate!(
track_state,
group,
sat_id,
sig,
num_code_blocks
)
Set the preferred coherent-integration length, in primary code blocks, for one signal on one satellite — the preferred_num_code_blocks_to_integrate field of the addressed TrackedSignal. The actual length is still capped per integration by the signal's bit/secondary-code period and held at 1 until bit/secondary sync (see calc_num_code_blocks_to_integrate); with the conventional estimator the loop bandwidth auto-scales by 1/N so the loop stays stable at any length.
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; an ArgumentError is thrown otherwise (issue #128). Pilot signals accept any length of at least one block.
The satellite is addressed exactly like the per-signal accessors (e.g. estimate_cn0) — in particular, the per-signal form always names the group explicitly, even on a single-group TrackState:
set_preferred_num_code_blocks_to_integrate!(ts, :gps_l5, 1, GPSL5I, 10) # (group, prn, signal)
set_preferred_num_code_blocks_to_integrate!(ts, :gps_l5, 1, 10) # single-signal sat
set_preferred_num_code_blocks_to_integrate!(ts, 1, 10) # single-group state
set_preferred_num_code_blocks_to_integrate!(ts, 10) # 1 group, 1 sat, 1 signalMutates track_state in place and returns it.
Real-time loops
A typical receiver loop builds the TrackState once, hoists the correlator outside the loop, and calls track! per chunk:
track_state = TrackState(; signal = GPSL1CA())
track_state = add_satellite!(track_state; prn = 1, code_phase = 0.0, carrier_doppler = 1000.0Hz)
# ... more sats ...
dc = CPUThreadedDownconvertAndCorrelator() # hoist outside the loop
while got_signal_chunk(rx)
chunk = read_chunk!(rx)
track!(chunk, track_state, sampling_frequency;
downconvert_and_correlator = dc)
# ... read per-sat state e.g. via get_sat_state(track_state, group, prn) ...
endBoth CPUDownconvertAndCorrelator and CPUThreadedDownconvertAndCorrelator hold long-lived per-thread scratch buffers that grow on first use and are reused thereafter. Relying on the default kwarg value rebuilds them on every call, which defeats the allocation-free design. This applies to both track (immutable) and track! (in-place).
track! writes back into the existing Vector{TrackedSat} slots of each per-group dictionary, so the tracking loop runs without GC pressure once the sat set is steady. The first track! call may grow each signal's filtered_prompts buffer via push!; from the second call onwards the capacity is settled.
BandMeasurement
One band's incoming sample buffer plus the front-end metadata needed to process it. Bundles samples with sampling_frequency and intermediate_frequency — these are inseparable in practice, and the bundle removes the chance of mismatched parallel NamedTuples in a multi-band track call.
For single-band tracking, the bare-buffer form track!(buf, state, fs) and the single-BandMeasurement form track!(BandMeasurement(buf, fs), state) both auto-wrap into a one-entry NamedTuple internally — see the Quick start for the bare-buffer form.
For multi-band tracking, build one BandMeasurement per band and pass them as a NamedTuple keyed by band_key:
julia> using Tracking, GNSSSignals
julia> using Tracking: Hz
julia> track_state = TrackState(;
signals = (legacy_gps_l1 = (GPSL1CA(),), gps_l5 = (GPSL5I(),)),
);
julia> track_state = add_satellite!(track_state; prn = 1, group = :legacy_gps_l1, code_phase = 0.0, carrier_doppler = 0.0Hz);
julia> track_state = add_satellite!(track_state; prn = 1, group = :gps_l5, code_phase = 0.0, carrier_doppler = 0.0Hz);
julia> buf_l1 = zeros(ComplexF64, 4000); # 1 ms at 4 MHz
julia> buf_l5 = zeros(ComplexF64, 25000); # 1 ms at 25 MHz
julia> track!((l1 = BandMeasurement(buf_l1, 4e6Hz),
l5 = BandMeasurement(buf_l5, 25e6Hz)), track_state);See Multi-band tracking for the full setup (group declaration, per-band antenna counts, duration matching).
Tracking.BandMeasurement — Type
One band's incoming sample buffer plus the front-end metadata needed to process it. Bundles samples with the sampling_frequency and intermediate_frequency they were captured at — these are inseparable in practice, and the bundle removes the chance of mismatched parallel NamedTuples in a multi-band track call.
Fields:
samples::S: complex sample buffer (Vectorfor one antenna,Matrixwith rows = samples and columns = antennas for an antenna array). Must be densely laid out in memory (unit row stride, columns packed back-to-back) — the SIMD downconvert/correlate kernels read the buffer through raw pointers with dense column-stride math, so a non-contiguous strided view would silently correlate the wrong samples. The constructor validates this and rejects non-dense buffers with anArgumentError; contiguousviews (e.g.view(buf, 1:4000)) remain fine.sampling_frequency::F: the buffer's sample rate (e.g.4e6Hz)intermediate_frequency::F: the band's IF (defaults to0.0Hz)
In a multi-band call, one BandMeasurement is built per band; a NamedTuple of BandMeasurements keyed by band feeds track. For the single-band case a plain buffer + scalar sample-rate keeps working unchanged.
BandMeasurement(buf, 4e6Hz) # IF defaults to 0.0Hz
BandMeasurement(buf, 4e6Hz, 1.575e6Hz) # explicit IF
BandMeasurement(buf; sampling_frequency = 4e6Hz) # kwarg formTracking.BandMeasurements — Type
Type alias for a NamedTuple of BandMeasurements — the multi-band input shape of track / track!. Keys are band symbols (see band_key).
Downconversion and correlation
Tracking.CPUDownconvertAndCorrelator — Type
CPU-based implementation of downconversion and correlation. Holds one ScratchBuffers — three long-lived Vector{UInt8} byte buffers, one per scratch role (code replica + the fused kernel's two tile halves). Buffers grow lazily on first use and are reused thereafter, so a hoisted instance has zero allocations per track! call in steady state.
For real-time loops, construct the correlator once outside the track! loop and pass it via the downconvert_and_correlator keyword argument — the default value rebuilds the buffers on every call.
Tracking.CPUThreadedDownconvertAndCorrelator — Type
Multi-threaded CPU downconvert and correlate. Holds one ScratchBuffers per thread, indexed by Threads.threadid() inside @batch (which pins each iteration to a fixed thread). Buffers grow lazily on first use and are reused thereafter, so a hoisted instance has near-zero allocations per track! call in steady state (Polyester's @batch keeps a small irreducible per-call closure allocation).
For real-time loops, construct the correlator once outside the track! loop and pass it via the downconvert_and_correlator keyword argument — the default value rebuilds the per-thread buffers on every call.
Tracking.AbstractDownconvertAndCorrelator — Type
Abstract downconverter and correlator type. Structs for downconversion and correlation must have this abstract type as a parent.
Correlator sample shifts and the early/late spacing are documented in Correlator.