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;
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    use crate::format::{JulianDate, Unix, JD};
26    use crate::{Time, TimeContext, TT, UT1, UTC};
27    use chrono::DateTime;
28    use qtty::Day as DayQuantity;
29    use qtty::Second;
30    use tempoch_time_data::TimeDataBundle;
31    #[cfg(any(test, feature = "runtime-data-fetch"))]
32    use tempoch_time_data::TimeDataError as InternalDataError;
33    use tempoch_time_data::TimeDataProvenance;
34
35    fn compiled_bundle_owned() -> TimeDataBundle {
36        (*compiled_time_data()).clone()
37    }
38
39    fn bundle_with_timestamp(timestamp: &str) -> TimeDataBundle {
40        let bundle = compiled_bundle_owned();
41        TimeDataBundle::new(
42            bundle.utc_tai_segments().to_vec(),
43            bundle.modern_delta_t_points().to_vec(),
44            bundle.modern_delta_t_observed_end_mjd(),
45            bundle.eop_points().to_vec(),
46            TimeDataProvenance::new(timestamp, "a", "b", "c", "d"),
47        )
48    }
49
50    #[test]
51    fn cache_is_selected_when_not_forcing_refresh() {
52        let cached = bundle_with_timestamp("cached");
53        let selected = select_time_data(
54            Ok(cached.clone()),
55            || {
56                Err(InternalDataError::Integrity(
57                    "refresh should not be called".into(),
58                ))
59            },
60            false,
61        )
62        .unwrap();
63        assert_eq!(selected.provenance().fetched_utc(), "cached");
64    }
65
66    #[test]
67    fn missing_cache_triggers_refresh() {
68        let refreshed = bundle_with_timestamp("refreshed");
69        let selected = select_time_data(
70            Err(InternalDataError::Integrity("missing cache".into())),
71            || Ok(refreshed.clone()),
72            false,
73        )
74        .unwrap();
75        assert_eq!(selected.provenance().fetched_utc(), "refreshed");
76    }
77
78    #[test]
79    fn force_refresh_ignores_cache() {
80        let cached = bundle_with_timestamp("cached");
81        let refreshed = bundle_with_timestamp("refreshed");
82        let selected = select_time_data(Ok(cached), || Ok(refreshed.clone()), true).unwrap();
83        assert_eq!(selected.provenance().fetched_utc(), "refreshed");
84    }
85
86    #[test]
87    fn force_refresh_propagates_refresh_error() {
88        let err = select_time_data(
89            Ok(bundle_with_timestamp("cached")),
90            || Err(InternalDataError::Download("network unreachable".into())),
91            true,
92        )
93        .unwrap_err();
94        assert!(
95            err.to_string().contains("network unreachable"),
96            "unexpected error: {err}"
97        );
98    }
99
100    #[test]
101    fn stale_cache_prefers_refresh_but_falls_back_if_refresh_fails() {
102        let stale = bundle_with_timestamp("2026-04-15T00:00:00");
103        let now = DateTime::from_timestamp(1_776_134_400, 0).unwrap();
104        let selected = select_time_data_for_auto_refresh(
105            Ok(stale.clone()),
106            || Err(InternalDataError::Download("network unreachable".into())),
107            now,
108        )
109        .unwrap();
110        assert_eq!(
111            selected.provenance().fetched_utc(),
112            stale.provenance().fetched_utc()
113        );
114    }
115
116    #[test]
117    fn fresh_cache_skips_refresh_in_auto_mode() {
118        let fresh = bundle_with_timestamp("2026-04-20T00:00:00");
119        let now = DateTime::from_timestamp(1_776_139_200, 0).unwrap();
120        let selected = select_time_data_for_auto_refresh(
121            Ok(fresh.clone()),
122            || {
123                Err(InternalDataError::Integrity(
124                    "refresh should not be called".into(),
125                ))
126            },
127            now,
128        )
129        .unwrap();
130        assert_eq!(
131            selected.provenance().fetched_utc(),
132            fresh.provenance().fetched_utc()
133        );
134    }
135
136    #[test]
137    fn ordinary_ut1_api_uses_override_bundle() {
138        let bundle = compiled_bundle_owned();
139        let mut eop_points = bundle.eop_points().to_vec();
140        let point = eop_points.iter().position(|p| p.mjd == 57_000).unwrap();
141        eop_points[point].ut1_minus_utc_seconds += 0.5;
142        let bundle = TimeDataBundle::new(
143            bundle.utc_tai_segments().to_vec(),
144            bundle.modern_delta_t_points().to_vec(),
145            bundle.modern_delta_t_observed_end_mjd(),
146            eop_points,
147            bundle.provenance().clone(),
148        );
149
150        with_test_time_data(bundle, || {
151            let ctx = TimeContext::with_builtin_eop();
152            let tt = Time::<TT>::from_raw_j2000_seconds(crate::encoding::day_to_j2000_seconds::<
153                crate::format::JD,
154            >(DayQuantity::new(
155                2_400_000.5 + 57_000.0,
156            )))
157            .unwrap();
158            let compiled = {
159                let data = compiled_time_data();
160                time_data_eop_at(data.as_ref(), DayQuantity::new(57_000.0))
161                    .unwrap()
162                    .ut1_minus_utc
163            };
164            let overridden = ctx.ut1_minus_utc(DayQuantity::new(57_000.0)).unwrap();
165            assert!((overridden - compiled).abs() > Second::new(0.1));
166
167            let ut1: Time<UT1> = tt.to_scale_with::<UT1>(&ctx).unwrap();
168            assert!(ut1.to::<JD>().raw().is_finite());
169        });
170    }
171
172    #[test]
173    fn time_context_snapshots_ut1_data_across_active_bundle_updates() {
174        with_runtime_data_lock(|| {
175            let baseline = compiled_bundle_owned();
176            let previous = active_time_data();
177            set_active_time_data(baseline.clone());
178            let ctx_before = TimeContext::with_builtin_eop();
179
180            let mut eop_points = baseline.eop_points().to_vec();
181            let point = eop_points.iter().position(|p| p.mjd == 57_000).unwrap();
182            eop_points[point].ut1_minus_utc_seconds += 0.5;
183            let overridden = TimeDataBundle::new(
184                baseline.utc_tai_segments().to_vec(),
185                baseline.modern_delta_t_points().to_vec(),
186                baseline.modern_delta_t_observed_end_mjd(),
187                eop_points,
188                baseline.provenance().clone(),
189            );
190            set_active_time_data(overridden);
191            let ctx_after = TimeContext::with_builtin_eop();
192
193            let before = ctx_before
194                .ut1_minus_utc(DayQuantity::new(57_000.0))
195                .unwrap();
196            let after = ctx_after.ut1_minus_utc(DayQuantity::new(57_000.0)).unwrap();
197            set_active_time_data((*previous).clone());
198
199            assert!((after - before).abs() > Second::new(0.1));
200        });
201    }
202
203    #[test]
204    fn ordinary_utc_api_uses_override_bundle() {
205        let bundle = compiled_bundle_owned();
206        let mut segments = bundle.utc_tai_segments().to_vec();
207        let segment = segments
208            .iter()
209            .position(|segment| segment.start_mjd <= 60_000 && segment.end_mjd.is_none())
210            .unwrap();
211        segments[segment].base_seconds += 1.0;
212        let bundle = TimeDataBundle::new(
213            segments,
214            bundle.modern_delta_t_points().to_vec(),
215            bundle.modern_delta_t_observed_end_mjd(),
216            bundle.eop_points().to_vec(),
217            bundle.provenance().clone(),
218        );
219        let unix = Second::new(1_680_000_000.25);
220        let compiled_value = {
221            let compiled = compiled_time_data();
222            let jd_utc = crate::encoding::unix_seconds_to_jd(unix);
223            let mjd_utc = crate::encoding::jd_to_mjd(jd_utc);
224            let tai_minus_utc =
225                time_data_try_tai_minus_utc_mjd(compiled.as_ref(), mjd_utc, false).unwrap();
226            (crate::encoding::day_to_j2000_seconds::<JD>(jd_utc) + tai_minus_utc).value()
227        };
228
229        with_test_time_data(bundle, || {
230            let overridden = Time::<UTC, Unix>::try_new_with(unix, &TimeContext::new()).unwrap();
231            let overridden_value =
232                overridden.raw_seconds_pair().0.value() + overridden.raw_seconds_pair().1.value();
233            assert!((overridden_value - compiled_value).abs() > 0.1);
234            let roundtrip = overridden
235                .raw_unix_seconds_with(&TimeContext::new())
236                .unwrap();
237            assert!((roundtrip - unix).abs() < Second::new(1e-3));
238            let chrono = overridden.try_to_chrono().unwrap();
239            let from_chrono = Time::<UTC>::try_from_chrono(chrono).unwrap();
240            let drift = ((from_chrono.raw_seconds_pair().0.value()
241                + from_chrono.raw_seconds_pair().1.value())
242                - overridden_value)
243                .abs();
244            assert!(drift < 1e-4, "chrono round-trip drift = {drift}");
245        });
246    }
247
248    #[test]
249    fn time_context_snapshots_utc_civil_data_across_active_bundle_updates() {
250        with_runtime_data_lock(|| {
251            let baseline = compiled_bundle_owned();
252            let previous = active_time_data();
253            set_active_time_data(baseline.clone());
254            let ctx_before = TimeContext::new();
255
256            let mut segments = baseline.utc_tai_segments().to_vec();
257            let segment = segments
258                .iter()
259                .position(|segment| segment.start_mjd <= 60_000 && segment.end_mjd.is_none())
260                .unwrap();
261            segments[segment].base_seconds += 1.0;
262            let overridden = TimeDataBundle::new(
263                segments,
264                baseline.modern_delta_t_points().to_vec(),
265                baseline.modern_delta_t_observed_end_mjd(),
266                baseline.eop_points().to_vec(),
267                baseline.provenance().clone(),
268            );
269            set_active_time_data(overridden);
270            let ctx_after = TimeContext::new();
271
272            let unix = Second::new(1_680_000_000.25);
273            let before = Time::<UTC, Unix>::try_new_with(unix, &ctx_before).unwrap();
274            let after = Time::<UTC, Unix>::try_new_with(unix, &ctx_after).unwrap();
275            let before_value =
276                before.raw_seconds_pair().0.value() + before.raw_seconds_pair().1.value();
277            let after_value =
278                after.raw_seconds_pair().0.value() + after.raw_seconds_pair().1.value();
279            set_active_time_data((*previous).clone());
280
281            assert!((after_value - before_value).abs() > 0.1);
282        });
283    }
284
285    #[test]
286    fn pre_1961_utc_errors_by_default_and_roundtrips_with_opt_in() {
287        let dt = DateTime::from_timestamp(-631_152_000, 250_000_000).unwrap();
288
289        assert!(matches!(
290            Time::<UTC>::try_from_chrono(dt),
291            Err(crate::ConversionError::UtcBeforeDefinition)
292        ));
293
294        let ctx = TimeContext::new().allow_pre_definition_utc();
295        let utc = Time::<UTC>::try_from_chrono_with(dt, &ctx).unwrap();
296        let back = utc.try_to_chrono_with(&ctx).unwrap();
297        let drift = (back.timestamp_nanos_opt().unwrap() - dt.timestamp_nanos_opt().unwrap()).abs();
298        assert!(drift < 50_000, "pre-1961 UTC round-trip drift = {drift} ns");
299
300        let unix = Second::new(-631_152_000.75);
301        assert!(matches!(
302            Time::<UTC, Unix>::try_new(unix),
303            Err(crate::ConversionError::UtcBeforeDefinition)
304        ));
305
306        let utc_from_unix = Time::<UTC, Unix>::try_new_with(unix, &ctx).unwrap();
307        let unix_back = utc_from_unix.raw_unix_seconds_with(&ctx).unwrap();
308        assert!((unix_back - unix).abs() < Second::new(1e-3));
309    }
310
311    #[test]
312    fn runtime_bundle_can_extend_delta_t_horizon_through_existing_api() {
313        let bundle = compiled_bundle_owned();
314        let mut points = bundle.modern_delta_t_points().to_vec();
315        let last = *points.last().unwrap();
316        points.push((last.0 + 31.0, last.1 + 0.25));
317        let bundle = TimeDataBundle::new(
318            bundle.utc_tai_segments().to_vec(),
319            points,
320            bundle.modern_delta_t_observed_end_mjd(),
321            bundle.eop_points().to_vec(),
322            bundle.provenance().clone(),
323        );
324        let beyond = crate::DELTA_T_PREDICTION_HORIZON_MJD + DayQuantity::new(15.0);
325        let jd = beyond + crate::foundation::constats::JD_MINUS_MJD;
326        let tt = JulianDate::<TT>::new(jd.value()).to_j2000s();
327
328        assert_eq!(
329            tt.to_scale_with::<UT1>(&TimeContext::new()).unwrap_err(),
330            crate::ConversionError::Ut1HorizonExceeded
331        );
332
333        with_test_time_data(bundle, || {
334            let ut1 = tt.to_scale_with::<UT1>(&TimeContext::new()).unwrap();
335            assert!(ut1.to::<JD>().raw().is_finite());
336        });
337    }
338
339    #[test]
340    fn eop_lookup_returns_none_when_bundle_has_gap() {
341        let bundle = compiled_bundle_owned();
342        let mut eop_points = bundle.eop_points().to_vec();
343        let gap_idx = eop_points
344            .windows(2)
345            .position(|window| window[1].mjd == window[0].mjd + 1)
346            .expect("compiled EOP series should contain adjacent rows")
347            + 1;
348        let gap_after = eop_points[gap_idx - 1].mjd;
349        eop_points.remove(gap_idx);
350        let bundle = TimeDataBundle::new(
351            bundle.utc_tai_segments().to_vec(),
352            bundle.modern_delta_t_points().to_vec(),
353            bundle.modern_delta_t_observed_end_mjd(),
354            eop_points,
355            bundle.provenance().clone(),
356        );
357
358        assert!(time_data_eop_at(&bundle, DayQuantity::new(gap_after as f64 + 0.5)).is_none());
359        assert!(time_data_eop_at(&bundle, DayQuantity::new((gap_after + 1) as f64)).is_none());
360    }
361
362    #[test]
363    fn compiled_bundle_is_available() {
364        let bundle = compiled_time_data();
365        assert!(!bundle.utc_tai_segments().is_empty());
366        assert!(!bundle.modern_delta_t_points().is_empty());
367        assert!(!bundle.eop_points().is_empty());
368    }
369}