tempoch_core/data/runtime_data/
mod.rs1mod 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}