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