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