Skip to main content

tempoch_core/
scales.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (C) 2026 Vallés Puig, Ramon
3
4//! Time-scale marker types.
5//!
6//! Each zero-sized type identifies a specific time scale and encodes how
7//! values in that scale relate to the canonical **Julian Date in TT**
8//! (Terrestrial Time).
9//!
10//! # Epoch counters
11//!
12//! | Marker | Description | Epoch (JD) |
13//! |--------|-------------|------------|
14//! | [`JD`] | Julian Date | 0.0 |
15//! | [`JDE`] | Julian Ephemeris Day | 0.0 |
16//! | [`MJD`] | Modified Julian Date | 2 400 000.5 |
17//! | [`UnixTime`] | Seconds since 1970-01-01 | 2 440 587.5 |
18//! | [`GPS`] | GPS Time (days) | 2 444 244.5 |
19//!
20//! # Physical / relativistic scales
21//!
22//! | Marker | Description |
23//! |--------|-------------|
24//! | [`TDB`] | Barycentric Dynamical Time (canonical) |
25//! | [`TT`]  | Terrestrial Time |
26//! | [`TAI`] | International Atomic Time |
27//! | [`TCG`] | Geocentric Coordinate Time (IAU 2000) |
28//! | [`TCB`] | Barycentric Coordinate Time (IAU 2006) |
29
30use super::instant::TimeScale;
31use qtty::Days;
32
33// ---------------------------------------------------------------------------
34// Epoch counters
35// ---------------------------------------------------------------------------
36
37/// Julian Date — the identity scale.
38///
39/// `to_jd_tt(v) = v`, i.e. the quantity *is* a Julian Day number.
40#[derive(Debug, Copy, Clone, PartialEq, PartialOrd)]
41pub struct JD;
42
43impl TimeScale for JD {
44    const LABEL: &'static str = "Julian Day:";
45
46    #[inline(always)]
47    fn to_jd_tt(value: Days) -> Days {
48        value
49    }
50
51    #[inline(always)]
52    fn from_jd_tt(jd_tt: Days) -> Days {
53        jd_tt
54    }
55}
56
57/// Julian Ephemeris Day — dynamic Julian day used by ephemerides.
58///
59/// Numerically this is an absolute Julian day on the TT axis in this crate.
60/// It is a semantic label for ephemeris formulas, not a UT→TT conversion.
61#[derive(Debug, Copy, Clone, PartialEq, PartialOrd)]
62pub struct JDE;
63
64impl TimeScale for JDE {
65    const LABEL: &'static str = "JDE";
66
67    #[inline(always)]
68    fn to_jd_tt(value: Days) -> Days {
69        value
70    }
71
72    #[inline(always)]
73    fn from_jd_tt(jd_tt: Days) -> Days {
74        jd_tt
75    }
76}
77
78/// Modified Julian Date — JD minus 2 400 000.5.
79#[derive(Debug, Copy, Clone, PartialEq, PartialOrd)]
80pub struct MJD;
81
82/// The constant offset between JD and MJD: `JD = MJD + MJD_EPOCH`.
83const MJD_EPOCH: Days = Days::new(2_400_000.5);
84
85impl TimeScale for MJD {
86    const LABEL: &'static str = "MJD";
87
88    #[inline(always)]
89    fn to_jd_tt(value: Days) -> Days {
90        value + MJD_EPOCH
91    }
92
93    #[inline(always)]
94    fn from_jd_tt(jd_tt: Days) -> Days {
95        jd_tt - MJD_EPOCH
96    }
97}
98
99// ---------------------------------------------------------------------------
100// Physical / relativistic scales
101// ---------------------------------------------------------------------------
102
103/// Barycentric Dynamical Time.
104///
105/// TDB differs from TT by a periodic correction of ≈1.7 ms amplitude
106/// (Fairhead & Bretagnon 1990).  This implementation applies the four
107/// largest periodic terms automatically in `to_jd_tt` / `from_jd_tt`,
108/// achieving accuracy better than 30 μs for dates within ±10 000 years
109/// of J2000.
110///
111/// ## References
112/// * Fairhead & Bretagnon (1990), A&A 229, 240
113/// * USNO Circular 179, eq. 2.6
114/// * IAU 2006 Resolution B3
115#[derive(Debug, Copy, Clone, PartialEq, PartialOrd)]
116pub struct TDB;
117
118/// Compute TDB − TT in days using the Fairhead & Bretagnon (1990) 4-term
119/// expression.  Accuracy: better than 30 μs for |t| < 100 centuries.
120#[inline]
121pub(crate) fn tdb_minus_tt_days(jd_tt: Days) -> Days {
122    // Julian centuries from J2000.0 on the TT axis
123    let t = (jd_tt.value() - 2_451_545.0) / 36_525.0;
124
125    // Earth's mean anomaly (radians)
126    let m_e = (357.5291092 + 35999.0502909 * t).to_radians();
127    // Mean anomaly of Jupiter (radians)
128    let m_j = (246.4512 + 3035.2335 * t).to_radians();
129    // Mean elongation of the Moon from the Sun (radians)
130    let d = (297.8502042 + 445267.1115168 * t).to_radians();
131    // Mean longitude of lunar ascending node (radians)
132    let om = (125.0445550 - 1934.1362091 * t).to_radians();
133
134    // TDB − TT in seconds (Fairhead & Bretagnon largest terms):
135    let dt_sec = 0.001_657 * (m_e + 0.01671 * m_e.sin()).sin()
136        + 0.000_022 * (d - m_e).sin()
137        + 0.000_014 * (2.0 * d).sin()
138        + 0.000_005 * m_j.sin()
139        + 0.000_005 * om.sin();
140
141    Days::new(dt_sec / 86_400.0)
142}
143
144impl TimeScale for TDB {
145    const LABEL: &'static str = "TDB";
146
147    #[inline]
148    fn to_jd_tt(tdb_value: Days) -> Days {
149        // JD(TT) = JD(TDB) - (TDB - TT)
150        // First approximation: use tdb_value as TT to compute the correction.
151        // The correction is < 2 ms so one iteration is sufficient for f64.
152        tdb_value - tdb_minus_tt_days(tdb_value)
153    }
154
155    #[inline]
156    fn from_jd_tt(jd_tt: Days) -> Days {
157        // JD(TDB) = JD(TT) + (TDB - TT)
158        jd_tt + tdb_minus_tt_days(jd_tt)
159    }
160}
161
162/// Terrestrial Time — the basis for astronomical ephemerides.
163///
164/// Numerically identical to JD when the Julian Day number already represents
165/// TT (which is the convention used throughout this crate).
166#[derive(Debug, Copy, Clone, PartialEq, PartialOrd)]
167pub struct TT;
168
169impl TimeScale for TT {
170    const LABEL: &'static str = "TT";
171
172    #[inline(always)]
173    fn to_jd_tt(value: Days) -> Days {
174        value // value is already JD(TT)
175    }
176
177    #[inline(always)]
178    fn from_jd_tt(jd_tt: Days) -> Days {
179        jd_tt
180    }
181}
182
183/// International Atomic Time.
184///
185/// `TT = TAI + 32.184 s`.
186#[derive(Debug, Copy, Clone, PartialEq, PartialOrd)]
187pub struct TAI;
188
189/// `TT = TAI + 32.184 s` expressed in days.
190const TT_MINUS_TAI: Days = Days::new(32.184 / 86_400.0);
191
192impl TimeScale for TAI {
193    const LABEL: &'static str = "TAI";
194
195    #[inline(always)]
196    fn to_jd_tt(value: Days) -> Days {
197        // TAI → TT: add 32.184 s
198        value + TT_MINUS_TAI
199    }
200
201    #[inline(always)]
202    fn from_jd_tt(jd_tt: Days) -> Days {
203        // TT → TAI: subtract 32.184 s
204        jd_tt - TT_MINUS_TAI
205    }
206}
207
208// ---------------------------------------------------------------------------
209// Coordinate time scales (IAU 2000 / 2006)
210// ---------------------------------------------------------------------------
211
212/// Geocentric Coordinate Time — the coordinate time for the GCRS.
213///
214/// TCG is the proper time experienced by a clock at the geocenter, free from
215/// the gravitational time dilation of the Earth's potential.
216///
217/// The defining relation (IAU 2000 Resolution B1.9) is:
218///
219/// ```text
220/// dTT / dTCG = 1 − L_G
221/// ```
222///
223/// where **L_G = 6.969 290 134 × 10⁻¹⁰** is an IAU defining constant.
224/// Integrating:
225///
226/// ```text
227/// TT = TCG − L_G × (JD_TCG − T₀)
228/// ```
229///
230/// with **T₀ = 2 443 144.500 372 5** (JD of 1977 January 1.0 TAI in the TCG scale).
231///
232/// ## References
233/// * IAU 2000 Resolution B1.9
234/// * IERS Conventions (2010), §1.2
235#[derive(Debug, Copy, Clone, PartialEq, PartialOrd)]
236pub struct TCG;
237
238/// IAU defining constant L_G (IAU 2000 Resolution B1.9).
239///
240/// Defines the rate difference between TT and TCG:
241/// `dTT/dTCG = 1 − L_G`.
242const L_G: f64 = 6.969_290_134e-10;
243
244/// TCG epoch T₀: JD(TCG) of 1977 January 1.0 TAI.
245const TCG_EPOCH_T0: f64 = 2_443_144.500_372_5;
246
247impl TimeScale for TCG {
248    const LABEL: &'static str = "TCG";
249
250    #[inline]
251    fn to_jd_tt(tcg_value: Days) -> Days {
252        // TT = TCG − L_G × (JD_TCG − T₀)
253        let jd_tcg = tcg_value.value();
254        Days::new(jd_tcg - L_G * (jd_tcg - TCG_EPOCH_T0))
255    }
256
257    #[inline]
258    fn from_jd_tt(jd_tt: Days) -> Days {
259        // JD_TCG = (JD_TT + L_G × T₀) / (1 − L_G)
260        //        ≈ JD_TT + L_G × (JD_TT − T₀)   (first-order, adequate for f64)
261        let tt = jd_tt.value();
262        Days::new(tt + L_G * (tt - TCG_EPOCH_T0) / (1.0 - L_G))
263    }
264}
265
266/// Barycentric Coordinate Time — the coordinate time for the BCRS.
267///
268/// TCB is the time coordinate complementary to barycentric spatial coordinates.
269/// It relates to TDB via a linear drift:
270///
271/// ```text
272/// TDB = TCB − L_B × (JD_TCB − T₀) + TDB₀
273/// ```
274///
275/// where:
276/// * **L_B = 1.550 519 768 × 10⁻⁸** (IAU 2006 Resolution B3, defining constant)
277/// * **TDB₀ = −6.55 × 10⁻⁵ s** (IAU 2006 Resolution B3)
278/// * **T₀ = 2 443 144.500 372 5** (JD of 1977 January 1.0 TAI)
279///
280/// Since TDB ≈ TT for route-through-JD(TT) purposes (the ≈1.7 ms periodic
281/// difference is handled separately), we use the TDB relation directly.
282///
283/// ## References
284/// * IAU 2006 Resolution B3
285/// * IERS Conventions (2010), §1.2
286#[derive(Debug, Copy, Clone, PartialEq, PartialOrd)]
287pub struct TCB;
288
289/// IAU defining constant L_B (IAU 2006 Resolution B3).
290///
291/// Defines the rate difference between TDB and TCB:
292/// `TDB = TCB − L_B × (JD_TCB − T₀) + TDB₀`.
293const L_B: f64 = 1.550_519_768e-8;
294
295impl TimeScale for TCB {
296    const LABEL: &'static str = "TCB";
297
298    #[inline]
299    fn to_jd_tt(tcb_value: Days) -> Days {
300        // TDB = TCB − L_B × (JD_TCB − T₀)
301        // Treating TDB ≈ TT (periodic ≈1.7 ms difference handled separately).
302        // Matches SOFA iauTcbtdb.
303        let jd_tcb = tcb_value.value();
304        Days::new(jd_tcb - L_B * (jd_tcb - TCG_EPOCH_T0))
305    }
306
307    #[inline]
308    fn from_jd_tt(jd_tt: Days) -> Days {
309        // JD_TCB = JD_TDB + L_B × (JD_TDB − T₀)
310        // Matches SOFA iauTdbtcb.
311        let tt = jd_tt.value();
312        Days::new(tt + L_B * (tt - TCG_EPOCH_T0))
313    }
314}
315
316// ---------------------------------------------------------------------------
317// Navigation counters
318// ---------------------------------------------------------------------------
319
320/// GPS Time — continuous day count since 1980-01-06T00:00:00 UTC.
321///
322/// GPS time has a fixed offset from TAI: `GPS = TAI − 19 s`.
323#[derive(Debug, Copy, Clone, PartialEq, PartialOrd)]
324pub struct GPS;
325
326/// JD(TT) of the GPS epoch (1980-01-06T00:00:00 UTC).
327/// GPS = TAI − 19s, and TT = TAI + 32.184s, so TT = GPS + 51.184s.
328/// GPS epoch in JD(TT): JD 2444244.5 + 51.184/86400.
329const GPS_EPOCH_JD_TT: Days = Days::new(2_444_244.5 + 51.184 / 86_400.0);
330
331impl TimeScale for GPS {
332    const LABEL: &'static str = "GPS";
333
334    #[inline(always)]
335    fn to_jd_tt(value: Days) -> Days {
336        value + GPS_EPOCH_JD_TT
337    }
338
339    #[inline(always)]
340    fn from_jd_tt(jd_tt: Days) -> Days {
341        jd_tt - GPS_EPOCH_JD_TT
342    }
343}
344
345/// Unix Time — seconds since 1970-01-01T00:00:00 UTC, stored as **days**.
346///
347/// This scale applies the cumulative leap-second offset from IERS Bulletin C
348/// to convert between UTC-epoch Unix timestamps and the uniform TT axis.
349/// The leap-second table covers 1972–2017 (28 insertions). Prior to 1972
350/// (before the leap-second system) the offset is fixed at 10 s (the initial
351/// TAI − UTC value adopted on 1972-01-01).
352///
353/// ## References
354/// * IERS Bulletin C (leap second announcements)
355/// * POSIX.1-2017 §4.16 (definition of Unix time)
356#[derive(Debug, Copy, Clone, PartialEq, PartialOrd)]
357pub struct UnixTime;
358
359/// JD of the Unix epoch (1970-01-01T00:00:00Z).
360const UNIX_EPOCH_JD: Days = Days::new(2_440_587.5);
361
362/// Leap-second table: (JD of leap-second insertion, cumulative TAI−UTC after).
363/// Source: IERS Bulletin C. Entries are the JD of 00:00:00 UTC on the day
364/// the leap second takes effect (i.e. the second is inserted at the end
365/// of the previous day).
366///
367/// TAI = UTC + leap_seconds (for times at or after each entry).
368/// TT = TAI + 32.184 s.
369const LEAP_SECONDS: [(f64, f64); 28] = [
370    (2_441_317.5, 10.0), // 1972-01-01
371    (2_441_499.5, 11.0), // 1972-07-01
372    (2_441_683.5, 12.0), // 1973-01-01
373    (2_442_048.5, 13.0), // 1974-01-01
374    (2_442_413.5, 14.0), // 1975-01-01
375    (2_442_778.5, 15.0), // 1976-01-01
376    (2_443_144.5, 16.0), // 1977-01-01
377    (2_443_509.5, 17.0), // 1978-01-01
378    (2_443_874.5, 18.0), // 1979-01-01
379    (2_444_239.5, 19.0), // 1980-01-01
380    (2_444_786.5, 20.0), // 1981-07-01
381    (2_445_151.5, 21.0), // 1982-07-01
382    (2_445_516.5, 22.0), // 1983-07-01
383    (2_446_247.5, 23.0), // 1985-07-01
384    (2_447_161.5, 24.0), // 1988-01-01
385    (2_447_892.5, 25.0), // 1990-01-01
386    (2_448_257.5, 26.0), // 1991-01-01
387    (2_448_804.5, 27.0), // 1992-07-01
388    (2_449_169.5, 28.0), // 1993-07-01
389    (2_449_534.5, 29.0), // 1994-07-01
390    (2_450_083.5, 30.0), // 1996-01-01
391    (2_450_630.5, 31.0), // 1997-07-01
392    (2_451_179.5, 32.0), // 1999-01-01
393    (2_453_736.5, 33.0), // 2006-01-01
394    (2_454_832.5, 34.0), // 2009-01-01
395    (2_456_109.5, 35.0), // 2012-07-01
396    (2_457_204.5, 36.0), // 2015-07-01
397    (2_457_754.5, 37.0), // 2017-01-01
398];
399
400/// Look up cumulative TAI−UTC (leap seconds) for a JD on the UTC axis.
401///
402/// Returns the number of leap seconds (TAI − UTC) in effect at the given
403/// Julian Date on the UTC time axis.  The table covers 1972–2017
404/// (28 insertions).  Before 1972 the conventional initial offset of 10 s
405/// is returned.
406///
407/// # Arguments
408///
409/// * `jd_utc` - Julian Date number on the UTC axis.
410#[inline]
411pub fn tai_minus_utc(jd_utc: f64) -> f64 {
412    // Binary search for the last entry <= jd_utc
413    let mut lo = 0usize;
414    let mut hi = LEAP_SECONDS.len();
415    while lo < hi {
416        let mid = lo + (hi - lo) / 2;
417        if LEAP_SECONDS[mid].0 <= jd_utc {
418            lo = mid + 1;
419        } else {
420            hi = mid;
421        }
422    }
423    if lo == 0 {
424        // Before 1972-01-01: use the initial offset of 10 s
425        // (this is an approximation; pre-1972 UTC had fractional offsets)
426        10.0
427    } else {
428        LEAP_SECONDS[lo - 1].1
429    }
430}
431
432/// TT = TAI + 32.184 s, and TAI = UTC + leap_seconds.
433/// So TT = UTC + leap_seconds + 32.184 s.
434const TT_MINUS_TAI_SECS: f64 = 32.184;
435
436impl TimeScale for UnixTime {
437    const LABEL: &'static str = "Unix";
438
439    #[inline]
440    fn to_jd_tt(value: Days) -> Days {
441        // value is Unix days (days since 1970-01-01 on the UTC axis)
442        let jd_utc = value.value() + UNIX_EPOCH_JD.value();
443        let ls = tai_minus_utc(jd_utc);
444        // JD(TT) = JD(UTC) + (TAI−UTC + 32.184) / 86400
445        Days::new(jd_utc + (ls + TT_MINUS_TAI_SECS) / 86_400.0)
446    }
447
448    #[inline]
449    fn from_jd_tt(jd_tt: Days) -> Days {
450        // Approximate JD(UTC) by subtracting the largest plausible offset,
451        // then refine with the correct leap-second count.
452        let approx_utc = jd_tt.value() - (37.0 + TT_MINUS_TAI_SECS) / 86_400.0;
453        let ls = tai_minus_utc(approx_utc);
454        let jd_utc = jd_tt.value() - (ls + TT_MINUS_TAI_SECS) / 86_400.0;
455        Days::new(jd_utc - UNIX_EPOCH_JD.value())
456    }
457}
458
459// ---------------------------------------------------------------------------
460// Universal Time (Earth-rotation based)
461// ---------------------------------------------------------------------------
462
463/// Universal Time — the civil time scale tied to Earth's rotation.
464///
465/// Unlike [`JD`], [`JDE`], and [`TT`] (which all live on the uniform TT
466/// axis), `UT` encodes a Julian Day on the **UT** axis.  The conversion
467/// to JD(TT) adds the epoch-dependent **ΔT** correction from Meeus (1998)
468/// ch. 9, and the inverse uses a three-iteration fixed-point solver
469/// with sub-microsecond accuracy.
470///
471/// [`Time::from_utc`](super::instant::Time::from_utc) routes through this
472/// scale automatically, so callers rarely need to construct `Time<UT>` directly.
473#[derive(Debug, Copy, Clone, PartialEq, PartialOrd)]
474pub struct UT;
475
476impl TimeScale for UT {
477    const LABEL: &'static str = "UT";
478
479    #[inline]
480    fn to_jd_tt(ut_value: Days) -> Days {
481        let jd_ut = super::instant::Time::<JD>::from_days(ut_value);
482        let dt_secs = super::delta_t::delta_t_seconds_from_ut(jd_ut);
483        ut_value + dt_secs.to::<qtty::Day>()
484    }
485
486    #[inline]
487    fn from_jd_tt(jd_tt: Days) -> Days {
488        // Solve ut + ΔT(ut)/86400 = tt via fixed-point iteration.
489        // dΔT/dJD ≈ 3×10⁻⁸, so convergence is immediate.
490        let mut ut = jd_tt;
491        for _ in 0..3 {
492            let jd_ut = super::instant::Time::<JD>::from_days(ut);
493            let dt_days = super::delta_t::delta_t_seconds_from_ut(jd_ut).to::<qtty::Day>();
494            ut = jd_tt - dt_days;
495        }
496        ut
497    }
498}
499
500// ---------------------------------------------------------------------------
501// Cross-scale From/Into and .to::<T>()  (generated by macro)
502// ---------------------------------------------------------------------------
503
504/// Generate pairwise `From<Time<A>> for Time<B>` implementations.
505macro_rules! impl_time_conversions {
506    // Base case: single scale, nothing left.
507    ($single:ty) => {};
508
509    // Recursive: generate pairs between $first and every $rest, then recurse.
510    ($first:ty, $($rest:ty),+ $(,)?) => {
511        $(
512            impl From<super::instant::Time<$first>> for super::instant::Time<$rest> {
513                #[inline]
514                fn from(t: super::instant::Time<$first>) -> Self {
515                    t.to::<$rest>()
516                }
517            }
518
519            impl From<super::instant::Time<$rest>> for super::instant::Time<$first> {
520                #[inline]
521                fn from(t: super::instant::Time<$rest>) -> Self {
522                    t.to::<$first>()
523                }
524            }
525        )+
526
527        impl_time_conversions!($($rest),+);
528    };
529}
530
531impl_time_conversions!(JD, JDE, MJD, TDB, TT, TAI, TCG, TCB, GPS, UnixTime, UT);
532
533#[cfg(test)]
534mod tests {
535    use super::super::instant::Time;
536    use super::*;
537    use qtty::{Day, Second, Seconds};
538
539    #[test]
540    fn jd_mjd_roundtrip() {
541        let jd = Time::<JD>::new(2_451_545.0);
542        let mjd: Time<MJD> = jd.to::<MJD>();
543        assert!((mjd.quantity() - Days::new(51_544.5)).abs() < Days::new(1e-10));
544        let back: Time<JD> = mjd.to::<JD>();
545        assert!((back.quantity() - Days::new(2_451_545.0)).abs() < Days::new(1e-10));
546    }
547
548    #[test]
549    fn jd_mjd_from_into() {
550        let jd = Time::<JD>::new(2_451_545.0);
551        let mjd: Time<MJD> = jd.into();
552        assert!((mjd.quantity() - Days::new(51_544.5)).abs() < Days::new(1e-10));
553        let back: Time<JD> = Time::from(mjd);
554        assert!((back.quantity() - Days::new(2_451_545.0)).abs() < Days::new(1e-10));
555    }
556
557    #[test]
558    fn tai_tt_offset() {
559        // TT = TAI + 32.184s
560        let tai = Time::<TAI>::new(2_451_545.0);
561        let tt: Time<TT> = tai.to::<TT>();
562        let expected_offset = Seconds::new(32.184).to::<Day>();
563        assert!((tt.quantity() - (tai.quantity() + expected_offset)).abs() < Days::new(1e-15));
564    }
565
566    #[test]
567    fn gps_epoch_roundtrip() {
568        // GPS epoch is JD 2444244.5 (in UTC); in TT it is shifted by 51.184s
569        let gps_zero = Time::<GPS>::new(0.0);
570        let jd: Time<JD> = gps_zero.to::<JD>();
571        let expected = Days::new(2_444_244.5) + Seconds::new(51.184).to::<Day>();
572        assert!((jd.quantity() - expected).abs() < Days::new(1e-12));
573    }
574
575    #[test]
576    fn unix_epoch_roundtrip() {
577        // Unix epoch (1970-01-01) has 10 s TAI−UTC and 32.184 s TT−TAI = 42.184 s TT−UTC
578        let unix_zero = Time::<UnixTime>::new(0.0);
579        let jd: Time<JD> = unix_zero.to::<JD>();
580        let expected = Days::new(2_440_587.5) + Seconds::new(42.184).to::<Day>();
581        assert!(
582            (jd.quantity() - expected).abs() < Days::new(1e-10),
583            "Unix epoch JD(TT) = {}, expected {}",
584            jd.quantity(),
585            expected
586        );
587    }
588
589    #[test]
590    fn unix_2020_leap_seconds() {
591        // 2020-01-01 00:00:00 UTC: TAI−UTC = 37 s, TT−UTC = 69.184 s
592        // JD(UTC) of 2020-01-01 = 2458849.5
593        // Unix days = 2458849.5 - 2440587.5 = 18262.0
594        let unix_2020 = Time::<UnixTime>::new(18262.0);
595        let jd: Time<JD> = unix_2020.to::<JD>();
596        let expected = Days::new(2_458_849.5) + Seconds::new(69.184).to::<Day>();
597        assert!(
598            (jd.quantity() - expected).abs() < Days::new(1e-10),
599            "2020 Unix JD(TT) = {}, expected {}",
600            jd.quantity(),
601            expected
602        );
603    }
604
605    #[test]
606    fn tdb_tt_offset() {
607        // TDB − TT periodic correction is ~1.7 ms at maximum.
608        // At J2000.0 t=0, the correction should be small but non-zero.
609        let jd = Time::<JD>::new(2_451_545.0);
610        let tdb: Time<TDB> = jd.to::<TDB>();
611        let offset_secs = (tdb.quantity() - jd.quantity()).to::<Second>();
612        // Should be within ±2 ms
613        assert!(
614            offset_secs.abs() < Seconds::new(0.002),
615            "TDB−TT offset at J2000 = {} s, expected < 2 ms",
616            offset_secs
617        );
618    }
619
620    #[test]
621    fn tdb_tt_roundtrip() {
622        let jd = Time::<JD>::new(2_451_545.0);
623        let tdb: Time<TDB> = jd.to::<TDB>();
624        let back: Time<JD> = tdb.to::<JD>();
625        assert!(
626            (back.quantity() - jd.quantity()).abs() < Days::new(1e-14),
627            "TDB→TT roundtrip error: {} days",
628            (back.quantity() - jd.quantity()).abs()
629        );
630    }
631
632    #[test]
633    fn tcg_tt_offset_at_j2000() {
634        // At J2000.0 (JD 2451545.0), TCG runs ahead of TT.
635        // TT = TCG − L_G × (JD_TCG − T₀)
636        // The offset at J2000 should be ~L_G × (2451545 − 2443144.5) × 86400 s
637        //   ≈ 6.97e-10 × 8400.5 × 86400 ≈ 0.506 s
638        let tt = Time::<TT>::new(2_451_545.0);
639        let tcg: Time<TCG> = tt.to::<TCG>();
640        let offset_days = tcg.quantity() - tt.quantity();
641        let offset_secs = offset_days.to::<Second>();
642        // TCG should be ahead of TT by ~0.506 s at J2000
643        assert!(
644            (offset_secs - Seconds::new(0.506)).abs() < Seconds::new(0.01),
645            "TCG−TT offset at J2000 = {} s, expected ~0.506 s",
646            offset_secs
647        );
648    }
649
650    #[test]
651    fn tcg_tt_roundtrip() {
652        let tt = Time::<TT>::new(2_451_545.0);
653        let tcg: Time<TCG> = tt.to::<TCG>();
654        let back: Time<TT> = tcg.to::<TT>();
655        assert!(
656            (back.quantity() - tt.quantity()).abs() < Days::new(1e-12),
657            "TCG→TT roundtrip error: {} days",
658            (back.quantity() - tt.quantity()).abs()
659        );
660    }
661
662    #[test]
663    fn tcb_tdb_offset_at_j2000() {
664        // TCB runs significantly ahead of TDB.
665        // Offset ≈ L_B × (2451545 − 2443144.5) × 86400 s
666        //        ≈ 1.55e-8 × 8400.5 × 86400 ≈ 11.25 s
667        let tt = Time::<TT>::new(2_451_545.0);
668        let tcb: Time<TCB> = tt.to::<TCB>();
669        let offset_days = tcb.quantity() - tt.quantity();
670        let offset_secs = offset_days.to::<Second>();
671        // TCB should be ahead of TT/TDB by ~11.25 s at J2000
672        assert!(
673            (offset_secs - Seconds::new(11.25)).abs() < Seconds::new(0.5),
674            "TCB−TT offset at J2000 = {} s, expected ~11.25 s",
675            offset_secs
676        );
677    }
678
679    #[test]
680    fn tcb_tt_roundtrip() {
681        let tt = Time::<TT>::new(2_458_000.0);
682        let tcb: Time<TCB> = tt.to::<TCB>();
683        let back: Time<TT> = tcb.to::<TT>();
684        assert!(
685            (back.quantity() - tt.quantity()).abs() < Days::new(1e-10),
686            "TCB→TT roundtrip error: {} days",
687            (back.quantity() - tt.quantity()).abs()
688        );
689    }
690
691    #[test]
692    fn ut_to_jd_applies_delta_t() {
693        let ut = Time::<UT>::new(2_451_545.0);
694        let jd: Time<JD> = ut.to::<JD>();
695        // ΔT at J2000 ≈ 63.83 s
696        let offset_secs = (jd.quantity() - ut.quantity()).to::<Second>();
697        assert!(
698            (offset_secs - Seconds::new(63.83)).abs() < Seconds::new(1.0),
699            "UT→JD offset = {} s, expected ~63.83 s",
700            offset_secs
701        );
702    }
703
704    #[test]
705    fn ut_jd_roundtrip() {
706        let jd = Time::<JD>::new(2_451_545.0);
707        let ut: Time<UT> = jd.to::<UT>();
708        let back: Time<JD> = ut.to::<JD>();
709        assert!(
710            (back.quantity() - jd.quantity()).abs() < Days::new(1e-12),
711            "roundtrip error: {} days",
712            (back.quantity() - jd.quantity()).abs()
713        );
714    }
715
716    #[test]
717    fn ut_from_into() {
718        let ut = Time::<UT>::new(2_451_545.0);
719        let jd: Time<JD> = ut.into();
720        let back: Time<UT> = jd.into();
721        assert!((back.quantity() - ut.quantity()).abs() < Days::new(1e-12));
722    }
723}