use kshana::impairment_study::{
build_real_gap_rows, real_loocv, real_permutation_pvalue, CvAxis, CvResult, GapSample,
ProbeRecord,
};
use kshana::realdata::{jammertest, Orient};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
const TZ_OFFSET_SEC: i64 = 7200;
fn die(msg: String) -> ! {
eprintln!("{msg}");
std::process::exit(1);
}
fn secs_of_day(ts: &str) -> Option<i64> {
let tail = ts.split([' ', 'T']).nth(1)?;
let hms = tail.trim_end_matches('Z');
let mut it = hms.split(':');
let h: i64 = it.next()?.trim().parse().ok()?;
let m: i64 = it.next()?.trim().parse().ok()?;
let s: i64 = it.next()?.split('.').next()?.trim().parse().ok()?;
Some(h * 3600 + m * 60 + s)
}
fn attack_windows(meta: &Value) -> Vec<(i64, i64)> {
let mut wins = Vec::new();
let mut open: Option<i64> = None;
if let Some(log) = meta.get("attack_log").and_then(Value::as_array) {
for ev in log {
let (Some(ts), Some(event)) = (
ev.get("timestamp_utc").and_then(Value::as_str),
ev.get("event").and_then(Value::as_str),
) else {
continue;
};
let Some(raw) = secs_of_day(ts) else { continue };
let s = (raw - TZ_OFFSET_SEC).rem_euclid(86_400);
let e = event.to_lowercase();
if e.contains("start") {
open = Some(s);
} else if (e.contains("end") || e.contains("stop")) && open.is_some() {
wins.push((open.take().unwrap(), s));
}
}
}
wins
}
fn in_attack(secs: i64, wins: &[(i64, i64)]) -> bool {
wins.iter().any(|&(s, e)| {
if s <= e {
secs >= s && secs <= e
} else {
secs >= s || secs <= e
}
})
}
fn find_scenarios(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else {
return;
};
let entries: Vec<_> = rd.flatten().collect();
if entries.iter().any(|e| e.file_name() == "scenario.json") {
out.push(dir.to_path_buf());
}
for e in entries {
if e.path().is_dir() {
find_scenarios(&e.path(), out);
}
}
}
fn cv_json(cv: &CvResult) -> Value {
json!({
"r2": cv.r2, "rmse": cv.rmse, "n_folds": cv.n_folds, "n_points": cv.pred_actual.len(),
"scatter": cv.pred_actual.iter().map(|(p, a)| json!({"predicted": p, "actual": a})).collect::<Vec<_>>(),
})
}
fn main() {
let mut args = std::env::args().skip(1);
let root = args.next().unwrap_or_else(|| {
die("usage: jammertest_probe <dataset_root> <out.json> [id_state]".into())
});
let out = args
.next()
.unwrap_or_else(|| die("missing out.json".into()));
let id_state = args.next().unwrap_or_else(|| "stationary".to_string());
let (lambda, target_pfa) = (0.1, 0.05);
let mut scenarios = Vec::new();
find_scenarios(Path::new(&root), &mut scenarios);
scenarios.sort();
if scenarios.is_empty() {
die(format!("no scenario.json found under {root}"));
}
let mut records: Vec<ProbeRecord> = Vec::new();
let (mut n_clean, mut n_attack, mut n_skipped) = (0u64, 0u64, 0u64);
for dir in &scenarios {
let meta: Value = match std::fs::read_to_string(dir.join("scenario.json"))
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
{
Some(m) => m,
None => {
n_skipped += 1;
continue;
}
};
let class = meta
.get("attack_type")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_lowercase()
.replace([' ', '+'], "_");
let path_str = dir.to_string_lossy().to_lowercase();
let state = if path_str.contains("dynamic") {
"dynamic"
} else if path_str.contains("stationary") {
"stationary"
} else {
n_skipped += 1;
continue;
};
let wins = attack_windows(&meta);
if wins.is_empty() {
n_skipped += 1;
continue; }
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 mut samples = jammertest::mon_rf_observations(&mon, Orient::Negate);
samples.extend(jammertest::rinex_cn0_observations(&rnx));
for t in &samples {
let Some(secs) = secs_of_day(&t.time) else {
continue;
};
let is_attack = in_attack(secs, &wins);
if is_attack {
n_attack += 1;
} else {
n_clean += 1;
}
records.push(ProbeRecord::new(
t.obs.detector.clone(),
if is_attack { class.as_str() } else { "nominal" },
state,
t.obs.score,
!is_attack,
));
}
}
eprintln!(
"{} scenarios ({n_skipped} skipped) -> {} records: clean {n_clean}, attack {n_attack}",
scenarios.len(),
records.len()
);
let samples: Vec<GapSample> = build_real_gap_rows(&records, &id_state, target_pfa);
if samples.is_empty() {
die(format!(
"no gap samples — each detector/class needs clean+attack in both '{id_state}' and the other state"
));
}
let mut sorted = samples.clone();
sorted.sort_by(|a, b| {
(a.class.clone(), a.detector.clone()).cmp(&(b.class.clone(), b.detector.clone()))
});
eprintln!("\n{} gap samples (detector x class):", sorted.len());
for s in &sorted {
eprintln!(
" {:<10} {:<18} gap {:+.3} (auc_in {:.3})",
s.detector, s.class, s.gap, s.features[0]
);
}
let by_det = real_loocv(&samples, lambda, CvAxis::Detector);
let by_class = real_loocv(&samples, lambda, CvAxis::Class);
let p_det = real_permutation_pvalue(&samples, lambda, CvAxis::Detector, 2000, 20_240_911);
let p_class = real_permutation_pvalue(&samples, lambda, CvAxis::Class, 2000, 20_240_911);
let classes: std::collections::BTreeSet<&str> =
sorted.iter().map(|s| s.class.as_str()).collect();
let detectors: std::collections::BTreeSet<&str> =
sorted.iter().map(|s| s.detector.as_str()).collect();
let value = json!({
"schema_version": "jammertest-optimism-probe/1",
"label": "REAL data: JammerTest 2024 (Zenodo 10.5281/zenodo.15910563). Shift axis = rover mobility \
(stationary vs dynamic); detectors = cn0 per constellation+band, agc, jamind; clean negatives \
are each scenario's own pre/post-attack seconds.",
"provenance": {
"engine": "kshana", "engine_version": env!("CARGO_PKG_VERSION"),
"dataset_doi": "10.5281/zenodo.15910563",
"shift_axis": "rover_state", "id_bin": id_state,
"tz_correction_sec": TZ_OFFSET_SEC,
"ridge_lambda": lambda, "target_pfa": target_pfa,
"counts": {"scenarios": scenarios.len(), "skipped": n_skipped, "records": records.len(),
"clean": n_clean, "attack": n_attack},
"classes": classes, "detectors": detectors,
},
"samples": sorted.iter().map(|s| json!({"detector": s.detector, "class": s.class, "gap": s.gap, "features": s.features})).collect::<Vec<_>>(),
"gap_predictor": {
"feature_names": ["auc_in","dprime","overlap","var_ratio","tail_margin","pd_at_pfa"],
"cv_leave_one_detector_out": cv_json(&by_det),
"cv_leave_one_class_out": cv_json(&by_class),
"permutation_null": {"n_permutations": 2000, "p_leave_one_detector_out": p_det, "p_leave_one_class_out": p_class},
},
});
if let Some(parent) = Path::new(&out).parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).unwrap_or_else(|e| die(format!("mkdir: {e}")));
}
}
std::fs::write(
&out,
serde_json::to_string_pretty(&value).expect("serialize"),
)
.unwrap_or_else(|e| die(format!("write: {e}")));
println!(
"jammertest-optimism-probe | {} samples ({} detectors x {} classes) | LOO-det R2 {:.3} (p={:.4}) / LOO-class R2 {:.3} (p={:.4}) | REAL data -> {}",
sorted.len(), detectors.len(), classes.len(), by_det.r2, p_det, by_class.r2, p_class, out
);
}