1use core::fmt;
15use core::ops::Range;
16
17use spacelab_core::Angle;
18
19mod 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum EopQuality {
54 Final,
56 Predicted,
58}
59
60impl EopQuality {
61 #[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#[derive(Debug, Clone, Copy, PartialEq)]
75pub struct Eop {
76 pub xp: Angle,
78 pub yp: Angle,
80 pub dut1_seconds: f64,
82 pub lod_seconds: f64,
85 pub dx: Angle,
87 pub dy: Angle,
89 pub quality: EopQuality,
91 pub xp_sigma: Angle,
93 pub yp_sigma: Angle,
95 pub dut1_sigma_seconds: f64,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq)]
101pub struct EopSample {
102 pub mjd: f64,
104 pub bulletin_a: Eop,
106 pub bulletin_b: Option<Eop>,
108}
109
110impl EopSample {
111 #[must_use]
117 pub fn best(&self) -> Eop {
118 self.bulletin_b.unwrap_or(self.bulletin_a)
119 }
120}
121
122#[derive(Debug, Clone, PartialEq)]
124pub enum EopError {
125 Field {
127 line: usize,
129 columns: Range<usize>,
131 },
132 Empty,
134 NotMonotonic {
136 line: usize,
138 },
139 Gap {
145 after: f64,
147 before: f64,
149 },
150 OutOfRange {
152 mjd: f64,
154 first: f64,
156 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#[derive(Debug, Clone, PartialEq)]
190pub struct EopSeries {
191 samples: Vec<EopSample>,
192}
193
194fn field(line: &str, columns: Range<usize>) -> Option<&str> {
196 let text = line.get(columns)?.trim();
197 (!text.is_empty()).then_some(text)
198}
199
200fn 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
210fn optional(line: &str, columns: Range<usize>) -> Option<f64> {
212 field(line, columns).and_then(|t| t.parse().ok())
213}
214
215fn 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
230fn 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 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 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_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 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 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 #[must_use]
330 pub fn samples(&self) -> &[EopSample] {
331 &self.samples
332 }
333
334 #[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 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 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 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 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 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 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 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 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}