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, TerminalRecordState};
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::sp3::continuity::{
36 check_continuity, ContinuityDefect, ContinuityOptions, ContinuityReport, EpochWindow,
37 StencilExtent, WindowContinuityDecision, WindowContinuityVerdict,
38};
39use crate::tolerances::WHOLE_SECOND_EPS_S;
40use crate::validate;
41use crate::{Error, Result};
42
43const MAX_EXACT_CLIQUE_NODES: usize = 32;
44
45#[derive(Debug, Clone, Copy, PartialEq)]
47pub struct ClockReferenceOffset {
48 pub epoch: Instant,
50 pub offset_s: f64,
54 pub satellites: usize,
56}
57
58pub fn clock_reference_offset(
84 reference: &Sp3,
85 other: &Sp3,
86 min_common: usize,
87) -> Vec<ClockReferenceOffset> {
88 let mut other_index: std::collections::HashMap<i64, usize> = std::collections::HashMap::new();
89 for (idx, epoch) in other.epochs.iter().enumerate() {
90 if let Some(seconds) = sp3_epoch_j2000_seconds(other, idx, epoch) {
91 other_index.insert(seconds.floor() as i64, idx);
92 }
93 }
94
95 let mut offsets = Vec::new();
96
97 for (ref_idx, epoch) in reference.epochs.iter().enumerate() {
98 let Some(ref_seconds) = sp3_epoch_j2000_seconds(reference, ref_idx, epoch) else {
99 continue;
100 };
101 let Some(&other_idx) = other_index.get(&(ref_seconds.floor() as i64)) else {
102 continue;
103 };
104
105 let (Ok(ref_states), Ok(other_states)) =
106 (reference.states_at(ref_idx), other.states_at(other_idx))
107 else {
108 continue;
109 };
110
111 let mut diffs: Vec<f64> = Vec::new();
112 for (sat, ref_state) in ref_states.iter() {
113 let Some(ref_clock) = ref_state.clock_s else {
114 continue;
115 };
116 if let Some(other_state) = other_states.get(sat) {
117 if let Some(other_clock) = other_state.clock_s {
118 let diff = other_clock - ref_clock;
119 if diff.is_finite() {
122 diffs.push(diff);
123 }
124 }
125 }
126 }
127
128 if diffs.len() >= min_common.max(1) {
129 if let Some(offset_s) = median(&mut diffs) {
130 offsets.push(ClockReferenceOffset {
131 epoch: *epoch,
132 offset_s,
133 satellites: diffs.len(),
134 });
135 }
136 }
137 }
138
139 offsets
140}
141
142fn median(values: &mut [f64]) -> Option<f64> {
143 crate::astro::math::robust::median_sorting_in_place(values)
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum MergeCombine {
155 Mean,
158 Median,
160 Precedence,
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum MergePrecedenceScope {
167 Cell,
170 SatelliteArc,
173}
174
175#[derive(Debug, Clone, Copy, PartialEq)]
185pub struct OutlierRejectOptions {
186 pub position_tolerance_m: f64,
188 pub clock_tolerance_s: f64,
190}
191
192#[non_exhaustive]
201#[derive(Debug, Clone, PartialEq)]
202pub struct MergeOptions {
203 pub position_tolerance_m: f64,
206 pub clock_tolerance_s: f64,
209 pub min_agree: usize,
215 pub clock_min_common: usize,
218 pub combine: MergeCombine,
220 pub precedence_scope: MergePrecedenceScope,
223 pub outlier_reject: Option<OutlierRejectOptions>,
226 pub target_epoch_interval_s: Option<f64>,
231 pub systems: Option<BTreeSet<GnssSystem>>,
234 pub frame_reconciliation: Sp3FrameReconciliationOptions,
237 pub provenance: Option<ProvenanceMode>,
243 pub verify_continuity: Option<ContinuityOptions>,
250}
251
252impl Default for MergeOptions {
253 fn default() -> Self {
256 Self {
257 position_tolerance_m: 0.5,
258 clock_tolerance_s: 5.0e-9,
259 min_agree: 2,
260 clock_min_common: 5,
261 combine: MergeCombine::Mean,
262 precedence_scope: MergePrecedenceScope::Cell,
263 outlier_reject: None,
264 target_epoch_interval_s: None,
265 systems: None,
266 frame_reconciliation: Sp3FrameReconciliationOptions::default(),
267 provenance: None,
268 verify_continuity: None,
269 }
270 }
271}
272
273#[derive(Debug, Clone, Default, PartialEq, Eq)]
275pub struct Sp3FrameReconciliationOptions {
276 pub asserted_equivalent_label_sets: Vec<Sp3FrameLabelSet>,
279 pub helmert: bool,
282}
283
284impl Sp3FrameReconciliationOptions {
285 pub const fn disabled() -> Self {
287 Self {
288 asserted_equivalent_label_sets: Vec::new(),
289 helmert: false,
290 }
291 }
292
293 pub const fn helmert() -> Self {
295 Self {
296 asserted_equivalent_label_sets: Vec::new(),
297 helmert: true,
298 }
299 }
300}
301
302#[derive(Debug, Clone, PartialEq, Eq)]
305pub struct Sp3FrameLabelSet {
306 pub labels: BTreeSet<String>,
308}
309
310impl Sp3FrameLabelSet {
311 pub fn new(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
313 Self {
314 labels: labels
315 .into_iter()
316 .map(|label| label.into().trim().to_string())
317 .collect(),
318 }
319 }
320
321 pub fn pair(a: impl Into<String>, b: impl Into<String>) -> Self {
323 Self::new([a.into(), b.into()])
324 }
325}
326
327#[derive(Debug, Clone, PartialEq)]
330pub struct MergeFlag {
331 pub epoch: Instant,
333 pub satellite: GnssSatelliteId,
335 pub sources: Vec<usize>,
340}
341
342#[derive(Debug, Clone, Copy, PartialEq)]
351pub struct AgreementMetric {
352 pub epoch: Instant,
354 pub satellite: GnssSatelliteId,
356 pub position_members: usize,
358 pub position_rms_m: f64,
361 pub position_max_m: f64,
364 pub clock_members: usize,
367 pub clock_rms_s: Option<f64>,
370 pub clock_max_s: Option<f64>,
373}
374
375#[derive(Debug, Clone, Copy, PartialEq)]
379pub struct EpochAgreement {
380 pub epoch: Instant,
382 pub satellites: usize,
384 pub position_rms_m: f64,
388 pub position_max_m: f64,
390 pub clock_rms_s: Option<f64>,
393 pub clock_max_s: Option<f64>,
395}
396
397#[derive(Debug, Clone, Copy, PartialEq, Eq)]
399pub enum Sp3FrameReconciliationMethod {
400 AssertedEquivalence,
403 Helmert,
406}
407
408#[derive(Debug, Clone, PartialEq)]
410pub struct Sp3FrameReconciliation {
411 pub source_index: usize,
413 pub source_label: String,
415 pub target_label: String,
417 pub method: Sp3FrameReconciliationMethod,
419 pub asserted_label_set: Option<Vec<String>>,
421 pub source_frame: Option<TerrestrialFrame>,
423 pub target_frame: Option<TerrestrialFrame>,
425 pub catalog_source_frame: Option<TerrestrialFrame>,
428 pub catalog_target_frame: Option<TerrestrialFrame>,
431 pub catalog_inverse: bool,
433 pub reference_epoch_year: Option<f64>,
436 pub parameters: Option<HelmertParameters>,
439 pub rates: Option<HelmertRates>,
441 pub provenance: Option<String>,
443 pub epoch_year_span: Option<[f64; 2]>,
446 pub records_affected: usize,
448 pub identity: bool,
451}
452
453#[derive(Debug, Clone, Copy, PartialEq, Eq)]
459pub enum ProvenanceMode {
460 Summary,
463 Full,
466}
467
468#[derive(Debug, Clone, PartialEq)]
478pub enum CellSelection {
479 SingleSource {
482 source: usize,
484 },
485 Precedence {
487 source: usize,
489 members: Vec<usize>,
491 },
492 Combined {
495 rule: MergeCombine,
497 members: Vec<usize>,
499 },
500}
501
502impl CellSelection {
503 pub fn selected_source(&self) -> Option<usize> {
508 match self {
509 Self::SingleSource { source } | Self::Precedence { source, .. } => Some(*source),
510 Self::Combined { .. } => None,
511 }
512 }
513
514 pub fn members(&self) -> Vec<usize> {
516 match self {
517 Self::SingleSource { source } => vec![*source],
518 Self::Precedence { members, .. } | Self::Combined { members, .. } => members.clone(),
519 }
520 }
521}
522
523#[derive(Debug, Clone, PartialEq)]
526pub struct CellProvenance {
527 pub epoch: Instant,
529 pub satellite: GnssSatelliteId,
531 pub position: CellSelection,
533 pub clock: Option<CellSelection>,
536}
537
538#[derive(Debug, Clone, Copy, PartialEq, Eq)]
540pub enum TransitionReason {
541 SoleAvailability,
543 Precedence,
545 OutlierRejection,
548 ConsensusChange,
551}
552
553#[derive(Debug, Clone, PartialEq)]
555pub struct PrecedenceTransition {
556 pub satellite: GnssSatelliteId,
558 pub epoch: Instant,
560 pub from_source: Option<usize>,
563 pub to_source: Option<usize>,
566 pub reason: TransitionReason,
568}
569
570#[derive(Debug, Clone, PartialEq)]
572pub struct ContributorCoverage {
573 pub source: usize,
575 pub cells_contributed: usize,
577 pub cells_selected: usize,
581 pub first_epoch: Option<Instant>,
583 pub last_epoch: Option<Instant>,
585 pub cells_absent: usize,
588}
589
590#[derive(Debug, Clone, PartialEq)]
596pub struct MergeProvenance {
597 pub mode: ProvenanceMode,
599 pub cells: Vec<CellProvenance>,
602 pub transitions: Vec<PrecedenceTransition>,
605 pub coverage: Vec<ContributorCoverage>,
607}
608
609#[derive(Debug, Clone, PartialEq)]
616pub struct MergeContinuityViolation {
617 pub defect: ContinuityDefect,
619 pub from_sources: Vec<usize>,
623 pub to_sources: Vec<usize>,
625 pub crosses_contributors: bool,
630}
631
632#[derive(Debug, Clone, PartialEq)]
638pub struct MergeContinuityReport {
639 pub report: ContinuityReport,
641 pub violations: Vec<MergeContinuityViolation>,
643}
644
645impl MergeContinuityReport {
646 pub fn attested(&self) -> bool {
648 self.report.attested()
649 }
650
651 pub fn splices(&self) -> impl Iterator<Item = &MergeContinuityViolation> {
653 self.violations
654 .iter()
655 .filter(|violation| violation.crosses_contributors)
656 }
657
658 pub fn splices_influencing(
661 &self,
662 window: EpochWindow,
663 stencil: StencilExtent,
664 ) -> Vec<&MergeContinuityViolation> {
665 self.splices()
666 .filter(|violation| violation.defect.influences(window, stencil))
667 .collect()
668 }
669
670 pub fn verdict_for_window(
676 &self,
677 window: EpochWindow,
678 stencil: StencilExtent,
679 ) -> WindowContinuityVerdict<'_> {
680 let influencing_defects = self.report.defects_influencing(window, stencil);
681 let influencing_splices = self.splices_influencing(window, stencil);
682 let all_splices = self.splices().collect();
683 let decision = if influencing_defects.is_empty() && influencing_splices.is_empty() {
684 WindowContinuityDecision::Accept
685 } else {
686 WindowContinuityDecision::Refuse
687 };
688 WindowContinuityVerdict {
689 decision,
690 influencing_defects,
691 influencing_splices,
692 all_defects: &self.report.defects,
693 all_splices,
694 }
695 }
696}
697
698#[derive(Debug, Clone, Default, PartialEq)]
700pub struct MergeReport {
701 pub frame_reconciliations: Vec<Sp3FrameReconciliation>,
703 pub quarantined: Vec<MergeFlag>,
706 pub single_source: Vec<MergeFlag>,
708 pub position_outliers: Vec<MergeFlag>,
711 pub clock_outliers: Vec<MergeFlag>,
714 pub agreement: Vec<AgreementMetric>,
719 pub provenance: Option<MergeProvenance>,
723 pub continuity: Option<MergeContinuityReport>,
726}
727
728impl MergeReport {
729 pub fn splices_influencing(
735 &self,
736 window: EpochWindow,
737 stencil: StencilExtent,
738 ) -> Option<Vec<&MergeContinuityViolation>> {
739 self.continuity
740 .as_ref()
741 .map(|report| report.splices_influencing(window, stencil))
742 }
743
744 pub fn continuity_verdict_for_window(
751 &self,
752 window: EpochWindow,
753 stencil: StencilExtent,
754 ) -> Option<WindowContinuityVerdict<'_>> {
755 self.continuity
756 .as_ref()
757 .map(|report| report.verdict_for_window(window, stencil))
758 }
759
760 pub fn single_source_fraction(&self) -> Option<f64> {
770 let accepted = self.agreement.len();
771 (accepted > 0).then(|| self.single_source.len() as f64 / accepted as f64)
772 }
773
774 pub fn position_agreement_rms_m(&self) -> Option<f64> {
788 pooled_rms(
789 self.agreement
790 .iter()
791 .filter(|m| m.position_members >= 2)
792 .map(|m| (m.position_rms_m, m.position_members)),
793 )
794 }
795
796 pub fn position_agreement_max_m(&self) -> Option<f64> {
799 self.agreement
800 .iter()
801 .map(|m| m.position_max_m)
802 .fold(None, |acc, v| Some(fold_max(acc, v)))
803 }
804
805 pub fn clock_agreement_rms_s(&self) -> Option<f64> {
807 pooled_rms(self.agreement.iter().filter_map(|m| {
808 m.clock_rms_s
809 .filter(|_| m.clock_members >= 2)
810 .map(|rms| (rms, m.clock_members))
811 }))
812 }
813
814 pub fn clock_agreement_max_s(&self) -> Option<f64> {
816 self.agreement
817 .iter()
818 .filter_map(|m| m.clock_max_s)
819 .fold(None, |acc, v| Some(fold_max(acc, v)))
820 }
821
822 pub fn per_epoch_agreement(&self) -> Vec<EpochAgreement> {
827 let mut out: Vec<EpochAgreement> = Vec::new();
828 let mut current_key: Option<i64> = None;
829 for m in &self.agreement {
830 let key = instant_to_j2000_seconds(&m.epoch).map(|s| s.floor() as i64);
831 if current_key != key || out.is_empty() {
832 out.push(EpochAgreement {
833 epoch: m.epoch,
834 satellites: 0,
835 position_rms_m: 0.0,
836 position_max_m: 0.0,
837 clock_rms_s: None,
838 clock_max_s: None,
839 });
840 current_key = key;
841 }
842 let agg = out.last_mut().expect("just pushed");
843 agg.position_max_m = agg.position_max_m.max(m.position_max_m);
844 if m.position_members >= 2 {
845 agg.satellites += 1;
846 }
847 if let Some(max) = m.clock_max_s.filter(|_| m.clock_members >= 2) {
851 agg.clock_max_s = Some(fold_max(agg.clock_max_s, max));
852 }
853 }
854
855 for agg in &mut out {
858 let key = instant_to_j2000_seconds(&agg.epoch).map(|s| s.floor() as i64);
859 agg.position_rms_m = pooled_rms(
860 self.agreement
861 .iter()
862 .filter(|m| {
863 m.position_members >= 2
864 && instant_to_j2000_seconds(&m.epoch).map(|s| s.floor() as i64) == key
865 })
866 .map(|m| (m.position_rms_m, m.position_members)),
867 )
868 .unwrap_or(0.0);
869 agg.clock_rms_s = pooled_rms(
870 self.agreement
871 .iter()
872 .filter(|m| instant_to_j2000_seconds(&m.epoch).map(|s| s.floor() as i64) == key)
873 .filter_map(|m| {
874 m.clock_rms_s
875 .filter(|_| m.clock_members >= 2)
876 .map(|rms| (rms, m.clock_members))
877 }),
878 );
879 }
880
881 out
882 }
883}
884
885fn pooled_rms(cells: impl Iterator<Item = (f64, usize)>) -> Option<f64> {
888 let mut sumsq = 0.0_f64;
889 let mut total = 0_usize;
890 for (rms, n) in cells {
891 sumsq += rms * rms * n as f64;
892 total += n;
893 }
894 (total > 0).then(|| (sumsq / total as f64).sqrt())
895}
896
897fn fold_max(acc: Option<f64>, value: f64) -> f64 {
899 match acc {
900 Some(current) if current >= value => current,
901 _ => value,
902 }
903}
904
905pub fn merge(sources: &[Sp3], opts: &MergeOptions) -> Result<(Sp3, MergeReport)> {
958 if sources.is_empty() {
959 return Err(Error::InvalidInput(
960 "merge requires at least one SP3 product".into(),
961 ));
962 }
963
964 validate_merge_options(opts)?;
965
966 let base = &sources[0].header;
971 for s in &sources[1..] {
972 if s.header.time_system != base.time_system {
973 return Err(Error::InvalidInput(format!(
974 "merge inputs have mismatched SP3 time systems ({:?} vs {:?})",
975 base.time_system, s.header.time_system
976 )));
977 }
978 }
979
980 let (prepared_sources, frame_reconciliations) = reconcile_sp3_coordinate_labels(sources, opts)?;
981 let sources = prepared_sources.as_slice();
982
983 let epoch_index: Vec<BTreeMap<i64, usize>> = sources
985 .iter()
986 .map(|s| {
987 s.epochs
988 .iter()
989 .enumerate()
990 .filter_map(|(i, ep)| {
991 sp3_epoch_j2000_seconds(s, i, ep).map(|sec| (sec.floor() as i64, i))
992 })
993 .collect()
994 })
995 .collect();
996
997 let epoch_interval_s = resolve_common_epoch_interval(sources, opts.target_epoch_interval_s)?;
998
999 let clock_offset: Vec<BTreeMap<i64, f64>> = sources
1002 .iter()
1003 .enumerate()
1004 .map(|(idx, s)| {
1005 if idx == 0 {
1006 BTreeMap::new()
1007 } else {
1008 clock_reference_offset(&sources[0], s, opts.clock_min_common)
1009 .into_iter()
1010 .filter_map(|o| {
1011 instant_to_j2000_seconds(&o.epoch)
1012 .map(|sec| (sec.floor() as i64, o.offset_s))
1013 })
1014 .collect()
1015 }
1016 })
1017 .collect();
1018
1019 let mut epoch_keys: BTreeMap<i64, Instant> = BTreeMap::new();
1023 for source in sources {
1024 for (idx, ep) in source.epochs.iter().enumerate() {
1025 if let Some(sec) = sp3_epoch_j2000_seconds(source, idx, ep) {
1026 epoch_keys.entry(sec.floor() as i64).or_insert(*ep);
1027 }
1028 }
1029 }
1030
1031 if let Some((&anchor, _)) = epoch_keys.iter().next() {
1036 let step = epoch_interval_s.round() as i64;
1037 if step > 0 {
1038 epoch_keys.retain(|&key, _| (key - anchor).rem_euclid(step) == 0);
1039 }
1040 }
1041
1042 if epoch_keys.is_empty() {
1043 return Err(Error::InvalidInput(
1044 "merge inputs have no epochs on the requested time grid".into(),
1045 ));
1046 }
1047
1048 let precedence_source_for_sat = if opts.combine == MergeCombine::Precedence
1049 && opts.precedence_scope == MergePrecedenceScope::SatelliteArc
1050 {
1051 Some(precedence_sources_for_satellites(
1052 sources,
1053 &epoch_index,
1054 &epoch_keys,
1055 opts.systems.as_ref(),
1056 ))
1057 } else {
1058 None
1059 };
1060
1061 let allowed_system = |sat: &GnssSatelliteId| {
1062 opts.systems
1063 .as_ref()
1064 .is_none_or(|systems| systems.contains(&sat.system))
1065 };
1066
1067 let mut out_epochs: Vec<Instant> = Vec::with_capacity(epoch_keys.len());
1068 let mut prov_cells: Vec<CellProvenance> = Vec::new();
1071 let mut prov_transitions: Vec<PrecedenceTransition> = Vec::new();
1072 let mut prov_contributed: Vec<usize> = vec![0; sources.len()];
1073 let mut prov_selected: Vec<usize> = vec![0; sources.len()];
1074 let mut prov_first: Vec<Option<Instant>> = vec![None; sources.len()];
1075 let mut prov_last: Vec<Option<Instant>> = vec![None; sources.len()];
1076 let mut prov_accepted_cells: usize = 0;
1077 let mut prov_previous: BTreeMap<GnssSatelliteId, CellSelection> = BTreeMap::new();
1078 let mut continuity_selection: BTreeMap<(GnssSatelliteId, i64), CellSelection> = BTreeMap::new();
1079
1080 let mut out_epoch_j2000_s: Vec<f64> = Vec::with_capacity(epoch_keys.len());
1081 let mut out_states: Vec<BTreeMap<GnssSatelliteId, Sp3State>> =
1082 Vec::with_capacity(epoch_keys.len());
1083 let mut out_raw: Vec<BTreeMap<GnssSatelliteId, RawNode>> = Vec::with_capacity(epoch_keys.len());
1084 let mut report = MergeReport {
1085 frame_reconciliations,
1086 ..MergeReport::default()
1087 };
1088 let mut all_sats: BTreeSet<GnssSatelliteId> = BTreeSet::new();
1089
1090 for (&key, &epoch) in &epoch_keys {
1091 out_epochs.push(epoch);
1092 out_epoch_j2000_s.push(key as f64);
1093 let mut states: BTreeMap<GnssSatelliteId, Sp3State> = BTreeMap::new();
1094 let mut raws: BTreeMap<GnssSatelliteId, RawNode> = BTreeMap::new();
1095
1096 let mut sats: BTreeSet<GnssSatelliteId> = BTreeSet::new();
1099 for (idx, s) in sources.iter().enumerate() {
1100 if let Some(&ei) = epoch_index[idx].get(&key) {
1101 if let Ok(map) = s.states_at(ei) {
1102 sats.extend(map.keys().copied().filter(|sat| allowed_system(sat)));
1103 }
1104 }
1105 }
1106
1107 for sat in sats {
1108 let arc_preferred_source = precedence_source_for_sat
1114 .as_ref()
1115 .and_then(|by_sat| by_sat.get(&sat).copied());
1116
1117 let mut pos: Vec<(usize, [f64; 3], Sp3Flags)> = Vec::new();
1118 let mut clk: Vec<(usize, f64, Sp3Flags)> = Vec::new();
1119 for (idx, s) in sources.iter().enumerate() {
1120 let Some(&ei) = epoch_index[idx].get(&key) else {
1121 continue;
1122 };
1123 let Ok(map) = s.states_at(ei) else { continue };
1124 let Some(state) = map.get(&sat) else { continue };
1125 pos.push((idx, state.position.as_array(), state.flags));
1126 if let Some(c) = state.clock_s {
1127 let offset = if idx == 0 {
1128 Some(0.0)
1129 } else {
1130 clock_offset_at(&clock_offset[idx], key)
1131 };
1132 if let Some(off) = offset {
1133 let aligned = c - off;
1134 if aligned.is_finite() {
1135 clk.push((idx, aligned, state.flags));
1136 }
1137 }
1138 }
1139 }
1140
1141 let position_preferred_source = match opts.precedence_scope {
1142 MergePrecedenceScope::Cell => pos.first().map(|(source, _, _)| *source),
1143 MergePrecedenceScope::SatelliteArc => arc_preferred_source,
1144 };
1145 let clock_preferred_source = match opts.precedence_scope {
1146 MergePrecedenceScope::Cell => clk.first().map(|(source, _, _)| *source),
1147 MergePrecedenceScope::SatelliteArc => arc_preferred_source,
1148 };
1149
1150 let flag = |srcs: Vec<usize>| MergeFlag {
1151 epoch,
1152 satellite: sat,
1153 sources: srcs,
1154 };
1155
1156 let (position_m, pos_members, pos_selection) = if opts.combine
1161 == MergeCombine::Precedence
1162 {
1163 let Some(preferred_source) = position_preferred_source else {
1164 continue;
1165 };
1166 let Some(preferred_idx) =
1167 pos.iter().position(|(src, _, _)| *src == preferred_source)
1168 else {
1169 continue;
1170 };
1171
1172 if pos.len() == 1 {
1173 report.single_source.push(flag(vec![pos[preferred_idx].0]));
1174 (
1175 pos[preferred_idx].1,
1176 vec![preferred_idx],
1177 CellSelection::SingleSource {
1178 source: pos[preferred_idx].0,
1179 },
1180 )
1181 } else if let Some(reject) = opts.outlier_reject {
1182 let pts: Vec<[f64; 3]> = pos.iter().map(|(_, p, _)| *p).collect();
1183 let cluster =
1184 largest_within(&pts, |a, b| dist3(a, b) <= reject.position_tolerance_m);
1185 if cluster.len() >= opts.min_agree.max(2) {
1186 let selected_idx = if cluster.contains(&preferred_idx) {
1187 preferred_idx
1188 } else {
1189 cluster[0]
1190 };
1191 let rejected: Vec<usize> = (0..pos.len())
1192 .filter(|i| !cluster.contains(i))
1193 .map(|i| pos[i].0)
1194 .collect();
1195 let rejected_selection = !rejected.is_empty();
1196 if rejected_selection {
1197 report.position_outliers.push(flag(rejected));
1198 }
1199 let selection = CellSelection::Precedence {
1200 source: pos[selected_idx].0,
1201 members: cluster.iter().map(|&i| pos[i].0).collect(),
1202 };
1203 (pos[selected_idx].1, cluster, selection)
1204 } else {
1205 report
1206 .quarantined
1207 .push(flag(pos.iter().map(|(i, _, _)| *i).collect()));
1208 continue;
1209 }
1210 } else {
1211 let pts: Vec<[f64; 3]> = pos.iter().map(|(_, p, _)| *p).collect();
1212 let cluster = largest_within_containing(&pts, preferred_idx, |a, b| {
1213 dist3(a, b) <= opts.position_tolerance_m
1214 });
1215 if cluster.len() >= opts.min_agree {
1216 let rejected: Vec<usize> = (0..pos.len())
1217 .filter(|i| !cluster.contains(i))
1218 .map(|i| pos[i].0)
1219 .collect();
1220 if !rejected.is_empty() {
1221 report.position_outliers.push(flag(rejected));
1222 }
1223 let selection = CellSelection::Precedence {
1224 source: pos[preferred_idx].0,
1225 members: cluster.iter().map(|&i| pos[i].0).collect(),
1226 };
1227 (pos[preferred_idx].1, cluster, selection)
1228 } else {
1229 report
1230 .quarantined
1231 .push(flag(pos.iter().map(|(i, _, _)| *i).collect()));
1232 continue;
1233 }
1234 }
1235 } else if pos.len() == 1 {
1236 report.single_source.push(flag(vec![pos[0].0]));
1237 (
1238 pos[0].1,
1239 vec![0usize],
1240 CellSelection::SingleSource { source: pos[0].0 },
1241 )
1242 } else {
1243 let pts: Vec<[f64; 3]> = pos.iter().map(|(_, p, _)| *p).collect();
1244 let cluster = largest_within(&pts, |a, b| dist3(a, b) <= opts.position_tolerance_m);
1245 if cluster.len() >= opts.min_agree {
1246 let rejected: Vec<usize> = (0..pos.len())
1247 .filter(|i| !cluster.contains(i))
1248 .map(|i| pos[i].0)
1249 .collect();
1250 if !rejected.is_empty() {
1251 report.position_outliers.push(flag(rejected));
1252 }
1253 let members: Vec<(usize, [f64; 3])> =
1254 cluster.iter().map(|&i| (pos[i].0, pos[i].1)).collect();
1255 let selection = CellSelection::Combined {
1256 rule: opts.combine,
1257 members: members.iter().map(|(source, _)| *source).collect(),
1258 };
1259 (combine3(&members, opts.combine), cluster, selection)
1260 } else {
1261 report
1262 .quarantined
1263 .push(flag(pos.iter().map(|(i, _, _)| *i).collect()));
1264 continue;
1265 }
1266 };
1267
1268 let mut clk_selection: Option<CellSelection> = None;
1271 let (clock_s, clk_members): (Option<f64>, Vec<usize>) = if clk.is_empty() {
1272 (None, Vec::new())
1273 } else if opts.combine == MergeCombine::Precedence {
1274 match clock_preferred_source
1275 .and_then(|src| clk.iter().position(|(clock_src, _, _)| *clock_src == src))
1276 {
1277 None => (None, Vec::new()),
1278 Some(preferred_idx) if clk.len() == 1 => {
1279 clk_selection = Some(CellSelection::SingleSource {
1280 source: clk[preferred_idx].0,
1281 });
1282 (Some(clk[preferred_idx].1), vec![preferred_idx])
1283 }
1284 Some(preferred_idx) if opts.outlier_reject.is_some() => {
1285 let reject = opts.outlier_reject.expect("checked above");
1286 let vals: Vec<f64> = clk.iter().map(|(_, c, _)| *c).collect();
1287 let cluster =
1288 largest_within(&vals, |a, b| (a - b).abs() <= reject.clock_tolerance_s);
1289 if cluster.len() >= opts.min_agree.max(2) {
1290 let selected_idx = if cluster.contains(&preferred_idx) {
1291 preferred_idx
1292 } else {
1293 cluster[0]
1294 };
1295 let rejected: Vec<usize> = (0..clk.len())
1296 .filter(|i| !cluster.contains(i))
1297 .map(|i| clk[i].0)
1298 .collect();
1299 if !rejected.is_empty() {
1300 report.clock_outliers.push(flag(rejected));
1301 }
1302 clk_selection = Some(CellSelection::Precedence {
1303 source: clk[selected_idx].0,
1304 members: cluster.iter().map(|&i| clk[i].0).collect(),
1305 });
1306 (Some(clk[selected_idx].1), cluster)
1307 } else {
1308 report
1309 .clock_outliers
1310 .push(flag(clk.iter().map(|(source, _, _)| *source).collect()));
1311 (None, Vec::new())
1312 }
1313 }
1314 Some(preferred_idx) => {
1315 let vals: Vec<f64> = clk.iter().map(|(_, c, _)| *c).collect();
1316 let cluster = largest_within_containing(&vals, preferred_idx, |a, b| {
1317 (a - b).abs() <= opts.clock_tolerance_s
1318 });
1319 if cluster.len() >= opts.min_agree {
1320 let rejected: Vec<usize> = (0..clk.len())
1321 .filter(|i| !cluster.contains(i))
1322 .map(|i| clk[i].0)
1323 .collect();
1324 if !rejected.is_empty() {
1325 report.clock_outliers.push(flag(rejected));
1326 }
1327 clk_selection = Some(CellSelection::Precedence {
1328 source: clk[preferred_idx].0,
1329 members: cluster.iter().map(|&i| clk[i].0).collect(),
1330 });
1331 (Some(clk[preferred_idx].1), cluster)
1332 } else {
1333 (None, Vec::new())
1334 }
1335 }
1336 }
1337 } else if clk.len() == 1 {
1338 clk_selection = Some(CellSelection::SingleSource { source: clk[0].0 });
1339 (Some(clk[0].1), vec![0usize])
1340 } else {
1341 let vals: Vec<f64> = clk.iter().map(|(_, c, _)| *c).collect();
1342 let cluster = largest_within(&vals, |a, b| (a - b).abs() <= opts.clock_tolerance_s);
1343 if cluster.len() >= opts.min_agree {
1344 let rejected: Vec<usize> = (0..clk.len())
1345 .filter(|i| !cluster.contains(i))
1346 .map(|i| clk[i].0)
1347 .collect();
1348 if !rejected.is_empty() {
1349 report.clock_outliers.push(flag(rejected));
1350 }
1351 let members: Vec<(usize, f64)> =
1352 cluster.iter().map(|&i| (clk[i].0, clk[i].1)).collect();
1353 clk_selection = Some(CellSelection::Combined {
1354 rule: opts.combine,
1355 members: members.iter().map(|(source, _)| *source).collect(),
1356 });
1357 (Some(combine_axis(&members, opts.combine)), cluster)
1358 } else {
1359 (None, Vec::new())
1360 }
1361 };
1362
1363 let mut flags = Sp3Flags::default();
1368 for &i in &pos_members {
1369 flags.maneuver |= pos[i].2.maneuver;
1370 flags.orbit_predicted |= pos[i].2.orbit_predicted;
1371 }
1372 for &i in &clk_members {
1373 flags.clock_event |= clk[i].2.clock_event;
1374 flags.clock_predicted |= clk[i].2.clock_predicted;
1375 }
1376
1377 let (position_rms_m, position_max_m) =
1380 position_dispersion(&pos, &pos_members, &position_m);
1381 let (clock_members_n, clock_rms_s, clock_max_s) = match clock_s {
1382 Some(c) => {
1383 let (rms, max) = clock_dispersion(&clk, &clk_members, c);
1384 (clk_members.len(), Some(rms), Some(max))
1385 }
1386 None => (0, None, None),
1387 };
1388 report.agreement.push(AgreementMetric {
1389 epoch,
1390 satellite: sat,
1391 position_members: pos_members.len(),
1392 position_rms_m,
1393 position_max_m,
1394 clock_members: clock_members_n,
1395 clock_rms_s,
1396 clock_max_s,
1397 });
1398
1399 if opts.verify_continuity.is_some() {
1400 continuity_selection.insert((sat, key), pos_selection.clone());
1401 }
1402
1403 if let Some(provenance_mode) = opts.provenance {
1404 record_cell_provenance(
1405 RecordCellProvenance {
1406 epoch,
1407 sat,
1408 position: &pos_selection,
1409 clock: clk_selection.as_ref(),
1410 candidates: &pos.iter().map(|(src, _, _)| *src).collect::<Vec<_>>(),
1411 mode: provenance_mode,
1412 },
1413 &mut ProvenanceAccumulator {
1414 cells: &mut prov_cells,
1415 transitions: &mut prov_transitions,
1416 contributed: &mut prov_contributed,
1417 selected: &mut prov_selected,
1418 first: &mut prov_first,
1419 last: &mut prov_last,
1420 accepted_cells: &mut prov_accepted_cells,
1421 previous: &mut prov_previous,
1422 },
1423 );
1424 }
1425
1426 all_sats.insert(sat);
1427 states.insert(
1428 sat,
1429 Sp3State {
1430 position: ItrfPositionM::new(position_m[0], position_m[1], position_m[2])
1431 .expect("valid ITRF position"),
1432 clock_s,
1433 velocity: None,
1434 clock_rate_s_s: None,
1435 flags,
1436 },
1437 );
1438 raws.insert(
1439 sat,
1440 RawNode {
1441 km: [
1442 position_m[0] / KM_TO_M,
1443 position_m[1] / KM_TO_M,
1444 position_m[2] / KM_TO_M,
1445 ],
1446 clock_us: clock_s.map(|c| c * 1.0e6),
1447 clock_event: flags.clock_event,
1448 },
1449 );
1450 }
1451
1452 out_states.push(states);
1453 out_raw.push(raws);
1454 }
1455
1456 let first_key = Some(out_epoch_j2000_s[0].floor() as i64);
1461 let base_idx = sources
1462 .iter()
1463 .position(|s| {
1464 s.epochs
1465 .first()
1466 .and_then(|ep| sp3_epoch_j2000_seconds(s, 0, ep))
1467 .map(|sec| sec.floor() as i64)
1468 == first_key
1469 })
1470 .or_else(|| {
1471 sources
1472 .iter()
1473 .enumerate()
1474 .filter_map(|(i, s)| {
1475 s.epochs
1476 .first()
1477 .and_then(|ep| sp3_epoch_j2000_seconds(s, 0, ep))
1478 .map(|sec| (sec, i))
1479 })
1480 .min_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)))
1481 .map(|(_, i)| i)
1482 })
1483 .unwrap_or(0);
1484 let first_epoch_header = first_epoch_header_fields(&out_epochs[0]).ok_or_else(|| {
1485 Error::InvalidInput("merged SP3 first epoch cannot be represented in header fields".into())
1486 })?;
1487
1488 let satellites: Vec<_> = all_sats.into_iter().collect();
1489 let satellite_accuracy_codes = satellites
1490 .iter()
1491 .map(|sat| {
1492 sources[base_idx]
1493 .header
1494 .satellites
1495 .iter()
1496 .position(|base_sat| base_sat == sat)
1497 .and_then(|idx| {
1498 sources[base_idx]
1499 .header
1500 .satellite_accuracy_codes
1501 .get(idx)
1502 .copied()
1503 })
1504 .unwrap_or(0)
1505 })
1506 .collect();
1507
1508 let header = Sp3Header {
1509 num_epochs: out_epochs.len() as u64,
1510 satellites,
1511 satellite_accuracy_codes,
1512 data_type: Sp3DataType::Position,
1513 gnss_week: first_epoch_header.gnss_week,
1514 seconds_of_week: first_epoch_header.seconds_of_week,
1515 epoch_interval_s,
1516 mjd: first_epoch_header.mjd,
1517 mjd_fraction: first_epoch_header.mjd_fraction,
1518 ..sources[base_idx].header.clone()
1519 };
1520
1521 let mandatory_header_lines = 5.max(header.satellites.len().div_ceil(17));
1522 let declared_satellite_tokens = header
1523 .satellites
1524 .iter()
1525 .map(ToString::to_string)
1526 .collect::<Vec<_>>();
1527 let epoch_position_tokens = vec![declared_satellite_tokens.clone(); out_epochs.len()];
1528 let epoch_state_record_sequence = epoch_position_tokens
1529 .iter()
1530 .map(|tokens| {
1531 tokens
1532 .iter()
1533 .cloned()
1534 .map(|token| ('P', token))
1535 .collect::<Vec<_>>()
1536 })
1537 .collect::<Vec<_>>();
1538 report.provenance = opts.provenance.map(|mode| MergeProvenance {
1539 mode,
1540 cells: prov_cells,
1541 transitions: prov_transitions,
1542 coverage: (0..sources.len())
1543 .map(|source| ContributorCoverage {
1544 source,
1545 cells_contributed: prov_contributed[source],
1546 cells_selected: prov_selected[source],
1547 first_epoch: prov_first[source],
1548 last_epoch: prov_last[source],
1549 cells_absent: prov_accepted_cells - prov_contributed[source],
1550 })
1551 .collect(),
1552 });
1553
1554 let merged = Sp3 {
1555 header,
1556 epochs: out_epochs,
1557 declared_num_epochs: out_epoch_j2000_s.len() as u64,
1558 declared_start_j2000_s: out_epoch_j2000_s.first().copied(),
1559 terminal_record: TerminalRecordState::valid(),
1560 satellite_header_lines: mandatory_header_lines,
1561 accuracy_header_lines: mandatory_header_lines,
1562 time_system_header_lines: 2,
1563 float_header_lines: 2,
1564 integer_header_lines: 2,
1565 header_comment_lines: 4,
1566 declared_satellite_count: Some(declared_satellite_tokens.len()),
1567 declared_satellite_tokens,
1568 epoch_velocity_tokens: vec![Vec::new(); epoch_position_tokens.len()],
1569 epoch_position_tokens,
1570 epoch_state_record_sequence,
1571 epoch_j2000_s: out_epoch_j2000_s,
1572 states: out_states,
1573 interp_raw: out_raw,
1574 comments: vec![format!("MERGED from {} SP3 products", sources.len())],
1575 skipped_records: sources.iter().map(|s| s.skipped_records).sum(),
1576 };
1577
1578 if let Some(continuity_options) = opts.verify_continuity.as_ref() {
1579 report.continuity = Some(verify_merged_continuity(
1580 &merged,
1581 continuity_options,
1582 &continuity_selection,
1583 ));
1584 }
1585
1586 Ok((merged, report))
1587}
1588
1589fn verify_merged_continuity(
1597 merged: &Sp3,
1598 options: &ContinuityOptions,
1599 selection: &BTreeMap<(GnssSatelliteId, i64), CellSelection>,
1600) -> MergeContinuityReport {
1601 let report = check_continuity(&merged.precise_ephemeris_samples(), options);
1602
1603 let violations = report
1604 .defects
1605 .iter()
1606 .map(|defect| {
1607 let sat = defect.satellite();
1608 let (from_epoch, to_epoch) = defect_epoch_pair(defect);
1609 let from_sources = from_epoch
1610 .and_then(|epoch| selection.get(&(sat, epoch)))
1611 .map(CellSelection::members)
1612 .unwrap_or_default();
1613 let to_sources = to_epoch
1614 .and_then(|epoch| selection.get(&(sat, epoch)))
1615 .map(CellSelection::members)
1616 .unwrap_or_default();
1617 let crosses_contributors =
1618 !from_sources.is_empty() && !to_sources.is_empty() && from_sources != to_sources;
1619 MergeContinuityViolation {
1620 defect: defect.clone(),
1621 from_sources,
1622 to_sources,
1623 crosses_contributors,
1624 }
1625 })
1626 .collect();
1627
1628 MergeContinuityReport { report, violations }
1629}
1630
1631fn defect_epoch_pair(defect: &ContinuityDefect) -> (Option<i64>, Option<i64>) {
1633 match defect {
1634 ContinuityDefect::SpeedBound {
1635 from_j2000_s,
1636 to_j2000_s,
1637 ..
1638 } => (Some(*from_j2000_s as i64), Some(*to_j2000_s as i64)),
1639 ContinuityDefect::HoldOutResidual {
1640 preceding_j2000_s,
1641 epoch_j2000_s,
1642 ..
1643 } => (Some(*preceding_j2000_s as i64), Some(*epoch_j2000_s as i64)),
1644 ContinuityDefect::DuplicateEpoch { epoch_j2000_s, .. } => {
1645 (Some(*epoch_j2000_s as i64), Some(*epoch_j2000_s as i64))
1646 }
1647 ContinuityDefect::SingleSampleSeries { .. } => (None, None),
1648 }
1649}
1650
1651fn reconcile_sp3_coordinate_labels(
1652 sources: &[Sp3],
1653 opts: &MergeOptions,
1654) -> Result<(Vec<Sp3>, Vec<Sp3FrameReconciliation>)> {
1655 let target_label = normalized_sp3_frame_label(&sources[0].header.coordinate_system);
1656 let mut prepared = sources.to_vec();
1657 let mut report = Vec::new();
1658
1659 for idx in 1..sources.len() {
1660 let source_label = normalized_sp3_frame_label(&sources[idx].header.coordinate_system);
1661 if source_label == target_label {
1662 continue;
1663 }
1664
1665 if let Some(asserted) = asserted_frame_label_set(
1666 &source_label,
1667 &target_label,
1668 &opts.frame_reconciliation.asserted_equivalent_label_sets,
1669 ) {
1670 prepared[idx].header.coordinate_system = target_label.clone();
1671 report.push(Sp3FrameReconciliation {
1672 source_index: idx,
1673 source_label,
1674 target_label: target_label.clone(),
1675 method: Sp3FrameReconciliationMethod::AssertedEquivalence,
1676 asserted_label_set: Some(asserted),
1677 source_frame: None,
1678 target_frame: None,
1679 catalog_source_frame: None,
1680 catalog_target_frame: None,
1681 catalog_inverse: false,
1682 reference_epoch_year: None,
1683 parameters: None,
1684 rates: None,
1685 provenance: None,
1686 epoch_year_span: None,
1687 records_affected: count_position_records(&sources[idx]),
1688 identity: true,
1689 });
1690 continue;
1691 }
1692
1693 if opts.frame_reconciliation.helmert {
1694 let from = sp3_coordinate_label_frame(&source_label).ok_or_else(|| {
1695 Error::InvalidInput(format!(
1696 "merge inputs have mismatched coordinate systems ({:?} vs {:?}); source label {:?} is not a known ITRF/IGS realization",
1697 sources[0].header.coordinate_system,
1698 sources[idx].header.coordinate_system,
1699 sources[idx].header.coordinate_system
1700 ))
1701 })?;
1702 let to = sp3_coordinate_label_frame(&target_label).ok_or_else(|| {
1703 Error::InvalidInput(format!(
1704 "merge inputs have mismatched coordinate systems ({:?} vs {:?}); target label {:?} is not a known ITRF/IGS realization",
1705 sources[0].header.coordinate_system,
1706 sources[idx].header.coordinate_system,
1707 sources[0].header.coordinate_system
1708 ))
1709 })?;
1710
1711 let transform_report = reconcile_source_by_helmert(
1712 &mut prepared[idx],
1713 idx,
1714 source_label,
1715 target_label.clone(),
1716 from,
1717 to,
1718 )?;
1719 report.push(transform_report);
1720 continue;
1721 }
1722
1723 return Err(Error::InvalidInput(format!(
1724 "merge inputs have mismatched coordinate systems ({:?} vs {:?})",
1725 sources[0].header.coordinate_system, sources[idx].header.coordinate_system
1726 )));
1727 }
1728
1729 Ok((prepared, report))
1730}
1731
1732fn asserted_frame_label_set(
1733 source_label: &str,
1734 target_label: &str,
1735 label_sets: &[Sp3FrameLabelSet],
1736) -> Option<Vec<String>> {
1737 label_sets.iter().find_map(|set| {
1738 if set.labels.contains(source_label) && set.labels.contains(target_label) {
1739 Some(set.labels.iter().cloned().collect())
1740 } else {
1741 None
1742 }
1743 })
1744}
1745
1746fn reconcile_source_by_helmert(
1747 source: &mut Sp3,
1748 source_index: usize,
1749 source_label: String,
1750 target_label: String,
1751 from: TerrestrialFrame,
1752 to: TerrestrialFrame,
1753) -> Result<Sp3FrameReconciliation> {
1754 let records_affected = count_position_records(source);
1755 let epoch_year_span = epoch_year_span(source);
1756 let identity = from == to;
1757
1758 if !identity {
1759 transform_sp3_positions(source, from, to)?;
1760 }
1761 source.header.coordinate_system = target_label.clone();
1762
1763 let published = published_transform_for_report(from, to);
1764 Ok(Sp3FrameReconciliation {
1765 source_index,
1766 source_label,
1767 target_label,
1768 method: Sp3FrameReconciliationMethod::Helmert,
1769 asserted_label_set: None,
1770 source_frame: Some(from),
1771 target_frame: Some(to),
1772 catalog_source_frame: published.map(|published| published.entry.from),
1773 catalog_target_frame: published.map(|published| published.entry.to),
1774 catalog_inverse: published.is_some_and(|published| published.inverse),
1775 reference_epoch_year: published.map(|published| published.entry.reference_epoch_year),
1776 parameters: published.map(|published| published.entry.parameters),
1777 rates: published.map(|published| published.entry.rates),
1778 provenance: published.map(|published| published.entry.provenance.to_string()),
1779 epoch_year_span,
1780 records_affected,
1781 identity,
1782 })
1783}
1784
1785fn transform_sp3_positions(
1786 source: &mut Sp3,
1787 from: TerrestrialFrame,
1788 to: TerrestrialFrame,
1789) -> Result<()> {
1790 let seconds_per_julian_year = DAYS_PER_JULIAN_YEAR * SECONDS_PER_DAY;
1791 for epoch_idx in 0..source.epochs.len() {
1792 let epoch_year = decimal_year(source.epochs[epoch_idx]);
1793 let states = &mut source.states[epoch_idx];
1794 let raw_nodes = &mut source.interp_raw[epoch_idx];
1795 for (sat, state) in states.iter_mut() {
1796 let position = TerrestrialPositionM::from_itrf(state.position);
1797 let velocity = state
1798 .velocity
1799 .map(|velocity| {
1800 let [vx, vy, vz] = velocity.as_array();
1801 TerrestrialVelocityMPerYear::new(
1802 vx * seconds_per_julian_year,
1803 vy * seconds_per_julian_year,
1804 vz * seconds_per_julian_year,
1805 )
1806 })
1807 .transpose()
1808 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1809 let transformed = frame_catalog::transform(position, velocity, from, to, epoch_year)
1810 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1811 let [x, y, z] = transformed.position.as_array();
1812 state.position = ItrfPositionM::new(x, y, z)
1813 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1814 state.velocity = transformed
1815 .velocity
1816 .map(|velocity| {
1817 let [vx, vy, vz] = velocity.as_array();
1818 ItrfVelocityMS::new(
1819 vx / seconds_per_julian_year,
1820 vy / seconds_per_julian_year,
1821 vz / seconds_per_julian_year,
1822 )
1823 })
1824 .transpose()
1825 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1826 if let Some(raw) = raw_nodes.get_mut(sat) {
1827 raw.km = [x / KM_TO_M, y / KM_TO_M, z / KM_TO_M];
1828 }
1829 }
1830 }
1831 Ok(())
1832}
1833
1834fn count_position_records(source: &Sp3) -> usize {
1835 source.states.iter().map(BTreeMap::len).sum()
1836}
1837
1838fn epoch_year_span(source: &Sp3) -> Option<[f64; 2]> {
1839 let first = source.epochs.first().copied().map(decimal_year)?;
1840 let last = source.epochs.last().copied().map(decimal_year)?;
1841 Some([first, last])
1842}
1843
1844fn decimal_year(epoch: Instant) -> f64 {
1845 let jd_midnight = julian_date_from_instant(epoch) + 0.5;
1846 let (year, _, _) = civil_from_julian_day_number(jd_midnight.floor() as i64);
1847 let days = if is_leap_year(year) { 366.0 } else { 365.0 };
1848 year as f64 + (fractional_day_of_year_from_instant(epoch) - 1.0) / days
1849}
1850
1851fn normalized_sp3_frame_label(label: &str) -> String {
1852 label.trim().to_string()
1853}
1854
1855fn sp3_coordinate_label_frame(label: &str) -> Option<TerrestrialFrame> {
1856 match label.trim() {
1857 "ITRF2020" | "ITRF20" | "IGS20" | "IGc20" => Some(TerrestrialFrame::Itrf2020),
1858 "ITRF2014" | "ITRF14" | "IGS14" | "IGb14" => Some(TerrestrialFrame::Itrf2014),
1859 "ITRF2008" | "ITRF08" | "IGS08" | "IGb08" => Some(TerrestrialFrame::Itrf2008),
1860 _ => None,
1861 }
1862}
1863
1864fn published_transform_for_report(
1865 from: TerrestrialFrame,
1866 to: TerrestrialFrame,
1867) -> Option<PublishedTransformForReport> {
1868 frame_catalog::catalog_entry(from, to)
1869 .map(|entry| PublishedTransformForReport {
1870 entry,
1871 inverse: false,
1872 })
1873 .or_else(|| {
1874 frame_catalog::catalog_entry(to, from).map(|entry| PublishedTransformForReport {
1875 entry,
1876 inverse: true,
1877 })
1878 })
1879}
1880
1881#[derive(Debug, Clone, Copy)]
1882struct PublishedTransformForReport {
1883 entry: &'static frame_catalog::HelmertTransform,
1884 inverse: bool,
1885}
1886
1887#[derive(Debug, Clone, Copy)]
1888struct FirstEpochHeaderFields {
1889 gnss_week: u32,
1890 seconds_of_week: f64,
1891 mjd: u32,
1892 mjd_fraction: f64,
1893}
1894
1895fn first_epoch_header_fields(epoch: &Instant) -> Option<FirstEpochHeaderFields> {
1896 let split = epoch.julian_date()?;
1897
1898 let mjd_day = mjd_from_jd(split.jd_whole);
1899 let mut mjd = mjd_day.floor();
1900 let mut mjd_fraction = split.fraction + (mjd_day - mjd);
1901 let fraction_days = mjd_fraction.floor();
1902 if fraction_days != 0.0 {
1903 mjd += fraction_days;
1904 mjd_fraction -= fraction_days;
1905 }
1906 if !(0.0..=u32::MAX as f64).contains(&mjd) {
1907 return None;
1908 }
1909
1910 let gps_seconds = instant_to_j2000_seconds(epoch)? + GPS_EPOCH_TO_J2000_S;
1911 let (gnss_week, seconds_of_week) = gnss::week_and_seconds_of_week(gps_seconds);
1912 if !(0.0..=u32::MAX as f64).contains(&gnss_week) {
1913 return None;
1914 }
1915
1916 Some(FirstEpochHeaderFields {
1917 gnss_week: gnss_week as u32,
1918 seconds_of_week,
1919 mjd: mjd as u32,
1920 mjd_fraction,
1921 })
1922}
1923
1924fn dist3(a: &[f64; 3], b: &[f64; 3]) -> f64 {
1925 vec3::norm3(vec3::sub3(*a, *b))
1926}
1927
1928fn position_dispersion(
1931 pos: &[(usize, [f64; 3], Sp3Flags)],
1932 members: &[usize],
1933 combined: &[f64; 3],
1934) -> (f64, f64) {
1935 let mut sumsq = 0.0;
1936 let mut max = 0.0_f64;
1937 for &i in members {
1938 let d = dist3(&pos[i].1, combined);
1939 sumsq += d * d;
1940 max = max.max(d);
1941 }
1942 ((sumsq / members.len().max(1) as f64).sqrt(), max)
1943}
1944
1945fn clock_dispersion(
1948 clk: &[(usize, f64, Sp3Flags)],
1949 members: &[usize],
1950 combined: f64,
1951) -> (f64, f64) {
1952 let mut sumsq = 0.0;
1953 let mut max = 0.0_f64;
1954 for &i in members {
1955 let d = (clk[i].1 - combined).abs();
1956 sumsq += d * d;
1957 max = max.max(d);
1958 }
1959 ((sumsq / members.len().max(1) as f64).sqrt(), max)
1960}
1961
1962fn clock_offset_at(offsets: &BTreeMap<i64, f64>, key: i64) -> Option<f64> {
1966 if let Some(offset) = offsets.get(&key) {
1967 return Some(*offset);
1968 }
1969 let (&before_key, &before) = offsets.range(..key).next_back()?;
1970 let (&after_key, &after) = offsets.range(key..).next()?;
1971 if after_key <= before_key {
1972 return None;
1973 }
1974 let fraction = (key - before_key) as f64 / (after_key - before_key) as f64;
1975 Some(before + fraction * (after - before))
1976}
1977
1978fn precedence_sources_for_satellites(
1979 sources: &[Sp3],
1980 epoch_index: &[BTreeMap<i64, usize>],
1981 epoch_keys: &BTreeMap<i64, Instant>,
1982 systems: Option<&BTreeSet<GnssSystem>>,
1983) -> BTreeMap<GnssSatelliteId, usize> {
1984 let mut by_sat = BTreeMap::new();
1985
1986 for (idx, source) in sources.iter().enumerate() {
1987 for key in epoch_keys.keys() {
1988 let Some(&epoch_idx) = epoch_index[idx].get(key) else {
1989 continue;
1990 };
1991 let Ok(states) = source.states_at(epoch_idx) else {
1992 continue;
1993 };
1994
1995 for sat in states.keys() {
1996 if systems.is_none_or(|allowed| allowed.contains(&sat.system)) {
1997 by_sat.entry(*sat).or_insert(idx);
1998 }
1999 }
2000 }
2001 }
2002
2003 by_sat
2004}
2005
2006fn validate_merge_options(opts: &MergeOptions) -> Result<()> {
2007 validate::finite_nonneg(opts.position_tolerance_m, "merge position tolerance meters")
2008 .map_err(|error| Error::InvalidInput(error.to_string()))?;
2009 validate::finite_nonneg(opts.clock_tolerance_s, "merge clock tolerance seconds")
2010 .map_err(|error| Error::InvalidInput(error.to_string()))?;
2011 if opts.min_agree == 0 {
2012 return Err(Error::InvalidInput(
2013 "merge minimum agreement must be at least one".into(),
2014 ));
2015 }
2016 if opts.clock_min_common == 0 {
2017 return Err(Error::InvalidInput(
2018 "merge minimum common clock satellites must be at least one".into(),
2019 ));
2020 }
2021 if let Some(reject) = opts.outlier_reject {
2022 validate::finite_nonneg(
2023 reject.position_tolerance_m,
2024 "merge outlier position tolerance meters",
2025 )
2026 .map_err(|error| Error::InvalidInput(error.to_string()))?;
2027 validate::finite_nonneg(
2028 reject.clock_tolerance_s,
2029 "merge outlier clock tolerance seconds",
2030 )
2031 .map_err(|error| Error::InvalidInput(error.to_string()))?;
2032 }
2033 if opts
2034 .systems
2035 .as_ref()
2036 .is_some_and(|systems| systems.is_empty())
2037 {
2038 return Err(Error::InvalidInput(
2039 "merge systems filter must not be empty".into(),
2040 ));
2041 }
2042 for labels in &opts.frame_reconciliation.asserted_equivalent_label_sets {
2043 if labels.labels.len() < 2 || labels.labels.iter().any(|label| label.trim().is_empty()) {
2044 return Err(Error::InvalidInput(
2045 "merge asserted frame label sets require at least two non-empty labels".into(),
2046 ));
2047 }
2048 }
2049 Ok(())
2050}
2051
2052fn resolve_common_epoch_interval(sources: &[Sp3], target: Option<f64>) -> Result<f64> {
2061 let intervals: Vec<f64> = sources
2062 .iter()
2063 .enumerate()
2064 .map(|(idx, source)| {
2065 effective_epoch_interval_s(source)?.ok_or_else(|| {
2066 Error::InvalidInput(format!(
2067 "merge input {idx} has no usable positive epoch interval"
2068 ))
2069 })
2070 })
2071 .collect::<Result<Vec<_>>>()?;
2072
2073 let common = match target {
2074 Some(t) if t.is_finite() && t > 0.0 => t,
2075 Some(t) => {
2076 return Err(Error::InvalidInput(format!(
2077 "merge target epoch interval must be positive and finite, got {t}"
2078 )))
2079 }
2080 None => intervals.iter().copied().fold(f64::INFINITY, f64::min),
2081 };
2082
2083 if (common - common.round()).abs() > WHOLE_SECOND_EPS_S || common.round() < 1.0 {
2088 return Err(Error::InvalidInput(format!(
2089 "merge common epoch interval {common:.6} s must be a positive whole number of seconds"
2090 )));
2091 }
2092
2093 for (idx, interval) in intervals.iter().copied().enumerate() {
2094 if !divides_evenly(interval, common) && !divides_evenly(common, interval) {
2095 return Err(Error::InvalidInput(format!(
2096 "merge inputs have mismatched epoch intervals: output {common:.6} s and input {idx} {interval:.6} s are not integer-commensurate (positional interpolation is not performed)"
2097 )));
2098 }
2099 }
2100
2101 Ok(common)
2102}
2103
2104fn divides_evenly(interval: f64, common: f64) -> bool {
2107 if !(interval.is_finite() && interval > 0.0 && common.is_finite() && common > 0.0) {
2108 return false;
2109 }
2110 let k = (common / interval).round();
2111 k >= 1.0 && same_interval(k * interval, common)
2112}
2113
2114fn effective_epoch_interval_s(source: &Sp3) -> Result<Option<f64>> {
2115 let secs: Vec<f64> = source
2116 .epochs
2117 .iter()
2118 .filter_map(instant_to_j2000_seconds)
2119 .collect();
2120 validate::require_strictly_increasing(secs.iter().copied(), "merge input epochs").map_err(
2121 |error| Error::InvalidInput(format!("{} must be strictly increasing", error.field())),
2122 )?;
2123 let gaps: Vec<f64> = secs.windows(2).map(|w| w[1] - w[0]).collect();
2124
2125 if gaps.is_empty() {
2126 let header = source.header.epoch_interval_s;
2127 return Ok((header.is_finite() && header > 0.0).then_some(header));
2128 }
2129
2130 let interval = gaps[0];
2131 if gaps.iter().all(|g| same_interval(*g, interval)) {
2132 Ok(Some(interval))
2133 } else {
2134 Ok(None)
2135 }
2136}
2137
2138fn same_interval(a: f64, b: f64) -> bool {
2139 (a - b).abs() <= WHOLE_SECOND_EPS_S
2140}
2141
2142fn largest_within<T>(items: &[T], within: impl Fn(&T, &T) -> bool) -> Vec<usize> {
2147 let n = items.len();
2148 if n <= 1 {
2149 return (0..n).collect();
2150 }
2151 let graph = agreement_graph(items, within);
2152 if n > MAX_EXACT_CLIQUE_NODES {
2153 return greedy_largest_clique(&graph);
2154 }
2155 let mut best = vec![0];
2156 let mut current = Vec::new();
2157 max_clique_search(&graph, &mut current, (0..n).collect(), &mut best);
2158 best
2159}
2160
2161fn largest_within_containing<T>(
2162 items: &[T],
2163 required: usize,
2164 within: impl Fn(&T, &T) -> bool,
2165) -> Vec<usize> {
2166 let n = items.len();
2167 if n == 0 || required >= n {
2168 return Vec::new();
2169 }
2170 if n == 1 {
2171 return vec![required];
2172 }
2173
2174 let graph = agreement_graph(items, within);
2175 if n > MAX_EXACT_CLIQUE_NODES {
2176 return greedy_largest_clique_containing(&graph, required);
2177 }
2178 let candidates = (0..n)
2179 .filter(|&idx| idx != required && graph[required][idx])
2180 .collect();
2181 let mut best = vec![required];
2182 let mut current = vec![required];
2183 max_clique_search(&graph, &mut current, candidates, &mut best);
2184 best
2185}
2186
2187fn agreement_graph<T>(items: &[T], within: impl Fn(&T, &T) -> bool) -> Vec<Vec<bool>> {
2188 let n = items.len();
2189 let mut graph = vec![vec![false; n]; n];
2190 for i in 0..n {
2191 graph[i][i] = true;
2192 for j in i + 1..n {
2193 let agrees = within(&items[i], &items[j]);
2194 graph[i][j] = agrees;
2195 graph[j][i] = agrees;
2196 }
2197 }
2198 graph
2199}
2200
2201fn greedy_largest_clique(graph: &[Vec<bool>]) -> Vec<usize> {
2202 let mut best = Vec::new();
2203 for seed in 0..graph.len() {
2204 let candidate = greedy_clique_from_seed(graph, seed);
2205 update_best_clique(&candidate, &mut best);
2206 }
2207 best
2208}
2209
2210fn greedy_largest_clique_containing(graph: &[Vec<bool>], required: usize) -> Vec<usize> {
2211 if required >= graph.len() {
2212 return Vec::new();
2213 }
2214 greedy_clique_from_seed(graph, required)
2215}
2216
2217fn greedy_clique_from_seed(graph: &[Vec<bool>], seed: usize) -> Vec<usize> {
2218 let mut clique = vec![seed];
2219 for (idx, _) in graph.iter().enumerate() {
2220 if idx == seed {
2221 continue;
2222 }
2223 if clique.iter().all(|&member| graph[member][idx]) {
2224 clique.push(idx);
2225 }
2226 }
2227 clique.sort_unstable();
2228 clique
2229}
2230
2231fn max_clique_search(
2232 graph: &[Vec<bool>],
2233 current: &mut Vec<usize>,
2234 mut candidates: Vec<usize>,
2235 best: &mut Vec<usize>,
2236) {
2237 candidates.sort_unstable();
2238 for (pos, &candidate) in candidates.iter().enumerate() {
2239 let remaining = candidates.len() - pos;
2240 if current.len() + remaining < best.len() {
2241 break;
2242 }
2243
2244 let next_candidates = candidates[pos + 1..]
2245 .iter()
2246 .copied()
2247 .filter(|&idx| graph[candidate][idx])
2248 .collect();
2249
2250 current.push(candidate);
2251 update_best_clique(current, best);
2252 max_clique_search(graph, current, next_candidates, best);
2253 current.pop();
2254 }
2255}
2256
2257fn update_best_clique(current: &[usize], best: &mut Vec<usize>) {
2258 let mut candidate = current.to_vec();
2259 candidate.sort_unstable();
2260 if candidate.len() > best.len()
2261 || (candidate.len() == best.len() && candidate.as_slice() < best.as_slice())
2262 {
2263 *best = candidate;
2264 }
2265}
2266
2267fn combine3(members: &[(usize, [f64; 3])], how: MergeCombine) -> [f64; 3] {
2268 [0usize, 1, 2].map(|axis| {
2269 let axis_members: Vec<(usize, f64)> = members.iter().map(|(s, v)| (*s, v[axis])).collect();
2270 combine_axis(&axis_members, how)
2271 })
2272}
2273
2274fn combine_axis(members: &[(usize, f64)], how: MergeCombine) -> f64 {
2275 match how {
2276 MergeCombine::Mean => members.iter().map(|(_, v)| *v).sum::<f64>() / members.len() as f64,
2277 MergeCombine::Median => {
2278 let mut vals: Vec<f64> = members.iter().map(|(_, v)| *v).collect();
2279 median(&mut vals).expect("consensus cluster is non-empty")
2280 }
2281 MergeCombine::Precedence => members
2282 .iter()
2283 .min_by_key(|(s, _)| *s)
2284 .map(|(_, v)| *v)
2285 .expect("consensus cluster is non-empty"),
2286 }
2287}
2288
2289pub fn align_clock_reference(reference: &Sp3, other: &Sp3, min_common: usize) -> Sp3 {
2303 let offsets: BTreeMap<i64, f64> = clock_reference_offset(reference, other, min_common)
2304 .into_iter()
2305 .filter_map(|o| {
2306 instant_to_j2000_seconds(&o.epoch).map(|sec| (sec.floor() as i64, o.offset_s))
2307 })
2308 .collect();
2309
2310 let mut aligned = other.clone();
2311 for ei in 0..aligned.epochs.len() {
2312 let Some(sec) = sp3_epoch_j2000_seconds(&aligned, ei, &aligned.epochs[ei]) else {
2313 continue;
2314 };
2315 let Some(&off) = offsets.get(&(sec.floor() as i64)) else {
2316 continue;
2317 };
2318 for state in aligned.states[ei].values_mut() {
2319 if let Some(c) = state.clock_s.as_mut() {
2320 *c -= off;
2321 }
2322 }
2323 for node in aligned.interp_raw[ei].values_mut() {
2324 if let Some(us) = node.clock_us.as_mut() {
2325 *us -= off * 1.0e6;
2326 }
2327 }
2328 }
2329 aligned
2330}
2331
2332struct RecordCellProvenance<'a> {
2334 epoch: Instant,
2335 sat: GnssSatelliteId,
2336 position: &'a CellSelection,
2337 clock: Option<&'a CellSelection>,
2338 candidates: &'a [usize],
2344 mode: ProvenanceMode,
2345}
2346
2347struct ProvenanceAccumulator<'a> {
2349 cells: &'a mut Vec<CellProvenance>,
2350 transitions: &'a mut Vec<PrecedenceTransition>,
2351 contributed: &'a mut [usize],
2352 selected: &'a mut [usize],
2353 first: &'a mut [Option<Instant>],
2354 last: &'a mut [Option<Instant>],
2355 accepted_cells: &'a mut usize,
2356 previous: &'a mut BTreeMap<GnssSatelliteId, CellSelection>,
2357}
2358
2359fn record_cell_provenance(cell: RecordCellProvenance<'_>, acc: &mut ProvenanceAccumulator<'_>) {
2366 *acc.accepted_cells += 1;
2367
2368 for source in cell.position.members() {
2369 acc.contributed[source] += 1;
2370 if acc.first[source].is_none() {
2371 acc.first[source] = Some(cell.epoch);
2372 }
2373 acc.last[source] = Some(cell.epoch);
2374 }
2375 if let Some(source) = cell.position.selected_source() {
2376 acc.selected[source] += 1;
2377 }
2378
2379 if let Some(transition) = transition_between(
2380 cell.sat,
2381 cell.epoch,
2382 acc.previous.get(&cell.sat),
2383 cell.position,
2384 cell.candidates,
2385 ) {
2386 acc.transitions.push(transition);
2387 }
2388 acc.previous.insert(cell.sat, cell.position.clone());
2389
2390 if cell.mode == ProvenanceMode::Full {
2391 acc.cells.push(CellProvenance {
2392 epoch: cell.epoch,
2393 satellite: cell.sat,
2394 position: cell.position.clone(),
2395 clock: cell.clock.cloned(),
2396 });
2397 }
2398}
2399
2400fn transition_between(
2407 sat: GnssSatelliteId,
2408 epoch: Instant,
2409 previous: Option<&CellSelection>,
2410 current: &CellSelection,
2411 candidates: &[usize],
2412) -> Option<PrecedenceTransition> {
2413 let Some(previous) = previous else {
2414 return Some(PrecedenceTransition {
2415 satellite: sat,
2416 epoch,
2417 from_source: None,
2418 to_source: current.selected_source(),
2419 reason: TransitionReason::SoleAvailability,
2420 });
2421 };
2422
2423 let from = previous.selected_source();
2424 let to = current.selected_source();
2425 if from == to && std::mem::discriminant(previous) == std::mem::discriminant(current) {
2426 return None;
2427 }
2428
2429 let current_members = current.members();
2434 let reason = match from {
2435 Some(from_source) if !candidates.contains(&from_source) => {
2436 TransitionReason::SoleAvailability
2437 }
2438 Some(from_source) if !current_members.contains(&from_source) => {
2439 TransitionReason::OutlierRejection
2440 }
2441 Some(_) if std::mem::discriminant(previous) != std::mem::discriminant(current) => {
2442 TransitionReason::ConsensusChange
2443 }
2444 Some(_) => TransitionReason::Precedence,
2445 None => TransitionReason::ConsensusChange,
2446 };
2447
2448 Some(PrecedenceTransition {
2449 satellite: sat,
2450 epoch,
2451 from_source: from,
2452 to_source: to,
2453 reason,
2454 })
2455}
2456
2457#[cfg(test)]
2458mod tests {
2459 use super::super::Sp3;
2460 use super::{
2461 align_clock_reference, clock_reference_offset, merge, MergeCombine, MergeOptions,
2462 MergePrecedenceScope, MergeReport, OutlierRejectOptions, Sp3FrameLabelSet,
2463 Sp3FrameReconciliationMethod, Sp3FrameReconciliationOptions,
2464 };
2465 use crate::constants::SECONDS_PER_DAY;
2466 use crate::id::{GnssSatelliteId, GnssSystem};
2467 use std::collections::BTreeSet;
2468
2469 type SatSample<'a> = (&'a str, [f64; 3], Option<f64>);
2472
2473 fn gps(prn: u8) -> GnssSatelliteId {
2474 GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
2475 }
2476
2477 fn sp3_build(records: &[(&str, [f64; 3], Option<f64>, &str)], cs: &str) -> Sp3 {
2483 let n = records.len();
2484 let mut sats = String::new();
2485 for (sat, _, _, _) in records {
2486 sats.push_str(sat);
2487 }
2488 for _ in n..17 {
2489 sats.push_str(" 0");
2490 }
2491 let mut body = String::new();
2492 body.push_str(&format!(
2493 "#cP2020 6 25 0 0 0.00000000 1 ORBIT {cs} FIT TST\n"
2494 ));
2495 body.push_str("## 2111 432000.00000000 900.00000000 59025 0.0000000000000\n");
2496 body.push_str(&format!("+ {n:2} {sats}\n"));
2497 body.push_str("++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n");
2498 body.push_str("%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
2499 body.push_str("%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
2500 body.push_str("%f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n");
2501 body.push_str("%f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n");
2502 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
2503 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
2504 body.push_str("/* TEST SP3-c FIXTURE\n");
2505 body.push_str("* 2020 6 25 0 0 0.00000000\n");
2506 for (sat, p, clk, flags) in records {
2507 let c = clk.unwrap_or(999_999.999_999);
2508 body.push_str(&format!(
2509 "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}{flags}\n",
2510 p[0], p[1], p[2]
2511 ));
2512 }
2513 body.push_str("EOF\n");
2514 Sp3::parse(body.as_bytes()).expect("parse test sp3")
2515 }
2516
2517 fn sp3_records(records: &[(&str, [f64; 3], Option<f64>)]) -> Sp3 {
2519 let full: Vec<(&str, [f64; 3], Option<f64>, &str)> =
2520 records.iter().map(|(s, p, c)| (*s, *p, *c, "")).collect();
2521 sp3_build(&full, "IGS14")
2522 }
2523
2524 fn sp3_two_epochs(
2525 epoch0: &[(&str, [f64; 3], Option<f64>)],
2526 epoch1: &[(&str, [f64; 3], Option<f64>)],
2527 interval_s: f64,
2528 cs: &str,
2529 ) -> Sp3 {
2530 let mut sats: Vec<&str> = epoch0
2531 .iter()
2532 .chain(epoch1.iter())
2533 .map(|(sat, _, _)| *sat)
2534 .collect();
2535 sats.sort_unstable();
2536 sats.dedup();
2537 let n = sats.len();
2538 let mut sat_field = String::new();
2539 for sat in &sats {
2540 sat_field.push_str(sat);
2541 }
2542 for _ in n..17 {
2543 sat_field.push_str(" 0");
2544 }
2545
2546 let mut body = String::new();
2547 body.push_str(&format!(
2548 "#cP2020 6 25 0 0 0.00000000 2 ORBIT {cs} FIT TST\n"
2549 ));
2550 body.push_str(&format!(
2551 "## 2111 432000.00000000 {interval_s:14.8} 59025 0.0000000000000\n"
2552 ));
2553 body.push_str(&format!("+ {n:2} {sat_field}\n"));
2554 body.push_str("++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n");
2555 body.push_str("%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
2556 body.push_str("%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
2557 body.push_str("%f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n");
2558 body.push_str("%f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n");
2559 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
2560 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
2561 body.push_str("/* TEST SP3-c FIXTURE\n");
2562 body.push_str("* 2020 6 25 0 0 0.00000000\n");
2563 for (sat, p, clk) in epoch0 {
2564 let c = clk.unwrap_or(999_999.999_999);
2565 body.push_str(&format!(
2566 "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}\n",
2567 p[0], p[1], p[2]
2568 ));
2569 }
2570 let second_hour = (interval_s as i64) / 3600;
2571 let second_minute = ((interval_s as i64) % 3600) / 60;
2572 let second_second = (interval_s as i64) % 60;
2573 body.push_str(&format!(
2574 "* 2020 6 25 {second_hour:2} {second_minute:2} {second_second:2}.00000000\n"
2575 ));
2576 for (sat, p, clk) in epoch1 {
2577 let c = clk.unwrap_or(999_999.999_999);
2578 body.push_str(&format!(
2579 "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}\n",
2580 p[0], p[1], p[2]
2581 ));
2582 }
2583 body.push_str("EOF\n");
2584 Sp3::parse(body.as_bytes()).expect("parse test sp3")
2585 }
2586
2587 fn sp3_epochs(
2589 start_offset_s: f64,
2590 epochs: &[&[SatSample<'_>]],
2591 interval_s: f64,
2592 cs: &str,
2593 ) -> Sp3 {
2594 let mut sats: Vec<&str> = epochs
2595 .iter()
2596 .flat_map(|e| e.iter().map(|(sat, _, _)| *sat))
2597 .collect();
2598 sats.sort_unstable();
2599 sats.dedup();
2600 let n = sats.len();
2601 let mut sat_field = String::new();
2602 for sat in &sats {
2603 sat_field.push_str(sat);
2604 }
2605 for _ in n..17 {
2606 sat_field.push_str(" 0");
2607 }
2608
2609 let hms = |t: i64| (t / 3600, (t % 3600) / 60, t % 60);
2610 let start = start_offset_s as i64;
2611 let (sh, sm, ss0) = hms(start);
2612
2613 let mut body = String::new();
2614 body.push_str(&format!(
2615 "#cP2020 6 25 {sh:2} {sm:2} {ss0:2}.00000000 {:2} ORBIT {cs} FIT TST\n",
2616 epochs.len()
2617 ));
2618 let sow = 432_000.0 + start_offset_s;
2620 let mjd_frac = start_offset_s / SECONDS_PER_DAY;
2621 body.push_str(&format!(
2622 "## 2111 {sow:15.8} {interval_s:14.8} 59025 {mjd_frac:.13}\n"
2623 ));
2624 body.push_str(&format!("+ {n:2} {sat_field}\n"));
2625 body.push_str("++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n");
2626 body.push_str("%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
2627 body.push_str("%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
2628 body.push_str("%f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n");
2629 body.push_str("%f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n");
2630 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
2631 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
2632 body.push_str("/* TEST SP3-c FIXTURE\n");
2633 for (k, recs) in epochs.iter().enumerate() {
2634 let (hh, mm, ss) = hms(start + (k as i64) * (interval_s as i64));
2635 body.push_str(&format!("* 2020 6 25 {hh:2} {mm:2} {ss:2}.00000000\n"));
2636 for (sat, p, clk) in recs.iter() {
2637 let c = clk.unwrap_or(999_999.999_999);
2638 body.push_str(&format!(
2639 "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}\n",
2640 p[0], p[1], p[2]
2641 ));
2642 }
2643 }
2644 body.push_str("EOF\n");
2645 Sp3::parse(body.as_bytes()).expect("parse test sp3")
2646 }
2647
2648 #[test]
2649 fn merge_unions_coverage_when_one_center_misses_a_satellite() {
2650 let a = sp3_records(&[
2653 ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
2654 ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
2655 ("G03", [17000.0, -22000.0, 7000.0], Some(300.0)),
2656 ]);
2657 let b = sp3_records(&[
2658 ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
2659 ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
2660 ]);
2661
2662 let (merged, report) = merge(&[a, b], &MergeOptions::default()).expect("merge");
2663
2664 let states = merged.states_at(0).expect("epoch 0");
2665 assert!(
2666 states.contains_key(&gps(3)),
2667 "merged output must cover G03 from the center that has it"
2668 );
2669 assert_eq!(states.len(), 3, "union is G01/G02/G03");
2670 let g01 = states[&gps(1)];
2672 assert!((g01.clock_s.unwrap() - 100.0e-6).abs() < 1.0e-15);
2673 assert!(report.quarantined.is_empty());
2675 assert_eq!(report.single_source.len(), 1);
2676 assert_eq!(report.single_source[0].satellite, gps(3));
2677
2678 let frac = report
2682 .single_source_fraction()
2683 .expect("accepted cells present");
2684 assert!(
2685 (frac - 1.0 / 3.0).abs() < 1.0e-12,
2686 "single-source fraction {frac}"
2687 );
2688 assert_eq!(MergeReport::default().single_source_fraction(), None);
2689 }
2690
2691 #[test]
2692 fn merge_rejects_non_executable_system_and_frame_policies() {
2693 let source = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))]);
2694
2695 let empty_systems = MergeOptions {
2696 systems: Some(BTreeSet::new()),
2697 ..MergeOptions::default()
2698 };
2699 let error = merge(std::slice::from_ref(&source), &empty_systems).unwrap_err();
2700 assert!(error
2701 .to_string()
2702 .contains("systems filter must not be empty"));
2703
2704 let incomplete_frame_set = MergeOptions {
2705 frame_reconciliation: Sp3FrameReconciliationOptions {
2706 asserted_equivalent_label_sets: vec![Sp3FrameLabelSet::new(["IGS20"])],
2707 helmert: false,
2708 },
2709 ..MergeOptions::default()
2710 };
2711 let error = merge(&[source], &incomplete_frame_set).unwrap_err();
2712 assert!(error.to_string().contains("at least two non-empty labels"));
2713 }
2714
2715 #[test]
2716 fn merge_combines_two_of_three_agreeing_sources_and_rejects_the_outlier() {
2717 let a = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))]);
2719 let b = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))]);
2720 let c = sp3_records(&[("G01", [15000.010, -20000.0, 5000.0], Some(100.0))]);
2721
2722 let (merged, report) = merge(&[a, b, c], &MergeOptions::default()).expect("merge");
2723
2724 let states = merged.states_at(0).expect("epoch 0");
2725 let g01 = states[&gps(1)];
2726 assert!(
2728 (g01.position.as_array()[0] - 15_000_000.0).abs() < 1.0e-3,
2729 "got {}",
2730 g01.position.as_array()[0]
2731 );
2732 assert_eq!(report.position_outliers.len(), 1);
2734 assert_eq!(report.position_outliers[0].sources, vec![2]);
2735 assert!(report.quarantined.is_empty());
2736 }
2737
2738 #[test]
2739 fn guarded_precedence_replaces_a_corrupt_preferred_position() {
2740 let preferred = sp3_records(&[("G01", [16000.0, -20000.0, 5000.0], None)]);
2741 let agreeing_a = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], None)]);
2742 let agreeing_b = sp3_records(&[("G01", [15000.0002, -20000.0, 5000.0], None)]);
2743 let opts = MergeOptions {
2744 combine: MergeCombine::Precedence,
2745 min_agree: 1,
2746 outlier_reject: Some(OutlierRejectOptions {
2747 position_tolerance_m: 0.5,
2748 clock_tolerance_s: 5.0e-9,
2749 }),
2750 ..MergeOptions::default()
2751 };
2752
2753 let (merged, report) = merge(&[preferred, agreeing_a, agreeing_b], &opts).expect("merge");
2754
2755 let x = merged.states_at(0).expect("epoch")[&gps(1)]
2756 .position
2757 .as_array()[0];
2758 assert_eq!(
2759 x, 15_000_000.0,
2760 "earliest member of the 2-source cluster wins"
2761 );
2762 assert_eq!(report.position_outliers.len(), 1);
2763 assert_eq!(report.position_outliers[0].sources, vec![0]);
2764 }
2765
2766 #[test]
2767 fn unguarded_precedence_preserves_the_existing_preferred_value_behavior() {
2768 let preferred = sp3_records(&[("G01", [16000.0, -20000.0, 5000.0], None)]);
2769 let agreeing_a = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], None)]);
2770 let agreeing_b = sp3_records(&[("G01", [15000.0002, -20000.0, 5000.0], None)]);
2771 let opts = MergeOptions {
2772 combine: MergeCombine::Precedence,
2773 min_agree: 1,
2774 outlier_reject: None,
2775 ..MergeOptions::default()
2776 };
2777
2778 let (merged, report) = merge(&[preferred, agreeing_a, agreeing_b], &opts).expect("merge");
2779
2780 let x = merged.states_at(0).expect("epoch")[&gps(1)]
2781 .position
2782 .as_array()[0];
2783 assert_eq!(x, 16_000_000.0);
2784 assert_eq!(report.position_outliers[0].sources, vec![1, 2]);
2785 }
2786
2787 #[test]
2788 fn guarded_precedence_keeps_a_preferred_member_of_the_majority() {
2789 let preferred = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], None)]);
2790 let agreeing = sp3_records(&[("G01", [15000.0002, -20000.0, 5000.0], None)]);
2791 let outlier = sp3_records(&[("G01", [16000.0, -20000.0, 5000.0], None)]);
2792 let opts = MergeOptions {
2793 combine: MergeCombine::Precedence,
2794 min_agree: 1,
2795 outlier_reject: Some(OutlierRejectOptions {
2796 position_tolerance_m: 0.5,
2797 clock_tolerance_s: 5.0e-9,
2798 }),
2799 ..MergeOptions::default()
2800 };
2801
2802 let (merged, report) = merge(&[preferred, agreeing, outlier], &opts).expect("merge");
2803
2804 let x = merged.states_at(0).expect("epoch")[&gps(1)]
2805 .position
2806 .as_array()[0];
2807 assert_eq!(x, 15_000_000.0);
2808 assert_eq!(report.position_outliers[0].sources, vec![2]);
2809 }
2810
2811 #[test]
2812 fn guarded_precedence_keeps_a_single_source_cell() {
2813 let only = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], None)]);
2814 let opts = MergeOptions {
2815 combine: MergeCombine::Precedence,
2816 min_agree: 1,
2817 outlier_reject: Some(OutlierRejectOptions {
2818 position_tolerance_m: 0.5,
2819 clock_tolerance_s: 5.0e-9,
2820 }),
2821 ..MergeOptions::default()
2822 };
2823
2824 let (merged, report) = merge(&[only], &opts).expect("merge");
2825
2826 assert!(merged.states_at(0).expect("epoch").contains_key(&gps(1)));
2827 assert_eq!(report.single_source.len(), 1);
2828 assert!(report.quarantined.is_empty());
2829 }
2830
2831 #[test]
2832 fn guarded_precedence_position_tolerance_is_inclusive() {
2833 for (delta_km, accepted) in [(0.000_499, true), (0.000_501, false)] {
2834 let a = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], None)]);
2835 let b = sp3_records(&[("G01", [15000.0 + delta_km, -20000.0, 5000.0], None)]);
2836 let opts = MergeOptions {
2837 combine: MergeCombine::Precedence,
2838 min_agree: 1,
2839 outlier_reject: Some(OutlierRejectOptions {
2840 position_tolerance_m: 0.5,
2841 clock_tolerance_s: 5.0e-9,
2842 }),
2843 ..MergeOptions::default()
2844 };
2845
2846 let (merged, report) = merge(&[a, b], &opts).expect("merge");
2847 assert_eq!(
2848 merged.states_at(0).expect("epoch").contains_key(&gps(1)),
2849 accepted,
2850 "delta {delta_km} km"
2851 );
2852 assert_eq!(report.quarantined.is_empty(), accepted);
2853 }
2854
2855 assert_eq!(
2856 super::largest_within(&[0.0_f64, 0.5_f64], |a, b| (*a - *b).abs() <= 0.5).len(),
2857 2,
2858 "the tolerance boundary itself is accepted"
2859 );
2860 }
2861
2862 #[test]
2863 fn guarded_precedence_replaces_a_corrupt_preferred_clock() {
2864 let positions = |clock_g01: f64| {
2865 sp3_records(&[
2866 ("G01", [15000.0, -20000.0, 5000.0], Some(clock_g01)),
2867 ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
2868 ("G03", [17000.0, -22000.0, 7000.0], Some(300.0)),
2869 ("G04", [18000.0, -23000.0, 8000.0], Some(400.0)),
2870 ("G05", [19000.0, -24000.0, 9000.0], Some(500.0)),
2871 ])
2872 };
2873 let opts = MergeOptions {
2874 combine: MergeCombine::Precedence,
2875 min_agree: 1,
2876 outlier_reject: Some(OutlierRejectOptions {
2877 position_tolerance_m: 0.5,
2878 clock_tolerance_s: 5.0e-9,
2879 }),
2880 ..MergeOptions::default()
2881 };
2882
2883 let (merged, report) = merge(
2884 &[positions(1100.0), positions(100.0), positions(100.0)],
2885 &opts,
2886 )
2887 .expect("merge");
2888
2889 let clock = merged.states_at(0).expect("epoch")[&gps(1)]
2890 .clock_s
2891 .expect("consensus clock");
2892 assert!((clock - 100.0e-6).abs() < 1.0e-15, "clock {clock}");
2893 let rejected = report
2894 .clock_outliers
2895 .iter()
2896 .find(|entry| entry.satellite == gps(1))
2897 .expect("clock outlier provenance");
2898 assert_eq!(rejected.sources, vec![0]);
2899 }
2900
2901 #[test]
2902 fn merge_consensus_handles_more_than_u32_mask_bits() {
2903 let sources: Vec<Sp3> = (0..33)
2906 .map(|idx| {
2907 let x_km = if idx < 32 { 15000.0 } else { 15000.010 };
2908 sp3_records(&[("G01", [x_km, -20000.0, 5000.0], Some(100.0))])
2909 })
2910 .collect();
2911
2912 for combine in [MergeCombine::Mean, MergeCombine::Precedence] {
2913 let opts = MergeOptions {
2914 combine,
2915 min_agree: 32,
2916 ..MergeOptions::default()
2917 };
2918
2919 let (merged, report) = merge(&sources, &opts).expect("33-source merge");
2920
2921 let states = merged.states_at(0).expect("epoch 0");
2922 let g01 = states[&gps(1)];
2923 assert!(
2924 (g01.position.as_array()[0] - 15_000_000.0).abs() < 1.0e-3,
2925 "{combine:?}: got {}",
2926 g01.position.as_array()[0]
2927 );
2928 assert_eq!(
2929 report.position_outliers.len(),
2930 1,
2931 "{combine:?}: expected one outlier report"
2932 );
2933 assert_eq!(report.position_outliers[0].sources, vec![32]);
2934 assert!(report.quarantined.is_empty(), "{combine:?}");
2935 }
2936 }
2937
2938 #[test]
2939 fn merge_bounds_large_overlap_clique_search() {
2940 let sources: Vec<Sp3> = (0..40)
2941 .map(|idx| {
2942 let x_km = if idx % 2 == 0 { 15000.0 } else { 15000.010 };
2943 sp3_records(&[("G01", [x_km, -20000.0, 5000.0], Some(100.0))])
2944 })
2945 .collect();
2946 let opts = MergeOptions {
2947 min_agree: 20,
2948 ..MergeOptions::default()
2949 };
2950
2951 let (merged, report) = merge(&sources, &opts).expect("bounded large-source merge");
2952
2953 let states = merged.states_at(0).expect("epoch 0");
2954 let g01 = states[&gps(1)];
2955 assert!(
2956 (g01.position.as_array()[0] - 15_000_000.0).abs() < 1.0e-3,
2957 "got {}",
2958 g01.position.as_array()[0]
2959 );
2960 assert_eq!(report.position_outliers.len(), 1);
2961 assert_eq!(
2962 report.position_outliers[0].sources,
2963 (1..40).step_by(2).collect::<Vec<_>>()
2964 );
2965 assert!(report.quarantined.is_empty());
2966 }
2967
2968 #[test]
2969 fn merge_quarantines_a_satellite_all_centers_disagree_on() {
2970 let a = sp3_records(&[("G01", [15000.000, -20000.0, 5000.0], Some(100.0))]);
2972 let b = sp3_records(&[("G01", [15000.010, -20000.0, 5000.0], Some(100.0))]);
2973 let c = sp3_records(&[("G01", [15000.020, -20000.0, 5000.0], Some(100.0))]);
2974
2975 let (merged, report) = merge(&[a, b, c], &MergeOptions::default()).expect("merge");
2976
2977 assert!(
2978 merged.states_at(0).expect("epoch 0").is_empty(),
2979 "no consensus -> G01 omitted, not averaged across disagreeing centers"
2980 );
2981 assert_eq!(report.quarantined.len(), 1);
2982 assert_eq!(report.quarantined[0].satellite, gps(1));
2983 }
2984
2985 #[test]
2986 fn merge_rejects_an_empty_input() {
2987 assert!(merge(&[], &MergeOptions::default()).is_err());
2988 }
2989
2990 #[test]
2991 fn merge_omits_an_unalignable_secondary_clock() {
2992 let a = sp3_records(&[
2997 ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
2998 ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
2999 ("G03", [17000.0, -22000.0, 7000.0], Some(300.0)),
3000 ]);
3001 let b = sp3_records(&[
3002 ("G01", [15000.0, -20000.0, 5000.0], Some(150.0)),
3003 ("G02", [16000.0, -21000.0, 6000.0], Some(250.0)),
3004 ("G03", [17000.0, -22000.0, 7000.0], Some(350.0)),
3005 ("G04", [18000.0, -23000.0, 8000.0], Some(450.0)),
3006 ]);
3007
3008 let (merged, _) = merge(&[a, b], &MergeOptions::default()).expect("merge");
3009 let states = merged.states_at(0).expect("epoch 0");
3010
3011 assert!(states.contains_key(&gps(4)));
3013 assert!(
3014 states[&gps(4)].clock_s.is_none(),
3015 "an unalignable secondary clock must be dropped, not emitted raw"
3016 );
3017 let g01_clock = states[&gps(1)]
3019 .clock_s
3020 .expect("G01 carries the reference clock");
3021 assert!((g01_clock - 100.0e-6).abs() < 1.0e-12, "got {g01_clock}");
3022 }
3023
3024 #[test]
3025 fn merge_rejects_mismatched_coordinate_systems() {
3026 let a = sp3_build(
3027 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
3028 "IGS14",
3029 );
3030 let b = sp3_build(
3031 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
3032 "IGS20",
3033 );
3034
3035 assert!(merge(&[a, b], &MergeOptions::default()).is_err());
3036 }
3037
3038 #[test]
3039 fn merge_rejects_different_igs_frame_labels_without_a_transform() {
3040 let a = sp3_build(
3041 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
3042 "IGS20",
3043 );
3044 let b = sp3_build(
3045 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
3046 "IGc20",
3047 );
3048
3049 let err = merge(&[a, b], &MergeOptions::default()).expect_err("frame mismatch");
3050 assert!(
3051 err.to_string().contains("mismatched coordinate systems"),
3052 "{err}"
3053 );
3054 }
3055
3056 #[test]
3057 fn merge_accepts_asserted_equivalent_labels_and_reports_assertion() {
3058 for (a_label, b_label) in [("IGS14", "ITRF2"), ("ITRF2", "IGS14")] {
3059 let a = sp3_build(
3060 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
3061 a_label,
3062 );
3063 let b = sp3_build(
3064 &[("G02", [16000.0, -21000.0, 6000.0], Some(200.0), "")],
3065 b_label,
3066 );
3067 let opts = MergeOptions {
3068 frame_reconciliation: super::Sp3FrameReconciliationOptions {
3069 asserted_equivalent_label_sets: vec![Sp3FrameLabelSet::pair("IGS14", "ITRF2")],
3070 helmert: false,
3071 },
3072 ..MergeOptions::default()
3073 };
3074
3075 let (merged, report) = merge(&[a, b], &opts).expect("asserted frame merge");
3076
3077 let states = merged.states_at(0).expect("epoch 0");
3078 assert!(states.contains_key(&gps(1)));
3079 assert!(states.contains_key(&gps(2)));
3080 assert_eq!(merged.header.coordinate_system, a_label);
3081 assert_eq!(report.frame_reconciliations.len(), 1);
3082 let reconciliation = &report.frame_reconciliations[0];
3083 assert_eq!(
3084 reconciliation.method,
3085 Sp3FrameReconciliationMethod::AssertedEquivalence
3086 );
3087 assert_eq!(reconciliation.source_index, 1);
3088 assert_eq!(reconciliation.source_label, b_label);
3089 assert_eq!(reconciliation.target_label, a_label);
3090 assert_eq!(reconciliation.records_affected, 1);
3091 assert!(reconciliation.parameters.is_none());
3092 assert!(reconciliation.rates.is_none());
3093 assert_eq!(
3094 reconciliation
3095 .asserted_label_set
3096 .as_ref()
3097 .expect("assertion set"),
3098 &vec!["IGS14".to_string(), "ITRF2".to_string()]
3099 );
3100 }
3101 }
3102
3103 #[test]
3104 fn merge_applies_helmert_reconciliation_to_resolved_labels() {
3105 let a = sp3_build(
3110 &[("G01", [14000.0, -19000.0, 4000.0], Some(100.0), "")],
3111 "IGS14",
3112 );
3113 let b = sp3_build(
3114 &[("G02", [15000.0, -20000.0, 5000.0], Some(200.0), "")],
3115 "IGS20",
3116 );
3117 let opts = MergeOptions {
3118 min_agree: 1,
3119 frame_reconciliation: super::Sp3FrameReconciliationOptions::helmert(),
3120 ..MergeOptions::default()
3121 };
3122
3123 let (merged, report) = merge(&[a, b], &opts).expect("helmert frame merge");
3124
3125 let g02 = merged.states_at(0).expect("epoch 0")[&gps(2)];
3126 let got = g02.position.as_array();
3127 let expected = [
3128 14_999_999.992_3,
3129 -19_999_999.993_048_087,
3130 5_000_000.000_396_175,
3131 ];
3132 for axis in 0..3 {
3133 assert!(
3134 (got[axis] - expected[axis]).abs() < 2.0e-9,
3135 "axis {axis}: got {}, expected {}",
3136 got[axis],
3137 expected[axis]
3138 );
3139 }
3140 assert_eq!(merged.header.coordinate_system, "IGS14");
3141 assert_eq!(report.frame_reconciliations.len(), 1);
3142 let reconciliation = &report.frame_reconciliations[0];
3143 assert_eq!(reconciliation.method, Sp3FrameReconciliationMethod::Helmert);
3144 assert_eq!(reconciliation.source_label, "IGS20");
3145 assert_eq!(reconciliation.target_label, "IGS14");
3146 assert_eq!(reconciliation.records_affected, 1);
3147 assert_eq!(
3148 reconciliation
3149 .parameters
3150 .expect("published parameters")
3151 .translation_mm,
3152 [-1.4, -0.9, 1.4]
3153 );
3154 assert_eq!(
3155 reconciliation.catalog_source_frame,
3156 Some(crate::frame_catalog::TerrestrialFrame::Itrf2020)
3157 );
3158 assert_eq!(
3159 reconciliation.catalog_target_frame,
3160 Some(crate::frame_catalog::TerrestrialFrame::Itrf2014)
3161 );
3162 assert!(!reconciliation.catalog_inverse);
3163 assert_eq!(
3164 reconciliation
3165 .rates
3166 .expect("published rates")
3167 .translation_mm_per_year,
3168 [0.0, -0.1, 0.2]
3169 );
3170 assert!(reconciliation
3171 .provenance
3172 .as_ref()
3173 .expect("provenance")
3174 .contains("ITRF2020 to past ITRFs"));
3175 }
3176
3177 #[test]
3178 fn merge_reports_inverse_helmert_catalog_direction() {
3179 let a = sp3_build(
3180 &[("G01", [14000.0, -19000.0, 4000.0], Some(100.0), "")],
3181 "IGS20",
3182 );
3183 let b = sp3_build(
3184 &[("G02", [15000.0, -20000.0, 5000.0], Some(200.0), "")],
3185 "IGS14",
3186 );
3187 let opts = MergeOptions {
3188 min_agree: 1,
3189 frame_reconciliation: super::Sp3FrameReconciliationOptions::helmert(),
3190 ..MergeOptions::default()
3191 };
3192
3193 let (_merged, report) = merge(&[a, b], &opts).expect("inverse helmert frame merge");
3194
3195 let reconciliation = &report.frame_reconciliations[0];
3196 assert_eq!(reconciliation.method, Sp3FrameReconciliationMethod::Helmert);
3197 assert_eq!(
3198 reconciliation.source_frame,
3199 Some(crate::frame_catalog::TerrestrialFrame::Itrf2014)
3200 );
3201 assert_eq!(
3202 reconciliation.target_frame,
3203 Some(crate::frame_catalog::TerrestrialFrame::Itrf2020)
3204 );
3205 assert_eq!(
3206 reconciliation.catalog_source_frame,
3207 Some(crate::frame_catalog::TerrestrialFrame::Itrf2020)
3208 );
3209 assert_eq!(
3210 reconciliation.catalog_target_frame,
3211 Some(crate::frame_catalog::TerrestrialFrame::Itrf2014)
3212 );
3213 assert!(reconciliation.catalog_inverse);
3214 assert_eq!(
3215 reconciliation
3216 .parameters
3217 .expect("published parameters")
3218 .translation_mm,
3219 [-1.4, -0.9, 1.4]
3220 );
3221 }
3222
3223 #[test]
3224 fn helmert_identity_label_reconciliation_is_bit_equal() {
3225 let a = sp3_build(
3226 &[("G01", [14000.0, -19000.0, 4000.0], Some(100.0), "")],
3227 "IGS20",
3228 );
3229 let b = sp3_build(
3230 &[("G02", [15000.125, -20000.5, 5000.25], Some(200.0), "")],
3231 "IGc20",
3232 );
3233 let original = b.states_at(0).expect("epoch 0")[&gps(2)].position;
3234 let opts = MergeOptions {
3235 min_agree: 1,
3236 frame_reconciliation: super::Sp3FrameReconciliationOptions::helmert(),
3237 ..MergeOptions::default()
3238 };
3239
3240 let (merged, report) = merge(&[a, b], &opts).expect("identity frame merge");
3241
3242 let g02 = merged.states_at(0).expect("epoch 0")[&gps(2)].position;
3243 for axis in 0..3 {
3244 assert_eq!(
3245 g02.as_array()[axis].to_bits(),
3246 original.as_array()[axis].to_bits()
3247 );
3248 }
3249 assert_eq!(report.frame_reconciliations.len(), 1);
3250 assert!(report.frame_reconciliations[0].identity);
3251 assert!(report.frame_reconciliations[0].parameters.is_none());
3252 }
3253
3254 #[test]
3255 fn helmert_reconciliation_rejects_unknown_labels() {
3256 let a = sp3_build(
3257 &[("G01", [14000.0, -19000.0, 4000.0], Some(100.0), "")],
3258 "ITRF2",
3259 );
3260 let b = sp3_build(
3261 &[("G02", [15000.0, -20000.0, 5000.0], Some(200.0), "")],
3262 "IGS20",
3263 );
3264 let opts = MergeOptions {
3265 frame_reconciliation: super::Sp3FrameReconciliationOptions::helmert(),
3266 ..MergeOptions::default()
3267 };
3268
3269 let err = merge(&[a, b], &opts).expect_err("unknown frame label");
3270
3271 assert!(
3272 err.to_string().contains("target label"),
3273 "unknown labels must not be guessed: {err}"
3274 );
3275 }
3276
3277 #[test]
3278 fn merge_uses_finest_union_grid_and_fills_sparse_precedence_cells() {
3279 let a = sp3_two_epochs(
3283 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
3284 &[("G01", [15003.0, -20003.0, 5003.0], Some(103.0))],
3285 900.0,
3286 "IGS14",
3287 );
3288 let b = sp3_epochs(
3289 0.0,
3290 &[
3291 &[("G01", [26000.0, -20000.0, 5000.0], Some(200.0))],
3292 &[("G01", [26001.0, -20001.0, 5001.0], Some(201.0))],
3293 &[("G01", [26002.0, -20002.0, 5002.0], Some(202.0))],
3294 &[("G01", [26003.0, -20003.0, 5003.0], Some(203.0))],
3295 ],
3296 300.0,
3297 "IGS14",
3298 );
3299
3300 let opts = MergeOptions {
3301 combine: MergeCombine::Precedence,
3302 min_agree: 1,
3303 ..MergeOptions::default()
3304 };
3305 let (merged, _report) = merge(&[a, b], &opts).expect("mixed-interval union merge");
3306
3307 assert_eq!(
3308 merged.header.epoch_interval_s, 300.0,
3309 "output is on the finest (300 s) input grid"
3310 );
3311 assert_eq!(
3312 merged.epochs.len(),
3313 4,
3314 "B fills the :05 and :10 epochs between A's samples"
3315 );
3316 let xs: Vec<f64> = (0..4)
3317 .map(|idx| {
3318 merged.states_at(idx).expect("epoch")[&gps(1)]
3319 .position
3320 .as_array()[0]
3321 })
3322 .collect();
3323 assert_eq!(
3324 xs,
3325 vec![15_000_000.0, 26_001_000.0, 26_002_000.0, 15_003_000.0]
3326 );
3327 }
3328
3329 #[test]
3330 fn mixed_cadence_interpolates_only_the_clock_datum_for_filled_cells() {
3331 let reference_epoch: Vec<SatSample<'_>> = vec![
3332 ("G01", [15_001.0, -20_000.0, 5_000.0], Some(100.0)),
3333 ("G02", [15_002.0, -20_000.0, 5_000.0], Some(200.0)),
3334 ("G03", [15_003.0, -20_000.0, 5_000.0], Some(300.0)),
3335 ("G04", [15_004.0, -20_000.0, 5_000.0], Some(400.0)),
3336 ("G05", [15_005.0, -20_000.0, 5_000.0], Some(500.0)),
3337 ];
3338 let shifted_epoch: Vec<SatSample<'_>> = reference_epoch
3339 .iter()
3340 .map(|(sat, position, clock)| (*sat, *position, clock.map(|value| value + 50.0)))
3341 .collect();
3342 let a = sp3_epochs(
3343 0.0,
3344 &[reference_epoch.as_slice(), reference_epoch.as_slice()],
3345 900.0,
3346 "IGS14",
3347 );
3348 let b = sp3_epochs(
3349 0.0,
3350 &[
3351 shifted_epoch.as_slice(),
3352 shifted_epoch.as_slice(),
3353 shifted_epoch.as_slice(),
3354 shifted_epoch.as_slice(),
3355 ],
3356 300.0,
3357 "IGS14",
3358 );
3359 let opts = MergeOptions {
3360 combine: MergeCombine::Precedence,
3361 min_agree: 1,
3362 ..MergeOptions::default()
3363 };
3364
3365 let (merged, _) = merge(&[a, b], &opts).expect("mixed-cadence clock merge");
3366
3367 assert_eq!(merged.epochs.len(), 4);
3368 for epoch_index in 0..4 {
3369 let clock = merged.states_at(epoch_index).expect("epoch")[&gps(1)]
3370 .clock_s
3371 .expect("aligned clock");
3372 assert!(
3373 (clock - 100.0e-6).abs() < 1.0e-15,
3374 "epoch {epoch_index}: {clock}"
3375 );
3376 }
3377 }
3378
3379 #[test]
3380 fn merge_decimates_with_explicit_coarser_target_interval() {
3381 let recs = |x: f64| vec![("G01", [x, -20000.0, 5000.0], Some(100.0))];
3383 let make = || {
3384 sp3_epochs(
3385 0.0,
3386 &[
3387 &recs(15000.0),
3388 &recs(15001.0),
3389 &recs(15002.0),
3390 &recs(15003.0),
3391 ],
3392 300.0,
3393 "IGS14",
3394 )
3395 };
3396 let opts = MergeOptions {
3397 min_agree: 1,
3398 target_epoch_interval_s: Some(900.0),
3399 ..MergeOptions::default()
3400 };
3401 let (merged, _) = merge(&[make(), make()], &opts).expect("explicit coarse target");
3402 assert_eq!(merged.header.epoch_interval_s, 900.0);
3403 assert_eq!(
3404 merged.epochs.len(),
3405 2,
3406 "decimated 5-min inputs to the 900 s target"
3407 );
3408 }
3409
3410 #[test]
3411 fn merge_rejects_non_divisible_epoch_intervals() {
3412 let a = sp3_two_epochs(
3416 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
3417 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
3418 900.0,
3419 "IGS14",
3420 );
3421 let b = sp3_two_epochs(
3422 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
3423 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
3424 400.0,
3425 "IGS14",
3426 );
3427
3428 let err = merge(&[a, b], &MergeOptions::default()).expect_err("non-divisible intervals");
3429 assert!(
3430 err.to_string().contains("mismatched epoch intervals"),
3431 "{err}"
3432 );
3433 }
3434
3435 #[test]
3436 fn merge_rejects_a_non_whole_second_common_interval() {
3437 let mk = || {
3440 sp3_two_epochs(
3441 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
3442 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
3443 900.0,
3444 "IGS14",
3445 )
3446 };
3447 let opts = MergeOptions {
3448 target_epoch_interval_s: Some(450.5),
3449 ..MergeOptions::default()
3450 };
3451 let err = merge(&[mk(), mk()], &opts).expect_err("fractional target");
3452 assert!(err.to_string().contains("whole number of seconds"), "{err}");
3453 }
3454
3455 #[test]
3456 fn merge_header_first_epoch_describes_the_union_grid_start() {
3457 let a = sp3_epochs(
3460 0.0,
3461 &[
3462 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
3463 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
3464 &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
3465 ],
3466 900.0,
3467 "IGS14",
3468 );
3469 let b = sp3_epochs(
3470 900.0,
3471 &[
3472 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
3473 &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
3474 &[("G01", [15003.0, -20003.0, 5003.0], Some(103.0))],
3475 ],
3476 900.0,
3477 "IGS14",
3478 );
3479
3480 let opts = MergeOptions {
3481 min_agree: 1,
3482 ..MergeOptions::default()
3483 };
3484 let (merged, _) = merge(&[a, b], &opts).expect("merge");
3485
3486 assert_eq!(
3487 merged.epochs.len(),
3488 4,
3489 "union epochs run from 00:00 to 00:45"
3490 );
3491 assert!(
3492 (merged.header.seconds_of_week - 345_600.0).abs() < 1.0e-6,
3493 "header sow must describe the union's first epoch 00:00 (345600 s), got {}",
3494 merged.header.seconds_of_week
3495 );
3496 assert!(
3497 merged.header.mjd_fraction.abs() < 1.0e-9,
3498 "header MJD fraction must describe 00:00, got {}",
3499 merged.header.mjd_fraction
3500 );
3501 }
3502
3503 #[test]
3504 fn merge_writer_recomputes_header_for_a_fine_union_grid() {
3505 let a = sp3_epochs(
3509 0.0,
3510 &[
3511 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
3512 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
3513 &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
3514 ],
3515 900.0,
3516 "IGS14",
3517 );
3518 let b = sp3_epochs(
3519 450.0,
3520 &[
3521 &[("G01", [15010.0, -20010.0, 5010.0], Some(110.0))],
3522 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
3523 &[("G01", [15011.0, -20011.0, 5011.0], Some(111.0))],
3524 &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
3525 ],
3526 450.0,
3527 "IGS14",
3528 );
3529
3530 let opts = MergeOptions {
3531 min_agree: 1,
3532 ..MergeOptions::default()
3533 };
3534 let (merged, _) = merge(&[a, b], &opts).expect("mixed-cadence merge");
3535
3536 assert_eq!(merged.epochs.len(), 5, "union epochs run every 7.5 minutes");
3537 let text = merged.to_sp3_string();
3538 let header = text
3539 .lines()
3540 .find(|line| line.starts_with("## "))
3541 .expect("written ## header");
3542 let first_epoch = text
3543 .lines()
3544 .find(|line| line.starts_with("* "))
3545 .expect("written first epoch");
3546
3547 assert_eq!(first_epoch, "* 2020 6 25 0 0 0.00000000");
3548 assert_eq!(
3549 header,
3550 "## 2111 345600.00000000 450.00000000 59025 0.0000000000000"
3551 );
3552 }
3553
3554 #[test]
3555 fn precedence_merge_never_switches_source_within_one_satellite_arc() {
3556 let a = sp3_two_epochs(
3557 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
3558 &[],
3559 900.0,
3560 "IGS14",
3561 );
3562 let b = sp3_two_epochs(
3563 &[("G01", [15000.001, -20000.0, 5000.0], Some(100.0))],
3564 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
3565 900.0,
3566 "IGS14",
3567 );
3568 let opts = MergeOptions {
3569 combine: MergeCombine::Precedence,
3570 min_agree: 1,
3571 precedence_scope: MergePrecedenceScope::SatelliteArc,
3572 ..MergeOptions::default()
3573 };
3574
3575 let (merged, _report) = merge(&[a, b], &opts).expect("merge");
3576 let epoch0 = merged.states_at(0).expect("epoch 0");
3577 let epoch1 = merged.states_at(1).expect("epoch 1");
3578
3579 assert!(epoch0.contains_key(&gps(1)));
3580 assert!(
3581 !epoch1.contains_key(&gps(1)),
3582 "G01 must not switch from source 0 at epoch 0 to source 1 at epoch 1"
3583 );
3584 assert_eq!(merged.header.epoch_interval_s, 900.0);
3585 }
3586
3587 #[test]
3588 fn cell_precedence_fills_a_preferred_source_dropout() {
3589 let a = sp3_two_epochs(
3590 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
3591 &[],
3592 900.0,
3593 "IGS14",
3594 );
3595 let b = sp3_two_epochs(
3596 &[("G01", [15000.001, -20000.0, 5000.0], Some(100.0))],
3597 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
3598 900.0,
3599 "IGS14",
3600 );
3601 let opts = MergeOptions {
3602 combine: MergeCombine::Precedence,
3603 min_agree: 1,
3604 ..MergeOptions::default()
3605 };
3606
3607 let (merged, report) = merge(&[a, b], &opts).expect("merge");
3608
3609 assert!(merged.states_at(0).expect("epoch 0").contains_key(&gps(1)));
3610 let epoch1 = merged.states_at(1).expect("epoch 1");
3611 assert!(
3612 epoch1.contains_key(&gps(1)),
3613 "source 1 must fill source 0's dropout"
3614 );
3615 assert_eq!(epoch1[&gps(1)].position.as_array()[0], 15_001_000.0);
3616 assert!(report
3617 .single_source
3618 .iter()
3619 .any(|entry| entry.satellite == gps(1) && entry.sources == vec![1]));
3620 }
3621
3622 #[test]
3623 fn merge_filters_requested_constellations_and_header_satellites() {
3624 let a = sp3_two_epochs(
3625 &[
3626 ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
3627 ("E01", [21000.0, -1000.0, 13000.0], Some(120.0)),
3628 ],
3629 &[
3630 ("G01", [15001.0, -20001.0, 5001.0], Some(101.0)),
3631 ("E01", [21001.0, -1001.0, 13001.0], Some(121.0)),
3632 ],
3633 900.0,
3634 "IGS14",
3635 );
3636 let systems = BTreeSet::from([GnssSystem::Gps]);
3637 let opts = MergeOptions {
3638 systems: Some(systems),
3639 ..MergeOptions::default()
3640 };
3641
3642 let (merged, _report) = merge(&[a], &opts).expect("merge");
3643
3644 assert_eq!(merged.header.satellites, vec![gps(1)]);
3645 for idx in 0..merged.epochs.len() {
3646 let states = merged.states_at(idx).expect("epoch");
3647 assert_eq!(states.keys().copied().collect::<Vec<_>>(), vec![gps(1)]);
3648 }
3649 }
3650
3651 #[test]
3652 fn merge_preserves_a_clock_event_flag() {
3653 let a = sp3_build(
3656 &[(
3657 "G01",
3658 [15000.0, -20000.0, 5000.0],
3659 Some(100.0),
3660 " E",
3661 )],
3662 "IGS14",
3663 );
3664 let b = sp3_build(
3665 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
3666 "IGS14",
3667 );
3668
3669 let (merged, _) = merge(&[a, b], &MergeOptions::default()).expect("merge");
3670 let g01 = merged.states_at(0).expect("epoch 0")[&gps(1)];
3671
3672 assert!(
3673 g01.flags.clock_event,
3674 "merged cell must preserve a contributing source's clock-event flag"
3675 );
3676 }
3677
3678 #[test]
3679 fn merge_reports_effective_epoch_interval_from_actual_epochs() {
3680 let body = "#cP2020 6 25 0 0 0.00000000 2 ORBIT IGS14 FIT TST\n\
3684 ## 2111 432000.00000000 300.00000000 59025 0.0000000000000\n\
3685 + 1 G01 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
3686 ++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
3687 %c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3688 %c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3689 %f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n\
3690 %f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n\
3691 %i 0 0 0 0 0 0 0 0 0\n\
3692 %i 0 0 0 0 0 0 0 0 0\n\
3693 /* TEST SP3-c FIXTURE\n\
3694 * 2020 6 25 0 0 0.00000000\n\
3695 PG01 15000.000000 -20000.000000 5000.000000 100.000000\n\
3696 * 2020 6 25 0 15 0.00000000\n\
3697 PG01 15001.000000 -20001.000000 5001.000000 101.000000\n\
3698 EOF\n";
3699 let a = Sp3::parse(body.as_bytes()).expect("parse test sp3");
3700
3701 let (merged, _) = merge(&[a], &MergeOptions::default()).expect("merge");
3702
3703 assert!(
3704 (merged.header.epoch_interval_s - 900.0).abs() < 1.0e-6,
3705 "got {}",
3706 merged.header.epoch_interval_s
3707 );
3708 }
3709
3710 #[test]
3711 fn merge_rejects_unsorted_input_epochs_before_cadence_inference() {
3712 let body = "#cP2020 6 25 0 0 0.00000000 2 ORBIT IGS14 FIT TST\n\
3713 ## 2111 432000.00000000 900.00000000 59025 0.0000000000000\n\
3714 + 1 G01 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
3715 ++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
3716 %c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3717 %c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3718 %f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n\
3719 %f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n\
3720 %i 0 0 0 0 0 0 0 0 0\n\
3721 %i 0 0 0 0 0 0 0 0 0\n\
3722 /* TEST SP3-c FIXTURE\n\
3723 * 2020 6 25 0 15 0.00000000\n\
3724 PG01 15001.000000 -20001.000000 5001.000000 101.000000\n\
3725 * 2020 6 25 0 0 0.00000000\n\
3726 PG01 15000.000000 -20000.000000 5000.000000 100.000000\n\
3727 EOF\n";
3728 let source = Sp3::parse(body.as_bytes()).expect("parse unsorted test sp3");
3729
3730 let err = merge(&[source], &MergeOptions::default()).expect_err("unsorted epochs");
3731
3732 assert!(
3733 err.to_string()
3734 .contains("merge input epochs must be strictly increasing"),
3735 "{err}"
3736 );
3737 }
3738
3739 #[test]
3740 fn align_clock_reference_puts_other_on_the_reference_datum() {
3741 let reference = sp3([100.0, 200.0, 300.0]);
3744 let other = sp3([150.0, 250.0, 350.0]);
3745
3746 let aligned = align_clock_reference(&reference, &other, 3);
3747
3748 let g01 = aligned.states_at(0).expect("epoch 0")[&gps(1)];
3749 assert!(
3750 (g01.clock_s.unwrap() - 100.0e-6).abs() < 1.0e-15,
3751 "got {}",
3752 g01.clock_s.unwrap()
3753 );
3754 let original = other.states_at(0).expect("epoch 0")[&gps(1)];
3756 assert_eq!(g01.position.as_array(), original.position.as_array());
3757 }
3758
3759 fn sp3(clocks_us: [f64; 3]) -> Sp3 {
3763 let body = format!(
3764 "#cP2020 6 25 0 0 0.00000000 1 ORBIT IGS14 FIT TST\n\
3765 ## 2111 432000.00000000 900.00000000 59025 0.0000000000000\n\
3766 + 3 G01G02G03 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
3767 ++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
3768 %c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3769 %c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3770 %f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n\
3771 %f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n\
3772 %i 0 0 0 0 0 0 0 0 0\n\
3773 %i 0 0 0 0 0 0 0 0 0\n\
3774 /* TEST SP3-c FIXTURE\n\
3775 * 2020 6 25 0 0 0.00000000\n\
3776 PG01 15000.000000 -20000.000000 5000.000000 {:13.6}\n\
3777 PG02 -1234.567890 2345.678901 -3456.789012 {:13.6}\n\
3778 PG03 8000.000000 12000.000000 -19000.000000 {:13.6}\n\
3779 EOF\n",
3780 clocks_us[0], clocks_us[1], clocks_us[2]
3781 );
3782 Sp3::parse(body.as_bytes()).expect("parse test sp3")
3783 }
3784
3785 #[test]
3786 fn recovers_a_uniform_datum_shift() {
3787 let reference = sp3([100.0, 200.0, 300.0]);
3789 let other = sp3([150.0, 250.0, 350.0]);
3790
3791 let offsets = clock_reference_offset(&reference, &other, 3);
3792
3793 assert_eq!(offsets.len(), 1);
3794 assert_eq!(offsets[0].satellites, 3);
3795 assert!(
3796 (offsets[0].offset_s - 5.0e-5).abs() < 1.0e-12,
3797 "got {}",
3798 offsets[0].offset_s
3799 );
3800 }
3801
3802 #[test]
3803 fn median_rejects_a_single_outlier_clock() {
3804 let reference = sp3([100.0, 200.0, 300.0]);
3807 let other = sp3([150.0, 250.0, 9_300.0]);
3808
3809 let offsets = clock_reference_offset(&reference, &other, 3);
3810
3811 assert_eq!(offsets.len(), 1);
3812 assert!(
3813 (offsets[0].offset_s - 5.0e-5).abs() < 1.0e-12,
3814 "got {}",
3815 offsets[0].offset_s
3816 );
3817 }
3818
3819 #[test]
3820 fn omits_epochs_below_min_common() {
3821 let reference = sp3([100.0, 200.0, 300.0]);
3824 let other = sp3([150.0, 250.0, 350.0]);
3825
3826 assert!(clock_reference_offset(&reference, &other, 4).is_empty());
3827 }
3828
3829 #[test]
3830 fn merge_agreement_metric_reports_known_position_dispersion() {
3831 let a = sp3_records(&[("G01", [15000.000, -20000.0, 5000.0], Some(100.0))]);
3836 let b = sp3_records(&[("G01", [15000.003, -20000.0, 5000.0], Some(100.0))]);
3837 let c = sp3_records(&[("G01", [15000.006, -20000.0, 5000.0], Some(100.0))]);
3838 let opts = MergeOptions {
3839 position_tolerance_m: 10.0,
3840 min_agree: 3,
3841 combine: MergeCombine::Mean,
3842 ..MergeOptions::default()
3843 };
3844
3845 let (_merged, report) = merge(&[a, b, c], &opts).expect("merge");
3846
3847 assert_eq!(report.agreement.len(), 1, "one accepted cell");
3848 let m = report.agreement[0];
3849 assert_eq!(m.satellite, gps(1));
3850 assert_eq!(m.position_members, 3);
3851 assert!(
3852 (m.position_rms_m - 6.0_f64.sqrt()).abs() < 1.0e-6,
3853 "got rms {}",
3854 m.position_rms_m
3855 );
3856 assert!(
3857 (m.position_max_m - 3.0).abs() < 1.0e-6,
3858 "got max {}",
3859 m.position_max_m
3860 );
3861
3862 assert!((report.position_agreement_rms_m().unwrap() - 6.0_f64.sqrt()).abs() < 1.0e-6);
3864 assert!((report.position_agreement_max_m().unwrap() - 3.0).abs() < 1.0e-6);
3865
3866 let per_epoch = report.per_epoch_agreement();
3868 assert_eq!(per_epoch.len(), 1);
3869 assert_eq!(per_epoch[0].satellites, 1);
3870 assert!((per_epoch[0].position_rms_m - 6.0_f64.sqrt()).abs() < 1.0e-6);
3871 assert!((per_epoch[0].position_max_m - 3.0).abs() < 1.0e-6);
3872 }
3873
3874 #[test]
3875 fn merge_agreement_metric_reports_known_clock_dispersion() {
3876 let a = sp3([100.0, 200.0, 300.0]);
3882 let b = sp3([100.0, 200.0, 330.0]);
3883 let c = sp3([100.0, 200.0, 270.0]);
3884 let opts = MergeOptions {
3885 clock_min_common: 1,
3886 clock_tolerance_s: 1.0e-3,
3887 min_agree: 3,
3888 combine: MergeCombine::Mean,
3889 ..MergeOptions::default()
3890 };
3891
3892 let (_merged, report) = merge(&[a, b, c], &opts).expect("merge");
3893
3894 let g03 = report
3895 .agreement
3896 .iter()
3897 .find(|m| m.satellite == gps(3))
3898 .expect("G03 agreement metric");
3899 assert_eq!(g03.clock_members, 3);
3900 let expected_rms_s = 600.0_f64.sqrt() * 1.0e-6;
3901 assert!(
3902 (g03.clock_rms_s.unwrap() - expected_rms_s).abs() < 1.0e-15,
3903 "got clock rms {:?}",
3904 g03.clock_rms_s
3905 );
3906 assert!(
3907 (g03.clock_max_s.unwrap() - 30.0e-6).abs() < 1.0e-15,
3908 "got clock max {:?}",
3909 g03.clock_max_s
3910 );
3911 for prn in [1u8, 2] {
3913 let m = report
3914 .agreement
3915 .iter()
3916 .find(|m| m.satellite == gps(prn))
3917 .expect("metric");
3918 assert!(m.clock_rms_s.unwrap().abs() < 1.0e-18, "prn {prn}");
3919 assert!(m.position_rms_m.abs() < 1.0e-9, "prn {prn}");
3921 }
3922
3923 let pooled = report.clock_agreement_rms_s().expect("clock pool");
3927 assert!(
3928 (pooled - expected_rms_s / 3.0_f64.sqrt()).abs() < 1.0e-15,
3929 "got pooled {pooled}"
3930 );
3931 assert!((report.clock_agreement_max_s().unwrap() - 30.0e-6).abs() < 1.0e-15);
3932 }
3933
3934 #[cfg(sidereon_repo_tests)]
3962 #[test]
3963 fn merge_agrees_with_published_igs_combined_within_cm() {
3964 fn load(name: &str) -> Sp3 {
3965 let path = format!("{}/tests/fixtures/sp3/{}", env!("CARGO_MANIFEST_DIR"), name);
3966 let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("read {path}: {e}"));
3967 Sp3::parse(&bytes).unwrap_or_else(|e| panic!("parse {name}: {e}"))
3968 }
3969
3970 let cod = load("COD0OPSFIN_20261200945_02H30M_15M_ORB_trim.SP3");
3971 let gfz = load("GFZ0OPSFIN_20261200945_02H30M_15M_ORB_trim.SP3");
3972 let jpl = load("JPL0OPSFIN_20261200945_02H30M_15M_ORB_trim.SP3");
3973 let igs = load("IGS0OPSFIN_20261200945_02H30M_15M_ORB.SP3");
3974
3975 let (merged, report) =
3976 merge(&[cod, gfz, jpl], &MergeOptions::default()).expect("multi-center merge");
3977
3978 assert!(
3981 report.quarantined.is_empty(),
3982 "centers should agree: {:?}",
3983 report.quarantined
3984 );
3985 assert!(
3988 report.single_source.is_empty(),
3989 "{:?}",
3990 report.single_source
3991 );
3992 assert!(
3993 report.position_outliers.is_empty(),
3994 "{:?}",
3995 report.position_outliers
3996 );
3997 assert!(
3998 report.agreement.iter().all(|a| a.position_members == 3),
3999 "every agreement cell should be a 3-source consensus"
4000 );
4001
4002 let mut igs_idx: std::collections::BTreeMap<i64, usize> = std::collections::BTreeMap::new();
4003 for (i, ep) in igs.epochs.iter().enumerate() {
4004 if let Some(s) = super::instant_to_j2000_seconds(ep) {
4005 igs_idx.insert(s.floor() as i64, i);
4006 }
4007 }
4008
4009 let mut sumsq = 0.0_f64;
4010 let mut max = 0.0_f64;
4011 let mut n = 0usize;
4012 for (mi, ep) in merged.epochs.iter().enumerate() {
4013 let key = super::instant_to_j2000_seconds(ep)
4014 .expect("merged epoch key")
4015 .floor() as i64;
4016 let ii = *igs_idx.get(&key).expect("IGS combined covers merged epoch");
4017 let merged_states = merged.states_at(mi).expect("merged states");
4018 let igs_states = igs.states_at(ii).expect("IGS states");
4019 for (sat, mst) in merged_states.iter() {
4020 let ist = igs_states
4021 .get(sat)
4022 .unwrap_or_else(|| panic!("merged sat {sat} missing from IGS combined"));
4023 let d = super::dist3(&mst.position.as_array(), &ist.position.as_array());
4024 sumsq += d * d;
4025 max = max.max(d);
4026 n += 1;
4027 }
4028 }
4029
4030 assert_eq!(n, 88, "expected exactly 88 compared cells, got {n}");
4033 let rms = (sumsq / n as f64).sqrt();
4034 assert!(
4036 rms < 0.02,
4037 "combine-vs-IGS RMS {:.4} m ({} cells) exceeds the 2 cm gate",
4038 rms,
4039 n
4040 );
4041 assert!(
4042 max < 0.05,
4043 "combine-vs-IGS max {max:.4} m exceeds the 5 cm gate"
4044 );
4045
4046 let dispersion = report
4048 .position_agreement_rms_m()
4049 .expect("multi-source cells present");
4050 assert!(
4051 dispersion < 0.05,
4052 "inter-center position dispersion {dispersion:.4} m"
4053 );
4054 }
4055}