use core::f64::consts::TAU;
use core::marker::PhantomData;
#[allow(unused_imports)]
use crate::math::F64Ext;
pub const J2000_JD: f64 = 2451545.0;
const MJD_OFFSET: f64 = 2400000.5;
const JULIAN_CENTURY: f64 = 36525.0;
const TT_MINUS_TAI_SEC: f64 = 32.184;
#[cfg(feature = "std")]
const UNIX_EPOCH_JD: f64 = 2440587.5;
const LEAP_SECONDS: &[(f64, f64)] = &[
(41317.0, 10.0), (41499.0, 11.0), (41683.0, 12.0), (42048.0, 13.0), (42413.0, 14.0), (42778.0, 15.0), (43144.0, 16.0), (43509.0, 17.0), (43874.0, 18.0), (44239.0, 19.0), (44786.0, 20.0), (45151.0, 21.0), (45516.0, 22.0), (46247.0, 23.0), (47161.0, 24.0), (47892.0, 25.0), (48257.0, 26.0), (48804.0, 27.0), (49169.0, 28.0), (49534.0, 29.0), (50083.0, 30.0), (50630.0, 31.0), (51179.0, 32.0), (53736.0, 33.0), (54832.0, 34.0), (56109.0, 35.0), (57204.0, 36.0), (57754.0, 37.0), ];
fn tai_minus_utc_at_mjd(utc_mjd: f64) -> f64 {
let mut offset = 10.0;
for &(mjd_start, val) in LEAP_SECONDS {
if utc_mjd >= mjd_start {
offset = val;
} else {
break;
}
}
offset
}
fn tdb_minus_tt(tt_jd: f64) -> f64 {
let d = tt_jd - J2000_JD;
let g_deg = 357.53 + 0.985_600_28 * d;
let g = g_deg.to_radians();
0.001_658 * g.sin() + 0.000_014 * (2.0 * g).sin()
}
mod sealed {
pub trait Sealed {}
}
pub trait TimeScale: sealed::Sealed {
const NAME: &'static str;
}
macro_rules! define_scale {
($name:ident, $display:expr, $doc:expr) => {
#[doc = $doc]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct $name;
impl sealed::Sealed for $name {}
impl TimeScale for $name {
const NAME: &'static str = $display;
}
};
}
define_scale!(
Utc,
"UTC",
"Coordinated Universal Time. Operational hybrid scale: rate = SI (TAI) \
with leap seconds to stay within 0.9 s of UT1. 一般的な入口 scale。"
);
define_scale!(
Tai,
"TAI",
"International Atomic Time. Proper-time-like scale realized by a global \
ensemble of atomic clocks. `TT = TAI + 32.184 s`."
);
define_scale!(
Tt,
"TT",
"Terrestrial Time. Coordinate-derived time (linear scale of TCG, \
`dTT/dTCG = 1 - L_G`, `L_G = 6.969290134e-10`; IAU 2000 B1.9). \
IAU 2006 precession と IAU 2000A/B nutation の独立変数。"
);
define_scale!(
Ut1,
"UT1",
"Universal Time (UT1). Earth rotation angle time scale — defining \
observable は ERA (IAU 2000 B1.8 / SOFA iauEra00)。atomic clock が \
刻む時刻ではなく Earth の瞬間的な向きを時間単位で表現したもの。"
);
define_scale!(
Tdb,
"TDB",
"Barycentric Dynamical Time. Coordinate-derived time (linear scale of TCB; \
IAU 2006 Resolution B3)。Meeus / JPL DE (Teph ≈ TDB) ephemeris と \
IAU 2009 body rotation の formally な独立変数。"
);
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DateTime {
pub year: i32,
pub month: u32,
pub day: u32,
pub hour: u32,
pub min: u32,
pub sec: f64,
}
impl DateTime {
pub fn new(year: i32, month: u32, day: u32, hour: u32, min: u32, sec: f64) -> Self {
DateTime {
year,
month,
day,
hour,
min,
sec,
}
}
}
impl core::fmt::Display for DateTime {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let sec = self.sec.round() as u32;
let (sec, carry) = if sec >= 60 { (0u32, 1u32) } else { (sec, 0) };
let min = self.min + carry;
let (min, carry) = if min >= 60 {
(min - 60, 1u32)
} else {
(min, 0)
};
let hour = self.hour + carry;
write!(
f,
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
self.year, self.month, self.day, hour, min, sec
)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Epoch<S: TimeScale = Utc> {
jd: f64,
_scale: PhantomData<S>,
}
impl<S: TimeScale> Epoch<S> {
pub fn jd(&self) -> f64 {
self.jd
}
pub fn mjd(&self) -> f64 {
self.jd - MJD_OFFSET
}
pub fn scale_name() -> &'static str {
S::NAME
}
pub(crate) fn from_jd_raw(jd: f64) -> Self {
Self {
jd,
_scale: PhantomData,
}
}
}
impl Epoch<Utc> {
pub fn from_jd(jd: f64) -> Self {
Epoch {
jd,
_scale: PhantomData,
}
}
pub fn from_mjd(mjd: f64) -> Self {
Epoch {
jd: mjd + MJD_OFFSET,
_scale: PhantomData,
}
}
pub fn j2000() -> Self {
Epoch {
jd: J2000_JD,
_scale: PhantomData,
}
}
pub fn from_datetime(dt: &DateTime) -> Self {
Self::from_gregorian(dt.year, dt.month, dt.day, dt.hour, dt.min, dt.sec)
}
pub fn from_gregorian(year: i32, month: u32, day: u32, hour: u32, min: u32, sec: f64) -> Self {
let (y, m) = if month <= 2 {
(year - 1, month + 12)
} else {
(year, month)
};
let a = y / 100;
let b = 2 - a + a / 4;
let jd = (365.25 * (y + 4716) as f64).floor()
+ (30.6001 * (m + 1) as f64).floor()
+ day as f64
+ b as f64
- 1524.5
+ (hour as f64 + min as f64 / 60.0 + sec / 3600.0) / 24.0;
Epoch {
jd,
_scale: PhantomData,
}
}
pub fn from_iso8601(s: &str) -> Option<Self> {
let s = s.trim();
let s = s.strip_suffix('Z')?;
let (date, time) = s.split_once('T')?;
let (year_s, rest) = date.split_once('-')?;
let (month_s, day_s) = rest.split_once('-')?;
let year: i32 = year_s.parse().ok()?;
let month: u32 = month_s.parse().ok()?;
let day: u32 = day_s.parse().ok()?;
let (hour_s, rest) = time.split_once(':')?;
let (min_s, sec_s) = rest.split_once(':')?;
let hour: u32 = hour_s.parse().ok()?;
let min: u32 = min_s.parse().ok()?;
let sec: f64 = sec_s.parse().ok()?;
if !(1..=12).contains(&month)
|| !(1..=31).contains(&day)
|| hour > 23
|| min > 59
|| sec >= 60.0
{
return None;
}
Some(Self::from_gregorian(year, month, day, hour, min, sec))
}
#[cfg(feature = "std")]
pub fn now() -> Self {
let unix_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock before Unix epoch")
.as_secs_f64();
Epoch {
jd: UNIX_EPOCH_JD + unix_secs / 86400.0,
_scale: PhantomData,
}
}
pub fn from_tle_epoch(year_2digit: u32, day_of_year: f64) -> Self {
let year = if year_2digit >= 57 {
1900 + year_2digit as i32
} else {
2000 + year_2digit as i32
};
let jan1 = Self::from_gregorian(year, 1, 1, 0, 0, 0.0);
Epoch {
jd: jan1.jd + (day_of_year - 1.0),
_scale: PhantomData,
}
}
pub fn centuries_since_j2000(&self) -> f64 {
(self.jd - J2000_JD) / JULIAN_CENTURY
}
pub fn add_seconds(&self, dt: f64) -> Self {
Epoch {
jd: self.jd + dt / 86400.0,
_scale: PhantomData,
}
}
pub fn add_si_seconds(&self, dt: f64) -> Self {
let utc_mjd = self.jd - MJD_OFFSET;
let leap_before = tai_minus_utc_at_mjd(utc_mjd);
let tai_jd = self.jd + leap_before / 86400.0;
let new_tai_jd = tai_jd + dt / 86400.0;
let mut guess_utc_jd = new_tai_jd - leap_before / 86400.0;
for _ in 0..3 {
let guess_mjd = guess_utc_jd - MJD_OFFSET;
let new_leap = tai_minus_utc_at_mjd(guess_mjd);
guess_utc_jd = new_tai_jd - new_leap / 86400.0;
}
Epoch {
jd: guess_utc_jd,
_scale: PhantomData,
}
}
pub fn to_datetime(&self) -> DateTime {
to_datetime_from_jd(self.jd)
}
pub fn to_datetime_normalized(&self) -> DateTime {
self.to_datetime()
}
pub fn gmst(&self) -> f64 {
era_formula(self.jd)
}
pub fn to_tai(&self) -> Epoch<Tai> {
let utc_mjd = self.jd - MJD_OFFSET;
let leap = tai_minus_utc_at_mjd(utc_mjd);
Epoch::<Tai>::from_jd_raw(self.jd + leap / 86400.0)
}
pub fn to_tt(&self) -> Epoch<Tt> {
self.to_tai().to_tt()
}
pub fn to_tdb(&self) -> Epoch<Tdb> {
self.to_tt().to_tdb()
}
pub fn to_ut1_naive(&self) -> Epoch<Ut1> {
Epoch::<Ut1>::from_jd_raw(self.jd)
}
pub fn to_ut1<P: crate::earth::eop::Ut1Offset + ?Sized>(&self, eop: &P) -> Epoch<Ut1> {
let mjd = self.jd - MJD_OFFSET;
let dut1 = eop.dut1(mjd);
Epoch::<Ut1>::from_jd_raw(self.jd + dut1 / 86400.0)
}
}
impl Epoch<Tai> {
pub fn from_jd_tai(jd: f64) -> Self {
Epoch::<Tai>::from_jd_raw(jd)
}
pub fn to_tt(&self) -> Epoch<Tt> {
Epoch::<Tt>::from_jd_raw(self.jd + TT_MINUS_TAI_SEC / 86400.0)
}
pub fn to_utc(&self) -> Epoch<Utc> {
let mut guess_utc_jd = self.jd - 37.0 / 86400.0; for _ in 0..3 {
let guess_mjd = guess_utc_jd - MJD_OFFSET;
let leap = tai_minus_utc_at_mjd(guess_mjd);
guess_utc_jd = self.jd - leap / 86400.0;
}
Epoch::<Utc>::from_jd_raw(guess_utc_jd)
}
}
impl Epoch<Tt> {
pub fn from_jd_tt(jd: f64) -> Self {
Epoch::<Tt>::from_jd_raw(jd)
}
pub fn centuries_since_j2000(&self) -> f64 {
(self.jd - J2000_JD) / JULIAN_CENTURY
}
pub fn to_tai(&self) -> Epoch<Tai> {
Epoch::<Tai>::from_jd_raw(self.jd - TT_MINUS_TAI_SEC / 86400.0)
}
pub fn to_tdb(&self) -> Epoch<Tdb> {
let delta = tdb_minus_tt(self.jd);
Epoch::<Tdb>::from_jd_raw(self.jd + delta / 86400.0)
}
}
impl Epoch<Tdb> {
pub fn from_jd_tdb(jd: f64) -> Self {
Epoch::<Tdb>::from_jd_raw(jd)
}
pub fn centuries_since_j2000(&self) -> f64 {
(self.jd - J2000_JD) / JULIAN_CENTURY
}
pub fn to_tt(&self) -> Epoch<Tt> {
let delta = tdb_minus_tt(self.jd);
Epoch::<Tt>::from_jd_raw(self.jd - delta / 86400.0)
}
}
impl Epoch<Ut1> {
pub fn from_jd_ut1(jd: f64) -> Self {
Epoch::<Ut1>::from_jd_raw(jd)
}
pub fn era(&self) -> f64 {
era_formula(self.jd)
}
}
fn era_formula(ut1_jd: f64) -> f64 {
let du = ut1_jd - J2000_JD;
let era = TAU * (0.7790572732640 + 1.002_737_811_911_354_6 * du);
let era = era % TAU;
if era < 0.0 { era + TAU } else { era }
}
fn to_datetime_from_jd(jd: f64) -> DateTime {
let jd = jd + 0.5;
let z = jd.floor() as i64;
let f = jd - z as f64;
let a = if z < 2299161 {
z
} else {
let alpha = ((z as f64 - 1867216.25) / 36524.25).floor() as i64;
z + 1 + alpha - alpha / 4
};
let b = a + 1524;
let c = ((b as f64 - 122.1) / 365.25).floor() as i64;
let d = (365.25 * c as f64).floor() as i64;
let e = ((b - d) as f64 / 30.6001).floor() as i64;
let day = (b - d - (30.6001 * e as f64).floor() as i64) as u32;
let month = if e < 14 { e - 1 } else { e - 13 } as u32;
let year = if month > 2 { c - 4716 } else { c - 4715 } as i32;
let hours_total = f * 24.0;
let hour = hours_total.floor() as u32;
let mins_total = (hours_total - hour as f64) * 60.0;
let min = mins_total.floor() as u32;
let sec = (mins_total - min as f64) * 60.0;
DateTime {
year,
month,
day,
hour,
min,
sec,
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Duration {
si_seconds: f64,
}
impl Duration {
pub const fn from_si_seconds(s: f64) -> Self {
Duration { si_seconds: s }
}
pub const fn from_minutes(m: f64) -> Self {
Duration {
si_seconds: m * 60.0,
}
}
pub const fn from_hours(h: f64) -> Self {
Duration {
si_seconds: h * 3600.0,
}
}
pub fn as_si_seconds(&self) -> f64 {
self.si_seconds
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mjd_jd_relationship() {
let epoch = Epoch::from_jd(2451545.0);
assert!((epoch.mjd() - 51544.5).abs() < 1e-12);
}
#[test]
fn scale_name_via_type() {
assert_eq!(Epoch::<Utc>::scale_name(), "UTC");
assert_eq!(Epoch::<Tai>::scale_name(), "TAI");
assert_eq!(Epoch::<Tt>::scale_name(), "TT");
assert_eq!(Epoch::<Ut1>::scale_name(), "UT1");
assert_eq!(Epoch::<Tdb>::scale_name(), "TDB");
}
#[test]
fn j2000_gregorian() {
let epoch = Epoch::from_gregorian(2000, 1, 1, 12, 0, 0.0);
assert!(
(epoch.jd() - J2000_JD).abs() < 1e-6,
"J2000 JD: expected {}, got {}",
J2000_JD,
epoch.jd()
);
}
#[test]
fn known_date_2024_march_equinox() {
let epoch = Epoch::from_gregorian(2024, 3, 20, 12, 0, 0.0);
let expected_jd = 2460390.0;
assert!(
(epoch.jd() - expected_jd).abs() < 0.01,
"2024-03-20 JD: expected ~{}, got {}",
expected_jd,
epoch.jd()
);
}
#[test]
fn gregorian_roundtrip() {
let original = Epoch::from_gregorian(2024, 6, 21, 15, 30, 45.0);
let dt = original.to_datetime();
assert_eq!(dt.year, 2024);
assert_eq!(dt.month, 6);
assert_eq!(dt.day, 21);
assert_eq!(dt.hour, 15);
assert_eq!(dt.min, 30);
assert!(
(dt.sec - 45.0).abs() < 0.01,
"sec: expected 45.0, got {}",
dt.sec
);
}
#[test]
fn gregorian_roundtrip_january() {
let original = Epoch::from_gregorian(2024, 1, 15, 0, 0, 0.0);
let dt = original.to_datetime();
assert_eq!(dt.year, 2024);
assert_eq!(dt.month, 1);
assert_eq!(dt.day, 15);
assert_eq!(dt.hour, 0);
assert_eq!(dt.min, 0);
}
#[test]
fn gregorian_roundtrip_february() {
let original = Epoch::from_gregorian(2024, 2, 29, 6, 0, 0.0);
let dt = original.to_datetime();
assert_eq!(dt.year, 2024);
assert_eq!(dt.month, 2);
assert_eq!(dt.day, 29);
assert_eq!(dt.hour, 6);
}
#[test]
fn datetime_display() {
let dt = DateTime::new(2024, 3, 20, 12, 0, 0.0);
assert_eq!(dt.to_string(), "2024-03-20T12:00:00Z");
}
#[test]
fn from_datetime_roundtrip() {
let dt = DateTime::new(2024, 6, 21, 15, 30, 45.0);
let epoch = Epoch::from_datetime(&dt);
let rt = epoch.to_datetime();
assert_eq!(rt.year, dt.year);
assert_eq!(rt.month, dt.month);
assert_eq!(rt.day, dt.day);
assert_eq!(rt.hour, dt.hour);
assert_eq!(rt.min, dt.min);
assert!((rt.sec - dt.sec).abs() < 0.01);
}
#[test]
fn add_seconds_one_day() {
let epoch = Epoch::j2000();
let next_day = epoch.add_seconds(86400.0);
assert!(
(next_day.jd() - (J2000_JD + 1.0)).abs() < 1e-12,
"add 86400s: expected JD {}, got {}",
J2000_JD + 1.0,
next_day.jd()
);
}
#[test]
fn now_returns_reasonable_jd() {
let epoch = Epoch::now();
assert!(
epoch.jd() > 2460676.0 && epoch.jd() < 2462502.0,
"Epoch::now() JD {} is outside 2025–2030 range",
epoch.jd()
);
let dt = epoch.to_datetime();
assert!(
dt.year >= 2025 && dt.year <= 2030,
"Epoch::now() year {} is outside expected range",
dt.year
);
}
#[test]
fn add_seconds_one_hour() {
let epoch = Epoch::j2000();
let plus_hour = epoch.add_seconds(3600.0);
let expected = J2000_JD + 1.0 / 24.0;
assert!((plus_hour.jd() - expected).abs() < 1e-12);
}
#[test]
fn centuries_since_j2000() {
let epoch = Epoch::j2000();
assert!((epoch.centuries_since_j2000() - 0.0).abs() < 1e-15);
let later = Epoch::from_jd(J2000_JD + JULIAN_CENTURY);
assert!((later.centuries_since_j2000() - 1.0).abs() < 1e-12);
}
#[test]
fn leap_second_2017_crossing_si_arithmetic() {
let before = Epoch::<Utc>::from_iso8601("2016-12-31T23:59:55Z").unwrap();
let naive = before.add_seconds(10.0);
let aware = before.add_si_seconds(10.0);
let dt_naive = naive.to_datetime();
assert_eq!(dt_naive.year, 2017);
assert_eq!(dt_naive.month, 1);
assert_eq!(dt_naive.day, 1);
assert_eq!(dt_naive.hour, 0);
assert_eq!(dt_naive.min, 0);
assert!((dt_naive.sec - 5.0).abs() < 0.01);
let dt_aware = aware.to_datetime();
assert_eq!(dt_aware.year, 2017);
assert_eq!(dt_aware.month, 1);
assert_eq!(dt_aware.day, 1);
assert_eq!(dt_aware.hour, 0);
assert_eq!(dt_aware.min, 0);
assert!(
(dt_aware.sec - 4.0).abs() < 0.01,
"add_si_seconds should absorb leap second: expected ~4.0 s, got {}",
dt_aware.sec
);
}
#[test]
fn utc_to_tdb_applies_expected_offset_2024() {
let utc = Epoch::<Utc>::from_iso8601("2024-03-20T12:00:00Z").unwrap();
let tdb = utc.to_tdb();
let delta_sec = (tdb.jd() - utc.jd()) * 86400.0;
let expected_sec = 37.0 + TT_MINUS_TAI_SEC;
assert!(
(delta_sec - expected_sec).abs() < 0.01,
"TDB - UTC at 2024-03-20: expected ~{} s, got {} s",
expected_sec,
delta_sec
);
}
#[test]
fn utc_tdb_tt_tai_roundtrip() {
let original = Epoch::<Utc>::from_iso8601("2024-06-15T08:30:45Z").unwrap();
let tdb = original.to_tdb();
let tt = tdb.to_tt();
let tai = tt.to_tai();
let utc = tai.to_utc();
assert!(
(utc.jd() - original.jd()).abs() < 1e-10,
"UTC→TDB→TT→TAI→UTC roundtrip diverged: original={} recovered={}",
original.jd(),
utc.jd()
);
}
#[test]
fn centuries_since_j2000_differs_per_scale() {
let utc = Epoch::<Utc>::from_iso8601("2024-03-20T12:00:00Z").unwrap();
let tt = utc.to_tt();
let tdb = utc.to_tdb();
let c_utc = utc.centuries_since_j2000();
let c_tt = tt.centuries_since_j2000();
let c_tdb = tdb.centuries_since_j2000();
let dc_tt_utc = c_tt - c_utc;
let expected_tt_utc = 69.184 / (86400.0 * 36525.0);
assert!(
(dc_tt_utc - expected_tt_utc).abs() < 1e-14,
"TT - UTC centuries: expected {:e}, got {:e}",
expected_tt_utc,
dc_tt_utc
);
let dc_tdb_tt = c_tdb - c_tt;
assert!(
dc_tdb_tt.abs() < 1e-11,
"TDB - TT centuries should be ~ms → ~5e-13 scale, got {:e}",
dc_tdb_tt
);
assert!(
dc_tdb_tt.abs() < dc_tt_utc.abs() * 0.001,
"TDB - TT should be much smaller than TT - UTC: dc_tdb_tt={:e}, dc_tt_utc={:e}",
dc_tdb_tt,
dc_tt_utc
);
}
#[test]
fn iso8601_valid() {
let epoch = Epoch::from_iso8601("2024-03-20T12:00:00Z").unwrap();
let expected = Epoch::from_gregorian(2024, 3, 20, 12, 0, 0.0);
assert!(
(epoch.jd() - expected.jd()).abs() < 1e-10,
"ISO parse mismatch"
);
}
#[test]
fn iso8601_with_seconds() {
let epoch = Epoch::from_iso8601("2000-01-01T12:00:00Z").unwrap();
assert!((epoch.jd() - J2000_JD).abs() < 1e-6);
}
#[test]
fn iso8601_invalid_no_z() {
assert!(Epoch::from_iso8601("2024-03-20T12:00:00").is_none());
}
#[test]
fn iso8601_invalid_format() {
assert!(Epoch::from_iso8601("not-a-date").is_none());
assert!(Epoch::from_iso8601("2024-13-01T00:00:00Z").is_none()); assert!(Epoch::from_iso8601("2024-01-32T00:00:00Z").is_none()); }
#[test]
fn gmst_at_j2000() {
let epoch = Epoch::j2000();
let gmst = epoch.gmst();
let expected = TAU * 0.7790572732640;
assert!(
(gmst - expected).abs() < 0.01,
"GMST at J2000: expected {:.4} rad, got {:.4} rad",
expected,
gmst
);
}
#[test]
fn gmst_increases_one_sidereal_day() {
let epoch = Epoch::j2000();
let gmst0 = epoch.gmst();
let next_day = epoch.add_seconds(86400.0);
let gmst1 = next_day.gmst();
let delta = if gmst1 > gmst0 {
gmst1 - gmst0
} else {
gmst1 + TAU - gmst0
};
let expected_delta = TAU * 1.002_737_811_911_354_6;
let expected_delta_mod = expected_delta % TAU;
assert!(
(delta - expected_delta_mod).abs() < 0.001,
"GMST daily increase: expected {:.6} rad, got {:.6} rad",
expected_delta_mod,
delta
);
}
#[test]
fn gmst_normalized() {
for days in [0.0, 0.5, 1.0, 100.0, 365.25, 3652.5] {
let epoch = Epoch::j2000().add_seconds(days * 86400.0);
let gmst = epoch.gmst();
assert!(
gmst >= 0.0 && gmst < TAU,
"GMST at +{days} days: {gmst} not in [0, 2π)"
);
}
}
#[test]
fn era_on_ut1_matches_legacy_gmst() {
let utc = Epoch::<Utc>::from_gregorian(2024, 3, 20, 12, 0, 0.0);
let ut1 = utc.to_ut1_naive();
assert_eq!(ut1.era(), utc.gmst());
}
#[test]
fn to_ut1_applies_dut1_offset() {
struct FixedDut1(f64);
impl crate::earth::eop::Ut1Offset for FixedDut1 {
fn dut1(&self, _utc_mjd: f64) -> f64 {
self.0
}
}
let utc = Epoch::<Utc>::from_gregorian(2024, 3, 20, 12, 0, 0.0);
let eop = FixedDut1(-0.250);
let ut1 = utc.to_ut1(&eop);
let delta_s = (ut1.jd() - utc.jd()) * 86400.0;
assert!(
(delta_s - (-0.250)).abs() < 1e-4,
"expected -0.250 s shift, got {delta_s}"
);
}
#[test]
fn to_ut1_naive_is_equivalent_to_zero_dut1_provider() {
struct ZeroDut1;
impl crate::earth::eop::Ut1Offset for ZeroDut1 {
fn dut1(&self, _utc_mjd: f64) -> f64 {
0.0
}
}
let utc = Epoch::<Utc>::from_gregorian(2024, 3, 20, 12, 0, 0.0);
let naive = utc.to_ut1_naive();
let precise = utc.to_ut1(&ZeroDut1);
assert_eq!(naive.jd(), precise.jd());
}
#[test]
fn to_ut1_accepts_trait_object_provider() {
struct Fixed(f64);
impl crate::earth::eop::Ut1Offset for Fixed {
fn dut1(&self, _: f64) -> f64 {
self.0
}
}
let utc = Epoch::<Utc>::from_gregorian(2024, 3, 20, 12, 0, 0.0);
let boxed: Box<dyn crate::earth::eop::Ut1Offset> = Box::new(Fixed(-0.100));
let _ut1_box: Epoch<Ut1> = utc.to_ut1(boxed.as_ref());
let dyn_ref: &dyn crate::earth::eop::Ut1Offset = &Fixed(-0.100);
let _ut1_dyn: Epoch<Ut1> = utc.to_ut1(dyn_ref);
}
#[test]
fn to_ut1_passes_utc_mjd_to_provider() {
use std::cell::Cell;
struct Recording(Cell<f64>);
impl crate::earth::eop::Ut1Offset for Recording {
fn dut1(&self, utc_mjd: f64) -> f64 {
self.0.set(utc_mjd);
0.0
}
}
let utc = Epoch::<Utc>::from_gregorian(2024, 1, 1, 0, 0, 0.0);
let r = Recording(Cell::new(f64::NAN));
let _ = utc.to_ut1(&r);
assert_eq!(r.0.get(), utc.mjd());
}
#[test]
fn tle_epoch_iss_2024() {
let epoch = Epoch::from_tle_epoch(24, 79.5);
let dt = epoch.to_datetime();
assert_eq!(dt.year, 2024);
assert_eq!(dt.month, 3);
assert_eq!(dt.day, 19);
assert_eq!(dt.hour, 12);
}
#[test]
fn tle_epoch_year_2000() {
let epoch = Epoch::from_tle_epoch(0, 1.0);
let dt = epoch.to_datetime();
assert_eq!(dt.year, 2000);
assert_eq!(dt.month, 1);
assert_eq!(dt.day, 1);
assert_eq!(dt.hour, 0);
}
#[test]
fn tle_epoch_year_1999() {
let epoch = Epoch::from_tle_epoch(99, 365.0);
let dt = epoch.to_datetime();
assert_eq!(dt.year, 1999);
assert_eq!(dt.month, 12);
assert_eq!(dt.day, 31);
}
#[test]
fn tle_epoch_year_57() {
let epoch = Epoch::from_tle_epoch(57, 1.0);
let dt = epoch.to_datetime();
assert_eq!(dt.year, 1957);
assert_eq!(dt.month, 1);
assert_eq!(dt.day, 1);
}
#[test]
fn tle_epoch_year_56() {
let epoch = Epoch::from_tle_epoch(56, 1.0);
let dt = epoch.to_datetime();
assert_eq!(dt.year, 2056);
}
#[test]
fn tle_epoch_matches_iso8601() {
let tle_epoch = Epoch::from_tle_epoch(24, 1.5);
let iso_epoch = Epoch::from_iso8601("2024-01-01T12:00:00Z").unwrap();
assert!(
(tle_epoch.jd() - iso_epoch.jd()).abs() < 1e-6,
"TLE epoch {} vs ISO epoch {}",
tle_epoch.jd(),
iso_epoch.jd()
);
}
#[test]
fn jd_to_utc_string_j2000() {
let s = Epoch::from_jd(J2000_JD).to_datetime().to_string();
assert_eq!(s, "2000-01-01T12:00:00Z");
}
#[test]
fn jd_to_utc_string_2024_march() {
let s = Epoch::from_jd(2460390.0).to_datetime().to_string();
assert_eq!(s, "2024-03-20T12:00:00Z");
}
#[test]
fn jd_to_utc_string_with_offset_1h() {
let s = Epoch::from_jd(J2000_JD)
.add_seconds(3600.0)
.to_datetime()
.to_string();
assert_eq!(s, "2000-01-01T13:00:00Z");
}
#[test]
fn jd_to_utc_string_with_offset_1day() {
let s = Epoch::from_jd(J2000_JD)
.add_seconds(86400.0)
.to_datetime()
.to_string();
assert_eq!(s, "2000-01-02T12:00:00Z");
}
#[test]
fn jd_to_utc_string_no_fractional_seconds() {
let s = Epoch::from_jd(J2000_JD)
.add_seconds(0.5)
.to_datetime()
.to_string();
assert!(
s.ends_with("Z") && !s.contains('.'),
"Should not contain fractional seconds: {s}"
);
}
#[test]
fn gmst_works_with_simple_eci_ecef() {
use crate::SimpleEci;
use crate::frame::{
Rotation, SimpleEcef as SimpleEcefMarker, SimpleEci as SimpleEciMarker,
};
let epoch = Epoch::from_gregorian(2024, 6, 21, 12, 0, 0.0);
let era = epoch.gmst();
let eci = SimpleEci::new(7000.0, 1000.0, 500.0);
let ecef = Rotation::<SimpleEciMarker, SimpleEcefMarker>::from_era(era).transform(&eci);
let roundtrip =
Rotation::<SimpleEcefMarker, SimpleEciMarker>::from_era(era).transform(&ecef);
let eps = 1e-10;
assert!((roundtrip.x() - eci.x()).abs() < eps);
assert!((roundtrip.y() - eci.y()).abs() < eps);
assert!((roundtrip.z() - eci.z()).abs() < eps);
}
#[test]
fn leap_second_table_monotonic() {
let mut prev_mjd = 0.0;
let mut prev_offset = 0.0;
for &(mjd, offset) in LEAP_SECONDS {
assert!(mjd > prev_mjd, "Leap table MJD not monotonic: {mjd}");
assert!(offset > prev_offset, "Leap offset not monotonic: {offset}");
prev_mjd = mjd;
prev_offset = offset;
}
}
#[test]
fn leap_second_2024_is_37() {
assert_eq!(tai_minus_utc_at_mjd(60000.0), 37.0);
}
#[test]
fn leap_second_before_1972_is_10() {
assert_eq!(tai_minus_utc_at_mjd(40000.0), 10.0);
}
#[test]
fn duration_si_seconds() {
assert_eq!(Duration::from_si_seconds(60.0).as_si_seconds(), 60.0);
assert_eq!(Duration::from_minutes(1.0).as_si_seconds(), 60.0);
assert_eq!(Duration::from_hours(1.0).as_si_seconds(), 3600.0);
}
}