physics: add time scales, epochs, and calendar conversions

This commit is contained in:
ApsisRS contributors 2026-09-26 13:19:51 +08:00
parent ce0f341cbd
commit f38e8a6097
10 changed files with 811 additions and 1 deletions

View File

@ -11,3 +11,5 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-leap.workspace = true

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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};

View File

@ -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())
}
}

View File

@ -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");
}

View File

@ -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");
}

View File

@ -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);
}
}

View File

@ -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);
}