Skip to main content

tempoch_core/format/
gnss_week.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (C) 2026 Vallés Puig, Ramon
3
4//! GNSS week-number and seconds-of-week formatting for `Time<S>` on the
5//! supported continuous GNSS scales (`GPST`, `GST`, `BDT`, `QZSST`).
6//!
7//! Each constellation has its own epoch:
8//!
9//! | System | Scale  | Epoch (UTC)            | Week-number rollover |
10//! |--------|--------|------------------------|----------------------|
11//! | GPS    | `GPST` | 1980-01-06T00:00:00Z   | 1024 weeks           |
12//! | Galileo| `GST`  | 1999-08-22T00:00:00Z   | 4096 weeks           |
13//! | BeiDou | `BDT`  | 2006-01-01T00:00:00Z   | 8192 weeks           |
14//! | QZSS   | `QZSST`| Same as GPS            | 1024 weeks (legacy)  |
15//!
16//! Each epoch above is given in *system time* (continuous, leap-second free),
17//! aligned with TAI minus the scale's fixed nominal offset. The conversions
18//! below operate in continuous system time only; the values do not represent
19//! UTC labels.
20//!
21//! ## Precision
22//!
23//! `from_gnss_week` constructs the result by starting at the constellation's
24//! epoch (stored as a split-f64 `Time<S>`) and calling `add_exact`, which
25//! adds the integer whole-second and nanosecond components separately. This
26//! avoids collapsing the full duration into a single `f64` before adding,
27//! and produces results accurate to within the split-f64 storage precision
28//! (typically < 1 μs for instants within a few hundred years of J2000).
29//!
30//! `to_gnss_week` extracts the integer-second and fractional-second components
31//! from the split-f64 pair and performs all week/seconds decomposition in
32//! integer arithmetic. Nanosecond fields are preserved as accurately as the
33//! split-f64 storage allows; for instants near 2024 the storage precision is
34//! approximately ±100 ns, so `subsecond_nanos` may differ from the
35//! constructed value by at most that amount.
36//!
37//! See:
38//! * IS-GPS-200 §20.3.3.3.1.1 (GPS week)
39//! * Galileo OS-SIS-ICD §5.1.2 (GST)
40//! * BeiDou ICD-OS §3.4 (BDT)
41//! * IS-QZSS-PNT (QZSS week, GPS-compatible)
42
43use crate::foundation::error::ConversionError;
44use crate::model::scale::{CoordinateScale, BDT, GPST, GST, QZSST};
45use crate::model::time::Time;
46#[cfg(not(feature = "std"))]
47use crate::qtty::Real;
48
49const SECONDS_PER_WEEK: crate::qtty::i128::Second = crate::qtty::i128::Second::new(7 * 86_400);
50
51/// Decomposed GNSS week-number form.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct GnssWeek {
54    /// Full week number since the constellation's defined epoch (no rollover).
55    pub week: crate::qtty::u32::Week,
56    /// Seconds since the start of `week` in `[0, 604800)`.
57    pub seconds_of_week: crate::qtty::u32::Second,
58    /// Subsecond nanoseconds remainder in `[0, 1_000_000_000)`.
59    pub subsecond_nanos: crate::qtty::u32::Nanosecond,
60}
61
62impl GnssWeek {
63    /// Construct, validating ranges.
64    pub fn new(
65        week: crate::qtty::u32::Week,
66        seconds_of_week: crate::qtty::u32::Second,
67        subsecond_nanos: crate::qtty::u32::Nanosecond,
68    ) -> Result<Self, ConversionError> {
69        if seconds_of_week.value() as i128 >= SECONDS_PER_WEEK.value()
70            || subsecond_nanos.value() >= 1_000_000_000
71        {
72            return Err(ConversionError::OutOfRange);
73        }
74        Ok(Self {
75            week,
76            seconds_of_week,
77            subsecond_nanos,
78        })
79    }
80
81    /// Return the subsecond nanoseconds remainder as a typed unsigned integer quantity.
82    ///
83    /// The returned value is always in `[0, 1_000_000_000)` nanoseconds.
84    pub fn subsecond_nanoseconds_u(&self) -> crate::qtty::u32::Nanosecond {
85        self.subsecond_nanos
86    }
87
88    /// Return the seconds since the start of the week as a typed unsigned integer quantity.
89    ///
90    /// The returned value is always in `[0, 604_800)` seconds.
91    pub fn seconds_of_week_u(&self) -> crate::qtty::u32::Second {
92        self.seconds_of_week
93    }
94
95    /// Construct from a typed unsigned nanosecond quantity.
96    ///
97    /// Rejects values ≥ 1 × 10⁹ ns.
98    pub fn new_with_nanoseconds_u(
99        week: crate::qtty::u32::Week,
100        seconds_of_week: crate::qtty::u32::Second,
101        subsecond: crate::qtty::u32::Nanosecond,
102    ) -> Result<Self, ConversionError> {
103        Self::new(week, seconds_of_week, subsecond)
104    }
105
106    /// Convert back to a total ExactDuration since the scale's epoch.
107    pub fn to_duration_since_epoch(&self) -> crate::ExactDuration {
108        let week_count = self.week.value() as i128;
109        let sow = self.seconds_of_week.value() as i128;
110        let seconds = week_count * SECONDS_PER_WEEK.value() + sow;
111        let nanos = seconds * 1_000_000_000 + self.subsecond_nanos.value() as i128;
112        crate::ExactDuration::from_nanos(nanos)
113    }
114}
115
116/// Sealed trait providing the J2000-second offset of each GNSS scale's epoch.
117///
118/// Implemented for `GPST`, `GST`, `BDT`, `QZSST` only.
119pub trait GnssWeekScale: CoordinateScale {
120    /// Nominal start-of-week-zero in *system time* J2000 seconds (computed
121    /// from the constellation's epoch expressed as TAI minus the fixed
122    /// system-time offset).
123    fn epoch_j2000_seconds() -> f64;
124
125    /// Maximum representable week number before rollover, for documentation
126    /// and validation purposes (the conversion itself uses full weeks).
127    fn rollover_period_weeks() -> u32;
128}
129
130// Empirically anchored constants: each value is the J2000-coordinate-seconds
131// of the constellation's defined week-0/second-0 epoch, where week 0 starts
132// at the listed UTC instant converted to the GNSS scale's continuous
133// coordinate axis. These are *definitions* tied to the system's published
134// week-numbering scheme, not derived from a calendar formula.
135//
136// To regenerate: convert the published epoch from UTC into the target GNSS
137// scale via `Time::<S>::from(parse_rfc3339_utc(epoch)).to_j2000s()` and read
138// the total J2000 seconds.
139const GPST_EPOCH_J2000_SECONDS: f64 = -630_763_200.0;
140const GST_EPOCH_J2000_SECONDS: f64 = -11_447_987.0;
141const BDT_EPOCH_J2000_SECONDS: f64 = 189_345_600.0;
142const QZSST_EPOCH_J2000_SECONDS: f64 = GPST_EPOCH_J2000_SECONDS;
143
144impl GnssWeekScale for GPST {
145    fn epoch_j2000_seconds() -> f64 {
146        GPST_EPOCH_J2000_SECONDS
147    }
148    fn rollover_period_weeks() -> u32 {
149        1024
150    }
151}
152impl GnssWeekScale for GST {
153    fn epoch_j2000_seconds() -> f64 {
154        GST_EPOCH_J2000_SECONDS
155    }
156    fn rollover_period_weeks() -> u32 {
157        4096
158    }
159}
160impl GnssWeekScale for BDT {
161    fn epoch_j2000_seconds() -> f64 {
162        BDT_EPOCH_J2000_SECONDS
163    }
164    fn rollover_period_weeks() -> u32 {
165        8192
166    }
167}
168impl GnssWeekScale for QZSST {
169    fn epoch_j2000_seconds() -> f64 {
170        QZSST_EPOCH_J2000_SECONDS
171    }
172    fn rollover_period_weeks() -> u32 {
173        1024
174    }
175}
176
177impl<S: GnssWeekScale> Time<S> {
178    /// Decompose this GNSS-scale instant into `(week, seconds_of_week,
179    /// subsecond_nanos)` since the constellation's defined epoch.
180    ///
181    /// The week number is *full* (no rollover applied); callers wanting the
182    /// modular broadcast value should compute
183    /// `week % S::rollover_period_weeks()`.
184    ///
185    /// The whole-second decomposition uses integer arithmetic on the split-f64
186    /// storage pair. The `subsecond_nanos` field is computed from the
187    /// fractional remainder; see the module doc for precision limits.
188    pub fn to_gnss_week(&self) -> Result<GnssWeek, ConversionError> {
189        let (hi, lo) = self.to_j2000s().raw_seconds_pair();
190        let hi_val = hi.value();
191        let lo_val = lo.value();
192
193        // Round hi to the nearest integer second so the residual stays small.
194        let hi_int = hi_val.round();
195        // sub_sec is the fractional-second part: the error of rounding hi, plus lo.
196        let sub_sec = (hi_val - hi_int) + lo_val;
197
198        // All epoch constants are exact integers expressible in f64 and i128.
199        let epoch_i128 = S::epoch_j2000_seconds() as i128;
200        // hi_int is within J2000-seconds range; cast via i64 then i128 is safe.
201        let hi_i128 = hi_int as i64 as i128;
202        let mut secs_since_epoch = hi_i128 - epoch_i128;
203
204        // Convert sub-second residual to nanoseconds, handling carry.
205        let raw_nanos = (sub_sec * 1.0e9).round() as i64;
206        let sub_nanos = if raw_nanos < 0 {
207            secs_since_epoch -= 1;
208            (raw_nanos + 1_000_000_000) as u32
209        } else if raw_nanos >= 1_000_000_000 {
210            secs_since_epoch += 1;
211            (raw_nanos - 1_000_000_000) as u32
212        } else {
213            raw_nanos as u32
214        };
215
216        if secs_since_epoch < 0 {
217            return Err(ConversionError::OutOfRange);
218        }
219
220        let total_secs = secs_since_epoch as u64;
221        let week_u64 = total_secs / SECONDS_PER_WEEK.value() as u64;
222        if week_u64 > u32::MAX as u64 {
223            return Err(ConversionError::OutOfRange);
224        }
225        let week = week_u64 as u32;
226        let seconds_of_week = (total_secs % SECONDS_PER_WEEK.value() as u64) as u32;
227
228        Ok(GnssWeek {
229            week: crate::qtty::u32::Week::new(week),
230            seconds_of_week: crate::qtty::u32::Second::new(seconds_of_week),
231            subsecond_nanos: crate::qtty::u32::Nanosecond::new(sub_nanos),
232        })
233    }
234
235    /// Build a GNSS-scale instant from `(week, seconds_of_week,
236    /// subsecond_nanos)` since the constellation's defined epoch.
237    ///
238    /// Uses `add_exact` to add the integer whole-second and nanosecond
239    /// components to the epoch separately, preserving sub-millisecond
240    /// precision within the split-f64 storage limits.
241    pub fn from_gnss_week(gw: GnssWeek) -> Result<Self, ConversionError> {
242        let epoch =
243            Time::<S>::from_raw_j2000_seconds(crate::qtty::Second::new(S::epoch_j2000_seconds()))?;
244        Ok(epoch.add_exact(gw.to_duration_since_epoch()))
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::format::iso::parse_rfc3339_utc;
252
253    #[test]
254    fn gps_epoch_is_week_zero_second_zero() {
255        let utc = parse_rfc3339_utc("1980-01-06T00:00:00Z").unwrap();
256        let gpst: Time<GPST> = utc.to::<GPST>();
257        let gw = gpst.to_gnss_week().unwrap();
258        assert_eq!(gw.week.value(), 0, "expected week 0, got {gw:?}");
259        assert_eq!(gw.seconds_of_week.value(), 0, "expected sow=0, got {gw:?}");
260        assert_eq!(gw.subsecond_nanos.value(), 0, "expected ns=0, got {gw:?}");
261    }
262
263    #[test]
264    fn galileo_epoch_is_week_zero_second_zero() {
265        let utc = parse_rfc3339_utc("1999-08-22T00:00:00Z").unwrap();
266        let gst: Time<GST> = utc.to::<GST>();
267        let gw = gst.to_gnss_week().unwrap();
268        assert_eq!(gw.week.value(), 0, "expected GST week 0, got {gw:?}");
269        assert_eq!(gw.seconds_of_week.value(), 0, "expected sow=0, got {gw:?}");
270        assert_eq!(gw.subsecond_nanos.value(), 0, "expected ns=0, got {gw:?}");
271    }
272
273    #[test]
274    fn beidou_epoch_is_week_zero_second_zero() {
275        let utc = parse_rfc3339_utc("2006-01-01T00:00:00Z").unwrap();
276        let bdt: Time<BDT> = utc.to::<BDT>();
277        let gw = bdt.to_gnss_week().unwrap();
278        assert_eq!(gw.week.value(), 0, "expected BDT week 0, got {gw:?}");
279        assert_eq!(gw.seconds_of_week.value(), 0, "expected sow=0, got {gw:?}");
280        assert_eq!(gw.subsecond_nanos.value(), 0, "expected ns=0, got {gw:?}");
281    }
282
283    #[test]
284    fn qzsst_aligned_with_gpst() {
285        let utc = parse_rfc3339_utc("1980-01-06T00:00:00Z").unwrap();
286        let q: Time<QZSST> = utc.to::<QZSST>();
287        let gp: Time<GPST> = utc.to::<GPST>();
288        let qw = q.to_gnss_week().unwrap();
289        let gw = gp.to_gnss_week().unwrap();
290        assert_eq!(qw.week, gw.week);
291        assert_eq!(qw.seconds_of_week, gw.seconds_of_week);
292        assert_eq!(qw.subsecond_nanos, gw.subsecond_nanos);
293    }
294
295    /// Round-trip test at GPS week 2200, sow 345600, subsecond 123_456_789 ns.
296    /// The integer-arithmetic path must preserve all three fields exactly
297    /// within the split-f64 storage tolerance.
298    #[test]
299    fn gps_week_round_trip_nanosecond_accurate() {
300        let gw = GnssWeek::new(
301            crate::qtty::u32::Week::new(2200),
302            crate::qtty::u32::Second::new(345_600),
303            crate::qtty::u32::Nanosecond::new(123_456_789),
304        )
305        .unwrap();
306        let t = Time::<GPST>::from_gnss_week(gw).unwrap();
307        let back = t.to_gnss_week().unwrap();
308        assert_eq!(back.week, gw.week, "week mismatch: {back:?} vs {gw:?}");
309        assert_eq!(
310            back.seconds_of_week, gw.seconds_of_week,
311            "sow mismatch: {back:?} vs {gw:?}"
312        );
313        // subsecond_nanos must be within ±200 ns of the original (split-f64
314        // storage precision near ~700 M seconds from J2000 is ~120 ns ULP).
315        let ns_delta =
316            (back.subsecond_nanos.value() as i64 - gw.subsecond_nanos.value() as i64).abs();
317        assert!(
318            ns_delta <= 200,
319            "subsecond_nanos drift {ns_delta} ns: {back:?} vs {gw:?}"
320        );
321    }
322
323    /// Week boundary: sow = 604_799, subsecond = 999_999_999 ns.
324    #[test]
325    fn gps_week_boundary() {
326        let gw = GnssWeek::new(
327            crate::qtty::u32::Week::new(2200),
328            crate::qtty::u32::Second::new(604_799),
329            crate::qtty::u32::Nanosecond::new(999_999_999),
330        )
331        .unwrap();
332        let t = Time::<GPST>::from_gnss_week(gw).unwrap();
333        let back = t.to_gnss_week().unwrap();
334        assert_eq!(back.week, gw.week, "week mismatch at boundary: {back:?}");
335        assert_eq!(
336            back.seconds_of_week, gw.seconds_of_week,
337            "sow mismatch at boundary: {back:?}"
338        );
339        let ns_delta =
340            (back.subsecond_nanos.value() as i64 - gw.subsecond_nanos.value() as i64).abs();
341        assert!(
342            ns_delta <= 200,
343            "subsecond_nanos drift {ns_delta} ns at boundary: {back:?}"
344        );
345    }
346
347    /// GPS week 1024 rollover: the full week number must not wrap.
348    #[test]
349    fn gps_week_1024_no_rollover() {
350        let gw = GnssWeek::new(
351            crate::qtty::u32::Week::new(1024),
352            crate::qtty::u32::Second::new(0),
353            crate::qtty::u32::Nanosecond::new(0),
354        )
355        .unwrap();
356        let t = Time::<GPST>::from_gnss_week(gw).unwrap();
357        let back = t.to_gnss_week().unwrap();
358        assert_eq!(back.week.value(), 1024);
359        assert_eq!(back.seconds_of_week.value(), 0);
360        assert_eq!(back.subsecond_nanos.value(), 0);
361    }
362
363    /// GPS week 2048 (second rollover boundary).
364    #[test]
365    fn gps_week_2048_no_rollover() {
366        let gw = GnssWeek::new(
367            crate::qtty::u32::Week::new(2048),
368            crate::qtty::u32::Second::new(0),
369            crate::qtty::u32::Nanosecond::new(0),
370        )
371        .unwrap();
372        let t = Time::<GPST>::from_gnss_week(gw).unwrap();
373        let back = t.to_gnss_week().unwrap();
374        assert_eq!(back.week.value(), 2048);
375        assert_eq!(back.seconds_of_week.value(), 0);
376        assert_eq!(back.subsecond_nanos.value(), 0);
377    }
378
379    #[test]
380    fn rollover_periods_are_documented() {
381        assert_eq!(<GPST as GnssWeekScale>::rollover_period_weeks(), 1024);
382        assert_eq!(<GST as GnssWeekScale>::rollover_period_weeks(), 4096);
383        assert_eq!(<BDT as GnssWeekScale>::rollover_period_weeks(), 8192);
384        assert_eq!(<QZSST as GnssWeekScale>::rollover_period_weeks(), 1024);
385    }
386
387    #[test]
388    fn out_of_range_inputs_rejected() {
389        assert!(GnssWeek::new(
390            crate::qtty::u32::Week::new(0),
391            crate::qtty::u32::Second::new(604_800),
392            crate::qtty::u32::Nanosecond::new(0),
393        )
394        .is_err());
395        assert!(GnssWeek::new(
396            crate::qtty::u32::Week::new(0),
397            crate::qtty::u32::Second::new(0),
398            crate::qtty::u32::Nanosecond::new(1_000_000_000),
399        )
400        .is_err());
401    }
402
403    #[test]
404    fn subsecond_nanoseconds_u_matches_field() {
405        let gw = GnssWeek::new(
406            crate::qtty::u32::Week::new(100),
407            crate::qtty::u32::Second::new(12_345),
408            crate::qtty::u32::Nanosecond::new(987_654_321),
409        )
410        .unwrap();
411        assert_eq!(gw.subsecond_nanoseconds_u().value(), 987_654_321_u32);
412    }
413
414    #[test]
415    fn new_with_nanoseconds_u_accepts_valid() {
416        let ns = crate::qtty::u32::Nanosecond::new(123_456_789);
417        let gw = GnssWeek::new_with_nanoseconds_u(
418            crate::qtty::u32::Week::new(500),
419            crate::qtty::u32::Second::new(100_000),
420            ns,
421        )
422        .unwrap();
423        assert_eq!(gw.subsecond_nanos.value(), 123_456_789);
424    }
425
426    #[test]
427    fn new_with_nanoseconds_u_rejects_invalid() {
428        // out of range
429        let big = crate::qtty::u32::Nanosecond::new(1_000_000_000);
430        assert!(GnssWeek::new_with_nanoseconds_u(
431            crate::qtty::u32::Week::new(0),
432            crate::qtty::u32::Second::new(0),
433            big,
434        )
435        .is_err());
436    }
437
438    #[test]
439    fn to_gnss_week_overflow_returns_out_of_range() {
440        // Build a huge positive ExactDuration that maps to more than u32::MAX weeks,
441        // then build a Time<GPST> that far in the future. Easiest way: construct a
442        // Time via from_raw_j2000_seconds with a very large positive offset
443        // corresponding to > u32::MAX * 604800 seconds past the GPST epoch.
444        // u32::MAX * 604800 = 2_600_468_889_600 seconds ≈ 2.6e12 s
445        // GPST epoch J2000 = -630_763_200 s
446        // So target J2000 seconds = -630_763_200 + 2_600_468_889_600 + 1 = ~2_599_838_126_401 s
447        // That's beyond the f64 exact-integer range so use a moderate approach:
448        // Create a GnssWeek with week u32::MAX; to_duration_since_epoch() returns
449        // a huge ExactDuration. Then from_gnss_week should succeed (it just adds),
450        // and to_gnss_week on the result should return the correct week (u32::MAX).
451        // Actually: let's verify that from_gnss_week does not silently wrap week.
452        let gw_max = GnssWeek {
453            week: crate::qtty::u32::Week::new(u32::MAX),
454            seconds_of_week: crate::qtty::u32::Second::new(0),
455            subsecond_nanos: crate::qtty::u32::Nanosecond::new(0),
456        };
457        // The duration is u32::MAX * 604800 * 1e9 ns ≈ 2.6e21 ns which fits in i128.
458        let dur = gw_max.to_duration_since_epoch();
459        let (_s, _n) = dur
460            .as_seconds_i64_nanos_checked()
461            .expect("should fit in i64");
462        // s ≈ 2.6e12 which is < i64::MAX, so add_exact should succeed.
463        let epoch = Time::<GPST>::from_raw_j2000_seconds(crate::qtty::Second::new(
464            GPST_EPOCH_J2000_SECONDS,
465        ))
466        .unwrap();
467        let t = epoch.add_exact(dur);
468        // Convert back — week_u64 = u32::MAX, which is exactly u32::MAX, should succeed.
469        let back = t.to_gnss_week().unwrap();
470        assert_eq!(back.week.value(), u32::MAX);
471
472        // Now test actual overflow: construct a raw j2000 instant such that
473        // secs_since_epoch / 604800 > u32::MAX.
474        // (u32::MAX + 1) * 604800 seconds past epoch:
475        let overflow_secs = (u32::MAX as i128 + 1) * SECONDS_PER_WEEK.value();
476        let epoch_j2000 = GPST_EPOCH_J2000_SECONDS as i128;
477        let j2000_secs = epoch_j2000 + overflow_secs;
478        // This is ~2.6e12 s past J2000, well within f64 precision for large integers.
479        let t2 = Time::<GPST>::from_raw_j2000_seconds(crate::qtty::Second::new(j2000_secs as f64))
480            .unwrap();
481        let result = t2.to_gnss_week();
482        assert!(
483            result.is_err(),
484            "expected OutOfRange for week > u32::MAX, got {result:?}"
485        );
486    }
487}