use log::warn;
use std::sync::atomic::{AtomicBool, Ordering};
const EOP_BINARY_MAGIC: &[u8; 4] = b"EOPT";
const EOP_BINARY_VERSION: u32 = 1;
const DEFAULT_EOP_BIN: &[u8] = include_bytes!("../test_data/eop/iers_eop_c04.bin");
#[derive(Debug, Clone)]
pub struct EopTable {
when_tjt: Vec<f64>,
val_seconds: Vec<f64>,
clamp_out_of_range: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum EopLoadError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("EOP binary truncated at offset {offset} (need {needed} bytes, have {have})")]
Truncated {
offset: usize,
needed: usize,
have: usize,
},
#[error("EOP binary magic mismatch: expected `EOPT`, got {0:?}")]
BadMagic([u8; 4]),
#[error("EOP binary version {found} unsupported (this build expects {expected})")]
BadVersion {
found: u32,
expected: u32,
},
#[error("EOP table invariant violated: {0}")]
InvalidTable(String),
}
impl EopTable {
pub fn from_entries(entries: Vec<(f64, f64)>) -> Self {
assert!(!entries.is_empty(), "EOP table must not be empty");
assert!(
entries.windows(2).all(|w| w[0].0 < w[1].0),
"EOP entries must be strictly increasing in TAI TJT"
);
let (when_tjt, val_seconds) = entries.into_iter().unzip();
Self {
when_tjt,
val_seconds,
clamp_out_of_range: false,
}
}
pub fn with_clamp_out_of_range(mut self, clamp: bool) -> Self {
self.clamp_out_of_range = clamp;
self
}
pub fn len(&self) -> usize {
self.when_tjt.len()
}
pub fn is_empty(&self) -> bool {
self.when_tjt.is_empty()
}
pub fn first_tai_tjt(&self) -> f64 {
self.when_tjt[0]
}
pub fn last_tai_tjt(&self) -> f64 {
self.when_tjt[self.when_tjt.len() - 1]
}
pub fn ut1_minus_tai_seconds(&self, tai_tjt: f64) -> f64 {
assert!(
tai_tjt.is_finite(),
"EopTable lookup requires a finite TAI TJT, got {tai_tjt}"
);
let last = self.when_tjt.len() - 1;
let first_when = self.when_tjt[0];
let last_when = self.when_tjt[last];
if tai_tjt < first_when {
assert!(
self.clamp_out_of_range,
"TAI TJT {tai_tjt} precedes first EOP table entry ({first_when}). \
Refusing to silently extend the boundary UT1-TAI value ({} s) \
across an uncovered epoch — that would accumulate ~1 ms/day of \
wrong-physics drift in GMST. Use a covered epoch (table covers \
TJT {first_when}..{last_when}), regenerate the EOP fixture for \
your epoch via `extract_eop_table`, or call \
`.with_clamp_out_of_range(true)` to opt into JEOD-faithful \
boundary clamping.",
self.val_seconds[0]
);
static WARNED_BEFORE: AtomicBool = AtomicBool::new(false);
if !WARNED_BEFORE.swap(true, Ordering::Relaxed) {
warn!(
"TAI TJT precedes first EOP table entry; \
using first value ({} s)",
self.val_seconds[0]
);
}
return self.val_seconds[0];
}
if tai_tjt > last_when {
assert!(
self.clamp_out_of_range,
"TAI TJT {tai_tjt} follows last EOP table entry ({last_when}). \
Refusing to silently extend the boundary UT1-TAI value ({} s) \
across an uncovered epoch — that would accumulate ~1 ms/day of \
wrong-physics drift in GMST. Refresh the EOP fixture via \
`extract_eop_table` (table covers TJT {first_when}..{last_when}), \
or call `.with_clamp_out_of_range(true)` to opt into \
JEOD-faithful boundary clamping.",
self.val_seconds[last]
);
static WARNED_AFTER: AtomicBool = AtomicBool::new(false);
if !WARNED_AFTER.swap(true, Ordering::Relaxed) {
warn!(
"TAI TJT follows last EOP table entry; \
using last value ({} s)",
self.val_seconds[last]
);
}
return self.val_seconds[last];
}
let idx = self.bracket_index(tai_tjt);
if idx == last {
return self.val_seconds[last];
}
let prev_when = self.when_tjt[idx];
let next_when = self.when_tjt[idx + 1];
let prev_value = self.val_seconds[idx];
let next_value = self.val_seconds[idx + 1];
let gradient = (next_value - prev_value) / (next_when - prev_when);
prev_value + (tai_tjt - prev_when) * gradient
}
fn bracket_index(&self, tai_tjt: f64) -> usize {
let upper = self.when_tjt.partition_point(|&w| w <= tai_tjt);
upper.saturating_sub(1)
}
pub fn save_binary(&self, path: &std::path::Path) -> std::io::Result<()> {
let bytes = self.to_binary_bytes();
std::fs::write(path, bytes)
}
pub fn to_binary_bytes(&self) -> Vec<u8> {
let count = self.when_tjt.len();
let mut buf = Vec::with_capacity(4 + 4 + 8 + 16 * count);
buf.extend_from_slice(EOP_BINARY_MAGIC);
buf.extend_from_slice(&EOP_BINARY_VERSION.to_le_bytes());
buf.extend_from_slice(&(count as u64).to_le_bytes());
for w in &self.when_tjt {
buf.extend_from_slice(&w.to_le_bytes());
}
for v in &self.val_seconds {
buf.extend_from_slice(&v.to_le_bytes());
}
buf
}
pub fn load_binary(path: &std::path::Path) -> Result<Self, EopLoadError> {
let buf = std::fs::read(path)?;
Self::load_binary_from_bytes(&buf)
}
pub fn load_binary_from_bytes(buf: &[u8]) -> Result<Self, EopLoadError> {
let mut cursor = 0usize;
let mut take = |n: usize| -> Result<&[u8], EopLoadError> {
if cursor + n > buf.len() {
return Err(EopLoadError::Truncated {
offset: cursor,
needed: n,
have: buf.len() - cursor,
});
}
let s = &buf[cursor..cursor + n];
cursor += n;
Ok(s)
};
let magic_slice = take(4)?;
let mut magic = [0u8; 4];
magic.copy_from_slice(magic_slice);
if magic != *EOP_BINARY_MAGIC {
return Err(EopLoadError::BadMagic(magic));
}
let mut v_buf = [0u8; 4];
v_buf.copy_from_slice(take(4)?);
let version = u32::from_le_bytes(v_buf);
if version != EOP_BINARY_VERSION {
return Err(EopLoadError::BadVersion {
found: version,
expected: EOP_BINARY_VERSION,
});
}
let mut c_buf = [0u8; 8];
c_buf.copy_from_slice(take(8)?);
let count = u64::from_le_bytes(c_buf);
if count > 10_000_000 {
return Err(EopLoadError::InvalidTable(format!(
"implausible entry count {count} (max 10,000,000)"
)));
}
let count_usize = usize::try_from(count).map_err(|_| {
EopLoadError::InvalidTable(format!(
"entry count {count} does not fit in a usize on this target"
))
})?;
let mut when_tjt = Vec::with_capacity(count_usize);
for _ in 0..count_usize {
let mut b = [0u8; 8];
b.copy_from_slice(take(8)?);
when_tjt.push(f64::from_le_bytes(b));
}
let mut val_seconds = Vec::with_capacity(count_usize);
for _ in 0..count_usize {
let mut b = [0u8; 8];
b.copy_from_slice(take(8)?);
val_seconds.push(f64::from_le_bytes(b));
}
if when_tjt.is_empty() {
return Err(EopLoadError::InvalidTable("empty table".to_string()));
}
if !when_tjt.windows(2).all(|w| w[0] < w[1]) {
return Err(EopLoadError::InvalidTable(
"when_tjt is not strictly monotonic".to_string(),
));
}
let entries: Vec<(f64, f64)> = when_tjt.into_iter().zip(val_seconds).collect();
Ok(Self::from_entries(entries))
}
pub fn parse_jeod_cc(src: &str) -> Result<Self, EopLoadError> {
let mut whens: std::collections::BTreeMap<usize, f64> = std::collections::BTreeMap::new();
let mut vals: std::collections::BTreeMap<usize, f64> = std::collections::BTreeMap::new();
for line in src.lines() {
let l = line.trim();
if let Some((idx, val)) = parse_indexed_assignment(l, "when_vec") {
whens.insert(idx, val);
} else if let Some((idx, val)) = parse_indexed_assignment(l, "val_vec") {
vals.insert(idx, val);
}
}
if whens.is_empty() || vals.is_empty() {
return Err(EopLoadError::InvalidTable(
"no when_vec/val_vec assignments found in JEOD source".to_string(),
));
}
if whens.len() != vals.len() {
return Err(EopLoadError::InvalidTable(format!(
"when_vec / val_vec length mismatch ({} vs {})",
whens.len(),
vals.len()
)));
}
let n = whens.len();
let mut entries = Vec::with_capacity(n);
for i in 0..n {
let when = whens.get(&i).ok_or_else(|| {
EopLoadError::InvalidTable(format!("missing when_vec[{i}] in JEOD source"))
})?;
let val = vals.get(&i).ok_or_else(|| {
EopLoadError::InvalidTable(format!("missing val_vec[{i}] in JEOD source"))
})?;
entries.push((*when, *val));
}
Ok(Self::from_entries(entries))
}
}
fn parse_indexed_assignment(line: &str, key: &str) -> Option<(usize, f64)> {
let needle = format!("->{key}[");
let p = line.find(&needle)?;
let after = &line[p + needle.len()..];
let close = after.find(']')?;
let idx_str = &after[..close];
let rest = after.get(close + 1..)?.trim_start();
let rest = rest.strip_prefix('=')?.trim_start();
let semi = rest.find(';')?;
let val_str = rest[..semi].trim();
let idx: usize = idx_str.parse().ok()?;
let val: f64 = val_str.parse().ok()?;
Some((idx, val))
}
pub fn default_eop_table() -> EopTable {
EopTable::load_binary_from_bytes(DEFAULT_EOP_BIN)
.expect("bundled IERS EOP fixture: regenerate with `extract_eop_table`")
}
#[cfg(test)]
#[allow(
clippy::float_cmp,
reason = "interpolation tests assert bit-exact equality at table sample points"
)]
mod tests {
use super::*;
fn synthetic_table() -> EopTable {
EopTable::from_entries(vec![
(1000.0, 0.100),
(1001.0, 0.101),
(1002.0, 0.102),
(1003.0, 0.103),
(1004.0, 0.104),
])
}
#[test]
fn exact_at_sample_points() {
let t = synthetic_table();
let pts: [(f64, f64); 5] = [
(1000.0, 0.100),
(1001.0, 0.101),
(1002.0, 0.102),
(1003.0, 0.103),
(1004.0, 0.104),
];
for (when, expected) in pts {
assert_eq!(t.ut1_minus_tai_seconds(when), expected);
}
}
#[test]
fn linear_interpolation_midpoint() {
let t = synthetic_table();
let v = t.ut1_minus_tai_seconds(1001.5);
assert!((v - 0.101_5).abs() < 1e-15, "got {v}");
}
#[test]
fn linear_interpolation_quarter_point() {
let t = synthetic_table();
let v = t.ut1_minus_tai_seconds(1002.25);
assert!((v - 0.102_25).abs() < 1e-15, "got {v}");
}
#[test]
#[should_panic(expected = "precedes first EOP table entry")]
fn out_of_range_before_panics_by_default() {
let t = synthetic_table();
let _ = t.ut1_minus_tai_seconds(999.999);
}
#[test]
#[should_panic(expected = "follows last EOP table entry")]
fn out_of_range_after_panics_by_default() {
let t = synthetic_table();
let _ = t.ut1_minus_tai_seconds(1004.5);
}
#[test]
fn clamp_opt_in_returns_boundary_value() {
let t = synthetic_table().with_clamp_out_of_range(true);
assert_eq!(t.ut1_minus_tai_seconds(900.0), 0.100);
assert_eq!(t.ut1_minus_tai_seconds(2000.0), 0.104);
}
#[test]
fn binary_round_trip() {
let t = synthetic_table();
let bytes = t.to_binary_bytes();
let back = EopTable::load_binary_from_bytes(&bytes).expect("round-trip decode");
assert_eq!(back.len(), t.len());
for i in 0..t.len() {
assert_eq!(back.when_tjt[i], t.when_tjt[i]);
assert_eq!(back.val_seconds[i], t.val_seconds[i]);
}
}
#[test]
fn binary_rejects_bad_magic() {
let mut bytes = synthetic_table().to_binary_bytes();
bytes[0] = b'X';
let err = EopTable::load_binary_from_bytes(&bytes).expect_err("bad magic must reject");
assert!(matches!(err, EopLoadError::BadMagic(_)));
}
#[test]
fn binary_rejects_bad_version() {
let mut bytes = synthetic_table().to_binary_bytes();
bytes[4] = 99;
let err = EopTable::load_binary_from_bytes(&bytes).expect_err("bad version must reject");
assert!(matches!(err, EopLoadError::BadVersion { .. }));
}
#[test]
fn parse_jeod_cc_minimal() {
let src = "
TimeConverter_TAI_UT1_ptr->last_index = 2;
TimeConverter_TAI_UT1_ptr->when_vec[0] = -2335.0; /* 1962 1 1 */
TimeConverter_TAI_UT1_ptr->val_vec[0] = -9.9673662;
TimeConverter_TAI_UT1_ptr->when_vec[1] = -2334.0; /* 1962 1 2 */
TimeConverter_TAI_UT1_ptr->val_vec[1] = -9.9679453;
TimeConverter_TAI_UT1_ptr->when_vec[2] = -2333.0; /* 1962 1 3 */
TimeConverter_TAI_UT1_ptr->val_vec[2] = -9.9684474;
";
let t = EopTable::parse_jeod_cc(src).expect("parse minimal JEOD source");
assert_eq!(t.len(), 3);
assert_eq!(t.first_tai_tjt(), -2335.0);
assert_eq!(t.last_tai_tjt(), -2333.0);
assert_eq!(t.ut1_minus_tai_seconds(-2334.0), -9.9679453);
}
#[test]
fn default_eop_table_loads() {
let t = default_eop_table();
assert!(
t.len() > 20_000,
"expected daily IERS EOP table, got {} entries",
t.len()
);
assert_eq!(t.first_tai_tjt(), -2335.0);
assert!(
t.last_tai_tjt() > 21_000.0,
"table should cover late-2020s epochs, got last TJT = {}",
t.last_tai_tjt()
);
}
#[test]
fn default_eop_table_matches_jeod_source_value() {
let t = default_eop_table();
let v = t.ut1_minus_tai_seconds(11_178.0);
assert_eq!(v, -31.2824458, "EOP at TAI TJT 11178");
}
}