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