use kshana::fusion::pack::{truth_trajectory, GnssInsScenario};
use kshana::interop::testbench::{export, read_motion_csv};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::path::PathBuf;
const TOL_ECEF_M: f64 = 3.0e-4;
const TOL_LATLON_DEG: f64 = 2.0e-9;
const TOL_HEIGHT_M: f64 = 3.0e-4;
const TOL_VNED_M_S: f64 = 2.0e-4;
const TOL_ANGLE_DEG: f64 = 1.0e-6;
const TOL_UTC_S: f64 = 1.0e-3;
const TOL_NMEA_HORIZ_M: f64 = 5.0e-3;
const TOL_NMEA_HEIGHT_M: f64 = 1.0e-3;
const TOL_NMEA_SPEED_KN: f64 = 1.0e-3;
const TOL_NMEA_COURSE_DEG: f64 = 1.0e-3;
const TOL_NMEA_TIME_S: f64 = 1.0e-3;
const TOL_TRUTH_Q: f64 = 1.0e-12;
const COURSE_MIN_SPEED_M_S: f64 = 0.05;
fn root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
fn fixture(name: &str) -> Value {
let p = root().join("tests/fixtures/testbench").join(name);
let text = std::fs::read_to_string(&p).unwrap_or_else(|e| {
panic!(
"read {}: {e}; run scripts/gen_testbench_ref.py (see its header)",
p.display()
)
});
serde_json::from_str(&text).expect("fixture parses")
}
fn f(v: &Value) -> f64 {
v.as_f64().expect("number")
}
fn arr3(v: &Value) -> [f64; 3] {
[f(&v[0]), f(&v[1]), f(&v[2])]
}
fn sha(b: &[u8]) -> String {
hex::encode(Sha256::digest(b))
}
fn wrap180(a: f64) -> f64 {
(a + 180.0).rem_euclid(360.0) - 180.0
}
struct Exported {
csv: Vec<u8>,
nmea: Vec<u8>,
samples: Vec<kshana::interop::testbench::MotionSample>,
}
fn exported(scenario: &str) -> Exported {
let src = std::fs::read_to_string(root().join("scenarios").join(scenario)).unwrap();
let files = export(&src, None).expect("export");
let get = |suffix: &str| {
files
.iter()
.find(|x| x.suffix == suffix)
.unwrap_or_else(|| panic!("{scenario}: no {suffix}"))
.bytes
.clone()
};
let csv = get(".motion.csv");
let nmea = get(".nmea");
let samples = read_motion_csv(std::str::from_utf8(&csv).unwrap()).expect("csv reads");
Exported { csv, nmea, samples }
}
fn reference() -> Value {
fixture("reference.json")
}
fn scenarios(r: &Value) -> Vec<(String, Value)> {
let m = r["scenarios"].as_object().expect("scenarios");
assert_eq!(m.len(), 4, "scenarios in reference.json");
assert!(m.contains_key("automotive-urban-canyon.toml"));
m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
}
#[test]
fn oracle_versions_are_the_pinned_ones() {
let r = reference();
let o = &r["oracles"];
assert_eq!(o["pyproj"], "3.8.0");
assert_eq!(o["geographiclib"], "2.1");
assert_eq!(o["scipy"], "1.18.1");
assert_eq!(o["pynmea2"], "1.19.0");
assert!(o["numpy"].as_str().is_some());
}
#[test]
fn the_oracles_checked_exactly_these_export_bytes() {
let r = reference();
for (name, s) in scenarios(&r) {
let e = exported(&name);
assert_eq!(sha(&e.csv), s["csv_sha256"], "{name}: motion.csv changed");
assert_eq!(sha(&e.nmea), s["nmea_sha256"], "{name}: nmea changed");
assert_eq!(
e.samples.len() as u64,
s["samples"].as_u64().unwrap(),
"{name}"
);
}
}
#[test]
fn worst_case_over_every_row_is_inside_the_registered_tolerances() {
let r = reference();
for (name, s) in scenarios(&r) {
let m = &s["max_over_every_row"];
let n = s["samples"].as_u64().unwrap();
assert!(n >= 50, "{name}: {n} samples");
assert_eq!(s["nmea_sentences"].as_u64().unwrap(), 2 * n, "{name}");
assert_eq!(
m["nmea_checksum_failures"], 0,
"{name}: pynmea2 checksum failures"
);
assert_eq!(m["nmea_stationary_courses_present"], 0, "{name}");
let chk = |key: &str, tol: f64| {
let v = f(&m[key]);
assert!(v <= tol, "{name}: {key} = {v:e} exceeds {tol:e}");
};
chk("ecef_vs_forward_m", TOL_ECEF_M);
chk("latlon_vs_inverse_deg", TOL_LATLON_DEG);
chk("h_vs_inverse_m", TOL_HEIGHT_M);
chk("utc_s", TOL_UTC_S);
chk("nmea_horizontal_m", TOL_NMEA_HORIZ_M);
chk("nmea_height_m", TOL_NMEA_HEIGHT_M);
chk("nmea_time_s", TOL_NMEA_TIME_S);
chk("nmea_speed_kn", TOL_NMEA_SPEED_KN);
}
}
#[test]
fn position_columns_agree_with_the_oracle_conversions() {
let r = reference();
let mut rows = 0;
for (name, s) in scenarios(&r) {
let e = exported(&name);
for row in s["rows"].as_array().unwrap() {
let i = row["i"].as_u64().unwrap() as usize;
let m = &e.samples[i];
let fwd = arr3(&row["ecef_forward_m"]);
let d = (0..3)
.map(|k| (m.ecef_m[k] - fwd[k]).powi(2))
.sum::<f64>()
.sqrt();
assert!(
d <= TOL_ECEF_M,
"{name} row {i}: Earth-fixed differs {d:e} m"
);
let inv = arr3(&row["geodetic_inverse_deg_deg_m"]);
assert!(
(m.lat_deg - inv[0]).abs() <= TOL_LATLON_DEG,
"{name} row {i}: latitude"
);
assert!(
(m.lon_deg - inv[1]).abs() <= TOL_LATLON_DEG,
"{name} row {i}: longitude"
);
assert!(
(m.h_m - inv[2]).abs() <= TOL_HEIGHT_M,
"{name} row {i}: height"
);
rows += 1;
}
}
assert!(rows > 100, "{rows} rows compared");
}
#[test]
fn local_velocity_matches_the_engines_true_north_east_down_velocity() {
let r = reference();
let mut rows = 0;
for (name, s) in scenarios(&r) {
let src = std::fs::read_to_string(root().join("scenarios").join(&name)).unwrap();
let truth = if s["kind"] == "gnss-ins" {
let scn: GnssInsScenario = toml::from_str(&src).unwrap();
Some(truth_trajectory(&scn))
} else {
None
};
for row in s["rows"].as_array().unwrap() {
let i = row["i"].as_u64().unwrap() as usize;
let got = arr3(&row["v_ned_from_csv_velocity_m_s"]);
let want = truth.as_ref().map_or([0.0; 3], |t| t[i].1.v_ned);
for k in 0..3 {
assert!(
(got[k] - want[k]).abs() <= TOL_VNED_M_S,
"{name} row {i} component {k}: oracle {} vs true {}",
got[k],
want[k]
);
}
rows += 1;
}
}
assert!(rows > 100, "{rows} rows compared");
}
#[test]
fn attitude_columns_match_scipys_euler_angles_of_the_true_quaternion() {
let r = reference();
let t = fixture("truth.json");
let mut rows = 0;
for (name, s) in scenarios(&r) {
if s["kind"] != "gnss-ins" {
continue;
}
let e = exported(&name);
let src = std::fs::read_to_string(root().join("scenarios").join(&name)).unwrap();
let scn: GnssInsScenario = toml::from_str(&src).unwrap();
let truth = truth_trajectory(&scn);
for row in s["rows"].as_array().unwrap() {
let i = row["i"].as_u64().unwrap() as usize;
let tr = t["scenarios"][&name]["rows"]
.as_array()
.unwrap()
.iter()
.find(|x| x["i"].as_u64() == Some(i as u64))
.expect("truth row");
let q = &truth[i].1.q;
let given = [
f(&tr["q_wxyz"][0]),
f(&tr["q_wxyz"][1]),
f(&tr["q_wxyz"][2]),
f(&tr["q_wxyz"][3]),
];
for (a, b) in given.iter().zip([q.w, q.x, q.y, q.z]) {
assert!(
(a - b).abs() <= TOL_TRUTH_Q,
"{name} row {i}: truth.json is stale"
);
}
let eu = arr3(&row["euler_zyx_deg"]);
let m = &e.samples[i];
assert!(
wrap180(m.heading_deg - eu[0]).abs() <= TOL_ANGLE_DEG,
"{name} row {i}: heading"
);
assert!(
(m.pitch_deg - eu[1]).abs() <= TOL_ANGLE_DEG,
"{name} row {i}: pitch"
);
assert!(
(m.roll_deg - eu[2]).abs() <= TOL_ANGLE_DEG,
"{name} row {i}: roll"
);
rows += 1;
}
}
assert!(rows > 50, "{rows} rows compared");
}
#[test]
fn nmea_decoded_by_pynmea2_matches_the_motion() {
let r = reference();
let mut rows = 0;
let mut courses = 0;
for (name, s) in scenarios(&r) {
let e = exported(&name);
for row in s["rows"].as_array().unwrap() {
let i = row["i"].as_u64().unwrap() as usize;
let m = &e.samples[i];
let n = &row["nmea"];
let dlat = (f(&n["lat_deg"]) - m.lat_deg).to_radians() * 6_378_137.0;
let dlon = (f(&n["lon_deg"]) - m.lon_deg).to_radians()
* 6_378_137.0
* m.lat_deg.to_radians().cos();
assert!(
dlat.hypot(dlon) <= TOL_NMEA_HORIZ_M,
"{name} row {i}: position"
);
let h = f(&n["alt_m"]) + f(&n["geo_sep_m"]);
assert!(
(h - m.h_m).abs() <= TOL_NMEA_HEIGHT_M,
"{name} row {i}: height"
);
if let Some(want) = row.get("speed_from_truth_kn") {
assert!(
(f(&n["speed_kn"]) - f(want)).abs() <= TOL_NMEA_SPEED_KN,
"{name} row {i}: speed"
);
let speed_m_s = f(want) * 1852.0 / 3600.0;
if speed_m_s >= COURSE_MIN_SPEED_M_S {
let got = f(&n["course_deg"]);
let want = f(&row["course_from_truth_deg"]);
assert!(
wrap180(got - want).abs() <= TOL_NMEA_COURSE_DEG,
"{name} row {i}: course {got} vs {want}"
);
courses += 1;
}
} else {
assert!(
f(&n["speed_kn"]).abs() <= TOL_NMEA_SPEED_KN,
"{name} row {i}"
);
assert!(
n["course_deg"].is_null(),
"{name} row {i}: stationary course"
);
}
rows += 1;
}
}
assert!(rows > 100, "{rows} rows compared");
assert!(courses > 50, "{courses} courses compared");
}