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