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 integration completion. It walks each group intrack_state.groups, reads the band'sBandMeasurementfrom themeasurements::BandMeasurementsNamedTuple viaband_key(group.band), and produces newTrackedSats with updatedcarrier_doppler/code_dopplerand updated per-sat estimator state.The matching mutating method
estimate_dopplers_and_filter_prompt!(track_state, measurements, prefer)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.
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, band_key
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).