use crate::epoch::{mjd_to_tjt, SECONDS_PER_DAY};
use log::warn;
use std::sync::atomic::{AtomicBool, Ordering};
#[derive(Debug, Clone)]
pub struct LeapSecondTable {
entries: Vec<(f64, f64)>,
clamp_out_of_range: bool,
}
impl LeapSecondTable {
pub fn from_entries(entries: Vec<(f64, f64)>) -> Self {
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,
}
}
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)
}
pub fn with_clamp_out_of_range(mut self, clamp: bool) -> Self {
self.clamp_out_of_range = clamp;
self
}
pub fn tai_utc_at_tai_tjt(&self, tai_tjt: f64) -> f64 {
if self.entries.is_empty() {
return 0.0;
}
let index = self.find_index_for_tai(tai_tjt);
self.entries[index].1
}
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
}
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
}
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
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
fn find_index_for_tai(&self, tai_tjt: f64) -> usize {
let last = self.entries.len() - 1;
let first_tai_boundary = self.entries[0].0 + self.entries[0].1 / SECONDS_PER_DAY;
if tai_tjt < first_tai_boundary {
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
);
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;
}
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
};
if tai_tjt > last_tai_boundary {
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
);
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
);
}
}
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
}
fn find_index_for_utc(&self, utc_tjt: f64) -> usize {
let last = self.entries.len() - 1;
if utc_tjt < self.entries[0].0 {
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
);
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;
}
if utc_tjt > self.entries[last].0 {
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
);
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;
}
for i in 0..last {
if utc_tjt >= self.entries[i].0 && utc_tjt < self.entries[i + 1].0 {
return i;
}
}
last
}
}
pub fn default_leap_second_table() -> LeapSecondTable {
let mjd_entries: &[(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), ];
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![]);
}
#[test]
#[should_panic(expected = "must not be empty")]
fn tm_39_panics_on_empty_leap_second_table() {
LeapSecondTable::from_entries(vec![]);
}
#[test]
#[should_panic(expected = "must be sorted by TJT")]
fn tm_39_panics_on_unsorted_leap_second_entries() {
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() {
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();
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() {
let table = default_leap_second_table().with_clamp_out_of_range(true);
let tjt_before = mjd_to_tjt(41000.0);
assert_eq!(table.tai_utc_at_utc_tjt(tjt_before), 10.0);
let tjt_after = mjd_to_tjt(58000.0);
assert_eq!(table.tai_utc_at_utc_tjt(tjt_after), 37.0);
}
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));
}
#[test]
#[should_panic(expected = "precedes first leap-second table entry")]
fn tm_41_panics_on_strict_utc_lookup_before_first_entry() {
let table = default_leap_second_table().with_clamp_out_of_range(false);
let _ = table.tai_utc_at_utc_tjt(mjd_to_tjt(41000.0));
}
#[test]
#[should_panic(expected = "follows last leap-second table entry")]
fn tm_41_panics_on_strict_utc_lookup_after_last_entry() {
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();
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() {
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 {
let utc_tjt = mjd_to_tjt(mjd) + 0.5; 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()
);
}
}
#[test]
fn tai_utc_at_tai_boundary_uses_post_leap_offset() {
let table = default_leap_second_table();
let when_i = 11179.0_f64;
let prev_val: f64 = 31.0;
let tai_at_boundary = when_i + prev_val / SECONDS_PER_DAY;
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"
);
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"
);
}
#[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)"
);
}
}