Skip to main content

spacelab_eop/
finals.rs

1//! Parsing of the IERS `finals2000A` series.
2//!
3//! # Format
4//!
5//! Fixed-width, 187 columns, one row per day. The column offsets below were not
6//! taken from memory: they were verified against the data itself. Where both
7//! bulletins are present, their pole positions must agree — after 2000 they do, to
8//! `5.6e-4` arcsec. A single-character misalignment would put that disagreement at
9//! order `0.1`. That check runs as a test.
10//!
11//! Rows near the end of the file carry only a date and an MJD: the IERS has
12//! reserved the day but has nothing to say about it yet. They are skipped.
13
14use core::fmt;
15use core::ops::Range;
16
17use spacelab_core::{Angle, ReferenceFrame};
18
19/// Zero-based, half-open column ranges of the `finals2000A` record.
20mod col {
21    use core::ops::Range;
22
23    pub const MJD: Range<usize> = 7..15;
24
25    pub const FLAG_POLE: Range<usize> = 16..17;
26    pub const XP: Range<usize> = 18..27;
27    pub const XP_SIGMA: Range<usize> = 27..36;
28    pub const YP: Range<usize> = 37..46;
29    pub const YP_SIGMA: Range<usize> = 46..55;
30
31    pub const DUT1: Range<usize> = 58..68;
32    pub const DUT1_SIGMA: Range<usize> = 68..78;
33
34    /// Excess length of day, milliseconds. Bulletin A only — the combined solution
35    /// does not republish it.
36    pub const LOD: Range<usize> = 79..86;
37
38    pub const DX: Range<usize> = 97..106;
39    pub const DY: Range<usize> = 116..125;
40
41    pub const B_XP: Range<usize> = 134..144;
42    pub const B_YP: Range<usize> = 144..154;
43    pub const B_DUT1: Range<usize> = 154..165;
44    pub const B_DX: Range<usize> = 165..175;
45    pub const B_DY: Range<usize> = 175..185;
46}
47
48/// Whether a value was measured or predicted.
49///
50/// Predicted `ΔUT1` a month ahead carries millisecond error. That is metres of
51/// longitude, so this cannot be a footnote.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum EopQuality {
54    /// Determined from observation.
55    Final,
56    /// Extrapolated by the IERS. Error grows with the horizon.
57    Predicted,
58}
59
60impl EopQuality {
61    /// The weaker of two qualities. Interpolating across a boundary yields a
62    /// prediction, not a measurement.
63    #[must_use]
64    pub fn worst(self, other: Self) -> Self {
65        if self == Self::Final && other == Self::Final {
66            Self::Final
67        } else {
68            Self::Predicted
69        }
70    }
71}
72
73/// Earth orientation at one instant.
74#[derive(Debug, Clone, Copy, PartialEq)]
75pub struct Eop {
76    /// Position of the Celestial Intermediate Pole along the IERS meridian.
77    pub xp: Angle,
78    /// Position of the pole, ninety degrees west.
79    pub yp: Angle,
80    /// `UT1 - UTC`, in seconds. Leap seconds kept it below 0.9 s; from 2027-05-20 there are none,
81    /// and only the 3600 s of CGPM 2026 Resolution 5 bounds it.
82    pub dut1_seconds: f64,
83    /// Excess length of day, in seconds: how much the day exceeded 86400 SI seconds.
84    /// Bulletin A only. The instantaneous Earth-rotation rate is `ω₀·(1 − LOD/86400)`.
85    pub lod_seconds: f64,
86    /// Celestial pole offset from the IAU 2000A model, `dX`.
87    pub dx: Angle,
88    /// Celestial pole offset from the IAU 2000A model, `dY`.
89    pub dy: Angle,
90    /// Whether these values were observed or predicted.
91    pub quality: EopQuality,
92    /// Reported uncertainty on `xp`.
93    pub xp_sigma: Angle,
94    /// Reported uncertainty on `yp`.
95    pub yp_sigma: Angle,
96    /// Reported uncertainty on `dut1_seconds`, in seconds.
97    pub dut1_sigma_seconds: f64,
98}
99
100/// One tabulated day.
101#[derive(Debug, Clone, Copy, PartialEq)]
102pub struct EopSample {
103    /// Modified Julian Date, at 0h UTC.
104    pub mjd: f64,
105    /// The rapid-service (Bulletin A) values, always present. A C04 series has no rapid service,
106    /// and there this repeats `bulletin_b`.
107    pub bulletin_a: Eop,
108    /// The final combined (Bulletin B) solution, where the IERS has published one. Every row of a
109    /// C04 series is that solution.
110    pub bulletin_b: Option<Eop>,
111}
112
113impl EopSample {
114    /// Bulletin B if it exists, otherwise Bulletin A.
115    ///
116    /// B is the final combined solution. A is the rapid service. Reading only A —
117    /// which is what a naive column-slice does — silently prefers the provisional
118    /// answer to the definitive one.
119    #[must_use]
120    pub fn best(&self) -> Eop {
121        self.bulletin_b.unwrap_or(self.bulletin_a)
122    }
123}
124
125/// Why an EOP record could not be read, or a query answered.
126#[derive(Debug, Clone, PartialEq)]
127pub enum EopError {
128    /// A field did not parse as a number.
129    Field {
130        /// One-based line number in the source.
131        line: usize,
132        /// The column range at fault.
133        columns: Range<usize>,
134    },
135    /// The series held no usable rows.
136    Empty,
137    /// A C04 header line the records depend on is missing, or names what this reader cannot use.
138    Header {
139        /// One-based line number: the offending header line, or the first record when the line
140        /// never appeared.
141        line: usize,
142        /// The header line the reader needed.
143        expected: &'static str,
144    },
145    /// Rows were not in ascending order of MJD.
146    NotMonotonic {
147        /// One-based line number where the order broke.
148        line: usize,
149    },
150    /// The requested date falls in a hole in the series.
151    ///
152    /// `finals2000A` is tabulated every day. A gap means the file was truncated or
153    /// spliced, and interpolating across it would quietly invent an Earth
154    /// orientation for days nobody observed.
155    Gap {
156        /// Last tabulated day before the hole.
157        after: f64,
158        /// First tabulated day after the hole.
159        before: f64,
160    },
161    /// The requested date lies outside the tabulated span.
162    OutOfRange {
163        /// What was asked for.
164        mjd: f64,
165        /// The first tabulated day.
166        first: f64,
167        /// The last tabulated day.
168        last: f64,
169    },
170}
171
172impl fmt::Display for EopError {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        match self {
175            Self::Field { line, columns } => {
176                write!(
177                    f,
178                    "line {line}: columns {}..{} are not a number",
179                    columns.start, columns.end
180                )
181            }
182            Self::Empty => write!(f, "no usable rows"),
183            Self::Header { line, expected } => write!(f, "line {line}: expected `{expected}`"),
184            Self::Gap { after, before } => {
185                write!(f, "no tabulated day between MJD {after} and {before}")
186            }
187            Self::NotMonotonic { line } => write!(f, "line {line}: MJD went backwards"),
188            Self::OutOfRange { mjd, first, last } => {
189                write!(
190                    f,
191                    "MJD {mjd} lies outside the tabulated span [{first}, {last}]"
192                )
193            }
194        }
195    }
196}
197
198impl core::error::Error for EopError {}
199
200/// A parsed `finals2000A` or C04 series.
201#[derive(Debug, Clone, PartialEq)]
202pub struct EopSeries {
203    pub(crate) samples: Vec<EopSample>,
204    pub(crate) frame: ReferenceFrame,
205}
206
207/// Read a fixed-width field, returning `None` when it is blank.
208fn field(line: &str, columns: Range<usize>) -> Option<&str> {
209    let text = line.get(columns)?.trim();
210    (!text.is_empty()).then_some(text)
211}
212
213/// Read a required numeric field.
214pub(crate) fn number(line: &str, columns: Range<usize>, line_no: usize) -> Result<f64, EopError> {
215    field(line, columns.clone())
216        .and_then(|t| t.parse().ok())
217        .ok_or(EopError::Field {
218            line: line_no,
219            columns,
220        })
221}
222
223/// Read an optional numeric field. A blank is absence, not an error.
224fn optional(line: &str, columns: Range<usize>) -> Option<f64> {
225    field(line, columns).and_then(|t| t.parse().ok())
226}
227
228/// Degree-3 Lagrange interpolation of `ys` at `x`, over the four nodes `xs`.
229fn lagrange4(xs: [f64; 4], ys: [f64; 4], x: f64) -> f64 {
230    let mut acc = 0.0;
231    for i in 0..4 {
232        let mut term = ys[i];
233        for j in 0..4 {
234            if i != j {
235                term *= (x - xs[j]) / (xs[i] - xs[j]);
236            }
237        }
238        acc += term;
239    }
240    acc
241}
242
243/// Remove one-second leap jumps from four consecutive `ΔUT1` values, anchored so the value at
244/// `base` is unchanged — expressing all four in `base`'s `ΔAT`, the convention that holds across
245/// the query interval. `ΔUT1 − ΔAT` is the quantity that is continuous at a leap second.
246fn destep_dut1(d: &mut [f64; 4], base: usize) {
247    for k in (base + 1)..4 {
248        let step = d[k] - d[k - 1];
249        if step.abs() > 0.5 {
250            let s = step.signum();
251            d[k..].iter_mut().for_each(|v| *v -= s);
252        }
253    }
254    for k in (0..base).rev() {
255        let step = d[k] - d[k + 1];
256        if step.abs() > 0.5 {
257            let s = step.signum();
258            d[..=k].iter_mut().for_each(|v| *v -= s);
259        }
260    }
261}
262
263impl EopSeries {
264    /// Parse the contents of a `finals2000A.all` file.
265    ///
266    /// Rows carrying only a date and an MJD — days the IERS has reserved but not
267    /// yet filled — are skipped rather than reported. Everything else must parse.
268    ///
269    /// # Errors
270    ///
271    /// [`EopError::Field`] on a malformed number, [`EopError::NotMonotonic`] if the
272    /// days are out of order, [`EopError::Empty`] if nothing usable remains.
273    pub fn parse_finals2000a(text: &str) -> Result<Self, EopError> {
274        let mut samples: Vec<EopSample> = Vec::new();
275
276        for (index, line) in text.lines().enumerate() {
277            let line_no = index + 1;
278
279            // A reserved-but-empty day. The pole flag is the sentinel: without it
280            // there is no data, whatever else the row may contain.
281            let Some(flag) = field(line, col::FLAG_POLE) else {
282                continue;
283            };
284            let quality = match flag {
285                "I" => EopQuality::Final,
286                _ => EopQuality::Predicted,
287            };
288
289            let mjd = number(line, col::MJD, line_no)?;
290
291            let bulletin_a = Eop {
292                xp: Angle::from_arcseconds(number(line, col::XP, line_no)?),
293                yp: Angle::from_arcseconds(number(line, col::YP, line_no)?),
294                dut1_seconds: number(line, col::DUT1, line_no)?,
295                // LOD is published in milliseconds; a blank early-series LOD is zero.
296                lod_seconds: optional(line, col::LOD).unwrap_or(0.0) / 1000.0,
297                dx: Angle::from_arcseconds(optional(line, col::DX).unwrap_or(0.0) / 1000.0),
298                dy: Angle::from_arcseconds(optional(line, col::DY).unwrap_or(0.0) / 1000.0),
299                quality,
300                xp_sigma: Angle::from_arcseconds(number(line, col::XP_SIGMA, line_no)?),
301                yp_sigma: Angle::from_arcseconds(number(line, col::YP_SIGMA, line_no)?),
302                dut1_sigma_seconds: number(line, col::DUT1_SIGMA, line_no)?,
303            };
304
305            // Bulletin B is all-or-nothing: the IERS publishes the combined
306            // solution as a unit, so a partial row would mean we misread a column.
307            let bulletin_b = match (
308                optional(line, col::B_XP),
309                optional(line, col::B_YP),
310                optional(line, col::B_DUT1),
311            ) {
312                (Some(xp), Some(yp), Some(dut1)) => Some(Eop {
313                    xp: Angle::from_arcseconds(xp),
314                    yp: Angle::from_arcseconds(yp),
315                    dut1_seconds: dut1,
316                    dx: Angle::from_arcseconds(optional(line, col::B_DX).unwrap_or(0.0) / 1000.0),
317                    dy: Angle::from_arcseconds(optional(line, col::B_DY).unwrap_or(0.0) / 1000.0),
318                    // Bulletin B exists only for days the IERS has finalised.
319                    quality: EopQuality::Final,
320                    ..bulletin_a
321                }),
322                _ => None,
323            };
324
325            if samples.last().is_some_and(|prev| mjd <= prev.mjd) {
326                return Err(EopError::NotMonotonic { line: line_no });
327            }
328            samples.push(EopSample {
329                mjd,
330                bulletin_a,
331                bulletin_b,
332            });
333        }
334
335        if samples.is_empty() {
336            return Err(EopError::Empty);
337        }
338        Ok(Self {
339            samples,
340            frame: ReferenceFrame::Unstated,
341        })
342    }
343
344    /// The terrestrial frame the series states its pole coordinates are in.
345    ///
346    /// [`ReferenceFrame::Unstated`] for `finals2000A`, which has no header to state one. A C04
347    /// series names its ITRF on its second line, and that ITRF holds for every row: the IERS
348    /// reprocessed the whole series, so it is a property of the file's vintage, not of a row's
349    /// epoch.
350    #[must_use]
351    pub const fn frame(&self) -> ReferenceFrame {
352        self.frame
353    }
354
355    /// Every tabulated day, in ascending order.
356    #[must_use]
357    pub fn samples(&self) -> &[EopSample] {
358        &self.samples
359    }
360
361    /// The first and last tabulated MJD.
362    #[must_use]
363    pub fn span(&self) -> (f64, f64) {
364        let first = self.samples.first().map_or(f64::NAN, |s| s.mjd);
365        let last = self.samples.last().map_or(f64::NAN, |s| s.mjd);
366        (first, last)
367    }
368
369    /// Earth orientation at an arbitrary UTC instant, by four-point Lagrange interpolation
370    /// (the IERS `interp` scheme), falling back to two-point linear at the series edges.
371    ///
372    /// The result's [`EopQuality`] is the weaker of the two bracketing days: an interval that
373    /// touches a prediction yields a prediction. This is the base series only; the diurnal and
374    /// semidiurnal terms live in [`crate::subdaily`] and are added by the caller when they matter.
375    ///
376    /// # Errors
377    ///
378    /// [`EopError::OutOfRange`] outside the tabulated span. We do not extrapolate
379    /// the rotation of the Earth.
380    pub fn at_mjd(&self, mjd: f64) -> Result<Eop, EopError> {
381        let (first, last) = self.span();
382        if !(first..=last).contains(&mjd) {
383            return Err(EopError::OutOfRange { mjd, first, last });
384        }
385
386        // Days are consecutive, so the index is arithmetic rather than a search —
387        // but do not assume it: verify, and fall back to a binary search.
388        let index = match self
389            .samples
390            .binary_search_by(|s| s.mjd.partial_cmp(&mjd).unwrap_or(core::cmp::Ordering::Less))
391        {
392            Ok(exact) => return Ok(self.samples[exact].best()),
393            Err(insert) => insert.saturating_sub(1),
394        };
395
396        let lo = &self.samples[index];
397        let hi = &self.samples[index + 1];
398
399        // The series is daily. A wider interval is a hole, and interpolation across a hole is
400        // an invention.
401        if hi.mjd - lo.mjd > 1.5 {
402            return Err(EopError::Gap {
403                after: lo.mjd,
404                before: hi.mjd,
405            });
406        }
407
408        let t = (mjd - lo.mjd) / (hi.mjd - lo.mjd);
409        let lerp = |x: f64, y: f64| x + (y - x) * t;
410
411        // Four-point Lagrange when a full, gap-free window [index-1 ..= index+2] exists; the
412        // smooth pole, rotation and pole offsets gain from the higher order. `ΔUT1` is
413        // discontinuous at a leap second (a one-second step on a tabulated midnight), so its four
414        // values are de-stepped into the query interval's `ΔAT` first — interpolating across the
415        // raw step would smear a whole second (~230 m). The reported uncertainties stay linear:
416        // an interpolated σ must not overshoot below zero.
417        let n = self.samples.len();
418        let window = (index >= 1 && index + 2 < n).then(|| {
419            let s = [&self.samples[index - 1], lo, hi, &self.samples[index + 2]];
420            (s[1].mjd - s[0].mjd <= 1.5 && s[3].mjd - s[2].mjd <= 1.5).then_some(s)
421        });
422
423        if let Some(Some(s)) = window {
424            let xs = [s[0].mjd, s[1].mjd, s[2].mjd, s[3].mjd];
425            let e = [s[0].best(), s[1].best(), s[2].best(), s[3].best()];
426            let lag =
427                |g: fn(&Eop) -> f64| lagrange4(xs, [g(&e[0]), g(&e[1]), g(&e[2]), g(&e[3])], mjd);
428            let mut d = [
429                e[0].dut1_seconds,
430                e[1].dut1_seconds,
431                e[2].dut1_seconds,
432                e[3].dut1_seconds,
433            ];
434            destep_dut1(&mut d, 1);
435            Ok(Eop {
436                xp: Angle::from_radians(lag(|p| p.xp.radians())),
437                yp: Angle::from_radians(lag(|p| p.yp.radians())),
438                dut1_seconds: lagrange4(xs, d, mjd),
439                lod_seconds: lag(|p| p.lod_seconds),
440                dx: Angle::from_radians(lag(|p| p.dx.radians())),
441                dy: Angle::from_radians(lag(|p| p.dy.radians())),
442                quality: e[1].quality.worst(e[2].quality),
443                xp_sigma: Angle::from_radians(lerp(
444                    e[1].xp_sigma.radians(),
445                    e[2].xp_sigma.radians(),
446                )),
447                yp_sigma: Angle::from_radians(lerp(
448                    e[1].yp_sigma.radians(),
449                    e[2].yp_sigma.radians(),
450                )),
451                dut1_sigma_seconds: lerp(e[1].dut1_sigma_seconds, e[2].dut1_sigma_seconds),
452            })
453        } else {
454            let (a, b) = (lo.best(), hi.best());
455            let hi_dut1 = {
456                let step = b.dut1_seconds - a.dut1_seconds;
457                if step.abs() > 0.5 {
458                    b.dut1_seconds - step.signum()
459                } else {
460                    b.dut1_seconds
461                }
462            };
463            Ok(Eop {
464                xp: Angle::from_radians(lerp(a.xp.radians(), b.xp.radians())),
465                yp: Angle::from_radians(lerp(a.yp.radians(), b.yp.radians())),
466                dut1_seconds: lerp(a.dut1_seconds, hi_dut1),
467                lod_seconds: lerp(a.lod_seconds, b.lod_seconds),
468                dx: Angle::from_radians(lerp(a.dx.radians(), b.dx.radians())),
469                dy: Angle::from_radians(lerp(a.dy.radians(), b.dy.radians())),
470                quality: a.quality.worst(b.quality),
471                xp_sigma: Angle::from_radians(lerp(a.xp_sigma.radians(), b.xp_sigma.radians())),
472                yp_sigma: Angle::from_radians(lerp(a.yp_sigma.radians(), b.yp_sigma.radians())),
473                dut1_sigma_seconds: lerp(a.dut1_sigma_seconds, b.dut1_sigma_seconds),
474            })
475        }
476    }
477}
478
479#[cfg(test)]
480mod interp_tests {
481    use super::{destep_dut1, lagrange4};
482
483    #[test]
484    fn lagrange4_reproduces_a_line_and_a_cubic_exactly() {
485        let xs = [10.0, 11.0, 12.0, 13.0];
486        let line = xs.map(|x| 3.0 * x + 1.0);
487        assert!((lagrange4(xs, line, 11.5) - (3.0 * 11.5 + 1.0)).abs() < 1e-9);
488        // Degree-3 interpolation is exact for a cubic.
489        let cubic = xs.map(|x: f64| 2.0 * x.powi(3) - 5.0 * x * x + x - 7.0);
490        let want = 2.0 * 11.5_f64.powi(3) - 5.0 * 11.5 * 11.5 + 11.5 - 7.0;
491        assert!((lagrange4(xs, cubic, 11.5) - want).abs() < 1e-6);
492        // At a node it returns the node value.
493        assert!((lagrange4(xs, line, 12.0) - line[2]).abs() < 1e-9);
494    }
495
496    #[test]
497    fn destep_dut1_removes_a_leap_jump_anchored_at_the_base() {
498        // A +1 s jump between indices 1 and 2; anchored at base 1, [0,1] stay, [2,3] are undone.
499        let mut d = [-0.3, -0.4, 0.6, 0.5];
500        destep_dut1(&mut d, 1);
501        assert!((d[0] - -0.3).abs() < 1e-12 && (d[1] - -0.4).abs() < 1e-12);
502        assert!((d[2] - -0.4).abs() < 1e-12 && (d[3] - -0.5).abs() < 1e-12);
503    }
504
505    #[test]
506    fn destep_dut1_undoes_a_jump_below_the_base_by_moving_the_earlier_days() {
507        // A +1 s jump between indices 0 and 1 — the leap fell on the window's first midnight, so
508        // it is the backward sweep that has to undo it, and only day 0 may move. The test above
509        // puts its jump above the base, which leaves this whole half of the function unexercised:
510        // reading the wrong neighbour, or starting the forward sweep one day early, both keep the
511        // sequence continuous there while shifting the anchored day by a whole second.
512        let mut d = [0.4, -0.6, -0.61, -0.62];
513        destep_dut1(&mut d, 1);
514        assert!((d[0] - -0.6).abs() < 1e-12, "day 0 = {}", d[0]);
515        assert!(
516            (d[1] - -0.6).abs() < 1e-12,
517            "the base must not move: {}",
518            d[1]
519        );
520        assert!((d[2] - -0.61).abs() < 1e-12 && (d[3] - -0.62).abs() < 1e-12);
521    }
522}