Skip to main content

sidereon_core/rinex_obs/
mod.rs

1//! RINEX 2.0x/3.0x/4.0x observation-file parser and single-frequency pseudorange
2//! extraction.
3//!
4//! Parses a RINEX observation file (`OBSERVATION DATA`) into a typed
5//! [`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),
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//!
38//! # Layout (RINEX 2)
39//!
40//! - The header uses one global `# / TYPES OF OBSERV` list. Legacy two-character
41//!   codes are mapped into the same three-character code strings used by the
42//!   RINEX 3 path as each satellite system is encountered.
43//! - The body is per-epoch: a fixed-column epoch line with a two-digit year,
44//!   event flag, satellite count, and up to twelve inline PRNs. The PRN list
45//!   continues on following lines from column 32 when needed.
46//! - Each satellite then contributes only observation fields, five per physical
47//!   line, with no leading satellite token. Blank value fields are retained as
48//!   `None`.
49
50use std::borrow::Cow;
51use std::collections::BTreeMap;
52
53use crate::astro::time::model::TimeScale;
54
55use crate::format::columns::{raw_field as field, raw_field_from};
56use crate::format::{Diagnostics, RecordRef, Skip, SkipReason};
57use crate::frequencies::{
58    rinex_band_frequency_hz, rinex_observation_frequency_hz, rinex_observation_wavelength_m,
59};
60use crate::id::{GnssSatelliteId, GnssSystem};
61use crate::rinex_common::time_scale_label;
62use crate::rinex_nav::valid_glonass_frequency_channel;
63use crate::validate::{self, FieldError};
64use crate::{Error, Result};
65
66/// Width of one RINEX-3 observation field (`F14.3` value + LLI + SSI).
67const OBS_FIELD_WIDTH: usize = 16;
68/// Width of the numeric part of one observation field (`F14.3`).
69const OBS_VALUE_WIDTH: usize = 14;
70/// Largest record count representable by a RINEX epoch `I3` field.
71const MAX_EPOCH_RECORD_COUNT: usize = 999;
72const HEADER_LABELS: &[&str] = &[
73    "RINEX VERSION / TYPE",
74    "PGM / RUN BY / DATE",
75    "COMMENT",
76    "APPROX POSITION XYZ",
77    "ANTENNA: DELTA H/E/N",
78    "SYS / # / OBS TYPES",
79    "# / TYPES OF OBSERV",
80    "SYS / SCALE FACTOR",
81    "SYS / PHASE SHIFT",
82    "TIME OF FIRST OBS",
83    "TIME OF LAST OBS",
84    "INTERVAL",
85    "GLONASS SLOT / FRQ #",
86    "GLONASS COD/PHS/BIS",
87    "SIGNAL STRENGTH UNIT",
88    "LEAP SECONDS",
89    "# OF SATELLITES",
90    "PRN / # OF OBS",
91    "MARKER NAME",
92    "MARKER NUMBER",
93    "MARKER TYPE",
94    "OBSERVER / AGENCY",
95    "REC # / TYPE / VERS",
96    "ANT # / TYPE",
97    "END OF HEADER",
98];
99
100/// A civil epoch as it appears on a RINEX observation epoch line, in the file's
101/// own time scale (no leap-second shifting). This is the natural boundary for
102/// the solver, which derives seconds-of-J2000 / second-of-day / day-of-year
103/// from the civil components.
104#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
105pub struct ObsEpochTime {
106    /// Four-digit calendar year.
107    pub year: i32,
108    /// Calendar month, 1..=12.
109    pub month: u8,
110    /// Calendar day of month, 1..=31.
111    pub day: u8,
112    /// Hour of day, 0..=23.
113    pub hour: u8,
114    /// Minute of hour, 0..=59.
115    pub minute: u8,
116    /// Seconds of minute (fractional), 0.0..60.0.
117    pub second: f64,
118}
119
120/// One reconstructed observation: a value (or blank) with its loss-of-lock and
121/// signal-strength indicators.
122#[derive(Debug, Clone, Copy, PartialEq)]
123pub struct ObsValue {
124    /// The observed value (meters for code/`C` observables, cycles for `L`,
125    /// etc.), or `None` when the field was blank.
126    pub value: Option<f64>,
127    /// Loss-of-lock indicator (RINEX LLI), `None` when blank.
128    pub lli: Option<u8>,
129    /// Signal-strength indicator (RINEX SSI), `None` when blank.
130    pub ssi: Option<u8>,
131}
132
133/// One `SYS / PHASE SHIFT` header record.
134#[derive(Debug, Clone, PartialEq)]
135pub struct ObsPhaseShift {
136    /// Constellation the phase-shift record applies to.
137    pub system: GnssSystem,
138    /// RINEX carrier observable code, e.g. `L1C`.
139    pub code: String,
140    /// Phase correction in carrier cycles.
141    pub correction_cycles: f64,
142    /// Optional satellite restriction. Empty means the correction applies to
143    /// all satellites of the system/code.
144    pub satellites: Vec<GnssSatelliteId>,
145}
146
147/// One `SYS / SCALE FACTOR` header record.
148#[derive(Debug, Clone, PartialEq)]
149pub struct ObsScaleFactor {
150    /// Constellation the scale-factor record applies to.
151    pub system: GnssSystem,
152    /// Factor to divide stored observations by before use.
153    pub factor: f64,
154    /// Observation codes affected. Empty means all codes for the system.
155    pub codes: Vec<String>,
156}
157
158/// One `PGM / RUN BY / DATE` header record.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct PgmRunByDate {
161    /// Program name, trimmed from A20.
162    pub program: String,
163    /// Run-by agency/user, trimmed from A20.
164    pub run_by: String,
165    /// Date string, trimmed from A20.
166    pub date: String,
167}
168
169/// One `REC # / TYPE / VERS` header record.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct ReceiverInfo {
172    /// Receiver serial number, trimmed from A20.
173    pub number: String,
174    /// Receiver type, trimmed from A20.
175    pub receiver_type: String,
176    /// Receiver firmware/version, trimmed from A20.
177    pub version: String,
178}
179
180/// One `ANT # / TYPE` header record.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct AntennaInfo {
183    /// Antenna serial number, trimmed from A20.
184    pub number: String,
185    /// Antenna type, trimmed from A20.
186    pub antenna_type: String,
187}
188
189/// `LEAP SECONDS` header record retained from an observation file.
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub struct ObsLeapSeconds {
192    /// Current leap-second count.
193    pub current: i64,
194    /// Future/past delta field, if present.
195    pub delta_future: Option<i64>,
196    /// GPS week field, if present.
197    pub week: Option<i64>,
198    /// Day field, if present.
199    pub day: Option<i64>,
200}
201
202/// One epoch record: the civil time, the event flag, and the per-satellite
203/// observation values (aligned to that system's `SYS / # / OBS TYPES` order).
204#[derive(Debug, Clone, PartialEq)]
205pub struct ObsEpoch {
206    /// Civil epoch in the header time scale.
207    pub epoch: ObsEpochTime,
208    /// Epoch flag: 0 = OK, 1 = power failure, >1 = an event record (skipped).
209    pub flag: u8,
210    /// Optional receiver clock offset from the epoch line, seconds.
211    pub rcv_clock_offset_s: Option<f64>,
212    /// Optional RINEX 4 epoch picosecond extension.
213    pub epoch_picoseconds: Option<u32>,
214    /// Satellite/special-record count declared on the epoch line.
215    pub declared_record_count: usize,
216    /// Number of special records declared by an event epoch.
217    pub special_record_count: usize,
218    /// Satellite → observation values, ascending satellite id. The value vector
219    /// is index-aligned to [`ObsHeader::obs_codes`] for that satellite's system.
220    pub sats: BTreeMap<GnssSatelliteId, Vec<ObsValue>>,
221}
222
223/// Parsed RINEX observation header.
224#[derive(Debug, Clone, PartialEq)]
225pub struct ObsHeader {
226    /// The full RINEX version (e.g. `2.11`, `3.05`, or `4.02`).
227    pub version: f64,
228    /// The surveyed a-priori receiver position (ECEF meters), if the file
229    /// carries an `APPROX POSITION XYZ` record.
230    pub approx_position_m: Option<[f64; 3]>,
231    /// Antenna reference-point offset from the marker in the RINEX
232    /// height/east/north convention (meters), if the file carries an
233    /// `ANTENNA: DELTA H/E/N` record.
234    pub antenna_delta_hen_m: Option<[f64; 3]>,
235    /// Per-constellation observation-code list, in declared order.
236    pub obs_codes: BTreeMap<GnssSystem, Vec<String>>,
237    /// Program/run-by/date header record.
238    pub program_run_by_date: Option<PgmRunByDate>,
239    /// Header comments retained in file order.
240    pub comments: Vec<String>,
241    /// Marker number, if present.
242    pub marker_number: Option<String>,
243    /// Marker type, if present.
244    pub marker_type: Option<String>,
245    /// Observer name, if present.
246    pub observer: Option<String>,
247    /// Agency name, if present.
248    pub agency: Option<String>,
249    /// Receiver information, if present.
250    pub receiver: Option<ReceiverInfo>,
251    /// Antenna information, if present.
252    pub antenna: Option<AntennaInfo>,
253    /// Nominal epoch spacing in seconds (`INTERVAL`), if present.
254    ///
255    /// RINEX permits zero when this optional metadata is unknown. Consumers
256    /// must not use a non-positive value as cadence.
257    pub interval_s: Option<f64>,
258    /// First observation epoch and its time system (`TIME OF FIRST OBS`).
259    pub time_of_first_obs: Option<(ObsEpochTime, TimeScale)>,
260    /// Last observation epoch and its time system (`TIME OF LAST OBS`).
261    pub time_of_last_obs: Option<(ObsEpochTime, TimeScale)>,
262    /// Declared distinct-satellite count.
263    pub n_satellites: Option<usize>,
264    /// Declared per-satellite, per-code observation counts.
265    pub prn_obs_counts: BTreeMap<GnssSatelliteId, Vec<Option<usize>>>,
266    /// Carrier phase-shift records (`SYS / PHASE SHIFT`), in header order.
267    pub phase_shifts: Vec<ObsPhaseShift>,
268    /// Observation scale-factor records (`SYS / SCALE FACTOR`), in header order.
269    pub scale_factors: Vec<ObsScaleFactor>,
270    /// GLONASS slot → frequency channel map (`GLONASS SLOT / FRQ #`), if present.
271    pub glonass_slots: BTreeMap<u8, i8>,
272    /// GLONASS code-phase bias/alignment record.
273    pub glonass_cod_phs_bis: Option<Vec<(String, f64)>>,
274    /// Signal-strength unit, e.g. `DBHZ`.
275    pub signal_strength_unit: Option<String>,
276    /// Observation-header leap-second record.
277    pub leap_seconds: Option<ObsLeapSeconds>,
278    /// Marker (station) name, if present.
279    pub marker_name: Option<String>,
280    /// Header labels retained only as drop-on-rewrite disclosure.
281    pub unretained_header_labels: Vec<String>,
282}
283
284/// A parsed RINEX observation product.
285///
286/// Construct with [`RinexObs::parse`]. Epochs are stored in file order; access
287/// the header via [`RinexObs::header`], the epochs via [`RinexObs::epochs`], and
288/// per-system code lists via [`RinexObs::obs_codes`].
289#[derive(Debug, Clone, PartialEq)]
290pub struct RinexObs {
291    /// The parsed header.
292    pub header: ObsHeader,
293    /// Epoch records in file order. Event records (flag > 1) are retained with
294    /// an empty satellite map so epoch indices stay stable.
295    pub epochs: Vec<ObsEpoch>,
296    /// Count of records skipped because their satellite token did not parse to a
297    /// representable [`GnssSatelliteId`]: an out-of-range entry in the `GLONASS
298    /// SLOT / FRQ #` header table, or an unknown/out-of-range satellite record
299    /// inside an epoch (e.g. an extended GLONASS slot like `R28` beyond the
300    /// engine's PRN cap). One such record is skipped rather than aborting the
301    /// whole file, mirroring [`crate::astro::sgp4::TleFile::skipped`].
302    pub skipped_records: usize,
303}
304
305impl RinexObs {
306    /// Parse RINEX observation text into a typed product.
307    ///
308    /// Returns [`Error::Parse`] if the file is not observation data, is not RINEX
309    /// major version 2, 3, or 4, is missing a required header record, or has a malformed
310    /// epoch record.
311    pub fn parse(text: &str) -> Result<Self> {
312        let mut parser = Parser::new();
313        let mut lines = text.lines();
314        parser.parse_header(&mut lines)?;
315        let mut body = lines.peekable();
316        if parser.is_rinex2() {
317            parser.parse_body_v2(&mut body)?;
318        } else {
319            parser.parse_body(&mut body)?;
320        }
321        parser.finish()
322    }
323
324    /// The parsed header.
325    pub fn header(&self) -> &ObsHeader {
326        &self.header
327    }
328
329    /// The epoch records, in file order.
330    pub fn epochs(&self) -> &[ObsEpoch] {
331        &self.epochs
332    }
333
334    /// The observation-code list for a constellation, in declared order.
335    pub fn obs_codes(&self, sys: GnssSystem) -> Option<&[String]> {
336        self.header.obs_codes.get(&sys).map(Vec::as_slice)
337    }
338}
339
340impl core::str::FromStr for RinexObs {
341    type Err = Error;
342
343    fn from_str(s: &str) -> Result<Self> {
344        Self::parse(s)
345    }
346}
347
348/// Per-system single-frequency code-selection policy.
349///
350/// For each constellation, an ordered list of observation codes to try; the
351/// first one present at an epoch is used. Build the version-aware defaults with
352/// [`SignalPolicy::default_for`] and adjust per system with
353/// [`SignalPolicy::with_override`].
354#[derive(Debug, Clone, PartialEq)]
355pub struct SignalPolicy {
356    /// Ordered preference list of observation codes per constellation.
357    pub codes: BTreeMap<GnssSystem, Vec<String>>,
358}
359
360impl SignalPolicy {
361    /// The default single-frequency pseudorange policy:
362    ///
363    /// - GPS `C1C` (L1 C/A),
364    /// - Galileo `C1C` then `C1X` (E1),
365    /// - BeiDou `C1I` for RINEX 3.02, `C2I` for 3.01 and 3.03+ (the B1I code
366    ///   label changed between minor versions),
367    /// - GLONASS `C1C` (G1 C/A).
368    ///
369    /// `version` is the file's RINEX version, which selects the BeiDou default.
370    pub fn default_for(version: f64) -> Result<Self> {
371        validate_finite_input(version, "version")?;
372        let mut codes = BTreeMap::new();
373        codes.insert(GnssSystem::Gps, vec!["C1C".to_string()]);
374        codes.insert(
375            GnssSystem::Galileo,
376            vec!["C1C".to_string(), "C1X".to_string()],
377        );
378        // BeiDou B1I label history: C2I in 3.01, relabelled band 1 (C1I) in
379        // 3.02, then reverted to C2I in 3.03 and later. Only the narrow 3.02
380        // window prefers C1I; every other version prefers C2I. Offer both, with
381        // the version-appropriate one first.
382        let beidou = if (3.015..3.025).contains(&version) {
383            vec!["C1I".to_string(), "C2I".to_string()]
384        } else {
385            vec!["C2I".to_string(), "C1I".to_string()]
386        };
387        codes.insert(GnssSystem::BeiDou, beidou);
388        codes.insert(GnssSystem::Glonass, vec!["C1C".to_string()]);
389        Ok(Self { codes })
390    }
391
392    /// Replace the preference list for one constellation.
393    pub fn with_override(mut self, sys: GnssSystem, codes: Vec<String>) -> Self {
394        self.codes.insert(sys, codes);
395        self
396    }
397}
398
399/// Optional per-system observation-code filter.
400///
401/// An empty filter keeps every parsed system and code. A non-empty filter keeps
402/// only listed systems; for each listed system, an empty code vector keeps every
403/// code while a non-empty vector keeps only those codes, in header order.
404#[derive(Debug, Clone, Default, PartialEq, Eq)]
405pub struct ObservationFilter {
406    /// Per-constellation code allow-list.
407    pub codes: BTreeMap<GnssSystem, Vec<String>>,
408}
409
410impl ObservationFilter {
411    /// Construct an empty filter that keeps every parsed observation.
412    pub fn all() -> Self {
413        Self::default()
414    }
415
416    /// Construct a filter from `(system, codes)` entries.
417    pub fn from_entries<I>(entries: I) -> Self
418    where
419        I: IntoIterator<Item = (GnssSystem, Vec<String>)>,
420    {
421        Self {
422            codes: entries.into_iter().collect(),
423        }
424    }
425
426    fn allowed_codes(&self, system: GnssSystem) -> Option<&[String]> {
427        if self.codes.is_empty() {
428            Some(&[])
429        } else {
430            self.codes.get(&system).map(Vec::as_slice)
431        }
432    }
433}
434
435/// Observation kind inferred from the RINEX observation-code leading letter.
436#[derive(Debug, Clone, Copy, PartialEq, Eq)]
437pub enum ObservationKind {
438    /// Code pseudorange (`C*`), meters.
439    Pseudorange,
440    /// Carrier phase (`L*`), cycles.
441    CarrierPhase,
442    /// Doppler (`D*`), hertz.
443    Doppler,
444    /// Signal strength (`S*`), dB-Hz.
445    SignalStrength,
446    /// Unknown or unsupported leading code letter.
447    Unknown,
448}
449
450impl ObservationKind {
451    /// Infer the kind from a RINEX observation code.
452    pub fn from_code(code: &str) -> Self {
453        match code.as_bytes().first().copied() {
454            Some(b'C') => Self::Pseudorange,
455            Some(b'L') => Self::CarrierPhase,
456            Some(b'D') => Self::Doppler,
457            Some(b'S') => Self::SignalStrength,
458            _ => Self::Unknown,
459        }
460    }
461
462    /// Stable lower-case API label.
463    pub fn as_str(self) -> &'static str {
464        match self {
465            Self::Pseudorange => "pseudorange",
466            Self::CarrierPhase => "carrier_phase",
467            Self::Doppler => "doppler",
468            Self::SignalStrength => "signal_strength",
469            Self::Unknown => "unknown",
470        }
471    }
472
473    /// Stable units label for the observation kind.
474    pub fn units_str(self) -> &'static str {
475        match self {
476            Self::Pseudorange => "meters",
477            Self::CarrierPhase => "cycles",
478            Self::Doppler => "hz",
479            Self::SignalStrength => "db_hz",
480            Self::Unknown => "unknown",
481        }
482    }
483}
484
485/// One labelled raw RINEX observation value.
486#[derive(Debug, Clone, PartialEq)]
487pub struct ObservationValueRow {
488    /// RINEX observation code, e.g. `C1C`, `L2W`, `D1C`.
489    pub code: String,
490    /// Kind inferred from the code's leading letter.
491    pub kind: ObservationKind,
492    /// Parsed observation value, or `None` for a blank field.
493    pub value: Option<f64>,
494    /// RINEX loss-of-lock indicator.
495    pub lli: Option<u8>,
496    /// RINEX signal-strength indicator.
497    pub ssi: Option<u8>,
498}
499
500/// One carrier-phase observation with its carrier metadata.
501#[derive(Debug, Clone, PartialEq)]
502pub struct CarrierPhaseRow {
503    /// RINEX carrier observation code, e.g. `L1C`.
504    pub code: String,
505    /// Phase in cycles as recorded in the RINEX observation body.
506    pub value_cycles: Option<f64>,
507    /// RINEX loss-of-lock indicator.
508    pub lli: Option<u8>,
509    /// RINEX signal-strength indicator.
510    pub ssi: Option<u8>,
511    /// Carrier frequency in hertz when known.
512    pub frequency_hz: Option<f64>,
513    /// Carrier wavelength in meters when known.
514    pub wavelength_m: Option<f64>,
515    /// Carrier phase in meters when both value and frequency are known.
516    pub value_m: Option<f64>,
517    /// Reported `SYS / PHASE SHIFT` correction in cycles. RINEX 3 stores
518    /// already-aligned phase observations, so this correction is metadata for
519    /// reconstructing originals and is not re-applied here.
520    pub phase_shift_cycles: f64,
521}
522
523/// Return labelled raw observation rows for one epoch, grouped by satellite.
524pub fn observation_values(
525    obs: &RinexObs,
526    epoch: &ObsEpoch,
527    filter: &ObservationFilter,
528) -> Result<Vec<(GnssSatelliteId, Vec<ObservationValueRow>)>> {
529    let mut out = Vec::new();
530    for (sat, values) in epoch
531        .sats
532        .iter()
533        .filter(|(sat, _)| filter.allowed_codes(sat.system).is_some())
534    {
535        let allowed_codes = filter
536            .allowed_codes(sat.system)
537            .expect("filter presence checked");
538        let Some(code_list) = obs.header.obs_codes.get(&sat.system) else {
539            continue;
540        };
541        let mut rows = Vec::new();
542        for (code, value) in code_list.iter().zip(values.iter()) {
543            if !allowed_codes.is_empty() && !allowed_codes.iter().any(|c| c == code) {
544                continue;
545            }
546            if let Some(value) = value.value {
547                validate_finite_input(value, "observation.value")?;
548            }
549            let kind = ObservationKind::from_code(code);
550            rows.push(ObservationValueRow {
551                code: code.clone(),
552                kind,
553                value: value.value,
554                lli: value.lli,
555                ssi: value.ssi,
556            });
557        }
558        out.push((*sat, rows));
559    }
560    Ok(out)
561}
562
563/// Return carrier-phase rows for one epoch, grouped by satellite.
564pub fn carrier_phase_rows(
565    obs: &RinexObs,
566    epoch: &ObsEpoch,
567    filter: &ObservationFilter,
568) -> Result<Vec<(GnssSatelliteId, Vec<CarrierPhaseRow>)>> {
569    validate_finite_input(obs.header.version, "version")?;
570    let mut out = Vec::new();
571    for (sat, rows) in observation_values(obs, epoch, filter)? {
572        let phases = rows
573            .into_iter()
574            .filter(|row| row.kind == ObservationKind::CarrierPhase)
575            .map(|row| carrier_phase_row(obs, sat, row))
576            .collect::<Result<Vec<_>>>()?;
577        out.push((sat, phases));
578    }
579    Ok(out)
580}
581
582/// Carrier frequency in hertz for a system and RINEX band digit.
583///
584/// GLONASS G1/G2 carriers require the FDMA channel number from the observation
585/// file's `GLONASS SLOT / FRQ #` records.
586pub fn band_frequency_hz(
587    system: GnssSystem,
588    band: char,
589    glonass_channel: Option<i8>,
590) -> Option<f64> {
591    rinex_band_frequency_hz(system, band, glonass_channel)
592}
593
594/// Carrier frequency in hertz for a system and full RINEX observation code.
595pub fn observation_frequency_hz(
596    system: GnssSystem,
597    code: &str,
598    rinex_version: f64,
599    glonass_channel: Option<i8>,
600) -> Result<Option<f64>> {
601    validate_finite_input(rinex_version, "version")?;
602    Ok(rinex_observation_frequency_hz(
603        system,
604        code,
605        rinex_version,
606        glonass_channel,
607    ))
608}
609
610fn carrier_phase_row(
611    obs: &RinexObs,
612    sat: GnssSatelliteId,
613    row: ObservationValueRow,
614) -> Result<CarrierPhaseRow> {
615    let glonass_channel = obs.header.glonass_slots.get(&sat.prn).copied();
616    let frequency_hz =
617        observation_frequency_hz(sat.system, &row.code, obs.header.version, glonass_channel)?;
618    let phase_shift_cycles = phase_shift_cycles(obs, sat, &row.code);
619    let value_cycles = row.value;
620    let wavelength_m =
621        rinex_observation_wavelength_m(sat.system, &row.code, obs.header.version, glonass_channel);
622    let value_m = match value_cycles.zip(wavelength_m) {
623        Some((cycles, lambda)) => {
624            let value_m = cycles * lambda;
625            validate_finite_input(value_m, "carrier_phase.value_m")?;
626            Some(value_m)
627        }
628        None => None,
629    };
630    Ok(CarrierPhaseRow {
631        code: row.code,
632        value_cycles,
633        lli: row.lli,
634        ssi: row.ssi,
635        frequency_hz,
636        wavelength_m,
637        value_m,
638        phase_shift_cycles,
639    })
640}
641
642fn phase_shift_cycles(obs: &RinexObs, sat: GnssSatelliteId, code: &str) -> f64 {
643    let mut system_wide = None;
644    for shift in obs.header.phase_shifts.iter().rev() {
645        if shift.system != sat.system || shift.code != code {
646            continue;
647        }
648        if shift.satellites.is_empty() {
649            if system_wide.is_none() {
650                system_wide = Some(shift.correction_cycles);
651            }
652        } else if shift.satellites.contains(&sat) {
653            return shift.correction_cycles;
654        }
655    }
656    system_wide.unwrap_or(0.0)
657}
658
659/// Extract single-frequency pseudoranges for one epoch under a [`SignalPolicy`].
660///
661/// For each satellite in the epoch, the first code in that system's preference
662/// list whose value is present at the epoch is used. Satellites whose system has
663/// no policy entry, or that lack every preferred code, are skipped. The result
664/// is the ascending-id `(satellite, range_m)` list the solver consumes.
665pub fn pseudoranges(
666    obs: &RinexObs,
667    epoch: &ObsEpoch,
668    policy: &SignalPolicy,
669) -> Result<Vec<(GnssSatelliteId, f64)>> {
670    let mut out = Vec::new();
671    for (sat, values) in &epoch.sats {
672        let Some(prefs) = policy.codes.get(&sat.system) else {
673            continue;
674        };
675        let Some(code_list) = obs.header.obs_codes.get(&sat.system) else {
676            continue;
677        };
678        for code in prefs {
679            if let Some(idx) = code_list.iter().position(|c| c == code) {
680                if let Some(ObsValue {
681                    value: Some(range_m),
682                    ..
683                }) = values.get(idx)
684                {
685                    validate_finite_input(*range_m, "pseudorange_m")?;
686                    out.push((*sat, *range_m));
687                    break;
688                }
689            }
690        }
691    }
692    Ok(out)
693}
694
695/// Incremental RINEX 3 observation parser state.
696struct Parser {
697    version: Option<f64>,
698    is_observation: bool,
699    approx_position_m: Option<[f64; 3]>,
700    antenna_delta_hen_m: Option<[f64; 3]>,
701    obs_codes: BTreeMap<GnssSystem, Vec<String>>,
702    interval_s: Option<f64>,
703    time_of_first_obs: Option<(ObsEpochTime, TimeScale)>,
704    time_of_last_obs: Option<(ObsEpochTime, TimeScale)>,
705    program_run_by_date: Option<PgmRunByDate>,
706    comments: Vec<String>,
707    marker_number: Option<String>,
708    marker_type: Option<String>,
709    observer: Option<String>,
710    agency: Option<String>,
711    receiver: Option<ReceiverInfo>,
712    antenna: Option<AntennaInfo>,
713    n_satellites: Option<usize>,
714    prn_obs_counts: BTreeMap<GnssSatelliteId, Vec<Option<usize>>>,
715    prn_obs_counts_current: Option<GnssSatelliteId>,
716    phase_shifts: Vec<ObsPhaseShift>,
717    scale_factors: Vec<ObsScaleFactor>,
718    scale_factor_continuation: Option<ScaleFactorContinuation>,
719    glonass_slots: BTreeMap<u8, i8>,
720    glonass_slots_remaining: Option<usize>,
721    glonass_cod_phs_bis: Option<Vec<(String, f64)>>,
722    signal_strength_unit: Option<String>,
723    leap_seconds: Option<ObsLeapSeconds>,
724    marker_name: Option<String>,
725    unretained_header_labels: Vec<String>,
726    epochs: Vec<ObsEpoch>,
727    /// The constellation whose `SYS / # / OBS TYPES` list is currently being
728    /// filled (for continuation lines).
729    current_obs_sys: Option<GnssSystem>,
730    /// Number of codes still expected for `current_obs_sys`.
731    obs_codes_remaining: usize,
732    /// RINEX 2 default system from the version record when it is not mixed.
733    rinex2_default_system: Option<GnssSystem>,
734    /// Legacy RINEX 2 global observation-code list, before per-system
735    /// canonicalization.
736    rinex2_obs_codes: Vec<String>,
737    /// Number of global RINEX 2 observation codes still expected.
738    rinex2_obs_codes_remaining: usize,
739    /// Forgiving-parse diagnostics: a GLONASS-slot or epoch satellite record
740    /// whose token does not parse to a representable [`GnssSatelliteId`] is
741    /// pushed here as a typed [`Skip`] rather than silently dropped. The public
742    /// [`RinexObs::skipped_records`] is derived from the skip count.
743    diagnostics: Diagnostics,
744}
745
746#[derive(Debug, Clone, Copy)]
747struct ScaleFactorContinuation {
748    remaining: usize,
749}
750
751impl Parser {
752    fn new() -> Self {
753        Self {
754            version: None,
755            is_observation: false,
756            approx_position_m: None,
757            antenna_delta_hen_m: None,
758            obs_codes: BTreeMap::new(),
759            interval_s: None,
760            time_of_first_obs: None,
761            time_of_last_obs: None,
762            program_run_by_date: None,
763            comments: Vec::new(),
764            marker_number: None,
765            marker_type: None,
766            observer: None,
767            agency: None,
768            receiver: None,
769            antenna: None,
770            n_satellites: None,
771            prn_obs_counts: BTreeMap::new(),
772            prn_obs_counts_current: None,
773            phase_shifts: Vec::new(),
774            scale_factors: Vec::new(),
775            scale_factor_continuation: None,
776            glonass_slots: BTreeMap::new(),
777            glonass_slots_remaining: None,
778            glonass_cod_phs_bis: None,
779            signal_strength_unit: None,
780            leap_seconds: None,
781            marker_name: None,
782            unretained_header_labels: Vec::new(),
783            epochs: Vec::new(),
784            current_obs_sys: None,
785            obs_codes_remaining: 0,
786            rinex2_default_system: None,
787            rinex2_obs_codes: Vec::new(),
788            rinex2_obs_codes_remaining: 0,
789            diagnostics: Diagnostics::new(),
790        }
791    }
792
793    fn is_rinex2(&self) -> bool {
794        self.version
795            .is_some_and(|version| version.floor() as i64 == 2)
796    }
797
798    /// Record a forgiving skip for a record whose satellite token is not a
799    /// representable [`GnssSatelliteId`], carrying the raw token as its identity.
800    fn push_unrepresentable_satellite_skip(&mut self, token: &str) {
801        self.diagnostics.push_skip(Skip {
802            at: RecordRef::default().with_satellite(token.trim()),
803            reason: SkipReason::UnrepresentableSatellite,
804        });
805    }
806
807    fn parse_header<'a, I: Iterator<Item = &'a str>>(&mut self, lines: &mut I) -> Result<()> {
808        let mut saw_end = false;
809        for raw in lines.by_ref() {
810            let raw_line = raw.trim_end_matches(['\r', '\n']);
811            // RINEX header fields are fixed-width printable ASCII. Keep the
812            // parser forgiving, but normalize invalid Unicode and control
813            // characters before interpreting byte columns. Otherwise a lossy
814            // UTF-8 replacement character can straddle a 20-column boundary,
815            // making parse -> write -> parse repartition retained fields.
816            let ascii_line = printable_ascii_header_columns(raw_line);
817            let line = normalize_header_line(&ascii_line);
818            let line = line.as_ref();
819            let label = raw_field_from(line, 60).trim();
820            match label {
821                "RINEX VERSION / TYPE" => self.parse_version(line)?,
822                "PGM / RUN BY / DATE" => self.parse_pgm_run_by_date(line),
823                "COMMENT" => self.comments.push(field(line, 0, 60).trim().to_string()),
824                "APPROX POSITION XYZ" => self.parse_approx_position(line)?,
825                "ANTENNA: DELTA H/E/N" => self.parse_antenna_delta(line)?,
826                "SYS / # / OBS TYPES" => self.parse_obs_types(line)?,
827                "# / TYPES OF OBSERV" => self.parse_obs_types_v2(line)?,
828                "SYS / SCALE FACTOR" => self.parse_scale_factor(line)?,
829                "SYS / PHASE SHIFT" => self.parse_phase_shift(line)?,
830                "TIME OF FIRST OBS" => self.parse_time_of_first_obs(line)?,
831                "TIME OF LAST OBS" => self.parse_time_of_last_obs(line)?,
832                "INTERVAL" => {
833                    self.interval_s = optional_f64_field(line, 0, 10, "interval_s")?;
834                }
835                "GLONASS SLOT / FRQ #" => self.parse_glonass_slots(line)?,
836                "GLONASS COD/PHS/BIS" => self.parse_glonass_cod_phs_bis(line)?,
837                "SIGNAL STRENGTH UNIT" => {
838                    let unit = field(line, 0, 20).trim();
839                    if !unit.is_empty() {
840                        self.signal_strength_unit = Some(unit.to_string());
841                    }
842                }
843                "LEAP SECONDS" => self.parse_leap_seconds(line)?,
844                "# OF SATELLITES" => {
845                    self.n_satellites =
846                        Some(strict_int_field::<usize>(line, 0, 6, "n_satellites")?);
847                }
848                "PRN / # OF OBS" => self.parse_prn_obs_counts(line)?,
849                "MARKER NAME" => {
850                    let name = field(line, 0, 60).trim();
851                    if !name.is_empty() {
852                        self.marker_name = Some(name.to_string());
853                    }
854                }
855                "MARKER NUMBER" => {
856                    self.marker_number = optional_trimmed(line, 0, 20);
857                }
858                "MARKER TYPE" => {
859                    self.marker_type = optional_trimmed(line, 0, 20);
860                }
861                "OBSERVER / AGENCY" => {
862                    self.observer = optional_trimmed(line, 0, 20);
863                    self.agency = optional_trimmed(line, 20, 60);
864                }
865                "REC # / TYPE / VERS" => {
866                    self.receiver = Some(ReceiverInfo {
867                        number: field(line, 0, 20).trim().to_string(),
868                        receiver_type: field(line, 20, 40).trim().to_string(),
869                        version: field(line, 40, 60).trim().to_string(),
870                    });
871                }
872                "ANT # / TYPE" => {
873                    self.antenna = Some(AntennaInfo {
874                        number: field(line, 0, 20).trim().to_string(),
875                        antenna_type: field(line, 20, 40).trim().to_string(),
876                    });
877                }
878                "END OF HEADER" => {
879                    self.ensure_obs_type_count_complete(line)?;
880                    self.ensure_obs_type_count_complete_v2(line)?;
881                    self.ensure_scale_factor_count_complete(line)?;
882                    saw_end = true;
883                    break;
884                }
885                // Every other header record is tolerated and surfaced to QC so
886                // callers know a rewrite will not carry it.
887                _ => {
888                    if !label.is_empty() {
889                        self.unretained_header_labels.push(label.to_string());
890                    }
891                }
892            }
893        }
894        if !saw_end {
895            return Err(Error::Parse("RINEX OBS header has no END OF HEADER".into()));
896        }
897        Ok(())
898    }
899
900    fn parse_version(&mut self, line: &str) -> Result<()> {
901        let version_field = field(line, 0, 20).trim();
902        let version = strict_f64_token(version_field, "version", line).or_else(|_| {
903            let token = field(line, 0, 60)
904                .split_whitespace()
905                .next()
906                .ok_or_else(|| Error::Parse(format!("RINEX OBS bad version field in {line:?}")))?;
907            strict_f64_token(token, "version", line)
908        })?;
909        // The file type letter is at column 20; observation files carry 'O'.
910        let type_field = field(line, 20, 40);
911        let body = field(line, 0, 60);
912        self.is_observation = type_field.trim_start().starts_with('O')
913            || type_field.contains("OBSERVATION")
914            || body.contains("OBSERVATION")
915            || body.split_whitespace().any(|token| token == "O");
916        if !self.is_observation {
917            return Err(Error::Parse(format!(
918                "RINEX file is not observation data: {type_field:?}"
919            )));
920        }
921        if !matches!(version.floor() as i64, 2..=4) {
922            return Err(Error::Parse(format!(
923                "RINEX OBS parser requires major version 2, 3, or 4, got {version}"
924            )));
925        }
926        if version.floor() as i64 == 2 {
927            let system_field = field(line, 40, 41).trim();
928            if let Some(letter) = system_field.chars().next().filter(|letter| *letter != 'M') {
929                self.rinex2_default_system = GnssSystem::from_letter(letter);
930            }
931        }
932        self.version = Some(version);
933        Ok(())
934    }
935
936    fn parse_approx_position(&mut self, line: &str) -> Result<()> {
937        let body = field(line, 0, 60);
938        self.approx_position_m = Some(strict_vec3_tokens(
939            body,
940            line,
941            [
942                "approx_position.x_m",
943                "approx_position.y_m",
944                "approx_position.z_m",
945            ],
946        )?);
947        Ok(())
948    }
949
950    fn parse_antenna_delta(&mut self, line: &str) -> Result<()> {
951        let body = field(line, 0, 60);
952        self.antenna_delta_hen_m = Some(strict_vec3_tokens(
953            body,
954            line,
955            [
956                "antenna_delta.height_m",
957                "antenna_delta.east_m",
958                "antenna_delta.north_m",
959            ],
960        )?);
961        Ok(())
962    }
963
964    fn parse_pgm_run_by_date(&mut self, line: &str) {
965        self.program_run_by_date = Some(PgmRunByDate {
966            program: field(line, 0, 20).trim().to_string(),
967            run_by: field(line, 20, 40).trim().to_string(),
968            date: field(line, 40, 60).trim().to_string(),
969        });
970    }
971
972    fn parse_obs_types(&mut self, line: &str) -> Result<()> {
973        // A new system line carries its letter at column 0 and the count at
974        // columns 3..6; a continuation line has a blank system field and only
975        // adds more codes to the current system.
976        let sys_field = field(line, 0, 1).trim();
977        if !sys_field.is_empty() {
978            let count = match strict_int_field::<usize>(line, 3, 6, "obs_type_count") {
979                Ok(count) => count,
980                Err(_) => return self.parse_obs_types_whitespace(line),
981            };
982            self.ensure_obs_type_count_complete(line)?;
983            let letter = sys_field.chars().next().unwrap();
984            let system = GnssSystem::from_letter(letter).ok_or_else(|| {
985                Error::Parse(format!("RINEX OBS unknown system letter {letter:?}"))
986            })?;
987            self.current_obs_sys = Some(system);
988            self.obs_codes_remaining = count;
989            self.obs_codes.entry(system).or_default();
990        }
991        let Some(system) = self.current_obs_sys else {
992            return Ok(());
993        };
994        // Codes occupy 4-wide fields (" CCC") from column 7; collect up to the
995        // remaining count.
996        let codes_section = field(line, 7, 60);
997        let list = self.obs_codes.get_mut(&system).expect("system inserted");
998        for tok in codes_section.split_whitespace() {
999            if self.obs_codes_remaining == 0 {
1000                return Err(Error::Parse(format!(
1001                    "RINEX OBS {system} SYS / # / OBS TYPES lists more codes than declared in {line:?}"
1002                )));
1003            }
1004            list.push(tok.to_string());
1005            self.obs_codes_remaining -= 1;
1006        }
1007        Ok(())
1008    }
1009
1010    fn parse_obs_types_v2(&mut self, line: &str) -> Result<()> {
1011        if field(line, 0, 6).trim().is_empty() {
1012            if self.rinex2_obs_codes_remaining == 0 {
1013                return Ok(());
1014            }
1015        } else {
1016            self.ensure_obs_type_count_complete_v2(line)?;
1017            self.rinex2_obs_codes.clear();
1018            self.rinex2_obs_codes_remaining =
1019                strict_int_field::<usize>(line, 0, 6, "rinex2.obs_type_count")?;
1020        }
1021        for code in field(line, 6, 60).split_whitespace() {
1022            if self.rinex2_obs_codes_remaining == 0 {
1023                return Err(Error::Parse(format!(
1024                    "RINEX OBS # / TYPES OF OBSERV lists more codes than declared in {line:?}"
1025                )));
1026            }
1027            self.rinex2_obs_codes.push(code.to_string());
1028            self.rinex2_obs_codes_remaining -= 1;
1029        }
1030        Ok(())
1031    }
1032
1033    fn parse_obs_types_whitespace(&mut self, line: &str) -> Result<()> {
1034        let tokens: Vec<&str> = field(line, 0, 60).split_whitespace().collect();
1035        if tokens.is_empty() {
1036            return Err(Error::Parse(format!(
1037                "RINEX OBS malformed SYS / # / OBS TYPES record: {line:?}"
1038            )));
1039        }
1040
1041        if tokens.len() >= 2 && tokens[0].len() == 1 {
1042            if let Ok(count) = strict_int_token::<usize>(tokens[1], "obs_type_count", line) {
1043                let letter = tokens[0]
1044                    .chars()
1045                    .next()
1046                    .ok_or_else(|| Error::Parse("RINEX OBS missing system letter".into()))?;
1047                let system = GnssSystem::from_letter(letter).ok_or_else(|| {
1048                    Error::Parse(format!("RINEX OBS unknown system letter {letter:?}"))
1049                })?;
1050                self.ensure_obs_type_count_complete(line)?;
1051                self.current_obs_sys = Some(system);
1052                self.obs_codes_remaining = count;
1053                self.obs_codes.entry(system).or_default();
1054                return self.push_obs_type_tokens(system, &tokens[2..], line);
1055            }
1056        }
1057
1058        let Some(system) = self.current_obs_sys else {
1059            return Err(Error::Parse(format!(
1060                "RINEX OBS malformed SYS / # / OBS TYPES record: {line:?}"
1061            )));
1062        };
1063        if self.obs_codes_remaining == 0 {
1064            return Err(Error::Parse(format!(
1065                "RINEX OBS {system} SYS / # / OBS TYPES lists more codes than declared in {line:?}"
1066            )));
1067        }
1068        self.push_obs_type_tokens(system, &tokens, line)
1069    }
1070
1071    fn push_obs_type_tokens(
1072        &mut self,
1073        system: GnssSystem,
1074        codes: &[&str],
1075        line: &str,
1076    ) -> Result<()> {
1077        let list = self.obs_codes.entry(system).or_default();
1078        for code in codes {
1079            if self.obs_codes_remaining == 0 {
1080                return Err(Error::Parse(format!(
1081                    "RINEX OBS {system} SYS / # / OBS TYPES lists more codes than declared in {line:?}"
1082                )));
1083            }
1084            list.push((*code).to_string());
1085            self.obs_codes_remaining -= 1;
1086        }
1087        Ok(())
1088    }
1089
1090    fn ensure_obs_type_count_complete(&self, line: &str) -> Result<()> {
1091        if self.obs_codes_remaining == 0 {
1092            return Ok(());
1093        }
1094        let Some(system) = self.current_obs_sys else {
1095            return Ok(());
1096        };
1097        let supplied = self.obs_codes.get(&system).map_or(0, Vec::len);
1098        let declared = supplied + self.obs_codes_remaining;
1099        Err(Error::Parse(format!(
1100            "RINEX OBS {system} SYS / # / OBS TYPES declares {declared} codes but supplies {supplied} before {line:?}"
1101        )))
1102    }
1103
1104    fn ensure_obs_type_count_complete_v2(&self, line: &str) -> Result<()> {
1105        if self.rinex2_obs_codes_remaining == 0 {
1106            return Ok(());
1107        }
1108        let supplied = self.rinex2_obs_codes.len();
1109        let declared = supplied + self.rinex2_obs_codes_remaining;
1110        Err(Error::Parse(format!(
1111            "RINEX OBS # / TYPES OF OBSERV declares {declared} codes but supplies {supplied} before {line:?}"
1112        )))
1113    }
1114
1115    fn parse_phase_shift(&mut self, line: &str) -> Result<()> {
1116        let tokens: Vec<&str> = field(line, 0, 60).split_whitespace().collect();
1117        if tokens.is_empty() {
1118            return Ok(());
1119        }
1120        if tokens.len() < 2 {
1121            return Err(Error::Parse(format!(
1122                "RINEX OBS phase-shift header has too few fields in {line:?}"
1123            )));
1124        }
1125
1126        let system = tokens[0]
1127            .chars()
1128            .next()
1129            .and_then(GnssSystem::from_letter)
1130            .ok_or_else(|| {
1131                Error::Parse(format!(
1132                    "RINEX OBS phase-shift system unparsable in {line:?}"
1133                ))
1134            })?;
1135        let code = tokens[1].to_string();
1136        let correction_cycles = match tokens.get(2) {
1137            Some(token) => strict_f64_token(token, "phase_shift.correction_cycles", line)?,
1138            None => 0.0,
1139        };
1140
1141        let satellites = if let Some(count_token) = tokens.get(3) {
1142            let count =
1143                strict_int_token::<usize>(count_token, "phase_shift.satellite_count", line)?;
1144            let sat_tokens = &tokens[4..];
1145            if sat_tokens.len() != count {
1146                return Err(Error::Parse(format!(
1147                    "RINEX OBS phase-shift satellite count mismatch in {line:?}"
1148                )));
1149            }
1150            sat_tokens
1151                .iter()
1152                .map(|token| {
1153                    parse_sv_token(token).ok_or_else(|| {
1154                        Error::Parse(format!(
1155                            "RINEX OBS phase-shift satellite token {token:?} unparsable in {line:?}"
1156                        ))
1157                    })
1158                })
1159                .collect::<Result<Vec<_>>>()?
1160        } else {
1161            Vec::new()
1162        };
1163
1164        self.phase_shifts.push(ObsPhaseShift {
1165            system,
1166            code,
1167            correction_cycles,
1168            satellites,
1169        });
1170        Ok(())
1171    }
1172
1173    fn parse_scale_factor(&mut self, line: &str) -> Result<()> {
1174        let sys_field = field(line, 0, 1).trim();
1175        if !sys_field.is_empty() {
1176            self.ensure_scale_factor_count_complete(line)?;
1177            let letter = sys_field.chars().next().unwrap();
1178            let system = GnssSystem::from_letter(letter).ok_or_else(|| {
1179                Error::Parse(format!("RINEX OBS unknown scale-factor system {letter:?}"))
1180            })?;
1181            let factor =
1182                scale_factor_value(strict_int_field::<u32>(line, 2, 6, "scale_factor.factor")?)?;
1183            let count_field = field(line, 8, 10).trim();
1184            let count = if count_field.is_empty() {
1185                0
1186            } else {
1187                strict_int_token::<usize>(count_field, "scale_factor.obs_type_count", line)?
1188            };
1189            self.scale_factors.push(ObsScaleFactor {
1190                system,
1191                factor,
1192                codes: Vec::new(),
1193            });
1194            if count == 0 {
1195                return Ok(());
1196            }
1197            self.scale_factor_continuation = Some(ScaleFactorContinuation { remaining: count });
1198        }
1199
1200        self.collect_scale_factor_codes(line)
1201    }
1202
1203    fn collect_scale_factor_codes(&mut self, line: &str) -> Result<()> {
1204        let Some(mut continuation) = self.scale_factor_continuation else {
1205            return Ok(());
1206        };
1207        let record = self
1208            .scale_factors
1209            .last_mut()
1210            .expect("scale factor continuation has a record");
1211        for code in field(line, 10, 60).split_whitespace() {
1212            if continuation.remaining == 0 {
1213                return Err(Error::Parse(format!(
1214                    "RINEX OBS SYS / SCALE FACTOR lists more codes than declared in {line:?}"
1215                )));
1216            }
1217            record.codes.push(code.to_string());
1218            continuation.remaining -= 1;
1219        }
1220        self.scale_factor_continuation = (continuation.remaining > 0).then_some(continuation);
1221        Ok(())
1222    }
1223
1224    fn ensure_scale_factor_count_complete(&self, line: &str) -> Result<()> {
1225        let Some(continuation) = self.scale_factor_continuation else {
1226            return Ok(());
1227        };
1228        let supplied = self
1229            .scale_factors
1230            .last()
1231            .map_or(0, |record| record.codes.len());
1232        let declared = supplied + continuation.remaining;
1233        Err(Error::Parse(format!(
1234            "RINEX OBS SYS / SCALE FACTOR declares {declared} codes but supplies {supplied} before {line:?}"
1235        )))
1236    }
1237
1238    fn parse_time_of_first_obs(&mut self, line: &str) -> Result<()> {
1239        self.time_of_first_obs = Some(self.parse_time_header(line, "time_of_first_obs")?);
1240        Ok(())
1241    }
1242
1243    fn parse_time_of_last_obs(&mut self, line: &str) -> Result<()> {
1244        self.time_of_last_obs = Some(self.parse_time_header(line, "time_of_last_obs")?);
1245        Ok(())
1246    }
1247
1248    fn parse_time_header(
1249        &self,
1250        line: &str,
1251        prefix: &'static str,
1252    ) -> Result<(ObsEpochTime, TimeScale)> {
1253        let body = field(line, 0, 43);
1254        let scale_label = field(line, 48, 51).trim();
1255        let scale = time_scale_from_label(scale_label, line)?;
1256        let year = match prefix {
1257            "time_of_last_obs" => "time_of_last_obs.year",
1258            _ => "time_of_first_obs.year",
1259        };
1260        let month = match prefix {
1261            "time_of_last_obs" => "time_of_last_obs.month",
1262            _ => "time_of_first_obs.month",
1263        };
1264        let day = match prefix {
1265            "time_of_last_obs" => "time_of_last_obs.day",
1266            _ => "time_of_first_obs.day",
1267        };
1268        let hour = match prefix {
1269            "time_of_last_obs" => "time_of_last_obs.hour",
1270            _ => "time_of_first_obs.hour",
1271        };
1272        let minute = match prefix {
1273            "time_of_last_obs" => "time_of_last_obs.minute",
1274            _ => "time_of_first_obs.minute",
1275        };
1276        let second = match prefix {
1277            "time_of_last_obs" => "time_of_last_obs.second",
1278            _ => "time_of_first_obs.second",
1279        };
1280        let epoch = parse_epoch_time_tokens(
1281            body,
1282            line,
1283            [year, month, day, hour, minute, second],
1284            civil_second_policy_for_time_scale(scale),
1285        )?;
1286        Ok((epoch, scale))
1287    }
1288
1289    fn parse_glonass_slots(&mut self, line: &str) -> Result<()> {
1290        // " N R01  1 R02 -4 ...": a count then 7-wide "SVNN ±k" entries.
1291        let count_field = field(line, 0, 3).trim();
1292        if !count_field.is_empty() {
1293            let count = strict_int_token::<usize>(count_field, "glonass_slot.count", line)?;
1294            self.glonass_slots_remaining = Some(count);
1295        }
1296        let body = field(line, 4, 60);
1297        let tokens: Vec<&str> = body.split_whitespace().collect();
1298        if !tokens.len().is_multiple_of(2) {
1299            return Err(Error::Parse(format!(
1300                "RINEX OBS GLONASS slot table has an odd token count in {line:?}"
1301            )));
1302        }
1303        for pair in tokens.chunks_exact(2) {
1304            // Each pair is one declared slot entry; account for it against the
1305            // declared count first, so a skipped (unrepresentable) slot still
1306            // balances the count check in `finish`.
1307            if let Some(remaining) = self.glonass_slots_remaining.as_mut() {
1308                if *remaining == 0 {
1309                    return Err(Error::Parse(format!(
1310                        "RINEX OBS GLONASS slot table has more entries than declared in {line:?}"
1311                    )));
1312                }
1313                *remaining -= 1;
1314            }
1315            // A slot token that does not parse to a representable
1316            // `GnssSatelliteId` (e.g. an extended GLONASS slot beyond the
1317            // engine's PRN cap, like R28 in real BKG/IGS products) must not
1318            // reject the whole header: skip the entry and count it, the same
1319            // treatment nav `parse_glonass` gives such slots.
1320            let Some(sat) = parse_sv_token(pair[0]) else {
1321                self.push_unrepresentable_satellite_skip(pair[0]);
1322                continue;
1323            };
1324            if sat.system != GnssSystem::Glonass {
1325                return Err(Error::Parse(format!(
1326                    "RINEX OBS GLONASS slot token {:?} is not GLONASS in {line:?}",
1327                    pair[0]
1328                )));
1329            }
1330            let channel = strict_int_token::<i8>(pair[1], "glonass_slot.channel", line)?;
1331            if !valid_glonass_frequency_channel(i32::from(channel)) {
1332                return Err(Error::Parse(format!(
1333                    "RINEX OBS invalid glonass_slot.channel: {channel} out of range in {line:?}"
1334                )));
1335            }
1336            self.glonass_slots.insert(sat.prn, channel);
1337        }
1338        Ok(())
1339    }
1340
1341    fn parse_glonass_cod_phs_bis(&mut self, line: &str) -> Result<()> {
1342        let tokens: Vec<&str> = field(line, 0, 60).split_whitespace().collect();
1343        let mut entries = Vec::new();
1344        for pair in tokens.chunks(2) {
1345            if pair.len() != 2 {
1346                return Err(Error::Parse(format!(
1347                    "RINEX OBS GLONASS COD/PHS/BIS has an odd token count in {line:?}"
1348                )));
1349            }
1350            entries.push((
1351                pair[0].to_string(),
1352                strict_f64_token(pair[1], "glonass_code_phase_bias", line)?,
1353            ));
1354        }
1355        self.glonass_cod_phs_bis = Some(entries);
1356        Ok(())
1357    }
1358
1359    fn parse_leap_seconds(&mut self, line: &str) -> Result<()> {
1360        let current = strict_int_field::<i64>(line, 0, 6, "leap_seconds.current")?;
1361        self.leap_seconds = Some(ObsLeapSeconds {
1362            current,
1363            delta_future: optional_i64_field(line, 6, 12, "leap_seconds.delta_future")?,
1364            week: optional_i64_field(line, 12, 18, "leap_seconds.week")?,
1365            day: optional_i64_field(line, 18, 24, "leap_seconds.day")?,
1366        });
1367        Ok(())
1368    }
1369
1370    fn parse_prn_obs_counts(&mut self, line: &str) -> Result<()> {
1371        let token = field(line, 0, 3).trim();
1372        let sat = if token.is_empty() {
1373            let Some(sat) = self.prn_obs_counts_current else {
1374                return Ok(());
1375            };
1376            sat
1377        } else {
1378            let Some(sat) = parse_sv_token(token) else {
1379                self.prn_obs_counts_current = None;
1380                self.push_unrepresentable_satellite_skip(token);
1381                return Ok(());
1382            };
1383            self.prn_obs_counts_current = Some(sat);
1384            sat
1385        };
1386        let count = self.obs_codes.get(&sat.system).map_or(0, Vec::len);
1387        let already = self.prn_obs_counts.get(&sat).map_or(0, Vec::len);
1388        let remaining = count.saturating_sub(already);
1389        let mut values = Vec::with_capacity(remaining.min(9));
1390        for idx in 0..remaining {
1391            let start = 3 + idx * 6;
1392            if start + 6 > 60 {
1393                break;
1394            }
1395            let raw = field(line, start, start + 6).trim();
1396            if raw.is_empty() {
1397                values.push(None);
1398            } else {
1399                values.push(Some(strict_int_token::<usize>(raw, "prn_obs_count", line)?));
1400            }
1401        }
1402        self.prn_obs_counts.entry(sat).or_default().extend(values);
1403        Ok(())
1404    }
1405
1406    fn parse_body<'a, I: Iterator<Item = &'a str>>(
1407        &mut self,
1408        lines: &mut std::iter::Peekable<I>,
1409    ) -> Result<()> {
1410        while let Some(raw) = lines.next() {
1411            let line = raw.trim_end_matches(['\r', '\n']);
1412            if line.is_empty() {
1413                continue;
1414            }
1415            if !line.starts_with('>') {
1416                // A stray non-epoch line outside an epoch block; tolerate.
1417                continue;
1418            }
1419            let time_scale = self
1420                .time_of_first_obs
1421                .map_or(TimeScale::Gpst, |(_, scale)| scale);
1422            let (epoch_time, flag, numsat, rcv_clock_offset_s, epoch_picoseconds) =
1423                parse_epoch_line(line, civil_second_policy_for_time_scale(time_scale))?;
1424
1425            if flag > 1 {
1426                // Event record: the next `numsat` lines are header/comment
1427                // records, not observations. Consume and skip them, keeping a
1428                // placeholder epoch so indices stay meaningful.
1429                for _ in 0..numsat {
1430                    lines
1431                        .next()
1432                        .ok_or_else(|| Error::Parse("RINEX OBS event record truncated".into()))?;
1433                }
1434                self.epochs.push(ObsEpoch {
1435                    epoch: epoch_time,
1436                    flag,
1437                    rcv_clock_offset_s,
1438                    epoch_picoseconds,
1439                    declared_record_count: numsat,
1440                    special_record_count: numsat,
1441                    sats: BTreeMap::new(),
1442                });
1443                continue;
1444            }
1445
1446            let mut sats = BTreeMap::new();
1447            for _ in 0..numsat {
1448                let sat_line = lines.next().ok_or_else(|| {
1449                    Error::Parse("RINEX OBS epoch truncated: missing satellite line".into())
1450                })?;
1451                let sat_line = sat_line.trim_end_matches(['\r', '\n']);
1452                // Resolve the satellite token first: a token that does not parse
1453                // to a representable `GnssSatelliteId` (e.g. an extended GLONASS
1454                // slot like R28) is an independent record that must not reject
1455                // the whole epoch/file. Skip the whole record - including any
1456                // wrapped continuation lines so the stream stays aligned - and
1457                // count it. No observation values are fabricated.
1458                let normalized = ascii_fixed_columns(sat_line);
1459                if !starts_with_sat_designator(&normalized) {
1460                    // Not a satellite record at all (e.g. a `>` epoch header): the
1461                    // declared `numsat` overran this epoch's records. That is
1462                    // structural corruption, not a skippable unknown satellite, so
1463                    // fail rather than swallow the next epoch's header/records.
1464                    return Err(Error::Parse(
1465                        "RINEX OBS epoch truncated: expected satellite record".into(),
1466                    ));
1467                }
1468                if parse_sv_token(field(&normalized, 0, 3)).is_none() {
1469                    // Lexically a satellite designator but the system/PRN is not
1470                    // representable (e.g. extended GLONASS slot R28): skip the whole
1471                    // record - including wrapped continuation lines - and count it.
1472                    // No observation values are fabricated.
1473                    self.push_unrepresentable_satellite_skip(field(&normalized, 0, 3));
1474                    consume_skipped_sat_continuations(lines);
1475                    continue;
1476                }
1477                let sat_record = self.collect_sat_record(sat_line, lines)?;
1478                let (sat, values) = self.parse_sat_line(&sat_record)?;
1479                sats.insert(sat, values);
1480            }
1481            self.epochs.push(ObsEpoch {
1482                epoch: epoch_time,
1483                flag,
1484                rcv_clock_offset_s,
1485                epoch_picoseconds,
1486                declared_record_count: numsat,
1487                special_record_count: 0,
1488                sats,
1489            });
1490        }
1491        Ok(())
1492    }
1493
1494    fn parse_body_v2<'a, I: Iterator<Item = &'a str>>(
1495        &mut self,
1496        lines: &mut std::iter::Peekable<I>,
1497    ) -> Result<()> {
1498        while let Some(raw) = lines.next() {
1499            let line = raw.trim_end_matches(['\r', '\n']);
1500            if line.is_empty() {
1501                continue;
1502            }
1503            let time_scale = self
1504                .time_of_first_obs
1505                .map_or(TimeScale::Gpst, |(_, scale)| scale);
1506            let (epoch_time, flag, numsat, rcv_clock_offset_s) =
1507                parse_epoch_line_v2(line, civil_second_policy_for_time_scale(time_scale))?;
1508
1509            if flag > 1 {
1510                for _ in 0..numsat {
1511                    lines
1512                        .next()
1513                        .ok_or_else(|| Error::Parse("RINEX OBS event record truncated".into()))?;
1514                }
1515                self.epochs.push(ObsEpoch {
1516                    epoch: epoch_time,
1517                    flag,
1518                    rcv_clock_offset_s,
1519                    epoch_picoseconds: None,
1520                    declared_record_count: numsat,
1521                    special_record_count: numsat,
1522                    sats: BTreeMap::new(),
1523                });
1524                continue;
1525            }
1526
1527            let sv_tokens = collect_epoch_sv_tokens_v2(line, numsat, lines)?;
1528            let obs_lines_per_sat = self.rinex2_obs_lines_per_sat()?;
1529            let mut sats = BTreeMap::new();
1530            for token in sv_tokens {
1531                let mut obs_lines = Vec::with_capacity(obs_lines_per_sat);
1532                for _ in 0..obs_lines_per_sat {
1533                    let obs_line = lines.next().ok_or_else(|| {
1534                        Error::Parse("RINEX OBS epoch truncated: missing observation line".into())
1535                    })?;
1536                    obs_lines.push(obs_line.trim_end_matches(['\r', '\n']).to_string());
1537                }
1538
1539                let Some(sat) = self.parse_sv_token_v2(&token) else {
1540                    self.push_unrepresentable_satellite_skip(&token);
1541                    continue;
1542                };
1543                self.ensure_rinex2_system_obs_codes(sat.system);
1544                let values = self.parse_sat_obs_v2(sat.system, &obs_lines)?;
1545                sats.insert(sat, values);
1546            }
1547            self.epochs.push(ObsEpoch {
1548                epoch: epoch_time,
1549                flag,
1550                rcv_clock_offset_s,
1551                epoch_picoseconds: None,
1552                declared_record_count: numsat,
1553                special_record_count: 0,
1554                sats,
1555            });
1556        }
1557        Ok(())
1558    }
1559
1560    fn rinex2_obs_lines_per_sat(&self) -> Result<usize> {
1561        if self.rinex2_obs_codes.is_empty() {
1562            return Err(Error::Parse(
1563                "RINEX OBS header has no # / TYPES OF OBSERV records".into(),
1564            ));
1565        }
1566        Ok(self.rinex2_obs_codes.len().div_ceil(5))
1567    }
1568
1569    fn parse_sv_token_v2(&self, token: &str) -> Option<GnssSatelliteId> {
1570        parse_sv_token_v2(token, self.rinex2_default_system.unwrap_or(GnssSystem::Gps))
1571    }
1572
1573    fn ensure_rinex2_system_obs_codes(&mut self, system: GnssSystem) {
1574        self.obs_codes.entry(system).or_insert_with(|| {
1575            self.rinex2_obs_codes
1576                .iter()
1577                .map(|code| canonical_rinex2_obs_code(system, code))
1578                .collect()
1579        });
1580    }
1581
1582    fn parse_sat_obs_v2(&self, system: GnssSystem, obs_lines: &[String]) -> Result<Vec<ObsValue>> {
1583        let code_list = self.obs_codes.get(&system).ok_or_else(|| {
1584            Error::Parse(format!(
1585                "RINEX OBS satellite system {system} has no canonical observation-code table"
1586            ))
1587        })?;
1588        let mut values = Vec::with_capacity(code_list.len());
1589        for (i, code) in code_list.iter().enumerate() {
1590            let line = obs_lines.get(i / 5).map_or("", String::as_str);
1591            let start = (i % 5) * OBS_FIELD_WIDTH;
1592            let value_str = field(line, start, start + OBS_VALUE_WIDTH).trim();
1593            let value = if value_str.is_empty() {
1594                None
1595            } else {
1596                let scale = self.scale_factor_for(system, code);
1597                let parsed = strict_f64_token(value_str, "observation.value", line)? / scale;
1598                if format!("{:.3}", parsed * scale).len() > OBS_VALUE_WIDTH {
1599                    return Err(Error::Parse(
1600                        "RINEX OBS observation value exceeds the F14.3 field width".into(),
1601                    ));
1602                }
1603                Some(parsed)
1604            };
1605            let lli = digit_at(line, start + OBS_VALUE_WIDTH);
1606            let ssi = digit_at(line, start + OBS_VALUE_WIDTH + 1);
1607            values.push(ObsValue { value, lli, ssi });
1608        }
1609        Ok(values)
1610    }
1611
1612    fn collect_sat_record<'a, I: Iterator<Item = &'a str>>(
1613        &self,
1614        first_line: &str,
1615        lines: &mut std::iter::Peekable<I>,
1616    ) -> Result<String> {
1617        let first_line = ascii_fixed_columns(first_line);
1618        let token = field(&first_line, 0, 3);
1619        let sat = parse_sv_token(token).ok_or_else(|| {
1620            Error::Parse(format!("RINEX OBS unparsable satellite token {token:?}"))
1621        })?;
1622        let n_obs = self.obs_count_for_sat(sat)?;
1623        let mut record = first_line.into_owned();
1624
1625        while sat_record_field_count(record.len()) < n_obs {
1626            let Some(raw_next) = lines.peek().copied() else {
1627                break;
1628            };
1629            let next = raw_next.trim_end_matches(['\r', '\n']);
1630            let next = ascii_fixed_columns(next);
1631            // Stop at the next record boundary. Use the *lexical* designator
1632            // check, not `parse_sv_token`: a new record whose token does not
1633            // resolve to a representable id (e.g. an extended GLONASS slot like
1634            // R28) is still a new satellite record, not continuation data. Only a
1635            // lexical check recognizes it; otherwise its observations would be
1636            // spliced onto this record and the skip would never be counted.
1637            if next.starts_with('>') || starts_with_sat_designator(&next) {
1638                break;
1639            }
1640            let continuation = lines.next().expect("peeked continuation line");
1641            let continuation = ascii_fixed_columns(continuation.trim_end_matches(['\r', '\n']));
1642            append_sat_continuation(&mut record, &continuation, n_obs);
1643        }
1644
1645        Ok(record)
1646    }
1647
1648    fn obs_count_for_sat(&self, sat: GnssSatelliteId) -> Result<usize> {
1649        self.obs_codes
1650            .get(&sat.system)
1651            .map(Vec::len)
1652            .ok_or_else(|| {
1653                Error::Parse(format!(
1654                    "RINEX OBS satellite {sat} uses undeclared observation system"
1655                ))
1656            })
1657    }
1658
1659    fn parse_sat_line(&self, line: &str) -> Result<(GnssSatelliteId, Vec<ObsValue>)> {
1660        let token = field(line, 0, 3);
1661        let sat = parse_sv_token(token).ok_or_else(|| {
1662            Error::Parse(format!("RINEX OBS unparsable satellite token {token:?}"))
1663        })?;
1664        let code_list = self.obs_codes.get(&sat.system).ok_or_else(|| {
1665            Error::Parse(format!(
1666                "RINEX OBS satellite {sat} uses undeclared observation system"
1667            ))
1668        })?;
1669        let mut values = Vec::with_capacity(code_list.len());
1670        for (i, code) in code_list.iter().enumerate() {
1671            let start = 3 + i * OBS_FIELD_WIDTH;
1672            let value_str = field(line, start, start + OBS_VALUE_WIDTH).trim();
1673            let value = if value_str.is_empty() {
1674                None
1675            } else {
1676                let scale = self.scale_factor_for(sat.system, code);
1677                let parsed = strict_f64_token(value_str, "observation.value", line)? / scale;
1678                // The serializer writes this value back as `F14.3` (value * scale).
1679                // A value whose three-decimal form needs more than the 14-column
1680                // field would expand it and shift the LLI/SSI and later fields on
1681                // reparse, so it is not representable in this format - reject it
1682                // rather than emit ambiguous text. Real F14.3 data is always in
1683                // range.
1684                if format!("{:.3}", parsed * scale).len() > OBS_VALUE_WIDTH {
1685                    return Err(Error::Parse(
1686                        "RINEX OBS observation value exceeds the F14.3 field width".into(),
1687                    ));
1688                }
1689                Some(parsed)
1690            };
1691            let lli = digit_at(line, start + OBS_VALUE_WIDTH);
1692            let ssi = digit_at(line, start + OBS_VALUE_WIDTH + 1);
1693            values.push(ObsValue { value, lli, ssi });
1694        }
1695        Ok((sat, values))
1696    }
1697
1698    fn finish(self) -> Result<RinexObs> {
1699        let version = self
1700            .version
1701            .ok_or_else(|| Error::Parse("RINEX OBS missing RINEX VERSION / TYPE".into()))?;
1702        if let Some(remaining) = self.glonass_slots_remaining {
1703            if remaining != 0 {
1704                return Err(Error::Parse(format!(
1705                    "RINEX OBS GLONASS slot table missing {remaining} declared entries"
1706                )));
1707            }
1708        }
1709        let mut obs_codes = self.obs_codes;
1710        if obs_codes.is_empty() && !self.rinex2_obs_codes.is_empty() {
1711            let system = self.rinex2_default_system.unwrap_or(GnssSystem::Gps);
1712            obs_codes.insert(
1713                system,
1714                self.rinex2_obs_codes
1715                    .iter()
1716                    .map(|code| canonical_rinex2_obs_code(system, code))
1717                    .collect(),
1718            );
1719        }
1720        if obs_codes.is_empty() {
1721            return Err(Error::Parse(
1722                "RINEX OBS header has no SYS / # / OBS TYPES records".into(),
1723            ));
1724        }
1725        let header = ObsHeader {
1726            version,
1727            approx_position_m: self.approx_position_m,
1728            antenna_delta_hen_m: self.antenna_delta_hen_m,
1729            obs_codes,
1730            program_run_by_date: self.program_run_by_date,
1731            comments: self.comments,
1732            marker_number: self.marker_number,
1733            marker_type: self.marker_type,
1734            observer: self.observer,
1735            agency: self.agency,
1736            receiver: self.receiver,
1737            antenna: self.antenna,
1738            interval_s: self.interval_s,
1739            time_of_first_obs: self.time_of_first_obs,
1740            time_of_last_obs: self.time_of_last_obs,
1741            n_satellites: self.n_satellites,
1742            prn_obs_counts: self.prn_obs_counts,
1743            phase_shifts: self.phase_shifts,
1744            scale_factors: self.scale_factors,
1745            glonass_slots: self.glonass_slots,
1746            glonass_cod_phs_bis: self.glonass_cod_phs_bis,
1747            signal_strength_unit: self.signal_strength_unit,
1748            leap_seconds: self.leap_seconds,
1749            marker_name: self.marker_name,
1750            unretained_header_labels: self.unretained_header_labels,
1751        };
1752        Ok(RinexObs {
1753            header,
1754            epochs: self.epochs,
1755            skipped_records: self.diagnostics.skips.len(),
1756        })
1757    }
1758
1759    fn scale_factor_for(&self, system: GnssSystem, code: &str) -> f64 {
1760        self.scale_factors
1761            .iter()
1762            .rev()
1763            .find(|record| {
1764                record.system == system
1765                    && (record.codes.is_empty() || record.codes.iter().any(|c| c == code))
1766            })
1767            .map_or(1.0, |record| record.factor)
1768    }
1769}
1770
1771fn normalize_header_line(line: &str) -> Cow<'_, str> {
1772    let fixed_label = raw_field_from(line, 60).trim();
1773    if HEADER_LABELS.contains(&fixed_label) {
1774        return Cow::Borrowed(line);
1775    }
1776
1777    for &label in HEADER_LABELS {
1778        let Some(index) = line.rfind(label) else {
1779            continue;
1780        };
1781        if !line[index + label.len()..].trim().is_empty() {
1782            continue;
1783        }
1784        let content = line[..index].trim_end();
1785        let content = truncate_header_content(content);
1786        return Cow::Owned(format!("{content:<60}{label}"));
1787    }
1788
1789    Cow::Borrowed(line)
1790}
1791
1792/// Replace characters outside RINEX's printable-ASCII domain without changing
1793/// byte-column offsets in the forgiving UTF-8 input.
1794fn printable_ascii_header_columns(line: &str) -> Cow<'_, str> {
1795    if line
1796        .bytes()
1797        .all(|byte| byte == b' ' || byte.is_ascii_graphic())
1798    {
1799        return Cow::Borrowed(line);
1800    }
1801
1802    let mut normalized = String::with_capacity(line.len());
1803    for ch in line.chars() {
1804        if ch == ' ' || ch.is_ascii_graphic() {
1805            normalized.push(ch);
1806        } else {
1807            // Fixed columns are byte columns. Preserve the byte width of a
1808            // lossy UTF-8 replacement so later fields stay at the offsets the
1809            // forgiving parser originally observed.
1810            for _ in 0..ch.len_utf8() {
1811                normalized.push(' ');
1812            }
1813        }
1814    }
1815    Cow::Owned(normalized)
1816}
1817
1818fn truncate_header_content(content: &str) -> Cow<'_, str> {
1819    if content.len() <= 60 {
1820        return Cow::Borrowed(content);
1821    }
1822    let mut end = 60;
1823    while !content.is_char_boundary(end) {
1824        end -= 1;
1825    }
1826    Cow::Owned(content[..end].to_string())
1827}
1828
1829/// Parse a RINEX-3 epoch line `> YYYY MM DD HH MM SS.sssssss  F NN [clock]`,
1830/// returning the civil time, event flag, and satellite count.
1831type ParsedEpochLine = (ObsEpochTime, u8, usize, Option<f64>, Option<u32>);
1832
1833fn parse_epoch_line(
1834    line: &str,
1835    second_policy: validate::CivilSecondPolicy,
1836) -> Result<ParsedEpochLine> {
1837    let body = line
1838        .strip_prefix('>')
1839        .ok_or_else(|| Error::Parse(format!("RINEX OBS epoch line lacks '>': {line:?}")))?;
1840    let tokens: Vec<&str> = body.split_whitespace().collect();
1841    if tokens.len() < 8 {
1842        return Err(Error::Parse(format!(
1843            "RINEX OBS epoch line has too few fields in {line:?}"
1844        )));
1845    }
1846    let epoch = parse_epoch_time_tokens(
1847        &tokens[..6].join(" "),
1848        line,
1849        [
1850            "epoch.year",
1851            "epoch.month",
1852            "epoch.day",
1853            "epoch.hour",
1854            "epoch.minute",
1855            "epoch.second",
1856        ],
1857        second_policy,
1858    )?;
1859
1860    let mut index = 6;
1861    let epoch_picoseconds = if tokens
1862        .get(index)
1863        .is_some_and(|token| token.len() == 5 && token.bytes().all(|b| b.is_ascii_digit()))
1864        && tokens.len() >= 9
1865    {
1866        let value = strict_int_token::<u32>(tokens[index], "epoch.picoseconds", line)?;
1867        index += 1;
1868        Some(value)
1869    } else {
1870        None
1871    };
1872    let flag = strict_int_token::<u8>(tokens[index], "epoch.flag", line)?;
1873    index += 1;
1874    let numsat = parse_epoch_record_count(tokens[index], line)?;
1875    index += 1;
1876    let rcv_clock_offset_s = tokens
1877        .get(index)
1878        .map(|token| strict_f64_token(token, "epoch.rcv_clock_offset_s", line))
1879        .transpose()?;
1880    Ok((epoch, flag, numsat, rcv_clock_offset_s, epoch_picoseconds))
1881}
1882
1883type ParsedEpochLineV2 = (ObsEpochTime, u8, usize, Option<f64>);
1884
1885fn parse_epoch_line_v2(
1886    line: &str,
1887    second_policy: validate::CivilSecondPolicy,
1888) -> Result<ParsedEpochLineV2> {
1889    let head = field(line, 0, 32);
1890    let tokens: Vec<&str> = head.split_whitespace().collect();
1891    if tokens.len() < 8 {
1892        return Err(Error::Parse(format!(
1893            "RINEX OBS v2 epoch line has too few fields in {line:?}"
1894        )));
1895    }
1896    let year = strict_int_token::<i32>(tokens[0], "epoch.year", line)?;
1897    let year = expand_rinex2_year(year);
1898    let month = strict_int_token::<i64>(tokens[1], "epoch.month", line)?;
1899    let day = strict_int_token::<i64>(tokens[2], "epoch.day", line)?;
1900    let hour = strict_int_token::<i64>(tokens[3], "epoch.hour", line)?;
1901    let minute = strict_int_token::<i64>(tokens[4], "epoch.minute", line)?;
1902    let second = strict_f64_token(tokens[5], "epoch.second", line)?;
1903    let civil = validate::civil_datetime_with_second_policy(
1904        i64::from(year),
1905        month,
1906        day,
1907        hour,
1908        minute,
1909        second,
1910        second_policy,
1911    )
1912    .map_err(|error| map_field_error(error, line))?;
1913    let flag = strict_int_token::<u8>(tokens[6], "epoch.flag", line)?;
1914    let numsat = parse_epoch_record_count(tokens[7], line)?;
1915    let clock = field(line, 68, line.len()).trim();
1916    let rcv_clock_offset_s = if clock.is_empty() {
1917        None
1918    } else {
1919        Some(strict_f64_token(clock, "epoch.rcv_clock_offset_s", line)?)
1920    };
1921    Ok((
1922        ObsEpochTime {
1923            year,
1924            month: civil.month as u8,
1925            day: civil.day as u8,
1926            hour: civil.hour as u8,
1927            minute: civil.minute as u8,
1928            second: civil.second,
1929        },
1930        flag,
1931        numsat,
1932        rcv_clock_offset_s,
1933    ))
1934}
1935
1936fn expand_rinex2_year(year: i32) -> i32 {
1937    if year >= 100 {
1938        year
1939    } else if year >= 80 {
1940        1900 + year
1941    } else {
1942        2000 + year
1943    }
1944}
1945
1946fn parse_epoch_record_count(token: &str, line: &str) -> Result<usize> {
1947    let count = strict_int_token::<usize>(token, "epoch.satellite_count", line)?;
1948    if token.len() > 3 || count > MAX_EPOCH_RECORD_COUNT {
1949        return Err(Error::Parse(format!(
1950            "RINEX OBS epoch satellite count exceeds the I3 field maximum of {MAX_EPOCH_RECORD_COUNT} in {line:?}"
1951        )));
1952    }
1953    Ok(count)
1954}
1955
1956fn collect_epoch_sv_tokens_v2<'a, I: Iterator<Item = &'a str>>(
1957    first_line: &str,
1958    count: usize,
1959    lines: &mut std::iter::Peekable<I>,
1960) -> Result<Vec<String>> {
1961    let mut tokens = Vec::with_capacity(count);
1962    append_epoch_sv_tokens_v2(first_line, count, &mut tokens);
1963    while tokens.len() < count {
1964        let continuation = lines.next().ok_or_else(|| {
1965            Error::Parse("RINEX OBS v2 epoch truncated: missing satellite-list line".into())
1966        })?;
1967        append_epoch_sv_tokens_v2(
1968            continuation.trim_end_matches(['\r', '\n']),
1969            count,
1970            &mut tokens,
1971        );
1972    }
1973    tokens.truncate(count);
1974    Ok(tokens)
1975}
1976
1977fn append_epoch_sv_tokens_v2(line: &str, count: usize, tokens: &mut Vec<String>) {
1978    let remaining = count.saturating_sub(tokens.len());
1979    for i in 0..remaining.min(12) {
1980        let start = 32 + i * 3;
1981        let token = field(line, start, start + 3);
1982        if token.trim().is_empty() {
1983            break;
1984        }
1985        tokens.push(token.to_string());
1986    }
1987}
1988
1989fn parse_sv_token_v2(token: &str, default_system: GnssSystem) -> Option<GnssSatelliteId> {
1990    let token = token.trim();
1991    if token.is_empty() {
1992        return None;
1993    }
1994    let mut chars = token.chars();
1995    let first = chars.next()?;
1996    let (system, prn_text) = if let Some(system) = GnssSystem::from_letter(first) {
1997        (system, chars.as_str().trim())
1998    } else {
1999        (default_system, token)
2000    };
2001    let prn = prn_text.parse::<u8>().ok()?;
2002    GnssSatelliteId::new(system, prn).ok()
2003}
2004
2005fn canonical_rinex2_obs_code(system: GnssSystem, code: &str) -> String {
2006    let code = code.trim();
2007    if code.len() == 3 {
2008        return code.to_string();
2009    }
2010    let mut chars = code.chars();
2011    let Some(kind) = chars.next() else {
2012        return code.to_string();
2013    };
2014    let Some(band) = chars.next() else {
2015        return code.to_string();
2016    };
2017    if chars.next().is_some() || !matches!(kind, 'C' | 'P' | 'L' | 'D' | 'S') {
2018        return code.to_string();
2019    }
2020
2021    if let Some(mapped) = canonical_rinex2_code_exact(system, kind, band) {
2022        return mapped.to_string();
2023    }
2024
2025    let canonical_kind = if kind == 'P' { 'C' } else { kind };
2026    let attr = rinex2_default_tracking_attr(system, kind, band);
2027    format!("{canonical_kind}{band}{attr}")
2028}
2029
2030fn canonical_rinex2_code_exact(system: GnssSystem, kind: char, band: char) -> Option<&'static str> {
2031    match (system, kind, band) {
2032        (GnssSystem::Gps, 'C', '1') => Some("C1C"),
2033        (GnssSystem::Gps, 'C', '2') => Some("C2C"),
2034        (GnssSystem::Gps, 'P', '1') => Some("C1W"),
2035        (GnssSystem::Gps, 'P', '2') => Some("C2W"),
2036        (GnssSystem::Glonass, 'C', '1') => Some("C1C"),
2037        (GnssSystem::Glonass, 'C', '2') => Some("C2C"),
2038        (GnssSystem::Glonass, 'P', '1') => Some("C1P"),
2039        (GnssSystem::Glonass, 'P', '2') => Some("C2P"),
2040        (GnssSystem::Galileo, 'C', '1') => Some("C1C"),
2041        (GnssSystem::Galileo, 'C', '2') => Some("C5Q"),
2042        (GnssSystem::Galileo, 'P', '1') => Some("C1X"),
2043        (GnssSystem::Galileo, 'P', '2') => Some("C5X"),
2044        (GnssSystem::BeiDou, 'C', '1') => Some("C2I"),
2045        (GnssSystem::BeiDou, 'C', '2') => Some("C7I"),
2046        (GnssSystem::BeiDou, 'P', '1') => Some("C2I"),
2047        (GnssSystem::BeiDou, 'P', '2') => Some("C6I"),
2048        (GnssSystem::Sbas, 'C', '1') => Some("C1C"),
2049        _ => None,
2050    }
2051}
2052
2053fn rinex2_default_tracking_attr(system: GnssSystem, kind: char, band: char) -> char {
2054    match system {
2055        GnssSystem::Gps => match band {
2056            '1' => 'C',
2057            '2' => {
2058                if kind == 'C' {
2059                    'C'
2060                } else {
2061                    'W'
2062                }
2063            }
2064            '5' => 'X',
2065            _ => 'X',
2066        },
2067        GnssSystem::Glonass => match band {
2068            '1' => 'C',
2069            '2' => 'P',
2070            '3' => 'X',
2071            _ => 'X',
2072        },
2073        GnssSystem::Galileo => match band {
2074            '1' | '6' => 'C',
2075            '5' | '7' | '8' => 'X',
2076            _ => 'X',
2077        },
2078        GnssSystem::BeiDou => match band {
2079            '2' | '6' | '7' => 'I',
2080            '1' => 'P',
2081            '5' | '8' => 'X',
2082            _ => 'X',
2083        },
2084        GnssSystem::Qzss => match band {
2085            '1' => 'C',
2086            '2' => 'L',
2087            '5' | '6' => 'X',
2088            _ => 'X',
2089        },
2090        GnssSystem::Navic => match band {
2091            '5' | '9' => 'A',
2092            _ => 'X',
2093        },
2094        GnssSystem::Sbas => match band {
2095            '1' => 'C',
2096            '5' => 'X',
2097            _ => 'X',
2098        },
2099    }
2100}
2101
2102/// Map a RINEX time-system label onto the core [`TimeScale`]. A blank label
2103/// defaults to GPS time, which is the scale a multi-GNSS observation file uses
2104/// in practice; an explicit unknown label is rejected.
2105fn time_scale_from_label(label: &str, line: &str) -> Result<TimeScale> {
2106    let label = label.trim();
2107    if label.is_empty() {
2108        Ok(TimeScale::Gpst)
2109    } else {
2110        time_scale_label(label).ok_or_else(|| {
2111            Error::Parse(format!(
2112                "RINEX OBS TIME OF FIRST OBS unknown time scale {label:?} in {line:?}"
2113            ))
2114        })
2115    }
2116}
2117
2118fn civil_second_policy_for_time_scale(scale: TimeScale) -> validate::CivilSecondPolicy {
2119    match scale {
2120        // GLONASST is UTC(SU)-based, so it can carry positive-leap-second labels.
2121        TimeScale::Utc | TimeScale::Glonasst => validate::CivilSecondPolicy::UtcLike,
2122        TimeScale::Tai
2123        | TimeScale::Tt
2124        | TimeScale::Tcg
2125        | TimeScale::Tdb
2126        | TimeScale::Tcb
2127        | TimeScale::Gpst
2128        | TimeScale::Gst
2129        | TimeScale::Bdt
2130        | TimeScale::Qzsst => validate::CivilSecondPolicy::Continuous,
2131    }
2132}
2133
2134fn parse_epoch_time_tokens(
2135    body: &str,
2136    line: &str,
2137    fields: [&'static str; 6],
2138    second_policy: validate::CivilSecondPolicy,
2139) -> Result<ObsEpochTime> {
2140    let tokens: Vec<&str> = body.split_whitespace().collect();
2141    if tokens.len() < fields.len() {
2142        let field = fields[tokens.len()];
2143        return Err(map_field_error(FieldError::Missing { field }, line));
2144    }
2145    let year = strict_int_token::<i32>(tokens[0], fields[0], line)?;
2146    let month = strict_int_token::<i64>(tokens[1], fields[1], line)?;
2147    let day = strict_int_token::<i64>(tokens[2], fields[2], line)?;
2148    let hour = strict_int_token::<i64>(tokens[3], fields[3], line)?;
2149    let minute = strict_int_token::<i64>(tokens[4], fields[4], line)?;
2150    let second = strict_f64_token(tokens[5], fields[5], line)?;
2151    let civil = validate::civil_datetime_with_second_policy(
2152        year as i64,
2153        month,
2154        day,
2155        hour,
2156        minute,
2157        second,
2158        second_policy,
2159    )
2160    .map_err(|error| map_field_error(error, line))?;
2161    Ok(ObsEpochTime {
2162        year,
2163        month: civil.month as u8,
2164        day: civil.day as u8,
2165        hour: civil.hour as u8,
2166        minute: civil.minute as u8,
2167        second: civil.second,
2168    })
2169}
2170
2171fn strict_vec3_tokens(body: &str, line: &str, fields: [&'static str; 3]) -> Result<[f64; 3]> {
2172    let tokens: Vec<&str> = body.split_whitespace().collect();
2173    if tokens.len() < fields.len() {
2174        let field = fields[tokens.len()];
2175        return Err(map_field_error(FieldError::Missing { field }, line));
2176    }
2177    Ok([
2178        strict_f64_token(tokens[0], fields[0], line)?,
2179        strict_f64_token(tokens[1], fields[1], line)?,
2180        strict_f64_token(tokens[2], fields[2], line)?,
2181    ])
2182}
2183
2184fn optional_f64_field(
2185    line: &str,
2186    start: usize,
2187    end: usize,
2188    field_name: &'static str,
2189) -> Result<Option<f64>> {
2190    let token = field(line, start, end).trim();
2191    if token.is_empty() {
2192        Ok(None)
2193    } else {
2194        strict_f64_token(token, field_name, line).map(Some)
2195    }
2196}
2197
2198fn optional_i64_field(
2199    line: &str,
2200    start: usize,
2201    end: usize,
2202    field_name: &'static str,
2203) -> Result<Option<i64>> {
2204    let token = field(line, start, end).trim();
2205    if token.is_empty() {
2206        Ok(None)
2207    } else {
2208        strict_int_token::<i64>(token, field_name, line).map(Some)
2209    }
2210}
2211
2212fn optional_trimmed(line: &str, start: usize, end: usize) -> Option<String> {
2213    let value = field(line, start, end).trim();
2214    (!value.is_empty()).then(|| value.to_string())
2215}
2216
2217fn strict_int_field<T>(line: &str, start: usize, end: usize, field_name: &'static str) -> Result<T>
2218where
2219    T: core::str::FromStr,
2220{
2221    strict_int_token(field(line, start, end), field_name, line)
2222}
2223
2224fn strict_f64_token(token: &str, field_name: &'static str, line: &str) -> Result<f64> {
2225    validate::strict_f64(token, field_name).map_err(|error| map_field_error(error, line))
2226}
2227
2228fn validate_finite_input(value: f64, field: &'static str) -> Result<()> {
2229    if value.is_finite() {
2230        Ok(())
2231    } else {
2232        Err(Error::InvalidInput(format!(
2233            "RINEX OBS {field} must be finite"
2234        )))
2235    }
2236}
2237
2238fn strict_int_token<T>(token: &str, field_name: &'static str, line: &str) -> Result<T>
2239where
2240    T: core::str::FromStr,
2241{
2242    validate::strict_int::<T>(token, field_name).map_err(|error| map_field_error(error, line))
2243}
2244
2245fn scale_factor_value(value: u32) -> Result<f64> {
2246    match value {
2247        1 | 10 | 100 | 1000 => Ok(f64::from(value)),
2248        _ => Err(Error::Parse(format!(
2249            "RINEX OBS invalid scale_factor.factor: expected 1, 10, 100, or 1000, got {value}"
2250        ))),
2251    }
2252}
2253
2254fn map_field_error(error: FieldError, line: &str) -> Error {
2255    Error::Parse(format!(
2256        "RINEX OBS invalid {}: {error} in {line:?}",
2257        error.field()
2258    ))
2259}
2260
2261fn obs_payload_field_count(payload_len: usize) -> usize {
2262    let full = payload_len / OBS_FIELD_WIDTH;
2263    let trailing = payload_len % OBS_FIELD_WIDTH;
2264    full + usize::from(trailing >= OBS_VALUE_WIDTH)
2265}
2266
2267fn sat_record_field_count(record_len: usize) -> usize {
2268    obs_payload_field_count(record_len.saturating_sub(3))
2269}
2270
2271fn ascii_fixed_columns(line: &str) -> Cow<'_, str> {
2272    if line.is_ascii() {
2273        Cow::Borrowed(line)
2274    } else {
2275        Cow::Owned(
2276            line.chars()
2277                .map(|ch| if ch.is_ascii() { ch } else { ' ' })
2278                .collect(),
2279        )
2280    }
2281}
2282
2283fn truncate_to_char_boundary(record: &mut String, len: usize) {
2284    let mut end = len.min(record.len());
2285    while !record.is_char_boundary(end) {
2286        end -= 1;
2287    }
2288    record.truncate(end);
2289}
2290
2291/// Whether `line` lexically begins with a RINEX satellite designator (a system
2292/// letter followed by one or two PRN digits), whether or not it parses to a
2293/// representable [`GnssSatelliteId`]. Used to find satellite-record boundaries
2294/// when skipping an unknown/out-of-range record, so that a following
2295/// unrepresentable record (e.g. another extended GLONASS slot) is not mistaken
2296/// for a wrapped continuation line. Observation continuation lines begin with a
2297/// right-justified numeric field, never a letter, so they never match.
2298fn starts_with_sat_designator(line: &str) -> bool {
2299    let Some(token) = line.get(0..3) else {
2300        return false;
2301    };
2302    let b = token.as_bytes();
2303    let prn = token[1..].trim();
2304    b[0].is_ascii_alphabetic()
2305        && (1..=2).contains(&prn.len())
2306        && prn.bytes().all(|byte| byte.is_ascii_digit())
2307}
2308
2309/// Consume the wrapped continuation lines of a satellite record being skipped
2310/// (its token did not resolve), leaving the iterator positioned at the next
2311/// satellite record or epoch header.
2312fn consume_skipped_sat_continuations<'a, I: Iterator<Item = &'a str>>(
2313    lines: &mut std::iter::Peekable<I>,
2314) {
2315    while let Some(raw_next) = lines.peek().copied() {
2316        let next = ascii_fixed_columns(raw_next.trim_end_matches(['\r', '\n']));
2317        if next.starts_with('>') || starts_with_sat_designator(&next) {
2318            break;
2319        }
2320        lines.next();
2321    }
2322}
2323
2324fn append_sat_continuation(record: &mut String, continuation: &str, n_obs: usize) {
2325    let fields_present = sat_record_field_count(record.len());
2326    let logical_len = 3 + fields_present * OBS_FIELD_WIDTH;
2327    truncate_to_char_boundary(record, logical_len);
2328
2329    let remaining = n_obs.saturating_sub(fields_present);
2330    let payload = field(continuation, 3, continuation.len());
2331    let fields_available = obs_payload_field_count(payload.len());
2332    let fields_to_copy = remaining.min(fields_available);
2333    let width = fields_to_copy * OBS_FIELD_WIDTH;
2334    record.push_str(field(payload, 0, width));
2335}
2336
2337/// Parse a 3-char SV token (e.g. `G01`, `C30`) into a [`GnssSatelliteId`].
2338fn parse_sv_token(token: &str) -> Option<GnssSatelliteId> {
2339    token.parse::<GnssSatelliteId>().ok()
2340}
2341
2342/// Read a single decimal digit at byte `col`, or `None` if it is blank /
2343/// non-digit / past end of line.
2344fn digit_at(line: &str, col: usize) -> Option<u8> {
2345    line.as_bytes()
2346        .get(col)
2347        .filter(|b| b.is_ascii_digit())
2348        .map(|b| b - b'0')
2349}
2350
2351mod write;
2352
2353#[cfg(all(test, sidereon_repo_tests))]
2354mod tests;