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