Skip to main content

kshana/
tracking_loop.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! **Tracking-loop loss-of-lock model: C/N₀ → lock/unlock with hysteresis, the time it
3//! takes to lose lock, and the spoof pull-in limits set by code and carrier offset rate.**
4//!
5//! The engine's existing denial and capture criteria are **ratios on received power**: a
6//! jammer denies at `J/S = 30 dB` and a spoofer captures at `J/S = 3 dB`
7//! ([`crate::attack_surface`]), and a satellite is "lost" when its effective C/N₀ crosses
8//! a flat 25 dB-Hz line ([`crate::jamming::lock_status`]). Real loss of lock does not work
9//! that way. A tracking channel gives up when its loop can no longer hold the signal, and
10//! that depends on the loop noise bandwidth `B_n`, the predetection integration time `T`,
11//! the correlator spacing `d`, and the dynamics the loop is being asked to follow. This
12//! module builds that model and reports the denial radius it implies **alongside** the
13//! power-ratio radius — never in place of it — with the difference named
14//! (`denial_radius_delta_km`) so the two can be compared rather than quietly swapped.
15//!
16//! Nothing here forks a second correlator or a second link budget. The loop primitives are
17//! [`crate::sdr`] ([`correlate`](crate::sdr::correlate), [`synth_if`](crate::sdr::synth_if),
18//! [`CaCode`](crate::sdr::CaCode)) and the radiometry is
19//! [`crate::jamming`] ([`j_over_s_db`](crate::jamming::j_over_s_db),
20//! [`effective_cn0_dbhz`](crate::jamming::effective_cn0_dbhz),
21//! [`nominal_cn0_dbhz`](crate::jamming::nominal_cn0_dbhz),
22//! [`q_factor`](crate::jamming::q_factor)).
23//!
24//! ## What is modelled
25//!
26//! 1. **Carrier-loop thermal jitter** — the Costas 1σ phase jitter
27//!    `σ_PLL = √( (B_n/(C/N₀)) · (1 + 1/(2·T·C/N₀)) )` rad, the second factor being the
28//!    squaring loss. The per-epoch (open-loop) discriminator jitter is the same expression
29//!    with `B_n → 1/(2T)`, and the two differ by exactly the loop-filter factor `√(2·B_n·T)`.
30//! 2. **Code-loop thermal jitter** — the non-coherent early/late 1σ code jitter
31//!    `σ_DLL = √( (d·B_n/(2·C/N₀)) · (1 + 2/((2−d)·T·C/N₀)) )` chips.
32//! 3. **The tracking-threshold rules** — a Costas loop is held to `3σ_PLL + θ_e ≤ 45°`
33//!    (the "15-degree rule": one sigma may not exceed a quarter of the 60° quarter-cycle
34//!    budget), and a DLL to `3σ_DLL + lag ≤ d/2` chips, the discriminator's linear
35//!    half-range. Both allowances are inputs; the defaults are the stated rules.
36//! 4. **Dynamic stress** — a second-order carrier loop carries a steady-state phase error
37//!    `θ_e = 360·ḟ/ω_n²` degrees under a Doppler rate `ḟ`, with `ω_n = B_n/0.53`; a
38//!    first-order code loop carries a ramp lag `ẋ/(4·B_n)` chips under a code slew `ẋ`.
39//!    These are what a spoofer pushing on the loop actually has to stay inside.
40//! 5. **Hysteresis** — a receiver that has lost lock must *re-pull-in*, and pull-in runs a
41//!    wider loop than steady-state tracking. Since jitter grows as `√B_n`, the re-lock
42//!    threshold is strictly above the drop threshold, and the width follows from the
43//!    bandwidth ratio rather than being asserted: in the thermal-dominated limit it is
44//!    `10·log₁₀(r)` dB and in the squaring-loss-dominated limit `5·log₁₀(r)` dB, so any
45//!    computed hysteresis must lie between those two bounds.
46//! 6. **Time to lose lock** — two distinct quantities, both reported. The *declared* loss
47//!    of lock is when a two-threshold lock detector with confirmation dwells transitions,
48//!    driven by a C/N₀ profile; the *physical* escape time is the mean time between cycle
49//!    slips of the Costas loop, `T̄ = π²·ρ'·I₀²(ρ')/(2·B_n)` with `ρ' = ρ/4` and loop SNR
50//!    `ρ = 1/σ_PLL²` (Viterbi's first-order result applied to the doubled phase `2φ` a Costas
51//!    loop tracks, whose slips are half cycles), reported in `log₁₀ s` because it spans
52//!    many decades.
53//!
54//! ## Validated vs Modelled
55//!
56//! **Validated against the existing [`crate::sdr`] correlator stepped forward** — a
57//! genuinely different route to the same numbers, not a restatement:
58//!
59//! * the open-loop Costas discriminator jitter including its squaring-loss term agrees
60//!   with the measured jitter of [`crate::sdr::correlate`] on seeded synthetic IF to
61//!   better than 2 % over 35–50 dB-Hz;
62//! * the closed-loop jitter reduction `σ_closed = σ_meas·√(2·B_n·T)` agrees with a
63//!   stepped first-order Costas loop built from the same correlator to ~3 %;
64//! * the first-order code-loop ramp lag `ẋ/(4·B_n)` agrees with the stepped DLL to
65//!   better than 0.1 %, with `B_n = g·slope/(4T)` the discrete loop's equivalent
66//!   bandwidth and `slope = 1/(2−d)` the ideal-triangle discriminator gain;
67//! * [`bessel_i0`] agrees with the standard modified-Bessel values to < 2 × 10⁻⁶ relative.
68//!
69//! **Modelled** — the default loop parameters (10 Hz carrier / 1 Hz code noise bandwidth,
70//! 1 ms predetection integration, 0.5 chip spacing), the 2× pull-in bandwidth ratio, the
71//! confirmation dwell lengths, the representative jammer power and antenna gains, and the
72//! C/N₀ profile are all **representative band figures**, not a datasheet for any receiver.
73//! Oscillator (Allan-deviation) and vibration jitter are **not** modelled: the carrier
74//! budget here is thermal plus dynamic stress only, and a real receiver adds those two
75//! terms, which can only make the threshold worse. Front-end bandwidth limiting, multipath,
76//! AGC dynamics, data-bit-transition losses, and aiding from an inertial or clock reference
77//! are likewise out of scope and stated rather than silently included. The slip time is
78//! Viterbi's first-order expression; a second-order loop slips more often (GNSS-SDR 0.0.19's
79//! second-order Costas loop slipped about ten times more often near the threshold in
80//! `tests/tracking_loop_gnss_sdr_oracle.rs`).
81//!
82//! References: Kaplan & Hegarty, *Understanding GPS/GNSS* (3rd ed.), ch. 8 (carrier and
83//! code tracking loops, thermal jitter, the tracking-threshold rules, the second-order
84//! `B_n = 0.53·ω_n` relation); A. J. Viterbi, *Principles of Coherent Communication*
85//! (1966) and F. M. Gardner, *Phaselock Techniques* (3rd ed.), ch. 9 (mean time between
86//! cycle slips); Abramowitz & Stegun §9.8 (the `I₀` polynomial approximations).
87
88use crate::jamming::{
89    effective_cn0_dbhz, j_over_s_db, nominal_cn0_dbhz, q_factor, CA_CHIP_RATE_HZ, C_M_PER_S, L1_HZ,
90};
91use crate::portable_math::PortableFloat;
92use serde::{Deserialize, Serialize};
93
94// --- constants -----------------------------------------------------------------------
95
96/// The Costas-loop carrier tracking-threshold allowance (degrees) applied to `3σ + θ_e`.
97/// This is the "15-degree rule" stated as its three-sigma form: one sigma of phase jitter
98/// may not exceed 15°, i.e. three sigma may not exceed 45°.
99pub const COSTAS_THRESHOLD_DEG: f64 = 45.0;
100
101/// The ratio `B_n/ω_n` for the standard second-order loop (damping ζ = 1/√2), as used to
102/// convert a stated loop noise bandwidth into the natural frequency that sets dynamic
103/// stress. Kaplan & Hegarty, ch. 8.
104pub const SECOND_ORDER_BN_OVER_WN: f64 = 0.53;
105
106/// Lower bound on the search bracket (m) used when inverting a link for a radius.
107pub const RADIUS_BRACKET_MIN_M: f64 = 1.0;
108
109/// Upper bound on the search bracket (m) used when inverting a link for a radius.
110pub const RADIUS_BRACKET_MAX_M: f64 = 1.0e10;
111
112/// Lowest C/N₀ (dB-Hz) considered when solving a tracking threshold.
113pub const CN0_SEARCH_MIN_DBHZ: f64 = -20.0;
114
115/// Highest C/N₀ (dB-Hz) considered when solving a tracking threshold.
116pub const CN0_SEARCH_MAX_DBHZ: f64 = 80.0;
117
118/// Hard cap on the number of lock-detector steps a single run may take, so no input can
119/// turn the state machine into an unbounded loop.
120pub const MAX_DETECTOR_STEPS: usize = 2_000_000;
121
122/// Hard cap on the length of any input axis (the spoof pull-in grid, the jitter table).
123pub const MAX_AXIS_LEN: usize = 64;
124
125#[inline]
126fn lin(db: f64) -> f64 {
127    10f64.ppowf(db / 10.0)
128}
129
130// --- thermal jitter ------------------------------------------------------------------
131
132/// **Per-epoch (open-loop) Costas discriminator phase jitter** (rad) at `cn0_dbhz` for a
133/// predetection integration time `t_s`:
134/// `σ² = (1/(2·C/N₀·T)) · (1 + 1/(2·T·C/N₀))`. The first factor is the post-correlation
135/// phase noise of a coherent integration; the second is the squaring loss a Costas
136/// discriminator pays for being insensitive to the data-bit sign.
137///
138/// This is the quantity the `atan(Q/I)` discriminator in [`crate::sdr::track`] produces
139/// each epoch, and the tests check it against that correlator directly. It is valid while
140/// the discriminator is in its linear region; below roughly 32 dB-Hz at `T` = 1 ms the
141/// real `atan` output saturates against its ±π/2 range and measures *less* jitter than
142/// this formula predicts — a bounded estimator, not a better loop.
143pub fn pll_measurement_jitter_rad(cn0_dbhz: f64, t_s: f64) -> f64 {
144    let c = lin(cn0_dbhz);
145    let t = t_s.max(f64::MIN_POSITIVE);
146    ((1.0 / (2.0 * c * t)) * (1.0 + 1.0 / (2.0 * t * c))).sqrt()
147}
148
149/// **Closed-loop Costas carrier thermal jitter** (rad):
150/// `σ_PLL² = (B_n/(C/N₀)) · (1 + 1/(2·T·C/N₀))`.
151///
152/// Identical to [`pll_measurement_jitter_rad`] scaled by the loop-filter factor
153/// `√(2·B_n·T)`, which is what a loop noise bandwidth means.
154pub fn pll_thermal_jitter_rad(cn0_dbhz: f64, bn_hz: f64, t_s: f64) -> f64 {
155    let c = lin(cn0_dbhz);
156    let t = t_s.max(f64::MIN_POSITIVE);
157    ((bn_hz.max(0.0) / c) * (1.0 + 1.0 / (2.0 * t * c))).sqrt()
158}
159
160/// **Per-epoch (open-loop) code-error jitter** (chips) of a non-coherent early/late
161/// discriminator at correlator spacing `d`:
162/// `σ² = (d/(4·T·C/N₀)) · (1 + 2/((2−d)·T·C/N₀))`.
163///
164/// The closed-loop form is this scaled by `√(2·B_n·T)`. As with the carrier, the real
165/// normalised amplitude discriminator saturates at low C/N₀ (its output is bounded by
166/// ±d/2 chips of implied error), so the formula over-predicts there.
167pub fn dll_measurement_jitter_chips(cn0_dbhz: f64, t_s: f64, spacing_chips: f64) -> f64 {
168    let c = lin(cn0_dbhz);
169    let t = t_s.max(f64::MIN_POSITIVE);
170    let d = spacing_chips.clamp(1e-6, 1.9);
171    ((d / (4.0 * t * c)) * (1.0 + 2.0 / ((2.0 - d) * t * c))).sqrt()
172}
173
174/// **Closed-loop code thermal jitter** (chips) for a non-coherent early/late DLL:
175/// `σ_DLL² = (d·B_n/(2·C/N₀)) · (1 + 2/((2−d)·T·C/N₀))`.
176pub fn dll_thermal_jitter_chips(cn0_dbhz: f64, bn_hz: f64, t_s: f64, spacing_chips: f64) -> f64 {
177    let c = lin(cn0_dbhz);
178    let t = t_s.max(f64::MIN_POSITIVE);
179    let d = spacing_chips.clamp(1e-6, 1.9);
180    ((d * bn_hz.max(0.0) / (2.0 * c)) * (1.0 + 2.0 / ((2.0 - d) * t * c))).sqrt()
181}
182
183// --- dynamic stress ------------------------------------------------------------------
184
185/// **Steady-state carrier phase error** (degrees) a second-order loop of noise bandwidth
186/// `bn_hz` carries under a Doppler rate of `doppler_rate_hz_per_s`:
187/// `θ_e = 360·ḟ/ω_n²` with `ω_n = B_n/0.53`. This is the dynamic-stress term that adds to
188/// `3σ` in the 45° carrier budget, and it is what limits how fast a spoofer may slew a
189/// victim's carrier before the victim's loop simply falls off the back.
190pub fn pll_dynamic_stress_deg(doppler_rate_hz_per_s: f64, bn_hz: f64) -> f64 {
191    let b = bn_hz.max(f64::MIN_POSITIVE);
192    360.0 * doppler_rate_hz_per_s * SECOND_ORDER_BN_OVER_WN * SECOND_ORDER_BN_OVER_WN / (b * b)
193}
194
195/// **Steady-state code lag** (chips) a first-order code loop of noise bandwidth `bn_hz`
196/// carries under a code slew of `code_slew_chips_per_s`: `lag = ẋ/(4·B_n)`.
197pub fn dll_ramp_lag_chips(code_slew_chips_per_s: f64, bn_hz: f64) -> f64 {
198    code_slew_chips_per_s / (4.0 * bn_hz.max(f64::MIN_POSITIVE))
199}
200
201/// The linear-region gain (chips of discriminator output per chip of code error) of the
202/// normalised early-minus-late **amplitude** discriminator
203/// `½·(|E|−|L|)/(|E|+|L|)` for an ideal triangular autocorrelation at spacing `d`:
204/// `1/(2−d)`. This is the constant that maps the per-epoch gain of the discrete loop in
205/// [`crate::sdr::track`] onto an equivalent loop noise bandwidth.
206pub fn early_late_amplitude_discriminator_slope(spacing_chips: f64) -> f64 {
207    1.0 / (2.0 - spacing_chips.clamp(1e-6, 1.9))
208}
209
210/// The equivalent one-sided loop noise bandwidth (Hz) of a **discrete first-order loop**
211/// that applies `gain × discriminator` once per `t_s`: `B_n = g·k/(4·T)`, where `k` is the
212/// discriminator's linear-region slope. A first-order loop with this bandwidth carries the
213/// ramp lag [`dll_ramp_lag_chips`] returns, which is how the two are cross-checked against
214/// the [`crate::sdr`] correlator.
215pub fn equivalent_first_order_bandwidth_hz(gain: f64, t_s: f64, discriminator_slope: f64) -> f64 {
216    gain * discriminator_slope / (4.0 * t_s.max(f64::MIN_POSITIVE))
217}
218
219// --- tracking thresholds --------------------------------------------------------------
220
221/// Bisect a monotonically **increasing** function of C/N₀ for its zero over the search
222/// bracket. Returns `None` when the bracket does not contain a sign change.
223fn bisect_cn0(f: impl Fn(f64) -> f64) -> Option<f64> {
224    let (mut lo, mut hi) = (CN0_SEARCH_MIN_DBHZ, CN0_SEARCH_MAX_DBHZ);
225    let (flo, fhi) = (f(lo), f(hi));
226    if !flo.is_finite() || !fhi.is_finite() || flo > 0.0 || fhi < 0.0 {
227        return None;
228    }
229    for _ in 0..200 {
230        let mid = 0.5 * (lo + hi);
231        if f(mid) < 0.0 {
232            lo = mid;
233        } else {
234            hi = mid;
235        }
236    }
237    Some(0.5 * (lo + hi))
238}
239
240/// **The carrier-loop tracking threshold** (dB-Hz): the lowest C/N₀ at which
241/// `3·σ_PLL + θ_e` still fits inside `allowance_deg`, for a loop of bandwidth `bn_hz`,
242/// integration `t_s`, under a Doppler rate `doppler_rate_hz_per_s`.
243///
244/// `None` when the dynamic stress alone already exceeds the allowance — the loop cannot
245/// hold that Doppler rate at **any** signal strength, which is a real answer and not a
246/// zero.
247pub fn carrier_tracking_threshold_cn0_dbhz(
248    bn_hz: f64,
249    t_s: f64,
250    allowance_deg: f64,
251    doppler_rate_hz_per_s: f64,
252) -> Option<f64> {
253    let stress = pll_dynamic_stress_deg(doppler_rate_hz_per_s, bn_hz);
254    bisect_cn0(|cn0| {
255        allowance_deg - stress - 3.0 * pll_thermal_jitter_rad(cn0, bn_hz, t_s).to_degrees()
256    })
257}
258
259/// **The code-loop tracking threshold** (dB-Hz): the lowest C/N₀ at which
260/// `3·σ_DLL + lag` still fits inside `allowance_chips`, for a DLL of bandwidth `bn_hz`,
261/// integration `t_s`, correlator spacing `spacing_chips`, under a code slew of
262/// `code_slew_chips_per_s`. `None` when the slew lag alone exceeds the allowance.
263pub fn code_tracking_threshold_cn0_dbhz(
264    bn_hz: f64,
265    t_s: f64,
266    spacing_chips: f64,
267    allowance_chips: f64,
268    code_slew_chips_per_s: f64,
269) -> Option<f64> {
270    let lag = dll_ramp_lag_chips(code_slew_chips_per_s, bn_hz).abs();
271    bisect_cn0(|cn0| {
272        allowance_chips - lag - 3.0 * dll_thermal_jitter_chips(cn0, bn_hz, t_s, spacing_chips)
273    })
274}
275
276// --- modified Bessel I0 and the cycle-slip time ----------------------------------------
277
278/// **Modified Bessel function of the first kind, order zero.** Abramowitz & Stegun §9.8
279/// polynomial approximations: 9.8.1 for `|x| ≤ 3.75` (|ε| < 1.6 × 10⁻⁷) and 9.8.2 for
280/// `|x| > 3.75` (|ε| < 1.9 × 10⁻⁷ on the scaled form). Overflows to infinity above
281/// `x ≈ 713`; use [`ln_bessel_i0`] where that matters.
282pub fn bessel_i0(x: f64) -> f64 {
283    let ax = x.abs();
284    if ax <= 3.75 {
285        let t = (ax / 3.75) * (ax / 3.75);
286        1.0 + t
287            * (3.515_622_9
288                + t * (3.089_942_4
289                    + t * (1.206_749_2 + t * (0.265_973_2 + t * (0.036_076_8 + t * 0.004_581_3)))))
290    } else {
291        (ln_bessel_i0(ax)).pexp()
292    }
293}
294
295/// **Natural logarithm of [`bessel_i0`]**, stable for arbitrarily large arguments: for
296/// `x > 3.75` the A&S 9.8.2 scaled form gives `ln I₀ = x − ½·ln x + ln(poly(3.75/x))`,
297/// so a loop SNR of several hundred (where `I₀` itself overflows a float) is still
298/// reportable. Needed because the mean time between cycle slips grows like `e^{2ρ}`.
299pub fn ln_bessel_i0(x: f64) -> f64 {
300    let ax = x.abs();
301    if ax <= 3.75 {
302        bessel_i0(ax).pln()
303    } else {
304        let t = 3.75 / ax;
305        let poly = 0.398_942_28
306            + t * (0.013_285_92
307                + t * (0.002_253_19
308                    + t * (-0.001_575_65
309                        + t * (0.009_162_81
310                            + t * (-0.020_577_06
311                                + t * (0.026_355_37 + t * (-0.016_476_33 + t * 0.003_923_77)))))));
312        ax - 0.5 * ax.pln() + poly.pln()
313    }
314}
315
316/// **Mean time between Costas carrier slips** (half cycles), reported as `log₁₀(seconds)`:
317/// `T̄ = π²·ρ'·I₀²(ρ')/(2·B_n)` with `ρ' = ρ/4` and loop SNR `ρ = 1/σ_PLL²` (Viterbi's first-order,
318/// sinusoidal-phase-detector result; Gardner, *Phaselock Techniques*, ch. 9).
319///
320/// A Costas loop is insensitive to the data sign, so it tracks the doubled phase `2φ`: that
321/// variable behaves as a phase-locked loop with loop SNR `ρ/4`, and one of its cycle slips is
322/// a half-cycle (π) slip of the carrier. Using `ρ` itself (the phase-locked-loop form) would
323/// overstate the slip time by many decades near the tracking threshold. Returned in log₁₀
324/// because the quantity spans many decades over the C/N₀ range of interest: at the 15° design
325/// point (`ρ' ≈ 3.6`) it is of order a minute for a 10 Hz loop, and it collapses below a
326/// second as the jitter grows. Viterbi's expression is for a first-order loop; a second-order
327/// loop slips more often, so this is an optimistic bound.
328pub fn log10_mean_time_to_cycle_slip_s(cn0_dbhz: f64, bn_hz: f64, t_s: f64) -> f64 {
329    let sigma = pll_thermal_jitter_rad(cn0_dbhz, bn_hz, t_s);
330    let rho = 0.25 / (sigma * sigma).max(f64::MIN_POSITIVE);
331    let ln10 = std::f64::consts::LN_10;
332    2.0 * std::f64::consts::PI.plog10() + rho.plog10() + 2.0 * ln_bessel_i0(rho) / ln10
333        - (2.0 * bn_hz.max(f64::MIN_POSITIVE)).plog10()
334}
335
336// --- the hysteretic lock detector -------------------------------------------------------
337
338/// Whether the receiver currently declares the channel locked.
339#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
340pub enum LockState {
341    /// The channel is declared locked and its measurements are used.
342    Locked,
343    /// The channel is declared unlocked; it must clear the (higher) re-lock threshold and
344    /// hold it for the confirmation dwell before being used again.
345    Unlocked,
346}
347
348impl LockState {
349    /// The state's label as it appears in a report.
350    pub fn as_str(self) -> &'static str {
351        match self {
352            LockState::Locked => "LOCKED",
353            LockState::Unlocked => "UNLOCKED",
354        }
355    }
356}
357
358/// A two-threshold lock detector with confirmation dwells — the mechanism that makes
359/// lock/unlock **hysteretic** rather than a single line a noisy C/N₀ estimate chatters
360/// across. Lock is dropped after `drop_confirm_epochs` consecutive epochs below
361/// `drop_cn0_dbhz`, and regained after `relock_confirm_epochs` consecutive epochs at or
362/// above the strictly higher `relock_cn0_dbhz`.
363#[derive(Clone, Copy, Debug)]
364pub struct LockDetector {
365    /// C/N₀ (dB-Hz) below which the tracking-threshold rule is violated.
366    pub drop_cn0_dbhz: f64,
367    /// C/N₀ (dB-Hz) that must be cleared to re-declare lock — the pull-in threshold.
368    pub relock_cn0_dbhz: f64,
369    /// Consecutive epochs below `drop_cn0_dbhz` required before lock is dropped.
370    pub drop_confirm_epochs: usize,
371    /// Consecutive epochs at or above `relock_cn0_dbhz` required before lock is regained.
372    pub relock_confirm_epochs: usize,
373    /// The current declared state.
374    pub state: LockState,
375    /// How many consecutive epochs the pending transition has been satisfied for.
376    pub streak: usize,
377}
378
379impl LockDetector {
380    /// A detector starting in [`LockState::Locked`] with the given thresholds and dwells.
381    pub fn new(
382        drop_cn0_dbhz: f64,
383        relock_cn0_dbhz: f64,
384        drop_confirm_epochs: usize,
385        relock_confirm_epochs: usize,
386    ) -> Self {
387        Self {
388            drop_cn0_dbhz,
389            relock_cn0_dbhz,
390            drop_confirm_epochs,
391            relock_confirm_epochs,
392            state: LockState::Locked,
393            streak: 0,
394        }
395    }
396
397    /// Feed one epoch's C/N₀ and return the declared state after it.
398    pub fn step(&mut self, cn0_dbhz: f64) -> LockState {
399        match self.state {
400            LockState::Locked => {
401                if cn0_dbhz < self.drop_cn0_dbhz {
402                    self.streak += 1;
403                    if self.streak >= self.drop_confirm_epochs.max(1) {
404                        self.state = LockState::Unlocked;
405                        self.streak = 0;
406                    }
407                } else {
408                    self.streak = 0;
409                }
410            }
411            LockState::Unlocked => {
412                if cn0_dbhz >= self.relock_cn0_dbhz {
413                    self.streak += 1;
414                    if self.streak >= self.relock_confirm_epochs.max(1) {
415                        self.state = LockState::Locked;
416                        self.streak = 0;
417                    }
418                } else {
419                    self.streak = 0;
420                }
421            }
422        }
423        self.state
424    }
425}
426
427/// The C/N₀ thresholds a loop configuration implies, with the binding loop named.
428#[derive(Clone, Copy, Debug)]
429pub struct LoopThresholds {
430    /// Carrier-loop drop threshold (dB-Hz), or `None` if the dynamics alone break it.
431    pub carrier_drop_cn0_dbhz: Option<f64>,
432    /// Code-loop drop threshold (dB-Hz), or `None` if the slew alone breaks it.
433    pub code_drop_cn0_dbhz: Option<f64>,
434    /// The drop threshold that binds: the larger of the two (whichever loop gives up first).
435    pub drop_cn0_dbhz: Option<f64>,
436    /// Which loop binds — `"carrier"`, `"code"`, or `"none"` if neither has a solution.
437    pub binding_loop: &'static str,
438    /// Re-lock threshold (dB-Hz) evaluated with the wider pull-in bandwidths.
439    pub relock_cn0_dbhz: Option<f64>,
440    /// `relock_cn0_dbhz − drop_cn0_dbhz` (dB): the hysteresis width.
441    pub hysteresis_db: Option<f64>,
442}
443
444/// The loop geometry a threshold calculation needs.
445#[derive(Clone, Copy, Debug)]
446pub struct LoopConfig {
447    /// Carrier (Costas) loop one-sided noise bandwidth (Hz).
448    pub pll_bandwidth_hz: f64,
449    /// Code (DLL) loop one-sided noise bandwidth (Hz).
450    pub dll_bandwidth_hz: f64,
451    /// Predetection coherent integration time (s).
452    pub integration_s: f64,
453    /// Early-to-late correlator spacing (chips).
454    pub spacing_chips: f64,
455    /// Carrier `3σ + θ_e` allowance (degrees).
456    pub carrier_allowance_deg: f64,
457    /// Code `3σ + lag` allowance (chips).
458    pub code_allowance_chips: f64,
459    /// Multiplier applied to both bandwidths when re-pulling-in after a loss of lock.
460    pub pullin_bandwidth_ratio: f64,
461}
462
463impl LoopConfig {
464    /// Solve the drop and re-lock thresholds for this configuration under a given carrier
465    /// Doppler rate and code slew (both zero for a static user).
466    pub fn thresholds(
467        &self,
468        doppler_rate_hz_per_s: f64,
469        code_slew_chips_per_s: f64,
470    ) -> LoopThresholds {
471        let carrier = carrier_tracking_threshold_cn0_dbhz(
472            self.pll_bandwidth_hz,
473            self.integration_s,
474            self.carrier_allowance_deg,
475            doppler_rate_hz_per_s,
476        );
477        let code = code_tracking_threshold_cn0_dbhz(
478            self.dll_bandwidth_hz,
479            self.integration_s,
480            self.spacing_chips,
481            self.code_allowance_chips,
482            code_slew_chips_per_s,
483        );
484        let (drop, binding) = match (carrier, code) {
485            (Some(a), Some(b)) => {
486                if a >= b {
487                    (Some(a), "carrier")
488                } else {
489                    (Some(b), "code")
490                }
491            }
492            (Some(a), None) => (Some(a), "carrier"),
493            (None, Some(b)) => (Some(b), "code"),
494            (None, None) => (None, "none"),
495        };
496        let r = self.pullin_bandwidth_ratio.max(1.0);
497        let relock_carrier = carrier_tracking_threshold_cn0_dbhz(
498            self.pll_bandwidth_hz * r,
499            self.integration_s,
500            self.carrier_allowance_deg,
501            doppler_rate_hz_per_s,
502        );
503        let relock_code = code_tracking_threshold_cn0_dbhz(
504            self.dll_bandwidth_hz * r,
505            self.integration_s,
506            self.spacing_chips,
507            self.code_allowance_chips,
508            code_slew_chips_per_s,
509        );
510        let relock = match (relock_carrier, relock_code) {
511            (Some(a), Some(b)) => Some(a.max(b)),
512            (Some(a), None) => Some(a),
513            (None, Some(b)) => Some(b),
514            (None, None) => None,
515        };
516        LoopThresholds {
517            carrier_drop_cn0_dbhz: carrier,
518            code_drop_cn0_dbhz: code,
519            drop_cn0_dbhz: drop,
520            binding_loop: binding,
521            relock_cn0_dbhz: relock,
522            hysteresis_db: match (relock, drop) {
523                (Some(r), Some(d)) => Some(r - d),
524                _ => None,
525            },
526        }
527    }
528}
529
530// --- spoof pull-in ----------------------------------------------------------------------
531
532/// The largest code slew (chips/s) a **noiseless** first-order DLL of bandwidth `bn_hz`
533/// can be dragged at before its ramp lag leaves the discriminator's linear half-range
534/// `allowance_chips`: `ẋ_max = 4·B_n·allowance`. A spoofer slewing faster than this does
535/// not capture a locked loop — it outruns it, and the victim falls back onto the authentic
536/// peak (or loses lock) instead of following the lie.
537///
538/// This is the geometric ceiling. At a finite signal strength the code jitter eats into
539/// the same allowance; [`max_code_slew_at_cn0_chips_per_s`] is the operative limit.
540pub fn max_code_slew_chips_per_s(bn_hz: f64, allowance_chips: f64) -> f64 {
541    4.0 * bn_hz.max(0.0) * allowance_chips.max(0.0)
542}
543
544/// The largest code slew (chips/s) a DLL can be dragged at **at signal strength
545/// `cn0_dbhz`**: the ramp lag may only use whatever `3σ_DLL` leaves of `allowance_chips`.
546/// Zero when the jitter already fills the budget. Reduces to
547/// [`max_code_slew_chips_per_s`] as the jitter goes to zero, and is the ceiling the
548/// pull-in map's cells are decided against, so the two can never disagree.
549pub fn max_code_slew_at_cn0_chips_per_s(
550    bn_hz: f64,
551    cn0_dbhz: f64,
552    t_s: f64,
553    spacing_chips: f64,
554    allowance_chips: f64,
555) -> f64 {
556    let jitter = 3.0 * dll_thermal_jitter_chips(cn0_dbhz, bn_hz, t_s, spacing_chips);
557    let headroom = allowance_chips - jitter;
558    if headroom <= 0.0 {
559        return 0.0;
560    }
561    4.0 * bn_hz.max(0.0) * headroom
562}
563
564/// The largest carrier Doppler rate (Hz/s) a second-order loop of bandwidth `bn_hz` can be
565/// dragged at, at signal strength `cn0_dbhz`: the dynamic stress must fit in whatever the
566/// jitter leaves of `allowance_deg`. Zero when the jitter already fills the budget.
567pub fn max_doppler_rate_hz_per_s(bn_hz: f64, cn0_dbhz: f64, t_s: f64, allowance_deg: f64) -> f64 {
568    let jitter = 3.0 * pll_thermal_jitter_rad(cn0_dbhz, bn_hz, t_s).to_degrees();
569    let headroom = allowance_deg - jitter;
570    if headroom <= 0.0 {
571        return 0.0;
572    }
573    headroom * bn_hz * bn_hz / (360.0 * SECOND_ORDER_BN_OVER_WN * SECOND_ORDER_BN_OVER_WN)
574}
575
576/// One cell of the spoof pull-in map: whether a spoofer slewing the victim's code at
577/// `code_slew_chips_per_s` and its carrier at `doppler_rate_hz_per_s` stays inside both
578/// loops' following limits.
579#[derive(Clone, Copy, Debug)]
580pub struct PullInCell {
581    /// Code slew the spoofer applies (chips/s).
582    pub code_slew_chips_per_s: f64,
583    /// Carrier Doppler rate the spoofer applies (Hz/s).
584    pub doppler_rate_hz_per_s: f64,
585    /// Steady-state code lag the victim's DLL carries (chips).
586    pub code_lag_chips: f64,
587    /// Steady-state carrier phase error the victim's PLL carries (degrees).
588    pub carrier_stress_deg: f64,
589    /// Total carrier error budget used, `3σ + θ_e` (degrees).
590    pub carrier_budget_deg: f64,
591    /// Whether the code loop follows (lag inside the allowance).
592    pub code_follows: bool,
593    /// Whether the carrier loop follows (`3σ + θ_e` inside the allowance).
594    pub carrier_follows: bool,
595    /// Whether the victim follows on both axes — the pull-in condition.
596    pub pulled_in: bool,
597}
598
599/// Evaluate one spoof pull-in cell for a loop configuration at signal strength `cn0_dbhz`.
600pub fn pull_in_cell(
601    cfg: &LoopConfig,
602    cn0_dbhz: f64,
603    code_slew_chips_per_s: f64,
604    doppler_rate_hz_per_s: f64,
605) -> PullInCell {
606    let lag = dll_ramp_lag_chips(code_slew_chips_per_s, cfg.dll_bandwidth_hz).abs();
607    let stress = pll_dynamic_stress_deg(doppler_rate_hz_per_s, cfg.pll_bandwidth_hz).abs();
608    let jitter = 3.0
609        * pll_thermal_jitter_rad(cn0_dbhz, cfg.pll_bandwidth_hz, cfg.integration_s).to_degrees();
610    let code_jitter = 3.0
611        * dll_thermal_jitter_chips(
612            cn0_dbhz,
613            cfg.dll_bandwidth_hz,
614            cfg.integration_s,
615            cfg.spacing_chips,
616        );
617    let budget = jitter + stress;
618    let code_follows = lag + code_jitter <= cfg.code_allowance_chips;
619    let carrier_follows = budget <= cfg.carrier_allowance_deg;
620    PullInCell {
621        code_slew_chips_per_s,
622        doppler_rate_hz_per_s,
623        code_lag_chips: lag,
624        carrier_stress_deg: stress,
625        carrier_budget_deg: budget,
626        code_follows,
627        carrier_follows,
628        pulled_in: code_follows && carrier_follows,
629    }
630}
631
632// --- the denial link --------------------------------------------------------------------
633
634/// The one-jammer link whose radius both denial criteria are evaluated on. Every leg is
635/// evaluated with the existing [`crate::jamming`] functions; nothing here re-derives the
636/// radiometry.
637#[derive(Clone, Copy, Debug)]
638pub struct DenialLink {
639    /// Jammer transmit power (dBW).
640    pub jammer_power_dbw: f64,
641    /// Jammer antenna gain toward the victim (dBi).
642    pub jammer_gain_dbi: f64,
643    /// Receive-antenna gain toward the jammer (dB).
644    pub rx_gain_toward_jammer_db: f64,
645    /// Receive-antenna gain toward the satellite (dB).
646    pub rx_gain_toward_sat_db: f64,
647    /// Isotropic received signal power (dBW).
648    pub signal_power_dbw: f64,
649    /// Carrier frequency (Hz).
650    pub freq_hz: f64,
651    /// Spreading-code chip rate (chips/s) — the processing gain.
652    pub chip_rate_hz: f64,
653    /// Spectral-separation coefficient `Q` for the jammer's spectrum.
654    pub q: f64,
655    /// Receiver system noise temperature (K).
656    pub temp_k: f64,
657}
658
659impl DenialLink {
660    /// The jammer-to-signal ratio (dB) at standoff `range_m`, via [`j_over_s_db`].
661    pub fn js_db_at(&self, range_m: f64) -> f64 {
662        j_over_s_db(
663            self.jammer_power_dbw,
664            self.jammer_gain_dbi,
665            self.rx_gain_toward_jammer_db,
666            range_m,
667            self.freq_hz,
668            self.signal_power_dbw,
669            self.rx_gain_toward_sat_db,
670        )
671    }
672
673    /// The un-jammed C/N₀ (dB-Hz), via [`nominal_cn0_dbhz`].
674    pub fn nominal_cn0_dbhz(&self) -> f64 {
675        nominal_cn0_dbhz(
676            self.signal_power_dbw,
677            self.rx_gain_toward_sat_db,
678            self.temp_k,
679        )
680    }
681
682    /// The effective C/N₀ (dB-Hz) at standoff `range_m`, via [`effective_cn0_dbhz`].
683    pub fn effective_cn0_dbhz_at(&self, range_m: f64) -> f64 {
684        effective_cn0_dbhz(
685            self.nominal_cn0_dbhz(),
686            self.js_db_at(range_m),
687            self.q,
688            self.chip_rate_hz,
689        )
690    }
691
692    /// The standoff (m) at which the J/S reaches `target_js_db` — the **existing**
693    /// power-ratio denial criterion, inverted. Bisected over
694    /// [`RADIUS_BRACKET_MIN_M`, `RADIUS_BRACKET_MAX_M`]; the returned flag says whether the
695    /// answer sits inside that bracket or was clamped to an end of it.
696    pub fn radius_for_js_db(&self, target_js_db: f64) -> (f64, &'static str) {
697        self.bisect_radius(&|r| self.js_db_at(r) - target_js_db)
698    }
699
700    /// The standoff (m) at which the effective C/N₀ falls to `target_cn0_dbhz` — the
701    /// **loop-dynamics** denial criterion. Same bracket and clamping contract as
702    /// [`Self::radius_for_js_db`].
703    pub fn radius_for_cn0_dbhz(&self, target_cn0_dbhz: f64) -> (f64, &'static str) {
704        self.bisect_radius(&|r| target_cn0_dbhz - self.effective_cn0_dbhz_at(r))
705    }
706
707    /// Bisect a function that is **decreasing** in range for its zero.
708    fn bisect_radius(&self, f: &dyn Fn(f64) -> f64) -> (f64, &'static str) {
709        let (mut lo, mut hi) = (RADIUS_BRACKET_MIN_M, RADIUS_BRACKET_MAX_M);
710        if f(lo) <= 0.0 {
711            return (lo, "below-bracket");
712        }
713        if f(hi) >= 0.0 {
714            return (hi, "above-bracket");
715        }
716        for _ in 0..200 {
717            let mid = 0.5 * (lo + hi);
718            if f(mid) > 0.0 {
719                lo = mid;
720            } else {
721                hi = mid;
722            }
723        }
724        (0.5 * (lo + hi), "bracketed")
725    }
726}
727
728// --- the scenario -------------------------------------------------------------------------
729
730fn d_pll_bandwidth_hz() -> f64 {
731    10.0
732}
733fn d_dll_bandwidth_hz() -> f64 {
734    1.0
735}
736fn d_integration_s() -> f64 {
737    0.001
738}
739fn d_spacing_chips() -> f64 {
740    0.5
741}
742fn d_carrier_allowance_deg() -> f64 {
743    COSTAS_THRESHOLD_DEG
744}
745fn d_pullin_ratio() -> f64 {
746    2.0
747}
748fn d_drop_confirm_epochs() -> usize {
749    50
750}
751fn d_relock_confirm_epochs() -> usize {
752    200
753}
754fn d_cn0_high_dbhz() -> f64 {
755    45.0
756}
757fn d_cn0_low_dbhz() -> f64 {
758    15.0
759}
760fn d_ramp_duration_s() -> f64 {
761    60.0
762}
763fn d_jammer_power_dbw() -> f64 {
764    10.0
765}
766fn d_signal_power_dbw() -> f64 {
767    crate::jamming::DEFAULT_SIGNAL_POWER_DBW
768}
769fn d_temp_k() -> f64 {
770    crate::jamming::DEFAULT_TEMP_K
771}
772fn d_freq_hz() -> f64 {
773    L1_HZ
774}
775fn d_chip_rate_hz() -> f64 {
776    CA_CHIP_RATE_HZ
777}
778fn d_jammer_type() -> String {
779    "broadband".to_string()
780}
781fn d_denial_js_threshold_db() -> f64 {
782    30.0
783}
784fn d_capture_js_threshold_db() -> f64 {
785    3.0
786}
787fn d_code_slews() -> Vec<f64> {
788    vec![0.1, 0.5, 1.0, 2.0, 5.0]
789}
790fn d_doppler_rates() -> Vec<f64> {
791    vec![1.0, 10.0, 40.0, 100.0, 500.0]
792}
793fn d_jitter_table_cn0() -> Vec<f64> {
794    vec![45.0, 40.0, 35.0, 30.0, 27.5, 25.0, 22.5, 20.0]
795}
796
797/// **A tracking-loop loss-of-lock scenario.** Every field is defaulted, so a body of only
798/// `kind = "tracking-loop"` reproduces the reference configuration.
799#[derive(Clone, Debug, Deserialize, Serialize)]
800pub struct TrackingLoopScenario {
801    /// Carrier (Costas) loop one-sided noise bandwidth (Hz).
802    #[serde(default = "d_pll_bandwidth_hz")]
803    pub pll_bandwidth_hz: f64,
804    /// Code (DLL) loop one-sided noise bandwidth (Hz).
805    #[serde(default = "d_dll_bandwidth_hz")]
806    pub dll_bandwidth_hz: f64,
807    /// Predetection coherent integration time (s).
808    #[serde(default = "d_integration_s")]
809    pub predetection_integration_s: f64,
810    /// Early-to-late correlator spacing (chips).
811    #[serde(default = "d_spacing_chips")]
812    pub correlator_spacing_chips: f64,
813    /// Carrier `3σ + θ_e` allowance (degrees); the 45° three-sigma form of the 15° rule.
814    #[serde(default = "d_carrier_allowance_deg")]
815    pub carrier_allowance_deg: f64,
816    /// Code `3σ + lag` allowance (chips). Absent ⇒ half the correlator spacing, the
817    /// discriminator's linear half-range.
818    #[serde(default)]
819    pub code_allowance_chips: Option<f64>,
820    /// Bandwidth multiplier applied when re-pulling-in after a loss of lock. This is what
821    /// makes the re-lock threshold higher than the drop threshold.
822    #[serde(default = "d_pullin_ratio")]
823    pub pullin_bandwidth_ratio: f64,
824    /// Consecutive sub-threshold epochs required before lock is declared lost.
825    #[serde(default = "d_drop_confirm_epochs")]
826    pub drop_confirm_epochs: usize,
827    /// Consecutive above-threshold epochs required before lock is re-declared.
828    #[serde(default = "d_relock_confirm_epochs")]
829    pub relock_confirm_epochs: usize,
830    /// Carrier Doppler rate the loop must follow while tracking (Hz/s).
831    #[serde(default)]
832    pub doppler_rate_hz_per_s: f64,
833    /// Code slew the loop must follow while tracking (chips/s).
834    #[serde(default)]
835    pub code_slew_chips_per_s: f64,
836    /// C/N₀ the profile starts and ends at (dB-Hz).
837    #[serde(default = "d_cn0_high_dbhz")]
838    pub cn0_high_dbhz: f64,
839    /// C/N₀ at the bottom of the profile (dB-Hz).
840    #[serde(default = "d_cn0_low_dbhz")]
841    pub cn0_low_dbhz: f64,
842    /// Duration of each leg of the down-then-up C/N₀ ramp (s).
843    #[serde(default = "d_ramp_duration_s")]
844    pub ramp_duration_s: f64,
845    /// C/N₀ values the jitter table is tabulated at (dB-Hz).
846    #[serde(default = "d_jitter_table_cn0")]
847    pub jitter_table_cn0_dbhz: Vec<f64>,
848    /// Jammer transmit power (dBW).
849    #[serde(default = "d_jammer_power_dbw")]
850    pub jammer_power_dbw: f64,
851    /// Jammer antenna gain toward the victim (dBi).
852    #[serde(default)]
853    pub jammer_gain_dbi: f64,
854    /// Receive-antenna gain toward the jammer (dB).
855    #[serde(default)]
856    pub rx_gain_toward_jammer_db: f64,
857    /// Receive-antenna gain toward the satellite (dB).
858    #[serde(default)]
859    pub rx_gain_toward_sat_db: f64,
860    /// Isotropic received signal power (dBW).
861    #[serde(default = "d_signal_power_dbw")]
862    pub signal_power_dbw: f64,
863    /// Receiver system noise temperature (K).
864    #[serde(default = "d_temp_k")]
865    pub temp_k: f64,
866    /// Carrier frequency (Hz).
867    #[serde(default = "d_freq_hz")]
868    pub freq_hz: f64,
869    /// Spreading-code chip rate (chips/s).
870    #[serde(default = "d_chip_rate_hz")]
871    pub chip_rate_hz: f64,
872    /// Jammer spectrum class fed to [`q_factor`]: `broadband`, `narrowband`/`cw`, `swept`.
873    #[serde(default = "d_jammer_type")]
874    pub jammer_type: String,
875    /// Override the spectral-separation coefficient `Q`.
876    #[serde(default)]
877    pub q_override: Option<f64>,
878    /// The **existing** power-ratio denial criterion (dB of J/S). Reported unchanged.
879    #[serde(default = "d_denial_js_threshold_db")]
880    pub denial_js_threshold_db: f64,
881    /// The **existing** power-ratio spoof-capture criterion (dB of J/S).
882    #[serde(default = "d_capture_js_threshold_db")]
883    pub capture_js_threshold_db: f64,
884    /// Code slew axis of the spoof pull-in map (chips/s).
885    #[serde(default = "d_code_slews")]
886    pub spoof_code_slew_chips_per_s: Vec<f64>,
887    /// Carrier Doppler rate axis of the spoof pull-in map (Hz/s).
888    #[serde(default = "d_doppler_rates")]
889    pub spoof_doppler_rate_hz_per_s: Vec<f64>,
890}
891
892impl Default for TrackingLoopScenario {
893    fn default() -> Self {
894        Self {
895            pll_bandwidth_hz: d_pll_bandwidth_hz(),
896            dll_bandwidth_hz: d_dll_bandwidth_hz(),
897            predetection_integration_s: d_integration_s(),
898            correlator_spacing_chips: d_spacing_chips(),
899            carrier_allowance_deg: d_carrier_allowance_deg(),
900            code_allowance_chips: None,
901            pullin_bandwidth_ratio: d_pullin_ratio(),
902            drop_confirm_epochs: d_drop_confirm_epochs(),
903            relock_confirm_epochs: d_relock_confirm_epochs(),
904            doppler_rate_hz_per_s: 0.0,
905            code_slew_chips_per_s: 0.0,
906            cn0_high_dbhz: d_cn0_high_dbhz(),
907            cn0_low_dbhz: d_cn0_low_dbhz(),
908            ramp_duration_s: d_ramp_duration_s(),
909            jitter_table_cn0_dbhz: d_jitter_table_cn0(),
910            jammer_power_dbw: d_jammer_power_dbw(),
911            jammer_gain_dbi: 0.0,
912            rx_gain_toward_jammer_db: 0.0,
913            rx_gain_toward_sat_db: 0.0,
914            signal_power_dbw: d_signal_power_dbw(),
915            temp_k: d_temp_k(),
916            freq_hz: d_freq_hz(),
917            chip_rate_hz: d_chip_rate_hz(),
918            jammer_type: d_jammer_type(),
919            q_override: None,
920            denial_js_threshold_db: d_denial_js_threshold_db(),
921            capture_js_threshold_db: d_capture_js_threshold_db(),
922            spoof_code_slew_chips_per_s: d_code_slews(),
923            spoof_doppler_rate_hz_per_s: d_doppler_rates(),
924        }
925    }
926}
927
928/// The C/N₀ profile the lock detector is driven over: a linear ramp from
929/// `cn0_high_dbhz` down to `cn0_low_dbhz` over `ramp_duration_s`, then back up over the
930/// same span. At time `t` (s).
931fn profile_cn0_dbhz(t: f64, high: f64, low: f64, leg: f64) -> f64 {
932    if leg <= 0.0 {
933        return high;
934    }
935    if t <= leg {
936        high + (low - high) * (t / leg)
937    } else if t <= 2.0 * leg {
938        low + (high - low) * ((t - leg) / leg)
939    } else {
940        high
941    }
942}
943
944/// The outcome of running the hysteretic detector over the C/N₀ profile.
945#[derive(Clone, Copy, Debug)]
946pub struct LockHistory {
947    /// Time (s) at which the profile first crosses below the drop threshold.
948    pub drop_crossing_s: f64,
949    /// Time (s) at which the detector declares loss of lock, or `NaN` if it never does.
950    pub declared_loss_s: f64,
951    /// Time (s) at which the profile first climbs back above the re-lock threshold.
952    pub relock_crossing_s: f64,
953    /// Time (s) at which the detector re-declares lock, or `NaN` if it never does.
954    pub declared_relock_s: f64,
955    /// Seconds the channel spent declared unlocked inside the profile.
956    pub unlocked_duration_s: f64,
957    /// The number of detector steps actually run.
958    pub steps: usize,
959}
960
961/// Drive `detector` over the down-then-up C/N₀ ramp at the loop's own epoch rate and
962/// report when it declared loss of lock and when it recovered. Deterministic.
963pub fn run_lock_history(
964    detector: &mut LockDetector,
965    high_dbhz: f64,
966    low_dbhz: f64,
967    leg_s: f64,
968    epoch_s: f64,
969) -> Result<LockHistory, String> {
970    let total = 2.0 * leg_s;
971    if !(epoch_s.is_finite() && epoch_s > 0.0 && total.is_finite()) || total < 0.0 {
972        return Err("tracking-loop: the C/N0 profile needs a positive epoch and duration".into());
973    }
974    let n = (total / epoch_s).ceil();
975    if !n.is_finite() || n > MAX_DETECTOR_STEPS as f64 {
976        return Err(format!(
977            "tracking-loop: the C/N0 profile would need more than {MAX_DETECTOR_STEPS} detector \
978             steps ({total} s at {epoch_s} s per epoch); shorten ramp_duration_s or lengthen \
979             predetection_integration_s"
980        ));
981    }
982    let steps = n as usize;
983    let mut h = LockHistory {
984        drop_crossing_s: f64::NAN,
985        declared_loss_s: f64::NAN,
986        relock_crossing_s: f64::NAN,
987        declared_relock_s: f64::NAN,
988        unlocked_duration_s: 0.0,
989        steps,
990    };
991    let mut prev = LockState::Locked;
992    for i in 0..steps {
993        let t = i as f64 * epoch_s;
994        let cn0 = profile_cn0_dbhz(t, high_dbhz, low_dbhz, leg_s);
995        if h.drop_crossing_s.is_nan() && cn0 < detector.drop_cn0_dbhz {
996            h.drop_crossing_s = t;
997        }
998        if !h.drop_crossing_s.is_nan()
999            && h.relock_crossing_s.is_nan()
1000            && cn0 >= detector.relock_cn0_dbhz
1001        {
1002            h.relock_crossing_s = t;
1003        }
1004        let s = detector.step(cn0);
1005        if s == LockState::Unlocked {
1006            h.unlocked_duration_s += epoch_s;
1007        }
1008        if prev == LockState::Locked && s == LockState::Unlocked && h.declared_loss_s.is_nan() {
1009            h.declared_loss_s = t;
1010        }
1011        if prev == LockState::Unlocked && s == LockState::Locked && h.declared_relock_s.is_nan() {
1012            h.declared_relock_s = t;
1013        }
1014        prev = s;
1015    }
1016    Ok(h)
1017}
1018
1019fn jnum(v: f64) -> serde_json::Value {
1020    if v.is_finite() {
1021        serde_json::json!(v)
1022    } else {
1023        serde_json::Value::Null
1024    }
1025}
1026
1027fn jopt(v: Option<f64>) -> serde_json::Value {
1028    match v {
1029        Some(x) if x.is_finite() => serde_json::json!(x),
1030        _ => serde_json::Value::Null,
1031    }
1032}
1033
1034impl TrackingLoopScenario {
1035    /// The resolved loop geometry, with the code allowance defaulted to half the spacing.
1036    pub fn loop_config(&self) -> LoopConfig {
1037        LoopConfig {
1038            pll_bandwidth_hz: self.pll_bandwidth_hz,
1039            dll_bandwidth_hz: self.dll_bandwidth_hz,
1040            integration_s: self.predetection_integration_s,
1041            spacing_chips: self.correlator_spacing_chips,
1042            carrier_allowance_deg: self.carrier_allowance_deg,
1043            code_allowance_chips: self
1044                .code_allowance_chips
1045                .unwrap_or(self.correlator_spacing_chips / 2.0),
1046            pullin_bandwidth_ratio: self.pullin_bandwidth_ratio,
1047        }
1048    }
1049
1050    /// The jammer link the two denial radii are evaluated on.
1051    pub fn denial_link(&self) -> DenialLink {
1052        DenialLink {
1053            jammer_power_dbw: self.jammer_power_dbw,
1054            jammer_gain_dbi: self.jammer_gain_dbi,
1055            rx_gain_toward_jammer_db: self.rx_gain_toward_jammer_db,
1056            rx_gain_toward_sat_db: self.rx_gain_toward_sat_db,
1057            signal_power_dbw: self.signal_power_dbw,
1058            freq_hz: self.freq_hz,
1059            chip_rate_hz: self.chip_rate_hz,
1060            q: q_factor(&self.jammer_type, self.q_override),
1061            temp_k: self.temp_k,
1062        }
1063    }
1064
1065    fn validate(&self) -> Result<(), String> {
1066        let finite = [
1067            ("pll_bandwidth_hz", self.pll_bandwidth_hz),
1068            ("dll_bandwidth_hz", self.dll_bandwidth_hz),
1069            (
1070                "predetection_integration_s",
1071                self.predetection_integration_s,
1072            ),
1073            ("correlator_spacing_chips", self.correlator_spacing_chips),
1074            ("carrier_allowance_deg", self.carrier_allowance_deg),
1075            ("pullin_bandwidth_ratio", self.pullin_bandwidth_ratio),
1076            ("cn0_high_dbhz", self.cn0_high_dbhz),
1077            ("cn0_low_dbhz", self.cn0_low_dbhz),
1078            ("ramp_duration_s", self.ramp_duration_s),
1079            ("jammer_power_dbw", self.jammer_power_dbw),
1080            ("signal_power_dbw", self.signal_power_dbw),
1081            ("temp_k", self.temp_k),
1082            ("freq_hz", self.freq_hz),
1083            ("chip_rate_hz", self.chip_rate_hz),
1084            ("doppler_rate_hz_per_s", self.doppler_rate_hz_per_s),
1085            ("code_slew_chips_per_s", self.code_slew_chips_per_s),
1086        ];
1087        for (name, v) in finite {
1088            if !v.is_finite() {
1089                return Err(format!("tracking-loop: {name} must be finite"));
1090            }
1091        }
1092        let positive = [
1093            ("pll_bandwidth_hz", self.pll_bandwidth_hz),
1094            ("dll_bandwidth_hz", self.dll_bandwidth_hz),
1095            (
1096                "predetection_integration_s",
1097                self.predetection_integration_s,
1098            ),
1099            ("correlator_spacing_chips", self.correlator_spacing_chips),
1100            ("carrier_allowance_deg", self.carrier_allowance_deg),
1101            ("temp_k", self.temp_k),
1102            ("freq_hz", self.freq_hz),
1103            ("chip_rate_hz", self.chip_rate_hz),
1104        ];
1105        for (name, v) in positive {
1106            if v <= 0.0 {
1107                return Err(format!("tracking-loop: {name} must be positive (got {v})"));
1108            }
1109        }
1110        if self.correlator_spacing_chips >= 1.9 {
1111            return Err(format!(
1112                "tracking-loop: correlator_spacing_chips must be below 1.9 chips (got {})",
1113                self.correlator_spacing_chips
1114            ));
1115        }
1116        if self.pullin_bandwidth_ratio < 1.0 {
1117            return Err(format!(
1118                "tracking-loop: pullin_bandwidth_ratio must be at least 1 (got {}) — a loop that \
1119                 re-pulls-in with a NARROWER bandwidth than it tracks with would invert the \
1120                 hysteresis",
1121                self.pullin_bandwidth_ratio
1122            ));
1123        }
1124        if self.ramp_duration_s < 0.0 {
1125            return Err("tracking-loop: ramp_duration_s must not be negative".into());
1126        }
1127        if self.cn0_low_dbhz > self.cn0_high_dbhz {
1128            return Err(format!(
1129                "tracking-loop: cn0_low_dbhz ({}) must not exceed cn0_high_dbhz ({})",
1130                self.cn0_low_dbhz, self.cn0_high_dbhz
1131            ));
1132        }
1133        for (name, axis) in [
1134            ("jitter_table_cn0_dbhz", &self.jitter_table_cn0_dbhz),
1135            (
1136                "spoof_code_slew_chips_per_s",
1137                &self.spoof_code_slew_chips_per_s,
1138            ),
1139            (
1140                "spoof_doppler_rate_hz_per_s",
1141                &self.spoof_doppler_rate_hz_per_s,
1142            ),
1143        ] {
1144            if axis.is_empty() {
1145                return Err(format!("tracking-loop: {name} must not be empty"));
1146            }
1147            if axis.len() > MAX_AXIS_LEN {
1148                return Err(format!(
1149                    "tracking-loop: {name} has {} entries; the cap is {MAX_AXIS_LEN}",
1150                    axis.len()
1151                ));
1152            }
1153            if axis.iter().any(|v| !v.is_finite()) {
1154                return Err(format!("tracking-loop: {name} holds a non-finite value"));
1155            }
1156        }
1157        if self.drop_confirm_epochs == 0 || self.relock_confirm_epochs == 0 {
1158            return Err("tracking-loop: the confirmation dwells must be at least 1 epoch".into());
1159        }
1160        Ok(())
1161    }
1162
1163    /// Run the scenario, returning the pretty-printed result document and a one-line
1164    /// summary. Deterministic: no random state and no wall-clock read.
1165    pub fn run_json(&self) -> Result<(String, String), String> {
1166        self.validate()?;
1167        let cfg = self.loop_config();
1168        let th = cfg.thresholds(self.doppler_rate_hz_per_s, self.code_slew_chips_per_s);
1169        let link = self.denial_link();
1170        let t = cfg.integration_s;
1171
1172        // --- jitter table -------------------------------------------------------------
1173        let jitter_rows: Vec<serde_json::Value> = self
1174            .jitter_table_cn0_dbhz
1175            .iter()
1176            .map(|&c| {
1177                let sp = pll_thermal_jitter_rad(c, cfg.pll_bandwidth_hz, t).to_degrees();
1178                let sd = dll_thermal_jitter_chips(c, cfg.dll_bandwidth_hz, t, cfg.spacing_chips);
1179                serde_json::json!({
1180                    "cn0_dbhz": c,
1181                    "pll_jitter_deg": sp,
1182                    "pll_three_sigma_deg": 3.0 * sp,
1183                    "dll_jitter_chips": sd,
1184                    "dll_three_sigma_chips": 3.0 * sd,
1185                    "carrier_within_allowance": 3.0 * sp <= cfg.carrier_allowance_deg,
1186                    "code_within_allowance": 3.0 * sd <= cfg.code_allowance_chips,
1187                    "log10_mean_time_to_cycle_slip_s":
1188                        log10_mean_time_to_cycle_slip_s(c, cfg.pll_bandwidth_hz, t),
1189                })
1190            })
1191            .collect();
1192
1193        // --- hysteresis bounds ---------------------------------------------------------
1194        let r = cfg.pullin_bandwidth_ratio.max(1.0);
1195        let hyst_lo = 5.0 * r.plog10();
1196        let hyst_hi = 10.0 * r.plog10();
1197
1198        // --- lock history ---------------------------------------------------------------
1199        let (history, history_status) = match (th.drop_cn0_dbhz, th.relock_cn0_dbhz) {
1200            (Some(d), Some(rl)) => {
1201                let mut det =
1202                    LockDetector::new(d, rl, self.drop_confirm_epochs, self.relock_confirm_epochs);
1203                (
1204                    Some(run_lock_history(
1205                        &mut det,
1206                        self.cn0_high_dbhz,
1207                        self.cn0_low_dbhz,
1208                        self.ramp_duration_s,
1209                        t,
1210                    )?),
1211                    "run",
1212                )
1213            }
1214            _ => (
1215                None,
1216                "not-run: the loop has no C/N0 threshold under the stated dynamics",
1217            ),
1218        };
1219
1220        // --- denial radii ----------------------------------------------------------------
1221        let (r_thresh_m, r_thresh_status) = link.radius_for_js_db(self.denial_js_threshold_db);
1222        let (r_capture_m, r_capture_status) = link.radius_for_js_db(self.capture_js_threshold_db);
1223        let (r_loop_m, r_loop_status, loop_js_db) = match th.drop_cn0_dbhz {
1224            Some(d) => {
1225                let (rm, st) = link.radius_for_cn0_dbhz(d);
1226                (Some(rm), st, Some(link.js_db_at(rm)))
1227            }
1228            None => (
1229                None,
1230                "not-computed: the loop has no C/N0 threshold under the stated dynamics",
1231                None,
1232            ),
1233        };
1234        let delta_km = r_loop_m.map(|rl| (rl - r_thresh_m) / 1000.0);
1235        let ratio = r_loop_m.map(|rl| rl / r_thresh_m);
1236
1237        // --- spoof pull-in ------------------------------------------------------------------
1238        let mut cells = Vec::new();
1239        for &cs in &self.spoof_code_slew_chips_per_s {
1240            for &dr in &self.spoof_doppler_rate_hz_per_s {
1241                let c = pull_in_cell(&cfg, self.cn0_high_dbhz, cs, dr);
1242                cells.push(serde_json::json!({
1243                    "code_slew_chips_per_s": c.code_slew_chips_per_s,
1244                    "code_slew_m_per_s": c.code_slew_chips_per_s * C_M_PER_S / self.chip_rate_hz,
1245                    "doppler_rate_hz_per_s": c.doppler_rate_hz_per_s,
1246                    "range_acceleration_m_per_s2": c.doppler_rate_hz_per_s * C_M_PER_S / self.freq_hz,
1247                    "code_lag_chips": c.code_lag_chips,
1248                    "carrier_stress_deg": c.carrier_stress_deg,
1249                    "carrier_budget_deg": c.carrier_budget_deg,
1250                    "code_follows": c.code_follows,
1251                    "carrier_follows": c.carrier_follows,
1252                    "pulled_in": c.pulled_in,
1253                }));
1254            }
1255        }
1256        let max_slew_noiseless =
1257            max_code_slew_chips_per_s(cfg.dll_bandwidth_hz, cfg.code_allowance_chips);
1258        let max_slew = max_code_slew_at_cn0_chips_per_s(
1259            cfg.dll_bandwidth_hz,
1260            self.cn0_high_dbhz,
1261            t,
1262            cfg.spacing_chips,
1263            cfg.code_allowance_chips,
1264        );
1265        let max_rate = max_doppler_rate_hz_per_s(
1266            cfg.pll_bandwidth_hz,
1267            self.cn0_high_dbhz,
1268            t,
1269            cfg.carrier_allowance_deg,
1270        );
1271
1272        let units = self.units_block();
1273
1274        let mut json = serde_json::json!({
1275            "kind": "tracking-loop",
1276            "label": "MIXED — VALIDATED loop closed forms, MODELLED loop configuration. The \
1277                      carrier and code thermal-jitter expressions, the loop-filter reduction \
1278                      sqrt(2*B_n*T) and the first-order ramp lag are cross-checked against the \
1279                      engine's own sdr correlator stepped forward on seeded synthetic IF (a \
1280                      different route to the same numbers), and the modified Bessel I0 against \
1281                      standard tabulated values. MODELLED: the loop bandwidths, integration \
1282                      time, correlator spacing, pull-in bandwidth ratio, confirmation dwells, \
1283                      jammer power and antenna gains are REPRESENTATIVE BAND FIGURES, not a \
1284                      datasheet for any receiver. NOT modelled: oscillator (Allan-deviation) \
1285                      and vibration jitter, front-end bandwidth limiting, multipath, AGC \
1286                      dynamics, data-bit-transition loss and external aiding; the Costas \
1287                      slip time is Viterbi's first-order expression on rho/4 (half-cycle \
1288                      slips), an optimistic bound for a second-order loop. The loop-dynamics denial \
1289                      radius is reported ALONGSIDE the existing power-ratio radius and never \
1290                      in place of it. Not a certified receiver-performance product.",
1291            "loop": {
1292                "pll_bandwidth_hz": cfg.pll_bandwidth_hz,
1293                "dll_bandwidth_hz": cfg.dll_bandwidth_hz,
1294                "predetection_integration_s": cfg.integration_s,
1295                "correlator_spacing_chips": cfg.spacing_chips,
1296                "carrier_allowance_deg": cfg.carrier_allowance_deg,
1297                "code_allowance_chips": cfg.code_allowance_chips,
1298                "pullin_bandwidth_ratio": cfg.pullin_bandwidth_ratio,
1299                "doppler_rate_hz_per_s": self.doppler_rate_hz_per_s,
1300                "code_slew_chips_per_s": self.code_slew_chips_per_s,
1301                "discriminator_slope_chips_per_chip":
1302                    early_late_amplitude_discriminator_slope(cfg.spacing_chips),
1303                "threshold_rule": "carrier: 3*sigma_PLL + theta_e <= carrier_allowance_deg (the \
1304                                   15-degree rule in its three-sigma, 45-degree form); code: \
1305                                   3*sigma_DLL + ramp lag <= code_allowance_chips, the \
1306                                   early-late discriminator's linear half-range d/2.",
1307            },
1308            "thresholds": {
1309                "carrier_drop_cn0_dbhz": jopt(th.carrier_drop_cn0_dbhz),
1310                "code_drop_cn0_dbhz": jopt(th.code_drop_cn0_dbhz),
1311                "drop_cn0_dbhz": jopt(th.drop_cn0_dbhz),
1312                "binding_loop": th.binding_loop,
1313                "relock_cn0_dbhz": jopt(th.relock_cn0_dbhz),
1314                "hysteresis_db": jopt(th.hysteresis_db),
1315                "hysteresis_lower_bound_db": hyst_lo,
1316                "hysteresis_upper_bound_db": hyst_hi,
1317                "hysteresis_definition": "the re-lock threshold is the same tracking-threshold \
1318                                          rule evaluated with the loop bandwidths a receiver \
1319                                          re-pulls-in at (pullin_bandwidth_ratio times the \
1320                                          tracking bandwidths). Jitter grows as sqrt(B_n) in the \
1321                                          thermal-dominated limit and as B_n^(1/4) in the \
1322                                          squaring-loss-dominated limit, so the width must lie \
1323                                          between 5*log10(ratio) and 10*log10(ratio) dB. It is \
1324                                          DERIVED from the bandwidth ratio, not asserted; the \
1325                                          ratio itself is a MODELLED design choice.",
1326            },
1327            "jitter_table": jitter_rows,
1328            "spoof_pull_in": {
1329                "max_code_slew_chips_per_s": max_slew,
1330                "max_code_slew_m_per_s": max_slew * C_M_PER_S / self.chip_rate_hz,
1331                "max_code_slew_noiseless_chips_per_s": max_slew_noiseless,
1332                "max_doppler_rate_hz_per_s": max_rate,
1333                "max_range_acceleration_m_per_s2": max_rate * C_M_PER_S / self.freq_hz,
1334                "evaluated_at_cn0_dbhz": self.cn0_high_dbhz,
1335                "cells": cells,
1336                "definition": "a spoofer captures by slewing the victim's code phase and carrier \
1337                               away from truth. The victim follows only while its first-order DLL \
1338                               ramp lag (code_slew / (4*B_dll)) plus 3*sigma_DLL stays inside the \
1339                               code allowance AND its second-order PLL dynamic stress \
1340                               (360*fdot/omega_n^2, omega_n = B_pll/0.53) plus 3*sigma_PLL stays \
1341                               inside the carrier allowance. A spoofer that slews FASTER than \
1342                               these limits does not capture the loop — it outruns it. This is \
1343                               why loop dynamics does not reduce the capture criterion to a \
1344                               power ratio at all.",
1345            },
1346        });
1347
1348        json["denial"] = serde_json::json!({
1349            "criterion_note": "BOTH criteria are reported. The threshold criterion is the \
1350                               engine's existing power ratio (J/S at denial_js_threshold_db, \
1351                               default 30 dB) and is unchanged. The loop-dynamics criterion is \
1352                               the standoff at which the effective C/N0 falls to the drop \
1353                               threshold this loop configuration implies. Neither replaces the \
1354                               other; denial_radius_delta_km is their signed difference.",
1355            "nominal_cn0_dbhz": link.nominal_cn0_dbhz(),
1356            "q_factor": link.q,
1357            "denial_js_threshold_db": self.denial_js_threshold_db,
1358            "denial_radius_threshold_km": r_thresh_m / 1000.0,
1359            "denial_radius_threshold_status": r_thresh_status,
1360            "denial_cn0_at_threshold_radius_dbhz": link.effective_cn0_dbhz_at(r_thresh_m),
1361            "denial_js_loop_db": jopt(loop_js_db),
1362            "denial_radius_loop_dynamics_km": jopt(r_loop_m.map(|x| x / 1000.0)),
1363            "denial_radius_loop_dynamics_status": r_loop_status,
1364            "denial_radius_delta_km": jopt(delta_km),
1365            "denial_radius_ratio": jopt(ratio),
1366            "denial_js_delta_db": jopt(loop_js_db.map(|j| j - self.denial_js_threshold_db)),
1367            "denial_radius_delta_definition": "denial_radius_delta_km = \
1368                                               denial_radius_loop_dynamics_km - \
1369                                               denial_radius_threshold_km. Negative means the \
1370                                               loop holds lock closer in than the power-ratio \
1371                                               rule allows, so the power-ratio rule OVERSTATES \
1372                                               the denied area; positive would mean the opposite.",
1373            "capture_js_threshold_db": self.capture_js_threshold_db,
1374            "capture_radius_threshold_km": r_capture_m / 1000.0,
1375            "capture_radius_threshold_status": r_capture_status,
1376            "capture_radius_loop_dynamics_km": serde_json::Value::Null,
1377            "capture_radius_loop_dynamics_status": "not-applicable: under loop dynamics the \
1378                                                    binding spoof-capture constraint is the code \
1379                                                    and carrier offset RATE the victim's loops \
1380                                                    can follow, not a power ratio, so there is no \
1381                                                    radius to report. See spoof_pull_in.",
1382        });
1383
1384        json["lock_history"] = match history {
1385            Some(h) => serde_json::json!({
1386                "status": history_status,
1387                "cn0_high_dbhz": self.cn0_high_dbhz,
1388                "cn0_low_dbhz": self.cn0_low_dbhz,
1389                "ramp_duration_s": self.ramp_duration_s,
1390                "ramp_rate_db_per_s": if self.ramp_duration_s > 0.0 {
1391                    (self.cn0_low_dbhz - self.cn0_high_dbhz) / self.ramp_duration_s
1392                } else { 0.0 },
1393                "detector_steps": h.steps,
1394                "drop_confirm_epochs": self.drop_confirm_epochs,
1395                "relock_confirm_epochs": self.relock_confirm_epochs,
1396                "drop_crossing_s": jnum(h.drop_crossing_s),
1397                "declared_loss_of_lock_s": jnum(h.declared_loss_s),
1398                "time_to_lose_lock_after_crossing_s":
1399                    jnum(h.declared_loss_s - h.drop_crossing_s),
1400                "relock_crossing_s": jnum(h.relock_crossing_s),
1401                "declared_relock_s": jnum(h.declared_relock_s),
1402                "unlocked_duration_s": h.unlocked_duration_s,
1403                "definition": "declared_loss_of_lock_s is when the two-threshold detector \
1404                               transitions, which is the crossing of the drop threshold plus the \
1405                               confirmation dwell. It is a DECLARATION time. The physical phase \
1406                               escape time is the separate cycle-slip column of the jitter \
1407                               table.",
1408            }),
1409            None => serde_json::json!({ "status": history_status }),
1410        };
1411        json["units"] = units;
1412
1413        let summary = format!(
1414            "tracking-loop: B_pll {:.1} Hz / B_dll {:.1} Hz, T {:.0} ms, d {:.2} chip -> drop {} \
1415             dB-Hz ({} binds), relock {} dB-Hz, hysteresis {} dB | loss of lock declared {} s \
1416             into the ramp | denial radius threshold(J/S {:.0} dB) {:.3} km vs loop dynamics {} \
1417             km, delta {} km | spoof pull-in <= {:.3} chip/s code and {:.2} Hz/s carrier \
1418             (MODELLED loop, VALIDATED closed forms)",
1419            cfg.pll_bandwidth_hz,
1420            cfg.dll_bandwidth_hz,
1421            cfg.integration_s * 1000.0,
1422            cfg.spacing_chips,
1423            opt4(th.drop_cn0_dbhz),
1424            th.binding_loop,
1425            opt4(th.relock_cn0_dbhz),
1426            opt4(th.hysteresis_db),
1427            history.map_or("n/a".to_string(), |h| format!("{:.3}", h.declared_loss_s)),
1428            self.denial_js_threshold_db,
1429            r_thresh_m / 1000.0,
1430            opt4(r_loop_m.map(|x| x / 1000.0)),
1431            opt4(delta_km),
1432            max_slew,
1433            max_rate,
1434        );
1435        let json = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
1436        Ok((json, summary))
1437    }
1438
1439    /// Unit and provenance class for every numeric field the report publishes. `input` is
1440    /// a value the caller supplied (or its documented default), `modelled-input` a
1441    /// representative band figure standing in for a real receiver or threat parameter, and
1442    /// `computed` a value this run derived.
1443    fn units_block(&self) -> serde_json::Value {
1444        let mut u = serde_json::json!({
1445            "loop.pll_bandwidth_hz": {"unit": "Hz", "provenance": "modelled-input", "note": "one-sided carrier loop noise bandwidth; representative band figure"},
1446            "loop.dll_bandwidth_hz": {"unit": "Hz", "provenance": "modelled-input", "note": "one-sided code loop noise bandwidth; representative band figure"},
1447            "loop.predetection_integration_s": {"unit": "s", "provenance": "modelled-input", "note": "coherent integration per epoch; the default 1 ms is one C/A code period"},
1448            "loop.correlator_spacing_chips": {"unit": "chip", "provenance": "modelled-input"},
1449            "loop.carrier_allowance_deg": {"unit": "deg", "provenance": "input", "note": "the 45 deg three-sigma form of the 15-degree Costas rule"},
1450            "loop.code_allowance_chips": {"unit": "chip", "provenance": "computed", "note": "defaults to half the correlator spacing, the discriminator's linear half-range"},
1451            "loop.pullin_bandwidth_ratio": {"unit": "ratio (dimensionless)", "provenance": "modelled-input", "note": "bandwidth multiplier while re-pulling-in; a design choice, not a physical constant"},
1452            "loop.doppler_rate_hz_per_s": {"unit": "Hz/s", "provenance": "input"},
1453            "loop.code_slew_chips_per_s": {"unit": "chip/s", "provenance": "input"},
1454            "loop.discriminator_slope_chips_per_chip": {"unit": "chip/chip (dimensionless)", "provenance": "computed", "note": "1/(2-d), the ideal-triangle gain of the normalised early-late amplitude discriminator"},
1455            "thresholds.carrier_drop_cn0_dbhz": {"unit": "dB-Hz", "provenance": "computed"},
1456            "thresholds.code_drop_cn0_dbhz": {"unit": "dB-Hz", "provenance": "computed"},
1457            "thresholds.drop_cn0_dbhz": {"unit": "dB-Hz", "provenance": "computed", "note": "the larger of the two; whichever loop gives up first binds"},
1458            "thresholds.relock_cn0_dbhz": {"unit": "dB-Hz", "provenance": "computed"},
1459            "thresholds.hysteresis_db": {"unit": "dB", "provenance": "computed", "note": "relock_cn0_dbhz - drop_cn0_dbhz"},
1460            "thresholds.hysteresis_lower_bound_db": {"unit": "dB", "provenance": "computed", "note": "5*log10(pullin_bandwidth_ratio), the squaring-loss-dominated limit"},
1461            "thresholds.hysteresis_upper_bound_db": {"unit": "dB", "provenance": "computed", "note": "10*log10(pullin_bandwidth_ratio), the thermal-dominated limit"},
1462            "jitter_table[].cn0_dbhz": {"unit": "dB-Hz", "provenance": "input"},
1463            "jitter_table[].pll_jitter_deg": {"unit": "deg", "provenance": "computed", "note": "1 sigma Costas carrier thermal jitter, squaring loss included"},
1464            "jitter_table[].pll_three_sigma_deg": {"unit": "deg", "provenance": "computed"},
1465            "jitter_table[].dll_jitter_chips": {"unit": "chip", "provenance": "computed", "note": "1 sigma non-coherent early-late code thermal jitter"},
1466            "jitter_table[].dll_three_sigma_chips": {"unit": "chip", "provenance": "computed"},
1467            "jitter_table[].log10_mean_time_to_cycle_slip_s": {"unit": "log10(s)", "provenance": "computed", "note": "Viterbi first-order mean time between slips; reported in log10 because it spans hundreds of decades"},
1468            "spoof_pull_in.max_code_slew_chips_per_s": {"unit": "chip/s", "provenance": "computed", "note": "4*B_dll*(code_allowance_chips - 3*sigma_DLL) at evaluated_at_cn0_dbhz; this is the ceiling the cells are decided against"},
1469            "spoof_pull_in.max_code_slew_noiseless_chips_per_s": {"unit": "chip/s", "provenance": "computed", "note": "4*B_dll*code_allowance_chips, the geometric ceiling with the code jitter set aside"},
1470            "spoof_pull_in.max_code_slew_m_per_s": {"unit": "m/s", "provenance": "computed", "note": "the same limit as a pseudorange drag rate at the stated chip rate"},
1471            "spoof_pull_in.max_doppler_rate_hz_per_s": {"unit": "Hz/s", "provenance": "computed"},
1472            "spoof_pull_in.max_range_acceleration_m_per_s2": {"unit": "m/s^2", "provenance": "computed", "note": "the same limit as a line-of-sight acceleration at the stated carrier"},
1473            "spoof_pull_in.evaluated_at_cn0_dbhz": {"unit": "dB-Hz", "provenance": "input"},
1474            "spoof_pull_in.cells[].code_slew_chips_per_s": {"unit": "chip/s", "provenance": "input"},
1475            "spoof_pull_in.cells[].code_slew_m_per_s": {"unit": "m/s", "provenance": "computed"},
1476            "spoof_pull_in.cells[].doppler_rate_hz_per_s": {"unit": "Hz/s", "provenance": "input"},
1477            "spoof_pull_in.cells[].range_acceleration_m_per_s2": {"unit": "m/s^2", "provenance": "computed"},
1478            "spoof_pull_in.cells[].code_lag_chips": {"unit": "chip", "provenance": "computed"},
1479            "spoof_pull_in.cells[].carrier_stress_deg": {"unit": "deg", "provenance": "computed"},
1480            "spoof_pull_in.cells[].carrier_budget_deg": {"unit": "deg", "provenance": "computed", "note": "3*sigma_PLL + dynamic stress"},
1481        });
1482        let denial = serde_json::json!({
1483            "denial.nominal_cn0_dbhz": {"unit": "dB-Hz", "provenance": "computed", "note": "jamming::nominal_cn0_dbhz for the stated signal power, antenna gain and noise temperature"},
1484            "denial.q_factor": {"unit": "ratio (dimensionless)", "provenance": "modelled-input", "note": "spectral-separation coefficient from jamming::q_factor"},
1485            "denial.denial_js_threshold_db": {"unit": "dB", "provenance": "input", "note": "the engine's existing power-ratio denial criterion; reported unchanged"},
1486            "denial.denial_radius_threshold_km": {"unit": "km", "provenance": "computed", "note": "standoff at which J/S reaches denial_js_threshold_db"},
1487            "denial.denial_cn0_at_threshold_radius_dbhz": {"unit": "dB-Hz", "provenance": "computed"},
1488            "denial.denial_js_loop_db": {"unit": "dB", "provenance": "computed", "note": "the J/S the loop-dynamics radius corresponds to"},
1489            "denial.denial_radius_loop_dynamics_km": {"unit": "km", "provenance": "computed", "note": "standoff at which the effective C/N0 falls to thresholds.drop_cn0_dbhz"},
1490            "denial.denial_radius_delta_km": {"unit": "km", "provenance": "computed", "note": "loop-dynamics radius minus threshold radius"},
1491            "denial.denial_radius_ratio": {"unit": "ratio (dimensionless)", "provenance": "computed"},
1492            "denial.denial_js_delta_db": {"unit": "dB", "provenance": "computed"},
1493            "denial.capture_js_threshold_db": {"unit": "dB", "provenance": "input", "note": "the engine's existing power-ratio capture criterion; reported unchanged"},
1494            "denial.capture_radius_threshold_km": {"unit": "km", "provenance": "computed"},
1495        });
1496        let hist = serde_json::json!({
1497            "lock_history.cn0_high_dbhz": {"unit": "dB-Hz", "provenance": "input"},
1498            "lock_history.cn0_low_dbhz": {"unit": "dB-Hz", "provenance": "input"},
1499            "lock_history.ramp_duration_s": {"unit": "s", "provenance": "input", "note": "per leg; the profile ramps down then back up"},
1500            "lock_history.ramp_rate_db_per_s": {"unit": "dB/s", "provenance": "computed"},
1501            "lock_history.detector_steps": {"unit": "count", "provenance": "computed"},
1502            "lock_history.drop_confirm_epochs": {"unit": "count", "provenance": "modelled-input"},
1503            "lock_history.relock_confirm_epochs": {"unit": "count", "provenance": "modelled-input"},
1504            "lock_history.drop_crossing_s": {"unit": "s", "provenance": "computed"},
1505            "lock_history.declared_loss_of_lock_s": {"unit": "s", "provenance": "computed"},
1506            "lock_history.time_to_lose_lock_after_crossing_s": {"unit": "s", "provenance": "computed", "note": "the confirmation dwell, in seconds"},
1507            "lock_history.relock_crossing_s": {"unit": "s", "provenance": "computed"},
1508            "lock_history.declared_relock_s": {"unit": "s", "provenance": "computed"},
1509            "lock_history.unlocked_duration_s": {"unit": "s", "provenance": "computed"},
1510        });
1511        for src in [denial, hist] {
1512            if let (Some(dst), Some(s)) = (u.as_object_mut(), src.as_object()) {
1513                for (k, v) in s {
1514                    dst.insert(k.clone(), v.clone());
1515                }
1516            }
1517        }
1518        u
1519    }
1520}
1521
1522fn opt4(v: Option<f64>) -> String {
1523    v.map_or_else(|| "n/a".to_string(), |x| format!("{x:.4}"))
1524}
1525
1526#[cfg(test)]
1527mod tests {
1528    use super::*;
1529    use crate::sdr::{correlate, synth_if, CaCode, CorrParams, CA_CODE_LEN};
1530
1531    // ---------------------------------------------------------------------------------
1532    // Oracle 1 — the modified Bessel function against standard tabulated values.
1533    // ---------------------------------------------------------------------------------
1534
1535    #[test]
1536    fn bessel_i0_matches_the_standard_tabulated_values() {
1537        // Abramowitz & Stegun Table 9.8 / the standard modified-Bessel implementation.
1538        // These are an EXTERNAL oracle: they are not produced by any expression in this
1539        // module, and the A&S 9.8.1/9.8.2 polynomials carry a stated |eps| < 1.9e-7.
1540        let table = [
1541            (0.0_f64, 1.0_f64),
1542            (0.5, 1.063_483_370_741_323_4),
1543            (1.0, 1.266_065_877_752_008_2),
1544            (2.0, 2.279_585_302_336_067),
1545            (3.0, 4.880_792_585_865_024),
1546            (3.75, 9.118_945_860_844_567),
1547            (5.0, 27.239_871_823_604_442),
1548            (10.0, 2_815.716_628_466_254),
1549            (20.0, 43_558_282.559_553_534),
1550        ];
1551        for (x, want) in table {
1552            let got = bessel_i0(x);
1553            let rel = ((got - want) / want).abs();
1554            assert!(rel < 2.0e-6, "I0({x}) = {got}, want {want} (rel {rel:.3e})");
1555        }
1556        // The log form agrees with the direct form where the direct form is representable,
1557        // and stays finite far beyond where it is not.
1558        for x in [1.0_f64, 4.0, 10.0, 50.0, 200.0] {
1559            let rel = ((ln_bessel_i0(x) - bessel_i0(x).pln()) / bessel_i0(x).pln()).abs();
1560            assert!(rel < 1.0e-6, "ln I0({x}) disagrees: rel {rel:.3e}");
1561        }
1562        assert!(ln_bessel_i0(5000.0).is_finite());
1563        assert!(
1564            !bessel_i0(5000.0).is_finite(),
1565            "the direct form must overflow — that is why the log form exists"
1566        );
1567    }
1568
1569    // ---------------------------------------------------------------------------------
1570    // Oracle 2 — the carrier jitter closed form against the real sdr correlator.
1571    // ---------------------------------------------------------------------------------
1572
1573    /// Per-component noise standard deviation that puts `synth_if`'s unit-amplitude signal
1574    /// at `cn0_dbhz`. `synth_if` adds `noise * (U(-1,1) + U(-1,1))`, so the per-component
1575    /// variance is `noise^2 * 2/3`; and `C/N0 = A^2 * fs / (2 * sigma^2)`.
1576    fn noise_for_cn0(cn0_dbhz: f64, fs: f64) -> f64 {
1577        let cn0 = 10f64.ppowf(cn0_dbhz / 10.0);
1578        let sigma2 = fs / (2.0 * cn0);
1579        (sigma2 / (2.0 / 3.0)).sqrt()
1580    }
1581
1582    fn std_of(v: &[f64]) -> f64 {
1583        let n = v.len() as f64;
1584        let m: f64 = v.iter().sum::<f64>() / n;
1585        (v.iter().map(|x| (x - m) * (x - m)).sum::<f64>() / n).sqrt()
1586    }
1587
1588    /// A sample rate that is deliberately NOT an integer multiple of the chip rate. The
1589    /// `chip_at` replica lookup in `sdr` is a zero-order hold on `floor(phase)`, so at an
1590    /// integer samples-per-chip rate every correlation lands on the same sub-chip grid and
1591    /// the early-late response degenerates into a staircase with dead bands. At a
1592    /// non-commensurate rate the sample phase sweeps the chip and the correlation recovers
1593    /// the ideal triangle. 5 MHz is 4.888 samples/chip (and is what `spoof_capture`
1594    /// already defaults to).
1595    const FS_HZ: f64 = 5.0e6;
1596
1597    #[test]
1598    fn open_loop_carrier_jitter_matches_the_sdr_correlator() {
1599        // The closed form is checked against a DIFFERENT route to the same number: the
1600        // measured spread of the atan(Q/I) discriminator of `sdr::correlate` over seeded
1601        // synthetic IF at a calibrated C/N0. Three noise realisations are pooled, because
1602        // one realisation carries its own few-percent bias and reusing a single seed across
1603        // C/N0 values would make four "independent" agreements share it.
1604        //
1605        // The squaring loss is not decorative and the window is chosen so that it can be
1606        // seen: at 45 dB-Hz it is a 0.8 % effect (below the sampling error, so both forms
1607        // fit), while at 35-40 dB-Hz it is 2-8 % and only the form that carries it lands.
1608        // Below about 32 dB-Hz the real atan discriminator saturates against its +-pi/2
1609        // range and measures LESS jitter than any linear theory predicts; that boundary is
1610        // asserted too, so the stated validity limit is pinned rather than just claimed.
1611        let code = CaCode::new(10).expect("PRN 10");
1612        let spe = (FS_HZ / 1000.0).round() as usize;
1613        let t = 1e-3;
1614        let n = 900usize;
1615        let measure = |cn0_db: f64| -> f64 {
1616            let noise = noise_for_cn0(cn0_db, FS_HZ);
1617            let mut all: Vec<f64> = Vec::with_capacity(3 * n);
1618            for seed in [9_123_457u64, 55_001, 3_141_593] {
1619                let iq = synth_if(
1620                    &code,
1621                    FS_HZ,
1622                    0.0,
1623                    crate::sdr::CA_CHIP_RATE_HZ,
1624                    0.0,
1625                    1.0,
1626                    spe * n,
1627                    noise,
1628                    seed,
1629                );
1630                for e in 0..n {
1631                    let (s, en) = (e * spe, e * spe + spe);
1632                    let cp = (crate::sdr::CA_CHIP_RATE_HZ * (s as f64 / FS_HZ))
1633                        .rem_euclid(CA_CODE_LEN as f64);
1634                    let p = CorrParams {
1635                        fs_hz: FS_HZ,
1636                        carrier_freq_hz: 0.0,
1637                        carrier_phase_rad: 0.0,
1638                        code_rate_hz: crate::sdr::CA_CHIP_RATE_HZ,
1639                        code_phase_chips: cp,
1640                        corr_spacing_chips: 0.5,
1641                    };
1642                    let c = correlate(&iq[s..en], &code, &p);
1643                    all.push((c.prompt.im / c.prompt.re).patan());
1644                }
1645            }
1646            std_of(&all)
1647        };
1648        for cn0_db in [45.0_f64, 40.0, 37.0, 35.0] {
1649            let measured = measure(cn0_db);
1650            let predicted = pll_measurement_jitter_rad(cn0_db, t);
1651            let rel = ((measured - predicted) / predicted).abs();
1652            assert!(
1653                rel < 0.02,
1654                "C/N0 {cn0_db} dB-Hz: correlator measured sigma {measured:.6} rad, closed form \
1655                 {predicted:.6} rad (rel {rel:.4})"
1656            );
1657            // Where the squaring loss is resolvable, the form that carries it must fit
1658            // several times better than the form that drops it.
1659            if cn0_db <= 40.0 {
1660                let no_loss = (1.0 / (2.0 * 10f64.ppowf(cn0_db / 10.0) * t)).sqrt();
1661                let rel_no_loss = ((measured - no_loss) / no_loss).abs();
1662                assert!(
1663                    rel_no_loss > 3.0 * rel,
1664                    "at {cn0_db} dB-Hz the squaring loss must be clearly visible: with it \
1665                     {rel:.4}, without it {rel_no_loss:.4}"
1666                );
1667            }
1668        }
1669        // The stated lower validity limit, asserted rather than asserted-about: at 32 dB-Hz
1670        // the bounded atan discriminator measures materially LESS than the linear theory.
1671        let measured = measure(32.0);
1672        let predicted = pll_measurement_jitter_rad(32.0, t);
1673        assert!(
1674            measured < 0.95 * predicted,
1675            "the discriminator must already be saturating at 32 dB-Hz: measured {measured:.6} \
1676             rad vs closed form {predicted:.6} rad"
1677        );
1678    }
1679
1680    #[test]
1681    fn closed_loop_carrier_jitter_matches_a_stepped_sdr_costas_loop() {
1682        // Close a first-order Costas loop around `sdr::correlate` — the same update law
1683        // `sdr::track` uses, with the frequency integrator off so the loop is exactly first
1684        // order — and compare the residual carrier-phase state's spread against
1685        // sigma_meas * sqrt(g/(2-g)), the exact steady-state variance of a discrete
1686        // first-order loop, and against the continuous form with B_n = g/(4T).
1687        let code = CaCode::new(10).expect("PRN 10");
1688        let spe = (FS_HZ / 1000.0).round() as usize;
1689        let t = 1e-3;
1690        let cn0_db = 45.0_f64;
1691        let noise = noise_for_cn0(cn0_db, FS_HZ);
1692        let n = 4000usize;
1693        let iq = synth_if(
1694            &code,
1695            FS_HZ,
1696            0.0,
1697            crate::sdr::CA_CHIP_RATE_HZ,
1698            0.0,
1699            1.0,
1700            spe * n,
1701            noise,
1702            777_001,
1703        );
1704        let sigma_meas = pll_measurement_jitter_rad(cn0_db, t);
1705        for g in [0.05_f64, 0.1, 0.2] {
1706            let mut ph = 0.0f64;
1707            let mut resid = Vec::with_capacity(n);
1708            for e in 0..n {
1709                let (s, en) = (e * spe, e * spe + spe);
1710                let cp = (crate::sdr::CA_CHIP_RATE_HZ * (s as f64 / FS_HZ))
1711                    .rem_euclid(CA_CODE_LEN as f64);
1712                let p = CorrParams {
1713                    fs_hz: FS_HZ,
1714                    carrier_freq_hz: 0.0,
1715                    carrier_phase_rad: ph,
1716                    code_rate_hz: crate::sdr::CA_CHIP_RATE_HZ,
1717                    code_phase_chips: cp,
1718                    corr_spacing_chips: 0.5,
1719                };
1720                let c = correlate(&iq[s..en], &code, &p);
1721                let disc = (c.prompt.im / c.prompt.re).patan();
1722                ph = (ph + g * disc).rem_euclid(std::f64::consts::TAU);
1723                let w = if ph > std::f64::consts::PI {
1724                    ph - std::f64::consts::TAU
1725                } else {
1726                    ph
1727                };
1728                resid.push(w);
1729            }
1730            let measured = std_of(&resid[500..]);
1731            let discrete = sigma_meas * (g / (2.0 - g)).sqrt();
1732            let rel = ((measured - discrete) / discrete).abs();
1733            assert!(
1734                rel < 0.06,
1735                "g {g}: stepped loop residual {measured:.5} rad vs discrete closed form \
1736                 {discrete:.5} rad (rel {rel:.4})"
1737            );
1738            // And the module's continuous form, with the equivalent bandwidth of that
1739            // discrete gain, lands on the same number to within the discrete/continuous
1740            // difference, which vanishes as g -> 0.
1741            let bn = equivalent_first_order_bandwidth_hz(g, t, 1.0);
1742            let continuous = pll_thermal_jitter_rad(cn0_db, bn, t);
1743            assert!(
1744                ((continuous - discrete) / discrete).abs() < g,
1745                "continuous B_n = g/(4T) must agree with the discrete form to O(g): \
1746                 {continuous:.5} vs {discrete:.5} at g {g}"
1747            );
1748            assert!(
1749                ((measured - continuous) / continuous).abs() < 0.10,
1750                "g {g}: stepped loop {measured:.5} rad vs continuous closed form \
1751                 {continuous:.5} rad"
1752            );
1753        }
1754    }
1755
1756    // ---------------------------------------------------------------------------------
1757    // Oracle 3 — the code-loop ramp lag against the real sdr correlator.
1758    // ---------------------------------------------------------------------------------
1759
1760    #[test]
1761    fn code_ramp_lag_matches_a_stepped_sdr_dll() {
1762        // Step the `sdr` DLL (the exact update law of `sdr::track`) over a noiseless signal
1763        // whose code chips FASTER than nominal, and compare the settled lag against
1764        // dll_ramp_lag_chips at the discrete loop's equivalent bandwidth. The loop's lag is
1765        // read at the middle of each correlation interval, which is where the correlator
1766        // averages the misalignment; the epoch-start value differs by exactly half an
1767        // epoch of slew and the test checks that too.
1768        let code = CaCode::new(10).expect("PRN 10");
1769        let d = 0.5_f64;
1770        let g = 0.1_f64;
1771        let t = 1e-3;
1772        let cp0 = 100.0_f64;
1773        let spe = (FS_HZ / 1000.0).round() as usize;
1774        let bn =
1775            equivalent_first_order_bandwidth_hz(g, t, early_late_amplitude_discriminator_slope(d));
1776        // The `chip_at` replica lookup is a zero-order hold on the sample grid, so the
1777        // stepped lag carries a small sub-sample dither whose period is one grid step of
1778        // relative code drift, `(R_c/fs) / (slew*T)` epochs. Averaging over a whole number
1779        // of those periods (after a settle burn-in) removes it; averaging over a partial
1780        // period does not, and leaves a few-percent bias that has nothing to do with the
1781        // loop theory under test.
1782        let grid_period_epochs = (crate::sdr::CA_CHIP_RATE_HZ / FS_HZ) / (t);
1783        let burn_in = 200usize;
1784        for slew in [1.0_f64, 2.0, 3.0] {
1785            let window = (2.0 * grid_period_epochs / slew).round() as usize;
1786            let n = burn_in + window;
1787            let iq = synth_if(
1788                &code,
1789                FS_HZ,
1790                0.0,
1791                crate::sdr::CA_CHIP_RATE_HZ + slew,
1792                cp0,
1793                1.0,
1794                spe * n,
1795                0.0,
1796                7,
1797            );
1798            let mut cp = cp0;
1799            let mut errs = Vec::with_capacity(n);
1800            for e in 0..n {
1801                let (s, en) = (e * spe, e * spe + spe);
1802                let p = CorrParams {
1803                    fs_hz: FS_HZ,
1804                    carrier_freq_hz: 0.0,
1805                    carrier_phase_rad: 0.0,
1806                    code_rate_hz: crate::sdr::CA_CHIP_RATE_HZ,
1807                    code_phase_chips: cp,
1808                    corr_spacing_chips: d,
1809                };
1810                let c = correlate(&iq[s..en], &code, &p);
1811                let (em, lm) = (c.early.abs(), c.late.abs());
1812                let disc = 0.5 * (em - lm) / (em + lm);
1813                let tt = (e * spe) as f64 / FS_HZ;
1814                let truth = cp0 + (crate::sdr::CA_CHIP_RATE_HZ + slew) * tt;
1815                let dx = cp - truth;
1816                let nn = CA_CODE_LEN as f64;
1817                let m = dx.rem_euclid(nn);
1818                errs.push(if m > nn / 2.0 { m - nn } else { m });
1819                cp = (cp + crate::sdr::CA_CHIP_RATE_HZ * t + g * disc).rem_euclid(nn);
1820            }
1821            let epoch_start_lag: f64 =
1822                errs[burn_in..].iter().sum::<f64>() / (errs.len() - burn_in) as f64;
1823            // The correlator integrates over the epoch, so the error it responds to is the
1824            // mid-epoch value: half an epoch of slew below the epoch-start value.
1825            let mid_epoch_lag = epoch_start_lag - slew * t / 2.0;
1826            let predicted = -dll_ramp_lag_chips(slew, bn);
1827            let rel = ((mid_epoch_lag - predicted) / predicted).abs();
1828            assert!(
1829                rel < 0.005,
1830                "slew {slew} chip/s: stepped DLL mid-epoch lag {mid_epoch_lag:.6} chips vs \
1831                 closed form {predicted:.6} chips at B_n {bn:.3} Hz (rel {rel:.4})"
1832            );
1833            // And the lag is inside the discriminator's linear half-range, so the linear
1834            // theory is being tested where it claims to apply.
1835            assert!(
1836                predicted.abs() < d / 2.0,
1837                "slew {slew} must stay inside the pull-in half-range for this check"
1838            );
1839        }
1840    }
1841
1842    // ---------------------------------------------------------------------------------
1843    // Algebraic identities (labelled as such — they are not independent oracles).
1844    // ---------------------------------------------------------------------------------
1845
1846    #[test]
1847    fn identity_loop_filter_reduces_measurement_jitter_by_sqrt_two_bt() {
1848        // ALGEBRAIC IDENTITY, not an independent oracle: this only states that the closed
1849        // and open loop forms in this module are the same expression scaled by sqrt(2*B*T).
1850        // The numerical content is carried by the correlator tests above.
1851        for cn0 in [20.0_f64, 30.0, 45.0] {
1852            for b in [0.5_f64, 2.0, 10.0, 50.0] {
1853                for t in [1e-3_f64, 5e-3, 2e-2] {
1854                    let a = pll_thermal_jitter_rad(cn0, b, t);
1855                    let e = pll_measurement_jitter_rad(cn0, t) * (2.0 * b * t).sqrt();
1856                    assert!((a - e).abs() <= 1e-12 * a.max(e), "PLL: {a} vs {e}");
1857                    for d in [0.1_f64, 0.5, 1.0] {
1858                        let a = dll_thermal_jitter_chips(cn0, b, t, d);
1859                        let e = dll_measurement_jitter_chips(cn0, t, d) * (2.0 * b * t).sqrt();
1860                        assert!((a - e).abs() <= 1e-12 * a.max(e), "DLL: {a} vs {e}");
1861                    }
1862                }
1863            }
1864        }
1865    }
1866
1867    #[test]
1868    fn hysteresis_lies_between_the_two_bandwidth_scaling_limits() {
1869        // Jitter scales as sqrt(B_n) when thermal noise dominates and as B_n^(1/4) when the
1870        // squaring loss does, so equalising jitter across a bandwidth ratio r costs between
1871        // 5*log10(r) and 10*log10(r) dB of C/N0. The computed hysteresis MUST sit in that
1872        // band — a bound derived from the asymptotics of the same formula, so a coding
1873        // error that dropped the squaring loss or mis-scaled the bandwidth breaks it.
1874        for r in [1.5_f64, 2.0, 4.0, 10.0] {
1875            for t in [1e-3_f64, 2e-2] {
1876                for b in [2.0_f64, 10.0, 25.0] {
1877                    let cfg = LoopConfig {
1878                        pll_bandwidth_hz: b,
1879                        dll_bandwidth_hz: 1.0,
1880                        integration_s: t,
1881                        spacing_chips: 0.5,
1882                        carrier_allowance_deg: COSTAS_THRESHOLD_DEG,
1883                        code_allowance_chips: 0.25,
1884                        pullin_bandwidth_ratio: r,
1885                    };
1886                    let th = cfg.thresholds(0.0, 0.0);
1887                    let h = th.hysteresis_db.expect("a hysteresis width");
1888                    let (lo, hi) = (5.0 * r.plog10(), 10.0 * r.plog10());
1889                    assert!(
1890                        h > lo - 1e-9 && h < hi + 1e-9,
1891                        "ratio {r}, B {b}, T {t}: hysteresis {h:.4} dB outside [{lo:.4}, {hi:.4}]"
1892                    );
1893                    assert!(
1894                        th.relock_cn0_dbhz.unwrap() > th.drop_cn0_dbhz.unwrap(),
1895                        "re-lock must be strictly harder than holding lock"
1896                    );
1897                }
1898            }
1899        }
1900    }
1901
1902    #[test]
1903    fn a_threshold_is_exactly_where_the_stated_rule_is_met() {
1904        // Evaluate the jitter AT the solved threshold and confirm the rule holds with
1905        // equality — the bisection solved the equation it claims to solve.
1906        let t = 1e-3;
1907        for b in [1.0_f64, 10.0, 30.0] {
1908            let c = carrier_tracking_threshold_cn0_dbhz(b, t, COSTAS_THRESHOLD_DEG, 0.0)
1909                .expect("a carrier threshold");
1910            let three_sigma = 3.0 * pll_thermal_jitter_rad(c, b, t).to_degrees();
1911            assert!(
1912                (three_sigma - COSTAS_THRESHOLD_DEG).abs() < 1e-6,
1913                "B {b}: 3 sigma at the threshold is {three_sigma}, not {COSTAS_THRESHOLD_DEG}"
1914            );
1915            for d in [0.1_f64, 0.5, 1.0] {
1916                let cc = code_tracking_threshold_cn0_dbhz(b, t, d, d / 2.0, 0.0)
1917                    .expect("a code threshold");
1918                let three = 3.0 * dll_thermal_jitter_chips(cc, b, t, d);
1919                assert!(
1920                    (three - d / 2.0).abs() < 1e-9,
1921                    "B {b} d {d}: 3 sigma at the threshold is {three}, not {}",
1922                    d / 2.0
1923                );
1924            }
1925        }
1926        // Dynamic stress that already fills the budget leaves NO threshold at any signal
1927        // strength. That is reported as None — an unreachable condition, not a zero.
1928        assert!(
1929            carrier_tracking_threshold_cn0_dbhz(1.0, 1e-3, COSTAS_THRESHOLD_DEG, 1000.0).is_none(),
1930            "a 1 Hz loop cannot hold 1000 Hz/s at any C/N0"
1931        );
1932    }
1933
1934    #[test]
1935    fn a_wider_loop_needs_more_signal_and_a_longer_dwell_costs_time() {
1936        let t = 1e-3;
1937        let mut prev = f64::NEG_INFINITY;
1938        for b in [1.0_f64, 2.0, 5.0, 10.0, 25.0, 50.0] {
1939            let c = carrier_tracking_threshold_cn0_dbhz(b, t, COSTAS_THRESHOLD_DEG, 0.0).unwrap();
1940            assert!(
1941                c > prev,
1942                "the threshold must rise with bandwidth: {b} Hz -> {c}"
1943            );
1944            prev = c;
1945        }
1946        // The detector declares loss exactly one confirmation dwell after the crossing.
1947        for dwell in [1usize, 10, 50, 500] {
1948            let mut det = LockDetector::new(25.0, 27.0, dwell, 10);
1949            let h = run_lock_history(&mut det, 45.0, 15.0, 60.0, 1e-3).expect("history");
1950            let lag = h.declared_loss_s - h.drop_crossing_s;
1951            assert!(
1952                (lag - dwell as f64 * 1e-3).abs() < 1.5e-3,
1953                "dwell {dwell}: declared {lag} s after the crossing"
1954            );
1955        }
1956    }
1957
1958    #[test]
1959    fn the_cycle_slip_time_collapses_as_the_jitter_approaches_a_quarter_cycle() {
1960        // The Costas slip time falls monotonically with C/N0, is of order a minute at the
1961        // 15-degree design point of a 10 Hz loop (rho/4 ~ 3.6), and is under a second once
1962        // the jitter approaches a quarter cycle.
1963        let (b, t) = (10.0_f64, 1e-3);
1964        let mut prev = f64::INFINITY;
1965        for cn0 in [45.0_f64, 40.0, 35.0, 30.0, 27.0, 25.0, 22.0, 20.0] {
1966            let l = log10_mean_time_to_cycle_slip_s(cn0, b, t);
1967            assert!(l < prev, "slip time must fall with C/N0 at {cn0}");
1968            prev = l;
1969        }
1970        let at_threshold = carrier_tracking_threshold_cn0_dbhz(b, t, COSTAS_THRESHOLD_DEG, 0.0)
1971            .expect("a threshold");
1972        let l = log10_mean_time_to_cycle_slip_s(at_threshold, b, t);
1973        assert!(
1974            (1.0..3.0).contains(&l),
1975            "at the 15-degree design point the Costas slip time is of order a minute, got 10^{l} s"
1976        );
1977        // The phase-locked-loop form (rho instead of rho/4) is far longer there.
1978        let sigma = pll_thermal_jitter_rad(at_threshold, b, t);
1979        let rho = 1.0 / (sigma * sigma);
1980        let pll_form = 2.0 * std::f64::consts::PI.plog10()
1981            + rho.plog10()
1982            + 2.0 * ln_bessel_i0(rho) / std::f64::consts::LN_10
1983            - (2.0 * b).plog10();
1984        assert!(pll_form - l > 5.0, "{pll_form} vs {l}");
1985        let quarter = log10_mean_time_to_cycle_slip_s(20.0, b, t);
1986        assert!(
1987            quarter < 0.0,
1988            "at 20 dB-Hz the Costas loop should slip within a second, got 10^{quarter}"
1989        );
1990    }
1991
1992    #[test]
1993    fn a_spoofer_that_slews_too_fast_outruns_the_loop() {
1994        let cfg = LoopConfig {
1995            pll_bandwidth_hz: 10.0,
1996            dll_bandwidth_hz: 1.0,
1997            integration_s: 1e-3,
1998            spacing_chips: 0.5,
1999            carrier_allowance_deg: COSTAS_THRESHOLD_DEG,
2000            code_allowance_chips: 0.25,
2001            pullin_bandwidth_ratio: 2.0,
2002        };
2003        let max = max_code_slew_chips_per_s(cfg.dll_bandwidth_hz, cfg.code_allowance_chips);
2004        assert!(
2005            (max - 1.0).abs() < 1e-12,
2006            "4 * 1 Hz * 0.25 chip = 1 chip/s, got {max}"
2007        );
2008        // The operative, jitter-aware ceiling is strictly below the geometric one and is
2009        // exactly the boundary the pull-in cells are decided at — the headline figure and
2010        // the map can never contradict each other.
2011        let live = max_code_slew_at_cn0_chips_per_s(
2012            cfg.dll_bandwidth_hz,
2013            45.0,
2014            cfg.integration_s,
2015            cfg.spacing_chips,
2016            cfg.code_allowance_chips,
2017        );
2018        assert!(
2019            live < max && live > 0.9 * max,
2020            "jitter-aware ceiling {live} vs {max}"
2021        );
2022        assert!(pull_in_cell(&cfg, 45.0, live * 0.999, 0.0).code_follows);
2023        assert!(!pull_in_cell(&cfg, 45.0, live * 1.001, 0.0).code_follows);
2024        // Just inside the limit the victim follows; far outside it does not — and the
2025        // transition is monotone in the slew rate.
2026        let mut seen_fail = false;
2027        for k in 1..=20 {
2028            let slew = live * k as f64 / 5.0;
2029            let cell = pull_in_cell(&cfg, 45.0, slew, 0.0);
2030            if !cell.code_follows {
2031                seen_fail = true;
2032            } else {
2033                assert!(!seen_fail, "following must not resume once it has failed");
2034            }
2035            assert!((cell.code_lag_chips - slew / (4.0 * cfg.dll_bandwidth_hz)).abs() < 1e-12);
2036        }
2037        assert!(seen_fail, "a fast enough slew must outrun the loop");
2038        // Same on the carrier axis, and the reported ceiling is the actual boundary.
2039        let rate = max_doppler_rate_hz_per_s(10.0, 45.0, 1e-3, COSTAS_THRESHOLD_DEG);
2040        assert!(pull_in_cell(&cfg, 45.0, 0.0, rate * 0.99).carrier_follows);
2041        assert!(!pull_in_cell(&cfg, 45.0, 0.0, rate * 1.01).carrier_follows);
2042    }
2043
2044    #[test]
2045    fn the_dynamic_stress_matches_the_second_order_loop_response() {
2046        // theta_e = 360 * fdot / omega_n^2 with omega_n = B_n/0.53, i.e. the steady-state
2047        // error of the type-2 error transfer s^2/(s^2 + 2*zeta*wn*s + wn^2) to an
2048        // acceleration input. Check against that final-value expression written out
2049        // independently of the module's constant folding.
2050        for b in [2.0_f64, 10.0, 25.0] {
2051            for fdot in [0.5_f64, 5.0, 50.0] {
2052                let wn = b / SECOND_ORDER_BN_OVER_WN;
2053                let want = 360.0 * fdot / (wn * wn);
2054                let got = pll_dynamic_stress_deg(fdot, b);
2055                assert!(
2056                    (got - want).abs() < 1e-12 * want.max(1.0),
2057                    "{got} vs {want}"
2058                );
2059            }
2060        }
2061    }
2062
2063    // ---------------------------------------------------------------------------------
2064    // The acceptance contract: both radii, and their named difference.
2065    // ---------------------------------------------------------------------------------
2066
2067    #[test]
2068    fn both_denial_radii_are_reported_and_their_difference_is_named() {
2069        let scn = TrackingLoopScenario::default();
2070        let (json, summary) = scn.run_json().expect("run");
2071        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
2072        let d = &v["denial"];
2073        let thr = d["denial_radius_threshold_km"]
2074            .as_f64()
2075            .expect("threshold radius");
2076        let lp = d["denial_radius_loop_dynamics_km"]
2077            .as_f64()
2078            .expect("loop-dynamics radius");
2079        let delta = d["denial_radius_delta_km"]
2080            .as_f64()
2081            .expect("the named delta");
2082        // Both present, neither replacing the other.
2083        assert!(thr > 0.0 && lp > 0.0);
2084        // The difference is the difference — exactly, not approximately.
2085        assert!(
2086            (delta - (lp - thr)).abs() < 1e-9,
2087            "delta {delta} != {lp} - {thr}"
2088        );
2089        assert!((d["denial_radius_ratio"].as_f64().unwrap() - lp / thr).abs() < 1e-12);
2090        // The existing power-ratio criterion is untouched: at the threshold radius, the
2091        // J/S really is the stated 30 dB, computed with the existing jamming function.
2092        let link = scn.denial_link();
2093        assert!(
2094            (link.js_db_at(thr * 1000.0) - scn.denial_js_threshold_db).abs() < 1e-6,
2095            "the threshold radius must be exactly where J/S = {} dB",
2096            scn.denial_js_threshold_db
2097        );
2098        // And the loop radius really is where the effective C/N0 hits the drop threshold.
2099        let drop = v["thresholds"]["drop_cn0_dbhz"].as_f64().unwrap();
2100        assert!((link.effective_cn0_dbhz_at(lp * 1000.0) - drop).abs() < 1e-6);
2101        // The difference is signed and explained, not a bare number.
2102        assert!(d["denial_radius_delta_definition"].is_string());
2103        assert!(d["criterion_note"].is_string());
2104        // Both radii and the delta reach the summary line.
2105        assert!(summary.contains("denial radius threshold"));
2106        assert!(summary.contains("loop dynamics"));
2107        assert!(summary.contains("delta"));
2108        // The capture criterion is reported as a power ratio AND honestly declined as a
2109        // loop-dynamics radius, rather than a fabricated one.
2110        assert!(d["capture_radius_threshold_km"].as_f64().unwrap() > 0.0);
2111        assert!(d["capture_radius_loop_dynamics_km"].is_null());
2112        assert!(d["capture_radius_loop_dynamics_status"]
2113            .as_str()
2114            .is_some_and(|s| s.starts_with("not-applicable")));
2115    }
2116
2117    #[test]
2118    fn the_loop_radius_moves_with_the_loop_and_not_with_the_power_ratio() {
2119        // A narrower carrier loop holds lock at a lower C/N0, so the jammer must come
2120        // closer: the loop-dynamics radius shrinks while the power-ratio radius does not
2121        // move at all. That is the whole point of reporting both.
2122        let base = TrackingLoopScenario::default();
2123        let narrow = TrackingLoopScenario {
2124            pll_bandwidth_hz: 2.0,
2125            ..TrackingLoopScenario::default()
2126        };
2127        let f = |s: &TrackingLoopScenario| {
2128            let (j, _) = s.run_json().unwrap();
2129            let v: serde_json::Value = serde_json::from_str(&j).unwrap();
2130            (
2131                v["denial"]["denial_radius_threshold_km"].as_f64().unwrap(),
2132                v["denial"]["denial_radius_loop_dynamics_km"]
2133                    .as_f64()
2134                    .unwrap(),
2135            )
2136        };
2137        let (t0, l0) = f(&base);
2138        let (t1, l1) = f(&narrow);
2139        assert!(
2140            (t0 - t1).abs() < 1e-9,
2141            "the power-ratio radius must not move"
2142        );
2143        assert!(
2144            l1 < l0,
2145            "a narrower loop must shrink the loop-dynamics radius: {l1} vs {l0}"
2146        );
2147    }
2148
2149    #[test]
2150    fn every_reported_figure_carries_a_unit_and_a_provenance_class() {
2151        let scn = TrackingLoopScenario::default();
2152        let (json, _) = scn.run_json().expect("run");
2153        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
2154        let units = v["units"].as_object().expect("a units block");
2155        assert!(!units.is_empty());
2156        for (field, meta) in units {
2157            assert!(meta["unit"].is_string(), "{field} has no unit");
2158            assert!(
2159                meta["provenance"].is_string(),
2160                "{field} has no provenance class"
2161            );
2162            let p = meta["provenance"].as_str().unwrap();
2163            assert!(
2164                matches!(p, "input" | "computed" | "modelled-input"),
2165                "{field} has an unknown provenance class {p:?}"
2166            );
2167        }
2168        let described: std::collections::HashSet<&str> = units.keys().map(|k| k.as_str()).collect();
2169        // Walk every numeric leaf of the document and require a units entry for it, with
2170        // array members addressed as `path[].field`.
2171        fn walk(
2172            v: &serde_json::Value,
2173            path: &str,
2174            described: &std::collections::HashSet<&str>,
2175            missing: &mut Vec<String>,
2176        ) {
2177            match v {
2178                serde_json::Value::Number(_) => {
2179                    if !described.contains(path) {
2180                        missing.push(path.to_string());
2181                    }
2182                }
2183                serde_json::Value::Object(o) => {
2184                    for (k, e) in o {
2185                        if path.is_empty() && k == "units" {
2186                            continue;
2187                        }
2188                        let p = if path.is_empty() {
2189                            k.clone()
2190                        } else {
2191                            format!("{path}.{k}")
2192                        };
2193                        walk(e, &p, described, missing);
2194                    }
2195                }
2196                serde_json::Value::Array(a) => {
2197                    if let Some(first) = a.first() {
2198                        walk(first, &format!("{path}[]"), described, missing);
2199                    }
2200                }
2201                _ => {}
2202            }
2203        }
2204        let mut missing = Vec::new();
2205        walk(&v, "", &described, &mut missing);
2206        assert!(
2207            missing.is_empty(),
2208            "numeric fields missing from the units block: {missing:?}"
2209        );
2210    }
2211
2212    #[test]
2213    fn the_units_block_describes_only_fields_that_exist() {
2214        let scn = TrackingLoopScenario::default();
2215        let (json, _) = scn.run_json().expect("run");
2216        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
2217        for field in v["units"].as_object().expect("units").keys() {
2218            let mut cur = &v;
2219            for seg in field.split('.') {
2220                cur = if let Some(name) = seg.strip_suffix("[]") {
2221                    let arr = cur
2222                        .get(name)
2223                        .and_then(|x| x.as_array())
2224                        .unwrap_or_else(|| panic!("{field}: {name} is not an array"));
2225                    arr.first()
2226                        .unwrap_or_else(|| panic!("{field}: {name} is empty"))
2227                } else {
2228                    cur.get(seg)
2229                        .unwrap_or_else(|| panic!("units names a missing field: {field}"))
2230                };
2231            }
2232            assert!(!cur.is_null(), "units names a null field: {field}");
2233        }
2234    }
2235
2236    #[test]
2237    fn the_run_is_reproducible_and_declares_what_is_modelled() {
2238        let scn = TrackingLoopScenario::default();
2239        let (a, sa) = scn.run_json().expect("run");
2240        let (b, sb) = scn.run_json().expect("run");
2241        assert_eq!(a, b, "the run must be reproducible");
2242        assert_eq!(sa, sb);
2243        let v: serde_json::Value = serde_json::from_str(&a).expect("valid JSON");
2244        assert_eq!(v["kind"], "tracking-loop");
2245        let label = v["label"].as_str().expect("a label");
2246        assert!(label.contains("MODELLED"));
2247        assert!(label.contains("VALIDATED"));
2248        // The exclusions are named, not implied.
2249        for phrase in [
2250            "Allan-deviation",
2251            "multipath",
2252            "front-end bandwidth",
2253            "half-cycle",
2254        ] {
2255            assert!(
2256                label.contains(phrase),
2257                "the label must name {phrase} as excluded"
2258            );
2259        }
2260        assert!(label.contains("ALONGSIDE"));
2261    }
2262
2263    #[test]
2264    fn the_hysteretic_detector_declares_loss_then_recovery_with_a_gap() {
2265        let scn = TrackingLoopScenario::default();
2266        let (json, _) = scn.run_json().expect("run");
2267        let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
2268        let h = &v["lock_history"];
2269        let loss = h["declared_loss_of_lock_s"].as_f64().expect("a loss time");
2270        let relock = h["declared_relock_s"].as_f64().expect("a relock time");
2271        let cross = h["drop_crossing_s"].as_f64().unwrap();
2272        let recross = h["relock_crossing_s"].as_f64().unwrap();
2273        assert!(cross < loss, "the declaration must follow the crossing");
2274        assert!(loss < recross, "the ramp must come back up after the loss");
2275        assert!(recross < relock, "recovery must follow its own crossing");
2276        // Hysteresis is visible in the profile: the C/N0 at which lock is regained is
2277        // strictly higher than the C/N0 at which it was dropped.
2278        let drop_cn0 = v["thresholds"]["drop_cn0_dbhz"].as_f64().unwrap();
2279        let relock_cn0 = v["thresholds"]["relock_cn0_dbhz"].as_f64().unwrap();
2280        assert!(relock_cn0 > drop_cn0);
2281        assert!(h["unlocked_duration_s"].as_f64().unwrap() > 0.0);
2282    }
2283
2284    #[test]
2285    fn bad_inputs_are_refused_with_a_message_not_a_panic() {
2286        let cases: &[(&str, TrackingLoopScenario)] = &[
2287            (
2288                "pullin_bandwidth_ratio",
2289                TrackingLoopScenario {
2290                    pullin_bandwidth_ratio: 0.5,
2291                    ..Default::default()
2292                },
2293            ),
2294            (
2295                "correlator_spacing_chips",
2296                TrackingLoopScenario {
2297                    correlator_spacing_chips: 2.5,
2298                    ..Default::default()
2299                },
2300            ),
2301            (
2302                "pll_bandwidth_hz",
2303                TrackingLoopScenario {
2304                    pll_bandwidth_hz: 0.0,
2305                    ..Default::default()
2306                },
2307            ),
2308            (
2309                "cn0_low_dbhz",
2310                TrackingLoopScenario {
2311                    cn0_low_dbhz: 60.0,
2312                    ..Default::default()
2313                },
2314            ),
2315            (
2316                "jitter_table_cn0_dbhz",
2317                TrackingLoopScenario {
2318                    jitter_table_cn0_dbhz: Vec::new(),
2319                    ..Default::default()
2320                },
2321            ),
2322        ];
2323        for (name, scn) in cases {
2324            let e = scn.run_json().expect_err("must be refused");
2325            assert!(e.contains(name), "the error must name {name}: {e}");
2326        }
2327        // A profile that would take an unbounded number of detector steps is refused
2328        // rather than run.
2329        let huge = TrackingLoopScenario {
2330            ramp_duration_s: 1.0e9,
2331            ..Default::default()
2332        };
2333        let e = huge.run_json().expect_err("must be refused");
2334        assert!(e.contains("detector steps"), "{e}");
2335    }
2336
2337    #[test]
2338    fn the_bundled_scenario_file_is_the_default_configuration() {
2339        let src = include_str!("../scenarios/tracking-loop.toml");
2340        let scn: TrackingLoopScenario = toml::from_str(src).expect("the bundled TOML parses");
2341        let (a, _) = scn.run_json().expect("run");
2342        let (b, _) = TrackingLoopScenario::default()
2343            .run_json()
2344            .expect("run the default");
2345        assert_eq!(a, b, "the bundled file must reproduce the default run");
2346    }
2347}