C/N₀ estimation

A C/N₀ estimator is advanced once per record with the record's prompt (TrackingLoops.update, called by apply_record) and read with estimate_cn0. The package provides:

default_cn0_estimator says which one a signal gets by default.

TrackingLoops.AbstractCN0Estimator — Type

Abstract supertype for CN0 (carrier-to-noise-density ratio) estimators. Each Tracking.TrackedSignal holds one estimator instance, stored in a type parameter — pass any subtype instance as the cn0_estimator keyword of Tracking.TrackedSignal / Tracking.TrackedSat to replace the default NoiseRefCN0Estimator; see default_cn0_estimator for which to pick when. Custom estimators subtype this and implement TrackingLoops.update and estimate_cn0, plus requires_noise_density if they read a measured noise floor.

source
TrackingLoops.CN0UpdateContext — Type

Per-record side information handed to update alongside the prompt: what the tracking loop knows about the record that an estimator cannot recover from the prompt stream alone — chiefly the navigation-bit grid, which lets an estimator sum prompts coherently without straddling a bit flip (NWPRCN0Estimator does).

Fields:

  • signal — the signal the record belongs to.
  • num_code_blocks — primary-code blocks this record spanned. One for the default configuration; more when the correlate step was lengthened by Tracking.set_preferred_num_code_blocks_to_integrate! or an external producer handed over longer records.
  • num_code_blocks_per_bit — blocks that form one navigation bit (symbol) of signal; 20 for GPS L1 C/A, 1 for GPS L1C-D / Galileo E1B, and 0 for a pilot, which carries no data and therefore has no bit grid — post-sync its prompts stay coherent for as long as the loops hold them.
  • bit_code_block_index — this record's offset inside the open navigation bit (0 = starts a fresh bit). -1 when the prompt cannot be summed coherently against the grid: before bit / secondary-code sync, and, for a secondary-coded signal, for a record correlated without the overlay wipe-off a sync in the same fold just established (see fold_record). Such a record's blocks still count towards the grid.
  • bit_buffer — the signal's BitBuffer as of before this record: the soft bits decoded so far, the open coherent bit accumulator, the lock polarity and the secondary-code phase.
  • noise_density — this signal's measured noise density N₀ (dimension 1/Hz), read by NoiseRefCN0Estimator. Nothing (in the type, not as a sentinel) means no AbstractNoiseEstimator is configured for the signal, so call sites monomorphise. A configured source whose window is still empty never reaches here — the fold skips the update — so otherwise this is always a plain scalar.
  • integration_time — this record's own T, so records of different lengths in one ring are each handled correctly. nothing on the bare-prompt-stream path.
source
TrackingLoops.default_cn0_estimator — Method
default_cn0_estimator(
    signal,
    num_prompts_for_cn0_estimation
)

The default CN0 estimator for signal: a NoiseRefCN0Estimator averaging over num_prompts_for_cn0_estimation records, against that signal's own measured noise density.

It reads a density, so Tracking.TrackState provisions the signal a CorrelatorNoiseEstimator automatically (see requires_noise_density) and track! fills it from the samples — the sample-driven path needs no configuration at all.

Why this and not NWPR

NWPRCN0Estimator needs a coherent narrowband window, so it does not apply uniformly. Measured through track! on a data-modulated GPS L1 C/A signal, 1200 code blocks, median over 9 seeds, fraction of runs reporting -Inf dB-Hz in brackets:

trueNWPR, 1-block recordsNoiseRefNWPR, 20-block recordsNoiseRef
2511.2 (56 %)23.4 (0)——
3029.3 ± 1.4029.6 ± 0.62——
4039.7 ± 0.3539.9 ± 0.1927.1 ± 5.7939.8 ± 0.37
4544.7 ± 0.3044.9 ± 0.1332.9 ± 2.0044.7 ± 0.30

At 25 dB-Hz, where lock decisions are made, NWPR's ratio leaves 1 < μ̂ < M in over half the runs. At long coherent records a record as long as its window has NBP ≡ WBP and NWPR falls back, while the non-coherent reference is immune to phase noise. On GPS L1C-D, Galileo E1B and secondary-coded signals before sync NWPR never gets a window at all (JuliaGNSS/Tracking.jl#217). NWPR is better only at the top of the range, where the reference carries a self-leakage bias (see NoiseRefCN0Estimator).

When to pass something else

Configure NWPRCN0Estimator explicitly for externally supplied correlator outputs without a noise observation. Everywhere else, prefer appending a NoiseObservation per signal with append_noise_observation!.

