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

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

  2. A per-satellite state struct (any name and shape you like). For the conventional estimator it is SatConventionalPLLAndDLL holding the loop filters and seed Dopplers; for a Kalman filter it might be the Kalman state vector.

  3. An init_estimator_state method 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 TrackState construction, as a type probe when validating pre-built sats, and by reset_loop_filters!. Register sats into shared state in update_estimator_on_handoff (below), never here.

  4. (Optional) An update_estimator_on_handoff method, 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_sats call with the dictionary of incoming sats and runs after per-sat seeding, so init_estimator_state sees the pre-update shared state. The default returns est unchanged, so estimators with no shared state need not implement it.

    Type constraint: 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. Keep the estimator an immutable struct and put any growing shared state in resizable containers (e.g. Vector, Matrix) you push!/resize! in place; if scalar fields need replacing, rebuild the estimator with Setfield.@set or a copying constructor.

    Use the return value of the handoff functions. Every entry point carries the estimator you return in the TrackState it gives back. Since TrackState is immutable, even add_satellite! can only honor a rebuilt estimator through its return value — write track_state = add_satellite!(track_state; ...), not a bare add_satellite!(track_state; ...), if your estimator rebuilds itself on handoff. (For in-place estimators, including the conventional ones, the returned TrackState is the input itself.)

  5. An estimate_dopplers_and_filter_prompt method dispatched on TrackState{<:Any, <:MyEstimator}. This is where the actual update logic runs, once per integration completion. It walks each group in track_state.groups, reads the band's BandMeasurement from the measurements::BandMeasurements NamedTuple via band_key(group.band), and produces new TrackedSats with updated carrier_doppler/code_doppler and updated per-sat estimator state.

    The matching mutating method estimate_dopplers_and_filter_prompt!(track_state, measurements, prefer) is what track! calls. To support real-time loops, define both — the mutating version walks each group's satellites.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.init_estimator_stateFunction

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.

source
Tracking.update_estimator_on_handoffFunction
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).

source