spacelab_gnss/sp3.rs
1//! SP3 precise-ephemeris ingestion and polynomial interpolation.
2//!
3//! Broadcast ephemerides ([`crate::gps`], [`crate::glonass`]) are good to a few metres; the IGS
4//! publishes *precise* orbit products in the SP3 format — Earth-fixed satellite positions on a coarse
5//! grid (typically 15 minutes) accurate to centimetres. This module parses those position records and
6//! interpolates them with Neville's algorithm (the standard ≈10th-order Lagrange interpolation), so a
7//! caller gets a centimetre-level position at any instant — more accurate than any broadcast model.
8//!
9//! Position (`P`) records carry the ECEF position (km → m) and satellite clock bias (µs → s); velocity
10//! (`V`) records carry the ECEF velocity (dm/s → m/s). Each is interpolated independently. Header,
11//! correlation (`EP`/`EV`) and comment lines are skipped; the `999999.999999` clock sentinel is read as
12//! "no clock".
13
14// The single letters (x, y, z, t, p) are position/time notation; the doc allow is for SP3/IGS names;
15// the calendar fields are parsed as f64 then narrowed to integer date components.
16#![allow(
17 clippy::many_single_char_names,
18 clippy::doc_markdown,
19 clippy::cast_possible_truncation,
20 clippy::cast_precision_loss
21)]
22
23use std::collections::BTreeMap;
24
25/// The frame line 1 states, columns 47-51 of the SP3-c/d header. The reader used to discard the
26/// field; the eight SP3 fixtures in this workspace carry three distinct labels between them --
27/// `IGS20`, `IGc20` and `SLR20` -- so it is populated in practice.
28pub use spacelab_core::{Itrs, ReferenceFrame};
29
30/// A satellite identifier — the SP3 system letter and PRN, e.g. `G01`, `R05`, `E12`.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
32pub struct SatId {
33 /// Constellation letter (`G`/`R`/`E`/`C`/`J`/`I`/`S`).
34 pub system: char,
35 /// Satellite PRN / slot.
36 pub prn: u8,
37}
38
39/// Integer Julian Day Number for a proleptic-Gregorian date (Fliegel–Van Flandern).
40pub(crate) fn jdn(y: i64, m: i64, d: i64) -> i64 {
41 let a = (14 - m) / 12;
42 let yy = y + 4800 - a;
43 let mm = m + 12 * a - 3;
44 d + (153 * mm + 2) / 5 + 365 * yy + yy / 4 - yy / 100 + yy / 400 - 32045
45}
46
47pub(crate) fn parse_sat_id(tok: &str) -> Option<SatId> {
48 let mut chars = tok.chars();
49 let system = chars.next()?;
50 let prn = tok.get(1..)?.trim().parse().ok()?;
51 Some(SatId { system, prn })
52}
53
54/// The time system the epoch records are stamped in, from the SP3 `%c` header (SP3-c/d field 4).
55///
56/// It matters because an SP3 epoch is not a time until the scale is named: IGS products are GPS
57/// time, IGLOS products are UTC, and the two differ by the whole leap-second count.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59pub enum TimeSystem {
60 /// GPS time. The default the format assigns when the field is absent or blank.
61 #[default]
62 Gps,
63 /// GLONASS time, which is UTC(SU) plus three hours and carries leap seconds.
64 Glo,
65 /// Galileo system time.
66 Gal,
67 /// International Atomic Time.
68 Tai,
69 /// Coordinated Universal Time.
70 Utc,
71 /// QZSS time.
72 Qzs,
73 /// BeiDou time.
74 Bdt,
75 /// NavIC (IRNSS) time.
76 Irn,
77}
78
79impl TimeSystem {
80 /// Seconds to add to an epoch in this system to reach TAI, where that offset is a *constant*.
81 ///
82 /// `None` for [`TimeSystem::Utc`] and [`TimeSystem::Glo`], whose offset is the leap-second count
83 /// at the epoch — a property of the date, not of the scale, so it is the caller's leap-second
84 /// table to apply and not this type's to guess.
85 #[must_use]
86 pub const fn tai_offset_seconds(self) -> Option<f64> {
87 match self {
88 // GNSS system times are steered to a fixed offset from TAI by their control segments.
89 Self::Gps | Self::Gal | Self::Qzs | Self::Irn => Some(19.0),
90 Self::Bdt => Some(33.0), // BDT started 14 s behind GPS time, at 2006-01-01 UTC
91 Self::Tai => Some(0.0),
92 Self::Glo | Self::Utc => None,
93 }
94 }
95}
96
97/// The format version, from line 1 column 2.
98///
99/// Reported rather than acted on: this reader's record handling is the same for every version,
100/// because the versions differ in what the *header* may declare and not in how a `P`, `V` or `*`
101/// line is laid out. What the version does tell a caller is which limits apply — SP3-c caps a file
102/// at 85 satellites and a 60-character comment, SP3-d raises those to 999 and 80 — and whether a
103/// missing `%c` record means "GPS time by default" (SP3-a, which has no such record) or "the
104/// producer omitted it".
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
106pub enum Version {
107 /// No `#` header line. An absence, not a default.
108 #[default]
109 Unstated,
110 /// SP3-a.
111 A,
112 /// SP3-b.
113 B,
114 /// SP3-c.
115 C,
116 /// SP3-d: at most 999 satellites, unlimited comment records, 80-character comments.
117 D,
118 /// A version character this reader has never heard of. Kept as broadcast rather than mapped to
119 /// the newest known version, because a file from the future is not a file this reader has
120 /// verified it can read.
121 Unrecognised(char),
122}
123
124impl Version {
125 /// Lower case only. Every version of the format spells its own character in lower case in
126 /// every example it prints, so `#D` is a producer's mistake and not a dialect — reporting it as
127 /// [`Version::Unrecognised`] surfaces that, where quietly accepting it would hide it. The
128 /// character is kept, so a caller that wants to be lenient still can be.
129 fn from_char(c: char) -> Self {
130 match c {
131 'a' => Self::A,
132 'b' => Self::B,
133 'c' => Self::C,
134 'd' => Self::D,
135 other => Self::Unrecognised(other),
136 }
137 }
138}
139
140/// The SP3 five-character coordinate-system label, trimmed and upper-cased by the caller.
141pub(crate) fn parse_reference_frame(tok: &str) -> ReferenceFrame {
142 match tok.trim() {
143 "" | "ccccc" => ReferenceFrame::Unstated,
144 "ITR08" | "ITRF08" | "ITRF2008" => ReferenceFrame::Itrs(Itrs::Itrf2008),
145 "ITR14" | "ITRF14" | "ITRF2014" => ReferenceFrame::Itrs(Itrs::Itrf2014),
146 "ITR20" | "ITRF20" | "ITRF2020" => ReferenceFrame::Itrs(Itrs::Itrf2020),
147 // The IGS letters are updates of one realization, not separate solutions.
148 "IGS08" | "IGb08" | "IGB08" => ReferenceFrame::Itrs(Itrs::Igs08),
149 "IGS14" | "IGb14" | "IGB14" => ReferenceFrame::Itrs(Itrs::Igs14),
150 "IGS20" | "IGb20" | "IGB20" | "IGc20" | "IGC20" => ReferenceFrame::Itrs(Itrs::Igs20),
151 "SLR20" | "SLRF20" | "SLRF2020" => ReferenceFrame::Itrs(Itrs::Slrf2020),
152 "PZ90" | "PZ-90" | "PZ9011" => ReferenceFrame::Pz90,
153 "WGS84" | "WGS-84" => ReferenceFrame::Wgs84,
154 _ => ReferenceFrame::Unrecognised,
155 }
156}
157
158pub(crate) fn parse_time_system(tok: &str) -> Option<TimeSystem> {
159 Some(match tok {
160 "GPS" | "ccc" => TimeSystem::Gps, // "ccc" is the format's blank filler, and GPS is its default
161 "GLO" => TimeSystem::Glo,
162 "GAL" => TimeSystem::Gal,
163 "TAI" => TimeSystem::Tai,
164 "UTC" => TimeSystem::Utc,
165 "QZS" => TimeSystem::Qzs,
166 "BDT" => TimeSystem::Bdt,
167 "IRN" => TimeSystem::Irn,
168 _ => return None,
169 })
170}
171
172/// Seconds from JDN 0 at 00:00 to the J2000 epoch, JD 2451545.0 = 2000-01-01 12:00. `jdn` counts
173/// whole days from midnight, JD from noon, so the half-day is not optional.
174pub(crate) const JDN_SECONDS_AT_J2000: f64 = 2_451_545.0 * 86_400.0 + 43_200.0;
175
176/// A parsed SP3 file: the epoch grid (seconds from the first epoch) and, per satellite, the ECEF
177/// positions (m), clock biases (s) and — when the file carries `V` records — velocities (m/s) at each
178/// epoch (`None` where a satellite/quantity is absent from an epoch).
179#[derive(Debug, Clone, Default)]
180pub struct Sp3 {
181 /// The first epoch, seconds since J2000 in `time_system`. Absent for a file with no epochs.
182 start_epoch: Option<f64>,
183 /// The scale `start_epoch` and the epoch grid are stamped in.
184 time_system: TimeSystem,
185 /// The frame the positions are in, from line 1 of the header.
186 reference_frame: ReferenceFrame,
187 /// The format version, from line 1 column 2.
188 version: Version,
189 epochs: Vec<f64>,
190 positions: BTreeMap<SatId, Vec<Option<[f64; 3]>>>,
191 clocks: BTreeMap<SatId, Vec<Option<f64>>>,
192 velocities: BTreeMap<SatId, Vec<Option<[f64; 3]>>>,
193}
194
195/// The SP3 sentinel for a missing/bad clock, in microseconds (`999999.999999`).
196const BAD_CLOCK_US: f64 = 999_999.0;
197
198/// Set `map[id][ei] = val`, back-filling the series to `n_epochs` with `None` on first sight.
199fn set_sample<T: Copy>(
200 map: &mut BTreeMap<SatId, Vec<Option<T>>>,
201 id: SatId,
202 ei: usize,
203 n_epochs: usize,
204 val: T,
205) {
206 let series = map.entry(id).or_insert_with(|| vec![None; n_epochs]);
207 if series.len() < n_epochs {
208 series.resize(n_epochs, None);
209 }
210 series[ei] = Some(val);
211}
212
213/// Neville's algorithm: the interpolating polynomial through `(xs[i], ys[i])` evaluated at `t`.
214fn neville(xs: &[f64], ys: &[f64], t: f64) -> f64 {
215 let n = xs.len();
216 let mut c = ys.to_vec();
217 for k in 1..n {
218 for i in (k..n).rev() {
219 c[i] = ((t - xs[i - k]) * c[i] - (t - xs[i]) * c[i - 1]) / (xs[i] - xs[i - k]);
220 }
221 }
222 c[n - 1]
223}
224
225/// The `(start, count)` window of `order + 1` samples centred on the sample epoch nearest `t`.
226fn window(epochs: &[f64], t: f64, order: usize) -> Option<(usize, usize)> {
227 if epochs.len() < 2 {
228 return None;
229 }
230 let want = (order + 1).min(epochs.len());
231 let nearest = epochs
232 .iter()
233 .enumerate()
234 .min_by(|(_, a), (_, b)| libm::fabs(**a - t).total_cmp(&libm::fabs(**b - t)))
235 .map(|(i, _)| i)?;
236 let start = nearest.saturating_sub(want / 2).min(epochs.len() - want);
237 Some((start, want))
238}
239
240impl Sp3 {
241 /// The satellites present in the file, in ascending order.
242 pub fn satellites(&self) -> impl Iterator<Item = SatId> + '_ {
243 self.positions.keys().copied()
244 }
245
246 /// The number of epochs.
247 #[must_use]
248 pub fn epoch_count(&self) -> usize {
249 self.epochs.len()
250 }
251
252 /// The epoch grid, seconds from the first epoch — the times [`Sp3::positions`] is sampled on.
253 #[must_use]
254 pub fn epochs(&self) -> &[f64] {
255 &self.epochs
256 }
257
258 /// `sat`'s ECEF positions (m) on the [`Sp3::epochs`] grid, `None` at an epoch the file carries no
259 /// record for. `None` for a satellite the file does not contain.
260 ///
261 /// These are the measurements. An orbit fit weighs *these*, not [`Sp3::interpolate`] — the
262 /// interpolant is a smooth curve drawn through them, so fitting to it would credit the polynomial
263 /// with information the product does not carry.
264 #[must_use]
265 pub fn positions(&self, sat: SatId) -> Option<&[Option<[f64; 3]>]> {
266 self.positions.get(&sat).map(Vec::as_slice)
267 }
268
269 /// The first epoch, **seconds since J2000** (2000-01-01 12:00) in [`Sp3::time_system`]. `None`
270 /// for a file with no epoch records.
271 ///
272 /// The epoch grid the interpolators take is relative to this instant, which is enough to
273 /// interpolate the file against itself and not enough for anything else: a force model needs the
274 /// absolute epoch to reach the EOP series and the planetary ephemeris. Reaching TT from here is
275 /// `start_epoch + time_system.tai_offset_seconds() + 32.184`, and the offset is deliberately not
276 /// applied for you — the scale is a property of the file, and silently normalising it would hide
277 /// a GLONASS product's leap seconds behind a number that looks right.
278 #[must_use]
279 pub fn start_epoch(&self) -> Option<f64> {
280 self.start_epoch
281 }
282
283 /// The format version, from line 1 column 2.
284 ///
285 /// [`Version::Unstated`] for a file with no `#` header line. See [`Version`] for what the
286 /// version does and does not change about a file.
287 #[must_use]
288 pub const fn version(&self) -> Version {
289 self.version
290 }
291
292 /// The reference frame the positions are in, from line 1 of the header.
293 ///
294 /// [`ReferenceFrame::Unstated`] when the file has no `#` line or the field is blank — which is
295 /// an absence and not a default, because the only safe thing to do with an unstated frame is
296 /// refuse to difference it against anything.
297 #[must_use]
298 pub const fn reference_frame(&self) -> ReferenceFrame {
299 self.reference_frame
300 }
301
302 /// The time system the epochs are stamped in, from the `%c` header. [`TimeSystem::Gps`] when the
303 /// file does not say, which is what the format prescribes.
304 #[must_use]
305 pub fn time_system(&self) -> TimeSystem {
306 self.time_system
307 }
308
309 /// Parse an SP3 file. Epoch lines start with `*`, position lines with `P`, velocity lines with `V`.
310 /// Line 1 supplies the [`ReferenceFrame`] and the `%c` header record the [`TimeSystem`]; other
311 /// header, correlation and comment lines are skipped, and a satellite first seen mid-file is
312 /// back-filled with `None`.
313 #[must_use]
314 pub fn parse(text: &str) -> Self {
315 let mut epochs: Vec<f64> = Vec::new();
316 let mut positions: BTreeMap<SatId, Vec<Option<[f64; 3]>>> = BTreeMap::new();
317 let mut clocks: BTreeMap<SatId, Vec<Option<f64>>> = BTreeMap::new();
318 let mut velocities: BTreeMap<SatId, Vec<Option<[f64; 3]>>> = BTreeMap::new();
319 let mut t0: Option<f64> = None;
320 let mut time_system: Option<TimeSystem> = None;
321 let mut reference_frame = ReferenceFrame::Unstated;
322 let mut version = Version::Unstated;
323
324 for line in text.lines() {
325 if line.starts_with('#') && !line.starts_with("##") {
326 // Line 1, columns 47-51 by the SP3-c/d spec, read by index because the two A5
327 // fields around it may be blank. Only the *first* `#` line is the header record;
328 // `##` is the GPS-week line and any later `#` is a comment in some producers.
329 // No length guard: `get(46..51)` already returns `None` for a line too short to
330 // hold the field, and a guard that rejects nothing its successor does not is dead
331 // code a mutation block cannot tell from live code.
332 if reference_frame == ReferenceFrame::Unstated {
333 reference_frame = line
334 .get(46..51)
335 .map_or(ReferenceFrame::Unstated, parse_reference_frame);
336 }
337 if version == Version::Unstated {
338 version = line
339 .chars()
340 .nth(1)
341 .map_or(Version::Unstated, Version::from_char);
342 }
343 } else if line.starts_with("%c") {
344 // `%c G cc GPS ccc cccc …` — columns 10-12 by the SP3-c/d spec, read by index
345 // rather than by token because the two fields before it may both be blank. Only the
346 // *first* `%c` record carries the time system; the second is filler that would
347 // otherwise read as `ccc` and silently reset a GLONASS file to GPS.
348 if time_system.is_none() {
349 time_system = line.get(9..12).and_then(parse_time_system);
350 }
351 } else if let Some(rest) = line.strip_prefix('*') {
352 let f: Vec<f64> = rest
353 .split_whitespace()
354 .filter_map(|t| t.parse().ok())
355 .collect();
356 if f.len() < 6 {
357 continue;
358 }
359 let (y, m, d) = (f[0] as i64, f[1] as i64, f[2] as i64);
360 let secs = (jdn(y, m, d) as f64) * 86_400.0 + f[3] * 3600.0 + f[4] * 60.0 + f[5];
361 let t = *t0.get_or_insert(secs);
362 epochs.push(secs - t);
363 // Extend every series by one absent slot (a record below fills it if present).
364 for s in positions.values_mut() {
365 s.push(None);
366 }
367 for s in clocks.values_mut() {
368 s.push(None);
369 }
370 for s in velocities.values_mut() {
371 s.push(None);
372 }
373 } else if line.starts_with('P') || line.starts_with('V') {
374 let idx = epochs.len().checked_sub(1);
375 let (Some(ei), Some(id)) = (idx, parse_sat_id(line.get(1..4).unwrap_or("").trim()))
376 else {
377 continue;
378 };
379 let vals: Vec<f64> = line[4..]
380 .split_whitespace()
381 .filter_map(|t| t.parse().ok())
382 .collect();
383 if vals.len() < 3 {
384 continue;
385 }
386 let n = epochs.len();
387 if line.starts_with('P') {
388 set_sample(
389 &mut positions,
390 id,
391 ei,
392 n,
393 [vals[0] * 1000.0, vals[1] * 1000.0, vals[2] * 1000.0],
394 );
395 // Clock bias (µs → s); the 999999.999999 sentinel means "no clock".
396 if let Some(&clk_us) = vals.get(3)
397 && libm::fabs(clk_us) < BAD_CLOCK_US
398 {
399 set_sample(&mut clocks, id, ei, n, clk_us * 1.0e-6);
400 }
401 } else {
402 // Velocity dm/s → m/s.
403 set_sample(
404 &mut velocities,
405 id,
406 ei,
407 n,
408 [vals[0] * 0.1, vals[1] * 0.1, vals[2] * 0.1],
409 );
410 }
411 }
412 }
413 Sp3 {
414 start_epoch: t0.map(|t| t - JDN_SECONDS_AT_J2000),
415 time_system: time_system.unwrap_or_default(),
416 reference_frame,
417 version,
418 epochs,
419 positions,
420 clocks,
421 velocities,
422 }
423 }
424
425 /// Interpolate satellite `sat`'s ECEF position (metres) at `t` seconds from the first epoch, using
426 /// the `order + 1` samples nearest `t` (Neville). `None` if the satellite is unknown or has too few
427 /// samples. `order` is clamped to the available sample count.
428 #[must_use]
429 pub fn interpolate(&self, sat: SatId, t: f64, order: usize) -> Option<[f64; 3]> {
430 self.interp_vec3(&self.positions, sat, t, order)
431 }
432
433 /// Interpolate satellite `sat`'s ECEF velocity (m/s) at `t` from the file's `V` records; `None` if
434 /// it carries none for this satellite.
435 #[must_use]
436 pub fn interpolate_velocity(&self, sat: SatId, t: f64, order: usize) -> Option<[f64; 3]> {
437 self.interp_vec3(&self.velocities, sat, t, order)
438 }
439
440 /// Interpolate satellite `sat`'s clock bias (seconds) at `t`; `None` if it has too few clock
441 /// samples.
442 #[must_use]
443 pub fn interpolate_clock(&self, sat: SatId, t: f64, order: usize) -> Option<f64> {
444 let series = self.clocks.get(&sat)?;
445 let pts: Vec<(f64, f64)> = self
446 .epochs
447 .iter()
448 .zip(series)
449 .filter_map(|(&e, c)| c.map(|v| (e, v)))
450 .collect();
451 let epochs: Vec<f64> = pts.iter().map(|(e, _)| *e).collect();
452 let (start, want) = window(&epochs, t, order)?;
453 let ys: Vec<f64> = pts[start..start + want].iter().map(|(_, v)| *v).collect();
454 Some(neville(&epochs[start..start + want], &ys, t))
455 }
456
457 /// Neville interpolation of a per-satellite 3-vector series (position or velocity) at `t`.
458 fn interp_vec3(
459 &self,
460 map: &BTreeMap<SatId, Vec<Option<[f64; 3]>>>,
461 sat: SatId,
462 t: f64,
463 order: usize,
464 ) -> Option<[f64; 3]> {
465 let series = map.get(&sat)?;
466 let pts: Vec<(f64, [f64; 3])> = self
467 .epochs
468 .iter()
469 .zip(series)
470 .filter_map(|(&e, p)| p.map(|xyz| (e, xyz)))
471 .collect();
472 let epochs: Vec<f64> = pts.iter().map(|(e, _)| *e).collect();
473 let (start, want) = window(&epochs, t, order)?;
474 let xs = &epochs[start..start + want];
475 let mut out = [0.0; 3];
476 for axis in 0..3 {
477 let ys: Vec<f64> = pts[start..start + want]
478 .iter()
479 .map(|(_, p)| p[axis])
480 .collect();
481 out[axis] = neville(xs, &ys, t);
482 }
483 Some(out)
484 }
485}
486
487#[cfg(test)]
488mod tests {
489 #![allow(clippy::unwrap_used, clippy::format_push_string)]
490 use super::*;
491
492 /// A synthetic SP3 for one satellite whose position is an exact cubic in time — an interpolator of
493 /// order ≥ 3 must reproduce it to rounding. Units: km.
494 fn cubic(t: f64) -> [f64; 3] {
495 [
496 1000.0 + 2.0 * t - 0.001 * t * t + 1.0e-7 * t * t * t,
497 -500.0 + 1.5 * t + 0.002 * t * t,
498 300.0 - 0.5 * t + 5.0e-8 * t * t * t,
499 ]
500 }
501
502 /// The exact time derivative of [`cubic`], km/s.
503 fn cubic_velocity(t: f64) -> [f64; 3] {
504 [
505 2.0 - 0.002 * t + 3.0e-7 * t * t,
506 1.5 + 0.004 * t,
507 -0.5 + 1.5e-7 * t * t,
508 ]
509 }
510
511 /// A linear synthetic clock bias, microseconds.
512 fn clock_us(t: f64) -> f64 {
513 1.0 + 2.0e-3 * t
514 }
515
516 /// The header line of each SP3 product this workspace actually reads, verbatim.
517 ///
518 /// Transcribed from `crates/spacelab/tests/fixtures/`: the IGS final and rapid GPS combinations
519 /// and the ILRS LAGEOS combination. The GLONASS line is IAC's own format for the same field and
520 /// is the case the type exists for.
521 #[test]
522 fn the_header_names_the_frame_and_each_real_product_names_a_different_one() {
523 for (line, want) in [
524 (
525 "#cP2025 1 1 0 0 0.00000000 96 ORBIT IGS20 HLM IGS",
526 ReferenceFrame::Itrs(Itrs::Igs20),
527 ),
528 (
529 "#cP2026 8 12 0 0 0.00000000 96 ORBIT IGc20 HLM IGS",
530 ReferenceFrame::Itrs(Itrs::Igs20),
531 ),
532 (
533 "#cV2025 1 12 0 0 0.00000000 5040 SLR SLR20 FIT COMB",
534 ReferenceFrame::Itrs(Itrs::Slrf2020),
535 ),
536 (
537 "#cP2025 1 1 0 0 0.00000000 96 ORBIT PZ90 HLM IAC",
538 ReferenceFrame::Pz90,
539 ),
540 ] {
541 assert_eq!(Sp3::parse(line).reference_frame(), want, "{line}");
542 }
543 }
544
545 /// Every label the table claims to recognise, and what each reduces to.
546 ///
547 /// The function *is* a lookup table, so the test is the table: a mutation block over the first
548 /// version deleted seven arms with nothing moving, because the fixture-driven test above only
549 /// ever asks for the four labels this repository happens to hold. An arm no test names is an arm
550 /// that can be removed, and a producer writing `ITRF14` would then have read as `Unrecognised`.
551 #[test]
552 fn every_recognised_label_reduces_to_its_own_solution() {
553 use Itrs::{Igs08, Igs14, Igs20, Itrf2008, Itrf2014, Itrf2020, Slrf2020};
554
555 for (labels, want, itrf) in [
556 (&["ITR08", "ITRF08", "ITRF2008"][..], Itrf2008, Itrf2008),
557 (&["ITR14", "ITRF14", "ITRF2014"][..], Itrf2014, Itrf2014),
558 (&["ITR20", "ITRF20", "ITRF2020"][..], Itrf2020, Itrf2020),
559 (&["IGS08", "IGb08", "IGB08"][..], Igs08, Itrf2008),
560 (&["IGS14", "IGb14", "IGB14"][..], Igs14, Itrf2014),
561 (
562 &["IGS20", "IGb20", "IGB20", "IGc20", "IGC20"][..],
563 Igs20,
564 Itrf2020,
565 ),
566 (&["SLR20", "SLRF20", "SLRF2020"][..], Slrf2020, Itrf2020),
567 ] {
568 for label in labels {
569 let got = parse_reference_frame(label);
570 assert_eq!(got, ReferenceFrame::Itrs(want), "{label}");
571 assert_eq!(got.itrs().map(Itrs::itrf), Some(itrf), "{label}");
572 // Padding is what the fixed-width field actually delivers.
573 assert_eq!(parse_reference_frame(&format!("{label} ")), got, "{label} ");
574 }
575 }
576
577 for label in ["PZ90", "PZ-90", "PZ9011"] {
578 assert_eq!(
579 parse_reference_frame(label),
580 ReferenceFrame::Pz90,
581 "{label}"
582 );
583 }
584 for label in ["WGS84", "WGS-84"] {
585 assert_eq!(
586 parse_reference_frame(label),
587 ReferenceFrame::Wgs84,
588 "{label}"
589 );
590 }
591 // The format's blank filler and an actually blank field are both an absence, not a default.
592 for label in ["", "ccccc", " "] {
593 assert_eq!(
594 parse_reference_frame(label),
595 ReferenceFrame::Unstated,
596 "{label:?}"
597 );
598 }
599 // Anything else is refused rather than guessed at, including labels that merely look close.
600 for label in ["ITRF", "IGS", "IGS21", "GTRF", "PZ", "cccc"] {
601 assert_eq!(
602 parse_reference_frame(label),
603 ReferenceFrame::Unrecognised,
604 "{label}"
605 );
606 }
607 }
608
609 /// A file with no header record says nothing about its frame, and nothing is what it reports.
610 #[test]
611 fn a_file_without_a_header_line_leaves_the_frame_unstated() {
612 let no_header = "* 2024 1 9 0 0 0.00000000\nPG01 1.0 2.0 3.0 4.0\n";
613 assert_eq!(
614 Sp3::parse(no_header).reference_frame(),
615 ReferenceFrame::Unstated
616 );
617 // A `##` line is the GPS-week record, not the header, and must not be read as one.
618 assert_eq!(
619 Sp3::parse("## 2431 259200.00000000 900.00000000 61264 0.0000000000000\n")
620 .reference_frame(),
621 ReferenceFrame::Unstated
622 );
623 // A short header line cannot be sliced at column 47 and must not panic.
624 assert_eq!(
625 Sp3::parse("#cP2025\n").reference_frame(),
626 ReferenceFrame::Unstated
627 );
628 }
629
630 fn synthetic_sp3() -> String {
631 // Five epochs 900 s apart on 2024-01-09, one satellite G01, with P (position km + clock µs) and
632 // V (velocity dm/s) records.
633 let mut s = String::from("#dV2024 1 9 0 0 0.00000000 5 ORBIT IGS20 HLM IGS\n");
634 for k in 0..5 {
635 let sec = f64::from(k * 900);
636 let (mm, ss) = ((k * 15) % 60, 0);
637 let hh = (k * 15) / 60;
638 s.push_str(&format!("* 2024 1 9 {hh:2} {mm} {ss}.00000000\n"));
639 let p = cubic(sec);
640 s.push_str(&format!(
641 "PG01 {:13.6} {:13.6} {:13.6} {:13.6}\n",
642 p[0],
643 p[1],
644 p[2],
645 clock_us(sec)
646 ));
647 let v = cubic_velocity(sec); // km/s → dm/s is ×1e4
648 s.push_str(&format!(
649 "VG01 {:13.6} {:13.6} {:13.6} {:13.6}\n",
650 v[0] * 1.0e4,
651 v[1] * 1.0e4,
652 v[2] * 1.0e4,
653 0.0
654 ));
655 }
656 s.push_str("EOF\n");
657 s
658 }
659
660 #[test]
661 fn parses_epochs_and_satellites() {
662 let sp3 = Sp3::parse(&synthetic_sp3());
663 assert_eq!(sp3.epoch_count(), 5);
664 let sats: Vec<SatId> = sp3.satellites().collect();
665 assert_eq!(
666 sats,
667 vec![SatId {
668 system: 'G',
669 prn: 1
670 }]
671 );
672 }
673
674 /// The two accessors an orbit fit reads, against the grid and the samples the file was written
675 /// from. `epoch_count` above says how many there are; nothing else said *what* they are, so a
676 /// parser that returned one epoch, or the right count of the wrong positions, read as covered.
677 #[test]
678 fn the_accessors_hand_back_the_grid_and_the_samples_the_file_carries() {
679 let sp3 = Sp3::parse(&synthetic_sp3());
680 assert_eq!(
681 sp3.epochs(),
682 [0.0, 900.0, 1800.0, 2700.0, 3600.0].as_slice()
683 );
684
685 let g01 = SatId {
686 system: 'G',
687 prn: 1,
688 };
689 let pos = sp3.positions(g01).unwrap();
690 assert_eq!(pos.len(), sp3.epochs().len());
691 for (k, sample) in pos.iter().enumerate() {
692 let want = cubic(sp3.epochs()[k]);
693 let got = sample.unwrap();
694 for axis in 0..3 {
695 // The record carries six decimals of a kilometre, so a millimetre is its resolution.
696 assert!(
697 (got[axis] - want[axis] * 1000.0).abs() < 1e-3,
698 "epoch {k} axis {axis}: {}",
699 got[axis]
700 );
701 }
702 }
703
704 // A satellite the file does not carry is absent, which is not the same as carried with no
705 // samples — the caller that cannot tell those apart fits an empty arc and reports success.
706 assert!(
707 sp3.positions(SatId {
708 system: 'R',
709 prn: 7
710 })
711 .is_none()
712 );
713 }
714
715 #[test]
716 fn interpolation_reproduces_a_cubic_exactly() {
717 let sp3 = Sp3::parse(&synthetic_sp3());
718 let g01 = SatId {
719 system: 'G',
720 prn: 1,
721 };
722 // Off-grid time (t = 1350 s, between samples 2 and 3): cubic recovered to sub-mm.
723 let got = sp3.interpolate(g01, 1350.0, 3).unwrap();
724 let want = cubic(1350.0);
725 for axis in 0..3 {
726 let want_m = want[axis] * 1000.0;
727 assert!(
728 (got[axis] - want_m).abs() < 1e-3,
729 "axis {axis}: {} vs {want_m}",
730 got[axis]
731 );
732 }
733 }
734
735 /// The absolute epoch, against a date arithmetic that shares nothing with the parser's.
736 ///
737 /// `jdn` counts whole days from midnight and JD is measured from noon, so the half-day between
738 /// them is the one place this conversion can be wrong by a number that still looks like a date.
739 /// The two references are computed by calendar difference — 8 774 days from 2000-01-01 to
740 /// 2024-01-09 and 9 132 to 2025-01-01, each less the half day — and one of them is the real IGS
741 /// final product's first epoch, so the header shape is a real one and not only the synthetic.
742 #[test]
743 fn the_start_epoch_is_seconds_since_j2000_in_the_files_own_scale() {
744 let sp3 = Sp3::parse(&synthetic_sp3());
745 assert_eq!(sp3.start_epoch(), Some(758_030_400.0));
746 // No `%c` record in the synthetic file, and the format's default is GPS.
747 assert_eq!(sp3.time_system(), TimeSystem::Gps);
748
749 // The first two header lines and first epoch of IGS0OPSFIN_20250010000_01D_15M_ORB.SP3.
750 let igs = "#cP2025 1 1 0 0 0.00000000 96 ORBIT IGS20 HLM IGS\n\
751 ## 2347 259200.00000000 900.00000000 60676 0.0000000000000\n\
752 %c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
753 %c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
754 * 2025 1 1 0 0 0.00000000\n\
755 PG01 15931.689402 2160.462672 21149.136216 999999.999999\n";
756 let sp3 = Sp3::parse(igs);
757 assert_eq!(sp3.start_epoch(), Some(788_961_600.0));
758 assert_eq!(sp3.time_system(), TimeSystem::Gps);
759 assert_eq!(sp3.time_system().tai_offset_seconds(), Some(19.0));
760 }
761
762 /// The time system is read from the *first* `%c` record only.
763 ///
764 /// The second one is all filler, and its columns 10-12 hold `ccc`, which the parser maps to the
765 /// format's GPS default. Reading both would therefore turn every GLONASS product into a GPS one
766 /// — silently, and with the epochs off by the whole leap-second count.
767 #[test]
768 fn the_time_system_is_taken_from_the_first_c_record_and_the_filler_cannot_undo_it() {
769 let glo = "#cP2025 1 1 0 0 0.00000000 96 ORBIT PZ90 HLM IAC\n\
770 %c R cc GLO ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
771 %c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
772 * 2025 1 1 0 0 0.00000000\n\
773 PR01 15931.689402 2160.462672 21149.136216 999999.999999\n";
774 let sp3 = Sp3::parse(glo);
775 assert_eq!(sp3.time_system(), TimeSystem::Glo);
776 // UTC-bearing scales have no constant offset to TAI, and saying so is the point.
777 assert_eq!(sp3.time_system().tai_offset_seconds(), None);
778
779 // A file with no epoch records has no epoch to report.
780 assert_eq!(Sp3::parse("#cP2025 1 1 0 0 0.0\n").start_epoch(), None);
781 }
782
783 /// Every token the `%c` column can carry, read at the parser rather than through a whole file.
784 ///
785 /// Through `Sp3::parse` the GPS arm is invisible: an unrecognised token leaves `time_system`
786 /// `None` and `unwrap_or_default()` answers `Gps` regardless, so deleting the arm that
787 /// *recognises* GPS changes nothing a parsed file can show. Recognised-as-GPS against
788 /// fell-back-to-the-default is a distinction only the function has, and the six scales below it
789 /// are the ones no fixture in this file ever names — a product stamped in BDT or IRN would have
790 /// been read as GPS, and the epochs off by the 14 s between them.
791 #[test]
792 fn every_time_system_token_maps_to_its_own_scale() {
793 for (tok, want, tai) in [
794 ("GPS", TimeSystem::Gps, Some(19.0)),
795 ("ccc", TimeSystem::Gps, Some(19.0)),
796 ("GLO", TimeSystem::Glo, None),
797 ("GAL", TimeSystem::Gal, Some(19.0)),
798 ("TAI", TimeSystem::Tai, Some(0.0)),
799 ("UTC", TimeSystem::Utc, None),
800 ("QZS", TimeSystem::Qzs, Some(19.0)),
801 ("BDT", TimeSystem::Bdt, Some(33.0)),
802 ("IRN", TimeSystem::Irn, Some(19.0)),
803 ] {
804 assert_eq!(parse_time_system(tok), Some(want), "token {tok}");
805 assert_eq!(want.tai_offset_seconds(), tai, "offset for {tok}");
806 }
807
808 // `GST` is Galileo System Time under its own name and `UT1` is a scale, but neither is a
809 // token this format defines; the caller's fallback to GPS is a different statement from
810 // having read one.
811 assert_eq!(parse_time_system("GST"), None);
812 assert_eq!(parse_time_system("UT1"), None);
813 assert_eq!(parse_time_system(""), None);
814 }
815
816 #[test]
817 fn interpolation_passes_through_the_sample_points() {
818 let sp3 = Sp3::parse(&synthetic_sp3());
819 let g01 = SatId {
820 system: 'G',
821 prn: 1,
822 };
823 let got = sp3.interpolate(g01, 900.0, 3).unwrap(); // exactly at epoch index 1
824 let want = cubic(900.0);
825 for axis in 0..3 {
826 assert!((got[axis] - want[axis] * 1000.0).abs() < 1e-3);
827 }
828 }
829
830 #[test]
831 fn interpolation_recovers_the_clock_and_velocity() {
832 let sp3 = Sp3::parse(&synthetic_sp3());
833 let g01 = SatId {
834 system: 'G',
835 prn: 1,
836 };
837 // Clock is linear in time → recovered exactly (order ≥ 1), reported in seconds.
838 let clk = sp3.interpolate_clock(g01, 1350.0, 3).unwrap();
839 assert!(
840 (clk - clock_us(1350.0) * 1.0e-6).abs() < 1e-15,
841 "clock {clk}"
842 );
843 // Velocity is the cubic's exact derivative → recovered to sub-mm/s, reported in m/s.
844 let vel = sp3.interpolate_velocity(g01, 1350.0, 3).unwrap();
845 let want = cubic_velocity(1350.0);
846 for axis in 0..3 {
847 let want_ms = want[axis] * 1000.0;
848 assert!(
849 (vel[axis] - want_ms).abs() < 1e-6,
850 "axis {axis}: {} vs {want_ms}",
851 vel[axis]
852 );
853 }
854 }
855
856 #[test]
857 fn a_position_only_file_has_no_velocity_and_the_clock_sentinel_is_skipped() {
858 // Two epochs, position only, every clock the 999999.999999 sentinel.
859 let text = "\
860 #dP2024 1 9 0 0 0.00000000 2 ORBIT IGS20 HLM IGS\n\
861 * 2024 1 9 0 0 0.00000000\n\
862 PG01 16000.000000 -5000.000000 20000.000000 999999.999999\n\
863 * 2024 1 9 0 15 0.00000000\n\
864 PG01 16100.000000 -5100.000000 20050.000000 999999.999999\n\
865 EOF\n";
866 let sp3 = Sp3::parse(text);
867 let g01 = SatId {
868 system: 'G',
869 prn: 1,
870 };
871 assert!(sp3.interpolate(g01, 100.0, 1).is_some()); // position present
872 assert!(sp3.interpolate_velocity(g01, 100.0, 1).is_none()); // no V records
873 assert!(sp3.interpolate_clock(g01, 100.0, 1).is_none()); // all-sentinel clock skipped
874 }
875
876 // Reference Julian Day Numbers from CPython's proleptic-Gregorian calendar,
877 // `date(y, m, d).toordinal() + 1721425` — an independent implementation, not Fliegel–Van
878 // Flandern again. Two anchors fix the offset: 2000-01-01 is JDN 2451545 (the J2000.0 day) and
879 // 1858-11-17 is JDN 2400001 (MJD 0).
880 const JDN_CASES: [(i64, i64, i64, i64); 18] = [
881 (1, 1, 1, 1_721_426),
882 (1582, 10, 15, 2_299_161),
883 (1858, 11, 17, 2_400_001),
884 (1900, 2, 28, 2_415_079),
885 (1900, 3, 1, 2_415_080), // 1900 is not a leap year: the yy/100 term
886 (1999, 12, 31, 2_451_544),
887 (2000, 1, 1, 2_451_545),
888 (2000, 2, 29, 2_451_604),
889 (2000, 3, 1, 2_451_605), // 2000 is a leap year: the yy/400 term
890 (2024, 1, 9, 2_460_319),
891 (2024, 1, 10, 2_460_320),
892 (2024, 2, 28, 2_460_369),
893 (2024, 2, 29, 2_460_370),
894 (2024, 3, 1, 2_460_371), // the (14 - m)/12 branch flips between February and March
895 (2024, 12, 31, 2_460_676),
896 (2025, 1, 1, 2_460_677),
897 (2100, 2, 28, 2_488_128),
898 (2100, 3, 1, 2_488_129),
899 ];
900
901 #[test]
902 fn jdn_matches_the_gregorian_calendar_at_every_boundary_that_matters() {
903 // `parse` only ever uses `jdn` inside `secs - t0`, so a file whose epochs share one calendar
904 // date cannot see it at all — every constant cancels, including a `jdn` that returns 0. This
905 // pins the function itself against an external calendar.
906 for (y, m, d, want) in JDN_CASES {
907 assert_eq!(jdn(y, m, d), want, "jdn({y}, {m}, {d})");
908 }
909 // Consecutive days differ by exactly one, across the century and leap-day boundaries above.
910 for w in JDN_CASES.windows(2) {
911 let (a, b) = (w[0], w[1]);
912 if b.3 == a.3 + 1 {
913 assert_eq!(jdn(b.0, b.1, b.2) - jdn(a.0, a.1, a.2), 1);
914 }
915 }
916 }
917
918 #[test]
919 fn the_epoch_grid_spans_a_day_boundary_and_carries_hours_minutes_and_seconds() {
920 // Three epochs across midnight, each with a different hour, minute and second, so no term of
921 // `jdn·86400 + h·3600 + m·60 + s` is constant across the file and none can cancel:
922 // e1 = 86400 − (23·3600 + 45·60 + 30) = 86400 − 85530 = 870 s
923 // e2 = e1 + (1·3600 + 2·60 + 3.5) = 870 + 3723.5 = 4593.5 s
924 let text = "\
925 #dP2024 12 31 23 45 30.00000000 3 ORBIT IGS20 HLM IGS\n\
926 * 2024 12 31 23 45 30.00000000\n\
927 PG01 16000.000000 -5000.000000 20000.000000\n\
928 * 2025 1 1 0 0 0.00000000\n\
929 PG01 16100.000000 -5100.000000 20050.000000\n\
930 * 2025 1 1 1 2 3.50000000\n\
931 PG01 16200.000000 -5200.000000 20100.000000\n\
932 EOF\n";
933 let sp3 = Sp3::parse(text);
934 assert_eq!(sp3.epochs, vec![0.0, 870.0, 4593.5], "epoch grid");
935 }
936
937 #[test]
938 fn the_interpolation_window_slides_with_the_query_time() {
939 // Twelve samples 100 s apart and `order = 3` leave eight possible window starts, so the
940 // centring and the end clamp are both reachable. With a five-epoch file and `order = 3` there
941 // are only two, and with two epochs there is exactly one — which is why every existing
942 // fixture leaves this arithmetic unobservable.
943 let epochs: Vec<f64> = (0..12).map(|i| f64::from(i) * 100.0).collect();
944 // `want = order + 1 = 4`, `start = nearest − want/2`, clamped to `len − want = 8`. Ties in the
945 // nearest-sample search go to the earlier sample (`min_by` keeps the first minimum).
946 for (t, want_start) in [
947 (0.0, 0), // first sample: saturating_sub keeps the start at zero
948 (250.0, 0), // tie between samples 2 and 3 -> 2, minus 2 -> 0
949 (450.0, 2), // tie between samples 4 and 5 -> 4, minus 2 -> 2
950 (650.0, 4), // nearest 6, centred -> 4
951 (1050.0, 8), // nearest 10, centred -> 8, at the clamp
952 (1100.0, 8), // last sample: nearest 11, centred -> 9, clamped to 8
953 ] {
954 assert_eq!(window(&epochs, t, 3), Some((want_start, 4)), "t = {t}");
955 }
956 // An order past the sample count takes the whole grid and pins `want` to the length.
957 assert_eq!(window(&epochs, 650.0, 50), Some((0, 12)));
958 }
959
960 #[test]
961 fn short_records_are_skipped_and_a_clock_at_the_sentinel_is_rejected() {
962 // Three guards none of the fixtures above can reach: an epoch line with fewer than six
963 // numeric fields, a position record with no clock column at all, and a clock at exactly the
964 // 999999 µs sentinel — which is bad, while 999998 µs is a real (if absurd) bias.
965 let text = "\
966 #dP2025 1 1 0 0 0.00000000 2 ORBIT IGS20 HLM IGS\n\
967 * 2025 1 1 0 0 0.00000000\n\
968 PG01 16000.000000 -5000.000000 20000.000000\n\
969 PG02 16000.000000 -5000.000000 20000.000000 999999.000000\n\
970 PG03 16000.000000 -5000.000000 20000.000000 999998.000000\n\
971 * 2025 1 1 0 15\n\
972 * 2025 1 1 0 15 0.00000000\n\
973 PG01 16100.000000 -5100.000000 20050.000000\n\
974 PG02 16100.000000 -5100.000000 20050.000000 999999.000000\n\
975 PG03 16100.000000 -5100.000000 20050.000000 999998.000000\n\
976 EOF\n";
977 let sp3 = Sp3::parse(text);
978 let sat = |prn| SatId { system: 'G', prn };
979 assert_eq!(
980 sp3.epochs,
981 vec![0.0, 900.0],
982 "the five-field epoch line is not an epoch"
983 );
984 // A three-column position record is a valid record with no clock.
985 assert!(sp3.interpolate(sat(1), 450.0, 1).is_some());
986 assert!(sp3.interpolate_clock(sat(1), 450.0, 1).is_none());
987 assert!(
988 sp3.interpolate_clock(sat(2), 450.0, 1).is_none(),
989 "999999 µs is the sentinel"
990 );
991 let clk = sp3.interpolate_clock(sat(3), 450.0, 1).unwrap();
992 assert!(
993 (clk - 999_998.0e-6).abs() < 1e-15,
994 "999998 µs is a bias, not a sentinel: {clk}"
995 );
996 }
997
998 #[test]
999 fn unknown_satellite_has_no_interpolant() {
1000 let sp3 = Sp3::parse(&synthetic_sp3());
1001 assert!(
1002 sp3.interpolate(
1003 SatId {
1004 system: 'R',
1005 prn: 7
1006 },
1007 500.0,
1008 3
1009 )
1010 .is_none()
1011 );
1012 }
1013}