use crate::error::GeomError;
pub const J2000: f64 = 2_451_545.0;
pub const JULIAN_CENTURY: f64 = 36_525.0;
pub fn julian_date(
year: i32,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: f64,
) -> Result<f64, GeomError> {
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return Err(GeomError::InvalidArgument("julian_date: bad month or day"));
}
if hour > 23 || minute > 59 || !(0.0..61.0).contains(&second) || !second.is_finite() {
return Err(GeomError::InvalidArgument("julian_date: bad time of day"));
}
let (y, m) = if month <= 2 { (year - 1, month + 12) } else { (year, month) };
let a = y.div_euclid(100);
let b = 2 - a + a.div_euclid(4);
let days = (365.25 * f64::from(y + 4716)).floor()
+ (30.6001 * f64::from(m + 1)).floor()
+ f64::from(day)
+ f64::from(b)
- 1524.5;
let fraction = (f64::from(hour) + f64::from(minute) / 60.0 + second / 3600.0) / 24.0;
Ok(days + fraction)
}
pub fn jd_to_calendar(jd: f64) -> Result<(i32, u32, u32, u32, u32, f64), GeomError> {
if !jd.is_finite() || !(-2e6..1e7).contains(&jd) {
return Err(GeomError::InvalidArgument("jd_to_calendar: the date is out of range"));
}
let shifted = jd + 0.5;
let z = shifted.floor();
let fraction = shifted - z;
let alpha = ((z - 1_867_216.25) / 36_524.25).floor();
let a = 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_fraction = b - d - (30.6001 * e).floor() + fraction;
let day = day_with_fraction.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 seconds = (day_with_fraction - day) * 86_400.0;
let hour = (seconds / 3600.0).floor();
seconds -= hour * 3600.0;
let minute = (seconds / 60.0).floor();
seconds -= minute * 60.0;
if !(0.0..2e6).contains(&year) && !(-2e6..2e6).contains(&year) {
return Err(GeomError::Degenerate("the year is out of range"));
}
Ok((
year as i32,
month as u32,
day as u32,
hour as u32,
minute as u32,
seconds,
))
}
pub fn gmst(jd: f64) -> Result<f64, GeomError> {
if !jd.is_finite() || !(-2e6..1e7).contains(&jd) {
return Err(GeomError::InvalidArgument("gmst: the date is out of range"));
}
let t = (jd - J2000) / JULIAN_CENTURY;
let seconds = 67_310.548_41
+ (876_600.0 * 3600.0 + 8_640_184.812_866) * t
+ 0.093_104 * t * t
- 6.2e-6 * t * t * t;
let turns = seconds / 86_400.0;
Ok(wrap_two_pi(turns.fract() * std::f64::consts::TAU))
}
pub fn local_sidereal(jd: f64, longitude: f64) -> Result<f64, GeomError> {
if !longitude.is_finite() {
return Err(GeomError::InvalidArgument("local_sidereal: the longitude is not finite"));
}
Ok(wrap_two_pi(gmst(jd)? + longitude))
}
pub fn tle_epoch_to_jd(epoch: f64) -> Result<f64, GeomError> {
if !epoch.is_finite() || !(0.0..100_000.0).contains(&epoch) {
return Err(GeomError::InvalidArgument("tle_epoch_to_jd: the epoch is out of range"));
}
let two_digit = (epoch / 1000.0).floor();
let day_of_year = epoch - two_digit * 1000.0;
if !(1.0..367.0).contains(&day_of_year) {
return Err(GeomError::InvalidArgument("tle_epoch_to_jd: bad day of year"));
}
let year = if two_digit < 57.0 { 2000.0 + two_digit } else { 1900.0 + two_digit };
let start = julian_date(year as i32 - 1, 12, 31, 0, 0, 0.0)?;
Ok(start + day_of_year)
}
fn wrap_two_pi(angle: f64) -> f64 {
let tau = std::f64::consts::TAU;
let wrapped = angle % tau;
if wrapped < 0.0 {
wrapped + tau
} else {
wrapped
}
}
#[cfg(test)]
mod tests {
use super::*;
const TAU: f64 = std::f64::consts::TAU;
#[test]
fn the_julian_date_of_j2000_is_the_number_the_epoch_is_defined_by() {
assert!((julian_date(2000, 1, 1, 12, 0, 0.0).unwrap() - J2000).abs() < 1e-9);
assert!((julian_date(2000, 1, 1, 0, 0, 0.0).unwrap() - (J2000 - 0.5)).abs() < 1e-9);
assert!((julian_date(1600, 1, 1, 0, 0, 0.0).unwrap() - 2_305_447.5).abs() < 1e-9);
assert!(
(julian_date(1957, 10, 4, 19, 28, 34.0).unwrap() - 2_436_116.311_5).abs() < 1e-3,
"Sputnik's launch came out at {}",
julian_date(1957, 10, 4, 19, 28, 34.0).unwrap()
);
}
#[test]
fn one_day_of_calendar_is_one_of_julian_date() {
for (y, m, d) in [
(2024, 2, 28),
(2023, 2, 28),
(1900, 2, 28),
(2000, 2, 28),
(1999, 12, 31),
(2026, 8, 25),
] {
let today = julian_date(y, m, d, 0, 0, 0.0).unwrap();
let tomorrow = jd_to_calendar(today + 1.0).unwrap();
let back = julian_date(tomorrow.0, tomorrow.1, tomorrow.2, 0, 0, 0.0).unwrap();
assert!((back - today - 1.0).abs() < 1e-9, "{y}-{m}-{d} plus a day went wrong");
}
let feb28 = julian_date(1900, 2, 28, 0, 0, 0.0).unwrap();
assert_eq!(jd_to_calendar(feb28 + 1.0).unwrap().1, 3, "1900 had a 29 February");
let feb28 = julian_date(2000, 2, 28, 0, 0, 0.0).unwrap();
assert_eq!(jd_to_calendar(feb28 + 1.0).unwrap().2, 29, "2000 had no 29 February");
}
#[test]
fn the_calendar_and_the_julian_date_invert_each_other() {
for (y, m, d, h, mi, s) in [
(2000, 1, 1, 12, 0, 0.0f64),
(1999, 12, 31, 23, 59, 59.0),
(2024, 2, 29, 6, 30, 15.5),
(1900, 3, 1, 0, 0, 0.0),
(1582, 10, 15, 0, 0, 0.0),
(1000, 6, 15, 18, 45, 30.0),
(-4712, 1, 1, 12, 0, 0.0),
] {
let jd = julian_date(y, m, d, h, mi, s).unwrap();
let (ry, rm, rd, rh, rmi, rs) = jd_to_calendar(jd).unwrap();
assert_eq!((ry, rm, rd, rh, rmi), (y, m, d, h, mi), "the date {y}-{m}-{d} came back wrong");
assert!((rs - s).abs() < 1e-3, "the seconds came back as {rs} not {s}");
}
}
#[test]
fn the_julian_date_runs_forward_with_the_clock() {
let mut previous = f64::NEG_INFINITY;
for (y, m, d, h) in [
(1999, 12, 31, 23),
(2000, 1, 1, 0),
(2000, 1, 1, 11),
(2000, 1, 1, 12),
(2000, 3, 1, 0),
(2001, 1, 1, 0),
] {
let jd = julian_date(y, m, d, h, 0, 0.0).unwrap();
assert!(jd > previous, "{y}-{m}-{d} {h}h did not follow its predecessor");
previous = jd;
}
let base = julian_date(2026, 5, 5, 0, 0, 0.0).unwrap();
let ulp = f64::EPSILON * base;
assert!(ulp > 1e-10 && ulp < 1e-9, "an ulp at this date is {ulp} days");
assert!((julian_date(2026, 5, 5, 1, 0, 0.0).unwrap() - base - 1.0 / 24.0).abs() < 4.0 * ulp);
assert!(
(julian_date(2026, 5, 5, 0, 1, 0.0).unwrap() - base - 1.0 / 1440.0).abs() < 4.0 * ulp
);
let early = julian_date(1, 1, 1, 0, 0, 0.0).unwrap();
assert!((julian_date(1, 1, 1, 1, 0, 0.0).unwrap() - early - 1.0 / 24.0).abs() < 1e-9);
assert!(julian_date(2026, 13, 1, 0, 0, 0.0).is_err());
assert!(julian_date(2026, 0, 1, 0, 0, 0.0).is_err());
assert!(julian_date(2026, 1, 32, 0, 0, 0.0).is_err());
assert!(julian_date(2026, 1, 1, 24, 0, 0.0).is_err());
assert!(julian_date(2026, 1, 1, 0, 60, 0.0).is_err());
assert!(jd_to_calendar(f64::NAN).is_err());
}
#[test]
fn sidereal_time_gains_four_minutes_on_the_clock_every_day() {
let a = gmst(J2000).unwrap();
let b = gmst(J2000 + 1.0).unwrap();
let gained = (b - a).rem_euclid(TAU);
let seconds = gained * 86_400.0 / TAU;
assert!((seconds - 236.5554).abs() < 0.01, "it gained {seconds} seconds");
let sidereal_day = 86_400.0 * TAU / (TAU + gained);
assert!((sidereal_day - 86_164.09).abs() < 0.02, "the sidereal day was {sidereal_day} s");
let hours = a * 12.0 / std::f64::consts::PI;
assert!((hours - 18.697_374_558).abs() < 1e-5, "GMST at J2000 was {hours} h");
for offset in [-40_000.0f64, -1.0, 0.0, 0.37, 1000.0, 40_000.0] {
let value = gmst(J2000 + offset).unwrap();
assert!((0.0..TAU).contains(&value), "GMST left its range at {offset}");
}
assert!(gmst(f64::INFINITY).is_err());
}
#[test]
fn local_sidereal_time_is_greenwichs_plus_the_longitude() {
for jd in [J2000, J2000 + 1234.5, J2000 - 9876.25] {
let greenwich = gmst(jd).unwrap();
assert!((local_sidereal(jd, 0.0).unwrap() - greenwich).abs() < 1e-15);
for longitude in [-3.0f64, -0.5, 0.5, 3.0] {
let local = local_sidereal(jd, longitude).unwrap();
let expected = (greenwich + longitude).rem_euclid(TAU);
assert!((local - expected).abs() < 1e-12, "{local} against {expected}");
assert!((0.0..TAU).contains(&local));
}
}
let hour = local_sidereal(J2000, 15f64.to_radians()).unwrap() - gmst(J2000).unwrap();
assert!((hour * 12.0 / std::f64::consts::PI - 1.0).abs() < 1e-12);
assert!(local_sidereal(J2000, f64::NAN).is_err());
}
#[test]
fn a_tle_epoch_resolves_its_two_digit_year_the_way_the_format_does() {
let (y, m, d, ..) = jd_to_calendar(tle_epoch_to_jd(24_001.0).unwrap()).unwrap();
assert_eq!((y, m, d), (2024, 1, 1), "24001 should be 1 January 2024");
let (y, m, d, ..) = jd_to_calendar(tle_epoch_to_jd(98_001.0).unwrap()).unwrap();
assert_eq!((y, m, d), (1998, 1, 1), "98001 should be 1 January 1998");
let (y, ..) = jd_to_calendar(tle_epoch_to_jd(56_001.0).unwrap()).unwrap();
assert_eq!(y, 2056);
let (y, ..) = jd_to_calendar(tle_epoch_to_jd(57_001.0).unwrap()).unwrap();
assert_eq!(y, 1957);
assert!(
(tle_epoch_to_jd(1.0).unwrap() - julian_date(2000, 1, 1, 0, 0, 0.0).unwrap()).abs()
< 1e-9
);
let noon = tle_epoch_to_jd(24_001.5).unwrap();
assert_eq!(jd_to_calendar(noon).unwrap().3, 12, "the half-day was not noon");
assert!(tle_epoch_to_jd(24_366.0).is_ok());
assert!(tle_epoch_to_jd(24_367.0).is_err());
assert!(tle_epoch_to_jd(24_000.5).is_err(), "there is no day zero");
assert!(tle_epoch_to_jd(-1.0).is_err());
}
}