Skip to main content

tempoch_core/model/
time.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (C) 2026 Vallés Puig, Ramon
3
4//! `Time<S, F>` — canonical instant with compensated precision and a format tag.
5
6use core::fmt;
7use core::marker::PhantomData;
8use core::ops::{Add, AddAssign, Sub, SubAssign};
9
10use crate::earth::context::TimeContext;
11use crate::encoding::jd_to_julian_centuries;
12use crate::format::{J2000s, TimeFormat};
13use crate::foundation::error::ConversionError;
14use crate::model::scale::conversion::{ContextScaleConvert, InfallibleScaleConvert};
15use crate::model::scale::{CoordinateScale, Scale, TT, UTC};
16use crate::model::target::{ContextConversionTarget, ConversionTarget, InfallibleConversionTarget};
17use crate::{FormatForScale, InfallibleFormatForScale};
18use affn::algebra::{Space, SplitPoint1, SplitQuantity};
19use qtty::time::TimeUnit;
20use qtty::unit::Second as SecondUnit;
21use qtty::{Quantity, Second};
22
23/// Split-axis scalars must not be NaN; ±∞ may be stored but many conversions still reject them.
24#[inline]
25fn coordinate_pair_ok(hi: f64, lo: f64) -> bool {
26    !hi.is_nan() && !lo.is_nan()
27}
28
29#[derive(Copy, Clone)]
30pub(crate) struct ScaleAxis<S: Scale>(PhantomData<fn() -> S>);
31
32impl<S: Scale> fmt::Debug for ScaleAxis<S> {
33    #[inline]
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        f.debug_tuple("ScaleAxis").field(&S::NAME).finish()
36    }
37}
38
39impl<S: Scale> Space for ScaleAxis<S> {}
40
41/// A point in time on scale `S`, tagged with external format phantom `F`.
42///
43/// The default `F` is [`J2000s`], so `Time<S>` in code is `Time<S, J2000s>`:
44/// SI seconds since J2000.0 TT on the scale's coordinate axis.
45///
46/// Storage is always a compensated `(hi, lo)` pair of seconds. The format tag
47/// does not duplicate storage; it only types the API (`raw()`, conversions, …).
48///
49/// # Preconditions
50///
51/// **NaN must never appear** in encoded scalars or storage components — behavior is undefined if it does.
52/// **±∞** may be carried when callers use instants as sentinels; operations that require finite coordinates
53/// (ΔT loops, UTC civil decoding, POSIX Unix mapping, …) may still return [`ConversionError::NonFinite`].
54pub struct Time<S: Scale, F: TimeFormat = J2000s> {
55    instant: SplitPoint1<ScaleAxis<S>, SecondUnit>,
56    _fmt: PhantomData<fn() -> F>,
57}
58
59impl<S: Scale, F: TimeFormat> Copy for Time<S, F> {}
60
61impl<S: Scale, F: TimeFormat> Clone for Time<S, F> {
62    #[inline]
63    fn clone(&self) -> Self {
64        *self
65    }
66}
67
68impl<S: Scale, F: TimeFormat> PartialEq for Time<S, F> {
69    #[inline]
70    fn eq(&self, other: &Self) -> bool {
71        self.split_seconds() == other.split_seconds()
72    }
73}
74
75impl<S: Scale, F: TimeFormat> PartialOrd for Time<S, F> {
76    #[inline]
77    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
78        let (self_hi, self_lo) = self.split_seconds();
79        let (other_hi, other_lo) = other.split_seconds();
80        match self_hi.partial_cmp(&other_hi) {
81            Some(core::cmp::Ordering::Equal) => self_lo.partial_cmp(&other_lo),
82            ordering => ordering,
83        }
84    }
85}
86
87impl<S: Scale, F: TimeFormat> fmt::Debug for Time<S, F> {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        let (hi, lo) = self.split_seconds();
90        f.debug_struct("Time")
91            .field("scale", &S::NAME)
92            .field("format", &F::NAME)
93            .field("hi_s", &hi)
94            .field("lo_s", &lo)
95            .finish()
96    }
97}
98
99impl<S: CoordinateScale, F> fmt::Display for Time<S, F>
100where
101    F: InfallibleFormatForScale<S>,
102    qtty::Quantity<F::Unit>: fmt::Display,
103{
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        if F::NAME == J2000s::NAME {
106            write!(f, "{} {:.9}", S::NAME, self.total_seconds().value())
107        } else {
108            fmt::Display::fmt(&F::from_time(*self), f)
109        }
110    }
111}
112
113impl fmt::Display for Time<UTC, crate::format::Unix> {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        match self.try_raw_with(&TimeContext::new()) {
116            Ok(q) => fmt::Display::fmt(&q, f),
117            Err(_) => f.write_str("Unix(<invalid for display>)"),
118        }
119    }
120}
121
122impl<S: CoordinateScale, F> fmt::LowerExp for Time<S, F>
123where
124    F: InfallibleFormatForScale<S>,
125    qtty::Quantity<F::Unit>: fmt::LowerExp,
126{
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        fmt::LowerExp::fmt(&F::from_time(*self), f)
129    }
130}
131
132impl<S: CoordinateScale, F> fmt::UpperExp for Time<S, F>
133where
134    F: InfallibleFormatForScale<S>,
135    qtty::Quantity<F::Unit>: fmt::UpperExp,
136{
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        fmt::UpperExp::fmt(&F::from_time(*self), f)
139    }
140}
141
142impl<S: Scale, F: TimeFormat> Time<S, F> {
143    #[inline]
144    pub(crate) fn from_split(hi: Second, lo: Second) -> Self {
145        debug_assert!(
146            coordinate_pair_ok(hi.value(), lo.value()),
147            "time split pair must not contain NaN"
148        );
149        let instant = SplitPoint1::new(hi, lo);
150        let (hi, lo) = instant.coordinate().pair();
151        debug_assert!(
152            coordinate_pair_ok(hi.value(), lo.value()),
153            "time split pair must not contain NaN"
154        );
155        Self {
156            instant,
157            _fmt: PhantomData,
158        }
159    }
160
161    #[inline]
162    pub(crate) fn try_from_split(hi: Second, lo: Second) -> Result<Self, ConversionError> {
163        if coordinate_pair_ok(hi.value(), lo.value()) {
164            Ok(Self::from_split(hi, lo))
165        } else {
166            Err(ConversionError::NonFinite)
167        }
168    }
169
170    /// Same instant, different format tag (zero cost).
171    #[inline]
172    pub fn reinterpret<G: TimeFormat>(self) -> Time<S, G> {
173        Time {
174            instant: self.instant,
175            _fmt: PhantomData,
176        }
177    }
178
179    /// SI J2000-second tagged view of the same instant.
180    #[inline]
181    pub fn to_j2000s(self) -> Time<S, J2000s> {
182        self.reinterpret()
183    }
184
185    #[inline]
186    pub(crate) fn split_seconds(self) -> (Second, Second) {
187        self.instant.coordinate().pair()
188    }
189
190    #[inline]
191    pub(crate) fn total_seconds(self) -> Second {
192        self.instant.coordinate().total()
193    }
194
195    /// Raw internal storage pair in J2000-TT seconds on the instance scale.
196    #[inline]
197    pub fn raw_seconds_pair(self) -> (Second, Second) {
198        self.split_seconds()
199    }
200}
201
202impl<S: CoordinateScale> Time<S, J2000s> {
203    /// Build from J2000 TT seconds on the scale's coordinate axis.
204    #[inline]
205    pub fn from_raw_j2000_seconds(seconds: Second) -> Result<Self, ConversionError> {
206        Self::try_from_split(seconds, Second::new(0.0))
207    }
208
209    /// Build from a split J2000-second pair on the scale's coordinate axis.
210    #[inline]
211    pub fn try_from_raw_j2000_seconds_split(
212        hi: Second,
213        lo: Second,
214    ) -> Result<Self, ConversionError> {
215        Self::try_from_split(hi, lo)
216    }
217
218    #[inline]
219    pub(crate) fn raw_j2000_seconds(self) -> Second {
220        self.total_seconds()
221    }
222
223    /// Shift this instant forward by a typed duration.
224    #[inline]
225    pub fn shifted_by<U>(self, delta: qtty::Quantity<U>) -> Self
226    where
227        U: TimeUnit,
228    {
229        self + delta
230    }
231
232    /// Shift this instant backward by a typed duration.
233    #[inline]
234    pub fn shifted_back_by<U>(self, delta: qtty::Quantity<U>) -> Self
235    where
236        U: TimeUnit,
237    {
238        self - delta
239    }
240
241    /// Duration from `other` to `self`.
242    #[inline]
243    pub fn duration_since(self, other: Self) -> Second {
244        self - other
245    }
246
247    /// Duration from `self` to `other`.
248    #[inline]
249    pub fn duration_until(self, other: Self) -> Second {
250        other - self
251    }
252}
253
254impl<S: CoordinateScale, F: InfallibleFormatForScale<S>> Time<S, F> {
255    /// Encoded scalar for this format (derived from split storage).
256    #[inline]
257    pub fn raw(self) -> Quantity<F::Unit> {
258        F::from_time(self)
259    }
260
261    /// Alias for [`Self::raw`].
262    #[inline]
263    pub fn quantity(self) -> Quantity<F::Unit> {
264        F::from_time(self)
265    }
266}
267
268impl<S: CoordinateScale, F> Time<S, F>
269where
270    F: FormatForScale<S>,
271{
272    #[inline]
273    pub fn try_raw_with(self, ctx: &TimeContext) -> Result<Quantity<F::Unit>, ConversionError> {
274        F::try_from_time(self, ctx)
275    }
276}
277
278impl<S: Scale, F: TimeFormat> Time<S, F> {
279    /// Unified infallible conversion to a scale/view target.
280    #[allow(private_bounds)]
281    #[inline]
282    pub fn to<T>(self) -> T::Output
283    where
284        T: InfallibleConversionTarget<S, F>,
285    {
286        T::convert(self)
287    }
288
289    /// Unified fallible conversion to a scale/view target.
290    #[allow(private_bounds)]
291    #[inline]
292    pub fn try_to<T>(self) -> Result<T::Output, ConversionError>
293    where
294        T: ConversionTarget<S, F>,
295    {
296        T::try_convert(self)
297    }
298
299    /// Unified context-backed conversion to a scale/view target.
300    #[allow(private_bounds)]
301    #[inline]
302    pub fn to_with<T>(self, ctx: &TimeContext) -> Result<T::Output, ConversionError>
303    where
304        T: ContextConversionTarget<S, F>,
305    {
306        T::convert_with(self, ctx)
307    }
308
309    /// Infallible scale conversion; preserves format tag `F`.
310    #[allow(private_bounds)]
311    #[inline]
312    pub fn to_scale<S2: Scale>(self) -> Time<S2, F>
313    where
314        S: InfallibleScaleConvert<S2>,
315    {
316        let (hi, lo) = self.split_seconds();
317        let (new_hi, new_lo) = <S as InfallibleScaleConvert<S2>>::convert(hi, lo);
318        Time::from_split(new_hi, new_lo)
319    }
320
321    /// Context-required scale conversion (UT1 routes); preserves `F`.
322    #[allow(private_bounds)]
323    #[inline]
324    pub fn to_scale_with<S2: Scale>(self, ctx: &TimeContext) -> Result<Time<S2, F>, ConversionError>
325    where
326        S: ContextScaleConvert<S2>,
327    {
328        let (hi, lo) = self.split_seconds();
329        let (new_hi, new_lo) = <S as ContextScaleConvert<S2>>::convert_with(hi, lo, ctx)?;
330        Ok(Time::from_split(new_hi, new_lo))
331    }
332}
333
334impl<S: Scale, F: FormatForScale<S>> Time<S, F> {
335    /// Fallible constructor from an encoded scalar.
336    ///
337    /// Only surfaces **domain** failures from format decoding (UTC policy, leap seconds, ranges, …).
338    /// Scalar hygiene is a caller precondition: **NaN must not be passed**; ±∞ is accepted only where the format decoder tolerates it.
339    #[inline]
340    pub fn try_new(raw: Quantity<F::Unit>) -> Result<Self, ConversionError> {
341        F::try_into_time(raw, &TimeContext::new())
342    }
343
344    /// Like [`Self::try_new`], but uses `ctx` for UTC / POSIX decoding policy.
345    #[inline]
346    pub fn try_new_with(
347        raw: Quantity<F::Unit>,
348        ctx: &TimeContext,
349    ) -> Result<Self, ConversionError> {
350        F::try_into_time(raw, ctx)
351    }
352}
353
354impl<S: Scale, F: InfallibleFormatForScale<S>> Time<S, F> {
355    /// Infallible constructor from the raw scalar value for format `F`.
356    ///
357    /// # Panics
358    ///
359    /// If `value` is **NaN**. ±∞ is allowed as storage when callers use sentinel instants.
360    #[track_caller]
361    #[inline]
362    pub fn new(value: f64) -> Self {
363        assert!(
364            !value.is_nan(),
365            "time scalar must not be NaN (±∞ is allowed)"
366        );
367        F::into_time(Quantity::<F::Unit>::new(value))
368    }
369}
370
371impl<S: CoordinateScale, F: InfallibleFormatForScale<S>> Time<S, F> {
372    #[inline]
373    pub fn min(self, other: Self) -> Self {
374        if self <= other {
375            self
376        } else {
377            other
378        }
379    }
380
381    #[inline]
382    pub fn max(self, other: Self) -> Self {
383        if self >= other {
384            self
385        } else {
386            other
387        }
388    }
389
390    #[inline]
391    pub fn mean(self, other: Self) -> Self {
392        let t = self.to_j2000s() + ((other.to_j2000s() - self.to_j2000s()) * 0.5);
393        t.reinterpret()
394    }
395}
396
397/// TT Julian date at J2000.0 (`JD 2 451 545.0`); matches [`Self::jd_epoch_tt`], usable in `const`.
398impl Time<TT, crate::format::JD> {
399    pub const JD_EPOCH_J2000_0: Self = Self {
400        instant: SplitPoint1::from_split(SplitQuantity::from_normalized_parts(
401            Second::new(0.0),
402            Second::new(0.0),
403        )),
404        _fmt: PhantomData,
405    };
406}
407
408impl<S: Scale> Time<S, crate::format::JD> {
409    /// TT J2000.0 as a Julian Date on scale `S` (JD 2 451 545.0).
410    #[inline]
411    pub fn jd_epoch_tt() -> Self
412    where
413        S: CoordinateScale,
414    {
415        Time::<S, J2000s>::from_raw_j2000_seconds(Second::new(0.0))
416            .expect("J2000 origin")
417            .reinterpret()
418    }
419
420    #[inline]
421    pub fn value(self) -> f64
422    where
423        S: CoordinateScale,
424    {
425        self.raw().value()
426    }
427
428    #[inline]
429    pub fn julian_centuries(self) -> f64
430    where
431        S: CoordinateScale,
432    {
433        jd_to_julian_centuries(self.raw())
434    }
435}
436
437impl<S: Scale> Time<S, crate::format::MJD> {
438    #[inline]
439    pub fn value(self) -> f64
440    where
441        S: CoordinateScale,
442    {
443        self.raw().value()
444    }
445}
446
447impl<S: CoordinateScale, F, U> Add<Quantity<U>> for Time<S, F>
448where
449    F: InfallibleFormatForScale<S>,
450    U: TimeUnit,
451{
452    type Output = Self;
453
454    #[inline]
455    fn add(self, rhs: Quantity<U>) -> Self::Output {
456        Self {
457            instant: self.instant + rhs.to::<SecondUnit>(),
458            _fmt: PhantomData,
459        }
460    }
461}
462
463impl<S: CoordinateScale, F, U> Sub<Quantity<U>> for Time<S, F>
464where
465    F: InfallibleFormatForScale<S>,
466    U: TimeUnit,
467{
468    type Output = Self;
469
470    #[inline]
471    fn sub(self, rhs: Quantity<U>) -> Self::Output {
472        Self {
473            instant: self.instant - rhs.to::<SecondUnit>(),
474            _fmt: PhantomData,
475        }
476    }
477}
478
479impl<S: CoordinateScale, F> Sub for Time<S, F>
480where
481    F: InfallibleFormatForScale<S>,
482    F::Unit: TimeUnit,
483{
484    type Output = Quantity<F::Unit>;
485
486    #[inline]
487    fn sub(self, rhs: Self) -> Self::Output {
488        let delta: Second = self.instant - rhs.instant;
489        delta.to::<F::Unit>()
490    }
491}
492
493impl<S: CoordinateScale, F, U> AddAssign<Quantity<U>> for Time<S, F>
494where
495    F: InfallibleFormatForScale<S>,
496    U: TimeUnit,
497{
498    #[inline]
499    fn add_assign(&mut self, rhs: Quantity<U>) {
500        *self = *self + rhs;
501    }
502}
503
504impl<S: CoordinateScale, F, U> SubAssign<Quantity<U>> for Time<S, F>
505where
506    F: InfallibleFormatForScale<S>,
507    U: TimeUnit,
508{
509    #[inline]
510    fn sub_assign(&mut self, rhs: Quantity<U>) {
511        *self = *self - rhs;
512    }
513}