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