use crate::frames::Vec3;
use crate::orbit::{
enu_basis, invert4, los_unit, visible_positions, visible_positions_labeled, ConstellationCfg,
Orbit, OrbitCfg, Propagator,
};
use crate::scenario::TimeCfg;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use rand_distr::{Distribution, Normal};
use serde::{Deserialize, Serialize};
fn ln_gamma(x: f64) -> f64 {
const G: f64 = 7.0;
const C: [f64; 9] = [
0.999_999_999_999_809_9,
676.520_368_121_885_1,
-1_259.139_216_722_402_8,
771.323_428_777_653_1,
-176.615_029_162_140_6,
12.507_343_278_686_905,
-0.138_571_095_265_720_12,
9.984_369_578_019_572e-6,
1.505_632_735_149_311_6e-7,
];
if x < 0.5 {
std::f64::consts::PI.ln() - (std::f64::consts::PI * x).sin().ln() - ln_gamma(1.0 - x)
} else {
let x = x - 1.0;
let mut a = C[0];
let t = x + G + 0.5;
for (i, &c) in C.iter().enumerate().skip(1) {
a += c / (x + i as f64);
}
0.5 * (2.0 * std::f64::consts::PI).ln() + (x + 0.5) * t.ln() - t + a.ln()
}
}
fn gammp(s: f64, x: f64) -> f64 {
if x <= 0.0 {
return 0.0;
}
let gln = ln_gamma(s);
if x < s + 1.0 {
let mut ap = s;
let mut sum = 1.0 / s;
let mut del = sum;
for _ in 0..300 {
ap += 1.0;
del *= x / ap;
sum += del;
if del.abs() < sum.abs() * 1e-15 {
break;
}
}
sum * (-x + s * x.ln() - gln).exp()
} else {
let tiny = 1e-300;
let mut b = x + 1.0 - s;
let mut c = 1.0 / tiny;
let mut d = 1.0 / b;
let mut h = d;
for i in 1..300 {
let an = -(i as f64) * (i as f64 - s);
b += 2.0;
d = an * d + b;
if d.abs() < tiny {
d = tiny;
}
c = b + an / c;
if c.abs() < tiny {
c = tiny;
}
d = 1.0 / d;
let del = d * c;
h *= del;
if (del - 1.0).abs() < 1e-15 {
break;
}
}
1.0 - (-x + s * x.ln() - gln).exp() * h
}
}
pub fn chi2_cdf(x: f64, k: f64) -> f64 {
gammp(k / 2.0, x / 2.0)
}
pub fn chi2_quantile(p: f64, k: f64) -> f64 {
if !k.is_finite() || k <= 0.0 || !p.is_finite() || p <= 0.0 {
return 0.0;
}
if p >= 1.0 {
return f64::INFINITY;
}
let (mut lo, mut hi) = (0.0_f64, k + 10.0 * k.sqrt() + 20.0);
while chi2_cdf(hi, k) < p {
hi *= 2.0;
}
for _ in 0..200 {
let mid = 0.5 * (lo + hi);
if chi2_cdf(mid, k) < p {
lo = mid;
} else {
hi = mid;
}
}
0.5 * (lo + hi)
}
pub fn normal_cdf(z: f64) -> f64 {
let x = z / std::f64::consts::SQRT_2;
let erf = if x >= 0.0 {
gammp(0.5, x * x)
} else {
-gammp(0.5, x * x)
};
0.5 * (1.0 + erf)
}
pub fn normal_quantile(p: f64) -> f64 {
if p <= 0.0 {
return f64::NEG_INFINITY;
}
if p >= 1.0 {
return f64::INFINITY;
}
let (mut lo, mut hi) = (-40.0_f64, 40.0_f64);
for _ in 0..200 {
let mid = 0.5 * (lo + hi);
if normal_cdf(mid) < p {
lo = mid;
} else {
hi = mid;
}
}
0.5 * (lo + hi)
}
pub fn noncentral_chi2_cdf(x: f64, k: f64, lambda: f64) -> f64 {
if lambda <= 0.0 {
return chi2_cdf(x, k);
}
let half = lambda / 2.0;
let mut term_ln = -half; let mut sum = 0.0;
for j in 0..600 {
if j > 0 {
term_ln += half.ln() - (j as f64).ln();
}
let weight = term_ln.exp();
sum += weight * chi2_cdf(x, k + 2.0 * j as f64);
if j as f64 > half && weight < 1e-14 {
break;
}
}
sum
}
pub fn pbias(t2: f64, dof: f64, p_md: f64) -> f64 {
let (mut lo, mut hi) = (0.0_f64, 10.0_f64);
while noncentral_chi2_cdf(t2, dof, hi) > p_md {
hi *= 2.0;
if hi > 1e9 {
break;
}
}
for _ in 0..200 {
let mid = 0.5 * (lo + hi);
if noncentral_chi2_cdf(t2, dof, mid) > p_md {
lo = mid;
} else {
hi = mid;
}
}
(0.5 * (lo + hi)).sqrt()
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RaimResult {
pub n_used: usize,
pub dof: usize,
pub sse: f64,
pub test_statistic: f64,
pub threshold: f64,
pub fault_detected: bool,
pub hpl_m: f64,
pub vpl_m: f64,
}
pub fn snapshot_raim(
user: Vec3,
sats: &[Vec3],
range_residual_m: &[f64],
sigma_m: f64,
p_fa: f64,
p_md: f64,
) -> Option<RaimResult> {
if sats.len() != range_residual_m.len() || sats.len() < 5 || sigma_m <= 0.0 {
return None;
}
let mut g: Vec<[f64; 4]> = Vec::with_capacity(sats.len());
for &s in sats {
let e = los_unit(user, s)?;
g.push([-e[0], -e[1], -e[2], 1.0]);
}
let n = g.len();
let dof = n - 4;
let mut gtg = [[0.0_f64; 4]; 4];
for row in &g {
for i in 0..4 {
for j in 0..4 {
gtg[i][j] += row[i] * row[j];
}
}
}
let a0 = invert4(gtg)?;
let s: Vec<[f64; 4]> = (0..n)
.map(|c| {
let mut col = [0.0_f64; 4];
for (i, ci) in col.iter_mut().enumerate() {
*ci = (0..4).map(|k| a0[i][k] * g[c][k]).sum();
}
col
})
.collect();
let mut x = [0.0_f64; 4];
for (c, &y) in range_residual_m.iter().enumerate() {
for i in 0..4 {
x[i] += s[c][i] * y;
}
}
let mut sse = 0.0;
for (c, &y) in range_residual_m.iter().enumerate() {
let pred: f64 = (0..4).map(|k| g[c][k] * x[k]).sum();
let r = y - pred;
sse += r * r;
}
let test_statistic = sse / (sigma_m * sigma_m);
let threshold = chi2_quantile(1.0 - p_fa, dof as f64);
let fault_detected = test_statistic > threshold;
let (east, north, up) = enu_basis(user)?;
let pb = pbias(threshold, dof as f64, p_md);
let mut slope_h_max = 0.0_f64;
let mut slope_v_max = 0.0_f64;
for c in 0..n {
let p_ii: f64 = (0..4).map(|k| g[c][k] * s[c][k]).sum();
let redundancy = (1.0 - p_ii).max(1e-12);
let pos = [s[c][0], s[c][1], s[c][2]];
let se = pos[0] * east[0] + pos[1] * east[1] + pos[2] * east[2];
let sn = pos[0] * north[0] + pos[1] * north[1] + pos[2] * north[2];
let su = pos[0] * up[0] + pos[1] * up[1] + pos[2] * up[2];
let slope_h = ((se * se + sn * sn) / redundancy).sqrt();
let slope_v = (su * su / redundancy).sqrt();
slope_h_max = slope_h_max.max(slope_h);
slope_v_max = slope_v_max.max(slope_v);
}
Some(RaimResult {
n_used: n,
dof,
sse,
test_statistic,
threshold,
fault_detected,
hpl_m: slope_h_max * pb * sigma_m,
vpl_m: slope_v_max * pb * sigma_m,
})
}
fn lsq_solution(g: &[[f64; 4]], y: &[f64]) -> Option<([f64; 4], [[f64; 4]; 4])> {
let mut gtg = [[0.0_f64; 4]; 4];
for row in g {
for i in 0..4 {
for j in 0..4 {
gtg[i][j] += row[i] * row[j];
}
}
}
let a = invert4(gtg)?;
let mut x = [0.0_f64; 4];
for (c, &yc) in y.iter().enumerate() {
for i in 0..4 {
let s_ic: f64 = (0..4).map(|k| a[i][k] * g[c][k]).sum();
x[i] += s_ic * yc;
}
}
Some((x, a))
}
fn axis_variance(a: &[[f64; 4]; 4], u: Vec3) -> f64 {
let mut v = 0.0;
for i in 0..3 {
for j in 0..3 {
v += u[i] * a[i][j] * u[j];
}
}
v.max(0.0)
}
fn axis_gain(a: &[[f64; 4]; 4], g_row: &[f64; 4], u: Vec3) -> f64 {
let mut s = 0.0;
for (i, &ui) in u.iter().enumerate() {
let s_i: f64 = (0..4).map(|k| a[i][k] * g_row[k]).sum();
s += ui * s_i;
}
s
}
fn axis_bias_sum(a: &[[f64; 4]; 4], g: &[[f64; 4]], u: Vec3, b_nom: f64) -> f64 {
if b_nom <= 0.0 {
return 0.0;
}
b_nom * g.iter().map(|row| axis_gain(a, row, u).abs()).sum::<f64>()
}
fn horiz_bias_sum(a: &[[f64; 4]; 4], g: &[[f64; 4]], east: Vec3, north: Vec3, b_nom: f64) -> f64 {
if b_nom <= 0.0 {
return 0.0;
}
b_nom
* g.iter()
.map(|row| {
let se = axis_gain(a, row, east);
let sn = axis_gain(a, row, north);
(se * se + sn * sn).sqrt()
})
.sum::<f64>()
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SolutionSeparationResult {
pub n_used: usize,
pub hpl_m: f64,
pub vpl_m: f64,
pub fault_detected: bool,
pub excluded_sv: Option<usize>,
pub max_normalized_separation: f64,
}
pub fn solution_separation_raim(
user: Vec3,
sats: &[Vec3],
range_residual_m: &[f64],
sigma_m: f64,
p_fa: f64,
p_md: f64,
) -> Option<SolutionSeparationResult> {
let n = sats.len();
if n != range_residual_m.len() || n < 6 || sigma_m <= 0.0 {
return None;
}
let mut g: Vec<[f64; 4]> = Vec::with_capacity(n);
for &s in sats {
let e = los_unit(user, s)?;
g.push([-e[0], -e[1], -e[2], 1.0]);
}
let (east, north, up) = enu_basis(user)?;
let (x0, a0) = lsq_solution(&g, range_residual_m)?;
let var0_v = axis_variance(&a0, up);
let var0_h = axis_variance(&a0, east) + axis_variance(&a0, north);
let k_fa = normal_quantile(1.0 - p_fa / 2.0);
let k_md = normal_quantile(1.0 - p_md);
let mut vpl = k_md * sigma_m * var0_v.sqrt();
let mut hpl = k_md * sigma_m * var0_h.sqrt();
let mut fault_detected = false;
let mut excluded_sv = None;
let mut max_norm_sep = 0.0_f64;
for k in 0..n {
let g_sub: Vec<[f64; 4]> = (0..n).filter(|&i| i != k).map(|i| g[i]).collect();
let y_sub: Vec<f64> = (0..n)
.filter(|&i| i != k)
.map(|i| range_residual_m[i])
.collect();
let (xk, ak) = match lsq_solution(&g_sub, &y_sub) {
Some(v) => v,
None => continue,
};
let dx = [xk[0] - x0[0], xk[1] - x0[1], xk[2] - x0[2]];
let sep_v = dx[0] * up[0] + dx[1] * up[1] + dx[2] * up[2];
let sep_e = dx[0] * east[0] + dx[1] * east[1] + dx[2] * east[2];
let sep_n = dx[0] * north[0] + dx[1] * north[1] + dx[2] * north[2];
let sep_h = (sep_e * sep_e + sep_n * sep_n).sqrt();
let vark_v = axis_variance(&ak, up);
let vark_h = axis_variance(&ak, east) + axis_variance(&ak, north);
let sig_ss_v = sigma_m * (vark_v - var0_v).max(0.0).sqrt();
let sig_ss_h = sigma_m * (vark_h - var0_h).max(0.0).sqrt();
let nrm_v = if sig_ss_v > 1e-9 {
sep_v.abs() / sig_ss_v
} else {
0.0
};
let nrm_h = if sig_ss_h > 1e-9 {
sep_h / sig_ss_h
} else {
0.0
};
let nrm = nrm_v.max(nrm_h);
if nrm > max_norm_sep {
max_norm_sep = nrm;
if nrm > k_fa {
fault_detected = true;
excluded_sv = Some(k);
}
}
let vpl_k = k_fa * sig_ss_v + k_md * sigma_m * vark_v.sqrt();
let hpl_k = k_fa * sig_ss_h + k_md * sigma_m * vark_h.sqrt();
vpl = vpl.max(vpl_k);
hpl = hpl.max(hpl_k);
}
Some(SolutionSeparationResult {
n_used: n,
hpl_m: hpl,
vpl_m: vpl,
fault_detected,
excluded_sv,
max_normalized_separation: max_norm_sep,
})
}
#[derive(Clone, Copy, Debug)]
pub struct AraimMode {
pub p_fault: f64,
pub threshold_m: f64,
pub bias_m: f64,
pub sigma_m: f64,
}
#[inline]
fn normal_q(z: f64) -> f64 {
1.0 - normal_cdf(z)
}
pub fn araim_integrity_risk(pl_m: f64, modes: &[AraimMode]) -> f64 {
modes
.iter()
.map(|m| {
if m.sigma_m <= 0.0 {
0.0
} else {
m.p_fault * normal_q((pl_m - m.bias_m - m.threshold_m) / m.sigma_m)
}
})
.sum()
}
pub fn araim_protection_level(modes: &[AraimMode], budget_one_sided: f64) -> f64 {
if modes.is_empty() || budget_one_sided <= 0.0 {
return f64::INFINITY;
}
let max_sigma = modes.iter().map(|m| m.sigma_m).fold(0.0, f64::max);
let max_bias_thresh = modes
.iter()
.map(|m| m.bias_m + m.threshold_m)
.fold(0.0, f64::max);
if max_sigma <= 0.0 {
return 0.0;
}
let ceiling = max_bias_thresh + 40.0 * max_sigma;
if araim_integrity_risk(0.0, modes) <= budget_one_sided {
return 0.0;
}
let (mut lo, mut hi) = (0.0_f64, ceiling);
for _ in 0..200 {
let mid = 0.5 * (lo + hi);
if araim_integrity_risk(mid, modes) > budget_one_sided {
lo = mid;
} else {
hi = mid;
}
}
0.5 * (lo + hi)
}
#[derive(Clone, Copy, Debug)]
pub struct FaultPriors {
pub p_sat: f64,
pub b_nom_m: f64,
}
#[derive(Clone, Copy, Debug)]
pub struct IntegrityBudget {
pub p_hmi_vert: f64,
pub p_hmi_horz: f64,
pub p_fa: f64,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct AraimResult {
pub n_used: usize,
pub hpl_m: f64,
pub vpl_m: f64,
pub p_hmi_vert: f64,
pub p_hmi_horz: f64,
pub fault_detected: bool,
pub excluded_sv: Option<usize>,
}
pub fn araim_raim(
user: Vec3,
sats: &[Vec3],
range_residual_m: &[f64],
sigma_m: f64,
priors: FaultPriors,
budget: IntegrityBudget,
) -> Option<AraimResult> {
let n = sats.len();
if n != range_residual_m.len() || n < 6 || sigma_m <= 0.0 {
return None;
}
let mut g: Vec<[f64; 4]> = Vec::with_capacity(n);
for &s in sats {
let e = los_unit(user, s)?;
g.push([-e[0], -e[1], -e[2], 1.0]);
}
let (east, north, up) = enu_basis(user)?;
let (x0, a0) = lsq_solution(&g, range_residual_m)?;
let var0_v = axis_variance(&a0, up);
let var0_h = axis_variance(&a0, east) + axis_variance(&a0, north);
let k_fa = normal_quantile(1.0 - budget.p_fa / (2.0 * n as f64));
let p_ff = (1.0 - n as f64 * priors.p_sat).max(0.0);
let mut modes_v = vec![AraimMode {
p_fault: p_ff,
threshold_m: 0.0,
bias_m: axis_bias_sum(&a0, &g, up, priors.b_nom_m),
sigma_m: sigma_m * var0_v.sqrt(),
}];
let mut modes_h = vec![AraimMode {
p_fault: p_ff,
threshold_m: 0.0,
bias_m: horiz_bias_sum(&a0, &g, east, north, priors.b_nom_m),
sigma_m: sigma_m * var0_h.sqrt(),
}];
let mut fault_detected = false;
let mut excluded_sv = None;
let mut max_norm_sep = 0.0_f64;
for k in 0..n {
let g_sub: Vec<[f64; 4]> = (0..n).filter(|&i| i != k).map(|i| g[i]).collect();
let y_sub: Vec<f64> = (0..n)
.filter(|&i| i != k)
.map(|i| range_residual_m[i])
.collect();
let (xk, ak) = match lsq_solution(&g_sub, &y_sub) {
Some(v) => v,
None => continue,
};
let dx = [xk[0] - x0[0], xk[1] - x0[1], xk[2] - x0[2]];
let sep_v = dx[0] * up[0] + dx[1] * up[1] + dx[2] * up[2];
let sep_e = dx[0] * east[0] + dx[1] * east[1] + dx[2] * east[2];
let sep_n = dx[0] * north[0] + dx[1] * north[1] + dx[2] * north[2];
let sep_h = (sep_e * sep_e + sep_n * sep_n).sqrt();
let vark_v = axis_variance(&ak, up);
let vark_h = axis_variance(&ak, east) + axis_variance(&ak, north);
let sig_ss_v = sigma_m * (vark_v - var0_v).max(0.0).sqrt();
let sig_ss_h = sigma_m * (vark_h - var0_h).max(0.0).sqrt();
let nrm_v = if sig_ss_v > 1e-9 {
sep_v.abs() / sig_ss_v
} else {
0.0
};
let nrm_h = if sig_ss_h > 1e-9 {
sep_h / sig_ss_h
} else {
0.0
};
let nrm = nrm_v.max(nrm_h);
if nrm > max_norm_sep {
max_norm_sep = nrm;
if nrm > k_fa {
fault_detected = true;
excluded_sv = Some(k);
}
}
modes_v.push(AraimMode {
p_fault: priors.p_sat,
threshold_m: k_fa * sig_ss_v,
bias_m: axis_bias_sum(&ak, &g_sub, up, priors.b_nom_m),
sigma_m: sigma_m * vark_v.sqrt(),
});
modes_h.push(AraimMode {
p_fault: priors.p_sat,
threshold_m: k_fa * sig_ss_h,
bias_m: horiz_bias_sum(&ak, &g_sub, east, north, priors.b_nom_m),
sigma_m: sigma_m * vark_h.sqrt(),
});
}
let vpl = araim_protection_level(&modes_v, budget.p_hmi_vert / 2.0);
let hpl = araim_protection_level(&modes_h, budget.p_hmi_horz / 2.0);
Some(AraimResult {
n_used: n,
hpl_m: hpl,
vpl_m: vpl,
p_hmi_vert: 2.0 * araim_integrity_risk(vpl, &modes_v),
p_hmi_horz: 2.0 * araim_integrity_risk(hpl, &modes_h),
fault_detected,
excluded_sv,
})
}
#[derive(Clone, Copy, Debug)]
pub struct DualFaultPriors {
pub p_sat: f64,
pub p_const: f64,
pub b_nom_m: f64,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct IntegritySupportMessage {
pub sigma_ure_m: f64,
pub sigma_ura_m: f64,
pub b_nom_m: f64,
pub p_sat: f64,
pub p_const: f64,
}
impl IntegritySupportMessage {
pub fn gps_galileo_reference() -> Self {
Self {
sigma_ure_m: 0.67,
sigma_ura_m: 0.75,
b_nom_m: 0.75,
p_sat: 1e-5,
p_const: 1e-4,
}
}
pub fn fault_priors(&self) -> FaultPriors {
FaultPriors {
p_sat: self.p_sat,
b_nom_m: self.b_nom_m,
}
}
pub fn dual_fault_priors(&self) -> DualFaultPriors {
DualFaultPriors {
p_sat: self.p_sat,
p_const: self.p_const,
b_nom_m: self.b_nom_m,
}
}
}
#[allow(clippy::too_many_arguments)]
fn araim_exclusion_mode(
g: &[[f64; 4]],
y: &[f64],
keep: impl Fn(usize) -> bool,
x0: &[f64; 4],
east: Vec3,
north: Vec3,
up: Vec3,
var0_v: f64,
var0_h: f64,
sigma_m: f64,
k_fa: f64,
p_fault: f64,
b_nom: f64,
min_keep: usize,
) -> Option<(AraimMode, AraimMode, f64)> {
let idx: Vec<usize> = (0..g.len()).filter(|&i| keep(i)).collect();
if idx.len() < min_keep {
return None;
}
let g_sub: Vec<[f64; 4]> = idx.iter().map(|&i| g[i]).collect();
let y_sub: Vec<f64> = idx.iter().map(|&i| y[i]).collect();
let (xk, ak) = lsq_solution(&g_sub, &y_sub)?;
let dx = [xk[0] - x0[0], xk[1] - x0[1], xk[2] - x0[2]];
let sep_v = dx[0] * up[0] + dx[1] * up[1] + dx[2] * up[2];
let sep_e = dx[0] * east[0] + dx[1] * east[1] + dx[2] * east[2];
let sep_n = dx[0] * north[0] + dx[1] * north[1] + dx[2] * north[2];
let sep_h = (sep_e * sep_e + sep_n * sep_n).sqrt();
let vark_v = axis_variance(&ak, up);
let vark_h = axis_variance(&ak, east) + axis_variance(&ak, north);
let sig_ss_v = sigma_m * (vark_v - var0_v).max(0.0).sqrt();
let sig_ss_h = sigma_m * (vark_h - var0_h).max(0.0).sqrt();
let nrm_v = if sig_ss_v > 1e-9 {
sep_v.abs() / sig_ss_v
} else {
0.0
};
let nrm_h = if sig_ss_h > 1e-9 {
sep_h / sig_ss_h
} else {
0.0
};
let mode_v = AraimMode {
p_fault,
threshold_m: k_fa * sig_ss_v,
bias_m: axis_bias_sum(&ak, &g_sub, up, b_nom),
sigma_m: sigma_m * vark_v.sqrt(),
};
let mode_h = AraimMode {
p_fault,
threshold_m: k_fa * sig_ss_h,
bias_m: horiz_bias_sum(&ak, &g_sub, east, north, b_nom),
sigma_m: sigma_m * vark_h.sqrt(),
};
Some((mode_v, mode_h, nrm_v.max(nrm_h)))
}
pub fn araim_dual_raim(
user: Vec3,
sats: &[Vec3],
constellation: &[u8],
range_residual_m: &[f64],
sigma_m: f64,
priors: DualFaultPriors,
budget: IntegrityBudget,
) -> Option<AraimResult> {
let n = sats.len();
if n != range_residual_m.len() || n != constellation.len() || n < 6 || sigma_m <= 0.0 {
return None;
}
let mut g: Vec<[f64; 4]> = Vec::with_capacity(n);
for &s in sats {
let e = los_unit(user, s)?;
g.push([-e[0], -e[1], -e[2], 1.0]);
}
let (east, north, up) = enu_basis(user)?;
let (x0, a0) = lsq_solution(&g, range_residual_m)?;
let var0_v = axis_variance(&a0, up);
let var0_h = axis_variance(&a0, east) + axis_variance(&a0, north);
let mut consts: Vec<u8> = constellation.to_vec();
consts.sort_unstable();
consts.dedup();
let cover_const = priors.p_const > 0.0;
let n_const = consts.len();
let n_modes = n + if cover_const { n_const } else { 0 };
let k_fa = normal_quantile(1.0 - budget.p_fa / (2.0 * n_modes as f64));
let p_const_total = if cover_const {
n_const as f64 * priors.p_const
} else {
0.0
};
let p_ff = (1.0 - n as f64 * priors.p_sat - p_const_total).max(0.0);
let mut modes_v = vec![AraimMode {
p_fault: p_ff,
threshold_m: 0.0,
bias_m: axis_bias_sum(&a0, &g, up, priors.b_nom_m),
sigma_m: sigma_m * var0_v.sqrt(),
}];
let mut modes_h = vec![AraimMode {
p_fault: p_ff,
threshold_m: 0.0,
bias_m: horiz_bias_sum(&a0, &g, east, north, priors.b_nom_m),
sigma_m: sigma_m * var0_h.sqrt(),
}];
let mut fault_detected = false;
let mut excluded_sv = None;
let mut max_norm_sep = 0.0_f64;
for k in 0..n {
if let Some((mv, mh, nrm)) = araim_exclusion_mode(
&g,
range_residual_m,
|i| i != k,
&x0,
east,
north,
up,
var0_v,
var0_h,
sigma_m,
k_fa,
priors.p_sat,
priors.b_nom_m,
5,
) {
modes_v.push(mv);
modes_h.push(mh);
if nrm > max_norm_sep {
max_norm_sep = nrm;
if nrm > k_fa {
fault_detected = true;
excluded_sv = Some(k);
}
}
}
}
if cover_const {
for &c in &consts {
match araim_exclusion_mode(
&g,
range_residual_m,
|i| constellation[i] != c,
&x0,
east,
north,
up,
var0_v,
var0_h,
sigma_m,
k_fa,
priors.p_const,
priors.b_nom_m,
5,
) {
Some((mv, mh, nrm)) => {
modes_v.push(mv);
modes_h.push(mh);
if nrm > max_norm_sep {
max_norm_sep = nrm;
if nrm > k_fa {
fault_detected = true;
}
}
}
None => return None,
}
}
}
let vpl = araim_protection_level(&modes_v, budget.p_hmi_vert / 2.0);
let hpl = araim_protection_level(&modes_h, budget.p_hmi_horz / 2.0);
Some(AraimResult {
n_used: n,
hpl_m: hpl,
vpl_m: vpl,
p_hmi_vert: 2.0 * araim_integrity_risk(vpl, &modes_v),
p_hmi_horz: 2.0 * araim_integrity_risk(hpl, &modes_h),
fault_detected,
excluded_sv,
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub enum StanfordRegion {
Available,
SystemUnavailable,
MisleadingInformation,
HazardouslyMisleadingInformation,
}
pub fn classify_stanford(error_m: f64, pl_m: f64, alert_limit_m: f64) -> StanfordRegion {
if pl_m >= error_m {
if pl_m <= alert_limit_m {
StanfordRegion::Available
} else {
StanfordRegion::SystemUnavailable
}
} else if error_m <= alert_limit_m {
StanfordRegion::MisleadingInformation
} else {
StanfordRegion::HazardouslyMisleadingInformation
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct StanfordPoint {
pub error_m: f64,
pub pl_m: f64,
pub region: StanfordRegion,
}
#[derive(Clone, Debug, Serialize)]
pub struct StanfordDiagram {
pub alert_limit_m: f64,
points: Vec<StanfordPoint>,
}
impl StanfordDiagram {
pub fn new(alert_limit_m: f64) -> Self {
Self {
alert_limit_m,
points: Vec::new(),
}
}
pub fn add(&mut self, error_m: f64, pl_m: f64) -> StanfordRegion {
let region = classify_stanford(error_m, pl_m, self.alert_limit_m);
self.points.push(StanfordPoint {
error_m,
pl_m,
region,
});
region
}
pub fn points(&self) -> &[StanfordPoint] {
&self.points
}
pub fn len(&self) -> usize {
self.points.len()
}
pub fn is_empty(&self) -> bool {
self.points.is_empty()
}
pub fn count(&self, region: StanfordRegion) -> usize {
self.points.iter().filter(|p| p.region == region).count()
}
pub fn availability(&self) -> f64 {
if self.points.is_empty() {
return 0.0;
}
self.count(StanfordRegion::Available) as f64 / self.points.len() as f64
}
pub fn integrity_events(&self) -> usize {
self.count(StanfordRegion::MisleadingInformation)
+ self.count(StanfordRegion::HazardouslyMisleadingInformation)
}
}
pub fn stanford_svg(diagram: &StanfordDiagram) -> String {
let (w, h) = (460.0_f64, 460.0_f64);
let (ml, mr, mt, mb) = (60.0_f64, 20.0_f64, 36.0_f64, 56.0_f64);
let pw = w - ml - mr;
let ph = h - mt - mb;
let al = diagram.alert_limit_m;
let mut vmax = al * 1.6;
for p in diagram.points() {
vmax = vmax.max(p.error_m).max(p.pl_m);
}
vmax = (vmax * 1.05).max(1.0);
let xof = |e: f64| ml + (e.min(vmax) / vmax) * pw;
let yof = |v: f64| mt + ph - (v.min(vmax) / vmax) * ph;
let color = |r: StanfordRegion| match r {
StanfordRegion::Available => "#46b67e",
StanfordRegion::SystemUnavailable => "#6e7a8a",
StanfordRegion::MisleadingInformation => "#d2925e",
StanfordRegion::HazardouslyMisleadingInformation => "#e5645a",
};
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=\"11\" fill=\"#bcb3a3\">"
));
svg.push_str(&format!(
"<rect width=\"{w:.0}\" height=\"{h:.0}\" fill=\"#0c0b08\"/>"
));
svg.push_str(&format!(
"<text x=\"{ml:.0}\" y=\"20\" font-size=\"14\" font-weight=\"bold\">Stanford diagram — integrity ({} epochs, AL = {al:.0} m)</text>",
diagram.len()
));
svg.push_str(&format!(
"<line x1=\"{:.1}\" y1=\"{:.1}\" x2=\"{:.1}\" y2=\"{:.1}\" stroke=\"#62594b\" stroke-dasharray=\"4 3\"/>",
xof(0.0),
yof(0.0),
xof(vmax),
yof(vmax)
));
svg.push_str(&format!(
"<line x1=\"{:.1}\" y1=\"{:.1}\" x2=\"{:.1}\" y2=\"{:.1}\" stroke=\"#e5645a\" stroke-dasharray=\"2 2\"/>",
xof(al),
yof(0.0),
xof(al),
yof(vmax)
));
svg.push_str(&format!(
"<line x1=\"{:.1}\" y1=\"{:.1}\" x2=\"{:.1}\" y2=\"{:.1}\" stroke=\"#e5645a\" stroke-dasharray=\"2 2\"/>",
xof(0.0),
yof(al),
xof(vmax),
yof(al)
));
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!(
"<text x=\"{:.0}\" y=\"{:.0}\" text-anchor=\"middle\">position error (m)</text>",
ml + pw / 2.0,
h - 16.0
));
let lbl_y = mt + ph / 2.0;
svg.push_str(&format!(
"<text x=\"16\" y=\"{lbl_y:.0}\" transform=\"rotate(-90 16 {lbl_y:.0})\" text-anchor=\"middle\">protection level (m)</text>"
));
for p in diagram.points() {
svg.push_str(&format!(
"<circle cx=\"{:.1}\" cy=\"{:.1}\" r=\"3\" fill=\"{}\" fill-opacity=\"0.85\"/>",
xof(p.error_m),
yof(p.pl_m),
color(p.region)
));
}
svg.push_str("</svg>");
svg
}
#[derive(Clone, Copy, Debug, Serialize)]
pub struct RaimConfig {
pub sigma_m: f64,
pub p_fa: f64,
pub p_md: f64,
pub al_h_m: f64,
pub al_v_m: f64,
}
#[derive(Clone, Copy, Debug, Serialize)]
pub struct RaimAvailabilityEpoch {
pub t_s: f64,
pub n_visible: usize,
pub hpl_m: Option<f64>,
pub vpl_m: Option<f64>,
pub available: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct RaimAvailabilityReport {
pub al_h_m: f64,
pub al_v_m: f64,
pub samples_total: usize,
pub samples_available: usize,
pub epochs: Vec<RaimAvailabilityEpoch>,
pub stanford: StanfordDiagram,
}
impl RaimAvailabilityReport {
pub fn availability(&self) -> f64 {
if self.samples_total == 0 {
0.0
} else {
self.samples_available as f64 / self.samples_total as f64
}
}
}
pub fn raim_availability_epoch(
t_s: f64,
user_ecef: Vec3,
sats_ecef: &[Vec3],
cfg: &RaimConfig,
) -> RaimAvailabilityEpoch {
let n_visible = sats_ecef.len();
let zero = vec![0.0; n_visible];
match snapshot_raim(user_ecef, sats_ecef, &zero, cfg.sigma_m, cfg.p_fa, cfg.p_md) {
Some(r) => {
let available = r.hpl_m <= cfg.al_h_m && r.vpl_m <= cfg.al_v_m;
RaimAvailabilityEpoch {
t_s,
n_visible,
hpl_m: Some(r.hpl_m),
vpl_m: Some(r.vpl_m),
available,
}
}
None => RaimAvailabilityEpoch {
t_s,
n_visible,
hpl_m: None,
vpl_m: None,
available: false,
},
}
}
pub fn constellation_raim_availability(
user: &Orbit,
gnss: &[Propagator],
step_s: f64,
duration_s: f64,
mask_deg: f64,
cfg: &RaimConfig,
seed: u64,
) -> RaimAvailabilityReport {
let n = (duration_s / step_s).round() as usize;
let mut epochs = Vec::with_capacity(n + 1);
let mut available = 0usize;
let mut rng = ChaCha8Rng::seed_from_u64(seed);
let sd = if cfg.sigma_m.is_finite() && cfg.sigma_m > 0.0 {
cfg.sigma_m
} else {
1e-12
};
let noise = Normal::new(0.0, sd).expect("finite positive sigma");
let mut stanford = StanfordDiagram::new(cfg.al_v_m);
for i in 0..=n {
let t = i as f64 * step_s;
let user_ecef = user.position_eci(t);
let sats = visible_positions(user, gnss, t, mask_deg);
let e = raim_availability_epoch(t, user_ecef, &sats, cfg);
if e.available {
available += 1;
}
let residuals: Vec<f64> = (0..sats.len()).map(|_| noise.sample(&mut rng)).collect();
if let Some(vpl) = e.vpl_m {
if let Some(verr) = vertical_position_error(user_ecef, &sats, &residuals) {
stanford.add(verr.abs(), vpl);
}
}
epochs.push(e);
}
RaimAvailabilityReport {
al_h_m: cfg.al_h_m,
al_v_m: cfg.al_v_m,
samples_total: epochs.len(),
samples_available: available,
epochs,
stanford,
}
}
#[allow(clippy::too_many_arguments)]
pub fn araim_dual_constellation_availability(
user: &Orbit,
gnss: &[Propagator],
labels: &[u8],
step_s: f64,
duration_s: f64,
mask_deg: f64,
sigma_m: f64,
priors: DualFaultPriors,
budget: IntegrityBudget,
al_h_m: f64,
al_v_m: f64,
) -> RaimAvailabilityReport {
let n = (duration_s / step_s).round() as usize;
let mut epochs = Vec::with_capacity(n + 1);
let mut available = 0usize;
for i in 0..=n {
let t = i as f64 * step_s;
let user_ecef = user.position_eci(t);
let (sats, vis_labels) = visible_positions_labeled(user, gnss, labels, t, mask_deg);
let resid = vec![0.0; sats.len()];
let e = match araim_dual_raim(
user_ecef,
&sats,
&vis_labels,
&resid,
sigma_m,
priors,
budget,
) {
Some(r) => RaimAvailabilityEpoch {
t_s: t,
n_visible: sats.len(),
hpl_m: Some(r.hpl_m),
vpl_m: Some(r.vpl_m),
available: r.hpl_m <= al_h_m && r.vpl_m <= al_v_m,
},
None => RaimAvailabilityEpoch {
t_s: t,
n_visible: sats.len(),
hpl_m: None,
vpl_m: None,
available: false,
},
};
if e.available {
available += 1;
}
epochs.push(e);
}
RaimAvailabilityReport {
al_h_m,
al_v_m,
samples_total: epochs.len(),
samples_available: available,
epochs,
stanford: StanfordDiagram::new(al_v_m),
}
}
fn vertical_position_error(user: Vec3, sats: &[Vec3], residuals: &[f64]) -> Option<f64> {
if sats.len() != residuals.len() {
return None;
}
let mut g: Vec<[f64; 4]> = Vec::with_capacity(sats.len());
for &s in sats {
let e = los_unit(user, s)?;
g.push([-e[0], -e[1], -e[2], 1.0]);
}
let (_east, _north, up) = enu_basis(user)?;
let (x, _a) = lsq_solution(&g, residuals)?;
Some(x[0] * up[0] + x[1] * up[1] + x[2] * up[2])
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct IntegrityScenario {
pub mask_deg: f64,
pub sigma_uere_m: f64,
#[serde(default)]
pub sigma_ura_m: f64,
#[serde(default)]
pub b_nom_m: f64,
pub p_fa: f64,
pub p_md: f64,
pub al_h_m: f64,
pub al_v_m: f64,
pub time: TimeCfg,
pub user: OrbitCfg,
pub constellation: ConstellationCfg,
#[serde(default)]
pub constellations: Vec<ConstellationCfg>,
#[serde(default)]
pub seed: u64,
#[serde(default)]
pub araim_dual: bool,
#[serde(default = "default_p_hmi")]
pub p_hmi: f64,
}
fn default_p_hmi() -> f64 {
1e-7
}
impl IntegrityScenario {
pub fn all_satellites(&self) -> Result<Vec<Propagator>, String> {
let mut sats = self.constellation.satellites()?;
for c in &self.constellations {
sats.extend(c.satellites()?);
}
Ok(sats)
}
pub fn all_satellites_labeled(&self) -> Result<(Vec<Propagator>, Vec<u8>), String> {
let primary = self.constellation.satellites()?;
let mut labels: Vec<u8> = vec![0u8; primary.len()];
let mut sats = primary;
for (i, c) in self.constellations.iter().enumerate() {
let cs = c.satellites()?;
let label = (i + 1).min(u8::MAX as usize) as u8;
labels.resize(labels.len() + cs.len(), label);
sats.extend(cs);
}
Ok((sats, labels))
}
pub fn run(&self) -> Result<RaimAvailabilityReport, String> {
let user = self.user.to_orbit();
if self.araim_dual {
let (sats, labels) = self.all_satellites_labeled()?;
let sigma_ura = if self.sigma_ura_m > 0.0 {
self.sigma_ura_m.max(self.sigma_uere_m)
} else {
self.sigma_uere_m
};
let ism = IntegritySupportMessage {
sigma_ure_m: self.sigma_uere_m,
sigma_ura_m: sigma_ura,
b_nom_m: self.b_nom_m,
p_sat: 1e-5,
p_const: 1e-4,
};
let budget = IntegrityBudget {
p_hmi_vert: self.p_hmi,
p_hmi_horz: self.p_hmi,
p_fa: self.p_fa,
};
return Ok(araim_dual_constellation_availability(
&user,
&sats,
&labels,
self.time.step_s,
self.time.duration_s,
self.mask_deg,
ism.sigma_ura_m,
ism.dual_fault_priors(),
budget,
self.al_h_m,
self.al_v_m,
));
}
let sats = self.all_satellites()?;
let cfg = RaimConfig {
sigma_m: self.sigma_uere_m,
p_fa: self.p_fa,
p_md: self.p_md,
al_h_m: self.al_h_m,
al_v_m: self.al_v_m,
};
Ok(constellation_raim_availability(
&user,
&sats,
self.time.step_s,
self.time.duration_s,
self.mask_deg,
&cfg,
self.seed,
))
}
}
pub fn availability_svg(report: &RaimAvailabilityReport) -> String {
let (w, h) = (820.0_f64, 420.0_f64);
let (ml, mr, mt, mb) = (70.0_f64, 20.0_f64, 30.0_f64, 70.0_f64);
let pw = w - ml - mr;
let ph = h - mt - mb;
let t_max = report.epochs.iter().map(|e| e.t_s).fold(1.0_f64, f64::max);
let mut y_max = report.al_h_m.max(report.al_v_m) * 1.4;
for e in &report.epochs {
if let Some(v) = e.hpl_m {
y_max = y_max.max(v);
}
if let Some(v) = e.vpl_m {
y_max = y_max.max(v);
}
}
if y_max <= 0.0 {
y_max = 1.0;
}
let xof = |t: f64| ml + (t / t_max) * pw;
let yof = |v: f64| mt + ph - (v.min(y_max) / y_max) * ph;
let axis_y = mt + ph;
let segments = |pick: &dyn Fn(&RaimAvailabilityEpoch) -> Option<f64>| -> String {
let mut out = String::new();
let mut cur: Vec<String> = Vec::new();
for e in &report.epochs {
match pick(e) {
Some(v) => cur.push(format!("{:.1},{:.1}", xof(e.t_s), yof(v))),
None => {
if cur.len() > 1 {
out.push_str(&format!("<polyline points=\"{}\"/>", cur.join(" ")));
}
cur.clear();
}
}
}
if cur.len() > 1 {
out.push_str(&format!("<polyline points=\"{}\"/>", cur.join(" ")));
}
out
};
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=\"18\" font-size=\"15\" font-weight=\"bold\">RAIM protection levels and availability ({:.0}% available)</text>",
report.availability() * 100.0
));
svg.push_str(&crate::chart::y_axis(
ml,
mt,
pw,
ph,
y_max,
"protection level (m)",
));
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
));
for (al, colour, label) in [
(report.al_h_m, "#e5645a", "HAL"),
(report.al_v_m, "#d2925e", "VAL"),
] {
let y = yof(al);
svg.push_str(&format!(
"<line x1=\"{ml:.0}\" y1=\"{y:.1}\" x2=\"{:.0}\" y2=\"{y:.1}\" stroke=\"{colour}\" stroke-dasharray=\"6 4\"/>",
ml + pw
));
svg.push_str(&format!(
"<text x=\"{:.0}\" y=\"{:.1}\" fill=\"{colour}\">{label} {al:.0} m</text>",
ml + pw - 70.0,
y - 4.0
));
}
svg.push_str(&format!(
"<g fill=\"none\" stroke=\"#e0bd84\" stroke-width=\"2\">{}</g>",
segments(&|e| e.hpl_m)
));
svg.push_str(&format!(
"<g fill=\"none\" stroke=\"#c79e63\" stroke-width=\"2\">{}</g>",
segments(&|e| e.vpl_m)
));
let strip_y = axis_y + 12.0;
let bw = pw / report.epochs.len().max(1) as f64;
for (i, e) in report.epochs.iter().enumerate() {
let colour = if e.available { "#46b67e" } else { "#e5645a" };
svg.push_str(&format!(
"<rect x=\"{:.1}\" y=\"{strip_y:.0}\" width=\"{:.1}\" height=\"10\" fill=\"{colour}\"/>",
ml + i as f64 * bw,
bw.max(0.5)
));
}
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=\"#e0bd84\">HPL</text><text x=\"{:.0}\" y=\"60\" fill=\"#c79e63\">VPL</text>",
ml + 10.0,
ml + 10.0
));
svg.push_str("</svg>");
svg
}
#[cfg(test)]
mod tests {
use super::*;
use crate::frames::{geodetic_to_ecef, Geodetic};
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;
#[test]
fn chi2_cdf_and_quantile_match_known_values() {
assert!((chi2_cdf(3.841_459, 1.0) - 0.95).abs() < 1e-4);
assert!((chi2_quantile(0.95, 1.0) - 3.841_459).abs() < 1e-3);
assert!((chi2_quantile(0.95, 10.0) - 18.307).abs() < 1e-2);
assert!((chi2_cdf(2.0 * 2.0_f64.ln(), 2.0) - 0.5).abs() < 1e-6);
}
#[test]
fn noncentral_reduces_to_central_at_zero_lambda() {
for &(x, k) in &[(3.0, 1.0), (10.0, 5.0), (20.0, 12.0)] {
assert!((noncentral_chi2_cdf(x, k, 0.0) - chi2_cdf(x, k)).abs() < 1e-12);
}
}
#[test]
fn pbias_hits_the_missed_detection_probability() {
let dof = 3.0;
let t2 = chi2_quantile(1.0 - 1e-5, dof);
let pb = pbias(t2, dof, 1e-3);
let got = noncentral_chi2_cdf(t2, dof, pb * pb);
assert!((got - 1e-3).abs() < 1e-4, "P_md = {got}, want 1e-3");
assert!(pb > 0.0);
}
fn gps_like_constellation(station: Geodetic) -> Vec<Vec3> {
let s = geodetic_to_ecef(station);
let (east, north, up) = enu_basis(s).unwrap();
let azel: [(f64, f64); 6] = [
(0.0, 80.0),
(45.0, 30.0),
(135.0, 45.0),
(225.0, 25.0),
(300.0, 60.0),
(180.0, 15.0),
];
let range = 20_200_000.0;
azel.iter()
.map(|&(az, el)| {
let (azr, elr) = (az.to_radians(), el.to_radians());
let de = elr.cos() * azr.sin();
let dn = elr.cos() * azr.cos();
let du = elr.sin();
[
s[0] + range * (de * east[0] + dn * north[0] + du * up[0]),
s[1] + range * (de * east[1] + dn * north[1] + du * up[1]),
s[2] + range * (de * east[2] + dn * north[2] + du * up[2]),
]
})
.collect()
}
fn dense_constellation(station: Geodetic) -> Vec<Vec3> {
let s = geodetic_to_ecef(station);
let (east, north, up) = enu_basis(s).unwrap();
let azel: [(f64, f64); 10] = [
(0.0, 78.0),
(40.0, 25.0),
(80.0, 52.0),
(120.0, 18.0),
(160.0, 40.0),
(200.0, 60.0),
(240.0, 22.0),
(280.0, 48.0),
(320.0, 30.0),
(350.0, 15.0),
];
let range = 20_200_000.0;
azel.iter()
.map(|&(az, el)| {
let (azr, elr) = (az.to_radians(), el.to_radians());
let de = elr.cos() * azr.sin();
let dn = elr.cos() * azr.cos();
let du = elr.sin();
[
s[0] + range * (de * east[0] + dn * north[0] + du * up[0]),
s[1] + range * (de * east[1] + dn * north[1] + du * up[1]),
s[2] + range * (de * east[2] + dn * north[2] + du * up[2]),
]
})
.collect()
}
#[test]
fn fault_free_geometry_does_not_alarm_and_gives_finite_pls() {
let station = Geodetic {
lat_rad: 0.9,
lon_rad: 0.3,
alt_m: 100.0,
};
let user = geodetic_to_ecef(station);
let sats = gps_like_constellation(station);
let mut rng = ChaCha8Rng::seed_from_u64(4);
let sigma = 5.0;
let resid: Vec<f64> = (0..sats.len())
.map(|_| (rng.gen::<f64>() - 0.5) * sigma)
.collect();
let r = snapshot_raim(user, &sats, &resid, sigma, 1e-5, 1e-3).expect("raim runs");
assert_eq!(r.n_used, 6);
assert_eq!(r.dof, 2);
assert!(
!r.fault_detected,
"no fault should be flagged: stat {} thr {}",
r.test_statistic, r.threshold
);
assert!(r.hpl_m > 0.0 && r.hpl_m.is_finite(), "HPL {}", r.hpl_m);
assert!(r.vpl_m > 0.0 && r.vpl_m.is_finite(), "VPL {}", r.vpl_m);
}
#[test]
fn large_single_satellite_bias_is_detected() {
let station = Geodetic {
lat_rad: 0.5,
lon_rad: -1.2,
alt_m: 0.0,
};
let user = geodetic_to_ecef(station);
let sats = gps_like_constellation(station);
let sigma = 5.0;
let mut resid = vec![0.0; sats.len()];
resid[2] = 300.0; let r = snapshot_raim(user, &sats, &resid, sigma, 1e-5, 1e-3).expect("raim runs");
assert!(
r.fault_detected,
"a 60-sigma bias must be detected: stat {} thr {}",
r.test_statistic, r.threshold
);
}
#[test]
fn fewer_than_five_satellites_returns_none() {
let station = Geodetic {
lat_rad: 0.0,
lon_rad: 0.0,
alt_m: 0.0,
};
let user = geodetic_to_ecef(station);
let sats = gps_like_constellation(station);
let four = &sats[..4];
assert!(snapshot_raim(user, four, &[0.0; 4], 5.0, 1e-5, 1e-3).is_none());
}
#[test]
fn normal_cdf_and_quantile_match_known_values() {
assert!((normal_cdf(0.0) - 0.5).abs() < 1e-12);
assert!((normal_cdf(1.959_964) - 0.975).abs() < 1e-6);
assert!((normal_cdf(-1.0) - 0.158_655_3).abs() < 1e-6);
assert!((normal_quantile(0.975) - 1.959_964).abs() < 1e-4);
assert!((normal_quantile(0.5)).abs() < 1e-6);
assert!((normal_quantile(1.0 - 1e-7) - 5.199_338).abs() < 1e-3);
assert!((normal_quantile(0.1) + normal_quantile(0.9)).abs() < 1e-4);
}
#[test]
fn solution_separation_fault_free_does_not_alarm_and_protects() {
let station = Geodetic {
lat_rad: 0.9,
lon_rad: 0.3,
alt_m: 100.0,
};
let user = geodetic_to_ecef(station);
let sats = gps_like_constellation(station);
let mut rng = ChaCha8Rng::seed_from_u64(7);
let sigma = 5.0;
let resid: Vec<f64> = (0..sats.len())
.map(|_| (rng.gen::<f64>() - 0.5) * sigma)
.collect();
let r = solution_separation_raim(user, &sats, &resid, sigma, 1e-5, 1e-3)
.expect("solution-separation runs");
assert_eq!(r.n_used, 6);
assert!(!r.fault_detected, "no fault expected");
assert!(r.max_normalized_separation < normal_quantile(1.0 - 1e-5 / 2.0));
assert!(r.hpl_m > 0.0 && r.hpl_m.is_finite(), "HPL {}", r.hpl_m);
assert!(r.vpl_m > 0.0 && r.vpl_m.is_finite(), "VPL {}", r.vpl_m);
assert_eq!(r.excluded_sv, None);
}
#[test]
fn solution_separation_detects_and_identifies_the_faulty_satellite() {
let station = Geodetic {
lat_rad: 0.5,
lon_rad: -1.2,
alt_m: 0.0,
};
let user = geodetic_to_ecef(station);
let sats = gps_like_constellation(station);
let sigma = 5.0;
let mut resid = vec![0.0; sats.len()];
resid[2] = 300.0; let r = solution_separation_raim(user, &sats, &resid, sigma, 1e-5, 1e-3)
.expect("solution-separation runs");
assert!(r.fault_detected, "a 60-σ bias must be detected");
assert_eq!(r.excluded_sv, Some(2), "should identify SV 2 as faulted");
}
#[test]
fn solution_separation_needs_six_satellites() {
let station = Geodetic {
lat_rad: 0.2,
lon_rad: 0.4,
alt_m: 0.0,
};
let user = geodetic_to_ecef(station);
let sats = gps_like_constellation(station);
let five = &sats[..5];
assert!(solution_separation_raim(user, five, &[0.0; 5], 5.0, 1e-5, 1e-3).is_none());
}
#[test]
fn araim_integrity_risk_sums_the_per_mode_tail_probabilities() {
let modes = [AraimMode {
p_fault: 1.0,
threshold_m: 0.0,
bias_m: 0.0,
sigma_m: 1.0,
}];
assert!((araim_integrity_risk(2.0, &modes) - 0.022_750_132).abs() < 1e-6);
let two = [
AraimMode {
p_fault: 1e-4,
threshold_m: 0.0,
bias_m: 0.0,
sigma_m: 1.0,
},
AraimMode {
p_fault: 1e-4,
threshold_m: 0.0,
bias_m: 0.0,
sigma_m: 1.0,
},
];
assert!((araim_integrity_risk(3.0, &two) - 2.0 * 1e-4 * 0.001_349_898).abs() < 1e-12);
assert!(araim_integrity_risk(2.0, &two) > araim_integrity_risk(4.0, &two));
}
#[test]
fn araim_protection_level_inverts_the_fault_free_budget() {
let modes = [AraimMode {
p_fault: 1.0,
threshold_m: 0.0,
bias_m: 0.0,
sigma_m: 1.0,
}];
let pl = araim_protection_level(&modes, 0.022_750_132);
assert!((pl - 2.0).abs() < 1e-3, "PL = {pl}, want 2.0");
}
#[test]
fn araim_protection_level_inverts_a_thresholded_fault_mode() {
let modes = [AraimMode {
p_fault: 1e-4,
threshold_m: 5.0,
bias_m: 0.0,
sigma_m: 2.0,
}];
let budget = 1e-4 * 0.001_349_898; let pl = araim_protection_level(&modes, budget);
assert!((pl - 11.0).abs() < 1e-2, "PL = {pl}, want 11.0");
}
#[test]
fn araim_raim_fault_free_protects_and_tighter_budget_raises_the_pl() {
let station = Geodetic {
lat_rad: 0.7,
lon_rad: 0.2,
alt_m: 50.0,
};
let user = geodetic_to_ecef(station);
let sats = dense_constellation(station);
let resid = vec![0.0; sats.len()];
let priors = FaultPriors {
p_sat: 1e-5,
b_nom_m: 0.0,
};
let loose = IntegrityBudget {
p_hmi_vert: 1e-4,
p_hmi_horz: 1e-4,
p_fa: 1e-5,
};
let tight = IntegrityBudget {
p_hmi_vert: 1e-9,
p_hmi_horz: 1e-9,
p_fa: 1e-5,
};
let rl = araim_raim(user, &sats, &resid, 1.0, priors, loose).expect("araim runs");
let rt = araim_raim(user, &sats, &resid, 1.0, priors, tight).expect("araim runs");
assert!(!rl.fault_detected, "no fault should be flagged");
assert!(rl.vpl_m > 0.0 && rl.vpl_m.is_finite(), "VPL {}", rl.vpl_m);
assert!(rl.hpl_m > 0.0 && rl.hpl_m.is_finite(), "HPL {}", rl.hpl_m);
assert!(
rl.p_hmi_vert <= loose.p_hmi_vert * 1.001,
"achieved P_HMI {} > allocated {}",
rl.p_hmi_vert,
loose.p_hmi_vert
);
assert!(
rt.vpl_m > rl.vpl_m,
"tighter budget VPL {} must exceed looser {}",
rt.vpl_m,
rl.vpl_m
);
}
#[test]
fn araim_raim_detects_and_identifies_a_faulty_satellite() {
let station = Geodetic {
lat_rad: 0.5,
lon_rad: -1.2,
alt_m: 0.0,
};
let user = geodetic_to_ecef(station);
let sats = gps_like_constellation(station);
let mut resid = vec![0.0; sats.len()];
resid[2] = 300.0; let r = araim_raim(
user,
&sats,
&resid,
5.0,
FaultPriors {
p_sat: 1e-5,
b_nom_m: 0.0,
},
IntegrityBudget {
p_hmi_vert: 1e-4,
p_hmi_horz: 1e-4,
p_fa: 1e-5,
},
)
.expect("araim runs");
assert!(r.fault_detected, "a 60-σ bias must be detected");
assert_eq!(r.excluded_sv, Some(2), "should identify SV 2 as faulted");
}
#[test]
fn araim_raim_needs_six_satellites() {
let station = Geodetic {
lat_rad: 0.2,
lon_rad: 0.4,
alt_m: 0.0,
};
let user = geodetic_to_ecef(station);
let sats = gps_like_constellation(station);
let five = &sats[..5];
assert!(araim_raim(
user,
five,
&[0.0; 5],
5.0,
FaultPriors {
p_sat: 1e-5,
b_nom_m: 0.0
},
IntegrityBudget {
p_hmi_vert: 1e-4,
p_hmi_horz: 1e-4,
p_fa: 1e-5,
},
)
.is_none());
}
#[test]
fn stanford_classifies_each_region() {
let al = 40.0; assert_eq!(classify_stanford(10.0, 25.0, al), StanfordRegion::Available);
assert_eq!(classify_stanford(25.0, 25.0, al), StanfordRegion::Available);
assert_eq!(
classify_stanford(30.0, 50.0, al),
StanfordRegion::SystemUnavailable
);
assert_eq!(
classify_stanford(30.0, 20.0, al),
StanfordRegion::MisleadingInformation
);
assert_eq!(
classify_stanford(60.0, 20.0, al),
StanfordRegion::HazardouslyMisleadingInformation
);
}
#[test]
fn stanford_diagram_accumulates_counts_and_availability() {
let mut d = StanfordDiagram::new(40.0);
d.add(10.0, 25.0); d.add(15.0, 30.0); d.add(30.0, 50.0); d.add(30.0, 20.0); d.add(60.0, 20.0); assert_eq!(d.len(), 5);
assert_eq!(d.count(StanfordRegion::Available), 2);
assert_eq!(d.count(StanfordRegion::SystemUnavailable), 1);
assert_eq!(d.integrity_events(), 2);
assert!((d.availability() - 2.0 / 5.0).abs() < 1e-12);
assert_eq!(d.points().len(), 5);
assert_eq!(d.points()[0].region, StanfordRegion::Available);
}
#[test]
fn stanford_diagram_serializes_to_json() {
let mut d = StanfordDiagram::new(40.0);
d.add(10.0, 25.0);
d.add(60.0, 20.0);
let json = serde_json::to_string(&d).expect("serializes");
assert!(json.contains("alert_limit_m"));
assert!(json.contains("Available"));
assert!(json.contains("HazardouslyMisleadingInformation"));
}
#[test]
fn raim_availability_epoch_judges_against_alert_limits() {
let station = Geodetic {
lat_rad: 0.7,
lon_rad: 0.1,
alt_m: 0.0,
};
let user = geodetic_to_ecef(station);
let sats = dense_constellation(station);
let cfg = RaimConfig {
sigma_m: 1.0,
p_fa: 1e-5,
p_md: 1e-3,
al_h_m: 40.0,
al_v_m: 50.0,
};
let e = raim_availability_epoch(0.0, user, &sats, &cfg);
assert_eq!(e.n_visible, 10);
assert!(e.hpl_m.is_some() && e.vpl_m.is_some());
assert!(e.available, "HPL {:?} VPL {:?}", e.hpl_m, e.vpl_m);
let strict = RaimConfig {
al_h_m: 1.0,
al_v_m: 1.0,
..cfg
};
assert!(!raim_availability_epoch(0.0, user, &sats, &strict).available);
let e4 = raim_availability_epoch(0.0, user, &sats[..4], &cfg);
assert_eq!(e4.hpl_m, None);
assert!(!e4.available);
}
#[test]
fn constellation_raim_availability_runs_end_to_end_over_sgp4_geometry() {
use crate::orbit::{ConstellationCfg, Orbit, R_EARTH_M};
let cons = ConstellationCfg {
altitude_km: 20_200.0,
inclination_deg: 55.0,
planes: 6,
sats_per_plane: 4,
phasing_f: 1.0,
tle: None,
rinex: None,
strict_checksum: false,
};
let gnss = cons.satellites().expect("constellation builds");
let user = Orbit::new(R_EARTH_M, 0.6, 0.2, 0.0);
let cfg = RaimConfig {
sigma_m: 6.0,
p_fa: 1e-5,
p_md: 1e-3,
al_h_m: 40.0,
al_v_m: 50.0,
};
let report = constellation_raim_availability(&user, &gnss, 300.0, 6000.0, 5.0, &cfg, 7);
assert_eq!(report.samples_total, report.epochs.len());
assert!(report.samples_total > 1);
assert!((0.0..=1.0).contains(&report.availability()));
let protected = report
.epochs
.iter()
.filter(|e| e.n_visible >= 5 && e.hpl_m.is_some())
.count();
assert!(protected > 0, "no epoch had a protected fix");
assert_eq!(report.stanford.len(), protected);
assert_eq!(
report
.stanford
.count(StanfordRegion::HazardouslyMisleadingInformation),
0,
"a no-fault realization must not be hazardously misleading"
);
let again = constellation_raim_availability(&user, &gnss, 300.0, 6000.0, 5.0, &cfg, 7);
assert_eq!(report.stanford.points(), again.stanford.points());
let json = serde_json::to_string(&report).expect("serializes");
assert!(json.contains("samples_available"));
assert!(json.contains("stanford") && json.contains("alert_limit_m"));
}
fn dual_setup() -> (Vec3, Vec<Vec3>, Vec<u8>, Vec<f64>, IntegrityBudget) {
let station = Geodetic {
lat_rad: 0.7,
lon_rad: 0.2,
alt_m: 50.0,
};
let user = geodetic_to_ecef(station);
let sats = dense_constellation(station); let constellation: Vec<u8> = (0..sats.len()).map(|i| (i % 2) as u8).collect();
let resid = vec![0.0; sats.len()];
let budget = IntegrityBudget {
p_hmi_vert: 1e-4,
p_hmi_horz: 1e-4,
p_fa: 1e-5,
};
(user, sats, constellation, resid, budget)
}
#[test]
fn dual_reduces_to_single_when_p_const_zero() {
let (user, sats, constellation, resid, budget) = dual_setup();
let single = araim_raim(
user,
&sats,
&resid,
1.0,
FaultPriors {
p_sat: 1e-5,
b_nom_m: 0.0,
},
budget,
)
.expect("single araim runs");
let dual = araim_dual_raim(
user,
&sats,
&constellation,
&resid,
1.0,
DualFaultPriors {
p_sat: 1e-5,
p_const: 0.0,
b_nom_m: 0.0,
},
budget,
)
.expect("dual araim runs");
assert!(
(dual.vpl_m - single.vpl_m).abs() < 1e-9,
"VPL {} vs {}",
dual.vpl_m,
single.vpl_m
);
assert!(
(dual.hpl_m - single.hpl_m).abs() < 1e-9,
"HPL {} vs {}",
dual.hpl_m,
single.hpl_m
);
}
#[test]
fn constellation_fault_mode_widens_the_protection_level() {
let (user, sats, constellation, resid, budget) = dual_setup();
let base = araim_dual_raim(
user,
&sats,
&constellation,
&resid,
1.0,
DualFaultPriors {
p_sat: 1e-5,
p_const: 0.0,
b_nom_m: 0.0,
},
budget,
)
.expect("base runs");
let dual = araim_dual_raim(
user,
&sats,
&constellation,
&resid,
1.0,
DualFaultPriors {
p_sat: 1e-5,
p_const: 1e-4,
b_nom_m: 0.0,
},
budget,
)
.expect("dual runs");
assert!(
dual.vpl_m > base.vpl_m,
"dual VPL {} !> base {}",
dual.vpl_m,
base.vpl_m
);
assert!(
dual.hpl_m > base.hpl_m,
"dual HPL {} !> base {}",
dual.hpl_m,
base.hpl_m
);
assert!(
dual.p_hmi_vert <= budget.p_hmi_vert * 1.001,
"P_HMI_v {}",
dual.p_hmi_vert
);
assert!(
dual.p_hmi_horz <= budget.p_hmi_horz * 1.001,
"P_HMI_h {}",
dual.p_hmi_horz
);
assert!(dual.vpl_m.is_finite() && dual.vpl_m > 0.0);
assert!(dual.hpl_m.is_finite() && dual.hpl_m > 0.0);
}
#[test]
fn nominal_bias_inflates_the_protection_level() {
let (user, sats, _c, resid, budget) = dual_setup();
let no_bias = araim_raim(
user,
&sats,
&resid,
0.75,
FaultPriors {
p_sat: 1e-5,
b_nom_m: 0.0,
},
budget,
)
.expect("zero-bias ARAIM runs");
let with_bias = araim_raim(
user,
&sats,
&resid,
0.75,
FaultPriors {
p_sat: 1e-5,
b_nom_m: 0.75,
},
budget,
)
.expect("biased ARAIM runs");
assert!(
with_bias.vpl_m > no_bias.vpl_m,
"b_nom must inflate VPL: {} !> {}",
with_bias.vpl_m,
no_bias.vpl_m
);
assert!(
with_bias.hpl_m > no_bias.hpl_m,
"b_nom must inflate HPL: {} !> {}",
with_bias.hpl_m,
no_bias.hpl_m
);
assert!(with_bias.vpl_m.is_finite() && with_bias.hpl_m.is_finite());
}
#[test]
fn single_constellation_cannot_tolerate_its_own_fault() {
let (user, sats, _c, resid, budget) = dual_setup();
let one_const = vec![0u8; sats.len()];
let r = araim_dual_raim(
user,
&sats,
&one_const,
&resid,
1.0,
DualFaultPriors {
p_sat: 1e-5,
p_const: 1e-4,
b_nom_m: 0.0,
},
budget,
);
assert!(
r.is_none(),
"single constellation must not be available against its own fault"
);
let ok = araim_dual_raim(
user,
&sats,
&one_const,
&resid,
1.0,
DualFaultPriors {
p_sat: 1e-5,
p_const: 0.0,
b_nom_m: 0.0,
},
budget,
);
assert!(
ok.is_some(),
"with no constellation hypothesis it should run"
);
}
#[test]
fn dual_araim_rejects_mismatched_or_thin_inputs() {
let (user, sats, constellation, resid, budget) = dual_setup();
let priors = DualFaultPriors {
p_sat: 1e-5,
p_const: 1e-4,
b_nom_m: 0.0,
};
assert!(araim_dual_raim(
user,
&sats,
&constellation[..9],
&resid,
1.0,
priors,
budget
)
.is_none());
assert!(araim_dual_raim(
user,
&sats[..5],
&constellation[..5],
&resid[..5],
1.0,
priors,
budget
)
.is_none());
}
fn gps_galileo_geometry() -> (Vec3, Vec<Vec3>, Vec<Vec3>, Vec<u8>) {
let station = Geodetic {
lat_rad: 0.6,
lon_rad: -0.4,
alt_m: 120.0,
};
let user = geodetic_to_ecef(station);
let (east, north, up) = enu_basis(user).unwrap();
let place = |az_deg: f64, el_deg: f64, range: f64| -> Vec3 {
let (az, el) = (az_deg.to_radians(), el_deg.to_radians());
let (de, dn, du) = (el.cos() * az.sin(), el.cos() * az.cos(), el.sin());
[
user[0] + range * (de * east[0] + dn * north[0] + du * up[0]),
user[1] + range * (de * east[1] + dn * north[1] + du * up[1]),
user[2] + range * (de * east[2] + dn * north[2] + du * up[2]),
]
};
let gps_azel = [
(10.0, 70.0),
(70.0, 30.0),
(140.0, 45.0),
(200.0, 25.0),
(260.0, 55.0),
(310.0, 35.0),
(350.0, 20.0),
];
let gal_azel = [
(40.0, 50.0),
(100.0, 20.0),
(170.0, 62.0),
(230.0, 40.0),
(290.0, 28.0),
(330.0, 65.0),
(20.0, 38.0),
];
let gps: Vec<Vec3> = gps_azel
.iter()
.map(|&(a, e)| place(a, e, 22_000_000.0))
.collect();
let mut all = gps.clone();
for &(a, e) in &gal_azel {
all.push(place(a, e, 25_000_000.0));
}
let labels: Vec<u8> = (0..all.len())
.map(|i| if i < gps.len() { 0 } else { 1 })
.collect();
(user, gps, all, labels)
}
#[test]
fn ism_reference_baseline_and_converters() {
let ism = IntegritySupportMessage::gps_galileo_reference();
assert_eq!(ism.p_sat, 1e-5);
assert_eq!(ism.p_const, 1e-4);
assert!((ism.b_nom_m - 0.75).abs() < 1e-12);
assert!(ism.sigma_ura_m >= ism.sigma_ure_m, "URA must bound URE");
assert_eq!(ism.fault_priors().p_sat, ism.p_sat);
assert_eq!(ism.dual_fault_priors().p_sat, ism.p_sat);
assert_eq!(ism.dual_fault_priors().p_const, ism.p_const);
}
#[test]
fn stanford_svg_renders_zones_and_points() {
let mut d = StanfordDiagram::new(35.0);
d.add(10.0, 25.0); d.add(10.0, 45.0); d.add(30.0, 20.0); d.add(50.0, 20.0); let svg = stanford_svg(&d);
assert!(svg.starts_with("<svg") && svg.trim_end().ends_with("</svg>"));
assert_eq!(svg.matches("<circle").count(), d.len());
assert!(svg.contains("Stanford"), "titled Stanford diagram");
assert!(svg.contains("35"), "alert limit annotated");
assert!(svg.contains("<line"), "boundary/axis lines drawn");
}
#[test]
fn dual_constellation_improves_geometry_and_tolerates_a_constellation_fault() {
let (user, gps, all, labels) = gps_galileo_geometry();
let budget = IntegrityBudget {
p_hmi_vert: 1e-4,
p_hmi_horz: 1e-4,
p_fa: 1e-5,
};
let ism = IntegritySupportMessage::gps_galileo_reference();
let gps_only = araim_raim(
user,
&gps,
&vec![0.0; gps.len()],
ism.sigma_ura_m,
ism.fault_priors(),
budget,
)
.expect("GPS-only ARAIM runs");
let pooled = araim_raim(
user,
&all,
&vec![0.0; all.len()],
ism.sigma_ura_m,
ism.fault_priors(),
budget,
)
.expect("pooled 14-SV ARAIM runs");
assert!(
pooled.hpl_m < gps_only.hpl_m,
"pooled HPL {:.2} m should beat GPS-only {:.2} m",
pooled.hpl_m,
gps_only.hpl_m
);
let dual = araim_dual_raim(
user,
&all,
&labels,
&vec![0.0; all.len()],
ism.sigma_ura_m,
ism.dual_fault_priors(),
budget,
)
.expect("dual-constellation ARAIM available");
assert!(dual.hpl_m.is_finite() && dual.hpl_m > 0.0 && dual.vpl_m > 0.0);
let one_constellation = vec![0u8; all.len()];
assert!(
araim_dual_raim(
user,
&all,
&one_constellation,
&vec![0.0; all.len()],
ism.sigma_ura_m,
ism.dual_fault_priors(),
budget,
)
.is_none(),
"a single-constellation user cannot survive its own constellation fault"
);
}
}