Skip to main content

gnss_time/
scale.rs

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