use crate::frames::Vec3;
use crate::orbit::{
enu_basis, invert4, los_unit, visible_positions, ConstellationCfg, Orbit, OrbitCfg, Propagator,
};
use crate::scenario::TimeCfg;
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 {
assert!(p > 0.0 && p < 1.0 && k > 0.0);
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)
}
#[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, 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, 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)
}
}
#[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>,
}
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,
) -> 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 = visible_positions(user, gnss, t, mask_deg);
let e = raim_availability_epoch(t, user_ecef, &sats, cfg);
if e.available {
available += 1;
}
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,
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct IntegrityScenario {
pub mask_deg: f64,
pub sigma_uere_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>,
}
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 run(&self) -> Result<RaimAvailabilityReport, String> {
let user = self.user.to_orbit();
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,
))
}
}
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=\"#cdd6e0\">"
));
svg.push_str(&format!(
"<rect width=\"{w:.0}\" height=\"{h:.0}\" fill=\"#0e131b\"/>"
));
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=\"#3a4757\"/>"
));
svg.push_str(&format!(
"<line x1=\"{ml:.0}\" y1=\"{axis_y:.0}\" x2=\"{:.0}\" y2=\"{axis_y:.0}\" stroke=\"#3a4757\"/>",
ml + pw
));
for (al, colour, label) in [
(report.al_h_m, "#d33", "HAL"),
(report.al_v_m, "#e67e22", "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=\"#5cb8d6\" stroke-width=\"2\">{}</g>",
segments(&|e| e.hpl_m)
));
svg.push_str(&format!(
"<g fill=\"none\" stroke=\"#8e44ad\" 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 { "#27ae60" } else { "#c0392b" };
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=\"#5cb8d6\">HPL</text><text x=\"{:.0}\" y=\"60\" fill=\"#8e44ad\">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 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,
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);
assert_eq!(report.samples_total, report.epochs.len());
assert!(report.samples_total > 1);
assert!((0.0..=1.0).contains(&report.availability()));
assert!(
report
.epochs
.iter()
.any(|e| e.n_visible >= 5 && e.hpl_m.is_some()),
"no epoch had a protected fix"
);
let json = serde_json::to_string(&report).expect("serializes");
assert!(json.contains("samples_available"));
}
}