use super::tightly_coupled::Sat;
use super::tightly_coupled17::{self, TightlyCoupled17};
use crate::clock_state::q_from_allan;
use crate::detection::chi2_inv_cdf;
use crate::inertial::quantum_imu::CaiAccelerometer;
use crate::inertial::{AccelCfg, ImuKind};
use crate::scenario::{GnssState, GnssTimeline, TimeCfg};
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use rand_distr::{Distribution, Normal};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
const C_M_S: f64 = 299_792_458.0;
const NEES_STATES: [usize; 8] = [0, 1, 2, 3, 4, 5, 15, 16];
const G_M_S2: f64 = 9.81;
const R_EARTH_M: f64 = 6.378_137e6;
fn default_sigma_pr() -> f64 {
1.0
}
fn default_sigma_rr() -> f64 {
0.05
}
fn default_consistency_seeds() -> usize {
48
}
fn default_q_factor() -> f64 {
1.0
}
fn default_r_factor() -> f64 {
1.0
}
fn default_speed() -> f64 {
100.0
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ClockAllanCfg {
pub id: String,
pub provenance: String,
pub white_fm_adev_1s: f64,
#[serde(default)]
pub rw_fm_level: f64,
}
impl ClockAllanCfg {
pub fn psds(&self) -> (f64, f64) {
let (q_wf, q_rw, _q_drift) = q_from_allan(self.white_fm_adev_1s, self.rw_fm_level, 0.0);
(q_wf, q_rw)
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct HybridUkfScenario {
pub seed: u64,
pub time: TimeCfg,
pub gnss: GnssTimeline,
pub accel: AccelCfg,
pub clock: ClockAllanCfg,
#[serde(default)]
pub residual_accel_bias_m_s2: f64,
#[serde(default = "default_speed")]
pub speed_m_s: f64,
#[serde(default = "default_sigma_pr")]
pub sigma_pr_m: f64,
#[serde(default = "default_sigma_rr")]
pub sigma_rr_mps: f64,
#[serde(default = "default_consistency_seeds")]
pub consistency_seeds: usize,
#[serde(default = "default_q_factor")]
pub q_factor: f64,
#[serde(default = "default_r_factor")]
pub r_factor: f64,
}
#[derive(Clone, Debug, Serialize, PartialEq)]
pub struct ConsistencyFoM {
pub nis_mean: f64,
pub nis_dof: usize,
pub nis_chi2_lower_95: f64,
pub nis_chi2_upper_95: f64,
pub nees_mean: f64,
pub nees_dof: usize,
pub nees_chi2_lower_95: f64,
pub nees_chi2_upper_95: f64,
pub consistent: bool,
pub seeds: usize,
}
#[derive(Clone, Debug, Serialize, PartialEq)]
pub struct CoastFoM {
pub aided_pos_rms_m: f64,
pub coast_end_pos_rms_m: f64,
pub coast_duration_s: f64,
}
#[derive(Clone, Debug, Serialize)]
pub struct HybridUkfResult {
pub schema_version: String,
pub engine_version: String,
pub scenario_hash: String,
pub seed: u64,
pub effective_q_va: f64,
pub quantum_cai: bool,
pub clock_q_wf: f64,
pub clock_q_rw: f64,
pub consistency: ConsistencyFoM,
pub coast: CoastFoM,
pub modelled_note: String,
}
fn sats() -> Vec<Sat> {
let r = 2.6e7;
let dirs: [[f64; 3]; 6] = [
[0.9, 0.3, 0.3],
[0.8, -0.4, 0.45],
[0.85, 0.1, -0.5],
[0.7, 0.5, -0.5],
[0.95, -0.2, -0.24],
[0.75, -0.5, 0.43],
];
dirs.iter()
.map(|d| {
let n = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
Sat {
pos: [r * d[0] / n, r * d[1] / n, r * d[2] / n],
vel: [0.0, 0.0, 0.0],
}
})
.collect()
}
fn truth_state(t: f64, speed: f64, ba: f64, clock_b: f64, clock_d: f64) -> Vec<f64> {
let mut x = vec![0.0; tightly_coupled17::N];
x[0] = R_EARTH_M;
x[1] = speed * t;
x[4] = speed; x[9] = ba; x[15] = clock_b; x[16] = clock_d; x
}
fn diag(vals: &[f64]) -> Vec<Vec<f64>> {
let n = vals.len();
let mut m = vec![vec![0.0; n]; n];
for (i, &v) in vals.iter().enumerate() {
m[i][i] = v;
}
m
}
fn resolve_imu(cfg: &AccelCfg) -> (f64, bool, Option<CaiAccelerometer>) {
match cfg.kind() {
ImuKind::QuantumCai(cai) => (cfg.effective_q_va(), true, Some(cai)),
ImuKind::Classical => (cfg.effective_q_va(), false, None),
}
}
fn build_q(q_va: f64, q_wf_phase: f64, q_rw_phase: f64, dt: f64, q_factor: f64) -> Vec<Vec<f64>> {
let mut qd = vec![1e-12; tightly_coupled17::N];
for k in 0..3 {
qd[3 + k] = q_va * dt; qd[9 + k] = 1e-10; qd[6 + k] = 1e-12; qd[12 + k] = 1e-16; }
let mut q = diag(&qd);
let c2 = C_M_S * C_M_S;
let q00 = (q_wf_phase * dt + q_rw_phase * dt * dt * dt / 3.0) * c2;
let q01 = (q_rw_phase * dt * dt / 2.0) * c2;
let q11 = (q_rw_phase * dt) * c2;
q[15][15] = q00.max(1e-30);
q[15][16] = q01;
q[16][15] = q01;
q[16][16] = q11.max(1e-30);
for row in q.iter_mut() {
for v in row.iter_mut() {
*v *= q_factor;
}
}
q
}
fn p0() -> Vec<Vec<f64>> {
diag(&[
1e2, 1e2, 1e2, 1.0, 1.0, 1.0, 1e-6, 1e-6, 1e-6, 1e-2, 1e-2, 1e-2, 1e-10, 1e-10, 1e-10, 1e2, 1.0, ])
}
fn cholesky(p: &[Vec<f64>]) -> Vec<Vec<f64>> {
let n = p.len();
let mut l = vec![vec![0.0; n]; n];
for i in 0..n {
for j in 0..=i {
let dot: f64 = (0..j).map(|k| l[i][k] * l[j][k]).sum();
if i == j {
l[i][j] = (p[i][i] - dot).max(0.0).sqrt();
} else if l[j][j] > 0.0 {
l[i][j] = (p[i][j] - dot) / l[j][j];
}
}
}
l
}
struct SeedRun {
nis_sum: f64,
nis_count: usize,
nees: Option<f64>,
aided_err_m: f64,
coast_err_m: f64,
}
fn run_one_seed(scn: &HybridUkfScenario, q_va: f64, clock: (f64, f64), seed: u64) -> SeedRun {
let dt = scn.time.step_s;
let n = (scn.time.duration_s / dt).round() as usize;
let sats = sats();
let gravity = [-G_M_S2, 0.0, 0.0];
let (q_wf_p, q_rw_p) = clock;
let q_truth = build_q(q_va, q_wf_p, q_rw_p, dt, 1.0);
let q_filter = build_q(q_va, q_wf_p, q_rw_p, dt, scn.q_factor);
let lq = cholesky(&q_truth);
let p0m = p0();
let l0 = cholesky(&p0m);
let ba = scn.residual_accel_bias_m_s2;
let speed = scn.speed_m_s;
let mut rng = ChaCha8Rng::seed_from_u64(seed);
let n01 = Normal::new(0.0, 1.0).unwrap();
let n_pr = Normal::new(0.0, scn.sigma_pr_m.max(1e-9)).unwrap();
let n_rr = Normal::new(0.0, scn.sigma_rr_mps.max(1e-9)).unwrap();
let nominal0 = truth_state(0.0, speed, ba, 0.0, 0.0);
let z0: Vec<f64> = (0..tightly_coupled17::N)
.map(|_| n01.sample(&mut rng))
.collect();
let mut x_true: Vec<f64> = nominal0
.iter()
.enumerate()
.map(|(i, &nom)| nom + (0..=i).map(|k| l0[i][k] * z0[k]).sum::<f64>())
.collect();
let mut nav = TightlyCoupled17::new(nominal0.clone(), p0m, q_filter, gravity);
let mut nis_sum = 0.0;
let mut nis_count = 0usize;
let mut aided_err_m = 0.0;
let mut coast_err_m = 0.0;
let mut aided_nees: Option<f64> = None;
for i in 0..=n {
let t = i as f64 * dt;
if i > 0 {
let w: Vec<f64> = (0..tightly_coupled17::N)
.map(|_| n01.sample(&mut rng))
.collect();
let mut nx = vec![0.0; tightly_coupled17::N];
for k in 0..3 {
nx[k] = x_true[k] + x_true[3 + k] * dt;
nx[3 + k] = x_true[3 + k];
nx[6 + k] = x_true[6 + k];
nx[9 + k] = x_true[9 + k];
nx[12 + k] = x_true[12 + k];
}
nx[15] = x_true[15] + x_true[16] * dt; nx[16] = x_true[16];
for r in 0..tightly_coupled17::N {
let noise: f64 = (0..=r).map(|cc| lq[r][cc] * w[cc]).sum();
nx[r] += noise;
}
x_true = nx;
let f_b = [G_M_S2 + ba, 0.0, 0.0];
nav.propagate_imu(dt, f_b, [0.0; 3]);
}
let state = scn.gnss.state_at(t);
if state == GnssState::Nominal {
let pr: Vec<f64> = sats
.iter()
.map(|s| tightly_coupled17::pseudorange(&x_true, s) + n_pr.sample(&mut rng))
.collect();
let rr: Vec<f64> = sats
.iter()
.map(|s| tightly_coupled17::range_rate(&x_true, s) + n_rr.sample(&mut rng))
.collect();
let rf = scn.r_factor.max(1e-12).sqrt();
if let Some(nis) =
nav.update_gnss_nis(&sats, &pr, &rr, scn.sigma_pr_m * rf, scn.sigma_rr_mps * rf)
{
nis_sum += nis;
nis_count += 1;
}
aided_err_m = nav.position_error([x_true[0], x_true[1], x_true[2]]);
aided_nees = nav.nees_subset(&x_true, &NEES_STATES);
} else {
coast_err_m = nav.position_error([x_true[0], x_true[1], x_true[2]]);
}
}
SeedRun {
nis_sum,
nis_count,
nees: aided_nees,
aided_err_m,
coast_err_m,
}
}
pub fn run_hybrid_ukf(scn: &HybridUkfScenario) -> HybridUkfResult {
let (q_va, quantum_cai, _cai) = resolve_imu(&scn.accel);
let (q_wf_p, q_rw_p) = scn.clock.psds();
let seeds = scn.consistency_seeds.max(1);
let mut nis_sum = 0.0;
let mut nis_count = 0usize;
let mut nees_sum = 0.0;
let mut nees_n = 0usize;
let mut aided_sum = 0.0;
let mut coast_sum = 0.0;
for s in 0..seeds {
let seed = scn
.seed
.wrapping_add((0x9E37_79B9_7F4A_7C15u64).wrapping_mul(s as u64 + 1));
let run = run_one_seed(scn, q_va, (q_wf_p, q_rw_p), seed);
nis_sum += run.nis_sum;
nis_count += run.nis_count;
if let Some(v) = run.nees {
nees_sum += v;
nees_n += 1;
}
aided_sum += run.aided_err_m;
coast_sum += run.coast_err_m;
}
let nis_mean = if nis_count > 0 {
nis_sum / nis_count as f64
} else {
0.0
};
let nees_mean = if nees_n > 0 {
nees_sum / nees_n as f64
} else {
0.0
};
let m = sats().len() * 2;
let runs_nis = if nis_count > 0 { seeds } else { 0 } as f64;
let (nis_lo, nis_hi) = if runs_nis > 0.0 {
let dof_nis = runs_nis * m as f64;
(
chi2_inv_cdf(0.025, dof_nis) / runs_nis,
chi2_inv_cdf(0.975, dof_nis) / runs_nis,
)
} else {
(0.0, f64::INFINITY)
};
let nx = NEES_STATES.len() as f64;
let runs = nees_n.max(1) as f64;
let dof_nees = nx * runs;
let nees_lo = chi2_inv_cdf(0.025, dof_nees) / runs;
let nees_hi = chi2_inv_cdf(0.975, dof_nees) / runs;
let consistent =
nis_mean >= nis_lo && nis_mean <= nis_hi && nees_mean >= nees_lo && nees_mean <= nees_hi;
let coast_duration_s = coast_duration(&scn.gnss);
HybridUkfResult {
schema_version: crate::interchange::SCHEMA_VERSION.into(),
engine_version: env!("CARGO_PKG_VERSION").into(),
scenario_hash: hash(scn),
seed: scn.seed,
effective_q_va: q_va,
quantum_cai,
clock_q_wf: q_wf_p,
clock_q_rw: q_rw_p,
consistency: ConsistencyFoM {
nis_mean,
nis_dof: m,
nis_chi2_lower_95: nis_lo,
nis_chi2_upper_95: nis_hi,
nees_mean,
nees_dof: NEES_STATES.len(),
nees_chi2_lower_95: nees_lo,
nees_chi2_upper_95: nees_hi,
consistent,
seeds,
},
coast: CoastFoM {
aided_pos_rms_m: aided_sum / seeds as f64,
coast_end_pos_rms_m: coast_sum / seeds as f64,
coast_duration_s,
},
modelled_note:
"MODELLED SIMULATION. Filter self-consistency (NEES + innovation-whiteness) \
under bracketed CAI and clock noise inputs — a self-consistency statement, NOT a \
real-world accuracy guarantee. Not field/flight results; no TRL>3, no flight heritage, \
no external validation. CAI hardware is partner-owned."
.into(),
}
}
fn coast_duration(gnss: &GnssTimeline) -> f64 {
gnss.windows
.iter()
.filter(|w| w.state != GnssState::Nominal)
.map(|w| (w.t1 - w.t0).max(0.0))
.sum()
}
fn hash(scn: &HybridUkfScenario) -> String {
let c = serde_json::to_string(scn).expect("scenario serializes");
let mut h = Sha256::new();
h.update(c.as_bytes());
hex::encode(h.finalize())
}
pub fn to_svg(result: &HybridUkfResult) -> String {
let (w, h) = (820.0_f64, 420.0_f64);
let (ml, mr, mt, mb) = (90.0_f64, 30.0_f64, 60.0_f64, 60.0_f64);
let pw = w - ml - mr;
let ph = h - mt - mb;
let cons = &result.consistency;
let nis_target = cons.nis_dof.max(1) as f64;
let nees_target = cons.nees_dof.max(1) as f64;
let nis_n = cons.nis_mean / nis_target;
let nis_lo = cons.nis_chi2_lower_95 / nis_target;
let nis_hi = cons.nis_chi2_upper_95 / nis_target;
let nees_n = cons.nees_mean / nees_target;
let nees_lo = cons.nees_chi2_lower_95 / nees_target;
let nees_hi = cons.nees_chi2_upper_95 / nees_target;
let y_max = 2.0_f64
.max(nis_n * 1.2)
.max(nees_n * 1.2)
.max(nis_hi * 1.2)
.max(nees_hi * 1.2);
let yof = |v: f64| mt + ph - (v.min(y_max) / y_max) * ph;
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=\"{ml:.0}\" y=\"24\" font-size=\"15\" font-weight=\"bold\">17-state hybrid UKF — filter self-consistency (modelled)</text>"
));
svg.push_str(&format!(
"<text x=\"{ml:.0}\" y=\"42\" font-size=\"11\" fill=\"#8a8170\">NEES + innovation-whiteness vs 95% \u{03c7}\u{00b2} bands; 1.0 = consistent. Self-consistency, not accuracy.</text>"
));
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
));
let one_y = yof(1.0);
svg.push_str(&format!(
"<line x1=\"{ml:.0}\" y1=\"{one_y:.1}\" x2=\"{:.0}\" y2=\"{one_y:.1}\" stroke=\"#5ec5b5\" stroke-dasharray=\"6 4\"/>",
ml + pw
));
svg.push_str(&format!(
"<text x=\"{:.0}\" y=\"{:.1}\" fill=\"#5ec5b5\">target 1.0</text>",
ml + 4.0,
one_y - 4.0
));
let groups = [
("NIS (whiteness)", nis_n, nis_lo, nis_hi),
("NEES", nees_n, nees_lo, nees_hi),
];
let slot = pw / groups.len() as f64;
let bw = slot * 0.34;
for (i, (label, val, lo, hi)) in groups.iter().enumerate() {
let cx = ml + slot * (i as f64 + 0.5);
let bx = cx - bw / 2.0;
let by = yof(*val);
let bh = axis_y - by;
let inside = *val >= *lo && *val <= *hi;
let fill = if inside { "#e0bd84" } else { "#e5645a" };
svg.push_str(&format!(
"<rect x=\"{bx:.1}\" y=\"{by:.1}\" width=\"{bw:.1}\" height=\"{bh:.1}\" fill=\"{fill}\"/>"
));
let (yl, yh) = (yof(*lo), yof(*hi));
svg.push_str(&format!(
"<line x1=\"{cx:.1}\" y1=\"{yh:.1}\" x2=\"{cx:.1}\" y2=\"{yl:.1}\" stroke=\"#cfc6b4\" stroke-width=\"2\"/>"
));
svg.push_str(&format!(
"<line x1=\"{:.1}\" y1=\"{yh:.1}\" x2=\"{:.1}\" y2=\"{yh:.1}\" stroke=\"#cfc6b4\" stroke-width=\"2\"/>",
cx - 8.0,
cx + 8.0
));
svg.push_str(&format!(
"<line x1=\"{:.1}\" y1=\"{yl:.1}\" x2=\"{:.1}\" y2=\"{yl:.1}\" stroke=\"#cfc6b4\" stroke-width=\"2\"/>",
cx - 8.0,
cx + 8.0
));
svg.push_str(&format!(
"<text x=\"{cx:.1}\" y=\"{:.1}\" text-anchor=\"middle\">{label}</text>",
axis_y + 18.0
));
svg.push_str(&format!(
"<text x=\"{cx:.1}\" y=\"{:.1}\" text-anchor=\"middle\" fill=\"#cfc6b4\">{val:.2}\u{00d7}</text>",
by - 6.0
));
}
let verdict = if cons.consistent {
("CONSISTENT", "#5ec5b5")
} else {
("INCONSISTENT", "#e5645a")
};
svg.push_str(&format!(
"<text x=\"{:.0}\" y=\"{:.0}\" text-anchor=\"end\" font-weight=\"bold\" fill=\"{}\">{}</text>",
ml + pw,
mt - 4.0,
verdict.1,
verdict.0
));
svg.push_str("</svg>");
svg
}
#[cfg(test)]
mod tests {
use super::*;
fn scenario() -> HybridUkfScenario {
toml::from_str(include_str!("../../scenarios/hybrid-ukf.toml"))
.expect("hybrid-ukf scenario parses")
}
fn fast_scenario() -> HybridUkfScenario {
let mut s = scenario();
s.consistency_seeds = 6;
s
}
#[test]
fn clock_q_engine_maps_allan_to_psds_by_hand() {
let cfg = ClockAllanCfg {
id: "x".into(),
provenance: "test".into(),
white_fm_adev_1s: 1e-12,
rw_fm_level: 1e-14,
};
let (q_wf, q_rw) = cfg.psds();
assert!((q_wf - 1e-24).abs() / 1e-24 < 1e-12, "q_wf = {q_wf}");
assert!((q_rw - 3e-28).abs() / 3e-28 < 1e-12, "q_rw = {q_rw}");
}
#[test]
fn build_q_clock_block_is_van_loan_in_range_units() {
let (q_wf, q_rw, dt) = (1e-24, 3e-28, 1.0);
let q = build_q(1e-9, q_wf, q_rw, dt, 1.0);
let c2 = C_M_S * C_M_S;
let want00 = (q_wf * dt + q_rw * dt * dt * dt / 3.0) * c2;
let want01 = (q_rw * dt * dt / 2.0) * c2;
let want11 = (q_rw * dt) * c2;
assert!(
(q[15][15] - want00).abs() / want00 < 1e-12,
"Q00 = {}",
q[15][15]
);
assert!(
(q[15][16] - want01).abs() / want01 < 1e-12,
"Q01 = {}",
q[15][16]
);
assert!(
(q[16][15] - q[15][16]).abs() < 1e-300,
"clock block symmetric"
);
assert!(
(q[16][16] - want11).abs() / want11 < 1e-12,
"Q11 = {}",
q[16][16]
);
for k in 0..3 {
assert!((q[3 + k][3 + k] - 1e-9 * dt).abs() / 1e-9 < 1e-9);
}
}
#[test]
fn cai_block_makes_q_va_physics_derived() {
let r = run_hybrid_ukf(&fast_scenario());
assert!(
r.quantum_cai,
"shipped scenario should resolve to a CAI sensor"
);
assert!(
r.effective_q_va > 0.0 && r.effective_q_va < 1e-8,
"derived q_va = {}",
r.effective_q_va
);
}
#[test]
fn matched_filter_is_self_consistent() {
let r = run_hybrid_ukf(&scenario());
let c = &r.consistency;
assert!(
c.consistent,
"matched filter flagged inconsistent: NIS {} in [{}, {}], NEES {} in [{}, {}]",
c.nis_mean,
c.nis_chi2_lower_95,
c.nis_chi2_upper_95,
c.nees_mean,
c.nees_chi2_lower_95,
c.nees_chi2_upper_95
);
assert_eq!(c.nis_dof, 12);
assert!(
c.nis_mean > 0.7 * c.nis_dof as f64 && c.nis_mean < 1.3 * c.nis_dof as f64,
"NIS mean {} far from target {}",
c.nis_mean,
c.nis_dof
);
assert_eq!(c.nees_dof, 8);
assert!(
c.nees_mean > 5.0 && c.nees_mean < 12.0,
"NEES mean {} far from 8",
c.nees_mean
);
assert!(c.nees_chi2_lower_95 < 8.0 && c.nees_chi2_upper_95 > 8.0);
}
#[test]
fn mistuned_filter_is_flagged_inconsistent() {
let mut under = fast_scenario();
under.r_factor = 0.04;
let r = run_hybrid_ukf(&under);
assert!(
!r.consistency.consistent,
"an over-confident (r_factor=0.04) filter must be flagged inconsistent: {:?}",
r.consistency
);
let matched = run_hybrid_ukf(&fast_scenario());
assert!(
r.consistency.nis_mean > matched.consistency.nis_mean,
"under-tuned NIS {} should exceed matched NIS {}",
r.consistency.nis_mean,
matched.consistency.nis_mean
);
let mut over = fast_scenario();
over.r_factor = 25.0;
let ro = run_hybrid_ukf(&over);
assert!(
!ro.consistency.consistent && ro.consistency.nis_mean < matched.consistency.nis_mean,
"over-tuned (r_factor=25) NIS {} should sit below matched {} and be rejected",
ro.consistency.nis_mean,
matched.consistency.nis_mean
);
}
#[test]
fn coast_is_cai_floor_bounded_and_aiding_converges() {
let r = run_hybrid_ukf(&fast_scenario());
assert!(
r.coast.aided_pos_rms_m < 20.0,
"GNSS-aided error should converge: {} m",
r.coast.aided_pos_rms_m
);
assert!(
r.coast.coast_duration_s > 0.0,
"scenario must have an outage"
);
assert!(
r.coast.coast_end_pos_rms_m.is_finite() && r.coast.coast_end_pos_rms_m < 1000.0,
"CAI-floor coast should stay bounded: {} m",
r.coast.coast_end_pos_rms_m
);
}
#[test]
fn run_is_bit_reproducible() {
let a = serde_json::to_string(&run_hybrid_ukf(&fast_scenario())).unwrap();
let b = serde_json::to_string(&run_hybrid_ukf(&fast_scenario())).unwrap();
assert_eq!(a, b);
}
#[test]
fn result_carries_the_modelled_honesty_label() {
let r = run_hybrid_ukf(&fast_scenario());
assert!(r.modelled_note.contains("MODELLED SIMULATION"));
assert!(r.modelled_note.contains("NOT a"));
assert!(r.modelled_note.to_lowercase().contains("partner-owned"));
}
#[test]
fn svg_is_self_contained() {
let svg = to_svg(&run_hybrid_ukf(&fast_scenario()));
assert!(svg.starts_with("<svg") && svg.trim_end().ends_with("</svg>"));
assert!(svg.contains("NEES") && svg.contains("whiteness"));
}
}