Correlator
The correlator computes the correlation between the incoming signal and the locally generated replica code at multiple code-phase offsets (early, prompt, late, …). Each TrackedSignal on a TrackedSat holds its own correlator, so a multi-signal satellite carries one correlator per signal.
Why early/late spacing matters
The prompt accumulator is what the PLL discriminator and CN0 estimator consume; the surrounding early/late accumulators exist purely to give the DLL a slope estimate of the code-correlation triangle. The early-minus-late amplitude crosses zero exactly when the replica is aligned with the incoming code, and its sign on either side tells the DLL which direction to push.
Notation used below:
d— early-to-late spacing, expressed as a fraction of one PRN chip (sod = 0.5means E and L sit ½ chip apart).ε— code-phase error: the offset between the locally generated replica and the incoming code, in chips.ε = 0is perfect lock.E,P,L— early, prompt, and late correlator outputs (complex accumulators).B_fe— pre-correlation (front-end) bandwidth in Hz: the analog + ADC bandwidth that limits how sharp the correlation peak can be.T_chip— one PRN chip period in seconds (1 / f_code); equivalentlyB_fe · T_chipis the front-end bandwidth measured in chip-rate units.Fs— sampling frequency in Hz.f_code— chip rate in Hz (1.023 MHz for GPS L1 C/A).
With those names settled, the spacing d determines several DLL properties together:
- Thermal-noise jitter decreases as
dshrinks, because the noise on the early and late accumulators is correlated and partially cancels in the early-minus-late difference. This is the central result of Van Dierendonck, Fenton & Ford ("Theory and Performance of Narrow Correlator Spacing in a GPS Receiver", NAVIGATION 39(3), 1992) and is contrary to the intuition that wider should give a smoother discriminator — it only does so when E and L are time-multiplexed (dither-DLL), which Tracking.jl does not do; here E, P, L are computed simultaneously from the same sample stream. - Multipath error also decreases as
dshrinks: a narrower discriminator samples the correlation function nearer the peak, where multipath distortion is smallest. Same source, same paper. - Robustness to dynamics decreases as
dshrinks. The discriminator is only linear inεover|ε| < d/2(the Springer Handbook of GNSS, §14.4.4, calls this the pull-in rangeT_DLL); outside it the slope drops to zero with the sign still correct, so the loop converges but no longer behaves as designed. The lock rule of thumb (Springer eq. 14.57) is3σ_DLL + ε_D ≤ T_DLL ≈ d/2, so a narrowdshrinks the budget for dynamic stress errorε_Dand the receiver is more prone to losing lock under fast LOS acceleration or scintillation. This is why NovAtel's original narrow-correlator design widensdduring acquisition and only narrows once in steady-state lock. - Front-end bandwidth interaction. The benefits above saturate (and eventually reverse) once
ddrops below roughly1 / (B_fe · T_chip): a band-limited correlation peak is rounded, so the early-minus-late slope flattens and noise jitter starts to grow again. Van Dierendonck reportsd ≈ 0.1is optimal at 8 MHz front-end bandwidth on L1 C/A.
So "narrower is better, up to a point that the front-end bandwidth allows" — there is no fundamental noise penalty until the band-limit kicks in.
Tracking.jl-specific constraint: sample rate sets the minimum d
Tracking.jl expresses the spacing as a preferred_early_late_to_prompt_code_shift fraction-of-chip; the actual sample shift is the nearest integer at the current sampling rate, with a floor of one sample. The realized d is therefore max(1, round(d_pref · Fs / f_code)) · f_code / Fs chips. To realize the recommended narrow d ≈ 0.1 on L1 C/A (1.023 MHz chip rate) the sampling rate has to be at least 10 · f_code ≈ 10.23 MHz; at 4 MHz the nearest integer shift saturates the spacing at 1 / (4e6 / 1.023e6) ≈ 0.26 chips regardless of what preferred_early_late_to_prompt_code_shift is set to. So narrow-correlator operation in Tracking.jl is bottlenecked by both the front-end bandwidth (the physical argument above) and the ADC/sampling rate (this implementation detail).
The VeryEarlyPromptLateCorrelator adds a second outer pair (very-early / very-late) to handle the multi-peaked code-correlation function of BOC-modulated signals like Galileo E1B — the inner pair locks the central peak and the outer pair disambiguates from the side-peaks.
Default Correlators
The default correlator depends on the GNSS signal type and is returned by get_default_correlator:
EarlyPromptLateCorrelatorfor GPS L1 C/A, GPS L2CM, GPS L2CL, GPS L5I, GPS L5Q, Galileo E5a-I, Galileo E5a-Q (BPSK modulation)VeryEarlyPromptLateCorrelatorfor GPS L1C-D, GPS L1C-P, Galileo E1B and Galileo E1C (BOC / CBOC / TMBOC modulation)
Tracking.get_default_correlator — Function
get_default_correlator(gpsl1)
get_default_correlator(gpsl1, num_ants)
Get the default correlator for the given GNSS system. Returns an EarlyPromptLateCorrelator for GPS L1 or a VeryEarlyPromptLateCorrelator for systems like Galileo E1B that use BOC modulation.
Correlator Types
Tracking.EarlyPromptLateCorrelator — Type
EarlyPromptLateCorrelator holding a user defined number of correlation values. The code is shifted in samples. Hence, the specified code shift is actually a preferred code shift, because depending on sampling frequency and code frequency the specified code shift might not be the actual code shift. It is as close as possible, though. The algorithm makes sure that at least one sample is shifted.
Tracking.VeryEarlyPromptLateCorrelator — Type
VeryEarlyPromptLateCorrelator holding a user defined number of correlation values. The code is shifted in samples. Hence, the specified code shift is actually a preferred code shift, because depending on sampling frequency and code frequency the specified code shift might not be the actual code shift. It is as close as possible, though. The algorithm makes sure that at least one sample is shifted.
Accessing Correlator Values
Tracking.get_early — Function
get_early(correlator)
Get early correlator
Tracking.get_prompt — Function
get_prompt(correlator)
Get prompt correlator
Tracking.get_late — Function
get_late(correlator)
Get late correlator
Tracking.get_accumulators — Function
get_accumulators(correlator)
Get all correlator accumulators
Tracking.get_num_accumulators — Function
get_num_accumulators(correlator)
Get number of accumulators
Tracking.get_num_ants — Method
get_num_ants(correlator)
Get number of antennas from correlator
Correlator outputs
Each completed integration within a processing chunk is recorded as a CorrelatorOutput, collected per signal and consumed by the Doppler estimator after the chunk (see Chunked Doppler updates). An external correlator producer (e.g. an FPGA) can build these itself and feed them straight to the estimator — see External correlator producers.
Tracking.CorrelatorOutput — Type
A single completed correlator output produced within one processing chunk.
track! processes each measurement in fixed-size time chunks. Every time a signal's coherent integration completes inside a chunk, the (raw, un-normalized) accumulator is snapshotted into a [CorrelatorOutput] and appended to that signal's correlator_outputs array; the Doppler estimator then folds over those records after the chunk. Storing the raw correlator plus integrated_samples lets the estimator normalize it (dividing by the sample count) and matches last_fully_integrated_correlator.
An external correlator producer (e.g. an FPGA) can build these itself and feed them straight to the estimator with append_correlator_output!; see External correlator producers.
Fields:
correlator: the raw accumulated correlator at completion (not normalized). Fill it with the accumulator scalingnormalizeexpects — the raw sum-of-products overintegrated_samples— which an external producer gets for free by reusingEarlyPromptLateCorrelator/update_accumulator.integrated_samples: samples integrated into this output (fornormalize, the loop-filterintegration_time, and the bit-buffer block count). For an external producer this is the true sample count of that integration.sample_index: sample index at which this integration ended, on the time grid the Doppler estimator and vector tracking read. The software correlate phase writes it buffer-relative — the end sample within the currenttrack!measurement (signal_start_samplereturns to 1 at the top of everytrack!call). An external producer with a free-running global sample counter must therefore map its global timestamp onto the same per-chunk origin before storing it here: subtract the sample index of the current chunk/epoch origin so the value is relative to the chunk the estimator is folding, keeping every satellite on one consistent time grid. The estimator itself does not readsample_index(the loop filters key offintegrated_samples); it is preserved for downstream vector/Kalman tracking.
Tracking.get_correlator_outputs — Function
get_correlator_outputs(t)
The CorrelatorOutputs this signal completed during the most recent processing chunk, in order. Populated by the correlate phase and consumed + cleared by the Doppler estimator after each chunk, so it is empty between track! calls; read it inside a custom estimator, or right after a bare downconvert_and_correlate!. See Chunked Doppler updates.
Tracking.append_correlator_output! — Function
append_correlator_output!(t, output)
Append an externally built CorrelatorOutput to signal's per-chunk correlator_outputs buffer and return signal.
This is the blessed ingest path for an external correlator producer (e.g. an FPGA streaming correlator dumps): build a CorrelatorOutput from the producer's raw accumulator, sample count and chunk-relative end index, append it here per signal in sample_index order, then run estimate_dopplers_and_filter_prompt! with a per-band sampling-frequency source to fold the batch and update the NCOs — no sample buffer or downconvert_and_correlate! needed. The estimator consumes and clears the buffer as part of that call, so between chunks it is empty again (same contract as the software correlate phase). Prefer this over mutating the vector returned by get_correlator_outputs directly — it documents intent and is type-checked (the output's correlator type must match the signal's).
See External correlator producers for the full offload contract.
append_correlator_output!(s, output, id)
Append an externally built CorrelatorOutput to the buffer of the addressed signal and return track_state. The output is followed by the same satellite/signal addressing forms as the per-signal accessors (e.g. get_correlator_outputs):
append_correlator_output!(track_state, output)— one group, one sat, one signal.append_correlator_output!(track_state, output, prn)— one group, one signal.append_correlator_output!(track_state, output, group, prn)— multi-group, one signal.append_correlator_output!(track_state, output, group, prn, sig)— per-signal.
The signal-level method (append_correlator_output!(::TrackedSignal, output)) carries the contract; see it and External correlator producers.
Sample shifts
The downconvert/correlate inner loop walks the incoming sample buffer once and asks the correlator where to lay down each accumulator relative to the prompt — that mapping is get_correlator_sample_shifts. The spacing in samples between the outermost shifts is get_early_late_sample_spacing, which is what the DLL uses to scale its discriminator output back to chips.
Tracking.get_correlator_sample_shifts — Function
get_correlator_sample_shifts(
correlator,
sampling_frequency,
code_frequency
)
Calculate the replica phase offset required for the correlator with respect to the prompt correlator, expressed in samples. The shifts are ordered from latest to earliest replica.
get_correlator_sample_shifts(
correlator,
sampling_frequency,
code_frequency
)
Calculate the replica phase offset required for the correlator with respect to the prompt correlator, expressed in samples. The shifts are ordered from latest to earliest replica.
Tracking.get_early_late_sample_spacing — Function
get_early_late_sample_spacing(
correlator,
sampling_frequency,
code_frequency
)
Calculate the total spacing between early and late correlator in samples.
Tracking.update_accumulator — Method
update_accumulator(correlator, accumulators)
Update the correlator with new accumulators
Tracking.update_accumulator — Method
update_accumulator(correlator, accumulators)
Update the correlator with new accumulators
Antenna and accumulator counts
Tracking.NumAnts — Type
Type parameter wrapper for specifying the number of antennas in the system. Use NumAnts(n) to create an instance.
Tracking.NumAccumulators — Type
Type parameter wrapper for specifying the number of correlator accumulators. Use NumAccumulators(n) to create an instance.
Post-correlation filter
A post-correlation filter reduces the correlator's per-antenna taps to the single channel the Doppler estimator and the C/N₀ estimator see. It does not combine the taps itself: it declares its combining weights through get_weights, and Tracking applies them. That indirection is what keeps the C/N₀ honest on an antenna array — the measured noise covariance is reduced through the same weights, so numerator and denominator describe the same channel (see CN0 Estimator).
DefaultPostCorrFilter is the identity for a single antenna and selects the last element for an array. For multi-antenna tracking a beamformer is the natural override; see the worked example in Tracking State.
Tracking.AbstractPostCorrFilter — Type
Abstract post correlation filter for the prompt signal — the linear combiner that reduces a correlator's per-antenna taps to the single complex value the discriminators and the C/N₀ estimator see.
An implementation must provide two methods:
get_weights(filter, ::NumAnts{M})— the combining weightswthis filter currently applies. A plainComplexF64atM == 1, anSVector{M,ComplexF64}above.update(filter, prompt)— return the filter to use for the next record, which is where an adaptive beamformer recomputes its weights.
The contract is deliberately linear in the antennas: the combined tap is wᴴ·b. That is what lets the C/N₀ path stay honest without any per-satellite noise state. The noise reference measures one spatial covariance R̂ per signal (see AbstractNoiseEstimator), and each satellite reduces it to its own scalar floor through its own weights, N₀ = wᴴR̂w — exact for any fixed w, because E[|wᴴn|²] = wᴴRw. A filter that combined its antennas non-linearly would have no such closed form, and its C/N₀ would silently be a ratio of two different noise scales.
Tracking.DefaultPostCorrFilter — Type
This is the default post correlation filter. For a single antenna channel it returns the prompt value as is. For multi antenna systems it selects the last antenna's value — the noise reference's covariance reduces to that antenna's own floor under these weights, so the two sides of the C/N₀ ratio agree.
Tracking.get_weights — Function
The linear combining weights w that filter currently applies to a correlator tap, as a ComplexF64 for NumAnts(1) and an SVector{M,ComplexF64} for NumAnts(M). The combined tap is wᴴ·b, and the matching post-combination noise floor is wᴴR̂w — see AbstractPostCorrFilter.
Required for every AbstractPostCorrFilter; there is no fallback, because a filter whose weights are unknown cannot be given a correct C/N₀.
Examples
A beamformer that averages the antenna elements:
struct MyBeamformer <: AbstractPostCorrFilter end
Tracking.update(f::MyBeamformer, prompt) = f
Tracking.get_weights(::MyBeamformer, ::NumAnts{1}) = 1.0 + 0.0im
Tracking.get_weights(::MyBeamformer, ::NumAnts{M}) where {M} =
SVector{M,ComplexF64}(ntuple(_ -> 1 / M + 0.0im, M))Integration sizing
Tracking.calc_signal_samples_to_integrate — Function
calc_signal_samples_to_integrate(
signal,
signal_start_sample,
sampling_frequency,
code_doppler,
code_phase,
preferred_num_code_blocks_to_integrate,
secondary_code_or_bit_found,
num_samples_signal
)
Calculate the number of signal samples to integrate and whether the integration will complete a full code period. Returns a tuple of (samplestointegrate, isintegrationcompleted). Takes into account available signal samples and code block boundaries.
Adjusting the spacing of the stock correlator
The stock EarlyPromptLateCorrelator already exposes its spacing as a constructor knob — you do not need a custom correlator type just to change d. Pass the desired chip fraction via preferred_early_late_to_prompt_code_shift and hand it to a TrackedSat:
julia> using Tracking, GNSSSignals
julia> using Tracking: Hz
julia> track_state = TrackState(; signal = GPSL1CA());
julia> sat = TrackedSat(GPSL1CA(), 1, 50.0, 1000.0Hz;
correlator = EarlyPromptLateCorrelator(
preferred_early_late_to_prompt_code_shift = 0.1,
));
julia> track_state = add_satellite!(track_state, :default, sat);
julia> get_correlator(track_state, :default, 1, 1).preferred_early_late_to_prompt_code_shift
0.1Likewise VeryEarlyPromptLateCorrelator exposes both the inner (preferred_early_late_to_prompt_code_shift, default 0.15) and the outer (preferred_very_early_late_to_prompt_code_shift, default 0.6) spacing.
Remember the sample-rate floor: the actual realized d is the nearest integer sample shift at the current sampling rate (minimum 1), so on low-Fs configurations the preferred value may be rounded to a coarser realized value (see the Tracking.jl-specific constraint above).
Changing the spacing of a satellite that's already tracking
TrackedSat, TrackedSignal, and EarlyPromptLateCorrelator are all immutable structs, so changing the spacing means producing a replacement sat with the new value and writing it into the satellites-dictionary slot. The cleanest way is Accessors.@set for the deeply-nested field update, followed by Dictionaries.set! for the dictionary write:
julia> using Accessors, Dictionaries
julia> sat = track_state.groups[:default].satellites[1];
julia> new_sat = @set sat.signals[1].correlator.preferred_early_late_to_prompt_code_shift = 0.7;
julia> Dictionaries.set!(track_state.groups[:default].satellites, 1, new_sat);
julia> get_correlator(track_state, :default, 1, 1).preferred_early_late_to_prompt_code_shift
0.7@set preserves everything else on the correlator (including the in-flight accumulator), only swapping the one field. The dictionary slot is reassigned in place, so the TrackState's concrete type doesn't change and the hot path stays type-stable across the swap.
Custom Correlators
Roll your own correlator when you need a layout the stock types don't provide — extra taps, asymmetric shifts, a non-standard get_correlator_sample_shifts mapping, or a different get_prompt_index arrangement. Subtype AbstractCorrelator (or AbstractEarlyPromptLateCorrelator to inherit the early/late accessors) and implement a small interface.
What to implement
| Method | Purpose |
|---|---|
get_accumulators(c) | Return the backing storage of accumulators (e.g. an SVector of ComplexF64). |
get_num_accumulators(c) | Return how many accumulators the correlator holds. Defaults to size(get_accumulators(c), 1) — only override if your layout differs. |
update_accumulator(c, accumulators) | Construct a fresh correlator instance with the given accumulators (immutable update). |
get_correlator_sample_shifts(c, sampling_frequency, code_frequency) | Return an SVector{N,Int} of sample offsets relative to the prompt, ordered from latest replica to earliest. |
The get_prompt_index default places the prompt at the middle of the accumulator vector — if you want a different layout (e.g. asymmetric shifts) override that too.
Skeleton
A 7-tap correlator with three early and three late accumulators (e.g. as a building block for a custom multipath-mitigation discriminator that fits a slope over multiple early/late samples rather than using a single E−L difference):
julia> using Tracking, GNSSSignals, StaticArrays
julia> using Tracking: AbstractEarlyPromptLateCorrelator,
get_initial_accumulator,
calc_preferred_code_shift_to_sample_shift,
NumAnts, NumAccumulators
julia> struct MultiTapCorrelator{M,T} <: AbstractEarlyPromptLateCorrelator{M}
accumulators::SVector{7,T}
tap_spacing_chips::Float64
# Dispatch on accumulator-vector element type to pin M, mirroring
# the pattern in `src/correlators/early_prompt_late.jl`.
function MultiTapCorrelator(
accumulators::AbstractVector{SVector{M,T}}, tap_spacing_chips,
) where {M,T<:Complex}
new{M,SVector{M,T}}(accumulators, tap_spacing_chips)
end
function MultiTapCorrelator(
accumulators::AbstractVector{T}, tap_spacing_chips,
) where {T<:Complex}
new{1,T}(accumulators, tap_spacing_chips)
end
end
julia> # Convenience kwarg-only constructor — matches the EPL surface
function MultiTapCorrelator(; num_ants::NumAnts = NumAnts(1),
tap_spacing_chips = 0.1)
MultiTapCorrelator(
get_initial_accumulator(num_ants, NumAccumulators(7)),
tap_spacing_chips,
)
end;
julia> Tracking.update_accumulator(c::MultiTapCorrelator, accumulators) =
MultiTapCorrelator(accumulators, c.tap_spacing_chips);
julia> function Tracking.get_correlator_sample_shifts(
c::MultiTapCorrelator, sampling_frequency, code_frequency,
)
s = calc_preferred_code_shift_to_sample_shift(
c.tap_spacing_chips, sampling_frequency, code_frequency,
)
SVector(-3s, -2s, -s, 0, s, 2s, 3s)
end;To use it, plug it into a TrackedSat and build the TrackState from that sat (so the slot type is taken from the custom correlator rather than the default EarlyPromptLateCorrelator):
julia> using Tracking: Hz
julia> sat = TrackedSat(GPSL1CA(), 1, 50.0, 1000.0Hz;
correlator = MultiTapCorrelator(tap_spacing_chips = 0.1));
julia> track_state = TrackState(GPSL1CA(), sat);
julia> get_num_accumulators(get_correlator(track_state, 1))
7The default DLL discriminator only reads the outermost early/late pair, so a multi-tap correlator like this is most useful in combination with a custom Doppler estimator (see Custom Doppler Estimator) that walks every accumulator. The reference implementations in src/correlators/early_prompt_late.jl and src/correlators/very_early_prompt_late.jl show the full pattern including the multi-antenna SVector element type.