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