Loop Filter
The loop filters are provided by TrackingLoopFilters.jl. This includes:
- first order loop filter
FirstOrderLF - second order bilinear loop filter
SecondOrderBilinearLF - second order boxcar loop filter
SecondOrderBoxcarLF - third order bilinear loop filter
ThirdOrderBilinearLF - third order boxcar loop filter
ThirdOrderBoxcarLF - third order assisted bilinear loop filter
ThirdOrderAssistedBilinearLF(combines PLL and FLL)
Default Configuration
The default Doppler estimator is ConventionalAssistedPLLAndDLL which uses:
ThirdOrderAssistedBilinearLFfor the carrier loop (FLL-assisted PLL for improved dynamics)SecondOrderBilinearLFfor the code loop
When TrackState builds the default estimator implicitly from a signal-tuple declaration, the carrier bandwidth is sized per signal from the signal's primary code period T, at BL · T ≈ 0.018 — ~10× margin from the BL · T < 0.18 stability edge of the bilinear third-order filter. The code bandwidth is a flat 1 Hz for every signal. The values fall out to:
| Signal | Primary period | Carrier BL | Code BL |
|---|---|---|---|
| GPS L1 C/A | 1 ms | 18 Hz | 1 Hz |
| GPS L5I | 1 ms | 18 Hz | 1 Hz |
| Galileo E1B | 4 ms | 4.5 Hz | 1 Hz |
| GPS L1C-D | 10 ms | 1.8 Hz | 1 Hz |
| GPS L1C-P | 10 ms | 1.8 Hz | 1 Hz |
| GPS L2 CM | 20 ms | 0.9 Hz | 1 Hz |
| GPS L2 CL | 1.5 s | 0.012 Hz | 1 Hz |
The 1-ms-primary-period signals (L1 C/A, L5I) keep the historical 18 Hz / 1 Hz default; longer-period signals get appropriately tighter carrier loops so the PLL stays stable. The DLL does not follow the carrier loop down: being carrier-aided it has almost no dynamic stress to track, so its bandwidth is a thermal-noise-versus-pull-in choice that does not scale with the symbol rate.
The two are also treated differently at filter time, since only the carrier bandwidth is a per-code-period reference. Integrating N primary blocks coherently scales the carrier bandwidth to BL/N, holding its BL · Δt product at the single-period value, while the code bandwidth is left alone and merely capped by the same product against the record's actual integration time (Tracking.effective_code_loop_filter_bandwidth). That cap is what pulls the L2 C primaries down in practice — 0.9 Hz for a 20 ms L2 CM integration, 0.012 Hz for a 1.5 s L2 CL one — while every integration shorter than 18 ms, whatever its signal or block count, runs the DLL at the full 1 Hz.
Reusing the third-order carrier filter's 0.018 product to cap the second-order code filter is conservative: transform-designed digital loops of this kind only destabilize around BL · Δt ≈ 0.4 (S. A. Stephens and J. B. Thomas, "Controlled-Root Formulation for Digital Phase-Locked Loops", IEEE Trans. Aerospace and Electronic Systems 31(1), 1995 — the standard treatment of digital-loop stability at large BL · Δt), so the code loop's cap carries even more stability margin than the carrier loop's.
Override per signal by defining methods of default_carrier_loop_filter_bandwidth / default_code_loop_filter_bandwidth, or override at construction time by passing your own doppler_estimator = to TrackState.
Doppler Estimators
Tracking.ConventionalPLLAndDLL — Type
Conventional Phase-Locked Loop (PLL) and Delay-Locked Loop (DLL) Doppler estimator. Configuration-only — per-satellite state lives in each TrackedSat wrapper, 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 — init_estimator_state sizes the bandwidth per satellite from that sat's estimator-driver signal (signals[1]) via default_carrier_loop_filter_bandwidth / default_code_loop_filter_bandwidth: the carrier loop is sized for the signal's own integration period (18 Hz for GPS L1 C/A, 4.5 Hz for Galileo E1B, 1.8 Hz for L1C-D / L1C-P, …), the code loop takes a flat 1 Hz. Pass an explicit bandwidth to override the auto-sizing for every satellite this estimator seeds.
The two bandwidths are referenced differently, because only the carrier loop's tuning tracks the update rate. The carrier bandwidth is referenced to a one-primary-code-period integration: when a signal coherently integrates N primary blocks (its per-TrackedSignal preferred_num_code_blocks_to_integrate, set via set_preferred_num_code_blocks_to_integrate!), it is automatically scaled to BL/N at filter time so the loop's BL·Δt stability product stays at its single-period value. This keeps the loop stable across integration lengths without the caller re-tuning the bandwidth — e.g. a 1 ms→10 ms switch needs no bandwidth change. The code bandwidth is an absolute value that longer integration does not narrow; it is only capped by the same stability product against the record's actual integration time — see effective_code_loop_filter_bandwidth.
Tracking.ConventionalAssistedPLLAndDLL — Function
ConventionalAssistedPLLAndDLL(; ...)
ConventionalAssistedPLLAndDLL(
;
carrier_loop_filter_bandwidth,
code_loop_filter_bandwidth
)
Create a ConventionalPLLAndDLL with FLL-assisted carrier tracking. This is the default Doppler estimator used by TrackState. Uses a ThirdOrderAssistedBilinearLF for the carrier loop filter which combines PLL and FLL discriminators for improved tracking under high dynamics.
Bandwidths default to nothing (auto): each satellite is seeded with the loop bandwidth recommended for its own estimator-driver signal — see ConventionalPLLAndDLL. Pass explicit bandwidths to override.
Tracking.default_carrier_loop_filter_bandwidth — Function
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 TrackState(; signal=…) 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.
Tracking.default_code_loop_filter_bandwidth — Function
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.
Tracking.effective_code_loop_filter_bandwidth — Function
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.
Resetting loop filters
When you change a signal's coherent-integration length mid-track with set_preferred_num_code_blocks_to_integrate!, reset the affected loop filters for a clean handoff so the previous integration length's filter state does not leak into the new one.
Tracking.reset_loop_filters! — Function
reset_loop_filters!(track_state)
Re-seed the Doppler-estimator state of every satellite (or one addressed satellite) from its current Doppler, giving each a freshly initialized loop filter. For the conventional PLL/DLL estimator this zeroes the carrier and code loop-filter integrators while preserving the converged carrier_doppler / code_doppler — and any per-satellite loop-bandwidth override carried on the SatConventionalPLLAndDLL state — so the loop continues from the converged frequency with a clean filter. Each signal's last_fully_integrated_filtered_prompt is cleared as well, so the first FLL update after the reset doesn't measure a prompt rotation that spans the old integration interval.
This is the recommended handoff when a signal's coherent-integration length changes mid-track — e.g. promoting GPS L5I from 1 ms to 10 ms via set_preferred_num_code_blocks_to_integrate!. The bilinear loop filter's integrator state is not portable across the change in update interval (Δt grows by the integration factor), so resetting it avoids a transient that can drag the loop out of lock; the converged Doppler is the right seed for the new, longer integration.
For VectorPLLAndDLL the re-seed additionally zeroes the externally-supplied NCO corrections (code_freq_update / carrier_freq_update) and the discriminator accumulators — the converged Doppler already folds in the last correction, so keeping it would apply it twice. A navigation filter must therefore re-issue its corrections via set_code_freq_updates! / set_carrier_freq_updates! after resetting a vector-loop satellite. The vt_on flag and any per-satellite bandwidth override are preserved.
Addressed like the per-signal accessors — no satellite id resets every satellite in track_state; (group, prn) or (single-group) prn resets one. Mutates track_state in place and returns it. Works for any AbstractDopplerEstimator through its init_estimator_state hook.
set_preferred_num_code_blocks_to_integrate!(track_state, 1, GPSL5I, 10)
reset_loop_filters!(track_state, 1) # clean handoff for PRN 1
reset_loop_filters!(track_state) # …or reset every satelliteCustom Configuration
You can customize the loop filters and bandwidths when creating the Doppler estimator:
julia> using Tracking, GNSSSignals, TrackingLoopFilters
julia> using Tracking: Hz
julia> # Use non-assisted PLL with custom loop filter types
doppler_estimator = ConventionalPLLAndDLL(
ThirdOrderBilinearLF, # carrier loop filter type
SecondOrderBilinearLF; # code loop filter type
carrier_loop_filter_bandwidth = 15.0Hz,
code_loop_filter_bandwidth = 0.5Hz
);
julia> track_state = TrackState(; signal = GPSL1CA(), doppler_estimator);
julia> track_state = add_satellite!(track_state; prn = 1, code_phase = 50.0, carrier_doppler = 1000.0Hz);Custom Loop Filters
You can implement a custom loop filter MyLoopFilter <: AbstractLoopFilter. In this case, a specialized filter_loop function is needed. For more information refer to TrackingLoopFilters.jl.
Custom Doppler Estimator
To replace the loop-filter-based estimator with a different algorithm (Kalman filter, joint-channel estimator, …), see the dedicated guide in custom_doppler_estimator.md.