Doppler estimators and loop filters
A Doppler estimator closes the carrier and code loops. Its configuration — loop filter types and bandwidths — is a plain value, a subtype of AbstractDopplerEstimator; the per-satellite state it advances is created with init_estimator_state. Every estimator is stepped with the same call,
state, carrier_doppler, code_doppler = step_loop(estimator, state, record, words, landing_sample)where record is a LoopRecord, words is the replica frequency the record was produced under — a FixedNCOWord for a software correlator, the channel's NCOTimeline for a hardware one — and landing_sample is the sample the new command takes effect at, or NO_LANDING_SAMPLE for "at the end of each record".
ConventionalPLLAndDLL— a PLL and a carrier-aided DLL.ConventionalAssistedPLLAndDLL— the same, with the PLL assisted by an FLL, which pulls in larger initial frequency errors. This is the default in Tracking.jl.NCOReferencedPLLAndDLL— stays stable when the correction it computes only takes effect several records later, as it does with a hardware NCO. With a fixed word and no landing sample it is the conventional loop.
The bandwidth rules (default_carrier_loop_filter_bandwidth, effective_code_loop_filter_bandwidth, …) keep the loops stable for a given integration time, and the integration-length rules (calc_num_code_blocks_to_integrate, …) say how long a signal may be integrated coherently once its bit or secondary code has been found.
The per-record fold
apply_record advances one signal component's SignalLoopState — its prompt filter, C/N₀ estimator and bit buffer — by one record, so every caller does it the same way.
TrackingLoops.SignalLoopState — Type
SignalLoopState(signal; num_prompts_for_cn0_estimation = 100, cn0_estimator, post_corr_filter)The per-record state of one signal component on a satellite: its bit buffer, C/N₀ estimator, post-correlation filter, the last filtered prompt (which the FLL discriminator chains from) and the block count of the last record. An immutable value, rebuilt by apply_record per record; the estimators buffer into vectors they own, so every component needs its own instance.
TrackingLoops.apply_record — Function
apply_record(state::SignalLoopState, signal, prn, output, sampling_frequency,
noise_density, noise_density_ready,
driver_carrier_phase = get_carrier_phase_offset(signal);
correlated_pre_sync = false)
-> (state, prompt, filtered_correlator, integrated_code_blocks, overshoot)fold_record on a SignalLoopState: the new state (with the filtered prompt as its last_filtered_prompt), the prompt, the filtered correlator the discriminators read, the blocks the record covered, and whether the record overshot the navigation-bit boundary — on which the bit buffer dropped sync and restarted its search. Nothing here logs; report overshoot the way the caller reports things (Tracking.jl warns once per satellite).
driver_carrier_phase is the carrier-phase offset of the satellite's estimator-driver signal (get_carrier_phase_offset), against which this component's bit-buffer prompt is de-rotated. It defaults to the signal's own offset — a no-op, right for the driver itself; a passenger component (the data half of a pilot/data pair) must be handed the driver's.
TrackingLoops.reset_signal_state — Method
reset_signal_state(state::SignalLoopState) -> SignalLoopStateThe state a freshly armed channel starts from — no sync, empty soft bits, an empty C/N₀ estimator, no previous prompt — reusing the vectors the previous occupant's state owned, so a re-arm allocates nothing. The post-correlation filter is kept as it is: it is configuration (a beamformer, say), not history, and a filter that adapts must be reset by whoever owns it.
A custom AbstractCN0Estimator that carries history has to add a method to TrackingLoops._reset_cn0_estimator; without one this throws rather than hand the new satellite the old one's C/N₀.
TrackingLoops.restart_bit_clock — Method
restart_bit_clock(state::SignalLoopState) -> SignalLoopStateThe state with a fresh, unsynchronised bit buffer — what a lost record costs a satellite (its bit clock is rebuilt from the signal), everything else kept.
Estimators
TrackingLoops.ConventionalPLLAndDLL — Type
Conventional Phase-Locked Loop (PLL) and Delay-Locked Loop (DLL) Doppler estimator. Configuration-only — per-satellite state is a SatConventionalPLLAndDLL, produced via init_estimator_state.
Type parameters CA and CO select the carrier and code loop filter types; the bandwidth fields configure the loop bandwidths used when seeding new satellites. Each bandwidth field is Maybe{typeof(1.0Hz)}: a nothing field (the default) means auto — the bandwidth is sized per satellite from its estimator-driver signal via default_carrier_loop_filter_bandwidth / default_code_loop_filter_bandwidth. The carrier bandwidth is a per-primary-code-period reference scaled to BL/N at filter time for an N-block record; the code bandwidth is absolute, only capped by effective_code_loop_filter_bandwidth.
TrackingLoops.LoopRecord — Type
One completed record as the loop-filter step sees it: the signal it belongs to, the filtered (antenna-combined, normalised) correlator, the previous record's filtered prompt the FLL chains from, the record's span and the blocks it covered, the band's sampling frequency, and fold_end — the end sample of the last record of the fold this record belongs to, which is what a landing sample is measured against (every record of a fold maps onto the delay-free loop's record the same distance ahead).
Two fields identify the record to an estimator that keeps per-satellite state of its own, as VectorPLLAndDLL does:
prn: the satellite (0when the host does not say);code_phase: the replica's code phase (chips) atsample_index, from theCorrelatorOutput(NaNwhen the producer does not report it).
For such an estimator sample_index / sampling_frequency must also be the time since one origin shared by every satellite of the band. A host whose correlator restarts its sample count passes that origin's offset as sample_offset to the constructor that takes a CorrelatorOutput; it is added to sample_index and fold_end.
TrackingLoops.NCOReferencedPLLAndDLL — Type
NCOReferencedPLLAndDLL(; carrier_loop_filter_bandwidth = nothing,
code_loop_filter_bandwidth = nothing,
predict_landing = true)FLL-assisted PLL and DLL Doppler estimator for a replica whose NCO words are applied with a known delay — the hardware-correlator receiver's default.
It is the ConventionalAssistedPLLAndDLL — the same third-order assisted bilinear carrier filter, the same second-order code filter, the same gains and the same bandwidth defaults — with its loop internals referenced to the device NCO instead of to the word the filter last computed:
- Every record is attributed to the word that ran under it. The phase discriminator is measured against the applied replica by construction; the frequency discriminator is re-based onto it too, so
applied word + FLL discriminatoris an absolute measurement of the signal's Doppler. The DLL is normalised with the applied code word. - The correction is sized for the moment it lands. The filter is stepped with the discriminators predicted at the landing sample of the new command: the measured phase error advanced by
2π ∫ (f̂ − w(τ)) dτover the words already scheduled at the NCO, and the frequency measurement taken relative to the word that will be running there.
With zero delay both steps are the identity and the estimator is the conventional loop, so the software receiver's noise performance is inherited rather than re-tuned.
Step 1 is not specific to this estimator: the conventional step_loop reads the applied code word from words too, and both discriminators are measured against the replica that ran, so neither loop needs a correction for it. What sets this estimator apart is step 2, including the re-basing of the frequency measurement onto the word that will be running at landing. predict_landing = false drops step 2 and is then arithmetically the ConventionalAssistedPLLAndDLL: the documented negative control, which fails exactly like the conventional loop at a few epochs of delay.
TrackingLoops.SatConventionalPLLAndDLL — Type
Per-satellite state for the conventional PLL and DLL Doppler estimator. Holds initial Doppler values and loop filter states.
TrackingLoops.SatNCOReferencedPLLAndDLL — Type
SatNCOReferencedPLLAndDLLPer-satellite state of an NCOReferencedPLLAndDLL: the handover Dopplers the loop filters' outputs are offsets from, both filters, their bandwidths, and the centre sample of the last record folded (the FLL measures the mean frequency offset between two prompts' centres, so that is the span its replica word is averaged over).
TrackingLoops.ConventionalAssistedPLLAndDLL — Method
ConventionalAssistedPLLAndDLL(; ...)
ConventionalAssistedPLLAndDLL(
;
carrier_loop_filter_bandwidth,
code_loop_filter_bandwidth
)
Create a ConventionalPLLAndDLL with FLL-assisted carrier tracking: a ThirdOrderAssistedBilinearLF carrier loop filter combining the PLL and FLL discriminators. Bandwidths default to nothing (auto).
TrackingLoops.estimator_state_type — Method
The estimator-state type a Doppler estimator produces (for slot typing).
TrackingLoops.init_estimator_state — Method
init_estimator_state(estimator, driver_signal, carrier_doppler, code_doppler)Build the per-satellite estimator state for a satellite whose loop is driven by driver_signal and starts at the given Dopplers. Auto bandwidths (nothing on the estimator) are resolved here from the driver signal.
This function must be pure: Tracking.jl also calls it to build template states and to re-seed satellites.
TrackingLoops.navigation_cycle — Method
navigation_cycle(estimator) -> Union{Int,Nothing}How many navigation cycles the estimator has run, or nothing for an estimator without them. The count changes exactly when navigation_solution and navigation_status do, so a consumer polling it after every step reads each solution once.
TrackingLoops.navigation_epoch — Method
navigation_epoch(estimator) -> Union{typeof(1.0s),Nothing}The epoch the latest navigation solution refers to, on the records' time grid: sample_index / sampling_frequency of the moment it describes. nothing before the first cycle, and for an estimator without navigation cycles.
TrackingLoops.navigation_solution — Method
navigation_solution(estimator) -> Union{PVTSolution,Nothing}The latest navigation solution an estimator computed, or nothing for an estimator that computes none (the scalar loops). VectorPLLAndDLL returns the scalar PVT's until its filter is seeded and the filter's after that: position, velocity, time, clock bias and drift, the DOP, the satellites that determined it with their residuals, and the inter-system and inter-frequency biases. Its containers are reused by the next cycle, so copy out what is needed later.
TrackingLoops.navigation_status — Method
navigation_status(estimator) -> Union{VTStatus,Nothing}What the latest navigation cycle did (VTStatus), or nothing for an estimator without one.
TrackingLoops.reset_estimator_state — Method
reset_estimator_state(estimator, state, carrier_doppler, code_doppler)Zero the loop-filter integrators and re-seed the state from the converged Dopplers, keeping the per-satellite bandwidths.
TrackingLoops.satellite_report — Method
satellite_report(estimator, signal, prn) -> Union{SatelliteReport,Nothing}What the estimator knows of satellite prn of signal (a SatelliteReport), or nothing when it keeps no per-satellite navigation state (the scalar loops) or has never seen the satellite.
TrackingLoops.step_loop — Method
step_loop(estimator::ConventionalPLLAndDLL, state, record::LoopRecord, words, landing_sample)
-> (state, carrier_doppler, code_doppler)One record through the conventional loop: PLL (and FLL, for the assisted filter) discriminators against the filtered prompt, the DLL normalised with the code word the record ran on, the carrier bandwidth scaled by the blocks the record covered, the code bandwidth capped by its stability product, and the Dopplers aided. landing_sample is ignored: the conventional loop assumes its command acts before the next record.
TrackingLoops.step_loop — Method
step_loop(estimator::NCOReferencedPLLAndDLL, state, record::LoopRecord, words, landing_sample)
-> (state, carrier_doppler, code_doppler)One record through the NCO-referenced loop. words gives the replica words the record really ran on; landing_sample is where the command computed from this record's fold lands (NO_LANDING_SAMPLE for a software correlator, where it acts at the record's end). See NCOReferencedPLLAndDLL.
TrackingLoops.wrap_half_cycle — Method
wrap_half_cycle(phase)Fold a carrier-phase error (in radians) into [−π/2, π/2], the range a BPSK prompt — and therefore pll_disc — can tell the phase in, since a data bit flip turns the prompt by π. Exact for any phase already inside it.
Loop-filter bandwidths
TrackingLoops.aid_dopplers — Method
aid_dopplers(
signal,
init_carrier_doppler,
init_code_doppler,
carrier_freq_update,
code_freq_update
)
Aid dopplers. That is velocity aiding for the carrier doppler and carrier aiding for the code doppler.
TrackingLoops.calculate_carrier_frequency_update — Method
calculate_carrier_frequency_update(signal, carrier_loop_filter, correlator, previous_prompt, integration_time, loop_bandwidth)
-> (carrier_freq_update, carrier_loop_filter)One carrier-loop step: the PLL discriminator (pll_disc) of correlator, filtered by carrier_loop_filter at loop_bandwidth. An FLL-assisted filter (ThirdOrderAssistedBilinearLF) is additionally fed the FLL discriminator (fll_disc) between previous_prompt and this record's prompt. Returns the carrier-frequency correction and the advanced filter.
TrackingLoops.calculate_code_frequency_update — Method
calculate_code_frequency_update(signal, code_loop_filter, correlator, code_doppler, sampling_frequency, integration_time, loop_bandwidth)
-> (code_freq_update, code_loop_filter)One code-loop step: the DLL discriminator (dll_disc) of correlator, filtered by code_loop_filter at loop_bandwidth. code_doppler is the code Doppler the replica ran with, which the discriminator needs to convert the tap spacing from samples to chips. Returns the code-frequency correction (before carrier aiding, see aid_dopplers) and the advanced filter.
TrackingLoops.default_carrier_loop_filter_bandwidth — Method
default_carrier_loop_filter_bandwidth(signal)
Recommended carrier-loop-filter bandwidth for signal's primary integration period. Sized so that the PLL time-bandwidth product BL * T lands at about 0.018 (≈10× margin from the 0.18 stability edge of the bilinear third-order filter). Used by Tracking.TrackState when the user doesn't pass an explicit doppler_estimator.
Override by defining a method for your signal type, or by constructing ConventionalAssistedPLLAndDLL yourself with explicit carrier_loop_filter_bandwidth = / code_loop_filter_bandwidth = kwargs.
T = get_code_length(signal) / get_code_frequency(signal) # primary period
BL = 0.018 / T # this defaultT here is the primary-code period, not the chosen coherent integration length. For GPS L1 C/A (T = 1 ms) and GPS L5I (T = 1 ms, a 10230-chip code at 10.23 MHz) this returns 18 Hz — matching the historical hand-picked default. For L1C-D / L1C-P (T = 10 ms) it returns 1.8 Hz, and for Galileo E1B (T = 4 ms) 4.5 Hz — the well-inside-stability values the multi-signal flagship use case needs.
This value is the reference bandwidth for a one-primary-code-period integration; it is not the bandwidth that ends up in the loop when you integrate longer. Coherently integrating N primary blocks grows the loop update interval to N·T, which would push BL·N·T toward the ~0.18 stability edge of the bilinear filter. To avoid that, the conventional estimator automatically scales the effective loop bandwidth by 1/N at filter time (see ConventionalPLLAndDLL), holding the BL·Δt stability product fixed at its single-period value. So you set this reference bandwidth once and the loop stays stable at any integration length — no manual 1/N adjustment is needed.
TrackingLoops.default_code_loop_filter_bandwidth — Method
default_code_loop_filter_bandwidth(signal)
Recommended code-loop-filter (DLL) bandwidth for signal: a flat 1 Hz for every signal.
A carrier-aided DLL has almost no dynamic stress to track — the code Doppler is handed to it by the PLL (see aid_dopplers) — so its bandwidth is a thermal-noise-versus-pull-in trade that scales with neither the symbol rate (the old 18:1 carrier:code ratio starved the long-primary signals' pull-in) nor the coherent integration length. 1 Hz sits inside the 0.25–2 Hz the reference software receivers (GNSS-SDR, SoftGNSS, PocketSDR) use across signals.
Unlike the carrier bandwidth this is an absolute value, not a per-primary-code-period reference. Only the loop's own BL · Δt stability product caps it, at filter time, against each record's actual integration time — see effective_code_loop_filter_bandwidth; the cap binds only past 18 ms (0.9 Hz for a 20 ms L2 CM integration, 0.012 Hz for a 1.5 s L2 CL one).
Override by defining a method for your signal type.
TrackingLoops.effective_code_loop_filter_bandwidth — Method
effective_code_loop_filter_bandwidth(
bandwidth,
integration_time
)
Effective code-loop bandwidth for a record that integrated for integration_time: the configured bandwidth, capped so the code loop's BL · Δt product stays inside MAX_LOOP_BANDWIDTH_TIME_PRODUCT.
The carrier loop takes a 1/N scaling instead, because its configured bandwidth is a per-primary-code-period reference — see ConventionalPLLAndDLL. The DLL's is an absolute value: carrier-aided, it has no dynamic stress that grows with the integration length, and neither its pull-in time nor its thermal-noise floor does either, so integrating longer must not narrow it. Only stability may, and stability depends on the update interval the record actually had — hence the cap against integration_time rather than a scaling by the block count. A 1/N here would take a 20 ms L1 C/A integration down to 0.05 Hz where stability allows 0.9 Hz, re-introducing through the integration length exactly the pull-in sag that sizing the DLL off the carrier default used to cause by signal.
For a single-block integration of any signal at or below the 18 ms period where the cap starts to bind, this returns the configured bandwidth unchanged.
Integration length
TrackingLoops.calc_num_code_blocks_for_bit_buffer — Method
calc_num_code_blocks_for_bit_buffer(
signal,
integrated_samples,
sampling_frequency,
secondary_code_or_bit_found
)
Number of primary code blocks to credit the bit-buffer accumulator with for a just-completed integration: always 1 before bit/secondary sync (the detectors shift exactly one prompt sign per call), and afterwards the whole blocks the record actually covered, recovered from its sample count.
TrackingLoops.calc_num_code_blocks_to_integrate — Method
calc_num_code_blocks_to_integrate(
signal,
preferred_num_code_blocks,
secondary_code_or_bit_found
)
Returns the appropriate number of code blocks to integrate. It will be just a single code block as long as the secondary code or bit hasn't been found. Once found, the coherent integration is capped by max_num_code_blocks_to_integrate and clamped to the largest divisor of that ceiling not exceeding the preferred value, so an integration never straddles a symbol boundary.
TrackingLoops.default_num_code_blocks_to_integrate — Method
default_num_code_blocks_to_integrate(_)
Coherent integration length, in primary code blocks, a freshly tracked signal starts at. One block for every signal but Galileo E5a-QP, whose 64.5 µs block is too short to run a loop on and which starts at a whole 31-block code cycle.
TrackingLoops.max_num_code_blocks_to_integrate — Method
max_num_code_blocks_to_integrate(signal)
Longest coherent integration, in primary code blocks, that signal's own structure allows — the ceiling calc_num_code_blocks_to_integrate clamps a satellite's preferred integration length against.
One full symbol: the data-bit period for data-bearing signals, or the secondary-code period for pilots (data_frequency == 0, e.g. GPS L1C-P). Integrating past it would straddle a symbol boundary and average two opposite signs away.
The default degenerates to a single block for a signal that has neither a multi-block data bit nor an overlay; Galileo E5a-QP overrides it to a whole 31-block code cycle (see galileo/e5a_qp.jl). GPS L2CL is the same shape and deliberately keeps the default: its 1.5 s primary period is already a whole coherent integration.