Skip to main content

gnss_time/
matrix.rs

1//! # Conversion matrix: the full graph of supported transformations
2//!
3//! This module documents and validates the **complete matrix** of allowed
4//! conversions between time scales, and also provides
5//! [`crate::matrix::ConversionMatrix`],
6//! a runtime type for checking scale compatibility.
7//!
8//! ## Offset table (sources: ICD-GLONASS, IS-GPS-200, OS-SIS-ICD Galileo, BDS-SIS-ICD)
9//!
10//! | From \ To  | GLONASS     | GPS        | Galileo    | BeiDou     | TAI        | UTC         |
11//! |------------|-------------|------------|------------|------------|------------|-------------|
12//! | **GLONASS**| —           | via UTC+LS | via UTC+LS | via UTC+LS | no (ctx)   | +757371600c |
13//! | **GPS**    | via UTC+LS  | —          | identity   | −14c       | +19c       | via LS      |
14//! | **Galileo**| via UTC+LS  | identity   | —          | −14c       | +19c       | via LS      |
15//! | **BeiDou** | via UTC+LS  | +14c       | +14c       | —          | +33c       | via LS      |
16//! | **TAI**    | no (ctx)    | −19c       | −19c       | −33c       | —          | via LS      |
17//! | **UTC**    | +757371600s | via LS     | via LS     | via LS     | via LS     | —           |
18
19use crate::{
20    Beidou, Glonass, GnssTimeError, Gps, IntoScale, IntoScaleWith, LeapSecondsProvider, Tai, Time,
21    Utc,
22};
23
24/// GPS offset relative to TAI in nanoseconds (GPS = TAI - 19 s).
25pub const TAI_OFFSET_GPS_NS: i64 = 19 * 1_000_000_000;
26
27/// Galileo offset relative to TAI in nanoseconds (GAL = TAI - 19 s).
28pub const TAI_OFFSET_GALILEO_NS: i64 = 19 * 1_000_000_000;
29
30/// BeiDou offset relative to TAI in nanoseconds (BDT = TAI - 33 s).
31pub const TAI_OFFSET_BEIDOU_NS: i64 = 33 * 1_000_000_000;
32
33/// TAI offset relative to itself (0 nanoseconds).
34pub const TAI_OFFSET_TAI_NS: i64 = 0;
35
36/// Constant epoch shift between GLONASS and UTC in nanoseconds.
37///
38/// GLONASS epoch (1996-01-01 00:00:00 UTC(SU)) is 757_371_600 seconds ahead
39/// of the UTC epoch (1972-01-01).
40pub const GLONASS_UTC_EPOCH_SHIFT_NS: i64 = 757_371_600 * 1_000_000_000;
41
42/// Conversion kind between two time scales.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44#[non_exhaustive]
45pub enum ConversionKind {
46    /// Fixed offset — no context required.
47    Fixed,
48
49    /// Identity mapping (GPS <-> Galileo: same nanoseconds).
50    Identity,
51
52    /// Constant epoch shift without leap-second context (GLONASS <-> UTC).
53    EpochShift,
54
55    /// Requires [`LeapSecondsProvider`].
56    Contextual,
57
58    /// Same scale (no conversion needed).
59    SameScale,
60}
61
62/// Runtime time-scale identifier.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64#[non_exhaustive]
65pub enum ScaleId {
66    /// GLONASS time scale.
67    Glonass,
68
69    /// GPS time scale.
70    Gps,
71
72    /// Galileo time scale.
73    Galileo,
74
75    /// BeiDou time scale.
76    Beidou,
77
78    /// International Atomic Time.
79    Tai,
80
81    /// Coordinated Universal Time.
82    Utc,
83}
84
85/// Conversion matrix: documents and validates all allowed routes between the
86/// supported time scales.
87///
88/// # Example
89///
90/// ```rust
91/// use gnss_time::{ConversionMatrix, ScaleId};
92///
93/// // Check that GPS <-> Galileo is a fixed conversion
94/// assert!(ScaleId::Gps.is_fixed(ScaleId::Galileo));
95///
96/// // Check that GPS <-> UTC requires leap seconds
97/// assert!(ScaleId::Gps.needs_leap_seconds(ScaleId::Utc));
98///
99/// // Full 6x6 matrix
100/// let matrix = ConversionMatrix::new();
101///
102/// assert_eq!(matrix.path_count(false), 14); // fixed paths
103/// assert_eq!(matrix.path_count(true), 16); // contextual paths
104/// ```
105pub struct ConversionMatrix;
106
107/// Result of the end-to-end conversion BeiDou -> GPS -> GLONASS -> UTC -> TAI.
108#[derive(Debug)]
109pub struct ConversionChain {
110    /// GLONASS time.
111    pub glonass: Time<Glonass>,
112
113    /// GPS time.
114    pub gps: Time<Gps>,
115
116    /// UTC time.
117    pub utc: Time<Utc>,
118
119    /// TAI time.
120    pub tai: Time<Tai>,
121}
122
123impl ScaleId {
124    /// All supported scales.
125    pub const ALL: [ScaleId; 6] = [
126        ScaleId::Glonass,
127        ScaleId::Gps,
128        ScaleId::Galileo,
129        ScaleId::Beidou,
130        ScaleId::Tai,
131        ScaleId::Utc,
132    ];
133
134    /// Returns the ASCII name of the scale.
135    #[inline]
136    #[must_use]
137    pub const fn name(self) -> &'static str {
138        match self {
139            ScaleId::Glonass => "GLO",
140            ScaleId::Gps => "GPS",
141            ScaleId::Galileo => "GAL",
142            ScaleId::Beidou => "BDT",
143            ScaleId::Tai => "TAI",
144            ScaleId::Utc => "UTC",
145        }
146    }
147
148    /// Determines the conversion kind between the current scale and a target
149    /// scale.
150    ///
151    /// # Parameters
152    /// - `target` — target time scale
153    ///
154    /// # Returns
155    /// The conversion kind: fixed, identity, epoch shift, contextual, or same
156    /// scale.
157    #[inline]
158    #[must_use]
159    pub const fn conversion_kind(
160        self,
161        target: ScaleId,
162    ) -> ConversionKind {
163        use ConversionKind::*;
164        use ScaleId::*;
165        match (self, target) {
166            // Same scale → no conversion needed
167            (a, b) if a as u8 == b as u8 => SameScale,
168            // GPS <-> TAI
169            (Gps, Tai) | (Tai, Gps) => Fixed,
170            // GPS <-> Galileo: identical TAI offset (19 s)
171            (Gps, Galileo) | (Galileo, Gps) => Identity,
172            // GPS <-> BeiDou: fixed ±14 seconds offset
173            (Gps, Beidou) | (Beidou, Gps) => Fixed,
174            // Galileo <-> BeiDou: same fixed relationship via TAI
175            (Galileo, Beidou) | (Beidou, Galileo) => Fixed,
176            // Galileo <-> TAI, BeiDou <-> TAI
177            (Galileo, Tai) | (Tai, Galileo) => Fixed,
178            (Beidou, Tai) | (Tai, Beidou) => Fixed,
179            // GLONASS <-> UTC: epoch shift, no leap-second handling
180            (Glonass, Utc) | (Utc, Glonass) => EpochShift,
181            // All conversions involving UTC require leap-second context
182            (Gps, Utc) | (Utc, Gps) => Contextual,
183            (Gps, Glonass) | (Glonass, Gps) => Contextual,
184            (Galileo, Utc) | (Utc, Galileo) => Contextual,
185            (Galileo, Glonass) | (Glonass, Galileo) => Contextual,
186            (Beidou, Utc) | (Utc, Beidou) => Contextual,
187            (Beidou, Glonass) | (Glonass, Beidou) => Contextual,
188            // TAI <-> UTC and TAI <-> GLONASS: context-dependent (leap seconds / epoch)
189            (Tai, Utc) | (Utc, Tai) => Contextual,
190            (Tai, Glonass) | (Glonass, Tai) => Contextual,
191            // Default for future or unknown scales
192            _ => Contextual,
193        }
194    }
195
196    /// Returns `true` if the conversion `self -> target` does not require leap
197    /// second context.
198    #[inline]
199    #[must_use]
200    pub const fn is_fixed(
201        self,
202        target: ScaleId,
203    ) -> bool {
204        matches!(
205            self.conversion_kind(target),
206            ConversionKind::Fixed | ConversionKind::Identity | ConversionKind::EpochShift
207        )
208    }
209
210    /// Returns `true` if the conversion requires a [`LeapSecondsProvider`].
211    #[inline]
212    #[must_use]
213    pub const fn needs_leap_seconds(
214        self,
215        target: ScaleId,
216    ) -> bool {
217        matches!(self.conversion_kind(target), ConversionKind::Contextual)
218    }
219}
220
221impl ConversionMatrix {
222    /// Creates a new conversion matrix.
223    #[inline]
224    #[must_use]
225    pub fn new() -> Self {
226        ConversionMatrix
227    }
228
229    /// Returns the number of paths of the requested type (fixed or contextual).
230    #[must_use]
231    pub fn path_count(
232        &self,
233        contextual: bool,
234    ) -> usize {
235        let mut count = 0;
236
237        for &from in &ScaleId::ALL {
238            for &to in &ScaleId::ALL {
239                if from != to {
240                    let kind = from.conversion_kind(to);
241                    let is_ctx = matches!(kind, ConversionKind::Contextual);
242
243                    if contextual == is_ctx {
244                        count += 1;
245                    }
246                }
247            }
248        }
249
250        count
251    }
252
253    /// Returns the conversion kind for `from -> to`.
254    #[inline]
255    #[must_use]
256    pub fn kind(
257        &self,
258        from: ScaleId,
259        to: ScaleId,
260    ) -> ConversionKind {
261        from.conversion_kind(to)
262    }
263}
264
265impl Default for ConversionMatrix {
266    fn default() -> Self {
267        ConversionMatrix::new()
268    }
269}
270
271/// Performs the conversion GPS -> BeiDou -> GLONASS -> UTC -> TAI in one call.
272pub fn beidou_via_gps_to_glonass_via_utc<P: LeapSecondsProvider>(
273    bdt: Time<Beidou>,
274    ls: &P,
275) -> Result<ConversionChain, GnssTimeError> {
276    let gps: Time<Gps> = bdt.into_scale()?;
277    let glo: Time<Glonass> = gps.into_scale_with(ls)?;
278    let utc: Time<Utc> = glo.into_scale()?;
279    let tai: Time<Tai> = gps.into_scale()?;
280
281    Ok(ConversionChain {
282        gps,
283        glonass: glo,
284        utc,
285        tai,
286    })
287}
288
289////////////////////////////////////////////////////////////////////////////////
290// Tests
291////////////////////////////////////////////////////////////////////////////////
292
293#[cfg(test)]
294mod tests {
295    #[allow(unused_imports)]
296    use std::vec;
297
298    use super::*;
299
300    #[test]
301    fn test_scale_id_names_are_correct() {
302        assert_eq!(ScaleId::Glonass.name(), "GLO");
303        assert_eq!(ScaleId::Gps.name(), "GPS");
304        assert_eq!(ScaleId::Galileo.name(), "GAL");
305        assert_eq!(ScaleId::Beidou.name(), "BDT");
306        assert_eq!(ScaleId::Tai.name(), "TAI");
307        assert_eq!(ScaleId::Utc.name(), "UTC");
308    }
309
310    #[test]
311    fn test_same_scale_is_same_scale() {
312        for &s in &ScaleId::ALL {
313            assert_eq!(s.conversion_kind(s), ConversionKind::SameScale);
314        }
315    }
316
317    #[test]
318    fn test_gps_galileo_is_identity() {
319        // Error
320        assert_eq!(
321            ScaleId::Gps.conversion_kind(ScaleId::Galileo),
322            ConversionKind::Identity
323        );
324        assert_eq!(
325            ScaleId::Galileo.conversion_kind(ScaleId::Gps),
326            ConversionKind::Identity
327        );
328    }
329
330    #[test]
331    fn test_gps_tai_is_fixed() {
332        assert_eq!(
333            ScaleId::Gps.conversion_kind(ScaleId::Tai),
334            ConversionKind::Fixed
335        );
336        assert_eq!(
337            ScaleId::Tai.conversion_kind(ScaleId::Gps),
338            ConversionKind::Fixed
339        );
340    }
341
342    #[test]
343    fn test_gps_beidou_is_fixed() {
344        assert_eq!(
345            ScaleId::Gps.conversion_kind(ScaleId::Beidou),
346            ConversionKind::Fixed
347        );
348        assert_eq!(
349            ScaleId::Beidou.conversion_kind(ScaleId::Gps),
350            ConversionKind::Fixed
351        );
352    }
353
354    #[test]
355    fn test_glonass_utc_is_epoch_shift() {
356        assert_eq!(
357            ScaleId::Glonass.conversion_kind(ScaleId::Utc),
358            ConversionKind::EpochShift
359        );
360        assert_eq!(
361            ScaleId::Utc.conversion_kind(ScaleId::Glonass),
362            ConversionKind::EpochShift
363        );
364    }
365
366    #[test]
367    fn test_contextual_conversions_require_leap_seconds() {
368        let contextual_pairs = [
369            (ScaleId::Gps, ScaleId::Utc),
370            (ScaleId::Gps, ScaleId::Glonass),
371            (ScaleId::Galileo, ScaleId::Utc),
372            (ScaleId::Galileo, ScaleId::Glonass),
373            (ScaleId::Beidou, ScaleId::Utc),
374            (ScaleId::Beidou, ScaleId::Glonass),
375        ];
376        for (from, to) in contextual_pairs {
377            assert!(
378                from.needs_leap_seconds(to),
379                "{:?} → {:?} should be contextual",
380                from,
381                to
382            );
383            assert!(
384                to.needs_leap_seconds(from),
385                "{:?} → {:?} should be contextual",
386                to,
387                from
388            );
389        }
390    }
391
392    #[test]
393    fn test_fixed_conversions_dont_need_leap_seconds() {
394        let fixed_pairs = [
395            (ScaleId::Gps, ScaleId::Tai),
396            (ScaleId::Gps, ScaleId::Galileo),
397            (ScaleId::Gps, ScaleId::Beidou),
398            (ScaleId::Galileo, ScaleId::Beidou),
399            (ScaleId::Glonass, ScaleId::Utc),
400        ];
401        for (from, to) in fixed_pairs {
402            assert!(from.is_fixed(to), "{:?} → {:?} should be fixed", from, to);
403            assert!(to.is_fixed(from), "{:?} → {:?} should be fixed", to, from);
404        }
405    }
406
407    #[test]
408    fn test_tai_offset_constants_are_correct() {
409        assert_eq!(TAI_OFFSET_GPS_NS, 19_000_000_000);
410        assert_eq!(TAI_OFFSET_GALILEO_NS, 19_000_000_000);
411        assert_eq!(TAI_OFFSET_BEIDOU_NS, 33_000_000_000);
412        assert_eq!(TAI_OFFSET_TAI_NS, 0);
413        assert_eq!(GLONASS_UTC_EPOCH_SHIFT_NS, 757_371_600_000_000_000);
414    }
415
416    #[test]
417    fn test_matrix_counts_are_correct() {
418        let m = ConversionMatrix::new();
419        // 6×6 matrix → 6 diagonal elements → 30 off-diagonal cells
420        //
421        // Fixed/Identity/EpochShift paths are symmetric pairs:
422        // GPS↔TAI(2) + GPS↔GAL(2) + GPS↔BDT(2)
423        // GAL↔BDT(2) + GAL↔TAI(2) + BDT↔TAI(2)
424        // GLO↔UTC(2) = 14 total fixed paths
425        assert_eq!(m.path_count(false), 14, "14 fixed paths");
426        // Remaining 30 − 14 = 16 are contextual conversions
427        assert_eq!(m.path_count(true), 16, "16 contextual paths");
428    }
429
430    #[test]
431    fn test_all_off_diagonal_cells_are_classified() {
432        // Check that every off-diagonal conversion is properly classified
433        // as Fixed / Identity / EpochShift / Contextual (never SameScale).
434        for &from in &ScaleId::ALL {
435            for &to in &ScaleId::ALL {
436                if from != to {
437                    let kind = from.conversion_kind(to);
438                    assert_ne!(
439                        kind,
440                        ConversionKind::SameScale,
441                        "{:?}→{:?} should not be SameScale",
442                        from,
443                        to
444                    );
445                }
446            }
447        }
448    }
449
450    #[test]
451    fn test_matrix_is_symmetric_in_kind_category() {
452        // For every pair, the "fixed vs contextual" classification must be symmetric.
453        for &from in &ScaleId::ALL {
454            for &to in &ScaleId::ALL {
455                if from != to {
456                    let fwd_fixed = from.is_fixed(to);
457                    let rev_fixed = to.is_fixed(from);
458                    assert_eq!(
459                        fwd_fixed, rev_fixed,
460                        "{:?}↔{:?}: fixed classification must be symmetric",
461                        from, to
462                    );
463                }
464            }
465        }
466    }
467}