use crate::fom::{FoMScores, Sample};
use crate::scenario::Scenario;
use crate::types::ModelSpec;
use serde::Serialize;
use sha2::{Digest, Sha256};
pub const UNITS: &[crate::field_schema::FieldUnit] = {
use crate::field_schema::{FieldUnit, ProvenanceClass::*};
macro_rules! clock_units {
($($s:literal),+ $(,)?) => {
&[
FieldUnit {
path: "seed",
unit: "1",
provenance: Input,
definition: "RNG seed of the quantum realisation; the classical \
realisation runs at seed + 0x9e3779b97f4a7c15",
},
FieldUnit {
path: "threshold_ns",
unit: "ns",
provenance: Input,
definition: "timing spec: a sample whose absolute timing error is at \
or below this is in spec",
},
FieldUnit {
path: "eci_track[][]",
unit: "km",
provenance: Computed,
definition: "one Earth-centred-inertial [x, y, z] component of the \
propagated user spacecraft at one sampled time; emitted \
only by the orbit pack, absent from a clock run",
},
$(
FieldUnit {
path: concat!($s, ".spec.params.y0"),
unit: "1",
provenance: Input,
definition: "deterministic fractional-frequency offset of the clock \
model (dimensionless df/f)",
},
FieldUnit {
path: concat!($s, ".spec.params.q_wf"),
unit: "s^2/s",
provenance: Input,
definition: "white-FM process-noise intensity: the clock phase gains \
variance q_wf*dt over a step dt, so q_wf is numerically \
sigma_y(1 s)^2",
},
FieldUnit {
path: concat!($s, ".spec.params.q_rw"),
unit: "1/s",
provenance: Input,
definition: "random-walk-FM process-noise intensity: the fractional \
frequency gains variance q_rw*dt over a step dt",
},
FieldUnit {
path: concat!($s, ".spec.params.drift"),
unit: "1/s",
provenance: Input,
definition: "linear fractional-frequency aging rate: the deterministic \
frequency is y0 + drift*t",
},
FieldUnit {
path: concat!($s, ".spec.params.flicker_floor"),
unit: "1",
provenance: Input,
definition: "flat flicker-FM Allan-deviation floor sigma_y of the clock \
model; null when no flicker component is configured",
},
FieldUnit {
path: concat!($s, ".series[].t"),
unit: "s",
provenance: Computed,
definition: "sample time on the uniform run grid, i*time.step_s",
},
FieldUnit {
path: concat!($s, ".series[].error_ns"),
unit: "ns",
provenance: Computed,
definition: "timing error at t: the true clock phase minus the coasted \
prediction; zero while GNSS is nominal",
},
FieldUnit {
path: concat!($s, ".fom.timing_rms_ns"),
unit: "ns",
provenance: Computed,
definition: "root mean square of the timing error over the outage \
samples",
},
FieldUnit {
path: concat!($s, ".fom.timing_p95_ns"),
unit: "ns",
provenance: Computed,
definition: "95th percentile (nearest-rank) of the absolute timing \
error over the outage samples",
},
FieldUnit {
path: concat!($s, ".fom.holdover_s"),
unit: "s",
provenance: Computed,
definition: "worst-case (shortest) in-spec coast across the outage \
segments; grid-bounded at time.step_s",
},
FieldUnit {
path: concat!($s, ".fom.resilience_slope_ns_per_s"),
unit: "ns/s",
provenance: Computed,
definition: "least-squares slope of the absolute timing error against \
time over the outage",
},
FieldUnit {
path: concat!($s, ".fom.availability"),
unit: "1",
provenance: Computed,
definition: "fraction of all samples in the run whose absolute timing \
error is within threshold_ns",
},
FieldUnit {
path: concat!($s, ".fom.integrity"),
unit: "1",
provenance: Computed,
definition: "filter self-consistency: the fraction of outage samples \
whose true error stays inside the Kalman 3-sigma phase \
bound; not an aviation HPL/VPL/RAIM integrity figure",
},
FieldUnit {
path: concat!($s, ".fom.security"),
unit: "1",
provenance: Computed,
definition: "analytic spoof-detectability bound from clock stability, \
clamp(1 - smallest detectable offset / threshold_ns, 0, \
1); meaningful only against a configured attack, and not \
a multi-satellite RAIM detector",
},
FieldUnit {
path: concat!($s, ".adev_curve[].tau_s"),
unit: "s",
provenance: Computed,
definition: "averaging time of this overlapping-Allan point, \
m*time.step_s at octave-spaced m",
},
FieldUnit {
path: concat!($s, ".adev_curve[].adev"),
unit: "1",
provenance: Computed,
definition: "overlapping Allan deviation sigma_y(tau) of the clock \
phase — a dimensionless fractional-frequency stability",
},
FieldUnit {
path: concat!($s, ".adev_curve[].n_samples"),
unit: "count",
provenance: Computed,
definition: "number of overlapping second differences averaged into \
this point",
},
FieldUnit {
path: concat!($s, ".adev_curve[].edf"),
unit: "1",
provenance: Computed,
definition: "effective degrees of freedom of this point for the \
power-law noise type identified over the whole record",
},
FieldUnit {
path: concat!($s, ".adev_curve[].ci_lo"),
unit: "1",
provenance: Computed,
definition: "lower end of the chi-squared 95% confidence interval on \
adev at this tau",
},
FieldUnit {
path: concat!($s, ".adev_curve[].ci_hi"),
unit: "1",
provenance: Computed,
definition: "upper end of the chi-squared 95% confidence interval on \
adev at this tau",
},
FieldUnit {
path: concat!($s, ".filter_health.nis_mean"),
unit: "1",
provenance: InternalConsistency,
definition: "pooled mean normalised innovation squared over the \
consistency ensemble; a matched filter gives 1",
},
FieldUnit {
path: concat!($s, ".filter_health.nis_chi2_lower_95"),
unit: "1",
provenance: ClosedForm,
definition: "lower 95% chi-squared acceptance bound on the NIS mean, \
chi2_0.025(K)/K over K pooled innovations",
},
FieldUnit {
path: concat!($s, ".filter_health.nis_chi2_upper_95"),
unit: "1",
provenance: ClosedForm,
definition: "upper 95% chi-squared acceptance bound on the NIS mean, \
chi2_0.975(K)/K over K pooled innovations",
},
FieldUnit {
path: concat!($s, ".filter_health.nees_mean"),
unit: "1",
provenance: InternalConsistency,
definition: "pooled mean normalised estimation error squared; a \
matched two-state filter gives 2",
},
FieldUnit {
path: concat!($s, ".filter_health.nees_chi2_lower_95"),
unit: "1",
provenance: ClosedForm,
definition: "lower 95% chi-squared acceptance bound on the NEES mean, \
chi2_0.025(2*seeds)/seeds",
},
FieldUnit {
path: concat!($s, ".filter_health.nees_chi2_upper_95"),
unit: "1",
provenance: ClosedForm,
definition: "upper 95% chi-squared acceptance bound on the NEES mean, \
chi2_0.975(2*seeds)/seeds",
},
)+
]
};
}
clock_units!("quantum", "classical")
};
#[derive(Clone, Debug, Serialize)]
pub struct ClockRun {
pub spec: ModelSpec,
pub series: Vec<Sample>,
pub fom: FoMScores,
#[serde(default)]
pub adev_curve: Vec<crate::allan::AdevPoint>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filter_health: Option<crate::filter_health::FilterHealth>,
}
#[derive(Clone, Debug, Serialize)]
pub struct StudyMeta {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub study_title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generated_utc: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disclaimer: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct RunResult {
pub schema_version: String,
pub engine_version: String,
pub scenario_hash: String,
pub seed: u64,
pub threshold_ns: f64,
pub quantum: ClockRun,
pub classical: ClockRun,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub eci_track: Option<Vec<[f64; 3]>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meta: Option<StudyMeta>,
}
pub fn slugify(name: &str) -> String {
let mut slug = String::with_capacity(name.len());
let mut prev_hyphen = false;
for ch in name.chars() {
if ch.is_ascii_alphanumeric() {
slug.push(ch.to_ascii_lowercase());
prev_hyphen = false;
} else if !slug.is_empty() && !prev_hyphen {
slug.push('-');
prev_hyphen = true;
}
}
while slug.ends_with('-') {
slug.pop();
}
if slug.is_empty() {
"study".to_string()
} else {
slug
}
}
pub fn study_meta_with_title(study_title: &str, generated_utc: &str) -> StudyMeta {
StudyMeta {
study_title: Some(study_title.to_string()),
generated_utc: Some(generated_utc.to_string()),
author: None,
disclaimer: None,
}
}
pub fn hash_scenario(scn: &Scenario) -> String {
let canonical = serde_json::to_string(scn).unwrap_or_default();
let mut h = Sha256::new();
h.update(canonical.as_bytes());
hex::encode(h.finalize())
}
pub fn to_svg(result: &RunResult) -> String {
use crate::fom::Sample;
let (w, h) = (820.0_f64, 420.0_f64);
let (ml, mr, mt, mb) = (70.0_f64, 20.0_f64, 30.0_f64, 50.0_f64);
let pw = w - ml - mr;
let ph = h - mt - mb;
let c = &result.classical.series;
let q = &result.quantum.series;
let t_max = c.iter().map(|s| s.t).fold(1.0_f64, f64::max);
let mut y_max = result.threshold_ns * 1.3;
for s in c.iter().chain(q.iter()) {
y_max = y_max.max(s.error_ns.abs());
}
if y_max <= 0.0 {
y_max = 1.0;
}
let xof = |t: f64| ml + (t / t_max) * pw;
let yof = |e: f64| mt + ph - (e.min(y_max) / y_max) * ph;
let points = |series: &[Sample]| {
series
.iter()
.map(|s| format!("{:.1},{:.1}", xof(s.t), yof(s.error_ns.abs())))
.collect::<Vec<_>>()
.join(" ")
};
let thr_y = yof(result.threshold_ns);
let axis_y = mt + ph;
let mut svg = String::new();
svg.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w:.0}\" height=\"{h:.0}\" font-family=\"sans-serif\" font-size=\"12\" fill=\"#bcb3a3\">"
));
svg.push_str(&format!(
"<rect width=\"{w:.0}\" height=\"{h:.0}\" fill=\"#0c0b08\"/>"
));
svg.push_str(&format!(
"<text x=\"{:.0}\" y=\"18\" font-size=\"15\" font-weight=\"bold\">Clock holdover: timing error during GNSS outage</text>",
ml
));
svg.push_str(&crate::chart::y_axis(
ml,
mt,
pw,
ph,
y_max,
"timing error (ns)",
));
svg.push_str(&format!(
"<line x1=\"{ml:.0}\" y1=\"{mt:.0}\" x2=\"{ml:.0}\" y2=\"{axis_y:.0}\" stroke=\"#342c21\"/>"
));
svg.push_str(&format!(
"<line x1=\"{ml:.0}\" y1=\"{axis_y:.0}\" x2=\"{:.0}\" y2=\"{axis_y:.0}\" stroke=\"#342c21\"/>",
ml + pw
));
svg.push_str(&format!(
"<line x1=\"{ml:.0}\" y1=\"{thr_y:.1}\" x2=\"{:.0}\" y2=\"{thr_y:.1}\" stroke=\"#e5645a\" stroke-dasharray=\"6 4\"/>",
ml + pw
));
svg.push_str(&format!(
"<text x=\"{:.0}\" y=\"{:.1}\" fill=\"#e5645a\">spec {:.0} ns</text>",
ml + 4.0,
thr_y - 4.0,
result.threshold_ns
));
svg.push_str(&format!(
"<polyline fill=\"none\" stroke=\"#d2925e\" stroke-width=\"2\" points=\"{}\"/>",
points(c)
));
svg.push_str(&format!(
"<polyline fill=\"none\" stroke=\"#e0bd84\" stroke-width=\"2\" points=\"{}\"/>",
points(q)
));
svg.push_str(&format!(
"<text x=\"{:.0}\" y=\"{:.0}\" text-anchor=\"middle\">time (s)</text>",
ml + pw / 2.0,
h - 12.0
));
svg.push_str(&format!(
"<text x=\"{:.0}\" y=\"44\" fill=\"#d2925e\">classical: {}</text>",
ml + 10.0,
result.classical.spec.id
));
svg.push_str(&format!(
"<text x=\"{:.0}\" y=\"60\" fill=\"#e0bd84\">quantum: {}</text>",
ml + 10.0,
result.quantum.spec.id
));
svg.push_str("</svg>");
svg
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scenario::*;
fn demo() -> Scenario {
Scenario {
seed: 1,
threshold_ns: 100.0,
runs: 1,
time: TimeCfg {
step_s: 10.0,
duration_s: 60.0,
},
gnss: GnssTimeline {
windows: vec![
GnssWindow {
t0: 0.0,
t1: 30.0,
state: GnssState::Nominal,
},
GnssWindow {
t0: 30.0,
t1: 60.0,
state: GnssState::Denied,
},
],
},
clock_quantum: ClockCfg {
id: "q".into(),
provenance: "d".into(),
y0: 1e-13,
q_wf: 1e-26,
q_rw: 1e-32,
drift: 0.0,
flicker_floor: 0.0,
},
clock_classical: ClockCfg {
id: "c".into(),
provenance: "d".into(),
y0: 1e-11,
q_wf: 1e-24,
q_rw: 1e-30,
drift: 0.0,
flicker_floor: 0.0,
},
}
}
#[test]
fn slugify_makes_filesystem_friendly_base_names() {
assert_eq!(slugify("My Study"), "my-study");
assert_eq!(slugify(" Trailing / Slashes -- "), "trailing-slashes");
assert_eq!(slugify("Q1 2026: Holdover (v2)"), "q1-2026-holdover-v2");
assert_eq!(slugify("already-slug"), "already-slug");
assert_eq!(slugify("***"), "study");
assert_eq!(slugify(""), "study");
}
#[test]
fn study_meta_with_title_sets_title_and_passed_in_stamp() {
let m = study_meta_with_title("My Study", "2026-06-23T00:00:00Z");
assert_eq!(m.study_title.as_deref(), Some("My Study"));
assert_eq!(m.generated_utc.as_deref(), Some("2026-06-23T00:00:00Z"));
assert_eq!(m.author, None);
assert_eq!(m.disclaimer, None);
}
#[test]
fn scenario_hash_is_deterministic_and_sensitive() {
let a = hash_scenario(&demo());
let b = hash_scenario(&demo());
assert_eq!(a, b);
assert_eq!(a.len(), 64);
let mut other = demo();
other.seed = 2;
assert_ne!(a, hash_scenario(&other));
}
}
#[cfg(test)]
mod svg_tests {
use super::*;
use crate::fom::{FoMScores, Sample};
use crate::scenario::GnssState::Denied;
use crate::types::ModelSpec;
fn run_of(id: &str, errs: &[f64]) -> ClockRun {
let series = errs
.iter()
.enumerate()
.map(|(i, &e)| Sample {
t: i as f64,
error_ns: e,
gnss: Denied,
})
.collect();
ClockRun {
spec: ModelSpec {
id: id.into(),
kind: "clock".into(),
provenance: "x".into(),
params: serde_json::json!({}),
},
series,
fom: FoMScores {
timing_rms_ns: 0.0,
timing_p95_ns: 0.0,
holdover_s: 0.0,
resilience_slope_ns_per_s: 0.0,
availability: 1.0,
integrity: None,
security: None,
},
adev_curve: vec![],
filter_health: None,
}
}
#[test]
fn to_svg_produces_valid_chart() {
let r = RunResult {
schema_version: crate::interchange::SCHEMA_VERSION.into(),
engine_version: "test".into(),
scenario_hash: "abc".into(),
seed: 1,
threshold_ns: 20.0,
quantum: run_of("optical", &[0.0, 0.0, 0.1]),
classical: run_of("csac", &[0.0, 15.0, 40.0]),
eci_track: None,
meta: None,
};
let svg = to_svg(&r);
assert!(svg.starts_with("<svg"));
assert_eq!(svg.matches("<polyline").count(), 2);
assert!(svg.contains("spec 20 ns"));
assert!(svg.ends_with("</svg>"));
}
}