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