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