Skip to main content

deep_time/eop/
mod.rs

1//! Earth Orientation Parameters (EOP) data parser and interpolator.
2//!
3//! Loads UT1–UTC offsets and polar motion (x, y) from standard IERS formats
4//! (`Finals2000A`, `C04`) or custom column layouts.
5//!
6//! Provides interpolation at any MJD and integrates with [`Dt`]
7//! for UT1 time scale conversions.
8//!
9//! Tries to be planet/body agnostic, such that custom data for any world
10//! might be able to be loaded and used.
11
12#![allow(clippy::indexing_slicing)]
13#![allow(clippy::excessive_precision)]
14#![allow(clippy::approx_constant)]
15#![allow(clippy::eq_op)]
16
17use crate::{Dt, DtErr, DtErrKind, Real, Scale, an_err};
18use alloc::string::String;
19use alloc::vec::Vec;
20use core::cmp::Ordering;
21
22/// Delimiter used to split columns in EOP data files.
23///
24/// Passed as the `separator` argument to:
25/// - [`EopData::data_from_reader`]
26/// - [`EopData::data_from_text_file`]
27/// - [`EopData::from_text_file`]
28/// - [`EopData::data_from_str`]
29/// - [`EopData::from_str`]
30/// - [`EopData::data_from_bytes`]
31/// - [`EopData::from_bytes`]
32///
33/// It controls how each line is tokenized before the parser extracts
34/// the MJD, offset, and polar-motion values.
35#[derive(Debug, Clone, Copy, Default)]
36pub enum Separator {
37    /// Split on any Unicode whitespace (default).
38    #[default]
39    Whitespace,
40    /// Comma-separated values (`,`).
41    Comma,
42    /// Tab-separated values (`\t`).
43    Tab,
44    /// Pipe-separated values (`|`).
45    Pipe,
46    /// Semicolon-separated values (`;`).
47    Semicolon,
48}
49
50/// Earth/Body Orientation Parameters Format.
51///
52/// Formats to provide to the parser, including a
53/// custom one to allow specific column indices.
54///
55/// - `Finals2000A` such as is available from
56///   <https://maia.usno.navy.mil/ser7/finals2000A.all>
57/// - `C04` such as is available from
58///   <https://datacenter.iers.org/data/latestVersion/EOP_20u24_C04_one_file_1962-now.txt>
59/// - `Custom` so you can provide your own specific column indices
60///   using [`CustomEopCols`].
61#[derive(Debug, Clone, Default)]
62pub enum EopFormat {
63    /// finals2000A.all / finals.all.iau2000.txt style files
64    #[default]
65    Finals2000A,
66    /// C04 long-term series
67    C04,
68    /// User-defined column indices (0-based)
69    Custom(CustomEopCols),
70}
71
72/// For use with [`EopFormat::Custom`].
73///
74/// Allows you to specify exactly which 0-based column contains each value
75/// when your input file does not match a standard IERS layout.
76#[derive(Debug, Clone)]
77pub struct CustomEopCols {
78    /// 0-based column index of the Modified Julian Date.
79    pub mjd: usize,
80    /// 0-based column index of the UT1−UTC (or equivalent) offset in seconds.
81    pub offset: usize,
82    /// Optional 0-based column index of polar motion *x* (arcseconds).
83    pub pm_x: Option<usize>,
84    /// Optional 0-based column index of polar motion *y* (arcseconds).
85    pub pm_y: Option<usize>,
86}
87
88/// A single parsed row of Earth Orientation Parameters.
89///
90/// - `mjd` — Modified Julian Date
91/// - `offset` — UT1 − UTC (or equivalent) in **seconds**
92/// - `pm_x`, `pm_y` — Polar motion in **arcseconds**
93#[derive(Debug, Clone)]
94pub struct EopDataRow {
95    /// Modified Julian Date of this sample.
96    pub mjd: Real,
97    /// e.g. UT1-UTC(s)
98    pub offset: Real,
99    /// polar motion x (arcsec)
100    pub pm_x: Real,
101    /// polar motion y (arcsec)
102    pub pm_y: Real,
103}
104
105/// Container for Earth/Body Orientation Parameters data.
106///
107/// - On Earth this would enable time scale conversions to and from
108///   the **UT1 time scale**.
109/// - Earth Orientation Parameters data is available from: <https://maia.usno.navy.mil/ser7/finals2000A.all>
110#[derive(Debug, Clone)]
111pub struct EopData {
112    rows: Vec<EopDataRow>,
113}
114
115#[cfg(feature = "std")]
116impl EopData {
117    /// Parse EOP data from any `std::io::BufRead` (file, network stream, etc.).
118    ///
119    /// Lines starting with `#` or longer than [`EopData::MAX_LINE_LEN`] are skipped.
120    /// The returned vector is always sorted by MJD.
121    pub fn data_from_reader<R: std::io::BufRead>(
122        mut reader: R,
123        format: EopFormat,
124        separator: Separator,
125    ) -> Result<Vec<EopDataRow>, DtErr> {
126        let mut line_buf = String::with_capacity(256);
127        let mut rows = Vec::new();
128
129        loop {
130            line_buf.clear();
131
132            let bytes_read = match reader.read_line(&mut line_buf) {
133                Ok(0) => break,
134                Ok(n) => n,
135                Err(e) => {
136                    return Err(an_err!(DtErrKind::IOErr, "{}", e));
137                }
138            };
139
140            if bytes_read > Self::MAX_LINE_LEN {
141                continue;
142            }
143
144            let trimmed = line_buf.trim();
145            if trimmed.is_empty() || trimmed.starts_with('#') {
146                continue;
147            }
148
149            if let Some(row) = Self::try_parse_row(trimmed, &format, separator) {
150                rows.push(row);
151            }
152        }
153
154        if rows.is_empty() {
155            return Err(an_err!(DtErrKind::Empty));
156        }
157
158        rows.sort_by(|a, b| a.mjd.partial_cmp(&b.mjd).unwrap_or(Ordering::Equal));
159        Ok(rows)
160    }
161
162    /// Returns a [`Vec`] of [`EopDataRow`] from a text file on disk.
163    ///
164    /// ## Examples
165    ///
166    /// ```rust
167    /// # #[cfg(all(feature = "eop", feature = "std"))]
168    /// # {
169    /// use deep_time::eop::{EopData, EopFormat, Separator};
170    ///
171    /// let path = "tests/assets/finals.all.iau2000.txt";
172    /// let rows = EopData::data_from_text_file(path, EopFormat::Finals2000A, Separator::Whitespace).unwrap();
173    /// # }
174    /// ```
175    ///
176    /// ## See also
177    ///
178    /// - [`EopData::from_text_file`](#method.from_text_file)
179    pub fn data_from_text_file<P: AsRef<std::path::Path>>(
180        path: P,
181        format: EopFormat,
182        separator: Separator,
183    ) -> Result<Vec<EopDataRow>, DtErr> {
184        use std::fs::File;
185        use std::io::BufReader;
186
187        let path = path.as_ref();
188        let file = File::open(path).map_err(|e| an_err!(DtErrKind::IOErr, "{}", e))?;
189
190        let reader = BufReader::new(file);
191        Self::data_from_reader(reader, format, separator)
192    }
193
194    /// Create an [`EopData`] by loading from a text file on disk.
195    ///
196    /// ## Examples
197    ///
198    /// ```rust
199    /// # #[cfg(all(feature = "eop", feature = "std"))]
200    /// # {
201    /// use deep_time::eop::{EopData, EopFormat, Separator};
202    ///
203    /// let path = "tests/assets/finals.all.iau2000.txt";
204    /// let provider = EopData::from_text_file(path, EopFormat::Finals2000A, Separator::Whitespace).unwrap();
205    /// # }
206    /// ```
207    pub fn from_text_file<P: AsRef<std::path::Path>>(
208        path: P,
209        format: EopFormat,
210        separator: Separator,
211    ) -> Result<Self, DtErr> {
212        let rows = Self::data_from_text_file(path, format, separator)?;
213        Ok(Self { rows })
214    }
215}
216
217impl EopData {
218    /// Maximum accepted length of a single input line when parsing EOP text.
219    pub const MAX_LINE_LEN: usize = 8192;
220
221    // Small helper — parses ONE row (shared by all paths)
222    fn try_parse_row(
223        trimmed: &str,
224        format: &EopFormat,
225        separator: Separator,
226    ) -> Option<EopDataRow> {
227        let parts: Vec<&str> = match separator {
228            Separator::Whitespace => trimmed.split_whitespace().collect(),
229            Separator::Comma => trimmed.split(',').map(|s| s.trim()).collect(),
230            Separator::Tab => trimmed.split('\t').map(|s| s.trim()).collect(),
231            Separator::Pipe => trimmed.split('|').map(|s| s.trim()).collect(),
232            Separator::Semicolon => trimmed.split(';').map(|s| s.trim()).collect(),
233        };
234
235        if parts.len() < 2 {
236            return None;
237        }
238
239        let (mjd, offset, pm_x, pm_y) = match format {
240            EopFormat::Finals2000A => {
241                let mjd_idx = parts.iter().position(|p| {
242                    p.contains('.') && p.parse::<Real>().is_ok_and(|v| v > 30000.0)
243                })?;
244
245                let mut flag_count = 0;
246                let mut offset_value: Option<Real> = None;
247                let mut pm_x_val: Real = 0.0;
248                let mut pm_y_val: Real = 0.0;
249
250                for i in (mjd_idx + 1)..parts.len() {
251                    let token = parts[i];
252
253                    let is_flag = token == "I"
254                        || token == "P"
255                        || token.starts_with("I-")
256                        || token.starts_with("P-");
257
258                    if is_flag {
259                        flag_count += 1;
260
261                        if flag_count == 1 {
262                            // After first flag: x_p and y_p
263                            if let (Some(x_str), Some(y_str)) = (parts.get(i + 1), parts.get(i + 3))
264                            {
265                                pm_x_val = x_str.parse::<Real>().unwrap_or(0.0);
266                                pm_y_val = y_str.parse::<Real>().unwrap_or(0.0);
267                            }
268                        }
269
270                        if flag_count == 2 {
271                            let value_str = if token.starts_with("I-") || token.starts_with("P-") {
272                                &token[1..]
273                            } else if i + 1 < parts.len() {
274                                parts[i + 1]
275                            } else {
276                                break;
277                            };
278                            if let Ok(val) = value_str.parse::<Real>() {
279                                offset_value = Some(val);
280                            }
281                            break;
282                        }
283                    }
284                }
285
286                let offset = offset_value?;
287                let mjd_val = parts[mjd_idx].parse::<Real>().ok()?;
288
289                (mjd_val, offset, pm_x_val, pm_y_val)
290            }
291
292            EopFormat::C04 => {
293                let mjd = parts.get(4)?.parse::<Real>().ok()?;
294                let pm_x = parts
295                    .get(5)
296                    .unwrap_or(&"0.0")
297                    .parse::<Real>()
298                    .unwrap_or(0.0);
299                let pm_y = parts
300                    .get(6)
301                    .unwrap_or(&"0.0")
302                    .parse::<Real>()
303                    .unwrap_or(0.0);
304                let offset = parts.get(7)?.parse::<Real>().ok()?;
305                (mjd, offset, pm_x, pm_y)
306            }
307
308            EopFormat::Custom(cols) => {
309                let mjd = parts.get(cols.mjd)?.parse::<Real>().ok()?;
310                let offset = parts.get(cols.offset)?.parse::<Real>().ok()?;
311                let pm_x = if let Some(pm_x_col) = cols.pm_x {
312                    parts
313                        .get(pm_x_col)
314                        .unwrap_or(&"0.0")
315                        .parse::<Real>()
316                        .ok()
317                        .unwrap_or(0.0)
318                } else {
319                    0.0
320                };
321                let pm_y = if let Some(pm_y_col) = cols.pm_y {
322                    parts
323                        .get(pm_y_col)
324                        .unwrap_or(&"0.0")
325                        .parse::<Real>()
326                        .ok()
327                        .unwrap_or(0.0)
328                } else {
329                    0.0
330                };
331                (mjd, offset, pm_x, pm_y)
332            }
333        };
334
335        Some(EopDataRow {
336            mjd,
337            offset,
338            pm_x,
339            pm_y,
340        })
341    }
342
343    fn parse_lines<'a>(
344        lines: impl Iterator<Item = &'a str>,
345        format: EopFormat,
346        separator: Separator,
347    ) -> Result<Vec<EopDataRow>, DtErr> {
348        let mut rows = Vec::new();
349
350        for line in lines {
351            let trimmed = line.trim();
352            if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.len() > Self::MAX_LINE_LEN
353            {
354                continue;
355            }
356
357            if let Some(row) = Self::try_parse_row(trimmed, &format, separator) {
358                rows.push(row);
359            }
360        }
361
362        if rows.is_empty() {
363            return Err(an_err!(DtErrKind::Empty));
364        }
365
366        rows.sort_by(|a, b| a.mjd.partial_cmp(&b.mjd).unwrap_or(Ordering::Equal));
367        Ok(rows)
368    }
369
370    /// Parse EOP data from a `&str`.
371    ///
372    /// Useful when the data is already in memory (embedded resource,
373    /// downloaded string, etc.).
374    pub fn data_from_str(
375        s: &str,
376        format: EopFormat,
377        separator: Separator,
378    ) -> Result<Vec<EopDataRow>, DtErr> {
379        Self::parse_lines(s.lines(), format, separator)
380    }
381
382    /// Parse EOP data from raw bytes.
383    ///
384    /// The bytes are interpreted as UTF-8. Invalid UTF-8 sequences
385    /// result in an empty string (and therefore an error).
386    pub fn data_from_bytes(
387        bytes: &[u8],
388        format: EopFormat,
389        separator: Separator,
390    ) -> Result<Vec<EopDataRow>, DtErr> {
391        let s = core::str::from_utf8(bytes).unwrap_or("");
392        Self::data_from_str(s, format, separator)
393    }
394
395    /// Create an [`EopData`] from a string slice.
396    pub fn from_str(s: &str, format: EopFormat, separator: Separator) -> Result<Self, DtErr> {
397        let rows = Self::data_from_str(s, format, separator)?;
398        Ok(Self { rows })
399    }
400
401    /// Create an [`EopData`] from raw bytes.
402    pub fn from_bytes(
403        bytes: &[u8],
404        format: EopFormat,
405        separator: Separator,
406    ) -> Result<Self, DtErr> {
407        let rows = Self::data_from_bytes(bytes, format, separator)?;
408        Ok(Self { rows })
409    }
410
411    /// Returns all interpolated orientation parameters (offset + polar motion)
412    /// at the given MJD.
413    ///
414    /// Returns `None` if the table is empty or the MJD is completely outside
415    /// the loaded data.
416    pub fn eop_offset(&self, mjd: Real) -> Option<EopOffset> {
417        if self.rows.is_empty() {
418            return None;
419        }
420
421        let idx = match self
422            .rows
423            .binary_search_by(|probe| probe.mjd.partial_cmp(&mjd).unwrap_or(Ordering::Equal))
424        {
425            Ok(i) => i,
426            Err(i) => {
427                if i == 0 {
428                    let row = &self.rows[0];
429                    return Some(EopOffset {
430                        offset: row.offset,
431                        pm_x: row.pm_x,
432                        pm_y: row.pm_y,
433                    });
434                }
435                if i >= self.rows.len() {
436                    let row = &self.rows[self.rows.len() - 1];
437                    return Some(EopOffset {
438                        offset: row.offset,
439                        pm_x: row.pm_x,
440                        pm_y: row.pm_y,
441                    });
442                }
443                i - 1
444            }
445        };
446
447        if idx + 1 < self.rows.len() {
448            let e0 = &self.rows[idx];
449            let e1 = &self.rows[idx + 1];
450
451            let t = (mjd - e0.mjd) / (e1.mjd - e0.mjd);
452
453            let offset = e0.offset + t * (e1.offset - e0.offset);
454            let pm_x = e0.pm_x + t * (e1.pm_x - e0.pm_x);
455            let pm_y = e0.pm_y + t * (e1.pm_y - e0.pm_y);
456
457            Some(EopOffset { offset, pm_x, pm_y })
458        } else {
459            let row = &self.rows[idx];
460            Some(EopOffset {
461                offset: row.offset,
462                pm_x: row.pm_x,
463                pm_y: row.pm_y,
464            })
465        }
466    }
467}
468
469/// Interpolated Body Orientation Parameters at a specific MJD.
470///
471/// Contains everything needed for high-precision sidereal time
472/// and polar-motion corrections.
473#[derive(Debug, Clone, Default)]
474pub struct EopOffset {
475    /// Value in **seconds** e.g. UT1 − UTC offset
476    pub offset: Real,
477    /// Polar motion x-coordinate in **arcseconds**
478    pub pm_x: Real,
479    /// Polar motion y-coordinate in **arcseconds**
480    pub pm_y: Real,
481}
482
483impl Dt {
484    /// Get an orientation parameters offset in seconds inside a struct: ([`EopOffset`])
485    /// for a particular Modified Julian Date.
486    ///
487    /// - On Earth this would be the UT1 time scale.
488    /// - Earth Orientation Parameters data is available from: <https://maia.usno.navy.mil/ser7/finals2000A.all>
489    pub fn mjd_to_eop_offset(mjd: Real, op_data: &EopData) -> Result<EopOffset, DtErr> {
490        let offset = op_data
491            .eop_offset(mjd)
492            .ok_or_else(|| an_err!(DtErrKind::MjdOutOfRange, "{mjd}"))?;
493        Ok(offset)
494    }
495
496    /// Get an orientation parameters offset in seconds for a particular Modified Julian Date.
497    ///
498    /// - On Earth this would be the UT1 time scale.
499    /// - Earth Orientation Parameters data is available from: <https://maia.usno.navy.mil/ser7/finals2000A.all>
500    #[inline]
501    pub fn mjd_to_eop_offset_f(mjd: Real, op_data: &EopData) -> Result<Real, DtErr> {
502        Self::mjd_to_eop_offset(mjd, op_data).map(|res| res.offset)
503    }
504
505    /// Offsets a [`Dt`] using orientation parameters data.
506    ///
507    /// - On Earth this would be the UT1 time scale.
508    /// - Earth Orientation Parameters data is available from: <https://maia.usno.navy.mil/ser7/finals2000A.all>
509    #[inline]
510    pub fn to_eop(&self, op_data: &EopData) -> Result<Self, DtErr> {
511        Ok(self.add(Dt::from_sec_f(
512            Self::mjd_to_eop_offset_f(self.to_mjd_f_raw(), op_data)?,
513            Scale::TAI,
514            Scale::TAI,
515        )))
516    }
517
518    /// Convert a [`Dt`] already offset using orientation parameters data back to whatever
519    /// it was before.
520    ///
521    /// - On Earth this would be the UT1 time scale.
522    /// - Earth Orientation Parameters data is available from: <https://maia.usno.navy.mil/ser7/finals2000A.all>
523    pub fn from_eop(&self, op_data: &EopData) -> Result<Self, DtErr> {
524        if op_data.rows.is_empty() {
525            return Err(an_err!(DtErrKind::Empty));
526        }
527        let mut guess = *self;
528
529        for _ in 0..8 {
530            let mjd = guess.to_mjd_f_raw();
531            let offset = op_data
532                .eop_offset(mjd)
533                .ok_or_else(|| an_err!(DtErrKind::MjdOutOfRange, "{mjd}"))?
534                .offset;
535
536            // Fixed-point: utc = ut1 − offset(mjd(utc)); evaluate offset at `guess`, subtract from `self` (ut1).
537            guess = self.sub(Dt::from_sec_f(offset, Scale::TAI, Scale::TAI));
538        }
539
540        Ok(guess)
541    }
542}