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::qtty::{Arcsecond, Day, Millisecond, Second};
30    use crate::{Time, TimeContext, TT, UT1, UTC};
31    use chrono::DateTime;
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            >(Day::new(
182                2_400_000.5 + 57_000.0,
183            )))
184            .unwrap();
185            let overridden = ctx.ut1_minus_utc(Day::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.ut1_minus_utc(Day::new(57_000.0)).unwrap();
222            let after = ctx_after.ut1_minus_utc(Day::new(57_000.0)).unwrap();
223            set_active_time_data((*previous).clone());
224
225            assert!((after - before).abs() > Second::new(0.1));
226        });
227    }
228
229    #[test]
230    fn ordinary_utc_api_uses_override_bundle() {
231        let bundle = compiled_bundle_owned();
232        let mut segments = bundle.utc_tai_segments().to_vec();
233        let segment = segments
234            .iter()
235            .position(|segment| segment.start_mjd <= 60_000 && segment.end_mjd.is_none())
236            .unwrap();
237        segments[segment].base = Second::new(segments[segment].base.value() + 1.0);
238        let bundle = TimeDataBundle::new(
239            segments,
240            bundle.modern_delta_t_points().to_vec(),
241            bundle.modern_delta_t_observed_end_mjd(),
242            bundle.eop_points().to_vec(),
243            bundle.provenance().clone(),
244        );
245        let unix = Second::new(1_680_000_000.25);
246        let compiled_value = {
247            let compiled = compiled_time_data();
248            let jd_utc = crate::encoding::unix_seconds_to_jd(unix);
249            let mjd_utc = crate::encoding::jd_to_mjd(jd_utc);
250            let tai_minus_utc =
251                time_data_try_tai_minus_utc_mjd(compiled.as_ref(), mjd_utc, false).unwrap();
252            (crate::encoding::day_to_j2000_seconds::<JD>(jd_utc) + tai_minus_utc).value()
253        };
254
255        with_test_time_data(bundle, || {
256            let overridden = Time::<UTC, Unix>::try_new_with(unix, &TimeContext::new()).unwrap();
257            let overridden_value =
258                overridden.raw_seconds_pair().0.value() + overridden.raw_seconds_pair().1.value();
259            assert!((overridden_value - compiled_value).abs() > 0.1);
260            let roundtrip = overridden
261                .raw_unix_seconds_with(&TimeContext::new())
262                .unwrap();
263            assert!((roundtrip - unix).abs() < Second::new(1e-3));
264            let chrono = overridden.try_to_chrono().unwrap();
265            let from_chrono = Time::<UTC>::try_from_chrono(chrono).unwrap();
266            let drift = ((from_chrono.raw_seconds_pair().0.value()
267                + from_chrono.raw_seconds_pair().1.value())
268                - overridden_value)
269                .abs();
270            assert!(drift < 1e-4, "chrono round-trip drift = {drift}");
271        });
272    }
273
274    #[test]
275    fn time_context_snapshots_utc_civil_data_across_active_bundle_updates() {
276        with_runtime_data_lock(|| {
277            let baseline = compiled_bundle_owned();
278            let previous = active_time_data();
279            set_active_time_data(baseline.clone());
280            let ctx_before = TimeContext::new();
281
282            let mut segments = baseline.utc_tai_segments().to_vec();
283            let segment = segments
284                .iter()
285                .position(|segment| segment.start_mjd <= 60_000 && segment.end_mjd.is_none())
286                .unwrap();
287            segments[segment].base = Second::new(segments[segment].base.value() + 1.0);
288            let overridden = TimeDataBundle::new(
289                segments,
290                baseline.modern_delta_t_points().to_vec(),
291                baseline.modern_delta_t_observed_end_mjd(),
292                baseline.eop_points().to_vec(),
293                baseline.provenance().clone(),
294            );
295            set_active_time_data(overridden);
296            let ctx_after = TimeContext::new();
297
298            let unix = Second::new(1_680_000_000.25);
299            let before = Time::<UTC, Unix>::try_new_with(unix, &ctx_before).unwrap();
300            let after = Time::<UTC, Unix>::try_new_with(unix, &ctx_after).unwrap();
301            let before_value =
302                before.raw_seconds_pair().0.value() + before.raw_seconds_pair().1.value();
303            let after_value =
304                after.raw_seconds_pair().0.value() + after.raw_seconds_pair().1.value();
305            set_active_time_data((*previous).clone());
306
307            assert!((after_value - before_value).abs() > 0.1);
308        });
309    }
310
311    #[test]
312    fn pre_1961_utc_errors_by_default_and_roundtrips_with_opt_in() {
313        let dt = DateTime::from_timestamp(-631_152_000, 250_000_000).unwrap();
314
315        assert!(matches!(
316            Time::<UTC>::try_from_chrono(dt),
317            Err(crate::ConversionError::UtcBeforeDefinition)
318        ));
319
320        let ctx = TimeContext::new().allow_pre_definition_utc();
321        let utc = Time::<UTC>::try_from_chrono_with(dt, &ctx).unwrap();
322        let back = utc.try_to_chrono_with(&ctx).unwrap();
323        let drift = (back.timestamp_nanos_opt().unwrap() - dt.timestamp_nanos_opt().unwrap()).abs();
324        assert!(drift < 50_000, "pre-1961 UTC round-trip drift = {drift} ns");
325
326        let unix = Second::new(-631_152_000.75);
327        assert!(matches!(
328            Time::<UTC, Unix>::try_new(unix),
329            Err(crate::ConversionError::UtcBeforeDefinition)
330        ));
331
332        let utc_from_unix = Time::<UTC, Unix>::try_new_with(unix, &ctx).unwrap();
333        let unix_back = utc_from_unix.raw_unix_seconds_with(&ctx).unwrap();
334        assert!((unix_back - unix).abs() < Second::new(1e-3));
335    }
336
337    #[test]
338    fn runtime_bundle_can_extend_delta_t_horizon_through_existing_api() {
339        let bundle = compiled_bundle_owned();
340        let mut points = bundle.modern_delta_t_points().to_vec();
341        let last = *points.last().unwrap();
342        points.push((last.0 + 31.0, last.1 + 0.25));
343        let bundle = TimeDataBundle::new(
344            bundle.utc_tai_segments().to_vec(),
345            points,
346            bundle.modern_delta_t_observed_end_mjd(),
347            bundle.eop_points().to_vec(),
348            bundle.provenance().clone(),
349        );
350        let beyond = crate::DELTA_T_PREDICTION_HORIZON_MJD + Day::new(15.0);
351        let jd = beyond + crate::foundation::constats::JD_MINUS_MJD;
352        let tt = JulianDate::<TT>::new(jd.value()).to_j2000s();
353
354        assert_eq!(
355            tt.to_scale_with::<UT1>(&TimeContext::new()).unwrap_err(),
356            crate::ConversionError::Ut1HorizonExceeded
357        );
358
359        with_test_time_data(bundle, || {
360            let ut1 = tt.to_scale_with::<UT1>(&TimeContext::new()).unwrap();
361            assert!(ut1.to::<JD>().raw().is_finite());
362        });
363    }
364
365    #[test]
366    fn eop_lookup_returns_none_when_bundle_has_gap() {
367        let bundle = eop_fixture_bundle();
368        let mut eop_points = bundle.eop_points().to_vec();
369        // Remove MJD 57_001 to create a gap between 57_000 and 57_002.
370        let gap_after = 57_000_i32;
371        eop_points.retain(|p| p.mjd != 57_001);
372        let bundle = TimeDataBundle::new(
373            bundle.utc_tai_segments().to_vec(),
374            bundle.modern_delta_t_points().to_vec(),
375            bundle.modern_delta_t_observed_end_mjd(),
376            eop_points,
377            bundle.provenance().clone(),
378        );
379
380        assert!(time_data_eop_at(&bundle, Day::new(gap_after as f64 + 0.5)).is_none());
381        assert!(time_data_eop_at(&bundle, Day::new((gap_after + 1) as f64)).is_none());
382    }
383
384    #[test]
385    fn compiled_bundle_is_available() {
386        let bundle = compiled_time_data();
387        assert!(!bundle.utc_tai_segments().is_empty());
388        assert!(!bundle.modern_delta_t_points().is_empty());
389        // EOP is not embedded in the compiled bundle; it requires an explicit
390        // runtime fetch via TimeDataManager. Verify the bundle reports None
391        // for EOP horizons when no EOP data is loaded.
392        assert!(bundle.eop_points().is_empty());
393        assert!(bundle.eop_start_mjd().is_none());
394        assert!(bundle.eop_end_mjd().is_none());
395    }
396}