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:

  • ThirdOrderAssistedBilinearLF for the carrier loop (FLL-assisted PLL for improved dynamics)
  • SecondOrderBilinearLF for 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:

SignalPrimary periodCarrier BLCode BL
GPS L1 C/A1 ms18 Hz1 Hz
GPS L5I1 ms18 Hz1 Hz
Galileo E1B4 ms4.5 Hz1 Hz
GPS L1C-D10 ms1.8 Hz1 Hz
GPS L1C-P10 ms1.8 Hz1 Hz
GPS L2 CM20 ms0.9 Hz1 Hz
GPS L2 CL1.5 s0.012 Hz1 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.ConventionalPLLAndDLLType

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

source
Tracking.ConventionalAssistedPLLAndDLLFunction
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.

source
Tracking.default_carrier_loop_filter_bandwidthFunction
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 default

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

source
Tracking.default_code_loop_filter_bandwidthFunction
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.

source
Tracking.effective_code_loop_filter_bandwidthFunction
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.

source

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 satellite
source

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