Skip to main content

tempoch_core/
time.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (C) 2026 Vallés Puig, Ramon
3
4//! `Time<S>` — the core public type.
5
6use core::marker::PhantomData;
7
8use super::context::TimeContext;
9use super::error::ConversionError;
10use super::scale::conversion::{ContextScaleConvert, InfallibleScaleConvert};
11use super::scale::{CoordinateScale, Scale};
12use super::target::{ContextConversionTarget, ConversionTarget, InfallibleConversionTarget};
13use affn::algebra::{Space, SplitPoint1};
14use qtty::unit::Second as SecondUnit;
15use qtty::Second;
16
17#[inline]
18fn is_finite_pair(hi: f64, lo: f64) -> bool {
19    hi.is_finite() && lo.is_finite()
20}
21
22#[derive(Copy, Clone)]
23pub(crate) struct ScaleAxis<S: Scale>(PhantomData<fn() -> S>);
24
25impl<S: Scale> core::fmt::Debug for ScaleAxis<S> {
26    #[inline]
27    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
28        f.debug_tuple("ScaleAxis").field(&S::NAME).finish()
29    }
30}
31
32impl<S: Scale> Space for ScaleAxis<S> {}
33
34/// A point in time on scale `S`.
35///
36/// Internally, `Time<S>` stores a compensated `(hi, lo)` pair of seconds since
37/// J2000 TT on the scale's coordinate axis. The pair sums to the exact value
38/// represented by the instance, while keeping the low-order remainder small
39/// enough to retain much better precision than a single `f64`.
40///
41/// `Time<S>` is intentionally modeled as an affine point, not as a raw scalar:
42/// subtracting two instants yields a duration, while adding or subtracting a
43/// duration shifts an instant. Internally this is represented with
44/// `affn::SplitPoint1`, which preserves the same point-vs-displacement
45/// semantics used elsewhere in the codebase.
46///
47/// The split representation exists because astronomical epochs are large
48/// values, while important corrections are often tiny. A single `f64` would
49/// discard low-order precision too aggressively once epoch-sized values are
50/// combined with sub-second or microsecond-scale offsets, so `Time<S>` keeps
51/// the large component in `hi` and the residual correction in `lo`.
52///
53/// `UTC` remains special: it stores a continuous instant on the same internal
54/// axis used by `TAI`, but its civil interpretation still comes from the
55/// active UTC-TAI table. Raw JD/MJD/J2000-second helpers and second-based
56/// arithmetic operate on that stored instant axis; use the civil API when you
57/// need leap-second-labelled UTC values.
58pub struct Time<S: Scale> {
59    instant: SplitPoint1<ScaleAxis<S>, SecondUnit>,
60}
61
62impl<S: Scale> Copy for Time<S> {}
63impl<S: Scale> Clone for Time<S> {
64    #[inline]
65    fn clone(&self) -> Self {
66        *self
67    }
68}
69
70impl<S: Scale> PartialEq for Time<S> {
71    #[inline]
72    fn eq(&self, other: &Self) -> bool {
73        self.split_seconds() == other.split_seconds()
74    }
75}
76
77impl<S: Scale> PartialOrd for Time<S> {
78    #[inline]
79    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
80        let (self_hi, self_lo) = self.split_seconds();
81        let (other_hi, other_lo) = other.split_seconds();
82        match self_hi.partial_cmp(&other_hi) {
83            Some(core::cmp::Ordering::Equal) => self_lo.partial_cmp(&other_lo),
84            ordering => ordering,
85        }
86    }
87}
88
89impl<S: Scale> core::fmt::Debug for Time<S> {
90    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
91        let (hi, lo) = self.split_seconds();
92        write!(f, "Time<{}>({:.17e}, {:.17e})", S::NAME, hi, lo)
93    }
94}
95
96impl<S: Scale> core::fmt::Display for Time<S> {
97    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
98        write!(f, "{} {:.9}", S::NAME, self.total_seconds())
99    }
100}
101
102impl<S: Scale> Time<S> {
103    #[inline]
104    pub(crate) fn new_unchecked(hi: Second, lo: Second) -> Self {
105        debug_assert!(hi.is_finite());
106        debug_assert!(lo.is_finite());
107        let instant = SplitPoint1::new(hi, lo);
108        let (hi, lo) = instant.coordinate().pair();
109        debug_assert!(hi.is_finite());
110        debug_assert!(lo.is_finite());
111        Self { instant }
112    }
113
114    #[inline]
115    pub(crate) fn try_new(hi: Second, lo: Second) -> Result<Self, ConversionError> {
116        if is_finite_pair(hi.value(), lo.value()) {
117            Ok(Self::new_unchecked(hi, lo))
118        } else {
119            Err(ConversionError::NonFinite)
120        }
121    }
122
123    #[inline]
124    pub(crate) fn split_seconds(self) -> (Second, Second) {
125        self.instant.coordinate().pair()
126    }
127
128    #[inline]
129    pub(crate) fn total_seconds(self) -> Second {
130        self.instant.coordinate().total()
131    }
132
133    /// Raw internal storage pair in J2000-TT seconds on the instance scale.
134    #[inline]
135    pub fn raw_seconds_pair(self) -> (Second, Second) {
136        self.split_seconds()
137    }
138}
139
140impl<S: CoordinateScale> Time<S> {
141    /// Build from J2000 TT seconds on the scale's coordinate axis.
142    #[inline]
143    pub(crate) fn from_raw_j2000_seconds(seconds: Second) -> Result<Self, ConversionError> {
144        Self::try_new(seconds, Second::new(0.0))
145    }
146
147    /// Build from a split J2000-second pair.
148    #[inline]
149    #[cfg(test)]
150    pub(crate) fn from_raw_j2000_seconds_split(
151        hi: Second,
152        lo: Second,
153    ) -> Result<Self, ConversionError> {
154        Self::try_new(hi, lo)
155    }
156
157    /// Scale-coordinate seconds since J2000 TT.
158    #[inline]
159    pub(crate) fn raw_j2000_seconds(self) -> Second {
160        self.total_seconds()
161    }
162}
163
164impl<S: Scale> Time<S> {
165    /// Unified infallible conversion to a scale/view target.
166    #[allow(private_bounds)]
167    #[inline]
168    pub fn to<T>(self) -> T::Output
169    where
170        T: InfallibleConversionTarget<S>,
171    {
172        T::convert(self)
173    }
174
175    /// Unified fallible conversion to a scale/view target.
176    #[allow(private_bounds)]
177    #[inline]
178    pub fn try_to<T>(self) -> Result<T::Output, ConversionError>
179    where
180        T: ConversionTarget<S>,
181    {
182        T::try_convert(self)
183    }
184
185    /// Unified context-backed conversion to a scale/view target.
186    #[allow(private_bounds)]
187    #[inline]
188    pub fn to_with<T>(self, ctx: &TimeContext) -> Result<T::Output, ConversionError>
189    where
190        T: ContextConversionTarget<S>,
191    {
192        T::convert_with(self, ctx)
193    }
194
195    /// Infallible scale conversion. Compiles only for pairs with a
196    /// closed-form, context-free conversion.
197    #[allow(private_bounds)]
198    #[inline]
199    pub fn to_scale<S2: Scale>(self) -> Time<S2>
200    where
201        S: InfallibleScaleConvert<S2>,
202    {
203        let (hi, lo) = self.split_seconds();
204        let (new_hi, new_lo) = <S as InfallibleScaleConvert<S2>>::convert(hi, lo);
205        Time::new_unchecked(new_hi, new_lo)
206    }
207
208    /// Context-required scale conversion (UT1 routes).
209    #[allow(private_bounds)]
210    #[inline]
211    pub fn to_scale_with<S2: Scale>(self, ctx: &TimeContext) -> Result<Time<S2>, ConversionError>
212    where
213        S: ContextScaleConvert<S2>,
214    {
215        let (hi, lo) = self.split_seconds();
216        let (new_hi, new_lo) = <S as ContextScaleConvert<S2>>::convert_with(hi, lo, ctx)?;
217        Ok(Time::new_unchecked(new_hi, new_lo))
218    }
219}
220
221impl<S: CoordinateScale> core::ops::Sub for Time<S> {
222    type Output = Second;
223
224    #[inline]
225    fn sub(self, rhs: Self) -> Second {
226        self.instant - rhs.instant
227    }
228}
229
230impl<S: CoordinateScale> core::ops::Add<Second> for Time<S> {
231    type Output = Self;
232
233    #[inline]
234    fn add(self, rhs: Second) -> Self {
235        Self {
236            instant: self.instant + rhs,
237        }
238    }
239}
240
241impl<S: CoordinateScale> core::ops::Sub<Second> for Time<S> {
242    type Output = Self;
243
244    #[inline]
245    fn sub(self, rhs: Second) -> Self {
246        Self {
247            instant: self.instant - rhs,
248        }
249    }
250}
251
252impl<S: CoordinateScale> core::ops::AddAssign<Second> for Time<S> {
253    #[inline]
254    fn add_assign(&mut self, rhs: Second) {
255        *self = *self + rhs;
256    }
257}
258
259impl<S: CoordinateScale> core::ops::SubAssign<Second> for Time<S> {
260    #[inline]
261    fn sub_assign(&mut self, rhs: Second) {
262        *self = *self - rhs;
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::super::encoding::{j2000_seconds_to_mjd, mjd_to_j2000_seconds};
269    use super::super::scale::{TAI, TCG, TDB, TT, UTC};
270    use super::*;
271
272    #[test]
273    fn normalized_constructor_keeps_sum() {
274        let time = Time::<TT>::from_raw_j2000_seconds_split(Second::new(1.0e9), Second::new(0.25))
275            .unwrap();
276        assert!((time.raw_j2000_seconds() - Second::new(1.0e9 + 0.25)).abs() < Second::new(1e-6));
277    }
278
279    #[test]
280    fn tt_tai_round_trip_exact_offset() {
281        let tt = Time::<TT>::from_raw_j2000_seconds(Second::new(0.0)).unwrap();
282        let tai = tt.to_scale::<TAI>();
283        let roundtrip = tai.to_scale::<TT>();
284        assert!(
285            (tt.raw_j2000_seconds() - roundtrip.raw_j2000_seconds()).abs() < Second::new(1e-12)
286        );
287        assert!((tai.raw_j2000_seconds() - Second::new(-32.184)).abs() < Second::new(1e-12));
288    }
289
290    #[test]
291    fn tt_tdb_round_trip_model_error() {
292        let tt = Time::<TT>::from_raw_j2000_seconds(Second::new(1_000_000.0)).unwrap();
293        let tdb = tt.to_scale::<TDB>();
294        let tt2 = tdb.to_scale::<TT>();
295        assert!((tt.raw_j2000_seconds() - tt2.raw_j2000_seconds()).abs() < Second::new(1e-6));
296    }
297
298    #[test]
299    fn tt_tcg_offset_is_finite() {
300        let tt = Time::<TT>::from_raw_j2000_seconds(qtty::Day::new(1.0).to::<qtty::unit::Second>())
301            .unwrap();
302        let tcg = tt.to_scale::<TCG>();
303        assert!(tcg.raw_j2000_seconds().is_finite());
304    }
305
306    #[test]
307    fn utc_exposes_raw_axis_helpers_and_arithmetic() {
308        let utc =
309            Time::<UTC>::from_raw_j2000_seconds(mjd_to_j2000_seconds(qtty::Day::new(51_544.5)))
310                .unwrap();
311        let shifted = utc + Second::new(10.0);
312        assert_eq!(
313            j2000_seconds_to_mjd(utc.raw_j2000_seconds()),
314            qtty::Day::new(51_544.5)
315        );
316        assert!((shifted - utc - Second::new(10.0)).abs() < Second::new(1e-12));
317    }
318
319    #[test]
320    #[allow(clippy::clone_on_copy)]
321    fn scale_axis_debug_and_time_formatting_are_stable() {
322        let axis = ScaleAxis::<TT>(PhantomData);
323        assert_eq!(format!("{axis:?}"), "ScaleAxis(\"TT\")");
324
325        let time = Time::<TT>::from_raw_j2000_seconds(Second::new(1.25)).unwrap();
326        let cloned = time.clone();
327        assert_eq!(cloned, time);
328        assert!(format!("{time:?}").starts_with("Time<TT>("));
329        assert_eq!(format!("{time}"), "TT 1.250000000 s");
330    }
331
332    #[test]
333    fn time_partial_order_and_assign_arithmetic() {
334        let start = Time::<TT>::from_raw_j2000_seconds(Second::new(10.0)).unwrap();
335        let mut shifted = start;
336
337        shifted += Second::new(3.0);
338        assert!(shifted > start);
339        assert_eq!(shifted - start, Second::new(3.0));
340
341        shifted -= Second::new(1.25);
342        assert_eq!(shifted - start, Second::new(1.75));
343        assert_eq!(shifted - Second::new(1.75), start);
344    }
345
346    #[test]
347    fn raw_j2000_constructor_rejects_nonfinite() {
348        assert!(matches!(
349            Time::<TT>::from_raw_j2000_seconds(Second::new(f64::NAN)),
350            Err(ConversionError::NonFinite)
351        ));
352    }
353}