Noise Estimator

NoiseRefCN0Estimator — the default C/N₀ estimator — divides each record's prompt power by a measured noise floor instead of inferring one from the prompt's own statistics. This page is about where that floor comes from.

The floor is estimated once per signal, never per satellite: every satellite tracking a signal shares its floor, and averaging once per signal is what makes the reference's own variance negligible against the per-record prompt statistics. The estimators live in TrackState's noise_estimators field, a NamedTuple keyed by signal id (GNSSSignals.get_signal_id:GPSL1CA, :GalileoE1B, …).

On the sample-driven path none of this needs configuring — TrackState provisions a CorrelatorNoiseEstimator for every signal asking for a density, and track! fills it. Read on if you feed correlator outputs from hardware, or if you want to know what the measurement actually does.

Tracking.AbstractNoiseEstimatorType

Abstract supertype for per-signal noise estimators — the source of the noise density N₀ that NoiseRefCN0Estimator divides each record's prompt power by.

One instance is held per signal in TrackState's noise_estimators NamedTuple (keyed by GNSSSignals.get_signal_id), never per satellite: every satellite of a signal shares one floor, and averaging it once per signal is what makes the reference's own variance negligible against the per-record prompt statistics.

Why per signal and not per RF band

What a record actually divides by is the post-correlation density. For an interferer of PSD S_I(f) on top of the thermal floor that is

N₀,eff = N₀,thermal + ∫ S_I(f) · |G(f)|² df

— the spectral separation coefficient, weighted by the despreading modulation's own spectrum |G(f)|². Two signals sharing one band, one antenna and one front end therefore see different floors the moment the interference is not white, and the difference is not small: BPSK(1) has its peak at DC and a null at ±1.023 MHz, BOC(1,1) is the reverse, so a CW tone at band centre is rejected by Galileo E1B and lands squarely in GPS L1 C/A, while a tone at ±1.023 MHz does the opposite. Front-end tilt, filter roll-off at the band edge and adjacent-band leakage all colour the floor the same way, more quietly.

Because CorrelatorNoiseEstimator despreads rather than metering power, keying by signal makes that integral measured rather than modelled: the reference runs the consumer's own code, so its spectral weighting is the consumer's by construction. This is the same argument that makes the reference backend-free — it traverses the identical path as the prompt — extended from the quantiser to the interference environment.

It also matches the hardware. A noise channel is a tracking channel with a wrong PRN, and a tracking channel is configured with a code; per-signal is what an FPGA would build anyway.

Why a density and not a power

For a correlator normalised the way Tracking.normalize normalises — by integrated_samples * code_amplitude — white input noise of per-sample variance σ² gives E|P|² = σ²/N = N₀/T. So N₀ = σ²/f_s is independent of the integration time, which is what lets one per-signal figure serve records of any length. It is stored as a Unitful quantity of dimension 1/Hz, so the consumer's ⟨|P|²⟩/N₀ − 1/T is dimension-checked rather than trusted.

The interface

Three required methods, all with a default on this abstract type:

  • update_noise! — measure one signal's slice of its band's samples and append the resulting observations. This is the software fill path; it has exactly one call site, inside downconvert_and_correlate!.
  • append_noise_observation! — append an observation built elsewhere. This is the hardware fill path (FPGA/ASIC correlator or a front-end power monitor), parallel to append_correlator_output!.
  • get_noise_density — the signal's current density, or nothing while nothing has been measured yet. A read, not a drain: the window keeps sliding.

A fourth, Tracking.noise_window_looks, is optional and only matters to a source that reports a covariance: an M×M estimate averaged from fewer than M independent looks is rank-deficient by construction, so the fold withholds it until there are enough. A source that leaves the default in place reports no look count and is never withheld.

The two fill paths live on disjoint call graphs — a hardware producer never calls downconvert_and_correlate! — so one concrete type serves both and no type parameter distinguishes them. See CorrelatorNoiseEstimator, the only shipped implementation.

A source that must not measure from samples (a pure power-monitor integration, say) subtypes this and gives update_noise! a no-op method; that is why the interface is exported.

source

Why per signal and not per RF band

Because what a record divides by is the post-correlation floor. For an interferer of PSD S_I(f) on top of the thermal floor that is

N₀,eff = N₀,thermal + ∫ S_I(f) · |G(f)|² df

— the spectral separation coefficient — weighted by the despreading modulation's own spectrum |G(f)|². So two signals sharing one band, one antenna and one front end do not share a noise floor the moment the interference is not white, and the gap is not subtle. BPSK(1) peaks at band centre and nulls at ±1.023 MHz; BOC(1,1) is the reverse. A CW tone at the chip rate is rejected by GPS L1 C/A and lands squarely on Galileo E1B; move it into C/A's main lobe and the order reverses. Measured through track! on one shared set of samples, 13 dB J/N tone, densities relative to the thermal floor (4 MHz, pure BOC(1,1) E1B):

toneGPS L1 C/AGalileo E1BE1B / C/A
none1.01.01.0
1.023 MHz1.43928×
0.4 MHz35260.75×

Reporting C/A's figure to E1B in the middle row overstates E1B's C/N₀ by ≈14.5 dB; the reversal in the last row is what makes this a spectral result and not a scale factor. The real CBOC E1B shows the same reversal about four times larger. Front-end tilt, filter roll-off at the band edge and adjacent-band leakage colour the floor the same way, more quietly.

