Skip to main content

tempoch_core/
eop.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (C) 2026 Vallés Puig, Ramon
3
4//! Daily IERS Earth Orientation Parameters from the compiled
5//! `finals2000A.all` series.
6//!
7//! The series combines observed Bulletin C04 values (flag `I` in the
8//! upstream file) with short-range Bulletin A predictions (flag `P`). The
9//! boundary between the two sub-ranges is [`EOP_OBSERVED_END_MJD`].
10//!
11//! The baseline series is loaded at compile time from
12//! [`crate::generated::eop_data`]. Runtime refresh can replace the active
13//! bundle used by these helpers.
14
15use crate::data::active::{active_time_data, time_data_eop_at};
16use qtty::{Day, Second};
17
18#[cfg(test)]
19use crate::generated::eop_data::{EOP_END_MJD, EOP_POINTS, EOP_START_MJD};
20
21/// Interpolated IERS Earth Orientation Parameters at a UTC MJD.
22///
23/// Fields carry the units used by the upstream IERS `finals2000A.all` file:
24///
25/// - `pm_xp`, `pm_yp` are *arcseconds* of polar motion.
26/// - `ut1_minus_utc` is *seconds of time* (DUT1).
27/// - `lod` is *milliseconds of time* excess over 86 400 SI seconds. It is
28///   `None` whenever the bracketing rows do not both supply a LOD value.
29/// - `dx`, `dy` are IAU 2000A celestial pole offsets in *milliarcseconds*.
30///
31/// Optional fields stay `None` when either bracketing upstream row leaves the
32/// source column blank; the API does not fabricate zero-valued PM or nutation
33/// quantities.
34#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct EopValues {
36    pub mjd_utc: Day,
37    pub pm_xp_arcsec: Option<f64>,
38    pub pm_yp_arcsec: Option<f64>,
39    pub ut1_minus_utc: Second,
40    pub lod_milliseconds: Option<f64>,
41    pub dx_milliarcsec: Option<f64>,
42    pub dy_milliarcsec: Option<f64>,
43    /// `true` when both bracketing rows are flagged observed (`I`).
44    pub ut1_observed: bool,
45}
46
47/// Returns `true` when [`builtin_eop_at`] would return `Some` for `mjd_utc`.
48#[inline]
49pub fn builtin_eop_covers(mjd_utc: Day) -> bool {
50    let data = active_time_data();
51    time_data_eop_at(data.as_ref(), mjd_utc).is_some()
52}
53
54/// Linearly interpolate compiled EOP at a UTC MJD.
55///
56/// Returns `None` when `mjd_utc` is outside the compiled `[EOP_START_MJD,
57/// EOP_END_MJD]` range. Within range the function always succeeds; optional
58/// quantities remain `None` whenever either bracketing row leaves the source
59/// field blank.
60pub fn builtin_eop_at(mjd_utc: Day) -> Option<EopValues> {
61    let data = active_time_data();
62    time_data_eop_at(data.as_ref(), mjd_utc)
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    #[test]
70    fn covers_start_and_end() {
71        assert!(builtin_eop_covers(Day::new(EOP_START_MJD as f64)));
72        assert!(builtin_eop_covers(Day::new(EOP_END_MJD as f64)));
73        assert!(!builtin_eop_covers(Day::new(EOP_START_MJD as f64 - 1.0)));
74        assert!(!builtin_eop_covers(Day::new(EOP_END_MJD as f64 + 1.0)));
75    }
76
77    #[test]
78    fn exact_point_matches_source() {
79        let mid = EOP_POINTS[EOP_POINTS.len() / 2];
80        let got = builtin_eop_at(Day::new(mid.mjd as f64)).unwrap();
81        assert_eq!(got.pm_xp_arcsec, mid.pm_xp_arcsec);
82        assert_eq!(got.pm_yp_arcsec, mid.pm_yp_arcsec);
83        assert!(
84            (got.ut1_minus_utc.value() - mid.ut1_minus_utc_seconds).abs() < 1e-12,
85            "ut1: {} vs {}",
86            got.ut1_minus_utc.value(),
87            mid.ut1_minus_utc_seconds
88        );
89        assert_eq!(got.dx_milliarcsec, mid.dx_milliarcsec);
90        assert_eq!(got.dy_milliarcsec, mid.dy_milliarcsec);
91    }
92
93    #[test]
94    fn midpoint_is_halfway() {
95        let lo = EOP_POINTS[100];
96        let hi = EOP_POINTS[101];
97        let got = builtin_eop_at(Day::new(lo.mjd as f64 + 0.5)).unwrap();
98        let expected = 0.5 * (lo.ut1_minus_utc_seconds + hi.ut1_minus_utc_seconds);
99        assert!((got.ut1_minus_utc.value() - expected).abs() < 1e-12);
100    }
101
102    #[test]
103    fn missing_optional_fields_remain_missing() {
104        let idx = EOP_POINTS
105            .windows(2)
106            .position(|window| {
107                window[0].dx_milliarcsec.is_none() && window[1].dx_milliarcsec.is_none()
108            })
109            .expect("generated EOP tail should include rows with blank nutation fields");
110        let lo = EOP_POINTS[idx];
111        let got = builtin_eop_at(Day::new(lo.mjd as f64 + 0.5)).unwrap();
112        assert_eq!(got.dx_milliarcsec, None);
113        assert_eq!(got.dy_milliarcsec, None);
114    }
115
116    #[test]
117    fn out_of_range_returns_none() {
118        assert!(builtin_eop_at(Day::new(EOP_START_MJD as f64 - 10.0)).is_none());
119        assert!(builtin_eop_at(Day::new(EOP_END_MJD as f64 + 10.0)).is_none());
120    }
121}