Skip to main content

gnss_time/
convert.rs

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