Skip to main content

sidereon_core/rinex_nav/
mod.rs

1//! RINEX 3.x and 4.xx navigation-message parsing (GPS LNAV, Galileo I/NAV and
2//! F/NAV, BeiDou D1/D2).
3//!
4//! Version 4 wraps each record in a `> EPH|STO|EOP|ION SVNN MSG` frame marker but
5//! keeps the same fixed-column broadcast-orbit layout, so the two versions share
6//! the block parser; only the record grouping differs. CNAV-family messages
7//! (CNAV/CNV1/CNV2/CNV3) reorder the orbit roster and are recognized but not
8//! parsed.
9//!
10//! Reads broadcast ephemeris records out of a RINEX navigation file into the
11//! typed [`BroadcastRecord`]s the [`crate::broadcast`] evaluator consumes. This
12//! is deterministic byte-to-record parsing of a fixed-column text format, not a
13//! float recipe: there is no 0-ULP claim here, and a small in-house parser is
14//! used in preference to a heavyweight RINEX dependency (the published `rinex`
15//! crate pulls ~90 transitive crates, including computational-geometry stacks,
16//! for what is a fixed-width text read).
17//!
18//! Scope: the GPS, Galileo, and BeiDou Keplerian record layouts (eight lines:
19//! the SV/epoch/clock line plus seven broadcast-orbit lines), plus the GLONASS
20//! four-line state-vector layout (parsed by [`parse_glonass`] and evaluated by
21//! the [`crate::glonass`] RK4 propagator, not the Keplerian path). Other
22//! constellations' records (SBAS, QZSS) are recognized as record boundaries and
23//! skipped, so a mixed file parses without error but yields only the supported
24//! systems.
25
26mod store;
27pub use store::BroadcastStore;
28
29mod write;
30pub use write::encode_nav;
31
32use crate::astro::time::model::{GnssWeekTow, TimeScale};
33use crate::astro::time::{civil, gnss};
34use crate::broadcast::{ClockPolynomial, ConstellationConstants, KeplerianElements};
35use crate::constants::{KM_TO_M, SECONDS_PER_HOUR};
36use crate::format::columns::{field, raw_field};
37use crate::id::{GnssSatelliteId, GnssSystem};
38use crate::ionex::GalileoNequickCoeffs;
39use crate::validate::{self, FieldError};
40
41/// Parse a fixed-column RINEX broadcast-orbit numeric field, accepting Fortran
42/// `D`/`d` exponents. `None` for a missing, blank, or malformed field. The field
43/// label matches the lenient numeric reader the RINEX family shares, so the
44/// accepted/rejected forms are identical across the readers.
45fn parse_f64(line: &str, start: usize, end: usize) -> Option<f64> {
46    let value = crate::format::columns::fortran_f64(line, start, end, "numeric field")?;
47    // The fixed-width `D19.12` serializer field cannot hold a three-digit
48    // exponent, so a value outside that range is not representable in this format.
49    // Treat it as absent (the lenient `None` the readers already use for a
50    // malformed field) so the parse/encode domains agree: a required field then
51    // surfaces as a parse error, an optional one as absent. Real broadcast values
52    // have small exponents and are unaffected.
53    write::d19_12_representable(value).then_some(value)
54}
55
56/// Fallback half-window (seconds, either side of `toe`) for a record that does
57/// not broadcast a fit interval (Galileo, BeiDou). A coarse validity guard - a
58/// stale or wrong-week product is off by at least a week, so this rejects it as
59/// "no ephemeris" rather than silently extrapolating. GPS records carry an
60/// explicit curve-fit interval (see [`BroadcastRecord::fit_interval_s`]) and use
61/// half of that instead.
62pub(crate) const MAX_EPHEMERIS_AGE_S: f64 = 4.0 * SECONDS_PER_HOUR;
63
64/// GLONASS broadcast records are valid +/-15 minutes around their reference
65/// epoch (the nominal half-hour upload cadence), so a query farther than this
66/// reports no ephemeris rather than extrapolating the RK4 integration.
67pub(crate) const GLONASS_MAX_AGE_S: f64 = 15.0 * 60.0;
68const GPS_NOMINAL_FIT_INTERVAL_S: f64 = 4.0 * SECONDS_PER_HOUR;
69const GPS_LEGACY_EXTENDED_FIT_INTERVAL_S: f64 = 8.0 * SECONDS_PER_HOUR;
70const GLONASS_FREQ_CHANNEL_MIN: i32 = -7;
71const GLONASS_FREQ_CHANNEL_MAX: i32 = 6;
72
73pub(crate) fn valid_glonass_frequency_channel(channel: i32) -> bool {
74    (GLONASS_FREQ_CHANNEL_MIN..=GLONASS_FREQ_CHANNEL_MAX).contains(&channel)
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78struct RinexVersion {
79    major: u8,
80    minor: u8,
81}
82
83impl RinexVersion {
84    fn gps_fit_interval_uses_legacy_flag(self) -> bool {
85        self.major == 3 && self.minor <= 2
86    }
87}
88
89/// Which broadcast navigation message a record carries.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum NavMessage {
92    /// GPS legacy navigation message.
93    GpsLnav,
94    /// Galileo integrity navigation message (E1/E5b dual, E1 single-frequency).
95    GalileoInav,
96    /// Galileo F/NAV message (E5a).
97    GalileoFnav,
98    /// BeiDou D1 message (MEO/IGSO satellites).
99    BeidouD1,
100    /// BeiDou D2 message (geostationary satellites).
101    BeidouD2,
102}
103
104/// Broadcast issue-of-data plus the navigation message identity.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub struct BroadcastIssue {
107    /// The native issue value: IODE for GPS, IODnav for Galileo.
108    pub issue: u32,
109    /// The navigation message carrying the issue value.
110    pub message: NavMessage,
111}
112
113/// A broadcast group-delay term carried by a RINEX NAV record.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum BroadcastGroupDelayTerm {
116    /// GPS LNAV TGD.
117    GpsTgd,
118    /// Galileo BGD E5a/E1.
119    GalileoBgdE5aE1,
120    /// Galileo BGD E5b/E1.
121    GalileoBgdE5bE1,
122    /// BeiDou TGD1.
123    BeidouTgd1,
124    /// BeiDou TGD2.
125    BeidouTgd2,
126}
127
128/// Per-signal broadcast group delays preserved from one NAV record.
129#[derive(Debug, Clone, Copy, PartialEq, Default)]
130pub struct BroadcastGroupDelays {
131    /// GPS LNAV TGD, seconds.
132    pub gps_tgd_s: Option<f64>,
133    /// Galileo BGD E5a/E1, seconds.
134    pub galileo_bgd_e5a_e1_s: Option<f64>,
135    /// Galileo BGD E5b/E1, seconds.
136    pub galileo_bgd_e5b_e1_s: Option<f64>,
137    /// BeiDou TGD1, seconds.
138    pub beidou_tgd1_s: Option<f64>,
139    /// BeiDou TGD2, seconds.
140    pub beidou_tgd2_s: Option<f64>,
141}
142
143impl BroadcastGroupDelays {
144    /// Build the GPS LNAV delay set.
145    pub const fn gps_lnav(tgd_s: f64) -> Self {
146        Self {
147            gps_tgd_s: Some(tgd_s),
148            galileo_bgd_e5a_e1_s: None,
149            galileo_bgd_e5b_e1_s: None,
150            beidou_tgd1_s: None,
151            beidou_tgd2_s: None,
152        }
153    }
154
155    /// Build the Galileo delay set.
156    pub const fn galileo(bgd_e5a_e1_s: f64, bgd_e5b_e1_s: f64) -> Self {
157        Self {
158            gps_tgd_s: None,
159            galileo_bgd_e5a_e1_s: Some(bgd_e5a_e1_s),
160            galileo_bgd_e5b_e1_s: Some(bgd_e5b_e1_s),
161            beidou_tgd1_s: None,
162            beidou_tgd2_s: None,
163        }
164    }
165
166    /// Build the BeiDou delay set.
167    pub const fn beidou(tgd1_s: f64, tgd2_s: f64) -> Self {
168        Self {
169            gps_tgd_s: None,
170            galileo_bgd_e5a_e1_s: None,
171            galileo_bgd_e5b_e1_s: None,
172            beidou_tgd1_s: Some(tgd1_s),
173            beidou_tgd2_s: Some(tgd2_s),
174        }
175    }
176
177    /// Select a specific group-delay term.
178    pub const fn get(&self, term: BroadcastGroupDelayTerm) -> Option<f64> {
179        match term {
180            BroadcastGroupDelayTerm::GpsTgd => self.gps_tgd_s,
181            BroadcastGroupDelayTerm::GalileoBgdE5aE1 => self.galileo_bgd_e5a_e1_s,
182            BroadcastGroupDelayTerm::GalileoBgdE5bE1 => self.galileo_bgd_e5b_e1_s,
183            BroadcastGroupDelayTerm::BeidouTgd1 => self.beidou_tgd1_s,
184            BroadcastGroupDelayTerm::BeidouTgd2 => self.beidou_tgd2_s,
185        }
186    }
187
188    /// The delay term historically used for broadcast-clock evaluation.
189    ///
190    /// BeiDou has no signal choice at this store level, so it keeps the previous
191    /// TGD1 behavior. Callers that know their signal should use [`Self::get`].
192    pub const fn for_message(self, system: GnssSystem, message: NavMessage) -> Option<f64> {
193        match (system, message) {
194            (GnssSystem::Gps, NavMessage::GpsLnav) => self.get(BroadcastGroupDelayTerm::GpsTgd),
195            (GnssSystem::Galileo, NavMessage::GalileoFnav) => {
196                self.get(BroadcastGroupDelayTerm::GalileoBgdE5aE1)
197            }
198            (GnssSystem::Galileo, NavMessage::GalileoInav) => {
199                self.get(BroadcastGroupDelayTerm::GalileoBgdE5bE1)
200            }
201            (GnssSystem::BeiDou, NavMessage::BeidouD1 | NavMessage::BeidouD2) => {
202                self.get(BroadcastGroupDelayTerm::BeidouTgd1)
203            }
204            _ => None,
205        }
206    }
207}
208
209/// Whether a BeiDou PRN is a geostationary satellite (BDS-2 C01-C05, BDS-3
210/// C59-C61), which take the geostationary orbit-evaluation branch.
211pub fn is_beidou_geo(sat: GnssSatelliteId) -> bool {
212    sat.system == GnssSystem::BeiDou && (sat.prn <= 5 || (59..=61).contains(&sat.prn))
213}
214
215/// A Klobuchar-8 broadcast ionosphere coefficient set (the eight alpha/beta
216/// values transmitted by GPS and BeiDou; the same model serves both, evaluated
217/// per carrier - see [`crate::ionex::klobuchar_native`]).
218#[derive(Debug, Clone, Copy, PartialEq)]
219pub struct KlobucharAlphaBeta {
220    /// Cosine-amplitude polynomial coefficients (a0..a3).
221    pub alpha: [f64; 4],
222    /// Period polynomial coefficients (b0..b3).
223    pub beta: [f64; 4],
224}
225
226/// Broadcast ionosphere-correction coefficients from a RINEX header's
227/// `IONOSPHERIC CORR` lines or RINEX 4 body `> ION` frames.
228///
229/// Captures the Klobuchar-8 sets used by GPS (`GPSA`/`GPSB`) and BeiDou
230/// (`BDSA`/`BDSB`), plus Galileo's three NeQuick-G effective-ionisation
231/// coefficients (`GAL`). QZSS and NavIC Klobuchar sets are not retained.
232#[derive(Debug, Clone, Copy, PartialEq, Default)]
233pub struct IonoCorrections {
234    /// GPS broadcast Klobuchar coefficients (`GPSA`/`GPSB`), if present.
235    pub gps: Option<KlobucharAlphaBeta>,
236    /// BeiDou broadcast Klobuchar coefficients (`BDSA`/`BDSB`), if present.
237    pub beidou: Option<KlobucharAlphaBeta>,
238    /// Galileo broadcast NeQuick-G coefficients (`GAL`), if present.
239    pub galileo: Option<GalileoNequickCoeffs>,
240}
241
242/// One parsed GLONASS broadcast record: a PZ-90.11 ECEF state vector and the
243/// clock terms, evaluated by the crate's GLONASS RK4 propagator (GLONASS is not
244/// Keplerian, so it does not use [`BroadcastRecord`]).
245#[derive(Debug, Clone, Copy, PartialEq)]
246pub struct GlonassRecord {
247    /// The transmitting satellite.
248    pub satellite_id: GnssSatelliteId,
249    /// Reference epoch as seconds past J2000 in **UTC** (leap-second-independent;
250    /// the store adds the GPS−UTC offset to compare with the GPST-aligned query).
251    pub toe_utc_j2000_s: f64,
252    /// PZ-90.11 ECEF position at the reference epoch (meters).
253    pub pos_m: [f64; 3],
254    /// PZ-90.11 ECEF velocity at the reference epoch (meters/second).
255    pub vel_m_s: [f64; 3],
256    /// Lunisolar acceleration at the reference epoch (meters/second^2).
257    pub acc_m_s2: [f64; 3],
258    /// Clock bias broadcast field (−TauN, seconds).
259    pub clk_bias: f64,
260    /// Relative frequency offset (+GammaN, dimensionless).
261    pub gamma_n: f64,
262    /// Satellite health (0 is healthy).
263    pub sv_health: f64,
264    /// FDMA frequency-channel number.
265    pub freq_channel: i32,
266}
267
268/// A GLONASS record skipped by [`parse_glonass_lenient`] because its slot is not
269/// representable as a [`GnssSatelliteId`] (an extended slot beyond the engine's
270/// PRN cap, e.g. `R28` in real BKG/IGS products).
271#[derive(Debug, Clone, PartialEq, Eq)]
272pub struct SkippedGlonass {
273    /// The 3-character satellite token as it appeared in the file (`R28`).
274    pub token: String,
275}
276
277/// The result of a lenient GLONASS parse: the representable records plus the
278/// slot tokens that were skipped.
279///
280/// Mirrors the partial-success reporting used elsewhere for unrepresentable
281/// input (`RinexObs::skipped_records`, [`crate::constellation::Catalog`]): a
282/// dropped record carries its identity rather than vanishing silently, so a
283/// caller can surface how many / which slots were skipped.
284#[derive(Debug, Clone, PartialEq, Default)]
285pub struct GlonassParse {
286    /// Records for representable slots, in file order.
287    pub records: Vec<GlonassRecord>,
288    /// Slots that could not be represented and were skipped, in file order.
289    pub skipped: Vec<SkippedGlonass>,
290}
291
292/// One parsed broadcast navigation record.
293#[derive(Debug, Clone, Copy, PartialEq)]
294pub struct BroadcastRecord {
295    /// The transmitting satellite.
296    pub satellite_id: GnssSatelliteId,
297    /// The navigation message the record carries.
298    pub message: NavMessage,
299    /// Broadcast issue-of-data for issue-matched correction products.
300    pub issue_of_data: BroadcastIssue,
301    /// Native broadcast week number (from the broadcast record).
302    pub week: u32,
303    /// Scale-tagged ephemeris reference time (`toe`).
304    pub toe: GnssWeekTow,
305    /// Scale-tagged clock reference time (`toc`).
306    pub toc: GnssWeekTow,
307    /// Keplerian orbital elements (`toe_sow` is seconds of week).
308    pub elements: KeplerianElements,
309    /// Clock polynomial (`toc_sow` is the record's own epoch, seconds of week).
310    pub clock: ClockPolynomial,
311    /// Broadcast group-delay terms carried by this message.
312    pub group_delays: BroadcastGroupDelays,
313    /// Satellite health word (0 is healthy for the GPS/Galileo nominal case).
314    pub sv_health: f64,
315    /// Signal-in-space accuracy: GPS URA (m) / Galileo SISA (m).
316    pub sv_accuracy_m: f64,
317    /// GPS curve-fit interval in seconds, centered on `toe` (IS-GPS-200): the
318    /// record is valid for `toe ± fit_interval_s / 2`. `None` for Galileo and
319    /// BeiDou, which do not broadcast a fit interval in the RINEX record; those
320    /// fall back to the crate's nominal four-hour age bound.
321    pub fit_interval_s: Option<f64>,
322}
323
324impl BroadcastRecord {
325    /// Native time scale used by this record's `toe`/`toc`.
326    pub const fn time_scale(&self) -> TimeScale {
327        self.toe.system
328    }
329
330    /// The per-constellation constants this record evaluates with.
331    pub const fn constants(&self) -> ConstellationConstants {
332        match self.satellite_id.system {
333            GnssSystem::Galileo => ConstellationConstants::GALILEO,
334            GnssSystem::BeiDou => ConstellationConstants::BEIDOU,
335            // GPS (and any other Keplerian system) use the GPS constants.
336            _ => ConstellationConstants::GPS,
337        }
338    }
339
340    /// Group delay used by the broadcast-clock evaluator for this message.
341    pub fn broadcast_clock_group_delay_s(&self) -> f64 {
342        self.group_delays
343            .for_message(self.satellite_id.system, self.message)
344            .unwrap_or(0.0)
345    }
346
347    /// Build a GPS LNAV record from decoded navigation-message subframes.
348    ///
349    /// This closes the `lnav::decode -> broadcast source` half of the real-time
350    /// pipeline: feed [`crate::navigation::lnav::decode`]'s output here, collect
351    /// the records into a [`BroadcastStore`], and solve with
352    /// [`solve_broadcast`](crate::positioning::solve_broadcast). The conversion
353    /// matches the RINEX navigation parser's record exactly except for the inputs
354    /// only the air interface carries:
355    ///
356    /// - The decoded angular elements are in semicircles (and semicircles/second)
357    ///   as transmitted by GPS LNAV; they are scaled to the radians the
358    ///   [`crate::broadcast`] evaluator expects (the harmonic `cuc..cis` terms are
359    ///   already radians and `crc`/`crs` meters, so they pass through unchanged).
360    /// - The 10-bit transmitted week number is ambiguous across the GPS
361    ///   1024-week rollover, so the full (unrolled) week is taken from
362    ///   `full_week` rather than inferred from the message. The caller-supplied
363    ///   `full_week` must agree with the decoded 10-bit week
364    ///   (`full_week % 1024 == decoded.week_number`); a disagreement means the
365    ///   caller is unrolling against the wrong rollover epoch and is rejected with
366    ///   [`LnavRecordError::WeekMismatch`] rather than silently dating the
367    ///   ephemeris to the wrong GPS week.
368    /// - The fit interval is derived from the fit-interval flag together with
369    ///   IODE/IODC per IS-GPS-200N 20.3.3.4.3.1 and Table 20-XII (the table the
370    ///   older revisions numbered 20-XI): `flag = 0` is the nominal 4-hour curve
371    ///   fit; `flag = 1` is an extended fit whose length is set by IODE/IODC
372    ///   (short-term extended `IODE < 240` is 6 hours; long-term extended
373    ///   `IODE` in `240..=255` is 8/14/26 hours by IODC range). Reserved IODC
374    ///   combinations are rejected with [`LnavRecordError::FitIntervalUnsupported`].
375    /// - The 4-bit URA index maps to its IS-GPS-200N 20.3.3.3.1.3 meters value;
376    ///   index 15 (no accuracy prediction / not to be used) carries no usable
377    ///   bound and is rejected with [`LnavRecordError::NoUraPrediction`].
378    ///
379    /// LNAV is the GPS L1 C/A message, so a non-GPS `satellite_id` is rejected.
380    pub fn from_lnav(
381        decoded: &crate::navigation::lnav::LnavDecoded,
382        satellite_id: GnssSatelliteId,
383        full_week: u32,
384    ) -> Result<Self, LnavRecordError> {
385        if satellite_id.system != GnssSystem::Gps {
386            return Err(LnavRecordError::NotGps(satellite_id));
387        }
388
389        // The unrolled `full_week` must reduce to the decoded 10-bit week
390        // (IS-GPS-200N 20.3.3.3.1.1). A mismatch means the caller unrolled
391        // against the wrong rollover epoch; trusting `full_week` would date the
392        // ephemeris to the wrong GPS week, so reject it.
393        if i64::from(full_week % 1024) != decoded.week_number {
394            return Err(LnavRecordError::WeekMismatch {
395                full_week,
396                decoded_week: decoded.week_number,
397            });
398        }
399
400        let sv_accuracy_m = gps_ura_index_to_meters(decoded.ura_index)
401            .ok_or(LnavRecordError::NoUraPrediction(decoded.ura_index))?;
402        let fit_interval_s =
403            gps_fit_interval_from_flag(decoded.fit_interval_flag, decoded.iode, decoded.iodc)?;
404
405        // GPS LNAV transmits the angular ephemeris elements in semicircles and
406        // semicircles/second; the Keplerian evaluator works in radians.
407        const SEMICIRCLE_TO_RAD: f64 = core::f64::consts::PI;
408
409        let elements = KeplerianElements {
410            sqrt_a: decoded.sqrt_a,
411            e: decoded.eccentricity,
412            m0: decoded.m0 * SEMICIRCLE_TO_RAD,
413            delta_n: decoded.delta_n * SEMICIRCLE_TO_RAD,
414            omega0: decoded.omega0 * SEMICIRCLE_TO_RAD,
415            i0: decoded.i0 * SEMICIRCLE_TO_RAD,
416            omega: decoded.omega * SEMICIRCLE_TO_RAD,
417            omega_dot: decoded.omega_dot * SEMICIRCLE_TO_RAD,
418            idot: decoded.idot * SEMICIRCLE_TO_RAD,
419            cuc: decoded.cuc,
420            cus: decoded.cus,
421            crc: decoded.crc,
422            crs: decoded.crs,
423            cic: decoded.cic,
424            cis: decoded.cis,
425            toe_sow: decoded.toe as f64,
426        };
427        let clock = ClockPolynomial {
428            af0: decoded.af0,
429            af1: decoded.af1,
430            af2: decoded.af2,
431            toc_sow: decoded.toc as f64,
432        };
433
434        let toe = GnssWeekTow::new(TimeScale::Gpst, full_week, elements.toe_sow)
435            .and_then(GnssWeekTow::normalized)
436            .map_err(|_| LnavRecordError::InvalidEpoch("toe"))?;
437        let toc = GnssWeekTow::new(TimeScale::Gpst, full_week, clock.toc_sow)
438            .and_then(GnssWeekTow::normalized)
439            .map_err(|_| LnavRecordError::InvalidEpoch("toc"))?;
440
441        Ok(BroadcastRecord {
442            satellite_id,
443            message: NavMessage::GpsLnav,
444            issue_of_data: BroadcastIssue {
445                issue: decoded.iode as u32,
446                message: NavMessage::GpsLnav,
447            },
448            week: full_week,
449            toe,
450            toc,
451            elements,
452            clock,
453            group_delays: BroadcastGroupDelays::gps_lnav(decoded.tgd),
454            sv_health: decoded.sv_health as f64,
455            sv_accuracy_m,
456            fit_interval_s: Some(fit_interval_s),
457        })
458    }
459}
460
461/// The nominal GPS user range accuracy (URA) value in meters for a 4-bit URA
462/// index N (IS-GPS-200N Section 20.3.3.3.1.3). Each value is the upper bound of
463/// the URA band the index represents. Index 15 carries no accuracy prediction
464/// (the SV is not to be used for safe navigation) and has no usable meters
465/// bound, so it returns `None` rather than a fabricated finite value.
466fn gps_ura_index_to_meters(index: i64) -> Option<f64> {
467    let meters = match index {
468        0 => 2.4,
469        1 => 3.4,
470        2 => 4.85,
471        3 => 6.85,
472        4 => 9.65,
473        5 => 13.65,
474        6 => 24.0,
475        7 => 48.0,
476        8 => 96.0,
477        9 => 192.0,
478        10 => 384.0,
479        11 => 768.0,
480        12 => 1536.0,
481        13 => 3072.0,
482        14 => 6144.0,
483        // 15 = no accuracy prediction / not to be used; anything outside the
484        // 4-bit range cannot occur from a decoded message either.
485        _ => return None,
486    };
487    Some(meters)
488}
489
490const GPS_FIT_INTERVAL_6H_S: f64 = 6.0 * SECONDS_PER_HOUR;
491const GPS_FIT_INTERVAL_8H_S: f64 = 8.0 * SECONDS_PER_HOUR;
492const GPS_FIT_INTERVAL_14H_S: f64 = 14.0 * SECONDS_PER_HOUR;
493const GPS_FIT_INTERVAL_26H_S: f64 = 26.0 * SECONDS_PER_HOUR;
494
495/// Curve-fit interval (seconds) for a GPS LNAV record from its fit-interval flag
496/// plus IODE/IODC, per IS-GPS-200N 20.3.3.4.3.1, 6.2.3, and Table 20-XII (the
497/// table older revisions numbered 20-XI).
498///
499/// `flag = 0` is the nominal 4-hour fit. `flag = 1` is an extended fit: IODE
500/// selects short-term extended operations (`IODE < 240`, a 6-hour fit) from
501/// long-term extended operations (`IODE` in `240..=255`), and for the long-term
502/// case the IODC range selects 8, 14, or 26 hours. Reserved IODC values and any
503/// other flag/IODE/IODC combination are rejected.
504fn gps_fit_interval_from_flag(
505    fit_interval_flag: i64,
506    iode: i64,
507    iodc: i64,
508) -> Result<f64, LnavRecordError> {
509    let unsupported = || LnavRecordError::FitIntervalUnsupported {
510        fit_interval_flag,
511        iode,
512        iodc,
513    };
514    match fit_interval_flag {
515        0 => Ok(GPS_NOMINAL_FIT_INTERVAL_S),
516        1 => {
517            if (0..240).contains(&iode) {
518                // Short-term extended operations (Table 20-XII, 2-14 day row).
519                // IODE is an 8-bit unsigned field, so a negative value is not a
520                // real decode and falls through to the unsupported error.
521                Ok(GPS_FIT_INTERVAL_6H_S)
522            } else if (240..=255).contains(&iode) {
523                // Long-term extended operations: IODC selects the fit length.
524                match iodc {
525                    240..=247 => Ok(GPS_FIT_INTERVAL_8H_S),
526                    248..=255 | 496 => Ok(GPS_FIT_INTERVAL_14H_S),
527                    497..=503 | 1021..=1023 => Ok(GPS_FIT_INTERVAL_26H_S),
528                    _ => Err(unsupported()),
529                }
530            } else {
531                Err(unsupported())
532            }
533        }
534        _ => Err(unsupported()),
535    }
536}
537
538/// Failure building a [`BroadcastRecord`] from decoded LNAV subframes.
539#[derive(Debug, Clone, Copy, PartialEq, Eq)]
540pub enum LnavRecordError {
541    /// LNAV is the GPS L1 C/A message; the satellite is not a GPS satellite.
542    NotGps(GnssSatelliteId),
543    /// A derived week/time-of-week value was not representable.
544    InvalidEpoch(&'static str),
545    /// The caller-supplied `full_week` does not reduce to the decoded 10-bit week
546    /// (`full_week % 1024 != decoded_week`), so it unrolls to the wrong GPS week.
547    WeekMismatch {
548        /// The caller-supplied unrolled week.
549        full_week: u32,
550        /// The 10-bit week decoded from the message.
551        decoded_week: i64,
552    },
553    /// URA index 15 (or an out-of-range index) carries no accuracy prediction.
554    NoUraPrediction(i64),
555    /// The fit-interval flag / IODE / IODC combination is reserved or otherwise
556    /// not a defined IS-GPS-200N Table 20-XII curve-fit interval.
557    FitIntervalUnsupported {
558        /// The 1-bit fit-interval flag from the message.
559        fit_interval_flag: i64,
560        /// The decoded IODE.
561        iode: i64,
562        /// The decoded IODC.
563        iodc: i64,
564    },
565}
566
567impl core::fmt::Display for LnavRecordError {
568    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
569        match self {
570            LnavRecordError::NotGps(sat) => {
571                write!(f, "LNAV is a GPS message; {sat} is not a GPS satellite")
572            }
573            LnavRecordError::InvalidEpoch(field) => {
574                write!(f, "derived {field} week/TOW is not representable")
575            }
576            LnavRecordError::WeekMismatch {
577                full_week,
578                decoded_week,
579            } => write!(
580                f,
581                "full_week {full_week} (week % 1024 = {}) disagrees with decoded 10-bit week {decoded_week}",
582                full_week % 1024
583            ),
584            LnavRecordError::NoUraPrediction(index) => {
585                write!(f, "URA index {index} carries no accuracy prediction")
586            }
587            LnavRecordError::FitIntervalUnsupported {
588                fit_interval_flag,
589                iode,
590                iodc,
591            } => write!(
592                f,
593                "fit interval flag {fit_interval_flag} with IODE {iode} / IODC {iodc} is not a defined curve-fit interval"
594            ),
595        }
596    }
597}
598
599impl std::error::Error for LnavRecordError {}
600
601fn broadcast_time_scale(system: GnssSystem) -> TimeScale {
602    match system {
603        GnssSystem::Galileo => TimeScale::Gst,
604        GnssSystem::BeiDou => TimeScale::Bdt,
605        _ => TimeScale::Gpst,
606    }
607}
608
609/// Why a RINEX NAV file could not be parsed.
610#[derive(Debug, Clone, PartialEq, Eq)]
611pub enum NavParseError {
612    /// The header did not declare a supported RINEX 3 navigation file.
613    UnsupportedHeader(String),
614    /// No `END OF HEADER` line was found.
615    MissingHeaderEnd,
616    /// A record was shorter than its message layout requires.
617    TruncatedRecord(String),
618    /// A required numeric field was missing or unparseable.
619    BadField {
620        /// The satellite whose record holds the bad field.
621        satellite: String,
622        /// Which field failed.
623        field: &'static str,
624    },
625    /// A required header numeric field was malformed, non-finite, or out of range.
626    BadHeaderField {
627        /// Which header field failed.
628        field: &'static str,
629    },
630}
631
632impl core::fmt::Display for NavParseError {
633    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
634        match self {
635            NavParseError::UnsupportedHeader(s) => write!(f, "unsupported RINEX NAV header: {s}"),
636            NavParseError::MissingHeaderEnd => write!(f, "no END OF HEADER line"),
637            NavParseError::TruncatedRecord(s) => write!(f, "truncated navigation record for {s}"),
638            NavParseError::BadField { satellite, field } => {
639                write!(f, "bad/missing {field} field in record for {satellite}")
640            }
641            NavParseError::BadHeaderField { field } => {
642                write!(f, "bad/missing {field} field in navigation header")
643            }
644        }
645    }
646}
647
648impl std::error::Error for NavParseError {}
649
650/// Parse a RINEX 3.x or 4.xx navigation file into the supported (GPS, Galileo,
651/// BeiDou) Keplerian records.
652///
653/// Records of other constellations (GLONASS state-vector, SBAS) are skipped, as
654/// are version-4 CNAV-family messages (CNAV/CNV1/CNV2/CNV3): their broadcast-orbit
655/// roster reorders the fixed columns (`t_op` for `toe`, `wn_op` for `week`, extra
656/// `adot`/`deltaN0Dot` terms), so they are recognized but not parsed rather than
657/// fed wrong values. The records are returned in file order; selection (by epoch,
658/// health, message type) is the caller's job.
659pub fn parse_nav(text: &str) -> Result<Vec<BroadcastRecord>, NavParseError> {
660    let mut lines = text.lines();
661    let version = verify_and_skip_header(&mut lines)?;
662    if version.major >= 4 {
663        parse_nav_v4(lines, version)
664    } else {
665        parse_nav_v3(lines, version)
666    }
667}
668
669/// Version-3 body: a record starts at a line whose first three columns are a
670/// system letter followed by two digits; continuation lines are column-indented.
671fn parse_nav_v3<'a, I>(
672    lines: I,
673    version: RinexVersion,
674) -> Result<Vec<BroadcastRecord>, NavParseError>
675where
676    I: Iterator<Item = &'a str>,
677{
678    let mut blocks: Vec<Vec<&str>> = Vec::new();
679    for line in lines {
680        if is_record_start(line) {
681            blocks.push(vec![line]);
682        } else if let Some(last) = blocks.last_mut() {
683            last.push(line);
684        }
685    }
686
687    let mut records = Vec::new();
688    for block in &blocks {
689        let letter = block[0].as_bytes()[0] as char;
690        match GnssSystem::from_letter(letter) {
691            Some(GnssSystem::Gps) | Some(GnssSystem::Galileo) | Some(GnssSystem::BeiDou) => {
692                records.push(parse_keplerian_block(block, None, version)?);
693            }
694            // Recognized boundary, unsupported model (GLONASS state-vector, SBAS): skip.
695            _ => {}
696        }
697    }
698    Ok(records)
699}
700
701/// Version-4 body: each record is introduced by a `> EPH|STO|EOP|ION SVNN MSG`
702/// frame marker. Only `EPH` frames carrying a supported Keplerian message are
703/// parsed; the broadcast-orbit lines that follow the marker have the same
704/// fixed-column layout as version 3, so they reuse [`parse_keplerian_block`].
705/// The message type is taken from the marker token (so I/NAV vs F/NAV and D1 vs
706/// D2 are explicit, not inferred) after the marker SV and message family are
707/// cross-checked against the body line. STO/EOP/ION frames, other
708/// constellations, and CNAV-family messages are skipped.
709fn parse_nav_v4<'a, I>(
710    lines: I,
711    version: RinexVersion,
712) -> Result<Vec<BroadcastRecord>, NavParseError>
713where
714    I: Iterator<Item = &'a str>,
715{
716    // Group by marker line: each frame is its marker plus the body lines up to
717    // the next marker.
718    let frames = v4_frames(lines);
719    let mut records = Vec::new();
720    for (marker, body) in &frames {
721        let Some((frame_type, sv, msg_token)) = parse_v4_marker(marker) else {
722            continue;
723        };
724        if frame_type != "EPH" {
725            continue; // STO/EOP/ION carry no ephemeris.
726        }
727        let letter = sv.as_bytes().first().copied().map_or(' ', char::from);
728        let supported = matches!(
729            GnssSystem::from_letter(letter),
730            Some(GnssSystem::Gps) | Some(GnssSystem::Galileo) | Some(GnssSystem::BeiDou)
731        );
732        if !supported {
733            continue; // GLONASS/SBAS/QZSS/NavIC: not a supported Keplerian system.
734        }
735        // Only messages whose orbit roster matches the version-3 column layout
736        // are parsed; CNAV-family (and anything unrecognized) is skipped.
737        if let Some(message) = nav_message_from_v4_token(msg_token) {
738            validate_v4_ephemeris_marker(sv, message, body)?;
739            records.push(parse_keplerian_block(body, Some(message), version)?);
740        }
741    }
742    Ok(records)
743}
744
745fn v4_frames<'a, I>(lines: I) -> Vec<(&'a str, Vec<&'a str>)>
746where
747    I: Iterator<Item = &'a str>,
748{
749    let mut frames: Vec<(&str, Vec<&str>)> = Vec::new();
750    for line in lines {
751        if is_v4_frame_marker(line) {
752            frames.push((line, Vec::new()));
753        } else if let Some((_, body)) = frames.last_mut() {
754            body.push(line);
755        }
756    }
757    frames
758}
759
760/// Whether a version-4 line is a frame marker (`> ...`).
761fn is_v4_frame_marker(line: &str) -> bool {
762    line.starts_with("> ")
763}
764
765/// Split a version-4 frame marker `> EPH G01 LNAV` into (frame type, SV, message
766/// token), or `None` if it is malformed. Mirrors the RINEX-4 marker layout:
767/// `>` then the 4-column frame class, the SV, and the message-type token.
768fn parse_v4_marker(line: &str) -> Option<(&str, &str, &str)> {
769    let rest = line.strip_prefix('>')?;
770    let mut fields = rest.split_whitespace();
771    let frame_type = fields.next()?;
772    let sv = fields.next()?;
773    let msg_token = fields.next()?;
774    Some((frame_type, sv, msg_token))
775}
776
777/// Map a version-4 EPH message token to the [`NavMessage`] for the supported
778/// Keplerian messages, or `None` for a message whose orbit layout does not match
779/// the version-3 columns (CNAV-family) or is otherwise unsupported here.
780fn nav_message_from_v4_token(token: &str) -> Option<NavMessage> {
781    match token {
782        "LNAV" => Some(NavMessage::GpsLnav),
783        "INAV" => Some(NavMessage::GalileoInav),
784        "FNAV" => Some(NavMessage::GalileoFnav),
785        "D1" => Some(NavMessage::BeidouD1),
786        "D2" => Some(NavMessage::BeidouD2),
787        _ => None,
788    }
789}
790
791fn validate_v4_ephemeris_marker(
792    marker_sv: &str,
793    message: NavMessage,
794    body: &[&str],
795) -> Result<(), NavParseError> {
796    let Some(body_sv) = body
797        .first()
798        .and_then(|line| line.get(0..3))
799        .map(str::trim)
800        .filter(|sv| !sv.is_empty())
801    else {
802        return Ok(());
803    };
804
805    if marker_sv != body_sv {
806        return Err(NavParseError::BadField {
807            satellite: marker_sv.to_string(),
808            field: "frame marker",
809        });
810    }
811
812    let system = body_sv
813        .as_bytes()
814        .first()
815        .and_then(|b| GnssSystem::from_letter(*b as char))
816        .ok_or_else(|| NavParseError::BadField {
817            satellite: body_sv.to_string(),
818            field: "system",
819        })?;
820    if !nav_message_matches_system(message, system) {
821        return Err(NavParseError::BadField {
822            satellite: body_sv.to_string(),
823            field: "message",
824        });
825    }
826
827    Ok(())
828}
829
830fn nav_message_matches_system(message: NavMessage, system: GnssSystem) -> bool {
831    matches!(
832        (message, system),
833        (NavMessage::GpsLnav, GnssSystem::Gps)
834            | (
835                NavMessage::GalileoInav | NavMessage::GalileoFnav,
836                GnssSystem::Galileo,
837            )
838            | (
839                NavMessage::BeidouD1 | NavMessage::BeidouD2,
840                GnssSystem::BeiDou,
841            )
842    )
843}
844
845/// Parse the broadcast ionosphere coefficients from a RINEX header's
846/// `IONOSPHERIC CORR` lines or RINEX 4 body `> ION` frames (GPS
847/// `GPSA`/`GPSB`, BeiDou `BDSA`/`BDSB`, and Galileo `GAL`).
848///
849/// A complete header label pair or body frame yields the coefficient set; a
850/// missing label or frame yields `None` for that system. Parsing is
851/// deterministic text, not a 0-ULP target.
852pub fn parse_iono_corrections(text: &str) -> Result<IonoCorrections, NavParseError> {
853    parse_iono_corrections_checked(text)
854}
855
856fn parse_iono_corrections_checked(text: &str) -> Result<IonoCorrections, NavParseError> {
857    // The IONOSPHERIC CORR line is `A4,1X,4(D12.4)`: a 4-char label, a space,
858    // then up to four coefficients in 12-wide columns.
859    //
860    // GPS/BeiDou are Klobuchar models with four coefficients per row
861    // (alpha0..alpha3 / beta0..beta3); all four columns are required and a
862    // truncated row is a malformed header, not a tolerable short line.
863    let klobuchar_row = |line: &str| -> Result<[f64; 4], NavParseError> {
864        Ok([
865            strict_header_f64(line, 5, 17, "ionospheric correction")?,
866            strict_header_f64(line, 17, 29, "ionospheric correction")?,
867            strict_header_f64(line, 29, 41, "ionospheric correction")?,
868            strict_header_f64(line, 41, 53, "ionospheric correction")?,
869        ])
870    };
871    // Galileo is NeQuick-G with three coefficients (ai0,ai1,ai2). The fourth
872    // column is the disturbance flag, which real/merged headers frequently leave
873    // blank; only the three coefficients are read, so the row parses whether or
874    // not that flag is present.
875    let nequick_row = |line: &str| -> Result<[f64; 3], NavParseError> {
876        Ok([
877            strict_header_f64(line, 5, 17, "ionospheric correction")?,
878            strict_header_f64(line, 17, 29, "ionospheric correction")?,
879            strict_header_f64(line, 29, 41, "ionospheric correction")?,
880        ])
881    };
882    let (mut gpsa, mut gpsb, mut bdsa, mut bdsb, mut gal) = (None, None, None, None, None);
883    for line in text.lines() {
884        if line.contains("END OF HEADER") {
885            break;
886        }
887        if !line.contains("IONOSPHERIC CORR") {
888            continue;
889        }
890        match line.get(0..4).map(str::trim) {
891            Some("GPSA") => gpsa = Some(klobuchar_row(line)?),
892            Some("GPSB") => gpsb = Some(klobuchar_row(line)?),
893            Some("BDSA") => bdsa = Some(klobuchar_row(line)?),
894            Some("BDSB") => bdsb = Some(klobuchar_row(line)?),
895            Some("GAL") => {
896                let row = nequick_row(line)?;
897                gal = Some(GalileoNequickCoeffs {
898                    ai0: row[0],
899                    ai1: row[1],
900                    ai2: row[2],
901                });
902            }
903            _ => {}
904        }
905    }
906    let pair = |a: Option<[f64; 4]>, b: Option<[f64; 4]>| match (a, b) {
907        (Some(alpha), Some(beta)) => Some(KlobucharAlphaBeta { alpha, beta }),
908        _ => None,
909    };
910    let mut iono = IonoCorrections {
911        gps: pair(gpsa, gpsb),
912        beidou: pair(bdsa, bdsb),
913        galileo: gal,
914    };
915    parse_v4_body_iono_corrections(text, &mut iono)?;
916    Ok(iono)
917}
918
919fn parse_v4_body_iono_corrections(
920    text: &str,
921    iono: &mut IonoCorrections,
922) -> Result<(), NavParseError> {
923    let mut lines = text.lines();
924    for line in lines.by_ref() {
925        if line.contains("END OF HEADER") {
926            break;
927        }
928    }
929
930    for (marker, body) in v4_frames(lines) {
931        let Some((frame_type, sv, _msg_token)) = parse_v4_marker(marker) else {
932            continue;
933        };
934        if frame_type != "ION" {
935            continue;
936        }
937        let values = parse_v4_iono_values(sv, &body)?;
938        match sv
939            .as_bytes()
940            .first()
941            .and_then(|b| GnssSystem::from_letter(*b as char))
942        {
943            Some(GnssSystem::Gps) => {
944                iono.gps = Some(KlobucharAlphaBeta {
945                    alpha: iono_values_4(&values, 0, sv)?,
946                    beta: iono_values_4(&values, 4, sv)?,
947                });
948            }
949            Some(GnssSystem::BeiDou) => {
950                iono.beidou = Some(KlobucharAlphaBeta {
951                    alpha: iono_values_4(&values, 0, sv)?,
952                    beta: iono_values_4(&values, 4, sv)?,
953                });
954            }
955            Some(GnssSystem::Galileo) => {
956                let coeffs = iono_values_3(&values, 0, sv)?;
957                iono.galileo = Some(GalileoNequickCoeffs {
958                    ai0: coeffs[0],
959                    ai1: coeffs[1],
960                    ai2: coeffs[2],
961                });
962            }
963            _ => {}
964        }
965    }
966    Ok(())
967}
968
969fn parse_v4_iono_values(sv: &str, body: &[&str]) -> Result<Vec<f64>, NavParseError> {
970    if body.is_empty() {
971        return Err(NavParseError::BadField {
972            satellite: sv.to_string(),
973            field: "ionospheric correction",
974        });
975    }
976
977    let mut values = Vec::new();
978    for (idx, line) in body.iter().enumerate() {
979        let ranges: &[(usize, usize)] = if idx == 0 {
980            &[(23, 42), (42, 61), (61, 80)]
981        } else {
982            &[(4, 23), (23, 42), (42, 61), (61, 80)]
983        };
984        for &(start, end) in ranges {
985            let raw = raw_field(line, start, end);
986            if raw.trim().is_empty() {
987                continue;
988            }
989            values.push(
990                validate::strict_f64(raw, "ionospheric correction")
991                    .map_err(|error| map_record_field_error(error, sv))?,
992            );
993        }
994    }
995    Ok(values)
996}
997
998fn iono_values_4(values: &[f64], start: usize, sv: &str) -> Result<[f64; 4], NavParseError> {
999    let Some(slice) = values.get(start..start + 4) else {
1000        return Err(NavParseError::BadField {
1001            satellite: sv.to_string(),
1002            field: "ionospheric correction",
1003        });
1004    };
1005    Ok([slice[0], slice[1], slice[2], slice[3]])
1006}
1007
1008fn iono_values_3(values: &[f64], start: usize, sv: &str) -> Result<[f64; 3], NavParseError> {
1009    let Some(slice) = values.get(start..start + 3) else {
1010        return Err(NavParseError::BadField {
1011            satellite: sv.to_string(),
1012            field: "ionospheric correction",
1013        });
1014    };
1015    Ok([slice[0], slice[1], slice[2]])
1016}
1017
1018/// The leap-second count (GPS − UTC) from the header's `LEAP SECONDS` line, used
1019/// to map a GLONASS (UTC) reference epoch onto the GPST-aligned query time. The
1020/// value is the first field; `None` if the line is absent.
1021pub fn parse_leap_seconds(text: &str) -> Result<Option<f64>, NavParseError> {
1022    parse_leap_seconds_checked(text)
1023}
1024
1025fn parse_leap_seconds_checked(text: &str) -> Result<Option<f64>, NavParseError> {
1026    for line in text.lines() {
1027        if line.contains("END OF HEADER") {
1028            break;
1029        }
1030        if line.contains("LEAP SECONDS") {
1031            return strict_header_integer_f64(line, 0, 6, "leap seconds").map(Some);
1032        }
1033    }
1034    Ok(None)
1035}
1036
1037/// Seconds from the J2000 epoch (2000-01-01 12:00) to a UTC calendar instant,
1038/// via the canonical no-leap civil conversion. Bit-identical to the previous
1039/// day-count arithmetic (the Julian Day Number is offset-equal to the Hinnant
1040/// day count, and the whole-second clock fields sum exactly in `f64`).
1041fn j2000_seconds_utc(y: i64, mo: i64, d: i64, h: i64, mi: i64, s: i64) -> f64 {
1042    civil::j2000_seconds(y as i32, mo as i32, d as i32, h as i32, mi as i32, s as f64)
1043}
1044
1045/// Parse the GLONASS epoch line (`Rnn YYYY MM DD HH MM SS`) to a UTC second past
1046/// J2000.
1047fn parse_glonass_epoch(l0: &str, sat: &str) -> Result<f64, NavParseError> {
1048    let year = strict_record_int::<i64>(l0, 4, 8, "epoch", sat)?;
1049    let month = strict_record_int::<i64>(l0, 9, 11, "epoch", sat)?;
1050    let day = strict_record_int::<i64>(l0, 12, 14, "epoch", sat)?;
1051    let hour = strict_record_int::<i64>(l0, 15, 17, "epoch", sat)?;
1052    let minute = strict_record_int::<i64>(l0, 18, 20, "epoch", sat)?;
1053    let second = strict_record_int::<i64>(l0, 21, 23, "epoch", sat)?;
1054    let civil = validate::civil_datetime_with_second_policy(
1055        year,
1056        month,
1057        day,
1058        hour,
1059        minute,
1060        second as f64,
1061        validate::CivilSecondPolicy::UtcLike,
1062    )
1063    .map_err(|_| NavParseError::BadField {
1064        satellite: sat.to_string(),
1065        field: "epoch",
1066    })?;
1067    Ok(j2000_seconds_utc(
1068        civil.year,
1069        i64::from(civil.month),
1070        i64::from(civil.day),
1071        i64::from(civil.hour),
1072        i64::from(civil.minute),
1073        civil.second as i64,
1074    ))
1075}
1076
1077/// Parse a 4-line RINEX 3 GLONASS record block into a [`GlonassRecord`]
1078/// (km/(km/s)/(km/s^2) state converted to SI). A missing or unparseable field is
1079/// a [`NavParseError`], not a silently dropped record.
1080fn parse_glonass_block(block: &[&str]) -> Result<GlonassRecord, NavParseError> {
1081    let l0 = block[0];
1082    let sat = l0.get(0..3).unwrap_or("").trim().to_string();
1083    if block.len() < 4 {
1084        return Err(NavParseError::TruncatedRecord(sat));
1085    }
1086    let bad = |what: &'static str| NavParseError::BadField {
1087        satellite: sat.clone(),
1088        field: what,
1089    };
1090    let satellite_id: GnssSatelliteId = sat.parse().map_err(|_| bad("prn"))?;
1091    let toe_utc_j2000_s = parse_glonass_epoch(l0, &sat)?;
1092    let clk_bias = parse_f64(l0, 23, 42).ok_or_else(|| bad("clock bias"))?;
1093    let gamma_n = parse_f64(l0, 42, 61).ok_or_else(|| bad("gamma_n"))?;
1094    let o1 = orbit_row(block[1]);
1095    let o2 = orbit_row(block[2]);
1096    let o3 = orbit_row(block[3]);
1097    let km = |v: Option<f64>, what: &'static str| v.map(|x| x * KM_TO_M).ok_or_else(|| bad(what));
1098    let g = |v: Option<f64>, what: &'static str| v.ok_or_else(|| bad(what));
1099    Ok(GlonassRecord {
1100        satellite_id,
1101        toe_utc_j2000_s,
1102        pos_m: [km(o1[0], "x")?, km(o2[0], "y")?, km(o3[0], "z")?],
1103        vel_m_s: [km(o1[1], "vx")?, km(o2[1], "vy")?, km(o3[1], "vz")?],
1104        acc_m_s2: [km(o1[2], "ax")?, km(o2[2], "ay")?, km(o3[2], "az")?],
1105        clk_bias,
1106        gamma_n,
1107        sv_health: g(o1[3], "health")?,
1108        freq_channel: glonass_frequency_channel(g(o2[3], "frequency channel")?, &sat)?,
1109    })
1110}
1111
1112/// Parse all GLONASS (`R`) records from a RINEX 3.x navigation file, in file
1113/// order; selection is the caller's job. A malformed *supported* record is a
1114/// [`NavParseError`] rather than a silently dropped one, but a record for a slot
1115/// the engine cannot represent (an extended GLONASS slot beyond the PRN cap, e.g.
1116/// `R28` in real BKG/IGS products) is skipped rather than rejecting the whole
1117/// file - the same treatment unsupported constellations get in
1118/// [`parse_nav_v3`]. (Version-4 GLONASS frames are not yet parsed.)
1119pub fn parse_glonass(text: &str) -> Result<Vec<GlonassRecord>, NavParseError> {
1120    Ok(parse_glonass_lenient(text)?.records)
1121}
1122
1123/// Like [`parse_glonass`], but also returns the slots that were skipped because
1124/// they are not representable as a [`GnssSatelliteId`] (an extended slot beyond
1125/// the PRN cap, e.g. `R28`).
1126///
1127/// [`parse_glonass`] drops that list silently; use this when a caller needs to
1128/// surface how many / which records were skipped, consistent with the
1129/// lenient-skip reporting elsewhere in the crate. A malformed *representable*
1130/// record is still a [`NavParseError`], not a skip.
1131pub fn parse_glonass_lenient(text: &str) -> Result<GlonassParse, NavParseError> {
1132    let mut lines = text.lines();
1133    verify_and_skip_header(&mut lines)?;
1134    let mut blocks: Vec<Vec<&str>> = Vec::new();
1135    for line in lines {
1136        if is_record_start(line) {
1137            blocks.push(vec![line]);
1138        } else if let Some(last) = blocks.last_mut() {
1139            last.push(line);
1140        }
1141    }
1142    let mut out = GlonassParse::default();
1143    for block in blocks.iter().filter(|b| b[0].starts_with('R')) {
1144        // A GLONASS slot beyond the engine's PRN cap is not representable as a
1145        // `GnssSatelliteId`. Skip such a record (one out-of-range slot must not
1146        // discard every other satellite's ephemeris) instead of erroring, but
1147        // record its identity so it is not lost silently; a representable slot
1148        // with a malformed numeric field still errors.
1149        let sat = block[0].get(0..3).unwrap_or("").trim();
1150        if sat.parse::<GnssSatelliteId>().is_err() {
1151            out.skipped.push(SkippedGlonass {
1152                token: sat.to_string(),
1153            });
1154            continue;
1155        }
1156        out.records.push(parse_glonass_block(block)?);
1157    }
1158    Ok(out)
1159}
1160
1161/// Skip the header, returning the RINEX version. Major versions 3 and 4 share
1162/// the fixed-column orbit layout; version 4 wraps each record in a frame marker
1163/// line (see [`parse_v4_marker`]), which is why `parse_nav` dispatches on it.
1164fn verify_and_skip_header<'a, I>(lines: &mut I) -> Result<RinexVersion, NavParseError>
1165where
1166    I: Iterator<Item = &'a str>,
1167{
1168    let mut version_seen: Option<RinexVersion> = None;
1169    for line in lines.by_ref() {
1170        if line.contains("RINEX VERSION / TYPE") {
1171            // Column 0-8 holds the version; column 20 the file type ('N' = NAV).
1172            let version = line.get(0..9).unwrap_or("").trim();
1173            let detected = parse_rinex_version(version);
1174            let is_nav = line.get(20..21) == Some("N");
1175            match (detected, is_nav) {
1176                (Some(v), true) => version_seen = Some(v),
1177                _ => {
1178                    return Err(NavParseError::UnsupportedHeader(
1179                        line.trim_end().to_string(),
1180                    ))
1181                }
1182            }
1183        }
1184        if line.contains("END OF HEADER") {
1185            return version_seen.ok_or_else(|| {
1186                NavParseError::UnsupportedHeader("no RINEX VERSION / TYPE".to_string())
1187            });
1188        }
1189    }
1190    Err(NavParseError::MissingHeaderEnd)
1191}
1192
1193fn parse_rinex_version(version: &str) -> Option<RinexVersion> {
1194    let (major, minor) = version.split_once('.')?;
1195    let major = major.trim().parse::<u8>().ok()?;
1196    if !matches!(major, 3 | 4) {
1197        return None;
1198    }
1199    let minor_digits = minor
1200        .chars()
1201        .take_while(char::is_ascii_digit)
1202        .collect::<String>();
1203    if minor_digits.is_empty() {
1204        return None;
1205    }
1206    let minor = minor_digits.parse::<u8>().ok()?;
1207    Some(RinexVersion { major, minor })
1208}
1209
1210fn is_record_start(line: &str) -> bool {
1211    let b = line.as_bytes();
1212    b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1].is_ascii_digit() && b[2].is_ascii_digit()
1213}
1214
1215/// The four broadcast-orbit values of a continuation line (columns 4/23/42/61).
1216fn orbit_row(line: &str) -> [Option<f64>; 4] {
1217    [
1218        parse_f64(line, 4, 23),
1219        parse_f64(line, 23, 42),
1220        parse_f64(line, 42, 61),
1221        parse_f64(line, 61, 80),
1222    ]
1223}
1224
1225#[derive(Debug, Clone, Copy)]
1226struct ClockReferenceEpoch {
1227    week: u32,
1228    sow: f64,
1229}
1230
1231fn parse_keplerian_block(
1232    block: &[&str],
1233    message_override: Option<NavMessage>,
1234    version: RinexVersion,
1235) -> Result<BroadcastRecord, NavParseError> {
1236    let l0 = block.first().copied().unwrap_or("");
1237    let sat = l0.get(0..3).unwrap_or("").trim().to_string();
1238    if block.len() < 8 {
1239        return Err(NavParseError::TruncatedRecord(sat));
1240    }
1241    let bad = |what: &'static str| NavParseError::BadField {
1242        satellite: sat.clone(),
1243        field: what,
1244    };
1245
1246    let letter = l0
1247        .as_bytes()
1248        .first()
1249        .copied()
1250        .map(|b| b as char)
1251        .ok_or_else(|| bad("system"))?;
1252    let system = GnssSystem::from_letter(letter).ok_or_else(|| bad("system"))?;
1253    let satellite_id: GnssSatelliteId = sat.parse().map_err(|_| bad("prn"))?;
1254
1255    // Clock line: epoch (-> toc) and the af0/af1/af2 polynomial.
1256    let time_scale = broadcast_time_scale(system);
1257    let toc_epoch = parse_toc(l0, &sat, time_scale)?;
1258    let toc_sow = toc_epoch.sow;
1259    let af0 = parse_f64(l0, 23, 42).ok_or_else(|| bad("af0"))?;
1260    let af1 = parse_f64(l0, 42, 61).ok_or_else(|| bad("af1"))?;
1261    let af2 = parse_f64(l0, 61, 80).ok_or_else(|| bad("af2"))?;
1262
1263    let o1 = orbit_row(block[1]);
1264    let o2 = orbit_row(block[2]);
1265    let o3 = orbit_row(block[3]);
1266    let o4 = orbit_row(block[4]);
1267    let o5 = orbit_row(block[5]);
1268    let o6 = orbit_row(block[6]);
1269
1270    let g = |v: Option<f64>, what: &'static str| v.ok_or_else(|| bad(what));
1271
1272    let elements = KeplerianElements {
1273        crs: g(o1[1], "crs")?,
1274        delta_n: g(o1[2], "deltaN")?,
1275        m0: g(o1[3], "m0")?,
1276        cuc: g(o2[0], "cuc")?,
1277        e: g(o2[1], "e")?,
1278        cus: g(o2[2], "cus")?,
1279        sqrt_a: g(o2[3], "sqrtA")?,
1280        toe_sow: g(o3[0], "toe")?,
1281        cic: g(o3[1], "cic")?,
1282        omega0: g(o3[2], "omega0")?,
1283        cis: g(o3[3], "cis")?,
1284        i0: g(o4[0], "i0")?,
1285        crc: g(o4[1], "crc")?,
1286        omega: g(o4[2], "omega")?,
1287        omega_dot: g(o4[3], "omegaDot")?,
1288        idot: g(o5[0], "idot")?,
1289    };
1290    let clock = ClockPolynomial {
1291        af0,
1292        af1,
1293        af2,
1294        toc_sow,
1295    };
1296
1297    let week = finite_integral_u32(g(o5[2], "week")?, "week", &sat)?;
1298    let toe = GnssWeekTow::new(time_scale, week, elements.toe_sow)
1299        .and_then(GnssWeekTow::normalized)
1300        .map_err(|_| bad("toe"))?;
1301    let toc = GnssWeekTow::new(time_scale, toc_epoch.week, clock.toc_sow)
1302        .and_then(GnssWeekTow::normalized)
1303        .map_err(|_| bad("toc"))?;
1304    let message = if let Some(message) = message_override {
1305        message
1306    } else {
1307        match system {
1308            GnssSystem::Galileo => galileo_message(g(o5[1], "data sources")?, &sat)?,
1309            GnssSystem::BeiDou => {
1310                if is_beidou_geo(satellite_id) {
1311                    NavMessage::BeidouD2
1312                } else {
1313                    NavMessage::BeidouD1
1314                }
1315            }
1316            _ => NavMessage::GpsLnav,
1317        }
1318    };
1319    let issue_of_data = BroadcastIssue {
1320        issue: finite_integral_u32(g(o1[0], "issue of data")?, "issue of data", &sat)?,
1321        message,
1322    };
1323
1324    let sv_accuracy_m = g(o6[0], "accuracy")?;
1325    let sv_health = g(o6[1], "health")?;
1326    let group_delays = match system {
1327        GnssSystem::Gps => BroadcastGroupDelays::gps_lnav(g(o6[2], "gps tgd")?),
1328        // RINEX Galileo ORBIT-6 carries BGD E5a/E1 in field 3 and BGD E5b/E1 in
1329        // field 4; both are part of the message representation regardless of
1330        // which one a clock consumer later selects.
1331        GnssSystem::Galileo => {
1332            BroadcastGroupDelays::galileo(g(o6[2], "bgd e5a/e1")?, g(o6[3], "bgd e5b/e1")?)
1333        }
1334        GnssSystem::BeiDou => {
1335            BroadcastGroupDelays::beidou(g(o6[2], "beidou tgd1")?, g(o6[3], "beidou tgd2")?)
1336        }
1337        _ => BroadcastGroupDelays::default(),
1338    };
1339
1340    // Only GPS LNAV broadcasts a curve-fit interval (ORBIT-7 field 2); Galileo
1341    // and BeiDou leave that column blank or spare, so they carry no fit interval.
1342    let fit_interval_s = match system {
1343        GnssSystem::Gps => {
1344            Some(gps_fit_interval_s(block[7], version).map_err(|()| bad("fit interval"))?)
1345        }
1346        _ => None,
1347    };
1348
1349    Ok(BroadcastRecord {
1350        satellite_id,
1351        message,
1352        issue_of_data,
1353        week,
1354        toe,
1355        toc,
1356        elements,
1357        clock,
1358        group_delays,
1359        sv_health,
1360        sv_accuracy_m,
1361        fit_interval_s,
1362    })
1363}
1364
1365/// The GPS curve-fit interval in seconds from the ORBIT-7 fit-interval field.
1366/// RINEX 3.03+ and 4.xx record this field in hours. Legacy RINEX 3.02 and older
1367/// files may carry the broadcast 0/1 fit-interval flag instead, where 1 means
1368/// more than four hours rather than one hour. Per IS-GPS-200 the decoded value
1369/// is the total interval centered on `toe`; a zero or absent field denotes the
1370/// nominal four hours.
1371///
1372/// A blank/absent field is the legitimate nominal case (some products omit it);
1373/// a present but non-numeric field is a malformed record, reported as `Err` so
1374/// the caller can raise the same `BadField` error as for other numeric fields
1375/// rather than silently substituting four hours.
1376fn gps_fit_interval_s(orbit7: &str, version: RinexVersion) -> Result<f64, ()> {
1377    let value = match field(orbit7, 23, 42) {
1378        None => 0.0,
1379        Some(_) => parse_f64(orbit7, 23, 42).ok_or(())?,
1380    };
1381    if value == 0.0 {
1382        Ok(GPS_NOMINAL_FIT_INTERVAL_S)
1383    } else if version.gps_fit_interval_uses_legacy_flag() && value == 1.0 {
1384        Ok(GPS_LEGACY_EXTENDED_FIT_INTERVAL_S)
1385    } else {
1386        Ok(value * SECONDS_PER_HOUR)
1387    }
1388}
1389
1390/// Classify a Galileo record from its data-source word (orbit-5 field 1): source
1391/// bit 1 is F/NAV, source bits 0/2 are I/NAV. Bits 8/9 describe the clock-pair
1392/// frequency and do not determine the navigation message type.
1393fn galileo_message(data_sources: f64, sat: &str) -> Result<NavMessage, NavParseError> {
1394    let word = finite_integral_u32(data_sources, "data sources", sat)?;
1395    if word & 0b010 != 0 {
1396        Ok(NavMessage::GalileoFnav)
1397    } else if word & 0b101 != 0 {
1398        Ok(NavMessage::GalileoInav)
1399    } else {
1400        // No source bit set: default to I/NAV (the operational E1 message).
1401        Ok(NavMessage::GalileoInav)
1402    }
1403}
1404
1405fn finite_integral_u32(value: f64, field: &'static str, sat: &str) -> Result<u32, NavParseError> {
1406    validate::finite(value, field).map_err(|error| map_record_field_error(error, sat))?;
1407    if value < 0.0 || value > f64::from(u32::MAX) || value.trunc() != value {
1408        return Err(NavParseError::BadField {
1409            satellite: sat.to_string(),
1410            field,
1411        });
1412    }
1413    Ok(value as u32)
1414}
1415
1416fn glonass_frequency_channel(value: f64, sat: &str) -> Result<i32, NavParseError> {
1417    const FIELD: &str = "frequency channel";
1418    validate::finite(value, FIELD).map_err(|error| map_record_field_error(error, sat))?;
1419    let channel = value as i32;
1420    if value.trunc() != value || !valid_glonass_frequency_channel(channel) {
1421        return Err(NavParseError::BadField {
1422            satellite: sat.to_string(),
1423            field: FIELD,
1424        });
1425    }
1426    Ok(channel)
1427}
1428
1429fn strict_header_f64(
1430    line: &str,
1431    start: usize,
1432    end: usize,
1433    field: &'static str,
1434) -> Result<f64, NavParseError> {
1435    validate::strict_f64(raw_field(line, start, end), field).map_err(map_header_field_error)
1436}
1437
1438fn strict_header_integer_f64(
1439    line: &str,
1440    start: usize,
1441    end: usize,
1442    field: &'static str,
1443) -> Result<f64, NavParseError> {
1444    let value = strict_header_f64(line, start, end, field)?;
1445    if value.trunc() != value {
1446        return Err(NavParseError::BadHeaderField { field });
1447    }
1448    Ok(value)
1449}
1450
1451fn strict_record_int<T>(
1452    line: &str,
1453    start: usize,
1454    end: usize,
1455    field: &'static str,
1456    satellite: &str,
1457) -> Result<T, NavParseError>
1458where
1459    T: core::str::FromStr,
1460{
1461    validate::strict_int::<T>(raw_field(line, start, end), field)
1462        .map_err(|error| map_record_field_error(error, satellite))
1463}
1464
1465fn map_record_field_error(error: FieldError, satellite: &str) -> NavParseError {
1466    NavParseError::BadField {
1467        satellite: satellite.to_string(),
1468        field: error.field(),
1469    }
1470}
1471
1472fn map_header_field_error(error: FieldError) -> NavParseError {
1473    NavParseError::BadHeaderField {
1474        field: error.field(),
1475    }
1476}
1477
1478/// Parse the clock reference epoch from the SV/epoch line into week and seconds
1479/// of week in the record's broadcast time scale.
1480fn parse_toc(
1481    l0: &str,
1482    sat: &str,
1483    time_scale: TimeScale,
1484) -> Result<ClockReferenceEpoch, NavParseError> {
1485    let year = strict_record_int::<i64>(l0, 4, 8, "toc epoch", sat)?;
1486    let month = strict_record_int::<i64>(l0, 9, 11, "toc epoch", sat)?;
1487    let day = strict_record_int::<i64>(l0, 12, 14, "toc epoch", sat)?;
1488    let hour = strict_record_int::<i64>(l0, 15, 17, "toc epoch", sat)?;
1489    let minute = strict_record_int::<i64>(l0, 18, 20, "toc epoch", sat)?;
1490    let second = strict_record_int::<i64>(l0, 21, 23, "toc epoch", sat)?;
1491    let civil = validate::civil_datetime_with_second_policy(
1492        year,
1493        month,
1494        day,
1495        hour,
1496        minute,
1497        second as f64,
1498        validate::CivilSecondPolicy::Continuous,
1499    )
1500    .map_err(|_| NavParseError::BadField {
1501        satellite: sat.to_string(),
1502        field: "toc epoch",
1503    })?;
1504    let month = i64::from(civil.month);
1505    let day = i64::from(civil.day);
1506    let week = gnss::week_from_calendar(time_scale, civil.year, month, day).ok_or_else(|| {
1507        NavParseError::BadField {
1508            satellite: sat.to_string(),
1509            field: "toc epoch",
1510        }
1511    })?;
1512    let sow = gnss::seconds_of_week_from_calendar(
1513        civil.year,
1514        month,
1515        day,
1516        i64::from(civil.hour),
1517        i64::from(civil.minute),
1518        civil.second as i64,
1519    );
1520    Ok(ClockReferenceEpoch { week, sow })
1521}
1522
1523#[cfg(all(test, sidereon_repo_tests))]
1524mod tests;