Skip to main content

sidereon_core/astro/time/
mod.rs

1//! Time scales and the public time model.
2//!
3//! The precise time-scale machinery, which used to be `pub(crate)` inside
4//! `orbis_nif` and is now public in the core crate. It exposes three layers:
5//!
6//! - [`scales`] - the parity-critical UTC->TAI->TT->TDB->UT1 conversion, moved
7//!   verbatim from `orbis_nif/src/time_scales.rs`. The numerics are byte-for-byte
8//!   identical so the existing Skyfield 0-ULP parity holds.
9//! - [`model`] - the public time model type family ([`TimeScale`], [`Instant`],
10//!   [`Duration`], [`JulianDateSplit`], [`GnssWeekTow`]).
11//! - [`eop`] - time/EOP validity + provenance API with strict-vs-permissive
12//!   policy hooks.
13//!
14//! The legacy thin [`Time`] (seconds since J2000, used by the propagator) is
15//! retained unchanged for backward compatibility.
16
17pub mod eop;
18pub mod model;
19pub mod scales;
20
21pub use eop::{
22    CoverageError, DegradeReason, LeapSecondTable, TimeScaleInputErrorKind, Ut1Provenance,
23    Validated, ValidityMode,
24};
25pub use model::{
26    Duration, GnssWeekTow, Instant, InstantRepr, JulianDateSplit, TimeModelError, TimeScale,
27    SECONDS_PER_WEEK,
28};
29pub use scales::TimeScales;
30
31/// Legacy lightweight epoch: seconds since the J2000 TDB epoch.
32///
33/// Kept for the propagator/force-model API surface; the richer public time
34/// model lives in [`model`].
35#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
36pub struct Time {
37    pub seconds_since_j2000: f64,
38}
39
40impl Time {
41    pub fn new(seconds_since_j2000: f64) -> Result<Self, TimeModelError> {
42        if !seconds_since_j2000.is_finite() {
43            return Err(TimeModelError::InvalidInput {
44                field: "seconds_since_j2000",
45                reason: "must be finite",
46            });
47        }
48        Ok(Self {
49            seconds_since_j2000,
50        })
51    }
52
53    pub fn tdb(&self) -> f64 {
54        self.seconds_since_j2000
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    #[test]
63    fn week_tow_normalizes_overflow() {
64        let wt = GnssWeekTow::new(TimeScale::Gpst, 100, SECONDS_PER_WEEK + 5.0)
65            .expect("valid week/TOW")
66            .normalized()
67            .expect("valid normalized week/TOW");
68        assert_eq!(wt.week, 101);
69        assert!((wt.tow_s - 5.0).abs() < 1e-9);
70    }
71
72    #[test]
73    fn week_tow_borrows_negative() {
74        let wt = GnssWeekTow::new(TimeScale::Gpst, 100, -10.0)
75            .expect("valid week/TOW")
76            .normalized()
77            .expect("valid normalized week/TOW");
78        assert_eq!(wt.week, 99);
79        assert!((wt.tow_s - (SECONDS_PER_WEEK - 10.0)).abs() < 1e-6);
80    }
81
82    #[test]
83    fn week_rollover_unrolls() {
84        let wt = GnssWeekTow::new(TimeScale::Gpst, 10, 0.0).expect("valid week/TOW");
85        assert_eq!(wt.unrolled_week(2).expect("valid unrolled week"), 10 + 2048);
86    }
87
88    #[test]
89    fn scalar_time_rejects_nonfinite_epoch_seconds() {
90        assert!(Time::new(f64::NAN).is_err());
91        assert!(Time::new(f64::INFINITY).is_err());
92        assert!(Time::new(f64::NEG_INFINITY).is_err());
93    }
94
95    #[test]
96    fn scalar_time_valid_epoch_is_unchanged() {
97        let t = Time::new(123.25).expect("valid scalar time");
98        assert_eq!(t.seconds_since_j2000, 123.25);
99        assert_eq!(t.tdb(), 123.25);
100    }
101
102    #[test]
103    fn ut1_coverage_strict_vs_permissive() {
104        let prov = scales::ut1_coverage();
105        // Inside coverage: ok, not degraded.
106        let mid = (prov.first_jd_tt + prov.last_jd_tt) / 2.0;
107        assert_eq!(
108            eop::check_ut1_coverage(&prov, mid, ValidityMode::Strict),
109            Ok(None)
110        );
111        // Before coverage: strict errors, permissive degrades.
112        let before = prov.first_jd_tt - 1.0;
113        assert!(eop::check_ut1_coverage(&prov, before, ValidityMode::Strict).is_err());
114        assert_eq!(
115            eop::check_ut1_coverage(&prov, before, ValidityMode::Permissive),
116            Ok(Some(DegradeReason::BeforeCoverage))
117        );
118    }
119
120    #[test]
121    fn time_scales_from_utc_unchanged_shape() {
122        // J2000 epoch sanity: 2000-01-01 12:00:00 UTC.
123        let ts = TimeScales::from_utc(2000, 1, 1, 12, 0, 0.0).expect("valid UTC instant");
124        assert!((ts.jd_tt - 2451545.0).abs() < 1e-3);
125    }
126
127    #[test]
128    fn time_scales_from_utc_rejects_non_finite_seconds() {
129        let err = TimeScales::from_utc(2000, 1, 1, 12, 0, f64::NAN)
130            .expect_err("non-finite second must error before time-scale arithmetic");
131        assert_eq!(
132            err,
133            CoverageError::InvalidInput {
134                field: "second",
135                kind: TimeScaleInputErrorKind::NonFinite
136            }
137        );
138
139        let err =
140            TimeScales::from_utc_validated(2000, 1, 1, 12, 0, f64::INFINITY, ValidityMode::Strict)
141                .expect_err("validated path must reject non-finite seconds before coverage checks");
142        assert_eq!(
143            err,
144            CoverageError::InvalidInput {
145                field: "second",
146                kind: TimeScaleInputErrorKind::NonFinite
147            }
148        );
149    }
150
151    #[test]
152    fn time_scales_from_utc_rejects_invalid_civil_datetime() {
153        let err = TimeScales::from_utc(2001, 2, 29, 12, 0, 0.0)
154            .expect_err("invalid civil date must error before time-scale arithmetic");
155        assert_eq!(
156            err,
157            CoverageError::InvalidInput {
158                field: "civil datetime",
159                kind: TimeScaleInputErrorKind::InvalidCivilDate,
160            }
161        );
162
163        let err = TimeScales::from_utc_validated(2000, 1, 1, 24, 0, 0.0, ValidityMode::Strict)
164            .expect_err("invalid civil time must error before coverage checks");
165        assert_eq!(
166            err,
167            CoverageError::InvalidInput {
168                field: "civil datetime",
169                kind: TimeScaleInputErrorKind::InvalidCivilTime,
170            }
171        );
172    }
173
174    #[test]
175    fn time_scales_from_utc_maps_positive_leap_second_between_neighbors() {
176        fn tt_delta_seconds(later: TimeScales, earlier: TimeScales) -> f64 {
177            (later.jd_whole - earlier.jd_whole) * 86_400.0
178                + (later.tt_fraction - earlier.tt_fraction) * 86_400.0
179        }
180
181        let before = TimeScales::from_utc(2016, 12, 31, 23, 59, 59.0).expect("leap eve second");
182        let leap = TimeScales::from_utc(2016, 12, 31, 23, 59, 60.0).expect("inserted leap second");
183        let after = TimeScales::from_utc(2017, 1, 1, 0, 0, 0.0).expect("post-leap midnight");
184
185        assert!(
186            (tt_delta_seconds(leap, before) - 1.0).abs() < 1.0e-8,
187            "leap label must be one SI second after :59"
188        );
189        assert!(
190            (tt_delta_seconds(after, leap) - 1.0).abs() < 1.0e-8,
191            "post-leap midnight must be one SI second after :60"
192        );
193        assert_ne!(leap, after, "leap second must not collapse onto midnight");
194
195        assert!(TimeScales::from_utc(2017, 1, 1, 0, 0, 60.0).is_err());
196        assert!(TimeScales::from_utc(2016, 12, 31, 23, 59, 61.0).is_err());
197        assert!(TimeScales::from_utc(2016, 12, 31, 23, 59, -1.0).is_err());
198    }
199
200    #[test]
201    fn from_utc_validated_in_coverage_is_bit_identical_and_not_degraded() {
202        // J2000 is comfortably inside the embedded UT1/EOP coverage interval.
203        let plain = TimeScales::from_utc(2000, 1, 1, 12, 0, 0.0).expect("valid UTC instant");
204        for mode in [ValidityMode::Strict, ValidityMode::Permissive] {
205            let v = TimeScales::from_utc_validated(2000, 1, 1, 12, 0, 0.0, mode)
206                .expect("in-coverage instant must not error in either mode");
207            assert_eq!(v.degraded, None, "in-coverage must not be degraded");
208            // The numerics must be the EXACT same bits as the parity path.
209            assert_eq!(
210                v.value, plain,
211                "validated numerics must equal from_utc bit-for-bit"
212            );
213        }
214    }
215
216    #[test]
217    fn from_utc_validated_strict_errors_before_coverage() {
218        let prov = scales::ut1_coverage();
219        // The UT1 table starts at MJD 41684 (1973); pick an instant safely before.
220        let (y, m, d) = (1960, 1, 1);
221        let plain = TimeScales::from_utc(y, m, d, 0, 0, 0.0).expect("valid UTC instant");
222        assert!(
223            plain.jd_tt < prov.first_jd_tt,
224            "fixture must be before coverage"
225        );
226
227        let err = TimeScales::from_utc_validated(y, m, d, 0, 0, 0.0, ValidityMode::Strict)
228            .expect_err("strict mode must error before coverage");
229        assert_eq!(
230            err,
231            CoverageError::OutsideCoverage(DegradeReason::BeforeCoverage)
232        );
233    }
234
235    #[test]
236    fn from_utc_validated_strict_errors_after_coverage() {
237        let prov = scales::ut1_coverage();
238        // The UT1 table ends at MJD 61239 (~2026); pick an instant safely after.
239        let (y, m, d) = (2100, 1, 1);
240        let plain = TimeScales::from_utc(y, m, d, 0, 0, 0.0).expect("valid UTC instant");
241        assert!(
242            plain.jd_tt > prov.last_jd_tt,
243            "fixture must be after coverage"
244        );
245
246        let err = TimeScales::from_utc_validated(y, m, d, 0, 0, 0.0, ValidityMode::Strict)
247            .expect_err("strict mode must error after coverage");
248        assert_eq!(
249            err,
250            CoverageError::OutsideCoverage(DegradeReason::AfterCoverage)
251        );
252    }
253
254    #[test]
255    fn from_utc_validated_permissive_clamps_and_marks_degraded() {
256        // Before coverage: permissive returns the clamped value, marked degraded,
257        // and the clamped numerics equal the parity path exactly.
258        let plain_before = TimeScales::from_utc(1960, 1, 1, 0, 0, 0.0).expect("valid UTC instant");
259        let before =
260            TimeScales::from_utc_validated(1960, 1, 1, 0, 0, 0.0, ValidityMode::Permissive)
261                .expect("permissive must not error");
262        assert_eq!(before.degraded, Some(DegradeReason::BeforeCoverage));
263        assert_eq!(before.value, plain_before);
264
265        // After coverage: permissive returns the clamped value, marked degraded.
266        let plain_after = TimeScales::from_utc(2100, 1, 1, 0, 0, 0.0).expect("valid UTC instant");
267        let after = TimeScales::from_utc_validated(2100, 1, 1, 0, 0, 0.0, ValidityMode::Permissive)
268            .expect("permissive must not error");
269        assert_eq!(after.degraded, Some(DegradeReason::AfterCoverage));
270        assert_eq!(after.value, plain_after);
271    }
272}