source
TrackingLoops.requires_noise_density — Method
requires_noise_density(_)

Does estimator read its signal's noise density out of its CN0UpdateContext? false for every estimator that infers its noise floor from the prompt stream (MomentsCN0Estimator, NWPRCN0Estimator, NoCN0Estimator); true only for NoiseRefCN0Estimator.

A trait, so a custom estimator can opt in. Two things key off it:

  • Provisioning. Tracking.TrackState gives a signal a CorrelatorNoiseEstimator only where its estimator returns true; any other signal runs no despread at all.
  • The warm-up skip. While a configured source's window is still empty, the fold skips the C/N₀ update for the requiring signal only. Skipping its neighbours too would make an NWPRCN0Estimator beside it drop its open window on every skipped record (see _update_nwpr).

The trait's home is the type and the instance method forwards to it, so provisioning folds out of Tracking.TrackState's type parameters. A custom estimator should define the type form to keep that.

source
TrackingLoops.update — Method
update(estimator, prompt, _)

Fold one completed record's prompt into estimator and return the updated estimator (immutable update). This three-argument form is the one the tracking loop calls and the extension point for custom AbstractCN0Estimators that profit from the navigation-bit information in context (see CN0UpdateContext).

The default implementation drops context and calls the two-argument TrackingLoops.update(estimator, prompt), so an estimator that only needs the prompt stream — like MomentsCN0Estimator — implements just that one.

source
TrackingLoops.update — Method
update(estimator, prompt)

Buffers the prompts such that they can be used to estimate the CN0. Returns a new estimator with the latest prompt added (immutable update). The moment ratio needs no side information, so this estimator implements only the prompt-only form of TrackingLoops.update; see update(::AbstractCN0Estimator, ::Any, ::CN0UpdateContext) for the extension point that also receives the navigation-bit context.

source
TrackingLoops.NWPRCN0Estimator — Type

Van Dierendonck's narrowband/wideband power ratio (NWPR) CN0 estimator.

The estimator for a correlator-ingest path with no noise observation (see default_cn0_estimator); NWPRCN0Estimator(::AbstractGNSSSignal) sizes the window for the signal. Over a window of M consecutive records it forms

NBP = |Σ_M prompt|²          (coherent — narrowband)
WBP =  Σ_M |prompt|²         (incoherent — wideband)

and over the windows that fit in num_records records reports

μ̂    = Σ_K NBP_k / Σ_K WBP_k
Ĉ/N₀ = (1 / T) · (μ̂ − 1) / (M − μ̂)

with T the record's integration time. Reference: A. J. Van Dierendonck, "GPS Receivers", ch. 8 in Global Positioning System: Theory and Applications, Vol. I, ed. B. W. Parkinson & J. J. Spilker Jr.; the formulas above are reproduced on ESA Navipedia.

Why the ratio of the sums, and not the mean of the ratios

The reference uses the mean of the per-window ratios, but the inversion assumes μ = E[NBP] / E[WBP], which only the ratio of the sums estimates consistently (E[NBP/WBP] < E[NBP]/E[WBP] at finite M). Bias measured with K → ∞:

true C/N₀mean of ratios, M = 2M = 5M = 20ratio of sums, any M
20 dB-Hz18.419.319.820.0
25 dB-Hz23.624.424.925.0
30 dB-Hz28.929.629.930.0

The ratio of sums is consistent at every M, so the estimate does not depend on the window choice. It shifts the estimate without changing its spread, and at equal false-alarm rate is the slightly better detector (at M = 2 and 0.1 %, it detects a true 25 dB-Hz in 22.5 % of updates against 15.0 %).

Against the moments method

