Skip to main content

sidereon_core/rinex_obs/
mod.rs

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