Skip to main content

tempoch_core/
instant.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (C) 2026 Vallés Puig, Ramon
3
4//! Generic time–scale parameterised instant.
5//!
6//! [`Time<S>`] is the core type of the time module.  It stores a scalar
7//! quantity in [`Days`] whose *meaning* is determined by the compile-time
8//! marker `S: TimeScale`.  All arithmetic (addition/subtraction of
9//! durations, difference between instants), UTC conversion, serialisation,
10//! and display are implemented generically — no code duplication.
11//!
12//! Domain-specific methods that only make sense for a particular scale
13//! (e.g. [`Time::<JD>::julian_centuries()`]) are placed in inherent `impl`
14//! blocks gated on the concrete marker type.
15
16use chrono::{DateTime, Utc};
17use qtty::*;
18use std::marker::PhantomData;
19use std::ops::{Add, AddAssign, Sub, SubAssign};
20
21#[cfg(feature = "serde")]
22use serde::{Deserialize, Deserializer, Serialize, Serializer};
23
24// ═══════════════════════════════════════════════════════════════════════════
25// TimeScale trait
26// ═══════════════════════════════════════════════════════════════════════════
27
28/// Marker trait for time scales.
29///
30/// A **time scale** defines:
31///
32/// 1. A human-readable **label** (e.g. `"JD"`, `"MJD"`, `"TAI"`).
33/// 2. A pair of conversion functions between the scale's native quantity
34///    (in [`Days`]) and **Julian Date in TT** (JD(TT)) — the canonical
35///    internal representation used throughout the crate.
36///
37/// For pure *epoch counters* (JD, MJD, Unix Time, GPS) the conversions are
38/// trivial constant offsets that the compiler will inline and fold away.
39///
40/// For *physical scales* (TT, TDB, TAI) the conversions may include
41/// function-based corrections (e.g. the ≈1.7 ms TDB↔TT periodic term).
42pub trait TimeScale: Copy + Clone + std::fmt::Debug + PartialEq + PartialOrd + 'static {
43    /// Display label used by [`Time`] formatting.
44    const LABEL: &'static str;
45
46    /// Convert a quantity in this scale's native unit to an absolute JD(TT).
47    fn to_jd_tt(value: Days) -> Days;
48
49    /// Convert an absolute JD(TT) back to this scale's native quantity.
50    fn from_jd_tt(jd_tt: Days) -> Days;
51}
52
53// ═══════════════════════════════════════════════════════════════════════════
54// Error types
55// ═══════════════════════════════════════════════════════════════════════════
56
57/// Error returned when a `Time` value is non-finite (`NaN` or `±∞`).
58///
59/// Non-finite values break ordering, intersection, and arithmetic invariants,
60/// so validated constructors ([`Time::try_new`], [`Time::try_from_days`])
61/// reject them.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct NonFiniteTimeError;
64
65impl std::fmt::Display for NonFiniteTimeError {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        write!(f, "time value must be finite (not NaN or infinity)")
68    }
69}
70
71impl std::error::Error for NonFiniteTimeError {}
72
73// ═══════════════════════════════════════════════════════════════════════════
74// Time<S> — the generic instant
75// ═══════════════════════════════════════════════════════════════════════════
76
77/// A point on time scale `S`.
78///
79/// Internally stores a single `Days` quantity whose interpretation depends on
80/// `S: TimeScale`.  The struct is `Copy` and zero-cost: `PhantomData` is
81/// zero-sized, so `Time<S>` is layout-identical to `Days` (a single `f64`).
82#[derive(Debug, Copy, Clone, PartialEq, PartialOrd)]
83pub struct Time<S: TimeScale> {
84    quantity: Days,
85    _scale: PhantomData<S>,
86}
87
88impl<S: TimeScale> Time<S> {
89    // ── constructors ──────────────────────────────────────────────────
90
91    /// Create from a raw scalar (days since the scale's epoch).
92    ///
93    /// **Note:** this constructor accepts any `f64`, including `NaN` and `±∞`.
94    /// Prefer [`try_new`](Self::try_new) when the value comes from untrusted
95    /// or computed input.
96    #[inline]
97    pub const fn new(value: f64) -> Self {
98        Self {
99            quantity: Days::new(value),
100            _scale: PhantomData,
101        }
102    }
103
104    /// Create from a raw scalar, rejecting non-finite values.
105    ///
106    /// Returns [`NonFiniteTimeError`] if `value` is `NaN`, `+∞`, or `−∞`.
107    ///
108    /// # Examples
109    ///
110    /// ```
111    /// # use tempoch_core as tempoch;
112    /// use tempoch::{Time, JD};
113    ///
114    /// assert!(Time::<JD>::try_new(2451545.0).is_ok());
115    /// assert!(Time::<JD>::try_new(f64::NAN).is_err());
116    /// assert!(Time::<JD>::try_new(f64::INFINITY).is_err());
117    /// ```
118    #[inline]
119    pub fn try_new(value: f64) -> Result<Self, NonFiniteTimeError> {
120        if value.is_finite() {
121            Ok(Self::new(value))
122        } else {
123            Err(NonFiniteTimeError)
124        }
125    }
126
127    /// Create from a [`Days`] quantity.
128    ///
129    /// **Note:** this constructor accepts any `f64`, including `NaN` and `±∞`.
130    /// Prefer [`try_from_days`](Self::try_from_days) when the value comes from
131    /// untrusted or computed input.
132    #[inline]
133    pub const fn from_days(days: Days) -> Self {
134        Self {
135            quantity: days,
136            _scale: PhantomData,
137        }
138    }
139
140    /// Create from a [`Days`] quantity, rejecting non-finite values.
141    ///
142    /// Returns [`NonFiniteTimeError`] if the underlying value is `NaN`,
143    /// `+∞`, or `−∞`.
144    #[inline]
145    pub fn try_from_days(days: Days) -> Result<Self, NonFiniteTimeError> {
146        Self::try_new(days.value())
147    }
148
149    // ── accessors ─────────────────────────────────────────────────────
150
151    /// The underlying quantity in days.
152    #[inline]
153    pub const fn quantity(&self) -> Days {
154        self.quantity
155    }
156
157    /// The underlying scalar value in days.
158    #[inline]
159    pub const fn value(&self) -> f64 {
160        self.quantity.value()
161    }
162
163    /// Absolute Julian Day (TT) corresponding to this instant.
164    #[inline]
165    pub fn julian_day(&self) -> Days {
166        S::to_jd_tt(self.quantity)
167    }
168
169    /// Absolute Julian Day (TT) as scalar.
170    #[inline]
171    pub fn julian_day_value(&self) -> f64 {
172        self.julian_day().value()
173    }
174
175    /// Build an instant from an absolute Julian Day (TT).
176    #[inline]
177    pub fn from_julian_day(jd: Days) -> Self {
178        Self::from_days(S::from_jd_tt(jd))
179    }
180
181    // ── cross-scale conversion (mirroring qtty's .to::<T>()) ─────────
182
183    /// Convert this instant to another time scale.
184    ///
185    /// The conversion routes through the canonical JD(TT) intermediate:
186    ///
187    /// ```text
188    /// self → JD(TT) → target
189    /// ```
190    ///
191    /// For pure epoch-offset scales this compiles down to a single
192    /// addition/subtraction.
193    #[inline]
194    pub fn to<T: TimeScale>(&self) -> Time<T> {
195        Time::<T>::from_julian_day(S::to_jd_tt(self.quantity))
196    }
197
198    // ── UTC helpers ───────────────────────────────────────────────────
199
200    /// Convert to a `chrono::DateTime<Utc>`.
201    ///
202    /// Inverts the ΔT correction to recover the UTC / UT timestamp.
203    /// Returns `None` if the value falls outside chrono's representable range.
204    pub fn to_utc(&self) -> Option<DateTime<Utc>> {
205        use super::scales::UT;
206        const UNIX_EPOCH_JD: f64 = 2_440_587.5;
207        let jd_ut = self.to::<UT>().quantity();
208        let seconds_since_epoch = (jd_ut - Days::new(UNIX_EPOCH_JD)).to::<Second>().value();
209        let secs = seconds_since_epoch.floor() as i64;
210        let nanos = ((seconds_since_epoch - secs as f64) * 1e9) as u32;
211        DateTime::<Utc>::from_timestamp(secs, nanos)
212    }
213
214    /// Build an instant from a `chrono::DateTime<Utc>`.
215    ///
216    /// The UTC timestamp is interpreted as Universal Time (≈ UT1) and the
217    /// epoch-dependent **ΔT** correction is applied automatically, so the
218    /// resulting `Time<S>` is on the target scale's axis.
219    pub fn from_utc(datetime: DateTime<Utc>) -> Self {
220        use super::scales::UT;
221        const UNIX_EPOCH_JD: f64 = 2_440_587.5;
222        let seconds_since_epoch = Seconds::new(datetime.timestamp() as f64);
223        let nanos = Seconds::new(datetime.timestamp_subsec_nanos() as f64 / 1e9);
224        let jd_ut = Days::new(UNIX_EPOCH_JD) + (seconds_since_epoch + nanos).to::<Day>();
225        Time::<UT>::from_days(jd_ut).to::<S>()
226    }
227
228    // ── min / max ─────────────────────────────────────────────────────
229
230    /// Element-wise minimum.
231    #[inline]
232    pub const fn min(self, other: Self) -> Self {
233        Self::from_days(self.quantity.min_const(other.quantity))
234    }
235
236    /// Element-wise maximum.
237    #[inline]
238    pub const fn max(self, other: Self) -> Self {
239        Self::from_days(self.quantity.max_const(other.quantity))
240    }
241
242    /// Mean (midpoint) between two instants on the same time scale.
243    #[inline]
244    pub const fn mean(self, other: Self) -> Self {
245        Self::from_days(self.quantity.const_add(other.quantity).const_div(2.0))
246    }
247}
248
249// ═══════════════════════════════════════════════════════════════════════════
250// Generic trait implementations
251// ═══════════════════════════════════════════════════════════════════════════
252
253// ── Display ───────────────────────────────────────────────────────────────
254
255impl<S: TimeScale> std::fmt::Display for Time<S> {
256    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257        write!(f, "{} {}", S::LABEL, self.quantity)
258    }
259}
260
261// ── Serde ─────────────────────────────────────────────────────────────────
262
263#[cfg(feature = "serde")]
264impl<S: TimeScale> Serialize for Time<S> {
265    fn serialize<Ser>(&self, serializer: Ser) -> Result<Ser::Ok, Ser::Error>
266    where
267        Ser: Serializer,
268    {
269        serializer.serialize_f64(self.value())
270    }
271}
272
273#[cfg(feature = "serde")]
274impl<'de, S: TimeScale> Deserialize<'de> for Time<S> {
275    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
276    where
277        D: Deserializer<'de>,
278    {
279        let v = f64::deserialize(deserializer)?;
280        if !v.is_finite() {
281            return Err(serde::de::Error::custom(
282                "time value must be finite (not NaN or infinity)",
283            ));
284        }
285        Ok(Self::new(v))
286    }
287}
288
289// ── Arithmetic ────────────────────────────────────────────────────────────
290
291impl<S: TimeScale> Add<Days> for Time<S> {
292    type Output = Self;
293    #[inline]
294    fn add(self, rhs: Days) -> Self::Output {
295        Self::from_days(self.quantity + rhs)
296    }
297}
298
299impl<S: TimeScale> AddAssign<Days> for Time<S> {
300    #[inline]
301    fn add_assign(&mut self, rhs: Days) {
302        self.quantity += rhs;
303    }
304}
305
306impl<S: TimeScale> Sub<Days> for Time<S> {
307    type Output = Self;
308    #[inline]
309    fn sub(self, rhs: Days) -> Self::Output {
310        Self::from_days(self.quantity - rhs)
311    }
312}
313
314impl<S: TimeScale> SubAssign<Days> for Time<S> {
315    #[inline]
316    fn sub_assign(&mut self, rhs: Days) {
317        self.quantity -= rhs;
318    }
319}
320
321impl<S: TimeScale> Sub for Time<S> {
322    type Output = Days;
323    #[inline]
324    fn sub(self, rhs: Self) -> Self::Output {
325        self.quantity - rhs.quantity
326    }
327}
328
329impl<S: TimeScale> std::ops::Div<Days> for Time<S> {
330    type Output = f64;
331    #[inline]
332    fn div(self, rhs: Days) -> Self::Output {
333        (self.quantity / rhs).simplify().value()
334    }
335}
336
337impl<S: TimeScale> std::ops::Div<f64> for Time<S> {
338    type Output = f64;
339    #[inline]
340    fn div(self, rhs: f64) -> Self::Output {
341        (self.quantity / rhs).value()
342    }
343}
344
345// ── From/Into Days ────────────────────────────────────────────────────────
346
347impl<S: TimeScale> From<Days> for Time<S> {
348    #[inline]
349    fn from(days: Days) -> Self {
350        Self::from_days(days)
351    }
352}
353
354impl<S: TimeScale> From<Time<S>> for Days {
355    #[inline]
356    fn from(time: Time<S>) -> Self {
357        time.quantity
358    }
359}
360
361// ═══════════════════════════════════════════════════════════════════════════
362// TimeInstant trait
363// ═══════════════════════════════════════════════════════════════════════════
364
365/// Trait for types that represent a point in time.
366///
367/// Types implementing this trait can be used as time instants in `Interval<T>`
368/// and provide conversions to/from UTC plus basic arithmetic operations.
369pub trait TimeInstant: Copy + Clone + PartialEq + PartialOrd + Sized {
370    /// The duration type used for arithmetic operations.
371    type Duration;
372
373    /// Convert this time instant to UTC DateTime.
374    fn to_utc(&self) -> Option<DateTime<Utc>>;
375
376    /// Create a time instant from UTC DateTime.
377    fn from_utc(datetime: DateTime<Utc>) -> Self;
378
379    /// Compute the difference between two time instants.
380    fn difference(&self, other: &Self) -> Self::Duration;
381
382    /// Add a duration to this time instant.
383    fn add_duration(&self, duration: Self::Duration) -> Self;
384
385    /// Subtract a duration from this time instant.
386    fn sub_duration(&self, duration: Self::Duration) -> Self;
387}
388
389impl<S: TimeScale> TimeInstant for Time<S> {
390    type Duration = Days;
391
392    #[inline]
393    fn to_utc(&self) -> Option<DateTime<Utc>> {
394        Time::to_utc(self)
395    }
396
397    #[inline]
398    fn from_utc(datetime: DateTime<Utc>) -> Self {
399        Time::from_utc(datetime)
400    }
401
402    #[inline]
403    fn difference(&self, other: &Self) -> Self::Duration {
404        *self - *other
405    }
406
407    #[inline]
408    fn add_duration(&self, duration: Self::Duration) -> Self {
409        *self + duration
410    }
411
412    #[inline]
413    fn sub_duration(&self, duration: Self::Duration) -> Self {
414        *self - duration
415    }
416}
417
418impl TimeInstant for DateTime<Utc> {
419    type Duration = chrono::Duration;
420
421    fn to_utc(&self) -> Option<DateTime<Utc>> {
422        Some(*self)
423    }
424
425    fn from_utc(datetime: DateTime<Utc>) -> Self {
426        datetime
427    }
428
429    fn difference(&self, other: &Self) -> Self::Duration {
430        *self - *other
431    }
432
433    fn add_duration(&self, duration: Self::Duration) -> Self {
434        *self + duration
435    }
436
437    fn sub_duration(&self, duration: Self::Duration) -> Self {
438        *self - duration
439    }
440}
441
442// ═══════════════════════════════════════════════════════════════════════════
443// Tests
444// ═══════════════════════════════════════════════════════════════════════════
445
446#[cfg(test)]
447mod tests {
448    use super::super::scales::{JD, MJD};
449    use super::*;
450    use chrono::TimeZone;
451
452    #[test]
453    fn test_julian_day_creation() {
454        let jd = Time::<JD>::new(2_451_545.0);
455        assert_eq!(jd.quantity(), Days::new(2_451_545.0));
456    }
457
458    #[test]
459    fn test_jd_utc_roundtrip() {
460        // from_utc applies ΔT (UT→TT); to_utc inverts it (TT→UT).
461        let datetime = DateTime::from_timestamp(946_728_000, 0).unwrap();
462        let jd = Time::<JD>::from_utc(datetime);
463        let back = jd.to_utc().expect("to_utc");
464        let delta_ns =
465            back.timestamp_nanos_opt().unwrap() - datetime.timestamp_nanos_opt().unwrap();
466        assert!(delta_ns.abs() < 1_000, "roundtrip error: {} ns", delta_ns);
467    }
468
469    #[test]
470    fn test_from_utc_applies_delta_t() {
471        // 2000-01-01 12:00:00 UTC → JD(UT)=2451545.0; ΔT≈63.83 s
472        let datetime = DateTime::from_timestamp(946_728_000, 0).unwrap();
473        let jd = Time::<JD>::from_utc(datetime);
474        let delta_t_secs = (jd.quantity() - Days::new(2_451_545.0)).to::<Second>();
475        assert!(
476            (delta_t_secs - Seconds::new(63.83)).abs() < Seconds::new(1.0),
477            "ΔT correction = {} s, expected ~63.83 s",
478            delta_t_secs
479        );
480    }
481
482    #[test]
483    fn test_julian_conversions() {
484        let jd = Time::<JD>::J2000 + Days::new(365_250.0);
485        assert!((jd.julian_millennias() - Millennia::new(1.0)).abs() < 1e-12);
486        assert!((jd.julian_centuries() - Centuries::new(10.0)).abs() < Centuries::new(1e-12));
487        assert!((jd.julian_years() - JulianYears::new(1000.0)).abs() < JulianYears::new(1e-9));
488    }
489
490    #[test]
491    fn test_tt_to_tdb_and_min_max() {
492        let jd_tdb = Time::<JD>::tt_to_tdb(Time::<JD>::J2000);
493        assert!((jd_tdb - Time::<JD>::J2000).abs() < 1e-6);
494
495        let earlier = Time::<JD>::J2000;
496        let later = earlier + Days::new(1.0);
497        assert_eq!(earlier.min(later), earlier);
498        assert_eq!(earlier.max(later), later);
499    }
500
501    #[test]
502    fn test_const_min_max() {
503        const A: Time<JD> = Time::<JD>::new(10.0);
504        const B: Time<JD> = Time::<JD>::new(14.0);
505        const MIN: Time<JD> = A.min(B);
506        const MAX: Time<JD> = A.max(B);
507        assert_eq!(MIN.quantity(), Days::new(10.0));
508        assert_eq!(MAX.quantity(), Days::new(14.0));
509    }
510
511    #[test]
512    fn test_mean_and_const_mean() {
513        let a = Time::<JD>::new(10.0);
514        let b = Time::<JD>::new(14.0);
515        assert_eq!(a.mean(b).quantity(), Days::new(12.0));
516        assert_eq!(b.mean(a).quantity(), Days::new(12.0));
517
518        const MID: Time<JD> = Time::<JD>::new(10.0).mean(Time::<JD>::new(14.0));
519        assert_eq!(MID.quantity(), Days::new(12.0));
520    }
521
522    #[test]
523    fn test_into_days() {
524        let jd = Time::<JD>::new(2_451_547.5);
525        let days: Days = jd.into();
526        assert_eq!(days, 2_451_547.5);
527
528        let roundtrip = Time::<JD>::from(days);
529        assert_eq!(roundtrip, jd);
530    }
531
532    #[test]
533    fn test_into_julian_years() {
534        let jd = Time::<JD>::J2000 + Days::new(365.25 * 2.0);
535        let years: JulianYears = jd.into();
536        assert!((years - JulianYears::new(2.0)).abs() < JulianYears::new(1e-12));
537
538        let roundtrip = Time::<JD>::from(years);
539        assert!((roundtrip.quantity() - jd.quantity()).abs() < Days::new(1e-12));
540    }
541
542    #[test]
543    fn test_into_centuries() {
544        let jd = Time::<JD>::J2000 + Days::new(36_525.0 * 3.0);
545        let centuries: Centuries = jd.into();
546        assert!((centuries - Centuries::new(3.0)).abs() < Centuries::new(1e-12));
547
548        let roundtrip = Time::<JD>::from(centuries);
549        assert!((roundtrip.quantity() - jd.quantity()).abs() < Days::new(1e-12));
550    }
551
552    #[test]
553    fn test_into_millennia() {
554        let jd = Time::<JD>::J2000 + Days::new(365_250.0 * 1.5);
555        let millennia: Millennia = jd.into();
556        assert!((millennia - Millennia::new(1.5)).abs() < Millennia::new(1e-12));
557
558        let roundtrip = Time::<JD>::from(millennia);
559        assert!((roundtrip.quantity() - jd.quantity()).abs() < Days::new(1e-9));
560    }
561
562    #[test]
563    fn test_mjd_creation() {
564        let mjd = Time::<MJD>::new(51_544.5);
565        assert_eq!(mjd.quantity(), Days::new(51_544.5));
566    }
567
568    #[test]
569    fn test_mjd_into_jd() {
570        let mjd = Time::<MJD>::new(51_544.5);
571        let jd: Time<JD> = mjd.into();
572        assert_eq!(jd.quantity(), Days::new(2_451_545.0));
573    }
574
575    #[test]
576    fn test_mjd_utc_roundtrip() {
577        let datetime = DateTime::from_timestamp(946_728_000, 0).unwrap();
578        let mjd = Time::<MJD>::from_utc(datetime);
579        let back = mjd.to_utc().expect("to_utc");
580        let delta_ns =
581            back.timestamp_nanos_opt().unwrap() - datetime.timestamp_nanos_opt().unwrap();
582        assert!(delta_ns.abs() < 1_000, "roundtrip error: {} ns", delta_ns);
583    }
584
585    #[test]
586    fn test_mjd_from_utc_applies_delta_t() {
587        // MJD epoch is JD − 2400000.5; ΔT should shift value by ~63.83/86400 days
588        let datetime = DateTime::from_timestamp(946_728_000, 0).unwrap();
589        let mjd = Time::<MJD>::from_utc(datetime);
590        let delta_t_secs = (mjd.quantity() - Days::new(51_544.5)).to::<Second>();
591        assert!(
592            (delta_t_secs - Seconds::new(63.83)).abs() < Seconds::new(1.0),
593            "ΔT correction = {} s, expected ~63.83 s",
594            delta_t_secs
595        );
596    }
597
598    #[test]
599    fn test_mjd_add_days() {
600        let mjd = Time::<MJD>::new(59_000.0);
601        let result = mjd + Days::new(1.5);
602        assert_eq!(result.quantity(), Days::new(59_001.5));
603    }
604
605    #[test]
606    fn test_mjd_sub_days() {
607        let mjd = Time::<MJD>::new(59_000.0);
608        let result = mjd - Days::new(1.5);
609        assert_eq!(result.quantity(), Days::new(58_998.5));
610    }
611
612    #[test]
613    fn test_mjd_sub_mjd() {
614        let mjd1 = Time::<MJD>::new(59_001.0);
615        let mjd2 = Time::<MJD>::new(59_000.0);
616        let diff = mjd1 - mjd2;
617        assert_eq!(diff, 1.0);
618    }
619
620    #[test]
621    fn test_mjd_comparison() {
622        let mjd1 = Time::<MJD>::new(59_000.0);
623        let mjd2 = Time::<MJD>::new(59_001.0);
624        assert!(mjd1 < mjd2);
625        assert!(mjd2 > mjd1);
626    }
627
628    #[test]
629    fn test_display_jd() {
630        let jd = Time::<JD>::new(2_451_545.0);
631        let s = format!("{jd}");
632        assert!(s.contains("Julian Day"));
633    }
634
635    #[test]
636    fn test_try_new_finite() {
637        let jd = Time::<JD>::try_new(2_451_545.0);
638        assert!(jd.is_ok());
639        assert_eq!(jd.unwrap().value(), 2_451_545.0);
640    }
641
642    #[test]
643    fn test_try_new_nan() {
644        assert!(Time::<JD>::try_new(f64::NAN).is_err());
645    }
646
647    #[test]
648    fn test_try_new_infinity() {
649        assert!(Time::<JD>::try_new(f64::INFINITY).is_err());
650        assert!(Time::<JD>::try_new(f64::NEG_INFINITY).is_err());
651    }
652
653    #[test]
654    fn test_try_from_days() {
655        assert!(Time::<JD>::try_from_days(Days::new(100.0)).is_ok());
656        assert!(Time::<JD>::try_from_days(Days::new(f64::NAN)).is_err());
657    }
658
659    #[test]
660    fn test_display_mjd() {
661        let mjd = Time::<MJD>::new(51_544.5);
662        let s = format!("{mjd}");
663        assert!(s.contains("MJD"));
664    }
665
666    #[test]
667    fn test_add_assign_sub_assign() {
668        let mut jd = Time::<JD>::new(2_451_545.0);
669        jd += Days::new(1.0);
670        assert_eq!(jd.quantity(), Days::new(2_451_546.0));
671        jd -= Days::new(0.5);
672        assert_eq!(jd.quantity(), Days::new(2_451_545.5));
673    }
674
675    #[test]
676    fn test_add_years() {
677        let jd = Time::<JD>::new(2_450_000.0);
678        let with_years = jd + Years::new(1.0);
679        let span: Days = with_years - jd;
680        assert!((span - Time::<JD>::JULIAN_YEAR).abs() < Days::new(1e-9));
681    }
682
683    #[test]
684    fn test_div_days_and_f64() {
685        let jd = Time::<JD>::new(100.0);
686        assert!((jd / Days::new(2.0) - 50.0).abs() < 1e-12);
687        assert!((jd / 4.0 - 25.0).abs() < 1e-12);
688    }
689
690    #[test]
691    fn test_to_method_jd_mjd() {
692        let jd = Time::<JD>::new(2_451_545.0);
693        let mjd = jd.to::<MJD>();
694        assert!((mjd.quantity() - Days::new(51_544.5)).abs() < Days::new(1e-10));
695    }
696
697    #[test]
698    fn timeinstant_for_julian_date_handles_arithmetic() {
699        let jd = Time::<JD>::new(2_451_545.0);
700        let other = jd + Days::new(2.0);
701
702        assert_eq!(jd.difference(&other), Days::new(-2.0));
703        assert_eq!(
704            jd.add_duration(Days::new(1.5)).quantity(),
705            Days::new(2_451_546.5)
706        );
707        assert_eq!(
708            other.sub_duration(Days::new(0.5)).quantity(),
709            Days::new(2_451_546.5)
710        );
711    }
712
713    #[test]
714    fn timeinstant_for_modified_julian_date_roundtrips_utc() {
715        let dt = DateTime::from_timestamp(946_684_800, 123_000_000).unwrap(); // 2000-01-01T00:00:00.123Z
716        let mjd = Time::<MJD>::from_utc(dt);
717        let back = mjd.to_utc().expect("mjd to utc");
718
719        assert_eq!(mjd.difference(&mjd), Days::new(0.0));
720        assert_eq!(
721            mjd.add_duration(Days::new(1.0)).quantity(),
722            mjd.quantity() + Days::new(1.0)
723        );
724        assert_eq!(
725            mjd.sub_duration(Days::new(0.5)).quantity(),
726            mjd.quantity() - Days::new(0.5)
727        );
728        let delta_ns = back.timestamp_nanos_opt().unwrap() - dt.timestamp_nanos_opt().unwrap();
729        assert!(delta_ns.abs() < 10_000, "nanos differ by {}", delta_ns);
730    }
731
732    #[test]
733    fn timeinstant_for_datetime_uses_chrono_durations() {
734        let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
735        let later = Utc.with_ymd_and_hms(2024, 1, 2, 6, 0, 0).unwrap();
736        let diff = later.difference(&base);
737
738        assert_eq!(diff.num_hours(), 30);
739        assert_eq!(
740            base.add_duration(diff + chrono::Duration::hours(6)),
741            later + chrono::Duration::hours(6)
742        );
743        assert_eq!(later.sub_duration(diff), base);
744        assert_eq!(TimeInstant::to_utc(&later), Some(later));
745    }
746}