1use ::time::{Date, Month, OffsetDateTime, PrimitiveDateTime, Time};
21
22use crate::angle::Angle;
23use crate::coords::Epoch;
24use crate::error::{Error, Result};
25
26const MJD_JD_OFFSET: f64 = 2_400_000.5;
28const MJD_EPOCH_JDN: i64 = 2_400_001;
31const J2000_JD: f64 = 2_451_545.0;
33const SECONDS_PER_DAY: f64 = 86_400.0;
34const NANOS_PER_DAY: i64 = 86_400 * 1_000_000_000;
35
36pub fn datetime_to_mjd(dt: PrimitiveDateTime) -> f64 {
49 let days = (i64::from(dt.date().to_julian_day()) - MJD_EPOCH_JDN) as f64;
50 let t = dt.time();
51 let day_seconds = f64::from(t.hour()) * 3_600.0
52 + f64::from(t.minute()) * 60.0
53 + f64::from(t.second())
54 + f64::from(t.nanosecond()) / 1e9;
55 days + day_seconds / SECONDS_PER_DAY
56}
57
58pub fn mjd_to_datetime(mjd: f64) -> Result<PrimitiveDateTime> {
72 let out_of_range = || Error::OutOfRange {
73 what: "mjd",
74 value: mjd,
75 };
76 if !mjd.is_finite() {
77 return Err(out_of_range());
78 }
79
80 let day = mjd.floor();
81 let mut jdn = day as i64 + MJD_EPOCH_JDN;
82 let mut nanos = ((mjd - day) * SECONDS_PER_DAY * 1e9).round() as i64;
83 if nanos >= NANOS_PER_DAY {
84 nanos -= NANOS_PER_DAY;
86 jdn += 1;
87 }
88
89 let date = i32::try_from(jdn)
90 .ok()
91 .and_then(|j| Date::from_julian_day(j).ok())
92 .ok_or_else(out_of_range)?;
93 let hour = (nanos / 3_600_000_000_000) as u8;
94 let minute = ((nanos / 60_000_000_000) % 60) as u8;
95 let second = ((nanos / 1_000_000_000) % 60) as u8;
96 let nano = (nanos % 1_000_000_000) as u32;
97 let time = Time::from_hms_nano(hour, minute, second, nano)
98 .expect("components are in range by construction");
99 Ok(PrimitiveDateTime::new(date, time))
100}
101
102pub fn jd_to_mjd(jd: f64) -> f64 {
110 jd - MJD_JD_OFFSET
111}
112
113pub fn mjd_to_jd(mjd: f64) -> f64 {
121 mjd + MJD_JD_OFFSET
122}
123
124pub fn julian_date(at: OffsetDateTime) -> f64 {
133 2_440_587.5 + at.unix_timestamp_nanos() as f64 / (SECONDS_PER_DAY * 1e9)
135}
136
137pub fn parse_date_obs(s: &str) -> Result<PrimitiveDateTime> {
156 parse_date_obs_opt(s).ok_or_else(|| Error::ParseDate(s.trim().to_string()))
157}
158
159fn parse_date_obs_opt(s: &str) -> Option<PrimitiveDateTime> {
160 let t = s.trim().trim_matches('\'').trim();
161 let (date_part, time_part) = match t.split_once('T') {
162 Some((d, tm)) => (d, Some(tm)),
163 None => (t, None),
164 };
165
166 let mut d = date_part.split('-');
167 let year: i32 = d.next()?.parse().ok()?;
168 let month: u8 = d.next()?.parse().ok()?;
169 let day: u8 = d.next()?.parse().ok()?;
170 if d.next().is_some() {
171 return None;
172 }
173 let date = Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()?;
174
175 let time = match time_part {
176 None => Time::MIDNIGHT,
177 Some(tp) => {
178 let tp = tp.strip_suffix('Z').unwrap_or(tp);
179 let mut parts = tp.split(':');
180 let hour: u8 = parts.next()?.parse().ok()?;
181 let minute: u8 = parts.next()?.parse().ok()?;
182 let (sec, nanos) = match parts.next() {
183 None => (0u8, 0u32),
184 Some(sec_field) => {
185 let (whole, frac) = match sec_field.split_once('.') {
186 Some((w, f)) => (w, Some(f)),
187 None => (sec_field, None),
188 };
189 let sec: u8 = whole.parse().ok()?;
190 let nanos = match frac {
191 None => 0,
192 Some(f) => {
193 let mut digits: String = f.chars().take(9).collect();
194 while digits.len() < 9 {
195 digits.push('0');
196 }
197 digits.parse::<u32>().ok()?
198 }
199 };
200 (sec, nanos)
201 }
202 };
203 if parts.next().is_some() {
204 return None;
205 }
206 Time::from_hms_nano(hour, minute, sec, nanos).ok()?
207 }
208 };
209
210 Some(PrimitiveDateTime::new(date, time))
211}
212
213pub fn format_date_obs(dt: PrimitiveDateTime) -> String {
224 let (d, t) = (dt.date(), dt.time());
225 let base = format!(
226 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
227 d.year(),
228 d.month() as u8,
229 d.day(),
230 t.hour(),
231 t.minute(),
232 t.second()
233 );
234 let nanos = t.nanosecond();
235 if nanos == 0 {
236 base
237 } else {
238 let frac = format!("{nanos:09}");
239 format!("{base}.{}", frac.trim_end_matches('0'))
240 }
241}
242
243pub fn julian_epoch_of(at: OffsetDateTime) -> Epoch {
260 Epoch::OfDate(2_000.0 + (julian_date(at) - J2000_JD) / 365.25)
261}
262
263pub fn gmst(at: OffsetDateTime) -> Angle {
277 let d = julian_date(at) - J2000_JD;
278 let t = d / 36_525.0;
279 let degrees =
280 280.460_618_37 + 360.985_647_366_29 * d + 0.000_387_933 * t * t - t * t * t / 38_710_000.0;
281 Angle::from_degrees(degrees).normalized_hours()
282}
283
284pub fn lst(at: OffsetDateTime, longitude_east: Angle) -> Angle {
298 (gmst(at) + longitude_east).normalized_hours()
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use ::time::macros::datetime;
305
306 #[test]
308 fn date_only_is_midnight() {
309 let dt = parse_date_obs("2026-07-11").unwrap();
310 assert_eq!(dt.time(), Time::MIDNIGHT);
311 assert_eq!(format_date_obs(dt), "2026-07-11T00:00:00");
312 }
313
314 #[test]
315 fn seconds_and_fraction_are_optional() {
316 assert_eq!(
317 format_date_obs(parse_date_obs("2026-07-11T22:15").unwrap()),
318 "2026-07-11T22:15:00"
319 );
320 let dt = parse_date_obs("2026-07-11T22:15:03.25").unwrap();
321 assert_eq!(dt.time().nanosecond(), 250_000_000);
322 assert_eq!(format_date_obs(dt), "2026-07-11T22:15:03.25");
323 }
324
325 #[test]
326 fn fraction_beyond_nanoseconds_is_truncated() {
327 let dt = parse_date_obs("2026-07-11T00:00:00.1234567891234").unwrap();
328 assert_eq!(dt.time().nanosecond(), 123_456_789);
329 }
330
331 #[test]
332 fn quoted_input_is_tolerated() {
333 assert!(parse_date_obs("'2026-07-11T01:02:03'").is_ok());
334 assert!(parse_date_obs(" 2026-07-11 ").is_ok());
335 }
336
337 #[test]
338 fn trailing_z_designator_is_tolerated() {
339 assert_eq!(
340 parse_date_obs("2026-07-11T22:15:03.25Z").unwrap(),
341 parse_date_obs("2026-07-11T22:15:03.25").unwrap()
342 );
343 assert_eq!(
344 parse_date_obs("2026-07-11T22:15Z").unwrap(),
345 parse_date_obs("2026-07-11T22:15").unwrap()
346 );
347 assert_eq!(
349 format_date_obs(parse_date_obs("2026-07-11T22:15:03Z").unwrap()),
350 "2026-07-11T22:15:03"
351 );
352 }
353
354 #[test]
355 fn invalid_forms_error_with_input() {
356 for bad in [
357 "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",
364 ] {
365 assert_eq!(
366 parse_date_obs(bad),
367 Err(Error::ParseDate(bad.to_string())),
368 "{bad:?} should not parse"
369 );
370 }
371 }
372
373 #[test]
374 fn format_trims_trailing_fraction_zeros() {
375 let dt = parse_date_obs("2026-07-11T00:00:00.100").unwrap();
376 assert_eq!(format_date_obs(dt), "2026-07-11T00:00:00.1");
377 let dt = parse_date_obs("2026-07-11T00:00:00.000").unwrap();
378 assert_eq!(format_date_obs(dt), "2026-07-11T00:00:00");
379 }
380
381 #[test]
382 fn mjd_anchors() {
383 assert_eq!(datetime_to_mjd(datetime!(1858-11-17 00:00)), 0.0);
385 let j2000 = datetime!(2000-01-01 12:00);
387 assert_eq!(datetime_to_mjd(j2000), 51_544.5);
388 assert_eq!(mjd_to_jd(51_544.5), 2_451_545.0);
389 assert_eq!(jd_to_mjd(2_451_545.0), 51_544.5);
390 assert_eq!(julian_date(j2000.assume_utc()), 2_451_545.0);
391 }
392
393 #[test]
394 fn mjd_round_trips_through_datetime() {
395 let dt = datetime!(2026-07-11 22:15:03.25);
396 let back = mjd_to_datetime(datetime_to_mjd(dt)).unwrap();
397 let delta = (back - dt).abs();
398 assert!(delta < ::time::Duration::microseconds(5), "delta {delta}");
399 }
400
401 #[test]
402 fn mjd_fraction_rounding_up_carries_to_next_day() {
403 let mjd = 60_964.999_999_999_999_9;
405 let dt = mjd_to_datetime(mjd).unwrap();
406 assert_eq!(dt.time(), Time::MIDNIGHT);
407 }
408
409 #[test]
410 fn mjd_rejects_unrepresentable() {
411 assert!(mjd_to_datetime(f64::NAN).is_err());
412 assert!(mjd_to_datetime(f64::INFINITY).is_err());
413 assert!(mjd_to_datetime(1e18).is_err());
414 }
415
416 #[test]
417 fn julian_date_folds_in_the_offset() {
418 let utc = datetime!(2000-01-01 12:00 UTC);
419 let dubai = datetime!(2000-01-01 16:00 +04:00);
420 assert_eq!(julian_date(utc), julian_date(dubai));
421 }
422}