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 ->
108/// TAI.
109#[derive(Debug)]
110pub struct ConversionChain {
111    /// GLONASS time.
112    pub glonass: Time<Glonass>,
113
114    /// GPS time.
115    pub gps: Time<Gps>,
116
117    /// UTC time.
118    pub utc: Time<Utc>,
119
120    /// TAI time.
121    pub tai: Time<Tai>,
122}
123
124impl ScaleId {
125    /// All supported scales.
126    pub const ALL: [ScaleId; 6] = [
127        ScaleId::Glonass,
128        ScaleId::Gps,
129        ScaleId::Galileo,
130        ScaleId::Beidou,
131        ScaleId::Tai,
132        ScaleId::Utc,
133    ];
134
135    /// Returns the ASCII name of the scale.
136    #[inline]
137    #[must_use]
138    pub const fn name(self) -> &'static str {
139        match self {
140            ScaleId::Glonass => "GLO",
141            ScaleId::Gps => "GPS",
142            ScaleId::Galileo => "GAL",
143            ScaleId::Beidou => "BDT",
144            ScaleId::Tai => "TAI",
145            ScaleId::Utc => "UTC",
146        }
147    }
148
149    /// Determines the conversion kind between the current scale and a target
150    /// scale.
151    ///
152    /// # Parameters
153    /// - `target` — target time scale
154    ///
155    /// # Returns
156    /// The conversion kind: fixed, identity, epoch shift, contextual, or same
157    /// scale.
158    #[inline]
159    #[must_use]
160    pub const fn conversion_kind(
161        self,
162        target: ScaleId,
163    ) -> ConversionKind {
164        use ConversionKind::{Contextual, EpochShift, Fixed, Identity, SameScale};
165        use ScaleId::{Beidou, Galileo, Glonass, Gps, Tai, Utc};
166
167        match (self, target) {
168            (a, b) if a as u8 == b as u8 => SameScale,
169
170            (Gps, Galileo) | (Galileo, Gps) => Identity,
171
172            // Fixed relationships
173            (Gps | Galileo | Beidou, Tai)
174            | (Tai, Gps | Galileo | Beidou)
175            | (Gps | Galileo, Beidou)
176            | (Beidou, Gps | Galileo) => Fixed,
177
178            (Glonass, Utc) | (Utc, Glonass) => EpochShift,
179
180            _ => Contextual,
181        }
182    }
183
184    /// Returns `true` if the conversion `self -> target` does not require leap
185    /// second context.
186    #[inline]
187    #[must_use]
188    pub const fn is_fixed(
189        self,
190        target: ScaleId,
191    ) -> bool {
192        matches!(
193            self.conversion_kind(target),
194            ConversionKind::Fixed | ConversionKind::Identity | ConversionKind::EpochShift
195        )
196    }
197
198    /// Returns `true` if the conversion requires a [`LeapSecondsProvider`].
199    #[inline]
200    #[must_use]
201    pub const fn needs_leap_seconds(
202        self,
203        target: ScaleId,
204    ) -> bool {
205        matches!(self.conversion_kind(target), ConversionKind::Contextual)
206    }
207}
208
209impl ConversionMatrix {
210    /// Creates a new conversion matrix.
211    #[inline]
212    #[must_use]
213    pub const fn new() -> Self {
214        ConversionMatrix
215    }
216
217    /// Returns the number of paths of the requested type (fixed or contextual).
218    #[must_use]
219    #[allow(clippy::unused_self)]
220    pub fn path_count(
221        &self,
222        contextual: bool,
223    ) -> usize {
224        let mut count = 0;
225
226        for &from in &ScaleId::ALL {
227            for &to in &ScaleId::ALL {
228                if from != to {
229                    let kind = from.conversion_kind(to);
230                    let is_ctx = matches!(kind, ConversionKind::Contextual);
231
232                    if contextual == is_ctx {
233                        count += 1;
234                    }
235                }
236            }
237        }
238
239        count
240    }
241
242    /// Returns the conversion kind for `from -> to`.
243    #[inline]
244    #[must_use]
245    #[allow(clippy::unused_self)]
246    pub const fn kind(
247        &self,
248        from: ScaleId,
249        to: ScaleId,
250    ) -> ConversionKind {
251        from.conversion_kind(to)
252    }
253}
254
255impl Default for ConversionMatrix {
256    fn default() -> Self {
257        ConversionMatrix::new()
258    }
259}
260
261/// Performs the conversion GPS -> `BeiDou` -> GLONASS -> UTC -> TAI in one
262/// call.
263///
264/// # Errors
265///
266/// This function returns [`GnssTimeError`] if any step in the conversion
267/// chain fails:
268/// - `BeiDou -> GPS` conversion (`into_scale`) fails
269/// - `GPS -> GLONASS` conversion with leap-second context fails
270/// - `GLONASS -> UTC` conversion fails (overflow)
271/// - `GPS -> TAI` conversion fails (overflow or invalid scale conversion)
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                "{from:?} -> {to:?} should be contextual",
380            );
381            assert!(
382                to.needs_leap_seconds(from),
383                "{to:?} -> {from:?} should be contextual",
384            );
385        }
386    }
387
388    #[test]
389    fn test_fixed_conversions_dont_need_leap_seconds() {
390        let fixed_pairs = [
391            (ScaleId::Gps, ScaleId::Tai),
392            (ScaleId::Gps, ScaleId::Galileo),
393            (ScaleId::Gps, ScaleId::Beidou),
394            (ScaleId::Galileo, ScaleId::Beidou),
395            (ScaleId::Glonass, ScaleId::Utc),
396        ];
397        for (from, to) in fixed_pairs {
398            assert!(from.is_fixed(to), "{from:?} -> {to:?} should be fixed");
399            assert!(to.is_fixed(from), "{to:?} -> {from:?} should be fixed");
400        }
401    }
402
403    #[test]
404    fn test_tai_offset_constants_are_correct() {
405        assert_eq!(TAI_OFFSET_GPS_NS, 19_000_000_000);
406        assert_eq!(TAI_OFFSET_GALILEO_NS, 19_000_000_000);
407        assert_eq!(TAI_OFFSET_BEIDOU_NS, 33_000_000_000);
408        assert_eq!(TAI_OFFSET_TAI_NS, 0);
409        assert_eq!(GLONASS_UTC_EPOCH_SHIFT_NS, 757_371_600_000_000_000);
410    }
411
412    #[test]
413    fn test_matrix_counts_are_correct() {
414        let m = ConversionMatrix::new();
415        // 6×6 matrix → 6 diagonal elements → 30 off-diagonal cells
416        //
417        // Fixed/Identity/EpochShift paths are symmetric pairs:
418        // GPS↔TAI(2) + GPS↔GAL(2) + GPS↔BDT(2)
419        // GAL↔BDT(2) + GAL↔TAI(2) + BDT↔TAI(2)
420        // GLO↔UTC(2) = 14 total fixed paths
421        assert_eq!(m.path_count(false), 14, "14 fixed paths");
422        // Remaining 30 − 14 = 16 are contextual conversions
423        assert_eq!(m.path_count(true), 16, "16 contextual paths");
424    }
425
426    #[test]
427    fn test_all_off_diagonal_cells_are_classified() {
428        // Check that every off-diagonal conversion is properly classified
429        // as Fixed / Identity / EpochShift / Contextual (never SameScale).
430        for &from in &ScaleId::ALL {
431            for &to in &ScaleId::ALL {
432                if from != to {
433                    let kind = from.conversion_kind(to);
434                    assert_ne!(
435                        kind,
436                        ConversionKind::SameScale,
437                        "{from:?} -> {to:?} should not be SameScale",
438                    );
439                }
440            }
441        }
442    }
443
444    #[test]
445    fn test_matrix_is_symmetric_in_kind_category() {
446        // For every pair, the "fixed vs contextual" classification must be symmetric.
447        for &from in &ScaleId::ALL {
448            for &to in &ScaleId::ALL {
449                if from != to {
450                    let fwd_fixed = from.is_fixed(to);
451                    let rev_fixed = to.is_fixed(from);
452                    assert_eq!(
453                        fwd_fixed, rev_fixed,
454                        "{from:?} <-> {to:?}: fixed classification must be symmetric",
455                    );
456                }
457            }
458        }
459    }
460}