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
}
fn epoch_key(mjd: f64) -> i64 {
(mjd * 1e6).round() as i64
}
fn ut1_residuals_by_epoch(daily: &[DailyUt1], h: Horizon) -> Vec<(f64, f64)> {
let mut out = Vec::new();
match h {
Horizon::Final => {
for d in daily {
if let Some(f) = d.ut1_final_s {
out.push((d.mjd, (d.ut1_rapid_s - f).abs()));
}
}
}
Horizon::Days(days) => {
for (i, base) in daily.iter().enumerate() {
let target_mjd = base.mjd + days as f64;
if let Some(target) = daily[i + 1..]
.iter()
.find(|d| (d.mjd - target_mjd).abs() < 1e-6)
{
out.push((base.mjd, (truth_ut1(base) - truth_ut1(target)).abs()));
}
}
}
}
out
}
fn pm_residuals_by_epoch(daily: &[DailyPm], h: Horizon) -> Vec<(f64, f64)> {
let mag = |dx: f64, dy: f64| (dx * dx + dy * dy).sqrt();
let mut out = Vec::new();
match h {
Horizon::Final => {
for d in daily {
if let Some((fx, fy)) = d.pm_final_as {
out.push((d.mjd, mag(d.xp_rapid_as - fx, d.yp_rapid_as - fy)));
}
}
}
Horizon::Days(days) => {
for (i, base) in daily.iter().enumerate() {
let target_mjd = base.mjd + days as f64;
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);
out.push((base.mjd, mag(bx - tx, by - ty)));
}
}
}
}
out
}
#[derive(Clone, Debug, PartialEq)]
pub struct JointComponent {
pub component: &'static str,
pub unit: &'static str,
pub n: usize,
pub epochs_mjd: Vec<f64>,
pub rms_native: f64,
pub p50_native: f64,
pub p95_native: f64,
pub max_native: f64,
pub rms_position_m: f64,
pub p95_position_m: f64,
pub rms_light_time_ns: f64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct JointEopError {
pub horizon: Horizon,
pub n: usize,
pub ut1: JointComponent,
pub polar_motion: JointComponent,
pub combined: JointComponent,
}
fn joint_component(
component: &'static str,
unit: &'static str,
horizon: Horizon,
pairs: &[(f64, f64)],
to_position_m: impl Fn(f64) -> f64,
) -> JointComponent {
let s = stats(horizon, pairs.iter().map(|(_, r)| *r).collect());
JointComponent {
component,
unit,
n: s.n,
epochs_mjd: pairs.iter().map(|(m, _)| *m).collect(),
rms_native: s.rms_s,
p50_native: s.p50_s,
p95_native: s.p95_s,
max_native: s.max_s,
rms_position_m: to_position_m(s.rms_s),
p95_position_m: to_position_m(s.p95_s),
rms_light_time_ns: to_position_m(s.rms_s) / C_M_S * 1e9,
}
}
pub fn joint_eop_error_vs_horizon(body: &str, horizons: &[Horizon]) -> Vec<JointEopError> {
let daily_ut1 = parse_daily_ut1(body);
let daily_pm = parse_daily_pm(body);
let mut out = Vec::new();
for &h in horizons {
let u = ut1_residuals_by_epoch(&daily_ut1, h);
let p = pm_residuals_by_epoch(&daily_pm, h);
let u_keys: std::collections::BTreeSet<i64> =
u.iter().map(|(m, _)| epoch_key(*m)).collect();
let p_keys: std::collections::BTreeSet<i64> =
p.iter().map(|(m, _)| epoch_key(*m)).collect();
let shared: std::collections::BTreeSet<i64> =
u_keys.intersection(&p_keys).copied().collect();
if shared.is_empty() {
continue;
}
let keep = |pairs: &[(f64, f64)]| -> Vec<(f64, f64)> {
pairs
.iter()
.filter(|(m, _)| shared.contains(&epoch_key(*m)))
.copied()
.collect()
};
let u_shared = keep(&u);
let p_shared = keep(&p);
let pm_pos = |as_: f64| polar_motion_position_error(as_ * crate::eop::ARCSEC_TO_RAD, 0.0);
let ut1_pos = |s_: f64| ut1_error_to_lunar(s_).0;
let combined: Vec<(f64, f64)> = u_shared
.iter()
.filter_map(|(m, ur)| {
p_shared
.iter()
.find(|(pm, _)| epoch_key(*pm) == epoch_key(*m))
.map(|(_, pr)| {
let a = ut1_pos(*ur);
let b = pm_pos(*pr);
(*m, (a * a + b * b).sqrt())
})
})
.collect();
let ut1 = joint_component("ut1", "s", h, &u_shared, ut1_pos);
let polar_motion = joint_component("polar-motion", "arcsec", h, &p_shared, pm_pos);
let combined = joint_component("combined", "m", h, &combined, |m| m);
out.push(JointEopError {
horizon: h,
n: ut1.n,
ut1,
polar_motion,
combined,
});
}
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 const ANNUAL_PERIOD_DAYS: f64 = 365.25;
pub const SEMIANNUAL_PERIOD_DAYS: f64 = ANNUAL_PERIOD_DAYS / 2.0;
pub const CHANDLER_PERIOD_DAYS: f64 = 433.0;
pub const MONTHLY_ZONAL_TIDE_PERIOD_DAYS: f64 = 27.554_550;
pub const FORTNIGHTLY_ZONAL_TIDE_PERIOD_DAYS: f64 = 13.660_791;
pub const DEFAULT_OPERATIONAL_WINDOW_DAYS: f64 = 15.0;
const MJD_EPS: f64 = 1e-6;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PeriodicTerm {
pub name: &'static str,
pub period_days: f64,
}
pub const UT1_PERIODIC_TERMS: &[PeriodicTerm] = &[
PeriodicTerm {
name: "annual",
period_days: ANNUAL_PERIOD_DAYS,
},
PeriodicTerm {
name: "semi-annual",
period_days: SEMIANNUAL_PERIOD_DAYS,
},
PeriodicTerm {
name: "monthly-zonal-tide",
period_days: MONTHLY_ZONAL_TIDE_PERIOD_DAYS,
},
PeriodicTerm {
name: "fortnightly-zonal-tide",
period_days: FORTNIGHTLY_ZONAL_TIDE_PERIOD_DAYS,
},
];
pub const PM_PERIODIC_TERMS: &[PeriodicTerm] = &[
PeriodicTerm {
name: "chandler",
period_days: CHANDLER_PERIOD_DAYS,
},
PeriodicTerm {
name: "annual",
period_days: ANNUAL_PERIOD_DAYS,
},
PeriodicTerm {
name: "semi-annual",
period_days: SEMIANNUAL_PERIOD_DAYS,
},
];
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct OperationalPredictorConfig {
pub window_days: f64,
pub min_cycle_fraction: f64,
pub anchor_residual: bool,
}
impl Default for OperationalPredictorConfig {
fn default() -> Self {
Self {
window_days: DEFAULT_OPERATIONAL_WINDOW_DAYS,
min_cycle_fraction: 0.5,
anchor_residual: true,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RejectedTerm {
pub name: &'static str,
pub period_days: f64,
pub cycles_spanned: f64,
pub threshold_cycles: f64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct OperationalFit {
pub issue_mjd: f64,
pub window_days: f64,
pub window_first_mjd: f64,
pub window_last_mjd: f64,
pub n_fit: usize,
pub term_names: Vec<&'static str>,
pub rejected_terms: Vec<RejectedTerm>,
pub coefficients: Vec<f64>,
pub periods_days: Vec<f64>,
pub anchor_residual: f64,
pub rms_fit_residual: f64,
}
impl OperationalFit {
pub fn predict(&self, target_mjd: f64) -> f64 {
let t = target_mjd - self.issue_mjd;
let mut y = self.coefficients[0] + self.coefficients[1] * (t / self.window_days);
for (k, p) in self.periods_days.iter().enumerate() {
let w = std::f64::consts::TAU * t / p;
y += self.coefficients[2 + 2 * k] * w.cos() + self.coefficients[3 + 2 * k] * w.sin();
}
y + self.anchor_residual
}
}
fn solve_normal_system(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
let k = b.len();
for col in 0..k {
let mut p = col;
for row in (col + 1)..k {
if a[row][col].abs() > a[p][col].abs() {
p = row;
}
}
if !a[p][col].is_finite() || a[p][col].abs() < 1e-300 {
return None;
}
a.swap(col, p);
b.swap(col, p);
for row in (col + 1)..k {
let f = a[row][col] / a[col][col];
let (upper, lower) = a.split_at_mut(row);
for (t, p) in lower[0][col..].iter_mut().zip(&upper[col][col..]) {
*t -= f * p;
}
b[row] -= f * b[col];
}
}
let mut x = vec![0.0; k];
for i in (0..k).rev() {
let mut s = b[i];
for c in (i + 1)..k {
s -= a[i][c] * x[c];
}
x[i] = s / a[i][i];
}
x.iter().all(|v| v.is_finite()).then_some(x)
}
pub fn fit_operational(
samples: &[(f64, f64)],
issue_mjd: f64,
terms: &[PeriodicTerm],
cfg: &OperationalPredictorConfig,
) -> Option<OperationalFit> {
if !(cfg.window_days.is_finite() && cfg.window_days > 0.0) {
return None;
}
let window: Vec<(f64, f64)> = samples
.iter()
.copied()
.filter(|(m, v)| {
m.is_finite()
&& v.is_finite()
&& *m <= issue_mjd + MJD_EPS
&& *m >= issue_mjd - cfg.window_days - MJD_EPS
})
.collect();
if window.len() < 2 {
return None;
}
let first = window.iter().map(|(m, _)| *m).fold(f64::INFINITY, f64::min);
let last = window
.iter()
.map(|(m, _)| *m)
.fold(f64::NEG_INFINITY, f64::max);
if first > issue_mjd - cfg.window_days + MJD_EPS {
return None;
}
let span = last - first;
let mut periods = Vec::new();
let mut term_names: Vec<&'static str> = vec!["bias", "rate"];
let mut rejected = Vec::new();
for t in terms {
let cycles = span / t.period_days;
if cycles >= cfg.min_cycle_fraction {
periods.push(t.period_days);
term_names.push(t.name);
} else {
rejected.push(RejectedTerm {
name: t.name,
period_days: t.period_days,
cycles_spanned: cycles,
threshold_cycles: cfg.min_cycle_fraction,
});
}
}
let n_params = 2 + 2 * periods.len();
if window.len() < 2 * n_params {
return None;
}
let row = |mjd: f64| -> Vec<f64> {
let t = mjd - issue_mjd;
let mut r = vec![1.0, t / cfg.window_days];
for p in &periods {
let w = std::f64::consts::TAU * t / p;
r.push(w.cos());
r.push(w.sin());
}
r
};
let mut ata = vec![vec![0.0f64; n_params]; n_params];
let mut atb = vec![0.0f64; n_params];
for (m, v) in &window {
let r = row(*m);
for i in 0..n_params {
atb[i] += r[i] * v;
for j in 0..n_params {
ata[i][j] += r[i] * r[j];
}
}
}
let coefficients = solve_normal_system(ata, atb)?;
let model_at = |mjd: f64| -> f64 {
row(mjd)
.iter()
.zip(&coefficients)
.map(|(a, b)| a * b)
.sum::<f64>()
};
let sum_sq: f64 = window
.iter()
.map(|(m, v)| {
let e = v - model_at(*m);
e * e
})
.sum();
let rms_fit_residual = (sum_sq / window.len() as f64).sqrt();
let anchor_residual = if cfg.anchor_residual {
let v_last = window
.iter()
.filter(|(m, _)| (m - last).abs() < MJD_EPS)
.map(|(_, v)| *v)
.next_back()?;
v_last - model_at(last)
} else {
0.0
};
Some(OperationalFit {
issue_mjd,
window_days: cfg.window_days,
window_first_mjd: first,
window_last_mjd: last,
n_fit: window.len(),
term_names,
rejected_terms: rejected,
coefficients,
periods_days: periods,
anchor_residual,
rms_fit_residual,
})
}
fn dat_at(mjd: f64) -> f64 {
crate::timescales::tai_minus_utc(mjd + crate::timescales::MJD_OFFSET)
}
fn rapid_ut1_tai_samples(daily: &[DailyUt1]) -> Vec<(f64, f64)> {
daily
.iter()
.map(|d| (d.mjd, d.ut1_rapid_s - dat_at(d.mjd)))
.collect()
}
pub fn latest_operational_fits(
body: &str,
cfg: &OperationalPredictorConfig,
) -> (Option<OperationalFit>, Option<OperationalFit>) {
let daily_ut1 = parse_daily_ut1(body);
let daily_pm = parse_daily_pm(body);
let last = daily_ut1
.iter()
.map(|d| d.mjd)
.fold(f64::NEG_INFINITY, f64::max);
if !last.is_finite() {
return (None, None);
}
let xp_samples: Vec<(f64, f64)> = daily_pm.iter().map(|d| (d.mjd, d.xp_rapid_as)).collect();
(
fit_operational(
&rapid_ut1_tai_samples(&daily_ut1),
last,
UT1_PERIODIC_TERMS,
cfg,
),
fit_operational(&xp_samples, last, PM_PERIODIC_TERMS, cfg),
)
}
#[derive(Clone, Debug, PartialEq)]
pub struct PredictorError {
pub predictor: &'static str,
pub quantity: &'static str,
pub unit: &'static str,
pub n: usize,
pub rms_native: f64,
pub p50_native: f64,
pub p95_native: f64,
pub max_native: f64,
pub rms_position_m: f64,
pub p95_position_m: f64,
pub rms_light_time_ns: f64,
}
fn predictor_error(
predictor: &'static str,
quantity: &'static str,
unit: &'static str,
resid: &[f64],
to_position_m: impl Fn(f64) -> f64,
) -> PredictorError {
let s = stats(Horizon::Final, resid.to_vec());
PredictorError {
predictor,
quantity,
unit,
n: s.n,
rms_native: s.rms_s,
p50_native: s.p50_s,
p95_native: s.p95_s,
max_native: s.max_s,
rms_position_m: to_position_m(s.rms_s),
p95_position_m: to_position_m(s.p95_s),
rms_light_time_ns: to_position_m(s.rms_s) / C_M_S * 1e9,
}
}
fn ut1_to_position_m(s: f64) -> f64 {
ut1_error_to_lunar(s).0
}
fn pm_to_position_m(arcsec: f64) -> f64 {
polar_motion_position_error(arcsec * crate::eop::ARCSEC_TO_RAD, 0.0)
}
#[derive(Clone, Debug, PartialEq)]
pub struct PredictorComparisonRow {
pub horizon: Horizon,
pub n: usize,
pub epochs_mjd: Vec<f64>,
pub target_mjds: Vec<f64>,
pub min_fit_lead_days: f64,
pub fit_rows_min: usize,
pub fit_rows_max: usize,
pub ut1_operational: PredictorError,
pub ut1_persistence: PredictorError,
pub pm_operational: PredictorError,
pub pm_persistence: PredictorError,
pub combined_operational: PredictorError,
pub combined_persistence: PredictorError,
}
impl PredictorComparisonRow {
pub fn combined_improvement_factor(&self) -> Option<f64> {
let o = self.combined_operational.rms_position_m;
(o > 0.0).then(|| self.combined_persistence.rms_position_m / o)
}
}
struct HorizonSamples {
epochs: Vec<f64>,
targets: Vec<f64>,
ut1_op: Vec<f64>,
ut1_pers: Vec<f64>,
pm_op: Vec<f64>,
pm_pers: Vec<f64>,
comb_op: Vec<f64>,
comb_pers: Vec<f64>,
min_lead: f64,
fit_rows_min: usize,
fit_rows_max: usize,
}
fn at_mjd<T: Copy>(rows: &[T], mjd: f64, key: impl Fn(&T) -> f64) -> Option<T> {
rows.iter()
.find(|r| (key(r) - mjd).abs() < MJD_EPS)
.copied()
}
#[allow(clippy::too_many_arguments)]
fn collect_horizon_samples(
daily_ut1: &[DailyUt1],
daily_pm: &[DailyPm],
ut1_samples: &[(f64, f64)],
xp_samples: &[(f64, f64)],
yp_samples: &[(f64, f64)],
days: u32,
cfg: &OperationalPredictorConfig,
) -> HorizonSamples {
let mut s = HorizonSamples {
epochs: Vec::new(),
targets: Vec::new(),
ut1_op: Vec::new(),
ut1_pers: Vec::new(),
pm_op: Vec::new(),
pm_pers: Vec::new(),
comb_op: Vec::new(),
comb_pers: Vec::new(),
min_lead: f64::INFINITY,
fit_rows_min: usize::MAX,
fit_rows_max: 0,
};
let mag = |dx: f64, dy: f64| (dx * dx + dy * dy).sqrt();
for base in daily_ut1 {
let t = base.mjd;
let target = t + days as f64;
let Some(final_ut1) = at_mjd(daily_ut1, target, |d| d.mjd).and_then(|d| d.ut1_final_s)
else {
continue;
};
let Some((final_xp, final_yp)) =
at_mjd(daily_pm, target, |d| d.mjd).and_then(|d| d.pm_final_as)
else {
continue;
};
let Some(p_base) = at_mjd(daily_pm, t, |d| d.mjd) else {
continue;
};
let (Some(f_ut1), Some(f_xp), Some(f_yp)) = (
fit_operational(ut1_samples, t, UT1_PERIODIC_TERMS, cfg),
fit_operational(xp_samples, t, PM_PERIODIC_TERMS, cfg),
fit_operational(yp_samples, t, PM_PERIODIC_TERMS, cfg),
) else {
continue;
};
let op_ut1 = f_ut1.predict(target) + dat_at(target);
let op_xp = f_xp.predict(target);
let op_yp = f_yp.predict(target);
let pers_ut1 = (base.ut1_rapid_s - dat_at(t)) + dat_at(target);
let (pers_xp, pers_yp) = (p_base.xp_rapid_as, p_base.yp_rapid_as);
let r_ut1_op = (op_ut1 - final_ut1).abs();
let r_ut1_pers = (pers_ut1 - final_ut1).abs();
let r_pm_op = mag(op_xp - final_xp, op_yp - final_yp);
let r_pm_pers = mag(pers_xp - final_xp, pers_yp - final_yp);
s.epochs.push(t);
s.targets.push(target);
s.comb_op
.push(mag(ut1_to_position_m(r_ut1_op), pm_to_position_m(r_pm_op)));
s.comb_pers.push(mag(
ut1_to_position_m(r_ut1_pers),
pm_to_position_m(r_pm_pers),
));
s.ut1_op.push(r_ut1_op);
s.ut1_pers.push(r_ut1_pers);
s.pm_op.push(r_pm_op);
s.pm_pers.push(r_pm_pers);
for f in [&f_ut1, &f_xp, &f_yp] {
s.min_lead = s.min_lead.min(target - f.window_last_mjd);
s.fit_rows_min = s.fit_rows_min.min(f.n_fit);
s.fit_rows_max = s.fit_rows_max.max(f.n_fit);
}
}
s
}
pub fn operational_vs_persistence_vs_horizon(
body: &str,
horizons: &[Horizon],
cfg: &OperationalPredictorConfig,
) -> Vec<PredictorComparisonRow> {
let daily_ut1 = parse_daily_ut1(body);
let daily_pm = parse_daily_pm(body);
let ut1_samples = rapid_ut1_tai_samples(&daily_ut1);
let xp_samples: Vec<(f64, f64)> = daily_pm.iter().map(|d| (d.mjd, d.xp_rapid_as)).collect();
let yp_samples: Vec<(f64, f64)> = daily_pm.iter().map(|d| (d.mjd, d.yp_rapid_as)).collect();
let mut out = Vec::new();
for &h in horizons {
let Horizon::Days(days) = h else { continue };
let s = collect_horizon_samples(
&daily_ut1,
&daily_pm,
&ut1_samples,
&xp_samples,
&yp_samples,
days,
cfg,
);
if s.epochs.is_empty() {
continue;
}
out.push(PredictorComparisonRow {
horizon: h,
n: s.epochs.len(),
min_fit_lead_days: s.min_lead,
fit_rows_min: s.fit_rows_min,
fit_rows_max: s.fit_rows_max,
ut1_operational: predictor_error(
"operational",
"ut1",
"s",
&s.ut1_op,
ut1_to_position_m,
),
ut1_persistence: predictor_error(
"persistence",
"ut1",
"s",
&s.ut1_pers,
ut1_to_position_m,
),
pm_operational: predictor_error(
"operational",
"polar-motion",
"arcsec",
&s.pm_op,
pm_to_position_m,
),
pm_persistence: predictor_error(
"persistence",
"polar-motion",
"arcsec",
&s.pm_pers,
pm_to_position_m,
),
combined_operational: predictor_error(
"operational",
"combined",
"m",
&s.comb_op,
|m| m,
),
combined_persistence: predictor_error("persistence", "combined", "m", &s.comb_pers, {
|m| m
}),
epochs_mjd: s.epochs,
target_mjds: s.targets,
})
}
out
}
pub fn equivalent_horizon_days(curve: &[(f64, f64)], target_position_m: f64) -> Option<f64> {
let mut pts: Vec<(f64, f64)> = curve
.iter()
.copied()
.filter(|(d, p)| d.is_finite() && p.is_finite())
.collect();
pts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
for w in pts.windows(2) {
let ((d0, p0), (d1, p1)) = (w[0], w[1]);
if (p0.min(p1)..=p0.max(p1)).contains(&target_position_m) && (p1 - p0).abs() > 0.0 {
return Some(d0 + (target_position_m - p0) * (d1 - d0) / (p1 - p0));
}
}
None
}
#[derive(Clone, Debug, PartialEq)]
pub struct ArchivedVintageRow {
pub horizon: Horizon,
pub issue_mjd: f64,
pub n: usize,
pub epochs_mjd: Vec<f64>,
pub ut1_archived: PredictorError,
pub ut1_operational: PredictorError,
pub ut1_persistence: PredictorError,
pub pm_archived: PredictorError,
pub pm_operational: PredictorError,
pub pm_persistence: PredictorError,
}
pub fn archived_vintage_comparison(
as_issued: &str,
later_final: &str,
horizons: &[Horizon],
cfg: &OperationalPredictorConfig,
) -> Vec<ArchivedVintageRow> {
let issued_pred = parse_all_predicted(as_issued);
let issued_ut1 = parse_daily_ut1(as_issued);
let issued_pm = parse_daily_pm(as_issued);
let cutoff = issued_ut1
.iter()
.filter(|d| d.ut1_final_s.is_some())
.map(|d| d.mjd)
.fold(f64::NEG_INFINITY, f64::max);
if !cutoff.is_finite() {
return Vec::new();
}
let later_ut1 = parse_daily_ut1(later_final);
let later_pm = parse_daily_pm(later_final);
let ut1_samples = rapid_ut1_tai_samples(&issued_ut1);
let xp_samples: Vec<(f64, f64)> = issued_pm.iter().map(|d| (d.mjd, d.xp_rapid_as)).collect();
let yp_samples: Vec<(f64, f64)> = issued_pm.iter().map(|d| (d.mjd, d.yp_rapid_as)).collect();
let base_ut1 = at_mjd(&issued_ut1, cutoff, |d| d.mjd);
let base_pm = at_mjd(&issued_pm, cutoff, |d| d.mjd);
let fit_ut1 = fit_operational(&ut1_samples, cutoff, UT1_PERIODIC_TERMS, cfg);
let fit_xp = fit_operational(&xp_samples, cutoff, PM_PERIODIC_TERMS, cfg);
let fit_yp = fit_operational(&yp_samples, cutoff, PM_PERIODIC_TERMS, cfg);
let mag = |dx: f64, dy: f64| (dx * dx + dy * dy).sqrt();
let mut out = Vec::new();
for &h in horizons {
let Horizon::Days(days) = h else { continue };
let target = cutoff + days as f64;
let mut epochs = Vec::new();
let (mut a_u, mut o_u, mut p_u) = (Vec::new(), Vec::new(), Vec::new());
let (mut a_p, mut o_p, mut p_p) = (Vec::new(), Vec::new(), Vec::new());
for pred in issued_pred
.iter()
.filter(|p| (p.mjd - target).abs() < MJD_EPS)
{
let Some(f_ut1) = at_mjd(&later_ut1, pred.mjd, |d| d.mjd).and_then(|d| d.ut1_final_s)
else {
continue;
};
epochs.push(pred.mjd);
a_u.push((pred.ut1_utc_s - f_ut1).abs());
if let Some(fit) = &fit_ut1 {
o_u.push((fit.predict(pred.mjd) + dat_at(pred.mjd) - f_ut1).abs());
}
if let Some(b) = base_ut1 {
p_u.push(((b.ut1_rapid_s - dat_at(cutoff) + dat_at(pred.mjd)) - f_ut1).abs());
}
if let Some((fx, fy)) =
at_mjd(&later_pm, pred.mjd, |d| d.mjd).and_then(|d| d.pm_final_as)
{
a_p.push(mag(pred.xp_arcsec - fx, pred.yp_arcsec - fy));
if let (Some(fx_fit), Some(fy_fit)) = (&fit_xp, &fit_yp) {
o_p.push(mag(
fx_fit.predict(pred.mjd) - fx,
fy_fit.predict(pred.mjd) - fy,
));
}
if let Some(b) = base_pm {
p_p.push(mag(b.xp_rapid_as - fx, b.yp_rapid_as - fy));
}
}
}
if epochs.is_empty() {
continue;
}
out.push(ArchivedVintageRow {
horizon: h,
issue_mjd: cutoff,
n: epochs.len(),
epochs_mjd: epochs,
ut1_archived: predictor_error(
"archived-bulletin-a",
"ut1",
"s",
&a_u,
ut1_to_position_m,
),
ut1_operational: predictor_error("operational", "ut1", "s", &o_u, ut1_to_position_m),
ut1_persistence: predictor_error("persistence", "ut1", "s", &p_u, ut1_to_position_m),
pm_archived: predictor_error(
"archived-bulletin-a",
"polar-motion",
"arcsec",
&a_p,
pm_to_position_m,
),
pm_operational: predictor_error(
"operational",
"polar-motion",
"arcsec",
&o_p,
pm_to_position_m,
),
pm_persistence: predictor_error(
"persistence",
"polar-motion",
"arcsec",
&p_p,
pm_to_position_m,
),
});
}
out
}
#[derive(Clone, Debug, PartialEq)]
pub struct BulletinAAgreement {
pub issue_mjd: f64,
pub n: usize,
pub first_lead_days: f64,
pub last_lead_days: f64,
pub ut1_rms_s: f64,
pub ut1_rms_position_m: f64,
pub pm_rms_arcsec: f64,
pub pm_rms_position_m: f64,
pub leads: Vec<BulletinALead>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BulletinALead {
pub lead_days: f64,
pub ut1_diff_s: f64,
pub ut1_position_m: f64,
pub pm_diff_arcsec: f64,
pub pm_position_m: f64,
}
pub fn bulletin_a_agreement(
body: &str,
cfg: &OperationalPredictorConfig,
) -> Option<BulletinAAgreement> {
let preds = parse_all_predicted(body);
if preds.is_empty() {
return None;
}
let daily_ut1 = parse_daily_ut1(body);
let daily_pm = parse_daily_pm(body);
let cutoff = daily_ut1
.iter()
.filter(|d| d.ut1_final_s.is_some())
.map(|d| d.mjd)
.fold(f64::NEG_INFINITY, f64::max);
if !cutoff.is_finite() {
return None;
}
let f_ut1 = fit_operational(
&rapid_ut1_tai_samples(&daily_ut1),
cutoff,
UT1_PERIODIC_TERMS,
cfg,
)?;
let xp_samples: Vec<(f64, f64)> = daily_pm.iter().map(|d| (d.mjd, d.xp_rapid_as)).collect();
let yp_samples: Vec<(f64, f64)> = daily_pm.iter().map(|d| (d.mjd, d.yp_rapid_as)).collect();
let f_xp = fit_operational(&xp_samples, cutoff, PM_PERIODIC_TERMS, cfg)?;
let f_yp = fit_operational(&yp_samples, cutoff, PM_PERIODIC_TERMS, cfg)?;
let ahead: Vec<&EopRecord> = preds.iter().filter(|p| p.mjd > cutoff + MJD_EPS).collect();
if ahead.is_empty() {
return None;
}
let mut du = Vec::new();
let mut dp = Vec::new();
let mut leads = Vec::new();
for p in &ahead {
let u = (f_ut1.predict(p.mjd) + dat_at(p.mjd) - p.ut1_utc_s).abs();
let dx = f_xp.predict(p.mjd) - p.xp_arcsec;
let dy = f_yp.predict(p.mjd) - p.yp_arcsec;
let m = (dx * dx + dy * dy).sqrt();
du.push(u);
dp.push(m);
leads.push(BulletinALead {
lead_days: p.mjd - cutoff,
ut1_diff_s: u,
ut1_position_m: ut1_to_position_m(u),
pm_diff_arcsec: m,
pm_position_m: pm_to_position_m(m),
});
}
leads.sort_by(|a, b| {
a.lead_days
.partial_cmp(&b.lead_days)
.unwrap_or(std::cmp::Ordering::Equal)
});
let rms = |v: &[f64]| (v.iter().map(|x| x * x).sum::<f64>() / v.len() as f64).sqrt();
let ut1_rms_s = rms(&du);
let pm_rms_arcsec = rms(&dp);
Some(BulletinAAgreement {
issue_mjd: cutoff,
n: ahead.len(),
first_lead_days: leads.first().map(|l| l.lead_days).unwrap_or(0.0),
last_lead_days: leads.last().map(|l| l.lead_days).unwrap_or(0.0),
ut1_rms_s,
ut1_rms_position_m: ut1_to_position_m(ut1_rms_s),
pm_rms_arcsec,
pm_rms_position_m: pm_to_position_m(pm_rms_arcsec),
leads,
})
}
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()
);
}
}
fn blank_the_pole_final(line: &str) -> String {
let c: Vec<char> = line.chars().collect();
assert!(c.len() > 165, "row must reach the Bulletin B UT1 block");
let mut out: String = c[..134].iter().collect();
out.push_str(&" ".repeat(20));
out.extend(&c[154..]);
out
}
#[test]
fn joint_table_components_share_one_identical_epoch_set() {
let horizons = [
Horizon::Final,
Horizon::Days(1),
Horizon::Days(2),
Horizon::Days(5),
];
for (name, body) in [("longspan", LONGSPAN), ("2026", FIXTURE_2026)] {
let joint = joint_eop_error_vs_horizon(body, &horizons);
assert!(!joint.is_empty(), "{name}: joint table must populate");
for row in &joint {
assert_eq!(row.ut1.component, "ut1");
assert_eq!(row.polar_motion.component, "polar-motion");
assert_eq!(row.combined.component, "combined");
assert_eq!(
row.ut1.n, row.polar_motion.n,
"{name} {:?}: UT1 n {} != pole n {}",
row.horizon, row.ut1.n, row.polar_motion.n
);
assert_eq!(row.combined.n, row.ut1.n, "{name} {:?}", row.horizon);
assert_eq!(row.n, row.ut1.n, "{name} {:?}", row.horizon);
assert!(
row.n > 0,
"{name} {:?}: empty rows must be omitted",
row.horizon
);
assert_eq!(row.ut1.epochs_mjd.len(), row.n);
assert_eq!(row.polar_motion.epochs_mjd.len(), row.n);
assert_eq!(row.combined.epochs_mjd.len(), row.n);
for i in 0..row.n {
let (a, b, c) = (
row.ut1.epochs_mjd[i],
row.polar_motion.epochs_mjd[i],
row.combined.epochs_mjd[i],
);
assert!(
(a - b).abs() < 1e-9 && (a - c).abs() < 1e-9,
"{name} {:?}: epoch {i} differs - UT1 {a}, pole {b}, combined {c}",
row.horizon
);
}
for w in row.ut1.epochs_mjd.windows(2) {
assert!(
w[1] > w[0],
"{name} {:?}: epochs not ascending",
row.horizon
);
}
}
}
}
#[test]
fn joint_combination_is_the_quadrature_sum_of_its_own_two_components() {
let horizons = [
Horizon::Final,
Horizon::Days(1),
Horizon::Days(2),
Horizon::Days(5),
Horizon::Days(10),
];
for (name, body) in [("longspan", LONGSPAN), ("2026", FIXTURE_2026)] {
let joint = joint_eop_error_vs_horizon(body, &horizons);
assert!(!joint.is_empty(), "{name}: joint table must populate");
for row in &joint {
let u = row.ut1.rms_position_m;
let p = row.polar_motion.rms_position_m;
let expect = (u * u + p * p).sqrt();
let got = row.combined.rms_position_m;
assert!(
(got - expect).abs() <= 1e-9 * expect.max(1.0),
"{name} {:?}: combined {got} m != quadrature sum {expect} m (UT1 {u}, pole {p})",
row.horizon
);
assert_eq!(row.combined.unit, "m");
assert!((row.combined.rms_native - got).abs() < 1e-12);
assert!(u > 0.0 && p > 0.0, "{name} {:?}: u {u}, p {p}", row.horizon);
assert!(
got >= u && got >= p,
"{name} {:?}: combination below a component",
row.horizon
);
assert!((u - ut1_error_to_lunar(row.ut1.rms_native).0).abs() < 1e-9);
assert!(
(p - polar_motion_position_error(
row.polar_motion.rms_native * crate::eop::ARCSEC_TO_RAD,
0.0
))
.abs()
< 1e-9
);
}
}
}
#[test]
fn joint_table_intersects_when_the_two_bulletin_b_blocks_disagree() {
let mut body = String::new();
let mut blanked = 0usize;
for (i, line) in LONGSPAN.lines().enumerate() {
if line.trim_start().starts_with('#') || line.len() < 165 {
body.push_str(line);
} else if i % 3 == 0 {
body.push_str(&blank_the_pole_final(line));
blanked += 1;
} else {
body.push_str(line);
}
body.push('\n');
}
assert!(
blanked >= 5,
"must blank several pole finals, blanked {blanked}"
);
let ut1 = prediction_error_vs_horizon(&body, &[Horizon::Final]);
let pm = pm_prediction_error_vs_horizon(&body, &[Horizon::Final]);
let joint = joint_eop_error_vs_horizon(&body, &[Horizon::Final]);
assert_eq!(ut1.len(), 1);
assert_eq!(pm.len(), 1);
assert_eq!(joint.len(), 1);
assert!(
ut1[0].n > pm[0].n,
"premise broken - UT1 n {} must exceed pole n {}",
ut1[0].n,
pm[0].n
);
let row = &joint[0];
assert_eq!(row.n, pm[0].n, "joint n must be the intersection size");
assert!(
row.n < ut1[0].n,
"joint n must drop below the UT1-only count"
);
assert_eq!(row.ut1.n, row.polar_motion.n);
assert_eq!(row.ut1.epochs_mjd, row.polar_motion.epochs_mjd);
assert!(
(row.ut1.rms_native - ut1[0].rms_s).abs() > 0.0,
"UT1 RMS over the shared rows must differ from the full-series RMS"
);
let (u, p) = (row.ut1.rms_position_m, row.polar_motion.rms_position_m);
assert!((row.combined.rms_position_m - (u * u + p * p).sqrt()).abs() < 1e-9);
}
#[test]
fn joint_table_omits_a_horizon_with_no_shared_rows() {
let joint = joint_eop_error_vs_horizon(FIXTURE, &[Horizon::Final, Horizon::Days(90)]);
assert_eq!(joint.len(), 1, "only the final floor can populate");
assert_eq!(joint[0].horizon, Horizon::Final);
assert!(joint_eop_error_vs_horizon("", &[Horizon::Final]).is_empty());
}
#[test]
fn the_fit_recovers_an_analytic_bias_rate_and_periodic_signal() {
let issue = 60000.0;
let truth = |mjd: f64| -> f64 {
let t = mjd - issue;
let a = std::f64::consts::TAU * t / ANNUAL_PERIOD_DAYS;
let s = std::f64::consts::TAU * t / SEMIANNUAL_PERIOD_DAYS;
-0.25 + 3.5e-4 * t + 0.031 * a.cos() - 0.017 * a.sin()
+ 0.009 * s.cos()
+ 0.004 * s.sin()
};
let samples: Vec<(f64, f64)> = (0..=400)
.map(|i| {
let mjd = issue - 400.0 + i as f64;
(mjd, truth(mjd))
})
.collect();
let cfg = OperationalPredictorConfig {
window_days: 365.0,
..Default::default()
};
let fit = fit_operational(&samples, issue, UT1_PERIODIC_TERMS, &cfg)
.expect("a 365-day window over 400 days of daily samples must fit");
assert_eq!(
fit.term_names,
vec![
"bias",
"rate",
"annual",
"semi-annual",
"monthly-zonal-tide",
"fortnightly-zonal-tide"
],
"a 365-day window must admit every candidate term"
);
assert!(fit.rejected_terms.is_empty());
assert!(
(fit.coefficients[0] - (-0.25)).abs() < 1e-9,
"{:?}",
fit.coefficients
);
assert!(
(fit.coefficients[1] - 3.5e-4 * 365.0).abs() < 1e-9,
"{:?}",
fit.coefficients
);
assert!((fit.coefficients[2] - 0.031).abs() < 1e-9);
assert!((fit.coefficients[3] - (-0.017)).abs() < 1e-9);
assert!(fit.rms_fit_residual < 1e-12, "{}", fit.rms_fit_residual);
assert!(fit.anchor_residual.abs() < 1e-12);
for h in [1.0, 10.0, 100.0] {
let got = fit.predict(issue + h);
assert!(
(got - truth(issue + h)).abs() < 1e-9,
"h={h}: {got} != {}",
truth(issue + h)
);
}
}
#[test]
fn a_bias_rate_fit_equals_the_closed_form_least_squares_solution() {
let daily = parse_daily_ut1(LONGSPAN);
let samples: Vec<(f64, f64)> = daily.iter().map(|d| (d.mjd, d.ut1_rapid_s)).collect();
let issue = 59600.0;
let cfg = OperationalPredictorConfig {
window_days: 6.0, anchor_residual: false,
..Default::default()
};
let fit = fit_operational(&samples, issue, UT1_PERIODIC_TERMS, &cfg).expect("fit");
assert_eq!(fit.term_names, vec!["bias", "rate"]);
assert_eq!(fit.rejected_terms.len(), UT1_PERIODIC_TERMS.len());
let rows: Vec<(f64, f64)> = samples
.iter()
.filter(|(m, _)| *m <= issue && *m >= issue - cfg.window_days)
.map(|(m, v)| ((m - issue) / cfg.window_days, *v))
.collect();
let n = rows.len() as f64;
let sx: f64 = rows.iter().map(|(x, _)| *x).sum();
let sy: f64 = rows.iter().map(|(_, y)| *y).sum();
let sxx: f64 = rows.iter().map(|(x, _)| x * x).sum();
let sxy: f64 = rows.iter().map(|(x, y)| x * y).sum();
let slope = (n * sxy - sx * sy) / (n * sxx - sx * sx);
let intercept = (sy - slope * sx) / n;
assert!(
(fit.coefficients[0] - intercept).abs() < 1e-14,
"intercept {} != {intercept}",
fit.coefficients[0]
);
assert!(
(fit.coefficients[1] - slope).abs() < 1e-12,
"slope {} != {slope}",
fit.coefficients[1]
);
}
#[test]
fn a_fit_cannot_see_a_single_observation_past_its_issue_epoch() {
let daily = parse_daily_ut1(LONGSPAN);
let issue = 59605.0;
let clean: Vec<(f64, f64)> = daily.iter().map(|d| (d.mjd, d.ut1_rapid_s)).collect();
let poisoned: Vec<(f64, f64)> = clean
.iter()
.map(|(m, v)| {
if *m > issue {
(*m, *v + 1_000.0)
} else {
(*m, *v)
}
})
.collect();
assert!(
poisoned.iter().any(|(m, _)| *m > issue),
"the fixture must carry rows past the issue epoch, or this proves nothing"
);
for window in [6.0, 15.0, 25.0] {
let cfg = OperationalPredictorConfig {
window_days: window,
..Default::default()
};
let a = fit_operational(&clean, issue, UT1_PERIODIC_TERMS, &cfg).expect("clean fit");
let b =
fit_operational(&poisoned, issue, UT1_PERIODIC_TERMS, &cfg).expect("poisoned fit");
assert_eq!(a, b, "window {window}: a future row reached the fit");
for h in [1.0, 2.0, 10.0] {
assert_eq!(a.predict(issue + h), b.predict(issue + h));
}
assert!(
a.window_last_mjd <= issue,
"{} > {issue}",
a.window_last_mjd
);
}
}
#[test]
fn an_incomplete_window_is_refused_rather_than_shortened() {
let daily = parse_daily_ut1(LONGSPAN);
let samples: Vec<(f64, f64)> = daily.iter().map(|d| (d.mjd, d.ut1_rapid_s)).collect();
let first = samples
.iter()
.map(|(m, _)| *m)
.fold(f64::INFINITY, f64::min);
let cfg = OperationalPredictorConfig {
window_days: 15.0,
..Default::default()
};
assert!(fit_operational(&samples, first + 14.0, UT1_PERIODIC_TERMS, &cfg).is_none());
let fit = fit_operational(&samples, first + 15.0, UT1_PERIODIC_TERMS, &cfg)
.expect("a complete window must fit");
assert!((fit.window_first_mjd - first).abs() < 1e-9);
assert_eq!(fit.n_fit, 16);
}
#[test]
fn periodic_terms_are_admitted_only_when_the_window_can_constrain_them() {
let issue = 60000.0;
let build = |span: f64| -> Vec<(f64, f64)> {
(0..=(span as i64 + 10))
.map(|i| {
let mjd = issue - span - 10.0 + i as f64;
(mjd, 0.1 + 1e-4 * (mjd - issue))
})
.collect()
};
for (window, expect_ut1, expect_pm) in [
(6.0f64, vec!["bias", "rate"], vec!["bias", "rate"]),
(
15.0,
vec![
"bias",
"rate",
"monthly-zonal-tide",
"fortnightly-zonal-tide",
],
vec!["bias", "rate"],
),
(
150.0,
vec![
"bias",
"rate",
"semi-annual",
"monthly-zonal-tide",
"fortnightly-zonal-tide",
],
vec!["bias", "rate", "semi-annual"],
),
(
365.0,
vec![
"bias",
"rate",
"annual",
"semi-annual",
"monthly-zonal-tide",
"fortnightly-zonal-tide",
],
vec!["bias", "rate", "chandler", "annual", "semi-annual"],
),
] {
let cfg = OperationalPredictorConfig {
window_days: window,
..Default::default()
};
let s = build(window);
let u = fit_operational(&s, issue, UT1_PERIODIC_TERMS, &cfg).expect("ut1 fit");
let p = fit_operational(&s, issue, PM_PERIODIC_TERMS, &cfg).expect("pm fit");
assert_eq!(u.term_names, expect_ut1, "UT1 terms at window {window}");
assert_eq!(p.term_names, expect_pm, "PM terms at window {window}");
assert!(
!u.term_names.contains(&"chandler"),
"the Chandler wobble must never enter the UT1 model"
);
for r in u.rejected_terms.iter().chain(p.rejected_terms.iter()) {
assert!(r.cycles_spanned < r.threshold_cycles);
assert!(r.cycles_spanned >= 0.0 && r.period_days > 0.0);
}
}
}
#[test]
fn both_predictors_are_scored_over_one_independently_reproducible_epoch_set() {
let cfg = OperationalPredictorConfig::default();
let hs = [Horizon::Days(1), Horizon::Days(3), Horizon::Days(10)];
let rows = operational_vs_persistence_vs_horizon(LONGSPAN, &hs, &cfg);
assert_eq!(rows.len(), hs.len());
let daily = parse_daily_ut1(LONGSPAN);
let pm = parse_daily_pm(LONGSPAN);
let first = daily.iter().map(|d| d.mjd).fold(f64::INFINITY, f64::min);
for row in &rows {
let Horizon::Days(h) = row.horizon else {
panic!("the Final horizon must not appear")
};
let expect: Vec<f64> = daily
.iter()
.filter(|d| d.mjd >= first + cfg.window_days - 1e-9)
.filter(|d| {
let t = d.mjd + h as f64;
daily
.iter()
.any(|x| (x.mjd - t).abs() < 1e-6 && x.ut1_final_s.is_some())
&& pm
.iter()
.any(|x| (x.mjd - t).abs() < 1e-6 && x.pm_final_as.is_some())
})
.map(|d| d.mjd)
.collect();
assert_eq!(row.epochs_mjd, expect, "horizon {h}");
assert_eq!(row.n, expect.len());
assert!(row.n > 0);
for e in [
&row.ut1_operational,
&row.ut1_persistence,
&row.pm_operational,
&row.pm_persistence,
&row.combined_operational,
&row.combined_persistence,
] {
assert_eq!(e.n, row.n, "{} {} sample count", e.predictor, e.quantity);
}
for (t, e) in row.target_mjds.iter().zip(&row.epochs_mjd) {
assert!((t - e - h as f64).abs() < 1e-9);
}
assert!(
(row.min_fit_lead_days - h as f64).abs() < 1e-9,
"lead {} at horizon {h}",
row.min_fit_lead_days
);
assert!(row.min_fit_lead_days > 0.0);
for (c, u, p) in [
(
&row.combined_operational,
&row.ut1_operational,
&row.pm_operational,
),
(
&row.combined_persistence,
&row.ut1_persistence,
&row.pm_persistence,
),
] {
let expect = (u.rms_position_m.powi(2) + p.rms_position_m.powi(2)).sqrt();
assert!(
(c.rms_position_m - expect).abs() <= 1e-9 * expect.max(1.0),
"combined {} != {expect}",
c.rms_position_m
);
}
}
for w in rows.windows(2) {
assert!(
w[1].n <= w[0].n,
"row counts must be non-increasing in the horizon: {} then {}",
w[0].n,
w[1].n
);
}
}
#[test]
fn a_target_without_a_published_final_is_dropped_not_rescored_against_the_rapid_value() {
let cfg = OperationalPredictorConfig::default();
let hs = [Horizon::Days(1)];
let before = operational_vs_persistence_vs_horizon(LONGSPAN, &hs, &cfg);
let target = *before[0]
.target_mjds
.last()
.expect("at least one scored target");
let blanked: String = LONGSPAN
.lines()
.map(|line| match parse_line(line) {
Some(r) if (r.mjd - target).abs() < 1e-6 => line
.chars()
.take(134)
.collect::<String>()
.trim_end()
.to_string(),
_ => line.to_string(),
})
.collect::<Vec<_>>()
.join("\n");
let after = operational_vs_persistence_vs_horizon(&blanked, &hs, &cfg);
assert_eq!(after[0].n, before[0].n - 1, "the epoch must be dropped");
assert!(!after[0]
.target_mjds
.iter()
.any(|t| (t - target).abs() < 1e-6));
}
#[test]
fn the_equivalent_horizon_interpolates_and_never_extrapolates() {
let curve = [(1.0, 10.0), (2.0, 20.0), (3.0, 30.0)];
let got = equivalent_horizon_days(&curve, 15.0).expect("15 m is bracketed");
assert!((got - 1.5).abs() < 1e-12, "{got}");
assert!((equivalent_horizon_days(&curve, 10.0).unwrap() - 1.0).abs() < 1e-12);
assert!((equivalent_horizon_days(&curve, 30.0).unwrap() - 3.0).abs() < 1e-12);
assert!(equivalent_horizon_days(&curve, 5.0).is_none());
assert!(equivalent_horizon_days(&curve, 45.0).is_none());
assert!(equivalent_horizon_days(&[], 15.0).is_none());
let shuffled = [(3.0, 30.0), (1.0, 10.0), (2.0, 20.0)];
assert_eq!(
equivalent_horizon_days(&shuffled, 15.0),
equivalent_horizon_days(&curve, 15.0)
);
}
#[test]
fn the_bulletin_a_agreement_reads_the_real_published_prediction_rows() {
let cfg = OperationalPredictorConfig::default();
let a =
bulletin_a_agreement(FIXTURE_2026, &cfg).expect("the 2026 extract publishes 12 rows");
assert_eq!(a.n, 12);
assert_eq!(a.leads.len(), 12);
assert_eq!(a.issue_mjd, 61192.0);
assert!((a.first_lead_days - 1.0).abs() < 1e-9);
assert!((a.last_lead_days - 12.0).abs() < 1e-9);
for w in a.leads.windows(2) {
assert!(w[1].lead_days > w[0].lead_days);
}
for l in &a.leads {
assert!(l.ut1_diff_s > 0.0 && l.pm_diff_arcsec > 0.0);
assert!(
(l.ut1_position_m - ut1_error_to_lunar(l.ut1_diff_s).0).abs() < 1e-9,
"the position column must be the lever-arm image of the difference"
);
}
let rms = |v: Vec<f64>| (v.iter().map(|x| x * x).sum::<f64>() / v.len() as f64).sqrt();
assert!((a.ut1_rms_s - rms(a.leads.iter().map(|l| l.ut1_diff_s).collect())).abs() < 1e-15);
assert!(a.leads[0].ut1_diff_s < a.leads[8].ut1_diff_s);
assert!(bulletin_a_agreement(FIXTURE, &cfg).is_none());
}
#[test]
fn residual_anchoring_is_an_effective_and_reversible_choice() {
let daily = parse_daily_ut1(LONGSPAN);
let samples: Vec<(f64, f64)> = daily.iter().map(|d| (d.mjd, d.ut1_rapid_s)).collect();
let issue = 59610.0;
let on = fit_operational(
&samples,
issue,
UT1_PERIODIC_TERMS,
&OperationalPredictorConfig::default(),
)
.expect("fit");
let off = fit_operational(
&samples,
issue,
UT1_PERIODIC_TERMS,
&OperationalPredictorConfig {
anchor_residual: false,
..Default::default()
},
)
.expect("fit");
assert_eq!(off.anchor_residual, 0.0);
assert!(on.anchor_residual.abs() > 0.0);
assert!(
(on.predict(issue + 1.0) - off.predict(issue + 1.0) - on.anchor_residual).abs() < 1e-15
);
let last = samples
.iter()
.filter(|(m, _)| (m - issue).abs() < 1e-9)
.map(|(_, v)| *v)
.next_back()
.expect("an observation at the issue epoch");
assert!(
(on.predict(issue) - last).abs() < 1e-12,
"{} != {last}",
on.predict(issue)
);
}
}