1use std::collections::{BTreeMap, BTreeSet};
17
18use crate::astro::math::vec3;
19use crate::astro::time::civil::{
20 civil_from_julian_day_number, fractional_day_of_year_from_instant, is_leap_year,
21 julian_date_from_instant, mjd_from_jd,
22};
23use crate::astro::time::gnss;
24use crate::astro::time::model::Instant;
25
26use super::interp::{instant_to_j2000_seconds, sp3_epoch_j2000_seconds};
27use super::{RawNode, Sp3, Sp3DataType, Sp3Flags, Sp3Header, Sp3State};
28use crate::constants::{DAYS_PER_JULIAN_YEAR, GPS_EPOCH_TO_J2000_S, KM_TO_M, SECONDS_PER_DAY};
29use crate::frame::{ItrfPositionM, ItrfVelocityMS};
30use crate::frame_catalog::{
31 self, HelmertParameters, HelmertRates, TerrestrialFrame, TerrestrialPositionM,
32 TerrestrialVelocityMPerYear,
33};
34use crate::id::{GnssSatelliteId, GnssSystem};
35use crate::tolerances::WHOLE_SECOND_EPS_S;
36use crate::validate;
37use crate::{Error, Result};
38
39const MAX_EXACT_CLIQUE_NODES: usize = 32;
40
41#[derive(Debug, Clone, Copy, PartialEq)]
43pub struct ClockReferenceOffset {
44 pub epoch: Instant,
46 pub offset_s: f64,
50 pub satellites: usize,
52}
53
54pub fn clock_reference_offset(
80 reference: &Sp3,
81 other: &Sp3,
82 min_common: usize,
83) -> Vec<ClockReferenceOffset> {
84 let mut other_index: std::collections::HashMap<i64, usize> = std::collections::HashMap::new();
85 for (idx, epoch) in other.epochs.iter().enumerate() {
86 if let Some(seconds) = sp3_epoch_j2000_seconds(other, idx, epoch) {
87 other_index.insert(seconds.floor() as i64, idx);
88 }
89 }
90
91 let mut offsets = Vec::new();
92
93 for (ref_idx, epoch) in reference.epochs.iter().enumerate() {
94 let Some(ref_seconds) = sp3_epoch_j2000_seconds(reference, ref_idx, epoch) else {
95 continue;
96 };
97 let Some(&other_idx) = other_index.get(&(ref_seconds.floor() as i64)) else {
98 continue;
99 };
100
101 let (Ok(ref_states), Ok(other_states)) =
102 (reference.states_at(ref_idx), other.states_at(other_idx))
103 else {
104 continue;
105 };
106
107 let mut diffs: Vec<f64> = Vec::new();
108 for (sat, ref_state) in ref_states.iter() {
109 let Some(ref_clock) = ref_state.clock_s else {
110 continue;
111 };
112 if let Some(other_state) = other_states.get(sat) {
113 if let Some(other_clock) = other_state.clock_s {
114 let diff = other_clock - ref_clock;
115 if diff.is_finite() {
118 diffs.push(diff);
119 }
120 }
121 }
122 }
123
124 if diffs.len() >= min_common.max(1) {
125 if let Some(offset_s) = median(&mut diffs) {
126 offsets.push(ClockReferenceOffset {
127 epoch: *epoch,
128 offset_s,
129 satellites: diffs.len(),
130 });
131 }
132 }
133 }
134
135 offsets
136}
137
138fn median(values: &mut [f64]) -> Option<f64> {
139 crate::astro::math::robust::median_sorting_in_place(values)
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum MergeCombine {
151 Mean,
154 Median,
156 Precedence,
158}
159
160#[derive(Debug, Clone, PartialEq)]
162pub struct MergeOptions {
163 pub position_tolerance_m: f64,
166 pub clock_tolerance_s: f64,
169 pub min_agree: usize,
175 pub clock_min_common: usize,
178 pub combine: MergeCombine,
180 pub target_epoch_interval_s: Option<f64>,
185 pub systems: Option<BTreeSet<GnssSystem>>,
188 pub frame_reconciliation: Sp3FrameReconciliationOptions,
191}
192
193impl Default for MergeOptions {
194 fn default() -> Self {
197 Self {
198 position_tolerance_m: 0.5,
199 clock_tolerance_s: 5.0e-9,
200 min_agree: 2,
201 clock_min_common: 5,
202 combine: MergeCombine::Mean,
203 target_epoch_interval_s: None,
204 systems: None,
205 frame_reconciliation: Sp3FrameReconciliationOptions::default(),
206 }
207 }
208}
209
210#[derive(Debug, Clone, Default, PartialEq, Eq)]
212pub struct Sp3FrameReconciliationOptions {
213 pub asserted_equivalent_label_sets: Vec<Sp3FrameLabelSet>,
216 pub helmert: bool,
219}
220
221impl Sp3FrameReconciliationOptions {
222 pub const fn disabled() -> Self {
224 Self {
225 asserted_equivalent_label_sets: Vec::new(),
226 helmert: false,
227 }
228 }
229
230 pub const fn helmert() -> Self {
232 Self {
233 asserted_equivalent_label_sets: Vec::new(),
234 helmert: true,
235 }
236 }
237}
238
239#[derive(Debug, Clone, PartialEq, Eq)]
242pub struct Sp3FrameLabelSet {
243 pub labels: BTreeSet<String>,
245}
246
247impl Sp3FrameLabelSet {
248 pub fn new(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
250 Self {
251 labels: labels
252 .into_iter()
253 .map(|label| label.into().trim().to_string())
254 .collect(),
255 }
256 }
257
258 pub fn pair(a: impl Into<String>, b: impl Into<String>) -> Self {
260 Self::new([a.into(), b.into()])
261 }
262}
263
264#[derive(Debug, Clone, PartialEq)]
267pub struct MergeFlag {
268 pub epoch: Instant,
270 pub satellite: GnssSatelliteId,
272 pub sources: Vec<usize>,
277}
278
279#[derive(Debug, Clone, Copy, PartialEq)]
288pub struct AgreementMetric {
289 pub epoch: Instant,
291 pub satellite: GnssSatelliteId,
293 pub position_members: usize,
295 pub position_rms_m: f64,
298 pub position_max_m: f64,
301 pub clock_members: usize,
304 pub clock_rms_s: Option<f64>,
307 pub clock_max_s: Option<f64>,
310}
311
312#[derive(Debug, Clone, Copy, PartialEq)]
316pub struct EpochAgreement {
317 pub epoch: Instant,
319 pub satellites: usize,
321 pub position_rms_m: f64,
325 pub position_max_m: f64,
327 pub clock_rms_s: Option<f64>,
330 pub clock_max_s: Option<f64>,
332}
333
334#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336pub enum Sp3FrameReconciliationMethod {
337 AssertedEquivalence,
340 Helmert,
343}
344
345#[derive(Debug, Clone, PartialEq)]
347pub struct Sp3FrameReconciliation {
348 pub source_index: usize,
350 pub source_label: String,
352 pub target_label: String,
354 pub method: Sp3FrameReconciliationMethod,
356 pub asserted_label_set: Option<Vec<String>>,
358 pub source_frame: Option<TerrestrialFrame>,
360 pub target_frame: Option<TerrestrialFrame>,
362 pub catalog_source_frame: Option<TerrestrialFrame>,
365 pub catalog_target_frame: Option<TerrestrialFrame>,
368 pub catalog_inverse: bool,
370 pub reference_epoch_year: Option<f64>,
373 pub parameters: Option<HelmertParameters>,
376 pub rates: Option<HelmertRates>,
378 pub provenance: Option<String>,
380 pub epoch_year_span: Option<[f64; 2]>,
383 pub records_affected: usize,
385 pub identity: bool,
388}
389
390#[derive(Debug, Clone, Default, PartialEq)]
392pub struct MergeReport {
393 pub frame_reconciliations: Vec<Sp3FrameReconciliation>,
395 pub quarantined: Vec<MergeFlag>,
398 pub single_source: Vec<MergeFlag>,
400 pub position_outliers: Vec<MergeFlag>,
403 pub agreement: Vec<AgreementMetric>,
408}
409
410impl MergeReport {
411 pub fn single_source_fraction(&self) -> Option<f64> {
421 let accepted = self.agreement.len();
422 (accepted > 0).then(|| self.single_source.len() as f64 / accepted as f64)
423 }
424
425 pub fn position_agreement_rms_m(&self) -> Option<f64> {
439 pooled_rms(
440 self.agreement
441 .iter()
442 .filter(|m| m.position_members >= 2)
443 .map(|m| (m.position_rms_m, m.position_members)),
444 )
445 }
446
447 pub fn position_agreement_max_m(&self) -> Option<f64> {
450 self.agreement
451 .iter()
452 .map(|m| m.position_max_m)
453 .fold(None, |acc, v| Some(fold_max(acc, v)))
454 }
455
456 pub fn clock_agreement_rms_s(&self) -> Option<f64> {
458 pooled_rms(self.agreement.iter().filter_map(|m| {
459 m.clock_rms_s
460 .filter(|_| m.clock_members >= 2)
461 .map(|rms| (rms, m.clock_members))
462 }))
463 }
464
465 pub fn clock_agreement_max_s(&self) -> Option<f64> {
467 self.agreement
468 .iter()
469 .filter_map(|m| m.clock_max_s)
470 .fold(None, |acc, v| Some(fold_max(acc, v)))
471 }
472
473 pub fn per_epoch_agreement(&self) -> Vec<EpochAgreement> {
478 let mut out: Vec<EpochAgreement> = Vec::new();
479 let mut current_key: Option<i64> = None;
480 for m in &self.agreement {
481 let key = instant_to_j2000_seconds(&m.epoch).map(|s| s.floor() as i64);
482 if current_key != key || out.is_empty() {
483 out.push(EpochAgreement {
484 epoch: m.epoch,
485 satellites: 0,
486 position_rms_m: 0.0,
487 position_max_m: 0.0,
488 clock_rms_s: None,
489 clock_max_s: None,
490 });
491 current_key = key;
492 }
493 let agg = out.last_mut().expect("just pushed");
494 agg.position_max_m = agg.position_max_m.max(m.position_max_m);
495 if m.position_members >= 2 {
496 agg.satellites += 1;
497 }
498 if let Some(max) = m.clock_max_s.filter(|_| m.clock_members >= 2) {
502 agg.clock_max_s = Some(fold_max(agg.clock_max_s, max));
503 }
504 }
505
506 for agg in &mut out {
509 let key = instant_to_j2000_seconds(&agg.epoch).map(|s| s.floor() as i64);
510 agg.position_rms_m = pooled_rms(
511 self.agreement
512 .iter()
513 .filter(|m| {
514 m.position_members >= 2
515 && instant_to_j2000_seconds(&m.epoch).map(|s| s.floor() as i64) == key
516 })
517 .map(|m| (m.position_rms_m, m.position_members)),
518 )
519 .unwrap_or(0.0);
520 agg.clock_rms_s = pooled_rms(
521 self.agreement
522 .iter()
523 .filter(|m| instant_to_j2000_seconds(&m.epoch).map(|s| s.floor() as i64) == key)
524 .filter_map(|m| {
525 m.clock_rms_s
526 .filter(|_| m.clock_members >= 2)
527 .map(|rms| (rms, m.clock_members))
528 }),
529 );
530 }
531
532 out
533 }
534}
535
536fn pooled_rms(cells: impl Iterator<Item = (f64, usize)>) -> Option<f64> {
539 let mut sumsq = 0.0_f64;
540 let mut total = 0_usize;
541 for (rms, n) in cells {
542 sumsq += rms * rms * n as f64;
543 total += n;
544 }
545 (total > 0).then(|| (sumsq / total as f64).sqrt())
546}
547
548fn fold_max(acc: Option<f64>, value: f64) -> f64 {
550 match acc {
551 Some(current) if current >= value => current,
552 _ => value,
553 }
554}
555
556pub fn merge(sources: &[Sp3], opts: &MergeOptions) -> Result<(Sp3, MergeReport)> {
604 if sources.is_empty() {
605 return Err(Error::InvalidInput(
606 "merge requires at least one SP3 product".into(),
607 ));
608 }
609
610 let base = &sources[0].header;
615 for s in &sources[1..] {
616 if s.header.time_system != base.time_system {
617 return Err(Error::InvalidInput(format!(
618 "merge inputs have mismatched SP3 time systems ({:?} vs {:?})",
619 base.time_system, s.header.time_system
620 )));
621 }
622 }
623
624 let (prepared_sources, frame_reconciliations) = reconcile_sp3_coordinate_labels(sources, opts)?;
625 let sources = prepared_sources.as_slice();
626
627 let epoch_index: Vec<BTreeMap<i64, usize>> = sources
629 .iter()
630 .map(|s| {
631 s.epochs
632 .iter()
633 .enumerate()
634 .filter_map(|(i, ep)| {
635 sp3_epoch_j2000_seconds(s, i, ep).map(|sec| (sec.floor() as i64, i))
636 })
637 .collect()
638 })
639 .collect();
640
641 let epoch_interval_s = resolve_common_epoch_interval(sources, opts.target_epoch_interval_s)?;
642
643 let clock_offset: Vec<BTreeMap<i64, f64>> = sources
646 .iter()
647 .enumerate()
648 .map(|(idx, s)| {
649 if idx == 0 {
650 BTreeMap::new()
651 } else {
652 clock_reference_offset(&sources[0], s, opts.clock_min_common)
653 .into_iter()
654 .filter_map(|o| {
655 instant_to_j2000_seconds(&o.epoch)
656 .map(|sec| (sec.floor() as i64, o.offset_s))
657 })
658 .collect()
659 }
660 })
661 .collect();
662
663 let mut epoch_keys: BTreeMap<i64, Instant> = sources[0]
668 .epochs
669 .iter()
670 .enumerate()
671 .filter_map(|(idx, ep)| {
672 sp3_epoch_j2000_seconds(&sources[0], idx, ep).map(|sec| (sec.floor() as i64, *ep))
673 })
674 .collect();
675
676 for index in epoch_index.iter().skip(1) {
677 epoch_keys.retain(|key, _| index.contains_key(key));
678 }
679
680 if let Some((&anchor, _)) = epoch_keys.iter().next() {
686 let step = epoch_interval_s.round() as i64;
687 if step > 0 {
688 epoch_keys.retain(|&key, _| (key - anchor).rem_euclid(step) == 0);
689 }
690 }
691
692 if epoch_keys.is_empty() {
693 return Err(Error::InvalidInput(
694 "merge inputs have no common epochs on a shared time grid".into(),
695 ));
696 }
697
698 let precedence_source_for_sat = if opts.combine == MergeCombine::Precedence {
699 Some(precedence_sources_for_satellites(
700 sources,
701 &epoch_index,
702 &epoch_keys,
703 opts.systems.as_ref(),
704 ))
705 } else {
706 None
707 };
708
709 let allowed_system = |sat: &GnssSatelliteId| {
710 opts.systems
711 .as_ref()
712 .is_none_or(|systems| systems.contains(&sat.system))
713 };
714
715 if let Some(systems) = &opts.systems {
716 if systems.is_empty() {
717 return Err(Error::InvalidInput(
718 "merge systems filter must not be empty".into(),
719 ));
720 }
721 }
722
723 let mut out_epochs: Vec<Instant> = Vec::with_capacity(epoch_keys.len());
724 let mut out_epoch_j2000_s: Vec<f64> = Vec::with_capacity(epoch_keys.len());
725 let mut out_states: Vec<BTreeMap<GnssSatelliteId, Sp3State>> =
726 Vec::with_capacity(epoch_keys.len());
727 let mut out_raw: Vec<BTreeMap<GnssSatelliteId, RawNode>> = Vec::with_capacity(epoch_keys.len());
728 let mut report = MergeReport {
729 frame_reconciliations,
730 ..MergeReport::default()
731 };
732 let mut all_sats: BTreeSet<GnssSatelliteId> = BTreeSet::new();
733
734 for (&key, &epoch) in &epoch_keys {
735 out_epochs.push(epoch);
736 out_epoch_j2000_s.push(key as f64);
737 let mut states: BTreeMap<GnssSatelliteId, Sp3State> = BTreeMap::new();
738 let mut raws: BTreeMap<GnssSatelliteId, RawNode> = BTreeMap::new();
739
740 let mut sats: BTreeSet<GnssSatelliteId> = BTreeSet::new();
743 for (idx, s) in sources.iter().enumerate() {
744 if let Some(&ei) = epoch_index[idx].get(&key) {
745 if let Ok(map) = s.states_at(ei) {
746 sats.extend(map.keys().copied().filter(|sat| allowed_system(sat)));
747 }
748 }
749 }
750
751 for sat in sats {
752 let preferred_source = precedence_source_for_sat
758 .as_ref()
759 .and_then(|by_sat| by_sat.get(&sat).copied());
760
761 let mut pos: Vec<(usize, [f64; 3], Sp3Flags)> = Vec::new();
762 let mut clk: Vec<(usize, f64, Sp3Flags)> = Vec::new();
763 for (idx, s) in sources.iter().enumerate() {
764 let Some(&ei) = epoch_index[idx].get(&key) else {
765 continue;
766 };
767 let Ok(map) = s.states_at(ei) else { continue };
768 let Some(state) = map.get(&sat) else { continue };
769 pos.push((idx, state.position.as_array(), state.flags));
770 if let Some(c) = state.clock_s {
771 let offset = if idx == 0 {
772 Some(0.0)
773 } else {
774 clock_offset[idx].get(&key).copied()
775 };
776 if let Some(off) = offset {
777 let aligned = c - off;
778 if aligned.is_finite() {
779 clk.push((idx, aligned, state.flags));
780 }
781 }
782 }
783 }
784
785 let flag = |srcs: Vec<usize>| MergeFlag {
786 epoch,
787 satellite: sat,
788 sources: srcs,
789 };
790
791 let (position_m, pos_members) = if opts.combine == MergeCombine::Precedence {
797 let Some(preferred_source) = preferred_source else {
798 continue;
799 };
800 let Some(preferred_idx) =
801 pos.iter().position(|(src, _, _)| *src == preferred_source)
802 else {
803 continue;
804 };
805
806 if pos.len() == 1 {
807 report.single_source.push(flag(vec![pos[preferred_idx].0]));
808 (pos[preferred_idx].1, vec![preferred_idx])
809 } else {
810 let pts: Vec<[f64; 3]> = pos.iter().map(|(_, p, _)| *p).collect();
811 let cluster = largest_within_containing(&pts, preferred_idx, |a, b| {
812 dist3(a, b) <= opts.position_tolerance_m
813 });
814 if cluster.len() >= opts.min_agree {
815 let rejected: Vec<usize> = (0..pos.len())
816 .filter(|i| !cluster.contains(i))
817 .map(|i| pos[i].0)
818 .collect();
819 if !rejected.is_empty() {
820 report.position_outliers.push(flag(rejected));
821 }
822 (pos[preferred_idx].1, cluster)
823 } else {
824 report
825 .quarantined
826 .push(flag(pos.iter().map(|(i, _, _)| *i).collect()));
827 continue;
828 }
829 }
830 } else if pos.len() == 1 {
831 report.single_source.push(flag(vec![pos[0].0]));
832 (pos[0].1, vec![0usize])
833 } else {
834 let pts: Vec<[f64; 3]> = pos.iter().map(|(_, p, _)| *p).collect();
835 let cluster = largest_within(&pts, |a, b| dist3(a, b) <= opts.position_tolerance_m);
836 if cluster.len() >= opts.min_agree {
837 let rejected: Vec<usize> = (0..pos.len())
838 .filter(|i| !cluster.contains(i))
839 .map(|i| pos[i].0)
840 .collect();
841 if !rejected.is_empty() {
842 report.position_outliers.push(flag(rejected));
843 }
844 let members: Vec<(usize, [f64; 3])> =
845 cluster.iter().map(|&i| (pos[i].0, pos[i].1)).collect();
846 (combine3(&members, opts.combine), cluster)
847 } else {
848 report
849 .quarantined
850 .push(flag(pos.iter().map(|(i, _, _)| *i).collect()));
851 continue;
852 }
853 };
854
855 let (clock_s, clk_members): (Option<f64>, Vec<usize>) = if clk.is_empty() {
858 (None, Vec::new())
859 } else if opts.combine == MergeCombine::Precedence {
860 match preferred_source
861 .and_then(|src| clk.iter().position(|(clock_src, _, _)| *clock_src == src))
862 {
863 None => (None, Vec::new()),
864 Some(preferred_idx) if clk.len() == 1 => {
865 (Some(clk[preferred_idx].1), vec![preferred_idx])
866 }
867 Some(preferred_idx) => {
868 let vals: Vec<f64> = clk.iter().map(|(_, c, _)| *c).collect();
869 let cluster = largest_within_containing(&vals, preferred_idx, |a, b| {
870 (a - b).abs() <= opts.clock_tolerance_s
871 });
872 if cluster.len() >= opts.min_agree {
873 (Some(clk[preferred_idx].1), cluster)
874 } else {
875 (None, Vec::new())
876 }
877 }
878 }
879 } else if clk.len() == 1 {
880 (Some(clk[0].1), vec![0usize])
881 } else {
882 let vals: Vec<f64> = clk.iter().map(|(_, c, _)| *c).collect();
883 let cluster = largest_within(&vals, |a, b| (a - b).abs() <= opts.clock_tolerance_s);
884 if cluster.len() >= opts.min_agree {
885 let members: Vec<(usize, f64)> =
886 cluster.iter().map(|&i| (clk[i].0, clk[i].1)).collect();
887 (Some(combine_axis(&members, opts.combine)), cluster)
888 } else {
889 (None, Vec::new())
890 }
891 };
892
893 let mut flags = Sp3Flags::default();
898 for &i in &pos_members {
899 flags.maneuver |= pos[i].2.maneuver;
900 flags.orbit_predicted |= pos[i].2.orbit_predicted;
901 }
902 for &i in &clk_members {
903 flags.clock_event |= clk[i].2.clock_event;
904 flags.clock_predicted |= clk[i].2.clock_predicted;
905 }
906
907 let (position_rms_m, position_max_m) =
910 position_dispersion(&pos, &pos_members, &position_m);
911 let (clock_members_n, clock_rms_s, clock_max_s) = match clock_s {
912 Some(c) => {
913 let (rms, max) = clock_dispersion(&clk, &clk_members, c);
914 (clk_members.len(), Some(rms), Some(max))
915 }
916 None => (0, None, None),
917 };
918 report.agreement.push(AgreementMetric {
919 epoch,
920 satellite: sat,
921 position_members: pos_members.len(),
922 position_rms_m,
923 position_max_m,
924 clock_members: clock_members_n,
925 clock_rms_s,
926 clock_max_s,
927 });
928
929 all_sats.insert(sat);
930 states.insert(
931 sat,
932 Sp3State {
933 position: ItrfPositionM::new(position_m[0], position_m[1], position_m[2])
934 .expect("valid ITRF position"),
935 clock_s,
936 velocity: None,
937 clock_rate_s_s: None,
938 flags,
939 },
940 );
941 raws.insert(
942 sat,
943 RawNode {
944 km: [
945 position_m[0] / KM_TO_M,
946 position_m[1] / KM_TO_M,
947 position_m[2] / KM_TO_M,
948 ],
949 clock_us: clock_s.map(|c| c * 1.0e6),
950 clock_event: flags.clock_event,
951 },
952 );
953 }
954
955 out_states.push(states);
956 out_raw.push(raws);
957 }
958
959 let first_key = Some(out_epoch_j2000_s[0].floor() as i64);
964 let base_idx = sources
965 .iter()
966 .position(|s| {
967 s.epochs
968 .first()
969 .and_then(|ep| sp3_epoch_j2000_seconds(s, 0, ep))
970 .map(|sec| sec.floor() as i64)
971 == first_key
972 })
973 .or_else(|| {
974 sources
975 .iter()
976 .enumerate()
977 .filter_map(|(i, s)| {
978 s.epochs
979 .first()
980 .and_then(|ep| sp3_epoch_j2000_seconds(s, 0, ep))
981 .map(|sec| (sec, i))
982 })
983 .min_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)))
984 .map(|(_, i)| i)
985 })
986 .unwrap_or(0);
987 let first_epoch_header = first_epoch_header_fields(&out_epochs[0]).ok_or_else(|| {
988 Error::InvalidInput("merged SP3 first epoch cannot be represented in header fields".into())
989 })?;
990
991 let satellites: Vec<_> = all_sats.into_iter().collect();
992 let satellite_accuracy_codes = satellites
993 .iter()
994 .map(|sat| {
995 sources[base_idx]
996 .header
997 .satellites
998 .iter()
999 .position(|base_sat| base_sat == sat)
1000 .and_then(|idx| {
1001 sources[base_idx]
1002 .header
1003 .satellite_accuracy_codes
1004 .get(idx)
1005 .copied()
1006 })
1007 .unwrap_or(0)
1008 })
1009 .collect();
1010
1011 let header = Sp3Header {
1012 num_epochs: out_epochs.len() as u64,
1013 satellites,
1014 satellite_accuracy_codes,
1015 data_type: Sp3DataType::Position,
1016 gnss_week: first_epoch_header.gnss_week,
1017 seconds_of_week: first_epoch_header.seconds_of_week,
1018 epoch_interval_s,
1019 mjd: first_epoch_header.mjd,
1020 mjd_fraction: first_epoch_header.mjd_fraction,
1021 ..sources[base_idx].header.clone()
1022 };
1023
1024 let merged = Sp3 {
1025 header,
1026 epochs: out_epochs,
1027 epoch_j2000_s: out_epoch_j2000_s,
1028 states: out_states,
1029 interp_raw: out_raw,
1030 comments: vec![format!("MERGED from {} SP3 products", sources.len())],
1031 skipped_records: sources.iter().map(|s| s.skipped_records).sum(),
1032 };
1033
1034 Ok((merged, report))
1035}
1036
1037fn reconcile_sp3_coordinate_labels(
1038 sources: &[Sp3],
1039 opts: &MergeOptions,
1040) -> Result<(Vec<Sp3>, Vec<Sp3FrameReconciliation>)> {
1041 let target_label = normalized_sp3_frame_label(&sources[0].header.coordinate_system);
1042 let mut prepared = sources.to_vec();
1043 let mut report = Vec::new();
1044
1045 for idx in 1..sources.len() {
1046 let source_label = normalized_sp3_frame_label(&sources[idx].header.coordinate_system);
1047 if source_label == target_label {
1048 continue;
1049 }
1050
1051 if let Some(asserted) = asserted_frame_label_set(
1052 &source_label,
1053 &target_label,
1054 &opts.frame_reconciliation.asserted_equivalent_label_sets,
1055 ) {
1056 prepared[idx].header.coordinate_system = target_label.clone();
1057 report.push(Sp3FrameReconciliation {
1058 source_index: idx,
1059 source_label,
1060 target_label: target_label.clone(),
1061 method: Sp3FrameReconciliationMethod::AssertedEquivalence,
1062 asserted_label_set: Some(asserted),
1063 source_frame: None,
1064 target_frame: None,
1065 catalog_source_frame: None,
1066 catalog_target_frame: None,
1067 catalog_inverse: false,
1068 reference_epoch_year: None,
1069 parameters: None,
1070 rates: None,
1071 provenance: None,
1072 epoch_year_span: None,
1073 records_affected: count_position_records(&sources[idx]),
1074 identity: true,
1075 });
1076 continue;
1077 }
1078
1079 if opts.frame_reconciliation.helmert {
1080 let from = sp3_coordinate_label_frame(&source_label).ok_or_else(|| {
1081 Error::InvalidInput(format!(
1082 "merge inputs have mismatched coordinate systems ({:?} vs {:?}); source label {:?} is not a known ITRF/IGS realization",
1083 sources[0].header.coordinate_system,
1084 sources[idx].header.coordinate_system,
1085 sources[idx].header.coordinate_system
1086 ))
1087 })?;
1088 let to = sp3_coordinate_label_frame(&target_label).ok_or_else(|| {
1089 Error::InvalidInput(format!(
1090 "merge inputs have mismatched coordinate systems ({:?} vs {:?}); target label {:?} is not a known ITRF/IGS realization",
1091 sources[0].header.coordinate_system,
1092 sources[idx].header.coordinate_system,
1093 sources[0].header.coordinate_system
1094 ))
1095 })?;
1096
1097 let transform_report = reconcile_source_by_helmert(
1098 &mut prepared[idx],
1099 idx,
1100 source_label,
1101 target_label.clone(),
1102 from,
1103 to,
1104 )?;
1105 report.push(transform_report);
1106 continue;
1107 }
1108
1109 return Err(Error::InvalidInput(format!(
1110 "merge inputs have mismatched coordinate systems ({:?} vs {:?})",
1111 sources[0].header.coordinate_system, sources[idx].header.coordinate_system
1112 )));
1113 }
1114
1115 Ok((prepared, report))
1116}
1117
1118fn asserted_frame_label_set(
1119 source_label: &str,
1120 target_label: &str,
1121 label_sets: &[Sp3FrameLabelSet],
1122) -> Option<Vec<String>> {
1123 label_sets.iter().find_map(|set| {
1124 if set.labels.contains(source_label) && set.labels.contains(target_label) {
1125 Some(set.labels.iter().cloned().collect())
1126 } else {
1127 None
1128 }
1129 })
1130}
1131
1132fn reconcile_source_by_helmert(
1133 source: &mut Sp3,
1134 source_index: usize,
1135 source_label: String,
1136 target_label: String,
1137 from: TerrestrialFrame,
1138 to: TerrestrialFrame,
1139) -> Result<Sp3FrameReconciliation> {
1140 let records_affected = count_position_records(source);
1141 let epoch_year_span = epoch_year_span(source);
1142 let identity = from == to;
1143
1144 if !identity {
1145 transform_sp3_positions(source, from, to)?;
1146 }
1147 source.header.coordinate_system = target_label.clone();
1148
1149 let published = published_transform_for_report(from, to);
1150 Ok(Sp3FrameReconciliation {
1151 source_index,
1152 source_label,
1153 target_label,
1154 method: Sp3FrameReconciliationMethod::Helmert,
1155 asserted_label_set: None,
1156 source_frame: Some(from),
1157 target_frame: Some(to),
1158 catalog_source_frame: published.map(|published| published.entry.from),
1159 catalog_target_frame: published.map(|published| published.entry.to),
1160 catalog_inverse: published.is_some_and(|published| published.inverse),
1161 reference_epoch_year: published.map(|published| published.entry.reference_epoch_year),
1162 parameters: published.map(|published| published.entry.parameters),
1163 rates: published.map(|published| published.entry.rates),
1164 provenance: published.map(|published| published.entry.provenance.to_string()),
1165 epoch_year_span,
1166 records_affected,
1167 identity,
1168 })
1169}
1170
1171fn transform_sp3_positions(
1172 source: &mut Sp3,
1173 from: TerrestrialFrame,
1174 to: TerrestrialFrame,
1175) -> Result<()> {
1176 let seconds_per_julian_year = DAYS_PER_JULIAN_YEAR * SECONDS_PER_DAY;
1177 for epoch_idx in 0..source.epochs.len() {
1178 let epoch_year = decimal_year(source.epochs[epoch_idx]);
1179 let states = &mut source.states[epoch_idx];
1180 let raw_nodes = &mut source.interp_raw[epoch_idx];
1181 for (sat, state) in states.iter_mut() {
1182 let position = TerrestrialPositionM::from_itrf(state.position);
1183 let velocity = state
1184 .velocity
1185 .map(|velocity| {
1186 let [vx, vy, vz] = velocity.as_array();
1187 TerrestrialVelocityMPerYear::new(
1188 vx * seconds_per_julian_year,
1189 vy * seconds_per_julian_year,
1190 vz * seconds_per_julian_year,
1191 )
1192 })
1193 .transpose()
1194 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1195 let transformed = frame_catalog::transform(position, velocity, from, to, epoch_year)
1196 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1197 let [x, y, z] = transformed.position.as_array();
1198 state.position = ItrfPositionM::new(x, y, z)
1199 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1200 state.velocity = transformed
1201 .velocity
1202 .map(|velocity| {
1203 let [vx, vy, vz] = velocity.as_array();
1204 ItrfVelocityMS::new(
1205 vx / seconds_per_julian_year,
1206 vy / seconds_per_julian_year,
1207 vz / seconds_per_julian_year,
1208 )
1209 })
1210 .transpose()
1211 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1212 if let Some(raw) = raw_nodes.get_mut(sat) {
1213 raw.km = [x / KM_TO_M, y / KM_TO_M, z / KM_TO_M];
1214 }
1215 }
1216 }
1217 Ok(())
1218}
1219
1220fn count_position_records(source: &Sp3) -> usize {
1221 source.states.iter().map(BTreeMap::len).sum()
1222}
1223
1224fn epoch_year_span(source: &Sp3) -> Option<[f64; 2]> {
1225 let first = source.epochs.first().copied().map(decimal_year)?;
1226 let last = source.epochs.last().copied().map(decimal_year)?;
1227 Some([first, last])
1228}
1229
1230fn decimal_year(epoch: Instant) -> f64 {
1231 let jd_midnight = julian_date_from_instant(epoch) + 0.5;
1232 let (year, _, _) = civil_from_julian_day_number(jd_midnight.floor() as i64);
1233 let days = if is_leap_year(year) { 366.0 } else { 365.0 };
1234 year as f64 + (fractional_day_of_year_from_instant(epoch) - 1.0) / days
1235}
1236
1237fn normalized_sp3_frame_label(label: &str) -> String {
1238 label.trim().to_string()
1239}
1240
1241fn sp3_coordinate_label_frame(label: &str) -> Option<TerrestrialFrame> {
1242 match label.trim() {
1243 "ITRF2020" | "ITRF20" | "IGS20" | "IGc20" => Some(TerrestrialFrame::Itrf2020),
1244 "ITRF2014" | "ITRF14" | "IGS14" | "IGb14" => Some(TerrestrialFrame::Itrf2014),
1245 "ITRF2008" | "ITRF08" | "IGS08" | "IGb08" => Some(TerrestrialFrame::Itrf2008),
1246 _ => None,
1247 }
1248}
1249
1250fn published_transform_for_report(
1251 from: TerrestrialFrame,
1252 to: TerrestrialFrame,
1253) -> Option<PublishedTransformForReport> {
1254 frame_catalog::catalog_entry(from, to)
1255 .map(|entry| PublishedTransformForReport {
1256 entry,
1257 inverse: false,
1258 })
1259 .or_else(|| {
1260 frame_catalog::catalog_entry(to, from).map(|entry| PublishedTransformForReport {
1261 entry,
1262 inverse: true,
1263 })
1264 })
1265}
1266
1267#[derive(Debug, Clone, Copy)]
1268struct PublishedTransformForReport {
1269 entry: &'static frame_catalog::HelmertTransform,
1270 inverse: bool,
1271}
1272
1273#[derive(Debug, Clone, Copy)]
1274struct FirstEpochHeaderFields {
1275 gnss_week: u32,
1276 seconds_of_week: f64,
1277 mjd: u32,
1278 mjd_fraction: f64,
1279}
1280
1281fn first_epoch_header_fields(epoch: &Instant) -> Option<FirstEpochHeaderFields> {
1282 let split = epoch.julian_date()?;
1283
1284 let mjd_day = mjd_from_jd(split.jd_whole);
1285 let mut mjd = mjd_day.floor();
1286 let mut mjd_fraction = split.fraction + (mjd_day - mjd);
1287 let fraction_days = mjd_fraction.floor();
1288 if fraction_days != 0.0 {
1289 mjd += fraction_days;
1290 mjd_fraction -= fraction_days;
1291 }
1292 if !(0.0..=u32::MAX as f64).contains(&mjd) {
1293 return None;
1294 }
1295
1296 let gps_seconds = instant_to_j2000_seconds(epoch)? + GPS_EPOCH_TO_J2000_S;
1297 let (gnss_week, seconds_of_week) = gnss::week_and_seconds_of_week(gps_seconds);
1298 if !(0.0..=u32::MAX as f64).contains(&gnss_week) {
1299 return None;
1300 }
1301
1302 Some(FirstEpochHeaderFields {
1303 gnss_week: gnss_week as u32,
1304 seconds_of_week,
1305 mjd: mjd as u32,
1306 mjd_fraction,
1307 })
1308}
1309
1310fn dist3(a: &[f64; 3], b: &[f64; 3]) -> f64 {
1311 vec3::norm3(vec3::sub3(*a, *b))
1312}
1313
1314fn position_dispersion(
1317 pos: &[(usize, [f64; 3], Sp3Flags)],
1318 members: &[usize],
1319 combined: &[f64; 3],
1320) -> (f64, f64) {
1321 let mut sumsq = 0.0;
1322 let mut max = 0.0_f64;
1323 for &i in members {
1324 let d = dist3(&pos[i].1, combined);
1325 sumsq += d * d;
1326 max = max.max(d);
1327 }
1328 ((sumsq / members.len().max(1) as f64).sqrt(), max)
1329}
1330
1331fn clock_dispersion(
1334 clk: &[(usize, f64, Sp3Flags)],
1335 members: &[usize],
1336 combined: f64,
1337) -> (f64, f64) {
1338 let mut sumsq = 0.0;
1339 let mut max = 0.0_f64;
1340 for &i in members {
1341 let d = (clk[i].1 - combined).abs();
1342 sumsq += d * d;
1343 max = max.max(d);
1344 }
1345 ((sumsq / members.len().max(1) as f64).sqrt(), max)
1346}
1347
1348fn precedence_sources_for_satellites(
1349 sources: &[Sp3],
1350 epoch_index: &[BTreeMap<i64, usize>],
1351 epoch_keys: &BTreeMap<i64, Instant>,
1352 systems: Option<&BTreeSet<GnssSystem>>,
1353) -> BTreeMap<GnssSatelliteId, usize> {
1354 let mut by_sat = BTreeMap::new();
1355
1356 for (idx, source) in sources.iter().enumerate() {
1357 for key in epoch_keys.keys() {
1358 let Some(&epoch_idx) = epoch_index[idx].get(key) else {
1359 continue;
1360 };
1361 let Ok(states) = source.states_at(epoch_idx) else {
1362 continue;
1363 };
1364
1365 for sat in states.keys() {
1366 if systems.is_none_or(|allowed| allowed.contains(&sat.system)) {
1367 by_sat.entry(*sat).or_insert(idx);
1368 }
1369 }
1370 }
1371 }
1372
1373 by_sat
1374}
1375
1376fn resolve_common_epoch_interval(sources: &[Sp3], target: Option<f64>) -> Result<f64> {
1391 let intervals: Vec<f64> = sources
1392 .iter()
1393 .enumerate()
1394 .map(|(idx, source)| {
1395 effective_epoch_interval_s(source)?.ok_or_else(|| {
1396 Error::InvalidInput(format!(
1397 "merge input {idx} has no usable positive epoch interval"
1398 ))
1399 })
1400 })
1401 .collect::<Result<Vec<_>>>()?;
1402
1403 let common = match target {
1404 Some(t) if t.is_finite() && t > 0.0 => t,
1405 Some(t) => {
1406 return Err(Error::InvalidInput(format!(
1407 "merge target epoch interval must be positive and finite, got {t}"
1408 )))
1409 }
1410 None => intervals.iter().copied().fold(0.0_f64, f64::max),
1411 };
1412
1413 if (common - common.round()).abs() > WHOLE_SECOND_EPS_S || common.round() < 1.0 {
1418 return Err(Error::InvalidInput(format!(
1419 "merge common epoch interval {common:.6} s must be a positive whole number of seconds"
1420 )));
1421 }
1422
1423 for (idx, interval) in intervals.iter().copied().enumerate() {
1424 if !divides_evenly(interval, common) {
1425 return Err(Error::InvalidInput(format!(
1426 "merge inputs have mismatched epoch intervals: common {common:.6} s is not an integer multiple of input {idx} {interval:.6} s (no exact-subset decimation; positional interpolation is not performed)"
1427 )));
1428 }
1429 }
1430
1431 Ok(common)
1432}
1433
1434fn divides_evenly(interval: f64, common: f64) -> bool {
1437 if !(interval.is_finite() && interval > 0.0 && common.is_finite() && common > 0.0) {
1438 return false;
1439 }
1440 let k = (common / interval).round();
1441 k >= 1.0 && same_interval(k * interval, common)
1442}
1443
1444fn effective_epoch_interval_s(source: &Sp3) -> Result<Option<f64>> {
1445 let secs: Vec<f64> = source
1446 .epochs
1447 .iter()
1448 .filter_map(instant_to_j2000_seconds)
1449 .collect();
1450 validate::require_strictly_increasing(secs.iter().copied(), "merge input epochs").map_err(
1451 |error| Error::InvalidInput(format!("{} must be strictly increasing", error.field())),
1452 )?;
1453 let gaps: Vec<f64> = secs.windows(2).map(|w| w[1] - w[0]).collect();
1454
1455 if gaps.is_empty() {
1456 let header = source.header.epoch_interval_s;
1457 return Ok((header.is_finite() && header > 0.0).then_some(header));
1458 }
1459
1460 let interval = gaps[0];
1461 if gaps.iter().all(|g| same_interval(*g, interval)) {
1462 Ok(Some(interval))
1463 } else {
1464 Ok(None)
1465 }
1466}
1467
1468fn same_interval(a: f64, b: f64) -> bool {
1469 (a - b).abs() <= WHOLE_SECOND_EPS_S
1470}
1471
1472fn largest_within<T>(items: &[T], within: impl Fn(&T, &T) -> bool) -> Vec<usize> {
1477 let n = items.len();
1478 if n <= 1 {
1479 return (0..n).collect();
1480 }
1481 let graph = agreement_graph(items, within);
1482 if n > MAX_EXACT_CLIQUE_NODES {
1483 return greedy_largest_clique(&graph);
1484 }
1485 let mut best = vec![0];
1486 let mut current = Vec::new();
1487 max_clique_search(&graph, &mut current, (0..n).collect(), &mut best);
1488 best
1489}
1490
1491fn largest_within_containing<T>(
1492 items: &[T],
1493 required: usize,
1494 within: impl Fn(&T, &T) -> bool,
1495) -> Vec<usize> {
1496 let n = items.len();
1497 if n == 0 || required >= n {
1498 return Vec::new();
1499 }
1500 if n == 1 {
1501 return vec![required];
1502 }
1503
1504 let graph = agreement_graph(items, within);
1505 if n > MAX_EXACT_CLIQUE_NODES {
1506 return greedy_largest_clique_containing(&graph, required);
1507 }
1508 let candidates = (0..n)
1509 .filter(|&idx| idx != required && graph[required][idx])
1510 .collect();
1511 let mut best = vec![required];
1512 let mut current = vec![required];
1513 max_clique_search(&graph, &mut current, candidates, &mut best);
1514 best
1515}
1516
1517fn agreement_graph<T>(items: &[T], within: impl Fn(&T, &T) -> bool) -> Vec<Vec<bool>> {
1518 let n = items.len();
1519 let mut graph = vec![vec![false; n]; n];
1520 for i in 0..n {
1521 graph[i][i] = true;
1522 for j in i + 1..n {
1523 let agrees = within(&items[i], &items[j]);
1524 graph[i][j] = agrees;
1525 graph[j][i] = agrees;
1526 }
1527 }
1528 graph
1529}
1530
1531fn greedy_largest_clique(graph: &[Vec<bool>]) -> Vec<usize> {
1532 let mut best = Vec::new();
1533 for seed in 0..graph.len() {
1534 let candidate = greedy_clique_from_seed(graph, seed);
1535 update_best_clique(&candidate, &mut best);
1536 }
1537 best
1538}
1539
1540fn greedy_largest_clique_containing(graph: &[Vec<bool>], required: usize) -> Vec<usize> {
1541 if required >= graph.len() {
1542 return Vec::new();
1543 }
1544 greedy_clique_from_seed(graph, required)
1545}
1546
1547fn greedy_clique_from_seed(graph: &[Vec<bool>], seed: usize) -> Vec<usize> {
1548 let mut clique = vec![seed];
1549 for (idx, _) in graph.iter().enumerate() {
1550 if idx == seed {
1551 continue;
1552 }
1553 if clique.iter().all(|&member| graph[member][idx]) {
1554 clique.push(idx);
1555 }
1556 }
1557 clique.sort_unstable();
1558 clique
1559}
1560
1561fn max_clique_search(
1562 graph: &[Vec<bool>],
1563 current: &mut Vec<usize>,
1564 mut candidates: Vec<usize>,
1565 best: &mut Vec<usize>,
1566) {
1567 candidates.sort_unstable();
1568 for (pos, &candidate) in candidates.iter().enumerate() {
1569 let remaining = candidates.len() - pos;
1570 if current.len() + remaining < best.len() {
1571 break;
1572 }
1573
1574 let next_candidates = candidates[pos + 1..]
1575 .iter()
1576 .copied()
1577 .filter(|&idx| graph[candidate][idx])
1578 .collect();
1579
1580 current.push(candidate);
1581 update_best_clique(current, best);
1582 max_clique_search(graph, current, next_candidates, best);
1583 current.pop();
1584 }
1585}
1586
1587fn update_best_clique(current: &[usize], best: &mut Vec<usize>) {
1588 let mut candidate = current.to_vec();
1589 candidate.sort_unstable();
1590 if candidate.len() > best.len()
1591 || (candidate.len() == best.len() && candidate.as_slice() < best.as_slice())
1592 {
1593 *best = candidate;
1594 }
1595}
1596
1597fn combine3(members: &[(usize, [f64; 3])], how: MergeCombine) -> [f64; 3] {
1598 [0usize, 1, 2].map(|axis| {
1599 let axis_members: Vec<(usize, f64)> = members.iter().map(|(s, v)| (*s, v[axis])).collect();
1600 combine_axis(&axis_members, how)
1601 })
1602}
1603
1604fn combine_axis(members: &[(usize, f64)], how: MergeCombine) -> f64 {
1605 match how {
1606 MergeCombine::Mean => members.iter().map(|(_, v)| *v).sum::<f64>() / members.len() as f64,
1607 MergeCombine::Median => {
1608 let mut vals: Vec<f64> = members.iter().map(|(_, v)| *v).collect();
1609 median(&mut vals).expect("consensus cluster is non-empty")
1610 }
1611 MergeCombine::Precedence => members
1612 .iter()
1613 .min_by_key(|(s, _)| *s)
1614 .map(|(_, v)| *v)
1615 .expect("consensus cluster is non-empty"),
1616 }
1617}
1618
1619pub fn align_clock_reference(reference: &Sp3, other: &Sp3, min_common: usize) -> Sp3 {
1633 let offsets: BTreeMap<i64, f64> = clock_reference_offset(reference, other, min_common)
1634 .into_iter()
1635 .filter_map(|o| {
1636 instant_to_j2000_seconds(&o.epoch).map(|sec| (sec.floor() as i64, o.offset_s))
1637 })
1638 .collect();
1639
1640 let mut aligned = other.clone();
1641 for ei in 0..aligned.epochs.len() {
1642 let Some(sec) = sp3_epoch_j2000_seconds(&aligned, ei, &aligned.epochs[ei]) else {
1643 continue;
1644 };
1645 let Some(&off) = offsets.get(&(sec.floor() as i64)) else {
1646 continue;
1647 };
1648 for state in aligned.states[ei].values_mut() {
1649 if let Some(c) = state.clock_s.as_mut() {
1650 *c -= off;
1651 }
1652 }
1653 for node in aligned.interp_raw[ei].values_mut() {
1654 if let Some(us) = node.clock_us.as_mut() {
1655 *us -= off * 1.0e6;
1656 }
1657 }
1658 }
1659 aligned
1660}
1661
1662#[cfg(test)]
1663mod tests {
1664 use super::super::Sp3;
1665 use super::{
1666 align_clock_reference, clock_reference_offset, merge, MergeCombine, MergeOptions,
1667 MergeReport, Sp3FrameLabelSet, Sp3FrameReconciliationMethod,
1668 };
1669 use crate::constants::SECONDS_PER_DAY;
1670 use crate::id::{GnssSatelliteId, GnssSystem};
1671 use std::collections::BTreeSet;
1672
1673 type SatSample<'a> = (&'a str, [f64; 3], Option<f64>);
1676
1677 fn gps(prn: u8) -> GnssSatelliteId {
1678 GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
1679 }
1680
1681 fn sp3_build(records: &[(&str, [f64; 3], Option<f64>, &str)], cs: &str) -> Sp3 {
1687 let n = records.len();
1688 let mut sats = String::new();
1689 for (sat, _, _, _) in records {
1690 sats.push_str(sat);
1691 }
1692 for _ in n..17 {
1693 sats.push_str(" 0");
1694 }
1695 let mut body = String::new();
1696 body.push_str(&format!(
1697 "#cP2020 6 25 0 0 0.00000000 1 ORBIT {cs} FIT TST\n"
1698 ));
1699 body.push_str("## 2111 432000.00000000 900.00000000 59025 0.0000000000000\n");
1700 body.push_str(&format!("+ {n:2} {sats}\n"));
1701 body.push_str("++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n");
1702 body.push_str("%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1703 body.push_str("%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1704 body.push_str("%f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n");
1705 body.push_str("%f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n");
1706 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1707 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1708 body.push_str("/* TEST SP3-c FIXTURE\n");
1709 body.push_str("* 2020 6 25 0 0 0.00000000\n");
1710 for (sat, p, clk, flags) in records {
1711 let c = clk.unwrap_or(999_999.999_999);
1712 body.push_str(&format!(
1713 "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}{flags}\n",
1714 p[0], p[1], p[2]
1715 ));
1716 }
1717 body.push_str("EOF\n");
1718 Sp3::parse(body.as_bytes()).expect("parse test sp3")
1719 }
1720
1721 fn sp3_records(records: &[(&str, [f64; 3], Option<f64>)]) -> Sp3 {
1723 let full: Vec<(&str, [f64; 3], Option<f64>, &str)> =
1724 records.iter().map(|(s, p, c)| (*s, *p, *c, "")).collect();
1725 sp3_build(&full, "IGS14")
1726 }
1727
1728 fn sp3_two_epochs(
1729 epoch0: &[(&str, [f64; 3], Option<f64>)],
1730 epoch1: &[(&str, [f64; 3], Option<f64>)],
1731 interval_s: f64,
1732 cs: &str,
1733 ) -> Sp3 {
1734 let mut sats: Vec<&str> = epoch0
1735 .iter()
1736 .chain(epoch1.iter())
1737 .map(|(sat, _, _)| *sat)
1738 .collect();
1739 sats.sort_unstable();
1740 sats.dedup();
1741 let n = sats.len();
1742 let mut sat_field = String::new();
1743 for sat in &sats {
1744 sat_field.push_str(sat);
1745 }
1746 for _ in n..17 {
1747 sat_field.push_str(" 0");
1748 }
1749
1750 let mut body = String::new();
1751 body.push_str(&format!(
1752 "#cP2020 6 25 0 0 0.00000000 2 ORBIT {cs} FIT TST\n"
1753 ));
1754 body.push_str(&format!(
1755 "## 2111 432000.00000000 {interval_s:14.8} 59025 0.0000000000000\n"
1756 ));
1757 body.push_str(&format!("+ {n:2} {sat_field}\n"));
1758 body.push_str("++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n");
1759 body.push_str("%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1760 body.push_str("%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1761 body.push_str("%f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n");
1762 body.push_str("%f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n");
1763 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1764 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1765 body.push_str("/* TEST SP3-c FIXTURE\n");
1766 body.push_str("* 2020 6 25 0 0 0.00000000\n");
1767 for (sat, p, clk) in epoch0 {
1768 let c = clk.unwrap_or(999_999.999_999);
1769 body.push_str(&format!(
1770 "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}\n",
1771 p[0], p[1], p[2]
1772 ));
1773 }
1774 let second_hour = (interval_s as i64) / 3600;
1775 let second_minute = ((interval_s as i64) % 3600) / 60;
1776 let second_second = (interval_s as i64) % 60;
1777 body.push_str(&format!(
1778 "* 2020 6 25 {second_hour:2} {second_minute:2} {second_second:2}.00000000\n"
1779 ));
1780 for (sat, p, clk) in epoch1 {
1781 let c = clk.unwrap_or(999_999.999_999);
1782 body.push_str(&format!(
1783 "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}\n",
1784 p[0], p[1], p[2]
1785 ));
1786 }
1787 body.push_str("EOF\n");
1788 Sp3::parse(body.as_bytes()).expect("parse test sp3")
1789 }
1790
1791 fn sp3_epochs(
1793 start_offset_s: f64,
1794 epochs: &[&[SatSample<'_>]],
1795 interval_s: f64,
1796 cs: &str,
1797 ) -> Sp3 {
1798 let mut sats: Vec<&str> = epochs
1799 .iter()
1800 .flat_map(|e| e.iter().map(|(sat, _, _)| *sat))
1801 .collect();
1802 sats.sort_unstable();
1803 sats.dedup();
1804 let n = sats.len();
1805 let mut sat_field = String::new();
1806 for sat in &sats {
1807 sat_field.push_str(sat);
1808 }
1809 for _ in n..17 {
1810 sat_field.push_str(" 0");
1811 }
1812
1813 let hms = |t: i64| (t / 3600, (t % 3600) / 60, t % 60);
1814 let start = start_offset_s as i64;
1815 let (sh, sm, ss0) = hms(start);
1816
1817 let mut body = String::new();
1818 body.push_str(&format!(
1819 "#cP2020 6 25 {sh:2} {sm:2} {ss0:2}.00000000 {:2} ORBIT {cs} FIT TST\n",
1820 epochs.len()
1821 ));
1822 let sow = 432_000.0 + start_offset_s;
1824 let mjd_frac = start_offset_s / SECONDS_PER_DAY;
1825 body.push_str(&format!(
1826 "## 2111 {sow:15.8} {interval_s:14.8} 59025 {mjd_frac:.13}\n"
1827 ));
1828 body.push_str(&format!("+ {n:2} {sat_field}\n"));
1829 body.push_str("++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n");
1830 body.push_str("%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1831 body.push_str("%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1832 body.push_str("%f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n");
1833 body.push_str("%f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n");
1834 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1835 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1836 body.push_str("/* TEST SP3-c FIXTURE\n");
1837 for (k, recs) in epochs.iter().enumerate() {
1838 let (hh, mm, ss) = hms(start + (k as i64) * (interval_s as i64));
1839 body.push_str(&format!("* 2020 6 25 {hh:2} {mm:2} {ss:2}.00000000\n"));
1840 for (sat, p, clk) in recs.iter() {
1841 let c = clk.unwrap_or(999_999.999_999);
1842 body.push_str(&format!(
1843 "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}\n",
1844 p[0], p[1], p[2]
1845 ));
1846 }
1847 }
1848 body.push_str("EOF\n");
1849 Sp3::parse(body.as_bytes()).expect("parse test sp3")
1850 }
1851
1852 #[test]
1853 fn merge_unions_coverage_when_one_center_misses_a_satellite() {
1854 let a = sp3_records(&[
1857 ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
1858 ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
1859 ("G03", [17000.0, -22000.0, 7000.0], Some(300.0)),
1860 ]);
1861 let b = sp3_records(&[
1862 ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
1863 ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
1864 ]);
1865
1866 let (merged, report) = merge(&[a, b], &MergeOptions::default()).expect("merge");
1867
1868 let states = merged.states_at(0).expect("epoch 0");
1869 assert!(
1870 states.contains_key(&gps(3)),
1871 "merged output must cover G03 from the center that has it"
1872 );
1873 assert_eq!(states.len(), 3, "union is G01/G02/G03");
1874 let g01 = states[&gps(1)];
1876 assert!((g01.clock_s.unwrap() - 100.0e-6).abs() < 1.0e-15);
1877 assert!(report.quarantined.is_empty());
1879 assert_eq!(report.single_source.len(), 1);
1880 assert_eq!(report.single_source[0].satellite, gps(3));
1881
1882 let frac = report
1886 .single_source_fraction()
1887 .expect("accepted cells present");
1888 assert!(
1889 (frac - 1.0 / 3.0).abs() < 1.0e-12,
1890 "single-source fraction {frac}"
1891 );
1892 assert_eq!(MergeReport::default().single_source_fraction(), None);
1893 }
1894
1895 #[test]
1896 fn merge_combines_two_of_three_agreeing_sources_and_rejects_the_outlier() {
1897 let a = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))]);
1899 let b = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))]);
1900 let c = sp3_records(&[("G01", [15000.010, -20000.0, 5000.0], Some(100.0))]);
1901
1902 let (merged, report) = merge(&[a, b, c], &MergeOptions::default()).expect("merge");
1903
1904 let states = merged.states_at(0).expect("epoch 0");
1905 let g01 = states[&gps(1)];
1906 assert!(
1908 (g01.position.as_array()[0] - 15_000_000.0).abs() < 1.0e-3,
1909 "got {}",
1910 g01.position.as_array()[0]
1911 );
1912 assert_eq!(report.position_outliers.len(), 1);
1914 assert_eq!(report.position_outliers[0].sources, vec![2]);
1915 assert!(report.quarantined.is_empty());
1916 }
1917
1918 #[test]
1919 fn merge_consensus_handles_more_than_u32_mask_bits() {
1920 let sources: Vec<Sp3> = (0..33)
1923 .map(|idx| {
1924 let x_km = if idx < 32 { 15000.0 } else { 15000.010 };
1925 sp3_records(&[("G01", [x_km, -20000.0, 5000.0], Some(100.0))])
1926 })
1927 .collect();
1928
1929 for combine in [MergeCombine::Mean, MergeCombine::Precedence] {
1930 let opts = MergeOptions {
1931 combine,
1932 min_agree: 32,
1933 ..MergeOptions::default()
1934 };
1935
1936 let (merged, report) = merge(&sources, &opts).expect("33-source merge");
1937
1938 let states = merged.states_at(0).expect("epoch 0");
1939 let g01 = states[&gps(1)];
1940 assert!(
1941 (g01.position.as_array()[0] - 15_000_000.0).abs() < 1.0e-3,
1942 "{combine:?}: got {}",
1943 g01.position.as_array()[0]
1944 );
1945 assert_eq!(
1946 report.position_outliers.len(),
1947 1,
1948 "{combine:?}: expected one outlier report"
1949 );
1950 assert_eq!(report.position_outliers[0].sources, vec![32]);
1951 assert!(report.quarantined.is_empty(), "{combine:?}");
1952 }
1953 }
1954
1955 #[test]
1956 fn merge_bounds_large_overlap_clique_search() {
1957 let sources: Vec<Sp3> = (0..40)
1958 .map(|idx| {
1959 let x_km = if idx % 2 == 0 { 15000.0 } else { 15000.010 };
1960 sp3_records(&[("G01", [x_km, -20000.0, 5000.0], Some(100.0))])
1961 })
1962 .collect();
1963 let opts = MergeOptions {
1964 min_agree: 20,
1965 ..MergeOptions::default()
1966 };
1967
1968 let (merged, report) = merge(&sources, &opts).expect("bounded large-source merge");
1969
1970 let states = merged.states_at(0).expect("epoch 0");
1971 let g01 = states[&gps(1)];
1972 assert!(
1973 (g01.position.as_array()[0] - 15_000_000.0).abs() < 1.0e-3,
1974 "got {}",
1975 g01.position.as_array()[0]
1976 );
1977 assert_eq!(report.position_outliers.len(), 1);
1978 assert_eq!(
1979 report.position_outliers[0].sources,
1980 (1..40).step_by(2).collect::<Vec<_>>()
1981 );
1982 assert!(report.quarantined.is_empty());
1983 }
1984
1985 #[test]
1986 fn merge_quarantines_a_satellite_all_centers_disagree_on() {
1987 let a = sp3_records(&[("G01", [15000.000, -20000.0, 5000.0], Some(100.0))]);
1989 let b = sp3_records(&[("G01", [15000.010, -20000.0, 5000.0], Some(100.0))]);
1990 let c = sp3_records(&[("G01", [15000.020, -20000.0, 5000.0], Some(100.0))]);
1991
1992 let (merged, report) = merge(&[a, b, c], &MergeOptions::default()).expect("merge");
1993
1994 assert!(
1995 merged.states_at(0).expect("epoch 0").is_empty(),
1996 "no consensus -> G01 omitted, not averaged across disagreeing centers"
1997 );
1998 assert_eq!(report.quarantined.len(), 1);
1999 assert_eq!(report.quarantined[0].satellite, gps(1));
2000 }
2001
2002 #[test]
2003 fn merge_rejects_an_empty_input() {
2004 assert!(merge(&[], &MergeOptions::default()).is_err());
2005 }
2006
2007 #[test]
2008 fn merge_omits_an_unalignable_secondary_clock() {
2009 let a = sp3_records(&[
2014 ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
2015 ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
2016 ("G03", [17000.0, -22000.0, 7000.0], Some(300.0)),
2017 ]);
2018 let b = sp3_records(&[
2019 ("G01", [15000.0, -20000.0, 5000.0], Some(150.0)),
2020 ("G02", [16000.0, -21000.0, 6000.0], Some(250.0)),
2021 ("G03", [17000.0, -22000.0, 7000.0], Some(350.0)),
2022 ("G04", [18000.0, -23000.0, 8000.0], Some(450.0)),
2023 ]);
2024
2025 let (merged, _) = merge(&[a, b], &MergeOptions::default()).expect("merge");
2026 let states = merged.states_at(0).expect("epoch 0");
2027
2028 assert!(states.contains_key(&gps(4)));
2030 assert!(
2031 states[&gps(4)].clock_s.is_none(),
2032 "an unalignable secondary clock must be dropped, not emitted raw"
2033 );
2034 let g01_clock = states[&gps(1)]
2036 .clock_s
2037 .expect("G01 carries the reference clock");
2038 assert!((g01_clock - 100.0e-6).abs() < 1.0e-12, "got {g01_clock}");
2039 }
2040
2041 #[test]
2042 fn merge_rejects_mismatched_coordinate_systems() {
2043 let a = sp3_build(
2044 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2045 "IGS14",
2046 );
2047 let b = sp3_build(
2048 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2049 "IGS20",
2050 );
2051
2052 assert!(merge(&[a, b], &MergeOptions::default()).is_err());
2053 }
2054
2055 #[test]
2056 fn merge_rejects_different_igs_frame_labels_without_a_transform() {
2057 let a = sp3_build(
2058 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2059 "IGS20",
2060 );
2061 let b = sp3_build(
2062 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2063 "IGc20",
2064 );
2065
2066 let err = merge(&[a, b], &MergeOptions::default()).expect_err("frame mismatch");
2067 assert!(
2068 err.to_string().contains("mismatched coordinate systems"),
2069 "{err}"
2070 );
2071 }
2072
2073 #[test]
2074 fn merge_accepts_asserted_equivalent_labels_and_reports_assertion() {
2075 for (a_label, b_label) in [("IGS14", "ITRF2"), ("ITRF2", "IGS14")] {
2076 let a = sp3_build(
2077 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2078 a_label,
2079 );
2080 let b = sp3_build(
2081 &[("G02", [16000.0, -21000.0, 6000.0], Some(200.0), "")],
2082 b_label,
2083 );
2084 let opts = MergeOptions {
2085 frame_reconciliation: super::Sp3FrameReconciliationOptions {
2086 asserted_equivalent_label_sets: vec![Sp3FrameLabelSet::pair("IGS14", "ITRF2")],
2087 helmert: false,
2088 },
2089 ..MergeOptions::default()
2090 };
2091
2092 let (merged, report) = merge(&[a, b], &opts).expect("asserted frame merge");
2093
2094 let states = merged.states_at(0).expect("epoch 0");
2095 assert!(states.contains_key(&gps(1)));
2096 assert!(states.contains_key(&gps(2)));
2097 assert_eq!(merged.header.coordinate_system, a_label);
2098 assert_eq!(report.frame_reconciliations.len(), 1);
2099 let reconciliation = &report.frame_reconciliations[0];
2100 assert_eq!(
2101 reconciliation.method,
2102 Sp3FrameReconciliationMethod::AssertedEquivalence
2103 );
2104 assert_eq!(reconciliation.source_index, 1);
2105 assert_eq!(reconciliation.source_label, b_label);
2106 assert_eq!(reconciliation.target_label, a_label);
2107 assert_eq!(reconciliation.records_affected, 1);
2108 assert!(reconciliation.parameters.is_none());
2109 assert!(reconciliation.rates.is_none());
2110 assert_eq!(
2111 reconciliation
2112 .asserted_label_set
2113 .as_ref()
2114 .expect("assertion set"),
2115 &vec!["IGS14".to_string(), "ITRF2".to_string()]
2116 );
2117 }
2118 }
2119
2120 #[test]
2121 fn merge_applies_helmert_reconciliation_to_resolved_labels() {
2122 let a = sp3_build(
2127 &[("G01", [14000.0, -19000.0, 4000.0], Some(100.0), "")],
2128 "IGS14",
2129 );
2130 let b = sp3_build(
2131 &[("G02", [15000.0, -20000.0, 5000.0], Some(200.0), "")],
2132 "IGS20",
2133 );
2134 let opts = MergeOptions {
2135 min_agree: 1,
2136 frame_reconciliation: super::Sp3FrameReconciliationOptions::helmert(),
2137 ..MergeOptions::default()
2138 };
2139
2140 let (merged, report) = merge(&[a, b], &opts).expect("helmert frame merge");
2141
2142 let g02 = merged.states_at(0).expect("epoch 0")[&gps(2)];
2143 let got = g02.position.as_array();
2144 let expected = [
2145 14_999_999.992_3,
2146 -19_999_999.993_048_087,
2147 5_000_000.000_396_175,
2148 ];
2149 for axis in 0..3 {
2150 assert!(
2151 (got[axis] - expected[axis]).abs() < 2.0e-9,
2152 "axis {axis}: got {}, expected {}",
2153 got[axis],
2154 expected[axis]
2155 );
2156 }
2157 assert_eq!(merged.header.coordinate_system, "IGS14");
2158 assert_eq!(report.frame_reconciliations.len(), 1);
2159 let reconciliation = &report.frame_reconciliations[0];
2160 assert_eq!(reconciliation.method, Sp3FrameReconciliationMethod::Helmert);
2161 assert_eq!(reconciliation.source_label, "IGS20");
2162 assert_eq!(reconciliation.target_label, "IGS14");
2163 assert_eq!(reconciliation.records_affected, 1);
2164 assert_eq!(
2165 reconciliation
2166 .parameters
2167 .expect("published parameters")
2168 .translation_mm,
2169 [-1.4, -0.9, 1.4]
2170 );
2171 assert_eq!(
2172 reconciliation.catalog_source_frame,
2173 Some(crate::frame_catalog::TerrestrialFrame::Itrf2020)
2174 );
2175 assert_eq!(
2176 reconciliation.catalog_target_frame,
2177 Some(crate::frame_catalog::TerrestrialFrame::Itrf2014)
2178 );
2179 assert!(!reconciliation.catalog_inverse);
2180 assert_eq!(
2181 reconciliation
2182 .rates
2183 .expect("published rates")
2184 .translation_mm_per_year,
2185 [0.0, -0.1, 0.2]
2186 );
2187 assert!(reconciliation
2188 .provenance
2189 .as_ref()
2190 .expect("provenance")
2191 .contains("ITRF2020 to past ITRFs"));
2192 }
2193
2194 #[test]
2195 fn merge_reports_inverse_helmert_catalog_direction() {
2196 let a = sp3_build(
2197 &[("G01", [14000.0, -19000.0, 4000.0], Some(100.0), "")],
2198 "IGS20",
2199 );
2200 let b = sp3_build(
2201 &[("G02", [15000.0, -20000.0, 5000.0], Some(200.0), "")],
2202 "IGS14",
2203 );
2204 let opts = MergeOptions {
2205 min_agree: 1,
2206 frame_reconciliation: super::Sp3FrameReconciliationOptions::helmert(),
2207 ..MergeOptions::default()
2208 };
2209
2210 let (_merged, report) = merge(&[a, b], &opts).expect("inverse helmert frame merge");
2211
2212 let reconciliation = &report.frame_reconciliations[0];
2213 assert_eq!(reconciliation.method, Sp3FrameReconciliationMethod::Helmert);
2214 assert_eq!(
2215 reconciliation.source_frame,
2216 Some(crate::frame_catalog::TerrestrialFrame::Itrf2014)
2217 );
2218 assert_eq!(
2219 reconciliation.target_frame,
2220 Some(crate::frame_catalog::TerrestrialFrame::Itrf2020)
2221 );
2222 assert_eq!(
2223 reconciliation.catalog_source_frame,
2224 Some(crate::frame_catalog::TerrestrialFrame::Itrf2020)
2225 );
2226 assert_eq!(
2227 reconciliation.catalog_target_frame,
2228 Some(crate::frame_catalog::TerrestrialFrame::Itrf2014)
2229 );
2230 assert!(reconciliation.catalog_inverse);
2231 assert_eq!(
2232 reconciliation
2233 .parameters
2234 .expect("published parameters")
2235 .translation_mm,
2236 [-1.4, -0.9, 1.4]
2237 );
2238 }
2239
2240 #[test]
2241 fn helmert_identity_label_reconciliation_is_bit_equal() {
2242 let a = sp3_build(
2243 &[("G01", [14000.0, -19000.0, 4000.0], Some(100.0), "")],
2244 "IGS20",
2245 );
2246 let b = sp3_build(
2247 &[("G02", [15000.125, -20000.5, 5000.25], Some(200.0), "")],
2248 "IGc20",
2249 );
2250 let original = b.states_at(0).expect("epoch 0")[&gps(2)].position;
2251 let opts = MergeOptions {
2252 min_agree: 1,
2253 frame_reconciliation: super::Sp3FrameReconciliationOptions::helmert(),
2254 ..MergeOptions::default()
2255 };
2256
2257 let (merged, report) = merge(&[a, b], &opts).expect("identity frame merge");
2258
2259 let g02 = merged.states_at(0).expect("epoch 0")[&gps(2)].position;
2260 for axis in 0..3 {
2261 assert_eq!(
2262 g02.as_array()[axis].to_bits(),
2263 original.as_array()[axis].to_bits()
2264 );
2265 }
2266 assert_eq!(report.frame_reconciliations.len(), 1);
2267 assert!(report.frame_reconciliations[0].identity);
2268 assert!(report.frame_reconciliations[0].parameters.is_none());
2269 }
2270
2271 #[test]
2272 fn helmert_reconciliation_rejects_unknown_labels() {
2273 let a = sp3_build(
2274 &[("G01", [14000.0, -19000.0, 4000.0], Some(100.0), "")],
2275 "ITRF2",
2276 );
2277 let b = sp3_build(
2278 &[("G02", [15000.0, -20000.0, 5000.0], Some(200.0), "")],
2279 "IGS20",
2280 );
2281 let opts = MergeOptions {
2282 frame_reconciliation: super::Sp3FrameReconciliationOptions::helmert(),
2283 ..MergeOptions::default()
2284 };
2285
2286 let err = merge(&[a, b], &opts).expect_err("unknown frame label");
2287
2288 assert!(
2289 err.to_string().contains("target label"),
2290 "unknown labels must not be guessed: {err}"
2291 );
2292 }
2293
2294 #[test]
2295 fn merge_decimates_finer_interval_onto_coarse_common_grid() {
2296 let a = sp3_two_epochs(
2302 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2303 &[("G01", [15003.0, -20003.0, 5003.0], Some(103.0))],
2304 900.0,
2305 "IGS14",
2306 );
2307 let b = sp3_epochs(
2308 0.0,
2309 &[
2310 &[("G01", [26000.0, -20000.0, 5000.0], Some(200.0))],
2311 &[("G01", [26001.0, -20001.0, 5001.0], Some(201.0))],
2312 &[("G01", [26002.0, -20002.0, 5002.0], Some(202.0))],
2313 &[("G01", [26003.0, -20003.0, 5003.0], Some(203.0))],
2314 ],
2315 300.0,
2316 "IGS14",
2317 );
2318
2319 let opts = MergeOptions {
2320 combine: MergeCombine::Precedence,
2321 min_agree: 1,
2322 ..MergeOptions::default()
2323 };
2324 let (merged, _report) =
2325 merge(&[a, b], &opts).expect("mixed-interval merge decimates onto the coarse grid");
2326
2327 assert_eq!(
2328 merged.header.epoch_interval_s, 900.0,
2329 "output is on the coarse (900 s) common grid"
2330 );
2331 assert_eq!(
2332 merged.epochs.len(),
2333 2,
2334 "only the two aligned epochs (:00, :15), not B's four"
2335 );
2336 for idx in 0..2 {
2339 let g01 = merged.states_at(idx).expect("epoch")[&gps(1)];
2340 assert!(
2341 (g01.position.as_array()[0] - 15_000_000.0 - (idx as f64) * 3000.0).abs() < 1.0,
2342 "epoch {idx}: expected A's value, got {}",
2343 g01.position.as_array()[0]
2344 );
2345 }
2346 assert!(merged.states_at(0).expect("epoch 0").contains_key(&gps(1)));
2347 assert!(merged.states_at(1).expect("epoch 1").contains_key(&gps(1)));
2348 }
2349
2350 #[test]
2351 fn merge_decimates_with_explicit_coarser_target_interval() {
2352 let recs = |x: f64| vec![("G01", [x, -20000.0, 5000.0], Some(100.0))];
2354 let make = || {
2355 sp3_epochs(
2356 0.0,
2357 &[
2358 &recs(15000.0),
2359 &recs(15001.0),
2360 &recs(15002.0),
2361 &recs(15003.0),
2362 ],
2363 300.0,
2364 "IGS14",
2365 )
2366 };
2367 let opts = MergeOptions {
2368 min_agree: 1,
2369 target_epoch_interval_s: Some(900.0),
2370 ..MergeOptions::default()
2371 };
2372 let (merged, _) = merge(&[make(), make()], &opts).expect("explicit coarse target");
2373 assert_eq!(merged.header.epoch_interval_s, 900.0);
2374 assert_eq!(
2375 merged.epochs.len(),
2376 2,
2377 "decimated 5-min inputs to the 900 s target"
2378 );
2379 }
2380
2381 #[test]
2382 fn merge_rejects_non_divisible_epoch_intervals() {
2383 let a = sp3_two_epochs(
2387 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2388 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2389 900.0,
2390 "IGS14",
2391 );
2392 let b = sp3_two_epochs(
2393 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2394 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2395 400.0,
2396 "IGS14",
2397 );
2398
2399 let err = merge(&[a, b], &MergeOptions::default()).expect_err("non-divisible intervals");
2400 assert!(
2401 err.to_string().contains("mismatched epoch intervals"),
2402 "{err}"
2403 );
2404 }
2405
2406 #[test]
2407 fn merge_rejects_a_non_whole_second_common_interval() {
2408 let mk = || {
2411 sp3_two_epochs(
2412 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2413 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2414 900.0,
2415 "IGS14",
2416 )
2417 };
2418 let opts = MergeOptions {
2419 target_epoch_interval_s: Some(450.5),
2420 ..MergeOptions::default()
2421 };
2422 let err = merge(&[mk(), mk()], &opts).expect_err("fractional target");
2423 assert!(err.to_string().contains("whole number of seconds"), "{err}");
2424 }
2425
2426 #[test]
2427 fn merge_header_first_epoch_describes_the_decimated_grid_start() {
2428 let a = sp3_epochs(
2433 0.0,
2434 &[
2435 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2436 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2437 &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
2438 ],
2439 900.0,
2440 "IGS14",
2441 );
2442 let b = sp3_epochs(
2443 900.0,
2444 &[
2445 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2446 &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
2447 &[("G01", [15003.0, -20003.0, 5003.0], Some(103.0))],
2448 ],
2449 900.0,
2450 "IGS14",
2451 );
2452
2453 let opts = MergeOptions {
2454 min_agree: 1,
2455 ..MergeOptions::default()
2456 };
2457 let (merged, _) = merge(&[a, b], &opts).expect("merge");
2458
2459 assert_eq!(merged.epochs.len(), 2, "common epochs are 00:15 and 00:30");
2460 assert!(
2461 (merged.header.seconds_of_week - 346_500.0).abs() < 1.0e-6,
2462 "header sow must describe the merged first epoch 00:15 (346500 s), got {}",
2463 merged.header.seconds_of_week
2464 );
2465 assert!(
2466 (merged.header.mjd_fraction - 900.0 / SECONDS_PER_DAY).abs() < 1.0e-9,
2467 "header MJD fraction must describe 00:15, got {}",
2468 merged.header.mjd_fraction
2469 );
2470 }
2471
2472 #[test]
2473 fn merge_writer_recomputes_header_when_common_grid_starts_after_all_inputs() {
2474 let a = sp3_epochs(
2479 0.0,
2480 &[
2481 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2482 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2483 &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
2484 ],
2485 900.0,
2486 "IGS14",
2487 );
2488 let b = sp3_epochs(
2489 450.0,
2490 &[
2491 &[("G01", [15010.0, -20010.0, 5010.0], Some(110.0))],
2492 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2493 &[("G01", [15011.0, -20011.0, 5011.0], Some(111.0))],
2494 &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
2495 ],
2496 450.0,
2497 "IGS14",
2498 );
2499
2500 let opts = MergeOptions {
2501 min_agree: 1,
2502 ..MergeOptions::default()
2503 };
2504 let (merged, _) = merge(&[a, b], &opts).expect("mixed-cadence merge");
2505
2506 assert_eq!(merged.epochs.len(), 2, "common epochs are 00:15 and 00:30");
2507 let text = merged.to_sp3_string();
2508 let header = text
2509 .lines()
2510 .find(|line| line.starts_with("## "))
2511 .expect("written ## header");
2512 let first_epoch = text
2513 .lines()
2514 .find(|line| line.starts_with("* "))
2515 .expect("written first epoch");
2516
2517 assert_eq!(first_epoch, "* 2020 6 25 0 15 0.00000000");
2518 assert_eq!(
2519 header,
2520 "## 2111 346500.00000000 900.00000000 59025 0.0104166666667"
2521 );
2522 }
2523
2524 #[test]
2525 fn precedence_merge_never_switches_source_within_one_satellite_arc() {
2526 let a = sp3_two_epochs(
2527 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2528 &[],
2529 900.0,
2530 "IGS14",
2531 );
2532 let b = sp3_two_epochs(
2533 &[("G01", [15000.001, -20000.0, 5000.0], Some(100.0))],
2534 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2535 900.0,
2536 "IGS14",
2537 );
2538 let opts = MergeOptions {
2539 combine: MergeCombine::Precedence,
2540 min_agree: 1,
2541 ..MergeOptions::default()
2542 };
2543
2544 let (merged, _report) = merge(&[a, b], &opts).expect("merge");
2545 let epoch0 = merged.states_at(0).expect("epoch 0");
2546 let epoch1 = merged.states_at(1).expect("epoch 1");
2547
2548 assert!(epoch0.contains_key(&gps(1)));
2549 assert!(
2550 !epoch1.contains_key(&gps(1)),
2551 "G01 must not switch from source 0 at epoch 0 to source 1 at epoch 1"
2552 );
2553 assert_eq!(merged.header.epoch_interval_s, 900.0);
2554 }
2555
2556 #[test]
2557 fn merge_filters_requested_constellations_and_header_satellites() {
2558 let a = sp3_two_epochs(
2559 &[
2560 ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
2561 ("E01", [21000.0, -1000.0, 13000.0], Some(120.0)),
2562 ],
2563 &[
2564 ("G01", [15001.0, -20001.0, 5001.0], Some(101.0)),
2565 ("E01", [21001.0, -1001.0, 13001.0], Some(121.0)),
2566 ],
2567 900.0,
2568 "IGS14",
2569 );
2570 let systems = BTreeSet::from([GnssSystem::Gps]);
2571 let opts = MergeOptions {
2572 systems: Some(systems),
2573 ..MergeOptions::default()
2574 };
2575
2576 let (merged, _report) = merge(&[a], &opts).expect("merge");
2577
2578 assert_eq!(merged.header.satellites, vec![gps(1)]);
2579 for idx in 0..merged.epochs.len() {
2580 let states = merged.states_at(idx).expect("epoch");
2581 assert_eq!(states.keys().copied().collect::<Vec<_>>(), vec![gps(1)]);
2582 }
2583 }
2584
2585 #[test]
2586 fn merge_preserves_a_clock_event_flag() {
2587 let a = sp3_build(
2590 &[(
2591 "G01",
2592 [15000.0, -20000.0, 5000.0],
2593 Some(100.0),
2594 " E",
2595 )],
2596 "IGS14",
2597 );
2598 let b = sp3_build(
2599 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2600 "IGS14",
2601 );
2602
2603 let (merged, _) = merge(&[a, b], &MergeOptions::default()).expect("merge");
2604 let g01 = merged.states_at(0).expect("epoch 0")[&gps(1)];
2605
2606 assert!(
2607 g01.flags.clock_event,
2608 "merged cell must preserve a contributing source's clock-event flag"
2609 );
2610 }
2611
2612 #[test]
2613 fn merge_reports_effective_epoch_interval_from_actual_epochs() {
2614 let body = "#cP2020 6 25 0 0 0.00000000 2 ORBIT IGS14 FIT TST\n\
2618 ## 2111 432000.00000000 300.00000000 59025 0.0000000000000\n\
2619 + 1 G01 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
2620 ++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
2621 %c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
2622 %c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
2623 %f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n\
2624 %f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n\
2625 %i 0 0 0 0 0 0 0 0 0\n\
2626 %i 0 0 0 0 0 0 0 0 0\n\
2627 /* TEST SP3-c FIXTURE\n\
2628 * 2020 6 25 0 0 0.00000000\n\
2629 PG01 15000.000000 -20000.000000 5000.000000 100.000000\n\
2630 * 2020 6 25 0 15 0.00000000\n\
2631 PG01 15001.000000 -20001.000000 5001.000000 101.000000\n\
2632 EOF\n";
2633 let a = Sp3::parse(body.as_bytes()).expect("parse test sp3");
2634
2635 let (merged, _) = merge(&[a], &MergeOptions::default()).expect("merge");
2636
2637 assert!(
2638 (merged.header.epoch_interval_s - 900.0).abs() < 1.0e-6,
2639 "got {}",
2640 merged.header.epoch_interval_s
2641 );
2642 }
2643
2644 #[test]
2645 fn merge_rejects_unsorted_input_epochs_before_cadence_inference() {
2646 let body = "#cP2020 6 25 0 0 0.00000000 2 ORBIT IGS14 FIT TST\n\
2647 ## 2111 432000.00000000 900.00000000 59025 0.0000000000000\n\
2648 + 1 G01 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
2649 ++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
2650 %c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
2651 %c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
2652 %f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n\
2653 %f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n\
2654 %i 0 0 0 0 0 0 0 0 0\n\
2655 %i 0 0 0 0 0 0 0 0 0\n\
2656 /* TEST SP3-c FIXTURE\n\
2657 * 2020 6 25 0 15 0.00000000\n\
2658 PG01 15001.000000 -20001.000000 5001.000000 101.000000\n\
2659 * 2020 6 25 0 0 0.00000000\n\
2660 PG01 15000.000000 -20000.000000 5000.000000 100.000000\n\
2661 EOF\n";
2662 let source = Sp3::parse(body.as_bytes()).expect("parse unsorted test sp3");
2663
2664 let err = merge(&[source], &MergeOptions::default()).expect_err("unsorted epochs");
2665
2666 assert!(
2667 err.to_string()
2668 .contains("merge input epochs must be strictly increasing"),
2669 "{err}"
2670 );
2671 }
2672
2673 #[test]
2674 fn align_clock_reference_puts_other_on_the_reference_datum() {
2675 let reference = sp3([100.0, 200.0, 300.0]);
2678 let other = sp3([150.0, 250.0, 350.0]);
2679
2680 let aligned = align_clock_reference(&reference, &other, 3);
2681
2682 let g01 = aligned.states_at(0).expect("epoch 0")[&gps(1)];
2683 assert!(
2684 (g01.clock_s.unwrap() - 100.0e-6).abs() < 1.0e-15,
2685 "got {}",
2686 g01.clock_s.unwrap()
2687 );
2688 let original = other.states_at(0).expect("epoch 0")[&gps(1)];
2690 assert_eq!(g01.position.as_array(), original.position.as_array());
2691 }
2692
2693 fn sp3(clocks_us: [f64; 3]) -> Sp3 {
2697 let body = format!(
2698 "#cP2020 6 25 0 0 0.00000000 1 ORBIT IGS14 FIT TST\n\
2699 ## 2111 432000.00000000 900.00000000 59025 0.0000000000000\n\
2700 + 3 G01G02G03 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
2701 ++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
2702 %c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
2703 %c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
2704 %f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n\
2705 %f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n\
2706 %i 0 0 0 0 0 0 0 0 0\n\
2707 %i 0 0 0 0 0 0 0 0 0\n\
2708 /* TEST SP3-c FIXTURE\n\
2709 * 2020 6 25 0 0 0.00000000\n\
2710 PG01 15000.000000 -20000.000000 5000.000000 {:13.6}\n\
2711 PG02 -1234.567890 2345.678901 -3456.789012 {:13.6}\n\
2712 PG03 8000.000000 12000.000000 -19000.000000 {:13.6}\n\
2713 EOF\n",
2714 clocks_us[0], clocks_us[1], clocks_us[2]
2715 );
2716 Sp3::parse(body.as_bytes()).expect("parse test sp3")
2717 }
2718
2719 #[test]
2720 fn recovers_a_uniform_datum_shift() {
2721 let reference = sp3([100.0, 200.0, 300.0]);
2723 let other = sp3([150.0, 250.0, 350.0]);
2724
2725 let offsets = clock_reference_offset(&reference, &other, 3);
2726
2727 assert_eq!(offsets.len(), 1);
2728 assert_eq!(offsets[0].satellites, 3);
2729 assert!(
2730 (offsets[0].offset_s - 5.0e-5).abs() < 1.0e-12,
2731 "got {}",
2732 offsets[0].offset_s
2733 );
2734 }
2735
2736 #[test]
2737 fn median_rejects_a_single_outlier_clock() {
2738 let reference = sp3([100.0, 200.0, 300.0]);
2741 let other = sp3([150.0, 250.0, 9_300.0]);
2742
2743 let offsets = clock_reference_offset(&reference, &other, 3);
2744
2745 assert_eq!(offsets.len(), 1);
2746 assert!(
2747 (offsets[0].offset_s - 5.0e-5).abs() < 1.0e-12,
2748 "got {}",
2749 offsets[0].offset_s
2750 );
2751 }
2752
2753 #[test]
2754 fn omits_epochs_below_min_common() {
2755 let reference = sp3([100.0, 200.0, 300.0]);
2758 let other = sp3([150.0, 250.0, 350.0]);
2759
2760 assert!(clock_reference_offset(&reference, &other, 4).is_empty());
2761 }
2762
2763 #[test]
2764 fn merge_agreement_metric_reports_known_position_dispersion() {
2765 let a = sp3_records(&[("G01", [15000.000, -20000.0, 5000.0], Some(100.0))]);
2770 let b = sp3_records(&[("G01", [15000.003, -20000.0, 5000.0], Some(100.0))]);
2771 let c = sp3_records(&[("G01", [15000.006, -20000.0, 5000.0], Some(100.0))]);
2772 let opts = MergeOptions {
2773 position_tolerance_m: 10.0,
2774 min_agree: 3,
2775 combine: MergeCombine::Mean,
2776 ..MergeOptions::default()
2777 };
2778
2779 let (_merged, report) = merge(&[a, b, c], &opts).expect("merge");
2780
2781 assert_eq!(report.agreement.len(), 1, "one accepted cell");
2782 let m = report.agreement[0];
2783 assert_eq!(m.satellite, gps(1));
2784 assert_eq!(m.position_members, 3);
2785 assert!(
2786 (m.position_rms_m - 6.0_f64.sqrt()).abs() < 1.0e-6,
2787 "got rms {}",
2788 m.position_rms_m
2789 );
2790 assert!(
2791 (m.position_max_m - 3.0).abs() < 1.0e-6,
2792 "got max {}",
2793 m.position_max_m
2794 );
2795
2796 assert!((report.position_agreement_rms_m().unwrap() - 6.0_f64.sqrt()).abs() < 1.0e-6);
2798 assert!((report.position_agreement_max_m().unwrap() - 3.0).abs() < 1.0e-6);
2799
2800 let per_epoch = report.per_epoch_agreement();
2802 assert_eq!(per_epoch.len(), 1);
2803 assert_eq!(per_epoch[0].satellites, 1);
2804 assert!((per_epoch[0].position_rms_m - 6.0_f64.sqrt()).abs() < 1.0e-6);
2805 assert!((per_epoch[0].position_max_m - 3.0).abs() < 1.0e-6);
2806 }
2807
2808 #[test]
2809 fn merge_agreement_metric_reports_known_clock_dispersion() {
2810 let a = sp3([100.0, 200.0, 300.0]);
2816 let b = sp3([100.0, 200.0, 330.0]);
2817 let c = sp3([100.0, 200.0, 270.0]);
2818 let opts = MergeOptions {
2819 clock_min_common: 1,
2820 clock_tolerance_s: 1.0e-3,
2821 min_agree: 3,
2822 combine: MergeCombine::Mean,
2823 ..MergeOptions::default()
2824 };
2825
2826 let (_merged, report) = merge(&[a, b, c], &opts).expect("merge");
2827
2828 let g03 = report
2829 .agreement
2830 .iter()
2831 .find(|m| m.satellite == gps(3))
2832 .expect("G03 agreement metric");
2833 assert_eq!(g03.clock_members, 3);
2834 let expected_rms_s = 600.0_f64.sqrt() * 1.0e-6;
2835 assert!(
2836 (g03.clock_rms_s.unwrap() - expected_rms_s).abs() < 1.0e-15,
2837 "got clock rms {:?}",
2838 g03.clock_rms_s
2839 );
2840 assert!(
2841 (g03.clock_max_s.unwrap() - 30.0e-6).abs() < 1.0e-15,
2842 "got clock max {:?}",
2843 g03.clock_max_s
2844 );
2845 for prn in [1u8, 2] {
2847 let m = report
2848 .agreement
2849 .iter()
2850 .find(|m| m.satellite == gps(prn))
2851 .expect("metric");
2852 assert!(m.clock_rms_s.unwrap().abs() < 1.0e-18, "prn {prn}");
2853 assert!(m.position_rms_m.abs() < 1.0e-9, "prn {prn}");
2855 }
2856
2857 let pooled = report.clock_agreement_rms_s().expect("clock pool");
2861 assert!(
2862 (pooled - expected_rms_s / 3.0_f64.sqrt()).abs() < 1.0e-15,
2863 "got pooled {pooled}"
2864 );
2865 assert!((report.clock_agreement_max_s().unwrap() - 30.0e-6).abs() < 1.0e-15);
2866 }
2867
2868 #[cfg(sidereon_repo_tests)]
2896 #[test]
2897 fn merge_agrees_with_published_igs_combined_within_cm() {
2898 fn load(name: &str) -> Sp3 {
2899 let path = format!("{}/tests/fixtures/sp3/{}", env!("CARGO_MANIFEST_DIR"), name);
2900 let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("read {path}: {e}"));
2901 Sp3::parse(&bytes).unwrap_or_else(|e| panic!("parse {name}: {e}"))
2902 }
2903
2904 let cod = load("COD0OPSFIN_20261200945_02H30M_15M_ORB_trim.SP3");
2905 let gfz = load("GFZ0OPSFIN_20261200945_02H30M_15M_ORB_trim.SP3");
2906 let jpl = load("JPL0OPSFIN_20261200945_02H30M_15M_ORB_trim.SP3");
2907 let igs = load("IGS0OPSFIN_20261200945_02H30M_15M_ORB.SP3");
2908
2909 let (merged, report) =
2910 merge(&[cod, gfz, jpl], &MergeOptions::default()).expect("multi-center merge");
2911
2912 assert!(
2915 report.quarantined.is_empty(),
2916 "centers should agree: {:?}",
2917 report.quarantined
2918 );
2919 assert!(
2922 report.single_source.is_empty(),
2923 "{:?}",
2924 report.single_source
2925 );
2926 assert!(
2927 report.position_outliers.is_empty(),
2928 "{:?}",
2929 report.position_outliers
2930 );
2931 assert!(
2932 report.agreement.iter().all(|a| a.position_members == 3),
2933 "every agreement cell should be a 3-source consensus"
2934 );
2935
2936 let mut igs_idx: std::collections::BTreeMap<i64, usize> = std::collections::BTreeMap::new();
2937 for (i, ep) in igs.epochs.iter().enumerate() {
2938 if let Some(s) = super::instant_to_j2000_seconds(ep) {
2939 igs_idx.insert(s.floor() as i64, i);
2940 }
2941 }
2942
2943 let mut sumsq = 0.0_f64;
2944 let mut max = 0.0_f64;
2945 let mut n = 0usize;
2946 for (mi, ep) in merged.epochs.iter().enumerate() {
2947 let key = super::instant_to_j2000_seconds(ep)
2948 .expect("merged epoch key")
2949 .floor() as i64;
2950 let ii = *igs_idx.get(&key).expect("IGS combined covers merged epoch");
2951 let merged_states = merged.states_at(mi).expect("merged states");
2952 let igs_states = igs.states_at(ii).expect("IGS states");
2953 for (sat, mst) in merged_states.iter() {
2954 let ist = igs_states
2955 .get(sat)
2956 .unwrap_or_else(|| panic!("merged sat {sat} missing from IGS combined"));
2957 let d = super::dist3(&mst.position.as_array(), &ist.position.as_array());
2958 sumsq += d * d;
2959 max = max.max(d);
2960 n += 1;
2961 }
2962 }
2963
2964 assert_eq!(n, 88, "expected exactly 88 compared cells, got {n}");
2967 let rms = (sumsq / n as f64).sqrt();
2968 assert!(
2970 rms < 0.02,
2971 "combine-vs-IGS RMS {:.4} m ({} cells) exceeds the 2 cm gate",
2972 rms,
2973 n
2974 );
2975 assert!(
2976 max < 0.05,
2977 "combine-vs-IGS max {max:.4} m exceeds the 5 cm gate"
2978 );
2979
2980 let dispersion = report
2982 .position_agreement_rms_m()
2983 .expect("multi-source cells present");
2984 assert!(
2985 dispersion < 0.05,
2986 "inter-center position dispersion {dispersion:.4} m"
2987 );
2988 }
2989}