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 scene_data_is_refused_on_a_pilot() {
let dir = scratch("pilot-data");
let out = dir.join("s.cf32").display().to_string();
let scene = |signal: &str| {
run(&args(&[
"scene",
&out,
"--rate",
"4092000",
"--duration",
"0.01",
"--signal",
signal,
"--prn",
"11",
"--data",
]))
};
assert_eq!(scene("galileo-e1c"), 2);
assert_eq!(scene("gps-l2c"), 2);
assert_eq!(scene("galileo-e1b"), 0);
}
#[test]
fn track_and_sweep_apply_the_front_end_flags() {
let dir = scratch("fe-track");
let raw = dir.join("s.cf32").display().to_string();
let filtered = dir.join("f.cf32").display().to_string();
assert_eq!(
run(&args(&[
"scene",
&raw,
"--rate",
"2046000",
"--duration",
"0.5",
"--signal",
"gps-l1ca",
"--prn",
"9",
"--doppler",
"1200",
"--cn0",
"50",
"--seed",
"3",
])),
0
);
let fe = ["--notch", "--bits", "2"];
let mut fe_args = vec!["frontend", raw.as_str(), filtered.as_str()];
fe_args.extend(fe);
assert_eq!(run(&args(&fe_args)), 0);
for cmd in ["track", "sweep"] {
let out = |name: &str| dir.join(format!("{cmd}.{name}.json")).display().to_string();
let (plain, inline, offline) = (out("plain"), out("inline"), out("offline"));
let base = |input: &str, json: &str, with_fe: bool| {
let mut v = vec![
cmd,
input,
"--signal",
"gps-l1ca",
"--prn",
"9",
"--acq-coherent",
"4",
];
if with_fe {
v.extend(fe);
}
v.extend(["--json", json]);
run(&args(&v))
};
assert_eq!(base(&raw, &plain, false), 0, "{cmd} plain");
assert_eq!(base(&raw, &inline, true), 0, "{cmd} with front end");
assert_eq!(
base(&filtered, &offline, false),
0,
"{cmd} on filtered file"
);
let read = |p: &str| std::fs::read_to_string(p).unwrap();
assert_eq!(
read(&inline),
read(&offline),
"{cmd}: inline front end differs from the iq frontend output"
);
assert_ne!(
read(&inline),
read(&plain),
"{cmd}: the front-end flags had no effect"
);
}
}
#[test]
fn track_default_handoff_does_not_false_lock_where_one_period_did() {
let dir = scratch("track-default");
let iq = dir.join("s.cf32").display().to_string();
assert_eq!(
run(&args(&[
"scene",
&iq,
"--rate",
"2046000",
"--duration",
"1.5",
"--signal",
"gps-l1ca",
"--prn",
"3,17",
"--doppler",
"1250,-2400",
"--cn0",
"45",
"--seed",
"7",
])),
0
);
let final_doppler = |extra: &[&str], name: &str| -> f64 {
let json = dir.join(name).display().to_string();
let mut a = vec![
"track", &iq, "--signal", "gps-l1ca", "--prn", "17", "--json", &json,
];
a.extend_from_slice(extra);
assert_eq!(run(&args(&a)), 0, "track {extra:?}");
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();
epochs.last().unwrap()["doppler_hz"].as_f64().unwrap()
};
let auto = final_doppler(&[], "auto.json");
assert!(
(auto + 2400.0).abs() < 25.0,
"default hand-off: final Doppler {auto} Hz, injected -2400 Hz"
);
let one_capped = final_doppler(&["--acq-coherent", "1"], "one-capped.json");
assert!(
(one_capped + 2400.0).abs() < 25.0,
"--acq-coherent 1 with the capped default step: final {one_capped} Hz, injected -2400 Hz"
);
let one = final_doppler(
&["--acq-coherent", "1", "--doppler-step", "666.6666667"],
"one.json",
);
assert!(
(one + 2400.0).abs() > 400.0,
"--acq-coherent 1 with a 667 Hz step no longer false-locks this channel (final {one} Hz); \
the scene no longer exercises the regression"
);
}
#[test]
fn auto_handoff_coherent_length_per_signal_family() {
use kshana::iq::acq::{auto_coherent_periods, AUTO_COHERENT_S};
use kshana::iq::cli::{build_code, signal_names};
use kshana::iq::SpreadingCode;
let expected: &[(&str, usize)] = &[
("gps-l1ca", 4),
("beidou-b1i", 4),
("glonass-l1of", 4),
("galileo-e1b", 1),
("beidou-b1c", 1),
("gps-l2c", 1),
("gps-l5i", 1),
("gps-l5q", 1),
("galileo-e5a-i", 1),
("galileo-e5a-q", 1),
("galileo-e1c", 1),
];
let mut named: Vec<&str> = expected.iter().map(|(s, _)| *s).collect();
let mut all: Vec<&str> = signal_names().to_vec();
named.sort_unstable();
all.sort_unstable();
assert_eq!(named, all, "pin the auto hand-off length of every signal");
for &(signal, periods) in expected {
let code = build_code(signal, 1).unwrap();
let t = code.period_s();
let n = auto_coherent_periods(t);
assert_eq!(n, periods, "{signal}: full period {t} s");
let coherent = n as f64 * t;
assert!(
coherent >= AUTO_COHERENT_S - 1e-9,
"{signal}: {coherent} s coherent is below the ~4 ms hand-off"
);
assert!(
n == 1 || coherent < AUTO_COHERENT_S + t,
"{signal}: {n} periods overshoot ~4 ms by a whole period"
);
}
}
#[test]
fn integer_scene_outputs_use_the_dynamic_range_and_round_trip() {
let dir = scratch("intscale");
for fmt in ["ci8", "ci16_le", "c2tc_msb"] {
let iq = dir.join(format!("s.{fmt}")).display().to_string();
let truth = format!("{iq}.truth.csv");
let acq_json = dir.join(format!("acq.{fmt}.json")).display().to_string();
let trk_json = dir.join(format!("trk.{fmt}.json")).display().to_string();
assert_eq!(
run(&args(&[
"scene",
&iq,
"--rate",
"2046000",
"--duration",
"0.8",
"--signal",
"gps-l1ca",
"--prn",
"9",
"--doppler",
"1200",
"--cn0",
"50",
"--seed",
"3",
"--format",
fmt,
])),
0,
"{fmt}"
);
let bytes = std::fs::read(&iq).unwrap();
match fmt {
"ci8" | "ci16_le" => {
let (vals, full): (Vec<f64>, f64) = if fmt == "ci8" {
(bytes.iter().map(|&b| f64::from(b as i8)).collect(), 127.0)
} else {
(
bytes
.chunks_exact(2)
.map(|c| f64::from(i16::from_le_bytes([c[0], c[1]])))
.collect(),
32767.0,
)
};
let n = vals.len() as f64;
let rms = (vals.iter().map(|v| v * v).sum::<f64>() / n).sqrt();
let target = full / 4.0;
assert!(
(rms / target - 1.0).abs() < 0.05,
"{fmt}: rms {rms:.3} LSB, want {target:.2}"
);
let clipped = vals.iter().filter(|v| v.abs() >= full).count() as f64 / n;
assert!(clipped < 5e-4, "{fmt}: clipped fraction {clipped:.2e}");
}
_ => {
let outer = bytes
.iter()
.flat_map(|&b| (0..4).map(move |k| (b >> (6 - 2 * k)) & 3))
.filter(|&c| c == 1 || c == 2)
.count() as f64
/ (4 * bytes.len()) as f64;
assert!(
(outer - 0.317).abs() < 0.03,
"{fmt}: |level| = 3 on {outer:.3} of elements, want ~0.317"
);
}
}
assert_eq!(
run(&args(&[
"acquire",
&iq,
"--signal",
"gps-l1ca",
"--prn",
"9",
"--coherent",
"4",
"--json",
&acq_json,
])),
0,
"{fmt}"
);
let (want_phase, want_dopp) = truth_at_zero(&truth)[&9];
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&acq_json).unwrap()).unwrap();
let det = &v["detections"][0];
assert!(det["acquired"].as_bool().unwrap(), "{fmt}: {det}");
let dopp = det["doppler_hz"].as_f64().unwrap();
assert!((dopp - want_dopp).abs() <= 200.0, "{fmt}: doppler {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, "{fmt}: code phase {phase} vs {want_phase}");
assert_eq!(
run(&args(&[
"track",
&iq,
"--signal",
"gps-l1ca",
"--prn",
"9",
"--acq-coherent",
"4",
"--json",
&trk_json,
])),
0,
"{fmt}"
);
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&trk_json).unwrap()).unwrap();
let last = v["channels"][0]["epochs"]
.as_array()
.unwrap()
.last()
.unwrap()
.clone();
assert!(
(last["doppler_hz"].as_f64().unwrap() - 1200.0).abs() < 5.0,
"{fmt}: final doppler {last}"
);
assert!(last["phase_lock"].as_bool().unwrap(), "{fmt}: {last}");
}
}
#[test]
fn glonass_channels_are_acquired_and_tracked_on_their_fdma_carriers() {
let dir = scratch("glonass");
let iq = dir.join("g.cf32").display().to_string();
let truth = format!("{iq}.truth.csv");
let acq_json = dir.join("acq.json").display().to_string();
let trk_json = dir.join("trk.json").display().to_string();
assert_eq!(
run(&args(&[
"scene",
&iq,
"--rate",
"10000000",
"--duration",
"0.3",
"--signal",
"glonass-l1of",
"--prn",
"0,3,-7",
"--doppler",
"500,-1200,2500",
"--cn0",
"48",
"--seed",
"1",
])),
0
);
let t0: Vec<(f64, f64)> = truth_from_csv(&std::fs::read_to_string(&truth).unwrap())
.unwrap()
.into_iter()
.filter(|r| r.t_s == 0.0)
.map(|r| (r.code_phase_chips, r.doppler_hz))
.collect();
assert_eq!(t0.len(), 3);
assert_eq!(
run(&args(&[
"acquire",
&iq,
"--signal",
"glonass-l1of",
"--prn",
"0,3,-7",
"--coherent",
"4",
"--json",
&acq_json,
])),
0
);
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();
for (det, &(want_phase, want_dopp)) in dets.iter().zip(&t0) {
assert!(det["acquired"].as_bool().unwrap(), "{det}");
let dopp = det["doppler_hz"].as_f64().unwrap();
assert!(
(dopp - want_dopp).abs() <= 170.0,
"doppler {dopp} vs {want_dopp}"
);
let phase = det["code_phase_chips"].as_f64().unwrap();
let err = ((phase - want_phase + 255.5).rem_euclid(511.0) - 255.5).abs();
assert!(err <= 1.0, "code phase {phase} vs {want_phase}");
}
assert_eq!(
run(&args(&[
"track",
&iq,
"--signal",
"glonass-l1of",
"--prn",
"0,3,-7",
"--acq-coherent",
"4",
"--json",
&trk_json,
])),
0
);
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&trk_json).unwrap()).unwrap();
for (ch, &(_, want_dopp)) in v["channels"].as_array().unwrap().iter().zip(&t0) {
let last = ch["epochs"].as_array().unwrap().last().unwrap();
let d = last["doppler_hz"].as_f64().unwrap();
assert!(
(d - want_dopp).abs() < 5.0,
"final doppler {d} vs {want_dopp}"
);
}
}
#[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 track_and_sweep_take_loop_designs_and_stream_their_outputs() {
let dir = scratch("designs");
let iq = dir.join("s.cf32").display().to_string();
let p = |n: &str| dir.join(n).display().to_string();
assert_eq!(
run(&args(&[
"scene",
&iq,
"--rate",
"2046000",
"--duration",
"1.5",
"--signal",
"gps-l1ca",
"--prn",
"6,21",
"--doppler",
"800,-1300",
"--cn0",
"46",
"--seed",
"4",
])),
0
);
let designs = p("loops.toml");
std::fs::write(
&designs,
"schema = \"kshana.loop-design/1\"\n\
[[design]]\nname = \"narrow\"\n[design.carrier]\npll_bw_hz = 6.0\n\
[[design]]\nname = \"wide\"\nextends = \"narrow\"\n[design.carrier]\npll_bw_hz = 20.0\n",
)
.unwrap();
let (epochs, events, summary) = (p("e.jsonl"), p("ev.jsonl"), p("summary.json"));
assert_eq!(
run(&args(&[
"track",
&iq,
"--signal",
"gps-l1ca",
"--prn",
"6,21",
"--design",
&designs,
"--design-name",
"wide",
"--dll-bw",
"1.0",
"--epochs",
&epochs,
"--events",
&events,
"--summary",
&summary,
])),
0
);
let s: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&summary).unwrap()).unwrap();
assert_eq!(s["schema"], "kshana.track-summary/1");
let d = &s["designs"][0];
assert_eq!(d["name"], "wide");
assert_eq!(d["carrier"]["pll_bw_hz"], 20.0);
assert_eq!(d["code"]["bw_hz"], 1.0, "the flag overrides the design");
assert_eq!(s["overridden_by_flags"][0], "--dll-bw");
assert_eq!(s["warnings"][0]["kind"], "commensurate_sampling");
assert_eq!(s["warnings"][0]["samples_per_chip"], 2.0);
let mut total = 0;
for ch in s["channels"].as_array().unwrap() {
assert_eq!(ch["final_state"], "LOCKED", "{ch}");
assert_eq!(ch["design_hash"], d["hash"]);
let r = &ch["resolved"];
assert_eq!(r["acq_coherent_periods"], 4, "{ch}");
assert!((r["acq_doppler_step_hz"].as_f64().unwrap() - 2.0 / 0.012).abs() < 1e-9);
assert_eq!(r["pll_bn_hz"], 20.0);
assert_eq!(r["fll_bn_hz"], 10.0);
assert!((r["t_track_s"].as_f64().unwrap() - 1e-3).abs() < 1e-12);
total += ch["epochs"].as_u64().unwrap();
}
let lines: Vec<String> = std::fs::read_to_string(&epochs)
.unwrap()
.lines()
.map(str::to_string)
.collect();
assert!(lines[0].contains("\"kshana.track-epoch/1\""));
assert!(
lines[0].contains("\"acq_doppler_step_hz\""),
"the epoch header records the resolved values: {}",
lines[0]
);
assert_eq!(lines.len() as u64 - 1, total, "one record per epoch");
let first: serde_json::Value = serde_json::from_str(&lines[1]).unwrap();
for k in [
"e_i",
"l_q",
"pll_disc_rad",
"code_rate_hz",
"cn0_beaulieu_dbhz",
"state",
] {
assert!(first.get(k).is_some(), "{k} missing from {first}");
}
let ev = std::fs::read_to_string(&events).unwrap();
assert_eq!(ev.matches("\"reason\":\"locked\"").count(), 2, "{ev}");
let csv = p("sweep.csv");
assert_eq!(
run(&args(&[
"sweep", &iq, "--signal", "gps-l1ca", "--prn", "6", "--design", &designs, "--csv",
&csv,
])),
0
);
let rows: Vec<Vec<String>> = std::fs::read_to_string(&csv)
.unwrap()
.lines()
.skip(1)
.map(|l| l.split(',').map(str::to_string).collect())
.collect();
assert_eq!(rows.len(), 2);
assert_eq!(
(rows[0][0].as_str(), rows[1][0].as_str()),
("narrow", "wide")
);
assert_ne!(rows[0][8], rows[1][8], "each row carries its design's hash");
let bad = p("bad.toml");
std::fs::write(&bad, "schema = \"kshana.loop-design/1\"\n[[design]]\nname = \"x\"\n[design.code]\nbandwidth = 1\n").unwrap();
assert_eq!(
run(&args(&[
"track", &iq, "--signal", "gps-l1ca", "--prn", "6", "--design", &bad
])),
2
);
assert_eq!(
run(&args(&[
"sweep", &iq, "--signal", "gps-l1ca", "--prn", "6", "--design", &designs, "--pll-bw",
"5,9",
])),
2
);
assert_eq!(
run(&args(&[
"track",
&iq,
"--signal",
"gps-l1ca",
"--prn",
"6",
"--epochs",
&p("e.unknown"),
])),
2
);
assert_ne!(
run(&args(&[
"sweep", &iq, "--signal", "gps-l1ca", "--prn", "6", "--design", &designs, "--pfa",
"2.0",
])),
0
);
}
fn synthetic_labfit_toml() -> String {
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 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()
};
toml::to_string(&scenario).unwrap()
}
#[test]
fn labfit_runs_from_a_synthetic_rinex_scenario() {
let dir = scratch("labfit");
let toml_path = dir.join("labfit.toml");
std::fs::write(&toml_path, synthetic_labfit_toml()).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
}
#[test]
fn integer_scene_scale_pins_the_rms_target_and_saturation_for_every_integer_encoding() {
use kshana::iq::io::{decode_samples, SampleFormat};
let dir = scratch("intscale-all");
let cases = [
("ci4_msb", 7.0),
("cu4_msb", 15.0),
("cu8", 255.0),
("ci12r_le", 2047.0),
("ci12l_be", 2047.0),
("cu12r_le", 4095.0),
("cu12l_be", 4095.0),
("cu16_le", 65535.0),
("cu16_be", 65535.0),
];
for (fmt, full) in cases {
let iq = dir.join(format!("s.{fmt}")).display().to_string();
assert_eq!(
run(&args(&[
"scene",
&iq,
"--rate",
"2046000",
"--duration",
"0.4",
"--signal",
"gps-l1ca",
"--prn",
"9",
"--doppler",
"1200",
"--cn0",
"50",
"--seed",
"3",
"--format",
fmt,
])),
0,
"{fmt}"
);
let bytes = std::fs::read(&iq).unwrap();
let samples = decode_samples(SampleFormat::parse(fmt).unwrap(), &bytes, 1.0);
let vals: Vec<f64> = samples.iter().flat_map(|s| [s.re, s.im]).collect();
let n = vals.len() as f64;
let rms = (vals.iter().map(|v| v * v).sum::<f64>() / n).sqrt();
let target = full / 4.0;
assert!(
(rms / target - 1.0).abs() < 0.05,
"{fmt}: rms {rms:.3}, want {target:.3}"
);
let clipped = vals.iter().filter(|v| v.abs() >= full).count() as f64 / n;
assert!(clipped < 5e-4, "{fmt}: clipped fraction {clipped:.2e}");
}
let iq = dir.join("s.c2sm_byte").display().to_string();
assert_eq!(
run(&args(&[
"scene",
&iq,
"--rate",
"2046000",
"--duration",
"0.4",
"--signal",
"gps-l1ca",
"--prn",
"9",
"--doppler",
"1200",
"--cn0",
"50",
"--seed",
"3",
"--format",
"c2sm_byte",
])),
0
);
let bytes = std::fs::read(&iq).unwrap();
let outer = bytes.iter().filter(|&&b| (b & 3) & 1 == 1).count() as f64 / bytes.len() as f64;
assert!(
(outer - 0.317).abs() < 0.03,
"c2sm_byte: |level| = 3 on {outer:.3} of elements, want ~0.317"
);
}
#[test]
fn extra_taps_reach_the_epoch_output_and_a_disagreeing_sweep_is_refused() {
let dir = scratch("taps");
let iq = dir.join("s.cf32").display().to_string();
let p = |n: &str| dir.join(n).display().to_string();
assert_eq!(
run(&args(&[
"scene",
&iq,
"--rate",
"2046000",
"--duration",
"0.6",
"--signal",
"gps-l1ca",
"--prn",
"6",
"--doppler",
"800",
"--cn0",
"46",
"--seed",
"4",
])),
0
);
let (plain, tapped, summary) = (p("plain.csv"), p("tapped.csv"), p("sum.json"));
let track = |extra: &[&str], out: &str| {
let mut v = vec![
"track",
&iq,
"--signal",
"gps-l1ca",
"--prn",
"6",
"--epochs",
out,
"--summary",
&summary,
];
v.extend_from_slice(extra);
run(&args(&v))
};
assert_eq!(track(&[], &plain), 0);
let hash_plain = std::fs::read_to_string(&summary).unwrap();
assert_eq!(track(&["--extra-taps", "0.25,-0.25,0.75"], &tapped), 0);
let hash_tapped = std::fs::read_to_string(&summary).unwrap();
assert_ne!(hash_plain, hash_tapped, "the design hash covers the taps");
let (a, b) = (
std::fs::read_to_string(&plain).unwrap(),
std::fs::read_to_string(&tapped).unwrap(),
);
let (ha, hb) = (a.lines().next().unwrap(), b.lines().next().unwrap());
let n = kshana::iq::track::sink::EPOCH_FIELDS.len();
assert_eq!(ha.split(',').count(), n);
assert_eq!(hb.split(',').count(), n + 9);
assert!(hb.ends_with("x2_offset_chips,x2_i,x2_q"));
for (ra, rb) in a.lines().skip(1).zip(b.lines().skip(1)) {
let (ca, cb): (Vec<&str>, Vec<&str>) = (ra.split(',').collect(), rb.split(',').collect());
assert_eq!(ca[..], cb[..n]);
assert_eq!(cb[n], "0.25");
assert_eq!((cb[n + 1], cb[n + 2]), (cb[6], cb[7]), "tap +0.25 is early");
assert_eq!(
(cb[n + 4], cb[n + 5]),
(cb[10], cb[11]),
"tap -0.25 is late"
);
}
let designs = p("d.toml");
std::fs::write(
&designs,
"schema = \"kshana.loop-design/1\"\n[[design]]\nname = \"a\"\n\
[[design]]\nname = \"b\"\n[design.integration]\nextra_taps_chips = [0.3]\n",
)
.unwrap();
assert_ne!(
run(&args(&[
"sweep", &iq, "--signal", "gps-l1ca", "--prn", "6", "--design", &designs,
])),
0
);
}
fn track_bytes(
iq: &str,
dir: &std::path::Path,
tag: &str,
prns: &str,
extra: &[&str],
) -> [Vec<u8>; 3] {
let p = |n: &str| dir.join(format!("{tag}-{n}")).display().to_string();
let (e, ev, s) = (p("e.bin"), p("ev.jsonl"), p("s.json"));
let mut v = vec![
"track",
iq,
"--signal",
"gps-l1ca",
"--prn",
prns,
"--epochs",
&e,
"--events",
&ev,
"--summary",
&s,
];
v.extend_from_slice(extra);
assert_eq!(run(&args(&v)), 0, "{tag}");
[e, ev, s].map(|f| std::fs::read(f).unwrap())
}
#[test]
fn threads_leave_the_output_bit_identical() {
let dir = scratch("threads");
let iq = dir.join("s.cf32").display().to_string();
assert_eq!(
run(&args(&[
"scene",
&iq,
"--rate",
"2046000",
"--duration",
"1.5",
"--signal",
"gps-l1ca",
"--prn",
"3,6,11,14,19",
"--doppler",
"800,-1300,2100,-600,0",
"--cn0",
"44",
"--seed",
"9",
])),
0
);
let prns = "3,6,11,14,19";
let serial = track_bytes(&iq, &dir, "t1", prns, &[]);
assert!(serial[0].len() > 10_000);
let events = String::from_utf8(serial[1].clone()).unwrap();
assert_eq!(
events.matches("\"reason\":\"locked\"").count(),
5,
"{events}"
);
for n in ["2", "7", "auto"] {
let got = track_bytes(&iq, &dir, &format!("n{n}"), prns, &["--threads", n]);
assert_eq!(got[0], serial[0], "epochs with --threads {n}");
assert_eq!(got[1], serial[1], "events with --threads {n}");
assert_eq!(got[2], serial[2], "summary with --threads {n}");
}
let off = track_bytes(
&iq,
&dir,
"r1",
"6,11",
&["--reacquire", "--acq-coherent", "1"],
);
let off4 = track_bytes(
&iq,
&dir,
"r4",
"6,11",
&["--reacquire", "--acq-coherent", "1", "--threads", "4"],
);
assert!(!off[1].is_empty(), "the re-acquisition run emits events");
assert_eq!(off, off4);
assert_ne!(
run(&args(&[
"track",
&iq,
"--signal",
"gps-l1ca",
"--prn",
"6",
"--threads",
"0"
])),
0
);
}
#[test]
#[ignore = "timing survey (release)"]
fn threads_speed_up_the_correlation() {
let dir = scratch("threads-speed");
let iq = dir.join("s.cf32").display().to_string();
let prns = "1,3,5,7,9,11,13,15,17,19,21,23";
let dop = "100,-1300,2100,-600,0,3300,-2500,900,-1700,1200,-300,2700";
assert_eq!(
run(&args(&[
"scene",
&iq,
"--rate",
"4092000",
"--duration",
"4",
"--signal",
"gps-l1ca",
"--prn",
prns,
"--doppler",
dop,
"--cn0",
"44",
"--seed",
"2",
])),
0
);
let time = |tag: &str, extra: &[&str]| {
let t = std::time::Instant::now();
let b = track_bytes(&iq, &dir, tag, prns, extra);
(t.elapsed().as_secs_f64(), b)
};
let (t1, b1) = time("s1", &[]);
let n = std::thread::available_parallelism().map_or(1, |n| n.get());
println!("cores available: {n}");
for k in ["2", "4", "8"] {
let (tk, bk) = time(&format!("s{k}"), &["--threads", k]);
assert_eq!(bk, b1, "--threads {k} is bit-identical");
println!(
"threads {k}: {tk:.2} s vs serial {t1:.2} s: {:.2}x",
t1 / tk
);
}
}
#[test]
fn acquire_exports_the_acquisition_surface() {
use kshana::iq::acq_surface::Surface;
let dir = scratch("surface");
let iq = dir.join("s.cf32").display().to_string();
let p = |n: &str| dir.join(n).display().to_string();
assert_eq!(
run(&args(&[
"scene",
&iq,
"--rate",
"2046000",
"--duration",
"0.05",
"--signal",
"gps-l1ca",
"--prn",
"6,21",
"--doppler",
"800,-1300",
"--cn0",
"46",
"--seed",
"4",
])),
0
);
let (bin, csv, json, det) = (p("s.bin"), p("s.csv"), p("s.json"), p("det.json"));
let base = |prn: &'static str| {
vec![
"acquire",
&iq as &str,
"--signal",
"gps-l1ca",
"--prn",
prn,
"--coherent",
"4",
"--doppler-max",
"3000",
]
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>()
};
let go = |prn: &'static str, extra: &[&str]| {
let mut v = base(prn);
v.extend(extra.iter().map(|s| s.to_string()));
run(&v)
};
assert_eq!(go("6", &["--json", &det, "--surface", &bin]), 0);
assert_eq!(go("6", &["--surface", &csv]), 0);
assert_eq!(go("6", &["--surface", &json]), 0);
let d: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&det).unwrap()).unwrap();
let surf =
Surface::read_binary(std::io::BufReader::new(std::fs::File::open(&bin).unwrap())).unwrap();
let h = &surf.header;
assert_eq!(
h.peak.doppler_hz,
d["detections"][0]["doppler_hz"].as_f64().unwrap()
);
assert_eq!(
h.peak.statistic,
d["detections"][0]["statistic"].as_f64().unwrap()
);
assert!(h.peak.acquired);
assert!((h.peak.doppler_hz - 800.0).abs() < 200.0);
assert!((h.fine_search.doppler_hz - 800.0).abs() < (h.peak.doppler_hz - 800.0).abs() + 1.0);
let text = std::fs::read_to_string(&csv).unwrap();
assert!(text.starts_with("# kshana.acq-surface/1"));
let j: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&json).unwrap()).unwrap();
assert_eq!(j["header"]["peak"]["delay_samples"], h.peak.delay_samples);
assert_ne!(go("6,21", &["--surface", &bin]), 0, "one PRN only");
assert_ne!(go("6", &["--surface", &p("s.dat")]), 0, "unknown suffix");
assert_eq!(
go("6", &["--surface", &p("s.dat"), "--surface-format", "csv"]),
0
);
}
#[test]
fn labfit_inline_returns_the_report_and_refuses_log_paths() {
let toml = synthetic_labfit_toml();
let v = kshana::surface::iq_labfit_inline(&toml, 64 * 1024 * 1024).unwrap();
assert!(v["report"].is_object() && v["markdown"].as_str().unwrap().len() > 100);
assert!(v["residuals_csv"].as_str().unwrap().contains(','));
assert!(v["predictions_csv"].is_string());
assert!(kshana::surface::iq_labfit_inline(&toml, 100).is_err());
let mut doc: toml::Value = toml::from_str(&toml).unwrap();
let log = doc["runs"][0]["log"].as_table_mut().unwrap();
log.remove("text");
log.insert("path".into(), "/etc/hostname".into());
let named = toml::to_string(&doc).unwrap();
let e = kshana::surface::iq_labfit_inline(&named, 64 * 1024 * 1024).unwrap_err();
assert!(e.contains("runs[0].log") && e.contains("path"), "{e}");
let mut doc2: toml::Value = toml::from_str(&toml).unwrap();
let nav = toml::Value::try_from(std::collections::BTreeMap::from([(
"path".to_string(),
"/etc/hostname".to_string(),
)]))
.unwrap();
doc2["runs"][1]["log"]
.as_table_mut()
.unwrap()
.insert("nav".into(), nav);
assert!(
kshana::surface::iq_labfit_inline(&toml::to_string(&doc2).unwrap(), 64 * 1024 * 1024)
.is_err()
);
}