Noise estimation
A noise estimator measures the post-correlation noise density N₀ of one signal, which a NoiseRefCN0Estimator divides each record's prompt power by. It is fed NoiseObservations — from a correlator tap on a PRN that is not transmitted (noise_observation_from_correlator) or from raw samples (noise_observation_from_samples) — with append_noise_observation! or update_noise!, and read with get_noise_density. CorrelatorNoiseEstimator keeps a sliding window of observations; subtype AbstractNoiseEstimator for another source.
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, antenna and front end therefore see different floors as soon as the interference is not white, and not by a little: BPSK(1) peaks at DC with 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.
It also matches the hardware: a noise channel is a tracking channel with a wrong PRN, configured with a code, so per signal is what an FPGA builds anyway.
Why a density and not a power
For a correlator normalised the way TrackingLoops.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.
TrackingLoops.AbstractNoiseEstimator — Type
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 (not per satellite or RF band) in Tracking.TrackState's noise_estimators. Every satellite of the signal shares the one floor, and averaging it once per signal makes the reference's own variance negligible against the per-record prompt statistics; see Noise estimation for why per signal rather than per band, and why a density.
The interface
Three required methods, all with a default on this abstract type:
update_noise!— the software fill path, called only inside Tracking.jl'sdownconvert_and_correlate!.append_noise_observation!— the hardware fill path, parallel toTracking.append_correlator_output!.get_noise_density— the signal's current density, ornothingwhile nothing has been measured yet.
A fourth, TrackingLoops.noise_window_looks, is optional and only matters to a source that reports a covariance.
The two fill paths live on disjoint call graphs, so one concrete type, CorrelatorNoiseEstimator, serves both.
TrackingLoops.NoiseDensity — Type
NoiseDensityThe type of a single-antenna noise density N₀, typeof(1.0 / 1.0Hz): what every shipped observation builder produces, and what get_noise_density returns for a single-antenna estimator. A multi-antenna window measures a spatial covariance matrix instead; see noise_density_type.
TrackingLoops.NoiseEstimators — Type
Type alias for a NamedTuple of AbstractNoiseEstimators keyed by signal id — the shape Tracking.TrackState holds them in. Not a Dictionary, whose value type would turn abstract (and allocate per chunk) once two signals hold different estimator types.
TrackingLoops.NoiseObservation — Type
One noise measurement, the producer-side value parallel to CorrelatorOutput: the already-normalised density plus the bookkeeping a sliding window needs. Build one with noise_observation, noise_observation_from_correlator or noise_observation_from_samples rather than by hand; all three reduce to the same N₀ on white noise.
Fields:
noise_density—N₀, of dimension1/Hz. For an antenna array it is theM×Mspatial covarianceR̂instead, of the same dimension elementwise: the diagonal is each antenna's ownN₀and the off-diagonals their noise correlation. A satellite reduces it to its own scalar floor through its beamforming weights,wᴴR̂w(seeAbstractPostCorrFilter).num_sub_integrations—M, the number of independent looks, and so the statistical weight (relative variance1/M). Not the sample count: forΣ_m |B_m|²overMsub-integrations ofNsamples each the density needsM·N, but one 1 ms coherent despread and 64 16-chip despreads cover the same samples with very different precision, so the window weights byM.duration— the wall-clock span of samples covered, which bounds the sliding window. NotM · N / f_sin general: the software source's looks are simultaneous taps.prn— the PRN whose code measured it; also carries the software source's PRN-rotation position (see_next_noise_prn).
TrackingLoops.append_noise_observation! — Method
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 Tracking.append_correlator_output!. The window is mutated in place, so this works through the immutable TrackState.
The Tracking.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 typeTrackingLoops.get_noise_density — Method
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 track! calls. The returned quantity has dimension 1/Hz (see Noise estimation).
TrackingLoops.noise_density_type — Method
noise_density_type(_)
The concrete type get_noise_density returns for estimator once its window holds anything, so the fold's density argument stays a plain scalar rather than a Union{Nothing,…}.
Defaults to typeof(1.0/1.0Hz), which every shipped builder produces.
TrackingLoops.noise_observation — Method
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, since the estimator does the squaring, scaling and averaging.
code_amplitude is the RMS amplitude of the sampled replica (see TrackingLoops.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.
For an antenna array pass an SVector of M per-antenna accumulations; the observation then carries the spatial covariance b·bᴴ. Report every element: each satellite's floor is wᴴR̂w under its own weights.
TrackingLoops.noise_observation_from_correlator — Method
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|²). M > 1 only cuts host traffic; one observation per dump is what noise_observation covers.
duration defaults to total_samples / sampling_frequency, right for consecutive sub-integrations. Pass it explicitly when they are simultaneous (e.g. correlator taps over the same N samples: N / sampling_frequency).
For an antenna array, pass Σ_m B_m·B_mᴴ as an SMatrix (sum the outer products, never the outer product of the sum).
TrackingLoops.noise_observation_from_samples — Method
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 one-sample sub-integration, so M == num_samples and no code_amplitude applies: the minimum-variance source, but unlike a despread it weights the spectrum flat, not by the signal's |G(f)|² (see Noise estimation), so it misweights coloured interference.
For an antenna array, pass Σ x·xᴴ as an SMatrix.
TrackingLoops.noise_window_looks — Method
noise_window_looks(_)
How many independent looks the estimator's current density is averaged from, or nothing if the source does not report it.
Only the rank gate reads this: an M×M covariance averaged from fewer than M looks is rank-deficient by construction, so the fold withholds it until there are enough. A source returning nothing is never gated.
TrackingLoops.update_noise! — Method
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 Tracking.BandMeasurement (the samples are per band; only the despreading code is per signal). first_sample:last_sample is the slice this call may consume. context is a NoiseUpdateContext.
This is the software fill path, called only inside Tracking.jl's downconvert_and_correlate!. The default is a no-op, for sources fed from outside. Implementations mutate the window in place and return the same struct, since TrackState is immutable.
TrackingLoops.CorrelatorNoiseEstimator — Type
The signal's noise reference, measured by despreading a PRN at a random code phase and Doppler. The only shipped AbstractNoiseEstimator; on a hardware path fill it with append_noise_observation! instead of update_noise!.
Why a despread and not a power meter
The reference runs the same quantise → downconvert → despread → accumulate path as the prompt, with the same kernel and (keyed by signal) the same code. So the measured N₀ already contains quantisation loss, the quantiser's operating point under load, AGC scaling and a CBOC replica's code amplitude, with no per-backend model; and the despread evaluates the signal's own ∫ S_I(f)·|G(f)|² df (see Noise estimation). A Σ|x|² power meter gets both wrong.
Open loop
No feedback of any kind: the reference runs at the band's nominal IF plus a random dither, at a random code phase, rotating over all PRNs of the family. It reads nothing from satellite state, so it works with zero satellites tracked, and on an FPGA a noise channel is a subset of a tracking channel.
Why code phase and Doppler are randomised
At a fixed code phase and zero Doppler, the relative phase against an untracked but present signal (spoofed, or visible and not acquired) is frozen, since it drifts only at the code Doppler f_d/1540. Such a signal has a 3 × 1.5 / 1023 ≈ 0.44 % chance per PRN of sitting in one of the three taps and then adds T·(C/N₀)/3 ≈ 10.5·N₀ (45 dB-Hz) to every observation on that PRN indefinitely (≈ +1.5 dB on N̂₀ after the rotation's dilution). Fresh draws per sub-integration turn that into independent per-observation trials (a hit needs both draws to land, so the two randomisations multiply): ≈ 0.07 dB residual for a full sky at 45 dB-Hz. Landing within ±1 chip of a tracked satellite's peak (≈ 0.6 %, ≈ 0.045 dB for the one observation it touches) is an order of magnitude under the whole-sky leakage already carried uncorrected, hence no untracked-PRN restriction; a constant 32-PRN pool also keeps the dilution from worsening as more satellites are acquired.
Neither draw biases the measurement: N̂₀ = |B|²/(N·A_c²·f_s) is unbiased for any phase, and a ±5 kHz dither smears the spectral weighting by 0.25 % of a 2 MHz main lobe. On an FPGA a free-running code generator gives an arbitrary phase for free.
Fields / configuration
window_duration: how far back the sliding window reaches (1 s). Longer means lower variance but more smear across AGC changes. The default holdsK_n ≈ 1000looks per tap at one sub-integration per code period, ≤ 0.08 dB from a variance-free reference at every C/N₀.tap_code_shift: tap spacing in chips (1.5). Taps help only if independent:corr(|Bᵢ|²,|Bⱼ|²) = |ρᵢⱼ|², so at ±0.5 chip three taps are worth 2.25 looks, at ≥ 1 chip 2.98. 1.5 chips sits in the autocorrelation null, clear of the 1- and 2-chip sidelobes.carrier_dither: half-width of the uniform offset added to the nominal IF per sub-integration (5 kHz, the terrestrial Doppler spread). Zero is useful only to isolate the code-phase draw in a test.rng: source of both draws, seededXoshiro(0)by default and advanced in place. Scattering the draws is the whole requirement (an attacker cannot observe the chunk grid), so a seeded, repeatable default costs nothing. PassRandom.default_rng()for a task-local stream (e.g. across threads). Repeatable on one Julia version only: do not tune a tolerance to a particular draw; design against the1/√(3K)spread ofN̂₀overKobservations.buffered: the sliding window, a length-managed FIFO written in place, so the struct is never rebuilt and per-signal state can live in an immutableTracking.TrackState.totals: running sums of the window (seeNoiseWindowTotals), keeping append and read O(1); an O(K) scan per call would make each chunk's cost grow with theK(≈ 2500 at 1 s and 0.4 ms chunks) the accuracy is bought with.
The sub-integration length is not a field: it is the signal's primary code period. Coherent integration buys nothing for noise power (Var(|B|²) = (E|B|²)²), so only the number of looks matters, yet shorter dumps would cost SIMD efficiency and a new kernel (losing bit-identical arithmetic), lose the full-period Gold code's DC balance (an ADC offset would bias the despread), and break cadence parity with a hardware correlator dumping on the code epoch. A long window buys the variance back cheaply (~1000 Float64 per signal per second).
TrackingLoops.CorrelatorNoiseEstimator — Method
CorrelatorNoiseEstimator(
;
window_duration,
tap_code_shift,
carrier_dither,
num_ants,
rng
)
Construct a CorrelatorNoiseEstimator; see the type for the parameters.
The window is sizehint!-ed to 4× the 1 ms observations in window_duration: with less headroom Julia periodically shifts the FIFO's front offset back and reallocates, so push!/popfirst! would not be allocation-free.
num_ants must match the antenna count of the signal group. Above NumAnts(1) the window holds the M×M noise covariance R̂, which each satellite reduces to its own floor wᴴR̂w through its beamforming weights (see update_noise!, AbstractPostCorrFilter). TrackState provisions the count when noise_estimators is nothing and rejects a mismatch otherwise.
TrackingLoops.append_noise_observation! — Method
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. Amortised O(1).
The window is bounded in time, not count, so one producer may report 16-chip accumulations and another a single pre-averaged 0.2 s figure under the same configuration.
Any NoiseObservation is converted to the window's field types (free for builder output), so a hand-assembled or Float32 one is not silently dropped by falling through to the abstract no-op.
TrackingLoops.get_noise_density — Method
get_noise_density(estimator)
The window's mean density weighted by num_sub_integrations (M), or nothing while it is empty. O(1), read off the running totals.
An entry's relative variance is 1/M (its independent looks), so entries of different dump counts combine correctly only weighted by M.
TrackingLoops.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, append the observations and return estimator. Forwards to despread_noise! on context.downconvert_and_correlator; a loop process uses append_noise_observation! instead.