physics: add time scales, epochs, and calendar conversions
This commit is contained in:
parent
ce0f341cbd
commit
f38e8a6097
|
|
@ -11,3 +11,5 @@ publish = false
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
apsis-core.workspace = true
|
||||||
|
apsis-leap.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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<Self> {
|
||||||
|
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<Self> {
|
||||||
|
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<f64> {
|
||||||
|
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<Calendar> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue