Custom Doppler Estimator
Tracking.jl ships ConventionalPLLAndDLL (and the FLL-assisted variant ConventionalAssistedPLLAndDLL), but you can plug in a different Doppler-estimation algorithm — e.g. a Kalman filter or a joint-channel estimator — by implementing a small set of methods.
Where state lives
A TrackedSat carries everything per-satellite: the shared carrier/code Doppler and phase, a tuple of TrackedSignals (one per signal being tracked), and the per-satellite estimator state in doppler_estimator_state. The estimator object itself (<: AbstractDopplerEstimator) is configuration plus any shared state that spans satellites or systems. Cleanly separating per-sat state from shared state makes the signal-path code (downconvert, correlate, sample-bookkeeping) estimator-agnostic — it touches the per-signal and per-sat fields directly and rewraps doppler_estimator_state unchanged.
What you implement
An estimator type subtyping
AbstractDopplerEstimator. It carries configuration and any cross-satellite or cross-system shared state (filter parameters, joint-state vectors, …). Per-satellite state does not live here.A per-satellite state struct (any name and shape you like). For the conventional estimator it is
SatConventionalPLLAndDLLholding the loop filters and seed Dopplers; for a Kalman filter it might be the Kalman state vector.An
init_estimator_statemethod for your estimator. It produces the initial per-sat state for a sat entering the track set (acquisition → tracking handoff):Tracking.init_estimator_state(::MyEstimator, sat::TrackedSat) = MyPerSatState(...)It must be pure (no observable side effects): besides seeding real sats, it is also called on the throwaway PRN-0 template sat that fixes a group's slot type at
TrackStateconstruction, as a type probe when validating pre-built sats, and byreset_loop_filters!. Register sats into shared state inupdate_estimator_on_handoff(below), never here.(Optional) An
update_estimator_on_handoffmethod, if your estimator carries cross-satellite or cross-system shared state that has to change when sats join the track set (joint-channel covariance, per-batch normalization terms, …):Tracking.update_estimator_on_handoff(est::MyEstimator, new_sats) = ...It is called once per
add_satellite!/merge_satscall with the dictionary of incoming sats and runs after per-sat seeding, soinit_estimator_statesees the pre-update shared state. The default returnsestunchanged, so estimators with no shared state need not implement it.Type constraint: the returned estimator must have the same concrete type as the input.
TrackStateis parameterized on the estimator type, and changing it would break inference. Keep the estimator an immutablestructand put any growing shared state in resizable containers (e.g.Vector,Matrix) youpush!/resize!in place; if scalar fields need replacing, rebuild the estimator withSetfield.@setor a copying constructor.Use the return value of the handoff functions. Every entry point carries the estimator you return in the
TrackStateit gives back. SinceTrackStateis immutable, evenadd_satellite!can only honor a rebuilt estimator through its return value — writetrack_state = add_satellite!(track_state; ...), not a bareadd_satellite!(track_state; ...), if your estimator rebuilds itself on handoff. (For in-place estimators, including the conventional ones, the returnedTrackStateis the input itself.)An
estimate_dopplers_and_filter_promptmethod dispatched onTrackState{<:Any, <:MyEstimator}. This is where the actual update logic runs, once per chunk (see Chunked Doppler updates). It walks each group intrack_state.groups, reads the band'sBandMeasurementfrom themeasurements::BandMeasurementsNamedTuple viaget_band_id(group.band), and produces newTrackedSats with updatedcarrier_doppler/code_dopplerand updated per-sat estimator state.Each signal's correlator outputs completed during the chunk are in its
correlator_outputs::Vector{CorrelatorOutput}(a chunk may hold zero, one, or several per signal), each carrying the raw correlator, its integrated-sample count, and the end sample index. Fold over them in order — threading whatever filter state you carry — and write the NCO Doppler once (typically from the last output). Empty each signal'scorrelator_outputswhen done, and remember the NCO Doppler is held fixed across the whole chunk, so a discriminator that needs the replica's Doppler should read the satellite'scode_doppler/carrier_doppler(the value that generated the chunk), not an intermediate per-output estimate.The matching mutating method
estimate_dopplers_and_filter_prompt!(track_state, measurements)is whattrack!calls. To support real-time loops, define both — the mutating version walks each group'ssatellites.values::Vector{TrackedSat}and reassigns slots in place.The estimator only needs the per-band sampling frequency out of
measurements(to turn each output'sintegrated_samplesinto an integration time and to normalize the DLL discriminator). The shippedConventionalPLLAndDLLtherefore also accepts a bare per-band sampling-frequency source in place ofmeasurements— aNamedTuple/Dictkeyed byget_band_id— so an external correlator producer can run the estimator with no sample buffer (see External correlator producers). A custom estimator that likewise reads only the rate is encouraged to offer the same overload.
Skeleton
The skeleton below is the smallest possible working estimator: every method returns a constant. Real estimators replace these bodies with the actual algorithm, but the structure — five methods, two structs — is what the rest of Tracking.jl dispatches on.
julia> using Tracking, GNSSSignals
julia> using Tracking: AbstractDopplerEstimator, TrackedSat, TrackState,
SignalGroup, BandMeasurements, get_band_id
julia> # 1. Estimator type — config + any shared state
struct MyEstimator <: AbstractDopplerEstimator end
julia> # 2. Per-sat state struct
struct SatMyEstimator end
julia> # 3. Seed each sat
Tracking.init_estimator_state(::MyEstimator, ::TrackedSat) = SatMyEstimator();
julia> # 4. (Optional) shared-state update on handoff — default returns
# `est` unchanged, so estimators with no shared state may skip this.
Tracking.update_estimator_on_handoff(est::MyEstimator, new_sats) = est;
julia> # 5a. Immutable form — walks each group, looks up its band's
# `BandMeasurement`, returns a fresh `TrackState`. Real implementations
# read `sat.signals[*].correlator` and compute new dopplers; here
# we just return the state unchanged.
function Tracking.estimate_dopplers_and_filter_prompt(
track_state::TrackState{<:Any, <:MyEstimator},
measurements::BandMeasurements,
)
return track_state
end;
julia> # 5b. In-place form — what `track!` actually calls. Real
# implementations write back into `g.satellites.values[i]`.
function Tracking.estimate_dopplers_and_filter_prompt!(
track_state::TrackState{<:Any, <:MyEstimator},
measurements::BandMeasurements,
)
return track_state
end;Plug it into a TrackState like any other estimator:
julia> using Tracking: Hz
julia> track_state = TrackState(; signal = GPSL1CA(), doppler_estimator = MyEstimator());
julia> track_state = add_satellite!(track_state; prn = 1, code_phase = 0.0, carrier_doppler = 1000.0Hz);
julia> track_state = track(zeros(ComplexF64, 4000), track_state, 4e6Hz);
julia> get_doppler_estimator_state(get_sat_state(track_state, 1))
SatMyEstimator()The existing ConventionalPLLAndDLL implementation in src/conventional_pll_and_dll.jl shows the full pattern, including how the immutable and in-place forms share a _update_tracked_sat_doppler helper so they cannot drift, and how the per-signal walk distinguishes the estimator-driver signal (signals[1], which drives the conventional PLL/DLL) from the other signals (which only have their prompts filtered). That split is a convention ConventionalPLLAndDLL chooses — your own estimator can use every signal's state any way you like.
What stays generic
You do not override the downconvert/correlate path. The per-sat update after correlation reads only estimator-agnostic fields (code/carrier phase, integrated samples, correlator) and rewraps the existing doppler_estimator_state unchanged. add_satellite! and merge_sats call init_estimator_state with whatever estimator the TrackState was built with.
API reference
Tracking.AbstractDopplerEstimator — Type
Abstract supertype for doppler estimators. Concrete subtypes carry estimator configuration (and any cross-satellite or cross-system shared state). The per-satellite state used by the estimator lives in each TrackedSat wrapper — see init_estimator_state for the extension point.
Tracking.init_estimator_state — Function
Build the per-satellite Doppler-estimator state used by estimator for the given satellite. A custom doppler estimator must define this method for its AbstractDopplerEstimator subtype.
This function must be pure (free of observable side effects): besides seeding each real satellite on entry, it is also called to build the throwaway PRN-0 template sat that fixes a group's dictionary slot type at TrackState construction, as a type probe when validating pre-built sats, and by reset_loop_filters! to re-seed existing satellites. Estimators with cross-satellite shared state must therefore not register satellites here — perform shared-state registration in update_estimator_on_handoff, which is called exactly once per handoff with the real incoming satellites.
Tracking.update_estimator_on_handoff — Function
update_estimator_on_handoff(estimator, _new_sats)
Optionally update an estimator's cross-satellite or cross-system shared state when new satellites enter the track set. Called once per handoff entry point (merge_sats, add_satellite, add_satellite!) with the dictionary of incoming satellites, after per-sat seeding via init_estimator_state.
The default returns estimator unchanged, so estimators with no shared state need not implement it.
The returned estimator must have the same concrete type as the input — TrackState is parameterized on the estimator type, and changing it would break inference. For growing shared state, hold the storage in a resizable container (e.g. Vector, Matrix) on an otherwise immutable estimator and push!/resize! it in place; rebuild the estimator with Setfield.@set or a copying constructor when fields need replacing.
Every entry point honors the return value: the TrackState it returns carries the returned estimator. TrackState is immutable, so even the in-place add_satellite! can only honor a rebuilt estimator through its return value — callers must keep using the returned TrackState rather than the one they passed in (for in-place estimators the two are identical).
Tracking.estimate_dopplers_and_filter_prompt — Function
estimate_dopplers_and_filter_prompt(
track_state,
sampling_frequencies
)
Estimate Dopplers and filter prompts for all satellites where the correlation has reached the end of the code or multiples of that. This function uses the conventional PLL and DLL implementation to estimate Dopplers for carrier and code. Those Doppler estimations will be used to create the next replicas to downconvert and decode the incoming signal. In addition to the Doppler estimation it will also filter the prompt with the configured post correlation filter. In the case that the that the correlation hasn't reached the end, e.g. in the case the incoming signal did not provide enough samples, it will return struct with zeroed values.
The sampling-frequency argument may be either a BandMeasurements NamedTuple (the track path, from which the per-band rate is read) or a bare per-band sampling-frequency source — a NamedTuple/Dict keyed by get_band_id mapping each band to its sampling frequency. The latter is the entry point for an external correlator producer (e.g. an FPGA): it needs no sample buffer, only the per-signal correlator_outputs and the rate that maps integrated_samples to an integration time. See External correlator producers.
estimate_dopplers_and_filter_prompt(
track_state,
sampling_frequencies
)
Estimate Dopplers and filter prompts for all satellites where the correlation has reached the end of the code or multiples of that, using the vector PLL and DLL implementation — see VectorPLLAndDLL for how the per-satellite loops are closed. In the case that the correlation hasn't reached the end, e.g. in the case the incoming signal did not provide enough samples, the state is passed through unchanged.
Tracking.estimate_dopplers_and_filter_prompt! — Function
estimate_dopplers_and_filter_prompt!(
track_state,
sampling_frequencies
)
In-place version of estimate_dopplers_and_filter_prompt. Walks each group's Vector{TrackedSat} backing storage and overwrites slots with the new immutable TrackedSat value. Returns the same track_state object — allocation-free in steady state when track!'s preconditions are met.
As with the immutable form, the second argument is either a BandMeasurements NamedTuple or a bare per-band sampling-frequency source keyed by get_band_id. The estimator only reads the per-band sampling frequency (to turn each output's integrated_samples into an integration time and to normalize the DLL discriminator); it consumes each signal's correlator_outputs and clears them, whether they were produced by the software correlate phase or appended by an external producer via append_correlator_output!.
estimate_dopplers_and_filter_prompt!(
track_state,
sampling_frequencies
)
In-place version of estimate_dopplers_and_filter_prompt for the vector PLL and DLL estimator. Like the conventional form, the second argument is a BandMeasurements NamedTuple or a bare per-band sampling-frequency source keyed by get_band_id; each signal's correlator_outputs are consumed and cleared.