use std::path::PathBuf;
use serde_json::Value;
use super::saastamoinen::{slant_components, standard_atmosphere};
fn parse_hex_float(s: &str) -> f64 {
let s = s.trim();
let (neg, rest) = match s.strip_prefix('-') {
Some(r) => (true, r),
None => (false, s),
};
let rest = rest
.strip_prefix("0x")
.or_else(|| rest.strip_prefix("0X"))
.unwrap_or_else(|| panic!("not a hex float (missing 0x): {s:?}"));
let (mantissa, exp_str) = rest
.split_once(['p', 'P'])
.unwrap_or_else(|| panic!("not a hex float (missing p exponent): {s:?}"));
let exp2: i32 = exp_str
.parse()
.unwrap_or_else(|_| panic!("bad binary exponent in {s:?}"));
let (int_part, frac_part) = match mantissa.split_once('.') {
Some((i, f)) => (i, f),
None => (mantissa, ""),
};
let int_val: f64 = i64::from_str_radix(int_part, 16)
.unwrap_or_else(|_| panic!("bad integer hex digits in {s:?}"))
as f64;
let mut frac_val = 0.0f64;
let mut scale = 1.0f64 / 16.0;
for c in frac_part.chars() {
let d = c
.to_digit(16)
.unwrap_or_else(|| panic!("bad hex frac digit {c:?} in {s:?}"));
frac_val += (d as f64) * scale;
scale /= 16.0;
}
let significand = int_val + frac_val;
let val = significand * 2.0f64.powi(exp2);
if neg {
-val
} else {
val
}
}
fn ulp_distance(a: f64, b: f64) -> u64 {
if a.is_nan() || b.is_nan() {
return u64::MAX;
}
ordered_i64(a).abs_diff(ordered_i64(b))
}
fn ordered_i64(x: f64) -> i64 {
let bits = x.to_bits() as i64;
if bits < 0 {
i64::MIN - bits
} else {
bits
}
}
fn float_hex(x: f64) -> String {
if x == 0.0 {
return if x.is_sign_negative() {
"-0x0.0p+0".into()
} else {
"0x0.0p+0".into()
};
}
let bits = x.to_bits();
let sign = if (bits >> 63) & 1 == 1 { "-" } else { "" };
let exp = ((bits >> 52) & 0x7ff) as i64;
let mantissa = bits & 0x000f_ffff_ffff_ffff;
let unbiased = exp - 1023;
if unbiased >= 0 {
format!("{sign}0x1.{mantissa:013x}p+{unbiased}")
} else {
format!("{sign}0x1.{mantissa:013x}p{unbiased}")
}
}
fn fixture_path() -> PathBuf {
let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
crate_dir
.join("tests/fixtures/troposphere_golden.json")
.canonicalize()
.unwrap_or_else(|e| {
panic!(
"cannot locate tests/fixtures/troposphere_golden.json from {}: {e}",
crate_dir.display()
)
})
}
fn hexf(v: &Value, key: &str) -> f64 {
parse_hex_float(
v[key]
.as_str()
.unwrap_or_else(|| panic!("missing/non-string {key}")),
)
}
#[test]
fn troposphere_zero_ulp_full_branch_matrix() {
let raw = std::fs::read_to_string(fixture_path()).expect("read troposphere_golden.json");
let doc: Value = serde_json::from_str(&raw).expect("parse troposphere_golden.json");
let probe = "0x1.921fb54442d18p+1"; assert_eq!(
float_hex(parse_hex_float(probe)),
probe,
"hex-float parser/serialiser round-trip is broken"
);
let mut failures: Vec<String> = Vec::new();
let mut checks = 0usize;
let slant_cases = doc["slant_cases"].as_array().expect("slant_cases array");
assert!(
slant_cases.len() >= 22,
"expected the full slant branch matrix (>= 22 cases), found {}",
slant_cases.len()
);
let slant_components_keys: &[&str] = &[
"e", "zhd_m", "zwd_m", "mh", "mw", "dm", "cosy", "y", "slant_m",
];
for case in slant_cases {
let name = case["name"].as_str().unwrap_or("<unnamed>");
let inp = &case["inputs"];
let exp = &case["expect"];
let got = slant_components(
hexf(inp, "el_rad"),
hexf(inp, "lat_rad"),
hexf(inp, "lon_rad"),
hexf(inp, "height_m"),
hexf(inp, "pressure_hpa"),
hexf(inp, "temperature_k"),
hexf(inp, "relative_humidity"),
hexf(inp, "doy"),
);
let actual = |c: &str| -> f64 {
match c {
"e" => got.e,
"zhd_m" => got.zhd_m,
"zwd_m" => got.zwd_m,
"mh" => got.mh,
"mw" => got.mw,
"dm" => got.dm,
"cosy" => got.cosy,
"y" => got.y,
"slant_m" => got.slant_m,
other => panic!("unknown component {other}"),
}
};
for &c in slant_components_keys {
let want = parse_hex_float(
exp[c]
.as_str()
.unwrap_or_else(|| panic!("case {name}: missing expected component {c}")),
);
let a = actual(c);
let ulp = ulp_distance(a, want);
checks += 1;
if ulp != 0 {
failures.push(format!(
"slant {name}.{c}: {ulp} ULP (rust={} ref={})",
float_hex(a),
exp[c].as_str().unwrap()
));
}
}
}
let std_cases = doc["standard_atmosphere_cases"]
.as_array()
.expect("standard_atmosphere_cases array");
assert!(
std_cases.len() >= 4,
"expected the standard-atmosphere family (>= 4 cases), found {}",
std_cases.len()
);
let std_keys: &[&str] = &["pressure_hpa", "temperature_k", "relative_humidity"];
for case in std_cases {
let name = case["name"].as_str().unwrap_or("<unnamed>");
let inp = &case["inputs"];
let exp = &case["expect"];
let got = standard_atmosphere(hexf(inp, "height_m"), hexf(inp, "relative_humidity"));
let actual = |c: &str| -> f64 {
match c {
"pressure_hpa" => got.pressure_hpa,
"temperature_k" => got.temperature_k,
"relative_humidity" => got.relative_humidity,
other => panic!("unknown component {other}"),
}
};
for &c in std_keys {
let want = parse_hex_float(
exp[c]
.as_str()
.unwrap_or_else(|| panic!("case {name}: missing expected component {c}")),
);
let a = actual(c);
let ulp = ulp_distance(a, want);
checks += 1;
if ulp != 0 {
failures.push(format!(
"std {name}.{c}: {ulp} ULP (rust={} ref={})",
float_hex(a),
exp[c].as_str().unwrap()
));
}
}
}
assert!(checks > 0, "no components were checked - fixture empty?");
assert!(
failures.is_empty(),
"Troposphere Rust port diverged from the reference recipe on {} of {checks} components:\n {}",
failures.len(),
failures.join("\n ")
);
}
#[test]
fn tropo_public_wrapper_is_0_ulp_when_epoch_reconstructs_doy() {
use astrodynamics::time::model::{Instant, JulianDateSplit, TimeScale};
let raw = std::fs::read_to_string(fixture_path()).expect("read troposphere_golden.json");
let doc: Value = serde_json::from_str(&raw).expect("parse troposphere_golden.json");
let cases = doc["slant_cases"].as_array().expect("slant_cases array");
let case = cases
.iter()
.find(|c| c["name"].as_str() == Some("zenith_midlat"))
.expect("zenith_midlat case present");
let inp = &case["inputs"];
assert_eq!(
hexf(inp, "doy"),
28.0,
"test assumes zenith_midlat is day-of-year 28"
);
let epoch = Instant::from_julian_date(TimeScale::Gpst, JulianDateSplit::new(2_451_571.5, 0.0));
let receiver = crate::frame::Wgs84Geodetic::new(
hexf(inp, "lat_rad"),
hexf(inp, "lon_rad"),
hexf(inp, "height_m"),
);
let met = super::Met::new(
hexf(inp, "pressure_hpa"),
hexf(inp, "temperature_k"),
hexf(inp, "relative_humidity"),
);
let got = super::tropo_slant(hexf(inp, "el_rad"), receiver, met, epoch);
let want = parse_hex_float(case["expect"]["slant_m"].as_str().unwrap());
assert_eq!(
got.to_bits(),
want.to_bits(),
"public tropo_slant not 0-ULP: got={} want={}",
float_hex(got),
case["expect"]["slant_m"].as_str().unwrap()
);
}
fn fixture_path_named(parts: &[&str]) -> PathBuf {
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
path.push("tests");
path.push("fixtures");
for part in parts {
path.push(part);
}
path
}
fn bits_value(v: &Value) -> f64 {
bitexact::parity::f64_from_hex(v.as_str().expect("hex-bit string")).expect("valid f64 bits")
}
fn bits_array3(v: &Value) -> [f64; 3] {
let values: Vec<f64> = v
.as_array()
.expect("hex-bit array")
.iter()
.map(bits_value)
.collect();
[values[0], values[1], values[2]]
}
#[test]
fn zwd_xyz_variant_matches_generated_fixture_bits() {
let raw = std::fs::read_to_string(fixture_path_named(&["tropo_zwd", "tropo_zwd.json"]))
.expect("read tropo_zwd fixture");
let doc: Value = serde_json::from_str(&raw).expect("parse tropo_zwd fixture");
assert_eq!(doc["schema"], "gnss-tropo-zwd-v1");
assert_eq!(doc["pyproj_version"], "3.6.1");
let mut checked = 0usize;
for case in doc["cases"].as_array().expect("cases") {
let name = case["name"].as_str().expect("name");
let day = case["day_of_year"].as_u64().expect("day") as u16;
let sat = bits_array3(&case["sat_xyz_bits"]);
let rx = bits_array3(&case["receiver_xyz_bits"]);
let receiver_lonlatalt = bits_array3(&case["receiver_lonlatalt_bits"]);
let options =
super::ZwdSlantOptions::new(super::ZwdEpoch::new(0, day), super::ZwdProfile::default());
let got = super::tropo_zwd_delay_xyz(options, &sat, &rx, |_| receiver_lonlatalt);
let want = bits_value(&case["delay_bits"]);
assert_eq!(
got.to_bits(),
want.to_bits(),
"{name} ZWD delay bits: got=0x{:016x} want=0x{:016x}",
got.to_bits(),
want.to_bits()
);
checked += 1;
}
assert!(checked > 0, "empty tropo_zwd fixture");
}