Unlike MomentsCN0Estimator, whose median on pure noise is ~27.6 dB-Hz at its default 100-prompt window (so a lock threshold below ~30 dB-Hz never trips and one at 30 dB-Hz false-alarms on ~19 % of updates), NWPR reports "no signal" on noise (Falletti, Pini & Lo Presti, IEEE T-AES 47(1):420–437, 2011; JuliaGNSS/Tracking.jl#217).

The coherence constraint

NBP is coherent, so a window must not straddle a bit flip (~7 dB loss) and must stay short against residual Doppler (M·T ≪ 1/(2·Δf); 25 Hz over 20 ms is half a cycle, ~9 dB). The window follows the navigation-bit grid in CN0UpdateContext:

signal statenarrowband window
synced, data-bearingnum_narrowband_code_blocks, tiling the bit
synced, pilot (no bit grid)num_narrowband_code_blocks, free-running
unsynced, data, no secondary codenum_presync_narrowband_code_blocks, unaligned
unsynced, secondary codenone: the unknown overlay flips every block
one symbol per block (L1C-D, E1B)none: no coherent window beyond one record
record at least as long as windownone: one record has NBP == WBP

The unaligned pre-sync window exists because bit sync takes seconds and fails below ~30 dB-Hz; it straddles a flip with probability (M−1)/L (~0.6 dB below 30 dB-Hz at five blocks of a 20-block bit, up to ~6 dB at a strong signal until sync).

The window is capped by the loop's coherence time, not by the bit period

A longer window buys little spread (the same records are only partitioned differently), while residual phase noise costs a bias averaging cannot remove. GPS L1 C/A at 1 ms records, locked at 45 dB-Hz and faded to a true 25 dB-Hz, median / 10th percentile over 96 runs:

PLL2 records51020 (one bit)
default, narrowed to 18 Hz24.8 / 21.124.7 / 22.724.7 / 22.824.7 / 22.6
40 Hz24.8 / 18.324.0 / 19.022.7 / -Inf18.2 / -Inf

The default loop (see CarrierLoopStage) has narrowed by the fade and holds phase over a whole bit: past five records the window changes neither the bias nor the spread (standard deviation 1.6 dB at 5 records, 1.4 at 20). A wider PLL does not, and the whole-bit window reads -Inf ("no signal") in 30 of the 96 runs. The default (~5 ms, see default_cn0_estimator) therefore stays short, for a loop that has not narrowed or runs wider; raise it where the PLL is narrow, for a pilot or for a signal that is never weak.

Where no window is admissible the fallback is reported (by default MomentsCN0Estimator, with its noise floor), e.g. for records integrated over a whole navigation bit.

Fields / configuration

  • num_records: records the estimate averages over (100, ~100 ms at GPS L1 C/A); memory is independent of M.
  • num_narrowband_code_blocks: window length in primary-code blocks, the cap on the coherent sum (the length itself without a bit grid: a pilot, or the two-argument TrackingLoops.update).
  • num_presync_narrowband_code_blocks: window length before the bit grid is known; 0 reports the fallback until sync.
  • buffered_narrowband_powers, buffered_wideband_powers, ratio_current_index, filled_ratio_length, num_records_per_ratio, ratios_are_bit_aligned: rings of completed windows' NBP and WBP, the M they were formed with and whether they followed the bit grid; a change of either restarts the rings.
  • narrowband_sum, wideband_power, num_accumulated_records, num_accumulated_code_blocks: the open window.
  • fallback: reported until a window completes, or always where none is admissible; fed every prompt.
source
TrackingLoops.NWPRCN0Estimator — Method
NWPRCN0Estimator(
    signal;
    num_records,
    num_narrowband_code_blocks,
    kwargs...
)

Construct an NWPRCN0Estimator whose coherent window covers about 5 ms of signal's code blocks, at least two (5 for a 1 ms code, 2 for GPS L1C-P's 10 ms); see NWPRCN0Estimator for why 5 ms. (A fixed block count would scale the window with the code period.) On a correlator-ingest path with no noise observation:

TrackedSat(
    GPSL1C_P(),
    prn,
    code_phase,
    doppler;
    cn0_estimator = NWPRCN0Estimator(GPSL1C_P()),
)

Every other keyword is forwarded unchanged.

source
TrackingLoops.NWPRCN0Estimator — Method
NWPRCN0Estimator(
;
    num_records,
    num_narrowband_code_blocks,
    num_presync_narrowband_code_blocks,
    fallback
)

Construct a fresh NWPRCN0Estimator averaging over the last num_records records; see there for the parameters.

source
TrackingLoops.estimate_cn0 — Method
estimate_cn0(estimator, integration_time)

Estimate the C/N₀ from the buffered powers (see NWPRCN0Estimator); integration_time is the record's, the T of the formula. Returns the fallback's value while no window has completed, and always where none is admissible.

μ̂ ≤ 1 (no detectable signal) yields -Inf dB-Hz, μ̂ ≥ M yields Inf dB-Hz: the limits of the expression, not clamped. Thresholding needs no special case; averaging the estimate does.

source
TrackingLoops.update — Method
update(estimator, prompt, context)

Accumulate one record's prompt into the open window, with length and alignment from the bit grid in context (see NWPRCN0Estimator). An open window is dropped once no longer admissible (e.g. the grid moved under it at sync); a window closing on one record (NBP == WBP) empties the rings, handing over to the fallback.