The payoff of despreading rather than metering power is that this integral is measured, never modelled: the reference runs the consumer's own code, so its spectral weighting is the consumer's by construction, with no jammer model and no spectrum analysis anywhere. It is the same argument that makes the measurement backend-free, extended from the quantiser to the interference environment.

It also matches the hardware: a noise channel is a tracking channel with a wrong PRN, and a tracking channel is configured with a code.

The samples remain a band property — one front end feeds every signal on it, and BandMeasurements stays keyed by band id. Only the despread, and therefore the measured floor, is per signal.

Why a density and not a power

For a correlator normalised the way Tracking normalises — by integrated_samples * code_amplitude — white input noise of per-sample variance σ² gives

E|P|² = σ²/N = N₀/T

so N₀ = σ²/f_s is independent of the integration time. That is the whole reason a density is stored rather than a power, and it buys two things:

  • One per-signal figure serves records of any length. A satellite still on 1-block records pre-sync and one promoted to 20-block records post-sync divide by the same N̂₀, each subtracting its own 1/T. Mixed record lengths are handled by construction rather than by guarding.
  • The reference needs no alignment to correlator records. It can run on whatever grid is convenient and be averaged over a much longer window than any satellite's prompt buffer, because N₀ is a slowly-varying front-end property.

N₀ carries Unitful dimensions of 1/Hz, so ⟨|P|²⟩/N₀ and 1/T are both in Hz and the arithmetic is dimension-checked rather than trusted.

The two fill paths

softwarehardware
fills the window viatrack!downconvert_and_correlate!update_noise!append_noise_observation!
enters downconvert_and_correlate!?yesno
needs sample buffers?yesno

They live on disjoint call graphs — a hardware producer never calls downconvert_and_correlate!, it injects correlator outputs and folds — so one concrete type serves both and nothing needs to distinguish them. You configure CorrelatorNoiseEstimator either way; you simply fill it differently.

Tracking.update_noise!Function
update_noise!(
    estimator,
    measurement,
    first_sample,
    last_sample,
    context
)

Measure one signal's noise on its band's samples and append the resulting observations to estimator's window, returning estimator.

measurement is the band's BandMeasurement — the samples are a band property, one front end feeding every signal on it; only the despreading code, and therefore the measured floor, is per signal. first_sample and last_sample bound the slice of it this call may consume (the current chunk). context is a NoiseUpdateContext, carrying what a source may need but a BandMeasurement does not have — the signal being measured, which PRNs are currently tracked on it, and the chunk index.

This is the software fill path and it has exactly one call site, inside downconvert_and_correlate!. A source whose observations arrive from outside (see append_noise_observation!) never reaches it, so the default here is a no-op: it mutates the window in place and leaves the struct alone, which is what lets TrackState hold it immutably.

source
update_noise!(
    estimator,
    measurement,
    first_sample,
    last_sample,
    context
)

Measure this signal's noise over samples first_sample:last_sample of measurement and append the resulting observations, returning estimator.

The slice is split into num_sub equal sub-integrations,

num_sub = max(1, round(Int, slice_duration / code_period))

with code_period the primary code period of the signal being measured — see CorrelatorNoiseEstimator for why the sub-integration length is derived rather than configured. Equal slices rather than fixed-length ones so no remainder is wasted and every window entry is statistically identical; the max(1, …) matters, because a signal whose code period exceeds the chunk (a 10 ms code with 1 ms chunks) would otherwise yield no observations at all, forever. Each observation is pushed individually: pre-averaging would put one entry per chunk in the window, re-welding its time span to the Doppler update rate.

Each sub-integration draws a fresh random code phase and a fresh random carrier offset within ±carrier_dither of the band's nominal IF, then runs for its slice. Nothing here follows a code-period boundary, and that is deliberate: the reference despreads with a wrong PRN at a wrong phase, so there is no signal to accumulate coherently and N̂₀ = |B|²/(N·A_c²·f_s) is unbiased for any N and any starting phase. Following boundaries would be actively harmful — a chunk rarely holds a whole number of code periods, so a boundary-aligned reference would have to carry a partial accumulator across calls, which is exactly the scalar state that has no per-signal anchor. Successive observations are independent because their sample ranges are disjoint; the replica repeating is irrelevant.

The draws are per sub-integration rather than per chunk so that every window entry is an independent trial — see CorrelatorNoiseEstimator for why a stationary phase and Doppler turn a chance alignment with a present-but- untracked signal into a permanent bias, and the randomisation turns it back into an occasional single-observation outlier.

All of the correlator's taps are used and pooled into one power sum, at tap_code_shift chips apart so they are genuinely independent looks (see the type's docstring). The despread runs on the caller's backend kernel — the same one the prompt goes through — which is what makes the measurement model-free.

With num_ants > 1 the reference despreads every antenna column and pools the taps into the array's spatial covariance R̂ = Σ_k b_k·b_kᴴ rather than a scalar power. One window per signal still, shared by every satellite on it and just as satellite-agnostic as the scalar case: the reduction to a per-satellite floor happens at C/N₀ time, where wᴴR̂w under that satellite's own weights is the exact noise scale of the very prompt being divided. Measuring one column instead — as this did before — and assuming unity noise gain silently invalidated the ratio for any real beamformer.

The reference is open-loop: no discriminator, no loop filter, no NCO update, and nothing read from satellite state at all. The PRN rotates over the whole family, tracked or not, because a random phase makes avoiding the tracked ones unnecessary.

source
Tracking.append_noise_observation!Function
append_noise_observation!(estimator, _)

