pub use crate::astro::constants::time::SECONDS_PER_WEEK;
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum TimeModelError {
#[error("invalid time model {field}: {reason}")]
InvalidInput {
field: &'static str,
reason: &'static str,
},
}
fn invalid_input(field: &'static str, reason: &'static str) -> TimeModelError {
TimeModelError::InvalidInput { field, reason }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TimeScale {
Utc,
Tai,
Tt,
Tdb,
Gpst,
Gst,
Bdt,
Glonasst,
Qzsst,
}
impl TimeScale {
pub fn abbrev(self) -> &'static str {
match self {
TimeScale::Utc => "UTC",
TimeScale::Tai => "TAI",
TimeScale::Tt => "TT",
TimeScale::Tdb => "TDB",
TimeScale::Gpst => "GPST",
TimeScale::Gst => "GST",
TimeScale::Bdt => "BDT",
TimeScale::Glonasst => "GLONASST",
TimeScale::Qzsst => "QZSST",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct JulianDateSplit {
pub jd_whole: f64,
pub fraction: f64,
}
impl JulianDateSplit {
pub fn new(jd_whole: f64, fraction: f64) -> Result<Self, TimeModelError> {
if !jd_whole.is_finite() {
return Err(invalid_input("jd_whole", "must be finite"));
}
if !fraction.is_finite() {
return Err(invalid_input("fraction", "must be finite"));
}
if !(-1.0..=1.0).contains(&fraction) {
return Err(invalid_input("fraction", "must be within one residual day"));
}
Ok(Self { jd_whole, fraction })
}
pub fn to_jd(self) -> f64 {
self.jd_whole + self.fraction
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum InstantRepr {
Nanos(i128),
JulianDate(JulianDateSplit),
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Instant {
pub scale: TimeScale,
pub repr: InstantRepr,
}
impl Instant {
pub fn from_julian_date(scale: TimeScale, jd: JulianDateSplit) -> Self {
Self {
scale,
repr: InstantRepr::JulianDate(jd),
}
}
pub fn from_nanos(scale: TimeScale, nanos: i128) -> Self {
Self {
scale,
repr: InstantRepr::Nanos(nanos),
}
}
pub fn julian_date(&self) -> Option<JulianDateSplit> {
match self.repr {
InstantRepr::JulianDate(jd) => Some(jd),
InstantRepr::Nanos(_) => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub struct Duration {
pub nanos: i128,
}
impl Duration {
pub const ZERO: Duration = Duration { nanos: 0 };
pub fn from_nanos(nanos: i128) -> Self {
Self { nanos }
}
pub fn from_seconds(seconds: f64) -> Result<Self, TimeModelError> {
if !seconds.is_finite() {
return Err(invalid_input("seconds", "must be finite"));
}
let nanos = seconds * 1e9;
if !nanos.is_finite() || nanos <= i128::MIN as f64 || nanos >= i128::MAX as f64 {
return Err(invalid_input(
"seconds",
"must convert to an i128 nanosecond count",
));
}
Ok(Self {
nanos: nanos as i128,
})
}
pub fn as_seconds(self) -> f64 {
self.nanos as f64 / 1e9
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GnssWeekTow {
pub system: TimeScale,
pub week: u32,
pub tow_s: f64,
}
impl GnssWeekTow {
pub fn new(system: TimeScale, week: u32, tow_s: f64) -> Result<Self, TimeModelError> {
if !tow_s.is_finite() {
return Err(invalid_input("tow_s", "must be finite"));
}
Ok(Self {
system,
week,
tow_s,
})
}
pub fn normalized(self) -> Result<Self, TimeModelError> {
if !self.tow_s.is_finite() {
return Err(invalid_input("tow_s", "must be finite"));
}
let mut week = self.week as i64;
let mut tow = self.tow_s;
let weeks_carry = (tow / SECONDS_PER_WEEK).floor();
if !weeks_carry.is_finite()
|| weeks_carry <= i64::MIN as f64
|| weeks_carry >= i64::MAX as f64
{
return Err(invalid_input("tow_s", "week carry is out of range"));
}
week = week
.checked_add(weeks_carry as i64)
.ok_or_else(|| invalid_input("tow_s", "week carry is out of range"))?;
tow -= weeks_carry * SECONDS_PER_WEEK;
if week < 0 {
week = 0;
tow = 0.0;
}
if week > u32::MAX as i64 {
return Err(invalid_input("tow_s", "normalized week is out of range"));
}
if !tow.is_finite() {
return Err(invalid_input("tow_s", "normalized TOW must be finite"));
}
Ok(Self {
system: self.system,
week: week as u32,
tow_s: tow,
})
}
pub fn unrolled_week(self, rollovers: u32) -> Result<u32, TimeModelError> {
let rollover_weeks = rollovers
.checked_mul(1024)
.ok_or_else(|| invalid_input("rollovers", "unrolled week is out of range"))?;
self.week
.checked_add(rollover_weeks)
.ok_or_else(|| invalid_input("rollovers", "unrolled week is out of range"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_julian_date_rejects_nonfinite_parts() {
assert!(JulianDateSplit::new(f64::NAN, 0.0).is_err());
assert!(JulianDateSplit::new(f64::INFINITY, 0.0).is_err());
assert!(JulianDateSplit::new(2_451_545.0, f64::NAN).is_err());
assert!(JulianDateSplit::new(2_451_545.0, f64::NEG_INFINITY).is_err());
}
#[test]
fn split_julian_date_rejects_out_of_range_fraction() {
assert!(JulianDateSplit::new(2_451_545.0, 1.0 + f64::EPSILON).is_err());
assert!(JulianDateSplit::new(2_451_545.0, -1.0 - f64::EPSILON).is_err());
}
#[test]
fn split_julian_date_valid_parts_are_unchanged() {
let jd = JulianDateSplit::new(2_451_545.0, -0.25).expect("valid split Julian date");
assert_eq!(jd.jd_whole, 2_451_545.0);
assert_eq!(jd.fraction, -0.25);
assert_eq!(jd.to_jd(), 2_451_544.75);
}
#[test]
fn duration_from_seconds_rejects_nonfinite_seconds() {
assert!(Duration::from_seconds(f64::NAN).is_err());
assert!(Duration::from_seconds(f64::INFINITY).is_err());
assert!(Duration::from_seconds(f64::NEG_INFINITY).is_err());
}
#[test]
fn duration_from_seconds_rejects_unrepresentable_nanoseconds() {
assert!(Duration::from_seconds(f64::MAX).is_err());
assert!(Duration::from_seconds(-f64::MAX).is_err());
}
#[test]
fn duration_from_seconds_valid_input_is_truncated_toward_zero() {
assert_eq!(
Duration::from_seconds(1.234_567_890_9)
.expect("valid duration")
.nanos,
1_234_567_890
);
assert_eq!(
Duration::from_seconds(-1.234_567_890_9)
.expect("valid duration")
.nanos,
-1_234_567_890
);
}
#[test]
fn gnss_week_tow_rejects_nonfinite_tow() {
assert!(GnssWeekTow::new(TimeScale::Gpst, 100, f64::NAN).is_err());
assert!(GnssWeekTow::new(TimeScale::Gpst, 100, f64::INFINITY).is_err());
assert!(GnssWeekTow::new(TimeScale::Gpst, 100, f64::NEG_INFINITY).is_err());
assert!(GnssWeekTow {
system: TimeScale::Gpst,
week: 100,
tow_s: f64::NAN,
}
.normalized()
.is_err());
}
#[test]
fn gnss_week_tow_rejects_out_of_range_week_carry() {
let err = GnssWeekTow::new(TimeScale::Gpst, u32::MAX, SECONDS_PER_WEEK)
.expect("finite TOW")
.normalized();
assert!(err.is_err());
}
#[test]
fn gnss_week_tow_valid_rollover_is_unchanged() {
let wt = GnssWeekTow::new(TimeScale::Gpst, 100, SECONDS_PER_WEEK + 5.0)
.expect("valid week/TOW")
.normalized()
.expect("valid normalized week/TOW");
assert_eq!(wt.week, 101);
assert_eq!(wt.tow_s, 5.0);
}
#[test]
fn gnss_week_tow_unrolled_week_rejects_overflow() {
let wt = GnssWeekTow::new(TimeScale::Gpst, u32::MAX, 0.0).expect("valid week/TOW");
let result = std::panic::catch_unwind(|| wt.unrolled_week(1));
assert!(result.is_ok(), "overflowing unrolled week must not panic");
assert_eq!(
result.expect("overflowing unrolled week should not unwind"),
Err(TimeModelError::InvalidInput {
field: "rollovers",
reason: "unrolled week is out of range",
})
);
}
#[test]
fn gnss_week_tow_unrolled_week_valid_input_is_unchanged() {
let wt = GnssWeekTow::new(TimeScale::Gpst, 10, 0.0).expect("valid week/TOW");
assert_eq!(wt.unrolled_week(2).expect("valid unrolled week"), 10 + 2048);
}
}