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 (so d = 0.5 means E and L sit ½ chip apart).
  • ε — code-phase error: the offset between the locally generated replica and the incoming code, in chips. ε = 0 is 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); equivalently B_fe · T_chip is 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 d shrinks, 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 d shrinks: a narrower discriminator samples the correlation function nearer the peak, where multipath distortion is smallest. Same source, same paper.
  • Robustness to dynamics decreases as d shrinks. The discriminator is only linear in ε over |ε| < d/2 (the Springer Handbook of GNSS, §14.4.4, calls this the pull-in range T_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) is 3σ_DLL + ε_D ≤ T_DLL ≈ d/2, so a narrow d shrinks the budget for dynamic stress error ε_D and the receiver is more prone to losing lock under fast LOS acceleration or scintillation. This is why NovAtel's original narrow-correlator design widens d during acquisition and only narrows once in steady-state lock.
  • Front-end bandwidth interaction. The benefits above saturate (and eventually reverse) once d drops below roughly 1 / (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 reports d ≈ 0.1 is 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:

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

source

Correlator Types

Tracking.EarlyPromptLateCorrelatorType

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.

source
Tracking.VeryEarlyPromptLateCorrelatorType

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.

source

Accessing Correlator Values

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

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 scaling normalize expects — the raw sum-of-products over integrated_samples — which an external producer gets for free by reusing EarlyPromptLateCorrelator / update_accumulator.
  • integrated_samples: samples integrated into this output (for normalize, the loop-filter integration_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 current track! measurement (signal_start_sample returns to 1 at the top of every track! 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 read sample_index (the loop filters key off integrated_samples); it is preserved for downstream vector/Kalman tracking.
source
Tracking.get_correlator_outputsFunction
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.

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

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

source

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

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

source

Antenna and accumulator counts

Tracking.NumAntsType

Type parameter wrapper for specifying the number of antennas in the system. Use NumAnts(n) to create an instance.

source
Tracking.NumAccumulatorsType

Type parameter wrapper for specifying the number of correlator accumulators. Use NumAccumulators(n) to create an instance.

source

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

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 weights w this filter currently applies. A plain ComplexF64 at M == 1, an SVector{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 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.

source
Tracking.DefaultPostCorrFilterType

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.

source
Tracking.get_weightsFunction

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

Integration sizing

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

source

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

Likewise 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

MethodPurpose
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))
7

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