Skip to main content

tempoch_core/earth/
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.
5//!
6//! The series combines observed Bulletin C04 values (flag `I` in the
7//! upstream file) with short-range Bulletin A predictions (flag `P`). The
8//! boundary between the two sub-ranges is exposed by [`eop_observed_end`].
9//!
10//! EOP data is **not** compiled into the crate.  It must be loaded at runtime
11//! via [`crate::data::runtime_data::update_runtime_time_data`] or
12//! [`crate::archive::time::TimeDataManager`].  Until a bundle is loaded,
13//! [`builtin_eop_at`] always returns `None`.
14
15use crate::data::runtime_data::{active_time_data, time_data_eop_at};
16use qtty::{Day, Second};
17
18/// First MJD present in the currently active EOP series, or `None` when no
19/// EOP data has been loaded.
20pub fn eop_start() -> Option<Day> {
21    active_time_data()
22        .eop_start_mjd()
23        .map(|v| Day::new(v as f64))
24}
25
26/// Last observed (non-predicted) MJD in the currently active EOP series, or
27/// `None` when no EOP data has been loaded.
28pub fn eop_observed_end() -> Option<Day> {
29    active_time_data()
30        .eop_observed_end_mjd()
31        .map(|v| Day::new(v as f64))
32}
33
34/// Last MJD (including predictions) in the currently active EOP series, or
35/// `None` when no EOP data has been loaded.
36pub fn eop_end() -> Option<Day> {
37    active_time_data().eop_end_mjd().map(|v| Day::new(v as f64))
38}
39
40/// Interpolated IERS Earth Orientation Parameters at a UTC MJD.
41///
42/// All fields carry SI-coherent qtty typed quantities:
43///
44/// - `pm_xp`, `pm_yp` — polar motion in arcseconds.
45/// - `ut1_minus_utc` — DUT1 in seconds of time.
46/// - `lod` — length-of-day excess in milliseconds of time.
47/// - `dx`, `dy` — IAU 2000A celestial pole offsets in milliarcseconds.
48///
49/// Optional fields stay `None` when either bracketing upstream row leaves the
50/// source column blank; the API does not fabricate zero-valued PM or nutation
51/// quantities.
52#[derive(Debug, Clone, Copy, PartialEq)]
53pub struct EopValues {
54    pub mjd_utc: Day,
55    pub pm_xp: Option<qtty::f64::Arcsecond>,
56    pub pm_yp: Option<qtty::f64::Arcsecond>,
57    pub ut1_minus_utc: Second,
58    pub lod: Option<qtty::f64::Millisecond>,
59    pub dx: Option<qtty::f64::MilliArcsecond>,
60    pub dy: Option<qtty::f64::MilliArcsecond>,
61    /// `true` when both bracketing rows are flagged observed (`I`).
62    pub ut1_observed: bool,
63}
64
65/// Returns `true` when [`builtin_eop_at`] would return `Some` for `mjd_utc`.
66#[inline]
67pub fn builtin_eop_covers(mjd_utc: Day) -> bool {
68    let data = active_time_data();
69    time_data_eop_at(data.as_ref(), mjd_utc).is_some()
70}
71
72/// Linearly interpolate EOP at a UTC MJD from the active bundle.
73///
74/// Returns `None` when `mjd_utc` is outside the loaded EOP range, or when no
75/// EOP data has been loaded.  Within range the function always succeeds;
76/// optional quantities remain `None` whenever either bracketing row leaves the
77/// source field blank.
78///
79/// EOP data is not compiled into the crate.  Call
80/// [`crate::data::runtime_data::update_runtime_time_data`] to load a cached
81/// bundle before querying EOP values.
82pub fn builtin_eop_at(mjd_utc: Day) -> Option<EopValues> {
83    let data = active_time_data();
84    time_data_eop_at(data.as_ref(), mjd_utc)
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use crate::archive::time::{EopPoint, TimeDataBundle, TimeDataProvenance, UtcTaiSegment};
91    use crate::data::runtime_data::with_test_time_data;
92    use qtty::{Arcsecond, Day, MilliArcsecond, Millisecond, Second};
93
94    fn make_test_eop_bundle(points: Vec<EopPoint>) -> TimeDataBundle {
95        TimeDataBundle::new(
96            vec![UtcTaiSegment {
97                start_mjd: 41317,
98                end_mjd: None,
99                base: Second::new(37.0),
100                reference_mjd: 41317.0,
101                slope_seconds_per_day: 0.0,
102            }],
103            vec![(41714.0, 42.184), (42369.0, 45.0)],
104            41714.0,
105            points,
106            TimeDataProvenance::new("test", "x", "x", "x", "x"),
107        )
108    }
109
110    fn three_point_fixture() -> Vec<EopPoint> {
111        vec![
112            EopPoint {
113                mjd: 50000,
114                pm_observed: true,
115                ut1_observed: true,
116                nutation_observed: true,
117                pm_xp: Some(Arcsecond::new(0.1)),
118                pm_yp: Some(Arcsecond::new(0.2)),
119                ut1_minus_utc: Second::new(0.3),
120                lod: Some(Millisecond::new(1.0)),
121                dx: Some(MilliArcsecond::new(0.01)),
122                dy: Some(MilliArcsecond::new(0.02)),
123            },
124            EopPoint {
125                mjd: 50001,
126                pm_observed: true,
127                ut1_observed: true,
128                nutation_observed: true,
129                pm_xp: Some(Arcsecond::new(0.2)),
130                pm_yp: Some(Arcsecond::new(0.4)),
131                ut1_minus_utc: Second::new(0.5),
132                lod: Some(Millisecond::new(2.0)),
133                dx: None,
134                dy: None,
135            },
136            EopPoint {
137                mjd: 50002,
138                pm_observed: false,
139                ut1_observed: false,
140                nutation_observed: false,
141                pm_xp: Some(Arcsecond::new(0.3)),
142                pm_yp: Some(Arcsecond::new(0.6)),
143                ut1_minus_utc: Second::new(0.7),
144                lod: None,
145                dx: None,
146                dy: None,
147            },
148        ]
149    }
150
151    #[test]
152    fn covers_start_and_end() {
153        let bundle = make_test_eop_bundle(three_point_fixture());
154        with_test_time_data(bundle, || {
155            assert!(builtin_eop_covers(Day::new(50000.0)));
156            assert!(builtin_eop_covers(Day::new(50002.0)));
157            assert!(!builtin_eop_covers(Day::new(49999.0)));
158            assert!(!builtin_eop_covers(Day::new(50003.0)));
159        });
160    }
161
162    #[test]
163    fn exact_point_matches_source() {
164        let points = three_point_fixture();
165        let bundle = make_test_eop_bundle(points.clone());
166        with_test_time_data(bundle, || {
167            let mid = &points[1];
168            let got = builtin_eop_at(Day::new(mid.mjd as f64)).unwrap();
169            assert_eq!(got.pm_xp.map(|v| v.value()), mid.pm_xp.map(|v| v.value()));
170            assert_eq!(got.pm_yp.map(|v| v.value()), mid.pm_yp.map(|v| v.value()));
171            assert!(
172                (got.ut1_minus_utc.value() - mid.ut1_minus_utc.value()).abs() < 1e-12,
173                "ut1: {} vs {}",
174                got.ut1_minus_utc.value(),
175                mid.ut1_minus_utc.value()
176            );
177            assert_eq!(got.dx.map(|v| v.value()), mid.dx.map(|v| v.value()));
178            assert_eq!(got.dy.map(|v| v.value()), mid.dy.map(|v| v.value()));
179        });
180    }
181
182    #[test]
183    fn midpoint_is_halfway() {
184        let points = three_point_fixture();
185        let bundle = make_test_eop_bundle(points.clone());
186        with_test_time_data(bundle, || {
187            let got = builtin_eop_at(Day::new(50000.5)).unwrap();
188            let expected =
189                0.5 * (points[0].ut1_minus_utc.value() + points[1].ut1_minus_utc.value());
190            assert!((got.ut1_minus_utc.value() - expected).abs() < 1e-12);
191        });
192    }
193
194    #[test]
195    fn missing_optional_fields_remain_missing() {
196        let bundle = make_test_eop_bundle(three_point_fixture());
197        with_test_time_data(bundle, || {
198            // points[1] and points[2] both have dx=None, dy=None; interpolating between them keeps None
199            let got = builtin_eop_at(Day::new(50001.5)).unwrap();
200            assert_eq!(got.dx, None);
201            assert_eq!(got.dy, None);
202        });
203    }
204
205    #[test]
206    fn out_of_range_returns_none() {
207        let bundle = make_test_eop_bundle(three_point_fixture());
208        with_test_time_data(bundle, || {
209            assert!(builtin_eop_at(Day::new(49990.0)).is_none());
210            assert!(builtin_eop_at(Day::new(50010.0)).is_none());
211        });
212    }
213
214    #[test]
215    fn no_eop_data_returns_none() {
216        let bundle = make_test_eop_bundle(Vec::new());
217        with_test_time_data(bundle, || {
218            assert!(builtin_eop_at(Day::new(50000.0)).is_none());
219            assert!(!builtin_eop_covers(Day::new(50000.0)));
220        });
221    }
222}