use kshana::impairment_eval::auc;
use kshana::impairment_study::{
build_real_gap_rows, real_loocv, real_permutation_pvalue, CvAxis, CvResult, ProbeRecord,
};
use kshana::realdata::iqif::{self, FeatureStageConfig, IqFormat};
use kshana::sdr::TrackConfig;
use serde_json::{json, Value};
use std::io::Read;
use std::path::Path;
fn die(msg: String) -> ! {
eprintln!("{msg}");
std::process::exit(1);
}
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 read_window(path: &str, n: usize) -> Vec<u8> {
let f = std::fs::File::open(path).unwrap_or_else(|e| die(format!("open {path}: {e}")));
let mut buf = vec![0u8; n];
let mut handle = f.take(n as u64);
let mut filled = 0;
loop {
match handle.read(&mut buf[filled..]) {
Ok(0) => break,
Ok(k) => filled += k,
Err(e) => die(format!("read {path}: {e}")),
}
}
buf.truncate(filled);
buf
}
fn main() {
let mut args = std::env::args().skip(1);
let out = args.next().unwrap_or_else(|| {
die("usage: texbat_probe out.json <fs_hz> <window_sec> <if_hz> label:path...".into())
});
let fs_hz: f64 = args
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(25_000_000.0);
let window_sec: f64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(8.0);
let if_hz: f64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(0.0);
let files: Vec<String> = args.collect();
if files.is_empty() {
die("need at least one label:path (e.g. nominal:cleanStatic.bin p10:ds2.bin)".into());
}
let cfg = FeatureStageConfig {
fs_hz,
if_hz,
doppler_max_hz: 6000.0,
doppler_step_hz: 250.0,
acq_threshold: 2.5,
n_epochs: (window_sec * 1000.0) as usize,
track: TrackConfig::default(),
};
let window_bytes = (fs_hz * window_sec) as usize * 4;
let target_pfa = 0.05;
let parsed: Vec<(String, String)> = files
.iter()
.map(|f| {
let (label, path) = f
.split_once(':')
.unwrap_or_else(|| die(format!("bad label:path '{f}'")));
(label.to_string(), path.to_string())
})
.collect();
let spoof_bins: Vec<&str> = parsed
.iter()
.filter(|(l, _)| !l.starts_with("nominal"))
.map(|(l, _)| l.as_str())
.collect();
if spoof_bins.is_empty() {
die("need at least one non-nominal (spoof) labelled file".into());
}
let mut records: Vec<ProbeRecord> = Vec::new();
for (label, path) in &parsed {
let bytes = read_window(path, window_bytes);
let iq = iqif::load_iq(&bytes, IqFormat::Int16Le);
eprintln!("{label}: {} samples from {path}", iq.len());
let mut acquired = 0;
for prn in 1u8..=32 {
let Some(dumps) = iqif::dumps_for_prn(&iq, prn, &cfg) else {
continue;
};
acquired += 1;
let sqm = iqif::sqm_observations(&dumps);
let pwr = iqif::prompt_power_observations(&dumps);
let push = |records: &mut Vec<ProbeRecord>,
det: String,
obs: &[kshana::realdata::Observation],
bin: &str,
nominal: bool,
class: &str| {
for o in obs {
records.push(ProbeRecord::new(det.clone(), class, bin, o.score, nominal));
}
};
if label.starts_with("nominal") {
for bin in &spoof_bins {
push(
&mut records,
format!("sqm_{prn}"),
&sqm,
bin,
true,
"nominal",
);
push(
&mut records,
format!("pwr_{prn}"),
&pwr,
bin,
true,
"nominal",
);
}
} else {
push(
&mut records,
format!("sqm_{prn}"),
&sqm,
label,
false,
"spoof",
);
push(
&mut records,
format!("pwr_{prn}"),
&pwr,
label,
false,
"spoof",
);
}
}
eprintln!(" acquired {acquired} PRNs");
}
let detectors: Vec<&str> = {
let mut d: Vec<&str> = records.iter().map(|r| r.detector.as_str()).collect();
d.sort();
d.dedup();
d
};
let id_bin = spoof_bins[0].to_string();
let samples = build_real_gap_rows(&records, &id_bin, target_pfa);
if samples.is_empty() {
die(format!(
"no gap samples: each detector needs nominal + spoof in id_bin '{id_bin}' and >=1 other bin"
));
}
let by_det = real_loocv(&samples, 0.1, CvAxis::Detector);
let p_det = real_permutation_pvalue(&samples, 0.1, CvAxis::Detector, 2000, 20_240_911);
let mut auc_tbl = serde_json::Map::new();
for det in &detectors {
let neg: Vec<f64> = records
.iter()
.filter(|r| r.detector == *det && r.is_nominal)
.map(|r| r.score)
.collect();
let per: Value = spoof_bins
.iter()
.map(|b| {
let pos: Vec<f64> = records
.iter()
.filter(|r| r.detector == *det && !r.is_nominal && r.shift_bin == **b)
.map(|r| r.score)
.collect();
let a = if pos.is_empty() || neg.is_empty() {
Value::Null
} else {
json!(auc(&pos, &neg))
};
((*b).to_string(), a)
})
.collect::<serde_json::Map<_, _>>()
.into();
auc_tbl.insert((*det).to_string(), per);
}
let value = json!({
"schema_version": "texbat-optimism-probe/1",
"label": "REAL raw-IF data via the kshana SDR feature stage (acquire + track). Clean recording = \
nominal negatives; each spoofing scenario = a spoof positive in its own power-advantage bin. \
Detectors = SQM (Early-Late) and prompt power per acquired PRN.",
"provenance": {
"engine": "kshana", "engine_version": env!("CARGO_PKG_VERSION"),
"fs_hz": fs_hz, "if_hz": if_hz, "window_sec": window_sec,
"id_bin": id_bin, "target_pfa": target_pfa,
"files": parsed.iter().map(|(l,p)| json!({"label": l, "path": p})).collect::<Vec<_>>(),
"detectors": detectors,
},
"auc_by_detector_and_bin": auc_tbl,
"samples": samples.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),
"permutation_null": {"n_permutations": 2000, "p_leave_one_detector_out": p_det},
},
});
if let Some(parent) = Path::new(&out).parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).ok();
}
}
std::fs::write(
&out,
serde_json::to_string_pretty(&value).expect("serialize"),
)
.unwrap_or_else(|e| die(format!("write {out}: {e}")));
println!(
"texbat-optimism-probe | {} gap samples ({} detectors) | LOO-det R2 {:.3} (p={:.4}) | REAL raw-IF -> {}",
samples.len(), detectors.len(), by_det.r2, p_det, out
);
}