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}