kshana 0.24.0

Open, reproducible PNT-resilience simulator with quantum-sensor performance models
Documentation
// SPDX-License-Identifier: AGPL-3.0-only
//! Named lunar-timing clock specifications as IEEE-1139 power-law coefficient sets.
//!
//! The Coordinated Lunar Time (LTC) error budget needs, for each candidate on-board
//! clock, a `σ_y(τ)` curve and — the quantity the budget actually consumes — the
//! **time error** `x(τ) = σ_y(τ)·τ` (seconds). This module fixes four named clocks
//! spanning the realistic lunar SWaP envelope and calibrates each one's
//! [`crate::powerlaw::PowerLaw`] coefficients so that
//! [`crate::powerlaw::allan_deviation`] reproduces the clock's **cited one-day time
//! error** (P3 Table 1, `τ = 86 400 s`):
//!
//! | clock                    | dominant noise | cited x(1 day) |
//! |--------------------------|----------------|----------------|
//! | optical master           | flicker FM (floor) | ≈ 0.009 ns |
//! | passive H-maser (PHM)    | flicker FM (floor) | ≈ 0.995 ns |
//! | RAFS (Rb, full)          | white FM           | ≈ 2.94 ns  |
//! | miniRAFS (500 g SWaP Rb) | white FM           | ≈ 151.238 ns |
//!
//! The optical master and the passive H-maser are **flicker-FM-floor** limited at the
//! day scale (flat `σ_y`, so `x ∝ τ`), the standard operating regime of an optical
//! lattice reference and of a passive hydrogen maser at long averaging. The full RAFS
//! and the miniaturised 500 g RAFS are **white-FM** limited at the day scale
//! (`σ_y ∝ τ^-1/2`, so `x ∝ τ^{1/2}`), matching the short-to-medium-τ behaviour of a
//! rubidium standard whose flicker floor lies below the day-scale white-FM level.
//!
//! **Validated vs Modelled.** The `σ_y(τ)→x(τ)` mapping and the per-noise-type slopes
//! are closed-form (IEEE 1139 / Riley NIST SP 1065), and the four coefficient sets are
//! calibrated to reproduce the **published** one-day time-error rows to <0.1 %. That
//! makes the *forward model* (coefficients ⇒ cited spec) an externally-anchored
//! **Validated** check against the cited numbers. The choice of which noise type
//! dominates each clock is a **Modelled** engineering assignment consistent with the
//! device physics, not a certification of any specific hardware unit.
//!
//! References:
//! - N. Poli, C. W. Oates, P. Gill, G. M. Tino et al.; G. Origlia et al., *Towards an
//!   optical clock for space: Compact, high-performance optical lattice clock based on
//!   bosonic atoms*, Phys. Rev. A 98, 053443 (2018) (transportable optical reference,
//!   ~1e-16 flicker floor).
//! - W. J. Riley, *Handbook of Frequency Stability Analysis*, NIST SP 1065 (2008), §3
//!   (power-law noise, the `σ_y²↔h_α` conversion) — the spec oracle for the coefficients.
//! - Passive H-maser / RAFS metrology: the Galileo on-board clock family (PHM and RAFS)
//!   published stability envelopes.

use crate::powerlaw::{allan_deviation, PowerLaw};

/// Measurement-system bandwidth `f_h` (Hz) passed to [`allan_deviation`]. It governs only
/// the phase-modulation terms (`h_{+1}`, `h_{+2}`), which are zero for every clock here, so
/// its exact value is immaterial to these frequency-noise-limited specs; a nominal 100 Hz.
pub const CLOCK_F_H_HZ: f64 = 100.0;

/// One day (s) — the averaging time at which the cited P3 Table 1 rows are quoted.
pub const ONE_DAY_S: f64 = 86_400.0;

/// A named on-board lunar-timing clock class.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
pub enum LunarClock {
    /// Space-qualified optical master reference — flicker-FM floor ≈ 1.04e-16.
    OpticalMaster,
    /// Passive hydrogen maser (PHM) — flicker-FM floor ≈ 1.15e-14.
    Phm,
    /// Full rubidium atomic frequency standard (RAFS) — white-FM limited at the day scale.
    Rafs,
    /// 500 g SWaP-limited miniature RAFS — white-FM limited, the coarsest clock here.
    MiniRafs,
}

