astrodyn_time 0.2.0

Time scales (TAI/UTC/UT1/TDB/TT/GMST) and converters for the astrodyn orbital-dynamics pipeline
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
//! [`LeapSecondTable`] — TAI ↔ UTC leap-second lookup.
//!
//! Ports
//! [`models/environment/time/src/tai_to_utc.cc`](https://github.com/nasa/jeod/blob/jeod_v5.4.0/models/environment/time/src/tai_to_utc.cc)
//! and the
//! [`Leap_Second.dat`](https://github.com/nasa/jeod/blob/jeod_v5.4.0/models/environment/time/data/Leap_Second.dat)
//! parser from JEOD v5.4.0. Required for any simulation that hits a
//! UTC ↔ TAI conversion.

use crate::epoch::{mjd_to_tjt, SECONDS_PER_DAY};
use log::warn;
use std::sync::atomic::{AtomicBool, Ordering};

/// Leap second lookup table for TAI↔UTC conversion.
///
/// Each entry maps a truncated Julian time (TJT) boundary to the cumulative
/// TAI-UTC offset in seconds at that boundary. Entries are sorted by TJT.
///
/// Mirrors JEOD's `when_vec` / `val_vec` arrays from `tai_to_utc.cc`.
///
/// # Out-of-range epochs
///
/// By default, epochs outside the table's `[first_boundary, last_boundary]`
/// range cause a **panic** (#485 H2): silently using a boundary value for an
/// epoch the table cannot resolve produces wrong UTC conversions that
/// cascade through every dependent calculation. To restore JEOD's
/// log-once-and-clamp behavior, call [`Self::with_clamp_out_of_range`] with `true`
/// — useful only for matching JEOD reference runs exactly or when the
/// caller has independent knowledge that the boundary value is correct
/// for the epoch in question.
#[derive(Debug, Clone)]
pub struct LeapSecondTable {
    /// (tjt_boundary, tai_minus_utc_seconds) pairs, sorted by TJT.
    entries: Vec<(f64, f64)>,
    /// When `false` (the default), out-of-range TAI/UTC lookups panic.
    /// When `true`, the table returns the boundary value and emits a
    /// one-time warning. See the struct rustdoc for the policy rationale.
    clamp_out_of_range: bool,
}

impl LeapSecondTable {
    /// Create a leap second table from (TJT, TAI-UTC seconds) pairs.
    /// Entries must be sorted by TJT.
    ///
    /// The constructed table panics on out-of-range epoch lookups; opt in
    /// to JEOD-faithful clamp behavior with [`Self::with_clamp_out_of_range`].
    pub fn from_entries(entries: Vec<(f64, f64)>) -> Self {
        // JEOD_INV: TM.39 — leap-second table must be non-empty and monotonic in TJT.
        // JEOD's `when_vec` is assumed monotonic in `time_converter_tai_utc.cc`; we enforce at construction.
        assert!(!entries.is_empty(), "Leap second table must not be empty");
        assert!(
            entries.windows(2).all(|w| w[0].0 <= w[1].0),
            "Leap second entries must be sorted by TJT"
        );
        Self {
            entries,
            clamp_out_of_range: false,
        }
    }

    /// Create from (MJD, TAI-UTC seconds) pairs (as found in Leap_Second.dat).
    pub fn from_mjd_entries(mjd_entries: &[(f64, f64)]) -> Self {
        let entries: Vec<(f64, f64)> = mjd_entries
            .iter()
            .map(|&(mjd, tai_utc)| (mjd_to_tjt(mjd), tai_utc))
            .collect();
        Self::from_entries(entries)
    }

    /// Opt in to JEOD's clamp-on-out-of-range behavior: a `log::warn!` is
    /// emitted once per process for each direction (TAI, UTC) and the
    /// boundary index is returned. Default is `false` (panic on
    /// out-of-range) — see the struct rustdoc for the policy rationale
    /// (#485 H2).
    pub fn with_clamp_out_of_range(mut self, clamp: bool) -> Self {
        self.clamp_out_of_range = clamp;
        self
    }

