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