diff --git a/docs/src/custom_doppler_estimator.md b/docs/src/custom_doppler_estimator.md index 13f30977..387602a2 100644 --- a/docs/src/custom_doppler_estimator.md +++ b/docs/src/custom_doppler_estimator.md @@ -90,6 +90,27 @@ per-sat fields directly and rewraps `doppler_estimator_state` unchanged. both — the mutating version walks each group's `satellites.values::Vector{TrackedSat}` and reassigns slots in place. +6. **A [`carrier_doppler_pull_in_range`](@ref) method**, if your estimator + is meant to accept freshly-acquired satellites. It reports the largest + absolute carrier-Doppler error a satellite handed over from acquisition + can carry and still be pulled into carrier lock: + + ```julia + Tracking.carrier_doppler_pull_in_range(::MyEstimator, signal) = ... # a frequency in Hz + ``` + + It is given the estimator (its carrier-loop configuration) and the + estimator-driver signal. Rather than assume a fixed integration length, + it should derive the start-of-tracking coherent integration time via + `handover_coherent_integration_time` — which asks the same machinery the + loop uses (`calc_num_code_blocks_to_integrate` in the pre-sync state) — so + it stays correct if the pre-sync integration policy changes, and needs only + the signal. A GNSS receiver uses this to pick the smallest + Acquisition.jl coherent integration time whose Doppler resolution still + lands every acquired satellite inside the loop's pull-in range. The + conventional FLL-assisted estimator returns `1/(4·T)` — the range over + which its FLL `atan` discriminator is unambiguous. + ## Skeleton The skeleton below is the smallest possible working estimator: every @@ -177,4 +198,5 @@ estimator the `TrackState` was built with. AbstractDopplerEstimator init_estimator_state update_estimator_on_handoff +carrier_doppler_pull_in_range ``` diff --git a/src/Tracking.jl b/src/Tracking.jl index b861cc45..18c18976 100644 --- a/src/Tracking.jl +++ b/src/Tracking.jl @@ -67,6 +67,7 @@ export get_early, AbstractDopplerEstimator, init_estimator_state, update_estimator_on_handoff, + carrier_doppler_pull_in_range, CPUDownconvertAndCorrelator, CPUThreadedDownconvertAndCorrelator, Int16DownconvertAndCorrelator, @@ -143,6 +144,10 @@ Abstract supertype for doppler estimators. Concrete subtypes carry estimator configuration (and any cross-satellite or cross-system shared state). The per-satellite state used by the estimator lives in each [`TrackedSat`](@ref) wrapper — see [`init_estimator_state`](@ref) for the extension point. + +An estimator that accepts freshly-acquired satellites must also define +[`carrier_doppler_pull_in_range`](@ref), reporting the largest acquisition +Doppler error it can still pull into carrier lock. """ abstract type AbstractDopplerEstimator end diff --git a/src/conventional_pll_and_dll.jl b/src/conventional_pll_and_dll.jl index cb194b09..87fc8f17 100644 --- a/src/conventional_pll_and_dll.jl +++ b/src/conventional_pll_and_dll.jl @@ -195,6 +195,86 @@ end """ $(SIGNATURES) +Carrier-Doppler pull-in range of the **FLL-assisted** conventional PLL/DLL +(the [`ConventionalAssistedPLLAndDLL`](@ref) default), whose carrier loop uses a +`ThirdOrderAssistedBilinearLF`. + +Pull-in of a frequency (Doppler) error is provided by the FLL discriminator +`fll_disc`, `atan(cross / dot) / (2π·T)`. Because it is a *two-quadrant* +`atan`, the carrier phase advanced between the two consecutive prompts it +compares, `Δφ = 2π·Δf·T`, is recovered unambiguously only within `±π/2`. Beyond +that the discriminator folds and drives the loop the wrong way, so the loop can +only pull in a Doppler error satisfying `2π·|Δf|·T ≤ π/2`, i.e. + +``` +|Δf| ≤ 1 / (4·T) +``` + +where `T` is the start-of-tracking coherent integration time from +`handover_coherent_integration_time`. As long as the pre-sync search +integrates single code blocks, `T` is one primary-code period and this gives +250 Hz for GPS L1 C/A and GPS L5I (T = 1 ms), 62.5 Hz for Galileo E1B (T = 4 ms), +and 25 Hz for GPS L1C-D / L1C-P (T = 10 ms) — but should that policy change, the +pull-in range follows automatically. +""" +function carrier_doppler_pull_in_range( + ::ConventionalPLLAndDLL{<:ThirdOrderAssistedBilinearLF}, + signal::AbstractGNSSSignal, +) + T = handover_coherent_integration_time(signal) + # FLL atan(cross/dot) is unambiguous over ±π/2 of inter-prompt phase + # (2π·Δf·T ≤ π/2) → pull-in = 1 / (4·T). + uconvert(Hz, 1 / (4 * T)) +end + +""" +$(SIGNATURES) + +Approximate carrier-Doppler pull-in range of a **pure-PLL** conventional +PLL/DLL — any [`ConventionalPLLAndDLL`](@ref) whose carrier loop filter is *not* +FLL-assisted (i.e. not a `ThirdOrderAssistedBilinearLF`). + +A pure PLL has no frequency discriminator: `pll_disc` measures phase, whose +loop-averaged response to a constant Doppler offset is zero, so there is no +static frequency pull-in like the FLL-assisted case. Pull-in instead happens +through the loop's transient dynamics and is governed by the carrier loop +bandwidth `B_L`. This returns the *fast lock-in* approximation (Gardner, +"Phaselock Techniques"; Best, "Phase-Locked Loops") — the offset a 2nd/3rd-order +loop locks within roughly one beat cycle: + +``` +|Δf| ≈ min(B_L, 1 / (2·T)) +``` + +`B_L` is the estimator's `carrier_loop_filter_bandwidth`, or — when that is +`nothing` (auto) — [`default_carrier_loop_filter_bandwidth`](@ref) for `signal`, +matching how [`init_estimator_state`](@ref) seeds each satellite. It is +referenced to one primary-code period, which is exactly the pre-sync integration +length, so no `1/N` scaling applies at handover. The result is capped by the +coherent-integration decorrelation limit `1/(2·T)` (`T` = start-of-tracking +integration time): beyond that the prompt correlation nulls out regardless of +loop bandwidth. + +This is an order-of-magnitude estimate, not the crisp discriminator bound of the +FLL-assisted method above. Slow "pull-in" (as opposed to fast lock-in) can +acquire larger offsets given many loop time constants; for robust wide-range +fresh-acquisition lock prefer [`ConventionalAssistedPLLAndDLL`](@ref). +""" +function carrier_doppler_pull_in_range( + estimator::ConventionalPLLAndDLL{CA}, + signal::AbstractGNSSSignal, +) where {CA<:AbstractLoopFilter} + B_L = something( + estimator.carrier_loop_filter_bandwidth, + default_carrier_loop_filter_bandwidth(signal), + ) + T = handover_coherent_integration_time(signal) + uconvert(Hz, min(B_L, 1 / (2 * T))) +end + +""" +$(SIGNATURES) + Build the per-satellite estimator state stored in a [`TrackedSat`](@ref) for a satellite tracked under [`ConventionalPLLAndDLL`](@ref). diff --git a/src/sample_parameters.jl b/src/sample_parameters.jl index b4d8dfd9..4efedb0f 100644 --- a/src/sample_parameters.jl +++ b/src/sample_parameters.jl @@ -38,6 +38,26 @@ end """ $(SIGNATURES) +Coherent integration time at the acquisition→tracking handover, before any +bit/secondary-code sync. Asks [`calc_num_code_blocks_to_integrate`](@ref) in the +pre-sync state (`secondary_code_or_bit_found = false`) and multiplies the +resulting block count by the primary-code period. In that pre-sync state the +`preferred_num_code_blocks` argument is not consulted (the bit/secondary search +always integrates single code blocks), so a placeholder `1` suffices and only the +signal is needed. This is one primary-code period today, but is derived rather +than assumed so it tracks any change to the pre-sync integration policy. + +Used by Doppler estimators to size their [`carrier_doppler_pull_in_range`](@ref) +from the start-of-tracking integration length. +""" +function handover_coherent_integration_time(signal::AbstractGNSSSignal) + num_code_blocks = calc_num_code_blocks_to_integrate(signal, 1, false) + num_code_blocks * get_code_length(signal) / get_code_frequency(signal) +end + +""" +$(SIGNATURES) + Number of primary code blocks to credit the bit-buffer accumulator with for a just-completed integration. diff --git a/src/sat_state.jl b/src/sat_state.jl index 4df79d08..7bd94385 100644 --- a/src/sat_state.jl +++ b/src/sat_state.jl @@ -727,6 +727,39 @@ function init_estimator_state end """ $(SIGNATURES) +Maximum absolute carrier-Doppler error (a frequency, in `Hz`) that a satellite +handed over from acquisition can carry and still be pulled into carrier lock by +this Doppler estimator. + +Returned one-sided and as a **hard ceiling**: at exactly +`±carrier_doppler_pull_in_range` the loop sits on the edge of lock with no +margin. It bounds the tolerable *post-acquisition Doppler residual*, so an +acquisition Doppler-bin *width* of `2·carrier_doppler_pull_in_range` (whose +worst-case residual is exactly the ceiling) is the theoretical maximum, **not** +a safe target — size the bin narrower to leave margin. + +This is an extension point a GNSS receiver can use to pick the smallest satellite +acquisition Doppler resolution required to lock in with the chosen Doppler +estimator. + +**Required interface function**: a custom [`AbstractDopplerEstimator`](@ref) that +is meant to accept freshly-acquired satellites must define this method for its +type (unimplemented estimators raise a `MethodError`, as with +[`init_estimator_state`](@ref)). It is given the estimator (its carrier-loop +configuration) and the estimator-driver signal, and should derive the +start-of-tracking coherent integration time from the signal via +`handover_coherent_integration_time` (which asks the loop's own +`calc_num_code_blocks_to_integrate` in the pre-sync state), so only the +signal is needed. + +See the [`ConventionalPLLAndDLL`](@ref) methods for the FLL-assisted default +(a crisp `1/(4·T)` discriminator bound) and the pure-PLL approximation. +""" +function carrier_doppler_pull_in_range end + +""" +$(SIGNATURES) + 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`](@ref), [`add_satellite`](@ref), [`add_satellite!`](@ref)) diff --git a/test/conventional_pll_and_dll.jl b/test/conventional_pll_and_dll.jl index 26b2dc17..80b03df5 100644 --- a/test/conventional_pll_and_dll.jl +++ b/test/conventional_pll_and_dll.jl @@ -1,8 +1,14 @@ module ConventionalPLLAndDLLTest using Test: @test, @testset, @inferred, @test_throws -using Unitful: Hz -using GNSSSignals: GPSL1CA, get_code_center_frequency_ratio +using Unitful: Hz, uconvert +using GNSSSignals: + GPSL1CA, + GPSL5I, + GalileoE1B, + get_code_center_frequency_ratio, + get_code_length, + get_code_frequency using TrackingLoopFilters: ThirdOrderBilinearLF, SecondOrderBilinearLF using StaticArrays: SVector using Dictionaries: Dictionary @@ -25,7 +31,14 @@ using Tracking: get_sat_states, update_accumulator, get_default_correlator, - merge_sats + merge_sats, + ConventionalAssistedPLLAndDLL, + carrier_doppler_pull_in_range, + AbstractDopplerEstimator + +# Estimator with no carrier_doppler_pull_in_range method — exercises the +# required-interface fallback on AbstractDopplerEstimator. +struct _PullInStubEstimator <: AbstractDopplerEstimator end # Build a stub `(L1 = BandMeasurement(...),)` NamedTuple to pass to the # estimator. Samples are unused by `estimate_dopplers_and_filter_prompt` @@ -306,4 +319,42 @@ end @test_throws ArgumentError merge_sats(track_state, bad_sat) end +@testset "Carrier Doppler pull-in range (FLL-assisted default)" begin + estimator = ConventionalAssistedPLLAndDLL() + + # ±1/(4T) with T from calc_num_code_blocks_to_integrate (pre-sync ⇒ 1 + # block ⇒ one primary-code period): 1 ms → 250 Hz, 4 ms → 62.5 Hz. FLL + # atan(cross/dot) unambiguous over ±π/2 of inter-prompt phase. + @test @inferred(carrier_doppler_pull_in_range(estimator, GPSL1CA())) ≈ 250.0Hz + @test carrier_doppler_pull_in_range(estimator, GPSL5I()) ≈ 250.0Hz + @test carrier_doppler_pull_in_range(estimator, GalileoE1B()) ≈ 62.5Hz + + # Consistent with the general 1/(4·primary-code-period) formula. + for signal in (GPSL1CA(), GPSL5I(), GalileoE1B()) + T = get_code_length(signal) / get_code_frequency(signal) + @test carrier_doppler_pull_in_range(estimator, signal) ≈ uconvert(Hz, 1 / (4 * T)) + end +end + +@testset "Carrier Doppler pull-in range (pure PLL approximation)" begin + # Plain ConventionalPLLAndDLL() carrier loop is a pure PLL (no FLL assist): + # ≈ min(B_L, 1/(2T)). Auto bandwidth for GPS L1 C/A is 18 Hz, well under + # the 1/(2·1ms) = 500 Hz coherent-integration decorrelation cap. + pure_pll = ConventionalPLLAndDLL() + @test @inferred(carrier_doppler_pull_in_range(pure_pll, GPSL1CA())) ≈ 18.0Hz + + # A very wide configured bandwidth is capped by 1/(2T) = 500 Hz (T = 1 ms). + wide = ConventionalPLLAndDLL(; carrier_loop_filter_bandwidth = 600.0Hz) + @test carrier_doppler_pull_in_range(wide, GPSL1CA()) ≈ 500.0Hz +end + +@testset "carrier_doppler_pull_in_range is a required interface" begin + # An estimator without its own method has no applicable method (bare stub, + # like init_estimator_state) → MethodError rather than a wrong answer. + @test_throws MethodError carrier_doppler_pull_in_range( + _PullInStubEstimator(), + GPSL1CA(), + ) +end + end