1use std::borrow::Cow;
51use std::collections::BTreeMap;
52
53use crate::astro::time::model::TimeScale;
54
55use crate::format::columns::{raw_field as field, raw_field_from};
56use crate::format::{Diagnostics, RecordRef, Skip, SkipReason};
57use crate::frequencies::{
58 rinex_band_frequency_hz, rinex_observation_frequency_hz, rinex_observation_wavelength_m,
59};
60use crate::id::{GnssSatelliteId, GnssSystem};
61use crate::rinex_common::time_scale_label;
62use crate::rinex_nav::valid_glonass_frequency_channel;
63use crate::validate::{self, FieldError};
64use crate::{Error, Result};
65
66const OBS_FIELD_WIDTH: usize = 16;
68const OBS_VALUE_WIDTH: usize = 14;
70const HEADER_LABELS: &[&str] = &[
71 "RINEX VERSION / TYPE",
72 "PGM / RUN BY / DATE",
73 "COMMENT",
74 "APPROX POSITION XYZ",
75 "ANTENNA: DELTA H/E/N",
76 "SYS / # / OBS TYPES",
77 "# / TYPES OF OBSERV",
78 "SYS / SCALE FACTOR",
79 "SYS / PHASE SHIFT",
80 "TIME OF FIRST OBS",
81 "TIME OF LAST OBS",
82 "INTERVAL",
83 "GLONASS SLOT / FRQ #",
84 "GLONASS COD/PHS/BIS",
85 "SIGNAL STRENGTH UNIT",
86 "LEAP SECONDS",
87 "# OF SATELLITES",
88 "PRN / # OF OBS",
89 "MARKER NAME",
90 "MARKER NUMBER",
91 "MARKER TYPE",
92 "OBSERVER / AGENCY",
93 "REC # / TYPE / VERS",
94 "ANT # / TYPE",
95 "END OF HEADER",
96];
97
98#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
103pub struct ObsEpochTime {
104 pub year: i32,
106 pub month: u8,
108 pub day: u8,
110 pub hour: u8,
112 pub minute: u8,
114 pub second: f64,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq)]
121pub struct ObsValue {
122 pub value: Option<f64>,
125 pub lli: Option<u8>,
127 pub ssi: Option<u8>,
129}
130
131#[derive(Debug, Clone, PartialEq)]
133pub struct ObsPhaseShift {
134 pub system: GnssSystem,
136 pub code: String,
138 pub correction_cycles: f64,
140 pub satellites: Vec<GnssSatelliteId>,
143}
144
145#[derive(Debug, Clone, PartialEq)]
147pub struct ObsScaleFactor {
148 pub system: GnssSystem,
150 pub factor: f64,
152 pub codes: Vec<String>,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct PgmRunByDate {
159 pub program: String,
161 pub run_by: String,
163 pub date: String,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct ReceiverInfo {
170 pub number: String,
172 pub receiver_type: String,
174 pub version: String,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct AntennaInfo {
181 pub number: String,
183 pub antenna_type: String,
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub struct ObsLeapSeconds {
190 pub current: i64,
192 pub delta_future: Option<i64>,
194 pub week: Option<i64>,
196 pub day: Option<i64>,
198}
199
200#[derive(Debug, Clone, PartialEq)]
203pub struct ObsEpoch {
204 pub epoch: ObsEpochTime,
206 pub flag: u8,
208 pub rcv_clock_offset_s: Option<f64>,
210 pub epoch_picoseconds: Option<u32>,
212 pub declared_record_count: usize,
214 pub special_record_count: usize,
216 pub sats: BTreeMap<GnssSatelliteId, Vec<ObsValue>>,
219}
220
221#[derive(Debug, Clone, PartialEq)]
223pub struct ObsHeader {
224 pub version: f64,
226 pub approx_position_m: Option<[f64; 3]>,
229 pub antenna_delta_hen_m: Option<[f64; 3]>,
233 pub obs_codes: BTreeMap<GnssSystem, Vec<String>>,
235 pub program_run_by_date: Option<PgmRunByDate>,
237 pub comments: Vec<String>,
239 pub marker_number: Option<String>,
241 pub marker_type: Option<String>,
243 pub observer: Option<String>,
245 pub agency: Option<String>,
247 pub receiver: Option<ReceiverInfo>,
249 pub antenna: Option<AntennaInfo>,
251 pub interval_s: Option<f64>,
253 pub time_of_first_obs: Option<(ObsEpochTime, TimeScale)>,
255 pub time_of_last_obs: Option<(ObsEpochTime, TimeScale)>,
257 pub n_satellites: Option<usize>,
259 pub prn_obs_counts: BTreeMap<GnssSatelliteId, Vec<Option<usize>>>,
261 pub phase_shifts: Vec<ObsPhaseShift>,
263 pub scale_factors: Vec<ObsScaleFactor>,
265 pub glonass_slots: BTreeMap<u8, i8>,
267 pub glonass_cod_phs_bis: Option<Vec<(String, f64)>>,
269 pub signal_strength_unit: Option<String>,
271 pub leap_seconds: Option<ObsLeapSeconds>,
273 pub marker_name: Option<String>,
275 pub unretained_header_labels: Vec<String>,
277}
278
279#[derive(Debug, Clone, PartialEq)]
285pub struct RinexObs {
286 pub header: ObsHeader,
288 pub epochs: Vec<ObsEpoch>,
291 pub skipped_records: usize,
298}
299
300impl RinexObs {
301 pub fn parse(text: &str) -> Result<Self> {
307 let mut parser = Parser::new();
308 let mut lines = text.lines();
309 parser.parse_header(&mut lines)?;
310 let mut body = lines.peekable();
311 if parser.is_rinex2() {
312 parser.parse_body_v2(&mut body)?;
313 } else {
314 parser.parse_body(&mut body)?;
315 }
316 parser.finish()
317 }
318
319 pub fn header(&self) -> &ObsHeader {
321 &self.header
322 }
323
324 pub fn epochs(&self) -> &[ObsEpoch] {
326 &self.epochs
327 }
328
329 pub fn obs_codes(&self, sys: GnssSystem) -> Option<&[String]> {
331 self.header.obs_codes.get(&sys).map(Vec::as_slice)
332 }
333}
334
335impl core::str::FromStr for RinexObs {
336 type Err = Error;
337
338 fn from_str(s: &str) -> Result<Self> {
339 Self::parse(s)
340 }
341}
342
343#[derive(Debug, Clone, PartialEq)]
350pub struct SignalPolicy {
351 pub codes: BTreeMap<GnssSystem, Vec<String>>,
353}
354
355impl SignalPolicy {
356 pub fn default_for(version: f64) -> Result<Self> {
366 validate_finite_input(version, "version")?;
367 let mut codes = BTreeMap::new();
368 codes.insert(GnssSystem::Gps, vec!["C1C".to_string()]);
369 codes.insert(
370 GnssSystem::Galileo,
371 vec!["C1C".to_string(), "C1X".to_string()],
372 );
373 let beidou = if (3.015..3.025).contains(&version) {
378 vec!["C1I".to_string(), "C2I".to_string()]
379 } else {
380 vec!["C2I".to_string(), "C1I".to_string()]
381 };
382 codes.insert(GnssSystem::BeiDou, beidou);
383 codes.insert(GnssSystem::Glonass, vec!["C1C".to_string()]);
384 Ok(Self { codes })
385 }
386
387 pub fn with_override(mut self, sys: GnssSystem, codes: Vec<String>) -> Self {
389 self.codes.insert(sys, codes);
390 self
391 }
392}
393
394#[derive(Debug, Clone, Default, PartialEq, Eq)]
400pub struct ObservationFilter {
401 pub codes: BTreeMap<GnssSystem, Vec<String>>,
403}
404
405impl ObservationFilter {
406 pub fn all() -> Self {
408 Self::default()
409 }
410
411 pub fn from_entries<I>(entries: I) -> Self
413 where
414 I: IntoIterator<Item = (GnssSystem, Vec<String>)>,
415 {
416 Self {
417 codes: entries.into_iter().collect(),
418 }
419 }
420
421 fn allowed_codes(&self, system: GnssSystem) -> Option<&[String]> {
422 if self.codes.is_empty() {
423 Some(&[])
424 } else {
425 self.codes.get(&system).map(Vec::as_slice)
426 }
427 }
428}
429
430#[derive(Debug, Clone, Copy, PartialEq, Eq)]
432pub enum ObservationKind {
433 Pseudorange,
435 CarrierPhase,
437 Doppler,
439 SignalStrength,
441 Unknown,
443}
444
445impl ObservationKind {
446 pub fn from_code(code: &str) -> Self {
448 match code.as_bytes().first().copied() {
449 Some(b'C') => Self::Pseudorange,
450 Some(b'L') => Self::CarrierPhase,
451 Some(b'D') => Self::Doppler,
452 Some(b'S') => Self::SignalStrength,
453 _ => Self::Unknown,
454 }
455 }
456
457 pub fn as_str(self) -> &'static str {
459 match self {
460 Self::Pseudorange => "pseudorange",
461 Self::CarrierPhase => "carrier_phase",
462 Self::Doppler => "doppler",
463 Self::SignalStrength => "signal_strength",
464 Self::Unknown => "unknown",
465 }
466 }
467
468 pub fn units_str(self) -> &'static str {
470 match self {
471 Self::Pseudorange => "meters",
472 Self::CarrierPhase => "cycles",
473 Self::Doppler => "hz",
474 Self::SignalStrength => "db_hz",
475 Self::Unknown => "unknown",
476 }
477 }
478}
479
480#[derive(Debug, Clone, PartialEq)]
482pub struct ObservationValueRow {
483 pub code: String,
485 pub kind: ObservationKind,
487 pub value: Option<f64>,
489 pub lli: Option<u8>,
491 pub ssi: Option<u8>,
493}
494
495#[derive(Debug, Clone, PartialEq)]
497pub struct CarrierPhaseRow {
498 pub code: String,
500 pub value_cycles: Option<f64>,
502 pub lli: Option<u8>,
504 pub ssi: Option<u8>,
506 pub frequency_hz: Option<f64>,
508 pub wavelength_m: Option<f64>,
510 pub value_m: Option<f64>,
512 pub phase_shift_cycles: f64,
516}
517
518pub fn observation_values(
520 obs: &RinexObs,
521 epoch: &ObsEpoch,
522 filter: &ObservationFilter,
523) -> Result<Vec<(GnssSatelliteId, Vec<ObservationValueRow>)>> {
524 let mut out = Vec::new();
525 for (sat, values) in epoch
526 .sats
527 .iter()
528 .filter(|(sat, _)| filter.allowed_codes(sat.system).is_some())
529 {
530 let allowed_codes = filter
531 .allowed_codes(sat.system)
532 .expect("filter presence checked");
533 let Some(code_list) = obs.header.obs_codes.get(&sat.system) else {
534 continue;
535 };
536 let mut rows = Vec::new();
537 for (code, value) in code_list.iter().zip(values.iter()) {
538 if !allowed_codes.is_empty() && !allowed_codes.iter().any(|c| c == code) {
539 continue;
540 }
541 if let Some(value) = value.value {
542 validate_finite_input(value, "observation.value")?;
543 }
544 let kind = ObservationKind::from_code(code);
545 rows.push(ObservationValueRow {
546 code: code.clone(),
547 kind,
548 value: value.value,
549 lli: value.lli,
550 ssi: value.ssi,
551 });
552 }
553 out.push((*sat, rows));
554 }
555 Ok(out)
556}
557
558pub fn carrier_phase_rows(
560 obs: &RinexObs,
561 epoch: &ObsEpoch,
562 filter: &ObservationFilter,
563) -> Result<Vec<(GnssSatelliteId, Vec<CarrierPhaseRow>)>> {
564 validate_finite_input(obs.header.version, "version")?;
565 let mut out = Vec::new();
566 for (sat, rows) in observation_values(obs, epoch, filter)? {
567 let phases = rows
568 .into_iter()
569 .filter(|row| row.kind == ObservationKind::CarrierPhase)
570 .map(|row| carrier_phase_row(obs, sat, row))
571 .collect::<Result<Vec<_>>>()?;
572 out.push((sat, phases));
573 }
574 Ok(out)
575}
576
577pub fn band_frequency_hz(
582 system: GnssSystem,
583 band: char,
584 glonass_channel: Option<i8>,
585) -> Option<f64> {
586 rinex_band_frequency_hz(system, band, glonass_channel)
587}
588
589pub fn observation_frequency_hz(
591 system: GnssSystem,
592 code: &str,
593 rinex_version: f64,
594 glonass_channel: Option<i8>,
595) -> Result<Option<f64>> {
596 validate_finite_input(rinex_version, "version")?;
597 Ok(rinex_observation_frequency_hz(
598 system,
599 code,
600 rinex_version,
601 glonass_channel,
602 ))
603}
604
605fn carrier_phase_row(
606 obs: &RinexObs,
607 sat: GnssSatelliteId,
608 row: ObservationValueRow,
609) -> Result<CarrierPhaseRow> {
610 let glonass_channel = obs.header.glonass_slots.get(&sat.prn).copied();
611 let frequency_hz =
612 observation_frequency_hz(sat.system, &row.code, obs.header.version, glonass_channel)?;
613 let phase_shift_cycles = phase_shift_cycles(obs, sat, &row.code);
614 let value_cycles = row.value;
615 let wavelength_m =
616 rinex_observation_wavelength_m(sat.system, &row.code, obs.header.version, glonass_channel);
617 let value_m = match value_cycles.zip(wavelength_m) {
618 Some((cycles, lambda)) => {
619 let value_m = cycles * lambda;
620 validate_finite_input(value_m, "carrier_phase.value_m")?;
621 Some(value_m)
622 }
623 None => None,
624 };
625 Ok(CarrierPhaseRow {
626 code: row.code,
627 value_cycles,
628 lli: row.lli,
629 ssi: row.ssi,
630 frequency_hz,
631 wavelength_m,
632 value_m,
633 phase_shift_cycles,
634 })
635}
636
637fn phase_shift_cycles(obs: &RinexObs, sat: GnssSatelliteId, code: &str) -> f64 {
638 let mut system_wide = None;
639 for shift in obs.header.phase_shifts.iter().rev() {
640 if shift.system != sat.system || shift.code != code {
641 continue;
642 }
643 if shift.satellites.is_empty() {
644 if system_wide.is_none() {
645 system_wide = Some(shift.correction_cycles);
646 }
647 } else if shift.satellites.contains(&sat) {
648 return shift.correction_cycles;
649 }
650 }
651 system_wide.unwrap_or(0.0)
652}
653
654pub fn pseudoranges(
661 obs: &RinexObs,
662 epoch: &ObsEpoch,
663 policy: &SignalPolicy,
664) -> Result<Vec<(GnssSatelliteId, f64)>> {
665 let mut out = Vec::new();
666 for (sat, values) in &epoch.sats {
667 let Some(prefs) = policy.codes.get(&sat.system) else {
668 continue;
669 };
670 let Some(code_list) = obs.header.obs_codes.get(&sat.system) else {
671 continue;
672 };
673 for code in prefs {
674 if let Some(idx) = code_list.iter().position(|c| c == code) {
675 if let Some(ObsValue {
676 value: Some(range_m),
677 ..
678 }) = values.get(idx)
679 {
680 validate_finite_input(*range_m, "pseudorange_m")?;
681 out.push((*sat, *range_m));
682 break;
683 }
684 }
685 }
686 }
687 Ok(out)
688}
689
690struct Parser {
692 version: Option<f64>,
693 is_observation: bool,
694 approx_position_m: Option<[f64; 3]>,
695 antenna_delta_hen_m: Option<[f64; 3]>,
696 obs_codes: BTreeMap<GnssSystem, Vec<String>>,
697 interval_s: Option<f64>,
698 time_of_first_obs: Option<(ObsEpochTime, TimeScale)>,
699 time_of_last_obs: Option<(ObsEpochTime, TimeScale)>,
700 program_run_by_date: Option<PgmRunByDate>,
701 comments: Vec<String>,
702 marker_number: Option<String>,
703 marker_type: Option<String>,
704 observer: Option<String>,
705 agency: Option<String>,
706 receiver: Option<ReceiverInfo>,
707 antenna: Option<AntennaInfo>,
708 n_satellites: Option<usize>,
709 prn_obs_counts: BTreeMap<GnssSatelliteId, Vec<Option<usize>>>,
710 prn_obs_counts_current: Option<GnssSatelliteId>,
711 phase_shifts: Vec<ObsPhaseShift>,
712 scale_factors: Vec<ObsScaleFactor>,
713 scale_factor_continuation: Option<ScaleFactorContinuation>,
714 glonass_slots: BTreeMap<u8, i8>,
715 glonass_slots_remaining: Option<usize>,
716 glonass_cod_phs_bis: Option<Vec<(String, f64)>>,
717 signal_strength_unit: Option<String>,
718 leap_seconds: Option<ObsLeapSeconds>,
719 marker_name: Option<String>,
720 unretained_header_labels: Vec<String>,
721 epochs: Vec<ObsEpoch>,
722 current_obs_sys: Option<GnssSystem>,
725 obs_codes_remaining: usize,
727 rinex2_default_system: Option<GnssSystem>,
729 rinex2_obs_codes: Vec<String>,
732 rinex2_obs_codes_remaining: usize,
734 diagnostics: Diagnostics,
739}
740
741#[derive(Debug, Clone, Copy)]
742struct ScaleFactorContinuation {
743 remaining: usize,
744}
745
746impl Parser {
747 fn new() -> Self {
748 Self {
749 version: None,
750 is_observation: false,
751 approx_position_m: None,
752 antenna_delta_hen_m: None,
753 obs_codes: BTreeMap::new(),
754 interval_s: None,
755 time_of_first_obs: None,
756 time_of_last_obs: None,
757 program_run_by_date: None,
758 comments: Vec::new(),
759 marker_number: None,
760 marker_type: None,
761 observer: None,
762 agency: None,
763 receiver: None,
764 antenna: None,
765 n_satellites: None,
766 prn_obs_counts: BTreeMap::new(),
767 prn_obs_counts_current: None,
768 phase_shifts: Vec::new(),
769 scale_factors: Vec::new(),
770 scale_factor_continuation: None,
771 glonass_slots: BTreeMap::new(),
772 glonass_slots_remaining: None,
773 glonass_cod_phs_bis: None,
774 signal_strength_unit: None,
775 leap_seconds: None,
776 marker_name: None,
777 unretained_header_labels: Vec::new(),
778 epochs: Vec::new(),
779 current_obs_sys: None,
780 obs_codes_remaining: 0,
781 rinex2_default_system: None,
782 rinex2_obs_codes: Vec::new(),
783 rinex2_obs_codes_remaining: 0,
784 diagnostics: Diagnostics::new(),
785 }
786 }
787
788 fn is_rinex2(&self) -> bool {
789 self.version
790 .is_some_and(|version| version.floor() as i64 == 2)
791 }
792
793 fn push_unrepresentable_satellite_skip(&mut self, token: &str) {
796 self.diagnostics.push_skip(Skip {
797 at: RecordRef::default().with_satellite(token.trim()),
798 reason: SkipReason::UnrepresentableSatellite,
799 });
800 }
801
802 fn parse_header<'a, I: Iterator<Item = &'a str>>(&mut self, lines: &mut I) -> Result<()> {
803 let mut saw_end = false;
804 for raw in lines.by_ref() {
805 let raw_line = raw.trim_end_matches(['\r', '\n']);
806 let line = normalize_header_line(raw_line);
807 let line = line.as_ref();
808 let label = raw_field_from(line, 60).trim();
809 match label {
810 "RINEX VERSION / TYPE" => self.parse_version(line)?,
811 "PGM / RUN BY / DATE" => self.parse_pgm_run_by_date(line),
812 "COMMENT" => self.comments.push(field(line, 0, 60).trim().to_string()),
813 "APPROX POSITION XYZ" => self.parse_approx_position(line)?,
814 "ANTENNA: DELTA H/E/N" => self.parse_antenna_delta(line)?,
815 "SYS / # / OBS TYPES" => self.parse_obs_types(line)?,
816 "# / TYPES OF OBSERV" => self.parse_obs_types_v2(line)?,
817 "SYS / SCALE FACTOR" => self.parse_scale_factor(line)?,
818 "SYS / PHASE SHIFT" => self.parse_phase_shift(line)?,
819 "TIME OF FIRST OBS" => self.parse_time_of_first_obs(line)?,
820 "TIME OF LAST OBS" => self.parse_time_of_last_obs(line)?,
821 "INTERVAL" => {
822 self.interval_s = Some(strict_f64_field(line, 0, 10, "interval_s")?);
823 }
824 "GLONASS SLOT / FRQ #" => self.parse_glonass_slots(line)?,
825 "GLONASS COD/PHS/BIS" => self.parse_glonass_cod_phs_bis(line)?,
826 "SIGNAL STRENGTH UNIT" => {
827 let unit = field(line, 0, 20).trim();
828 if !unit.is_empty() {
829 self.signal_strength_unit = Some(unit.to_string());
830 }
831 }
832 "LEAP SECONDS" => self.parse_leap_seconds(line)?,
833 "# OF SATELLITES" => {
834 self.n_satellites =
835 Some(strict_int_field::<usize>(line, 0, 6, "n_satellites")?);
836 }
837 "PRN / # OF OBS" => self.parse_prn_obs_counts(line)?,
838 "MARKER NAME" => {
839 let name = field(line, 0, 60).trim();
840 if !name.is_empty() {
841 self.marker_name = Some(name.to_string());
842 }
843 }
844 "MARKER NUMBER" => {
845 self.marker_number = optional_trimmed(line, 0, 20);
846 }
847 "MARKER TYPE" => {
848 self.marker_type = optional_trimmed(line, 0, 20);
849 }
850 "OBSERVER / AGENCY" => {
851 self.observer = optional_trimmed(line, 0, 20);
852 self.agency = optional_trimmed(line, 20, 60);
853 }
854 "REC # / TYPE / VERS" => {
855 self.receiver = Some(ReceiverInfo {
856 number: field(line, 0, 20).trim().to_string(),
857 receiver_type: field(line, 20, 40).trim().to_string(),
858 version: field(line, 40, 60).trim().to_string(),
859 });
860 }
861 "ANT # / TYPE" => {
862 self.antenna = Some(AntennaInfo {
863 number: field(line, 0, 20).trim().to_string(),
864 antenna_type: field(line, 20, 40).trim().to_string(),
865 });
866 }
867 "END OF HEADER" => {
868 self.ensure_obs_type_count_complete(line)?;
869 self.ensure_obs_type_count_complete_v2(line)?;
870 self.ensure_scale_factor_count_complete(line)?;
871 saw_end = true;
872 break;
873 }
874 _ => {
877 if !label.is_empty() {
878 self.unretained_header_labels.push(label.to_string());
879 }
880 }
881 }
882 }
883 if !saw_end {
884 return Err(Error::Parse("RINEX OBS header has no END OF HEADER".into()));
885 }
886 Ok(())
887 }
888
889 fn parse_version(&mut self, line: &str) -> Result<()> {
890 let version_field = field(line, 0, 20).trim();
891 let version = strict_f64_token(version_field, "version", line).or_else(|_| {
892 let token = field(line, 0, 60)
893 .split_whitespace()
894 .next()
895 .ok_or_else(|| Error::Parse(format!("RINEX OBS bad version field in {line:?}")))?;
896 strict_f64_token(token, "version", line)
897 })?;
898 let type_field = field(line, 20, 40);
900 let body = field(line, 0, 60);
901 self.is_observation = type_field.trim_start().starts_with('O')
902 || type_field.contains("OBSERVATION")
903 || body.contains("OBSERVATION")
904 || body.split_whitespace().any(|token| token == "O");
905 if !self.is_observation {
906 return Err(Error::Parse(format!(
907 "RINEX file is not observation data: {type_field:?}"
908 )));
909 }
910 if !matches!(version.floor() as i64, 2..=4) {
911 return Err(Error::Parse(format!(
912 "RINEX OBS parser requires major version 2, 3, or 4, got {version}"
913 )));
914 }
915 if version.floor() as i64 == 2 {
916 let system_field = field(line, 40, 41).trim();
917 if let Some(letter) = system_field.chars().next().filter(|letter| *letter != 'M') {
918 self.rinex2_default_system = GnssSystem::from_letter(letter);
919 }
920 }
921 self.version = Some(version);
922 Ok(())
923 }
924
925 fn parse_approx_position(&mut self, line: &str) -> Result<()> {
926 let body = field(line, 0, 60);
927 self.approx_position_m = Some(strict_vec3_tokens(
928 body,
929 line,
930 [
931 "approx_position.x_m",
932 "approx_position.y_m",
933 "approx_position.z_m",
934 ],
935 )?);
936 Ok(())
937 }
938
939 fn parse_antenna_delta(&mut self, line: &str) -> Result<()> {
940 let body = field(line, 0, 60);
941 self.antenna_delta_hen_m = Some(strict_vec3_tokens(
942 body,
943 line,
944 [
945 "antenna_delta.height_m",
946 "antenna_delta.east_m",
947 "antenna_delta.north_m",
948 ],
949 )?);
950 Ok(())
951 }
952
953 fn parse_pgm_run_by_date(&mut self, line: &str) {
954 self.program_run_by_date = Some(PgmRunByDate {
955 program: field(line, 0, 20).trim().to_string(),
956 run_by: field(line, 20, 40).trim().to_string(),
957 date: field(line, 40, 60).trim().to_string(),
958 });
959 }
960
961 fn parse_obs_types(&mut self, line: &str) -> Result<()> {
962 let sys_field = field(line, 0, 1).trim();
966 if !sys_field.is_empty() {
967 let count = match strict_int_field::<usize>(line, 3, 6, "obs_type_count") {
968 Ok(count) => count,
969 Err(_) => return self.parse_obs_types_whitespace(line),
970 };
971 self.ensure_obs_type_count_complete(line)?;
972 let letter = sys_field.chars().next().unwrap();
973 let system = GnssSystem::from_letter(letter).ok_or_else(|| {
974 Error::Parse(format!("RINEX OBS unknown system letter {letter:?}"))
975 })?;
976 self.current_obs_sys = Some(system);
977 self.obs_codes_remaining = count;
978 self.obs_codes.entry(system).or_default();
979 }
980 let Some(system) = self.current_obs_sys else {
981 return Ok(());
982 };
983 let codes_section = field(line, 7, 60);
986 let list = self.obs_codes.get_mut(&system).expect("system inserted");
987 for tok in codes_section.split_whitespace() {
988 if self.obs_codes_remaining == 0 {
989 return Err(Error::Parse(format!(
990 "RINEX OBS {system} SYS / # / OBS TYPES lists more codes than declared in {line:?}"
991 )));
992 }
993 list.push(tok.to_string());
994 self.obs_codes_remaining -= 1;
995 }
996 Ok(())
997 }
998
999 fn parse_obs_types_v2(&mut self, line: &str) -> Result<()> {
1000 if field(line, 0, 6).trim().is_empty() {
1001 if self.rinex2_obs_codes_remaining == 0 {
1002 return Ok(());
1003 }
1004 } else {
1005 self.ensure_obs_type_count_complete_v2(line)?;
1006 self.rinex2_obs_codes.clear();
1007 self.rinex2_obs_codes_remaining =
1008 strict_int_field::<usize>(line, 0, 6, "rinex2.obs_type_count")?;
1009 }
1010 for code in field(line, 6, 60).split_whitespace() {
1011 if self.rinex2_obs_codes_remaining == 0 {
1012 return Err(Error::Parse(format!(
1013 "RINEX OBS # / TYPES OF OBSERV lists more codes than declared in {line:?}"
1014 )));
1015 }
1016 self.rinex2_obs_codes.push(code.to_string());
1017 self.rinex2_obs_codes_remaining -= 1;
1018 }
1019 Ok(())
1020 }
1021
1022 fn parse_obs_types_whitespace(&mut self, line: &str) -> Result<()> {
1023 let tokens: Vec<&str> = field(line, 0, 60).split_whitespace().collect();
1024 if tokens.is_empty() {
1025 return Err(Error::Parse(format!(
1026 "RINEX OBS malformed SYS / # / OBS TYPES record: {line:?}"
1027 )));
1028 }
1029
1030 if tokens.len() >= 2 && tokens[0].len() == 1 {
1031 if let Ok(count) = strict_int_token::<usize>(tokens[1], "obs_type_count", line) {
1032 let letter = tokens[0]
1033 .chars()
1034 .next()
1035 .ok_or_else(|| Error::Parse("RINEX OBS missing system letter".into()))?;
1036 let system = GnssSystem::from_letter(letter).ok_or_else(|| {
1037 Error::Parse(format!("RINEX OBS unknown system letter {letter:?}"))
1038 })?;
1039 self.ensure_obs_type_count_complete(line)?;
1040 self.current_obs_sys = Some(system);
1041 self.obs_codes_remaining = count;
1042 self.obs_codes.entry(system).or_default();
1043 return self.push_obs_type_tokens(system, &tokens[2..], line);
1044 }
1045 }
1046
1047 let Some(system) = self.current_obs_sys else {
1048 return Err(Error::Parse(format!(
1049 "RINEX OBS malformed SYS / # / OBS TYPES record: {line:?}"
1050 )));
1051 };
1052 if self.obs_codes_remaining == 0 {
1053 return Err(Error::Parse(format!(
1054 "RINEX OBS {system} SYS / # / OBS TYPES lists more codes than declared in {line:?}"
1055 )));
1056 }
1057 self.push_obs_type_tokens(system, &tokens, line)
1058 }
1059
1060 fn push_obs_type_tokens(
1061 &mut self,
1062 system: GnssSystem,
1063 codes: &[&str],
1064 line: &str,
1065 ) -> Result<()> {
1066 let list = self.obs_codes.entry(system).or_default();
1067 for code in codes {
1068 if self.obs_codes_remaining == 0 {
1069 return Err(Error::Parse(format!(
1070 "RINEX OBS {system} SYS / # / OBS TYPES lists more codes than declared in {line:?}"
1071 )));
1072 }
1073 list.push((*code).to_string());
1074 self.obs_codes_remaining -= 1;
1075 }
1076 Ok(())
1077 }
1078
1079 fn ensure_obs_type_count_complete(&self, line: &str) -> Result<()> {
1080 if self.obs_codes_remaining == 0 {
1081 return Ok(());
1082 }
1083 let Some(system) = self.current_obs_sys else {
1084 return Ok(());
1085 };
1086 let supplied = self.obs_codes.get(&system).map_or(0, Vec::len);
1087 let declared = supplied + self.obs_codes_remaining;
1088 Err(Error::Parse(format!(
1089 "RINEX OBS {system} SYS / # / OBS TYPES declares {declared} codes but supplies {supplied} before {line:?}"
1090 )))
1091 }
1092
1093 fn ensure_obs_type_count_complete_v2(&self, line: &str) -> Result<()> {
1094 if self.rinex2_obs_codes_remaining == 0 {
1095 return Ok(());
1096 }
1097 let supplied = self.rinex2_obs_codes.len();
1098 let declared = supplied + self.rinex2_obs_codes_remaining;
1099 Err(Error::Parse(format!(
1100 "RINEX OBS # / TYPES OF OBSERV declares {declared} codes but supplies {supplied} before {line:?}"
1101 )))
1102 }
1103
1104 fn parse_phase_shift(&mut self, line: &str) -> Result<()> {
1105 let tokens: Vec<&str> = field(line, 0, 60).split_whitespace().collect();
1106 if tokens.is_empty() {
1107 return Ok(());
1108 }
1109 if tokens.len() < 2 {
1110 return Err(Error::Parse(format!(
1111 "RINEX OBS phase-shift header has too few fields in {line:?}"
1112 )));
1113 }
1114
1115 let system = tokens[0]
1116 .chars()
1117 .next()
1118 .and_then(GnssSystem::from_letter)
1119 .ok_or_else(|| {
1120 Error::Parse(format!(
1121 "RINEX OBS phase-shift system unparsable in {line:?}"
1122 ))
1123 })?;
1124 let code = tokens[1].to_string();
1125 let correction_cycles = match tokens.get(2) {
1126 Some(token) => strict_f64_token(token, "phase_shift.correction_cycles", line)?,
1127 None => 0.0,
1128 };
1129
1130 let satellites = if let Some(count_token) = tokens.get(3) {
1131 let count =
1132 strict_int_token::<usize>(count_token, "phase_shift.satellite_count", line)?;
1133 let sat_tokens = &tokens[4..];
1134 if sat_tokens.len() != count {
1135 return Err(Error::Parse(format!(
1136 "RINEX OBS phase-shift satellite count mismatch in {line:?}"
1137 )));
1138 }
1139 sat_tokens
1140 .iter()
1141 .map(|token| {
1142 parse_sv_token(token).ok_or_else(|| {
1143 Error::Parse(format!(
1144 "RINEX OBS phase-shift satellite token {token:?} unparsable in {line:?}"
1145 ))
1146 })
1147 })
1148 .collect::<Result<Vec<_>>>()?
1149 } else {
1150 Vec::new()
1151 };
1152
1153 self.phase_shifts.push(ObsPhaseShift {
1154 system,
1155 code,
1156 correction_cycles,
1157 satellites,
1158 });
1159 Ok(())
1160 }
1161
1162 fn parse_scale_factor(&mut self, line: &str) -> Result<()> {
1163 let sys_field = field(line, 0, 1).trim();
1164 if !sys_field.is_empty() {
1165 self.ensure_scale_factor_count_complete(line)?;
1166 let letter = sys_field.chars().next().unwrap();
1167 let system = GnssSystem::from_letter(letter).ok_or_else(|| {
1168 Error::Parse(format!("RINEX OBS unknown scale-factor system {letter:?}"))
1169 })?;
1170 let factor =
1171 scale_factor_value(strict_int_field::<u32>(line, 2, 6, "scale_factor.factor")?)?;
1172 let count_field = field(line, 8, 10).trim();
1173 let count = if count_field.is_empty() {
1174 0
1175 } else {
1176 strict_int_token::<usize>(count_field, "scale_factor.obs_type_count", line)?
1177 };
1178 self.scale_factors.push(ObsScaleFactor {
1179 system,
1180 factor,
1181 codes: Vec::new(),
1182 });
1183 if count == 0 {
1184 return Ok(());
1185 }
1186 self.scale_factor_continuation = Some(ScaleFactorContinuation { remaining: count });
1187 }
1188
1189 self.collect_scale_factor_codes(line)
1190 }
1191
1192 fn collect_scale_factor_codes(&mut self, line: &str) -> Result<()> {
1193 let Some(mut continuation) = self.scale_factor_continuation else {
1194 return Ok(());
1195 };
1196 let record = self
1197 .scale_factors
1198 .last_mut()
1199 .expect("scale factor continuation has a record");
1200 for code in field(line, 10, 60).split_whitespace() {
1201 if continuation.remaining == 0 {
1202 return Err(Error::Parse(format!(
1203 "RINEX OBS SYS / SCALE FACTOR lists more codes than declared in {line:?}"
1204 )));
1205 }
1206 record.codes.push(code.to_string());
1207 continuation.remaining -= 1;
1208 }
1209 self.scale_factor_continuation = (continuation.remaining > 0).then_some(continuation);
1210 Ok(())
1211 }
1212
1213 fn ensure_scale_factor_count_complete(&self, line: &str) -> Result<()> {
1214 let Some(continuation) = self.scale_factor_continuation else {
1215 return Ok(());
1216 };
1217 let supplied = self
1218 .scale_factors
1219 .last()
1220 .map_or(0, |record| record.codes.len());
1221 let declared = supplied + continuation.remaining;
1222 Err(Error::Parse(format!(
1223 "RINEX OBS SYS / SCALE FACTOR declares {declared} codes but supplies {supplied} before {line:?}"
1224 )))
1225 }
1226
1227 fn parse_time_of_first_obs(&mut self, line: &str) -> Result<()> {
1228 self.time_of_first_obs = Some(self.parse_time_header(line, "time_of_first_obs")?);
1229 Ok(())
1230 }
1231
1232 fn parse_time_of_last_obs(&mut self, line: &str) -> Result<()> {
1233 self.time_of_last_obs = Some(self.parse_time_header(line, "time_of_last_obs")?);
1234 Ok(())
1235 }
1236
1237 fn parse_time_header(
1238 &self,
1239 line: &str,
1240 prefix: &'static str,
1241 ) -> Result<(ObsEpochTime, TimeScale)> {
1242 let body = field(line, 0, 43);
1243 let scale_label = field(line, 48, 51).trim();
1244 let scale = time_scale_from_label(scale_label, line)?;
1245 let year = match prefix {
1246 "time_of_last_obs" => "time_of_last_obs.year",
1247 _ => "time_of_first_obs.year",
1248 };
1249 let month = match prefix {
1250 "time_of_last_obs" => "time_of_last_obs.month",
1251 _ => "time_of_first_obs.month",
1252 };
1253 let day = match prefix {
1254 "time_of_last_obs" => "time_of_last_obs.day",
1255 _ => "time_of_first_obs.day",
1256 };
1257 let hour = match prefix {
1258 "time_of_last_obs" => "time_of_last_obs.hour",
1259 _ => "time_of_first_obs.hour",
1260 };
1261 let minute = match prefix {
1262 "time_of_last_obs" => "time_of_last_obs.minute",
1263 _ => "time_of_first_obs.minute",
1264 };
1265 let second = match prefix {
1266 "time_of_last_obs" => "time_of_last_obs.second",
1267 _ => "time_of_first_obs.second",
1268 };
1269 let epoch = parse_epoch_time_tokens(
1270 body,
1271 line,
1272 [year, month, day, hour, minute, second],
1273 civil_second_policy_for_time_scale(scale),
1274 )?;
1275 Ok((epoch, scale))
1276 }
1277
1278 fn parse_glonass_slots(&mut self, line: &str) -> Result<()> {
1279 let count_field = field(line, 0, 3).trim();
1281 if !count_field.is_empty() {
1282 let count = strict_int_token::<usize>(count_field, "glonass_slot.count", line)?;
1283 self.glonass_slots_remaining = Some(count);
1284 }
1285 let body = field(line, 4, 60);
1286 let tokens: Vec<&str> = body.split_whitespace().collect();
1287 if !tokens.len().is_multiple_of(2) {
1288 return Err(Error::Parse(format!(
1289 "RINEX OBS GLONASS slot table has an odd token count in {line:?}"
1290 )));
1291 }
1292 for pair in tokens.chunks_exact(2) {
1293 if let Some(remaining) = self.glonass_slots_remaining.as_mut() {
1297 if *remaining == 0 {
1298 return Err(Error::Parse(format!(
1299 "RINEX OBS GLONASS slot table has more entries than declared in {line:?}"
1300 )));
1301 }
1302 *remaining -= 1;
1303 }
1304 let Some(sat) = parse_sv_token(pair[0]) else {
1310 self.push_unrepresentable_satellite_skip(pair[0]);
1311 continue;
1312 };
1313 if sat.system != GnssSystem::Glonass {
1314 return Err(Error::Parse(format!(
1315 "RINEX OBS GLONASS slot token {:?} is not GLONASS in {line:?}",
1316 pair[0]
1317 )));
1318 }
1319 let channel = strict_int_token::<i8>(pair[1], "glonass_slot.channel", line)?;
1320 if !valid_glonass_frequency_channel(i32::from(channel)) {
1321 return Err(Error::Parse(format!(
1322 "RINEX OBS invalid glonass_slot.channel: {channel} out of range in {line:?}"
1323 )));
1324 }
1325 self.glonass_slots.insert(sat.prn, channel);
1326 }
1327 Ok(())
1328 }
1329
1330 fn parse_glonass_cod_phs_bis(&mut self, line: &str) -> Result<()> {
1331 let tokens: Vec<&str> = field(line, 0, 60).split_whitespace().collect();
1332 let mut entries = Vec::new();
1333 for pair in tokens.chunks(2) {
1334 if pair.len() != 2 {
1335 return Err(Error::Parse(format!(
1336 "RINEX OBS GLONASS COD/PHS/BIS has an odd token count in {line:?}"
1337 )));
1338 }
1339 entries.push((
1340 pair[0].to_string(),
1341 strict_f64_token(pair[1], "glonass_code_phase_bias", line)?,
1342 ));
1343 }
1344 self.glonass_cod_phs_bis = Some(entries);
1345 Ok(())
1346 }
1347
1348 fn parse_leap_seconds(&mut self, line: &str) -> Result<()> {
1349 let current = strict_int_field::<i64>(line, 0, 6, "leap_seconds.current")?;
1350 self.leap_seconds = Some(ObsLeapSeconds {
1351 current,
1352 delta_future: optional_i64_field(line, 6, 12, "leap_seconds.delta_future")?,
1353 week: optional_i64_field(line, 12, 18, "leap_seconds.week")?,
1354 day: optional_i64_field(line, 18, 24, "leap_seconds.day")?,
1355 });
1356 Ok(())
1357 }
1358
1359 fn parse_prn_obs_counts(&mut self, line: &str) -> Result<()> {
1360 let token = field(line, 0, 3).trim();
1361 let sat = if token.is_empty() {
1362 let Some(sat) = self.prn_obs_counts_current else {
1363 return Ok(());
1364 };
1365 sat
1366 } else {
1367 let Some(sat) = parse_sv_token(token) else {
1368 self.prn_obs_counts_current = None;
1369 self.push_unrepresentable_satellite_skip(token);
1370 return Ok(());
1371 };
1372 self.prn_obs_counts_current = Some(sat);
1373 sat
1374 };
1375 let count = self.obs_codes.get(&sat.system).map_or(0, Vec::len);
1376 let already = self.prn_obs_counts.get(&sat).map_or(0, Vec::len);
1377 let remaining = count.saturating_sub(already);
1378 let mut values = Vec::with_capacity(remaining.min(9));
1379 for idx in 0..remaining {
1380 let start = 3 + idx * 6;
1381 if start + 6 > 60 {
1382 break;
1383 }
1384 let raw = field(line, start, start + 6).trim();
1385 if raw.is_empty() {
1386 values.push(None);
1387 } else {
1388 values.push(Some(strict_int_token::<usize>(raw, "prn_obs_count", line)?));
1389 }
1390 }
1391 self.prn_obs_counts.entry(sat).or_default().extend(values);
1392 Ok(())
1393 }
1394
1395 fn parse_body<'a, I: Iterator<Item = &'a str>>(
1396 &mut self,
1397 lines: &mut std::iter::Peekable<I>,
1398 ) -> Result<()> {
1399 while let Some(raw) = lines.next() {
1400 let line = raw.trim_end_matches(['\r', '\n']);
1401 if line.is_empty() {
1402 continue;
1403 }
1404 if !line.starts_with('>') {
1405 continue;
1407 }
1408 let time_scale = self
1409 .time_of_first_obs
1410 .map_or(TimeScale::Gpst, |(_, scale)| scale);
1411 let (epoch_time, flag, numsat, rcv_clock_offset_s, epoch_picoseconds) =
1412 parse_epoch_line(line, civil_second_policy_for_time_scale(time_scale))?;
1413
1414 if flag > 1 {
1415 for _ in 0..numsat {
1419 lines
1420 .next()
1421 .ok_or_else(|| Error::Parse("RINEX OBS event record truncated".into()))?;
1422 }
1423 self.epochs.push(ObsEpoch {
1424 epoch: epoch_time,
1425 flag,
1426 rcv_clock_offset_s,
1427 epoch_picoseconds,
1428 declared_record_count: numsat,
1429 special_record_count: numsat,
1430 sats: BTreeMap::new(),
1431 });
1432 continue;
1433 }
1434
1435 let mut sats = BTreeMap::new();
1436 for _ in 0..numsat {
1437 let sat_line = lines.next().ok_or_else(|| {
1438 Error::Parse("RINEX OBS epoch truncated: missing satellite line".into())
1439 })?;
1440 let sat_line = sat_line.trim_end_matches(['\r', '\n']);
1441 let normalized = ascii_fixed_columns(sat_line);
1448 if !starts_with_sat_designator(&normalized) {
1449 return Err(Error::Parse(
1454 "RINEX OBS epoch truncated: expected satellite record".into(),
1455 ));
1456 }
1457 if parse_sv_token(field(&normalized, 0, 3)).is_none() {
1458 self.push_unrepresentable_satellite_skip(field(&normalized, 0, 3));
1463 consume_skipped_sat_continuations(lines);
1464 continue;
1465 }
1466 let sat_record = self.collect_sat_record(sat_line, lines)?;
1467 let (sat, values) = self.parse_sat_line(&sat_record)?;
1468 sats.insert(sat, values);
1469 }
1470 self.epochs.push(ObsEpoch {
1471 epoch: epoch_time,
1472 flag,
1473 rcv_clock_offset_s,
1474 epoch_picoseconds,
1475 declared_record_count: numsat,
1476 special_record_count: 0,
1477 sats,
1478 });
1479 }
1480 Ok(())
1481 }
1482
1483 fn parse_body_v2<'a, I: Iterator<Item = &'a str>>(
1484 &mut self,
1485 lines: &mut std::iter::Peekable<I>,
1486 ) -> Result<()> {
1487 while let Some(raw) = lines.next() {
1488 let line = raw.trim_end_matches(['\r', '\n']);
1489 if line.is_empty() {
1490 continue;
1491 }
1492 let time_scale = self
1493 .time_of_first_obs
1494 .map_or(TimeScale::Gpst, |(_, scale)| scale);
1495 let (epoch_time, flag, numsat, rcv_clock_offset_s) =
1496 parse_epoch_line_v2(line, civil_second_policy_for_time_scale(time_scale))?;
1497
1498 if flag > 1 {
1499 for _ in 0..numsat {
1500 lines
1501 .next()
1502 .ok_or_else(|| Error::Parse("RINEX OBS event record truncated".into()))?;
1503 }
1504 self.epochs.push(ObsEpoch {
1505 epoch: epoch_time,
1506 flag,
1507 rcv_clock_offset_s,
1508 epoch_picoseconds: None,
1509 declared_record_count: numsat,
1510 special_record_count: numsat,
1511 sats: BTreeMap::new(),
1512 });
1513 continue;
1514 }
1515
1516 let sv_tokens = collect_epoch_sv_tokens_v2(line, numsat, lines)?;
1517 let obs_lines_per_sat = self.rinex2_obs_lines_per_sat()?;
1518 let mut sats = BTreeMap::new();
1519 for token in sv_tokens {
1520 let mut obs_lines = Vec::with_capacity(obs_lines_per_sat);
1521 for _ in 0..obs_lines_per_sat {
1522 let obs_line = lines.next().ok_or_else(|| {
1523 Error::Parse("RINEX OBS epoch truncated: missing observation line".into())
1524 })?;
1525 obs_lines.push(obs_line.trim_end_matches(['\r', '\n']).to_string());
1526 }
1527
1528 let Some(sat) = self.parse_sv_token_v2(&token) else {
1529 self.push_unrepresentable_satellite_skip(&token);
1530 continue;
1531 };
1532 self.ensure_rinex2_system_obs_codes(sat.system);
1533 let values = self.parse_sat_obs_v2(sat.system, &obs_lines)?;
1534 sats.insert(sat, values);
1535 }
1536 self.epochs.push(ObsEpoch {
1537 epoch: epoch_time,
1538 flag,
1539 rcv_clock_offset_s,
1540 epoch_picoseconds: None,
1541 declared_record_count: numsat,
1542 special_record_count: 0,
1543 sats,
1544 });
1545 }
1546 Ok(())
1547 }
1548
1549 fn rinex2_obs_lines_per_sat(&self) -> Result<usize> {
1550 if self.rinex2_obs_codes.is_empty() {
1551 return Err(Error::Parse(
1552 "RINEX OBS header has no # / TYPES OF OBSERV records".into(),
1553 ));
1554 }
1555 Ok(self.rinex2_obs_codes.len().div_ceil(5))
1556 }
1557
1558 fn parse_sv_token_v2(&self, token: &str) -> Option<GnssSatelliteId> {
1559 parse_sv_token_v2(token, self.rinex2_default_system.unwrap_or(GnssSystem::Gps))
1560 }
1561
1562 fn ensure_rinex2_system_obs_codes(&mut self, system: GnssSystem) {
1563 self.obs_codes.entry(system).or_insert_with(|| {
1564 self.rinex2_obs_codes
1565 .iter()
1566 .map(|code| canonical_rinex2_obs_code(system, code))
1567 .collect()
1568 });
1569 }
1570
1571 fn parse_sat_obs_v2(&self, system: GnssSystem, obs_lines: &[String]) -> Result<Vec<ObsValue>> {
1572 let code_list = self.obs_codes.get(&system).ok_or_else(|| {
1573 Error::Parse(format!(
1574 "RINEX OBS satellite system {system} has no canonical observation-code table"
1575 ))
1576 })?;
1577 let mut values = Vec::with_capacity(code_list.len());
1578 for (i, code) in code_list.iter().enumerate() {
1579 let line = obs_lines.get(i / 5).map_or("", String::as_str);
1580 let start = (i % 5) * OBS_FIELD_WIDTH;
1581 let value_str = field(line, start, start + OBS_VALUE_WIDTH).trim();
1582 let value = if value_str.is_empty() {
1583 None
1584 } else {
1585 let scale = self.scale_factor_for(system, code);
1586 let parsed = strict_f64_token(value_str, "observation.value", line)? / scale;
1587 if format!("{:.3}", parsed * scale).len() > OBS_VALUE_WIDTH {
1588 return Err(Error::Parse(
1589 "RINEX OBS observation value exceeds the F14.3 field width".into(),
1590 ));
1591 }
1592 Some(parsed)
1593 };
1594 let lli = digit_at(line, start + OBS_VALUE_WIDTH);
1595 let ssi = digit_at(line, start + OBS_VALUE_WIDTH + 1);
1596 values.push(ObsValue { value, lli, ssi });
1597 }
1598 Ok(values)
1599 }
1600
1601 fn collect_sat_record<'a, I: Iterator<Item = &'a str>>(
1602 &self,
1603 first_line: &str,
1604 lines: &mut std::iter::Peekable<I>,
1605 ) -> Result<String> {
1606 let first_line = ascii_fixed_columns(first_line);
1607 let token = field(&first_line, 0, 3);
1608 let sat = parse_sv_token(token).ok_or_else(|| {
1609 Error::Parse(format!("RINEX OBS unparsable satellite token {token:?}"))
1610 })?;
1611 let n_obs = self.obs_count_for_sat(sat)?;
1612 let mut record = first_line.into_owned();
1613
1614 while sat_record_field_count(record.len()) < n_obs {
1615 let Some(raw_next) = lines.peek().copied() else {
1616 break;
1617 };
1618 let next = raw_next.trim_end_matches(['\r', '\n']);
1619 let next = ascii_fixed_columns(next);
1620 if next.starts_with('>') || starts_with_sat_designator(&next) {
1627 break;
1628 }
1629 let continuation = lines.next().expect("peeked continuation line");
1630 let continuation = ascii_fixed_columns(continuation.trim_end_matches(['\r', '\n']));
1631 append_sat_continuation(&mut record, &continuation, n_obs);
1632 }
1633
1634 Ok(record)
1635 }
1636
1637 fn obs_count_for_sat(&self, sat: GnssSatelliteId) -> Result<usize> {
1638 self.obs_codes
1639 .get(&sat.system)
1640 .map(Vec::len)
1641 .ok_or_else(|| {
1642 Error::Parse(format!(
1643 "RINEX OBS satellite {sat} uses undeclared observation system"
1644 ))
1645 })
1646 }
1647
1648 fn parse_sat_line(&self, line: &str) -> Result<(GnssSatelliteId, Vec<ObsValue>)> {
1649 let token = field(line, 0, 3);
1650 let sat = parse_sv_token(token).ok_or_else(|| {
1651 Error::Parse(format!("RINEX OBS unparsable satellite token {token:?}"))
1652 })?;
1653 let code_list = self.obs_codes.get(&sat.system).ok_or_else(|| {
1654 Error::Parse(format!(
1655 "RINEX OBS satellite {sat} uses undeclared observation system"
1656 ))
1657 })?;
1658 let mut values = Vec::with_capacity(code_list.len());
1659 for (i, code) in code_list.iter().enumerate() {
1660 let start = 3 + i * OBS_FIELD_WIDTH;
1661 let value_str = field(line, start, start + OBS_VALUE_WIDTH).trim();
1662 let value = if value_str.is_empty() {
1663 None
1664 } else {
1665 let scale = self.scale_factor_for(sat.system, code);
1666 let parsed = strict_f64_token(value_str, "observation.value", line)? / scale;
1667 if format!("{:.3}", parsed * scale).len() > OBS_VALUE_WIDTH {
1674 return Err(Error::Parse(
1675 "RINEX OBS observation value exceeds the F14.3 field width".into(),
1676 ));
1677 }
1678 Some(parsed)
1679 };
1680 let lli = digit_at(line, start + OBS_VALUE_WIDTH);
1681 let ssi = digit_at(line, start + OBS_VALUE_WIDTH + 1);
1682 values.push(ObsValue { value, lli, ssi });
1683 }
1684 Ok((sat, values))
1685 }
1686
1687 fn finish(self) -> Result<RinexObs> {
1688 let version = self
1689 .version
1690 .ok_or_else(|| Error::Parse("RINEX OBS missing RINEX VERSION / TYPE".into()))?;
1691 if let Some(remaining) = self.glonass_slots_remaining {
1692 if remaining != 0 {
1693 return Err(Error::Parse(format!(
1694 "RINEX OBS GLONASS slot table missing {remaining} declared entries"
1695 )));
1696 }
1697 }
1698 let mut obs_codes = self.obs_codes;
1699 if obs_codes.is_empty() && !self.rinex2_obs_codes.is_empty() {
1700 let system = self.rinex2_default_system.unwrap_or(GnssSystem::Gps);
1701 obs_codes.insert(
1702 system,
1703 self.rinex2_obs_codes
1704 .iter()
1705 .map(|code| canonical_rinex2_obs_code(system, code))
1706 .collect(),
1707 );
1708 }
1709 if obs_codes.is_empty() {
1710 return Err(Error::Parse(
1711 "RINEX OBS header has no SYS / # / OBS TYPES records".into(),
1712 ));
1713 }
1714 let header = ObsHeader {
1715 version,
1716 approx_position_m: self.approx_position_m,
1717 antenna_delta_hen_m: self.antenna_delta_hen_m,
1718 obs_codes,
1719 program_run_by_date: self.program_run_by_date,
1720 comments: self.comments,
1721 marker_number: self.marker_number,
1722 marker_type: self.marker_type,
1723 observer: self.observer,
1724 agency: self.agency,
1725 receiver: self.receiver,
1726 antenna: self.antenna,
1727 interval_s: self.interval_s,
1728 time_of_first_obs: self.time_of_first_obs,
1729 time_of_last_obs: self.time_of_last_obs,
1730 n_satellites: self.n_satellites,
1731 prn_obs_counts: self.prn_obs_counts,
1732 phase_shifts: self.phase_shifts,
1733 scale_factors: self.scale_factors,
1734 glonass_slots: self.glonass_slots,
1735 glonass_cod_phs_bis: self.glonass_cod_phs_bis,
1736 signal_strength_unit: self.signal_strength_unit,
1737 leap_seconds: self.leap_seconds,
1738 marker_name: self.marker_name,
1739 unretained_header_labels: self.unretained_header_labels,
1740 };
1741 Ok(RinexObs {
1742 header,
1743 epochs: self.epochs,
1744 skipped_records: self.diagnostics.skips.len(),
1745 })
1746 }
1747
1748 fn scale_factor_for(&self, system: GnssSystem, code: &str) -> f64 {
1749 self.scale_factors
1750 .iter()
1751 .rev()
1752 .find(|record| {
1753 record.system == system
1754 && (record.codes.is_empty() || record.codes.iter().any(|c| c == code))
1755 })
1756 .map_or(1.0, |record| record.factor)
1757 }
1758}
1759
1760fn normalize_header_line(line: &str) -> Cow<'_, str> {
1761 let fixed_label = raw_field_from(line, 60).trim();
1762 if HEADER_LABELS.contains(&fixed_label) {
1763 return Cow::Borrowed(line);
1764 }
1765
1766 for &label in HEADER_LABELS {
1767 let Some(index) = line.rfind(label) else {
1768 continue;
1769 };
1770 if !line[index + label.len()..].trim().is_empty() {
1771 continue;
1772 }
1773 let content = line[..index].trim_end();
1774 let content = truncate_header_content(content);
1775 return Cow::Owned(format!("{content:<60}{label}"));
1776 }
1777
1778 Cow::Borrowed(line)
1779}
1780
1781fn truncate_header_content(content: &str) -> Cow<'_, str> {
1782 if content.len() <= 60 {
1783 return Cow::Borrowed(content);
1784 }
1785 let mut end = 60;
1786 while !content.is_char_boundary(end) {
1787 end -= 1;
1788 }
1789 Cow::Owned(content[..end].to_string())
1790}
1791
1792type ParsedEpochLine = (ObsEpochTime, u8, usize, Option<f64>, Option<u32>);
1795
1796fn parse_epoch_line(
1797 line: &str,
1798 second_policy: validate::CivilSecondPolicy,
1799) -> Result<ParsedEpochLine> {
1800 let body = line
1801 .strip_prefix('>')
1802 .ok_or_else(|| Error::Parse(format!("RINEX OBS epoch line lacks '>': {line:?}")))?;
1803 let tokens: Vec<&str> = body.split_whitespace().collect();
1804 if tokens.len() < 8 {
1805 return Err(Error::Parse(format!(
1806 "RINEX OBS epoch line has too few fields in {line:?}"
1807 )));
1808 }
1809 let epoch = parse_epoch_time_tokens(
1810 &tokens[..6].join(" "),
1811 line,
1812 [
1813 "epoch.year",
1814 "epoch.month",
1815 "epoch.day",
1816 "epoch.hour",
1817 "epoch.minute",
1818 "epoch.second",
1819 ],
1820 second_policy,
1821 )?;
1822
1823 let mut index = 6;
1824 let epoch_picoseconds = if tokens
1825 .get(index)
1826 .is_some_and(|token| token.len() == 5 && token.bytes().all(|b| b.is_ascii_digit()))
1827 && tokens.len() >= 9
1828 {
1829 let value = strict_int_token::<u32>(tokens[index], "epoch.picoseconds", line)?;
1830 index += 1;
1831 Some(value)
1832 } else {
1833 None
1834 };
1835 let flag = strict_int_token::<u8>(tokens[index], "epoch.flag", line)?;
1836 index += 1;
1837 let numsat = strict_int_token::<usize>(tokens[index], "epoch.satellite_count", line)?;
1838 index += 1;
1839 let rcv_clock_offset_s = tokens
1840 .get(index)
1841 .map(|token| strict_f64_token(token, "epoch.rcv_clock_offset_s", line))
1842 .transpose()?;
1843 Ok((epoch, flag, numsat, rcv_clock_offset_s, epoch_picoseconds))
1844}
1845
1846type ParsedEpochLineV2 = (ObsEpochTime, u8, usize, Option<f64>);
1847
1848fn parse_epoch_line_v2(
1849 line: &str,
1850 second_policy: validate::CivilSecondPolicy,
1851) -> Result<ParsedEpochLineV2> {
1852 let head = field(line, 0, 32);
1853 let tokens: Vec<&str> = head.split_whitespace().collect();
1854 if tokens.len() < 8 {
1855 return Err(Error::Parse(format!(
1856 "RINEX OBS v2 epoch line has too few fields in {line:?}"
1857 )));
1858 }
1859 let year = strict_int_token::<i32>(tokens[0], "epoch.year", line)?;
1860 let year = expand_rinex2_year(year);
1861 let month = strict_int_token::<i64>(tokens[1], "epoch.month", line)?;
1862 let day = strict_int_token::<i64>(tokens[2], "epoch.day", line)?;
1863 let hour = strict_int_token::<i64>(tokens[3], "epoch.hour", line)?;
1864 let minute = strict_int_token::<i64>(tokens[4], "epoch.minute", line)?;
1865 let second = strict_f64_token(tokens[5], "epoch.second", line)?;
1866 let civil = validate::civil_datetime_with_second_policy(
1867 i64::from(year),
1868 month,
1869 day,
1870 hour,
1871 minute,
1872 second,
1873 second_policy,
1874 )
1875 .map_err(|error| map_field_error(error, line))?;
1876 let flag = strict_int_token::<u8>(tokens[6], "epoch.flag", line)?;
1877 let numsat = strict_int_token::<usize>(tokens[7], "epoch.satellite_count", line)?;
1878 let clock = field(line, 68, line.len()).trim();
1879 let rcv_clock_offset_s = if clock.is_empty() {
1880 None
1881 } else {
1882 Some(strict_f64_token(clock, "epoch.rcv_clock_offset_s", line)?)
1883 };
1884 Ok((
1885 ObsEpochTime {
1886 year,
1887 month: civil.month as u8,
1888 day: civil.day as u8,
1889 hour: civil.hour as u8,
1890 minute: civil.minute as u8,
1891 second: civil.second,
1892 },
1893 flag,
1894 numsat,
1895 rcv_clock_offset_s,
1896 ))
1897}
1898
1899fn expand_rinex2_year(year: i32) -> i32 {
1900 if year >= 100 {
1901 year
1902 } else if year >= 80 {
1903 1900 + year
1904 } else {
1905 2000 + year
1906 }
1907}
1908
1909fn collect_epoch_sv_tokens_v2<'a, I: Iterator<Item = &'a str>>(
1910 first_line: &str,
1911 count: usize,
1912 lines: &mut std::iter::Peekable<I>,
1913) -> Result<Vec<String>> {
1914 let mut tokens = Vec::with_capacity(count);
1915 append_epoch_sv_tokens_v2(first_line, count, &mut tokens);
1916 while tokens.len() < count {
1917 let continuation = lines.next().ok_or_else(|| {
1918 Error::Parse("RINEX OBS v2 epoch truncated: missing satellite-list line".into())
1919 })?;
1920 append_epoch_sv_tokens_v2(
1921 continuation.trim_end_matches(['\r', '\n']),
1922 count,
1923 &mut tokens,
1924 );
1925 }
1926 tokens.truncate(count);
1927 Ok(tokens)
1928}
1929
1930fn append_epoch_sv_tokens_v2(line: &str, count: usize, tokens: &mut Vec<String>) {
1931 let remaining = count.saturating_sub(tokens.len());
1932 for i in 0..remaining.min(12) {
1933 let start = 32 + i * 3;
1934 let token = field(line, start, start + 3);
1935 if token.trim().is_empty() {
1936 break;
1937 }
1938 tokens.push(token.to_string());
1939 }
1940}
1941
1942fn parse_sv_token_v2(token: &str, default_system: GnssSystem) -> Option<GnssSatelliteId> {
1943 let token = token.trim();
1944 if token.is_empty() {
1945 return None;
1946 }
1947 let mut chars = token.chars();
1948 let first = chars.next()?;
1949 let (system, prn_text) = if let Some(system) = GnssSystem::from_letter(first) {
1950 (system, chars.as_str().trim())
1951 } else {
1952 (default_system, token)
1953 };
1954 let prn = prn_text.parse::<u8>().ok()?;
1955 GnssSatelliteId::new(system, prn).ok()
1956}
1957
1958fn canonical_rinex2_obs_code(system: GnssSystem, code: &str) -> String {
1959 let code = code.trim();
1960 if code.len() == 3 {
1961 return code.to_string();
1962 }
1963 let mut chars = code.chars();
1964 let Some(kind) = chars.next() else {
1965 return code.to_string();
1966 };
1967 let Some(band) = chars.next() else {
1968 return code.to_string();
1969 };
1970 if chars.next().is_some() || !matches!(kind, 'C' | 'P' | 'L' | 'D' | 'S') {
1971 return code.to_string();
1972 }
1973
1974 if let Some(mapped) = canonical_rinex2_code_exact(system, kind, band) {
1975 return mapped.to_string();
1976 }
1977
1978 let canonical_kind = if kind == 'P' { 'C' } else { kind };
1979 let attr = rinex2_default_tracking_attr(system, kind, band);
1980 format!("{canonical_kind}{band}{attr}")
1981}
1982
1983fn canonical_rinex2_code_exact(system: GnssSystem, kind: char, band: char) -> Option<&'static str> {
1984 match (system, kind, band) {
1985 (GnssSystem::Gps, 'C', '1') => Some("C1C"),
1986 (GnssSystem::Gps, 'C', '2') => Some("C2C"),
1987 (GnssSystem::Gps, 'P', '1') => Some("C1W"),
1988 (GnssSystem::Gps, 'P', '2') => Some("C2W"),
1989 (GnssSystem::Glonass, 'C', '1') => Some("C1C"),
1990 (GnssSystem::Glonass, 'C', '2') => Some("C2C"),
1991 (GnssSystem::Glonass, 'P', '1') => Some("C1P"),
1992 (GnssSystem::Glonass, 'P', '2') => Some("C2P"),
1993 (GnssSystem::Galileo, 'C', '1') => Some("C1C"),
1994 (GnssSystem::Galileo, 'C', '2') => Some("C5Q"),
1995 (GnssSystem::Galileo, 'P', '1') => Some("C1X"),
1996 (GnssSystem::Galileo, 'P', '2') => Some("C5X"),
1997 (GnssSystem::BeiDou, 'C', '1') => Some("C2I"),
1998 (GnssSystem::BeiDou, 'C', '2') => Some("C7I"),
1999 (GnssSystem::BeiDou, 'P', '1') => Some("C2I"),
2000 (GnssSystem::BeiDou, 'P', '2') => Some("C6I"),
2001 (GnssSystem::Sbas, 'C', '1') => Some("C1C"),
2002 _ => None,
2003 }
2004}
2005
2006fn rinex2_default_tracking_attr(system: GnssSystem, kind: char, band: char) -> char {
2007 match system {
2008 GnssSystem::Gps => match band {
2009 '1' => 'C',
2010 '2' => {
2011 if kind == 'C' {
2012 'C'
2013 } else {
2014 'W'
2015 }
2016 }
2017 '5' => 'X',
2018 _ => 'X',
2019 },
2020 GnssSystem::Glonass => match band {
2021 '1' => 'C',
2022 '2' => 'P',
2023 '3' => 'X',
2024 _ => 'X',
2025 },
2026 GnssSystem::Galileo => match band {
2027 '1' | '6' => 'C',
2028 '5' | '7' | '8' => 'X',
2029 _ => 'X',
2030 },
2031 GnssSystem::BeiDou => match band {
2032 '2' | '6' | '7' => 'I',
2033 '1' => 'P',
2034 '5' | '8' => 'X',
2035 _ => 'X',
2036 },
2037 GnssSystem::Qzss => match band {
2038 '1' => 'C',
2039 '2' => 'L',
2040 '5' | '6' => 'X',
2041 _ => 'X',
2042 },
2043 GnssSystem::Navic => match band {
2044 '5' | '9' => 'A',
2045 _ => 'X',
2046 },
2047 GnssSystem::Sbas => match band {
2048 '1' => 'C',
2049 '5' => 'X',
2050 _ => 'X',
2051 },
2052 }
2053}
2054
2055fn time_scale_from_label(label: &str, line: &str) -> Result<TimeScale> {
2059 let label = label.trim();
2060 if label.is_empty() {
2061 Ok(TimeScale::Gpst)
2062 } else {
2063 time_scale_label(label).ok_or_else(|| {
2064 Error::Parse(format!(
2065 "RINEX OBS TIME OF FIRST OBS unknown time scale {label:?} in {line:?}"
2066 ))
2067 })
2068 }
2069}
2070
2071fn civil_second_policy_for_time_scale(scale: TimeScale) -> validate::CivilSecondPolicy {
2072 match scale {
2073 TimeScale::Utc | TimeScale::Glonasst => validate::CivilSecondPolicy::UtcLike,
2075 TimeScale::Tai
2076 | TimeScale::Tt
2077 | TimeScale::Tcg
2078 | TimeScale::Tdb
2079 | TimeScale::Tcb
2080 | TimeScale::Gpst
2081 | TimeScale::Gst
2082 | TimeScale::Bdt
2083 | TimeScale::Qzsst => validate::CivilSecondPolicy::Continuous,
2084 }
2085}
2086
2087fn parse_epoch_time_tokens(
2088 body: &str,
2089 line: &str,
2090 fields: [&'static str; 6],
2091 second_policy: validate::CivilSecondPolicy,
2092) -> Result<ObsEpochTime> {
2093 let tokens: Vec<&str> = body.split_whitespace().collect();
2094 if tokens.len() < fields.len() {
2095 let field = fields[tokens.len()];
2096 return Err(map_field_error(FieldError::Missing { field }, line));
2097 }
2098 let year = strict_int_token::<i32>(tokens[0], fields[0], line)?;
2099 let month = strict_int_token::<i64>(tokens[1], fields[1], line)?;
2100 let day = strict_int_token::<i64>(tokens[2], fields[2], line)?;
2101 let hour = strict_int_token::<i64>(tokens[3], fields[3], line)?;
2102 let minute = strict_int_token::<i64>(tokens[4], fields[4], line)?;
2103 let second = strict_f64_token(tokens[5], fields[5], line)?;
2104 let civil = validate::civil_datetime_with_second_policy(
2105 year as i64,
2106 month,
2107 day,
2108 hour,
2109 minute,
2110 second,
2111 second_policy,
2112 )
2113 .map_err(|error| map_field_error(error, line))?;
2114 Ok(ObsEpochTime {
2115 year,
2116 month: civil.month as u8,
2117 day: civil.day as u8,
2118 hour: civil.hour as u8,
2119 minute: civil.minute as u8,
2120 second: civil.second,
2121 })
2122}
2123
2124fn strict_vec3_tokens(body: &str, line: &str, fields: [&'static str; 3]) -> Result<[f64; 3]> {
2125 let tokens: Vec<&str> = body.split_whitespace().collect();
2126 if tokens.len() < fields.len() {
2127 let field = fields[tokens.len()];
2128 return Err(map_field_error(FieldError::Missing { field }, line));
2129 }
2130 Ok([
2131 strict_f64_token(tokens[0], fields[0], line)?,
2132 strict_f64_token(tokens[1], fields[1], line)?,
2133 strict_f64_token(tokens[2], fields[2], line)?,
2134 ])
2135}
2136
2137fn strict_f64_field(line: &str, start: usize, end: usize, field_name: &'static str) -> Result<f64> {
2138 strict_f64_token(field(line, start, end), field_name, line)
2139}
2140
2141fn optional_i64_field(
2142 line: &str,
2143 start: usize,
2144 end: usize,
2145 field_name: &'static str,
2146) -> Result<Option<i64>> {
2147 let token = field(line, start, end).trim();
2148 if token.is_empty() {
2149 Ok(None)
2150 } else {
2151 strict_int_token::<i64>(token, field_name, line).map(Some)
2152 }
2153}
2154
2155fn optional_trimmed(line: &str, start: usize, end: usize) -> Option<String> {
2156 let value = field(line, start, end).trim();
2157 (!value.is_empty()).then(|| value.to_string())
2158}
2159
2160fn strict_int_field<T>(line: &str, start: usize, end: usize, field_name: &'static str) -> Result<T>
2161where
2162 T: core::str::FromStr,
2163{
2164 strict_int_token(field(line, start, end), field_name, line)
2165}
2166
2167fn strict_f64_token(token: &str, field_name: &'static str, line: &str) -> Result<f64> {
2168 validate::strict_f64(token, field_name).map_err(|error| map_field_error(error, line))
2169}
2170
2171fn validate_finite_input(value: f64, field: &'static str) -> Result<()> {
2172 if value.is_finite() {
2173 Ok(())
2174 } else {
2175 Err(Error::InvalidInput(format!(
2176 "RINEX OBS {field} must be finite"
2177 )))
2178 }
2179}
2180
2181fn strict_int_token<T>(token: &str, field_name: &'static str, line: &str) -> Result<T>
2182where
2183 T: core::str::FromStr,
2184{
2185 validate::strict_int::<T>(token, field_name).map_err(|error| map_field_error(error, line))
2186}
2187
2188fn scale_factor_value(value: u32) -> Result<f64> {
2189 match value {
2190 1 | 10 | 100 | 1000 => Ok(f64::from(value)),
2191 _ => Err(Error::Parse(format!(
2192 "RINEX OBS invalid scale_factor.factor: expected 1, 10, 100, or 1000, got {value}"
2193 ))),
2194 }
2195}
2196
2197fn map_field_error(error: FieldError, line: &str) -> Error {
2198 Error::Parse(format!(
2199 "RINEX OBS invalid {}: {error} in {line:?}",
2200 error.field()
2201 ))
2202}
2203
2204fn obs_payload_field_count(payload_len: usize) -> usize {
2205 let full = payload_len / OBS_FIELD_WIDTH;
2206 let trailing = payload_len % OBS_FIELD_WIDTH;
2207 full + usize::from(trailing >= OBS_VALUE_WIDTH)
2208}
2209
2210fn sat_record_field_count(record_len: usize) -> usize {
2211 obs_payload_field_count(record_len.saturating_sub(3))
2212}
2213
2214fn ascii_fixed_columns(line: &str) -> Cow<'_, str> {
2215 if line.is_ascii() {
2216 Cow::Borrowed(line)
2217 } else {
2218 Cow::Owned(
2219 line.chars()
2220 .map(|ch| if ch.is_ascii() { ch } else { ' ' })
2221 .collect(),
2222 )
2223 }
2224}
2225
2226fn truncate_to_char_boundary(record: &mut String, len: usize) {
2227 let mut end = len.min(record.len());
2228 while !record.is_char_boundary(end) {
2229 end -= 1;
2230 }
2231 record.truncate(end);
2232}
2233
2234fn starts_with_sat_designator(line: &str) -> bool {
2242 let Some(token) = line.get(0..3) else {
2243 return false;
2244 };
2245 let b = token.as_bytes();
2246 let prn = token[1..].trim();
2247 b[0].is_ascii_alphabetic()
2248 && (1..=2).contains(&prn.len())
2249 && prn.bytes().all(|byte| byte.is_ascii_digit())
2250}
2251
2252fn consume_skipped_sat_continuations<'a, I: Iterator<Item = &'a str>>(
2256 lines: &mut std::iter::Peekable<I>,
2257) {
2258 while let Some(raw_next) = lines.peek().copied() {
2259 let next = ascii_fixed_columns(raw_next.trim_end_matches(['\r', '\n']));
2260 if next.starts_with('>') || starts_with_sat_designator(&next) {
2261 break;
2262 }
2263 lines.next();
2264 }
2265}
2266
2267fn append_sat_continuation(record: &mut String, continuation: &str, n_obs: usize) {
2268 let fields_present = sat_record_field_count(record.len());
2269 let logical_len = 3 + fields_present * OBS_FIELD_WIDTH;
2270 truncate_to_char_boundary(record, logical_len);
2271
2272 let remaining = n_obs.saturating_sub(fields_present);
2273 let payload = field(continuation, 3, continuation.len());
2274 let fields_available = obs_payload_field_count(payload.len());
2275 let fields_to_copy = remaining.min(fields_available);
2276 let width = fields_to_copy * OBS_FIELD_WIDTH;
2277 record.push_str(field(payload, 0, width));
2278}
2279
2280fn parse_sv_token(token: &str) -> Option<GnssSatelliteId> {
2282 token.parse::<GnssSatelliteId>().ok()
2283}
2284
2285fn digit_at(line: &str, col: usize) -> Option<u8> {
2288 line.as_bytes()
2289 .get(col)
2290 .filter(|b| b.is_ascii_digit())
2291 .map(|b| b - b'0')
2292}
2293
2294mod write;
2295
2296#[cfg(all(test, sidereon_repo_tests))]
2297mod tests;