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 on the scale's coordinate axis.
148    ///
149    /// This is the public constructor for FFI and wrapper layers that need to
150    /// preserve the compensated `(hi, lo)` representation instead of
151    /// flattening it to one scalar.
152    #[inline]
153    pub fn try_from_raw_j2000_seconds_split(
154        hi: Second,
155        lo: Second,
156    ) -> Result<Self, ConversionError> {
157        Self::try_new(hi, lo)
158    }
159
160    /// Build from a split J2000-second pair.
161    #[inline]
162    #[cfg(test)]
163    pub(crate) fn from_raw_j2000_seconds_split(
164        hi: Second,
165        lo: Second,
166    ) -> Result<Self, ConversionError> {
167        Self::try_new(hi, lo)
168    }
169
170    /// Scale-coordinate seconds since J2000 TT.
171    #[inline]
172    pub(crate) fn raw_j2000_seconds(self) -> Second {
173        self.total_seconds()
174    }
175}
176
177impl<S: Scale> Time<S> {
178    /// Unified infallible conversion to a scale/view target.
179    #[allow(private_bounds)]
180    #[inline]
181    pub fn to<T>(self) -> T::Output
182    where
183        T: InfallibleConversionTarget<S>,
184    {
185        T::convert(self)
186    }
187
188    /// Unified fallible conversion to a scale/view target.
189    #[allow(private_bounds)]
190    #[inline]
191    pub fn try_to<T>(self) -> Result<T::Output, ConversionError>
192    where
193        T: ConversionTarget<S>,
194    {
195        T::try_convert(self)
196    }
197
198    /// Unified context-backed conversion to a scale/view target.
199    #[allow(private_bounds)]
200    #[inline]
201    pub fn to_with<T>(self, ctx: &TimeContext) -> Result<T::Output, ConversionError>
202    where
203        T: ContextConversionTarget<S>,
204    {
205        T::convert_with(self, ctx)
206    }
207
208    /// Infallible scale conversion. Compiles only for pairs with a
209    /// closed-form, context-free conversion.
210    #[allow(private_bounds)]
211    #[inline]
212    pub fn to_scale<S2: Scale>(self) -> Time<S2>
213    where
214        S: InfallibleScaleConvert<S2>,
215    {
216        let (hi, lo) = self.split_seconds();
217        let (new_hi, new_lo) = <S as InfallibleScaleConvert<S2>>::convert(hi, lo);
218        Time::new_unchecked(new_hi, new_lo)
219    }
220
221    /// Context-required scale conversion (UT1 routes).
222    #[allow(private_bounds)]
223    #[inline]
224    pub fn to_scale_with<S2: Scale>(self, ctx: &TimeContext) -> Result<Time<S2>, ConversionError>
225    where
226        S: ContextScaleConvert<S2>,
227    {
228        let (hi, lo) = self.split_seconds();
229        let (new_hi, new_lo) = <S as ContextScaleConvert<S2>>::convert_with(hi, lo, ctx)?;
230        Ok(Time::new_unchecked(new_hi, new_lo))
231    }
232}
233
234impl<S: CoordinateScale> core::ops::Sub for Time<S> {
235    type Output = Second;
236
237    #[inline]
238    fn sub(self, rhs: Self) -> Second {
239        self.instant - rhs.instant
240    }
241}
242
243impl<S: CoordinateScale> core::ops::Add<Second> for Time<S> {
244    type Output = Self;
245
246    #[inline]
247    fn add(self, rhs: Second) -> Self {
248        Self {
249            instant: self.instant + rhs,
250        }
251    }
252}
253
254impl<S: CoordinateScale> core::ops::Sub<Second> for Time<S> {
255    type Output = Self;
256
257    #[inline]
258    fn sub(self, rhs: Second) -> Self {
259        Self {
260            instant: self.instant - rhs,
261        }
262    }
263}
264
265impl<S: CoordinateScale> core::ops::AddAssign<Second> for Time<S> {
266    #[inline]
267    fn add_assign(&mut self, rhs: Second) {
268        *self = *self + rhs;
269    }
270}
271
272impl<S: CoordinateScale> core::ops::SubAssign<Second> for Time<S> {
273    #[inline]
274    fn sub_assign(&mut self, rhs: Second) {
275        *self = *self - rhs;
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::super::encoding::{j2000_seconds_to_mjd, mjd_to_j2000_seconds};
282    use super::super::scale::{TAI, TCG, TDB, TT, UTC};
283    use super::*;
284
285    #[test]
286    fn normalized_constructor_keeps_sum() {
287        let time = Time::<TT>::from_raw_j2000_seconds_split(Second::new(1.0e9), Second::new(0.25))
288            .unwrap();
289        assert!((time.raw_j2000_seconds() - Second::new(1.0e9 + 0.25)).abs() < Second::new(1e-6));
290    }
291
292    #[test]
293    fn tt_tai_round_trip_exact_offset() {
294        let tt = Time::<TT>::from_raw_j2000_seconds(Second::new(0.0)).unwrap();
295        let tai = tt.to_scale::<TAI>();
296        let roundtrip = tai.to_scale::<TT>();
297        assert!(
298            (tt.raw_j2000_seconds() - roundtrip.raw_j2000_seconds()).abs() < Second::new(1e-12)
299        );
300        assert!((tai.raw_j2000_seconds() - Second::new(-32.184)).abs() < Second::new(1e-12));
301    }
302
303    #[test]
304    fn tt_tdb_round_trip_model_error() {
305        let tt = Time::<TT>::from_raw_j2000_seconds(Second::new(1_000_000.0)).unwrap();
306        let tdb = tt.to_scale::<TDB>();
307        let tt2 = tdb.to_scale::<TT>();
308        assert!((tt.raw_j2000_seconds() - tt2.raw_j2000_seconds()).abs() < Second::new(1e-6));
309    }
310
311    #[test]
312    fn tt_tcg_offset_is_finite() {
313        let tt = Time::<TT>::from_raw_j2000_seconds(qtty::Day::new(1.0).to::<qtty::unit::Second>())
314            .unwrap();
315        let tcg = tt.to_scale::<TCG>();
316        assert!(tcg.raw_j2000_seconds().is_finite());
317    }
318
319    #[test]
320    fn utc_exposes_raw_axis_helpers_and_arithmetic() {
321        let utc =
322            Time::<UTC>::from_raw_j2000_seconds(mjd_to_j2000_seconds(qtty::Day::new(51_544.5)))
323                .unwrap();
324        let shifted = utc + Second::new(10.0);
325        assert_eq!(
326            j2000_seconds_to_mjd(utc.raw_j2000_seconds()),
327            qtty::Day::new(51_544.5)
328        );
329        assert!((shifted - utc - Second::new(10.0)).abs() < Second::new(1e-12));
330    }
331
332    #[test]
333    #[allow(clippy::clone_on_copy)]
334    fn scale_axis_debug_and_time_formatting_are_stable() {
335        let axis = ScaleAxis::<TT>(PhantomData);
336        assert_eq!(format!("{axis:?}"), "ScaleAxis(\"TT\")");
337
338        let time = Time::<TT>::from_raw_j2000_seconds(Second::new(1.25)).unwrap();
339        let cloned = time.clone();
340        assert_eq!(cloned, time);
341        assert!(format!("{time:?}").starts_with("Time<TT>("));
342        assert_eq!(format!("{time}"), "TT 1.250000000 s");
343    }
344
345    #[test]
346    fn time_partial_order_and_assign_arithmetic() {
347        let start = Time::<TT>::from_raw_j2000_seconds(Second::new(10.0)).unwrap();
348        let mut shifted = start;
349
350        shifted += Second::new(3.0);
351        assert!(shifted > start);
352        assert_eq!(shifted - start, Second::new(3.0));
353
354        shifted -= Second::new(1.25);
355        assert_eq!(shifted - start, Second::new(1.75));
356        assert_eq!(shifted - Second::new(1.75), start);
357    }
358
359    #[test]
360    fn raw_j2000_constructor_rejects_nonfinite() {
361        assert!(matches!(
362            Time::<TT>::from_raw_j2000_seconds(Second::new(f64::NAN)),
363            Err(ConversionError::NonFinite)
364        ));
365    }
366}