use crate::report::ClockRun;
use crate::run::run_clock;
use crate::scenario::Scenario;
use crate::types::ModelSpec;
use serde::Serialize;
const GOLDEN: u64 = 0x9e37_79b9_7f4a_7c15;
#[derive(Clone, Copy, Debug, Serialize)]
pub struct Stat {
pub mean: f64,
pub p05: f64,
pub p50: f64,
pub p95: f64,
}
#[derive(Clone, Copy, Debug, Serialize)]
pub struct BandPoint {
pub t: f64,
pub p05_ns: f64,
pub p50_ns: f64,
pub p95_ns: f64,
}
#[derive(Clone, Debug, Serialize)]
pub struct EnsembleClock {
pub spec: ModelSpec,
pub holdover_s: Stat,
pub timing_p95_ns: Stat,
pub timing_rms_ns: Stat,
pub availability: Stat,
pub integrity: Option<Stat>,
pub security: Option<f64>,
pub band: Vec<BandPoint>,
}
#[derive(Clone, Debug, Serialize)]
pub struct EnsembleResult {
pub schema_version: String,
pub engine_version: String,
pub scenario_hash: String,
pub seed: u64,
pub runs: usize,
pub threshold_ns: f64,
pub quantum: EnsembleClock,
pub classical: EnsembleClock,
}
fn percentile(sorted: &[f64], p: f64) -> f64 {
if sorted.is_empty() {
return 0.0;
}
let idx = (((sorted.len() - 1) as f64) * p).round() as usize;
sorted[idx]
}
fn stat(mut v: Vec<f64>) -> Stat {
let mean = if v.is_empty() {
0.0
} else {
v.iter().sum::<f64>() / v.len() as f64
};
v.sort_by(f64::total_cmp);
Stat {
mean,
p05: percentile(&v, 0.05),
p50: percentile(&v, 0.50),
p95: percentile(&v, 0.95),
}
}
fn aggregate(runs: &[ClockRun]) -> EnsembleClock {
let spec = runs[0].spec.clone();
let holdover_s = stat(runs.iter().map(|r| r.fom.holdover_s).collect());
let timing_p95_ns = stat(runs.iter().map(|r| r.fom.timing_p95_ns).collect());
let timing_rms_ns = stat(runs.iter().map(|r| r.fom.timing_rms_ns).collect());
let availability = stat(runs.iter().map(|r| r.fom.availability).collect());
let integrity = if runs.iter().all(|r| r.fom.integrity.is_some()) {
Some(stat(
runs.iter().map(|r| r.fom.integrity.unwrap()).collect(),
))
} else {
None
};
let security = runs[0].fom.security;
let n_samples = runs[0].series.len();
let mut band = Vec::with_capacity(n_samples);
for i in 0..n_samples {
let t = runs[0].series[i].t;
let mut errs: Vec<f64> = runs.iter().map(|r| r.series[i].error_ns.abs()).collect();
errs.sort_by(f64::total_cmp);
band.push(BandPoint {
t,
p05_ns: percentile(&errs, 0.05),
p50_ns: percentile(&errs, 0.50),
p95_ns: percentile(&errs, 0.95),
});
}
EnsembleClock {
spec,
holdover_s,
timing_p95_ns,
timing_rms_ns,
availability,
integrity,
security,
band,
}
}
pub fn run_ensemble(scn: &Scenario) -> EnsembleResult {
let runs = scn.runs.max(1);
let mut q = Vec::with_capacity(runs);
let mut c = Vec::with_capacity(runs);
for k in 0..runs {
let s = scn.seed.wrapping_add(k as u64);
q.push(run_clock(scn, &scn.clock_quantum, s));
c.push(run_clock(scn, &scn.clock_classical, s.wrapping_add(GOLDEN)));
}
EnsembleResult {
schema_version: "0.1".into(),
engine_version: env!("CARGO_PKG_VERSION").into(),
scenario_hash: crate::report::hash_scenario(scn),
seed: scn.seed,
runs,
threshold_ns: scn.threshold_ns,
quantum: aggregate(&q),
classical: aggregate(&c),
}
}
pub fn to_svg(result: &EnsembleResult) -> String {
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 bands = [&result.classical.band, &result.quantum.band];
let t_max = bands
.iter()
.flat_map(|b| b.iter())
.map(|p| p.t)
.fold(1.0_f64, f64::max);
let mut y_max = result.threshold_ns * 1.3;
for b in bands {
for p in b.iter() {
y_max = y_max.max(p.p95_ns);
}
}
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 band_poly = |b: &[BandPoint]| {
let mut pts: Vec<String> = b
.iter()
.map(|p| format!("{:.1},{:.1}", xof(p.t), yof(p.p05_ns)))
.collect();
for p in b.iter().rev() {
pts.push(format!("{:.1},{:.1}", xof(p.t), yof(p.p95_ns)));
}
pts.join(" ")
};
let median_line = |b: &[BandPoint]| {
b.iter()
.map(|p| format!("{:.1},{:.1}", xof(p.t), yof(p.p50_ns)))
.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\">"
));
svg.push_str(&format!(
"<rect width=\"{w:.0}\" height=\"{h:.0}\" fill=\"white\"/>"
));
svg.push_str(&format!(
"<text x=\"{:.0}\" y=\"18\" font-size=\"15\" font-weight=\"bold\">Clock holdover: timing-error confidence band ({} runs)</text>",
ml, result.runs
));
svg.push_str(&crate::chart::y_axis(
ml,
mt,
pw,
ph,
y_max,
"timing error (ns)",
));
svg.push_str(&format!(
"<polygon fill=\"#c0392b\" fill-opacity=\"0.18\" stroke=\"none\" points=\"{}\"/>",
band_poly(&result.classical.band)
));
svg.push_str(&format!(
"<polygon fill=\"#2471a3\" fill-opacity=\"0.18\" stroke=\"none\" points=\"{}\"/>",
band_poly(&result.quantum.band)
));
svg.push_str(&format!(
"<line x1=\"{ml:.0}\" y1=\"{mt:.0}\" x2=\"{ml:.0}\" y2=\"{axis_y:.0}\" stroke=\"#888\"/>"
));
svg.push_str(&format!(
"<line x1=\"{ml:.0}\" y1=\"{axis_y:.0}\" x2=\"{:.0}\" y2=\"{axis_y:.0}\" stroke=\"#888\"/>",
ml + pw
));
svg.push_str(&format!(
"<line x1=\"{ml:.0}\" y1=\"{thr_y:.1}\" x2=\"{:.0}\" y2=\"{thr_y:.1}\" stroke=\"#d33\" stroke-dasharray=\"6 4\"/>",
ml + pw
));
svg.push_str(&format!(
"<text x=\"{:.0}\" y=\"{:.1}\" fill=\"#d33\">spec {:.0} ns</text>",
ml + 4.0,
thr_y - 4.0,
result.threshold_ns
));
svg.push_str(&format!(
"<polyline fill=\"none\" stroke=\"#c0392b\" stroke-width=\"2\" points=\"{}\"/>",
median_line(&result.classical.band)
));
svg.push_str(&format!(
"<polyline fill=\"none\" stroke=\"#2471a3\" stroke-width=\"2\" points=\"{}\"/>",
median_line(&result.quantum.band)
));
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=\"#c0392b\">classical: {} (median, 5-95% band)</text>",
ml + 10.0,
result.classical.spec.id
));
svg.push_str(&format!(
"<text x=\"{:.0}\" y=\"60\" fill=\"#2471a3\">quantum: {} (median, 5-95% band)</text>",
ml + 10.0,
result.quantum.spec.id
));
svg.push_str("</svg>");
svg
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scenario::*;
fn demo(runs: usize) -> Scenario {
Scenario {
seed: 7,
threshold_ns: 50.0,
runs,
time: TimeCfg {
step_s: 30.0,
duration_s: 3600.0,
},
gnss: GnssTimeline {
windows: vec![
GnssWindow {
t0: 0.0,
t1: 600.0,
state: GnssState::Nominal,
},
GnssWindow {
t0: 600.0,
t1: 3600.0,
state: GnssState::Denied,
},
],
},
clock_quantum: ClockCfg {
id: "optical".into(),
provenance: "demo".into(),
y0: 1e-13,
q_wf: 1e-26,
q_rw: 1e-34,
drift: 0.0,
flicker_floor: 0.0,
},
clock_classical: ClockCfg {
id: "csac".into(),
provenance: "demo".into(),
y0: 1e-11,
q_wf: 1e-22,
q_rw: 1e-30,
drift: 0.0,
flicker_floor: 0.0,
},
}
}
#[test]
fn percentile_is_nearest_rank() {
let v = vec![1.0, 2.0, 3.0, 4.0, 5.0];
assert_eq!(percentile(&v, 0.0), 1.0);
assert_eq!(percentile(&v, 0.5), 3.0); assert_eq!(percentile(&v, 1.0), 5.0);
}
#[test]
fn single_run_collapses_the_spread() {
let r = run_ensemble(&demo(1));
let s = r.quantum.holdover_s;
assert_eq!(s.mean, s.p05);
assert_eq!(s.p05, s.p50);
assert_eq!(s.p50, s.p95);
assert_eq!(r.runs, 1);
}
#[test]
fn band_is_ordered_and_full_length() {
let r = run_ensemble(&demo(16));
assert_eq!(r.classical.band.len(), 3600 / 30 + 1);
for p in &r.classical.band {
assert!(p.p05_ns <= p.p50_ns + 1e-9, "p05<=p50 at t={}", p.t);
assert!(p.p50_ns <= p.p95_ns + 1e-9, "p50<=p95 at t={}", p.t);
}
let last = r.quantum.band.len() - 1;
assert!(r.quantum.band[last].p50_ns <= r.classical.band[last].p50_ns);
}
#[test]
fn ensemble_is_reproducible() {
let a = run_ensemble(&demo(8));
let b = run_ensemble(&demo(8));
assert_eq!(a.quantum.holdover_s.mean, b.quantum.holdover_s.mean);
assert_eq!(a.classical.timing_p95_ns.p95, b.classical.timing_p95_ns.p95);
}
#[test]
fn svg_has_bands_and_medians() {
let r = run_ensemble(&demo(8));
let svg = to_svg(&r);
assert!(svg.starts_with("<svg"));
assert_eq!(svg.matches("<polygon").count(), 2);
assert_eq!(svg.matches("<polyline").count(), 2);
assert!(svg.contains("8 runs"));
assert!(svg.ends_with("</svg>"));
}
}