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 id (see GNSSSignals.get_band_id).

The bare-buffer form track(buf, state, fs; intermediate_frequency = ...) is preserved as a thin wrapper that builds a single-entry NamedTuple{(get_band_id(band),)} 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 (CPUThreadedDownconvertAndCorrelator) keeps a small residual — about 64 B per completed code-block integration per group — but only when the process runs with more than one thread and the group holds more than one satellite (so Polyester's @batch actually distributes work). track! launches one @batch per code block, so this residual scales with the chunk length rather than staying flat per call; for a real-time loop with fixed-size chunks it is bounded per call and, in practice, dwarfed by the input sample buffer the caller allocates each chunk. The single-threaded backend has none of it.

The root cause is not the per-satellite state per se — it is that the GNSS signal object is not an isbits type: GPSL1CA, for example, holds a Matrix{Int16} code table and a (also non-isbits) SignalLUT. Polyester roots bare Arrays and isbits values into its worker tasks for free (that is why a Vector{Float64} kernel is allocation-free), but it pays a small per-launch allocation to root any other non-isbits object touched inside the @batch region. Each satellite's downconvert_and_correlate reaches its signal (for code-replica generation), so the parallel loop touches that non-isbits object once per launch. It could be removed by generating the code from the LUT's bare arrays (a prototype doing so inside the loop measures 0 B at full parallel throughput) — which needs a GNSSSignals-side bare-array gen_code! — or, in-tree, by a serial code-gen pre-pass (allocation-free but slower, since it serializes ~30 % of the work). Neither is currently worth ~64 B/block. See CPUThreadedDownconvertAndCorrelator for the full analysis.

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 the band's GNSSSignals.get_band_id (e.g. :L1, :L5):

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
Tracking.BandMeasurementsType

Type alias for a NamedTuple of BandMeasurements — the multi-band input shape of track / track!. Keys are the bands' GNSSSignals.get_band_id symbols (e.g. :L1, :L5) — nameof of the band type, folding to a compile-time constant, so the per-call NamedTuple lookup is free and new bands work without any Tracking-side registration.

source

Downconversion and correlation

The default CPUThreadedDownconvertAndCorrelator runs a Float32 pipeline and accepts any complex sample type. For Complex{Int16} (integer ADC) sample buffers there is an opt-in integer backend, Int16ThreadedDownconvertAndCorrelator (and its single-threaded sibling Int16DownconvertAndCorrelator), which is typically ~1.3–2.9× faster. Select it explicitly via the downconvert_and_correlator keyword; it errors on a non-Complex{Int16} measurement. Its constructor takes one required positional argument, max_meas — your front end's full-scale (the largest |real|/|imag| a sample can take, e.g. 2^11 for a 12-bit ADC) — from which it sizes the carrier replica so the integer carrier wipe cannot overflow. There is no default: see Int16DownconvertAndCorrelator for why under-declaring it is catastrophic.

For an even faster bit-wise option on Complex{Int16} captures of BPSK signals, OneBitThreadedDownconvertAndCorrelator (and its single-threaded sibling OneBitDownconvertAndCorrelator) hard-limits the measurement, carrier and code to a single sign bit, so downconversion becomes XOR and the tap accumulate becomes popcount — measured ~1.5–5.6× faster than the Float32 backend (the gap grows with sampling rate). It trades ≈2–3 dB of SNR for that speed; since the discriminators, C/N0 and bit buffer are ratio-normalised, the coarse amplitude is immaterial. Bit-wise correlation is awkward for non-binary modulations, so this backend is BPSK-only and errors on CBOC/BOC code types.

Between the two sits TwoBitThreadedDownconvertAndCorrelator (and its single-threaded sibling TwoBitDownconvertAndCorrelator): the same bit-plane XOR + popcount machinery, but with a second magnitude bit for the measurement and the carrier, making both 4-level {±1, ±3} quantities (the carrier's sign and magnitude bit planes come straight off SinCosLUT's 2-bit NCO). That recovers ≈2 dB of the one-bit backend's SNR loss (leaving only ≈0.8 dB vs Float32) at roughly Int16 speed — so pick one-bit when raw speed matters most, two-bit when you want near-Int16 sensitivity with the bit-wise memory/layout advantages, and Int16 when quantisation loss must be negligible. The threshold keyword sets the measurement's magnitude split point in ADC counts (≈1σ of the front end's input is the classic near-optimal choice; the default 512 suits a properly-AGC'd 12-bit capture). Same scope as one-bit: Complex{Int16} samples, binary (BPSK) codes only.

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, parallelized over the satellites (PRNs) of each group. 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's scratch is allocation-free in steady state.

One @batch is launched per code block (once per downconvert_and_correlate! call inside track!'s inner loop). When the process runs with more than one thread and the group has more than one satellite, each launch keeps a small Polyester allocation (~64 B), so the total scales with the number of completed code blocks in the chunk rather than staying flat per track! call. With a single thread, a single satellite, or the single-threaded CPUDownconvertAndCorrelator, there is no such residual.

Why it allocates at all — Polyester's @batch roots only bare Arrays and isbits values into its worker tasks for free (that is why a Vector{Float64} kernel allocates nothing). It pays a small per-launch allocation to root any non-isbits struct referenced inside the region — and it is the struct-ness that costs, not the contents: capturing even a plain struct whose only fields are Matrixes and isbits scalars measures the same ~64 B/launch, whereas capturing those same bare arrays measures 0. The culprit here is the GNSS signal: GPSL1CA, for instance, is not isbits — it wraps a Matrix{Int16} code table and a (also non-isbits) SignalLUT. Each satellite's code-replica generation needs its signal, so every @batch launch touches that non-isbits object once. (The per-satellite TrackedSat is likewise non-isbits — it carries the signal plus the CN0/bit buffers — and the loop reaches the signal through it, so iterating Vector{TrackedSat} is not free the way iterating a Vector{Float64} is.)

Note that merely hoisting the SignalLUT out of the signal does not help — a SignalLUT is itself a non-isbits struct (it wraps Matrix{Int8} fields), so capturing it costs the same as capturing the whole GPSL1CA. What removes the cost entirely is a code-generation entry point that takes the LUT's bare arrays and isbits fields as separate arguments (padded, secondary, subchip_factor, …) rather than a struct: the @batch closure then captures only bare Arrays and isbits values, which Polyester roots for free. A prototype of exactly this — generating from the raw padded matrix, done inside the parallel loop — is bit-faithful and measures 0 B/launch, and because generation stays in the @batch region it keeps the full parallel throughput. Realizing it needs a GNSSSignals-side API that threads the bare arrays down to the resample kernel without re-wrapping them in a struct inside the region (reconstructing one there sends the generator dynamic and allocates far more). Absent that, the only in-tree way to reach 0 is a serial code-gen pre-pass, which is allocation-free but serializes ~30 % of the work and slows the threaded pipeline — the inferior fallback. Neither is currently worth ~64 B/block, which is bounded per call and dwarfed by the caller's input buffer.

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
Tracking.Int16DownconvertAndCorrelatorType

Integer (Complex{Int16}) hybrid-blocked CPU downconvert + correlate backend (single-threaded). Opt-in alternative to [CPUDownconvertAndCorrelator] for Complex{Int16} sample buffers; errors on any other sample element type. Construct once outside the track! loop and pass it via the downconvert_and_correlator keyword for an allocation-free steady state on the static correlator path (EPL/VEPL and any SVector-shifts correlator). The runtime AbstractVector-shifts fallback additionally allocates the small Vector it returns each integration, but reuses the same thread-local scratch.

Arguments

max_meas (the first positional argument, required — no default) is the largest |real|/|imag| any measurement sample will take, i.e. your front end's full-scale (e.g. 2^11 for a 12-bit ADC). From it the constructor picks the LARGEST carrier-replica amplitude whose carrier wipe still fits Int16, and the wipe arithmetic type (Int16 on the fast path, else Int32). The carrier wipe is a complex multiply DI = mᵣ·cos + mᵢ·sin, DQ = mᵢ·cos − mᵣ·sin — two products summed per output — so the amplitude is sized against 2·max_meas·amplitude ≤ typemax(Int16), bounding both the products and their sum. There is deliberately no default: under-declaring max_meas silently overflows the Int16 wipe and corrupts the correlation catastrophically, so you must state it explicitly. Over-declaring is safe and only coarsens the carrier quantisation.

Performance: keep `max_meas < 2^14`

For max_meas ≥ 2^14 no Int16-safe carrier amplitude ≥ 1 exists, so the backend falls back to an exact Int32 carrier wipe — the on-x86 vpmaddwd Int16 fast path no longer applies — and shrinks the strip-mine block to keep the correlation accumulators from overflowing. This stays correct but is measurably slower. This backend is tuned for ≤12-bit sample buffers; keep max_meas below 2^14 to stay on the fast path.

The blk keyword sets the strip-mine block length (samples). It must be ≥ 1, validated at construction — blk ≤ 0 would make the strip-mine loop never advance and hang track! (issue #169). A blk larger than the overflow-safe block is accepted and simply clamped for the flush (see _int16_flush_len / _int16_safe_blk), so the correlation accumulators never wrap.

source
Tracking.OneBitDownconvertAndCorrelatorType

One-bit (hard-limited) bit-wise CPU downconvert + correlate backend (single-threaded). Opt-in alternative to [CPUDownconvertAndCorrelator] for Complex{Int16} sample buffers: it 1-bit-quantises the measurement, carrier and code and correlates with XOR + popcount. Construct once outside the track! loop and pass it via the downconvert_and_correlator keyword for an allocation-free steady state. 1-bit quantisation trades ≈2–3 dB of SNR for bit-wise speed; downstream consumers are ratio-normalised, so the coarse amplitude is immaterial.

source
Tracking.TwoBitDownconvertAndCorrelatorType

Two-bit (sign + magnitude) bit-wise CPU downconvert + correlate backend (single-threaded). Opt-in alternative to [CPUDownconvertAndCorrelator] for Complex{Int16} sample buffers, sitting between the OneBitDownconvertAndCorrelator and Int16DownconvertAndCorrelator: measurement and carrier are 4-level {±1, ±3} (2-bit sign + magnitude, the carrier bit planes straight off SinCosLUT's NCO), the code stays 1-bit, correlated with XOR + masked popcount. Recovers ≈2 dB of correlation SNR over the one-bit backend (≈0.8 dB total quantisation loss vs Float32) while keeping the bit-wise speed advantage over the integer/float backends. Construct once outside the track! loop and pass it via the downconvert_and_correlator keyword for an allocation-free steady state.

threshold is the measurement magnitude split point in ADC counts (|component| ≥ threshold ⇒ the ±3 level); set it near 1σ of your front end's input for the near-optimal 4-level quantiser. Downstream consumers are ratio-normalised, so the absolute scale is immaterial.

source
Tracking.AbstractDownconvertAndCorrelatorType

Abstract downconverter and correlator type. Structs for downconversion and correlation must have this abstract type as a parent.

The per-sat correlation loop, per-group body, and public downconvert_and_correlate(!) entry points are defined once on this abstract type (see downconvert_and_correlate_cpu.jl); a subtype customises behaviour by overriding the dispatch hooks it needs — _correlate_signals / _scratch_buffers (kernel + scratch), _threading (serial vs. Polyester @batch, default serial), and _check_sample_type (per-backend sample-type check, default no-op). A subtype that overrides none inherits the single-threaded CPU plumbing rather than getting a MethodError.

source

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