1use std::collections::{BTreeMap, BTreeSet};
17
18use crate::astro::math::vec3;
19use crate::astro::time::civil::{
20 civil_from_julian_day_number, fractional_day_of_year_from_instant, is_leap_year,
21 julian_date_from_instant, mjd_from_jd,
22};
23use crate::astro::time::gnss;
24use crate::astro::time::model::Instant;
25
26use super::interp::{instant_to_j2000_seconds, sp3_epoch_j2000_seconds};
27use super::{RawNode, Sp3, Sp3DataType, Sp3Flags, Sp3Header, Sp3State};
28use crate::constants::{DAYS_PER_JULIAN_YEAR, GPS_EPOCH_TO_J2000_S, KM_TO_M, SECONDS_PER_DAY};
29use crate::frame::{ItrfPositionM, ItrfVelocityMS};
30use crate::frame_catalog::{
31 self, HelmertParameters, HelmertRates, TerrestrialFrame, TerrestrialPositionM,
32 TerrestrialVelocityMPerYear,
33};
34use crate::id::{GnssSatelliteId, GnssSystem};
35use crate::tolerances::WHOLE_SECOND_EPS_S;
36use crate::validate;
37use crate::{Error, Result};
38
39const MAX_EXACT_CLIQUE_NODES: usize = 32;
40
41#[derive(Debug, Clone, Copy, PartialEq)]
43pub struct ClockReferenceOffset {
44 pub epoch: Instant,
46 pub offset_s: f64,
50 pub satellites: usize,
52}
53
54pub fn clock_reference_offset(
80 reference: &Sp3,
81 other: &Sp3,
82 min_common: usize,
83) -> Vec<ClockReferenceOffset> {
84 let mut other_index: std::collections::HashMap<i64, usize> = std::collections::HashMap::new();
85 for (idx, epoch) in other.epochs.iter().enumerate() {
86 if let Some(seconds) = sp3_epoch_j2000_seconds(other, idx, epoch) {
87 other_index.insert(seconds.floor() as i64, idx);
88 }
89 }
90
91 let mut offsets = Vec::new();
92
93 for (ref_idx, epoch) in reference.epochs.iter().enumerate() {
94 let Some(ref_seconds) = sp3_epoch_j2000_seconds(reference, ref_idx, epoch) else {
95 continue;
96 };
97 let Some(&other_idx) = other_index.get(&(ref_seconds.floor() as i64)) else {
98 continue;
99 };
100
101 let (Ok(ref_states), Ok(other_states)) =
102 (reference.states_at(ref_idx), other.states_at(other_idx))
103 else {
104 continue;
105 };
106
107 let mut diffs: Vec<f64> = Vec::new();
108 for (sat, ref_state) in ref_states.iter() {
109 let Some(ref_clock) = ref_state.clock_s else {
110 continue;
111 };
112 if let Some(other_state) = other_states.get(sat) {
113 if let Some(other_clock) = other_state.clock_s {
114 let diff = other_clock - ref_clock;
115 if diff.is_finite() {
118 diffs.push(diff);
119 }
120 }
121 }
122 }
123
124 if diffs.len() >= min_common.max(1) {
125 if let Some(offset_s) = median(&mut diffs) {
126 offsets.push(ClockReferenceOffset {
127 epoch: *epoch,
128 offset_s,
129 satellites: diffs.len(),
130 });
131 }
132 }
133 }
134
135 offsets
136}
137
138fn median(values: &mut [f64]) -> Option<f64> {
139 crate::astro::math::robust::median_sorting_in_place(values)
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum MergeCombine {
151 Mean,
154 Median,
156 Precedence,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum MergePrecedenceScope {
163 Cell,
166 SatelliteArc,
169}
170
171#[derive(Debug, Clone, Copy, PartialEq)]
181pub struct OutlierRejectOptions {
182 pub position_tolerance_m: f64,
184 pub clock_tolerance_s: f64,
186}
187
188#[derive(Debug, Clone, PartialEq)]
190pub struct MergeOptions {
191 pub position_tolerance_m: f64,
194 pub clock_tolerance_s: f64,
197 pub min_agree: usize,
203 pub clock_min_common: usize,
206 pub combine: MergeCombine,
208 pub precedence_scope: MergePrecedenceScope,
211 pub outlier_reject: Option<OutlierRejectOptions>,
214 pub target_epoch_interval_s: Option<f64>,
219 pub systems: Option<BTreeSet<GnssSystem>>,
222 pub frame_reconciliation: Sp3FrameReconciliationOptions,
225}
226
227impl Default for MergeOptions {
228 fn default() -> Self {
231 Self {
232 position_tolerance_m: 0.5,
233 clock_tolerance_s: 5.0e-9,
234 min_agree: 2,
235 clock_min_common: 5,
236 combine: MergeCombine::Mean,
237 precedence_scope: MergePrecedenceScope::Cell,
238 outlier_reject: None,
239 target_epoch_interval_s: None,
240 systems: None,
241 frame_reconciliation: Sp3FrameReconciliationOptions::default(),
242 }
243 }
244}
245
246#[derive(Debug, Clone, Default, PartialEq, Eq)]
248pub struct Sp3FrameReconciliationOptions {
249 pub asserted_equivalent_label_sets: Vec<Sp3FrameLabelSet>,
252 pub helmert: bool,
255}
256
257impl Sp3FrameReconciliationOptions {
258 pub const fn disabled() -> Self {
260 Self {
261 asserted_equivalent_label_sets: Vec::new(),
262 helmert: false,
263 }
264 }
265
266 pub const fn helmert() -> Self {
268 Self {
269 asserted_equivalent_label_sets: Vec::new(),
270 helmert: true,
271 }
272 }
273}
274
275#[derive(Debug, Clone, PartialEq, Eq)]
278pub struct Sp3FrameLabelSet {
279 pub labels: BTreeSet<String>,
281}
282
283impl Sp3FrameLabelSet {
284 pub fn new(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
286 Self {
287 labels: labels
288 .into_iter()
289 .map(|label| label.into().trim().to_string())
290 .collect(),
291 }
292 }
293
294 pub fn pair(a: impl Into<String>, b: impl Into<String>) -> Self {
296 Self::new([a.into(), b.into()])
297 }
298}
299
300#[derive(Debug, Clone, PartialEq)]
303pub struct MergeFlag {
304 pub epoch: Instant,
306 pub satellite: GnssSatelliteId,
308 pub sources: Vec<usize>,
313}
314
315#[derive(Debug, Clone, Copy, PartialEq)]
324pub struct AgreementMetric {
325 pub epoch: Instant,
327 pub satellite: GnssSatelliteId,
329 pub position_members: usize,
331 pub position_rms_m: f64,
334 pub position_max_m: f64,
337 pub clock_members: usize,
340 pub clock_rms_s: Option<f64>,
343 pub clock_max_s: Option<f64>,
346}
347
348#[derive(Debug, Clone, Copy, PartialEq)]
352pub struct EpochAgreement {
353 pub epoch: Instant,
355 pub satellites: usize,
357 pub position_rms_m: f64,
361 pub position_max_m: f64,
363 pub clock_rms_s: Option<f64>,
366 pub clock_max_s: Option<f64>,
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq)]
372pub enum Sp3FrameReconciliationMethod {
373 AssertedEquivalence,
376 Helmert,
379}
380
381#[derive(Debug, Clone, PartialEq)]
383pub struct Sp3FrameReconciliation {
384 pub source_index: usize,
386 pub source_label: String,
388 pub target_label: String,
390 pub method: Sp3FrameReconciliationMethod,
392 pub asserted_label_set: Option<Vec<String>>,
394 pub source_frame: Option<TerrestrialFrame>,
396 pub target_frame: Option<TerrestrialFrame>,
398 pub catalog_source_frame: Option<TerrestrialFrame>,
401 pub catalog_target_frame: Option<TerrestrialFrame>,
404 pub catalog_inverse: bool,
406 pub reference_epoch_year: Option<f64>,
409 pub parameters: Option<HelmertParameters>,
412 pub rates: Option<HelmertRates>,
414 pub provenance: Option<String>,
416 pub epoch_year_span: Option<[f64; 2]>,
419 pub records_affected: usize,
421 pub identity: bool,
424}
425
426#[derive(Debug, Clone, Default, PartialEq)]
428pub struct MergeReport {
429 pub frame_reconciliations: Vec<Sp3FrameReconciliation>,
431 pub quarantined: Vec<MergeFlag>,
434 pub single_source: Vec<MergeFlag>,
436 pub position_outliers: Vec<MergeFlag>,
439 pub clock_outliers: Vec<MergeFlag>,
442 pub agreement: Vec<AgreementMetric>,
447}
448
449impl MergeReport {
450 pub fn single_source_fraction(&self) -> Option<f64> {
460 let accepted = self.agreement.len();
461 (accepted > 0).then(|| self.single_source.len() as f64 / accepted as f64)
462 }
463
464 pub fn position_agreement_rms_m(&self) -> Option<f64> {
478 pooled_rms(
479 self.agreement
480 .iter()
481 .filter(|m| m.position_members >= 2)
482 .map(|m| (m.position_rms_m, m.position_members)),
483 )
484 }
485
486 pub fn position_agreement_max_m(&self) -> Option<f64> {
489 self.agreement
490 .iter()
491 .map(|m| m.position_max_m)
492 .fold(None, |acc, v| Some(fold_max(acc, v)))
493 }
494
495 pub fn clock_agreement_rms_s(&self) -> Option<f64> {
497 pooled_rms(self.agreement.iter().filter_map(|m| {
498 m.clock_rms_s
499 .filter(|_| m.clock_members >= 2)
500 .map(|rms| (rms, m.clock_members))
501 }))
502 }
503
504 pub fn clock_agreement_max_s(&self) -> Option<f64> {
506 self.agreement
507 .iter()
508 .filter_map(|m| m.clock_max_s)
509 .fold(None, |acc, v| Some(fold_max(acc, v)))
510 }
511
512 pub fn per_epoch_agreement(&self) -> Vec<EpochAgreement> {
517 let mut out: Vec<EpochAgreement> = Vec::new();
518 let mut current_key: Option<i64> = None;
519 for m in &self.agreement {
520 let key = instant_to_j2000_seconds(&m.epoch).map(|s| s.floor() as i64);
521 if current_key != key || out.is_empty() {
522 out.push(EpochAgreement {
523 epoch: m.epoch,
524 satellites: 0,
525 position_rms_m: 0.0,
526 position_max_m: 0.0,
527 clock_rms_s: None,
528 clock_max_s: None,
529 });
530 current_key = key;
531 }
532 let agg = out.last_mut().expect("just pushed");
533 agg.position_max_m = agg.position_max_m.max(m.position_max_m);
534 if m.position_members >= 2 {
535 agg.satellites += 1;
536 }
537 if let Some(max) = m.clock_max_s.filter(|_| m.clock_members >= 2) {
541 agg.clock_max_s = Some(fold_max(agg.clock_max_s, max));
542 }
543 }
544
545 for agg in &mut out {
548 let key = instant_to_j2000_seconds(&agg.epoch).map(|s| s.floor() as i64);
549 agg.position_rms_m = pooled_rms(
550 self.agreement
551 .iter()
552 .filter(|m| {
553 m.position_members >= 2
554 && instant_to_j2000_seconds(&m.epoch).map(|s| s.floor() as i64) == key
555 })
556 .map(|m| (m.position_rms_m, m.position_members)),
557 )
558 .unwrap_or(0.0);
559 agg.clock_rms_s = pooled_rms(
560 self.agreement
561 .iter()
562 .filter(|m| instant_to_j2000_seconds(&m.epoch).map(|s| s.floor() as i64) == key)
563 .filter_map(|m| {
564 m.clock_rms_s
565 .filter(|_| m.clock_members >= 2)
566 .map(|rms| (rms, m.clock_members))
567 }),
568 );
569 }
570
571 out
572 }
573}
574
575fn pooled_rms(cells: impl Iterator<Item = (f64, usize)>) -> Option<f64> {
578 let mut sumsq = 0.0_f64;
579 let mut total = 0_usize;
580 for (rms, n) in cells {
581 sumsq += rms * rms * n as f64;
582 total += n;
583 }
584 (total > 0).then(|| (sumsq / total as f64).sqrt())
585}
586
587fn fold_max(acc: Option<f64>, value: f64) -> f64 {
589 match acc {
590 Some(current) if current >= value => current,
591 _ => value,
592 }
593}
594
595pub fn merge(sources: &[Sp3], opts: &MergeOptions) -> Result<(Sp3, MergeReport)> {
648 if sources.is_empty() {
649 return Err(Error::InvalidInput(
650 "merge requires at least one SP3 product".into(),
651 ));
652 }
653
654 validate_merge_options(opts)?;
655
656 let base = &sources[0].header;
661 for s in &sources[1..] {
662 if s.header.time_system != base.time_system {
663 return Err(Error::InvalidInput(format!(
664 "merge inputs have mismatched SP3 time systems ({:?} vs {:?})",
665 base.time_system, s.header.time_system
666 )));
667 }
668 }
669
670 let (prepared_sources, frame_reconciliations) = reconcile_sp3_coordinate_labels(sources, opts)?;
671 let sources = prepared_sources.as_slice();
672
673 let epoch_index: Vec<BTreeMap<i64, usize>> = sources
675 .iter()
676 .map(|s| {
677 s.epochs
678 .iter()
679 .enumerate()
680 .filter_map(|(i, ep)| {
681 sp3_epoch_j2000_seconds(s, i, ep).map(|sec| (sec.floor() as i64, i))
682 })
683 .collect()
684 })
685 .collect();
686
687 let epoch_interval_s = resolve_common_epoch_interval(sources, opts.target_epoch_interval_s)?;
688
689 let clock_offset: Vec<BTreeMap<i64, f64>> = sources
692 .iter()
693 .enumerate()
694 .map(|(idx, s)| {
695 if idx == 0 {
696 BTreeMap::new()
697 } else {
698 clock_reference_offset(&sources[0], s, opts.clock_min_common)
699 .into_iter()
700 .filter_map(|o| {
701 instant_to_j2000_seconds(&o.epoch)
702 .map(|sec| (sec.floor() as i64, o.offset_s))
703 })
704 .collect()
705 }
706 })
707 .collect();
708
709 let mut epoch_keys: BTreeMap<i64, Instant> = BTreeMap::new();
713 for source in sources {
714 for (idx, ep) in source.epochs.iter().enumerate() {
715 if let Some(sec) = sp3_epoch_j2000_seconds(source, idx, ep) {
716 epoch_keys.entry(sec.floor() as i64).or_insert(*ep);
717 }
718 }
719 }
720
721 if let Some((&anchor, _)) = epoch_keys.iter().next() {
726 let step = epoch_interval_s.round() as i64;
727 if step > 0 {
728 epoch_keys.retain(|&key, _| (key - anchor).rem_euclid(step) == 0);
729 }
730 }
731
732 if epoch_keys.is_empty() {
733 return Err(Error::InvalidInput(
734 "merge inputs have no epochs on the requested time grid".into(),
735 ));
736 }
737
738 let precedence_source_for_sat = if opts.combine == MergeCombine::Precedence
739 && opts.precedence_scope == MergePrecedenceScope::SatelliteArc
740 {
741 Some(precedence_sources_for_satellites(
742 sources,
743 &epoch_index,
744 &epoch_keys,
745 opts.systems.as_ref(),
746 ))
747 } else {
748 None
749 };
750
751 let allowed_system = |sat: &GnssSatelliteId| {
752 opts.systems
753 .as_ref()
754 .is_none_or(|systems| systems.contains(&sat.system))
755 };
756
757 let mut out_epochs: Vec<Instant> = Vec::with_capacity(epoch_keys.len());
758 let mut out_epoch_j2000_s: Vec<f64> = Vec::with_capacity(epoch_keys.len());
759 let mut out_states: Vec<BTreeMap<GnssSatelliteId, Sp3State>> =
760 Vec::with_capacity(epoch_keys.len());
761 let mut out_raw: Vec<BTreeMap<GnssSatelliteId, RawNode>> = Vec::with_capacity(epoch_keys.len());
762 let mut report = MergeReport {
763 frame_reconciliations,
764 ..MergeReport::default()
765 };
766 let mut all_sats: BTreeSet<GnssSatelliteId> = BTreeSet::new();
767
768 for (&key, &epoch) in &epoch_keys {
769 out_epochs.push(epoch);
770 out_epoch_j2000_s.push(key as f64);
771 let mut states: BTreeMap<GnssSatelliteId, Sp3State> = BTreeMap::new();
772 let mut raws: BTreeMap<GnssSatelliteId, RawNode> = BTreeMap::new();
773
774 let mut sats: BTreeSet<GnssSatelliteId> = BTreeSet::new();
777 for (idx, s) in sources.iter().enumerate() {
778 if let Some(&ei) = epoch_index[idx].get(&key) {
779 if let Ok(map) = s.states_at(ei) {
780 sats.extend(map.keys().copied().filter(|sat| allowed_system(sat)));
781 }
782 }
783 }
784
785 for sat in sats {
786 let arc_preferred_source = precedence_source_for_sat
792 .as_ref()
793 .and_then(|by_sat| by_sat.get(&sat).copied());
794
795 let mut pos: Vec<(usize, [f64; 3], Sp3Flags)> = Vec::new();
796 let mut clk: Vec<(usize, f64, Sp3Flags)> = Vec::new();
797 for (idx, s) in sources.iter().enumerate() {
798 let Some(&ei) = epoch_index[idx].get(&key) else {
799 continue;
800 };
801 let Ok(map) = s.states_at(ei) else { continue };
802 let Some(state) = map.get(&sat) else { continue };
803 pos.push((idx, state.position.as_array(), state.flags));
804 if let Some(c) = state.clock_s {
805 let offset = if idx == 0 {
806 Some(0.0)
807 } else {
808 clock_offset_at(&clock_offset[idx], key)
809 };
810 if let Some(off) = offset {
811 let aligned = c - off;
812 if aligned.is_finite() {
813 clk.push((idx, aligned, state.flags));
814 }
815 }
816 }
817 }
818
819 let position_preferred_source = match opts.precedence_scope {
820 MergePrecedenceScope::Cell => pos.first().map(|(source, _, _)| *source),
821 MergePrecedenceScope::SatelliteArc => arc_preferred_source,
822 };
823 let clock_preferred_source = match opts.precedence_scope {
824 MergePrecedenceScope::Cell => clk.first().map(|(source, _, _)| *source),
825 MergePrecedenceScope::SatelliteArc => arc_preferred_source,
826 };
827
828 let flag = |srcs: Vec<usize>| MergeFlag {
829 epoch,
830 satellite: sat,
831 sources: srcs,
832 };
833
834 let (position_m, pos_members) = if opts.combine == MergeCombine::Precedence {
839 let Some(preferred_source) = position_preferred_source else {
840 continue;
841 };
842 let Some(preferred_idx) =
843 pos.iter().position(|(src, _, _)| *src == preferred_source)
844 else {
845 continue;
846 };
847
848 if pos.len() == 1 {
849 report.single_source.push(flag(vec![pos[preferred_idx].0]));
850 (pos[preferred_idx].1, vec![preferred_idx])
851 } else if let Some(reject) = opts.outlier_reject {
852 let pts: Vec<[f64; 3]> = pos.iter().map(|(_, p, _)| *p).collect();
853 let cluster =
854 largest_within(&pts, |a, b| dist3(a, b) <= reject.position_tolerance_m);
855 if cluster.len() >= opts.min_agree.max(2) {
856 let selected_idx = if cluster.contains(&preferred_idx) {
857 preferred_idx
858 } else {
859 cluster[0]
860 };
861 let rejected: Vec<usize> = (0..pos.len())
862 .filter(|i| !cluster.contains(i))
863 .map(|i| pos[i].0)
864 .collect();
865 if !rejected.is_empty() {
866 report.position_outliers.push(flag(rejected));
867 }
868 (pos[selected_idx].1, cluster)
869 } else {
870 report
871 .quarantined
872 .push(flag(pos.iter().map(|(i, _, _)| *i).collect()));
873 continue;
874 }
875 } else {
876 let pts: Vec<[f64; 3]> = pos.iter().map(|(_, p, _)| *p).collect();
877 let cluster = largest_within_containing(&pts, preferred_idx, |a, b| {
878 dist3(a, b) <= opts.position_tolerance_m
879 });
880 if cluster.len() >= opts.min_agree {
881 let rejected: Vec<usize> = (0..pos.len())
882 .filter(|i| !cluster.contains(i))
883 .map(|i| pos[i].0)
884 .collect();
885 if !rejected.is_empty() {
886 report.position_outliers.push(flag(rejected));
887 }
888 (pos[preferred_idx].1, cluster)
889 } else {
890 report
891 .quarantined
892 .push(flag(pos.iter().map(|(i, _, _)| *i).collect()));
893 continue;
894 }
895 }
896 } else if pos.len() == 1 {
897 report.single_source.push(flag(vec![pos[0].0]));
898 (pos[0].1, vec![0usize])
899 } else {
900 let pts: Vec<[f64; 3]> = pos.iter().map(|(_, p, _)| *p).collect();
901 let cluster = largest_within(&pts, |a, b| dist3(a, b) <= opts.position_tolerance_m);
902 if cluster.len() >= opts.min_agree {
903 let rejected: Vec<usize> = (0..pos.len())
904 .filter(|i| !cluster.contains(i))
905 .map(|i| pos[i].0)
906 .collect();
907 if !rejected.is_empty() {
908 report.position_outliers.push(flag(rejected));
909 }
910 let members: Vec<(usize, [f64; 3])> =
911 cluster.iter().map(|&i| (pos[i].0, pos[i].1)).collect();
912 (combine3(&members, opts.combine), cluster)
913 } else {
914 report
915 .quarantined
916 .push(flag(pos.iter().map(|(i, _, _)| *i).collect()));
917 continue;
918 }
919 };
920
921 let (clock_s, clk_members): (Option<f64>, Vec<usize>) = if clk.is_empty() {
924 (None, Vec::new())
925 } else if opts.combine == MergeCombine::Precedence {
926 match clock_preferred_source
927 .and_then(|src| clk.iter().position(|(clock_src, _, _)| *clock_src == src))
928 {
929 None => (None, Vec::new()),
930 Some(preferred_idx) if clk.len() == 1 => {
931 (Some(clk[preferred_idx].1), vec![preferred_idx])
932 }
933 Some(preferred_idx) if opts.outlier_reject.is_some() => {
934 let reject = opts.outlier_reject.expect("checked above");
935 let vals: Vec<f64> = clk.iter().map(|(_, c, _)| *c).collect();
936 let cluster =
937 largest_within(&vals, |a, b| (a - b).abs() <= reject.clock_tolerance_s);
938 if cluster.len() >= opts.min_agree.max(2) {
939 let selected_idx = if cluster.contains(&preferred_idx) {
940 preferred_idx
941 } else {
942 cluster[0]
943 };
944 let rejected: Vec<usize> = (0..clk.len())
945 .filter(|i| !cluster.contains(i))
946 .map(|i| clk[i].0)
947 .collect();
948 if !rejected.is_empty() {
949 report.clock_outliers.push(flag(rejected));
950 }
951 (Some(clk[selected_idx].1), cluster)
952 } else {
953 report
954 .clock_outliers
955 .push(flag(clk.iter().map(|(source, _, _)| *source).collect()));
956 (None, Vec::new())
957 }
958 }
959 Some(preferred_idx) => {
960 let vals: Vec<f64> = clk.iter().map(|(_, c, _)| *c).collect();
961 let cluster = largest_within_containing(&vals, preferred_idx, |a, b| {
962 (a - b).abs() <= opts.clock_tolerance_s
963 });
964 if cluster.len() >= opts.min_agree {
965 let rejected: Vec<usize> = (0..clk.len())
966 .filter(|i| !cluster.contains(i))
967 .map(|i| clk[i].0)
968 .collect();
969 if !rejected.is_empty() {
970 report.clock_outliers.push(flag(rejected));
971 }
972 (Some(clk[preferred_idx].1), cluster)
973 } else {
974 (None, Vec::new())
975 }
976 }
977 }
978 } else if clk.len() == 1 {
979 (Some(clk[0].1), vec![0usize])
980 } else {
981 let vals: Vec<f64> = clk.iter().map(|(_, c, _)| *c).collect();
982 let cluster = largest_within(&vals, |a, b| (a - b).abs() <= opts.clock_tolerance_s);
983 if cluster.len() >= opts.min_agree {
984 let rejected: Vec<usize> = (0..clk.len())
985 .filter(|i| !cluster.contains(i))
986 .map(|i| clk[i].0)
987 .collect();
988 if !rejected.is_empty() {
989 report.clock_outliers.push(flag(rejected));
990 }
991 let members: Vec<(usize, f64)> =
992 cluster.iter().map(|&i| (clk[i].0, clk[i].1)).collect();
993 (Some(combine_axis(&members, opts.combine)), cluster)
994 } else {
995 (None, Vec::new())
996 }
997 };
998
999 let mut flags = Sp3Flags::default();
1004 for &i in &pos_members {
1005 flags.maneuver |= pos[i].2.maneuver;
1006 flags.orbit_predicted |= pos[i].2.orbit_predicted;
1007 }
1008 for &i in &clk_members {
1009 flags.clock_event |= clk[i].2.clock_event;
1010 flags.clock_predicted |= clk[i].2.clock_predicted;
1011 }
1012
1013 let (position_rms_m, position_max_m) =
1016 position_dispersion(&pos, &pos_members, &position_m);
1017 let (clock_members_n, clock_rms_s, clock_max_s) = match clock_s {
1018 Some(c) => {
1019 let (rms, max) = clock_dispersion(&clk, &clk_members, c);
1020 (clk_members.len(), Some(rms), Some(max))
1021 }
1022 None => (0, None, None),
1023 };
1024 report.agreement.push(AgreementMetric {
1025 epoch,
1026 satellite: sat,
1027 position_members: pos_members.len(),
1028 position_rms_m,
1029 position_max_m,
1030 clock_members: clock_members_n,
1031 clock_rms_s,
1032 clock_max_s,
1033 });
1034
1035 all_sats.insert(sat);
1036 states.insert(
1037 sat,
1038 Sp3State {
1039 position: ItrfPositionM::new(position_m[0], position_m[1], position_m[2])
1040 .expect("valid ITRF position"),
1041 clock_s,
1042 velocity: None,
1043 clock_rate_s_s: None,
1044 flags,
1045 },
1046 );
1047 raws.insert(
1048 sat,
1049 RawNode {
1050 km: [
1051 position_m[0] / KM_TO_M,
1052 position_m[1] / KM_TO_M,
1053 position_m[2] / KM_TO_M,
1054 ],
1055 clock_us: clock_s.map(|c| c * 1.0e6),
1056 clock_event: flags.clock_event,
1057 },
1058 );
1059 }
1060
1061 out_states.push(states);
1062 out_raw.push(raws);
1063 }
1064
1065 let first_key = Some(out_epoch_j2000_s[0].floor() as i64);
1070 let base_idx = sources
1071 .iter()
1072 .position(|s| {
1073 s.epochs
1074 .first()
1075 .and_then(|ep| sp3_epoch_j2000_seconds(s, 0, ep))
1076 .map(|sec| sec.floor() as i64)
1077 == first_key
1078 })
1079 .or_else(|| {
1080 sources
1081 .iter()
1082 .enumerate()
1083 .filter_map(|(i, s)| {
1084 s.epochs
1085 .first()
1086 .and_then(|ep| sp3_epoch_j2000_seconds(s, 0, ep))
1087 .map(|sec| (sec, i))
1088 })
1089 .min_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)))
1090 .map(|(_, i)| i)
1091 })
1092 .unwrap_or(0);
1093 let first_epoch_header = first_epoch_header_fields(&out_epochs[0]).ok_or_else(|| {
1094 Error::InvalidInput("merged SP3 first epoch cannot be represented in header fields".into())
1095 })?;
1096
1097 let satellites: Vec<_> = all_sats.into_iter().collect();
1098 let satellite_accuracy_codes = satellites
1099 .iter()
1100 .map(|sat| {
1101 sources[base_idx]
1102 .header
1103 .satellites
1104 .iter()
1105 .position(|base_sat| base_sat == sat)
1106 .and_then(|idx| {
1107 sources[base_idx]
1108 .header
1109 .satellite_accuracy_codes
1110 .get(idx)
1111 .copied()
1112 })
1113 .unwrap_or(0)
1114 })
1115 .collect();
1116
1117 let header = Sp3Header {
1118 num_epochs: out_epochs.len() as u64,
1119 satellites,
1120 satellite_accuracy_codes,
1121 data_type: Sp3DataType::Position,
1122 gnss_week: first_epoch_header.gnss_week,
1123 seconds_of_week: first_epoch_header.seconds_of_week,
1124 epoch_interval_s,
1125 mjd: first_epoch_header.mjd,
1126 mjd_fraction: first_epoch_header.mjd_fraction,
1127 ..sources[base_idx].header.clone()
1128 };
1129
1130 let merged = Sp3 {
1131 header,
1132 epochs: out_epochs,
1133 epoch_j2000_s: out_epoch_j2000_s,
1134 states: out_states,
1135 interp_raw: out_raw,
1136 comments: vec![format!("MERGED from {} SP3 products", sources.len())],
1137 skipped_records: sources.iter().map(|s| s.skipped_records).sum(),
1138 };
1139
1140 Ok((merged, report))
1141}
1142
1143fn reconcile_sp3_coordinate_labels(
1144 sources: &[Sp3],
1145 opts: &MergeOptions,
1146) -> Result<(Vec<Sp3>, Vec<Sp3FrameReconciliation>)> {
1147 let target_label = normalized_sp3_frame_label(&sources[0].header.coordinate_system);
1148 let mut prepared = sources.to_vec();
1149 let mut report = Vec::new();
1150
1151 for idx in 1..sources.len() {
1152 let source_label = normalized_sp3_frame_label(&sources[idx].header.coordinate_system);
1153 if source_label == target_label {
1154 continue;
1155 }
1156
1157 if let Some(asserted) = asserted_frame_label_set(
1158 &source_label,
1159 &target_label,
1160 &opts.frame_reconciliation.asserted_equivalent_label_sets,
1161 ) {
1162 prepared[idx].header.coordinate_system = target_label.clone();
1163 report.push(Sp3FrameReconciliation {
1164 source_index: idx,
1165 source_label,
1166 target_label: target_label.clone(),
1167 method: Sp3FrameReconciliationMethod::AssertedEquivalence,
1168 asserted_label_set: Some(asserted),
1169 source_frame: None,
1170 target_frame: None,
1171 catalog_source_frame: None,
1172 catalog_target_frame: None,
1173 catalog_inverse: false,
1174 reference_epoch_year: None,
1175 parameters: None,
1176 rates: None,
1177 provenance: None,
1178 epoch_year_span: None,
1179 records_affected: count_position_records(&sources[idx]),
1180 identity: true,
1181 });
1182 continue;
1183 }
1184
1185 if opts.frame_reconciliation.helmert {
1186 let from = sp3_coordinate_label_frame(&source_label).ok_or_else(|| {
1187 Error::InvalidInput(format!(
1188 "merge inputs have mismatched coordinate systems ({:?} vs {:?}); source label {:?} is not a known ITRF/IGS realization",
1189 sources[0].header.coordinate_system,
1190 sources[idx].header.coordinate_system,
1191 sources[idx].header.coordinate_system
1192 ))
1193 })?;
1194 let to = sp3_coordinate_label_frame(&target_label).ok_or_else(|| {
1195 Error::InvalidInput(format!(
1196 "merge inputs have mismatched coordinate systems ({:?} vs {:?}); target label {:?} is not a known ITRF/IGS realization",
1197 sources[0].header.coordinate_system,
1198 sources[idx].header.coordinate_system,
1199 sources[0].header.coordinate_system
1200 ))
1201 })?;
1202
1203 let transform_report = reconcile_source_by_helmert(
1204 &mut prepared[idx],
1205 idx,
1206 source_label,
1207 target_label.clone(),
1208 from,
1209 to,
1210 )?;
1211 report.push(transform_report);
1212 continue;
1213 }
1214
1215 return Err(Error::InvalidInput(format!(
1216 "merge inputs have mismatched coordinate systems ({:?} vs {:?})",
1217 sources[0].header.coordinate_system, sources[idx].header.coordinate_system
1218 )));
1219 }
1220
1221 Ok((prepared, report))
1222}
1223
1224fn asserted_frame_label_set(
1225 source_label: &str,
1226 target_label: &str,
1227 label_sets: &[Sp3FrameLabelSet],
1228) -> Option<Vec<String>> {
1229 label_sets.iter().find_map(|set| {
1230 if set.labels.contains(source_label) && set.labels.contains(target_label) {
1231 Some(set.labels.iter().cloned().collect())
1232 } else {
1233 None
1234 }
1235 })
1236}
1237
1238fn reconcile_source_by_helmert(
1239 source: &mut Sp3,
1240 source_index: usize,
1241 source_label: String,
1242 target_label: String,
1243 from: TerrestrialFrame,
1244 to: TerrestrialFrame,
1245) -> Result<Sp3FrameReconciliation> {
1246 let records_affected = count_position_records(source);
1247 let epoch_year_span = epoch_year_span(source);
1248 let identity = from == to;
1249
1250 if !identity {
1251 transform_sp3_positions(source, from, to)?;
1252 }
1253 source.header.coordinate_system = target_label.clone();
1254
1255 let published = published_transform_for_report(from, to);
1256 Ok(Sp3FrameReconciliation {
1257 source_index,
1258 source_label,
1259 target_label,
1260 method: Sp3FrameReconciliationMethod::Helmert,
1261 asserted_label_set: None,
1262 source_frame: Some(from),
1263 target_frame: Some(to),
1264 catalog_source_frame: published.map(|published| published.entry.from),
1265 catalog_target_frame: published.map(|published| published.entry.to),
1266 catalog_inverse: published.is_some_and(|published| published.inverse),
1267 reference_epoch_year: published.map(|published| published.entry.reference_epoch_year),
1268 parameters: published.map(|published| published.entry.parameters),
1269 rates: published.map(|published| published.entry.rates),
1270 provenance: published.map(|published| published.entry.provenance.to_string()),
1271 epoch_year_span,
1272 records_affected,
1273 identity,
1274 })
1275}
1276
1277fn transform_sp3_positions(
1278 source: &mut Sp3,
1279 from: TerrestrialFrame,
1280 to: TerrestrialFrame,
1281) -> Result<()> {
1282 let seconds_per_julian_year = DAYS_PER_JULIAN_YEAR * SECONDS_PER_DAY;
1283 for epoch_idx in 0..source.epochs.len() {
1284 let epoch_year = decimal_year(source.epochs[epoch_idx]);
1285 let states = &mut source.states[epoch_idx];
1286 let raw_nodes = &mut source.interp_raw[epoch_idx];
1287 for (sat, state) in states.iter_mut() {
1288 let position = TerrestrialPositionM::from_itrf(state.position);
1289 let velocity = state
1290 .velocity
1291 .map(|velocity| {
1292 let [vx, vy, vz] = velocity.as_array();
1293 TerrestrialVelocityMPerYear::new(
1294 vx * seconds_per_julian_year,
1295 vy * seconds_per_julian_year,
1296 vz * seconds_per_julian_year,
1297 )
1298 })
1299 .transpose()
1300 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1301 let transformed = frame_catalog::transform(position, velocity, from, to, epoch_year)
1302 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1303 let [x, y, z] = transformed.position.as_array();
1304 state.position = ItrfPositionM::new(x, y, z)
1305 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1306 state.velocity = transformed
1307 .velocity
1308 .map(|velocity| {
1309 let [vx, vy, vz] = velocity.as_array();
1310 ItrfVelocityMS::new(
1311 vx / seconds_per_julian_year,
1312 vy / seconds_per_julian_year,
1313 vz / seconds_per_julian_year,
1314 )
1315 })
1316 .transpose()
1317 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1318 if let Some(raw) = raw_nodes.get_mut(sat) {
1319 raw.km = [x / KM_TO_M, y / KM_TO_M, z / KM_TO_M];
1320 }
1321 }
1322 }
1323 Ok(())
1324}
1325
1326fn count_position_records(source: &Sp3) -> usize {
1327 source.states.iter().map(BTreeMap::len).sum()
1328}
1329
1330fn epoch_year_span(source: &Sp3) -> Option<[f64; 2]> {
1331 let first = source.epochs.first().copied().map(decimal_year)?;
1332 let last = source.epochs.last().copied().map(decimal_year)?;
1333 Some([first, last])
1334}
1335
1336fn decimal_year(epoch: Instant) -> f64 {
1337 let jd_midnight = julian_date_from_instant(epoch) + 0.5;
1338 let (year, _, _) = civil_from_julian_day_number(jd_midnight.floor() as i64);
1339 let days = if is_leap_year(year) { 366.0 } else { 365.0 };
1340 year as f64 + (fractional_day_of_year_from_instant(epoch) - 1.0) / days
1341}
1342
1343fn normalized_sp3_frame_label(label: &str) -> String {
1344 label.trim().to_string()
1345}
1346
1347fn sp3_coordinate_label_frame(label: &str) -> Option<TerrestrialFrame> {
1348 match label.trim() {
1349 "ITRF2020" | "ITRF20" | "IGS20" | "IGc20" => Some(TerrestrialFrame::Itrf2020),
1350 "ITRF2014" | "ITRF14" | "IGS14" | "IGb14" => Some(TerrestrialFrame::Itrf2014),
1351 "ITRF2008" | "ITRF08" | "IGS08" | "IGb08" => Some(TerrestrialFrame::Itrf2008),
1352 _ => None,
1353 }
1354}
1355
1356fn published_transform_for_report(
1357 from: TerrestrialFrame,
1358 to: TerrestrialFrame,
1359) -> Option<PublishedTransformForReport> {
1360 frame_catalog::catalog_entry(from, to)
1361 .map(|entry| PublishedTransformForReport {
1362 entry,
1363 inverse: false,
1364 })
1365 .or_else(|| {
1366 frame_catalog::catalog_entry(to, from).map(|entry| PublishedTransformForReport {
1367 entry,
1368 inverse: true,
1369 })
1370 })
1371}
1372
1373#[derive(Debug, Clone, Copy)]
1374struct PublishedTransformForReport {
1375 entry: &'static frame_catalog::HelmertTransform,
1376 inverse: bool,
1377}
1378
1379#[derive(Debug, Clone, Copy)]
1380struct FirstEpochHeaderFields {
1381 gnss_week: u32,
1382 seconds_of_week: f64,
1383 mjd: u32,
1384 mjd_fraction: f64,
1385}
1386
1387fn first_epoch_header_fields(epoch: &Instant) -> Option<FirstEpochHeaderFields> {
1388 let split = epoch.julian_date()?;
1389
1390 let mjd_day = mjd_from_jd(split.jd_whole);
1391 let mut mjd = mjd_day.floor();
1392 let mut mjd_fraction = split.fraction + (mjd_day - mjd);
1393 let fraction_days = mjd_fraction.floor();
1394 if fraction_days != 0.0 {
1395 mjd += fraction_days;
1396 mjd_fraction -= fraction_days;
1397 }
1398 if !(0.0..=u32::MAX as f64).contains(&mjd) {
1399 return None;
1400 }
1401
1402 let gps_seconds = instant_to_j2000_seconds(epoch)? + GPS_EPOCH_TO_J2000_S;
1403 let (gnss_week, seconds_of_week) = gnss::week_and_seconds_of_week(gps_seconds);
1404 if !(0.0..=u32::MAX as f64).contains(&gnss_week) {
1405 return None;
1406 }
1407
1408 Some(FirstEpochHeaderFields {
1409 gnss_week: gnss_week as u32,
1410 seconds_of_week,
1411 mjd: mjd as u32,
1412 mjd_fraction,
1413 })
1414}
1415
1416fn dist3(a: &[f64; 3], b: &[f64; 3]) -> f64 {
1417 vec3::norm3(vec3::sub3(*a, *b))
1418}
1419
1420fn position_dispersion(
1423 pos: &[(usize, [f64; 3], Sp3Flags)],
1424 members: &[usize],
1425 combined: &[f64; 3],
1426) -> (f64, f64) {
1427 let mut sumsq = 0.0;
1428 let mut max = 0.0_f64;
1429 for &i in members {
1430 let d = dist3(&pos[i].1, combined);
1431 sumsq += d * d;
1432 max = max.max(d);
1433 }
1434 ((sumsq / members.len().max(1) as f64).sqrt(), max)
1435}
1436
1437fn clock_dispersion(
1440 clk: &[(usize, f64, Sp3Flags)],
1441 members: &[usize],
1442 combined: f64,
1443) -> (f64, f64) {
1444 let mut sumsq = 0.0;
1445 let mut max = 0.0_f64;
1446 for &i in members {
1447 let d = (clk[i].1 - combined).abs();
1448 sumsq += d * d;
1449 max = max.max(d);
1450 }
1451 ((sumsq / members.len().max(1) as f64).sqrt(), max)
1452}
1453
1454fn clock_offset_at(offsets: &BTreeMap<i64, f64>, key: i64) -> Option<f64> {
1458 if let Some(offset) = offsets.get(&key) {
1459 return Some(*offset);
1460 }
1461 let (&before_key, &before) = offsets.range(..key).next_back()?;
1462 let (&after_key, &after) = offsets.range(key..).next()?;
1463 if after_key <= before_key {
1464 return None;
1465 }
1466 let fraction = (key - before_key) as f64 / (after_key - before_key) as f64;
1467 Some(before + fraction * (after - before))
1468}
1469
1470fn precedence_sources_for_satellites(
1471 sources: &[Sp3],
1472 epoch_index: &[BTreeMap<i64, usize>],
1473 epoch_keys: &BTreeMap<i64, Instant>,
1474 systems: Option<&BTreeSet<GnssSystem>>,
1475) -> BTreeMap<GnssSatelliteId, usize> {
1476 let mut by_sat = BTreeMap::new();
1477
1478 for (idx, source) in sources.iter().enumerate() {
1479 for key in epoch_keys.keys() {
1480 let Some(&epoch_idx) = epoch_index[idx].get(key) else {
1481 continue;
1482 };
1483 let Ok(states) = source.states_at(epoch_idx) else {
1484 continue;
1485 };
1486
1487 for sat in states.keys() {
1488 if systems.is_none_or(|allowed| allowed.contains(&sat.system)) {
1489 by_sat.entry(*sat).or_insert(idx);
1490 }
1491 }
1492 }
1493 }
1494
1495 by_sat
1496}
1497
1498fn validate_merge_options(opts: &MergeOptions) -> Result<()> {
1499 validate::finite_nonneg(opts.position_tolerance_m, "merge position tolerance meters")
1500 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1501 validate::finite_nonneg(opts.clock_tolerance_s, "merge clock tolerance seconds")
1502 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1503 if opts.min_agree == 0 {
1504 return Err(Error::InvalidInput(
1505 "merge minimum agreement must be at least one".into(),
1506 ));
1507 }
1508 if opts.clock_min_common == 0 {
1509 return Err(Error::InvalidInput(
1510 "merge minimum common clock satellites must be at least one".into(),
1511 ));
1512 }
1513 if let Some(reject) = opts.outlier_reject {
1514 validate::finite_nonneg(
1515 reject.position_tolerance_m,
1516 "merge outlier position tolerance meters",
1517 )
1518 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1519 validate::finite_nonneg(
1520 reject.clock_tolerance_s,
1521 "merge outlier clock tolerance seconds",
1522 )
1523 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1524 }
1525 if opts
1526 .systems
1527 .as_ref()
1528 .is_some_and(|systems| systems.is_empty())
1529 {
1530 return Err(Error::InvalidInput(
1531 "merge systems filter must not be empty".into(),
1532 ));
1533 }
1534 for labels in &opts.frame_reconciliation.asserted_equivalent_label_sets {
1535 if labels.labels.len() < 2 || labels.labels.iter().any(|label| label.trim().is_empty()) {
1536 return Err(Error::InvalidInput(
1537 "merge asserted frame label sets require at least two non-empty labels".into(),
1538 ));
1539 }
1540 }
1541 Ok(())
1542}
1543
1544fn resolve_common_epoch_interval(sources: &[Sp3], target: Option<f64>) -> Result<f64> {
1553 let intervals: Vec<f64> = sources
1554 .iter()
1555 .enumerate()
1556 .map(|(idx, source)| {
1557 effective_epoch_interval_s(source)?.ok_or_else(|| {
1558 Error::InvalidInput(format!(
1559 "merge input {idx} has no usable positive epoch interval"
1560 ))
1561 })
1562 })
1563 .collect::<Result<Vec<_>>>()?;
1564
1565 let common = match target {
1566 Some(t) if t.is_finite() && t > 0.0 => t,
1567 Some(t) => {
1568 return Err(Error::InvalidInput(format!(
1569 "merge target epoch interval must be positive and finite, got {t}"
1570 )))
1571 }
1572 None => intervals.iter().copied().fold(f64::INFINITY, f64::min),
1573 };
1574
1575 if (common - common.round()).abs() > WHOLE_SECOND_EPS_S || common.round() < 1.0 {
1580 return Err(Error::InvalidInput(format!(
1581 "merge common epoch interval {common:.6} s must be a positive whole number of seconds"
1582 )));
1583 }
1584
1585 for (idx, interval) in intervals.iter().copied().enumerate() {
1586 if !divides_evenly(interval, common) && !divides_evenly(common, interval) {
1587 return Err(Error::InvalidInput(format!(
1588 "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)"
1589 )));
1590 }
1591 }
1592
1593 Ok(common)
1594}
1595
1596fn divides_evenly(interval: f64, common: f64) -> bool {
1599 if !(interval.is_finite() && interval > 0.0 && common.is_finite() && common > 0.0) {
1600 return false;
1601 }
1602 let k = (common / interval).round();
1603 k >= 1.0 && same_interval(k * interval, common)
1604}
1605
1606fn effective_epoch_interval_s(source: &Sp3) -> Result<Option<f64>> {
1607 let secs: Vec<f64> = source
1608 .epochs
1609 .iter()
1610 .filter_map(instant_to_j2000_seconds)
1611 .collect();
1612 validate::require_strictly_increasing(secs.iter().copied(), "merge input epochs").map_err(
1613 |error| Error::InvalidInput(format!("{} must be strictly increasing", error.field())),
1614 )?;
1615 let gaps: Vec<f64> = secs.windows(2).map(|w| w[1] - w[0]).collect();
1616
1617 if gaps.is_empty() {
1618 let header = source.header.epoch_interval_s;
1619 return Ok((header.is_finite() && header > 0.0).then_some(header));
1620 }
1621
1622 let interval = gaps[0];
1623 if gaps.iter().all(|g| same_interval(*g, interval)) {
1624 Ok(Some(interval))
1625 } else {
1626 Ok(None)
1627 }
1628}
1629
1630fn same_interval(a: f64, b: f64) -> bool {
1631 (a - b).abs() <= WHOLE_SECOND_EPS_S
1632}
1633
1634fn largest_within<T>(items: &[T], within: impl Fn(&T, &T) -> bool) -> Vec<usize> {
1639 let n = items.len();
1640 if n <= 1 {
1641 return (0..n).collect();
1642 }
1643 let graph = agreement_graph(items, within);
1644 if n > MAX_EXACT_CLIQUE_NODES {
1645 return greedy_largest_clique(&graph);
1646 }
1647 let mut best = vec![0];
1648 let mut current = Vec::new();
1649 max_clique_search(&graph, &mut current, (0..n).collect(), &mut best);
1650 best
1651}
1652
1653fn largest_within_containing<T>(
1654 items: &[T],
1655 required: usize,
1656 within: impl Fn(&T, &T) -> bool,
1657) -> Vec<usize> {
1658 let n = items.len();
1659 if n == 0 || required >= n {
1660 return Vec::new();
1661 }
1662 if n == 1 {
1663 return vec![required];
1664 }
1665
1666 let graph = agreement_graph(items, within);
1667 if n > MAX_EXACT_CLIQUE_NODES {
1668 return greedy_largest_clique_containing(&graph, required);
1669 }
1670 let candidates = (0..n)
1671 .filter(|&idx| idx != required && graph[required][idx])
1672 .collect();
1673 let mut best = vec![required];
1674 let mut current = vec![required];
1675 max_clique_search(&graph, &mut current, candidates, &mut best);
1676 best
1677}
1678
1679fn agreement_graph<T>(items: &[T], within: impl Fn(&T, &T) -> bool) -> Vec<Vec<bool>> {
1680 let n = items.len();
1681 let mut graph = vec![vec![false; n]; n];
1682 for i in 0..n {
1683 graph[i][i] = true;
1684 for j in i + 1..n {
1685 let agrees = within(&items[i], &items[j]);
1686 graph[i][j] = agrees;
1687 graph[j][i] = agrees;
1688 }
1689 }
1690 graph
1691}
1692
1693fn greedy_largest_clique(graph: &[Vec<bool>]) -> Vec<usize> {
1694 let mut best = Vec::new();
1695 for seed in 0..graph.len() {
1696 let candidate = greedy_clique_from_seed(graph, seed);
1697 update_best_clique(&candidate, &mut best);
1698 }
1699 best
1700}
1701
1702fn greedy_largest_clique_containing(graph: &[Vec<bool>], required: usize) -> Vec<usize> {
1703 if required >= graph.len() {
1704 return Vec::new();
1705 }
1706 greedy_clique_from_seed(graph, required)
1707}
1708
1709fn greedy_clique_from_seed(graph: &[Vec<bool>], seed: usize) -> Vec<usize> {
1710 let mut clique = vec![seed];
1711 for (idx, _) in graph.iter().enumerate() {
1712 if idx == seed {
1713 continue;
1714 }
1715 if clique.iter().all(|&member| graph[member][idx]) {
1716 clique.push(idx);
1717 }
1718 }
1719 clique.sort_unstable();
1720 clique
1721}
1722
1723fn max_clique_search(
1724 graph: &[Vec<bool>],
1725 current: &mut Vec<usize>,
1726 mut candidates: Vec<usize>,
1727 best: &mut Vec<usize>,
1728) {
1729 candidates.sort_unstable();
1730 for (pos, &candidate) in candidates.iter().enumerate() {
1731 let remaining = candidates.len() - pos;
1732 if current.len() + remaining < best.len() {
1733 break;
1734 }
1735
1736 let next_candidates = candidates[pos + 1..]
1737 .iter()
1738 .copied()
1739 .filter(|&idx| graph[candidate][idx])
1740 .collect();
1741
1742 current.push(candidate);
1743 update_best_clique(current, best);
1744 max_clique_search(graph, current, next_candidates, best);
1745 current.pop();
1746 }
1747}
1748
1749fn update_best_clique(current: &[usize], best: &mut Vec<usize>) {
1750 let mut candidate = current.to_vec();
1751 candidate.sort_unstable();
1752 if candidate.len() > best.len()
1753 || (candidate.len() == best.len() && candidate.as_slice() < best.as_slice())
1754 {
1755 *best = candidate;
1756 }
1757}
1758
1759fn combine3(members: &[(usize, [f64; 3])], how: MergeCombine) -> [f64; 3] {
1760 [0usize, 1, 2].map(|axis| {
1761 let axis_members: Vec<(usize, f64)> = members.iter().map(|(s, v)| (*s, v[axis])).collect();
1762 combine_axis(&axis_members, how)
1763 })
1764}
1765
1766fn combine_axis(members: &[(usize, f64)], how: MergeCombine) -> f64 {
1767 match how {
1768 MergeCombine::Mean => members.iter().map(|(_, v)| *v).sum::<f64>() / members.len() as f64,
1769 MergeCombine::Median => {
1770 let mut vals: Vec<f64> = members.iter().map(|(_, v)| *v).collect();
1771 median(&mut vals).expect("consensus cluster is non-empty")
1772 }
1773 MergeCombine::Precedence => members
1774 .iter()
1775 .min_by_key(|(s, _)| *s)
1776 .map(|(_, v)| *v)
1777 .expect("consensus cluster is non-empty"),
1778 }
1779}
1780
1781pub fn align_clock_reference(reference: &Sp3, other: &Sp3, min_common: usize) -> Sp3 {
1795 let offsets: BTreeMap<i64, f64> = clock_reference_offset(reference, other, min_common)
1796 .into_iter()
1797 .filter_map(|o| {
1798 instant_to_j2000_seconds(&o.epoch).map(|sec| (sec.floor() as i64, o.offset_s))
1799 })
1800 .collect();
1801
1802 let mut aligned = other.clone();
1803 for ei in 0..aligned.epochs.len() {
1804 let Some(sec) = sp3_epoch_j2000_seconds(&aligned, ei, &aligned.epochs[ei]) else {
1805 continue;
1806 };
1807 let Some(&off) = offsets.get(&(sec.floor() as i64)) else {
1808 continue;
1809 };
1810 for state in aligned.states[ei].values_mut() {
1811 if let Some(c) = state.clock_s.as_mut() {
1812 *c -= off;
1813 }
1814 }
1815 for node in aligned.interp_raw[ei].values_mut() {
1816 if let Some(us) = node.clock_us.as_mut() {
1817 *us -= off * 1.0e6;
1818 }
1819 }
1820 }
1821 aligned
1822}
1823
1824#[cfg(test)]
1825mod tests {
1826 use super::super::Sp3;
1827 use super::{
1828 align_clock_reference, clock_reference_offset, merge, MergeCombine, MergeOptions,
1829 MergePrecedenceScope, MergeReport, OutlierRejectOptions, Sp3FrameLabelSet,
1830 Sp3FrameReconciliationMethod, Sp3FrameReconciliationOptions,
1831 };
1832 use crate::constants::SECONDS_PER_DAY;
1833 use crate::id::{GnssSatelliteId, GnssSystem};
1834 use std::collections::BTreeSet;
1835
1836 type SatSample<'a> = (&'a str, [f64; 3], Option<f64>);
1839
1840 fn gps(prn: u8) -> GnssSatelliteId {
1841 GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
1842 }
1843
1844 fn sp3_build(records: &[(&str, [f64; 3], Option<f64>, &str)], cs: &str) -> Sp3 {
1850 let n = records.len();
1851 let mut sats = String::new();
1852 for (sat, _, _, _) in records {
1853 sats.push_str(sat);
1854 }
1855 for _ in n..17 {
1856 sats.push_str(" 0");
1857 }
1858 let mut body = String::new();
1859 body.push_str(&format!(
1860 "#cP2020 6 25 0 0 0.00000000 1 ORBIT {cs} FIT TST\n"
1861 ));
1862 body.push_str("## 2111 432000.00000000 900.00000000 59025 0.0000000000000\n");
1863 body.push_str(&format!("+ {n:2} {sats}\n"));
1864 body.push_str("++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n");
1865 body.push_str("%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1866 body.push_str("%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1867 body.push_str("%f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n");
1868 body.push_str("%f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n");
1869 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1870 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1871 body.push_str("/* TEST SP3-c FIXTURE\n");
1872 body.push_str("* 2020 6 25 0 0 0.00000000\n");
1873 for (sat, p, clk, flags) in records {
1874 let c = clk.unwrap_or(999_999.999_999);
1875 body.push_str(&format!(
1876 "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}{flags}\n",
1877 p[0], p[1], p[2]
1878 ));
1879 }
1880 body.push_str("EOF\n");
1881 Sp3::parse(body.as_bytes()).expect("parse test sp3")
1882 }
1883
1884 fn sp3_records(records: &[(&str, [f64; 3], Option<f64>)]) -> Sp3 {
1886 let full: Vec<(&str, [f64; 3], Option<f64>, &str)> =
1887 records.iter().map(|(s, p, c)| (*s, *p, *c, "")).collect();
1888 sp3_build(&full, "IGS14")
1889 }
1890
1891 fn sp3_two_epochs(
1892 epoch0: &[(&str, [f64; 3], Option<f64>)],
1893 epoch1: &[(&str, [f64; 3], Option<f64>)],
1894 interval_s: f64,
1895 cs: &str,
1896 ) -> Sp3 {
1897 let mut sats: Vec<&str> = epoch0
1898 .iter()
1899 .chain(epoch1.iter())
1900 .map(|(sat, _, _)| *sat)
1901 .collect();
1902 sats.sort_unstable();
1903 sats.dedup();
1904 let n = sats.len();
1905 let mut sat_field = String::new();
1906 for sat in &sats {
1907 sat_field.push_str(sat);
1908 }
1909 for _ in n..17 {
1910 sat_field.push_str(" 0");
1911 }
1912
1913 let mut body = String::new();
1914 body.push_str(&format!(
1915 "#cP2020 6 25 0 0 0.00000000 2 ORBIT {cs} FIT TST\n"
1916 ));
1917 body.push_str(&format!(
1918 "## 2111 432000.00000000 {interval_s:14.8} 59025 0.0000000000000\n"
1919 ));
1920 body.push_str(&format!("+ {n:2} {sat_field}\n"));
1921 body.push_str("++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n");
1922 body.push_str("%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1923 body.push_str("%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1924 body.push_str("%f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n");
1925 body.push_str("%f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n");
1926 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1927 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1928 body.push_str("/* TEST SP3-c FIXTURE\n");
1929 body.push_str("* 2020 6 25 0 0 0.00000000\n");
1930 for (sat, p, clk) in epoch0 {
1931 let c = clk.unwrap_or(999_999.999_999);
1932 body.push_str(&format!(
1933 "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}\n",
1934 p[0], p[1], p[2]
1935 ));
1936 }
1937 let second_hour = (interval_s as i64) / 3600;
1938 let second_minute = ((interval_s as i64) % 3600) / 60;
1939 let second_second = (interval_s as i64) % 60;
1940 body.push_str(&format!(
1941 "* 2020 6 25 {second_hour:2} {second_minute:2} {second_second:2}.00000000\n"
1942 ));
1943 for (sat, p, clk) in epoch1 {
1944 let c = clk.unwrap_or(999_999.999_999);
1945 body.push_str(&format!(
1946 "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}\n",
1947 p[0], p[1], p[2]
1948 ));
1949 }
1950 body.push_str("EOF\n");
1951 Sp3::parse(body.as_bytes()).expect("parse test sp3")
1952 }
1953
1954 fn sp3_epochs(
1956 start_offset_s: f64,
1957 epochs: &[&[SatSample<'_>]],
1958 interval_s: f64,
1959 cs: &str,
1960 ) -> Sp3 {
1961 let mut sats: Vec<&str> = epochs
1962 .iter()
1963 .flat_map(|e| e.iter().map(|(sat, _, _)| *sat))
1964 .collect();
1965 sats.sort_unstable();
1966 sats.dedup();
1967 let n = sats.len();
1968 let mut sat_field = String::new();
1969 for sat in &sats {
1970 sat_field.push_str(sat);
1971 }
1972 for _ in n..17 {
1973 sat_field.push_str(" 0");
1974 }
1975
1976 let hms = |t: i64| (t / 3600, (t % 3600) / 60, t % 60);
1977 let start = start_offset_s as i64;
1978 let (sh, sm, ss0) = hms(start);
1979
1980 let mut body = String::new();
1981 body.push_str(&format!(
1982 "#cP2020 6 25 {sh:2} {sm:2} {ss0:2}.00000000 {:2} ORBIT {cs} FIT TST\n",
1983 epochs.len()
1984 ));
1985 let sow = 432_000.0 + start_offset_s;
1987 let mjd_frac = start_offset_s / SECONDS_PER_DAY;
1988 body.push_str(&format!(
1989 "## 2111 {sow:15.8} {interval_s:14.8} 59025 {mjd_frac:.13}\n"
1990 ));
1991 body.push_str(&format!("+ {n:2} {sat_field}\n"));
1992 body.push_str("++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n");
1993 body.push_str("%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1994 body.push_str("%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1995 body.push_str("%f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n");
1996 body.push_str("%f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n");
1997 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1998 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1999 body.push_str("/* TEST SP3-c FIXTURE\n");
2000 for (k, recs) in epochs.iter().enumerate() {
2001 let (hh, mm, ss) = hms(start + (k as i64) * (interval_s as i64));
2002 body.push_str(&format!("* 2020 6 25 {hh:2} {mm:2} {ss:2}.00000000\n"));
2003 for (sat, p, clk) in recs.iter() {
2004 let c = clk.unwrap_or(999_999.999_999);
2005 body.push_str(&format!(
2006 "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}\n",
2007 p[0], p[1], p[2]
2008 ));
2009 }
2010 }
2011 body.push_str("EOF\n");
2012 Sp3::parse(body.as_bytes()).expect("parse test sp3")
2013 }
2014
2015 #[test]
2016 fn merge_unions_coverage_when_one_center_misses_a_satellite() {
2017 let a = sp3_records(&[
2020 ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
2021 ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
2022 ("G03", [17000.0, -22000.0, 7000.0], Some(300.0)),
2023 ]);
2024 let b = sp3_records(&[
2025 ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
2026 ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
2027 ]);
2028
2029 let (merged, report) = merge(&[a, b], &MergeOptions::default()).expect("merge");
2030
2031 let states = merged.states_at(0).expect("epoch 0");
2032 assert!(
2033 states.contains_key(&gps(3)),
2034 "merged output must cover G03 from the center that has it"
2035 );
2036 assert_eq!(states.len(), 3, "union is G01/G02/G03");
2037 let g01 = states[&gps(1)];
2039 assert!((g01.clock_s.unwrap() - 100.0e-6).abs() < 1.0e-15);
2040 assert!(report.quarantined.is_empty());
2042 assert_eq!(report.single_source.len(), 1);
2043 assert_eq!(report.single_source[0].satellite, gps(3));
2044
2045 let frac = report
2049 .single_source_fraction()
2050 .expect("accepted cells present");
2051 assert!(
2052 (frac - 1.0 / 3.0).abs() < 1.0e-12,
2053 "single-source fraction {frac}"
2054 );
2055 assert_eq!(MergeReport::default().single_source_fraction(), None);
2056 }
2057
2058 #[test]
2059 fn merge_rejects_non_executable_system_and_frame_policies() {
2060 let source = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))]);
2061
2062 let empty_systems = MergeOptions {
2063 systems: Some(BTreeSet::new()),
2064 ..MergeOptions::default()
2065 };
2066 let error = merge(std::slice::from_ref(&source), &empty_systems).unwrap_err();
2067 assert!(error
2068 .to_string()
2069 .contains("systems filter must not be empty"));
2070
2071 let incomplete_frame_set = MergeOptions {
2072 frame_reconciliation: Sp3FrameReconciliationOptions {
2073 asserted_equivalent_label_sets: vec![Sp3FrameLabelSet::new(["IGS20"])],
2074 helmert: false,
2075 },
2076 ..MergeOptions::default()
2077 };
2078 let error = merge(&[source], &incomplete_frame_set).unwrap_err();
2079 assert!(error.to_string().contains("at least two non-empty labels"));
2080 }
2081
2082 #[test]
2083 fn merge_combines_two_of_three_agreeing_sources_and_rejects_the_outlier() {
2084 let a = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))]);
2086 let b = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))]);
2087 let c = sp3_records(&[("G01", [15000.010, -20000.0, 5000.0], Some(100.0))]);
2088
2089 let (merged, report) = merge(&[a, b, c], &MergeOptions::default()).expect("merge");
2090
2091 let states = merged.states_at(0).expect("epoch 0");
2092 let g01 = states[&gps(1)];
2093 assert!(
2095 (g01.position.as_array()[0] - 15_000_000.0).abs() < 1.0e-3,
2096 "got {}",
2097 g01.position.as_array()[0]
2098 );
2099 assert_eq!(report.position_outliers.len(), 1);
2101 assert_eq!(report.position_outliers[0].sources, vec![2]);
2102 assert!(report.quarantined.is_empty());
2103 }
2104
2105 #[test]
2106 fn guarded_precedence_replaces_a_corrupt_preferred_position() {
2107 let preferred = sp3_records(&[("G01", [16000.0, -20000.0, 5000.0], None)]);
2108 let agreeing_a = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], None)]);
2109 let agreeing_b = sp3_records(&[("G01", [15000.0002, -20000.0, 5000.0], None)]);
2110 let opts = MergeOptions {
2111 combine: MergeCombine::Precedence,
2112 min_agree: 1,
2113 outlier_reject: Some(OutlierRejectOptions {
2114 position_tolerance_m: 0.5,
2115 clock_tolerance_s: 5.0e-9,
2116 }),
2117 ..MergeOptions::default()
2118 };
2119
2120 let (merged, report) = merge(&[preferred, agreeing_a, agreeing_b], &opts).expect("merge");
2121
2122 let x = merged.states_at(0).expect("epoch")[&gps(1)]
2123 .position
2124 .as_array()[0];
2125 assert_eq!(
2126 x, 15_000_000.0,
2127 "earliest member of the 2-source cluster wins"
2128 );
2129 assert_eq!(report.position_outliers.len(), 1);
2130 assert_eq!(report.position_outliers[0].sources, vec![0]);
2131 }
2132
2133 #[test]
2134 fn unguarded_precedence_preserves_the_existing_preferred_value_behavior() {
2135 let preferred = sp3_records(&[("G01", [16000.0, -20000.0, 5000.0], None)]);
2136 let agreeing_a = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], None)]);
2137 let agreeing_b = sp3_records(&[("G01", [15000.0002, -20000.0, 5000.0], None)]);
2138 let opts = MergeOptions {
2139 combine: MergeCombine::Precedence,
2140 min_agree: 1,
2141 outlier_reject: None,
2142 ..MergeOptions::default()
2143 };
2144
2145 let (merged, report) = merge(&[preferred, agreeing_a, agreeing_b], &opts).expect("merge");
2146
2147 let x = merged.states_at(0).expect("epoch")[&gps(1)]
2148 .position
2149 .as_array()[0];
2150 assert_eq!(x, 16_000_000.0);
2151 assert_eq!(report.position_outliers[0].sources, vec![1, 2]);
2152 }
2153
2154 #[test]
2155 fn guarded_precedence_keeps_a_preferred_member_of_the_majority() {
2156 let preferred = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], None)]);
2157 let agreeing = sp3_records(&[("G01", [15000.0002, -20000.0, 5000.0], None)]);
2158 let outlier = sp3_records(&[("G01", [16000.0, -20000.0, 5000.0], None)]);
2159 let opts = MergeOptions {
2160 combine: MergeCombine::Precedence,
2161 min_agree: 1,
2162 outlier_reject: Some(OutlierRejectOptions {
2163 position_tolerance_m: 0.5,
2164 clock_tolerance_s: 5.0e-9,
2165 }),
2166 ..MergeOptions::default()
2167 };
2168
2169 let (merged, report) = merge(&[preferred, agreeing, outlier], &opts).expect("merge");
2170
2171 let x = merged.states_at(0).expect("epoch")[&gps(1)]
2172 .position
2173 .as_array()[0];
2174 assert_eq!(x, 15_000_000.0);
2175 assert_eq!(report.position_outliers[0].sources, vec![2]);
2176 }
2177
2178 #[test]
2179 fn guarded_precedence_keeps_a_single_source_cell() {
2180 let only = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], None)]);
2181 let opts = MergeOptions {
2182 combine: MergeCombine::Precedence,
2183 min_agree: 1,
2184 outlier_reject: Some(OutlierRejectOptions {
2185 position_tolerance_m: 0.5,
2186 clock_tolerance_s: 5.0e-9,
2187 }),
2188 ..MergeOptions::default()
2189 };
2190
2191 let (merged, report) = merge(&[only], &opts).expect("merge");
2192
2193 assert!(merged.states_at(0).expect("epoch").contains_key(&gps(1)));
2194 assert_eq!(report.single_source.len(), 1);
2195 assert!(report.quarantined.is_empty());
2196 }
2197
2198 #[test]
2199 fn guarded_precedence_position_tolerance_is_inclusive() {
2200 for (delta_km, accepted) in [(0.000_499, true), (0.000_501, false)] {
2201 let a = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], None)]);
2202 let b = sp3_records(&[("G01", [15000.0 + delta_km, -20000.0, 5000.0], None)]);
2203 let opts = MergeOptions {
2204 combine: MergeCombine::Precedence,
2205 min_agree: 1,
2206 outlier_reject: Some(OutlierRejectOptions {
2207 position_tolerance_m: 0.5,
2208 clock_tolerance_s: 5.0e-9,
2209 }),
2210 ..MergeOptions::default()
2211 };
2212
2213 let (merged, report) = merge(&[a, b], &opts).expect("merge");
2214 assert_eq!(
2215 merged.states_at(0).expect("epoch").contains_key(&gps(1)),
2216 accepted,
2217 "delta {delta_km} km"
2218 );
2219 assert_eq!(report.quarantined.is_empty(), accepted);
2220 }
2221
2222 assert_eq!(
2223 super::largest_within(&[0.0_f64, 0.5_f64], |a, b| (*a - *b).abs() <= 0.5).len(),
2224 2,
2225 "the tolerance boundary itself is accepted"
2226 );
2227 }
2228
2229 #[test]
2230 fn guarded_precedence_replaces_a_corrupt_preferred_clock() {
2231 let positions = |clock_g01: f64| {
2232 sp3_records(&[
2233 ("G01", [15000.0, -20000.0, 5000.0], Some(clock_g01)),
2234 ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
2235 ("G03", [17000.0, -22000.0, 7000.0], Some(300.0)),
2236 ("G04", [18000.0, -23000.0, 8000.0], Some(400.0)),
2237 ("G05", [19000.0, -24000.0, 9000.0], Some(500.0)),
2238 ])
2239 };
2240 let opts = MergeOptions {
2241 combine: MergeCombine::Precedence,
2242 min_agree: 1,
2243 outlier_reject: Some(OutlierRejectOptions {
2244 position_tolerance_m: 0.5,
2245 clock_tolerance_s: 5.0e-9,
2246 }),
2247 ..MergeOptions::default()
2248 };
2249
2250 let (merged, report) = merge(
2251 &[positions(1100.0), positions(100.0), positions(100.0)],
2252 &opts,
2253 )
2254 .expect("merge");
2255
2256 let clock = merged.states_at(0).expect("epoch")[&gps(1)]
2257 .clock_s
2258 .expect("consensus clock");
2259 assert!((clock - 100.0e-6).abs() < 1.0e-15, "clock {clock}");
2260 let rejected = report
2261 .clock_outliers
2262 .iter()
2263 .find(|entry| entry.satellite == gps(1))
2264 .expect("clock outlier provenance");
2265 assert_eq!(rejected.sources, vec![0]);
2266 }
2267
2268 #[test]
2269 fn merge_consensus_handles_more_than_u32_mask_bits() {
2270 let sources: Vec<Sp3> = (0..33)
2273 .map(|idx| {
2274 let x_km = if idx < 32 { 15000.0 } else { 15000.010 };
2275 sp3_records(&[("G01", [x_km, -20000.0, 5000.0], Some(100.0))])
2276 })
2277 .collect();
2278
2279 for combine in [MergeCombine::Mean, MergeCombine::Precedence] {
2280 let opts = MergeOptions {
2281 combine,
2282 min_agree: 32,
2283 ..MergeOptions::default()
2284 };
2285
2286 let (merged, report) = merge(&sources, &opts).expect("33-source merge");
2287
2288 let states = merged.states_at(0).expect("epoch 0");
2289 let g01 = states[&gps(1)];
2290 assert!(
2291 (g01.position.as_array()[0] - 15_000_000.0).abs() < 1.0e-3,
2292 "{combine:?}: got {}",
2293 g01.position.as_array()[0]
2294 );
2295 assert_eq!(
2296 report.position_outliers.len(),
2297 1,
2298 "{combine:?}: expected one outlier report"
2299 );
2300 assert_eq!(report.position_outliers[0].sources, vec![32]);
2301 assert!(report.quarantined.is_empty(), "{combine:?}");
2302 }
2303 }
2304
2305 #[test]
2306 fn merge_bounds_large_overlap_clique_search() {
2307 let sources: Vec<Sp3> = (0..40)
2308 .map(|idx| {
2309 let x_km = if idx % 2 == 0 { 15000.0 } else { 15000.010 };
2310 sp3_records(&[("G01", [x_km, -20000.0, 5000.0], Some(100.0))])
2311 })
2312 .collect();
2313 let opts = MergeOptions {
2314 min_agree: 20,
2315 ..MergeOptions::default()
2316 };
2317
2318 let (merged, report) = merge(&sources, &opts).expect("bounded large-source merge");
2319
2320 let states = merged.states_at(0).expect("epoch 0");
2321 let g01 = states[&gps(1)];
2322 assert!(
2323 (g01.position.as_array()[0] - 15_000_000.0).abs() < 1.0e-3,
2324 "got {}",
2325 g01.position.as_array()[0]
2326 );
2327 assert_eq!(report.position_outliers.len(), 1);
2328 assert_eq!(
2329 report.position_outliers[0].sources,
2330 (1..40).step_by(2).collect::<Vec<_>>()
2331 );
2332 assert!(report.quarantined.is_empty());
2333 }
2334
2335 #[test]
2336 fn merge_quarantines_a_satellite_all_centers_disagree_on() {
2337 let a = sp3_records(&[("G01", [15000.000, -20000.0, 5000.0], Some(100.0))]);
2339 let b = sp3_records(&[("G01", [15000.010, -20000.0, 5000.0], Some(100.0))]);
2340 let c = sp3_records(&[("G01", [15000.020, -20000.0, 5000.0], Some(100.0))]);
2341
2342 let (merged, report) = merge(&[a, b, c], &MergeOptions::default()).expect("merge");
2343
2344 assert!(
2345 merged.states_at(0).expect("epoch 0").is_empty(),
2346 "no consensus -> G01 omitted, not averaged across disagreeing centers"
2347 );
2348 assert_eq!(report.quarantined.len(), 1);
2349 assert_eq!(report.quarantined[0].satellite, gps(1));
2350 }
2351
2352 #[test]
2353 fn merge_rejects_an_empty_input() {
2354 assert!(merge(&[], &MergeOptions::default()).is_err());
2355 }
2356
2357 #[test]
2358 fn merge_omits_an_unalignable_secondary_clock() {
2359 let a = sp3_records(&[
2364 ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
2365 ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
2366 ("G03", [17000.0, -22000.0, 7000.0], Some(300.0)),
2367 ]);
2368 let b = sp3_records(&[
2369 ("G01", [15000.0, -20000.0, 5000.0], Some(150.0)),
2370 ("G02", [16000.0, -21000.0, 6000.0], Some(250.0)),
2371 ("G03", [17000.0, -22000.0, 7000.0], Some(350.0)),
2372 ("G04", [18000.0, -23000.0, 8000.0], Some(450.0)),
2373 ]);
2374
2375 let (merged, _) = merge(&[a, b], &MergeOptions::default()).expect("merge");
2376 let states = merged.states_at(0).expect("epoch 0");
2377
2378 assert!(states.contains_key(&gps(4)));
2380 assert!(
2381 states[&gps(4)].clock_s.is_none(),
2382 "an unalignable secondary clock must be dropped, not emitted raw"
2383 );
2384 let g01_clock = states[&gps(1)]
2386 .clock_s
2387 .expect("G01 carries the reference clock");
2388 assert!((g01_clock - 100.0e-6).abs() < 1.0e-12, "got {g01_clock}");
2389 }
2390
2391 #[test]
2392 fn merge_rejects_mismatched_coordinate_systems() {
2393 let a = sp3_build(
2394 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2395 "IGS14",
2396 );
2397 let b = sp3_build(
2398 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2399 "IGS20",
2400 );
2401
2402 assert!(merge(&[a, b], &MergeOptions::default()).is_err());
2403 }
2404
2405 #[test]
2406 fn merge_rejects_different_igs_frame_labels_without_a_transform() {
2407 let a = sp3_build(
2408 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2409 "IGS20",
2410 );
2411 let b = sp3_build(
2412 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2413 "IGc20",
2414 );
2415
2416 let err = merge(&[a, b], &MergeOptions::default()).expect_err("frame mismatch");
2417 assert!(
2418 err.to_string().contains("mismatched coordinate systems"),
2419 "{err}"
2420 );
2421 }
2422
2423 #[test]
2424 fn merge_accepts_asserted_equivalent_labels_and_reports_assertion() {
2425 for (a_label, b_label) in [("IGS14", "ITRF2"), ("ITRF2", "IGS14")] {
2426 let a = sp3_build(
2427 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2428 a_label,
2429 );
2430 let b = sp3_build(
2431 &[("G02", [16000.0, -21000.0, 6000.0], Some(200.0), "")],
2432 b_label,
2433 );
2434 let opts = MergeOptions {
2435 frame_reconciliation: super::Sp3FrameReconciliationOptions {
2436 asserted_equivalent_label_sets: vec![Sp3FrameLabelSet::pair("IGS14", "ITRF2")],
2437 helmert: false,
2438 },
2439 ..MergeOptions::default()
2440 };
2441
2442 let (merged, report) = merge(&[a, b], &opts).expect("asserted frame merge");
2443
2444 let states = merged.states_at(0).expect("epoch 0");
2445 assert!(states.contains_key(&gps(1)));
2446 assert!(states.contains_key(&gps(2)));
2447 assert_eq!(merged.header.coordinate_system, a_label);
2448 assert_eq!(report.frame_reconciliations.len(), 1);
2449 let reconciliation = &report.frame_reconciliations[0];
2450 assert_eq!(
2451 reconciliation.method,
2452 Sp3FrameReconciliationMethod::AssertedEquivalence
2453 );
2454 assert_eq!(reconciliation.source_index, 1);
2455 assert_eq!(reconciliation.source_label, b_label);
2456 assert_eq!(reconciliation.target_label, a_label);
2457 assert_eq!(reconciliation.records_affected, 1);
2458 assert!(reconciliation.parameters.is_none());
2459 assert!(reconciliation.rates.is_none());
2460 assert_eq!(
2461 reconciliation
2462 .asserted_label_set
2463 .as_ref()
2464 .expect("assertion set"),
2465 &vec!["IGS14".to_string(), "ITRF2".to_string()]
2466 );
2467 }
2468 }
2469
2470 #[test]
2471 fn merge_applies_helmert_reconciliation_to_resolved_labels() {
2472 let a = sp3_build(
2477 &[("G01", [14000.0, -19000.0, 4000.0], Some(100.0), "")],
2478 "IGS14",
2479 );
2480 let b = sp3_build(
2481 &[("G02", [15000.0, -20000.0, 5000.0], Some(200.0), "")],
2482 "IGS20",
2483 );
2484 let opts = MergeOptions {
2485 min_agree: 1,
2486 frame_reconciliation: super::Sp3FrameReconciliationOptions::helmert(),
2487 ..MergeOptions::default()
2488 };
2489
2490 let (merged, report) = merge(&[a, b], &opts).expect("helmert frame merge");
2491
2492 let g02 = merged.states_at(0).expect("epoch 0")[&gps(2)];
2493 let got = g02.position.as_array();
2494 let expected = [
2495 14_999_999.992_3,
2496 -19_999_999.993_048_087,
2497 5_000_000.000_396_175,
2498 ];
2499 for axis in 0..3 {
2500 assert!(
2501 (got[axis] - expected[axis]).abs() < 2.0e-9,
2502 "axis {axis}: got {}, expected {}",
2503 got[axis],
2504 expected[axis]
2505 );
2506 }
2507 assert_eq!(merged.header.coordinate_system, "IGS14");
2508 assert_eq!(report.frame_reconciliations.len(), 1);
2509 let reconciliation = &report.frame_reconciliations[0];
2510 assert_eq!(reconciliation.method, Sp3FrameReconciliationMethod::Helmert);
2511 assert_eq!(reconciliation.source_label, "IGS20");
2512 assert_eq!(reconciliation.target_label, "IGS14");
2513 assert_eq!(reconciliation.records_affected, 1);
2514 assert_eq!(
2515 reconciliation
2516 .parameters
2517 .expect("published parameters")
2518 .translation_mm,
2519 [-1.4, -0.9, 1.4]
2520 );
2521 assert_eq!(
2522 reconciliation.catalog_source_frame,
2523 Some(crate::frame_catalog::TerrestrialFrame::Itrf2020)
2524 );
2525 assert_eq!(
2526 reconciliation.catalog_target_frame,
2527 Some(crate::frame_catalog::TerrestrialFrame::Itrf2014)
2528 );
2529 assert!(!reconciliation.catalog_inverse);
2530 assert_eq!(
2531 reconciliation
2532 .rates
2533 .expect("published rates")
2534 .translation_mm_per_year,
2535 [0.0, -0.1, 0.2]
2536 );
2537 assert!(reconciliation
2538 .provenance
2539 .as_ref()
2540 .expect("provenance")
2541 .contains("ITRF2020 to past ITRFs"));
2542 }
2543
2544 #[test]
2545 fn merge_reports_inverse_helmert_catalog_direction() {
2546 let a = sp3_build(
2547 &[("G01", [14000.0, -19000.0, 4000.0], Some(100.0), "")],
2548 "IGS20",
2549 );
2550 let b = sp3_build(
2551 &[("G02", [15000.0, -20000.0, 5000.0], Some(200.0), "")],
2552 "IGS14",
2553 );
2554 let opts = MergeOptions {
2555 min_agree: 1,
2556 frame_reconciliation: super::Sp3FrameReconciliationOptions::helmert(),
2557 ..MergeOptions::default()
2558 };
2559
2560 let (_merged, report) = merge(&[a, b], &opts).expect("inverse helmert frame merge");
2561
2562 let reconciliation = &report.frame_reconciliations[0];
2563 assert_eq!(reconciliation.method, Sp3FrameReconciliationMethod::Helmert);
2564 assert_eq!(
2565 reconciliation.source_frame,
2566 Some(crate::frame_catalog::TerrestrialFrame::Itrf2014)
2567 );
2568 assert_eq!(
2569 reconciliation.target_frame,
2570 Some(crate::frame_catalog::TerrestrialFrame::Itrf2020)
2571 );
2572 assert_eq!(
2573 reconciliation.catalog_source_frame,
2574 Some(crate::frame_catalog::TerrestrialFrame::Itrf2020)
2575 );
2576 assert_eq!(
2577 reconciliation.catalog_target_frame,
2578 Some(crate::frame_catalog::TerrestrialFrame::Itrf2014)
2579 );
2580 assert!(reconciliation.catalog_inverse);
2581 assert_eq!(
2582 reconciliation
2583 .parameters
2584 .expect("published parameters")
2585 .translation_mm,
2586 [-1.4, -0.9, 1.4]
2587 );
2588 }
2589
2590 #[test]
2591 fn helmert_identity_label_reconciliation_is_bit_equal() {
2592 let a = sp3_build(
2593 &[("G01", [14000.0, -19000.0, 4000.0], Some(100.0), "")],
2594 "IGS20",
2595 );
2596 let b = sp3_build(
2597 &[("G02", [15000.125, -20000.5, 5000.25], Some(200.0), "")],
2598 "IGc20",
2599 );
2600 let original = b.states_at(0).expect("epoch 0")[&gps(2)].position;
2601 let opts = MergeOptions {
2602 min_agree: 1,
2603 frame_reconciliation: super::Sp3FrameReconciliationOptions::helmert(),
2604 ..MergeOptions::default()
2605 };
2606
2607 let (merged, report) = merge(&[a, b], &opts).expect("identity frame merge");
2608
2609 let g02 = merged.states_at(0).expect("epoch 0")[&gps(2)].position;
2610 for axis in 0..3 {
2611 assert_eq!(
2612 g02.as_array()[axis].to_bits(),
2613 original.as_array()[axis].to_bits()
2614 );
2615 }
2616 assert_eq!(report.frame_reconciliations.len(), 1);
2617 assert!(report.frame_reconciliations[0].identity);
2618 assert!(report.frame_reconciliations[0].parameters.is_none());
2619 }
2620
2621 #[test]
2622 fn helmert_reconciliation_rejects_unknown_labels() {
2623 let a = sp3_build(
2624 &[("G01", [14000.0, -19000.0, 4000.0], Some(100.0), "")],
2625 "ITRF2",
2626 );
2627 let b = sp3_build(
2628 &[("G02", [15000.0, -20000.0, 5000.0], Some(200.0), "")],
2629 "IGS20",
2630 );
2631 let opts = MergeOptions {
2632 frame_reconciliation: super::Sp3FrameReconciliationOptions::helmert(),
2633 ..MergeOptions::default()
2634 };
2635
2636 let err = merge(&[a, b], &opts).expect_err("unknown frame label");
2637
2638 assert!(
2639 err.to_string().contains("target label"),
2640 "unknown labels must not be guessed: {err}"
2641 );
2642 }
2643
2644 #[test]
2645 fn merge_uses_finest_union_grid_and_fills_sparse_precedence_cells() {
2646 let a = sp3_two_epochs(
2650 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2651 &[("G01", [15003.0, -20003.0, 5003.0], Some(103.0))],
2652 900.0,
2653 "IGS14",
2654 );
2655 let b = sp3_epochs(
2656 0.0,
2657 &[
2658 &[("G01", [26000.0, -20000.0, 5000.0], Some(200.0))],
2659 &[("G01", [26001.0, -20001.0, 5001.0], Some(201.0))],
2660 &[("G01", [26002.0, -20002.0, 5002.0], Some(202.0))],
2661 &[("G01", [26003.0, -20003.0, 5003.0], Some(203.0))],
2662 ],
2663 300.0,
2664 "IGS14",
2665 );
2666
2667 let opts = MergeOptions {
2668 combine: MergeCombine::Precedence,
2669 min_agree: 1,
2670 ..MergeOptions::default()
2671 };
2672 let (merged, _report) = merge(&[a, b], &opts).expect("mixed-interval union merge");
2673
2674 assert_eq!(
2675 merged.header.epoch_interval_s, 300.0,
2676 "output is on the finest (300 s) input grid"
2677 );
2678 assert_eq!(
2679 merged.epochs.len(),
2680 4,
2681 "B fills the :05 and :10 epochs between A's samples"
2682 );
2683 let xs: Vec<f64> = (0..4)
2684 .map(|idx| {
2685 merged.states_at(idx).expect("epoch")[&gps(1)]
2686 .position
2687 .as_array()[0]
2688 })
2689 .collect();
2690 assert_eq!(
2691 xs,
2692 vec![15_000_000.0, 26_001_000.0, 26_002_000.0, 15_003_000.0]
2693 );
2694 }
2695
2696 #[test]
2697 fn mixed_cadence_interpolates_only_the_clock_datum_for_filled_cells() {
2698 let reference_epoch: Vec<SatSample<'_>> = vec![
2699 ("G01", [15_001.0, -20_000.0, 5_000.0], Some(100.0)),
2700 ("G02", [15_002.0, -20_000.0, 5_000.0], Some(200.0)),
2701 ("G03", [15_003.0, -20_000.0, 5_000.0], Some(300.0)),
2702 ("G04", [15_004.0, -20_000.0, 5_000.0], Some(400.0)),
2703 ("G05", [15_005.0, -20_000.0, 5_000.0], Some(500.0)),
2704 ];
2705 let shifted_epoch: Vec<SatSample<'_>> = reference_epoch
2706 .iter()
2707 .map(|(sat, position, clock)| (*sat, *position, clock.map(|value| value + 50.0)))
2708 .collect();
2709 let a = sp3_epochs(
2710 0.0,
2711 &[reference_epoch.as_slice(), reference_epoch.as_slice()],
2712 900.0,
2713 "IGS14",
2714 );
2715 let b = sp3_epochs(
2716 0.0,
2717 &[
2718 shifted_epoch.as_slice(),
2719 shifted_epoch.as_slice(),
2720 shifted_epoch.as_slice(),
2721 shifted_epoch.as_slice(),
2722 ],
2723 300.0,
2724 "IGS14",
2725 );
2726 let opts = MergeOptions {
2727 combine: MergeCombine::Precedence,
2728 min_agree: 1,
2729 ..MergeOptions::default()
2730 };
2731
2732 let (merged, _) = merge(&[a, b], &opts).expect("mixed-cadence clock merge");
2733
2734 assert_eq!(merged.epochs.len(), 4);
2735 for epoch_index in 0..4 {
2736 let clock = merged.states_at(epoch_index).expect("epoch")[&gps(1)]
2737 .clock_s
2738 .expect("aligned clock");
2739 assert!(
2740 (clock - 100.0e-6).abs() < 1.0e-15,
2741 "epoch {epoch_index}: {clock}"
2742 );
2743 }
2744 }
2745
2746 #[test]
2747 fn merge_decimates_with_explicit_coarser_target_interval() {
2748 let recs = |x: f64| vec![("G01", [x, -20000.0, 5000.0], Some(100.0))];
2750 let make = || {
2751 sp3_epochs(
2752 0.0,
2753 &[
2754 &recs(15000.0),
2755 &recs(15001.0),
2756 &recs(15002.0),
2757 &recs(15003.0),
2758 ],
2759 300.0,
2760 "IGS14",
2761 )
2762 };
2763 let opts = MergeOptions {
2764 min_agree: 1,
2765 target_epoch_interval_s: Some(900.0),
2766 ..MergeOptions::default()
2767 };
2768 let (merged, _) = merge(&[make(), make()], &opts).expect("explicit coarse target");
2769 assert_eq!(merged.header.epoch_interval_s, 900.0);
2770 assert_eq!(
2771 merged.epochs.len(),
2772 2,
2773 "decimated 5-min inputs to the 900 s target"
2774 );
2775 }
2776
2777 #[test]
2778 fn merge_rejects_non_divisible_epoch_intervals() {
2779 let a = sp3_two_epochs(
2783 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2784 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2785 900.0,
2786 "IGS14",
2787 );
2788 let b = sp3_two_epochs(
2789 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2790 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2791 400.0,
2792 "IGS14",
2793 );
2794
2795 let err = merge(&[a, b], &MergeOptions::default()).expect_err("non-divisible intervals");
2796 assert!(
2797 err.to_string().contains("mismatched epoch intervals"),
2798 "{err}"
2799 );
2800 }
2801
2802 #[test]
2803 fn merge_rejects_a_non_whole_second_common_interval() {
2804 let mk = || {
2807 sp3_two_epochs(
2808 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2809 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2810 900.0,
2811 "IGS14",
2812 )
2813 };
2814 let opts = MergeOptions {
2815 target_epoch_interval_s: Some(450.5),
2816 ..MergeOptions::default()
2817 };
2818 let err = merge(&[mk(), mk()], &opts).expect_err("fractional target");
2819 assert!(err.to_string().contains("whole number of seconds"), "{err}");
2820 }
2821
2822 #[test]
2823 fn merge_header_first_epoch_describes_the_union_grid_start() {
2824 let a = sp3_epochs(
2827 0.0,
2828 &[
2829 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2830 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2831 &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
2832 ],
2833 900.0,
2834 "IGS14",
2835 );
2836 let b = sp3_epochs(
2837 900.0,
2838 &[
2839 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2840 &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
2841 &[("G01", [15003.0, -20003.0, 5003.0], Some(103.0))],
2842 ],
2843 900.0,
2844 "IGS14",
2845 );
2846
2847 let opts = MergeOptions {
2848 min_agree: 1,
2849 ..MergeOptions::default()
2850 };
2851 let (merged, _) = merge(&[a, b], &opts).expect("merge");
2852
2853 assert_eq!(
2854 merged.epochs.len(),
2855 4,
2856 "union epochs run from 00:00 to 00:45"
2857 );
2858 assert!(
2859 (merged.header.seconds_of_week - 345_600.0).abs() < 1.0e-6,
2860 "header sow must describe the union's first epoch 00:00 (345600 s), got {}",
2861 merged.header.seconds_of_week
2862 );
2863 assert!(
2864 merged.header.mjd_fraction.abs() < 1.0e-9,
2865 "header MJD fraction must describe 00:00, got {}",
2866 merged.header.mjd_fraction
2867 );
2868 }
2869
2870 #[test]
2871 fn merge_writer_recomputes_header_for_a_fine_union_grid() {
2872 let a = sp3_epochs(
2876 0.0,
2877 &[
2878 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2879 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2880 &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
2881 ],
2882 900.0,
2883 "IGS14",
2884 );
2885 let b = sp3_epochs(
2886 450.0,
2887 &[
2888 &[("G01", [15010.0, -20010.0, 5010.0], Some(110.0))],
2889 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2890 &[("G01", [15011.0, -20011.0, 5011.0], Some(111.0))],
2891 &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
2892 ],
2893 450.0,
2894 "IGS14",
2895 );
2896
2897 let opts = MergeOptions {
2898 min_agree: 1,
2899 ..MergeOptions::default()
2900 };
2901 let (merged, _) = merge(&[a, b], &opts).expect("mixed-cadence merge");
2902
2903 assert_eq!(merged.epochs.len(), 5, "union epochs run every 7.5 minutes");
2904 let text = merged.to_sp3_string();
2905 let header = text
2906 .lines()
2907 .find(|line| line.starts_with("## "))
2908 .expect("written ## header");
2909 let first_epoch = text
2910 .lines()
2911 .find(|line| line.starts_with("* "))
2912 .expect("written first epoch");
2913
2914 assert_eq!(first_epoch, "* 2020 6 25 0 0 0.00000000");
2915 assert_eq!(
2916 header,
2917 "## 2111 345600.00000000 450.00000000 59025 0.0000000000000"
2918 );
2919 }
2920
2921 #[test]
2922 fn precedence_merge_never_switches_source_within_one_satellite_arc() {
2923 let a = sp3_two_epochs(
2924 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2925 &[],
2926 900.0,
2927 "IGS14",
2928 );
2929 let b = sp3_two_epochs(
2930 &[("G01", [15000.001, -20000.0, 5000.0], Some(100.0))],
2931 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2932 900.0,
2933 "IGS14",
2934 );
2935 let opts = MergeOptions {
2936 combine: MergeCombine::Precedence,
2937 min_agree: 1,
2938 precedence_scope: MergePrecedenceScope::SatelliteArc,
2939 ..MergeOptions::default()
2940 };
2941
2942 let (merged, _report) = merge(&[a, b], &opts).expect("merge");
2943 let epoch0 = merged.states_at(0).expect("epoch 0");
2944 let epoch1 = merged.states_at(1).expect("epoch 1");
2945
2946 assert!(epoch0.contains_key(&gps(1)));
2947 assert!(
2948 !epoch1.contains_key(&gps(1)),
2949 "G01 must not switch from source 0 at epoch 0 to source 1 at epoch 1"
2950 );
2951 assert_eq!(merged.header.epoch_interval_s, 900.0);
2952 }
2953
2954 #[test]
2955 fn cell_precedence_fills_a_preferred_source_dropout() {
2956 let a = sp3_two_epochs(
2957 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2958 &[],
2959 900.0,
2960 "IGS14",
2961 );
2962 let b = sp3_two_epochs(
2963 &[("G01", [15000.001, -20000.0, 5000.0], Some(100.0))],
2964 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2965 900.0,
2966 "IGS14",
2967 );
2968 let opts = MergeOptions {
2969 combine: MergeCombine::Precedence,
2970 min_agree: 1,
2971 ..MergeOptions::default()
2972 };
2973
2974 let (merged, report) = merge(&[a, b], &opts).expect("merge");
2975
2976 assert!(merged.states_at(0).expect("epoch 0").contains_key(&gps(1)));
2977 let epoch1 = merged.states_at(1).expect("epoch 1");
2978 assert!(
2979 epoch1.contains_key(&gps(1)),
2980 "source 1 must fill source 0's dropout"
2981 );
2982 assert_eq!(epoch1[&gps(1)].position.as_array()[0], 15_001_000.0);
2983 assert!(report
2984 .single_source
2985 .iter()
2986 .any(|entry| entry.satellite == gps(1) && entry.sources == vec![1]));
2987 }
2988
2989 #[test]
2990 fn merge_filters_requested_constellations_and_header_satellites() {
2991 let a = sp3_two_epochs(
2992 &[
2993 ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
2994 ("E01", [21000.0, -1000.0, 13000.0], Some(120.0)),
2995 ],
2996 &[
2997 ("G01", [15001.0, -20001.0, 5001.0], Some(101.0)),
2998 ("E01", [21001.0, -1001.0, 13001.0], Some(121.0)),
2999 ],
3000 900.0,
3001 "IGS14",
3002 );
3003 let systems = BTreeSet::from([GnssSystem::Gps]);
3004 let opts = MergeOptions {
3005 systems: Some(systems),
3006 ..MergeOptions::default()
3007 };
3008
3009 let (merged, _report) = merge(&[a], &opts).expect("merge");
3010
3011 assert_eq!(merged.header.satellites, vec![gps(1)]);
3012 for idx in 0..merged.epochs.len() {
3013 let states = merged.states_at(idx).expect("epoch");
3014 assert_eq!(states.keys().copied().collect::<Vec<_>>(), vec![gps(1)]);
3015 }
3016 }
3017
3018 #[test]
3019 fn merge_preserves_a_clock_event_flag() {
3020 let a = sp3_build(
3023 &[(
3024 "G01",
3025 [15000.0, -20000.0, 5000.0],
3026 Some(100.0),
3027 " E",
3028 )],
3029 "IGS14",
3030 );
3031 let b = sp3_build(
3032 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
3033 "IGS14",
3034 );
3035
3036 let (merged, _) = merge(&[a, b], &MergeOptions::default()).expect("merge");
3037 let g01 = merged.states_at(0).expect("epoch 0")[&gps(1)];
3038
3039 assert!(
3040 g01.flags.clock_event,
3041 "merged cell must preserve a contributing source's clock-event flag"
3042 );
3043 }
3044
3045 #[test]
3046 fn merge_reports_effective_epoch_interval_from_actual_epochs() {
3047 let body = "#cP2020 6 25 0 0 0.00000000 2 ORBIT IGS14 FIT TST\n\
3051 ## 2111 432000.00000000 300.00000000 59025 0.0000000000000\n\
3052 + 1 G01 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
3053 ++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
3054 %c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3055 %c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3056 %f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n\
3057 %f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n\
3058 %i 0 0 0 0 0 0 0 0 0\n\
3059 %i 0 0 0 0 0 0 0 0 0\n\
3060 /* TEST SP3-c FIXTURE\n\
3061 * 2020 6 25 0 0 0.00000000\n\
3062 PG01 15000.000000 -20000.000000 5000.000000 100.000000\n\
3063 * 2020 6 25 0 15 0.00000000\n\
3064 PG01 15001.000000 -20001.000000 5001.000000 101.000000\n\
3065 EOF\n";
3066 let a = Sp3::parse(body.as_bytes()).expect("parse test sp3");
3067
3068 let (merged, _) = merge(&[a], &MergeOptions::default()).expect("merge");
3069
3070 assert!(
3071 (merged.header.epoch_interval_s - 900.0).abs() < 1.0e-6,
3072 "got {}",
3073 merged.header.epoch_interval_s
3074 );
3075 }
3076
3077 #[test]
3078 fn merge_rejects_unsorted_input_epochs_before_cadence_inference() {
3079 let body = "#cP2020 6 25 0 0 0.00000000 2 ORBIT IGS14 FIT TST\n\
3080 ## 2111 432000.00000000 900.00000000 59025 0.0000000000000\n\
3081 + 1 G01 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
3082 ++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
3083 %c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3084 %c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3085 %f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n\
3086 %f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n\
3087 %i 0 0 0 0 0 0 0 0 0\n\
3088 %i 0 0 0 0 0 0 0 0 0\n\
3089 /* TEST SP3-c FIXTURE\n\
3090 * 2020 6 25 0 15 0.00000000\n\
3091 PG01 15001.000000 -20001.000000 5001.000000 101.000000\n\
3092 * 2020 6 25 0 0 0.00000000\n\
3093 PG01 15000.000000 -20000.000000 5000.000000 100.000000\n\
3094 EOF\n";
3095 let source = Sp3::parse(body.as_bytes()).expect("parse unsorted test sp3");
3096
3097 let err = merge(&[source], &MergeOptions::default()).expect_err("unsorted epochs");
3098
3099 assert!(
3100 err.to_string()
3101 .contains("merge input epochs must be strictly increasing"),
3102 "{err}"
3103 );
3104 }
3105
3106 #[test]
3107 fn align_clock_reference_puts_other_on_the_reference_datum() {
3108 let reference = sp3([100.0, 200.0, 300.0]);
3111 let other = sp3([150.0, 250.0, 350.0]);
3112
3113 let aligned = align_clock_reference(&reference, &other, 3);
3114
3115 let g01 = aligned.states_at(0).expect("epoch 0")[&gps(1)];
3116 assert!(
3117 (g01.clock_s.unwrap() - 100.0e-6).abs() < 1.0e-15,
3118 "got {}",
3119 g01.clock_s.unwrap()
3120 );
3121 let original = other.states_at(0).expect("epoch 0")[&gps(1)];
3123 assert_eq!(g01.position.as_array(), original.position.as_array());
3124 }
3125
3126 fn sp3(clocks_us: [f64; 3]) -> Sp3 {
3130 let body = format!(
3131 "#cP2020 6 25 0 0 0.00000000 1 ORBIT IGS14 FIT TST\n\
3132 ## 2111 432000.00000000 900.00000000 59025 0.0000000000000\n\
3133 + 3 G01G02G03 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
3134 ++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
3135 %c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3136 %c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3137 %f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n\
3138 %f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n\
3139 %i 0 0 0 0 0 0 0 0 0\n\
3140 %i 0 0 0 0 0 0 0 0 0\n\
3141 /* TEST SP3-c FIXTURE\n\
3142 * 2020 6 25 0 0 0.00000000\n\
3143 PG01 15000.000000 -20000.000000 5000.000000 {:13.6}\n\
3144 PG02 -1234.567890 2345.678901 -3456.789012 {:13.6}\n\
3145 PG03 8000.000000 12000.000000 -19000.000000 {:13.6}\n\
3146 EOF\n",
3147 clocks_us[0], clocks_us[1], clocks_us[2]
3148 );
3149 Sp3::parse(body.as_bytes()).expect("parse test sp3")
3150 }
3151
3152 #[test]
3153 fn recovers_a_uniform_datum_shift() {
3154 let reference = sp3([100.0, 200.0, 300.0]);
3156 let other = sp3([150.0, 250.0, 350.0]);
3157
3158 let offsets = clock_reference_offset(&reference, &other, 3);
3159
3160 assert_eq!(offsets.len(), 1);
3161 assert_eq!(offsets[0].satellites, 3);
3162 assert!(
3163 (offsets[0].offset_s - 5.0e-5).abs() < 1.0e-12,
3164 "got {}",
3165 offsets[0].offset_s
3166 );
3167 }
3168
3169 #[test]
3170 fn median_rejects_a_single_outlier_clock() {
3171 let reference = sp3([100.0, 200.0, 300.0]);
3174 let other = sp3([150.0, 250.0, 9_300.0]);
3175
3176 let offsets = clock_reference_offset(&reference, &other, 3);
3177
3178 assert_eq!(offsets.len(), 1);
3179 assert!(
3180 (offsets[0].offset_s - 5.0e-5).abs() < 1.0e-12,
3181 "got {}",
3182 offsets[0].offset_s
3183 );
3184 }
3185
3186 #[test]
3187 fn omits_epochs_below_min_common() {
3188 let reference = sp3([100.0, 200.0, 300.0]);
3191 let other = sp3([150.0, 250.0, 350.0]);
3192
3193 assert!(clock_reference_offset(&reference, &other, 4).is_empty());
3194 }
3195
3196 #[test]
3197 fn merge_agreement_metric_reports_known_position_dispersion() {
3198 let a = sp3_records(&[("G01", [15000.000, -20000.0, 5000.0], Some(100.0))]);
3203 let b = sp3_records(&[("G01", [15000.003, -20000.0, 5000.0], Some(100.0))]);
3204 let c = sp3_records(&[("G01", [15000.006, -20000.0, 5000.0], Some(100.0))]);
3205 let opts = MergeOptions {
3206 position_tolerance_m: 10.0,
3207 min_agree: 3,
3208 combine: MergeCombine::Mean,
3209 ..MergeOptions::default()
3210 };
3211
3212 let (_merged, report) = merge(&[a, b, c], &opts).expect("merge");
3213
3214 assert_eq!(report.agreement.len(), 1, "one accepted cell");
3215 let m = report.agreement[0];
3216 assert_eq!(m.satellite, gps(1));
3217 assert_eq!(m.position_members, 3);
3218 assert!(
3219 (m.position_rms_m - 6.0_f64.sqrt()).abs() < 1.0e-6,
3220 "got rms {}",
3221 m.position_rms_m
3222 );
3223 assert!(
3224 (m.position_max_m - 3.0).abs() < 1.0e-6,
3225 "got max {}",
3226 m.position_max_m
3227 );
3228
3229 assert!((report.position_agreement_rms_m().unwrap() - 6.0_f64.sqrt()).abs() < 1.0e-6);
3231 assert!((report.position_agreement_max_m().unwrap() - 3.0).abs() < 1.0e-6);
3232
3233 let per_epoch = report.per_epoch_agreement();
3235 assert_eq!(per_epoch.len(), 1);
3236 assert_eq!(per_epoch[0].satellites, 1);
3237 assert!((per_epoch[0].position_rms_m - 6.0_f64.sqrt()).abs() < 1.0e-6);
3238 assert!((per_epoch[0].position_max_m - 3.0).abs() < 1.0e-6);
3239 }
3240
3241 #[test]
3242 fn merge_agreement_metric_reports_known_clock_dispersion() {
3243 let a = sp3([100.0, 200.0, 300.0]);
3249 let b = sp3([100.0, 200.0, 330.0]);
3250 let c = sp3([100.0, 200.0, 270.0]);
3251 let opts = MergeOptions {
3252 clock_min_common: 1,
3253 clock_tolerance_s: 1.0e-3,
3254 min_agree: 3,
3255 combine: MergeCombine::Mean,
3256 ..MergeOptions::default()
3257 };
3258
3259 let (_merged, report) = merge(&[a, b, c], &opts).expect("merge");
3260
3261 let g03 = report
3262 .agreement
3263 .iter()
3264 .find(|m| m.satellite == gps(3))
3265 .expect("G03 agreement metric");
3266 assert_eq!(g03.clock_members, 3);
3267 let expected_rms_s = 600.0_f64.sqrt() * 1.0e-6;
3268 assert!(
3269 (g03.clock_rms_s.unwrap() - expected_rms_s).abs() < 1.0e-15,
3270 "got clock rms {:?}",
3271 g03.clock_rms_s
3272 );
3273 assert!(
3274 (g03.clock_max_s.unwrap() - 30.0e-6).abs() < 1.0e-15,
3275 "got clock max {:?}",
3276 g03.clock_max_s
3277 );
3278 for prn in [1u8, 2] {
3280 let m = report
3281 .agreement
3282 .iter()
3283 .find(|m| m.satellite == gps(prn))
3284 .expect("metric");
3285 assert!(m.clock_rms_s.unwrap().abs() < 1.0e-18, "prn {prn}");
3286 assert!(m.position_rms_m.abs() < 1.0e-9, "prn {prn}");
3288 }
3289
3290 let pooled = report.clock_agreement_rms_s().expect("clock pool");
3294 assert!(
3295 (pooled - expected_rms_s / 3.0_f64.sqrt()).abs() < 1.0e-15,
3296 "got pooled {pooled}"
3297 );
3298 assert!((report.clock_agreement_max_s().unwrap() - 30.0e-6).abs() < 1.0e-15);
3299 }
3300
3301 #[cfg(sidereon_repo_tests)]
3329 #[test]
3330 fn merge_agrees_with_published_igs_combined_within_cm() {
3331 fn load(name: &str) -> Sp3 {
3332 let path = format!("{}/tests/fixtures/sp3/{}", env!("CARGO_MANIFEST_DIR"), name);
3333 let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("read {path}: {e}"));
3334 Sp3::parse(&bytes).unwrap_or_else(|e| panic!("parse {name}: {e}"))
3335 }
3336
3337 let cod = load("COD0OPSFIN_20261200945_02H30M_15M_ORB_trim.SP3");
3338 let gfz = load("GFZ0OPSFIN_20261200945_02H30M_15M_ORB_trim.SP3");
3339 let jpl = load("JPL0OPSFIN_20261200945_02H30M_15M_ORB_trim.SP3");
3340 let igs = load("IGS0OPSFIN_20261200945_02H30M_15M_ORB.SP3");
3341
3342 let (merged, report) =
3343 merge(&[cod, gfz, jpl], &MergeOptions::default()).expect("multi-center merge");
3344
3345 assert!(
3348 report.quarantined.is_empty(),
3349 "centers should agree: {:?}",
3350 report.quarantined
3351 );
3352 assert!(
3355 report.single_source.is_empty(),
3356 "{:?}",
3357 report.single_source
3358 );
3359 assert!(
3360 report.position_outliers.is_empty(),
3361 "{:?}",
3362 report.position_outliers
3363 );
3364 assert!(
3365 report.agreement.iter().all(|a| a.position_members == 3),
3366 "every agreement cell should be a 3-source consensus"
3367 );
3368
3369 let mut igs_idx: std::collections::BTreeMap<i64, usize> = std::collections::BTreeMap::new();
3370 for (i, ep) in igs.epochs.iter().enumerate() {
3371 if let Some(s) = super::instant_to_j2000_seconds(ep) {
3372 igs_idx.insert(s.floor() as i64, i);
3373 }
3374 }
3375
3376 let mut sumsq = 0.0_f64;
3377 let mut max = 0.0_f64;
3378 let mut n = 0usize;
3379 for (mi, ep) in merged.epochs.iter().enumerate() {
3380 let key = super::instant_to_j2000_seconds(ep)
3381 .expect("merged epoch key")
3382 .floor() as i64;
3383 let ii = *igs_idx.get(&key).expect("IGS combined covers merged epoch");
3384 let merged_states = merged.states_at(mi).expect("merged states");
3385 let igs_states = igs.states_at(ii).expect("IGS states");
3386 for (sat, mst) in merged_states.iter() {
3387 let ist = igs_states
3388 .get(sat)
3389 .unwrap_or_else(|| panic!("merged sat {sat} missing from IGS combined"));
3390 let d = super::dist3(&mst.position.as_array(), &ist.position.as_array());
3391 sumsq += d * d;
3392 max = max.max(d);
3393 n += 1;
3394 }
3395 }
3396
3397 assert_eq!(n, 88, "expected exactly 88 compared cells, got {n}");
3400 let rms = (sumsq / n as f64).sqrt();
3401 assert!(
3403 rms < 0.02,
3404 "combine-vs-IGS RMS {:.4} m ({} cells) exceeds the 2 cm gate",
3405 rms,
3406 n
3407 );
3408 assert!(
3409 max < 0.05,
3410 "combine-vs-IGS max {max:.4} m exceeds the 5 cm gate"
3411 );
3412
3413 let dispersion = report
3415 .position_agreement_rms_m()
3416 .expect("multi-source cells present");
3417 assert!(
3418 dispersion < 0.05,
3419 "inter-center position dispersion {dispersion:.4} m"
3420 );
3421 }
3422}