    /// Look up the TAI-UTC offset (in seconds) at a given TAI TJT.
    ///
    /// Returns the cumulative number of leap seconds (TAI - UTC) in seconds.
    pub fn tai_utc_at_tai_tjt(&self, tai_tjt: f64) -> f64 {
        if self.entries.is_empty() {
            return 0.0;
        }
        // The TAI TJT includes the leap second offset itself, so we need to
        // find the entry where the UTC boundary (in TJT) matches. JEOD converts
        // the offset to days and applies: utc_tjt = tai_tjt - tai_utc/86400.
        // We iterate to find the correct bracket.
        let index = self.find_index_for_tai(tai_tjt);
        self.entries[index].1
    }

    /// Look up the TAI-UTC offset (in seconds) at a given UTC TJT.
    pub fn tai_utc_at_utc_tjt(&self, utc_tjt: f64) -> f64 {
        if self.entries.is_empty() {
            return 0.0;
        }
        let index = self.find_index_for_utc(utc_tjt);
        self.entries[index].1
    }

    /// Convert TAI TJT to UTC TJT.
    pub fn tai_to_utc_tjt(&self, tai_tjt: f64) -> f64 {
        let tai_utc = self.tai_utc_at_tai_tjt(tai_tjt);
        tai_tjt - tai_utc / SECONDS_PER_DAY
    }

    /// Convert UTC TJT to TAI TJT.
    pub fn utc_to_tai_tjt(&self, utc_tjt: f64) -> f64 {
        let tai_utc = self.tai_utc_at_utc_tjt(utc_tjt);
        utc_tjt + tai_utc / SECONDS_PER_DAY
    }

