sidereon_core/sp3/mod.rs
1//! SP3-c / SP3-d precise-ephemeris parser.
2//!
3//! Parses the IGS SP3 precise orbit/clock format, both **SP3-c** and **SP3-d**
4//! (Hilla 2016), into a typed [`Sp3`] product. The parser is multi-GNSS,
5//! handles position/clock records plus optional velocity records,
6//! missing-value sentinels, predicted / clock-event / maneuver flags, and a
7//! system-aware [`GnssSatelliteId`]; the product's time system is read from the
8//! header.
9//!
10//! # Build vs adopt
11//!
12//! The spec permits using the `sp3` crate (MPL-2.0) as a deterministic byte
13//! reader, OR hand-rolling the record parsing. **This module hand-rolls it**,
14//! deliberately:
15//!
16//! - The `refs/sp3` crate hard-depends on `hifitime` for its `Epoch`,
17//! `TimeScale`, and `Duration`, and on `flate2`. `sidereon-core` models time
18//! with the **core crate's own** [`Instant`] / [`TimeScale`]
19//! family, which is hifitime-free; adopting the `sp3` crate would
20//! invert that and pull a parallel time stack into the GNSS layer.
21//! - The `sp3` crate also carries its own `SV` / `Constellation` identifiers
22//! that duplicate this crate's [`GnssSatelliteId`] / [`GnssSystem`].
23//! - The SP3 record grammar is small, fixed-column, and fully specified, so a
24//! byte reader is low-risk. (Note: the `refs/sp3` velocity parser at
25//! `parsing.rs:241-245` has an axis bug - it reuses the Y component for X;
26//! this module reads each axis independently and is unit-tested for it.)
27//!
28//! Parsing only is adopted-grade work; it is **not** a contested float recipe.
29//! The interpolation that consumes this product is built
30//! separately to match the `scipy.interpolate` reference and is out of scope
31//! for this module.
32//!
33//! # Units
34//!
35//! SP3 stores positions in **kilometers** and clock offsets in **microseconds**
36//! (velocities in dm/s, clock-rate in 1e-4 us/s). This parser converts at parse
37//! time to the crate's internal SI base units - positions in **meters**
38//! (`km * 1000.0`), clocks in **seconds** (`us * 1e-6`), velocities in **m/s**
39//! (`(dm/s) * 1e-1`), clock-rate in **s/s** (`(1e-4 us/s) * 1e-10`). Each scale
40//! factor is applied as a single multiply so the operation order is fixed for
41//! the clock-unit-conversion golden test.
42//!
43//! # Frames
44//!
45//! Positions/velocities are returned as frame-tagged [`ItrfPositionM`] /
46//! [`ItrfVelocityMS`], never a bare `position_m`.
47
48use std::collections::BTreeMap;
49
50use crate::astro::time::civil::{j2000_seconds, split_julian_date};
51use crate::astro::time::model::{Instant, InstantRepr, JulianDateSplit, TimeScale};
52
53use crate::constants::{KM_TO_M, US_TO_S};
54use crate::format::columns::{
55 char_at, raw_field as field, raw_field_from as field_from, strict_f64,
56};
57use crate::format::{Diagnostics, RecordRef, Skip, SkipReason};
58use crate::frame::{ItrfPositionM, ItrfVelocityMS};
59use crate::id::{is_valid_prn, GnssSatelliteId, GnssSystem};
60use crate::validate;
61use crate::{Error, Result};
62
63/// SP3 missing/bad position component sentinel, in kilometers.
64///
65/// SP3 writes a satellite with no usable orbit as a position record of exactly
66/// `0.000000 0.000000 0.000000`. We treat an all-zero position as "missing"
67/// (matching the `refs/sp3` validity guard at `parsing.rs:186`): a satellite is
68/// never legitimately at the geocenter.
69const MISSING_POSITION_KM: f64 = 0.0;
70/// SP3 missing velocity component sentinel, in decimeters per second.
71///
72/// Velocity products still carry a `V` record for each `P` record. When no
73/// velocity estimate exists, the record uses the all-zero vector sentinel rather
74/// than being omitted; do not surface that as a fabricated stationary satellite.
75const MISSING_VELOCITY_DM_S: f64 = 0.0;
76
77/// SP3 bad-clock sentinel, in microseconds: `999999.999999`.
78///
79/// A clock value at or above this magnitude means "no clock estimate"; it is
80/// surfaced as `clock_s = None`, not converted.
81const BAD_CLOCK_US: f64 = 999_999.999_999;
82
83/// SP3 velocity records are in decimeters per second; dm/s -> m/s is `* 0.1`.
84const DM_S_TO_M_S: f64 = 1.0e-1;
85/// SP3 clock-rate is in 1e-4 microseconds/second; -> s/s is `* 1e-10`.
86const CLOCK_RATE_TO_S_PER_S: f64 = 1.0e-10;
87
88/// SP3 format version.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum Sp3Version {
91 /// SP3-a (legacy, GPS-only).
92 A,
93 /// SP3-b.
94 B,
95 /// SP3-c.
96 C,
97 /// SP3-d (multi-GNSS, Hilla 2016).
98 D,
99}
100
101impl Sp3Version {
102 fn from_char(c: char) -> Result<Self> {
103 match c {
104 'a' | 'A' => Ok(Sp3Version::A),
105 'b' | 'B' => Ok(Sp3Version::B),
106 'c' | 'C' => Ok(Sp3Version::C),
107 'd' | 'D' => Ok(Sp3Version::D),
108 other => Err(Error::Parse(format!("unknown SP3 version '{other}'"))),
109 }
110 }
111}
112
113/// What kind of records the file carries.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum Sp3DataType {
116 /// Position + clock records only (`#?P...`).
117 Position,
118 /// Position + velocity (+ clock + clock-rate) records (`#?V...`).
119 Velocity,
120}
121
122impl Sp3DataType {
123 fn from_char(c: char) -> Result<Self> {
124 match c {
125 'P' => Ok(Sp3DataType::Position),
126 'V' => Ok(Sp3DataType::Velocity),
127 other => Err(Error::Parse(format!("unknown SP3 data type '{other}'"))),
128 }
129 }
130}
131
132/// SP3 time-system labels from the `%c` descriptor.
133///
134/// The core [`TimeScale`] model does not distinguish every SP3 label as its own
135/// global scale. Keep the exact SP3 label here so products using GLONASS, QZSS,
136/// or IRNSS time are accepted and can be serialized without being silently
137/// relabeled.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
139pub enum Sp3TimeSystem {
140 /// GPS time (`GPS`).
141 Gps,
142 /// GLONASS UTC time system (`GLO`).
143 Glonass,
144 /// Galileo system time (`GAL`).
145 Galileo,
146 /// International Atomic Time (`TAI`).
147 Tai,
148 /// Coordinated Universal Time (`UTC`).
149 Utc,
150 /// QZSS time (`QZS`).
151 Qzss,
152 /// BeiDou time (`BDT`).
153 Beidou,
154 /// IRNSS / NavIC time (`IRN`).
155 Irnss,
156}
157
158impl Sp3TimeSystem {
159 /// Canonical three-character SP3 label.
160 pub fn label(self) -> &'static str {
161 match self {
162 Sp3TimeSystem::Gps => "GPS",
163 Sp3TimeSystem::Glonass => "GLO",
164 Sp3TimeSystem::Galileo => "GAL",
165 Sp3TimeSystem::Tai => "TAI",
166 Sp3TimeSystem::Utc => "UTC",
167 Sp3TimeSystem::Qzss => "QZS",
168 Sp3TimeSystem::Beidou => "BDT",
169 Sp3TimeSystem::Irnss => "IRN",
170 }
171 }
172
173 /// Core time scale used to tag parsed [`Instant`] values.
174 ///
175 /// For labels the core model has exactly, this is the direct equivalent. For
176 /// SP3-only labels, the exact product label remains available through
177 /// [`Sp3Header::time_system`], and this value preserves the existing
178 /// interpolation-axis API until the global time model grows those scales.
179 pub fn time_scale(self) -> TimeScale {
180 match self {
181 Sp3TimeSystem::Gps | Sp3TimeSystem::Irnss => TimeScale::Gpst,
182 // QZSST is the exact core scale for the SP3 "QZS" label (nominally
183 // synchronous with GPST); IRNSS has no distinct core scale yet.
184 Sp3TimeSystem::Qzss => TimeScale::Qzsst,
185 Sp3TimeSystem::Glonass | Sp3TimeSystem::Utc => TimeScale::Utc,
186 Sp3TimeSystem::Galileo => TimeScale::Gst,
187 Sp3TimeSystem::Tai => TimeScale::Tai,
188 Sp3TimeSystem::Beidou => TimeScale::Bdt,
189 }
190 }
191
192 fn civil_second_policy(self) -> validate::CivilSecondPolicy {
193 match self {
194 Sp3TimeSystem::Glonass | Sp3TimeSystem::Utc => validate::CivilSecondPolicy::UtcLike,
195 Sp3TimeSystem::Gps
196 | Sp3TimeSystem::Galileo
197 | Sp3TimeSystem::Tai
198 | Sp3TimeSystem::Qzss
199 | Sp3TimeSystem::Beidou
200 | Sp3TimeSystem::Irnss => validate::CivilSecondPolicy::Continuous,
201 }
202 }
203}
204
205/// Per-record quality / status flags (SP3-c columns 75-80, SP3-d same layout).
206///
207/// All four flags are independent and any combination may appear (e.g. a
208/// predicted orbit during a maneuver). They are surfaced verbatim from the
209/// record and never alter the parsed numbers.
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
211pub struct Sp3Flags {
212 /// `E` in the clock-event column: a clock discontinuity occurred near this
213 /// epoch; clock interpolation across it is unsafe.
214 pub clock_event: bool,
215 /// `P` in the clock-prediction column: the clock is predicted, not fitted.
216 pub clock_predicted: bool,
217 /// `M` in the maneuver column: the satellite was being maneuvered; the
218 /// state is not suitable for precise navigation.
219 pub maneuver: bool,
220 /// `P` in the orbit-prediction column: the orbit is predicted, not fitted.
221 pub orbit_predicted: bool,
222}
223
224/// A single satellite state at one SP3 epoch.
225///
226/// This is the spec's `Sp3State { position: ItrfPositionM, clock_s, velocity?,
227/// clock_rate?, flags }`. The frame/units are encoded in the
228/// member types; missing optional values are `None` rather than sentinels.
229#[derive(Debug, Clone, Copy, PartialEq)]
230pub struct Sp3State {
231 /// Satellite position in the ITRF/IGS ECEF frame, meters.
232 pub position: ItrfPositionM,
233 /// Satellite clock offset in **seconds** (`None` if the bad-clock sentinel
234 /// `999999.999999` us was recorded).
235 pub clock_s: Option<f64>,
236 /// Satellite velocity in the ITRF/IGS ECEF frame, m/s (present only for
237 /// velocity products).
238 pub velocity: Option<ItrfVelocityMS>,
239 /// Satellite clock rate in **seconds per second** (present only for
240 /// velocity products that carry a clock-rate field).
241 pub clock_rate_s_s: Option<f64>,
242 /// Per-record status flags.
243 pub flags: Sp3Flags,
244}
245
246/// Prediction status aggregated over every satellite record at one SP3 epoch.
247#[derive(Debug, Clone, PartialEq)]
248pub struct Sp3EpochPrediction {
249 /// The parsed epoch.
250 pub epoch: Instant,
251 /// Satellites whose orbit record is marked predicted at this epoch.
252 pub orbit_predicted_satellites: Vec<GnssSatelliteId>,
253 /// Satellites whose clock record is marked predicted at this epoch.
254 pub clock_predicted_satellites: Vec<GnssSatelliteId>,
255}
256
257impl Sp3EpochPrediction {
258 /// True when no position or clock record at this epoch is marked predicted.
259 pub fn is_observed(&self) -> bool {
260 self.orbit_predicted_satellites.is_empty() && self.clock_predicted_satellites.is_empty()
261 }
262}
263
264/// Product-wide observed/predicted metadata derived from SP3 record flags.
265#[derive(Debug, Clone, PartialEq)]
266pub struct Sp3PredictionSummary {
267 /// Per-epoch prediction status in parsed epoch order.
268 pub epochs: Vec<Sp3EpochPrediction>,
269 /// Last epoch before the first epoch containing any predicted record. This
270 /// is the product's truthful contiguous observed-through boundary. It is
271 /// `None` when the first epoch is already predicted or the product is empty.
272 pub observed_through: Option<Instant>,
273}
274
275/// Parsed SP3 header.
276#[derive(Debug, Clone, PartialEq)]
277pub struct Sp3Header {
278 /// SP3 format version (`a`/`b`/`c`/`d`).
279 pub version: Sp3Version,
280 /// Whether the file carries velocity records.
281 pub data_type: Sp3DataType,
282 /// Number of parsed epochs in the canonical product.
283 pub num_epochs: u64,
284 /// Coordinate-system / IGS-realization label (e.g. `IGS14`, `ITRF2`).
285 pub coordinate_system: String,
286 /// Orbit-type label (e.g. `FIT`, `BHN`).
287 pub orbit_type: String,
288 /// Producing agency.
289 pub agency: String,
290 /// GNSS week number (in the file's time system).
291 pub gnss_week: u32,
292 /// Seconds of week of the first epoch.
293 pub seconds_of_week: f64,
294 /// Nominal epoch spacing in seconds.
295 pub epoch_interval_s: f64,
296 /// Modified Julian Day of the first epoch (integer part).
297 pub mjd: u32,
298 /// Fractional day of the first epoch.
299 pub mjd_fraction: f64,
300 /// Time system label the epochs are expressed in. For SP3-b/c/d this is read
301 /// strictly from the first `%c` descriptor (a missing/short/blank descriptor
302 /// is a parse error, never a silent GPST default); SP3-a is implicitly GPST.
303 pub time_system: Sp3TimeSystem,
304 /// Core [`TimeScale`] used to tag parsed [`Instant`] values. See
305 /// [`Sp3Header::time_system`] for the exact SP3 label when the product uses
306 /// a standard SP3 time system that is not modeled as a distinct core scale.
307 pub time_scale: TimeScale,
308 /// The satellite list declared in the `+` header lines.
309 pub satellites: Vec<GnssSatelliteId>,
310 /// Per-satellite accuracy exponent codes from the `++` header lines,
311 /// index-aligned with [`Sp3Header::satellites`].
312 pub satellite_accuracy_codes: Vec<u16>,
313}
314
315/// A parsed SP3 precise-ephemeris product.
316///
317/// Construct with [`Sp3::parse`]. Epochs are stored in ascending order; each
318/// epoch maps satellite -> [`Sp3State`]. Per-satellite/per-epoch access is via
319/// [`Sp3::state`]; arbitrary-epoch interpolation is built separately to match
320/// the parity reference and is not part of this parser.
321#[derive(Debug, Clone, PartialEq)]
322pub struct Sp3 {
323 /// The parsed header.
324 pub header: Sp3Header,
325 /// Epochs in ascending time order, tagged with the header time scale.
326 pub epochs: Vec<Instant>,
327 /// Exact seconds since J2000 for each parsed epoch, in the product time
328 /// scale, formed from the epoch record's civil fields with integer
329 /// whole-second arithmetic.
330 epoch_j2000_s: Vec<f64>,
331 /// `epoch_index -> (satellite -> state)`. Parallel to [`Sp3::epochs`].
332 states: Vec<BTreeMap<GnssSatelliteId, Sp3State>>,
333 /// `epoch_index -> (satellite -> native-unit node)`. Parallel to
334 /// [`Sp3::epochs`]; populated **only** from genuine position records. The
335 /// interpolator fits its spline over these (km/us straight from the ASCII,
336 /// exactly as the `scipy`/`gnssanalysis` reference does); reconstructing km
337 /// from the public meters (`km->m->km`) drifts up to 1 ULP and breaks the
338 /// 0-ULP parity. See `sp3/interp.rs`.
339 interp_raw: Vec<BTreeMap<GnssSatelliteId, RawNode>>,
340 /// Free-form `/*` comment lines (notice retained for provenance).
341 pub comments: Vec<String>,
342 /// Count of entries skipped because their satellite token did not parse to a
343 /// representable [`GnssSatelliteId`] (e.g. an extended GLONASS slot like `R28`
344 /// beyond the engine's PRN cap): position/velocity records, plus `+`-header
345 /// satellite declarations. Lets callers tell a clean file
346 /// (`skipped_records == 0`) apart from one carrying unsupported satellites,
347 /// without aborting the whole parse on one such entry. Mirrors
348 /// [`crate::astro::sgp4::TleFile::skipped`].
349 pub skipped_records: usize,
350}
351
352/// Native-unit interpolation node: the file's own km / microseconds, kept
353/// verbatim from the ASCII so the spline fit is bit-identical to the reference.
354/// Private - the public surface is meters/seconds via [`Sp3State`].
355#[derive(Debug, Clone, Copy, PartialEq)]
356struct RawNode {
357 /// ECEF position in native SP3 kilometers (X/Y/Z), exact ASCII->f64.
358 km: [f64; 3],
359 /// Clock offset in native SP3 microseconds (`None` for the bad-clock
360 /// sentinel), exact ASCII->f64.
361 clock_us: Option<f64>,
362 /// Whether this epoch carried the clock-event (`E`) flag (clock-arc split).
363 clock_event: bool,
364}
365
366impl Sp3 {
367 /// Parse an SP3-c or SP3-d byte buffer into a typed product.
368 ///
369 /// `bytes` is the full file content (already decompressed; this crate does
370 /// not do gzip - that is a caller-layer I/O concern). Returns
371 /// [`Error::Parse`] with a human-readable reason on malformed input.
372 pub fn parse(bytes: &[u8]) -> Result<Self> {
373 let text = std::str::from_utf8(bytes)
374 .map_err(|e| Error::Parse(format!("SP3 is not valid UTF-8: {e}")))?;
375 Self::parse_str(text)
376 }
377
378 /// Parse from a `&str` (the UTF-8 fast path used by [`Sp3::parse`]).
379 pub fn parse_str(text: &str) -> Result<Self> {
380 if !text.is_ascii() {
381 return Err(Error::Parse("SP3 product text must be ASCII".into()));
382 }
383 let mut parser = Parser::new();
384 for (index, raw) in text.lines().enumerate() {
385 parser.feed(raw, index + 1)?;
386 }
387 parser.finish()
388 }
389
390 /// The satellites present in this product (from the header satellite list).
391 pub fn satellites(&self) -> &[GnssSatelliteId] {
392 &self.header.satellites
393 }
394
395 /// Number of parsed epochs.
396 pub fn epoch_count(&self) -> usize {
397 self.epochs.len()
398 }
399
400 /// The state of `sat` at the parsed epoch with index `epoch_index`.
401 ///
402 /// Returns [`Error::EpochOutOfRange`] if the index is past the end, or
403 /// [`Error::UnknownSatellite`] if the satellite has no record at that epoch.
404 pub fn state(&self, sat: GnssSatelliteId, epoch_index: usize) -> Result<Sp3State> {
405 let per_epoch = self.states.get(epoch_index).ok_or(Error::EpochOutOfRange)?;
406 per_epoch
407 .get(&sat)
408 .copied()
409 .ok_or(Error::UnknownSatellite(sat))
410 }
411
412 /// All `(satellite, state)` pairs recorded at `epoch_index`, in ascending
413 /// satellite order.
414 pub fn states_at(&self, epoch_index: usize) -> Result<&BTreeMap<GnssSatelliteId, Sp3State>> {
415 self.states.get(epoch_index).ok_or(Error::EpochOutOfRange)
416 }
417
418 /// Aggregate the per-record SP3 orbit/clock prediction flags by epoch and
419 /// compute the contiguous observed-through boundary.
420 ///
421 /// This uses the actual `P` flags carried by position records; it never
422 /// assumes a fixed ultra-rapid observed duration. Individual cell flags
423 /// remain available through [`Sp3::state`] and [`Sp3::states_at`].
424 pub fn prediction_summary(&self) -> Sp3PredictionSummary {
425 let epochs: Vec<Sp3EpochPrediction> = self
426 .epochs
427 .iter()
428 .copied()
429 .zip(self.states.iter())
430 .map(|(epoch, states)| Sp3EpochPrediction {
431 epoch,
432 orbit_predicted_satellites: states
433 .iter()
434 .filter_map(|(satellite, state)| {
435 state.flags.orbit_predicted.then_some(*satellite)
436 })
437 .collect(),
438 clock_predicted_satellites: states
439 .iter()
440 .filter_map(|(satellite, state)| {
441 state.flags.clock_predicted.then_some(*satellite)
442 })
443 .collect(),
444 })
445 .collect();
446 let first_predicted = epochs.iter().position(|epoch| !epoch.is_observed());
447 let observed_through = match first_predicted {
448 Some(0) => None,
449 Some(index) => self.epochs.get(index - 1).copied(),
450 None => self.epochs.last().copied(),
451 };
452
453 Sp3PredictionSummary {
454 epochs,
455 observed_through,
456 }
457 }
458}
459
460impl core::str::FromStr for Sp3 {
461 type Err = Error;
462
463 fn from_str(s: &str) -> Result<Self> {
464 Self::parse_str(s)
465 }
466}
467
468#[cfg(test)]
469impl Sp3 {}
470
471/// Parse an SP3 time-system label.
472///
473/// SP3-c/-d encode the time system in the `%c` descriptor line (chars 9-12).
474/// SP3-a is implicitly GPST. Unknown labels error rather than silently
475/// defaulting, so a parity pipeline never mis-attributes an epoch's scale.
476fn time_system_from_label(label: &str) -> Result<Sp3TimeSystem> {
477 match label.trim() {
478 "GPS" => Ok(Sp3TimeSystem::Gps),
479 "GLO" => Ok(Sp3TimeSystem::Glonass),
480 "GAL" => Ok(Sp3TimeSystem::Galileo),
481 "TAI" => Ok(Sp3TimeSystem::Tai),
482 "UTC" => Ok(Sp3TimeSystem::Utc),
483 "QZS" => Ok(Sp3TimeSystem::Qzss),
484 "BDT" | "BDS" => Ok(Sp3TimeSystem::Beidou),
485 "IRN" => Ok(Sp3TimeSystem::Irnss),
486 trimmed => Err(Error::Parse(format!(
487 "unsupported SP3 time system '{trimmed}'"
488 ))),
489 }
490}
491
492/// Compute the integer-day / fraction split Julian date from a Gregorian UTC-ish
493/// civil epoch, with the day fraction carried separately (Skyfield split
494/// convention, matching [`JulianDateSplit`]).
495///
496/// SP3 epoch lines are civil dates in the file's *own* time system; we keep
497/// them in that scale (no leap-second shifting here - that is a conversion
498/// concern handled by the core `scales` machinery, not the parser). The
499/// algorithm is the standard Fliegel-Van Flandern Gregorian-to-JDN, then the
500/// time-of-day fraction. JDN is computed in integer arithmetic so the whole-day
501/// boundary is exact; only the sub-day fraction is floating point.
502fn civil_to_julian_split(civil: validate::ValidCivil) -> Result<JulianDateSplit> {
503 // Canonical civil-to-split conversion: the integer JDN places the `*.5`
504 // civil-midnight boundary and the within-day clock fields become the
505 // fraction. SP3 epochs are civil days in the file's own scale (no leap
506 // second). The carry below is retained for the rare epoch whose seconds
507 // overflow a day.
508 let (mut jd_whole, mut fraction) = split_julian_date(
509 civil.year as i32,
510 civil.month as i32,
511 civil.day as i32,
512 civil.hour as i32,
513 civil.minute as i32,
514 civil.second,
515 );
516 if fraction > 1.0 {
517 let carry = fraction.floor();
518 jd_whole += carry;
519 fraction -= carry;
520 }
521 JulianDateSplit::new(jd_whole, fraction)
522 .map_err(|error| Error::Parse(format!("invalid SP3 epoch Julian date: {error}")))
523}
524
525/// Incremental line-driven SP3 parser state machine.
526struct Parser {
527 version: Option<Sp3Version>,
528 data_type: Option<Sp3DataType>,
529 num_epochs: u64,
530 coordinate_system: String,
531 orbit_type: String,
532 agency: String,
533 gnss_week: u32,
534 seconds_of_week: f64,
535 epoch_interval_s: f64,
536 mjd: u32,
537 mjd_fraction: f64,
538 time_system: Option<Sp3TimeSystem>,
539 /// `+`-line declared satellites, in file order.
540 sat_list: Vec<GnssSatelliteId>,
541 /// `++`-line per-satellite accuracy codes, in satellite-list order.
542 sat_accuracy_codes: Vec<u16>,
543 /// Number of real (non-padding) `+`-line satellite slots seen, including any
544 /// dropped because their token was unrepresentable. The `++` accuracy codes
545 /// are positionally aligned with these declaration slots, so this is the axis
546 /// the accuracy parser walks (not the filtered [`Self::sat_list`]).
547 declared_sat_slots: usize,
548 /// Declaration-slot indices (into the `declared_sat_slots` axis) whose token
549 /// was unrepresentable and dropped from [`Self::sat_list`]. Their `++`
550 /// accuracy columns must be skipped so the surviving satellites keep their own
551 /// codes. Empty for every well-formed file, making the accuracy parse a no-op
552 /// realignment in the common case.
553 dropped_sat_slots: Vec<usize>,
554 /// Cursor along the declaration-slot axis consumed by the `++` accuracy
555 /// parser across one or more `++` lines.
556 accuracy_slot_cursor: usize,
557 /// `%c` descriptor lines seen so far (the first carries the time system).
558 pc_count: u32,
559 /// Header line 1 parsed?
560 have_line1: bool,
561 /// Header line 2 parsed?
562 have_line2: bool,
563 /// Epoch currently being filled.
564 current_epoch: Option<Instant>,
565 epochs: Vec<Instant>,
566 epoch_j2000_s: Vec<f64>,
567 states: Vec<BTreeMap<GnssSatelliteId, Sp3State>>,
568 interp_raw: Vec<BTreeMap<GnssSatelliteId, RawNode>>,
569 comments: Vec<String>,
570 diagnostics: Diagnostics,
571 done: bool,
572}
573
574impl Parser {
575 fn new() -> Self {
576 Self {
577 version: None,
578 data_type: None,
579 num_epochs: 0,
580 coordinate_system: String::new(),
581 orbit_type: String::new(),
582 agency: String::new(),
583 gnss_week: 0,
584 seconds_of_week: 0.0,
585 epoch_interval_s: 0.0,
586 mjd: 0,
587 mjd_fraction: 0.0,
588 time_system: None,
589 sat_list: Vec::new(),
590 sat_accuracy_codes: Vec::new(),
591 declared_sat_slots: 0,
592 dropped_sat_slots: Vec::new(),
593 accuracy_slot_cursor: 0,
594 pc_count: 0,
595 have_line1: false,
596 have_line2: false,
597 current_epoch: None,
598 epochs: Vec::new(),
599 epoch_j2000_s: Vec::new(),
600 states: Vec::new(),
601 interp_raw: Vec::new(),
602 comments: Vec::new(),
603 diagnostics: Diagnostics::new(),
604 done: false,
605 }
606 }
607
608 fn feed(&mut self, raw: &str, line_number: usize) -> Result<()> {
609 if self.done {
610 return Ok(());
611 }
612 // SP3 is fixed-column ASCII; trim only the trailing CR / newline noise,
613 // never leading spaces (columns are significant).
614 let line = raw.trim_end_matches(['\r', '\n']);
615
616 if line == "EOF" {
617 self.done = true;
618 return Ok(());
619 }
620 if line.starts_with("/*") {
621 // Comment line; columns 4.. are the text.
622 if line.len() > 3 {
623 self.comments.push(line[3..].trim_end().to_string());
624 } else {
625 self.comments.push(String::new());
626 }
627 return Ok(());
628 }
629 // Header line 2 (`##`) must be tested before line 1 (`#`).
630 if line.starts_with("##") {
631 self.parse_line2(line)?;
632 return Ok(());
633 }
634 if line.starts_with('#') {
635 self.parse_line1(line)?;
636 return Ok(());
637 }
638 if line.starts_with('+') {
639 self.parse_plus_line(line, line_number)?;
640 return Ok(());
641 }
642 if line.starts_with("%c") {
643 self.parse_pc_line(line)?;
644 return Ok(());
645 }
646 if line.starts_with("%f") || line.starts_with("%i") {
647 // Float/int accuracy descriptor lines - not needed for the typed
648 // state; skipped deterministically.
649 return Ok(());
650 }
651 if line.starts_with('*') {
652 self.parse_epoch_line(line)?;
653 return Ok(());
654 }
655 if line.starts_with('P') {
656 self.parse_position_line(line, line_number)?;
657 return Ok(());
658 }
659 if line.starts_with('V') {
660 self.parse_velocity_line(line, line_number)?;
661 return Ok(());
662 }
663 // Unknown / ignorable line (e.g. `%/`); skip without failing - SP3 has
664 // optional descriptor lines a parser must tolerate.
665 Ok(())
666 }
667
668 /// Header line 1: `#cP2020 ...` / `#dV...`.
669 fn parse_line1(&mut self, line: &str) -> Result<()> {
670 // Minimum well-formed line-1 length per the standard.
671 if line.len() < 55 {
672 return Err(Error::Parse(format!(
673 "SP3 header line 1 too short: {line:?}"
674 )));
675 }
676 let chars: Vec<char> = line.chars().collect();
677 let version = Sp3Version::from_char(chars[1])?;
678 self.version = Some(version);
679 self.data_type = Some(Sp3DataType::from_char(chars[2])?);
680 // SP3-a predates the %c time-system descriptor and is implicitly GPST.
681 // Set it here so a (correct) SP3-a file with no %c line still resolves,
682 // while SP3-b/c/d are left as None until a valid %c line proves the
683 // scale (a missing %c then becomes a hard error, not a GPST default).
684 if matches!(version, Sp3Version::A) {
685 self.time_system = Some(Sp3TimeSystem::Gps);
686 }
687
688 // Column layout per the SP3 standard, matching the (round-trip-tested)
689 // refs/sp3 line-1 reader: num_epochs 32..40, observables 40..45,
690 // coord_system 45..51, orbit_type 51..55, agency 55...
691 self.num_epochs = field(line, 32, 40)
692 .trim()
693 .parse::<u64>()
694 .map_err(|_| Error::Parse(format!("SP3 num_epochs unparsable in {line:?}")))?;
695 self.coordinate_system = field(line, 45, 51).trim().to_string();
696 self.orbit_type = field(line, 51, 55).trim().to_string();
697 self.agency = field_from(line, 55).trim().to_string();
698 self.have_line1 = true;
699 Ok(())
700 }
701
702 /// Header line 2: `## 2276 21600.00000000 900.00000000 60176 0.25...`.
703 fn parse_line2(&mut self, line: &str) -> Result<()> {
704 self.gnss_week = field(line, 3, 7)
705 .trim()
706 .parse::<u32>()
707 .map_err(|_| Error::Parse(format!("SP3 GNSS week unparsable in {line:?}")))?;
708 self.seconds_of_week = field(line, 8, 23)
709 .trim()
710 .parse::<f64>()
711 .map_err(|_| Error::Parse(format!("SP3 seconds-of-week unparsable in {line:?}")))?;
712 self.epoch_interval_s = field(line, 24, 38)
713 .trim()
714 .parse::<f64>()
715 .map_err(|_| Error::Parse(format!("SP3 epoch interval unparsable in {line:?}")))?;
716 self.mjd = field(line, 39, 44)
717 .trim()
718 .parse::<u32>()
719 .map_err(|_| Error::Parse(format!("SP3 MJD unparsable in {line:?}")))?;
720 self.mjd_fraction = strict_f64(field_from(line, 45), "mjd_fraction")
721 .map_err(|error| map_field_error(error, line))?;
722 self.have_line2 = true;
723 Ok(())
724 }
725
726 /// `+` satellite-list line: `+ 32 G01G02...` (3-char SV tokens from
727 /// column 9 in groups of 17). Continuation `+` lines append more tokens.
728 fn parse_plus_line(&mut self, line: &str, line_number: usize) -> Result<()> {
729 if line.starts_with("++") {
730 return self.parse_accuracy_line(line);
731 }
732 // SV tokens start at column 9 (0-based), each 3 chars, up to 17 per line.
733 let mut col = 9;
734 while col + 3 <= line.len() {
735 let token = field(line, col, col + 3);
736 let trimmed = token.trim();
737 // Unused satellite slots are zero-filled, not a declaration. The
738 // SP3 zero-fill varies between producers (` 0`, ` 00`, `000`), so
739 // any all-zero (or blank) token is padding - never a satellite,
740 // whose token is a system letter + PRN (or, in SP3-a, a non-zero
741 // numeric PRN). Misreading ` 00` as an unrepresentable satellite
742 // inflates `skipped_records` and breaks the parse/write/parse
743 // round trip (the writer re-emits the canonical ` 0`).
744 if trimmed.is_empty() || trimmed.bytes().all(|b| b == b'0') {
745 col += 3;
746 continue;
747 }
748 // This is a real declaration slot; the `++` accuracy codes are aligned
749 // to this axis, so track its index whether or not the token resolves.
750 let slot_index = self.declared_sat_slots;
751 self.declared_sat_slots += 1;
752 if let Some(id) = parse_sv_token(token, self.version) {
753 if !self.sat_list.contains(&id) {
754 self.sat_list.push(id);
755 }
756 } else {
757 // A declared satellite whose token is not representable (e.g. an
758 // extended GLONASS slot R28 beyond the engine's PRN cap) is
759 // dropped from the satellite list, but counted rather than dropped
760 // silently - consistent with the position/velocity record paths
761 // (see `Sp3::skipped_records`). Record the slot so its accuracy
762 // column is skipped, keeping the surviving codes aligned.
763 self.push_unrepresentable_satellite_skip(line_number, token);
764 self.dropped_sat_slots.push(slot_index);
765 }
766 col += 3;
767 }
768 Ok(())
769 }
770
771 /// `++` per-satellite accuracy-code line: 3-char integer fields from column
772 /// 9, aligned with the `+` declaration slots.
773 ///
774 /// The columns track the `+` declaration order, so a column whose declaration
775 /// slot was dropped (an unrepresentable satellite) is read and discarded, not
776 /// pushed - otherwise the surviving satellites would inherit a neighbour's
777 /// accuracy code. With no dropped slots this is exactly the 1:1 push as before.
778 fn parse_accuracy_line(&mut self, line: &str) -> Result<()> {
779 let mut col = 9;
780 while col + 3 <= line.len() && self.accuracy_slot_cursor < self.declared_sat_slots {
781 let token = field(line, col, col + 3);
782 let trimmed = token.trim();
783 let code = if trimmed.is_empty() {
784 0
785 } else {
786 validate::strict_int::<u16>(trimmed, "satellite_accuracy_code")
787 .map_err(|error| map_field_error(error, line))?
788 };
789 if !self.dropped_sat_slots.contains(&self.accuracy_slot_cursor) {
790 self.sat_accuracy_codes.push(code);
791 }
792 self.accuracy_slot_cursor += 1;
793 col += 3;
794 }
795 Ok(())
796 }
797
798 /// `%c` descriptor: the first one (chars 9-12) carries the time system.
799 fn parse_pc_line(&mut self, line: &str) -> Result<()> {
800 if self.pc_count == 0 {
801 // SP3-a is implicitly GPST regardless of descriptor content.
802 if matches!(self.version, Some(Sp3Version::A)) {
803 self.time_system = Some(Sp3TimeSystem::Gps);
804 } else if line.len() >= 12 {
805 let label = field(line, 9, 12);
806 let trimmed = label.trim();
807 // STRICT: a blank time-system field on the first %c is not GPST,
808 // it is malformed. Reject rather than silently defaulting so a
809 // precise pipeline never mis-attributes an epoch's scale.
810 if trimmed.is_empty() {
811 return Err(Error::Parse(format!(
812 "SP3 %c time system is blank in {line:?}"
813 )));
814 }
815 self.time_system = Some(time_system_from_label(label)?);
816 } else {
817 // STRICT: a short %c line for SP3-b/c/d carries no time system
818 // we can trust. Reject rather than defaulting to GPST.
819 return Err(Error::Parse(format!(
820 "SP3 %c descriptor too short to carry a time system: {line:?}"
821 )));
822 }
823 }
824 self.pc_count += 1;
825 Ok(())
826 }
827
828 /// Epoch line: `* 2020 6 24 0 0 0.00000000`.
829 fn parse_epoch_line(&mut self, line: &str) -> Result<()> {
830 // STRICT: by the time we reach data, the time system must be known -
831 // implicitly GPST for SP3-a (set at line 1), or from a valid first %c
832 // line for SP3-b/c/d. A missing/blank/short %c is an error, never GPST.
833 let time_system = self.time_system.ok_or_else(|| {
834 Error::Parse("SP3 epoch encountered with no time system (missing %c descriptor)".into())
835 })?;
836 let scale = time_system.time_scale();
837 // Fields after the leading `* ` (3 chars), then space-delimited.
838 let body = &line[1..];
839 let mut it = body.split_whitespace();
840 let year: i64 = next_field(&mut it, "epoch year")?;
841 let month: i64 = next_field(&mut it, "epoch month")?;
842 let day: i64 = next_field(&mut it, "epoch day")?;
843 let hour: i64 = next_field(&mut it, "epoch hour")?;
844 let minute: i64 = next_field(&mut it, "epoch minute")?;
845 let seconds: f64 = next_field(&mut it, "epoch seconds")?;
846
847 let civil = validate::civil_datetime_with_second_policy(
848 year,
849 month,
850 day,
851 hour,
852 minute,
853 seconds,
854 time_system.civil_second_policy(),
855 )
856 .map_err(|error| map_field_error(error, line))?;
857 let split = civil_to_julian_split(civil)?;
858 let epoch_j2000_s = j2000_seconds(
859 civil.year as i32,
860 civil.month as i32,
861 civil.day as i32,
862 civil.hour as i32,
863 civil.minute as i32,
864 civil.second,
865 );
866 let epoch = Instant {
867 scale,
868 repr: InstantRepr::JulianDate(split),
869 };
870 self.epochs.push(epoch);
871 self.epoch_j2000_s.push(epoch_j2000_s);
872 self.states.push(BTreeMap::new());
873 self.interp_raw.push(BTreeMap::new());
874 self.current_epoch = Some(epoch);
875 Ok(())
876 }
877
878 /// Position+clock record: `PG01 x y z clk ...flags`.
879 fn parse_position_line(&mut self, line: &str, line_number: usize) -> Result<()> {
880 if self.current_epoch.is_none() {
881 return Err(Error::Parse(
882 "SP3 position record before any epoch line".into(),
883 ));
884 }
885 if line.len() < 46 {
886 return Err(Error::Parse(format!(
887 "SP3 position record truncated before vector fields in {line:?}"
888 )));
889 }
890 let token = field(line, 1, 4);
891 let Some(sat) = parse_sv_token(token, self.version) else {
892 // A token that does not parse to a representable `GnssSatelliteId`
893 // (e.g. an extended GLONASS slot like R28 beyond the engine's PRN
894 // cap) is an independent, unsupported record. One such record must
895 // not reject the whole file - skip and count it, mirroring nav
896 // `parse_glonass` and `parse_tle_file`.
897 self.push_unrepresentable_satellite_skip(line_number, token);
898 return Ok(());
899 };
900
901 // The header `+` lines are the authoritative satellite declaration; a
902 // position record for an undeclared satellite is malformed. Accepting it
903 // would store a state the writer (which emits only declared satellites)
904 // cannot reproduce, breaking parse/encode/parse round-tripping.
905 if !self.sat_list.contains(&sat) {
906 return Err(Error::Parse(format!(
907 "SP3 position record for satellite {token:?} not in the header satellite list"
908 )));
909 }
910
911 let x_km = parse_coord(line, 4, 18)?;
912 let y_km = parse_coord(line, 18, 32)?;
913 let z_km = parse_coord(line, 32, 46)?;
914
915 // All-zero position is the missing-orbit sentinel: skip the record.
916 if x_km == MISSING_POSITION_KM && y_km == MISSING_POSITION_KM && z_km == MISSING_POSITION_KM
917 {
918 return Ok(());
919 }
920
921 let clock_us = parse_clock_us(line)?;
922 let clock_s = clock_us.map(|us| us * US_TO_S);
923
924 let flags = parse_flags(line);
925
926 let position = ItrfPositionM::new(x_km * KM_TO_M, y_km * KM_TO_M, z_km * KM_TO_M)
927 .map_err(|e| Error::Parse(format!("SP3 invalid position record: {e}")))?;
928 let state = Sp3State {
929 position,
930 clock_s,
931 velocity: None,
932 clock_rate_s_s: None,
933 flags,
934 };
935 let idx = self.states.len() - 1;
936 self.states[idx].insert(sat, state);
937 // Keep the native-unit node for the interpolation path (see RawNode):
938 // the spline must fit the file's own km/us, not the km->m->km round trip.
939 self.interp_raw[idx].insert(
940 sat,
941 RawNode {
942 km: [x_km, y_km, z_km],
943 clock_us,
944 clock_event: flags.clock_event,
945 },
946 );
947 Ok(())
948 }
949
950 /// Velocity record: `VG01 vx vy vz clkrate ...`. Augments the matching
951 /// position record at the current epoch (must follow it).
952 fn parse_velocity_line(&mut self, line: &str, line_number: usize) -> Result<()> {
953 if self.current_epoch.is_none() {
954 return Err(Error::Parse(
955 "SP3 velocity record before any epoch line".into(),
956 ));
957 }
958 if line.len() < 46 {
959 return Err(Error::Parse(format!(
960 "SP3 velocity record truncated before vector fields in {line:?}"
961 )));
962 }
963 let token = field(line, 1, 4);
964 let Some(sat) = parse_sv_token(token, self.version) else {
965 // Unparsable / out-of-range satellite token: skip and count, same
966 // as the position-record path above.
967 self.push_unrepresentable_satellite_skip(line_number, token);
968 return Ok(());
969 };
970
971 // SP3 velocity is in dm/s; read each axis independently (the refs/sp3
972 // crate has a bug here that reuses Y for X - we do not).
973 let vx_dm_s = parse_coord(line, 4, 18)?;
974 let vy_dm_s = parse_coord(line, 18, 32)?;
975 let vz_dm_s = parse_coord(line, 32, 46)?;
976
977 let missing_velocity = vx_dm_s == MISSING_VELOCITY_DM_S
978 && vy_dm_s == MISSING_VELOCITY_DM_S
979 && vz_dm_s == MISSING_VELOCITY_DM_S;
980 let velocity = ItrfVelocityMS::new(
981 vx_dm_s * DM_S_TO_M_S,
982 vy_dm_s * DM_S_TO_M_S,
983 vz_dm_s * DM_S_TO_M_S,
984 )
985 .map_err(|e| Error::Parse(format!("SP3 invalid velocity record: {e}")))?;
986
987 // Clock-rate field shares the clock column; bad-clock sentinel applies.
988 let clock_rate_s_s = parse_clock_us(line)?.map(|rate| rate * CLOCK_RATE_TO_S_PER_S);
989
990 let idx = self.states.len() - 1;
991 match self.states[idx].get_mut(&sat) {
992 Some(state) if !missing_velocity => {
993 state.velocity = Some(velocity);
994 state.clock_rate_s_s = clock_rate_s_s;
995 }
996 Some(_) => {}
997 None => {
998 // A V-record always follows its P-record for the same satellite
999 // at the same epoch (SP3 format invariant). With no preceding
1000 // P-record this satellite has NO valid position at this epoch;
1001 // synthesizing one (e.g. the geocenter (0,0,0)) would fabricate
1002 // an orbit that the all-zero missing-orbit guard exists to
1003 // reject, and would leak through the public state()/states_at().
1004 // Treat it as malformed and skip - consistent with the parser's
1005 // tolerant skipping of other malformed records. No state is
1006 // inserted, so the satellite stays UnknownSatellite at this
1007 // epoch and no (0,0,0) position is ever exposed.
1008 }
1009 }
1010 Ok(())
1011 }
1012
1013 fn push_unrepresentable_satellite_skip(&mut self, line_number: usize, token: &str) {
1014 self.diagnostics.push_skip(Skip {
1015 at: RecordRef::at_line(line_number).with_satellite(token),
1016 reason: SkipReason::UnrepresentableSatellite,
1017 });
1018 }
1019
1020 fn finish(self) -> Result<Sp3> {
1021 if !self.have_line1 {
1022 return Err(Error::Parse("SP3 missing header line 1".into()));
1023 }
1024 if !self.have_line2 {
1025 return Err(Error::Parse("SP3 missing header line 2".into()));
1026 }
1027 let version = self
1028 .version
1029 .ok_or_else(|| Error::Parse("SP3 version not determined".into()))?;
1030 let data_type = self
1031 .data_type
1032 .ok_or_else(|| Error::Parse("SP3 data type not determined".into()))?;
1033 // STRICT: SP3-a is implicitly GPST (set at line 1); SP3-b/c/d must have
1034 // proved their scale from a valid first %c line. Never default here.
1035 let time_system = self.time_system.ok_or_else(|| {
1036 Error::Parse(
1037 "SP3 time system not determined (missing/short/blank %c descriptor)".into(),
1038 )
1039 })?;
1040 let time_scale = time_system.time_scale();
1041
1042 let mut satellite_accuracy_codes = self.sat_accuracy_codes;
1043 satellite_accuracy_codes.truncate(self.sat_list.len());
1044 satellite_accuracy_codes.resize(self.sat_list.len(), 0);
1045 let skipped_records = self.diagnostics.skips.len();
1046
1047 let header = Sp3Header {
1048 version,
1049 data_type,
1050 num_epochs: self.epochs.len() as u64,
1051 coordinate_system: self.coordinate_system,
1052 orbit_type: self.orbit_type,
1053 agency: self.agency,
1054 gnss_week: self.gnss_week,
1055 seconds_of_week: self.seconds_of_week,
1056 epoch_interval_s: self.epoch_interval_s,
1057 mjd: self.mjd,
1058 mjd_fraction: self.mjd_fraction,
1059 time_system,
1060 time_scale,
1061 satellites: self.sat_list,
1062 satellite_accuracy_codes,
1063 };
1064
1065 Ok(Sp3 {
1066 header,
1067 epochs: self.epochs,
1068 epoch_j2000_s: self.epoch_j2000_s,
1069 states: self.states,
1070 interp_raw: self.interp_raw,
1071 comments: self.comments,
1072 skipped_records,
1073 })
1074 }
1075}
1076
1077/// Parse a fixed-column float coordinate, mapping failures to a parse error
1078/// that names the offending text.
1079fn parse_coord(line: &str, start: usize, end: usize) -> Result<f64> {
1080 let raw = field(line, start, end).trim();
1081 strict_f64(raw, "coordinate").map_err(|error| map_field_error(error, line))
1082}
1083
1084/// Parse the clock column (chars 46..60). Returns `None` for the bad-clock
1085/// sentinel `999999.999999` or an absent/blank field; `Some(us)` otherwise.
1086fn parse_clock_us(line: &str) -> Result<Option<f64>> {
1087 if line.len() <= 46 {
1088 return Ok(None);
1089 }
1090 let raw = field(line, 46, 60).trim();
1091 if raw.is_empty() {
1092 return Ok(None);
1093 }
1094 let value = strict_f64(raw, "clock").map_err(|error| map_field_error(error, line))?;
1095 // Sentinel: any value at or beyond the bad-clock magnitude is "no estimate".
1096 if value.abs() >= BAD_CLOCK_US {
1097 return Ok(None);
1098 }
1099 Ok(Some(value))
1100}
1101
1102fn map_field_error(error: validate::FieldError, line: &str) -> Error {
1103 Error::Parse(format!("SP3 {error} in {line:?}"))
1104}
1105
1106/// Parse the four status flags from their fixed columns (SP3-c/-d shared
1107/// layout): clock-event col 74 = `E`, clock-prediction col 75 = `P`,
1108/// maneuver col 78 = `M`, orbit-prediction col 79 = `P`.
1109fn parse_flags(line: &str) -> Sp3Flags {
1110 let at = |col: usize, want: char| -> bool { char_at(line, col) == Some(want) };
1111 Sp3Flags {
1112 clock_event: at(74, 'E'),
1113 clock_predicted: at(75, 'P'),
1114 maneuver: at(78, 'M'),
1115 orbit_predicted: at(79, 'P'),
1116 }
1117}
1118
1119/// Parse a 3-char SV token (e.g. `G01`, `C30`, or a bare ` 1` in SP3-a) into a
1120/// [`GnssSatelliteId`]. Returns `None` on an unrecognized token.
1121fn parse_sv_token(token: &str, version: Option<Sp3Version>) -> Option<GnssSatelliteId> {
1122 let token = token.trim();
1123 if token.is_empty() {
1124 return None;
1125 }
1126 let first = token.chars().next()?;
1127 if first.is_ascii_digit() {
1128 // SP3-a GPS-only: bare numeric PRN, optionally space-padded.
1129 if matches!(version, Some(Sp3Version::A)) || version.is_none() {
1130 let prn = token.parse::<u8>().ok()?;
1131 if !is_valid_prn(GnssSystem::Gps, prn) {
1132 return None;
1133 }
1134 return GnssSatelliteId::new(GnssSystem::Gps, prn).ok();
1135 }
1136 return None;
1137 }
1138 token.parse::<GnssSatelliteId>().ok()
1139}
1140
1141/// Pull and parse the next whitespace-delimited field from an iterator.
1142fn next_field<T: std::str::FromStr>(
1143 it: &mut std::str::SplitWhitespace<'_>,
1144 what: &str,
1145) -> Result<T> {
1146 let tok = it
1147 .next()
1148 .ok_or_else(|| Error::Parse(format!("SP3 missing {what}")))?;
1149 tok.parse::<T>()
1150 .map_err(|_| Error::Parse(format!("SP3 {what} {tok:?} unparsable")))
1151}
1152
1153mod combine;
1154mod interp;
1155mod interpolant;
1156mod interpolant_store;
1157mod samples;
1158mod verify;
1159mod write;
1160
1161pub use combine::{
1162 align_clock_reference, clock_reference_offset, merge, AgreementMetric, ClockReferenceOffset,
1163 EpochAgreement, MergeCombine, MergeFlag, MergeOptions, MergePrecedenceScope, MergeReport,
1164 OutlierRejectOptions, Sp3FrameLabelSet, Sp3FrameReconciliation, Sp3FrameReconciliationMethod,
1165 Sp3FrameReconciliationOptions,
1166};
1167pub use interpolant::{PreciseEphemerisInterpolant, PreciseInterpolantError};
1168pub use interpolant_store::{
1169 precise_interpolant_store_checksum64, MmapPreciseEphemerisInterpolant,
1170 PreciseInterpolantStoreError,
1171};
1172pub use samples::{
1173 sp3_ecef_state_to_eci, PreciseEphemerisSample, PreciseEphemerisSamples,
1174 PreciseEphemerisStateSample, PreciseSamplesError,
1175};
1176pub use verify::{
1177 compare_position_series, InterpolationComparison, InterpolationDivergence, ReferenceState,
1178};
1179
1180#[cfg(all(test, sidereon_repo_tests))]
1181mod tests;