Append one externally built NoiseObservation to estimator's sliding window and return estimator. This is the hardware fill path, parallel to append_correlator_output! — see there for how the two differ.

The window is mutated in place and the struct is not rebuilt, so this is allocation-free in steady state and works through the immutable TrackState.

The TrackState form selects the signal:

append_noise_observation!(track_state, obs)             # single-signal TrackState
append_noise_observation!(track_state, obs, :GPSL1CA)   # explicit signal id
append_noise_observation!(track_state, obs, GPSL1CA)    # or the signal type
source
append_noise_observation!(estimator, observation)

Append observation to the signal's sliding window, dropping entries off the front while the remainder still spans window_duration. Returns estimator.

The window is bounded in time, not in observation count, which is what lets one producer report 16-chip accumulations and another a single pre-averaged 0.2 s figure under the same configuration and with no scalar state — the span is a property of the FIFO rather than of a configured count.

O(1) per call, amortised, whatever the window holds — see totals in the type's docstring for why that matters here rather than being a micro-optimisation.

Any NoiseObservation is accepted and retyped onto the window's own field types, which costs nothing for one the builders produced (they already emit the canonical pair) and is what keeps a hand-assembled or Float32 one from matching no method here and falling through to the abstract no-op — where it would be dropped silently and leave the window empty forever.

source
append_noise_observation!(
    track_state,
    observation,
    signal_id
)

Append an externally built NoiseObservation to the addressed signal's noise estimator and return track_state:

  • append_noise_observation!(track_state, obs) — single-signal TrackState.
  • append_noise_observation!(track_state, obs, :GPSL1CA) — explicit signal id.
  • append_noise_observation!(track_state, obs, GPSL1CA) — or the signal type / an instance of it, which is what a caller usually has to hand.

This is the hardware/FPGA fill path, and it is symmetric with what such a producer already does for the taps: append_correlator_output! per signal, append_noise_observation! per signal, then estimate_dopplers_and_filter_prompt! to fold. The two are deliberately not the same mechanism — see append_noise_observation!'s estimator-level method for the table of differences.

Per signal and not per band because the floor a record divides by is the post-correlation one, and that depends on the despreading modulation: a noise channel is a tracking channel with a wrong PRN, so it is configured with a code exactly like the ones it serves. See AbstractNoiseEstimator.

The signal must have a noise estimator; it has one whenever its C/N₀ estimator reads a density (see requires_noise_density), or whenever you declared one through TrackState's noise_estimators keyword.

source
Tracking.get_noise_densityFunction
get_noise_density(_)

The signal's current noise density N₀, or nothing while the window holds nothing yet.

A read, not a drain: the window keeps sliding across chunks and across track! calls, so every record of every satellite tracking that signal divides by the same figure. The returned quantity has dimension 1/Hz — see AbstractNoiseEstimator for why a density rather than a power, and why per signal rather than per band.

source
get_noise_density(estimator)

The window's M-weighted mean density, or nothing while it is empty.

Weighted by num_sub_integrations because that is the number of independent looks each entry represents: the density itself needs only the sample count, but its relative variance is 1/M, so a one-dump entry and a 64-dump entry combine correctly only when weighted this way.

Read straight off the window's running totals, so this is O(1). It is called once per signal per chunk from the Doppler-estimator fold, which is why it may not walk the window: doing so charged every chunk for the window length, i.e. for the very K the estimator's accuracy is bought with.

source

The software source

Tracking.CorrelatorNoiseEstimatorType

The signal's noise reference, measured by despreading an untracked PRN — the only AbstractNoiseEstimator Tracking ships, and the one to configure on a hardware path too (you simply fill it with append_noise_observation! instead of letting update_noise! fill it).

Why a despread and not a power meter

The reference traverses the identical quantise → downconvert → despread → accumulate path as the prompt, reusing the same kernel, the same replica generator and — because the estimator is keyed by signal — the same code. So the measured N₀ already contains every imperfection of that chain — the 1-bit / 2-bit quantisation loss, the quantiser's operating point under load, the input scaling an AGC step moves, the code amplitude of a CBOC replica — with no per-backend model and no closed-form correction. A Σ|x|² power meter would need one line per backend and would still miss the second-order coupling where a strong signal shifts that operating point.

It is also what makes the spectral weighting right rather than assumed. Sharing the consumer's code means the despread evaluates that signal's own ∫ S_I(f)·|G(f)|² df — the coloured-interference case a power meter, or a reference despread with some other signal's code, both get wrong. See AbstractNoiseEstimator.

It is open-loop

The reference has no feedback of any kind: it runs at the band's nominal IF, at a randomly dithered offset from zero Doppler, with a random code phase and a rotating PRN. There is no discriminator, no loop filter and no NCO update ever written to it — and, since the randomisation removed the need to know which PRNs are in use, it reads nothing from satellite state, not even the tracked key set. It is therefore correct with zero satellites tracked, and on an FPGA a noise channel is a strict subset of a tracking channel rather than an addition to one.

Why the code phase and the Doppler are randomised