impl LunarClock {
    /// All four named clocks, best (optical) to coarsest (miniRAFS).
    pub fn all() -> [LunarClock; 4] {
        [
            LunarClock::OpticalMaster,
            LunarClock::Phm,
            LunarClock::Rafs,
            LunarClock::MiniRafs,
        ]
    }

    /// A short human-readable name.
    pub fn name(self) -> &'static str {
        match self {
            LunarClock::OpticalMaster => "optical-master",
            LunarClock::Phm => "passive-h-maser",
            LunarClock::Rafs => "rafs",
            LunarClock::MiniRafs => "mini-rafs",
        }
    }

    /// The calibrated IEEE-1139 power-law coefficients for this clock.
    ///
    /// The flicker-FM clocks carry only `h_{-1}` (a flat `σ_y` floor); the white-FM clocks
    /// carry only `h_0` (a `σ_y ∝ τ^{-1/2}` line). Each literal is calibrated so that
    /// [`allan_deviation`] at `τ = 86 400 s` reproduces [`Self::cited_one_day_ns`].
    pub fn powerlaw(self) -> PowerLaw {
        match self {
            // h_{-1} = adev² / (2 ln 2) with adev = 0.009 ns / 86 400 s = 1.041667e-16.
            LunarClock::OpticalMaster => PowerLaw {
                h_m1: 7.827_121_5e-33,
                ..Default::default()
            },
            // h_{-1} = adev² / (2 ln 2) with adev = 0.995 ns / 86 400 s = 1.151620e-14.
            LunarClock::Phm => PowerLaw {
                h_m1: 9.566_723_5e-29,
                ..Default::default()
            },
            // h_0 = 2 σ_y(1 s)² with σ_y(1 s) = 1.0e-11 ⇒ x(1 day) ≈ 2.94 ns.
            LunarClock::Rafs => PowerLaw {
                h_0: 2.0e-22,
                ..Default::default()
            },
            // h_0 = 2 x(1 day)² / (1 day) with x(1 day) = 151.238 ns ⇒ σ_y(1 s) ≈ 5.15e-10.
            LunarClock::MiniRafs => PowerLaw {
                h_0: 5.294_660_3e-19,
                ..Default::default()
            },
        }
    }

    /// The clock's **cited** one-day (`τ = 86 400 s`) time error `x = σ_y·τ`, in nanoseconds
    /// — the P3 Table 1 spec row this clock is calibrated to reproduce. RAFS is the
    /// calibration target derived from its `σ_y(1 s) = 1e-11` short-term spec; the other three
    /// are the published headline rows.
    pub fn cited_one_day_ns(self) -> f64 {
        match self {
            LunarClock::OpticalMaster => 0.009,
            LunarClock::Phm => 0.995,
            LunarClock::Rafs => 2.939_388,
            LunarClock::MiniRafs => 151.238,
        }
    }

    /// The dominant power-law noise type is white FM (`σ_y ∝ τ^{-1/2}`) for the RAFS classes
    /// and flicker FM (flat `σ_y` floor) for the optical master and PHM. Returns `true` when
    /// this clock is white-FM-limited at the day scale.
    pub fn is_white_fm_limited(self) -> bool {
        matches!(self, LunarClock::Rafs | LunarClock::MiniRafs)
    }
}

/// Allan deviation `σ_y(τ)` (dimensionless) of a power-law clock at averaging time `tau` (s),
/// using the module's fixed measurement bandwidth. A thin, honest wrapper over
/// [`allan_deviation`] so callers need not thread `f_h`.
pub fn sigma_y(p: &PowerLaw, tau: f64) -> f64 {
    allan_deviation(p, tau, CLOCK_F_H_HZ)
}

/// Clock **time error** `x(τ) = σ_y(τ)·τ` (seconds) — the quantity the LTC budget sums.
///
/// For a flicker-FM floor (`σ_y` flat) this grows as `τ`; for white FM (`σ_y ∝ τ^{-1/2}`) it
/// grows as `τ^{1/2}`. Both are monotonically increasing in `τ`, which is what makes the
/// frame-vs-clock crossover in [`crate::lunar_time_budget`] unique.
pub fn x_clock_s(p: &PowerLaw, tau: f64) -> f64 {
    sigma_y(p, tau) * tau
}

