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.trackFunction
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:

ArgumentMeaning
AbstractVecOrMatBare sample buffer. Single-band TrackState only.
BandMeasurementOne band's bundled buffer + sample rate. Single-band TrackState.
NamedTuple{...} of BandMeasurementsMulti-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)
end

The 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 ms

The 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).

source
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)
end

The 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.

source

Optional parameters

  • 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 and multi-band forms the IF lives on each BandMeasurement.

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 signal

Mutates track_state in place and returns it.

source

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) ...
end

Both 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.BandMeasurementType

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 (Vector for one antenna, Matrix with 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 an ArgumentError; contiguous views (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 to 0.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 form
source

Downconversion and correlation

Tracking.CPUDownconvertAndCorrelatorType

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.

source
Tracking.CPUThreadedDownconvertAndCorrelatorType

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.

source

Correlator sample shifts and the early/late spacing are documented in Correlator.