Skip to main content

sidereon_core/rinex_nav/
mod.rs

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