Skip to main content

sidereon_core/rinex_obs/
mod.rs

1//! RINEX 3.0x/4.0x observation-file parser and single-frequency pseudorange
2//! extraction.
3//!
4//! Parses a RINEX **version 3 or 4** observation file (`OBSERVATION DATA`) into a
5//! typed [`RinexObs`] product: the header (including the surveyed
6//! [`ObsHeader::approx_position_m`] a-priori receiver position and optional
7//! [`ObsHeader::antenna_delta_hen_m`] antenna offset), the per-constellation
8//! observation-code table, and the per-epoch
9//! satellite→observation values. A pseudorange helper ([`pseudoranges`]) then
10//! selects one single-frequency code per system and yields the
11//! `(satellite, range_m)` pairs the single-point-positioning solver consumes.
12//!
13//! # Build vs adopt
14//!
15//! Like the SP3 and RINEX-NAV readers, this is a hand-rolled, fixed-column text
16//! reader in the house style rather than an adoption of the MPL-2.0 `rinex`
17//! crate (which would pull a parallel time stack and identifier set into the
18//! GNSS layer). The grammar is small and fully specified.
19//!
20//! It is a **deterministic byte-to-record** parse of a fixed-column text format,
21//! not a float recipe; there is no 0-ULP claim here. The pseudorange values are
22//! the file's own ASCII decimals parsed to `f64` and carried through unchanged.
23//!
24//! # Layout (RINEX 3)
25//!
26//! - Header records are `cols 0..60` content + `cols 60..80` label. The
27//!   load-bearing ones are `RINEX VERSION / TYPE` (must be observation, major 3),
28//!   `APPROX POSITION XYZ`, `ANTENNA: DELTA H/E/N`, `SYS / # / OBS TYPES` (the
29//!   per-system code list, order-preserving, with continuation lines),
30//!   `SYS / SCALE FACTOR`, `SYS / PHASE SHIFT`, `TIME OF FIRST OBS` (+ time
31//!   system), `INTERVAL`, and the optional `GLONASS SLOT / FRQ #`.
32//! - The body is per-epoch: a `>`-prefixed epoch line carrying the civil time,
33//!   an event flag, and the satellite count, then one logical record per
34//!   satellite with each observation as a 16-column `F14.3` value + LLI + SSI
35//!   field, in the order the system's `SYS / # / OBS TYPES` list declares. A
36//!   logical satellite record may wrap across 80-column continuation lines.
37
38use std::borrow::Cow;
39use std::collections::BTreeMap;
40
41use crate::astro::time::model::TimeScale;
42
43use crate::format::columns::{raw_field as field, raw_field_from};
44use crate::format::{Diagnostics, RecordRef, Skip, SkipReason};
45use crate::frequencies::{
46    rinex_band_frequency_hz, rinex_observation_frequency_hz, rinex_observation_wavelength_m,
47};
48use crate::id::{GnssSatelliteId, GnssSystem};
49use crate::rinex_common::time_scale_label;
50use crate::rinex_nav::valid_glonass_frequency_channel;
51use crate::validate::{self, FieldError};
52use crate::{Error, Result};
53
54/// Width of one RINEX-3 observation field (`F14.3` value + LLI + SSI).
55const OBS_FIELD_WIDTH: usize = 16;
56/// Width of the numeric part of one observation field (`F14.3`).
57const OBS_VALUE_WIDTH: usize = 14;
58
59/// A civil epoch as it appears on a RINEX observation epoch line, in the file's
60/// own time scale (no leap-second shifting). This is the natural boundary for
61/// the solver, which derives seconds-of-J2000 / second-of-day / day-of-year
62/// from the civil components.
63#[derive(Debug, Clone, Copy, PartialEq)]
64pub struct ObsEpochTime {
65    /// Four-digit calendar year.
66    pub year: i32,
67    /// Calendar month, 1..=12.
68    pub month: u8,
69    /// Calendar day of month, 1..=31.
70    pub day: u8,
71    /// Hour of day, 0..=23.
72    pub hour: u8,
73    /// Minute of hour, 0..=59.
74    pub minute: u8,
75    /// Seconds of minute (fractional), 0.0..60.0.
76    pub second: f64,
77}
78
79/// One reconstructed observation: a value (or blank) with its loss-of-lock and
80/// signal-strength indicators.
81#[derive(Debug, Clone, Copy, PartialEq)]
82pub struct ObsValue {
83    /// The observed value (meters for code/`C` observables, cycles for `L`,
84    /// etc.), or `None` when the field was blank.
85    pub value: Option<f64>,
86    /// Loss-of-lock indicator (RINEX LLI), `None` when blank.
87    pub lli: Option<u8>,
88    /// Signal-strength indicator (RINEX SSI), `None` when blank.
89    pub ssi: Option<u8>,
90}
91
92/// One `SYS / PHASE SHIFT` header record.
93#[derive(Debug, Clone, PartialEq)]
94pub struct ObsPhaseShift {
95    /// Constellation the phase-shift record applies to.
96    pub system: GnssSystem,
97    /// RINEX carrier observable code, e.g. `L1C`.
98    pub code: String,
99    /// Phase correction in carrier cycles.
100    pub correction_cycles: f64,
101    /// Optional satellite restriction. Empty means the correction applies to
102    /// all satellites of the system/code.
103    pub satellites: Vec<GnssSatelliteId>,
104}
105
106/// One `SYS / SCALE FACTOR` header record.
107#[derive(Debug, Clone, PartialEq)]
108pub struct ObsScaleFactor {
109    /// Constellation the scale-factor record applies to.
110    pub system: GnssSystem,
111    /// Factor to divide stored observations by before use.
112    pub factor: f64,
113    /// Observation codes affected. Empty means all codes for the system.
114    pub codes: Vec<String>,
115}
116
117/// One `PGM / RUN BY / DATE` header record.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct PgmRunByDate {
120    /// Program name, trimmed from A20.
121    pub program: String,
122    /// Run-by agency/user, trimmed from A20.
123    pub run_by: String,
124    /// Date string, trimmed from A20.
125    pub date: String,
126}
127
128/// One `REC # / TYPE / VERS` header record.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct ReceiverInfo {
131    /// Receiver serial number, trimmed from A20.
132    pub number: String,
133    /// Receiver type, trimmed from A20.
134    pub receiver_type: String,
135    /// Receiver firmware/version, trimmed from A20.
136    pub version: String,
137}
138
139/// One `ANT # / TYPE` header record.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct AntennaInfo {
142    /// Antenna serial number, trimmed from A20.
143    pub number: String,
144    /// Antenna type, trimmed from A20.
145    pub antenna_type: String,
146}
147
148/// `LEAP SECONDS` header record retained from an observation file.
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub struct ObsLeapSeconds {
151    /// Current leap-second count.
152    pub current: i64,
153    /// Future/past delta field, if present.
154    pub delta_future: Option<i64>,
155    /// GPS week field, if present.
156    pub week: Option<i64>,
157    /// Day field, if present.
158    pub day: Option<i64>,
159}
160
161/// One epoch record: the civil time, the event flag, and the per-satellite
162/// observation values (aligned to that system's `SYS / # / OBS TYPES` order).
163#[derive(Debug, Clone, PartialEq)]
164pub struct ObsEpoch {
165    /// Civil epoch in the header time scale.
166    pub epoch: ObsEpochTime,
167    /// Epoch flag: 0 = OK, 1 = power failure, >1 = an event record (skipped).
168    pub flag: u8,
169    /// Optional receiver clock offset from the epoch line, seconds.
170    pub rcv_clock_offset_s: Option<f64>,
171    /// Optional RINEX 4 epoch picosecond extension.
172    pub epoch_picoseconds: Option<u32>,
173    /// Satellite/special-record count declared on the epoch line.
174    pub declared_record_count: usize,
175    /// Number of special records declared by an event epoch.
176    pub special_record_count: usize,
177    /// Satellite → observation values, ascending satellite id. The value vector
178    /// is index-aligned to [`ObsHeader::obs_codes`] for that satellite's system.
179    pub sats: BTreeMap<GnssSatelliteId, Vec<ObsValue>>,
180}
181
182/// Parsed RINEX 3/4 observation header.
183#[derive(Debug, Clone, PartialEq)]
184pub struct ObsHeader {
185    /// The full RINEX version (e.g. `3.05`); the major must be 3 or 4.
186    pub version: f64,
187    /// The surveyed a-priori receiver position (ECEF meters), if the file
188    /// carries an `APPROX POSITION XYZ` record.
189    pub approx_position_m: Option<[f64; 3]>,
190    /// Antenna reference-point offset from the marker in the RINEX
191    /// height/east/north convention (meters), if the file carries an
192    /// `ANTENNA: DELTA H/E/N` record.
193    pub antenna_delta_hen_m: Option<[f64; 3]>,
194    /// Per-constellation observation-code list, in declared order.
195    pub obs_codes: BTreeMap<GnssSystem, Vec<String>>,
196    /// Program/run-by/date header record.
197    pub program_run_by_date: Option<PgmRunByDate>,
198    /// Header comments retained in file order.
199    pub comments: Vec<String>,
200    /// Marker number, if present.
201    pub marker_number: Option<String>,
202    /// Marker type, if present.
203    pub marker_type: Option<String>,
204    /// Observer name, if present.
205    pub observer: Option<String>,
206    /// Agency name, if present.
207    pub agency: Option<String>,
208    /// Receiver information, if present.
209    pub receiver: Option<ReceiverInfo>,
210    /// Antenna information, if present.
211    pub antenna: Option<AntennaInfo>,
212    /// Nominal epoch spacing in seconds (`INTERVAL`), if present.
213    pub interval_s: Option<f64>,
214    /// First observation epoch and its time system (`TIME OF FIRST OBS`).
215    pub time_of_first_obs: Option<(ObsEpochTime, TimeScale)>,
216    /// Last observation epoch and its time system (`TIME OF LAST OBS`).
217    pub time_of_last_obs: Option<(ObsEpochTime, TimeScale)>,
218    /// Declared distinct-satellite count.
219    pub n_satellites: Option<usize>,
220    /// Declared per-satellite, per-code observation counts.
221    pub prn_obs_counts: BTreeMap<GnssSatelliteId, Vec<Option<usize>>>,
222    /// Carrier phase-shift records (`SYS / PHASE SHIFT`), in header order.
223    pub phase_shifts: Vec<ObsPhaseShift>,
224    /// Observation scale-factor records (`SYS / SCALE FACTOR`), in header order.
225    pub scale_factors: Vec<ObsScaleFactor>,
226    /// GLONASS slot → frequency channel map (`GLONASS SLOT / FRQ #`), if present.
227    pub glonass_slots: BTreeMap<u8, i8>,
228    /// GLONASS code-phase bias/alignment record.
229    pub glonass_cod_phs_bis: Option<Vec<(String, f64)>>,
230    /// Signal-strength unit, e.g. `DBHZ`.
231    pub signal_strength_unit: Option<String>,
232    /// Observation-header leap-second record.
233    pub leap_seconds: Option<ObsLeapSeconds>,
234    /// Marker (station) name, if present.
235    pub marker_name: Option<String>,
236    /// Header labels retained only as drop-on-rewrite disclosure.
237    pub unretained_header_labels: Vec<String>,
238}
239
240/// A parsed RINEX 3 observation product.
241///
242/// Construct with [`RinexObs::parse`]. Epochs are stored in file order; access
243/// the header via [`RinexObs::header`], the epochs via [`RinexObs::epochs`], and
244/// per-system code lists via [`RinexObs::obs_codes`].
245#[derive(Debug, Clone, PartialEq)]
246pub struct RinexObs {
247    /// The parsed header.
248    pub header: ObsHeader,
249    /// Epoch records in file order. Event records (flag > 1) are retained with
250    /// an empty satellite map so epoch indices stay stable.
251    pub epochs: Vec<ObsEpoch>,
252    /// Count of records skipped because their satellite token did not parse to a
253    /// representable [`GnssSatelliteId`]: an out-of-range entry in the `GLONASS
254    /// SLOT / FRQ #` header table, or an unknown/out-of-range satellite record
255    /// inside an epoch (e.g. an extended GLONASS slot like `R28` beyond the
256    /// engine's PRN cap). One such record is skipped rather than aborting the
257    /// whole file, mirroring [`crate::astro::sgp4::TleFile::skipped`].
258    pub skipped_records: usize,
259}
260
261impl RinexObs {
262    /// Parse RINEX 3/4 observation text into a typed product.
263    ///
264    /// Returns [`Error::Parse`] if the file is not observation data, is not RINEX
265    /// major version 3 or 4, is missing a required header record, or has a malformed
266    /// epoch record.
267    pub fn parse(text: &str) -> Result<Self> {
268        let mut parser = Parser::new();
269        let mut lines = text.lines();
270        parser.parse_header(&mut lines)?;
271        parser.parse_body(&mut lines.peekable())?;
272        parser.finish()
273    }
274
275    /// The parsed header.
276    pub fn header(&self) -> &ObsHeader {
277        &self.header
278    }
279
280    /// The epoch records, in file order.
281    pub fn epochs(&self) -> &[ObsEpoch] {
282        &self.epochs
283    }
284
285    /// The observation-code list for a constellation, in declared order.
286    pub fn obs_codes(&self, sys: GnssSystem) -> Option<&[String]> {
287        self.header.obs_codes.get(&sys).map(Vec::as_slice)
288    }
289}
290
291impl core::str::FromStr for RinexObs {
292    type Err = Error;
293
294    fn from_str(s: &str) -> Result<Self> {
295        Self::parse(s)
296    }
297}
298
299/// Per-system single-frequency code-selection policy.
300///
301/// For each constellation, an ordered list of observation codes to try; the
302/// first one present at an epoch is used. Build the version-aware defaults with
303/// [`SignalPolicy::default_for`] and adjust per system with
304/// [`SignalPolicy::with_override`].
305#[derive(Debug, Clone, PartialEq)]
306pub struct SignalPolicy {
307    /// Ordered preference list of observation codes per constellation.
308    pub codes: BTreeMap<GnssSystem, Vec<String>>,
309}
310
311impl SignalPolicy {
312    /// The default single-frequency pseudorange policy:
313    ///
314    /// - GPS `C1C` (L1 C/A),
315    /// - Galileo `C1C` then `C1X` (E1),
316    /// - BeiDou `C1I` for RINEX 3.02, `C2I` for 3.01 and 3.03+ (the B1I code
317    ///   label changed between minor versions),
318    /// - GLONASS `C1C` (G1 C/A).
319    ///
320    /// `version` is the file's RINEX version, which selects the BeiDou default.
321    pub fn default_for(version: f64) -> Result<Self> {
322        validate_finite_input(version, "version")?;
323        let mut codes = BTreeMap::new();
324        codes.insert(GnssSystem::Gps, vec!["C1C".to_string()]);
325        codes.insert(
326            GnssSystem::Galileo,
327            vec!["C1C".to_string(), "C1X".to_string()],
328        );
329        // BeiDou B1I label history: C2I in 3.01, relabelled band 1 (C1I) in
330        // 3.02, then reverted to C2I in 3.03 and later. Only the narrow 3.02
331        // window prefers C1I; every other version prefers C2I. Offer both, with
332        // the version-appropriate one first.
333        let beidou = if (3.015..3.025).contains(&version) {
334            vec!["C1I".to_string(), "C2I".to_string()]
335        } else {
336            vec!["C2I".to_string(), "C1I".to_string()]
337        };
338        codes.insert(GnssSystem::BeiDou, beidou);
339        codes.insert(GnssSystem::Glonass, vec!["C1C".to_string()]);
340        Ok(Self { codes })
341    }
342
343    /// Replace the preference list for one constellation.
344    pub fn with_override(mut self, sys: GnssSystem, codes: Vec<String>) -> Self {
345        self.codes.insert(sys, codes);
346        self
347    }
348}
349
350/// Optional per-system observation-code filter.
351///
352/// An empty filter keeps every parsed system and code. A non-empty filter keeps
353/// only listed systems; for each listed system, an empty code vector keeps every
354/// code while a non-empty vector keeps only those codes, in header order.
355#[derive(Debug, Clone, Default, PartialEq, Eq)]
356pub struct ObservationFilter {
357    /// Per-constellation code allow-list.
358    pub codes: BTreeMap<GnssSystem, Vec<String>>,
359}
360
361impl ObservationFilter {
362    /// Construct an empty filter that keeps every parsed observation.
363    pub fn all() -> Self {
364        Self::default()
365    }
366
367    /// Construct a filter from `(system, codes)` entries.
368    pub fn from_entries<I>(entries: I) -> Self
369    where
370        I: IntoIterator<Item = (GnssSystem, Vec<String>)>,
371    {
372        Self {
373            codes: entries.into_iter().collect(),
374        }
375    }
376
377    fn allowed_codes(&self, system: GnssSystem) -> Option<&[String]> {
378        if self.codes.is_empty() {
379            Some(&[])
380        } else {
381            self.codes.get(&system).map(Vec::as_slice)
382        }
383    }
384}
385
386/// Observation kind inferred from the RINEX observation-code leading letter.
387#[derive(Debug, Clone, Copy, PartialEq, Eq)]
388pub enum ObservationKind {
389    /// Code pseudorange (`C*`), meters.
390    Pseudorange,
391    /// Carrier phase (`L*`), cycles.
392    CarrierPhase,
393    /// Doppler (`D*`), hertz.
394    Doppler,
395    /// Signal strength (`S*`), dB-Hz.
396    SignalStrength,
397    /// Unknown or unsupported leading code letter.
398    Unknown,
399}
400
401impl ObservationKind {
402    /// Infer the kind from a RINEX observation code.
403    pub fn from_code(code: &str) -> Self {
404        match code.as_bytes().first().copied() {
405            Some(b'C') => Self::Pseudorange,
406            Some(b'L') => Self::CarrierPhase,
407            Some(b'D') => Self::Doppler,
408            Some(b'S') => Self::SignalStrength,
409            _ => Self::Unknown,
410        }
411    }
412
413    /// Stable lower-case API label.
414    pub fn as_str(self) -> &'static str {
415        match self {
416            Self::Pseudorange => "pseudorange",
417            Self::CarrierPhase => "carrier_phase",
418            Self::Doppler => "doppler",
419            Self::SignalStrength => "signal_strength",
420            Self::Unknown => "unknown",
421        }
422    }
423
424    /// Stable units label for the observation kind.
425    pub fn units_str(self) -> &'static str {
426        match self {
427            Self::Pseudorange => "meters",
428            Self::CarrierPhase => "cycles",
429            Self::Doppler => "hz",
430            Self::SignalStrength => "db_hz",
431            Self::Unknown => "unknown",
432        }
433    }
434}
435
436/// One labelled raw RINEX observation value.
437#[derive(Debug, Clone, PartialEq)]
438pub struct ObservationValueRow {
439    /// RINEX observation code, e.g. `C1C`, `L2W`, `D1C`.
440    pub code: String,
441    /// Kind inferred from the code's leading letter.
442    pub kind: ObservationKind,
443    /// Parsed observation value, or `None` for a blank field.
444    pub value: Option<f64>,
445    /// RINEX loss-of-lock indicator.
446    pub lli: Option<u8>,
447    /// RINEX signal-strength indicator.
448    pub ssi: Option<u8>,
449}
450
451/// One carrier-phase observation with its carrier metadata.
452#[derive(Debug, Clone, PartialEq)]
453pub struct CarrierPhaseRow {
454    /// RINEX carrier observation code, e.g. `L1C`.
455    pub code: String,
456    /// Phase in cycles as recorded in the RINEX observation body.
457    pub value_cycles: Option<f64>,
458    /// RINEX loss-of-lock indicator.
459    pub lli: Option<u8>,
460    /// RINEX signal-strength indicator.
461    pub ssi: Option<u8>,
462    /// Carrier frequency in hertz when known.
463    pub frequency_hz: Option<f64>,
464    /// Carrier wavelength in meters when known.
465    pub wavelength_m: Option<f64>,
466    /// Carrier phase in meters when both value and frequency are known.
467    pub value_m: Option<f64>,
468    /// Reported `SYS / PHASE SHIFT` correction in cycles. RINEX 3 stores
469    /// already-aligned phase observations, so this correction is metadata for
470    /// reconstructing originals and is not re-applied here.
471    pub phase_shift_cycles: f64,
472}
473
474/// Return labelled raw observation rows for one epoch, grouped by satellite.
475pub fn observation_values(
476    obs: &RinexObs,
477    epoch: &ObsEpoch,
478    filter: &ObservationFilter,
479) -> Result<Vec<(GnssSatelliteId, Vec<ObservationValueRow>)>> {
480    let mut out = Vec::new();
481    for (sat, values) in epoch
482        .sats
483        .iter()
484        .filter(|(sat, _)| filter.allowed_codes(sat.system).is_some())
485    {
486        let allowed_codes = filter
487            .allowed_codes(sat.system)
488            .expect("filter presence checked");
489        let Some(code_list) = obs.header.obs_codes.get(&sat.system) else {
490            continue;
491        };
492        let mut rows = Vec::new();
493        for (code, value) in code_list.iter().zip(values.iter()) {
494            if !allowed_codes.is_empty() && !allowed_codes.iter().any(|c| c == code) {
495                continue;
496            }
497            if let Some(value) = value.value {
498                validate_finite_input(value, "observation.value")?;
499            }
500            let kind = ObservationKind::from_code(code);
501            rows.push(ObservationValueRow {
502                code: code.clone(),
503                kind,
504                value: value.value,
505                lli: value.lli,
506                ssi: value.ssi,
507            });
508        }
509        out.push((*sat, rows));
510    }
511    Ok(out)
512}
513
514/// Return carrier-phase rows for one epoch, grouped by satellite.
515pub fn carrier_phase_rows(
516    obs: &RinexObs,
517    epoch: &ObsEpoch,
518    filter: &ObservationFilter,
519) -> Result<Vec<(GnssSatelliteId, Vec<CarrierPhaseRow>)>> {
520    validate_finite_input(obs.header.version, "version")?;
521    let mut out = Vec::new();
522    for (sat, rows) in observation_values(obs, epoch, filter)? {
523        let phases = rows
524            .into_iter()
525            .filter(|row| row.kind == ObservationKind::CarrierPhase)
526            .map(|row| carrier_phase_row(obs, sat, row))
527            .collect::<Result<Vec<_>>>()?;
528        out.push((sat, phases));
529    }
530    Ok(out)
531}
532
533/// Carrier frequency in hertz for a system and RINEX band digit.
534///
535/// GLONASS G1/G2 carriers require the FDMA channel number from the observation
536/// file's `GLONASS SLOT / FRQ #` records.
537pub fn band_frequency_hz(
538    system: GnssSystem,
539    band: char,
540    glonass_channel: Option<i8>,
541) -> Option<f64> {
542    rinex_band_frequency_hz(system, band, glonass_channel)
543}
544
545/// Carrier frequency in hertz for a system and full RINEX observation code.
546pub fn observation_frequency_hz(
547    system: GnssSystem,
548    code: &str,
549    rinex_version: f64,
550    glonass_channel: Option<i8>,
551) -> Result<Option<f64>> {
552    validate_finite_input(rinex_version, "version")?;
553    Ok(rinex_observation_frequency_hz(
554        system,
555        code,
556        rinex_version,
557        glonass_channel,
558    ))
559}
560
561fn carrier_phase_row(
562    obs: &RinexObs,
563    sat: GnssSatelliteId,
564    row: ObservationValueRow,
565) -> Result<CarrierPhaseRow> {
566    let glonass_channel = obs.header.glonass_slots.get(&sat.prn).copied();
567    let frequency_hz =
568        observation_frequency_hz(sat.system, &row.code, obs.header.version, glonass_channel)?;
569    let phase_shift_cycles = phase_shift_cycles(obs, sat, &row.code);
570    let value_cycles = row.value;
571    let wavelength_m =
572        rinex_observation_wavelength_m(sat.system, &row.code, obs.header.version, glonass_channel);
573    let value_m = match value_cycles.zip(wavelength_m) {
574        Some((cycles, lambda)) => {
575            let value_m = cycles * lambda;
576            validate_finite_input(value_m, "carrier_phase.value_m")?;
577            Some(value_m)
578        }
579        None => None,
580    };
581    Ok(CarrierPhaseRow {
582        code: row.code,
583        value_cycles,
584        lli: row.lli,
585        ssi: row.ssi,
586        frequency_hz,
587        wavelength_m,
588        value_m,
589        phase_shift_cycles,
590    })
591}
592
593fn phase_shift_cycles(obs: &RinexObs, sat: GnssSatelliteId, code: &str) -> f64 {
594    let mut system_wide = None;
595    for shift in obs.header.phase_shifts.iter().rev() {
596        if shift.system != sat.system || shift.code != code {
597            continue;
598        }
599        if shift.satellites.is_empty() {
600            if system_wide.is_none() {
601                system_wide = Some(shift.correction_cycles);
602            }
603        } else if shift.satellites.contains(&sat) {
604            return shift.correction_cycles;
605        }
606    }
607    system_wide.unwrap_or(0.0)
608}
609
610/// Extract single-frequency pseudoranges for one epoch under a [`SignalPolicy`].
611///
612/// For each satellite in the epoch, the first code in that system's preference
613/// list whose value is present at the epoch is used. Satellites whose system has
614/// no policy entry, or that lack every preferred code, are skipped. The result
615/// is the ascending-id `(satellite, range_m)` list the solver consumes.
616pub fn pseudoranges(
617    obs: &RinexObs,
618    epoch: &ObsEpoch,
619    policy: &SignalPolicy,
620) -> Result<Vec<(GnssSatelliteId, f64)>> {
621    let mut out = Vec::new();
622    for (sat, values) in &epoch.sats {
623        let Some(prefs) = policy.codes.get(&sat.system) else {
624            continue;
625        };
626        let Some(code_list) = obs.header.obs_codes.get(&sat.system) else {
627            continue;
628        };
629        for code in prefs {
630            if let Some(idx) = code_list.iter().position(|c| c == code) {
631                if let Some(ObsValue {
632                    value: Some(range_m),
633                    ..
634                }) = values.get(idx)
635                {
636                    validate_finite_input(*range_m, "pseudorange_m")?;
637                    out.push((*sat, *range_m));
638                    break;
639                }
640            }
641        }
642    }
643    Ok(out)
644}
645
646/// Incremental RINEX 3 observation parser state.
647struct Parser {
648    version: Option<f64>,
649    is_observation: bool,
650    approx_position_m: Option<[f64; 3]>,
651    antenna_delta_hen_m: Option<[f64; 3]>,
652    obs_codes: BTreeMap<GnssSystem, Vec<String>>,
653    interval_s: Option<f64>,
654    time_of_first_obs: Option<(ObsEpochTime, TimeScale)>,
655    time_of_last_obs: Option<(ObsEpochTime, TimeScale)>,
656    program_run_by_date: Option<PgmRunByDate>,
657    comments: Vec<String>,
658    marker_number: Option<String>,
659    marker_type: Option<String>,
660    observer: Option<String>,
661    agency: Option<String>,
662    receiver: Option<ReceiverInfo>,
663    antenna: Option<AntennaInfo>,
664    n_satellites: Option<usize>,
665    prn_obs_counts: BTreeMap<GnssSatelliteId, Vec<Option<usize>>>,
666    phase_shifts: Vec<ObsPhaseShift>,
667    scale_factors: Vec<ObsScaleFactor>,
668    scale_factor_continuation: Option<ScaleFactorContinuation>,
669    glonass_slots: BTreeMap<u8, i8>,
670    glonass_slots_remaining: Option<usize>,
671    glonass_cod_phs_bis: Option<Vec<(String, f64)>>,
672    signal_strength_unit: Option<String>,
673    leap_seconds: Option<ObsLeapSeconds>,
674    marker_name: Option<String>,
675    unretained_header_labels: Vec<String>,
676    epochs: Vec<ObsEpoch>,
677    /// The constellation whose `SYS / # / OBS TYPES` list is currently being
678    /// filled (for continuation lines).
679    current_obs_sys: Option<GnssSystem>,
680    /// Number of codes still expected for `current_obs_sys`.
681    obs_codes_remaining: usize,
682    /// Forgiving-parse diagnostics: a GLONASS-slot or epoch satellite record
683    /// whose token does not parse to a representable [`GnssSatelliteId`] is
684    /// pushed here as a typed [`Skip`] rather than silently dropped. The public
685    /// [`RinexObs::skipped_records`] is derived from the skip count.
686    diagnostics: Diagnostics,
687}
688
689#[derive(Debug, Clone, Copy)]
690struct ScaleFactorContinuation {
691    remaining: usize,
692}
693
694impl Parser {
695    fn new() -> Self {
696        Self {
697            version: None,
698            is_observation: false,
699            approx_position_m: None,
700            antenna_delta_hen_m: None,
701            obs_codes: BTreeMap::new(),
702            interval_s: None,
703            time_of_first_obs: None,
704            time_of_last_obs: None,
705            program_run_by_date: None,
706            comments: Vec::new(),
707            marker_number: None,
708            marker_type: None,
709            observer: None,
710            agency: None,
711            receiver: None,
712            antenna: None,
713            n_satellites: None,
714            prn_obs_counts: BTreeMap::new(),
715            phase_shifts: Vec::new(),
716            scale_factors: Vec::new(),
717            scale_factor_continuation: None,
718            glonass_slots: BTreeMap::new(),
719            glonass_slots_remaining: None,
720            glonass_cod_phs_bis: None,
721            signal_strength_unit: None,
722            leap_seconds: None,
723            marker_name: None,
724            unretained_header_labels: Vec::new(),
725            epochs: Vec::new(),
726            current_obs_sys: None,
727            obs_codes_remaining: 0,
728            diagnostics: Diagnostics::new(),
729        }
730    }
731
732    /// Record a forgiving skip for a record whose satellite token is not a
733    /// representable [`GnssSatelliteId`], carrying the raw token as its identity.
734    fn push_unrepresentable_satellite_skip(&mut self, token: &str) {
735        self.diagnostics.push_skip(Skip {
736            at: RecordRef::default().with_satellite(token.trim()),
737            reason: SkipReason::UnrepresentableSatellite,
738        });
739    }
740
741    fn parse_header<'a, I: Iterator<Item = &'a str>>(&mut self, lines: &mut I) -> Result<()> {
742        let mut saw_end = false;
743        for raw in lines.by_ref() {
744            let line = raw.trim_end_matches(['\r', '\n']);
745            let label = raw_field_from(line, 60).trim();
746            match label {
747                "RINEX VERSION / TYPE" => self.parse_version(line)?,
748                "PGM / RUN BY / DATE" => self.parse_pgm_run_by_date(line),
749                "COMMENT" => self.comments.push(field(line, 0, 60).trim().to_string()),
750                "APPROX POSITION XYZ" => self.parse_approx_position(line)?,
751                "ANTENNA: DELTA H/E/N" => self.parse_antenna_delta(line)?,
752                "SYS / # / OBS TYPES" => self.parse_obs_types(line)?,
753                "SYS / SCALE FACTOR" => self.parse_scale_factor(line)?,
754                "SYS / PHASE SHIFT" => self.parse_phase_shift(line)?,
755                "TIME OF FIRST OBS" => self.parse_time_of_first_obs(line)?,
756                "TIME OF LAST OBS" => self.parse_time_of_last_obs(line)?,
757                "INTERVAL" => {
758                    self.interval_s = Some(strict_f64_field(line, 0, 10, "interval_s")?);
759                }
760                "GLONASS SLOT / FRQ #" => self.parse_glonass_slots(line)?,
761                "GLONASS COD/PHS/BIS" => self.parse_glonass_cod_phs_bis(line)?,
762                "SIGNAL STRENGTH UNIT" => {
763                    let unit = field(line, 0, 20).trim();
764                    if !unit.is_empty() {
765                        self.signal_strength_unit = Some(unit.to_string());
766                    }
767                }
768                "LEAP SECONDS" => self.parse_leap_seconds(line)?,
769                "# OF SATELLITES" => {
770                    self.n_satellites =
771                        Some(strict_int_field::<usize>(line, 0, 6, "n_satellites")?);
772                }
773                "PRN / # OF OBS" => self.parse_prn_obs_counts(line)?,
774                "MARKER NAME" => {
775                    let name = field(line, 0, 60).trim();
776                    if !name.is_empty() {
777                        self.marker_name = Some(name.to_string());
778                    }
779                }
780                "MARKER NUMBER" => {
781                    self.marker_number = optional_trimmed(line, 0, 20);
782                }
783                "MARKER TYPE" => {
784                    self.marker_type = optional_trimmed(line, 0, 20);
785                }
786                "OBSERVER / AGENCY" => {
787                    self.observer = optional_trimmed(line, 0, 20);
788                    self.agency = optional_trimmed(line, 20, 60);
789                }
790                "REC # / TYPE / VERS" => {
791                    self.receiver = Some(ReceiverInfo {
792                        number: field(line, 0, 20).trim().to_string(),
793                        receiver_type: field(line, 20, 40).trim().to_string(),
794                        version: field(line, 40, 60).trim().to_string(),
795                    });
796                }
797                "ANT # / TYPE" => {
798                    self.antenna = Some(AntennaInfo {
799                        number: field(line, 0, 20).trim().to_string(),
800                        antenna_type: field(line, 20, 40).trim().to_string(),
801                    });
802                }
803                "END OF HEADER" => {
804                    self.ensure_obs_type_count_complete(line)?;
805                    self.ensure_scale_factor_count_complete(line)?;
806                    saw_end = true;
807                    break;
808                }
809                // Every other header record is tolerated and surfaced to QC so
810                // callers know a rewrite will not carry it.
811                _ => {
812                    if !label.is_empty() {
813                        self.unretained_header_labels.push(label.to_string());
814                    }
815                }
816            }
817        }
818        if !saw_end {
819            return Err(Error::Parse("RINEX OBS header has no END OF HEADER".into()));
820        }
821        Ok(())
822    }
823
824    fn parse_version(&mut self, line: &str) -> Result<()> {
825        let version = field(line, 0, 20).trim();
826        let version = strict_f64_token(version, "version", line)?;
827        // The file type letter is at column 20; observation files carry 'O'.
828        let type_field = field(line, 20, 40);
829        self.is_observation =
830            type_field.trim_start().starts_with('O') || type_field.contains("OBSERVATION");
831        if !self.is_observation {
832            return Err(Error::Parse(format!(
833                "RINEX file is not observation data: {type_field:?}"
834            )));
835        }
836        if !matches!(version.floor() as i64, 3 | 4) {
837            return Err(Error::Parse(format!(
838                "RINEX OBS parser requires major version 3 or 4, got {version}"
839            )));
840        }
841        self.version = Some(version);
842        Ok(())
843    }
844
845    fn parse_approx_position(&mut self, line: &str) -> Result<()> {
846        let body = field(line, 0, 60);
847        self.approx_position_m = Some(strict_vec3_tokens(
848            body,
849            line,
850            [
851                "approx_position.x_m",
852                "approx_position.y_m",
853                "approx_position.z_m",
854            ],
855        )?);
856        Ok(())
857    }
858
859    fn parse_antenna_delta(&mut self, line: &str) -> Result<()> {
860        let body = field(line, 0, 60);
861        self.antenna_delta_hen_m = Some(strict_vec3_tokens(
862            body,
863            line,
864            [
865                "antenna_delta.height_m",
866                "antenna_delta.east_m",
867                "antenna_delta.north_m",
868            ],
869        )?);
870        Ok(())
871    }
872
873    fn parse_pgm_run_by_date(&mut self, line: &str) {
874        self.program_run_by_date = Some(PgmRunByDate {
875            program: field(line, 0, 20).trim().to_string(),
876            run_by: field(line, 20, 40).trim().to_string(),
877            date: field(line, 40, 60).trim().to_string(),
878        });
879    }
880
881    fn parse_obs_types(&mut self, line: &str) -> Result<()> {
882        // A new system line carries its letter at column 0 and the count at
883        // columns 3..6; a continuation line has a blank system field and only
884        // adds more codes to the current system.
885        let sys_field = field(line, 0, 1).trim();
886        if !sys_field.is_empty() {
887            self.ensure_obs_type_count_complete(line)?;
888            let letter = sys_field.chars().next().unwrap();
889            let system = GnssSystem::from_letter(letter).ok_or_else(|| {
890                Error::Parse(format!("RINEX OBS unknown system letter {letter:?}"))
891            })?;
892            let count = strict_int_field::<usize>(line, 3, 6, "obs_type_count")?;
893            self.current_obs_sys = Some(system);
894            self.obs_codes_remaining = count;
895            self.obs_codes.entry(system).or_default();
896        }
897        let Some(system) = self.current_obs_sys else {
898            return Ok(());
899        };
900        // Codes occupy 4-wide fields (" CCC") from column 7; collect up to the
901        // remaining count.
902        let codes_section = field(line, 7, 60);
903        let list = self.obs_codes.get_mut(&system).expect("system inserted");
904        for tok in codes_section.split_whitespace() {
905            if self.obs_codes_remaining == 0 {
906                return Err(Error::Parse(format!(
907                    "RINEX OBS {system} SYS / # / OBS TYPES lists more codes than declared in {line:?}"
908                )));
909            }
910            list.push(tok.to_string());
911            self.obs_codes_remaining -= 1;
912        }
913        Ok(())
914    }
915
916    fn ensure_obs_type_count_complete(&self, line: &str) -> Result<()> {
917        if self.obs_codes_remaining == 0 {
918            return Ok(());
919        }
920        let Some(system) = self.current_obs_sys else {
921            return Ok(());
922        };
923        let supplied = self.obs_codes.get(&system).map_or(0, Vec::len);
924        let declared = supplied + self.obs_codes_remaining;
925        Err(Error::Parse(format!(
926            "RINEX OBS {system} SYS / # / OBS TYPES declares {declared} codes but supplies {supplied} before {line:?}"
927        )))
928    }
929
930    fn parse_phase_shift(&mut self, line: &str) -> Result<()> {
931        let tokens: Vec<&str> = field(line, 0, 60).split_whitespace().collect();
932        if tokens.is_empty() {
933            return Ok(());
934        }
935        if tokens.len() < 2 {
936            return Err(Error::Parse(format!(
937                "RINEX OBS phase-shift header has too few fields in {line:?}"
938            )));
939        }
940
941        let system = tokens[0]
942            .chars()
943            .next()
944            .and_then(GnssSystem::from_letter)
945            .ok_or_else(|| {
946                Error::Parse(format!(
947                    "RINEX OBS phase-shift system unparsable in {line:?}"
948                ))
949            })?;
950        let code = tokens[1].to_string();
951        let correction_cycles = match tokens.get(2) {
952            Some(token) => strict_f64_token(token, "phase_shift.correction_cycles", line)?,
953            None => 0.0,
954        };
955
956        let satellites = if let Some(count_token) = tokens.get(3) {
957            let count =
958                strict_int_token::<usize>(count_token, "phase_shift.satellite_count", line)?;
959            let sat_tokens = &tokens[4..];
960            if sat_tokens.len() != count {
961                return Err(Error::Parse(format!(
962                    "RINEX OBS phase-shift satellite count mismatch in {line:?}"
963                )));
964            }
965            sat_tokens
966                .iter()
967                .map(|token| {
968                    parse_sv_token(token).ok_or_else(|| {
969                        Error::Parse(format!(
970                            "RINEX OBS phase-shift satellite token {token:?} unparsable in {line:?}"
971                        ))
972                    })
973                })
974                .collect::<Result<Vec<_>>>()?
975        } else {
976            Vec::new()
977        };
978
979        self.phase_shifts.push(ObsPhaseShift {
980            system,
981            code,
982            correction_cycles,
983            satellites,
984        });
985        Ok(())
986    }
987
988    fn parse_scale_factor(&mut self, line: &str) -> Result<()> {
989        let sys_field = field(line, 0, 1).trim();
990        if !sys_field.is_empty() {
991            self.ensure_scale_factor_count_complete(line)?;
992            let letter = sys_field.chars().next().unwrap();
993            let system = GnssSystem::from_letter(letter).ok_or_else(|| {
994                Error::Parse(format!("RINEX OBS unknown scale-factor system {letter:?}"))
995            })?;
996            let factor =
997                scale_factor_value(strict_int_field::<u32>(line, 2, 6, "scale_factor.factor")?)?;
998            let count_field = field(line, 8, 10).trim();
999            let count = if count_field.is_empty() {
1000                0
1001            } else {
1002                strict_int_token::<usize>(count_field, "scale_factor.obs_type_count", line)?
1003            };
1004            self.scale_factors.push(ObsScaleFactor {
1005                system,
1006                factor,
1007                codes: Vec::new(),
1008            });
1009            if count == 0 {
1010                return Ok(());
1011            }
1012            self.scale_factor_continuation = Some(ScaleFactorContinuation { remaining: count });
1013        }
1014
1015        self.collect_scale_factor_codes(line)
1016    }
1017
1018    fn collect_scale_factor_codes(&mut self, line: &str) -> Result<()> {
1019        let Some(mut continuation) = self.scale_factor_continuation else {
1020            return Ok(());
1021        };
1022        let record = self
1023            .scale_factors
1024            .last_mut()
1025            .expect("scale factor continuation has a record");
1026        for code in field(line, 10, 60).split_whitespace() {
1027            if continuation.remaining == 0 {
1028                return Err(Error::Parse(format!(
1029                    "RINEX OBS SYS / SCALE FACTOR lists more codes than declared in {line:?}"
1030                )));
1031            }
1032            record.codes.push(code.to_string());
1033            continuation.remaining -= 1;
1034        }
1035        self.scale_factor_continuation = (continuation.remaining > 0).then_some(continuation);
1036        Ok(())
1037    }
1038
1039    fn ensure_scale_factor_count_complete(&self, line: &str) -> Result<()> {
1040        let Some(continuation) = self.scale_factor_continuation else {
1041            return Ok(());
1042        };
1043        let supplied = self
1044            .scale_factors
1045            .last()
1046            .map_or(0, |record| record.codes.len());
1047        let declared = supplied + continuation.remaining;
1048        Err(Error::Parse(format!(
1049            "RINEX OBS SYS / SCALE FACTOR declares {declared} codes but supplies {supplied} before {line:?}"
1050        )))
1051    }
1052
1053    fn parse_time_of_first_obs(&mut self, line: &str) -> Result<()> {
1054        self.time_of_first_obs = Some(self.parse_time_header(line, "time_of_first_obs")?);
1055        Ok(())
1056    }
1057
1058    fn parse_time_of_last_obs(&mut self, line: &str) -> Result<()> {
1059        self.time_of_last_obs = Some(self.parse_time_header(line, "time_of_last_obs")?);
1060        Ok(())
1061    }
1062
1063    fn parse_time_header(
1064        &self,
1065        line: &str,
1066        prefix: &'static str,
1067    ) -> Result<(ObsEpochTime, TimeScale)> {
1068        let body = field(line, 0, 43);
1069        let scale_label = field(line, 48, 51).trim();
1070        let scale = time_scale_from_label(scale_label, line)?;
1071        let year = match prefix {
1072            "time_of_last_obs" => "time_of_last_obs.year",
1073            _ => "time_of_first_obs.year",
1074        };
1075        let month = match prefix {
1076            "time_of_last_obs" => "time_of_last_obs.month",
1077            _ => "time_of_first_obs.month",
1078        };
1079        let day = match prefix {
1080            "time_of_last_obs" => "time_of_last_obs.day",
1081            _ => "time_of_first_obs.day",
1082        };
1083        let hour = match prefix {
1084            "time_of_last_obs" => "time_of_last_obs.hour",
1085            _ => "time_of_first_obs.hour",
1086        };
1087        let minute = match prefix {
1088            "time_of_last_obs" => "time_of_last_obs.minute",
1089            _ => "time_of_first_obs.minute",
1090        };
1091        let second = match prefix {
1092            "time_of_last_obs" => "time_of_last_obs.second",
1093            _ => "time_of_first_obs.second",
1094        };
1095        let epoch = parse_epoch_time_tokens(
1096            body,
1097            line,
1098            [year, month, day, hour, minute, second],
1099            civil_second_policy_for_time_scale(scale),
1100        )?;
1101        Ok((epoch, scale))
1102    }
1103
1104    fn parse_glonass_slots(&mut self, line: &str) -> Result<()> {
1105        // " N R01  1 R02 -4 ...": a count then 7-wide "SVNN ±k" entries.
1106        let count_field = field(line, 0, 3).trim();
1107        if !count_field.is_empty() {
1108            let count = strict_int_token::<usize>(count_field, "glonass_slot.count", line)?;
1109            self.glonass_slots_remaining = Some(count);
1110        }
1111        let body = field(line, 4, 60);
1112        let tokens: Vec<&str> = body.split_whitespace().collect();
1113        if !tokens.len().is_multiple_of(2) {
1114            return Err(Error::Parse(format!(
1115                "RINEX OBS GLONASS slot table has an odd token count in {line:?}"
1116            )));
1117        }
1118        for pair in tokens.chunks_exact(2) {
1119            // Each pair is one declared slot entry; account for it against the
1120            // declared count first, so a skipped (unrepresentable) slot still
1121            // balances the count check in `finish`.
1122            if let Some(remaining) = self.glonass_slots_remaining.as_mut() {
1123                if *remaining == 0 {
1124                    return Err(Error::Parse(format!(
1125                        "RINEX OBS GLONASS slot table has more entries than declared in {line:?}"
1126                    )));
1127                }
1128                *remaining -= 1;
1129            }
1130            // A slot token that does not parse to a representable
1131            // `GnssSatelliteId` (e.g. an extended GLONASS slot beyond the
1132            // engine's PRN cap, like R28 in real BKG/IGS products) must not
1133            // reject the whole header: skip the entry and count it, the same
1134            // treatment nav `parse_glonass` gives such slots.
1135            let Some(sat) = parse_sv_token(pair[0]) else {
1136                self.push_unrepresentable_satellite_skip(pair[0]);
1137                continue;
1138            };
1139            if sat.system != GnssSystem::Glonass {
1140                return Err(Error::Parse(format!(
1141                    "RINEX OBS GLONASS slot token {:?} is not GLONASS in {line:?}",
1142                    pair[0]
1143                )));
1144            }
1145            let channel = strict_int_token::<i8>(pair[1], "glonass_slot.channel", line)?;
1146            if !valid_glonass_frequency_channel(i32::from(channel)) {
1147                return Err(Error::Parse(format!(
1148                    "RINEX OBS invalid glonass_slot.channel: {channel} out of range in {line:?}"
1149                )));
1150            }
1151            self.glonass_slots.insert(sat.prn, channel);
1152        }
1153        Ok(())
1154    }
1155
1156    fn parse_glonass_cod_phs_bis(&mut self, line: &str) -> Result<()> {
1157        let tokens: Vec<&str> = field(line, 0, 60).split_whitespace().collect();
1158        let mut entries = Vec::new();
1159        for pair in tokens.chunks(2) {
1160            if pair.len() != 2 {
1161                return Err(Error::Parse(format!(
1162                    "RINEX OBS GLONASS COD/PHS/BIS has an odd token count in {line:?}"
1163                )));
1164            }
1165            entries.push((
1166                pair[0].to_string(),
1167                strict_f64_token(pair[1], "glonass_code_phase_bias", line)?,
1168            ));
1169        }
1170        self.glonass_cod_phs_bis = Some(entries);
1171        Ok(())
1172    }
1173
1174    fn parse_leap_seconds(&mut self, line: &str) -> Result<()> {
1175        let current = strict_int_field::<i64>(line, 0, 6, "leap_seconds.current")?;
1176        self.leap_seconds = Some(ObsLeapSeconds {
1177            current,
1178            delta_future: optional_i64_field(line, 6, 12, "leap_seconds.delta_future")?,
1179            week: optional_i64_field(line, 12, 18, "leap_seconds.week")?,
1180            day: optional_i64_field(line, 18, 24, "leap_seconds.day")?,
1181        });
1182        Ok(())
1183    }
1184
1185    fn parse_prn_obs_counts(&mut self, line: &str) -> Result<()> {
1186        let token = field(line, 0, 3).trim();
1187        if token.is_empty() {
1188            return Ok(());
1189        }
1190        let Some(sat) = parse_sv_token(token) else {
1191            self.push_unrepresentable_satellite_skip(token);
1192            return Ok(());
1193        };
1194        let count = self.obs_codes.get(&sat.system).map_or(0, Vec::len);
1195        let mut values = Vec::with_capacity(count);
1196        for idx in 0..count {
1197            let start = 3 + idx * 6;
1198            let raw = field(line, start, start + 6).trim();
1199            if raw.is_empty() {
1200                values.push(None);
1201            } else {
1202                values.push(Some(strict_int_token::<usize>(raw, "prn_obs_count", line)?));
1203            }
1204        }
1205        self.prn_obs_counts.insert(sat, values);
1206        Ok(())
1207    }
1208
1209    fn parse_body<'a, I: Iterator<Item = &'a str>>(
1210        &mut self,
1211        lines: &mut std::iter::Peekable<I>,
1212    ) -> Result<()> {
1213        while let Some(raw) = lines.next() {
1214            let line = raw.trim_end_matches(['\r', '\n']);
1215            if line.is_empty() {
1216                continue;
1217            }
1218            if !line.starts_with('>') {
1219                // A stray non-epoch line outside an epoch block; tolerate.
1220                continue;
1221            }
1222            let time_scale = self
1223                .time_of_first_obs
1224                .map_or(TimeScale::Gpst, |(_, scale)| scale);
1225            let (epoch_time, flag, numsat, rcv_clock_offset_s, epoch_picoseconds) =
1226                parse_epoch_line(line, civil_second_policy_for_time_scale(time_scale))?;
1227
1228            if flag > 1 {
1229                // Event record: the next `numsat` lines are header/comment
1230                // records, not observations. Consume and skip them, keeping a
1231                // placeholder epoch so indices stay meaningful.
1232                for _ in 0..numsat {
1233                    lines
1234                        .next()
1235                        .ok_or_else(|| Error::Parse("RINEX OBS event record truncated".into()))?;
1236                }
1237                self.epochs.push(ObsEpoch {
1238                    epoch: epoch_time,
1239                    flag,
1240                    rcv_clock_offset_s,
1241                    epoch_picoseconds,
1242                    declared_record_count: numsat,
1243                    special_record_count: numsat,
1244                    sats: BTreeMap::new(),
1245                });
1246                continue;
1247            }
1248
1249            let mut sats = BTreeMap::new();
1250            for _ in 0..numsat {
1251                let sat_line = lines.next().ok_or_else(|| {
1252                    Error::Parse("RINEX OBS epoch truncated: missing satellite line".into())
1253                })?;
1254                let sat_line = sat_line.trim_end_matches(['\r', '\n']);
1255                // Resolve the satellite token first: a token that does not parse
1256                // to a representable `GnssSatelliteId` (e.g. an extended GLONASS
1257                // slot like R28) is an independent record that must not reject
1258                // the whole epoch/file. Skip the whole record - including any
1259                // wrapped continuation lines so the stream stays aligned - and
1260                // count it. No observation values are fabricated.
1261                let normalized = ascii_fixed_columns(sat_line);
1262                if !starts_with_sat_designator(&normalized) {
1263                    // Not a satellite record at all (e.g. a `>` epoch header): the
1264                    // declared `numsat` overran this epoch's records. That is
1265                    // structural corruption, not a skippable unknown satellite, so
1266                    // fail rather than swallow the next epoch's header/records.
1267                    return Err(Error::Parse(
1268                        "RINEX OBS epoch truncated: expected satellite record".into(),
1269                    ));
1270                }
1271                if parse_sv_token(field(&normalized, 0, 3)).is_none() {
1272                    // Lexically a satellite designator but the system/PRN is not
1273                    // representable (e.g. extended GLONASS slot R28): skip the whole
1274                    // record - including wrapped continuation lines - and count it.
1275                    // No observation values are fabricated.
1276                    self.push_unrepresentable_satellite_skip(field(&normalized, 0, 3));
1277                    consume_skipped_sat_continuations(lines);
1278                    continue;
1279                }
1280                let sat_record = self.collect_sat_record(sat_line, lines)?;
1281                let (sat, values) = self.parse_sat_line(&sat_record)?;
1282                sats.insert(sat, values);
1283            }
1284            self.epochs.push(ObsEpoch {
1285                epoch: epoch_time,
1286                flag,
1287                rcv_clock_offset_s,
1288                epoch_picoseconds,
1289                declared_record_count: numsat,
1290                special_record_count: 0,
1291                sats,
1292            });
1293        }
1294        Ok(())
1295    }
1296
1297    fn collect_sat_record<'a, I: Iterator<Item = &'a str>>(
1298        &self,
1299        first_line: &str,
1300        lines: &mut std::iter::Peekable<I>,
1301    ) -> Result<String> {
1302        let first_line = ascii_fixed_columns(first_line);
1303        let token = field(&first_line, 0, 3);
1304        let sat = parse_sv_token(token).ok_or_else(|| {
1305            Error::Parse(format!("RINEX OBS unparsable satellite token {token:?}"))
1306        })?;
1307        let n_obs = self.obs_count_for_sat(sat)?;
1308        let mut record = first_line.into_owned();
1309
1310        while sat_record_field_count(record.len()) < n_obs {
1311            let Some(raw_next) = lines.peek().copied() else {
1312                break;
1313            };
1314            let next = raw_next.trim_end_matches(['\r', '\n']);
1315            let next = ascii_fixed_columns(next);
1316            // Stop at the next record boundary. Use the *lexical* designator
1317            // check, not `parse_sv_token`: a new record whose token does not
1318            // resolve to a representable id (e.g. an extended GLONASS slot like
1319            // R28) is still a new satellite record, not continuation data. Only a
1320            // lexical check recognizes it; otherwise its observations would be
1321            // spliced onto this record and the skip would never be counted.
1322            if next.starts_with('>') || starts_with_sat_designator(&next) {
1323                break;
1324            }
1325            let continuation = lines.next().expect("peeked continuation line");
1326            let continuation = ascii_fixed_columns(continuation.trim_end_matches(['\r', '\n']));
1327            append_sat_continuation(&mut record, &continuation, n_obs);
1328        }
1329
1330        Ok(record)
1331    }
1332
1333    fn obs_count_for_sat(&self, sat: GnssSatelliteId) -> Result<usize> {
1334        self.obs_codes
1335            .get(&sat.system)
1336            .map(Vec::len)
1337            .ok_or_else(|| {
1338                Error::Parse(format!(
1339                    "RINEX OBS satellite {sat} uses undeclared observation system"
1340                ))
1341            })
1342    }
1343
1344    fn parse_sat_line(&self, line: &str) -> Result<(GnssSatelliteId, Vec<ObsValue>)> {
1345        let token = field(line, 0, 3);
1346        let sat = parse_sv_token(token).ok_or_else(|| {
1347            Error::Parse(format!("RINEX OBS unparsable satellite token {token:?}"))
1348        })?;
1349        let code_list = self.obs_codes.get(&sat.system).ok_or_else(|| {
1350            Error::Parse(format!(
1351                "RINEX OBS satellite {sat} uses undeclared observation system"
1352            ))
1353        })?;
1354        let mut values = Vec::with_capacity(code_list.len());
1355        for (i, code) in code_list.iter().enumerate() {
1356            let start = 3 + i * OBS_FIELD_WIDTH;
1357            let value_str = field(line, start, start + OBS_VALUE_WIDTH).trim();
1358            let value = if value_str.is_empty() {
1359                None
1360            } else {
1361                let scale = self.scale_factor_for(sat.system, code);
1362                let parsed = strict_f64_token(value_str, "observation.value", line)? / scale;
1363                // The serializer writes this value back as `F14.3` (value * scale).
1364                // A value whose three-decimal form needs more than the 14-column
1365                // field would expand it and shift the LLI/SSI and later fields on
1366                // reparse, so it is not representable in this format - reject it
1367                // rather than emit ambiguous text. Real F14.3 data is always in
1368                // range.
1369                if format!("{:.3}", parsed * scale).len() > OBS_VALUE_WIDTH {
1370                    return Err(Error::Parse(
1371                        "RINEX OBS observation value exceeds the F14.3 field width".into(),
1372                    ));
1373                }
1374                Some(parsed)
1375            };
1376            let lli = digit_at(line, start + OBS_VALUE_WIDTH);
1377            let ssi = digit_at(line, start + OBS_VALUE_WIDTH + 1);
1378            values.push(ObsValue { value, lli, ssi });
1379        }
1380        Ok((sat, values))
1381    }
1382
1383    fn finish(self) -> Result<RinexObs> {
1384        let version = self
1385            .version
1386            .ok_or_else(|| Error::Parse("RINEX OBS missing RINEX VERSION / TYPE".into()))?;
1387        if let Some(remaining) = self.glonass_slots_remaining {
1388            if remaining != 0 {
1389                return Err(Error::Parse(format!(
1390                    "RINEX OBS GLONASS slot table missing {remaining} declared entries"
1391                )));
1392            }
1393        }
1394        if self.obs_codes.is_empty() {
1395            return Err(Error::Parse(
1396                "RINEX OBS header has no SYS / # / OBS TYPES records".into(),
1397            ));
1398        }
1399        let header = ObsHeader {
1400            version,
1401            approx_position_m: self.approx_position_m,
1402            antenna_delta_hen_m: self.antenna_delta_hen_m,
1403            obs_codes: self.obs_codes,
1404            program_run_by_date: self.program_run_by_date,
1405            comments: self.comments,
1406            marker_number: self.marker_number,
1407            marker_type: self.marker_type,
1408            observer: self.observer,
1409            agency: self.agency,
1410            receiver: self.receiver,
1411            antenna: self.antenna,
1412            interval_s: self.interval_s,
1413            time_of_first_obs: self.time_of_first_obs,
1414            time_of_last_obs: self.time_of_last_obs,
1415            n_satellites: self.n_satellites,
1416            prn_obs_counts: self.prn_obs_counts,
1417            phase_shifts: self.phase_shifts,
1418            scale_factors: self.scale_factors,
1419            glonass_slots: self.glonass_slots,
1420            glonass_cod_phs_bis: self.glonass_cod_phs_bis,
1421            signal_strength_unit: self.signal_strength_unit,
1422            leap_seconds: self.leap_seconds,
1423            marker_name: self.marker_name,
1424            unretained_header_labels: self.unretained_header_labels,
1425        };
1426        Ok(RinexObs {
1427            header,
1428            epochs: self.epochs,
1429            skipped_records: self.diagnostics.skips.len(),
1430        })
1431    }
1432
1433    fn scale_factor_for(&self, system: GnssSystem, code: &str) -> f64 {
1434        self.scale_factors
1435            .iter()
1436            .rev()
1437            .find(|record| {
1438                record.system == system
1439                    && (record.codes.is_empty() || record.codes.iter().any(|c| c == code))
1440            })
1441            .map_or(1.0, |record| record.factor)
1442    }
1443}
1444
1445/// Parse a RINEX-3 epoch line `> YYYY MM DD HH MM SS.sssssss  F NN [clock]`,
1446/// returning the civil time, event flag, and satellite count.
1447type ParsedEpochLine = (ObsEpochTime, u8, usize, Option<f64>, Option<u32>);
1448
1449fn parse_epoch_line(
1450    line: &str,
1451    second_policy: validate::CivilSecondPolicy,
1452) -> Result<ParsedEpochLine> {
1453    let body = line
1454        .strip_prefix('>')
1455        .ok_or_else(|| Error::Parse(format!("RINEX OBS epoch line lacks '>': {line:?}")))?;
1456    let tokens: Vec<&str> = body.split_whitespace().collect();
1457    if tokens.len() < 8 {
1458        return Err(Error::Parse(format!(
1459            "RINEX OBS epoch line has too few fields in {line:?}"
1460        )));
1461    }
1462    let epoch = parse_epoch_time_tokens(
1463        &tokens[..6].join(" "),
1464        line,
1465        [
1466            "epoch.year",
1467            "epoch.month",
1468            "epoch.day",
1469            "epoch.hour",
1470            "epoch.minute",
1471            "epoch.second",
1472        ],
1473        second_policy,
1474    )?;
1475
1476    let mut index = 6;
1477    let epoch_picoseconds = if tokens
1478        .get(index)
1479        .is_some_and(|token| token.len() == 5 && token.bytes().all(|b| b.is_ascii_digit()))
1480        && tokens.len() >= 9
1481    {
1482        let value = strict_int_token::<u32>(tokens[index], "epoch.picoseconds", line)?;
1483        index += 1;
1484        Some(value)
1485    } else {
1486        None
1487    };
1488    let flag = strict_int_token::<u8>(tokens[index], "epoch.flag", line)?;
1489    index += 1;
1490    let numsat = strict_int_token::<usize>(tokens[index], "epoch.satellite_count", line)?;
1491    index += 1;
1492    let rcv_clock_offset_s = tokens
1493        .get(index)
1494        .map(|token| strict_f64_token(token, "epoch.rcv_clock_offset_s", line))
1495        .transpose()?;
1496    Ok((epoch, flag, numsat, rcv_clock_offset_s, epoch_picoseconds))
1497}
1498
1499/// Map a RINEX time-system label onto the core [`TimeScale`]. A blank label
1500/// defaults to GPS time, which is the scale a multi-GNSS observation file uses
1501/// in practice; an explicit unknown label is rejected.
1502fn time_scale_from_label(label: &str, line: &str) -> Result<TimeScale> {
1503    let label = label.trim();
1504    if label.is_empty() {
1505        Ok(TimeScale::Gpst)
1506    } else {
1507        time_scale_label(label).ok_or_else(|| {
1508            Error::Parse(format!(
1509                "RINEX OBS TIME OF FIRST OBS unknown time scale {label:?} in {line:?}"
1510            ))
1511        })
1512    }
1513}
1514
1515fn civil_second_policy_for_time_scale(scale: TimeScale) -> validate::CivilSecondPolicy {
1516    match scale {
1517        // GLONASST is UTC(SU)-based, so it can carry positive-leap-second labels.
1518        TimeScale::Utc | TimeScale::Glonasst => validate::CivilSecondPolicy::UtcLike,
1519        TimeScale::Tai
1520        | TimeScale::Tt
1521        | TimeScale::Tdb
1522        | TimeScale::Gpst
1523        | TimeScale::Gst
1524        | TimeScale::Bdt
1525        | TimeScale::Qzsst => validate::CivilSecondPolicy::Continuous,
1526    }
1527}
1528
1529fn parse_epoch_time_tokens(
1530    body: &str,
1531    line: &str,
1532    fields: [&'static str; 6],
1533    second_policy: validate::CivilSecondPolicy,
1534) -> Result<ObsEpochTime> {
1535    let tokens: Vec<&str> = body.split_whitespace().collect();
1536    if tokens.len() < fields.len() {
1537        let field = fields[tokens.len()];
1538        return Err(map_field_error(FieldError::Missing { field }, line));
1539    }
1540    let year = strict_int_token::<i32>(tokens[0], fields[0], line)?;
1541    let month = strict_int_token::<i64>(tokens[1], fields[1], line)?;
1542    let day = strict_int_token::<i64>(tokens[2], fields[2], line)?;
1543    let hour = strict_int_token::<i64>(tokens[3], fields[3], line)?;
1544    let minute = strict_int_token::<i64>(tokens[4], fields[4], line)?;
1545    let second = strict_f64_token(tokens[5], fields[5], line)?;
1546    let civil = validate::civil_datetime_with_second_policy(
1547        year as i64,
1548        month,
1549        day,
1550        hour,
1551        minute,
1552        second,
1553        second_policy,
1554    )
1555    .map_err(|error| map_field_error(error, line))?;
1556    Ok(ObsEpochTime {
1557        year,
1558        month: civil.month as u8,
1559        day: civil.day as u8,
1560        hour: civil.hour as u8,
1561        minute: civil.minute as u8,
1562        second: civil.second,
1563    })
1564}
1565
1566fn strict_vec3_tokens(body: &str, line: &str, fields: [&'static str; 3]) -> Result<[f64; 3]> {
1567    let tokens: Vec<&str> = body.split_whitespace().collect();
1568    if tokens.len() < fields.len() {
1569        let field = fields[tokens.len()];
1570        return Err(map_field_error(FieldError::Missing { field }, line));
1571    }
1572    Ok([
1573        strict_f64_token(tokens[0], fields[0], line)?,
1574        strict_f64_token(tokens[1], fields[1], line)?,
1575        strict_f64_token(tokens[2], fields[2], line)?,
1576    ])
1577}
1578
1579fn strict_f64_field(line: &str, start: usize, end: usize, field_name: &'static str) -> Result<f64> {
1580    strict_f64_token(field(line, start, end), field_name, line)
1581}
1582
1583fn optional_i64_field(
1584    line: &str,
1585    start: usize,
1586    end: usize,
1587    field_name: &'static str,
1588) -> Result<Option<i64>> {
1589    let token = field(line, start, end).trim();
1590    if token.is_empty() {
1591        Ok(None)
1592    } else {
1593        strict_int_token::<i64>(token, field_name, line).map(Some)
1594    }
1595}
1596
1597fn optional_trimmed(line: &str, start: usize, end: usize) -> Option<String> {
1598    let value = field(line, start, end).trim();
1599    (!value.is_empty()).then(|| value.to_string())
1600}
1601
1602fn strict_int_field<T>(line: &str, start: usize, end: usize, field_name: &'static str) -> Result<T>
1603where
1604    T: core::str::FromStr,
1605{
1606    strict_int_token(field(line, start, end), field_name, line)
1607}
1608
1609fn strict_f64_token(token: &str, field_name: &'static str, line: &str) -> Result<f64> {
1610    validate::strict_f64(token, field_name).map_err(|error| map_field_error(error, line))
1611}
1612
1613fn validate_finite_input(value: f64, field: &'static str) -> Result<()> {
1614    if value.is_finite() {
1615        Ok(())
1616    } else {
1617        Err(Error::InvalidInput(format!(
1618            "RINEX OBS {field} must be finite"
1619        )))
1620    }
1621}
1622
1623fn strict_int_token<T>(token: &str, field_name: &'static str, line: &str) -> Result<T>
1624where
1625    T: core::str::FromStr,
1626{
1627    validate::strict_int::<T>(token, field_name).map_err(|error| map_field_error(error, line))
1628}
1629
1630fn scale_factor_value(value: u32) -> Result<f64> {
1631    match value {
1632        1 | 10 | 100 | 1000 => Ok(f64::from(value)),
1633        _ => Err(Error::Parse(format!(
1634            "RINEX OBS invalid scale_factor.factor: expected 1, 10, 100, or 1000, got {value}"
1635        ))),
1636    }
1637}
1638
1639fn map_field_error(error: FieldError, line: &str) -> Error {
1640    Error::Parse(format!(
1641        "RINEX OBS invalid {}: {error} in {line:?}",
1642        error.field()
1643    ))
1644}
1645
1646fn obs_payload_field_count(payload_len: usize) -> usize {
1647    let full = payload_len / OBS_FIELD_WIDTH;
1648    let trailing = payload_len % OBS_FIELD_WIDTH;
1649    full + usize::from(trailing >= OBS_VALUE_WIDTH)
1650}
1651
1652fn sat_record_field_count(record_len: usize) -> usize {
1653    obs_payload_field_count(record_len.saturating_sub(3))
1654}
1655
1656fn ascii_fixed_columns(line: &str) -> Cow<'_, str> {
1657    if line.is_ascii() {
1658        Cow::Borrowed(line)
1659    } else {
1660        Cow::Owned(
1661            line.chars()
1662                .map(|ch| if ch.is_ascii() { ch } else { ' ' })
1663                .collect(),
1664        )
1665    }
1666}
1667
1668fn truncate_to_char_boundary(record: &mut String, len: usize) {
1669    let mut end = len.min(record.len());
1670    while !record.is_char_boundary(end) {
1671        end -= 1;
1672    }
1673    record.truncate(end);
1674}
1675
1676/// Whether `line` lexically begins with a RINEX satellite designator (a system
1677/// letter followed by two PRN digits), whether or not it parses to a
1678/// representable [`GnssSatelliteId`]. Used to find satellite-record boundaries
1679/// when skipping an unknown/out-of-range record, so that a following
1680/// unrepresentable record (e.g. another extended GLONASS slot) is not mistaken
1681/// for a wrapped continuation line. Observation continuation lines begin with a
1682/// right-justified numeric field, never a letter, so they never match.
1683fn starts_with_sat_designator(line: &str) -> bool {
1684    let b = line.as_bytes();
1685    b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1].is_ascii_digit() && b[2].is_ascii_digit()
1686}
1687
1688/// Consume the wrapped continuation lines of a satellite record being skipped
1689/// (its token did not resolve), leaving the iterator positioned at the next
1690/// satellite record or epoch header.
1691fn consume_skipped_sat_continuations<'a, I: Iterator<Item = &'a str>>(
1692    lines: &mut std::iter::Peekable<I>,
1693) {
1694    while let Some(raw_next) = lines.peek().copied() {
1695        let next = ascii_fixed_columns(raw_next.trim_end_matches(['\r', '\n']));
1696        if next.starts_with('>') || starts_with_sat_designator(&next) {
1697            break;
1698        }
1699        lines.next();
1700    }
1701}
1702
1703fn append_sat_continuation(record: &mut String, continuation: &str, n_obs: usize) {
1704    let fields_present = sat_record_field_count(record.len());
1705    let logical_len = 3 + fields_present * OBS_FIELD_WIDTH;
1706    truncate_to_char_boundary(record, logical_len);
1707
1708    let remaining = n_obs.saturating_sub(fields_present);
1709    let payload = field(continuation, 3, continuation.len());
1710    let fields_available = obs_payload_field_count(payload.len());
1711    let fields_to_copy = remaining.min(fields_available);
1712    let width = fields_to_copy * OBS_FIELD_WIDTH;
1713    record.push_str(field(payload, 0, width));
1714}
1715
1716/// Parse a 3-char SV token (e.g. `G01`, `C30`) into a [`GnssSatelliteId`].
1717fn parse_sv_token(token: &str) -> Option<GnssSatelliteId> {
1718    token.parse::<GnssSatelliteId>().ok()
1719}
1720
1721/// Read a single decimal digit at byte `col`, or `None` if it is blank /
1722/// non-digit / past end of line.
1723fn digit_at(line: &str, col: usize) -> Option<u8> {
1724    line.as_bytes()
1725        .get(col)
1726        .filter(|b| b.is_ascii_digit())
1727        .map(|b| b - b'0')
1728}
1729
1730mod write;
1731
1732#[cfg(all(test, sidereon_repo_tests))]
1733mod tests;