Skip to main content

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}