    /// Number of entries in the table.
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// Whether the table is empty.
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// Find the index for a TAI TJT value.
    ///
    /// `entries[i] = (when_vec[i], val_vec[i])` where `when_vec[i]` is a UTC
    /// boundary in TJT and `val_vec[i]` is TAI-UTC (in seconds) applied AT and
    /// AFTER that boundary. The TAI instant at which the i-th transition
    /// occurs (UTC first reaches `when_vec[i]` using the previous offset
    /// `val_vec[i-1]`) is `tai_boundary[i] = when_vec[i] + val_vec[i-1]/86400`.
    ///
    /// JEOD's `convert_a_to_b` updates the offset stepwise: it holds index
    /// `i-1` until UTC (computed with that offset) reaches `when_vec[i]`,
    /// then advances to index `i`. Equivalently, for a given TAI the offset
    /// is `val_vec[i]` where `i` is the largest index with
    /// `tai_tjt >= when_vec[i] + val_vec[i-1]/86400`. This matches JEOD's
    /// "true_utc" convention where the leap second is counted as a real UTC
    /// second: within the leap second, UTC rewinds by 1 s so the interval
    /// `23:59:59..24:00:00` is traversed twice (once labelled 23:59:59, once
    /// labelled 23:59:60).
    ///
    /// O(n) over the leap-second table (28 entries today, growing by 0–1 per
    /// year). Fine for occasional UTC↔TAI conversions; do not call in tight
    /// integrator inner loops — cache the result if you need it per step.
    fn find_index_for_tai(&self, tai_tjt: f64) -> usize {
        let last = self.entries.len() - 1;
        // Before the first entry: JEOD uses val_vec[0], so return index 0.
        // Boundary at i=0 in the "TAI frame" is `when_vec[0] + val_vec[0]/86400`
        // (no prior entry, so we use val_vec[0] itself). Default policy
        // panics; the JEOD-faithful clamp path emits a one-time WARN.
        let first_tai_boundary = self.entries[0].0 + self.entries[0].1 / SECONDS_PER_DAY;
        if tai_tjt < first_tai_boundary {
            // JEOD_INV: TM.41 — out-of-range TAI lookup before table start.
            // Default fail-loudly (#485 H2); JEOD-faithful clamp behavior
            // is reachable via `with_clamp_out_of_range(true)`.
            assert!(
                self.clamp_out_of_range,
                "TAI time {tai_tjt} precedes first leap-second table boundary \
                 ({first_tai_boundary}). The boundary TAI-UTC value ({} s) \
                 would silently propagate as wrong physics. \
                 Use a covered epoch, or call \
                 `.with_clamp_out_of_range(true)` on the LeapSecondTable to \
                 opt in to JEOD-faithful clamp behavior.",
                self.entries[0].1
            );
            // JEOD_INV: TM.41 — JEOD-faithful warn on opt-in clamp path.
            static WARNED_BEFORE_TAI: AtomicBool = AtomicBool::new(false);
            if !WARNED_BEFORE_TAI.swap(true, Ordering::Relaxed) {
                warn!(
                    "TAI time precedes first leap second table boundary; \
                     using first value ({} s)",
                    self.entries[0].1
                );
            }
            return 0;
        }
        // After the last boundary: the last regime extends forward
        // indefinitely. Default policy panics; clamp opt-in warns and
        // continues with the boundary value via the search below.
        let last_tai_boundary = if last == 0 {
            self.entries[0].0 + self.entries[0].1 / SECONDS_PER_DAY
        } else {
            self.entries[last].0 + self.entries[last - 1].1 / SECONDS_PER_DAY
        };
        // Strictly past the last regime boundary is OOR. Equality is in-range:
        // the boundary is the instant the last regime starts and the lookup
        // value at that instant is the table's authoritative answer.
        if tai_tjt > last_tai_boundary {
            // JEOD_INV: TM.41 — out-of-range TAI lookup past table end.
            assert!(
                self.clamp_out_of_range,
                "TAI time {tai_tjt} follows last leap-second table boundary \
                 ({last_tai_boundary}). The boundary TAI-UTC value ({} s) \
                 would silently propagate as wrong physics. \
                 Refresh the table for the new epoch, or call \
                 `.with_clamp_out_of_range(true)` on the LeapSecondTable to \
                 opt in to JEOD-faithful clamp behavior.",
                self.entries[last].1
            );
            // JEOD_INV: TM.41 — JEOD-faithful warn on opt-in clamp path.
            static WARNED_AFTER_TAI: AtomicBool = AtomicBool::new(false);
            if !WARNED_AFTER_TAI.swap(true, Ordering::Relaxed) {
                warn!(
                    "TAI time follows last leap second table boundary; \
                     using last value ({} s)",
                    self.entries[last].1
                );
            }
        }
        // Search from the top down for the largest i with
        //   tai_tjt >= when_vec[i] + val_vec[i-1]/86400.
        for i in (1..=last).rev() {
            let tai_boundary_i = self.entries[i].0 + self.entries[i - 1].1 / SECONDS_PER_DAY;
            if tai_tjt >= tai_boundary_i {
                return i;
            }
        }
        0
    }

    /// Find the index for a UTC TJT value using binary-style search.
    fn find_index_for_utc(&self, utc_tjt: f64) -> usize {
        let last = self.entries.len() - 1;
        // JEOD time_converter_tai_utc.cc:158-233: log INFORM when time is
        // outside the leap second table range. Default policy panics
        // (#485 H2); clamp opt-in restores the warn-and-continue behavior.
        if utc_tjt < self.entries[0].0 {
            // JEOD_INV: TM.41 — out-of-range UTC lookup before table start.
            assert!(
                self.clamp_out_of_range,
                "UTC time {utc_tjt} precedes first leap-second table entry \
                 ({}). The boundary TAI-UTC value ({} s) would silently \
                 propagate as wrong physics. Use a covered epoch, or call \
                 `.with_clamp_out_of_range(true)` on the LeapSecondTable to \
                 opt in to JEOD-faithful clamp behavior.",
                self.entries[0].0, self.entries[0].1
            );
            // JEOD_INV: TM.41 — JEOD-faithful warn on opt-in clamp path.
            static WARNED_BEFORE: AtomicBool = AtomicBool::new(false);
            if !WARNED_BEFORE.swap(true, Ordering::Relaxed) {
                warn!(
                    "Time precedes first leap second table entry; \
                     using first value ({} s)",
                    self.entries[0].1
                );
            }
            return 0;
        }
        // Strictly past the last regime boundary is OOR. Equality is in-range:
        // the boundary is the instant the last regime starts (handled by the
        // linear search returning `last` as its fall-through).
        if utc_tjt > self.entries[last].0 {
            // JEOD_INV: TM.41 — out-of-range UTC lookup past table end.
            assert!(
                self.clamp_out_of_range,
                "UTC time {utc_tjt} follows last leap-second table entry \
                 ({}). The boundary TAI-UTC value ({} s) would silently \
                 propagate as wrong physics. Refresh the table for the new \
                 epoch, or call `.with_clamp_out_of_range(true)` on the \
                 LeapSecondTable to opt in to JEOD-faithful clamp behavior.",
                self.entries[last].0, self.entries[last].1
            );
            // JEOD_INV: TM.41 — JEOD-faithful warn on opt-in clamp path.
            static WARNED_AFTER: AtomicBool = AtomicBool::new(false);
            if !WARNED_AFTER.swap(true, Ordering::Relaxed) {
                warn!(
                    "Time follows last leap second table entry; \
                     using last value ({} s)",
                    self.entries[last].1
                );
            }
            return last;
        }
        // Linear search (28 entries, fast enough; matches JEOD's loop)
        for i in 0..last {
            if utc_tjt >= self.entries[i].0 && utc_tjt < self.entries[i + 1].0 {
                return i;
            }
        }
        last
    }
}

/// Create the default leap second table from hardcoded JEOD data.
///
/// Data from JEOD `Leap_Second.dat` (28 entries, 1972-2017).
/// MJD values from the file, TAI-UTC in seconds.
///
/// The returned table has `clamp_out_of_range = true` by default
/// (#485 H2 pragmatic resolution): production missions routinely target
/// post-2017 epochs, and the IERS "no new leap seconds since 2017" state
/// is the legitimate operating regime extension. Callers needing strict
/// out-of-range rejection (asserting the epoch falls inside the tabulated
/// range) should construct via [`LeapSecondTable::from_entries`] or
/// [`LeapSecondTable::from_mjd_entries`] directly without chaining the
/// clamp opt-in.
pub fn default_leap_second_table() -> LeapSecondTable {
    // (MJD, TAI-UTC seconds) from JEOD Leap_Second.dat
    let mjd_entries: &[(f64, f64)] = &[
        (41317.0, 10.0), // 1972-01-01
        (41499.0, 11.0), // 1972-07-01
        (41683.0, 12.0), // 1973-01-01
        (42048.0, 13.0), // 1974-01-01
        (42413.0, 14.0), // 1975-01-01
        (42778.0, 15.0), // 1976-01-01
        (43144.0, 16.0), // 1977-01-01
        (43509.0, 17.0), // 1978-01-01
        (43874.0, 18.0), // 1979-01-01
        (44239.0, 19.0), // 1980-01-01
        (44786.0, 20.0), // 1981-07-01
        (45151.0, 21.0), // 1982-07-01
        (45516.0, 22.0), // 1983-07-01
        (46247.0, 23.0), // 1985-07-01
        (47161.0, 24.0), // 1988-01-01
        (47892.0, 25.0), // 1990-01-01
        (48257.0, 26.0), // 1991-01-01
        (48804.0, 27.0), // 1992-07-01
        (49169.0, 28.0), // 1993-07-01
        (49534.0, 29.0), // 1994-07-01
        (50083.0, 30.0), // 1996-01-01
        (50630.0, 31.0), // 1997-07-01
        (51179.0, 32.0), // 1999-01-01
        (53736.0, 33.0), // 2006-01-01
        (54832.0, 34.0), // 2009-01-01
        (56109.0, 35.0), // 2012-07-01
        (57204.0, 36.0), // 2015-07-01
        (57754.0, 37.0), // 2017-01-01
    ];
    LeapSecondTable::from_mjd_entries(mjd_entries).with_clamp_out_of_range(true)
}

#[cfg(test)]
#[allow(
    clippy::float_cmp,
    reason = "leap-second TAI-UTC table boundaries are integers stored in f64; bit-exact equality is the invariant"
)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "must not be empty")]
    fn empty_table_panics() {
        LeapSecondTable::from_entries(vec![]);
    }

    /// Pins the empty-table assert in `LeapSecondTable::from_entries`. The
    /// `find_index_for_tai`/`find_index_for_utc` lookups index `self.entries[0]`
    /// and `self.entries[last]` directly; an empty table would index out of
    /// bounds at the first lookup. JEOD's `time_converter_tai_utc.cc` assumes
    /// a populated `when_vec` from `Leap_Second.dat`; we assert at construction
    /// so a missing / empty fixture fails loudly at table-build time rather
    /// than panicking opaquely on the first TAI↔UTC conversion.
    // JEOD_INV: TM.39 — negative test: empty leap-second table rejected at construction
    #[test]
    #[should_panic(expected = "must not be empty")]
    fn tm_39_panics_on_empty_leap_second_table() {
        // JEOD_INV: TM.39 — empty `when_vec`/`val_vec` rejected at construction.
        LeapSecondTable::from_entries(vec![]);
    }

    /// Pins the monotonicity assert in `LeapSecondTable::from_entries`. The
    /// `find_index_for_utc` search returns the first `i` with
    /// `entries[i].0 <= utc_tjt < entries[i+1].0`; an unsorted `when_vec` would
    /// return a wrong bracket without panicking, silently producing a wrong
    /// TAI-UTC offset. JEOD's algorithm relies on monotonic `when_vec` by
    /// convention; we enforce at construction.
    // JEOD_INV: TM.39 — negative test: out-of-order TJT entries rejected
    #[test]
    #[should_panic(expected = "must be sorted by TJT")]
    fn tm_39_panics_on_unsorted_leap_second_entries() {
        // JEOD_INV: TM.39 — non-monotonic `when_vec` rejected at construction.
        LeapSecondTable::from_entries(vec![(50000.0, 32.0), (40000.0, 10.0)]);
    }

    #[test]
    fn default_table_has_28_entries() {
        let table = default_leap_second_table();
        assert_eq!(table.len(), 28);
    }

    #[test]
    fn tai_utc_at_j2000() {
        // At J2000 (2000-01-01 12:00 TT), TAI-UTC = 32s
        // J2000 TAI TJT ≈ 11544.4996275
        let table = default_leap_second_table();
        let tai_utc = table.tai_utc_at_tai_tjt(11544.4996);
        assert_eq!(tai_utc, 32.0, "TAI-UTC at J2000 should be 32s");
    }

    #[test]
    fn tai_utc_round_trip() {
        let table = default_leap_second_table();
        // Test a TJT in 2010 (between 2009 and 2012 leap seconds)
        // MJD 55000 = TJT 15000.0, TAI-UTC should be 34
        let utc_tjt = 15000.0;
        let tai_tjt = table.utc_to_tai_tjt(utc_tjt);
        let utc_back = table.tai_to_utc_tjt(tai_tjt);
        assert!(
            (utc_back - utc_tjt).abs() < 1e-12,
            "Round trip: {} -> {} -> {}",
            utc_tjt,
            tai_tjt,
            utc_back
        );
    }

    #[test]
    fn first_and_last_entries_clamping() {
        // Opt in to JEOD-faithful clamp behavior — without the flag, OOR
        // epochs panic by default (#485 H2).
        let table = default_leap_second_table().with_clamp_out_of_range(true);
        // Before first entry: should use first entry's offset.
        let tjt_before = mjd_to_tjt(41000.0);
        assert_eq!(table.tai_utc_at_utc_tjt(tjt_before), 10.0);

        // After last entry.
        let tjt_after = mjd_to_tjt(58000.0);
        assert_eq!(table.tai_utc_at_utc_tjt(tjt_after), 37.0);
    }

    /// Helper: build a strict (non-clamping) copy of the default table. Used
    /// by the panic tests to assert the fail-loudly behavior without going
    /// through `default_leap_second_table()` (which is clamp-by-default per
    /// the #485 H2 pragmatic resolution).
    fn strict_default_table() -> LeapSecondTable {
        default_leap_second_table().with_clamp_out_of_range(false)
    }

    #[test]
    #[should_panic(expected = "precedes first leap-second table entry")]
    fn utc_before_table_panics_when_strict() {
        let _ = strict_default_table().tai_utc_at_utc_tjt(mjd_to_tjt(41000.0));
    }

    #[test]
    #[should_panic(expected = "follows last leap-second table entry")]
    fn utc_after_table_panics_when_strict() {
        let _ = strict_default_table().tai_utc_at_utc_tjt(mjd_to_tjt(58000.0));
    }

    #[test]
    #[should_panic(expected = "precedes first leap-second table boundary")]
    fn tai_before_table_panics_when_strict() {
        let _ = strict_default_table().tai_utc_at_tai_tjt(mjd_to_tjt(41000.0));
    }

    #[test]
    #[should_panic(expected = "follows last leap-second table boundary")]
    fn tai_after_table_panics_when_strict() {
        let _ = strict_default_table().tai_utc_at_tai_tjt(mjd_to_tjt(58000.0));
    }

    /// Pins the strict out-of-range UTC lookup assert at
    /// `find_index_for_utc`'s pre-first-entry branch. The default
    /// `default_leap_second_table()` factory opts in to JEOD-faithful
    /// clamp-and-warn (#485 H2); strict mode is reachable by chaining
    /// `with_clamp_out_of_range(false)`. A neutered guard would silently
    /// return the boundary TAI-UTC value, producing a wrong UTC
    /// conversion that cascades through every dependent time scale.
    // JEOD_INV: TM.41 — negative test: strict UTC lookup before first entry panics
    #[test]
    #[should_panic(expected = "precedes first leap-second table entry")]
    fn tm_41_panics_on_strict_utc_lookup_before_first_entry() {
        // JEOD_INV: TM.41 — out-of-range UTC TJT in strict mode (clamp opt-out).
        let table = default_leap_second_table().with_clamp_out_of_range(false);
        let _ = table.tai_utc_at_utc_tjt(mjd_to_tjt(41000.0));
    }

    /// Pins the strict out-of-range UTC lookup assert at
    /// `find_index_for_utc`'s post-last-entry branch. Mirrors the
    /// before-first-entry test above; the late-epoch direction matters
    /// for missions targeting epochs past the table's last tabulated
    /// boundary (post-2017 with our shipped 28-entry table).
    // JEOD_INV: TM.41 — negative test: strict UTC lookup after last entry panics
    #[test]
    #[should_panic(expected = "follows last leap-second table entry")]
    fn tm_41_panics_on_strict_utc_lookup_after_last_entry() {
        // JEOD_INV: TM.41 — out-of-range UTC TJT in strict mode (clamp opt-out).
        let table = default_leap_second_table().with_clamp_out_of_range(false);
        let _ = table.tai_utc_at_utc_tjt(mjd_to_tjt(70000.0));
    }

    #[test]
    fn tai_utc_correct_at_all_28_boundaries() {
        let table = default_leap_second_table();
        // (MJD, expected TAI-UTC) for all 28 entries from JEOD Leap_Second.dat
        let boundaries: &[(f64, f64)] = &[
            (41317.0, 10.0),
            (41499.0, 11.0),
            (41683.0, 12.0),
            (42048.0, 13.0),
            (42413.0, 14.0),
            (42778.0, 15.0),
            (43144.0, 16.0),
            (43509.0, 17.0),
            (43874.0, 18.0),
            (44239.0, 19.0),
            (44786.0, 20.0),
            (45151.0, 21.0),
            (45516.0, 22.0),
            (46247.0, 23.0),
            (47161.0, 24.0),
            (47892.0, 25.0),
            (48257.0, 26.0),
            (48804.0, 27.0),
            (49169.0, 28.0),
            (49534.0, 29.0),
            (50083.0, 30.0),
            (50630.0, 31.0),
            (51179.0, 32.0),
            (53736.0, 33.0),
            (54832.0, 34.0),
            (56109.0, 35.0),
            (57204.0, 36.0),
            (57754.0, 37.0),
        ];
        for &(mjd, expected_tai_utc) in boundaries {
            let utc_tjt = mjd_to_tjt(mjd);
            let tai_utc = table.tai_utc_at_utc_tjt(utc_tjt);
            assert_eq!(
                tai_utc, expected_tai_utc,
                "At MJD {}: expected TAI-UTC={}s, got {}s",
                mjd, expected_tai_utc, tai_utc
            );
        }
    }

    #[test]
    fn tai_utc_round_trip_all_boundaries() {
        // Includes the last boundary at MJD 57754 (2017-01-01); the test
        // probes "boundary + 0.5d" which is strictly past that regime's
        // last in-table entry, so we opt in to JEOD-faithful clamp
        // behavior (#485 H2). The default-panic behavior is exercised by
        // the dedicated `*_panics_by_default` tests above.
        let table = default_leap_second_table().with_clamp_out_of_range(true);
        let mjds: &[f64] = &[
            41317.0, 41499.0, 42048.0, 43144.0, 44786.0, 47161.0, 49169.0, 50083.0, 51179.0,
            53736.0, 56109.0, 57754.0,
        ];
        for &mjd in mjds {
            // Test slightly after the boundary (to avoid edge ambiguity)
            let utc_tjt = mjd_to_tjt(mjd) + 0.5; // half a day after
            let tai_tjt = table.utc_to_tai_tjt(utc_tjt);
            let utc_back = table.tai_to_utc_tjt(tai_tjt);
            assert!(
                (utc_back - utc_tjt).abs() < 1e-12,
                "Round trip failed at MJD {}: {} -> {} -> {}, err={}",
                mjd,
                utc_tjt,
                tai_tjt,
                utc_back,
                (utc_back - utc_tjt).abs()
            );
        }
    }

    /// At the instant TAI reaches a leap-second boundary in TAI time
    /// (`tai_tjt = when_vec[i] + val_vec[i-1]/86400`), JEOD's
    /// `convert_a_to_b` updates the offset to `val_vec[i]`. Verify our
    /// stateless lookup reproduces this (matches JEOD time_converter_tai_utc.cc
    /// line 271 `while(utc >= next_when)`).
    #[test]
    fn tai_utc_at_tai_boundary_uses_post_leap_offset() {
        let table = default_leap_second_table();
        // 1999-01-01 UTC boundary: when_vec[22] = MJD 51179 = TJT 11179.
        // TAI-UTC transitions 31 → 32 at this UTC boundary.
        let when_i = 11179.0_f64;
        let prev_val: f64 = 31.0;
        // At TAI exactly when UTC reaches boundary with old offset applied:
        let tai_at_boundary = when_i + prev_val / SECONDS_PER_DAY;
        // JEOD updates offset here, so TAI-UTC at this TAI = 32 (post-leap).
        assert_eq!(
            table.tai_utc_at_tai_tjt(tai_at_boundary),
            32.0,
            "At TAI reaching a leap boundary, should use the post-leap offset"
        );
        // And just before (in TAI), should still be 31.
        let before_boundary = tai_at_boundary - 1e-9;
        assert_eq!(
            table.tai_utc_at_tai_tjt(before_boundary),
            31.0,
            "Just before the TAI boundary, should still use pre-leap offset"
        );
    }

    /// Exact reproduction of the SIM_4 RUN_JEOD2x row at t=86400 in
    /// `tier3_sim_time_docker.rs`: TAI TJT = 11179.0003587963, JEOD UTC TJT =
    /// 11178.99998842593. The boundary is crossed exactly at this TAI — JEOD
    /// updates its internal state on crossing and reports the post-leap offset.
    #[test]
    fn tai_utc_matches_astrodyn4_at_leap_crossing() {
        let table = default_leap_second_table();
        let tai_tjt = 11179.0003587963_f64;
        let expected_utc_tjt = 11178.99998842593_f64;
        let got = table.tai_to_utc_tjt(tai_tjt);
        let err_sec = (got - expected_utc_tjt).abs() * SECONDS_PER_DAY;
        assert!(
            err_sec < 1e-6,
            "SIM_4 leap crossing: got UTC TJT {got}, expected {expected_utc_tjt} \
             (diff {err_sec:.4e} s)"
        );
    }
}