CN0 Estimator
The CN0 (Carrier-to-Noise density ratio) estimator provides a measure of signal quality in dB-Hz. Each TrackedSignal on a TrackedSat holds its own CN0 estimator, so a multi-signal satellite produces one CN0 value per signal.
The estimator of each signal is fed its own prompt once per completed record — the estimator-driver signal has no privileged role here, and the per-signal values are not interchangeable: on GPS L1C the pilot carries ~75 % of the power against the data component's ~25 %, a 4.8 dB difference.
Nothing inside Tracking reads estimate_cn0 — it is a reporting API, so this costs only the per-record estimator update, which is well under a per cent of the correlation work for that record (~40 ns against ~9 µs for a two-signal satellite over 4000 samples). estimate_cn0 itself is of the same order and runs only when you call it, so a signal whose C/N₀ you never read costs almost nothing either way.
The reason to switch a signal off is therefore not speed but honesty. NoCN0Estimator is the per-signal opt-out.
Choosing an estimator
Four estimators ship, and each column below is a reason somebody picks a different one.
NoiseRefCN0Estimator (default) | NWPRCN0Estimator | MomentsCN0Estimator | NoCN0Estimator | |
|---|---|---|---|---|
| Needs the band's samples (or a noise observation)? | yes | no | no | no |
| Needs bit sync / a coherent window? | no | yes | no | no |
Needs to know the window length M? | no | yes | no | no |
| Works on GPS L1C-D, Galileo E1B, pre-sync secondary codes? | yes | no — falls back | yes, at its floor | n/a |
| Bias, thermal only | <0.03 dB | ≈+0.05 dB everywhere | ≈27.6 dB-Hz floor on noise | n/a |
| High-C/N₀ bias floor | ≈0.13 dB at 45, ≈0.40 at 50 (self-leakage) | none | none | n/a |
| High-C/N₀ σ | keeps improving, until the row above dominates | saturates at √(M/((M−1)K)) ≈0.5 dB | — | n/a |
| Degenerate outputs | none | -Inf/Inf outside 1 < μ̂ < M — 5.6 % of estimates at 20 dB-Hz | none | always -Inf |
| Phase-noise / residual-Doppler sensitivity | immune (non-coherent) | sensitive — the coherent sum loses power; −12 dB at 120 Hz of handoff Doppler, see Residual Doppler at acquisition handoff | immune | n/a |
| Cost | per-record arithmetic + one despread per signal per chunk | per-record arithmetic | per-record arithmetic | none |
The "degenerate" row is a rate of unusable outputs, not a per-window discard rate: estimate_cn0 pools every buffered window and range-checks the pooled ratio once.
Which one do I use?
- Sample-driven
track!→NoiseRefCN0Estimator. This is the default and needs no configuration: the signal is provisioned aCorrelatorNoiseEstimatorautomatically andtrack!fills it. - Correlator outputs from hardware, with a noise observation → the same, plus one
append_noise_observation!per signal per fold. See Noise Estimator for what a producer has to report; the short version is raw accumulations of an untracked PRN, with every division and all averaging done here. - Correlator outputs from hardware, without one →
NWPRCN0Estimator(signal), accepting its fallback on the signals it cannot serve. This is the one place NWPR is still the right choice. - A signal whose C/N₀ nobody reads (the passenger of a co-tracked pair) →
NoCN0Estimator; see Not measuring a signal at all. MomentsCN0Estimatoris a fallback component and a prompt-stream tool, not a recommended standalone default — see Why not the moment method for the ≈27.6 dB-Hz floor that rules it out.
The default: C/N₀ against a measured noise floor
NoiseRefCN0Estimator computes, per record,
Ĉ/N₀ = ⟨|P|²⟩ / N̂₀ − 1/Twith N̂₀ the band's measured noise density and T that record's own integration time, then averages the per-record terms. It is non-coherent: no bit sync, no window, no M, no fallback, no saturation ceiling — one set of characteristics on every signal.
Where the density comes from is a separate, pluggable thing: see Noise Estimator. On the sample-driven path it is automatic.
Tracking.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: it needs no bit sync, no window length M, no coherent window and no fallback, it is immune to residual carrier-phase error, and it has no saturation ceiling. It is therefore the one estimator that works uniformly on every signal — including GPS L1C-D and Galileo E1B (blocks_per_bit == 1) and any secondary-coded signal before sync, where NWPR admits no coherent window at all and defers permanently to a fallback with a different bias (issue #217).
What it needs
A noise density for its own signal — not for its RF band, because what this divides by is the post-correlation floor and that depends on the despreading modulation (see AbstractNoiseEstimator). On the sample-driven path it is automatic: TrackState provisions a CorrelatorNoiseEstimator for every signal whose estimator asks for one (see requires_noise_density), and track! measures before the fold reads. On a correlator-ingest path you configure the same type and fill it with append_noise_observation! per signal instead. With no source configured for the signal, update throws — a wiring mistake, and a silent substitution would hide a backend that fails to populate the reference. While a configured source's window is merely still empty, the update is skipped (the fold warns once per signal) and estimate_cn0 reports -Inf dB-Hz until it fills.
Three deliberate properties
- No
fallbackfield. The three reasons NWPR needs one — warm-up, no admissible window pre-sync, and no window ever at one symbol per code block — all vanish. One record plus a density is a valid, if noisy, estimate on every signal. estimate_cn0'sintegration_timeargument is ignored, becauseTis applied per record at update time, where it is actually known. The signature stays for interface uniformity; this is not an oversight.- Only the average is floored, never the per-record term. Individual terms go negative at low C/N₀ and that is exactly what makes the mean unbiased; clamping per record would reintroduce a noise floor of the kind
MomentsCN0Estimatorhas.
The one bias it carries
The reference despreads with a wrong PRN, so besides the thermal floor and the other satellites' interference — both of which NWPR sees identically — it also collects the tracked satellite's own power, ε_self/N₀ = C/f_chip. NWPR does not: it despreads with the correct code, where the satellite's power appears in both NBP and WBP and cancels in the ratio. On GPS L1 C/A that is 0.013 dB at 35 dB-Hz, 0.042 at 40, 0.13 at 45 and 0.40 at 50 (≈10× smaller on the 10.23 Mcps signals), always reading low. It is carried rather than corrected, because the correction N̂₀ ← N̂₀ − Σᵢ Ĉᵢ/f_chip would make every satellite's C/N₀ a function of every other satellite's estimate — the cross-satellite feedback loop this design exists without. Below 40 dB-Hz it sits under NWPR's own +0.05 dB bias, and that is the range where lock and loss decisions are made.
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.
Tracking.default_cn0_estimator — Function
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 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 is accurate where it applies, but it needs a coherent narrowband window and so does not apply uniformly. Measured through track! on a data-modulated GPS L1 C/A signal, 1200 code blocks, median over 9 seeds, with the 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 |
Three things in that table decided the default. At 25 dB-Hz NWPR's window lands outside 1 < μ̂ < M in over half the runs and the surviving estimates read 14 dB low, which is exactly the regime a lock detector has to work in. At long coherent records NWPR collapses — a record as long as its own window has NBP ≡ WBP and no window exists at all, so it falls back — while the non-coherent reference is immune to the phase-noise wash and barely moves between 1- and 20-block records. And on GPS L1C-D, Galileo E1B and any secondary-coded signal before sync, NWPR admits no window ever and defers permanently to a fallback with a different bias (issue #217).
What NWPR is still better at is the top of the range, where the reference carries a self-leakage bias it does not: ≈0.13 dB at 45 dB-Hz and ≈0.40 at 50 on L1 C/A. See NoiseRefCN0Estimator.
When to pass something else
NWPRCN0Estimator remains exported and is the estimator to configure explicitly for externally supplied correlator outputs without a noise observation — a correlator-ingest path that cannot also report Σ|B|² for an untracked PRN. It is the one place it is still necessary; everywhere else, prefer appending a NoiseObservation per signal with append_noise_observation!.
Getting the low-C/N₀ variance
Below about 28 dB-Hz a non-coherent estimator is the noisier of the two at matched record length, and the reason is squaring loss: NWPR sums M prompts coherently before squaring, so at low SNR its σ is better by exactly √(M−1).
The way to buy that back is not a post-correlation window. A coherent sum of five 1 ms prompts is one 5 ms prompt — NWPR's narrowband window is a longer coherent integration reconstructed after the fact, not extra information. Taking the same integration in the correlator instead, with set_preferred_num_code_blocks_to_integrate!, makes the noise reference better than NWPR at every C/N₀ and helps the discriminators as well. Modelled over 100 ms of observation, 4000 trials × 9 seeds, relative σ in dB:
| C/N₀ | NWPR (M = 5) | NoiseRef, 1 ms records | NoiseRef, 5 ms records |
|---|---|---|---|
| 20 | 2.96 | 4.79 | 2.76 |
| 25 | 1.50 | 1.72 | 1.24 |
| 30 | 0.90 | 0.75 | 0.65 |
| 40 | 0.54 | 0.20 | 0.20 |
| 50 | 0.50 | 0.06 | 0.06 |
Two honest caveats. The record length also sets the discriminator behaviour and the loop's update interval, so this is a trade, not a free win — the conventional estimator rescales the carrier bandwidth by 1/N to keep the loop stable, but a longer record still means a slower loop. And the length is capped at one block until bit/secondary sync is found, so pre-sync you are on the 1 ms column whatever you configure.
The one bias it carries
The reference despreads with a wrong PRN, so besides the thermal floor it also collects the tracked satellite's own power — ε_self/N₀ = C/f_chip. NWPR does not: it despreads with the correct code, where the satellite's power appears in both NBP and WBP and cancels in the ratio. On GPS L1 C/A:
| own C/N₀ | C/N₀ reads low by |
|---|---|
| 35 dB-Hz | 0.013 dB |
| 40 dB-Hz | 0.042 dB |
| 45 dB-Hz | 0.13 dB |
| 50 dB-Hz | 0.40 dB |
A wider spreading bandwidth scales it down in proportion, so GPS L5 and Galileo E5a at 10.23 Mcps are ≈10× better — 0.013 dB even at 50 dB-Hz.
It is carried rather than corrected on purpose: the correction N̂₀ ← N̂₀ − Σᵢ Ĉᵢ/f_chip would make every satellite's C/N₀ a function of every other satellite's estimate, which is a cross-satellite feedback loop this design exists without. Below 40 dB-Hz the term sits under NWPR's own +0.05 dB bias, and that is the range where lock and loss decisions are made.
NWPRCN0Estimator: the coherent-window estimator
Van Dierendonck's narrowband/wideband power ratio. Per narrowband window of M records it forms the coherent power NBP = |Σ prompt|² and the incoherent power WBP = Σ |prompt|², divides the sums of both over as many windows as fit in num_prompts_for_cn0_estimation records, and turns that mean power ratio into a C/N₀. It was the default until the measured noise reference landed, and it is what to configure on a correlator-ingest path that cannot report a noise observation.
Tracking.NWPRCN0Estimator — Type
Van Dierendonck's narrowband/wideband power ratio (NWPR) CN0 estimator.
It was the library default until the per-band noise reference landed, and remains the estimator to configure explicitly on a correlator-ingest path with no noise observation — a producer that hands over CorrelatorOutputs but cannot also report Σ|B|² for an untracked PRN. That is the one place it is still the right choice; see default_cn0_estimator for the measurements that moved the default, and NWPRCN0Estimator(::AbstractGNSSSignal) for the constructor that sizes its window for the signal.
Over a narrowband window of M consecutive records it forms
NBP = |Σ_M prompt|² (coherent — narrowband)
WBP = Σ_M |prompt|² (incoherent — wideband)combines the windows that fit in num_records records into a mean power ratio μ̂, and reports
μ̂ = Σ_K NBP_k / Σ_K WBP_k
Ĉ/N₀ = (1 / T) · (μ̂ − 1) / (M − μ̂)with T the record's own integration time (the integration_time argument of estimate_cn0). 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 spells the mean ratio as μ̂ = (1/K) Σ NBP_k / WBP_k, the mean of the per-window ratios. The inversion (μ̂ − 1) / (M − μ̂) is derived from μ = E[NBP] / E[WBP], which the ratio of the sums estimates consistently and the mean of the ratios does not: E[NBP/WBP] < E[NBP]/E[WBP] at finite M, so the mean of the ratios reads low. Measured on synthetic prompts with K → ∞, so that only the bias is left:
| 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 size of the correction is a function of the window length, and at the default five-block window it is small: ~0.6 dB of the bias above is left there, and in the loop the two forms agree to a few tenths (+0.2 / −0.1 / +0.4 dB at a true 20 / 25 / 30 dB-Hz, measured from the same buffered windows). It earns its keep at shorter windows — +1.4 / +1.9 / +1.0 dB at a two-block one — and at the M = 20 of the classic GPS L1 C/A configuration the two agree to ~0.15 dB, which is why the literature's form does no harm there. The reason to prefer the ratio of the sums anyway is that it is the consistent estimator of μ at every window length, so the estimate does not depend on how the window was chosen.
The correction is a shift, not a variance trade: the spread is unchanged, and at equal false-alarm rate the ratio of the sums is the slightly better detector too — at M = 2 and a 0.1 % false-alarm rate it detects a true 25 dB-Hz signal in 22.5 % of updates against the mean of the ratios' 15.0 %.
Why it is the default
NWPR is usable as a detection statistic near threshold, which MomentsCN0Estimator is not: the moment ratio's sample moments manufacture signal power out of noise at finite window length, so at its default 100-prompt window M2M4 reports a median of ~27.6 dB-Hz on pure noise and cannot separate a true 20 dB-Hz signal from noise at all (a code lock threshold below ~30 dB-Hz can never trip, and one at 30 dB-Hz has a ~19 % per-update false-alarm rate). NWPR on the identical prompt streams tracks the truth from 20 dB-Hz up and reports "no signal" on noise, with a visibly tighter spread below 32 dB-Hz (Falletti, Pini & Lo Presti, IEEE T-AES 47(1):420–437, 2011). See JuliaGNSS/Tracking.jl#217 for the measurements.
The coherence constraint, and why this belongs in Tracking
NBP is a coherent sum, so a window must not straddle a navigation-bit flip (a mid-window flip costs ~7 dB) and must stay short against the residual Doppler (M·T ≪ 1/(2·Δf); 25 Hz over a 20 ms window is half a cycle and costs ~9 dB). Where the bit flips sit is something the tracking loop knows and a consumer of get_filtered_prompts does not, so the window is taken from the navigation-bit grid in CN0UpdateContext — that is the whole reason this estimator lives here rather than on top of the prompt stream:
| signal state | narrowband window |
|---|---|
| bit / secondary sync found, data-bearing signal | num_narrowband_code_blocks, tiling the navigation bit from its start |
| bit / secondary sync found, pilot (no data) | num_narrowband_code_blocks (no bit grid to respect) |
| sync not found yet, data-bearing without secondary code | num_presync_narrowband_code_blocks, unaligned |
| sync not found yet, signal with a secondary code | none — the unknown overlay flips sign every code block |
| one symbol per code block (GPS L1C-D, Galileo E1B) | none — no coherent window longer than one record exists |
| record at least as long as its own window | none — a one-record window has NBP == WBP by construction |
The pre-sync window matters more than it may look: the CFAR bit-edge detector needs seconds to lock at 35 dB-Hz and does not lock at all below ~30 dB-Hz, so a bit-aligned window alone would leave exactly the regime this estimator exists for on the fallback estimator. An unaligned window of M records inside an L-block bit straddles a flip with probability (M−1)/L, which at the default five blocks of a 20-block GPS L1 C/A bit costs ~0.6 dB of bias below 30 dB-Hz (and up to ~6 dB at a strong signal, for the fraction of a second until sync is found) while removing the moment ratio's noise floor entirely.
The window is capped by the loop's coherence time, not by the bit period
A bit-aligned window does not have to span the whole bit: windows of num_narrowband_code_blocks tile the bit from its start, which straddles no flip either and keeps the coherent sum inside the loop's coherence time. That cap is what the length is for — the longest flip-free window is not the best one. The gain from a longer window saturates quickly, because num_records records are combined either way and only their partition changes: at a true 25 dB-Hz, going from a 5-record to a 20-record window buys 0.8 dB of spread. The cost does not saturate. Residual phase noise makes the coherent sum lose power, which is a bias — the one error more averaging cannot remove. In the loop at a true 25 dB-Hz, with the conventional PLL at 1 ms records:
| window | 2 records | 5 | 10 | 20 (one full L1 C/A bit) |
|---|---|---|---|---|
| reported C/N₀ | 24.6 | 22.9 | 19.8 | 15.9 |
so the default cap is deliberately short (~5 ms of code blocks, see default_cn0_estimator). Raise it for a pilot, a narrow carrier loop or a signal that is never weak; the ideal-coherence estimate improves monotonically with it.
Where no window is admissible at all the fallback estimator's value is reported instead — a different estimator, with a different bias and, at the default MomentsCN0Estimator, a ≈27.6 dB-Hz floor on pure noise. That is what NoiseRefCN0Estimator exists to retire (issue #217). It also includes records that are themselves at least as long as the window — with set_preferred_num_code_blocks_to_integrate! at one navigation bit, say, a window closes on a single record, NBP == WBP identically, and the estimator reports its fallback for good.
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). The ring buffer of ratios is sized from it:num_records ÷ Mratios are averaged, so the memory stays put whenMchanges.num_narrowband_code_blocks— window length in primary-code blocks: the cap on the coherent sum, and the window length itself for a signal with no navigation-bit grid (a pilot, or a bare prompt stream fed through the two-argumentTracking.update).num_presync_narrowband_code_blocks— window length in primary-code blocks used while the bit grid is still unknown (see the table above);0disables the pre-sync window and reports thefallbackuntil sync.buffered_narrowband_powers,buffered_wideband_powers,ratio_current_index,filled_ratio_length,num_records_per_ratio,ratios_are_bit_aligned— the ring buffers of completed windows'NBPandWBP, the record countMthey were formed with, and whether they followed the navigation-bit grid. The two powers are buffered separately because the estimate divides their sums.Menters the estimate, so a window completing with a different record count (records lengthened at bit sync, say) restarts the buffers — and so does a window completing on the other side of the sync transition, since a pre-sync window may have straddled a bit flip and must not be averaged together with clean bit-aligned ones. A window closing on a single record empties them instead of restarting them: the records have grown to the window length, so there is nothing left to average and thefallbacktakes over (the last row of the table above).narrowband_sum,wideband_power,num_accumulated_records,num_accumulated_code_blocks— the currently open window.fallback— the estimator reported while no window has completed yet, and for the signals of the table above that never get one. Defaults to aMomentsCN0Estimatorand is fed every prompt.
Tracking.NWPRCN0Estimator — Method
NWPRCN0Estimator(
signal;
num_records,
num_narrowband_code_blocks,
kwargs...
)
Construct an NWPRCN0Estimator whose coherent window is sized for signal: the whole code blocks covering about 5 ms, at least two of them — 5 blocks for a 1 ms code, 2 for GPS L1C-P's 10 ms one.
About 5 ms is what a coherent sum survives with the default 18 Hz carrier loop at the low C/N₀ this estimator exists for; see NWPRCN0Estimator for the measurements and for when to raise it. The window is what caps the coherent sum for a data-bearing signal (whose windows tile the navigation bit) and is the window outright for a pilot, so sizing it in blocks without knowing the code period gets it wrong by the period's ratio.
This is the form to reach for on a correlator-ingest path with no noise observation, which is the one place NWPR is still the estimator to choose over the default NoiseRefCN0Estimator — see default_cn0_estimator:
TrackedSat(
GPSL1C_P(),
prn,
code_phase,
doppler;
cn0_estimator = NWPRCN0Estimator(GPSL1C_P()),
)Every other keyword is forwarded unchanged.
Why not the moment method
The Moments Method (M2M4, MomentsCN0Estimator) was the default up to and including Tracking 5.1. It is a moment ratio, and at a finite window the sample moments fluctuate enough to manufacture signal power out of noise. Measured on synthetic prompts (amplitude √(C/N₀·T) in unit-variance complex noise), 100 one-millisecond records, 400 realizations:
| true C/N₀ | M2M4 median | p10 | p90 | NWPR (M = 20) median |
|---|---|---|---|---|
| noise only | 27.6 | 22.6 | 31.1 | −Inf ("no signal") |
| 20 dB-Hz | 27.7 | 22.5 | 30.9 | 19.9 |
| 25 dB-Hz | 27.9 | 23.4 | 30.9 | 25.0 |
| 30 dB-Hz | 30.5 | 27.3 | 32.6 | 30.0 |
| 35 dB-Hz | 35.3 | 33.9 | 36.4 | 35.0 |
| 45 dB-Hz | 45.1 | 44.3 | 45.9 | 45.0 |
M2M4 is accurate from ~35 dB-Hz up and blind below ~30: a true 20 dB-Hz signal and pure noise both read ~27.7. As a per-update detection statistic that makes pure noise clear a 30 dB-Hz threshold 19 % of the time, a 28 dB-Hz threshold 45 %, and a 25 dB-Hz threshold 76 % — so the usual code-lock hysteresis (lock at 34 dB-Hz, drop at 25) cannot be built on it. At M = 20 NWPR's false-alarm rate against any threshold from 20 to 32 dB-Hz is 0 %, and its spread at low C/N₀ is much tighter (p10–p90 of 15.8–22.2 at a true 20 dB-Hz). This mirrors the published comparisons (Falletti, Pini & Lo Presti, IEEE T-AES 47(1):420–437, 2011). See issue #217 for the full measurements.
Those are the numbers for one 20-record window; the shipped default runs shorter windows (see below), which trades some of that margin for immunity against the loop's phase noise. End to end through track on a data-modulated GPS L1 C/A signal at the defaults, 600 ms per run, medians over 16 seeds:
| true C/N₀ | NWPR (default) | p10 | p90 | M2M4 |
|---|---|---|---|---|
| noise only | −Inf in 12 of 16 runs | 27.9 | ||
| 20 dB-Hz | 18.3 | 6.0 | 22.2 | 27.6 |
| 25 dB-Hz | 23.9 | 16.3 | 26.3 | 28.3 |
| 30 dB-Hz | 29.0 | 27.4 | 30.3 | 30.8 |
| 35 dB-Hz | 34.1 | 33.3 | 35.0 | 35.3 |
| 45 dB-Hz | 44.3 | 43.7 | 45.2 | 45.4 |
On pure noise, pre-sync — where a code-lock detector has to make its call — the default reports a median of -Inf, clears a 20 dB-Hz threshold in 4 % of updates and a 25 dB-Hz threshold in 0 % (200 runs, highest value seen 22.4). M2M4 on the same runs clears 25 dB-Hz in 67 % of updates.
The trade-off is that NBP is a coherent sum, so it is sensitive to anything that decorrelates the prompts inside a window — see What the estimator returns.
The window follows the navigation bits
A narrowband window must not straddle a navigation-bit flip (a mid-window flip costs ~7 dB). Where the bit boundaries are is something the tracking loop knows and a downstream consumer of get_filtered_prompts does not, so each record's prompt is handed to the estimator together with the navigation-bit state in a CN0UpdateContext, and the window is derived from it:
| signal state | narrowband window |
|---|---|
| sync found, data-bearing signal (GPS L1 C/A, L5I, …) | num_narrowband_code_blocks (5), tiling the navigation bit from its start |
| sync found, pilot (GPS L1C-P, L2CL, Galileo E1C, …) | num_narrowband_code_blocks (no bit grid to respect) |
| sync not found, data-bearing without secondary code | num_presync_narrowband_code_blocks (5), unaligned |
| sync not found, signal with a secondary code | none — the unknown overlay flips every code block |
| one symbol per code block (GPS L1C-D, Galileo E1B) | none — no window longer than one record is coherent |
| record at least as long as its own window | none — a one-record window has NBP == WBP |
Where no window is admissible the estimator falls back to a MomentsCN0Estimator, which needs no coherence at all — and with it to that estimator's floor. Note the last row: with set_preferred_num_code_blocks_to_integrate! at a whole navigation bit, a window closes on a single record and NWPR reports its fallback for good.
GPS L1C-D and Galileo E1B carry one navigation symbol per code block (a 10 ms and a 4 ms code period against a 100 sym/s and 250 sym/s data rate), so no two consecutive records are guaranteed to share a sign and the longest coherent window is a single record — where NBP == WBP identically. Those two signals therefore keep the moment ratio for good, and with it its floor: pure noise still reads ~27.6 dB-Hz on L1C-D and E1B, and a true 20 dB-Hz signal is indistinguishable from it. The same applies to any signal before its sync is found if it carries a secondary code (GPS L5, L2C, Galileo E1C, E5a), where the unknown overlay flips the sign every code block.
There is no fix inside a ~100-record window: without coherence only the prompt magnitudes carry information, and at a per-record SNR of 0.1–0.3 their distribution is barely distinguishable from the noise-only one at that sample count. (A phase-blind (E|z|)²/E|z|² estimator does better than M2M4 on false alarms — 42 % against 76 % at a 25 dB-Hz threshold on pure noise, 6 % against 19 % at 30 dB-Hz — but its median on noise is still ~27.5 dB-Hz, so it moves the floor rather than removing it. Its floor shrinks as N^{-1/2} against the moment ratio's N^{-1/4}, so it would need seconds of averaging, not tens of seconds.)
This is what the default estimator exists to retire. NoiseRefCN0Estimator has no window at all, so those signals and phases are ordinary records to it — that is issue #217, and the reason the default moved. If you are on NWPR anyway (a correlator-ingest path with no noise observation), the mitigations are: take the lock decision from the pilot of the pair, which is the component the loops track anyway (L1C-D with L1C-P, E1B with E1C, both on one TrackedSat); give the data component a NoCN0Estimator; or give it an NWPRCN0Estimator(signal; fallback = NoCN0Estimator()), which reports -Inf dB-Hz exactly when no coherent window is available.
The pre-sync window matters: the bit-edge detector needs seconds to lock at 35 dB-Hz and does not lock below ~30 dB-Hz, so a strictly bit-aligned window would leave the low-C/N₀ regime — the one this estimator exists for — on the moment ratio. An unaligned window of M records inside an L-block bit straddles a flip with probability (M−1)/L, so the default five blocks of a 20-block GPS L1 C/A bit costs ~0.6 dB below 30 dB-Hz (and up to ~6 dB at a strong signal, for the fraction of a second until sync is found) while removing the moment ratio's noise floor completely.
The window is capped by the loop's coherence time
A bit-aligned window does not have to span the whole bit, and it should not: the num_narrowband_code_blocks windows tile the bit from its start, so none straddles a flip and none runs past the coherence time of the loop that produced the prompts. The longest flip-free window is not the best one — a longer coherent window buys only a little (the same num_records records are combined either way, so only their partition changes: at a true 25 dB-Hz, going from a 5-record to a 20-record window buys 0.8 dB of spread), while decoherence over a long window costs a bias, the one error more averaging cannot remove.
The case that shows it is a satellite that fades after bit sync was found, which is the normal way a receiver reaches low C/N₀. Locked in at 45 dB-Hz, then faded to a true 25 dB-Hz, medians over 96 runs:
| coherent window | reported C/N₀ | p10 | runs reading -Inf |
|---|---|---|---|
| 5 blocks (default) | 23.6 | 19.5 | 1 / 96 |
| one full 20-block bit | 21.0 | 12.0 | 17 / 96 |
A whole-bit coherent sum reports "no signal" on a satellite that is being tracked. Raise num_narrowband_code_blocks for a pilot, a narrow carrier loop, or a signal that is never weak; lower it if the loop is noisier than the default.
Configuring the estimator
num_prompts_for_cn0_estimation (default 100, ~100 ms for L1 C/A) sets how many records the estimate averages over — for the default estimator, and for NWPR's fallback as well:
julia> using Tracking, GNSSSignals
julia> using Tracking: Hz
julia> track_state = TrackState(; signal = GPSL1CA());
julia> sat = TrackedSat(GPSL1CA(), 1, 50.0, 1000.0Hz; num_prompts_for_cn0_estimation = 200);
julia> track_state = add_satellite!(track_state, :default, sat);
julia> get_prn(track_state, 1)
1To choose a different estimator — or to configure NWPR's windows explicitly — pass a cn0_estimator instance. It is a type parameter of TrackedSignal, so any AbstractCN0Estimator is stored as is:
julia> using Tracking, GNSSSignals
julia> using Tracking: Hz
julia> sat = TrackedSat(GPSL1CA(), 1, 50.0, 1000.0Hz; cn0_estimator = MomentsCN0Estimator(100)); # the default up to 5.1
julia> Tracking.get_cn0_estimator(sat) isa MomentsCN0Estimator
true
julia> sat = TrackedSat(
GPSL1CA(),
1,
50.0,
1000.0Hz;
cn0_estimator = NWPRCN0Estimator(
GPSL1CA();
num_records = 400, # ~400 ms of memory
num_presync_narrowband_code_blocks = 0, # M2M4 until bit sync
),
);
julia> Tracking.get_cn0_estimator(sat).num_records
400Passing the signal to NWPRCN0Estimator is what sizes its coherent window for that signal's code period — 5 blocks for a 1 ms code, 2 for GPS L1C-P's 10 ms one. The window is a count of blocks, so leaving it at the bare constructor's 5 makes it 50 ms long on a 10 ms code.
Each signal needs its own estimator instance — they buffer into a vector, so sharing one between two signals of a satellite corrupts both. The multi-signal TrackedSat constructor therefore takes a tuple, one estimator per signal:
TrackedSat(
(GPSL1C_P(), GPSL1CA()),
prn,
code_phase,
carrier_doppler;
cn0_estimator = (NoiseRefCN0Estimator(), MomentsCN0Estimator(100)),
)A signal is provisioned a noise source if its estimator reads one, so the mixed case above provisions exactly one: the noise-referenced signal gets its own density, the other neither reads nor pays for one. A signal nobody asks a density of is not provisioned at all and runs no despread — which is the answer for anyone who deliberately stays on NWPR.
Per signal rather than per band because the floor is the post-correlation one and therefore a property of the despreading modulation, not of the RF band; see Why per signal and not per RF band. The cost of that is linear in the noise-referenced signals sharing a band — a despread each per chunk, against n_sats tracking correlations apiece.
Not measuring a signal at all
NoCN0Estimator keeps no state, does no per-record work and reports -Inf dB-Hz. It is the way to say "this signal's C/N₀ is not measured" — which is worth saying in two situations:
# the non-driver signal of a pair whose C/N₀ nobody reads
TrackedSat(
(GPSL1C_P(), GPSL1C_D()),
prn,
code_phase,
carrier_doppler;
cn0_estimator = (NoiseRefCN0Estimator(), NoCN0Estimator()),
)
# NWPR with no moment-ratio floor underneath it: "no estimate" until a coherent
# window exists, instead of the moment ratio's ~27.6 dB-Hz on noise
NWPRCN0Estimator(GPSL1C_D(); fallback = NoCN0Estimator())The second only matters if you are on NWPR at all — the default estimator has no fallback and no floor. Where you are, it turns the fallback's noise floor into an honest -Inf for every signal and phase that admits no coherent window (see the warning above). The per-record saving of the first is real but tiny — one estimator update is well under a per cent of the correlation work for that record — so choose it for clarity, not for speed.
It reports -Inf dB-Hz rather than NaN dB-Hz deliberately: with Unitful's Level comparison, NaN dB-Hz >= threshold is true for every threshold, so a NaN would clear every lock detector it met. -Inf compares false against any finite threshold, which is the safe answer to "is this signal locked?".
Tracking.MomentsCN0Estimator — Type
MomentsCN0Estimator to estimate the CN0
Tracking.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 "do not measure this signal's C/N₀" — either to skip the per-record work on a signal whose C/N₀ nobody reads, or, more importantly, to avoid publishing a number that cannot be trusted.
Two places where that matters:
- the non-driver signals of a multi-signal
TrackedSatwhose C/N₀ is never read:cn0_estimator = (NWPRCN0Estimator(), NoCN0Estimator())(the saving is small — the per-record update is well under a per cent of a correlation — so reach for this for the reason below, not for speed); - as
NWPRCN0Estimator'sfallback, where it replaces theMomentsCN0Estimator's ~27.6 dB-Hz noise floor with an honest "no estimate" for the signals and phases that admit no coherent window (a secondary-coded signal before sync, or GPS L1C-D / Galileo E1B at any time):NWPRCN0Estimator(; fallback = NoCN0Estimator()).
-Inf dB-Hz rather than NaN dB-Hz, even though "not measured" is what is meant: NaN dB-Hz >= threshold is true for every threshold with Unitful's Level comparison, so a NaN would clear every lock detector it met. -Inf compares false against any finite threshold, which is the safe answer to "is this signal locked?".
What the estimator returns
- Before the first record has been folded in — nothing has been measured and
estimate_cn0returns-Inf dB-Hz. This is the value seen immediately afteradd_satellite!, and it is-Infrather than a finite number precisely so a lock detector cannot clear on it. - Driving a noise-free signal through
track— there is no noise to divide by, so the result runs off to a very large value; NWPR's coherent and incoherent powers agree exactly (μ̂ = M) and it reportsInf dB-Hz, as does the moment estimator. The Quick start shows this. - Driving a noisy signal — the estimate converges to the underlying CN0 as the buffers fill. Real-world signals typically land in 30–50 dB-Hz.
- Driving noise only — the noise-referenced default reports whatever the averaged per-record terms come to, floored at
-Inf dB-Hzonce the mean fails to clear zero — which on pure noise is most of the time, and never a positive floor. NWPR reports-Inf dB-Hzwhenever the mean ratio sits at the incoherent floor (μ̂ ≤ 1). Either way that is the median outcome and not a guarantee: code that thresholds the estimate needs no special case, code that averages it must handle the infinities. - While the loops are struggling — this is where the two differ most. NWPR measures coherent C/N₀, so residual phase noise counts against it: at 1 ms records and the default windows it reads ~0.9 dB low at a true 35 dB-Hz, ~1.1 dB low at 25 dB-Hz and ~1.7 dB low at 20 dB-Hz, and raising
num_narrowband_code_blockspast the loop's coherence time makes it much worse. The non-coherent default is immune — measured throughtrack!at a true 45 dB-Hz it reads 44.9 at 1-block records and 44.7 at 20-block ones, where NWPR falls to 32.9.
Every estimator here reports C/N₀ against the effective noise density, including multi-access interference: the other satellites' cross-correlation adds incoherent power ε that both NWPR and the noise reference see, so both report C/(N₀+ε). That is what other receivers report and the more useful number for a lock detector. The one term they do not share is the reference's self-leakage — see The one bias it carries.
N̂₀ is measured before the prompt is combined, but the prompt the estimator sees is post-AbstractPostCorrFilter — so on an antenna array the two sides of the ratio would describe different channels unless the floor is reduced through the very weights that produced the prompt.
It is. With num_ants > 1 the noise reference despreads every antenna column and its window holds the array's M×M spatial noise covariance R̂ rather than one scalar — one window per signal, shared by every satellite on it, exactly as satellite-agnostic as the single-antenna case. Each satellite then reduces R̂ to its own scalar floor at C/N₀ time through its own current weights,
N̂₀ = wᴴ R̂ wwhich is exact rather than approximate for any fixed w, because E[|wᴴn|²] = wᴴRw. Nothing is stored per satellite: only the final read is.
This is why get_weights is a required part of the AbstractPostCorrFilter contract, and why that contract is deliberately linear in the antennas — a non-linear combiner has no such closed form. DefaultPostCorrFilter selects one antenna (last), for which wᴴR̂w is that antenna's own diagonal entry, so it reports exactly what a single-antenna run on that column would.
Residual Doppler at acquisition handoff
Acquisition hands over a Doppler estimate good to a search-bin width — commonly ±100 Hz or so — and the loop takes tens of milliseconds to pull it in. That transient is where the coherent and non-coherent estimators differ most, and it is also exactly when a lock detector is being asked whether to keep the satellite.
Measured through track! at a true 40 dB-Hz, 1 ms records, the satellite seeded with a deliberate carrier-Doppler error, median of 5 seeds, read over the first 60 ms while the loop is still converging:
| Δf | real within-record loss | NWPR | Moments | NoiseRef |
|---|---|---|---|---|
| 0 Hz | 0.00 dB | 39.8 | 40.5 | 40.1 |
| 30 Hz | −0.01 dB | 39.8 | 40.4 | 40.1 |
| 60 Hz | −0.05 dB | 39.2 | 40.4 | 40.1 |
| 120 Hz | −0.21 dB | 27.6 | 40.5 | 39.9 |
| 250 Hz | −0.91 dB | −Inf | 40.1 | 39.1 |
At 120 Hz NWPR under-reports by 12 dB; at 250 Hz it reports -Inf dB-Hz — "no signal" — on a satellite that is being tracked perfectly well.
The reason is the coherent sum. A residual Δf ramps the prompt's phase by 2π·Δf·T per record, so across the window the ramp is Δf·M·T cycles: 0.6 of a cycle at 120 Hz over the default 5 ms window, which spreads the five phasors over 216° and costs NBP ~5.9 dB. μ̂ collapses toward 1 and the inversion (μ̂−1)/(M−μ̂) falls off a cliff; past 1.25 cycles μ̂ ≤ 1 and the estimate is -Inf. This is the constraint M·T ≪ 1/(2·Δf) stated under NWPRCN0Estimator, which at the default window means Δf ≪ 100 Hz.
The non-coherent estimators see only the within-record loss, sinc²(Δf·T) — 0.21 dB at 120 Hz, 0.91 dB at 250 Hz. That is real signal loss the correlator genuinely suffers, so reporting it is correct rather than a defect; the measured 39.1 dB-Hz at 250 Hz is 40 minus exactly that.
What makes the default robust here is that the noise reference is open-loop: it runs near the band's nominal IF, at a random code phase, and measures noise, which is white. No amount of Doppler error on any satellite can perturb the denominator, so N̂₀ is trustworthy precisely when nothing derived from the loop is.
Once the loop converges, NWPR recovers — 39.2 dB-Hz at every offset up to 120 Hz after 300 ms. At 250 Hz it stays at -Inf, and the noise reference's 38.9 says why: the loop never pulled in from there, so the residual — and its real 0.9 dB loss — is still present. The two agree about the physics and disagree about whether to report it.
If you are on NWPR anyway (a correlator-ingest path with no noise observation), shorten the window that applies before bit sync — but it only goes so far, because a two-record window is also the noisiest one:
num_presync_narrowband_code_blocks | Δf·M·T at 120 Hz | reported |
|---|---|---|
| 5 (default) | 0.60 cycles | 27.6 |
| 3 | 0.36 cycles | 32.4 |
| 2 | 0.24 cycles | 34.9 |
NWPRCN0Estimator(GPSL1CA(); num_presync_narrowband_code_blocks = 2)The doctest below builds a noisy L1 C/A signal at a known 45 dB-Hz CN0 and drives 25 1-ms tracking cycles through it:
julia> using Tracking, GNSSSignals, Random
julia> using Tracking: Hz
julia> using GNSSSignals: gen_code, get_code_frequency
julia> function run_cn0_demo()
sys = GPSL1CA()
fs = 4e6Hz
num_samples = 4000 # 1 ms at 4 MHz
prn = 1
cn0_db_hz = 45.0
sigma = sqrt((fs/Hz) / 10^(cn0_db_hz/10) / 2)
code_freq = get_code_frequency(sys)
rng = MersenneTwister(0)
track_state = TrackState(; signal = GPSL1CA())
track_state =
add_satellite!(track_state; prn, code_phase = 0.0, carrier_doppler = 0.0Hz)
for _ = 1:25
clean = gen_code(num_samples, sys, prn, fs, code_freq, 0.0)
noise = sigma .* randn(rng, ComplexF64, num_samples)
track_state = track(clean .+ noise, track_state, fs)
end
estimate_cn0(track_state, 1)
end;
julia> run_cn0_demo() # 25 ms is pre-sync, so the unaligned five-block window is in use
47.9 dB-HzThe 1-σ noise amplitude is derived from the target CN0 by inverting C/N₀ = signal_power / (σ² / Fs). With unit-amplitude code samples this collapses to σ² = Fs / 10^(CN0_dB/10), split evenly across the complex sample's real and imaginary parts. The seeded MersenneTwister(0) keeps the doctest deterministic; 25 records only fill a quarter of the default 100-record window, so a few dB of spread is expected. The value is also a pre-sync one: five of those records land in a window that straddles a data-bit flip once the signal carries data, which this noise-free-code demo does not.
Tracking.estimate_cn0 — Function
estimate_cn0(estimator, integration_time)
Estimates the CN0 based on the struct MomentsCN0Estimator.
estimate_cn0(_, integration_time)
Always -Inf dB-Hz; see NoCN0Estimator for why that value and not NaN.
estimate_cn0(estimator, integration_time)
Estimate the CN0 from the buffered narrowband and wideband powers, dividing by integration_time — the record's integration time, which is the predetection integration time T of Van Dierendonck's formula (see NWPRCN0Estimator).
The mean power ratio is μ̂ = Σ NBP_k / Σ WBP_k — the ratio of the sums, not the mean of the per-window ratios, which is biased low at short windows; see NWPRCN0Estimator.
Until the first narrowband window has completed — and for good on a signal that admits no window at all — the fallback estimator's value is returned instead.
The mean ratio μ̂ lies in [1, M] by construction and both ends are reported as the limits of the expression rather than clamped to a magic number: μ̂ ≤ 1 means the coherent sum holds no more power than the incoherent one, i.e. no detectable signal, and yields -Inf dB-Hz; μ̂ ≥ M means no detectable noise and yields Inf dB-Hz (the same value a noise-free signal gets out of MomentsCN0Estimator). A consumer thresholding the estimate needs no special case for either; one averaging it does, which is why the infinities are documented rather than hidden behind an epsilon.
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, where each record's own value was known — a record lengthened by set_preferred_num_code_blocks_to_integrate! is therefore handled correctly even when it sits in the ring beside shorter ones. The argument stays for interface uniformity with the other estimators.
An empty ring, a mean that has not cleared zero, and a non-finite mean all report -Inf dB-Hz — the house convention that a missing estimate is -Inf and never NaN (see NoCN0Estimator for why). The last case is what makes the convention hold rather than merely be intended: NaN dB-Hz compares >= true against every lock threshold, so letting one out would turn a dead signal into a locked one, and mean_cn0 <= 0 is exactly the test a NaN passes through. _noise_density_and_ready keeps the reachable source of one — a zero measured floor — out of the ring in the first place; this covers what a NaN prompt would still put there.
Custom CN0 Estimators
Tracking.AbstractCN0Estimator — Type
Abstract supertype for CN0 (carrier-to-noise-density ratio) estimators. Each TrackedSignal holds one estimator instance, stored in a type parameter — pass any subtype instance as the cn0_estimator keyword of TrackedSignal / TrackedSat to replace the default NoiseRefCN0Estimator; see default_cn0_estimator for which to pick when. Custom estimators subtype this and implement Tracking.update and estimate_cn0, plus requires_noise_density if they read a measured noise floor.
Tracking.CN0UpdateContext — Type
Per-record side information handed to Tracking.update alongside the prompt: everything the tracking loop knows about the record that an estimator cannot recover from the prompt stream alone. Its reason for existing is the navigation-bit grid — where the data-bit boundaries sit and whether they are known yet — which is what lets an estimator sum prompts coherently over exactly one symbol (NWPRCN0Estimator does) instead of straddling a bit flip.
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 byset_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— blocks already accumulated into the currently open navigation bit before this record, i.e. this record's own offset inside the bit;0means the record starts a fresh bit. It is-1whenever this record's prompt cannot be summed coherently against the grid: before the signal's bit / secondary-code sync has been found, and — for a secondary-coded signal only — for a record of the fold that follows a sync detected in that same fold, which was correlated without the overlay wipe-off the sync just established (see_apply_correlator_output). Such a record's blocks still count towards the grid, so the offset stays right for the records after it.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), for an estimator that divides the prompt's power by a measured floor instead of inferring one from the prompt stream (NoiseRefCN0Estimatordoes). Per signal and not per band because the floor is the post-correlation one — seeAbstractNoiseEstimator. It is a type parameter, not a sentinel:Nothingsays noAbstractNoiseEstimatoris configured for this signal at all, which is a static property of the setup, so every call site monomorphises and the estimators stay allocation-free. A configured source whose window is merely still empty never reaches here — the fold skips the update instead, so this field is unconditionally a plain scalar whenever it is notNothing.integration_time— this record's ownT, so an estimator that works per record does not have to be told oneTfor a whole ring of records integrated at different lengths.nothingwhen the caller did not supply one (the bare-prompt-stream path).
You can implement your own estimator by creating a subtype of AbstractCN0Estimator and implementing:
Tracking.update(cn0_estimator::MyCN0Estimator, prompt)— return a new estimator with the latest prompt added (immutable update).estimate_cn0(cn0_estimator::MyCN0Estimator, integration_time)— return the CN0 estimate as adB-Hzquantity.integration_timeis the integration time of the record the last prompt came from, i.e.get_last_fully_integrated_integration_time.
If the estimator can profit from the navigation-bit grid — bit boundaries, whether sync has been found, the decoded soft bits — implement the three-argument form instead, which is what the tracking loop calls:
Tracking.update(cn0_estimator::MyCN0Estimator, prompt, context::CN0UpdateContext)
The default three-argument method drops the context and calls the two-argument one, so implementing only the latter is fine.
Tracking.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 Tracking.update; see update(::AbstractCN0Estimator, ::Any, ::CN0UpdateContext) for the extension point that also receives the navigation-bit context.
Tracking.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 Tracking.update(estimator, prompt), so an estimator that only needs the prompt stream — like MomentsCN0Estimator — implements just that one.
Tracking.update — Method
update(estimator, prompt, context)
Accumulate one record's prompt into the open narrowband window, taking the window length and its alignment from the navigation-bit grid in context (see NWPRCN0Estimator for the per-signal-state table). A window that follows the bit grid is only started where a window may start — the bit's own boundary, or a multiple of the window length inside the bit — and an open window is dropped rather than closed whenever it is no longer admissible: no window at all applies to this record (sync lost, or never established), or the grid moved under it when sync was found.
A closed window contributes to the estimate only if it held at least two records; with one record NBP == WBP identically and the window carries no information.
Tracking.update — Method
update(estimator, prompt)
Advance the estimator on a bare prompt stream, with no navigation-bit context: each prompt counts as a one-block record and windows run back to back at num_narrowband_code_blocks, aligned to the first prompt. This is the form to use when folding a captured prompt stream by hand; inside track the three-argument form above is called, which aligns the window to the navigation-bit grid instead.
Plug it in with the cn0_estimator keyword of TrackedSignal or TrackedSat — the estimator is a type parameter of TrackedSignal, so your type is stored as is:
julia> using Tracking, GNSSSignals
julia> using Tracking: Hz
julia> struct MyCN0Estimator <: Tracking.AbstractCN0Estimator end
julia> Tracking.update(estimator::MyCN0Estimator, prompt) = estimator;
julia> Tracking.estimate_cn0(::MyCN0Estimator, integration_time) = 42.0 * Tracking.dBHz;
julia> sat = TrackedSat(GPSL1CA(), 1, 50.0, 1000.0Hz; cn0_estimator = MyCN0Estimator());
julia> estimate_cn0(sat)
42.0 dB-Hz