Skip to main content

skymath/
time.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Time scales: Julian dates, FITS `DATE-OBS` strings, Julian epochs, and
6//! sidereal time.
7//!
8//! Provenance: [`parse_date_obs`] / [`format_date_obs`] are extracted from
9//! `fits-header` (`src/dates.rs`); the MJD/JD conversions and [`gmst`] /
10//! [`lst`] are written fresh, validated against Meeus, *Astronomical
11//! Algorithms* 2nd ed. (chapters 7 and 12).
12//!
13//! GMST uses the IAU-1982 polynomial (Meeus eq. 12.4) on the UT1 ≈ UTC
14//! assumption: |ΔUT1| < 0.9 s of time ≈ 13.5″ of hour angle, inside the
15//! crate's planning-grade contract. Functions taking [`OffsetDateTime`]
16//! convert to UTC internally, so passing local civil time cannot skew
17//! sidereal results. JD/MJD are carried as `f64` — microsecond-level
18//! resolution in the current era.
19
20use ::time::{Date, Month, OffsetDateTime, PrimitiveDateTime, Time};
21
22use crate::angle::Angle;
23use crate::coords::Epoch;
24use crate::error::{Error, Result};
25
26/// JD − MJD: the MJD epoch (1858-11-17T00:00 UTC) as a Julian date.
27const MJD_JD_OFFSET: f64 = 2_400_000.5;
28/// Julian day number of the MJD epoch's calendar day (`to_julian_day` of
29/// 1858-11-17, i.e. the JD of that day's *noon*).
30const MJD_EPOCH_JDN: i64 = 2_400_001;
31/// J2000.0 (2000-01-01T12:00 TT ≈ UTC at planning grade) as a Julian date.
32const 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
36// ── Julian / Modified Julian dates ─────────────────────────────────────────────
37
38/// Modified Julian Date of a timezone-naive instant (taken as UTC). Inverse
39/// of [`mjd_to_datetime`].
40///
41/// ```
42/// use skymath::datetime_to_mjd;
43/// use time::macros::datetime;
44///
45/// // J2000.0 = MJD 51544.5.
46/// assert_eq!(datetime_to_mjd(datetime!(2000-01-01 12:00)), 51_544.5);
47/// ```
48pub 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
58/// Timezone-naive UTC instant of a Modified Julian Date. Inverse of
59/// [`datetime_to_mjd`].
60///
61/// Errors with [`Error::OutOfRange`] when `mjd` is not finite or falls
62/// outside the `time` crate's representable calendar range.
63///
64/// ```
65/// use skymath::mjd_to_datetime;
66/// use time::macros::datetime;
67///
68/// assert_eq!(mjd_to_datetime(51_544.5).unwrap(), datetime!(2000-01-01 12:00));
69/// assert!(mjd_to_datetime(f64::NAN).is_err());
70/// ```
71pub 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        // The fractional part rounded up to a full day.
85        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
102/// Convert a Julian Date to a Modified Julian Date. Inverse of [`mjd_to_jd`].
103///
104/// ```
105/// use skymath::jd_to_mjd;
106///
107/// assert_eq!(jd_to_mjd(2_451_545.0), 51_544.5); // J2000.0
108/// ```
109pub fn jd_to_mjd(jd: f64) -> f64 {
110    jd - MJD_JD_OFFSET
111}
112
113/// Convert a Modified Julian Date to a Julian Date. Inverse of [`jd_to_mjd`].
114///
115/// ```
116/// use skymath::mjd_to_jd;
117///
118/// assert_eq!(mjd_to_jd(51_544.5), 2_451_545.0); // J2000.0
119/// ```
120pub fn mjd_to_jd(mjd: f64) -> f64 {
121    mjd + MJD_JD_OFFSET
122}
123
124/// Julian Date of an instant; the offset is folded in (UTC internally).
125///
126/// ```
127/// use skymath::julian_date;
128/// use time::macros::datetime;
129///
130/// assert_eq!(julian_date(datetime!(2000-01-01 12:00 UTC)), 2_451_545.0);
131/// ```
132pub fn julian_date(at: OffsetDateTime) -> f64 {
133    // The Unix epoch (1970-01-01T00:00 UTC) is JD 2 440 587.5.
134    2_440_587.5 + at.unix_timestamp_nanos() as f64 / (SECONDS_PER_DAY * 1e9)
135}
136
137// ── FITS DATE-OBS ──────────────────────────────────────────────────────────────
138
139/// Parse a FITS civil date/time (`YYYY-MM-DD[Thh:mm:ss[.fff]]`),
140/// timezone-naive; a date-only value means midnight. Quoted card values,
141/// surrounding whitespace, and a trailing `Z` UTC designator (common in
142/// real-world FITS/XISF data, uppercase only — matching the strict uppercase
143/// `T` separator) are tolerated. FITS strings carry no offset — bridge to
144/// [`OffsetDateTime`] with `parse_date_obs(s)?.assume_utc()`. Inverse of
145/// [`format_date_obs`], which never emits `Z`.
146///
147/// ```
148/// use skymath::parse_date_obs;
149/// use time::macros::datetime;
150///
151/// assert_eq!(parse_date_obs("2026-07-11T22:15:03.25")?, datetime!(2026-07-11 22:15:03.25));
152/// assert_eq!(parse_date_obs("2026-07-11")?, datetime!(2026-07-11 00:00));
153/// # Ok::<(), skymath::Error>(())
154/// ```
155pub 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
213/// Format a date/time back to the FITS civil form
214/// (`YYYY-MM-DDThh:mm:ss[.fff]`), dropping a zero sub-second part. Inverse of
215/// [`parse_date_obs`].
216///
217/// ```
218/// use skymath::format_date_obs;
219/// use time::macros::datetime;
220///
221/// assert_eq!(format_date_obs(datetime!(2026-07-11 22:15:00)), "2026-07-11T22:15:00");
222/// ```
223pub 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
243// ── Julian epoch & sidereal time ───────────────────────────────────────────────
244
245/// The Julian epoch of an instant, e.g. `Epoch::OfDate(2026.52…)` for
246/// mid-July 2026. Feeds [`precess`](crate::precess) when moving an
247/// [`Equatorial`](crate::Equatorial) position to "tonight".
248///
249/// ```
250/// use skymath::julian_epoch_of;
251/// use skymath::Epoch;
252/// use time::macros::datetime;
253///
254/// assert_eq!(
255///     julian_epoch_of(datetime!(2000-01-01 12:00 UTC)),
256///     Epoch::OfDate(2000.0)
257/// );
258/// ```
259pub fn julian_epoch_of(at: OffsetDateTime) -> Epoch {
260    Epoch::OfDate(2_000.0 + (julian_date(at) - J2000_JD) / 365.25)
261}
262
263/// Greenwich Mean Sidereal Time, normalized to `[0h, 24h)`. Feeds [`lst`] at
264/// an observer's longitude.
265///
266/// IAU-1982 polynomial (Meeus eq. 12.4) on UT1 ≈ UTC: accurate to ~0.1 s of
267/// time over ±1 century around J2000.
268///
269/// ```
270/// use skymath::gmst;
271/// use time::OffsetDateTime;
272///
273/// let hours = gmst(OffsetDateTime::now_utc()).hours();
274/// assert!((0.0..24.0).contains(&hours));
275/// ```
276pub 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
284/// Local Sidereal Time at an east-positive longitude, normalized to
285/// `[0h, 24h)`. `lst(at, lon) == gmst(at) + lon` (wrapped).
286///
287/// ```
288/// use skymath::{gmst, lst, Location};
289/// use time::OffsetDateTime;
290///
291/// let site = Location::parse("+52 05 32", "+004 18 27", 6.0)?;
292/// let now = OffsetDateTime::now_utc();
293/// let expected = (gmst(now) + site.longitude()).normalized_hours();
294/// assert!((lst(now, site.longitude()).hours() - expected.hours()).abs() < 1e-9);
295/// # Ok::<(), skymath::Error>(())
296/// ```
297pub 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    // Extracted with parse/format from fits-header `src/dates.rs`.
307    #[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        // Formatting stays Z-free: round-tripping drops the designator.
348        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",          // month
358            "2026-02-30",          // day
359            "2026-07-11T25:00:00", // hour
360            "2026-07-11-05",       // extra date part
361            "2026-07-11T1:2:3:4",  // extra time part
362            "2026",                // no month/day
363            "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        // The MJD epoch itself.
384        assert_eq!(datetime_to_mjd(datetime!(1858-11-17 00:00)), 0.0);
385        // J2000.0 = JD 2 451 545.0 = MJD 51 544.5.
386        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        // One sub-nanosecond step below a whole day must not produce 24:00:00.
404        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}