use crate::constants::{J2000_JD, J2000_MJD, J2000_TAI_UNIX_S, SECONDS_PER_DAY};
use crate::leap_seconds::{get_tai_utc_offset, tai_to_utc_full};
use crate::types::{BrightDateError, BrightDateValue};
use chrono::{DateTime, TimeZone, Utc};
pub fn from_unix_ms(ms: f64) -> Result<BrightDateValue, BrightDateError> {
if !ms.is_finite() {
return Err(BrightDateError::InvalidInput(format!(
"expected finite Unix ms, got {ms}"
)));
}
let utc_s = ms / 1_000.0;
let offset = get_tai_utc_offset(utc_s.floor() as i64) as f64;
let tai_s_since_unix = utc_s + offset;
Ok((tai_s_since_unix - J2000_TAI_UNIX_S) / SECONDS_PER_DAY)
}
pub fn from_unix_seconds(s: f64) -> Result<BrightDateValue, BrightDateError> {
if !s.is_finite() {
return Err(BrightDateError::InvalidInput(format!(
"expected finite Unix seconds, got {s}"
)));
}
from_unix_ms(s * 1_000.0)
}
pub fn from_date_time(dt: DateTime<Utc>) -> BrightDateValue {
from_unix_ms(dt.timestamp_millis() as f64).unwrap_or(f64::NAN)
}
pub fn from_julian_date(jd: f64) -> BrightDateValue {
jd - J2000_JD
}
pub fn from_modified_julian_date(mjd: f64) -> BrightDateValue {
mjd - J2000_MJD
}
pub fn from_iso(s: &str) -> Result<BrightDateValue, BrightDateError> {
let dt = s.parse::<DateTime<Utc>>().map_err(|e| {
BrightDateError::ParseError(format!("invalid ISO 8601 \"{s}\": {e}"))
})?;
Ok(from_date_time(dt))
}
pub fn from_gps_time(gps_week: u32, gps_seconds: f64) -> BrightDateValue {
const GPS_EPOCH_UNIX_TAI: i64 = 315_964_800 + 19; const SECONDS_PER_WEEK: f64 = 604_800.0;
let gps_elapsed_s = gps_week as f64 * SECONDS_PER_WEEK + gps_seconds;
let tai_s_since_unix = GPS_EPOCH_UNIX_TAI as f64 + gps_elapsed_s;
(tai_s_since_unix - J2000_TAI_UNIX_S) / SECONDS_PER_DAY
}
#[inline]
fn bd_to_tai_unix_s(bd: BrightDateValue) -> f64 {
bd * SECONDS_PER_DAY + J2000_TAI_UNIX_S
}
pub fn to_unix_ms(bd: BrightDateValue) -> f64 {
let tai_s = bd_to_tai_unix_s(bd);
let conv = tai_to_utc_full(tai_s.floor() as i64);
let frac = tai_s - tai_s.floor();
(conv.utc_unix_seconds as f64 + frac) * 1_000.0
}
pub fn to_unix_seconds(bd: BrightDateValue) -> f64 {
to_unix_ms(bd) / 1_000.0
}
pub fn to_date_time(bd: BrightDateValue) -> DateTime<Utc> {
let ms = to_unix_ms(bd) as i64;
Utc.timestamp_millis_opt(ms).single().unwrap_or(DateTime::<Utc>::UNIX_EPOCH)
}
pub fn to_julian_date(bd: BrightDateValue) -> f64 {
bd + J2000_JD
}
pub fn to_modified_julian_date(bd: BrightDateValue) -> f64 {
bd + J2000_MJD
}
pub fn to_iso(bd: BrightDateValue) -> String {
let tai_s = bd_to_tai_unix_s(bd);
let tai_s_int = tai_s.floor() as i64;
let frac = tai_s - tai_s.floor();
let conv = tai_to_utc_full(tai_s_int);
if conv.is_leap_second {
let dt = Utc
.timestamp_opt(conv.utc_unix_seconds, 0)
.single()
.unwrap_or(DateTime::<Utc>::UNIX_EPOCH);
let millis = (frac * 1000.0).round() as i64;
return format!(
"{}T{}:{}:60.{:03}Z",
dt.format("%Y-%m-%d"),
dt.format("%H"),
dt.format("%M"),
millis.clamp(0, 999),
);
}
let ms = (conv.utc_unix_seconds as f64 + frac) * 1_000.0;
let dt = Utc
.timestamp_millis_opt(ms as i64)
.single()
.unwrap_or(DateTime::<Utc>::UNIX_EPOCH);
dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
}
pub fn to_gps_time(bd: BrightDateValue) -> (u32, f64) {
const GPS_EPOCH_UNIX_TAI: f64 = 315_964_819.0; const SECONDS_PER_WEEK: f64 = 604_800.0;
let tai_s = bd_to_tai_unix_s(bd);
let gps_elapsed = (tai_s - GPS_EPOCH_UNIX_TAI).max(0.0);
let week = (gps_elapsed / SECONDS_PER_WEEK).floor() as u32;
let sec = gps_elapsed - (week as f64 * SECONDS_PER_WEEK);
(week, sec)
}
#[deprecated(since = "1.0.0", note = "BrightDate is TAI-coherent; this is now identity")]
pub fn utc_to_tai_bright_date(bd: BrightDateValue) -> BrightDateValue {
bd
}
#[deprecated(since = "1.0.0", note = "BrightDate is TAI-coherent; this is now identity")]
pub fn tai_to_utc_bright_date(bd: BrightDateValue) -> BrightDateValue {
bd
}
pub fn tai_utc_offset_seconds_at(bd: BrightDateValue) -> i32 {
let tai_s = bd_to_tai_unix_s(bd);
let utc_s = tai_to_utc_full(tai_s.floor() as i64).utc_unix_seconds;
get_tai_utc_offset(utc_s)
}
pub fn normalize(bd: BrightDateValue) -> BrightDateValue {
bd
}
pub use crate::leap_seconds::{tai_to_utc, TaiToUtc};
#[cfg(test)]
mod tests {
use super::*;
use crate::constants::J2000_UTC_UNIX_MS;
#[test]
fn j2000_unix_ms_maps_to_bd_zero() {
let bd = from_unix_ms(J2000_UTC_UNIX_MS).unwrap();
assert!(bd.abs() < 1e-9, "BD at J2000.0 should be 0, got {bd}");
}
#[test]
fn noon_utc_maps_to_64_184_seconds_past_j2000() {
let bd = from_unix_ms(946_728_000_000.0).unwrap();
let expected = 64.184 / SECONDS_PER_DAY;
assert!(
(bd - expected).abs() < 1e-12,
"noon UTC should be 64.184 s past J2000.0; got BD={bd}, expected {expected}"
);
}
#[test]
fn unix_ms_roundtrip() {
let ms = 1_700_000_000_000.0; let bd = from_unix_ms(ms).unwrap();
let back = to_unix_ms(bd);
assert!((back - ms).abs() < 1.0, "roundtrip drift: {} ms", back - ms);
}
#[test]
fn iso_roundtrip() {
let iso = "2025-06-15T10:30:00.000Z";
let bd = from_iso(iso).unwrap();
let back = to_iso(bd);
assert_eq!(back, iso);
}
#[test]
fn julian_date_exact_at_j2000() {
assert_eq!(from_julian_date(2_451_545.0), 0.0);
assert_eq!(to_julian_date(0.0), 2_451_545.0);
}
#[test]
fn modified_julian_date_exact_at_j2000() {
assert_eq!(from_modified_julian_date(51_544.5), 0.0);
assert_eq!(to_modified_julian_date(0.0), 51_544.5);
}
}