Skip to main content

sidereon_core/rinex_obs/
mod.rs

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