From f38e8a6097b3e15a2377fef3efdd43c7662e877f Mon Sep 17 00:00:00 2001 From: ApsisRS contributors Date: Sat, 26 Sep 2026 13:19:51 +0800 Subject: [PATCH] physics: add time scales, epochs, and calendar conversions --- crates/physics/apsis-time/Cargo.toml | 2 + crates/physics/apsis-time/src/calendar.rs | 161 +++++++++++++++++ crates/physics/apsis-time/src/epoch.rs | 153 ++++++++++++++++ crates/physics/apsis-time/src/julian.rs | 24 +++ crates/physics/apsis-time/src/lib.rs | 50 +++++- crates/physics/apsis-time/src/scale.rs | 71 ++++++++ crates/physics/apsis-time/tests/calendar.rs | 109 +++++++++++ crates/physics/apsis-time/tests/epoch.rs | 189 ++++++++++++++++++++ crates/physics/apsis-time/tests/julian.rs | 25 +++ crates/physics/apsis-time/tests/scale.rs | 28 +++ 10 files changed, 811 insertions(+), 1 deletion(-) create mode 100644 crates/physics/apsis-time/src/calendar.rs create mode 100644 crates/physics/apsis-time/src/epoch.rs create mode 100644 crates/physics/apsis-time/src/julian.rs create mode 100644 crates/physics/apsis-time/src/scale.rs create mode 100644 crates/physics/apsis-time/tests/calendar.rs create mode 100644 crates/physics/apsis-time/tests/epoch.rs create mode 100644 crates/physics/apsis-time/tests/julian.rs create mode 100644 crates/physics/apsis-time/tests/scale.rs diff --git a/crates/physics/apsis-time/Cargo.toml b/crates/physics/apsis-time/Cargo.toml index 899d7de..b9affc0 100644 --- a/crates/physics/apsis-time/Cargo.toml +++ b/crates/physics/apsis-time/Cargo.toml @@ -11,3 +11,5 @@ publish = false workspace = true [dependencies] +apsis-core.workspace = true +apsis-leap.workspace = true diff --git a/crates/physics/apsis-time/src/calendar.rs b/crates/physics/apsis-time/src/calendar.rs new file mode 100644 index 0000000..d4cc4e1 --- /dev/null +++ b/crates/physics/apsis-time/src/calendar.rs @@ -0,0 +1,161 @@ +//! Proleptic Gregorian calendar conversions. +//! +//! The calendar is the proleptic Gregorian calendar, extended backwards +//! before its historical adoption. A day is identified by its Modified +//! Julian Date (MJD), which counts days from 1858-11-17. MJD 0 is the start +//! of that day, so the MJD of a day is an integer and an instant within the +//! day adds a fraction. +//! +//! The conversion uses the algorithm published by Howard Hinnant in +//! *chrono-Compatible Low-Level Date Algorithms* (2015), which is exact for +//! the whole range of the Gregorian calendar and needs no lookup table. +//! +//! The seconds field is a floating point number so that a leap second can +//! be represented as second 60. Values are not constrained here; callers +//! that require a valid calendar date must check the fields they care +//! about. + +use core::fmt; + +/// The number of seconds in a day, not counting leap seconds. +pub const SECONDS_PER_DAY: f64 = 86_400.0; + +/// A calendar date and time of day. +/// +/// Fields are the proleptic Gregorian calendar fields. `second` is a +/// floating point number in the range `[0, 60)`. +/// +/// The second may not reach 60. A leap second is written as second 60 in +/// UTC, but that label does not have a unique position on a continuous time +/// scale: it occupies the same interval that the following midnight would +/// otherwise occupy. This crate therefore treats leap seconds only where +/// they are unambiguous, in the `TAI - UTC` offset lookup, and not as +/// calendar instants. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Calendar { + /// Year, negative for years before year 1. + pub year: i32, + /// Month in `1..=12`. + pub month: u32, + /// Day of month in `1..=31`. + pub day: u32, + /// Hour in `0..=23`. + pub hour: u32, + /// Minute in `0..=59`. + pub minute: u32, + /// Second in `[0, 61)`. + pub second: f64, +} + +impl Calendar { + /// Creates a calendar date from its fields. + pub const fn new(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: f64) -> Self { + Self { + year, + month, + day, + hour, + minute, + second, + } + } + + /// Creates a calendar date at the start of the given day. + pub const fn from_ymd(year: i32, month: u32, day: u32) -> Self { + Self::new(year, month, day, 0, 0, 0.0) + } + + /// Returns the seconds elapsed since the start of the day. + pub fn seconds_of_day(self) -> f64 { + f64::from(self.hour) * 3_600.0 + f64::from(self.minute) * 60.0 + self.second + } + + /// Returns `true` when the fields form a date that exists in the + /// proleptic Gregorian calendar. + pub fn is_valid(self) -> bool { + (1..=12).contains(&self.month) + && self.day >= 1 + && self.day <= days_in_month(self.year, self.month) + && self.hour <= 23 + && self.minute <= 59 + && self.second >= 0.0 + && self.second < 60.0 + } +} + +impl fmt::Display for Calendar { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{:04}-{:02}-{:02}T{:02}:{:02}:{:06.3}", + self.year, self.month, self.day, self.hour, self.minute, self.second + ) + } +} + +/// Returns `true` when `year` is a leap year in the proleptic Gregorian +/// calendar. +pub const fn is_leap_year(year: i32) -> bool { + (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 +} + +/// Returns the number of days in the given month of the given year. +/// +/// Returns zero when `month` is outside `1..=12`. +pub const fn days_in_month(year: i32, month: u32) -> u32 { + match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 => { + if is_leap_year(year) { + 29 + } else { + 28 + } + } + _ => 0, + } +} + +/// Converts a calendar date, ignoring the time of day, to a Modified Julian +/// Date. +/// +/// The result is the MJD of the start of the given day, so it is an +/// integer. The time of day is ignored; use [`Calendar::seconds_of_day`] to +/// account for it. +pub const fn mjd_from_ymd(year: i32, month: u32, day: u32) -> f64 { + // Shift the year so that March is the first month, which places the + // leap day at the end of the shifted year and removes a special case + // from the day count. + let a = (14 - month as i64) / 12; + let y = year as i64 + 4800 - a; + let m = month as i64 + 12 * a - 3; + let jdn = day as i64 + (153 * m + 2) / 5 + 365 * y + y / 4 - y / 100 + y / 400 - 32045; + (jdn - 2_400_001) as f64 +} + +/// Converts a Modified Julian Date to a calendar date. +/// +/// The fractional part is ignored, so the result is the calendar day +/// containing the instant. +pub fn ymd_from_mjd(mjd: f64) -> (i32, u32, u32) { + let jdn = mjd.floor() as i64 + 2_400_001; + let a = jdn + 32_044; + let b = (4 * a + 3) / 146_097; + let c = a - (146_097 * b) / 4; + let d = (4 * c + 3) / 1_461; + let e = c - (1_461 * d) / 4; + let m = (5 * e + 2) / 153; + let day = (e - (153 * m + 2) / 5 + 1) as u32; + let month = (m + 3 - 12 * (m / 10)) as u32; + let year = (100 * b + d - 4_800 + (m / 10)) as i32; + (year, month, day) +} + +/// Keeps only the fractional part of an MJD as seconds since midnight. +/// +/// The result lies in `[0, 86400)` for any finite input. +pub fn seconds_of_day_from_mjd(mjd: f64) -> f64 { + let fraction = mjd - mjd.floor(); + fraction * SECONDS_PER_DAY +} diff --git a/crates/physics/apsis-time/src/epoch.rs b/crates/physics/apsis-time/src/epoch.rs new file mode 100644 index 0000000..73410be --- /dev/null +++ b/crates/physics/apsis-time/src/epoch.rs @@ -0,0 +1,153 @@ +//! The instant type and its scale conversions. +//! +//! An [`Epoch`] stores an instant as a Modified Julian Date in International +//! Atomic Time. TAI is chosen as the internal representation because it is +//! uniform and because every other scale in this crate is either a constant +//! offset from it or, for UTC, a step offset that can be looked up. +//! +//! Storing the unified scale makes comparison and differencing between two +//! epochs exact regardless of the scales they were created in. The scale is +//! therefore not part of an epoch's identity; it is a parameter of the +//! conversion methods that read or write a number. + +use crate::calendar::{ + Calendar, SECONDS_PER_DAY, mjd_from_ymd, seconds_of_day_from_mjd, ymd_from_mjd, +}; +use crate::scale::{TAI_MINUS_GPS, TT_MINUS_TAI, TimeScale}; +use apsis_leap::{tai_minus_utc_at_mjd, utc_mjd_from_tai_mjd}; +use core::fmt; + +/// An instant on the TAI time scale, stored as a Modified Julian Date. +/// +/// The field is private so that the stored scale is guaranteed. Use +/// [`Epoch::from_mjd`] or the named constructors to build one. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +pub struct Epoch { + tai_mjd: f64, +} + +impl Epoch { + /// The instant 2000-01-01T12:00:00 TT, which defines the J2000.0 epoch. + /// + /// The value is `2451545.0` on the TT scale, offset back to TAI. + pub const J2000: Self = Self::from_tai_mjd_const(51_544.5 - TT_MINUS_TAI / SECONDS_PER_DAY); + + /// Creates an epoch from a Modified Julian Date on the TAI scale. + pub const fn from_tai_mjd(tai_mjd: f64) -> Self { + Self { tai_mjd } + } + + /// Const constructor used by the associated constants. + const fn from_tai_mjd_const(tai_mjd: f64) -> Self { + Self { tai_mjd } + } + + /// Creates an epoch from a Modified Julian Date on the given scale. + /// + /// Returns `None` when the instant cannot be expressed on the requested + /// scale. This happens for UTC before 1972, where the leap second table + /// used to convert from UTC does not apply. + pub fn from_mjd(mjd: f64, scale: TimeScale) -> Option { + if !mjd.is_finite() { + return None; + } + let tai_mjd = match scale { + TimeScale::Tai => mjd, + TimeScale::Tt => mjd - TT_MINUS_TAI / SECONDS_PER_DAY, + TimeScale::Gps => mjd + TAI_MINUS_GPS / SECONDS_PER_DAY, + TimeScale::Utc => { + let offset = tai_minus_utc_at_mjd(mjd)?; + mjd + offset / SECONDS_PER_DAY + } + }; + Some(Self { tai_mjd }) + } + + /// Creates an epoch from a calendar date, interpreting it on the given + /// scale. + /// + /// Returns `None` when the fields do not form a valid date, or when the + /// instant cannot be expressed on the requested scale. + pub fn from_calendar(calendar: Calendar, scale: TimeScale) -> Option { + if !calendar.is_valid() { + return None; + } + let mjd = mjd_from_ymd(calendar.year, calendar.month, calendar.day) + + calendar.seconds_of_day() / SECONDS_PER_DAY; + Self::from_mjd(mjd, scale) + } + + /// Returns the instant as a Modified Julian Date on the TAI scale. + pub const fn tai_mjd(self) -> f64 { + self.tai_mjd + } + + /// Returns the instant as a Modified Julian Date on the given scale. + /// + /// Returns `None` when the instant precedes 1972 and the UTC scale is + /// requested, because the leap second table does not cover that range. + pub fn mjd(self, scale: TimeScale) -> Option { + match scale { + TimeScale::Tai => Some(self.tai_mjd), + TimeScale::Tt => Some(self.tai_mjd + TT_MINUS_TAI / SECONDS_PER_DAY), + TimeScale::Gps => Some(self.tai_mjd - TAI_MINUS_GPS / SECONDS_PER_DAY), + TimeScale::Utc => utc_mjd_from_tai_mjd(self.tai_mjd), + } + } + + /// Returns the instant as a calendar date on the given scale. + /// + /// Returns `None` when the scale conversion is not available for this + /// instant; see [`Epoch::mjd`]. + pub fn calendar(self, scale: TimeScale) -> Option { + let mjd = self.mjd(scale)?; + let (year, month, day) = ymd_from_mjd(mjd); + let seconds = seconds_of_day_from_mjd(mjd); + let hour = (seconds / 3_600.0).floor(); + let minute = ((seconds - hour * 3_600.0) / 60.0).floor(); + let second = seconds - hour * 3_600.0 - minute * 60.0; + Some(Calendar::new( + year, + month, + day, + hour as u32, + minute as u32, + second, + )) + } + + /// Returns the signed number of seconds from `self` to `other`. + /// + /// The result is positive when `other` is later. Because both epochs + /// are stored on the uniform TAI scale, the difference is exact and + /// does not depend on the scales used to create either epoch. + pub fn seconds_until(self, other: Self) -> f64 { + (other.tai_mjd - self.tai_mjd) * SECONDS_PER_DAY + } + + /// Returns an epoch shifted by `seconds`. + /// + /// The shift is measured in SI seconds on the uniform TAI scale, so it + /// advances the instant by exactly that much elapsed time. This is the + /// only shift offered: a shift measured on the UTC clock would differ + /// when the interval spans an inserted leap second, and the UTC clock + /// does not have a single well defined numeric value across such an + /// instant. Callers that need calendar arithmetic on the UTC scale + /// should convert to a [`Calendar`], adjust it, and convert back. + pub fn add_seconds(self, seconds: f64) -> Self { + Self { + tai_mjd: self.tai_mjd + seconds / SECONDS_PER_DAY, + } + } + + /// Returns `true` when the stored Modified Julian Date is finite. + pub const fn is_finite(self) -> bool { + self.tai_mjd.is_finite() + } +} + +impl fmt::Display for Epoch { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} TAI", self.tai_mjd) + } +} diff --git a/crates/physics/apsis-time/src/julian.rs b/crates/physics/apsis-time/src/julian.rs new file mode 100644 index 0000000..8514d20 --- /dev/null +++ b/crates/physics/apsis-time/src/julian.rs @@ -0,0 +1,24 @@ +//! Conversions between Modified Julian Date and Julian Date. +//! +//! A Julian Date counts days from noon on 4713-01-01 in the proleptic +//! Julian calendar. A Modified Julian Date counts days from midnight on +//! 1858-11-17. The two therefore differ by a fixed number of days, and the +//! half day offset accounts for the different times of day at which each +//! scale starts a day. +//! +//! The offset is exact, so these conversions introduce no rounding beyond +//! the representation of the input. + +/// The number of days between the start of the Julian Date scale and the +/// start of the Modified Julian Date scale. +pub const JULIAN_DATE_OFFSET: f64 = 2_400_000.5; + +/// Converts a Julian Date to a Modified Julian Date. +pub const fn mjd_from_julian_date(julian_date: f64) -> f64 { + julian_date - JULIAN_DATE_OFFSET +} + +/// Converts a Modified Julian Date to a Julian Date. +pub const fn julian_date_from_mjd(mjd: f64) -> f64 { + mjd + JULIAN_DATE_OFFSET +} diff --git a/crates/physics/apsis-time/src/lib.rs b/crates/physics/apsis-time/src/lib.rs index 7720466..eb700c3 100644 --- a/crates/physics/apsis-time/src/lib.rs +++ b/crates/physics/apsis-time/src/lib.rs @@ -1 +1,49 @@ -//! Time scales, epochs, and Julian date conversions. +//! Time scales, epochs, and calendar conversions. +//! +//! This crate provides the time model used by the rest of the workspace. +//! An [`Epoch`] is an instant stored as a Modified Julian Date on the +//! International Atomic Time scale, and [`TimeScale`] names the scale a +//! number is expressed in. +//! +//! Storing a single uniform scale makes the arithmetic exact: the +//! difference between two epochs is the elapsed SI time between them, +//! independent of the scales they were created in. UTC is not uniform, +//! because leap seconds are inserted to keep it near the rotation of the +//! Earth, so UTC conversions are routed through the table in `apsis-leap`. +//! +//! ``` +//! use apsis_time::{Calendar, Epoch, TimeScale}; +//! +//! // Build an instant from a UTC calendar date and read it back. +//! let calendar = Calendar::from_ymd(2024, 6, 15); +//! let epoch = Epoch::from_calendar(calendar, TimeScale::Utc).expect("covered by the leap table"); +//! let back = epoch.calendar(TimeScale::Utc).expect("covered by the leap table"); +//! assert_eq!(back.year, 2024); +//! assert_eq!(back.month, 6); +//! assert_eq!(back.day, 15); +//! ``` +//! +//! Sources: +//! +//! - Julian date and Modified Julian date conventions: IAU, as described in +//! the *Explanatory Supplement to the Astronomical Almanac*. +//! - Terrestrial Time offset from TAI: IAU 2000 Resolution B1.9. +//! - Leap seconds: IERS Bulletin C, embedded by `apsis-leap`. +//! - Calendar algorithms: Howard Hinnant, *chrono-Compatible Low-Level Date +//! Algorithms*, 2015. + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +mod calendar; +mod epoch; +mod julian; +mod scale; + +pub use calendar::{ + Calendar, SECONDS_PER_DAY, days_in_month, is_leap_year, mjd_from_ymd, seconds_of_day_from_mjd, + ymd_from_mjd, +}; +pub use epoch::Epoch; +pub use julian::{JULIAN_DATE_OFFSET, julian_date_from_mjd, mjd_from_julian_date}; +pub use scale::{TAI_MINUS_GPS, TT_MINUS_TAI, TimeScale}; diff --git a/crates/physics/apsis-time/src/scale.rs b/crates/physics/apsis-time/src/scale.rs new file mode 100644 index 0000000..0688fde --- /dev/null +++ b/crates/physics/apsis-time/src/scale.rs @@ -0,0 +1,71 @@ +//! Time scales and the fixed offsets between them. +//! +//! Four scales are represented. TAI, TT, and GPS are uniform: one second in +//! each is one SI second, and they differ only by constant offsets fixed by +//! definition. UTC is not uniform, because it is adjusted by leap seconds to +//! stay near the rotation of the Earth. +//! +//! The offsets between the uniform scales are exact: +//! +//! - `TT = TAI + 32.184 s`, by the definition of Terrestrial Time. +//! - `TAI = GPS + 19 s`, by the definition of GPS time, whose origin was +//! set to coincide with UTC at 1980-01-06. +//! +//! Source: IAU 2000 Resolution B1.9 for Terrestrial Time; the GPS interface +//! specification for GPS time. Neither relation has changed since those +//! definitions took effect, so both are treated as constants here. + +use core::fmt; + +/// Terrestrial Time minus International Atomic Time, in seconds. +/// +/// Exact by the definition of Terrestrial Time. +pub const TT_MINUS_TAI: f64 = 32.184; + +/// International Atomic Time minus GPS time, in seconds. +/// +/// Exact by the definition of GPS time. +pub const TAI_MINUS_GPS: f64 = 19.0; + +/// A time scale. +/// +/// The scale determines how a numeric instant is interpreted. It does not +/// change the instant itself; converting an [`Epoch`](crate::Epoch) between +/// scales only changes the number used to display it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum TimeScale { + /// Coordinated Universal Time, adjusted by leap seconds. + Utc, + /// International Atomic Time, a uniform scale. + Tai, + /// Terrestrial Time, a uniform scale defined for geocentric work. + Tt, + /// GPS time, a uniform scale maintained by the GPS constellation. + Gps, +} + +impl TimeScale { + /// Returns `true` when the scale runs at a uniform rate. + /// + /// UTC is the only scale here that is not uniform, because leap seconds + /// make some of its minutes 61 seconds long. + pub const fn is_uniform(self) -> bool { + !matches!(self, Self::Utc) + } + + /// Returns the short name of the scale, such as `TAI` or `UTC`. + pub const fn name(self) -> &'static str { + match self { + Self::Utc => "UTC", + Self::Tai => "TAI", + Self::Tt => "TT", + Self::Gps => "GPS", + } + } +} + +impl fmt::Display for TimeScale { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} diff --git a/crates/physics/apsis-time/tests/calendar.rs b/crates/physics/apsis-time/tests/calendar.rs new file mode 100644 index 0000000..5061e91 --- /dev/null +++ b/crates/physics/apsis-time/tests/calendar.rs @@ -0,0 +1,109 @@ +//! Tests for the calendar conversions. + +use apsis_time::{Calendar, days_in_month, is_leap_year, mjd_from_ymd, ymd_from_mjd}; + +#[test] +fn modified_julian_date_zero_is_the_documented_day() { + assert_eq!(mjd_from_ymd(1858, 11, 17), 0.0); + assert_eq!(ymd_from_mjd(0.0), (1858, 11, 17)); +} + +#[test] +fn known_dates_map_to_known_modified_julian_dates() { + assert_eq!(mjd_from_ymd(1970, 1, 1), 40_587.0); + assert_eq!(mjd_from_ymd(2000, 1, 1), 51_544.0); + assert_eq!(mjd_from_ymd(2017, 1, 1), 57_754.0); + assert_eq!(mjd_from_ymd(2024, 1, 1), 60_310.0); +} + +#[test] +fn calendar_conversion_round_trips_over_a_long_span() { + // Walk day by day over a span that crosses every leap year rule. + let mut mjd = mjd_from_ymd(1899, 12, 20); + let end = mjd_from_ymd(2101, 1, 10); + while mjd < end { + let (year, month, day) = ymd_from_mjd(mjd); + assert_eq!( + mjd_from_ymd(year, month, day), + mjd, + "round trip failed at MJD {mjd}" + ); + mjd += 1.0; + } +} + +#[test] +fn fractional_modified_julian_dates_map_to_the_containing_day() { + let day = mjd_from_ymd(2024, 6, 15); + assert_eq!(ymd_from_mjd(day + 0.25), (2024, 6, 15)); + assert_eq!(ymd_from_mjd(day + 0.75), (2024, 6, 15)); + // One day later, but still the same instant of the following day. + assert_eq!(ymd_from_mjd(day + 1.25), (2024, 6, 16)); +} + +#[test] +fn leap_years_follow_the_gregorian_rule() { + assert!(is_leap_year(2000)); + assert!(is_leap_year(2024)); + assert!(!is_leap_year(1900)); + assert!(!is_leap_year(2100)); + assert!(!is_leap_year(2023)); + // Year zero is a leap year in the proleptic calendar. + assert!(is_leap_year(0)); +} + +#[test] +fn february_length_follows_the_leap_year_rule() { + assert_eq!(days_in_month(2024, 2), 29); + assert_eq!(days_in_month(2023, 2), 28); + assert_eq!(days_in_month(1900, 2), 28); + assert_eq!(days_in_month(2000, 2), 29); +} + +#[test] +fn days_in_month_rejects_invalid_months() { + assert_eq!(days_in_month(2024, 0), 0); + assert_eq!(days_in_month(2024, 13), 0); +} + +#[test] +fn every_month_has_the_expected_length() { + let expected = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + for (index, days) in expected.iter().enumerate() { + let month = index as u32 + 1; + assert_eq!(days_in_month(2023, month), *days, "month {month}"); + } +} + +#[test] +fn validation_accepts_real_dates() { + assert!(Calendar::from_ymd(2024, 2, 29).is_valid()); + assert!(Calendar::new(2016, 12, 31, 23, 59, 59.5).is_valid()); +} + +#[test] +fn validation_rejects_impossible_dates_and_the_leap_second_label() { + assert!(!Calendar::from_ymd(2023, 2, 29).is_valid()); + assert!(!Calendar::from_ymd(2024, 13, 1).is_valid()); + assert!(!Calendar::from_ymd(2024, 1, 0).is_valid()); + assert!(!Calendar::from_ymd(2024, 1, 32).is_valid()); + assert!(!Calendar::new(2024, 1, 1, 24, 0, 0.0).is_valid()); + assert!(!Calendar::new(2024, 1, 1, 0, 60, 0.0).is_valid()); + // Second 60 is the leap second label, which is not a representable + // instant on the continuous Modified Julian Date scale. + assert!(!Calendar::new(2016, 12, 31, 23, 59, 60.0).is_valid()); + assert!(!Calendar::new(2024, 1, 1, 0, 0, 61.0).is_valid()); +} + +#[test] +fn seconds_of_day_accumulates_the_time_fields() { + let calendar = Calendar::new(2024, 1, 1, 1, 2, 3.5); + assert!((calendar.seconds_of_day() - 3_723.5).abs() < 1e-12); + assert_eq!(Calendar::from_ymd(2024, 1, 1).seconds_of_day(), 0.0); +} + +#[test] +fn display_uses_an_iso_style_layout() { + let calendar = Calendar::new(2024, 6, 15, 12, 30, 45.25); + assert_eq!(calendar.to_string(), "2024-06-15T12:30:45.250"); +} diff --git a/crates/physics/apsis-time/tests/epoch.rs b/crates/physics/apsis-time/tests/epoch.rs new file mode 100644 index 0000000..4679e17 --- /dev/null +++ b/crates/physics/apsis-time/tests/epoch.rs @@ -0,0 +1,189 @@ +//! Tests for the instant type and its scale conversions. + +use apsis_time::{Calendar, Epoch, SECONDS_PER_DAY, TAI_MINUS_GPS, TT_MINUS_TAI, TimeScale}; + +/// Tolerance for comparisons of Modified Julian Dates near the present day. +/// +/// A double holds a Modified Julian Date near 60 000 with a resolution of +/// about 1e-11 days, roughly one microsecond. Conversions that add or +/// remove a scale offset each introduce one rounding at that resolution, so +/// comparisons are made at ten microseconds to leave room for a few steps. +const MJD_TOL: f64 = 1e-9; + +fn assert_mjd_close(actual: f64, expected: f64) { + assert!( + (actual - expected).abs() < MJD_TOL, + "expected {expected}, got {actual}" + ); +} + +#[test] +fn j2000_constant_lands_on_the_defining_instant() { + // 2000-01-01T12:00:00 TT is Julian Date 2451545.0, or MJD 51544.5. + let tt = Epoch::J2000 + .mjd(TimeScale::Tt) + .expect("TT is always available"); + assert_mjd_close(tt, 51_544.5); +} + +#[test] +fn uniform_scales_differ_by_their_defining_offsets() { + let epoch = Epoch::from_mjd(60_000.0, TimeScale::Tai).expect("TAI is always available"); + assert_mjd_close( + epoch.mjd(TimeScale::Tt).expect("TT is available"), + 60_000.0 + TT_MINUS_TAI / SECONDS_PER_DAY, + ); + assert_mjd_close( + epoch.mjd(TimeScale::Gps).expect("GPS is available"), + 60_000.0 - TAI_MINUS_GPS / SECONDS_PER_DAY, + ); +} + +#[test] +fn construction_from_each_scale_round_trips() { + // 60 000.5 is a mid-day instant, which keeps the fractional part of + // every intermediate value exactly representable. + let input = 60_000.5; + for scale in [ + TimeScale::Tai, + TimeScale::Tt, + TimeScale::Gps, + TimeScale::Utc, + ] { + let epoch = Epoch::from_mjd(input, scale).expect("instant is inside the leap table"); + // Reading the instant back on the same scale recovers the number + // that was supplied. + assert_mjd_close(epoch.mjd(scale).expect("scale is available"), input); + } +} + +#[test] +fn utc_construction_applies_the_leap_second_offset() { + // In 2017 the offset TAI - UTC is 37 seconds. + let utc = 60_000.0; + let epoch = Epoch::from_mjd(utc, TimeScale::Utc).expect("covered by the table"); + assert_mjd_close(epoch.tai_mjd(), utc + 37.0 / SECONDS_PER_DAY); +} + +#[test] +fn non_finite_input_is_rejected() { + assert_eq!(Epoch::from_mjd(f64::NAN, TimeScale::Tai), None); + assert_eq!(Epoch::from_mjd(f64::INFINITY, TimeScale::Tai), None); +} + +#[test] +fn utc_conversion_is_rejected_before_the_table_begins() { + let before = 40_000.0; + assert_eq!(Epoch::from_mjd(before, TimeScale::Utc), None); + // The same instant is representable in TAI. + let epoch = Epoch::from_mjd(before, TimeScale::Tai).expect("TAI is always available"); + assert_eq!(epoch.mjd(TimeScale::Utc), None); +} + +#[test] +fn calendar_construction_and_read_back_agree() { + let calendar = Calendar::new(2024, 6, 15, 12, 30, 45.0); + let epoch = Epoch::from_calendar(calendar, TimeScale::Utc).expect("covered by the table"); + let read_back = epoch + .calendar(TimeScale::Utc) + .expect("covered by the table"); + assert_eq!(read_back.year, 2024); + assert_eq!(read_back.month, 6); + assert_eq!(read_back.day, 15); + assert_eq!(read_back.hour, 12); + assert_eq!(read_back.minute, 30); + assert!((read_back.second - 45.0).abs() < 1e-4); +} + +#[test] +fn calendar_construction_rejects_impossible_dates() { + let calendar = Calendar::from_ymd(2023, 2, 29); + assert_eq!(Epoch::from_calendar(calendar, TimeScale::Tai), None); +} + +#[test] +fn seconds_until_is_independent_of_the_scale_used() { + let start = Epoch::from_calendar(Calendar::from_ymd(2024, 1, 1), TimeScale::Utc) + .expect("covered by the table"); + let end = Epoch::from_calendar(Calendar::from_ymd(2024, 1, 2), TimeScale::Utc) + .expect("covered by the table"); + // One calendar day in early 2024 contains no leap second, so the + // elapsed time is exactly 86 400 seconds. + assert!((start.seconds_until(end) - 86_400.0).abs() < 1e-6); + assert!((end.seconds_until(start) + 86_400.0).abs() < 1e-6); +} + +#[test] +fn add_seconds_moves_along_the_uniform_scale() { + let epoch = Epoch::from_mjd(60_000.5, TimeScale::Tai).expect("TAI is always available"); + let later = epoch.add_seconds(3_600.0); + assert!((epoch.seconds_until(later) - 3_600.0).abs() < 1e-3); + let earlier = epoch.add_seconds(-3_600.0); + assert!((earlier.seconds_until(epoch) - 3_600.0).abs() < 1e-3); +} + +#[test] +fn add_seconds_crosses_a_leap_second_as_elapsed_time() { + // 2016-12-31T23:59:59.5 UTC, half a second before the inserted leap + // second. The leap second makes the next UTC midnight 1.5 SI seconds + // away, because the interval labelled 23:59:60 is a whole second long. + let before = Epoch::from_calendar(Calendar::new(2016, 12, 31, 23, 59, 59.5), TimeScale::Utc) + .expect("covered by the table"); + + // One and a half seconds of elapsed time reaches the next midnight. + let after = before.add_seconds(1.5); + let utc = after + .calendar(TimeScale::Utc) + .expect("covered by the table"); + assert_eq!((utc.year, utc.month, utc.day), (2017, 1, 1)); + assert!(utc.second.abs() < 1e-4, "got {}", utc.second); + + // An instant strictly inside the leap second has no UTC label on the + // continuous Modified Julian Date scale, because it would be written + // 23:59:60 and that value does not exist there. + assert_eq!(before.add_seconds(1.0).mjd(TimeScale::Utc), None); + + // Reading the interval on the UTC clock shows half a second of clock + // advance over one and a half seconds of elapsed time. + let utc_before = before.mjd(TimeScale::Utc).expect("covered by the table"); + let utc_after = after.mjd(TimeScale::Utc).expect("covered by the table"); + let clock_advance = (utc_after - utc_before) * SECONDS_PER_DAY; + assert!((clock_advance - 0.5).abs() < 1e-3, "got {clock_advance}"); +} + +#[test] +fn ordering_follows_the_instant() { + let earlier = Epoch::from_mjd(60_000.0, TimeScale::Tai).expect("TAI is always available"); + let later = earlier.add_seconds(1.0); + assert!(earlier < later); + assert!(later > earlier); + assert!(earlier <= earlier); +} + +#[test] +fn equality_is_scale_independent() { + // The same physical instant described in two scales yields equal + // epochs. + let in_tai = Epoch::from_mjd(60_000.0, TimeScale::Tai).expect("TAI is always available"); + let in_tt = Epoch::from_mjd(60_000.0 + TT_MINUS_TAI / SECONDS_PER_DAY, TimeScale::Tt) + .expect("TT is always available"); + assert_eq!(in_tai, in_tt); + assert!(in_tai.seconds_until(in_tt).abs() < 1e-6); +} + +#[test] +fn finiteness_is_reported() { + assert!( + Epoch::from_mjd(60_000.0, TimeScale::Tai) + .expect("always available") + .is_finite() + ); + assert!(!Epoch::from_tai_mjd(f64::NAN).is_finite()); + assert!(!Epoch::from_tai_mjd(f64::INFINITY).is_finite()); +} + +#[test] +fn display_reports_the_stored_scale() { + let epoch = Epoch::from_tai_mjd(60_000.5); + assert_eq!(epoch.to_string(), "60000.5 TAI"); +} diff --git a/crates/physics/apsis-time/tests/julian.rs b/crates/physics/apsis-time/tests/julian.rs new file mode 100644 index 0000000..c83207f --- /dev/null +++ b/crates/physics/apsis-time/tests/julian.rs @@ -0,0 +1,25 @@ +//! Tests for the Julian Date conversions. + +use apsis_time::{JULIAN_DATE_OFFSET, julian_date_from_mjd, mjd_from_julian_date}; + +#[test] +fn the_offset_matches_the_defining_values() { + // MJD 0 is Julian Date 2400000.5. + assert_eq!(JULIAN_DATE_OFFSET, 2_400_000.5); + assert_eq!(julian_date_from_mjd(0.0), 2_400_000.5); + assert_eq!(mjd_from_julian_date(2_400_000.5), 0.0); +} + +#[test] +fn j2000_maps_to_the_expected_julian_date() { + // J2000.0 is MJD 51544.5 and Julian Date 2451545.0. + assert_eq!(julian_date_from_mjd(51_544.5), 2_451_545.0); + assert_eq!(mjd_from_julian_date(2_451_545.0), 51_544.5); +} + +#[test] +fn conversion_round_trips() { + for mjd in [0.0, 40_587.0, 51_544.5, 60_000.25, 60_310.0] { + assert_eq!(mjd_from_julian_date(julian_date_from_mjd(mjd)), mjd); + } +} diff --git a/crates/physics/apsis-time/tests/scale.rs b/crates/physics/apsis-time/tests/scale.rs new file mode 100644 index 0000000..cfe42be --- /dev/null +++ b/crates/physics/apsis-time/tests/scale.rs @@ -0,0 +1,28 @@ +//! Tests for the time scale definitions. + +use apsis_time::{TAI_MINUS_GPS, TT_MINUS_TAI, TimeScale}; + +#[test] +fn only_utc_is_not_uniform() { + assert!(!TimeScale::Utc.is_uniform()); + assert!(TimeScale::Tai.is_uniform()); + assert!(TimeScale::Tt.is_uniform()); + assert!(TimeScale::Gps.is_uniform()); +} + +#[test] +fn scales_report_their_names() { + assert_eq!(TimeScale::Utc.name(), "UTC"); + assert_eq!(TimeScale::Tai.name(), "TAI"); + assert_eq!(TimeScale::Tt.name(), "TT"); + assert_eq!(TimeScale::Gps.name(), "GPS"); + assert_eq!(TimeScale::Tai.to_string(), "TAI"); +} + +#[test] +fn offsets_match_their_definitions() { + // Terrestrial Time is ahead of TAI by the defining constant. + assert_eq!(TT_MINUS_TAI, 32.184); + // GPS time lags TAI by the defining integer number of seconds. + assert_eq!(TAI_MINUS_GPS, 19.0); +}