Skip to main content

sidereon_core/rinex_nav/
mod.rs

1//! RINEX 3.x and 4.xx navigation-message parsing (GPS LNAV/CNAV/CNAV-2, QZSS
2//! CNAV/CNAV-2, Galileo I/NAV and 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 for legacy messages, so
6//! those versions share the block parser; only the record grouping differs.
7//! GPS/QZSS CNAV and CNAV-2 use a separate RINEX 4 roster and are parsed into a
8//! CNAV extension. BeiDou CNV1/CNV2/CNV3, QZSS LNAV, NavIC, SBAS, and RINEX 4
9//! GLONASS FDMA frames are recognized as frame boundaries and skipped.
10//!
11//! Reads broadcast ephemeris records out of a RINEX navigation file into the
12//! typed [`BroadcastRecord`]s the [`crate::broadcast`] evaluator consumes. This
13//! is deterministic byte-to-record parsing of a fixed-column text format, not a
14//! float recipe: there is no 0-ULP claim here, and a small in-house parser is
15//! used in preference to a heavyweight RINEX dependency (the published `rinex`
16//! crate pulls ~90 transitive crates, including computational-geometry stacks,
17//! for what is a fixed-width text read).
18//!
19//! Scope: the GPS, QZSS, Galileo, and BeiDou Keplerian record layouts, plus the
20//! GLONASS four-line state-vector layout (parsed by [`parse_glonass`] and
21//! evaluated by the [`crate::glonass`] RK4 propagator, not the Keplerian path).
22//! Unsupported constellations and message rosters are skipped so a mixed file
23//! parses without error while yielding only the supported systems.
24
25mod store;
26pub use store::{BroadcastStore, NavMessagePreference};
27
28mod write;
29pub use write::encode_nav;
30
31use crate::astro::time::model::{GnssWeekTow, TimeScale};
32use crate::astro::time::{civil, gnss};
33use crate::broadcast::{ClockPolynomial, ConstellationConstants, KeplerianElements};
34use crate::constants::{KM_TO_M, SECONDS_PER_HOUR, SECONDS_PER_WEEK};
35use crate::format::columns::{field, raw_field};
36use crate::id::{GnssSatelliteId, GnssSystem};
37use crate::ionex::GalileoNequickCoeffs;
38use crate::validate::{self, FieldError};
39
40/// Parse a fixed-column RINEX broadcast-orbit numeric field, accepting Fortran
41/// `D`/`d` exponents. `None` for a missing, blank, or malformed field. The field
42/// label matches the lenient numeric reader the RINEX family shares, so the
43/// accepted/rejected forms are identical across the readers.
44fn parse_f64(line: &str, start: usize, end: usize) -> Option<f64> {
45    let value = crate::format::columns::fortran_f64(line, start, end, "numeric field")?;
46    // The fixed-width `D19.12` serializer field cannot hold a three-digit
47    // exponent, so a value outside that range is not representable in this format.
48    // Treat it as absent (the lenient `None` the readers already use for a
49    // malformed field) so the parse/encode domains agree: a required field then
50    // surfaces as a parse error, an optional one as absent. Real broadcast values
51    // have small exponents and are unaffected.
52    write::d19_12_representable(value).then_some(value)
53}
54
55/// Fallback half-window (seconds, either side of `toe`) for a record that does
56/// not broadcast a fit interval (Galileo, BeiDou). A coarse validity guard - a
57/// stale or wrong-week product is off by at least a week, so this rejects it as
58/// "no ephemeris" rather than silently extrapolating. GPS records carry an
59/// explicit curve-fit interval (see [`BroadcastRecord::fit_interval_s`]) and use
60/// half of that instead.
61pub(crate) const MAX_EPHEMERIS_AGE_S: f64 = 4.0 * SECONDS_PER_HOUR;
62
63/// GLONASS broadcast records are valid +/-15 minutes around their reference
64/// epoch (the nominal half-hour upload cadence), so a query farther than this
65/// reports no ephemeris rather than extrapolating the RK4 integration.
66pub(crate) const GLONASS_MAX_AGE_S: f64 = 15.0 * 60.0;
67const GPS_NOMINAL_FIT_INTERVAL_S: f64 = 4.0 * SECONDS_PER_HOUR;
68const GPS_LEGACY_EXTENDED_FIT_INTERVAL_S: f64 = 8.0 * SECONDS_PER_HOUR;
69const GLONASS_FREQ_CHANNEL_MIN: i32 = -7;
70const GLONASS_FREQ_CHANNEL_MAX: i32 = 6;
71
72pub(crate) fn valid_glonass_frequency_channel(channel: i32) -> bool {
73    (GLONASS_FREQ_CHANNEL_MIN..=GLONASS_FREQ_CHANNEL_MAX).contains(&channel)
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77struct RinexVersion {
78    major: u8,
79    minor: u8,
80}
81
82impl RinexVersion {
83    fn gps_fit_interval_uses_legacy_flag(self) -> bool {
84        self.major == 3 && self.minor <= 2
85    }
86}
87
88/// Which broadcast navigation message a record carries.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum NavMessage {
91    /// GPS legacy navigation message.
92    GpsLnav,
93    /// GPS CNAV message (L2C/L5), RINEX 4 token `CNAV`.
94    GpsCnav,
95    /// GPS CNAV-2 message (L1C), RINEX 4 token `CNV2`.
96    GpsCnav2,
97    /// QZSS CNAV message, RINEX 4 token `CNAV`.
98    QzssCnav,
99    /// QZSS CNAV-2 message, RINEX 4 token `CNV2`.
100    QzssCnav2,
101    /// Galileo integrity navigation message (E1/E5b dual, E1 single-frequency).
102    GalileoInav,
103    /// Galileo F/NAV message (E5a).
104    GalileoFnav,
105    /// BeiDou D1 message (MEO/IGSO satellites).
106    BeidouD1,
107    /// BeiDou D2 message (geostationary satellites).
108    BeidouD2,
109}
110
111impl NavMessage {
112    /// Whether this is a GPS/QZSS CNAV-family message.
113    pub const fn is_cnav_family(self) -> bool {
114        matches!(
115            self,
116            Self::GpsCnav | Self::GpsCnav2 | Self::QzssCnav | Self::QzssCnav2
117        )
118    }
119}
120
121/// Broadcast issue-of-data plus the navigation message identity.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub struct BroadcastIssue {
124    /// The native issue value: IODE for GPS, IODnav for Galileo.
125    pub issue: u32,
126    /// The navigation message carrying the issue value.
127    pub message: NavMessage,
128}
129
130/// A broadcast group-delay term carried by a RINEX NAV record.
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum BroadcastGroupDelayTerm {
133    /// GPS LNAV TGD.
134    GpsTgd,
135    /// Galileo BGD E5a/E1.
136    GalileoBgdE5aE1,
137    /// Galileo BGD E5b/E1.
138    GalileoBgdE5bE1,
139    /// BeiDou TGD1.
140    BeidouTgd1,
141    /// BeiDou TGD2.
142    BeidouTgd2,
143    /// GPS/QZSS CNAV ISC for L1 C/A.
144    CnavIscL1Ca,
145    /// GPS/QZSS CNAV ISC for L2C.
146    CnavIscL2C,
147    /// GPS/QZSS CNAV ISC for L5 I5.
148    CnavIscL5I5,
149    /// GPS/QZSS CNAV ISC for L5 Q5.
150    CnavIscL5Q5,
151    /// GPS/QZSS CNAV-2 ISC for L1C data.
152    CnavIscL1Cd,
153    /// GPS/QZSS CNAV-2 ISC for L1C pilot.
154    CnavIscL1Cp,
155}
156
157/// A GPS/QZSS signal a CNAV-family group-delay correction applies to.
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum CnavSignal {
160    /// L1 C/A.
161    L1Ca,
162    /// L2C.
163    L2C,
164    /// L5 I5.
165    L5I5,
166    /// L5 Q5.
167    L5Q5,
168    /// L1C pilot.
169    L1Cp,
170    /// L1C data.
171    L1Cd,
172}
173
174/// Per-signal broadcast group delays preserved from one NAV record.
175#[derive(Debug, Clone, Copy, PartialEq, Default)]
176pub struct BroadcastGroupDelays {
177    /// GPS LNAV TGD, seconds.
178    pub gps_tgd_s: Option<f64>,
179    /// Galileo BGD E5a/E1, seconds.
180    pub galileo_bgd_e5a_e1_s: Option<f64>,
181    /// Galileo BGD E5b/E1, seconds.
182    pub galileo_bgd_e5b_e1_s: Option<f64>,
183    /// BeiDou TGD1, seconds.
184    pub beidou_tgd1_s: Option<f64>,
185    /// BeiDou TGD2, seconds.
186    pub beidou_tgd2_s: Option<f64>,
187    /// GPS/QZSS CNAV ISC for L1 C/A, seconds.
188    pub cnav_isc_l1ca_s: Option<f64>,
189    /// GPS/QZSS CNAV ISC for L2C, seconds.
190    pub cnav_isc_l2c_s: Option<f64>,
191    /// GPS/QZSS CNAV ISC for L5 I5, seconds.
192    pub cnav_isc_l5i5_s: Option<f64>,
193    /// GPS/QZSS CNAV ISC for L5 Q5, seconds.
194    pub cnav_isc_l5q5_s: Option<f64>,
195    /// GPS/QZSS CNAV-2 ISC for L1C data, seconds.
196    pub cnav_isc_l1cd_s: Option<f64>,
197    /// GPS/QZSS CNAV-2 ISC for L1C pilot, seconds.
198    pub cnav_isc_l1cp_s: Option<f64>,
199}
200
201impl BroadcastGroupDelays {
202    /// Build the GPS LNAV delay set.
203    pub const fn gps_lnav(tgd_s: f64) -> Self {
204        Self {
205            gps_tgd_s: Some(tgd_s),
206            galileo_bgd_e5a_e1_s: None,
207            galileo_bgd_e5b_e1_s: None,
208            beidou_tgd1_s: None,
209            beidou_tgd2_s: None,
210            cnav_isc_l1ca_s: None,
211            cnav_isc_l2c_s: None,
212            cnav_isc_l5i5_s: None,
213            cnav_isc_l5q5_s: None,
214            cnav_isc_l1cd_s: None,
215            cnav_isc_l1cp_s: None,
216        }
217    }
218
219    /// Build the Galileo delay set.
220    pub const fn galileo(bgd_e5a_e1_s: f64, bgd_e5b_e1_s: f64) -> Self {
221        Self {
222            gps_tgd_s: None,
223            galileo_bgd_e5a_e1_s: Some(bgd_e5a_e1_s),
224            galileo_bgd_e5b_e1_s: Some(bgd_e5b_e1_s),
225            beidou_tgd1_s: None,
226            beidou_tgd2_s: None,
227            cnav_isc_l1ca_s: None,
228            cnav_isc_l2c_s: None,
229            cnav_isc_l5i5_s: None,
230            cnav_isc_l5q5_s: None,
231            cnav_isc_l1cd_s: None,
232            cnav_isc_l1cp_s: None,
233        }
234    }
235
236    /// Build the BeiDou delay set.
237    pub const fn beidou(tgd1_s: f64, tgd2_s: f64) -> Self {
238        Self {
239            gps_tgd_s: None,
240            galileo_bgd_e5a_e1_s: None,
241            galileo_bgd_e5b_e1_s: None,
242            beidou_tgd1_s: Some(tgd1_s),
243            beidou_tgd2_s: Some(tgd2_s),
244            cnav_isc_l1ca_s: None,
245            cnav_isc_l2c_s: None,
246            cnav_isc_l5i5_s: None,
247            cnav_isc_l5q5_s: None,
248            cnav_isc_l1cd_s: None,
249            cnav_isc_l1cp_s: None,
250        }
251    }
252
253    /// Build a GPS/QZSS CNAV-family delay set.
254    pub const fn cnav(
255        tgd_s: Option<f64>,
256        isc_l1ca_s: Option<f64>,
257        isc_l2c_s: Option<f64>,
258        isc_l5i5_s: Option<f64>,
259        isc_l5q5_s: Option<f64>,
260        isc_l1cd_s: Option<f64>,
261        isc_l1cp_s: Option<f64>,
262    ) -> Self {
263        Self {
264            gps_tgd_s: tgd_s,
265            galileo_bgd_e5a_e1_s: None,
266            galileo_bgd_e5b_e1_s: None,
267            beidou_tgd1_s: None,
268            beidou_tgd2_s: None,
269            cnav_isc_l1ca_s: isc_l1ca_s,
270            cnav_isc_l2c_s: isc_l2c_s,
271            cnav_isc_l5i5_s: isc_l5i5_s,
272            cnav_isc_l5q5_s: isc_l5q5_s,
273            cnav_isc_l1cd_s: isc_l1cd_s,
274            cnav_isc_l1cp_s: isc_l1cp_s,
275        }
276    }
277
278    /// Select a specific group-delay term.
279    pub const fn get(&self, term: BroadcastGroupDelayTerm) -> Option<f64> {
280        match term {
281            BroadcastGroupDelayTerm::GpsTgd => self.gps_tgd_s,
282            BroadcastGroupDelayTerm::GalileoBgdE5aE1 => self.galileo_bgd_e5a_e1_s,
283            BroadcastGroupDelayTerm::GalileoBgdE5bE1 => self.galileo_bgd_e5b_e1_s,
284            BroadcastGroupDelayTerm::BeidouTgd1 => self.beidou_tgd1_s,
285            BroadcastGroupDelayTerm::BeidouTgd2 => self.beidou_tgd2_s,
286            BroadcastGroupDelayTerm::CnavIscL1Ca => self.cnav_isc_l1ca_s,
287            BroadcastGroupDelayTerm::CnavIscL2C => self.cnav_isc_l2c_s,
288            BroadcastGroupDelayTerm::CnavIscL5I5 => self.cnav_isc_l5i5_s,
289            BroadcastGroupDelayTerm::CnavIscL5Q5 => self.cnav_isc_l5q5_s,
290            BroadcastGroupDelayTerm::CnavIscL1Cd => self.cnav_isc_l1cd_s,
291            BroadcastGroupDelayTerm::CnavIscL1Cp => self.cnav_isc_l1cp_s,
292        }
293    }
294
295    /// The total CNAV single-frequency clock adjustment (TGD - ISC), seconds.
296    ///
297    /// Callers subtract this from the satellite clock offset by passing it as the
298    /// `tgd_s` argument to the broadcast evaluator. Returns `None` when TGD or
299    /// the selected ISC is unavailable.
300    pub fn cnav_single_frequency_correction_s(&self, signal: CnavSignal) -> Option<f64> {
301        let isc = match signal {
302            CnavSignal::L1Ca => self.cnav_isc_l1ca_s,
303            CnavSignal::L2C => self.cnav_isc_l2c_s,
304            CnavSignal::L5I5 => self.cnav_isc_l5i5_s,
305            CnavSignal::L5Q5 => self.cnav_isc_l5q5_s,
306            CnavSignal::L1Cp => self.cnav_isc_l1cp_s,
307            CnavSignal::L1Cd => self.cnav_isc_l1cd_s,
308        }?;
309        Some(self.gps_tgd_s? - isc)
310    }
311
312    /// The delay term historically used for broadcast-clock evaluation.
313    ///
314    /// BeiDou has no signal choice at this store level, so it keeps the previous
315    /// TGD1 behavior. CNAV-family clock evaluation keeps the record-level
316    /// default of treating a missing TGD or L1 C/A ISC as zero. Callers that know
317    /// their signal should use [`Self::get`] or
318    /// [`Self::cnav_single_frequency_correction_s`].
319    pub const fn for_message(self, system: GnssSystem, message: NavMessage) -> Option<f64> {
320        match (system, message) {
321            (GnssSystem::Gps, NavMessage::GpsLnav) => self.get(BroadcastGroupDelayTerm::GpsTgd),
322            (GnssSystem::Galileo, NavMessage::GalileoFnav) => {
323                self.get(BroadcastGroupDelayTerm::GalileoBgdE5aE1)
324            }
325            (GnssSystem::Galileo, NavMessage::GalileoInav) => {
326                self.get(BroadcastGroupDelayTerm::GalileoBgdE5bE1)
327            }
328            (GnssSystem::BeiDou, NavMessage::BeidouD1 | NavMessage::BeidouD2) => {
329                self.get(BroadcastGroupDelayTerm::BeidouTgd1)
330            }
331            (
332                GnssSystem::Gps | GnssSystem::Qzss,
333                NavMessage::GpsCnav
334                | NavMessage::GpsCnav2
335                | NavMessage::QzssCnav
336                | NavMessage::QzssCnav2,
337            ) => match (self.gps_tgd_s, self.cnav_isc_l1ca_s) {
338                (Some(tgd), Some(isc)) => Some(tgd - isc),
339                (Some(tgd), None) => Some(tgd),
340                (None, Some(isc)) => Some(-isc),
341                (None, None) => Some(0.0),
342            },
343            _ => None,
344        }
345    }
346}
347
348/// CNAV/CNAV-2 parameters that have no legacy counterpart.
349#[derive(Debug, Clone, Copy, PartialEq)]
350pub struct CnavParameters {
351    /// Semi-major axis rate ADOT (m/s), ORBIT-1 field 1.
352    pub adot_m_s: f64,
353    /// Rate of the mean-motion difference (rad/s^2), ORBIT-5 field 2.
354    pub delta_n0_dot_rad_s2: f64,
355    /// CEI data-sequence propagation epoch: WNop week plus top seconds of week.
356    pub top: GnssWeekTow,
357    /// URA_ED index, [-16, 15].
358    pub ura_ed_index: i8,
359    /// URA_NED0 index, [-16, 15].
360    pub ura_ned0_index: i8,
361    /// URA_NED1 index, [0, 7].
362    pub ura_ned1_index: u8,
363    /// URA_NED2 index, [0, 7].
364    pub ura_ned2_index: u8,
365    /// Transmission time of message t_tm, seconds of week.
366    pub transmission_time_sow: f64,
367    /// Optional decimal-coded flag bits.
368    pub flags: Option<u32>,
369}
370
371/// Nominal URA meters for a CNAV ED/NED0 index.
372///
373/// Returns `None` for the no-prediction indices 15 and -16.
374pub fn cnav_ura_nominal_m(index: i8) -> Option<f64> {
375    match index {
376        -16 | 15 => None,
377        1 => Some(2.8),
378        3 => Some(5.7),
379        5 => Some(11.3),
380        -15..=6 => Some(2.0_f64.powf(1.0 + f64::from(index) / 2.0)),
381        7..=14 => Some(2.0_f64.powi(i32::from(index) - 2)),
382        _ => None,
383    }
384}
385
386/// Time-dependent CNAV URA_NED bound in meters at GPST `t`.
387pub fn cnav_ura_ned_m(params: &CnavParameters, t: GnssWeekTow) -> Option<f64> {
388    let ned0 = cnav_ura_nominal_m(params.ura_ned0_index)?;
389    let ned1 = 2.0_f64.powi(-(14 + i32::from(params.ura_ned1_index)));
390    let ned2 = 2.0_f64.powi(-(28 + i32::from(params.ura_ned2_index)));
391    let dt_op = (f64::from(t.week) - f64::from(params.top.week)) * SECONDS_PER_WEEK
392        + (t.tow_s - params.top.tow_s);
393    let linear = ned0 + ned1 * dt_op;
394    if dt_op <= 93_600.0 {
395        Some(linear)
396    } else {
397        Some(linear + ned2 * (dt_op - 93_600.0) * (dt_op - 93_600.0))
398    }
399}
400
401/// Whether a BeiDou PRN is a geostationary satellite (BDS-2 C01-C05, BDS-3
402/// C59-C61), which take the geostationary orbit-evaluation branch.
403pub fn is_beidou_geo(sat: GnssSatelliteId) -> bool {
404    sat.system == GnssSystem::BeiDou && (sat.prn <= 5 || (59..=61).contains(&sat.prn))
405}
406
407/// A Klobuchar-8 broadcast ionosphere coefficient set (the eight alpha/beta
408/// values transmitted by GPS and BeiDou; the same model serves both, evaluated
409/// per carrier - see [`crate::ionex::klobuchar_native`]).
410#[derive(Debug, Clone, Copy, PartialEq)]
411pub struct KlobucharAlphaBeta {
412    /// Cosine-amplitude polynomial coefficients (a0..a3).
413    pub alpha: [f64; 4],
414    /// Period polynomial coefficients (b0..b3).
415    pub beta: [f64; 4],
416}
417
418/// Broadcast ionosphere-correction coefficients from a RINEX header's
419/// `IONOSPHERIC CORR` lines or RINEX 4 body `> ION` frames.
420///
421/// Captures the Klobuchar-8 sets used by GPS (`GPSA`/`GPSB`) and BeiDou
422/// (`BDSA`/`BDSB`), plus Galileo's three NeQuick-G effective-ionisation
423/// coefficients (`GAL`). QZSS and NavIC Klobuchar sets are not retained.
424#[derive(Debug, Clone, Copy, PartialEq, Default)]
425pub struct IonoCorrections {
426    /// GPS broadcast Klobuchar coefficients (`GPSA`/`GPSB`), if present.
427    pub gps: Option<KlobucharAlphaBeta>,
428    /// BeiDou broadcast Klobuchar coefficients (`BDSA`/`BDSB`), if present.
429    pub beidou: Option<KlobucharAlphaBeta>,
430    /// Galileo broadcast NeQuick-G coefficients (`GAL`), if present.
431    pub galileo: Option<GalileoNequickCoeffs>,
432}
433
434/// One parsed GLONASS broadcast record: a PZ-90.11 ECEF state vector and the
435/// clock terms, evaluated by the crate's GLONASS RK4 propagator (GLONASS is not
436/// Keplerian, so it does not use [`BroadcastRecord`]).
437#[derive(Debug, Clone, Copy, PartialEq)]
438pub struct GlonassRecord {
439    /// The transmitting satellite.
440    pub satellite_id: GnssSatelliteId,
441    /// Reference epoch as seconds past J2000 in **UTC** (leap-second-independent;
442    /// the store adds the GPS−UTC offset to compare with the GPST-aligned query).
443    pub toe_utc_j2000_s: f64,
444    /// PZ-90.11 ECEF position at the reference epoch (meters).
445    pub pos_m: [f64; 3],
446    /// PZ-90.11 ECEF velocity at the reference epoch (meters/second).
447    pub vel_m_s: [f64; 3],
448    /// Lunisolar acceleration at the reference epoch (meters/second^2).
449    pub acc_m_s2: [f64; 3],
450    /// Clock bias broadcast field (−TauN, seconds).
451    pub clk_bias: f64,
452    /// Relative frequency offset (+GammaN, dimensionless).
453    pub gamma_n: f64,
454    /// Satellite health (0 is healthy).
455    pub sv_health: f64,
456    /// FDMA frequency-channel number.
457    pub freq_channel: i32,
458}
459
460/// A GLONASS record skipped by [`parse_glonass_lenient`] because its slot is not
461/// representable as a [`GnssSatelliteId`] (an extended slot beyond the engine's
462/// PRN cap, e.g. `R28` in real BKG/IGS products).
463#[derive(Debug, Clone, PartialEq, Eq)]
464pub struct SkippedGlonass {
465    /// The 3-character satellite token as it appeared in the file (`R28`).
466    pub token: String,
467}
468
469/// The result of a lenient GLONASS parse: the representable records plus the
470/// slot tokens that were skipped.
471///
472/// Mirrors the partial-success reporting used elsewhere for unrepresentable
473/// input (`RinexObs::skipped_records`, [`crate::constellation::Catalog`]): a
474/// dropped record carries its identity rather than vanishing silently, so a
475/// caller can surface how many / which slots were skipped.
476#[derive(Debug, Clone, PartialEq, Default)]
477pub struct GlonassParse {
478    /// Records for representable slots, in file order.
479    pub records: Vec<GlonassRecord>,
480    /// Slots that could not be represented and were skipped, in file order.
481    pub skipped: Vec<SkippedGlonass>,
482}
483
484/// One parsed broadcast navigation record.
485#[derive(Debug, Clone, Copy, PartialEq)]
486pub struct BroadcastRecord {
487    /// The transmitting satellite.
488    pub satellite_id: GnssSatelliteId,
489    /// The navigation message the record carries.
490    pub message: NavMessage,
491    /// Broadcast issue-of-data for issue-matched correction products.
492    pub issue_of_data: BroadcastIssue,
493    /// Native broadcast week number (from the broadcast record).
494    pub week: u32,
495    /// Scale-tagged ephemeris reference time (`toe`).
496    pub toe: GnssWeekTow,
497    /// Scale-tagged clock reference time (`toc`).
498    pub toc: GnssWeekTow,
499    /// Keplerian orbital elements (`toe_sow` is seconds of week).
500    pub elements: KeplerianElements,
501    /// Clock polynomial (`toc_sow` is the record's own epoch, seconds of week).
502    pub clock: ClockPolynomial,
503    /// Broadcast group-delay terms carried by this message.
504    pub group_delays: BroadcastGroupDelays,
505    /// CNAV/CNAV-2 extension, present only for GPS/QZSS CNAV-family records.
506    pub cnav: Option<CnavParameters>,
507    /// Satellite health word (0 is healthy for the GPS/Galileo nominal case).
508    pub sv_health: f64,
509    /// Signal-in-space accuracy: GPS URA (m) / Galileo SISA (m).
510    pub sv_accuracy_m: f64,
511    /// GPS curve-fit interval in seconds, centered on `toe` (IS-GPS-200): the
512    /// record is valid for `toe ± fit_interval_s / 2`. `None` for Galileo and
513    /// BeiDou, which do not broadcast a fit interval in the RINEX record; those
514    /// fall back to the crate's nominal four-hour age bound.
515    pub fit_interval_s: Option<f64>,
516}
517
518impl BroadcastRecord {
519    /// Native time scale used by this record's `toe`/`toc`.
520    pub const fn time_scale(&self) -> TimeScale {
521        self.toe.system
522    }
523
524    /// The per-constellation constants this record evaluates with.
525    pub const fn constants(&self) -> ConstellationConstants {
526        match self.satellite_id.system {
527            GnssSystem::Galileo => ConstellationConstants::GALILEO,
528            GnssSystem::BeiDou => ConstellationConstants::BEIDOU,
529            // GPS (and any other Keplerian system) use the GPS constants.
530            _ => ConstellationConstants::GPS,
531        }
532    }
533
534    /// Group delay used by the broadcast-clock evaluator for this message.
535    pub fn broadcast_clock_group_delay_s(&self) -> f64 {
536        self.group_delays
537            .for_message(self.satellite_id.system, self.message)
538            .unwrap_or(0.0)
539    }
540
541    /// Build a GPS LNAV record from decoded navigation-message subframes.
542    ///
543    /// This closes the `lnav::decode -> broadcast source` half of the real-time
544    /// pipeline: feed [`crate::navigation::lnav::decode`]'s output here, collect
545    /// the records into a [`BroadcastStore`], and solve with
546    /// [`solve_broadcast`](crate::positioning::solve_broadcast). The conversion
547    /// matches the RINEX navigation parser's record exactly except for the inputs
548    /// only the air interface carries:
549    ///
550    /// - The decoded angular elements are in semicircles (and semicircles/second)
551    ///   as transmitted by GPS LNAV; they are scaled to the radians the
552    ///   [`crate::broadcast`] evaluator expects (the harmonic `cuc..cis` terms are
553    ///   already radians and `crc`/`crs` meters, so they pass through unchanged).
554    /// - The 10-bit transmitted week number is ambiguous across the GPS
555    ///   1024-week rollover, so the full (unrolled) week is taken from
556    ///   `full_week` rather than inferred from the message. The caller-supplied
557    ///   `full_week` must agree with the decoded 10-bit week
558    ///   (`full_week % 1024 == decoded.week_number`); a disagreement means the
559    ///   caller is unrolling against the wrong rollover epoch and is rejected with
560    ///   [`LnavRecordError::WeekMismatch`] rather than silently dating the
561    ///   ephemeris to the wrong GPS week.
562    /// - The fit interval is derived from the fit-interval flag together with
563    ///   IODE/IODC per IS-GPS-200N 20.3.3.4.3.1 and Table 20-XII (the table the
564    ///   older revisions numbered 20-XI): `flag = 0` is the nominal 4-hour curve
565    ///   fit; `flag = 1` is an extended fit whose length is set by IODE/IODC
566    ///   (short-term extended `IODE < 240` is 6 hours; long-term extended
567    ///   `IODE` in `240..=255` is 8/14/26 hours by IODC range). Reserved IODC
568    ///   combinations are rejected with [`LnavRecordError::FitIntervalUnsupported`].
569    /// - The 4-bit URA index maps to its IS-GPS-200N 20.3.3.3.1.3 meters value;
570    ///   index 15 (no accuracy prediction / not to be used) carries no usable
571    ///   bound and is rejected with [`LnavRecordError::NoUraPrediction`].
572    ///
573    /// LNAV is the GPS L1 C/A message, so a non-GPS `satellite_id` is rejected.
574    pub fn from_lnav(
575        decoded: &crate::navigation::lnav::LnavDecoded,
576        satellite_id: GnssSatelliteId,
577        full_week: u32,
578    ) -> Result<Self, LnavRecordError> {
579        if satellite_id.system != GnssSystem::Gps {
580            return Err(LnavRecordError::NotGps(satellite_id));
581        }
582
583        // The unrolled `full_week` must reduce to the decoded 10-bit week
584        // (IS-GPS-200N 20.3.3.3.1.1). A mismatch means the caller unrolled
585        // against the wrong rollover epoch; trusting `full_week` would date the
586        // ephemeris to the wrong GPS week, so reject it.
587        if i64::from(full_week % 1024) != decoded.week_number {
588            return Err(LnavRecordError::WeekMismatch {
589                full_week,
590                decoded_week: decoded.week_number,
591            });
592        }
593
594        let sv_accuracy_m = gps_ura_index_to_meters(decoded.ura_index)
595            .ok_or(LnavRecordError::NoUraPrediction(decoded.ura_index))?;
596        let fit_interval_s =
597            gps_fit_interval_from_flag(decoded.fit_interval_flag, decoded.iode, decoded.iodc)?;
598
599        // GPS LNAV transmits the angular ephemeris elements in semicircles and
600        // semicircles/second; the Keplerian evaluator works in radians.
601        const SEMICIRCLE_TO_RAD: f64 = core::f64::consts::PI;
602
603        let elements = KeplerianElements {
604            sqrt_a: decoded.sqrt_a,
605            e: decoded.eccentricity,
606            m0: decoded.m0 * SEMICIRCLE_TO_RAD,
607            delta_n: decoded.delta_n * SEMICIRCLE_TO_RAD,
608            omega0: decoded.omega0 * SEMICIRCLE_TO_RAD,
609            i0: decoded.i0 * SEMICIRCLE_TO_RAD,
610            omega: decoded.omega * SEMICIRCLE_TO_RAD,
611            omega_dot: decoded.omega_dot * SEMICIRCLE_TO_RAD,
612            idot: decoded.idot * SEMICIRCLE_TO_RAD,
613            cuc: decoded.cuc,
614            cus: decoded.cus,
615            crc: decoded.crc,
616            crs: decoded.crs,
617            cic: decoded.cic,
618            cis: decoded.cis,
619            toe_sow: decoded.toe as f64,
620        };
621        let clock = ClockPolynomial {
622            af0: decoded.af0,
623            af1: decoded.af1,
624            af2: decoded.af2,
625            toc_sow: decoded.toc as f64,
626        };
627
628        let toe = GnssWeekTow::new(TimeScale::Gpst, full_week, elements.toe_sow)
629            .and_then(GnssWeekTow::normalized)
630            .map_err(|_| LnavRecordError::InvalidEpoch("toe"))?;
631        let toc = GnssWeekTow::new(TimeScale::Gpst, full_week, clock.toc_sow)
632            .and_then(GnssWeekTow::normalized)
633            .map_err(|_| LnavRecordError::InvalidEpoch("toc"))?;
634
635        Ok(BroadcastRecord {
636            satellite_id,
637            message: NavMessage::GpsLnav,
638            issue_of_data: BroadcastIssue {
639                issue: decoded.iode as u32,
640                message: NavMessage::GpsLnav,
641            },
642            week: full_week,
643            toe,
644            toc,
645            elements,
646            clock,
647            group_delays: BroadcastGroupDelays::gps_lnav(decoded.tgd),
648            cnav: None,
649            sv_health: decoded.sv_health as f64,
650            sv_accuracy_m,
651            fit_interval_s: Some(fit_interval_s),
652        })
653    }
654}
655
656/// The nominal GPS user range accuracy (URA) value in meters for a 4-bit URA
657/// index N (IS-GPS-200N Section 20.3.3.3.1.3). Each value is the upper bound of
658/// the URA band the index represents. Index 15 carries no accuracy prediction
659/// (the SV is not to be used for safe navigation) and has no usable meters
660/// bound, so it returns `None` rather than a fabricated finite value.
661fn gps_ura_index_to_meters(index: i64) -> Option<f64> {
662    let meters = match index {
663        0 => 2.4,
664        1 => 3.4,
665        2 => 4.85,
666        3 => 6.85,
667        4 => 9.65,
668        5 => 13.65,
669        6 => 24.0,
670        7 => 48.0,
671        8 => 96.0,
672        9 => 192.0,
673        10 => 384.0,
674        11 => 768.0,
675        12 => 1536.0,
676        13 => 3072.0,
677        14 => 6144.0,
678        // 15 = no accuracy prediction / not to be used; anything outside the
679        // 4-bit range cannot occur from a decoded message either.
680        _ => return None,
681    };
682    Some(meters)
683}
684
685const GPS_FIT_INTERVAL_6H_S: f64 = 6.0 * SECONDS_PER_HOUR;
686const GPS_FIT_INTERVAL_8H_S: f64 = 8.0 * SECONDS_PER_HOUR;
687const GPS_FIT_INTERVAL_14H_S: f64 = 14.0 * SECONDS_PER_HOUR;
688const GPS_FIT_INTERVAL_26H_S: f64 = 26.0 * SECONDS_PER_HOUR;
689
690/// Curve-fit interval (seconds) for a GPS LNAV record from its fit-interval flag
691/// plus IODE/IODC, per IS-GPS-200N 20.3.3.4.3.1, 6.2.3, and Table 20-XII (the
692/// table older revisions numbered 20-XI).
693///
694/// `flag = 0` is the nominal 4-hour fit. `flag = 1` is an extended fit: IODE
695/// selects short-term extended operations (`IODE < 240`, a 6-hour fit) from
696/// long-term extended operations (`IODE` in `240..=255`), and for the long-term
697/// case the IODC range selects 8, 14, or 26 hours. Reserved IODC values and any
698/// other flag/IODE/IODC combination are rejected.
699fn gps_fit_interval_from_flag(
700    fit_interval_flag: i64,
701    iode: i64,
702    iodc: i64,
703) -> Result<f64, LnavRecordError> {
704    let unsupported = || LnavRecordError::FitIntervalUnsupported {
705        fit_interval_flag,
706        iode,
707        iodc,
708    };
709    match fit_interval_flag {
710        0 => Ok(GPS_NOMINAL_FIT_INTERVAL_S),
711        1 => {
712            if (0..240).contains(&iode) {
713                // Short-term extended operations (Table 20-XII, 2-14 day row).
714                // IODE is an 8-bit unsigned field, so a negative value is not a
715                // real decode and falls through to the unsupported error.
716                Ok(GPS_FIT_INTERVAL_6H_S)
717            } else if (240..=255).contains(&iode) {
718                // Long-term extended operations: IODC selects the fit length.
719                match iodc {
720                    240..=247 => Ok(GPS_FIT_INTERVAL_8H_S),
721                    248..=255 | 496 => Ok(GPS_FIT_INTERVAL_14H_S),
722                    497..=503 | 1021..=1023 => Ok(GPS_FIT_INTERVAL_26H_S),
723                    _ => Err(unsupported()),
724                }
725            } else {
726                Err(unsupported())
727            }
728        }
729        _ => Err(unsupported()),
730    }
731}
732
733/// Failure building a [`BroadcastRecord`] from decoded LNAV subframes.
734#[derive(Debug, Clone, Copy, PartialEq, Eq)]
735pub enum LnavRecordError {
736    /// LNAV is the GPS L1 C/A message; the satellite is not a GPS satellite.
737    NotGps(GnssSatelliteId),
738    /// A derived week/time-of-week value was not representable.
739    InvalidEpoch(&'static str),
740    /// The caller-supplied `full_week` does not reduce to the decoded 10-bit week
741    /// (`full_week % 1024 != decoded_week`), so it unrolls to the wrong GPS week.
742    WeekMismatch {
743        /// The caller-supplied unrolled week.
744        full_week: u32,
745        /// The 10-bit week decoded from the message.
746        decoded_week: i64,
747    },
748    /// URA index 15 (or an out-of-range index) carries no accuracy prediction.
749    NoUraPrediction(i64),
750    /// The fit-interval flag / IODE / IODC combination is reserved or otherwise
751    /// not a defined IS-GPS-200N Table 20-XII curve-fit interval.
752    FitIntervalUnsupported {
753        /// The 1-bit fit-interval flag from the message.
754        fit_interval_flag: i64,
755        /// The decoded IODE.
756        iode: i64,
757        /// The decoded IODC.
758        iodc: i64,
759    },
760}
761
762impl core::fmt::Display for LnavRecordError {
763    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
764        match self {
765            LnavRecordError::NotGps(sat) => {
766                write!(f, "LNAV is a GPS message; {sat} is not a GPS satellite")
767            }
768            LnavRecordError::InvalidEpoch(field) => {
769                write!(f, "derived {field} week/TOW is not representable")
770            }
771            LnavRecordError::WeekMismatch {
772                full_week,
773                decoded_week,
774            } => write!(
775                f,
776                "full_week {full_week} (week % 1024 = {}) disagrees with decoded 10-bit week {decoded_week}",
777                full_week % 1024
778            ),
779            LnavRecordError::NoUraPrediction(index) => {
780                write!(f, "URA index {index} carries no accuracy prediction")
781            }
782            LnavRecordError::FitIntervalUnsupported {
783                fit_interval_flag,
784                iode,
785                iodc,
786            } => write!(
787                f,
788                "fit interval flag {fit_interval_flag} with IODE {iode} / IODC {iodc} is not a defined curve-fit interval"
789            ),
790        }
791    }
792}
793
794impl std::error::Error for LnavRecordError {}
795
796fn broadcast_time_scale(system: GnssSystem) -> TimeScale {
797    match system {
798        GnssSystem::Galileo => TimeScale::Gst,
799        GnssSystem::BeiDou => TimeScale::Bdt,
800        _ => TimeScale::Gpst,
801    }
802}
803
804/// Why a RINEX NAV file could not be parsed.
805#[derive(Debug, Clone, PartialEq, Eq)]
806pub enum NavParseError {
807    /// The header did not declare a supported RINEX navigation file.
808    UnsupportedHeader(String),
809    /// No `END OF HEADER` line was found.
810    MissingHeaderEnd,
811    /// A record was shorter than its message layout requires.
812    TruncatedRecord(String),
813    /// A required numeric field was missing or unparseable.
814    BadField {
815        /// The satellite whose record holds the bad field.
816        satellite: String,
817        /// Which field failed.
818        field: &'static str,
819    },
820    /// A required header numeric field was malformed, non-finite, or out of range.
821    BadHeaderField {
822        /// Which header field failed.
823        field: &'static str,
824    },
825}
826
827impl core::fmt::Display for NavParseError {
828    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
829        match self {
830            NavParseError::UnsupportedHeader(s) => write!(f, "unsupported RINEX NAV header: {s}"),
831            NavParseError::MissingHeaderEnd => write!(f, "no END OF HEADER line"),
832            NavParseError::TruncatedRecord(s) => write!(f, "truncated navigation record for {s}"),
833            NavParseError::BadField { satellite, field } => {
834                write!(f, "bad/missing {field} field in record for {satellite}")
835            }
836            NavParseError::BadHeaderField { field } => {
837                write!(f, "bad/missing {field} field in navigation header")
838            }
839        }
840    }
841}
842
843impl std::error::Error for NavParseError {}
844
845#[derive(Debug, Clone, PartialEq, Eq)]
846pub struct SkippedNavBlock {
847    pub satellite: String,
848    pub message: String,
849}
850
851#[derive(Debug, Clone, PartialEq)]
852pub struct NavParse {
853    pub records: Vec<BroadcastRecord>,
854    pub skipped: Vec<SkippedNavBlock>,
855}
856
857/// Parse a RINEX 3.x or 4.xx navigation file into the supported GPS, QZSS,
858/// Galileo, and BeiDou Keplerian records.
859///
860/// Unsupported RINEX 4 message rosters, including BeiDou CNV1/CNV2/CNV3 and
861/// QZSS LNAV, are skipped rather than fed through the wrong layout. The records
862/// are returned in file order; selection by epoch, health, and message type is
863/// the caller's job.
864pub fn parse_nav(text: &str) -> Result<Vec<BroadcastRecord>, NavParseError> {
865    let mut lines = text.lines();
866    let version = verify_and_skip_header(&mut lines)?;
867    if version.major >= 4 {
868        parse_nav_v4(lines, version)
869    } else {
870        parse_nav_v3(lines, version)
871    }
872}
873
874/// Parse supported NAV records while dropping malformed supported body blocks.
875///
876/// Header failures remain fatal because no record boundaries are trustworthy
877/// before the file type and version are known. Unsupported constellations and
878/// unsupported version-4 messages follow the strict parser's existing skip policy.
879pub fn parse_nav_lenient(text: &str) -> Result<NavParse, NavParseError> {
880    let mut lines = text.lines();
881    let version = verify_and_skip_header(&mut lines)?;
882    let (records, skipped) = if version.major >= 4 {
883        parse_nav_v4_lenient(lines, version)
884    } else {
885        parse_nav_v3_lenient(lines, version)
886    };
887    Ok(NavParse { records, skipped })
888}
889
890/// Version-3 body: a record starts at a line whose first three columns are a
891/// system letter followed by two digits; continuation lines are column-indented.
892fn parse_nav_v3<'a, I>(
893    lines: I,
894    version: RinexVersion,
895) -> Result<Vec<BroadcastRecord>, NavParseError>
896where
897    I: Iterator<Item = &'a str>,
898{
899    let mut blocks: Vec<Vec<&str>> = Vec::new();
900    for line in lines {
901        if is_record_start(line) {
902            blocks.push(vec![line]);
903        } else if let Some(last) = blocks.last_mut() {
904            last.push(line);
905        }
906    }
907
908    let mut records = Vec::new();
909    for block in &blocks {
910        let letter = block[0].as_bytes()[0] as char;
911        match GnssSystem::from_letter(letter) {
912            Some(GnssSystem::Gps) | Some(GnssSystem::Galileo) | Some(GnssSystem::BeiDou) => {
913                records.push(parse_keplerian_block(block, None, version)?);
914            }
915            // Recognized boundary, unsupported model (GLONASS state-vector, SBAS): skip.
916            _ => {}
917        }
918    }
919    Ok(records)
920}
921
922fn parse_nav_v3_lenient<'a, I>(
923    lines: I,
924    version: RinexVersion,
925) -> (Vec<BroadcastRecord>, Vec<SkippedNavBlock>)
926where
927    I: Iterator<Item = &'a str>,
928{
929    let mut blocks: Vec<Vec<&str>> = Vec::new();
930    for line in lines {
931        if is_record_start(line) {
932            blocks.push(vec![line]);
933        } else if let Some(last) = blocks.last_mut() {
934            last.push(line);
935        }
936    }
937
938    let mut records = Vec::new();
939    let mut skipped = Vec::new();
940    for block in &blocks {
941        let letter = block[0].as_bytes()[0] as char;
942        match GnssSystem::from_letter(letter) {
943            Some(GnssSystem::Gps) | Some(GnssSystem::Galileo) | Some(GnssSystem::BeiDou) => {
944                match parse_keplerian_block(block, None, version) {
945                    Ok(record) => records.push(record),
946                    Err(error) => skipped.push(SkippedNavBlock {
947                        satellite: nav_block_satellite(block),
948                        message: error.to_string(),
949                    }),
950                }
951            }
952            _ => {}
953        }
954    }
955    (records, skipped)
956}
957
958/// Version-4 body: each record is introduced by a `> EPH|STO|EOP|ION SVNN MSG`
959/// frame marker. `EPH` frames carrying a supported legacy Keplerian message use
960/// [`parse_keplerian_block`]; GPS/QZSS CNAV-family messages use
961/// [`parse_cnav_block`]. The message type is taken from the marker token (so
962/// I/NAV vs F/NAV, D1 vs D2, and GPS/QZSS CNV2 vs BeiDou CNV2 are explicit)
963/// after the marker SV and message family are cross-checked against the body
964/// line. STO/EOP/ION frames and unsupported message rosters are skipped.
965fn parse_nav_v4<'a, I>(
966    lines: I,
967    version: RinexVersion,
968) -> Result<Vec<BroadcastRecord>, NavParseError>
969where
970    I: Iterator<Item = &'a str>,
971{
972    // Group by marker line: each frame is its marker plus the body lines up to
973    // the next marker.
974    let frames = v4_frames(lines);
975    let mut records = Vec::new();
976    for (marker, body) in &frames {
977        let Some((frame_type, sv, msg_token)) = parse_v4_marker(marker) else {
978            continue;
979        };
980        if frame_type != "EPH" {
981            continue; // STO/EOP/ION carry no ephemeris.
982        }
983        let letter = sv.as_bytes().first().copied().map_or(' ', char::from);
984        let Some(system) = GnssSystem::from_letter(letter) else {
985            continue;
986        };
987        let supported = matches!(
988            system,
989            GnssSystem::Gps | GnssSystem::Galileo | GnssSystem::BeiDou | GnssSystem::Qzss
990        );
991        if !supported {
992            continue; // GLONASS/SBAS/NavIC: not a supported Keplerian system here.
993        }
994        if let Some(message) = nav_message_from_v4_token(msg_token, system) {
995            validate_v4_ephemeris_marker(sv, message, body)?;
996            if message.is_cnav_family() {
997                records.push(parse_cnav_block(body, message)?);
998            } else {
999                records.push(parse_keplerian_block(body, Some(message), version)?);
1000            }
1001        } else if known_v4_ephemeris_token(msg_token)
1002            && !explicitly_skipped_v4_message(msg_token, system)
1003        {
1004            return Err(NavParseError::BadField {
1005                satellite: sv.to_string(),
1006                field: "message",
1007            });
1008        }
1009    }
1010    Ok(records)
1011}
1012
1013fn parse_nav_v4_lenient<'a, I>(
1014    lines: I,
1015    version: RinexVersion,
1016) -> (Vec<BroadcastRecord>, Vec<SkippedNavBlock>)
1017where
1018    I: Iterator<Item = &'a str>,
1019{
1020    let frames = v4_frames(lines);
1021    let mut records = Vec::new();
1022    let mut skipped = Vec::new();
1023    for (marker, body) in &frames {
1024        let Some((frame_type, sv, msg_token)) = parse_v4_marker(marker) else {
1025            continue;
1026        };
1027        if frame_type != "EPH" {
1028            continue;
1029        }
1030        let letter = sv.as_bytes().first().copied().map_or(' ', char::from);
1031        let Some(system) = GnssSystem::from_letter(letter) else {
1032            continue;
1033        };
1034        let supported = matches!(
1035            system,
1036            GnssSystem::Gps | GnssSystem::Qzss | GnssSystem::Galileo | GnssSystem::BeiDou
1037        );
1038        if !supported {
1039            continue;
1040        }
1041        if let Some(message) = nav_message_from_v4_token(msg_token, system) {
1042            let parsed = validate_v4_ephemeris_marker(sv, message, body)
1043                .and_then(|()| parse_keplerian_block(body, Some(message), version));
1044            match parsed {
1045                Ok(record) => records.push(record),
1046                Err(error) => skipped.push(SkippedNavBlock {
1047                    satellite: sv.to_string(),
1048                    message: error.to_string(),
1049                }),
1050            }
1051        }
1052    }
1053    (records, skipped)
1054}
1055
1056fn nav_block_satellite(block: &[&str]) -> String {
1057    block
1058        .first()
1059        .and_then(|line| line.get(0..3))
1060        .unwrap_or("")
1061        .trim()
1062        .to_string()
1063}
1064
1065fn v4_frames<'a, I>(lines: I) -> Vec<(&'a str, Vec<&'a str>)>
1066where
1067    I: Iterator<Item = &'a str>,
1068{
1069    let mut frames: Vec<(&str, Vec<&str>)> = Vec::new();
1070    for line in lines {
1071        if is_v4_frame_marker(line) {
1072            frames.push((line, Vec::new()));
1073        } else if let Some((_, body)) = frames.last_mut() {
1074            body.push(line);
1075        }
1076    }
1077    frames
1078}
1079
1080/// Whether a version-4 line is a frame marker (`> ...`).
1081fn is_v4_frame_marker(line: &str) -> bool {
1082    line.starts_with("> ")
1083}
1084
1085/// Split a version-4 frame marker `> EPH G01 LNAV` into (frame type, SV, message
1086/// token), or `None` if it is malformed. Mirrors the RINEX-4 marker layout:
1087/// `>` then the 4-column frame class, the SV, and the message-type token.
1088fn parse_v4_marker(line: &str) -> Option<(&str, &str, &str)> {
1089    let rest = line.strip_prefix('>')?;
1090    let mut fields = rest.split_whitespace();
1091    let frame_type = fields.next()?;
1092    let sv = fields.next()?;
1093    let msg_token = fields.next()?;
1094    Some((frame_type, sv, msg_token))
1095}
1096
1097/// Map a version-4 EPH message token to the [`NavMessage`] for the supported
1098/// Keplerian messages. `CNV2` is system-overloaded: GPS/QZSS CNV2 is supported
1099/// as CNAV-2, while BeiDou CNV2 is a different roster and remains skipped.
1100fn nav_message_from_v4_token(token: &str, system: GnssSystem) -> Option<NavMessage> {
1101    match (token, system) {
1102        ("LNAV", GnssSystem::Gps) => Some(NavMessage::GpsLnav),
1103        ("CNAV", GnssSystem::Gps) => Some(NavMessage::GpsCnav),
1104        ("CNV2", GnssSystem::Gps) => Some(NavMessage::GpsCnav2),
1105        ("CNAV", GnssSystem::Qzss) => Some(NavMessage::QzssCnav),
1106        ("CNV2", GnssSystem::Qzss) => Some(NavMessage::QzssCnav2),
1107        ("INAV", GnssSystem::Galileo) => Some(NavMessage::GalileoInav),
1108        ("FNAV", GnssSystem::Galileo) => Some(NavMessage::GalileoFnav),
1109        ("D1", GnssSystem::BeiDou) => Some(NavMessage::BeidouD1),
1110        ("D2", GnssSystem::BeiDou) => Some(NavMessage::BeidouD2),
1111        _ => None,
1112    }
1113}
1114
1115fn known_v4_ephemeris_token(token: &str) -> bool {
1116    matches!(
1117        token,
1118        "LNAV" | "CNAV" | "CNV1" | "CNV2" | "CNV3" | "INAV" | "FNAV" | "D1" | "D2"
1119    )
1120}
1121
1122fn explicitly_skipped_v4_message(token: &str, system: GnssSystem) -> bool {
1123    matches!(
1124        (token, system),
1125        ("LNAV", GnssSystem::Qzss) | ("CNV1" | "CNV2" | "CNV3", GnssSystem::BeiDou)
1126    )
1127}
1128
1129fn validate_v4_ephemeris_marker(
1130    marker_sv: &str,
1131    message: NavMessage,
1132    body: &[&str],
1133) -> Result<(), NavParseError> {
1134    let Some(body_sv) = body
1135        .first()
1136        .and_then(|line| line.get(0..3))
1137        .map(str::trim)
1138        .filter(|sv| !sv.is_empty())
1139    else {
1140        return Ok(());
1141    };
1142
1143    let same_satellite = match (
1144        marker_sv.parse::<GnssSatelliteId>(),
1145        body_sv.parse::<GnssSatelliteId>(),
1146    ) {
1147        (Ok(marker), Ok(body)) => marker == body,
1148        _ => marker_sv == body_sv,
1149    };
1150
1151    if !same_satellite {
1152        return Err(NavParseError::BadField {
1153            satellite: marker_sv.to_string(),
1154            field: "frame marker",
1155        });
1156    }
1157
1158    let system = body_sv
1159        .as_bytes()
1160        .first()
1161        .and_then(|b| GnssSystem::from_letter(*b as char))
1162        .ok_or_else(|| NavParseError::BadField {
1163            satellite: body_sv.to_string(),
1164            field: "system",
1165        })?;
1166    if !nav_message_matches_system(message, system) {
1167        return Err(NavParseError::BadField {
1168            satellite: body_sv.to_string(),
1169            field: "message",
1170        });
1171    }
1172
1173    Ok(())
1174}
1175
1176fn nav_message_matches_system(message: NavMessage, system: GnssSystem) -> bool {
1177    matches!(
1178        (message, system),
1179        (NavMessage::GpsLnav, GnssSystem::Gps)
1180            | (NavMessage::GpsCnav | NavMessage::GpsCnav2, GnssSystem::Gps)
1181            | (
1182                NavMessage::QzssCnav | NavMessage::QzssCnav2,
1183                GnssSystem::Qzss,
1184            )
1185            | (
1186                NavMessage::GalileoInav | NavMessage::GalileoFnav,
1187                GnssSystem::Galileo,
1188            )
1189            | (
1190                NavMessage::BeidouD1 | NavMessage::BeidouD2,
1191                GnssSystem::BeiDou,
1192            )
1193    )
1194}
1195
1196/// Parse the broadcast ionosphere coefficients from a RINEX header's
1197/// `IONOSPHERIC CORR` lines or RINEX 4 body `> ION` frames (GPS
1198/// `GPSA`/`GPSB`, BeiDou `BDSA`/`BDSB`, and Galileo `GAL`).
1199///
1200/// A complete header label pair or body frame yields the coefficient set; a
1201/// missing label or frame yields `None` for that system. Parsing is
1202/// deterministic text, not a 0-ULP target.
1203pub fn parse_iono_corrections(text: &str) -> Result<IonoCorrections, NavParseError> {
1204    parse_iono_corrections_checked(text)
1205}
1206
1207fn parse_iono_corrections_checked(text: &str) -> Result<IonoCorrections, NavParseError> {
1208    // The IONOSPHERIC CORR line is `A4,1X,4(D12.4)`: a 4-char label, a space,
1209    // then up to four coefficients in 12-wide columns.
1210    //
1211    // GPS/BeiDou are Klobuchar models with four coefficients per row
1212    // (alpha0..alpha3 / beta0..beta3); all four columns are required and a
1213    // truncated row is a malformed header, not a tolerable short line.
1214    let klobuchar_row = |line: &str| -> Result<[f64; 4], NavParseError> {
1215        Ok([
1216            strict_header_f64(line, 5, 17, "ionospheric correction")?,
1217            strict_header_f64(line, 17, 29, "ionospheric correction")?,
1218            strict_header_f64(line, 29, 41, "ionospheric correction")?,
1219            strict_header_f64(line, 41, 53, "ionospheric correction")?,
1220        ])
1221    };
1222    // Galileo is NeQuick-G with three coefficients (ai0,ai1,ai2). The fourth
1223    // column is the disturbance flag, which real/merged headers frequently leave
1224    // blank; only the three coefficients are read, so the row parses whether or
1225    // not that flag is present.
1226    let nequick_row = |line: &str| -> Result<[f64; 3], NavParseError> {
1227        Ok([
1228            strict_header_f64(line, 5, 17, "ionospheric correction")?,
1229            strict_header_f64(line, 17, 29, "ionospheric correction")?,
1230            strict_header_f64(line, 29, 41, "ionospheric correction")?,
1231        ])
1232    };
1233    let (mut gpsa, mut gpsb, mut bdsa, mut bdsb, mut gal) = (None, None, None, None, None);
1234    for line in text.lines() {
1235        if line.contains("END OF HEADER") {
1236            break;
1237        }
1238        if !line.contains("IONOSPHERIC CORR") {
1239            continue;
1240        }
1241        match line.get(0..4).map(str::trim) {
1242            Some("GPSA") => gpsa = Some(klobuchar_row(line)?),
1243            Some("GPSB") => gpsb = Some(klobuchar_row(line)?),
1244            Some("BDSA") => bdsa = Some(klobuchar_row(line)?),
1245            Some("BDSB") => bdsb = Some(klobuchar_row(line)?),
1246            Some("GAL") => {
1247                let row = nequick_row(line)?;
1248                gal = Some(GalileoNequickCoeffs {
1249                    ai0: row[0],
1250                    ai1: row[1],
1251                    ai2: row[2],
1252                });
1253            }
1254            _ => {}
1255        }
1256    }
1257    let pair = |a: Option<[f64; 4]>, b: Option<[f64; 4]>| match (a, b) {
1258        (Some(alpha), Some(beta)) => Some(KlobucharAlphaBeta { alpha, beta }),
1259        _ => None,
1260    };
1261    let mut iono = IonoCorrections {
1262        gps: pair(gpsa, gpsb),
1263        beidou: pair(bdsa, bdsb),
1264        galileo: gal,
1265    };
1266    parse_v4_body_iono_corrections(text, &mut iono)?;
1267    Ok(iono)
1268}
1269
1270fn parse_v4_body_iono_corrections(
1271    text: &str,
1272    iono: &mut IonoCorrections,
1273) -> Result<(), NavParseError> {
1274    let mut lines = text.lines();
1275    for line in lines.by_ref() {
1276        if line.contains("END OF HEADER") {
1277            break;
1278        }
1279    }
1280
1281    for (marker, body) in v4_frames(lines) {
1282        let Some((frame_type, sv, _msg_token)) = parse_v4_marker(marker) else {
1283            continue;
1284        };
1285        if frame_type != "ION" {
1286            continue;
1287        }
1288        let values = parse_v4_iono_values(sv, &body)?;
1289        match sv
1290            .as_bytes()
1291            .first()
1292            .and_then(|b| GnssSystem::from_letter(*b as char))
1293        {
1294            Some(GnssSystem::Gps) => {
1295                iono.gps = Some(KlobucharAlphaBeta {
1296                    alpha: iono_values_4(&values, 0, sv)?,
1297                    beta: iono_values_4(&values, 4, sv)?,
1298                });
1299            }
1300            Some(GnssSystem::BeiDou) => {
1301                iono.beidou = Some(KlobucharAlphaBeta {
1302                    alpha: iono_values_4(&values, 0, sv)?,
1303                    beta: iono_values_4(&values, 4, sv)?,
1304                });
1305            }
1306            Some(GnssSystem::Galileo) => {
1307                let coeffs = iono_values_3(&values, 0, sv)?;
1308                iono.galileo = Some(GalileoNequickCoeffs {
1309                    ai0: coeffs[0],
1310                    ai1: coeffs[1],
1311                    ai2: coeffs[2],
1312                });
1313            }
1314            _ => {}
1315        }
1316    }
1317    Ok(())
1318}
1319
1320fn parse_v4_iono_values(sv: &str, body: &[&str]) -> Result<Vec<f64>, NavParseError> {
1321    if body.is_empty() {
1322        return Err(NavParseError::BadField {
1323            satellite: sv.to_string(),
1324            field: "ionospheric correction",
1325        });
1326    }
1327
1328    let mut values = Vec::new();
1329    for (idx, line) in body.iter().enumerate() {
1330        let ranges: &[(usize, usize)] = if idx == 0 {
1331            &[(23, 42), (42, 61), (61, 80)]
1332        } else {
1333            &[(4, 23), (23, 42), (42, 61), (61, 80)]
1334        };
1335        for &(start, end) in ranges {
1336            let raw = raw_field(line, start, end);
1337            if raw.trim().is_empty() {
1338                continue;
1339            }
1340            values.push(
1341                validate::strict_f64(raw, "ionospheric correction")
1342                    .map_err(|error| map_record_field_error(error, sv))?,
1343            );
1344        }
1345    }
1346    Ok(values)
1347}
1348
1349fn iono_values_4(values: &[f64], start: usize, sv: &str) -> Result<[f64; 4], NavParseError> {
1350    let Some(slice) = values.get(start..start + 4) else {
1351        return Err(NavParseError::BadField {
1352            satellite: sv.to_string(),
1353            field: "ionospheric correction",
1354        });
1355    };
1356    Ok([slice[0], slice[1], slice[2], slice[3]])
1357}
1358
1359fn iono_values_3(values: &[f64], start: usize, sv: &str) -> Result<[f64; 3], NavParseError> {
1360    let Some(slice) = values.get(start..start + 3) else {
1361        return Err(NavParseError::BadField {
1362            satellite: sv.to_string(),
1363            field: "ionospheric correction",
1364        });
1365    };
1366    Ok([slice[0], slice[1], slice[2]])
1367}
1368
1369/// The leap-second count (GPS − UTC) from the header's `LEAP SECONDS` line, used
1370/// to map a GLONASS (UTC) reference epoch onto the GPST-aligned query time. The
1371/// value is the first field; `None` if the line is absent.
1372pub fn parse_leap_seconds(text: &str) -> Result<Option<f64>, NavParseError> {
1373    parse_leap_seconds_checked(text)
1374}
1375
1376fn parse_leap_seconds_checked(text: &str) -> Result<Option<f64>, NavParseError> {
1377    for line in text.lines() {
1378        if line.contains("END OF HEADER") {
1379            break;
1380        }
1381        if line.contains("LEAP SECONDS") {
1382            return strict_header_integer_f64(line, 0, 6, "leap seconds").map(Some);
1383        }
1384    }
1385    Ok(None)
1386}
1387
1388/// Seconds from the J2000 epoch (2000-01-01 12:00) to a UTC calendar instant,
1389/// via the canonical no-leap civil conversion. Bit-identical to the previous
1390/// day-count arithmetic (the Julian Day Number is offset-equal to the Hinnant
1391/// day count, and the whole-second clock fields sum exactly in `f64`).
1392fn j2000_seconds_utc(y: i64, mo: i64, d: i64, h: i64, mi: i64, s: i64) -> f64 {
1393    civil::j2000_seconds(y as i32, mo as i32, d as i32, h as i32, mi as i32, s as f64)
1394}
1395
1396/// Parse the GLONASS epoch line (`Rnn YYYY MM DD HH MM SS`) to a UTC second past
1397/// J2000.
1398fn parse_glonass_epoch(l0: &str, sat: &str) -> Result<f64, NavParseError> {
1399    let year = strict_record_int::<i64>(l0, 4, 8, "epoch", sat)?;
1400    let month = strict_record_int::<i64>(l0, 9, 11, "epoch", sat)?;
1401    let day = strict_record_int::<i64>(l0, 12, 14, "epoch", sat)?;
1402    let hour = strict_record_int::<i64>(l0, 15, 17, "epoch", sat)?;
1403    let minute = strict_record_int::<i64>(l0, 18, 20, "epoch", sat)?;
1404    let second = strict_record_int::<i64>(l0, 21, 23, "epoch", sat)?;
1405    let civil = validate::civil_datetime_with_second_policy(
1406        year,
1407        month,
1408        day,
1409        hour,
1410        minute,
1411        second as f64,
1412        validate::CivilSecondPolicy::UtcLike,
1413    )
1414    .map_err(|_| NavParseError::BadField {
1415        satellite: sat.to_string(),
1416        field: "epoch",
1417    })?;
1418    Ok(j2000_seconds_utc(
1419        civil.year,
1420        i64::from(civil.month),
1421        i64::from(civil.day),
1422        i64::from(civil.hour),
1423        i64::from(civil.minute),
1424        civil.second as i64,
1425    ))
1426}
1427
1428/// Parse a 4-line RINEX 3 GLONASS record block into a [`GlonassRecord`]
1429/// (km/(km/s)/(km/s^2) state converted to SI). A missing or unparseable field is
1430/// a [`NavParseError`], not a silently dropped record.
1431fn parse_glonass_block(block: &[&str]) -> Result<GlonassRecord, NavParseError> {
1432    let l0 = block[0];
1433    let sat = l0.get(0..3).unwrap_or("").trim().to_string();
1434    if block.len() < 4 {
1435        return Err(NavParseError::TruncatedRecord(sat));
1436    }
1437    let bad = |what: &'static str| NavParseError::BadField {
1438        satellite: sat.clone(),
1439        field: what,
1440    };
1441    let satellite_id: GnssSatelliteId = sat.parse().map_err(|_| bad("prn"))?;
1442    let toe_utc_j2000_s = parse_glonass_epoch(l0, &sat)?;
1443    let clk_bias = parse_f64(l0, 23, 42).ok_or_else(|| bad("clock bias"))?;
1444    let gamma_n = parse_f64(l0, 42, 61).ok_or_else(|| bad("gamma_n"))?;
1445    let o1 = orbit_row(block[1]);
1446    let o2 = orbit_row(block[2]);
1447    let o3 = orbit_row(block[3]);
1448    let km = |v: Option<f64>, what: &'static str| v.map(|x| x * KM_TO_M).ok_or_else(|| bad(what));
1449    let g = |v: Option<f64>, what: &'static str| v.ok_or_else(|| bad(what));
1450    Ok(GlonassRecord {
1451        satellite_id,
1452        toe_utc_j2000_s,
1453        pos_m: [km(o1[0], "x")?, km(o2[0], "y")?, km(o3[0], "z")?],
1454        vel_m_s: [km(o1[1], "vx")?, km(o2[1], "vy")?, km(o3[1], "vz")?],
1455        acc_m_s2: [km(o1[2], "ax")?, km(o2[2], "ay")?, km(o3[2], "az")?],
1456        clk_bias,
1457        gamma_n,
1458        sv_health: g(o1[3], "health")?,
1459        freq_channel: glonass_frequency_channel(g(o2[3], "frequency channel")?, &sat)?,
1460    })
1461}
1462
1463/// Parse all GLONASS (`R`) records from a RINEX 3.x navigation file, in file
1464/// order; selection is the caller's job. A malformed *supported* record is a
1465/// [`NavParseError`] rather than a silently dropped one, but a record for a slot
1466/// the engine cannot represent (an extended GLONASS slot beyond the PRN cap, e.g.
1467/// `R28` in real BKG/IGS products) is skipped rather than rejecting the whole
1468/// file - the same treatment unsupported constellations get in
1469/// [`parse_nav_v3`]. (Version-4 GLONASS frames are not yet parsed.)
1470pub fn parse_glonass(text: &str) -> Result<Vec<GlonassRecord>, NavParseError> {
1471    Ok(parse_glonass_lenient(text)?.records)
1472}
1473
1474/// Like [`parse_glonass`], but also returns the slots that were skipped because
1475/// they are not representable as a [`GnssSatelliteId`] (an extended slot beyond
1476/// the PRN cap, e.g. `R28`).
1477///
1478/// [`parse_glonass`] drops that list silently; use this when a caller needs to
1479/// surface how many / which records were skipped, consistent with the
1480/// lenient-skip reporting elsewhere in the crate. A malformed *representable*
1481/// record is still a [`NavParseError`], not a skip.
1482pub fn parse_glonass_lenient(text: &str) -> Result<GlonassParse, NavParseError> {
1483    let mut lines = text.lines();
1484    verify_and_skip_header(&mut lines)?;
1485    let mut blocks: Vec<Vec<&str>> = Vec::new();
1486    for line in lines {
1487        if is_record_start(line) {
1488            blocks.push(vec![line]);
1489        } else if let Some(last) = blocks.last_mut() {
1490            last.push(line);
1491        }
1492    }
1493    let mut out = GlonassParse::default();
1494    for block in blocks.iter().filter(|b| b[0].starts_with('R')) {
1495        // A GLONASS slot beyond the engine's PRN cap is not representable as a
1496        // `GnssSatelliteId`. Skip such a record (one out-of-range slot must not
1497        // discard every other satellite's ephemeris) instead of erroring, but
1498        // record its identity so it is not lost silently; a representable slot
1499        // with a malformed numeric field still errors.
1500        let sat = block[0].get(0..3).unwrap_or("").trim();
1501        if sat.parse::<GnssSatelliteId>().is_err() {
1502            out.skipped.push(SkippedGlonass {
1503                token: sat.to_string(),
1504            });
1505            continue;
1506        }
1507        out.records.push(parse_glonass_block(block)?);
1508    }
1509    Ok(out)
1510}
1511
1512/// Skip the header, returning the RINEX version. Major versions 3 and 4 share
1513/// the fixed-column orbit layout; version 4 wraps each record in a frame marker
1514/// line (see [`parse_v4_marker`]), which is why `parse_nav` dispatches on it.
1515fn verify_and_skip_header<'a, I>(lines: &mut I) -> Result<RinexVersion, NavParseError>
1516where
1517    I: Iterator<Item = &'a str>,
1518{
1519    let mut version_seen: Option<RinexVersion> = None;
1520    for line in lines.by_ref() {
1521        if line.contains("RINEX VERSION / TYPE") {
1522            // Column 0-8 holds the version; column 20 the file type ('N' = NAV).
1523            let version = line.get(0..9).unwrap_or("").trim();
1524            let detected = parse_rinex_version(version);
1525            let is_nav = line.get(20..21) == Some("N");
1526            match (detected, is_nav) {
1527                (Some(v), true) => version_seen = Some(v),
1528                _ => {
1529                    return Err(NavParseError::UnsupportedHeader(
1530                        line.trim_end().to_string(),
1531                    ))
1532                }
1533            }
1534        }
1535        if line.contains("END OF HEADER") {
1536            return version_seen.ok_or_else(|| {
1537                NavParseError::UnsupportedHeader("no RINEX VERSION / TYPE".to_string())
1538            });
1539        }
1540    }
1541    Err(NavParseError::MissingHeaderEnd)
1542}
1543
1544fn parse_rinex_version(version: &str) -> Option<RinexVersion> {
1545    let (major, minor) = version.split_once('.')?;
1546    let major = major.trim().parse::<u8>().ok()?;
1547    if !matches!(major, 3 | 4) {
1548        return None;
1549    }
1550    let minor_digits = minor
1551        .chars()
1552        .take_while(char::is_ascii_digit)
1553        .collect::<String>();
1554    if minor_digits.is_empty() {
1555        return None;
1556    }
1557    let minor = minor_digits.parse::<u8>().ok()?;
1558    Some(RinexVersion { major, minor })
1559}
1560
1561fn is_record_start(line: &str) -> bool {
1562    let Some(token) = line.get(0..3) else {
1563        return false;
1564    };
1565    let b = token.as_bytes();
1566    let prn = token[1..].trim();
1567    b[0].is_ascii_alphabetic()
1568        && (1..=2).contains(&prn.len())
1569        && prn.bytes().all(|byte| byte.is_ascii_digit())
1570}
1571
1572/// The four broadcast-orbit values of a continuation line (columns 4/23/42/61).
1573fn orbit_row(line: &str) -> [Option<f64>; 4] {
1574    [
1575        parse_f64(line, 4, 23),
1576        parse_f64(line, 23, 42),
1577        parse_f64(line, 42, 61),
1578        parse_f64(line, 61, 80),
1579    ]
1580}
1581
1582fn raw_orbit_field(line: &str, field_index: usize) -> &str {
1583    const RANGES: [(usize, usize); 4] = [(4, 23), (23, 42), (42, 61), (61, 80)];
1584    let (start, end) = RANGES[field_index];
1585    raw_field(line, start, end)
1586}
1587
1588#[derive(Debug, Clone, Copy)]
1589struct ClockReferenceEpoch {
1590    week: u32,
1591    sow: f64,
1592}
1593
1594fn parse_keplerian_block(
1595    block: &[&str],
1596    message_override: Option<NavMessage>,
1597    version: RinexVersion,
1598) -> Result<BroadcastRecord, NavParseError> {
1599    let l0 = block.first().copied().unwrap_or("");
1600    let sat = l0.get(0..3).unwrap_or("").trim().to_string();
1601    if block.len() < 8 {
1602        return Err(NavParseError::TruncatedRecord(sat));
1603    }
1604    let bad = |what: &'static str| NavParseError::BadField {
1605        satellite: sat.clone(),
1606        field: what,
1607    };
1608
1609    let letter = l0
1610        .as_bytes()
1611        .first()
1612        .copied()
1613        .map(|b| b as char)
1614        .ok_or_else(|| bad("system"))?;
1615    let system = GnssSystem::from_letter(letter).ok_or_else(|| bad("system"))?;
1616    let satellite_id: GnssSatelliteId = sat.parse().map_err(|_| bad("prn"))?;
1617
1618    // Clock line: epoch (-> toc) and the af0/af1/af2 polynomial.
1619    let time_scale = broadcast_time_scale(system);
1620    let toc_epoch = parse_toc(l0, &sat, time_scale)?;
1621    let toc_sow = toc_epoch.sow;
1622    let af0 = parse_f64(l0, 23, 42).ok_or_else(|| bad("af0"))?;
1623    let af1 = parse_f64(l0, 42, 61).ok_or_else(|| bad("af1"))?;
1624    let af2 = parse_f64(l0, 61, 80).ok_or_else(|| bad("af2"))?;
1625
1626    let o1 = orbit_row(block[1]);
1627    let o2 = orbit_row(block[2]);
1628    let o3 = orbit_row(block[3]);
1629    let o4 = orbit_row(block[4]);
1630    let o5 = orbit_row(block[5]);
1631    let o6 = orbit_row(block[6]);
1632
1633    let g = |v: Option<f64>, what: &'static str| v.ok_or_else(|| bad(what));
1634
1635    let elements = KeplerianElements {
1636        crs: g(o1[1], "crs")?,
1637        delta_n: g(o1[2], "deltaN")?,
1638        m0: g(o1[3], "m0")?,
1639        cuc: g(o2[0], "cuc")?,
1640        e: g(o2[1], "e")?,
1641        cus: g(o2[2], "cus")?,
1642        sqrt_a: g(o2[3], "sqrtA")?,
1643        toe_sow: g(o3[0], "toe")?,
1644        cic: g(o3[1], "cic")?,
1645        omega0: g(o3[2], "omega0")?,
1646        cis: g(o3[3], "cis")?,
1647        i0: g(o4[0], "i0")?,
1648        crc: g(o4[1], "crc")?,
1649        omega: g(o4[2], "omega")?,
1650        omega_dot: g(o4[3], "omegaDot")?,
1651        idot: g(o5[0], "idot")?,
1652    };
1653    let clock = ClockPolynomial {
1654        af0,
1655        af1,
1656        af2,
1657        toc_sow,
1658    };
1659
1660    let week = finite_integral_u32(g(o5[2], "week")?, "week", &sat)?;
1661    let toe = GnssWeekTow::new(time_scale, week, elements.toe_sow)
1662        .and_then(GnssWeekTow::normalized)
1663        .map_err(|_| bad("toe"))?;
1664    let toc = GnssWeekTow::new(time_scale, toc_epoch.week, clock.toc_sow)
1665        .and_then(GnssWeekTow::normalized)
1666        .map_err(|_| bad("toc"))?;
1667    let message = if let Some(message) = message_override {
1668        message
1669    } else {
1670        match system {
1671            GnssSystem::Galileo => galileo_message(g(o5[1], "data sources")?, &sat)?,
1672            GnssSystem::BeiDou => {
1673                if is_beidou_geo(satellite_id) {
1674                    NavMessage::BeidouD2
1675                } else {
1676                    NavMessage::BeidouD1
1677                }
1678            }
1679            _ => NavMessage::GpsLnav,
1680        }
1681    };
1682    let issue_of_data = BroadcastIssue {
1683        issue: finite_integral_u32(g(o1[0], "issue of data")?, "issue of data", &sat)?,
1684        message,
1685    };
1686
1687    let sv_accuracy_m = g(o6[0], "accuracy")?;
1688    let sv_health = g(o6[1], "health")?;
1689    let group_delays = match system {
1690        GnssSystem::Gps => BroadcastGroupDelays::gps_lnav(g(o6[2], "gps tgd")?),
1691        // RINEX Galileo ORBIT-6 carries BGD E5a/E1 in field 3 and BGD E5b/E1 in
1692        // field 4; both are part of the message representation regardless of
1693        // which one a clock consumer later selects.
1694        GnssSystem::Galileo => {
1695            BroadcastGroupDelays::galileo(g(o6[2], "bgd e5a/e1")?, g(o6[3], "bgd e5b/e1")?)
1696        }
1697        GnssSystem::BeiDou => {
1698            BroadcastGroupDelays::beidou(g(o6[2], "beidou tgd1")?, g(o6[3], "beidou tgd2")?)
1699        }
1700        _ => BroadcastGroupDelays::default(),
1701    };
1702
1703    // Only GPS LNAV broadcasts a curve-fit interval (ORBIT-7 field 2); Galileo
1704    // and BeiDou leave that column blank or spare, so they carry no fit interval.
1705    let fit_interval_s = match system {
1706        GnssSystem::Gps => {
1707            Some(gps_fit_interval_s(block[7], version).map_err(|()| bad("fit interval"))?)
1708        }
1709        _ => None,
1710    };
1711
1712    Ok(BroadcastRecord {
1713        satellite_id,
1714        message,
1715        issue_of_data,
1716        week,
1717        toe,
1718        toc,
1719        elements,
1720        clock,
1721        group_delays,
1722        cnav: None,
1723        sv_health,
1724        sv_accuracy_m,
1725        fit_interval_s,
1726    })
1727}
1728
1729fn parse_cnav_block(block: &[&str], message: NavMessage) -> Result<BroadcastRecord, NavParseError> {
1730    let l0 = block.first().copied().unwrap_or("");
1731    let sat = l0.get(0..3).unwrap_or("").trim().to_string();
1732    let is_cnav2 = matches!(message, NavMessage::GpsCnav2 | NavMessage::QzssCnav2);
1733    let required_lines = if is_cnav2 { 10 } else { 9 };
1734    if block.len() < required_lines {
1735        return Err(NavParseError::TruncatedRecord(sat));
1736    }
1737    let bad = |what: &'static str| NavParseError::BadField {
1738        satellite: sat.clone(),
1739        field: what,
1740    };
1741
1742    let letter = l0
1743        .as_bytes()
1744        .first()
1745        .copied()
1746        .map(|b| b as char)
1747        .ok_or_else(|| bad("system"))?;
1748    GnssSystem::from_letter(letter).ok_or_else(|| bad("system"))?;
1749    let satellite_id: GnssSatelliteId = sat.parse().map_err(|_| bad("prn"))?;
1750    let toc_epoch = parse_toc(l0, &sat, TimeScale::Gpst)?;
1751    let af0 = parse_f64(l0, 23, 42).ok_or_else(|| bad("af0"))?;
1752    let af1 = parse_f64(l0, 42, 61).ok_or_else(|| bad("af1"))?;
1753    let af2 = parse_f64(l0, 61, 80).ok_or_else(|| bad("af2"))?;
1754
1755    let o1 = orbit_row(block[1]);
1756    let o2 = orbit_row(block[2]);
1757    let o3 = orbit_row(block[3]);
1758    let o4 = orbit_row(block[4]);
1759    let o5 = orbit_row(block[5]);
1760    let o6 = orbit_row(block[6]);
1761    let o8 = orbit_row(block[8]);
1762    let o9 = if is_cnav2 {
1763        Some(orbit_row(block[9]))
1764    } else {
1765        None
1766    };
1767
1768    let g = |v: Option<f64>, what: &'static str| v.ok_or_else(|| bad(what));
1769    let elements = KeplerianElements {
1770        crs: g(o1[1], "crs")?,
1771        delta_n: g(o1[2], "deltaN0")?,
1772        m0: g(o1[3], "m0")?,
1773        cuc: g(o2[0], "cuc")?,
1774        e: g(o2[1], "e")?,
1775        cus: g(o2[2], "cus")?,
1776        sqrt_a: g(o2[3], "sqrtA0")?,
1777        toe_sow: toc_epoch.sow,
1778        cic: g(o3[1], "cic")?,
1779        omega0: g(o3[2], "omega0")?,
1780        cis: g(o3[3], "cis")?,
1781        i0: g(o4[0], "i0")?,
1782        crc: g(o4[1], "crc")?,
1783        omega: g(o4[2], "omega")?,
1784        omega_dot: g(o4[3], "omegaDot")?,
1785        idot: g(o5[0], "idot")?,
1786    };
1787    let clock = ClockPolynomial {
1788        af0,
1789        af1,
1790        af2,
1791        toc_sow: toc_epoch.sow,
1792    };
1793
1794    let week = toc_epoch.week;
1795    let toe = GnssWeekTow::new(TimeScale::Gpst, week, elements.toe_sow)
1796        .and_then(GnssWeekTow::normalized)
1797        .map_err(|_| bad("toe"))?;
1798    let toc = GnssWeekTow::new(TimeScale::Gpst, week, clock.toc_sow)
1799        .and_then(GnssWeekTow::normalized)
1800        .map_err(|_| bad("toc"))?;
1801    let wn_op = finite_integral_u32(
1802        g(if is_cnav2 { o9.unwrap()[1] } else { o8[1] }, "wn_op")?,
1803        "wn_op",
1804        &sat,
1805    )?;
1806    let top_sow = g(o3[0], "top")?;
1807    let top = GnssWeekTow::new(TimeScale::Gpst, wn_op, top_sow)
1808        .and_then(GnssWeekTow::normalized)
1809        .map_err(|_| bad("top"))?;
1810    let ura_ed_index = finite_integral_i8(g(o6[0], "ura_ed")?, "ura_ed", -16, 15, &sat)?;
1811    let ura_ned0_index = finite_integral_i8(g(o5[2], "ura_ned0")?, "ura_ned0", -16, 15, &sat)?;
1812    let ura_ned1_index = finite_integral_u8(g(o5[3], "ura_ned1")?, "ura_ned1", 0, 7, &sat)?;
1813    let ura_ned2_index = finite_integral_u8(g(o6[3], "ura_ned2")?, "ura_ned2", 0, 7, &sat)?;
1814    let health_max = if is_cnav2 { 1 } else { 7 };
1815    let sv_health = f64::from(finite_integral_u8(
1816        g(o6[1], "health")?,
1817        "health",
1818        0,
1819        health_max,
1820        &sat,
1821    )?);
1822    let transmission_time_sow = g(if is_cnav2 { o9.unwrap()[0] } else { o8[0] }, "t_tm")?;
1823    let flags = optional_integral_u32(
1824        if is_cnav2 {
1825            raw_orbit_field(block[9], 2)
1826        } else {
1827            raw_orbit_field(block[8], 2)
1828        },
1829        "flags",
1830        &sat,
1831    )?;
1832
1833    let tgd = optional_cnav_delay(raw_orbit_field(block[6], 2), "tgd", &sat)?;
1834    let isc_l1ca = optional_cnav_delay(raw_orbit_field(block[7], 0), "isc_l1ca", &sat)?;
1835    let isc_l2c = optional_cnav_delay(raw_orbit_field(block[7], 1), "isc_l2c", &sat)?;
1836    let isc_l5i5 = optional_cnav_delay(raw_orbit_field(block[7], 2), "isc_l5i5", &sat)?;
1837    let isc_l5q5 = optional_cnav_delay(raw_orbit_field(block[7], 3), "isc_l5q5", &sat)?;
1838    let (isc_l1cd, isc_l1cp) = if is_cnav2 {
1839        (
1840            optional_cnav_delay(raw_orbit_field(block[8], 0), "isc_l1cd", &sat)?,
1841            optional_cnav_delay(raw_orbit_field(block[8], 1), "isc_l1cp", &sat)?,
1842        )
1843    } else {
1844        (None, None)
1845    };
1846
1847    let cnav = CnavParameters {
1848        adot_m_s: g(o1[0], "adot")?,
1849        delta_n0_dot_rad_s2: g(o5[1], "deltaN0Dot")?,
1850        top,
1851        ura_ed_index,
1852        ura_ned0_index,
1853        ura_ned1_index,
1854        ura_ned2_index,
1855        transmission_time_sow,
1856        flags,
1857    };
1858    let sv_accuracy_m = cnav_ura_nominal_m(ura_ed_index).unwrap_or(8192.0);
1859    let issue = (elements.toe_sow / 300.0).round() as u32;
1860
1861    Ok(BroadcastRecord {
1862        satellite_id,
1863        message,
1864        issue_of_data: BroadcastIssue { issue, message },
1865        week,
1866        toe,
1867        toc,
1868        elements,
1869        clock,
1870        group_delays: BroadcastGroupDelays::cnav(
1871            tgd, isc_l1ca, isc_l2c, isc_l5i5, isc_l5q5, isc_l1cd, isc_l1cp,
1872        ),
1873        cnav: Some(cnav),
1874        sv_health,
1875        sv_accuracy_m,
1876        fit_interval_s: Some(3.0 * SECONDS_PER_HOUR),
1877    })
1878}
1879
1880/// The GPS curve-fit interval in seconds from the ORBIT-7 fit-interval field.
1881/// RINEX 3.03+ and 4.xx record this field in hours. Legacy RINEX 3.02 and older
1882/// files may carry the broadcast 0/1 fit-interval flag instead, where 1 means
1883/// more than four hours rather than one hour. Per IS-GPS-200 the decoded value
1884/// is the total interval centered on `toe`; a zero or absent field denotes the
1885/// nominal four hours.
1886///
1887/// A blank/absent field is the legitimate nominal case (some products omit it);
1888/// a present but non-numeric field is a malformed record, reported as `Err` so
1889/// the caller can raise the same `BadField` error as for other numeric fields
1890/// rather than silently substituting four hours.
1891fn gps_fit_interval_s(orbit7: &str, version: RinexVersion) -> Result<f64, ()> {
1892    let value = match field(orbit7, 23, 42) {
1893        None => 0.0,
1894        Some(_) => parse_f64(orbit7, 23, 42).ok_or(())?,
1895    };
1896    if value == 0.0 {
1897        Ok(GPS_NOMINAL_FIT_INTERVAL_S)
1898    } else if version.gps_fit_interval_uses_legacy_flag() && value == 1.0 {
1899        Ok(GPS_LEGACY_EXTENDED_FIT_INTERVAL_S)
1900    } else {
1901        Ok(value * SECONDS_PER_HOUR)
1902    }
1903}
1904
1905/// Classify a Galileo record from its data-source word (orbit-5 field 1): source
1906/// bit 1 is F/NAV, source bits 0/2 are I/NAV. Bits 8/9 describe the clock-pair
1907/// frequency and do not determine the navigation message type.
1908fn galileo_message(data_sources: f64, sat: &str) -> Result<NavMessage, NavParseError> {
1909    let word = finite_integral_u32(data_sources, "data sources", sat)?;
1910    if word & 0b010 != 0 {
1911        Ok(NavMessage::GalileoFnav)
1912    } else if word & 0b101 != 0 {
1913        Ok(NavMessage::GalileoInav)
1914    } else {
1915        // No source bit set: default to I/NAV (the operational E1 message).
1916        Ok(NavMessage::GalileoInav)
1917    }
1918}
1919
1920fn finite_integral_u32(value: f64, field: &'static str, sat: &str) -> Result<u32, NavParseError> {
1921    validate::finite(value, field).map_err(|error| map_record_field_error(error, sat))?;
1922    if value < 0.0 || value > f64::from(u32::MAX) || value.trunc() != value {
1923        return Err(NavParseError::BadField {
1924            satellite: sat.to_string(),
1925            field,
1926        });
1927    }
1928    Ok(value as u32)
1929}
1930
1931fn finite_integral_i8(
1932    value: f64,
1933    field: &'static str,
1934    min: i8,
1935    max: i8,
1936    sat: &str,
1937) -> Result<i8, NavParseError> {
1938    validate::finite(value, field).map_err(|error| map_record_field_error(error, sat))?;
1939    if value < f64::from(min) || value > f64::from(max) || value.trunc() != value {
1940        return Err(NavParseError::BadField {
1941            satellite: sat.to_string(),
1942            field,
1943        });
1944    }
1945    Ok(value as i8)
1946}
1947
1948fn finite_integral_u8(
1949    value: f64,
1950    field: &'static str,
1951    min: u8,
1952    max: u8,
1953    sat: &str,
1954) -> Result<u8, NavParseError> {
1955    validate::finite(value, field).map_err(|error| map_record_field_error(error, sat))?;
1956    if value < f64::from(min) || value > f64::from(max) || value.trunc() != value {
1957        return Err(NavParseError::BadField {
1958            satellite: sat.to_string(),
1959            field,
1960        });
1961    }
1962    Ok(value as u8)
1963}
1964
1965fn optional_integral_u32(
1966    raw: &str,
1967    field: &'static str,
1968    sat: &str,
1969) -> Result<Option<u32>, NavParseError> {
1970    if raw.trim().is_empty() {
1971        return Ok(None);
1972    }
1973    let value =
1974        validate::strict_f64(raw, field).map_err(|error| map_record_field_error(error, sat))?;
1975    finite_integral_u32(value, field, sat).map(Some)
1976}
1977
1978fn optional_cnav_delay(
1979    raw: &str,
1980    field: &'static str,
1981    sat: &str,
1982) -> Result<Option<f64>, NavParseError> {
1983    if raw.trim().is_empty() {
1984        return Ok(None);
1985    }
1986    let value =
1987        validate::strict_f64(raw, field).map_err(|error| map_record_field_error(error, sat))?;
1988    if !write::d19_12_representable(value) {
1989        return Err(NavParseError::BadField {
1990            satellite: sat.to_string(),
1991            field,
1992        });
1993    }
1994    let mut rendered = String::new();
1995    write::push_d19_12(&mut rendered, value);
1996    let mut sentinel = String::new();
1997    write::push_d19_12(&mut sentinel, -4096.0 * 2.0_f64.powi(-35));
1998    if rendered == sentinel {
1999        Ok(None)
2000    } else {
2001        Ok(Some(value))
2002    }
2003}
2004
2005fn glonass_frequency_channel(value: f64, sat: &str) -> Result<i32, NavParseError> {
2006    const FIELD: &str = "frequency channel";
2007    validate::finite(value, FIELD).map_err(|error| map_record_field_error(error, sat))?;
2008    let channel = value as i32;
2009    if value.trunc() != value || !valid_glonass_frequency_channel(channel) {
2010        return Err(NavParseError::BadField {
2011            satellite: sat.to_string(),
2012            field: FIELD,
2013        });
2014    }
2015    Ok(channel)
2016}
2017
2018fn strict_header_f64(
2019    line: &str,
2020    start: usize,
2021    end: usize,
2022    field: &'static str,
2023) -> Result<f64, NavParseError> {
2024    validate::strict_f64(raw_field(line, start, end), field).map_err(map_header_field_error)
2025}
2026
2027fn strict_header_integer_f64(
2028    line: &str,
2029    start: usize,
2030    end: usize,
2031    field: &'static str,
2032) -> Result<f64, NavParseError> {
2033    let value = strict_header_f64(line, start, end, field)?;
2034    if value.trunc() != value {
2035        return Err(NavParseError::BadHeaderField { field });
2036    }
2037    Ok(value)
2038}
2039
2040fn strict_record_int<T>(
2041    line: &str,
2042    start: usize,
2043    end: usize,
2044    field: &'static str,
2045    satellite: &str,
2046) -> Result<T, NavParseError>
2047where
2048    T: core::str::FromStr,
2049{
2050    validate::strict_int::<T>(raw_field(line, start, end), field)
2051        .map_err(|error| map_record_field_error(error, satellite))
2052}
2053
2054fn map_record_field_error(error: FieldError, satellite: &str) -> NavParseError {
2055    NavParseError::BadField {
2056        satellite: satellite.to_string(),
2057        field: error.field(),
2058    }
2059}
2060
2061fn map_header_field_error(error: FieldError) -> NavParseError {
2062    NavParseError::BadHeaderField {
2063        field: error.field(),
2064    }
2065}
2066
2067/// Parse the clock reference epoch from the SV/epoch line into week and seconds
2068/// of week in the record's broadcast time scale.
2069fn parse_toc(
2070    l0: &str,
2071    sat: &str,
2072    time_scale: TimeScale,
2073) -> Result<ClockReferenceEpoch, NavParseError> {
2074    let year = strict_record_int::<i64>(l0, 4, 8, "toc epoch", sat)?;
2075    let month = strict_record_int::<i64>(l0, 9, 11, "toc epoch", sat)?;
2076    let day = strict_record_int::<i64>(l0, 12, 14, "toc epoch", sat)?;
2077    let hour = strict_record_int::<i64>(l0, 15, 17, "toc epoch", sat)?;
2078    let minute = strict_record_int::<i64>(l0, 18, 20, "toc epoch", sat)?;
2079    let second = strict_record_int::<i64>(l0, 21, 23, "toc epoch", sat)?;
2080    let civil = validate::civil_datetime_with_second_policy(
2081        year,
2082        month,
2083        day,
2084        hour,
2085        minute,
2086        second as f64,
2087        validate::CivilSecondPolicy::Continuous,
2088    )
2089    .map_err(|_| NavParseError::BadField {
2090        satellite: sat.to_string(),
2091        field: "toc epoch",
2092    })?;
2093    let month = i64::from(civil.month);
2094    let day = i64::from(civil.day);
2095    let week = gnss::week_from_calendar(time_scale, civil.year, month, day).ok_or_else(|| {
2096        NavParseError::BadField {
2097            satellite: sat.to_string(),
2098            field: "toc epoch",
2099        }
2100    })?;
2101    let sow = gnss::seconds_of_week_from_calendar(
2102        civil.year,
2103        month,
2104        day,
2105        i64::from(civil.hour),
2106        i64::from(civil.minute),
2107        civil.second as i64,
2108    );
2109    Ok(ClockReferenceEpoch { week, sow })
2110}
2111
2112#[cfg(all(test, sidereon_repo_tests))]
2113mod tests;