1use std::collections::{BTreeMap, BTreeSet};
8
9use crate::astro::time::model::TimeScale;
10use crate::crinex;
11use crate::id::{GnssSatelliteId, GnssSystem};
12use crate::rinex_common::{dominant_obs_interval_s, obs_epoch_seconds, usable_obs_interval_s};
13use crate::rinex_nav::{
14 parse_iono_corrections, parse_leap_seconds, parse_nav, parse_nav_lenient, BroadcastRecord,
15 IonoCorrections, NavMessage, NavParseError,
16};
17use crate::rinex_obs::{
18 AntennaInfo, ObsEpoch, ObsEpochTime, ObsHeader, PgmRunByDate, ReceiverInfo, RinexObs,
19};
20use crate::Result;
21
22const EARTH_FIXED_RADIUS_MIN_M: f64 = 6_300_000.0;
23const EARTH_FIXED_RADIUS_MAX_M: f64 = 6_400_000.0;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
27pub enum Severity {
28 Fatal,
30 Error,
32 Warning,
34 Info,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Default)]
40pub struct FindingRef {
41 pub epoch_index: Option<usize>,
43 pub satellite: Option<String>,
45 pub field: Option<&'static str>,
47}
48
49impl FindingRef {
50 fn field(field: &'static str) -> Self {
51 Self {
52 field: Some(field),
53 ..Self::default()
54 }
55 }
56
57 fn epoch(epoch_index: usize) -> Self {
58 Self {
59 epoch_index: Some(epoch_index),
60 ..Self::default()
61 }
62 }
63
64 fn sat(epoch_index: usize, sat: GnssSatelliteId) -> Self {
65 Self {
66 epoch_index: Some(epoch_index),
67 satellite: Some(sat.to_string()),
68 ..Self::default()
69 }
70 }
71}
72
73#[derive(Debug, Clone, PartialEq)]
75#[non_exhaustive]
76pub enum Finding {
77 ObsFatalParse { at: FindingRef, message: String },
79 ObsUnpublishedVersion { at: FindingRef, version: f64 },
81 ObsMissingHeader { at: FindingRef, label: &'static str },
83 ObsMissingObsTypes { at: FindingRef },
85 ObsInvalidObsCode {
87 at: FindingRef,
88 system: GnssSystem,
89 code: String,
90 },
91 ObsDuplicateObsCode {
93 at: FindingRef,
94 system: GnssSystem,
95 code: String,
96 },
97 ObsTimeOfFirstMismatch {
99 at: FindingRef,
100 declared: ObsEpochTime,
101 declared_scale: TimeScale,
102 observed: ObsEpochTime,
103 observed_scale: TimeScale,
104 },
105 ObsTimeOfLastMismatch {
109 at: FindingRef,
110 declared: ObsEpochTime,
111 declared_scale: TimeScale,
112 observed: ObsEpochTime,
113 observed_scale: TimeScale,
114 },
115 ObsIntervalMismatch {
117 at: FindingRef,
118 declared_s: f64,
119 observed_s: f64,
120 },
121 ObsSatelliteCountMismatch {
123 at: FindingRef,
124 declared: usize,
125 observed: usize,
126 },
127 ObsPrnObsCountMismatch {
129 at: FindingRef,
130 satellite: GnssSatelliteId,
131 code: String,
132 declared: Option<usize>,
133 observed: usize,
134 },
135 ObsGlonassSlotIssue {
137 at: FindingRef,
138 satellite: GnssSatelliteId,
139 issue: &'static str,
140 },
141 ObsPhaseShiftUndeclaredCode {
143 at: FindingRef,
144 system: GnssSystem,
145 code: String,
146 },
147 ObsScaleFactorIssue {
149 at: FindingRef,
150 system: GnssSystem,
151 code: Option<String>,
152 },
153 ObsMarkerTypeIssue { at: FindingRef, marker_type: String },
155 ObsIdentityFieldIssue {
157 at: FindingRef,
158 label: &'static str,
159 value: String,
160 },
161 ObsImplausibleApproxPosition { at: FindingRef, radius_m: f64 },
163 ObsImplausibleAntennaDelta {
165 at: FindingRef,
166 component: usize,
167 value_m: f64,
168 },
169 ObsEpochOrder {
171 at: FindingRef,
172 previous: ObsEpochTime,
173 current: ObsEpochTime,
174 },
175 ObsDuplicateEpoch { at: FindingRef, epoch: ObsEpochTime },
177 ObsSkippedRecords { at: FindingRef, count: usize },
179 ObsEpochSatCountMismatch {
181 at: FindingRef,
182 declared: usize,
183 retained: usize,
184 },
185 ObsEventSpecialRecords { at: FindingRef, count: usize },
187 ObsUnretainedHeader { at: FindingRef, label: String },
189 ObsPseudorangeOutOfRange {
191 at: FindingRef,
192 code: String,
193 value_m: f64,
194 },
195 ObsLossOfLockOutOfRange {
197 at: FindingRef,
198 code: String,
199 lli: u8,
200 },
201 ObsEventEpoch { at: FindingRef, flag: u8 },
203 ObsEmptySatelliteRecord { at: FindingRef },
205 ObsEpochGap {
207 at: FindingRef,
208 gap_s: f64,
209 interval_s: f64,
210 },
211 NavFatalParse { at: FindingRef, message: String },
213 NavLeapSecondsAbsent { at: FindingRef },
215 NavIonoMalformed { at: FindingRef, message: String },
217 NavDroppedBlock {
219 at: FindingRef,
220 satellite: String,
221 message: String,
222 },
223 NavDuplicateRecord {
225 at: FindingRef,
226 satellite: GnssSatelliteId,
227 same_payload: bool,
228 },
229 NavUnsortedRecords { at: FindingRef },
231 NavImplausibleRecord {
233 at: FindingRef,
234 satellite: GnssSatelliteId,
235 field: &'static str,
236 value: f64,
237 },
238 NavUnhealthyRecords {
240 at: FindingRef,
241 system: GnssSystem,
242 count: usize,
243 },
244 NavOutOfScopeRecords {
246 at: FindingRef,
247 class: String,
248 count: usize,
249 },
250 ObsIntervalUnavailable { at: FindingRef },
252 ObsInvalidInterval { at: FindingRef, declared_s: f64 },
254}
255
256impl Finding {
257 pub const fn code(&self) -> &'static str {
259 match self {
260 Self::ObsFatalParse { .. } => "OBS-H01",
261 Self::ObsUnpublishedVersion { .. } => "OBS-H02",
262 Self::ObsMissingHeader { .. } => "OBS-H03",
263 Self::ObsMissingObsTypes { .. } => "OBS-H04",
264 Self::ObsInvalidObsCode { .. } => "OBS-H05",
265 Self::ObsDuplicateObsCode { .. } => "OBS-H06",
266 Self::ObsTimeOfFirstMismatch { .. } => "OBS-H07",
267 Self::ObsTimeOfLastMismatch { .. } => "OBS-H08",
268 Self::ObsIntervalMismatch { .. } => "OBS-H09",
269 Self::ObsSatelliteCountMismatch { .. } => "OBS-H10",
270 Self::ObsPrnObsCountMismatch { .. } => "OBS-H11",
271 Self::ObsGlonassSlotIssue { .. } => "OBS-H12",
272 Self::ObsPhaseShiftUndeclaredCode { .. } => "OBS-H13",
273 Self::ObsScaleFactorIssue { .. } => "OBS-H14",
274 Self::ObsMarkerTypeIssue { .. } => "OBS-H15",
275 Self::ObsIdentityFieldIssue { .. } => "OBS-H16",
276 Self::ObsImplausibleApproxPosition { .. } => "OBS-H17",
277 Self::ObsImplausibleAntennaDelta { .. } => "OBS-H18",
278 Self::ObsIntervalUnavailable { .. } => "OBS-H19",
279 Self::ObsInvalidInterval { .. } => "OBS-H20",
280 Self::ObsUnretainedHeader { .. } => "OBS-H90",
281 Self::ObsEpochOrder { .. } => "OBS-B01",
282 Self::ObsDuplicateEpoch { .. } => "OBS-B02",
283 Self::ObsEpochSatCountMismatch { .. } => "OBS-B03",
284 Self::ObsSkippedRecords { .. } => "OBS-B04",
285 Self::ObsPseudorangeOutOfRange { .. } => "OBS-B05",
286 Self::ObsLossOfLockOutOfRange { .. } => "OBS-B06",
287 Self::ObsEventEpoch { .. } => "OBS-B07",
288 Self::ObsEmptySatelliteRecord { .. } => "OBS-B08",
289 Self::ObsEpochGap { .. } => "OBS-B09",
290 Self::ObsEventSpecialRecords { .. } => "OBS-B11",
291 Self::NavFatalParse { .. } => "NAV-H01",
292 Self::NavLeapSecondsAbsent { .. } => "NAV-H02",
293 Self::NavIonoMalformed { .. } => "NAV-H03",
294 Self::NavDroppedBlock { .. } => "NAV-B01",
295 Self::NavDuplicateRecord { .. } => "NAV-B02",
296 Self::NavUnsortedRecords { .. } => "NAV-B03",
297 Self::NavImplausibleRecord { .. } => "NAV-B04",
298 Self::NavUnhealthyRecords { .. } => "NAV-B05",
299 Self::NavOutOfScopeRecords { .. } => "NAV-B06",
300 }
301 }
302
303 pub const fn severity(&self) -> Severity {
305 match self {
306 Self::ObsFatalParse { .. }
307 | Self::ObsMissingObsTypes { .. }
308 | Self::NavFatalParse { .. } => Severity::Fatal,
309 Self::ObsUnpublishedVersion { .. }
310 | Self::ObsSkippedRecords { .. }
311 | Self::ObsPseudorangeOutOfRange { .. }
312 | Self::ObsLossOfLockOutOfRange { .. }
313 | Self::ObsIntervalMismatch { .. }
314 | Self::ObsPhaseShiftUndeclaredCode { .. }
315 | Self::ObsMarkerTypeIssue { .. }
316 | Self::ObsIdentityFieldIssue { .. }
317 | Self::ObsImplausibleApproxPosition { .. }
318 | Self::ObsImplausibleAntennaDelta { .. }
319 | Self::ObsEventSpecialRecords { .. }
320 | Self::NavIonoMalformed { .. }
321 | Self::NavImplausibleRecord { .. } => Severity::Warning,
322 Self::ObsEventEpoch { .. }
323 | Self::ObsEmptySatelliteRecord { .. }
324 | Self::ObsEpochGap { .. }
325 | Self::ObsIntervalUnavailable { .. }
326 | Self::ObsUnretainedHeader { .. }
327 | Self::NavLeapSecondsAbsent { .. }
328 | Self::NavUnsortedRecords { .. }
329 | Self::NavUnhealthyRecords { .. }
330 | Self::NavOutOfScopeRecords { .. } => Severity::Info,
331 Self::NavDuplicateRecord { same_payload, .. } => {
332 if *same_payload {
333 Severity::Warning
334 } else {
335 Severity::Error
336 }
337 }
338 _ => Severity::Error,
339 }
340 }
341
342 pub const fn spec_ref(&self) -> &'static str {
344 match self {
345 Self::ObsFatalParse { .. } => "RINEX 3.05/4.02 Table A2",
346 Self::ObsUnpublishedVersion { .. } => "RINEX version history",
347 Self::ObsMissingHeader { .. } => "RINEX 3.05/4.02 Table A2",
348 Self::ObsMissingObsTypes { .. } => "RINEX 3.05/4.02 Table A2",
349 Self::ObsInvalidObsCode { .. } => "RINEX 3.05 Tables 13-20",
350 Self::ObsDuplicateObsCode { .. } => "RINEX 3.05 section 5.2",
351 Self::ObsTimeOfFirstMismatch { .. } => "RINEX 3.05 Table A2",
352 Self::ObsTimeOfLastMismatch { .. } => "RINEX 3.05 Table A2, TIME OF LAST OBS",
353 Self::ObsIntervalMismatch { .. } => "RINEX 3.05 Table A2",
354 Self::ObsSatelliteCountMismatch { .. } => "RINEX 3.05 Table A2, # OF SATELLITES",
355 Self::ObsPrnObsCountMismatch { .. } => "RINEX 3.05 Table A2, PRN / # OF OBS",
356 Self::ObsGlonassSlotIssue { .. } => "RINEX 3.05 Table A2",
357 Self::ObsPhaseShiftUndeclaredCode { .. } => "RINEX 3.05 Table A2",
358 Self::ObsScaleFactorIssue { .. } => "RINEX 3.05 Table A2",
359 Self::ObsMarkerTypeIssue { .. } => "RINEX 3.05 Table 8",
360 Self::ObsIdentityFieldIssue { .. } => "RINEX 3.05 Table A2 identity fields",
361 Self::ObsImplausibleApproxPosition { .. } => "RINEX 3.05 Table A2",
362 Self::ObsImplausibleAntennaDelta { .. } => "RINEX 3.05 Table A2",
363 Self::ObsIntervalUnavailable { .. } => {
364 "RINEX 2.11 section 5.3; RINEX 3.05/4.02 section 6.5 and Table A2, INTERVAL"
365 }
366 Self::ObsInvalidInterval { .. } => {
367 "RINEX 2.11 Table A2; RINEX 3.05/4.02 Table A2, INTERVAL"
368 }
369 Self::ObsUnretainedHeader { .. } => "RINEX 3.05 section 6.6",
370 Self::ObsEpochOrder { .. } => "RINEX 3.05 Table A3",
371 Self::ObsDuplicateEpoch { .. } => "RINEX 3.05 Table A3",
372 Self::ObsEpochSatCountMismatch { .. } => "RINEX 3.05 Table A3, NUM SAT",
373 Self::ObsSkippedRecords { .. } => "parser diagnostic",
374 Self::ObsPseudorangeOutOfRange { .. } => "RINEX QC policy",
375 Self::ObsLossOfLockOutOfRange { .. } => "RINEX 3.05 Table A3 note 1",
376 Self::ObsEventEpoch { .. } => "RINEX 3.05 Table A3",
377 Self::ObsEmptySatelliteRecord { .. } => "RINEX QC policy",
378 Self::ObsEpochGap { .. } => "RINEX QC policy",
379 Self::ObsEventSpecialRecords { .. } => "RINEX 3.05/4.02 Table A3",
380 Self::NavFatalParse { .. } => "RINEX 3.05 Table A5 / RINEX 4.02 Table A7",
381 Self::NavLeapSecondsAbsent { .. } => "RINEX 3.05 Table A5",
382 Self::NavIonoMalformed { .. } => "RINEX 3.05 Table A5",
383 Self::NavDroppedBlock { .. } => "RINEX 3.05/4.02 navigation record layout",
384 Self::NavDuplicateRecord { .. } => "RINEX 3.05 section 6.12",
385 Self::NavUnsortedRecords { .. } => "RINEX QC policy",
386 Self::NavImplausibleRecord { .. } => "RINEX QC policy",
387 Self::NavUnhealthyRecords { .. } => "RINEX 3.05 broadcast record layout",
388 Self::NavOutOfScopeRecords { .. } => "RINEX QC parse-scope disclosure",
389 }
390 }
391
392 pub const fn at(&self) -> &FindingRef {
394 match self {
395 Self::ObsFatalParse { at, .. }
396 | Self::ObsUnpublishedVersion { at, .. }
397 | Self::ObsMissingHeader { at, .. }
398 | Self::ObsMissingObsTypes { at }
399 | Self::ObsInvalidObsCode { at, .. }
400 | Self::ObsDuplicateObsCode { at, .. }
401 | Self::ObsTimeOfFirstMismatch { at, .. }
402 | Self::ObsTimeOfLastMismatch { at, .. }
403 | Self::ObsIntervalMismatch { at, .. }
404 | Self::ObsSatelliteCountMismatch { at, .. }
405 | Self::ObsPrnObsCountMismatch { at, .. }
406 | Self::ObsGlonassSlotIssue { at, .. }
407 | Self::ObsPhaseShiftUndeclaredCode { at, .. }
408 | Self::ObsScaleFactorIssue { at, .. }
409 | Self::ObsMarkerTypeIssue { at, .. }
410 | Self::ObsIdentityFieldIssue { at, .. }
411 | Self::ObsImplausibleApproxPosition { at, .. }
412 | Self::ObsImplausibleAntennaDelta { at, .. }
413 | Self::ObsIntervalUnavailable { at }
414 | Self::ObsInvalidInterval { at, .. }
415 | Self::ObsUnretainedHeader { at, .. }
416 | Self::ObsEpochOrder { at, .. }
417 | Self::ObsDuplicateEpoch { at, .. }
418 | Self::ObsEpochSatCountMismatch { at, .. }
419 | Self::ObsSkippedRecords { at, .. }
420 | Self::ObsPseudorangeOutOfRange { at, .. }
421 | Self::ObsLossOfLockOutOfRange { at, .. }
422 | Self::ObsEventEpoch { at, .. }
423 | Self::ObsEmptySatelliteRecord { at }
424 | Self::ObsEpochGap { at, .. }
425 | Self::ObsEventSpecialRecords { at, .. }
426 | Self::NavFatalParse { at, .. }
427 | Self::NavLeapSecondsAbsent { at }
428 | Self::NavIonoMalformed { at, .. }
429 | Self::NavDroppedBlock { at, .. }
430 | Self::NavDuplicateRecord { at, .. }
431 | Self::NavUnsortedRecords { at }
432 | Self::NavImplausibleRecord { at, .. }
433 | Self::NavUnhealthyRecords { at, .. }
434 | Self::NavOutOfScopeRecords { at, .. } => at,
435 }
436 }
437
438 pub const fn is_repairable(&self) -> bool {
440 matches!(
441 self,
442 Self::ObsTimeOfFirstMismatch { .. }
443 | Self::ObsTimeOfLastMismatch { .. }
444 | Self::ObsIntervalMismatch { .. }
445 | Self::ObsIntervalUnavailable { .. }
446 | Self::ObsInvalidInterval { .. }
447 | Self::ObsSatelliteCountMismatch { .. }
448 | Self::ObsPrnObsCountMismatch { .. }
449 | Self::ObsEpochOrder { .. }
450 | Self::ObsDuplicateEpoch { .. }
451 | Self::ObsEpochSatCountMismatch { .. }
452 | Self::ObsEmptySatelliteRecord { .. }
453 | Self::NavDuplicateRecord {
454 same_payload: true,
455 ..
456 }
457 | Self::NavUnsortedRecords { .. }
458 )
459 }
460}
461
462#[derive(Debug, Clone, PartialEq)]
464pub struct LintReport {
465 pub findings: Vec<Finding>,
467 pub decoded_from_crinex: bool,
469}
470
471impl LintReport {
472 pub fn is_clean(&self) -> bool {
474 self.findings
475 .iter()
476 .all(|f| !matches!(f.severity(), Severity::Fatal | Severity::Error))
477 }
478
479 pub fn count(&self, severity: Severity) -> usize {
481 self.findings
482 .iter()
483 .filter(|finding| finding.severity() == severity)
484 .count()
485 }
486}
487
488#[derive(Debug, Clone, PartialEq)]
490pub struct RepairOptions {
491 pub file_stamp: Option<PgmRunByDate>,
493 pub set_interval: bool,
498 pub set_time_of_last_obs: bool,
500 pub set_obs_counts: bool,
502 pub drop_empty_records: bool,
504 pub sort_records: bool,
506 pub drop_unsupported: bool,
508}
509
510impl Default for RepairOptions {
511 fn default() -> Self {
512 Self {
513 file_stamp: None,
514 set_interval: false,
515 set_time_of_last_obs: false,
516 set_obs_counts: false,
517 drop_empty_records: false,
518 sort_records: true,
519 drop_unsupported: false,
520 }
521 }
522}
523
524#[derive(Debug, Clone, PartialEq, Eq)]
526pub struct RepairAction {
527 pub id: &'static str,
529 pub message: String,
531}
532
533#[derive(Debug, Clone, PartialEq)]
535pub struct ObsRepair {
536 pub repaired: RinexObs,
538 pub actions: Vec<RepairAction>,
540 pub remaining: LintReport,
542 pub decoded_from_crinex: bool,
544}
545
546#[derive(Debug, Clone, PartialEq)]
548pub struct NavRepair {
549 pub records: Vec<BroadcastRecord>,
551 pub iono: Option<IonoCorrections>,
553 pub leap_seconds: Option<f64>,
555 pub actions: Vec<RepairAction>,
557 pub remaining: LintReport,
559}
560
561#[derive(Debug, Clone, Default, PartialEq)]
563pub struct ObsHeaderEdit {
564 marker_name: Option<String>,
565 marker_number: Option<Option<String>>,
566 marker_type: Option<String>,
567 observer: Option<String>,
568 agency: Option<String>,
569 receiver: Option<ReceiverInfo>,
570 antenna: Option<AntennaInfo>,
571 antenna_height_m: Option<f64>,
572 antenna_eccentricity_en_m: Option<(f64, f64)>,
573 approx_position_m: Option<[f64; 3]>,
574}
575
576#[derive(Debug, Clone, PartialEq, Eq)]
578pub struct AppliedEdit {
579 pub field: &'static str,
581 pub old_value: Option<String>,
583 pub new_value: Option<String>,
585 pub warning: Option<String>,
587}
588
589#[derive(Debug, Clone, PartialEq, Eq)]
591pub enum HeaderEditError {
592 InvalidField {
594 field: &'static str,
596 reason: &'static str,
598 },
599}
600
601impl core::fmt::Display for HeaderEditError {
602 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
603 match self {
604 Self::InvalidField { field, reason } => {
605 write!(f, "invalid RINEX OBS header field {field}: {reason}")
606 }
607 }
608 }
609}
610
611impl std::error::Error for HeaderEditError {}
612
613impl ObsHeaderEdit {
614 pub fn new() -> Self {
616 Self::default()
617 }
618
619 pub fn marker_name(mut self, v: &str) -> Self {
621 self.marker_name = Some(v.to_string());
622 self
623 }
624
625 pub fn marker_number(mut self, v: &str) -> Self {
627 self.marker_number = Some(Some(v.to_string()));
628 self
629 }
630
631 pub fn clear_marker_number(mut self) -> Self {
633 self.marker_number = Some(None);
634 self
635 }
636
637 pub fn marker_type(mut self, v: &str) -> Self {
639 self.marker_type = Some(v.to_string());
640 self
641 }
642
643 pub fn observer(mut self, v: &str) -> Self {
645 self.observer = Some(v.to_string());
646 self
647 }
648
649 pub fn agency(mut self, v: &str) -> Self {
651 self.agency = Some(v.to_string());
652 self
653 }
654
655 pub fn receiver(mut self, number: &str, receiver_type: &str, version: &str) -> Self {
657 self.receiver = Some(ReceiverInfo {
658 number: number.to_string(),
659 receiver_type: receiver_type.to_string(),
660 version: version.to_string(),
661 });
662 self
663 }
664
665 pub fn antenna(mut self, number: &str, antenna_type: &str) -> Self {
667 self.antenna = Some(AntennaInfo {
668 number: number.to_string(),
669 antenna_type: antenna_type.to_string(),
670 });
671 self
672 }
673
674 pub fn antenna_height_m(mut self, v: f64) -> Self {
676 self.antenna_height_m = Some(v);
677 self
678 }
679
680 pub fn antenna_eccentricity_en_m(mut self, east: f64, north: f64) -> Self {
682 self.antenna_eccentricity_en_m = Some((east, north));
683 self
684 }
685
686 pub fn approx_position_m(mut self, xyz: [f64; 3]) -> Self {
688 self.approx_position_m = Some(xyz);
689 self
690 }
691
692 pub fn apply(
694 self,
695 header: &mut ObsHeader,
696 ) -> std::result::Result<Vec<AppliedEdit>, HeaderEditError> {
697 self.validate()?;
698 let original = header.clone();
699 let mut edited = header.clone();
700 let mut applied = Vec::new();
701
702 if let Some(value) = self.marker_name {
703 push_edit(
704 &mut applied,
705 "MARKER NAME",
706 edited.marker_name.clone(),
707 Some(value.clone()),
708 None,
709 );
710 edited.marker_name = Some(value);
711 }
712 if let Some(value) = self.marker_number {
713 push_edit(
714 &mut applied,
715 "MARKER NUMBER",
716 edited.marker_number.clone(),
717 value.clone(),
718 None,
719 );
720 edited.marker_number = value;
721 }
722 if let Some(value) = self.marker_type {
723 let warning = (!is_valid_marker_type(&value))
724 .then(|| "not a RINEX Table 8 marker type".to_string());
725 push_edit(
726 &mut applied,
727 "MARKER TYPE",
728 edited.marker_type.clone(),
729 Some(value.clone()),
730 warning,
731 );
732 edited.marker_type = Some(value);
733 }
734 if let Some(value) = self.observer {
735 push_edit(
736 &mut applied,
737 "OBSERVER",
738 edited.observer.clone(),
739 Some(value.clone()),
740 None,
741 );
742 edited.observer = Some(value);
743 }
744 if let Some(value) = self.agency {
745 push_edit(
746 &mut applied,
747 "AGENCY",
748 edited.agency.clone(),
749 Some(value.clone()),
750 None,
751 );
752 edited.agency = Some(value);
753 }
754 if let Some(value) = self.receiver {
755 push_edit(
756 &mut applied,
757 "REC # / TYPE / VERS",
758 edited.receiver.as_ref().map(format_receiver),
759 Some(format_receiver(&value)),
760 None,
761 );
762 edited.receiver = Some(value);
763 }
764 if let Some(value) = self.antenna {
765 push_edit(
766 &mut applied,
767 "ANT # / TYPE",
768 edited.antenna.as_ref().map(format_antenna),
769 Some(format_antenna(&value)),
770 None,
771 );
772 edited.antenna = Some(value);
773 }
774 if let Some(value) = self.approx_position_m {
775 push_edit(
776 &mut applied,
777 "APPROX POSITION XYZ",
778 edited.approx_position_m.map(|v| format!("{v:?}")),
779 Some(format!("{value:?}")),
780 None,
781 );
782 edited.approx_position_m = Some(value);
783 }
784 if self.antenna_height_m.is_some() || self.antenna_eccentricity_en_m.is_some() {
785 let mut delta = edited.antenna_delta_hen_m.unwrap_or([0.0; 3]);
786 if let Some(height) = self.antenna_height_m {
787 delta[0] = height;
788 }
789 if let Some((east, north)) = self.antenna_eccentricity_en_m {
790 delta[1] = east;
791 delta[2] = north;
792 }
793 push_edit(
794 &mut applied,
795 "ANTENNA: DELTA H/E/N",
796 edited.antenna_delta_hen_m.map(|v| format!("{v:?}")),
797 Some(format!("{delta:?}")),
798 None,
799 );
800 edited.antenna_delta_hen_m = Some(delta);
801 }
802
803 if edited == original {
804 return Ok(Vec::new());
805 }
806 *header = edited;
807 Ok(applied)
808 }
809
810 fn validate(&self) -> std::result::Result<(), HeaderEditError> {
811 if let Some(value) = &self.marker_name {
812 validate_text_field("MARKER NAME", value, 60, false)?;
813 }
814 if let Some(Some(value)) = &self.marker_number {
815 validate_text_field("MARKER NUMBER", value, 20, true)?;
816 }
817 if let Some(value) = &self.marker_type {
818 validate_text_field("MARKER TYPE", value, 20, false)?;
819 }
820 if let Some(value) = &self.observer {
821 validate_text_field("OBSERVER", value, 20, false)?;
822 }
823 if let Some(value) = &self.agency {
824 validate_text_field("AGENCY", value, 40, false)?;
825 }
826 if let Some(value) = &self.receiver {
827 validate_text_field("REC #", &value.number, 20, true)?;
828 validate_text_field("REC TYPE", &value.receiver_type, 20, false)?;
829 validate_text_field("REC VERS", &value.version, 20, true)?;
830 }
831 if let Some(value) = &self.antenna {
832 validate_text_field("ANT #", &value.number, 20, true)?;
833 validate_text_field("ANT TYPE", &value.antenna_type, 20, false)?;
834 }
835 if let Some(value) = self.antenna_height_m {
836 validate_antenna_delta("ANTENNA HEIGHT", value)?;
837 }
838 if let Some((east, north)) = self.antenna_eccentricity_en_m {
839 validate_antenna_delta("ANTENNA EAST", east)?;
840 validate_antenna_delta("ANTENNA NORTH", north)?;
841 }
842 if let Some(xyz) = self.approx_position_m {
843 if !xyz.iter().all(|value| value.is_finite()) {
844 return Err(HeaderEditError::InvalidField {
845 field: "APPROX POSITION XYZ",
846 reason: "must be finite",
847 });
848 }
849 let radius = (xyz[0] * xyz[0] + xyz[1] * xyz[1] + xyz[2] * xyz[2]).sqrt();
850 if radius != 0.0
851 && !(EARTH_FIXED_RADIUS_MIN_M..=EARTH_FIXED_RADIUS_MAX_M).contains(&radius)
852 {
853 return Err(HeaderEditError::InvalidField {
854 field: "APPROX POSITION XYZ",
855 reason: "radius outside earth-fixed range",
856 });
857 }
858 }
859 Ok(())
860 }
861}
862
863pub fn lint_obs(obs: &RinexObs) -> LintReport {
865 LintReport {
866 findings: obs_findings(obs),
867 decoded_from_crinex: false,
868 }
869}
870
871pub fn lint_obs_text(text: &str) -> LintReport {
873 let (decoded_from_crinex, text) = match decode_if_crinex(text) {
874 Ok(v) => v,
875 Err(error) => {
876 return LintReport {
877 findings: vec![Finding::ObsFatalParse {
878 at: FindingRef::default(),
879 message: error.to_string(),
880 }],
881 decoded_from_crinex: true,
882 };
883 }
884 };
885 match RinexObs::parse(&text) {
886 Ok(obs) => LintReport {
887 findings: obs_findings(&obs),
888 decoded_from_crinex,
889 },
890 Err(error) => LintReport {
891 findings: vec![classify_obs_parse_error(&error.to_string())],
892 decoded_from_crinex,
893 },
894 }
895}
896
897fn classify_obs_parse_error(message: &str) -> Finding {
898 if message.contains("no SYS / # / OBS TYPES") || message.contains("no # / TYPES OF OBSERV") {
899 Finding::ObsMissingObsTypes {
900 at: FindingRef::field("SYS / # / OBS TYPES"),
901 }
902 } else {
903 Finding::ObsFatalParse {
904 at: FindingRef::default(),
905 message: message.to_string(),
906 }
907 }
908}
909
910pub fn lint_nav_text(text: &str) -> LintReport {
912 let mut findings = Vec::new();
913 match parse_nav_lenient(text) {
914 Ok(parsed) => {
915 findings.extend(nav_findings(&parsed.records));
916 for skipped in parsed.skipped {
917 findings.push(Finding::NavDroppedBlock {
918 at: FindingRef {
919 satellite: Some(skipped.satellite.clone()),
920 ..FindingRef::default()
921 },
922 satellite: skipped.satellite,
923 message: skipped.message,
924 });
925 }
926 }
927 Err(error) => {
928 findings.push(Finding::NavFatalParse {
929 at: FindingRef::default(),
930 message: error.to_string(),
931 });
932 }
933 }
934 for (class, count) in nav_scope_tallies(text) {
935 findings.push(Finding::NavOutOfScopeRecords {
936 at: FindingRef::default(),
937 class,
938 count,
939 });
940 }
941 if matches!(parse_leap_seconds(text), Ok(None)) {
942 findings.push(Finding::NavLeapSecondsAbsent {
943 at: FindingRef::field("LEAP SECONDS"),
944 });
945 }
946 if let Err(error) = parse_iono_corrections(text) {
947 findings.push(Finding::NavIonoMalformed {
948 at: FindingRef::field("IONOSPHERIC CORR"),
949 message: error.to_string(),
950 });
951 }
952 LintReport {
953 findings,
954 decoded_from_crinex: false,
955 }
956}
957
958pub fn repair_obs(obs: &RinexObs, options: &RepairOptions) -> ObsRepair {
960 let mut repaired = obs.clone();
961 let mut actions = Vec::new();
962 repair_obs_order_and_duplicates(&mut repaired, &mut actions);
963 repair_obs_times(&mut repaired, options, &mut actions);
964 repair_obs_counts(&mut repaired, options, &mut actions);
965 repair_obs_file_stamp(&mut repaired, options, &mut actions);
966 repair_obs_unsupported_records(&mut repaired, options, &mut actions);
967 if options.set_interval {
968 repair_obs_interval(&mut repaired, &mut actions);
969 }
970 if options.drop_empty_records {
971 repair_obs_empty_records(&mut repaired, &mut actions);
972 }
973 let remaining = lint_obs(&repaired);
974 ObsRepair {
975 repaired,
976 actions,
977 remaining,
978 decoded_from_crinex: false,
979 }
980}
981
982pub fn repair_obs_text(text: &str, options: &RepairOptions) -> Result<ObsRepair> {
984 let (decoded_from_crinex, text) = decode_if_crinex(text)?;
985 let obs = RinexObs::parse(&text)?;
986 if !options.drop_unsupported && !obs.header.unretained_header_labels.is_empty() {
987 return Err(crate::Error::InvalidInput(
988 "RINEX OBS text repair would drop unretained header records".to_string(),
989 ));
990 }
991 if !options.drop_unsupported
992 && obs
993 .epochs
994 .iter()
995 .any(|epoch| epoch.flag > 1 && epoch.special_record_count > 0)
996 {
997 return Err(crate::Error::InvalidInput(
998 "RINEX OBS text repair would drop event special records".to_string(),
999 ));
1000 }
1001 let mut repaired = repair_obs(&obs, options);
1002 repaired.decoded_from_crinex = decoded_from_crinex;
1003 repaired.remaining.decoded_from_crinex = decoded_from_crinex;
1004 Ok(repaired)
1005}
1006
1007pub fn repair_obs_to_crinex_string(repair: &ObsRepair) -> Result<String> {
1009 crinex::encode_crinex(&repair.repaired.to_rinex_string())
1010}
1011
1012pub fn repair_nav(records: &[BroadcastRecord], options: &RepairOptions) -> NavRepair {
1014 let mut records = records.to_vec();
1015 let mut actions = Vec::new();
1016 repair_nav_duplicates(&mut records, &mut actions);
1017 if options.sort_records {
1018 repair_nav_order(&mut records, &mut actions);
1019 }
1020 let remaining = LintReport {
1021 findings: nav_findings(&records),
1022 decoded_from_crinex: false,
1023 };
1024 NavRepair {
1025 records,
1026 iono: None,
1027 leap_seconds: None,
1028 actions,
1029 remaining,
1030 }
1031}
1032
1033pub fn repair_nav_text(
1035 text: &str,
1036 options: &RepairOptions,
1037) -> std::result::Result<NavRepair, NavParseError> {
1038 let scope_tallies = nav_scope_tallies(text);
1039 if !scope_tallies.is_empty() && !options.drop_unsupported {
1040 return Err(NavParseError::UnsupportedHeader(format!(
1041 "RINEX NAV text repair would drop out-of-scope records: {scope_tallies:?}"
1042 )));
1043 }
1044 let records = parse_nav(text)?;
1045 let mut repair = repair_nav(&records, options);
1046 if !scope_tallies.is_empty() {
1047 for (class, count) in scope_tallies {
1048 repair.actions.push(RepairAction {
1049 id: "NAV-B06",
1050 message: format!("dropped {count} out-of-scope NAV records in {class}"),
1051 });
1052 }
1053 }
1054 repair.iono = parse_iono_corrections(text).ok();
1055 repair.leap_seconds = parse_leap_seconds(text).ok().flatten();
1056 Ok(repair)
1057}
1058
1059fn decode_if_crinex(text: &str) -> Result<(bool, String)> {
1060 let is_crinex = text
1061 .lines()
1062 .next()
1063 .is_some_and(|line| line.get(60..).unwrap_or("").contains("CRINEX VERS"));
1064 if is_crinex {
1065 Ok((true, crinex::decode(text)?))
1066 } else {
1067 Ok((false, text.to_string()))
1068 }
1069}
1070
1071fn obs_findings(obs: &RinexObs) -> Vec<Finding> {
1072 let mut findings = Vec::new();
1073 lint_obs_header(&obs.header, &mut findings);
1074 lint_obs_body(obs, &mut findings);
1075 findings
1076}
1077
1078fn lint_obs_header(header: &ObsHeader, findings: &mut Vec<Finding>) {
1079 if !matches!(published_obs_version(header.version), Some(())) {
1080 findings.push(Finding::ObsUnpublishedVersion {
1081 at: FindingRef::field("RINEX VERSION / TYPE"),
1082 version: header.version,
1083 });
1084 }
1085 if header.marker_name.is_none() {
1086 findings.push(Finding::ObsMissingHeader {
1087 at: FindingRef::field("MARKER NAME"),
1088 label: "MARKER NAME",
1089 });
1090 }
1091 if header.program_run_by_date.is_none() {
1092 findings.push(Finding::ObsMissingHeader {
1093 at: FindingRef::field("PGM / RUN BY / DATE"),
1094 label: "PGM / RUN BY / DATE",
1095 });
1096 }
1097 if header.observer.is_none() || header.agency.is_none() {
1098 findings.push(Finding::ObsMissingHeader {
1099 at: FindingRef::field("OBSERVER / AGENCY"),
1100 label: "OBSERVER / AGENCY",
1101 });
1102 }
1103 if header.receiver.is_none() {
1104 findings.push(Finding::ObsMissingHeader {
1105 at: FindingRef::field("REC # / TYPE / VERS"),
1106 label: "REC # / TYPE / VERS",
1107 });
1108 }
1109 if header.antenna.is_none() {
1110 findings.push(Finding::ObsMissingHeader {
1111 at: FindingRef::field("ANT # / TYPE"),
1112 label: "ANT # / TYPE",
1113 });
1114 }
1115 if header.antenna_delta_hen_m.is_none() {
1116 findings.push(Finding::ObsMissingHeader {
1117 at: FindingRef::field("ANTENNA: DELTA H/E/N"),
1118 label: "ANTENNA: DELTA H/E/N",
1119 });
1120 }
1121 if header.approx_position_m.is_none()
1122 && header
1123 .marker_type
1124 .as_deref()
1125 .is_none_or(is_earth_fixed_marker_type)
1126 {
1127 findings.push(Finding::ObsMissingHeader {
1128 at: FindingRef::field("APPROX POSITION XYZ"),
1129 label: "APPROX POSITION XYZ",
1130 });
1131 }
1132 if header.time_of_first_obs.is_none() {
1133 findings.push(Finding::ObsMissingHeader {
1134 at: FindingRef::field("TIME OF FIRST OBS"),
1135 label: "TIME OF FIRST OBS",
1136 });
1137 }
1138 if header.obs_codes.is_empty() {
1139 findings.push(Finding::ObsMissingObsTypes {
1140 at: FindingRef::field("SYS / # / OBS TYPES"),
1141 });
1142 }
1143 if let Some(declared_s) = header.interval_s {
1144 if declared_s == 0.0 {
1145 findings.push(Finding::ObsIntervalUnavailable {
1146 at: FindingRef::field("INTERVAL"),
1147 });
1148 } else if !usable_obs_interval_s(declared_s) {
1149 findings.push(Finding::ObsInvalidInterval {
1150 at: FindingRef::field("INTERVAL"),
1151 declared_s,
1152 });
1153 }
1154 }
1155 for (&system, codes) in &header.obs_codes {
1156 let mut seen = BTreeSet::new();
1157 for code in codes {
1158 if !is_valid_obs_code(system, code, header.version) {
1159 findings.push(Finding::ObsInvalidObsCode {
1160 at: FindingRef::field("SYS / # / OBS TYPES"),
1161 system,
1162 code: code.clone(),
1163 });
1164 }
1165 if !seen.insert(code.as_str()) {
1166 findings.push(Finding::ObsDuplicateObsCode {
1167 at: FindingRef::field("SYS / # / OBS TYPES"),
1168 system,
1169 code: code.clone(),
1170 });
1171 }
1172 }
1173 }
1174 if let Some(marker_type) = &header.marker_type {
1175 if !is_valid_marker_type(marker_type) {
1176 findings.push(Finding::ObsMarkerTypeIssue {
1177 at: FindingRef::field("MARKER TYPE"),
1178 marker_type: marker_type.clone(),
1179 });
1180 }
1181 }
1182 lint_identity_field(findings, "MARKER NAME", header.marker_name.as_deref(), 60);
1183 lint_identity_field(
1184 findings,
1185 "MARKER NUMBER",
1186 header.marker_number.as_deref(),
1187 20,
1188 );
1189 lint_identity_field(findings, "MARKER TYPE", header.marker_type.as_deref(), 20);
1190 lint_identity_field(findings, "OBSERVER", header.observer.as_deref(), 20);
1191 lint_identity_field(findings, "AGENCY", header.agency.as_deref(), 40);
1192 if let Some(receiver) = &header.receiver {
1193 lint_identity_field(findings, "REC #", Some(&receiver.number), 20);
1194 lint_identity_field(findings, "REC TYPE", Some(&receiver.receiver_type), 20);
1195 lint_identity_field(findings, "REC VERS", Some(&receiver.version), 20);
1196 }
1197 if let Some(antenna) = &header.antenna {
1198 lint_identity_field(findings, "ANT #", Some(&antenna.number), 20);
1199 lint_identity_field(findings, "ANT TYPE", Some(&antenna.antenna_type), 20);
1200 }
1201 for shift in &header.phase_shifts {
1202 if !header
1203 .obs_codes
1204 .get(&shift.system)
1205 .is_some_and(|codes| codes.iter().any(|code| code == &shift.code))
1206 {
1207 findings.push(Finding::ObsPhaseShiftUndeclaredCode {
1208 at: FindingRef::field("SYS / PHASE SHIFT"),
1209 system: shift.system,
1210 code: shift.code.clone(),
1211 });
1212 }
1213 }
1214 for factor in &header.scale_factors {
1215 if !matches!(factor.factor as i64, 1 | 10 | 100 | 1000) {
1216 findings.push(Finding::ObsScaleFactorIssue {
1217 at: FindingRef::field("SYS / SCALE FACTOR"),
1218 system: factor.system,
1219 code: None,
1220 });
1221 }
1222 for code in &factor.codes {
1223 if !header
1224 .obs_codes
1225 .get(&factor.system)
1226 .is_some_and(|codes| codes.iter().any(|declared| declared == code))
1227 {
1228 findings.push(Finding::ObsScaleFactorIssue {
1229 at: FindingRef::field("SYS / SCALE FACTOR"),
1230 system: factor.system,
1231 code: Some(code.clone()),
1232 });
1233 }
1234 }
1235 }
1236 if let Some(pos) = header.approx_position_m {
1237 let radius = (pos[0] * pos[0] + pos[1] * pos[1] + pos[2] * pos[2]).sqrt();
1238 if radius != 0.0 && !(EARTH_FIXED_RADIUS_MIN_M..=EARTH_FIXED_RADIUS_MAX_M).contains(&radius)
1239 {
1240 findings.push(Finding::ObsImplausibleApproxPosition {
1241 at: FindingRef::field("APPROX POSITION XYZ"),
1242 radius_m: radius,
1243 });
1244 }
1245 }
1246 if let Some(delta) = header.antenna_delta_hen_m {
1247 for (idx, value) in delta.into_iter().enumerate() {
1248 if value.abs() > 100.0 {
1249 findings.push(Finding::ObsImplausibleAntennaDelta {
1250 at: FindingRef::field("ANTENNA: DELTA H/E/N"),
1251 component: idx,
1252 value_m: value,
1253 });
1254 }
1255 }
1256 }
1257 for label in &header.unretained_header_labels {
1258 findings.push(Finding::ObsUnretainedHeader {
1259 at: FindingRef::field("header"),
1260 label: label.clone(),
1261 });
1262 }
1263}
1264
1265fn lint_obs_body(obs: &RinexObs, findings: &mut Vec<Finding>) {
1266 if obs.skipped_records > 0 {
1267 findings.push(Finding::ObsSkippedRecords {
1268 at: FindingRef::default(),
1269 count: obs.skipped_records,
1270 });
1271 }
1272 if let Some(first) = first_normal_epoch(obs) {
1273 if let Some((declared, declared_scale)) = obs.header.time_of_first_obs {
1274 let observed_scale = obs_body_time_scale(obs);
1275 if !same_epoch_time(declared, first.epoch) || declared_scale != observed_scale {
1276 findings.push(Finding::ObsTimeOfFirstMismatch {
1277 at: FindingRef::field("TIME OF FIRST OBS"),
1278 declared,
1279 declared_scale,
1280 observed: first.epoch,
1281 observed_scale,
1282 });
1283 }
1284 }
1285 }
1286 if let Some(last) = last_normal_epoch(obs) {
1287 if let Some((declared, declared_scale)) = obs.header.time_of_last_obs {
1288 let observed_scale = obs_body_time_scale(obs);
1289 if !same_epoch_time(declared, last.epoch) || declared_scale != observed_scale {
1290 findings.push(Finding::ObsTimeOfLastMismatch {
1291 at: FindingRef::field("TIME OF LAST OBS"),
1292 declared,
1293 declared_scale,
1294 observed: last.epoch,
1295 observed_scale,
1296 });
1297 }
1298 }
1299 }
1300 lint_obs_counts(obs, findings);
1301 lint_obs_epoch_order(obs, findings);
1302 if let Some(observed) = dominant_interval_for_epochs(&obs.epochs) {
1303 if let Some(declared) = obs
1304 .header
1305 .interval_s
1306 .filter(|interval_s| usable_obs_interval_s(*interval_s))
1307 {
1308 if (declared - observed).abs() > 1.0e-6 {
1309 findings.push(Finding::ObsIntervalMismatch {
1310 at: FindingRef::field("INTERVAL"),
1311 declared_s: declared,
1312 observed_s: observed,
1313 });
1314 }
1315 }
1316 lint_obs_gaps(obs, observed, findings);
1317 }
1318 lint_obs_glonass_slots(obs, findings);
1319 lint_obs_values(obs, findings);
1320}
1321
1322fn lint_obs_epoch_order(obs: &RinexObs, findings: &mut Vec<Finding>) {
1323 let mut previous: Option<(usize, ObsEpochTime)> = None;
1324 let mut seen = BTreeMap::new();
1325 for (idx, epoch) in obs.epochs.iter().enumerate().filter(|(_, e)| e.flag <= 1) {
1326 let key = epoch_key(epoch.epoch);
1327 if let Some((_, prev)) = previous {
1328 if key < epoch_key(prev) {
1329 findings.push(Finding::ObsEpochOrder {
1330 at: FindingRef::epoch(idx),
1331 previous: prev,
1332 current: epoch.epoch,
1333 });
1334 }
1335 }
1336 if seen.insert(key, idx).is_some() {
1337 findings.push(Finding::ObsDuplicateEpoch {
1338 at: FindingRef::epoch(idx),
1339 epoch: epoch.epoch,
1340 });
1341 }
1342 previous = Some((idx, epoch.epoch));
1343 }
1344}
1345
1346fn lint_obs_counts(obs: &RinexObs, findings: &mut Vec<Finding>) {
1347 let body_counts = body_obs_counts(obs);
1348 let distinct_sats = body_counts.keys().copied().collect::<BTreeSet<_>>();
1349 if let Some(declared) = obs.header.n_satellites {
1350 let observed = distinct_sats.len();
1351 if declared != 0 && declared != observed {
1352 findings.push(Finding::ObsSatelliteCountMismatch {
1353 at: FindingRef::field("# OF SATELLITES"),
1354 declared,
1355 observed,
1356 });
1357 }
1358 }
1359 for (&sat, declared_counts) in &obs.header.prn_obs_counts {
1360 let Some(codes) = obs.header.obs_codes.get(&sat.system) else {
1361 continue;
1362 };
1363 let observed_counts = body_counts.get(&sat);
1364 for (idx, declared) in declared_counts.iter().enumerate() {
1365 let code = codes.get(idx).cloned().unwrap_or_default();
1366 let observed = observed_counts
1367 .and_then(|counts| counts.get(idx).copied())
1368 .unwrap_or(0);
1369 if declared.unwrap_or(0) != observed {
1370 findings.push(Finding::ObsPrnObsCountMismatch {
1371 at: FindingRef {
1372 satellite: Some(sat.to_string()),
1373 field: Some("PRN / # OF OBS"),
1374 ..FindingRef::default()
1375 },
1376 satellite: sat,
1377 code,
1378 declared: *declared,
1379 observed,
1380 });
1381 }
1382 }
1383 }
1384}
1385
1386fn lint_obs_glonass_slots(obs: &RinexObs, findings: &mut Vec<Finding>) {
1387 let has_glonass_codes = obs.header.obs_codes.contains_key(&GnssSystem::Glonass);
1388 if !has_glonass_codes {
1389 return;
1390 }
1391 let mut reported_missing = BTreeSet::new();
1392 for (&prn, &channel) in &obs.header.glonass_slots {
1393 if !crate::rinex_nav::valid_glonass_frequency_channel(i32::from(channel)) {
1394 if let Ok(satellite) = GnssSatelliteId::new(GnssSystem::Glonass, prn) {
1395 findings.push(Finding::ObsGlonassSlotIssue {
1396 at: FindingRef {
1397 satellite: Some(satellite.to_string()),
1398 field: Some("GLONASS SLOT / FRQ #"),
1399 ..FindingRef::default()
1400 },
1401 satellite,
1402 issue: "invalid channel",
1403 });
1404 }
1405 }
1406 }
1407 for epoch in &obs.epochs {
1408 for sat in epoch
1409 .sats
1410 .keys()
1411 .filter(|sat| sat.system == GnssSystem::Glonass)
1412 {
1413 if !obs.header.glonass_slots.contains_key(&sat.prn) && reported_missing.insert(*sat) {
1414 findings.push(Finding::ObsGlonassSlotIssue {
1415 at: FindingRef {
1416 satellite: Some(sat.to_string()),
1417 field: Some("GLONASS SLOT / FRQ #"),
1418 ..FindingRef::default()
1419 },
1420 satellite: *sat,
1421 issue: "missing slot",
1422 });
1423 }
1424 }
1425 }
1426}
1427
1428fn lint_obs_values(obs: &RinexObs, findings: &mut Vec<Finding>) {
1429 for (epoch_index, epoch) in obs.epochs.iter().enumerate() {
1430 if epoch.flag > 1 {
1431 findings.push(Finding::ObsEventEpoch {
1432 at: FindingRef::epoch(epoch_index),
1433 flag: epoch.flag,
1434 });
1435 if epoch.special_record_count > 0 {
1436 findings.push(Finding::ObsEventSpecialRecords {
1437 at: FindingRef::epoch(epoch_index),
1438 count: epoch.special_record_count,
1439 });
1440 }
1441 continue;
1442 }
1443 if epoch.declared_record_count != epoch.sats.len() {
1444 findings.push(Finding::ObsEpochSatCountMismatch {
1445 at: FindingRef::epoch(epoch_index),
1446 declared: epoch.declared_record_count,
1447 retained: epoch.sats.len(),
1448 });
1449 }
1450 for (&sat, values) in &epoch.sats {
1451 let all_blank = values.iter().all(|value| value.value.is_none());
1452 if all_blank {
1453 findings.push(Finding::ObsEmptySatelliteRecord {
1454 at: FindingRef::sat(epoch_index, sat),
1455 });
1456 }
1457 let codes = obs.header.obs_codes.get(&sat.system).map(Vec::as_slice);
1458 for (idx, value) in values.iter().enumerate() {
1459 let code = codes
1460 .and_then(|codes| codes.get(idx))
1461 .map_or("", String::as_str);
1462 if code.starts_with('C') {
1463 if let Some(v) = value.value {
1464 if !(15_000_000.0..=50_000_000.0).contains(&v) {
1465 findings.push(Finding::ObsPseudorangeOutOfRange {
1466 at: FindingRef::sat(epoch_index, sat),
1467 code: code.to_string(),
1468 value_m: v,
1469 });
1470 }
1471 }
1472 }
1473 if let Some(lli) = value.lli {
1474 if lli > 7 {
1475 findings.push(Finding::ObsLossOfLockOutOfRange {
1476 at: FindingRef::sat(epoch_index, sat),
1477 code: code.to_string(),
1478 lli,
1479 });
1480 }
1481 }
1482 }
1483 }
1484 }
1485}
1486
1487fn lint_obs_gaps(obs: &RinexObs, interval_s: f64, findings: &mut Vec<Finding>) {
1488 let mut previous: Option<ObsEpochTime> = None;
1489 for (idx, epoch) in obs.epochs.iter().enumerate().filter(|(_, e)| e.flag <= 1) {
1490 if let Some(prev) = previous {
1491 let gap = obs_epoch_seconds(epoch.epoch) - obs_epoch_seconds(prev);
1492 if gap > interval_s * 1.5 {
1493 findings.push(Finding::ObsEpochGap {
1494 at: FindingRef::epoch(idx),
1495 gap_s: gap,
1496 interval_s,
1497 });
1498 }
1499 }
1500 previous = Some(epoch.epoch);
1501 }
1502}
1503
1504fn body_obs_counts(obs: &RinexObs) -> BTreeMap<GnssSatelliteId, Vec<usize>> {
1505 let mut counts: BTreeMap<GnssSatelliteId, Vec<usize>> = BTreeMap::new();
1506 for epoch in obs.epochs.iter().filter(|epoch| epoch.flag <= 1) {
1507 for (&sat, values) in &epoch.sats {
1508 let Some(codes) = obs.header.obs_codes.get(&sat.system) else {
1509 continue;
1510 };
1511 let entry = counts.entry(sat).or_insert_with(|| vec![0; codes.len()]);
1512 if entry.len() < codes.len() {
1513 entry.resize(codes.len(), 0);
1514 }
1515 for (idx, value) in values.iter().enumerate() {
1516 if value.value.is_some() {
1517 if let Some(count) = entry.get_mut(idx) {
1518 *count += 1;
1519 }
1520 }
1521 }
1522 }
1523 }
1524 counts
1525}
1526
1527fn is_earth_fixed_marker_type(marker_type: &str) -> bool {
1528 matches!(
1529 marker_type.trim(),
1530 "" | "GEODETIC" | "NON_GEODETIC" | "FIXED_BUOY"
1531 )
1532}
1533
1534fn is_valid_marker_type(marker_type: &str) -> bool {
1535 matches!(
1536 marker_type.trim(),
1537 "GEODETIC"
1538 | "NON_GEODETIC"
1539 | "NON_PHYSICAL"
1540 | "SPACEBORNE"
1541 | "AIRBORNE"
1542 | "WATER_CRAFT"
1543 | "GROUND_CRAFT"
1544 | "FIXED_BUOY"
1545 | "FLOATING_BUOY"
1546 | "FLOATING_ICE"
1547 | "GLACIER"
1548 | "BALLOON"
1549 | "ANIMAL"
1550 | "HUMAN"
1551 )
1552}
1553
1554fn lint_identity_field(
1555 findings: &mut Vec<Finding>,
1556 label: &'static str,
1557 value: Option<&str>,
1558 max_width: usize,
1559) {
1560 let Some(value) = value else {
1561 return;
1562 };
1563 if value.len() > max_width || !value.bytes().all(|b| (0x20..=0x7e).contains(&b)) {
1564 findings.push(Finding::ObsIdentityFieldIssue {
1565 at: FindingRef::field(label),
1566 label,
1567 value: value.to_string(),
1568 });
1569 }
1570}
1571
1572fn validate_text_field(
1573 field: &'static str,
1574 value: &str,
1575 max_width: usize,
1576 allow_empty: bool,
1577) -> std::result::Result<(), HeaderEditError> {
1578 let trimmed = value.trim();
1579 if !allow_empty && trimmed.is_empty() {
1580 return Err(HeaderEditError::InvalidField {
1581 field,
1582 reason: "must not be empty",
1583 });
1584 }
1585 if trimmed.len() > max_width {
1586 return Err(HeaderEditError::InvalidField {
1587 field,
1588 reason: "too wide for RINEX field",
1589 });
1590 }
1591 if !trimmed.bytes().all(|b| (0x20..=0x7e).contains(&b)) {
1592 return Err(HeaderEditError::InvalidField {
1593 field,
1594 reason: "must be printable ASCII",
1595 });
1596 }
1597 Ok(())
1598}
1599
1600fn validate_antenna_delta(
1601 field: &'static str,
1602 value: f64,
1603) -> std::result::Result<(), HeaderEditError> {
1604 if !value.is_finite() {
1605 return Err(HeaderEditError::InvalidField {
1606 field,
1607 reason: "must be finite",
1608 });
1609 }
1610 if value.abs() > 100.0 {
1611 return Err(HeaderEditError::InvalidField {
1612 field,
1613 reason: "magnitude exceeds 100 m",
1614 });
1615 }
1616 Ok(())
1617}
1618
1619fn push_edit(
1620 applied: &mut Vec<AppliedEdit>,
1621 field: &'static str,
1622 old_value: Option<String>,
1623 new_value: Option<String>,
1624 warning: Option<String>,
1625) {
1626 if old_value != new_value || warning.is_some() {
1627 applied.push(AppliedEdit {
1628 field,
1629 old_value,
1630 new_value,
1631 warning,
1632 });
1633 }
1634}
1635
1636fn format_receiver(value: &ReceiverInfo) -> String {
1637 format!("{}/{}/{}", value.number, value.receiver_type, value.version)
1638}
1639
1640fn format_antenna(value: &AntennaInfo) -> String {
1641 format!("{}/{}", value.number, value.antenna_type)
1642}
1643
1644fn nav_findings(records: &[BroadcastRecord]) -> Vec<Finding> {
1645 let mut findings = Vec::new();
1646 lint_nav_duplicates(records, &mut findings);
1647 lint_nav_order(records, &mut findings);
1648 lint_nav_plausibility(records, &mut findings);
1649 findings
1650}
1651
1652fn nav_scope_tallies(text: &str) -> BTreeMap<String, usize> {
1653 let mut body = false;
1654 let mut version_major = 3_u8;
1655 let mut tallies = BTreeMap::new();
1656 for line in text.lines() {
1657 if line.contains("RINEX VERSION / TYPE")
1658 && line.get(0..9).unwrap_or("").trim().starts_with('4')
1659 {
1660 version_major = 4;
1661 }
1662 if !body {
1663 if line.contains("END OF HEADER") {
1664 body = true;
1665 }
1666 continue;
1667 }
1668 if version_major >= 4 {
1669 if let Some(rest) = line.strip_prefix('>') {
1670 let fields: Vec<_> = rest.split_whitespace().collect();
1671 if fields.len() < 3 {
1672 continue;
1673 }
1674 let frame = fields[0];
1675 let sv = fields[1];
1676 let msg = fields[2];
1677 let system = sv.chars().next();
1678 let class = if frame != "EPH" {
1679 Some(format!("v4 {frame} frame"))
1680 } else if !matches!(system, Some('G' | 'E' | 'C')) {
1681 Some(format!(
1682 "unsupported constellation {}",
1683 system.unwrap_or('?')
1684 ))
1685 } else if !matches!(msg, "LNAV" | "INAV" | "FNAV" | "D1" | "D2") {
1686 Some(format!("unsupported message {msg}"))
1687 } else {
1688 None
1689 };
1690 if let Some(class) = class {
1691 *tallies.entry(class).or_default() += 1;
1692 }
1693 }
1694 } else if is_nav_record_start_text(line) {
1695 let system = line.as_bytes()[0] as char;
1696 if !matches!(system, 'G' | 'E' | 'C') {
1697 *tallies
1698 .entry(format!("unsupported constellation {system}"))
1699 .or_default() += 1;
1700 }
1701 }
1702 }
1703 tallies
1704}
1705
1706fn is_nav_record_start_text(line: &str) -> bool {
1707 let b = line.as_bytes();
1708 b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1].is_ascii_digit() && b[2].is_ascii_digit()
1709}
1710
1711fn lint_nav_duplicates(records: &[BroadcastRecord], findings: &mut Vec<Finding>) {
1712 let mut seen: BTreeMap<String, usize> = BTreeMap::new();
1713 for (idx, record) in records.iter().enumerate() {
1714 let key = nav_identity(record);
1715 if let Some(first_idx) = seen.get(&key).copied() {
1716 findings.push(Finding::NavDuplicateRecord {
1717 at: FindingRef::epoch(idx),
1718 satellite: record.satellite_id,
1719 same_payload: records[first_idx] == *record,
1720 });
1721 } else {
1722 seen.insert(key, idx);
1723 }
1724 }
1725}
1726
1727fn lint_nav_order(records: &[BroadcastRecord], findings: &mut Vec<Finding>) {
1728 if records
1729 .windows(2)
1730 .any(|pair| nav_sort_key(&pair[0]) > nav_sort_key(&pair[1]))
1731 {
1732 findings.push(Finding::NavUnsortedRecords {
1733 at: FindingRef::default(),
1734 });
1735 }
1736}
1737
1738fn lint_nav_plausibility(records: &[BroadcastRecord], findings: &mut Vec<Finding>) {
1739 let mut unhealthy: BTreeMap<GnssSystem, usize> = BTreeMap::new();
1740 for (idx, record) in records.iter().enumerate() {
1741 if !(0.0..=0.1).contains(&record.elements.e) {
1742 findings.push(Finding::NavImplausibleRecord {
1743 at: FindingRef::epoch(idx),
1744 satellite: record.satellite_id,
1745 field: "eccentricity",
1746 value: record.elements.e,
1747 });
1748 }
1749 if !(4_000.0..=8_000.0).contains(&record.elements.sqrt_a) {
1750 findings.push(Finding::NavImplausibleRecord {
1751 at: FindingRef::epoch(idx),
1752 satellite: record.satellite_id,
1753 field: "sqrt_a",
1754 value: record.elements.sqrt_a,
1755 });
1756 }
1757 if record.sv_health != 0.0 {
1758 *unhealthy.entry(record.satellite_id.system).or_default() += 1;
1759 }
1760 }
1761 for (system, count) in unhealthy {
1762 findings.push(Finding::NavUnhealthyRecords {
1763 at: FindingRef::default(),
1764 system,
1765 count,
1766 });
1767 }
1768}
1769
1770fn repair_obs_order_and_duplicates(obs: &mut RinexObs, actions: &mut Vec<RepairAction>) {
1771 if obs.epochs.iter().any(|epoch| epoch.flag > 1) {
1772 return;
1773 }
1774 let before = obs.epochs.clone();
1775 obs.epochs.sort_by_key(|epoch| epoch_key(epoch.epoch));
1776 let mut merged: Vec<ObsEpoch> = Vec::new();
1777 let mut discarded = Vec::new();
1778 for epoch in obs.epochs.drain(..) {
1779 if let Some(last) = merged.last_mut() {
1780 if same_epoch_time(last.epoch, epoch.epoch) {
1781 for (sat, values) in epoch.sats {
1782 match last.sats.entry(sat) {
1783 std::collections::btree_map::Entry::Vacant(slot) => {
1784 slot.insert(values);
1785 }
1786 std::collections::btree_map::Entry::Occupied(_) => {
1787 discarded.push(sat);
1788 }
1789 }
1790 }
1791 last.declared_record_count = last.sats.len();
1792 continue;
1793 }
1794 }
1795 merged.push(epoch);
1796 }
1797 obs.epochs = merged;
1798 if obs.epochs != before {
1799 let discarded = discarded
1800 .iter()
1801 .map(ToString::to_string)
1802 .collect::<Vec<_>>()
1803 .join(",");
1804 actions.push(RepairAction {
1805 id: "A3",
1806 message: format!(
1807 "sorted epochs and merged duplicate epochs, discarded duplicate satellite rows [{discarded}]"
1808 ),
1809 });
1810 }
1811}
1812
1813fn repair_obs_times(obs: &mut RinexObs, options: &RepairOptions, actions: &mut Vec<RepairAction>) {
1814 let Some(first) = first_normal_epoch(obs).map(|epoch| epoch.epoch) else {
1815 return;
1816 };
1817 let scale = obs_body_time_scale(obs);
1818 if obs
1819 .header
1820 .time_of_first_obs
1821 .is_none_or(|(declared, declared_scale)| {
1822 !same_epoch_time(declared, first) || declared_scale != scale
1823 })
1824 {
1825 obs.header.time_of_first_obs = Some((first, scale));
1826 actions.push(RepairAction {
1827 id: "A4",
1828 message: "recomputed TIME OF FIRST OBS".to_string(),
1829 });
1830 }
1831 let Some(last) = last_normal_epoch(obs).map(|epoch| epoch.epoch) else {
1832 return;
1833 };
1834 if options.set_time_of_last_obs
1837 || obs
1838 .header
1839 .time_of_last_obs
1840 .is_some_and(|(declared, declared_scale)| {
1841 !same_epoch_time(declared, last) || declared_scale != scale
1842 })
1843 {
1844 obs.header.time_of_last_obs = Some((last, scale));
1845 actions.push(RepairAction {
1846 id: "A4",
1847 message: "recomputed TIME OF LAST OBS".to_string(),
1848 });
1849 }
1850}
1851
1852fn repair_obs_counts(obs: &mut RinexObs, options: &RepairOptions, actions: &mut Vec<RepairAction>) {
1853 if !options.set_obs_counts
1854 && obs.header.n_satellites.is_none()
1855 && obs.header.prn_obs_counts.is_empty()
1856 {
1857 return;
1858 }
1859 let counts = body_obs_counts(obs);
1860 obs.header.n_satellites = Some(counts.len());
1861 obs.header.prn_obs_counts = counts
1862 .into_iter()
1863 .map(|(sat, values)| (sat, values.into_iter().map(Some).collect()))
1864 .collect();
1865 actions.push(RepairAction {
1866 id: "A5",
1867 message: "recomputed observation count headers".to_string(),
1868 });
1869}
1870
1871fn repair_obs_file_stamp(
1872 obs: &mut RinexObs,
1873 options: &RepairOptions,
1874 actions: &mut Vec<RepairAction>,
1875) {
1876 if let Some(stamp) = &options.file_stamp {
1877 if obs.header.program_run_by_date.as_ref() != Some(stamp) {
1878 obs.header.program_run_by_date = Some(stamp.clone());
1879 actions.push(RepairAction {
1880 id: "A8",
1881 message: "set PGM / RUN BY / DATE".to_string(),
1882 });
1883 }
1884 }
1885}
1886
1887fn repair_obs_unsupported_records(
1888 obs: &mut RinexObs,
1889 options: &RepairOptions,
1890 actions: &mut Vec<RepairAction>,
1891) {
1892 if !options.drop_unsupported {
1893 return;
1894 }
1895 let mut dropped = 0_usize;
1896 for epoch in &mut obs.epochs {
1897 if epoch.flag > 1 && epoch.special_record_count > 0 {
1898 dropped += epoch.special_record_count;
1899 epoch.special_record_count = 0;
1900 epoch.declared_record_count = 0;
1901 }
1902 }
1903 if dropped > 0 {
1904 actions.push(RepairAction {
1905 id: "OBS-B11",
1906 message: format!("dropped {dropped} event special records"),
1907 });
1908 }
1909 let labels = std::mem::take(&mut obs.header.unretained_header_labels);
1910 if !labels.is_empty() {
1911 actions.push(RepairAction {
1912 id: "OBS-H90",
1913 message: format!("dropped {} unretained header records", labels.len()),
1914 });
1915 }
1916}
1917
1918fn repair_obs_interval(obs: &mut RinexObs, actions: &mut Vec<RepairAction>) {
1919 let Some(interval) = dominant_interval_for_epochs(&obs.epochs) else {
1920 if obs
1921 .header
1922 .interval_s
1923 .is_some_and(|declared| !usable_obs_interval_s(declared))
1924 {
1925 obs.header.interval_s = None;
1926 actions.push(RepairAction {
1927 id: "A6",
1928 message: "removed unusable INTERVAL because no cadence could be inferred"
1929 .to_string(),
1930 });
1931 }
1932 return;
1933 };
1934 if obs.header.interval_s.is_none_or(|declared| {
1935 !usable_obs_interval_s(declared) || (declared - interval).abs() > 1.0e-6
1936 }) {
1937 obs.header.interval_s = Some(interval);
1938 actions.push(RepairAction {
1939 id: "A6",
1940 message: format!("set INTERVAL to {interval:.3} seconds"),
1941 });
1942 }
1943}
1944
1945fn repair_obs_empty_records(obs: &mut RinexObs, actions: &mut Vec<RepairAction>) {
1946 let mut dropped = 0_usize;
1947 for epoch in &mut obs.epochs {
1948 let before = epoch.sats.len();
1949 epoch
1950 .sats
1951 .retain(|_, values| values.iter().any(|value| value.value.is_some()));
1952 let removed = before - epoch.sats.len();
1953 if removed > 0 {
1954 epoch.declared_record_count = epoch.sats.len();
1955 }
1956 dropped += removed;
1957 }
1958 if dropped > 0 {
1959 actions.push(RepairAction {
1960 id: "A7",
1961 message: format!("dropped {dropped} empty satellite records"),
1962 });
1963 }
1964}
1965
1966fn repair_nav_duplicates(records: &mut Vec<BroadcastRecord>, actions: &mut Vec<RepairAction>) {
1967 let mut seen: BTreeMap<String, Vec<BroadcastRecord>> = BTreeMap::new();
1968 let mut out = Vec::with_capacity(records.len());
1969 let mut dropped = 0_usize;
1970 for record in records.drain(..) {
1971 let key = nav_identity(&record);
1972 let family = seen.entry(key).or_default();
1973 if family.contains(&record) {
1974 dropped += 1;
1975 } else {
1976 family.push(record);
1977 out.push(record);
1978 }
1979 }
1980 *records = out;
1981 if dropped > 0 {
1982 actions.push(RepairAction {
1983 id: "A11",
1984 message: format!("dropped {dropped} identical duplicate NAV records"),
1985 });
1986 }
1987}
1988
1989fn repair_nav_order(records: &mut [BroadcastRecord], actions: &mut Vec<RepairAction>) {
1990 let before = records.to_vec();
1991 records.sort_by_key(nav_sort_key);
1992 if records != before {
1993 actions.push(RepairAction {
1994 id: "A12",
1995 message: "sorted NAV records".to_string(),
1996 });
1997 }
1998}
1999
2000fn published_obs_version(version: f64) -> Option<()> {
2001 let scaled = (version * 100.0).round() as i64;
2002 matches!(
2003 scaled,
2004 200 | 201 | 202 | 210 | 211 | 212 | 300 | 301 | 302 | 303 | 304 | 305 | 400 | 401 | 402
2005 )
2006 .then_some(())
2007}
2008
2009fn is_valid_obs_code(system: GnssSystem, code: &str, version: f64) -> bool {
2010 let mut chars = code.chars();
2011 let Some(kind) = chars.next() else {
2012 return false;
2013 };
2014 let Some(band) = chars.next() else {
2015 return false;
2016 };
2017 let Some(attr) = chars.next() else {
2018 return false;
2019 };
2020 if chars.next().is_some() || !"CLDSX".contains(kind) || !band.is_ascii_digit() {
2021 return false;
2022 }
2023 obs_code_band_attr_allowed(system, band, attr, version)
2024}
2025
2026fn obs_code_band_attr_allowed(system: GnssSystem, band: char, attr: char, _version: f64) -> bool {
2027 match system {
2028 GnssSystem::Gps => match band {
2031 '1' => "CWPYMSLXN".contains(attr),
2032 '2' => "CWPYMSLDXN".contains(attr),
2033 '5' => "IQX".contains(attr),
2034 _ => false,
2035 },
2036 GnssSystem::Glonass => match band {
2037 '1' | '2' => "CP".contains(attr),
2038 '3' => "IQX".contains(attr),
2039 '4' | '6' => "ABX".contains(attr),
2040 _ => false,
2041 },
2042 GnssSystem::Galileo => match band {
2043 '1' => "ABCXZ".contains(attr),
2044 '5' | '7' | '8' => "IQX".contains(attr),
2045 '6' => "ABCXZ".contains(attr),
2046 _ => false,
2047 },
2048 GnssSystem::BeiDou => match band {
2049 '1' => "DPXAN".contains(attr),
2050 '2' => "IQX".contains(attr),
2051 '5' => "DPX".contains(attr),
2052 '6' => "IQX".contains(attr),
2053 '7' => "IQXDPZ".contains(attr),
2054 '8' => "DPX".contains(attr),
2055 _ => false,
2056 },
2057 GnssSystem::Qzss => match band {
2058 '1' => "CSLXZ".contains(attr),
2059 '2' => "SLX".contains(attr),
2060 '5' => "IQX".contains(attr),
2061 '6' => "SLXEZ".contains(attr),
2062 _ => false,
2063 },
2064 GnssSystem::Navic => match band {
2065 '5' | '9' => "ABCX".contains(attr),
2066 _ => false,
2067 },
2068 GnssSystem::Sbas => match band {
2069 '1' => "C".contains(attr),
2070 '5' => "IQX".contains(attr),
2071 _ => false,
2072 },
2073 }
2074}
2075
2076fn first_normal_epoch(obs: &RinexObs) -> Option<&ObsEpoch> {
2077 obs.epochs.iter().find(|epoch| epoch.flag <= 1)
2078}
2079
2080fn last_normal_epoch(obs: &RinexObs) -> Option<&ObsEpoch> {
2081 obs.epochs.iter().rev().find(|epoch| epoch.flag <= 1)
2082}
2083
2084fn obs_body_time_scale(obs: &RinexObs) -> TimeScale {
2088 match (obs.header.time_of_first_obs, obs.header.time_of_last_obs) {
2089 (Some((_, scale)), _) | (None, Some((_, scale))) => scale,
2090 _ => TimeScale::Gpst,
2091 }
2092}
2093
2094fn dominant_interval_for_epochs(epochs: &[ObsEpoch]) -> Option<f64> {
2095 let normal: Vec<_> = epochs
2096 .iter()
2097 .filter(|epoch| epoch.flag <= 1)
2098 .map(|epoch| epoch.epoch)
2099 .collect();
2100 dominant_obs_interval_s(&normal)
2101}
2102
2103fn epoch_key(epoch: ObsEpochTime) -> (i32, u8, u8, u8, u8, i64) {
2104 (
2105 epoch.year,
2106 epoch.month,
2107 epoch.day,
2108 epoch.hour,
2109 epoch.minute,
2110 (epoch.second * 10_000_000.0).round() as i64,
2111 )
2112}
2113
2114fn same_epoch_time(a: ObsEpochTime, b: ObsEpochTime) -> bool {
2115 epoch_key(a) == epoch_key(b)
2116}
2117
2118fn nav_identity(record: &BroadcastRecord) -> String {
2119 format!(
2120 "{}:{:?}:{}:{:016x}:{}",
2121 record.satellite_id,
2122 record.message,
2123 record.toc.week,
2124 record.toc.tow_s.to_bits(),
2125 record.issue_of_data.issue
2126 )
2127}
2128
2129fn nav_sort_key(record: &BroadcastRecord) -> (GnssSystem, u8, u32, u64, u8) {
2130 (
2131 record.satellite_id.system,
2132 record.satellite_id.prn,
2133 record.toc.week,
2134 record.toc.tow_s.to_bits(),
2135 nav_message_rank(record.message),
2136 )
2137}
2138
2139const fn nav_message_rank(message: NavMessage) -> u8 {
2140 match message {
2141 NavMessage::GpsLnav => 0,
2142 NavMessage::GpsCnav => 1,
2143 NavMessage::GpsCnav2 => 2,
2144 NavMessage::QzssLnav => 3,
2145 NavMessage::QzssCnav => 4,
2146 NavMessage::QzssCnav2 => 5,
2147 NavMessage::GalileoInav => 6,
2148 NavMessage::GalileoFnav => 7,
2149 NavMessage::BeidouD1 => 8,
2150 NavMessage::BeidouD2 => 9,
2151 }
2152}
2153
2154#[cfg(test)]
2155mod tests;