Skip to main content

sidereon_core/astro/time/
scales.rs

1//! Precise time scale conversions: UTC -> TAI -> TT -> TDB -> UT1.
2//!
3//! Mirrors Skyfield's `_utc()` path for bit-exact parity. The delta-T numerics,
4//! summation order, and transcendental sequence are preserved EXACTLY: this
5//! module is parity-critical and must not be refactored in any way that
6//! perturbs a single last bit.
7//!
8//! The only change from the `orbis_nif` original is visibility: the formerly
9//! `pub(crate)` `TimeScales` internals are promoted to a clean public API so a
10//! Rust-only consumer of `sidereon-core` can reach the precise time scales
11//! without pulling in Rustler or the BEAM.
12
13use crate::astro::constants::time::{BDT_MINUS_TAI_S, GPST_MINUS_TAI_S};
14use crate::astro::constants::time::{
15    DAYS_PER_JULIAN_CENTURY, J2000_JD, SECONDS_PER_DAY, TT_MINUS_TAI_S,
16};
17use crate::astro::data::iers::UT1_DATA;
18use crate::astro::time::civil;
19use crate::astro::time::eop::{
20    check_ut1_coverage, CoverageError, LeapSecondTable, TimeScaleInputErrorKind, Ut1Provenance,
21    Validated, ValidityMode,
22};
23use crate::astro::time::model::TimeScale;
24use crate::validate::{self, FieldError};
25
26const ROUND_1E7: f64 = 10_000_000.0;
27
28/// GLONASS system time minus UTC(SU), seconds. GLONASST = UTC(SU) + 3 h, a fixed
29/// three-hour advance with no leap-second term of its own (ICD GLONASS Edition
30/// 5.1, 2008, sec. 3.3.3). GLONASST still tracks UTC's leap seconds because UTC
31/// does, so any GLONASST<->atomic-scale offset is epoch-dependent.
32pub const GLONASST_MINUS_UTC_S: f64 = 3.0 * 3600.0;
33
34/// Resolved set of Julian-date split time scales for one UTC instant.
35///
36/// All fields use the Skyfield split convention: `jd_whole` carries the integer
37/// (and TAI-aligned) part of the day, and the per-scale `*_fraction` fields carry
38/// the residual so that `jd_<scale> == jd_whole + <scale>_fraction` reproduces
39/// the full Julian date without catastrophic cancellation.
40#[derive(Debug, Clone, Copy, PartialEq)]
41pub struct TimeScales {
42    /// Integer Julian day boundary (TAI-aligned), shared by all scales.
43    pub jd_whole: f64,
44    /// UT1 day fraction relative to `jd_whole`.
45    pub ut1_fraction: f64,
46    /// TT day fraction relative to `jd_whole`.
47    pub tt_fraction: f64,
48    /// TDB day fraction relative to `jd_whole`.
49    pub tdb_fraction: f64,
50    /// Full UT1 Julian date.
51    pub jd_ut1: f64,
52    /// Full TT Julian date.
53    pub jd_tt: f64,
54    /// Full TDB Julian date.
55    pub jd_tdb: f64,
56}
57
58struct LeapSecondEntry {
59    mjd: i32,
60    tai_utc: f64,
61}
62
63static LEAP_SECONDS: &[LeapSecondEntry] = &[
64    LeapSecondEntry {
65        mjd: 41317,
66        tai_utc: 10.0,
67    },
68    LeapSecondEntry {
69        mjd: 41499,
70        tai_utc: 11.0,
71    },
72    LeapSecondEntry {
73        mjd: 41683,
74        tai_utc: 12.0,
75    },
76    LeapSecondEntry {
77        mjd: 42048,
78        tai_utc: 13.0,
79    },
80    LeapSecondEntry {
81        mjd: 42413,
82        tai_utc: 14.0,
83    },
84    LeapSecondEntry {
85        mjd: 42778,
86        tai_utc: 15.0,
87    },
88    LeapSecondEntry {
89        mjd: 43144,
90        tai_utc: 16.0,
91    },
92    LeapSecondEntry {
93        mjd: 43509,
94        tai_utc: 17.0,
95    },
96    LeapSecondEntry {
97        mjd: 43874,
98        tai_utc: 18.0,
99    },
100    LeapSecondEntry {
101        mjd: 44239,
102        tai_utc: 19.0,
103    },
104    LeapSecondEntry {
105        mjd: 44786,
106        tai_utc: 20.0,
107    },
108    LeapSecondEntry {
109        mjd: 45151,
110        tai_utc: 21.0,
111    },
112    LeapSecondEntry {
113        mjd: 45516,
114        tai_utc: 22.0,
115    },
116    LeapSecondEntry {
117        mjd: 46247,
118        tai_utc: 23.0,
119    },
120    LeapSecondEntry {
121        mjd: 47161,
122        tai_utc: 24.0,
123    },
124    LeapSecondEntry {
125        mjd: 47892,
126        tai_utc: 25.0,
127    },
128    LeapSecondEntry {
129        mjd: 48257,
130        tai_utc: 26.0,
131    },
132    LeapSecondEntry {
133        mjd: 48804,
134        tai_utc: 27.0,
135    },
136    LeapSecondEntry {
137        mjd: 49169,
138        tai_utc: 28.0,
139    },
140    LeapSecondEntry {
141        mjd: 49534,
142        tai_utc: 29.0,
143    },
144    LeapSecondEntry {
145        mjd: 50083,
146        tai_utc: 30.0,
147    },
148    LeapSecondEntry {
149        mjd: 50448,
150        tai_utc: 31.0,
151    },
152    LeapSecondEntry {
153        mjd: 50813,
154        tai_utc: 32.0,
155    },
156    LeapSecondEntry {
157        mjd: 53736,
158        tai_utc: 33.0,
159    },
160    LeapSecondEntry {
161        mjd: 54832,
162        tai_utc: 34.0,
163    },
164    LeapSecondEntry {
165        mjd: 56109,
166        tai_utc: 35.0,
167    },
168    LeapSecondEntry {
169        mjd: 57204,
170        tai_utc: 36.0,
171    },
172    LeapSecondEntry {
173        mjd: 57754,
174        tai_utc: 37.0,
175    },
176];
177
178/// One segment of the pre-1972 "rubber second" UTC, where TAI-UTC varied as a
179/// piecewise-linear function of the UTC Modified Julian Date rather than by
180/// integer leap-second steps.
181struct RubberSecondEntry {
182    /// Integer UTC MJD at which this segment takes effect.
183    start_mjd: i32,
184    /// Constant term of `TAI-UTC = base + (MJD - ref_mjd) * rate` (seconds).
185    base: f64,
186    /// Reference MJD the linear drift is measured from.
187    ref_mjd: f64,
188    /// Drift rate of TAI-UTC, seconds per day of MJD.
189    rate: f64,
190}
191
192/// The published IERS/USNO TAI-UTC table for the 1961-01-01 .. 1972-01-01
193/// rubber-second era (USNO `tai-utc.dat`). Each segment gives
194/// `TAI-UTC = base + (MJD - ref_mjd) * rate`, with MJD the UTC Modified Julian
195/// Date. The table ends where the integer leap-second table (`LEAP_SECONDS`)
196/// begins at MJD 41317 (1972-01-01, TAI-UTC = 10 s exactly).
197static RUBBER_SECONDS: &[RubberSecondEntry] = &[
198    RubberSecondEntry {
199        start_mjd: 37300,
200        base: 1.4228180,
201        ref_mjd: 37300.0,
202        rate: 0.001296,
203    },
204    RubberSecondEntry {
205        start_mjd: 37512,
206        base: 1.3728180,
207        ref_mjd: 37300.0,
208        rate: 0.001296,
209    },
210    RubberSecondEntry {
211        start_mjd: 37665,
212        base: 1.8458580,
213        ref_mjd: 37665.0,
214        rate: 0.0011232,
215    },
216    RubberSecondEntry {
217        start_mjd: 38334,
218        base: 1.9458580,
219        ref_mjd: 37665.0,
220        rate: 0.0011232,
221    },
222    RubberSecondEntry {
223        start_mjd: 38395,
224        base: 3.2401300,
225        ref_mjd: 38761.0,
226        rate: 0.001296,
227    },
228    RubberSecondEntry {
229        start_mjd: 38486,
230        base: 3.3401300,
231        ref_mjd: 38761.0,
232        rate: 0.001296,
233    },
234    RubberSecondEntry {
235        start_mjd: 38639,
236        base: 3.4401300,
237        ref_mjd: 38761.0,
238        rate: 0.001296,
239    },
240    RubberSecondEntry {
241        start_mjd: 38761,
242        base: 3.5401300,
243        ref_mjd: 38761.0,
244        rate: 0.001296,
245    },
246    RubberSecondEntry {
247        start_mjd: 38820,
248        base: 3.6401300,
249        ref_mjd: 38761.0,
250        rate: 0.001296,
251    },
252    RubberSecondEntry {
253        start_mjd: 38942,
254        base: 3.7401300,
255        ref_mjd: 38761.0,
256        rate: 0.001296,
257    },
258    RubberSecondEntry {
259        start_mjd: 39004,
260        base: 3.8401300,
261        ref_mjd: 38761.0,
262        rate: 0.001296,
263    },
264    RubberSecondEntry {
265        start_mjd: 39126,
266        base: 4.3131700,
267        ref_mjd: 39126.0,
268        rate: 0.002592,
269    },
270    RubberSecondEntry {
271        start_mjd: 39887,
272        base: 4.2131700,
273        ref_mjd: 39126.0,
274        rate: 0.002592,
275    },
276];
277
278impl TimeScales {
279    /// Resolve the split-Julian-date time scales for a UTC calendar instant.
280    ///
281    /// Validates the public boundary, then runs the exact Skyfield `_utc()` path.
282    pub fn from_utc(
283        year: i32,
284        month: i32,
285        day: i32,
286        hour: i32,
287        minute: i32,
288        second: f64,
289    ) -> Result<Self, CoverageError> {
290        validate::finite(second, "second").map_err(map_time_scale_field_error)?;
291        validate::civil_datetime_with_second_policy(
292            i64::from(year),
293            i64::from(month),
294            i64::from(day),
295            i64::from(hour),
296            i64::from(minute),
297            second,
298            validate::CivilSecondPolicy::UtcLike,
299        )
300        .map_err(map_time_scale_field_error)?;
301        if second >= 60.0 && !is_positive_leap_second_label(year, month, day, hour, minute) {
302            return Err(CoverageError::InvalidInput {
303                field: "civil datetime",
304                kind: TimeScaleInputErrorKind::InvalidCivilTime,
305            });
306        }
307        Ok(Self::from_utc_unchecked(
308            year, month, day, hour, minute, second,
309        ))
310    }
311
312    /// Exact Skyfield `_utc()` path. The arithmetic order below is load-bearing
313    /// for 0-ULP parity and MUST NOT be reordered.
314    fn from_utc_unchecked(
315        year: i32,
316        month: i32,
317        day: i32,
318        hour: i32,
319        minute: i32,
320        second: f64,
321    ) -> Self {
322        let jd_day = julian_day_number(year, month, day);
323        let jd1 = jd_day as f64 - 0.5;
324        let utc_seconds_of_day = hour as f64 * 3600.0 + minute as f64 * 60.0 + second;
325        let leap_lookup_second = if second >= 60.0 { 59.0 } else { second };
326        let jd2 =
327            (leap_lookup_second + minute as f64 * 60.0 + hour as f64 * 3600.0) / SECONDS_PER_DAY;
328        let jd_utc_total = jd1 + jd2;
329
330        let leap_seconds = find_leap_seconds(jd_utc_total);
331        let utc_seconds_at_midnight = jd1 * SECONDS_PER_DAY;
332
333        let utc_whole_seconds = utc_seconds_of_day.trunc();
334        let utc_subsecond = utc_seconds_of_day.fract();
335
336        // Mirror Skyfield's _utc() path.
337        let tai_seconds = utc_seconds_at_midnight + leap_seconds + utc_whole_seconds;
338        let jd_whole = (tai_seconds / SECONDS_PER_DAY).floor();
339        let tai_fraction =
340            (tai_seconds - jd_whole * SECONDS_PER_DAY + utc_subsecond) / SECONDS_PER_DAY;
341        let tt_offset_days = TT_MINUS_TAI_S / SECONDS_PER_DAY;
342
343        let tt_fraction = tai_fraction + tt_offset_days;
344        let jd_tt = jd_whole + tt_fraction;
345
346        let delta_t = interpolate_delta_t(jd_tt);
347        let ut1_fraction = tt_fraction - delta_t / SECONDS_PER_DAY;
348        let jd_ut1 = jd_whole + ut1_fraction;
349
350        let t = (jd_whole - J2000_JD + tt_fraction) / DAYS_PER_JULIAN_CENTURY;
351        let tdb_minus_tt_seconds = 0.001657 * (628.3076 * t + 6.2401).sin()
352            + 0.000022 * (575.3385 * t + 4.2970).sin()
353            + 0.000014 * (1256.6152 * t + 6.1969).sin()
354            + 0.000005 * (606.9777 * t + 4.0212).sin()
355            + 0.000005 * (52.9691 * t + 0.4444).sin()
356            + 0.000002 * (21.3299 * t + 5.5431).sin()
357            + 0.000010 * t * (628.3076 * t + 4.2490).sin();
358
359        let tdb_fraction = tt_fraction + tdb_minus_tt_seconds / SECONDS_PER_DAY;
360        let jd_tdb = jd_whole + tdb_fraction;
361
362        Self {
363            jd_whole,
364            ut1_fraction,
365            tt_fraction,
366            tdb_fraction,
367            jd_ut1,
368            jd_tt,
369            jd_tdb,
370        }
371    }
372
373    /// Coverage-policy-enforced variant of [`TimeScales::from_utc`].
374    ///
375    /// The numerics are produced by [`TimeScales::from_utc`] **unchanged** (the
376    /// delta-T / UT1-UTC lookups still clamp at the embedded table edges exactly
377    /// as before, preserving Skyfield 0-ULP parity). The only addition is that
378    /// the resulting TT instant is classified against the embedded UT1/EOP
379    /// coverage interval under the requested [`ValidityMode`]:
380    ///
381    /// - [`ValidityMode::Strict`]: an instant outside `[first_jd_tt, last_jd_tt]`
382    ///   (where the delta-T table would have silently clamped/extrapolated)
383    ///   returns [`CoverageError::OutsideCoverage`]. Nothing degraded is ever
384    ///   returned.
385    /// - [`ValidityMode::Permissive`]: the clamped value is returned, paired with
386    ///   a [`crate::astro::time::eop::DegradeReason`] when the instant fell outside
387    ///   coverage. This is the historical (parity) behaviour, now made explicit.
388    ///
389    /// In-coverage results are bit-identical to [`TimeScales::from_utc`] and are
390    /// flagged not-degraded.
391    pub fn from_utc_validated(
392        year: i32,
393        month: i32,
394        day: i32,
395        hour: i32,
396        minute: i32,
397        second: f64,
398        mode: ValidityMode,
399    ) -> Result<Validated<Self>, CoverageError> {
400        // Numerics first, exactly as the parity path produces them.
401        let scales = Self::from_utc(year, month, day, hour, minute, second)?;
402        // Classify the (already-clamped) instant against UT1 coverage. We
403        // classify at jd_tt because the delta-T table axis is in TT (see
404        // `ut1_coverage`), and jd_tt is independent of the clamped delta-T.
405        let prov = ut1_coverage();
406        let degraded = check_ut1_coverage(&prov, scales.jd_tt, mode)?;
407        Ok(Validated {
408            value: scales,
409            degraded,
410        })
411    }
412
413    /// Build [`TimeScales`] for a calendar instant labelled in `scale`.
414    ///
415    /// Non-UTC scales (GPST/GST/BDT/QZSST/TAI/TT/TDB and GLONASST) are converted
416    /// to the UTC calendar label first via [`scale_calendar_to_utc`], so the
417    /// Earth-orientation inputs used downstream are correct rather than offset by
418    /// the scale's leap-second gap, then routed through [`Self::from_utc`]. This
419    /// is the single home for the system-time-to-UTC inverse that the
420    /// reduced-orbit bridge previously reimplemented.
421    pub fn from_scale(
422        scale: TimeScale,
423        year: i32,
424        month: i32,
425        day: i32,
426        hour: i32,
427        minute: i32,
428        second: f64,
429    ) -> Result<Self, CoverageError> {
430        let utc = scale_calendar_to_utc(
431            scale,
432            ScaleCal {
433                year,
434                month,
435                day,
436                hour,
437                minute,
438                second,
439            },
440        );
441        Self::from_utc(
442            utc.year, utc.month, utc.day, utc.hour, utc.minute, utc.second,
443        )
444    }
445}
446
447/// A mutable civil calendar instant used by the scale-to-UTC inverse.
448#[derive(Debug, Clone, Copy, PartialEq)]
449struct ScaleCal {
450    year: i32,
451    month: i32,
452    day: i32,
453    hour: i32,
454    minute: i32,
455    second: f64,
456}
457
458/// Convert a calendar instant labelled in `scale` to the UTC calendar instant
459/// [`TimeScales::from_utc`] consumes.
460///
461/// GLONASST = UTC(SU) + 3 h exactly (no leap term in the 3 h offset), so
462/// recovering UTC is a plain -3 h shift that preserves UTC's leap labels. The
463/// atomic-aligned scales are shifted to TAI and then resolved to UTC through the
464/// leap-second table.
465fn scale_calendar_to_utc(scale: TimeScale, cal: ScaleCal) -> ScaleCal {
466    match scale {
467        TimeScale::Utc => cal,
468        TimeScale::Glonasst => normalize_calendar_seconds(cal, cal.second - GLONASST_MINUS_UTC_S),
469        _ => {
470            let tai = normalize_calendar_seconds(cal, cal.second + tai_minus_scale_seconds(scale));
471            tai_calendar_to_utc(tai)
472        }
473    }
474}
475
476fn tai_minus_scale_seconds(scale: TimeScale) -> f64 {
477    match scale {
478        // Utc/Glonasst are handled before reaching here; 0.0 keeps the match
479        // total without affecting the atomic path.
480        TimeScale::Utc | TimeScale::Glonasst => 0.0,
481        TimeScale::Tai => 0.0,
482        TimeScale::Tt | TimeScale::Tdb => -TT_MINUS_TAI_S,
483        // QZSST is steered synchronous with GPST, so it shares GPST's TAI offset.
484        TimeScale::Gpst | TimeScale::Gst | TimeScale::Qzsst => GPST_MINUS_TAI_S,
485        TimeScale::Bdt => BDT_MINUS_TAI_S,
486    }
487}
488
489fn tai_calendar_to_utc(tai: ScaleCal) -> ScaleCal {
490    if let Some(utc) = positive_leap_second_utc_label(tai) {
491        return utc;
492    }
493
494    let mut leap = leap_seconds_at_utc_label(tai);
495    let mut utc = normalize_calendar_seconds(tai, tai.second - leap);
496    for _ in 0..3 {
497        let next_leap = leap_seconds_at_utc_label(utc);
498        if next_leap == leap {
499            return utc;
500        }
501        leap = next_leap;
502        utc = normalize_calendar_seconds(tai, tai.second - leap);
503    }
504    utc
505}
506
507fn positive_leap_second_utc_label(tai: ScaleCal) -> Option<ScaleCal> {
508    let tai_sod = seconds_of_day(tai);
509    let utc_midnight = ScaleCal {
510        year: tai.year,
511        month: tai.month,
512        day: tai.day,
513        hour: 0,
514        minute: 0,
515        second: 0.0,
516    };
517    let previous_second = normalize_calendar_seconds(utc_midnight, -1.0);
518    let old_leap = leap_seconds_at_utc_label(previous_second);
519    let new_leap = leap_seconds_at_utc_label(utc_midnight);
520    if new_leap <= old_leap || !(old_leap..new_leap).contains(&tai_sod) {
521        return None;
522    }
523
524    let mut utc = previous_second;
525    utc.second = 60.0 + (tai_sod - old_leap);
526    Some(utc)
527}
528
529fn leap_seconds_at_utc_label(cal: ScaleCal) -> f64 {
530    let jd1 = julian_day_number(cal.year, cal.month, cal.day) as f64 - 0.5;
531    let lookup_second = if cal.second >= 60.0 { 59.0 } else { cal.second };
532    let jd2 =
533        (cal.hour as f64 * 3600.0 + cal.minute as f64 * 60.0 + lookup_second) / SECONDS_PER_DAY;
534    find_leap_seconds(jd1 + jd2)
535}
536
537fn seconds_of_day(cal: ScaleCal) -> f64 {
538    cal.hour as f64 * 3600.0 + cal.minute as f64 * 60.0 + cal.second
539}
540
541fn normalize_calendar_seconds(mut cal: ScaleCal, second: f64) -> ScaleCal {
542    // A non-finite second has no civil carry to perform and would spin the
543    // subtract-by-60 loops below forever (inf - 60 == inf). Pass it through
544    // unchanged so `from_utc` resolves it to a clean error instead of hanging.
545    if !second.is_finite() {
546        cal.second = second;
547        return cal;
548    }
549    cal.second = second;
550    while cal.second < 0.0 {
551        cal.second += 60.0;
552        cal.minute -= 1;
553    }
554    while cal.second >= 60.0 {
555        cal.second -= 60.0;
556        cal.minute += 1;
557    }
558    while cal.minute < 0 {
559        cal.minute += 60;
560        cal.hour -= 1;
561    }
562    while cal.minute > 59 {
563        cal.minute -= 60;
564        cal.hour += 1;
565    }
566    while cal.hour < 0 {
567        cal.hour += 24;
568        cal.day -= 1;
569    }
570    while cal.hour > 23 {
571        cal.hour -= 24;
572        cal.day += 1;
573    }
574    while cal.day < 1 {
575        cal.month -= 1;
576        if cal.month < 1 {
577            cal.month = 12;
578            cal.year -= 1;
579        }
580        cal.day = civil::days_in_month(i64::from(cal.year), i64::from(cal.month)) as i32;
581    }
582    loop {
583        let month_days = civil::days_in_month(i64::from(cal.year), i64::from(cal.month)) as i32;
584        if cal.day <= month_days {
585            break;
586        }
587        cal.day -= month_days;
588        cal.month += 1;
589        if cal.month > 12 {
590            cal.month = 1;
591            cal.year += 1;
592        }
593    }
594    cal
595}
596
597pub(crate) fn is_positive_leap_second_label(
598    year: i32,
599    month: i32,
600    day: i32,
601    hour: i32,
602    minute: i32,
603) -> bool {
604    if hour != 23 || minute != 59 {
605        return false;
606    }
607    let jd1 = julian_day_number(year, month, day) as f64 - 0.5;
608    find_leap_seconds(jd1 + 1.0) > find_leap_seconds(jd1)
609}
610
611impl From<&FieldError> for TimeScaleInputErrorKind {
612    fn from(error: &FieldError) -> Self {
613        match error {
614            FieldError::Missing { .. } => Self::Missing,
615            FieldError::NonFinite { .. } => Self::NonFinite,
616            FieldError::NotPositive { .. } => Self::NotPositive,
617            FieldError::Negative { .. } => Self::Negative,
618            FieldError::OutOfRange { .. } => Self::OutOfRange,
619            FieldError::FloatParse { .. } => Self::FloatParse,
620            FieldError::IntParse { .. } => Self::IntParse,
621            FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
622            FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
623        }
624    }
625}
626
627fn map_time_scale_field_error(error: FieldError) -> CoverageError {
628    CoverageError::InvalidInput {
629        field: error.field(),
630        kind: TimeScaleInputErrorKind::from(&error),
631    }
632}
633
634/// Civil calendar -> Julian day number (Fliegel-style, integer arithmetic).
635pub fn julian_day_number(year: i32, month: i32, day: i32) -> i64 {
636    let year = i64::from(year);
637    let month = i64::from(month);
638    let day = i64::from(day);
639    let janfeb = month <= 2;
640    let g = year + 4716 - if janfeb { 1 } else { 0 };
641    let f = (month + 9) % 12;
642    let e = 1461 * g / 4 + day - 1402;
643    let j = e + (153 * f + 2) / 5;
644    j + 38 - ((g + 184) / 100) * 3 / 4
645}
646
647/// TAI-UTC (cumulative leap seconds) for a UTC Julian date.
648///
649/// For instants from 1972-01-01 (MJD 41317) onward this reads the embedded IERS
650/// integer leap-second table, clamping above to the last entry exactly as the
651/// original `orbis_nif` implementation did (parity-preserving). This post-1972
652/// branch is byte-for-byte the original lookup and is never perturbed.
653///
654/// For instants in the 1961-01-01 .. 1972-01-01 rubber-second era it evaluates
655/// the published piecewise-linear IERS/USNO model
656/// `TAI-UTC = base + (MJD - ref_mjd) * rate` (see [`RUBBER_SECONDS`]) using the
657/// fractional UTC MJD, so the offset is continuous within each segment as the
658/// historical definition requires. Before 1961 (and for a non-finite input) it
659/// clamps to the first rubber-second segment's value rather than extrapolating
660/// into undefined territory.
661///
662/// Boundary semantics (post-1972): the date is the integer MJD (`(jd_utc -
663/// 2400000.5) as i32`), so the count steps at UTC midnight (the table's
664/// effective MJD is the first full day under the new value). The inserted leap
665/// second `23:59:60` and the following `00:00:00` share essentially the same
666/// Julian date, so an end-of-day instant resolves to the **post-leap** count -
667/// the leap second itself cannot be distinguished from the next day's start
668/// through a JD. This is intrinsic to a JD-keyed lookup and is pinned to
669/// `orbis_nif` for bit-exact parity; callers that must label `23:59:60`
670/// distinctly have to carry the civil second out-of-band rather than rely on
671/// this function.
672pub fn find_leap_seconds(jd_utc: f64) -> f64 {
673    let mjd = (jd_utc - 2400000.5) as i32;
674    if mjd >= LEAP_SECONDS[0].mjd {
675        // Post-1972 integer leap-second table (unchanged, bit-identical).
676        let mut ls = 10.0;
677        for entry in LEAP_SECONDS {
678            if mjd >= entry.mjd {
679                ls = entry.tai_utc;
680            } else {
681                break;
682            }
683        }
684        return ls;
685    }
686    rubber_tai_minus_utc(jd_utc)
687}
688
689/// TAI - UTC (the full accumulated leap-second count) at a UTC Julian date.
690///
691/// This is the IERS / Bulletin C quantity: the difference between International
692/// Atomic Time and Coordinated Universal Time. From 2017-01-01 onward it is
693/// **37 s**. It is the unambiguously-named alias of [`find_leap_seconds`] and
694/// returns the identical value.
695///
696/// This is **not** the GNSS "leap seconds since the GPS epoch" quantity. A GNSS
697/// caller who wants GPS - UTC (18 s in 2017) must call [`gps_utc_offset_s`];
698/// using this function there over-counts by `TAI - GPST = 19 s`.
699///
700/// See [`find_leap_seconds`] for the table, rubber-second, and boundary
701/// semantics, which this function inherits verbatim.
702pub fn tai_utc_offset_s(jd_utc: f64) -> f64 {
703    find_leap_seconds(jd_utc)
704}
705
706/// GPS - UTC (the GNSS leap-second offset since the GPS epoch) at a UTC Julian
707/// date.
708///
709/// This is the IS-GPS-200 quantity broadcast in the navigation message: the
710/// difference between GPS system time and UTC. From 2017-01-01 onward it is
711/// **18 s**. By definition `GPST - TAI = -19 s` (equivalently
712/// `TAI - GPST = +19 s`), so
713/// `GPS-UTC = (TAI-UTC) + (GPST-TAI) = (TAI-UTC) - 19 s`,
714/// i.e. `gps_utc_offset_s == tai_utc_offset_s - 19`.
715///
716/// Use this, not [`tai_utc_offset_s`], whenever you mean "the leap seconds a
717/// GPS receiver applies"; the two differ by a constant 19 s and silently
718/// returning TAI - UTC where GPS - UTC is expected is a 19 s blunder.
719pub fn gps_utc_offset_s(jd_utc: f64) -> f64 {
720    find_leap_seconds(jd_utc) - GPST_MINUS_TAI_S
721}
722
723/// Evaluate the pre-1972 piecewise-linear TAI-UTC model at a UTC Julian date.
724///
725/// Selects the latest [`RUBBER_SECONDS`] segment whose `start_mjd` precedes the
726/// instant's fractional MJD and applies `base + (MJD - ref_mjd) * rate`. Inputs
727/// before the first segment (pre-1961) and non-finite inputs clamp to the first
728/// segment's constant term.
729fn rubber_tai_minus_utc(jd_utc: f64) -> f64 {
730    let mjd = jd_utc - 2400000.5;
731    let first = &RUBBER_SECONDS[0];
732    // Pre-1961 or non-finite input clamps to the first segment's constant.
733    if mjd.is_nan() || mjd < first.start_mjd as f64 {
734        return first.base;
735    }
736    let mut selected = first;
737    for entry in RUBBER_SECONDS {
738        if mjd >= entry.start_mjd as f64 {
739            selected = entry;
740        } else {
741            break;
742        }
743    }
744    selected.base + (mjd - selected.ref_mjd) * selected.rate
745}
746
747/// Error returned by the inter-system time-scale offset helpers.
748#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
749pub enum TimeOffsetError {
750    /// A fixed-offset query [`timescale_offset_s`] named a UTC-based scale
751    /// (UTC or GLONASST) whose offset to the atomic scales depends on the
752    /// instant's leap-second count. Use [`timescale_offset_at_s`] with an epoch.
753    #[error(
754        "time-scale {0} is UTC-based; its offset is epoch-dependent, use timescale_offset_at_s"
755    )]
756    EpochRequired(&'static str),
757    /// TDB differs from TT by an epoch-dependent periodic relativistic term, not
758    /// a fixed offset; resolve TDB through [`TimeScales::from_utc`] instead.
759    #[error("time-scale {0} has no fixed/constant offset; resolve it through TimeScales")]
760    Unsupported(&'static str),
761    /// A leap-aware query received a non-finite UTC Julian date.
762    #[error("utc_jd must be finite to resolve leap seconds for scale {0}")]
763    NonFiniteEpoch(&'static str),
764}
765
766/// Stable machine-readable discriminant for [`TimeOffsetError`].
767///
768/// The variants of [`TimeOffsetError`] carry only a human-facing `&'static str`,
769/// which a C/FFI caller cannot branch on without parsing text. This `#[repr(u8)]`
770/// code gives each variant a stable numeric tag (reachable across the FFI
771/// boundary as `code() as u8`). The values are part of the public contract: `0`
772/// is reserved for "no error", and an existing code is never renumbered.
773#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
774#[repr(u8)]
775pub enum TimeOffsetErrorCode {
776    /// [`TimeOffsetError::EpochRequired`].
777    EpochRequired = 1,
778    /// [`TimeOffsetError::Unsupported`].
779    Unsupported = 2,
780    /// [`TimeOffsetError::NonFiniteEpoch`].
781    NonFiniteEpoch = 3,
782}
783
784impl TimeOffsetError {
785    /// The stable machine-readable discriminant for this error.
786    ///
787    /// For C/FFI consumers that must distinguish the variants programmatically
788    /// without parsing the [`Display`](core::fmt::Display) message. The numeric
789    /// values (`error.code() as u8`) are a stable part of the public API.
790    #[must_use]
791    pub fn code(&self) -> TimeOffsetErrorCode {
792        match self {
793            Self::EpochRequired(_) => TimeOffsetErrorCode::EpochRequired,
794            Self::Unsupported(_) => TimeOffsetErrorCode::Unsupported,
795            Self::NonFiniteEpoch(_) => TimeOffsetErrorCode::NonFiniteEpoch,
796        }
797    }
798}
799
800/// True for scales whose offset to TAI carries UTC leap seconds (UTC, GLONASST).
801fn is_utc_based(scale: TimeScale) -> bool {
802    matches!(scale, TimeScale::Utc | TimeScale::Glonasst)
803}
804
805/// `scale_reading - TAI_reading` (seconds) for the same physical instant.
806///
807/// For the atomic scales (TAI/TT/GPST/GST/QZSST/BDT) this is a fixed constant
808/// and `utc_jd` is ignored. For the UTC-based scales (UTC/GLONASST) it depends
809/// on the leap-second count at `utc_jd`. TDB is rejected (epoch-dependent
810/// periodic term, no fixed offset).
811fn scale_minus_tai_s(scale: TimeScale, utc_jd: f64) -> Result<f64, TimeOffsetError> {
812    let leap = |s: TimeScale| -> Result<f64, TimeOffsetError> {
813        if !utc_jd.is_finite() {
814            return Err(TimeOffsetError::NonFiniteEpoch(s.abbrev()));
815        }
816        Ok(find_leap_seconds(utc_jd))
817    };
818    Ok(match scale {
819        TimeScale::Tai => 0.0,
820        // TT = TAI + 32.184 s (IERS Conventions 2010, TT definition).
821        TimeScale::Tt => TT_MINUS_TAI_S,
822        // GPST = TAI - 19 s (IS-GPS-200, fixed since the 1980 GPS epoch).
823        TimeScale::Gpst => -GPST_MINUS_TAI_S,
824        // GST nominally = GPST (Galileo OS SIS ICD, sec. 5.1.3: GST is steered
825        // to GPST; the real-time GGTO is a *broadcast* correction, not a fixed
826        // constant). Nominal GST - TAI therefore equals GPST - TAI = -19 s.
827        TimeScale::Gst => -GPST_MINUS_TAI_S,
828        // QZSST nominally = GPST (IS-QZSS-PNT, sec. 3.2.2: synchronous with
829        // GPST). Nominal QZSST - TAI = GPST - TAI = -19 s.
830        TimeScale::Qzsst => -GPST_MINUS_TAI_S,
831        // BDT = TAI - 33 s (BeiDou ICD, BDT epoch 2006-01-01, 14 leap seconds
832        // behind GPST's 1980 epoch: GPST - BDT = 14 s, so BDT - TAI = -33 s).
833        TimeScale::Bdt => -BDT_MINUS_TAI_S,
834        // UTC = TAI - (TAI-UTC), leap-second dependent.
835        TimeScale::Utc => -leap(scale)?,
836        // GLONASST = UTC + 3 h = TAI - (TAI-UTC) + 3 h.
837        TimeScale::Glonasst => -leap(scale)? + GLONASST_MINUS_UTC_S,
838        TimeScale::Tdb => return Err(TimeOffsetError::Unsupported("TDB")),
839    })
840}
841
842/// Fixed inter-system offset `to_reading - from_reading` (seconds) for scales
843/// whose mutual offset is a constant.
844///
845/// Returns the value that, added to a `from`-scale reading, yields the
846/// `to`-scale reading of the same physical instant. This covers the atomic
847/// scales TAI/TT/GPST/GST/QZSST/BDT, whose offsets are fixed by their defining
848/// ICDs (see [`scale_minus_tai_s`] for the per-scale citations).
849///
850/// Returns [`TimeOffsetError::EpochRequired`] if either scale is UTC-based
851/// (UTC/GLONASST) — those need [`timescale_offset_at_s`] — and
852/// [`TimeOffsetError::Unsupported`] for TDB.
853///
854/// # Note on the brief's `(from, to)` signature
855///
856/// The original A1 brief specified `timescale_offset_s(from, to) -> Result<f64>`.
857/// That signature cannot express the leap-aware GLONASST/UTC offsets (they need
858/// an epoch), so this function keeps the no-epoch form for the fixed atomic
859/// offsets and *errors* for UTC-based scales, while the leap-aware variant
860/// [`timescale_offset_at_s`] takes an explicit epoch. The split makes the
861/// epoch a compile-time-visible requirement rather than a silently-ignored arg.
862pub fn timescale_offset_s(from: TimeScale, to: TimeScale) -> Result<f64, TimeOffsetError> {
863    for scale in [from, to] {
864        if is_utc_based(scale) {
865            return Err(TimeOffsetError::EpochRequired(scale.abbrev()));
866        }
867    }
868    // utc_jd is unused for the atomic scales reached here.
869    timescale_offset_at_s(from, to, f64::NAN)
870}
871
872/// Leap-aware inter-system offset `to_reading - from_reading` (seconds) at a
873/// given UTC instant.
874///
875/// `utc_jd` is the UTC Julian date of the instant, used only to resolve the
876/// leap-second count when `from` or `to` is UTC-based (UTC/GLONASST); for
877/// purely atomic pairs it is ignored. Away from a leap-second boundary the
878/// leap count is stable, so the exact scale of `utc_jd` is immaterial; within
879/// the boundary window pass the UTC Julian date so the correct count is picked.
880///
881/// The result, added to a `from`-scale reading, yields the `to`-scale reading
882/// of the same physical instant. TDB is rejected (see [`TimeOffsetError`]).
883pub fn timescale_offset_at_s(
884    from: TimeScale,
885    to: TimeScale,
886    utc_jd: f64,
887) -> Result<f64, TimeOffsetError> {
888    Ok(scale_minus_tai_s(to, utc_jd)? - scale_minus_tai_s(from, utc_jd)?)
889}
890
891/// Provenance + coverage descriptor for the embedded leap-second table.
892///
893/// Exposed so a precision pipeline can interrogate table coverage and apply
894/// strict-vs-permissive policy (see [`crate::astro::time::eop`]).
895pub fn leap_second_table() -> LeapSecondTable {
896    LeapSecondTable {
897        source: "IERS Bulletin C (TAI-UTC), bundled in sidereon-core",
898        first_mjd: LEAP_SECONDS.first().map(|e| e.mjd).unwrap_or(0),
899        last_mjd: LEAP_SECONDS.last().map(|e| e.mjd).unwrap_or(0),
900        entries: LEAP_SECONDS.len(),
901    }
902}
903
904fn interpolate_delta_t(jd_tt: f64) -> f64 {
905    // Build delta-T table on first call (matching C++ lazy static pattern).
906    use std::sync::LazyLock;
907
908    struct DeltaTRow {
909        jd_tt: f64,
910        delta_t: f64,
911    }
912
913    static TABLE: LazyLock<Vec<DeltaTRow>> = LazyLock::new(|| {
914        UT1_DATA
915            .iter()
916            .map(|entry| {
917                let jd_utc = entry.mjd as f64 + 2400000.5;
918                let leap_seconds = find_leap_seconds(jd_utc);
919                let tt_minus_utc = leap_seconds + TT_MINUS_TAI_S;
920                let delta_t = ((tt_minus_utc - entry.ut1_utc) * ROUND_1E7).round() / ROUND_1E7;
921                DeltaTRow {
922                    jd_tt: jd_utc + tt_minus_utc / SECONDS_PER_DAY,
923                    delta_t,
924                }
925            })
926            .collect()
927    });
928
929    // Binary search for the bracketing entries.
930    match TABLE.binary_search_by(|row| row.jd_tt.partial_cmp(&jd_tt).unwrap()) {
931        Ok(i) => TABLE[i].delta_t,
932        Err(0) => TABLE[0].delta_t,
933        Err(i) if i >= TABLE.len() => TABLE.last().unwrap().delta_t,
934        Err(i) => {
935            let p1 = &TABLE[i - 1];
936            let p2 = &TABLE[i];
937            p1.delta_t + (jd_tt - p1.jd_tt) * (p2.delta_t - p1.delta_t) / (p2.jd_tt - p1.jd_tt)
938        }
939    }
940}
941
942/// UT1 coverage interval for the embedded EOP table, in TT Julian dates.
943///
944/// Outside this interval the delta-T interpolation clamps to the nearest table
945/// edge (parity-preserving Skyfield behaviour). Strict-mode callers should treat
946/// instants outside `[first_jd_tt, last_jd_tt]` as degraded; see
947/// [`crate::astro::time::eop`].
948pub fn ut1_coverage() -> Ut1Provenance {
949    let first = UT1_DATA.first();
950    let last = UT1_DATA.last();
951    let to_jd_tt = |mjd: i32| -> f64 {
952        let jd_utc = mjd as f64 + 2400000.5;
953        let tt_minus_utc = find_leap_seconds(jd_utc) + TT_MINUS_TAI_S;
954        jd_utc + tt_minus_utc / SECONDS_PER_DAY
955    };
956    Ut1Provenance {
957        source: "IERS Earth Orientation Parameters (UT1-UTC), bundled",
958        first_mjd: first.map(|e| e.mjd).unwrap_or(0),
959        last_mjd: last.map(|e| e.mjd).unwrap_or(0),
960        first_jd_tt: first.map(|e| to_jd_tt(e.mjd)).unwrap_or(0.0),
961        last_jd_tt: last.map(|e| to_jd_tt(e.mjd)).unwrap_or(0.0),
962        entries: UT1_DATA.len(),
963    }
964}
965
966#[cfg(test)]
967mod tests {
968    use super::*;
969
970    #[test]
971    fn julian_day_number_widens_extreme_inputs_before_arithmetic() {
972        let _ = julian_day_number(i32::MIN, i32::MAX, i32::MAX);
973        let _ = julian_day_number(i32::MAX, i32::MIN, i32::MIN);
974    }
975
976    /// UTC Julian date for a calendar instant, built exactly as the parity path
977    /// (`from_utc_unchecked`) does, so the embedded leap table is queried at the
978    /// same instant the production code would.
979    fn utc_jd(year: i32, month: i32, day: i32, hour: i32, minute: i32, second: f64) -> f64 {
980        let jd1 = julian_day_number(year, month, day) as f64 - 0.5;
981        let sod = hour as f64 * 3600.0 + minute as f64 * 60.0 + second;
982        jd1 + sod / SECONDS_PER_DAY
983    }
984
985    // --- Pre-1972 rubber-second UTC-TAI model. --------------------------------
986
987    #[test]
988    fn tai_minus_utc_pre_1972_matches_published_table() {
989        // Published IERS/USNO TAI-UTC values (tai-utc.dat) for the rubber-second
990        // era, TAI-UTC = base + (MJD - ref) * rate, evaluated at UTC midnight.
991        let cases = [
992            // (year, month, day, published TAI-UTC seconds)
993            (1961, 1, 1, 1.4228180), // segment start MJD 37300
994            (1965, 1, 1, 3.5401300), // segment start MJD 38761
995            (1968, 2, 1, 6.1856820), // MJD 39887: 4.2131700 + 761*0.002592
996            (1971, 1, 1, 8.9461620), // MJD 40952: 4.2131700 + 1826*0.002592
997        ];
998        for (y, m, d, want) in cases {
999            let jd = utc_jd(y, m, d, 0, 0, 0.0);
1000            let got = find_leap_seconds(jd);
1001            assert!(
1002                (got - want).abs() < 1.0e-7,
1003                "TAI-UTC at {y}-{m:02}-{d:02}: got {got}, want {want}"
1004            );
1005        }
1006    }
1007
1008    #[test]
1009    fn tai_minus_utc_pre_1972_is_continuous_within_a_segment() {
1010        // The rubber second drifts linearly: noon must sit half a day's rate
1011        // above midnight inside the 1968 segment (rate 0.002592 s/day).
1012        let midnight = find_leap_seconds(utc_jd(1969, 6, 1, 0, 0, 0.0));
1013        let noon = find_leap_seconds(utc_jd(1969, 6, 1, 12, 0, 0.0));
1014        assert!(
1015            (noon - midnight - 0.5 * 0.002592).abs() < 1.0e-9,
1016            "rubber-second drift over half a day must equal 0.5*rate"
1017        );
1018    }
1019
1020    #[test]
1021    fn tai_minus_utc_steps_to_ten_at_1972_and_post_1972_unchanged() {
1022        // The famous final rubber-second value just before the 1972 step.
1023        let pre = find_leap_seconds(utc_jd(1971, 12, 31, 0, 0, 0.0));
1024        assert!((pre - 9.8896500).abs() < 1.0e-7, "1971-12-31 TAI-UTC");
1025        // 1972-01-01 is the first integer leap-second entry: exactly 10 s.
1026        assert_eq!(find_leap_seconds(utc_jd(1972, 1, 1, 0, 0, 0.0)), 10.0);
1027        // Post-1972 integer table is untouched (bit-identical goldens).
1028        assert_eq!(find_leap_seconds(utc_jd(1980, 1, 1, 0, 0, 0.0)), 19.0);
1029        assert_eq!(find_leap_seconds(utc_jd(2017, 1, 1, 0, 0, 0.0)), 37.0);
1030    }
1031
1032    #[test]
1033    fn tai_utc_and_gps_utc_offsets_match_iers_and_is_gps_200() {
1034        // 2017-01-01 onward: IERS Bulletin C TAI-UTC = 37 s; IS-GPS-200
1035        // GPS-UTC = 18 s; the two differ by the fixed GPST-TAI = 19 s.
1036        let jd_2017 = utc_jd(2017, 1, 1, 0, 0, 0.0);
1037        assert_eq!(tai_utc_offset_s(jd_2017), 37.0);
1038        assert_eq!(gps_utc_offset_s(jd_2017), 18.0);
1039        assert_eq!(tai_utc_offset_s(jd_2017) - gps_utc_offset_s(jd_2017), 19.0);
1040
1041        // The named alias must equal the historical accessor bit-for-bit, and the
1042        // GPS offset must track it minus 19 s across the whole integer table.
1043        for (y, m, d) in [(1980, 1, 1), (2000, 1, 1), (2009, 1, 1), (2017, 1, 1)] {
1044            let jd = utc_jd(y, m, d, 0, 0, 0.0);
1045            assert_eq!(
1046                tai_utc_offset_s(jd).to_bits(),
1047                find_leap_seconds(jd).to_bits()
1048            );
1049            assert_eq!(gps_utc_offset_s(jd), find_leap_seconds(jd) - 19.0);
1050        }
1051
1052        // Cross-check GPS-UTC against the leap-aware UTC->GPST offset, which is
1053        // independently validated against RTKLIB.
1054        assert_eq!(
1055            gps_utc_offset_s(jd_2017),
1056            timescale_offset_at_s(TimeScale::Utc, TimeScale::Gpst, jd_2017)
1057                .expect("leap-aware offset")
1058        );
1059    }
1060
1061    #[test]
1062    fn tai_minus_utc_pre_1961_clamps_to_first_segment() {
1063        // Before the table and for a non-finite input, clamp to the 1961 value.
1064        assert_eq!(find_leap_seconds(utc_jd(1958, 1, 1, 0, 0, 0.0)), 1.4228180);
1065        assert_eq!(find_leap_seconds(f64::NAN), 1.4228180);
1066    }
1067
1068    // --- Fixed atomic-scale offsets (hex-float goldens). ----------------------
1069    //
1070    // Goldens are exact f64 bit patterns. `timescale_offset_s(from, to)` returns
1071    // `to_reading - from_reading`, i.e. the value added to a `from` reading to
1072    // obtain the `to` reading of the same instant.
1073
1074    #[test]
1075    fn offset_gpst_to_bdt_is_minus_14s() {
1076        // BeiDou ICD: BDT = GPST - 14 s. Golden: -14.0.
1077        let want = f64::from_bits(0xc02c_0000_0000_0000);
1078        assert_eq!(
1079            timescale_offset_s(TimeScale::Gpst, TimeScale::Bdt).expect("fixed offset"),
1080            want
1081        );
1082        assert_eq!(want, -14.0);
1083    }
1084
1085    #[test]
1086    fn offset_bdt_to_gpst_is_plus_14s() {
1087        assert_eq!(
1088            timescale_offset_s(TimeScale::Bdt, TimeScale::Gpst).expect("fixed offset"),
1089            14.0
1090        );
1091    }
1092
1093    #[test]
1094    fn offset_gpst_to_gst_is_nominal_zero() {
1095        // Galileo OS SIS ICD: GST steered to GPST; nominal GGTO = 0 (the live
1096        // GGTO is a broadcast correction, not represented here).
1097        assert_eq!(
1098            timescale_offset_s(TimeScale::Gpst, TimeScale::Gst).expect("fixed offset"),
1099            0.0
1100        );
1101    }
1102
1103    #[test]
1104    fn offset_gpst_to_qzsst_is_nominal_zero() {
1105        // IS-QZSS-PNT: QZSST synchronous with GPST; nominal offset = 0.
1106        assert_eq!(
1107            timescale_offset_s(TimeScale::Gpst, TimeScale::Qzsst).expect("fixed offset"),
1108            0.0
1109        );
1110        assert_eq!(
1111            timescale_offset_s(TimeScale::Gst, TimeScale::Qzsst).expect("fixed offset"),
1112            0.0
1113        );
1114    }
1115
1116    #[test]
1117    fn offset_tai_to_tt_is_32_184s() {
1118        // IERS Conventions: TT = TAI + 32.184 s. Golden: exact bits of 32.184.
1119        let want = f64::from_bits(0x4040_178d_4fdf_3b64);
1120        assert_eq!(
1121            timescale_offset_s(TimeScale::Tai, TimeScale::Tt).expect("fixed offset"),
1122            want
1123        );
1124        assert_eq!(want, 32.184);
1125    }
1126
1127    #[test]
1128    fn offset_gpst_to_tt_is_51_184s() {
1129        // TT - GPST = (TAI + 32.184) - (TAI - 19) = 51.184 s. Golden: exact bits.
1130        let want = f64::from_bits(0x4049_978d_4fdf_3b64);
1131        assert_eq!(
1132            timescale_offset_s(TimeScale::Gpst, TimeScale::Tt).expect("fixed offset"),
1133            want
1134        );
1135        assert_eq!(want, 51.184);
1136    }
1137
1138    #[test]
1139    fn offset_gpst_to_tai_is_plus_19s() {
1140        // IS-GPS-200: GPST = TAI - 19 s, so TAI - GPST = +19 s.
1141        assert_eq!(
1142            timescale_offset_s(TimeScale::Gpst, TimeScale::Tai).expect("fixed offset"),
1143            19.0
1144        );
1145    }
1146
1147    #[test]
1148    fn fixed_offsets_are_antisymmetric_for_atomic_pairs() {
1149        let atomic = [
1150            TimeScale::Tai,
1151            TimeScale::Tt,
1152            TimeScale::Gpst,
1153            TimeScale::Gst,
1154            TimeScale::Qzsst,
1155            TimeScale::Bdt,
1156        ];
1157        for &a in &atomic {
1158            for &b in &atomic {
1159                let ab = timescale_offset_s(a, b).expect("fixed offset");
1160                let ba = timescale_offset_s(b, a).expect("fixed offset");
1161                assert_eq!(ab, -ba, "offset({a:?},{b:?}) must be -offset({b:?},{a:?})");
1162            }
1163        }
1164    }
1165
1166    // --- Error cases. ---------------------------------------------------------
1167
1168    #[test]
1169    fn fixed_offset_requires_epoch_for_utc_based_scales() {
1170        assert_eq!(
1171            timescale_offset_s(TimeScale::Gpst, TimeScale::Utc),
1172            Err(TimeOffsetError::EpochRequired("UTC"))
1173        );
1174        assert_eq!(
1175            timescale_offset_s(TimeScale::Glonasst, TimeScale::Gpst),
1176            Err(TimeOffsetError::EpochRequired("GLONASST"))
1177        );
1178    }
1179
1180    #[test]
1181    fn tdb_has_no_fixed_offset() {
1182        assert_eq!(
1183            timescale_offset_s(TimeScale::Gpst, TimeScale::Tdb),
1184            Err(TimeOffsetError::Unsupported("TDB"))
1185        );
1186        assert_eq!(
1187            timescale_offset_at_s(TimeScale::Tt, TimeScale::Tdb, 2_451_545.0),
1188            Err(TimeOffsetError::Unsupported("TDB"))
1189        );
1190    }
1191
1192    #[test]
1193    fn leap_aware_offset_rejects_non_finite_epoch() {
1194        assert_eq!(
1195            timescale_offset_at_s(TimeScale::Gpst, TimeScale::Utc, f64::NAN),
1196            Err(TimeOffsetError::NonFiniteEpoch("UTC"))
1197        );
1198        assert_eq!(
1199            timescale_offset_at_s(TimeScale::Glonasst, TimeScale::Gpst, f64::INFINITY),
1200            Err(TimeOffsetError::NonFiniteEpoch("GLONASST"))
1201        );
1202    }
1203
1204    #[test]
1205    fn error_code_maps_each_variant_to_stable_discriminant() {
1206        assert_eq!(
1207            TimeOffsetError::EpochRequired("UTC").code(),
1208            TimeOffsetErrorCode::EpochRequired
1209        );
1210        assert_eq!(
1211            TimeOffsetError::Unsupported("TDB").code(),
1212            TimeOffsetErrorCode::Unsupported
1213        );
1214        assert_eq!(
1215            TimeOffsetError::NonFiniteEpoch("UTC").code(),
1216            TimeOffsetErrorCode::NonFiniteEpoch
1217        );
1218        // The repr(u8) values are the stable FFI contract: 0 is reserved for
1219        // "no error", so the codes start at 1 and never collide.
1220        assert_eq!(TimeOffsetErrorCode::EpochRequired as u8, 1);
1221        assert_eq!(TimeOffsetErrorCode::Unsupported as u8, 2);
1222        assert_eq!(TimeOffsetErrorCode::NonFiniteEpoch as u8, 3);
1223        // The code is independent of the payload string.
1224        assert_eq!(
1225            TimeOffsetError::EpochRequired("GLONASST").code() as u8,
1226            TimeOffsetError::EpochRequired("UTC").code() as u8
1227        );
1228    }
1229
1230    #[test]
1231    fn leap_aware_offset_ignores_epoch_for_atomic_pairs() {
1232        // Atomic pair never touches the leap table, so a NaN epoch is harmless.
1233        assert_eq!(
1234            timescale_offset_at_s(TimeScale::Gpst, TimeScale::Bdt, f64::NAN)
1235                .expect("atomic pair ignores epoch"),
1236            -14.0
1237        );
1238    }
1239
1240    // --- Leap-aware GPST<->UTC<->GLONASST, validated against RTKLIB. -----------
1241    //
1242    // RTKLIB (gpst2utc/utc2gpst + the +3 h GLONASST advance) reports, at the
1243    // listed UTC instants:
1244    //   2017-01-01 00:00:00  GPST-UTC=+18  UTC-GPST=-18  GLONASST-GPST=+10782
1245    //   2016-12-31 23:59:59  GPST-UTC=+17  UTC-GPST=-17  GLONASST-GPST=+10783
1246    //   2000-01-01 12:00:00  GPST-UTC=+13  UTC-GPST=-13  GLONASST-GPST=+10787
1247
1248    #[test]
1249    fn offset_utc_gpst_matches_rtklib_2017() {
1250        let jd = utc_jd(2017, 1, 1, 0, 0, 0.0);
1251        // GPST - UTC = +18 (golden 18.0).
1252        let want = f64::from_bits(0x4032_0000_0000_0000);
1253        assert_eq!(
1254            timescale_offset_at_s(TimeScale::Utc, TimeScale::Gpst, jd).expect("leap-aware offset"),
1255            want
1256        );
1257        assert_eq!(want, 18.0);
1258        // UTC - GPST = -18.
1259        assert_eq!(
1260            timescale_offset_at_s(TimeScale::Gpst, TimeScale::Utc, jd).expect("leap-aware offset"),
1261            -18.0
1262        );
1263    }
1264
1265    #[test]
1266    fn offset_glonasst_gpst_matches_rtklib_2017() {
1267        let jd = utc_jd(2017, 1, 1, 0, 0, 0.0);
1268        // GLONASST - GPST = +10782 (golden bits of 10782.0).
1269        let want = f64::from_bits(0x40c5_0f00_0000_0000);
1270        assert_eq!(
1271            timescale_offset_at_s(TimeScale::Gpst, TimeScale::Glonasst, jd)
1272                .expect("leap-aware offset"),
1273            want
1274        );
1275        assert_eq!(want, 10782.0);
1276    }
1277
1278    #[test]
1279    fn offset_glonasst_gpst_at_j2000_matches_rtklib() {
1280        let jd = utc_jd(2000, 1, 1, 12, 0, 0.0);
1281        // GLONASST - GPST = +10787 at the J2000 leap epoch (TAI-UTC=32).
1282        assert_eq!(
1283            timescale_offset_at_s(TimeScale::Gpst, TimeScale::Glonasst, jd)
1284                .expect("leap-aware offset"),
1285            10787.0
1286        );
1287    }
1288
1289    /// The brief's required leap-second-boundary test: the GPST->GLONASST offset
1290    /// must step by exactly one second across the 2016-12-31 -> 2017-01-01 leap,
1291    /// from +10783 s (TAI-UTC=36) to +10782 s (TAI-UTC=37), matching RTKLIB.
1292    #[test]
1293    fn glonasst_offset_steps_across_2017_leap_second() {
1294        let before = utc_jd(2016, 12, 31, 23, 59, 59.0);
1295        let after = utc_jd(2017, 1, 1, 0, 0, 0.0);
1296
1297        let off_before = timescale_offset_at_s(TimeScale::Gpst, TimeScale::Glonasst, before)
1298            .expect("leap-aware offset");
1299        let off_after = timescale_offset_at_s(TimeScale::Gpst, TimeScale::Glonasst, after)
1300            .expect("leap-aware offset");
1301
1302        assert_eq!(off_before, f64::from_bits(0x40c5_0f80_0000_0000)); // 10783.0
1303        assert_eq!(off_after, f64::from_bits(0x40c5_0f00_0000_0000)); // 10782.0
1304        assert_eq!(off_before, 10783.0);
1305        assert_eq!(off_after, 10782.0);
1306        // The leap insertion lengthens the GPST-ahead-of-UTC gap by 1 s, so the
1307        // GLONASST(=UTC+3h)-minus-GPST offset shrinks by exactly 1 s.
1308        assert_eq!(off_before - off_after, 1.0);
1309
1310        // Cross-check the UTC leg too: GPST-UTC goes 17 -> 18 across the leap.
1311        assert_eq!(
1312            timescale_offset_at_s(TimeScale::Utc, TimeScale::Gpst, before)
1313                .expect("leap-aware offset"),
1314            17.0
1315        );
1316        assert_eq!(
1317            timescale_offset_at_s(TimeScale::Utc, TimeScale::Gpst, after)
1318                .expect("leap-aware offset"),
1319            18.0
1320        );
1321    }
1322
1323    /// Cross-check the leap-aware GPST<->UTC offset against the parity-critical
1324    /// `TimeScales::from_utc` path (which is itself Skyfield 0-ULP). The two TT
1325    /// fractions for the same instant labelled in UTC vs in GPST must differ by
1326    /// the offset this helper reports.
1327    #[test]
1328    fn leap_aware_offset_agrees_with_timescales_path() {
1329        // GPST is ahead of UTC by `timescale_offset_at_s(Utc, Gpst, jd)` seconds.
1330        let jd = utc_jd(2020, 6, 15, 0, 0, 0.0);
1331        let gpst_minus_utc =
1332            timescale_offset_at_s(TimeScale::Utc, TimeScale::Gpst, jd).expect("leap-aware offset");
1333        // 2020 is between the 2017 leap and now: TAI-UTC=37, GPST-UTC=18.
1334        assert_eq!(gpst_minus_utc, 18.0);
1335    }
1336}