A reference at a fixed code phase and exactly zero Doppler is a stationary target, and the failure that matters is not an attacker — it is that a hit never goes away. The replica is re-anchored to code phase 0 on a grid running at the nominal chip rate, so the relative phase against any incoming signal at zero Doppler is frozen: whatever it lands on, it stays. A signal that is present but untracked — a spoofed PRN, or simply a visible satellite the receiver has not acquired — has a 3 × 1.5 / 1023 ≈ 0.44 % chance per PRN of sitting inside one of the three taps, and if it does it contributes T·(C/N₀)/3 ≈ 10.5·N₀ at 45 dB-Hz to every observation on that PRN, indefinitely (≈+1.5 dB on N̂₀ after the rotation's dilution). Worse, the geometry is perverse: relative phase drifts only at the signal's own code Doppler (f_d/1540), so low Doppler is both what makes a hit possible and what makes it permanent.

Drawing a fresh code phase and a fresh carrier offset per sub-integration turns that standing bias into an independent per-observation trial. For a full sky at 45 dB-Hz the residual is ≈0.07 dB — a handful of 1 %-sized outliers scattered through a 1000-entry window, rather than a permanent shift of the floor — and, because a hit now needs both draws to land, the two randomisations multiply.

It also makes the untracked-PRN restriction unnecessary, which is why the reference now rotates over all PRNs of the family: a random phase lands within ±1 chip of a tracked satellite's peak with probability ≈0.6 %, worth 10.5/1000 ≈ 0.045 dB for the one observation it touches — an order of magnitude under the whole-sky leakage the estimator already carries uncorrected. A constant 32-PRN pool also stops the rotation from shortening (and the dilution from worsening) exactly as the receiver acquires more satellites.

Neither draw biases the measurement. N̂₀ = |B|²/(N·A_c²·f_s) is unbiased for any starting phase, and for the carrier the reference measures the same noise power wherever it sits; carrier_dither of ±5 kHz smears the spectral weighting ∫ S_I(f)·|G(f)|² df by 0.25 % of a 2 MHz main lobe, which no coloured interferer resolves. On an FPGA an arbitrary code phase is easier than phase 0 — a free-running code generator gives you one, where phase 0 needs a reset.

Fields / configuration

  • window_duration — how far back the sliding window reaches (1 s by default). The one genuine tunable, because it trades: longer means lower variance, but a longer smear across AGC changes. At the default and one sub-integration per code period the window holds K_n ≈ 1000 looks per tap, which costs ≤0.08 dB against a variance-free reference at every C/N₀ — below which no further tuning is warranted.

  • tap_code_shift — the reference correlator's tap spacing in chips (1.5 by default). Taps are only worth having if they are statistically independent, and at the tracking default of ±0.5 chip they are not: for jointly circular Gaussian accumulations corr(|Bᵢ|²,|Bⱼ|²) = |ρᵢⱼ|², and the sampled-code correlation at half a chip is ≈0.5, so three taps are worth 2.25 independent looks. At ≥1 chip they are worth 2.98. 1.5 chips sits in the autocorrelation null and clear of both the 1- and 2-chip sidelobe values.

  • carrier_dither — half-width of the uniform offset added to the band's nominal IF, per sub-integration (5 kHz by default, i.e. the terrestrial GNSS Doppler spread). Zero pins the reference at the IF exactly, which restores half of the stationary target described above — useful to isolate the code-phase draw in a test, and not otherwise.

  • rng — the source of the code-phase and carrier draws, seeded by default (Xoshiro(0), one stream per estimator, advanced in place like buffered so the struct is never rebuilt). What the randomisation has to defeat is a stationary reference, not a reader: an attacker cannot observe the chunk grid the phase would be measured against, so scattering the draws is the whole requirement and unpredictability buys nothing on top. A seeded default keeps a run repeatable, which is worth having in a library whose other guarantees are phrased as bit-identical arithmetic. Pass rng = Random.default_rng() for a task-local stream (also the one to use if you ever share an estimator across threads).

    Repeatable on one Julia version, and no further: Xoshiro's stream is not part of Julia's compatibility guarantee and does change across releases. Do not build a tolerance around a particular draw — size the window so the assertion holds for any draw. Every N̂₀ here is a 1/√(3K) estimate over K observations, and that is the number to design against.

  • buffered — the sliding window itself, a length-managed FIFO written in place. The Vector's own length is the position, so there is no ring index to write back and the struct is never rebuilt — which is what lets per-signal state live in an immutable TrackState.

  • totals — the window's running sums (span, M-weighted density, looks), maintained as entries are pushed and dropped. They are what keeps both append_noise_observation! and get_noise_density O(1); recomputing them per call made each an O(K) scan, and since the whole design buys its variance by making K large (≈2500 entries at a 1 s window and a 0.4 ms chunk), that put a per-chunk cost directly proportional to the accuracy asked for. Written in place through Ref cells, exactly as buffered is, so the "never rebuilt" property still holds — and a cache of buffered rather than state of its own, recomputed from it exactly often enough that the incremental arithmetic cannot drift.

The sub-integration length is deliberately not a field: it is the primary code period of the signal it measures, derived rather than configured. Coherent integration buys nothing for noise-power estimation — one dump carries 100 % relative error however long it is, because Var(|B|²) = (E|B|²)² — so all the information lives in the number of looks, and shortening the dump is the only way to buy more of them. Three things say not to: SIMD (64 kernel calls per 1 ms chunk instead of one, and a shorter dump would need a new kernel variant, forfeiting the bit-identical-arithmetic property the whole approach rests on), DC balance (a full-period Gold code is balanced, E[(Σc)²] ≈ 1; any sub-period window behaves like iid signs, ≈16 at 16 chips, so an ADC offset biases a short despread and not a full-period one), and cadence parity with a hardware correlator, which naturally dumps on the code epoch. The variance is bought back with window_duration instead, which is nearly free — a 1 s window is ~1000 Float64 densities per signal.

source
Tracking.CorrelatorNoiseEstimatorMethod
CorrelatorNoiseEstimator(
;
    window_duration,
    tap_code_shift,
    carrier_dither,
    num_ants,
    rng
)

Construct a CorrelatorNoiseEstimator averaging over the last window_duration of observations, with the reference correlator's taps spaced tap_code_shift chips apart and its carrier dithered by up to carrier_dither either side of the band's nominal IF. See the type's docstring for what the parameters buy and why nothing else is configurable.

rng is drawn from for the per-sub-integration code phase and carrier offset. It defaults to a seeded Xoshiro(0), so a run repeats on a given Julia version; pass Random.default_rng() for a task-local stream. See the type's docstring for why scattering the draws — rather than making them unguessable — is the whole requirement, and for why the seed is not something to calibrate against.

The window is sizehint!-ed to four times the number of code-period observations it expects to hold. The headroom is what makes the FIFO's push!/popfirst! pair measure exactly zero bytes: at 1× or 2× Julia periodically shifts the front offset back and reallocates.

num_ants must match the antenna count of the signal group this estimator is keyed to. At NumAnts(1) the window holds scalar densities; above it, the M×M spatial covariance of the array's noise, which each satellite reduces to its own scalar floor through its own beamforming weights (wᴴR̂w, see update_noise! and AbstractPostCorrFilter). TrackState provisions the right count automatically when noise_estimators is left at nothing, and rejects a mismatch when the estimators are passed explicitly.

source
Tracking.update_noise!Method
update_noise!(
    estimator,
    measurement,
    first_sample,
    last_sample,
    context
)

Measure this signal's noise over samples first_sample:last_sample of measurement and append the resulting observations, returning estimator.

The slice is split into num_sub equal sub-integrations,

num_sub = max(1, round(Int, slice_duration / code_period))

with code_period the primary code period of the signal being measured — see CorrelatorNoiseEstimator for why the sub-integration length is derived rather than configured. Equal slices rather than fixed-length ones so no remainder is wasted and every window entry is statistically identical; the max(1, …) matters, because a signal whose code period exceeds the chunk (a 10 ms code with 1 ms chunks) would otherwise yield no observations at all, forever. Each observation is pushed individually: pre-averaging would put one entry per chunk in the window, re-welding its time span to the Doppler update rate.

Each sub-integration draws a fresh random code phase and a fresh random carrier offset within ±carrier_dither of the band's nominal IF, then runs for its slice. Nothing here follows a code-period boundary, and that is deliberate: the reference despreads with a wrong PRN at a wrong phase, so there is no signal to accumulate coherently and N̂₀ = |B|²/(N·A_c²·f_s) is unbiased for any N and any starting phase. Following boundaries would be actively harmful — a chunk rarely holds a whole number of code periods, so a boundary-aligned reference would have to carry a partial accumulator across calls, which is exactly the scalar state that has no per-signal anchor. Successive observations are independent because their sample ranges are disjoint; the replica repeating is irrelevant.

The draws are per sub-integration rather than per chunk so that every window entry is an independent trial — see CorrelatorNoiseEstimator for why a stationary phase and Doppler turn a chance alignment with a present-but- untracked signal into a permanent bias, and the randomisation turns it back into an occasional single-observation outlier.

All of the correlator's taps are used and pooled into one power sum, at tap_code_shift chips apart so they are genuinely independent looks (see the type's docstring). The despread runs on the caller's backend kernel — the same one the prompt goes through — which is what makes the measurement model-free.

