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: TimeFormat> Time<S, F> {
269    /// Exact-precision duration from `other` to `self`.
270    ///
271    /// Unlike the [`Sub`] implementation that returns a `Quantity<F::Unit>`
272    /// (and therefore goes through `f64`), this method projects the difference
273    /// into [`crate::ExactDuration`], which has 1 ns resolution.
274    ///
275    /// **Precision note:** `Time<S>` stores instants as a compensated split-f64
276    /// pair. Near typical astronomy epochs (e.g. J2000 ± 50 years) the ULP of
277    /// the high word is roughly 120–150 ns, so differences smaller than that
278    /// may not round-trip exactly. For sub-microsecond precision on two instants
279    /// that were originally constructed from the same `ExactDuration` arithmetic,
280    /// the compensation pair reduces the error significantly, but this is not a
281    /// guarantee of nanosecond parity for arbitrary instants.
282    ///
283    /// Returns [`crate::DurationError::Overflow`] only if the difference is
284    /// outside the i128-nanosecond range (≈ ±5.4 × 10²¹ yr), which is unreachable
285    /// for any physical astronomy use case.
286    #[inline]
287    pub fn diff_exact(self, other: Self) -> Result<crate::ExactDuration, crate::DurationError> {
288        let delta: Second = self.instant - other.instant;
289        crate::ExactDuration::try_from_quantity(delta)
290    }
291
292    /// Shift this instant by an [`crate::ExactDuration`], returning `Err` if the
293    /// duration's seconds component exceeds the `i64` range (≈ ±292 billion years).
294    ///
295    /// **Precision note:** The duration is split into a whole-second component and
296    /// a sub-second nanosecond remainder, each added to the compensated split-f64
297    /// storage separately. The whole-second part is an integer `f64` (exact for
298    /// `|seconds| < 2^53`). The nanosecond remainder crosses the split-f64 storage
299    /// boundary and is therefore bounded by the documented split-f64 precision
300    /// limits (ULP ≈ 120–150 ns near J2000 ± 50 years), so shifts smaller than
301    /// that threshold may not alter the stored instant.
302    #[inline]
303    pub fn try_add_exact(
304        self,
305        delta: crate::ExactDuration,
306    ) -> Result<Self, crate::foundation::duration::DurationError> {
307        let (whole_secs, sub_nanos) = delta.as_seconds_i64_nanos_checked()?;
308        let t = self.instant + Second::new(whole_secs as f64);
309        Ok(Self {
310            instant: t + Second::new(sub_nanos as f64 * 1e-9),
311            _fmt: PhantomData,
312        })
313    }
314
315    /// Shift this instant backward by an [`crate::ExactDuration`], returning `Err`
316    /// if the duration's seconds component exceeds the `i64` range.
317    ///
318    /// See [`Self::try_add_exact`] for precision notes.
319    #[inline]
320    pub fn try_sub_exact(
321        self,
322        delta: crate::ExactDuration,
323    ) -> Result<Self, crate::foundation::duration::DurationError> {
324        let (whole_secs, sub_nanos) = delta.as_seconds_i64_nanos_checked()?;
325        let t = self.instant - Second::new(whole_secs as f64);
326        Ok(Self {
327            instant: t - Second::new(sub_nanos as f64 * 1e-9),
328            _fmt: PhantomData,
329        })
330    }
331
332    /// Shift this instant by an [`crate::ExactDuration`].
333    ///
334    /// **Panics** if the duration's seconds component exceeds the `i64` range
335    /// (≈ ±292 billion years). Use [`try_add_exact`](Self::try_add_exact) for
336    /// the fallible variant that returns `Err` instead.
337    ///
338    /// See [`try_add_exact`](Self::try_add_exact) for precision notes.
339    #[inline]
340    pub fn add_exact(self, delta: crate::ExactDuration) -> Self {
341        self.try_add_exact(delta)
342            .expect("ExactDuration::add_exact: duration exceeds i64 seconds range")
343    }
344
345    /// Shift this instant backward by an [`crate::ExactDuration`].
346    ///
347    /// **Panics** if the duration's seconds component exceeds the `i64` range.
348    /// Use [`try_sub_exact`](Self::try_sub_exact) for the fallible variant.
349    ///
350    /// See [`try_add_exact`](Self::try_add_exact) for precision notes.
351    #[inline]
352    pub fn sub_exact(self, delta: crate::ExactDuration) -> Self {
353        self.try_sub_exact(delta)
354            .expect("ExactDuration::sub_exact: duration exceeds i64 seconds range")
355    }
356
357    /// Round this instant to the nearest multiple of `quantum` measured from
358    /// `epoch`. Banker's rounding (half-to-even) at the quantum boundary.
359    /// Returns `self` unchanged on overflow.
360    pub fn round_to_epoch(self, epoch: Self, quantum: crate::ExactDuration) -> Self {
361        match self.diff_exact(epoch) {
362            Ok(d) => epoch.add_exact(d.round_to(quantum)),
363            Err(_) => self,
364        }
365    }
366
367    /// Floor this instant toward `epoch − ∞` at `quantum`.
368    pub fn floor_to_epoch(self, epoch: Self, quantum: crate::ExactDuration) -> Self {
369        match self.diff_exact(epoch) {
370            Ok(d) => epoch.add_exact(d.floor_to(quantum)),
371            Err(_) => self,
372        }
373    }
374
375    /// Ceil this instant toward `epoch + ∞` at `quantum`.
376    pub fn ceil_to_epoch(self, epoch: Self, quantum: crate::ExactDuration) -> Self {
377        match self.diff_exact(epoch) {
378            Ok(d) => epoch.add_exact(d.ceil_to(quantum)),
379            Err(_) => self,
380        }
381    }
382}
383
384impl<S: CoordinateScale, F> Time<S, F>
385where
386    F: FormatForScale<S>,
387{
388    #[inline]
389    pub fn try_raw_with(self, ctx: &TimeContext) -> Result<Quantity<F::Unit>, ConversionError> {
390        F::try_from_time(self, ctx)
391    }
392}
393
394impl<S: Scale, F: TimeFormat> Time<S, F> {
395    /// Unified infallible conversion to a scale/view target.
396    #[allow(private_bounds)]
397    #[inline]
398    pub fn to<T>(self) -> T::Output
399    where
400        T: InfallibleConversionTarget<S, F>,
401    {
402        T::convert(self)
403    }
404
405    /// Unified fallible conversion to a scale/view target.
406    #[allow(private_bounds)]
407    #[inline]
408    pub fn try_to<T>(self) -> Result<T::Output, ConversionError>
409    where
410        T: ConversionTarget<S, F>,
411    {
412        T::try_convert(self)
413    }
414
415    /// Unified context-backed conversion to a scale/view target.
416    #[allow(private_bounds)]
417    #[inline]
418    pub fn to_with<T>(self, ctx: &TimeContext) -> Result<T::Output, ConversionError>
419    where
420        T: ContextConversionTarget<S, F>,
421    {
422        T::convert_with(self, ctx)
423    }
424
425    /// Infallible scale conversion; preserves format tag `F`.
426    #[allow(private_bounds)]
427    #[inline]
428    pub fn to_scale<S2: Scale>(self) -> Time<S2, F>
429    where
430        S: InfallibleScaleConvert<S2>,
431    {
432        let (hi, lo) = self.split_seconds();
433        let (new_hi, new_lo) = <S as InfallibleScaleConvert<S2>>::convert(hi, lo);
434        Time::from_split(new_hi, new_lo)
435    }
436
437    /// Context-required scale conversion (UT1 routes); preserves `F`.
438    #[allow(private_bounds)]
439    #[inline]
440    pub fn to_scale_with<S2: Scale>(self, ctx: &TimeContext) -> Result<Time<S2, F>, ConversionError>
441    where
442        S: ContextScaleConvert<S2>,
443    {
444        let (hi, lo) = self.split_seconds();
445        let (new_hi, new_lo) = <S as ContextScaleConvert<S2>>::convert_with(hi, lo, ctx)?;
446        Ok(Time::from_split(new_hi, new_lo))
447    }
448}
449
450impl<S: Scale, F: FormatForScale<S>> Time<S, F> {
451    /// Fallible constructor from an encoded scalar.
452    ///
453    /// Only surfaces **domain** failures from format decoding (UTC policy, leap seconds, ranges, …).
454    /// Scalar hygiene is a caller precondition: **NaN must not be passed**; ±∞ is accepted only where the format decoder tolerates it.
455    #[inline]
456    pub fn try_new(raw: Quantity<F::Unit>) -> Result<Self, ConversionError> {
457        F::try_into_time(raw, &TimeContext::new())
458    }
459
460    /// Like [`Self::try_new`], but uses `ctx` for UTC / POSIX decoding policy.
461    #[inline]
462    pub fn try_new_with(
463        raw: Quantity<F::Unit>,
464        ctx: &TimeContext,
465    ) -> Result<Self, ConversionError> {
466        F::try_into_time(raw, ctx)
467    }
468}
469
470impl<S: Scale, F: InfallibleFormatForScale<S>> Time<S, F> {
471    /// Infallible constructor from the raw scalar value for format `F`.
472    ///
473    /// # Panics
474    ///
475    /// If `value` is **NaN**. ±∞ is allowed as storage when callers use sentinel instants.
476    #[track_caller]
477    #[inline]
478    pub fn new(value: f64) -> Self {
479        assert!(
480            !value.is_nan(),
481            "time scalar must not be NaN (±∞ is allowed)"
482        );
483        F::into_time(Quantity::<F::Unit>::new(value))
484    }
485}
486
487impl<S: CoordinateScale, F: InfallibleFormatForScale<S>> Time<S, F> {
488    #[inline]
489    pub fn min(self, other: Self) -> Self {
490        if self <= other {
491            self
492        } else {
493            other
494        }
495    }
496
497    #[inline]
498    pub fn max(self, other: Self) -> Self {
499        if self >= other {
500            self
501        } else {
502            other
503        }
504    }
505
506    #[inline]
507    pub fn mean(self, other: Self) -> Self {
508        let t = self.to_j2000s() + ((other.to_j2000s() - self.to_j2000s()) * 0.5);
509        t.reinterpret()
510    }
511}
512
513/// TT Julian date at J2000.0 (`JD 2 451 545.0`); matches [`Self::jd_epoch_tt`], usable in `const`.
514impl Time<TT, crate::format::JD> {
515    pub const JD_EPOCH_J2000_0: Self = Self {
516        instant: SplitPoint1::from_split(SplitQuantity::from_normalized_parts(
517            Second::new(0.0),
518            Second::new(0.0),
519        )),
520        _fmt: PhantomData,
521    };
522}
523
524impl<S: Scale> Time<S, crate::format::JD> {
525    /// TT J2000.0 as a Julian Date on scale `S` (JD 2 451 545.0).
526    #[inline]
527    pub fn jd_epoch_tt() -> Self
528    where
529        S: CoordinateScale,
530    {
531        Time::<S, J2000s>::from_raw_j2000_seconds(Second::new(0.0))
532            .expect("J2000 origin")
533            .reinterpret()
534    }
535
536    #[inline]
537    pub fn value(self) -> f64
538    where
539        S: CoordinateScale,
540    {
541        self.raw().value()
542    }
543
544    #[inline]
545    pub fn julian_centuries(self) -> f64
546    where
547        S: CoordinateScale,
548    {
549        jd_to_julian_centuries(self.raw())
550    }
551}
552
553impl<S: Scale> Time<S, crate::format::MJD> {
554    #[inline]
555    pub fn value(self) -> f64
556    where
557        S: CoordinateScale,
558    {
559        self.raw().value()
560    }
561}
562
563impl<S: CoordinateScale, F, U> Add<Quantity<U>> for Time<S, F>
564where
565    F: InfallibleFormatForScale<S>,
566    U: TimeUnit,
567{
568    type Output = Self;
569
570    #[inline]
571    fn add(self, rhs: Quantity<U>) -> Self::Output {
572        Self {
573            instant: self.instant + rhs.to::<SecondUnit>(),
574            _fmt: PhantomData,
575        }
576    }
577}
578
579impl<S: CoordinateScale, F, U> Sub<Quantity<U>> for Time<S, F>
580where
581    F: InfallibleFormatForScale<S>,
582    U: TimeUnit,
583{
584    type Output = Self;
585
586    #[inline]
587    fn sub(self, rhs: Quantity<U>) -> Self::Output {
588        Self {
589            instant: self.instant - rhs.to::<SecondUnit>(),
590            _fmt: PhantomData,
591        }
592    }
593}
594
595impl<S: CoordinateScale, F> Sub for Time<S, F>
596where
597    F: InfallibleFormatForScale<S>,
598    F::Unit: TimeUnit,
599{
600    type Output = Quantity<F::Unit>;
601
602    #[inline]
603    fn sub(self, rhs: Self) -> Self::Output {
604        let delta: Second = self.instant - rhs.instant;
605        delta.to::<F::Unit>()
606    }
607}
608
609impl<S: CoordinateScale, F, U> AddAssign<Quantity<U>> for Time<S, F>
610where
611    F: InfallibleFormatForScale<S>,
612    U: TimeUnit,
613{
614    #[inline]
615    fn add_assign(&mut self, rhs: Quantity<U>) {
616        *self = *self + rhs;
617    }
618}
619
620impl<S: CoordinateScale, F, U> SubAssign<Quantity<U>> for Time<S, F>
621where
622    F: InfallibleFormatForScale<S>,
623    U: TimeUnit,
624{
625    #[inline]
626    fn sub_assign(&mut self, rhs: Quantity<U>) {
627        *self = *self - rhs;
628    }
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634    use crate::format::J2000s;
635    use crate::foundation::duration::ExactDuration;
636    use crate::model::scale::TAI;
637
638    type TaiJ2000 = Time<TAI, J2000s>;
639
640    fn j2000_tai() -> TaiJ2000 {
641        TaiJ2000::from_raw_j2000_seconds(Second::new(0.0)).unwrap()
642    }
643
644    fn j2000_tai_plus_50yr() -> TaiJ2000 {
645        // 50 Julian years = 50 * 365.25 * 86400 = 1_577_836_800 s
646        TaiJ2000::from_raw_j2000_seconds(Second::new(1_577_836_800.0)).unwrap()
647    }
648
649    #[test]
650    fn add_exact_1ns_at_j2000() {
651        let t = j2000_tai();
652        let d = ExactDuration::from_nanos(1);
653        let shifted = t.add_exact(d);
654        let diff = shifted.diff_exact(t).unwrap();
655        // Near J2000 hi ≈ 0; lo stores the 1 ns shift exactly.
656        assert_eq!(diff.as_nanos_i128(), 1, "1 ns shift at J2000 must be exact");
657    }
658
659    #[test]
660    fn add_sub_round_trip_1ns_at_j2000_plus_50yr() {
661        let t = j2000_tai_plus_50yr();
662        // 1 ns: ULP of hi at 1.57e9 s is ~240 ns, so the lo word carries it.
663        for ns in [1_i128, 123, 999] {
664            let d = ExactDuration::from_nanos(ns);
665            let shifted = t.add_exact(d).sub_exact(d);
666            let back = shifted.diff_exact(t).unwrap();
667            assert!(
668                back.as_nanos_i128().abs() < 100,
669                "add/sub round-trip drift at J2000+50yr for {ns} ns: {} ns",
670                back.as_nanos_i128()
671            );
672        }
673    }
674
675    #[test]
676    fn add_exact_1yr_plus_1ns_preserves_1ns() {
677        let t = j2000_tai();
678        // 1 Julian year = 31_557_600 s
679        let one_year = ExactDuration::from_nanos(31_557_600 * 1_000_000_000);
680        let one_ns = ExactDuration::from_nanos(1);
681        let combined = (one_year + one_ns)
682            .checked_add(ExactDuration::ZERO)
683            .unwrap();
684        let d_year = t.add_exact(one_year);
685        let d_combined = t.add_exact(combined);
686        let diff = d_combined.diff_exact(d_year).unwrap();
687        // The difference should be 1 ns; allow up to 2 ns for sub-nanosecond f64 rounding.
688        assert!(
689            diff.as_nanos_i128().abs() <= 2,
690            "1 yr + 1 ns shift must preserve 1 ns component; diff = {} ns",
691            diff.as_nanos_i128()
692        );
693    }
694
695    #[test]
696    fn try_add_exact_overflow_returns_err() {
697        let t = j2000_tai();
698        // ExactDuration::MAX has > i64::MAX seconds → try_add_exact must return Err.
699        let result = t.try_add_exact(ExactDuration::MAX);
700        assert!(
701            result.is_err(),
702            "expected Err for try_add_exact(MAX), got Ok"
703        );
704        let result2 = t.try_sub_exact(ExactDuration::MAX);
705        assert!(
706            result2.is_err(),
707            "expected Err for try_sub_exact(MAX), got Ok"
708        );
709    }
710
711    #[test]
712    #[should_panic(expected = "ExactDuration::add_exact")]
713    fn add_exact_panics_on_overflow() {
714        let t = j2000_tai();
715        let _ = t.add_exact(ExactDuration::MAX);
716    }
717}