1mod 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
37pub(crate) const MAX_EPHEMERIS_AGE_S: f64 = 4.0 * 3600.0;
44
45pub(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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum NavMessage {
73 GpsLnav,
75 GalileoInav,
77 GalileoFnav,
79 BeidouD1,
81 BeidouD2,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum BroadcastGroupDelayTerm {
88 GpsTgd,
90 GalileoBgdE5aE1,
92 GalileoBgdE5bE1,
94 BeidouTgd1,
96 BeidouTgd2,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Default)]
102pub struct BroadcastGroupDelays {
103 pub gps_tgd_s: Option<f64>,
105 pub galileo_bgd_e5a_e1_s: Option<f64>,
107 pub galileo_bgd_e5b_e1_s: Option<f64>,
109 pub beidou_tgd1_s: Option<f64>,
111 pub beidou_tgd2_s: Option<f64>,
113}
114
115impl BroadcastGroupDelays {
116 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 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 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 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 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
181pub fn is_beidou_geo(sat: GnssSatelliteId) -> bool {
184 sat.system == GnssSystem::BeiDou && (sat.prn <= 5 || (59..=61).contains(&sat.prn))
185}
186
187#[derive(Debug, Clone, Copy, PartialEq)]
191pub struct KlobucharAlphaBeta {
192 pub alpha: [f64; 4],
194 pub beta: [f64; 4],
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Default)]
205pub struct IonoCorrections {
206 pub gps: Option<KlobucharAlphaBeta>,
208 pub beidou: Option<KlobucharAlphaBeta>,
210 pub galileo: Option<GalileoNequickCoeffs>,
212}
213
214#[derive(Debug, Clone, Copy, PartialEq)]
218pub struct GlonassRecord {
219 pub satellite_id: GnssSatelliteId,
221 pub toe_utc_j2000_s: f64,
224 pub pos_m: [f64; 3],
226 pub vel_m_s: [f64; 3],
228 pub acc_m_s2: [f64; 3],
230 pub clk_bias: f64,
232 pub gamma_n: f64,
234 pub sv_health: f64,
236 pub freq_channel: i32,
238}
239
240#[derive(Debug, Clone, Copy, PartialEq)]
242pub struct BroadcastRecord {
243 pub satellite_id: GnssSatelliteId,
245 pub message: NavMessage,
247 pub week: u32,
249 pub toe: GnssWeekTow,
251 pub toc: GnssWeekTow,
253 pub elements: KeplerianElements,
255 pub clock: ClockPolynomial,
257 pub group_delays: BroadcastGroupDelays,
259 pub sv_health: f64,
261 pub sv_accuracy_m: f64,
263 pub fit_interval_s: Option<f64>,
268}
269
270impl BroadcastRecord {
271 pub const fn time_scale(&self) -> TimeScale {
273 self.toe.system
274 }
275
276 pub const fn constants(&self) -> ConstellationConstants {
278 match self.satellite_id.system {
279 GnssSystem::Galileo => ConstellationConstants::GALILEO,
280 GnssSystem::BeiDou => ConstellationConstants::BEIDOU,
281 _ => ConstellationConstants::GPS,
283 }
284 }
285
286 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 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 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 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
403fn 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 _ => 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
437fn 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 Ok(GPS_FIT_INTERVAL_6H_S)
464 } else if (240..=255).contains(&iode) {
465 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
482pub enum LnavRecordError {
483 NotGps(GnssSatelliteId),
485 InvalidEpoch(&'static str),
487 WeekMismatch {
490 full_week: u32,
492 decoded_week: i64,
494 },
495 NoUraPrediction(i64),
497 FitIntervalUnsupported {
500 fit_interval_flag: i64,
502 iode: i64,
504 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#[derive(Debug, Clone, PartialEq, Eq)]
553pub enum NavParseError {
554 UnsupportedHeader(String),
556 MissingHeaderEnd,
558 TruncatedRecord(String),
560 BadField {
562 satellite: String,
564 field: &'static str,
566 },
567 BadHeaderField {
569 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
592pub 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
611fn 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 _ => {}
638 }
639 }
640 Ok(records)
641}
642
643fn 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 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; }
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; }
677 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
702fn is_v4_frame_marker(line: &str) -> bool {
704 line.starts_with("> ")
705}
706
707fn 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
719fn 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
787pub 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 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
945pub 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
964fn 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
976fn 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
983fn 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
1015fn 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
1050pub 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
1072fn 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 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
1126fn 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
1136fn 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 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 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 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
1300fn 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
1325fn 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 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
1413fn 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;