1use 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
30pub const DEFAULT_CLOCK_JUMP_THRESHOLD_S: f64 = 0.0005;
32
33#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct ObservationQcOptions {
36 pub interval_override_s: Option<f64>,
38 pub gap_factor: f64,
40 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#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)]
56pub enum ObservationQcError {
57 #[error("invalid observation QC interval: must be finite and positive")]
59 InvalidInterval,
60 #[error("invalid observation QC gap factor: must be finite and greater than one")]
62 InvalidGapFactor,
63 #[error("invalid observation QC clock-jump threshold: must be finite and positive")]
65 InvalidClockJumpThreshold,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
70pub enum IntervalSource {
71 Override,
73 Header,
75 Inferred,
77 Unresolved,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
83pub enum ObservationQcNote {
84 NonMonotonicEpoch { epoch_index: usize },
86 IntervalUnresolved,
88}
89
90#[derive(Debug, Clone, PartialEq, serde::Serialize)]
92pub struct ObservationQcReport {
93 pub header: ObservationQcHeader,
95 pub total_epoch_records: usize,
97 pub observation_epochs: usize,
100 pub event_records: usize,
102 pub power_failure_epochs: usize,
104 pub skipped_records: usize,
106 pub interval_s: Option<f64>,
108 pub interval_source: IntervalSource,
110 pub missing_epochs: usize,
112 pub data_gaps: Vec<ObservationDataGap>,
114 pub clock_jumps: Vec<ClockJump>,
116 pub cycle_slips: CycleSlipQc,
118 pub multipath: MultipathReport,
120 pub systems: Vec<SystemObservationQc>,
122 pub satellites: Vec<SatelliteObservationQc>,
124 pub satellite_signals: Vec<SatelliteSignalQc>,
126 pub system_signals: Vec<SystemSignalQc>,
128 pub lint_findings: Vec<ObservationQcFinding>,
130 pub notes: Vec<ObservationQcNote>,
132}
133
134#[derive(Debug, Clone, PartialEq, serde::Serialize)]
136pub struct ObservationQcHeader {
137 pub marker_name: Option<String>,
139 pub marker_number: Option<String>,
141 pub marker_type: Option<String>,
143 pub receiver: Option<ObservationQcReceiver>,
145 pub antenna: Option<ObservationQcAntenna>,
147 pub approx_position_m: Option<[f64; 3]>,
149 pub antenna_delta_hen_m: Option<[f64; 3]>,
151 pub time_of_first_obs: Option<ObservationQcTime>,
153 pub time_of_last_obs: Option<ObservationQcTime>,
155 pub duration_s: Option<f64>,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
161pub struct ObservationQcReceiver {
162 pub number: String,
164 pub receiver_type: String,
166 pub version: String,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
172pub struct ObservationQcAntenna {
173 pub number: String,
175 pub antenna_type: String,
177}
178
179#[derive(Debug, Clone, PartialEq, serde::Serialize)]
181pub struct ObservationQcTime {
182 pub epoch: ObsEpochTime,
184 pub time_scale: Option<String>,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
190pub struct ObservationQcFinding {
191 pub code: String,
193 pub severity: Severity,
195 pub spec_ref: String,
197}
198
199#[derive(Debug, Clone, PartialEq, serde::Serialize)]
201pub struct ObservationDataGap {
202 pub start_epoch: ObsEpochTime,
204 pub end_epoch: ObsEpochTime,
206 pub nominal_interval_s: f64,
208 pub observed_delta_s: f64,
210 pub missing_epochs: usize,
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
216pub struct ClockJump {
217 pub epoch_index: usize,
219 pub epoch: ObsEpochTime,
221 pub delta_s: f64,
223}
224
225#[derive(Debug, Clone, PartialEq, Default, serde::Serialize)]
227pub struct CycleSlipQc {
228 pub observations: usize,
230 pub total_slips: usize,
232 pub observations_per_slip: Option<f64>,
234 pub by_system: Vec<SystemCycleSlipQc>,
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
240pub struct SystemCycleSlipQc {
241 pub system: GnssSystem,
243 pub observations: usize,
245 pub slips: usize,
247 pub observations_per_slip: Option<f64>,
249}
250
251#[derive(Debug, Clone, PartialEq, serde::Serialize)]
253pub struct SystemObservationQc {
254 pub system: GnssSystem,
256 pub satellites_seen: usize,
258 pub epochs_with_observations: usize,
260 pub value_observations: usize,
262 pub expected_observations: usize,
264 pub completeness_ratio: Option<f64>,
266 pub gap_count: usize,
268 pub total_gap_s: f64,
270}
271
272#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
274pub struct SatelliteObservationQc {
275 pub satellite: GnssSatelliteId,
277 pub epochs_with_observations: usize,
279 pub value_observations: usize,
281}
282
283#[derive(Debug, Clone, PartialEq, serde::Serialize)]
285pub struct SatelliteSignalQc {
286 pub satellite: GnssSatelliteId,
288 pub code: String,
290 pub value_observations: usize,
292 pub ssi: Option<SsiHistogram>,
295 pub snr: Option<SnrStats>,
297}
298
299#[derive(Debug, Clone, PartialEq, serde::Serialize)]
301pub struct SystemSignalQc {
302 pub system: GnssSystem,
304 pub code: String,
306 pub value_observations: usize,
308 pub ssi: Option<SsiHistogram>,
311 pub snr: Option<SnrStats>,
313}
314
315#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
317pub struct SsiHistogram {
318 pub counts: [u64; 10],
320}
321
322#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
324pub struct SnrStats {
325 pub n: usize,
327 pub mean: f64,
329 pub min: f64,
331 pub max: f64,
333 pub std: Option<f64>,
335}
336
337pub fn observation_qc(obs: &RinexObs) -> ObservationQcReport {
339 observation_qc_with_options(obs, ObservationQcOptions::default())
340 .expect("default observation QC options are valid")
341}
342
343pub 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: time_scale_rinex_label(scale).map(str::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
679pub 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
702pub 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 crate::astro::math::robust::median_sorting_in_place(values)
801}
802
803fn millisecond_clock_step(delta_s: f64, threshold_s: f64) -> bool {
804 if !delta_s.is_finite() || delta_s.abs() < threshold_s {
805 return false;
806 }
807
808 let step_ms = delta_s.abs() * 1000.0;
809 let nearest_ms = step_ms.round();
810 if nearest_ms < 1.0 {
811 return false;
812 }
813
814 let tolerance_ms = (threshold_s * 500.0).min(0.25);
815 (step_ms - nearest_ms).abs() <= tolerance_ms
816}
817
818#[derive(Debug, Clone, Copy, Default)]
819struct CycleSlipAccum {
820 observations: usize,
821 slips: usize,
822}
823
824fn finish_cycle_slip_qc(by_system: BTreeMap<GnssSystem, CycleSlipAccum>) -> CycleSlipQc {
825 let observations = by_system.values().map(|acc| acc.observations).sum();
826 let total_slips = by_system.values().map(|acc| acc.slips).sum();
827 let by_system = by_system
828 .into_iter()
829 .map(|(system, acc)| SystemCycleSlipQc {
830 system,
831 observations: acc.observations,
832 slips: acc.slips,
833 observations_per_slip: observations_per_slip(acc.observations, acc.slips),
834 })
835 .collect();
836
837 CycleSlipQc {
838 observations,
839 total_slips,
840 observations_per_slip: observations_per_slip(observations, total_slips),
841 by_system,
842 }
843}
844
845fn observations_per_slip(observations: usize, slips: usize) -> Option<f64> {
846 (slips > 0).then(|| observations as f64 / slips as f64)
847}
848
849fn dual_frequency_epochs(obs: &RinexObs) -> Vec<DualFrequencyEpoch> {
850 obs.epochs()
851 .iter()
852 .filter(|epoch| epoch.flag <= 1)
853 .map(|epoch| DualFrequencyEpoch {
854 gap_time_s: Some(obs_epoch_seconds(epoch.epoch)),
855 observations: epoch
856 .sats
857 .iter()
858 .filter_map(|(satellite, values)| {
859 dual_frequency_observation(obs, *satellite, values)
860 })
861 .collect(),
862 })
863 .collect()
864}
865
866#[derive(Debug, Clone, Copy)]
867struct DualFrequencyBand {
868 first_index: usize,
869 rinex_band: char,
870 frequency_hz: f64,
871 pseudorange_m: Option<f64>,
872 pseudorange_rank: u8,
873 carrier_phase_cyc: Option<f64>,
874 lli: Option<i64>,
875}
876
877fn dual_frequency_observation(
878 obs: &RinexObs,
879 satellite: GnssSatelliteId,
880 values: &[crate::rinex::observations::ObsValue],
881) -> Option<DualFrequencyObservation> {
882 dual_frequency_observation_with_pseudorange_selection(
883 obs,
884 satellite,
885 values,
886 PseudorangeSelection::HeaderOrder,
887 )
888}
889
890fn multipath_dual_frequency_observation(
891 obs: &RinexObs,
892 satellite: GnssSatelliteId,
893 values: &[crate::rinex::observations::ObsValue],
894) -> Option<DualFrequencyObservation> {
895 dual_frequency_observation_with_pseudorange_selection(
896 obs,
897 satellite,
898 values,
899 PseudorangeSelection::PreferPreciseCode,
900 )
901}
902
903#[derive(Debug, Clone, Copy)]
904enum PseudorangeSelection {
905 HeaderOrder,
906 PreferPreciseCode,
907}
908
909fn dual_frequency_observation_with_pseudorange_selection(
910 obs: &RinexObs,
911 satellite: GnssSatelliteId,
912 values: &[crate::rinex::observations::ObsValue],
913 pseudorange_selection: PseudorangeSelection,
914) -> Option<DualFrequencyObservation> {
915 let codes = obs.header().obs_codes.get(&satellite.system)?;
916 let glonass_channel = obs.header().glonass_slots.get(&satellite.prn).copied();
917 let mut bands = Vec::<DualFrequencyBand>::new();
918
919 for (index, (code, value)) in codes.iter().zip(values.iter()).enumerate() {
920 let kind = code.as_bytes().first().copied();
921 if !matches!(kind, Some(b'C' | b'L')) {
922 continue;
923 }
924 let rinex_band = code.chars().nth(1)?;
925 let Some(raw_value) = value.value else {
926 continue;
927 };
928 if !raw_value.is_finite() {
929 continue;
930 }
931 let frequency_hz = rinex_observation_frequency_hz(
932 satellite.system,
933 code,
934 obs.header().version,
935 glonass_channel,
936 )?;
937
938 let band_index = if let Some(existing) = bands
939 .iter()
940 .position(|band| same_frequency_hz(band.frequency_hz, frequency_hz))
941 {
942 existing
943 } else {
944 bands.push(DualFrequencyBand {
945 first_index: index,
946 rinex_band,
947 frequency_hz,
948 pseudorange_m: None,
949 pseudorange_rank: u8::MAX,
950 carrier_phase_cyc: None,
951 lli: None,
952 });
953 bands.len() - 1
954 };
955
956 let band = &mut bands[band_index];
957 match kind {
958 Some(b'C') if pseudorange_rank(code, pseudorange_selection) < band.pseudorange_rank => {
959 band.pseudorange_rank = pseudorange_rank(code, pseudorange_selection);
960 band.pseudorange_m = Some(raw_value);
961 }
962 Some(b'L') if band.carrier_phase_cyc.is_none() => {
963 band.carrier_phase_cyc = Some(raw_value);
964 band.lli = value.lli.map(i64::from);
965 }
966 _ => {}
967 }
968 }
969
970 let mut usable = bands
971 .into_iter()
972 .filter(|band| band.pseudorange_m.is_some() && band.carrier_phase_cyc.is_some())
973 .collect::<Vec<_>>();
974 let (band1, band2) = select_dual_frequency_bands(satellite.system, &mut usable)?;
975
976 Some(DualFrequencyObservation {
977 satellite_id: satellite.to_string(),
978 ambiguity_id: format!(
979 "{}:{:.0}:{:.0}",
980 satellite, band1.frequency_hz, band2.frequency_hz
981 ),
982 p1_m: band1.pseudorange_m?,
983 p2_m: band2.pseudorange_m?,
984 phi1_cyc: band1.carrier_phase_cyc?,
985 phi2_cyc: band2.carrier_phase_cyc?,
986 f1_hz: band1.frequency_hz,
987 f2_hz: band2.frequency_hz,
988 lli1: band1.lli,
989 lli2: band2.lli,
990 })
991}
992
993fn select_dual_frequency_bands(
994 system: GnssSystem,
995 usable: &mut [DualFrequencyBand],
996) -> Option<(DualFrequencyBand, DualFrequencyBand)> {
997 if let Some(pair) = default_iono_free_pair(system) {
998 let pair_f1_hz = frequency_hz(system, pair.band1)?;
999 let pair_f2_hz = frequency_hz(system, pair.band2)?;
1000 let band1 = usable
1001 .iter()
1002 .copied()
1003 .find(|band| same_frequency_hz(band.frequency_hz, pair_f1_hz));
1004 let band2 = usable
1005 .iter()
1006 .copied()
1007 .find(|band| same_frequency_hz(band.frequency_hz, pair_f2_hz));
1008 if let (Some(band1), Some(band2)) = (band1, band2) {
1009 return Some((band1, band2));
1010 }
1011 }
1012
1013 usable.sort_by_key(|band| (rinex_band_sort_key(band.rinex_band), band.first_index));
1014 let band1 = *usable.first()?;
1015 let band2 = usable
1016 .iter()
1017 .copied()
1018 .find(|band| !same_frequency_hz(band.frequency_hz, band1.frequency_hz))?;
1019 Some((band1, band2))
1020}
1021
1022fn rinex_band_sort_key(band: char) -> u32 {
1023 band.to_digit(10).unwrap_or(u32::MAX)
1024}
1025
1026fn pseudorange_rank(code: &str, selection: PseudorangeSelection) -> u8 {
1027 match selection {
1028 PseudorangeSelection::HeaderOrder => 0,
1029 PseudorangeSelection::PreferPreciseCode => pseudorange_preference_rank(code),
1030 }
1031}
1032
1033fn pseudorange_preference_rank(code: &str) -> u8 {
1034 match code.chars().nth(2) {
1035 Some('W' | 'P' | 'Y' | 'M' | 'N') => 0,
1036 Some('X') => 1,
1037 Some('C' | 'S' | 'L') => 2,
1038 Some(_) => 3,
1039 None => 4,
1040 }
1041}
1042
1043fn same_frequency_hz(a: f64, b: f64) -> bool {
1044 (a - b).abs() <= 1.0e-3
1045}
1046
1047fn system_from_satellite_token(satellite_id: &str) -> Option<GnssSystem> {
1048 satellite_id
1049 .chars()
1050 .next()
1051 .and_then(GnssSystem::from_letter)
1052}
1053
1054#[derive(Debug, Default)]
1055struct SystemObservationAccum {
1056 satellites: BTreeSet<GnssSatelliteId>,
1057 epochs_with_observations: usize,
1058 value_observations: usize,
1059 expected_observations: usize,
1060}
1061
1062#[derive(Debug, Default)]
1063struct SatelliteAccum {
1064 epochs_with_observations: usize,
1065 value_observations: usize,
1066}
1067
1068#[derive(Debug, Default)]
1069struct SignalAccum {
1070 value_observations: usize,
1071 ssi: SsiAccum,
1072 snr: SnrAccum,
1073}
1074
1075impl SignalAccum {
1076 fn add(&mut self, code: &str, value: Option<f64>, ssi: Option<u8>) {
1077 self.value_observations += 1;
1078 self.ssi.add(ssi);
1079 if code.starts_with('S') {
1080 if let Some(value) = value {
1081 self.snr.add(value);
1082 }
1083 }
1084 }
1085}
1086
1087#[derive(Debug, Default)]
1088struct SsiAccum {
1089 counts: [u64; 10],
1090}
1091
1092impl SsiAccum {
1093 fn add(&mut self, value: Option<u8>) {
1094 let idx = value.unwrap_or(0).min(9) as usize;
1095 self.counts[idx] += 1;
1096 }
1097
1098 fn finish(self) -> Option<SsiHistogram> {
1099 if self.counts.iter().all(|count| *count == 0) {
1100 return None;
1101 }
1102
1103 Some(SsiHistogram {
1104 counts: self.counts,
1105 })
1106 }
1107}
1108
1109#[derive(Debug, Default)]
1110struct SnrAccum {
1111 samples: Vec<f64>,
1112}
1113
1114impl SnrAccum {
1115 fn add(&mut self, value: f64) {
1116 self.samples.push(value);
1117 }
1118
1119 fn finish(self) -> Option<SnrStats> {
1120 if self.samples.is_empty() {
1121 return None;
1122 }
1123 let n = self.samples.len();
1124 let mean = self.samples.iter().sum::<f64>() / n as f64;
1125 let min = self.samples.iter().copied().fold(f64::INFINITY, f64::min);
1126 let max = self
1127 .samples
1128 .iter()
1129 .copied()
1130 .fold(f64::NEG_INFINITY, f64::max);
1131 let std = (n > 1).then(|| {
1132 let sum_sq = self
1133 .samples
1134 .iter()
1135 .map(|value| {
1136 let residual = *value - mean;
1137 residual * residual
1138 })
1139 .sum::<f64>();
1140 (sum_sq / (n - 1) as f64).sqrt()
1141 });
1142 Some(SnrStats {
1143 n,
1144 mean,
1145 min,
1146 max,
1147 std,
1148 })
1149 }
1150}
1151
1152#[cfg(test)]
1153mod tests {
1154 use super::*;
1158 use crate::constants::{C_M_S, F_L1_HZ, F_L2_HZ};
1159 use crate::crinex;
1160 use crate::rinex::observations::{ObsEpoch, ObsHeader, ObsValue};
1161 use serde_json::Value;
1162 use std::collections::BTreeMap;
1163 use std::path::PathBuf;
1164
1165 #[test]
1166 fn observation_qc_counts_epochs_satellites_signals_and_ssi() {
1167 let g01 = sat(1);
1168 let g02 = sat(2);
1169 let obs = observation_file(vec![
1170 epoch(
1171 0,
1172 0.0,
1173 0,
1174 BTreeMap::from([
1175 (
1176 g01,
1177 vec![
1178 obs_value(Some(1.0), Some(5)),
1179 obs_value(Some(2.0), Some(6)),
1180 obs_value(None, None),
1181 ],
1182 ),
1183 (
1184 g02,
1185 vec![
1186 obs_value(Some(10.0), Some(4)),
1187 obs_value(None, None),
1188 obs_value(None, None),
1189 ],
1190 ),
1191 ]),
1192 ),
1193 epoch(
1194 0,
1195 30.0,
1196 1,
1197 BTreeMap::from([(
1198 g01,
1199 vec![
1200 obs_value(Some(3.0), Some(7)),
1201 obs_value(None, None),
1202 obs_value(Some(9.0), Some(8)),
1203 ],
1204 )]),
1205 ),
1206 epoch(1, 0.0, 2, BTreeMap::new()),
1207 ]);
1208
1209 let report = observation_qc(&obs);
1210
1211 assert_eq!(report.total_epoch_records, 3);
1212 assert_eq!(report.observation_epochs, 2);
1213 assert_eq!(report.event_records, 1);
1214 assert_eq!(report.power_failure_epochs, 1);
1215 assert_eq!(report.skipped_records, 0);
1216 assert!(report.clock_jumps.is_empty());
1217 assert_eq!(report.cycle_slips, CycleSlipQc::default());
1218 assert_eq!(report.multipath, MultipathReport::default());
1219 assert_eq!(report.satellites.len(), 2);
1220 assert_eq!(
1221 report.satellites[0],
1222 SatelliteObservationQc {
1223 satellite: g01,
1224 epochs_with_observations: 2,
1225 value_observations: 4,
1226 }
1227 );
1228 assert_eq!(
1229 report.satellites[1],
1230 SatelliteObservationQc {
1231 satellite: g02,
1232 epochs_with_observations: 1,
1233 value_observations: 1,
1234 }
1235 );
1236
1237 let g01_c1c = report
1238 .satellite_signals
1239 .iter()
1240 .find(|signal| signal.satellite == g01 && signal.code == "C1C")
1241 .expect("G01 C1C signal");
1242 assert_eq!(g01_c1c.value_observations, 2);
1243 assert_eq!(
1244 g01_c1c.ssi,
1245 Some(SsiHistogram {
1246 counts: [0, 0, 0, 0, 0, 1, 0, 1, 0, 0],
1247 })
1248 );
1249 assert_eq!(g01_c1c.snr, None);
1250
1251 let gps_c1c = report
1252 .system_signals
1253 .iter()
1254 .find(|signal| signal.system == GnssSystem::Gps && signal.code == "C1C")
1255 .expect("GPS C1C signal");
1256 assert_eq!(gps_c1c.value_observations, 3);
1257 assert_eq!(
1258 gps_c1c.ssi,
1259 Some(SsiHistogram {
1260 counts: [0, 0, 0, 0, 1, 1, 0, 1, 0, 0],
1261 })
1262 );
1263
1264 let gps_s1c = report
1265 .system_signals
1266 .iter()
1267 .find(|signal| signal.system == GnssSystem::Gps && signal.code == "S1C")
1268 .expect("GPS S1C signal");
1269 assert_eq!(
1270 gps_s1c.snr,
1271 Some(SnrStats {
1272 n: 1,
1273 mean: 9.0,
1274 min: 9.0,
1275 max: 9.0,
1276 std: None,
1277 })
1278 );
1279 }
1280
1281 #[test]
1282 fn observation_qc_detects_nominal_interval_gaps() {
1283 let g01 = sat(1);
1284 let obs = observation_file(vec![
1285 epoch(
1286 0,
1287 0.0,
1288 0,
1289 BTreeMap::from([(g01, vec![obs_value(Some(1.0), Some(5))])]),
1290 ),
1291 epoch(
1292 1,
1293 30.0,
1294 0,
1295 BTreeMap::from([(g01, vec![obs_value(Some(2.0), Some(6))])]),
1296 ),
1297 ]);
1298
1299 let report = observation_qc(&obs);
1300
1301 assert_eq!(report.missing_epochs, 2);
1302 assert_eq!(report.data_gaps.len(), 1);
1303 assert_eq!(report.data_gaps[0].nominal_interval_s, 30.0);
1304 assert_eq!(report.data_gaps[0].observed_delta_s, 90.0);
1305 assert_eq!(report.data_gaps[0].missing_epochs, 2);
1306 }
1307
1308 #[test]
1309 fn observation_qc_infers_interval_when_header_is_absent() {
1310 let g01 = sat(1);
1311 let mut obs = observation_file(vec![
1312 epoch(
1313 0,
1314 0.0,
1315 0,
1316 BTreeMap::from([(g01, vec![obs_value(Some(1.0), Some(5))])]),
1317 ),
1318 epoch(
1319 0,
1320 30.0,
1321 0,
1322 BTreeMap::from([(g01, vec![obs_value(Some(2.0), Some(6))])]),
1323 ),
1324 epoch(
1325 2,
1326 0.0,
1327 0,
1328 BTreeMap::from([(g01, vec![obs_value(Some(3.0), Some(7))])]),
1329 ),
1330 ]);
1331 obs.header.interval_s = None;
1332
1333 let report = observation_qc(&obs);
1334
1335 assert_eq!(report.interval_s, Some(30.0));
1336 assert_eq!(report.interval_source, IntervalSource::Inferred);
1337 assert_eq!(report.missing_epochs, 2);
1338 }
1339
1340 #[test]
1341 fn observation_qc_notes_non_monotonic_epochs_and_excludes_them_from_gaps() {
1342 let g01 = sat(1);
1343 let obs = observation_file(vec![
1344 epoch(
1345 1,
1346 0.0,
1347 0,
1348 BTreeMap::from([(g01, vec![obs_value(Some(1.0), Some(5))])]),
1349 ),
1350 epoch(
1351 0,
1352 30.0,
1353 0,
1354 BTreeMap::from([(g01, vec![obs_value(Some(2.0), Some(6))])]),
1355 ),
1356 ]);
1357
1358 let report = observation_qc(&obs);
1359
1360 assert_eq!(
1361 report.notes,
1362 vec![ObservationQcNote::NonMonotonicEpoch { epoch_index: 1 }]
1363 );
1364 assert!(report.data_gaps.is_empty());
1365 }
1366
1367 #[test]
1368 fn observation_qc_rejects_invalid_options() {
1369 let obs = observation_file(Vec::new());
1370
1371 let err = observation_qc_with_options(
1372 &obs,
1373 ObservationQcOptions {
1374 interval_override_s: Some(0.0),
1375 ..ObservationQcOptions::default()
1376 },
1377 )
1378 .expect_err("invalid interval");
1379 assert_eq!(err, ObservationQcError::InvalidInterval);
1380
1381 let err = observation_qc_with_options(
1382 &obs,
1383 ObservationQcOptions {
1384 interval_override_s: None,
1385 gap_factor: 1.0,
1386 ..ObservationQcOptions::default()
1387 },
1388 )
1389 .expect_err("invalid gap factor");
1390 assert_eq!(err, ObservationQcError::InvalidGapFactor);
1391
1392 let err = observation_qc_with_options(
1393 &obs,
1394 ObservationQcOptions {
1395 clock_jump_threshold_s: 0.0,
1396 ..ObservationQcOptions::default()
1397 },
1398 )
1399 .expect_err("invalid clock-jump threshold");
1400 assert_eq!(err, ObservationQcError::InvalidClockJumpThreshold);
1401 }
1402
1403 #[test]
1404 fn detect_clock_jumps_flags_injected_millisecond_step() {
1405 let g01 = sat(1);
1406 let mut obs = observation_file(vec![
1407 epoch(
1408 0,
1409 0.0,
1410 0,
1411 BTreeMap::from([(g01, vec![obs_value(Some(1.0), None)])]),
1412 ),
1413 epoch(
1414 0,
1415 30.0,
1416 0,
1417 BTreeMap::from([(g01, vec![obs_value(Some(2.0), None)])]),
1418 ),
1419 epoch(
1420 1,
1421 0.0,
1422 0,
1423 BTreeMap::from([(g01, vec![obs_value(Some(3.0), None)])]),
1424 ),
1425 epoch(
1426 1,
1427 30.0,
1428 0,
1429 BTreeMap::from([(g01, vec![obs_value(Some(4.0), None)])]),
1430 ),
1431 ]);
1432 let offsets_s = [0.0, 0.000_010, 0.001_020, 0.001_030];
1433 for (epoch, offset_s) in obs.epochs.iter_mut().zip(offsets_s) {
1434 epoch.rcv_clock_offset_s = Some(offset_s);
1435 }
1436
1437 let jumps = detect_clock_jumps(&obs, DEFAULT_CLOCK_JUMP_THRESHOLD_S);
1438
1439 assert_eq!(jumps.len(), 1);
1440 assert_eq!(jumps[0].epoch_index, 2);
1441 assert_close(jumps[0].delta_s, 0.001, "clock jump");
1442
1443 let report = observation_qc(&obs);
1444 assert_eq!(report.clock_jumps, jumps);
1445 }
1446
1447 #[test]
1448 fn detect_clock_jumps_ignores_linear_clock_drift() {
1449 let g01 = sat(1);
1450 let mut obs = observation_file(
1451 (0..4)
1452 .map(|idx| {
1453 epoch(
1454 idx / 2,
1455 if idx % 2 == 0 { 0.0 } else { 30.0 },
1456 0,
1457 BTreeMap::from([(g01, vec![obs_value(Some(idx as f64), None)])]),
1458 )
1459 })
1460 .collect(),
1461 );
1462 for (idx, epoch) in obs.epochs.iter_mut().enumerate() {
1463 epoch.rcv_clock_offset_s = Some(idx as f64 * 0.000_010);
1464 }
1465
1466 assert!(detect_clock_jumps(&obs, DEFAULT_CLOCK_JUMP_THRESHOLD_S).is_empty());
1467 assert!(observation_qc(&obs).clock_jumps.is_empty());
1468 }
1469
1470 #[test]
1471 fn observation_qc_tallies_synthetic_injected_cycle_slip() {
1472 let g01 = sat(1);
1473 let obs = observation_file(
1474 (0usize..5)
1475 .map(|epoch_index| {
1476 let wide_lane_cycles = if epoch_index >= 3 { 14.0 } else { 8.0 };
1477 epoch(
1478 (epoch_index / 2) as u8,
1479 if epoch_index % 2 == 0 { 0.0 } else { 30.0 },
1480 0,
1481 BTreeMap::from([(
1482 g01,
1483 dual_frequency_values(epoch_index, wide_lane_cycles, 0.0),
1484 )]),
1485 )
1486 })
1487 .collect(),
1488 );
1489
1490 let report = observation_qc(&obs);
1491
1492 assert_eq!(
1493 report.cycle_slips,
1494 CycleSlipQc {
1495 observations: 5,
1496 total_slips: 1,
1497 observations_per_slip: Some(5.0),
1498 by_system: vec![SystemCycleSlipQc {
1499 system: GnssSystem::Gps,
1500 observations: 5,
1501 slips: 1,
1502 observations_per_slip: Some(5.0),
1503 }],
1504 }
1505 );
1506 }
1507
1508 #[test]
1509 fn multipath_combination_and_arc_rms_match_closed_form() {
1510 let p1_m = 20_000_010.0;
1511 let l1_m = 20_000_003.0;
1512 let l2_m = 20_000_001.0;
1513 let f2sq = F_L2_HZ * F_L2_HZ;
1514 let denom = F_L1_HZ * F_L1_HZ - f2sq;
1515 let expected = p1_m - l1_m - (2.0 * f2sq / denom) * (l1_m - l2_m);
1516
1517 assert_close(
1518 mp_combination(p1_m, l1_m, l2_m, F_L1_HZ, F_L2_HZ),
1519 expected,
1520 "MP1 combination",
1521 );
1522 assert_close(
1523 arc_multipath_rms(&[1.0, 3.0, 5.0]),
1524 (8.0_f64 / 3.0).sqrt(),
1525 "arc RMS",
1526 );
1527 }
1528
1529 #[test]
1530 fn multipath_stats_splits_arc_on_injected_lli_slip() {
1531 let g01 = sat(1);
1532 let high_threshold_config = CycleSlipConfig {
1533 melbourne_wubbena_threshold_cycles: 1.0e6,
1534 geometry_free_threshold_m: 1.0e6,
1535 minimum_arc_length: 10,
1536 maximum_gap_s: 1.0e6,
1537 ..CycleSlipConfig::default()
1538 };
1539 let with_slip = observation_file(
1540 (0usize..4)
1541 .map(|epoch_index| {
1542 let bias_m = if epoch_index >= 2 { 10.0 } else { 0.0 };
1543 epoch(
1544 (epoch_index / 2) as u8,
1545 if epoch_index % 2 == 0 { 0.0 } else { 30.0 },
1546 0,
1547 BTreeMap::from([(
1548 g01,
1549 dual_frequency_values_with_mp_bias(
1550 epoch_index,
1551 bias_m,
1552 epoch_index == 2,
1553 ),
1554 )]),
1555 )
1556 })
1557 .collect(),
1558 );
1559 let without_slip = observation_file(
1560 (0usize..4)
1561 .map(|epoch_index| {
1562 let bias_m = if epoch_index >= 2 { 10.0 } else { 0.0 };
1563 epoch(
1564 (epoch_index / 2) as u8,
1565 if epoch_index % 2 == 0 { 0.0 } else { 30.0 },
1566 0,
1567 BTreeMap::from([(
1568 g01,
1569 dual_frequency_values_with_mp_bias(epoch_index, bias_m, false),
1570 )]),
1571 )
1572 })
1573 .collect(),
1574 );
1575
1576 let split = multipath_stats(&with_slip, &high_threshold_config);
1577 let unsplit = multipath_stats(&without_slip, &high_threshold_config);
1578 let split_mp1 = split.satellites[0].mp1.expect("split MP1");
1579 let unsplit_mp1 = unsplit.satellites[0].mp1.expect("unsplit MP1");
1580
1581 assert_eq!(split_mp1.n, 4);
1582 assert_eq!(unsplit_mp1.n, 4);
1583 assert_close(split_mp1.rms_m, 0.0, "split MP1 RMS");
1584 assert_close(unsplit_mp1.rms_m, 25.0 / 6.0, "unsplit MP1 RMS");
1585 }
1586
1587 #[test]
1588 fn multipath_matches_teqc_algo0010_oracle() {
1589 let oracle = read_json_fixture("qc/teqc_algo0010_2015001_v1_trim.json");
1590 let path = fixture_path("tests/fixtures/obs/algo0010_2015001_v1_trim.crx");
1591 let crx = std::fs::read_to_string(&path)
1592 .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
1593 let decoded = crinex::decode(&crx).expect("decode CRINEX v1 fixture");
1594 let obs = RinexObs::parse(&decoded).expect("parse decoded RINEX 2 OBS");
1595 let report = observation_qc(&obs);
1596 let gps = report
1597 .multipath
1598 .systems
1599 .iter()
1600 .find(|system| system.system == GnssSystem::Gps)
1601 .expect("GPS multipath row");
1602 let mp1 = gps.mp1.expect("GPS MP1");
1603 let mp2 = gps.mp2.expect("GPS MP2");
1604
1605 assert_eq!(mp1.n, 23);
1606 assert_eq!(mp2.n, 23);
1607 assert_close_tolerance(
1608 mp1.rms_m,
1609 oracle["summary"]["moving_average_mp12_m"].as_f64().unwrap(),
1610 1.0e-6,
1611 "teqc MP12",
1612 );
1613 assert_close_tolerance(
1614 mp2.rms_m,
1615 oracle["summary"]["moving_average_mp21_m"].as_f64().unwrap(),
1616 1.0e-6,
1617 "teqc MP21",
1618 );
1619 }
1620
1621 #[test]
1622 fn observation_qc_accepts_crinex_v1_decoded_rinex2() {
1623 let path = fixture_path("tests/fixtures/obs/algo0010_2015001_v1_trim.crx");
1624 let crx = std::fs::read_to_string(&path)
1625 .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
1626 let decoded = crinex::decode(&crx).expect("decode CRINEX v1 fixture");
1627 let obs = RinexObs::parse(&decoded).expect("parse decoded RINEX 2 OBS");
1628 let report: ObservationQcReport = observation_qc(&obs);
1629
1630 assert_eq!(report.total_epoch_records, 2);
1631 assert_eq!(report.observation_epochs, 2);
1632 assert_eq!(report.event_records, 0);
1633 assert_eq!(report.skipped_records, 0);
1634 assert_eq!(report.interval_s, Some(30.0));
1635 assert_eq!(report.interval_source, IntervalSource::Header);
1636 assert_eq!(report.missing_epochs, 0);
1637 assert_eq!(report.satellites.len(), 20);
1638
1639 let g08 = GnssSatelliteId::new(GnssSystem::Gps, 8).expect("valid GPS PRN");
1640 let g08_report = report
1641 .satellites
1642 .iter()
1643 .find(|sat| sat.satellite == g08)
1644 .expect("G08 QC row");
1645 assert_eq!(g08_report.epochs_with_observations, 2);
1646 assert_eq!(g08_report.value_observations, 14);
1647 }
1648
1649 #[test]
1650 fn observation_qc_matches_independent_real_fixture_oracles() {
1651 let doc = read_json_fixture("qc/observation_qc_real_oracles.json");
1652 assert_eq!(
1653 doc["provenance"]["generator"],
1654 "crates/sidereon-core/fixtures-generators/generate_observation_qc_oracles.py"
1655 );
1656 for fixture in doc["fixtures"].as_array().expect("fixtures array") {
1657 let rel = fixture["path"].as_str().expect("fixture path");
1658 let text = std::fs::read_to_string(fixture_path(rel))
1659 .unwrap_or_else(|e| panic!("read {rel}: {e}"));
1660 let obs = RinexObs::parse(&text).unwrap_or_else(|e| panic!("parse {rel}: {e}"));
1661 let report = observation_qc(&obs);
1662
1663 assert_eq!(
1664 report.total_epoch_records,
1665 fixture["total_epoch_records"].as_u64().unwrap() as usize,
1666 "{rel}"
1667 );
1668 assert_eq!(
1669 report.observation_epochs,
1670 fixture["observation_epochs"].as_u64().unwrap() as usize,
1671 "{rel}"
1672 );
1673 assert_eq!(
1674 report.event_records,
1675 fixture["event_records"].as_u64().unwrap() as usize,
1676 "{rel}"
1677 );
1678 assert_eq!(
1679 report.power_failure_epochs,
1680 fixture["power_failure_epochs"].as_u64().unwrap() as usize,
1681 "{rel}"
1682 );
1683 assert_eq!(
1684 report.skipped_records,
1685 fixture["skipped_records"].as_u64().unwrap() as usize,
1686 "{rel}"
1687 );
1688 assert_close(
1689 report.interval_s.expect("oracle interval"),
1690 fixture["interval_s"].as_f64().unwrap(),
1691 rel,
1692 );
1693 assert_eq!(
1694 report.missing_epochs,
1695 fixture["missing_epochs"].as_u64().unwrap() as usize,
1696 "{rel}"
1697 );
1698 assert_gaps(&report.data_gaps, &fixture["data_gaps"], rel);
1699 assert_satellites(&report.satellites, &fixture["satellites"], rel);
1700 assert_satellite_signals(
1701 &report.satellite_signals,
1702 &fixture["satellite_signals"],
1703 rel,
1704 );
1705 assert_system_signals(&report.system_signals, &fixture["system_signals"], rel);
1706 }
1707 }
1708
1709 #[test]
1710 fn observation_qc_pins_real_fixture_cycle_slip_tally() {
1711 let rel = "tests/fixtures/obs/ESBC00DNK_R_20201770000_01D_30S_MO_120epoch.rnx";
1712 let text = std::fs::read_to_string(fixture_path(rel))
1713 .unwrap_or_else(|e| panic!("read {rel}: {e}"));
1714 let obs = RinexObs::parse(&text).unwrap_or_else(|e| panic!("parse {rel}: {e}"));
1715 let report = observation_qc(&obs);
1716
1717 assert_eq!(report.cycle_slips.observations, 4135);
1718 assert_eq!(report.cycle_slips.total_slips, 27);
1719 assert_close(
1720 report
1721 .cycle_slips
1722 .observations_per_slip
1723 .expect("observations per slip"),
1724 4135.0 / 27.0,
1725 rel,
1726 );
1727
1728 let by_system = report
1729 .cycle_slips
1730 .by_system
1731 .iter()
1732 .map(|row| {
1733 (
1734 row.system,
1735 (
1736 row.observations,
1737 row.slips,
1738 row.observations_per_slip
1739 .expect("system observations per slip"),
1740 ),
1741 )
1742 })
1743 .collect::<BTreeMap<_, _>>();
1744 assert_eq!(by_system[&GnssSystem::Gps].0, 1282);
1745 assert_eq!(by_system[&GnssSystem::Gps].1, 4);
1746 assert_close(by_system[&GnssSystem::Gps].2, 1282.0 / 4.0, rel);
1747 assert_eq!(by_system[&GnssSystem::Glonass].0, 784);
1748 assert_eq!(by_system[&GnssSystem::Glonass].1, 10);
1749 assert_close(by_system[&GnssSystem::Glonass].2, 784.0 / 10.0, rel);
1750 assert_eq!(by_system[&GnssSystem::Galileo].0, 1023);
1751 assert_eq!(by_system[&GnssSystem::Galileo].1, 9);
1752 assert_close(by_system[&GnssSystem::Galileo].2, 1023.0 / 9.0, rel);
1753 assert_eq!(by_system[&GnssSystem::BeiDou].0, 1046);
1754 assert_eq!(by_system[&GnssSystem::BeiDou].1, 4);
1755 assert_close(by_system[&GnssSystem::BeiDou].2, 1046.0 / 4.0, rel);
1756 }
1757
1758 #[test]
1759 fn observation_qc_report_text_snapshot_esbc00dnk() {
1760 let rel = "tests/fixtures/obs/ESBC00DNK_R_20201770000_01D_30S_MO_120epoch.rnx";
1761 let text = std::fs::read_to_string(fixture_path(rel))
1762 .unwrap_or_else(|e| panic!("read {rel}: {e}"));
1763 let obs = RinexObs::parse(&text).unwrap_or_else(|e| panic!("parse {rel}: {e}"));
1764 let report = observation_qc(&obs);
1765 let rendered = render_text(&report);
1766
1767 assert_eq!(rendered, ESBC_QC_REPORT_TEXT);
1768 assert!(rendered.contains("G GPS"));
1769 assert!(rendered.contains("R GLONASS"));
1770 assert!(rendered.contains("E Galileo"));
1771 assert!(rendered.contains("C BeiDou"));
1772 assert!(rendered.contains("S SBAS"));
1773 assert!(rendered.contains("0.292"));
1774 assert!(rendered.contains("1.174"));
1775 assert!(rendered.contains("FINDINGS"));
1776 assert!(rendered.contains("CODE SEVERITY SPEC REF"));
1777 }
1778
1779 #[test]
1780 fn observation_qc_report_json_contains_expected_fields_esbc00dnk() {
1781 let rel = "tests/fixtures/obs/ESBC00DNK_R_20201770000_01D_30S_MO_120epoch.rnx";
1782 let text = std::fs::read_to_string(fixture_path(rel))
1783 .unwrap_or_else(|e| panic!("read {rel}: {e}"));
1784 let obs = RinexObs::parse(&text).unwrap_or_else(|e| panic!("parse {rel}: {e}"));
1785 let report = observation_qc(&obs);
1786
1787 let encoded = serde_json::to_string(&report).expect("serialize QC report");
1788 let doc: Value = serde_json::from_str(&encoded).expect("parse serialized QC report");
1789
1790 assert_eq!(doc["header"]["marker_name"], "ESBC00DNK");
1791 assert_eq!(doc["header"]["receiver"]["receiver_type"], "SEPT POLARX5");
1792 assert_eq!(doc["interval_s"], 30.0);
1793
1794 let gps = json_system(&doc, "Gps");
1795 assert_eq!(gps["satellites_seen"], 13);
1796 assert_eq!(gps["epochs_with_observations"], 120);
1797 assert_eq!(gps["value_observations"], 18645);
1798 assert_close(
1799 gps["completeness_ratio"].as_f64().unwrap(),
1800 0.800489438433797,
1801 "GPS JSON completeness",
1802 );
1803
1804 let gps_mp = json_multipath_system(&doc, "Gps");
1805 assert_close(
1806 gps_mp["mp1"]["rms_m"].as_f64().unwrap(),
1807 0.29240479301672934,
1808 "GPS JSON MP1",
1809 );
1810 let beidou_mp = json_multipath_system(&doc, "BeiDou");
1811 assert_close(
1812 beidou_mp["mp2"]["rms_m"].as_f64().unwrap(),
1813 1.1736185873490712,
1814 "BeiDou JSON MP2",
1815 );
1816
1817 let galileo_slips = doc["cycle_slips"]["by_system"]
1818 .as_array()
1819 .expect("cycle slip systems")
1820 .iter()
1821 .find(|row| row["system"] == "Galileo")
1822 .expect("Galileo cycle slip row");
1823 assert_eq!(galileo_slips["slips"], 9);
1824 assert_eq!(doc["lint_findings"].as_array().unwrap().len(), 0);
1825 }
1826
1827 #[test]
1828 fn observation_qc_report_html_contains_rows_without_external_assets() {
1829 let rel = "tests/fixtures/obs/ESBC00DNK_R_20201770000_01D_30S_MO_120epoch.rnx";
1830 let text = std::fs::read_to_string(fixture_path(rel))
1831 .unwrap_or_else(|e| panic!("read {rel}: {e}"));
1832 let obs = RinexObs::parse(&text).unwrap_or_else(|e| panic!("parse {rel}: {e}"));
1833 let report = observation_qc(&obs);
1834 let html = render_html(&report);
1835
1836 assert!(html.contains("<td class=\"text\">G</td>"));
1837 assert!(html.contains("<td class=\"text\">GPS</td>"));
1838 assert!(html.contains("<td>0.292</td>"));
1839 assert!(html.contains("<td>1.174</td>"));
1840 assert!(html.contains("<h2>Findings</h2>"));
1841 assert!(!html.contains("http"));
1842 }
1843
1844 const ESBC_QC_REPORT_TEXT: &str = r#"RINEX OBSERVATION QC +QC SUMMARY
1845
1846HEADER
1847 MARKER NAME ESBC00DNK
1848 MARKER NUMBER 10118M001
1849 MARKER TYPE GEODETIC
1850 RECEIVER 3047937 / SEPT POLARX5 / 5.2.0
1851 ANTENNA CR5200327016 / ASH701945E_M SCIS
1852 POSITION XYZ M 3582105.2910 532589.7313 5232754.8054
1853 ANTENNA HEN M 0.2160 0.0000 0.0000
1854 TIME FIRST 2020-06-25 00:00:00.0000000 GPS
1855 TIME LAST 2020-06-25 00:59:30.0000000 GPS
1856 INTERVAL S 30.000 (header)
1857 DURATION S 3570.0
1858
1859PER-CONSTELLATION
1860SYS NAME SATS EPOCHS OBS EXPECT COMP SNR MEAN/MIN BY BAND MP1 RMS MP2 RMS SLIPS GAPS GAP S
1861--- -------- ---- ------ -------- -------- -------- ------------------------------------------------------------------------------------------------ -------- -------- ------ ---- ---------
1862G 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
1863R 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
1864E 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
1865C 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
1866S SBAS 5 120 3032 4144 0.732 1:38.2/30.8 5:33.5/31.5 - - 0 0 0.0
1867
1868FINDINGS
1869CODE SEVERITY SPEC REF
1870-------- -------- ------------------------------------------------
1871NONE
1872"#;
1873
1874 fn observation_file(epochs: Vec<ObsEpoch>) -> RinexObs {
1875 RinexObs {
1876 header: ObsHeader {
1877 version: 3.05,
1878 approx_position_m: None,
1879 antenna_delta_hen_m: None,
1880 obs_codes: BTreeMap::from([(
1881 GnssSystem::Gps,
1882 vec![
1883 "C1C".to_string(),
1884 "L1C".to_string(),
1885 "S1C".to_string(),
1886 "C2W".to_string(),
1887 "L2W".to_string(),
1888 ],
1889 )]),
1890 program_run_by_date: None,
1891 comments: Vec::new(),
1892 marker_number: None,
1893 marker_type: None,
1894 observer: None,
1895 agency: None,
1896 receiver: None,
1897 antenna: None,
1898 interval_s: Some(30.0),
1899 time_of_first_obs: None,
1900 time_of_last_obs: None,
1901 n_satellites: None,
1902 prn_obs_counts: BTreeMap::new(),
1903 phase_shifts: Vec::new(),
1904 scale_factors: Vec::new(),
1905 glonass_slots: BTreeMap::new(),
1906 glonass_cod_phs_bis: None,
1907 signal_strength_unit: None,
1908 leap_seconds: None,
1909 marker_name: None,
1910 unretained_header_labels: Vec::new(),
1911 },
1912 epochs,
1913 skipped_records: 0,
1914 }
1915 }
1916
1917 fn epoch(
1918 minute: u8,
1919 second: f64,
1920 flag: u8,
1921 sats: BTreeMap<GnssSatelliteId, Vec<ObsValue>>,
1922 ) -> ObsEpoch {
1923 ObsEpoch {
1924 epoch: ObsEpochTime {
1925 year: 2024,
1926 month: 1,
1927 day: 1,
1928 hour: 0,
1929 minute,
1930 second,
1931 },
1932 flag,
1933 rcv_clock_offset_s: None,
1934 epoch_picoseconds: None,
1935 declared_record_count: sats.len(),
1936 special_record_count: if flag > 1 { sats.len() } else { 0 },
1937 sats,
1938 }
1939 }
1940
1941 fn obs_value(value: Option<f64>, ssi: Option<u8>) -> ObsValue {
1942 ObsValue {
1943 value,
1944 lli: None,
1945 ssi,
1946 }
1947 }
1948
1949 fn dual_frequency_values(
1950 epoch_index: usize,
1951 melbourne_wubbena_cycles: f64,
1952 geometry_free_m: f64,
1953 ) -> Vec<ObsValue> {
1954 let geometric_m = 23_000_000.0 + epoch_index as f64 * 100.0;
1955 let lambda1 = C_M_S / F_L1_HZ;
1956 let lambda2 = C_M_S / F_L2_HZ;
1957 let lambda_wl = C_M_S / (F_L1_HZ - F_L2_HZ);
1958 let l2_m = geometric_m + lambda_wl * (melbourne_wubbena_cycles - geometry_free_m / lambda1);
1959 let l1_m = l2_m + geometry_free_m;
1960
1961 vec![
1962 obs_value(Some(geometric_m), None),
1963 obs_value(Some(l1_m / lambda1), None),
1964 obs_value(None, None),
1965 obs_value(Some(geometric_m), None),
1966 obs_value(Some(l2_m / lambda2), None),
1967 ]
1968 }
1969
1970 fn dual_frequency_values_with_mp_bias(
1971 epoch_index: usize,
1972 p_bias_m: f64,
1973 lli_slip: bool,
1974 ) -> Vec<ObsValue> {
1975 let mut values = dual_frequency_values(epoch_index, 8.0, 0.0);
1976 values[0].value = values[0].value.map(|value| value + p_bias_m);
1977 values[3].value = values[3].value.map(|value| value + p_bias_m);
1978 if lli_slip {
1979 values[1].lli = Some(1);
1980 }
1981 values
1982 }
1983
1984 fn sat(prn: u8) -> GnssSatelliteId {
1985 GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid GPS PRN")
1986 }
1987
1988 fn fixture_path(rel: &str) -> PathBuf {
1989 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(rel)
1990 }
1991
1992 fn read_json_fixture(rel: &str) -> Value {
1993 let path = fixture_path(&format!("tests/fixtures/{rel}"));
1994 let raw = std::fs::read_to_string(&path)
1995 .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
1996 serde_json::from_str(&raw).unwrap_or_else(|e| panic!("parse {}: {e}", path.display()))
1997 }
1998
1999 fn json_system<'a>(doc: &'a Value, system: &str) -> &'a Value {
2000 doc["systems"]
2001 .as_array()
2002 .expect("systems")
2003 .iter()
2004 .find(|row| row["system"] == system)
2005 .unwrap_or_else(|| panic!("missing JSON system {system}"))
2006 }
2007
2008 fn json_multipath_system<'a>(doc: &'a Value, system: &str) -> &'a Value {
2009 doc["multipath"]["systems"]
2010 .as_array()
2011 .expect("multipath systems")
2012 .iter()
2013 .find(|row| row["system"] == system)
2014 .unwrap_or_else(|| panic!("missing JSON multipath system {system}"))
2015 }
2016
2017 fn assert_close(actual: f64, expected: f64, context: &str) {
2018 assert_close_tolerance(actual, expected, 1.0e-9, context);
2019 }
2020
2021 fn assert_close_tolerance(actual: f64, expected: f64, tolerance: f64, context: &str) {
2022 assert!(
2023 (actual - expected).abs() <= tolerance,
2024 "{context}: actual {actual:?}, expected {expected:?}"
2025 );
2026 }
2027
2028 fn assert_gaps(actual: &[ObservationDataGap], expected: &Value, context: &str) {
2029 let expected = expected.as_array().expect("gap array");
2030 assert_eq!(actual.len(), expected.len(), "{context}");
2031 for (actual, expected) in actual.iter().zip(expected) {
2032 assert_epoch(&actual.start_epoch, &expected["start_epoch"], context);
2033 assert_epoch(&actual.end_epoch, &expected["end_epoch"], context);
2034 assert_close(
2035 actual.nominal_interval_s,
2036 expected["nominal_interval_s"].as_f64().unwrap(),
2037 context,
2038 );
2039 assert_close(
2040 actual.observed_delta_s,
2041 expected["observed_delta_s"].as_f64().unwrap(),
2042 context,
2043 );
2044 assert_eq!(
2045 actual.missing_epochs,
2046 expected["missing_epochs"].as_u64().unwrap() as usize,
2047 "{context}"
2048 );
2049 }
2050 }
2051
2052 fn assert_epoch(actual: &ObsEpochTime, expected: &Value, context: &str) {
2053 assert_eq!(
2054 actual.year,
2055 expected["year"].as_i64().unwrap() as i32,
2056 "{context}"
2057 );
2058 assert_eq!(
2059 actual.month,
2060 expected["month"].as_u64().unwrap() as u8,
2061 "{context}"
2062 );
2063 assert_eq!(
2064 actual.day,
2065 expected["day"].as_u64().unwrap() as u8,
2066 "{context}"
2067 );
2068 assert_eq!(
2069 actual.hour,
2070 expected["hour"].as_u64().unwrap() as u8,
2071 "{context}"
2072 );
2073 assert_eq!(
2074 actual.minute,
2075 expected["minute"].as_u64().unwrap() as u8,
2076 "{context}"
2077 );
2078 assert_close(actual.second, expected["second"].as_f64().unwrap(), context);
2079 }
2080
2081 fn assert_satellites(actual: &[SatelliteObservationQc], expected: &Value, context: &str) {
2082 let expected = expected.as_array().expect("satellites array");
2083 assert_eq!(actual.len(), expected.len(), "{context}");
2084 let actual = actual
2085 .iter()
2086 .map(|sat| {
2087 (
2088 sat.satellite.to_string(),
2089 (sat.epochs_with_observations, sat.value_observations),
2090 )
2091 })
2092 .collect::<BTreeMap<_, _>>();
2093 for expected in expected {
2094 let satellite = expected["satellite"].as_str().unwrap();
2095 let actual = actual
2096 .get(satellite)
2097 .unwrap_or_else(|| panic!("{context}: missing satellite {satellite}"));
2098 assert_eq!(
2099 actual.0,
2100 expected["epochs_with_observations"].as_u64().unwrap() as usize,
2101 "{context} {satellite}"
2102 );
2103 assert_eq!(
2104 actual.1,
2105 expected["value_observations"].as_u64().unwrap() as usize,
2106 "{context} {satellite}"
2107 );
2108 }
2109 }
2110
2111 fn assert_satellite_signals(actual: &[SatelliteSignalQc], expected: &Value, context: &str) {
2112 let expected = expected.as_array().expect("satellite signals array");
2113 assert_eq!(actual.len(), expected.len(), "{context}");
2114 let actual = actual
2115 .iter()
2116 .map(|signal| {
2117 (
2118 (signal.satellite.to_string(), signal.code.as_str()),
2119 (signal.value_observations, signal.ssi, signal.snr),
2120 )
2121 })
2122 .collect::<BTreeMap<_, _>>();
2123 for expected in expected {
2124 let satellite = expected["satellite"].as_str().unwrap();
2125 let code = expected["code"].as_str().unwrap();
2126 let actual = actual
2127 .get(&(satellite.to_string(), code))
2128 .unwrap_or_else(|| panic!("{context}: missing {satellite} {code}"));
2129 assert_eq!(
2130 actual.0,
2131 expected["value_observations"].as_u64().unwrap() as usize,
2132 "{context} {satellite} {code}"
2133 );
2134 assert_ssi(actual.1, &expected["ssi"], context);
2135 assert_snr(actual.2, &expected["snr"], context);
2136 }
2137 }
2138
2139 fn assert_system_signals(actual: &[SystemSignalQc], expected: &Value, context: &str) {
2140 let expected = expected.as_array().expect("system signals array");
2141 assert_eq!(actual.len(), expected.len(), "{context}");
2142 let actual = actual
2143 .iter()
2144 .map(|signal| {
2145 (
2146 (signal.system.letter().to_string(), signal.code.as_str()),
2147 (signal.value_observations, signal.ssi, signal.snr),
2148 )
2149 })
2150 .collect::<BTreeMap<_, _>>();
2151 for expected in expected {
2152 let system = expected["system"].as_str().unwrap();
2153 let code = expected["code"].as_str().unwrap();
2154 let actual = actual
2155 .get(&(system.to_string(), code))
2156 .unwrap_or_else(|| panic!("{context}: missing {system} {code}"));
2157 assert_eq!(
2158 actual.0,
2159 expected["value_observations"].as_u64().unwrap() as usize,
2160 "{context} {system} {code}"
2161 );
2162 assert_ssi(actual.1, &expected["ssi"], context);
2163 assert_snr(actual.2, &expected["snr"], context);
2164 }
2165 }
2166
2167 fn assert_ssi(actual: Option<SsiHistogram>, expected: &Value, context: &str) {
2168 if expected.is_null() {
2169 assert_eq!(actual, None, "{context}");
2170 return;
2171 }
2172 let expected = expected
2173 .as_array()
2174 .expect("ssi array")
2175 .iter()
2176 .map(|value| value.as_u64().unwrap())
2177 .collect::<Vec<_>>();
2178 assert_eq!(actual.expect("ssi").counts.to_vec(), expected, "{context}");
2179 }
2180
2181 fn assert_snr(actual: Option<SnrStats>, expected: &Value, context: &str) {
2182 if expected.is_null() {
2183 assert_eq!(actual, None, "{context}");
2184 return;
2185 }
2186 let actual = actual.expect("snr");
2187 assert_eq!(
2188 actual.n,
2189 expected["n"].as_u64().unwrap() as usize,
2190 "{context}"
2191 );
2192 assert_close(actual.mean, expected["mean"].as_f64().unwrap(), context);
2193 assert_close(actual.min, expected["min"].as_f64().unwrap(), context);
2194 assert_close(actual.max, expected["max"].as_f64().unwrap(), context);
2195 if expected["std"].is_null() {
2196 assert_eq!(actual.std, None, "{context}");
2197 } else {
2198 assert_close(
2199 actual.std.expect("std"),
2200 expected["std"].as_f64().unwrap(),
2201 context,
2202 );
2203 }
2204 }
2205}