Skip to main content

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/// Columns and decimal places of an SP3 record value field (`F14.6`): positions
88/// in km, velocities in dm/s, clock and clock-rate in microseconds.
89const RECORD_VALUE_WIDTH: usize = 14;
90const RECORD_VALUE_DECIMALS: usize = 6;
91/// Columns and decimals of the header line-2 (`##`) seconds-of-week (`F15.8`),
92/// epoch-interval (`F14.8`), and MJD-fraction (13 decimals, unbounded columns
93/// as the last field on the line) values.
94const LINE2_SECONDS_OF_WEEK_WIDTH: usize = 15;
95const LINE2_SECONDS_OF_WEEK_DECIMALS: usize = 8;
96const LINE2_INTERVAL_WIDTH: usize = 14;
97const LINE2_INTERVAL_DECIMALS: usize = 8;
98const LINE2_MJD_FRACTION_DECIMALS: usize = 13;
99/// Columns and decimals of the epoch-record (`*`) seconds field (`F11.8`).
100const EPOCH_SECONDS_WIDTH: usize = 11;
101const EPOCH_SECONDS_DECIMALS: usize = 8;
102
103/// SP3 format version.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum Sp3Version {
106    /// SP3-a (legacy, GPS-only).
107    A,
108    /// SP3-b.
109    B,
110    /// SP3-c.
111    C,
112    /// SP3-d (multi-GNSS, Hilla 2016).
113    D,
114}
115
116impl Sp3Version {
117    fn from_char(c: char) -> Result<Self> {
118        match c {
119            'a' | 'A' => Ok(Sp3Version::A),
120            'b' | 'B' => Ok(Sp3Version::B),
121            'c' | 'C' => Ok(Sp3Version::C),
122            'd' | 'D' => Ok(Sp3Version::D),
123            other => Err(Error::Parse(format!("unknown SP3 version '{other}'"))),
124        }
125    }
126}
127
128/// What kind of records the file carries.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum Sp3DataType {
131    /// Position + clock records only (`#?P...`).
132    Position,
133    /// Position + velocity (+ clock + clock-rate) records (`#?V...`).
134    Velocity,
135}
136
137impl Sp3DataType {
138    fn from_char(c: char) -> Result<Self> {
139        match c {
140            'P' => Ok(Sp3DataType::Position),
141            'V' => Ok(Sp3DataType::Velocity),
142            other => Err(Error::Parse(format!("unknown SP3 data type '{other}'"))),
143        }
144    }
145}
146
147/// SP3 time-system labels from the `%c` descriptor.
148///
149/// The core [`TimeScale`] model does not distinguish every SP3 label as its own
150/// global scale. Keep the exact SP3 label here so products using GLONASS, QZSS,
151/// or IRNSS time are accepted and can be serialized without being silently
152/// relabeled.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
154pub enum Sp3TimeSystem {
155    /// GPS time (`GPS`).
156    Gps,
157    /// GLONASS UTC time system (`GLO`).
158    Glonass,
159    /// Galileo system time (`GAL`).
160    Galileo,
161    /// International Atomic Time (`TAI`).
162    Tai,
163    /// Coordinated Universal Time (`UTC`).
164    Utc,
165    /// QZSS time (`QZS`).
166    Qzss,
167    /// BeiDou time (`BDT`).
168    Beidou,
169    /// IRNSS / NavIC time (`IRN`).
170    Irnss,
171}
172
173impl Sp3TimeSystem {
174    /// Canonical three-character SP3 label.
175    pub fn label(self) -> &'static str {
176        match self {
177            Sp3TimeSystem::Gps => "GPS",
178            Sp3TimeSystem::Glonass => "GLO",
179            Sp3TimeSystem::Galileo => "GAL",
180            Sp3TimeSystem::Tai => "TAI",
181            Sp3TimeSystem::Utc => "UTC",
182            Sp3TimeSystem::Qzss => "QZS",
183            Sp3TimeSystem::Beidou => "BDT",
184            Sp3TimeSystem::Irnss => "IRN",
185        }
186    }
187
188    /// Core time scale used to tag parsed [`Instant`] values.
189    ///
190    /// For labels the core model has exactly, this is the direct equivalent. For
191    /// SP3-only labels, the exact product label remains available through
192    /// [`Sp3Header::time_system`], and this value preserves the existing
193    /// interpolation-axis API until the global time model grows those scales.
194    pub fn time_scale(self) -> TimeScale {
195        match self {
196            Sp3TimeSystem::Gps | Sp3TimeSystem::Irnss => TimeScale::Gpst,
197            // QZSST is the exact core scale for the SP3 "QZS" label (nominally
198            // synchronous with GPST); IRNSS has no distinct core scale yet.
199            Sp3TimeSystem::Qzss => TimeScale::Qzsst,
200            Sp3TimeSystem::Glonass | Sp3TimeSystem::Utc => TimeScale::Utc,
201            Sp3TimeSystem::Galileo => TimeScale::Gst,
202            Sp3TimeSystem::Tai => TimeScale::Tai,
203            Sp3TimeSystem::Beidou => TimeScale::Bdt,
204        }
205    }
206
207    fn civil_second_policy(self) -> validate::CivilSecondPolicy {
208        match self {
209            Sp3TimeSystem::Glonass | Sp3TimeSystem::Utc => validate::CivilSecondPolicy::UtcLike,
210            Sp3TimeSystem::Gps
211            | Sp3TimeSystem::Galileo
212            | Sp3TimeSystem::Tai
213            | Sp3TimeSystem::Qzss
214            | Sp3TimeSystem::Beidou
215            | Sp3TimeSystem::Irnss => validate::CivilSecondPolicy::Continuous,
216        }
217    }
218}
219
220/// Per-record quality / status flags (SP3-c columns 75-80, SP3-d same layout).
221///
222/// All four flags are independent and any combination may appear (e.g. a
223/// predicted orbit during a maneuver). They are surfaced verbatim from the
224/// record and never alter the parsed numbers.
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
226pub struct Sp3Flags {
227    /// `E` in the clock-event column: a clock discontinuity occurred near this
228    /// epoch; clock interpolation across it is unsafe.
229    pub clock_event: bool,
230    /// `P` in the clock-prediction column: the clock is predicted, not fitted.
231    pub clock_predicted: bool,
232    /// `M` in the maneuver column: the satellite was being maneuvered; the
233    /// state is not suitable for precise navigation.
234    pub maneuver: bool,
235    /// `P` in the orbit-prediction column: the orbit is predicted, not fitted.
236    pub orbit_predicted: bool,
237}
238
239/// A single satellite state at one SP3 epoch.
240///
241/// This is the spec's `Sp3State { position: ItrfPositionM, clock_s, velocity?,
242/// clock_rate?, flags }`. The frame/units are encoded in the
243/// member types; missing optional values are `None` rather than sentinels.
244#[derive(Debug, Clone, Copy, PartialEq)]
245pub struct Sp3State {
246    /// Satellite position in the ITRF/IGS ECEF frame, meters.
247    pub position: ItrfPositionM,
248    /// Satellite clock offset in **seconds** (`None` if the bad-clock sentinel
249    /// `999999.999999` us was recorded).
250    pub clock_s: Option<f64>,
251    /// Satellite velocity in the ITRF/IGS ECEF frame, m/s (present only for
252    /// velocity products).
253    pub velocity: Option<ItrfVelocityMS>,
254    /// Satellite clock rate in **seconds per second** (present only for
255    /// velocity products that carry a clock-rate field).
256    pub clock_rate_s_s: Option<f64>,
257    /// Per-record status flags.
258    pub flags: Sp3Flags,
259}
260
261/// Prediction status aggregated over every satellite record at one SP3 epoch.
262#[derive(Debug, Clone, PartialEq)]
263pub struct Sp3EpochPrediction {
264    /// The parsed epoch.
265    pub epoch: Instant,
266    /// Satellites whose orbit record is marked predicted at this epoch.
267    pub orbit_predicted_satellites: Vec<GnssSatelliteId>,
268    /// Satellites whose clock record is marked predicted at this epoch.
269    pub clock_predicted_satellites: Vec<GnssSatelliteId>,
270}
271
272impl Sp3EpochPrediction {
273    /// True when no position or clock record at this epoch is marked predicted.
274    pub fn is_observed(&self) -> bool {
275        self.orbit_predicted_satellites.is_empty() && self.clock_predicted_satellites.is_empty()
276    }
277}
278
279/// Product-wide observed/predicted metadata derived from SP3 record flags.
280#[derive(Debug, Clone, PartialEq)]
281pub struct Sp3PredictionSummary {
282    /// Per-epoch prediction status in parsed epoch order.
283    pub epochs: Vec<Sp3EpochPrediction>,
284    /// Last epoch before the first epoch containing any predicted record. This
285    /// is the product's truthful contiguous observed-through boundary. It is
286    /// `None` when the first epoch is already predicted or the product is empty.
287    pub observed_through: Option<Instant>,
288}
289
290/// Parsed SP3 header.
291#[derive(Debug, Clone, PartialEq)]
292pub struct Sp3Header {
293    /// SP3 format version (`a`/`b`/`c`/`d`).
294    pub version: Sp3Version,
295    /// Whether the file carries velocity records.
296    pub data_type: Sp3DataType,
297    /// Number of parsed epochs in the canonical product.
298    pub num_epochs: u64,
299    /// Coordinate-system / IGS-realization label (e.g. `IGS14`, `ITRF2`).
300    pub coordinate_system: String,
301    /// Orbit-type label (e.g. `FIT`, `BHN`).
302    pub orbit_type: String,
303    /// Producing agency.
304    pub agency: String,
305    /// GNSS week number (in the file's time system).
306    pub gnss_week: u32,
307    /// Seconds of week of the first epoch.
308    pub seconds_of_week: f64,
309    /// Nominal epoch spacing in seconds.
310    pub epoch_interval_s: f64,
311    /// Modified Julian Day of the first epoch (integer part).
312    pub mjd: u32,
313    /// Fractional day of the first epoch.
314    pub mjd_fraction: f64,
315    /// Time system label the epochs are expressed in. For SP3-b/c/d this is read
316    /// strictly from the first `%c` descriptor (a missing/short/blank descriptor
317    /// is a parse error, never a silent GPST default); SP3-a is implicitly GPST.
318    pub time_system: Sp3TimeSystem,
319    /// Core [`TimeScale`] used to tag parsed [`Instant`] values. See
320    /// [`Sp3Header::time_system`] for the exact SP3 label when the product uses
321    /// a standard SP3 time system that is not modeled as a distinct core scale.
322    pub time_scale: TimeScale,
323    /// The satellite list declared in the `+` header lines.
324    pub satellites: Vec<GnssSatelliteId>,
325    /// Per-satellite accuracy exponent codes from the `++` header lines,
326    /// index-aligned with [`Sp3Header::satellites`].
327    pub satellite_accuracy_codes: Vec<u16>,
328}
329
330/// A parsed SP3 precise-ephemeris product.
331///
332/// Construct with [`Sp3::parse`]. Epochs are stored in file order; exact-product
333/// consumers can use [`validate_exact_sp3`] to require a strictly increasing,
334/// regular requested-cadence grid. Each epoch maps satellite -> [`Sp3State`].
335/// Per-satellite/per-epoch access is via [`Sp3::state`]; arbitrary-epoch
336/// interpolation is built separately to match the parity reference and is not
337/// part of this parser.
338#[derive(Debug, Clone)]
339pub struct Sp3 {
340    /// The parsed header.
341    pub header: Sp3Header,
342    /// Epochs in file order, tagged with the header time scale.
343    pub epochs: Vec<Instant>,
344    /// Epoch count declared on SP3 header line 1. Kept separately from
345    /// [`Sp3Header::num_epochs`], which intentionally remains the number of
346    /// epoch records actually parsed for backward compatibility.
347    declared_num_epochs: u64,
348    /// Start epoch declared on SP3 header line 1, expressed as seconds since
349    /// J2000 in the product time scale. `None` means the legacy permissive
350    /// parser could not interpret those otherwise-unused line-1 fields; exact
351    /// product validation rejects that condition.
352    declared_start_j2000_s: Option<f64>,
353    /// Exact-integrity facts about the logical terminal record. The general
354    /// parser remains permissive; exact validation interprets this state.
355    terminal_record: TerminalRecordState,
356    /// Raw mandatory header-record counts retained for exact validation.
357    satellite_header_lines: usize,
358    accuracy_header_lines: usize,
359    time_system_header_lines: usize,
360    float_header_lines: usize,
361    integer_header_lines: usize,
362    header_comment_lines: usize,
363    /// Raw line-3 satellite count and record sequences retained only for exact
364    /// acquisition validation. Tokens include declarations the typed parser
365    /// cannot represent, so exact validation can still prove count and order.
366    declared_satellite_count: Option<usize>,
367    declared_satellite_tokens: Vec<String>,
368    epoch_position_tokens: Vec<Vec<String>>,
369    epoch_velocity_tokens: Vec<Vec<String>>,
370    epoch_state_record_sequence: Vec<Vec<(char, String)>>,
371    /// Exact seconds since J2000 for each parsed epoch, in the product time
372    /// scale, formed from the epoch record's civil fields with integer
373    /// whole-second arithmetic.
374    epoch_j2000_s: Vec<f64>,
375    /// `epoch_index -> (satellite -> state)`. Parallel to [`Sp3::epochs`].
376    states: Vec<BTreeMap<GnssSatelliteId, Sp3State>>,
377    /// `epoch_index -> (satellite -> native-unit node)`. Parallel to
378    /// [`Sp3::epochs`]; populated **only** from genuine position records. The
379    /// interpolator fits its spline over these (km/us straight from the ASCII,
380    /// exactly as the `scipy`/`gnssanalysis` reference does); reconstructing km
381    /// from the public meters (`km->m->km`) drifts up to 1 ULP and breaks the
382    /// 0-ULP parity. See `sp3/interp.rs`.
383    interp_raw: Vec<BTreeMap<GnssSatelliteId, RawNode>>,
384    /// Free-form `/*` comment lines (notice retained for provenance).
385    pub comments: Vec<String>,
386    /// Count of entries skipped because their satellite token did not parse to a
387    /// representable [`GnssSatelliteId`] (e.g. an extended GLONASS slot like `R28`
388    /// beyond the engine's PRN cap): position/velocity records, plus `+`-header
389    /// satellite declarations. Lets callers tell a clean file
390    /// (`skipped_records == 0`) apart from one carrying unsupported satellites,
391    /// without aborting the whole parse on one such entry. Mirrors
392    /// [`crate::astro::sgp4::TleFile::skipped`].
393    pub skipped_records: usize,
394}
395
396// Preserve the parser's existing canonical-product equality contract. Raw
397// line-1 declarations are retained only as acquisition-integrity evidence and
398// are intentionally excluded: serialization canonicalizes them to the actual
399// body count and first epoch.
400impl PartialEq for Sp3 {
401    fn eq(&self, other: &Self) -> bool {
402        self.header == other.header
403            && self.epochs == other.epochs
404            && self.epoch_j2000_s == other.epoch_j2000_s
405            && self.states == other.states
406            && self.interp_raw == other.interp_raw
407            && self.comments == other.comments
408            && self.skipped_records == other.skipped_records
409    }
410}
411
412/// Native-unit interpolation node: the file's own km / microseconds, kept
413/// verbatim from the ASCII so the spline fit is bit-identical to the reference.
414/// Private - the public surface is meters/seconds via [`Sp3State`].
415#[derive(Debug, Clone, Copy, PartialEq)]
416struct RawNode {
417    /// ECEF position in native SP3 kilometers (X/Y/Z), exact ASCII->f64.
418    km: [f64; 3],
419    /// Clock offset in native SP3 microseconds (`None` for the bad-clock
420    /// sentinel), exact ASCII->f64.
421    clock_us: Option<f64>,
422    /// Whether this epoch carried the clock-event (`E`) flag (clock-arc split).
423    clock_event: bool,
424}
425
426impl Sp3 {
427    /// Parse an SP3-c or SP3-d byte buffer into a typed product.
428    ///
429    /// `bytes` is the full file content (already decompressed; this crate does
430    /// not do gzip - that is a caller-layer I/O concern). Returns
431    /// [`Error::Parse`] with a human-readable reason on malformed input.
432    pub fn parse(bytes: &[u8]) -> Result<Self> {
433        let text = std::str::from_utf8(bytes)
434            .map_err(|e| Error::Parse(format!("SP3 is not valid UTF-8: {e}")))?;
435        Self::parse_str(text)
436    }
437
438    /// Parse from a `&str` (the UTF-8 fast path used by [`Sp3::parse`]).
439    pub fn parse_str(text: &str) -> Result<Self> {
440        if !text.is_ascii() {
441            return Err(Error::Parse("SP3 product text must be ASCII".into()));
442        }
443        let mut parser = Parser::new();
444        for (index, raw) in text.lines().enumerate() {
445            parser.feed(raw, index + 1)?;
446        }
447        parser.finish()
448    }
449
450    /// The satellites present in this product (from the header satellite list).
451    pub fn satellites(&self) -> &[GnssSatelliteId] {
452        &self.header.satellites
453    }
454
455    /// Number of parsed epochs.
456    pub fn epoch_count(&self) -> usize {
457        self.epochs.len()
458    }
459
460    /// Epoch count written in SP3 header line 1.
461    ///
462    /// This can differ from [`Sp3::epoch_count`] when a truncated or otherwise
463    /// inconsistent file is parsed through the deliberately permissive base
464    /// parser. Use [`validate_exact_sp3`] when those fields must agree.
465    pub fn declared_epoch_count(&self) -> u64 {
466        self.declared_num_epochs
467    }
468
469    /// Start epoch written in SP3 header line 1, as seconds since J2000 in the
470    /// product time scale.
471    ///
472    /// The base parser historically ignored these civil fields, so malformed
473    /// values are represented as `None` rather than changing `Sp3::parse`
474    /// compatibility. Exact product validation requires `Some` and checks it
475    /// against both the request and the first parsed epoch.
476    pub fn declared_start_j2000_s(&self) -> Option<f64> {
477        self.declared_start_j2000_s
478    }
479
480    /// The state of `sat` at the parsed epoch with index `epoch_index`.
481    ///
482    /// Returns [`Error::EpochOutOfRange`] if the index is past the end, or
483    /// [`Error::UnknownSatellite`] if the satellite has no record at that epoch.
484    pub fn state(&self, sat: GnssSatelliteId, epoch_index: usize) -> Result<Sp3State> {
485        let per_epoch = self.states.get(epoch_index).ok_or(Error::EpochOutOfRange)?;
486        per_epoch
487            .get(&sat)
488            .copied()
489            .ok_or(Error::UnknownSatellite(sat))
490    }
491
492    /// All `(satellite, state)` pairs recorded at `epoch_index`, in ascending
493    /// satellite order.
494    pub fn states_at(&self, epoch_index: usize) -> Result<&BTreeMap<GnssSatelliteId, Sp3State>> {
495        self.states.get(epoch_index).ok_or(Error::EpochOutOfRange)
496    }
497
498    /// Aggregate the per-record SP3 orbit/clock prediction flags by epoch and
499    /// compute the contiguous observed-through boundary.
500    ///
501    /// This uses the actual `P` flags carried by position records; it never
502    /// assumes a fixed ultra-rapid observed duration. Individual cell flags
503    /// remain available through [`Sp3::state`] and [`Sp3::states_at`].
504    pub fn prediction_summary(&self) -> Sp3PredictionSummary {
505        let epochs: Vec<Sp3EpochPrediction> = self
506            .epochs
507            .iter()
508            .copied()
509            .zip(self.states.iter())
510            .map(|(epoch, states)| Sp3EpochPrediction {
511                epoch,
512                orbit_predicted_satellites: states
513                    .iter()
514                    .filter_map(|(satellite, state)| {
515                        state.flags.orbit_predicted.then_some(*satellite)
516                    })
517                    .collect(),
518                clock_predicted_satellites: states
519                    .iter()
520                    .filter_map(|(satellite, state)| {
521                        state.flags.clock_predicted.then_some(*satellite)
522                    })
523                    .collect(),
524            })
525            .collect();
526        let first_predicted = epochs.iter().position(|epoch| !epoch.is_observed());
527        let observed_through = match first_predicted {
528            Some(0) => None,
529            Some(index) => self.epochs.get(index - 1).copied(),
530            None => self.epochs.last().copied(),
531        };
532
533        Sp3PredictionSummary {
534            epochs,
535            observed_through,
536        }
537    }
538}
539
540impl core::str::FromStr for Sp3 {
541    type Err = Error;
542
543    fn from_str(s: &str) -> Result<Self> {
544        Self::parse_str(s)
545    }
546}
547
548#[cfg(test)]
549impl Sp3 {}
550
551/// Parse an SP3 time-system label.
552///
553/// SP3-c/-d encode the time system in the `%c` descriptor line (chars 9-12).
554/// SP3-a is implicitly GPST. Unknown labels error rather than silently
555/// defaulting, so a parity pipeline never mis-attributes an epoch's scale.
556fn time_system_from_label(label: &str) -> Result<Sp3TimeSystem> {
557    match label.trim() {
558        "GPS" => Ok(Sp3TimeSystem::Gps),
559        "GLO" => Ok(Sp3TimeSystem::Glonass),
560        "GAL" => Ok(Sp3TimeSystem::Galileo),
561        "TAI" => Ok(Sp3TimeSystem::Tai),
562        "UTC" => Ok(Sp3TimeSystem::Utc),
563        "QZS" => Ok(Sp3TimeSystem::Qzss),
564        "BDT" | "BDS" => Ok(Sp3TimeSystem::Beidou),
565        "IRN" => Ok(Sp3TimeSystem::Irnss),
566        trimmed => Err(Error::Parse(format!(
567            "unsupported SP3 time system '{trimmed}'"
568        ))),
569    }
570}
571
572/// Compute the integer-day / fraction split Julian date from a Gregorian UTC-ish
573/// civil epoch, with the day fraction carried separately (Skyfield split
574/// convention, matching [`JulianDateSplit`]).
575///
576/// SP3 epoch lines are civil dates in the file's *own* time system; we keep
577/// them in that scale (no leap-second shifting here - that is a conversion
578/// concern handled by the core `scales` machinery, not the parser). The
579/// algorithm is the standard Fliegel-Van Flandern Gregorian-to-JDN, then the
580/// time-of-day fraction. JDN is computed in integer arithmetic so the whole-day
581/// boundary is exact; only the sub-day fraction is floating point.
582fn civil_to_julian_split(civil: validate::ValidCivil) -> Result<JulianDateSplit> {
583    // Canonical civil-to-split conversion: the integer JDN places the `*.5`
584    // civil-midnight boundary and the within-day clock fields become the
585    // fraction. SP3 epochs are civil days in the file's own scale (no leap
586    // second). The carry below is retained for the rare epoch whose seconds
587    // overflow a day.
588    let (mut jd_whole, mut fraction) = split_julian_date(
589        civil.year as i32,
590        civil.month as i32,
591        civil.day as i32,
592        civil.hour as i32,
593        civil.minute as i32,
594        civil.second,
595    );
596    if fraction > 1.0 {
597        let carry = fraction.floor();
598        jd_whole += carry;
599        fraction -= carry;
600    }
601    JulianDateSplit::new(jd_whole, fraction)
602        .map_err(|error| Error::Parse(format!("invalid SP3 epoch Julian date: {error}")))
603}
604
605/// Conservative maximum for one logical SP3 record.
606///
607/// SP3-d defines the EOF field as `EOF` in columns 1-3 (`A3`) but does not
608/// explicitly prescribe padding after column 3. Official ESA products pad the
609/// record with ASCII spaces through column 80, official GFZ products have also
610/// been observed padding through column 40, and other official products stop
611/// at column 3. Sidereon accepts those interoperable forms while bounding the
612/// record at the conventional 80-column width.
613const SP3_RECORD_WIDTH: usize = 80;
614
615#[derive(Debug, Clone, Copy, PartialEq, Eq)]
616enum EofRecordKind {
617    Valid,
618    Malformed,
619    Other,
620}
621
622#[derive(Debug, Clone, Copy, PartialEq, Eq)]
623struct MalformedEofRecord {
624    line_number: usize,
625    record_length: usize,
626}
627
628#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
629struct TerminalRecordState {
630    had_valid_record: bool,
631    first_malformed_record: Option<MalformedEofRecord>,
632    had_trailing_content: bool,
633}
634
635impl TerminalRecordState {
636    const fn valid() -> Self {
637        Self {
638            had_valid_record: true,
639            first_malformed_record: None,
640            had_trailing_content: false,
641        }
642    }
643}
644
645/// Classify a complete logical record without performing a substring search.
646fn classify_eof_record(line: &str) -> EofRecordKind {
647    if let Some(padding) = line.strip_prefix("EOF") {
648        if line.len() <= SP3_RECORD_WIDTH && padding.bytes().all(|byte| byte == b' ') {
649            EofRecordKind::Valid
650        } else {
651            EofRecordKind::Malformed
652        }
653    } else if line
654        .trim_start_matches(|character: char| character.is_ascii_whitespace())
655        .starts_with("EOF")
656    {
657        EofRecordKind::Malformed
658    } else {
659        EofRecordKind::Other
660    }
661}
662
663/// Incremental line-driven SP3 parser state machine.
664struct Parser {
665    version: Option<Sp3Version>,
666    data_type: Option<Sp3DataType>,
667    num_epochs: u64,
668    declared_start_j2000_s: Option<f64>,
669    coordinate_system: String,
670    orbit_type: String,
671    agency: String,
672    gnss_week: u32,
673    seconds_of_week: f64,
674    epoch_interval_s: f64,
675    mjd: u32,
676    mjd_fraction: f64,
677    time_system: Option<Sp3TimeSystem>,
678    /// `+`-line declared satellites, in file order.
679    sat_list: Vec<GnssSatelliteId>,
680    declared_satellite_count: Option<usize>,
681    declared_satellite_tokens: Vec<String>,
682    /// `++`-line per-satellite accuracy codes, in satellite-list order.
683    sat_accuracy_codes: Vec<u16>,
684    /// Number of real (non-padding) `+`-line satellite slots seen, including any
685    /// dropped because their token was unrepresentable. The `++` accuracy codes
686    /// are positionally aligned with these declaration slots, so this is the axis
687    /// the accuracy parser walks (not the filtered [`Self::sat_list`]).
688    declared_sat_slots: usize,
689    /// Declaration-slot indices (into the `declared_sat_slots` axis) whose token
690    /// was unrepresentable and dropped from [`Self::sat_list`]. Their `++`
691    /// accuracy columns must be skipped so the surviving satellites keep their own
692    /// codes. Empty for every well-formed file, making the accuracy parse a no-op
693    /// realignment in the common case.
694    dropped_sat_slots: Vec<usize>,
695    /// Cursor along the declaration-slot axis consumed by the `++` accuracy
696    /// parser across one or more `++` lines.
697    accuracy_slot_cursor: usize,
698    /// `%c` descriptor lines seen so far (the first carries the time system).
699    pc_count: u32,
700    satellite_header_lines: usize,
701    accuracy_header_lines: usize,
702    float_header_lines: usize,
703    integer_header_lines: usize,
704    header_comment_lines: usize,
705    /// Header line 1 parsed?
706    have_line1: bool,
707    /// Header line 2 parsed?
708    have_line2: bool,
709    /// Epoch currently being filled.
710    current_epoch: Option<Instant>,
711    epochs: Vec<Instant>,
712    epoch_j2000_s: Vec<f64>,
713    states: Vec<BTreeMap<GnssSatelliteId, Sp3State>>,
714    interp_raw: Vec<BTreeMap<GnssSatelliteId, RawNode>>,
715    epoch_position_tokens: Vec<Vec<String>>,
716    epoch_velocity_tokens: Vec<Vec<String>>,
717    epoch_state_record_sequence: Vec<Vec<(char, String)>>,
718    comments: Vec<String>,
719    diagnostics: Diagnostics,
720    terminal_record: TerminalRecordState,
721}
722
723impl Parser {
724    fn new() -> Self {
725        Self {
726            version: None,
727            data_type: None,
728            num_epochs: 0,
729            declared_start_j2000_s: None,
730            coordinate_system: String::new(),
731            orbit_type: String::new(),
732            agency: String::new(),
733            gnss_week: 0,
734            seconds_of_week: 0.0,
735            epoch_interval_s: 0.0,
736            mjd: 0,
737            mjd_fraction: 0.0,
738            time_system: None,
739            sat_list: Vec::new(),
740            declared_satellite_count: None,
741            declared_satellite_tokens: Vec::new(),
742            sat_accuracy_codes: Vec::new(),
743            declared_sat_slots: 0,
744            dropped_sat_slots: Vec::new(),
745            accuracy_slot_cursor: 0,
746            pc_count: 0,
747            satellite_header_lines: 0,
748            accuracy_header_lines: 0,
749            float_header_lines: 0,
750            integer_header_lines: 0,
751            header_comment_lines: 0,
752            have_line1: false,
753            have_line2: false,
754            current_epoch: None,
755            epochs: Vec::new(),
756            epoch_j2000_s: Vec::new(),
757            states: Vec::new(),
758            interp_raw: Vec::new(),
759            epoch_position_tokens: Vec::new(),
760            epoch_velocity_tokens: Vec::new(),
761            epoch_state_record_sequence: Vec::new(),
762            comments: Vec::new(),
763            diagnostics: Diagnostics::new(),
764            terminal_record: TerminalRecordState::default(),
765        }
766    }
767
768    fn feed(&mut self, raw: &str, line_number: usize) -> Result<()> {
769        if self.terminal_record.had_valid_record {
770            if !raw.bytes().all(|byte| byte == b' ') {
771                self.terminal_record.had_trailing_content = true;
772            }
773            return Ok(());
774        }
775        // `str::lines` removes one LF or one CRLF separator. Any remaining CR,
776        // LF, leading whitespace, or other byte is record content; SP3 columns
777        // are significant, so it must not be trimmed here.
778        let line = raw;
779
780        match classify_eof_record(line) {
781            EofRecordKind::Valid => {
782                self.terminal_record.had_valid_record = true;
783                return Ok(());
784            }
785            EofRecordKind::Malformed => {
786                self.terminal_record
787                    .first_malformed_record
788                    .get_or_insert(MalformedEofRecord {
789                        line_number,
790                        record_length: line.len(),
791                    });
792                return Ok(());
793            }
794            EofRecordKind::Other => {}
795        }
796        if line.starts_with("/*") {
797            if self.integer_header_lines >= 2 && self.epochs.is_empty() {
798                self.header_comment_lines += 1;
799            }
800            // Comment line; columns 4.. are the text.
801            // Blank records are mandatory structural padding, not semantic
802            // comments. Count them above but do not add empty strings to the
803            // public `comments` collection.
804            if line.len() > 3 {
805                let comment = line[3..].trim_end();
806                if !comment.is_empty() {
807                    self.comments.push(comment.to_string());
808                }
809            }
810            return Ok(());
811        }
812        // Header line 2 (`##`) must be tested before line 1 (`#`).
813        if line.starts_with("##") {
814            self.parse_line2(line)?;
815            return Ok(());
816        }
817        if line.starts_with('#') {
818            self.parse_line1(line)?;
819            return Ok(());
820        }
821        if line.starts_with('+') {
822            if line.starts_with("++") {
823                self.accuracy_header_lines += 1;
824            } else {
825                self.satellite_header_lines += 1;
826            }
827            self.parse_plus_line(line, line_number)?;
828            return Ok(());
829        }
830        if line.starts_with("%c") {
831            self.parse_pc_line(line)?;
832            return Ok(());
833        }
834        if line.starts_with("%f") {
835            self.float_header_lines += 1;
836            // Float accuracy descriptors are retained only as structural
837            // evidence for exact validation.
838            return Ok(());
839        }
840        if line.starts_with("%i") {
841            self.integer_header_lines += 1;
842            // Float/int accuracy descriptor lines - not needed for the typed
843            // state; skipped deterministically.
844            return Ok(());
845        }
846        if line.starts_with('*') {
847            self.parse_epoch_line(line)?;
848            return Ok(());
849        }
850        if line.starts_with('P') {
851            self.parse_position_line(line, line_number)?;
852            return Ok(());
853        }
854        if line.starts_with('V') {
855            self.parse_velocity_line(line, line_number)?;
856            return Ok(());
857        }
858        // Unknown / ignorable line (e.g. `%/`); skip without failing - SP3 has
859        // optional descriptor lines a parser must tolerate.
860        Ok(())
861    }
862
863    /// Header line 1: `#cP2020 ...` / `#dV...`.
864    fn parse_line1(&mut self, line: &str) -> Result<()> {
865        // Minimum well-formed line-1 length per the standard.
866        if line.len() < 55 {
867            return Err(Error::Parse(format!(
868                "SP3 header line 1 too short: {line:?}"
869            )));
870        }
871        let chars: Vec<char> = line.chars().collect();
872        let version = Sp3Version::from_char(chars[1])?;
873        self.version = Some(version);
874        self.data_type = Some(Sp3DataType::from_char(chars[2])?);
875        // SP3-a predates the %c time-system descriptor and is implicitly GPST.
876        // Set it here so a (correct) SP3-a file with no %c line still resolves,
877        // while SP3-b/c/d are left as None until a valid %c line proves the
878        // scale (a missing %c then becomes a hard error, not a GPST default).
879        if matches!(version, Sp3Version::A) {
880            self.time_system = Some(Sp3TimeSystem::Gps);
881        }
882
883        // Column layout per the SP3 standard, matching the (round-trip-tested)
884        // refs/sp3 line-1 reader: num_epochs 32..40, observables 40..45,
885        // coord_system 45..51, orbit_type 51..55, agency 55...
886        self.num_epochs = field(line, 32, 40)
887            .trim()
888            .parse::<u64>()
889            .map_err(|_| Error::Parse(format!("SP3 num_epochs unparsable in {line:?}")))?;
890        // Keep line-1 start metadata for exact-product validation. These fields
891        // were historically cosmetic to the base parser, so use a best-effort
892        // parse here: malformed values remain parse-compatible but are rejected
893        // by the exact validator as unavailable declared metadata.
894        self.declared_start_j2000_s = parse_declared_start_j2000_s(line);
895        self.coordinate_system = field(line, 45, 51).trim().to_string();
896        self.orbit_type = field(line, 51, 55).trim().to_string();
897        self.agency = field_from(line, 55).trim().to_string();
898        self.have_line1 = true;
899        Ok(())
900    }
901
902    /// Header line 2: `## 2276  21600.00000000   900.00000000 60176 0.25...`.
903    fn parse_line2(&mut self, line: &str) -> Result<()> {
904        self.gnss_week = field(line, 3, 7)
905            .trim()
906            .parse::<u32>()
907            .map_err(|_| Error::Parse(format!("SP3 GNSS week unparsable in {line:?}")))?;
908        let seconds_of_week = field(line, 8, 23)
909            .trim()
910            .parse::<f64>()
911            .map_err(|_| Error::Parse(format!("SP3 seconds-of-week unparsable in {line:?}")))?;
912        self.seconds_of_week = exact_in_field(
913            seconds_of_week,
914            Some(LINE2_SECONDS_OF_WEEK_WIDTH),
915            LINE2_SECONDS_OF_WEEK_DECIMALS,
916            "seconds-of-week",
917            line,
918        )?;
919        let epoch_interval_s = field(line, 24, 38)
920            .trim()
921            .parse::<f64>()
922            .map_err(|_| Error::Parse(format!("SP3 epoch interval unparsable in {line:?}")))?;
923        self.epoch_interval_s = exact_in_field(
924            epoch_interval_s,
925            Some(LINE2_INTERVAL_WIDTH),
926            LINE2_INTERVAL_DECIMALS,
927            "epoch interval",
928            line,
929        )?;
930        self.mjd = field(line, 39, 44)
931            .trim()
932            .parse::<u32>()
933            .map_err(|_| Error::Parse(format!("SP3 MJD unparsable in {line:?}")))?;
934        let mjd_fraction = strict_f64(field_from(line, 45), "mjd_fraction")
935            .map_err(|error| map_field_error(error, line))?;
936        self.mjd_fraction = exact_in_field(
937            mjd_fraction,
938            None,
939            LINE2_MJD_FRACTION_DECIMALS,
940            "MJD fraction",
941            line,
942        )?;
943        self.have_line2 = true;
944        Ok(())
945    }
946
947    /// `+` satellite-list line: `+   32   G01G02...` (3-char SV tokens from
948    /// column 9 in groups of 17). Continuation `+` lines append more tokens.
949    fn parse_plus_line(&mut self, line: &str, line_number: usize) -> Result<()> {
950        if line.starts_with("++") {
951            return self.parse_accuracy_line(line);
952        }
953        if self.satellite_header_lines == 1 {
954            self.declared_satellite_count = field(line, 3, 6).trim().parse::<usize>().ok();
955        }
956        // SV tokens start at column 9 (0-based), each 3 chars, up to 17 per line.
957        let mut col = 9;
958        while col + 3 <= line.len() {
959            let token = field(line, col, col + 3);
960            let trimmed = token.trim();
961            // Unused satellite slots are zero-filled, not a declaration. The
962            // SP3 zero-fill varies between producers (`  0`, ` 00`, `000`), so
963            // any all-zero (or blank) token is padding - never a satellite,
964            // whose token is a system letter + PRN (or, in SP3-a, a non-zero
965            // numeric PRN). Misreading ` 00` as an unrepresentable satellite
966            // inflates `skipped_records` and breaks the parse/write/parse
967            // round trip (the writer re-emits the canonical `  0`).
968            if trimmed.is_empty() || trimmed.bytes().all(|b| b == b'0') {
969                col += 3;
970                continue;
971            }
972            // This is a real declaration slot; the `++` accuracy codes are aligned
973            // to this axis, so track its index whether or not the token resolves.
974            let slot_index = self.declared_sat_slots;
975            self.declared_sat_slots += 1;
976            self.declared_satellite_tokens.push(trimmed.to_owned());
977            if let Some(id) = parse_sv_token(token, self.version) {
978                if !self.sat_list.contains(&id) {
979                    self.sat_list.push(id);
980                }
981            } else {
982                // A declared satellite whose token is not representable (e.g. an
983                // extended GLONASS slot R28 beyond the engine's PRN cap) is
984                // dropped from the satellite list, but counted rather than dropped
985                // silently - consistent with the position/velocity record paths
986                // (see `Sp3::skipped_records`). Record the slot so its accuracy
987                // column is skipped, keeping the surviving codes aligned.
988                self.push_unrepresentable_satellite_skip(line_number, token);
989                self.dropped_sat_slots.push(slot_index);
990            }
991            col += 3;
992        }
993        Ok(())
994    }
995
996    /// `++` per-satellite accuracy-code line: 3-char integer fields from column
997    /// 9, aligned with the `+` declaration slots.
998    ///
999    /// The columns track the `+` declaration order, so a column whose declaration
1000    /// slot was dropped (an unrepresentable satellite) is read and discarded, not
1001    /// pushed - otherwise the surviving satellites would inherit a neighbour's
1002    /// accuracy code. With no dropped slots this is exactly the 1:1 push as before.
1003    fn parse_accuracy_line(&mut self, line: &str) -> Result<()> {
1004        let mut col = 9;
1005        while col + 3 <= line.len() && self.accuracy_slot_cursor < self.declared_sat_slots {
1006            let token = field(line, col, col + 3);
1007            let trimmed = token.trim();
1008            let code = if trimmed.is_empty() {
1009                0
1010            } else {
1011                validate::strict_int::<u16>(trimmed, "satellite_accuracy_code")
1012                    .map_err(|error| map_field_error(error, line))?
1013            };
1014            if !self.dropped_sat_slots.contains(&self.accuracy_slot_cursor) {
1015                self.sat_accuracy_codes.push(code);
1016            }
1017            self.accuracy_slot_cursor += 1;
1018            col += 3;
1019        }
1020        Ok(())
1021    }
1022
1023    /// `%c` descriptor: the first one (chars 9-12) carries the time system.
1024    fn parse_pc_line(&mut self, line: &str) -> Result<()> {
1025        if self.pc_count == 0 {
1026            // SP3-a is implicitly GPST regardless of descriptor content.
1027            if matches!(self.version, Some(Sp3Version::A)) {
1028                self.time_system = Some(Sp3TimeSystem::Gps);
1029            } else if line.len() >= 12 {
1030                let label = field(line, 9, 12);
1031                let trimmed = label.trim();
1032                // STRICT: a blank time-system field on the first %c is not GPST,
1033                // it is malformed. Reject rather than silently defaulting so a
1034                // precise pipeline never mis-attributes an epoch's scale.
1035                if trimmed.is_empty() {
1036                    return Err(Error::Parse(format!(
1037                        "SP3 %c time system is blank in {line:?}"
1038                    )));
1039                }
1040                self.time_system = Some(time_system_from_label(label)?);
1041            } else {
1042                // STRICT: a short %c line for SP3-b/c/d carries no time system
1043                // we can trust. Reject rather than defaulting to GPST.
1044                return Err(Error::Parse(format!(
1045                    "SP3 %c descriptor too short to carry a time system: {line:?}"
1046                )));
1047            }
1048        }
1049        self.pc_count += 1;
1050        Ok(())
1051    }
1052
1053    /// Epoch line: `*  2020  6 24  0  0  0.00000000`.
1054    fn parse_epoch_line(&mut self, line: &str) -> Result<()> {
1055        // STRICT: by the time we reach data, the time system must be known -
1056        // implicitly GPST for SP3-a (set at line 1), or from a valid first %c
1057        // line for SP3-b/c/d. A missing/blank/short %c is an error, never GPST.
1058        let time_system = self.time_system.ok_or_else(|| {
1059            Error::Parse("SP3 epoch encountered with no time system (missing %c descriptor)".into())
1060        })?;
1061        let scale = time_system.time_scale();
1062        // Fields after the leading `*  ` (3 chars), then space-delimited.
1063        let body = &line[1..];
1064        let mut it = body.split_whitespace();
1065        let year: i64 = next_field(&mut it, "epoch year")?;
1066        let month: i64 = next_field(&mut it, "epoch month")?;
1067        let day: i64 = next_field(&mut it, "epoch day")?;
1068        let hour: i64 = next_field(&mut it, "epoch hour")?;
1069        let minute: i64 = next_field(&mut it, "epoch minute")?;
1070        let seconds: f64 = next_field(&mut it, "epoch seconds")?;
1071        // The epoch instant is written back through this same `F11.8` field, so
1072        // seconds carrying more precision than it expresses would re-parse as a
1073        // different instant and shift the epoch (`0.0000009999` re-emits as
1074        // `0.00000100`). Same rule the record and header line-2 values apply.
1075        let seconds = exact_in_field(
1076            seconds,
1077            Some(EPOCH_SECONDS_WIDTH),
1078            EPOCH_SECONDS_DECIMALS,
1079            "epoch seconds",
1080            line,
1081        )?;
1082
1083        let civil = validate::civil_datetime_with_second_policy(
1084            year,
1085            month,
1086            day,
1087            hour,
1088            minute,
1089            seconds,
1090            time_system.civil_second_policy(),
1091        )
1092        .map_err(|error| map_field_error(error, line))?;
1093        let split = civil_to_julian_split(civil)?;
1094        let epoch_j2000_s = j2000_seconds(
1095            civil.year as i32,
1096            civil.month as i32,
1097            civil.day as i32,
1098            civil.hour as i32,
1099            civil.minute as i32,
1100            civil.second,
1101        );
1102        let epoch = Instant {
1103            scale,
1104            repr: InstantRepr::JulianDate(split),
1105        };
1106        self.epochs.push(epoch);
1107        self.epoch_j2000_s.push(epoch_j2000_s);
1108        self.states.push(BTreeMap::new());
1109        self.interp_raw.push(BTreeMap::new());
1110        self.epoch_position_tokens.push(Vec::new());
1111        self.epoch_velocity_tokens.push(Vec::new());
1112        self.epoch_state_record_sequence.push(Vec::new());
1113        self.current_epoch = Some(epoch);
1114        Ok(())
1115    }
1116
1117    /// Position+clock record: `PG01  x  y  z  clk  ...flags`.
1118    fn parse_position_line(&mut self, line: &str, line_number: usize) -> Result<()> {
1119        if self.current_epoch.is_none() {
1120            return Err(Error::Parse(
1121                "SP3 position record before any epoch line".into(),
1122            ));
1123        }
1124        if line.len() < 46 {
1125            return Err(Error::Parse(format!(
1126                "SP3 position record truncated before vector fields in {line:?}"
1127            )));
1128        }
1129        let token = field(line, 1, 4);
1130        self.epoch_position_tokens
1131            .last_mut()
1132            .expect("current epoch has a raw position-token list")
1133            .push(token.trim().to_owned());
1134        self.epoch_state_record_sequence
1135            .last_mut()
1136            .expect("current epoch has a raw state-record sequence")
1137            .push(('P', token.trim().to_owned()));
1138        let Some(sat) = parse_sv_token(token, self.version) else {
1139            // A token that does not parse to a representable `GnssSatelliteId`
1140            // (e.g. an extended GLONASS slot like R28 beyond the engine's PRN
1141            // cap) is an independent, unsupported record. One such record must
1142            // not reject the whole file - skip and count it, mirroring nav
1143            // `parse_glonass` and `parse_tle_file`.
1144            self.push_unrepresentable_satellite_skip(line_number, token);
1145            return Ok(());
1146        };
1147
1148        // The header `+` lines are the authoritative satellite declaration; a
1149        // position record for an undeclared satellite is malformed. Accepting it
1150        // would store a state the writer (which emits only declared satellites)
1151        // cannot reproduce, breaking parse/encode/parse round-tripping.
1152        if !self.sat_list.contains(&sat) {
1153            return Err(Error::Parse(format!(
1154                "SP3 position record for satellite {token:?} not in the header satellite list"
1155            )));
1156        }
1157
1158        let x_km = parse_coord(line, 4, 18)?;
1159        let y_km = parse_coord(line, 18, 32)?;
1160        let z_km = parse_coord(line, 32, 46)?;
1161
1162        // All-zero position is the missing-orbit sentinel: skip the record.
1163        if x_km == MISSING_POSITION_KM && y_km == MISSING_POSITION_KM && z_km == MISSING_POSITION_KM
1164        {
1165            return Ok(());
1166        }
1167
1168        let clock_us = parse_clock_us(line)?;
1169        let clock_s = clock_us.map(|us| us * US_TO_S);
1170
1171        let flags = parse_flags(line);
1172
1173        let position = ItrfPositionM::new(x_km * KM_TO_M, y_km * KM_TO_M, z_km * KM_TO_M)
1174            .map_err(|e| Error::Parse(format!("SP3 invalid position record: {e}")))?;
1175        let state = Sp3State {
1176            position,
1177            clock_s,
1178            velocity: None,
1179            clock_rate_s_s: None,
1180            flags,
1181        };
1182        let idx = self.states.len() - 1;
1183        self.states[idx].insert(sat, state);
1184        // Keep the native-unit node for the interpolation path (see RawNode):
1185        // the spline must fit the file's own km/us, not the km->m->km round trip.
1186        self.interp_raw[idx].insert(
1187            sat,
1188            RawNode {
1189                km: [x_km, y_km, z_km],
1190                clock_us,
1191                clock_event: flags.clock_event,
1192            },
1193        );
1194        Ok(())
1195    }
1196
1197    /// Velocity record: `VG01  vx  vy  vz  clkrate ...`. Augments the matching
1198    /// position record at the current epoch (must follow it).
1199    fn parse_velocity_line(&mut self, line: &str, line_number: usize) -> Result<()> {
1200        if self.current_epoch.is_none() {
1201            return Err(Error::Parse(
1202                "SP3 velocity record before any epoch line".into(),
1203            ));
1204        }
1205        if line.len() < 46 {
1206            return Err(Error::Parse(format!(
1207                "SP3 velocity record truncated before vector fields in {line:?}"
1208            )));
1209        }
1210        let token = field(line, 1, 4);
1211        self.epoch_velocity_tokens
1212            .last_mut()
1213            .expect("current epoch has a raw velocity-token list")
1214            .push(token.trim().to_owned());
1215        self.epoch_state_record_sequence
1216            .last_mut()
1217            .expect("current epoch has a raw state-record sequence")
1218            .push(('V', token.trim().to_owned()));
1219        let Some(sat) = parse_sv_token(token, self.version) else {
1220            // Unparsable / out-of-range satellite token: skip and count, same
1221            // as the position-record path above.
1222            self.push_unrepresentable_satellite_skip(line_number, token);
1223            return Ok(());
1224        };
1225
1226        // SP3 velocity is in dm/s; read each axis independently (the refs/sp3
1227        // crate has a bug here that reuses Y for X - we do not).
1228        let vx_dm_s = parse_coord(line, 4, 18)?;
1229        let vy_dm_s = parse_coord(line, 18, 32)?;
1230        let vz_dm_s = parse_coord(line, 32, 46)?;
1231
1232        let missing_velocity = vx_dm_s == MISSING_VELOCITY_DM_S
1233            && vy_dm_s == MISSING_VELOCITY_DM_S
1234            && vz_dm_s == MISSING_VELOCITY_DM_S;
1235        let velocity = ItrfVelocityMS::new(
1236            vx_dm_s * DM_S_TO_M_S,
1237            vy_dm_s * DM_S_TO_M_S,
1238            vz_dm_s * DM_S_TO_M_S,
1239        )
1240        .map_err(|e| Error::Parse(format!("SP3 invalid velocity record: {e}")))?;
1241
1242        // Clock-rate field shares the clock column; bad-clock sentinel applies.
1243        let clock_rate_s_s = parse_clock_us(line)?.map(|rate| rate * CLOCK_RATE_TO_S_PER_S);
1244
1245        let idx = self.states.len() - 1;
1246        match self.states[idx].get_mut(&sat) {
1247            Some(state) if !missing_velocity => {
1248                state.velocity = Some(velocity);
1249                state.clock_rate_s_s = clock_rate_s_s;
1250            }
1251            Some(_) => {}
1252            None => {
1253                // A V-record always follows its P-record for the same satellite
1254                // at the same epoch (SP3 format invariant). With no preceding
1255                // P-record this satellite has NO valid position at this epoch;
1256                // synthesizing one (e.g. the geocenter (0,0,0)) would fabricate
1257                // an orbit that the all-zero missing-orbit guard exists to
1258                // reject, and would leak through the public state()/states_at().
1259                // Treat it as malformed and skip - consistent with the parser's
1260                // tolerant skipping of other malformed records. No state is
1261                // inserted, so the satellite stays UnknownSatellite at this
1262                // epoch and no (0,0,0) position is ever exposed.
1263            }
1264        }
1265        Ok(())
1266    }
1267
1268    fn push_unrepresentable_satellite_skip(&mut self, line_number: usize, token: &str) {
1269        self.diagnostics.push_skip(Skip {
1270            at: RecordRef::at_line(line_number).with_satellite(token),
1271            reason: SkipReason::UnrepresentableSatellite,
1272        });
1273    }
1274
1275    fn finish(self) -> Result<Sp3> {
1276        if !self.have_line1 {
1277            return Err(Error::Parse("SP3 missing header line 1".into()));
1278        }
1279        if !self.have_line2 {
1280            return Err(Error::Parse("SP3 missing header line 2".into()));
1281        }
1282        let version = self
1283            .version
1284            .ok_or_else(|| Error::Parse("SP3 version not determined".into()))?;
1285        let data_type = self
1286            .data_type
1287            .ok_or_else(|| Error::Parse("SP3 data type not determined".into()))?;
1288        // STRICT: SP3-a is implicitly GPST (set at line 1); SP3-b/c/d must have
1289        // proved their scale from a valid first %c line. Never default here.
1290        let time_system = self.time_system.ok_or_else(|| {
1291            Error::Parse(
1292                "SP3 time system not determined (missing/short/blank %c descriptor)".into(),
1293            )
1294        })?;
1295        let time_scale = time_system.time_scale();
1296
1297        let mut satellite_accuracy_codes = self.sat_accuracy_codes;
1298        satellite_accuracy_codes.truncate(self.sat_list.len());
1299        satellite_accuracy_codes.resize(self.sat_list.len(), 0);
1300        let skipped_records = self.diagnostics.skips.len();
1301
1302        let header = Sp3Header {
1303            version,
1304            data_type,
1305            num_epochs: self.epochs.len() as u64,
1306            coordinate_system: self.coordinate_system,
1307            orbit_type: self.orbit_type,
1308            agency: self.agency,
1309            gnss_week: self.gnss_week,
1310            seconds_of_week: self.seconds_of_week,
1311            epoch_interval_s: self.epoch_interval_s,
1312            mjd: self.mjd,
1313            mjd_fraction: self.mjd_fraction,
1314            time_system,
1315            time_scale,
1316            satellites: self.sat_list,
1317            satellite_accuracy_codes,
1318        };
1319
1320        Ok(Sp3 {
1321            header,
1322            epochs: self.epochs,
1323            declared_num_epochs: self.num_epochs,
1324            declared_start_j2000_s: self.declared_start_j2000_s,
1325            terminal_record: self.terminal_record,
1326            satellite_header_lines: self.satellite_header_lines,
1327            accuracy_header_lines: self.accuracy_header_lines,
1328            time_system_header_lines: self.pc_count as usize,
1329            float_header_lines: self.float_header_lines,
1330            integer_header_lines: self.integer_header_lines,
1331            header_comment_lines: self.header_comment_lines,
1332            declared_satellite_count: self.declared_satellite_count,
1333            declared_satellite_tokens: self.declared_satellite_tokens,
1334            epoch_position_tokens: self.epoch_position_tokens,
1335            epoch_velocity_tokens: self.epoch_velocity_tokens,
1336            epoch_state_record_sequence: self.epoch_state_record_sequence,
1337            epoch_j2000_s: self.epoch_j2000_s,
1338            states: self.states,
1339            interp_raw: self.interp_raw,
1340            comments: self.comments,
1341            skipped_records,
1342        })
1343    }
1344}
1345
1346/// Best-effort parse of the civil start epoch carried on SP3 header line 1.
1347///
1348/// Exact validation treats `None` as an integrity failure. Keeping this helper
1349/// non-fallible preserves the long-standing permissive behavior of `Sp3::parse`
1350/// for callers that only consume epoch records.
1351fn parse_declared_start_j2000_s(line: &str) -> Option<f64> {
1352    let year = field(line, 3, 7).trim().parse::<i64>().ok()?;
1353    let month = field(line, 8, 10).trim().parse::<i64>().ok()?;
1354    let day = field(line, 11, 13).trim().parse::<i64>().ok()?;
1355    let hour = field(line, 14, 16).trim().parse::<i64>().ok()?;
1356    let minute = field(line, 17, 19).trim().parse::<i64>().ok()?;
1357    let second = field(line, 20, 31).trim().parse::<f64>().ok()?;
1358    let civil = validate::civil_datetime_with_second_policy(
1359        year,
1360        month,
1361        day,
1362        hour,
1363        minute,
1364        second,
1365        validate::CivilSecondPolicy::UtcLike,
1366    )
1367    .ok()?;
1368    Some(j2000_seconds(
1369        civil.year as i32,
1370        civil.month as i32,
1371        civil.day as i32,
1372        civil.hour as i32,
1373        civil.minute as i32,
1374        civil.second,
1375    ))
1376}
1377
1378/// Parse a fixed-column float coordinate, mapping failures to a parse error
1379/// that names the offending text.
1380fn parse_coord(line: &str, start: usize, end: usize) -> Result<f64> {
1381    let raw = field(line, start, end).trim();
1382    let value = strict_f64(raw, "coordinate").map_err(|error| map_field_error(error, line))?;
1383    exact_in_field(
1384        value,
1385        Some(RECORD_VALUE_WIDTH),
1386        RECORD_VALUE_DECIMALS,
1387        "coordinate",
1388        line,
1389    )
1390}
1391
1392/// Reject a value the record's fixed-column field cannot re-emit unchanged.
1393///
1394/// Every parsed value is written back through the same `Fw.d` format, so one
1395/// carrying more precision than the field expresses - or one too wide for its
1396/// columns - re-parses as a different number and the parse -> write -> parse
1397/// identity breaks. This is the same rule the satellite-list check applies:
1398/// never accept a record the writer cannot reproduce. `width` is `None` for a
1399/// field the format does not column-bound.
1400///
1401/// Non-finite values are left to the callers that already model them: the
1402/// record fields reject them through [`strict_f64`], and the permissive header
1403/// path carries them to exact validation, which reports them as the typed
1404/// non-finite start-metadata / cadence integrity failures.
1405fn exact_in_field(
1406    value: f64,
1407    width: Option<usize>,
1408    decimals: usize,
1409    what: &str,
1410    line: &str,
1411) -> Result<f64> {
1412    if !value.is_finite() {
1413        return Ok(value);
1414    }
1415    let text = format!("{value:.decimals$}");
1416    let overflows = width.is_some_and(|width| text.len() > width);
1417    if overflows || text.parse::<f64>() != Ok(value) {
1418        let field = match width {
1419            Some(width) => format!("F{width}.{decimals}"),
1420            None => format!("F.{decimals}"),
1421        };
1422        return Err(Error::Parse(format!(
1423            "SP3 {what} {value} is not representable in its {field} field in {line:?}"
1424        )));
1425    }
1426    Ok(value)
1427}
1428
1429/// Parse the clock column (chars 46..60). Returns `None` for the bad-clock
1430/// sentinel `999999.999999` or an absent/blank field; `Some(us)` otherwise.
1431fn parse_clock_us(line: &str) -> Result<Option<f64>> {
1432    if line.len() <= 46 {
1433        return Ok(None);
1434    }
1435    let raw = field(line, 46, 60).trim();
1436    if raw.is_empty() {
1437        return Ok(None);
1438    }
1439    let value = strict_f64(raw, "clock").map_err(|error| map_field_error(error, line))?;
1440    // Sentinel: any value at or beyond the bad-clock magnitude is "no estimate".
1441    if value.abs() >= BAD_CLOCK_US {
1442        return Ok(None);
1443    }
1444    exact_in_field(
1445        value,
1446        Some(RECORD_VALUE_WIDTH),
1447        RECORD_VALUE_DECIMALS,
1448        "clock",
1449        line,
1450    )
1451    .map(Some)
1452}
1453
1454fn map_field_error(error: validate::FieldError, line: &str) -> Error {
1455    Error::Parse(format!("SP3 {error} in {line:?}"))
1456}
1457
1458/// Parse the four status flags from their fixed columns (SP3-c/-d shared
1459/// layout): clock-event col 74 = `E`, clock-prediction col 75 = `P`,
1460/// maneuver col 78 = `M`, orbit-prediction col 79 = `P`.
1461fn parse_flags(line: &str) -> Sp3Flags {
1462    let at = |col: usize, want: char| -> bool { char_at(line, col) == Some(want) };
1463    Sp3Flags {
1464        clock_event: at(74, 'E'),
1465        clock_predicted: at(75, 'P'),
1466        maneuver: at(78, 'M'),
1467        orbit_predicted: at(79, 'P'),
1468    }
1469}
1470
1471/// Parse a 3-char SV token (e.g. `G01`, `C30`, or a bare `  1` in SP3-a) into a
1472/// [`GnssSatelliteId`]. Returns `None` on an unrecognized token.
1473fn parse_sv_token(token: &str, version: Option<Sp3Version>) -> Option<GnssSatelliteId> {
1474    let token = token.trim();
1475    if token.is_empty() {
1476        return None;
1477    }
1478    let first = token.chars().next()?;
1479    if first.is_ascii_digit() {
1480        // SP3-a GPS-only: bare numeric PRN, optionally space-padded.
1481        if matches!(version, Some(Sp3Version::A)) || version.is_none() {
1482            let prn = token.parse::<u8>().ok()?;
1483            if !is_valid_prn(GnssSystem::Gps, prn) {
1484                return None;
1485            }
1486            return GnssSatelliteId::new(GnssSystem::Gps, prn).ok();
1487        }
1488        return None;
1489    }
1490    token.parse::<GnssSatelliteId>().ok()
1491}
1492
1493/// Pull and parse the next whitespace-delimited field from an iterator.
1494fn next_field<T: std::str::FromStr>(
1495    it: &mut std::str::SplitWhitespace<'_>,
1496    what: &str,
1497) -> Result<T> {
1498    let tok = it
1499        .next()
1500        .ok_or_else(|| Error::Parse(format!("SP3 missing {what}")))?;
1501    tok.parse::<T>()
1502        .map_err(|_| Error::Parse(format!("SP3 {what} {tok:?} unparsable")))
1503}
1504
1505mod combine;
1506mod continuity;
1507mod exact;
1508mod interp;
1509mod interpolant;
1510mod interpolant_store;
1511mod provenance;
1512mod samples;
1513mod verify;
1514mod write;
1515
1516pub use combine::{
1517    align_clock_reference, clock_reference_offset, merge, AgreementMetric, CellProvenance,
1518    CellSelection, ClockReferenceOffset, ContributorCoverage, EpochAgreement, MergeCombine,
1519    MergeContinuityReport, MergeContinuityViolation, MergeFlag, MergeOptions, MergePrecedenceScope,
1520    MergeProvenance, MergeReport, OutlierRejectOptions, PrecedenceTransition, ProvenanceMode,
1521    Sp3FrameLabelSet, Sp3FrameReconciliation, Sp3FrameReconciliationMethod,
1522    Sp3FrameReconciliationOptions, TransitionReason,
1523};
1524pub use continuity::{
1525    check_continuity, ContinuityCheck, ContinuityDefect, ContinuityOptions, ContinuityReport,
1526    OrbitClass, SpeedBound,
1527};
1528pub use exact::{
1529    parse_exact_sp3, validate_exact_sp3, ExactSp3Coverage, ExactSp3Request, ExactSp3ValidationError,
1530};
1531pub use interpolant::{PreciseEphemerisInterpolant, PreciseInterpolantError};
1532pub use interpolant_store::{
1533    precise_interpolant_store_checksum64, MmapPreciseEphemerisInterpolant,
1534    PreciseInterpolantStoreError,
1535};
1536pub use provenance::{
1537    Sp3ArtifactIdentity, Sp3MergeInputIdentity, Sp3MergeInputIdentityError,
1538    SP3_MERGE_INPUT_ID_PREFIX, SP3_MERGE_INPUT_SCHEMA_VERSION,
1539};
1540pub use samples::{
1541    sp3_ecef_state_to_eci, PreciseEphemerisSample, PreciseEphemerisSamples,
1542    PreciseEphemerisStateSample, PreciseSamplesError,
1543};
1544pub use verify::{
1545    compare_position_series, InterpolationComparison, InterpolationDivergence, ReferenceState,
1546};
1547
1548#[cfg(all(test, sidereon_repo_tests))]
1549mod tests;