Skip to main content

tempoch_core/data/runtime_data/
mod.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (C) 2026 Vallés Puig, Ramon
3
4mod eop;
5mod store;
6mod utc_tai;
7
8pub(crate) use eop::{time_data_delta_t, time_data_eop_at};
9pub(crate) use store::{active_time_data, active_time_data_source};
10#[cfg(test)]
11pub(crate) use store::{
12    compiled_time_data, select_time_data, select_time_data_for_auto_refresh, set_active_time_data,
13    with_runtime_data_lock, with_test_time_data,
14};
15#[cfg(feature = "runtime-data-fetch")]
16pub use store::{fetch_latest_time_data, refresh_runtime_time_data, update_runtime_time_data};
17pub(crate) use utc_tai::{
18    time_data_tai_seconds_from_utc, time_data_tai_seconds_is_in_leap_window,
19    time_data_try_tai_minus_utc_mjd, time_data_utc_from_tai_seconds,
20};
21
22#[cfg(test)]
23mod tests {
24    use super::*;
25    #[cfg(any(test, feature = "runtime-data-fetch"))]
26    use crate::archive::time::TimeDataError as InternalDataError;
27    use crate::archive::time::{EopPoint, TimeDataBundle, TimeDataProvenance};
28    use crate::format::{JulianDate, Unix, JD};
29    use crate::{Time, TimeContext, TT, UT1, UTC};
30    use chrono::DateTime;
31    use qtty::{Arcsecond, Day as DayQuantity, Millisecond, Second};
32
33    fn compiled_bundle_owned() -> TimeDataBundle {
34        (*compiled_time_data()).clone()
35    }
36
37    /// Build a bundle with a small EOP fixture spanning MJD 56_999–57_002.
38    /// The compiled UTC-TAI and ΔT tables are reused; only EOP is synthetic.
39    fn eop_fixture_bundle() -> TimeDataBundle {
40        let base = compiled_bundle_owned();
41        let eop_points: Vec<EopPoint> = (56_999_i32..=57_002)
42            .map(|mjd| EopPoint {
43                mjd,
44                pm_observed: true,
45                ut1_observed: true,
46                nutation_observed: true,
47                pm_xp: Some(Arcsecond::new(0.1)),
48                pm_yp: Some(Arcsecond::new(0.1)),
49                ut1_minus_utc: Second::new(0.3),
50                lod: Some(Millisecond::new(1.0)),
51                dx: None,
52                dy: None,
53            })
54            .collect();
55        TimeDataBundle::new(
56            base.utc_tai_segments().to_vec(),
57            base.modern_delta_t_points().to_vec(),
58            base.modern_delta_t_observed_end_mjd(),
59            eop_points,
60            base.provenance().clone(),
61        )
62    }
63
64    fn bundle_with_timestamp(timestamp: &str) -> TimeDataBundle {
65        let bundle = compiled_bundle_owned();
66        TimeDataBundle::new(
67            bundle.utc_tai_segments().to_vec(),
68            bundle.modern_delta_t_points().to_vec(),
69            bundle.modern_delta_t_observed_end_mjd(),
70            bundle.eop_points().to_vec(),
71            TimeDataProvenance::new(timestamp, "a", "b", "c", "d"),
72        )
73    }
74
75    #[test]
76    fn cache_is_selected_when_not_forcing_refresh() {
77        let cached = bundle_with_timestamp("cached");
78        let selected = select_time_data(
79            Ok(cached.clone()),
80            || {
81                Err(InternalDataError::Integrity(
82                    "refresh should not be called".into(),
83                ))
84            },
85            false,
86        )
87        .unwrap();
88        assert_eq!(selected.provenance().fetched_utc(), "cached");
89    }
90
91    #[test]
92    fn missing_cache_triggers_refresh() {
93        let refreshed = bundle_with_timestamp("refreshed");
94        let selected = select_time_data(
95            Err(InternalDataError::Integrity("missing cache".into())),
96            || Ok(refreshed.clone()),
97            false,
98        )
99        .unwrap();
100        assert_eq!(selected.provenance().fetched_utc(), "refreshed");
101    }
102
103    #[test]
104    fn force_refresh_ignores_cache() {
105        let cached = bundle_with_timestamp("cached");
106        let refreshed = bundle_with_timestamp("refreshed");
107        let selected = select_time_data(Ok(cached), || Ok(refreshed.clone()), true).unwrap();
108        assert_eq!(selected.provenance().fetched_utc(), "refreshed");
109    }
110
111    #[test]
112    fn force_refresh_propagates_refresh_error() {
113        let err = select_time_data(
114            Ok(bundle_with_timestamp("cached")),
115            || Err(InternalDataError::Download("network unreachable".into())),
116            true,
117        )
118        .unwrap_err();
119        assert!(
120            err.to_string().contains("network unreachable"),
121            "unexpected error: {err}"
122        );
123    }
124
125    #[test]
126    fn stale_cache_prefers_refresh_but_falls_back_if_refresh_fails() {
127        let stale = bundle_with_timestamp("2026-04-15T00:00:00");
128        let now = DateTime::from_timestamp(1_776_134_400, 0).unwrap();
129        let selected = select_time_data_for_auto_refresh(
130            Ok(stale.clone()),
131            || Err(InternalDataError::Download("network unreachable".into())),
132            now,
133        )
134        .unwrap();
135        assert_eq!(
136            selected.provenance().fetched_utc(),
137            stale.provenance().fetched_utc()
138        );
139    }
140
141    #[test]
142    fn fresh_cache_skips_refresh_in_auto_mode() {
143        let fresh = bundle_with_timestamp("2026-04-20T00:00:00");
144        let now = DateTime::from_timestamp(1_776_139_200, 0).unwrap();
145        let selected = select_time_data_for_auto_refresh(
146            Ok(fresh.clone()),
147            || {
148                Err(InternalDataError::Integrity(
149                    "refresh should not be called".into(),
150                ))
151            },
152            now,
153        )
154        .unwrap();
155        assert_eq!(
156            selected.provenance().fetched_utc(),
157            fresh.provenance().fetched_utc()
158        );
159    }
160
161    #[test]
162    fn ordinary_ut1_api_uses_override_bundle() {
163        let base = eop_fixture_bundle();
164        let base_ut1_seconds = 0.3_f64;
165        let mut modified_eop = base.eop_points().to_vec();
166        if let Some(p) = modified_eop.iter_mut().find(|p| p.mjd == 57_000) {
167            p.ut1_minus_utc = Second::new(p.ut1_minus_utc.value() + 0.5);
168        }
169        let overridden = TimeDataBundle::new(
170            base.utc_tai_segments().to_vec(),
171            base.modern_delta_t_points().to_vec(),
172            base.modern_delta_t_observed_end_mjd(),
173            modified_eop,
174            base.provenance().clone(),
175        );
176
177        with_test_time_data(overridden, || {
178            let ctx = TimeContext::with_builtin_eop();
179            let tt = Time::<TT>::from_raw_j2000_seconds(crate::encoding::day_to_j2000_seconds::<
180                crate::format::JD,
181            >(DayQuantity::new(
182                2_400_000.5 + 57_000.0,
183            )))
184            .unwrap();
185            let overridden = ctx.ut1_minus_utc(DayQuantity::new(57_000.0)).unwrap();
186            assert!(
187                (overridden - Second::new(base_ut1_seconds + 0.5)).abs() < Second::new(1e-6),
188                "expected overridden UT1-UTC ≈ {:.3} s, got {:.6} s",
189                base_ut1_seconds + 0.5,
190                overridden.value(),
191            );
192
193            let ut1: Time<UT1> = tt.to_scale_with::<UT1>(&ctx).unwrap();
194            assert!(ut1.to::<JD>().raw().is_finite());
195        });
196    }
197
198    #[test]
199    fn time_context_snapshots_ut1_data_across_active_bundle_updates() {
200        with_runtime_data_lock(|| {
201            let baseline = eop_fixture_bundle();
202            let previous = active_time_data();
203            set_active_time_data(baseline.clone());
204            let ctx_before = TimeContext::with_builtin_eop();
205
206            let mut eop_points = baseline.eop_points().to_vec();
207            {
208                let p = eop_points.iter_mut().find(|p| p.mjd == 57_000).unwrap();
209                p.ut1_minus_utc = Second::new(p.ut1_minus_utc.value() + 0.5);
210            }
211            let overridden = TimeDataBundle::new(
212                baseline.utc_tai_segments().to_vec(),
213                baseline.modern_delta_t_points().to_vec(),
214                baseline.modern_delta_t_observed_end_mjd(),
215                eop_points,
216                baseline.provenance().clone(),
217            );
218            set_active_time_data(overridden);
219            let ctx_after = TimeContext::with_builtin_eop();
220
221            let before = ctx_before
222                .ut1_minus_utc(DayQuantity::new(57_000.0))
223                .unwrap();
224            let after = ctx_after.ut1_minus_utc(DayQuantity::new(57_000.0)).unwrap();
225            set_active_time_data((*previous).clone());
226
227            assert!((after - before).abs() > Second::new(0.1));
228        });
229    }
230
231    #[test]
232    fn ordinary_utc_api_uses_override_bundle() {
233        let bundle = compiled_bundle_owned();
234        let mut segments = bundle.utc_tai_segments().to_vec();
235        let segment = segments
236            .iter()
237            .position(|segment| segment.start_mjd <= 60_000 && segment.end_mjd.is_none())
238            .unwrap();
239        segments[segment].base = Second::new(segments[segment].base.value() + 1.0);
240        let bundle = TimeDataBundle::new(
241            segments,
242            bundle.modern_delta_t_points().to_vec(),
243            bundle.modern_delta_t_observed_end_mjd(),
244            bundle.eop_points().to_vec(),
245            bundle.provenance().clone(),
246        );
247        let unix = Second::new(1_680_000_000.25);
248        let compiled_value = {
249            let compiled = compiled_time_data();
250            let jd_utc = crate::encoding::unix_seconds_to_jd(unix);
251            let mjd_utc = crate::encoding::jd_to_mjd(jd_utc);
252            let tai_minus_utc =
253                time_data_try_tai_minus_utc_mjd(compiled.as_ref(), mjd_utc, false).unwrap();
254            (crate::encoding::day_to_j2000_seconds::<JD>(jd_utc) + tai_minus_utc).value()
255        };
256
257        with_test_time_data(bundle, || {
258            let overridden = Time::<UTC, Unix>::try_new_with(unix, &TimeContext::new()).unwrap();
259            let overridden_value =
260                overridden.raw_seconds_pair().0.value() + overridden.raw_seconds_pair().1.value();
261            assert!((overridden_value - compiled_value).abs() > 0.1);
262            let roundtrip = overridden
263                .raw_unix_seconds_with(&TimeContext::new())
264                .unwrap();
265            assert!((roundtrip - unix).abs() < Second::new(1e-3));
266            let chrono = overridden.try_to_chrono().unwrap();
267            let from_chrono = Time::<UTC>::try_from_chrono(chrono).unwrap();
268            let drift = ((from_chrono.raw_seconds_pair().0.value()
269                + from_chrono.raw_seconds_pair().1.value())
270                - overridden_value)
271                .abs();
272            assert!(drift < 1e-4, "chrono round-trip drift = {drift}");
273        });
274    }
275
276    #[test]
277    fn time_context_snapshots_utc_civil_data_across_active_bundle_updates() {
278        with_runtime_data_lock(|| {
279            let baseline = compiled_bundle_owned();
280            let previous = active_time_data();
281            set_active_time_data(baseline.clone());
282            let ctx_before = TimeContext::new();
283
284            let mut segments = baseline.utc_tai_segments().to_vec();
285            let segment = segments
286                .iter()
287                .position(|segment| segment.start_mjd <= 60_000 && segment.end_mjd.is_none())
288                .unwrap();
289            segments[segment].base = Second::new(segments[segment].base.value() + 1.0);
290            let overridden = TimeDataBundle::new(
291                segments,
292                baseline.modern_delta_t_points().to_vec(),
293                baseline.modern_delta_t_observed_end_mjd(),
294                baseline.eop_points().to_vec(),
295                baseline.provenance().clone(),
296            );
297            set_active_time_data(overridden);
298            let ctx_after = TimeContext::new();
299
300            let unix = Second::new(1_680_000_000.25);
301            let before = Time::<UTC, Unix>::try_new_with(unix, &ctx_before).unwrap();
302            let after = Time::<UTC, Unix>::try_new_with(unix, &ctx_after).unwrap();
303            let before_value =
304                before.raw_seconds_pair().0.value() + before.raw_seconds_pair().1.value();
305            let after_value =
306                after.raw_seconds_pair().0.value() + after.raw_seconds_pair().1.value();
307            set_active_time_data((*previous).clone());
308
309            assert!((after_value - before_value).abs() > 0.1);
310        });
311    }
312
313    #[test]
314    fn pre_1961_utc_errors_by_default_and_roundtrips_with_opt_in() {
315        let dt = DateTime::from_timestamp(-631_152_000, 250_000_000).unwrap();
316
317        assert!(matches!(
318            Time::<UTC>::try_from_chrono(dt),
319            Err(crate::ConversionError::UtcBeforeDefinition)
320        ));
321
322        let ctx = TimeContext::new().allow_pre_definition_utc();
323        let utc = Time::<UTC>::try_from_chrono_with(dt, &ctx).unwrap();
324        let back = utc.try_to_chrono_with(&ctx).unwrap();
325        let drift = (back.timestamp_nanos_opt().unwrap() - dt.timestamp_nanos_opt().unwrap()).abs();
326        assert!(drift < 50_000, "pre-1961 UTC round-trip drift = {drift} ns");
327
328        let unix = Second::new(-631_152_000.75);
329        assert!(matches!(
330            Time::<UTC, Unix>::try_new(unix),
331            Err(crate::ConversionError::UtcBeforeDefinition)
332        ));
333
334        let utc_from_unix = Time::<UTC, Unix>::try_new_with(unix, &ctx).unwrap();
335        let unix_back = utc_from_unix.raw_unix_seconds_with(&ctx).unwrap();
336        assert!((unix_back - unix).abs() < Second::new(1e-3));
337    }
338
339    #[test]
340    fn runtime_bundle_can_extend_delta_t_horizon_through_existing_api() {
341        let bundle = compiled_bundle_owned();
342        let mut points = bundle.modern_delta_t_points().to_vec();
343        let last = *points.last().unwrap();
344        points.push((last.0 + 31.0, last.1 + 0.25));
345        let bundle = TimeDataBundle::new(
346            bundle.utc_tai_segments().to_vec(),
347            points,
348            bundle.modern_delta_t_observed_end_mjd(),
349            bundle.eop_points().to_vec(),
350            bundle.provenance().clone(),
351        );
352        let beyond = crate::DELTA_T_PREDICTION_HORIZON_MJD + DayQuantity::new(15.0);
353        let jd = beyond + crate::foundation::constats::JD_MINUS_MJD;
354        let tt = JulianDate::<TT>::new(jd.value()).to_j2000s();
355
356        assert_eq!(
357            tt.to_scale_with::<UT1>(&TimeContext::new()).unwrap_err(),
358            crate::ConversionError::Ut1HorizonExceeded
359        );
360
361        with_test_time_data(bundle, || {
362            let ut1 = tt.to_scale_with::<UT1>(&TimeContext::new()).unwrap();
363            assert!(ut1.to::<JD>().raw().is_finite());
364        });
365    }
366
367    #[test]
368    fn eop_lookup_returns_none_when_bundle_has_gap() {
369        let bundle = eop_fixture_bundle();
370        let mut eop_points = bundle.eop_points().to_vec();
371        // Remove MJD 57_001 to create a gap between 57_000 and 57_002.
372        let gap_after = 57_000_i32;
373        eop_points.retain(|p| p.mjd != 57_001);
374        let bundle = TimeDataBundle::new(
375            bundle.utc_tai_segments().to_vec(),
376            bundle.modern_delta_t_points().to_vec(),
377            bundle.modern_delta_t_observed_end_mjd(),
378            eop_points,
379            bundle.provenance().clone(),
380        );
381
382        assert!(time_data_eop_at(&bundle, DayQuantity::new(gap_after as f64 + 0.5)).is_none());
383        assert!(time_data_eop_at(&bundle, DayQuantity::new((gap_after + 1) as f64)).is_none());
384    }
385
386    #[test]
387    fn compiled_bundle_is_available() {
388        let bundle = compiled_time_data();
389        assert!(!bundle.utc_tai_segments().is_empty());
390        assert!(!bundle.modern_delta_t_points().is_empty());
391        // EOP is not embedded in the compiled bundle; it requires an explicit
392        // runtime fetch via TimeDataManager. Verify the bundle reports None
393        // for EOP horizons when no EOP data is loaded.
394        assert!(bundle.eop_points().is_empty());
395        assert!(bundle.eop_start_mjd().is_none());
396        assert!(bundle.eop_end_mjd().is_none());
397    }
398}