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 Tcg,
46 Tdb,
48 Tcb,
50 Gpst,
52 Gst,
54 Bdt,
56 Glonasst,
60 Qzsst,
63}
64
65impl TimeScale {
66 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#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
90pub struct JulianDateSplit {
91 pub jd_whole: f64,
93 pub fraction: f64,
95}
96
97impl JulianDateSplit {
98 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 pub fn to_jd(self) -> f64 {
118 self.jd_whole + self.fraction
119 }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq)]
128pub enum InstantRepr {
129 Nanos(i128),
131 JulianDate(JulianDateSplit),
133}
134
135#[derive(Debug, Clone, Copy, PartialEq)]
137pub struct Instant {
138 pub scale: TimeScale,
140 pub repr: InstantRepr,
142}
143
144impl Instant {
145 pub fn from_julian_date(scale: TimeScale, jd: JulianDateSplit) -> Self {
147 Self {
148 scale,
149 repr: InstantRepr::JulianDate(jd),
150 }
151 }
152
153 pub fn from_nanos(scale: TimeScale, nanos: i128) -> Self {
155 Self {
156 scale,
157 repr: InstantRepr::Nanos(nanos),
158 }
159 }
160
161 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
172pub struct Duration {
173 pub nanos: i128,
175}
176
177impl Duration {
178 pub const ZERO: Duration = Duration { nanos: 0 };
180
181 pub fn from_nanos(nanos: i128) -> Self {
183 Self { nanos }
184 }
185
186 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 pub fn as_seconds(self) -> f64 {
205 self.nanos as f64 / 1e9
206 }
207}
208
209#[derive(Debug, Clone, Copy, PartialEq)]
215pub struct GnssWeekTow {
216 pub system: TimeScale,
218 pub week: u32,
220 pub tow_s: f64,
222}
223
224impl GnssWeekTow {
225 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 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 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}