umsh_gnss/epoch.rs
1//! Conversion between broken-down civil time and the Unix epoch.
2//!
3//! Receivers report the date and time of day as separate fields, and
4//! `PROP_TIME` carries one `UINT32` second count, so something has to
5//! convert between them. Hosts need the same conversion in reverse to
6//! show an operator what the device thinks the time is.
7//!
8//! The arithmetic is Howard Hinnant's days-from-civil algorithm: integer
9//! only, no lookup tables, and correct for the whole proleptic Gregorian
10//! calendar rather than only for the years a leap-year table happens to
11//! cover.
12//!
13//! Everything here is UTC. A local time-zone offset is presentation,
14//! applied by whatever is doing the presenting — see
15//! [`DateTime::shifted`].
16
17/// The largest second count [`u32`] can express: 2106-02-07T06:28:15Z.
18///
19/// `PROP_TIME` is unsigned, which is what buys the extra 68 years over
20/// the signed encoding everyone worries about; nothing here needs to
21/// handle a wrap before then.
22pub const MAX_EPOCH: u32 = u32::MAX;
23
24/// The earliest instant a receiver is believed: 2020-01-01T00:00:00Z.
25///
26/// A receiver whose backup domain has lost power does not report *no*
27/// time. It reports the start of its own epoch — a T1000-E's AG3335 comes
28/// back saying 1980-01-06, the GPS epoch — and an RMC carrying that is
29/// well-formed in every respect except being wrong by decades.
30///
31/// Nothing downstream can catch this. The wall clock's precedence rules
32/// are about *which* source wins, not whether a source is lying, and an
33/// unset clock accepts a receiver-RTC restore by design. So the check
34/// belongs here, at the parse: an instant from before any of this software
35/// existed is not an instant, and a receiver reporting one is a receiver
36/// with no clock — which is a state the design already handles.
37///
38/// # The 2080 cliff
39///
40/// This floor combines with the GPS-epoch two-digit-year window to accept
41/// **2020 through 2079** and nothing else. In 2080 a receiver reports `80`,
42/// the window reads that as 1980, and this rejects it — the same reading
43/// that makes a reset receiver detectable today.
44///
45/// Two digits cannot distinguish "the receiver's clock was lost" from "it
46/// is fifty-four years later", so the ambiguity is inherent rather than
47/// chosen; every NMEA consumer windowing on the GPS epoch shares it. The
48/// alternative — windowing forward, so `80` means 2080 — buys a working
49/// 2080 at the cost of believing every clock-less receiver between now and
50/// then, which is the failure that actually happens. `PROP_TIME`'s `u32`
51/// runs out in 2106 regardless.
52pub const MIN_PLAUSIBLE_EPOCH: u32 = 1_577_836_800;
53
54/// A broken-down civil date and time, in UTC unless a caller has
55/// deliberately shifted it.
56#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
57pub struct DateTime {
58 /// Proleptic Gregorian year.
59 pub year: i32,
60 /// Month, 1–12.
61 pub month: u8,
62 /// Day of month, 1–31.
63 pub day: u8,
64 /// Hour, 0–23.
65 pub hour: u8,
66 /// Minute, 0–59.
67 pub minute: u8,
68 /// Second, 0–59. Leap seconds are not represented: receivers report
69 /// UTC with the leap second smeared or repeated, and a second that
70 /// cannot be encoded is worse than one that is merely repeated.
71 pub second: u8,
72}
73
74impl DateTime {
75 /// The Unix epoch itself, 1970-01-01T00:00:00Z.
76 pub const EPOCH: Self = Self {
77 year: 1970,
78 month: 1,
79 day: 1,
80 hour: 0,
81 minute: 0,
82 second: 0,
83 };
84
85 /// Whether every field is in range and the day exists in its month.
86 ///
87 /// Note what this does *not* check: a year outside the range
88 /// [`to_unix`](Self::to_unix) can encode is still a valid date, and is
89 /// rejected there instead.
90 pub const fn is_valid(&self) -> bool {
91 self.month >= 1
92 && self.month <= 12
93 && self.day >= 1
94 && self.day <= days_in_month(self.year, self.month)
95 && self.hour <= 23
96 && self.minute <= 59
97 && self.second <= 59
98 }
99
100 /// Seconds since the Unix epoch, or `None` when the date is invalid
101 /// or falls outside what `PROP_TIME` can carry.
102 ///
103 /// A receiver reporting a date before 1970 or past 2106 is reporting
104 /// a receiver fault, not a time, so refusing is the right answer:
105 /// silently clamping would set a clock to a value the device would
106 /// then defend.
107 pub const fn to_unix(&self) -> Option<u32> {
108 if !self.is_valid() {
109 return None;
110 }
111 let days = days_from_civil(self.year, self.month, self.day);
112 let seconds =
113 days * 86_400 + self.hour as i64 * 3_600 + self.minute as i64 * 60 + self.second as i64;
114 if seconds < 0 || seconds > MAX_EPOCH as i64 {
115 return None;
116 }
117 Some(seconds as u32)
118 }
119
120 /// Break a Unix second count down into civil fields.
121 ///
122 /// Total: every `u32` names a real UTC instant, so unlike
123 /// [`to_unix`](Self::to_unix) there is nothing to refuse.
124 pub const fn from_unix(seconds: u32) -> Self {
125 let days = (seconds / 86_400) as i64;
126 let rest = seconds % 86_400;
127 let (year, month, day) = civil_from_days(days);
128 Self {
129 year,
130 month,
131 day,
132 hour: (rest / 3_600) as u8,
133 minute: (rest % 3_600 / 60) as u8,
134 second: (rest % 60) as u8,
135 }
136 }
137
138 /// This instant shifted by a time-zone offset in minutes east of UTC,
139 /// for rendering a local time.
140 ///
141 /// The result is a wall-clock reading, not an instant: it no longer
142 /// converts back through [`to_unix`](Self::to_unix) to what it came
143 /// from, which is exactly what makes it presentation. `None` when the
144 /// shift leaves the representable range.
145 pub const fn shifted(&self, offset_minutes: i16) -> Option<Self> {
146 let Some(seconds) = self.to_unix() else {
147 return None;
148 };
149 let shifted = seconds as i64 + offset_minutes as i64 * 60;
150 if shifted < 0 || shifted > MAX_EPOCH as i64 {
151 return None;
152 }
153 Some(Self::from_unix(shifted as u32))
154 }
155}
156
157/// Days in `month` of `year`, 1-indexed. Zero for an out-of-range month,
158/// which makes an invalid month fail the day check in
159/// [`DateTime::is_valid`] rather than needing its own branch.
160pub const fn days_in_month(year: i32, month: u8) -> u8 {
161 match month {
162 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
163 4 | 6 | 9 | 11 => 30,
164 2 if is_leap_year(year) => 29,
165 2 => 28,
166 _ => 0,
167 }
168}
169
170/// The proleptic Gregorian leap-year rule.
171pub const fn is_leap_year(year: i32) -> bool {
172 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
173}
174
175/// Days since 1970-01-01 for a proleptic Gregorian date.
176///
177/// Hinnant's algorithm: shift the year so March is the first month, which
178/// puts the leap day at the end of the year and lets the day-of-year
179/// become a closed-form expression, then count 400-year eras — the cycle
180/// over which the Gregorian calendar exactly repeats.
181pub const fn days_from_civil(year: i32, month: u8, day: u8) -> i64 {
182 let y = if month <= 2 { year - 1 } else { year } as i64;
183 let era = if y >= 0 { y } else { y - 399 } / 400;
184 // Year within the era, 0–399.
185 let yoe = y - era * 400;
186 let m = month as i64;
187 let d = day as i64;
188 // Day within the March-based year, 0–365.
189 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
190 // Day within the era, 0–146096.
191 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
192 // 719468 is the day number of 1970-01-01 counted from the era start.
193 era * 146_097 + doe - 719_468
194}
195
196/// The proleptic Gregorian date `days` after 1970-01-01. The inverse of
197/// [`days_from_civil`].
198pub const fn civil_from_days(days: i64) -> (i32, u8, u8) {
199 let z = days + 719_468;
200 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
201 let doe = z - era * 146_097;
202 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
203 let y = yoe + era * 400;
204 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
205 let mp = (5 * doy + 2) / 153;
206 let d = doy - (153 * mp + 2) / 5 + 1;
207 let m = if mp < 10 { mp + 3 } else { mp - 9 };
208 let year = if m <= 2 { y + 1 } else { y };
209 (year as i32, m as u8, d as u8)
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[track_caller]
217 fn round_trip(seconds: u32, expected: DateTime) {
218 assert_eq!(DateTime::from_unix(seconds), expected, "from {seconds}");
219 assert_eq!(expected.to_unix(), Some(seconds), "to {seconds}");
220 }
221
222 #[test]
223 fn known_instants_round_trip() {
224 round_trip(0, DateTime::EPOCH);
225 round_trip(
226 1_000_000_000,
227 DateTime {
228 year: 2001,
229 month: 9,
230 day: 9,
231 hour: 1,
232 minute: 46,
233 second: 40,
234 },
235 );
236 // The 32-bit signed rollover everyone worries about is an
237 // ordinary instant here, because PROP_TIME is unsigned.
238 round_trip(
239 2_147_483_648,
240 DateTime {
241 year: 2038,
242 month: 1,
243 day: 19,
244 hour: 3,
245 minute: 14,
246 second: 8,
247 },
248 );
249 // The last second the encoding can express.
250 round_trip(
251 MAX_EPOCH,
252 DateTime {
253 year: 2106,
254 month: 2,
255 day: 7,
256 hour: 6,
257 minute: 28,
258 second: 15,
259 },
260 );
261 }
262
263 #[test]
264 fn leap_days_are_real_days() {
265 // 2000 is a leap year (the 400 rule), 1900 was not (the 100
266 // rule), 2024 is (the 4 rule).
267 assert!(is_leap_year(2000));
268 assert!(!is_leap_year(1900));
269 assert!(is_leap_year(2024));
270 assert_eq!(days_in_month(2024, 2), 29);
271 assert_eq!(days_in_month(2023, 2), 28);
272
273 let leap_day = DateTime {
274 year: 2024,
275 month: 2,
276 day: 29,
277 hour: 12,
278 minute: 0,
279 second: 0,
280 };
281 assert!(leap_day.is_valid());
282 let seconds = leap_day.to_unix().unwrap();
283 assert_eq!(DateTime::from_unix(seconds), leap_day);
284
285 let no_such_day = DateTime {
286 day: 29,
287 year: 2023,
288 ..leap_day
289 };
290 assert!(!no_such_day.is_valid());
291 assert_eq!(no_such_day.to_unix(), None);
292 }
293
294 /// Every day for eight years, across two leap years and a century
295 /// boundary that is *not* a leap year, must survive the round trip.
296 #[test]
297 fn every_day_of_several_years_round_trips() {
298 for year in 1897..=1905 {
299 for month in 1..=12u8 {
300 for day in 1..=days_in_month(year, month) {
301 // Before 1970 there is no epoch second, but the
302 // calendar arithmetic must still invert.
303 let days = days_from_civil(year, month, day);
304 assert_eq!(civil_from_days(days), (year, month, day));
305 }
306 }
307 }
308 for year in 2020..=2028 {
309 for month in 1..=12u8 {
310 for day in 1..=days_in_month(year, month) {
311 let civil = DateTime {
312 year,
313 month,
314 day,
315 hour: 6,
316 minute: 30,
317 second: 15,
318 };
319 let seconds = civil.to_unix().expect("in range");
320 assert_eq!(DateTime::from_unix(seconds), civil);
321 }
322 }
323 }
324 }
325
326 #[test]
327 fn out_of_range_instants_are_refused_rather_than_clamped() {
328 // A receiver reporting 1969 is reporting a fault.
329 let before = DateTime {
330 year: 1969,
331 month: 12,
332 day: 31,
333 hour: 23,
334 minute: 59,
335 second: 59,
336 };
337 assert!(before.is_valid());
338 assert_eq!(before.to_unix(), None);
339
340 let after = DateTime {
341 year: 2107,
342 month: 1,
343 day: 1,
344 hour: 0,
345 minute: 0,
346 second: 0,
347 };
348 assert!(after.is_valid());
349 assert_eq!(after.to_unix(), None);
350 }
351
352 #[test]
353 fn field_ranges_are_checked() {
354 let base = DateTime {
355 year: 2026,
356 month: 8,
357 day: 4,
358 hour: 12,
359 minute: 0,
360 second: 0,
361 };
362 assert!(base.is_valid());
363 assert!(!DateTime { month: 0, ..base }.is_valid());
364 assert!(!DateTime { month: 13, ..base }.is_valid());
365 assert!(!DateTime { day: 0, ..base }.is_valid());
366 assert!(!DateTime { day: 32, ..base }.is_valid());
367 assert!(!DateTime { hour: 24, ..base }.is_valid());
368 assert!(!DateTime { minute: 60, ..base }.is_valid());
369 // A leap second is not representable; the receiver's next
370 // sentence carries an ordinary one.
371 assert!(!DateTime { second: 60, ..base }.is_valid());
372 }
373
374 #[test]
375 fn shifting_produces_a_local_reading_across_a_date_boundary() {
376 // 2026-08-04T02:30:00Z is the previous evening in California.
377 let utc = DateTime {
378 year: 2026,
379 month: 8,
380 day: 4,
381 hour: 2,
382 minute: 30,
383 second: 0,
384 };
385 assert_eq!(
386 utc.shifted(-7 * 60),
387 Some(DateTime {
388 year: 2026,
389 month: 8,
390 day: 3,
391 hour: 19,
392 minute: 30,
393 second: 0,
394 })
395 );
396 // ...and the next morning in Auckland.
397 assert_eq!(
398 utc.shifted(12 * 60),
399 Some(DateTime {
400 year: 2026,
401 month: 8,
402 day: 4,
403 hour: 14,
404 minute: 30,
405 second: 0,
406 })
407 );
408 assert_eq!(utc.shifted(0), Some(utc));
409 // A shift off the end of the encoding has no reading.
410 assert_eq!(DateTime::from_unix(MAX_EPOCH).shifted(60), None);
411 assert_eq!(DateTime::EPOCH.shifted(-60), None);
412 }
413}