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