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