With num_ants > 1 the reference despreads every antenna column and pools the taps into the array's spatial covariance R̂ = Σ_k b_k·b_kᴴ rather than a scalar power. One window per signal still, shared by every satellite on it and just as satellite-agnostic as the scalar case: the reduction to a per-satellite floor happens at C/N₀ time, where wᴴR̂w under that satellite's own weights is the exact noise scale of the very prompt being divided. Measuring one column instead — as this did before — and assuming unity noise gain silently invalidated the ratio for any real beamformer.

The reference is open-loop: no discriminator, no loop filter, no NCO update, and nothing read from satellite state at all. The PRN rotates over the whole family, tracked or not, because a random phase makes avoiding the tracked ones unnecessary.

source

Four properties are worth knowing about it.

It is open-loop. The reference runs at its band's nominal IF, dithered, with a random code phase and a rotating PRN. There is no discriminator, no loop filter and no NCO update ever written to it, and it reads nothing at all from satellite state — so it is correct with zero satellites tracked, which is also what would make it usable as an acquisition CFAR floor.

Its code phase and Doppler are randomised, drawn afresh for every sub-integration. The reason is not an adversary; it is that a stationary reference makes a chance alignment permanent. The replica is re-anchored to phase 0 on a grid running at the nominal chip rate, so against any incoming signal at zero Doppler the relative phase is frozen wherever it lands — and a present-but-untracked signal (a spoofed PRN, or a visible satellite the receiver has not acquired) has a 3 × 1.5 / 1023 ≈ 0.44 % chance per PRN of landing inside a tap, worth T·(C/N₀)/3 ≈ 10.5·N₀ at 45 dB-Hz on every observation with that code, forever. The geometry is perverse too: relative phase drifts only at the signal's own code Doppler, so low Doppler both enables a hit and makes it stick. Drawing the phase and the carrier per sub-integration turns that into an independent trial per observation — a ≈0.07 dB residual instead of a ≈1.5 dB standing bias — and since a hit needs both draws, the two multiply.

