Skip to main content

sidereon_core/rinex_nav/
mod.rs

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