Track

track takes a measurement and a TrackState, downconverts and correlates every tracked satellite, drives the Doppler estimator, and returns the updated TrackState. track! is the in-place counterpart for real-time loops; after one warmup call its single-threaded path is allocation-free (see track! for the threaded path's residual).

Tracking.track — Function
track(measurements, track_state; kwargs...)

Track one or more BandMeasurements: downconvert, correlate and estimate the Dopplers of every satellite, and return the updated TrackState with new phase/Doppler estimates and decoded bits.

The first argument is one of:

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 takes sampling_frequency as the next positional argument and an intermediate_frequency keyword (default 0). Every form runs the same per-chunk loop: downconvert and correlate all groups, then estimate.

The returned TrackState is structurally detached from the input: each group's key set and slot vector are copied, so adding, removing or tracking satellites on one never affects the other. The copy is shallow, though, and these are shared with the input (treat it as a stale handle, or deepcopy it first):

  • the per-satellite scratch vectors (filtered_prompts, correlator_outputs, the soft bits, the C/N₀ estimator's buffer), overwritten by the next call on either state; a bare downconvert_and_correlate likewise appends to the input's correlator_outputs;
  • each signal's noise estimator, which track advances in place: branched states divide by one noise reference, and advancing two concurrently races, so build one TrackState per thread (see downconvert_and_correlate for why the window is not copied);
  • the doppler_estimator itself, so results can be read off the instance you passed in. One with state of its own (TrackingLoops' VectorPLLAndDLL) is advanced by track as by track!, and two branches would mix their records in one navigation filter.

For real-time loops, construct the correlator once outside the loop:

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 builds fresh per-thread scratch buffers on every call. See track! for the in-place variant.

The coherent-integration length is set per signal with set_preferred_num_code_blocks_to_integrate!, not passed to track:

set_preferred_num_code_blocks_to_integrate!(track_state, :gps_l5, 1, GPSL5I, 10)  # PRN 1 L5I: 10 ms

The conventional estimator caps the carrier loop bandwidth at 0.09 / T_int and the code loop's at 0.018 / T_int, so longer integration stays stable (see ConventionalPLLAndDLL).

source
Tracking.track! — Function
track!(
    measurements,
    track_state;
    downconvert_and_correlator,
    doppler_update_interval
)

In-place version of track: overwrites the satellite slots of track_state and returns it.

After one warmup call the single-threaded path is allocation-free. The threaded path (CPUThreadedDownconvertAndCorrelator) keeps a residual of about 96 B per group per correlate pass (one per doppler_update_interval chunk, so it scales with the buffer length), when running on more than one thread with more than one work item in the group (the noise despreads count, riding the first group); see CPUThreadedDownconvertAndCorrelator for why.

For real-time loops, construct the correlator once outside the loop:

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

Its per-thread scratch buffers grow on first use; the default would regrow them every call.

source

Optional parameters

  • downconvert_and_correlator: the backend, by default CPUThreadedDownconvertAndCorrelator(). Hoist it outside real-time loops (see below).
  • intermediate_frequency: the IF, by default 0.0Hz. Bare-buffer form only; otherwise it lives on each BandMeasurement.
  • doppler_update_interval: the NCO-update interval, a time (e.g. 1u"ms"). nothing means the smallest primary-code period of the tracked signals (1 ms for GPS L1 C/A); see Chunked Doppler updates. Longer intervals cost less estimation at a lower update rate.

The coherent-integration length is set per signal with set_preferred_num_code_blocks_to_integrate!. It starts at default_num_code_blocks_to_integrate (1, except a 31-block, 2 ms code cycle for Galileo E5a-QP, whose 64.5 µs code period is too short to run a loop on), is capped by max_num_code_blocks_to_integrate (one bit or secondary-code period), and takes effect only after bit or secondary-code sync.

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, of one signal on one satellite (the addressed TrackedSignal's preferred_num_code_blocks_to_integrate). The actual length is capped by the bit/secondary-code period and held at 1 until sync (see calc_num_code_blocks_to_integrate); the conventional estimator caps its loop bandwidths against the integration time, so the loop stays stable.

For data-bearing signals the length must divide the code blocks per bit (20 for GPS L1 C/A, 10 for GPS L5I), or an ArgumentError is thrown (#128). Pilots accept any length of at least one block.

Addressed like the per-signal accessors; the per-signal form names the group 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

In the TrackingLoops manual:

Chunked Doppler updates

track / track! walk each measurement in chunks of doppler_update_interval, each with one correlate pass and one estimate:

  1. Correlate each satellite up to its last completed integration boundary in the chunk, collecting every completed integration into the signal's correlator_outputs as a CorrelatorOutput tagged with its end sample. A 1 ms code in a 1 ms chunk yields 0, 1 or 2 outputs.
  2. Estimate: the Doppler estimator folds the outputs in order and writes every satellite's NCO once per chunk, at a common epoch.

The next chunk's pass starts at that boundary, so every integration runs at one NCO Doppler and each correction takes effect at the boundary where its integration completed, as with a per-code-period update. A final pass drains each satellite's trailing partial into its accumulator, carried into the next track! call. get_correlator_outputs reads a chunk's outputs before the estimate clears them.

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

The backends hold per-thread scratch buffers that grow on first use; the keyword's default rebuilds them on every call, for track and track! alike. track! writes into the existing satellite slots, and from its second call on the buffers' capacity is settled.

External correlator producers

The Doppler estimator reads only each signal's correlator_outputs, never the samples, so an external producer (e.g. an FPGA streaming correlator dumps) can supply them and the host runs only the loops. (Transport, wire format and NCO marshaling live in GNSSReceiver.jl.)

Per chunk:

  1. Ingest. Build a CorrelatorOutput from the producer's raw accumulator and append it with append_correlator_output!, per signal in sample_index order:

    append_correlator_output!(track_state, output, group, prn, sig)
  2. Estimate. Call estimate_dopplers_and_filter_prompt! with the per-band sampling frequencies (a NamedTuple or Dict keyed by get_band_id) in place of the samples:

    estimate_dopplers_and_filter_prompt!(track_state, (L1 = 25e6Hz, L5 = 25e6Hz))
    # or: estimate_dopplers_and_filter_prompt!(track_state, Dict(:L1 => 25e6Hz))

    BandMeasurements work too. The call consumes and clears each signal's correlator_outputs.

  3. Marshal the updated get_code_doppler / get_carrier_doppler back to the hardware NCOs.

Caller contract

  • CorrelatorOutput.correlator: the raw accumulator (sum of products over integrated_samples, as normalize expects), e.g. built with EarlyPromptLateCorrelator / update_accumulator.
  • CorrelatorOutput.integrated_samples: the producer's true sample count.
  • CorrelatorOutput.sample_index: the end sample relative to the start of the current measurement, as the software path writes it (signal_start_sample returns to 1 each track!). A producer with a free-running global counter subtracts the measurement's origin; the estimate phase adds the band's TrackState sample_offsets before handing the record to the Doppler estimator.

Transport delay

The DLL discriminator takes the pre-update code_doppler as the one in effect during the integration, right for a one-chunk feedback pipeline. With a deeper delay, tag outputs with their epoch and schedule each NCO update for a known future epoch.

BandMeasurement

One band's samples with their sampling_frequency and intermediate_frequency. A single band may pass a bare buffer, track!(buf, state, fs), or one BandMeasurement; several bands pass a NamedTuple keyed by GNSSSignals.get_band_id:

julia> using Tracking, GNSSSignals

julia> using Tracking: Hz

julia> using Random: Xoshiro

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 = randn(Xoshiro(1), ComplexF64, 4000);   # 1 ms at  4 MHz

julia> buf_l5 = randn(Xoshiro(2), ComplexF64, 25000);  # 1 ms at 25 MHz

julia> track!((L1 = BandMeasurement(buf_l1, 4e6Hz),
               L5 = BandMeasurement(buf_l5, 25e6Hz)), track_state);

Noise, not zeros: the default C/N₀ estimator measures the noise floor from the samples, and track! warns, once per signal, about an all-zero buffer (see AbstractNoiseEstimator). See Multi-band tracking for the full setup.

Tracking.BandMeasurement — Type

One band's sample buffer with the sampling_frequency and intermediate_frequency it was captured at.

Fields:

  • samples::S: complex sample buffer (Vector for one antenna, Matrix with rows = samples and columns = antennas for an antenna array). Must be dense (unit row stride, columns packed back-to-back), as the SIMD kernels read it through raw pointers; anything else is an ArgumentError. Contiguous views (e.g. view(buf, 1:4000)) are fine.
  • sampling_frequency::F: the buffer's sample rate (e.g. 4e6Hz)
  • intermediate_frequency::F: the band's IF (defaults to 0.0Hz)

A multi-band track takes a NamedTuple of them keyed by band; a single band may also pass a plain buffer and sample rate.

BandMeasurement(buf, 4e6Hz)                              # IF defaults to 0.0Hz
BandMeasurement(buf, 4e6Hz, 1.575e6Hz)                   # explicit IF
BandMeasurement(buf; sampling_frequency = 4e6Hz)         # kwarg form
source
Tracking.BandMeasurements — Type

A NamedTuple of BandMeasurements, the multi-band input of track / track!, keyed by GNSSSignals.get_band_id (e.g. :L1, :L5), a compile-time constant.

source

Downconversion and correlation

The default CPUThreadedDownconvertAndCorrelator runs in Float32 and accepts any complex sample type. For Complex{Int16} samples there are three opt-in backends, each with a threaded and a single-threaded variant, selected via the downconvert_and_correlator keyword:

Tracking.CPUDownconvertAndCorrelator — Type

CPU-based implementation of downconversion and correlation. Its scratch buffers grow lazily on first use and are reused thereafter, so a hoisted instance is allocation-free 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.CPUThreadedDownconvertAndCorrelator — Type

Multi-threaded CPU downconvert and correlate, parallelized over the satellites (PRNs) of each group. Holds one set of scratch buffers per thread, indexed by Threads.threadid() inside @batch (which pins each iteration to a thread), grown lazily and reused thereafter.

With more than one thread and more than one work item, each @batch launch (one per downconvert_and_correlate! call, so one per code block inside track!) keeps a Polyester allocation of ~96 B. It is bounded per launch and does not scale with the input buffer. Measuring a noise density keeps it at ~96 B (the noise despreads ride the loop as extra items behind one pointer, see _park_noise_items!), but a single-satellite state then pays it too, as its loop has two items; in exchange the despread usually costs a fraction of its ~1.75 µs serial time.

Polyester copies everything the @batch region references into one argument tuple per launch. A tuple of only isbits members (bare Arrays qualify, as PtrArrays) is elided; otherwise it is heap-allocated at its sizeof, with immutable structs copied by value and mutable objects as one pointer. The non-isbits member here is the GNSS signal (GPSL1CA wraps a Matrix{Int16} and a non-isbits SignalLUT), which code-replica generation needs. A prototype that generates from the LUT's bare arrays passed as separate arguments measures 0 B/launch at full throughput, but needs a GNSSSignals API that does not re-wrap them in a struct; a serial code-gen pre-pass also reaches 0 but serializes ~30 % of the work. Neither is worth ~96 B per launch.

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.Int16DownconvertAndCorrelator — Type

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 it once outside the track! loop and pass it via the downconvert_and_correlator keyword: allocation-free in steady state for SVector sample shifts (EPL/VEPL); runtime AbstractVector shifts allocate only the returned Vector.

Arguments

max_meas (required, no default) is the largest |real|/|imag| any sample will take, i.e. the front end's full-scale (e.g. 2^11 for a 12-bit ADC). The carrier amplitude is the largest with 2·max_meas·amplitude ≤ typemax(Int16), since the complex wipe DI = mᵣ·cos + mᵢ·sin sums two products. Under-declaring max_meas silently overflows the Int16 wipe and corrupts the correlation; over-declaring is safe and only coarsens the carrier quantisation.

Performance: keep `max_meas < 2^14`

For max_meas ≥ 2^14 no Int16-safe amplitude ≥ 1 exists: the backend falls back to an exact Int32 wipe (off the vpmaddwd fast path) and a shorter strip-mine block. Correct, but measurably slower.

blk is the strip-mine block length in samples. It must be ≥ 1 (blk ≤ 0 would hang track!, #169); a larger-than-safe blk is clamped so the accumulators never wrap.

source
Tracking.OneBitDownconvertAndCorrelator — Type

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 measurement, carrier and code and correlates with XOR + popcount, trading ≈2–3 dB of SNR for bit-wise speed. Construct it once outside the track! loop and pass it via the downconvert_and_correlator keyword for an allocation-free steady state.

source
Tracking.TwoBitDownconvertAndCorrelator — Type

Two-bit (sign + magnitude) bit-wise CPU downconvert + correlate backend (single-threaded). Opt-in alternative to [CPUDownconvertAndCorrelator] for Complex{Int16} sample buffers, between OneBitDownconvertAndCorrelator and Int16DownconvertAndCorrelator: measurement and carrier are 4-level {±1, ±3}, the code 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) at bit-wise speed. Construct it 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 in ADC counts (|component| ≥ threshold ⇒ ±3); near 1σ of the front end's input is the near-optimal 4-level quantiser.

source
Tracking.AbstractDownconvertAndCorrelator — Type

Supertype of the downconvert-and-correlate backends.

The downconvert_and_correlate(!) loops are defined once on this type (see downconvert_and_correlate_cpu.jl); a subtype implements the hooks: _despread_one_signal! (the correlation primitive, for satellites and the noise reference alike; alone it gives a working single-signal backend), _correlate_signals / _scratch_buffers (multi-signal kernel and scratch), _threading (default serial) and _check_sample_type (default no-op).

source

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