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