use crate::eop::{
parse_all_predicted, parse_bulletin_b_pm, parse_bulletin_b_ut1, parse_line, EopRecord,
};
use crate::timescales::{ERA_TURNS_PER_UT1_DAY, SECONDS_PER_DAY};
pub const C_M_S: f64 = 299_792_458.0;
pub const D_EM_M: f64 = 384_400_000.0;
pub const OMEGA_EARTH_RAD_S: f64 = std::f64::consts::TAU * ERA_TURNS_PER_UT1_DAY / SECONDS_PER_DAY;
pub const LEVER_M_PER_S: f64 = D_EM_M * OMEGA_EARTH_RAD_S;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Horizon {
Final,
Days(u32),
}
impl Horizon {
pub fn days(self) -> f64 {
match self {
Horizon::Final => 0.0,
Horizon::Days(d) => d as f64,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct HorizonError {
pub horizon: Horizon,
pub n: usize,
pub rms_s: f64,
pub p50_s: f64,
pub p95_s: f64,
pub max_s: f64,
}
impl HorizonError {
pub fn rms_ms(&self) -> f64 {
self.rms_s * 1e3
}
pub fn p50_ms(&self) -> f64 {
self.p50_s * 1e3
}
pub fn p95_ms(&self) -> f64 {
self.p95_s * 1e3
}
pub fn rms_position_m(&self) -> f64 {
ut1_error_to_lunar(self.rms_s).0
}
}
fn percentile_sorted(sorted: &[f64], p: f64) -> f64 {
if sorted.is_empty() {
return 0.0;
}
let n = sorted.len();
let rank = (p * n as f64).ceil().max(1.0) as usize;
sorted[rank.min(n) - 1]
}
fn stats(horizon: Horizon, mut abs_resid: Vec<f64>) -> HorizonError {
let n = abs_resid.len();
let sum_sq: f64 = abs_resid.iter().map(|r| r * r).sum();
let rms_s = if n == 0 {
0.0
} else {
(sum_sq / n as f64).sqrt()
};
abs_resid.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
HorizonError {
horizon,
n,
rms_s,
p50_s: percentile_sorted(&abs_resid, 0.50),
p95_s: percentile_sorted(&abs_resid, 0.95),
max_s: abs_resid.last().copied().unwrap_or(0.0),
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DailyUt1 {
pub mjd: f64,
pub ut1_rapid_s: f64,
pub ut1_final_s: Option<f64>,
}
pub fn parse_daily_ut1(body: &str) -> Vec<DailyUt1> {
let mut out: Vec<DailyUt1> = body
.lines()
.filter_map(|line| {
let rec = parse_line(line)?;
Some(DailyUt1 {
mjd: rec.mjd,
ut1_rapid_s: rec.ut1_utc_s,
ut1_final_s: parse_bulletin_b_ut1(line),
})
})
.collect();
out.sort_by(|a, b| {
a.mjd
.partial_cmp(&b.mjd)
.unwrap_or(std::cmp::Ordering::Equal)
});
out
}
fn truth_ut1(d: &DailyUt1) -> f64 {
d.ut1_final_s.unwrap_or(d.ut1_rapid_s)
}
pub fn prediction_error_vs_horizon(body: &str, horizons: &[Horizon]) -> Vec<HorizonError> {
let daily = parse_daily_ut1(body);
let mut out = Vec::new();
for &h in horizons {
match h {
Horizon::Final => {
let resid: Vec<f64> = daily
.iter()
.filter_map(|d| d.ut1_final_s.map(|f| (d.ut1_rapid_s - f).abs()))
.collect();
if !resid.is_empty() {
out.push(stats(Horizon::Final, resid));
}
}
Horizon::Days(days) => {
let dt = days as f64;
let mut resid = Vec::new();
for (i, base) in daily.iter().enumerate() {
let target_mjd = base.mjd + dt;
if let Some(target) = daily[i + 1..]
.iter()
.find(|d| (d.mjd - target_mjd).abs() < 1e-6)
{
resid.push((truth_ut1(base) - truth_ut1(target)).abs());
}
}
if !resid.is_empty() {
out.push(stats(Horizon::Days(days), resid));
}
}
}
}
out
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DailyPm {
pub mjd: f64,
pub xp_rapid_as: f64,
pub yp_rapid_as: f64,
pub pm_final_as: Option<(f64, f64)>,
}
pub fn parse_daily_pm(body: &str) -> Vec<DailyPm> {
let mut out: Vec<DailyPm> = body
.lines()
.filter_map(|line| {
let rec = parse_line(line)?;
Some(DailyPm {
mjd: rec.mjd,
xp_rapid_as: rec.xp_arcsec,
yp_rapid_as: rec.yp_arcsec,
pm_final_as: parse_bulletin_b_pm(line),
})
})
.collect();
out.sort_by(|a, b| {
a.mjd
.partial_cmp(&b.mjd)
.unwrap_or(std::cmp::Ordering::Equal)
});
out
}
fn truth_pm(d: &DailyPm) -> (f64, f64) {
d.pm_final_as.unwrap_or((d.xp_rapid_as, d.yp_rapid_as))
}
pub fn pm_prediction_error_vs_horizon(body: &str, horizons: &[Horizon]) -> Vec<HorizonError> {
let daily = parse_daily_pm(body);
let mag = |(dx, dy): (f64, f64)| (dx * dx + dy * dy).sqrt();
let mut out = Vec::new();
for &h in horizons {
match h {
Horizon::Final => {
let resid: Vec<f64> = daily
.iter()
.filter_map(|d| {
d.pm_final_as
.map(|(fx, fy)| mag((d.xp_rapid_as - fx, d.yp_rapid_as - fy)))
})
.collect();
if !resid.is_empty() {
out.push(stats(Horizon::Final, resid));
}
}
Horizon::Days(days) => {
let dt = days as f64;
let mut resid = Vec::new();
for (i, base) in daily.iter().enumerate() {
let target_mjd = base.mjd + dt;
if let Some(target) = daily[i + 1..]
.iter()
.find(|d| (d.mjd - target_mjd).abs() < 1e-6)
{
let (bx, by) = truth_pm(base);
let (tx, ty) = truth_pm(target);
resid.push(mag((bx - tx, by - ty)));
}
}
if !resid.is_empty() {
out.push(stats(Horizon::Days(days), resid));
}
}
}
}
out
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PredictedRowsSummary {
pub n: usize,
pub first_mjd: Option<f64>,
pub last_mjd: Option<f64>,
}
pub fn predicted_rows_summary(body: &str) -> PredictedRowsSummary {
let preds: Vec<EopRecord> = parse_all_predicted(body);
let mjds: Vec<f64> = preds.iter().map(|r| r.mjd).collect();
PredictedRowsSummary {
n: preds.len(),
first_mjd: mjds
.iter()
.cloned()
.fold(None, |acc, m| Some(acc.map_or(m, |a: f64| a.min(m)))),
last_mjd: mjds
.iter()
.cloned()
.fold(None, |acc, m| Some(acc.map_or(m, |a: f64| a.max(m)))),
}
}
pub fn predicted_vs_final_ut1(
as_issued: &str,
later_final: &str,
horizons: &[Horizon],
) -> Vec<HorizonError> {
let issued = parse_all_predicted(as_issued);
let cutoff = parse_daily_ut1(as_issued)
.iter()
.filter(|d| d.ut1_final_s.is_some())
.map(|d| d.mjd)
.fold(f64::NEG_INFINITY, f64::max);
let later: Vec<DailyUt1> = parse_daily_ut1(later_final);
let final_at = |mjd: f64| -> Option<f64> {
later
.iter()
.find(|d| (d.mjd - mjd).abs() < 1e-6)
.and_then(|d| d.ut1_final_s)
};
let mut out = Vec::new();
for &h in horizons {
let Horizon::Days(days) = h else { continue };
if !cutoff.is_finite() {
continue;
}
let target = cutoff + days as f64;
let resid: Vec<f64> = issued
.iter()
.filter(|p| (p.mjd - target).abs() < 1e-6)
.filter_map(|p| final_at(p.mjd).map(|f| (p.ut1_utc_s - f).abs()))
.collect();
if !resid.is_empty() {
out.push(stats(Horizon::Days(days), resid));
}
}
out
}
pub fn ut1_error_to_lunar(delta_ut1_s: f64) -> (f64, f64) {
let position_m = LEVER_M_PER_S * delta_ut1_s;
let time_s = position_m / C_M_S;
(position_m, time_s)
}
pub fn lunar_position_to_ut1(position_m: f64) -> f64 {
position_m / LEVER_M_PER_S
}
pub fn frame_position_error_at_moon(delta_ut1_s: f64, delta_xp_rad: f64, delta_yp_rad: f64) -> f64 {
let ut1_rot = OMEGA_EARTH_RAD_S * delta_ut1_s;
D_EM_M * (ut1_rot * ut1_rot + delta_xp_rad * delta_xp_rad + delta_yp_rad * delta_yp_rad).sqrt()
}
pub fn polar_motion_position_error(delta_xp_rad: f64, delta_yp_rad: f64) -> f64 {
frame_position_error_at_moon(0.0, delta_xp_rad, delta_yp_rad)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FrameErrorBudget {
pub eop_term_m: f64,
pub ephemeris_term_m: f64,
pub frame_realization_floor_m: f64,
pub total_m: f64,
pub total_time_ns: f64,
}
pub fn frame_error_budget(
delta_ut1_s: f64,
delta_xp_rad: f64,
delta_yp_rad: f64,
ephemeris_cov: crate::lunar_frame_predict::OdCovariance,
latency_s: f64,
frame_realization_floor_m: f64,
) -> FrameErrorBudget {
let eop = frame_position_error_at_moon(delta_ut1_s, delta_xp_rad, delta_yp_rad);
let eph = crate::lunar_frame_predict::predict_frame_error(ephemeris_cov, latency_s)
.predicted_pos_sigma_m;
let floor = frame_realization_floor_m.max(0.0);
let total = (eop * eop + eph * eph + floor * floor).sqrt();
FrameErrorBudget {
eop_term_m: eop,
ephemeris_term_m: eph,
frame_realization_floor_m: floor,
total_m: total,
total_time_ns: total / C_M_S * 1e9,
}
}
pub fn derived_frame_realization_floor_m(noise_sigma_m: f64) -> f64 {
crate::lunar_frame_realise::LunarFrameRealiseScenario {
noise_sigma_m,
..crate::lunar_frame_realise::LunarFrameRealiseScenario::default()
}
.run()
.rms_residual_m
}
pub const SVG_W: f64 = 860.0;
pub const SVG_H: f64 = 640.0;
const ML: f64 = 84.0;
const MR: f64 = 92.0;
const PW: f64 = SVG_W - ML - MR;
const PANEL_A_TOP: f64 = 48.0;
const PANEL_B_TOP: f64 = 372.0;
const PANEL_H: f64 = 200.0;
pub const X_MAX_DAYS: f64 = 12.0;
pub const A_Y_MAX_MS: f64 = 1.2;
pub const B_Y_MAX_M: f64 = 40.0;
pub const MARKER_UT1_MS: f64 = 0.5;
pub const MARKER_HORIZON_DAYS: f64 = 5.0;
pub const MARKER_POS_M: f64 = 15.0;
pub fn x_of_days(days: f64) -> f64 {
ML + (days / X_MAX_DAYS) * PW
}
pub fn a_y_of_ms(ms: f64) -> f64 {
PANEL_A_TOP + PANEL_H - (ms / A_Y_MAX_MS).clamp(0.0, 1.0) * PANEL_H
}
pub fn b_y_of_m(m: f64) -> f64 {
PANEL_B_TOP + PANEL_H - (m / B_Y_MAX_M).clamp(0.0, 1.0) * PANEL_H
}
fn polyline(points: &[(f64, f64)], stroke: &str) -> String {
let pts = points
.iter()
.map(|(x, y)| format!("{x:.1},{y:.1}"))
.collect::<Vec<_>>()
.join(" ");
format!("<polyline fill=\"none\" stroke=\"{stroke}\" stroke-width=\"2\" points=\"{pts}\"/>")
}
pub fn growth_annotation(curve: &[HorizonError]) -> Option<(f64, f64)> {
let one = curve
.iter()
.find(|h| h.horizon == Horizon::Days(1))
.map(|h| h.rms_position_m())?;
if one <= 0.0 || !one.is_finite() {
return None;
}
let (far_days, far_pos) = curve
.iter()
.filter_map(|h| match h.horizon {
Horizon::Days(d) if d >= 2 => Some((d as f64, h.rms_position_m())),
_ => None,
})
.max_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))?;
Some((far_pos / one, far_days))
}
pub fn frame_eop_svg(curve: &[HorizonError]) -> String {
let mut s = String::new();
s.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{SVG_W:.0}\" height=\"{SVG_H:.0}\" font-family=\"sans-serif\" font-size=\"12\" fill=\"#bcb3a3\">"
));
s.push_str(&format!(
"<rect width=\"{SVG_W:.0}\" height=\"{SVG_H:.0}\" fill=\"#0c0b08\"/>"
));
s.push_str(&format!(
"<text x=\"{ML:.0}\" y=\"22\" font-size=\"15\" font-weight=\"bold\" fill=\"#e0bd84\">Real-time frame / EOP prediction budget for lunar timing</text>"
));
let a_axis_y = PANEL_A_TOP + PANEL_H;
let b_axis_y = PANEL_B_TOP + PANEL_H;
s.push_str(&crate::chart::y_axis(
ML,
PANEL_A_TOP,
PW,
PANEL_H,
A_Y_MAX_MS,
"UT1 error (ms)",
));
s.push_str(&format!(
"<line x1=\"{ML:.0}\" y1=\"{PANEL_A_TOP:.0}\" x2=\"{ML:.0}\" y2=\"{a_axis_y:.0}\" stroke=\"#342c21\"/>"
));
s.push_str(&format!(
"<line x1=\"{ML:.0}\" y1=\"{a_axis_y:.0}\" x2=\"{:.0}\" y2=\"{a_axis_y:.0}\" stroke=\"#342c21\"/>",
ML + PW
));
s.push_str(&format!(
"<text x=\"{ML:.0}\" y=\"40\" fill=\"#8c8273\">(a)</text>"
));
let floor_ms = curve
.iter()
.find(|h| h.horizon == Horizon::Final)
.map(|h| h.rms_ms())
.unwrap_or(0.02);
let floor_y = a_y_of_ms(floor_ms);
s.push_str(&format!(
"<line x1=\"{ML:.0}\" y1=\"{floor_y:.1}\" x2=\"{:.0}\" y2=\"{floor_y:.1}\" stroke=\"#6fae7a\" stroke-dasharray=\"4 3\"/>",
ML + PW
));
s.push_str(&format!(
"<text x=\"{:.0}\" y=\"{:.1}\" fill=\"#6fae7a\">IERS final floor {floor_ms:.3} ms</text>",
ML + 6.0,
floor_y - 4.0
));
let mark_y = a_y_of_ms(MARKER_UT1_MS);
s.push_str(&format!(
"<line x1=\"{ML:.0}\" y1=\"{mark_y:.1}\" x2=\"{:.0}\" y2=\"{mark_y:.1}\" stroke=\"#e5645a\" stroke-dasharray=\"6 4\"/>",
ML + PW
));
s.push_str(&format!(
"<text x=\"{:.0}\" y=\"{:.1}\" fill=\"#e5645a\">~{MARKER_UT1_MS} ms = ~{MARKER_POS_M:.0} m at Moon</text>",
ML + 6.0,
mark_y - 4.0
));
let mark_x = x_of_days(MARKER_HORIZON_DAYS);
s.push_str(&format!(
"<line x1=\"{mark_x:.1}\" y1=\"{PANEL_A_TOP:.0}\" x2=\"{mark_x:.1}\" y2=\"{a_axis_y:.0}\" stroke=\"#d2925e\" stroke-dasharray=\"3 3\"/>"
));
s.push_str(&format!(
"<text x=\"{:.1}\" y=\"{:.0}\" fill=\"#d2925e\">~{MARKER_HORIZON_DAYS:.0} d</text>",
mark_x + 4.0,
PANEL_A_TOP + 14.0
));
let a_pts: Vec<(f64, f64)> = curve
.iter()
.map(|h| (x_of_days(h.horizon.days()), a_y_of_ms(h.rms_ms())))
.collect();
s.push_str(&polyline(&a_pts, "#e0bd84"));
for (x, y) in &a_pts {
s.push_str(&format!(
"<circle cx=\"{x:.1}\" cy=\"{y:.1}\" r=\"3\" fill=\"#e0bd84\"/>"
));
}
s.push_str(&crate::chart::y_axis(
ML,
PANEL_B_TOP,
PW,
PANEL_H,
B_Y_MAX_M,
"position at Moon (m)",
));
s.push_str(&format!(
"<line x1=\"{ML:.0}\" y1=\"{PANEL_B_TOP:.0}\" x2=\"{ML:.0}\" y2=\"{b_axis_y:.0}\" stroke=\"#342c21\"/>"
));
s.push_str(&format!(
"<line x1=\"{ML:.0}\" y1=\"{b_axis_y:.0}\" x2=\"{:.0}\" y2=\"{b_axis_y:.0}\" stroke=\"#342c21\"/>",
ML + PW
));
s.push_str(&format!(
"<text x=\"{ML:.0}\" y=\"{:.0}\" fill=\"#8c8273\">(b)</text>",
PANEL_B_TOP - 8.0
));
let right_x = ML + PW;
for i in 0..=4 {
let frac = i as f64 / 4.0;
let y = PANEL_B_TOP + PANEL_H - frac * PANEL_H;
let pos_m = B_Y_MAX_M * frac;
let ns = pos_m / C_M_S * 1e9;
s.push_str(&format!(
"<text x=\"{:.0}\" y=\"{:.1}\" text-anchor=\"start\" fill=\"#8c8273\" font-size=\"11\">{ns:.0} ns</text>",
right_x + 6.0,
y + 4.0
));
}
let rc = PANEL_B_TOP + PANEL_H / 2.0;
s.push_str(&format!(
"<text x=\"{:.0}\" y=\"{rc:.1}\" text-anchor=\"middle\" fill=\"#8c8273\" font-size=\"12\" transform=\"rotate(90 {:.0} {rc:.1})\">equiv. timing (ns)</text>",
SVG_W - 16.0,
SVG_W - 16.0
));
let m15_y = b_y_of_m(MARKER_POS_M);
s.push_str(&format!(
"<line x1=\"{ML:.0}\" y1=\"{m15_y:.1}\" x2=\"{:.0}\" y2=\"{m15_y:.1}\" stroke=\"#e5645a\" stroke-dasharray=\"6 4\"/>",
ML + PW
));
s.push_str(&format!(
"<text x=\"{:.0}\" y=\"{:.1}\" fill=\"#e5645a\">{MARKER_POS_M:.0} m ({:.1} ns)</text>",
ML + 6.0,
m15_y - 4.0,
MARKER_POS_M / C_M_S * 1e9
));
s.push_str(&format!(
"<line x1=\"{mark_x:.1}\" y1=\"{PANEL_B_TOP:.0}\" x2=\"{mark_x:.1}\" y2=\"{b_axis_y:.0}\" stroke=\"#d2925e\" stroke-dasharray=\"3 3\"/>"
));
let b_pts: Vec<(f64, f64)> = curve
.iter()
.map(|h| (x_of_days(h.horizon.days()), b_y_of_m(h.rms_position_m())))
.collect();
s.push_str(&polyline(&b_pts, "#e0bd84"));
for (x, y) in &b_pts {
s.push_str(&format!(
"<circle cx=\"{x:.1}\" cy=\"{y:.1}\" r=\"3\" fill=\"#e0bd84\"/>"
));
}
if let Some((factor, far_days)) = growth_annotation(curve) {
s.push_str(&format!(
"<text x=\"{:.1}\" y=\"{:.0}\" fill=\"#d2925e\">~{factor:.1}x, 1 d\u{2192}{far_days:.0} d</text>",
mark_x + 4.0,
PANEL_B_TOP + 16.0,
));
}
s.push_str(&format!(
"<text x=\"{:.0}\" y=\"{:.0}\" text-anchor=\"middle\" fill=\"#8c8273\">prediction horizon (days)</text>",
ML + PW / 2.0,
SVG_H - 12.0
));
s.push_str("</svg>");
s
}
#[cfg(test)]
mod tests {
use super::*;
use crate::frames::polar_motion_matrix;
use crate::precession::mat_vec;
const FIXTURE: &str = include_str!("../tests/fixtures/agency/eop/finals2000A_2022001.txt");
const LONGSPAN: &str =
include_str!("../tests/fixtures/agency/eop/finals2000A_2022001_longspan.txt");
const FIXTURE_2026: &str = include_str!("../tests/fixtures/agency/eop/finals2000A_2026.txt");
#[test]
fn one_ms_ut1_is_28m_and_93_5ns() {
let (pos, t) = ut1_error_to_lunar(1e-3);
assert!(
(pos - 28.03).abs() < 0.02,
"position {pos} m, expected 28.03"
);
assert!(
(t * 1e9 - 93.5).abs() < 0.1,
"time {} ns, expected 93.5",
t * 1e9
);
}
#[test]
fn lever_arm_inverse_round_trips() {
let dut1 = 0.734e-3;
let (pos, _) = ut1_error_to_lunar(dut1);
assert!((lunar_position_to_ut1(pos) - dut1).abs() < 1e-15);
assert!((lunar_position_to_ut1(15.0) * 1e3 - 0.535).abs() < 0.01);
}
#[test]
fn omega_earth_matches_cio_era_rate() {
let era0 = crate::cio::earth_rotation_angle(2_451_545.0);
let era1 = crate::cio::earth_rotation_angle(2_451_546.0);
let per_day = era1 - era0 + std::f64::consts::TAU; let omega = per_day / SECONDS_PER_DAY;
assert!((OMEGA_EARTH_RAD_S - omega).abs() < 1e-14);
assert!((OMEGA_EARTH_RAD_S - 7.292115e-5).abs() < 1e-10);
}
#[test]
fn polar_motion_lever_matches_cio_rotation() {
let dxp = crate::frames::arcsec(0.02); let jd_tt = 2_451_545.0;
let r = [D_EM_M, 0.0, 0.0];
let m0 = polar_motion_matrix(0.0, 0.0, jd_tt);
let m1 = polar_motion_matrix(dxp, 0.0, jd_tt);
let r0 = mat_vec(&m0, r);
let r1 = mat_vec(&m1, r);
let disp =
((r1[0] - r0[0]).powi(2) + (r1[1] - r0[1]).powi(2) + (r1[2] - r0[2]).powi(2)).sqrt();
let closed = frame_position_error_at_moon(0.0, dxp, 0.0);
assert!(
(disp - closed).abs() / closed < 5e-3,
"cio rotation {disp} m vs closed form {closed} m"
);
assert!((closed - D_EM_M * dxp).abs() < 1e-6);
}
#[test]
fn combined_budget_is_rss_of_terms() {
let ut1 = 0.5e-3;
let dxp = crate::frames::arcsec(0.03);
let dyp = crate::frames::arcsec(0.04);
let combined = frame_position_error_at_moon(ut1, dxp, dyp);
let ut1_only = frame_position_error_at_moon(ut1, 0.0, 0.0);
let pm_only = frame_position_error_at_moon(0.0, dxp, dyp);
assert!((ut1_only - ut1_error_to_lunar(ut1).0.abs()).abs() < 1e-9);
assert!((combined - (ut1_only * ut1_only + pm_only * pm_only).sqrt()).abs() < 1e-9);
}
#[test]
fn measured_final_floor_and_growth_from_real_fixture() {
let horizons = [
Horizon::Final,
Horizon::Days(1),
Horizon::Days(2),
Horizon::Days(3),
];
let curve = prediction_error_vs_horizon(FIXTURE, &horizons);
let get = |h: Horizon| {
*curve
.iter()
.find(|e| e.horizon == h)
.expect("horizon present in the fixture")
};
let floor = get(Horizon::Final);
let d1 = get(Horizon::Days(1));
let d2 = get(Horizon::Days(2));
assert_eq!(floor.n, 5);
assert_eq!(d1.n, 4);
assert_eq!(d2.n, 3);
assert!(
floor.rms_ms() > 0.005 && floor.rms_ms() < 0.05,
"final floor {} ms outside published band",
floor.rms_ms()
);
assert!(d1.rms_ms() > floor.rms_ms());
assert!(d2.rms_ms() > floor.rms_ms());
assert!(
d1.rms_ms() > 0.05 && d1.rms_ms() < 0.6,
"1-day {} ms",
d1.rms_ms()
);
assert!(
d2.rms_ms() > 0.05 && d2.rms_ms() < 0.8,
"2-day {} ms",
d2.rms_ms()
);
assert!(d2.rms_ms() >= d1.rms_ms());
assert!(d1.p95_ms() >= d1.p50_ms());
assert!((d1.rms_position_m() - ut1_error_to_lunar(d1.rms_s).0).abs() < 1e-9);
}
#[test]
fn horizons_beyond_the_data_are_omitted() {
let curve = prediction_error_vs_horizon(
FIXTURE,
&[Horizon::Final, Horizon::Days(5), Horizon::Days(10)],
);
assert!(curve.iter().any(|e| e.horizon == Horizon::Final));
assert!(!curve.iter().any(|e| e.horizon == Horizon::Days(5)));
assert!(!curve.iter().any(|e| e.horizon == Horizon::Days(10)));
}
#[test]
fn daily_pairs_parse_rapid_and_final_from_real_rows() {
let daily = parse_daily_ut1(FIXTURE);
assert_eq!(daily.len(), 5);
assert_eq!(daily[0].mjd, 59578.0);
assert!((daily[0].ut1_rapid_s - (-0.1101027)).abs() < 1e-12);
assert!((daily[0].ut1_final_s.expect("final present") - (-0.1101029)).abs() < 1e-12);
}
#[test]
fn svg_markers_match_numeric_outputs() {
let curve = prediction_error_vs_horizon(
FIXTURE,
&[
Horizon::Final,
Horizon::Days(1),
Horizon::Days(2),
Horizon::Days(3),
],
);
let svg = frame_eop_svg(&curve);
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
assert_eq!(svg, frame_eop_svg(&curve));
let mark_x = x_of_days(MARKER_HORIZON_DAYS);
assert!(svg.contains(&format!("x1=\"{mark_x:.1}\"")));
let mark_y = a_y_of_ms(MARKER_UT1_MS);
assert!(svg.contains(&format!("y1=\"{mark_y:.1}\"")));
let m15_y = b_y_of_m(MARKER_POS_M);
assert!(svg.contains(&format!("y1=\"{m15_y:.1}\"")));
assert!((ut1_error_to_lunar(MARKER_UT1_MS * 1e-3).0 - MARKER_POS_M).abs() < 1.5);
let floor = curve
.iter()
.find(|h| h.horizon == Horizon::Final)
.expect("final floor");
let floor_y = a_y_of_ms(floor.rms_ms());
assert!(svg.contains(&format!("y1=\"{floor_y:.1}\"")));
let x0 = x_of_days(0.0);
let y0 = a_y_of_ms(floor.rms_ms());
assert!(svg.contains(&format!("cx=\"{x0:.1}\" cy=\"{y0:.1}\"")));
}
#[test]
fn frame_error_budget_is_rss_of_derived_terms() {
use crate::lunar_frame_predict::{OdCovariance, REALTIME_LATENCY_S};
let b = frame_error_budget(
0.5e-3,
0.0,
0.0,
OdCovariance::representative(),
REALTIME_LATENCY_S,
0.2,
);
let expect = (b.eop_term_m * b.eop_term_m
+ b.ephemeris_term_m * b.ephemeris_term_m
+ b.frame_realization_floor_m * b.frame_realization_floor_m)
.sqrt();
assert!((b.total_m - expect).abs() < 1e-9, "RSS");
assert!(
(b.total_time_ns - b.total_m / C_M_S * 1e9).abs() < 1e-6,
"time map"
);
assert!(
b.ephemeris_term_m > 10.0,
"ephemeris term {}",
b.ephemeris_term_m
);
assert!((b.frame_realization_floor_m - 0.2).abs() < 1e-12);
}
#[test]
fn growth_annotation_matches_the_measured_curve_and_is_genuine() {
let horizons = [
Horizon::Days(1),
Horizon::Days(2),
Horizon::Days(3),
Horizon::Days(5),
Horizon::Days(10),
];
let curve = prediction_error_vs_horizon(LONGSPAN, &horizons);
let (factor, far_days) = growth_annotation(&curve).expect("annotation present");
let pos_at = |d: u32| {
curve
.iter()
.find(|h| h.horizon == Horizon::Days(d))
.map(|h| h.rms_position_m())
.unwrap()
};
let expect = pos_at(10) / pos_at(1);
assert!(
(factor - expect).abs() < 1e-9,
"annotation factor {factor} vs recompute {expect}"
);
assert_eq!(far_days, 10.0, "longest real horizon must be 10 days");
assert!(
(4.0..6.0).contains(&factor),
"1 d -> 10 d growth factor {factor} outside the genuine ~5x band"
);
let svg = frame_eop_svg(&curve);
let expected_text = format!("~{factor:.1}x, 1 d\u{2192}{far_days:.0} d");
assert!(
svg.contains(&expected_text),
"SVG missing genuine growth annotation `{expected_text}`"
);
assert!(!svg.contains("~1x"));
assert!(!svg.contains("~5x vs 1 d"));
}
#[test]
fn growth_annotation_absent_when_only_one_day_horizon() {
let curve = prediction_error_vs_horizon(LONGSPAN, &[Horizon::Days(1)]);
assert!(growth_annotation(&curve).is_none());
let svg = frame_eop_svg(&curve);
assert!(!svg.contains("1 d\u{2192}"));
}
#[test]
fn growth_annotation_picks_max_day_value_not_index() {
let mut curve = prediction_error_vs_horizon(
LONGSPAN,
&[Horizon::Days(1), Horizon::Days(10), Horizon::Days(5)],
);
curve.reverse(); let (_f, far_days) = growth_annotation(&curve).expect("annotation present");
assert_eq!(
far_days, 10.0,
"must pick the largest day VALUE (10), not index"
);
}
#[test]
fn polar_motion_position_error_matches_cio_rotation() {
let dxp = crate::frames::arcsec(0.02); let dyp = crate::frames::arcsec(0.015); let pm = polar_motion_position_error(dxp, dyp);
assert!((pm - frame_position_error_at_moon(0.0, dxp, dyp)).abs() < 1e-12);
let jd_tt = 2_451_545.0;
let r = [D_EM_M, 0.0, 0.0];
let m0 = polar_motion_matrix(0.0, 0.0, jd_tt);
let m1 = polar_motion_matrix(dxp, 0.0, jd_tt);
let r0 = mat_vec(&m0, r);
let r1 = mat_vec(&m1, r);
let disp =
((r1[0] - r0[0]).powi(2) + (r1[1] - r0[1]).powi(2) + (r1[2] - r0[2]).powi(2)).sqrt();
let closed = polar_motion_position_error(dxp, 0.0);
assert!(
(disp - closed).abs() / closed < 5e-3,
"cio {disp} vs closed {closed}"
);
}
#[test]
fn pm_prediction_error_curve_from_real_data_in_iers_band() {
let curve = pm_prediction_error_vs_horizon(
FIXTURE_2026,
&[
Horizon::Final,
Horizon::Days(1),
Horizon::Days(2),
Horizon::Days(5),
],
);
let get = |h: Horizon| {
*curve
.iter()
.find(|e| e.horizon == h)
.expect("horizon present")
};
let floor_mas = get(Horizon::Final).rms_s * 1e3;
let d1_mas = get(Horizon::Days(1)).rms_s * 1e3;
let d2_mas = get(Horizon::Days(2)).rms_s * 1e3;
assert_eq!(get(Horizon::Final).n, 20, "20 paired final rows");
assert!(
floor_mas > 0.0 && floor_mas < 1.0,
"PM final floor {floor_mas} mas outside the sub-mas IERS band"
);
assert!(d1_mas > floor_mas, "1-day {d1_mas} !> floor {floor_mas}");
assert!(d2_mas >= d1_mas, "growth non-monotone: {d2_mas} < {d1_mas}");
assert!(
(0.1..20.0).contains(&d2_mas),
"2-day PM error {d2_mas} mas outside the expected daily-growth band"
);
let d2_rad = get(Horizon::Days(2)).rms_s * crate::eop::ARCSEC_TO_RAD;
let pos_m = polar_motion_position_error(d2_rad, 0.0);
assert!(pos_m > 0.0 && pos_m.is_finite());
}
#[test]
fn derived_floor_equals_helmert_post_fit_residual() {
use crate::lunar_frame_realise::LunarFrameRealiseScenario;
for tie in [0.1_f64, 0.2, 0.5] {
let derived = derived_frame_realization_floor_m(tie);
let report = LunarFrameRealiseScenario {
noise_sigma_m: tie,
..LunarFrameRealiseScenario::default()
}
.run();
assert!(
(derived - report.rms_residual_m).abs() < 1e-12,
"derived floor {derived} != realisation residual {}",
report.rms_residual_m
);
assert!(
derived > 0.3 * tie && derived < 2.0 * tie,
"derived floor {derived} m not near the {tie} m tie-noise level"
);
}
let default_floor = derived_frame_realization_floor_m(0.2);
assert!(
(0.10..0.30).contains(&default_floor),
"default derived floor {default_floor} m outside the expected band"
);
assert!(derived_frame_realization_floor_m(0.0) < 1e-3);
}
#[test]
fn predicted_rows_summary_reads_real_prediction_rows() {
let s = predicted_rows_summary(FIXTURE_2026);
assert_eq!(s.n, 12, "12 real Bulletin A prediction-only rows");
assert_eq!(s.first_mjd, Some(61193.0));
assert_eq!(s.last_mjd, Some(61204.0));
assert_eq!(predicted_rows_summary(LONGSPAN).n, 0);
}
#[test]
fn predicted_vs_final_vintage_differencing_on_real_rows() {
let later = LONGSPAN;
let mut as_issued = String::new();
let mut cutoff_seen = 0;
for line in later.lines() {
if line.trim_start().starts_with('#') || line.len() < 68 {
as_issued.push_str(line);
as_issued.push('\n');
continue;
}
if cutoff_seen < 5 {
as_issued.push_str(line);
cutoff_seen += 1;
} else {
let head: String = line.chars().take(134).collect();
as_issued.push_str(head.trim_end());
}
as_issued.push('\n');
}
assert!(predicted_rows_summary(&as_issued).n > 0);
let resid = predicted_vs_final_ut1(
&as_issued,
later,
&[Horizon::Days(1), Horizon::Days(2), Horizon::Days(5)],
);
assert!(
!resid.is_empty(),
"vintage differencing produced no residuals"
);
for e in &resid {
assert!(e.rms_s.is_finite() && e.rms_s >= 0.0);
assert!(e.n >= 1, "at least one matched predicted→final pair");
assert!(
e.rms_ms() < 10.0,
"{:?} residual {} ms implausibly large",
e.horizon,
e.rms_ms()
);
}
}
}