use crate::resilience::arch::{PntArchitecture, PntSource, SourceKind, TechniqueCategory};
use crate::resilience::score::SimSummary;
use crate::resilience::study::ScenarioMix;
use serde::Serialize;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub enum Threat {
Nominal,
WidebandJam,
Spoofing,
Meaconing,
Combined,
}
impl Threat {
pub fn name(self) -> &'static str {
match self {
Threat::Nominal => "nominal",
Threat::WidebandJam => "wideband_jam",
Threat::Spoofing => "spoofing",
Threat::Meaconing => "meaconing",
Threat::Combined => "combined",
}
}
pub fn all() -> [Threat; 5] {
[
Threat::Nominal,
Threat::WidebandJam,
Threat::Spoofing,
Threat::Meaconing,
Threat::Combined,
]
}
fn denies_gnss(self) -> bool {
!matches!(self, Threat::Nominal)
}
}
fn holdover_capacity(kind: SourceKind, threat: Threat) -> f64 {
match kind {
SourceKind::Clock | SourceKind::Eloran => 3600.0,
SourceKind::Terrain | SourceKind::Gravity | SourceKind::Magnetic => 600.0,
SourceKind::SignalOfOpportunity => 600.0,
SourceKind::Inertial => 300.0,
SourceKind::GnssL1 | SourceKind::GnssL5 | SourceKind::GnssMultiBand => {
if threat == Threat::Nominal {
3600.0
} else {
0.0
}
}
}
}
fn detect_auc(threat: Threat, has_verify: bool, groups: usize) -> f64 {
let base = match threat {
Threat::Nominal => 0.50,
Threat::WidebandJam => {
if has_verify {
0.95
} else {
0.90
}
} Threat::Spoofing => {
if has_verify {
0.85
} else {
0.55
}
}
Threat::Meaconing => {
if has_verify {
0.70
} else {
0.52
}
} Threat::Combined => {
if has_verify {
0.92
} else {
0.60
}
}
};
(base + 0.02 * (groups as f64 - 1.0)).min(0.99)
}
pub fn simulate(arch: &PntArchitecture, threat: Threat) -> SimSummary {
let total_q: f64 = arch.sources.iter().map(|s| s.quality.clamp(0.0, 1.0)).sum();
let surviving: Vec<&PntSource> = arch
.sources
.iter()
.filter(|s| !(threat.denies_gnss() && s.kind.is_gnss_rf()))
.collect();
let surv_q: f64 = surviving.iter().map(|s| s.quality.clamp(0.0, 1.0)).sum();
let avail_base = if total_q > 0.0 { surv_q / total_q } else { 0.0 };
let holdover_s = surviving
.iter()
.map(|s| holdover_capacity(s.kind, threat))
.fold(0.0_f64, f64::max);
let has_verify = arch.has(TechniqueCategory::Verify);
let groups = arch.independent_group_count();
let auc = detect_auc(threat, has_verify, groups);
let detected = has_verify && (auc - 0.5) * 2.0 > 0.5;
let stealthy = matches!(threat, Threat::Spoofing | Threat::Meaconing) && !detected;
let availability = if threat == Threat::Nominal || !stealthy {
avail_base
} else {
avail_base * 0.5
};
let integrity = if threat == Threat::Nominal || detected {
avail_base
} else {
avail_base * 0.5
};
let bounded = if threat == Threat::Nominal {
true
} else {
holdover_s > 0.0 && !stealthy
};
SimSummary {
holdover_s,
availability,
detect_auc: auc,
integrity,
security: ((auc - 0.5) * 2.0).clamp(0.0, 1.0),
bounded,
}
}
fn src(kind: SourceKind, group: u32, quality: f64) -> PntSource {
PntSource::new(kind, group, quality)
}
pub fn reference_panel() -> Vec<PntArchitecture> {
use TechniqueCategory::*;
vec![
PntArchitecture::new("gnss_l1", vec![src(SourceKind::GnssL1, 1, 0.95)], []),
PntArchitecture::new(
"gnss_multiband",
vec![
src(SourceKind::GnssMultiBand, 1, 1.0),
src(SourceKind::GnssL5, 1, 0.9),
],
[Verify, Diversify],
),
PntArchitecture::new(
"gnss_ins",
vec![
src(SourceKind::GnssMultiBand, 1, 1.0),
src(SourceKind::Inertial, 2, 0.6),
],
[Verify, Diversify, Mitigate, Recover],
),
PntArchitecture::new(
"spoof_hardened",
vec![
src(SourceKind::GnssMultiBand, 1, 1.0),
src(SourceKind::Inertial, 2, 0.6),
],
[Verify, Isolate, Mitigate],
),
PntArchitecture::new(
"checkbox_gnss",
vec![src(SourceKind::GnssL1, 1, 0.95)],
TechniqueCategory::all(),
),
PntArchitecture::new(
"quad_gnss",
vec![
src(SourceKind::GnssMultiBand, 1, 0.95),
src(SourceKind::GnssL5, 2, 0.9),
src(SourceKind::GnssL1, 3, 0.9),
src(SourceKind::GnssMultiBand, 4, 0.9),
],
[
TechniqueCategory::Verify,
TechniqueCategory::Diversify,
TechniqueCategory::Mitigate,
],
),
PntArchitecture::new(
"diverse_full",
vec![
src(SourceKind::GnssMultiBand, 1, 1.0),
src(SourceKind::Inertial, 2, 0.6),
src(SourceKind::Clock, 3, 0.9),
src(SourceKind::Eloran, 4, 0.8),
],
TechniqueCategory::all(),
),
]
}
pub fn scenario_ensemble(panel: &[PntArchitecture]) -> Vec<ScenarioMix> {
Threat::all()
.iter()
.map(|&t| ScenarioMix {
name: t.name().to_string(),
per_arch: panel
.iter()
.map(|a| (a.name.clone(), simulate(a, t)))
.collect(),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn by_name<'a>(panel: &'a [PntArchitecture], name: &str) -> &'a PntArchitecture {
panel.iter().find(|a| a.name == name).unwrap()
}
#[test]
fn all_gnss_collapses_under_wideband_jam() {
let p = reference_panel();
let s = simulate(by_name(&p, "gnss_l1"), Threat::WidebandJam);
assert!(s.availability < 1e-9, "should lose availability: {s:?}");
assert_eq!(s.holdover_s, 0.0);
assert!(!s.bounded, "all-GNSS under jam must be unbounded");
}
#[test]
fn diverse_architecture_coasts_under_jam() {
let p = reference_panel();
let s = simulate(by_name(&p, "diverse_full"), Threat::WidebandJam);
assert_eq!(s.holdover_s, 3600.0, "clock/eLoran should sustain holdover");
assert!(s.bounded, "diverse architecture must stay bounded");
assert!(s.availability > 0.0);
}
#[test]
fn meaconing_is_harder_to_detect_than_jamming() {
let p = reference_panel();
let a = by_name(&p, "diverse_full"); assert!(
simulate(a, Threat::Meaconing).detect_auc < simulate(a, Threat::WidebandJam).detect_auc
);
}
#[test]
fn paper_tiger_and_diverse_share_declared_posture() {
let p = reference_panel();
assert_eq!(
by_name(&p, "checkbox_gnss").techniques,
by_name(&p, "diverse_full").techniques
);
assert_ne!(
simulate(by_name(&p, "checkbox_gnss"), Threat::WidebandJam).bounded,
simulate(by_name(&p, "diverse_full"), Threat::WidebandJam).bounded
);
}
#[test]
fn ensemble_covers_every_threat_and_architecture() {
let p = reference_panel();
let e = scenario_ensemble(&p);
assert_eq!(e.len(), Threat::all().len());
for mix in &e {
assert_eq!(mix.per_arch.len(), p.len());
}
}
}