use kshana::orbit::{dop, enu_basis};
type Vec3 = [f64; 3];
const REF: &str = include_str!("fixtures/dop/dop_reference.csv");
const REL_TOL: f64 = 1e-6;
fn los_enu(el_deg: f64, az_deg: f64) -> Vec3 {
let (el, az) = (el_deg.to_radians(), az_deg.to_radians());
[el.cos() * az.sin(), el.cos() * az.cos(), el.sin()]
}
fn user_ecef(lat_deg: f64, lon_deg: f64) -> Vec3 {
let (lat, lon) = (lat_deg.to_radians(), lon_deg.to_radians());
let r = 6.371e6;
[
r * lat.cos() * lon.cos(),
r * lat.cos() * lon.sin(),
r * lat.sin(),
]
}
fn sat_at(user: Vec3, east: Vec3, north: Vec3, up: Vec3, el_deg: f64, az_deg: f64) -> Vec3 {
let l = los_enu(el_deg, az_deg);
let range = 2.0e7;
[
user[0] + range * (l[0] * east[0] + l[1] * north[0] + l[2] * up[0]),
user[1] + range * (l[0] * east[1] + l[1] * north[1] + l[2] * up[1]),
user[2] + range * (l[0] * east[2] + l[1] * north[2] + l[2] * up[2]),
]
}
fn rel_diff(got: f64, want: f64) -> f64 {
(got - want).abs() / want.abs().max(1e-12)
}
#[test]
fn dop_matches_gnss_lib_py_reference_geometries() {
let user = user_ecef(45.0, 10.0);
let (east, north, up) = enu_basis(user).expect("valid ENU basis at a mid-latitude user");
let mut checked = 0usize;
for line in REF.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let cols: Vec<&str> = line.split(';').collect();
assert!(
cols.len() == 9,
"malformed reference row (expected 9 fields): {line}"
);
let label = cols[0];
let n: usize = cols[1].parse().expect("satellite count");
let els: Vec<f64> = cols[2].split('|').map(|s| s.parse().unwrap()).collect();
let azs: Vec<f64> = cols[3].split('|').map(|s| s.parse().unwrap()).collect();
assert_eq!(els.len(), n, "{label}: elevation count");
assert_eq!(azs.len(), n, "{label}: azimuth count");
let (want_gdop, want_pdop, want_hdop, want_vdop, want_tdop) = (
cols[4].parse::<f64>().unwrap(),
cols[5].parse::<f64>().unwrap(),
cols[6].parse::<f64>().unwrap(),
cols[7].parse::<f64>().unwrap(),
cols[8].parse::<f64>().unwrap(),
);
assert!(
want_gdop > 0.0 && want_pdop > 0.0,
"{label}: trivial oracle"
);
let sats: Vec<Vec3> = els
.iter()
.zip(&azs)
.map(|(&el, &az)| sat_at(user, east, north, up, el, az))
.collect();
let d = dop(user, &sats).unwrap_or_else(|| panic!("{label}: dop() returned None"));
for (name, got, want) in [
("GDOP", d.gdop, want_gdop),
("PDOP", d.pdop, want_pdop),
("HDOP", d.hdop, want_hdop),
("VDOP", d.vdop, want_vdop),
("TDOP", d.tdop, want_tdop),
] {
let rd = rel_diff(got, want);
assert!(
rd <= REL_TOL,
"{label}: {name} {got:.9} vs gnss_lib_py {want:.9} (rel {rd:.2e} > {REL_TOL:.0e})"
);
}
checked += 1;
}
assert!(
checked >= 8,
"expected >= 8 reference geometries, checked {checked}"
);
}
#[test]
fn dop_components_satisfy_the_pythagorean_identities() {
let user = user_ecef(45.0, 10.0);
let (east, north, up) = enu_basis(user).expect("valid ENU basis");
let (els, azs) = (
[20.0, 45.0, 70.0, 15.0, 30.0, 60.0],
[10.0, 110.0, 220.0, 300.0, 160.0, 40.0],
);
let sats: Vec<Vec3> = els
.iter()
.zip(&azs)
.map(|(&el, &az)| sat_at(user, east, north, up, el, az))
.collect();
let d = dop(user, &sats).expect("dop");
assert!(
rel_diff(d.pdop * d.pdop, d.hdop * d.hdop + d.vdop * d.vdop) < 1e-9,
"PDOP² = HDOP² + VDOP²"
);
assert!(
rel_diff(d.gdop * d.gdop, d.pdop * d.pdop + d.tdop * d.tdop) < 1e-9,
"GDOP² = PDOP² + TDOP²"
);
}