This is also why the rotation covers the whole PRN family, tracked codes included. Skipping tracked PRNs was what made a fixed phase 0 safe; a random phase makes it unnecessary, and dropping it removes the reference's only dependency on satellite state while keeping the pool at a constant 32 — under the old rule the pool shrank, and diluted a bad draw less well, exactly as the receiver acquired more satellites. Landing within ±1 chip of a tracked peak now costs ≈0.045 dB for the single observation it touches.

It is model-free, and that is the reason it despreads rather than measuring Σ|x|². The reference traverses the identical quantise → downconvert → despread → accumulate path as the prompt, on the same kernel, so the measured N̂₀ already contains every imperfection of that chain — the quantisation loss, the quantiser's operating point under load, the input scaling an AGC step moves, a CBOC replica's code amplitude — with no per-backend analysis and no closed-form correction. It shows up directly: the same 45 dB-Hz sky reports ≈45 dB-Hz on the float and Int16 backends, ≈44 on two-bit and ≈43 on one-bit, i.e. each backend's own quantisation loss, without a line of per-backend correction anywhere.

Its sub-integration length is derived, not configured. Coherent integration buys nothing for noise-power estimation: for a complex-Gaussian accumulation Var(|B|²) = (E|B|²)², so one dump carries 100 % relative error however long it is, and all the information about σ² lives in the number of independent looks. Shortening the dump is therefore the only way to buy more of them — and three things say not to. SIMD (64 kernel calls per 1 ms chunk instead of one, and a shorter dump would need a new kernel variant, forfeiting the bit-identical arithmetic the approach rests on). DC balance (a full-period Gold code is balanced, E[(Σc)²] ≈ 1; a 16-chip window behaves like iid signs at ≈16, so an ADC offset biases a short despread and not a full-period one). And cadence parity with a hardware correlator, which naturally dumps on the code epoch. The variance is bought back with window_duration instead, which is nearly free — a 1 s window is ~1000 Float64 densities per signal.

Hardware producers (FPGA / ASIC)

The question a hardware implementer asks first is whether the noise measurement should reuse the existing downconvert-and-correlate datapath or get a dedicated, minimal one. Reuse it — allocate a channel. Not because it is less work, but because on the architectures FPGA correlators actually use it is also cheaper:

  • On a time-multiplexed engine — one physical datapath across N channels at a high clock, per-channel state in BRAM — a noise channel is one more slot in the schedule. A handful of state words, essentially zero logic. A dedicated path is a whole second datapath.
  • On a fully parallel array it costs less than one channel out of N, because the reference is open-loop: fixed NCO increments, no discriminator, no loop-filter interface, no dump-on-command synchronisation. A dedicated path would have to re-implement the carrier NCO, code generator and complex multiplier to save accumulators you were not using — so it is larger, not smaller.
  • No divider and no float on chip. Report the raw integer accumulation with its weight and sample count; every division, the A_c² and f_s scaling, and all averaging happen in Tracking. That is both the cheapest option on-chip and the mechanism that forces the two paths to agree.

And reuse is what buys the model-freeness above: identical quantisation and rounding, so the 1-bit/2-bit loss cancels in the ratio; identical input scaling, so an AGC step moves numerator and denominator together; identical code generator, so A_c matches exactly. A path tapping at a different point — before a decimation filter, at a different bit width — breaks all three silently.

What the producer must match

Only two things, because everything else is computed here:

  1. Report raw accumulations, through one of the builders below. They ride the same return path as the correlator outputs, so no new wire interface is needed: append_noise_observation! sits beside append_correlator_output! on the host.
  2. Randomise the code phase per dump, and rotate the PRN. Do not pin the phase — see above for why a stationary reference turns a chance alignment into a permanent one. On an FPGA this is the easy option: a free-running code generator gives an arbitrary phase, where phase 0 needs a reset. Dithering the carrier a few kHz either side of the nominal IF costs one more LFSR draw and multiplies the protection. With the phase randomised there is no need to know which PRNs are tracked, so the noise channel needs no input from the tracking channels at all.

A producer that can only dump on the code epoch matches the software default exactly, so in the normal case there is nothing to reconcile. Where a producer does differ it still does not conflict: "the two paths behave the same" means computed by the same code, on the same scale, with the same bias, which the shared window guarantees because it is M-weighted and bounded in time. Only the precision differs — a code-epoch dump gives K_n ≈ 1000 looks per second per tap, which is indistinguishable from anything finer below 40 dB-Hz.

The observation and its builders

Tracking.NoiseObservationType

One noise measurement, the producer-side value parallel to CorrelatorOutput. It carries the already-normalised density plus the two pieces of bookkeeping a sliding window needs, so the internal contract is one scalar and every unit conversion happens at the boundary where the caller knows their hardware.

Build one with noise_observation, noise_observation_from_correlator or noise_observation_from_samples rather than by hand — they are the three shapes a producer actually has, and all three reduce to the same N₀ on the same white input.

