gnss_time/leap.rs
1//! # Leap seconds — conversion context
2//!
3//! ## Why this is an explicit parameter instead of global state
4//!
5//! ```text
6//! // Hidden state — bad
7//! let utc = gps.to_utc(); // where do the leap seconds come from?
8//!
9//! // Explicit context — good
10//! let utc = gps_to_utc(gps, LeapSeconds::builtin())?;
11//! ```
12//!
13//! Reasons:
14//! - `no_std` / embedded environments: no global mutable state
15//! - GNSS receivers: leap-second table may be updated at runtime
16//! - Testing: easy dependency injection without mocks
17//! - Determinism: results do not depend on future IERS updates
18//!
19//! ## Supported conversions
20//!
21//! | Function | Leap-second context? |
22//! |--------------------|-----------------------------|
23//! | `glonass_to_utc` | **no** (constant shift) |
24//! | `utc_to_glonass` | **no** (constant shift) |
25//! | `gps_to_utc` | yes |
26//! | `utc_to_gps` | yes |
27//! | `gps_to_glonass` | yes (via UTC) |
28//! | `glonass_to_gps` | yes (via UTC) |
29//!
30//! ## GLONASS and leap seconds
31//!
32//! GLONASS tracks UTC(SU) = UTC + 3 hours, including leap-second insertions.
33//!
34//! Therefore GLONASS ↔ UTC conversion is a **constant nanosecond shift**
35//! relative to epoch alignment.
36//!
37//! Leap seconds are only required when converting into GPS / Galileo / `BeiDou`
38//! time scales.
39
40use crate::{
41 tables::BUILTIN_TABLE, Beidou, CivilDate, Galileo, Glonass, GnssTimeError, Gps, Tai, Time, Utc,
42};
43
44/// Maximum number of entries in a [`RuntimeLeapSeconds`] buffer.
45///
46/// 64 entries is far beyond any plausible number of leap seconds in the
47/// foreseeable future (current count from 1972: 27 events).
48pub const RUNTIME_CAPACITY: usize = 64;
49
50static BUILTIN_LEAP_SECONDS: LeapSeconds = LeapSeconds {
51 entries: &BUILTIN_TABLE,
52};
53
54/// Nanoseconds from the UTC epoch (1972-01-01) to the GLONASS epoch
55/// (1995-12-31 21:00:00 UTC).
56///
57/// `UTC_nanos = GLO_nanos + GLONASS_FROM_UTC_EPOCH_NS`
58const GLONASS_FROM_UTC_EPOCH_NS: i64 = {
59 // от UTC-epoch до 1996-01-01 00:00:00 UTC
60 let to_1996 = CivilDate::new(1972, 1, 1).nanos_until(CivilDate::new(1996, 1, 1));
61
62 // minus 3 hours: GLONASS epoch = 3 hours earlier in UTC
63 to_1996 - 3 * 3_600 * 1_000_000_000_i64
64 // = 8766 days * 86400 * 1e9 - 10800 * 1e9
65 // = 757_382_400_000_000_000 - 10_800_000_000_000 = 757_371_600_000_000_000
66};
67
68const _VERIFY_GLONASS_OFFSET: () = {
69 let s = GLONASS_FROM_UTC_EPOCH_NS / 1_000_000_000;
70
71 assert!(
72 s == 757_371_600,
73 "GLONASS -> UTC epoch offset must be 757371600 s"
74 );
75};
76
77/// Nanoseconds from the UTC epoch (1972-01-01) to the GPS epoch (1980-01-06).
78///
79/// The GPS epoch is later, so the value is positive.
80/// `UTC_nanos_from_1972 = GPS_nanos_from_1980 - (TAI_minus_UTC - 19) * 1e9 +
81/// THIS`
82const UTC_TO_GPS_EPOCH_NS: i64 = CivilDate::new(1972, 1, 1).nanos_until(CivilDate::new(1980, 1, 6));
83// = 2927 days * 86400 * 1e9 = 252_892_800_000_000_000 ns
84
85const _VERIFY_UTC_GPS_OFFSET: () = {
86 let s = UTC_TO_GPS_EPOCH_NS / 1_000_000_000;
87
88 assert!(
89 s == 252_892_800,
90 "UTC -> GPS epoch offset must be 252892800 s (2927 days)"
91 );
92};
93
94/// Source of TAI-UTC corrections for conversions involving UTC and GLONASS.
95///
96/// This makes it possible to provide custom tables, for example values read
97/// from a GNSS receiver almanac, without changing the crate code.
98///
99/// # Example
100///
101/// ```rust
102/// use gnss_time::{LeapEntry, LeapSecondsProvider, Tai, Time};
103///
104/// struct FixedLeap(i32);
105///
106/// impl LeapSecondsProvider for FixedLeap {
107/// fn tai_minus_utc_at(
108/// &self,
109/// _tai: Time<Tai>,
110/// ) -> i32 {
111/// self.0
112/// }
113/// }
114/// ```
115pub trait LeapSecondsProvider {
116 /// Returns TAI - UTC (in seconds) for the given TAI moment.
117 fn tai_minus_utc_at(
118 &self,
119 tai: Time<Tai>,
120 ) -> i32;
121}
122
123/// Error returned by [`RuntimeLeapSeconds::try_extend`].
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
125#[must_use = "handle the extension error; ignoring it means the table was not updated"]
126#[non_exhaustive]
127pub enum LeapExtendError {
128 /// The new entry's `tai_nanos` is not strictly greater than the last
129 /// existing entry — the table would become unsorted.
130 NotStrictlyAscending,
131
132 /// The new entry's `tai_minus_utc` is not exactly one more than the last
133 /// existing entry — every leap second must increment the counter by 1.
134 NonUnitIncrement,
135
136 /// The runtime buffer is full; no more entries can be appended.
137 BufferFull,
138}
139
140/// One leap-second table entry.
141///
142/// Starting from `tai_minus_utc` (internal TAI nanoseconds), `TAI - UTC =
143/// tai_minus_utc` seconds.
144///
145/// Strict contract: the table must be sorted by `tai_nanos` in ascending order.
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
147pub struct LeapEntry {
148 /// Internal TAI nanoseconds (inclusive lower bound).
149 pub tai_nanos: u64,
150
151 /// TAI - UTC in whole seconds, valid from this moment onward.
152 pub tai_minus_utc: i32,
153}
154
155/// Static leap-second correction table.
156///
157/// The built-in table [`builtin`](LeapSeconds::builtin) covers all events from
158/// the GPS start (1980-01-06) through 2017-01-01 inclusive.
159/// For times after the last entry, the last known value is returned
160/// (the standard "assume no new leap seconds" approach).
161///
162/// # `no_std`
163///
164/// `LeapSeconds` stores `&'static [LeapEntry]` — there are no allocations, and
165/// it works everywhere.
166///
167/// # Examples
168///
169/// ```rust
170/// use gnss_time::{gps_to_utc, DurationParts, Gps, LeapSeconds, LeapSecondsProvider, Time};
171///
172/// // Built-in table (up to 2017)
173/// let ls = LeapSeconds::builtin();
174///
175/// let gps = Time::<Gps>::from_week_tow(
176/// 1981,
177/// DurationParts {
178/// seconds: 0,
179/// nanos: 0,
180/// },
181/// )
182/// .unwrap();
183/// let utc = gps_to_utc(gps, &ls).unwrap();
184/// // GPS leads UTC by 18 seconds in this period
185/// ```
186pub struct LeapSeconds {
187 entries: &'static [LeapEntry], // (Unix seconds, TAI-UTC)
188}
189
190/// A heap-free, fixed-capacity leap-second table for embedded / receiver use.
191///
192/// Suitable for GNSS receivers that receive the current leap-second count from
193/// the GPS navigation message and need an up-to-date table without any heap
194/// allocation.
195///
196/// Start with [`from_builtin`](Self::from_builtin) to pre-populate the
197/// compile-time snapshot, then call [`try_extend`](Self::try_extend) whenever
198/// the receiver almanac reports a new event.
199///
200/// # Capacity
201///
202/// Holds up to [`RUNTIME_CAPACITY`] (64) entries.
203///
204/// # Example
205///
206/// ```rust
207/// use gnss_time::{LeapEntry, LeapSecondsProvider, RuntimeLeapSeconds, Tai, Time};
208///
209/// let mut rt = RuntimeLeapSeconds::from_builtin();
210///
211/// // Hypothetical future event (illustrative only).
212/// // rt.try_extend(LeapEntry::new(9_999_999_999_000_000_000, 38)).unwrap();
213///
214/// assert_eq!(rt.current_tai_minus_utc(), 37);
215/// ```
216#[derive(Debug)]
217pub struct RuntimeLeapSeconds {
218 buf: [LeapEntry; RUNTIME_CAPACITY],
219 len: usize,
220}
221
222impl LeapEntry {
223 /// Creates a new leap-second entry.
224 ///
225 /// # Parameters
226 /// - `tai_nanos`: threshold value in TAI nanoseconds (inclusive lower
227 /// bound) from which this offset applies.
228 /// - `tai_minus_utc`: TAI - UTC in seconds that applies from this
229 /// threshold.
230 #[inline]
231 #[must_use]
232 pub const fn new(
233 tai_nanos: u64,
234 tai_minus_utc: i32,
235 ) -> Self {
236 LeapEntry {
237 tai_nanos,
238 tai_minus_utc,
239 }
240 }
241}
242
243impl LeapSeconds {
244 /// Built-in table valid through 2017-01-01.
245 ///
246 /// Covers all 19 entries in the GPS era (1980-01-06 … 2017-01-01).
247 ///
248 /// **Last verified:** IERS Bulletin C 70 (December 2024) — no new leap
249 /// seconds scheduled through June 2025. Status as of May 2026: TAI−UTC =
250 /// 37, unchanged.
251 ///
252 /// Source: [IERS Bulletin C](https://www.iers.org/IERS/EN/Publications/Bulletins/bulletins.html)
253 #[inline]
254 #[must_use]
255 pub fn builtin() -> &'static LeapSeconds {
256 &BUILTIN_LEAP_SECONDS
257 }
258
259 /// Creates a table from a custom static slice.
260 ///
261 /// This is an alias for [`from_table`](Self::from_table), provided for API
262 /// symmetry with [`RuntimeLeapSeconds::from_slice`].
263 ///
264 /// # Requirements
265 ///
266 /// `entries` must be sorted by `tai_nanos` in strictly ascending order and
267 /// each consecutive `tai_minus_utc` must increment by exactly 1.
268 ///
269 /// # Example
270 ///
271 /// ```rust
272 /// use gnss_time::{LeapEntry, LeapSeconds};
273 ///
274 /// static MY_TABLE: [LeapEntry; 1] = [LeapEntry::new(0, 37)];
275 /// let ls = LeapSeconds::from_slice(&MY_TABLE);
276 ///
277 /// assert_eq!(ls.len(), 1);
278 /// ```
279 #[inline]
280 #[must_use]
281 pub const fn from_slice(entries: &'static [LeapEntry]) -> Self {
282 Self { entries }
283 }
284
285 /// Creates a table from a custom static slice (canonical name).
286 ///
287 /// # Requirements
288 ///
289 /// `entries` must be sorted by `tai_nanos` in ascending order.
290 #[inline]
291 #[must_use]
292 pub const fn from_table(entries: &'static [LeapEntry]) -> Self {
293 Self { entries }
294 }
295
296 /// Returns the number of entries in the table.
297 #[inline]
298 #[must_use]
299 pub const fn len(&self) -> usize {
300 self.entries.len()
301 }
302
303 /// Returns `true` if the table is empty.
304 #[inline]
305 #[must_use]
306 pub const fn is_empty(&self) -> bool {
307 self.entries.is_empty()
308 }
309
310 /// Returns all table entries (for inspection / serialization).
311 #[inline]
312 #[must_use]
313 pub const fn entries(&self) -> &[LeapEntry] {
314 self.entries
315 }
316
317 /// Returns the TAI timestamp of the most recent leap-second event.
318 ///
319 /// Returns `None` when the table contains only the base entry (threshold
320 /// = 0) or is empty — in those cases there is no recorded event timestamp.
321 ///
322 /// Useful for diagnostics: compare against the current time to detect
323 /// whether the table may be stale.
324 ///
325 /// # Example
326 ///
327 /// ```rust
328 /// use gnss_time::LeapSeconds;
329 ///
330 /// let ls = LeapSeconds::builtin();
331 /// let last = ls.last_update().expect("builtin table is non-empty");
332 ///
333 /// // 2017-01-01 TAI threshold
334 /// assert_eq!(last.as_nanos(), 1_167_264_037_000_000_000);
335 /// ```
336 #[inline]
337 #[must_use]
338 pub const fn last_update(&self) -> Option<Time<Tai>> {
339 if self.entries.len() <= 1 {
340 return None;
341 }
342
343 let last = &self.entries[self.entries.len() - 1];
344
345 Some(Time::<Tai>::from_nanos(last.tai_nanos))
346 }
347
348 /// Returns the current TAI − UTC value (the `tai_minus_utc` of the last
349 /// entry), or 19 for an empty table.
350 ///
351 /// Equivalent to `tai_minus_utc_at(Time::<Tai>::MAX)`.
352 ///
353 /// # Example
354 ///
355 /// ```rust
356 /// use gnss_time::LeapSeconds;
357 ///
358 /// assert_eq!(LeapSeconds::builtin().current_tai_minus_utc(), 37);
359 /// ```
360 #[inline]
361 #[must_use]
362 pub const fn current_tai_minus_utc(&self) -> i32 {
363 if self.entries.is_empty() {
364 return 19;
365 }
366
367 self.entries[self.entries.len() - 1].tai_minus_utc
368 }
369}
370
371impl RuntimeLeapSeconds {
372 /// Creates an empty runtime table.
373 ///
374 /// Call [`try_extend`](Self::try_extend) or use
375 /// [`from_builtin`](Self::from_builtin) before performing conversions.
376 #[inline]
377 #[must_use]
378 pub const fn new() -> Self {
379 Self {
380 buf: [LeapEntry::new(0, 0); RUNTIME_CAPACITY],
381 len: 0,
382 }
383 }
384
385 /// Creates a runtime table pre-populated from built-in static table.
386 ///
387 /// This is the recommended starting point for receivers: begin with the
388 /// compile-time snapshot and extend when the almanac reports new data.
389 ///
390 /// # Panics
391 ///
392 /// Panics if `BUILTIN_YABLE.len() > RUNTIME_CAPACITY` (cannot happen with
393 /// current constants, but asserted for correctness).
394 #[must_use]
395 pub fn from_builtin() -> Self {
396 assert!(
397 BUILTIN_TABLE.len() <= RUNTIME_CAPACITY,
398 "BUILTIN_TABLE exceeds RUNTIME_CAPACITY"
399 );
400
401 let mut rt = Self::new();
402
403 for &entry in &BUILTIN_TABLE {
404 rt.buf[rt.len] = entry;
405 rt.len += 1;
406 }
407
408 rt
409 }
410
411 /// Creates a runtime table from a slice of entries.
412 ///
413 /// Mirrors [`LeapSeconds::from_slice`] for contexts where a mutable /
414 /// extendable table is needed.
415 ///
416 /// # Errors
417 ///
418 /// Returns [`LeapExtendError::BufferFull`] if `entries.len() >
419 /// RUNTIME_CAPACITY`.
420 #[inline]
421 pub fn from_slice(entries: &[LeapEntry]) -> Result<Self, LeapExtendError> {
422 if entries.len() > RUNTIME_CAPACITY {
423 return Err(LeapExtendError::BufferFull);
424 }
425
426 let mut rt = Self::new();
427
428 for &entry in entries {
429 rt.buf[rt.len] = entry;
430 rt.len += 1;
431 }
432
433 Ok(rt)
434 }
435
436 /// Appends a new leap-second event to the runtime table.
437 ///
438 /// Internally, the table is treated as a strictly ordered sequence of
439 /// leap-second transitions. Each new entry must extend the sequence
440 /// without breaking its monotonic structure.
441 ///
442 /// # Validation
443 ///
444 /// The new entry must satisfy:
445 /// - `entry.tai_nanos > last().tai_nanos` — strictly ascending order
446 /// - `entry.tai_minus_utc == last().tai_minus_utc + 1` — unit increment
447 ///
448 /// # Errors
449 ///
450 /// - [`LeapExtendError::NotStrictlyAscending`] — threshold not increasing
451 /// - [`LeapExtendError::NonUnitIncrement`] — value does not increment by 1
452 /// - [`LeapExtendError::BufferFull`] — capacity exhausted
453 ///
454 /// # Notes
455 ///
456 /// This method does not attempt to validate whether the provided entry
457 /// corresponds to a *real* leap second published by official sources.
458 /// It only enforces internal consistency of the sequence.
459 ///
460 /// # Example
461 ///
462 /// ```rust
463 /// use gnss_time::{LeapEntry, RuntimeLeapSeconds};
464 ///
465 /// let mut rt = RuntimeLeapSeconds::from_builtin();
466 ///
467 /// // Hypothetical future leap second (not a real event).
468 /// rt.try_extend(LeapEntry::new(9_999_999_999_000_000_000, 38))
469 /// .unwrap();
470 ///
471 /// assert_eq!(rt.current_tai_minus_utc(), 38);
472 /// assert_eq!(rt.len(), 20);
473 /// ```
474 pub fn try_extend(
475 &mut self,
476 entry: LeapEntry,
477 ) -> Result<(), LeapExtendError> {
478 // Prevent writing past the fixed buffer.
479 // This keeps the structure allocation-free and predictable.
480 if self.len >= RUNTIME_CAPACITY {
481 return Err(LeapExtendError::BufferFull);
482 }
483
484 // If there is at least one entry, validate against the last one.
485 if self.len > 0 {
486 let last = &self.buf[self.len - 1];
487
488 // Enforce strict monotonicity in time.
489 // Equal or smaller timestamps would break ordering assumptions.
490 if entry.tai_nanos <= last.tai_nanos {
491 return Err(LeapExtendError::NotStrictlyAscending);
492 }
493
494 // Enforce +1 step in TAI−UTC offset.
495 // Anything else would violate leap second semantics.
496 if entry.tai_minus_utc != last.tai_minus_utc + 1 {
497 return Err(LeapExtendError::NonUnitIncrement);
498 }
499 }
500
501 self.buf[self.len] = entry;
502 self.len += 1;
503
504 Ok(())
505 }
506
507 /// Returns the number of entries currently in the table.
508 #[inline]
509 #[must_use]
510 pub const fn len(&self) -> usize {
511 self.len
512 }
513
514 /// Returns `true` if the table has no entries.
515 #[inline]
516 #[must_use]
517 pub const fn is_empty(&self) -> bool {
518 self.len == 0
519 }
520
521 /// Returns all live entries as a slice.
522 #[inline]
523 #[must_use]
524 pub fn entries(&self) -> &[LeapEntry] {
525 &self.buf[..self.len]
526 }
527
528 /// Returns the TAI timestamp of the most recent event, or `None` for a
529 /// single-entry or empty table.
530 #[inline]
531 #[must_use]
532 pub const fn last_update(&self) -> Option<Time<Tai>> {
533 if self.len <= 1 {
534 return None;
535 }
536
537 Some(Time::<Tai>::from_nanos(self.buf[self.len - 1].tai_nanos))
538 }
539
540 /// Returns the current TAI - UTC value (last entry), or 19 for an empty
541 /// table.
542 #[inline]
543 #[must_use]
544 pub const fn current_tai_minus_utc(&self) -> i32 {
545 if self.len == 0 {
546 return 19;
547 }
548
549 self.buf[self.len - 1].tai_minus_utc
550 }
551}
552
553impl LeapSecondsProvider for LeapSeconds {
554 fn tai_minus_utc_at(
555 &self,
556 tai: Time<Tai>,
557 ) -> i32 {
558 let nanos = tai.as_nanos();
559 let entries = self.entries;
560
561 if entries.is_empty() {
562 return 19; // safe fallback value at GPS epoch
563 }
564
565 // Find the last entry with tai_nanos <= nanos
566 match entries.binary_search_by_key(&nanos, |e| e.tai_nanos) {
567 // Exact match: use found entry
568 Ok(i) => entries[i].tai_minus_utc,
569 // nanos is before first entry: return initial value
570 Err(0) => entries[0].tai_minus_utc,
571 // Standard case: entry before insertion point
572 Err(i) => entries[i - 1].tai_minus_utc,
573 }
574 }
575}
576
577impl LeapSecondsProvider for RuntimeLeapSeconds {
578 fn tai_minus_utc_at(
579 &self,
580 tai: Time<Tai>,
581 ) -> i32 {
582 let entries = self.entries();
583 let nanos = tai.as_nanos();
584
585 if entries.is_empty() {
586 return 19;
587 }
588
589 match entries.binary_search_by_key(&nanos, |e| e.tai_nanos) {
590 Ok(i) => entries[i].tai_minus_utc,
591 Err(0) => entries[0].tai_minus_utc,
592 Err(i) => entries[i - 1].tai_minus_utc,
593 }
594 }
595}
596
597// Generic implementation: &P automatically implements LeapSecondsProvider if P
598// does. This allows passing &LeapSeconds::builtin() directly.
599impl<P: LeapSecondsProvider> LeapSecondsProvider for &P {
600 fn tai_minus_utc_at(
601 &self,
602 tai: Time<Tai>,
603 ) -> i32 {
604 (*self).tai_minus_utc_at(tai)
605 }
606}
607
608////////////////////////////////////////////////////////////////////////////////
609// GLONASS -> UTC, GPS
610////////////////////////////////////////////////////////////////////////////////
611
612/// Converts GLONASS -> UTC (without leap-second context).
613///
614/// GLONASS tracks UTC(SU) = UTC + 3h, including leap seconds.
615/// Both scales store continuous nanoseconds, so the conversion is just a
616/// constant epoch shift.
617///
618/// # Shift
619///
620/// `UTC_ns = GLO_ns + 757_371_600_000_000_000`
621/// (= days from UTC epoch to GLONASS epoch × 86400 × 1e9)
622///
623/// # Errors
624///
625/// [`GnssTimeError::Overflow`] — if UTC < UTC epoch (1972-01-01).
626pub fn glonass_to_utc(glo: Time<Glonass>) -> Result<Time<Utc>, GnssTimeError> {
627 let utc_ns = i128::from(glo.as_nanos()) + i128::from(GLONASS_FROM_UTC_EPOCH_NS);
628
629 if utc_ns < 0 || utc_ns > i128::from(u64::MAX) {
630 return Err(GnssTimeError::Overflow);
631 }
632
633 let nanos = u64::try_from(utc_ns).map_err(|_| GnssTimeError::Overflow)?;
634
635 Ok(Time::<Utc>::from_nanos(nanos))
636}
637
638/// Converts GLONASS -> GPS via UTC.
639///
640/// Requires leap-second context (for UTC -> GPS).
641///
642/// # Errors
643///
644/// Propagates:
645/// - [`GnssTimeError::Overflow`] from [`glonass_to_utc`]
646/// - [`GnssTimeError::Overflow`] from [`utc_to_gps`]
647pub fn glonass_to_gps<P: LeapSecondsProvider>(
648 glo: Time<Glonass>,
649 ls: &P,
650) -> Result<Time<Gps>, GnssTimeError> {
651 let utc = glonass_to_utc(glo)?;
652
653 utc_to_gps(utc, ls)
654}
655
656/// Converts GLONASS -> Galileo via UTC (requires leap-second context).
657///
658/// # Errors
659///
660/// Propagates:
661/// - [`GnssTimeError::Overflow`] from [`glonass_to_utc`]
662/// - [`GnssTimeError::Overflow`] from [`utc_to_galileo`]
663pub fn glonass_to_galileo<P: LeapSecondsProvider>(
664 glo: Time<Glonass>,
665 ls: &P,
666) -> Result<Time<Galileo>, GnssTimeError> {
667 let utc = glonass_to_utc(glo)?;
668
669 utc_to_galileo(utc, ls)
670}
671
672/// Converts GLONASS -> `BeiDou` via UTC (requires leap-second context).
673///
674/// # Errors
675///
676/// Propagates:
677/// - [`GnssTimeError::Overflow`] from [`glonass_to_utc`]
678/// - [`GnssTimeError::Overflow`] from [`utc_to_beidou`]
679pub fn glonass_to_beidou<P: LeapSecondsProvider>(
680 glo: Time<Glonass>,
681 ls: &P,
682) -> Result<Time<Beidou>, GnssTimeError> {
683 let utc = glonass_to_utc(glo)?;
684
685 utc_to_beidou(utc, ls)
686}
687
688////////////////////////////////////////////////////////////////////////////////
689// GPS -> UTC, GLONASS
690////////////////////////////////////////////////////////////////////////////////
691
692/// Converts GPS -> UTC.
693///
694/// Requires an explicit [`LeapSecondsProvider`] context.
695///
696/// # Formula
697///
698/// ```text
699/// UTC_nanos_from_1972 = GPS_nanos_from_1980 - (TAI_minus_UTC - 19) * 1e9 + GPS_EPOCH_OFFSET_FROM_UTC_EPOCH_ns
700/// ```
701///
702/// # Errors
703///
704/// [`GnssTimeError::Overflow`] — the result does not fit into `u64`.
705///
706/// # Example
707///
708/// ```rust
709/// use gnss_time::{gps_to_utc, Gps, LeapSeconds, Time};
710///
711/// let ls = LeapSeconds::builtin();
712/// let gps = Time::<Gps>::from_nanos(0); // GPS epoch
713/// let utc = gps_to_utc(gps, &ls).unwrap();
714///
715/// // At the GPS epoch (1980-01-06), GPS-UTC = 0; UTC should represent the same instant
716/// assert_eq!(utc.as_nanos(), 252_892_800_000_000_000); // from 1972-01-01
717/// ```
718pub fn gps_to_utc<P: LeapSecondsProvider>(
719 gps: Time<Gps>,
720 ls: &P,
721) -> Result<Time<Utc>, GnssTimeError> {
722 let tai = gps.to_tai()?;
723 let n = ls.tai_minus_utc_at(tai);
724 // UTC_ns = GPS_ns - (n - 19) * 1e9 + epoch_offset
725 let utc_ns = i128::from(gps.as_nanos()) - (i128::from(n - 19) * 1_000_000_000_i128)
726 + i128::from(UTC_TO_GPS_EPOCH_NS);
727
728 if utc_ns < 0 || utc_ns > i128::from(u64::MAX) {
729 return Err(GnssTimeError::Overflow);
730 }
731
732 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
733 let nanos = utc_ns as u64;
734
735 Ok(Time::<Utc>::from_nanos(nanos))
736}
737
738/// Converts GPS -> GLONASS via UTC.
739///
740/// Requires leap-second context (for GPS -> UTC).
741///
742/// # Errors
743///
744/// Propagates:
745/// - [`GnssTimeError::Overflow`] from [`gps_to_utc`]
746/// - [`GnssTimeError::Overflow`] from [`utc_to_glonass`]
747pub fn gps_to_glonass<P: LeapSecondsProvider>(
748 gps: Time<Gps>,
749 ls: &P,
750) -> Result<Time<Glonass>, GnssTimeError> {
751 let utc = gps_to_utc(gps, ls)?;
752
753 utc_to_glonass(utc)
754}
755
756////////////////////////////////////////////////////////////////////////////////
757// Galileo -> UTC, GLONASS
758////////////////////////////////////////////////////////////////////////////////
759
760/// Galileo -> UTC (requires leap-second context).
761///
762/// # Errors
763///
764/// Returns `GnssTimeError` if either:
765/// - the Galileo → GPS time scale conversion fails, or
766/// - the underlying GPS → UTC conversion fails (e.g. overflow).
767pub fn galileo_to_utc<P: LeapSecondsProvider>(
768 gal: Time<Galileo>,
769 ls: &P,
770) -> Result<Time<Utc>, GnssTimeError> {
771 // Galileo and GPS share the same TAI offset, so we convert via GPS as an
772 // intermediate step.
773 let gps = gal.try_convert::<Gps>()?;
774
775 gps_to_utc(gps, ls)
776}
777
778/// Converts GLONASS -> Galileo via UTC (requires leap-second context).
779///
780/// # Errors
781///
782/// Returns [`GnssTimeError`] if either:
783/// - the Galileo → GPS → UTC conversion fails (e.g. invalid conversion or
784/// overflow)
785/// - the UTC → GLONASS conversion fails due to overflow
786pub fn galileo_to_glonass<P: LeapSecondsProvider>(
787 gal: Time<Galileo>,
788 ls: &P,
789) -> Result<Time<Glonass>, GnssTimeError> {
790 let utc = galileo_to_utc(gal, ls)?;
791
792 utc_to_glonass(utc)
793}
794
795////////////////////////////////////////////////////////////////////////////////
796// BeiDou -> UTC
797////////////////////////////////////////////////////////////////////////////////
798
799/// `BeiDou` -> UTC (requires leap-second context).
800///
801/// This conversion proceeds via GPS as an intermediate time scale.
802///
803/// # Errors
804///
805/// Returns [`GnssTimeError`] if:
806/// - the internal `BeiDou -> GPS` conversion fails (`try_convert::<Gps>`), or
807/// - the resulting GPS -> UTC conversion overflows the valid `u64` time range.
808pub fn beidou_to_utc<P: LeapSecondsProvider>(
809 bdt: Time<Beidou>,
810 ls: &P,
811) -> Result<Time<Utc>, GnssTimeError> {
812 let gps = bdt.try_convert::<Gps>()?;
813
814 gps_to_utc(gps, ls)
815}
816
817/// `BeiDou` -> GLONASS via UTC (requires leap-second context).
818///
819/// # Errors
820///
821/// This function will return an error if:
822/// - the intermediate `BeiDou -> UTC` conversion fails (see [`beidou_to_utc`])
823/// - the `UTC -> GLONASS` conversion fails due to overflow (see
824/// [`utc_to_glonass`])
825pub fn beidou_to_glonass<P: LeapSecondsProvider>(
826 bdt: Time<Beidou>,
827 ls: &P,
828) -> Result<Time<Glonass>, GnssTimeError> {
829 let utc = beidou_to_utc(bdt, ls)?;
830
831 utc_to_glonass(utc)
832}
833
834////////////////////////////////////////////////////////////////////////////////
835// UTC -> GLONASS, GPS, Galielo, BeiDou
836////////////////////////////////////////////////////////////////////////////////
837
838/// Converts UTC -> GLONASS (without leap-second context).
839///
840/// # Errors
841///
842/// [`GnssTimeError::Overflow`] — if UTC is earlier than the GLONASS epoch
843/// (1996-01-01 UTC(SU)).
844pub fn utc_to_glonass(utc: Time<Utc>) -> Result<Time<Glonass>, GnssTimeError> {
845 let glo_ns = i128::from(utc.as_nanos()) - i128::from(GLONASS_FROM_UTC_EPOCH_NS);
846
847 if glo_ns < 0 || glo_ns > i128::from(u64::MAX) {
848 return Err(GnssTimeError::Overflow);
849 }
850
851 let nanos = u64::try_from(glo_ns).map_err(|_| GnssTimeError::Overflow)?;
852
853 Ok(Time::<Glonass>::from_nanos(nanos))
854}
855
856/// Converts UTC -> GPS.
857///
858/// Requires an explicit [`LeapSecondsProvider`] context.
859///
860/// # Accuracy at leap-second insertion
861///
862/// During the 1-second leap-second insertion window, the result may be off by
863/// 1 second. For all other instants, the result is exact.
864///
865/// # Errors
866///
867/// [`GnssTimeError::Overflow`] — the result does not fit into `u64`.
868pub fn utc_to_gps<P: LeapSecondsProvider>(
869 utc: Time<Utc>,
870 ls: &P,
871) -> Result<Time<Gps>, GnssTimeError> {
872 // Two-pass computation for correct leap-second boundary handling.
873 //
874 // Pass 1: approximate TAI assuming GPS-UTC = 0.
875 let approx_tai_ns =
876 i128::from(utc.as_nanos()) - i128::from(UTC_TO_GPS_EPOCH_NS) + 19_000_000_000_i128;
877
878 let tai1 = match u64::try_from(approx_tai_ns) {
879 Ok(ns) => Time::<Tai>::from_nanos(ns),
880 Err(_) => Time::<Tai>::EPOCH,
881 };
882
883 let n1 = ls.tai_minus_utc_at(tai1);
884
885 // Pass 2: refinement using n1, resolving boundary ambiguity.
886 let refined_tai_ns = i128::from(utc.as_nanos()) - i128::from(UTC_TO_GPS_EPOCH_NS)
887 + (i128::from(n1) * 1_000_000_000_i128);
888
889 let tai2 = match u64::try_from(refined_tai_ns) {
890 Ok(ns) => Time::<Tai>::from_nanos(ns),
891 Err(_) => tai1,
892 };
893
894 let n = ls.tai_minus_utc_at(tai2);
895
896 let gps_ns = i128::from(utc.as_nanos()) + (i128::from(n - 19) * 1_000_000_000_i128)
897 - i128::from(UTC_TO_GPS_EPOCH_NS);
898
899 let gps_ns = u64::try_from(gps_ns).map_err(|_| GnssTimeError::Overflow)?;
900
901 Ok(Time::<Gps>::from_nanos(gps_ns))
902}
903
904/// Converts UTC -> Galileo (requires leap-second context).
905///
906/// # Errors
907///
908/// Returns [`GnssTimeError`] if either:
909/// - the intermediate UTC → GPS conversion fails (e.g. overflow), or
910/// - the GPS → Galileo time-scale conversion fails.
911pub fn utc_to_galileo<P: LeapSecondsProvider>(
912 utc: Time<Utc>,
913 ls: &P,
914) -> Result<Time<Galileo>, GnssTimeError> {
915 let gps = utc_to_gps(utc, ls)?;
916
917 gps.try_convert::<Galileo>()
918}
919
920/// Converts UTC -> `BeiDou` (requires leap-second context).
921///
922/// # Errors
923///
924/// This function returns [`GnssTimeError`] if:
925/// - the intermediate UTC → GPS conversion fails (overflow), or
926/// - the GPS → `BeiDou` conversion fails (`try_convert::<Beidou>`).
927pub fn utc_to_beidou<P: LeapSecondsProvider>(
928 utc: Time<Utc>,
929 ls: &P,
930) -> Result<Time<Beidou>, GnssTimeError> {
931 let gps = utc_to_gps(utc, ls)?;
932
933 gps.try_convert::<Beidou>()
934}
935
936impl core::fmt::Display for LeapExtendError {
937 fn fmt(
938 &self,
939 f: &mut core::fmt::Formatter<'_>,
940 ) -> core::fmt::Result {
941 match self {
942 LeapExtendError::NotStrictlyAscending => {
943 f.write_str("new entry tai_nanos is not strictly greater than the last entry")
944 }
945 LeapExtendError::NonUnitIncrement => {
946 f.write_str("new entry tai_minus_utc be exactly one more tham the last entry")
947 }
948 LeapExtendError::BufferFull => {
949 f.write_str("runtime leap-second buffer is full; cannot add more entries")
950 }
951 }
952 }
953}
954
955#[cfg(feature = "std")]
956impl std::error::Error for LeapExtendError {}
957
958impl Default for RuntimeLeapSeconds {
959 fn default() -> Self {
960 Self::new()
961 }
962}
963
964////////////////////////////////////////////////////////////////////////////////
965// Tests
966////////////////////////////////////////////////////////////////////////////////
967
968#[cfg(test)]
969mod tests {
970 #[allow(unused_imports)]
971 use std::string::ToString;
972
973 use super::*;
974 use crate::{scale::Gps, DurationParts};
975
976 #[test]
977 fn test_utc_to_gps_epoch_offset_is_252892800_seconds() {
978 assert_eq!(UTC_TO_GPS_EPOCH_NS / 1_000_000_000, 252_892_800);
979 }
980
981 #[test]
982 fn test_glonass_epoch_offset_is_757371600_seconds() {
983 assert_eq!(GLONASS_FROM_UTC_EPOCH_NS / 1_000_000_000, 757_371_600);
984 }
985
986 #[test]
987 fn test_builtin_table_length() {
988 assert_eq!(LeapSeconds::builtin().len(), 19);
989 }
990
991 #[test]
992 fn test_utc_to_gps_epoch_offset_is_2927_days() {
993 assert_eq!(UTC_TO_GPS_EPOCH_NS / 1_000_000_000 / 86_400, 2927);
994 }
995
996 #[test]
997 fn test_glonass_epoch_offset_from_utc_epoch_is_correct() {
998 // 757_371_600 s = 8766 days * 86400 - 3h
999 // = (days from 1972-01-01 to 1996-01-01) * 86400 - 10800
1000 assert_eq!(GLONASS_FROM_UTC_EPOCH_NS / 1_000_000_000, 757_371_600);
1001 }
1002
1003 #[test]
1004 fn test_builtin_table_is_sorted() {
1005 let entries = LeapSeconds::builtin().entries();
1006
1007 for w in entries.windows(2) {
1008 assert!(w[0].tai_nanos < w[1].tai_nanos, "table not sorted at {w:?}",);
1009 }
1010 }
1011
1012 #[test]
1013 fn test_builtin_table_starts_with_tai_minus_utc_19() {
1014 assert_eq!(LeapSeconds::builtin().entries()[0].tai_minus_utc, 19);
1015 }
1016
1017 #[test]
1018 fn test_builtin_table_ends_with_tai_minus_utc_37() {
1019 let last = *LeapSeconds::builtin().entries().last().unwrap();
1020 assert_eq!(last.tai_minus_utc, 37);
1021 }
1022
1023 #[test]
1024 fn test_builtin_table_has_monotone_increasing_tai_minus_utc() {
1025 let entries = LeapSeconds::builtin().entries();
1026
1027 for w in entries.windows(2) {
1028 assert_eq!(
1029 w[1].tai_minus_utc,
1030 w[0].tai_minus_utc + 1,
1031 "expected each entry to increment by 1"
1032 );
1033 }
1034 }
1035
1036 // Cross-reference against raw IERS Bulletin C data.
1037 //
1038 // Each TAI threshold is independently recomputed from the Unix event
1039 // timestamp using the canonical formula and compared to the compiled
1040 // table.
1041 #[test]
1042 fn test_builtin_table_matches_iers_bulletin_c() {
1043 const GPS_EPOCH_UNIX: u64 = 315_964_800;
1044
1045 // (unix_event_timestamp, expected_tai_minus_utc)
1046 let iers_events: &[(u64, i32)] = &[
1047 (362_793_600, 20), // 1981-07-01
1048 (394_329_600, 21), // 1982-07-01
1049 (425_865_600, 22), // 1983-07-01
1050 (489_024_000, 23), // 1985-07-01
1051 (567_993_600, 24), // 1988-01-01
1052 (631_152_000, 25), // 1990-01-01
1053 (662_688_000, 26), // 1991-01-01
1054 (709_948_800, 27), // 1992-07-01
1055 (741_484_800, 28), // 1993-07-01
1056 (773_020_800, 29), // 1994-07-01
1057 (820_454_400, 30), // 1996-01-01
1058 (867_715_200, 31), // 1997-07-01
1059 (915_148_800, 32), // 1999-01-01
1060 (1_136_073_600, 33), // 2006-01-01
1061 (1_230_768_000, 34), // 2009-01-01
1062 (1_341_100_800, 35), // 2012-07-01
1063 (1_435_708_800, 36), // 2015-07-01
1064 (1_483_228_800, 37), // 2017-01-01
1065 ];
1066
1067 let entries = LeapSeconds::builtin().entries();
1068
1069 // Entry 0 is the base value at GPS epoch.
1070 assert_eq!(entries[0].tai_nanos, 0);
1071 assert_eq!(entries[0].tai_minus_utc, 19);
1072
1073 // Entries 1..18 must match the IERS events exactly.
1074 for (idx, &(unix, expected_n)) in iers_events.iter().enumerate() {
1075 let gps_s = unix - GPS_EPOCH_UNIX;
1076 let expected_threshold = (gps_s + u64::try_from(expected_n).unwrap()) * 1_000_000_000;
1077 let entry = &entries[idx + 1];
1078
1079 assert_eq!(
1080 entry.tai_nanos,
1081 expected_threshold,
1082 "threshold mismatch at IERS event {} (unix={})",
1083 idx + 1,
1084 unix
1085 );
1086 assert_eq!(
1087 entry.tai_minus_utc,
1088 expected_n,
1089 "tai_minus_utc mismatch at IERS event {} (unix={})",
1090 idx + 1,
1091 unix
1092 );
1093 }
1094 }
1095
1096 #[test]
1097 fn test_last_update_builtin_is_2017_threshold() {
1098 let last = LeapSeconds::builtin()
1099 .last_update()
1100 .expect("builtin must have last_update");
1101
1102 assert_eq!(last.as_nanos(), 1_167_264_037_000_000_000);
1103 }
1104
1105 #[test]
1106 fn test_last_update_single_entry_is_none() {
1107 static SINGLE: [LeapEntry; 1] = [LeapEntry::new(0, 37)];
1108 let ls = LeapSeconds::from_slice(&SINGLE);
1109
1110 assert!(ls.last_update().is_none());
1111 }
1112
1113 #[test]
1114 fn test_last_update_empty_is_none() {
1115 static EMPTY: [LeapEntry; 0] = [];
1116 let ls = LeapSeconds::from_slice(&EMPTY);
1117
1118 assert!(ls.last_update().is_none());
1119 }
1120
1121 #[test]
1122 fn test_current_tai_minus_utc_builtin_is_37() {
1123 assert_eq!(LeapSeconds::builtin().current_tai_minus_utc(), 37);
1124 }
1125
1126 #[test]
1127 fn test_current_tai_minus_utc_empty_is_fallback_19() {
1128 static EMPTY: [LeapEntry; 0] = [];
1129 let ls = LeapSeconds::from_slice(&EMPTY);
1130
1131 assert_eq!(ls.current_tai_minus_utc(), 19);
1132 }
1133
1134 #[test]
1135 fn test_from_slice_and_from_table_are_equivalent() {
1136 static TABLE: [LeapEntry; 2] = [LeapEntry::new(0, 19), LeapEntry::new(1_000_000, 20)];
1137
1138 let ls_slice = LeapSeconds::from_slice(&TABLE);
1139 let ls_table = LeapSeconds::from_table(&TABLE);
1140
1141 assert_eq!(ls_slice.len(), ls_table.len());
1142 assert_eq!(
1143 ls_slice.entries()[0].tai_nanos,
1144 ls_table.entries()[0].tai_nanos
1145 );
1146 }
1147
1148 #[test]
1149 fn test_lookup_at_tai_zero_returns_19() {
1150 let ls = LeapSeconds::builtin();
1151 assert_eq!(ls.tai_minus_utc_at(Time::<Tai>::EPOCH), 19);
1152 }
1153
1154 #[test]
1155 fn test_lookup_at_max_tai_returns_37() {
1156 let ls = LeapSeconds::builtin();
1157 assert_eq!(ls.tai_minus_utc_at(Time::<Tai>::MAX), 37);
1158 }
1159
1160 #[test]
1161 fn test_lookup_at_max_tai_returns_last_value() {
1162 let ls = LeapSeconds::builtin();
1163
1164 assert_eq!(ls.tai_minus_utc_at(Time::<Tai>::MAX), 37);
1165 }
1166
1167 #[test]
1168 fn test_lookup_at_exact_2017_threshold_returns_37() {
1169 let ls = LeapSeconds::builtin();
1170 // Threshold TAI value for 2017-01-01 = 1_167_264_037_000_000_000
1171 let tai = Time::<Tai>::from_nanos(1_167_264_037_000_000_000);
1172
1173 assert_eq!(ls.tai_minus_utc_at(tai), 37);
1174 }
1175
1176 #[test]
1177 fn test_lookup_one_ns_before_2017_threshold_returns_36() {
1178 let ls = LeapSeconds::builtin();
1179 let tai = Time::<Tai>::from_nanos(1_167_264_037_000_000_000 - 1);
1180
1181 assert_eq!(ls.tai_minus_utc_at(tai), 36);
1182 }
1183
1184 #[test]
1185 fn test_lookup_at_1999_threshold_returns_32() {
1186 let ls = LeapSeconds::builtin();
1187 // Threshold TAI value for 1999-01-01 = 599_184_032_000_000_000
1188 let tai = Time::<Tai>::from_nanos(599_184_032_000_000_000);
1189
1190 assert_eq!(ls.tai_minus_utc_at(tai), 32);
1191 }
1192
1193 #[test]
1194 fn test_lookup_one_ns_before_1999_threshold_returns_31() {
1195 let ls = LeapSeconds::builtin();
1196 let tai = Time::<Tai>::from_nanos(599_184_032_000_000_000 - 1);
1197
1198 assert_eq!(ls.tai_minus_utc_at(tai), 31);
1199 }
1200
1201 #[test]
1202 fn test_gps_utc_gps_roundtrip_at_gps_epoch() {
1203 let ls = LeapSeconds::builtin();
1204 let gps = Time::<Gps>::EPOCH;
1205 let utc = gps_to_utc(gps, &ls).unwrap();
1206 let back = utc_to_gps(utc, &ls).unwrap();
1207
1208 assert_eq!(gps, back);
1209 }
1210
1211 #[test]
1212 fn test_gps_utc_gps_roundtrip_at_2020() {
1213 let ls = LeapSeconds::builtin();
1214 // GPS 2020-01-01 ≈ week 2086
1215 let gps = Time::<Gps>::from_week_tow(
1216 2086,
1217 DurationParts {
1218 seconds: 0,
1219 nanos: 0,
1220 },
1221 )
1222 .unwrap();
1223 let utc = gps_to_utc(gps, &ls).unwrap();
1224 let back = utc_to_gps(utc, &ls).unwrap();
1225
1226 assert_eq!(gps, back);
1227 }
1228
1229 #[test]
1230 fn test_gps_epoch_utc_is_correct_offset_from_utc_epoch() {
1231 let ls = LeapSeconds::builtin();
1232 // At GPS epoch (1980-01-06) TAI-UTC = 19, GPS-UTC = 0
1233 // UTC nanos = GPS nanos + UTC_TO_GPS_EPOCH_NS = 0 +
1234 // 252_892_800_000_000_000
1235 let utc = gps_to_utc(Time::<Gps>::EPOCH, &ls).unwrap();
1236
1237 assert_eq!(utc.as_nanos(), 252_892_800_000_000_000);
1238 }
1239
1240 // Checking GPS-UTC = 18 at 2017-01-01 00:00:00 UTC.
1241 //
1242 // GPS at 2017-01-01 (unix=1483228800):
1243 // GPS_s = (1483228800 - 315964800) + (37-19) = 1167264000 + 18 = 1167264018
1244 // UTC nanos from UTC_epoch = 16437 days * 86400 * 1e9 =
1245 // 1_420_156_800_000_000_000
1246 #[test]
1247 fn test_gps_minus_utc_is_18s_at_2017_01_01() {
1248 let ls = LeapSeconds::builtin();
1249 // GPS seconds for 2017-01-01 00:00:00 UTC
1250 // = (unix - GPS_EPOCH_UNIX) + (TAI-UTC - 19) = (1483228800 - 315964800) + 18
1251 let gps_s: u64 = 1_167_264_000 + 18;
1252 let gps = Time::<Gps>::from_seconds(gps_s);
1253 let utc = gps_to_utc(gps, &ls).unwrap();
1254
1255 // UTC nanos for 2017-01-01 = 16437 days * 86400 * 1e9
1256 let expected_utc_ns: u64 = 16_437 * 86_400 * 1_000_000_000;
1257
1258 assert_eq!(utc.as_nanos(), expected_utc_ns);
1259 }
1260
1261 // Check GPS-UTC = 13 on 1999-01-01 00:00:00 UTC.
1262 #[test]
1263 fn test_gps_minus_utc_is_13s_at_1999_01_01() {
1264 let ls = LeapSeconds::builtin();
1265 // GPS_s = (915148800 - 315964800) + (32 - 19) = 599184000 + 13 = 599184013
1266 let gps = Time::<Gps>::from_seconds(599_184_013);
1267 let utc = gps_to_utc(gps, &ls).unwrap();
1268
1269 // UTC from UTC epoch to 1999-01-01:
1270 // days_from_unix(1999-01-01) - days_from_unix(1972-01-01)
1271 // = 10592 - 730 = 9862 days (verified below)
1272 // UTC_s = 9862 * 86400 = 851_948_800
1273 let expected_utc_s: u64 = 9_862 * 86_400;
1274
1275 assert_eq!(utc.as_seconds(), expected_utc_s);
1276 }
1277
1278 // 1998-12-31 → 1999-01-01: TAI-UTC changes 31 → 32, GPS-UTC 12 → 13.
1279 //
1280 // GPS jumps from ...011 to ...013 (there is no ...012 in real UTC time).
1281 #[test]
1282 fn test_leap_second_transition_1999_gps_jumps_by_2s() {
1283 let ls = LeapSeconds::builtin();
1284
1285 // 1 second before transition: 1998-12-31 23:59:59 UTC
1286 // unix = 915148799, TAI-UTC = 31 (old value)
1287 // GPS_s = (915148799 - 315964800) + 12 = 599183999 + 12 = 599184011
1288 let gps_before = Time::<Gps>::from_seconds(599_184_011);
1289
1290 // Immediately after: 1999-01-01 00:00:00 UTC
1291 // unix = 915148800, TAI-UTC = 32 (new value)
1292 // GPS_s = (915148800 - 315964800) + 13 = 599184000 + 13 = 599184013
1293 let gps_after = Time::<Gps>::from_seconds(599_184_013);
1294
1295 // Both should convert correctly
1296 let utc_before = gps_to_utc(gps_before, &ls).unwrap();
1297 let utc_after = gps_to_utc(gps_after, &ls).unwrap();
1298
1299 // UTC-after - UTC-before = 1 second (leap second insertion adjusts the scale)
1300 let diff = (utc_after - utc_before).as_seconds();
1301
1302 assert_eq!(diff, 1, "GPS jumped 2s but UTC advanced 1s (leap second)");
1303 }
1304
1305 // 2016-12-31 → 2017-01-01: TAI-UTC 36 → 37, GPS-UTC 17 → 18.
1306 #[test]
1307 fn test_leap_second_transition_2017_gps_jumps_by_2s() {
1308 let ls = LeapSeconds::builtin();
1309 // 1 second before: unix = 1483228799,
1310 // GPS_s = (1483228799 - 315964800) + 17
1311 let gps_before = Time::<Gps>::from_seconds(1_167_263_999 + 17);
1312 // Immediately after: unix = 1483228800,
1313 // GPS_s = (1483228800 - 315964800) + 18
1314 let gps_after = Time::<Gps>::from_seconds(1_167_264_000 + 18);
1315 let utc_before = gps_to_utc(gps_before, &ls).unwrap();
1316 let utc_after = gps_to_utc(gps_after, &ls).unwrap();
1317 let diff = (utc_after - utc_before).as_seconds();
1318
1319 assert_eq!(diff, 1, "GPS jumped 2s but UTC advanced 1s");
1320 }
1321
1322 #[test]
1323 fn test_glonass_epoch_to_utc_gives_correct_nanos() {
1324 // GLONASS epoch = 1996-01-01 00:00:00 UTC(SU)
1325 // which corresponds to 1995-12-31 21:00:00 UTC
1326 //
1327 // UTC offset from UTC epoch:
1328 // (days to 1995-12-31) * 86400 + 21h * 3600 = ...
1329 // Verified via GLONASS_FROM_UTC_EPOCH_NS constant
1330 let utc = glonass_to_utc(Time::<Glonass>::EPOCH).unwrap();
1331
1332 assert_eq!(utc.as_nanos(), GLONASS_FROM_UTC_EPOCH_NS as u64);
1333 }
1334
1335 #[test]
1336 fn test_utc_to_glonass_epoch_gives_zero() {
1337 let utc = Time::<Utc>::from_nanos(GLONASS_FROM_UTC_EPOCH_NS as u64);
1338 let glo = utc_to_glonass(utc).unwrap();
1339
1340 assert_eq!(glo, Time::<Glonass>::EPOCH);
1341 }
1342
1343 #[test]
1344 fn test_glonass_utc_glonass_roundtrip() {
1345 let glo = Time::<Glonass>::from_day_tod(
1346 10_000,
1347 DurationParts {
1348 seconds: 43_200,
1349 nanos: 0,
1350 },
1351 )
1352 .unwrap();
1353 let utc = glonass_to_utc(glo).unwrap();
1354 let back = utc_to_glonass(utc).unwrap();
1355
1356 assert_eq!(glo, back);
1357 }
1358
1359 #[test]
1360 fn test_utc_before_glonass_epoch_returns_error() {
1361 // UTC epoch (1972-01-01) is earlier than GLONASS epoch (1996),
1362 // so conversion results in underflow/overflow
1363 let utc = Time::<Utc>::EPOCH;
1364
1365 assert!(matches!(utc_to_glonass(utc), Err(GnssTimeError::Overflow)));
1366 }
1367
1368 #[test]
1369 fn test_glonass_offset_is_exactly_3_hours_less_than_day_boundary() {
1370 // Offset = 8766 days * 86400 - 3*3600 (exactly 3 hours before midnight
1371 // 1996-01-01 UTC)
1372 let three_hours_ns: i64 = 3 * 3_600 * 1_000_000_000;
1373 let days_ns: i64 = 8766 * 86_400 * 1_000_000_000;
1374
1375 assert_eq!(GLONASS_FROM_UTC_EPOCH_NS, days_ns - three_hours_ns);
1376 }
1377
1378 #[test]
1379 fn test_gps_to_glonass_to_gps_roundtrip() {
1380 let ls = LeapSeconds::builtin();
1381 // GPS time in 2020 (after the last leap second in 2017)
1382 let gps = Time::<Gps>::from_week_tow(
1383 2100,
1384 DurationParts {
1385 seconds: 86400,
1386 nanos: 0,
1387 },
1388 )
1389 .unwrap();
1390 let glo = gps_to_glonass(gps, &ls).unwrap();
1391 let back = glonass_to_gps(glo, &ls).unwrap();
1392
1393 assert_eq!(gps, back);
1394 }
1395
1396 #[test]
1397 fn test_custom_provider_works() {
1398 struct Always37;
1399
1400 impl LeapSecondsProvider for Always37 {
1401 fn tai_minus_utc_at(
1402 &self,
1403 _: Time<Tai>,
1404 ) -> i32 {
1405 37
1406 }
1407 }
1408
1409 let gps = Time::<Gps>::from_seconds(1_000_000_000);
1410 let utc = gps_to_utc(gps, &Always37).unwrap();
1411 let back = utc_to_gps(utc, &Always37).unwrap();
1412
1413 assert_eq!(gps, back);
1414 }
1415
1416 #[test]
1417 fn test_empty_table_returns_fallback_19() {
1418 static EMPTY: [LeapEntry; 0] = [];
1419
1420 let ls = LeapSeconds::from_table(&EMPTY);
1421
1422 assert_eq!(
1423 ls.tai_minus_utc_at(Time::<Tai>::from_seconds(1_000_000)),
1424 19
1425 );
1426 }
1427
1428 #[test]
1429 fn test_runtime_from_builtin_has_19_entries() {
1430 assert_eq!(RuntimeLeapSeconds::from_builtin().len(), 19);
1431 }
1432
1433 #[test]
1434 fn test_runtime_from_builtin_current_is_37() {
1435 assert_eq!(
1436 RuntimeLeapSeconds::from_builtin().current_tai_minus_utc(),
1437 37
1438 );
1439 }
1440
1441 #[test]
1442 fn test_runtime_try_extend_valid() {
1443 let mut rt = RuntimeLeapSeconds::from_builtin();
1444 rt.try_extend(LeapEntry::new(9_999_999_999_000_000_000, 38))
1445 .unwrap();
1446
1447 assert_eq!(rt.len(), 20);
1448 assert_eq!(rt.current_tai_minus_utc(), 38);
1449 }
1450
1451 #[test]
1452 fn test_runtime_try_extend_last_update_updated() {
1453 let mut rt = RuntimeLeapSeconds::from_builtin();
1454 rt.try_extend(LeapEntry::new(9_999_999_999_000_000_000, 38))
1455 .unwrap();
1456
1457 let last = rt.last_update().unwrap();
1458 assert_eq!(last.as_nanos(), 9_999_999_999_000_000_000);
1459 }
1460
1461 #[test]
1462 fn test_runtime_try_extend_not_ascending_error() {
1463 let mut rt = RuntimeLeapSeconds::from_builtin();
1464 // Same threshold as last builtin entry — not strictly ascending.
1465 let err = rt
1466 .try_extend(LeapEntry::new(1_167_264_037_000_000_000, 38))
1467 .unwrap_err();
1468
1469 assert_eq!(err, LeapExtendError::NotStrictlyAscending);
1470 }
1471
1472 #[test]
1473 fn test_runtime_try_extend_non_unit_increment_error() {
1474 let mut rt = RuntimeLeapSeconds::from_builtin();
1475 // Skips to 39 instead of 38.
1476 let err = rt
1477 .try_extend(LeapEntry::new(9_999_999_999_000_000_000, 39))
1478 .unwrap_err();
1479
1480 assert_eq!(err, LeapExtendError::NonUnitIncrement);
1481 }
1482
1483 #[test]
1484 fn test_runtime_from_slice_too_large_returns_buffer_full() {
1485 let big: std::vec::Vec<LeapEntry> = (0..=RUNTIME_CAPACITY)
1486 .map(|i| {
1487 let i32_i = i32::try_from(i).expect("i fits in i32");
1488 LeapEntry::new(i as u64 * 1_000_000_000, 19 + i32_i)
1489 })
1490 .collect();
1491 let err = RuntimeLeapSeconds::from_slice(&big).unwrap_err();
1492
1493 assert_eq!(err, LeapExtendError::BufferFull);
1494 }
1495
1496 #[test]
1497 fn test_runtime_provider_matches_static_at_all_thresholds() {
1498 let rt = RuntimeLeapSeconds::from_builtin();
1499 let ls = LeapSeconds::builtin();
1500
1501 let test_nanos: &[u64] = &[
1502 0,
1503 46_828_820_000_000_000,
1504 599_184_032_000_000_000,
1505 1_167_264_037_000_000_000,
1506 u64::MAX,
1507 ];
1508
1509 for &nanos in test_nanos {
1510 let tai = Time::<Tai>::from_nanos(nanos);
1511 assert_eq!(
1512 rt.tai_minus_utc_at(tai),
1513 ls.tai_minus_utc_at(tai),
1514 "mismatch at tai_nanos={nanos}",
1515 );
1516 }
1517 }
1518
1519 #[test]
1520 fn test_runtime_empty_last_update_is_none() {
1521 assert!(RuntimeLeapSeconds::new().last_update().is_none());
1522 }
1523
1524 #[test]
1525 fn test_runtime_single_entry_last_update_is_none() {
1526 let mut rt = RuntimeLeapSeconds::new();
1527 rt.try_extend(LeapEntry::new(0, 19)).unwrap();
1528 assert!(rt.last_update().is_none());
1529 }
1530
1531 #[test]
1532 fn test_gps_utc_gps_roundtrip_with_runtime_table() {
1533 let rt = RuntimeLeapSeconds::from_builtin();
1534 let gps = Time::<Gps>::from_week_tow(
1535 2086,
1536 DurationParts {
1537 seconds: 0,
1538 nanos: 0,
1539 },
1540 )
1541 .unwrap();
1542 let utc = gps_to_utc(gps, &rt).unwrap();
1543 let back = utc_to_gps(utc, &rt).unwrap();
1544
1545 assert_eq!(gps, back);
1546 }
1547
1548 #[test]
1549 fn test_gps_utc_roundtrip_extended_table() {
1550 let mut rt = RuntimeLeapSeconds::from_builtin();
1551 rt.try_extend(LeapEntry::new(9_999_999_999_000_000_000, 38))
1552 .unwrap();
1553
1554 let gps = Time::<Gps>::from_week_tow(
1555 2086,
1556 DurationParts {
1557 seconds: 0,
1558 nanos: 0,
1559 },
1560 )
1561 .unwrap();
1562 let utc = gps_to_utc(gps, &rt).unwrap();
1563 let back = utc_to_gps(utc, &rt).unwrap();
1564
1565 assert_eq!(gps, back);
1566 }
1567
1568 #[test]
1569 fn test_gps_epoch_utc_is_correct() {
1570 let ls = LeapSeconds::builtin();
1571 let utc = gps_to_utc(Time::<Gps>::EPOCH, &ls).unwrap();
1572
1573 assert_eq!(utc.as_nanos(), 252_892_800_000_000_000);
1574 }
1575
1576 #[test]
1577 fn test_custom_provider_roundtrip() {
1578 struct Always37;
1579 impl LeapSecondsProvider for Always37 {
1580 fn tai_minus_utc_at(
1581 &self,
1582 _: Time<Tai>,
1583 ) -> i32 {
1584 37
1585 }
1586 }
1587
1588 let gps = Time::<Gps>::from_seconds(1_000_000_000);
1589 let utc = gps_to_utc(gps, &Always37).unwrap();
1590 let back = utc_to_gps(utc, &Always37).unwrap();
1591
1592 assert_eq!(gps, back);
1593 }
1594}