Custom Doppler Estimator
The Doppler estimators are TrackingLoops'. track! drives any estimator through TrackingLoops' estimator interface, so ConventionalPLLAndDLL, ConventionalAssistedPLLAndDLL, NCOReferencedPLLAndDLL, the vector loop VectorPLLAndDLL and your own (a Kalman filter, a joint-channel estimator, …) are interchangeable as doppler_estimator.
Where state lives
A TrackedSat carries everything per satellite: the shared carrier/code Doppler and phase, one TrackedSignal per signal, and the estimator's per-satellite state in doppler_estimator_state. The estimator object (<: AbstractDopplerEstimator) holds configuration plus any state shared across satellites. The signal path (downconvert, correlate) is estimator-agnostic and passes doppler_estimator_state through unchanged.
What you implement
An estimator type subtyping
AbstractDopplerEstimator: configuration and shared state, no per-satellite state.A per-satellite state struct, e.g.
SatConventionalPLLAndDLL(loop filters and seed Dopplers) or a Kalman state vector.An
init_estimator_statemethod seeding a satellite entering the track set:TrackingLoops.init_estimator_state(::MyEstimator, sat::TrackedSat) = MyPerSatState(...)It must be pure: it is also called on the PRN-0 template sat that fixes a group's slot type, as a type probe when validating pre-built sats, and by
reset_loop_filters!. Register sats into shared state inupdate_estimator_on_handoff, never here.(Optional) An
update_estimator_on_handoffmethod, if shared state has to change when sats join (joint-channel covariance, 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, after per-sat seeding. The default returnsestunchanged.The returned estimator must keep the input's concrete type (
TrackStateis parameterized on it): grow shared state in place in resizable containers, or rebuild scalar fields withSetfield.@setor a copying constructor. SinceTrackStateis immutable, a rebuilt estimator only reaches you through the return value: writetrack_state = add_satellite!(track_state; ...). For in-place estimators the returnedTrackStateis the input itself.The per-record step, in one of two ways.
A
step_loopmethod (TrackingLoops' per-record interface) is alltrack!needs. Every completed record of every signal of the satellite goes throughTrackingLoops.step_loop(estimator, state, record, words, landing_sample)with the satellite's one state, the estimator-driver signal's (signals[1]) first. It returns the new state and carrier and code Dopplers; the satellite takes the driver's, as another signal's record returns the command in force. SeeLoopRecordfor what a record carries. Tracking does the rest: the bit buffer, the C/N₀ estimators, the phase snap at sync. ATrackingLoops.reset_estimator_state(estimator, state, carrier_doppler, code_doppler)method chooses whatreset_loop_filters!keeps; without one, the state is re-initialized. Such an estimator also runs unchanged in a hardware correlator's loop process.An
estimate_dopplers_and_filter_promptmethod onTrackState{<:Any, <:MyEstimator}instead replaces the whole estimate phase, for an estimator that does not fit a per-record step (e.g. one stepping all satellites jointly). It runs once per chunk (see Chunked Doppler updates), walkstrack_state.groups, reads each band'sBandMeasurementviaget_band_id(group.band), and producesTrackedSats with newcarrier_doppler/code_dopplerand per-sat state.Each signal's records of the chunk are in its
correlator_outputs::Vector{CorrelatorOutput}(zero, one or several), each with the raw correlator, its sample count, its end sample index (from the current measurement's first sample) and the replica's code phase there. Fold over them in order, write the NCO Doppler once (typically from the last), and emptycorrelator_outputs. The NCO Doppler is fixed across the chunk, so a discriminator needing the replica's Doppler reads the satellite'scode_doppler/carrier_doppler.track!calls the mutatingestimate_dopplers_and_filter_prompt!(track_state, measurements), which reassigns each group'ssatellites.valuesslots in place; define both.The generic estimate phase also accepts a
NamedTuple/Dictof per-band sampling frequencies (keyed byget_band_id) in place ofmeasurements, so an external correlator producer needs no sample buffer (see External correlator producers). A custom estimator that reads only the rate should offer the same overload.
Skeleton
The smallest working estimator that replaces the estimate phase, with constant bodies; the structure (two structs, four methods) is what Tracking dispatches on.
julia> using Tracking, TrackingLoops, GNSSSignals
julia> using Tracking:
TrackedSat,
TrackState,
SignalGroup,
BandMeasurements,
get_band_id
julia> using TrackingLoops: AbstractDopplerEstimator
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
TrackingLoops.init_estimator_state(::MyEstimator, ::TrackedSat) = SatMyEstimator();
julia> # 4. (Optional) shared-state update on handoff
Tracking.update_estimator_on_handoff(est::MyEstimator, new_sats) = est;
julia> # 5a. Immutable form: a real one folds each signal's `correlator_outputs`
# into new Dopplers and returns a fresh `TrackState`.
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!` calls: writes `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 generic estimate phase in src/conventional_pll_and_dll.jl shows the full pattern: the immutable form delegates to the in-place one, and the walk steps the estimator-driver signal (signals[1], whose records close the loops) before the passengers. That split is a convention of the per-record path only.
What stays generic
You do not override the downconvert/correlate path; it passes doppler_estimator_state through unchanged. add_satellite! and merge_sats call init_estimator_state with the TrackState's estimator.
API reference
TrackingLoops.init_estimator_state — Function
init_estimator_state(estimator, sat::TrackedSat)Build the per-satellite Doppler-estimator state for sat: TrackingLoops' init_estimator_state on the satellite's estimator-driver signal (signals[1]) and its handover Dopplers. A custom estimator defines either this form or TrackingLoops' four-argument one.
It must be pure: it also builds the PRN-0 template sat that fixes a group's slot type, probes types when validating pre-built sats, and re-seeds in reset_loop_filters!. Register satellites in shared state in update_estimator_on_handoff instead.
Tracking.update_estimator_on_handoff — Function
update_estimator_on_handoff(estimator, _new_sats)
Update an estimator's cross-satellite shared state when satellites enter the track set. Called once per handoff (merge_sats, add_satellite, add_satellite!) with the incoming satellites, after init_estimator_state. The default returns estimator unchanged.
The result must have the input's concrete type (TrackState is parameterized on it): grow resizable containers in place, or rebuild with Setfield.@set or a copying constructor. The returned TrackState carries the result, so callers of the in-place add_satellite! too must keep using the returned state.
Tracking.estimate_dopplers_and_filter_prompt — Function
estimate_dopplers_and_filter_prompt(
track_state,
sampling_frequencies
)
Fold every completed correlator record of every satellite through the estimator's TrackingLoops step_loop (any estimator implementing it works), giving the Dopplers for the next replicas and the post-correlation-filtered prompts.
The second argument is a BandMeasurements NamedTuple (the track path) or a NamedTuple/Dict mapping each get_band_id to its sampling frequency: the entry point for an external correlator producer (e.g. an FPGA), which needs no sample buffer. See External correlator producers.
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, taking the same second argument: overwrites each group's TrackedSat slots and returns track_state, allocation-free in steady state under track!'s preconditions.
It reads only the per-band sampling frequency (turning integrated_samples into an integration time), and consumes and clears each signal's correlator_outputs, whether from the software correlate phase or from append_correlator_output!.
In the TrackingLoops manual:
One record through a loop
The device-independent step lives in TrackingLoops, so the software receiver and a hardware correlator's loop process run the same arithmetic. track! passes the chunk's own replica word and no landing sample.
In the TrackingLoops manual:
The delay-aware loop
NCOReferencedPLLAndDLL is for a correlator whose NCO word takes effect records after the one that motivated it, as in hardware. Given a fixed word and no landing sample, as track! supplies, it is the conventional loop to the bit at the same bandwidths; by default it pulls in at the narrow 18 Hz, as a wider loop tolerates less command delay.
In the TrackingLoops manual:
The vector loop
track! drives VectorPLLAndDLL(signals...) through step_loop like any other estimator. Inside, its navigation engine decodes every satellite's bits (of a plain signal, or of the data component of a pilot + data pair, e.g. VectorPLLAndDLL(GalileoE1C() => GalileoE1B()) with signals = (GalileoE1C(), GalileoE1B())), solves the PVT, and from its first fix closes all loops with a navigation filter. Read navigation_solution(estimator) and navigation_status(estimator).
estimator = VectorPLLAndDLL(GPSL1CA())
track_state = TrackState(; signal = GPSL1CA(), doppler_estimator = estimator)
for chunk in chunks
track!(chunk, track_state, sampling_frequency)
end
navigation_solution(estimator)Each record gives it the satellite (prn), the replica's code phase at the record's end, and a time grid shared by a band's satellites: the estimate phase adds the band's running offset (TrackState's sample_offsets, advanced by every track!). Step every satellite at least every half vt_update_interval (50 ms by default), which any track! on a shorter chunk does.
The engine and the offsets belong together: a TrackState built afresh around an estimator that has run starts every band at 0, so pass it the old state's sample_offsets (see TrackState). To branch a state, deepcopy it; track shares the estimator with its input.
In the vector loop the navigation filter alone sets a satellite's carrier NCO; a residual PLL follows the carrier on the prompts without steering it, and carrier_loop_stage reports VECTOR_LOOP. reset_loop_filters! re-seeds the inner loop and keeps the satellite in the vector loop: it drops the code correction (the converged Dopplers contain it) until the filter's next cycle, and keeps the carrier command (they do not).
In the TrackingLoops manual: