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::{Ut1Entry, 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/// TT/TCG defining rate constant `L_G` from IAU 2000 Resolution B1.9.
36pub const TT_TCG_RATE_L_G: f64 = 6.969290134e-10;
37
38/// TDB/TCB defining rate constant `L_B` from IAU 2006 Resolution B3.
39pub const TDB_TCB_RATE_L_B: f64 = 1.550519768e-8;
40
41/// TDB offset constant `TDB0`, seconds, from IAU 2006 Resolution B3.
42pub const TDB_TCB_OFFSET_TDB0_S: f64 = -6.55e-5;
43
44/// TT/TCG and TDB/TCB reference epoch Julian Date.
45pub const TCG_TCB_REFERENCE_JD: f64 = 2_443_144.500_372_5;
46
47/// Resolved set of Julian-date split time scales for one UTC instant.
48///
49/// All fields use the Skyfield split convention: `jd_whole` carries the integer
50/// (and TAI-aligned) part of the day, and the per-scale `*_fraction` fields carry
51/// the residual so that `jd_<scale> == jd_whole + <scale>_fraction` reproduces
52/// the full Julian date without catastrophic cancellation.
53#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct TimeScales {
55    /// Integer Julian day boundary (TAI-aligned), shared by all scales.
56    pub jd_whole: f64,
57    /// UT1 day fraction relative to `jd_whole`.
58    pub ut1_fraction: f64,
59    /// TT day fraction relative to `jd_whole`.
60    pub tt_fraction: f64,
61    /// TDB day fraction relative to `jd_whole`.
62    pub tdb_fraction: f64,
63    /// Full UT1 Julian date.
64    pub jd_ut1: f64,
65    /// Full TT Julian date.
66    pub jd_tt: f64,
67    /// Full TDB Julian date.
68    pub jd_tdb: f64,
69}
70
71/// One post-1972 TAI-UTC step keyed by UTC Modified Julian Date.
72#[derive(Debug, Clone, Copy, PartialEq)]
73pub struct LeapSecondEntry {
74    /// First UTC Modified Julian Date carrying this TAI-UTC value.
75    pub mjd: i32,
76    /// TAI minus UTC, seconds.
77    pub tai_utc: f64,
78}
79
80/// Caller-supplied time tables for UTC, TAI, TT, UT1, TCG, TDB, and TCB.
81#[derive(Debug, Clone, Copy)]
82pub struct TimeTables<'a> {
83    /// Leap-second table entries with the same shape as the embedded table.
84    pub leap_seconds: &'a [LeapSecondEntry],
85    /// UT1-UTC samples with the same shape as the embedded table.
86    pub ut1_utc: &'a [Ut1Entry],
87}
88
89impl<'a> TimeTables<'a> {
90    /// Construct time tables from caller-owned slices.
91    pub fn new(
92        leap_seconds: &'a [LeapSecondEntry],
93        ut1_utc: &'a [Ut1Entry],
94    ) -> Result<Self, CoverageError> {
95        validate_time_tables(leap_seconds, ut1_utc)?;
96        Ok(Self {
97            leap_seconds,
98            ut1_utc,
99        })
100    }
101
102    /// Provenance and coverage for this leap-second table.
103    #[must_use]
104    pub fn leap_second_table(&self) -> LeapSecondTable {
105        leap_second_table_for(self.leap_seconds, "caller-supplied leap-second table")
106    }
107
108    /// Coverage for this UT1-UTC table, expressed on the TT axis.
109    #[must_use]
110    pub fn ut1_coverage(&self) -> Ut1Provenance {
111        ut1_coverage_for(
112            self.ut1_utc,
113            self.leap_seconds,
114            "caller-supplied UT1-UTC table",
115        )
116    }
117}
118
119impl TimeTables<'static> {
120    /// Embedded time tables bundled with the crate.
121    #[must_use]
122    pub fn embedded() -> Self {
123        Self {
124            leap_seconds: LEAP_SECONDS,
125            ut1_utc: &UT1_DATA,
126        }
127    }
128}
129
130static LEAP_SECONDS: &[LeapSecondEntry] = &[
131    LeapSecondEntry {
132        mjd: 41317,
133        tai_utc: 10.0,
134    },
135    LeapSecondEntry {
136        mjd: 41499,
137        tai_utc: 11.0,
138    },
139    LeapSecondEntry {
140        mjd: 41683,
141        tai_utc: 12.0,
142    },
143    LeapSecondEntry {
144        mjd: 42048,
145        tai_utc: 13.0,
146    },
147    LeapSecondEntry {
148        mjd: 42413,
149        tai_utc: 14.0,
150    },
151    LeapSecondEntry {
152        mjd: 42778,
153        tai_utc: 15.0,
154    },
155    LeapSecondEntry {
156        mjd: 43144,
157        tai_utc: 16.0,
158    },
159    LeapSecondEntry {
160        mjd: 43509,
161        tai_utc: 17.0,
162    },
163    LeapSecondEntry {
164        mjd: 43874,
165        tai_utc: 18.0,
166    },
167    LeapSecondEntry {
168        mjd: 44239,
169        tai_utc: 19.0,
170    },
171    LeapSecondEntry {
172        mjd: 44786,
173        tai_utc: 20.0,
174    },
175    LeapSecondEntry {
176        mjd: 45151,
177        tai_utc: 21.0,
178    },
179    LeapSecondEntry {
180        mjd: 45516,
181        tai_utc: 22.0,
182    },
183    LeapSecondEntry {
184        mjd: 46247,
185        tai_utc: 23.0,
186    },
187    LeapSecondEntry {
188        mjd: 47161,
189        tai_utc: 24.0,
190    },
191    LeapSecondEntry {
192        mjd: 47892,
193        tai_utc: 25.0,
194    },
195    LeapSecondEntry {
196        mjd: 48257,
197        tai_utc: 26.0,
198    },
199    LeapSecondEntry {
200        mjd: 48804,
201        tai_utc: 27.0,
202    },
203    LeapSecondEntry {
204        mjd: 49169,
205        tai_utc: 28.0,
206    },
207    LeapSecondEntry {
208        mjd: 49534,
209        tai_utc: 29.0,
210    },
211    LeapSecondEntry {
212        mjd: 50083,
213        tai_utc: 30.0,
214    },
215    LeapSecondEntry {
216        mjd: 50448,
217        tai_utc: 31.0,
218    },
219    LeapSecondEntry {
220        mjd: 50813,
221        tai_utc: 32.0,
222    },
223    LeapSecondEntry {
224        mjd: 53736,
225        tai_utc: 33.0,
226    },
227    LeapSecondEntry {
228        mjd: 54832,
229        tai_utc: 34.0,
230    },
231    LeapSecondEntry {
232        mjd: 56109,
233        tai_utc: 35.0,
234    },
235    LeapSecondEntry {
236        mjd: 57204,
237        tai_utc: 36.0,
238    },
239    LeapSecondEntry {
240        mjd: 57754,
241        tai_utc: 37.0,
242    },
243];
244
245/// One segment of the pre-1972 "rubber second" UTC, where TAI-UTC varied as a
246/// piecewise-linear function of the UTC Modified Julian Date rather than by
247/// integer leap-second steps.
248struct RubberSecondEntry {
249    /// Integer UTC MJD at which this segment takes effect.
250    start_mjd: i32,
251    /// Constant term of `TAI-UTC = base + (MJD - ref_mjd) * rate` (seconds).
252    base: f64,
253    /// Reference MJD the linear drift is measured from.
254    ref_mjd: f64,
255    /// Drift rate of TAI-UTC, seconds per day of MJD.
256    rate: f64,
257}
258
259/// The published IERS/USNO TAI-UTC table for the 1961-01-01 .. 1972-01-01
260/// rubber-second era (USNO `tai-utc.dat`). Each segment gives
261/// `TAI-UTC = base + (MJD - ref_mjd) * rate`, with MJD the UTC Modified Julian
262/// Date. The table ends where the integer leap-second table (`LEAP_SECONDS`)
263/// begins at MJD 41317 (1972-01-01, TAI-UTC = 10 s exactly).
264static RUBBER_SECONDS: &[RubberSecondEntry] = &[
265    RubberSecondEntry {
266        start_mjd: 37300,
267        base: 1.4228180,
268        ref_mjd: 37300.0,
269        rate: 0.001296,
270    },
271    RubberSecondEntry {
272        start_mjd: 37512,
273        base: 1.3728180,
274        ref_mjd: 37300.0,
275        rate: 0.001296,
276    },
277    RubberSecondEntry {
278        start_mjd: 37665,
279        base: 1.8458580,
280        ref_mjd: 37665.0,
281        rate: 0.0011232,
282    },
283    RubberSecondEntry {
284        start_mjd: 38334,
285        base: 1.9458580,
286        ref_mjd: 37665.0,
287        rate: 0.0011232,
288    },
289    RubberSecondEntry {
290        start_mjd: 38395,
291        base: 3.2401300,
292        ref_mjd: 38761.0,
293        rate: 0.001296,
294    },
295    RubberSecondEntry {
296        start_mjd: 38486,
297        base: 3.3401300,
298        ref_mjd: 38761.0,
299        rate: 0.001296,
300    },
301    RubberSecondEntry {
302        start_mjd: 38639,
303        base: 3.4401300,
304        ref_mjd: 38761.0,
305        rate: 0.001296,
306    },
307    RubberSecondEntry {
308        start_mjd: 38761,
309        base: 3.5401300,
310        ref_mjd: 38761.0,
311        rate: 0.001296,
312    },
313    RubberSecondEntry {
314        start_mjd: 38820,
315        base: 3.6401300,
316        ref_mjd: 38761.0,
317        rate: 0.001296,
318    },
319    RubberSecondEntry {
320        start_mjd: 38942,
321        base: 3.7401300,
322        ref_mjd: 38761.0,
323        rate: 0.001296,
324    },
325    RubberSecondEntry {
326        start_mjd: 39004,
327        base: 3.8401300,
328        ref_mjd: 38761.0,
329        rate: 0.001296,
330    },
331    RubberSecondEntry {
332        start_mjd: 39126,
333        base: 4.3131700,
334        ref_mjd: 39126.0,
335        rate: 0.002592,
336    },
337    RubberSecondEntry {
338        start_mjd: 39887,
339        base: 4.2131700,
340        ref_mjd: 39126.0,
341        rate: 0.002592,
342    },
343];
344
345impl TimeScales {
346    /// Resolve the split-Julian-date time scales for a UTC calendar instant.
347    ///
348    /// Validates the public boundary, then runs the exact Skyfield `_utc()` path.
349    pub fn from_utc(
350        year: i32,
351        month: i32,
352        day: i32,
353        hour: i32,
354        minute: i32,
355        second: f64,
356    ) -> Result<Self, CoverageError> {
357        validate_utc_input_embedded(year, month, day, hour, minute, second)?;
358        Ok(Self::from_utc_unchecked(
359            year, month, day, hour, minute, second,
360        ))
361    }
362
363    /// Resolve time scales for a UTC calendar instant using caller-supplied
364    /// leap-second and UT1-UTC tables.
365    pub fn from_utc_with_tables(
366        year: i32,
367        month: i32,
368        day: i32,
369        hour: i32,
370        minute: i32,
371        second: f64,
372        tables: TimeTables<'_>,
373    ) -> Result<Self, CoverageError> {
374        validate_time_tables(tables.leap_seconds, tables.ut1_utc)?;
375        validate_utc_input_with_table(year, month, day, hour, minute, second, tables.leap_seconds)?;
376        let scales =
377            Self::from_utc_with_tables_unchecked(year, month, day, hour, minute, second, tables)?;
378        let prov = tables.ut1_coverage();
379        check_ut1_coverage(&prov, scales.jd_tt, ValidityMode::Strict)?;
380        Ok(scales)
381    }
382
383    /// Exact Skyfield `_utc()` path. The arithmetic order below is load-bearing
384    /// for 0-ULP parity and MUST NOT be reordered.
385    fn from_utc_unchecked(
386        year: i32,
387        month: i32,
388        day: i32,
389        hour: i32,
390        minute: i32,
391        second: f64,
392    ) -> Self {
393        let jd_day = julian_day_number(year, month, day);
394        let jd1 = jd_day as f64 - 0.5;
395        let utc_seconds_of_day =
396            hour as f64 * SECONDS_PER_HOUR + minute as f64 * SECONDS_PER_MINUTE + second;
397        let leap_lookup_second = if second >= 60.0 { 59.0 } else { second };
398        let jd2 = (leap_lookup_second
399            + minute as f64 * SECONDS_PER_MINUTE
400            + hour as f64 * SECONDS_PER_HOUR)
401            / SECONDS_PER_DAY;
402        let jd_utc_total = jd1 + jd2;
403
404        let leap_seconds = find_leap_seconds(jd_utc_total);
405        let utc_seconds_at_midnight = jd1 * SECONDS_PER_DAY;
406
407        let utc_whole_seconds = utc_seconds_of_day.trunc();
408        let utc_subsecond = utc_seconds_of_day.fract();
409
410        // Mirror Skyfield's _utc() path.
411        let tai_seconds = utc_seconds_at_midnight + leap_seconds + utc_whole_seconds;
412        let jd_whole = (tai_seconds / SECONDS_PER_DAY).floor();
413        let tai_fraction =
414            (tai_seconds - jd_whole * SECONDS_PER_DAY + utc_subsecond) / SECONDS_PER_DAY;
415        let tt_offset_days = TT_MINUS_TAI_S / SECONDS_PER_DAY;
416
417        let tt_fraction = tai_fraction + tt_offset_days;
418        let jd_tt = jd_whole + tt_fraction;
419
420        let delta_t = interpolate_delta_t(jd_tt);
421        let ut1_fraction = tt_fraction - delta_t / SECONDS_PER_DAY;
422        let jd_ut1 = jd_whole + ut1_fraction;
423
424        let t = (jd_whole - J2000_JD + tt_fraction) / DAYS_PER_JULIAN_CENTURY;
425        let tdb_minus_tt_seconds = 0.001657 * (628.3076 * t + 6.2401).sin()
426            + 0.000022 * (575.3385 * t + 4.2970).sin()
427            + 0.000014 * (1256.6152 * t + 6.1969).sin()
428            + 0.000005 * (606.9777 * t + 4.0212).sin()
429            + 0.000005 * (52.9691 * t + 0.4444).sin()
430            + 0.000002 * (21.3299 * t + 5.5431).sin()
431            + 0.000010 * t * (628.3076 * t + 4.2490).sin();
432
433        let tdb_fraction = tt_fraction + tdb_minus_tt_seconds / SECONDS_PER_DAY;
434        let jd_tdb = jd_whole + tdb_fraction;
435
436        Self {
437            jd_whole,
438            ut1_fraction,
439            tt_fraction,
440            tdb_fraction,
441            jd_ut1,
442            jd_tt,
443            jd_tdb,
444        }
445    }
446
447    /// Exact UTC conversion path using caller-supplied tables.
448    fn from_utc_with_tables_unchecked(
449        year: i32,
450        month: i32,
451        day: i32,
452        hour: i32,
453        minute: i32,
454        second: f64,
455        tables: TimeTables<'_>,
456    ) -> Result<Self, CoverageError> {
457        let jd_day = julian_day_number(year, month, day);
458        let jd1 = jd_day as f64 - 0.5;
459        let utc_seconds_of_day =
460            hour as f64 * SECONDS_PER_HOUR + minute as f64 * SECONDS_PER_MINUTE + second;
461        let leap_lookup_second = if second >= 60.0 { 59.0 } else { second };
462        let jd2 = (leap_lookup_second
463            + minute as f64 * SECONDS_PER_MINUTE
464            + hour as f64 * SECONDS_PER_HOUR)
465            / SECONDS_PER_DAY;
466        let jd_utc_total = jd1 + jd2;
467
468        let leap_seconds = find_leap_seconds_in_table_checked(jd_utc_total, tables.leap_seconds)?;
469        let utc_seconds_at_midnight = jd1 * SECONDS_PER_DAY;
470
471        let utc_whole_seconds = utc_seconds_of_day.trunc();
472        let utc_subsecond = utc_seconds_of_day.fract();
473
474        let tai_seconds = utc_seconds_at_midnight + leap_seconds + utc_whole_seconds;
475        let jd_whole = (tai_seconds / SECONDS_PER_DAY).floor();
476        let tai_fraction =
477            (tai_seconds - jd_whole * SECONDS_PER_DAY + utc_subsecond) / SECONDS_PER_DAY;
478        let tt_offset_days = TT_MINUS_TAI_S / SECONDS_PER_DAY;
479
480        let tt_fraction = tai_fraction + tt_offset_days;
481        let jd_tt = jd_whole + tt_fraction;
482
483        let delta_t = interpolate_delta_t_with_table(jd_tt, tables.ut1_utc, tables.leap_seconds)?;
484        let ut1_fraction = tt_fraction - delta_t / SECONDS_PER_DAY;
485        let jd_ut1 = jd_whole + ut1_fraction;
486
487        let t = (jd_whole - J2000_JD + tt_fraction) / DAYS_PER_JULIAN_CENTURY;
488        let tdb_minus_tt_seconds = 0.001657 * (628.3076 * t + 6.2401).sin()
489            + 0.000022 * (575.3385 * t + 4.2970).sin()
490            + 0.000014 * (1256.6152 * t + 6.1969).sin()
491            + 0.000005 * (606.9777 * t + 4.0212).sin()
492            + 0.000005 * (52.9691 * t + 0.4444).sin()
493            + 0.000002 * (21.3299 * t + 5.5431).sin()
494            + 0.000010 * t * (628.3076 * t + 4.2490).sin();
495
496        let tdb_fraction = tt_fraction + tdb_minus_tt_seconds / SECONDS_PER_DAY;
497        let jd_tdb = jd_whole + tdb_fraction;
498
499        Ok(Self {
500            jd_whole,
501            ut1_fraction,
502            tt_fraction,
503            tdb_fraction,
504            jd_ut1,
505            jd_tt,
506            jd_tdb,
507        })
508    }
509
510    /// Coverage-policy-enforced variant of [`TimeScales::from_utc`].
511    ///
512    /// The numerics are produced by [`TimeScales::from_utc`] **unchanged** (the
513    /// delta-T / UT1-UTC lookups still clamp at the embedded table edges exactly
514    /// as before, preserving Skyfield 0-ULP parity). The only addition is that
515    /// the resulting TT instant is classified against the embedded UT1/EOP
516    /// coverage interval under the requested [`ValidityMode`]:
517    ///
518    /// - [`ValidityMode::Strict`]: an instant outside `[first_jd_tt, last_jd_tt]`
519    ///   (where the delta-T table would have silently clamped/extrapolated)
520    ///   returns [`CoverageError::OutsideCoverage`]. Nothing degraded is ever
521    ///   returned.
522    /// - [`ValidityMode::Permissive`]: the clamped value is returned, paired with
523    ///   a [`crate::astro::time::eop::DegradeReason`] when the instant fell outside
524    ///   coverage. This is the historical (parity) behaviour, now made explicit.
525    ///
526    /// In-coverage results are bit-identical to [`TimeScales::from_utc`] and are
527    /// flagged not-degraded.
528    pub fn from_utc_validated(
529        year: i32,
530        month: i32,
531        day: i32,
532        hour: i32,
533        minute: i32,
534        second: f64,
535        mode: ValidityMode,
536    ) -> Result<Validated<Self>, CoverageError> {
537        // Numerics first, exactly as the parity path produces them.
538        let scales = Self::from_utc(year, month, day, hour, minute, second)?;
539        // Classify the (already-clamped) instant against UT1 coverage. We
540        // classify at jd_tt because the delta-T table axis is in TT (see
541        // `ut1_coverage`), and jd_tt is independent of the clamped delta-T.
542        let prov = ut1_coverage();
543        let degraded = check_ut1_coverage(&prov, scales.jd_tt, mode)?;
544        Ok(Validated {
545            value: scales,
546            degraded,
547        })
548    }
549
550    /// Coverage-policy-enforced variant of [`TimeScales::from_utc_with_tables`].
551    #[allow(clippy::too_many_arguments)]
552    pub fn from_utc_validated_with_tables(
553        year: i32,
554        month: i32,
555        day: i32,
556        hour: i32,
557        minute: i32,
558        second: f64,
559        mode: ValidityMode,
560        tables: TimeTables<'_>,
561    ) -> Result<Validated<Self>, CoverageError> {
562        validate_time_tables(tables.leap_seconds, tables.ut1_utc)?;
563        validate_utc_input_with_table(year, month, day, hour, minute, second, tables.leap_seconds)?;
564        let scales =
565            Self::from_utc_with_tables_unchecked(year, month, day, hour, minute, second, tables)?;
566        let prov = tables.ut1_coverage();
567        let degraded = check_ut1_coverage(&prov, scales.jd_tt, mode)?;
568        Ok(Validated {
569            value: scales,
570            degraded,
571        })
572    }
573
574    /// Build [`TimeScales`] for a calendar instant labelled in `scale`.
575    ///
576    /// Non-UTC scales (GPST/GST/BDT/QZSST/TAI/TT/TDB and GLONASST) are converted
577    /// to the UTC calendar label first via [`scale_calendar_to_utc`], so the
578    /// Earth-orientation inputs used downstream are correct rather than offset by
579    /// the scale's leap-second gap, then routed through [`Self::from_utc`]. This
580    /// is the single home for the system-time-to-UTC inverse that the
581    /// reduced-orbit bridge previously reimplemented.
582    pub fn from_scale(
583        scale: TimeScale,
584        year: i32,
585        month: i32,
586        day: i32,
587        hour: i32,
588        minute: i32,
589        second: f64,
590    ) -> Result<Self, CoverageError> {
591        let cal = ScaleCal {
592            year,
593            month,
594            day,
595            hour,
596            minute,
597            second,
598        };
599        validate_scale_input_embedded(scale, cal)?;
600        let utc = scale_calendar_to_utc(scale, cal, LEAP_SECONDS);
601        Self::from_utc(
602            utc.year, utc.month, utc.day, utc.hour, utc.minute, utc.second,
603        )
604    }
605
606    /// Build [`TimeScales`] for a calendar instant labelled in `scale` using
607    /// caller-supplied leap-second and UT1-UTC tables.
608    #[allow(clippy::too_many_arguments)]
609    pub fn from_scale_with_tables(
610        scale: TimeScale,
611        year: i32,
612        month: i32,
613        day: i32,
614        hour: i32,
615        minute: i32,
616        second: f64,
617        tables: TimeTables<'_>,
618    ) -> Result<Self, CoverageError> {
619        validate_time_tables(tables.leap_seconds, tables.ut1_utc)?;
620        let cal = ScaleCal {
621            year,
622            month,
623            day,
624            hour,
625            minute,
626            second,
627        };
628        validate_scale_input_with_table(scale, cal, tables.leap_seconds)?;
629        let utc = scale_calendar_to_utc_with_table(scale, cal, tables.leap_seconds)?;
630        Self::from_utc_with_tables(
631            utc.year, utc.month, utc.day, utc.hour, utc.minute, utc.second, tables,
632        )
633    }
634
635    /// Full TCG Julian Date from this instant's TT coordinate.
636    #[must_use]
637    pub fn jd_tcg(&self) -> f64 {
638        tt_to_tcg_jd(self.jd_tt)
639    }
640
641    /// TCG day fraction relative to [`TimeScales::jd_whole`].
642    #[must_use]
643    pub fn tcg_fraction(&self) -> f64 {
644        tcg_fraction_from_tt_split(self.jd_whole, self.tt_fraction)
645    }
646
647    /// Full TCB Julian Date from this instant's TDB coordinate.
648    #[must_use]
649    pub fn jd_tcb(&self) -> f64 {
650        tdb_to_tcb_jd(self.jd_tdb)
651    }
652
653    /// TCB day fraction relative to [`TimeScales::jd_whole`].
654    #[must_use]
655    pub fn tcb_fraction(&self) -> f64 {
656        tcb_fraction_from_tdb_split(self.jd_whole, self.tdb_fraction)
657    }
658}
659
660/// A mutable civil calendar instant used by the scale-to-UTC inverse.
661#[derive(Debug, Clone, Copy, PartialEq)]
662struct ScaleCal {
663    year: i32,
664    month: i32,
665    day: i32,
666    hour: i32,
667    minute: i32,
668    second: f64,
669}
670
671/// Convert a calendar instant labelled in `scale` to the UTC calendar instant
672/// [`TimeScales::from_utc`] consumes.
673///
674/// GLONASST = UTC(SU) + 3 h exactly (no leap term in the 3 h offset), so
675/// recovering UTC is a plain -3 h shift that preserves UTC's leap labels. The
676/// atomic-aligned scales are shifted to TAI and then resolved to UTC through the
677/// leap-second table.
678fn scale_calendar_to_utc(
679    scale: TimeScale,
680    cal: ScaleCal,
681    leap_seconds: &[LeapSecondEntry],
682) -> ScaleCal {
683    match scale {
684        TimeScale::Utc => cal,
685        TimeScale::Glonasst => normalize_calendar_seconds(cal, cal.second - GLONASST_MINUS_UTC_S),
686        TimeScale::Tcg => {
687            let tcg_jd = continuous_calendar_jd(cal);
688            coordinate_calendar_to_utc(
689                cal,
690                tcg_to_tt_jd(tcg_jd) - tcg_jd,
691                TimeScale::Tt,
692                leap_seconds,
693            )
694        }
695        TimeScale::Tdb => {
696            let tdb_jd = continuous_calendar_jd(cal);
697            coordinate_calendar_to_utc(
698                cal,
699                tdb_to_tt_jd_for_tdb_input(tdb_jd) - tdb_jd,
700                TimeScale::Tt,
701                leap_seconds,
702            )
703        }
704        TimeScale::Tcb => {
705            let tcb_jd = continuous_calendar_jd(cal);
706            let tdb_jd = tcb_to_tdb_jd(tcb_jd);
707            let tt_jd = tdb_to_tt_jd_for_tdb_input(tdb_jd);
708            coordinate_calendar_to_utc(cal, tt_jd - tcb_jd, TimeScale::Tt, leap_seconds)
709        }
710        _ => {
711            let tai = normalize_calendar_seconds(cal, cal.second + tai_minus_scale_seconds(scale));
712            tai_calendar_to_utc(tai, leap_seconds)
713        }
714    }
715}
716
717fn scale_calendar_to_utc_with_table(
718    scale: TimeScale,
719    cal: ScaleCal,
720    leap_seconds: &[LeapSecondEntry],
721) -> Result<ScaleCal, CoverageError> {
722    match scale {
723        TimeScale::Utc => Ok(cal),
724        TimeScale::Glonasst => Ok(normalize_calendar_seconds(
725            cal,
726            cal.second - GLONASST_MINUS_UTC_S,
727        )),
728        TimeScale::Tcg => {
729            let tcg_jd = continuous_calendar_jd(cal);
730            coordinate_calendar_to_utc_with_table(
731                cal,
732                tcg_to_tt_jd(tcg_jd) - tcg_jd,
733                TimeScale::Tt,
734                leap_seconds,
735            )
736        }
737        TimeScale::Tdb => {
738            let tdb_jd = continuous_calendar_jd(cal);
739            coordinate_calendar_to_utc_with_table(
740                cal,
741                tdb_to_tt_jd_for_tdb_input(tdb_jd) - tdb_jd,
742                TimeScale::Tt,
743                leap_seconds,
744            )
745        }
746        TimeScale::Tcb => {
747            let tcb_jd = continuous_calendar_jd(cal);
748            let tdb_jd = tcb_to_tdb_jd(tcb_jd);
749            let tt_jd = tdb_to_tt_jd_for_tdb_input(tdb_jd);
750            coordinate_calendar_to_utc_with_table(cal, tt_jd - tcb_jd, TimeScale::Tt, leap_seconds)
751        }
752        _ => {
753            let tai = normalize_calendar_seconds(cal, cal.second + tai_minus_scale_seconds(scale));
754            tai_calendar_to_utc_with_table(tai, leap_seconds)
755        }
756    }
757}
758
759fn coordinate_calendar_to_utc(
760    cal: ScaleCal,
761    target_minus_source_days: f64,
762    target_scale: TimeScale,
763    leap_seconds: &[LeapSecondEntry],
764) -> ScaleCal {
765    let target =
766        normalize_calendar_seconds(cal, cal.second + target_minus_source_days * SECONDS_PER_DAY);
767    let tai = normalize_calendar_seconds(
768        target,
769        target.second + tai_minus_scale_seconds(target_scale),
770    );
771    tai_calendar_to_utc(tai, leap_seconds)
772}
773
774fn coordinate_calendar_to_utc_with_table(
775    cal: ScaleCal,
776    target_minus_source_days: f64,
777    target_scale: TimeScale,
778    leap_seconds: &[LeapSecondEntry],
779) -> Result<ScaleCal, CoverageError> {
780    let target =
781        normalize_calendar_seconds(cal, cal.second + target_minus_source_days * SECONDS_PER_DAY);
782    let tai = normalize_calendar_seconds(
783        target,
784        target.second + tai_minus_scale_seconds(target_scale),
785    );
786    tai_calendar_to_utc_with_table(tai, leap_seconds)
787}
788
789fn continuous_calendar_jd(cal: ScaleCal) -> f64 {
790    let jd1 = julian_day_number(cal.year, cal.month, cal.day) as f64 - 0.5;
791    jd1 + seconds_of_day(cal) / SECONDS_PER_DAY
792}
793
794fn tai_minus_scale_seconds(scale: TimeScale) -> f64 {
795    match scale {
796        // Utc/Glonasst/TCG/TDB/TCB are handled before reaching here; 0.0 keeps
797        // the match total without affecting the atomic path.
798        TimeScale::Utc | TimeScale::Glonasst | TimeScale::Tcg | TimeScale::Tdb | TimeScale::Tcb => {
799            0.0
800        }
801        TimeScale::Tai => 0.0,
802        TimeScale::Tt => -TT_MINUS_TAI_S,
803        // QZSST is steered synchronous with GPST, so it shares GPST's TAI offset.
804        TimeScale::Gpst | TimeScale::Gst | TimeScale::Qzsst => GPST_MINUS_TAI_S,
805        TimeScale::Bdt => BDT_MINUS_TAI_S,
806    }
807}
808
809fn tai_calendar_to_utc(tai: ScaleCal, leap_seconds: &[LeapSecondEntry]) -> ScaleCal {
810    if let Some(utc) = positive_leap_second_utc_label(tai, leap_seconds) {
811        return utc;
812    }
813
814    let mut leap = leap_seconds_at_utc_label(tai, leap_seconds);
815    let mut utc = normalize_calendar_seconds(tai, tai.second - leap);
816    for _ in 0..3 {
817        let next_leap = leap_seconds_at_utc_label(utc, leap_seconds);
818        if next_leap == leap {
819            return utc;
820        }
821        leap = next_leap;
822        utc = normalize_calendar_seconds(tai, tai.second - leap);
823    }
824    utc
825}
826
827fn tai_calendar_to_utc_with_table(
828    tai: ScaleCal,
829    leap_seconds: &[LeapSecondEntry],
830) -> Result<ScaleCal, CoverageError> {
831    if let Some(utc) = positive_leap_second_utc_label_with_table(tai, leap_seconds)? {
832        return Ok(utc);
833    }
834
835    let mut leap = leap_seconds_at_utc_label_checked(tai, leap_seconds)?;
836    let mut utc = normalize_calendar_seconds(tai, tai.second - leap);
837    for _ in 0..3 {
838        let next_leap = leap_seconds_at_utc_label_checked(utc, leap_seconds)?;
839        if next_leap == leap {
840            return Ok(utc);
841        }
842        leap = next_leap;
843        utc = normalize_calendar_seconds(tai, tai.second - leap);
844    }
845    Ok(utc)
846}
847
848fn positive_leap_second_utc_label(
849    tai: ScaleCal,
850    leap_seconds: &[LeapSecondEntry],
851) -> Option<ScaleCal> {
852    let tai_sod = seconds_of_day(tai);
853    let utc_midnight = ScaleCal {
854        year: tai.year,
855        month: tai.month,
856        day: tai.day,
857        hour: 0,
858        minute: 0,
859        second: 0.0,
860    };
861    let previous_second = normalize_calendar_seconds(utc_midnight, -1.0);
862    let old_leap = leap_seconds_at_utc_label(previous_second, leap_seconds);
863    let new_leap = leap_seconds_at_utc_label(utc_midnight, leap_seconds);
864    if new_leap <= old_leap || !(old_leap..new_leap).contains(&tai_sod) {
865        return None;
866    }
867
868    let mut utc = previous_second;
869    utc.second = 60.0 + (tai_sod - old_leap);
870    Some(utc)
871}
872
873fn positive_leap_second_utc_label_with_table(
874    tai: ScaleCal,
875    leap_seconds: &[LeapSecondEntry],
876) -> Result<Option<ScaleCal>, CoverageError> {
877    let tai_sod = seconds_of_day(tai);
878    let utc_midnight = ScaleCal {
879        year: tai.year,
880        month: tai.month,
881        day: tai.day,
882        hour: 0,
883        minute: 0,
884        second: 0.0,
885    };
886    let previous_second = normalize_calendar_seconds(utc_midnight, -1.0);
887    let Ok(old_leap) = leap_seconds_at_utc_label_checked(previous_second, leap_seconds) else {
888        return Ok(None);
889    };
890    let new_leap = leap_seconds_at_utc_label_checked(utc_midnight, leap_seconds)?;
891    if new_leap <= old_leap || !(old_leap..new_leap).contains(&tai_sod) {
892        return Ok(None);
893    }
894
895    let mut utc = previous_second;
896    utc.second = 60.0 + (tai_sod - old_leap);
897    Ok(Some(utc))
898}
899
900fn leap_seconds_at_utc_label(cal: ScaleCal, leap_seconds: &[LeapSecondEntry]) -> f64 {
901    let jd1 = julian_day_number(cal.year, cal.month, cal.day) as f64 - 0.5;
902    let lookup_second = if cal.second >= 60.0 { 59.0 } else { cal.second };
903    let jd2 = (cal.hour as f64 * SECONDS_PER_HOUR
904        + cal.minute as f64 * SECONDS_PER_MINUTE
905        + lookup_second)
906        / SECONDS_PER_DAY;
907    find_leap_seconds_in_table(jd1 + jd2, leap_seconds)
908}
909
910fn leap_seconds_at_utc_label_checked(
911    cal: ScaleCal,
912    leap_seconds: &[LeapSecondEntry],
913) -> Result<f64, CoverageError> {
914    let jd1 = julian_day_number(cal.year, cal.month, cal.day) as f64 - 0.5;
915    let lookup_second = if cal.second >= 60.0 { 59.0 } else { cal.second };
916    let jd2 = (cal.hour as f64 * SECONDS_PER_HOUR
917        + cal.minute as f64 * SECONDS_PER_MINUTE
918        + lookup_second)
919        / SECONDS_PER_DAY;
920    find_leap_seconds_in_table_checked(jd1 + jd2, leap_seconds)
921}
922
923fn seconds_of_day(cal: ScaleCal) -> f64 {
924    cal.hour as f64 * SECONDS_PER_HOUR + cal.minute as f64 * SECONDS_PER_MINUTE + cal.second
925}
926
927fn tdb_to_tt_jd_for_tdb_input(jd_tdb: f64) -> f64 {
928    let mut jd_tt = jd_tdb;
929    for _ in 0..4 {
930        jd_tt = jd_tdb - tdb_minus_tt_seconds_at_tt_jd(jd_tt) / SECONDS_PER_DAY;
931    }
932    jd_tt
933}
934
935fn tdb_minus_tt_seconds_at_tt_jd(jd_tt: f64) -> f64 {
936    let t = (jd_tt - J2000_JD) / DAYS_PER_JULIAN_CENTURY;
937    0.001657 * (628.3076 * t + 6.2401).sin()
938        + 0.000022 * (575.3385 * t + 4.2970).sin()
939        + 0.000014 * (1256.6152 * t + 6.1969).sin()
940        + 0.000005 * (606.9777 * t + 4.0212).sin()
941        + 0.000005 * (52.9691 * t + 0.4444).sin()
942        + 0.000002 * (21.3299 * t + 5.5431).sin()
943        + 0.000010 * t * (628.3076 * t + 4.2490).sin()
944}
945
946fn normalize_calendar_seconds(mut cal: ScaleCal, second: f64) -> ScaleCal {
947    // A non-finite second has no civil carry to perform and would spin the
948    // subtract-by-60 loops below forever (inf - 60 == inf). Pass it through
949    // unchanged so `from_utc` resolves it to a clean error instead of hanging.
950    if !second.is_finite() {
951        cal.second = second;
952        return cal;
953    }
954    cal.second = second;
955    while cal.second < 0.0 {
956        cal.second += 60.0;
957        cal.minute -= 1;
958    }
959    while cal.second >= 60.0 {
960        cal.second -= 60.0;
961        cal.minute += 1;
962    }
963    while cal.minute < 0 {
964        cal.minute += 60;
965        cal.hour -= 1;
966    }
967    while cal.minute > 59 {
968        cal.minute -= 60;
969        cal.hour += 1;
970    }
971    while cal.hour < 0 {
972        cal.hour += 24;
973        cal.day -= 1;
974    }
975    while cal.hour > 23 {
976        cal.hour -= 24;
977        cal.day += 1;
978    }
979    while cal.day < 1 {
980        cal.month -= 1;
981        if cal.month < 1 {
982            cal.month = 12;
983            cal.year -= 1;
984        }
985        cal.day = civil::days_in_month(i64::from(cal.year), i64::from(cal.month)) as i32;
986    }
987    loop {
988        let month_days = civil::days_in_month(i64::from(cal.year), i64::from(cal.month)) as i32;
989        if cal.day <= month_days {
990            break;
991        }
992        cal.day -= month_days;
993        cal.month += 1;
994        if cal.month > 12 {
995            cal.month = 1;
996            cal.year += 1;
997        }
998    }
999    cal
1000}
1001
1002pub(crate) fn is_positive_leap_second_label(
1003    year: i32,
1004    month: i32,
1005    day: i32,
1006    hour: i32,
1007    minute: i32,
1008) -> bool {
1009    is_positive_leap_second_label_with_table(year, month, day, hour, minute, LEAP_SECONDS)
1010}
1011
1012fn is_positive_leap_second_label_with_table(
1013    year: i32,
1014    month: i32,
1015    day: i32,
1016    hour: i32,
1017    minute: i32,
1018    leap_seconds: &[LeapSecondEntry],
1019) -> bool {
1020    if hour != 23 || minute != 59 {
1021        return false;
1022    }
1023    let jd1 = julian_day_number(year, month, day) as f64 - 0.5;
1024    find_leap_seconds_in_table(jd1 + 1.0, leap_seconds)
1025        > find_leap_seconds_in_table(jd1, leap_seconds)
1026}
1027
1028fn is_positive_leap_second_label_with_table_checked(
1029    year: i32,
1030    month: i32,
1031    day: i32,
1032    hour: i32,
1033    minute: i32,
1034    leap_seconds: &[LeapSecondEntry],
1035) -> Result<bool, CoverageError> {
1036    if hour != 23 || minute != 59 {
1037        return Ok(false);
1038    }
1039    let jd1 = julian_day_number(year, month, day) as f64 - 0.5;
1040    Ok(find_leap_seconds_in_table_checked(jd1 + 1.0, leap_seconds)?
1041        > find_leap_seconds_in_table_checked(jd1, leap_seconds)?)
1042}
1043
1044impl From<&FieldError> for TimeScaleInputErrorKind {
1045    fn from(error: &FieldError) -> Self {
1046        match error {
1047            FieldError::Missing { .. } => Self::Missing,
1048            FieldError::NonFinite { .. } => Self::NonFinite,
1049            FieldError::NotPositive { .. } => Self::NotPositive,
1050            FieldError::Negative { .. } => Self::Negative,
1051            FieldError::OutOfRange { .. } => Self::OutOfRange,
1052            FieldError::FloatParse { .. } => Self::FloatParse,
1053            FieldError::IntParse { .. } => Self::IntParse,
1054            FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
1055            FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
1056        }
1057    }
1058}
1059
1060fn map_time_scale_field_error(error: FieldError) -> CoverageError {
1061    CoverageError::InvalidInput {
1062        field: error.field(),
1063        kind: TimeScaleInputErrorKind::from(&error),
1064    }
1065}
1066
1067fn validate_utc_input_embedded(
1068    year: i32,
1069    month: i32,
1070    day: i32,
1071    hour: i32,
1072    minute: i32,
1073    second: f64,
1074) -> Result<(), CoverageError> {
1075    validate_utc_civil_input(year, month, day, hour, minute, second)?;
1076    if second >= 60.0
1077        && !is_positive_leap_second_label_with_table(year, month, day, hour, minute, LEAP_SECONDS)
1078    {
1079        return Err(CoverageError::InvalidInput {
1080            field: "civil datetime",
1081            kind: TimeScaleInputErrorKind::InvalidCivilTime,
1082        });
1083    }
1084    Ok(())
1085}
1086
1087fn validate_utc_input_with_table(
1088    year: i32,
1089    month: i32,
1090    day: i32,
1091    hour: i32,
1092    minute: i32,
1093    second: f64,
1094    leap_seconds: &[LeapSecondEntry],
1095) -> Result<(), CoverageError> {
1096    validate_utc_civil_input(year, month, day, hour, minute, second)?;
1097    ensure_leap_table_covers_calendar(year, month, day, hour, minute, second, leap_seconds)?;
1098    if second >= 60.0
1099        && !is_positive_leap_second_label_with_table_checked(
1100            year,
1101            month,
1102            day,
1103            hour,
1104            minute,
1105            leap_seconds,
1106        )?
1107    {
1108        return Err(CoverageError::InvalidInput {
1109            field: "civil datetime",
1110            kind: TimeScaleInputErrorKind::InvalidCivilTime,
1111        });
1112    }
1113    Ok(())
1114}
1115
1116fn validate_utc_civil_input(
1117    year: i32,
1118    month: i32,
1119    day: i32,
1120    hour: i32,
1121    minute: i32,
1122    second: f64,
1123) -> Result<(), CoverageError> {
1124    validate::finite(second, "second").map_err(map_time_scale_field_error)?;
1125    validate::civil_datetime_with_second_policy(
1126        i64::from(year),
1127        i64::from(month),
1128        i64::from(day),
1129        i64::from(hour),
1130        i64::from(minute),
1131        second,
1132        validate::CivilSecondPolicy::UtcLike,
1133    )
1134    .map_err(map_time_scale_field_error)?;
1135    Ok(())
1136}
1137
1138fn ensure_leap_table_covers_calendar(
1139    year: i32,
1140    month: i32,
1141    day: i32,
1142    hour: i32,
1143    minute: i32,
1144    second: f64,
1145    leap_seconds: &[LeapSecondEntry],
1146) -> Result<(), CoverageError> {
1147    let jd1 = julian_day_number(year, month, day) as f64 - 0.5;
1148    let lookup_second = if second >= 60.0 { 59.0 } else { second };
1149    let jd2 = (hour as f64 * SECONDS_PER_HOUR + minute as f64 * SECONDS_PER_MINUTE + lookup_second)
1150        / SECONDS_PER_DAY;
1151    find_leap_seconds_in_table_checked(jd1 + jd2, leap_seconds).map(|_| ())
1152}
1153
1154fn validate_time_tables(
1155    leap_seconds: &[LeapSecondEntry],
1156    ut1_utc: &[Ut1Entry],
1157) -> Result<(), CoverageError> {
1158    validate_leap_seconds_table(leap_seconds)?;
1159    validate_ut1_table(ut1_utc)?;
1160    if effective_ut1_utc_rows(ut1_utc, leap_seconds).len() < 2 {
1161        return Err(table_error("ut1_utc", TimeScaleInputErrorKind::Missing));
1162    }
1163    Ok(())
1164}
1165
1166fn validate_leap_seconds_table(leap_seconds: &[LeapSecondEntry]) -> Result<(), CoverageError> {
1167    if leap_seconds.is_empty() {
1168        return Err(table_error(
1169            "leap_seconds",
1170            TimeScaleInputErrorKind::Missing,
1171        ));
1172    }
1173    let mut previous_mjd = leap_seconds[0].mjd;
1174    if !leap_seconds[0].tai_utc.is_finite() {
1175        return Err(table_error(
1176            "leap_seconds",
1177            TimeScaleInputErrorKind::NonFinite,
1178        ));
1179    }
1180    for entry in &leap_seconds[1..] {
1181        if entry.mjd <= previous_mjd {
1182            return Err(table_error(
1183                "leap_seconds",
1184                TimeScaleInputErrorKind::OutOfRange,
1185            ));
1186        }
1187        if !entry.tai_utc.is_finite() {
1188            return Err(table_error(
1189                "leap_seconds",
1190                TimeScaleInputErrorKind::NonFinite,
1191            ));
1192        }
1193        previous_mjd = entry.mjd;
1194    }
1195    Ok(())
1196}
1197
1198fn validate_ut1_table(ut1_utc: &[Ut1Entry]) -> Result<(), CoverageError> {
1199    if ut1_utc.len() < 2 {
1200        return Err(table_error("ut1_utc", TimeScaleInputErrorKind::Missing));
1201    }
1202    let mut previous_mjd = ut1_utc[0].mjd;
1203    if !ut1_utc[0].ut1_utc.is_finite() {
1204        return Err(table_error("ut1_utc", TimeScaleInputErrorKind::NonFinite));
1205    }
1206    for entry in &ut1_utc[1..] {
1207        if entry.mjd <= previous_mjd {
1208            return Err(table_error("ut1_utc", TimeScaleInputErrorKind::OutOfRange));
1209        }
1210        if !entry.ut1_utc.is_finite() {
1211            return Err(table_error("ut1_utc", TimeScaleInputErrorKind::NonFinite));
1212        }
1213        previous_mjd = entry.mjd;
1214    }
1215    Ok(())
1216}
1217
1218fn effective_ut1_utc_rows<'a>(
1219    ut1_utc: &'a [Ut1Entry],
1220    leap_seconds: &[LeapSecondEntry],
1221) -> &'a [Ut1Entry] {
1222    debug_assert!(!leap_seconds.is_empty());
1223    let first_covered_mjd = leap_seconds[0].mjd;
1224    let first = ut1_utc
1225        .iter()
1226        .position(|entry| entry.mjd >= first_covered_mjd)
1227        .unwrap_or(ut1_utc.len());
1228    &ut1_utc[first..]
1229}
1230
1231fn table_error(field: &'static str, kind: TimeScaleInputErrorKind) -> CoverageError {
1232    CoverageError::InvalidInput { field, kind }
1233}
1234
1235fn validate_scale_input_embedded(scale: TimeScale, cal: ScaleCal) -> Result<(), CoverageError> {
1236    if scale == TimeScale::Utc {
1237        return validate_utc_input_embedded(
1238            cal.year, cal.month, cal.day, cal.hour, cal.minute, cal.second,
1239        );
1240    }
1241    validate_continuous_scale_input(cal)
1242}
1243
1244fn validate_scale_input_with_table(
1245    scale: TimeScale,
1246    cal: ScaleCal,
1247    leap_seconds: &[LeapSecondEntry],
1248) -> Result<(), CoverageError> {
1249    if scale == TimeScale::Utc {
1250        return validate_utc_input_with_table(
1251            cal.year,
1252            cal.month,
1253            cal.day,
1254            cal.hour,
1255            cal.minute,
1256            cal.second,
1257            leap_seconds,
1258        );
1259    }
1260    validate_continuous_scale_input(cal)?;
1261    if is_utc_based(scale) {
1262        let utc = scale_calendar_to_utc_with_table(scale, cal, leap_seconds)?;
1263        ensure_leap_table_covers_calendar(
1264            utc.year,
1265            utc.month,
1266            utc.day,
1267            utc.hour,
1268            utc.minute,
1269            utc.second,
1270            leap_seconds,
1271        )?;
1272    }
1273    Ok(())
1274}
1275
1276fn validate_continuous_scale_input(cal: ScaleCal) -> Result<(), CoverageError> {
1277    validate::finite(cal.second, "second").map_err(map_time_scale_field_error)?;
1278    validate::civil_datetime_with_second_policy(
1279        i64::from(cal.year),
1280        i64::from(cal.month),
1281        i64::from(cal.day),
1282        i64::from(cal.hour),
1283        i64::from(cal.minute),
1284        cal.second,
1285        validate::CivilSecondPolicy::Continuous,
1286    )
1287    .map_err(map_time_scale_field_error)
1288    .map(|_| ())
1289}
1290
1291/// Convert a TT Julian Date to TCG using IAU 2000 Resolution B1.9.
1292#[must_use]
1293pub fn tt_to_tcg_jd(jd_tt: f64) -> f64 {
1294    TCG_TCB_REFERENCE_JD + (jd_tt - TCG_TCB_REFERENCE_JD) / (1.0 - TT_TCG_RATE_L_G)
1295}
1296
1297/// Convert a TCG Julian Date to TT using IAU 2000 Resolution B1.9.
1298#[must_use]
1299pub fn tcg_to_tt_jd(jd_tcg: f64) -> f64 {
1300    TCG_TCB_REFERENCE_JD + (jd_tcg - TCG_TCB_REFERENCE_JD) * (1.0 - TT_TCG_RATE_L_G)
1301}
1302
1303/// Convert a TDB Julian Date to TCB using IAU 2006 Resolution B3.
1304#[must_use]
1305pub fn tdb_to_tcb_jd(jd_tdb: f64) -> f64 {
1306    TCG_TCB_REFERENCE_JD
1307        + (((jd_tdb - TCG_TCB_REFERENCE_JD) * SECONDS_PER_DAY - TDB_TCB_OFFSET_TDB0_S)
1308            / (1.0 - TDB_TCB_RATE_L_B))
1309            / SECONDS_PER_DAY
1310}
1311
1312/// Convert a TCB Julian Date to TDB using IAU 2006 Resolution B3.
1313#[must_use]
1314pub fn tcb_to_tdb_jd(jd_tcb: f64) -> f64 {
1315    TCG_TCB_REFERENCE_JD
1316        + (((jd_tcb - TCG_TCB_REFERENCE_JD) * SECONDS_PER_DAY) * (1.0 - TDB_TCB_RATE_L_B)
1317            + TDB_TCB_OFFSET_TDB0_S)
1318            / SECONDS_PER_DAY
1319}
1320
1321fn tcg_fraction_from_tt_split(jd_whole: f64, tt_fraction: f64) -> f64 {
1322    let elapsed_tt_days = (jd_whole - TCG_TCB_REFERENCE_JD) + tt_fraction;
1323    tt_fraction + elapsed_tt_days * TT_TCG_RATE_L_G / (1.0 - TT_TCG_RATE_L_G)
1324}
1325
1326fn tcb_fraction_from_tdb_split(jd_whole: f64, tdb_fraction: f64) -> f64 {
1327    let elapsed_tdb_days = (jd_whole - TCG_TCB_REFERENCE_JD) + tdb_fraction;
1328    tdb_fraction
1329        + (elapsed_tdb_days * TDB_TCB_RATE_L_B - TDB_TCB_OFFSET_TDB0_S / SECONDS_PER_DAY)
1330            / (1.0 - TDB_TCB_RATE_L_B)
1331}
1332
1333/// Civil calendar -> Julian day number (Fliegel-style, integer arithmetic).
1334pub fn julian_day_number(year: i32, month: i32, day: i32) -> i64 {
1335    let year = i64::from(year);
1336    let month = i64::from(month);
1337    let day = i64::from(day);
1338    let janfeb = month <= 2;
1339    let g = year + 4716 - if janfeb { 1 } else { 0 };
1340    let f = (month + 9) % 12;
1341    let e = 1461 * g / 4 + day - 1402;
1342    let j = e + (153 * f + 2) / 5;
1343    j + 38 - ((g + 184) / 100) * 3 / 4
1344}
1345
1346/// TAI-UTC (cumulative leap seconds) for a UTC Julian date.
1347///
1348/// For instants from 1972-01-01 (MJD 41317) onward this reads the embedded IERS
1349/// integer leap-second table, clamping above to the last entry exactly as the
1350/// original `orbis_nif` implementation did (parity-preserving). This post-1972
1351/// branch is byte-for-byte the original lookup and is never perturbed.
1352///
1353/// For instants in the 1961-01-01 .. 1972-01-01 rubber-second era it evaluates
1354/// the published piecewise-linear IERS/USNO model
1355/// `TAI-UTC = base + (MJD - ref_mjd) * rate` (see [`RUBBER_SECONDS`]) using the
1356/// fractional UTC MJD, so the offset is continuous within each segment as the
1357/// historical definition requires. Before 1961 it clamps to the first
1358/// rubber-second segment's value rather than extrapolating into undefined
1359/// territory. Non-finite inputs return `NaN`.
1360///
1361/// Boundary semantics (post-1972): the date is the integer MJD (`(jd_utc -
1362/// 2400000.5) as i32`), so the count steps at UTC midnight (the table's
1363/// effective MJD is the first full day under the new value). The inserted leap
1364/// second `23:59:60` and the following `00:00:00` share essentially the same
1365/// Julian date, so an end-of-day instant resolves to the **post-leap** count -
1366/// the leap second itself cannot be distinguished from the next day's start
1367/// through a JD. This is intrinsic to a JD-keyed lookup and is pinned to
1368/// `orbis_nif` for bit-exact parity; callers that must label `23:59:60`
1369/// distinctly have to carry the civil second out-of-band rather than rely on
1370/// this function.
1371pub fn find_leap_seconds(jd_utc: f64) -> f64 {
1372    if !jd_utc.is_finite() {
1373        return f64::NAN;
1374    }
1375    let mjd = (jd_utc - 2400000.5) as i32;
1376    if mjd >= LEAP_SECONDS[0].mjd {
1377        // Post-1972 integer leap-second table (unchanged, bit-identical).
1378        let mut ls = 10.0;
1379        for entry in LEAP_SECONDS {
1380            if mjd >= entry.mjd {
1381                ls = entry.tai_utc;
1382            } else {
1383                break;
1384            }
1385        }
1386        return ls;
1387    }
1388    rubber_tai_minus_utc(jd_utc)
1389}
1390
1391fn find_leap_seconds_in_table(jd_utc: f64, leap_seconds: &[LeapSecondEntry]) -> f64 {
1392    debug_assert!(!leap_seconds.is_empty());
1393    if !jd_utc.is_finite() {
1394        return f64::NAN;
1395    }
1396    let mjd = (jd_utc - 2400000.5) as i32;
1397    if mjd >= leap_seconds[0].mjd {
1398        let mut ls = leap_seconds[0].tai_utc;
1399        for entry in leap_seconds {
1400            if mjd >= entry.mjd {
1401                ls = entry.tai_utc;
1402            } else {
1403                break;
1404            }
1405        }
1406        return ls;
1407    }
1408    rubber_tai_minus_utc(jd_utc)
1409}
1410
1411fn find_leap_seconds_in_table_checked(
1412    jd_utc: f64,
1413    leap_seconds: &[LeapSecondEntry],
1414) -> Result<f64, CoverageError> {
1415    debug_assert!(!leap_seconds.is_empty());
1416    if !jd_utc.is_finite() {
1417        return Err(table_error(
1418            "leap_seconds",
1419            TimeScaleInputErrorKind::NonFinite,
1420        ));
1421    }
1422    let mjd = (jd_utc - 2400000.5) as i32;
1423    if mjd < leap_seconds[0].mjd {
1424        return Err(table_error(
1425            "leap_seconds",
1426            TimeScaleInputErrorKind::OutOfRange,
1427        ));
1428    }
1429
1430    let mut ls = leap_seconds[0].tai_utc;
1431    for entry in leap_seconds {
1432        if mjd >= entry.mjd {
1433            ls = entry.tai_utc;
1434        } else {
1435            break;
1436        }
1437    }
1438    Ok(ls)
1439}
1440
1441/// TAI - UTC (the full accumulated leap-second count) at a UTC Julian date.
1442///
1443/// This is the IERS / Bulletin C quantity: the difference between International
1444/// Atomic Time and Coordinated Universal Time. From 2017-01-01 onward it is
1445/// **37 s**. It is the unambiguously-named alias of [`find_leap_seconds`] and
1446/// returns the identical value.
1447///
1448/// This is **not** the GNSS "leap seconds since the GPS epoch" quantity. A GNSS
1449/// caller who wants GPS - UTC (18 s in 2017) must call [`gps_utc_offset_s`];
1450/// using this function there over-counts by `TAI - GPST = 19 s`.
1451///
1452/// See [`find_leap_seconds`] for the table, rubber-second, and boundary
1453/// semantics, which this function inherits verbatim.
1454pub fn tai_utc_offset_s(jd_utc: f64) -> f64 {
1455    find_leap_seconds(jd_utc)
1456}
1457
1458/// GPS - UTC (the GNSS leap-second offset since the GPS epoch) at a UTC Julian
1459/// date.
1460///
1461/// This is the IS-GPS-200 quantity broadcast in the navigation message: the
1462/// difference between GPS system time and UTC. From 2017-01-01 onward it is
1463/// **18 s**. By definition `GPST - TAI = -19 s` (equivalently
1464/// `TAI - GPST = +19 s`), so
1465/// `GPS-UTC = (TAI-UTC) + (GPST-TAI) = (TAI-UTC) - 19 s`,
1466/// i.e. `gps_utc_offset_s == tai_utc_offset_s - 19`.
1467///
1468/// Use this, not [`tai_utc_offset_s`], whenever you mean "the leap seconds a
1469/// GPS receiver applies"; the two differ by a constant 19 s and silently
1470/// returning TAI - UTC where GPS - UTC is expected is a 19 s blunder.
1471pub fn gps_utc_offset_s(jd_utc: f64) -> f64 {
1472    find_leap_seconds(jd_utc) - GPST_MINUS_TAI_S
1473}
1474
1475/// Evaluate the pre-1972 piecewise-linear TAI-UTC model at a UTC Julian date.
1476///
1477/// Selects the latest [`RUBBER_SECONDS`] segment whose `start_mjd` precedes the
1478/// instant's fractional MJD and applies `base + (MJD - ref_mjd) * rate`. Inputs
1479/// before the first segment (pre-1961) clamp to the first segment's constant
1480/// term. Non-finite inputs return `NaN`.
1481fn rubber_tai_minus_utc(jd_utc: f64) -> f64 {
1482    let mjd = jd_utc - 2400000.5;
1483    let first = &RUBBER_SECONDS[0];
1484    if !mjd.is_finite() {
1485        return f64::NAN;
1486    }
1487    // Pre-1961 input clamps to the first segment's constant.
1488    if mjd < first.start_mjd as f64 {
1489        return first.base;
1490    }
1491    let mut selected = first;
1492    for entry in RUBBER_SECONDS {
1493        if mjd >= entry.start_mjd as f64 {
1494            selected = entry;
1495        } else {
1496            break;
1497        }
1498    }
1499    selected.base + (mjd - selected.ref_mjd) * selected.rate
1500}
1501
1502/// Error returned by the inter-system time-scale offset helpers.
1503#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
1504pub enum TimeOffsetError {
1505    /// A fixed-offset query [`timescale_offset_s`] named a UTC-based scale
1506    /// (UTC or GLONASST) whose offset to the atomic scales depends on the
1507    /// instant's leap-second count. Use [`timescale_offset_at_s`] with an epoch.
1508    #[error(
1509        "time-scale {0} is UTC-based; its offset is epoch-dependent, use timescale_offset_at_s"
1510    )]
1511    EpochRequired(&'static str),
1512    /// The named coordinate scale has no fixed offset; resolve it through
1513    /// [`TimeScales`] or the scale-specific conversion helpers.
1514    #[error("time-scale {0} has no fixed/constant offset; resolve it through TimeScales")]
1515    Unsupported(&'static str),
1516    /// A leap-aware query received a non-finite UTC Julian date.
1517    #[error("utc_jd must be finite to resolve leap seconds for scale {0}")]
1518    NonFiniteEpoch(&'static str),
1519}
1520
1521/// Stable machine-readable discriminant for [`TimeOffsetError`].
1522///
1523/// The variants of [`TimeOffsetError`] carry only a human-facing `&'static str`,
1524/// which a C/FFI caller cannot branch on without parsing text. This `#[repr(u8)]`
1525/// code gives each variant a stable numeric tag (reachable across the FFI
1526/// boundary as `code() as u8`). The values are part of the public contract: `0`
1527/// is reserved for "no error", and an existing code is never renumbered.
1528#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1529#[repr(u8)]
1530pub enum TimeOffsetErrorCode {
1531    /// [`TimeOffsetError::EpochRequired`].
1532    EpochRequired = 1,
1533    /// [`TimeOffsetError::Unsupported`].
1534    Unsupported = 2,
1535    /// [`TimeOffsetError::NonFiniteEpoch`].
1536    NonFiniteEpoch = 3,
1537}
1538
1539impl TimeOffsetError {
1540    /// The stable machine-readable discriminant for this error.
1541    ///
1542    /// For C/FFI consumers that must distinguish the variants programmatically
1543    /// without parsing the [`Display`](core::fmt::Display) message. The numeric
1544    /// values (`error.code() as u8`) are a stable part of the public API.
1545    #[must_use]
1546    pub fn code(&self) -> TimeOffsetErrorCode {
1547        match self {
1548            Self::EpochRequired(_) => TimeOffsetErrorCode::EpochRequired,
1549            Self::Unsupported(_) => TimeOffsetErrorCode::Unsupported,
1550            Self::NonFiniteEpoch(_) => TimeOffsetErrorCode::NonFiniteEpoch,
1551        }
1552    }
1553}
1554
1555/// True for scales whose offset to TAI carries UTC leap seconds (UTC, GLONASST).
1556fn is_utc_based(scale: TimeScale) -> bool {
1557    matches!(scale, TimeScale::Utc | TimeScale::Glonasst)
1558}
1559
1560/// `scale_reading - TAI_reading` (seconds) for the same physical instant.
1561///
1562/// For the atomic scales (TAI/TT/GPST/GST/QZSST/BDT) this is a fixed constant
1563/// and `utc_jd` is ignored. For the UTC-based scales (UTC/GLONASST) it depends
1564/// on the leap-second count at `utc_jd`. TDB is rejected (epoch-dependent
1565/// periodic term, no fixed offset).
1566fn scale_minus_tai_s(scale: TimeScale, utc_jd: f64) -> Result<f64, TimeOffsetError> {
1567    let leap = |s: TimeScale| -> Result<f64, TimeOffsetError> {
1568        if !utc_jd.is_finite() {
1569            return Err(TimeOffsetError::NonFiniteEpoch(s.abbrev()));
1570        }
1571        Ok(find_leap_seconds(utc_jd))
1572    };
1573    Ok(match scale {
1574        TimeScale::Tai => 0.0,
1575        // TT = TAI + 32.184 s (IERS Conventions 2010, TT definition).
1576        TimeScale::Tt => TT_MINUS_TAI_S,
1577        // GPST = TAI - 19 s (IS-GPS-200, fixed since the 1980 GPS epoch).
1578        TimeScale::Gpst => -GPST_MINUS_TAI_S,
1579        // GST nominally = GPST (Galileo OS SIS ICD, sec. 5.1.3: GST is steered
1580        // to GPST; the real-time GGTO is a *broadcast* correction, not a fixed
1581        // constant). Nominal GST - TAI therefore equals GPST - TAI = -19 s.
1582        TimeScale::Gst => -GPST_MINUS_TAI_S,
1583        // QZSST nominally = GPST (IS-QZSS-PNT, sec. 3.2.2: synchronous with
1584        // GPST). Nominal QZSST - TAI = GPST - TAI = -19 s.
1585        TimeScale::Qzsst => -GPST_MINUS_TAI_S,
1586        // BDT = TAI - 33 s (BeiDou ICD, BDT epoch 2006-01-01, 14 leap seconds
1587        // behind GPST's 1980 epoch: GPST - BDT = 14 s, so BDT - TAI = -33 s).
1588        TimeScale::Bdt => -BDT_MINUS_TAI_S,
1589        // UTC = TAI - (TAI-UTC), leap-second dependent.
1590        TimeScale::Utc => -leap(scale)?,
1591        // GLONASST = UTC + 3 h = TAI - (TAI-UTC) + 3 h.
1592        TimeScale::Glonasst => -leap(scale)? + GLONASST_MINUS_UTC_S,
1593        TimeScale::Tcg | TimeScale::Tdb | TimeScale::Tcb => {
1594            return Err(TimeOffsetError::Unsupported(scale.abbrev()));
1595        }
1596    })
1597}
1598
1599/// Fixed inter-system offset `to_reading - from_reading` (seconds) for scales
1600/// whose mutual offset is a constant.
1601///
1602/// Returns the value that, added to a `from`-scale reading, yields the
1603/// `to`-scale reading of the same physical instant. This covers the atomic
1604/// scales TAI/TT/GPST/GST/QZSST/BDT, whose offsets are fixed by their defining
1605/// ICDs (see [`scale_minus_tai_s`] for the per-scale citations).
1606///
1607/// Returns [`TimeOffsetError::EpochRequired`] if either scale is UTC-based
1608/// (UTC/GLONASST), which need [`timescale_offset_at_s`], and
1609/// [`TimeOffsetError::Unsupported`] for TDB.
1610///
1611/// # Note on the brief's `(from, to)` signature
1612///
1613/// The original A1 brief specified `timescale_offset_s(from, to) -> Result<f64>`.
1614/// That signature cannot express the leap-aware GLONASST/UTC offsets (they need
1615/// an epoch), so this function keeps the no-epoch form for the fixed atomic
1616/// offsets and *errors* for UTC-based scales, while the leap-aware variant
1617/// [`timescale_offset_at_s`] takes an explicit epoch. The split makes the
1618/// epoch a compile-time-visible requirement rather than a silently-ignored arg.
1619pub fn timescale_offset_s(from: TimeScale, to: TimeScale) -> Result<f64, TimeOffsetError> {
1620    for scale in [from, to] {
1621        if is_utc_based(scale) {
1622            return Err(TimeOffsetError::EpochRequired(scale.abbrev()));
1623        }
1624    }
1625    // utc_jd is unused for the atomic scales reached here.
1626    timescale_offset_at_s(from, to, f64::NAN)
1627}
1628
1629/// Leap-aware inter-system offset `to_reading - from_reading` (seconds) at a
1630/// given UTC instant.
1631///
1632/// `utc_jd` is the UTC Julian date of the instant, used only to resolve the
1633/// leap-second count when `from` or `to` is UTC-based (UTC/GLONASST); for
1634/// purely atomic pairs it is ignored. Away from a leap-second boundary the
1635/// leap count is stable, so the exact scale of `utc_jd` is immaterial; within
1636/// the boundary window pass the UTC Julian date so the correct count is picked.
1637///
1638/// The result, added to a `from`-scale reading, yields the `to`-scale reading
1639/// of the same physical instant. TDB is rejected (see [`TimeOffsetError`]).
1640pub fn timescale_offset_at_s(
1641    from: TimeScale,
1642    to: TimeScale,
1643    utc_jd: f64,
1644) -> Result<f64, TimeOffsetError> {
1645    Ok(scale_minus_tai_s(to, utc_jd)? - scale_minus_tai_s(from, utc_jd)?)
1646}
1647
1648/// Provenance + coverage descriptor for the embedded leap-second table.
1649///
1650/// Exposed so a precision pipeline can interrogate table coverage and apply
1651/// strict-vs-permissive policy (see [`crate::astro::time::eop`]).
1652pub fn leap_second_table() -> LeapSecondTable {
1653    leap_second_table_for(
1654        LEAP_SECONDS,
1655        "IERS Bulletin C (TAI-UTC), bundled in sidereon-core",
1656    )
1657}
1658
1659fn leap_second_table_for(
1660    leap_seconds: &[LeapSecondEntry],
1661    source: &'static str,
1662) -> LeapSecondTable {
1663    LeapSecondTable {
1664        source,
1665        first_mjd: leap_seconds.first().map(|e| e.mjd).unwrap_or(0),
1666        last_mjd: leap_seconds.last().map(|e| e.mjd).unwrap_or(0),
1667        entries: leap_seconds.len(),
1668    }
1669}
1670
1671fn interpolate_delta_t(jd_tt: f64) -> f64 {
1672    // Build delta-T table on first call (matching C++ lazy static pattern).
1673    use std::sync::LazyLock;
1674
1675    struct DeltaTRow {
1676        jd_tt: f64,
1677        delta_t: f64,
1678    }
1679
1680    static TABLE: LazyLock<Vec<DeltaTRow>> = LazyLock::new(|| {
1681        UT1_DATA
1682            .iter()
1683            .map(|entry| {
1684                let jd_utc = entry.mjd as f64 + 2400000.5;
1685                let leap_seconds = find_leap_seconds(jd_utc);
1686                let tt_minus_utc = leap_seconds + TT_MINUS_TAI_S;
1687                let delta_t = ((tt_minus_utc - entry.ut1_utc) * ROUND_1E7).round() / ROUND_1E7;
1688                DeltaTRow {
1689                    jd_tt: jd_utc + tt_minus_utc / SECONDS_PER_DAY,
1690                    delta_t,
1691                }
1692            })
1693            .collect()
1694    });
1695
1696    // Binary search for the bracketing entries.
1697    match TABLE.binary_search_by(|row| row.jd_tt.partial_cmp(&jd_tt).unwrap()) {
1698        Ok(i) => TABLE[i].delta_t,
1699        Err(0) => TABLE[0].delta_t,
1700        Err(i) if i >= TABLE.len() => TABLE.last().unwrap().delta_t,
1701        Err(i) => {
1702            let p1 = &TABLE[i - 1];
1703            let p2 = &TABLE[i];
1704            p1.delta_t + (jd_tt - p1.jd_tt) * (p2.delta_t - p1.delta_t) / (p2.jd_tt - p1.jd_tt)
1705        }
1706    }
1707}
1708
1709fn interpolate_delta_t_with_table(
1710    jd_tt: f64,
1711    ut1_utc: &[Ut1Entry],
1712    leap_seconds: &[LeapSecondEntry],
1713) -> Result<f64, CoverageError> {
1714    struct DeltaTRow {
1715        jd_tt: f64,
1716        delta_t: f64,
1717    }
1718
1719    let table: Vec<DeltaTRow> = effective_ut1_utc_rows(ut1_utc, leap_seconds)
1720        .iter()
1721        .map(|entry| {
1722            let jd_utc = entry.mjd as f64 + 2400000.5;
1723            let leap_seconds = find_leap_seconds_in_table_checked(jd_utc, leap_seconds)?;
1724            let tt_minus_utc = leap_seconds + TT_MINUS_TAI_S;
1725            let delta_t = ((tt_minus_utc - entry.ut1_utc) * ROUND_1E7).round() / ROUND_1E7;
1726            Ok(DeltaTRow {
1727                jd_tt: jd_utc + tt_minus_utc / SECONDS_PER_DAY,
1728                delta_t,
1729            })
1730        })
1731        .collect::<Result<_, CoverageError>>()?;
1732
1733    let delta_t = match table.binary_search_by(|row| row.jd_tt.partial_cmp(&jd_tt).unwrap()) {
1734        Ok(i) => table[i].delta_t,
1735        Err(0) => table[0].delta_t,
1736        Err(i) if i >= table.len() => table.last().unwrap().delta_t,
1737        Err(i) => {
1738            let p1 = &table[i - 1];
1739            let p2 = &table[i];
1740            p1.delta_t + (jd_tt - p1.jd_tt) * (p2.delta_t - p1.delta_t) / (p2.jd_tt - p1.jd_tt)
1741        }
1742    };
1743    Ok(delta_t)
1744}
1745
1746/// UT1 coverage interval for the embedded EOP table, in TT Julian dates.
1747///
1748/// Outside this interval the delta-T interpolation clamps to the nearest table
1749/// edge (parity-preserving Skyfield behaviour). Strict-mode callers should treat
1750/// instants outside `[first_jd_tt, last_jd_tt]` as degraded; see
1751/// [`crate::astro::time::eop`].
1752pub fn ut1_coverage() -> Ut1Provenance {
1753    ut1_coverage_for(
1754        &UT1_DATA,
1755        LEAP_SECONDS,
1756        "IERS Earth Orientation Parameters (UT1-UTC), bundled",
1757    )
1758}
1759
1760fn ut1_coverage_for(
1761    ut1_utc: &[Ut1Entry],
1762    leap_seconds: &[LeapSecondEntry],
1763    source: &'static str,
1764) -> Ut1Provenance {
1765    let ut1_utc = effective_ut1_utc_rows(ut1_utc, leap_seconds);
1766    let first = ut1_utc.first();
1767    let last = ut1_utc.last();
1768    let to_jd_tt = |mjd: i32| -> f64 {
1769        let jd_utc = mjd as f64 + 2400000.5;
1770        let tt_minus_utc = find_leap_seconds_in_table_checked(jd_utc, leap_seconds)
1771            .expect("effective UT1 rows are covered by leap table")
1772            + TT_MINUS_TAI_S;
1773        jd_utc + tt_minus_utc / SECONDS_PER_DAY
1774    };
1775    Ut1Provenance {
1776        source,
1777        first_mjd: first.map(|e| e.mjd).unwrap_or(0),
1778        last_mjd: last.map(|e| e.mjd).unwrap_or(0),
1779        first_jd_tt: first.map(|e| to_jd_tt(e.mjd)).unwrap_or(0.0),
1780        last_jd_tt: last.map(|e| to_jd_tt(e.mjd)).unwrap_or(0.0),
1781        entries: ut1_utc.len(),
1782    }
1783}
1784
1785#[cfg(test)]
1786mod tests {
1787    use super::*;
1788
1789    #[test]
1790    fn julian_day_number_widens_extreme_inputs_before_arithmetic() {
1791        let _ = julian_day_number(i32::MIN, i32::MAX, i32::MAX);
1792        let _ = julian_day_number(i32::MAX, i32::MIN, i32::MIN);
1793    }
1794
1795    /// UTC Julian date for a calendar instant, built exactly as the parity path
1796    /// (`from_utc_unchecked`) does, so the embedded leap table is queried at the
1797    /// same instant the production code would.
1798    fn utc_jd(year: i32, month: i32, day: i32, hour: i32, minute: i32, second: f64) -> f64 {
1799        let jd1 = julian_day_number(year, month, day) as f64 - 0.5;
1800        let sod = hour as f64 * SECONDS_PER_HOUR + minute as f64 * SECONDS_PER_MINUTE + second;
1801        jd1 + sod / SECONDS_PER_DAY
1802    }
1803
1804    fn positive_ulp_distance(a: f64, b: f64) -> u64 {
1805        debug_assert!(a.is_sign_positive());
1806        debug_assert!(b.is_sign_positive());
1807        a.to_bits().abs_diff(b.to_bits())
1808    }
1809
1810    // --- IAU TCG/TCB linear definitions. -------------------------------------
1811
1812    #[test]
1813    fn iau_tcg_tcb_constants_are_exact_f64_bits() {
1814        assert_eq!(TT_TCG_RATE_L_G.to_bits(), 0x3e07_f240_9f5d_dc8f);
1815        assert_eq!(TDB_TCB_RATE_L_B.to_bits(), 0x3e50_a609_49f9_cf0c);
1816        assert_eq!(TDB_TCB_OFFSET_TDB0_S.to_bits(), 0xbf11_2ba1_6e7a_311f);
1817        assert_eq!(TCG_TCB_REFERENCE_JD.to_bits(), 0x4142_a3c4_400c_34c2);
1818        assert_eq!(TT_TCG_RATE_L_G, 6.969290134e-10);
1819        assert_eq!(TDB_TCB_RATE_L_B, 1.550519768e-8);
1820        assert_eq!(TDB_TCB_OFFSET_TDB0_S, -6.55e-5);
1821        assert_eq!(TCG_TCB_REFERENCE_JD, 2_443_144.500_372_5);
1822    }
1823
1824    #[test]
1825    fn tcg_tcb_linear_conversions_round_trip_to_one_ulp() {
1826        let cases = [
1827            TCG_TCB_REFERENCE_JD,
1828            J2000_JD,
1829            2_460_000.5,
1830            2_443_145.5,
1831            2_443_144.500_372_5 + 1_000.0,
1832            2_460_123.456_789,
1833            2_400_014.327_160_368,
1834            2_400_015.561_728_258,
1835        ];
1836        for jd in cases {
1837            let tcg = tt_to_tcg_jd(jd);
1838            let tt_back = tcg_to_tt_jd(tcg);
1839            assert!(
1840                positive_ulp_distance(tt_back, jd) <= 1,
1841                "TT->TCG->TT round trip at JD {jd}: got {tt_back}"
1842            );
1843
1844            let tcb = tdb_to_tcb_jd(jd);
1845            let tdb_back = tcb_to_tdb_jd(tcb);
1846            assert!(
1847                positive_ulp_distance(tdb_back, jd) <= 1,
1848                "TDB->TCB->TDB round trip at JD {jd}: got {tdb_back}"
1849            );
1850        }
1851    }
1852
1853    #[test]
1854    fn tcg_reference_epoch_is_synchronized_and_tcb_carries_tdb0_before_jd_rounding() {
1855        assert_eq!(
1856            tt_to_tcg_jd(TCG_TCB_REFERENCE_JD).to_bits(),
1857            TCG_TCB_REFERENCE_JD.to_bits()
1858        );
1859        assert_eq!(
1860            tcg_to_tt_jd(TCG_TCB_REFERENCE_JD).to_bits(),
1861            TCG_TCB_REFERENCE_JD.to_bits()
1862        );
1863
1864        let tdb_offset_at_reference_s = (TCG_TCB_REFERENCE_JD - TCG_TCB_REFERENCE_JD)
1865            * SECONDS_PER_DAY
1866            * (1.0 - TDB_TCB_RATE_L_B)
1867            + TDB_TCB_OFFSET_TDB0_S;
1868        assert_eq!(
1869            tdb_offset_at_reference_s.to_bits(),
1870            TDB_TCB_OFFSET_TDB0_S.to_bits()
1871        );
1872
1873        let tdb_at_tcb_reference = tcb_to_tdb_jd(TCG_TCB_REFERENCE_JD);
1874        let rounded_full_jd = TCG_TCB_REFERENCE_JD + TDB_TCB_OFFSET_TDB0_S / SECONDS_PER_DAY;
1875        assert_eq!(tdb_at_tcb_reference.to_bits(), rounded_full_jd.to_bits());
1876        assert_eq!(tdb_at_tcb_reference.to_bits(), 0x4142_a3c4_400c_34c0);
1877        assert_eq!(
1878            tdb_to_tcb_jd(tdb_at_tcb_reference).to_bits(),
1879            TCG_TCB_REFERENCE_JD.to_bits()
1880        );
1881    }
1882
1883    #[test]
1884    fn tai_defining_epoch_maps_tt_and_tcg_to_reference_jd() {
1885        let scales = TimeScales::from_scale(TimeScale::Tai, 1977, 1, 1, 0, 0, 0.0)
1886            .expect("valid TAI reference instant");
1887        assert_eq!(scales.jd_tt.to_bits(), TCG_TCB_REFERENCE_JD.to_bits());
1888        assert_eq!(scales.jd_tcg().to_bits(), TCG_TCB_REFERENCE_JD.to_bits());
1889    }
1890
1891    #[test]
1892    fn tcb_calendar_reference_input_resolves_to_tt_reference_jd() {
1893        let scales = TimeScales::from_scale(TimeScale::Tcb, 1977, 1, 1, 0, 0, 32.184)
1894            .expect("valid TCB reference instant");
1895        assert_eq!(scales.jd_tt.to_bits(), TCG_TCB_REFERENCE_JD.to_bits());
1896        assert_eq!(scales.jd_tcg().to_bits(), TCG_TCB_REFERENCE_JD.to_bits());
1897    }
1898
1899    #[test]
1900    fn tdb_calendar_input_uses_periodic_tdb_tt_inverse() {
1901        let tdb_jd = J2000_JD;
1902        let tt_jd = tdb_to_tt_jd_for_tdb_input(tdb_jd);
1903        let reconstructed_tdb = tt_jd + tdb_minus_tt_seconds_at_tt_jd(tt_jd) / SECONDS_PER_DAY;
1904        assert_eq!(reconstructed_tdb.to_bits(), tdb_jd.to_bits());
1905
1906        let scales = TimeScales::from_scale(TimeScale::Tdb, 2000, 1, 1, 12, 0, 0.0)
1907            .expect("valid TDB input");
1908        assert_eq!(scales.jd_tdb.to_bits(), tdb_jd.to_bits());
1909    }
1910
1911    #[test]
1912    fn tcg_tcb_fractions_use_split_affine_relations() {
1913        let scales = TimeScales::from_utc(2000, 1, 1, 12, 0, 0.0).expect("valid UTC instant");
1914
1915        assert_eq!(scales.tcg_fraction().to_bits(), 0x3f48_88c2_8751_43f2);
1916        assert_ne!(
1917            scales.tcg_fraction().to_bits(),
1918            (scales.jd_tcg() - scales.jd_whole).to_bits()
1919        );
1920
1921        assert_eq!(scales.tcb_fraction().to_bits(), 0x3f4c_9c46_0494_33ba);
1922        assert_ne!(
1923            scales.tcb_fraction().to_bits(),
1924            (scales.jd_tcb() - scales.jd_whole).to_bits()
1925        );
1926    }
1927
1928    #[test]
1929    fn from_scale_rejects_continuous_scale_leap_second_labels_before_normalizing() {
1930        let expected = Err(CoverageError::InvalidInput {
1931            field: "civil datetime",
1932            kind: TimeScaleInputErrorKind::InvalidCivilTime,
1933        });
1934        for scale in [
1935            TimeScale::Tai,
1936            TimeScale::Tt,
1937            TimeScale::Tcg,
1938            TimeScale::Tdb,
1939            TimeScale::Tcb,
1940            TimeScale::Gpst,
1941            TimeScale::Gst,
1942            TimeScale::Bdt,
1943            TimeScale::Qzsst,
1944        ] {
1945            assert_eq!(
1946                TimeScales::from_scale(scale, 2017, 1, 1, 0, 0, 60.0),
1947                expected
1948            );
1949        }
1950        assert!(TimeScales::from_scale(TimeScale::Utc, 2016, 12, 31, 23, 59, 60.0).is_ok());
1951
1952        let tables = TimeTables::embedded();
1953        assert_eq!(
1954            TimeScales::from_scale_with_tables(TimeScale::Tcb, 2017, 1, 1, 0, 0, 60.0, tables),
1955            expected
1956        );
1957    }
1958
1959    #[test]
1960    fn embedded_tables_path_is_bit_identical() {
1961        let tables = TimeTables::embedded();
1962        for (year, month, day, hour, minute, second) in [
1963            (1973, 1, 2, 0, 0, 0.0),
1964            (2000, 1, 1, 12, 0, 0.0),
1965            (2016, 12, 31, 23, 59, 60.0),
1966            (2026, 6, 1, 0, 0, 0.0),
1967        ] {
1968            let embedded = TimeScales::from_utc(year, month, day, hour, minute, second)
1969                .expect("embedded UTC conversion");
1970            let via_tables =
1971                TimeScales::from_utc_with_tables(year, month, day, hour, minute, second, tables)
1972                    .expect("table UTC conversion");
1973            assert_eq!(via_tables, embedded);
1974        }
1975    }
1976
1977    #[test]
1978    fn caller_leap_table_future_step_shifts_tt_by_exactly_one_second() {
1979        let mut leap_seconds = LEAP_SECONDS.to_vec();
1980        let last = leap_seconds.last().expect("embedded leap table");
1981        leap_seconds.push(LeapSecondEntry {
1982            mjd: 61041,
1983            tai_utc: last.tai_utc + 1.0,
1984        });
1985        let tables = TimeTables::new(&leap_seconds, &UT1_DATA).expect("valid override tables");
1986
1987        let embedded =
1988            TimeScales::from_utc(2026, 1, 2, 0, 0, 0.0).expect("embedded UTC conversion");
1989        let override_scales = TimeScales::from_utc_with_tables(2026, 1, 2, 0, 0, 0.0, tables)
1990            .expect("override UTC conversion");
1991        assert_eq!(
1992            override_scales.jd_whole.to_bits(),
1993            embedded.jd_whole.to_bits()
1994        );
1995        assert_eq!(
1996            override_scales.tt_fraction.to_bits(),
1997            (embedded.tt_fraction + 1.0 / SECONDS_PER_DAY).to_bits()
1998        );
1999
2000        let before_embedded =
2001            TimeScales::from_utc(2025, 12, 1, 0, 0, 0.0).expect("embedded UTC conversion");
2002        let before_override = TimeScales::from_utc_with_tables(2025, 12, 1, 0, 0, 0.0, tables)
2003            .expect("override UTC conversion");
2004        assert_eq!(before_override, before_embedded);
2005    }
2006
2007    #[test]
2008    fn caller_leap_table_must_cover_queried_epoch() {
2009        let leap_seconds = [LeapSecondEntry {
2010            mjd: 61041,
2011            tai_utc: 38.0,
2012        }];
2013        let tables = TimeTables::new(&leap_seconds, &UT1_DATA).expect("valid future tables");
2014
2015        let err = TimeScales::from_utc_with_tables(2025, 12, 31, 0, 0, 0.0, tables)
2016            .expect_err("caller leap table must cover the query epoch");
2017        assert_eq!(
2018            err,
2019            CoverageError::InvalidInput {
2020                field: "leap_seconds",
2021                kind: TimeScaleInputErrorKind::OutOfRange
2022            }
2023        );
2024
2025        let err = TimeScales::from_utc_validated_with_tables(
2026            2025,
2027            12,
2028            31,
2029            0,
2030            0,
2031            0.0,
2032            ValidityMode::Permissive,
2033            tables,
2034        )
2035        .expect_err("permissive mode still requires leap table coverage");
2036        assert_eq!(
2037            err,
2038            CoverageError::InvalidInput {
2039                field: "leap_seconds",
2040                kind: TimeScaleInputErrorKind::OutOfRange
2041            }
2042        );
2043    }
2044
2045    #[test]
2046    fn caller_ut1_table_uses_validated_coverage_modes() {
2047        let ut1_utc = [
2048            Ut1Entry {
2049                mjd: 61041,
2050                ut1_utc: 0.0,
2051            },
2052            Ut1Entry {
2053                mjd: 61042,
2054                ut1_utc: 0.0,
2055            },
2056        ];
2057        let tables = TimeTables::new(LEAP_SECONDS, &ut1_utc).expect("valid short UT1 table");
2058
2059        let strict = TimeScales::from_utc_with_tables(2025, 12, 31, 0, 0, 0.0, tables)
2060            .expect_err("strict caller-table path must reject before UT1 coverage");
2061        assert_eq!(
2062            strict,
2063            CoverageError::OutsideCoverage(crate::astro::time::eop::DegradeReason::BeforeCoverage)
2064        );
2065
2066        let permissive = TimeScales::from_utc_validated_with_tables(
2067            2025,
2068            12,
2069            31,
2070            0,
2071            0,
2072            0.0,
2073            ValidityMode::Permissive,
2074            tables,
2075        )
2076        .expect("permissive caller-table path returns degraded value");
2077        assert_eq!(
2078            permissive.degraded,
2079            Some(crate::astro::time::eop::DegradeReason::BeforeCoverage)
2080        );
2081
2082        let strict_after = TimeScales::from_utc_validated_with_tables(
2083            2026,
2084            1,
2085            3,
2086            0,
2087            0,
2088            0.0,
2089            ValidityMode::Strict,
2090            tables,
2091        )
2092        .expect_err("strict caller-table path must reject after UT1 coverage");
2093        assert_eq!(
2094            strict_after,
2095            CoverageError::OutsideCoverage(crate::astro::time::eop::DegradeReason::AfterCoverage)
2096        );
2097    }
2098
2099    #[test]
2100    fn caller_tables_reject_short_or_malformed_inputs() {
2101        assert_eq!(
2102            TimeTables::new(&[], &UT1_DATA).expect_err("empty leap table"),
2103            CoverageError::InvalidInput {
2104                field: "leap_seconds",
2105                kind: TimeScaleInputErrorKind::Missing
2106            }
2107        );
2108        let unsorted = [
2109            LeapSecondEntry {
2110                mjd: 41317,
2111                tai_utc: 10.0,
2112            },
2113            LeapSecondEntry {
2114                mjd: 41317,
2115                tai_utc: 11.0,
2116            },
2117        ];
2118        assert_eq!(
2119            TimeTables::new(&unsorted, &UT1_DATA).expect_err("unsorted leap table"),
2120            CoverageError::InvalidInput {
2121                field: "leap_seconds",
2122                kind: TimeScaleInputErrorKind::OutOfRange
2123            }
2124        );
2125        assert_eq!(
2126            TimeTables::new(LEAP_SECONDS, &[]).expect_err("short UT1 table"),
2127            CoverageError::InvalidInput {
2128                field: "ut1_utc",
2129                kind: TimeScaleInputErrorKind::Missing
2130            }
2131        );
2132    }
2133
2134    // --- Pre-1972 rubber-second UTC-TAI model. --------------------------------
2135
2136    #[test]
2137    fn tai_minus_utc_pre_1972_matches_published_table() {
2138        // Published IERS/USNO TAI-UTC values (tai-utc.dat) for the rubber-second
2139        // era, TAI-UTC = base + (MJD - ref) * rate, evaluated at UTC midnight.
2140        let cases = [
2141            // (year, month, day, published TAI-UTC seconds)
2142            (1961, 1, 1, 1.4228180), // segment start MJD 37300
2143            (1965, 1, 1, 3.5401300), // segment start MJD 38761
2144            (1968, 2, 1, 6.1856820), // MJD 39887: 4.2131700 + 761*0.002592
2145            (1971, 1, 1, 8.9461620), // MJD 40952: 4.2131700 + 1826*0.002592
2146        ];
2147        for (y, m, d, want) in cases {
2148            let jd = utc_jd(y, m, d, 0, 0, 0.0);
2149            let got = find_leap_seconds(jd);
2150            assert!(
2151                (got - want).abs() < 1.0e-7,
2152                "TAI-UTC at {y}-{m:02}-{d:02}: got {got}, want {want}"
2153            );
2154        }
2155    }
2156
2157    #[test]
2158    fn tai_minus_utc_pre_1972_is_continuous_within_a_segment() {
2159        // The rubber second drifts linearly: noon must sit half a day's rate
2160        // above midnight inside the 1968 segment (rate 0.002592 s/day).
2161        let midnight = find_leap_seconds(utc_jd(1969, 6, 1, 0, 0, 0.0));
2162        let noon = find_leap_seconds(utc_jd(1969, 6, 1, 12, 0, 0.0));
2163        assert!(
2164            (noon - midnight - 0.5 * 0.002592).abs() < 1.0e-9,
2165            "rubber-second drift over half a day must equal 0.5*rate"
2166        );
2167    }
2168
2169    #[test]
2170    fn tai_minus_utc_steps_to_ten_at_1972_and_post_1972_unchanged() {
2171        // The famous final rubber-second value just before the 1972 step.
2172        let pre = find_leap_seconds(utc_jd(1971, 12, 31, 0, 0, 0.0));
2173        assert!((pre - 9.8896500).abs() < 1.0e-7, "1971-12-31 TAI-UTC");
2174        // 1972-01-01 is the first integer leap-second entry: exactly 10 s.
2175        assert_eq!(find_leap_seconds(utc_jd(1972, 1, 1, 0, 0, 0.0)), 10.0);
2176        // Post-1972 integer table is untouched (bit-identical goldens).
2177        assert_eq!(find_leap_seconds(utc_jd(1980, 1, 1, 0, 0, 0.0)), 19.0);
2178        assert_eq!(find_leap_seconds(utc_jd(2017, 1, 1, 0, 0, 0.0)), 37.0);
2179    }
2180
2181    #[test]
2182    fn tai_utc_and_gps_utc_offsets_match_iers_and_is_gps_200() {
2183        // 2017-01-01 onward: IERS Bulletin C TAI-UTC = 37 s; IS-GPS-200
2184        // GPS-UTC = 18 s; the two differ by the fixed GPST-TAI = 19 s.
2185        let jd_2017 = utc_jd(2017, 1, 1, 0, 0, 0.0);
2186        assert_eq!(tai_utc_offset_s(jd_2017), 37.0);
2187        assert_eq!(gps_utc_offset_s(jd_2017), 18.0);
2188        assert_eq!(tai_utc_offset_s(jd_2017) - gps_utc_offset_s(jd_2017), 19.0);
2189
2190        // The named alias must equal the historical accessor bit-for-bit, and the
2191        // GPS offset must track it minus 19 s across the whole integer table.
2192        for (y, m, d) in [(1980, 1, 1), (2000, 1, 1), (2009, 1, 1), (2017, 1, 1)] {
2193            let jd = utc_jd(y, m, d, 0, 0, 0.0);
2194            assert_eq!(
2195                tai_utc_offset_s(jd).to_bits(),
2196                find_leap_seconds(jd).to_bits()
2197            );
2198            assert_eq!(gps_utc_offset_s(jd), find_leap_seconds(jd) - 19.0);
2199        }
2200
2201        // Cross-check GPS-UTC against the leap-aware UTC->GPST offset, which is
2202        // independently validated against RTKLIB.
2203        assert_eq!(
2204            gps_utc_offset_s(jd_2017),
2205            timescale_offset_at_s(TimeScale::Utc, TimeScale::Gpst, jd_2017)
2206                .expect("leap-aware offset")
2207        );
2208    }
2209
2210    #[test]
2211    fn tai_minus_utc_pre_1961_clamps_to_first_segment_and_nonfinite_is_nan() {
2212        // Before the table, clamp to the first defined 1961 value.
2213        assert_eq!(find_leap_seconds(utc_jd(1958, 1, 1, 0, 0, 0.0)), 1.4228180);
2214        assert!(find_leap_seconds(f64::NAN).is_nan());
2215        assert!(find_leap_seconds(f64::INFINITY).is_nan());
2216    }
2217
2218    // --- Fixed atomic-scale offsets (hex-float goldens). ----------------------
2219    //
2220    // Goldens are exact f64 bit patterns. `timescale_offset_s(from, to)` returns
2221    // `to_reading - from_reading`, i.e. the value added to a `from` reading to
2222    // obtain the `to` reading of the same instant.
2223
2224    #[test]
2225    fn offset_gpst_to_bdt_is_minus_14s() {
2226        // BeiDou ICD: BDT = GPST - 14 s. Golden: -14.0.
2227        let want = f64::from_bits(0xc02c_0000_0000_0000);
2228        assert_eq!(
2229            timescale_offset_s(TimeScale::Gpst, TimeScale::Bdt).expect("fixed offset"),
2230            want
2231        );
2232        assert_eq!(want, -14.0);
2233    }
2234
2235    #[test]
2236    fn offset_bdt_to_gpst_is_plus_14s() {
2237        assert_eq!(
2238            timescale_offset_s(TimeScale::Bdt, TimeScale::Gpst).expect("fixed offset"),
2239            14.0
2240        );
2241    }
2242
2243    #[test]
2244    fn offset_gpst_to_gst_is_nominal_zero() {
2245        // Galileo OS SIS ICD: GST steered to GPST; nominal GGTO = 0 (the live
2246        // GGTO is a broadcast correction, not represented here).
2247        assert_eq!(
2248            timescale_offset_s(TimeScale::Gpst, TimeScale::Gst).expect("fixed offset"),
2249            0.0
2250        );
2251    }
2252
2253    #[test]
2254    fn offset_gpst_to_qzsst_is_nominal_zero() {
2255        // IS-QZSS-PNT: QZSST synchronous with GPST; nominal offset = 0.
2256        assert_eq!(
2257            timescale_offset_s(TimeScale::Gpst, TimeScale::Qzsst).expect("fixed offset"),
2258            0.0
2259        );
2260        assert_eq!(
2261            timescale_offset_s(TimeScale::Gst, TimeScale::Qzsst).expect("fixed offset"),
2262            0.0
2263        );
2264    }
2265
2266    #[test]
2267    fn offset_tai_to_tt_is_32_184s() {
2268        // IERS Conventions: TT = TAI + 32.184 s. Golden: exact bits of 32.184.
2269        let want = f64::from_bits(0x4040_178d_4fdf_3b64);
2270        assert_eq!(
2271            timescale_offset_s(TimeScale::Tai, TimeScale::Tt).expect("fixed offset"),
2272            want
2273        );
2274        assert_eq!(want, 32.184);
2275    }
2276
2277    #[test]
2278    fn offset_gpst_to_tt_is_51_184s() {
2279        // TT - GPST = (TAI + 32.184) - (TAI - 19) = 51.184 s. Golden: exact bits.
2280        let want = f64::from_bits(0x4049_978d_4fdf_3b64);
2281        assert_eq!(
2282            timescale_offset_s(TimeScale::Gpst, TimeScale::Tt).expect("fixed offset"),
2283            want
2284        );
2285        assert_eq!(want, 51.184);
2286    }
2287
2288    #[test]
2289    fn offset_gpst_to_tai_is_plus_19s() {
2290        // IS-GPS-200: GPST = TAI - 19 s, so TAI - GPST = +19 s.
2291        assert_eq!(
2292            timescale_offset_s(TimeScale::Gpst, TimeScale::Tai).expect("fixed offset"),
2293            19.0
2294        );
2295    }
2296
2297    #[test]
2298    fn fixed_offsets_are_antisymmetric_for_atomic_pairs() {
2299        let atomic = [
2300            TimeScale::Tai,
2301            TimeScale::Tt,
2302            TimeScale::Gpst,
2303            TimeScale::Gst,
2304            TimeScale::Qzsst,
2305            TimeScale::Bdt,
2306        ];
2307        for &a in &atomic {
2308            for &b in &atomic {
2309                let ab = timescale_offset_s(a, b).expect("fixed offset");
2310                let ba = timescale_offset_s(b, a).expect("fixed offset");
2311                assert_eq!(ab, -ba, "offset({a:?},{b:?}) must be -offset({b:?},{a:?})");
2312            }
2313        }
2314    }
2315
2316    // --- Error cases. ---------------------------------------------------------
2317
2318    #[test]
2319    fn fixed_offset_requires_epoch_for_utc_based_scales() {
2320        assert_eq!(
2321            timescale_offset_s(TimeScale::Gpst, TimeScale::Utc),
2322            Err(TimeOffsetError::EpochRequired("UTC"))
2323        );
2324        assert_eq!(
2325            timescale_offset_s(TimeScale::Glonasst, TimeScale::Gpst),
2326            Err(TimeOffsetError::EpochRequired("GLONASST"))
2327        );
2328    }
2329
2330    #[test]
2331    fn tdb_has_no_fixed_offset() {
2332        assert_eq!(
2333            timescale_offset_s(TimeScale::Gpst, TimeScale::Tdb),
2334            Err(TimeOffsetError::Unsupported("TDB"))
2335        );
2336        assert_eq!(
2337            timescale_offset_at_s(TimeScale::Tt, TimeScale::Tdb, 2_451_545.0),
2338            Err(TimeOffsetError::Unsupported("TDB"))
2339        );
2340    }
2341
2342    #[test]
2343    fn leap_aware_offset_rejects_non_finite_epoch() {
2344        assert_eq!(
2345            timescale_offset_at_s(TimeScale::Gpst, TimeScale::Utc, f64::NAN),
2346            Err(TimeOffsetError::NonFiniteEpoch("UTC"))
2347        );
2348        assert_eq!(
2349            timescale_offset_at_s(TimeScale::Glonasst, TimeScale::Gpst, f64::INFINITY),
2350            Err(TimeOffsetError::NonFiniteEpoch("GLONASST"))
2351        );
2352    }
2353
2354    #[test]
2355    fn error_code_maps_each_variant_to_stable_discriminant() {
2356        assert_eq!(
2357            TimeOffsetError::EpochRequired("UTC").code(),
2358            TimeOffsetErrorCode::EpochRequired
2359        );
2360        assert_eq!(
2361            TimeOffsetError::Unsupported("TDB").code(),
2362            TimeOffsetErrorCode::Unsupported
2363        );
2364        assert_eq!(
2365            TimeOffsetError::NonFiniteEpoch("UTC").code(),
2366            TimeOffsetErrorCode::NonFiniteEpoch
2367        );
2368        // The repr(u8) values are the stable FFI contract: 0 is reserved for
2369        // "no error", so the codes start at 1 and never collide.
2370        assert_eq!(TimeOffsetErrorCode::EpochRequired as u8, 1);
2371        assert_eq!(TimeOffsetErrorCode::Unsupported as u8, 2);
2372        assert_eq!(TimeOffsetErrorCode::NonFiniteEpoch as u8, 3);
2373        // The code is independent of the payload string.
2374        assert_eq!(
2375            TimeOffsetError::EpochRequired("GLONASST").code() as u8,
2376            TimeOffsetError::EpochRequired("UTC").code() as u8
2377        );
2378    }
2379
2380    #[test]
2381    fn leap_aware_offset_ignores_epoch_for_atomic_pairs() {
2382        // Atomic pair never touches the leap table, so a NaN epoch is harmless.
2383        assert_eq!(
2384            timescale_offset_at_s(TimeScale::Gpst, TimeScale::Bdt, f64::NAN)
2385                .expect("atomic pair ignores epoch"),
2386            -14.0
2387        );
2388    }
2389
2390    // --- Leap-aware GPST<->UTC<->GLONASST, validated against RTKLIB. -----------
2391    //
2392    // RTKLIB (gpst2utc/utc2gpst + the +3 h GLONASST advance) reports, at the
2393    // listed UTC instants:
2394    //   2017-01-01 00:00:00  GPST-UTC=+18  UTC-GPST=-18  GLONASST-GPST=+10782
2395    //   2016-12-31 23:59:59  GPST-UTC=+17  UTC-GPST=-17  GLONASST-GPST=+10783
2396    //   2000-01-01 12:00:00  GPST-UTC=+13  UTC-GPST=-13  GLONASST-GPST=+10787
2397
2398    #[test]
2399    fn offset_utc_gpst_matches_rtklib_2017() {
2400        let jd = utc_jd(2017, 1, 1, 0, 0, 0.0);
2401        // GPST - UTC = +18 (golden 18.0).
2402        let want = f64::from_bits(0x4032_0000_0000_0000);
2403        assert_eq!(
2404            timescale_offset_at_s(TimeScale::Utc, TimeScale::Gpst, jd).expect("leap-aware offset"),
2405            want
2406        );
2407        assert_eq!(want, 18.0);
2408        // UTC - GPST = -18.
2409        assert_eq!(
2410            timescale_offset_at_s(TimeScale::Gpst, TimeScale::Utc, jd).expect("leap-aware offset"),
2411            -18.0
2412        );
2413    }
2414
2415    #[test]
2416    fn offset_glonasst_gpst_matches_rtklib_2017() {
2417        let jd = utc_jd(2017, 1, 1, 0, 0, 0.0);
2418        // GLONASST - GPST = +10782 (golden bits of 10782.0).
2419        let want = f64::from_bits(0x40c5_0f00_0000_0000);
2420        assert_eq!(
2421            timescale_offset_at_s(TimeScale::Gpst, TimeScale::Glonasst, jd)
2422                .expect("leap-aware offset"),
2423            want
2424        );
2425        assert_eq!(want, 10782.0);
2426    }
2427
2428    #[test]
2429    fn offset_glonasst_gpst_at_j2000_matches_rtklib() {
2430        let jd = utc_jd(2000, 1, 1, 12, 0, 0.0);
2431        // GLONASST - GPST = +10787 at the J2000 leap epoch (TAI-UTC=32).
2432        assert_eq!(
2433            timescale_offset_at_s(TimeScale::Gpst, TimeScale::Glonasst, jd)
2434                .expect("leap-aware offset"),
2435            10787.0
2436        );
2437    }
2438
2439    /// The brief's required leap-second-boundary test: the GPST->GLONASST offset
2440    /// must step by exactly one second across the 2016-12-31 -> 2017-01-01 leap,
2441    /// from +10783 s (TAI-UTC=36) to +10782 s (TAI-UTC=37), matching RTKLIB.
2442    #[test]
2443    fn glonasst_offset_steps_across_2017_leap_second() {
2444        let before = utc_jd(2016, 12, 31, 23, 59, 59.0);
2445        let after = utc_jd(2017, 1, 1, 0, 0, 0.0);
2446
2447        let off_before = timescale_offset_at_s(TimeScale::Gpst, TimeScale::Glonasst, before)
2448            .expect("leap-aware offset");
2449        let off_after = timescale_offset_at_s(TimeScale::Gpst, TimeScale::Glonasst, after)
2450            .expect("leap-aware offset");
2451
2452        assert_eq!(off_before, f64::from_bits(0x40c5_0f80_0000_0000)); // 10783.0
2453        assert_eq!(off_after, f64::from_bits(0x40c5_0f00_0000_0000)); // 10782.0
2454        assert_eq!(off_before, 10783.0);
2455        assert_eq!(off_after, 10782.0);
2456        // The leap insertion lengthens the GPST-ahead-of-UTC gap by 1 s, so the
2457        // GLONASST(=UTC+3h)-minus-GPST offset shrinks by exactly 1 s.
2458        assert_eq!(off_before - off_after, 1.0);
2459
2460        // Cross-check the UTC leg too: GPST-UTC goes 17 -> 18 across the leap.
2461        assert_eq!(
2462            timescale_offset_at_s(TimeScale::Utc, TimeScale::Gpst, before)
2463                .expect("leap-aware offset"),
2464            17.0
2465        );
2466        assert_eq!(
2467            timescale_offset_at_s(TimeScale::Utc, TimeScale::Gpst, after)
2468                .expect("leap-aware offset"),
2469            18.0
2470        );
2471    }
2472
2473    /// Cross-check the leap-aware GPST<->UTC offset against the parity-critical
2474    /// `TimeScales::from_utc` path (which is itself Skyfield 0-ULP). The two TT
2475    /// fractions for the same instant labelled in UTC vs in GPST must differ by
2476    /// the offset this helper reports.
2477    #[test]
2478    fn leap_aware_offset_agrees_with_timescales_path() {
2479        // GPST is ahead of UTC by `timescale_offset_at_s(Utc, Gpst, jd)` seconds.
2480        let jd = utc_jd(2020, 6, 15, 0, 0, 0.0);
2481        let gpst_minus_utc =
2482            timescale_offset_at_s(TimeScale::Utc, TimeScale::Gpst, jd).expect("leap-aware offset");
2483        // 2020 is between the 2017 leap and now: TAI-UTC=37, GPST-UTC=18.
2484        assert_eq!(gpst_minus_utc, 18.0);
2485    }
2486}