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