Skip to main content

gnss_time/
convert.rs

1//! # GNSS time scale conversion
2//!
3//! Type-safe conversion between GNSS time scales.
4//!
5//! This module provides:
6//!
7//! - [`IntoScale`] — conversions with fixed offsets
8//! - [`IntoScaleWith`] — conversions requiring leap-second data
9//! - [`ConvertResult`] — representation of leap-second ambiguity
10//!
11//! ## Overview
12//!
13//! | Source → Target | Trait                           | Leap seconds |
14//! |----------------|----------------------------------|--------------|
15//! | GPS → TAI      | [`IntoScale`]                    | No           |
16//! | GPS → Galileo  | [`IntoScale`]                    | No           |
17//! | GPS → BeiDou   | [`IntoScale`]                    | No           |
18//! | UTC ↔ GPS      | [`IntoScaleWith`]                | Yes          |
19//! | GPS ↔ GLONASS  | [`IntoScaleWith`]                | Yes          |
20//!
21//! Fixed-offset conversions are lossless and do not require external data.
22//! Leap-second-aware conversions require a [`LeapSecondsProvider`].
23//!
24//! ## Usage
25//!
26//! Fixed-offset conversions:
27//!
28//! ```rust
29//! use gnss_time::{DurationParts, Galileo, Gps, IntoScale, Tai, Time};
30//!
31//! let gps = Time::<Gps>::from_week_tow(
32//!     2345,
33//!     DurationParts {
34//!         seconds: 0,
35//!         nanos: 0,
36//!     },
37//! )
38//! .unwrap();
39//! let tai: Time<Tai> = gps.into_scale().unwrap();
40//! let gal: Time<Galileo> = gps.into_scale().unwrap();
41//! ```
42//!
43//! Leap-second-aware conversions:
44//!
45//! ```rust
46//! use gnss_time::{DurationParts, Gps, IntoScaleWith, LeapSeconds, Time, Utc};
47//!
48//! let gps = Time::<Gps>::from_week_tow(
49//!     2200,
50//!     DurationParts {
51//!         seconds: 0,
52//!         nanos: 0,
53//!     },
54//! )
55//! .unwrap();
56//! let ls = LeapSeconds::builtin();
57//! let utc: Time<Utc> = gps.into_scale_with(ls).unwrap();
58//! ```
59//!
60//! ## Leap-second ambiguity
61//!
62//! During leap-second insertion, `GPS → UTC` conversion may map into a
63//! one-second interval that cannot be represented as a single unambiguous
64//! civil-time instant.
65//!
66//! Use [`IntoScaleWith::into_scale_with_checked`] to detect ambiguity.
67//!
68//! ```rust
69//! use gnss_time::{ConvertResult, Gps, IntoScaleWith, LeapSeconds, Time, Utc};
70//!
71//! let ls = LeapSeconds::builtin();
72//! let gps = Time::<Gps>::from_seconds(1_167_264_018);
73//!
74//! let result: Result<ConvertResult<Time<Utc>>, _> = gps.into_scale_with_checked(ls);
75//!
76//! assert!(matches!(result, Ok(ConvertResult::AmbiguousLeapSecond(_))));
77//! ```
78
79use crate::{
80    beidou_to_glonass, beidou_to_utc, galileo_to_glonass, galileo_to_utc, glonass_to_beidou,
81    glonass_to_galileo, glonass_to_gps, glonass_to_utc, gps_to_glonass, gps_to_utc, utc_to_beidou,
82    utc_to_galileo, utc_to_glonass, utc_to_gps, Beidou, Galileo, Glonass, GnssTimeError, Gps,
83    LeapSecondsProvider, Tai, Time, TimeScale, Utc,
84};
85
86/// Fixed-offset conversion between time scales.
87///
88/// This trait is implemented for conversions where the relationship between
89/// time scales is constant and independent of leap seconds.
90#[must_use = "conversion result must be used; ignoring it discards the converted time"]
91pub trait IntoScale<Target: TimeScale>: Sized {
92    /// Converts the value into the target time scale.
93    ///
94    /// # Errors
95    ///
96    /// Returns [`GnssTimeError::Overflow`] if the result cannot be represented
97    /// in the target scale.
98    fn into_scale(self) -> Result<Time<Target>, GnssTimeError>;
99}
100
101/// Leap-second-aware conversion between time scales.
102///
103/// Required for conversions involving UTC or any scale derived from UTC.
104///
105/// This trait uses an external [`LeapSecondsProvider`] to resolve
106/// discontinuities introduced by leap seconds.
107#[must_use = "conversion result must be used; ignoring it discards the converted time"]
108pub trait IntoScaleWith<Target: TimeScale>: Sized {
109    /// Converts using leap-second data.
110    fn into_scale_with<P: LeapSecondsProvider>(
111        self,
112        ls: P,
113    ) -> Result<Time<Target>, GnssTimeError>;
114
115    /// Converts and reports whether the result is ambiguous due to a
116    /// leap-second insertion.
117    fn into_scale_with_checked<P: LeapSecondsProvider>(
118        self,
119        ls: P,
120    ) -> Result<ConvertResult<Time<Target>>, GnssTimeError>;
121}
122
123/// Result of a leap-second-aware conversion.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
125#[must_use = "ConvertResult contains ambiguity information; call .into_inner() or match explicitly"]
126pub enum ConvertResult<T> {
127    /// Unambiguous conversion result.
128    Exact(T),
129
130    /// Conversion falls inside a leap-second insertion window.
131    ///
132    /// The value represents the closest representable instant.
133    AmbiguousLeapSecond(T),
134}
135
136impl<T> ConvertResult<T> {
137    /// Returns the inner value.
138    #[inline]
139    #[must_use]
140    pub fn into_inner(self) -> T {
141        match self {
142            Self::Exact(t) | Self::AmbiguousLeapSecond(t) => t,
143        }
144    }
145
146    /// Returns `true` if the result is unambiguous.
147    #[inline]
148    #[must_use]
149    pub fn is_exact(&self) -> bool {
150        matches!(self, Self::Exact(_))
151    }
152
153    /// Returns `true` if the result is ambiguous due to a leap second.
154    #[inline]
155    #[must_use]
156    pub fn is_ambiguous(&self) -> bool {
157        matches!(self, Self::AmbiguousLeapSecond(_))
158    }
159}
160
161////////////////////////////////////////////////////////////////////////////////
162// GLONASS for Gps, Galileo, Beidou, UTC
163////////////////////////////////////////////////////////////////////////////////
164
165impl IntoScale<Glonass> for Time<Utc> {
166    /// UTC -> GLONASS: постоянный сдвиг эпохи.
167    ///
168    /// # Errors
169    ///
170    /// [`GnssTimeError::Overflow`] если UTC раньше эпохи GLONASS
171    /// (1995-12-31 21:00:00 UTC).
172    #[inline]
173    fn into_scale(self) -> Result<Time<Glonass>, GnssTimeError> {
174        utc_to_glonass(self)
175    }
176}
177
178impl IntoScaleWith<Glonass> for Time<Gps> {
179    fn into_scale_with<P: LeapSecondsProvider>(
180        self,
181        ls: P,
182    ) -> Result<Time<Glonass>, GnssTimeError> {
183        gps_to_glonass(self, &ls)
184    }
185
186    fn into_scale_with_checked<P: LeapSecondsProvider>(
187        self,
188        ls: P,
189    ) -> Result<ConvertResult<Time<Glonass>>, GnssTimeError> {
190        Ok(ConvertResult::Exact(gps_to_glonass(self, &ls)?))
191    }
192}
193
194impl IntoScaleWith<Glonass> for Time<Galileo> {
195    fn into_scale_with<P: LeapSecondsProvider>(
196        self,
197        ls: P,
198    ) -> Result<Time<Glonass>, GnssTimeError> {
199        galileo_to_glonass(self, &ls)
200    }
201
202    fn into_scale_with_checked<P: LeapSecondsProvider>(
203        self,
204        ls: P,
205    ) -> Result<ConvertResult<Time<Glonass>>, GnssTimeError> {
206        Ok(ConvertResult::Exact(galileo_to_glonass(self, &ls)?))
207    }
208}
209
210impl IntoScaleWith<Glonass> for Time<Beidou> {
211    fn into_scale_with<P: LeapSecondsProvider>(
212        self,
213        ls: P,
214    ) -> Result<Time<Glonass>, GnssTimeError> {
215        beidou_to_glonass(self, &ls)
216    }
217
218    fn into_scale_with_checked<P: LeapSecondsProvider>(
219        self,
220        ls: P,
221    ) -> Result<ConvertResult<Time<Glonass>>, GnssTimeError> {
222        Ok(ConvertResult::Exact(beidou_to_glonass(self, &ls)?))
223    }
224}
225
226////////////////////////////////////////////////////////////////////////////////
227// Gps for Glonass, Galileo, Beidou, Tai, Utc
228////////////////////////////////////////////////////////////////////////////////
229
230impl IntoScale<Gps> for Time<Galileo> {
231    #[inline]
232    fn into_scale(self) -> Result<Time<Gps>, GnssTimeError> {
233        self.try_convert::<Gps>()
234    }
235}
236
237impl IntoScale<Gps> for Time<Beidou> {
238    /// BeiDou -> GPS: `GPS = BDT + 14s`.
239    ///
240    /// ```rust
241    /// use gnss_time::{Beidou, Gps, IntoScale, Time};
242    ///
243    /// let bdt = Time::<Beidou>::from_seconds(86);
244    /// let gps: Time<Gps> = bdt.into_scale().unwrap();
245    ///
246    /// assert_eq!(gps.as_seconds(), 100); // 86 - 19 + 33 = 100
247    /// ```
248    #[inline]
249    fn into_scale(self) -> Result<Time<Gps>, GnssTimeError> {
250        self.try_convert::<Gps>()
251    }
252}
253
254impl IntoScale<Gps> for Time<Tai> {
255    /// TAI -> GPS: subtract 19 seconds.
256    ///
257    /// ```rust
258    /// use gnss_time::{Gps, IntoScale, Tai, Time};
259    ///
260    /// let tai = Time::<Tai>::from_seconds(119);
261    /// let gps: Time<Gps> = tai.into_scale().unwrap();
262    ///
263    /// assert_eq!(gps.as_seconds(), 100);
264    /// ```
265    #[inline]
266    fn into_scale(self) -> Result<Time<Gps>, GnssTimeError> {
267        Time::<Gps>::from_tai(self)
268    }
269}
270
271impl IntoScaleWith<Gps> for Time<Glonass> {
272    /// GLONASS -> GPS via UTC.
273    fn into_scale_with<P: LeapSecondsProvider>(
274        self,
275        ls: P,
276    ) -> Result<Time<Gps>, GnssTimeError> {
277        glonass_to_gps(self, &ls)
278    }
279
280    fn into_scale_with_checked<P: LeapSecondsProvider>(
281        self,
282        ls: P,
283    ) -> Result<ConvertResult<Time<Gps>>, GnssTimeError> {
284        Ok(ConvertResult::Exact(glonass_to_gps(self, &ls)?))
285    }
286}
287
288impl IntoScaleWith<Gps> for Time<Utc> {
289    /// UTC -> GPS with leap-second context.
290    ///
291    /// ```rust
292    /// use gnss_time::{DurationParts, Gps, IntoScale, IntoScaleWith, LeapSeconds, Time, Utc};
293    ///
294    /// let ls = LeapSeconds::builtin();
295    /// let gps_orig = Time::<Gps>::from_week_tow(
296    ///     2086,
297    ///     DurationParts {
298    ///         seconds: 0,
299    ///         nanos: 0,
300    ///     },
301    /// )
302    /// .unwrap();
303    /// let utc: Time<Utc> = gps_orig.into_scale_with(ls).unwrap();
304    /// let gps_back: Time<Gps> = utc.into_scale_with(ls).unwrap();
305    ///
306    /// assert_eq!(gps_orig, gps_back);
307    /// ```
308    fn into_scale_with<P: LeapSecondsProvider>(
309        self,
310        ls: P,
311    ) -> Result<Time<Gps>, GnssTimeError> {
312        utc_to_gps(self, &ls)
313    }
314
315    fn into_scale_with_checked<P: LeapSecondsProvider>(
316        self,
317        ls: P,
318    ) -> Result<ConvertResult<Time<Gps>>, GnssTimeError> {
319        // UTC -> GPS is unambiguous: each UTC nanosecond corresponds to
320        // exactly one GPS nanosecond (GPS has no skipped or repeated seconds).
321        Ok(ConvertResult::Exact(utc_to_gps(self, &ls)?))
322    }
323}
324
325////////////////////////////////////////////////////////////////////////////////
326// Galileo for Glonass, Gps, Beidou, Utc
327////////////////////////////////////////////////////////////////////////////////
328
329impl IntoScale<Galileo> for Time<Gps> {
330    /// GPS -> Galileo: identical at nanosecond level (both use `TAI − 19s`).
331    ///
332    /// GPS and Galileo timestamps with identical nanoseconds represent
333    /// the same physical instant.
334    ///
335    /// ```rust
336    /// use gnss_time::{Galileo, Gps, IntoScale, Time};
337    ///
338    /// let gps = Time::<Gps>::from_seconds(12_345);
339    /// let gal: Time<Galileo> = gps.into_scale().unwrap();
340    ///
341    /// assert_eq!(gps.as_nanos(), gal.as_nanos());
342    /// ```
343    #[inline]
344    fn into_scale(self) -> Result<Time<Galileo>, GnssTimeError> {
345        // GPS and Galileo use the same offset relative to TAI (19 s)
346        // → converting via TAI preserves nanoseconds exactly
347        self.try_convert::<Galileo>()
348    }
349}
350
351impl IntoScale<Galileo> for Time<Beidou> {
352    /// BeiDou -> Galileo via TAI.
353    #[inline]
354    fn into_scale(self) -> Result<Time<Galileo>, GnssTimeError> {
355        self.try_convert::<Galileo>()
356    }
357}
358
359impl IntoScaleWith<Galileo> for Time<Glonass> {
360    /// GLONASS -> Galileo via UTC.
361    fn into_scale_with<P: LeapSecondsProvider>(
362        self,
363        ls: P,
364    ) -> Result<Time<Galileo>, GnssTimeError> {
365        glonass_to_galileo(self, &ls)
366    }
367
368    fn into_scale_with_checked<P: LeapSecondsProvider>(
369        self,
370        ls: P,
371    ) -> Result<ConvertResult<Time<Galileo>>, GnssTimeError> {
372        Ok(ConvertResult::Exact(glonass_to_galileo(self, &ls)?))
373    }
374}
375
376impl IntoScaleWith<Galileo> for Time<Utc> {
377    /// UTC -> Galileo via GPS.
378    fn into_scale_with<P: LeapSecondsProvider>(
379        self,
380        ls: P,
381    ) -> Result<Time<Galileo>, GnssTimeError> {
382        utc_to_galileo(self, &ls)
383    }
384
385    fn into_scale_with_checked<P: LeapSecondsProvider>(
386        self,
387        ls: P,
388    ) -> Result<ConvertResult<Time<Galileo>>, GnssTimeError> {
389        Ok(ConvertResult::Exact(utc_to_galileo(self, &ls)?))
390    }
391}
392
393////////////////////////////////////////////////////////////////////////////////
394// Beidou for Glonass, Gps, Galileo, Utc
395////////////////////////////////////////////////////////////////////////////////
396
397impl IntoScale<Beidou> for Time<Gps> {
398    /// GPS -> BeiDou: `BDT = GPS - 14s`.
399    ///
400    /// ```rust
401    /// use gnss_time::{Beidou, Gps, IntoScale, Time};
402    ///
403    /// let gps = Time::<Gps>::from_seconds(100);
404    /// let bdt: Time<Beidou> = gps.into_scale().unwrap();
405    ///
406    /// assert_eq!(bdt.as_seconds(), 86); // 100 - 14 = 86
407    /// ```
408    #[inline]
409    fn into_scale(self) -> Result<Time<Beidou>, GnssTimeError> {
410        self.try_convert::<Beidou>()
411    }
412}
413
414impl IntoScale<Beidou> for Time<Galileo> {
415    /// Galileo -> BeiDou via TAI.
416    #[inline]
417    fn into_scale(self) -> Result<Time<Beidou>, GnssTimeError> {
418        self.try_convert::<Beidou>()
419    }
420}
421
422impl IntoScaleWith<Beidou> for Time<Utc> {
423    /// UTC → BeiDou via GPS.
424    fn into_scale_with<P: LeapSecondsProvider>(
425        self,
426        ls: P,
427    ) -> Result<Time<Beidou>, GnssTimeError> {
428        utc_to_beidou(self, &ls)
429    }
430    fn into_scale_with_checked<P: LeapSecondsProvider>(
431        self,
432        ls: P,
433    ) -> Result<ConvertResult<Time<Beidou>>, GnssTimeError> {
434        Ok(ConvertResult::Exact(utc_to_beidou(self, &ls)?))
435    }
436}
437
438impl IntoScaleWith<Beidou> for Time<Glonass> {
439    /// GLONASS -> BeiDou via UTC.
440    fn into_scale_with<P: LeapSecondsProvider>(
441        self,
442        ls: P,
443    ) -> Result<Time<Beidou>, GnssTimeError> {
444        glonass_to_beidou(self, &ls)
445    }
446
447    fn into_scale_with_checked<P: LeapSecondsProvider>(
448        self,
449        ls: P,
450    ) -> Result<ConvertResult<Time<Beidou>>, GnssTimeError> {
451        Ok(ConvertResult::Exact(glonass_to_beidou(self, &ls)?))
452    }
453}
454
455////////////////////////////////////////////////////////////////////////////////
456// Utc for Glonass, Gps, Galileo, Beidou
457////////////////////////////////////////////////////////////////////////////////
458
459impl IntoScale<Utc> for Time<Glonass> {
460    /// GLONASS -> UTC: fixed epoch shift.
461    ///
462    /// GLONASS uses UTC(SU), a time scale offset from UTC by +3 hours and
463    /// including leap seconds.
464    ///
465    /// ```rust
466    /// use gnss_time::{DurationParts, Glonass, IntoScale, Time, Utc};
467    ///
468    /// let glo = Time::<Glonass>::from_day_tod(
469    ///     0,
470    ///     DurationParts {
471    ///         seconds: 0,
472    ///         nanos: 0,
473    ///     },
474    /// )
475    /// .unwrap(); // GLONASS epoch
476    /// let utc: Time<Utc> = glo.into_scale().unwrap();
477    ///
478    /// // UTC at the GLONASS epoch:
479    /// // 1995-12-31 21:00:00 UTC = 757_371_600 s from 1972
480    /// assert_eq!(utc.as_nanos(), 757_371_600_000_000_000);
481    /// ```
482    #[inline]
483    fn into_scale(self) -> Result<Time<Utc>, GnssTimeError> {
484        glonass_to_utc(self)
485    }
486}
487
488impl IntoScaleWith<Utc> for Time<Gps> {
489    /// GPS -> UTC with leap-second context.
490    ///
491    /// Round-trip consistency: `GPS -> UTC -> GPS` is exact (< 1 ns) for all
492    /// moments except the one-second leap-second insertion window.
493    ///
494    /// ```rust
495    /// use gnss_time::{Gps, IntoScaleWith, LeapSeconds, Time, Utc};
496    ///
497    /// let ls = LeapSeconds::builtin();
498    /// let gps = Time::<Gps>::from_seconds(1_167_264_018); // 2017-01-01 GPS
499    /// let utc: Time<Utc> = gps.into_scale_with(ls).unwrap();
500    ///
501    /// let delta = gps.as_seconds() as i64 - utc.as_seconds() as i64 + 252_892_800_i64;
502    ///
503    /// // GPS leads UTC by 18 s → UTC is 18 s earlier
504    /// assert_eq!(delta, 18);
505    /// ```
506    #[inline]
507    fn into_scale_with<P: LeapSecondsProvider>(
508        self,
509        ls: P,
510    ) -> Result<Time<Utc>, GnssTimeError> {
511        gps_to_utc(self, &ls)
512    }
513
514    fn into_scale_with_checked<P: LeapSecondsProvider>(
515        self,
516        ls: P,
517    ) -> Result<ConvertResult<Time<Utc>>, GnssTimeError> {
518        let utc = gps_to_utc(self, &ls)?;
519
520        // Detect leap-second window: compute TAI at this GPS timestamp
521        // and compare leap-second offsets before and after.
522        // If values differ — we are inside (or adjacent to) a leap-second boundary.
523        let tai = self.to_tai()?;
524        let n_at = ls.tai_minus_utc_at(tai);
525
526        // Check 1 second back to detect entry into leap second
527        let tai_prev = if tai.as_nanos() >= 1_000_000_000 {
528            Time::<Tai>::from_nanos(tai.as_nanos() - 1_000_000_000)
529        } else {
530            tai
531        };
532        let n_before = ls.tai_minus_utc_at(tai_prev);
533
534        if n_at != n_before {
535            // We crossed a leap-second boundary within the last second.
536            // The GPS second corresponding to the old offset is ambiguous.
537            Ok(ConvertResult::AmbiguousLeapSecond(utc))
538        } else {
539            Ok(ConvertResult::Exact(utc))
540        }
541    }
542}
543
544impl IntoScaleWith<Utc> for Time<Galileo> {
545    /// Galileo -> UTC via GPS (both share the same TAI offset of 19s).
546    fn into_scale_with<P: LeapSecondsProvider>(
547        self,
548        ls: P,
549    ) -> Result<Time<Utc>, GnssTimeError> {
550        galileo_to_utc(self, &ls)
551    }
552
553    fn into_scale_with_checked<P: LeapSecondsProvider>(
554        self,
555        ls: P,
556    ) -> Result<ConvertResult<Time<Utc>>, GnssTimeError> {
557        Ok(ConvertResult::Exact(galileo_to_utc(self, &ls)?))
558    }
559}
560
561impl IntoScaleWith<Utc> for Time<Beidou> {
562    /// BeiDou -> UTC via GPS.
563    fn into_scale_with<P: LeapSecondsProvider>(
564        self,
565        ls: P,
566    ) -> Result<Time<Utc>, GnssTimeError> {
567        beidou_to_utc(self, &ls)
568    }
569
570    fn into_scale_with_checked<P: LeapSecondsProvider>(
571        self,
572        ls: P,
573    ) -> Result<ConvertResult<Time<Utc>>, GnssTimeError> {
574        Ok(ConvertResult::Exact(beidou_to_utc(self, &ls)?))
575    }
576}
577
578////////////////////////////////////////////////////////////////////////////////
579// Tai for Gps
580////////////////////////////////////////////////////////////////////////////////
581
582impl IntoScale<Tai> for Time<Gps> {
583    /// GPS -> TAI: add 19 seconds (constant, no leap seconds).
584    ///
585    /// ```rust
586    /// use gnss_time::{Gps, IntoScale, Tai, Time};
587    ///
588    /// let gps = Time::<Gps>::from_seconds(100);
589    /// let tai: Time<Tai> = gps.into_scale().unwrap();
590    ///
591    /// assert_eq!(tai.as_seconds(), 119);
592    /// ```
593    #[inline]
594    fn into_scale(self) -> Result<Time<Tai>, GnssTimeError> {
595        self.to_tai()
596    }
597}
598
599////////////////////////////////////////////////////////////////////////////////
600// Tests
601////////////////////////////////////////////////////////////////////////////////
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606    use crate::{DurationParts, LeapSeconds};
607
608    #[test]
609    fn test_gps_to_tai_adds_19_seconds() {
610        let gps = Time::<Gps>::from_seconds(100);
611        let tai: Time<Tai> = gps.into_scale().unwrap();
612
613        // 100 + 19
614        assert_eq!(tai.as_seconds(), 119);
615    }
616
617    #[test]
618    fn test_tai_to_gps_subtracts_19_seconds() {
619        let tai = Time::<Tai>::from_seconds(119);
620        let gps: Time<Gps> = tai.into_scale().unwrap();
621
622        // 119 - 19 = 100
623        assert_eq!(gps.as_seconds(), 100);
624    }
625
626    #[test]
627    fn test_gps_tai_gps_roundtrip() {
628        let gps = Time::<Gps>::from_week_tow(
629            2345,
630            DurationParts {
631                seconds: 432_000,
632                nanos: 0,
633            },
634        )
635        .unwrap();
636        let tai: Time<Tai> = gps.into_scale().unwrap();
637        let back: Time<Gps> = tai.into_scale().unwrap();
638
639        assert_eq!(gps, back);
640    }
641
642    #[test]
643    fn test_tai_to_gps_underflow_at_tai_zero() {
644        // TAI(0) − 19 s → negative GPS time → overflow
645        let tai = Time::<Tai>::EPOCH;
646        let result: Result<Time<Gps>, _> = tai.into_scale();
647
648        assert!(matches!(result, Err(GnssTimeError::Overflow)));
649    }
650
651    #[test]
652    fn test_gps_to_galileo_preserves_nanos() {
653        let gps = Time::<Gps>::from_seconds(12_345_678);
654        let gal: Time<Galileo> = gps.into_scale().unwrap();
655
656        assert_eq!(gps.as_nanos(), gal.as_nanos());
657    }
658
659    #[test]
660    fn test_galileo_to_gps_preserves_nanos() {
661        let gal = Time::<Galileo>::from_seconds(99_999_999);
662        let gps: Time<Gps> = gal.into_scale().unwrap();
663
664        assert_eq!(gal.as_nanos(), gps.as_nanos());
665    }
666
667    #[test]
668    fn test_gps_galileo_gps_roundtrip() {
669        let gps = Time::<Gps>::from_week_tow(
670            2000,
671            DurationParts {
672                seconds: 123_456,
673                nanos: 789_000_000,
674            },
675        )
676        .unwrap();
677        let gal: Time<Galileo> = gps.into_scale().unwrap();
678        let back: Time<Gps> = gal.into_scale().unwrap();
679
680        assert_eq!(gps, back);
681    }
682
683    #[test]
684    fn test_gps_to_beidou_subtracts_14_seconds() {
685        // GPS + 19 s = TAI; BDT + 33 s = TAI → BDT = GPS + 19 - 33 = GPS - 14
686        let gps = Time::<Gps>::from_seconds(100);
687        let bdt: Time<Beidou> = gps.into_scale().unwrap();
688
689        assert_eq!(bdt.as_seconds(), 86); // 100 - 14 = 86
690    }
691
692    #[test]
693    fn test_beidou_to_gps_adds_14_seconds() {
694        let bdt = Time::<Beidou>::from_seconds(86);
695        let gps: Time<Gps> = bdt.into_scale().unwrap();
696
697        assert_eq!(gps.as_seconds(), 100);
698    }
699
700    #[test]
701    fn test_gps_beidou_gps_roundtrip() {
702        let gps = Time::<Gps>::from_week_tow(
703            2100,
704            DurationParts {
705                seconds: 86_400,
706                nanos: 0,
707            },
708        )
709        .unwrap();
710        let bdt: Time<Beidou> = gps.into_scale().unwrap();
711        let back: Time<Gps> = bdt.into_scale().unwrap();
712
713        assert_eq!(gps, back);
714    }
715
716    #[test]
717    fn test_galileo_beidou_roundtrip() {
718        let gal = Time::<Galileo>::from_seconds(1_000_000_000);
719        let bdt: Time<Beidou> = gal.into_scale().unwrap();
720        let back: Time<Galileo> = bdt.into_scale().unwrap();
721
722        assert_eq!(gal, back);
723    }
724
725    #[test]
726    fn test_glonass_epoch_to_utc_nanos() {
727        let glo = Time::<Glonass>::EPOCH;
728        let utc: Time<Utc> = glo.into_scale().unwrap();
729
730        // GLONASS epoch = 1995-12-31 21:00:00 UTC = 757_371_600 seconds from 1972
731        assert_eq!(utc.as_nanos(), 757_371_600_000_000_000);
732    }
733
734    #[test]
735    fn test_utc_at_glonass_epoch_gives_zero() {
736        let utc = Time::<Utc>::from_nanos(757_371_600_000_000_000);
737        let glo: Time<Glonass> = utc.into_scale().unwrap();
738
739        assert_eq!(glo, Time::<Glonass>::EPOCH);
740    }
741
742    #[test]
743    fn test_glonass_utc_glonass_roundtrip() {
744        let glo = Time::<Glonass>::from_day_tod(
745            10_000,
746            DurationParts {
747                seconds: 36_000,
748                nanos: 0,
749            },
750        )
751        .unwrap();
752        let utc: Time<Utc> = glo.into_scale().unwrap();
753        let back: Time<Glonass> = utc.into_scale().unwrap();
754
755        assert_eq!(glo, back);
756    }
757
758    #[test]
759    fn test_utc_before_glonass_epoch_is_error() {
760        let utc = Time::<Utc>::EPOCH;
761        let result: Result<Time<Glonass>, _> = utc.into_scale();
762
763        assert!(matches!(result, Err(GnssTimeError::Overflow)));
764    }
765
766    #[test]
767    fn test_gps_utc_gps_roundtrip_at_gps_epoch() {
768        let ls = LeapSeconds::builtin();
769        let gps = Time::<Gps>::EPOCH;
770        let utc: Time<Utc> = gps.into_scale_with(ls).unwrap();
771        let back: Time<Gps> = utc.into_scale_with(ls).unwrap();
772
773        assert_eq!(gps, back);
774    }
775
776    #[test]
777    fn test_gps_utc_gps_roundtrip_at_2020() {
778        let ls = LeapSeconds::builtin();
779        let gps = Time::<Gps>::from_week_tow(
780            2086,
781            DurationParts {
782                seconds: 0,
783                nanos: 0,
784            },
785        )
786        .unwrap();
787        let utc: Time<Utc> = gps.into_scale_with(ls).unwrap();
788        let back: Time<Gps> = utc.into_scale_with(ls).unwrap();
789
790        assert_eq!(gps, back);
791    }
792
793    #[test]
794    fn test_gps_utc_roundtrip_exact_at_nanosecond_level() {
795        let ls = LeapSeconds::builtin();
796        // Use a timestamp with a non-zero nanosecond component
797        let gps = Time::<Gps>::from_nanos(1_167_264_100_123_456_789);
798        let utc: Time<Utc> = gps.into_scale_with(ls).unwrap();
799        let back: Time<Gps> = utc.into_scale_with(ls).unwrap();
800
801        assert_eq!(gps, back); // exact, no rounding
802    }
803
804    #[test]
805    fn test_gps_leads_utc_by_18s_at_2017_01_01() {
806        let ls = LeapSeconds::builtin();
807        // 2017-01-01 UTC: 16,437 days * 86,400 s from 1972-01-01
808        let expected_utc_s: u64 = 16_437 * 86_400;
809        // GPS seconds for this UTC moment:
810        // GPS = UTC - epoch_offset + (n - 19)
811        // where n = 37, epoch_offset = 252,892,800 s
812        let gps_s: u64 = 1_167_264_000 + 18; // pre-verified
813        let gps = Time::<Gps>::from_seconds(gps_s);
814        let utc: Time<Utc> = gps.into_scale_with(ls).unwrap();
815
816        assert_eq!(utc.as_seconds(), expected_utc_s);
817    }
818
819    #[test]
820    fn test_gps_leads_utc_by_13s_at_1999_01_01() {
821        let ls = LeapSeconds::builtin();
822        let gps = Time::<Gps>::from_seconds(599_184_013);
823        let utc: Time<Utc> = gps.into_scale_with(ls).unwrap();
824        let expected_utc_s: u64 = 9_862 * 86_400; // days from 1972 to 1999
825
826        assert_eq!(utc.as_seconds(), expected_utc_s);
827    }
828
829    #[test]
830    fn test_gps_glonass_gps_roundtrip() {
831        let ls = LeapSeconds::builtin();
832        let gps = Time::<Gps>::from_week_tow(
833            2100,
834            DurationParts {
835                seconds: 86_400,
836                nanos: 0,
837            },
838        )
839        .unwrap();
840        let glo: Time<Glonass> = gps.into_scale_with(ls).unwrap();
841        let back: Time<Gps> = glo.into_scale_with(ls).unwrap();
842
843        assert_eq!(gps, back);
844    }
845
846    #[test]
847    fn test_normal_gps_gives_exact_convert_result() {
848        let ls = LeapSeconds::builtin();
849        let gps = Time::<Gps>::from_week_tow(
850            2086,
851            DurationParts {
852                seconds: 0,
853                nanos: 0,
854            },
855        )
856        .unwrap();
857        let result: ConvertResult<Time<Utc>> = gps.into_scale_with_checked(ls).unwrap();
858
859        assert!(result.is_exact());
860    }
861
862    #[test]
863    fn test_utc_to_gps_always_exact() {
864        let ls = LeapSeconds::builtin();
865        let utc = Time::<Utc>::from_nanos(757_371_600_000_000_000 + 1_000_000_000);
866        let result: ConvertResult<Time<Gps>> = utc.into_scale_with_checked(ls).unwrap();
867
868        assert!(result.is_exact());
869    }
870
871    #[test]
872    fn test_into_inner_returns_value() {
873        let t = Time::<Gps>::from_seconds(100);
874        let r = ConvertResult::Exact(t);
875
876        assert_eq!(r.into_inner(), t);
877
878        let t2 = Time::<Gps>::from_seconds(200);
879        let r2 = ConvertResult::AmbiguousLeapSecond(t2);
880
881        assert_eq!(r2.into_inner(), t2);
882    }
883
884    #[test]
885    fn test_gps_to_tai_overflow_at_max() {
886        let gps = Time::<Gps>::MAX;
887        let result: Result<Time<Tai>, _> = gps.into_scale();
888
889        assert!(matches!(result, Err(GnssTimeError::Overflow)));
890    }
891
892    #[test]
893    fn test_into_scale_gps_tai_matches_to_tai() {
894        let gps = Time::<Gps>::from_seconds(999_999);
895        let via_trait: Time<Tai> = gps.into_scale().unwrap();
896        let via_method = gps.to_tai().unwrap();
897
898        assert_eq!(via_trait, via_method);
899    }
900
901    #[test]
902    fn test_into_scale_with_gps_utc_matches_gps_to_utc() {
903        use crate::leap::{gps_to_utc, LeapSeconds};
904        let ls = LeapSeconds::builtin();
905        let gps = Time::<Gps>::from_seconds(599_184_013);
906        let via_trait: Time<Utc> = gps.into_scale_with(ls).unwrap();
907        let via_fn = gps_to_utc(gps, ls).unwrap();
908
909        assert_eq!(via_trait, via_fn);
910    }
911
912    #[test]
913    fn test_gps_to_utc_detects_leap_second_ambiguity() {
914        let ls = LeapSeconds::builtin();
915        // GPS time прямо на leap second boundary (2017-01-01)
916        let gps = Time::<Gps>::from_seconds(1_167_264_018);
917        let result: ConvertResult<Time<Utc>> = gps.into_scale_with_checked(ls).unwrap();
918
919        assert!(matches!(result, ConvertResult::AmbiguousLeapSecond(_)));
920    }
921
922    #[test]
923    fn test_all_roundtrip_invariants() {
924        let ls = LeapSeconds::builtin();
925
926        let gps_values = [
927            Time::<Gps>::from_week_tow(
928                2086,
929                DurationParts {
930                    seconds: 0,
931                    nanos: 0,
932                },
933            )
934            .unwrap(),
935            Time::<Gps>::from_week_tow(
936                2100,
937                DurationParts {
938                    seconds: 86_400,
939                    nanos: 0,
940                },
941            )
942            .unwrap(),
943            Time::<Gps>::from_nanos(1_167_264_100_123_456_789),
944        ];
945
946        for gps in gps_values {
947            let utc: Time<Utc> = gps.into_scale_with(ls).unwrap();
948            let back: Time<Gps> = utc.into_scale_with(ls).unwrap();
949            assert_eq!(gps, back);
950
951            let gal: Time<Galileo> = gps.into_scale().unwrap();
952            let back: Time<Gps> = gal.into_scale().unwrap();
953            assert_eq!(gps, back);
954
955            let bdt: Time<Beidou> = gps.into_scale().unwrap();
956            let back: Time<Gps> = bdt.into_scale().unwrap();
957            assert_eq!(gps, back);
958        }
959    }
960
961    #[test]
962    fn test_gps_epoch_to_utc_is_exact() {
963        let ls = LeapSeconds::builtin();
964
965        let gps = Time::<Gps>::EPOCH;
966        let utc: Time<Utc> = gps.into_scale_with(ls).unwrap();
967
968        assert_eq!(utc.as_seconds(), 252_892_800);
969    }
970
971    #[test]
972    fn test_gps_epoch_utc_roundtrip() {
973        let ls = LeapSeconds::builtin();
974
975        let gps = Time::<Gps>::EPOCH;
976        let utc: Time<Utc> = gps.into_scale_with(ls).unwrap();
977        let back: Time<Gps> = utc.into_scale_with(ls).unwrap();
978
979        assert_eq!(gps, back);
980    }
981
982    #[test]
983    fn test_glonass_roundtrip_invariants_supported_range() {
984        let ls = LeapSeconds::builtin();
985
986        let gps_values = [
987            Time::<Gps>::from_week_tow(
988                2086,
989                DurationParts {
990                    seconds: 0,
991                    nanos: 0,
992                },
993            )
994            .unwrap(),
995            Time::<Gps>::from_week_tow(
996                2100,
997                DurationParts {
998                    seconds: 86_400,
999                    nanos: 0,
1000                },
1001            )
1002            .unwrap(),
1003            Time::<Gps>::from_nanos(1_167_264_100_123_456_789),
1004        ];
1005
1006        for gps in gps_values {
1007            let glo: Time<Glonass> = gps.into_scale_with(ls).unwrap();
1008            let back: Time<Gps> = glo.into_scale_with(ls).unwrap();
1009
1010            assert_eq!(gps, back);
1011        }
1012    }
1013
1014    #[test]
1015    fn test_checked_variants_contract() {
1016        let ls = LeapSeconds::builtin();
1017        let gps = Time::<Gps>::from_week_tow(
1018            2000,
1019            DurationParts {
1020                seconds: 0,
1021                nanos: 0,
1022            },
1023        )
1024        .unwrap();
1025        let res: ConvertResult<Time<Utc>> = gps.into_scale_with_checked(ls).unwrap();
1026
1027        match res {
1028            ConvertResult::Exact(_) => {}
1029            ConvertResult::AmbiguousLeapSecond(_) => panic!("unexpected ambiguity"),
1030        }
1031    }
1032
1033    #[test]
1034    fn test_convert_result_consistency() {
1035        let t = Time::<Gps>::from_seconds(42);
1036        let exact = ConvertResult::Exact(t);
1037
1038        assert!(exact.is_exact());
1039        assert!(!exact.is_ambiguous());
1040
1041        let amb = ConvertResult::AmbiguousLeapSecond(t);
1042
1043        assert!(!amb.is_exact());
1044        assert!(amb.is_ambiguous());
1045    }
1046
1047    #[test]
1048    fn test_gps_to_tai_overflow_near_max() {
1049        let gps = Time::<Gps>::from_nanos(Time::<Gps>::MAX.as_nanos() - 1);
1050        let result: Result<Time<Tai>, _> = gps.into_scale();
1051
1052        assert!(matches!(result, Err(GnssTimeError::Overflow)));
1053    }
1054
1055    #[test]
1056    fn test_gps_to_tai_near_overflow_succeeds() {
1057        let gps = Time::<Gps>::from_nanos(Time::<Gps>::MAX.as_nanos() - 20_000_000_000);
1058        let tai: Time<Tai> = gps.into_scale().unwrap();
1059
1060        assert!(tai.as_nanos() > gps.as_nanos());
1061    }
1062
1063    #[test]
1064    fn test_glonass_utc_symmetry_random() {
1065        let utc = Time::<Utc>::from_nanos(800_000_000_000_000_000);
1066        let glo: Time<Glonass> = utc.into_scale().unwrap();
1067        let back: Time<Utc> = glo.into_scale().unwrap();
1068
1069        assert_eq!(utc, back);
1070    }
1071}