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::qtty::time::TimeUnit;
18use crate::qtty::{self, Quantity, Second};
19use crate::{FormatForScale, InfallibleFormatForScale};
20use affn::algebra::{Space, SplitPoint1, SplitQuantity};
21
22/// Split-axis scalars must not be NaN; ±∞ may be stored but many conversions still reject them.
23#[inline]
24fn coordinate_pair_ok(hi: f64, lo: f64) -> bool {
25    !hi.is_nan() && !lo.is_nan()
26}
27
28#[derive(Copy, Clone)]
29pub(crate) struct ScaleAxis<S: Scale>(PhantomData<fn() -> S>);
30
31impl<S: Scale> fmt::Debug for ScaleAxis<S> {
32    #[inline]
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        f.debug_tuple("ScaleAxis").field(&S::NAME).finish()
35    }
36}
37
38impl<S: Scale> Space for ScaleAxis<S> {}
39
40/// A point in time on scale `S`, tagged with external format phantom `F`.
41///
42/// The default `F` is [`J2000s`], so `Time<S>` in code is `Time<S, J2000s>`:
43/// SI seconds since J2000.0 TT on the scale's coordinate axis.
44///
45/// Storage is always a compensated `(hi, lo)` pair of seconds. The format tag
46/// does not duplicate storage; it only types the API (`raw()`, conversions, …).
47///
48/// # Preconditions
49///
50/// **NaN must never appear** in encoded scalars or storage components — behavior is undefined if it does.
51/// **±∞** may be carried when callers use instants as sentinels; operations that require finite coordinates
52/// (ΔT loops, UTC civil decoding, POSIX Unix mapping, …) may still return [`ConversionError::NonFinite`].
53pub struct Time<S: Scale, F: TimeFormat = J2000s> {
54    instant: SplitPoint1<ScaleAxis<S>, qtty::unit::Second>,
55    _fmt: PhantomData<fn() -> F>,
56}
57
58impl<S: Scale, F: TimeFormat> Copy for Time<S, F> {}
59
60impl<S: Scale, F: TimeFormat> Clone for Time<S, F> {
61    #[inline]
62    fn clone(&self) -> Self {
63        *self
64    }
65}
66
67impl<S: Scale, F: TimeFormat> PartialEq for Time<S, F> {
68    #[inline]
69    fn eq(&self, other: &Self) -> bool {
70        self.split_seconds() == other.split_seconds()
71    }
72}
73
74impl<S: Scale, F: TimeFormat> PartialOrd for Time<S, F> {
75    #[inline]
76    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
77        let (self_hi, self_lo) = self.split_seconds();
78        let (other_hi, other_lo) = other.split_seconds();
79        match self_hi.partial_cmp(&other_hi) {
80            Some(core::cmp::Ordering::Equal) => self_lo.partial_cmp(&other_lo),
81            ordering => ordering,
82        }
83    }
84}
85
86impl<S: Scale, F: TimeFormat> fmt::Debug for Time<S, F> {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        let (hi, lo) = self.split_seconds();
89        f.debug_struct("Time")
90            .field("scale", &S::NAME)
91            .field("format", &F::NAME)
92            .field("hi_s", &hi)
93            .field("lo_s", &lo)
94            .finish()
95    }
96}
97
98impl<S: CoordinateScale, F> fmt::Display for Time<S, F>
99where
100    F: InfallibleFormatForScale<S>,
101    crate::qtty::Quantity<F::Unit>: fmt::Display,
102{
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        if F::NAME == J2000s::NAME {
105            write!(f, "{} {:.9}", S::NAME, self.total_seconds().value())
106        } else {
107            fmt::Display::fmt(&F::from_time(*self), f)
108        }
109    }
110}
111
112impl fmt::Display for Time<UTC, crate::format::Unix> {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        match self.try_raw_with(&TimeContext::new()) {
115            Ok(q) => fmt::Display::fmt(&q, f),
116            Err(_) => f.write_str("Unix(<invalid for display>)"),
117        }
118    }
119}
120
121impl<S: CoordinateScale, F> fmt::LowerExp for Time<S, F>
122where
123    F: InfallibleFormatForScale<S>,
124    crate::qtty::Quantity<F::Unit>: fmt::LowerExp,
125{
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        fmt::LowerExp::fmt(&F::from_time(*self), f)
128    }
129}
130
131impl<S: CoordinateScale, F> fmt::UpperExp for Time<S, F>
132where
133    F: InfallibleFormatForScale<S>,
134    crate::qtty::Quantity<F::Unit>: fmt::UpperExp,
135{
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        fmt::UpperExp::fmt(&F::from_time(*self), f)
138    }
139}
140
141impl<S: Scale, F: TimeFormat> Time<S, F> {
142    #[inline]
143    pub(crate) fn from_split(hi: Second, lo: Second) -> Self {
144        debug_assert!(
145            coordinate_pair_ok(hi.value(), lo.value()),
146            "time split pair must not contain NaN"
147        );
148        let instant = SplitPoint1::new(hi, lo);
149        let (hi, lo) = instant.coordinate().pair();
150        debug_assert!(
151            coordinate_pair_ok(hi.value(), lo.value()),
152            "time split pair must not contain NaN"
153        );
154        Self {
155            instant,
156            _fmt: PhantomData,
157        }
158    }
159
160    #[inline]
161    pub(crate) fn try_from_split(hi: Second, lo: Second) -> Result<Self, ConversionError> {
162        if coordinate_pair_ok(hi.value(), lo.value()) {
163            Ok(Self::from_split(hi, lo))
164        } else {
165            Err(ConversionError::NonFinite)
166        }
167    }
168
169    /// Same instant, different format tag (zero cost).
170    #[inline]
171    pub fn reinterpret<G: TimeFormat>(self) -> Time<S, G> {
172        Time {
173            instant: self.instant,
174            _fmt: PhantomData,
175        }
176    }
177
178    /// SI J2000-second tagged view of the same instant.
179    #[inline]
180    pub fn to_j2000s(self) -> Time<S, J2000s> {
181        self.reinterpret()
182    }
183
184    #[inline]
185    pub(crate) fn split_seconds(self) -> (Second, Second) {
186        self.instant.coordinate().pair()
187    }
188
189    #[inline]
190    pub(crate) fn total_seconds(self) -> Second {
191        self.instant.coordinate().total()
192    }
193
194    /// Raw internal storage pair in J2000-TT seconds on the instance scale.
195    #[inline]
196    pub fn raw_seconds_pair(self) -> (Second, Second) {
197        self.split_seconds()
198    }
199}
200
201impl<S: CoordinateScale> Time<S, J2000s> {
202    /// Build from J2000 TT seconds on the scale's coordinate axis.
203    #[inline]
204    pub fn from_raw_j2000_seconds(seconds: Second) -> Result<Self, ConversionError> {
205        Self::try_from_split(seconds, Second::new(0.0))
206    }
207
208    /// Build from a split J2000-second pair on the scale's coordinate axis.
209    #[inline]
210    pub fn try_from_raw_j2000_seconds_split(
211        hi: Second,
212        lo: Second,
213    ) -> Result<Self, ConversionError> {
214        Self::try_from_split(hi, lo)
215    }
216
217    #[inline]
218    pub(crate) fn raw_j2000_seconds(self) -> Second {
219        self.total_seconds()
220    }
221
222    /// Shift this instant forward by a typed duration.
223    #[inline]
224    pub fn shifted_by<U>(self, delta: crate::qtty::Quantity<U>) -> Self
225    where
226        U: TimeUnit,
227    {
228        self + delta
229    }
230
231    /// Shift this instant backward by a typed duration.
232    #[inline]
233    pub fn shifted_back_by<U>(self, delta: crate::qtty::Quantity<U>) -> Self
234    where
235        U: TimeUnit,
236    {
237        self - delta
238    }
239
240    /// Duration from `other` to `self`.
241    #[inline]
242    pub fn duration_since(self, other: Self) -> Second {
243        self - other
244    }
245
246    /// Duration from `self` to `other`.
247    #[inline]
248    pub fn duration_until(self, other: Self) -> Second {
249        other - self
250    }
251}
252
253impl<S: CoordinateScale, F: InfallibleFormatForScale<S>> Time<S, F> {
254    /// Encoded scalar for this format (derived from split storage).
255    #[inline]
256    pub fn raw(self) -> Quantity<F::Unit> {
257        F::from_time(self)
258    }
259
260    /// Alias for [`Self::raw`].
261    #[inline]
262    pub fn quantity(self) -> Quantity<F::Unit> {
263        F::from_time(self)
264    }
265}
266
267impl<S: CoordinateScale, F: TimeFormat> Time<S, F> {
268    /// Exact-precision duration from `other` to `self`.
269    ///
270    /// Unlike the [`Sub`] implementation that returns a `Quantity<F::Unit>`
271    /// (and therefore goes through `f64`), this method projects the difference
272    /// into [`crate::ExactDuration`], which has 1 ns resolution.
273    ///
274    /// **Precision note:** `Time<S>` stores instants as a compensated split-f64
275    /// pair. Near typical astronomy epochs (e.g. J2000 ± 50 years) the ULP of
276    /// the high word is roughly 120–150 ns, so differences smaller than that
277    /// may not round-trip exactly. For sub-microsecond precision on two instants
278    /// that were originally constructed from the same `ExactDuration` arithmetic,
279    /// the compensation pair reduces the error significantly, but this is not a
280    /// guarantee of nanosecond parity for arbitrary instants.
281    ///
282    /// Returns [`crate::DurationError::Overflow`] only if the difference is
283    /// outside the i128-nanosecond range (≈ ±5.4 × 10²¹ yr), which is unreachable
284    /// for any physical astronomy use case.
285    #[inline]
286    pub fn diff_exact(self, other: Self) -> Result<crate::ExactDuration, crate::DurationError> {
287        let delta: Second = self.instant - other.instant;
288        crate::ExactDuration::try_from_quantity(delta)
289    }
290
291    /// Shift this instant by an [`crate::ExactDuration`], returning `Err` if the
292    /// duration's seconds component exceeds the `i64` range (≈ ±292 billion years).
293    ///
294    /// **Precision note:** The duration is split into a whole-second component and
295    /// a sub-second nanosecond remainder, each added to the compensated split-f64
296    /// storage separately. The whole-second part is an integer `f64` (exact for
297    /// `|seconds| < 2^53`). The nanosecond remainder crosses the split-f64 storage
298    /// boundary and is therefore bounded by the documented split-f64 precision
299    /// limits (ULP ≈ 120–150 ns near J2000 ± 50 years), so shifts smaller than
300    /// that threshold may not alter the stored instant.
301    #[inline]
302    pub fn try_add_exact(
303        self,
304        delta: crate::ExactDuration,
305    ) -> Result<Self, crate::foundation::duration::DurationError> {
306        let (whole_secs, sub_nanos) = delta.as_seconds_i64_nanos_checked()?;
307        let t = self.instant + Second::new(whole_secs as f64);
308        Ok(Self {
309            instant: t + Second::new(sub_nanos as f64 * 1e-9),
310            _fmt: PhantomData,
311        })
312    }
313
314    /// Shift this instant backward by an [`crate::ExactDuration`], returning `Err`
315    /// if the duration's seconds component exceeds the `i64` range.
316    ///
317    /// See [`Self::try_add_exact`] for precision notes.
318    #[inline]
319    pub fn try_sub_exact(
320        self,
321        delta: crate::ExactDuration,
322    ) -> Result<Self, crate::foundation::duration::DurationError> {
323        let (whole_secs, sub_nanos) = delta.as_seconds_i64_nanos_checked()?;
324        let t = self.instant - Second::new(whole_secs as f64);
325        Ok(Self {
326            instant: t - Second::new(sub_nanos as f64 * 1e-9),
327            _fmt: PhantomData,
328        })
329    }
330
331    /// Shift this instant by an [`crate::ExactDuration`].
332    ///
333    /// **Panics** if the duration's seconds component exceeds the `i64` range
334    /// (≈ ±292 billion years). Use [`try_add_exact`](Self::try_add_exact) for
335    /// the fallible variant that returns `Err` instead.
336    ///
337    /// See [`try_add_exact`](Self::try_add_exact) for precision notes.
338    #[inline]
339    pub fn add_exact(self, delta: crate::ExactDuration) -> Self {
340        self.try_add_exact(delta)
341            .expect("ExactDuration::add_exact: duration exceeds i64 seconds range")
342    }
343
344    /// Shift this instant backward by an [`crate::ExactDuration`].
345    ///
346    /// **Panics** if the duration's seconds component exceeds the `i64` range.
347    /// Use [`try_sub_exact`](Self::try_sub_exact) for the fallible variant.
348    ///
349    /// See [`try_add_exact`](Self::try_add_exact) for precision notes.
350    #[inline]
351    pub fn sub_exact(self, delta: crate::ExactDuration) -> Self {
352        self.try_sub_exact(delta)
353            .expect("ExactDuration::sub_exact: duration exceeds i64 seconds range")
354    }
355
356    /// Round this instant to the nearest multiple of `quantum` measured from
357    /// `epoch`. Banker's rounding (half-to-even) at the quantum boundary.
358    /// Returns `self` unchanged on overflow.
359    pub fn round_to_epoch(self, epoch: Self, quantum: crate::ExactDuration) -> Self {
360        match self.diff_exact(epoch) {
361            Ok(d) => epoch.add_exact(d.round_to(quantum)),
362            Err(_) => self,
363        }
364    }
365
366    /// Floor this instant toward `epoch − ∞` at `quantum`.
367    pub fn floor_to_epoch(self, epoch: Self, quantum: crate::ExactDuration) -> Self {
368        match self.diff_exact(epoch) {
369            Ok(d) => epoch.add_exact(d.floor_to(quantum)),
370            Err(_) => self,
371        }
372    }
373
374    /// Ceil this instant toward `epoch + ∞` at `quantum`.
375    pub fn ceil_to_epoch(self, epoch: Self, quantum: crate::ExactDuration) -> Self {
376        match self.diff_exact(epoch) {
377            Ok(d) => epoch.add_exact(d.ceil_to(quantum)),
378            Err(_) => self,
379        }
380    }
381}
382
383impl<S: CoordinateScale, F> Time<S, F>
384where
385    F: FormatForScale<S>,
386{
387    #[inline]
388    pub fn try_raw_with(self, ctx: &TimeContext) -> Result<Quantity<F::Unit>, ConversionError> {
389        F::try_from_time(self, ctx)
390    }
391}
392
393impl<S: Scale, F: TimeFormat> Time<S, F> {
394    /// Unified infallible conversion to a scale/view target.
395    #[allow(private_bounds)]
396    #[inline]
397    pub fn to<T>(self) -> T::Output
398    where
399        T: InfallibleConversionTarget<S, F>,
400    {
401        T::convert(self)
402    }
403
404    /// Unified fallible conversion to a scale/view target.
405    #[allow(private_bounds)]
406    #[inline]
407    pub fn try_to<T>(self) -> Result<T::Output, ConversionError>
408    where
409        T: ConversionTarget<S, F>,
410    {
411        T::try_convert(self)
412    }
413
414    /// Unified context-backed conversion to a scale/view target.
415    #[allow(private_bounds)]
416    #[inline]
417    pub fn to_with<T>(self, ctx: &TimeContext) -> Result<T::Output, ConversionError>
418    where
419        T: ContextConversionTarget<S, F>,
420    {
421        T::convert_with(self, ctx)
422    }
423
424    /// Infallible scale conversion; preserves format tag `F`.
425    #[allow(private_bounds)]
426    #[inline]
427    pub fn to_scale<S2: Scale>(self) -> Time<S2, F>
428    where
429        S: InfallibleScaleConvert<S2>,
430    {
431        let (hi, lo) = self.split_seconds();
432        let (new_hi, new_lo) = <S as InfallibleScaleConvert<S2>>::convert(hi, lo);
433        Time::from_split(new_hi, new_lo)
434    }
435
436    /// Context-required scale conversion (UT1 routes); preserves `F`.
437    #[allow(private_bounds)]
438    #[inline]
439    pub fn to_scale_with<S2: Scale>(self, ctx: &TimeContext) -> Result<Time<S2, F>, ConversionError>
440    where
441        S: ContextScaleConvert<S2>,
442    {
443        let (hi, lo) = self.split_seconds();
444        let (new_hi, new_lo) = <S as ContextScaleConvert<S2>>::convert_with(hi, lo, ctx)?;
445        Ok(Time::from_split(new_hi, new_lo))
446    }
447}
448
449impl<S: Scale, F: FormatForScale<S>> Time<S, F> {
450    /// Fallible constructor from an encoded scalar.
451    ///
452    /// Only surfaces **domain** failures from format decoding (UTC policy, leap seconds, ranges, …).
453    /// Scalar hygiene is a caller precondition: **NaN must not be passed**; ±∞ is accepted only where the format decoder tolerates it.
454    #[inline]
455    pub fn try_new(raw: Quantity<F::Unit>) -> Result<Self, ConversionError> {
456        F::try_into_time(raw, &TimeContext::new())
457    }
458
459    /// Like [`Self::try_new`], but uses `ctx` for UTC / POSIX decoding policy.
460    #[inline]
461    pub fn try_new_with(
462        raw: Quantity<F::Unit>,
463        ctx: &TimeContext,
464    ) -> Result<Self, ConversionError> {
465        F::try_into_time(raw, ctx)
466    }
467}
468
469impl<S: Scale, F: InfallibleFormatForScale<S>> Time<S, F> {
470    /// Infallible constructor from the raw scalar value for format `F`.
471    ///
472    /// # Panics
473    ///
474    /// If `value` is **NaN**. ±∞ is allowed as storage when callers use sentinel instants.
475    #[track_caller]
476    #[inline]
477    pub fn new(value: f64) -> Self {
478        assert!(
479            !value.is_nan(),
480            "time scalar must not be NaN (±∞ is allowed)"
481        );
482        F::into_time(Quantity::<F::Unit>::new(value))
483    }
484}
485
486impl<S: CoordinateScale, F: InfallibleFormatForScale<S>> Time<S, F> {
487    #[inline]
488    pub fn min(self, other: Self) -> Self {
489        if self <= other {
490            self
491        } else {
492            other
493        }
494    }
495
496    #[inline]
497    pub fn max(self, other: Self) -> Self {
498        if self >= other {
499            self
500        } else {
501            other
502        }
503    }
504
505    #[inline]
506    pub fn mean(self, other: Self) -> Self {
507        let t = self.to_j2000s() + ((other.to_j2000s() - self.to_j2000s()) * 0.5);
508        t.reinterpret()
509    }
510}
511
512/// TT Julian date at J2000.0 (`JD 2 451 545.0`); matches [`Self::jd_epoch_tt`], usable in `const`.
513impl Time<TT, crate::format::JD> {
514    pub const JD_EPOCH_J2000_0: Self = Self {
515        instant: SplitPoint1::from_split(SplitQuantity::from_normalized_parts(
516            Second::new(0.0),
517            Second::new(0.0),
518        )),
519        _fmt: PhantomData,
520    };
521}
522
523impl<S: Scale> Time<S, crate::format::JD> {
524    /// TT J2000.0 as a Julian Date on scale `S` (JD 2 451 545.0).
525    #[inline]
526    pub fn jd_epoch_tt() -> Self
527    where
528        S: CoordinateScale,
529    {
530        Time::<S, J2000s>::from_raw_j2000_seconds(Second::new(0.0))
531            .expect("J2000 origin")
532            .reinterpret()
533    }
534
535    #[inline]
536    pub fn value(self) -> f64
537    where
538        S: CoordinateScale,
539    {
540        self.raw().value()
541    }
542
543    #[inline]
544    pub fn julian_centuries(self) -> f64
545    where
546        S: CoordinateScale,
547    {
548        jd_to_julian_centuries(self.raw())
549    }
550}
551
552impl<S: Scale> Time<S, crate::format::MJD> {
553    #[inline]
554    pub fn value(self) -> f64
555    where
556        S: CoordinateScale,
557    {
558        self.raw().value()
559    }
560}
561
562impl<S: CoordinateScale, F, U> Add<Quantity<U>> for Time<S, F>
563where
564    F: InfallibleFormatForScale<S>,
565    U: TimeUnit,
566{
567    type Output = Self;
568
569    #[inline]
570    fn add(self, rhs: Quantity<U>) -> Self::Output {
571        Self {
572            instant: self.instant + rhs.to::<qtty::unit::Second>(),
573            _fmt: PhantomData,
574        }
575    }
576}
577
578impl<S: CoordinateScale, F, U> Sub<Quantity<U>> for Time<S, F>
579where
580    F: InfallibleFormatForScale<S>,
581    U: TimeUnit,
582{
583    type Output = Self;
584
585    #[inline]
586    fn sub(self, rhs: Quantity<U>) -> Self::Output {
587        Self {
588            instant: self.instant - rhs.to::<qtty::unit::Second>(),
589            _fmt: PhantomData,
590        }
591    }
592}
593
594impl<S: CoordinateScale, F> Sub for Time<S, F>
595where
596    F: InfallibleFormatForScale<S>,
597    F::Unit: TimeUnit,
598{
599    type Output = Quantity<F::Unit>;
600
601    #[inline]
602    fn sub(self, rhs: Self) -> Self::Output {
603        let delta: Second = self.instant - rhs.instant;
604        delta.to::<F::Unit>()
605    }
606}
607
608impl<S: CoordinateScale, F, U> AddAssign<Quantity<U>> for Time<S, F>
609where
610    F: InfallibleFormatForScale<S>,
611    U: TimeUnit,
612{
613    #[inline]
614    fn add_assign(&mut self, rhs: Quantity<U>) {
615        *self = *self + rhs;
616    }
617}
618
619impl<S: CoordinateScale, F, U> SubAssign<Quantity<U>> for Time<S, F>
620where
621    F: InfallibleFormatForScale<S>,
622    U: TimeUnit,
623{
624    #[inline]
625    fn sub_assign(&mut self, rhs: Quantity<U>) {
626        *self = *self - rhs;
627    }
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633    use crate::format::J2000s;
634    use crate::foundation::duration::ExactDuration;
635    use crate::model::scale::TAI;
636
637    type TaiJ2000 = Time<TAI, J2000s>;
638
639    fn j2000_tai() -> TaiJ2000 {
640        TaiJ2000::from_raw_j2000_seconds(Second::new(0.0)).unwrap()
641    }
642
643    fn j2000_tai_plus_50yr() -> TaiJ2000 {
644        // 50 Julian years = 50 * 365.25 * 86400 = 1_577_836_800 s
645        TaiJ2000::from_raw_j2000_seconds(Second::new(1_577_836_800.0)).unwrap()
646    }
647
648    #[test]
649    fn add_exact_1ns_at_j2000() {
650        let t = j2000_tai();
651        let d = ExactDuration::from_nanos(1);
652        let shifted = t.add_exact(d);
653        let diff = shifted.diff_exact(t).unwrap();
654        // Near J2000 hi ≈ 0; lo stores the 1 ns shift exactly.
655        assert_eq!(diff.as_nanos_i128(), 1, "1 ns shift at J2000 must be exact");
656    }
657
658    #[test]
659    fn add_sub_round_trip_1ns_at_j2000_plus_50yr() {
660        let t = j2000_tai_plus_50yr();
661        // 1 ns: ULP of hi at 1.57e9 s is ~240 ns, so the lo word carries it.
662        for ns in [1_i128, 123, 999] {
663            let d = ExactDuration::from_nanos(ns);
664            let shifted = t.add_exact(d).sub_exact(d);
665            let back = shifted.diff_exact(t).unwrap();
666            assert!(
667                back.as_nanos_i128().abs() < 100,
668                "add/sub round-trip drift at J2000+50yr for {ns} ns: {} ns",
669                back.as_nanos_i128()
670            );
671        }
672    }
673
674    #[test]
675    fn add_exact_1yr_plus_1ns_preserves_1ns() {
676        let t = j2000_tai();
677        // 1 Julian year = 31_557_600 s
678        let one_year = ExactDuration::from_nanos(31_557_600 * 1_000_000_000);
679        let one_ns = ExactDuration::from_nanos(1);
680        let combined = (one_year + one_ns)
681            .checked_add(ExactDuration::ZERO)
682            .unwrap();
683        let d_year = t.add_exact(one_year);
684        let d_combined = t.add_exact(combined);
685        let diff = d_combined.diff_exact(d_year).unwrap();
686        // The difference should be 1 ns; allow up to 2 ns for sub-nanosecond f64 rounding.
687        assert!(
688            diff.as_nanos_i128().abs() <= 2,
689            "1 yr + 1 ns shift must preserve 1 ns component; diff = {} ns",
690            diff.as_nanos_i128()
691        );
692    }
693
694    #[test]
695    fn try_add_exact_overflow_returns_err() {
696        let t = j2000_tai();
697        // ExactDuration::MAX has > i64::MAX seconds → try_add_exact must return Err.
698        let result = t.try_add_exact(ExactDuration::MAX);
699        assert!(
700            result.is_err(),
701            "expected Err for try_add_exact(MAX), got Ok"
702        );
703        let result2 = t.try_sub_exact(ExactDuration::MAX);
704        assert!(
705            result2.is_err(),
706            "expected Err for try_sub_exact(MAX), got Ok"
707        );
708    }
709
710    #[test]
711    #[should_panic(expected = "ExactDuration::add_exact")]
712    fn add_exact_panics_on_overflow() {
713        let t = j2000_tai();
714        let _ = t.add_exact(ExactDuration::MAX);
715    }
716}