pub const JD_J2000: f64 = 2_451_545.0;
pub const MJD_OFFSET: f64 = 2_400_000.5;
pub const TT_MINUS_TAI: f64 = 32.184;
pub const SECONDS_PER_DAY: f64 = 86_400.0;
#[allow(clippy::excessive_precision)]
pub const ERA_TURNS_AT_J2000: f64 = 0.7790572732640;
#[allow(clippy::excessive_precision)]
pub const ERA_TURNS_PER_UT1_DAY: f64 = 1.00273781191135448;
pub fn julian_date(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: f64) -> f64 {
let (y, m) = if month <= 2 {
(year - 1, month as i32 + 12)
} else {
(year, month as i32)
};
let a = (y as f64 / 100.0).floor();
let b = 2.0 - a + (a / 4.0).floor();
let day_fraction =
day as f64 + (hour as f64 * 3600.0 + minute as f64 * 60.0 + second) / SECONDS_PER_DAY;
(365.25 * (y as f64 + 4716.0)).floor() + (30.6001 * (m as f64 + 1.0)).floor() + day_fraction + b
- 1524.5
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CivilTime {
pub year: i32,
pub month: u32,
pub day: u32,
pub hour: u32,
pub minute: u32,
pub second: f64,
}
pub fn civil_from_jd(jd: f64) -> CivilTime {
let jd2 = jd + 0.5;
let z = jd2.floor();
let f = jd2 - z;
let a = if z < 2_299_161.0 {
z
} else {
let alpha = ((z - 1_867_216.25) / 36_524.25).floor();
z + 1.0 + alpha - (alpha / 4.0).floor()
};
let b = a + 1524.0;
let c = ((b - 122.1) / 365.25).floor();
let d = (365.25 * c).floor();
let e = ((b - d) / 30.6001).floor();
let day_with_frac = b - d - (30.6001 * e).floor() + f;
let day = day_with_frac.floor();
let month = if e < 14.0 { e - 1.0 } else { e - 13.0 };
let year = if month > 2.0 { c - 4716.0 } else { c - 4715.0 };
let mut secs = (day_with_frac - day) * SECONDS_PER_DAY;
if secs < 0.0 {
secs = 0.0;
}
let hour = (secs / 3600.0).floor();
secs -= hour * 3600.0;
let minute = (secs / 60.0).floor();
secs -= minute * 60.0;
CivilTime {
year: year as i32,
month: month as u32,
day: day as u32,
hour: hour as u32,
minute: minute as u32,
second: secs,
}
}
pub fn mjd_from_jd(jd: f64) -> f64 {
jd - MJD_OFFSET
}
const LEAP_TABLE: &[(i32, u32, u32, f64)] = &[
(1972, 1, 1, 10.0),
(1972, 7, 1, 11.0),
(1973, 1, 1, 12.0),
(1974, 1, 1, 13.0),
(1975, 1, 1, 14.0),
(1976, 1, 1, 15.0),
(1977, 1, 1, 16.0),
(1978, 1, 1, 17.0),
(1979, 1, 1, 18.0),
(1980, 1, 1, 19.0),
(1981, 7, 1, 20.0),
(1982, 7, 1, 21.0),
(1983, 7, 1, 22.0),
(1985, 7, 1, 23.0),
(1988, 1, 1, 24.0),
(1990, 1, 1, 25.0),
(1991, 1, 1, 26.0),
(1992, 7, 1, 27.0),
(1993, 7, 1, 28.0),
(1994, 7, 1, 29.0),
(1996, 1, 1, 30.0),
(1997, 7, 1, 31.0),
(1999, 1, 1, 32.0),
(2006, 1, 1, 33.0),
(2009, 1, 1, 34.0),
(2012, 7, 1, 35.0),
(2015, 7, 1, 36.0),
(2017, 1, 1, 37.0),
];
pub fn tai_minus_utc(jd_utc: f64) -> f64 {
let mut secs = LEAP_TABLE[0].3;
for &(y, m, d, s) in LEAP_TABLE {
let jd_entry = julian_date(y, m, d, 0, 0, 0.0);
if jd_utc >= jd_entry {
secs = s;
} else {
break;
}
}
secs
}
pub fn utc_to_tai(jd_utc: f64) -> f64 {
jd_utc + tai_minus_utc(jd_utc) / SECONDS_PER_DAY
}
pub fn tai_to_tt(jd_tai: f64) -> f64 {
jd_tai + TT_MINUS_TAI / SECONDS_PER_DAY
}
pub fn utc_to_tt(jd_utc: f64) -> f64 {
tai_to_tt(utc_to_tai(jd_utc))
}
pub const TAI_MINUS_GPS: f64 = 19.0;
pub fn gps_to_tt(jd_gps: f64) -> f64 {
jd_gps + (TAI_MINUS_GPS + TT_MINUS_TAI) / SECONDS_PER_DAY
}
pub fn utc_to_ut1(jd_utc: f64, dut1_seconds: f64) -> f64 {
jd_utc + dut1_seconds / SECONDS_PER_DAY
}
pub fn earth_rotation_angle(jd_ut1: f64) -> f64 {
let tu = jd_ut1 - JD_J2000;
let turns = ERA_TURNS_AT_J2000 + ERA_TURNS_PER_UT1_DAY * tu;
let frac = turns - turns.floor(); 2.0 * std::f64::consts::PI * frac
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TwoPartJd {
pub day: f64,
pub frac: f64,
}
#[inline]
fn two_sum(a: f64, b: f64) -> (f64, f64) {
let s = a + b;
let bb = s - a;
let err = (a - (s - bb)) + (b - bb);
(s, err)
}
impl TwoPartJd {
pub fn from_f64(jd: f64) -> Self {
let day = jd.round();
let frac = jd - day;
TwoPartJd { day, frac }
}
pub fn new(day: f64, frac: f64) -> Self {
TwoPartJd { day, frac }.normalised()
}
pub fn to_f64(self) -> f64 {
self.day + self.frac
}
fn normalised(self) -> Self {
let carry = self.frac.round();
let rem = self.frac - carry; let (day, err) = two_sum(self.day, carry);
TwoPartJd {
day,
frac: rem + err,
}
}
pub fn add_seconds(self, dt_s: f64) -> Self {
let ddays = dt_s / SECONDS_PER_DAY;
let (frac, err) = two_sum(self.frac, ddays);
TwoPartJd {
day: self.day,
frac: frac + err,
}
.normalised()
}
pub fn diff_seconds(self, other: TwoPartJd) -> f64 {
let dday = self.day - other.day;
let dfrac = self.frac - other.frac;
(dday + dfrac) * SECONDS_PER_DAY
}
}
pub fn tdb_minus_tt(jd_tt: f64) -> f64 {
let g_deg = 357.53 + 0.985_600_28 * (jd_tt - JD_J2000);
let g = g_deg.to_radians();
0.001_657 * g.sin() + 0.000_022 * (2.0 * g).sin()
}
pub fn tt_to_tdb(jd_tt: f64) -> f64 {
jd_tt + tdb_minus_tt(jd_tt) / SECONDS_PER_DAY
}
pub fn tdb_to_tt(jd_tdb: f64) -> f64 {
jd_tdb - tdb_minus_tt(jd_tdb) / SECONDS_PER_DAY
}
#[cfg(test)]
mod tests {
use super::*;
const EPS_JD: f64 = 1e-9;
#[test]
fn j2000_epoch_is_2451545() {
assert!((julian_date(2000, 1, 1, 12, 0, 0.0) - JD_J2000).abs() < EPS_JD);
assert!((julian_date(2000, 1, 1, 0, 0, 0.0) - 2_451_544.5).abs() < EPS_JD);
}
#[test]
fn known_julian_dates() {
assert!((julian_date(1957, 10, 4, 19, 26, 24.0) - 2_436_116.31).abs() < 1e-2); assert!((julian_date(2000, 1, 1, 0, 0, 0.0) - 2_451_544.5).abs() < EPS_JD);
assert!((julian_date(1999, 1, 1, 0, 0, 0.0) - 2_451_179.5).abs() < EPS_JD);
assert!((julian_date(1987, 1, 27, 0, 0, 0.0) - 2_446_822.5).abs() < EPS_JD);
assert!(mjd_from_jd(julian_date(1858, 11, 17, 0, 0, 0.0)).abs() < EPS_JD);
}
#[test]
fn julian_date_round_trips_through_civil() {
for &(y, mo, d, h, mi, s) in &[
(2000, 1, 1, 12, 0, 0.0),
(2026, 6, 2, 17, 45, 30.0),
(1972, 1, 1, 0, 0, 0.0),
(1999, 12, 31, 23, 59, 59.0),
(2024, 2, 29, 6, 30, 0.0), ] {
let jd = julian_date(y, mo, d, h, mi, s);
let c = civil_from_jd(jd);
assert_eq!(
(c.year, c.month, c.day, c.hour, c.minute),
(y, mo, d, h, mi)
);
assert!(
(c.second - s).abs() < 1e-3,
"second mismatch for {y}-{mo}-{d}: {} vs {s}",
c.second
);
}
}
#[test]
fn leap_seconds_match_iers_history() {
let at = |y, m, d| tai_minus_utc(julian_date(y, m, d, 0, 0, 0.0));
assert_eq!(at(1972, 1, 1), 10.0);
assert_eq!(at(1999, 1, 1), 32.0);
assert_eq!(at(2006, 1, 1), 33.0);
assert_eq!(at(2009, 1, 1), 34.0);
assert_eq!(at(2015, 7, 1), 36.0);
assert_eq!(at(2017, 1, 1), 37.0);
assert_eq!(at(2026, 6, 2), 37.0); assert_eq!(at(2016, 12, 31), 36.0);
assert_eq!(at(1998, 6, 1), 31.0);
assert_eq!(at(1970, 1, 1), 10.0);
}
#[test]
fn tt_minus_utc_is_leap_plus_offset() {
let jd_utc = julian_date(2020, 1, 1, 0, 0, 0.0);
let tt = utc_to_tt(jd_utc);
let delta_s = (tt - jd_utc) * SECONDS_PER_DAY;
assert!(
(delta_s - (37.0 + 32.184)).abs() < 1e-4,
"TT-UTC = {delta_s}"
);
let jd_tai = utc_to_tai(jd_utc);
assert!(((tai_to_tt(jd_tai) - jd_tai) * SECONDS_PER_DAY - 32.184).abs() < 1e-4);
}
#[test]
fn ut1_applies_dut1() {
let jd_utc = julian_date(2020, 1, 1, 0, 0, 0.0);
let dut1 = -0.1772; let jd_ut1 = utc_to_ut1(jd_utc, dut1);
assert!(((jd_ut1 - jd_utc) * SECONDS_PER_DAY - dut1).abs() < 1e-4);
}
#[test]
fn era_at_j2000_matches_iau_value() {
let era = earth_rotation_angle(JD_J2000);
let expect = 2.0 * std::f64::consts::PI * ERA_TURNS_AT_J2000;
assert!((era - expect).abs() < 1e-12, "ERA(J2000) = {era}");
let deg = era.to_degrees();
assert!((deg - 280.4606).abs() < 1e-3, "ERA(J2000) = {deg} deg");
}
#[test]
fn era_advances_one_sidereal_turn_per_ut1_day() {
let jd = JD_J2000 + 100.0;
let d0 = earth_rotation_angle(jd);
let d1 = earth_rotation_angle(jd + 1.0);
let two_pi = 2.0 * std::f64::consts::PI;
let mut delta = d1 - d0;
if delta < 0.0 {
delta += two_pi; }
let expect = two_pi * ERA_TURNS_PER_UT1_DAY.fract();
assert!(
(delta - expect).abs() < 1e-9,
"daily ERA advance = {delta}, want {expect}"
);
assert!((0.0..two_pi).contains(&d0) && (0.0..two_pi).contains(&d1));
}
#[test]
fn two_part_roundtrip() {
for &jd in &[
JD_J2000,
2_451_544.5,
2_460_000.123_456_789,
2_436_116.31, MJD_OFFSET,
0.0,
2_488_069.75, ] {
let t = TwoPartJd::from_f64(jd);
assert!(t.frac.abs() <= 0.5, "frac not normalised: {}", t.frac);
assert_eq!(t.to_f64(), jd, "roundtrip jd={jd}");
assert!((t.to_f64() - jd).abs() <= f64::EPSILON * jd.abs().max(1.0));
}
}
#[test]
fn two_part_subus_precision() {
let jd = 2_451_545.123_456;
let dt = 1e-6;
let base = TwoPartJd::from_f64(jd);
let moved = base.add_seconds(dt);
let recovered = moved.diff_seconds(base);
assert!(
(recovered - dt).abs() < 1e-12,
"two-part lost the microsecond: recovered {recovered} s, want {dt} s"
);
let naive = ((jd + dt / SECONDS_PER_DAY) - jd) * SECONDS_PER_DAY;
let naive_err = (naive - dt).abs();
let two_part_err = (recovered - dt).abs();
assert!(
naive_err > two_part_err * 1e3,
"expected naive f64 to lose precision the two-part form keeps: \
naive_err={naive_err} s, two_part_err={two_part_err} s"
);
}
#[test]
fn add_then_diff_seconds() {
let base = TwoPartJd::from_f64(2_451_545.0);
for &n in &[1e-7, 1e-3, 1.0, 60.0, 3600.0, 86_400.0, 250_000.0] {
let moved = base.add_seconds(n);
let back = moved.diff_seconds(base);
assert!(
(back - n).abs() < 1e-6,
"single add/diff: n={n}, recovered {back}"
);
}
let step = 1.234_567;
let mut t = base;
for _ in 0..1000 {
t = t.add_seconds(step);
}
let total = t.diff_seconds(base);
assert!(
(total - 1000.0 * step).abs() < 1e-6,
"chained adds: total {total}, want {}",
1000.0 * step
);
assert!(t.frac.abs() <= 0.5);
let there = base.add_seconds(12_345.678);
let back = there.add_seconds(-12_345.678);
assert!(back.diff_seconds(base).abs() < 1e-9);
}
#[test]
fn two_part_new_normalises() {
let t = TwoPartJd::new(2_451_545.0, 3.75);
assert!(t.frac.abs() <= 0.5);
assert!((t.to_f64() - (2_451_545.0 + 3.75)).abs() < 1e-9);
let a = TwoPartJd::new(2_451_545.0, 0.25);
let b = TwoPartJd::new(2_451_540.0, 0.25);
assert!((a.diff_seconds(b) - 5.0 * SECONDS_PER_DAY).abs() < 1e-6);
}
#[test]
fn tdb_tt_bounded() {
for d in 0..=366 {
let jd = JD_J2000 + d as f64;
let delta_s = (tt_to_tdb(jd) - jd) * SECONDS_PER_DAY;
assert!(
delta_s.abs() < 1.7e-3,
"TDB-TT out of bound at +{d} d: {delta_s} s"
);
}
let at_j2000 = tdb_minus_tt(JD_J2000);
assert!(
at_j2000 < 0.0,
"expected negative TDB-TT just after perihelion-ish"
);
assert!(
(at_j2000 - (-7.330501e-5)).abs() < 1e-9,
"TDB-TT(J2000) = {at_j2000} s, want -7.330501e-5 s"
);
let applied = (tt_to_tdb(JD_J2000) - JD_J2000) * SECONDS_PER_DAY;
assert!((applied - at_j2000).abs() < 1e-4, "applied={applied}");
let tdb_tp = TwoPartJd::from_f64(JD_J2000).add_seconds(at_j2000);
let applied_tp = tdb_tp.diff_seconds(TwoPartJd::from_f64(JD_J2000));
assert!(
(applied_tp - at_j2000).abs() < 1e-12,
"two-part applied={applied_tp}"
);
}
#[test]
fn tdb_tt_roundtrip() {
for &jd in &[
JD_J2000,
JD_J2000 + 90.0,
JD_J2000 + 182.0,
2_460_000.5,
2_436_116.31,
] {
let jd_tdb = tt_to_tdb(jd);
let back = tdb_to_tt(jd_tdb);
assert!(
(back - jd).abs() < 1e-12,
"TT roundtrip jd={jd}: back={back}, err={} day",
(back - jd).abs()
);
}
}
}