use kshana::iq::cli::run;
use kshana::iq::scene::truth_from_csv;
use std::path::PathBuf;
fn scratch(name: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let uniq = SEQ.fetch_add(1, Ordering::Relaxed);
let d = std::env::temp_dir().join(format!(
"kshana-iq-cli-{}-{uniq}-{name}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn args(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn scene_writes_samples_sidecar_and_truth() {
let dir = scratch("scene");
let out = dir.join("s.cf32").display().to_string();
let truth = format!("{out}.truth.csv");
assert_eq!(
run(&args(&[
"scene",
&out,
"--rate",
"2046000",
"--duration",
"0.1",
"--signal",
"gps-l1ca",
"--prn",
"3,11",
"--doppler",
"1000,-1500",
"--cn0",
"48",
"--no-noise",
"--seed",
"5",
])),
0
);
let bytes = std::fs::metadata(&out).unwrap().len();
assert_eq!(bytes, (0.1 * 2_046_000.0) as u64 * 8);
let sc: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(format!("{out}.json")).unwrap()).unwrap();
assert_eq!(sc["format"], "cf32_le");
assert_eq!(sc["sample_rate_hz"], 2.046e6);
let records = truth_from_csv(&std::fs::read_to_string(&truth).unwrap()).unwrap();
let t0: Vec<_> = records.iter().filter(|r| r.t_s == 0.0).collect();
assert_eq!(t0.len(), 2);
assert!(t0.iter().all(|r| r.visible));
}
fn truth_at_zero(path: &str) -> std::collections::HashMap<u32, (f64, f64)> {
truth_from_csv(&std::fs::read_to_string(path).unwrap())
.unwrap()
.into_iter()
.filter(|r| r.t_s == 0.0)
.map(|r| (r.sat_id, (r.code_phase_chips, r.doppler_hz)))
.collect()
}
#[test]
fn acquire_recovers_injected_code_phase_and_doppler() {
let dir = scratch("acq");
let iq = dir.join("s.cf32").display().to_string();
let truth = format!("{iq}.truth.csv");
let acq_json = dir.join("acq.json").display().to_string();
assert_eq!(
run(&args(&[
"scene",
&iq,
"--rate",
"2046000",
"--duration",
"0.05",
"--signal",
"gps-l1ca",
"--prn",
"5,12",
"--doppler",
"1000,-1500",
"--cn0",
"50",
"--no-noise",
])),
0
);
assert_eq!(
run(&args(&[
"acquire",
&iq,
"--signal",
"gps-l1ca",
"--prn",
"5,12",
"--doppler-max",
"4000",
"--doppler-step",
"250",
"--json",
&acq_json,
])),
0
);
let want = truth_at_zero(&truth);
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&acq_json).unwrap()).unwrap();
let dets = v["detections"].as_array().unwrap();
assert_eq!(dets.len(), 2);
for (det, prn) in dets.iter().zip([5u32, 12]) {
assert!(det["acquired"].as_bool().unwrap(), "{det}");
let (want_phase, want_dopp) = want[&prn];
let dopp = det["doppler_hz"].as_f64().unwrap();
assert!(
(dopp - want_dopp).abs() <= 250.0,
"doppler {dopp} vs {want_dopp}"
);
let phase = det["code_phase_chips"].as_f64().unwrap();
let err = ((phase - want_phase + 511.5).rem_euclid(1023.0) - 511.5).abs();
assert!(err <= 1.0, "code phase {phase} vs {want_phase} (err {err})");
}
}
#[test]
fn broadcast_nav_scene_recovers_geometry() {
let dir = scratch("nav");
let iq = dir.join("b.cf32").display().to_string();
let truth = format!("{iq}.truth.csv");
let nav = "tests/fixtures/igs/BRDC00WRD_R_20181330000_01D_GN.rnx";
assert_eq!(
run(&args(&[
"scene",
&iq,
"--rate",
"2046000",
"--window",
"0.002",
"--nav",
nav,
"--rx-pos",
"50.09,8.66,150",
"--start",
"600",
"--cn0",
"50",
"--no-noise",
"--mask",
"10",
])),
0
);
let recs = truth_from_csv(&std::fs::read_to_string(&truth).unwrap()).unwrap();
let epoch0: Vec<_> = recs.iter().filter(|r| r.t_s == 0.0 && r.visible).collect();
assert!(epoch0.len() >= 6, "{} visible", epoch0.len());
for r in &epoch0 {
assert!(
r.pseudorange_m > 1.9e7 && r.pseudorange_m < 2.7e7,
"PRN {} pseudorange {}",
r.sat_id,
r.pseudorange_m
);
assert!(
r.doppler_hz.abs() < 5000.0,
"PRN {} doppler {}",
r.sat_id,
r.doppler_hz
);
}
}
#[test]
fn frontend_command_and_inline_flags_filter_and_acquire() {
let dir = scratch("fe");
let iq = dir.join("s.cf32").display().to_string();
let filt = dir.join("f.cf32").display().to_string();
let acq_json = dir.join("acq.json").display().to_string();
assert_eq!(
run(&args(&[
"scene",
&iq,
"--rate",
"2046000",
"--duration",
"0.05",
"--signal",
"gps-l1ca",
"--prn",
"11",
"--doppler",
"900",
"--cn0",
"50",
])),
0
);
assert_eq!(
run(&args(&["frontend", &iq, &filt, "--bits", "3", "--agc",])),
0
);
assert!(std::path::Path::new(&filt).exists());
assert!(std::path::Path::new(&format!("{filt}.json")).exists());
assert_eq!(
run(&args(&[
"acquire",
&filt,
"--signal",
"gps-l1ca",
"--prn",
"11",
"--doppler-max",
"4000",
"--doppler-step",
"250",
"--json",
&acq_json,
])),
0
);
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&acq_json).unwrap()).unwrap();
assert!(v["detections"][0]["acquired"].as_bool().unwrap(), "{v}");
let acq2 = dir.join("acq2.json").display().to_string();
assert_eq!(
run(&args(&[
"acquire",
&iq,
"--signal",
"gps-l1ca",
"--prn",
"11",
"--doppler-max",
"4000",
"--doppler-step",
"250",
"--bits",
"3",
"--agc",
"--json",
&acq2,
])),
0
);
let v2: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&acq2).unwrap()).unwrap();
assert!(v2["detections"][0]["acquired"].as_bool().unwrap(), "{v2}");
}
#[test]
fn scene_channel_flags_apply_and_validate() {
let dir = scratch("chan");
let iq = dir.join("c.cf32").display().to_string();
assert_eq!(
run(&args(&[
"scene",
&iq,
"--rate",
"2046000",
"--duration",
"0.01",
"--signal",
"gps-l1ca",
"--prn",
"5",
"--cn0",
"48",
"--no-noise",
"--iono-stec",
"25",
"--tropo",
"--s4",
"0.5",
"--multipath-height",
"2.0",
"--multipath-ground",
"wet",
])),
0
);
assert_eq!(
run(&args(&[
"scene",
&iq,
"--rate",
"2046000",
"--duration",
"0.01",
"--signal",
"gps-l1ca",
"--prn",
"5",
"--no-noise",
"--nlos",
])),
2
);
assert_eq!(
run(&args(&[
"scene",
&iq,
"--rate",
"2046000",
"--duration",
"0.01",
"--signal",
"gps-l1ca",
"--prn",
"5",
"--no-noise",
"--iono-stec",
"10",
"--iono-klobuchar",
])),
2
);
}
#[test]
fn scene_sigmf_round_trips_through_acquire() {
let dir = scratch("sigmf");
let meta = dir.join("s.sigmf-meta").display().to_string();
let data = dir.join("s.sigmf-data").display().to_string();
let truth = format!("{meta}.truth.csv");
let acq_json = dir.join("acq.json").display().to_string();
assert_eq!(
run(&args(&[
"scene",
&meta,
"--rate",
"2046000",
"--duration",
"0.05",
"--signal",
"gps-l1ca",
"--prn",
"7,19",
"--doppler",
"800,-1200",
"--cn0",
"50",
"--no-noise",
"--format",
"sigmf",
])),
0
);
assert!(std::path::Path::new(&data).exists(), "no sigmf-data");
let meta_json: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&meta).unwrap()).unwrap();
assert_eq!(meta_json["global"]["core:datatype"], "cf32_le");
assert!(meta_json["annotations"].as_array().unwrap().len() >= 2);
assert_eq!(
run(&args(&[
"acquire",
&meta,
"--signal",
"gps-l1ca",
"--prn",
"7,19",
"--doppler-max",
"4000",
"--doppler-step",
"250",
"--json",
&acq_json,
])),
0
);
let want = truth_at_zero(&truth);
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&acq_json).unwrap()).unwrap();
let dets = v["detections"].as_array().unwrap();
assert_eq!(dets.len(), 2);
for (det, prn) in dets.iter().zip([7u32, 19]) {
assert!(det["acquired"].as_bool().unwrap(), "{det}");
let (want_phase, want_dopp) = want[&prn];
let dopp = det["doppler_hz"].as_f64().unwrap();
assert!(
(dopp - want_dopp).abs() <= 250.0,
"doppler {dopp} vs {want_dopp}"
);
let phase = det["code_phase_chips"].as_f64().unwrap();
let err = ((phase - want_phase + 511.5).rem_euclid(1023.0) - 511.5).abs();
assert!(err <= 1.0, "code phase {phase} vs {want_phase} (err {err})");
}
}
#[test]
fn track_converges_to_injected_doppler() {
let dir = scratch("track");
let iq = dir.join("s.cf32").display().to_string();
let json = dir.join("track.json").display().to_string();
assert_eq!(
run(&args(&[
"scene",
&iq,
"--rate",
"2046000",
"--duration",
"0.8",
"--signal",
"gps-l1ca",
"--prn",
"9",
"--doppler",
"1200",
"--cn0",
"50",
"--no-noise",
])),
0
);
assert_eq!(
run(&args(&[
"track", &iq, "--signal", "gps-l1ca", "--prn", "9", "--json", &json,
])),
0
);
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&json).unwrap()).unwrap();
let epochs = v["channels"][0]["epochs"].as_array().unwrap();
assert!(epochs.len() > 500);
let last = epochs.last().unwrap();
assert!(
(last["doppler_hz"].as_f64().unwrap() - 1200.0).abs() < 5.0,
"final doppler {last}"
);
assert!(last["phase_lock"].as_bool().unwrap(), "{last}");
}
#[test]
fn sweep_orders_lock_fraction_with_carrier_bandwidth() {
let dir = scratch("sweep");
let iq = dir.join("s.cf32").display().to_string();
let csv = dir.join("sweep.csv").display().to_string();
assert_eq!(
run(&args(&[
"scene",
&iq,
"--rate",
"2046000",
"--duration",
"1.0",
"--signal",
"gps-l1ca",
"--prn",
"6",
"--doppler",
"800",
"--cn0",
"44",
"--seed",
"2",
])),
0
);
assert_eq!(
run(&args(&[
"sweep",
&iq,
"--signal",
"gps-l1ca",
"--prn",
"6",
"--pll-bw",
"6,20",
"--json",
&dir.join("sweep.json").display().to_string(),
"--csv",
&csv,
])),
0
);
let rows: Vec<String> = std::fs::read_to_string(&csv)
.unwrap()
.lines()
.skip(1)
.map(str::to_string)
.collect();
assert_eq!(rows.len(), 2, "two designs");
let lock = |row: &str| -> f64 { row.split(',').nth(5).unwrap().parse().unwrap() };
assert!(lock(&rows[1]) >= lock(&rows[0]), "{rows:?}");
}
#[test]
fn labfit_runs_from_a_synthetic_rinex_scenario() {
use kshana::iq::labfit::schema::{Conditions, FitCfg, LabFitScenario};
use kshana::iq::labfit::synth::{synthesize_timeline, SynthSpec};
use kshana::iq::labfit::{model::ModelKind, schema::RunCfg};
use kshana::receiver_trust::scenario::{FileSource, LogCfg};
use kshana::receiver_trust::LogFormat;
let dir = scratch("labfit");
let sats: Vec<(String, f64)> = [(0usize, 42.0), (1, 45.0), (2, 48.0)]
.iter()
.map(|&(i, n)| (format!("G{:02}", i + 3), n))
.collect();
let conds: Vec<Conditions> = [(1.0f64, 0.5f64), (2.0, 1.0), (0.5, 1.5)]
.iter()
.map(|&(up, down)| ramp(up, down))
.collect();
let mut runs = Vec::new();
for (i, c) in conds.iter().enumerate() {
let tl = synthesize_timeline(
ModelKind::Empirical,
&[30.0, 4.0, 1.5, 3.0],
&Default::default(),
2.0,
c,
&SynthSpec {
sats: sats.clone(),
epoch_s: 1.0,
run_end_s: 200.0,
cn0_noise_db: 0.0,
threshold_jitter_db: 0.0,
seed: i as u64 + 1,
},
);
runs.push(RunCfg {
label: format!("run-{i}"),
log: LogCfg {
format: LogFormat::Rinex,
source: FileSource {
text: Some(rinex_text(&tl)),
..FileSource::default()
},
nav: None,
},
conditions: c.clone(),
});
}
let scenario = LabFitScenario {
kind: Some("iq-labfit".into()),
name: Some("cli smoke".into()),
fit: FitCfg {
bootstrap: 0,
folds: 0,
..FitCfg::default()
},
runs,
..LabFitScenario::default()
};
let toml_path = dir.join("labfit.toml");
std::fs::write(&toml_path, toml::to_string(&scenario).unwrap()).unwrap();
assert_eq!(run(&args(&["labfit", &toml_path.display().to_string()])), 0);
for ext in [
"labfit.json",
"residuals.csv",
"predictions.csv",
"labfit.md",
] {
let p = dir.join(format!("labfit.{ext}"));
assert!(p.exists(), "missing {}", p.display());
}
let report: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(dir.join("labfit.labfit.json")).unwrap())
.unwrap();
assert_eq!(report["kind"], "iq-labfit");
}
#[test]
fn error_paths_return_the_documented_codes() {
let dir = scratch("errs");
assert_eq!(run(&args(&["scene", "--rate", "2046000"])), 2);
let out = dir.join("x.cf32").display().to_string();
assert_eq!(
run(&args(&[
"scene",
&out,
"--rate",
"2046000",
"--duration",
"0.01",
"--signal",
"nope",
"--prn",
"1",
])),
2
);
assert_eq!(
run(&args(&[
"acquire",
&dir.join("missing.cf32").display().to_string(),
"--signal",
"gps-l1ca",
"--prn",
"1",
"--format",
"cf32_le",
"--rate",
"2046000",
])),
1
);
assert_eq!(
run(&args(&[
"labfit",
&dir.join("none.toml").display().to_string()
])),
1
);
}
fn ramp(up: f64, down: f64) -> kshana::iq::labfit::schema::Conditions {
use kshana::iq::labfit::schema::{Conditions, Interp, LevelKind, LevelPoint};
let (onset, offset, peak) = (20.0, 150.0, 40.0);
Conditions {
onset_s: onset,
offset_s: Some(offset),
level_kind: LevelKind::Cn0Drop,
levels: vec![
LevelPoint {
t_s: onset,
level_db: 0.0,
},
LevelPoint {
t_s: onset + peak / up,
level_db: peak,
},
LevelPoint {
t_s: offset - peak / down,
level_db: peak,
},
LevelPoint {
t_s: offset,
level_db: 0.0,
},
],
interp: Interp::Linear,
jammer_type: None,
doppler_rate_hz_per_s: 0.0,
code_slew_chips_per_s: 0.0,
}
}
fn rinex_text(tl: &kshana::receiver_trust::Timeline) -> String {
let mut s = String::new();
let h = |s: &mut String, body: &str, label: &str| s.push_str(&format!("{body:<60}{label}\n"));
h(
&mut s,
" 3.04 OBSERVATION DATA G (GPS)",
"RINEX VERSION / TYPE",
);
h(&mut s, "G 1 S1C", "SYS / # / OBS TYPES");
h(&mut s, " 1.000", "INTERVAL");
h(
&mut s,
" 2024 9 11 0 0 0.0000000 GPS",
"TIME OF FIRST OBS",
);
h(&mut s, "", "END OF HEADER");
for e in &tl.epochs {
let (hh, mm, ss) = (
(e.t_s / 3600.0).floor(),
((e.t_s % 3600.0) / 60.0).floor(),
e.t_s % 60.0,
);
s.push_str(&format!(
"> 2024 09 11 {:02} {:02} {:10.7} 0 {:2}\n",
hh as u32,
mm as u32,
ss,
e.cn0.len()
));
for c in &e.cn0 {
s.push_str(&format!("{}{:14.3} \n", c.sat, c.cn0_dbhz));
}
}
s
}