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        // Format: "JD 2451545.0" — scale label followed by the raw day value.
258        // The `d` unit suffix is intentionally omitted: for time scales the
259        // scale label already conveys the scale (JD, MJD, TT, …) and the
260        // trailing `d` was redundant and visually confusing.
261        // All format flags (precision, width, …) are forwarded to the f64
262        // value so that e.g. `format!("{:.9}", my_jd)` works directly.
263        write!(f, "{} ", S::LABEL)?;
264        std::fmt::Display::fmt(&self.quantity.value(), f)
265    }
266}
267
268// ── Serde ─────────────────────────────────────────────────────────────────
269
270#[cfg(feature = "serde")]
271impl<S: TimeScale> Serialize for Time<S> {
272    fn serialize<Ser>(&self, serializer: Ser) -> Result<Ser::Ok, Ser::Error>
273    where
274        Ser: Serializer,
275    {
276        serializer.serialize_f64(self.value())
277    }
278}
279
280#[cfg(feature = "serde")]
281impl<'de, S: TimeScale> Deserialize<'de> for Time<S> {
282    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
283    where
284        D: Deserializer<'de>,
285    {
286        let v = f64::deserialize(deserializer)?;
287        if !v.is_finite() {
288            return Err(serde::de::Error::custom(
289                "time value must be finite (not NaN or infinity)",
290            ));
291        }
292        Ok(Self::new(v))
293    }
294}
295
296// ── Arithmetic ────────────────────────────────────────────────────────────
297
298impl<S: TimeScale> Add<Days> for Time<S> {
299    type Output = Self;
300    #[inline]
301    fn add(self, rhs: Days) -> Self::Output {
302        Self::from_days(self.quantity + rhs)
303    }
304}
305
306impl<S: TimeScale> AddAssign<Days> for Time<S> {
307    #[inline]
308    fn add_assign(&mut self, rhs: Days) {
309        self.quantity += rhs;
310    }
311}
312
313impl<S: TimeScale> Sub<Days> for Time<S> {
314    type Output = Self;
315    #[inline]
316    fn sub(self, rhs: Days) -> Self::Output {
317        Self::from_days(self.quantity - rhs)
318    }
319}
320
321impl<S: TimeScale> SubAssign<Days> for Time<S> {
322    #[inline]
323    fn sub_assign(&mut self, rhs: Days) {
324        self.quantity -= rhs;
325    }
326}
327
328impl<S: TimeScale> Sub for Time<S> {
329    type Output = Days;
330    #[inline]
331    fn sub(self, rhs: Self) -> Self::Output {
332        self.quantity - rhs.quantity
333    }
334}
335
336impl<S: TimeScale> std::ops::Div<Days> for Time<S> {
337    type Output = f64;
338    #[inline]
339    fn div(self, rhs: Days) -> Self::Output {
340        (self.quantity / rhs).simplify().value()
341    }
342}
343
344impl<S: TimeScale> std::ops::Div<f64> for Time<S> {
345    type Output = f64;
346    #[inline]
347    fn div(self, rhs: f64) -> Self::Output {
348        (self.quantity / rhs).value()
349    }
350}
351
352// ── From/Into Days ────────────────────────────────────────────────────────
353
354impl<S: TimeScale> From<Days> for Time<S> {
355    #[inline]
356    fn from(days: Days) -> Self {
357        Self::from_days(days)
358    }
359}
360
361impl<S: TimeScale> From<Time<S>> for Days {
362    #[inline]
363    fn from(time: Time<S>) -> Self {
364        time.quantity
365    }
366}
367
368// ═══════════════════════════════════════════════════════════════════════════
369// TimeInstant trait
370// ═══════════════════════════════════════════════════════════════════════════
371
372/// Trait for types that represent a point in time.
373///
374/// Types implementing this trait can be used as time instants in `Interval<T>`
375/// and provide conversions to/from UTC plus basic arithmetic operations.
376pub trait TimeInstant: Copy + Clone + PartialEq + PartialOrd + Sized {
377    /// The duration type used for arithmetic operations.
378    type Duration;
379
380    /// Convert this time instant to UTC DateTime.
381    fn to_utc(&self) -> Option<DateTime<Utc>>;
382
383    /// Create a time instant from UTC DateTime.
384    fn from_utc(datetime: DateTime<Utc>) -> Self;
385
386    /// Compute the difference between two time instants.
387    fn difference(&self, other: &Self) -> Self::Duration;
388
389    /// Add a duration to this time instant.
390    fn add_duration(&self, duration: Self::Duration) -> Self;
391
392    /// Subtract a duration from this time instant.
393    fn sub_duration(&self, duration: Self::Duration) -> Self;
394}
395
396impl<S: TimeScale> TimeInstant for Time<S> {
397    type Duration = Days;
398
399    #[inline]
400    fn to_utc(&self) -> Option<DateTime<Utc>> {
401        Time::to_utc(self)
402    }
403
404    #[inline]
405    fn from_utc(datetime: DateTime<Utc>) -> Self {
406        Time::from_utc(datetime)
407    }
408
409    #[inline]
410    fn difference(&self, other: &Self) -> Self::Duration {
411        *self - *other
412    }
413
414    #[inline]
415    fn add_duration(&self, duration: Self::Duration) -> Self {
416        *self + duration
417    }
418
419    #[inline]
420    fn sub_duration(&self, duration: Self::Duration) -> Self {
421        *self - duration
422    }
423}
424
425impl TimeInstant for DateTime<Utc> {
426    type Duration = chrono::Duration;
427
428    fn to_utc(&self) -> Option<DateTime<Utc>> {
429        Some(*self)
430    }
431
432    fn from_utc(datetime: DateTime<Utc>) -> Self {
433        datetime
434    }
435
436    fn difference(&self, other: &Self) -> Self::Duration {
437        *self - *other
438    }
439
440    fn add_duration(&self, duration: Self::Duration) -> Self {
441        *self + duration
442    }
443
444    fn sub_duration(&self, duration: Self::Duration) -> Self {
445        *self - duration
446    }
447}
448
449// ═══════════════════════════════════════════════════════════════════════════
450// Tests
451// ═══════════════════════════════════════════════════════════════════════════
452
453#[cfg(test)]
454mod tests {
455    use super::super::scales::{JD, MJD};
456    use super::*;
457    use chrono::TimeZone;
458
459    #[test]
460    fn test_julian_day_creation() {
461        let jd = Time::<JD>::new(2_451_545.0);
462        assert_eq!(jd.quantity(), Days::new(2_451_545.0));
463    }
464
465    #[test]
466    fn test_jd_utc_roundtrip() {
467        // from_utc applies ΔT (UT→TT); to_utc inverts it (TT→UT).
468        let datetime = DateTime::from_timestamp(946_728_000, 0).unwrap();
469        let jd = Time::<JD>::from_utc(datetime);
470        let back = jd.to_utc().expect("to_utc");
471        let delta_ns =
472            back.timestamp_nanos_opt().unwrap() - datetime.timestamp_nanos_opt().unwrap();
473        assert!(delta_ns.abs() < 1_000, "roundtrip error: {} ns", delta_ns);
474    }
475
476    #[test]
477    fn test_from_utc_applies_delta_t() {
478        // 2000-01-01 12:00:00 UTC → JD(UT)=2451545.0; ΔT≈63.83 s
479        let datetime = DateTime::from_timestamp(946_728_000, 0).unwrap();
480        let jd = Time::<JD>::from_utc(datetime);
481        let delta_t_secs = (jd.quantity() - Days::new(2_451_545.0)).to::<Second>();
482        assert!(
483            (delta_t_secs - Seconds::new(63.83)).abs() < Seconds::new(1.0),
484            "ΔT correction = {} s, expected ~63.83 s",
485            delta_t_secs
486        );
487    }
488
489    #[test]
490    fn test_julian_conversions() {
491        let jd = Time::<JD>::J2000 + Days::new(365_250.0);
492        assert!((jd.julian_millennias() - Millennia::new(1.0)).abs() < 1e-12);
493        assert!((jd.julian_centuries() - Centuries::new(10.0)).abs() < Centuries::new(1e-12));
494        assert!((jd.julian_years() - JulianYears::new(1000.0)).abs() < JulianYears::new(1e-9));
495    }
496
497    #[test]
498    fn test_tt_to_tdb_and_min_max() {
499        let jd_tdb = Time::<JD>::tt_to_tdb(Time::<JD>::J2000);
500        assert!((jd_tdb - Time::<JD>::J2000).abs() < 1e-6);
501
502        let earlier = Time::<JD>::J2000;
503        let later = earlier + Days::new(1.0);
504        assert_eq!(earlier.min(later), earlier);
505        assert_eq!(earlier.max(later), later);
506    }
507
508    #[test]
509    fn test_const_min_max() {
510        const A: Time<JD> = Time::<JD>::new(10.0);
511        const B: Time<JD> = Time::<JD>::new(14.0);
512        const MIN: Time<JD> = A.min(B);
513        const MAX: Time<JD> = A.max(B);
514        assert_eq!(MIN.quantity(), Days::new(10.0));
515        assert_eq!(MAX.quantity(), Days::new(14.0));
516    }
517
518    #[test]
519    fn test_mean_and_const_mean() {
520        let a = Time::<JD>::new(10.0);
521        let b = Time::<JD>::new(14.0);
522        assert_eq!(a.mean(b).quantity(), Days::new(12.0));
523        assert_eq!(b.mean(a).quantity(), Days::new(12.0));
524
525        const MID: Time<JD> = Time::<JD>::new(10.0).mean(Time::<JD>::new(14.0));
526        assert_eq!(MID.quantity(), Days::new(12.0));
527    }
528
529    #[test]
530    fn test_into_days() {
531        let jd = Time::<JD>::new(2_451_547.5);
532        let days: Days = jd.into();
533        assert_eq!(days, 2_451_547.5);
534
535        let roundtrip = Time::<JD>::from(days);
536        assert_eq!(roundtrip, jd);
537    }
538
539    #[test]
540    fn test_into_julian_years() {
541        let jd = Time::<JD>::J2000 + Days::new(365.25 * 2.0);
542        let years: JulianYears = jd.into();
543        assert!((years - JulianYears::new(2.0)).abs() < JulianYears::new(1e-12));
544
545        let roundtrip = Time::<JD>::from(years);
546        assert!((roundtrip.quantity() - jd.quantity()).abs() < Days::new(1e-12));
547    }
548
549    #[test]
550    fn test_into_centuries() {
551        let jd = Time::<JD>::J2000 + Days::new(36_525.0 * 3.0);
552        let centuries: Centuries = jd.into();
553        assert!((centuries - Centuries::new(3.0)).abs() < Centuries::new(1e-12));
554
555        let roundtrip = Time::<JD>::from(centuries);
556        assert!((roundtrip.quantity() - jd.quantity()).abs() < Days::new(1e-12));
557    }
558
559    #[test]
560    fn test_into_millennia() {
561        let jd = Time::<JD>::J2000 + Days::new(365_250.0 * 1.5);
562        let millennia: Millennia = jd.into();
563        assert!((millennia - Millennia::new(1.5)).abs() < Millennia::new(1e-12));
564
565        let roundtrip = Time::<JD>::from(millennia);
566        assert!((roundtrip.quantity() - jd.quantity()).abs() < Days::new(1e-9));
567    }
568
569    #[test]
570    fn test_mjd_creation() {
571        let mjd = Time::<MJD>::new(51_544.5);
572        assert_eq!(mjd.quantity(), Days::new(51_544.5));
573    }
574
575    #[test]
576    fn test_mjd_into_jd() {
577        let mjd = Time::<MJD>::new(51_544.5);
578        let jd: Time<JD> = mjd.into();
579        assert_eq!(jd.quantity(), Days::new(2_451_545.0));
580    }
581
582    #[test]
583    fn test_mjd_utc_roundtrip() {
584        let datetime = DateTime::from_timestamp(946_728_000, 0).unwrap();
585        let mjd = Time::<MJD>::from_utc(datetime);
586        let back = mjd.to_utc().expect("to_utc");
587        let delta_ns =
588            back.timestamp_nanos_opt().unwrap() - datetime.timestamp_nanos_opt().unwrap();
589        assert!(delta_ns.abs() < 1_000, "roundtrip error: {} ns", delta_ns);
590    }
591
592    #[test]
593    fn test_mjd_from_utc_applies_delta_t() {
594        // MJD epoch is JD − 2400000.5; ΔT should shift value by ~63.83/86400 days
595        let datetime = DateTime::from_timestamp(946_728_000, 0).unwrap();
596        let mjd = Time::<MJD>::from_utc(datetime);
597        let delta_t_secs = (mjd.quantity() - Days::new(51_544.5)).to::<Second>();
598        assert!(
599            (delta_t_secs - Seconds::new(63.83)).abs() < Seconds::new(1.0),
600            "ΔT correction = {} s, expected ~63.83 s",
601            delta_t_secs
602        );
603    }
604
605    #[test]
606    fn test_mjd_add_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(59_001.5));
610    }
611
612    #[test]
613    fn test_mjd_sub_days() {
614        let mjd = Time::<MJD>::new(59_000.0);
615        let result = mjd - Days::new(1.5);
616        assert_eq!(result.quantity(), Days::new(58_998.5));
617    }
618
619    #[test]
620    fn test_mjd_sub_mjd() {
621        let mjd1 = Time::<MJD>::new(59_001.0);
622        let mjd2 = Time::<MJD>::new(59_000.0);
623        let diff = mjd1 - mjd2;
624        assert_eq!(diff, 1.0);
625    }
626
627    #[test]
628    fn test_mjd_comparison() {
629        let mjd1 = Time::<MJD>::new(59_000.0);
630        let mjd2 = Time::<MJD>::new(59_001.0);
631        assert!(mjd1 < mjd2);
632        assert!(mjd2 > mjd1);
633    }
634
635    #[test]
636    fn test_display_jd() {
637        let jd = Time::<JD>::new(2_451_545.0);
638        let s = format!("{jd}");
639        assert!(s.contains("Julian Day"));
640    }
641
642    #[test]
643    fn test_try_new_finite() {
644        let jd = Time::<JD>::try_new(2_451_545.0);
645        assert!(jd.is_ok());
646        assert_eq!(jd.unwrap().value(), 2_451_545.0);
647    }
648
649    #[test]
650    fn test_try_new_nan() {
651        assert!(Time::<JD>::try_new(f64::NAN).is_err());
652    }
653
654    #[test]
655    fn test_try_new_infinity() {
656        assert!(Time::<JD>::try_new(f64::INFINITY).is_err());
657        assert!(Time::<JD>::try_new(f64::NEG_INFINITY).is_err());
658    }
659
660    #[test]
661    fn test_try_from_days() {
662        assert!(Time::<JD>::try_from_days(Days::new(100.0)).is_ok());
663        assert!(Time::<JD>::try_from_days(Days::new(f64::NAN)).is_err());
664    }
665
666    #[test]
667    fn test_display_mjd() {
668        let mjd = Time::<MJD>::new(51_544.5);
669        let s = format!("{mjd}");
670        assert!(s.contains("MJD"));
671    }
672
673    #[test]
674    fn test_add_assign_sub_assign() {
675        let mut jd = Time::<JD>::new(2_451_545.0);
676        jd += Days::new(1.0);
677        assert_eq!(jd.quantity(), Days::new(2_451_546.0));
678        jd -= Days::new(0.5);
679        assert_eq!(jd.quantity(), Days::new(2_451_545.5));
680    }
681
682    #[test]
683    fn test_add_years() {
684        let jd = Time::<JD>::new(2_450_000.0);
685        let with_years = jd + Years::new(1.0);
686        let span: Days = with_years - jd;
687        assert!((span - Time::<JD>::JULIAN_YEAR).abs() < Days::new(1e-9));
688    }
689
690    #[test]
691    fn test_div_days_and_f64() {
692        let jd = Time::<JD>::new(100.0);
693        assert!((jd / Days::new(2.0) - 50.0).abs() < 1e-12);
694        assert!((jd / 4.0 - 25.0).abs() < 1e-12);
695    }
696
697    #[test]
698    fn test_to_method_jd_mjd() {
699        let jd = Time::<JD>::new(2_451_545.0);
700        let mjd = jd.to::<MJD>();
701        assert!((mjd.quantity() - Days::new(51_544.5)).abs() < Days::new(1e-10));
702    }
703
704    #[test]
705    fn timeinstant_for_julian_date_handles_arithmetic() {
706        let jd = Time::<JD>::new(2_451_545.0);
707        let other = jd + Days::new(2.0);
708
709        assert_eq!(jd.difference(&other), Days::new(-2.0));
710        assert_eq!(
711            jd.add_duration(Days::new(1.5)).quantity(),
712            Days::new(2_451_546.5)
713        );
714        assert_eq!(
715            other.sub_duration(Days::new(0.5)).quantity(),
716            Days::new(2_451_546.5)
717        );
718    }
719
720    #[test]
721    fn timeinstant_for_modified_julian_date_roundtrips_utc() {
722        let dt = DateTime::from_timestamp(946_684_800, 123_000_000).unwrap(); // 2000-01-01T00:00:00.123Z
723        let mjd = Time::<MJD>::from_utc(dt);
724        let back = mjd.to_utc().expect("mjd to utc");
725
726        assert_eq!(mjd.difference(&mjd), Days::new(0.0));
727        assert_eq!(
728            mjd.add_duration(Days::new(1.0)).quantity(),
729            mjd.quantity() + Days::new(1.0)
730        );
731        assert_eq!(
732            mjd.sub_duration(Days::new(0.5)).quantity(),
733            mjd.quantity() - Days::new(0.5)
734        );
735        let delta_ns = back.timestamp_nanos_opt().unwrap() - dt.timestamp_nanos_opt().unwrap();
736        assert!(delta_ns.abs() < 10_000, "nanos differ by {}", delta_ns);
737    }
738
739    #[test]
740    fn timeinstant_for_datetime_uses_chrono_durations() {
741        let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
742        let later = Utc.with_ymd_and_hms(2024, 1, 2, 6, 0, 0).unwrap();
743        let diff = later.difference(&base);
744
745        assert_eq!(diff.num_hours(), 30);
746        assert_eq!(
747            base.add_duration(diff + chrono::Duration::hours(6)),
748            later + chrono::Duration::hours(6)
749        );
750        assert_eq!(later.sub_duration(diff), base);
751        assert_eq!(TimeInstant::to_utc(&later), Some(later));
752    }
753
754    // ── New coverage tests ────────────────────────────────────────────
755
756    #[test]
757    fn test_non_finite_error_display() {
758        let err = NonFiniteTimeError;
759        let msg = format!("{err}");
760        assert!(msg.contains("finite"), "unexpected: {msg}");
761    }
762
763    #[test]
764    fn test_julian_day_and_julian_day_value() {
765        // MJD 51544.5 == JD 2451545.0 (J2000.0 in TT).
766        let mjd = Time::<MJD>::new(51_544.5);
767        let jd_days = mjd.julian_day();
768        assert!(
769            (jd_days - Days::new(2_451_545.0)).abs() < Days::new(1e-10),
770            "julian_day mismatch: {jd_days}"
771        );
772        assert!(
773            (mjd.julian_day_value() - 2_451_545.0).abs() < 1e-10,
774            "julian_day_value mismatch: {}",
775            mjd.julian_day_value()
776        );
777    }
778
779    #[test]
780    fn test_timeinstant_trait_to_utc_and_from_utc_for_time() {
781        // Call to_utc / from_utc through the TimeInstant trait (UFCS) so that
782        // the forwarding wrapper functions in the TimeInstant impl are covered.
783        let jd = Time::<JD>::new(2_451_545.0);
784        let utc: Option<_> = TimeInstant::to_utc(&jd);
785        assert!(utc.is_some());
786        let back: Time<JD> = TimeInstant::from_utc(utc.unwrap());
787        assert!((back.value() - jd.value()).abs() < 1e-6);
788    }
789
790    #[test]
791    fn test_datetime_timeinstant_from_utc() {
792        // Exercises TimeInstant::from_utc for DateTime<Utc>.
793        let dt = DateTime::from_timestamp(0, 0).unwrap();
794        let back: DateTime<Utc> = TimeInstant::from_utc(dt);
795        assert_eq!(back, dt);
796    }
797
798    #[cfg(feature = "serde")]
799    #[test]
800    fn test_serde_serialize_time() {
801        let jd = Time::<JD>::new(2_451_545.0);
802        let json = serde_json::to_string(&jd).unwrap();
803        assert!(json.contains("2451545"), "serialized: {json}");
804        let back: Time<JD> = serde_json::from_str(&json).unwrap();
805        assert_eq!(jd.value(), back.value());
806    }
807
808    #[cfg(feature = "serde")]
809    #[test]
810    fn test_serde_deserialize_nan_rejected() {
811        use serde::{de::IntoDeserializer, Deserialize};
812        let result: Result<Time<JD>, serde::de::value::Error> =
813            Time::<JD>::deserialize(f64::NAN.into_deserializer());
814        assert!(result.is_err());
815        let msg = result.unwrap_err().to_string();
816        assert!(msg.contains("finite"), "unexpected error: {msg}");
817    }
818}