Skip to main content

sidereon_core/rinex_obs/
mod.rs

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