Fields:

  • noise_densityN₀, of dimension 1/Hz. For an antenna array it is the M×M spatial covariance instead, of the same dimension elementwise: the diagonal is each antenna's own N₀ and the off-diagonals their noise correlation. A satellite reduces it to its own scalar floor through its beamforming weights, wᴴR̂w (see AbstractPostCorrFilter).
  • num_sub_integrationsM, the number of independent looks that went into it, and therefore its statistical weight. Not the sample count: for Σ_m |B_m|² over M sub-integrations of N samples each, the density needs only M·N, but its relative variance is 1/M. A producer handing back one 1 ms coherent despread and one handing back 64 16-chip despreads report the same total samples and wildly different precision, so the window is averaged weighted by M.
  • duration — the wall-clock span of samples the observation covers, which is what bounds the sliding window. It is not M · N / f_s in general: the software source's M looks are the correlator's taps, which are simultaneous rather than consecutive.
  • prn — the PRN whose code was used to measure it. Legitimate metadata (the observation really was measured with that code), and it is what carries the software source's PRN-rotation position without any scalar state.
source
Tracking.noise_observationFunction
noise_observation(
    accumulation,
    integrated_samples,
    sampling_frequency;
    code_amplitude,
    prn
)

Build a NoiseObservation from one dump given as a raw complex accumulation Σ x·c over integrated_samples samples — the most faithful form a producer can report, since Tracking does the squaring, the scaling and all the averaging itself.

code_amplitude is the RMS amplitude of the sampled replica (see Tracking.normalize); leave it at 1 for a ±1 code, pass the code's RMS for a multi-level one (CBOC). prn records which code measured it.

Pass an SVector of M per-antenna accumulations for an antenna array, and the observation carries the spatial covariance b·bᴴ instead of the scalar |b|². Report every element the front end has: a satellite's floor is wᴴR̂w under its own beamforming weights, and an array collapsed to one number cannot answer that.

source
Tracking.noise_observation_from_correlatorFunction
noise_observation_from_correlator(
    accumulated_power,
    num_sub_integrations,
    total_samples,
    sampling_frequency;
    code_amplitude,
    prn,
    duration
)

Build a NoiseObservation from M = num_sub_integrations dumps pre-summed on chip as Σ_m |B_m|², covering total_samples = M · N samples in all.

Pre-summing must be incoherent (Σ|B_m|², never |Σ B_m|²), which is why this builder takes a power rather than a complex value. M > 1 exists only so a high-rate producer can cut host traffic; the natural granularity is one observation per dump with M = 1, which noise_observation covers.

duration defaults to total_samples / sampling_frequency, which is right when the M sub-integrations are consecutive in time. Pass it explicitly when they are simultaneous — the software source's M looks are the reference correlator's taps, which all integrate the same N samples, so their span is N / sampling_frequency and not M times that.

For an antenna array, pass the pre-summed spatial covariance Σ_m B_m·B_mᴴ as an SMatrix. The incoherence requirement is the same one term for term: sum the outer products, never form the outer product of the sum.

source
Tracking.noise_observation_from_samplesFunction
noise_observation_from_samples(
    accumulated_power,
    num_samples,
    sampling_frequency;
    prn
)

Build a NoiseObservation from a front-end / AGC power monitor: Σ|x|² over num_samples raw samples.

This is noise_observation_from_correlator at a sub-integration of one sample — the replica degenerates to a single ±1 value, |x·c|² = |x|², and the window mean is exactly σ̂² — so M == num_samples and no code_amplitude applies. It is the minimum-variance, flat-weighting end of the same continuum the despread source sits at the spectral-fidelity end of; a tilted front end is the one thing that tells them apart.

For an antenna array, pass Σ x·xᴴ as an SMatrix — the array's raw spatial covariance, which is what a beamformer's weights are reduced against.

source

All three reduce to the same N₀ on the same white input, which is what makes the paths interchangeable.

The weight is M, the number of independent looks — not the sample count. For Σ_m |B_m|² over M sub-integrations of N samples each the density needs only M·N, but its relative variance is 1/M: a producer handing back one 1 ms coherent despread and one handing back 64 16-chip despreads report the same total samples and wildly different precision. Weighting the window by M is what makes observations from different producers combinable at all.

A worked ingest, per fold:

# taps, per signal, exactly as before
append_correlator_output!(track_state, CorrelatorOutput(correlator, n, sample_index), prn)

# the noise channel: Σ|B|² pooled over its taps, one signal at a time. A noise
# channel is configured with a code, so this is the granularity a producer
# already has.
# `accumulated_power` is the raw integer sum off the wire; `num_taps` is how many
# accumulators it pooled, and the sample count is per tap times that.
append_noise_observation!(
    track_state,
    noise_observation_from_correlator(
        accumulated_power,
        num_taps,
        num_taps * samples_per_dump,
        sampling_frequency;
        prn = noise_prn,
        # the taps are simultaneous, not consecutive — they all integrate the
        # same samples, so the window must count that span once
        duration = samples_per_dump / sampling_frequency,
    ),
    :GPSL1CA,
)

estimate_dopplers_and_filter_prompt!(track_state, (L1 = sampling_frequency,))

If a signal has a noise estimator and nothing ever fills it, the fold warns once per signal and every C/N₀ on it reads -Inf dB-Hz. That is the likeliest integration mistake and the warning names the fix; it never fires on the sample-driven path.

How append_noise_observation! differs from append_correlator_output!

They are deliberately not the same mechanism:

append_correlator_output!append_noise_observation!
scopeone signal of one satelliteone signal, across every satellite tracking it
payloadthe whole correlator: every tap kept separate, complex, per antennathe taps pooled — into one power sum, or into one spatial covariance for an array — plus M, the span and the PRN
drivesdiscriminators, both loop filters, bit sync, the NCO update, the C/N₀ promptthe noise floor only
timingsample_index is load-bearing — vector tracking needs a common gridnone: N₀ is slowly varying, so only "recent" matters
lifetimedrained by the fold each chunk, buffer reusedsliding window bounded in time, never drained
countone per completed coherent integration per signalany granularity; M carries the weight