source
TrackingLoops.update — Method
update(estimator, prompt)

Advance the estimator on a bare prompt stream without bit context: each prompt is a one-block record and windows run back to back at num_narrowband_code_blocks.

source
TrackingLoops.NoiseRefCN0Estimator — Type

C/N₀ against a measured noise reference: per record,

Ĉ/N₀ = ⟨|P|²⟩ / N̂₀ − 1/T

with N̂₀ the signal's noise density from its AbstractNoiseEstimator and T that record's own integration time. The ring averages the per-record terms and estimate_cn0 converts the mean once.

Unlike NWPRCN0Estimator this is non-coherent: no bit sync, no window, no M, no fallback, no saturation ceiling, and immune to residual carrier-phase error. It therefore works uniformly on every signal, including GPS L1C-D, Galileo E1B and secondary-coded signals before sync (JuliaGNSS/Tracking.jl#217).

What it needs

A noise density for its own signal (see Noise estimation for why per signal). On the sample-driven path Tracking.TrackState provisions a CorrelatorNoiseEstimator automatically (see requires_noise_density); on a correlator-ingest path fill the same type with append_noise_observation!. With no source configured, update throws: a silent substitution would hide a backend that never populates the reference. While a configured window is still empty, the fold skips the update (Tracking.jl warns once per signal) and estimate_cn0 reports -Inf dB-Hz.

Deliberate properties

  • No fallback. One record plus a density is a valid, if noisy, estimate on every signal.
  • Only the average is floored, never the per-record term. Negative terms at low C/N₀ keep the mean unbiased; clamping them would reintroduce a floor like MomentsCN0Estimator's.

The one bias it carries

The reference despreads with a wrong PRN, so it also collects the tracked satellite's own power, ε_self/N₀ = C/f_chip, which NWPR cancels in its ratio: C/N₀ reads low by 0.013 dB at 35 dB-Hz, 0.042 at 40, 0.13 at 45 and 0.40 at 50 on GPS L1 C/A (≈10× less on the 10.23 Mcps signals). It is not corrected, since N̂₀ ← N̂₀ − Σᵢ Ĉᵢ/f_chip would couple every satellite's C/N₀ to every other's. Below 40 dB-Hz, where lock and loss are decided, it sits under NWPR's own +0.05 dB bias.

Fields / configuration

  • num_records — how many records the estimate averages over, i.e. the memory of the estimator (100 by default, ~100 ms at GPS L1 C/A).
  • buffered_cn0, current_index, filled_length — the ring of per-record C/N₀ terms in linear Hz, written in place, plus its position and fill.
source
TrackingLoops.estimate_cn0 — Method
estimate_cn0(estimator, integration_time)

Mean of the buffered per-record terms, converted once with dBHz.

integration_time is ignored: T was applied per record in update, so a record lengthened by Tracking.set_preferred_num_code_blocks_to_integrate! is handled correctly beside shorter ones. The argument stays for interface uniformity.

An empty ring, a mean that has not cleared zero, and a non-finite mean all report -Inf dB-Hz, never NaN (see NoCN0Estimator for why). The explicit isfinite catches a NaN prompt, which passes mean_cn0 <= 0 (a zero floor is already kept out of the ring by _noise_density_and_ready).

source
TrackingLoops.update — Method
update(estimator, prompt, context)

Fold one record's prompt into the ring: |P|²/N̂₀ − 1/T in linear Hz, with the density and the record's own integration time taken from context. The term is not clamped (see NoiseRefCN0Estimator).

source
TrackingLoops.NoCN0Estimator — Type

A CN0 estimator that estimates nothing: it keeps no state, does no work per record, and reports -Inf dB-Hz. Use it to say "this signal's C/N₀ is not measured" rather than publish a number that cannot be trusted — for a non-driver signal whose C/N₀ nobody reads (e.g. cn0_estimator = (NWPRCN0Estimator(), NoCN0Estimator()) on a multi-signal Tracking.TrackedSat), or as NWPRCN0Estimator's fallback in place of the MomentsCN0Estimator's ~27.6 dB-Hz noise floor where no coherent window exists (NWPRCN0Estimator(; fallback = NoCN0Estimator())). The per-record saving is under 1 % of a correlation, so this is for honesty, not speed.

-Inf and not NaN because, with Unitful's Level comparison, NaN dB-Hz >= threshold is true for every threshold, so a NaN would clear every lock detector. -Inf compares false against any finite threshold. This is the package-wide convention for a missing estimate.

source