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:
MomentsCN0Estimator— the moments method on a window of prompts;NWPRCN0Estimator— the narrow-to-wideband power ratio, summing prompts coherently over short windows, aligned to the bit grid once known;NoiseRefCN0Estimator— divides the prompt power by a noise density measured independently of the signal (see Noise estimation);NoCN0Estimator— for a component whose C/N₀ is not needed.
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.
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 byTracking.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) ofsignal;20for GPS L1 C/A,1for GPS L1C-D / Galileo E1B, and0for 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).-1when 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 (seefold_record). Such a record's blocks still count towards the grid.bit_buffer— the signal'sBitBufferas 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 densityN₀(dimension1/Hz), read byNoiseRefCN0Estimator.Nothing(in the type, not as a sentinel) means noAbstractNoiseEstimatoris 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 ownT, so records of different lengths in one ring are each handled correctly.nothingon the bare-prompt-stream path.
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:
| true | NWPR, 1-block records | NoiseRef | NWPR, 20-block records | NoiseRef |
|---|---|---|---|---|
| 25 | 11.2 (56 %) | 23.4 (0) | — | — |
| 30 | 29.3 ± 1.40 | 29.6 ± 0.62 | — | — |
| 40 | 39.7 ± 0.35 | 39.9 ± 0.19 | 27.1 ± 5.79 | 39.8 ± 0.37 |
| 45 | 44.7 ± 0.30 | 44.9 ± 0.13 | 32.9 ± 2.00 | 44.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!.
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.TrackStategives a signal aCorrelatorNoiseEstimatoronly where its estimator returnstrue; 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
NWPRCN0Estimatorbeside 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.
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.
TrackingLoops.MomentsCN0Estimator — Type
MomentsCN0Estimator to estimate the CN0
TrackingLoops.estimate_cn0 — Method
estimate_cn0(estimator, integration_time)
Estimates the CN0 based on the struct MomentsCN0Estimator.
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.
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 = 2 | M = 5 | M = 20 | ratio of sums, any M |
|---|---|---|---|---|
| 20 dB-Hz | 18.4 | 19.3 | 19.8 | 20.0 |
| 25 dB-Hz | 23.6 | 24.4 | 24.9 | 25.0 |
| 30 dB-Hz | 28.9 | 29.6 | 29.9 | 30.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 state | narrowband window |
|---|---|
| synced, data-bearing | num_narrowband_code_blocks, tiling the bit |
| synced, pilot (no bit grid) | num_narrowband_code_blocks, free-running |
| unsynced, data, no secondary code | num_presync_narrowband_code_blocks, unaligned |
| unsynced, secondary code | none: 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 window | none: 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:
| PLL | 2 records | 5 | 10 | 20 (one bit) |
|---|---|---|---|---|
| default, narrowed to 18 Hz | 24.8 / 21.1 | 24.7 / 22.7 | 24.7 / 22.8 | 24.7 / 22.6 |
| 40 Hz | 24.8 / 18.3 | 24.0 / 19.0 | 22.7 / -Inf | 18.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 ofM.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-argumentTrackingLoops.update).num_presync_narrowband_code_blocks: window length before the bit grid is known;0reports thefallbackuntil 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'NBPandWBP, theMthey 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.
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.
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.
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.
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.
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.
TrackingLoops.NoiseRefCN0Estimator — Type
C/N₀ against a measured noise reference: per record,
Ĉ/N₀ = ⟨|P|²⟩ / N̂₀ − 1/Twith 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.
TrackingLoops.NoiseRefCN0Estimator — Method
NoiseRefCN0Estimator(; num_records)
Construct a fresh NoiseRefCN0Estimator averaging over the last num_records records.
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).
TrackingLoops.requires_noise_density — Method
requires_noise_density(_)
true: a signal carrying this estimator is provisioned a CorrelatorNoiseEstimator; see requires_noise_density.
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).
TrackingLoops.update — Method
update(_, prompt)
A bare prompt stream carries no noise density and no integration time, so this estimator has no two-argument form — use the three-argument update(::AbstractCN0Estimator, ::Any, ::CN0UpdateContext), which is what the tracking loop calls, or MomentsCN0Estimator / NWPRCN0Estimator for folding a captured prompt stream by hand.
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.
TrackingLoops.estimate_cn0 — Method
estimate_cn0(_, integration_time)
Always -Inf dB-Hz; see NoCN0Estimator for why that value and not NaN.
TrackingLoops.update — Method
update(estimator, prompt)
Ignore the prompt and return the estimator unchanged — NoCN0Estimator holds no state.