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::model::{Instant, InstantRepr, JulianDateSplit, TimeScale};
51
52use crate::constants::{KM_TO_M, SECONDS_PER_DAY, US_TO_S};
53use crate::frame::{ItrfPositionM, ItrfVelocityMS};
54use crate::id::{is_valid_prn, GnssSatelliteId, GnssSystem};
55use crate::parse::{raw_field as field, raw_field_from as field_from};
56use crate::validate;
57use crate::{Error, Result};
58
59/// SP3 missing/bad position component sentinel, in kilometers.
60///
61/// SP3 writes a satellite with no usable orbit as a position record of exactly
62/// `0.000000 0.000000 0.000000`. We treat an all-zero position as "missing"
63/// (matching the `refs/sp3` validity guard at `parsing.rs:186`): a satellite is
64/// never legitimately at the geocenter.
65const MISSING_POSITION_KM: f64 = 0.0;
66/// SP3 missing velocity component sentinel, in decimeters per second.
67///
68/// Velocity products still carry a `V` record for each `P` record. When no
69/// velocity estimate exists, the record uses the all-zero vector sentinel rather
70/// than being omitted; do not surface that as a fabricated stationary satellite.
71const MISSING_VELOCITY_DM_S: f64 = 0.0;
72
73/// SP3 bad-clock sentinel, in microseconds: `999999.999999`.
74///
75/// A clock value at or above this magnitude means "no clock estimate"; it is
76/// surfaced as `clock_s = None`, not converted.
77const BAD_CLOCK_US: f64 = 999_999.999_999;
78
79/// SP3 velocity records are in decimeters per second; dm/s -> m/s is `* 0.1`.
80const DM_S_TO_M_S: f64 = 1.0e-1;
81/// SP3 clock-rate is in 1e-4 microseconds/second; -> s/s is `* 1e-10`.
82const CLOCK_RATE_TO_S_PER_S: f64 = 1.0e-10;
83
84/// SP3 format version.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum Sp3Version {
87 /// SP3-a (legacy, GPS-only).
88 A,
89 /// SP3-b.
90 B,
91 /// SP3-c.
92 C,
93 /// SP3-d (multi-GNSS, Hilla 2016).
94 D,
95}
96
97impl Sp3Version {
98 fn from_char(c: char) -> Result<Self> {
99 match c {
100 'a' | 'A' => Ok(Sp3Version::A),
101 'b' | 'B' => Ok(Sp3Version::B),
102 'c' | 'C' => Ok(Sp3Version::C),
103 'd' | 'D' => Ok(Sp3Version::D),
104 other => Err(Error::Parse(format!("unknown SP3 version '{other}'"))),
105 }
106 }
107}
108
109/// What kind of records the file carries.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum Sp3DataType {
112 /// Position + clock records only (`#?P...`).
113 Position,
114 /// Position + velocity (+ clock + clock-rate) records (`#?V...`).
115 Velocity,
116}
117
118impl Sp3DataType {
119 fn from_char(c: char) -> Result<Self> {
120 match c {
121 'P' => Ok(Sp3DataType::Position),
122 'V' => Ok(Sp3DataType::Velocity),
123 other => Err(Error::Parse(format!("unknown SP3 data type '{other}'"))),
124 }
125 }
126}
127
128/// SP3 time-system labels from the `%c` descriptor.
129///
130/// The core [`TimeScale`] model does not distinguish every SP3 label as its own
131/// global scale. Keep the exact SP3 label here so products using GLONASS, QZSS,
132/// or IRNSS time are accepted and can be serialized without being silently
133/// relabeled.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
135pub enum Sp3TimeSystem {
136 /// GPS time (`GPS`).
137 Gps,
138 /// GLONASS UTC time system (`GLO`).
139 Glonass,
140 /// Galileo system time (`GAL`).
141 Galileo,
142 /// International Atomic Time (`TAI`).
143 Tai,
144 /// Coordinated Universal Time (`UTC`).
145 Utc,
146 /// QZSS time (`QZS`).
147 Qzss,
148 /// BeiDou time (`BDT`).
149 Beidou,
150 /// IRNSS / NavIC time (`IRN`).
151 Irnss,
152}
153
154impl Sp3TimeSystem {
155 /// Canonical three-character SP3 label.
156 pub fn label(self) -> &'static str {
157 match self {
158 Sp3TimeSystem::Gps => "GPS",
159 Sp3TimeSystem::Glonass => "GLO",
160 Sp3TimeSystem::Galileo => "GAL",
161 Sp3TimeSystem::Tai => "TAI",
162 Sp3TimeSystem::Utc => "UTC",
163 Sp3TimeSystem::Qzss => "QZS",
164 Sp3TimeSystem::Beidou => "BDT",
165 Sp3TimeSystem::Irnss => "IRN",
166 }
167 }
168
169 /// Core time scale used to tag parsed [`Instant`] values.
170 ///
171 /// For labels the core model has exactly, this is the direct equivalent. For
172 /// SP3-only labels, the exact product label remains available through
173 /// [`Sp3Header::time_system`], and this value preserves the existing
174 /// interpolation-axis API until the global time model grows those scales.
175 pub fn time_scale(self) -> TimeScale {
176 match self {
177 Sp3TimeSystem::Gps | Sp3TimeSystem::Qzss | Sp3TimeSystem::Irnss => TimeScale::Gpst,
178 Sp3TimeSystem::Glonass | Sp3TimeSystem::Utc => TimeScale::Utc,
179 Sp3TimeSystem::Galileo => TimeScale::Gst,
180 Sp3TimeSystem::Tai => TimeScale::Tai,
181 Sp3TimeSystem::Beidou => TimeScale::Bdt,
182 }
183 }
184
185 fn civil_second_policy(self) -> validate::CivilSecondPolicy {
186 match self {
187 Sp3TimeSystem::Glonass | Sp3TimeSystem::Utc => validate::CivilSecondPolicy::UtcLike,
188 Sp3TimeSystem::Gps
189 | Sp3TimeSystem::Galileo
190 | Sp3TimeSystem::Tai
191 | Sp3TimeSystem::Qzss
192 | Sp3TimeSystem::Beidou
193 | Sp3TimeSystem::Irnss => validate::CivilSecondPolicy::Continuous,
194 }
195 }
196}
197
198/// Per-record quality / status flags (SP3-c columns 75-80, SP3-d same layout).
199///
200/// All four flags are independent and any combination may appear (e.g. a
201/// predicted orbit during a maneuver). They are surfaced verbatim from the
202/// record and never alter the parsed numbers.
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
204pub struct Sp3Flags {
205 /// `E` in the clock-event column: a clock discontinuity occurred near this
206 /// epoch; clock interpolation across it is unsafe.
207 pub clock_event: bool,
208 /// `P` in the clock-prediction column: the clock is predicted, not fitted.
209 pub clock_predicted: bool,
210 /// `M` in the maneuver column: the satellite was being maneuvered; the
211 /// state is not suitable for precise navigation.
212 pub maneuver: bool,
213 /// `P` in the orbit-prediction column: the orbit is predicted, not fitted.
214 pub orbit_predicted: bool,
215}
216
217/// A single satellite state at one SP3 epoch.
218///
219/// This is the spec's `Sp3State { position: ItrfPositionM, clock_s, velocity?,
220/// clock_rate?, flags }`. The frame/units are encoded in the
221/// member types; missing optional values are `None` rather than sentinels.
222#[derive(Debug, Clone, Copy, PartialEq)]
223pub struct Sp3State {
224 /// Satellite position in the ITRF/IGS ECEF frame, meters.
225 pub position: ItrfPositionM,
226 /// Satellite clock offset in **seconds** (`None` if the bad-clock sentinel
227 /// `999999.999999` us was recorded).
228 pub clock_s: Option<f64>,
229 /// Satellite velocity in the ITRF/IGS ECEF frame, m/s (present only for
230 /// velocity products).
231 pub velocity: Option<ItrfVelocityMS>,
232 /// Satellite clock rate in **seconds per second** (present only for
233 /// velocity products that carry a clock-rate field).
234 pub clock_rate_s_s: Option<f64>,
235 /// Per-record status flags.
236 pub flags: Sp3Flags,
237}
238
239/// Parsed SP3 header.
240#[derive(Debug, Clone, PartialEq)]
241pub struct Sp3Header {
242 /// SP3 format version (`a`/`b`/`c`/`d`).
243 pub version: Sp3Version,
244 /// Whether the file carries velocity records.
245 pub data_type: Sp3DataType,
246 /// Number of parsed epochs in the canonical product.
247 pub num_epochs: u64,
248 /// Coordinate-system / IGS-realization label (e.g. `IGS14`, `ITRF2`).
249 pub coordinate_system: String,
250 /// Orbit-type label (e.g. `FIT`, `BHN`).
251 pub orbit_type: String,
252 /// Producing agency.
253 pub agency: String,
254 /// GNSS week number (in the file's time system).
255 pub gnss_week: u32,
256 /// Seconds of week of the first epoch.
257 pub seconds_of_week: f64,
258 /// Nominal epoch spacing in seconds.
259 pub epoch_interval_s: f64,
260 /// Modified Julian Day of the first epoch (integer part).
261 pub mjd: u32,
262 /// Fractional day of the first epoch.
263 pub mjd_fraction: f64,
264 /// Time system label the epochs are expressed in. For SP3-b/c/d this is read
265 /// strictly from the first `%c` descriptor (a missing/short/blank descriptor
266 /// is a parse error, never a silent GPST default); SP3-a is implicitly GPST.
267 pub time_system: Sp3TimeSystem,
268 /// Core [`TimeScale`] used to tag parsed [`Instant`] values. See
269 /// [`Sp3Header::time_system`] for the exact SP3 label when the product uses
270 /// a standard SP3 time system that is not modeled as a distinct core scale.
271 pub time_scale: TimeScale,
272 /// The satellite list declared in the `+` header lines.
273 pub satellites: Vec<GnssSatelliteId>,
274 /// Per-satellite accuracy exponent codes from the `++` header lines,
275 /// index-aligned with [`Sp3Header::satellites`].
276 pub satellite_accuracy_codes: Vec<u16>,
277}
278
279/// A parsed SP3 precise-ephemeris product.
280///
281/// Construct with [`Sp3::parse`]. Epochs are stored in ascending order; each
282/// epoch maps satellite -> [`Sp3State`]. Per-satellite/per-epoch access is via
283/// [`Sp3::state`]; arbitrary-epoch interpolation is built separately to match
284/// the parity reference and is not part of this parser.
285#[derive(Debug, Clone, PartialEq)]
286pub struct Sp3 {
287 /// The parsed header.
288 pub header: Sp3Header,
289 /// Epochs in ascending time order, tagged with the header time scale.
290 pub epochs: Vec<Instant>,
291 /// `epoch_index -> (satellite -> state)`. Parallel to [`Sp3::epochs`].
292 states: Vec<BTreeMap<GnssSatelliteId, Sp3State>>,
293 /// `epoch_index -> (satellite -> native-unit node)`. Parallel to
294 /// [`Sp3::epochs`]; populated **only** from genuine position records. The
295 /// interpolator fits its spline over these (km/us straight from the ASCII,
296 /// exactly as the `scipy`/`gnssanalysis` reference does); reconstructing km
297 /// from the public meters (`km->m->km`) drifts up to 1 ULP and breaks the
298 /// 0-ULP parity. See `sp3/interp.rs`.
299 interp_raw: Vec<BTreeMap<GnssSatelliteId, RawNode>>,
300 /// Free-form `/*` comment lines (notice retained for provenance).
301 pub comments: Vec<String>,
302}
303
304/// Native-unit interpolation node: the file's own km / microseconds, kept
305/// verbatim from the ASCII so the spline fit is bit-identical to the reference.
306/// Private - the public surface is meters/seconds via [`Sp3State`].
307#[derive(Debug, Clone, Copy, PartialEq)]
308struct RawNode {
309 /// ECEF position in native SP3 kilometers (X/Y/Z), exact ASCII->f64.
310 km: [f64; 3],
311 /// Clock offset in native SP3 microseconds (`None` for the bad-clock
312 /// sentinel), exact ASCII->f64.
313 clock_us: Option<f64>,
314 /// Whether this epoch carried the clock-event (`E`) flag (clock-arc split).
315 clock_event: bool,
316}
317
318impl Sp3 {
319 /// Parse an SP3-c or SP3-d byte buffer into a typed product.
320 ///
321 /// `bytes` is the full file content (already decompressed; this crate does
322 /// not do gzip - that is a caller-layer I/O concern). Returns
323 /// [`Error::Parse`] with a human-readable reason on malformed input.
324 pub fn parse(bytes: &[u8]) -> Result<Self> {
325 let text = std::str::from_utf8(bytes)
326 .map_err(|e| Error::Parse(format!("SP3 is not valid UTF-8: {e}")))?;
327 Self::parse_str(text)
328 }
329
330 /// Parse from a `&str` (the UTF-8 fast path used by [`Sp3::parse`]).
331 pub fn parse_str(text: &str) -> Result<Self> {
332 if !text.is_ascii() {
333 return Err(Error::Parse("SP3 product text must be ASCII".into()));
334 }
335 let mut parser = Parser::new();
336 for raw in text.lines() {
337 parser.feed(raw)?;
338 }
339 parser.finish()
340 }
341
342 /// The satellites present in this product (from the header satellite list).
343 pub fn satellites(&self) -> &[GnssSatelliteId] {
344 &self.header.satellites
345 }
346
347 /// Number of parsed epochs.
348 pub fn epoch_count(&self) -> usize {
349 self.epochs.len()
350 }
351
352 /// The state of `sat` at the parsed epoch with index `epoch_index`.
353 ///
354 /// Returns [`Error::EpochOutOfRange`] if the index is past the end, or
355 /// [`Error::UnknownSatellite`] if the satellite has no record at that epoch.
356 pub fn state(&self, sat: GnssSatelliteId, epoch_index: usize) -> Result<Sp3State> {
357 let per_epoch = self.states.get(epoch_index).ok_or(Error::EpochOutOfRange)?;
358 per_epoch
359 .get(&sat)
360 .copied()
361 .ok_or(Error::UnknownSatellite(sat))
362 }
363
364 /// All `(satellite, state)` pairs recorded at `epoch_index`, in ascending
365 /// satellite order.
366 pub fn states_at(&self, epoch_index: usize) -> Result<&BTreeMap<GnssSatelliteId, Sp3State>> {
367 self.states.get(epoch_index).ok_or(Error::EpochOutOfRange)
368 }
369}
370
371impl core::str::FromStr for Sp3 {
372 type Err = Error;
373
374 fn from_str(s: &str) -> Result<Self> {
375 Self::parse_str(s)
376 }
377}
378
379#[cfg(test)]
380impl Sp3 {}
381
382/// Parse an SP3 time-system label.
383///
384/// SP3-c/-d encode the time system in the `%c` descriptor line (chars 9-12).
385/// SP3-a is implicitly GPST. Unknown labels error rather than silently
386/// defaulting, so a parity pipeline never mis-attributes an epoch's scale.
387fn time_system_from_label(label: &str) -> Result<Sp3TimeSystem> {
388 match label.trim() {
389 "GPS" => Ok(Sp3TimeSystem::Gps),
390 "GLO" => Ok(Sp3TimeSystem::Glonass),
391 "GAL" => Ok(Sp3TimeSystem::Galileo),
392 "TAI" => Ok(Sp3TimeSystem::Tai),
393 "UTC" => Ok(Sp3TimeSystem::Utc),
394 "QZS" => Ok(Sp3TimeSystem::Qzss),
395 "BDT" | "BDS" => Ok(Sp3TimeSystem::Beidou),
396 "IRN" => Ok(Sp3TimeSystem::Irnss),
397 trimmed => Err(Error::Parse(format!(
398 "unsupported SP3 time system '{trimmed}'"
399 ))),
400 }
401}
402
403/// Compute the integer-day / fraction split Julian date from a Gregorian UTC-ish
404/// civil epoch, with the day fraction carried separately (Skyfield split
405/// convention, matching [`JulianDateSplit`]).
406///
407/// SP3 epoch lines are civil dates in the file's *own* time system; we keep
408/// them in that scale (no leap-second shifting here - that is a conversion
409/// concern handled by the core `scales` machinery, not the parser). The
410/// algorithm is the standard Fliegel-Van Flandern Gregorian-to-JDN, then the
411/// time-of-day fraction. JDN is computed in integer arithmetic so the whole-day
412/// boundary is exact; only the sub-day fraction is floating point.
413fn civil_to_julian_split(civil: validate::ValidCivil) -> Result<JulianDateSplit> {
414 let month = i64::from(civil.month);
415 let day = i64::from(civil.day);
416 // Fliegel-Van Flandern (integer) Julian Day Number for the calendar day.
417 let a = (14 - month) / 12;
418 let y = civil.year + 4800 - a;
419 let m = month + 12 * a - 3;
420 let jdn = day + (153 * m + 2) / 5 + 365 * y + y / 4 - y / 100 + y / 400 - 32_045;
421 // JDN as defined starts at noon; SP3 epochs are civil days, so the JD whole
422 // boundary we carry is (jdn - 0.5) i.e. midnight of that civil day, with the
423 // time-of-day added as the fraction. Store the *.5 boundary as jd_whole.
424 let mut jd_whole = jdn as f64 - 0.5;
425 let day_seconds = civil.hour as f64 * 3600.0 + civil.minute as f64 * 60.0 + civil.second;
426 let mut fraction = day_seconds / SECONDS_PER_DAY;
427 if fraction > 1.0 {
428 let carry = fraction.floor();
429 jd_whole += carry;
430 fraction -= carry;
431 }
432 JulianDateSplit::new(jd_whole, fraction)
433 .map_err(|error| Error::Parse(format!("invalid SP3 epoch Julian date: {error}")))
434}
435
436/// Incremental line-driven SP3 parser state machine.
437struct Parser {
438 version: Option<Sp3Version>,
439 data_type: Option<Sp3DataType>,
440 num_epochs: u64,
441 coordinate_system: String,
442 orbit_type: String,
443 agency: String,
444 gnss_week: u32,
445 seconds_of_week: f64,
446 epoch_interval_s: f64,
447 mjd: u32,
448 mjd_fraction: f64,
449 time_system: Option<Sp3TimeSystem>,
450 /// `+`-line declared satellites, in file order.
451 sat_list: Vec<GnssSatelliteId>,
452 /// `++`-line per-satellite accuracy codes, in satellite-list order.
453 sat_accuracy_codes: Vec<u16>,
454 /// `%c` descriptor lines seen so far (the first carries the time system).
455 pc_count: u32,
456 /// Header line 1 parsed?
457 have_line1: bool,
458 /// Header line 2 parsed?
459 have_line2: bool,
460 /// Epoch currently being filled.
461 current_epoch: Option<Instant>,
462 epochs: Vec<Instant>,
463 states: Vec<BTreeMap<GnssSatelliteId, Sp3State>>,
464 interp_raw: Vec<BTreeMap<GnssSatelliteId, RawNode>>,
465 comments: Vec<String>,
466 done: bool,
467}
468
469impl Parser {
470 fn new() -> Self {
471 Self {
472 version: None,
473 data_type: None,
474 num_epochs: 0,
475 coordinate_system: String::new(),
476 orbit_type: String::new(),
477 agency: String::new(),
478 gnss_week: 0,
479 seconds_of_week: 0.0,
480 epoch_interval_s: 0.0,
481 mjd: 0,
482 mjd_fraction: 0.0,
483 time_system: None,
484 sat_list: Vec::new(),
485 sat_accuracy_codes: Vec::new(),
486 pc_count: 0,
487 have_line1: false,
488 have_line2: false,
489 current_epoch: None,
490 epochs: Vec::new(),
491 states: Vec::new(),
492 interp_raw: Vec::new(),
493 comments: Vec::new(),
494 done: false,
495 }
496 }
497
498 fn feed(&mut self, raw: &str) -> Result<()> {
499 if self.done {
500 return Ok(());
501 }
502 // SP3 is fixed-column ASCII; trim only the trailing CR / newline noise,
503 // never leading spaces (columns are significant).
504 let line = raw.trim_end_matches(['\r', '\n']);
505
506 if line == "EOF" {
507 self.done = true;
508 return Ok(());
509 }
510 if line.starts_with("/*") {
511 // Comment line; columns 4.. are the text.
512 if line.len() > 3 {
513 self.comments.push(line[3..].trim_end().to_string());
514 } else {
515 self.comments.push(String::new());
516 }
517 return Ok(());
518 }
519 // Header line 2 (`##`) must be tested before line 1 (`#`).
520 if line.starts_with("##") {
521 self.parse_line2(line)?;
522 return Ok(());
523 }
524 if line.starts_with('#') {
525 self.parse_line1(line)?;
526 return Ok(());
527 }
528 if line.starts_with('+') {
529 self.parse_plus_line(line)?;
530 return Ok(());
531 }
532 if line.starts_with("%c") {
533 self.parse_pc_line(line)?;
534 return Ok(());
535 }
536 if line.starts_with("%f") || line.starts_with("%i") {
537 // Float/int accuracy descriptor lines - not needed for the typed
538 // state; skipped deterministically.
539 return Ok(());
540 }
541 if line.starts_with('*') {
542 self.parse_epoch_line(line)?;
543 return Ok(());
544 }
545 if line.starts_with('P') {
546 self.parse_position_line(line)?;
547 return Ok(());
548 }
549 if line.starts_with('V') {
550 self.parse_velocity_line(line)?;
551 return Ok(());
552 }
553 // Unknown / ignorable line (e.g. `%/`); skip without failing - SP3 has
554 // optional descriptor lines a parser must tolerate.
555 Ok(())
556 }
557
558 /// Header line 1: `#cP2020 ...` / `#dV...`.
559 fn parse_line1(&mut self, line: &str) -> Result<()> {
560 // Minimum well-formed line-1 length per the standard.
561 if line.len() < 55 {
562 return Err(Error::Parse(format!(
563 "SP3 header line 1 too short: {line:?}"
564 )));
565 }
566 let chars: Vec<char> = line.chars().collect();
567 let version = Sp3Version::from_char(chars[1])?;
568 self.version = Some(version);
569 self.data_type = Some(Sp3DataType::from_char(chars[2])?);
570 // SP3-a predates the %c time-system descriptor and is implicitly GPST.
571 // Set it here so a (correct) SP3-a file with no %c line still resolves,
572 // while SP3-b/c/d are left as None until a valid %c line proves the
573 // scale (a missing %c then becomes a hard error, not a GPST default).
574 if matches!(version, Sp3Version::A) {
575 self.time_system = Some(Sp3TimeSystem::Gps);
576 }
577
578 // Column layout per the SP3 standard, matching the (round-trip-tested)
579 // refs/sp3 line-1 reader: num_epochs 32..40, observables 40..45,
580 // coord_system 45..51, orbit_type 51..55, agency 55...
581 self.num_epochs = field(line, 32, 40)
582 .trim()
583 .parse::<u64>()
584 .map_err(|_| Error::Parse(format!("SP3 num_epochs unparsable in {line:?}")))?;
585 self.coordinate_system = field(line, 45, 51).trim().to_string();
586 self.orbit_type = field(line, 51, 55).trim().to_string();
587 self.agency = field_from(line, 55).trim().to_string();
588 self.have_line1 = true;
589 Ok(())
590 }
591
592 /// Header line 2: `## 2276 21600.00000000 900.00000000 60176 0.25...`.
593 fn parse_line2(&mut self, line: &str) -> Result<()> {
594 self.gnss_week = field(line, 3, 7)
595 .trim()
596 .parse::<u32>()
597 .map_err(|_| Error::Parse(format!("SP3 GNSS week unparsable in {line:?}")))?;
598 self.seconds_of_week = field(line, 8, 23)
599 .trim()
600 .parse::<f64>()
601 .map_err(|_| Error::Parse(format!("SP3 seconds-of-week unparsable in {line:?}")))?;
602 self.epoch_interval_s = field(line, 24, 38)
603 .trim()
604 .parse::<f64>()
605 .map_err(|_| Error::Parse(format!("SP3 epoch interval unparsable in {line:?}")))?;
606 self.mjd = field(line, 39, 44)
607 .trim()
608 .parse::<u32>()
609 .map_err(|_| Error::Parse(format!("SP3 MJD unparsable in {line:?}")))?;
610 self.mjd_fraction = validate::strict_f64(field_from(line, 45), "mjd_fraction")
611 .map_err(|error| map_field_error(error, line))?;
612 self.have_line2 = true;
613 Ok(())
614 }
615
616 /// `+` satellite-list line: `+ 32 G01G02...` (3-char SV tokens from
617 /// column 9 in groups of 17). Continuation `+` lines append more tokens.
618 fn parse_plus_line(&mut self, line: &str) -> Result<()> {
619 if line.starts_with("++") {
620 return self.parse_accuracy_line(line);
621 }
622 // SV tokens start at column 9 (0-based), each 3 chars, up to 17 per line.
623 let chars: Vec<char> = line.chars().collect();
624 let mut col = 9;
625 while col + 3 <= chars.len() {
626 let token: String = chars[col..col + 3].iter().collect();
627 let trimmed = token.trim();
628 // ` 0` / blank padding marks the end of the real entries.
629 if trimmed.is_empty() || trimmed == "0" {
630 col += 3;
631 continue;
632 }
633 if let Some(id) = parse_sv_token(&token, self.version) {
634 if !self.sat_list.contains(&id) {
635 self.sat_list.push(id);
636 }
637 }
638 col += 3;
639 }
640 Ok(())
641 }
642
643 /// `++` per-satellite accuracy-code line: 3-char integer fields from column
644 /// 9, aligned with the `+` satellite slots.
645 fn parse_accuracy_line(&mut self, line: &str) -> Result<()> {
646 let chars: Vec<char> = line.chars().collect();
647 let mut col = 9;
648 while col + 3 <= chars.len() && self.sat_accuracy_codes.len() < self.sat_list.len() {
649 let token: String = chars[col..col + 3].iter().collect();
650 let trimmed = token.trim();
651 let code = if trimmed.is_empty() {
652 0
653 } else {
654 validate::strict_int::<u16>(trimmed, "satellite_accuracy_code")
655 .map_err(|error| map_field_error(error, line))?
656 };
657 self.sat_accuracy_codes.push(code);
658 col += 3;
659 }
660 Ok(())
661 }
662
663 /// `%c` descriptor: the first one (chars 9-12) carries the time system.
664 fn parse_pc_line(&mut self, line: &str) -> Result<()> {
665 if self.pc_count == 0 {
666 // SP3-a is implicitly GPST regardless of descriptor content.
667 if matches!(self.version, Some(Sp3Version::A)) {
668 self.time_system = Some(Sp3TimeSystem::Gps);
669 } else if line.len() >= 12 {
670 let label = field(line, 9, 12);
671 let trimmed = label.trim();
672 // STRICT: a blank time-system field on the first %c is not GPST,
673 // it is malformed. Reject rather than silently defaulting so a
674 // precise pipeline never mis-attributes an epoch's scale.
675 if trimmed.is_empty() {
676 return Err(Error::Parse(format!(
677 "SP3 %c time system is blank in {line:?}"
678 )));
679 }
680 self.time_system = Some(time_system_from_label(label)?);
681 } else {
682 // STRICT: a short %c line for SP3-b/c/d carries no time system
683 // we can trust. Reject rather than defaulting to GPST.
684 return Err(Error::Parse(format!(
685 "SP3 %c descriptor too short to carry a time system: {line:?}"
686 )));
687 }
688 }
689 self.pc_count += 1;
690 Ok(())
691 }
692
693 /// Epoch line: `* 2020 6 24 0 0 0.00000000`.
694 fn parse_epoch_line(&mut self, line: &str) -> Result<()> {
695 // STRICT: by the time we reach data, the time system must be known -
696 // implicitly GPST for SP3-a (set at line 1), or from a valid first %c
697 // line for SP3-b/c/d. A missing/blank/short %c is an error, never GPST.
698 let time_system = self.time_system.ok_or_else(|| {
699 Error::Parse("SP3 epoch encountered with no time system (missing %c descriptor)".into())
700 })?;
701 let scale = time_system.time_scale();
702 // Fields after the leading `* ` (3 chars), then space-delimited.
703 let body = &line[1..];
704 let mut it = body.split_whitespace();
705 let year: i64 = next_field(&mut it, "epoch year")?;
706 let month: i64 = next_field(&mut it, "epoch month")?;
707 let day: i64 = next_field(&mut it, "epoch day")?;
708 let hour: i64 = next_field(&mut it, "epoch hour")?;
709 let minute: i64 = next_field(&mut it, "epoch minute")?;
710 let seconds: f64 = next_field(&mut it, "epoch seconds")?;
711
712 let civil = validate::civil_datetime_with_second_policy(
713 year,
714 month,
715 day,
716 hour,
717 minute,
718 seconds,
719 time_system.civil_second_policy(),
720 )
721 .map_err(|error| map_field_error(error, line))?;
722 let split = civil_to_julian_split(civil)?;
723 let epoch = Instant {
724 scale,
725 repr: InstantRepr::JulianDate(split),
726 };
727 self.epochs.push(epoch);
728 self.states.push(BTreeMap::new());
729 self.interp_raw.push(BTreeMap::new());
730 self.current_epoch = Some(epoch);
731 Ok(())
732 }
733
734 /// Position+clock record: `PG01 x y z clk ...flags`.
735 fn parse_position_line(&mut self, line: &str) -> Result<()> {
736 if self.current_epoch.is_none() {
737 return Err(Error::Parse(
738 "SP3 position record before any epoch line".into(),
739 ));
740 }
741 if line.len() < 46 {
742 return Err(Error::Parse(format!(
743 "SP3 position record truncated before vector fields in {line:?}"
744 )));
745 }
746 let token = field(line, 1, 4);
747 let sat = parse_sv_token(token, self.version)
748 .ok_or_else(|| Error::Parse(format!("SP3 unparsable satellite token {token:?}")))?;
749
750 // The header `+` lines are the authoritative satellite declaration; a
751 // position record for an undeclared satellite is malformed. Accepting it
752 // would store a state the writer (which emits only declared satellites)
753 // cannot reproduce, breaking parse/encode/parse round-tripping.
754 if !self.sat_list.contains(&sat) {
755 return Err(Error::Parse(format!(
756 "SP3 position record for satellite {token:?} not in the header satellite list"
757 )));
758 }
759
760 let x_km = parse_coord(line, 4, 18)?;
761 let y_km = parse_coord(line, 18, 32)?;
762 let z_km = parse_coord(line, 32, 46)?;
763
764 // All-zero position is the missing-orbit sentinel: skip the record.
765 if x_km == MISSING_POSITION_KM && y_km == MISSING_POSITION_KM && z_km == MISSING_POSITION_KM
766 {
767 return Ok(());
768 }
769
770 let clock_us = parse_clock_us(line)?;
771 let clock_s = clock_us.map(|us| us * US_TO_S);
772
773 let flags = parse_flags(line);
774
775 let position = ItrfPositionM::new(x_km * KM_TO_M, y_km * KM_TO_M, z_km * KM_TO_M)
776 .map_err(|e| Error::Parse(format!("SP3 invalid position record: {e}")))?;
777 let state = Sp3State {
778 position,
779 clock_s,
780 velocity: None,
781 clock_rate_s_s: None,
782 flags,
783 };
784 let idx = self.states.len() - 1;
785 self.states[idx].insert(sat, state);
786 // Keep the native-unit node for the interpolation path (see RawNode):
787 // the spline must fit the file's own km/us, not the km->m->km round trip.
788 self.interp_raw[idx].insert(
789 sat,
790 RawNode {
791 km: [x_km, y_km, z_km],
792 clock_us,
793 clock_event: flags.clock_event,
794 },
795 );
796 Ok(())
797 }
798
799 /// Velocity record: `VG01 vx vy vz clkrate ...`. Augments the matching
800 /// position record at the current epoch (must follow it).
801 fn parse_velocity_line(&mut self, line: &str) -> Result<()> {
802 if self.current_epoch.is_none() {
803 return Err(Error::Parse(
804 "SP3 velocity record before any epoch line".into(),
805 ));
806 }
807 if line.len() < 46 {
808 return Err(Error::Parse(format!(
809 "SP3 velocity record truncated before vector fields in {line:?}"
810 )));
811 }
812 let token = field(line, 1, 4);
813 let sat = parse_sv_token(token, self.version)
814 .ok_or_else(|| Error::Parse(format!("SP3 unparsable satellite token {token:?}")))?;
815
816 // SP3 velocity is in dm/s; read each axis independently (the refs/sp3
817 // crate has a bug here that reuses Y for X - we do not).
818 let vx_dm_s = parse_coord(line, 4, 18)?;
819 let vy_dm_s = parse_coord(line, 18, 32)?;
820 let vz_dm_s = parse_coord(line, 32, 46)?;
821
822 let missing_velocity = vx_dm_s == MISSING_VELOCITY_DM_S
823 && vy_dm_s == MISSING_VELOCITY_DM_S
824 && vz_dm_s == MISSING_VELOCITY_DM_S;
825 let velocity = ItrfVelocityMS::new(
826 vx_dm_s * DM_S_TO_M_S,
827 vy_dm_s * DM_S_TO_M_S,
828 vz_dm_s * DM_S_TO_M_S,
829 )
830 .map_err(|e| Error::Parse(format!("SP3 invalid velocity record: {e}")))?;
831
832 // Clock-rate field shares the clock column; bad-clock sentinel applies.
833 let clock_rate_s_s = parse_clock_us(line)?.map(|rate| rate * CLOCK_RATE_TO_S_PER_S);
834
835 let idx = self.states.len() - 1;
836 match self.states[idx].get_mut(&sat) {
837 Some(state) if !missing_velocity => {
838 state.velocity = Some(velocity);
839 state.clock_rate_s_s = clock_rate_s_s;
840 }
841 Some(_) => {}
842 None => {
843 // A V-record always follows its P-record for the same satellite
844 // at the same epoch (SP3 format invariant). With no preceding
845 // P-record this satellite has NO valid position at this epoch;
846 // synthesizing one (e.g. the geocenter (0,0,0)) would fabricate
847 // an orbit that the all-zero missing-orbit guard exists to
848 // reject, and would leak through the public state()/states_at().
849 // Treat it as malformed and skip - consistent with the parser's
850 // tolerant skipping of other malformed records. No state is
851 // inserted, so the satellite stays UnknownSatellite at this
852 // epoch and no (0,0,0) position is ever exposed.
853 }
854 }
855 Ok(())
856 }
857
858 fn finish(self) -> Result<Sp3> {
859 if !self.have_line1 {
860 return Err(Error::Parse("SP3 missing header line 1".into()));
861 }
862 if !self.have_line2 {
863 return Err(Error::Parse("SP3 missing header line 2".into()));
864 }
865 let version = self
866 .version
867 .ok_or_else(|| Error::Parse("SP3 version not determined".into()))?;
868 let data_type = self
869 .data_type
870 .ok_or_else(|| Error::Parse("SP3 data type not determined".into()))?;
871 // STRICT: SP3-a is implicitly GPST (set at line 1); SP3-b/c/d must have
872 // proved their scale from a valid first %c line. Never default here.
873 let time_system = self.time_system.ok_or_else(|| {
874 Error::Parse(
875 "SP3 time system not determined (missing/short/blank %c descriptor)".into(),
876 )
877 })?;
878 let time_scale = time_system.time_scale();
879
880 let mut satellite_accuracy_codes = self.sat_accuracy_codes;
881 satellite_accuracy_codes.truncate(self.sat_list.len());
882 satellite_accuracy_codes.resize(self.sat_list.len(), 0);
883
884 let header = Sp3Header {
885 version,
886 data_type,
887 num_epochs: self.epochs.len() as u64,
888 coordinate_system: self.coordinate_system,
889 orbit_type: self.orbit_type,
890 agency: self.agency,
891 gnss_week: self.gnss_week,
892 seconds_of_week: self.seconds_of_week,
893 epoch_interval_s: self.epoch_interval_s,
894 mjd: self.mjd,
895 mjd_fraction: self.mjd_fraction,
896 time_system,
897 time_scale,
898 satellites: self.sat_list,
899 satellite_accuracy_codes,
900 };
901
902 Ok(Sp3 {
903 header,
904 epochs: self.epochs,
905 states: self.states,
906 interp_raw: self.interp_raw,
907 comments: self.comments,
908 })
909 }
910}
911
912/// Parse a fixed-column float coordinate, mapping failures to a parse error
913/// that names the offending text.
914fn parse_coord(line: &str, start: usize, end: usize) -> Result<f64> {
915 let raw = field(line, start, end).trim();
916 validate::strict_f64(raw, "coordinate").map_err(|error| map_field_error(error, line))
917}
918
919/// Parse the clock column (chars 46..60). Returns `None` for the bad-clock
920/// sentinel `999999.999999` or an absent/blank field; `Some(us)` otherwise.
921fn parse_clock_us(line: &str) -> Result<Option<f64>> {
922 if line.len() <= 46 {
923 return Ok(None);
924 }
925 let raw = field(line, 46, 60).trim();
926 if raw.is_empty() {
927 return Ok(None);
928 }
929 let value = validate::strict_f64(raw, "clock").map_err(|error| map_field_error(error, line))?;
930 // Sentinel: any value at or beyond the bad-clock magnitude is "no estimate".
931 if value.abs() >= BAD_CLOCK_US {
932 return Ok(None);
933 }
934 Ok(Some(value))
935}
936
937fn map_field_error(error: validate::FieldError, line: &str) -> Error {
938 Error::Parse(format!("SP3 {error} in {line:?}"))
939}
940
941/// Parse the four status flags from their fixed columns (SP3-c/-d shared
942/// layout): clock-event col 74 = `E`, clock-prediction col 75 = `P`,
943/// maneuver col 78 = `M`, orbit-prediction col 79 = `P`.
944fn parse_flags(line: &str) -> Sp3Flags {
945 let at = |col: usize, want: char| -> bool {
946 line.as_bytes().get(col).map(|&b| b as char) == Some(want)
947 };
948 Sp3Flags {
949 clock_event: at(74, 'E'),
950 clock_predicted: at(75, 'P'),
951 maneuver: at(78, 'M'),
952 orbit_predicted: at(79, 'P'),
953 }
954}
955
956/// Parse a 3-char SV token (e.g. `G01`, `C30`, or a bare ` 1` in SP3-a) into a
957/// [`GnssSatelliteId`]. Returns `None` on an unrecognized token.
958fn parse_sv_token(token: &str, version: Option<Sp3Version>) -> Option<GnssSatelliteId> {
959 let token = token.trim();
960 if token.is_empty() {
961 return None;
962 }
963 let first = token.chars().next()?;
964 if first.is_ascii_digit() {
965 // SP3-a GPS-only: bare numeric PRN, optionally space-padded.
966 if matches!(version, Some(Sp3Version::A)) || version.is_none() {
967 let prn = token.parse::<u8>().ok()?;
968 if !is_valid_prn(GnssSystem::Gps, prn) {
969 return None;
970 }
971 return GnssSatelliteId::new(GnssSystem::Gps, prn).ok();
972 }
973 return None;
974 }
975 token.parse::<GnssSatelliteId>().ok()
976}
977
978/// Pull and parse the next whitespace-delimited field from an iterator.
979fn next_field<T: std::str::FromStr>(
980 it: &mut std::str::SplitWhitespace<'_>,
981 what: &str,
982) -> Result<T> {
983 let tok = it
984 .next()
985 .ok_or_else(|| Error::Parse(format!("SP3 missing {what}")))?;
986 tok.parse::<T>()
987 .map_err(|_| Error::Parse(format!("SP3 {what} {tok:?} unparsable")))
988}
989
990mod combine;
991mod interp;
992mod write;
993
994pub use combine::{
995 align_clock_reference, clock_reference_offset, merge, ClockReferenceOffset, MergeCombine,
996 MergeFlag, MergeOptions, MergeReport,
997};
998
999#[cfg(all(test, sidereon_repo_tests))]
1000mod tests;