use ::time::{Date, Month, OffsetDateTime, PrimitiveDateTime, Time};
use crate::angle::Angle;
use crate::coords::Epoch;
use crate::error::{Error, Result};
const MJD_JD_OFFSET: f64 = 2_400_000.5;
const MJD_EPOCH_JDN: i64 = 2_400_001;
const J2000_JD: f64 = 2_451_545.0;
const SECONDS_PER_DAY: f64 = 86_400.0;
const NANOS_PER_DAY: i64 = 86_400 * 1_000_000_000;
pub fn datetime_to_mjd(dt: PrimitiveDateTime) -> f64 {
let days = (i64::from(dt.date().to_julian_day()) - MJD_EPOCH_JDN) as f64;
let t = dt.time();
let day_seconds = f64::from(t.hour()) * 3_600.0
+ f64::from(t.minute()) * 60.0
+ f64::from(t.second())
+ f64::from(t.nanosecond()) / 1e9;
days + day_seconds / SECONDS_PER_DAY
}
pub fn mjd_to_datetime(mjd: f64) -> Result<PrimitiveDateTime> {
let out_of_range = || Error::OutOfRange {
what: "mjd",
value: mjd,
};
if !mjd.is_finite() {
return Err(out_of_range());
}
let day = mjd.floor();
let mut jdn = day as i64 + MJD_EPOCH_JDN;
let mut nanos = ((mjd - day) * SECONDS_PER_DAY * 1e9).round() as i64;
if nanos >= NANOS_PER_DAY {
nanos -= NANOS_PER_DAY;
jdn += 1;
}
let date = i32::try_from(jdn)
.ok()
.and_then(|j| Date::from_julian_day(j).ok())
.ok_or_else(out_of_range)?;
let hour = (nanos / 3_600_000_000_000) as u8;
let minute = ((nanos / 60_000_000_000) % 60) as u8;
let second = ((nanos / 1_000_000_000) % 60) as u8;
let nano = (nanos % 1_000_000_000) as u32;
let time = Time::from_hms_nano(hour, minute, second, nano)
.expect("components are in range by construction");
Ok(PrimitiveDateTime::new(date, time))
}
pub fn jd_to_mjd(jd: f64) -> f64 {
jd - MJD_JD_OFFSET
}
pub fn mjd_to_jd(mjd: f64) -> f64 {
mjd + MJD_JD_OFFSET
}
pub fn julian_date(at: OffsetDateTime) -> f64 {
2_440_587.5 + at.unix_timestamp_nanos() as f64 / (SECONDS_PER_DAY * 1e9)
}
pub fn parse_date_obs(s: &str) -> Result<PrimitiveDateTime> {
parse_date_obs_opt(s).ok_or_else(|| Error::ParseDate(s.trim().to_string()))
}
fn parse_date_obs_opt(s: &str) -> Option<PrimitiveDateTime> {
let t = s.trim().trim_matches('\'').trim();
let (date_part, time_part) = match t.split_once('T') {
Some((d, tm)) => (d, Some(tm)),
None => (t, None),
};
let mut d = date_part.split('-');
let year: i32 = d.next()?.parse().ok()?;
let month: u8 = d.next()?.parse().ok()?;
let day: u8 = d.next()?.parse().ok()?;
if d.next().is_some() {
return None;
}
let date = Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()?;
let time = match time_part {
None => Time::MIDNIGHT,
Some(tp) => {
let mut parts = tp.split(':');
let hour: u8 = parts.next()?.parse().ok()?;
let minute: u8 = parts.next()?.parse().ok()?;
let (sec, nanos) = match parts.next() {
None => (0u8, 0u32),
Some(sec_field) => {
let (whole, frac) = match sec_field.split_once('.') {
Some((w, f)) => (w, Some(f)),
None => (sec_field, None),
};
let sec: u8 = whole.parse().ok()?;
let nanos = match frac {
None => 0,
Some(f) => {
let mut digits: String = f.chars().take(9).collect();
while digits.len() < 9 {
digits.push('0');
}
digits.parse::<u32>().ok()?
}
};
(sec, nanos)
}
};
if parts.next().is_some() {
return None;
}
Time::from_hms_nano(hour, minute, sec, nanos).ok()?
}
};
Some(PrimitiveDateTime::new(date, time))
}
pub fn format_date_obs(dt: PrimitiveDateTime) -> String {
let (d, t) = (dt.date(), dt.time());
let base = format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
d.year(),
d.month() as u8,
d.day(),
t.hour(),
t.minute(),
t.second()
);
let nanos = t.nanosecond();
if nanos == 0 {
base
} else {
let frac = format!("{nanos:09}");
format!("{base}.{}", frac.trim_end_matches('0'))
}
}
pub fn julian_epoch_of(at: OffsetDateTime) -> Epoch {
Epoch::OfDate(2_000.0 + (julian_date(at) - J2000_JD) / 365.25)
}
pub fn gmst(at: OffsetDateTime) -> Angle {
let d = julian_date(at) - J2000_JD;
let t = d / 36_525.0;
let degrees =
280.460_618_37 + 360.985_647_366_29 * d + 0.000_387_933 * t * t - t * t * t / 38_710_000.0;
Angle::from_degrees(degrees).normalized_hours()
}
pub fn lst(at: OffsetDateTime, longitude_east: Angle) -> Angle {
(gmst(at) + longitude_east).normalized_hours()
}
#[cfg(test)]
mod tests {
use super::*;
use ::time::macros::datetime;
#[test]
fn date_only_is_midnight() {
let dt = parse_date_obs("2026-07-11").unwrap();
assert_eq!(dt.time(), Time::MIDNIGHT);
assert_eq!(format_date_obs(dt), "2026-07-11T00:00:00");
}
#[test]
fn seconds_and_fraction_are_optional() {
assert_eq!(
format_date_obs(parse_date_obs("2026-07-11T22:15").unwrap()),
"2026-07-11T22:15:00"
);
let dt = parse_date_obs("2026-07-11T22:15:03.25").unwrap();
assert_eq!(dt.time().nanosecond(), 250_000_000);
assert_eq!(format_date_obs(dt), "2026-07-11T22:15:03.25");
}
#[test]
fn fraction_beyond_nanoseconds_is_truncated() {
let dt = parse_date_obs("2026-07-11T00:00:00.1234567891234").unwrap();
assert_eq!(dt.time().nanosecond(), 123_456_789);
}
#[test]
fn quoted_input_is_tolerated() {
assert!(parse_date_obs("'2026-07-11T01:02:03'").is_ok());
assert!(parse_date_obs(" 2026-07-11 ").is_ok());
}
#[test]
fn invalid_forms_error_with_input() {
for bad in [
"2026-13-01", "2026-02-30", "2026-07-11T25:00:00", "2026-07-11-05", "2026-07-11T1:2:3:4", "2026", "not a date",
] {
assert_eq!(
parse_date_obs(bad),
Err(Error::ParseDate(bad.to_string())),
"{bad:?} should not parse"
);
}
}
#[test]
fn format_trims_trailing_fraction_zeros() {
let dt = parse_date_obs("2026-07-11T00:00:00.100").unwrap();
assert_eq!(format_date_obs(dt), "2026-07-11T00:00:00.1");
let dt = parse_date_obs("2026-07-11T00:00:00.000").unwrap();
assert_eq!(format_date_obs(dt), "2026-07-11T00:00:00");
}
#[test]
fn mjd_anchors() {
assert_eq!(datetime_to_mjd(datetime!(1858-11-17 00:00)), 0.0);
let j2000 = datetime!(2000-01-01 12:00);
assert_eq!(datetime_to_mjd(j2000), 51_544.5);
assert_eq!(mjd_to_jd(51_544.5), 2_451_545.0);
assert_eq!(jd_to_mjd(2_451_545.0), 51_544.5);
assert_eq!(julian_date(j2000.assume_utc()), 2_451_545.0);
}
#[test]
fn mjd_round_trips_through_datetime() {
let dt = datetime!(2026-07-11 22:15:03.25);
let back = mjd_to_datetime(datetime_to_mjd(dt)).unwrap();
let delta = (back - dt).abs();
assert!(delta < ::time::Duration::microseconds(5), "delta {delta}");
}
#[test]
fn mjd_fraction_rounding_up_carries_to_next_day() {
let mjd = 60_964.999_999_999_999_9;
let dt = mjd_to_datetime(mjd).unwrap();
assert_eq!(dt.time(), Time::MIDNIGHT);
}
#[test]
fn mjd_rejects_unrepresentable() {
assert!(mjd_to_datetime(f64::NAN).is_err());
assert!(mjd_to_datetime(f64::INFINITY).is_err());
assert!(mjd_to_datetime(1e18).is_err());
}
#[test]
fn julian_date_folds_in_the_offset() {
let utc = datetime!(2000-01-01 12:00 UTC);
let dubai = datetime!(2000-01-01 16:00 +04:00);
assert_eq!(julian_date(utc), julian_date(dubai));
}
}