Skip to main content

sidereon_core/rinex_obs/
mod.rs

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