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}