1use std::collections::BTreeMap;
8
9use crate::id::{GnssSatelliteId, GnssSystem};
10use crate::rinex::observations::{ObsEpochTime, RinexObs};
11use crate::rinex_common::{dominant_obs_interval_s, obs_epoch_seconds};
12
13#[derive(Debug, Clone, Copy, PartialEq)]
15pub struct ObservationQcOptions {
16 pub interval_override_s: Option<f64>,
18 pub gap_factor: f64,
20}
21
22impl Default for ObservationQcOptions {
23 fn default() -> Self {
24 Self {
25 interval_override_s: None,
26 gap_factor: 1.5,
27 }
28 }
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)]
33pub enum ObservationQcError {
34 #[error("invalid observation QC interval: must be finite and positive")]
36 InvalidInterval,
37 #[error("invalid observation QC gap factor: must be finite and greater than one")]
39 InvalidGapFactor,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum IntervalSource {
45 Override,
47 Header,
49 Inferred,
51 Unresolved,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum ObservationQcNote {
58 NonMonotonicEpoch { epoch_index: usize },
60 IntervalUnresolved,
62}
63
64#[derive(Debug, Clone, PartialEq)]
66pub struct ObservationQcReport {
67 pub total_epoch_records: usize,
69 pub observation_epochs: usize,
72 pub event_records: usize,
74 pub power_failure_epochs: usize,
76 pub skipped_records: usize,
78 pub interval_s: Option<f64>,
80 pub interval_source: IntervalSource,
82 pub missing_epochs: usize,
84 pub data_gaps: Vec<ObservationDataGap>,
86 pub satellites: Vec<SatelliteObservationQc>,
88 pub satellite_signals: Vec<SatelliteSignalQc>,
90 pub system_signals: Vec<SystemSignalQc>,
92 pub notes: Vec<ObservationQcNote>,
94}
95
96#[derive(Debug, Clone, PartialEq)]
98pub struct ObservationDataGap {
99 pub start_epoch: ObsEpochTime,
101 pub end_epoch: ObsEpochTime,
103 pub nominal_interval_s: f64,
105 pub observed_delta_s: f64,
107 pub missing_epochs: usize,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct SatelliteObservationQc {
114 pub satellite: GnssSatelliteId,
116 pub epochs_with_observations: usize,
118 pub value_observations: usize,
120}
121
122#[derive(Debug, Clone, PartialEq)]
124pub struct SatelliteSignalQc {
125 pub satellite: GnssSatelliteId,
127 pub code: String,
129 pub value_observations: usize,
131 pub ssi: Option<SsiHistogram>,
134 pub snr: Option<SnrStats>,
136}
137
138#[derive(Debug, Clone, PartialEq)]
140pub struct SystemSignalQc {
141 pub system: GnssSystem,
143 pub code: String,
145 pub value_observations: usize,
147 pub ssi: Option<SsiHistogram>,
150 pub snr: Option<SnrStats>,
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub struct SsiHistogram {
157 pub counts: [u64; 10],
159}
160
161#[derive(Debug, Clone, Copy, PartialEq)]
163pub struct SnrStats {
164 pub n: usize,
166 pub mean: f64,
168 pub min: f64,
170 pub max: f64,
172 pub std: Option<f64>,
174}
175
176pub fn observation_qc(obs: &RinexObs) -> ObservationQcReport {
178 observation_qc_with_options(obs, ObservationQcOptions::default())
179 .expect("default observation QC options are valid")
180}
181
182pub fn observation_qc_with_options(
184 obs: &RinexObs,
185 options: ObservationQcOptions,
186) -> Result<ObservationQcReport, ObservationQcError> {
187 validate_options(options)?;
188
189 let mut satellites: BTreeMap<GnssSatelliteId, SatelliteAccum> = BTreeMap::new();
190 let mut satellite_signals: BTreeMap<(GnssSatelliteId, String), SignalAccum> = BTreeMap::new();
191 let mut system_signals: BTreeMap<(GnssSystem, String), SignalAccum> = BTreeMap::new();
192 let mut observation_epoch_times = Vec::new();
193
194 let mut observation_epochs = 0;
195 let mut event_records = 0;
196 let mut power_failure_epochs = 0;
197
198 for epoch in obs.epochs() {
199 if epoch.flag > 1 {
200 event_records += 1;
201 continue;
202 }
203
204 observation_epochs += 1;
205 if epoch.flag == 1 {
206 power_failure_epochs += 1;
207 }
208 observation_epoch_times.push(epoch.epoch);
209
210 for (satellite, values) in &epoch.sats {
211 let value_observations = values.iter().filter(|value| value.value.is_some()).count();
212 if value_observations == 0 {
213 continue;
214 }
215
216 let satellite_acc = satellites.entry(*satellite).or_default();
217 satellite_acc.epochs_with_observations += 1;
218 satellite_acc.value_observations += value_observations;
219
220 let Some(codes) = obs.header().obs_codes.get(&satellite.system) else {
221 continue;
222 };
223
224 for (index, value) in values.iter().enumerate() {
225 if value.value.is_none() {
226 continue;
227 }
228
229 let Some(code) = codes.get(index) else {
230 continue;
231 };
232
233 let sat_signal = satellite_signals
234 .entry((*satellite, code.clone()))
235 .or_default();
236 sat_signal.add(code, value.value, value.ssi);
237
238 let sys_signal = system_signals
239 .entry((satellite.system, code.clone()))
240 .or_default();
241 sys_signal.add(code, value.value, value.ssi);
242 }
243 }
244 }
245
246 let mut notes = non_monotonic_notes(&observation_epoch_times);
247 let (interval_s, interval_source) =
248 resolve_interval(obs, options, &observation_epoch_times, &mut notes)?;
249 let data_gaps = detect_gaps(options, &observation_epoch_times, interval_s)?;
250 let missing_epochs = data_gaps.iter().map(|gap| gap.missing_epochs).sum();
251
252 Ok(ObservationQcReport {
253 total_epoch_records: obs.epochs().len(),
254 observation_epochs,
255 event_records,
256 power_failure_epochs,
257 skipped_records: obs.skipped_records,
258 interval_s,
259 interval_source,
260 missing_epochs,
261 data_gaps,
262 satellites: satellites
263 .into_iter()
264 .map(|(satellite, acc)| SatelliteObservationQc {
265 satellite,
266 epochs_with_observations: acc.epochs_with_observations,
267 value_observations: acc.value_observations,
268 })
269 .collect(),
270 satellite_signals: satellite_signals
271 .into_iter()
272 .map(|((satellite, code), acc)| SatelliteSignalQc {
273 satellite,
274 code,
275 value_observations: acc.value_observations,
276 ssi: acc.ssi.finish(),
277 snr: acc.snr.finish(),
278 })
279 .collect(),
280 system_signals: system_signals
281 .into_iter()
282 .map(|((system, code), acc)| SystemSignalQc {
283 system,
284 code,
285 value_observations: acc.value_observations,
286 ssi: acc.ssi.finish(),
287 snr: acc.snr.finish(),
288 })
289 .collect(),
290 notes,
291 })
292}
293
294fn validate_options(options: ObservationQcOptions) -> Result<(), ObservationQcError> {
295 if !options.gap_factor.is_finite() || options.gap_factor <= 1.0 {
296 return Err(ObservationQcError::InvalidGapFactor);
297 }
298
299 if let Some(interval_s) = options.interval_override_s {
300 validate_interval(interval_s)?;
301 }
302
303 Ok(())
304}
305
306fn validate_interval(interval_s: f64) -> Result<(), ObservationQcError> {
307 if interval_s.is_finite() && interval_s > 0.0 {
308 Ok(())
309 } else {
310 Err(ObservationQcError::InvalidInterval)
311 }
312}
313
314fn resolve_interval(
315 obs: &RinexObs,
316 options: ObservationQcOptions,
317 observation_epoch_times: &[ObsEpochTime],
318 notes: &mut Vec<ObservationQcNote>,
319) -> Result<(Option<f64>, IntervalSource), ObservationQcError> {
320 let Some(interval_s) = options.interval_override_s else {
321 if let Some(interval_s) = obs.header().interval_s {
322 validate_interval(interval_s)?;
323 return Ok((Some(interval_s), IntervalSource::Header));
324 }
325 if let Some(interval_s) = dominant_obs_interval_s(observation_epoch_times) {
326 return Ok((Some(interval_s), IntervalSource::Inferred));
327 }
328 notes.push(ObservationQcNote::IntervalUnresolved);
329 return Ok((None, IntervalSource::Unresolved));
330 };
331 validate_interval(interval_s)?;
332 Ok((Some(interval_s), IntervalSource::Override))
333}
334
335fn detect_gaps(
336 options: ObservationQcOptions,
337 observation_epoch_times: &[ObsEpochTime],
338 interval_s: Option<f64>,
339) -> Result<Vec<ObservationDataGap>, ObservationQcError> {
340 let Some(interval_s) = interval_s else {
341 return Ok(Vec::new());
342 };
343
344 let mut gaps = Vec::new();
345 for window in observation_epoch_times.windows(2) {
346 let start_epoch = window[0];
347 let end_epoch = window[1];
348 let observed_delta_s = obs_epoch_seconds(end_epoch) - obs_epoch_seconds(start_epoch);
349 if observed_delta_s <= 0.0 || observed_delta_s <= interval_s * options.gap_factor {
350 continue;
351 }
352
353 let missing_epochs = ((observed_delta_s / interval_s).round() as isize - 1) as usize;
354 gaps.push(ObservationDataGap {
355 start_epoch,
356 end_epoch,
357 nominal_interval_s: interval_s,
358 observed_delta_s,
359 missing_epochs,
360 });
361 }
362
363 Ok(gaps)
364}
365
366fn non_monotonic_notes(observation_epoch_times: &[ObsEpochTime]) -> Vec<ObservationQcNote> {
367 let mut notes = Vec::new();
368 for (idx, window) in observation_epoch_times.windows(2).enumerate() {
369 if obs_epoch_seconds(window[1]) - obs_epoch_seconds(window[0]) <= 0.0 {
370 notes.push(ObservationQcNote::NonMonotonicEpoch {
371 epoch_index: idx + 1,
372 });
373 }
374 }
375 notes
376}
377
378#[derive(Debug, Default)]
379struct SatelliteAccum {
380 epochs_with_observations: usize,
381 value_observations: usize,
382}
383
384#[derive(Debug, Default)]
385struct SignalAccum {
386 value_observations: usize,
387 ssi: SsiAccum,
388 snr: SnrAccum,
389}
390
391impl SignalAccum {
392 fn add(&mut self, code: &str, value: Option<f64>, ssi: Option<u8>) {
393 self.value_observations += 1;
394 self.ssi.add(ssi);
395 if code.starts_with('S') {
396 if let Some(value) = value {
397 self.snr.add(value);
398 }
399 }
400 }
401}
402
403#[derive(Debug, Default)]
404struct SsiAccum {
405 counts: [u64; 10],
406}
407
408impl SsiAccum {
409 fn add(&mut self, value: Option<u8>) {
410 let idx = value.unwrap_or(0).min(9) as usize;
411 self.counts[idx] += 1;
412 }
413
414 fn finish(self) -> Option<SsiHistogram> {
415 if self.counts.iter().all(|count| *count == 0) {
416 return None;
417 }
418
419 Some(SsiHistogram {
420 counts: self.counts,
421 })
422 }
423}
424
425#[derive(Debug, Default)]
426struct SnrAccum {
427 samples: Vec<f64>,
428}
429
430impl SnrAccum {
431 fn add(&mut self, value: f64) {
432 self.samples.push(value);
433 }
434
435 fn finish(self) -> Option<SnrStats> {
436 if self.samples.is_empty() {
437 return None;
438 }
439 let n = self.samples.len();
440 let mean = self.samples.iter().sum::<f64>() / n as f64;
441 let min = self.samples.iter().copied().fold(f64::INFINITY, f64::min);
442 let max = self
443 .samples
444 .iter()
445 .copied()
446 .fold(f64::NEG_INFINITY, f64::max);
447 let std = (n > 1).then(|| {
448 let sum_sq = self
449 .samples
450 .iter()
451 .map(|value| {
452 let residual = *value - mean;
453 residual * residual
454 })
455 .sum::<f64>();
456 (sum_sq / (n - 1) as f64).sqrt()
457 });
458 Some(SnrStats {
459 n,
460 mean,
461 min,
462 max,
463 std,
464 })
465 }
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471 use crate::rinex::observations::{ObsEpoch, ObsHeader, ObsValue};
472 use serde_json::Value;
473 use std::collections::BTreeMap;
474 use std::path::PathBuf;
475
476 #[test]
477 fn observation_qc_counts_epochs_satellites_signals_and_ssi() {
478 let g01 = sat(1);
479 let g02 = sat(2);
480 let obs = observation_file(vec![
481 epoch(
482 0,
483 0.0,
484 0,
485 BTreeMap::from([
486 (
487 g01,
488 vec![
489 obs_value(Some(1.0), Some(5)),
490 obs_value(Some(2.0), Some(6)),
491 obs_value(None, None),
492 ],
493 ),
494 (
495 g02,
496 vec![
497 obs_value(Some(10.0), Some(4)),
498 obs_value(None, None),
499 obs_value(None, None),
500 ],
501 ),
502 ]),
503 ),
504 epoch(
505 0,
506 30.0,
507 1,
508 BTreeMap::from([(
509 g01,
510 vec![
511 obs_value(Some(3.0), Some(7)),
512 obs_value(None, None),
513 obs_value(Some(9.0), Some(8)),
514 ],
515 )]),
516 ),
517 epoch(1, 0.0, 2, BTreeMap::new()),
518 ]);
519
520 let report = observation_qc(&obs);
521
522 assert_eq!(report.total_epoch_records, 3);
523 assert_eq!(report.observation_epochs, 2);
524 assert_eq!(report.event_records, 1);
525 assert_eq!(report.power_failure_epochs, 1);
526 assert_eq!(report.skipped_records, 0);
527 assert_eq!(report.satellites.len(), 2);
528 assert_eq!(
529 report.satellites[0],
530 SatelliteObservationQc {
531 satellite: g01,
532 epochs_with_observations: 2,
533 value_observations: 4,
534 }
535 );
536 assert_eq!(
537 report.satellites[1],
538 SatelliteObservationQc {
539 satellite: g02,
540 epochs_with_observations: 1,
541 value_observations: 1,
542 }
543 );
544
545 let g01_c1c = report
546 .satellite_signals
547 .iter()
548 .find(|signal| signal.satellite == g01 && signal.code == "C1C")
549 .expect("G01 C1C signal");
550 assert_eq!(g01_c1c.value_observations, 2);
551 assert_eq!(
552 g01_c1c.ssi,
553 Some(SsiHistogram {
554 counts: [0, 0, 0, 0, 0, 1, 0, 1, 0, 0],
555 })
556 );
557 assert_eq!(g01_c1c.snr, None);
558
559 let gps_c1c = report
560 .system_signals
561 .iter()
562 .find(|signal| signal.system == GnssSystem::Gps && signal.code == "C1C")
563 .expect("GPS C1C signal");
564 assert_eq!(gps_c1c.value_observations, 3);
565 assert_eq!(
566 gps_c1c.ssi,
567 Some(SsiHistogram {
568 counts: [0, 0, 0, 0, 1, 1, 0, 1, 0, 0],
569 })
570 );
571
572 let gps_s1c = report
573 .system_signals
574 .iter()
575 .find(|signal| signal.system == GnssSystem::Gps && signal.code == "S1C")
576 .expect("GPS S1C signal");
577 assert_eq!(
578 gps_s1c.snr,
579 Some(SnrStats {
580 n: 1,
581 mean: 9.0,
582 min: 9.0,
583 max: 9.0,
584 std: None,
585 })
586 );
587 }
588
589 #[test]
590 fn observation_qc_detects_nominal_interval_gaps() {
591 let g01 = sat(1);
592 let obs = observation_file(vec![
593 epoch(
594 0,
595 0.0,
596 0,
597 BTreeMap::from([(g01, vec![obs_value(Some(1.0), Some(5))])]),
598 ),
599 epoch(
600 1,
601 30.0,
602 0,
603 BTreeMap::from([(g01, vec![obs_value(Some(2.0), Some(6))])]),
604 ),
605 ]);
606
607 let report = observation_qc(&obs);
608
609 assert_eq!(report.missing_epochs, 2);
610 assert_eq!(report.data_gaps.len(), 1);
611 assert_eq!(report.data_gaps[0].nominal_interval_s, 30.0);
612 assert_eq!(report.data_gaps[0].observed_delta_s, 90.0);
613 assert_eq!(report.data_gaps[0].missing_epochs, 2);
614 }
615
616 #[test]
617 fn observation_qc_infers_interval_when_header_is_absent() {
618 let g01 = sat(1);
619 let mut obs = observation_file(vec![
620 epoch(
621 0,
622 0.0,
623 0,
624 BTreeMap::from([(g01, vec![obs_value(Some(1.0), Some(5))])]),
625 ),
626 epoch(
627 0,
628 30.0,
629 0,
630 BTreeMap::from([(g01, vec![obs_value(Some(2.0), Some(6))])]),
631 ),
632 epoch(
633 2,
634 0.0,
635 0,
636 BTreeMap::from([(g01, vec![obs_value(Some(3.0), Some(7))])]),
637 ),
638 ]);
639 obs.header.interval_s = None;
640
641 let report = observation_qc(&obs);
642
643 assert_eq!(report.interval_s, Some(30.0));
644 assert_eq!(report.interval_source, IntervalSource::Inferred);
645 assert_eq!(report.missing_epochs, 2);
646 }
647
648 #[test]
649 fn observation_qc_notes_non_monotonic_epochs_and_excludes_them_from_gaps() {
650 let g01 = sat(1);
651 let obs = observation_file(vec![
652 epoch(
653 1,
654 0.0,
655 0,
656 BTreeMap::from([(g01, vec![obs_value(Some(1.0), Some(5))])]),
657 ),
658 epoch(
659 0,
660 30.0,
661 0,
662 BTreeMap::from([(g01, vec![obs_value(Some(2.0), Some(6))])]),
663 ),
664 ]);
665
666 let report = observation_qc(&obs);
667
668 assert_eq!(
669 report.notes,
670 vec![ObservationQcNote::NonMonotonicEpoch { epoch_index: 1 }]
671 );
672 assert!(report.data_gaps.is_empty());
673 }
674
675 #[test]
676 fn observation_qc_rejects_invalid_options() {
677 let obs = observation_file(Vec::new());
678
679 let err = observation_qc_with_options(
680 &obs,
681 ObservationQcOptions {
682 interval_override_s: Some(0.0),
683 gap_factor: 1.5,
684 },
685 )
686 .expect_err("invalid interval");
687 assert_eq!(err, ObservationQcError::InvalidInterval);
688
689 let err = observation_qc_with_options(
690 &obs,
691 ObservationQcOptions {
692 interval_override_s: None,
693 gap_factor: 1.0,
694 },
695 )
696 .expect_err("invalid gap factor");
697 assert_eq!(err, ObservationQcError::InvalidGapFactor);
698 }
699
700 #[test]
701 fn observation_qc_matches_independent_real_fixture_oracles() {
702 let doc = read_json_fixture("qc/observation_qc_real_oracles.json");
703 assert_eq!(
704 doc["provenance"]["generator"],
705 "crates/sidereon-core/fixtures-generators/generate_observation_qc_oracles.py"
706 );
707 for fixture in doc["fixtures"].as_array().expect("fixtures array") {
708 let rel = fixture["path"].as_str().expect("fixture path");
709 let text = std::fs::read_to_string(fixture_path(rel))
710 .unwrap_or_else(|e| panic!("read {rel}: {e}"));
711 let obs = RinexObs::parse(&text).unwrap_or_else(|e| panic!("parse {rel}: {e}"));
712 let report = observation_qc(&obs);
713
714 assert_eq!(
715 report.total_epoch_records,
716 fixture["total_epoch_records"].as_u64().unwrap() as usize,
717 "{rel}"
718 );
719 assert_eq!(
720 report.observation_epochs,
721 fixture["observation_epochs"].as_u64().unwrap() as usize,
722 "{rel}"
723 );
724 assert_eq!(
725 report.event_records,
726 fixture["event_records"].as_u64().unwrap() as usize,
727 "{rel}"
728 );
729 assert_eq!(
730 report.power_failure_epochs,
731 fixture["power_failure_epochs"].as_u64().unwrap() as usize,
732 "{rel}"
733 );
734 assert_eq!(
735 report.skipped_records,
736 fixture["skipped_records"].as_u64().unwrap() as usize,
737 "{rel}"
738 );
739 assert_close(
740 report.interval_s.expect("oracle interval"),
741 fixture["interval_s"].as_f64().unwrap(),
742 rel,
743 );
744 assert_eq!(
745 report.missing_epochs,
746 fixture["missing_epochs"].as_u64().unwrap() as usize,
747 "{rel}"
748 );
749 assert_gaps(&report.data_gaps, &fixture["data_gaps"], rel);
750 assert_satellites(&report.satellites, &fixture["satellites"], rel);
751 assert_satellite_signals(
752 &report.satellite_signals,
753 &fixture["satellite_signals"],
754 rel,
755 );
756 assert_system_signals(&report.system_signals, &fixture["system_signals"], rel);
757 }
758 }
759
760 fn observation_file(epochs: Vec<ObsEpoch>) -> RinexObs {
761 RinexObs {
762 header: ObsHeader {
763 version: 3.05,
764 approx_position_m: None,
765 antenna_delta_hen_m: None,
766 obs_codes: BTreeMap::from([(
767 GnssSystem::Gps,
768 vec!["C1C".to_string(), "L1C".to_string(), "S1C".to_string()],
769 )]),
770 program_run_by_date: None,
771 comments: Vec::new(),
772 marker_number: None,
773 marker_type: None,
774 observer: None,
775 agency: None,
776 receiver: None,
777 antenna: None,
778 interval_s: Some(30.0),
779 time_of_first_obs: None,
780 time_of_last_obs: None,
781 n_satellites: None,
782 prn_obs_counts: BTreeMap::new(),
783 phase_shifts: Vec::new(),
784 scale_factors: Vec::new(),
785 glonass_slots: BTreeMap::new(),
786 glonass_cod_phs_bis: None,
787 signal_strength_unit: None,
788 leap_seconds: None,
789 marker_name: None,
790 unretained_header_labels: Vec::new(),
791 },
792 epochs,
793 skipped_records: 0,
794 }
795 }
796
797 fn epoch(
798 minute: u8,
799 second: f64,
800 flag: u8,
801 sats: BTreeMap<GnssSatelliteId, Vec<ObsValue>>,
802 ) -> ObsEpoch {
803 ObsEpoch {
804 epoch: ObsEpochTime {
805 year: 2024,
806 month: 1,
807 day: 1,
808 hour: 0,
809 minute,
810 second,
811 },
812 flag,
813 rcv_clock_offset_s: None,
814 epoch_picoseconds: None,
815 declared_record_count: sats.len(),
816 special_record_count: if flag > 1 { sats.len() } else { 0 },
817 sats,
818 }
819 }
820
821 fn obs_value(value: Option<f64>, ssi: Option<u8>) -> ObsValue {
822 ObsValue {
823 value,
824 lli: None,
825 ssi,
826 }
827 }
828
829 fn sat(prn: u8) -> GnssSatelliteId {
830 GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid GPS PRN")
831 }
832
833 fn fixture_path(rel: &str) -> PathBuf {
834 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(rel)
835 }
836
837 fn read_json_fixture(rel: &str) -> Value {
838 let path = fixture_path(&format!("tests/fixtures/{rel}"));
839 let raw = std::fs::read_to_string(&path)
840 .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
841 serde_json::from_str(&raw).unwrap_or_else(|e| panic!("parse {}: {e}", path.display()))
842 }
843
844 fn assert_close(actual: f64, expected: f64, context: &str) {
845 assert!(
846 (actual - expected).abs() <= 1.0e-9,
847 "{context}: actual {actual:?}, expected {expected:?}"
848 );
849 }
850
851 fn assert_gaps(actual: &[ObservationDataGap], expected: &Value, context: &str) {
852 let expected = expected.as_array().expect("gap array");
853 assert_eq!(actual.len(), expected.len(), "{context}");
854 for (actual, expected) in actual.iter().zip(expected) {
855 assert_epoch(&actual.start_epoch, &expected["start_epoch"], context);
856 assert_epoch(&actual.end_epoch, &expected["end_epoch"], context);
857 assert_close(
858 actual.nominal_interval_s,
859 expected["nominal_interval_s"].as_f64().unwrap(),
860 context,
861 );
862 assert_close(
863 actual.observed_delta_s,
864 expected["observed_delta_s"].as_f64().unwrap(),
865 context,
866 );
867 assert_eq!(
868 actual.missing_epochs,
869 expected["missing_epochs"].as_u64().unwrap() as usize,
870 "{context}"
871 );
872 }
873 }
874
875 fn assert_epoch(actual: &ObsEpochTime, expected: &Value, context: &str) {
876 assert_eq!(
877 actual.year,
878 expected["year"].as_i64().unwrap() as i32,
879 "{context}"
880 );
881 assert_eq!(
882 actual.month,
883 expected["month"].as_u64().unwrap() as u8,
884 "{context}"
885 );
886 assert_eq!(
887 actual.day,
888 expected["day"].as_u64().unwrap() as u8,
889 "{context}"
890 );
891 assert_eq!(
892 actual.hour,
893 expected["hour"].as_u64().unwrap() as u8,
894 "{context}"
895 );
896 assert_eq!(
897 actual.minute,
898 expected["minute"].as_u64().unwrap() as u8,
899 "{context}"
900 );
901 assert_close(actual.second, expected["second"].as_f64().unwrap(), context);
902 }
903
904 fn assert_satellites(actual: &[SatelliteObservationQc], expected: &Value, context: &str) {
905 let expected = expected.as_array().expect("satellites array");
906 assert_eq!(actual.len(), expected.len(), "{context}");
907 let actual = actual
908 .iter()
909 .map(|sat| {
910 (
911 sat.satellite.to_string(),
912 (sat.epochs_with_observations, sat.value_observations),
913 )
914 })
915 .collect::<BTreeMap<_, _>>();
916 for expected in expected {
917 let satellite = expected["satellite"].as_str().unwrap();
918 let actual = actual
919 .get(satellite)
920 .unwrap_or_else(|| panic!("{context}: missing satellite {satellite}"));
921 assert_eq!(
922 actual.0,
923 expected["epochs_with_observations"].as_u64().unwrap() as usize,
924 "{context} {satellite}"
925 );
926 assert_eq!(
927 actual.1,
928 expected["value_observations"].as_u64().unwrap() as usize,
929 "{context} {satellite}"
930 );
931 }
932 }
933
934 fn assert_satellite_signals(actual: &[SatelliteSignalQc], expected: &Value, context: &str) {
935 let expected = expected.as_array().expect("satellite signals array");
936 assert_eq!(actual.len(), expected.len(), "{context}");
937 let actual = actual
938 .iter()
939 .map(|signal| {
940 (
941 (signal.satellite.to_string(), signal.code.as_str()),
942 (signal.value_observations, signal.ssi, signal.snr),
943 )
944 })
945 .collect::<BTreeMap<_, _>>();
946 for expected in expected {
947 let satellite = expected["satellite"].as_str().unwrap();
948 let code = expected["code"].as_str().unwrap();
949 let actual = actual
950 .get(&(satellite.to_string(), code))
951 .unwrap_or_else(|| panic!("{context}: missing {satellite} {code}"));
952 assert_eq!(
953 actual.0,
954 expected["value_observations"].as_u64().unwrap() as usize,
955 "{context} {satellite} {code}"
956 );
957 assert_ssi(actual.1, &expected["ssi"], context);
958 assert_snr(actual.2, &expected["snr"], context);
959 }
960 }
961
962 fn assert_system_signals(actual: &[SystemSignalQc], expected: &Value, context: &str) {
963 let expected = expected.as_array().expect("system signals array");
964 assert_eq!(actual.len(), expected.len(), "{context}");
965 let actual = actual
966 .iter()
967 .map(|signal| {
968 (
969 (signal.system.letter().to_string(), signal.code.as_str()),
970 (signal.value_observations, signal.ssi, signal.snr),
971 )
972 })
973 .collect::<BTreeMap<_, _>>();
974 for expected in expected {
975 let system = expected["system"].as_str().unwrap();
976 let code = expected["code"].as_str().unwrap();
977 let actual = actual
978 .get(&(system.to_string(), code))
979 .unwrap_or_else(|| panic!("{context}: missing {system} {code}"));
980 assert_eq!(
981 actual.0,
982 expected["value_observations"].as_u64().unwrap() as usize,
983 "{context} {system} {code}"
984 );
985 assert_ssi(actual.1, &expected["ssi"], context);
986 assert_snr(actual.2, &expected["snr"], context);
987 }
988 }
989
990 fn assert_ssi(actual: Option<SsiHistogram>, expected: &Value, context: &str) {
991 if expected.is_null() {
992 assert_eq!(actual, None, "{context}");
993 return;
994 }
995 let expected = expected
996 .as_array()
997 .expect("ssi array")
998 .iter()
999 .map(|value| value.as_u64().unwrap())
1000 .collect::<Vec<_>>();
1001 assert_eq!(actual.expect("ssi").counts.to_vec(), expected, "{context}");
1002 }
1003
1004 fn assert_snr(actual: Option<SnrStats>, expected: &Value, context: &str) {
1005 if expected.is_null() {
1006 assert_eq!(actual, None, "{context}");
1007 return;
1008 }
1009 let actual = actual.expect("snr");
1010 assert_eq!(
1011 actual.n,
1012 expected["n"].as_u64().unwrap() as usize,
1013 "{context}"
1014 );
1015 assert_close(actual.mean, expected["mean"].as_f64().unwrap(), context);
1016 assert_close(actual.min, expected["min"].as_f64().unwrap(), context);
1017 assert_close(actual.max, expected["max"].as_f64().unwrap(), context);
1018 if expected["std"].is_null() {
1019 assert_eq!(actual.std, None, "{context}");
1020 } else {
1021 assert_close(
1022 actual.std.expect("std"),
1023 expected["std"].as_f64().unwrap(),
1024 context,
1025 );
1026 }
1027 }
1028}