1use 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;
10const MAX_YEAR: i32 = 200_000;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
16pub struct UtcInstant {
17 micros: i64,
18}
19
20fn 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#[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 pub const J2000: UtcInstant = UtcInstant {
68 micros: 946_728_000 * US_PER_SECOND,
69 };
70
71 pub fn from_micros(micros: i64) -> Self {
73 UtcInstant { micros }
74 }
75
76 pub fn micros(&self) -> i64 {
78 self.micros
79 }
80
81 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 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 pub fn weekday(&self) -> u32 {
120 (self.micros.div_euclid(US_PER_DAY) + 3).rem_euclid(7) as u32
122 }
123
124 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 pub fn seconds_since(&self, other: &UtcInstant) -> f64 {
134 (self.micros - other.micros) as f64 / US_PER_SECOND as f64
135 }
136
137 pub fn add_micros(&self, micros: i64) -> Self {
140 UtcInstant {
141 micros: self.micros.saturating_add(micros),
142 }
143 }
144
145 pub fn plus_days(&self, days: f64) -> Self {
148 self.add_micros(timedelta_days_to_micros(days))
149 }
150
151 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 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 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
214fn 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 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); assert_eq!(UtcInstant::J2000.isoformat(), "2000-01-01T12:00:00+00:00");
255 assert_eq!(UtcInstant::J2000.weekday(), 5); 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 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}