tempoch_core/context.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (C) 2026 Vallés Puig, Ramon
3
4//! Conversion context.
5
6use crate::data::active::{active_time_data, time_data_eop_at};
7use crate::eop::EopValues;
8use qtty::{Day, Second};
9use std::sync::Arc;
10use tempoch_time_data::TimeDataBundle;
11
12/// Explicit, immutable context for conversions that need one.
13///
14/// A `TimeContext` snapshots the active time-data bundle at construction time
15/// and selects which parts of that snapshot back context-required conversions.
16/// The default constructor [`TimeContext::new`] uses the monthly ΔT series from
17/// the captured bundle, matching the behaviour of previous versions;
18/// [`TimeContext::with_builtin_eop`] selects the daily IERS `finals2000A.all`
19/// series from that same snapshot for the highest-fidelity bundled UT1 path
20/// inside its coverage window.
21///
22/// # ΔT / UT1 accuracy
23///
24/// | Epoch range | Default context (monthly ΔT) | `with_builtin_eop()` |
25/// |---|---|---|
26/// | Pre-948 CE | ±hundreds of s (Stephenson & Houlden quadratic) | same (outside EOP range) |
27/// | 948–1619 | ±15 s (Stephenson & Houlden) | same |
28/// | 1620–1973 | ±0.1–1 s (Meeus biennial table) | same |
29/// | 1973 – EOP start | ~0.01 s (USNO monthly) | same |
30/// | EOP observed range | < 15 ms from the bundled daily IERS-derived path over the compiled observed overlap | preferred highest-fidelity bundled UT1 path |
31/// | EOP prediction range | < 0.2 s from the bundled short-range daily prediction over the compiled prediction overlap | preferred highest-fidelity bundled UT1 path |
32/// | Beyond EOP | monthly ΔT only; prediction uncertainty grows | falls back to monthly ΔT |
33///
34/// The builtin EOP is only consulted inside the captured bundle's coverage;
35/// outside of that range the monthly ΔT path applies unchanged. Construct a
36/// fresh context after refreshing the active bundle if you want to use the
37/// updated runtime data.
38#[derive(Debug, Clone)]
39pub struct TimeContext {
40 data: Arc<TimeDataBundle>,
41 eop: EopSource,
42 utc_pre_definition: bool,
43}
44
45#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
46enum EopSource {
47 /// Use the monthly ΔT series only (default, bit-compatible with
48 /// pre-EOP tempoch releases).
49 #[default]
50 None,
51 /// Consult the compiled daily IERS `finals2000A.all` series when the
52 /// requested epoch is within coverage, and fall back to the monthly ΔT
53 /// series otherwise.
54 Builtin,
55}
56
57impl Default for TimeContext {
58 #[inline]
59 fn default() -> Self {
60 Self::new()
61 }
62}
63
64impl TimeContext {
65 #[inline]
66 fn snapshot(eop: EopSource) -> Self {
67 Self {
68 data: active_time_data(),
69 eop,
70 utc_pre_definition: false,
71 }
72 }
73
74 /// Construct a default context backed by the monthly ΔT table.
75 ///
76 /// This is the lightweight, always-available choice. It does not consult
77 /// the daily EOP series even when the bundled data contains one.
78 #[inline]
79 pub fn new() -> Self {
80 Self::snapshot(EopSource::None)
81 }
82
83 /// Construct a context that prefers the compiled daily IERS
84 /// `finals2000A.all` series for UT1 conversions when the epoch is
85 /// within its coverage window.
86 ///
87 /// Outside the bundled EOP coverage, this falls back to the same monthly
88 /// ΔT path used by [`TimeContext::new`].
89 #[inline]
90 pub fn with_builtin_eop() -> Self {
91 Self::snapshot(EopSource::Builtin)
92 }
93
94 #[inline]
95 pub(crate) fn time_data(&self) -> &TimeDataBundle {
96 self.data.as_ref()
97 }
98
99 /// Allow UTC conversions for dates before 1961-01-01.
100 ///
101 /// By default, [`Time::<UTC>::try_from_chrono_with`](crate::Time::try_from_chrono_with) and related conversions
102 /// return [`crate::ConversionError::UtcBeforeDefinition`] for any date
103 /// before MJD 37 300 (1961-01-01), because UTC was not an international
104 /// standard before that date and the back-extrapolated offset is
105 /// historically fabricated.
106 ///
107 /// Calling this method on a context opts into the approximate
108 /// continuation: the first official UTC-TAI segment is extrapolated
109 /// backwards. Round-trips close, but the values are not
110 /// standards-defined UTC.
111 ///
112 /// # Example
113 /// ```
114 /// use tempoch_core::{TimeContext, Time, UTC};
115 /// use chrono::DateTime;
116 ///
117 /// let dt = DateTime::from_timestamp(-631_152_000, 0).unwrap();
118 /// let ctx = TimeContext::new().allow_pre_definition_utc();
119 /// let utc = Time::<UTC>::try_from_chrono_with(dt, &ctx).unwrap();
120 /// ```
121 #[inline]
122 pub fn allow_pre_definition_utc(mut self) -> Self {
123 self.utc_pre_definition = true;
124 self
125 }
126
127 #[inline]
128 pub(crate) fn allows_pre_definition_utc(&self) -> bool {
129 self.utc_pre_definition
130 }
131 /// Interpolated EOP at `mjd_utc`, if this context has an EOP source and
132 /// the MJD is in range.
133 ///
134 /// This exposes the same interpolated values that context-backed scale
135 /// conversions consult internally, so callers can inspect or reuse them
136 /// without reimplementing the lookup path.
137 #[inline]
138 pub fn eop_at(&self, mjd_utc: Day) -> Option<EopValues> {
139 match self.eop {
140 EopSource::None => None,
141 EopSource::Builtin => time_data_eop_at(self.time_data(), mjd_utc),
142 }
143 }
144
145 /// Interpolated `UT1 - UTC` from the context's EOP source, if available.
146 ///
147 /// Returns `None` when this context is monthly-ΔT-only or when the epoch is
148 /// outside the captured EOP coverage window.
149 #[inline]
150 pub fn ut1_minus_utc(&self, mjd_utc: Day) -> Option<Second> {
151 self.eop_at(mjd_utc).map(|v| v.ut1_minus_utc)
152 }
153}