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#[repr(transparent)]
83#[derive(Debug, Copy, Clone, PartialEq, PartialOrd)]
84pub struct Time<S: TimeScale> {
85    quantity: Days,
86    _scale: PhantomData<S>,
87}
88
89impl<S: TimeScale> Time<S> {
90    // ── constructors ──────────────────────────────────────────────────
91
92    /// Create from a raw scalar (days since the scale's epoch).
93    ///
94    /// **Note:** this constructor accepts any `f64`, including `NaN` and `±∞`.
95    /// Prefer [`try_new`](Self::try_new) when the value comes from untrusted
96    /// or computed input.
97    #[inline]
98    pub const fn new(value: f64) -> Self {
99        Self {
100            quantity: Days::new(value),
101            _scale: PhantomData,
102        }
103    }
104
105    /// Create from a raw scalar, rejecting non-finite values.
106    ///
107    /// Returns [`NonFiniteTimeError`] if `value` is `NaN`, `+∞`, or `−∞`.
108    ///
109    /// # Examples
110    ///
111    /// ```
112    /// # use tempoch_core as tempoch;
113    /// use tempoch::{Time, JD};
114    ///
115    /// assert!(Time::<JD>::try_new(2451545.0).is_ok());
116    /// assert!(Time::<JD>::try_new(f64::NAN).is_err());
117    /// assert!(Time::<JD>::try_new(f64::INFINITY).is_err());
118    /// ```
119    #[inline]
120    pub fn try_new(value: f64) -> Result<Self, NonFiniteTimeError> {
121        if value.is_finite() {
122            Ok(Self::new(value))
123        } else {
124            Err(NonFiniteTimeError)
125        }
126    }
127
128    /// Create from a [`Days`] quantity.
129    ///
130    /// **Note:** this constructor accepts any `f64`, including `NaN` and `±∞`.
131    /// Prefer [`try_from_days`](Self::try_from_days) when the value comes from
132    /// untrusted or computed input.
133    #[inline]
134    pub const fn from_days(days: Days) -> Self {
135        Self {
136            quantity: days,
137            _scale: PhantomData,
138        }
139    }
140
141    /// Create from a [`Days`] quantity, rejecting non-finite values.
142    ///
143    /// Returns [`NonFiniteTimeError`] if the underlying value is `NaN`,
144    /// `+∞`, or `−∞`.
145    #[inline]
146    pub fn try_from_days(days: Days) -> Result<Self, NonFiniteTimeError> {
147        Self::try_new(days.value())
148    }
149
150    // ── accessors ─────────────────────────────────────────────────────
151
152    /// The underlying quantity in days.
153    #[inline]
154    pub const fn quantity(&self) -> Days {
155        self.quantity
156    }
157
158    /// The underlying scalar value in days.
159    #[inline]
160    pub const fn value(&self) -> f64 {
161        self.quantity.value()
162    }
163
164    /// Absolute Julian Day (TT) corresponding to this instant.
165    #[inline]
166    pub fn julian_day(&self) -> Days {
167        S::to_jd_tt(self.quantity)
168    }
169
170    /// Absolute Julian Day (TT) as scalar.
171    #[inline]
172    pub fn julian_day_value(&self) -> f64 {
173        self.julian_day().value()
174    }
175
176    /// Build an instant from an absolute Julian Day (TT).
177    #[inline]
178    pub fn from_julian_day(jd: Days) -> Self {
179        Self::from_days(S::from_jd_tt(jd))
180    }
181
182    // ── cross-scale conversion (mirroring qtty's .to::<T>()) ─────────
183
184    /// Convert this instant to another time scale.
185    ///
186    /// The conversion routes through the canonical JD(TT) intermediate:
187    ///
188    /// ```text
189    /// self → JD(TT) → target
190    /// ```
191    ///
192    /// For pure epoch-offset scales this compiles down to a single
193    /// addition/subtraction.
194    #[inline]
195    pub fn to<T: TimeScale>(&self) -> Time<T> {
196        Time::<T>::from_julian_day(S::to_jd_tt(self.quantity))
197    }
198
199    // ── UTC helpers ───────────────────────────────────────────────────
200
201    /// Convert to a `chrono::DateTime<Utc>`.
202    ///
203    /// Inverts the ΔT correction to recover the UTC / UT timestamp.
204    /// Returns `None` if the value falls outside chrono's representable range.
205    pub fn to_utc(&self) -> Option<DateTime<Utc>> {
206        use super::scales::UT;
207        const UNIX_EPOCH_JD: f64 = 2_440_587.5;
208        let jd_ut = self.to::<UT>().quantity();
209        let seconds_since_epoch = (jd_ut - Days::new(UNIX_EPOCH_JD)).to::<Second>().value();
210        let secs = seconds_since_epoch.floor() as i64;
211        let nanos = ((seconds_since_epoch - secs as f64) * 1e9) as u32;
212        DateTime::<Utc>::from_timestamp(secs, nanos)
213    }
214
215    /// Build an instant from a `chrono::DateTime<Utc>`.
216    ///
217    /// The UTC timestamp is interpreted as Universal Time (≈ UT1) and the
218    /// epoch-dependent **ΔT** correction is applied automatically, so the
219    /// resulting `Time<S>` is on the target scale's axis.
220    pub fn from_utc(datetime: DateTime<Utc>) -> Self {
221        use super::scales::UT;
222        const UNIX_EPOCH_JD: f64 = 2_440_587.5;
223        let seconds_since_epoch = Seconds::new(datetime.timestamp() as f64);
224        let nanos = Seconds::new(datetime.timestamp_subsec_nanos() as f64 / 1e9);
225        let jd_ut = Days::new(UNIX_EPOCH_JD) + (seconds_since_epoch + nanos).to::<Day>();
226        Time::<UT>::from_days(jd_ut).to::<S>()
227    }
228
229    // ── min / max ─────────────────────────────────────────────────────
230
231    /// Element-wise minimum.
232    #[inline]
233    pub const fn min(self, other: Self) -> Self {
234        Self::from_days(self.quantity.min_const(other.quantity))
235    }
236
237    /// Element-wise maximum.
238    #[inline]
239    pub const fn max(self, other: Self) -> Self {
240        Self::from_days(self.quantity.max_const(other.quantity))
241    }
242
243    /// Mean (midpoint) between two instants on the same time scale.
244    #[inline]
245    pub const fn mean(self, other: Self) -> Self {
246        Self::from_days(self.quantity.const_add(other.quantity).const_div(2.0))
247    }
248}
249
250// ═══════════════════════════════════════════════════════════════════════════
251// Generic trait implementations
252// ═══════════════════════════════════════════════════════════════════════════
253
254// ── Display ───────────────────────────────────────────────────────────────
255
256impl<S: TimeScale> std::fmt::Display for Time<S> {
257    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258        // Format: "JD 2451545.0" — scale label followed by the raw day value.
259        // The `d` unit suffix is intentionally omitted: for time scales the
260        // scale label already conveys the scale (JD, MJD, TT, …) and the
261        // trailing `d` was redundant and visually confusing.
262        // All format flags (precision, width, …) are forwarded to the f64
263        // value so that e.g. `format!("{:.9}", my_jd)` works directly.
264        write!(f, "{} ", S::LABEL)?;
265        std::fmt::Display::fmt(&self.quantity.value(), f)
266    }
267}
268
269// ── Serde ─────────────────────────────────────────────────────────────────
270
271#[cfg(feature = "serde")]
272impl<S: TimeScale> Serialize for Time<S> {
273    fn serialize<Ser>(&self, serializer: Ser) -> Result<Ser::Ok, Ser::Error>
274    where
275        Ser: Serializer,
276    {
277        serializer.serialize_f64(self.value())
278    }
279}
280
281#[cfg(feature = "serde")]
282impl<'de, S: TimeScale> Deserialize<'de> for Time<S> {
283    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
284    where
285        D: Deserializer<'de>,
286    {
287        let v = f64::deserialize(deserializer)?;
288        if !v.is_finite() {
289            return Err(serde::de::Error::custom(
290                "time value must be finite (not NaN or infinity)",
291            ));
292        }
293        Ok(Self::new(v))
294    }
295}
296
297// ── Arithmetic ────────────────────────────────────────────────────────────
298
299impl<S: TimeScale> Add<Days> for Time<S> {
300    type Output = Self;
301    #[inline]
302    fn add(self, rhs: Days) -> Self::Output {
303        Self::from_days(self.quantity + rhs)
304    }
305}
306
307impl<S: TimeScale> AddAssign<Days> for Time<S> {
308    #[inline]
309    fn add_assign(&mut self, rhs: Days) {
310        self.quantity += rhs;
311    }
312}
313
314impl<S: TimeScale> Sub<Days> for Time<S> {
315    type Output = Self;
316    #[inline]
317    fn sub(self, rhs: Days) -> Self::Output {
318        Self::from_days(self.quantity - rhs)
319    }
320}
321
322impl<S: TimeScale> SubAssign<Days> for Time<S> {
323    #[inline]
324    fn sub_assign(&mut self, rhs: Days) {
325        self.quantity -= rhs;
326    }
327}
328
329impl<S: TimeScale> Sub for Time<S> {
330    type Output = Days;
331    #[inline]
332    fn sub(self, rhs: Self) -> Self::Output {
333        self.quantity - rhs.quantity
334    }
335}
336
337impl<S: TimeScale> std::ops::Div<Days> for Time<S> {
338    type Output = f64;
339    #[inline]
340    fn div(self, rhs: Days) -> Self::Output {
341        (self.quantity / rhs).simplify().value()
342    }
343}
344
345impl<S: TimeScale> std::ops::Div<f64> for Time<S> {
346    type Output = f64;
347    #[inline]
348    fn div(self, rhs: f64) -> Self::Output {
349        (self.quantity / rhs).value()
350    }
351}
352
353// ── From/Into Days ────────────────────────────────────────────────────────
354
355impl<S: TimeScale> From<Days> for Time<S> {
356    #[inline]
357    fn from(days: Days) -> Self {
358        Self::from_days(days)
359    }
360}
361
362impl<S: TimeScale> From<Time<S>> for Days {
363    #[inline]
364    fn from(time: Time<S>) -> Self {
365        time.quantity
366    }
367}
368
369// ═══════════════════════════════════════════════════════════════════════════
370// TimeInstant trait
371// ═══════════════════════════════════════════════════════════════════════════
372
373/// Trait for types that represent a point in time.
374///
375/// Types implementing this trait can be used as time instants in `Interval<T>`
376/// and provide conversions to/from UTC plus basic arithmetic operations.
377pub trait TimeInstant: Copy + Clone + PartialEq + PartialOrd + Sized {
378    /// The duration type used for arithmetic operations.
379    type Duration;
380
381    /// Convert this time instant to UTC DateTime.
382    fn to_utc(&self) -> Option<DateTime<Utc>>;
383
384    /// Create a time instant from UTC DateTime.
385    fn from_utc(datetime: DateTime<Utc>) -> Self;
386
387    /// Compute the difference between two time instants.
388    fn difference(&self, other: &Self) -> Self::Duration;
389
390    /// Add a duration to this time instant.
391    fn add_duration(&self, duration: Self::Duration) -> Self;
392
393    /// Subtract a duration from this time instant.
394    fn sub_duration(&self, duration: Self::Duration) -> Self;
395}
396
397impl<S: TimeScale> TimeInstant for Time<S> {
398    type Duration = Days;
399
400    #[inline]
401    fn to_utc(&self) -> Option<DateTime<Utc>> {
402        Time::to_utc(self)
403    }
404
405    #[inline]
406    fn from_utc(datetime: DateTime<Utc>) -> Self {
407        Time::from_utc(datetime)
408    }
409
410    #[inline]
411    fn difference(&self, other: &Self) -> Self::Duration {
412        *self - *other
413    }
414
415    #[inline]
416    fn add_duration(&self, duration: Self::Duration) -> Self {
417        *self + duration
418    }
419
420    #[inline]
421    fn sub_duration(&self, duration: Self::Duration) -> Self {
422        *self - duration
423    }
424}
425
426impl TimeInstant for DateTime<Utc> {
427    type Duration = chrono::Duration;
428
429    fn to_utc(&self) -> Option<DateTime<Utc>> {
430        Some(*self)
431    }
432
433    fn from_utc(datetime: DateTime<Utc>) -> Self {
434        datetime
435    }
436
437    fn difference(&self, other: &Self) -> Self::Duration {
438        *self - *other
439    }
440
441    fn add_duration(&self, duration: Self::Duration) -> Self {
442        *self + duration
443    }
444
445    fn sub_duration(&self, duration: Self::Duration) -> Self {
446        *self - duration
447    }
448}
449
450// ═══════════════════════════════════════════════════════════════════════════
451// Tests
452// ═══════════════════════════════════════════════════════════════════════════
453
454#[cfg(test)]
455mod tests {
456    use super::super::scales::{JD, MJD};
457    use super::*;
458    use chrono::TimeZone;
459
460    #[test]
461    fn test_julian_day_creation() {
462        let jd = Time::<JD>::new(2_451_545.0);
463        assert_eq!(jd.quantity(), Days::new(2_451_545.0));
464    }
465
466    #[test]
467    fn test_jd_utc_roundtrip() {
468        // from_utc applies ΔT (UT→TT); to_utc inverts it (TT→UT).
469        let datetime = DateTime::from_timestamp(946_728_000, 0).unwrap();
470        let jd = Time::<JD>::from_utc(datetime);
471        let back = jd.to_utc().expect("to_utc");
472        let delta_ns =
473            back.timestamp_nanos_opt().unwrap() - datetime.timestamp_nanos_opt().unwrap();
474        assert!(delta_ns.abs() < 1_000, "roundtrip error: {} ns", delta_ns);
475    }
476
477    #[test]
478    fn test_from_utc_applies_delta_t() {
479        // 2000-01-01 12:00:00 UTC → JD(UT)=2451545.0; ΔT≈63.83 s
480        let datetime = DateTime::from_timestamp(946_728_000, 0).unwrap();
481        let jd = Time::<JD>::from_utc(datetime);
482        let delta_t_secs = (jd.quantity() - Days::new(2_451_545.0)).to::<Second>();
483        assert!(
484            (delta_t_secs - Seconds::new(63.83)).abs() < Seconds::new(1.0),
485            "ΔT correction = {} s, expected ~63.83 s",
486            delta_t_secs
487        );
488    }
489
490    #[test]
491    fn test_julian_conversions() {
492        let jd = Time::<JD>::J2000 + Days::new(365_250.0);
493        assert!((jd.julian_millennias() - Millennia::new(1.0)).abs() < 1e-12);
494        assert!((jd.julian_centuries() - Centuries::new(10.0)).abs() < Centuries::new(1e-12));
495        assert!((jd.julian_years() - JulianYears::new(1000.0)).abs() < JulianYears::new(1e-9));
496    }
497
498    #[test]
499    fn test_tt_to_tdb_and_min_max() {
500        let jd_tdb = Time::<JD>::tt_to_tdb(Time::<JD>::J2000);
501        assert!((jd_tdb - Time::<JD>::J2000).abs() < 1e-6);
502
503        let earlier = Time::<JD>::J2000;
504        let later = earlier + Days::new(1.0);
505        assert_eq!(earlier.min(later), earlier);
506        assert_eq!(earlier.max(later), later);
507    }
508
509    #[test]
510    fn test_const_min_max() {
511        const A: Time<JD> = Time::<JD>::new(10.0);
512        const B: Time<JD> = Time::<JD>::new(14.0);
513        const MIN: Time<JD> = A.min(B);
514        const MAX: Time<JD> = A.max(B);
515        assert_eq!(MIN.quantity(), Days::new(10.0));
516        assert_eq!(MAX.quantity(), Days::new(14.0));
517    }
518
519    #[test]
520    fn test_mean_and_const_mean() {
521        let a = Time::<JD>::new(10.0);
522        let b = Time::<JD>::new(14.0);
523        assert_eq!(a.mean(b).quantity(), Days::new(12.0));
524        assert_eq!(b.mean(a).quantity(), Days::new(12.0));
525
526        const MID: Time<JD> = Time::<JD>::new(10.0).mean(Time::<JD>::new(14.0));
527        assert_eq!(MID.quantity(), Days::new(12.0));
528    }
529
530    #[test]
531    fn test_into_days() {
532        let jd = Time::<JD>::new(2_451_547.5);
533        let days: Days = jd.into();
534        assert_eq!(days, 2_451_547.5);
535
536        let roundtrip = Time::<JD>::from(days);
537        assert_eq!(roundtrip, jd);
538    }
539
540    #[test]
541    fn test_into_julian_years() {
542        let jd = Time::<JD>::J2000 + Days::new(365.25 * 2.0);
543        let years: JulianYears = jd.into();
544        assert!((years - JulianYears::new(2.0)).abs() < JulianYears::new(1e-12));
545
546        let roundtrip = Time::<JD>::from(years);
547        assert!((roundtrip.quantity() - jd.quantity()).abs() < Days::new(1e-12));
548    }
549
550    #[test]
551    fn time_has_days_layout() {
552        assert_eq!(std::mem::size_of::<Time<JD>>(), std::mem::size_of::<Days>());
553        assert_eq!(
554            std::mem::align_of::<Time<JD>>(),
555            std::mem::align_of::<Days>()
556        );
557    }
558
559    #[test]
560    fn test_into_centuries() {
561        let jd = Time::<JD>::J2000 + Days::new(36_525.0 * 3.0);
562        let centuries: Centuries = jd.into();
563        assert!((centuries - Centuries::new(3.0)).abs() < Centuries::new(1e-12));
564
565        let roundtrip = Time::<JD>::from(centuries);
566        assert!((roundtrip.quantity() - jd.quantity()).abs() < Days::new(1e-12));
567    }
568
569    #[test]
570    fn test_into_millennia() {
571        let jd = Time::<JD>::J2000 + Days::new(365_250.0 * 1.5);
572        let millennia: Millennia = jd.into();
573        assert!((millennia - Millennia::new(1.5)).abs() < Millennia::new(1e-12));
574
575        let roundtrip = Time::<JD>::from(millennia);
576        assert!((roundtrip.quantity() - jd.quantity()).abs() < Days::new(1e-9));
577    }
578
579    #[test]
580    fn test_mjd_creation() {
581        let mjd = Time::<MJD>::new(51_544.5);
582        assert_eq!(mjd.quantity(), Days::new(51_544.5));
583    }
584
585    #[test]
586    fn test_mjd_into_jd() {
587        let mjd = Time::<MJD>::new(51_544.5);
588        let jd: Time<JD> = mjd.into();
589        assert_eq!(jd.quantity(), Days::new(2_451_545.0));
590    }
591
592    #[test]
593    fn test_mjd_utc_roundtrip() {
594        let datetime = DateTime::from_timestamp(946_728_000, 0).unwrap();
595        let mjd = Time::<MJD>::from_utc(datetime);
596        let back = mjd.to_utc().expect("to_utc");
597        let delta_ns =
598            back.timestamp_nanos_opt().unwrap() - datetime.timestamp_nanos_opt().unwrap();
599        assert!(delta_ns.abs() < 1_000, "roundtrip error: {} ns", delta_ns);
600    }
601
602    #[test]
603    fn test_mjd_from_utc_applies_delta_t() {
604        // MJD epoch is JD − 2400000.5; ΔT should shift value by ~63.83/86400 days
605        let datetime = DateTime::from_timestamp(946_728_000, 0).unwrap();
606        let mjd = Time::<MJD>::from_utc(datetime);
607        let delta_t_secs = (mjd.quantity() - Days::new(51_544.5)).to::<Second>();
608        assert!(
609            (delta_t_secs - Seconds::new(63.83)).abs() < Seconds::new(1.0),
610            "ΔT correction = {} s, expected ~63.83 s",
611            delta_t_secs
612        );
613    }
614
615    #[test]
616    fn test_mjd_add_days() {
617        let mjd = Time::<MJD>::new(59_000.0);
618        let result = mjd + Days::new(1.5);
619        assert_eq!(result.quantity(), Days::new(59_001.5));
620    }
621
622    #[test]
623    fn test_mjd_sub_days() {
624        let mjd = Time::<MJD>::new(59_000.0);
625        let result = mjd - Days::new(1.5);
626        assert_eq!(result.quantity(), Days::new(58_998.5));
627    }
628
629    #[test]
630    fn test_mjd_sub_mjd() {
631        let mjd1 = Time::<MJD>::new(59_001.0);
632        let mjd2 = Time::<MJD>::new(59_000.0);
633        let diff = mjd1 - mjd2;
634        assert_eq!(diff, 1.0);
635    }
636
637    #[test]
638    fn test_mjd_comparison() {
639        let mjd1 = Time::<MJD>::new(59_000.0);
640        let mjd2 = Time::<MJD>::new(59_001.0);
641        assert!(mjd1 < mjd2);
642        assert!(mjd2 > mjd1);
643    }
644
645    #[test]
646    fn test_display_jd() {
647        let jd = Time::<JD>::new(2_451_545.0);
648        let s = format!("{jd}");
649        assert!(s.contains("Julian Day"));
650    }
651
652    #[test]
653    fn test_try_new_finite() {
654        let jd = Time::<JD>::try_new(2_451_545.0);
655        assert!(jd.is_ok());
656        assert_eq!(jd.unwrap().value(), 2_451_545.0);
657    }
658
659    #[test]
660    fn test_try_new_nan() {
661        assert!(Time::<JD>::try_new(f64::NAN).is_err());
662    }
663
664    #[test]
665    fn test_try_new_infinity() {
666        assert!(Time::<JD>::try_new(f64::INFINITY).is_err());
667        assert!(Time::<JD>::try_new(f64::NEG_INFINITY).is_err());
668    }
669
670    #[test]
671    fn test_try_from_days() {
672        assert!(Time::<JD>::try_from_days(Days::new(100.0)).is_ok());
673        assert!(Time::<JD>::try_from_days(Days::new(f64::NAN)).is_err());
674    }
675
676    #[test]
677    fn test_display_mjd() {
678        let mjd = Time::<MJD>::new(51_544.5);
679        let s = format!("{mjd}");
680        assert!(s.contains("MJD"));
681    }
682
683    #[test]
684    fn test_add_assign_sub_assign() {
685        let mut jd = Time::<JD>::new(2_451_545.0);
686        jd += Days::new(1.0);
687        assert_eq!(jd.quantity(), Days::new(2_451_546.0));
688        jd -= Days::new(0.5);
689        assert_eq!(jd.quantity(), Days::new(2_451_545.5));
690    }
691
692    #[test]
693    fn test_add_years() {
694        let jd = Time::<JD>::new(2_450_000.0);
695        let with_years = jd + Years::new(1.0);
696        let span: Days = with_years - jd;
697        assert!((span - Time::<JD>::JULIAN_YEAR).abs() < Days::new(1e-9));
698    }
699
700    #[test]
701    fn test_div_days_and_f64() {
702        let jd = Time::<JD>::new(100.0);
703        assert!((jd / Days::new(2.0) - 50.0).abs() < 1e-12);
704        assert!((jd / 4.0 - 25.0).abs() < 1e-12);
705    }
706
707    #[test]
708    fn test_to_method_jd_mjd() {
709        let jd = Time::<JD>::new(2_451_545.0);
710        let mjd = jd.to::<MJD>();
711        assert!((mjd.quantity() - Days::new(51_544.5)).abs() < Days::new(1e-10));
712    }
713
714    #[test]
715    fn timeinstant_for_julian_date_handles_arithmetic() {
716        let jd = Time::<JD>::new(2_451_545.0);
717        let other = jd + Days::new(2.0);
718
719        assert_eq!(jd.difference(&other), Days::new(-2.0));
720        assert_eq!(
721            jd.add_duration(Days::new(1.5)).quantity(),
722            Days::new(2_451_546.5)
723        );
724        assert_eq!(
725            other.sub_duration(Days::new(0.5)).quantity(),
726            Days::new(2_451_546.5)
727        );
728    }
729
730    #[test]
731    fn timeinstant_for_modified_julian_date_roundtrips_utc() {
732        let dt = DateTime::from_timestamp(946_684_800, 123_000_000).unwrap(); // 2000-01-01T00:00:00.123Z
733        let mjd = Time::<MJD>::from_utc(dt);
734        let back = mjd.to_utc().expect("mjd to utc");
735
736        assert_eq!(mjd.difference(&mjd), Days::new(0.0));
737        assert_eq!(
738            mjd.add_duration(Days::new(1.0)).quantity(),
739            mjd.quantity() + Days::new(1.0)
740        );
741        assert_eq!(
742            mjd.sub_duration(Days::new(0.5)).quantity(),
743            mjd.quantity() - Days::new(0.5)
744        );
745        let delta_ns = back.timestamp_nanos_opt().unwrap() - dt.timestamp_nanos_opt().unwrap();
746        assert!(delta_ns.abs() < 10_000, "nanos differ by {}", delta_ns);
747    }
748
749    #[test]
750    fn timeinstant_for_datetime_uses_chrono_durations() {
751        let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
752        let later = Utc.with_ymd_and_hms(2024, 1, 2, 6, 0, 0).unwrap();
753        let diff = later.difference(&base);
754
755        assert_eq!(diff.num_hours(), 30);
756        assert_eq!(
757            base.add_duration(diff + chrono::Duration::hours(6)),
758            later + chrono::Duration::hours(6)
759        );
760        assert_eq!(later.sub_duration(diff), base);
761        assert_eq!(TimeInstant::to_utc(&later), Some(later));
762    }
763
764    // ── New coverage tests ────────────────────────────────────────────
765
766    #[test]
767    fn test_non_finite_error_display() {
768        let err = NonFiniteTimeError;
769        let msg = format!("{err}");
770        assert!(msg.contains("finite"), "unexpected: {msg}");
771    }
772
773    #[test]
774    fn test_julian_day_and_julian_day_value() {
775        // MJD 51544.5 == JD 2451545.0 (J2000.0 in TT).
776        let mjd = Time::<MJD>::new(51_544.5);
777        let jd_days = mjd.julian_day();
778        assert!(
779            (jd_days - Days::new(2_451_545.0)).abs() < Days::new(1e-10),
780            "julian_day mismatch: {jd_days}"
781        );
782        assert!(
783            (mjd.julian_day_value() - 2_451_545.0).abs() < 1e-10,
784            "julian_day_value mismatch: {}",
785            mjd.julian_day_value()
786        );
787    }
788
789    #[test]
790    fn test_timeinstant_trait_to_utc_and_from_utc_for_time() {
791        // Call to_utc / from_utc through the TimeInstant trait (UFCS) so that
792        // the forwarding wrapper functions in the TimeInstant impl are covered.
793        let jd = Time::<JD>::new(2_451_545.0);
794        let utc: Option<_> = TimeInstant::to_utc(&jd);
795        assert!(utc.is_some());
796        let back: Time<JD> = TimeInstant::from_utc(utc.unwrap());
797        assert!((back.value() - jd.value()).abs() < 1e-6);
798    }
799
800    #[test]
801    fn test_datetime_timeinstant_from_utc() {
802        // Exercises TimeInstant::from_utc for DateTime<Utc>.
803        let dt = DateTime::from_timestamp(0, 0).unwrap();
804        let back: DateTime<Utc> = TimeInstant::from_utc(dt);
805        assert_eq!(back, dt);
806    }
807
808    #[cfg(feature = "serde")]
809    #[test]
810    fn test_serde_serialize_time() {
811        let jd = Time::<JD>::new(2_451_545.0);
812        let json = serde_json::to_string(&jd).unwrap();
813        assert!(json.contains("2451545"), "serialized: {json}");
814        let back: Time<JD> = serde_json::from_str(&json).unwrap();
815        assert_eq!(jd.value(), back.value());
816    }
817
818    #[cfg(feature = "serde")]
819    #[test]
820    fn test_serde_deserialize_nan_rejected() {
821        use serde::{de::IntoDeserializer, Deserialize};
822        let result: Result<Time<JD>, serde::de::value::Error> =
823            Time::<JD>::deserialize(f64::NAN.into_deserializer());
824        assert!(result.is_err());
825        let msg = result.unwrap_err().to_string();
826        assert!(msg.contains("finite"), "unexpected error: {msg}");
827    }
828}