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 Glonasst,
56 Qzsst,
59}
60
61impl TimeScale {
62 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#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
84pub struct JulianDateSplit {
85 pub jd_whole: f64,
87 pub fraction: f64,
89}
90
91impl JulianDateSplit {
92 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 pub fn to_jd(self) -> f64 {
112 self.jd_whole + self.fraction
113 }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq)]
122pub enum InstantRepr {
123 Nanos(i128),
125 JulianDate(JulianDateSplit),
127}
128
129#[derive(Debug, Clone, Copy, PartialEq)]
131pub struct Instant {
132 pub scale: TimeScale,
134 pub repr: InstantRepr,
136}
137
138impl Instant {
139 pub fn from_julian_date(scale: TimeScale, jd: JulianDateSplit) -> Self {
141 Self {
142 scale,
143 repr: InstantRepr::JulianDate(jd),
144 }
145 }
146
147 pub fn from_nanos(scale: TimeScale, nanos: i128) -> Self {
149 Self {
150 scale,
151 repr: InstantRepr::Nanos(nanos),
152 }
153 }
154
155 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
166pub struct Duration {
167 pub nanos: i128,
169}
170
171impl Duration {
172 pub const ZERO: Duration = Duration { nanos: 0 };
174
175 pub fn from_nanos(nanos: i128) -> Self {
177 Self { nanos }
178 }
179
180 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 pub fn as_seconds(self) -> f64 {
199 self.nanos as f64 / 1e9
200 }
201}
202
203#[derive(Debug, Clone, Copy, PartialEq)]
209pub struct GnssWeekTow {
210 pub system: TimeScale,
212 pub week: u32,
214 pub tow_s: f64,
216}
217
218impl GnssWeekTow {
219 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 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 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}