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