Skip to main content

sidereon_core/astro/time/
model.rs

1//! Public time model type family.
2//!
3//! A bare `Epoch` is ambiguous (element epoch vs observation epoch vs product
4//! epoch), so the public surface is a concrete family of types that always name
5//! their scale and use a precision-preserving representation:
6//!
7//! - [`TimeScale`] - the named time scale of an instant.
8//! - [`Instant`] - a scale + a split-Julian-date / integer-nanosecond repr.
9//! - [`Duration`] - an integer-nanosecond elapsed interval.
10//! - [`JulianDateSplit`] - the two-part (whole + fraction) Julian date used to
11//!   avoid catastrophic cancellation, matching the Skyfield split convention.
12//! - [`GnssWeekTow`] - a GNSS week number + time-of-week with rollover handling.
13//!
14//! These are representation/value types only. The parity-critical conversion
15//! numerics live in [`crate::astro::time::scales`]; this module deliberately holds no
16//! transcendental math so it cannot perturb the 0-ULP contract.
17
18pub use crate::astro::constants::time::SECONDS_PER_WEEK;
19
20/// Error returned when constructing public time model values from invalid input.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
22pub enum TimeModelError {
23    /// A public constructor received a non-finite or out-of-domain input.
24    #[error("invalid time model {field}: {reason}")]
25    InvalidInput {
26        field: &'static str,
27        reason: &'static str,
28    },
29}
30
31fn invalid_input(field: &'static str, reason: &'static str) -> TimeModelError {
32    TimeModelError::InvalidInput { field, reason }
33}
34
35/// Named time scales supported by the time model.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub enum TimeScale {
38    /// Coordinated Universal Time.
39    Utc,
40    /// International Atomic Time.
41    Tai,
42    /// Terrestrial Time.
43    Tt,
44    /// Barycentric Dynamical Time.
45    Tdb,
46    /// GPS time.
47    Gpst,
48    /// Galileo System Time.
49    Gst,
50    /// BeiDou Time.
51    Bdt,
52    /// GLONASS system time. UTC(SU)-based: GLONASST = UTC(SU) + 3 h, so it
53    /// carries UTC leap seconds and its offset to the atomic scales is
54    /// epoch-dependent (ICD GLONASS, Edition 5.1, 2008, sec. 3.3.3).
55    Glonasst,
56    /// QZSS system time. Steered to be synchronous with GPST (IS-QZSS-PNT,
57    /// sec. 3.2.2), so the nominal QZSST-GPST offset is zero.
58    Qzsst,
59}
60
61impl TimeScale {
62    /// Short uppercase identifier (`"UTC"`, `"TAI"`, ...).
63    pub fn abbrev(self) -> &'static str {
64        match self {
65            TimeScale::Utc => "UTC",
66            TimeScale::Tai => "TAI",
67            TimeScale::Tt => "TT",
68            TimeScale::Tdb => "TDB",
69            TimeScale::Gpst => "GPST",
70            TimeScale::Gst => "GST",
71            TimeScale::Bdt => "BDT",
72            TimeScale::Glonasst => "GLONASST",
73            TimeScale::Qzsst => "QZSST",
74        }
75    }
76}
77
78/// Two-part Julian date (whole day boundary + day fraction).
79///
80/// Carrying the integer day separately from the fraction preserves
81/// sub-microsecond precision across the full Julian-date range, and matches the
82/// Skyfield split that [`crate::astro::time::scales::TimeScales`] produces.
83#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
84pub struct JulianDateSplit {
85    /// Integer day boundary (typically `*.0` or `*.5`).
86    pub jd_whole: f64,
87    /// Residual day fraction relative to `jd_whole`.
88    pub fraction: f64,
89}
90
91impl JulianDateSplit {
92    /// Construct a split Julian date.
93    pub fn new(jd_whole: f64, fraction: f64) -> Result<Self, TimeModelError> {
94        if !jd_whole.is_finite() {
95            return Err(invalid_input("jd_whole", "must be finite"));
96        }
97        if !fraction.is_finite() {
98            return Err(invalid_input("fraction", "must be finite"));
99        }
100        if !(-1.0..=1.0).contains(&fraction) {
101            return Err(invalid_input("fraction", "must be within one residual day"));
102        }
103        Ok(Self { jd_whole, fraction })
104    }
105
106    /// Recombine into a single `f64` Julian date.
107    ///
108    /// Note: recombination is itself a float operation and is NOT guaranteed to
109    /// be 0-ULP against a reference that consumes the split form directly; keep
110    /// the split form when feeding a parity-matched recipe.
111    pub fn to_jd(self) -> f64 {
112        self.jd_whole + self.fraction
113    }
114}
115
116/// Internal representation backing an [`Instant`].
117///
118/// Two reprs are offered to avoid precision loss for different consumers:
119/// integer nanoseconds for exact arithmetic (hifitime-style), and the split
120/// Julian date for the astronomy/Skyfield path.
121#[derive(Debug, Clone, Copy, PartialEq)]
122pub enum InstantRepr {
123    /// Integer nanoseconds since an implied scale epoch (exact arithmetic).
124    Nanos(i128),
125    /// Two-part Julian date in the instant's own scale.
126    JulianDate(JulianDateSplit),
127}
128
129/// A point in time, always tagged with its [`TimeScale`].
130#[derive(Debug, Clone, Copy, PartialEq)]
131pub struct Instant {
132    /// The time scale this instant is expressed in.
133    pub scale: TimeScale,
134    /// The precision-preserving representation.
135    pub repr: InstantRepr,
136}
137
138impl Instant {
139    /// An instant from a split Julian date in the given scale.
140    pub fn from_julian_date(scale: TimeScale, jd: JulianDateSplit) -> Self {
141        Self {
142            scale,
143            repr: InstantRepr::JulianDate(jd),
144        }
145    }
146
147    /// An instant from integer nanoseconds in the given scale.
148    pub fn from_nanos(scale: TimeScale, nanos: i128) -> Self {
149        Self {
150            scale,
151            repr: InstantRepr::Nanos(nanos),
152        }
153    }
154
155    /// The split Julian date, if this instant is stored in that form.
156    pub fn julian_date(&self) -> Option<JulianDateSplit> {
157        match self.repr {
158            InstantRepr::JulianDate(jd) => Some(jd),
159            InstantRepr::Nanos(_) => None,
160        }
161    }
162}
163
164/// An elapsed interval, stored as exact integer nanoseconds.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
166pub struct Duration {
167    /// Signed elapsed nanoseconds.
168    pub nanos: i128,
169}
170
171impl Duration {
172    /// Zero duration.
173    pub const ZERO: Duration = Duration { nanos: 0 };
174
175    /// Construct from integer nanoseconds.
176    pub fn from_nanos(nanos: i128) -> Self {
177        Self { nanos }
178    }
179
180    /// Construct from seconds. Sub-nanosecond input is truncated toward zero.
181    pub fn from_seconds(seconds: f64) -> Result<Self, TimeModelError> {
182        if !seconds.is_finite() {
183            return Err(invalid_input("seconds", "must be finite"));
184        }
185        let nanos = seconds * 1e9;
186        if !nanos.is_finite() || nanos <= i128::MIN as f64 || nanos >= i128::MAX as f64 {
187            return Err(invalid_input(
188                "seconds",
189                "must convert to an i128 nanosecond count",
190            ));
191        }
192        Ok(Self {
193            nanos: nanos as i128,
194        })
195    }
196
197    /// Convert to floating-point seconds.
198    pub fn as_seconds(self) -> f64 {
199        self.nanos as f64 / 1e9
200    }
201}
202
203/// A GNSS week number + time-of-week, tagged by constellation.
204///
205/// `week` is the constellation's native (rolled-over) week count; `tow_s` is
206/// seconds into that week in `[0, 604800)`. Rollover handling is provided by
207/// [`GnssWeekTow::normalized`] and [`GnssWeekTow::unrolled_week`].
208#[derive(Debug, Clone, Copy, PartialEq)]
209pub struct GnssWeekTow {
210    /// Which constellation's week/TOW convention this uses.
211    pub system: TimeScale,
212    /// Week number (constellation-native, may have rolled over).
213    pub week: u32,
214    /// Time of week in seconds, nominally `[0, 604800)`.
215    pub tow_s: f64,
216}
217
218impl GnssWeekTow {
219    /// Construct a week/TOW value.
220    pub fn new(system: TimeScale, week: u32, tow_s: f64) -> Result<Self, TimeModelError> {
221        if !tow_s.is_finite() {
222            return Err(invalid_input("tow_s", "must be finite"));
223        }
224        Ok(Self {
225            system,
226            week,
227            tow_s,
228        })
229    }
230
231    /// Normalize so `tow_s` lands in `[0, 604800)`, carrying whole weeks into
232    /// `week`. Negative `tow_s` borrows from the week count.
233    pub fn normalized(self) -> Result<Self, TimeModelError> {
234        if !self.tow_s.is_finite() {
235            return Err(invalid_input("tow_s", "must be finite"));
236        }
237        let mut week = self.week as i64;
238        let mut tow = self.tow_s;
239        let weeks_carry = (tow / SECONDS_PER_WEEK).floor();
240        if !weeks_carry.is_finite()
241            || weeks_carry <= i64::MIN as f64
242            || weeks_carry >= i64::MAX as f64
243        {
244            return Err(invalid_input("tow_s", "week carry is out of range"));
245        }
246        week = week
247            .checked_add(weeks_carry as i64)
248            .ok_or_else(|| invalid_input("tow_s", "week carry is out of range"))?;
249        tow -= weeks_carry * SECONDS_PER_WEEK;
250        if week < 0 {
251            week = 0;
252            tow = 0.0;
253        }
254        if week > u32::MAX as i64 {
255            return Err(invalid_input("tow_s", "normalized week is out of range"));
256        }
257        if !tow.is_finite() {
258            return Err(invalid_input("tow_s", "normalized TOW must be finite"));
259        }
260        Ok(Self {
261            system: self.system,
262            week: week as u32,
263            tow_s: tow,
264        })
265    }
266
267    /// Apply a 1024-week rollover count to recover the continuous week number
268    /// (GPS legacy 10-bit week). `rollovers` is the number of completed
269    /// 1024-week eras since the system's epoch.
270    pub fn unrolled_week(self, rollovers: u32) -> Result<u32, TimeModelError> {
271        let rollover_weeks = rollovers
272            .checked_mul(1024)
273            .ok_or_else(|| invalid_input("rollovers", "unrolled week is out of range"))?;
274        self.week
275            .checked_add(rollover_weeks)
276            .ok_or_else(|| invalid_input("rollovers", "unrolled week is out of range"))
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn split_julian_date_rejects_nonfinite_parts() {
286        assert!(JulianDateSplit::new(f64::NAN, 0.0).is_err());
287        assert!(JulianDateSplit::new(f64::INFINITY, 0.0).is_err());
288        assert!(JulianDateSplit::new(2_451_545.0, f64::NAN).is_err());
289        assert!(JulianDateSplit::new(2_451_545.0, f64::NEG_INFINITY).is_err());
290    }
291
292    #[test]
293    fn split_julian_date_rejects_out_of_range_fraction() {
294        assert!(JulianDateSplit::new(2_451_545.0, 1.0 + f64::EPSILON).is_err());
295        assert!(JulianDateSplit::new(2_451_545.0, -1.0 - f64::EPSILON).is_err());
296    }
297
298    #[test]
299    fn split_julian_date_valid_parts_are_unchanged() {
300        let jd = JulianDateSplit::new(2_451_545.0, -0.25).expect("valid split Julian date");
301        assert_eq!(jd.jd_whole, 2_451_545.0);
302        assert_eq!(jd.fraction, -0.25);
303        assert_eq!(jd.to_jd(), 2_451_544.75);
304    }
305
306    #[test]
307    fn duration_from_seconds_rejects_nonfinite_seconds() {
308        assert!(Duration::from_seconds(f64::NAN).is_err());
309        assert!(Duration::from_seconds(f64::INFINITY).is_err());
310        assert!(Duration::from_seconds(f64::NEG_INFINITY).is_err());
311    }
312
313    #[test]
314    fn duration_from_seconds_rejects_unrepresentable_nanoseconds() {
315        assert!(Duration::from_seconds(f64::MAX).is_err());
316        assert!(Duration::from_seconds(-f64::MAX).is_err());
317    }
318
319    #[test]
320    fn duration_from_seconds_valid_input_is_truncated_toward_zero() {
321        assert_eq!(
322            Duration::from_seconds(1.234_567_890_9)
323                .expect("valid duration")
324                .nanos,
325            1_234_567_890
326        );
327        assert_eq!(
328            Duration::from_seconds(-1.234_567_890_9)
329                .expect("valid duration")
330                .nanos,
331            -1_234_567_890
332        );
333    }
334
335    #[test]
336    fn gnss_week_tow_rejects_nonfinite_tow() {
337        assert!(GnssWeekTow::new(TimeScale::Gpst, 100, f64::NAN).is_err());
338        assert!(GnssWeekTow::new(TimeScale::Gpst, 100, f64::INFINITY).is_err());
339        assert!(GnssWeekTow::new(TimeScale::Gpst, 100, f64::NEG_INFINITY).is_err());
340        assert!(GnssWeekTow {
341            system: TimeScale::Gpst,
342            week: 100,
343            tow_s: f64::NAN,
344        }
345        .normalized()
346        .is_err());
347    }
348
349    #[test]
350    fn gnss_week_tow_rejects_out_of_range_week_carry() {
351        let err = GnssWeekTow::new(TimeScale::Gpst, u32::MAX, SECONDS_PER_WEEK)
352            .expect("finite TOW")
353            .normalized();
354        assert!(err.is_err());
355    }
356
357    #[test]
358    fn gnss_week_tow_valid_rollover_is_unchanged() {
359        let wt = GnssWeekTow::new(TimeScale::Gpst, 100, SECONDS_PER_WEEK + 5.0)
360            .expect("valid week/TOW")
361            .normalized()
362            .expect("valid normalized week/TOW");
363        assert_eq!(wt.week, 101);
364        assert_eq!(wt.tow_s, 5.0);
365    }
366
367    #[test]
368    fn gnss_week_tow_unrolled_week_rejects_overflow() {
369        let wt = GnssWeekTow::new(TimeScale::Gpst, u32::MAX, 0.0).expect("valid week/TOW");
370        let result = std::panic::catch_unwind(|| wt.unrolled_week(1));
371        assert!(result.is_ok(), "overflowing unrolled week must not panic");
372        assert_eq!(
373            result.expect("overflowing unrolled week should not unwind"),
374            Err(TimeModelError::InvalidInput {
375                field: "rollovers",
376                reason: "unrolled week is out of range",
377            })
378        );
379    }
380
381    #[test]
382    fn gnss_week_tow_unrolled_week_valid_input_is_unchanged() {
383        let wt = GnssWeekTow::new(TimeScale::Gpst, 10, 0.0).expect("valid week/TOW");
384        assert_eq!(wt.unrolled_week(2).expect("valid unrolled week"), 10 + 2048);
385    }
386}