1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
// SPDX-License-Identifier: AGPL-3.0-only
//! **GNSS-denied clock holdover.**
//!
//! The defining value of a high-stability (and especially a *quantum*) clock in a
//! PNT system is **holdover**: when the GNSS signal is jammed, spoofed or simply
//! unavailable, how long can the onboard clock free-run before its timing error
//! exceeds the budget? Kshana already models clock noise comprehensively — Allan
//! deviation and noise-type classification ([`crate::allan`]), the three-state
//! van Loan Kalman clock ([`crate::clock_state`]), and stochastic synthesis
//! ([`crate::models::ClockModel`]). What it lacked was the one operational answer a
//! resilience trade asks directly: *given this clock, what is my coast time to an
//! N-nanosecond error?*
//!
//! This module exposes that answer in closed form. For the standard power-law
//! clock model the **phase-error variance after coasting `t` seconds** from a
//! perfectly known state is the van Loan process-noise term
//!
//! ```text
//! σ_x²(t) = q_wf·t + q_rw·t³/3 + q_drift·t⁵/20 (s²)
//! ```
//!
//! with `q_wf` the white-FM PSD, `q_rw` the random-walk-FM PSD and `q_drift` the
//! random-run/drift PSD (the same PSDs [`crate::clock_state::q_from_allan`]
//! produces from Allan deviations). This is *identical* to the phase variance
//! `ClockState3::new(q_wf, q_rw, q_drift).predict(t)` accumulates from zero
//! covariance — the cross-check is a unit test here. Because each term is
//! non-negative and increasing, the variance is monotone in `t`, so inverting it
//! for the **holdover duration to a phase-error threshold** is a well-posed
//! root-find ([`holdover_seconds`]).
//!
//! On top of the stochastic growth a clock also drifts deterministically: a known
//! residual frequency offset `y₀` and aging `D` give a time-interval error
//! `x(t) = y₀·t + ½·D·t²` ([`deterministic_tie`]) — the part a good estimator
//! removes while GNSS is present and which therefore enters holdover only through
//! its *estimation* residual.
//!
//! **A caveat that must travel with every class-based holdover figure.** For a
//! very stable clock the white-FM term is so small that the holdover to a tight
//! timing threshold is dominated *not* by the cited `σ_y(1 s)` but by the assumed
//! long-tau red-noise floor (`q_rw`, `q_drift`). The [`ClockClass`] and
//! [`QuantumClockClass`] convenience methods *synthesise* that floor from the
//! white-FM ADEV (two and four decades below it) — a representative modelling
//! assumption, **not** a measured value, and the holdover is sensitive to it
//! (sweeping the floor a decade can move a class holdover several-fold). For a
//! defensible result, call [`holdover_seconds`] with the clock's **measured**
//! `q_rw`/`q_drift` rather than relying on the class default; the class figures are
//! an order-of-magnitude bracket whose long-tau answer is floor-governed and is
//! reported as such, not a per-unit specification.
//!
//! Scope is the **timing-error budget** a feasibility trade needs; it is not a
//! clock-hardware design tool. The quantum-clock classes ([`QuantumClockClass`])
//! carry representative order-of-magnitude stabilities from the open literature,
//! exactly as [`crate::clock_state::ClockClass`] does for the classical
//! oscillators — not any one flight unit.
//!
//! References: Riley, *Handbook of Frequency Stability Analysis* (NIST SP 1065);
//! Zucca & Tavella, *The Clock Model and Its Relationship with the Allan and
//! Related Variances* (IEEE UFFC, 2005); Ludlow et al., *Optical atomic clocks*
//! (Rev. Mod. Phys. 87, 2015); Burt et al., *Demonstration of a trapped-ion atomic
//! clock in space* (Nature 595, 2021).
use crate::clock_state::{q_from_allan, ClockClass};
use crate::types::Seconds;
/// Speed of light (m/s) — for mapping a timing error to a one-way range error.
pub const C_LIGHT_M_PER_S: f64 = 299_792_458.0;
/// **Stochastic phase-error variance** `σ_x²(t)` (s²) after free-running for `t`
/// seconds from a perfectly known clock state, for the power-law PSDs
/// `(q_wf, q_rw, q_drift)`: `q_wf·t + q_rw·t³/3 + q_drift·t⁵/20` (van Loan; see
/// module docs). Negative `t` is clamped to zero.
pub fn coast_phase_variance(q_wf: f64, q_rw: f64, q_drift: f64, t: Seconds) -> f64 {
let t = t.max(0.0);
let t2 = t * t;
let t3 = t2 * t;
let t5 = t3 * t2;
q_wf * t + q_rw * t3 / 3.0 + q_drift * t5 / 20.0
}
/// **Stochastic phase-error 1-σ** (s) after coasting `t` seconds — the square root
/// of [`coast_phase_variance`].
pub fn coast_phase_sigma(q_wf: f64, q_rw: f64, q_drift: f64, t: Seconds) -> Seconds {
coast_phase_variance(q_wf, q_rw, q_drift, t).sqrt()
}
/// **Holdover duration** (s): the coast time at which the stochastic phase-error
/// 1-σ first reaches `threshold_s` seconds, for PSDs `(q_wf, q_rw, q_drift)`.
/// Inverts the monotone [`coast_phase_variance`] for `σ_x(t) = threshold_s`.
///
/// Returns `f64::INFINITY` if the clock has no process noise (all PSDs zero) and
/// `0.0` for a non-positive threshold. The white-FM-only case has the exact
/// closed form `t = threshold² / q_wf`.
pub fn holdover_seconds(q_wf: f64, q_rw: f64, q_drift: f64, threshold_s: Seconds) -> Seconds {
// PSDs must be finite and non-negative; the monotonicity the inversion relies on
// is otherwise violated and the bisection would return a plausible-looking but
// meaningless value. Fail loudly with NaN rather than silently.
if !q_wf.is_finite() || !q_rw.is_finite() || !q_drift.is_finite() || !threshold_s.is_finite() {
return f64::NAN;
}
if q_wf < 0.0 || q_rw < 0.0 || q_drift < 0.0 {
return f64::NAN;
}
if threshold_s <= 0.0 {
return 0.0;
}
let target = threshold_s * threshold_s; // compare on variance
let var = |t: f64| coast_phase_variance(q_wf, q_rw, q_drift, t);
if var(1.0) == 0.0 && q_wf == 0.0 && q_rw == 0.0 && q_drift == 0.0 {
return f64::INFINITY;
}
// Bracket: expand hi until the variance exceeds the target. Integer-counted
// doubling; if still under target after the bound, the threshold is unreachable.
let mut hi = 1.0_f64;
for _ in 0..=200 {
if var(hi) >= target {
break;
}
hi *= 2.0;
}
if var(hi) < target {
return f64::INFINITY; // unreachable threshold within any sane horizon
}
// Bisection (variance is monotone increasing in t).
let mut lo = 0.0_f64;
for _ in 0..100 {
let mid = 0.5 * (lo + hi);
if var(mid) < target {
lo = mid;
} else {
hi = mid;
}
}
0.5 * (lo + hi)
}
/// **Deterministic time-interval error** (s) after `t` seconds for a residual
/// fractional-frequency offset `freq_offset` (dimensionless `y₀`) and aging/drift
/// `drift` (1/s): `x(t) = y₀·t + ½·D·t²`. This is the part GNSS removes while it is
/// present; only its post-estimation residual contributes to holdover.
pub fn deterministic_tie(freq_offset: f64, drift: f64, t: Seconds) -> Seconds {
let t = t.max(0.0);
freq_offset * t + 0.5 * drift * t * t
}
/// Map a timing error (s) to the one-way range error (m) it causes: `c · Δt`.
pub fn phase_to_range_m(phase_s: Seconds) -> f64 {
C_LIGHT_M_PER_S * phase_s
}
/// Holdover (s) for a classical reference [`ClockClass`] to a phase-error
/// threshold, using the class's representative power-law PSDs.
pub fn holdover_for_class(class: ClockClass, threshold_s: Seconds) -> Seconds {
let (q_wf, q_rw, q_drift) = class.psds();
holdover_seconds(q_wf, q_rw, q_drift, threshold_s)
}
/// Reference **quantum-clock** classes, by their τ = 1 s Allan deviation — the
/// optical and trapped-ion clocks a quantum-PNT demonstrator weighs against the
/// classical oscillators in [`ClockClass`].
///
/// Figures are representative order-of-magnitude values from the open literature
/// (Ludlow et al., *Rev. Mod. Phys.* 2015; Burt et al., *Nature* 2021); they
/// bracket the holdover an optical clock buys, not any one flight unit. The
/// long-tau red-noise floors are *synthesised* two and four decades below the
/// white-FM `σ_y(1 s)` (as for [`ClockClass`]). **Important:** for a clock this
/// stable the holdover to a tight threshold is governed by that assumed floor, not
/// by the cited ADEV — see the module-level caveat. Use the explicit
/// [`holdover_seconds`] with measured floors for a defensible number.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum QuantumClockClass {
/// **Optical lattice clock** (neutral Sr/Yb) — many-atom optical reference:
/// `σ_y(1 s) ≈ 5e-16` (Ludlow et al. 2015). The most stable short-term class.
OpticalLattice,
/// **Trapped-ion optical clock** (e.g. Al⁺ quantum-logic) — single-ion
/// reference with exceptional accuracy: `σ_y(1 s) ≈ 1e-15`.
TrappedIon,
/// **Space-qualified mercury-ion clock** (DSAC-heritage, microwave) —
/// `σ_y(1 s) ≈ 1e-13` reaching ≈1e-15 at long tau (Burt et al. 2021). The
/// flight-demonstrated quantum reference today.
MercuryIon,
}
impl QuantumClockClass {
/// The class's representative white-FM Allan deviation at τ = 1 s.
pub fn adev_1s(self) -> f64 {
match self {
QuantumClockClass::OpticalLattice => 5.0e-16,
QuantumClockClass::TrappedIon => 1.0e-15,
QuantumClockClass::MercuryIon => 1.0e-13,
}
}
/// Representative `(q_wf, q_rw, q_drift)` PSDs for this class, via
/// [`q_from_allan`] with conservative long-tau floors (see type docs).
pub fn psds(self) -> (f64, f64, f64) {
let a = self.adev_1s();
q_from_allan(a, a * 1.0e-2, a * 1.0e-4)
}
/// Holdover (s) for this clock to a phase-error threshold `threshold_s`.
pub fn holdover_seconds(self, threshold_s: Seconds) -> Seconds {
let (q_wf, q_rw, q_drift) = self.psds();
holdover_seconds(q_wf, q_rw, q_drift, threshold_s)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::clock_state::ClockState3;
// ── Cross-module check: the single-shot closed form equals the FULL
// multi-step Kalman covariance recursion. Stepping `predict` 500 times
// actually exercises the F·P·Fᵀ propagation and the developing phase/freq/
// drift cross-terms — the t³/3 and t⁵/20 phase-variance growth only emerges
// from integrating the lower-state covariance through F over many steps — so
// this is a genuinely independent computation path, not a re-statement of the
// same polynomial (a wrong coefficient in either copy would diverge here).
#[test]
fn coast_variance_matches_multistep_kalman_recursion() {
let (q_wf, q_rw, q_drift) = (1e-20, 1e-24, 1e-30);
for &t in &[100.0, 3600.0] {
let mut cs = ClockState3::new(q_wf, q_rw, q_drift);
let steps = 500;
let dt = t / steps as f64;
for _ in 0..steps {
cs.predict(dt);
}
let theirs = cs.p[0][0]; // full F·P·Fᵀ + Q accumulation over 500 steps
let ours = coast_phase_variance(q_wf, q_rw, q_drift, t);
let rel = (ours - theirs).abs() / theirs.max(1e-300);
assert!(
rel < 1e-9,
"t={t}: closed {ours:.6e} vs 500-step recursion {theirs:.6e}"
);
}
}
// ── White-FM-only holdover has the exact closed form t = (thr/σ)² ─────────
#[test]
fn white_fm_holdover_is_exact_closed_form() {
let q_wf = 9e-21; // (3e-11)² → σ_y(1 s) ≈ 3e-11
let threshold = 1e-9; // 1 ns
let t = holdover_seconds(q_wf, 0.0, 0.0, threshold);
let exact = threshold * threshold / q_wf; // q_wf·t = threshold²
let rel = (t - exact).abs() / exact;
assert!(rel < 1e-9, "holdover {t:.6e} vs exact {exact:.6e}");
}
// ── Round-trip: σ(holdover) == threshold ──────────────────────────────────
#[test]
fn holdover_round_trips_to_threshold() {
let (q_wf, q_rw, q_drift) = (1e-22, 1e-26, 1e-32);
let threshold = 1e-8;
let t = holdover_seconds(q_wf, q_rw, q_drift, threshold);
let sigma = coast_phase_sigma(q_wf, q_rw, q_drift, t);
let rel = (sigma - threshold).abs() / threshold;
assert!(
rel < 1e-6,
"σ(holdover)={sigma:.6e} vs threshold {threshold:.6e}"
);
}
// ── Monotonicity: variance strictly increases with coast time ─────────────
#[test]
fn variance_is_monotone_in_time() {
let (q_wf, q_rw, q_drift) = (1e-20, 1e-24, 1e-30);
let mut prev = 0.0;
for k in 0..20 {
let t = (k as f64) * 100.0 + 1.0;
let v = coast_phase_variance(q_wf, q_rw, q_drift, t);
assert!(v > prev, "variance not increasing at t={t}");
prev = v;
}
}
// ── Ordering follows the SHARED floor recipe, by construction ──────────────
// Every class uses the identical recipe q_from_allan(a, a·1e-2, a·1e-4), so
// holdover is a fixed monotone-decreasing function of the single parameter a.
// This is therefore a *consistency* check (a smaller cited ADEV, under the same
// assumed floor, yields a longer holdover) — NOT an independent physical
// discovery, since the floor (which dominates, see below) scales with a too.
#[test]
fn holdover_ordering_follows_the_shared_floor_recipe() {
let threshold = 1e-8; // 10 ns
let csac = holdover_for_class(ClockClass::Csac, threshold);
let uso = holdover_for_class(ClockClass::Uso, threshold);
let dsac = holdover_for_class(ClockClass::Dsac, threshold);
let optical = QuantumClockClass::OpticalLattice.holdover_seconds(threshold);
assert!(uso > csac, "USO {uso:.2} should beat CSAC {csac:.2}");
assert!(dsac > uso, "DSAC {dsac:.2} should beat USO {uso:.2}");
assert!(
optical > dsac,
"optical {optical:.2} should beat DSAC {dsac:.2}"
);
}
// ── Honest self-check: for a very stable clock the class holdover to a tight
// threshold is GOVERNED BY the assumed long-tau floor, not the cited ADEV.
// This test exists to make that dependency explicit, not to sell a headline
// "optical coasts N hours" number (which would be a floor artefact).
#[test]
fn class_holdover_to_tight_threshold_is_floor_dominated() {
let threshold = 1e-9; // 1 ns
let a = QuantumClockClass::OpticalLattice.adev_1s();
// White-FM alone (no floor) gives an absurd, mission-irrelevant holdover —
// proof that the cited ADEV does NOT set the class number:
let (q_wf, _, _) = QuantumClockClass::OpticalLattice.psds();
let white_only = holdover_seconds(q_wf, 0.0, 0.0, threshold);
assert!(
white_only > 1.0e9,
"white-FM-only holdover {white_only:.2e} s is mission-irrelevant"
);
// With the assumed floor it is far shorter — the floor sets it:
let with_floor = QuantumClockClass::OpticalLattice.holdover_seconds(threshold);
assert!(
with_floor < white_only / 1.0e3,
"the assumed floor must dominate the class holdover"
);
// …and the answer moves materially with the assumed floor decade:
let (q_wf2, q_rw2, q_drift2) = q_from_allan(a, a * 1.0e-3, a * 1.0e-3);
let steeper = holdover_seconds(q_wf2, q_rw2, q_drift2, threshold);
assert!(
(steeper - with_floor).abs() / with_floor > 0.2,
"holdover should be sensitive to the assumed floor ({steeper:.2e} vs {with_floor:.2e})"
);
}
// ── Bad inputs fail loudly (NaN), not silently with a plausible number ─────
#[test]
fn nan_or_negative_psd_returns_nan() {
assert!(holdover_seconds(f64::NAN, 0.0, 0.0, 1e-9).is_nan());
assert!(holdover_seconds(-1e-20, 0.0, 0.0, 1e-9).is_nan());
assert!(holdover_seconds(1e-20, -1e-30, 0.0, 1e-9).is_nan());
}
// ── Deterministic TIE: pure drift gives ½ D t² ────────────────────────────
#[test]
fn deterministic_tie_quadratic_in_drift() {
let drift = 1e-12; // 1/s
let t = 1000.0;
let tie = deterministic_tie(0.0, drift, t);
let expected = 0.5 * drift * t * t;
assert!((tie - expected).abs() < 1e-18, "tie {tie:.6e}");
// and the linear frequency term:
let tie2 = deterministic_tie(1e-13, 0.0, t);
assert!((tie2 - 1e-13 * t).abs() < 1e-22);
}
// ── Range mapping: 1 ns → ~0.2998 m ───────────────────────────────────────
#[test]
fn one_ns_is_about_thirty_cm() {
let r = phase_to_range_m(1e-9);
assert!((r - 0.299_792_458).abs() < 1e-9, "1 ns → {r} m");
}
// ── Edge cases ────────────────────────────────────────────────────────────
#[test]
fn zero_threshold_and_noiseless_clock() {
assert_eq!(holdover_seconds(1e-20, 0.0, 0.0, 0.0), 0.0);
assert_eq!(holdover_seconds(0.0, 0.0, 0.0, 1e-9), f64::INFINITY);
}
}