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