Skip to main content

deep_time/eop/
mod.rs

1//! Earth orientation tables: load, interpolate, apply UT1 offsets.
2//!
3//! [`EopData`] is the table. Each [`EopDataRow`] is an epoch, an offset in
4//! seconds, and optional polar motion. [`EopData::eop_offset`] interpolates to
5//! an [`EopOffset`]. Loaders take an [`EopFormat`] and a [`Separator`].
6//! There is no [`Scale::UT1`](../enum.Scale.html).
7//!
8//! ## Formats
9//!
10//! - [`EopFormat::C04`] — IERS C04 / eopc04. UTC MJD, UT1−UTC.
11//! - [`EopFormat::Finals2000A`] — USNO finals. UTC MJD, UT1−UTC; Bulletin B
12//!   when filled, else A.
13//! - [`EopFormat::JplEop2`] — JPL EOP2. TAI MJD; file TAI−UT1 (ms) stored as
14//!   UT1−TAI (seconds).
15//! - [`EopFormat::Custom`] — column indices via [`CustomEopCols`], or build
16//!   with [`EopData::from_rows`].
17//!
18//! IERS loaders set [`EopData::epoch_scale`] to UTC and strip ~1 s leap jumps
19//! before interpolating. JPL and custom default to TAI and no leap-strip.
20//!
21//! ## Applying an offset
22//!
23//! [`Dt::to_ut1`](../struct.Dt.html#method.to_ut1) / [`Dt::from_ut1`](../struct.Dt.html#method.from_ut1)
24//! use the table’s epoch scale. [`Dt::to_eop`](../struct.Dt.html#method.to_eop)
25//! / [`Dt::from_eop`](../struct.Dt.html#method.from_eop) take an explicit table
26//! epoch (custom / other bodies).
27//!
28//! [`Dt::utc_mjd_to_ut1_mjd`](../struct.Dt.html#method.utc_mjd_to_ut1_mjd) maps
29//! a UTC MJD float to UT1 MJD (IAU/ERFA leap-day length), which is a different
30//! input from a [`Dt`](../struct.Dt.html).
31//!
32//! Out of range, interpolation holds the nearest endpoint. [`EopData::merge`]
33//! keeps the base table’s leap-strip and epoch-scale; do not merge IERS with JPL.
34
35#![allow(clippy::indexing_slicing)]
36#![allow(clippy::excessive_precision)]
37#![allow(clippy::approx_constant)]
38#![allow(clippy::eq_op)]
39
40use crate::{Dt, DtErr, DtErrKind, Real, Scale, an_err, floor_f, round};
41use alloc::string::String;
42use alloc::vec::Vec;
43use core::cmp::Ordering;
44
45/// Delimiter used to split columns in EOP data files.
46///
47/// Passed as the `separator` argument to the various `data_from_*` /
48/// `from_*` loaders. Controls how each line is tokenized before the parser
49/// extracts the epoch, offset, and polar-motion values.
50#[derive(Debug, Clone, Copy, Default)]
51pub enum Separator {
52    /// Split on any Unicode whitespace (default).
53    #[default]
54    Whitespace,
55    /// Comma-separated values (`,`).
56    Comma,
57    /// Tab-separated values (`\t`).
58    Tab,
59    /// Pipe-separated values (`|`).
60    Pipe,
61    /// Semicolon-separated values (`;`).
62    Semicolon,
63}
64
65/// File layout for the orientation-table parser.
66///
67/// - [`Finals2000A`](#variant.Finals2000A) — e.g.
68///   <https://maia.usno.navy.mil/ser7/finals2000A.all>
69/// - [`C04`](#variant.C04) — e.g.
70///   <https://datacenter.iers.org/data/latestVersion/EOP_20u24_C04_one_file_1962-now.txt>
71/// - [`JplEop2`](#variant.JplEop2) — e.g.
72///   <https://eop2-external.jpl.nasa.gov/eop2/latest_eop2.short>
73/// - [`Custom`](#variant.Custom) — column indices via [`CustomEopCols`]
74///
75/// IERS formats set [`EopData::strip_offset_leaps`] to `true`; JplEop2 and Custom
76/// to `false`.
77#[derive(Debug, Clone, Default)]
78pub enum EopFormat {
79    /// USNO finals2000A fixed-width lines.
80    ///
81    /// Uses final (B) columns when filled, otherwise rapid (A). Epoch is UTC MJD.
82    #[default]
83    Finals2000A,
84    /// IERS C04 / eopc04 long-term series. Epoch is UTC MJD.
85    C04,
86    /// JPL EOP2 comma-separated series (`latest_eop2.short` / `.long`).
87    ///
88    /// File columns: TAI MJD, PMx (mas), PMy (mas), TAI−UT1 (ms), …
89    /// Stored as: `epoch` = TAI MJD, `offset` = **UT1 − TAI** in seconds
90    /// (file ms/1000, then negated), `pm_x` / `pm_y` in arcseconds.
91    ///
92    /// [`to_ut1`](../struct.Dt.html#method.to_ut1) adds `offset`. For this
93    /// table that means TAI + (UT1 − TAI) = UT1.
94    ///
95    /// Always comma-split; the `separator` argument is ignored.
96    JplEop2,
97    /// User-defined column indices (0-based). Epoch is whatever that column holds.
98    Custom(CustomEopCols),
99}
100
101/// For use with [`EopFormat::Custom`].
102///
103/// 0-based column indices for a delimited orientation file.
104#[derive(Debug, Clone)]
105pub struct CustomEopCols {
106    /// 0-based column index of the time key (epoch).
107    pub epoch: usize,
108    /// 0-based column index of the orientation offset in **seconds**.
109    pub offset: usize,
110    /// Optional 0-based column index of polar motion *x* (arcseconds).
111    pub pm_x: Option<usize>,
112    /// Optional 0-based column index of polar motion *y* (arcseconds).
113    pub pm_y: Option<usize>,
114}
115
116/// One sample in an orientation table.
117///
118/// - `epoch` — time key of this sample (UTC MJD for IERS; TAI MJD for JPL EOP2;
119///   arbitrary float for custom / other-body tables)
120/// - `offset` — seconds to **add** to reach UT1 (UT1 − UTC in IERS files;
121///   UT1 − TAI in JPL EOP2)
122/// - `pm_x`, `pm_y` — polar motion in **arcseconds** (0 if unused)
123#[derive(Debug, Clone)]
124pub struct EopDataRow {
125    /// Time key of this sample (same units as queries to [`EopData::eop_offset`]).
126    pub epoch: Real,
127    /// Orientation offset in seconds (e.g. UT1−UTC).
128    pub offset: Real,
129    /// Polar motion x (arcsec).
130    pub pm_x: Real,
131    /// Polar motion y (arcsec).
132    pub pm_y: Real,
133}
134
135/// Sorted orientation-parameter table for a body.
136///
137/// Interpolate with [`eop_offset`](Self::eop_offset). On Earth, load IERS data
138/// and use [`Dt::to_ut1`](../struct.Dt.html#method.to_ut1). For other bodies,
139/// load custom rows and call [`Dt::to_eop`](../struct.Dt.html#method.to_eop)
140/// / [`Dt::from_eop`](../struct.Dt.html#method.from_eop) with the epoch you
141/// computed for that instant.
142#[derive(Debug, Clone)]
143pub struct EopData {
144    /// Sample rows, sorted by ascending [`EopDataRow::epoch`].
145    ///
146    /// Loaders and [`from_rows`](Self::from_rows) keep this order.
147    /// [`eop_offset`](Self::eop_offset) and [`merge`](Self::merge) assume it.
148    pub rows: Vec<EopDataRow>,
149    /// When true, a ~1 s jump between neighboring `offset` samples is removed
150    /// before interpolation (leap-second days on Earth UT1−UTC tables).
151    ///
152    /// Finals2000A / C04 loaders set this `true`; JplEop2 and Custom set `false`.
153    pub strip_offset_leaps: bool,
154    /// Time scale of each row’s [`EopDataRow::epoch`] MJD.
155    ///
156    /// [`Dt::to_ut1`](../struct.Dt.html#method.to_ut1) converts the `Dt` to this
157    /// scale before lookup. C04 / Finals2000A → [`Scale::UTC`]; JplEop2 and
158    /// [`from_rows`](Self::from_rows) → [`Scale::TAI`]. Override with
159    /// [`with_epoch_scale`](Self::with_epoch_scale).
160    pub epoch_scale: Scale,
161}
162
163impl EopData {
164    fn strip_for_format(format: &EopFormat) -> bool {
165        matches!(format, EopFormat::Finals2000A | EopFormat::C04)
166    }
167
168    fn epoch_scale_for_format(format: &EopFormat) -> Scale {
169        match format {
170            EopFormat::Finals2000A | EopFormat::C04 => Scale::UTC,
171            EopFormat::JplEop2 | EopFormat::Custom(_) => Scale::TAI,
172        }
173    }
174
175    fn from_parsed_rows(rows: Vec<EopDataRow>, format: &EopFormat) -> Self {
176        Self {
177            rows,
178            strip_offset_leaps: Self::strip_for_format(format),
179            epoch_scale: Self::epoch_scale_for_format(format),
180        }
181    }
182
183    /// Build a table from already-parsed rows (sorted by `epoch`).
184    ///
185    /// Set `strip_offset_leaps` true for Earth UT1−UTC-style leap days.
186    /// [`epoch_scale`](Self::epoch_scale) defaults to [`Scale::TAI`]; chain
187    /// [`with_epoch_scale`](Self::with_epoch_scale) for UTC-indexed tables.
188    #[must_use]
189    pub fn from_rows(mut rows: Vec<EopDataRow>, strip_offset_leaps: bool) -> Self {
190        rows.sort_by(|a, b| a.epoch.partial_cmp(&b.epoch).unwrap_or(Ordering::Equal));
191        Self {
192            rows,
193            strip_offset_leaps,
194            epoch_scale: Scale::TAI,
195        }
196    }
197
198    /// Set [`strip_offset_leaps`](Self::strip_offset_leaps).
199    #[must_use]
200    pub fn with_strip_offset_leaps(mut self, on: bool) -> Self {
201        self.strip_offset_leaps = on;
202        self
203    }
204
205    /// Set [`epoch_scale`](Self::epoch_scale).
206    #[must_use]
207    pub fn with_epoch_scale(mut self, scale: Scale) -> Self {
208        self.epoch_scale = scale;
209        self
210    }
211}
212
213#[cfg(feature = "std")]
214impl EopData {
215    /// Parse EOP data from any `std::io::BufRead` (file, network stream, etc.).
216    ///
217    /// Lines starting with `#` or longer than [`EopData::MAX_LINE_LEN`] are skipped.
218    /// The returned vector is always sorted by epoch.
219    pub fn data_from_reader<R: std::io::BufRead>(
220        mut reader: R,
221        format: EopFormat,
222        separator: Separator,
223    ) -> Result<Vec<EopDataRow>, DtErr> {
224        let mut line_buf = String::with_capacity(256);
225        let mut rows = Vec::new();
226
227        loop {
228            line_buf.clear();
229
230            let bytes_read = match reader.read_line(&mut line_buf) {
231                Ok(0) => break,
232                Ok(n) => n,
233                Err(e) => {
234                    return Err(an_err!(DtErrKind::IOErr, "{}", e));
235                }
236            };
237
238            if bytes_read > Self::MAX_LINE_LEN {
239                continue;
240            }
241
242            // Keep leading layout for fixed-width Finals; strip CR/LF only.
243            let line = line_buf.trim_end();
244            if Self::skip_eop_line(line, &format) {
245                continue;
246            }
247
248            if let Some(row) = Self::try_parse_row(line, &format, separator) {
249                rows.push(row);
250            }
251        }
252
253        if rows.is_empty() {
254            return Err(an_err!(DtErrKind::Empty));
255        }
256
257        rows.sort_by(|a, b| a.epoch.partial_cmp(&b.epoch).unwrap_or(Ordering::Equal));
258        Ok(rows)
259    }
260
261    /// Returns a [`Vec`] of [`EopDataRow`] from a text file on disk.
262    ///
263    /// ## Examples
264    ///
265    /// ```rust
266    /// # #[cfg(all(feature = "eop", feature = "std"))]
267    /// # {
268    /// use deep_time::eop::{EopData, EopFormat, Separator};
269    ///
270    /// let path = "tests/assets/finals.all.iau2000.txt";
271    /// let rows = EopData::data_from_text_file(path, EopFormat::Finals2000A, Separator::Whitespace).unwrap();
272    /// # }
273    /// ```
274    ///
275    /// ## See also
276    ///
277    /// - [`EopData::from_text_file`](#method.from_text_file)
278    pub fn data_from_text_file<P: AsRef<std::path::Path>>(
279        path: P,
280        format: EopFormat,
281        separator: Separator,
282    ) -> Result<Vec<EopDataRow>, DtErr> {
283        use std::fs::File;
284        use std::io::BufReader;
285
286        let path = path.as_ref();
287        let file = File::open(path).map_err(|e| an_err!(DtErrKind::IOErr, "{}", e))?;
288
289        let reader = BufReader::new(file);
290        Self::data_from_reader(reader, format, separator)
291    }
292
293    /// Create an [`EopData`] by loading from a text file on disk.
294    ///
295    /// ## Examples
296    ///
297    /// ```rust
298    /// # #[cfg(all(feature = "eop", feature = "std"))]
299    /// # {
300    /// use deep_time::eop::{EopData, EopFormat, Separator};
301    ///
302    /// let path = "tests/assets/finals.all.iau2000.txt";
303    /// let provider = EopData::from_text_file(path, EopFormat::Finals2000A, Separator::Whitespace).unwrap();
304    /// # }
305    /// ```
306    pub fn from_text_file<P: AsRef<std::path::Path>>(
307        path: P,
308        format: EopFormat,
309        separator: Separator,
310    ) -> Result<Self, DtErr> {
311        let rows = Self::data_from_text_file(path, format.clone(), separator)?;
312        Ok(Self::from_parsed_rows(rows, &format))
313    }
314}
315
316impl EopData {
317    /// Maximum accepted length of a single input line when parsing EOP text.
318    pub const MAX_LINE_LEN: usize = 8192;
319
320    /// Parse a single EOP row.
321    ///
322    /// For Finals, `line` should keep leading layout (only trailing newline
323    /// stripped by the caller). C04/Custom trim when tokenizing.
324    fn try_parse_row(line: &str, format: &EopFormat, separator: Separator) -> Option<EopDataRow> {
325        match format {
326            // USNO finals2000A fixed-width (CDS ReadMe). Prefer B when filled, else A.
327            EopFormat::Finals2000A => {
328                // Need at least through Bulletin A UT1-UTC (bytes 59–68, 1-based).
329                if line.len() < 68 {
330                    return None;
331                }
332
333                let field = |s: &str| -> Option<Real> {
334                    let t = s.trim();
335                    if t.is_empty() { None } else { t.parse().ok() }
336                };
337
338                // 1-based CDS columns → 0-based half-open slices
339                let epoch = field(line.get(7..15)?)?;
340                let pm_x_a = line.get(18..27).and_then(field);
341                let pm_y_a = line.get(37..46).and_then(field);
342                let ut1_a = field(line.get(58..68)?)?;
343
344                let ut1_b = if line.len() >= 165 {
345                    line.get(154..165).and_then(field)
346                } else {
347                    None
348                };
349                let pm_x_b = if line.len() >= 144 {
350                    line.get(134..144).and_then(field)
351                } else {
352                    None
353                };
354                let pm_y_b = if line.len() >= 154 {
355                    line.get(144..154).and_then(field)
356                } else {
357                    None
358                };
359
360                let offset = ut1_b.unwrap_or(ut1_a);
361                let (pm_x, pm_y) = match (pm_x_b, pm_y_b) {
362                    (Some(x), Some(y)) => (x, y),
363                    _ => (pm_x_a.unwrap_or(0.0), pm_y_a.unwrap_or(0.0)),
364                };
365
366                Some(EopDataRow {
367                    epoch,
368                    offset,
369                    pm_x,
370                    pm_y,
371                })
372            }
373
374            EopFormat::C04 => {
375                let parts = Self::split_eop_line(line, separator);
376                if parts.len() < 2 {
377                    return None;
378                }
379                let epoch = parts.get(4)?.parse::<Real>().ok()?;
380                let pm_x = parts
381                    .get(5)
382                    .unwrap_or(&"0.0")
383                    .parse::<Real>()
384                    .unwrap_or(0.0);
385                let pm_y = parts
386                    .get(6)
387                    .unwrap_or(&"0.0")
388                    .parse::<Real>()
389                    .unwrap_or(0.0);
390                let offset = parts.get(7)?.parse::<Real>().ok()?;
391                Some(EopDataRow {
392                    epoch,
393                    offset,
394                    pm_x,
395                    pm_y,
396                })
397            }
398
399            EopFormat::JplEop2 => {
400                let parts = Self::split_eop_line(line, Separator::Comma);
401                if parts.len() < 4 {
402                    return None;
403                }
404                let epoch = parts.first()?.parse::<Real>().ok()?;
405                let pm_x = parts.get(1)?.parse::<Real>().ok()? / 1000.0;
406                let pm_y = parts.get(2)?.parse::<Real>().ok()? / 1000.0;
407                // File is TAI−UT1 in ms. Store UT1−TAI in seconds so `to_ut1` adds it.
408                let offset = -(parts.get(3)?.parse::<Real>().ok()? / 1000.0);
409                Some(EopDataRow {
410                    epoch,
411                    offset,
412                    pm_x,
413                    pm_y,
414                })
415            }
416
417            EopFormat::Custom(cols) => {
418                let parts = Self::split_eop_line(line, separator);
419                if parts.len() < 2 {
420                    return None;
421                }
422                let epoch = parts.get(cols.epoch)?.parse::<Real>().ok()?;
423                let offset = parts.get(cols.offset)?.parse::<Real>().ok()?;
424                let pm_x = if let Some(pm_x_col) = cols.pm_x {
425                    parts
426                        .get(pm_x_col)
427                        .unwrap_or(&"0.0")
428                        .parse::<Real>()
429                        .ok()
430                        .unwrap_or(0.0)
431                } else {
432                    0.0
433                };
434                let pm_y = if let Some(pm_y_col) = cols.pm_y {
435                    parts
436                        .get(pm_y_col)
437                        .unwrap_or(&"0.0")
438                        .parse::<Real>()
439                        .ok()
440                        .unwrap_or(0.0)
441                } else {
442                    0.0
443                };
444                Some(EopDataRow {
445                    epoch,
446                    offset,
447                    pm_x,
448                    pm_y,
449                })
450            }
451        }
452    }
453
454    fn split_eop_line(line: &str, separator: Separator) -> Vec<&str> {
455        let trimmed = line.trim();
456        match separator {
457            Separator::Whitespace => trimmed.split_whitespace().collect(),
458            Separator::Comma => trimmed.split(',').map(|s| s.trim()).collect(),
459            Separator::Tab => trimmed.split('\t').map(|s| s.trim()).collect(),
460            Separator::Pipe => trimmed.split('|').map(|s| s.trim()).collect(),
461            Separator::Semicolon => trimmed.split(';').map(|s| s.trim()).collect(),
462        }
463    }
464
465    fn skip_eop_line(line: &str, format: &EopFormat) -> bool {
466        if line.is_empty() || line.len() > Self::MAX_LINE_LEN {
467            return true;
468        }
469        if line.starts_with('#') {
470            return true;
471        }
472        match format {
473            EopFormat::C04 | EopFormat::Custom(_) => line.trim_start().starts_with('#'),
474            EopFormat::JplEop2 => {
475                let t = line.trim_start();
476                t.starts_with('$') || t.starts_with("EOP2=") || t.starts_with("EOP2L")
477            }
478            EopFormat::Finals2000A => false,
479        }
480    }
481
482    fn parse_lines<'a>(
483        lines: impl Iterator<Item = &'a str>,
484        format: EopFormat,
485        separator: Separator,
486    ) -> Result<Vec<EopDataRow>, DtErr> {
487        let mut rows = Vec::new();
488
489        for line in lines {
490            // Keep leading columns for fixed-width Finals; only strip ends.
491            let line = line.trim_end();
492            if Self::skip_eop_line(line, &format) {
493                continue;
494            }
495
496            if let Some(row) = Self::try_parse_row(line, &format, separator) {
497                rows.push(row);
498            }
499        }
500
501        if rows.is_empty() {
502            return Err(an_err!(DtErrKind::Empty));
503        }
504
505        rows.sort_by(|a, b| a.epoch.partial_cmp(&b.epoch).unwrap_or(Ordering::Equal));
506        Ok(rows)
507    }
508
509    /// Parse EOP data from a `&str`.
510    ///
511    /// Useful when the data is already in memory (embedded resource,
512    /// downloaded string, etc.).
513    pub fn data_from_str(
514        s: &str,
515        format: EopFormat,
516        separator: Separator,
517    ) -> Result<Vec<EopDataRow>, DtErr> {
518        Self::parse_lines(s.lines(), format, separator)
519    }
520
521    /// Parse EOP data from raw bytes.
522    ///
523    /// The bytes are interpreted as UTF-8. Invalid UTF-8 sequences
524    /// result in an empty string (and therefore an error).
525    pub fn data_from_bytes(
526        bytes: &[u8],
527        format: EopFormat,
528        separator: Separator,
529    ) -> Result<Vec<EopDataRow>, DtErr> {
530        let s = core::str::from_utf8(bytes).unwrap_or("");
531        Self::data_from_str(s, format, separator)
532    }
533
534    /// Create an [`EopData`] from a string slice.
535    pub fn from_str(s: &str, format: EopFormat, separator: Separator) -> Result<Self, DtErr> {
536        let rows = Self::data_from_str(s, format.clone(), separator)?;
537        Ok(Self::from_parsed_rows(rows, &format))
538    }
539
540    /// Create an [`EopData`] from raw bytes.
541    pub fn from_bytes(
542        bytes: &[u8],
543        format: EopFormat,
544        separator: Separator,
545    ) -> Result<Self, DtErr> {
546        let rows = Self::data_from_bytes(bytes, format.clone(), separator)?;
547        Ok(Self::from_parsed_rows(rows, &format))
548    }
549
550    /// Merge rows from `other` into `self` by epoch.
551    ///
552    /// For each row in `other`:
553    ///
554    /// - **Same epoch already in `self`:** if `overwrite_rows`, replace
555    ///   `offset` / `pm_x` / `pm_y`; otherwise leave `self`’s row as-is.
556    /// - **Epoch not in `self`:** if `add_rows`, insert the row (table stays
557    ///   sorted by epoch); otherwise skip it.
558    ///
559    /// Both flags may be true or only one. If both are false, this is a no-op.
560    /// [`strip_offset_leaps`](Self::strip_offset_leaps) and
561    /// [`epoch_scale`](Self::epoch_scale) on `self` are unchanged.
562    ///
563    /// ## Example
564    ///
565    /// ```rust
566    /// # #[cfg(all(feature = "eop", feature = "std"))]
567    /// # {
568    /// use deep_time::eop::{EopData, EopFormat, Separator};
569    ///
570    /// let mut eop = EopData::from_text_file(
571    ///     "tests/assets/EOP_20u24_C04_one_file_1962-now.txt",
572    ///     EopFormat::C04,
573    ///     Separator::Whitespace,
574    /// ).unwrap();
575    /// let finals = EopData::from_text_file(
576    ///     "tests/assets/finals.all.iau2000.txt",
577    ///     EopFormat::Finals2000A,
578    ///     Separator::Whitespace,
579    /// ).unwrap();
580    /// // Keep C04 on overlap; append Finals prediction days only.
581    /// eop.merge(&finals, true, false);
582    /// # }
583    /// ```
584    pub fn merge(&mut self, other: &EopData, add_rows: bool, overwrite_rows: bool) {
585        if !add_rows && !overwrite_rows {
586            return;
587        }
588        for src in &other.rows {
589            match self.rows.binary_search_by(|probe| {
590                probe
591                    .epoch
592                    .partial_cmp(&src.epoch)
593                    .unwrap_or(Ordering::Equal)
594            }) {
595                Ok(i) => {
596                    if overwrite_rows {
597                        let dst = &mut self.rows[i];
598                        dst.offset = src.offset;
599                        dst.pm_x = src.pm_x;
600                        dst.pm_y = src.pm_y;
601                    }
602                }
603                Err(i) => {
604                    if add_rows {
605                        self.rows.insert(i, src.clone());
606                    }
607                }
608            }
609        }
610    }
611
612    /// Convenience: [`merge`](Self::merge) consuming `self`.
613    #[must_use]
614    pub fn with_merge(mut self, other: &EopData, add_rows: bool, overwrite_rows: bool) -> Self {
615        self.merge(other, add_rows, overwrite_rows);
616        self
617    }
618
619    /// Interpolated orientation parameters at `epoch`.
620    ///
621    /// Linear blend between neighboring samples. If
622    /// [`strip_offset_leaps`](Self::strip_offset_leaps) is set, a ~1 s jump in
623    /// `offset` between those samples is removed before blending. Outside the
624    /// table range the nearest endpoint is held. Returns `None` if empty.
625    pub fn eop_offset(&self, epoch: Real) -> Option<EopOffset> {
626        if self.rows.is_empty() {
627            return None;
628        }
629
630        // Match Astropy `searchsorted(..., side="right") - 1`: left sample is
631        // the last row with epoch_row <= query (except before the first row).
632        let idx = match self
633            .rows
634            .binary_search_by(|probe| probe.epoch.partial_cmp(&epoch).unwrap_or(Ordering::Equal))
635        {
636            Ok(i) => i,
637            Err(i) => {
638                if i == 0 {
639                    let row = &self.rows[0];
640                    return Some(EopOffset {
641                        offset: row.offset,
642                        pm_x: row.pm_x,
643                        pm_y: row.pm_y,
644                    });
645                }
646                if i >= self.rows.len() {
647                    let row = &self.rows[self.rows.len() - 1];
648                    return Some(EopOffset {
649                        offset: row.offset,
650                        pm_x: row.pm_x,
651                        pm_y: row.pm_y,
652                    });
653                }
654                i - 1
655            }
656        };
657
658        if idx + 1 < self.rows.len() {
659            let e0 = &self.rows[idx];
660            let e1 = &self.rows[idx + 1];
661
662            let span = e1.epoch - e0.epoch;
663            // Exact table epoch (or degenerate span): no blend needed.
664            if span == 0.0 || epoch == e0.epoch {
665                return Some(EopOffset {
666                    offset: e0.offset,
667                    pm_x: e0.pm_x,
668                    pm_y: e0.pm_y,
669                });
670            }
671
672            let t = (epoch - e0.epoch) / span;
673
674            let mut d_offset = e1.offset - e0.offset;
675            if self.strip_offset_leaps {
676                d_offset -= round(d_offset);
677            }
678
679            let offset = e0.offset + t * d_offset;
680            let pm_x = e0.pm_x + t * (e1.pm_x - e0.pm_x);
681            let pm_y = e0.pm_y + t * (e1.pm_y - e0.pm_y);
682
683            Some(EopOffset { offset, pm_x, pm_y })
684        } else {
685            let row = &self.rows[idx];
686            Some(EopOffset {
687                offset: row.offset,
688                pm_x: row.pm_x,
689                pm_y: row.pm_y,
690            })
691        }
692    }
693}
694
695/// Interpolated orientation parameters at one epoch.
696#[derive(Debug, Clone, Default)]
697pub struct EopOffset {
698    /// Offset in **seconds** (e.g. UT1 − UTC on Earth).
699    pub offset: Real,
700    /// Polar motion x-coordinate in **arcseconds**.
701    pub pm_x: Real,
702    /// Polar motion y-coordinate in **arcseconds**.
703    pub pm_y: Real,
704}
705
706impl Dt {
707    /// Full [`EopOffset`] at a table epoch.
708    ///
709    /// Outside the table the nearest endpoint is held. Errors only when the
710    /// table is empty.
711    pub fn eop_offset_at(epoch: Real, op_data: &EopData) -> Result<EopOffset, DtErr> {
712        op_data
713            .eop_offset(epoch)
714            .ok_or_else(|| an_err!(DtErrKind::Empty, "{epoch}"))
715    }
716
717    /// Offset in seconds at a table epoch.
718    #[inline]
719    pub fn eop_offset_at_f(epoch: Real, op_data: &EopData) -> Result<Real, DtErr> {
720        Self::eop_offset_at(epoch, op_data).map(|res| res.offset)
721    }
722
723    /// Same as [`Dt::eop_offset_at`](../struct.Dt.html#method.eop_offset_at).
724    #[inline]
725    pub fn mjd_to_eop_offset(mjd: Real, op_data: &EopData) -> Result<EopOffset, DtErr> {
726        Self::eop_offset_at(mjd, op_data)
727    }
728
729    /// Same as [`Dt::eop_offset_at_f`](../struct.Dt.html#method.eop_offset_at_f).
730    #[inline]
731    pub fn mjd_to_eop_offset_f(mjd: Real, op_data: &EopData) -> Result<Real, DtErr> {
732        Self::eop_offset_at_f(mjd, op_data)
733    }
734
735    /// UT1 MJD from a UTC MJD (IAU / ERFA / Astropy convention).
736    ///
737    /// The fractional part of `mjd_utc` is a fraction of the UTC day, which
738    /// is 86 401 SI seconds on a leap-second insertion day (see
739    /// [`Dt::utc_day_length_sec`](../struct.Dt.html#method.utc_day_length_sec)). Then:
740    ///
741    /// ```text
742    /// UT1 MJD = day + (frac × UTC_day_length + DUT1) / 86400
743    /// ```
744    ///
745    /// On a normal day this is `mjd_utc + DUT1/86400`. On a leap-insertion
746    /// interior it is **not** — Astropy's `Time(mjd, scale="utc").ut1.mjd`
747    /// follows this same stretch.
748    ///
749    /// DUT1 is interpolated at `mjd_utc` (IERS table epoch).
750    pub fn utc_mjd_to_ut1_mjd(mjd_utc: Real, op_data: &EopData) -> Result<Real, DtErr> {
751        let dut1 = Self::mjd_to_eop_offset_f(mjd_utc, op_data)?;
752        let day = floor_f(mjd_utc);
753        let frac = mjd_utc - day;
754        let day_len = Self::utc_day_length_sec(mjd_utc);
755        Ok(day + (frac * day_len + dut1) / 86_400.0)
756    }
757
758    /// Convert this [`Dt`](../struct.Dt.html) to UT1 using the table’s
759    /// [`EopData::epoch_scale`].
760    ///
761    /// The `Dt` is converted to that scale, the table is interpolated at the
762    /// resulting MJD, and the offset is added to that converted instant.
763    /// IERS tables (UTC, UT1−UTC) and JPL EOP2 (TAI, UT1−TAI) both work
764    /// without placing the `Dt` on the table scale first.
765    ///
766    /// When the table’s
767    /// [`EopData::strip_offset_leaps`]
768    /// field is `true`, a ~1 s jump between neighboring offset samples is
769    /// removed before interpolating. C04 / Finals loaders set it `true`; JPL
770    /// EOP2 and custom tables leave it `false`. Change the field on the table,
771    /// or use
772    /// [`EopData::with_strip_offset_leaps`],
773    /// if you want the other behaviour.
774    ///
775    /// Returned attoseconds are the UT1 clock. There is no `Scale::UT1`.
776    /// Leap seconds are not smeared through the UTC day; for a UTC MJD *float*
777    /// in the IAU/ERFA sense see [`Dt::utc_mjd_to_ut1_mjd`](../struct.Dt.html#method.utc_mjd_to_ut1_mjd).
778    ///
779    /// For an explicit table epoch use [`Dt::to_eop`](../struct.Dt.html#method.to_eop).
780    pub fn to_ut1(&self, op_data: &EopData) -> Result<Self, DtErr> {
781        let on_epoch = self.to(op_data.epoch_scale);
782        on_epoch.to_eop(op_data, on_epoch.to_mjd_f_raw())
783    }
784
785    /// Inverse of [`Dt::to_ut1`](../struct.Dt.html#method.to_ut1): subtract the table offset looked
786    /// up at the table’s epoch scale (fixed-point).
787    ///
788    /// Uses the table’s
789    /// [`EopData::strip_offset_leaps`]
790    /// field the same way as [`Dt::to_ut1`](../struct.Dt.html#method.to_ut1) (`true` for C04 / Finals,
791    /// `false` for JPL EOP2). Change the field on the table if you want the
792    /// other behaviour.
793    ///
794    /// For an explicit table epoch use [`Dt::from_eop`](../struct.Dt.html#method.from_eop).
795    pub fn from_ut1(&self, op_data: &EopData) -> Result<Self, DtErr> {
796        if op_data.rows.is_empty() {
797            return Err(an_err!(DtErrKind::Empty));
798        }
799        let ut1 = self.to(op_data.epoch_scale);
800        let mut without = ut1;
801
802        for _ in 0..8 {
803            let epoch = without.to_mjd_f_raw();
804            let offset = op_data
805                .eop_offset(epoch)
806                .ok_or_else(|| an_err!(DtErrKind::Empty, "{epoch}"))?
807                .offset;
808
809            without = ut1.sub(Dt::from_sec_f(offset, Scale::TAI, Scale::TAI));
810        }
811
812        Ok(without)
813    }
814
815    /// Add the table offset at the given `epoch` (seconds).
816    ///
817    /// `epoch` is the file’s time index (UTC MJD for IERS, TAI MJD for JPL
818    /// EOP2, whatever was stored for [`EopFormat::Custom`]).
819    #[inline]
820    pub fn to_eop(&self, op_data: &EopData, epoch: Real) -> Result<Self, DtErr> {
821        Ok(self.add(Dt::from_sec_f(
822            Self::eop_offset_at_f(epoch, op_data)?,
823            Scale::TAI,
824            Scale::TAI,
825        )))
826    }
827
828    /// Subtract the table offset at the given `epoch` (seconds).
829    ///
830    /// `epoch` is the file’s time index (UTC MJD for IERS, TAI MJD for JPL
831    /// EOP2, whatever was stored for [`EopFormat::Custom`]).
832    ///
833    /// If you do not have that table epoch and the table is keyed on a
834    /// [`Scale`] MJD, use [`Dt::from_ut1`](../struct.Dt.html#method.from_ut1) instead.
835    #[inline]
836    pub fn from_eop(&self, op_data: &EopData, epoch: Real) -> Result<Self, DtErr> {
837        Ok(self.sub(Dt::from_sec_f(
838            Self::eop_offset_at_f(epoch, op_data)?,
839            Scale::TAI,
840            Scale::TAI,
841        )))
842    }
843}