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: 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
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 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 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}