Skip to main content

astroceleste_engine/
instant.rs

1//! UTC instants with microsecond resolution, mirroring the Python `datetime` behaviour
2//! the reference implementation relies on (ISO formatting, float-day arithmetic).
3
4use std::fmt;
5
6use crate::time::julian_day;
7
8const US_PER_SECOND: i64 = 1_000_000;
9const US_PER_DAY: i64 = 86_400 * US_PER_SECOND;
10/// Latest year [`UtcInstant::parse`] accepts: past every JPL kernel (DE441 ends in 17191)
11/// and far from overflowing the microsecond count.
12const MAX_YEAR: i32 = 200_000;
13
14/// A UTC instant, as microseconds since 1970-01-01T00:00:00Z.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
16pub struct UtcInstant {
17    micros: i64,
18}
19
20/// Days since 1970-01-01 of a proleptic-Gregorian date (H. Hinnant, `days_from_civil`).
21fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
22    let y = if month <= 2 { year - 1 } else { year };
23    let era = y.div_euclid(400);
24    let yoe = y - era * 400;
25    let mp = (month + 9) % 12;
26    let doy = (153 * mp + 2) / 5 + day - 1;
27    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
28    era * 146_097 + doe - 719_468
29}
30
31fn days_in_month(year: i32, month: u32) -> u32 {
32    match month {
33        2 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29,
34        2 => 28,
35        4 | 6 | 9 | 11 => 30,
36        _ => 31,
37    }
38}
39
40fn civil_from_days(z: i64) -> (i64, i64, i64) {
41    let z = z + 719_468;
42    let era = z.div_euclid(146_097);
43    let doe = z - era * 146_097;
44    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
45    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
46    let mp = (5 * doy + 2) / 153;
47    let day = doy - (153 * mp + 2) / 5 + 1;
48    let month = if mp < 10 { mp + 3 } else { mp - 9 };
49    let year = yoe + era * 400 + i64::from(month <= 2);
50    (year, month, day)
51}
52
53/// The text given to [`UtcInstant::parse`] is not a supported date-time.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct ParseError(pub String);
56
57impl fmt::Display for ParseError {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        write!(f, "invalid UTC date-time: {}", self.0)
60    }
61}
62
63impl std::error::Error for ParseError {}
64
65impl UtcInstant {
66    /// 2000-01-01T12:00:00Z.
67    pub const J2000: UtcInstant = UtcInstant {
68        micros: 946_728_000 * US_PER_SECOND,
69    };
70
71    /// From microseconds since the Unix epoch.
72    pub fn from_micros(micros: i64) -> Self {
73        UtcInstant { micros }
74    }
75
76    /// Microseconds since the Unix epoch.
77    pub fn micros(&self) -> i64 {
78        self.micros
79    }
80
81    /// From proleptic-Gregorian calendar fields (UTC). Out-of-range fields roll over
82    /// (day 32 is the next month's first); the result saturates instead of overflowing.
83    pub fn from_civil(
84        year: i32,
85        month: u32,
86        day: u32,
87        hour: u32,
88        minute: u32,
89        second: u32,
90        microsecond: u32,
91    ) -> Self {
92        let days = days_from_civil(year as i64, month as i64, day as i64);
93        let secs = hour as i64 * 3600 + minute as i64 * 60 + second as i64;
94        UtcInstant {
95            micros: days
96                .saturating_mul(US_PER_DAY)
97                .saturating_add(secs * US_PER_SECOND + microsecond as i64),
98        }
99    }
100
101    /// (year, month, day, hour, minute, second, microsecond)
102    pub fn civil(&self) -> (i32, u32, u32, u32, u32, u32, u32) {
103        let days = self.micros.div_euclid(US_PER_DAY);
104        let rem = self.micros.rem_euclid(US_PER_DAY);
105        let (y, m, d) = civil_from_days(days);
106        let secs = rem / US_PER_SECOND;
107        (
108            y as i32,
109            m as u32,
110            d as u32,
111            (secs / 3600) as u32,
112            (secs % 3600 / 60) as u32,
113            (secs % 60) as u32,
114            (rem % US_PER_SECOND) as u32,
115        )
116    }
117
118    /// Python `datetime.weekday()`: Monday is 0.
119    pub fn weekday(&self) -> u32 {
120        // 1970-01-01 was a Thursday (3).
121        (self.micros.div_euclid(US_PER_DAY) + 3).rem_euclid(7) as u32
122    }
123
124    /// Julian day as the reference computes it from calendar fields.
125    pub fn julian_day(&self) -> f64 {
126        let (y, m, d, hh, mm, ss, us) = self.civil();
127        let hour =
128            hh as f64 + (mm as f64 / 60.0) + (ss as f64 / 3600.0) + (us as f64 / 3_600_000_000.0);
129        julian_day(y, m, d, hour)
130    }
131
132    /// Seconds from `other` to `self`, like Python `(self - other).total_seconds()`.
133    pub fn seconds_since(&self, other: &UtcInstant) -> f64 {
134        (self.micros - other.micros) as f64 / US_PER_SECOND as f64
135    }
136
137    /// `micros` microseconds later (earlier if negative), saturating at the ends of the
138    /// representable range.
139    pub fn add_micros(&self, micros: i64) -> Self {
140        UtcInstant {
141            micros: self.micros.saturating_add(micros),
142        }
143    }
144
145    /// `self + timedelta(days=days)`, with CPython's rounding of float days to
146    /// microseconds (whole days exact, fraction rounded half to even).
147    pub fn plus_days(&self, days: f64) -> Self {
148        self.add_micros(timedelta_days_to_micros(days))
149    }
150
151    /// Same day at another time of day (Python `dt.replace(hour=…, minute=…)`).
152    pub fn with_time(&self, hour: u32, minute: u32, second: u32, microsecond: u32) -> Self {
153        let (y, m, d, ..) = self.civil();
154        UtcInstant::from_civil(y, m, d, hour, minute, second, microsecond)
155    }
156
157    /// Python `datetime.isoformat()` of an aware UTC datetime.
158    pub fn isoformat(&self) -> String {
159        let (y, m, d, hh, mm, ss, us) = self.civil();
160        let fraction = if us == 0 {
161            String::new()
162        } else {
163            format!(".{us:06}")
164        };
165        format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}{fraction}+00:00")
166    }
167
168    /// Parse `YYYY-MM-DDTHH:MM[:SS[.ffffff]]` with an optional `Z` or `+00:00` suffix.
169    /// The date must exist (no 31 February) and the year be at most 200 000.
170    pub fn parse(text: &str) -> Result<Self, ParseError> {
171        let err = || ParseError(text.to_string());
172        let body = text
173            .strip_suffix('Z')
174            .or_else(|| text.strip_suffix("+00:00"))
175            .unwrap_or(text);
176        let (date, time) = body.split_once(['T', ' ']).ok_or_else(err)?;
177        let mut d = date.split('-');
178        let year: i32 = d.next().and_then(|v| v.parse().ok()).ok_or_else(err)?;
179        let month: u32 = d.next().and_then(|v| v.parse().ok()).ok_or_else(err)?;
180        let day: u32 = d.next().and_then(|v| v.parse().ok()).ok_or_else(err)?;
181        let mut t = time.split(':');
182        let hour: u32 = t.next().and_then(|v| v.parse().ok()).ok_or_else(err)?;
183        let minute: u32 = t.next().and_then(|v| v.parse().ok()).ok_or_else(err)?;
184        let (second, micro) = match t.next() {
185            None => (0, 0),
186            Some(s) => {
187                let (whole, frac) = s.split_once('.').unwrap_or((s, ""));
188                let second: u32 = whole.parse().map_err(|_| err())?;
189                let micro = if frac.is_empty() {
190                    0
191                } else {
192                    let digits: String = frac.chars().chain("000000".chars()).take(6).collect();
193                    digits.parse().map_err(|_| err())?
194                };
195                (second, micro)
196            }
197        };
198        if !(0..=MAX_YEAR).contains(&year)
199            || !(1..=12).contains(&month)
200            || day == 0
201            || day > days_in_month(year, month)
202            || hour > 23
203            || minute > 59
204            || second > 59
205        {
206            return Err(err());
207        }
208        Ok(UtcInstant::from_civil(
209            year, month, day, hour, minute, second, micro,
210        ))
211    }
212}
213
214/// CPython `timedelta(days=x)` for a float `x`, in microseconds.
215fn timedelta_days_to_micros(days: f64) -> i64 {
216    let int_part = days.trunc();
217    let frac_part = days - int_part;
218    let mut total = int_part as i64 * US_PER_DAY;
219    if frac_part == 0.0 {
220        return total;
221    }
222    let scaled = US_PER_DAY as f64 * frac_part;
223    let scaled_int = scaled.trunc();
224    let leftover = scaled - scaled_int;
225    total += scaled_int as i64;
226    if leftover != 0.0 {
227        let mut whole = leftover.round();
228        if (whole - leftover).abs() == 0.5 {
229            // Half-way: round so that the total is even.
230            let odd = f64::from(total.rem_euclid(2) == 1);
231            whole = 2.0 * ((leftover + odd) * 0.5).round() - odd;
232        }
233        total += whole as i64;
234    }
235    total
236}
237
238impl fmt::Display for UtcInstant {
239    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240        f.write_str(&self.isoformat())
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn civil_round_trip_and_weekday() {
250        let t = UtcInstant::from_civil(2024, 2, 29, 23, 59, 59, 123_456);
251        assert_eq!(t.civil(), (2024, 2, 29, 23, 59, 59, 123_456));
252        assert_eq!(t.isoformat(), "2024-02-29T23:59:59.123456+00:00");
253        assert_eq!(t.weekday(), 3); // Thursday
254        assert_eq!(UtcInstant::J2000.isoformat(), "2000-01-01T12:00:00+00:00");
255        assert_eq!(UtcInstant::J2000.weekday(), 5); // Saturday
256        let old = UtcInstant::from_civil(1850, 1, 2, 0, 0, 0, 0);
257        assert_eq!(old.civil(), (1850, 1, 2, 0, 0, 0, 0));
258    }
259
260    #[test]
261    fn parses_iso() {
262        let t = UtcInstant::parse("1987-03-20T21:52:00").unwrap();
263        assert_eq!(t, UtcInstant::from_civil(1987, 3, 20, 21, 52, 0, 0));
264        let t = UtcInstant::parse("2000-01-01T05:59:33.272404+00:00").unwrap();
265        assert_eq!(t.civil().6, 272_404);
266        assert!(UtcInstant::parse("2000-13-01T00:00").is_err());
267        assert!(UtcInstant::parse("2001-02-29T00:00").is_err());
268        assert!(UtcInstant::parse("2023-04-31T00:00").is_err());
269        assert!(UtcInstant::parse("2000-02-29T00:00").is_ok());
270        assert!(UtcInstant::parse("1900-02-29T00:00").is_err());
271        assert!(UtcInstant::parse("2147483647-01-01T00:00").is_err());
272        assert!(UtcInstant::parse("17191-01-01T00:00").is_ok());
273    }
274
275    #[test]
276    fn julian_day_matches_formula() {
277        assert_eq!(UtcInstant::J2000.julian_day(), 2_451_545.0);
278    }
279
280    #[test]
281    fn float_days_round_like_python() {
282        // Python: timedelta(days=0.5) == 12 h; timedelta(days=1/3) == 8:00:00
283        assert_eq!(timedelta_days_to_micros(0.5), US_PER_DAY / 2);
284        assert_eq!(timedelta_days_to_micros(1.0 / 3.0), 28_800_000_000);
285        assert_eq!(timedelta_days_to_micros(-0.25), -US_PER_DAY / 4);
286    }
287}