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}