Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/src/custom_doppler_estimator.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -177,4 +198,5 @@ estimator the `TrackState` was built with.
AbstractDopplerEstimator
init_estimator_state
update_estimator_on_handoff
carrier_doppler_pull_in_range
```
5 changes: 5 additions & 0 deletions src/Tracking.jl
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export get_early,
AbstractDopplerEstimator,
init_estimator_state,
update_estimator_on_handoff,
carrier_doppler_pull_in_range,
CPUDownconvertAndCorrelator,
CPUThreadedDownconvertAndCorrelator,
Int16DownconvertAndCorrelator,
Expand Down Expand Up @@ -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

Expand Down
80 changes: 80 additions & 0 deletions src/conventional_pll_and_dll.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
20 changes: 20 additions & 0 deletions src/sample_parameters.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
33 changes: 33 additions & 0 deletions src/sat_state.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
57 changes: 54 additions & 3 deletions test/conventional_pll_and_dll.jl
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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`
Expand Down Expand Up @@ -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
Loading