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