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