Skip to main content

gnss_time/
scale.rs

1//! # GNSS time scale marker types
2//!
3//! Each GNSS system operates on its own time scale with a fixed relationship
4//! to TAI (International Atomic Time).
5//!
6//! ## Sealed trait
7//!
8//! [`TimeScale`] cannot be implemented outside this crate — the sealed pattern
9//! prevents accidental addition of custom time scales.
10//!
11//! ## Display formats
12//!
13//! | Scale   | Example format              |
14//! |---------|-----------------------------|
15//! | GLONASS | `"GLO 10512:43200.000"`     |
16//! | GPS     | `"GPS 2345:432000.000"`     |
17//! | Galileo | `"GAL 1303:432000.000"`     |
18//! | BeiDou  | `"BDT 960:432000.000"`      |
19//! | TAI     | `"TAI +1000000000s 0ns"`    |
20//! | UTC     | `"UTC +1000000000s 0ns"`    |
21
22use crate::epoch::CivilDate;
23
24// Sealed pattern — prevents external implementations
25mod private {
26    pub trait Sealed {}
27}
28
29macro_rules! define_scale {
30    (
31        $(#[$meta:meta])*
32        $name:ident,
33        display  = $display:literal,
34        offset   = $offset:expr,
35        epoch    = $epoch:expr,
36        style    = $style:expr
37    ) => {
38        $(#[$meta])*
39        #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
40        pub struct $name;
41
42        impl private::Sealed for $name {}
43
44        impl TimeScale for $name {
45            const NAME:          &'static str  = $display;
46            const OFFSET_TO_TAI: OffsetToTai   = $offset;
47            const EPOCH_CIVIL:   CivilDate     = $epoch;
48            const DISPLAY_STYLE: DisplayStyle  = $style;
49        }
50    };
51}
52
53pub(crate) const NANOS_PER_SECOND: i64 = 1_000_000_000;
54
55/// Relationship between a time scale and TAI.
56///
57/// Strict contract:
58///     T_tai = T_self + offset
59///
60/// This must be consistent for all scales.
61/// Violating it breaks cross-scale conversions.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63pub enum OffsetToTai {
64    /// Fixed offset (does not require leap seconds)
65    Fixed(i64),
66
67    /// Depends on external context (UTC, GLONASS)
68    Contextual,
69}
70
71/// Controls how [`crate::Time`]`<S>` is formatted via
72/// [`core::fmt::Display`].
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
74pub enum DisplayStyle {
75    /// `"NAME WWW:SSSSSS.mm"` — week : time-of-week (GPS, Galileo, BeiDou)
76    ///
77    /// The TOW seconds field is always zero-padded to **6 digits**
78    /// (maximum 604_799 s).
79    WeekTow,
80
81    /// `"NAME DDDDD:SSSSS.mmm"` — day : time-of-day (GLONASS)
82    ///
83    /// The TOD seconds field is always zero-padded to **5 digits**
84    /// (maximum 86_399 s).
85    DayTod,
86
87    /// `"NAME +Ss Nns"` — simple nanosecond format for (TAI, UTC)
88    Simple,
89}
90
91/// Marker trait for GNSS / atomic time scales.
92///
93/// This trait is **sealed** and cannot be implemented outside this crate.
94///
95/// Each scale defines:
96/// - [`TimeScale::NAME`] — short name
97/// - [`TimeScale::OFFSET_TO_TAI`] — conversion to TAI
98pub trait TimeScale: private::Sealed + Copy + Clone + Eq + PartialEq + core::fmt::Debug {
99    /// Short ASCII name of the scale, used in Display/debug output.
100    const NAME: &'static str;
101
102    /// Offset relative to TAI:
103    ///
104    /// STRICT CONTRACT:
105    ///     T_tai = T_self + offset
106    ///
107    /// For contextual scales (UTC, GLONASS),
108    /// leap-second handling is required.
109    const OFFSET_TO_TAI: OffsetToTai;
110
111    /// Civil date of the scale's epoch
112    /// (where `Time<S>::EPOCH == 0 ns`)
113    const EPOCH_CIVIL: CivilDate;
114
115    /// Time display format
116    const DISPLAY_STYLE: DisplayStyle;
117}
118
119define_scale!(
120    /// GLONASS — Russian time system (UTC(SU) + 3 hours)
121    ///
122    /// - Epoch: 1996-01-01 00:00:00 UTC(SU)
123    /// - Operates relative to UTC(SU)
124    /// - Requires leap-second handling
125    /// - Format: `"GLO 10512:43200.000"`
126    Glonass,
127    display = "GLO",
128    offset = OffsetToTai::Contextual,
129    epoch   = CivilDate::new(1996, 1, 1),
130    style   = DisplayStyle::DayTod
131);
132
133define_scale!(
134    /// GPS — American Global Positioning System
135    ///
136    /// - Epoch: 1980-01-06 UTC
137    /// - GPS = TAI − 19 seconds
138    /// - No leap seconds (fixed offset)
139    /// - Format: `"GPS 2345:432000.000"`
140    Gps,
141    display = "GPS",
142    offset  = OffsetToTai::Fixed(19 * NANOS_PER_SECOND),
143    epoch   = CivilDate::new(1980, 1, 6),
144    style   = DisplayStyle::WeekTow
145);
146
147define_scale!(
148    /// Galileo — European navigation system (GST)
149    ///
150    /// - Epoch: 1999-08-22 UTC
151    /// - Same offset as GPS (TAI − 19 s)
152    /// - Equal numeric values represent the same physical instant
153    /// - Format: `"GAL 1303:432000.000"`
154    Galileo,
155    display = "GAL",
156    offset = OffsetToTai::Fixed(19 * NANOS_PER_SECOND),
157    epoch = CivilDate::new(1999, 8, 22),
158    style   = DisplayStyle::WeekTow
159);
160
161define_scale!(
162    /// BeiDou — Chinese navigation system (BDT)
163    ///
164    /// - Epoch: 2006-01-01 UTC
165    /// - BDT = TAI − 33 seconds
166    /// - BDT = GPS − 14 seconds
167    /// - Format: `"BDT 960:432000.000"`
168    Beidou,
169    display = "BDT",
170    offset = OffsetToTai::Fixed(33 * NANOS_PER_SECOND),
171    epoch = CivilDate::new(2006, 1, 1),
172    style = DisplayStyle::WeekTow
173);
174
175define_scale!(
176    /// TAI — International Atomic Time
177    ///
178    /// - Epoch: 1958-01-01
179    /// - Base scale for all conversions
180    /// - TAI = TAI + 0
181    /// - Format: `"TAI +Ss Nns"`
182    ///
183    /// # Important
184    ///
185    /// Inside this crate, TAI is used as the pivot for conversions,
186    /// not as an absolute scale from 1958 onward (this is planned separately).
187    Tai,
188    display = "TAI",
189    offset = OffsetToTai::Fixed(0),
190    epoch = CivilDate::new(1958, 1, 1),
191    style = DisplayStyle::Simple
192);
193
194define_scale!(
195    /// UTC — Coordinated Universal Time
196    ///
197    /// - UTC = TAI − LS(t)
198    /// - Requires a runtime leap-second table
199    /// - Format: `"UTC +Ss Nns"`
200    Utc,
201    display = "UTC",
202    offset = OffsetToTai::Contextual,
203    epoch = CivilDate::new(1972, 1, 1),
204    style = DisplayStyle::Simple
205);
206
207impl OffsetToTai {
208    /// Returns the fixed offset in nanoseconds.
209    #[inline(always)]
210    #[must_use]
211    pub const fn fixed(self) -> Option<i64> {
212        match self {
213            OffsetToTai::Fixed(v) => Some(v),
214            OffsetToTai::Contextual => None,
215        }
216    }
217
218    /// Returns `true` for scales that require runtime context (UTC, GLONASS).
219    #[inline(always)]
220    #[must_use]
221    pub const fn is_contextual(self) -> bool {
222        matches!(self, OffsetToTai::Contextual)
223    }
224
225    /// Returns `true` for scale with a fixed TAI offset.
226    #[inline(always)]
227    #[must_use]
228    pub const fn is_fixed(self) -> bool {
229        matches!(self, OffsetToTai::Fixed(_))
230    }
231}
232
233////////////////////////////////////////////////////////////////////////////////
234// Tests
235////////////////////////////////////////////////////////////////////////////////
236
237#[cfg(test)]
238mod tests {
239    use std::{collections::HashSet, mem::size_of};
240
241    use super::*;
242
243    #[test]
244    fn test_name_are_correct() {
245        assert_eq!(Glonass::NAME, "GLO");
246        assert_eq!(Gps::NAME, "GPS");
247        assert_eq!(Galileo::NAME, "GAL");
248        assert_eq!(Beidou::NAME, "BDT");
249        assert_eq!(Tai::NAME, "TAI");
250        assert_eq!(Utc::NAME, "UTC");
251    }
252
253    #[test]
254    fn test_fixed_offsets() {
255        assert_eq!(
256            Gps::OFFSET_TO_TAI,
257            OffsetToTai::Fixed(19 * NANOS_PER_SECOND)
258        );
259        assert_eq!(
260            Galileo::OFFSET_TO_TAI,
261            OffsetToTai::Fixed(19 * NANOS_PER_SECOND)
262        );
263        assert_eq!(
264            Beidou::OFFSET_TO_TAI,
265            OffsetToTai::Fixed(33 * NANOS_PER_SECOND)
266        );
267        assert_eq!(Tai::OFFSET_TO_TAI, OffsetToTai::Fixed(0));
268    }
269
270    #[test]
271    fn test_contextual_offsets() {
272        assert_eq!(Utc::OFFSET_TO_TAI, OffsetToTai::Contextual);
273        assert_eq!(Glonass::OFFSET_TO_TAI, OffsetToTai::Contextual);
274    }
275
276    #[test]
277    fn test_scale_types_are_copy() {
278        fn assert_copy<T: Copy>() {}
279        assert_copy::<Glonass>();
280        assert_copy::<Gps>();
281        assert_copy::<Galileo>();
282        assert_copy::<Beidou>();
283        assert_copy::<Tai>();
284        assert_copy::<Utc>();
285    }
286
287    #[test]
288    fn test_gps_and_galileo_are_aligned() {
289        // Same TAI offset → synchronous (time-aligned) instants
290        assert_eq!(Gps::OFFSET_TO_TAI, Galileo::OFFSET_TO_TAI);
291    }
292
293    #[test]
294    fn test_tai_invariant_is_valid() {
295        assert_eq!(Tai::OFFSET_TO_TAI, OffsetToTai::Fixed(0));
296        assert!(Tai::OFFSET_TO_TAI.fixed().unwrap() == 0);
297    }
298
299    #[test]
300    fn test_names_are_unique() {
301        let names = [
302            Gps::NAME,
303            Glonass::NAME,
304            Galileo::NAME,
305            Beidou::NAME,
306            Tai::NAME,
307            Utc::NAME,
308        ];
309        let set: HashSet<_> = names.iter().collect();
310
311        assert_eq!(set.len(), names.len());
312    }
313
314    #[test]
315    fn test_fixed_scales_are_really_fixed() {
316        let fixed_scales = [
317            Gps::OFFSET_TO_TAI,
318            Galileo::OFFSET_TO_TAI,
319            Beidou::OFFSET_TO_TAI,
320            Tai::OFFSET_TO_TAI,
321        ];
322
323        for scale in fixed_scales {
324            assert!(scale.fixed().is_some(), "Expected Fixed, got Contextual");
325        }
326    }
327
328    #[test]
329    fn test_contextual_only_where_expected() {
330        assert!(Utc::OFFSET_TO_TAI.is_contextual());
331        assert!(Glonass::OFFSET_TO_TAI.is_contextual());
332    }
333
334    #[test]
335    fn test_scale_is_zero_sized() {
336        assert_eq!(size_of::<Glonass>(), 0);
337        assert_eq!(size_of::<Gps>(), 0);
338        assert_eq!(size_of::<Galileo>(), 0);
339        assert_eq!(size_of::<Beidou>(), 0);
340        assert_eq!(size_of::<Tai>(), 0);
341        assert_eq!(size_of::<Utc>(), 0);
342    }
343
344    #[test]
345    fn test_scale_is_copy() {
346        fn assert_copy<T: Copy + Clone + Eq + PartialEq + core::fmt::Debug>() {}
347        assert_copy::<Glonass>();
348        assert_copy::<Gps>();
349        assert_copy::<Galileo>();
350        assert_copy::<Beidou>();
351        assert_copy::<Tai>();
352        assert_copy::<Utc>();
353    }
354
355    #[test]
356    fn test_display_styles() {
357        assert_eq!(Gps::DISPLAY_STYLE, DisplayStyle::WeekTow);
358        assert_eq!(Glonass::DISPLAY_STYLE, DisplayStyle::DayTod);
359        assert_eq!(Galileo::DISPLAY_STYLE, DisplayStyle::WeekTow);
360        assert_eq!(Beidou::DISPLAY_STYLE, DisplayStyle::WeekTow);
361        assert_eq!(Tai::DISPLAY_STYLE, DisplayStyle::Simple);
362        assert_eq!(Utc::DISPLAY_STYLE, DisplayStyle::Simple);
363    }
364
365    #[test]
366    fn test_offset_to_tai_helpers() {
367        assert!(OffsetToTai::Fixed(0).is_fixed());
368        assert!(!OffsetToTai::Fixed(0).is_contextual());
369        assert!(OffsetToTai::Contextual.is_contextual());
370        assert!(!OffsetToTai::Contextual.is_fixed());
371        assert_eq!(OffsetToTai::Fixed(42).fixed(), Some(42));
372        assert_eq!(OffsetToTai::Contextual.fixed(), None);
373    }
374
375    #[test]
376    fn test_epoch_civil_dates() {
377        assert_eq!(Gps::EPOCH_CIVIL.year, 1980);
378        assert_eq!(Glonass::EPOCH_CIVIL.year, 1996);
379        assert_eq!(Galileo::EPOCH_CIVIL.year, 1999);
380        assert_eq!(Beidou::EPOCH_CIVIL.year, 2006);
381        assert_eq!(Tai::EPOCH_CIVIL.year, 1958);
382    }
383
384    #[test]
385    fn test_tai_invariant() {
386        assert_eq!(Tai::OFFSET_TO_TAI, OffsetToTai::Fixed(0));
387        assert_eq!(Tai::OFFSET_TO_TAI.fixed(), Some(0));
388    }
389
390    #[test]
391    fn test_contract_all_scales() {
392        fn check<T: TimeScale>() {
393            match T::OFFSET_TO_TAI {
394                OffsetToTai::Fixed(0) => assert_eq!(T::NAME, "TAI"),
395                OffsetToTai::Fixed(_) => { /* GPS, GAL, BDT */ }
396                OffsetToTai::Contextual => {
397                    assert!(T::NAME == "UTC" || T::NAME == "GLO")
398                }
399            }
400        }
401        check::<Gps>();
402        check::<Glonass>();
403        check::<Galileo>();
404        check::<Beidou>();
405        check::<Tai>();
406        check::<Utc>();
407    }
408}