sidereon_core/astro/time/
model.rs1pub use crate::astro::constants::time::SECONDS_PER_WEEK;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
22pub enum TimeModelError {
23 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub enum TimeScale {
38 Utc,
40 Tai,
42 Tt,
44 Tdb,
46 Gpst,
48 Gst,
50 Bdt,
52}
53
54impl TimeScale {
55 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#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
75pub struct JulianDateSplit {
76 pub jd_whole: f64,
78 pub fraction: f64,
80}
81
82impl JulianDateSplit {
83 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 pub fn to_jd(self) -> f64 {
103 self.jd_whole + self.fraction
104 }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq)]
113pub enum InstantRepr {
114 Nanos(i128),
116 JulianDate(JulianDateSplit),
118}
119
120#[derive(Debug, Clone, Copy, PartialEq)]
122pub struct Instant {
123 pub scale: TimeScale,
125 pub repr: InstantRepr,
127}
128
129impl Instant {
130 pub fn from_julian_date(scale: TimeScale, jd: JulianDateSplit) -> Self {
132 Self {
133 scale,
134 repr: InstantRepr::JulianDate(jd),
135 }
136 }
137
138 pub fn from_nanos(scale: TimeScale, nanos: i128) -> Self {
140 Self {
141 scale,
142 repr: InstantRepr::Nanos(nanos),
143 }
144 }
145
146 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
157pub struct Duration {
158 pub nanos: i128,
160}
161
162impl Duration {
163 pub const ZERO: Duration = Duration { nanos: 0 };
165
166 pub fn from_nanos(nanos: i128) -> Self {
168 Self { nanos }
169 }
170
171 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 pub fn as_seconds(self) -> f64 {
190 self.nanos as f64 / 1e9
191 }
192}
193
194#[derive(Debug, Clone, Copy, PartialEq)]
200pub struct GnssWeekTow {
201 pub system: TimeScale,
203 pub week: u32,
205 pub tow_s: f64,
207}
208
209impl GnssWeekTow {
210 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 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 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}