use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Serialize;
use serde_json::Value;
use super::ingest::{days_from_civil, read_ubx, timeline_from};
use super::monitors::{run_monitors, MonitorConfig, TrustResult};
use super::{LogEpoch, SatCn0, Timeline};
use crate::realdata::jammertest::{mon_rf_records, rinex_cn0_records};
pub const TZ_SHIFT_S: f64 = -7200.0;
pub const GUARD_S: f64 = 60.0;
pub const ALPHA: f64 = 0.01;
pub const B1_MAX_PFA: f64 = 0.02;
pub const B2_MAX_PFA: f64 = 0.05;
pub const B2_MIN_PD: f64 = 0.50;
pub const MIN_JAMMING_FOLDERS: usize = 3;
pub const MIN_CLEAN_EPOCHS: usize = 1000;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Stat {
Cn0Drop,
Agc,
JamInd,
}
impl Stat {
pub const ALL: [Stat; 3] = [Stat::Cn0Drop, Stat::Agc, Stat::JamInd];
pub fn config_field(self) -> &'static str {
match self {
Stat::Cn0Drop => "cn0_drop_db",
Stat::Agc => "agc_k_sigma",
Stat::JamInd => "jam_ind_threshold",
}
}
pub fn default_threshold(self) -> f64 {
let d = MonitorConfig::default();
match self {
Stat::Cn0Drop => d.cn0_drop_db,
Stat::Agc => d.agc_k_sigma,
Stat::JamInd => d.jam_ind_threshold,
}
}
pub fn grid(self) -> Vec<f64> {
match self {
Stat::Cn0Drop => (1..=60).map(|i| f64::from(i) * 0.5).collect(),
Stat::Agc => (1..=40)
.map(|i| f64::from(i) * 0.5)
.chain((21..=100).map(f64::from))
.chain((11..=100).map(|i| f64::from(i) * 10.0))
.collect(),
Stat::JamInd => (0..=254).map(f64::from).collect(),
}
}
pub fn alarms(self, v: Option<f64>, thr: f64) -> bool {
match (self, v) {
(_, None) => false,
(Stat::Cn0Drop, Some(v)) => v >= thr,
(Stat::Agc | Stat::JamInd, Some(v)) => v > thr,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Class {
Clean,
Jamming,
OtherAttack,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Sample {
pub class: Class,
pub cn0_drop: Option<f64>,
pub agc_absz: Option<f64>,
pub jam_ind: Option<f64>,
}
impl Sample {
pub fn get(&self, s: Stat) -> Option<f64> {
match s {
Stat::Cn0Drop => self.cn0_drop,
Stat::Agc => self.agc_absz,
Stat::JamInd => self.jam_ind,
}
}
}
pub fn utc_seconds(ts: &str) -> Option<f64> {
let ts = ts.trim().trim_end_matches(" UTC").trim_end_matches('Z');
let (date, time) = ts.split_once([' ', 'T'])?;
let mut d = date.split('-');
let y: i64 = d.next()?.parse().ok()?;
let mo: i64 = d.next()?.parse().ok()?;
let da: i64 = d.next()?.parse().ok()?;
let mut t = time.trim().split(':');
let h: f64 = t.next()?.parse().ok()?;
let mi: f64 = t.next()?.parse().ok()?;
let s: f64 = t.next()?.parse().ok()?;
if !(1..=12).contains(&mo) || !(1..=31).contains(&da) || !(0.0..24.0).contains(&h) {
return None;
}
Some(days_from_civil(y, mo, da) as f64 * 86_400.0 + h * 3600.0 + mi * 60.0 + s)
}
pub fn attack_windows(meta: &Value) -> Vec<(f64, f64)> {
let mut wins = Vec::new();
let mut open = None;
for ev in meta
.get("attack_log")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let (Some(ts), Some(text)) = (
ev.get("timestamp_utc").and_then(Value::as_str),
ev.get("event").and_then(Value::as_str),
) else {
continue;
};
let Some(t) = utc_seconds(ts) else { continue };
let t = t + TZ_SHIFT_S;
let e = text.to_lowercase();
if e.contains("start") {
open = Some(t);
} else if e.contains("end") || e.contains("stop") {
if let Some(s) = open.take() {
wins.push((s, t));
}
}
}
wins
}
pub fn label(t: f64, windows: &[(f64, f64)], jamming_folder: bool) -> Option<Class> {
if windows
.iter()
.any(|&(s, e)| t >= s + GUARD_S && t <= e - GUARD_S)
{
return Some(if jamming_folder {
Class::Jamming
} else {
Class::OtherAttack
});
}
if windows
.iter()
.all(|&(s, e)| t < s - GUARD_S || t > e + GUARD_S)
{
return Some(Class::Clean);
}
None
}
pub fn calibration_contaminated(t0: f64, calibration_s: f64, windows: &[(f64, f64)]) -> bool {
windows
.iter()
.any(|&(s, e)| t0 + calibration_s > s - GUARD_S && t0 < e)
}
pub fn timeline_from_csv(mon_rf: &str, rinex: &str) -> Option<(Timeline, f64)> {
#[derive(Default)]
struct Bin {
agc: (f64, usize),
jam: Option<f64>,
cn0: BTreeMap<(String, &'static str), (f64, usize)>,
}
let mut bins: BTreeMap<i64, Bin> = BTreeMap::new();
for r in mon_rf_records(mon_rf) {
let Some(t) = utc_seconds(&r.time) else {
continue;
};
let b = bins.entry(t.floor() as i64).or_default();
if let Some(a) = r.agc {
b.agc.0 += a;
b.agc.1 += 1;
}
if let Some(j) = r.jam_ind {
b.jam = Some(b.jam.map_or(j, |x: f64| x.max(j)));
}
}
for r in rinex_cn0_records(rinex) {
let Some(t) = utc_seconds(&r.time) else {
continue;
};
let acc = bins
.entry(t.floor() as i64)
.or_default()
.cn0
.entry((r.sat, r.band))
.or_default();
acc.0 += r.cn0_dbhz;
acc.1 += 1;
}
let first = *bins.keys().next()?;
let epochs: Vec<LogEpoch> = bins
.into_iter()
.map(|(k, b)| LogEpoch {
t_s: (k - first) as f64,
agc: (b.agc.1 > 0).then(|| b.agc.0 / b.agc.1 as f64),
jam_ind: b.jam,
cn0: b
.cn0
.into_iter()
.map(|((sat, band), (s, n))| SatCn0 {
sat,
band: band.to_string(),
cn0_dbhz: s / n as f64,
})
.collect(),
..LogEpoch::default()
})
.collect();
Some((timeline_from(epochs, 0), first as f64))
}
pub fn timeline_origin_utc(tl: &Timeline) -> Option<f64> {
tl.epochs.iter().find_map(|e| {
let l = e.time_label.as_deref()?;
if l.starts_with("GPS") {
return None;
}
utc_seconds(l).map(|t| t - e.t_s)
})
}
pub fn samples_from(
trust: &TrustResult,
t0: f64,
calibration_s: f64,
windows: &[(f64, f64)],
jamming_folder: bool,
) -> Vec<Sample> {
trust
.epochs
.iter()
.filter(|e| e.t_s >= calibration_s)
.filter_map(|e| {
let class = label(t0 + e.t_s, windows, jamming_folder)?;
Some(Sample {
class,
cn0_drop: e.cn0_drop_db,
agc_absz: e.agc_z.map(f64::abs),
jam_ind: e.jam_ind,
})
})
.collect()
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize)]
pub struct Counts {
pub clean: usize,
pub clean_alarmed: usize,
pub jamming: usize,
pub jamming_alarmed: usize,
pub other: usize,
pub other_alarmed: usize,
}
impl Counts {
pub fn of(samples: &[Sample], alarm: impl Fn(&Sample) -> bool) -> Self {
let mut c = Counts::default();
for s in samples {
let a = alarm(s) as usize;
match s.class {
Class::Clean => (c.clean += 1, c.clean_alarmed += a),
Class::Jamming => (c.jamming += 1, c.jamming_alarmed += a),
Class::OtherAttack => (c.other += 1, c.other_alarmed += a),
};
}
c
}
pub fn add(&mut self, o: &Counts) {
self.clean += o.clean;
self.clean_alarmed += o.clean_alarmed;
self.jamming += o.jamming;
self.jamming_alarmed += o.jamming_alarmed;
self.other += o.other;
self.other_alarmed += o.other_alarmed;
}
pub fn pfa(&self) -> Option<f64> {
(self.clean > 0).then(|| self.clean_alarmed as f64 / self.clean as f64)
}
pub fn pd(&self) -> Option<f64> {
(self.jamming > 0).then(|| self.jamming_alarmed as f64 / self.jamming as f64)
}
pub fn pd_other(&self) -> Option<f64> {
(self.other > 0).then(|| self.other_alarmed as f64 / self.other as f64)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct RocPoint {
pub threshold: f64,
pub pfa: Option<f64>,
pub pd: Option<f64>,
pub pd_other: Option<f64>,
}
pub fn roc(samples: &[Sample], s: Stat) -> Vec<RocPoint> {
s.grid()
.into_iter()
.map(|thr| {
let c = Counts::of(samples, |x| s.alarms(x.get(s), thr));
RocPoint {
threshold: thr,
pfa: c.pfa(),
pd: c.pd(),
pd_other: c.pd_other(),
}
})
.collect()
}
pub fn select(samples: &[Sample], s: Stat, alpha: f64) -> Option<f64> {
s.grid().into_iter().find(|&thr| {
Counts::of(samples, |x| s.alarms(x.get(s), thr))
.pfa()
.is_some_and(|p| p <= alpha)
})
}
pub fn auc(samples: &[Sample], s: Stat) -> Option<f64> {
let key = |x: &Sample| x.get(s).unwrap_or(f64::NEG_INFINITY);
let mut v: Vec<(f64, bool)> = samples
.iter()
.filter(|x| x.class != Class::OtherAttack)
.map(|x| (key(x), x.class == Class::Jamming))
.collect();
let n_pos = v.iter().filter(|x| x.1).count();
let n_neg = v.len() - n_pos;
if n_pos == 0 || n_neg == 0 {
return None;
}
v.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut rank_sum = 0.0;
let mut i = 0;
while i < v.len() {
let mut j = i;
while j + 1 < v.len() && v[j + 1].0 == v[i].0 {
j += 1;
}
let avg = (i + j) as f64 / 2.0 + 1.0;
rank_sum += avg * v[i..=j].iter().filter(|x| x.1).count() as f64;
i = j + 1;
}
let np = n_pos as f64;
Some((rank_sum - np * (np + 1.0) / 2.0) / (np * n_neg as f64))
}
pub type Thresholds = [f64; 3];
pub fn default_thresholds() -> Thresholds {
Stat::ALL.map(Stat::default_threshold)
}
pub fn union_counts(samples: &[Sample], thr: &Thresholds) -> Counts {
Counts::of(samples, |x| {
Stat::ALL
.iter()
.zip(thr)
.any(|(s, t)| s.alarms(x.get(*s), *t))
})
}
#[derive(Clone, Debug, Serialize)]
pub struct Folder {
pub path: String,
pub attack_type: String,
pub rover_state: String,
pub jamming: bool,
pub windows_utc_s: Vec<(f64, f64)>,
pub log_start_utc_s: Option<f64>,
pub excluded: Option<String>,
pub epochs: Counts,
#[serde(skip)]
pub samples: Vec<Sample>,
pub ubx: Option<PathBuf>,
}
fn find_scenarios(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else {
return;
};
let mut entries: Vec<_> = rd.flatten().map(|e| e.path()).collect();
entries.sort();
if entries.iter().any(|p| p.ends_with("scenario.json")) {
out.push(dir.to_path_buf());
}
for p in entries {
if p.is_dir() {
find_scenarios(&p, out);
}
}
}
fn label_timeline(
tl: &Timeline,
t0: f64,
windows: &[(f64, f64)],
jamming: bool,
) -> Result<Vec<Sample>, String> {
let cfg = MonitorConfig::default();
if calibration_contaminated(t0, cfg.calibration_s, windows) {
return Err(
"calibration window less than the guard before, or inside, an attack window".into(),
);
}
let trust = run_monitors(tl, None, &cfg).map_err(|e| format!("run_monitors: {e}"))?;
Ok(samples_from(
&trust,
t0,
cfg.calibration_s,
windows,
jamming,
))
}
pub fn read_folder(root: &Path, dir: &Path) -> Folder {
let rel = dir
.strip_prefix(root)
.unwrap_or(dir)
.to_string_lossy()
.into_owned();
let meta: Value = std::fs::read_to_string(dir.join("scenario.json"))
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or(Value::Null);
let attack_type = meta
.get("attack_type")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
let jamming = attack_type.to_lowercase().contains("jamming");
let lower = rel.to_lowercase();
let rover_state = if lower.contains("stationary") {
"stationary"
} else if lower.contains("dynamic") {
"dynamic"
} else {
"unknown"
}
.to_string();
let windows = attack_windows(&meta);
let ubx = std::fs::read_dir(dir).ok().and_then(|rd| {
let mut v: Vec<PathBuf> = rd
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "ubx"))
.collect();
v.sort();
v.into_iter().next()
});
let mut f = Folder {
path: rel,
attack_type,
rover_state,
jamming,
windows_utc_s: windows.clone(),
log_start_utc_s: None,
excluded: None,
epochs: Counts::default(),
samples: Vec::new(),
ubx,
};
if windows.is_empty() {
f.excluded = Some("no attack window in attack_log".into());
return f;
}
let mon = std::fs::read_to_string(dir.join("mon_rf.csv")).unwrap_or_default();
let rnx = std::fs::read_to_string(dir.join("rinex.csv")).unwrap_or_default();
let Some((tl, t0)) = timeline_from_csv(&mon, &rnx) else {
f.excluded = Some("no readable epoch in mon_rf.csv or rinex.csv".into());
return f;
};
f.log_start_utc_s = Some(t0);
match label_timeline(&tl, t0, &windows, jamming) {
Ok(s) => {
f.epochs = Counts::of(&s, |_| false);
f.samples = s;
}
Err(e) => f.excluded = Some(e),
}
f
}
pub fn read_folder_ubx(f: &Folder) -> Option<Result<Vec<Sample>, String>> {
let path = f.ubx.as_ref()?;
Some((|| {
let bytes = std::fs::read(path).map_err(|e| format!("read: {e}"))?;
let tl = read_ubx(&bytes);
let t0 = timeline_origin_utc(&tl).ok_or("no UTC-labelled epoch in the UBX log")?;
label_timeline(&tl, t0, &f.windows_utc_s, f.jamming)
})())
}
#[derive(Clone, Debug, Serialize)]
pub struct StatReport {
pub stat: Stat,
pub config_field: &'static str,
pub default_threshold: f64,
pub at_default: Counts,
pub auc: Option<f64>,
pub selected: Option<f64>,
pub at_selected: Option<Counts>,
pub loso_thresholds: BTreeMap<String, Option<f64>>,
pub loso_heldout: Counts,
pub by_state: BTreeMap<String, (Counts, Option<Counts>)>,
pub roc: Vec<RocPoint>,
}
#[derive(Clone, Debug, Serialize)]
pub struct Bar {
pub bar: String,
pub measured: String,
pub pass: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct UbxCheck {
pub path: String,
pub error: Option<String>,
pub csv_default: Counts,
pub ubx_default: Option<Counts>,
pub csv_calibrated: Counts,
pub ubx_calibrated: Option<Counts>,
}
#[derive(Clone, Debug, Serialize)]
pub struct CalibrationReport {
pub schema: &'static str,
pub dataset: &'static str,
pub method: BTreeMap<&'static str, f64>,
pub folders: Vec<Folder>,
pub n_jamming_folders: usize,
pub stats: Vec<StatReport>,
pub calibrated: Thresholds,
pub union_default: Counts,
pub union_calibrated: Counts,
pub union_loso_heldout: Counts,
pub bars: Vec<Bar>,
pub minimum_data: bool,
pub verdict: &'static str,
pub ubx_check: Vec<UbxCheck>,
}
fn pooled(folders: &[&Folder]) -> Vec<Sample> {
folders
.iter()
.flat_map(|f| f.samples.iter().copied())
.collect()
}
fn fmt_p(p: Option<f64>) -> String {
p.map_or("n/a".into(), |p| format!("{p:.4}"))
}
pub fn run_dataset(root: &Path, with_ubx: bool) -> Result<CalibrationReport, String> {
let mut dirs = Vec::new();
find_scenarios(root, &mut dirs);
if dirs.is_empty() {
return Err(format!("no scenario.json under {}", root.display()));
}
let folders: Vec<Folder> = dirs.iter().map(|d| read_folder(root, d)).collect();
let ev: Vec<&Folder> = folders.iter().filter(|f| f.excluded.is_none()).collect();
let all = pooled(&ev);
let n_jamming_folders = ev
.iter()
.filter(|f| f.jamming && f.epochs.jamming > 0)
.count();
let mut stats = Vec::new();
let mut calibrated = default_thresholds();
let mut loso_thr: BTreeMap<String, Thresholds> = BTreeMap::new();
for (k, s) in Stat::ALL.into_iter().enumerate() {
let selected = select(&all, s, ALPHA);
if let Some(t) = selected {
calibrated[k] = t;
}
let mut loso_thresholds = BTreeMap::new();
let mut loso_heldout = Counts::default();
for (i, f) in ev.iter().enumerate() {
let others: Vec<&Folder> = ev
.iter()
.enumerate()
.filter(|(j, _)| *j != i)
.map(|(_, g)| *g)
.collect();
let t = select(&pooled(&others), s, ALPHA);
loso_thresholds.insert(f.path.clone(), t);
let thr = t.unwrap_or_else(|| s.default_threshold());
loso_thr
.entry(f.path.clone())
.or_insert_with(default_thresholds)[k] = thr;
loso_heldout.add(&Counts::of(&f.samples, |x| s.alarms(x.get(s), thr)));
}
let mut by_state = BTreeMap::new();
for st in ["stationary", "dynamic"] {
let sub = pooled(
&ev.iter()
.filter(|f| f.rover_state == st)
.copied()
.collect::<Vec<_>>(),
);
let d = Counts::of(&sub, |x| s.alarms(x.get(s), s.default_threshold()));
let c = selected.map(|t| Counts::of(&sub, |x| s.alarms(x.get(s), t)));
by_state.insert(st.to_string(), (d, c));
}
stats.push(StatReport {
stat: s,
config_field: s.config_field(),
default_threshold: s.default_threshold(),
at_default: Counts::of(&all, |x| s.alarms(x.get(s), s.default_threshold())),
auc: auc(&all, s),
selected,
at_selected: selected.map(|t| Counts::of(&all, |x| s.alarms(x.get(s), t))),
loso_thresholds,
loso_heldout,
by_state,
roc: roc(&all, s),
});
}
let mut union_loso_heldout = Counts::default();
for f in &ev {
union_loso_heldout.add(&union_counts(&f.samples, &loso_thr[&f.path]));
}
let mut bars = Vec::new();
for r in &stats {
let p = r.loso_heldout.pfa();
bars.push(Bar {
bar: format!(
"B1 {}: held-out clean Pfa <= {B1_MAX_PFA} (calibrated: {})",
r.config_field,
r.selected.is_some()
),
measured: format!(
"Pfa {} ({}/{})",
fmt_p(p),
r.loso_heldout.clean_alarmed,
r.loso_heldout.clean
),
pass: r.selected.is_some() && p.is_some_and(|p| p <= B1_MAX_PFA),
});
}
let (up, ud) = (union_loso_heldout.pfa(), union_loso_heldout.pd());
bars.push(Bar {
bar: format!("B2 union: held-out clean Pfa <= {B2_MAX_PFA} and jamming Pd >= {B2_MIN_PD}"),
measured: format!(
"Pfa {} ({}/{}), Pd {} ({}/{})",
fmt_p(up),
union_loso_heldout.clean_alarmed,
union_loso_heldout.clean,
fmt_p(ud),
union_loso_heldout.jamming_alarmed,
union_loso_heldout.jamming
),
pass: up.is_some_and(|p| p <= B2_MAX_PFA) && ud.is_some_and(|p| p >= B2_MIN_PD),
});
let n_clean = all.iter().filter(|s| s.class == Class::Clean).count();
let minimum_data = n_jamming_folders >= MIN_JAMMING_FOLDERS && n_clean >= MIN_CLEAN_EPOCHS;
let verdict = if !minimum_data {
"MODELLED (insufficient data)"
} else if bars.iter().all(|b| b.pass) {
"VALIDATED"
} else {
"MODELLED (a pre-registered bar failed)"
};
let mut ubx_check = Vec::new();
if with_ubx {
for f in &ev {
let Some(r) = read_folder_ubx(f) else {
continue;
};
let dflt = default_thresholds();
let (error, ubx_default, ubx_calibrated) = match r {
Ok(s) => (
None,
Some(union_counts(&s, &dflt)),
Some(union_counts(&s, &calibrated)),
),
Err(e) => (Some(e), None, None),
};
ubx_check.push(UbxCheck {
path: f.path.clone(),
error,
csv_default: union_counts(&f.samples, &dflt),
ubx_default,
csv_calibrated: union_counts(&f.samples, &calibrated),
ubx_calibrated,
});
}
}
let method = BTreeMap::from([
("tz_shift_s", TZ_SHIFT_S),
("guard_s", GUARD_S),
("alpha", ALPHA),
("b1_max_pfa", B1_MAX_PFA),
("b2_max_pfa", B2_MAX_PFA),
("b2_min_pd", B2_MIN_PD),
("calibration_s", MonitorConfig::default().calibration_s),
]);
Ok(CalibrationReport {
schema: "kshana-receiver-trust-jammertest-calibration/1",
dataset:
"GNSS Dataset Under Jamming, Spoofing, and Meaconing Conditions (JammerTest 2024), \
Zenodo record 15911589, doi:10.5281/zenodo.15910563, GPL-3.0-or-later",
method,
union_default: union_counts(&all, &default_thresholds()),
union_calibrated: union_counts(&all, &calibrated),
folders,
n_jamming_folders,
stats,
calibrated,
union_loso_heldout,
bars,
minimum_data,
verdict,
ubx_check,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn s(class: Class, v: f64) -> Sample {
Sample {
class,
cn0_drop: Some(v),
agc_absz: Some(v),
jam_ind: Some(v),
}
}
#[test]
fn utc_seconds_reads_both_stamp_styles() {
let a = utc_seconds("2024-09-10 13:57:53.600000").unwrap();
let b = utc_seconds("2024-09-10T13:57:53.600Z").unwrap();
assert!((a - b).abs() < 1e-9);
assert_eq!(utc_seconds("1970-01-02T00:00:01Z").unwrap(), 86_401.0);
assert!(utc_seconds("GPS week 2331 TOW 1.000 s").is_none());
}
#[test]
fn attack_windows_pair_start_and_end_and_shift_to_utc() {
let meta: Value = serde_json::from_str(
r#"{"attack_log":[
{"timestamp_utc":"2024-09-10T16:00:00Z","event":"Jamming started"},
{"timestamp_utc":"2024-09-10T16:40:00Z","event":"Jamming end"},
{"timestamp_utc":"2024-09-10T17:00:00Z","event":"Meaconing started"},
{"timestamp_utc":"2024-09-10T17:10:00Z","event":"Meaconing ended"}]}"#,
)
.unwrap();
let w = attack_windows(&meta);
let t = utc_seconds("2024-09-10T14:00:00Z").unwrap();
assert_eq!(w.len(), 2);
assert_eq!(w[0], (t, t + 2400.0));
assert_eq!(w[1], (t + 3600.0, t + 4200.0));
}
#[test]
fn labels_respect_the_guard_band_and_the_folder_class() {
let w = [(1000.0, 2000.0)];
assert_eq!(label(939.0, &w, true), Some(Class::Clean));
assert_eq!(label(941.0, &w, true), None);
assert_eq!(label(1060.0, &w, true), Some(Class::Jamming));
assert_eq!(label(1500.0, &w, false), Some(Class::OtherAttack));
assert_eq!(label(1950.0, &w, true), None);
assert_eq!(label(2061.0, &w, true), Some(Class::Clean));
assert!(calibration_contaminated(900.0, 60.0, &w));
assert!(calibration_contaminated(1500.0, 60.0, &w));
assert!(!calibration_contaminated(870.0, 60.0, &w));
assert!(!calibration_contaminated(2000.0, 60.0, &w));
}
#[test]
fn roc_selection_and_auc_on_a_hand_built_set() {
let mut v: Vec<Sample> = (0..100).map(|i| s(Class::Clean, i as f64 / 10.0)).collect();
v.extend((0..10).map(|_| s(Class::Jamming, 20.0)));
let c = Counts::of(&v, |x| Stat::Cn0Drop.alarms(x.cn0_drop, 9.5));
assert_eq!((c.clean_alarmed, c.jamming_alarmed), (5, 10));
assert_eq!(select(&v, Stat::Cn0Drop, 0.01), Some(10.0));
assert_eq!(select(&v, Stat::JamInd, 0.01), Some(10.0));
assert_eq!(auc(&v, Stat::Agc), Some(1.0));
let r = roc(&v, Stat::Cn0Drop);
assert_eq!(r.len(), 60);
assert!(r
.windows(2)
.all(|p| p[1].pfa <= p[0].pfa && p[1].pd <= p[0].pd));
}
#[test]
fn undefined_statistics_never_alarm_and_rank_lowest() {
let none = Sample {
class: Class::Jamming,
cn0_drop: None,
agc_absz: None,
jam_ind: None,
};
assert!(!Stat::Cn0Drop.alarms(None, 0.0));
let v = vec![none, s(Class::Clean, 1.0)];
assert_eq!(auc(&v, Stat::Cn0Drop), Some(0.0));
}
#[test]
fn csv_timeline_bins_by_second_and_feeds_the_monitors() {
let mut mon = String::from("identity,real_time,agcCnt_01,agcCnt_02,jamInd_01,jamInd_02\n");
let mut rnx = String::from("time,satellite,snr_L1,snr_L2\n");
for t in 0..200 {
let (hh, mm, ss) = (10 + t / 3600, t / 60 % 60, t % 60);
let jam = if t >= 150 { 200 } else { 10 };
mon.push_str(&format!(
"MON-RF,2024-09-10 {hh:02}:{mm:02}:{ss:02}.600000,5000,4000,{jam},5\n"
));
for sat in ["G01", "G02", "E03", "E04"] {
let c = if t >= 150 { 30.0 } else { 45.0 };
rnx.push_str(&format!(
"2024-09-10 {hh:02}:{mm:02}:{ss:02}.0,{sat},{c},{}\n",
c - 3.0
));
}
}
let (tl, t0) = timeline_from_csv(&mon, &rnx).unwrap();
assert_eq!(tl.epochs.len(), 200);
assert_eq!(t0, utc_seconds("2024-09-10 10:00:00").unwrap());
assert_eq!(tl.epochs[0].agc, Some(4500.0));
assert_eq!(tl.epochs[0].cn0.len(), 8);
let trust = run_monitors(&tl, None, &MonitorConfig::default()).unwrap();
let w = [(t0 + 140.0, t0 + 400.0)];
let samples = samples_from(&trust, t0, 60.0, &w, true);
assert_eq!(samples.len(), 20);
assert!(samples.iter().all(|s| s.class == Class::Clean));
let last = trust.epochs.last().unwrap();
assert_eq!(last.jam_ind, Some(200.0));
assert!((last.cn0_drop_db.unwrap() - 15.0).abs() < 1e-9);
}
}