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    if marker_sv != body_sv {
1144        return Err(NavParseError::BadField {
1145            satellite: marker_sv.to_string(),
1146            field: "frame marker",
1147        });
1148    }
1149
1150    let system = body_sv
1151        .as_bytes()
1152        .first()
1153        .and_then(|b| GnssSystem::from_letter(*b as char))
1154        .ok_or_else(|| NavParseError::BadField {
1155            satellite: body_sv.to_string(),
1156            field: "system",
1157        })?;
1158    if !nav_message_matches_system(message, system) {
1159        return Err(NavParseError::BadField {
1160            satellite: body_sv.to_string(),
1161            field: "message",
1162        });
1163    }
1164
1165    Ok(())
1166}
1167
1168fn nav_message_matches_system(message: NavMessage, system: GnssSystem) -> bool {
1169    matches!(
1170        (message, system),
1171        (NavMessage::GpsLnav, GnssSystem::Gps)
1172            | (NavMessage::GpsCnav | NavMessage::GpsCnav2, GnssSystem::Gps)
1173            | (
1174                NavMessage::QzssCnav | NavMessage::QzssCnav2,
1175                GnssSystem::Qzss,
1176            )
1177            | (
1178                NavMessage::GalileoInav | NavMessage::GalileoFnav,
1179                GnssSystem::Galileo,
1180            )
1181            | (
1182                NavMessage::BeidouD1 | NavMessage::BeidouD2,
1183                GnssSystem::BeiDou,
1184            )
1185    )
1186}
1187
1188/// Parse the broadcast ionosphere coefficients from a RINEX header's
1189/// `IONOSPHERIC CORR` lines or RINEX 4 body `> ION` frames (GPS
1190/// `GPSA`/`GPSB`, BeiDou `BDSA`/`BDSB`, and Galileo `GAL`).
1191///
1192/// A complete header label pair or body frame yields the coefficient set; a
1193/// missing label or frame yields `None` for that system. Parsing is
1194/// deterministic text, not a 0-ULP target.
1195pub fn parse_iono_corrections(text: &str) -> Result<IonoCorrections, NavParseError> {
1196    parse_iono_corrections_checked(text)
1197}
1198
1199fn parse_iono_corrections_checked(text: &str) -> Result<IonoCorrections, NavParseError> {
1200    // The IONOSPHERIC CORR line is `A4,1X,4(D12.4)`: a 4-char label, a space,
1201    // then up to four coefficients in 12-wide columns.
1202    //
1203    // GPS/BeiDou are Klobuchar models with four coefficients per row
1204    // (alpha0..alpha3 / beta0..beta3); all four columns are required and a
1205    // truncated row is a malformed header, not a tolerable short line.
1206    let klobuchar_row = |line: &str| -> Result<[f64; 4], NavParseError> {
1207        Ok([
1208            strict_header_f64(line, 5, 17, "ionospheric correction")?,
1209            strict_header_f64(line, 17, 29, "ionospheric correction")?,
1210            strict_header_f64(line, 29, 41, "ionospheric correction")?,
1211            strict_header_f64(line, 41, 53, "ionospheric correction")?,
1212        ])
1213    };
1214    // Galileo is NeQuick-G with three coefficients (ai0,ai1,ai2). The fourth
1215    // column is the disturbance flag, which real/merged headers frequently leave
1216    // blank; only the three coefficients are read, so the row parses whether or
1217    // not that flag is present.
1218    let nequick_row = |line: &str| -> Result<[f64; 3], NavParseError> {
1219        Ok([
1220            strict_header_f64(line, 5, 17, "ionospheric correction")?,
1221            strict_header_f64(line, 17, 29, "ionospheric correction")?,
1222            strict_header_f64(line, 29, 41, "ionospheric correction")?,
1223        ])
1224    };
1225    let (mut gpsa, mut gpsb, mut bdsa, mut bdsb, mut gal) = (None, None, None, None, None);
1226    for line in text.lines() {
1227        if line.contains("END OF HEADER") {
1228            break;
1229        }
1230        if !line.contains("IONOSPHERIC CORR") {
1231            continue;
1232        }
1233        match line.get(0..4).map(str::trim) {
1234            Some("GPSA") => gpsa = Some(klobuchar_row(line)?),
1235            Some("GPSB") => gpsb = Some(klobuchar_row(line)?),
1236            Some("BDSA") => bdsa = Some(klobuchar_row(line)?),
1237            Some("BDSB") => bdsb = Some(klobuchar_row(line)?),
1238            Some("GAL") => {
1239                let row = nequick_row(line)?;
1240                gal = Some(GalileoNequickCoeffs {
1241                    ai0: row[0],
1242                    ai1: row[1],
1243                    ai2: row[2],
1244                });
1245            }
1246            _ => {}
1247        }
1248    }
1249    let pair = |a: Option<[f64; 4]>, b: Option<[f64; 4]>| match (a, b) {
1250        (Some(alpha), Some(beta)) => Some(KlobucharAlphaBeta { alpha, beta }),
1251        _ => None,
1252    };
1253    let mut iono = IonoCorrections {
1254        gps: pair(gpsa, gpsb),
1255        beidou: pair(bdsa, bdsb),
1256        galileo: gal,
1257    };
1258    parse_v4_body_iono_corrections(text, &mut iono)?;
1259    Ok(iono)
1260}
1261
1262fn parse_v4_body_iono_corrections(
1263    text: &str,
1264    iono: &mut IonoCorrections,
1265) -> Result<(), NavParseError> {
1266    let mut lines = text.lines();
1267    for line in lines.by_ref() {
1268        if line.contains("END OF HEADER") {
1269            break;
1270        }
1271    }
1272
1273    for (marker, body) in v4_frames(lines) {
1274        let Some((frame_type, sv, _msg_token)) = parse_v4_marker(marker) else {
1275            continue;
1276        };
1277        if frame_type != "ION" {
1278            continue;
1279        }
1280        let values = parse_v4_iono_values(sv, &body)?;
1281        match sv
1282            .as_bytes()
1283            .first()
1284            .and_then(|b| GnssSystem::from_letter(*b as char))
1285        {
1286            Some(GnssSystem::Gps) => {
1287                iono.gps = Some(KlobucharAlphaBeta {
1288                    alpha: iono_values_4(&values, 0, sv)?,
1289                    beta: iono_values_4(&values, 4, sv)?,
1290                });
1291            }
1292            Some(GnssSystem::BeiDou) => {
1293                iono.beidou = Some(KlobucharAlphaBeta {
1294                    alpha: iono_values_4(&values, 0, sv)?,
1295                    beta: iono_values_4(&values, 4, sv)?,
1296                });
1297            }
1298            Some(GnssSystem::Galileo) => {
1299                let coeffs = iono_values_3(&values, 0, sv)?;
1300                iono.galileo = Some(GalileoNequickCoeffs {
1301                    ai0: coeffs[0],
1302                    ai1: coeffs[1],
1303                    ai2: coeffs[2],
1304                });
1305            }
1306            _ => {}
1307        }
1308    }
1309    Ok(())
1310}
1311
1312fn parse_v4_iono_values(sv: &str, body: &[&str]) -> Result<Vec<f64>, NavParseError> {
1313    if body.is_empty() {
1314        return Err(NavParseError::BadField {
1315            satellite: sv.to_string(),
1316            field: "ionospheric correction",
1317        });
1318    }
1319
1320    let mut values = Vec::new();
1321    for (idx, line) in body.iter().enumerate() {
1322        let ranges: &[(usize, usize)] = if idx == 0 {
1323            &[(23, 42), (42, 61), (61, 80)]
1324        } else {
1325            &[(4, 23), (23, 42), (42, 61), (61, 80)]
1326        };
1327        for &(start, end) in ranges {
1328            let raw = raw_field(line, start, end);
1329            if raw.trim().is_empty() {
1330                continue;
1331            }
1332            values.push(
1333                validate::strict_f64(raw, "ionospheric correction")
1334                    .map_err(|error| map_record_field_error(error, sv))?,
1335            );
1336        }
1337    }
1338    Ok(values)
1339}
1340
1341fn iono_values_4(values: &[f64], start: usize, sv: &str) -> Result<[f64; 4], NavParseError> {
1342    let Some(slice) = values.get(start..start + 4) else {
1343        return Err(NavParseError::BadField {
1344            satellite: sv.to_string(),
1345            field: "ionospheric correction",
1346        });
1347    };
1348    Ok([slice[0], slice[1], slice[2], slice[3]])
1349}
1350
1351fn iono_values_3(values: &[f64], start: usize, sv: &str) -> Result<[f64; 3], NavParseError> {
1352    let Some(slice) = values.get(start..start + 3) else {
1353        return Err(NavParseError::BadField {
1354            satellite: sv.to_string(),
1355            field: "ionospheric correction",
1356        });
1357    };
1358    Ok([slice[0], slice[1], slice[2]])
1359}
1360
1361/// The leap-second count (GPS − UTC) from the header's `LEAP SECONDS` line, used
1362/// to map a GLONASS (UTC) reference epoch onto the GPST-aligned query time. The
1363/// value is the first field; `None` if the line is absent.
1364pub fn parse_leap_seconds(text: &str) -> Result<Option<f64>, NavParseError> {
1365    parse_leap_seconds_checked(text)
1366}
1367
1368fn parse_leap_seconds_checked(text: &str) -> Result<Option<f64>, NavParseError> {
1369    for line in text.lines() {
1370        if line.contains("END OF HEADER") {
1371            break;
1372        }
1373        if line.contains("LEAP SECONDS") {
1374            return strict_header_integer_f64(line, 0, 6, "leap seconds").map(Some);
1375        }
1376    }
1377    Ok(None)
1378}
1379
1380/// Seconds from the J2000 epoch (2000-01-01 12:00) to a UTC calendar instant,
1381/// via the canonical no-leap civil conversion. Bit-identical to the previous
1382/// day-count arithmetic (the Julian Day Number is offset-equal to the Hinnant
1383/// day count, and the whole-second clock fields sum exactly in `f64`).
1384fn j2000_seconds_utc(y: i64, mo: i64, d: i64, h: i64, mi: i64, s: i64) -> f64 {
1385    civil::j2000_seconds(y as i32, mo as i32, d as i32, h as i32, mi as i32, s as f64)
1386}
1387
1388/// Parse the GLONASS epoch line (`Rnn YYYY MM DD HH MM SS`) to a UTC second past
1389/// J2000.
1390fn parse_glonass_epoch(l0: &str, sat: &str) -> Result<f64, NavParseError> {
1391    let year = strict_record_int::<i64>(l0, 4, 8, "epoch", sat)?;
1392    let month = strict_record_int::<i64>(l0, 9, 11, "epoch", sat)?;
1393    let day = strict_record_int::<i64>(l0, 12, 14, "epoch", sat)?;
1394    let hour = strict_record_int::<i64>(l0, 15, 17, "epoch", sat)?;
1395    let minute = strict_record_int::<i64>(l0, 18, 20, "epoch", sat)?;
1396    let second = strict_record_int::<i64>(l0, 21, 23, "epoch", sat)?;
1397    let civil = validate::civil_datetime_with_second_policy(
1398        year,
1399        month,
1400        day,
1401        hour,
1402        minute,
1403        second as f64,
1404        validate::CivilSecondPolicy::UtcLike,
1405    )
1406    .map_err(|_| NavParseError::BadField {
1407        satellite: sat.to_string(),
1408        field: "epoch",
1409    })?;
1410    Ok(j2000_seconds_utc(
1411        civil.year,
1412        i64::from(civil.month),
1413        i64::from(civil.day),
1414        i64::from(civil.hour),
1415        i64::from(civil.minute),
1416        civil.second as i64,
1417    ))
1418}
1419
1420/// Parse a 4-line RINEX 3 GLONASS record block into a [`GlonassRecord`]
1421/// (km/(km/s)/(km/s^2) state converted to SI). A missing or unparseable field is
1422/// a [`NavParseError`], not a silently dropped record.
1423fn parse_glonass_block(block: &[&str]) -> Result<GlonassRecord, NavParseError> {
1424    let l0 = block[0];
1425    let sat = l0.get(0..3).unwrap_or("").trim().to_string();
1426    if block.len() < 4 {
1427        return Err(NavParseError::TruncatedRecord(sat));
1428    }
1429    let bad = |what: &'static str| NavParseError::BadField {
1430        satellite: sat.clone(),
1431        field: what,
1432    };
1433    let satellite_id: GnssSatelliteId = sat.parse().map_err(|_| bad("prn"))?;
1434    let toe_utc_j2000_s = parse_glonass_epoch(l0, &sat)?;
1435    let clk_bias = parse_f64(l0, 23, 42).ok_or_else(|| bad("clock bias"))?;
1436    let gamma_n = parse_f64(l0, 42, 61).ok_or_else(|| bad("gamma_n"))?;
1437    let o1 = orbit_row(block[1]);
1438    let o2 = orbit_row(block[2]);
1439    let o3 = orbit_row(block[3]);
1440    let km = |v: Option<f64>, what: &'static str| v.map(|x| x * KM_TO_M).ok_or_else(|| bad(what));
1441    let g = |v: Option<f64>, what: &'static str| v.ok_or_else(|| bad(what));
1442    Ok(GlonassRecord {
1443        satellite_id,
1444        toe_utc_j2000_s,
1445        pos_m: [km(o1[0], "x")?, km(o2[0], "y")?, km(o3[0], "z")?],
1446        vel_m_s: [km(o1[1], "vx")?, km(o2[1], "vy")?, km(o3[1], "vz")?],
1447        acc_m_s2: [km(o1[2], "ax")?, km(o2[2], "ay")?, km(o3[2], "az")?],
1448        clk_bias,
1449        gamma_n,
1450        sv_health: g(o1[3], "health")?,
1451        freq_channel: glonass_frequency_channel(g(o2[3], "frequency channel")?, &sat)?,
1452    })
1453}
1454
1455/// Parse all GLONASS (`R`) records from a RINEX 3.x navigation file, in file
1456/// order; selection is the caller's job. A malformed *supported* record is a
1457/// [`NavParseError`] rather than a silently dropped one, but a record for a slot
1458/// the engine cannot represent (an extended GLONASS slot beyond the PRN cap, e.g.
1459/// `R28` in real BKG/IGS products) is skipped rather than rejecting the whole
1460/// file - the same treatment unsupported constellations get in
1461/// [`parse_nav_v3`]. (Version-4 GLONASS frames are not yet parsed.)
1462pub fn parse_glonass(text: &str) -> Result<Vec<GlonassRecord>, NavParseError> {
1463    Ok(parse_glonass_lenient(text)?.records)
1464}
1465
1466/// Like [`parse_glonass`], but also returns the slots that were skipped because
1467/// they are not representable as a [`GnssSatelliteId`] (an extended slot beyond
1468/// the PRN cap, e.g. `R28`).
1469///
1470/// [`parse_glonass`] drops that list silently; use this when a caller needs to
1471/// surface how many / which records were skipped, consistent with the
1472/// lenient-skip reporting elsewhere in the crate. A malformed *representable*
1473/// record is still a [`NavParseError`], not a skip.
1474pub fn parse_glonass_lenient(text: &str) -> Result<GlonassParse, NavParseError> {
1475    let mut lines = text.lines();
1476    verify_and_skip_header(&mut lines)?;
1477    let mut blocks: Vec<Vec<&str>> = Vec::new();
1478    for line in lines {
1479        if is_record_start(line) {
1480            blocks.push(vec![line]);
1481        } else if let Some(last) = blocks.last_mut() {
1482            last.push(line);
1483        }
1484    }
1485    let mut out = GlonassParse::default();
1486    for block in blocks.iter().filter(|b| b[0].starts_with('R')) {
1487        // A GLONASS slot beyond the engine's PRN cap is not representable as a
1488        // `GnssSatelliteId`. Skip such a record (one out-of-range slot must not
1489        // discard every other satellite's ephemeris) instead of erroring, but
1490        // record its identity so it is not lost silently; a representable slot
1491        // with a malformed numeric field still errors.
1492        let sat = block[0].get(0..3).unwrap_or("").trim();
1493        if sat.parse::<GnssSatelliteId>().is_err() {
1494            out.skipped.push(SkippedGlonass {
1495                token: sat.to_string(),
1496            });
1497            continue;
1498        }
1499        out.records.push(parse_glonass_block(block)?);
1500    }
1501    Ok(out)
1502}
1503
1504/// Skip the header, returning the RINEX version. Major versions 3 and 4 share
1505/// the fixed-column orbit layout; version 4 wraps each record in a frame marker
1506/// line (see [`parse_v4_marker`]), which is why `parse_nav` dispatches on it.
1507fn verify_and_skip_header<'a, I>(lines: &mut I) -> Result<RinexVersion, NavParseError>
1508where
1509    I: Iterator<Item = &'a str>,
1510{
1511    let mut version_seen: Option<RinexVersion> = None;
1512    for line in lines.by_ref() {
1513        if line.contains("RINEX VERSION / TYPE") {
1514            // Column 0-8 holds the version; column 20 the file type ('N' = NAV).
1515            let version = line.get(0..9).unwrap_or("").trim();
1516            let detected = parse_rinex_version(version);
1517            let is_nav = line.get(20..21) == Some("N");
1518            match (detected, is_nav) {
1519                (Some(v), true) => version_seen = Some(v),
1520                _ => {
1521                    return Err(NavParseError::UnsupportedHeader(
1522                        line.trim_end().to_string(),
1523                    ))
1524                }
1525            }
1526        }
1527        if line.contains("END OF HEADER") {
1528            return version_seen.ok_or_else(|| {
1529                NavParseError::UnsupportedHeader("no RINEX VERSION / TYPE".to_string())
1530            });
1531        }
1532    }
1533    Err(NavParseError::MissingHeaderEnd)
1534}
1535
1536fn parse_rinex_version(version: &str) -> Option<RinexVersion> {
1537    let (major, minor) = version.split_once('.')?;
1538    let major = major.trim().parse::<u8>().ok()?;
1539    if !matches!(major, 3 | 4) {
1540        return None;
1541    }
1542    let minor_digits = minor
1543        .chars()
1544        .take_while(char::is_ascii_digit)
1545        .collect::<String>();
1546    if minor_digits.is_empty() {
1547        return None;
1548    }
1549    let minor = minor_digits.parse::<u8>().ok()?;
1550    Some(RinexVersion { major, minor })
1551}
1552
1553fn is_record_start(line: &str) -> bool {
1554    let b = line.as_bytes();
1555    b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1].is_ascii_digit() && b[2].is_ascii_digit()
1556}
1557
1558/// The four broadcast-orbit values of a continuation line (columns 4/23/42/61).
1559fn orbit_row(line: &str) -> [Option<f64>; 4] {
1560    [
1561        parse_f64(line, 4, 23),
1562        parse_f64(line, 23, 42),
1563        parse_f64(line, 42, 61),
1564        parse_f64(line, 61, 80),
1565    ]
1566}
1567
1568fn raw_orbit_field(line: &str, field_index: usize) -> &str {
1569    const RANGES: [(usize, usize); 4] = [(4, 23), (23, 42), (42, 61), (61, 80)];
1570    let (start, end) = RANGES[field_index];
1571    raw_field(line, start, end)
1572}
1573
1574#[derive(Debug, Clone, Copy)]
1575struct ClockReferenceEpoch {
1576    week: u32,
1577    sow: f64,
1578}
1579
1580fn parse_keplerian_block(
1581    block: &[&str],
1582    message_override: Option<NavMessage>,
1583    version: RinexVersion,
1584) -> Result<BroadcastRecord, NavParseError> {
1585    let l0 = block.first().copied().unwrap_or("");
1586    let sat = l0.get(0..3).unwrap_or("").trim().to_string();
1587    if block.len() < 8 {
1588        return Err(NavParseError::TruncatedRecord(sat));
1589    }
1590    let bad = |what: &'static str| NavParseError::BadField {
1591        satellite: sat.clone(),
1592        field: what,
1593    };
1594
1595    let letter = l0
1596        .as_bytes()
1597        .first()
1598        .copied()
1599        .map(|b| b as char)
1600        .ok_or_else(|| bad("system"))?;
1601    let system = GnssSystem::from_letter(letter).ok_or_else(|| bad("system"))?;
1602    let satellite_id: GnssSatelliteId = sat.parse().map_err(|_| bad("prn"))?;
1603
1604    // Clock line: epoch (-> toc) and the af0/af1/af2 polynomial.
1605    let time_scale = broadcast_time_scale(system);
1606    let toc_epoch = parse_toc(l0, &sat, time_scale)?;
1607    let toc_sow = toc_epoch.sow;
1608    let af0 = parse_f64(l0, 23, 42).ok_or_else(|| bad("af0"))?;
1609    let af1 = parse_f64(l0, 42, 61).ok_or_else(|| bad("af1"))?;
1610    let af2 = parse_f64(l0, 61, 80).ok_or_else(|| bad("af2"))?;
1611
1612    let o1 = orbit_row(block[1]);
1613    let o2 = orbit_row(block[2]);
1614    let o3 = orbit_row(block[3]);
1615    let o4 = orbit_row(block[4]);
1616    let o5 = orbit_row(block[5]);
1617    let o6 = orbit_row(block[6]);
1618
1619    let g = |v: Option<f64>, what: &'static str| v.ok_or_else(|| bad(what));
1620
1621    let elements = KeplerianElements {
1622        crs: g(o1[1], "crs")?,
1623        delta_n: g(o1[2], "deltaN")?,
1624        m0: g(o1[3], "m0")?,
1625        cuc: g(o2[0], "cuc")?,
1626        e: g(o2[1], "e")?,
1627        cus: g(o2[2], "cus")?,
1628        sqrt_a: g(o2[3], "sqrtA")?,
1629        toe_sow: g(o3[0], "toe")?,
1630        cic: g(o3[1], "cic")?,
1631        omega0: g(o3[2], "omega0")?,
1632        cis: g(o3[3], "cis")?,
1633        i0: g(o4[0], "i0")?,
1634        crc: g(o4[1], "crc")?,
1635        omega: g(o4[2], "omega")?,
1636        omega_dot: g(o4[3], "omegaDot")?,
1637        idot: g(o5[0], "idot")?,
1638    };
1639    let clock = ClockPolynomial {
1640        af0,
1641        af1,
1642        af2,
1643        toc_sow,
1644    };
1645
1646    let week = finite_integral_u32(g(o5[2], "week")?, "week", &sat)?;
1647    let toe = GnssWeekTow::new(time_scale, week, elements.toe_sow)
1648        .and_then(GnssWeekTow::normalized)
1649        .map_err(|_| bad("toe"))?;
1650    let toc = GnssWeekTow::new(time_scale, toc_epoch.week, clock.toc_sow)
1651        .and_then(GnssWeekTow::normalized)
1652        .map_err(|_| bad("toc"))?;
1653    let message = if let Some(message) = message_override {
1654        message
1655    } else {
1656        match system {
1657            GnssSystem::Galileo => galileo_message(g(o5[1], "data sources")?, &sat)?,
1658            GnssSystem::BeiDou => {
1659                if is_beidou_geo(satellite_id) {
1660                    NavMessage::BeidouD2
1661                } else {
1662                    NavMessage::BeidouD1
1663                }
1664            }
1665            _ => NavMessage::GpsLnav,
1666        }
1667    };
1668    let issue_of_data = BroadcastIssue {
1669        issue: finite_integral_u32(g(o1[0], "issue of data")?, "issue of data", &sat)?,
1670        message,
1671    };
1672
1673    let sv_accuracy_m = g(o6[0], "accuracy")?;
1674    let sv_health = g(o6[1], "health")?;
1675    let group_delays = match system {
1676        GnssSystem::Gps => BroadcastGroupDelays::gps_lnav(g(o6[2], "gps tgd")?),
1677        // RINEX Galileo ORBIT-6 carries BGD E5a/E1 in field 3 and BGD E5b/E1 in
1678        // field 4; both are part of the message representation regardless of
1679        // which one a clock consumer later selects.
1680        GnssSystem::Galileo => {
1681            BroadcastGroupDelays::galileo(g(o6[2], "bgd e5a/e1")?, g(o6[3], "bgd e5b/e1")?)
1682        }
1683        GnssSystem::BeiDou => {
1684            BroadcastGroupDelays::beidou(g(o6[2], "beidou tgd1")?, g(o6[3], "beidou tgd2")?)
1685        }
1686        _ => BroadcastGroupDelays::default(),
1687    };
1688
1689    // Only GPS LNAV broadcasts a curve-fit interval (ORBIT-7 field 2); Galileo
1690    // and BeiDou leave that column blank or spare, so they carry no fit interval.
1691    let fit_interval_s = match system {
1692        GnssSystem::Gps => {
1693            Some(gps_fit_interval_s(block[7], version).map_err(|()| bad("fit interval"))?)
1694        }
1695        _ => None,
1696    };
1697
1698    Ok(BroadcastRecord {
1699        satellite_id,
1700        message,
1701        issue_of_data,
1702        week,
1703        toe,
1704        toc,
1705        elements,
1706        clock,
1707        group_delays,
1708        cnav: None,
1709        sv_health,
1710        sv_accuracy_m,
1711        fit_interval_s,
1712    })
1713}
1714
1715fn parse_cnav_block(block: &[&str], message: NavMessage) -> Result<BroadcastRecord, NavParseError> {
1716    let l0 = block.first().copied().unwrap_or("");
1717    let sat = l0.get(0..3).unwrap_or("").trim().to_string();
1718    let is_cnav2 = matches!(message, NavMessage::GpsCnav2 | NavMessage::QzssCnav2);
1719    let required_lines = if is_cnav2 { 10 } else { 9 };
1720    if block.len() < required_lines {
1721        return Err(NavParseError::TruncatedRecord(sat));
1722    }
1723    let bad = |what: &'static str| NavParseError::BadField {
1724        satellite: sat.clone(),
1725        field: what,
1726    };
1727
1728    let letter = l0
1729        .as_bytes()
1730        .first()
1731        .copied()
1732        .map(|b| b as char)
1733        .ok_or_else(|| bad("system"))?;
1734    GnssSystem::from_letter(letter).ok_or_else(|| bad("system"))?;
1735    let satellite_id: GnssSatelliteId = sat.parse().map_err(|_| bad("prn"))?;
1736    let toc_epoch = parse_toc(l0, &sat, TimeScale::Gpst)?;
1737    let af0 = parse_f64(l0, 23, 42).ok_or_else(|| bad("af0"))?;
1738    let af1 = parse_f64(l0, 42, 61).ok_or_else(|| bad("af1"))?;
1739    let af2 = parse_f64(l0, 61, 80).ok_or_else(|| bad("af2"))?;
1740
1741    let o1 = orbit_row(block[1]);
1742    let o2 = orbit_row(block[2]);
1743    let o3 = orbit_row(block[3]);
1744    let o4 = orbit_row(block[4]);
1745    let o5 = orbit_row(block[5]);
1746    let o6 = orbit_row(block[6]);
1747    let o8 = orbit_row(block[8]);
1748    let o9 = if is_cnav2 {
1749        Some(orbit_row(block[9]))
1750    } else {
1751        None
1752    };
1753
1754    let g = |v: Option<f64>, what: &'static str| v.ok_or_else(|| bad(what));
1755    let elements = KeplerianElements {
1756        crs: g(o1[1], "crs")?,
1757        delta_n: g(o1[2], "deltaN0")?,
1758        m0: g(o1[3], "m0")?,
1759        cuc: g(o2[0], "cuc")?,
1760        e: g(o2[1], "e")?,
1761        cus: g(o2[2], "cus")?,
1762        sqrt_a: g(o2[3], "sqrtA0")?,
1763        toe_sow: toc_epoch.sow,
1764        cic: g(o3[1], "cic")?,
1765        omega0: g(o3[2], "omega0")?,
1766        cis: g(o3[3], "cis")?,
1767        i0: g(o4[0], "i0")?,
1768        crc: g(o4[1], "crc")?,
1769        omega: g(o4[2], "omega")?,
1770        omega_dot: g(o4[3], "omegaDot")?,
1771        idot: g(o5[0], "idot")?,
1772    };
1773    let clock = ClockPolynomial {
1774        af0,
1775        af1,
1776        af2,
1777        toc_sow: toc_epoch.sow,
1778    };
1779
1780    let week = toc_epoch.week;
1781    let toe = GnssWeekTow::new(TimeScale::Gpst, week, elements.toe_sow)
1782        .and_then(GnssWeekTow::normalized)
1783        .map_err(|_| bad("toe"))?;
1784    let toc = GnssWeekTow::new(TimeScale::Gpst, week, clock.toc_sow)
1785        .and_then(GnssWeekTow::normalized)
1786        .map_err(|_| bad("toc"))?;
1787    let wn_op = finite_integral_u32(
1788        g(if is_cnav2 { o9.unwrap()[1] } else { o8[1] }, "wn_op")?,
1789        "wn_op",
1790        &sat,
1791    )?;
1792    let top_sow = g(o3[0], "top")?;
1793    let top = GnssWeekTow::new(TimeScale::Gpst, wn_op, top_sow)
1794        .and_then(GnssWeekTow::normalized)
1795        .map_err(|_| bad("top"))?;
1796    let ura_ed_index = finite_integral_i8(g(o6[0], "ura_ed")?, "ura_ed", -16, 15, &sat)?;
1797    let ura_ned0_index = finite_integral_i8(g(o5[2], "ura_ned0")?, "ura_ned0", -16, 15, &sat)?;
1798    let ura_ned1_index = finite_integral_u8(g(o5[3], "ura_ned1")?, "ura_ned1", 0, 7, &sat)?;
1799    let ura_ned2_index = finite_integral_u8(g(o6[3], "ura_ned2")?, "ura_ned2", 0, 7, &sat)?;
1800    let health_max = if is_cnav2 { 1 } else { 7 };
1801    let sv_health = f64::from(finite_integral_u8(
1802        g(o6[1], "health")?,
1803        "health",
1804        0,
1805        health_max,
1806        &sat,
1807    )?);
1808    let transmission_time_sow = g(if is_cnav2 { o9.unwrap()[0] } else { o8[0] }, "t_tm")?;
1809    let flags = optional_integral_u32(
1810        if is_cnav2 {
1811            raw_orbit_field(block[9], 2)
1812        } else {
1813            raw_orbit_field(block[8], 2)
1814        },
1815        "flags",
1816        &sat,
1817    )?;
1818
1819    let tgd = optional_cnav_delay(raw_orbit_field(block[6], 2), "tgd", &sat)?;
1820    let isc_l1ca = optional_cnav_delay(raw_orbit_field(block[7], 0), "isc_l1ca", &sat)?;
1821    let isc_l2c = optional_cnav_delay(raw_orbit_field(block[7], 1), "isc_l2c", &sat)?;
1822    let isc_l5i5 = optional_cnav_delay(raw_orbit_field(block[7], 2), "isc_l5i5", &sat)?;
1823    let isc_l5q5 = optional_cnav_delay(raw_orbit_field(block[7], 3), "isc_l5q5", &sat)?;
1824    let (isc_l1cd, isc_l1cp) = if is_cnav2 {
1825        (
1826            optional_cnav_delay(raw_orbit_field(block[8], 0), "isc_l1cd", &sat)?,
1827            optional_cnav_delay(raw_orbit_field(block[8], 1), "isc_l1cp", &sat)?,
1828        )
1829    } else {
1830        (None, None)
1831    };
1832
1833    let cnav = CnavParameters {
1834        adot_m_s: g(o1[0], "adot")?,
1835        delta_n0_dot_rad_s2: g(o5[1], "deltaN0Dot")?,
1836        top,
1837        ura_ed_index,
1838        ura_ned0_index,
1839        ura_ned1_index,
1840        ura_ned2_index,
1841        transmission_time_sow,
1842        flags,
1843    };
1844    let sv_accuracy_m = cnav_ura_nominal_m(ura_ed_index).unwrap_or(8192.0);
1845    let issue = (elements.toe_sow / 300.0).round() as u32;
1846
1847    Ok(BroadcastRecord {
1848        satellite_id,
1849        message,
1850        issue_of_data: BroadcastIssue { issue, message },
1851        week,
1852        toe,
1853        toc,
1854        elements,
1855        clock,
1856        group_delays: BroadcastGroupDelays::cnav(
1857            tgd, isc_l1ca, isc_l2c, isc_l5i5, isc_l5q5, isc_l1cd, isc_l1cp,
1858        ),
1859        cnav: Some(cnav),
1860        sv_health,
1861        sv_accuracy_m,
1862        fit_interval_s: Some(3.0 * SECONDS_PER_HOUR),
1863    })
1864}
1865
1866/// The GPS curve-fit interval in seconds from the ORBIT-7 fit-interval field.
1867/// RINEX 3.03+ and 4.xx record this field in hours. Legacy RINEX 3.02 and older
1868/// files may carry the broadcast 0/1 fit-interval flag instead, where 1 means
1869/// more than four hours rather than one hour. Per IS-GPS-200 the decoded value
1870/// is the total interval centered on `toe`; a zero or absent field denotes the
1871/// nominal four hours.
1872///
1873/// A blank/absent field is the legitimate nominal case (some products omit it);
1874/// a present but non-numeric field is a malformed record, reported as `Err` so
1875/// the caller can raise the same `BadField` error as for other numeric fields
1876/// rather than silently substituting four hours.
1877fn gps_fit_interval_s(orbit7: &str, version: RinexVersion) -> Result<f64, ()> {
1878    let value = match field(orbit7, 23, 42) {
1879        None => 0.0,
1880        Some(_) => parse_f64(orbit7, 23, 42).ok_or(())?,
1881    };
1882    if value == 0.0 {
1883        Ok(GPS_NOMINAL_FIT_INTERVAL_S)
1884    } else if version.gps_fit_interval_uses_legacy_flag() && value == 1.0 {
1885        Ok(GPS_LEGACY_EXTENDED_FIT_INTERVAL_S)
1886    } else {
1887        Ok(value * SECONDS_PER_HOUR)
1888    }
1889}
1890
1891/// Classify a Galileo record from its data-source word (orbit-5 field 1): source
1892/// bit 1 is F/NAV, source bits 0/2 are I/NAV. Bits 8/9 describe the clock-pair
1893/// frequency and do not determine the navigation message type.
1894fn galileo_message(data_sources: f64, sat: &str) -> Result<NavMessage, NavParseError> {
1895    let word = finite_integral_u32(data_sources, "data sources", sat)?;
1896    if word & 0b010 != 0 {
1897        Ok(NavMessage::GalileoFnav)
1898    } else if word & 0b101 != 0 {
1899        Ok(NavMessage::GalileoInav)
1900    } else {
1901        // No source bit set: default to I/NAV (the operational E1 message).
1902        Ok(NavMessage::GalileoInav)
1903    }
1904}
1905
1906fn finite_integral_u32(value: f64, field: &'static str, sat: &str) -> Result<u32, NavParseError> {
1907    validate::finite(value, field).map_err(|error| map_record_field_error(error, sat))?;
1908    if value < 0.0 || value > f64::from(u32::MAX) || value.trunc() != value {
1909        return Err(NavParseError::BadField {
1910            satellite: sat.to_string(),
1911            field,
1912        });
1913    }
1914    Ok(value as u32)
1915}
1916
1917fn finite_integral_i8(
1918    value: f64,
1919    field: &'static str,
1920    min: i8,
1921    max: i8,
1922    sat: &str,
1923) -> Result<i8, NavParseError> {
1924    validate::finite(value, field).map_err(|error| map_record_field_error(error, sat))?;
1925    if value < f64::from(min) || value > f64::from(max) || value.trunc() != value {
1926        return Err(NavParseError::BadField {
1927            satellite: sat.to_string(),
1928            field,
1929        });
1930    }
1931    Ok(value as i8)
1932}
1933
1934fn finite_integral_u8(
1935    value: f64,
1936    field: &'static str,
1937    min: u8,
1938    max: u8,
1939    sat: &str,
1940) -> Result<u8, NavParseError> {
1941    validate::finite(value, field).map_err(|error| map_record_field_error(error, sat))?;
1942    if value < f64::from(min) || value > f64::from(max) || value.trunc() != value {
1943        return Err(NavParseError::BadField {
1944            satellite: sat.to_string(),
1945            field,
1946        });
1947    }
1948    Ok(value as u8)
1949}
1950
1951fn optional_integral_u32(
1952    raw: &str,
1953    field: &'static str,
1954    sat: &str,
1955) -> Result<Option<u32>, NavParseError> {
1956    if raw.trim().is_empty() {
1957        return Ok(None);
1958    }
1959    let value =
1960        validate::strict_f64(raw, field).map_err(|error| map_record_field_error(error, sat))?;
1961    finite_integral_u32(value, field, sat).map(Some)
1962}
1963
1964fn optional_cnav_delay(
1965    raw: &str,
1966    field: &'static str,
1967    sat: &str,
1968) -> Result<Option<f64>, NavParseError> {
1969    if raw.trim().is_empty() {
1970        return Ok(None);
1971    }
1972    let value =
1973        validate::strict_f64(raw, field).map_err(|error| map_record_field_error(error, sat))?;
1974    if !write::d19_12_representable(value) {
1975        return Err(NavParseError::BadField {
1976            satellite: sat.to_string(),
1977            field,
1978        });
1979    }
1980    let mut rendered = String::new();
1981    write::push_d19_12(&mut rendered, value);
1982    let mut sentinel = String::new();
1983    write::push_d19_12(&mut sentinel, -4096.0 * 2.0_f64.powi(-35));
1984    if rendered == sentinel {
1985        Ok(None)
1986    } else {
1987        Ok(Some(value))
1988    }
1989}
1990
1991fn glonass_frequency_channel(value: f64, sat: &str) -> Result<i32, NavParseError> {
1992    const FIELD: &str = "frequency channel";
1993    validate::finite(value, FIELD).map_err(|error| map_record_field_error(error, sat))?;
1994    let channel = value as i32;
1995    if value.trunc() != value || !valid_glonass_frequency_channel(channel) {
1996        return Err(NavParseError::BadField {
1997            satellite: sat.to_string(),
1998            field: FIELD,
1999        });
2000    }
2001    Ok(channel)
2002}
2003
2004fn strict_header_f64(
2005    line: &str,
2006    start: usize,
2007    end: usize,
2008    field: &'static str,
2009) -> Result<f64, NavParseError> {
2010    validate::strict_f64(raw_field(line, start, end), field).map_err(map_header_field_error)
2011}
2012
2013fn strict_header_integer_f64(
2014    line: &str,
2015    start: usize,
2016    end: usize,
2017    field: &'static str,
2018) -> Result<f64, NavParseError> {
2019    let value = strict_header_f64(line, start, end, field)?;
2020    if value.trunc() != value {
2021        return Err(NavParseError::BadHeaderField { field });
2022    }
2023    Ok(value)
2024}
2025
2026fn strict_record_int<T>(
2027    line: &str,
2028    start: usize,
2029    end: usize,
2030    field: &'static str,
2031    satellite: &str,
2032) -> Result<T, NavParseError>
2033where
2034    T: core::str::FromStr,
2035{
2036    validate::strict_int::<T>(raw_field(line, start, end), field)
2037        .map_err(|error| map_record_field_error(error, satellite))
2038}
2039
2040fn map_record_field_error(error: FieldError, satellite: &str) -> NavParseError {
2041    NavParseError::BadField {
2042        satellite: satellite.to_string(),
2043        field: error.field(),
2044    }
2045}
2046
2047fn map_header_field_error(error: FieldError) -> NavParseError {
2048    NavParseError::BadHeaderField {
2049        field: error.field(),
2050    }
2051}
2052
2053/// Parse the clock reference epoch from the SV/epoch line into week and seconds
2054/// of week in the record's broadcast time scale.
2055fn parse_toc(
2056    l0: &str,
2057    sat: &str,
2058    time_scale: TimeScale,
2059) -> Result<ClockReferenceEpoch, NavParseError> {
2060    let year = strict_record_int::<i64>(l0, 4, 8, "toc epoch", sat)?;
2061    let month = strict_record_int::<i64>(l0, 9, 11, "toc epoch", sat)?;
2062    let day = strict_record_int::<i64>(l0, 12, 14, "toc epoch", sat)?;
2063    let hour = strict_record_int::<i64>(l0, 15, 17, "toc epoch", sat)?;
2064    let minute = strict_record_int::<i64>(l0, 18, 20, "toc epoch", sat)?;
2065    let second = strict_record_int::<i64>(l0, 21, 23, "toc epoch", sat)?;
2066    let civil = validate::civil_datetime_with_second_policy(
2067        year,
2068        month,
2069        day,
2070        hour,
2071        minute,
2072        second as f64,
2073        validate::CivilSecondPolicy::Continuous,
2074    )
2075    .map_err(|_| NavParseError::BadField {
2076        satellite: sat.to_string(),
2077        field: "toc epoch",
2078    })?;
2079    let month = i64::from(civil.month);
2080    let day = i64::from(civil.day);
2081    let week = gnss::week_from_calendar(time_scale, civil.year, month, day).ok_or_else(|| {
2082        NavParseError::BadField {
2083            satellite: sat.to_string(),
2084            field: "toc epoch",
2085        }
2086    })?;
2087    let sow = gnss::seconds_of_week_from_calendar(
2088        civil.year,
2089        month,
2090        day,
2091        i64::from(civil.hour),
2092        i64::from(civil.minute),
2093        civil.second as i64,
2094    );
2095    Ok(ClockReferenceEpoch { week, sow })
2096}
2097
2098#[cfg(all(test, sidereon_repo_tests))]
2099mod tests;