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::{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 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 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 assert!(bundle.eop_points().is_empty());
395 assert!(bundle.eop_start_mjd().is_none());
396 assert!(bundle.eop_end_mjd().is_none());
397 }
398}