The payload row is the sharpest difference. Both traverse a multi-tap correlator, for opposite reasons: the prompt path needs the taps kept apart because their differences are the code and carrier discriminants, while the reference pools them, because at ≥1 chip spacing they are three independent looks at the same noise and nothing about their relative values means anything.

The antenna dimension is the one thing the reference does keep. With num_ants > 1 the pooled payload is the array's M×M spatial covariance R̂ = Σ_k b_k·b_kᴴ rather than a scalar Σ_k |b_k|² — its diagonal is each antenna's own N₀ and its off-diagonals the antennas' noise correlation. That is what a beamformer's C/N₀ needs: the floor its weights actually see is wᴴR̂w, which no single number can answer. Every builder below takes the per-antenna form of the input it already takes, so a hardware source reports all M elements rather than collapsing them.

Writing your own source

AbstractNoiseEstimator and its three methods are public, so a source that is neither of the shipped paths — a front-end power monitor read over a sideband, say — is a subtype away:

struct MyPowerMonitor <: Tracking.AbstractNoiseEstimator
    densities::Vector{typeof(1.0 / 1.0Hz)}
end

# fed from outside, so the sample-driven path is a no-op (that is the default,
# and it is why `update_noise!` need not be implemented at all here)
Tracking.append_noise_observation!(e::MyPowerMonitor, obs) =
    (push!(e.densities, obs.noise_density); e)
Tracking.get_noise_density(e::MyPowerMonitor) =
    isempty(e.densities) ? nothing : sum(e.densities) / length(e.densities)

Pass it in through TrackState's noise_estimators keyword:

TrackState(; signal = GPSL1CA(), noise_estimators = (GPSL1CA = MyPowerMonitor(...),))

Two contracts to keep. The window must be mutated in place and the struct returned unchanged — TrackState is immutable and never rebuilt for a noise update. And Tracking.noise_density_type must name the concrete type get_noise_density returns, so the fold can split off the nothing once per signal per chunk and keep everything below it monomorphic; it defaults to typeof(1.0/1.0Hz), which every shipped builder produces.

Tracking.noise_density_typeFunction
noise_density_type(_)

The concrete type get_noise_density returns for estimator once its window holds anything. Used to keep the fold's density argument a plain scalar — never a Union{Nothing,…} — so update stays devirtualised and allocation-free even while the window is still empty.

Defaults to typeof(1.0/1.0Hz), which every shipped builder produces.

source
Tracking.NoiseUpdateContextType

Per-call side information handed to update_noise!: what a software noise source may need and a BandMeasurement does not carry.

Fields:

  • signalthe signal being measured, i.e. the one this estimator's key names. Its primary code period sets the sub-integration length and its code family is the one the reference despreads with, so the measurement carries that signal's own spectral weighting — which is the point of keying the estimator by signal (see AbstractNoiseEstimator). Nothing here is chosen: it is the consumer's signal, not a per-band stand-in for it.
  • chunk_index — the index of the chunk being measured, on the same grid downconvert_and_correlate! uses.
  • downconvert_and_correlator — the backend running this call. A software source must despread on it rather than on a kernel of its own choosing: the one- and two-bit accumulators are popcount counts rather than sample sums, so a float-kernel reference would compare |P|² and N̂₀ on incompatible scales, and the quantisation loss would drop out of the measurement instead of being carried by it.

Kept as one struct so that adding a field later is not a signature change.

Note what is not here: which PRNs are currently tracked. The reference used to need it, to skip tracked PRNs and so keep a fixed code phase 0 off their correlation peaks. CorrelatorNoiseEstimator now randomises the phase instead, so it rotates over the whole family and reads nothing from satellite state — which is what makes "open-loop" literal rather than approximate.

source
Tracking.requires_noise_densityFunction
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.

It is a trait rather than a hard-coded type check because update(::AbstractCN0Estimator, ::Any, ::CN0UpdateContext) is a documented extension point: a third-party estimator that wants a density must be able to say so, and one that does not must not pay for it.

Two things key off it, and both are compile-time constants on the estimator's type:

  • Provisioning. TrackState gives a signal a CorrelatorNoiseEstimator only where that signal's estimator returns true. A signal that does not ask gets no entry at all, so its despread never runs and costs exactly zero — which is the answer for anyone who deliberately stays on NWPR.
  • 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 — its co-residents on the same band and the same satellite are untouched, because each has its own source. An NWPRCN0Estimator beside it would otherwise be corrupted: a record missing from the bit grid makes _update_nwpr drop its open narrowband window, so NWPR would silently degrade to its fallback.

The trait's real home is the type, and the instance method forwards to it. That is what lets provisioning be decided from a group's already-fixed slot type rather than from a satellite value: the whole noise_estimators NamedTuple — its keys included — then folds out of TrackState's type parameters instead of inferring as a union of "provisioned" and "not". A custom estimator may define either form; defining the type form is the one that keeps that folding.

source
requires_noise_density(_)

This estimator reads its signal's noise density, so a signal carrying it is provisioned with a CorrelatorNoiseEstimator. Declared on the type, so the provisioning decision folds out of a group's slot type — see requires_noise_density.

source