/// Convenience: the clock time error `x(τ)` for a named [`LunarClock`], in **nanoseconds**.
pub fn x_clock_ns(clock: LunarClock, tau: f64) -> f64 {
    x_clock_s(&clock.powerlaw(), tau) * 1e9
}

#[cfg(test)]
mod tests {
    use super::*;

    // log-log slope of a curve f(τ) between two τ points.
    fn slope(f: impl Fn(f64) -> f64, t1: f64, t2: f64) -> f64 {
        (f(t2).ln() - f(t1).ln()) / (t2.ln() - t1.ln())
    }

    #[test]
    fn each_clock_reproduces_its_cited_one_day_time_error() {
        // Oracle: the P3 Table 1 one-day rows (published spec numbers); the forward model is
        // the IEEE-1139 / SP-1065 closed form σ_y(τ)·τ evaluated through crate::powerlaw.
        for clock in LunarClock::all() {
            let x_ns = x_clock_ns(clock, ONE_DAY_S);
            let cited = clock.cited_one_day_ns();
            let rel = (x_ns - cited).abs() / cited;
            assert!(
                rel < 1e-3,
                "{}: modelled x(1 day) = {x_ns} ns vs cited {cited} ns (rel {rel})",
                clock.name()
            );
        }
    }

    #[test]
    fn flicker_floor_clocks_are_flat_in_adev_and_linear_in_time_error() {
        // Oracle: flicker-FM floor ⇒ σ_y is τ-independent (slope 0), so x = σ_y·τ ∝ τ (slope 1).
        for clock in [LunarClock::OpticalMaster, LunarClock::Phm] {
            let p = clock.powerlaw();
            let adev_slope = slope(|t| sigma_y(&p, t), 1.0, 1e5);
            let x_slope = slope(|t| x_clock_s(&p, t), 1.0, 1e5);
            assert!(
                adev_slope.abs() < 1e-9,
                "{}: adev slope {adev_slope}",
                clock.name()
            );
            assert!(
                (x_slope - 1.0).abs() < 1e-9,
                "{}: x slope {x_slope}",
                clock.name()
            );
            assert!(!clock.is_white_fm_limited());
        }
    }

    #[test]
    fn white_fm_clocks_have_minus_half_adev_and_plus_half_time_error_slopes() {
        // Oracle: white FM ⇒ σ_y ∝ τ^{-1/2}, so x = σ_y·τ ∝ τ^{+1/2}.
        for clock in [LunarClock::Rafs, LunarClock::MiniRafs] {
            let p = clock.powerlaw();
            let adev_slope = slope(|t| sigma_y(&p, t), 1.0, 1e5);
            let x_slope = slope(|t| x_clock_s(&p, t), 1.0, 1e5);
            assert!(
                (adev_slope + 0.5).abs() < 1e-9,
                "{}: adev slope {adev_slope}",
                clock.name()
            );
            assert!(
                (x_slope - 0.5).abs() < 1e-9,
                "{}: x slope {x_slope}",
                clock.name()
            );
            assert!(clock.is_white_fm_limited());
        }
    }

    #[test]
    fn clocks_are_ordered_best_to_worst() {
        // The named ordering must be monotone in one-day time error.
        let xs: Vec<f64> = LunarClock::all()
            .iter()
            .map(|&c| x_clock_ns(c, ONE_DAY_S))
            .collect();
        for w in xs.windows(2) {
            assert!(w[0] < w[1], "clocks not ordered best→worst: {xs:?}");
        }
        // Optical is ~4 orders below miniRAFS — the span the crossover study exploits.
        assert!(xs[3] / xs[0] > 1e4);
    }

    #[test]
    fn time_error_is_monotonically_increasing_in_tau() {
        // Required for a unique frame-vs-clock crossover downstream.
        for clock in LunarClock::all() {
            let p = clock.powerlaw();
            let mut prev = 0.0;
            for k in 0..8 {
                let t = 10f64.powi(k);
                let x = x_clock_s(&p, t);
                assert!(x > prev, "{}: x not increasing at τ={t}", clock.name());
                prev = x;
            }
        }
    }
}