use crate::raim::{araim_integrity_risk, araim_protection_level, normal_quantile, AraimMode};
#[derive(Clone, Copy, Debug)]
pub struct TimeSource {
pub sigma_s: f64,
pub bias_s: f64,
pub p_fault: f64,
}
#[derive(Clone, Copy, Debug)]
pub struct ScalarTpl {
pub pl_s: f64,
pub driving_subset: Option<usize>,
pub ir_achieved: f64,
}
fn fused_variance(vars: impl Iterator<Item = f64>) -> f64 {
let inv: f64 = vars.map(|v| 1.0 / v).sum();
if inv <= 0.0 {
f64::INFINITY
} else {
1.0 / inv
}
}
pub fn scalar_tpl(sources: &[TimeSource], ir_budget: f64, p_fa: f64) -> Option<ScalarTpl> {
if sources.is_empty() {
return None;
}
let n = sources.len();
let sigma_ff2 = fused_variance(sources.iter().map(|s| s.sigma_s.powi(2)));
let w_ff: Vec<f64> = sources
.iter()
.map(|s| sigma_ff2 / s.sigma_s.powi(2))
.collect();
let bias_ff: f64 = sources
.iter()
.zip(&w_ff)
.map(|(s, w)| w * s.bias_s.abs())
.sum();
let k_fa = if n >= 2 {
normal_quantile(1.0 - p_fa / (2.0 * n as f64))
} else {
normal_quantile(1.0 - p_fa / 2.0)
};
let mut modes = Vec::with_capacity(n + 1);
modes.push(AraimMode {
p_fault: 1.0 - sources.iter().map(|s| s.p_fault).sum::<f64>(),
threshold_m: 0.0,
bias_m: bias_ff,
sigma_m: sigma_ff2.sqrt(),
});
let mut driving_subset = None;
if n >= 2 {
let mut worst_metric = f64::NEG_INFINITY;
for j in 0..n {
let sub_var = fused_variance(
sources
.iter()
.enumerate()
.filter(|(i, _)| *i != j)
.map(|(_, s)| s.sigma_s.powi(2)),
);
let sig_ss = (sub_var - sigma_ff2).max(0.0).sqrt();
let bias_sub: f64 = sources
.iter()
.enumerate()
.filter(|(i, _)| *i != j)
.map(|(_, s)| (sub_var / s.sigma_s.powi(2)) * s.bias_s.abs())
.sum();
let threshold = k_fa * sig_ss;
let metric = bias_sub + threshold + sub_var.sqrt();
if metric > worst_metric {
worst_metric = metric;
driving_subset = Some(j);
}
modes.push(AraimMode {
p_fault: sources[j].p_fault,
threshold_m: threshold,
bias_m: bias_sub,
sigma_m: sub_var.sqrt(),
});
}
}
let budget_one_sided = ir_budget / 2.0;
let pl_s = araim_protection_level(&modes, budget_one_sided);
let ir_achieved = araim_integrity_risk(pl_s, &modes);
Some(ScalarTpl {
pl_s,
driving_subset,
ir_achieved,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::raim::normal_quantile;
#[test]
fn single_source_is_rank_one_no_subset() {
let s = [TimeSource {
sigma_s: 2e-9,
bias_s: 1e-9,
p_fault: 1e-4,
}];
let r = scalar_tpl(&s, 1e-5, 1e-3).unwrap();
let expected = 1e-9 + 2e-9 * normal_quantile(1.0 - (1e-5 / 2.0) / (1.0 - 1e-4));
assert!(
(r.pl_s - expected).abs() < 1e-11,
"N=1 PL must equal bias + sigma·Phi^-1(1 - ir/2/(1-p_fault))"
);
assert!(r.pl_s > s[0].bias_s);
assert!(r.driving_subset.is_none() || r.driving_subset == Some(0));
}
#[test]
fn two_equal_sources_fuse_then_exclude() {
let s = [
TimeSource {
sigma_s: 3e-9,
bias_s: 0.0,
p_fault: 1e-4,
},
TimeSource {
sigma_s: 3e-9,
bias_s: 0.0,
p_fault: 1e-4,
},
];
let r = scalar_tpl(&s, 1e-5, 1e-3).unwrap();
assert!(r.pl_s.is_finite() && r.pl_s > 0.0);
assert!(r.driving_subset.is_some());
let sigma_ff = super::fused_variance([9e-18f64, 9e-18f64].into_iter()).sqrt();
assert!(
(sigma_ff - 3e-9 / 2f64.sqrt()).abs() < 1e-15,
"fault-free fusion of two equal sources must be sigma/sqrt(2)"
);
let sigma_sub = super::fused_variance([9e-18f64].into_iter()).sqrt();
assert!(
(sigma_sub - 3e-9).abs() < 1e-15,
"single-remaining-source subset must recover sigma"
);
}
#[test]
fn bias_dominates_the_pl_not_geometry() {
let base = [
TimeSource {
sigma_s: 2e-9,
bias_s: 0.0,
p_fault: 1e-4,
},
TimeSource {
sigma_s: 2e-9,
bias_s: 0.0,
p_fault: 1e-4,
},
TimeSource {
sigma_s: 2e-9,
bias_s: 0.0,
p_fault: 1e-4,
},
];
let mut biased = base;
biased[0].bias_s = 20e-9;
let r0 = scalar_tpl(&base, 1e-5, 1e-3).unwrap();
let r1 = scalar_tpl(&biased, 1e-5, 1e-3).unwrap();
assert!(r1.pl_s > r0.pl_s + 5e-9);
}
#[test]
fn achieved_ir_meets_budget() {
let s = [
TimeSource {
sigma_s: 2e-9,
bias_s: 1e-9,
p_fault: 1e-4,
},
TimeSource {
sigma_s: 4e-9,
bias_s: 2e-9,
p_fault: 1e-4,
},
];
let ir = 1e-5;
let r = scalar_tpl(&s, ir, 1e-3).unwrap();
assert!(r.ir_achieved <= ir / 2.0 + 1e-12);
}
#[test]
fn empty_sources_return_none() {
assert!(scalar_tpl(&[], 1e-5, 1e-3).is_none());
}
}