Skip to main content

sidereon_core/rinex_qc/
mod.rs

1//! RINEX observation/navigation lint and mechanical repair.
2//!
3//! This module is a sans-I/O layer over the existing RINEX and CRINEX readers.
4//! It does not implement a parser. Text entry points decode through the owning
5//! modules, then report typed findings derived from the parsed products.
6
7use 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/// Severity assigned to a lint finding.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
27pub enum Severity {
28    /// The file cannot be represented by the existing parsed product.
29    Fatal,
30    /// The parsed product violates a standard-required invariant.
31    Error,
32    /// The parsed product is suspicious or will lose information in this slice.
33    Warning,
34    /// A useful fact about product scope or content.
35    Info,
36}
37
38/// Location associated with a lint finding when known.
39#[derive(Debug, Clone, PartialEq, Eq, Default)]
40pub struct FindingRef {
41    /// Zero-based epoch index.
42    pub epoch_index: Option<usize>,
43    /// Satellite token.
44    pub satellite: Option<String>,
45    /// Header or record field name.
46    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/// A typed RINEX lint finding.
74#[derive(Debug, Clone, PartialEq)]
75#[non_exhaustive]
76pub enum Finding {
77    /// OBS parse failed before a parsed product existed.
78    ObsFatalParse { at: FindingRef, message: String },
79    /// OBS version is not one of the published versions covered here.
80    ObsUnpublishedVersion { at: FindingRef, version: f64 },
81    /// A mandatory OBS header retained by the current product is absent.
82    ObsMissingHeader { at: FindingRef, label: &'static str },
83    /// OBS header has no observation-code table.
84    ObsMissingObsTypes { at: FindingRef },
85    /// OBS code syntax is not valid for this first slice.
86    ObsInvalidObsCode {
87        at: FindingRef,
88        system: GnssSystem,
89        code: String,
90    },
91    /// OBS code is duplicated in one system table.
92    ObsDuplicateObsCode {
93        at: FindingRef,
94        system: GnssSystem,
95        code: String,
96    },
97    /// TIME OF FIRST OBS disagrees with the body.
98    ObsTimeOfFirstMismatch {
99        at: FindingRef,
100        declared: ObsEpochTime,
101        declared_scale: TimeScale,
102        observed: ObsEpochTime,
103        observed_scale: TimeScale,
104    },
105    /// TIME OF LAST OBS disagrees with the body epoch or with the file time
106    /// system declared by TIME OF FIRST OBS (RINEX 3.05: TIME OF FIRST OBS
107    /// defines the time system; TIME OF LAST OBS must agree with it).
108    ObsTimeOfLastMismatch {
109        at: FindingRef,
110        declared: ObsEpochTime,
111        declared_scale: TimeScale,
112        observed: ObsEpochTime,
113        observed_scale: TimeScale,
114    },
115    /// INTERVAL disagrees with the dominant epoch spacing.
116    ObsIntervalMismatch {
117        at: FindingRef,
118        declared_s: f64,
119        observed_s: f64,
120    },
121    /// # OF SATELLITES disagrees with the body.
122    ObsSatelliteCountMismatch {
123        at: FindingRef,
124        declared: usize,
125        observed: usize,
126    },
127    /// PRN / # OF OBS disagrees with body tallies.
128    ObsPrnObsCountMismatch {
129        at: FindingRef,
130        satellite: GnssSatelliteId,
131        code: String,
132        declared: Option<usize>,
133        observed: usize,
134    },
135    /// GLONASS observations need a valid slot/frequency table.
136    ObsGlonassSlotIssue {
137        at: FindingRef,
138        satellite: GnssSatelliteId,
139        issue: &'static str,
140    },
141    /// SYS / PHASE SHIFT names a code absent from SYS / # / OBS TYPES.
142    ObsPhaseShiftUndeclaredCode {
143        at: FindingRef,
144        system: GnssSystem,
145        code: String,
146    },
147    /// SYS / SCALE FACTOR is invalid or names an undeclared code.
148    ObsScaleFactorIssue {
149        at: FindingRef,
150        system: GnssSystem,
151        code: Option<String>,
152    },
153    /// MARKER TYPE is not a RINEX Table 8 keyword.
154    ObsMarkerTypeIssue { at: FindingRef, marker_type: String },
155    /// Identity/header field exceeds width or has non-printable ASCII.
156    ObsIdentityFieldIssue {
157        at: FindingRef,
158        label: &'static str,
159        value: String,
160    },
161    /// Approximate position is implausible for a fixed marker.
162    ObsImplausibleApproxPosition { at: FindingRef, radius_m: f64 },
163    /// Antenna height/east/north offset is implausible.
164    ObsImplausibleAntennaDelta {
165        at: FindingRef,
166        component: usize,
167        value_m: f64,
168    },
169    /// Epoch times are not strictly increasing.
170    ObsEpochOrder {
171        at: FindingRef,
172        previous: ObsEpochTime,
173        current: ObsEpochTime,
174    },
175    /// Two normal epochs carry the same timestamp.
176    ObsDuplicateEpoch { at: FindingRef, epoch: ObsEpochTime },
177    /// The parser skipped satellite records it could not represent.
178    ObsSkippedRecords { at: FindingRef, count: usize },
179    /// Epoch record count disagreed with retained satellite records.
180    ObsEpochSatCountMismatch {
181        at: FindingRef,
182        declared: usize,
183        retained: usize,
184    },
185    /// A retained event epoch had special records that are not retained.
186    ObsEventSpecialRecords { at: FindingRef, count: usize },
187    /// Header record is outside the retained OBS product.
188    ObsUnretainedHeader { at: FindingRef, label: String },
189    /// A pseudorange value is outside the configured plausibility window.
190    ObsPseudorangeOutOfRange {
191        at: FindingRef,
192        code: String,
193        value_m: f64,
194    },
195    /// LLI digit is outside the three defined bits.
196    ObsLossOfLockOutOfRange {
197        at: FindingRef,
198        code: String,
199        lli: u8,
200    },
201    /// Event epoch retained with no special records.
202    ObsEventEpoch { at: FindingRef, flag: u8 },
203    /// Satellite record has all observation fields blank.
204    ObsEmptySatelliteRecord { at: FindingRef },
205    /// Epoch gap is larger than 1.5 times the dominant interval.
206    ObsEpochGap {
207        at: FindingRef,
208        gap_s: f64,
209        interval_s: f64,
210    },
211    /// NAV parse failed before a parsed product existed.
212    NavFatalParse { at: FindingRef, message: String },
213    /// NAV header has no LEAP SECONDS record.
214    NavLeapSecondsAbsent { at: FindingRef },
215    /// NAV ionospheric correction records are malformed.
216    NavIonoMalformed { at: FindingRef, message: String },
217    /// NAV record block was dropped by lenient parsing.
218    NavDroppedBlock {
219        at: FindingRef,
220        satellite: String,
221        message: String,
222    },
223    /// Duplicate NAV records share an identity.
224    NavDuplicateRecord {
225        at: FindingRef,
226        satellite: GnssSatelliteId,
227        same_payload: bool,
228    },
229    /// NAV records are not in canonical order.
230    NavUnsortedRecords { at: FindingRef },
231    /// NAV broadcast fields are outside this slice's plausibility limits.
232    NavImplausibleRecord {
233        at: FindingRef,
234        satellite: GnssSatelliteId,
235        field: &'static str,
236        value: f64,
237    },
238    /// NAV records include unhealthy satellite records.
239    NavUnhealthyRecords {
240        at: FindingRef,
241        system: GnssSystem,
242        count: usize,
243    },
244    /// NAV records outside the retained/writable scope are present.
245    NavOutOfScopeRecords {
246        at: FindingRef,
247        class: String,
248        count: usize,
249    },
250}
251
252impl Finding {
253    /// Stable rule identifier.
254    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    /// Rule severity.
298    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    /// Standard or policy reference for the rule.
336    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    /// Finding location.
380    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    /// Whether this finding can be changed by the repair helpers in this slice.
424    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/// Lint result.
446#[derive(Debug, Clone, PartialEq)]
447pub struct LintReport {
448    /// Findings in deterministic rule order.
449    pub findings: Vec<Finding>,
450    /// Whether a CRINEX input was decoded before linting.
451    pub decoded_from_crinex: bool,
452}
453
454impl LintReport {
455    /// Clean means no fatal or error findings.
456    pub fn is_clean(&self) -> bool {
457        self.findings
458            .iter()
459            .all(|f| !matches!(f.severity(), Severity::Fatal | Severity::Error))
460    }
461
462    /// Count findings by severity.
463    pub fn count(&self, severity: Severity) -> usize {
464        self.findings
465            .iter()
466            .filter(|finding| finding.severity() == severity)
467            .count()
468    }
469}
470
471/// Repair options for the first core slice.
472#[derive(Debug, Clone, PartialEq)]
473pub struct RepairOptions {
474    /// Caller-supplied PGM/RUN BY/DATE stamp for A8.
475    pub file_stamp: Option<PgmRunByDate>,
476    /// Set `INTERVAL` to the dominant normal-epoch spacing.
477    pub set_interval: bool,
478    /// Set `TIME OF LAST OBS` when absent or wrong.
479    pub set_time_of_last_obs: bool,
480    /// Recompute `# OF SATELLITES` and `PRN / # OF OBS`.
481    pub set_obs_counts: bool,
482    /// Drop satellite rows whose observation fields are all blank.
483    pub drop_empty_records: bool,
484    /// Sort NAV records by satellite and toc.
485    pub sort_records: bool,
486    /// Allow text repair to drop records outside the retained product scope.
487    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/// One mechanical repair action.
505#[derive(Debug, Clone, PartialEq, Eq)]
506pub struct RepairAction {
507    /// Catalog action id, e.g. `A3`.
508    pub id: &'static str,
509    /// Short action description.
510    pub message: String,
511}
512
513/// Observation repair result.
514#[derive(Debug, Clone, PartialEq)]
515pub struct ObsRepair {
516    /// Repaired parsed product.
517    pub repaired: RinexObs,
518    /// Actions applied.
519    pub actions: Vec<RepairAction>,
520    /// Lint report after repair.
521    pub remaining: LintReport,
522    /// Whether text input was decoded from CRINEX.
523    pub decoded_from_crinex: bool,
524}
525
526/// Navigation repair result.
527#[derive(Debug, Clone, PartialEq)]
528pub struct NavRepair {
529    /// Repaired broadcast records.
530    pub records: Vec<BroadcastRecord>,
531    /// Header ionospheric corrections parsed from text, if available.
532    pub iono: Option<IonoCorrections>,
533    /// Header leap-second count parsed from text, if available.
534    pub leap_seconds: Option<f64>,
535    /// Actions applied.
536    pub actions: Vec<RepairAction>,
537    /// Lint report after repair.
538    pub remaining: LintReport,
539}
540
541/// Validated observation-header edit builder.
542#[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/// One field changed by [`ObsHeaderEdit`].
557#[derive(Debug, Clone, PartialEq, Eq)]
558pub struct AppliedEdit {
559    /// Field label.
560    pub field: &'static str,
561    /// Previous value.
562    pub old_value: Option<String>,
563    /// New value.
564    pub new_value: Option<String>,
565    /// Warning text for accepted suspicious values.
566    pub warning: Option<String>,
567}
568
569/// Header edit validation failure.
570#[derive(Debug, Clone, PartialEq, Eq)]
571pub enum HeaderEditError {
572    /// A staged field failed validation.
573    InvalidField {
574        /// Field label.
575        field: &'static str,
576        /// Validation reason.
577        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    /// Create an empty edit builder.
595    pub fn new() -> Self {
596        Self::default()
597    }
598
599    /// Stage marker name.
600    pub fn marker_name(mut self, v: &str) -> Self {
601        self.marker_name = Some(v.to_string());
602        self
603    }
604
605    /// Stage marker number.
606    pub fn marker_number(mut self, v: &str) -> Self {
607        self.marker_number = Some(Some(v.to_string()));
608        self
609    }
610
611    /// Stage marker-number removal.
612    pub fn clear_marker_number(mut self) -> Self {
613        self.marker_number = Some(None);
614        self
615    }
616
617    /// Stage marker type.
618    pub fn marker_type(mut self, v: &str) -> Self {
619        self.marker_type = Some(v.to_string());
620        self
621    }
622
623    /// Stage observer.
624    pub fn observer(mut self, v: &str) -> Self {
625        self.observer = Some(v.to_string());
626        self
627    }
628
629    /// Stage agency.
630    pub fn agency(mut self, v: &str) -> Self {
631        self.agency = Some(v.to_string());
632        self
633    }
634
635    /// Stage receiver fields.
636    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    /// Stage antenna fields.
646    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    /// Stage antenna height.
655    pub fn antenna_height_m(mut self, v: f64) -> Self {
656        self.antenna_height_m = Some(v);
657        self
658    }
659
660    /// Stage antenna east/north eccentricities.
661    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    /// Stage approximate position.
667    pub fn approx_position_m(mut self, xyz: [f64; 3]) -> Self {
668        self.approx_position_m = Some(xyz);
669        self
670    }
671
672    /// Validate and apply all staged changes atomically.
673    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
843/// Lint an already parsed observation product.
844pub fn lint_obs(obs: &RinexObs) -> LintReport {
845    LintReport {
846        findings: obs_findings(obs),
847        decoded_from_crinex: false,
848    }
849}
850
851/// CRINEX-transparent observation lint entry point.
852pub 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") || message.contains("no # / TYPES OF OBSERV") {
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
890/// Lint navigation text with the existing NAV parser and header readers.
891pub 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
938/// Repair an already parsed observation product.
939pub 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
962/// CRINEX-transparent observation repair entry point.
963pub 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
987/// Encode an observation repair product as CRINEX through the existing codec.
988pub fn repair_obs_to_crinex_string(repair: &ObsRepair) -> Result<String> {
989    crinex::encode_crinex(&repair.repaired.to_rinex_string())
990}
991
992/// Repair parsed navigation records.
993pub 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
1013/// Repair navigation text through the existing parser.
1014pub 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    // TIME OF FIRST OBS is the time-system authority (RINEX 3.05); a
1802    // disagreeing TIME OF LAST OBS is rewritten to match it.
1803    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!(
1960        scaled,
1961        200 | 201 | 202 | 210 | 211 | 212 | 300 | 301 | 302 | 303 | 304 | 305 | 400 | 401 | 402
1962    )
1963    .then_some(())
1964}
1965
1966fn is_valid_obs_code(system: GnssSystem, code: &str, version: f64) -> bool {
1967    let mut chars = code.chars();
1968    let Some(kind) = chars.next() else {
1969        return false;
1970    };
1971    let Some(band) = chars.next() else {
1972        return false;
1973    };
1974    let Some(attr) = chars.next() else {
1975        return false;
1976    };
1977    if chars.next().is_some() || !"CLDSX".contains(kind) || !band.is_ascii_digit() {
1978        return false;
1979    }
1980    obs_code_band_attr_allowed(system, band, attr, version)
1981}
1982
1983fn obs_code_band_attr_allowed(system: GnssSystem, band: char, attr: char, _version: f64) -> bool {
1984    match system {
1985        // RINEX 3.05 Tables 14-20 plus RINEX 4.02 additions used by the
1986        // committed fixtures. Band checks are explicit so GPS C9C is rejected.
1987        GnssSystem::Gps => match band {
1988            '1' => "CWPYMSLXN".contains(attr),
1989            '2' => "CWPYMSLDXN".contains(attr),
1990            '5' => "IQX".contains(attr),
1991            _ => false,
1992        },
1993        GnssSystem::Glonass => match band {
1994            '1' | '2' => "CP".contains(attr),
1995            '3' => "IQX".contains(attr),
1996            '4' | '6' => "ABX".contains(attr),
1997            _ => false,
1998        },
1999        GnssSystem::Galileo => match band {
2000            '1' => "ABCXZ".contains(attr),
2001            '5' | '7' | '8' => "IQX".contains(attr),
2002            '6' => "ABCXZ".contains(attr),
2003            _ => false,
2004        },
2005        GnssSystem::BeiDou => match band {
2006            '1' => "DPXAN".contains(attr),
2007            '2' => "IQX".contains(attr),
2008            '5' => "DPX".contains(attr),
2009            '6' => "IQX".contains(attr),
2010            '7' => "IQXDPZ".contains(attr),
2011            '8' => "DPX".contains(attr),
2012            _ => false,
2013        },
2014        GnssSystem::Qzss => match band {
2015            '1' => "CSLXZ".contains(attr),
2016            '2' => "SLX".contains(attr),
2017            '5' => "IQX".contains(attr),
2018            '6' => "SLXEZ".contains(attr),
2019            _ => false,
2020        },
2021        GnssSystem::Navic => match band {
2022            '5' | '9' => "ABCX".contains(attr),
2023            _ => false,
2024        },
2025        GnssSystem::Sbas => match band {
2026            '1' => "C".contains(attr),
2027            '5' => "IQX".contains(attr),
2028            _ => false,
2029        },
2030    }
2031}
2032
2033fn first_normal_epoch(obs: &RinexObs) -> Option<&ObsEpoch> {
2034    obs.epochs.iter().find(|epoch| epoch.flag <= 1)
2035}
2036
2037fn last_normal_epoch(obs: &RinexObs) -> Option<&ObsEpoch> {
2038    obs.epochs.iter().rev().find(|epoch| epoch.flag <= 1)
2039}
2040
2041/// RINEX 3.05 Table A2: TIME OF FIRST OBS carries the file's time system, so
2042/// it is authoritative; TIME OF LAST OBS must agree with it and is only
2043/// consulted when TIME OF FIRST OBS is absent.
2044fn obs_body_time_scale(obs: &RinexObs) -> TimeScale {
2045    match (obs.header.time_of_first_obs, obs.header.time_of_last_obs) {
2046        (Some((_, scale)), _) | (None, Some((_, scale))) => scale,
2047        _ => TimeScale::Gpst,
2048    }
2049}
2050
2051fn dominant_interval_for_epochs(epochs: &[ObsEpoch]) -> Option<f64> {
2052    let normal: Vec<_> = epochs
2053        .iter()
2054        .filter(|epoch| epoch.flag <= 1)
2055        .map(|epoch| epoch.epoch)
2056        .collect();
2057    dominant_obs_interval_s(&normal)
2058}
2059
2060fn epoch_key(epoch: ObsEpochTime) -> (i32, u8, u8, u8, u8, i64) {
2061    (
2062        epoch.year,
2063        epoch.month,
2064        epoch.day,
2065        epoch.hour,
2066        epoch.minute,
2067        (epoch.second * 10_000_000.0).round() as i64,
2068    )
2069}
2070
2071fn same_epoch_time(a: ObsEpochTime, b: ObsEpochTime) -> bool {
2072    epoch_key(a) == epoch_key(b)
2073}
2074
2075fn nav_identity(record: &BroadcastRecord) -> String {
2076    format!(
2077        "{}:{:?}:{}:{:016x}:{}",
2078        record.satellite_id,
2079        record.message,
2080        record.toc.week,
2081        record.toc.tow_s.to_bits(),
2082        record.issue_of_data.issue
2083    )
2084}
2085
2086fn nav_sort_key(record: &BroadcastRecord) -> (GnssSystem, u8, u32, u64, u8) {
2087    (
2088        record.satellite_id.system,
2089        record.satellite_id.prn,
2090        record.toc.week,
2091        record.toc.tow_s.to_bits(),
2092        nav_message_rank(record.message),
2093    )
2094}
2095
2096const fn nav_message_rank(message: NavMessage) -> u8 {
2097    match message {
2098        NavMessage::GpsLnav => 0,
2099        NavMessage::GpsCnav => 1,
2100        NavMessage::GpsCnav2 => 2,
2101        NavMessage::QzssCnav => 3,
2102        NavMessage::QzssCnav2 => 4,
2103        NavMessage::GalileoInav => 5,
2104        NavMessage::GalileoFnav => 6,
2105        NavMessage::BeidouD1 => 7,
2106        NavMessage::BeidouD2 => 8,
2107    }
2108}
2109
2110#[cfg(test)]
2111mod tests;