Skip to main content

sidereon_core/observation_qc/
mod.rs

1//! RINEX observation-file quality-control rollups.
2//!
3//! This module works from an already parsed [`RinexObs`] product. It does not
4//! parse, repair, or resample files; it reports the completeness and signal
5//! indicators a caller needs before choosing solver inputs.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9mod multipath;
10mod report_html;
11mod report_text;
12
13pub use multipath::{
14    arc_multipath_rms, mp_combination, multipath_stats, MpStats, MultipathReport,
15    SatelliteMultipathQc, SystemMultipathQc,
16};
17pub use report_html::render_html;
18pub use report_text::render_text;
19
20use crate::frequencies::{default_iono_free_pair, frequency_hz, rinex_observation_frequency_hz};
21use crate::id::{GnssSatelliteId, GnssSystem};
22use crate::precise_positioning::{
23    detect_cycle_slips as detect_dual_frequency_cycle_slips, CycleSlipConfig, DualFrequencyEpoch,
24    DualFrequencyObservation,
25};
26use crate::rinex::observations::{ObsEpochTime, RinexObs};
27use crate::rinex_common::{
28    dominant_obs_interval_s, obs_epoch_seconds, time_scale_rinex_label, usable_obs_interval_s,
29};
30use crate::rinex_qc::{lint_obs, Severity};
31
32/// Default receiver-clock jump threshold, in seconds.
33pub const DEFAULT_CLOCK_JUMP_THRESHOLD_S: f64 = 0.0005;
34
35/// Options controlling RINEX observation QC aggregation.
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub struct ObservationQcOptions {
38    /// Override the header `INTERVAL` value when detecting missing epochs.
39    pub interval_override_s: Option<f64>,
40    /// Minimum `delta / interval` ratio that is treated as a data gap.
41    pub gap_factor: f64,
42    /// Minimum de-trended receiver-clock step treated as a clock jump.
43    pub clock_jump_threshold_s: f64,
44}
45
46impl Default for ObservationQcOptions {
47    fn default() -> Self {
48        Self {
49            interval_override_s: None,
50            gap_factor: 1.5,
51            clock_jump_threshold_s: DEFAULT_CLOCK_JUMP_THRESHOLD_S,
52        }
53    }
54}
55
56/// Error returned when QC options are invalid.
57#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)]
58pub enum ObservationQcError {
59    /// The supplied nominal interval was zero, negative, or non-finite.
60    #[error("invalid observation QC interval: must be finite and positive")]
61    InvalidInterval,
62    /// The supplied gap factor was not finite and greater than one.
63    #[error("invalid observation QC gap factor: must be finite and greater than one")]
64    InvalidGapFactor,
65    /// The supplied clock-jump threshold was zero, negative, or non-finite.
66    #[error("invalid observation QC clock-jump threshold: must be finite and positive")]
67    InvalidClockJumpThreshold,
68}
69
70/// Source of the interval used for gap detection.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
72pub enum IntervalSource {
73    /// Caller override.
74    Override,
75    /// Header `INTERVAL`.
76    Header,
77    /// Modal positive epoch delta inferred from the body.
78    Inferred,
79    /// Not enough positive epoch deltas were available.
80    Unresolved,
81}
82
83/// Non-fatal QC note.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
85pub enum ObservationQcNote {
86    /// Adjacent observation epochs were duplicate or out of order.
87    NonMonotonicEpoch { epoch_index: usize },
88    /// No interval could be resolved.
89    IntervalUnresolved,
90}
91
92/// Aggregate QC report for one parsed RINEX observation file.
93#[derive(Debug, Clone, PartialEq, serde::Serialize)]
94pub struct ObservationQcReport {
95    /// Header metadata copied from the source observation product.
96    pub header: ObservationQcHeader,
97    /// Total number of epoch records retained by the parser, including events.
98    pub total_epoch_records: usize,
99    /// Count of normal observation epochs (`flag == 0`) and power-failure
100    /// observation epochs (`flag == 1`).
101    pub observation_epochs: usize,
102    /// Count of non-observation event records (`flag > 1`).
103    pub event_records: usize,
104    /// Count of observation epochs marked as power-failure epochs (`flag == 1`).
105    pub power_failure_epochs: usize,
106    /// Count of malformed records skipped by the RINEX observation parser.
107    pub skipped_records: usize,
108    /// Interval used for gap detection.
109    pub interval_s: Option<f64>,
110    /// Where `interval_s` came from.
111    pub interval_source: IntervalSource,
112    /// Estimated number of missing nominal epochs across all detected gaps.
113    pub missing_epochs: usize,
114    /// Gaps detected from adjacent observation epochs and the nominal interval.
115    pub data_gaps: Vec<ObservationDataGap>,
116    /// Millisecond-scale receiver-clock jumps detected from epoch clock offsets.
117    pub clock_jumps: Vec<ClockJump>,
118    /// Aggregate dual-frequency cycle-slip counts.
119    pub cycle_slips: CycleSlipQc,
120    /// MP1/MP2 teqc moving-average multipath RMS.
121    pub multipath: MultipathReport,
122    /// Per-constellation observation completeness and gap rollups.
123    pub systems: Vec<SystemObservationQc>,
124    /// Per-satellite observation completeness.
125    pub satellites: Vec<SatelliteObservationQc>,
126    /// Per-satellite, per-code observation completeness and SSI statistics.
127    pub satellite_signals: Vec<SatelliteSignalQc>,
128    /// Per-system, per-code observation completeness and SSI statistics.
129    pub system_signals: Vec<SystemSignalQc>,
130    /// RINEX lint findings summarized for report rendering.
131    pub lint_findings: Vec<ObservationQcFinding>,
132    /// Non-fatal QC notes.
133    pub notes: Vec<ObservationQcNote>,
134}
135
136/// Source observation header fields rendered in the QC report.
137#[derive(Debug, Clone, PartialEq, serde::Serialize)]
138pub struct ObservationQcHeader {
139    /// Marker name.
140    pub marker_name: Option<String>,
141    /// Marker number.
142    pub marker_number: Option<String>,
143    /// Marker type.
144    pub marker_type: Option<String>,
145    /// Receiver information.
146    pub receiver: Option<ObservationQcReceiver>,
147    /// Antenna information.
148    pub antenna: Option<ObservationQcAntenna>,
149    /// Approximate receiver ECEF position, meters.
150    pub approx_position_m: Option<[f64; 3]>,
151    /// Antenna height/east/north offset, meters.
152    pub antenna_delta_hen_m: Option<[f64; 3]>,
153    /// Time of first observation.
154    pub time_of_first_obs: Option<ObservationQcTime>,
155    /// Time of last observation.
156    pub time_of_last_obs: Option<ObservationQcTime>,
157    /// Duration covered by retained observation epochs, seconds.
158    pub duration_s: Option<f64>,
159}
160
161/// Receiver identity fields copied from `REC # / TYPE / VERS`.
162#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
163pub struct ObservationQcReceiver {
164    /// Receiver serial number.
165    pub number: String,
166    /// Receiver type.
167    pub receiver_type: String,
168    /// Receiver firmware/version.
169    pub version: String,
170}
171
172/// Antenna identity fields copied from `ANT # / TYPE`.
173#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
174pub struct ObservationQcAntenna {
175    /// Antenna serial number.
176    pub number: String,
177    /// Antenna type.
178    pub antenna_type: String,
179}
180
181/// Observation epoch with an optional RINEX time-system label.
182#[derive(Debug, Clone, PartialEq, serde::Serialize)]
183pub struct ObservationQcTime {
184    /// Civil epoch.
185    pub epoch: ObsEpochTime,
186    /// RINEX time-system label, when declared in the source header.
187    pub time_scale: Option<String>,
188}
189
190/// Compact lint finding retained by the QC report.
191#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
192pub struct ObservationQcFinding {
193    /// Stable finding code.
194    pub code: String,
195    /// Finding severity.
196    pub severity: Severity,
197    /// RINEX specification or QC policy reference.
198    pub spec_ref: String,
199}
200
201/// One detected gap between adjacent observation epochs.
202#[derive(Debug, Clone, PartialEq, serde::Serialize)]
203pub struct ObservationDataGap {
204    /// Epoch immediately before the gap.
205    pub start_epoch: ObsEpochTime,
206    /// Epoch immediately after the gap.
207    pub end_epoch: ObsEpochTime,
208    /// Nominal interval used for the estimate.
209    pub nominal_interval_s: f64,
210    /// Observed delta between the two retained epochs.
211    pub observed_delta_s: f64,
212    /// Estimated missing nominal epochs between `start_epoch` and `end_epoch`.
213    pub missing_epochs: usize,
214}
215
216/// One detected receiver-clock jump.
217#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
218pub struct ClockJump {
219    /// Epoch index in the parsed observation stream where the jump appears.
220    pub epoch_index: usize,
221    /// Epoch where the jump appears.
222    pub epoch: ObsEpochTime,
223    /// De-trended receiver-clock step, in seconds.
224    pub delta_s: f64,
225}
226
227/// Aggregate cycle-slip counts from the dual-frequency detector.
228#[derive(Debug, Clone, PartialEq, Default, serde::Serialize)]
229pub struct CycleSlipQc {
230    /// Total dual-frequency observations passed to the detector.
231    pub observations: usize,
232    /// Total detector-flagged cycle slips.
233    pub total_slips: usize,
234    /// Dual-frequency observations per flagged slip.
235    pub observations_per_slip: Option<f64>,
236    /// Per-constellation detector counts.
237    pub by_system: Vec<SystemCycleSlipQc>,
238}
239
240/// Per-constellation cycle-slip counts.
241#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
242pub struct SystemCycleSlipQc {
243    /// GNSS constellation.
244    pub system: GnssSystem,
245    /// Dual-frequency observations passed to the detector for this constellation.
246    pub observations: usize,
247    /// Detector-flagged cycle slips for this constellation.
248    pub slips: usize,
249    /// Dual-frequency observations per flagged slip for this constellation.
250    pub observations_per_slip: Option<f64>,
251}
252
253/// Per-constellation observation completeness and gap counts.
254#[derive(Debug, Clone, PartialEq, serde::Serialize)]
255pub struct SystemObservationQc {
256    /// GNSS constellation.
257    pub system: GnssSystem,
258    /// Distinct satellites with at least one non-blank observation.
259    pub satellites_seen: usize,
260    /// Epochs where this constellation has at least one non-blank observation.
261    pub epochs_with_observations: usize,
262    /// Non-blank observation values.
263    pub value_observations: usize,
264    /// Retained observation slots in satellite records for this constellation.
265    pub expected_observations: usize,
266    /// `value_observations / expected_observations`, when defined.
267    pub completeness_ratio: Option<f64>,
268    /// Number of per-constellation data gaps.
269    pub gap_count: usize,
270    /// Missing nominal time across per-constellation gaps, seconds.
271    pub total_gap_s: f64,
272}
273
274/// Per-satellite observation counts.
275#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
276pub struct SatelliteObservationQc {
277    /// Satellite id.
278    pub satellite: GnssSatelliteId,
279    /// Epochs where the satellite has at least one non-blank observation value.
280    pub epochs_with_observations: usize,
281    /// Non-blank observation values across all codes and observation epochs.
282    pub value_observations: usize,
283}
284
285/// Per-satellite, per-observation-code counts.
286#[derive(Debug, Clone, PartialEq, serde::Serialize)]
287pub struct SatelliteSignalQc {
288    /// Satellite id.
289    pub satellite: GnssSatelliteId,
290    /// RINEX observation code, e.g. `C1C`, `L1C`, or `S1C`.
291    pub code: String,
292    /// Non-blank values for this satellite/code pair.
293    pub value_observations: usize,
294    /// Signal-strength indicator statistics for non-blank values that carried
295    /// an SSI digit.
296    pub ssi: Option<SsiHistogram>,
297    /// Raw S-code statistics when this code is an `S*` observable.
298    pub snr: Option<SnrStats>,
299}
300
301/// Per-system, per-observation-code counts.
302#[derive(Debug, Clone, PartialEq, serde::Serialize)]
303pub struct SystemSignalQc {
304    /// GNSS constellation.
305    pub system: GnssSystem,
306    /// RINEX observation code, e.g. `C1C`, `L1C`, or `S1C`.
307    pub code: String,
308    /// Non-blank values for this system/code pair across satellites.
309    pub value_observations: usize,
310    /// Signal-strength indicator statistics for non-blank values that carried
311    /// an SSI digit.
312    pub ssi: Option<SsiHistogram>,
313    /// Raw S-code statistics when this code is an `S*` observable.
314    pub snr: Option<SnrStats>,
315}
316
317/// Histogram over RINEX SSI digits. Index 0 is blank/unknown.
318#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
319pub struct SsiHistogram {
320    /// Counts indexed by SSI digit.
321    pub counts: [u64; 10],
322}
323
324/// Summary statistics over raw numeric `S*` signal-strength observations.
325#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
326pub struct SnrStats {
327    /// Number of samples.
328    pub n: usize,
329    /// Arithmetic mean.
330    pub mean: f64,
331    /// Minimum sample.
332    pub min: f64,
333    /// Maximum sample.
334    pub max: f64,
335    /// Sample standard deviation, absent for one sample.
336    pub std: Option<f64>,
337}
338
339/// Build a QC report with default options.
340///
341/// A zero, negative, or non-finite source `INTERVAL` is never used as cadence.
342/// The report carries the corresponding lint finding and either infers cadence
343/// from finite positive epoch deltas or marks it [`IntervalSource::Unresolved`].
344pub fn observation_qc(obs: &RinexObs) -> ObservationQcReport {
345    observation_qc_validated(obs, ObservationQcOptions::default())
346}
347
348/// Build a QC report with explicit options.
349///
350/// Source metadata follows the same fail-visible behavior as
351/// [`observation_qc`]. An unusable caller-supplied interval override is instead
352/// rejected as [`ObservationQcError::InvalidInterval`].
353pub fn observation_qc_with_options(
354    obs: &RinexObs,
355    options: ObservationQcOptions,
356) -> Result<ObservationQcReport, ObservationQcError> {
357    validate_options(options)?;
358    Ok(observation_qc_validated(obs, options))
359}
360
361fn observation_qc_validated(obs: &RinexObs, options: ObservationQcOptions) -> ObservationQcReport {
362    let mut satellites: BTreeMap<GnssSatelliteId, SatelliteAccum> = BTreeMap::new();
363    let mut systems: BTreeMap<GnssSystem, SystemObservationAccum> = BTreeMap::new();
364    let mut satellite_signals: BTreeMap<(GnssSatelliteId, String), SignalAccum> = BTreeMap::new();
365    let mut system_signals: BTreeMap<(GnssSystem, String), SignalAccum> = BTreeMap::new();
366    let mut observation_epoch_times = Vec::new();
367    let mut system_epoch_times: BTreeMap<GnssSystem, Vec<ObsEpochTime>> = BTreeMap::new();
368
369    let mut observation_epochs = 0;
370    let mut event_records = 0;
371    let mut power_failure_epochs = 0;
372
373    for epoch in obs.epochs() {
374        if epoch.flag > 1 {
375            event_records += 1;
376            continue;
377        }
378
379        observation_epochs += 1;
380        if epoch.flag == 1 {
381            power_failure_epochs += 1;
382        }
383        observation_epoch_times.push(epoch.epoch);
384
385        let mut epoch_systems = BTreeSet::new();
386        for (satellite, values) in &epoch.sats {
387            let value_observations = values.iter().filter(|value| value.value.is_some()).count();
388            let system_acc = systems.entry(satellite.system).or_default();
389            system_acc.expected_observations += values.len();
390            system_acc.value_observations += value_observations;
391
392            if value_observations == 0 {
393                continue;
394            }
395
396            system_acc.satellites.insert(*satellite);
397            epoch_systems.insert(satellite.system);
398
399            let satellite_acc = satellites.entry(*satellite).or_default();
400            satellite_acc.epochs_with_observations += 1;
401            satellite_acc.value_observations += value_observations;
402
403            let Some(codes) = obs.header().obs_codes.get(&satellite.system) else {
404                continue;
405            };
406
407            for (index, value) in values.iter().enumerate() {
408                if value.value.is_none() {
409                    continue;
410                }
411
412                let Some(code) = codes.get(index) else {
413                    continue;
414                };
415
416                let sat_signal = satellite_signals
417                    .entry((*satellite, code.clone()))
418                    .or_default();
419                sat_signal.add(code, value.value, value.ssi);
420
421                let sys_signal = system_signals
422                    .entry((satellite.system, code.clone()))
423                    .or_default();
424                sys_signal.add(code, value.value, value.ssi);
425            }
426        }
427
428        for system in epoch_systems {
429            systems.entry(system).or_default().epochs_with_observations += 1;
430            system_epoch_times
431                .entry(system)
432                .or_default()
433                .push(epoch.epoch);
434        }
435    }
436
437    let mut notes = non_monotonic_notes(&observation_epoch_times);
438    let (interval_s, interval_source) =
439        resolve_interval(obs, options, &observation_epoch_times, &mut notes);
440    let data_gaps = detect_gaps(options, &observation_epoch_times, interval_s);
441    let missing_epochs = data_gaps
442        .iter()
443        .map(|gap| gap.missing_epochs)
444        .fold(0_usize, usize::saturating_add);
445    let clock_jumps = detect_clock_jumps(obs, options.clock_jump_threshold_s);
446    let cycle_slips = aggregate_cycle_slips(obs);
447    let multipath = multipath_stats(obs, &CycleSlipConfig::default());
448    let systems = finish_system_observation_qc(systems, &system_epoch_times, options, interval_s);
449
450    ObservationQcReport {
451        header: observation_qc_header(obs, &observation_epoch_times),
452        total_epoch_records: obs.epochs().len(),
453        observation_epochs,
454        event_records,
455        power_failure_epochs,
456        skipped_records: obs.skipped_records,
457        interval_s,
458        interval_source,
459        missing_epochs,
460        data_gaps,
461        clock_jumps,
462        cycle_slips,
463        multipath,
464        systems,
465        satellites: satellites
466            .into_iter()
467            .map(|(satellite, acc)| SatelliteObservationQc {
468                satellite,
469                epochs_with_observations: acc.epochs_with_observations,
470                value_observations: acc.value_observations,
471            })
472            .collect(),
473        satellite_signals: satellite_signals
474            .into_iter()
475            .map(|((satellite, code), acc)| SatelliteSignalQc {
476                satellite,
477                code,
478                value_observations: acc.value_observations,
479                ssi: acc.ssi.finish(),
480                snr: acc.snr.finish(),
481            })
482            .collect(),
483        system_signals: system_signals
484            .into_iter()
485            .map(|((system, code), acc)| SystemSignalQc {
486                system,
487                code,
488                value_observations: acc.value_observations,
489                ssi: acc.ssi.finish(),
490                snr: acc.snr.finish(),
491            })
492            .collect(),
493        lint_findings: observation_qc_findings(obs),
494        notes,
495    }
496}
497
498fn validate_options(options: ObservationQcOptions) -> Result<(), ObservationQcError> {
499    if !options.gap_factor.is_finite() || options.gap_factor <= 1.0 {
500        return Err(ObservationQcError::InvalidGapFactor);
501    }
502    if !positive_finite(options.clock_jump_threshold_s) {
503        return Err(ObservationQcError::InvalidClockJumpThreshold);
504    }
505
506    if let Some(interval_s) = options.interval_override_s {
507        validate_interval(interval_s)?;
508    }
509
510    Ok(())
511}
512
513fn validate_interval(interval_s: f64) -> Result<(), ObservationQcError> {
514    if usable_obs_interval_s(interval_s) {
515        Ok(())
516    } else {
517        Err(ObservationQcError::InvalidInterval)
518    }
519}
520
521fn positive_finite(value: f64) -> bool {
522    value.is_finite() && value > 0.0
523}
524
525fn observation_qc_header(
526    obs: &RinexObs,
527    observation_epoch_times: &[ObsEpochTime],
528) -> ObservationQcHeader {
529    let header = obs.header();
530    let time_of_first_obs = header
531        .time_of_first_obs
532        .map(stamped_declared_time)
533        .or_else(|| observation_epoch_times.first().copied().map(unstamped_time));
534    let time_of_last_obs = header
535        .time_of_last_obs
536        .map(stamped_declared_time)
537        .or_else(|| observation_epoch_times.last().copied().map(unstamped_time));
538    let duration_s = observation_epoch_times
539        .first()
540        .zip(observation_epoch_times.last())
541        .map(|(first, last)| obs_epoch_seconds(*last) - obs_epoch_seconds(*first))
542        .filter(|duration_s| duration_s.is_finite() && *duration_s >= 0.0);
543
544    ObservationQcHeader {
545        marker_name: header.marker_name.clone(),
546        marker_number: header.marker_number.clone(),
547        marker_type: header.marker_type.clone(),
548        receiver: header
549            .receiver
550            .as_ref()
551            .map(|receiver| ObservationQcReceiver {
552                number: receiver.number.clone(),
553                receiver_type: receiver.receiver_type.clone(),
554                version: receiver.version.clone(),
555            }),
556        antenna: header.antenna.as_ref().map(|antenna| ObservationQcAntenna {
557            number: antenna.number.clone(),
558            antenna_type: antenna.antenna_type.clone(),
559        }),
560        approx_position_m: header.approx_position_m,
561        antenna_delta_hen_m: header.antenna_delta_hen_m,
562        time_of_first_obs,
563        time_of_last_obs,
564        duration_s,
565    }
566}
567
568fn stamped_declared_time(
569    (epoch, scale): (ObsEpochTime, crate::astro::time::model::TimeScale),
570) -> ObservationQcTime {
571    ObservationQcTime {
572        epoch,
573        time_scale: time_scale_rinex_label(scale).map(str::to_string),
574    }
575}
576
577fn unstamped_time(epoch: ObsEpochTime) -> ObservationQcTime {
578    ObservationQcTime {
579        epoch,
580        time_scale: None,
581    }
582}
583
584fn finish_system_observation_qc(
585    systems: BTreeMap<GnssSystem, SystemObservationAccum>,
586    system_epoch_times: &BTreeMap<GnssSystem, Vec<ObsEpochTime>>,
587    options: ObservationQcOptions,
588    interval_s: Option<f64>,
589) -> Vec<SystemObservationQc> {
590    systems
591        .into_iter()
592        .map(|(system, acc)| {
593            let times = system_epoch_times
594                .get(&system)
595                .map(Vec::as_slice)
596                .unwrap_or(&[]);
597            let gaps = detect_gaps(options, times, interval_s);
598            let total_gap_s = gaps
599                .iter()
600                .map(|gap| gap.missing_epochs as f64 * gap.nominal_interval_s)
601                .sum::<f64>();
602            let total_gap_s = if total_gap_s == 0.0 { 0.0 } else { total_gap_s };
603            SystemObservationQc {
604                system,
605                satellites_seen: acc.satellites.len(),
606                epochs_with_observations: acc.epochs_with_observations,
607                value_observations: acc.value_observations,
608                expected_observations: acc.expected_observations,
609                completeness_ratio: (acc.expected_observations > 0)
610                    .then(|| acc.value_observations as f64 / acc.expected_observations as f64),
611                gap_count: gaps.len(),
612                total_gap_s,
613            }
614        })
615        .collect()
616}
617
618fn observation_qc_findings(obs: &RinexObs) -> Vec<ObservationQcFinding> {
619    lint_obs(obs)
620        .findings
621        .into_iter()
622        .map(|finding| ObservationQcFinding {
623            code: finding.code().to_string(),
624            severity: finding.severity(),
625            spec_ref: finding.spec_ref().to_string(),
626        })
627        .collect()
628}
629
630fn resolve_interval(
631    obs: &RinexObs,
632    options: ObservationQcOptions,
633    observation_epoch_times: &[ObsEpochTime],
634    notes: &mut Vec<ObservationQcNote>,
635) -> (Option<f64>, IntervalSource) {
636    let Some(interval_s) = options.interval_override_s else {
637        if let Some(interval_s) = obs
638            .header()
639            .interval_s
640            .filter(|interval_s| usable_obs_interval_s(*interval_s))
641        {
642            return (Some(interval_s), IntervalSource::Header);
643        }
644        if let Some(interval_s) = dominant_obs_interval_s(observation_epoch_times) {
645            return (Some(interval_s), IntervalSource::Inferred);
646        }
647        notes.push(ObservationQcNote::IntervalUnresolved);
648        return (None, IntervalSource::Unresolved);
649    };
650    (Some(interval_s), IntervalSource::Override)
651}
652
653fn detect_gaps(
654    options: ObservationQcOptions,
655    observation_epoch_times: &[ObsEpochTime],
656    interval_s: Option<f64>,
657) -> Vec<ObservationDataGap> {
658    let Some(interval_s) = interval_s else {
659        return Vec::new();
660    };
661
662    let mut gaps = Vec::new();
663    for window in observation_epoch_times.windows(2) {
664        let start_epoch = window[0];
665        let end_epoch = window[1];
666        let observed_delta_s = obs_epoch_seconds(end_epoch) - obs_epoch_seconds(start_epoch);
667        if !observed_delta_s.is_finite()
668            || observed_delta_s <= 0.0
669            || observed_delta_s <= interval_s * options.gap_factor
670        {
671            continue;
672        }
673
674        let missing_epochs = ((observed_delta_s / interval_s).round() - 1.0).max(0.0) as usize;
675        gaps.push(ObservationDataGap {
676            start_epoch,
677            end_epoch,
678            nominal_interval_s: interval_s,
679            observed_delta_s,
680            missing_epochs,
681        });
682    }
683
684    gaps
685}
686
687fn non_monotonic_notes(observation_epoch_times: &[ObsEpochTime]) -> Vec<ObservationQcNote> {
688    let mut notes = Vec::new();
689    for (idx, window) in observation_epoch_times.windows(2).enumerate() {
690        if obs_epoch_seconds(window[1]) - obs_epoch_seconds(window[0]) <= 0.0 {
691            notes.push(ObservationQcNote::NonMonotonicEpoch {
692                epoch_index: idx + 1,
693            });
694        }
695    }
696    notes
697}
698
699/// Detect millisecond-scale receiver-clock jumps from RINEX epoch clock offsets.
700pub fn detect_clock_jumps(obs: &RinexObs, threshold_s: f64) -> Vec<ClockJump> {
701    if !positive_finite(threshold_s) {
702        return Vec::new();
703    }
704
705    let deltas = clock_offset_deltas(obs);
706    let nominal_drift_s_per_s = nominal_clock_drift_s_per_s(&deltas, threshold_s);
707
708    deltas
709        .into_iter()
710        .filter_map(|delta| {
711            let expected_delta_s = nominal_drift_s_per_s * delta.time_delta_s;
712            let adjusted_delta_s = delta.raw_delta_s - expected_delta_s;
713            millisecond_clock_step(adjusted_delta_s, threshold_s).then_some(ClockJump {
714                epoch_index: delta.epoch_index,
715                epoch: delta.epoch,
716                delta_s: adjusted_delta_s,
717            })
718        })
719        .collect()
720}
721
722/// Aggregate cycle-slip counts by reusing the dual-frequency detector.
723pub fn aggregate_cycle_slips(obs: &RinexObs) -> CycleSlipQc {
724    let epochs = dual_frequency_epochs(obs);
725    let mut by_system = BTreeMap::<GnssSystem, CycleSlipAccum>::new();
726
727    for epoch in &epochs {
728        for observation in &epoch.observations {
729            if let Some(system) = system_from_satellite_token(&observation.satellite_id) {
730                by_system.entry(system).or_default().observations += 1;
731            }
732        }
733    }
734
735    let Ok(flags) = detect_dual_frequency_cycle_slips(&epochs, CycleSlipConfig::default()) else {
736        return finish_cycle_slip_qc(by_system);
737    };
738
739    for epoch in flags {
740        for observation in epoch.observations {
741            if !observation.slip {
742                continue;
743            }
744            if let Some(system) = system_from_satellite_token(&observation.satellite_id) {
745                by_system.entry(system).or_default().slips += 1;
746            }
747        }
748    }
749
750    finish_cycle_slip_qc(by_system)
751}
752
753#[derive(Debug, Clone, Copy)]
754struct ClockOffsetSample {
755    epoch_index: usize,
756    epoch: ObsEpochTime,
757    epoch_time_s: f64,
758    offset_s: f64,
759}
760
761#[derive(Debug, Clone, Copy)]
762struct ClockOffsetDelta {
763    epoch_index: usize,
764    epoch: ObsEpochTime,
765    time_delta_s: f64,
766    raw_delta_s: f64,
767}
768
769fn clock_offset_deltas(obs: &RinexObs) -> Vec<ClockOffsetDelta> {
770    let mut previous: Option<ClockOffsetSample> = None;
771    let mut deltas = Vec::new();
772
773    for (epoch_index, epoch) in obs.epochs().iter().enumerate() {
774        if epoch.flag > 1 {
775            continue;
776        }
777        let Some(offset_s) = epoch.rcv_clock_offset_s else {
778            continue;
779        };
780        if !offset_s.is_finite() {
781            continue;
782        }
783
784        let sample = ClockOffsetSample {
785            epoch_index,
786            epoch: epoch.epoch,
787            epoch_time_s: obs_epoch_seconds(epoch.epoch),
788            offset_s,
789        };
790
791        if let Some(prev) = previous {
792            let time_delta_s = sample.epoch_time_s - prev.epoch_time_s;
793            if time_delta_s > 0.0 {
794                deltas.push(ClockOffsetDelta {
795                    epoch_index: sample.epoch_index,
796                    epoch: sample.epoch,
797                    time_delta_s,
798                    raw_delta_s: sample.offset_s - prev.offset_s,
799                });
800            }
801        }
802
803        previous = Some(sample);
804    }
805
806    deltas
807}
808
809fn nominal_clock_drift_s_per_s(deltas: &[ClockOffsetDelta], threshold_s: f64) -> f64 {
810    let mut slopes = deltas
811        .iter()
812        .filter(|delta| delta.raw_delta_s.abs() < threshold_s)
813        .map(|delta| delta.raw_delta_s / delta.time_delta_s)
814        .filter(|slope| slope.is_finite())
815        .collect::<Vec<_>>();
816    median(&mut slopes).unwrap_or(0.0)
817}
818
819fn median(values: &mut [f64]) -> Option<f64> {
820    crate::astro::math::robust::median_sorting_in_place(values)
821}
822
823fn millisecond_clock_step(delta_s: f64, threshold_s: f64) -> bool {
824    if !delta_s.is_finite() || delta_s.abs() < threshold_s {
825        return false;
826    }
827
828    let step_ms = delta_s.abs() * 1000.0;
829    let nearest_ms = step_ms.round();
830    if nearest_ms < 1.0 {
831        return false;
832    }
833
834    let tolerance_ms = (threshold_s * 500.0).min(0.25);
835    (step_ms - nearest_ms).abs() <= tolerance_ms
836}
837
838#[derive(Debug, Clone, Copy, Default)]
839struct CycleSlipAccum {
840    observations: usize,
841    slips: usize,
842}
843
844fn finish_cycle_slip_qc(by_system: BTreeMap<GnssSystem, CycleSlipAccum>) -> CycleSlipQc {
845    let observations = by_system.values().map(|acc| acc.observations).sum();
846    let total_slips = by_system.values().map(|acc| acc.slips).sum();
847    let by_system = by_system
848        .into_iter()
849        .map(|(system, acc)| SystemCycleSlipQc {
850            system,
851            observations: acc.observations,
852            slips: acc.slips,
853            observations_per_slip: observations_per_slip(acc.observations, acc.slips),
854        })
855        .collect();
856
857    CycleSlipQc {
858        observations,
859        total_slips,
860        observations_per_slip: observations_per_slip(observations, total_slips),
861        by_system,
862    }
863}
864
865fn observations_per_slip(observations: usize, slips: usize) -> Option<f64> {
866    (slips > 0).then(|| observations as f64 / slips as f64)
867}
868
869fn dual_frequency_epochs(obs: &RinexObs) -> Vec<DualFrequencyEpoch> {
870    obs.epochs()
871        .iter()
872        .filter(|epoch| epoch.flag <= 1)
873        .map(|epoch| DualFrequencyEpoch {
874            gap_time_s: Some(obs_epoch_seconds(epoch.epoch)),
875            observations: epoch
876                .sats
877                .iter()
878                .filter_map(|(satellite, values)| {
879                    dual_frequency_observation(obs, *satellite, values)
880                })
881                .collect(),
882        })
883        .collect()
884}
885
886#[derive(Debug, Clone, Copy)]
887struct DualFrequencyBand {
888    first_index: usize,
889    rinex_band: char,
890    frequency_hz: f64,
891    pseudorange_m: Option<f64>,
892    pseudorange_rank: u8,
893    carrier_phase_cyc: Option<f64>,
894    lli: Option<i64>,
895}
896
897fn dual_frequency_observation(
898    obs: &RinexObs,
899    satellite: GnssSatelliteId,
900    values: &[crate::rinex::observations::ObsValue],
901) -> Option<DualFrequencyObservation> {
902    dual_frequency_observation_with_pseudorange_selection(
903        obs,
904        satellite,
905        values,
906        PseudorangeSelection::HeaderOrder,
907    )
908}
909
910fn multipath_dual_frequency_observation(
911    obs: &RinexObs,
912    satellite: GnssSatelliteId,
913    values: &[crate::rinex::observations::ObsValue],
914) -> Option<DualFrequencyObservation> {
915    dual_frequency_observation_with_pseudorange_selection(
916        obs,
917        satellite,
918        values,
919        PseudorangeSelection::PreferPreciseCode,
920    )
921}
922
923#[derive(Debug, Clone, Copy)]
924enum PseudorangeSelection {
925    HeaderOrder,
926    PreferPreciseCode,
927}
928
929fn dual_frequency_observation_with_pseudorange_selection(
930    obs: &RinexObs,
931    satellite: GnssSatelliteId,
932    values: &[crate::rinex::observations::ObsValue],
933    pseudorange_selection: PseudorangeSelection,
934) -> Option<DualFrequencyObservation> {
935    let codes = obs.header().obs_codes.get(&satellite.system)?;
936    let glonass_channel = obs.header().glonass_slots.get(&satellite.prn).copied();
937    let mut bands = Vec::<DualFrequencyBand>::new();
938
939    for (index, (code, value)) in codes.iter().zip(values.iter()).enumerate() {
940        let kind = code.as_bytes().first().copied();
941        if !matches!(kind, Some(b'C' | b'L')) {
942            continue;
943        }
944        let rinex_band = code.chars().nth(1)?;
945        let Some(raw_value) = value.value else {
946            continue;
947        };
948        if !raw_value.is_finite() {
949            continue;
950        }
951        let frequency_hz = rinex_observation_frequency_hz(
952            satellite.system,
953            code,
954            obs.header().version,
955            glonass_channel,
956        )?;
957
958        let band_index = if let Some(existing) = bands
959            .iter()
960            .position(|band| same_frequency_hz(band.frequency_hz, frequency_hz))
961        {
962            existing
963        } else {
964            bands.push(DualFrequencyBand {
965                first_index: index,
966                rinex_band,
967                frequency_hz,
968                pseudorange_m: None,
969                pseudorange_rank: u8::MAX,
970                carrier_phase_cyc: None,
971                lli: None,
972            });
973            bands.len() - 1
974        };
975
976        let band = &mut bands[band_index];
977        match kind {
978            Some(b'C') if pseudorange_rank(code, pseudorange_selection) < band.pseudorange_rank => {
979                band.pseudorange_rank = pseudorange_rank(code, pseudorange_selection);
980                band.pseudorange_m = Some(raw_value);
981            }
982            Some(b'L') if band.carrier_phase_cyc.is_none() => {
983                band.carrier_phase_cyc = Some(raw_value);
984                band.lli = value.lli.map(i64::from);
985            }
986            _ => {}
987        }
988    }
989
990    let mut usable = bands
991        .into_iter()
992        .filter(|band| band.pseudorange_m.is_some() && band.carrier_phase_cyc.is_some())
993        .collect::<Vec<_>>();
994    let (band1, band2) = select_dual_frequency_bands(satellite.system, &mut usable)?;
995
996    Some(DualFrequencyObservation {
997        satellite_id: satellite.to_string(),
998        ambiguity_id: format!(
999            "{}:{:.0}:{:.0}",
1000            satellite, band1.frequency_hz, band2.frequency_hz
1001        ),
1002        p1_m: band1.pseudorange_m?,
1003        p2_m: band2.pseudorange_m?,
1004        phi1_cyc: band1.carrier_phase_cyc?,
1005        phi2_cyc: band2.carrier_phase_cyc?,
1006        f1_hz: band1.frequency_hz,
1007        f2_hz: band2.frequency_hz,
1008        lli1: band1.lli,
1009        lli2: band2.lli,
1010    })
1011}
1012
1013fn select_dual_frequency_bands(
1014    system: GnssSystem,
1015    usable: &mut [DualFrequencyBand],
1016) -> Option<(DualFrequencyBand, DualFrequencyBand)> {
1017    if let Some(pair) = default_iono_free_pair(system) {
1018        let pair_f1_hz = frequency_hz(system, pair.band1)?;
1019        let pair_f2_hz = frequency_hz(system, pair.band2)?;
1020        let band1 = usable
1021            .iter()
1022            .copied()
1023            .find(|band| same_frequency_hz(band.frequency_hz, pair_f1_hz));
1024        let band2 = usable
1025            .iter()
1026            .copied()
1027            .find(|band| same_frequency_hz(band.frequency_hz, pair_f2_hz));
1028        if let (Some(band1), Some(band2)) = (band1, band2) {
1029            return Some((band1, band2));
1030        }
1031    }
1032
1033    usable.sort_by_key(|band| (rinex_band_sort_key(band.rinex_band), band.first_index));
1034    let band1 = *usable.first()?;
1035    let band2 = usable
1036        .iter()
1037        .copied()
1038        .find(|band| !same_frequency_hz(band.frequency_hz, band1.frequency_hz))?;
1039    Some((band1, band2))
1040}
1041
1042fn rinex_band_sort_key(band: char) -> u32 {
1043    band.to_digit(10).unwrap_or(u32::MAX)
1044}
1045
1046fn pseudorange_rank(code: &str, selection: PseudorangeSelection) -> u8 {
1047    match selection {
1048        PseudorangeSelection::HeaderOrder => 0,
1049        PseudorangeSelection::PreferPreciseCode => pseudorange_preference_rank(code),
1050    }
1051}
1052
1053fn pseudorange_preference_rank(code: &str) -> u8 {
1054    match code.chars().nth(2) {
1055        Some('W' | 'P' | 'Y' | 'M' | 'N') => 0,
1056        Some('X') => 1,
1057        Some('C' | 'S' | 'L') => 2,
1058        Some(_) => 3,
1059        None => 4,
1060    }
1061}
1062
1063fn same_frequency_hz(a: f64, b: f64) -> bool {
1064    (a - b).abs() <= 1.0e-3
1065}
1066
1067fn system_from_satellite_token(satellite_id: &str) -> Option<GnssSystem> {
1068    satellite_id
1069        .chars()
1070        .next()
1071        .and_then(GnssSystem::from_letter)
1072}
1073
1074#[derive(Debug, Default)]
1075struct SystemObservationAccum {
1076    satellites: BTreeSet<GnssSatelliteId>,
1077    epochs_with_observations: usize,
1078    value_observations: usize,
1079    expected_observations: usize,
1080}
1081
1082#[derive(Debug, Default)]
1083struct SatelliteAccum {
1084    epochs_with_observations: usize,
1085    value_observations: usize,
1086}
1087
1088#[derive(Debug, Default)]
1089struct SignalAccum {
1090    value_observations: usize,
1091    ssi: SsiAccum,
1092    snr: SnrAccum,
1093}
1094
1095impl SignalAccum {
1096    fn add(&mut self, code: &str, value: Option<f64>, ssi: Option<u8>) {
1097        self.value_observations += 1;
1098        self.ssi.add(ssi);
1099        if code.starts_with('S') {
1100            if let Some(value) = value {
1101                self.snr.add(value);
1102            }
1103        }
1104    }
1105}
1106
1107#[derive(Debug, Default)]
1108struct SsiAccum {
1109    counts: [u64; 10],
1110}
1111
1112impl SsiAccum {
1113    fn add(&mut self, value: Option<u8>) {
1114        let idx = value.unwrap_or(0).min(9) as usize;
1115        self.counts[idx] += 1;
1116    }
1117
1118    fn finish(self) -> Option<SsiHistogram> {
1119        if self.counts.iter().all(|count| *count == 0) {
1120            return None;
1121        }
1122
1123        Some(SsiHistogram {
1124            counts: self.counts,
1125        })
1126    }
1127}
1128
1129#[derive(Debug, Default)]
1130struct SnrAccum {
1131    samples: Vec<f64>,
1132}
1133
1134impl SnrAccum {
1135    fn add(&mut self, value: f64) {
1136        self.samples.push(value);
1137    }
1138
1139    fn finish(self) -> Option<SnrStats> {
1140        if self.samples.is_empty() {
1141            return None;
1142        }
1143        let n = self.samples.len();
1144        let mean = self.samples.iter().sum::<f64>() / n as f64;
1145        let min = self.samples.iter().copied().fold(f64::INFINITY, f64::min);
1146        let max = self
1147            .samples
1148            .iter()
1149            .copied()
1150            .fold(f64::NEG_INFINITY, f64::max);
1151        let std = (n > 1).then(|| {
1152            let sum_sq = self
1153                .samples
1154                .iter()
1155                .map(|value| {
1156                    let residual = *value - mean;
1157                    residual * residual
1158                })
1159                .sum::<f64>();
1160            (sum_sq / (n - 1) as f64).sqrt()
1161        });
1162        Some(SnrStats {
1163            n,
1164            mean,
1165            min,
1166            max,
1167            std,
1168        })
1169    }
1170}
1171
1172#[cfg(test)]
1173mod tests {
1174    //! Teqc oracle provenance is retained in `tests/fixtures/qc/teqc_algo0010_2015001_v1_trim.json`;
1175    //! the MP regression below uses teqc 2019Feb25 on the decoded RINEX 2 fixture.
1176
1177    use super::*;
1178    use crate::constants::{C_M_S, F_L1_HZ, F_L2_HZ};
1179    use crate::crinex;
1180    use crate::rinex::observations::{ObsEpoch, ObsHeader, ObsValue};
1181    use serde_json::Value;
1182    use std::collections::BTreeMap;
1183    use std::path::PathBuf;
1184
1185    #[test]
1186    fn observation_qc_counts_epochs_satellites_signals_and_ssi() {
1187        let g01 = sat(1);
1188        let g02 = sat(2);
1189        let obs = observation_file(vec![
1190            epoch(
1191                0,
1192                0.0,
1193                0,
1194                BTreeMap::from([
1195                    (
1196                        g01,
1197                        vec![
1198                            obs_value(Some(1.0), Some(5)),
1199                            obs_value(Some(2.0), Some(6)),
1200                            obs_value(None, None),
1201                        ],
1202                    ),
1203                    (
1204                        g02,
1205                        vec![
1206                            obs_value(Some(10.0), Some(4)),
1207                            obs_value(None, None),
1208                            obs_value(None, None),
1209                        ],
1210                    ),
1211                ]),
1212            ),
1213            epoch(
1214                0,
1215                30.0,
1216                1,
1217                BTreeMap::from([(
1218                    g01,
1219                    vec![
1220                        obs_value(Some(3.0), Some(7)),
1221                        obs_value(None, None),
1222                        obs_value(Some(9.0), Some(8)),
1223                    ],
1224                )]),
1225            ),
1226            epoch(1, 0.0, 2, BTreeMap::new()),
1227        ]);
1228
1229        let report = observation_qc(&obs);
1230
1231        assert_eq!(report.total_epoch_records, 3);
1232        assert_eq!(report.observation_epochs, 2);
1233        assert_eq!(report.event_records, 1);
1234        assert_eq!(report.power_failure_epochs, 1);
1235        assert_eq!(report.skipped_records, 0);
1236        assert!(report.clock_jumps.is_empty());
1237        assert_eq!(report.cycle_slips, CycleSlipQc::default());
1238        assert_eq!(report.multipath, MultipathReport::default());
1239        assert_eq!(report.satellites.len(), 2);
1240        assert_eq!(
1241            report.satellites[0],
1242            SatelliteObservationQc {
1243                satellite: g01,
1244                epochs_with_observations: 2,
1245                value_observations: 4,
1246            }
1247        );
1248        assert_eq!(
1249            report.satellites[1],
1250            SatelliteObservationQc {
1251                satellite: g02,
1252                epochs_with_observations: 1,
1253                value_observations: 1,
1254            }
1255        );
1256
1257        let g01_c1c = report
1258            .satellite_signals
1259            .iter()
1260            .find(|signal| signal.satellite == g01 && signal.code == "C1C")
1261            .expect("G01 C1C signal");
1262        assert_eq!(g01_c1c.value_observations, 2);
1263        assert_eq!(
1264            g01_c1c.ssi,
1265            Some(SsiHistogram {
1266                counts: [0, 0, 0, 0, 0, 1, 0, 1, 0, 0],
1267            })
1268        );
1269        assert_eq!(g01_c1c.snr, None);
1270
1271        let gps_c1c = report
1272            .system_signals
1273            .iter()
1274            .find(|signal| signal.system == GnssSystem::Gps && signal.code == "C1C")
1275            .expect("GPS C1C signal");
1276        assert_eq!(gps_c1c.value_observations, 3);
1277        assert_eq!(
1278            gps_c1c.ssi,
1279            Some(SsiHistogram {
1280                counts: [0, 0, 0, 0, 1, 1, 0, 1, 0, 0],
1281            })
1282        );
1283
1284        let gps_s1c = report
1285            .system_signals
1286            .iter()
1287            .find(|signal| signal.system == GnssSystem::Gps && signal.code == "S1C")
1288            .expect("GPS S1C signal");
1289        assert_eq!(
1290            gps_s1c.snr,
1291            Some(SnrStats {
1292                n: 1,
1293                mean: 9.0,
1294                min: 9.0,
1295                max: 9.0,
1296                std: None,
1297            })
1298        );
1299    }
1300
1301    #[test]
1302    fn observation_qc_detects_nominal_interval_gaps() {
1303        let g01 = sat(1);
1304        let obs = observation_file(vec![
1305            epoch(
1306                0,
1307                0.0,
1308                0,
1309                BTreeMap::from([(g01, vec![obs_value(Some(1.0), Some(5))])]),
1310            ),
1311            epoch(
1312                1,
1313                30.0,
1314                0,
1315                BTreeMap::from([(g01, vec![obs_value(Some(2.0), Some(6))])]),
1316            ),
1317        ]);
1318
1319        let report = observation_qc(&obs);
1320
1321        assert_eq!(report.missing_epochs, 2);
1322        assert_eq!(report.data_gaps.len(), 1);
1323        assert_eq!(report.data_gaps[0].nominal_interval_s, 30.0);
1324        assert_eq!(report.data_gaps[0].observed_delta_s, 90.0);
1325        assert_eq!(report.data_gaps[0].missing_epochs, 2);
1326    }
1327
1328    #[test]
1329    fn observation_qc_infers_interval_when_header_is_absent() {
1330        let g01 = sat(1);
1331        let mut obs = observation_file(vec![
1332            epoch(
1333                0,
1334                0.0,
1335                0,
1336                BTreeMap::from([(g01, vec![obs_value(Some(1.0), Some(5))])]),
1337            ),
1338            epoch(
1339                0,
1340                30.0,
1341                0,
1342                BTreeMap::from([(g01, vec![obs_value(Some(2.0), Some(6))])]),
1343            ),
1344            epoch(
1345                2,
1346                0.0,
1347                0,
1348                BTreeMap::from([(g01, vec![obs_value(Some(3.0), Some(7))])]),
1349            ),
1350        ]);
1351        obs.header.interval_s = None;
1352
1353        let report = observation_qc(&obs);
1354
1355        assert_eq!(report.interval_s, Some(30.0));
1356        assert_eq!(report.interval_source, IntervalSource::Inferred);
1357        assert_eq!(report.missing_epochs, 2);
1358    }
1359
1360    #[test]
1361    fn observation_qc_does_not_use_zero_header_interval_as_cadence() {
1362        let g01 = sat(1);
1363        let observations = BTreeMap::from([(g01, vec![obs_value(Some(1.0), Some(5))])]);
1364        let mut obs = observation_file(vec![
1365            epoch(0, 0.0, 0, observations.clone()),
1366            epoch(0, 30.0, 0, observations.clone()),
1367            epoch(1, 0.0, 0, observations.clone()),
1368            epoch(2, 30.0, 0, observations),
1369        ]);
1370        obs.header.interval_s = Some(0.0);
1371
1372        let report = observation_qc(&obs);
1373
1374        assert_eq!(report.interval_s, Some(30.0));
1375        assert_eq!(report.interval_source, IntervalSource::Inferred);
1376        assert_eq!(report.missing_epochs, 2);
1377        assert_eq!(report.data_gaps.len(), 1);
1378        assert!(report
1379            .lint_findings
1380            .iter()
1381            .any(|finding| { finding.code == "OBS-H19" && finding.severity == Severity::Info }));
1382    }
1383
1384    #[test]
1385    fn observation_qc_reports_unresolved_zero_header_interval_without_calculating_gaps() {
1386        let mut obs = observation_file(Vec::new());
1387        obs.header.interval_s = Some(-0.0);
1388
1389        let report = observation_qc(&obs);
1390
1391        assert_eq!(report.interval_s, None);
1392        assert_eq!(report.interval_source, IntervalSource::Unresolved);
1393        assert!(report.data_gaps.is_empty());
1394        assert_eq!(report.missing_epochs, 0);
1395        assert!(report
1396            .notes
1397            .contains(&ObservationQcNote::IntervalUnresolved));
1398        assert!(report
1399            .lint_findings
1400            .iter()
1401            .any(|finding| { finding.code == "OBS-H19" && finding.severity == Severity::Info }));
1402    }
1403
1404    #[test]
1405    fn observation_qc_ignores_and_reports_invalid_source_intervals() {
1406        let g01 = sat(1);
1407        let original = observation_file(vec![
1408            epoch(
1409                0,
1410                0.0,
1411                0,
1412                BTreeMap::from([(g01, vec![obs_value(Some(1.0), Some(5))])]),
1413            ),
1414            epoch(
1415                0,
1416                30.0,
1417                0,
1418                BTreeMap::from([(g01, vec![obs_value(Some(2.0), Some(6))])]),
1419            ),
1420        ]);
1421
1422        for invalid in [-1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1423            let mut obs = original.clone();
1424            obs.header.interval_s = Some(invalid);
1425
1426            let report = observation_qc(&obs);
1427
1428            assert_eq!(report.interval_s, Some(30.0), "{invalid:?}");
1429            assert_eq!(
1430                report.interval_source,
1431                IntervalSource::Inferred,
1432                "{invalid:?}"
1433            );
1434            assert!(report.lint_findings.iter().any(|finding| {
1435                finding.code == "OBS-H20" && finding.severity == Severity::Error
1436            }));
1437        }
1438    }
1439
1440    #[test]
1441    fn observation_qc_saturates_missing_epoch_counts_for_tiny_positive_interval() {
1442        let g01 = sat(1);
1443        let observations = BTreeMap::from([(g01, vec![obs_value(Some(1.0), Some(5))])]);
1444        let mut obs = observation_file(vec![
1445            epoch(0, 0.0, 0, observations.clone()),
1446            epoch(0, 30.0, 0, observations.clone()),
1447            epoch(1, 0.0, 0, observations.clone()),
1448            epoch(1, 30.0, 0, observations),
1449        ]);
1450        obs.header.interval_s = Some(f64::MIN_POSITIVE);
1451
1452        let report = observation_qc(&obs);
1453
1454        assert_eq!(report.data_gaps.len(), 3);
1455        assert!(report
1456            .data_gaps
1457            .iter()
1458            .all(|gap| gap.missing_epochs == usize::MAX));
1459        assert_eq!(report.missing_epochs, usize::MAX);
1460    }
1461
1462    #[test]
1463    fn observation_qc_skips_nonfinite_public_epoch_deltas_without_panicking() {
1464        let g01 = sat(1);
1465        let original = observation_file(vec![
1466            epoch(
1467                0,
1468                0.0,
1469                0,
1470                BTreeMap::from([(g01, vec![obs_value(Some(1.0), Some(5))])]),
1471            ),
1472            epoch(
1473                0,
1474                30.0,
1475                0,
1476                BTreeMap::from([(g01, vec![obs_value(Some(2.0), Some(6))])]),
1477            ),
1478        ]);
1479
1480        for nonfinite in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1481            let mut obs = original.clone();
1482            obs.header.interval_s = None;
1483            obs.epochs[1].epoch.second = nonfinite;
1484            let report = observation_qc(&obs);
1485            assert_eq!(report.interval_s, None, "{nonfinite:?}");
1486            assert_eq!(
1487                report.interval_source,
1488                IntervalSource::Unresolved,
1489                "{nonfinite:?}"
1490            );
1491            assert!(report.data_gaps.is_empty(), "{nonfinite:?}");
1492            assert_eq!(report.missing_epochs, 0, "{nonfinite:?}");
1493        }
1494    }
1495
1496    #[test]
1497    fn observation_qc_notes_non_monotonic_epochs_and_excludes_them_from_gaps() {
1498        let g01 = sat(1);
1499        let obs = observation_file(vec![
1500            epoch(
1501                1,
1502                0.0,
1503                0,
1504                BTreeMap::from([(g01, vec![obs_value(Some(1.0), Some(5))])]),
1505            ),
1506            epoch(
1507                0,
1508                30.0,
1509                0,
1510                BTreeMap::from([(g01, vec![obs_value(Some(2.0), Some(6))])]),
1511            ),
1512        ]);
1513
1514        let report = observation_qc(&obs);
1515
1516        assert_eq!(
1517            report.notes,
1518            vec![ObservationQcNote::NonMonotonicEpoch { epoch_index: 1 }]
1519        );
1520        assert!(report.data_gaps.is_empty());
1521    }
1522
1523    #[test]
1524    fn observation_qc_rejects_invalid_options() {
1525        let obs = observation_file(Vec::new());
1526
1527        for invalid in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1528            let err = observation_qc_with_options(
1529                &obs,
1530                ObservationQcOptions {
1531                    interval_override_s: Some(invalid),
1532                    ..ObservationQcOptions::default()
1533                },
1534            )
1535            .expect_err("invalid interval");
1536            assert_eq!(err, ObservationQcError::InvalidInterval, "{invalid:?}");
1537        }
1538
1539        let err = observation_qc_with_options(
1540            &obs,
1541            ObservationQcOptions {
1542                interval_override_s: None,
1543                gap_factor: 1.0,
1544                ..ObservationQcOptions::default()
1545            },
1546        )
1547        .expect_err("invalid gap factor");
1548        assert_eq!(err, ObservationQcError::InvalidGapFactor);
1549
1550        let err = observation_qc_with_options(
1551            &obs,
1552            ObservationQcOptions {
1553                clock_jump_threshold_s: 0.0,
1554                ..ObservationQcOptions::default()
1555            },
1556        )
1557        .expect_err("invalid clock-jump threshold");
1558        assert_eq!(err, ObservationQcError::InvalidClockJumpThreshold);
1559    }
1560
1561    #[test]
1562    fn detect_clock_jumps_flags_injected_millisecond_step() {
1563        let g01 = sat(1);
1564        let mut obs = observation_file(vec![
1565            epoch(
1566                0,
1567                0.0,
1568                0,
1569                BTreeMap::from([(g01, vec![obs_value(Some(1.0), None)])]),
1570            ),
1571            epoch(
1572                0,
1573                30.0,
1574                0,
1575                BTreeMap::from([(g01, vec![obs_value(Some(2.0), None)])]),
1576            ),
1577            epoch(
1578                1,
1579                0.0,
1580                0,
1581                BTreeMap::from([(g01, vec![obs_value(Some(3.0), None)])]),
1582            ),
1583            epoch(
1584                1,
1585                30.0,
1586                0,
1587                BTreeMap::from([(g01, vec![obs_value(Some(4.0), None)])]),
1588            ),
1589        ]);
1590        let offsets_s = [0.0, 0.000_010, 0.001_020, 0.001_030];
1591        for (epoch, offset_s) in obs.epochs.iter_mut().zip(offsets_s) {
1592            epoch.rcv_clock_offset_s = Some(offset_s);
1593        }
1594
1595        let jumps = detect_clock_jumps(&obs, DEFAULT_CLOCK_JUMP_THRESHOLD_S);
1596
1597        assert_eq!(jumps.len(), 1);
1598        assert_eq!(jumps[0].epoch_index, 2);
1599        assert_close(jumps[0].delta_s, 0.001, "clock jump");
1600
1601        let report = observation_qc(&obs);
1602        assert_eq!(report.clock_jumps, jumps);
1603    }
1604
1605    #[test]
1606    fn detect_clock_jumps_ignores_linear_clock_drift() {
1607        let g01 = sat(1);
1608        let mut obs = observation_file(
1609            (0..4)
1610                .map(|idx| {
1611                    epoch(
1612                        idx / 2,
1613                        if idx % 2 == 0 { 0.0 } else { 30.0 },
1614                        0,
1615                        BTreeMap::from([(g01, vec![obs_value(Some(idx as f64), None)])]),
1616                    )
1617                })
1618                .collect(),
1619        );
1620        for (idx, epoch) in obs.epochs.iter_mut().enumerate() {
1621            epoch.rcv_clock_offset_s = Some(idx as f64 * 0.000_010);
1622        }
1623
1624        assert!(detect_clock_jumps(&obs, DEFAULT_CLOCK_JUMP_THRESHOLD_S).is_empty());
1625        assert!(observation_qc(&obs).clock_jumps.is_empty());
1626    }
1627
1628    #[test]
1629    fn observation_qc_tallies_synthetic_injected_cycle_slip() {
1630        let g01 = sat(1);
1631        let obs = observation_file(
1632            (0usize..5)
1633                .map(|epoch_index| {
1634                    let wide_lane_cycles = if epoch_index >= 3 { 14.0 } else { 8.0 };
1635                    epoch(
1636                        (epoch_index / 2) as u8,
1637                        if epoch_index % 2 == 0 { 0.0 } else { 30.0 },
1638                        0,
1639                        BTreeMap::from([(
1640                            g01,
1641                            dual_frequency_values(epoch_index, wide_lane_cycles, 0.0),
1642                        )]),
1643                    )
1644                })
1645                .collect(),
1646        );
1647
1648        let report = observation_qc(&obs);
1649
1650        assert_eq!(
1651            report.cycle_slips,
1652            CycleSlipQc {
1653                observations: 5,
1654                total_slips: 1,
1655                observations_per_slip: Some(5.0),
1656                by_system: vec![SystemCycleSlipQc {
1657                    system: GnssSystem::Gps,
1658                    observations: 5,
1659                    slips: 1,
1660                    observations_per_slip: Some(5.0),
1661                }],
1662            }
1663        );
1664    }
1665
1666    #[test]
1667    fn multipath_combination_and_arc_rms_match_closed_form() {
1668        let p1_m = 20_000_010.0;
1669        let l1_m = 20_000_003.0;
1670        let l2_m = 20_000_001.0;
1671        let f2sq = F_L2_HZ * F_L2_HZ;
1672        let denom = F_L1_HZ * F_L1_HZ - f2sq;
1673        let expected = p1_m - l1_m - (2.0 * f2sq / denom) * (l1_m - l2_m);
1674
1675        assert_close(
1676            mp_combination(p1_m, l1_m, l2_m, F_L1_HZ, F_L2_HZ),
1677            expected,
1678            "MP1 combination",
1679        );
1680        assert_close(
1681            arc_multipath_rms(&[1.0, 3.0, 5.0]),
1682            (8.0_f64 / 3.0).sqrt(),
1683            "arc RMS",
1684        );
1685    }
1686
1687    #[test]
1688    fn multipath_stats_splits_arc_on_injected_lli_slip() {
1689        let g01 = sat(1);
1690        let high_threshold_config = CycleSlipConfig {
1691            melbourne_wubbena_threshold_cycles: 1.0e6,
1692            geometry_free_threshold_m: 1.0e6,
1693            minimum_arc_length: 10,
1694            maximum_gap_s: 1.0e6,
1695            ..CycleSlipConfig::default()
1696        };
1697        let with_slip = observation_file(
1698            (0usize..4)
1699                .map(|epoch_index| {
1700                    let bias_m = if epoch_index >= 2 { 10.0 } else { 0.0 };
1701                    epoch(
1702                        (epoch_index / 2) as u8,
1703                        if epoch_index % 2 == 0 { 0.0 } else { 30.0 },
1704                        0,
1705                        BTreeMap::from([(
1706                            g01,
1707                            dual_frequency_values_with_mp_bias(
1708                                epoch_index,
1709                                bias_m,
1710                                epoch_index == 2,
1711                            ),
1712                        )]),
1713                    )
1714                })
1715                .collect(),
1716        );
1717        let without_slip = observation_file(
1718            (0usize..4)
1719                .map(|epoch_index| {
1720                    let bias_m = if epoch_index >= 2 { 10.0 } else { 0.0 };
1721                    epoch(
1722                        (epoch_index / 2) as u8,
1723                        if epoch_index % 2 == 0 { 0.0 } else { 30.0 },
1724                        0,
1725                        BTreeMap::from([(
1726                            g01,
1727                            dual_frequency_values_with_mp_bias(epoch_index, bias_m, false),
1728                        )]),
1729                    )
1730                })
1731                .collect(),
1732        );
1733
1734        let split = multipath_stats(&with_slip, &high_threshold_config);
1735        let unsplit = multipath_stats(&without_slip, &high_threshold_config);
1736        let split_mp1 = split.satellites[0].mp1.expect("split MP1");
1737        let unsplit_mp1 = unsplit.satellites[0].mp1.expect("unsplit MP1");
1738
1739        assert_eq!(split_mp1.n, 4);
1740        assert_eq!(unsplit_mp1.n, 4);
1741        assert_close(split_mp1.rms_m, 0.0, "split MP1 RMS");
1742        assert_close(unsplit_mp1.rms_m, 25.0 / 6.0, "unsplit MP1 RMS");
1743    }
1744
1745    #[test]
1746    fn multipath_matches_teqc_algo0010_oracle() {
1747        let oracle = read_json_fixture("qc/teqc_algo0010_2015001_v1_trim.json");
1748        let path = fixture_path("tests/fixtures/obs/algo0010_2015001_v1_trim.crx");
1749        let crx = std::fs::read_to_string(&path)
1750            .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
1751        let decoded = crinex::decode(&crx).expect("decode CRINEX v1 fixture");
1752        let obs = RinexObs::parse(&decoded).expect("parse decoded RINEX 2 OBS");
1753        let report = observation_qc(&obs);
1754        let gps = report
1755            .multipath
1756            .systems
1757            .iter()
1758            .find(|system| system.system == GnssSystem::Gps)
1759            .expect("GPS multipath row");
1760        let mp1 = gps.mp1.expect("GPS MP1");
1761        let mp2 = gps.mp2.expect("GPS MP2");
1762
1763        assert_eq!(mp1.n, 23);
1764        assert_eq!(mp2.n, 23);
1765        assert_close_tolerance(
1766            mp1.rms_m,
1767            oracle["summary"]["moving_average_mp12_m"].as_f64().unwrap(),
1768            1.0e-6,
1769            "teqc MP12",
1770        );
1771        assert_close_tolerance(
1772            mp2.rms_m,
1773            oracle["summary"]["moving_average_mp21_m"].as_f64().unwrap(),
1774            1.0e-6,
1775            "teqc MP21",
1776        );
1777    }
1778
1779    #[test]
1780    fn observation_qc_accepts_crinex_v1_decoded_rinex2() {
1781        let path = fixture_path("tests/fixtures/obs/algo0010_2015001_v1_trim.crx");
1782        let crx = std::fs::read_to_string(&path)
1783            .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
1784        let decoded = crinex::decode(&crx).expect("decode CRINEX v1 fixture");
1785        let obs = RinexObs::parse(&decoded).expect("parse decoded RINEX 2 OBS");
1786        let report: ObservationQcReport = observation_qc(&obs);
1787
1788        assert_eq!(report.total_epoch_records, 2);
1789        assert_eq!(report.observation_epochs, 2);
1790        assert_eq!(report.event_records, 0);
1791        assert_eq!(report.skipped_records, 0);
1792        assert_eq!(report.interval_s, Some(30.0));
1793        assert_eq!(report.interval_source, IntervalSource::Header);
1794        assert_eq!(report.missing_epochs, 0);
1795        assert_eq!(report.satellites.len(), 20);
1796
1797        let g08 = GnssSatelliteId::new(GnssSystem::Gps, 8).expect("valid GPS PRN");
1798        let g08_report = report
1799            .satellites
1800            .iter()
1801            .find(|sat| sat.satellite == g08)
1802            .expect("G08 QC row");
1803        assert_eq!(g08_report.epochs_with_observations, 2);
1804        assert_eq!(g08_report.value_observations, 14);
1805    }
1806
1807    #[test]
1808    fn observation_qc_matches_independent_real_fixture_oracles() {
1809        let doc = read_json_fixture("qc/observation_qc_real_oracles.json");
1810        assert_eq!(
1811            doc["provenance"]["generator"],
1812            "crates/sidereon-core/fixtures-generators/generate_observation_qc_oracles.py"
1813        );
1814        for fixture in doc["fixtures"].as_array().expect("fixtures array") {
1815            let rel = fixture["path"].as_str().expect("fixture path");
1816            let text = std::fs::read_to_string(fixture_path(rel))
1817                .unwrap_or_else(|e| panic!("read {rel}: {e}"));
1818            let obs = RinexObs::parse(&text).unwrap_or_else(|e| panic!("parse {rel}: {e}"));
1819            let report = observation_qc(&obs);
1820
1821            assert_eq!(
1822                report.total_epoch_records,
1823                fixture["total_epoch_records"].as_u64().unwrap() as usize,
1824                "{rel}"
1825            );
1826            assert_eq!(
1827                report.observation_epochs,
1828                fixture["observation_epochs"].as_u64().unwrap() as usize,
1829                "{rel}"
1830            );
1831            assert_eq!(
1832                report.event_records,
1833                fixture["event_records"].as_u64().unwrap() as usize,
1834                "{rel}"
1835            );
1836            assert_eq!(
1837                report.power_failure_epochs,
1838                fixture["power_failure_epochs"].as_u64().unwrap() as usize,
1839                "{rel}"
1840            );
1841            assert_eq!(
1842                report.skipped_records,
1843                fixture["skipped_records"].as_u64().unwrap() as usize,
1844                "{rel}"
1845            );
1846            assert_close(
1847                report.interval_s.expect("oracle interval"),
1848                fixture["interval_s"].as_f64().unwrap(),
1849                rel,
1850            );
1851            assert_eq!(
1852                report.missing_epochs,
1853                fixture["missing_epochs"].as_u64().unwrap() as usize,
1854                "{rel}"
1855            );
1856            assert_gaps(&report.data_gaps, &fixture["data_gaps"], rel);
1857            assert_satellites(&report.satellites, &fixture["satellites"], rel);
1858            assert_satellite_signals(
1859                &report.satellite_signals,
1860                &fixture["satellite_signals"],
1861                rel,
1862            );
1863            assert_system_signals(&report.system_signals, &fixture["system_signals"], rel);
1864        }
1865    }
1866
1867    #[test]
1868    fn observation_qc_pins_real_fixture_cycle_slip_tally() {
1869        let rel = "tests/fixtures/obs/ESBC00DNK_R_20201770000_01D_30S_MO_120epoch.rnx";
1870        let text = std::fs::read_to_string(fixture_path(rel))
1871            .unwrap_or_else(|e| panic!("read {rel}: {e}"));
1872        let obs = RinexObs::parse(&text).unwrap_or_else(|e| panic!("parse {rel}: {e}"));
1873        let report = observation_qc(&obs);
1874
1875        assert_eq!(report.cycle_slips.observations, 4135);
1876        assert_eq!(report.cycle_slips.total_slips, 27);
1877        assert_close(
1878            report
1879                .cycle_slips
1880                .observations_per_slip
1881                .expect("observations per slip"),
1882            4135.0 / 27.0,
1883            rel,
1884        );
1885
1886        let by_system = report
1887            .cycle_slips
1888            .by_system
1889            .iter()
1890            .map(|row| {
1891                (
1892                    row.system,
1893                    (
1894                        row.observations,
1895                        row.slips,
1896                        row.observations_per_slip
1897                            .expect("system observations per slip"),
1898                    ),
1899                )
1900            })
1901            .collect::<BTreeMap<_, _>>();
1902        assert_eq!(by_system[&GnssSystem::Gps].0, 1282);
1903        assert_eq!(by_system[&GnssSystem::Gps].1, 4);
1904        assert_close(by_system[&GnssSystem::Gps].2, 1282.0 / 4.0, rel);
1905        assert_eq!(by_system[&GnssSystem::Glonass].0, 784);
1906        assert_eq!(by_system[&GnssSystem::Glonass].1, 10);
1907        assert_close(by_system[&GnssSystem::Glonass].2, 784.0 / 10.0, rel);
1908        assert_eq!(by_system[&GnssSystem::Galileo].0, 1023);
1909        assert_eq!(by_system[&GnssSystem::Galileo].1, 9);
1910        assert_close(by_system[&GnssSystem::Galileo].2, 1023.0 / 9.0, rel);
1911        assert_eq!(by_system[&GnssSystem::BeiDou].0, 1046);
1912        assert_eq!(by_system[&GnssSystem::BeiDou].1, 4);
1913        assert_close(by_system[&GnssSystem::BeiDou].2, 1046.0 / 4.0, rel);
1914    }
1915
1916    #[test]
1917    fn observation_qc_report_text_snapshot_esbc00dnk() {
1918        let rel = "tests/fixtures/obs/ESBC00DNK_R_20201770000_01D_30S_MO_120epoch.rnx";
1919        let text = std::fs::read_to_string(fixture_path(rel))
1920            .unwrap_or_else(|e| panic!("read {rel}: {e}"));
1921        let obs = RinexObs::parse(&text).unwrap_or_else(|e| panic!("parse {rel}: {e}"));
1922        let report = observation_qc(&obs);
1923        let rendered = render_text(&report);
1924
1925        assert_eq!(rendered, ESBC_QC_REPORT_TEXT);
1926        assert!(rendered.contains("G   GPS"));
1927        assert!(rendered.contains("R   GLONASS"));
1928        assert!(rendered.contains("E   Galileo"));
1929        assert!(rendered.contains("C   BeiDou"));
1930        assert!(rendered.contains("S   SBAS"));
1931        assert!(rendered.contains("0.292"));
1932        assert!(rendered.contains("1.174"));
1933        assert!(rendered.contains("FINDINGS"));
1934        assert!(rendered.contains("CODE     SEVERITY SPEC REF"));
1935    }
1936
1937    #[test]
1938    fn observation_qc_report_json_contains_expected_fields_esbc00dnk() {
1939        let rel = "tests/fixtures/obs/ESBC00DNK_R_20201770000_01D_30S_MO_120epoch.rnx";
1940        let text = std::fs::read_to_string(fixture_path(rel))
1941            .unwrap_or_else(|e| panic!("read {rel}: {e}"));
1942        let obs = RinexObs::parse(&text).unwrap_or_else(|e| panic!("parse {rel}: {e}"));
1943        let report = observation_qc(&obs);
1944
1945        let encoded = serde_json::to_string(&report).expect("serialize QC report");
1946        let doc: Value = serde_json::from_str(&encoded).expect("parse serialized QC report");
1947
1948        assert_eq!(doc["header"]["marker_name"], "ESBC00DNK");
1949        assert_eq!(doc["header"]["receiver"]["receiver_type"], "SEPT POLARX5");
1950        assert_eq!(doc["interval_s"], 30.0);
1951
1952        let gps = json_system(&doc, "Gps");
1953        assert_eq!(gps["satellites_seen"], 13);
1954        assert_eq!(gps["epochs_with_observations"], 120);
1955        assert_eq!(gps["value_observations"], 18645);
1956        assert_close(
1957            gps["completeness_ratio"].as_f64().unwrap(),
1958            0.800489438433797,
1959            "GPS JSON completeness",
1960        );
1961
1962        let gps_mp = json_multipath_system(&doc, "Gps");
1963        assert_close(
1964            gps_mp["mp1"]["rms_m"].as_f64().unwrap(),
1965            0.29240479301672934,
1966            "GPS JSON MP1",
1967        );
1968        let beidou_mp = json_multipath_system(&doc, "BeiDou");
1969        assert_close(
1970            beidou_mp["mp2"]["rms_m"].as_f64().unwrap(),
1971            1.1736185873490712,
1972            "BeiDou JSON MP2",
1973        );
1974
1975        let galileo_slips = doc["cycle_slips"]["by_system"]
1976            .as_array()
1977            .expect("cycle slip systems")
1978            .iter()
1979            .find(|row| row["system"] == "Galileo")
1980            .expect("Galileo cycle slip row");
1981        assert_eq!(galileo_slips["slips"], 9);
1982        assert_eq!(doc["lint_findings"].as_array().unwrap().len(), 0);
1983    }
1984
1985    #[test]
1986    fn observation_qc_report_html_contains_rows_without_external_assets() {
1987        let rel = "tests/fixtures/obs/ESBC00DNK_R_20201770000_01D_30S_MO_120epoch.rnx";
1988        let text = std::fs::read_to_string(fixture_path(rel))
1989            .unwrap_or_else(|e| panic!("read {rel}: {e}"));
1990        let obs = RinexObs::parse(&text).unwrap_or_else(|e| panic!("parse {rel}: {e}"));
1991        let report = observation_qc(&obs);
1992        let html = render_html(&report);
1993
1994        assert!(html.contains("<td class=\"text\">G</td>"));
1995        assert!(html.contains("<td class=\"text\">GPS</td>"));
1996        assert!(html.contains("<td>0.292</td>"));
1997        assert!(html.contains("<td>1.174</td>"));
1998        assert!(html.contains("<h2>Findings</h2>"));
1999        assert!(!html.contains("http"));
2000    }
2001
2002    const ESBC_QC_REPORT_TEXT: &str = r#"RINEX OBSERVATION QC +QC SUMMARY
2003
2004HEADER
2005  MARKER NAME        ESBC00DNK
2006  MARKER NUMBER      10118M001
2007  MARKER TYPE        GEODETIC
2008  RECEIVER           3047937 / SEPT POLARX5 / 5.2.0
2009  ANTENNA            CR5200327016 / ASH701945E_M    SCIS
2010  POSITION XYZ M     3582105.2910 532589.7313 5232754.8054
2011  ANTENNA HEN M      0.2160 0.0000 0.0000
2012  TIME FIRST         2020-06-25 00:00:00.0000000 GPS
2013  TIME LAST          2020-06-25 00:59:30.0000000 GPS
2014  INTERVAL S         30.000 (header)
2015  DURATION S         3570.0
2016
2017PER-CONSTELLATION
2018SYS NAME     SATS EPOCHS      OBS   EXPECT     COMP SNR MEAN/MIN BY BAND                                                                              MP1 RMS  MP2 RMS  SLIPS GAPS     GAP S
2019--- -------- ---- ------ -------- -------- -------- ------------------------------------------------------------------------------------------------ -------- -------- ------ ---- ---------
2020G   GPS        13    120    18645    23292    0.800 1:39.0/5.5 2:37.7/5.5 5:36.0/23.8                                                                   0.292    0.281      4    0       0.0
2021R   GLONASS    12    120    16323    22600    0.722 1:42.9/20.5 2:42.0/21.5 3:35.4/25.2                                                                 0.519    0.314     10    0       0.0
2022E   Galileo     9    120    19147    20540    0.932 1:42.2/18.8 5:36.5/20.5 6:34.8/24.0 7:45.2/25.5 8:45.2/26.0                                         0.386    0.483      9    0       0.0
2023C   BeiDou     12    120    11213    15708    0.714 2:42.6/32.5 6:35.6/26.8 7:41.2/36.0                                                                 1.017    1.174      4    0       0.0
2024S   SBAS        5    120     3032     4144    0.732 1:38.2/30.8 5:33.5/31.5                                                                                 -        -      0    0       0.0
2025
2026FINDINGS
2027CODE     SEVERITY SPEC REF
2028-------- -------- ------------------------------------------------
2029NONE
2030"#;
2031
2032    fn observation_file(epochs: Vec<ObsEpoch>) -> RinexObs {
2033        RinexObs {
2034            header: ObsHeader {
2035                version: 3.05,
2036                approx_position_m: None,
2037                antenna_delta_hen_m: None,
2038                obs_codes: BTreeMap::from([(
2039                    GnssSystem::Gps,
2040                    vec![
2041                        "C1C".to_string(),
2042                        "L1C".to_string(),
2043                        "S1C".to_string(),
2044                        "C2W".to_string(),
2045                        "L2W".to_string(),
2046                    ],
2047                )]),
2048                program_run_by_date: None,
2049                comments: Vec::new(),
2050                marker_number: None,
2051                marker_type: None,
2052                observer: None,
2053                agency: None,
2054                receiver: None,
2055                antenna: None,
2056                interval_s: Some(30.0),
2057                time_of_first_obs: None,
2058                time_of_last_obs: None,
2059                n_satellites: None,
2060                prn_obs_counts: BTreeMap::new(),
2061                phase_shifts: Vec::new(),
2062                scale_factors: Vec::new(),
2063                glonass_slots: BTreeMap::new(),
2064                glonass_cod_phs_bis: None,
2065                signal_strength_unit: None,
2066                leap_seconds: None,
2067                marker_name: None,
2068                unretained_header_labels: Vec::new(),
2069            },
2070            epochs,
2071            skipped_records: 0,
2072        }
2073    }
2074
2075    fn epoch(
2076        minute: u8,
2077        second: f64,
2078        flag: u8,
2079        sats: BTreeMap<GnssSatelliteId, Vec<ObsValue>>,
2080    ) -> ObsEpoch {
2081        ObsEpoch {
2082            epoch: ObsEpochTime {
2083                year: 2024,
2084                month: 1,
2085                day: 1,
2086                hour: 0,
2087                minute,
2088                second,
2089            },
2090            flag,
2091            rcv_clock_offset_s: None,
2092            epoch_picoseconds: None,
2093            declared_record_count: sats.len(),
2094            special_record_count: if flag > 1 { sats.len() } else { 0 },
2095            sats,
2096        }
2097    }
2098
2099    fn obs_value(value: Option<f64>, ssi: Option<u8>) -> ObsValue {
2100        ObsValue {
2101            value,
2102            lli: None,
2103            ssi,
2104        }
2105    }
2106
2107    fn dual_frequency_values(
2108        epoch_index: usize,
2109        melbourne_wubbena_cycles: f64,
2110        geometry_free_m: f64,
2111    ) -> Vec<ObsValue> {
2112        let geometric_m = 23_000_000.0 + epoch_index as f64 * 100.0;
2113        let lambda1 = C_M_S / F_L1_HZ;
2114        let lambda2 = C_M_S / F_L2_HZ;
2115        let lambda_wl = C_M_S / (F_L1_HZ - F_L2_HZ);
2116        let l2_m = geometric_m + lambda_wl * (melbourne_wubbena_cycles - geometry_free_m / lambda1);
2117        let l1_m = l2_m + geometry_free_m;
2118
2119        vec![
2120            obs_value(Some(geometric_m), None),
2121            obs_value(Some(l1_m / lambda1), None),
2122            obs_value(None, None),
2123            obs_value(Some(geometric_m), None),
2124            obs_value(Some(l2_m / lambda2), None),
2125        ]
2126    }
2127
2128    fn dual_frequency_values_with_mp_bias(
2129        epoch_index: usize,
2130        p_bias_m: f64,
2131        lli_slip: bool,
2132    ) -> Vec<ObsValue> {
2133        let mut values = dual_frequency_values(epoch_index, 8.0, 0.0);
2134        values[0].value = values[0].value.map(|value| value + p_bias_m);
2135        values[3].value = values[3].value.map(|value| value + p_bias_m);
2136        if lli_slip {
2137            values[1].lli = Some(1);
2138        }
2139        values
2140    }
2141
2142    fn sat(prn: u8) -> GnssSatelliteId {
2143        GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid GPS PRN")
2144    }
2145
2146    fn fixture_path(rel: &str) -> PathBuf {
2147        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(rel)
2148    }
2149
2150    fn read_json_fixture(rel: &str) -> Value {
2151        let path = fixture_path(&format!("tests/fixtures/{rel}"));
2152        let raw = std::fs::read_to_string(&path)
2153            .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
2154        serde_json::from_str(&raw).unwrap_or_else(|e| panic!("parse {}: {e}", path.display()))
2155    }
2156
2157    fn json_system<'a>(doc: &'a Value, system: &str) -> &'a Value {
2158        doc["systems"]
2159            .as_array()
2160            .expect("systems")
2161            .iter()
2162            .find(|row| row["system"] == system)
2163            .unwrap_or_else(|| panic!("missing JSON system {system}"))
2164    }
2165
2166    fn json_multipath_system<'a>(doc: &'a Value, system: &str) -> &'a Value {
2167        doc["multipath"]["systems"]
2168            .as_array()
2169            .expect("multipath systems")
2170            .iter()
2171            .find(|row| row["system"] == system)
2172            .unwrap_or_else(|| panic!("missing JSON multipath system {system}"))
2173    }
2174
2175    fn assert_close(actual: f64, expected: f64, context: &str) {
2176        assert_close_tolerance(actual, expected, 1.0e-9, context);
2177    }
2178
2179    fn assert_close_tolerance(actual: f64, expected: f64, tolerance: f64, context: &str) {
2180        assert!(
2181            (actual - expected).abs() <= tolerance,
2182            "{context}: actual {actual:?}, expected {expected:?}"
2183        );
2184    }
2185
2186    fn assert_gaps(actual: &[ObservationDataGap], expected: &Value, context: &str) {
2187        let expected = expected.as_array().expect("gap array");
2188        assert_eq!(actual.len(), expected.len(), "{context}");
2189        for (actual, expected) in actual.iter().zip(expected) {
2190            assert_epoch(&actual.start_epoch, &expected["start_epoch"], context);
2191            assert_epoch(&actual.end_epoch, &expected["end_epoch"], context);
2192            assert_close(
2193                actual.nominal_interval_s,
2194                expected["nominal_interval_s"].as_f64().unwrap(),
2195                context,
2196            );
2197            assert_close(
2198                actual.observed_delta_s,
2199                expected["observed_delta_s"].as_f64().unwrap(),
2200                context,
2201            );
2202            assert_eq!(
2203                actual.missing_epochs,
2204                expected["missing_epochs"].as_u64().unwrap() as usize,
2205                "{context}"
2206            );
2207        }
2208    }
2209
2210    fn assert_epoch(actual: &ObsEpochTime, expected: &Value, context: &str) {
2211        assert_eq!(
2212            actual.year,
2213            expected["year"].as_i64().unwrap() as i32,
2214            "{context}"
2215        );
2216        assert_eq!(
2217            actual.month,
2218            expected["month"].as_u64().unwrap() as u8,
2219            "{context}"
2220        );
2221        assert_eq!(
2222            actual.day,
2223            expected["day"].as_u64().unwrap() as u8,
2224            "{context}"
2225        );
2226        assert_eq!(
2227            actual.hour,
2228            expected["hour"].as_u64().unwrap() as u8,
2229            "{context}"
2230        );
2231        assert_eq!(
2232            actual.minute,
2233            expected["minute"].as_u64().unwrap() as u8,
2234            "{context}"
2235        );
2236        assert_close(actual.second, expected["second"].as_f64().unwrap(), context);
2237    }
2238
2239    fn assert_satellites(actual: &[SatelliteObservationQc], expected: &Value, context: &str) {
2240        let expected = expected.as_array().expect("satellites array");
2241        assert_eq!(actual.len(), expected.len(), "{context}");
2242        let actual = actual
2243            .iter()
2244            .map(|sat| {
2245                (
2246                    sat.satellite.to_string(),
2247                    (sat.epochs_with_observations, sat.value_observations),
2248                )
2249            })
2250            .collect::<BTreeMap<_, _>>();
2251        for expected in expected {
2252            let satellite = expected["satellite"].as_str().unwrap();
2253            let actual = actual
2254                .get(satellite)
2255                .unwrap_or_else(|| panic!("{context}: missing satellite {satellite}"));
2256            assert_eq!(
2257                actual.0,
2258                expected["epochs_with_observations"].as_u64().unwrap() as usize,
2259                "{context} {satellite}"
2260            );
2261            assert_eq!(
2262                actual.1,
2263                expected["value_observations"].as_u64().unwrap() as usize,
2264                "{context} {satellite}"
2265            );
2266        }
2267    }
2268
2269    fn assert_satellite_signals(actual: &[SatelliteSignalQc], expected: &Value, context: &str) {
2270        let expected = expected.as_array().expect("satellite signals array");
2271        assert_eq!(actual.len(), expected.len(), "{context}");
2272        let actual = actual
2273            .iter()
2274            .map(|signal| {
2275                (
2276                    (signal.satellite.to_string(), signal.code.as_str()),
2277                    (signal.value_observations, signal.ssi, signal.snr),
2278                )
2279            })
2280            .collect::<BTreeMap<_, _>>();
2281        for expected in expected {
2282            let satellite = expected["satellite"].as_str().unwrap();
2283            let code = expected["code"].as_str().unwrap();
2284            let actual = actual
2285                .get(&(satellite.to_string(), code))
2286                .unwrap_or_else(|| panic!("{context}: missing {satellite} {code}"));
2287            assert_eq!(
2288                actual.0,
2289                expected["value_observations"].as_u64().unwrap() as usize,
2290                "{context} {satellite} {code}"
2291            );
2292            assert_ssi(actual.1, &expected["ssi"], context);
2293            assert_snr(actual.2, &expected["snr"], context);
2294        }
2295    }
2296
2297    fn assert_system_signals(actual: &[SystemSignalQc], expected: &Value, context: &str) {
2298        let expected = expected.as_array().expect("system signals array");
2299        assert_eq!(actual.len(), expected.len(), "{context}");
2300        let actual = actual
2301            .iter()
2302            .map(|signal| {
2303                (
2304                    (signal.system.letter().to_string(), signal.code.as_str()),
2305                    (signal.value_observations, signal.ssi, signal.snr),
2306                )
2307            })
2308            .collect::<BTreeMap<_, _>>();
2309        for expected in expected {
2310            let system = expected["system"].as_str().unwrap();
2311            let code = expected["code"].as_str().unwrap();
2312            let actual = actual
2313                .get(&(system.to_string(), code))
2314                .unwrap_or_else(|| panic!("{context}: missing {system} {code}"));
2315            assert_eq!(
2316                actual.0,
2317                expected["value_observations"].as_u64().unwrap() as usize,
2318                "{context} {system} {code}"
2319            );
2320            assert_ssi(actual.1, &expected["ssi"], context);
2321            assert_snr(actual.2, &expected["snr"], context);
2322        }
2323    }
2324
2325    fn assert_ssi(actual: Option<SsiHistogram>, expected: &Value, context: &str) {
2326        if expected.is_null() {
2327            assert_eq!(actual, None, "{context}");
2328            return;
2329        }
2330        let expected = expected
2331            .as_array()
2332            .expect("ssi array")
2333            .iter()
2334            .map(|value| value.as_u64().unwrap())
2335            .collect::<Vec<_>>();
2336        assert_eq!(actual.expect("ssi").counts.to_vec(), expected, "{context}");
2337    }
2338
2339    fn assert_snr(actual: Option<SnrStats>, expected: &Value, context: &str) {
2340        if expected.is_null() {
2341            assert_eq!(actual, None, "{context}");
2342            return;
2343        }
2344        let actual = actual.expect("snr");
2345        assert_eq!(
2346            actual.n,
2347            expected["n"].as_u64().unwrap() as usize,
2348            "{context}"
2349        );
2350        assert_close(actual.mean, expected["mean"].as_f64().unwrap(), context);
2351        assert_close(actual.min, expected["min"].as_f64().unwrap(), context);
2352        assert_close(actual.max, expected["max"].as_f64().unwrap(), context);
2353        if expected["std"].is_null() {
2354            assert_eq!(actual.std, None, "{context}");
2355        } else {
2356            assert_close(
2357                actual.std.expect("std"),
2358                expected["std"].as_f64().unwrap(),
2359                context,
2360            );
2361        }
2362    }
2363}