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