use crate::scenario::GnssState;
use crate::types::Seconds;
use serde::Serialize;
pub(crate) fn worst_case_holdover(series: &[(Seconds, bool, bool)]) -> Seconds {
let mut worst: Option<Seconds> = None;
let mut seg_start: Option<Seconds> = None;
let mut seg_breach: Option<Seconds> = None;
let mut seg_last = 0.0;
let close =
|start: Seconds, breach: Option<Seconds>, last: Seconds, worst: &mut Option<Seconds>| {
let h = breach.map_or(last - start, |b| b - start);
*worst = Some(worst.map_or(h, |w: Seconds| w.min(h)));
};
for &(t, outage, breached) in series {
if outage {
if seg_start.is_none() {
seg_start = Some(t);
seg_breach = None;
}
seg_last = t;
if breached && seg_breach.is_none() {
seg_breach = Some(t);
}
} else if let Some(start) = seg_start.take() {
close(start, seg_breach, seg_last, &mut worst);
seg_breach = None;
}
}
if let Some(start) = seg_start {
close(start, seg_breach, seg_last, &mut worst);
}
worst.unwrap_or(0.0)
}
#[derive(Clone, Debug, Serialize)]
pub struct Sample {
pub t: Seconds,
pub error_ns: f64,
pub gnss: GnssState,
}
#[derive(Clone, Debug, Serialize)]
pub struct FoMScores {
pub timing_rms_ns: f64,
pub timing_p95_ns: f64,
pub holdover_s: f64,
pub resilience_slope_ns_per_s: f64,
pub availability: f64,
pub integrity: Option<f64>,
pub security: Option<f64>,
}
#[derive(Clone, Debug, Serialize)]
pub struct PositioningFom {
pub cep_m: f64,
pub sep_m: f64,
pub drms2_m: f64,
pub hpl_m: f64,
}
pub fn positioning_performance() -> Result<PositioningFom, String> {
Err(
"Position-domain FoM (CEP, SEP, 2DRMS, HPL) requires a 2-D/3-D INS fusion \
solution and a multi-measurement integrity monitor — not available in this \
release. The inertial pack currently reports a single-axis pos_rms_m only."
.to_string(),
)
}
pub fn score(samples: &[Sample], threshold_ns: f64) -> FoMScores {
let n = samples.len().max(1) as f64;
let within = samples
.iter()
.filter(|s| s.error_ns.abs() <= threshold_ns)
.count();
let availability = within as f64 / n;
let outage: Vec<&Sample> = samples
.iter()
.filter(|s| s.gnss != GnssState::Nominal)
.collect();
if outage.is_empty() {
return FoMScores {
timing_rms_ns: 0.0,
timing_p95_ns: 0.0,
holdover_s: 0.0,
resilience_slope_ns_per_s: 0.0,
availability,
integrity: None,
security: None,
};
}
let m = outage.len() as f64;
let sumsq: f64 = outage.iter().map(|s| s.error_ns * s.error_ns).sum();
let timing_rms_ns = (sumsq / m).sqrt();
let mut abs: Vec<f64> = outage.iter().map(|s| s.error_ns.abs()).collect();
abs.sort_by(|a, b| a.total_cmp(b));
let idx = (((abs.len().saturating_sub(1)) as f64) * 0.95).round() as usize;
let timing_p95_ns = abs.get(idx).copied().unwrap_or(0.0);
let segs: Vec<(Seconds, bool, bool)> = samples
.iter()
.map(|s| {
(
s.t,
s.gnss != GnssState::Nominal,
s.error_ns.abs() > threshold_ns,
)
})
.collect();
let holdover_s = worst_case_holdover(&segs);
let mean_t = outage.iter().map(|s| s.t).sum::<f64>() / m;
let mean_y = outage.iter().map(|s| s.error_ns.abs()).sum::<f64>() / m;
let mut num = 0.0;
let mut den = 0.0;
for s in &outage {
num += (s.t - mean_t) * (s.error_ns.abs() - mean_y);
den += (s.t - mean_t) * (s.t - mean_t);
}
let resilience_slope_ns_per_s = if den > 0.0 { num / den } else { 0.0 };
FoMScores {
timing_rms_ns,
timing_p95_ns,
holdover_s,
resilience_slope_ns_per_s,
availability,
integrity: None,
security: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scenario::GnssState::Denied;
fn s(t: f64, e: f64) -> Sample {
Sample {
t,
error_ns: e,
gnss: Denied,
}
}
#[test]
fn hand_derived_scores() {
let samples = vec![s(0.0, 0.0), s(1.0, 100.0), s(2.0, 200.0)];
let f = score(&samples, 150.0);
assert!((f.timing_rms_ns - 129.0994).abs() < 1e-3);
assert_eq!(f.timing_p95_ns, 200.0);
assert!((f.availability - 2.0 / 3.0).abs() < 1e-9);
assert_eq!(f.holdover_s, 2.0);
assert!((f.resilience_slope_ns_per_s - 100.0).abs() < 1e-9);
assert!(f.integrity.is_none() && f.security.is_none());
}
#[test]
fn multi_window_holdover_is_worst_segment() {
let nominal = |t: f64, e: f64| Sample {
t,
error_ns: e,
gnss: GnssState::Nominal,
};
let samples = vec![
s(0.0, 0.0),
s(1.0, 0.0),
s(2.0, 200.0),
nominal(3.0, 0.0),
s(4.0, 0.0),
s(5.0, 200.0),
];
let f = score(&samples, 150.0);
assert_eq!(f.holdover_s, 1.0);
}
#[test]
fn unbreached_segment_reports_its_span() {
let nominal = |t: f64| Sample {
t,
error_ns: 0.0,
gnss: GnssState::Nominal,
};
let samples = vec![
s(0.0, 0.0), s(1.0, 10.0),
s(2.0, 20.0),
s(3.0, 30.0),
nominal(4.0),
s(5.0, 0.0), s(6.0, 500.0),
];
let f = score(&samples, 150.0);
assert_eq!(f.holdover_s, 1.0);
}
#[test]
fn worst_case_holdover_no_outage_is_zero() {
assert_eq!(
worst_case_holdover(&[(0.0, false, false), (1.0, false, false)]),
0.0
);
}
#[test]
fn positioning_performance_is_an_honest_stub() {
let r = positioning_performance();
assert!(r.is_err());
let msg = r.unwrap_err();
assert!(
msg.contains("CEP") && msg.contains("INS fusion"),
"stub message: {msg}"
);
}
}