use crate::geometry::{sky_geometry, ClockModel, Site, SkyRequest, SystemSpec};
use crate::portable_distr::{Distribution, Normal};
use crate::portable_math::PortableFloat;
use crate::scenario::{GnssState, Scenario};
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use serde::{Deserialize, Serialize};
type Vec3 = [f64; 3];
pub const MAX_EPOCHS: usize = 10_000;
const SALT: u64 = 0x1A7E_6C17_0000_05B0;
fn d_systems() -> Vec<String> {
vec!["gps-baseline".into()]
}
fn d_mask() -> f64 {
5.0
}
fn d_sigma() -> f64 {
1.0
}
fn d_pfa() -> f64 {
1e-5
}
fn d_pmd() -> f64 {
1e-3
}
fn d_hal() -> f64 {
40.0
}
fn d_val() -> f64 {
50.0
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct InjectedBias {
pub sat_id: String,
pub bias_m: f64,
pub t0_s: f64,
pub t1_s: f64,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct IntegrityCfg {
pub site: Site,
#[serde(default = "d_systems")]
pub systems: Vec<String>,
#[serde(default = "d_mask")]
pub mask_deg: f64,
#[serde(default = "d_sigma")]
pub sigma_m: f64,
#[serde(default = "d_pfa")]
pub p_fa: f64,
#[serde(default = "d_pmd")]
pub p_md: f64,
#[serde(default = "d_hal")]
pub hal_m: f64,
#[serde(default = "d_val")]
pub val_m: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub step_s: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub injected_bias: Option<InjectedBias>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct IntegrityEpoch {
pub t_s: f64,
pub gnss_state: &'static str,
pub n_sats: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub hpl_m: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vpl_m: Option<f64>,
pub verdict: &'static str,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct IntegrityFom {
pub label: &'static str,
pub n_epochs: usize,
pub n_denied: usize,
pub n_evaluated: usize,
pub n_available: usize,
pub n_alarm: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub availability: Option<f64>,
pub availability_of_run: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub hpl_median_m: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hpl_max_m: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vpl_max_m: Option<f64>,
pub epochs: Vec<IntegrityEpoch>,
pub note: &'static str,
}
pub const NOTE: &str = "MODELLED. Snapshot RAIM (parity chi-square test, slope protection \
levels) on a representative preset sky seen from the stated site, with synthetic Gaussian \
range residuals of the stated sigma and any stated bias; unavailable while the timeline \
says GNSS is denied (never extrapolated); a degraded window is evaluated as nominal. A \
separate block: it does not change the holdover figures of merit.";
impl IntegrityCfg {
fn validate(&self) -> Result<(), String> {
let pos = |n: &str, v: f64| -> Result<(), String> {
if v.is_finite() && v > 0.0 {
Ok(())
} else {
Err(format!(
"integrity: {n} must be finite and positive, got {v}"
))
}
};
pos("sigma_m", self.sigma_m)?;
pos("hal_m", self.hal_m)?;
pos("val_m", self.val_m)?;
for (n, v) in [("p_fa", self.p_fa), ("p_md", self.p_md)] {
if !(v > 0.0 && v < 1.0) {
return Err(format!("integrity: {n} must lie in (0, 1), got {v}"));
}
}
if !(self.mask_deg.is_finite() && (0.0..90.0).contains(&self.mask_deg)) {
return Err("integrity: mask_deg must lie in [0, 90)".into());
}
if let Some(s) = self.step_s {
pos("step_s", s)?;
}
if self.systems.is_empty() {
return Err("integrity: systems must name at least one preset".into());
}
if let Some(b) = &self.injected_bias {
if !(b.bias_m.is_finite() && b.t0_s.is_finite() && b.t1_s.is_finite()) {
return Err("integrity: injected_bias needs finite bias_m, t0_s and t1_s".into());
}
}
Ok(())
}
}
fn state_name(s: GnssState) -> &'static str {
match s {
GnssState::Nominal => "nominal",
GnssState::Degraded => "degraded",
GnssState::Denied => "denied",
}
}
fn basis(site: &Site) -> (Vec3, Vec3, Vec3) {
let (p, l) = (site.lat_deg.to_radians(), site.lon_deg.to_radians());
(
[-l.psin(), l.pcos(), 0.0],
[-p.psin() * l.pcos(), -p.psin() * l.psin(), p.pcos()],
[p.pcos() * l.pcos(), p.pcos() * l.psin(), p.psin()],
)
}
pub fn sky_at(cfg: &IntegrityCfg, user: Vec3, t: f64) -> Result<Vec<(String, Vec3)>, String> {
let req = SkyRequest {
site: Some(cfg.site),
time_s: Some(t),
systems: Some(cfg.systems.iter().cloned().map(SystemSpec::Name).collect()),
mask_deg: Some(cfg.mask_deg),
clock: ClockModel::Common,
..SkyRequest::default()
};
let g = sky_geometry(&req)?;
let (e, n, u) = basis(&cfg.site);
let r = 2.0e7;
Ok(g.satellites
.iter()
.filter(|s| s.visible)
.map(|s| {
let d = crate::geometry::los_enu(s.az_deg, s.el_deg);
let pos = [
user[0] + r * (d[0] * e[0] + d[1] * n[0] + d[2] * u[0]),
user[1] + r * (d[0] * e[1] + d[1] * n[1] + d[2] * u[1]),
user[2] + r * (d[0] * e[2] + d[1] * n[2] + d[2] * u[2]),
];
(s.id.clone(), pos)
})
.collect())
}
pub fn run(scn: &Scenario, cfg: &IntegrityCfg) -> Result<IntegrityFom, String> {
cfg.validate()?;
let step = cfg.step_s.unwrap_or(scn.time.step_s.max(60.0));
let n = (scn.time.duration_s / step).floor() as usize + 1;
if n > MAX_EPOCHS {
return Err(format!(
"integrity: {n} epochs exceed the {MAX_EPOCHS}-epoch cap; raise step_s"
));
}
let user = crate::geometry::site_ecef(&cfg.site)?;
let noise = Normal::new(0.0, cfg.sigma_m).expect("finite positive sigma");
let mut epochs = Vec::with_capacity(n);
for i in 0..n {
let t = i as f64 * step;
let state = scn.gnss.state_at(t);
if state == GnssState::Denied {
epochs.push(IntegrityEpoch {
t_s: t,
gnss_state: "denied",
n_sats: 0,
hpl_m: None,
vpl_m: None,
verdict: "unavailable-gnss-denied",
});
continue;
}
let sky = sky_at(cfg, user, t)?;
let mut rng =
ChaCha8Rng::seed_from_u64(scn.seed ^ SALT ^ (i as u64).wrapping_mul(0x9E37_79B9));
let resid: Vec<f64> = sky
.iter()
.map(|(id, _)| {
let mut r = noise.sample(&mut rng);
if let Some(b) = &cfg.injected_bias {
if *id == b.sat_id && t >= b.t0_s && t < b.t1_s {
r += b.bias_m;
}
}
r
})
.collect();
let sats: Vec<Vec3> = sky.iter().map(|s| s.1).collect();
let res = crate::raim::snapshot_raim(user, &sats, &resid, cfg.sigma_m, cfg.p_fa, cfg.p_md);
let (verdict, hpl, vpl) = match res {
None => ("unavailable-too-few-satellites", None, None),
Some(r) => {
let v = if r.fault_detected {
"alarm"
} else if r.hpl_m <= cfg.hal_m && r.vpl_m <= cfg.val_m {
"available"
} else {
"unavailable-pl-above-al"
};
(v, Some(r.hpl_m), Some(r.vpl_m))
}
};
epochs.push(IntegrityEpoch {
t_s: t,
gnss_state: state_name(state),
n_sats: sats.len(),
hpl_m: hpl,
vpl_m: vpl,
verdict,
});
}
let n_denied = epochs.iter().filter(|e| e.gnss_state == "denied").count();
let n_evaluated = epochs.len() - n_denied;
let n_available = epochs.iter().filter(|e| e.verdict == "available").count();
let n_alarm = epochs.iter().filter(|e| e.verdict == "alarm").count();
let mut hpls: Vec<f64> = epochs.iter().filter_map(|e| e.hpl_m).collect();
hpls.sort_by(|a, b| a.total_cmp(b));
let median = (!hpls.is_empty()).then(|| {
let m = hpls.len() / 2;
if hpls.len() % 2 == 0 {
0.5 * (hpls[m - 1] + hpls[m])
} else {
hpls[m]
}
});
Ok(IntegrityFom {
label: "MODELLED",
n_epochs: epochs.len(),
n_denied,
n_evaluated,
n_available,
n_alarm,
availability: (n_evaluated > 0).then(|| n_available as f64 / n_evaluated as f64),
availability_of_run: n_available as f64 / epochs.len() as f64,
hpl_median_m: median,
hpl_max_m: hpls.last().copied(),
vpl_max_m: epochs.iter().filter_map(|e| e.vpl_m).reduce(f64::max),
epochs,
note: NOTE,
})
}
pub const UNITS: &[crate::field_schema::FieldUnit] = {
use crate::field_schema::{FieldUnit, ProvenanceClass::*};
macro_rules! u {
($p:expr, $u:expr, $c:expr, $d:expr) => {
FieldUnit {
path: $p,
unit: $u,
provenance: $c,
definition: $d,
}
};
}
&[
u!(
"integrity_fom.n_epochs",
"count",
Computed,
"integrity epochs"
),
u!(
"integrity_fom.n_denied",
"count",
Computed,
"epochs in a denied window, where RAIM is unavailable"
),
u!(
"integrity_fom.n_evaluated",
"count",
Computed,
"epochs evaluated (not denied)"
),
u!(
"integrity_fom.n_available",
"count",
Computed,
"evaluated epochs with no alarm and both protection levels within the alert limits"
),
u!(
"integrity_fom.n_alarm",
"count",
Computed,
"evaluated epochs whose chi-square test alarmed"
),
u!(
"integrity_fom.availability",
"1",
Computed,
"n_available / n_evaluated"
),
u!(
"integrity_fom.availability_of_run",
"1",
Computed,
"n_available / n_epochs"
),
u!(
"integrity_fom.hpl_median_m",
"m",
Computed,
"median horizontal protection level over the evaluated epochs"
),
u!(
"integrity_fom.hpl_max_m",
"m",
Computed,
"largest horizontal protection level"
),
u!(
"integrity_fom.vpl_max_m",
"m",
Computed,
"largest vertical protection level"
),
u!(
"integrity_fom.epochs[].t_s",
"s",
Computed,
"scenario time of the integrity epoch"
),
u!(
"integrity_fom.epochs[].n_sats",
"count",
Computed,
"satellites above the mask"
),
u!(
"integrity_fom.epochs[].hpl_m",
"m",
Computed,
"horizontal protection level (snapshot RAIM, slope method)"
),
u!(
"integrity_fom.epochs[].vpl_m",
"m",
Computed,
"vertical protection level (snapshot RAIM, slope method)"
),
]
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bad_sections_are_refused() {
let ok: IntegrityCfg = toml::from_str("site = { lat_deg = 59.4, lon_deg = 24.7 }").unwrap();
assert!(ok.validate().is_ok());
for bad in [
"site = { lat_deg = 59.4, lon_deg = 24.7 }\nsigma_m = 0.0",
"site = { lat_deg = 59.4, lon_deg = 24.7 }\np_fa = 1.0",
"site = { lat_deg = 59.4, lon_deg = 24.7 }\nsystems = []",
] {
let c: IntegrityCfg = toml::from_str(bad).unwrap();
assert!(c.validate().is_err(), "{bad}");
}
assert!(
toml::from_str::<IntegrityCfg>("site = { lat_deg = 1.0, lon_deg = 2.0 }\nx = 1")
.is_err()
);
}
}