Skip to main content

tempoch_core/format/
mod.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (C) 2026 Vallés Puig, Ramon
3
4//! Typed external formats for [`crate::Time`].
5//!
6//! A *format* marker specifies how a time instant is externally expressed.
7//! The built-in markers live in [`markers`]: Julian Day (`JD`),
8//! Modified Julian Day (`MJD`), J2000 seconds (`J2000s`), POSIX seconds
9//! (`Unix`), and GPS seconds (`GPS`). Format is orthogonal to *scale*:
10//! `JulianDate<TT>` and `JulianDate<UTC>` share the same format but live on
11//! different physical time axes, and the compiler treats them as distinct,
12//! incompatible types.
13//!
14//! Instants are always [`crate::Time<S, F>`] with compensated J2000-second
15//! storage; `F` is a phantom encoding tag for `raw()`, conversions, and targets.
16//!
17//! [`JulianDate<S>`], [`ModifiedJulianDate<S>`], [`UnixTime`], and [`GpsTime`] implement
18//! [`Into`] into the default-tagged [`crate::Time`] instant on their scale (`Time<S>`,
19//! [`Time<UTC>`](crate::Time<crate::UTC>), [`Time<TAI>`](crate::Time<crate::TAI>)), equivalent to [`Time::to_j2000s`].
20//! [`crate::Interval::try_new`] therefore accepts encoded endpoints wherever `Into<crate::Time<S>>` is required (including [`crate::Period`]).
21//!
22//! [`J2000Seconds<S>`] is a type alias for [`crate::Time<S>`]; prefer it when you want an explicit name for the default tag.
23//!
24//! # Main types
25//!
26//! - [`TimeFormat`](crate::format::TimeFormat) — sealed marker trait (`JD`, `MJD`, …).
27//! - [`FormatForScale<S>`] — witness that format `F` can encode scale `S`.
28//! - [`InfallibleFormatForScale<S>`] — witness that the round-trip is
29//!   context-free (except where the format itself requires a context, e.g. Unix).
30
31mod time_format;
32pub use time_format::TimeFormat;
33
34pub mod markers;
35pub use markers::{J2000s, Unix, GPS, JD, MJD};
36
37mod traits;
38pub use traits::{FormatForScale, InfallibleFormatForScale};
39
40mod impls;
41
42mod chrono;
43
44/// Julian day instant on scale `S` (`JD` tag).
45pub type JulianDate<S> = crate::model::time::Time<S, JD>;
46/// Modified Julian day instant on scale `S`.
47pub type ModifiedJulianDate<S> = crate::model::time::Time<S, MJD>;
48/// SI seconds since J2000.0 on scale `S`.
49pub type J2000Seconds<S> = crate::model::time::Time<S, J2000s>;
50/// POSIX / Unix seconds on the UTC axis.
51pub type UnixTime = crate::model::time::Time<crate::model::scale::UTC, Unix>;
52/// GPS seconds on the TAI axis.
53pub type GpsTime = crate::model::time::Time<crate::model::scale::TAI, GPS>;
54
55impl<S: crate::model::scale::Scale> From<JulianDate<S>> for crate::Time<S> {
56    #[inline]
57    fn from(value: JulianDate<S>) -> Self {
58        value.to_j2000s()
59    }
60}
61
62impl<S: crate::model::scale::Scale> From<ModifiedJulianDate<S>> for crate::Time<S> {
63    #[inline]
64    fn from(value: ModifiedJulianDate<S>) -> Self {
65        value.to_j2000s()
66    }
67}
68
69impl From<UnixTime> for crate::Time<crate::model::scale::UTC> {
70    #[inline]
71    fn from(value: UnixTime) -> Self {
72        value.to_j2000s()
73    }
74}
75
76impl From<GpsTime> for crate::Time<crate::model::scale::TAI> {
77    #[inline]
78    fn from(value: GpsTime) -> Self {
79        value.to_j2000s()
80    }
81}
82
83// ── Tests ─────────────────────────────────────────────────────────────────────
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::earth::context::TimeContext;
89    use crate::model::scale::{TAI, TT, UTC};
90    use crate::model::target::ConversionTarget;
91    use qtty::{Day, Second};
92
93    #[test]
94    fn encoded_time_display_delegates_to_quantity() {
95        let jd = JulianDate::<TT>::new(2_451_545.123_456_789);
96
97        assert_eq!(format!("{jd:.9}"), "2451545.123456789 d");
98    }
99
100    #[test]
101    fn encoded_time_lower_exp_delegates_to_quantity() {
102        let seconds = J2000Seconds::<TT>::new(1_234.5);
103        let formatted = format!("{seconds:.2e}");
104
105        assert_eq!(formatted, format!("{:.2e}", seconds.raw()));
106    }
107
108    #[test]
109    fn encoded_time_upper_exp_delegates_to_quantity() {
110        let seconds = J2000Seconds::<TT>::new(1_234.5);
111        let formatted = format!("{seconds:.2E}");
112
113        assert_eq!(formatted, format!("{:.2E}", seconds.raw()));
114    }
115
116    #[test]
117    fn encoded_time_clone_matches_original() {
118        let jd = JulianDate::<TT>::new(2_451_545.0);
119        let cloned = <JulianDate<TT> as Clone>::clone(&jd);
120        assert_eq!(jd.raw(), cloned.raw());
121    }
122
123    #[test]
124    fn encoded_time_partial_eq() {
125        let a = JulianDate::<TT>::new(2_451_545.0);
126        let b = JulianDate::<TT>::new(2_451_545.0);
127        let c = JulianDate::<TT>::new(2_451_546.0);
128        assert_eq!(a, b);
129        assert_ne!(a, c);
130    }
131
132    #[test]
133    fn encoded_time_quantity_is_alias_for_raw() {
134        let jd = JulianDate::<TT>::new(2_451_545.5);
135        assert_eq!(jd.raw(), jd.quantity());
136    }
137
138    #[test]
139    fn encoded_time_new_accepts_scalar_values() {
140        let jd = JulianDate::<TT>::new(2_460_000.5);
141
142        assert_eq!(jd.raw(), Day::new(2_460_000.5));
143    }
144
145    #[test]
146    fn encoded_time_try_to_time_on_unix() {
147        let ctx = TimeContext::new();
148        let unix = UnixTime::try_new(Second::new(946_727_935.816)).unwrap();
149        let time = unix.to_j2000s();
150        let back = <Unix as crate::format::FormatForScale<UTC>>::try_from_time(time, &ctx).unwrap();
151        assert!((back - Second::new(946_727_935.816)).abs() < Second::new(1e-3));
152    }
153
154    #[test]
155    fn encoded_time_to_infallible_conversion() {
156        let jd = JulianDate::<TT>::new(2_451_545.0);
157        let mjd: ModifiedJulianDate<TT> = jd.to::<MJD>();
158        assert!((mjd.raw().value() - 51_544.5).abs() < 1e-9);
159    }
160
161    #[test]
162    fn encoded_time_try_to_conversion() {
163        let jd = JulianDate::<TT>::new(2_451_545.0);
164        let mjd: ModifiedJulianDate<TT> = jd.try_to::<MJD>().unwrap();
165        assert!((mjd.raw().value() - 51_544.5).abs() < 1e-9);
166    }
167
168    #[test]
169    fn encoded_time_to_with_for_unix() {
170        let ctx = TimeContext::new();
171        let jd = JulianDate::<UTC>::new(2_451_545.0);
172        let unix: UnixTime = jd.to_with::<Unix>(&ctx).unwrap();
173        let unix_sec = unix.try_raw_with(&ctx).unwrap();
174        assert!(unix_sec.value().is_finite());
175        assert!(unix_sec.value() > 9e8 && unix_sec.value() < 1e10);
176    }
177
178    #[test]
179    fn gps_format_roundtrip_through_tai() {
180        let gps_seconds = Second::new(0.0);
181        let time = <GPS as crate::format::InfallibleFormatForScale<TAI>>::into_time(gps_seconds);
182        let back = <GPS as crate::format::InfallibleFormatForScale<TAI>>::from_time(time);
183        assert!((back - gps_seconds).abs() < Second::new(1e-12));
184    }
185
186    #[test]
187    fn gps_encoded_time_to_time_roundtrip() {
188        let gps = GpsTime::new(1_234_567.89);
189        let time = gps.to_j2000s();
190        let back: GpsTime = time.to::<GPS>();
191        assert!((back.raw() - gps.raw()).abs() < Second::new(1e-6));
192    }
193
194    #[test]
195    fn from_encoded_time_into_time() {
196        let jd = JulianDate::<TT>::new(2_451_545.0);
197        let time: crate::model::time::Time<TT> = jd.into();
198        let back: JulianDate<TT> = time.to::<JD>();
199        assert!((back.raw() - Day::new(2_451_545.0)).abs() < Day::new(1e-12));
200    }
201
202    #[test]
203    fn encoded_into_default_time_matches_to_j2000s() {
204        let jd = JulianDate::<TT>::new(2_451_545.25);
205        let mjd = ModifiedJulianDate::<TT>::new(51_545.0);
206        assert_eq!(crate::Time::<TT>::from(jd), jd.to_j2000s());
207        assert_eq!(crate::Time::<TT>::from(mjd), mjd.to_j2000s());
208        let unix = UnixTime::try_new(Second::new(1_700_000_000.0)).unwrap();
209        assert_eq!(crate::Time::<UTC>::from(unix), unix.to_j2000s());
210        let gps = GpsTime::new(100.0);
211        assert_eq!(crate::Time::<TAI>::from(gps), gps.to_j2000s());
212    }
213
214    #[test]
215    fn period_try_new_accepts_encoded_endpoints_via_into() {
216        use crate::Period;
217
218        let jd_a = JulianDate::<TT>::new(2_451_545.0);
219        let jd_b = JulianDate::<TT>::new(2_451_546.0);
220        let from_jd = Period::<TT>::try_new(jd_a, jd_b).unwrap();
221        let explicit_jd = Period::<TT>::try_new(jd_a.to_j2000s(), jd_b.to_j2000s()).unwrap();
222        assert_eq!(from_jd, explicit_jd);
223
224        let mjd_a = ModifiedJulianDate::<TT>::new(51_544.0);
225        let mjd_b = ModifiedJulianDate::<TT>::new(51_545.0);
226        let from_mjd = Period::<TT>::try_new(mjd_a, mjd_b).unwrap();
227        let explicit_mjd = Period::<TT>::try_new(mjd_a.to_j2000s(), mjd_b.to_j2000s()).unwrap();
228        assert_eq!(from_mjd, explicit_mjd);
229    }
230
231    #[test]
232    fn infallible_conversion_target_for_j2000s() {
233        let jd = JulianDate::<TT>::new(2_451_545.0);
234        let time = jd.to_j2000s();
235        let j2k: J2000Seconds<TT> = time.to::<J2000s>();
236        assert!((j2k.raw().value()).abs() < 1e-6);
237    }
238
239    #[test]
240    fn conversion_target_try_convert_for_j2000s() {
241        let jd = JulianDate::<TT>::new(2_451_545.0);
242        let time = jd.to_j2000s();
243        let j2k: J2000Seconds<TT> = time.try_to::<J2000s>().unwrap();
244        assert!((j2k.raw().value()).abs() < 1e-6);
245    }
246
247    #[test]
248    fn conversion_target_try_convert_for_jd() {
249        let mjd = ModifiedJulianDate::<TT>::new(51_544.0);
250        let time = mjd.to_j2000s();
251        let jd: JulianDate<TT> = JD::try_convert(time).unwrap();
252        assert!((jd.raw().value() - 2_451_544.5).abs() < 1e-9);
253    }
254
255    #[test]
256    fn conversion_target_try_convert_for_mjd() {
257        let jd = JulianDate::<TT>::new(2_451_545.0);
258        let time = jd.to_j2000s();
259        let mjd: ModifiedJulianDate<TT> = MJD::try_convert(time).unwrap();
260        assert!((mjd.raw().value() - 51_544.5).abs() < 1e-9);
261    }
262
263    #[test]
264    fn gps_conversion_target_try_convert() {
265        let jd = JulianDate::<TT>::new(2_451_545.0);
266        let time = jd.to_j2000s();
267        let gps: GpsTime = GPS::try_convert(time).unwrap();
268        assert!(gps.raw().is_finite());
269    }
270
271    #[test]
272    fn unix_context_conversion_target() {
273        let ctx = TimeContext::new();
274        let jd = JulianDate::<UTC>::new(2_451_545.0);
275        let utc_time = jd.to_j2000s();
276        let unix: crate::model::time::Time<UTC, Unix> =
277            <Unix as crate::model::target::ContextConversionTarget<
278                UTC,
279                crate::format::J2000s,
280            >>::convert_with(utc_time, &ctx)
281            .unwrap();
282        let unix_sec = unix.try_raw_with(&ctx).unwrap();
283        assert!(unix_sec.value().is_finite());
284        assert!(unix_sec.value() > 9e8 && unix_sec.value() < 1e10);
285    }
286
287    #[test]
288    fn debug_includes_format_and_scale() {
289        let jd = JulianDate::<TT>::new(2_451_545.0);
290        let dbg = format!("{jd:?}");
291        assert!(dbg.contains("TT"), "debug should contain scale name");
292        assert!(dbg.contains("JD"), "debug should contain format name");
293    }
294
295    #[test]
296    fn jd_on_tt_and_utc_are_distinct_types() {
297        fn accept_tt(x: JulianDate<TT>) -> Day {
298            x.raw()
299        }
300        fn accept_utc(x: JulianDate<UTC>) -> Day {
301            x.raw()
302        }
303
304        let tt_jd = JulianDate::<TT>::new(2_451_545.0);
305        let utc_jd = JulianDate::<UTC>::new(2_451_545.0);
306
307        let _ = accept_tt(tt_jd);
308        let _ = accept_utc(utc_jd);
309    }
310
311    #[test]
312    fn format_names_are_correct() {
313        assert_eq!(JD::NAME, "JD");
314        assert_eq!(MJD::NAME, "MJD");
315        assert_eq!(J2000s::NAME, "J2000s");
316        assert_eq!(Unix::NAME, "Unix");
317        assert_eq!(GPS::NAME, "GPS");
318    }
319
320    #[test]
321    fn chrono_helpers_with_explicit_context_cover_tt_encoded_formats() {
322        let ctx = TimeContext::new().allow_pre_definition_utc();
323        let dt =
324            ::chrono::DateTime::<::chrono::Utc>::from_timestamp(946_728_123, 250_000_000).unwrap();
325
326        let jd = JulianDate::<TT>::try_from_chrono_with(dt, &ctx).unwrap();
327        let mjd = ModifiedJulianDate::<TT>::from_chrono_with(dt, &ctx);
328        let j2k = J2000Seconds::<TT>::from(dt);
329
330        let jd_back = jd.try_to_chrono_with(&ctx).unwrap();
331        let mjd_back = mjd.to_chrono_with(&ctx).unwrap();
332        let j2k_back = j2k.to_chrono().unwrap();
333
334        assert!(
335            (jd_back.timestamp_nanos_opt().unwrap() - dt.timestamp_nanos_opt().unwrap()).abs()
336                < 50_000
337        );
338        assert!(
339            (mjd_back.timestamp_nanos_opt().unwrap() - dt.timestamp_nanos_opt().unwrap()).abs()
340                < 50_000
341        );
342        assert!(
343            (j2k_back.timestamp_nanos_opt().unwrap() - dt.timestamp_nanos_opt().unwrap()).abs()
344                < 50_000
345        );
346    }
347
348    #[test]
349    fn format_trait_impls_cover_j2000_jd_mjd_and_gps() {
350        let ctx = TimeContext::new();
351        let tt = J2000Seconds::<TT>::new(123.5);
352        let tai = crate::Time::<TAI>::new(456.75);
353
354        let j2000 = <J2000s as crate::format::FormatForScale<TT>>::try_from_time(tt, &ctx).unwrap();
355        assert_eq!(j2000, tt.raw());
356        assert_eq!(
357            <J2000s as crate::format::FormatForScale<TT>>::try_into_time(j2000, &ctx).unwrap(),
358            tt
359        );
360
361        let jd = <JD as crate::format::FormatForScale<TT>>::try_from_time(tt, &ctx).unwrap();
362        let mjd = <MJD as crate::format::FormatForScale<TT>>::try_from_time(tt, &ctx).unwrap();
363        assert!(
364            (<JD as crate::format::FormatForScale<TT>>::try_into_time(jd, &ctx)
365                .unwrap()
366                .to_j2000s()
367                .raw()
368                .value()
369                - tt.raw().value())
370            .abs()
371                < 1e-4
372        );
373        assert!(
374            (<MJD as crate::format::FormatForScale<TT>>::try_into_time(mjd, &ctx)
375                .unwrap()
376                .to_j2000s()
377                .raw()
378                .value()
379                - tt.raw().value())
380            .abs()
381                < 1e-4
382        );
383
384        let gps = <GPS as crate::format::FormatForScale<TAI>>::try_from_time(tai, &ctx).unwrap();
385        assert_eq!(
386            <GPS as crate::format::FormatForScale<TAI>>::try_into_time(gps, &ctx).unwrap(),
387            tai.to::<GPS>()
388        );
389    }
390}