Skip to main content

tempoch_core/
format.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (C) 2026 Vallés Puig, Ramon
3
4//! Typed encoded formats of [`crate::Time`].
5//!
6//! A *format* marker specifies how a time instant is externally expressed:
7//! Julian Day (`JD`), Modified Julian Day (`MJD`), J2000 seconds (`J2000s`),
8//! POSIX seconds (`Unix`), or GPS seconds (`GPS`). Format is orthogonal to
9//! *scale*: `JulianDate<TT>` and `JulianDate<UTC>` share the same format but
10//! live on different physical time axes, and the compiler treats them as
11//! distinct, incompatible types.
12//!
13//! # Main types
14//!
15//! - [`TimeFormat`] — sealed marker trait for format tags (`JD`, `MJD`, …).
16//! - [`EncodedTime<S, F>`](crate::EncodedTime) — a typed encoded instant; `S`
17//!   is the [`Scale`] and `F` is the [`TimeFormat`].
18//! - [`FormatForScale<S>`] — witness that format `F` can encode scale `S`.
19//! - [`InfallibleFormatForScale<S>`] — witness that the round-trip is
20//!   context-free.
21
22use core::fmt;
23use core::marker::PhantomData;
24
25use crate::context::TimeContext;
26use crate::encoding::{
27    j2000_seconds_to_jd, j2000_seconds_to_mjd, jd_to_j2000_seconds, mjd_to_j2000_seconds,
28};
29use crate::error::ConversionError;
30use crate::scale::conversion::InfallibleScaleConvert;
31use crate::scale::{CoordinateScale, Scale, TAI, UTC};
32use crate::sealed::Sealed;
33use crate::target::{ContextConversionTarget, ConversionTarget, InfallibleConversionTarget};
34use crate::time::Time;
35use qtty::{Day, Quantity, Second, Unit};
36
37/// Marker trait for an external time encoding such as JD or Unix time.
38///
39/// A `TimeFormat` value is a zero-sized tag that identifies how a time instant
40/// is expressed (Julian Day, Modified Julian Day, J2000 seconds, POSIX seconds,
41/// GPS seconds). It is orthogonal to [`Scale`], which identifies the physical
42/// time axis.
43///
44/// Sealed: implementations live in this crate only.
45#[allow(private_bounds)]
46pub trait TimeFormat: Sealed + Copy + Clone + fmt::Debug + 'static {
47    /// Quantity unit used by this format.
48    type Unit: Unit;
49
50    /// Human-readable format name.
51    const NAME: &'static str;
52}
53
54/// Witness that format `F` can encode and decode instants on scale `S`.
55#[allow(private_bounds)]
56pub trait FormatForScale<S: Scale>: TimeFormat + Sealed {
57    fn try_from_time(
58        time: Time<S>,
59        ctx: &TimeContext,
60    ) -> Result<Quantity<Self::Unit>, ConversionError>;
61    fn try_into_time(
62        raw: Quantity<Self::Unit>,
63        ctx: &TimeContext,
64    ) -> Result<Time<S>, ConversionError>;
65}
66
67/// Witness that format `F` can encode scale `S` without a [`TimeContext`].
68#[allow(private_bounds)]
69pub trait InfallibleFormatForScale<S: Scale>: FormatForScale<S> + Sealed {
70    fn from_time(time: Time<S>) -> Quantity<Self::Unit>;
71    fn into_time(raw: Quantity<Self::Unit>) -> Time<S>;
72}
73
74// ── Format markers ────────────────────────────────────────────────────────────
75
76/// J2000 seconds on the source scale's coordinate axis.
77#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
78pub struct J2000s;
79
80/// Julian Day on the source scale's coordinate axis.
81#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
82pub struct JD;
83
84/// Modified Julian Day on the source scale's coordinate axis.
85#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
86pub struct MJD;
87
88/// POSIX seconds on the UTC civil axis.
89#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
90pub struct Unix;
91
92/// GPS seconds since the GPS epoch on the TAI/GPS continuous axis.
93#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
94pub struct GPS;
95
96impl Sealed for J2000s {}
97impl Sealed for JD {}
98impl Sealed for MJD {}
99impl Sealed for Unix {}
100impl Sealed for GPS {}
101
102impl TimeFormat for J2000s {
103    type Unit = qtty::unit::Second;
104    const NAME: &'static str = "J2000s";
105}
106
107impl TimeFormat for JD {
108    type Unit = qtty::unit::Day;
109    const NAME: &'static str = "JD";
110}
111
112impl TimeFormat for MJD {
113    type Unit = qtty::unit::Day;
114    const NAME: &'static str = "MJD";
115}
116
117impl TimeFormat for Unix {
118    type Unit = qtty::unit::Second;
119    const NAME: &'static str = "Unix";
120}
121
122impl TimeFormat for GPS {
123    type Unit = qtty::unit::Second;
124    const NAME: &'static str = "GPS";
125}
126
127// ── EncodedTime ───────────────────────────────────────────────────────────────
128
129/// A typed external encoding of a [`Time<S>`] instant.
130///
131/// `EncodedTime<S, F>` carries two phantom type parameters:
132///
133/// - `S: Scale` — the physical time axis (`TT`, `TAI`, `UTC`, …).
134/// - `F: TimeFormat` — the encoding scheme (`JD`, `MJD`, `J2000s`, `Unix`,
135///   `GPS`).
136///
137/// The compiler therefore treats `EncodedTime<TT, JD>` and
138/// `EncodedTime<UTC, JD>` as completely distinct, incompatible types even
139/// though both carry a day-valued quantity internally.
140pub struct EncodedTime<S: Scale, F: TimeFormat> {
141    raw: Quantity<F::Unit>,
142    _marker: PhantomData<fn() -> S>,
143}
144
145impl<S: Scale, F: TimeFormat> Copy for EncodedTime<S, F> {}
146
147impl<S: Scale, F: TimeFormat> Clone for EncodedTime<S, F> {
148    #[inline]
149    fn clone(&self) -> Self {
150        *self
151    }
152}
153
154impl<S: Scale, F: TimeFormat> fmt::Debug for EncodedTime<S, F> {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        f.debug_struct("EncodedTime")
157            .field("scale", &S::NAME)
158            .field("format", &F::NAME)
159            .field("raw", &self.raw)
160            .finish()
161    }
162}
163
164impl<S: Scale, F: TimeFormat> fmt::Display for EncodedTime<S, F>
165where
166    qtty::Quantity<F::Unit>: fmt::Display,
167{
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        fmt::Display::fmt(&self.raw, f)
170    }
171}
172
173impl<S: Scale, F: TimeFormat> fmt::LowerExp for EncodedTime<S, F>
174where
175    qtty::Quantity<F::Unit>: fmt::LowerExp,
176{
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        fmt::LowerExp::fmt(&self.raw, f)
179    }
180}
181
182impl<S: Scale, F: TimeFormat> fmt::UpperExp for EncodedTime<S, F>
183where
184    qtty::Quantity<F::Unit>: fmt::UpperExp,
185{
186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187        fmt::UpperExp::fmt(&self.raw, f)
188    }
189}
190
191impl<S: Scale, F: TimeFormat> PartialEq for EncodedTime<S, F> {
192    #[inline]
193    fn eq(&self, other: &Self) -> bool {
194        self.raw == other.raw
195    }
196}
197
198impl<S: Scale, F: TimeFormat> PartialOrd for EncodedTime<S, F> {
199    #[inline]
200    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
201        self.raw.partial_cmp(&other.raw)
202    }
203}
204
205impl<S: Scale, F: TimeFormat> EncodedTime<S, F> {
206    #[inline]
207    pub(crate) const fn new_unchecked(raw: Quantity<F::Unit>) -> Self {
208        Self {
209            raw,
210            _marker: PhantomData,
211        }
212    }
213
214    /// Construct from a raw typed quantity without checking for finiteness.
215    ///
216    /// For use in `const` contexts. The caller must ensure `raw` is finite;
217    /// passing a non-finite value produces incorrect behaviour.
218    #[inline]
219    pub const fn from_raw_unchecked(raw: Quantity<F::Unit>) -> Self {
220        Self::new_unchecked(raw)
221    }
222
223    /// Return the underlying typed quantity.
224    #[inline]
225    pub const fn raw(self) -> Quantity<F::Unit> {
226        self.raw
227    }
228
229    /// Alias for [`Self::raw`].
230    #[inline]
231    pub const fn quantity(self) -> Quantity<F::Unit> {
232        self.raw
233    }
234}
235
236impl<S: Scale> EncodedTime<S, JD> {
237    /// J2000.0 epoch as a Julian Date on scale `S` (JD 2 451 545.0).
238    pub const J2000: Self = Self::from_raw_unchecked(crate::constats::J2000_JD_TT.raw());
239}
240
241impl<S: Scale, F> EncodedTime<S, F>
242where
243    F: FormatForScale<S>,
244{
245    /// Construct a typed encoded instant from its raw quantity.
246    #[inline]
247    pub fn try_new(raw: Quantity<F::Unit>) -> Result<Self, ConversionError> {
248        if raw.is_finite() {
249            Ok(Self::new_unchecked(raw))
250        } else {
251            Err(ConversionError::NonFinite)
252        }
253    }
254
255    /// Convert this encoded instant to the canonical [`Time<S>`] model.
256    ///
257    /// Snapshots the active time-data bundle at call time via
258    /// [`TimeContext::new`]. For reproducible pipelines, prefer
259    /// [`to_time_with`](Self::to_time_with) with an explicit context.
260    #[inline]
261    pub fn try_to_time(self) -> Result<Time<S>, ConversionError> {
262        F::try_into_time(self.raw, &TimeContext::new())
263    }
264
265    /// Convert this encoded instant to the canonical [`Time<S>`] model using an explicit context.
266    #[inline]
267    pub fn to_time_with(self, ctx: &TimeContext) -> Result<Time<S>, ConversionError> {
268        F::try_into_time(self.raw, ctx)
269    }
270}
271
272impl<S: Scale, F> EncodedTime<S, F>
273where
274    F: InfallibleFormatForScale<S>,
275{
276    #[inline]
277    pub(crate) fn from_time_infallible(time: Time<S>) -> Self {
278        Self::new_unchecked(F::from_time(time))
279    }
280
281    /// Infallible conversion to the canonical [`Time<S>`] model.
282    #[inline]
283    pub fn to_time(self) -> Time<S> {
284        F::into_time(self.raw)
285    }
286
287    /// Unified infallible conversion to a target scale or encoded format.
288    #[allow(private_bounds)]
289    #[inline]
290    pub fn to<T>(self) -> T::Output
291    where
292        T: InfallibleConversionTarget<S>,
293    {
294        T::convert(self.to_time())
295    }
296
297    /// Unified fallible conversion to a target scale or encoded format.
298    #[allow(private_bounds)]
299    #[inline]
300    pub fn try_to<T>(self) -> Result<T::Output, ConversionError>
301    where
302        T: ConversionTarget<S>,
303    {
304        T::try_convert(self.to_time())
305    }
306}
307
308impl<S: Scale, F> EncodedTime<S, F>
309where
310    F: FormatForScale<S>,
311{
312    /// Unified context-backed conversion to a target scale or encoded format.
313    #[allow(private_bounds)]
314    #[inline]
315    pub fn to_with<T>(self, ctx: &TimeContext) -> Result<T::Output, ConversionError>
316    where
317        T: ContextConversionTarget<S>,
318    {
319        T::convert_with(self.to_time_with(ctx)?, ctx)
320    }
321}
322
323// ── Type aliases ──────────────────────────────────────────────────────────────
324
325/// `EncodedTime<S, JD>` convenience alias.
326pub type JulianDate<S> = EncodedTime<S, JD>;
327
328/// `EncodedTime<S, MJD>` convenience alias.
329pub type ModifiedJulianDate<S> = EncodedTime<S, MJD>;
330
331/// `EncodedTime<S, J2000s>` convenience alias.
332pub type J2000Seconds<S> = EncodedTime<S, J2000s>;
333
334/// `EncodedTime<UTC, Unix>` convenience alias.
335pub type UnixTime = EncodedTime<UTC, Unix>;
336
337/// `EncodedTime<TAI, GPS>` convenience alias.
338pub type GpsTime = EncodedTime<TAI, GPS>;
339
340// ── FormatForScale impls ──────────────────────────────────────────────────────
341
342macro_rules! coordinate_format {
343    ($fmt:ty, $quantity:ty, $from_time:expr, $to_time:expr) => {
344        impl<S: CoordinateScale> FormatForScale<S> for $fmt {
345            #[inline]
346            fn try_from_time(
347                time: Time<S>,
348                _ctx: &TimeContext,
349            ) -> Result<$quantity, ConversionError> {
350                Ok(<Self as InfallibleFormatForScale<S>>::from_time(time))
351            }
352
353            #[inline]
354            fn try_into_time(
355                raw: $quantity,
356                _ctx: &TimeContext,
357            ) -> Result<Time<S>, ConversionError> {
358                Ok(<Self as InfallibleFormatForScale<S>>::into_time(raw))
359            }
360        }
361
362        impl<S: CoordinateScale> InfallibleFormatForScale<S> for $fmt {
363            #[inline]
364            fn from_time(time: Time<S>) -> $quantity {
365                $from_time(time)
366            }
367
368            #[inline]
369            fn into_time(raw: $quantity) -> Time<S> {
370                $to_time(raw)
371            }
372        }
373    };
374}
375
376coordinate_format!(
377    J2000s,
378    Second,
379    |time: Time<_>| time.raw_j2000_seconds(),
380    |raw: Second| Time::from_raw_j2000_seconds(raw).expect("finite J2000 seconds must decode")
381);
382coordinate_format!(
383    JD,
384    Day,
385    |time: Time<_>| j2000_seconds_to_jd(time.raw_j2000_seconds()),
386    |raw: Day| Time::from_raw_j2000_seconds(jd_to_j2000_seconds(raw))
387        .expect("finite Julian date must decode")
388);
389coordinate_format!(
390    MJD,
391    Day,
392    |time: Time<_>| j2000_seconds_to_mjd(time.raw_j2000_seconds()),
393    |raw: Day| Time::from_raw_j2000_seconds(mjd_to_j2000_seconds(raw))
394        .expect("finite Modified Julian date must decode")
395);
396
397impl FormatForScale<UTC> for Unix {
398    #[inline]
399    fn try_from_time(time: Time<UTC>, ctx: &TimeContext) -> Result<Second, ConversionError> {
400        time.raw_unix_seconds_with(ctx)
401    }
402
403    #[inline]
404    fn try_into_time(raw: Second, ctx: &TimeContext) -> Result<Time<UTC>, ConversionError> {
405        Time::from_raw_unix_seconds_with(raw, ctx)
406    }
407}
408
409impl FormatForScale<TAI> for GPS {
410    #[inline]
411    fn try_from_time(time: Time<TAI>, _ctx: &TimeContext) -> Result<Second, ConversionError> {
412        Ok(<Self as InfallibleFormatForScale<TAI>>::from_time(time))
413    }
414
415    #[inline]
416    fn try_into_time(raw: Second, _ctx: &TimeContext) -> Result<Time<TAI>, ConversionError> {
417        Ok(<Self as InfallibleFormatForScale<TAI>>::into_time(raw))
418    }
419}
420
421impl InfallibleFormatForScale<TAI> for GPS {
422    #[inline]
423    fn from_time(time: Time<TAI>) -> Second {
424        time.raw_gps_seconds()
425    }
426
427    #[inline]
428    fn into_time(raw: Second) -> Time<TAI> {
429        Time::from_raw_gps_seconds(raw).expect("finite GPS seconds must decode")
430    }
431}
432
433// ── From/Into between EncodedTime and Time ────────────────────────────────────
434
435impl<S: Scale, F> From<EncodedTime<S, F>> for Time<S>
436where
437    F: InfallibleFormatForScale<S>,
438{
439    #[inline]
440    fn from(value: EncodedTime<S, F>) -> Self {
441        value.to_time()
442    }
443}
444
445impl<S: Scale, F> From<Time<S>> for EncodedTime<S, F>
446where
447    F: InfallibleFormatForScale<S>,
448{
449    #[inline]
450    fn from(value: Time<S>) -> Self {
451        Self::from_time_infallible(value)
452    }
453}
454
455// ── ConversionTarget impls for format markers ─────────────────────────────────
456
457impl<S: CoordinateScale> ConversionTarget<S> for J2000s {
458    type Output = EncodedTime<S, J2000s>;
459
460    #[inline]
461    fn try_convert(src: Time<S>) -> Result<Self::Output, ConversionError> {
462        Ok(EncodedTime::from_time_infallible(src))
463    }
464}
465
466impl<S: CoordinateScale> InfallibleConversionTarget<S> for J2000s {
467    #[inline]
468    fn convert(src: Time<S>) -> Self::Output {
469        EncodedTime::from_time_infallible(src)
470    }
471}
472
473impl<S: CoordinateScale> ConversionTarget<S> for JD {
474    type Output = EncodedTime<S, JD>;
475
476    #[inline]
477    fn try_convert(src: Time<S>) -> Result<Self::Output, ConversionError> {
478        Ok(EncodedTime::from_time_infallible(src))
479    }
480}
481
482impl<S: CoordinateScale> InfallibleConversionTarget<S> for JD {
483    #[inline]
484    fn convert(src: Time<S>) -> Self::Output {
485        EncodedTime::from_time_infallible(src)
486    }
487}
488
489impl<S: CoordinateScale> ConversionTarget<S> for MJD {
490    type Output = EncodedTime<S, MJD>;
491
492    #[inline]
493    fn try_convert(src: Time<S>) -> Result<Self::Output, ConversionError> {
494        Ok(EncodedTime::from_time_infallible(src))
495    }
496}
497
498impl<S: CoordinateScale> InfallibleConversionTarget<S> for MJD {
499    #[inline]
500    fn convert(src: Time<S>) -> Self::Output {
501        EncodedTime::from_time_infallible(src)
502    }
503}
504
505impl<S> ConversionTarget<S> for Unix
506where
507    S: crate::scale::Scale + InfallibleScaleConvert<UTC>,
508{
509    type Output = EncodedTime<UTC, Unix>;
510
511    /// Snapshots the active time-data bundle at call time via
512    /// [`TimeContext::new`]. For reproducible pipelines, prefer
513    /// [`to_with::<Unix>(&ctx)`](crate::time::Time::to_with).
514    #[inline]
515    fn try_convert(src: Time<S>) -> Result<Self::Output, ConversionError> {
516        let utc = src.to_scale::<UTC>();
517        let raw = Unix::try_from_time(utc, &TimeContext::new())?;
518        Ok(EncodedTime::new_unchecked(raw))
519    }
520}
521
522impl ContextConversionTarget<UTC> for Unix {
523    type Output = EncodedTime<UTC, Unix>;
524
525    #[inline]
526    fn convert_with(src: Time<UTC>, ctx: &TimeContext) -> Result<Self::Output, ConversionError> {
527        let raw = Unix::try_from_time(src, ctx)?;
528        Ok(EncodedTime::new_unchecked(raw))
529    }
530}
531
532impl<S> ContextConversionTarget<S> for Unix
533where
534    S: crate::scale::Scale + crate::scale::conversion::ContextScaleConvert<UTC>,
535{
536    type Output = EncodedTime<UTC, Unix>;
537
538    #[inline]
539    fn convert_with(src: Time<S>, ctx: &TimeContext) -> Result<Self::Output, ConversionError> {
540        let utc = src.to_scale_with::<UTC>(ctx)?;
541        let raw = Unix::try_from_time(utc, ctx)?;
542        Ok(EncodedTime::new_unchecked(raw))
543    }
544}
545
546impl<S> ConversionTarget<S> for GPS
547where
548    S: crate::scale::Scale + InfallibleScaleConvert<TAI>,
549{
550    type Output = EncodedTime<TAI, GPS>;
551
552    #[inline]
553    fn try_convert(src: Time<S>) -> Result<Self::Output, ConversionError> {
554        Ok(Self::convert(src))
555    }
556}
557
558impl<S> InfallibleConversionTarget<S> for GPS
559where
560    S: crate::scale::Scale + InfallibleScaleConvert<TAI>,
561{
562    #[inline]
563    fn convert(src: Time<S>) -> Self::Output {
564        EncodedTime::from_time_infallible(src.to_scale::<TAI>())
565    }
566}
567
568impl<S> ContextConversionTarget<S> for GPS
569where
570    S: crate::scale::Scale + crate::scale::conversion::ContextScaleConvert<TAI>,
571{
572    type Output = EncodedTime<TAI, GPS>;
573
574    #[inline]
575    fn convert_with(src: Time<S>, ctx: &TimeContext) -> Result<Self::Output, ConversionError> {
576        let tai = src.to_scale_with::<TAI>(ctx)?;
577        Ok(EncodedTime::from_time_infallible(tai))
578    }
579}
580
581// ── Tests ─────────────────────────────────────────────────────────────────────
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586    use crate::context::TimeContext;
587    use crate::scale::{TAI, TT, UTC};
588    use qtty::{Day, Second};
589
590    #[test]
591    fn encoded_time_display_delegates_to_quantity() {
592        let jd = JulianDate::<TT>::try_new(Day::new(2_451_545.123_456_789)).unwrap();
593
594        assert_eq!(format!("{jd:.9}"), "2451545.123456789 d");
595    }
596
597    #[test]
598    fn encoded_time_lower_exp_delegates_to_quantity() {
599        let seconds = J2000Seconds::<TT>::try_new(Second::new(1_234.5)).unwrap();
600        let formatted = format!("{seconds:.2e}");
601
602        assert_eq!(formatted, format!("{:.2e}", seconds.raw()));
603    }
604
605    #[test]
606    fn encoded_time_upper_exp_delegates_to_quantity() {
607        let seconds = J2000Seconds::<TT>::try_new(Second::new(1_234.5)).unwrap();
608        let formatted = format!("{seconds:.2E}");
609
610        assert_eq!(formatted, format!("{:.2E}", seconds.raw()));
611    }
612
613    #[test]
614    fn encoded_time_clone_matches_original() {
615        let jd = JulianDate::<TT>::try_new(Day::new(2_451_545.0)).unwrap();
616        let cloned = <JulianDate<TT> as Clone>::clone(&jd);
617        assert_eq!(jd.raw(), cloned.raw());
618    }
619
620    #[test]
621    fn encoded_time_partial_eq() {
622        let a = JulianDate::<TT>::try_new(Day::new(2_451_545.0)).unwrap();
623        let b = JulianDate::<TT>::try_new(Day::new(2_451_545.0)).unwrap();
624        let c = JulianDate::<TT>::try_new(Day::new(2_451_546.0)).unwrap();
625        assert_eq!(a, b);
626        assert_ne!(a, c);
627    }
628
629    #[test]
630    fn encoded_time_quantity_is_alias_for_raw() {
631        let jd = JulianDate::<TT>::try_new(Day::new(2_451_545.5)).unwrap();
632        assert_eq!(jd.raw(), jd.quantity());
633    }
634
635    #[test]
636    fn encoded_time_try_to_time_on_unix() {
637        let ctx = TimeContext::new();
638        // J2000.0 epoch corresponds to 2000-01-01 11:58:55.816 UTC → unix ~946727935.816
639        let unix = UnixTime::try_new(Second::new(946_727_935.816)).unwrap();
640        let time = unix.to_time_with(&ctx).unwrap();
641        // Round-trip: time back to unix
642        let back = <Unix as FormatForScale<UTC>>::try_from_time(time, &ctx).unwrap();
643        assert!((back - Second::new(946_727_935.816)).abs() < Second::new(1e-3));
644    }
645
646    #[test]
647    fn encoded_time_to_infallible_conversion() {
648        // JulianDate<TT> implements InfallibleFormatForScale<TT>, so .to::<MJD>() works.
649        let jd = JulianDate::<TT>::try_new(Day::new(2_451_545.0)).unwrap();
650        let mjd: ModifiedJulianDate<TT> = jd.to::<MJD>();
651        // JD 2451545.0 == MJD 51544.5 (MJD = JD - 2400000.5)
652        assert!((mjd.raw().value() - 51_544.5).abs() < 1e-9);
653    }
654
655    #[test]
656    fn encoded_time_try_to_conversion() {
657        let jd = JulianDate::<TT>::try_new(Day::new(2_451_545.0)).unwrap();
658        let mjd: ModifiedJulianDate<TT> = jd.try_to::<MJD>().unwrap();
659        assert!((mjd.raw().value() - 51_544.5).abs() < 1e-9);
660    }
661
662    #[test]
663    fn encoded_time_to_with_for_unix() {
664        let ctx = TimeContext::new();
665        let jd = JulianDate::<UTC>::try_new(Day::new(2_451_545.0)).unwrap();
666        let unix: UnixTime = jd.to_with::<Unix>(&ctx).unwrap();
667        // JD 2451545.0 in UTC is around year 2000; unix value should be ~946M seconds.
668        assert!(unix.raw().value().is_finite());
669        assert!(unix.raw().value() > 9e8 && unix.raw().value() < 1e10);
670    }
671
672    #[test]
673    fn gps_format_roundtrip_through_tai() {
674        let gps_seconds = Second::new(0.0);
675        let time: crate::time::Time<TAI> =
676            <GPS as InfallibleFormatForScale<TAI>>::into_time(gps_seconds);
677        let back = <GPS as InfallibleFormatForScale<TAI>>::from_time(time);
678        assert!((back - gps_seconds).abs() < Second::new(1e-12));
679    }
680
681    #[test]
682    fn gps_encoded_time_to_time_roundtrip() {
683        let gps = GpsTime::try_new(Second::new(1_234_567.89)).unwrap();
684        let time = gps.to_time();
685        let back: GpsTime = time.into();
686        // f64 floating point round-trip tolerance for large values
687        assert!((back.raw() - gps.raw()).abs() < Second::new(1e-6));
688    }
689
690    #[test]
691    fn from_encoded_time_into_time() {
692        let jd = JulianDate::<TT>::try_new(Day::new(2_451_545.0)).unwrap();
693        let time: crate::time::Time<TT> = jd.into();
694        let back: JulianDate<TT> = time.into();
695        assert!((back.raw() - Day::new(2_451_545.0)).abs() < Day::new(1e-12));
696    }
697
698    #[test]
699    fn infallible_conversion_target_for_j2000s() {
700        let jd = JulianDate::<TT>::try_new(Day::new(2_451_545.0)).unwrap();
701        let time = jd.to_time();
702        // J2000s::convert gives the J2000 seconds representation
703        let j2k: J2000Seconds<TT> = J2000s::convert(time);
704        // J2000.0 epoch is 0 J2000 seconds by definition
705        assert!((j2k.raw().value()).abs() < 1e-6);
706    }
707
708    #[test]
709    fn conversion_target_try_convert_for_j2000s() {
710        let jd = JulianDate::<TT>::try_new(Day::new(2_451_545.0)).unwrap();
711        let time = jd.to_time();
712        let j2k: J2000Seconds<TT> = J2000s::try_convert(time).unwrap();
713        assert!((j2k.raw().value()).abs() < 1e-6);
714    }
715
716    #[test]
717    fn conversion_target_try_convert_for_jd() {
718        let mjd = ModifiedJulianDate::<TT>::try_new(Day::new(51_544.0)).unwrap();
719        let time = mjd.to_time();
720        let jd: JulianDate<TT> = JD::try_convert(time).unwrap();
721        // MJD 51544.0 == JD 2451544.5 (JD = MJD + 2400000.5)
722        assert!((jd.raw().value() - 2_451_544.5).abs() < 1e-9);
723    }
724
725    #[test]
726    fn conversion_target_try_convert_for_mjd() {
727        let jd = JulianDate::<TT>::try_new(Day::new(2_451_545.0)).unwrap();
728        let time = jd.to_time();
729        let mjd: ModifiedJulianDate<TT> = MJD::try_convert(time).unwrap();
730        // JD 2451545.0 == MJD 51544.5 (MJD = JD - 2400000.5)
731        assert!((mjd.raw().value() - 51_544.5).abs() < 1e-9);
732    }
733
734    #[test]
735    fn gps_conversion_target_try_convert() {
736        let jd = JulianDate::<TT>::try_new(Day::new(2_451_545.0)).unwrap();
737        let time = jd.to_time();
738        let gps: GpsTime = GPS::try_convert(time).unwrap();
739        assert!(gps.raw().is_finite());
740    }
741
742    #[test]
743    fn unix_context_conversion_target() {
744        let ctx = TimeContext::new();
745        let jd = JulianDate::<UTC>::try_new(Day::new(2_451_545.0)).unwrap();
746        let utc_time = jd.to_time();
747        let unix =
748            <Unix as crate::target::ContextConversionTarget<UTC>>::convert_with(utc_time, &ctx)
749                .unwrap();
750        // The Unix time for a JD around the year 2000 should be around 946M–947M seconds.
751        assert!(unix.raw().value().is_finite());
752        assert!(unix.raw().value() > 9e8 && unix.raw().value() < 1e10);
753    }
754
755    #[test]
756    fn debug_includes_format_and_scale() {
757        let jd = JulianDate::<TT>::try_new(Day::new(2_451_545.0)).unwrap();
758        let dbg = format!("{jd:?}");
759        assert!(dbg.contains("TT"), "debug should contain scale name");
760        assert!(dbg.contains("JD"), "debug should contain format name");
761    }
762
763    /// Verifies that `EncodedTime<TT, JD>` and `EncodedTime<UTC, JD>` are
764    /// statically distinct types that cannot be accidentally interchanged.
765    ///
766    /// The phantom scale parameter makes a Julian Date on TT and a Julian Date
767    /// on UTC completely different types even though both hold a `Day` quantity.
768    #[test]
769    fn jd_on_tt_and_utc_are_distinct_types() {
770        fn accept_tt(x: EncodedTime<TT, JD>) -> Day {
771            x.raw()
772        }
773        fn accept_utc(x: EncodedTime<UTC, JD>) -> Day {
774            x.raw()
775        }
776
777        let tt_jd = JulianDate::<TT>::try_new(Day::new(2_451_545.0)).unwrap();
778        let utc_jd = JulianDate::<UTC>::try_new(Day::new(2_451_545.0)).unwrap();
779
780        // Both are valid individually; the types enforce scale separation.
781        let _ = accept_tt(tt_jd);
782        let _ = accept_utc(utc_jd);
783    }
784
785    #[test]
786    fn format_names_are_correct() {
787        assert_eq!(JD::NAME, "JD");
788        assert_eq!(MJD::NAME, "MJD");
789        assert_eq!(J2000s::NAME, "J2000s");
790        assert_eq!(Unix::NAME, "Unix");
791        assert_eq!(GPS::NAME, "GPS");
792    }
793}