Skip to main content

gnss_time/
epoch.rs

1//! # Epochs and calendar arithmetic
2//!
3//! This module defines calendar epochs used by GNSS time scales and provides
4//! a minimal civil calendar type for epoch arithmetic.
5//!
6//! ## Overview
7//!
8//! GNSS time scales are anchored to fixed calendar epochs. This module
9//! provides:
10//!
11//! - [`CivilDate`] — proleptic Gregorian calendar date (UTC, no time-of-day)
12//! - Canonical epoch constants for supported GNSS time scales
13//! - Compile-time day and nanosecond offsets between epochs
14//!
15//! ## Epoch reference table
16//!
17//! | Scale   | Epoch (UTC)                      | TAI − UTC |
18//! |---------|----------------------------------|-----------|
19//! | GLONASS | 1996-01-01 00:00:00 UTC(SU)      | 30 s      |
20//! | GPS     | 1980-01-06 00:00:00 UTC          | 19 s      |
21//! | Galileo | 1999-08-22 00:00:00 UTC          | 32 s      |
22//! | BeiDou  | 2006-01-01 00:00:00 UTC          | 33 s      |
23//! | TAI     | 1958-01-01 00:00:00 (definition) | —         |
24//! | Unix    | 1970-01-01 00:00:00 UTC          | 10 s      |
25//!
26//! ## Notes on representation
27//!
28//! Each `Time<S>::EPOCH` corresponds to the epoch listed above. For all
29//! systems, internal representation is ultimately aligned through a TAI
30//! reference pivot defined in [`OffsetToTai`](crate::scale::OffsetToTai).
31//!
32//! The constants in this module define *calendar offsets only* and do not
33//! include leap second handling.
34
35/// Proleptic Gregorian calendar date.
36///
37/// A minimal date type used for epoch definitions and calendar arithmetic.
38///
39/// This type does not include time-of-day, timezone, or leap second
40/// information.
41///
42/// ## Validity
43///
44/// No validation is performed. Invalid dates are allowed and may produce
45/// undefined calendar results.
46///
47/// ## Examples
48///
49/// ```rust
50/// use gnss_time::{CivilDate, GALILEO_EPOCH, GPS_EPOCH};
51///
52/// let delta = GPS_EPOCH.seconds_until(GALILEO_EPOCH);
53/// assert_eq!(delta, 619_315_200);
54/// ```
55#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
56pub struct CivilDate {
57    /// Year (e.g. 1980)
58    pub year: i32,
59    /// Month (1–12)
60    pub month: u8,
61    /// Day of month (1–31)
62    pub day: u8,
63}
64
65impl CivilDate {
66    /// Creates a new calendar date.
67    ///
68    /// No validation is performed.
69    #[inline]
70    #[must_use]
71    pub const fn new(
72        year: i32,
73        month: u8,
74        day: u8,
75    ) -> Self {
76        CivilDate { year, month, day }
77    }
78
79    /// Returns the number of days since the Unix epoch (`1970-01-01`).
80    ///
81    /// Negative values indicate dates before the epoch.
82    ///
83    /// Uses Howard Hinnant’s algorithm.
84    #[inline]
85    #[must_use]
86    pub const fn days_from_unix(self) -> i64 {
87        days_from_unix_impl(self.year, self.month as i32, self.day as i32)
88    }
89
90    /// Returns the signed difference in days (`other - self`).
91    #[inline]
92    #[must_use]
93    pub const fn days_until(
94        self,
95        other: CivilDate,
96    ) -> i64 {
97        other.days_from_unix() - self.days_from_unix()
98    }
99
100    /// Returns the difference in whole seconds.
101    ///
102    /// Time-of-day is not considered.
103    #[inline]
104    #[must_use]
105    pub const fn seconds_until(
106        self,
107        other: CivilDate,
108    ) -> i64 {
109        self.days_until(other) * 86_400
110    }
111
112    /// Returns the difference in nanoseconds.
113    ///
114    /// Time-of-day is not considered.
115    #[inline]
116    #[must_use]
117    pub const fn nanos_until(
118        self,
119        other: CivilDate,
120    ) -> i64 {
121        self.seconds_until(other) * 1_000_000_000
122    }
123}
124
125/// Converts a civil date to days since Unix epoch.
126///
127/// Implementation based on Howard Hinnant’s algorithm:
128/// <http://howardhinnant.github.io/date_algorithms.html>
129const fn days_from_unix_impl(
130    y: i32,
131    m: i32,
132    d: i32,
133) -> i64 {
134    // Shift January/February so they become months 11/12 of the previous year.
135    // This ensures the leap day (Feb 29) always falls at the end of the "year".
136    let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
137    let y = y as i64;
138    // 400-year era containing year y
139    let era = if y >= 0 { y / 400 } else { (y - 399) / 400 };
140    let yoe = (y - era * 400) as u64; // год внутри эры [0, 399]
141
142    // Day of year in shifted month system [0, 365]
143    let doy = ((153 * m as i64 + 2) / 5 + d as i64 - 1) as u64;
144    // Day within 400-year era [0, 146096]
145    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
146    // Days since 1970-01-01 (719468 = offset from start of 400-year era to 1970)
147    era * 146_097 + doe as i64 - 719_468
148}
149
150/// TAI epoch (1958-01-01).
151pub const TAI_EPOCH: CivilDate = CivilDate::new(1958, 1, 1);
152
153/// Unix epoch (1970-01-01).
154pub const UNIX_EPOCH: CivilDate = CivilDate::new(1970, 1, 1);
155
156/// GPS epoch (1980-01-06).
157pub const GPS_EPOCH: CivilDate = CivilDate::new(1980, 1, 6);
158
159/// GLONASS epoch (1996-01-01 UTC(SU)).
160pub const GLONASS_EPOCH: CivilDate = CivilDate::new(1996, 1, 1);
161
162/// Galileo epoch (1999-08-22).
163pub const GALILEO_EPOCH: CivilDate = CivilDate::new(1999, 8, 22);
164
165/// BeiDou epoch (2006-01-01).
166pub const BEIDOU_EPOCH: CivilDate = CivilDate::new(2006, 1, 1);
167
168/// TAI − UTC at GPS epoch.
169pub const LEAP_SECONDS_AT_GPS_EPOCH: i64 = 19;
170
171/// TAI − UTC at GLONASS epoch.
172pub const LEAP_SECONDS_AT_GLONASS_EPOCH: i64 = 30;
173
174/// TAI − UTC at Galileo epoch.
175pub const LEAP_SECONDS_AT_GALILEO_EPOCH: i64 = 32;
176
177/// TAI − UTC at BeiDou epoch.
178pub const LEAP_SECONDS_AT_BEIDOU_EPOCH: i64 = 33;
179
180/// Days between GPS and Galileo epochs.
181pub const DAYS_GPS_TO_GALILEO: i64 = GPS_EPOCH.days_until(GALILEO_EPOCH);
182
183/// Days between GPS and BeiDou epochs.
184pub const DAYS_GPS_TO_BEIDOU: i64 = GPS_EPOCH.days_until(BEIDOU_EPOCH);
185
186/// Days between GPS and GLONASS epochs.
187pub const DAYS_GPS_TO_GLONASS: i64 = GPS_EPOCH.days_until(GLONASS_EPOCH);
188
189/// Days between Unix and GPS epochs.
190pub const DAYS_UNIX_TO_GPS: i64 = UNIX_EPOCH.days_until(GPS_EPOCH);
191
192/// Nanoseconds between GPS and Galileo epochs.
193pub const NANOS_GPS_TO_GALILEO_EPOCH: i64 = GPS_EPOCH.nanos_until(GALILEO_EPOCH);
194
195/// Nanoseconds between GPS and BeiDou epochs (calendar only).
196pub const NANOS_GPS_TO_BEIDOU_EPOCH_CALENDAR: i64 = GPS_EPOCH.nanos_until(BEIDOU_EPOCH);
197
198// Galileo−GPS calendar delta must equal 619 315 200 s.
199const _VERIFY_GALILEO: () = {
200    let s = NANOS_GPS_TO_GALILEO_EPOCH / 1_000_000_000;
201    assert!(s == 619_315_200, "Galileo epoch offset check failed");
202};
203
204// BeiDou−GPS calendar delta must equal 820 108 800 s.
205const _VERIFY_BEIDOU: () = {
206    let s = NANOS_GPS_TO_BEIDOU_EPOCH_CALENDAR / 1_000_000_000;
207    assert!(s == 820_108_800, "BeiDou epoch offset check failed");
208};
209
210// GPS epoch must be 3657 days from Unix epoch.
211const _VERIFY_GPS_UNIX: () = {
212    assert!(DAYS_UNIX_TO_GPS == 3657, "GPS Unix offset check failed");
213};
214
215// GLONASS epoch must be 5839 days from GPS epoch.
216const _VERIFY_GLONASS: () = {
217    assert!(
218        DAYS_GPS_TO_GLONASS == 5839,
219        "GLONASS epoch offset check failed"
220    );
221};
222
223impl core::fmt::Display for CivilDate {
224    fn fmt(
225        &self,
226        f: &mut core::fmt::Formatter<'_>,
227    ) -> core::fmt::Result {
228        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
229    }
230}
231
232////////////////////////////////////////////////////////////////////////////////
233// Tests
234////////////////////////////////////////////////////////////////////////////////
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn test_unix_epoch_is_day_zero() {
242        assert_eq!(UNIX_EPOCH.days_from_unix(), 0);
243    }
244
245    #[test]
246    fn test_gps_epoch_is_3657_days_from_unix() {
247        assert_eq!(GPS_EPOCH.days_from_unix(), 3657);
248    }
249
250    #[test]
251    fn test_galileo_epoch_days_from_unix() {
252        // 1999-08-22: well-known reference value
253        assert_eq!(GALILEO_EPOCH.days_from_unix(), 10825);
254    }
255
256    #[test]
257    fn test_beidou_epoch_days_from_unix() {
258        // 2006-01-01
259        assert_eq!(BEIDOU_EPOCH.days_from_unix(), 13149);
260    }
261
262    #[test]
263    fn test_glonass_epoch_days_from_unix() {
264        // 1996-01-01
265        assert_eq!(GLONASS_EPOCH.days_from_unix(), 9496);
266    }
267
268    #[test]
269    fn test_gps_to_galileo_is_7168_days() {
270        assert_eq!(DAYS_GPS_TO_GALILEO, 7168);
271    }
272
273    #[test]
274    fn test_gps_to_beidou_is_9492_days() {
275        assert_eq!(DAYS_GPS_TO_BEIDOU, 9492);
276    }
277
278    #[test]
279    fn test_gps_to_glonass_is_5839_days() {
280        assert_eq!(DAYS_GPS_TO_GLONASS, 5839);
281    }
282
283    #[test]
284    fn test_galileo_minus_gps_is_619315200_seconds() {
285        assert_eq!(GPS_EPOCH.seconds_until(GALILEO_EPOCH), 619_315_200);
286    }
287
288    #[test]
289    fn test_beidou_minus_gps_calendar_is_820108800_seconds() {
290        assert_eq!(GPS_EPOCH.seconds_until(BEIDOU_EPOCH), 820_108_800);
291    }
292
293    #[test]
294    fn test_glonass_minus_gps_is_505123200_seconds() {
295        // 5839 days * 86_400 = 504_921_600 seconds
296        let expected = 5839_i64 * 86_400;
297
298        assert_eq!(GPS_EPOCH.seconds_until(GLONASS_EPOCH), expected);
299    }
300
301    #[test]
302    fn test_days_until_is_antisymmetric() {
303        let a = CivilDate::new(2000, 1, 1);
304        let b = CivilDate::new(2001, 1, 1);
305
306        assert_eq!(a.days_until(b), -b.days_until(a));
307    }
308
309    #[test]
310    fn test_days_until_self_is_zero() {
311        assert_eq!(GPS_EPOCH.days_until(GPS_EPOCH), 0);
312    }
313
314    #[test]
315    fn test_year_2000_is_leap_year() {
316        // 2000-02-29 is a valid date; 2000-03-01 = 2000-02-29 + 1 day
317        let feb29 = CivilDate::new(2000, 2, 29);
318        let mar01 = CivilDate::new(2000, 3, 1);
319
320        assert_eq!(feb29.days_until(mar01), 1);
321    }
322
323    #[test]
324    fn test_year_1900_is_not_leap_year() {
325        // 1900 is divisible by 100 but not by 400 → not a leap year
326        let feb28 = CivilDate::new(1900, 2, 28);
327        let mar01 = CivilDate::new(1900, 3, 1);
328
329        // Если бы 1900 был високосным годом, разрыв был бы 2 дня. Но он равен 1.
330        assert_eq!(feb28.days_until(mar01), 1);
331    }
332
333    #[test]
334    fn test_epoch_dates_are_correct() {
335        assert_eq!(GPS_EPOCH, CivilDate::new(1980, 1, 6));
336        assert_eq!(GLONASS_EPOCH, CivilDate::new(1996, 1, 1));
337        assert_eq!(GALILEO_EPOCH, CivilDate::new(1999, 8, 22));
338        assert_eq!(BEIDOU_EPOCH, CivilDate::new(2006, 1, 1));
339        assert_eq!(TAI_EPOCH, CivilDate::new(1958, 1, 1));
340        assert_eq!(UNIX_EPOCH, CivilDate::new(1970, 1, 1));
341    }
342
343    #[test]
344    fn test_leap_seconds_at_epochs_match_official_values() {
345        // Historical IERS leap second table
346        assert_eq!(LEAP_SECONDS_AT_GPS_EPOCH, 19);
347        assert_eq!(LEAP_SECONDS_AT_GLONASS_EPOCH, 30);
348        assert_eq!(LEAP_SECONDS_AT_BEIDOU_EPOCH, 33);
349    }
350
351    #[test]
352    fn test_nanos_gps_to_galileo_matches_known_value() {
353        assert_eq!(NANOS_GPS_TO_GALILEO_EPOCH, 619_315_200_000_000_000_i64);
354    }
355
356    #[test]
357    fn test_nanos_gps_to_beidou_calendar_matches_known_value() {
358        assert_eq!(
359            NANOS_GPS_TO_BEIDOU_EPOCH_CALENDAR,
360            820_108_800_000_000_000_i64
361        );
362    }
363
364    #[test]
365    fn test_pre_unix_date_is_negative() {
366        let date = CivilDate::new(1960, 1, 1);
367
368        assert!(date.days_from_unix() < 0);
369    }
370
371    #[test]
372    fn test_invalid_date_does_not_panic() {
373        let date = CivilDate::new(2024, 13, 40);
374        let _ = date.days_from_unix(); // просто проверка устойчивости
375    }
376
377    #[test]
378    fn test_ordering_is_consistent() {
379        let a = CivilDate::new(2000, 1, 1);
380        let b = CivilDate::new(2001, 1, 1);
381
382        assert!(a.days_from_unix() < b.days_from_unix());
383    }
384
385    #[test]
386    fn test_seconds_until_matches_days() {
387        let a = CivilDate::new(2000, 1, 1);
388        let b = CivilDate::new(2000, 1, 2);
389
390        assert_eq!(a.seconds_until(b), 86_400);
391    }
392
393    #[test]
394    fn test_nanos_until_matches_seconds() {
395        let a = CivilDate::new(2000, 1, 1);
396        let b = CivilDate::new(2000, 1, 2);
397
398        assert_eq!(a.nanos_until(b), 86_400_000_000_000);
399    }
400
401    #[test]
402    fn test_gps_epoch_days_constant_is_stable() {
403        assert_eq!(GPS_EPOCH.days_from_unix(), 3657);
404    }
405
406    #[test]
407    fn test_monotonicity_property() {
408        let a = CivilDate::new(2000, 1, 1);
409        let b = CivilDate::new(2000, 1, 2);
410        let c = CivilDate::new(2000, 1, 3);
411
412        assert!(a.days_from_unix() < b.days_from_unix());
413        assert!(b.days_from_unix() < c.days_from_unix());
414    }
415}