diff --git a/.idea/lox-space.iml b/.idea/lox-space.iml index b2060bea..1e600b65 100644 --- a/.idea/lox-space.iml +++ b/.idea/lox-space.iml @@ -21,6 +21,7 @@ + diff --git a/crates/lox-space/examples/iss.rs b/crates/lox-space/examples/iss.rs index 66767908..ecc8e060 100644 --- a/crates/lox-space/examples/iss.rs +++ b/crates/lox-space/examples/iss.rs @@ -6,15 +6,16 @@ * file, you can obtain one at https://mozilla.org/MPL/2.0/. */ +use lox_core::time::utc::UTC; use lox_space::prelude::*; fn main() { let date = Date::new(2016, 5, 30).unwrap(); - let time = Time::new(12, 0, 0).unwrap(); - let epoch = Epoch::from_date_and_time(TimeScale::TDB, date, time); + let utc = UTC::new(12, 0, 0).unwrap(); + let time = Time::from_date_and_utc_timestamp(TimeScale::TDB, date, utc); let position = DVec3::new(6068279.27, -1692843.94, -2516619.18) * 1e-3; let velocity = DVec3::new(-660.415582, 5495.938726, -5303.093233) * 1e-3; - let iss_cartesian = Cartesian::new(epoch, Earth, Icrf, position, velocity); + let iss_cartesian = Cartesian::new(time, Earth, Icrf, position, velocity); let iss = Keplerian::from(iss_cartesian); println!("ISS Orbit for Julian Day {}", iss.time().days_since_j2000(),); diff --git a/crates/lox-space/src/prelude.rs b/crates/lox-space/src/prelude.rs index b9f9c620..3f8a9894 100644 --- a/crates/lox-space/src/prelude.rs +++ b/crates/lox-space/src/prelude.rs @@ -7,8 +7,9 @@ */ pub use lox_core::bodies::*; + pub use lox_core::coords::two_body::{Cartesian, Keplerian}; pub use lox_core::coords::DVec3; pub use lox_core::frames::*; +pub use lox_core::time::continuous::*; pub use lox_core::time::dates::*; -pub use lox_core::time::epochs::*; diff --git a/crates/lox_core/src/coords/states.rs b/crates/lox_core/src/coords/states.rs index 94900e47..6de8772c 100644 --- a/crates/lox_core/src/coords/states.rs +++ b/crates/lox_core/src/coords/states.rs @@ -10,23 +10,23 @@ use float_eq::float_eq; use glam::{DMat3, DVec3}; use crate::math::{mod_two_pi, normalize_two_pi}; -use crate::time::epochs::Epoch; +use crate::time::continuous::Time; pub trait TwoBodyState { - fn time(&self) -> Epoch; + fn time(&self) -> Time; fn to_cartesian_state(&self, grav_param: f64) -> CartesianState; fn to_keplerian_state(&self, grav_param: f64) -> KeplerianState; } #[derive(Debug, Copy, Clone, PartialEq)] pub struct CartesianState { - time: Epoch, + time: Time, position: DVec3, velocity: DVec3, } impl CartesianState { - pub fn new(time: Epoch, position: DVec3, velocity: DVec3) -> Self { + pub fn new(time: Time, position: DVec3, velocity: DVec3) -> Self { Self { time, position, @@ -44,7 +44,7 @@ impl CartesianState { } impl TwoBodyState for CartesianState { - fn time(&self) -> Epoch { + fn time(&self) -> Time { self.time } @@ -120,7 +120,7 @@ impl TwoBodyState for CartesianState { #[derive(Debug, Copy, Clone, PartialEq)] pub struct KeplerianState { - time: Epoch, + time: Time, semi_major: f64, eccentricity: f64, inclination: f64, @@ -131,7 +131,7 @@ pub struct KeplerianState { impl KeplerianState { pub fn new( - time: Epoch, + time: Time, semi_major: f64, eccentricity: f64, inclination: f64, @@ -196,7 +196,7 @@ impl KeplerianState { } impl TwoBodyState for KeplerianState { - fn time(&self) -> Epoch { + fn time(&self) -> Time { self.time } @@ -231,7 +231,7 @@ fn is_circular(eccentricity: f64) -> bool { #[cfg(test)] mod tests { - use crate::time::epochs::TimeScale; + use crate::time::continuous::TimeScale; use float_eq::assert_float_eq; use glam::DVec3; @@ -239,7 +239,7 @@ mod tests { #[test] fn test_elliptic() { - let time = Epoch::j2000(TimeScale::TDB); + let time = Time::j2000(TimeScale::TDB); let grav_param = 3.9860047e14; let semi_major = 24464560.0; let eccentricity = 0.7311; @@ -295,7 +295,7 @@ mod tests { #[test] fn test_circular() { - let time = Epoch::j2000(TimeScale::TDB); + let time = Time::j2000(TimeScale::TDB); let grav_param = 3.986004418e14; let semi_major = 6778136.6; let eccentricity = 0.0; @@ -336,7 +336,7 @@ mod tests { #[test] fn test_circular_orekit() { - let time = Epoch::j2000(TimeScale::TDB); + let time = Time::j2000(TimeScale::TDB); let grav_param = 3.9860047e14; let semi_major = 24464560.0; let eccentricity = 0.0; @@ -368,7 +368,7 @@ mod tests { #[test] fn test_hyperbolic_orekit() { - let time = Epoch::j2000(TimeScale::TDB); + let time = Time::j2000(TimeScale::TDB); let grav_param = 3.9860047e14; let semi_major = -24464560.0; let eccentricity = 1.7311; @@ -400,7 +400,7 @@ mod tests { #[test] fn test_equatorial() { - let time = Epoch::j2000(TimeScale::TDB); + let time = Time::j2000(TimeScale::TDB); let grav_param = 3.9860047e14; let semi_major = 24464560.0; let eccentricity = 0.7311; @@ -432,7 +432,7 @@ mod tests { #[test] fn test_circular_equatorial() { - let time = Epoch::j2000(TimeScale::TDB); + let time = Time::j2000(TimeScale::TDB); let grav_param = 3.9860047e14; let semi_major = 24464560.0; let eccentricity = 0.0; diff --git a/crates/lox_core/src/coords/two_body.rs b/crates/lox_core/src/coords/two_body.rs index 8a15cf5a..4484f669 100644 --- a/crates/lox_core/src/coords/two_body.rs +++ b/crates/lox_core/src/coords/two_body.rs @@ -12,7 +12,7 @@ use crate::bodies::PointMass; use crate::coords::states::{CartesianState, KeplerianState, TwoBodyState}; use crate::coords::CoordinateSystem; use crate::frames::{InertialFrame, ReferenceFrame}; -use crate::time::epochs::Epoch; +use crate::time::continuous::Time; pub trait TwoBody where @@ -40,7 +40,7 @@ where T: PointMass + Copy, S: ReferenceFrame + Copy, { - pub fn new(time: Epoch, origin: T, frame: S, position: DVec3, velocity: DVec3) -> Self { + pub fn new(time: Time, origin: T, frame: S, position: DVec3, velocity: DVec3) -> Self { let state = CartesianState::new(time, position, velocity); Self { state, @@ -49,7 +49,7 @@ where } } - pub fn time(&self) -> Epoch { + pub fn time(&self) -> Time { self.state.time() } @@ -127,7 +127,7 @@ where { #[allow(clippy::too_many_arguments)] pub fn new( - time: Epoch, + time: Time, origin: T, frame: S, semi_major: f64, @@ -153,7 +153,7 @@ where } } - pub fn time(&self) -> Epoch { + pub fn time(&self) -> Time { self.state.time() } @@ -233,18 +233,18 @@ where mod tests { use float_eq::assert_float_eq; + use super::*; use crate::bodies::Earth; use crate::frames::Icrf; - use crate::time::dates::{Date, Time}; - use crate::time::epochs::TimeScale; - - use super::*; + use crate::time::continuous::{Time, TimeScale}; + use crate::time::dates::Date; + use crate::time::utc::UTC; #[test] fn test_cartesian() { let date = Date::new(2023, 3, 25).expect("Date should be valid"); - let time = Time::new(21, 8, 0).expect("Time should be valid"); - let epoch = Epoch::from_date_and_time(TimeScale::TDB, date, time); + let utc = UTC::new(21, 8, 0).expect("Time should be valid"); + let time = Time::from_date_and_utc_timestamp(TimeScale::TDB, date, utc); let pos = DVec3::new( -0.107622532467967e7, -0.676589636432773e7, @@ -256,12 +256,12 @@ mod tests { -0.118801577532701e4, ) * 1e-3; - let cartesian = Cartesian::new(epoch, Earth, Icrf, pos, vel); + let cartesian = Cartesian::new(time, Earth, Icrf, pos, vel); assert_eq!(cartesian.to_cartesian(), cartesian); let cartesian1 = cartesian.to_keplerian().to_cartesian(); - assert_eq!(cartesian1.time(), epoch); + assert_eq!(cartesian1.time(), time); assert_eq!(cartesian1.origin(), Earth); assert_eq!(cartesian1.reference_frame(), Icrf); @@ -276,8 +276,8 @@ mod tests { #[test] fn test_keplerian() { let date = Date::new(2023, 3, 25).expect("Date should be valid"); - let time = Time::new(21, 8, 0).expect("Time should be valid"); - let epoch = Epoch::from_date_and_time(TimeScale::TDB, date, time); + let utc = UTC::new(21, 8, 0).expect("Time should be valid"); + let time = Time::from_date_and_utc_timestamp(TimeScale::TDB, date, utc); let semi_major = 24464560.0e-3; let eccentricity = 0.7311; let inclination = 0.122138; @@ -286,7 +286,7 @@ mod tests { let true_anomaly = 0.44369564302687126; let keplerian = Keplerian::new( - epoch, + time, Earth, Icrf, semi_major, @@ -300,7 +300,7 @@ mod tests { let keplerian1 = keplerian.to_cartesian().to_keplerian(); - assert_eq!(keplerian1.time(), epoch); + assert_eq!(keplerian1.time(), time); assert_eq!(keplerian1.origin(), Earth); assert_eq!(keplerian1.reference_frame(), Icrf); diff --git a/crates/lox_core/src/earth/nutation.rs b/crates/lox_core/src/earth/nutation.rs index ae03eeeb..9500a694 100644 --- a/crates/lox_core/src/earth/nutation.rs +++ b/crates/lox_core/src/earth/nutation.rs @@ -16,7 +16,7 @@ use crate::earth::nutation::iau2000::nutation_iau2000a; use crate::earth::nutation::iau2000::nutation_iau2000b; use crate::earth::nutation::iau2006::nutation_iau2006a; use crate::math::RADIANS_IN_ARCSECOND; -use crate::time::epochs::Epoch; +use crate::time::continuous::Time; use crate::time::intervals::tdb_julian_centuries_since_j2000; use crate::types::Radians; @@ -63,9 +63,9 @@ impl Add<&Self> for Nutation { } } -/// Calculate nutation coefficients at `epoch` using the given [Model]. -pub fn nutation(model: Model, epoch: Epoch) -> Nutation { - let t = tdb_julian_centuries_since_j2000(epoch); +/// Calculate nutation coefficients at `time` using the given [Model]. +pub fn nutation(model: Model, time: Time) -> Nutation { + let t = tdb_julian_centuries_since_j2000(time); match model { Model::IAU1980 => nutation_iau1980(t), Model::IAU2000A => nutation_iau2000a(t), @@ -98,57 +98,56 @@ fn point1_microarcsec_to_rad(p1_uas: Point1Microarcsec) -> Radians { #[cfg(test)] mod tests { + use crate::time::continuous::TimeScale; use float_eq::assert_float_eq; - use crate::time::epochs::{Epoch, TimeScale}; - use super::*; const TOLERANCE: f64 = 1e-12; #[test] fn test_nutation_iau1980() { - let epoch = Epoch::j2000(TimeScale::TT); + let time = Time::j2000(TimeScale::TT); let expected = Nutation { longitude: -0.00006750247617532478, obliquity: -0.00002799221238377013, }; - let actual = nutation(Model::IAU1980, epoch); + let actual = nutation(Model::IAU1980, time); assert_float_eq!(expected.longitude, actual.longitude, rel <= TOLERANCE); assert_float_eq!(expected.obliquity, actual.obliquity, rel <= TOLERANCE); } #[test] fn test_nutation_iau2000a() { - let epoch = Epoch::j2000(TimeScale::TT); + let time = Time::j2000(TimeScale::TT); let expected = Nutation { longitude: -0.00006754422426417299, obliquity: -0.00002797083119237414, }; - let actual = nutation(Model::IAU2000A, epoch); + let actual = nutation(Model::IAU2000A, time); assert_float_eq!(expected.longitude, actual.longitude, rel <= TOLERANCE); assert_float_eq!(expected.obliquity, actual.obliquity, rel <= TOLERANCE); } #[test] fn test_nutation_iau2000b() { - let epoch = Epoch::j2000(TimeScale::TT); + let time = Time::j2000(TimeScale::TT); let expected = Nutation { longitude: -0.00006754261253992235, obliquity: -0.00002797092331098565, }; - let actual = nutation(Model::IAU2000B, epoch); + let actual = nutation(Model::IAU2000B, time); assert_float_eq!(expected.longitude, actual.longitude, rel <= TOLERANCE); assert_float_eq!(expected.obliquity, actual.obliquity, rel <= TOLERANCE); } #[test] fn test_nutation_iau2006a() { - let epoch = Epoch::j2000(TimeScale::TT); + let time = Time::j2000(TimeScale::TT); let expected = Nutation { longitude: -0.00006754425598969513, obliquity: -0.00002797083119237414, }; - let actual = nutation(Model::IAU2006A, epoch); + let actual = nutation(Model::IAU2006A, time); assert_float_eq!(expected.longitude, actual.longitude, rel <= TOLERANCE); assert_float_eq!(expected.obliquity, actual.obliquity, rel <= TOLERANCE); } diff --git a/crates/lox_core/src/errors.rs b/crates/lox_core/src/errors.rs index 926c19f5..b4cd4ea9 100644 --- a/crates/lox_core/src/errors.rs +++ b/crates/lox_core/src/errors.rs @@ -8,14 +8,16 @@ use thiserror::Error; -#[derive(Error, Debug)] +#[derive(Error, Debug, PartialEq)] pub enum LoxError { #[error("invalid date `{0}-{1}-{2}`")] InvalidDate(i64, i64, i64), #[error("invalid time `{0}:{1}:{2}`")] - InvalidTime(i64, i64, i64), - #[error("invalid time `{0}:{1}:{2}`")] - InvalidSeconds(i64, i64, f64), + InvalidTime(u8, u8, u8), + #[error("seconds must be in the range [0.0, 60.0], but was `{0}`")] + InvalidSeconds(f64), + #[error("PerMille value must be in the range [0, 999], but was `{0}`")] + InvalidPerMille(u16), #[error("day of year cannot be 366 for a non-leap year")] NonLeapYear, #[error("unknown body `{0}`")] diff --git a/crates/lox_core/src/time.rs b/crates/lox_core/src/time.rs index bd1c7a95..055248c5 100644 --- a/crates/lox_core/src/time.rs +++ b/crates/lox_core/src/time.rs @@ -6,8 +6,155 @@ * file, you can obtain one at https://mozilla.org/MPL/2.0/. */ +use crate::errors::LoxError; +use std::fmt; +use std::fmt::{Display, Formatter}; + pub mod constants; +pub mod continuous; pub mod dates; -pub mod epochs; pub mod intervals; pub mod leap_seconds; +pub mod utc; + +/// `WallClock` is the trait by which high-precision time representations expose human-readable time components. +pub trait WallClock { + fn hour(&self) -> i64; + fn minute(&self) -> i64; + fn second(&self) -> i64; + fn millisecond(&self) -> i64; + fn microsecond(&self) -> i64; + fn nanosecond(&self) -> i64; + fn picosecond(&self) -> i64; + fn femtosecond(&self) -> i64; + fn attosecond(&self) -> i64; +} + +/// Newtype wrapper for thousandths of an SI-prefixed subsecond (milli, micro, nano, etc.). +#[repr(transparent)] +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct PerMille(u16); + +impl PerMille { + pub fn new(per_mille: u16) -> Result { + if !(0..1000).contains(&per_mille) { + Err(LoxError::InvalidPerMille(per_mille)) + } else { + Ok(Self(per_mille)) + } + } +} + +impl Display for PerMille { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{:03}", self.0) + } +} + +impl TryFrom for PerMille { + type Error = LoxError; + + fn try_from(per_mille: u16) -> Result { + Self::new(per_mille) + } +} + +#[allow(clippy::from_over_into)] // the Into conversion is infallible, but From is not +impl Into for PerMille { + fn into(self) -> i64 { + self.0 as i64 + } +} + +#[cfg(test)] +mod tests { + use crate::errors::LoxError; + use crate::time::PerMille; + + #[test] + fn test_per_mille_new() { + struct TestCase { + desc: &'static str, + input: u16, + expected: Result, + } + + let test_cases = [ + TestCase { + desc: "on lower bound", + input: 0, + expected: Ok(PerMille(0)), + }, + TestCase { + desc: "between bounds", + input: 1, + expected: Ok(PerMille(1)), + }, + TestCase { + desc: "on upper bound", + input: 999, + expected: Ok(PerMille(999)), + }, + TestCase { + desc: "above upper bound", + input: 1000, + expected: Err(LoxError::InvalidPerMille(1000)), + }, + ]; + + for tc in test_cases { + let actual = PerMille::new(tc.input); + assert_eq!( + actual, tc.expected, + "expected {:?} when input is {:?}, but got {:?}", + tc.expected, tc.input, tc.desc + ); + } + } + + #[test] + fn test_per_mille_display() { + struct TestCase { + input: PerMille, + expected: &'static str, + } + + let test_cases = [ + TestCase { + input: PerMille(1), + expected: "001", + }, + TestCase { + input: PerMille(11), + expected: "011", + }, + TestCase { + input: PerMille(111), + expected: "111", + }, + ]; + + for tc in test_cases { + let actual = format!("{}", tc.input); + assert_eq!( + actual, tc.expected, + "expected {:?} when input is {:?}, but got {:?}", + tc.expected, tc.input, actual, + ); + } + } + + #[test] + fn test_per_mille_try_from() { + assert_eq!(PerMille::try_from(0), Ok(PerMille(0))); + assert_eq!( + PerMille::try_from(1000), + Err(LoxError::InvalidPerMille(1000)) + ); + } + + #[test] + fn test_per_mille_into_i64() { + assert_eq!(Into::::into(PerMille(0)), 0i64); + } +} diff --git a/crates/lox_core/src/time/constants.rs b/crates/lox_core/src/time/constants.rs index a3ccdcc1..3634df7c 100644 --- a/crates/lox_core/src/time/constants.rs +++ b/crates/lox_core/src/time/constants.rs @@ -8,3 +8,4 @@ pub mod f64; pub mod i64; +pub mod u64; diff --git a/crates/lox_core/src/time/constants/i64.rs b/crates/lox_core/src/time/constants/i64.rs index c8e5f3b5..65644b7e 100644 --- a/crates/lox_core/src/time/constants/i64.rs +++ b/crates/lox_core/src/time/constants/i64.rs @@ -11,3 +11,15 @@ pub const SECONDS_PER_MINUTE: i64 = 60; pub const SECONDS_PER_HOUR: i64 = 60 * SECONDS_PER_MINUTE; pub const SECONDS_PER_DAY: i64 = 24 * SECONDS_PER_HOUR; + +pub const SECONDS_PER_HALF_DAY: i64 = SECONDS_PER_DAY / 2; + +pub const MILLISECONDS_PER_SECOND: i64 = 1_000; + +pub const MICROSECONDS_PER_SECOND: i64 = 1_000_000; + +pub const NANOSECONDS_PER_SECOND: i64 = 1_000_000_000; + +pub const PICOSECONDS_PER_SECOND: i64 = 1_000_000_000_000; + +pub const FEMTOSECONDS_PER_SECOND: i64 = 1_000_000_000_000_000; diff --git a/crates/lox_core/src/time/constants/u64.rs b/crates/lox_core/src/time/constants/u64.rs new file mode 100644 index 00000000..06c5b990 --- /dev/null +++ b/crates/lox_core/src/time/constants/u64.rs @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024. Helge Eichhorn and the LOX contributors + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + */ + +pub const MILLISECONDS_PER_SECOND: u64 = 1_000; + +pub const MICROSECONDS_PER_SECOND: u64 = 1_000_000; + +pub const NANOSECONDS_PER_SECOND: u64 = 1_000_000_000; + +pub const PICOSECONDS_PER_SECOND: u64 = 1_000_000_000_000; + +pub const FEMTOSECONDS_PER_SECOND: u64 = 1_000_000_000_000_000; + +pub const ATTOSECONDS_PER_SECOND: u64 = 1_000_000_000_000_000_000; + +pub const ATTOSECONDS_PER_MILLISECOND: u64 = 1_000_000_000_000_000; + +pub const ATTOSECONDS_PER_MICROSECOND: u64 = 1_000_000_000_000; + +pub const ATTOSECONDS_PER_NANOSECOND: u64 = 1_000_000_000; + +pub const ATTOSECONDS_PER_PICOSECOND: u64 = 1_000_000; + +pub const ATTOSECONDS_PER_FEMTOSECOND: u64 = 1_000; diff --git a/crates/lox_core/src/time/continuous.rs b/crates/lox_core/src/time/continuous.rs new file mode 100644 index 00000000..e482802a --- /dev/null +++ b/crates/lox_core/src/time/continuous.rs @@ -0,0 +1,1413 @@ +/* + * Copyright (c) 2023. Helge Eichhorn and the LOX contributors + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + */ + +//! Module continuous provides representations and functions for working with time scales without leap seconds. +//! +//! Continuous times are represented with attosecond precision. +//! +//! The supported timescales are specified by [TimeScale]. + +use std::fmt; +use std::fmt::{Display, Formatter}; +use std::ops::{Add, Sub}; + +use num::{abs, ToPrimitive}; + +use crate::time::constants::i64::{ + SECONDS_PER_DAY, SECONDS_PER_HALF_DAY, SECONDS_PER_HOUR, SECONDS_PER_MINUTE, +}; +use crate::time::constants::u64::{ + ATTOSECONDS_PER_FEMTOSECOND, ATTOSECONDS_PER_MICROSECOND, ATTOSECONDS_PER_MILLISECOND, + ATTOSECONDS_PER_NANOSECOND, ATTOSECONDS_PER_PICOSECOND, ATTOSECONDS_PER_SECOND, +}; +use crate::time::dates::Calendar::ProlepticJulian; +use crate::time::dates::Date; +use crate::time::utc::{UTCDateTime, UTC}; +use crate::time::{constants, WallClock}; + +/// An absolute continuous time difference with attosecond precision. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub struct TimeDelta { + seconds: u64, + attoseconds: u64, +} + +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq)] +/// `RawTime` is the base time representation for time scales without leap seconds. It is measured relative to +/// J2000. `RawTime::default()` represents the epoch itself. +/// +/// `RawTime` has attosecond precision, and supports times within 292 billion years either side of the epoch. +pub struct RawTime { + // The sign of the time is determined exclusively by the sign of the `second` field. `attoseconds` is always the + // positive count of attoseconds since the last whole second. For example, one attosecond before the epoch is + // represented as + // ``` + // let time = RawTime { + // seconds: -1, + // attoseconds: ATTOSECONDS_PER_SECOND - 1, + // }; + // ``` + seconds: i64, + attoseconds: u64, +} + +impl RawTime { + fn is_negative(&self) -> bool { + self.seconds < 0 + } + + fn hour(&self) -> i64 { + // Since J2000 is taken from midday, we offset by half a day to get the wall clock hour. + let day_seconds: i64 = if self.is_negative() { + SECONDS_PER_DAY - (abs(self.seconds) + SECONDS_PER_HALF_DAY) % SECONDS_PER_DAY + } else { + (self.seconds + SECONDS_PER_HALF_DAY) % SECONDS_PER_DAY + }; + day_seconds / SECONDS_PER_HOUR + } + + fn minute(&self) -> i64 { + let hour_seconds: i64 = if self.is_negative() { + SECONDS_PER_HOUR - abs(self.seconds) % SECONDS_PER_HOUR + } else { + self.seconds % SECONDS_PER_HOUR + }; + hour_seconds / SECONDS_PER_MINUTE + } + + fn second(&self) -> i64 { + if self.is_negative() { + SECONDS_PER_MINUTE - abs(self.seconds) % SECONDS_PER_MINUTE + } else { + self.seconds % SECONDS_PER_MINUTE + } + } + + fn millisecond(&self) -> i64 { + (self.attoseconds / ATTOSECONDS_PER_MILLISECOND) as i64 + } + + fn microsecond(&self) -> i64 { + (self.attoseconds / ATTOSECONDS_PER_MICROSECOND % 1000) as i64 + } + + fn nanosecond(&self) -> i64 { + (self.attoseconds / ATTOSECONDS_PER_NANOSECOND % 1000) as i64 + } + + fn picosecond(&self) -> i64 { + (self.attoseconds / ATTOSECONDS_PER_PICOSECOND % 1000) as i64 + } + + fn femtosecond(&self) -> i64 { + (self.attoseconds / ATTOSECONDS_PER_FEMTOSECOND % 1000) as i64 + } + + fn attosecond(&self) -> i64 { + (self.attoseconds % 1000) as i64 + } +} + +impl Add for RawTime { + type Output = Self; + + /// The implementation of [Add] for [RawTime] follows the default Rust rules for integer overflow, which + /// should be sufficient for all practical purposes. + fn add(self, rhs: TimeDelta) -> Self::Output { + let mut attoseconds = self.attoseconds + rhs.attoseconds; + let mut seconds = self.seconds + rhs.seconds as i64; + if attoseconds >= ATTOSECONDS_PER_SECOND { + seconds += 1; + attoseconds -= ATTOSECONDS_PER_SECOND; + } + Self { + seconds, + attoseconds, + } + } +} + +impl Sub for RawTime { + type Output = Self; + + /// The implementation of [Sub] for [RawTime] follows the default Rust rules for integer overflow, which + /// should be sufficient for all practical purposes. + fn sub(self, rhs: TimeDelta) -> Self::Output { + let mut seconds = self.seconds - rhs.seconds as i64; + let mut attoseconds = self.attoseconds; + if rhs.attoseconds > self.attoseconds { + seconds -= 1; + attoseconds = ATTOSECONDS_PER_SECOND - (rhs.attoseconds - self.attoseconds); + } else { + attoseconds -= rhs.attoseconds; + } + Self { + seconds, + attoseconds, + } + } +} + +/// The continuous time scales supported by Lox. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum TimeScale { + TAI, + TCB, + TCG, + TDB, + TT, + UT1, +} + +impl Display for TimeScale { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", Into::<&str>::into(*self)) + } +} + +#[allow(clippy::from_over_into)] // Into is infallible, but From is not +impl Into<&str> for TimeScale { + fn into(self) -> &'static str { + match self { + TimeScale::TAI => "TAI", + TimeScale::TCB => "TCB", + TimeScale::TCG => "TCG", + TimeScale::TDB => "TDB", + TimeScale::TT => "TT", + TimeScale::UT1 => "UT1", + } + } +} + +/// CalendarDate allows continuous time formats to report their date in their respective calendar. +pub trait CalendarDate { + fn date(&self) -> Date; +} + +/// International Atomic Time. Defaults to the J2000 epoch. +#[derive(Debug, Copy, Default, Clone, Eq, PartialEq)] +pub struct TAI(RawTime); + +impl TAI { + pub fn to_ut1(&self, _dut: TimeDelta, _dat: TimeDelta) -> UT1 { + todo!() + } +} + +impl CalendarDate for TAI { + fn date(&self) -> Date { + todo!() + } +} + +/// Barycentric Coordinate Time. Defaults to the J2000 epoch. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq)] +pub struct TCB(RawTime); + +/// Geocentric Coordinate Time. Defaults to the J2000 epoch. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq)] +pub struct TCG(RawTime); + +/// Barycentric Dynamical Time. Defaults to the J2000 epoch. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq)] +pub struct TDB(RawTime); + +/// Terrestrial Time. Defaults to the J2000 epoch. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq)] +pub struct TT(RawTime); + +/// Universal Time. Defaults to the J2000 epoch. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq)] +pub struct UT1(RawTime); + +/// Implements the `WallClock` trait for the a time scale based on [RawTime] in terms of the underlying +/// raw time. +macro_rules! wall_clock { + ($time_scale:ident, $test_module:ident) => { + impl WallClock for $time_scale { + fn hour(&self) -> i64 { + self.0.hour() + } + + fn minute(&self) -> i64 { + self.0.minute() + } + + fn second(&self) -> i64 { + self.0.second() + } + + fn millisecond(&self) -> i64 { + self.0.millisecond() + } + + fn microsecond(&self) -> i64 { + self.0.microsecond() + } + + fn nanosecond(&self) -> i64 { + self.0.nanosecond() + } + + fn picosecond(&self) -> i64 { + self.0.picosecond() + } + + fn femtosecond(&self) -> i64 { + self.0.femtosecond() + } + + fn attosecond(&self) -> i64 { + self.0.attosecond() + } + } + + #[cfg(test)] + mod $test_module { + use super::{$time_scale, RawTime}; + use crate::time::WallClock; + + const RAW_TIME: RawTime = RawTime { + seconds: 1234, + attoseconds: 5678, + }; + + const TIME: $time_scale = $time_scale(RAW_TIME); + + #[test] + fn test_hour_delegation() { + assert_eq!(TIME.hour(), RAW_TIME.hour()); + } + + #[test] + fn test_minute_delegation() { + assert_eq!(TIME.minute(), RAW_TIME.minute()); + } + + #[test] + fn test_second_delegation() { + assert_eq!(TIME.second(), RAW_TIME.second()); + } + + #[test] + fn test_millisecond_delegation() { + assert_eq!(TIME.millisecond(), RAW_TIME.millisecond()); + } + + #[test] + fn test_microsecond_delegation() { + assert_eq!(TIME.microsecond(), RAW_TIME.microsecond()); + } + + #[test] + fn test_nanosecond_delegation() { + assert_eq!(TIME.nanosecond(), RAW_TIME.nanosecond()); + } + + #[test] + fn test_picosecond_delegation() { + assert_eq!(TIME.picosecond(), RAW_TIME.picosecond()); + } + + #[test] + fn test_femtosecond_delegation() { + assert_eq!(TIME.femtosecond(), RAW_TIME.femtosecond()); + } + + #[test] + fn test_attosecond_delegation() { + assert_eq!(TIME.attosecond(), RAW_TIME.attosecond()); + } + } + }; +} + +// Implement WallClock for all continuous time scales. +wall_clock!(TAI, tai_wall_clock_tests); +wall_clock!(TCB, tcb_wall_clock_tests); +wall_clock!(TCG, tcg_wall_clock_tests); +wall_clock!(TDB, tdb_wall_clock_tests); +wall_clock!(TT, tt_wall_clock_tests); +wall_clock!(UT1, ut1_wall_clock_tests); + +/// `Time` represents a time in any of the supported continuous timescales. +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum Time { + TAI(TAI), + TCB(TCB), + TCG(TCG), + TDB(TDB), + TT(TT), + UT1(UT1), +} + +impl Time { + /// Instantiates a `Time` of the given scale from a date and UTC timestamp. + pub fn from_date_and_utc_timestamp(scale: TimeScale, date: Date, time: UTC) -> Self { + let day_in_seconds = date.j2000() * SECONDS_PER_DAY - SECONDS_PER_DAY / 2; + let hour_in_seconds = time.hour() * SECONDS_PER_HOUR; + let minute_in_seconds = time.minute() * SECONDS_PER_MINUTE; + let seconds = day_in_seconds + hour_in_seconds + minute_in_seconds + time.second(); + let attoseconds = time.subsecond_as_attoseconds(); + let raw = RawTime { + seconds, + attoseconds, + }; + Self::from_raw(scale, raw) + } + + /// Instantiates a `Time` of the given scale from a UTC datetime. + pub fn from_utc_datetime(scale: TimeScale, dt: UTCDateTime) -> Self { + Self::from_date_and_utc_timestamp(scale, dt.date(), dt.time()) + } + + pub fn scale(&self) -> TimeScale { + match &self { + Time::TAI(_) => TimeScale::TAI, + Time::TCB(_) => TimeScale::TCB, + Time::TCG(_) => TimeScale::TCG, + Time::TDB(_) => TimeScale::TDB, + Time::TT(_) => TimeScale::TT, + Time::UT1(_) => TimeScale::UT1, + } + } + + /// Returns the J2000 epoch in the given timescale. + pub fn j2000(scale: TimeScale) -> Self { + Self::from_raw(scale, RawTime::default()) + } + + /// Returns, as an epoch in the given timescale, midday on the first day of the proleptic Julian + /// calendar. + pub fn jd0(scale: TimeScale) -> Self { + // This represents 4713 BC, since there is no year 0 separating BC and AD. + let first_proleptic_day = Date::new_unchecked(ProlepticJulian, -4712, 1, 1); + let midday = UTC::new(12, 0, 0).expect("midday should be a valid time"); + Self::from_date_and_utc_timestamp(scale, first_proleptic_day, midday) + } + + fn from_raw(scale: TimeScale, raw: RawTime) -> Self { + match scale { + TimeScale::TAI => Time::TAI(TAI(raw)), + TimeScale::TCB => Time::TCB(TCB(raw)), + TimeScale::TCG => Time::TCG(TCG(raw)), + TimeScale::TDB => Time::TDB(TDB(raw)), + TimeScale::TT => Time::TT(TT(raw)), + TimeScale::UT1 => Time::UT1(UT1(raw)), + } + } + + fn raw(&self) -> RawTime { + match self { + Time::TAI(tai) => tai.0, + Time::TCB(tcb) => tcb.0, + Time::TCG(tcg) => tcg.0, + Time::TDB(tdb) => tdb.0, + Time::TT(tt) => tt.0, + Time::UT1(ut1) => ut1.0, + } + } + + /// The number of whole seconds since J2000. + pub fn seconds(&self) -> i64 { + self.raw().seconds + } + + /// The number of attoseconds from the last whole second. + pub fn attoseconds(&self) -> u64 { + self.raw().attoseconds + } + + /// The fractional number of Julian days since J2000. + pub fn days_since_j2000(&self) -> f64 { + let d1 = self.seconds().to_f64().unwrap_or_default() / constants::f64::SECONDS_PER_DAY; + let d2 = self.attoseconds().to_f64().unwrap() / constants::f64::ATTOSECONDS_PER_DAY; + d2 + d1 + } +} + +impl Display for Time { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!( + f, + "{:02}:{:02}:{:02}.{:03}.{:03}.{:03}.{:03}.{:03}.{:03} {}", + self.hour(), + self.minute(), + self.second(), + self.millisecond(), + self.microsecond(), + self.nanosecond(), + self.picosecond(), + self.femtosecond(), + self.attosecond(), + self.scale(), + ) + } +} + +impl WallClock for Time { + fn hour(&self) -> i64 { + match self { + Time::TAI(t) => t.hour(), + Time::TCB(t) => t.hour(), + Time::TCG(t) => t.hour(), + Time::TDB(t) => t.hour(), + Time::TT(t) => t.hour(), + Time::UT1(t) => t.hour(), + } + } + + fn minute(&self) -> i64 { + match self { + Time::TAI(t) => t.minute(), + Time::TCB(t) => t.minute(), + Time::TCG(t) => t.minute(), + Time::TDB(t) => t.minute(), + Time::TT(t) => t.minute(), + Time::UT1(t) => t.minute(), + } + } + + fn second(&self) -> i64 { + match self { + Time::TAI(t) => t.second(), + Time::TCB(t) => t.second(), + Time::TCG(t) => t.second(), + Time::TDB(t) => t.second(), + Time::TT(t) => t.second(), + Time::UT1(t) => t.second(), + } + } + + fn millisecond(&self) -> i64 { + match self { + Time::TAI(t) => t.millisecond(), + Time::TCB(t) => t.millisecond(), + Time::TCG(t) => t.millisecond(), + Time::TDB(t) => t.millisecond(), + Time::TT(t) => t.millisecond(), + Time::UT1(t) => t.millisecond(), + } + } + + fn microsecond(&self) -> i64 { + match self { + Time::TAI(t) => t.microsecond(), + Time::TCB(t) => t.microsecond(), + Time::TCG(t) => t.microsecond(), + Time::TDB(t) => t.microsecond(), + Time::TT(t) => t.microsecond(), + Time::UT1(t) => t.microsecond(), + } + } + + fn nanosecond(&self) -> i64 { + match self { + Time::TAI(t) => t.nanosecond(), + Time::TCB(t) => t.nanosecond(), + Time::TCG(t) => t.nanosecond(), + Time::TDB(t) => t.nanosecond(), + Time::TT(t) => t.nanosecond(), + Time::UT1(t) => t.nanosecond(), + } + } + + fn picosecond(&self) -> i64 { + match self { + Time::TAI(t) => t.picosecond(), + Time::TCB(t) => t.picosecond(), + Time::TCG(t) => t.picosecond(), + Time::TDB(t) => t.picosecond(), + Time::TT(t) => t.picosecond(), + Time::UT1(t) => t.picosecond(), + } + } + + fn femtosecond(&self) -> i64 { + match self { + Time::TAI(t) => t.femtosecond(), + Time::TCB(t) => t.femtosecond(), + Time::TCG(t) => t.femtosecond(), + Time::TDB(t) => t.femtosecond(), + Time::TT(t) => t.femtosecond(), + Time::UT1(t) => t.femtosecond(), + } + } + + fn attosecond(&self) -> i64 { + match self { + Time::TAI(t) => t.attosecond(), + Time::TCB(t) => t.attosecond(), + Time::TCG(t) => t.attosecond(), + Time::TDB(t) => t.attosecond(), + Time::TT(t) => t.attosecond(), + Time::UT1(t) => t.attosecond(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::time::dates::Calendar::Gregorian; + + const TIME_SCALES: [TimeScale; 6] = [ + TimeScale::TAI, + TimeScale::TCB, + TimeScale::TCG, + TimeScale::TDB, + TimeScale::TT, + TimeScale::UT1, + ]; + + #[test] + fn test_raw_time_is_negative() { + assert!(RawTime { + seconds: -1, + attoseconds: 0 + } + .is_negative()); + assert!(!RawTime { + seconds: 0, + attoseconds: 0 + } + .is_negative()); + assert!(!RawTime { + seconds: 1, + attoseconds: 0 + } + .is_negative()); + } + + #[test] + fn test_raw_time_hour() { + struct TestCase { + desc: &'static str, + time: RawTime, + expected_hour: i64, + } + + let test_cases = [ + TestCase { + desc: "zero value", + time: RawTime { + seconds: 0, + attoseconds: 0, + }, + expected_hour: 12, + }, + TestCase { + desc: "one attosecond less than an hour", + time: RawTime { + seconds: SECONDS_PER_HOUR - 1, + attoseconds: ATTOSECONDS_PER_SECOND - 1, + }, + expected_hour: 12, + }, + TestCase { + desc: "exactly one hour", + time: RawTime { + seconds: SECONDS_PER_HOUR, + attoseconds: 0, + }, + expected_hour: 13, + }, + TestCase { + desc: "one day and one hour", + time: RawTime { + seconds: SECONDS_PER_HOUR * 25, + attoseconds: 0, + }, + expected_hour: 13, + }, + TestCase { + desc: "one attosecond less than the epoch", + time: RawTime { + seconds: -1, + attoseconds: ATTOSECONDS_PER_SECOND - 1, + }, + expected_hour: 11, + }, + TestCase { + desc: "one hour less than the epoch", + time: RawTime { + seconds: -SECONDS_PER_HOUR, + attoseconds: 0, + }, + expected_hour: 11, + }, + TestCase { + desc: "one hour and one attosecond less than the epoch", + time: RawTime { + seconds: -SECONDS_PER_HOUR - 1, + attoseconds: ATTOSECONDS_PER_SECOND - 1, + }, + expected_hour: 10, + }, + TestCase { + desc: "one day less than the epoch", + time: RawTime { + seconds: -SECONDS_PER_DAY, + attoseconds: 0, + }, + expected_hour: 12, + }, + TestCase { + // Exercises the case where the number of seconds exceeds the number of seconds in a day. + desc: "two days less than the epoch", + time: RawTime { + seconds: -SECONDS_PER_DAY * 2, + attoseconds: 0, + }, + expected_hour: 12, + }, + ]; + + for tc in test_cases { + let actual = tc.time.hour(); + assert_eq!( + actual, tc.expected_hour, + "{}: expected {}, got {}", + tc.desc, tc.expected_hour, actual + ); + } + } + + #[test] + fn test_raw_time_minute() { + struct TestCase { + desc: &'static str, + time: RawTime, + expected_minute: i64, + } + + let test_cases = [ + TestCase { + desc: "zero value", + time: RawTime { + seconds: 0, + attoseconds: 0, + }, + expected_minute: 0, + }, + TestCase { + desc: "one attosecond less than one minute", + time: RawTime { + seconds: SECONDS_PER_MINUTE - 1, + attoseconds: ATTOSECONDS_PER_SECOND - 1, + }, + expected_minute: 0, + }, + TestCase { + desc: "one minute", + time: RawTime { + seconds: SECONDS_PER_MINUTE, + attoseconds: 0, + }, + expected_minute: 1, + }, + TestCase { + desc: "one attosecond less than an hour", + time: RawTime { + seconds: SECONDS_PER_HOUR - 1, + attoseconds: ATTOSECONDS_PER_SECOND - 1, + }, + expected_minute: 59, + }, + TestCase { + desc: "exactly one hour", + time: RawTime { + seconds: SECONDS_PER_HOUR, + attoseconds: 0, + }, + expected_minute: 0, + }, + TestCase { + desc: "one hour and one minute", + time: RawTime { + seconds: SECONDS_PER_HOUR + SECONDS_PER_MINUTE, + attoseconds: 0, + }, + expected_minute: 1, + }, + TestCase { + desc: "one attosecond less than the epoch", + time: RawTime { + seconds: -1, + attoseconds: ATTOSECONDS_PER_SECOND - 1, + }, + expected_minute: 59, + }, + TestCase { + desc: "one minute less than the epoch", + time: RawTime { + seconds: -SECONDS_PER_MINUTE, + attoseconds: 0, + }, + expected_minute: 59, + }, + TestCase { + desc: "one minute and one attosecond less than the epoch", + time: RawTime { + seconds: -SECONDS_PER_MINUTE - 1, + attoseconds: ATTOSECONDS_PER_SECOND - 1, + }, + expected_minute: 58, + }, + ]; + + for tc in test_cases { + let actual = tc.time.minute(); + assert_eq!( + actual, tc.expected_minute, + "{}: expected {}, got {}", + tc.desc, tc.expected_minute, actual + ); + } + } + + #[test] + fn test_raw_time_second() { + struct TestCase { + desc: &'static str, + time: RawTime, + expected_second: i64, + } + + let test_cases = [ + TestCase { + desc: "zero value", + time: RawTime { + seconds: 0, + attoseconds: 0, + }, + expected_second: 0, + }, + TestCase { + desc: "one attosecond less than one second", + time: RawTime { + seconds: 0, + attoseconds: ATTOSECONDS_PER_SECOND - 1, + }, + expected_second: 0, + }, + TestCase { + desc: "one second", + time: RawTime { + seconds: 1, + attoseconds: 0, + }, + expected_second: 1, + }, + TestCase { + desc: "one attosecond less than a minute", + time: RawTime { + seconds: SECONDS_PER_MINUTE - 1, + attoseconds: ATTOSECONDS_PER_SECOND - 1, + }, + expected_second: 59, + }, + TestCase { + desc: "exactly one minute", + time: RawTime { + seconds: SECONDS_PER_MINUTE, + attoseconds: 0, + }, + expected_second: 0, + }, + TestCase { + desc: "one minute and one second", + time: RawTime { + seconds: SECONDS_PER_MINUTE + 1, + attoseconds: 0, + }, + expected_second: 1, + }, + TestCase { + desc: "one attosecond less than the epoch", + time: RawTime { + seconds: -1, + attoseconds: ATTOSECONDS_PER_SECOND - 1, + }, + expected_second: 59, + }, + TestCase { + desc: "one second less than the epoch", + time: RawTime { + seconds: -1, + attoseconds: 0, + }, + expected_second: 59, + }, + TestCase { + desc: "one second and one attosecond less than the epoch", + time: RawTime { + seconds: -2, + attoseconds: ATTOSECONDS_PER_SECOND - 1, + }, + expected_second: 58, + }, + ]; + + for tc in test_cases { + let actual = tc.time.second(); + assert_eq!( + actual, tc.expected_second, + "{}: expected {}, got {}", + tc.desc, tc.expected_second, actual + ); + } + } + + #[test] + fn test_raw_time_subseconds_with_positive_seconds() { + let time = RawTime { + seconds: 0, + attoseconds: 123_456_789_012_345_678, + }; + + struct TestCase { + unit: &'static str, + expected: i64, + actual: i64, + } + + let test_cases = [ + TestCase { + unit: "millisecond", + expected: 123, + actual: time.millisecond(), + }, + TestCase { + unit: "microsecond", + expected: 456, + actual: time.microsecond(), + }, + TestCase { + unit: "nanosecond", + expected: 789, + actual: time.nanosecond(), + }, + TestCase { + unit: "picosecond", + expected: 12, + actual: time.picosecond(), + }, + TestCase { + unit: "femtosecond", + expected: 345, + actual: time.femtosecond(), + }, + TestCase { + unit: "attosecond", + expected: 678, + actual: time.attosecond(), + }, + ]; + + for tc in test_cases { + assert_eq!( + tc.actual, tc.expected, + "expected {} {}, got {}", + tc.unit, tc.expected, tc.actual + ); + } + } + + #[test] + fn test_raw_time_subseconds_with_negative_seconds() { + let time = RawTime { + seconds: -1, + attoseconds: 123_456_789_012_345_678, + }; + + struct TestCase { + unit: &'static str, + expected: i64, + actual: i64, + } + + let test_cases = [ + TestCase { + unit: "millisecond", + expected: 123, + actual: time.millisecond(), + }, + TestCase { + unit: "microsecond", + expected: 456, + actual: time.microsecond(), + }, + TestCase { + unit: "nanosecond", + expected: 789, + actual: time.nanosecond(), + }, + TestCase { + unit: "picosecond", + expected: 12, + actual: time.picosecond(), + }, + TestCase { + unit: "femtosecond", + expected: 345, + actual: time.femtosecond(), + }, + TestCase { + unit: "attosecond", + expected: 678, + actual: time.attosecond(), + }, + ]; + + for tc in test_cases { + assert_eq!( + tc.actual, tc.expected, + "expected {} {}, got {}", + tc.unit, tc.expected, tc.actual + ); + } + } + + #[test] + fn test_raw_time_add_time_delta() { + struct TestCase { + desc: &'static str, + delta: TimeDelta, + time: RawTime, + expected: RawTime, + } + + let test_cases = [ + TestCase { + desc: "positive time with no attosecond wrap", + delta: TimeDelta { + seconds: 1, + attoseconds: 1, + }, + time: RawTime { + seconds: 1, + attoseconds: 0, + }, + expected: RawTime { + seconds: 2, + attoseconds: 1, + }, + }, + TestCase { + desc: "positive time with attosecond wrap", + delta: TimeDelta { + seconds: 1, + attoseconds: 2, + }, + time: RawTime { + seconds: 1, + attoseconds: ATTOSECONDS_PER_SECOND - 1, + }, + expected: RawTime { + seconds: 3, + attoseconds: 1, + }, + }, + TestCase { + desc: "negative time with no attosecond wrap", + delta: TimeDelta { + seconds: 1, + attoseconds: 1, + }, + time: RawTime { + seconds: -1, + attoseconds: 0, + }, + expected: RawTime { + seconds: 0, + attoseconds: 1, + }, + }, + TestCase { + desc: "negative time with attosecond wrap", + delta: TimeDelta { + seconds: 1, + attoseconds: 2, + }, + time: RawTime { + seconds: -1, + attoseconds: ATTOSECONDS_PER_SECOND - 1, + }, + expected: RawTime { + seconds: 1, + attoseconds: 1, + }, + }, + ]; + + for tc in test_cases { + let actual = tc.time + tc.delta; + assert_eq!( + actual, tc.expected, + "{}: expected {:?}, got {:?}", + tc.desc, tc.expected, actual + ); + } + } + + #[test] + fn test_raw_time_sub_time_delta() { + struct TestCase { + desc: &'static str, + delta: TimeDelta, + time: RawTime, + expected: RawTime, + } + + let test_cases = [ + TestCase { + desc: "positive time with no attosecond wrap", + delta: TimeDelta { + seconds: 1, + attoseconds: 1, + }, + time: RawTime { + seconds: 2, + attoseconds: 2, + }, + expected: RawTime { + seconds: 1, + attoseconds: 1, + }, + }, + TestCase { + desc: "positive time with attosecond wrap", + delta: TimeDelta { + seconds: 1, + attoseconds: 2, + }, + time: RawTime { + seconds: 2, + attoseconds: 1, + }, + expected: RawTime { + seconds: 0, + attoseconds: ATTOSECONDS_PER_SECOND - 1, + }, + }, + TestCase { + desc: "negative time with no attosecond wrap", + delta: TimeDelta { + seconds: 1, + attoseconds: 1, + }, + time: RawTime { + seconds: -1, + attoseconds: 2, + }, + expected: RawTime { + seconds: -2, + attoseconds: 1, + }, + }, + TestCase { + desc: "negative time with attosecond wrap", + delta: TimeDelta { + seconds: 1, + attoseconds: 2, + }, + time: RawTime { + seconds: -1, + attoseconds: 1, + }, + expected: RawTime { + seconds: -3, + attoseconds: ATTOSECONDS_PER_SECOND - 1, + }, + }, + TestCase { + desc: "transition from positive to negative time", + delta: TimeDelta { + seconds: 1, + attoseconds: 2, + }, + time: RawTime { + seconds: 0, + attoseconds: 1, + }, + expected: RawTime { + seconds: -2, + attoseconds: ATTOSECONDS_PER_SECOND - 1, + }, + }, + ]; + + for tc in test_cases { + let actual = tc.time - tc.delta; + assert_eq!( + actual, tc.expected, + "{}: expected {:?}, got {:?}", + tc.desc, tc.expected, actual + ); + } + } + + #[test] + fn test_timescale_into_str() { + let test_cases = [ + (TimeScale::TAI, "TAI"), + (TimeScale::TCB, "TCB"), + (TimeScale::TCG, "TCG"), + (TimeScale::TDB, "TDB"), + (TimeScale::TT, "TT"), + (TimeScale::UT1, "UT1"), + ]; + + for (scale, expected) in test_cases { + assert_eq!(Into::<&str>::into(scale), expected); + } + } + + #[test] + fn test_time_from_date_and_utc_timestamp() { + let date = Date::new_unchecked(Gregorian, 2021, 1, 1); + let utc = UTC::new(12, 34, 56).expect("time should be valid"); + let datetime = UTCDateTime::new(date, utc); + + for scale in TIME_SCALES { + let actual = Time::from_date_and_utc_timestamp(scale, date, utc); + let expected = Time::from_utc_datetime(scale, datetime); + assert_eq!(actual, expected); + } + } + + #[test] + fn test_time_display() { + let time = Time::TAI(TAI::default()); + let expected = "12:00:00.000.000.000.000.000.000 TAI".to_string(); + let actual = time.to_string(); + assert_eq!(actual, expected); + } + + #[test] + fn test_time_j2000() { + [ + (TimeScale::TAI, Time::TAI(TAI::default())), + (TimeScale::TCB, Time::TCB(TCB::default())), + (TimeScale::TCG, Time::TCG(TCG::default())), + (TimeScale::TDB, Time::TDB(TDB::default())), + (TimeScale::TT, Time::TT(TT::default())), + (TimeScale::UT1, Time::UT1(UT1::default())), + ] + .iter() + .for_each(|(scale, expected)| { + let actual = Time::j2000(*scale); + assert_eq!(*expected, actual); + }); + } + + #[test] + fn test_time_jd0() { + [ + ( + TimeScale::TAI, + Time::TAI(TAI(RawTime { + seconds: -211813488000, + attoseconds: 0, + })), + ), + ( + TimeScale::TCB, + Time::TCB(TCB(RawTime { + seconds: -211813488000, + attoseconds: 0, + })), + ), + ( + TimeScale::TCG, + Time::TCG(TCG(RawTime { + seconds: -211813488000, + attoseconds: 0, + })), + ), + ( + TimeScale::TDB, + Time::TDB(TDB(RawTime { + seconds: -211813488000, + attoseconds: 0, + })), + ), + ( + TimeScale::TT, + Time::TT(TT(RawTime { + seconds: -211813488000, + attoseconds: 0, + })), + ), + ( + TimeScale::UT1, + Time::UT1(UT1(RawTime { + seconds: -211813488000, + attoseconds: 0, + })), + ), + ] + .iter() + .for_each(|(scale, expected)| { + let actual = Time::jd0(*scale); + assert_eq!(*expected, actual); + }); + } + + #[test] + fn test_time_scale() { + let test_cases = [ + (Time::TAI(TAI::default()), TimeScale::TAI), + (Time::TCB(TCB::default()), TimeScale::TCB), + (Time::TCG(TCG::default()), TimeScale::TCG), + (Time::TDB(TDB::default()), TimeScale::TDB), + (Time::TT(TT::default()), TimeScale::TT), + (Time::UT1(UT1::default()), TimeScale::UT1), + ]; + + for (time, expected) in test_cases { + assert_eq!(time.scale(), expected); + } + } + + #[test] + fn test_time_wall_clock_hour() { + let raw_time = RawTime::default(); + let expected = raw_time.hour(); + for scale in TIME_SCALES { + let time = Time::from_raw(scale, raw_time); + let actual = time.hour(); + assert_eq!( + actual, expected, + "expected time in scale {} to have hour {}, but got {}", + scale, expected, actual + ); + } + } + + #[test] + fn test_time_wall_clock_minute() { + let raw_time = RawTime::default(); + let expected = raw_time.minute(); + for scale in TIME_SCALES { + let time = Time::from_raw(scale, raw_time); + let actual = time.minute(); + assert_eq!( + actual, expected, + "expected time in scale {} to have minute {}, but got {}", + scale, expected, actual + ); + } + } + + #[test] + fn test_time_wall_clock_second() { + let raw_time = RawTime::default(); + let expected = raw_time.second(); + for scale in TIME_SCALES { + let time = Time::from_raw(scale, raw_time); + let actual = time.second(); + assert_eq!( + actual, expected, + "expected time in scale {} to have second {}, but got {}", + scale, expected, actual + ); + } + } + + #[test] + fn test_time_wall_clock_millisecond() { + let raw_time = RawTime::default(); + let expected = raw_time.millisecond(); + for scale in TIME_SCALES { + let time = Time::from_raw(scale, raw_time); + let actual = time.millisecond(); + assert_eq!( + actual, expected, + "expected time in scale {} to have millisecond {}, but got {}", + scale, expected, actual + ); + } + } + + #[test] + fn test_time_wall_clock_microsecond() { + let raw_time = RawTime::default(); + let expected = raw_time.microsecond(); + for scale in TIME_SCALES { + let time = Time::from_raw(scale, raw_time); + let actual = time.microsecond(); + assert_eq!( + actual, expected, + "expected time in scale {} to have microsecond {}, but got {}", + scale, expected, actual + ); + } + } + + #[test] + fn test_time_wall_clock_nanosecond() { + let raw_time = RawTime::default(); + let expected = raw_time.nanosecond(); + for scale in TIME_SCALES { + let time = Time::from_raw(scale, raw_time); + let actual = time.nanosecond(); + assert_eq!( + actual, expected, + "expected time in scale {} to have nanosecond {}, but got {}", + scale, expected, actual + ); + } + } + + #[test] + fn test_time_wall_clock_picosecond() { + let raw_time = RawTime::default(); + let expected = raw_time.picosecond(); + for scale in TIME_SCALES { + let time = Time::from_raw(scale, raw_time); + let actual = time.picosecond(); + assert_eq!( + actual, expected, + "expected time in scale {} to have picosecond {}, but got {}", + scale, expected, actual + ); + } + } + + #[test] + fn test_time_wall_clock_femtosecond() { + let raw_time = RawTime::default(); + let expected = raw_time.femtosecond(); + for scale in TIME_SCALES { + let time = Time::from_raw(scale, raw_time); + let actual = time.femtosecond(); + assert_eq!( + actual, expected, + "expected time in scale {} to have femtosecond {}, but got {}", + scale, expected, actual + ); + } + } + + #[test] + fn test_time_wall_clock_attosecond() { + let raw_time = RawTime::default(); + let expected = raw_time.attosecond(); + for scale in TIME_SCALES { + let time = Time::from_raw(scale, raw_time); + let actual = time.attosecond(); + assert_eq!( + actual, expected, + "expected time in scale {} to have attosecond {}, but got {}", + scale, expected, actual + ); + } + } +} diff --git a/crates/lox_core/src/time/dates.rs b/crates/lox_core/src/time/dates.rs index 9f19f79a..7562c70c 100644 --- a/crates/lox_core/src/time/dates.rs +++ b/crates/lox_core/src/time/dates.rs @@ -7,7 +7,6 @@ */ use crate::errors::LoxError; -use num::ToPrimitive; #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum Calendar { @@ -16,7 +15,7 @@ pub enum Calendar { Gregorian, } -#[derive(Debug, Copy, Clone)] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] pub struct Date { calendar: Calendar, year: i64, @@ -105,125 +104,6 @@ impl Date { } } -#[derive(Debug, Copy, Clone, Default)] -pub struct Time { - hour: i64, - minute: i64, - second: i64, - milli: i64, - micro: i64, - nano: i64, - pico: i64, - femto: i64, - atto: i64, -} - -impl Time { - pub fn new(hour: i64, minute: i64, second: i64) -> Result { - if !(0..24).contains(&hour) || !(0..60).contains(&minute) || !(0..61).contains(&second) { - Err(LoxError::InvalidTime(hour, minute, second)) - } else { - Ok(Self { - hour, - minute, - second, - ..Default::default() - }) - } - } - - pub fn milli(mut self, milli: i64) -> Self { - self.milli = milli; - self - } - - pub fn micro(mut self, micro: i64) -> Self { - self.micro = micro; - self - } - - pub fn nano(mut self, nano: i64) -> Self { - self.nano = nano; - self - } - - pub fn pico(mut self, pico: i64) -> Self { - self.pico = pico; - self - } - - pub fn femto(mut self, femto: i64) -> Self { - self.femto = femto; - self - } - - pub fn atto(mut self, atto: i64) -> Self { - self.atto = atto; - self - } - - pub fn from_seconds(hour: i64, minute: i64, seconds: f64) -> Result { - if !(0.0..61.0).contains(&seconds) { - return Err(LoxError::InvalidSeconds(hour, minute, seconds)); - } - let sub = split_seconds(seconds.fract()).unwrap(); - let second = seconds.round().to_i64().unwrap(); - Self::new(hour, minute, second)?; - Ok(Self { - hour, - minute, - second, - milli: sub[0], - micro: sub[1], - nano: sub[2], - pico: sub[3], - femto: sub[4], - atto: sub[5], - }) - } - - pub fn hour(&self) -> i64 { - self.hour - } - - pub fn minute(&self) -> i64 { - self.minute - } - - pub fn second(&self) -> i64 { - self.second - } - - pub fn attosecond(&self) -> i64 { - self.milli * i64::pow(10, 15) - + self.micro * i64::pow(10, 12) - + self.nano * i64::pow(10, 9) - + self.pico * i64::pow(10, 6) - + self.femto * i64::pow(10, 3) - + self.atto - } -} - -#[derive(Debug, Copy, Clone)] -pub struct DateTime { - date: Date, - time: Time, -} - -impl DateTime { - pub fn new(date: Date, time: Time) -> Self { - Self { date, time } - } - - pub fn date(&self) -> Date { - self.date - } - - pub fn time(&self) -> Time { - self.time - } -} - fn find_year(calendar: Calendar, j2000day: i64) -> i64 { match calendar { Calendar::ProlepticJulian => -((-4 * j2000day - 2920488) / 1461), @@ -310,91 +190,9 @@ fn j2000(calendar: Calendar, year: i64, month: i64, day: i64) -> i64 { d1 + d2 } -fn split_seconds(seconds: f64) -> Option<[i64; 6]> { - if !(0.0..1.0).contains(&seconds) { - return None; - } - let mut atto = (seconds * 1e18).to_i64()?; - let mut parts: [i64; 6] = [0; 6]; - for (i, exponent) in (3..18).step_by(3).rev().enumerate() { - let factor = i64::pow(10, exponent); - parts[i] = atto / factor; - atto -= parts[i] * factor; - } - parts[5] = atto / 10 * 10; - Some(parts) -} - #[cfg(test)] mod tests { - use super::*; - use proptest::prelude::*; - - proptest! { - #[test] - fn prop_test_split_seconds(s in 0.0..1.0) { - prop_assert!(split_seconds(s).is_some()) - } - } - - #[test] - fn test_sub_second() { - let s1 = split_seconds(0.123).expect("seconds should be valid"); - assert_eq!(123, s1[0]); - assert_eq!(0, s1[1]); - assert_eq!(0, s1[2]); - assert_eq!(0, s1[3]); - assert_eq!(0, s1[4]); - assert_eq!(0, s1[5]); - let s2 = split_seconds(0.123_456).expect("seconds should be valid"); - assert_eq!(123, s2[0]); - assert_eq!(456, s2[1]); - assert_eq!(0, s2[2]); - assert_eq!(0, s2[3]); - assert_eq!(0, s2[4]); - assert_eq!(0, s2[5]); - let s3 = split_seconds(0.123_456_789).expect("seconds should be valid"); - assert_eq!(123, s3[0]); - assert_eq!(456, s3[1]); - assert_eq!(789, s3[2]); - assert_eq!(0, s3[3]); - assert_eq!(0, s3[4]); - assert_eq!(0, s3[5]); - let s4 = split_seconds(0.123_456_789_123).expect("seconds should be valid"); - assert_eq!(123, s4[0]); - assert_eq!(456, s4[1]); - assert_eq!(789, s4[2]); - assert_eq!(123, s4[3]); - assert_eq!(0, s4[4]); - assert_eq!(0, s4[5]); - let s5 = split_seconds(0.123_456_789_123_456).expect("seconds should be valid"); - assert_eq!(123, s5[0]); - assert_eq!(456, s5[1]); - assert_eq!(789, s5[2]); - assert_eq!(123, s5[3]); - assert_eq!(456, s5[4]); - assert_eq!(0, s5[5]); - let s6 = split_seconds(0.123_456_789_123_456_78).expect("seconds should be valid"); - assert_eq!(123, s6[0]); - assert_eq!(456, s6[1]); - assert_eq!(789, s6[2]); - assert_eq!(123, s6[3]); - assert_eq!(456, s6[4]); - assert_eq!(780, s6[5]); - let s7 = split_seconds(0.000_000_000_000_000_01).expect("seconds should be valid"); - assert_eq!(0, s7[0]); - assert_eq!(0, s7[1]); - assert_eq!(0, s7[2]); - assert_eq!(0, s7[3]); - assert_eq!(0, s7[4]); - assert_eq!(10, s7[5]); - } - - #[test] - fn test_illegal_split_second() { - assert!(split_seconds(2.0).is_none()); - assert!(split_seconds(-0.2).is_none()); - } + use crate::time::dates::{Calendar, Date}; #[test] fn test_date_new_unchecked() { diff --git a/crates/lox_core/src/time/epochs.rs b/crates/lox_core/src/time/epochs.rs deleted file mode 100644 index 0ec338a8..00000000 --- a/crates/lox_core/src/time/epochs.rs +++ /dev/null @@ -1,250 +0,0 @@ -/* - * Copyright (c) 2023. Helge Eichhorn and the LOX contributors - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - */ - -use num::ToPrimitive; -use std::fmt; -use std::fmt::Formatter; - -use crate::time::constants; -use crate::time::constants::i64::{SECONDS_PER_DAY, SECONDS_PER_HOUR, SECONDS_PER_MINUTE}; - -use crate::time::dates::Calendar::ProlepticJulian; -use crate::time::dates::{Date, DateTime, Time}; - -#[derive(Debug, Copy, Clone, Eq, PartialEq)] -pub enum TimeScale { - TAI, - TCB, - TCG, - TDB, - TT, - UT1, -} - -impl fmt::Display for TimeScale { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - match self { - TimeScale::TAI => write!(f, "TAI"), - TimeScale::TCB => write!(f, "TCB"), - TimeScale::TCG => write!(f, "TCG"), - TimeScale::TDB => write!(f, "TDB"), - TimeScale::TT => write!(f, "TT"), - TimeScale::UT1 => write!(f, "UT1"), - } - } -} - -#[derive(Debug, Default, Copy, Clone, Eq, PartialEq)] -pub struct RawEpoch { - second: i64, - attosecond: i64, -} - -#[derive(Debug, Copy, Clone, Eq, PartialEq)] -pub enum Epoch { - TAI(RawEpoch), - TCB(RawEpoch), - TCG(RawEpoch), - TDB(RawEpoch), - TT(RawEpoch), - UT1(RawEpoch), -} - -impl Epoch { - pub fn from_date_and_time(scale: TimeScale, date: Date, time: Time) -> Self { - let day_in_seconds = date.j2000() * SECONDS_PER_DAY - SECONDS_PER_DAY / 2; - let hour_in_seconds = time.hour() * SECONDS_PER_HOUR; - let minute_in_seconds = time.minute() * SECONDS_PER_MINUTE; - let second = day_in_seconds + hour_in_seconds + minute_in_seconds + time.second(); - let attosecond = time.attosecond(); - let raw = RawEpoch { second, attosecond }; - Self::from_raw(scale, raw) - } - - pub fn from_datetime(scale: TimeScale, dt: DateTime) -> Self { - Self::from_date_and_time(scale, dt.date(), dt.time()) - } - - /// Returns the J2000 epoch in the given timescale. - pub fn j2000(scale: TimeScale) -> Self { - Self::from_raw(scale, RawEpoch::default()) - } - - /// Returns, as an epoch in the given timescale, midday on the first day of the proleptic Julian - /// calendar. - pub fn jd0(scale: TimeScale) -> Self { - // This represents 4713 BC, since there is no year 0 separating BC and AD. - let first_proleptic_day = Date::new_unchecked(ProlepticJulian, -4712, 1, 1); - let midday = Time::new(12, 0, 0).expect("midday should be a valid time"); - Self::from_date_and_time(scale, first_proleptic_day, midday) - } - - fn from_raw(scale: TimeScale, raw: RawEpoch) -> Self { - match scale { - TimeScale::TAI => Epoch::TAI(raw), - TimeScale::TCB => Epoch::TCB(raw), - TimeScale::TCG => Epoch::TCG(raw), - TimeScale::TDB => Epoch::TDB(raw), - TimeScale::TT => Epoch::TT(raw), - TimeScale::UT1 => Epoch::UT1(raw), - } - } - - fn raw(&self) -> &RawEpoch { - match self { - Epoch::TAI(raw) - | Epoch::TCB(raw) - | Epoch::TCG(raw) - | Epoch::TDB(raw) - | Epoch::TT(raw) - | Epoch::UT1(raw) => raw, - } - } - - pub fn second(&self) -> i64 { - self.raw().second - } - - pub fn attosecond(&self) -> i64 { - self.raw().attosecond - } - - pub fn days_since_j2000(&self) -> f64 { - let d1 = self.second().to_f64().unwrap_or_default() / constants::f64::SECONDS_PER_DAY; - let d2 = self.attosecond().to_f64().unwrap() / constants::f64::ATTOSECONDS_PER_DAY; - d2 + d1 - } - - pub fn scale(&self) -> &'static str { - match self { - Epoch::TAI(_) => "TAI", - Epoch::TCB(_) => "TCB", - Epoch::TCG(_) => "TCG", - Epoch::TDB(_) => "TDB", - Epoch::TT(_) => "TT", - Epoch::UT1(_) => "UT1", - } - } -} - -impl fmt::Display for Epoch { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "foo") - } -} - -#[cfg(test)] -mod tests { - use crate::time::epochs::{Epoch, RawEpoch, TimeScale}; - - #[test] - fn test_epoch_j2000() { - [ - ( - TimeScale::TAI, - Epoch::TAI(RawEpoch { - second: 0, - attosecond: 0, - }), - ), - ( - TimeScale::TCB, - Epoch::TCB(RawEpoch { - second: 0, - attosecond: 0, - }), - ), - ( - TimeScale::TCG, - Epoch::TCG(RawEpoch { - second: 0, - attosecond: 0, - }), - ), - ( - TimeScale::TDB, - Epoch::TDB(RawEpoch { - second: 0, - attosecond: 0, - }), - ), - ( - TimeScale::TT, - Epoch::TT(RawEpoch { - second: 0, - attosecond: 0, - }), - ), - ( - TimeScale::UT1, - Epoch::UT1(RawEpoch { - second: 0, - attosecond: 0, - }), - ), - ] - .iter() - .for_each(|(scale, expected)| { - let actual = Epoch::j2000(*scale); - assert_eq!(*expected, actual); - }); - } - - #[test] - fn test_epoch_jd0() { - [ - ( - TimeScale::TAI, - Epoch::TAI(RawEpoch { - second: -211813488000, - attosecond: 0, - }), - ), - ( - TimeScale::TCB, - Epoch::TCB(RawEpoch { - second: -211813488000, - attosecond: 0, - }), - ), - ( - TimeScale::TCG, - Epoch::TCG(RawEpoch { - second: -211813488000, - attosecond: 0, - }), - ), - ( - TimeScale::TDB, - Epoch::TDB(RawEpoch { - second: -211813488000, - attosecond: 0, - }), - ), - ( - TimeScale::TT, - Epoch::TT(RawEpoch { - second: -211813488000, - attosecond: 0, - }), - ), - ( - TimeScale::UT1, - Epoch::UT1(RawEpoch { - second: -211813488000, - attosecond: 0, - }), - ), - ] - .iter() - .for_each(|(scale, expected)| { - let actual = Epoch::jd0(*scale); - assert_eq!(*expected, actual); - }); - } -} diff --git a/crates/lox_core/src/time/intervals.rs b/crates/lox_core/src/time/intervals.rs index d2c0151b..b9800894 100644 --- a/crates/lox_core/src/time/intervals.rs +++ b/crates/lox_core/src/time/intervals.rs @@ -1,13 +1,13 @@ use crate::time::constants; -use crate::time::epochs::Epoch; +use crate::time::continuous::Time; /// Although strictly TDB, TT is sufficient for most applications. pub type TDBJulianCenturiesSinceJ2000 = f64; -pub fn tdb_julian_centuries_since_j2000(epoch: Epoch) -> TDBJulianCenturiesSinceJ2000 { - match epoch { - Epoch::TT(_) | Epoch::TDB(_) => { - epoch.days_since_j2000() / constants::f64::DAYS_PER_JULIAN_CENTURY +pub fn tdb_julian_centuries_since_j2000(time: Time) -> TDBJulianCenturiesSinceJ2000 { + match time { + Time::TT(_) | Time::TDB(_) => { + time.days_since_j2000() / constants::f64::DAYS_PER_JULIAN_CENTURY } _ => todo!("perform the simpler of the conversions to TT or TDB first"), } @@ -18,12 +18,13 @@ pub type TTJulianCenturiesSinceJ2000 = f64; pub type UT1DaysSinceJ2000 = f64; #[cfg(test)] -mod epoch_tests { +mod tests { use float_eq::assert_float_eq; + use crate::time::continuous::{Time, TimeScale}; use crate::time::dates::Calendar::Gregorian; - use crate::time::dates::{Date, Time}; - use crate::time::epochs::{Epoch, TimeScale}; + use crate::time::dates::Date; + use crate::time::utc::UTC; use super::tdb_julian_centuries_since_j2000; @@ -32,24 +33,24 @@ mod epoch_tests { #[test] fn test_tdb_julian_centuries_since_j2000_tt() { - let jd0 = Epoch::jd0(TimeScale::TT); + let jd0 = Time::jd0(TimeScale::TT); assert_float_eq!( -67.11964407939767, tdb_julian_centuries_since_j2000(jd0), rel <= TOLERANCE ); - let j2000 = Epoch::j2000(TimeScale::TT); + let j2000 = Time::j2000(TimeScale::TT); assert_float_eq!( 0.0, tdb_julian_centuries_since_j2000(j2000), rel <= TOLERANCE ); - let j2100 = Epoch::from_date_and_time( + let j2100 = Time::from_date_and_utc_timestamp( TimeScale::TT, Date::new_unchecked(Gregorian, 2100, 1, 1), - Time::new(12, 0, 0).expect("midday should be a valid time"), + UTC::new(12, 0, 0).expect("midday should be a valid time"), ); assert_float_eq!( 1.0, diff --git a/crates/lox_core/src/time/utc.rs b/crates/lox_core/src/time/utc.rs new file mode 100644 index 00000000..a193b911 --- /dev/null +++ b/crates/lox_core/src/time/utc.rs @@ -0,0 +1,332 @@ +use crate::errors::LoxError; +use crate::time::constants::u64::{ + ATTOSECONDS_PER_FEMTOSECOND, ATTOSECONDS_PER_MICROSECOND, ATTOSECONDS_PER_MILLISECOND, + ATTOSECONDS_PER_NANOSECOND, ATTOSECONDS_PER_PICOSECOND, +}; +use crate::time::dates::Date; +use crate::time::{PerMille, WallClock}; +use num::ToPrimitive; +use std::fmt::Display; + +/// A UTC timestamp with additional support for fractional seconds represented with attosecond +/// precision. +/// +/// The `UTC` struct provides the ability to represent leap seconds by setting the `second` +/// component to 60. However, it has no awareness of whether a user-specified leap second is valid. +/// It is intended strictly as an IO time format which must be converted to a continuous time format +/// to be used in calculations. +#[derive(Debug, Copy, Clone, Default, Eq, PartialEq)] +pub struct UTC { + hour: u8, + minute: u8, + second: u8, + pub milli: PerMille, + pub micro: PerMille, + pub nano: PerMille, + pub pico: PerMille, + pub femto: PerMille, + pub atto: PerMille, +} + +impl UTC { + pub fn new(hour: u8, minute: u8, second: u8) -> Result { + if !(0..24).contains(&hour) || !(0..60).contains(&minute) || !(0..61).contains(&second) { + Err(LoxError::InvalidTime(hour, minute, second)) + } else { + Ok(Self { + hour, + minute, + second, + ..Default::default() + }) + } + } + + pub fn from_fractional_seconds(hour: u8, minute: u8, seconds: f64) -> Result { + if !(0.0..61.0).contains(&seconds) { + return Err(LoxError::InvalidSeconds(seconds)); + } + let sub = split_seconds(seconds.fract()).unwrap(); + let second = seconds.round().to_u8().unwrap(); + Self::new(hour, minute, second)?; + Ok(Self { + hour, + minute, + second, + milli: PerMille(sub[0] as u16), + micro: PerMille(sub[1] as u16), + nano: PerMille(sub[2] as u16), + pico: PerMille(sub[3] as u16), + femto: PerMille(sub[4] as u16), + atto: PerMille(sub[5] as u16), + }) + } + + pub fn subsecond_as_attoseconds(&self) -> u64 { + let mut attoseconds = self.atto.0 as u64; + attoseconds += self.femto.0 as u64 * ATTOSECONDS_PER_FEMTOSECOND; + attoseconds += self.pico.0 as u64 * ATTOSECONDS_PER_PICOSECOND; + attoseconds += self.nano.0 as u64 * ATTOSECONDS_PER_NANOSECOND; + attoseconds += self.micro.0 as u64 * ATTOSECONDS_PER_MICROSECOND; + attoseconds += self.milli.0 as u64 * ATTOSECONDS_PER_MILLISECOND; + attoseconds + } +} + +impl Display for UTC { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{:02}:{:02}:{:02}.{}.{}.{}.{}.{}.{} UTC", + self.hour, + self.minute, + self.second, + self.milli, + self.micro, + self.nano, + self.pico, + self.femto, + self.atto + )?; + Ok(()) + } +} + +impl WallClock for UTC { + fn hour(&self) -> i64 { + self.hour as i64 + } + + fn minute(&self) -> i64 { + self.minute as i64 + } + + fn second(&self) -> i64 { + self.second as i64 + } + + fn millisecond(&self) -> i64 { + self.milli.into() + } + + fn microsecond(&self) -> i64 { + self.micro.into() + } + + fn nanosecond(&self) -> i64 { + self.nano.into() + } + + fn picosecond(&self) -> i64 { + self.pico.into() + } + + fn femtosecond(&self) -> i64 { + self.femto.into() + } + + fn attosecond(&self) -> i64 { + self.atto.into() + } +} + +/// Split a floating-point second into SI-prefixed integer parts. +fn split_seconds(seconds: f64) -> Option<[i64; 6]> { + if !(0.0..1.0).contains(&seconds) { + return None; + } + let mut atto = (seconds * 1e18).to_i64()?; + let mut parts: [i64; 6] = [0; 6]; + for (i, exponent) in (3..18).step_by(3).rev().enumerate() { + let factor = i64::pow(10, exponent); + parts[i] = atto / factor; + atto -= parts[i] * factor; + } + parts[5] = atto / 10 * 10; + Some(parts) +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct UTCDateTime { + date: Date, + time: UTC, +} + +impl UTCDateTime { + pub fn new(date: Date, time: UTC) -> Self { + Self { date, time } + } + + pub fn date(&self) -> Date { + self.date + } + + pub fn time(&self) -> UTC { + self.time + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::time::dates::Calendar::Gregorian; + use proptest::{prop_assert, proptest}; + + const TIME: UTC = UTC { + hour: 12, + minute: 34, + second: 56, + milli: PerMille(789), + micro: PerMille(123), + nano: PerMille(456), + pico: PerMille(789), + femto: PerMille(123), + atto: PerMille(456), + }; + + #[test] + fn test_time_display() { + assert_eq!("12:34:56.789.123.456.789.123.456 UTC", TIME.to_string()); + } + + #[test] + fn test_utc_wall_clock_hour() { + assert_eq!(TIME.hour(), TIME.hour as i64); + } + + #[test] + fn test_utc_wall_clock_minute() { + assert_eq!(TIME.minute(), TIME.minute as i64); + } + + #[test] + fn test_utc_wall_clock_second() { + assert_eq!(TIME.second(), TIME.second as i64); + } + + #[test] + fn test_utc_wall_clock_millisecond() { + assert_eq!(TIME.millisecond(), TIME.milli.into()); + } + + #[test] + fn test_utc_wall_clock_microsecond() { + assert_eq!(TIME.microsecond(), TIME.micro.into()); + } + + #[test] + fn test_utc_wall_clock_nanosecond() { + assert_eq!(TIME.nanosecond(), TIME.nano.into()); + } + + #[test] + fn test_utc_wall_clock_picosecond() { + assert_eq!(TIME.picosecond(), TIME.pico.into()); + } + + #[test] + fn test_utc_wall_clock_femtosecond() { + assert_eq!(TIME.femtosecond(), TIME.femto.into()); + } + + #[test] + fn test_utc_wall_clock_attosecond() { + assert_eq!(TIME.attosecond(), TIME.atto.into()); + } + + proptest! { + #[test] + fn prop_test_split_seconds(s in 0.0..1.0) { + prop_assert!(split_seconds(s).is_some()) + } + } + + #[test] + fn test_split_seconds() { + let s1 = split_seconds(0.123).expect("seconds should be valid"); + assert_eq!(123, s1[0]); + assert_eq!(0, s1[1]); + assert_eq!(0, s1[2]); + assert_eq!(0, s1[3]); + assert_eq!(0, s1[4]); + assert_eq!(0, s1[5]); + let s2 = split_seconds(0.123_456).expect("seconds should be valid"); + assert_eq!(123, s2[0]); + assert_eq!(456, s2[1]); + assert_eq!(0, s2[2]); + assert_eq!(0, s2[3]); + assert_eq!(0, s2[4]); + assert_eq!(0, s2[5]); + let s3 = split_seconds(0.123_456_789).expect("seconds should be valid"); + assert_eq!(123, s3[0]); + assert_eq!(456, s3[1]); + assert_eq!(789, s3[2]); + assert_eq!(0, s3[3]); + assert_eq!(0, s3[4]); + assert_eq!(0, s3[5]); + let s4 = split_seconds(0.123_456_789_123).expect("seconds should be valid"); + assert_eq!(123, s4[0]); + assert_eq!(456, s4[1]); + assert_eq!(789, s4[2]); + assert_eq!(123, s4[3]); + assert_eq!(0, s4[4]); + assert_eq!(0, s4[5]); + let s5 = split_seconds(0.123_456_789_123_456).expect("seconds should be valid"); + assert_eq!(123, s5[0]); + assert_eq!(456, s5[1]); + assert_eq!(789, s5[2]); + assert_eq!(123, s5[3]); + assert_eq!(456, s5[4]); + assert_eq!(0, s5[5]); + let s6 = split_seconds(0.123_456_789_123_456_78).expect("seconds should be valid"); + assert_eq!(123, s6[0]); + assert_eq!(456, s6[1]); + assert_eq!(789, s6[2]); + assert_eq!(123, s6[3]); + assert_eq!(456, s6[4]); + assert_eq!(780, s6[5]); + let s7 = split_seconds(0.000_000_000_000_000_01).expect("seconds should be valid"); + assert_eq!(0, s7[0]); + assert_eq!(0, s7[1]); + assert_eq!(0, s7[2]); + assert_eq!(0, s7[3]); + assert_eq!(0, s7[4]); + assert_eq!(10, s7[5]); + } + + #[test] + fn test_illegal_split_seconds() { + assert!(split_seconds(2.0).is_none()); + assert!(split_seconds(-0.2).is_none()); + } + + #[test] + fn test_utc_datetime_new() { + let date = Date::new_unchecked(Gregorian, 2021, 1, 1); + let time = UTC::new(12, 34, 56).expect("time should be valid"); + let expected = UTCDateTime { date, time }; + let actual = UTCDateTime::new(date, time); + assert_eq!(expected, actual); + } + + #[test] + fn test_from_fractional_seconds() { + let hour = 0; + let minute = 0; + let second = 0.123_456_789_123_456_78; + let expected = UTC { + hour: 0, + minute: 0, + second: 0, + milli: PerMille(123), + micro: PerMille(456), + nano: PerMille(789), + pico: PerMille(123), + femto: PerMille(456), + atto: PerMille(780), + }; + let actual = + UTC::from_fractional_seconds(hour, minute, second).expect("time should be valid"); + assert_eq!(expected, actual); + } +} diff --git a/crates/lox_core/tests/dates.rs b/crates/lox_core/tests/dates.rs index d929c221..c1f15fab 100644 --- a/crates/lox_core/tests/dates.rs +++ b/crates/lox_core/tests/dates.rs @@ -6,7 +6,8 @@ * file, you can obtain one at https://mozilla.org/MPL/2.0/. */ -use lox_core::time::dates::{Date, Time}; +use lox_core::time::dates::Date; +use lox_core::time::utc::UTC; use rstest::rstest; #[rstest] @@ -47,7 +48,7 @@ fn test_illegal_dates() { #[test] fn test_illegal_times() { - assert!(Time::from_seconds(24, 59, 59.0).is_err()); - assert!(Time::from_seconds(23, 60, 59.0).is_err()); - assert!(Time::from_seconds(23, 59, 61.0).is_err()); + assert!(UTC::from_fractional_seconds(24, 59, 59.0).is_err()); + assert!(UTC::from_fractional_seconds(23, 60, 59.0).is_err()); + assert!(UTC::from_fractional_seconds(23, 59, 61.0).is_err()); } diff --git a/crates/lox_py/src/coords.rs b/crates/lox_py/src/coords.rs index a0928c36..7747188b 100644 --- a/crates/lox_py/src/coords.rs +++ b/crates/lox_py/src/coords.rs @@ -16,7 +16,7 @@ use lox_core::coords::DVec3; use crate::bodies::PyBody; use crate::frames::PyFrame; -use crate::time::PyEpoch; +use crate::time::PyTime; #[pyclass(name = "Cartesian")] pub struct PyCartesian { @@ -30,7 +30,7 @@ impl PyCartesian { #[allow(clippy::too_many_arguments)] #[new] fn new( - time: &PyEpoch, + time: &PyTime, body: PyObject, frame: &str, x: f64, @@ -50,8 +50,8 @@ impl PyCartesian { }) } - fn time(&self) -> PyEpoch { - PyEpoch(self.state.time()) + fn time(&self) -> PyTime { + PyTime(self.state.time()) } fn reference_frame(&self) -> String { @@ -95,7 +95,7 @@ impl PyKeplerian { #[new] #[allow(clippy::too_many_arguments)] fn new( - t: &PyEpoch, + t: &PyTime, body: PyObject, frame: &str, semi_major_axis: f64, @@ -123,8 +123,8 @@ impl PyKeplerian { }) } - fn time(&self) -> PyEpoch { - PyEpoch(self.state.time()) + fn time(&self) -> PyTime { + PyTime(self.state.time()) } fn reference_frame(&self) -> String { @@ -180,7 +180,7 @@ mod tests { #[test] fn test_cartesian() { - let epoch = PyEpoch::new( + let epoch = PyTime::new( "TDB", 2023, 3, @@ -244,7 +244,7 @@ mod tests { #[test] fn test_keplerian() { - let epoch = PyEpoch::new( + let epoch = PyTime::new( "TDB", 2023, 3, diff --git a/crates/lox_py/src/lib.rs b/crates/lox_py/src/lib.rs index 4fcea7e9..87c161a8 100644 --- a/crates/lox_py/src/lib.rs +++ b/crates/lox_py/src/lib.rs @@ -12,7 +12,7 @@ use thiserror::Error; use crate::bodies::{PyBarycenter, PyMinorBody, PyPlanet, PySatellite, PySun}; use crate::coords::{PyCartesian, PyKeplerian}; -use crate::time::{PyEpoch, PyTimeScale}; +use crate::time::{PyTime, PyTimeScale}; use lox_core::errors::LoxError; mod bodies; @@ -50,7 +50,7 @@ impl From for PyErr { #[pymodule] fn lox_space(_py: Python, m: &PyModule) -> PyResult<()> { m.add_class::()?; - m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/crates/lox_py/src/time.rs b/crates/lox_py/src/time.rs index 45dd72aa..565bff01 100644 --- a/crates/lox_py/src/time.rs +++ b/crates/lox_py/src/time.rs @@ -8,8 +8,10 @@ use pyo3::{pyclass, pymethods}; -use lox_core::time::dates::{Date, Time}; -use lox_core::time::epochs::{Epoch, TimeScale}; +use lox_core::time::continuous::{Time, TimeScale}; +use lox_core::time::dates::Date; +use lox_core::time::utc::UTC; +use lox_core::time::PerMille; use crate::LoxPyError; @@ -40,12 +42,12 @@ impl PyTimeScale { } } -#[pyclass(name = "Epoch")] +#[pyclass(name = "Time")] #[derive(Debug, Copy, Clone, Eq, PartialEq)] -pub struct PyEpoch(pub Epoch); +pub struct PyTime(pub Time); #[pymethods] -impl PyEpoch { +impl PyTime { #[allow(clippy::too_many_arguments)] #[pyo3(signature = ( scale, @@ -68,15 +70,15 @@ impl PyEpoch { year: i64, month: i64, day: i64, - hour: Option, - minute: Option, - second: Option, - milli: Option, - micro: Option, - nano: Option, - pico: Option, - femto: Option, - atto: Option, + hour: Option, + minute: Option, + second: Option, + milli: Option, + micro: Option, + nano: Option, + pico: Option, + femto: Option, + atto: Option, ) -> Result { let time_scale = PyTimeScale::new(scale)?; let date = Date::new(year, month, day)?; @@ -84,26 +86,30 @@ impl PyEpoch { let hour = hour.unwrap_or(0); let minute = minute.unwrap_or(0); let second = second.unwrap_or(0); - let mut time = Time::new(hour, minute, second)?; + let mut utc = UTC::new(hour, minute, second)?; if let Some(milli) = milli { - time = time.milli(milli); + utc.milli = PerMille::new(milli)?; } if let Some(micro) = micro { - time = time.micro(micro); + utc.micro = PerMille::new(micro)?; } if let Some(nano) = nano { - time = time.nano(nano); + utc.nano = PerMille::new(nano)?; } if let Some(pico) = pico { - time = time.pico(pico); + utc.pico = PerMille::new(pico)?; } if let Some(femto) = femto { - time = time.femto(femto); + utc.femto = PerMille::new(femto)?; } if let Some(atto) = atto { - time = time.atto(atto); + utc.atto = PerMille::new(atto)?; } - Ok(PyEpoch(Epoch::from_date_and_time(time_scale.0, date, time))) + Ok(PyTime(Time::from_date_and_utc_timestamp( + time_scale.0, + date, + utc, + ))) } fn days_since_j2000(&self) -> f64 { @@ -111,7 +117,7 @@ impl PyEpoch { } fn scale(&self) -> &str { - self.0.scale() + self.0.scale().into() } } @@ -144,7 +150,7 @@ mod tests { #[test] fn test_time() { - let time = PyEpoch::new( + let time = PyTime::new( "TDB", 2024, 1, @@ -160,7 +166,7 @@ mod tests { Some(789), ) .expect("time should be valid"); - assert_eq!(time.0.attosecond(), 123456789123456789); + assert_eq!(time.0.attoseconds(), 123456789123456789); assert_float_eq!(time.days_since_j2000(), 8765.542374114084, rel <= 1e-8); assert_eq!(time.scale(), "TDB"); } diff --git a/crates/lox_py/tests/test_coords.py b/crates/lox_py/tests/test_coords.py index 33926b0d..7f5c50b2 100644 --- a/crates/lox_py/tests/test_coords.py +++ b/crates/lox_py/tests/test_coords.py @@ -8,7 +8,7 @@ def test_coords(): - time = lox.Epoch("TDB", 2016, 5, 30, 12) + time = lox.Time("TDB", 2016, 5, 30, 12) x = 6068.27927 y = -1692.84394 z = -2516.61918