use std::path::PathBuf;
use serde_json::Value;
use super::klobuchar_l1_components;
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/klobuchar_golden.json")
.canonicalize()
.unwrap_or_else(|e| {
panic!(
"cannot locate tests/fixtures/klobuchar_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}")),
)
}
fn hexf_array4(v: &Value, key: &str) -> [f64; 4] {
let arr = v[key]
.as_array()
.unwrap_or_else(|| panic!("{key} not an array"));
assert_eq!(arr.len(), 4, "{key} must have 4 elements");
[
parse_hex_float(arr[0].as_str().unwrap()),
parse_hex_float(arr[1].as_str().unwrap()),
parse_hex_float(arr[2].as_str().unwrap()),
parse_hex_float(arr[3].as_str().unwrap()),
]
}
#[test]
fn klobuchar_l1_zero_ulp_full_branch_matrix() {
let raw = std::fs::read_to_string(fixture_path()).expect("read klobuchar_golden.json");
let doc: Value = serde_json::from_str(&raw).expect("parse klobuchar_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 cases = doc["cases"].as_array().expect("cases array");
assert!(
cases.len() >= 11,
"expected the full branch matrix (>= 11 cases), found {}",
cases.len()
);
let components: &[&str] = &[
"psi",
"phi_i",
"lambda_i",
"phi_m",
"t",
"F",
"AMP",
"PER",
"x",
"t_iono",
"delay_l1_m",
];
let mut failures: Vec<String> = Vec::new();
let mut checks = 0usize;
for case in cases {
let name = case["name"].as_str().unwrap_or("<unnamed>");
let inp = &case["inputs"];
let exp = &case["expect"];
let got = klobuchar_l1_components(
hexf(inp, "lat_deg"),
hexf(inp, "lon_deg"),
hexf(inp, "az_deg"),
hexf(inp, "el_deg"),
hexf(inp, "t_gps_s"),
hexf_array4(inp, "alpha"),
hexf_array4(inp, "beta"),
);
let actual = |c: &str| -> f64 {
match c {
"psi" => got.psi,
"phi_i" => got.phi_i,
"lambda_i" => got.lambda_i,
"phi_m" => got.phi_m,
"t" => got.t,
"F" => got.f,
"AMP" => got.amp,
"PER" => got.per,
"x" => got.x,
"t_iono" => got.t_iono,
"delay_l1_m" => got.delay_l1_m,
other => panic!("unknown component {other}"),
}
};
for &c in components {
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!(
"{name}.{c}: {ulp} ULP (rust={} ref={})",
float_hex(a),
exp[c].as_str().unwrap()
));
}
}
let f_b1i = parse_hex_float(doc["constants"]["f_b1i"].as_str().expect("constants.f_b1i"));
let params = super::super::KlobucharParams {
alpha: hexf_array4(inp, "alpha"),
beta: hexf_array4(inp, "beta"),
};
let got_b1i = super::super::klobuchar_native(
¶ms,
hexf(inp, "lat_deg"),
hexf(inp, "lon_deg"),
hexf(inp, "az_deg"),
hexf(inp, "el_deg"),
hexf(inp, "t_gps_s"),
f_b1i,
);
let want_b1i = parse_hex_float(
exp["delay_b1i_m"]
.as_str()
.unwrap_or_else(|| panic!("case {name}: missing delay_b1i_m")),
);
let ulp = ulp_distance(got_b1i, want_b1i);
checks += 1;
if ulp != 0 {
failures.push(format!(
"{name}.delay_b1i_m: {ulp} ULP (rust={} ref={})",
float_hex(got_b1i),
exp["delay_b1i_m"].as_str().unwrap()
));
}
}
assert!(checks > 0, "no components were checked - fixture empty?");
assert!(
failures.is_empty(),
"Klobuchar Rust port diverged from the reference recipe on {} of {checks} components:\n {}",
failures.len(),
failures.join("\n ")
);
}
#[test]
fn klobuchar_public_wrapper_matches_golden_within_conversion_bound() {
use astrodynamics::time::model::{Instant, JulianDateSplit, TimeScale};
use core::f64::consts::PI;
let raw = std::fs::read_to_string(fixture_path()).expect("read klobuchar_golden.json");
let doc: Value = serde_json::from_str(&raw).expect("parse klobuchar_golden.json");
let cases = doc["cases"].as_array().expect("cases array");
const BOUND_M: f64 = 1.0e-6;
let mut checked = 0usize;
for case in cases {
let name = case["name"].as_str().unwrap_or("<unnamed>");
let inp = &case["inputs"];
let exp = &case["expect"];
let receiver = crate::frame::Wgs84Geodetic::new(
hexf(inp, "lat_deg") * PI / 180.0,
hexf(inp, "lon_deg") * PI / 180.0,
0.0,
);
let el_rad = hexf(inp, "el_deg") * PI / 180.0;
let az_rad = hexf(inp, "az_deg") * PI / 180.0;
let params = super::super::KlobucharParams {
alpha: hexf_array4(inp, "alpha"),
beta: hexf_array4(inp, "beta"),
};
let t_gps_s = hexf(inp, "t_gps_s");
let epoch = Instant::from_julian_date(
TimeScale::Gpst,
JulianDateSplit::new(2_451_544.5, t_gps_s / 86_400.0),
);
let got = super::super::klobuchar(¶ms, receiver, el_rad, az_rad, epoch, super::F_L1);
let want = parse_hex_float(exp["delay_l1_m"].as_str().unwrap());
let diff = (got - want).abs();
assert!(
diff < BOUND_M,
"{name}: public klobuchar wrapper off by {diff:e} m (> {BOUND_M:e}); got={}, want={}",
float_hex(got),
exp["delay_l1_m"].as_str().unwrap()
);
checked += 1;
}
assert!(
checked >= 11,
"expected the full case matrix, checked {checked}"
);
}