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 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 and correlator_outputs, 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. The same applies to a bare downconvert_and_correlate: the returned state's correlator_outputs alias the input's, so reuse of one input state across several calls appends to the same buffers.
Each signal's noise estimator is shared the same way, and track advances it: a CorrelatorNoiseEstimator's sliding window and RNG stream are written in place, so branching two states from one input leaves them dividing by one shared noise reference, and advancing two of them concurrently races on it. Build a separate TrackState per thread rather than branching one — see downconvert_and_correlate for why the window is not copied.
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 carrier loop bandwidth by 1/N for its integration length N, so longer integration stays stable without re-tuning; the code loop keeps its absolute bandwidth, capped only where stability requires (see ConventionalPLLAndDLL).
Tracking.track! — Function
track!(
measurements,
track_state;
downconvert_and_correlator,
doppler_update_interval
)
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 96 B per completed code-block integration per group — but only when the process runs with more than one thread and the group's loop holds more than one work item (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.
A TrackState whose C/N₀ estimators read a measured noise density pays the same ~96 B, even though its per-signal noise despread rides that same parallel loop: the loop reaches the despread's descriptor through one pointer rather than by value. It does mean a group with a single satellite now has two work items, so it pays the residual where it previously paid none.
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)
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.doppler_update_interval— the Doppler-estimation / NCO-update interval, a time (e.g.1u"ms"). Defaults tonothing⇒ 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). 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 (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 carrier loop bandwidth auto-scales by 1/N so longer integration stays stable without re-tuning; the code loop keeps its absolute bandwidth and is capped only where the longer update interval would otherwise threaten stability.
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 carrier loop bandwidth auto-scales by 1/N so the loop stays stable at any length, and the code loop bandwidth is left as configured unless stability caps it.
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.
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:
- 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_outputsbuffer, tagged with the sample index at which it ended (aCorrelatorOutput; 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. - 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 (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:
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.
External correlator producers
The correlate phase and the Doppler estimator are decoupled: the estimator consumes each signal's correlator_outputs buffer and never touches the sample buffer directly. That lets an external producer — e.g. an FPGA/hardware correlator that streams completed correlator dumps — supply the outputs and run only the loop filters on the host. The FPGA does downconversion + correlation; the host folds the dumps through the estimator and streams the resulting NCO Dopplers back. (The DMA transport, wire format and NCO marshaling live in GNSSReceiver.jl; this section is only the Tracking.jl-side contract.)
The offload loop, per processing chunk (epoch):
Ingest. For each satellite/signal, build a
CorrelatorOutputfrom the producer's raw accumulator and append it withappend_correlator_output!— appended per signal insample_indexorder:append_correlator_output!(track_state, output, group, prn, sig)Prefer this over mutating the vector
get_correlator_outputsreturns: it documents intent and type-checks the correlator against the signal's.Estimate. Fold the batch and update every satellite's NCO once by calling
estimate_dopplers_and_filter_prompt!with a per-band sampling-frequency source instead of a sample buffer — aNamedTuple/Dictkeyed by the band'sget_band_id(e.g.:L1,:L5):estimate_dopplers_and_filter_prompt!(track_state, (L1 = 25e6Hz, L5 = 25e6Hz)) # or: estimate_dopplers_and_filter_prompt!(track_state, Dict(:L1 => 25e6Hz))This skips
downconvert_and_correlate!entirely. The rate must be per band (the estimator walks groups that may be on different bands); passing the originalBandMeasurementsworks too and reads the rate off each band'sBandMeasurement, so the CPUtrack!path is unchanged. The estimator consumes and clears each signal'scorrelator_outputsas part of this call — it is empty again afterwards, ready for the next chunk. This is the public contract external producers rely on.Marshal the updated
code_doppler/carrier_doppler(read withget_code_doppler/get_carrier_doppler) back to the hardware NCOs.
Caller contract
CorrelatorOutput.correlator— the raw accumulator (sum-of-products overintegrated_samples, matching whatnormalizeexpects). ReusingEarlyPromptLateCorrelator/update_accumulatoron the producer side satisfies this by construction.CorrelatorOutput.integrated_samples— the producer's true sample count for that integration.CorrelatorOutput.sample_index— the chunk-relative end sample. The software path writes it buffer-relative (signal_start_samplereturns to 1 eachtrack!); a producer with a free-running global sample counter must subtract the current chunk/epoch origin so every satellite reads a consistent per-chunk time grid (the estimator itself does not read it — it is preserved for downstream vector/Kalman tracking).
Transport delay
The DLL discriminator uses the satellite's pre-update code_doppler as the value in effect during the integration — correct for a one-epoch feedback pipeline (the NCO written from this chunk's estimate takes effect on the next chunk). If your hardware pipeline has deeper feedback delay, that is the caller's responsibility: tag outputs with their epoch and schedule each NCO update to a known future epoch so the loop stays consistent with when the Doppler actually reaches the correlator.
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> 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 rather than zeros in that buffer on purpose: the default C/N₀ estimator measures each signal's noise floor from the samples it is given, and a buffer that is identically zero has no floor to measure — track! says so, once per signal, rather than dividing by it (see AbstractNoiseEstimator).
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 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.
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.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, 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 loop has more than one work item, each launch keeps a small Polyester allocation, 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 or the single-threaded CPUDownconvertAndCorrelator there is no such residual.
The residual measures ~96 B per launch, and a TrackState whose C/N₀ estimators read a measured noise density measures the same ~96 B: the chunk's noise despreads ride the same loop as extra work items (see _dc_group_loop!), but the loop reaches their descriptors through one pointer into a reusable box rather than by value — see _park_noise_items! for why by-value copies cost 152 B of Polyester argument tuple and a pointer costs 8. The one thing the noise reference still changes is that a single-satellite state pays the residual where it previously paid none, because the loop then has two items rather than one. What that buys is the despread's wall time: as one more item in the parallel loop it usually costs a fraction of the ~1.75 µs it costs as a serial pass, and full price only when it happens to open a new scheduling step. The figure is per launch and bounded; it does not scale with the input buffer.
Why it allocates at all — Polyester copies everything the @batch region references into one argument tuple per launch and heap-allocates that tuple (ManualMemory.Reference) so the worker tasks can read it. Two rules decide what that costs:
- A tuple whose every member is
isbits(bareArrays becomePtrArrays, so they qualify) does not escape and is elided outright — that is why aVector{Float64}kernel allocates nothing. - As soon as one member is not, the whole tuple is allocated at its
sizeof, and each member is copied by value if it is an immutable struct, or as a single pointer if it is a mutable object.
Which is why "capture a struct or its bare arrays" is not a wash: a plain struct whose fields are Matrixes and isbits scalars costs its own sizeof in the tuple, where the same arrays passed separately cost 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 — and each satellite's code-replica generation needs it. (The per-satellite TrackedSat is likewise non-isbits, but it is reached through Vector{TrackedSat}, which is mutable and so costs one pointer; it is the loop's other captures that put the tuple over the isbits line.)
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 ~96 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.
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 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.
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.
Tracking.Int16ThreadedDownconvertAndCorrelator — Type
Multi-threaded integer (Complex{Int16}) hybrid-blocked backend. One Int16ScratchBuffers per thread (indexed by Threads.threadid() inside @batch); the carrier table is immutable and shared. See Int16DownconvertAndCorrelator for the max_meas amplitude argument.
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 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.
Tracking.OneBitThreadedDownconvertAndCorrelator — Type
Multi-threaded one-bit bit-wise backend. One OneBitScratchBuffers per thread (indexed by Threads.threadid() inside @batch). See OneBitDownconvertAndCorrelator.
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, 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.
Tracking.TwoBitThreadedDownconvertAndCorrelator — Type
Multi-threaded two-bit bit-wise backend. One TwoBitScratchBuffers per thread (indexed by Threads.threadid() inside @batch). See TwoBitDownconvertAndCorrelator.
Tracking.AbstractDownconvertAndCorrelator — Type
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 — _despread_one_signal! (the one correlation primitive, which both the per-satellite path and the noise reference go through), _correlate_signals / _scratch_buffers (multi-signal 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; one that overrides only _despread_one_signal! gets a working single-signal path, satellites and noise measurement alike.
Correlator sample shifts and the early/late spacing are documented in Correlator.