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, 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 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 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 assert!(bundle.eop_points().is_empty());
393 assert!(bundle.eop_start_mjd().is_none());
394 assert!(bundle.eop_end_mjd().is_none());
395 }
396}