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 if let Some(systems) = &opts.systems {
758 if systems.is_empty() {
759 return Err(Error::InvalidInput(
760 "merge systems filter must not be empty".into(),
761 ));
762 }
763 }
764
765 let mut out_epochs: Vec<Instant> = Vec::with_capacity(epoch_keys.len());
766 let mut out_epoch_j2000_s: Vec<f64> = Vec::with_capacity(epoch_keys.len());
767 let mut out_states: Vec<BTreeMap<GnssSatelliteId, Sp3State>> =
768 Vec::with_capacity(epoch_keys.len());
769 let mut out_raw: Vec<BTreeMap<GnssSatelliteId, RawNode>> = Vec::with_capacity(epoch_keys.len());
770 let mut report = MergeReport {
771 frame_reconciliations,
772 ..MergeReport::default()
773 };
774 let mut all_sats: BTreeSet<GnssSatelliteId> = BTreeSet::new();
775
776 for (&key, &epoch) in &epoch_keys {
777 out_epochs.push(epoch);
778 out_epoch_j2000_s.push(key as f64);
779 let mut states: BTreeMap<GnssSatelliteId, Sp3State> = BTreeMap::new();
780 let mut raws: BTreeMap<GnssSatelliteId, RawNode> = BTreeMap::new();
781
782 let mut sats: BTreeSet<GnssSatelliteId> = BTreeSet::new();
785 for (idx, s) in sources.iter().enumerate() {
786 if let Some(&ei) = epoch_index[idx].get(&key) {
787 if let Ok(map) = s.states_at(ei) {
788 sats.extend(map.keys().copied().filter(|sat| allowed_system(sat)));
789 }
790 }
791 }
792
793 for sat in sats {
794 let arc_preferred_source = precedence_source_for_sat
800 .as_ref()
801 .and_then(|by_sat| by_sat.get(&sat).copied());
802
803 let mut pos: Vec<(usize, [f64; 3], Sp3Flags)> = Vec::new();
804 let mut clk: Vec<(usize, f64, Sp3Flags)> = Vec::new();
805 for (idx, s) in sources.iter().enumerate() {
806 let Some(&ei) = epoch_index[idx].get(&key) else {
807 continue;
808 };
809 let Ok(map) = s.states_at(ei) else { continue };
810 let Some(state) = map.get(&sat) else { continue };
811 pos.push((idx, state.position.as_array(), state.flags));
812 if let Some(c) = state.clock_s {
813 let offset = if idx == 0 {
814 Some(0.0)
815 } else {
816 clock_offset_at(&clock_offset[idx], key)
817 };
818 if let Some(off) = offset {
819 let aligned = c - off;
820 if aligned.is_finite() {
821 clk.push((idx, aligned, state.flags));
822 }
823 }
824 }
825 }
826
827 let position_preferred_source = match opts.precedence_scope {
828 MergePrecedenceScope::Cell => pos.first().map(|(source, _, _)| *source),
829 MergePrecedenceScope::SatelliteArc => arc_preferred_source,
830 };
831 let clock_preferred_source = match opts.precedence_scope {
832 MergePrecedenceScope::Cell => clk.first().map(|(source, _, _)| *source),
833 MergePrecedenceScope::SatelliteArc => arc_preferred_source,
834 };
835
836 let flag = |srcs: Vec<usize>| MergeFlag {
837 epoch,
838 satellite: sat,
839 sources: srcs,
840 };
841
842 let (position_m, pos_members) = if opts.combine == MergeCombine::Precedence {
847 let Some(preferred_source) = position_preferred_source else {
848 continue;
849 };
850 let Some(preferred_idx) =
851 pos.iter().position(|(src, _, _)| *src == preferred_source)
852 else {
853 continue;
854 };
855
856 if pos.len() == 1 {
857 report.single_source.push(flag(vec![pos[preferred_idx].0]));
858 (pos[preferred_idx].1, vec![preferred_idx])
859 } else if let Some(reject) = opts.outlier_reject {
860 let pts: Vec<[f64; 3]> = pos.iter().map(|(_, p, _)| *p).collect();
861 let cluster =
862 largest_within(&pts, |a, b| dist3(a, b) <= reject.position_tolerance_m);
863 if cluster.len() >= opts.min_agree.max(2) {
864 let selected_idx = if cluster.contains(&preferred_idx) {
865 preferred_idx
866 } else {
867 cluster[0]
868 };
869 let rejected: Vec<usize> = (0..pos.len())
870 .filter(|i| !cluster.contains(i))
871 .map(|i| pos[i].0)
872 .collect();
873 if !rejected.is_empty() {
874 report.position_outliers.push(flag(rejected));
875 }
876 (pos[selected_idx].1, cluster)
877 } else {
878 report
879 .quarantined
880 .push(flag(pos.iter().map(|(i, _, _)| *i).collect()));
881 continue;
882 }
883 } else {
884 let pts: Vec<[f64; 3]> = pos.iter().map(|(_, p, _)| *p).collect();
885 let cluster = largest_within_containing(&pts, preferred_idx, |a, b| {
886 dist3(a, b) <= opts.position_tolerance_m
887 });
888 if cluster.len() >= opts.min_agree {
889 let rejected: Vec<usize> = (0..pos.len())
890 .filter(|i| !cluster.contains(i))
891 .map(|i| pos[i].0)
892 .collect();
893 if !rejected.is_empty() {
894 report.position_outliers.push(flag(rejected));
895 }
896 (pos[preferred_idx].1, cluster)
897 } else {
898 report
899 .quarantined
900 .push(flag(pos.iter().map(|(i, _, _)| *i).collect()));
901 continue;
902 }
903 }
904 } else if pos.len() == 1 {
905 report.single_source.push(flag(vec![pos[0].0]));
906 (pos[0].1, vec![0usize])
907 } else {
908 let pts: Vec<[f64; 3]> = pos.iter().map(|(_, p, _)| *p).collect();
909 let cluster = largest_within(&pts, |a, b| dist3(a, b) <= opts.position_tolerance_m);
910 if cluster.len() >= opts.min_agree {
911 let rejected: Vec<usize> = (0..pos.len())
912 .filter(|i| !cluster.contains(i))
913 .map(|i| pos[i].0)
914 .collect();
915 if !rejected.is_empty() {
916 report.position_outliers.push(flag(rejected));
917 }
918 let members: Vec<(usize, [f64; 3])> =
919 cluster.iter().map(|&i| (pos[i].0, pos[i].1)).collect();
920 (combine3(&members, opts.combine), cluster)
921 } else {
922 report
923 .quarantined
924 .push(flag(pos.iter().map(|(i, _, _)| *i).collect()));
925 continue;
926 }
927 };
928
929 let (clock_s, clk_members): (Option<f64>, Vec<usize>) = if clk.is_empty() {
932 (None, Vec::new())
933 } else if opts.combine == MergeCombine::Precedence {
934 match clock_preferred_source
935 .and_then(|src| clk.iter().position(|(clock_src, _, _)| *clock_src == src))
936 {
937 None => (None, Vec::new()),
938 Some(preferred_idx) if clk.len() == 1 => {
939 (Some(clk[preferred_idx].1), vec![preferred_idx])
940 }
941 Some(preferred_idx) if opts.outlier_reject.is_some() => {
942 let reject = opts.outlier_reject.expect("checked above");
943 let vals: Vec<f64> = clk.iter().map(|(_, c, _)| *c).collect();
944 let cluster =
945 largest_within(&vals, |a, b| (a - b).abs() <= reject.clock_tolerance_s);
946 if cluster.len() >= opts.min_agree.max(2) {
947 let selected_idx = if cluster.contains(&preferred_idx) {
948 preferred_idx
949 } else {
950 cluster[0]
951 };
952 let rejected: Vec<usize> = (0..clk.len())
953 .filter(|i| !cluster.contains(i))
954 .map(|i| clk[i].0)
955 .collect();
956 if !rejected.is_empty() {
957 report.clock_outliers.push(flag(rejected));
958 }
959 (Some(clk[selected_idx].1), cluster)
960 } else {
961 report
962 .clock_outliers
963 .push(flag(clk.iter().map(|(source, _, _)| *source).collect()));
964 (None, Vec::new())
965 }
966 }
967 Some(preferred_idx) => {
968 let vals: Vec<f64> = clk.iter().map(|(_, c, _)| *c).collect();
969 let cluster = largest_within_containing(&vals, preferred_idx, |a, b| {
970 (a - b).abs() <= opts.clock_tolerance_s
971 });
972 if cluster.len() >= opts.min_agree {
973 let rejected: Vec<usize> = (0..clk.len())
974 .filter(|i| !cluster.contains(i))
975 .map(|i| clk[i].0)
976 .collect();
977 if !rejected.is_empty() {
978 report.clock_outliers.push(flag(rejected));
979 }
980 (Some(clk[preferred_idx].1), cluster)
981 } else {
982 (None, Vec::new())
983 }
984 }
985 }
986 } else if clk.len() == 1 {
987 (Some(clk[0].1), vec![0usize])
988 } else {
989 let vals: Vec<f64> = clk.iter().map(|(_, c, _)| *c).collect();
990 let cluster = largest_within(&vals, |a, b| (a - b).abs() <= opts.clock_tolerance_s);
991 if cluster.len() >= opts.min_agree {
992 let rejected: Vec<usize> = (0..clk.len())
993 .filter(|i| !cluster.contains(i))
994 .map(|i| clk[i].0)
995 .collect();
996 if !rejected.is_empty() {
997 report.clock_outliers.push(flag(rejected));
998 }
999 let members: Vec<(usize, f64)> =
1000 cluster.iter().map(|&i| (clk[i].0, clk[i].1)).collect();
1001 (Some(combine_axis(&members, opts.combine)), cluster)
1002 } else {
1003 (None, Vec::new())
1004 }
1005 };
1006
1007 let mut flags = Sp3Flags::default();
1012 for &i in &pos_members {
1013 flags.maneuver |= pos[i].2.maneuver;
1014 flags.orbit_predicted |= pos[i].2.orbit_predicted;
1015 }
1016 for &i in &clk_members {
1017 flags.clock_event |= clk[i].2.clock_event;
1018 flags.clock_predicted |= clk[i].2.clock_predicted;
1019 }
1020
1021 let (position_rms_m, position_max_m) =
1024 position_dispersion(&pos, &pos_members, &position_m);
1025 let (clock_members_n, clock_rms_s, clock_max_s) = match clock_s {
1026 Some(c) => {
1027 let (rms, max) = clock_dispersion(&clk, &clk_members, c);
1028 (clk_members.len(), Some(rms), Some(max))
1029 }
1030 None => (0, None, None),
1031 };
1032 report.agreement.push(AgreementMetric {
1033 epoch,
1034 satellite: sat,
1035 position_members: pos_members.len(),
1036 position_rms_m,
1037 position_max_m,
1038 clock_members: clock_members_n,
1039 clock_rms_s,
1040 clock_max_s,
1041 });
1042
1043 all_sats.insert(sat);
1044 states.insert(
1045 sat,
1046 Sp3State {
1047 position: ItrfPositionM::new(position_m[0], position_m[1], position_m[2])
1048 .expect("valid ITRF position"),
1049 clock_s,
1050 velocity: None,
1051 clock_rate_s_s: None,
1052 flags,
1053 },
1054 );
1055 raws.insert(
1056 sat,
1057 RawNode {
1058 km: [
1059 position_m[0] / KM_TO_M,
1060 position_m[1] / KM_TO_M,
1061 position_m[2] / KM_TO_M,
1062 ],
1063 clock_us: clock_s.map(|c| c * 1.0e6),
1064 clock_event: flags.clock_event,
1065 },
1066 );
1067 }
1068
1069 out_states.push(states);
1070 out_raw.push(raws);
1071 }
1072
1073 let first_key = Some(out_epoch_j2000_s[0].floor() as i64);
1078 let base_idx = sources
1079 .iter()
1080 .position(|s| {
1081 s.epochs
1082 .first()
1083 .and_then(|ep| sp3_epoch_j2000_seconds(s, 0, ep))
1084 .map(|sec| sec.floor() as i64)
1085 == first_key
1086 })
1087 .or_else(|| {
1088 sources
1089 .iter()
1090 .enumerate()
1091 .filter_map(|(i, s)| {
1092 s.epochs
1093 .first()
1094 .and_then(|ep| sp3_epoch_j2000_seconds(s, 0, ep))
1095 .map(|sec| (sec, i))
1096 })
1097 .min_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)))
1098 .map(|(_, i)| i)
1099 })
1100 .unwrap_or(0);
1101 let first_epoch_header = first_epoch_header_fields(&out_epochs[0]).ok_or_else(|| {
1102 Error::InvalidInput("merged SP3 first epoch cannot be represented in header fields".into())
1103 })?;
1104
1105 let satellites: Vec<_> = all_sats.into_iter().collect();
1106 let satellite_accuracy_codes = satellites
1107 .iter()
1108 .map(|sat| {
1109 sources[base_idx]
1110 .header
1111 .satellites
1112 .iter()
1113 .position(|base_sat| base_sat == sat)
1114 .and_then(|idx| {
1115 sources[base_idx]
1116 .header
1117 .satellite_accuracy_codes
1118 .get(idx)
1119 .copied()
1120 })
1121 .unwrap_or(0)
1122 })
1123 .collect();
1124
1125 let header = Sp3Header {
1126 num_epochs: out_epochs.len() as u64,
1127 satellites,
1128 satellite_accuracy_codes,
1129 data_type: Sp3DataType::Position,
1130 gnss_week: first_epoch_header.gnss_week,
1131 seconds_of_week: first_epoch_header.seconds_of_week,
1132 epoch_interval_s,
1133 mjd: first_epoch_header.mjd,
1134 mjd_fraction: first_epoch_header.mjd_fraction,
1135 ..sources[base_idx].header.clone()
1136 };
1137
1138 let merged = Sp3 {
1139 header,
1140 epochs: out_epochs,
1141 epoch_j2000_s: out_epoch_j2000_s,
1142 states: out_states,
1143 interp_raw: out_raw,
1144 comments: vec![format!("MERGED from {} SP3 products", sources.len())],
1145 skipped_records: sources.iter().map(|s| s.skipped_records).sum(),
1146 };
1147
1148 Ok((merged, report))
1149}
1150
1151fn reconcile_sp3_coordinate_labels(
1152 sources: &[Sp3],
1153 opts: &MergeOptions,
1154) -> Result<(Vec<Sp3>, Vec<Sp3FrameReconciliation>)> {
1155 let target_label = normalized_sp3_frame_label(&sources[0].header.coordinate_system);
1156 let mut prepared = sources.to_vec();
1157 let mut report = Vec::new();
1158
1159 for idx in 1..sources.len() {
1160 let source_label = normalized_sp3_frame_label(&sources[idx].header.coordinate_system);
1161 if source_label == target_label {
1162 continue;
1163 }
1164
1165 if let Some(asserted) = asserted_frame_label_set(
1166 &source_label,
1167 &target_label,
1168 &opts.frame_reconciliation.asserted_equivalent_label_sets,
1169 ) {
1170 prepared[idx].header.coordinate_system = target_label.clone();
1171 report.push(Sp3FrameReconciliation {
1172 source_index: idx,
1173 source_label,
1174 target_label: target_label.clone(),
1175 method: Sp3FrameReconciliationMethod::AssertedEquivalence,
1176 asserted_label_set: Some(asserted),
1177 source_frame: None,
1178 target_frame: None,
1179 catalog_source_frame: None,
1180 catalog_target_frame: None,
1181 catalog_inverse: false,
1182 reference_epoch_year: None,
1183 parameters: None,
1184 rates: None,
1185 provenance: None,
1186 epoch_year_span: None,
1187 records_affected: count_position_records(&sources[idx]),
1188 identity: true,
1189 });
1190 continue;
1191 }
1192
1193 if opts.frame_reconciliation.helmert {
1194 let from = sp3_coordinate_label_frame(&source_label).ok_or_else(|| {
1195 Error::InvalidInput(format!(
1196 "merge inputs have mismatched coordinate systems ({:?} vs {:?}); source label {:?} is not a known ITRF/IGS realization",
1197 sources[0].header.coordinate_system,
1198 sources[idx].header.coordinate_system,
1199 sources[idx].header.coordinate_system
1200 ))
1201 })?;
1202 let to = sp3_coordinate_label_frame(&target_label).ok_or_else(|| {
1203 Error::InvalidInput(format!(
1204 "merge inputs have mismatched coordinate systems ({:?} vs {:?}); target label {:?} is not a known ITRF/IGS realization",
1205 sources[0].header.coordinate_system,
1206 sources[idx].header.coordinate_system,
1207 sources[0].header.coordinate_system
1208 ))
1209 })?;
1210
1211 let transform_report = reconcile_source_by_helmert(
1212 &mut prepared[idx],
1213 idx,
1214 source_label,
1215 target_label.clone(),
1216 from,
1217 to,
1218 )?;
1219 report.push(transform_report);
1220 continue;
1221 }
1222
1223 return Err(Error::InvalidInput(format!(
1224 "merge inputs have mismatched coordinate systems ({:?} vs {:?})",
1225 sources[0].header.coordinate_system, sources[idx].header.coordinate_system
1226 )));
1227 }
1228
1229 Ok((prepared, report))
1230}
1231
1232fn asserted_frame_label_set(
1233 source_label: &str,
1234 target_label: &str,
1235 label_sets: &[Sp3FrameLabelSet],
1236) -> Option<Vec<String>> {
1237 label_sets.iter().find_map(|set| {
1238 if set.labels.contains(source_label) && set.labels.contains(target_label) {
1239 Some(set.labels.iter().cloned().collect())
1240 } else {
1241 None
1242 }
1243 })
1244}
1245
1246fn reconcile_source_by_helmert(
1247 source: &mut Sp3,
1248 source_index: usize,
1249 source_label: String,
1250 target_label: String,
1251 from: TerrestrialFrame,
1252 to: TerrestrialFrame,
1253) -> Result<Sp3FrameReconciliation> {
1254 let records_affected = count_position_records(source);
1255 let epoch_year_span = epoch_year_span(source);
1256 let identity = from == to;
1257
1258 if !identity {
1259 transform_sp3_positions(source, from, to)?;
1260 }
1261 source.header.coordinate_system = target_label.clone();
1262
1263 let published = published_transform_for_report(from, to);
1264 Ok(Sp3FrameReconciliation {
1265 source_index,
1266 source_label,
1267 target_label,
1268 method: Sp3FrameReconciliationMethod::Helmert,
1269 asserted_label_set: None,
1270 source_frame: Some(from),
1271 target_frame: Some(to),
1272 catalog_source_frame: published.map(|published| published.entry.from),
1273 catalog_target_frame: published.map(|published| published.entry.to),
1274 catalog_inverse: published.is_some_and(|published| published.inverse),
1275 reference_epoch_year: published.map(|published| published.entry.reference_epoch_year),
1276 parameters: published.map(|published| published.entry.parameters),
1277 rates: published.map(|published| published.entry.rates),
1278 provenance: published.map(|published| published.entry.provenance.to_string()),
1279 epoch_year_span,
1280 records_affected,
1281 identity,
1282 })
1283}
1284
1285fn transform_sp3_positions(
1286 source: &mut Sp3,
1287 from: TerrestrialFrame,
1288 to: TerrestrialFrame,
1289) -> Result<()> {
1290 let seconds_per_julian_year = DAYS_PER_JULIAN_YEAR * SECONDS_PER_DAY;
1291 for epoch_idx in 0..source.epochs.len() {
1292 let epoch_year = decimal_year(source.epochs[epoch_idx]);
1293 let states = &mut source.states[epoch_idx];
1294 let raw_nodes = &mut source.interp_raw[epoch_idx];
1295 for (sat, state) in states.iter_mut() {
1296 let position = TerrestrialPositionM::from_itrf(state.position);
1297 let velocity = state
1298 .velocity
1299 .map(|velocity| {
1300 let [vx, vy, vz] = velocity.as_array();
1301 TerrestrialVelocityMPerYear::new(
1302 vx * seconds_per_julian_year,
1303 vy * seconds_per_julian_year,
1304 vz * seconds_per_julian_year,
1305 )
1306 })
1307 .transpose()
1308 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1309 let transformed = frame_catalog::transform(position, velocity, from, to, epoch_year)
1310 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1311 let [x, y, z] = transformed.position.as_array();
1312 state.position = ItrfPositionM::new(x, y, z)
1313 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1314 state.velocity = transformed
1315 .velocity
1316 .map(|velocity| {
1317 let [vx, vy, vz] = velocity.as_array();
1318 ItrfVelocityMS::new(
1319 vx / seconds_per_julian_year,
1320 vy / seconds_per_julian_year,
1321 vz / seconds_per_julian_year,
1322 )
1323 })
1324 .transpose()
1325 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1326 if let Some(raw) = raw_nodes.get_mut(sat) {
1327 raw.km = [x / KM_TO_M, y / KM_TO_M, z / KM_TO_M];
1328 }
1329 }
1330 }
1331 Ok(())
1332}
1333
1334fn count_position_records(source: &Sp3) -> usize {
1335 source.states.iter().map(BTreeMap::len).sum()
1336}
1337
1338fn epoch_year_span(source: &Sp3) -> Option<[f64; 2]> {
1339 let first = source.epochs.first().copied().map(decimal_year)?;
1340 let last = source.epochs.last().copied().map(decimal_year)?;
1341 Some([first, last])
1342}
1343
1344fn decimal_year(epoch: Instant) -> f64 {
1345 let jd_midnight = julian_date_from_instant(epoch) + 0.5;
1346 let (year, _, _) = civil_from_julian_day_number(jd_midnight.floor() as i64);
1347 let days = if is_leap_year(year) { 366.0 } else { 365.0 };
1348 year as f64 + (fractional_day_of_year_from_instant(epoch) - 1.0) / days
1349}
1350
1351fn normalized_sp3_frame_label(label: &str) -> String {
1352 label.trim().to_string()
1353}
1354
1355fn sp3_coordinate_label_frame(label: &str) -> Option<TerrestrialFrame> {
1356 match label.trim() {
1357 "ITRF2020" | "ITRF20" | "IGS20" | "IGc20" => Some(TerrestrialFrame::Itrf2020),
1358 "ITRF2014" | "ITRF14" | "IGS14" | "IGb14" => Some(TerrestrialFrame::Itrf2014),
1359 "ITRF2008" | "ITRF08" | "IGS08" | "IGb08" => Some(TerrestrialFrame::Itrf2008),
1360 _ => None,
1361 }
1362}
1363
1364fn published_transform_for_report(
1365 from: TerrestrialFrame,
1366 to: TerrestrialFrame,
1367) -> Option<PublishedTransformForReport> {
1368 frame_catalog::catalog_entry(from, to)
1369 .map(|entry| PublishedTransformForReport {
1370 entry,
1371 inverse: false,
1372 })
1373 .or_else(|| {
1374 frame_catalog::catalog_entry(to, from).map(|entry| PublishedTransformForReport {
1375 entry,
1376 inverse: true,
1377 })
1378 })
1379}
1380
1381#[derive(Debug, Clone, Copy)]
1382struct PublishedTransformForReport {
1383 entry: &'static frame_catalog::HelmertTransform,
1384 inverse: bool,
1385}
1386
1387#[derive(Debug, Clone, Copy)]
1388struct FirstEpochHeaderFields {
1389 gnss_week: u32,
1390 seconds_of_week: f64,
1391 mjd: u32,
1392 mjd_fraction: f64,
1393}
1394
1395fn first_epoch_header_fields(epoch: &Instant) -> Option<FirstEpochHeaderFields> {
1396 let split = epoch.julian_date()?;
1397
1398 let mjd_day = mjd_from_jd(split.jd_whole);
1399 let mut mjd = mjd_day.floor();
1400 let mut mjd_fraction = split.fraction + (mjd_day - mjd);
1401 let fraction_days = mjd_fraction.floor();
1402 if fraction_days != 0.0 {
1403 mjd += fraction_days;
1404 mjd_fraction -= fraction_days;
1405 }
1406 if !(0.0..=u32::MAX as f64).contains(&mjd) {
1407 return None;
1408 }
1409
1410 let gps_seconds = instant_to_j2000_seconds(epoch)? + GPS_EPOCH_TO_J2000_S;
1411 let (gnss_week, seconds_of_week) = gnss::week_and_seconds_of_week(gps_seconds);
1412 if !(0.0..=u32::MAX as f64).contains(&gnss_week) {
1413 return None;
1414 }
1415
1416 Some(FirstEpochHeaderFields {
1417 gnss_week: gnss_week as u32,
1418 seconds_of_week,
1419 mjd: mjd as u32,
1420 mjd_fraction,
1421 })
1422}
1423
1424fn dist3(a: &[f64; 3], b: &[f64; 3]) -> f64 {
1425 vec3::norm3(vec3::sub3(*a, *b))
1426}
1427
1428fn position_dispersion(
1431 pos: &[(usize, [f64; 3], Sp3Flags)],
1432 members: &[usize],
1433 combined: &[f64; 3],
1434) -> (f64, f64) {
1435 let mut sumsq = 0.0;
1436 let mut max = 0.0_f64;
1437 for &i in members {
1438 let d = dist3(&pos[i].1, combined);
1439 sumsq += d * d;
1440 max = max.max(d);
1441 }
1442 ((sumsq / members.len().max(1) as f64).sqrt(), max)
1443}
1444
1445fn clock_dispersion(
1448 clk: &[(usize, f64, Sp3Flags)],
1449 members: &[usize],
1450 combined: f64,
1451) -> (f64, f64) {
1452 let mut sumsq = 0.0;
1453 let mut max = 0.0_f64;
1454 for &i in members {
1455 let d = (clk[i].1 - combined).abs();
1456 sumsq += d * d;
1457 max = max.max(d);
1458 }
1459 ((sumsq / members.len().max(1) as f64).sqrt(), max)
1460}
1461
1462fn clock_offset_at(offsets: &BTreeMap<i64, f64>, key: i64) -> Option<f64> {
1466 if let Some(offset) = offsets.get(&key) {
1467 return Some(*offset);
1468 }
1469 let (&before_key, &before) = offsets.range(..key).next_back()?;
1470 let (&after_key, &after) = offsets.range(key..).next()?;
1471 if after_key <= before_key {
1472 return None;
1473 }
1474 let fraction = (key - before_key) as f64 / (after_key - before_key) as f64;
1475 Some(before + fraction * (after - before))
1476}
1477
1478fn precedence_sources_for_satellites(
1479 sources: &[Sp3],
1480 epoch_index: &[BTreeMap<i64, usize>],
1481 epoch_keys: &BTreeMap<i64, Instant>,
1482 systems: Option<&BTreeSet<GnssSystem>>,
1483) -> BTreeMap<GnssSatelliteId, usize> {
1484 let mut by_sat = BTreeMap::new();
1485
1486 for (idx, source) in sources.iter().enumerate() {
1487 for key in epoch_keys.keys() {
1488 let Some(&epoch_idx) = epoch_index[idx].get(key) else {
1489 continue;
1490 };
1491 let Ok(states) = source.states_at(epoch_idx) else {
1492 continue;
1493 };
1494
1495 for sat in states.keys() {
1496 if systems.is_none_or(|allowed| allowed.contains(&sat.system)) {
1497 by_sat.entry(*sat).or_insert(idx);
1498 }
1499 }
1500 }
1501 }
1502
1503 by_sat
1504}
1505
1506fn validate_merge_options(opts: &MergeOptions) -> Result<()> {
1507 validate::finite_nonneg(opts.position_tolerance_m, "merge position tolerance meters")
1508 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1509 validate::finite_nonneg(opts.clock_tolerance_s, "merge clock tolerance seconds")
1510 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1511 if opts.min_agree == 0 {
1512 return Err(Error::InvalidInput(
1513 "merge minimum agreement must be at least one".into(),
1514 ));
1515 }
1516 if opts.clock_min_common == 0 {
1517 return Err(Error::InvalidInput(
1518 "merge minimum common clock satellites must be at least one".into(),
1519 ));
1520 }
1521 if let Some(reject) = opts.outlier_reject {
1522 validate::finite_nonneg(
1523 reject.position_tolerance_m,
1524 "merge outlier position tolerance meters",
1525 )
1526 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1527 validate::finite_nonneg(
1528 reject.clock_tolerance_s,
1529 "merge outlier clock tolerance seconds",
1530 )
1531 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1532 }
1533 Ok(())
1534}
1535
1536fn resolve_common_epoch_interval(sources: &[Sp3], target: Option<f64>) -> Result<f64> {
1545 let intervals: Vec<f64> = sources
1546 .iter()
1547 .enumerate()
1548 .map(|(idx, source)| {
1549 effective_epoch_interval_s(source)?.ok_or_else(|| {
1550 Error::InvalidInput(format!(
1551 "merge input {idx} has no usable positive epoch interval"
1552 ))
1553 })
1554 })
1555 .collect::<Result<Vec<_>>>()?;
1556
1557 let common = match target {
1558 Some(t) if t.is_finite() && t > 0.0 => t,
1559 Some(t) => {
1560 return Err(Error::InvalidInput(format!(
1561 "merge target epoch interval must be positive and finite, got {t}"
1562 )))
1563 }
1564 None => intervals.iter().copied().fold(f64::INFINITY, f64::min),
1565 };
1566
1567 if (common - common.round()).abs() > WHOLE_SECOND_EPS_S || common.round() < 1.0 {
1572 return Err(Error::InvalidInput(format!(
1573 "merge common epoch interval {common:.6} s must be a positive whole number of seconds"
1574 )));
1575 }
1576
1577 for (idx, interval) in intervals.iter().copied().enumerate() {
1578 if !divides_evenly(interval, common) && !divides_evenly(common, interval) {
1579 return Err(Error::InvalidInput(format!(
1580 "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)"
1581 )));
1582 }
1583 }
1584
1585 Ok(common)
1586}
1587
1588fn divides_evenly(interval: f64, common: f64) -> bool {
1591 if !(interval.is_finite() && interval > 0.0 && common.is_finite() && common > 0.0) {
1592 return false;
1593 }
1594 let k = (common / interval).round();
1595 k >= 1.0 && same_interval(k * interval, common)
1596}
1597
1598fn effective_epoch_interval_s(source: &Sp3) -> Result<Option<f64>> {
1599 let secs: Vec<f64> = source
1600 .epochs
1601 .iter()
1602 .filter_map(instant_to_j2000_seconds)
1603 .collect();
1604 validate::require_strictly_increasing(secs.iter().copied(), "merge input epochs").map_err(
1605 |error| Error::InvalidInput(format!("{} must be strictly increasing", error.field())),
1606 )?;
1607 let gaps: Vec<f64> = secs.windows(2).map(|w| w[1] - w[0]).collect();
1608
1609 if gaps.is_empty() {
1610 let header = source.header.epoch_interval_s;
1611 return Ok((header.is_finite() && header > 0.0).then_some(header));
1612 }
1613
1614 let interval = gaps[0];
1615 if gaps.iter().all(|g| same_interval(*g, interval)) {
1616 Ok(Some(interval))
1617 } else {
1618 Ok(None)
1619 }
1620}
1621
1622fn same_interval(a: f64, b: f64) -> bool {
1623 (a - b).abs() <= WHOLE_SECOND_EPS_S
1624}
1625
1626fn largest_within<T>(items: &[T], within: impl Fn(&T, &T) -> bool) -> Vec<usize> {
1631 let n = items.len();
1632 if n <= 1 {
1633 return (0..n).collect();
1634 }
1635 let graph = agreement_graph(items, within);
1636 if n > MAX_EXACT_CLIQUE_NODES {
1637 return greedy_largest_clique(&graph);
1638 }
1639 let mut best = vec![0];
1640 let mut current = Vec::new();
1641 max_clique_search(&graph, &mut current, (0..n).collect(), &mut best);
1642 best
1643}
1644
1645fn largest_within_containing<T>(
1646 items: &[T],
1647 required: usize,
1648 within: impl Fn(&T, &T) -> bool,
1649) -> Vec<usize> {
1650 let n = items.len();
1651 if n == 0 || required >= n {
1652 return Vec::new();
1653 }
1654 if n == 1 {
1655 return vec![required];
1656 }
1657
1658 let graph = agreement_graph(items, within);
1659 if n > MAX_EXACT_CLIQUE_NODES {
1660 return greedy_largest_clique_containing(&graph, required);
1661 }
1662 let candidates = (0..n)
1663 .filter(|&idx| idx != required && graph[required][idx])
1664 .collect();
1665 let mut best = vec![required];
1666 let mut current = vec![required];
1667 max_clique_search(&graph, &mut current, candidates, &mut best);
1668 best
1669}
1670
1671fn agreement_graph<T>(items: &[T], within: impl Fn(&T, &T) -> bool) -> Vec<Vec<bool>> {
1672 let n = items.len();
1673 let mut graph = vec![vec![false; n]; n];
1674 for i in 0..n {
1675 graph[i][i] = true;
1676 for j in i + 1..n {
1677 let agrees = within(&items[i], &items[j]);
1678 graph[i][j] = agrees;
1679 graph[j][i] = agrees;
1680 }
1681 }
1682 graph
1683}
1684
1685fn greedy_largest_clique(graph: &[Vec<bool>]) -> Vec<usize> {
1686 let mut best = Vec::new();
1687 for seed in 0..graph.len() {
1688 let candidate = greedy_clique_from_seed(graph, seed);
1689 update_best_clique(&candidate, &mut best);
1690 }
1691 best
1692}
1693
1694fn greedy_largest_clique_containing(graph: &[Vec<bool>], required: usize) -> Vec<usize> {
1695 if required >= graph.len() {
1696 return Vec::new();
1697 }
1698 greedy_clique_from_seed(graph, required)
1699}
1700
1701fn greedy_clique_from_seed(graph: &[Vec<bool>], seed: usize) -> Vec<usize> {
1702 let mut clique = vec![seed];
1703 for (idx, _) in graph.iter().enumerate() {
1704 if idx == seed {
1705 continue;
1706 }
1707 if clique.iter().all(|&member| graph[member][idx]) {
1708 clique.push(idx);
1709 }
1710 }
1711 clique.sort_unstable();
1712 clique
1713}
1714
1715fn max_clique_search(
1716 graph: &[Vec<bool>],
1717 current: &mut Vec<usize>,
1718 mut candidates: Vec<usize>,
1719 best: &mut Vec<usize>,
1720) {
1721 candidates.sort_unstable();
1722 for (pos, &candidate) in candidates.iter().enumerate() {
1723 let remaining = candidates.len() - pos;
1724 if current.len() + remaining < best.len() {
1725 break;
1726 }
1727
1728 let next_candidates = candidates[pos + 1..]
1729 .iter()
1730 .copied()
1731 .filter(|&idx| graph[candidate][idx])
1732 .collect();
1733
1734 current.push(candidate);
1735 update_best_clique(current, best);
1736 max_clique_search(graph, current, next_candidates, best);
1737 current.pop();
1738 }
1739}
1740
1741fn update_best_clique(current: &[usize], best: &mut Vec<usize>) {
1742 let mut candidate = current.to_vec();
1743 candidate.sort_unstable();
1744 if candidate.len() > best.len()
1745 || (candidate.len() == best.len() && candidate.as_slice() < best.as_slice())
1746 {
1747 *best = candidate;
1748 }
1749}
1750
1751fn combine3(members: &[(usize, [f64; 3])], how: MergeCombine) -> [f64; 3] {
1752 [0usize, 1, 2].map(|axis| {
1753 let axis_members: Vec<(usize, f64)> = members.iter().map(|(s, v)| (*s, v[axis])).collect();
1754 combine_axis(&axis_members, how)
1755 })
1756}
1757
1758fn combine_axis(members: &[(usize, f64)], how: MergeCombine) -> f64 {
1759 match how {
1760 MergeCombine::Mean => members.iter().map(|(_, v)| *v).sum::<f64>() / members.len() as f64,
1761 MergeCombine::Median => {
1762 let mut vals: Vec<f64> = members.iter().map(|(_, v)| *v).collect();
1763 median(&mut vals).expect("consensus cluster is non-empty")
1764 }
1765 MergeCombine::Precedence => members
1766 .iter()
1767 .min_by_key(|(s, _)| *s)
1768 .map(|(_, v)| *v)
1769 .expect("consensus cluster is non-empty"),
1770 }
1771}
1772
1773pub fn align_clock_reference(reference: &Sp3, other: &Sp3, min_common: usize) -> Sp3 {
1787 let offsets: BTreeMap<i64, f64> = clock_reference_offset(reference, other, min_common)
1788 .into_iter()
1789 .filter_map(|o| {
1790 instant_to_j2000_seconds(&o.epoch).map(|sec| (sec.floor() as i64, o.offset_s))
1791 })
1792 .collect();
1793
1794 let mut aligned = other.clone();
1795 for ei in 0..aligned.epochs.len() {
1796 let Some(sec) = sp3_epoch_j2000_seconds(&aligned, ei, &aligned.epochs[ei]) else {
1797 continue;
1798 };
1799 let Some(&off) = offsets.get(&(sec.floor() as i64)) else {
1800 continue;
1801 };
1802 for state in aligned.states[ei].values_mut() {
1803 if let Some(c) = state.clock_s.as_mut() {
1804 *c -= off;
1805 }
1806 }
1807 for node in aligned.interp_raw[ei].values_mut() {
1808 if let Some(us) = node.clock_us.as_mut() {
1809 *us -= off * 1.0e6;
1810 }
1811 }
1812 }
1813 aligned
1814}
1815
1816#[cfg(test)]
1817mod tests {
1818 use super::super::Sp3;
1819 use super::{
1820 align_clock_reference, clock_reference_offset, merge, MergeCombine, MergeOptions,
1821 MergePrecedenceScope, MergeReport, OutlierRejectOptions, Sp3FrameLabelSet,
1822 Sp3FrameReconciliationMethod,
1823 };
1824 use crate::constants::SECONDS_PER_DAY;
1825 use crate::id::{GnssSatelliteId, GnssSystem};
1826 use std::collections::BTreeSet;
1827
1828 type SatSample<'a> = (&'a str, [f64; 3], Option<f64>);
1831
1832 fn gps(prn: u8) -> GnssSatelliteId {
1833 GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
1834 }
1835
1836 fn sp3_build(records: &[(&str, [f64; 3], Option<f64>, &str)], cs: &str) -> Sp3 {
1842 let n = records.len();
1843 let mut sats = String::new();
1844 for (sat, _, _, _) in records {
1845 sats.push_str(sat);
1846 }
1847 for _ in n..17 {
1848 sats.push_str(" 0");
1849 }
1850 let mut body = String::new();
1851 body.push_str(&format!(
1852 "#cP2020 6 25 0 0 0.00000000 1 ORBIT {cs} FIT TST\n"
1853 ));
1854 body.push_str("## 2111 432000.00000000 900.00000000 59025 0.0000000000000\n");
1855 body.push_str(&format!("+ {n:2} {sats}\n"));
1856 body.push_str("++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n");
1857 body.push_str("%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1858 body.push_str("%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1859 body.push_str("%f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n");
1860 body.push_str("%f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n");
1861 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1862 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1863 body.push_str("/* TEST SP3-c FIXTURE\n");
1864 body.push_str("* 2020 6 25 0 0 0.00000000\n");
1865 for (sat, p, clk, flags) in records {
1866 let c = clk.unwrap_or(999_999.999_999);
1867 body.push_str(&format!(
1868 "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}{flags}\n",
1869 p[0], p[1], p[2]
1870 ));
1871 }
1872 body.push_str("EOF\n");
1873 Sp3::parse(body.as_bytes()).expect("parse test sp3")
1874 }
1875
1876 fn sp3_records(records: &[(&str, [f64; 3], Option<f64>)]) -> Sp3 {
1878 let full: Vec<(&str, [f64; 3], Option<f64>, &str)> =
1879 records.iter().map(|(s, p, c)| (*s, *p, *c, "")).collect();
1880 sp3_build(&full, "IGS14")
1881 }
1882
1883 fn sp3_two_epochs(
1884 epoch0: &[(&str, [f64; 3], Option<f64>)],
1885 epoch1: &[(&str, [f64; 3], Option<f64>)],
1886 interval_s: f64,
1887 cs: &str,
1888 ) -> Sp3 {
1889 let mut sats: Vec<&str> = epoch0
1890 .iter()
1891 .chain(epoch1.iter())
1892 .map(|(sat, _, _)| *sat)
1893 .collect();
1894 sats.sort_unstable();
1895 sats.dedup();
1896 let n = sats.len();
1897 let mut sat_field = String::new();
1898 for sat in &sats {
1899 sat_field.push_str(sat);
1900 }
1901 for _ in n..17 {
1902 sat_field.push_str(" 0");
1903 }
1904
1905 let mut body = String::new();
1906 body.push_str(&format!(
1907 "#cP2020 6 25 0 0 0.00000000 2 ORBIT {cs} FIT TST\n"
1908 ));
1909 body.push_str(&format!(
1910 "## 2111 432000.00000000 {interval_s:14.8} 59025 0.0000000000000\n"
1911 ));
1912 body.push_str(&format!("+ {n:2} {sat_field}\n"));
1913 body.push_str("++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n");
1914 body.push_str("%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1915 body.push_str("%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1916 body.push_str("%f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n");
1917 body.push_str("%f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n");
1918 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1919 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1920 body.push_str("/* TEST SP3-c FIXTURE\n");
1921 body.push_str("* 2020 6 25 0 0 0.00000000\n");
1922 for (sat, p, clk) in epoch0 {
1923 let c = clk.unwrap_or(999_999.999_999);
1924 body.push_str(&format!(
1925 "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}\n",
1926 p[0], p[1], p[2]
1927 ));
1928 }
1929 let second_hour = (interval_s as i64) / 3600;
1930 let second_minute = ((interval_s as i64) % 3600) / 60;
1931 let second_second = (interval_s as i64) % 60;
1932 body.push_str(&format!(
1933 "* 2020 6 25 {second_hour:2} {second_minute:2} {second_second:2}.00000000\n"
1934 ));
1935 for (sat, p, clk) in epoch1 {
1936 let c = clk.unwrap_or(999_999.999_999);
1937 body.push_str(&format!(
1938 "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}\n",
1939 p[0], p[1], p[2]
1940 ));
1941 }
1942 body.push_str("EOF\n");
1943 Sp3::parse(body.as_bytes()).expect("parse test sp3")
1944 }
1945
1946 fn sp3_epochs(
1948 start_offset_s: f64,
1949 epochs: &[&[SatSample<'_>]],
1950 interval_s: f64,
1951 cs: &str,
1952 ) -> Sp3 {
1953 let mut sats: Vec<&str> = epochs
1954 .iter()
1955 .flat_map(|e| e.iter().map(|(sat, _, _)| *sat))
1956 .collect();
1957 sats.sort_unstable();
1958 sats.dedup();
1959 let n = sats.len();
1960 let mut sat_field = String::new();
1961 for sat in &sats {
1962 sat_field.push_str(sat);
1963 }
1964 for _ in n..17 {
1965 sat_field.push_str(" 0");
1966 }
1967
1968 let hms = |t: i64| (t / 3600, (t % 3600) / 60, t % 60);
1969 let start = start_offset_s as i64;
1970 let (sh, sm, ss0) = hms(start);
1971
1972 let mut body = String::new();
1973 body.push_str(&format!(
1974 "#cP2020 6 25 {sh:2} {sm:2} {ss0:2}.00000000 {:2} ORBIT {cs} FIT TST\n",
1975 epochs.len()
1976 ));
1977 let sow = 432_000.0 + start_offset_s;
1979 let mjd_frac = start_offset_s / SECONDS_PER_DAY;
1980 body.push_str(&format!(
1981 "## 2111 {sow:15.8} {interval_s:14.8} 59025 {mjd_frac:.13}\n"
1982 ));
1983 body.push_str(&format!("+ {n:2} {sat_field}\n"));
1984 body.push_str("++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n");
1985 body.push_str("%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1986 body.push_str("%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1987 body.push_str("%f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n");
1988 body.push_str("%f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n");
1989 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1990 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1991 body.push_str("/* TEST SP3-c FIXTURE\n");
1992 for (k, recs) in epochs.iter().enumerate() {
1993 let (hh, mm, ss) = hms(start + (k as i64) * (interval_s as i64));
1994 body.push_str(&format!("* 2020 6 25 {hh:2} {mm:2} {ss:2}.00000000\n"));
1995 for (sat, p, clk) in recs.iter() {
1996 let c = clk.unwrap_or(999_999.999_999);
1997 body.push_str(&format!(
1998 "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}\n",
1999 p[0], p[1], p[2]
2000 ));
2001 }
2002 }
2003 body.push_str("EOF\n");
2004 Sp3::parse(body.as_bytes()).expect("parse test sp3")
2005 }
2006
2007 #[test]
2008 fn merge_unions_coverage_when_one_center_misses_a_satellite() {
2009 let a = sp3_records(&[
2012 ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
2013 ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
2014 ("G03", [17000.0, -22000.0, 7000.0], Some(300.0)),
2015 ]);
2016 let b = sp3_records(&[
2017 ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
2018 ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
2019 ]);
2020
2021 let (merged, report) = merge(&[a, b], &MergeOptions::default()).expect("merge");
2022
2023 let states = merged.states_at(0).expect("epoch 0");
2024 assert!(
2025 states.contains_key(&gps(3)),
2026 "merged output must cover G03 from the center that has it"
2027 );
2028 assert_eq!(states.len(), 3, "union is G01/G02/G03");
2029 let g01 = states[&gps(1)];
2031 assert!((g01.clock_s.unwrap() - 100.0e-6).abs() < 1.0e-15);
2032 assert!(report.quarantined.is_empty());
2034 assert_eq!(report.single_source.len(), 1);
2035 assert_eq!(report.single_source[0].satellite, gps(3));
2036
2037 let frac = report
2041 .single_source_fraction()
2042 .expect("accepted cells present");
2043 assert!(
2044 (frac - 1.0 / 3.0).abs() < 1.0e-12,
2045 "single-source fraction {frac}"
2046 );
2047 assert_eq!(MergeReport::default().single_source_fraction(), None);
2048 }
2049
2050 #[test]
2051 fn merge_combines_two_of_three_agreeing_sources_and_rejects_the_outlier() {
2052 let a = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))]);
2054 let b = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))]);
2055 let c = sp3_records(&[("G01", [15000.010, -20000.0, 5000.0], Some(100.0))]);
2056
2057 let (merged, report) = merge(&[a, b, c], &MergeOptions::default()).expect("merge");
2058
2059 let states = merged.states_at(0).expect("epoch 0");
2060 let g01 = states[&gps(1)];
2061 assert!(
2063 (g01.position.as_array()[0] - 15_000_000.0).abs() < 1.0e-3,
2064 "got {}",
2065 g01.position.as_array()[0]
2066 );
2067 assert_eq!(report.position_outliers.len(), 1);
2069 assert_eq!(report.position_outliers[0].sources, vec![2]);
2070 assert!(report.quarantined.is_empty());
2071 }
2072
2073 #[test]
2074 fn guarded_precedence_replaces_a_corrupt_preferred_position() {
2075 let preferred = sp3_records(&[("G01", [16000.0, -20000.0, 5000.0], None)]);
2076 let agreeing_a = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], None)]);
2077 let agreeing_b = sp3_records(&[("G01", [15000.0002, -20000.0, 5000.0], None)]);
2078 let opts = MergeOptions {
2079 combine: MergeCombine::Precedence,
2080 min_agree: 1,
2081 outlier_reject: Some(OutlierRejectOptions {
2082 position_tolerance_m: 0.5,
2083 clock_tolerance_s: 5.0e-9,
2084 }),
2085 ..MergeOptions::default()
2086 };
2087
2088 let (merged, report) = merge(&[preferred, agreeing_a, agreeing_b], &opts).expect("merge");
2089
2090 let x = merged.states_at(0).expect("epoch")[&gps(1)]
2091 .position
2092 .as_array()[0];
2093 assert_eq!(
2094 x, 15_000_000.0,
2095 "earliest member of the 2-source cluster wins"
2096 );
2097 assert_eq!(report.position_outliers.len(), 1);
2098 assert_eq!(report.position_outliers[0].sources, vec![0]);
2099 }
2100
2101 #[test]
2102 fn unguarded_precedence_preserves_the_existing_preferred_value_behavior() {
2103 let preferred = sp3_records(&[("G01", [16000.0, -20000.0, 5000.0], None)]);
2104 let agreeing_a = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], None)]);
2105 let agreeing_b = sp3_records(&[("G01", [15000.0002, -20000.0, 5000.0], None)]);
2106 let opts = MergeOptions {
2107 combine: MergeCombine::Precedence,
2108 min_agree: 1,
2109 outlier_reject: None,
2110 ..MergeOptions::default()
2111 };
2112
2113 let (merged, report) = merge(&[preferred, agreeing_a, agreeing_b], &opts).expect("merge");
2114
2115 let x = merged.states_at(0).expect("epoch")[&gps(1)]
2116 .position
2117 .as_array()[0];
2118 assert_eq!(x, 16_000_000.0);
2119 assert_eq!(report.position_outliers[0].sources, vec![1, 2]);
2120 }
2121
2122 #[test]
2123 fn guarded_precedence_keeps_a_preferred_member_of_the_majority() {
2124 let preferred = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], None)]);
2125 let agreeing = sp3_records(&[("G01", [15000.0002, -20000.0, 5000.0], None)]);
2126 let outlier = sp3_records(&[("G01", [16000.0, -20000.0, 5000.0], None)]);
2127 let opts = MergeOptions {
2128 combine: MergeCombine::Precedence,
2129 min_agree: 1,
2130 outlier_reject: Some(OutlierRejectOptions {
2131 position_tolerance_m: 0.5,
2132 clock_tolerance_s: 5.0e-9,
2133 }),
2134 ..MergeOptions::default()
2135 };
2136
2137 let (merged, report) = merge(&[preferred, agreeing, outlier], &opts).expect("merge");
2138
2139 let x = merged.states_at(0).expect("epoch")[&gps(1)]
2140 .position
2141 .as_array()[0];
2142 assert_eq!(x, 15_000_000.0);
2143 assert_eq!(report.position_outliers[0].sources, vec![2]);
2144 }
2145
2146 #[test]
2147 fn guarded_precedence_keeps_a_single_source_cell() {
2148 let only = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], None)]);
2149 let opts = MergeOptions {
2150 combine: MergeCombine::Precedence,
2151 min_agree: 1,
2152 outlier_reject: Some(OutlierRejectOptions {
2153 position_tolerance_m: 0.5,
2154 clock_tolerance_s: 5.0e-9,
2155 }),
2156 ..MergeOptions::default()
2157 };
2158
2159 let (merged, report) = merge(&[only], &opts).expect("merge");
2160
2161 assert!(merged.states_at(0).expect("epoch").contains_key(&gps(1)));
2162 assert_eq!(report.single_source.len(), 1);
2163 assert!(report.quarantined.is_empty());
2164 }
2165
2166 #[test]
2167 fn guarded_precedence_position_tolerance_is_inclusive() {
2168 for (delta_km, accepted) in [(0.000_499, true), (0.000_501, false)] {
2169 let a = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], None)]);
2170 let b = sp3_records(&[("G01", [15000.0 + delta_km, -20000.0, 5000.0], None)]);
2171 let opts = MergeOptions {
2172 combine: MergeCombine::Precedence,
2173 min_agree: 1,
2174 outlier_reject: Some(OutlierRejectOptions {
2175 position_tolerance_m: 0.5,
2176 clock_tolerance_s: 5.0e-9,
2177 }),
2178 ..MergeOptions::default()
2179 };
2180
2181 let (merged, report) = merge(&[a, b], &opts).expect("merge");
2182 assert_eq!(
2183 merged.states_at(0).expect("epoch").contains_key(&gps(1)),
2184 accepted,
2185 "delta {delta_km} km"
2186 );
2187 assert_eq!(report.quarantined.is_empty(), accepted);
2188 }
2189
2190 assert_eq!(
2191 super::largest_within(&[0.0_f64, 0.5_f64], |a, b| (*a - *b).abs() <= 0.5).len(),
2192 2,
2193 "the tolerance boundary itself is accepted"
2194 );
2195 }
2196
2197 #[test]
2198 fn guarded_precedence_replaces_a_corrupt_preferred_clock() {
2199 let positions = |clock_g01: f64| {
2200 sp3_records(&[
2201 ("G01", [15000.0, -20000.0, 5000.0], Some(clock_g01)),
2202 ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
2203 ("G03", [17000.0, -22000.0, 7000.0], Some(300.0)),
2204 ("G04", [18000.0, -23000.0, 8000.0], Some(400.0)),
2205 ("G05", [19000.0, -24000.0, 9000.0], Some(500.0)),
2206 ])
2207 };
2208 let opts = MergeOptions {
2209 combine: MergeCombine::Precedence,
2210 min_agree: 1,
2211 outlier_reject: Some(OutlierRejectOptions {
2212 position_tolerance_m: 0.5,
2213 clock_tolerance_s: 5.0e-9,
2214 }),
2215 ..MergeOptions::default()
2216 };
2217
2218 let (merged, report) = merge(
2219 &[positions(1100.0), positions(100.0), positions(100.0)],
2220 &opts,
2221 )
2222 .expect("merge");
2223
2224 let clock = merged.states_at(0).expect("epoch")[&gps(1)]
2225 .clock_s
2226 .expect("consensus clock");
2227 assert!((clock - 100.0e-6).abs() < 1.0e-15, "clock {clock}");
2228 let rejected = report
2229 .clock_outliers
2230 .iter()
2231 .find(|entry| entry.satellite == gps(1))
2232 .expect("clock outlier provenance");
2233 assert_eq!(rejected.sources, vec![0]);
2234 }
2235
2236 #[test]
2237 fn merge_consensus_handles_more_than_u32_mask_bits() {
2238 let sources: Vec<Sp3> = (0..33)
2241 .map(|idx| {
2242 let x_km = if idx < 32 { 15000.0 } else { 15000.010 };
2243 sp3_records(&[("G01", [x_km, -20000.0, 5000.0], Some(100.0))])
2244 })
2245 .collect();
2246
2247 for combine in [MergeCombine::Mean, MergeCombine::Precedence] {
2248 let opts = MergeOptions {
2249 combine,
2250 min_agree: 32,
2251 ..MergeOptions::default()
2252 };
2253
2254 let (merged, report) = merge(&sources, &opts).expect("33-source merge");
2255
2256 let states = merged.states_at(0).expect("epoch 0");
2257 let g01 = states[&gps(1)];
2258 assert!(
2259 (g01.position.as_array()[0] - 15_000_000.0).abs() < 1.0e-3,
2260 "{combine:?}: got {}",
2261 g01.position.as_array()[0]
2262 );
2263 assert_eq!(
2264 report.position_outliers.len(),
2265 1,
2266 "{combine:?}: expected one outlier report"
2267 );
2268 assert_eq!(report.position_outliers[0].sources, vec![32]);
2269 assert!(report.quarantined.is_empty(), "{combine:?}");
2270 }
2271 }
2272
2273 #[test]
2274 fn merge_bounds_large_overlap_clique_search() {
2275 let sources: Vec<Sp3> = (0..40)
2276 .map(|idx| {
2277 let x_km = if idx % 2 == 0 { 15000.0 } else { 15000.010 };
2278 sp3_records(&[("G01", [x_km, -20000.0, 5000.0], Some(100.0))])
2279 })
2280 .collect();
2281 let opts = MergeOptions {
2282 min_agree: 20,
2283 ..MergeOptions::default()
2284 };
2285
2286 let (merged, report) = merge(&sources, &opts).expect("bounded large-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 "got {}",
2293 g01.position.as_array()[0]
2294 );
2295 assert_eq!(report.position_outliers.len(), 1);
2296 assert_eq!(
2297 report.position_outliers[0].sources,
2298 (1..40).step_by(2).collect::<Vec<_>>()
2299 );
2300 assert!(report.quarantined.is_empty());
2301 }
2302
2303 #[test]
2304 fn merge_quarantines_a_satellite_all_centers_disagree_on() {
2305 let a = sp3_records(&[("G01", [15000.000, -20000.0, 5000.0], Some(100.0))]);
2307 let b = sp3_records(&[("G01", [15000.010, -20000.0, 5000.0], Some(100.0))]);
2308 let c = sp3_records(&[("G01", [15000.020, -20000.0, 5000.0], Some(100.0))]);
2309
2310 let (merged, report) = merge(&[a, b, c], &MergeOptions::default()).expect("merge");
2311
2312 assert!(
2313 merged.states_at(0).expect("epoch 0").is_empty(),
2314 "no consensus -> G01 omitted, not averaged across disagreeing centers"
2315 );
2316 assert_eq!(report.quarantined.len(), 1);
2317 assert_eq!(report.quarantined[0].satellite, gps(1));
2318 }
2319
2320 #[test]
2321 fn merge_rejects_an_empty_input() {
2322 assert!(merge(&[], &MergeOptions::default()).is_err());
2323 }
2324
2325 #[test]
2326 fn merge_omits_an_unalignable_secondary_clock() {
2327 let a = sp3_records(&[
2332 ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
2333 ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
2334 ("G03", [17000.0, -22000.0, 7000.0], Some(300.0)),
2335 ]);
2336 let b = sp3_records(&[
2337 ("G01", [15000.0, -20000.0, 5000.0], Some(150.0)),
2338 ("G02", [16000.0, -21000.0, 6000.0], Some(250.0)),
2339 ("G03", [17000.0, -22000.0, 7000.0], Some(350.0)),
2340 ("G04", [18000.0, -23000.0, 8000.0], Some(450.0)),
2341 ]);
2342
2343 let (merged, _) = merge(&[a, b], &MergeOptions::default()).expect("merge");
2344 let states = merged.states_at(0).expect("epoch 0");
2345
2346 assert!(states.contains_key(&gps(4)));
2348 assert!(
2349 states[&gps(4)].clock_s.is_none(),
2350 "an unalignable secondary clock must be dropped, not emitted raw"
2351 );
2352 let g01_clock = states[&gps(1)]
2354 .clock_s
2355 .expect("G01 carries the reference clock");
2356 assert!((g01_clock - 100.0e-6).abs() < 1.0e-12, "got {g01_clock}");
2357 }
2358
2359 #[test]
2360 fn merge_rejects_mismatched_coordinate_systems() {
2361 let a = sp3_build(
2362 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2363 "IGS14",
2364 );
2365 let b = sp3_build(
2366 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2367 "IGS20",
2368 );
2369
2370 assert!(merge(&[a, b], &MergeOptions::default()).is_err());
2371 }
2372
2373 #[test]
2374 fn merge_rejects_different_igs_frame_labels_without_a_transform() {
2375 let a = sp3_build(
2376 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2377 "IGS20",
2378 );
2379 let b = sp3_build(
2380 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2381 "IGc20",
2382 );
2383
2384 let err = merge(&[a, b], &MergeOptions::default()).expect_err("frame mismatch");
2385 assert!(
2386 err.to_string().contains("mismatched coordinate systems"),
2387 "{err}"
2388 );
2389 }
2390
2391 #[test]
2392 fn merge_accepts_asserted_equivalent_labels_and_reports_assertion() {
2393 for (a_label, b_label) in [("IGS14", "ITRF2"), ("ITRF2", "IGS14")] {
2394 let a = sp3_build(
2395 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2396 a_label,
2397 );
2398 let b = sp3_build(
2399 &[("G02", [16000.0, -21000.0, 6000.0], Some(200.0), "")],
2400 b_label,
2401 );
2402 let opts = MergeOptions {
2403 frame_reconciliation: super::Sp3FrameReconciliationOptions {
2404 asserted_equivalent_label_sets: vec![Sp3FrameLabelSet::pair("IGS14", "ITRF2")],
2405 helmert: false,
2406 },
2407 ..MergeOptions::default()
2408 };
2409
2410 let (merged, report) = merge(&[a, b], &opts).expect("asserted frame merge");
2411
2412 let states = merged.states_at(0).expect("epoch 0");
2413 assert!(states.contains_key(&gps(1)));
2414 assert!(states.contains_key(&gps(2)));
2415 assert_eq!(merged.header.coordinate_system, a_label);
2416 assert_eq!(report.frame_reconciliations.len(), 1);
2417 let reconciliation = &report.frame_reconciliations[0];
2418 assert_eq!(
2419 reconciliation.method,
2420 Sp3FrameReconciliationMethod::AssertedEquivalence
2421 );
2422 assert_eq!(reconciliation.source_index, 1);
2423 assert_eq!(reconciliation.source_label, b_label);
2424 assert_eq!(reconciliation.target_label, a_label);
2425 assert_eq!(reconciliation.records_affected, 1);
2426 assert!(reconciliation.parameters.is_none());
2427 assert!(reconciliation.rates.is_none());
2428 assert_eq!(
2429 reconciliation
2430 .asserted_label_set
2431 .as_ref()
2432 .expect("assertion set"),
2433 &vec!["IGS14".to_string(), "ITRF2".to_string()]
2434 );
2435 }
2436 }
2437
2438 #[test]
2439 fn merge_applies_helmert_reconciliation_to_resolved_labels() {
2440 let a = sp3_build(
2445 &[("G01", [14000.0, -19000.0, 4000.0], Some(100.0), "")],
2446 "IGS14",
2447 );
2448 let b = sp3_build(
2449 &[("G02", [15000.0, -20000.0, 5000.0], Some(200.0), "")],
2450 "IGS20",
2451 );
2452 let opts = MergeOptions {
2453 min_agree: 1,
2454 frame_reconciliation: super::Sp3FrameReconciliationOptions::helmert(),
2455 ..MergeOptions::default()
2456 };
2457
2458 let (merged, report) = merge(&[a, b], &opts).expect("helmert frame merge");
2459
2460 let g02 = merged.states_at(0).expect("epoch 0")[&gps(2)];
2461 let got = g02.position.as_array();
2462 let expected = [
2463 14_999_999.992_3,
2464 -19_999_999.993_048_087,
2465 5_000_000.000_396_175,
2466 ];
2467 for axis in 0..3 {
2468 assert!(
2469 (got[axis] - expected[axis]).abs() < 2.0e-9,
2470 "axis {axis}: got {}, expected {}",
2471 got[axis],
2472 expected[axis]
2473 );
2474 }
2475 assert_eq!(merged.header.coordinate_system, "IGS14");
2476 assert_eq!(report.frame_reconciliations.len(), 1);
2477 let reconciliation = &report.frame_reconciliations[0];
2478 assert_eq!(reconciliation.method, Sp3FrameReconciliationMethod::Helmert);
2479 assert_eq!(reconciliation.source_label, "IGS20");
2480 assert_eq!(reconciliation.target_label, "IGS14");
2481 assert_eq!(reconciliation.records_affected, 1);
2482 assert_eq!(
2483 reconciliation
2484 .parameters
2485 .expect("published parameters")
2486 .translation_mm,
2487 [-1.4, -0.9, 1.4]
2488 );
2489 assert_eq!(
2490 reconciliation.catalog_source_frame,
2491 Some(crate::frame_catalog::TerrestrialFrame::Itrf2020)
2492 );
2493 assert_eq!(
2494 reconciliation.catalog_target_frame,
2495 Some(crate::frame_catalog::TerrestrialFrame::Itrf2014)
2496 );
2497 assert!(!reconciliation.catalog_inverse);
2498 assert_eq!(
2499 reconciliation
2500 .rates
2501 .expect("published rates")
2502 .translation_mm_per_year,
2503 [0.0, -0.1, 0.2]
2504 );
2505 assert!(reconciliation
2506 .provenance
2507 .as_ref()
2508 .expect("provenance")
2509 .contains("ITRF2020 to past ITRFs"));
2510 }
2511
2512 #[test]
2513 fn merge_reports_inverse_helmert_catalog_direction() {
2514 let a = sp3_build(
2515 &[("G01", [14000.0, -19000.0, 4000.0], Some(100.0), "")],
2516 "IGS20",
2517 );
2518 let b = sp3_build(
2519 &[("G02", [15000.0, -20000.0, 5000.0], Some(200.0), "")],
2520 "IGS14",
2521 );
2522 let opts = MergeOptions {
2523 min_agree: 1,
2524 frame_reconciliation: super::Sp3FrameReconciliationOptions::helmert(),
2525 ..MergeOptions::default()
2526 };
2527
2528 let (_merged, report) = merge(&[a, b], &opts).expect("inverse helmert frame merge");
2529
2530 let reconciliation = &report.frame_reconciliations[0];
2531 assert_eq!(reconciliation.method, Sp3FrameReconciliationMethod::Helmert);
2532 assert_eq!(
2533 reconciliation.source_frame,
2534 Some(crate::frame_catalog::TerrestrialFrame::Itrf2014)
2535 );
2536 assert_eq!(
2537 reconciliation.target_frame,
2538 Some(crate::frame_catalog::TerrestrialFrame::Itrf2020)
2539 );
2540 assert_eq!(
2541 reconciliation.catalog_source_frame,
2542 Some(crate::frame_catalog::TerrestrialFrame::Itrf2020)
2543 );
2544 assert_eq!(
2545 reconciliation.catalog_target_frame,
2546 Some(crate::frame_catalog::TerrestrialFrame::Itrf2014)
2547 );
2548 assert!(reconciliation.catalog_inverse);
2549 assert_eq!(
2550 reconciliation
2551 .parameters
2552 .expect("published parameters")
2553 .translation_mm,
2554 [-1.4, -0.9, 1.4]
2555 );
2556 }
2557
2558 #[test]
2559 fn helmert_identity_label_reconciliation_is_bit_equal() {
2560 let a = sp3_build(
2561 &[("G01", [14000.0, -19000.0, 4000.0], Some(100.0), "")],
2562 "IGS20",
2563 );
2564 let b = sp3_build(
2565 &[("G02", [15000.125, -20000.5, 5000.25], Some(200.0), "")],
2566 "IGc20",
2567 );
2568 let original = b.states_at(0).expect("epoch 0")[&gps(2)].position;
2569 let opts = MergeOptions {
2570 min_agree: 1,
2571 frame_reconciliation: super::Sp3FrameReconciliationOptions::helmert(),
2572 ..MergeOptions::default()
2573 };
2574
2575 let (merged, report) = merge(&[a, b], &opts).expect("identity frame merge");
2576
2577 let g02 = merged.states_at(0).expect("epoch 0")[&gps(2)].position;
2578 for axis in 0..3 {
2579 assert_eq!(
2580 g02.as_array()[axis].to_bits(),
2581 original.as_array()[axis].to_bits()
2582 );
2583 }
2584 assert_eq!(report.frame_reconciliations.len(), 1);
2585 assert!(report.frame_reconciliations[0].identity);
2586 assert!(report.frame_reconciliations[0].parameters.is_none());
2587 }
2588
2589 #[test]
2590 fn helmert_reconciliation_rejects_unknown_labels() {
2591 let a = sp3_build(
2592 &[("G01", [14000.0, -19000.0, 4000.0], Some(100.0), "")],
2593 "ITRF2",
2594 );
2595 let b = sp3_build(
2596 &[("G02", [15000.0, -20000.0, 5000.0], Some(200.0), "")],
2597 "IGS20",
2598 );
2599 let opts = MergeOptions {
2600 frame_reconciliation: super::Sp3FrameReconciliationOptions::helmert(),
2601 ..MergeOptions::default()
2602 };
2603
2604 let err = merge(&[a, b], &opts).expect_err("unknown frame label");
2605
2606 assert!(
2607 err.to_string().contains("target label"),
2608 "unknown labels must not be guessed: {err}"
2609 );
2610 }
2611
2612 #[test]
2613 fn merge_uses_finest_union_grid_and_fills_sparse_precedence_cells() {
2614 let a = sp3_two_epochs(
2618 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2619 &[("G01", [15003.0, -20003.0, 5003.0], Some(103.0))],
2620 900.0,
2621 "IGS14",
2622 );
2623 let b = sp3_epochs(
2624 0.0,
2625 &[
2626 &[("G01", [26000.0, -20000.0, 5000.0], Some(200.0))],
2627 &[("G01", [26001.0, -20001.0, 5001.0], Some(201.0))],
2628 &[("G01", [26002.0, -20002.0, 5002.0], Some(202.0))],
2629 &[("G01", [26003.0, -20003.0, 5003.0], Some(203.0))],
2630 ],
2631 300.0,
2632 "IGS14",
2633 );
2634
2635 let opts = MergeOptions {
2636 combine: MergeCombine::Precedence,
2637 min_agree: 1,
2638 ..MergeOptions::default()
2639 };
2640 let (merged, _report) = merge(&[a, b], &opts).expect("mixed-interval union merge");
2641
2642 assert_eq!(
2643 merged.header.epoch_interval_s, 300.0,
2644 "output is on the finest (300 s) input grid"
2645 );
2646 assert_eq!(
2647 merged.epochs.len(),
2648 4,
2649 "B fills the :05 and :10 epochs between A's samples"
2650 );
2651 let xs: Vec<f64> = (0..4)
2652 .map(|idx| {
2653 merged.states_at(idx).expect("epoch")[&gps(1)]
2654 .position
2655 .as_array()[0]
2656 })
2657 .collect();
2658 assert_eq!(
2659 xs,
2660 vec![15_000_000.0, 26_001_000.0, 26_002_000.0, 15_003_000.0]
2661 );
2662 }
2663
2664 #[test]
2665 fn mixed_cadence_interpolates_only_the_clock_datum_for_filled_cells() {
2666 let reference_epoch: Vec<SatSample<'_>> = vec![
2667 ("G01", [15_001.0, -20_000.0, 5_000.0], Some(100.0)),
2668 ("G02", [15_002.0, -20_000.0, 5_000.0], Some(200.0)),
2669 ("G03", [15_003.0, -20_000.0, 5_000.0], Some(300.0)),
2670 ("G04", [15_004.0, -20_000.0, 5_000.0], Some(400.0)),
2671 ("G05", [15_005.0, -20_000.0, 5_000.0], Some(500.0)),
2672 ];
2673 let shifted_epoch: Vec<SatSample<'_>> = reference_epoch
2674 .iter()
2675 .map(|(sat, position, clock)| (*sat, *position, clock.map(|value| value + 50.0)))
2676 .collect();
2677 let a = sp3_epochs(
2678 0.0,
2679 &[reference_epoch.as_slice(), reference_epoch.as_slice()],
2680 900.0,
2681 "IGS14",
2682 );
2683 let b = sp3_epochs(
2684 0.0,
2685 &[
2686 shifted_epoch.as_slice(),
2687 shifted_epoch.as_slice(),
2688 shifted_epoch.as_slice(),
2689 shifted_epoch.as_slice(),
2690 ],
2691 300.0,
2692 "IGS14",
2693 );
2694 let opts = MergeOptions {
2695 combine: MergeCombine::Precedence,
2696 min_agree: 1,
2697 ..MergeOptions::default()
2698 };
2699
2700 let (merged, _) = merge(&[a, b], &opts).expect("mixed-cadence clock merge");
2701
2702 assert_eq!(merged.epochs.len(), 4);
2703 for epoch_index in 0..4 {
2704 let clock = merged.states_at(epoch_index).expect("epoch")[&gps(1)]
2705 .clock_s
2706 .expect("aligned clock");
2707 assert!(
2708 (clock - 100.0e-6).abs() < 1.0e-15,
2709 "epoch {epoch_index}: {clock}"
2710 );
2711 }
2712 }
2713
2714 #[test]
2715 fn merge_decimates_with_explicit_coarser_target_interval() {
2716 let recs = |x: f64| vec![("G01", [x, -20000.0, 5000.0], Some(100.0))];
2718 let make = || {
2719 sp3_epochs(
2720 0.0,
2721 &[
2722 &recs(15000.0),
2723 &recs(15001.0),
2724 &recs(15002.0),
2725 &recs(15003.0),
2726 ],
2727 300.0,
2728 "IGS14",
2729 )
2730 };
2731 let opts = MergeOptions {
2732 min_agree: 1,
2733 target_epoch_interval_s: Some(900.0),
2734 ..MergeOptions::default()
2735 };
2736 let (merged, _) = merge(&[make(), make()], &opts).expect("explicit coarse target");
2737 assert_eq!(merged.header.epoch_interval_s, 900.0);
2738 assert_eq!(
2739 merged.epochs.len(),
2740 2,
2741 "decimated 5-min inputs to the 900 s target"
2742 );
2743 }
2744
2745 #[test]
2746 fn merge_rejects_non_divisible_epoch_intervals() {
2747 let a = sp3_two_epochs(
2751 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2752 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2753 900.0,
2754 "IGS14",
2755 );
2756 let b = sp3_two_epochs(
2757 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2758 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2759 400.0,
2760 "IGS14",
2761 );
2762
2763 let err = merge(&[a, b], &MergeOptions::default()).expect_err("non-divisible intervals");
2764 assert!(
2765 err.to_string().contains("mismatched epoch intervals"),
2766 "{err}"
2767 );
2768 }
2769
2770 #[test]
2771 fn merge_rejects_a_non_whole_second_common_interval() {
2772 let mk = || {
2775 sp3_two_epochs(
2776 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2777 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2778 900.0,
2779 "IGS14",
2780 )
2781 };
2782 let opts = MergeOptions {
2783 target_epoch_interval_s: Some(450.5),
2784 ..MergeOptions::default()
2785 };
2786 let err = merge(&[mk(), mk()], &opts).expect_err("fractional target");
2787 assert!(err.to_string().contains("whole number of seconds"), "{err}");
2788 }
2789
2790 #[test]
2791 fn merge_header_first_epoch_describes_the_union_grid_start() {
2792 let a = sp3_epochs(
2795 0.0,
2796 &[
2797 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2798 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2799 &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
2800 ],
2801 900.0,
2802 "IGS14",
2803 );
2804 let b = sp3_epochs(
2805 900.0,
2806 &[
2807 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2808 &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
2809 &[("G01", [15003.0, -20003.0, 5003.0], Some(103.0))],
2810 ],
2811 900.0,
2812 "IGS14",
2813 );
2814
2815 let opts = MergeOptions {
2816 min_agree: 1,
2817 ..MergeOptions::default()
2818 };
2819 let (merged, _) = merge(&[a, b], &opts).expect("merge");
2820
2821 assert_eq!(
2822 merged.epochs.len(),
2823 4,
2824 "union epochs run from 00:00 to 00:45"
2825 );
2826 assert!(
2827 (merged.header.seconds_of_week - 345_600.0).abs() < 1.0e-6,
2828 "header sow must describe the union's first epoch 00:00 (345600 s), got {}",
2829 merged.header.seconds_of_week
2830 );
2831 assert!(
2832 merged.header.mjd_fraction.abs() < 1.0e-9,
2833 "header MJD fraction must describe 00:00, got {}",
2834 merged.header.mjd_fraction
2835 );
2836 }
2837
2838 #[test]
2839 fn merge_writer_recomputes_header_for_a_fine_union_grid() {
2840 let a = sp3_epochs(
2844 0.0,
2845 &[
2846 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2847 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2848 &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
2849 ],
2850 900.0,
2851 "IGS14",
2852 );
2853 let b = sp3_epochs(
2854 450.0,
2855 &[
2856 &[("G01", [15010.0, -20010.0, 5010.0], Some(110.0))],
2857 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2858 &[("G01", [15011.0, -20011.0, 5011.0], Some(111.0))],
2859 &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
2860 ],
2861 450.0,
2862 "IGS14",
2863 );
2864
2865 let opts = MergeOptions {
2866 min_agree: 1,
2867 ..MergeOptions::default()
2868 };
2869 let (merged, _) = merge(&[a, b], &opts).expect("mixed-cadence merge");
2870
2871 assert_eq!(merged.epochs.len(), 5, "union epochs run every 7.5 minutes");
2872 let text = merged.to_sp3_string();
2873 let header = text
2874 .lines()
2875 .find(|line| line.starts_with("## "))
2876 .expect("written ## header");
2877 let first_epoch = text
2878 .lines()
2879 .find(|line| line.starts_with("* "))
2880 .expect("written first epoch");
2881
2882 assert_eq!(first_epoch, "* 2020 6 25 0 0 0.00000000");
2883 assert_eq!(
2884 header,
2885 "## 2111 345600.00000000 450.00000000 59025 0.0000000000000"
2886 );
2887 }
2888
2889 #[test]
2890 fn precedence_merge_never_switches_source_within_one_satellite_arc() {
2891 let a = sp3_two_epochs(
2892 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2893 &[],
2894 900.0,
2895 "IGS14",
2896 );
2897 let b = sp3_two_epochs(
2898 &[("G01", [15000.001, -20000.0, 5000.0], Some(100.0))],
2899 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2900 900.0,
2901 "IGS14",
2902 );
2903 let opts = MergeOptions {
2904 combine: MergeCombine::Precedence,
2905 min_agree: 1,
2906 precedence_scope: MergePrecedenceScope::SatelliteArc,
2907 ..MergeOptions::default()
2908 };
2909
2910 let (merged, _report) = merge(&[a, b], &opts).expect("merge");
2911 let epoch0 = merged.states_at(0).expect("epoch 0");
2912 let epoch1 = merged.states_at(1).expect("epoch 1");
2913
2914 assert!(epoch0.contains_key(&gps(1)));
2915 assert!(
2916 !epoch1.contains_key(&gps(1)),
2917 "G01 must not switch from source 0 at epoch 0 to source 1 at epoch 1"
2918 );
2919 assert_eq!(merged.header.epoch_interval_s, 900.0);
2920 }
2921
2922 #[test]
2923 fn cell_precedence_fills_a_preferred_source_dropout() {
2924 let a = sp3_two_epochs(
2925 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2926 &[],
2927 900.0,
2928 "IGS14",
2929 );
2930 let b = sp3_two_epochs(
2931 &[("G01", [15000.001, -20000.0, 5000.0], Some(100.0))],
2932 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2933 900.0,
2934 "IGS14",
2935 );
2936 let opts = MergeOptions {
2937 combine: MergeCombine::Precedence,
2938 min_agree: 1,
2939 ..MergeOptions::default()
2940 };
2941
2942 let (merged, report) = merge(&[a, b], &opts).expect("merge");
2943
2944 assert!(merged.states_at(0).expect("epoch 0").contains_key(&gps(1)));
2945 let epoch1 = merged.states_at(1).expect("epoch 1");
2946 assert!(
2947 epoch1.contains_key(&gps(1)),
2948 "source 1 must fill source 0's dropout"
2949 );
2950 assert_eq!(epoch1[&gps(1)].position.as_array()[0], 15_001_000.0);
2951 assert!(report
2952 .single_source
2953 .iter()
2954 .any(|entry| entry.satellite == gps(1) && entry.sources == vec![1]));
2955 }
2956
2957 #[test]
2958 fn merge_filters_requested_constellations_and_header_satellites() {
2959 let a = sp3_two_epochs(
2960 &[
2961 ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
2962 ("E01", [21000.0, -1000.0, 13000.0], Some(120.0)),
2963 ],
2964 &[
2965 ("G01", [15001.0, -20001.0, 5001.0], Some(101.0)),
2966 ("E01", [21001.0, -1001.0, 13001.0], Some(121.0)),
2967 ],
2968 900.0,
2969 "IGS14",
2970 );
2971 let systems = BTreeSet::from([GnssSystem::Gps]);
2972 let opts = MergeOptions {
2973 systems: Some(systems),
2974 ..MergeOptions::default()
2975 };
2976
2977 let (merged, _report) = merge(&[a], &opts).expect("merge");
2978
2979 assert_eq!(merged.header.satellites, vec![gps(1)]);
2980 for idx in 0..merged.epochs.len() {
2981 let states = merged.states_at(idx).expect("epoch");
2982 assert_eq!(states.keys().copied().collect::<Vec<_>>(), vec![gps(1)]);
2983 }
2984 }
2985
2986 #[test]
2987 fn merge_preserves_a_clock_event_flag() {
2988 let a = sp3_build(
2991 &[(
2992 "G01",
2993 [15000.0, -20000.0, 5000.0],
2994 Some(100.0),
2995 " E",
2996 )],
2997 "IGS14",
2998 );
2999 let b = sp3_build(
3000 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
3001 "IGS14",
3002 );
3003
3004 let (merged, _) = merge(&[a, b], &MergeOptions::default()).expect("merge");
3005 let g01 = merged.states_at(0).expect("epoch 0")[&gps(1)];
3006
3007 assert!(
3008 g01.flags.clock_event,
3009 "merged cell must preserve a contributing source's clock-event flag"
3010 );
3011 }
3012
3013 #[test]
3014 fn merge_reports_effective_epoch_interval_from_actual_epochs() {
3015 let body = "#cP2020 6 25 0 0 0.00000000 2 ORBIT IGS14 FIT TST\n\
3019 ## 2111 432000.00000000 300.00000000 59025 0.0000000000000\n\
3020 + 1 G01 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
3021 ++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
3022 %c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3023 %c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3024 %f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n\
3025 %f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n\
3026 %i 0 0 0 0 0 0 0 0 0\n\
3027 %i 0 0 0 0 0 0 0 0 0\n\
3028 /* TEST SP3-c FIXTURE\n\
3029 * 2020 6 25 0 0 0.00000000\n\
3030 PG01 15000.000000 -20000.000000 5000.000000 100.000000\n\
3031 * 2020 6 25 0 15 0.00000000\n\
3032 PG01 15001.000000 -20001.000000 5001.000000 101.000000\n\
3033 EOF\n";
3034 let a = Sp3::parse(body.as_bytes()).expect("parse test sp3");
3035
3036 let (merged, _) = merge(&[a], &MergeOptions::default()).expect("merge");
3037
3038 assert!(
3039 (merged.header.epoch_interval_s - 900.0).abs() < 1.0e-6,
3040 "got {}",
3041 merged.header.epoch_interval_s
3042 );
3043 }
3044
3045 #[test]
3046 fn merge_rejects_unsorted_input_epochs_before_cadence_inference() {
3047 let body = "#cP2020 6 25 0 0 0.00000000 2 ORBIT IGS14 FIT TST\n\
3048 ## 2111 432000.00000000 900.00000000 59025 0.0000000000000\n\
3049 + 1 G01 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
3050 ++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
3051 %c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3052 %c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3053 %f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n\
3054 %f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n\
3055 %i 0 0 0 0 0 0 0 0 0\n\
3056 %i 0 0 0 0 0 0 0 0 0\n\
3057 /* TEST SP3-c FIXTURE\n\
3058 * 2020 6 25 0 15 0.00000000\n\
3059 PG01 15001.000000 -20001.000000 5001.000000 101.000000\n\
3060 * 2020 6 25 0 0 0.00000000\n\
3061 PG01 15000.000000 -20000.000000 5000.000000 100.000000\n\
3062 EOF\n";
3063 let source = Sp3::parse(body.as_bytes()).expect("parse unsorted test sp3");
3064
3065 let err = merge(&[source], &MergeOptions::default()).expect_err("unsorted epochs");
3066
3067 assert!(
3068 err.to_string()
3069 .contains("merge input epochs must be strictly increasing"),
3070 "{err}"
3071 );
3072 }
3073
3074 #[test]
3075 fn align_clock_reference_puts_other_on_the_reference_datum() {
3076 let reference = sp3([100.0, 200.0, 300.0]);
3079 let other = sp3([150.0, 250.0, 350.0]);
3080
3081 let aligned = align_clock_reference(&reference, &other, 3);
3082
3083 let g01 = aligned.states_at(0).expect("epoch 0")[&gps(1)];
3084 assert!(
3085 (g01.clock_s.unwrap() - 100.0e-6).abs() < 1.0e-15,
3086 "got {}",
3087 g01.clock_s.unwrap()
3088 );
3089 let original = other.states_at(0).expect("epoch 0")[&gps(1)];
3091 assert_eq!(g01.position.as_array(), original.position.as_array());
3092 }
3093
3094 fn sp3(clocks_us: [f64; 3]) -> Sp3 {
3098 let body = format!(
3099 "#cP2020 6 25 0 0 0.00000000 1 ORBIT IGS14 FIT TST\n\
3100 ## 2111 432000.00000000 900.00000000 59025 0.0000000000000\n\
3101 + 3 G01G02G03 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
3102 ++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
3103 %c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3104 %c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3105 %f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n\
3106 %f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n\
3107 %i 0 0 0 0 0 0 0 0 0\n\
3108 %i 0 0 0 0 0 0 0 0 0\n\
3109 /* TEST SP3-c FIXTURE\n\
3110 * 2020 6 25 0 0 0.00000000\n\
3111 PG01 15000.000000 -20000.000000 5000.000000 {:13.6}\n\
3112 PG02 -1234.567890 2345.678901 -3456.789012 {:13.6}\n\
3113 PG03 8000.000000 12000.000000 -19000.000000 {:13.6}\n\
3114 EOF\n",
3115 clocks_us[0], clocks_us[1], clocks_us[2]
3116 );
3117 Sp3::parse(body.as_bytes()).expect("parse test sp3")
3118 }
3119
3120 #[test]
3121 fn recovers_a_uniform_datum_shift() {
3122 let reference = sp3([100.0, 200.0, 300.0]);
3124 let other = sp3([150.0, 250.0, 350.0]);
3125
3126 let offsets = clock_reference_offset(&reference, &other, 3);
3127
3128 assert_eq!(offsets.len(), 1);
3129 assert_eq!(offsets[0].satellites, 3);
3130 assert!(
3131 (offsets[0].offset_s - 5.0e-5).abs() < 1.0e-12,
3132 "got {}",
3133 offsets[0].offset_s
3134 );
3135 }
3136
3137 #[test]
3138 fn median_rejects_a_single_outlier_clock() {
3139 let reference = sp3([100.0, 200.0, 300.0]);
3142 let other = sp3([150.0, 250.0, 9_300.0]);
3143
3144 let offsets = clock_reference_offset(&reference, &other, 3);
3145
3146 assert_eq!(offsets.len(), 1);
3147 assert!(
3148 (offsets[0].offset_s - 5.0e-5).abs() < 1.0e-12,
3149 "got {}",
3150 offsets[0].offset_s
3151 );
3152 }
3153
3154 #[test]
3155 fn omits_epochs_below_min_common() {
3156 let reference = sp3([100.0, 200.0, 300.0]);
3159 let other = sp3([150.0, 250.0, 350.0]);
3160
3161 assert!(clock_reference_offset(&reference, &other, 4).is_empty());
3162 }
3163
3164 #[test]
3165 fn merge_agreement_metric_reports_known_position_dispersion() {
3166 let a = sp3_records(&[("G01", [15000.000, -20000.0, 5000.0], Some(100.0))]);
3171 let b = sp3_records(&[("G01", [15000.003, -20000.0, 5000.0], Some(100.0))]);
3172 let c = sp3_records(&[("G01", [15000.006, -20000.0, 5000.0], Some(100.0))]);
3173 let opts = MergeOptions {
3174 position_tolerance_m: 10.0,
3175 min_agree: 3,
3176 combine: MergeCombine::Mean,
3177 ..MergeOptions::default()
3178 };
3179
3180 let (_merged, report) = merge(&[a, b, c], &opts).expect("merge");
3181
3182 assert_eq!(report.agreement.len(), 1, "one accepted cell");
3183 let m = report.agreement[0];
3184 assert_eq!(m.satellite, gps(1));
3185 assert_eq!(m.position_members, 3);
3186 assert!(
3187 (m.position_rms_m - 6.0_f64.sqrt()).abs() < 1.0e-6,
3188 "got rms {}",
3189 m.position_rms_m
3190 );
3191 assert!(
3192 (m.position_max_m - 3.0).abs() < 1.0e-6,
3193 "got max {}",
3194 m.position_max_m
3195 );
3196
3197 assert!((report.position_agreement_rms_m().unwrap() - 6.0_f64.sqrt()).abs() < 1.0e-6);
3199 assert!((report.position_agreement_max_m().unwrap() - 3.0).abs() < 1.0e-6);
3200
3201 let per_epoch = report.per_epoch_agreement();
3203 assert_eq!(per_epoch.len(), 1);
3204 assert_eq!(per_epoch[0].satellites, 1);
3205 assert!((per_epoch[0].position_rms_m - 6.0_f64.sqrt()).abs() < 1.0e-6);
3206 assert!((per_epoch[0].position_max_m - 3.0).abs() < 1.0e-6);
3207 }
3208
3209 #[test]
3210 fn merge_agreement_metric_reports_known_clock_dispersion() {
3211 let a = sp3([100.0, 200.0, 300.0]);
3217 let b = sp3([100.0, 200.0, 330.0]);
3218 let c = sp3([100.0, 200.0, 270.0]);
3219 let opts = MergeOptions {
3220 clock_min_common: 1,
3221 clock_tolerance_s: 1.0e-3,
3222 min_agree: 3,
3223 combine: MergeCombine::Mean,
3224 ..MergeOptions::default()
3225 };
3226
3227 let (_merged, report) = merge(&[a, b, c], &opts).expect("merge");
3228
3229 let g03 = report
3230 .agreement
3231 .iter()
3232 .find(|m| m.satellite == gps(3))
3233 .expect("G03 agreement metric");
3234 assert_eq!(g03.clock_members, 3);
3235 let expected_rms_s = 600.0_f64.sqrt() * 1.0e-6;
3236 assert!(
3237 (g03.clock_rms_s.unwrap() - expected_rms_s).abs() < 1.0e-15,
3238 "got clock rms {:?}",
3239 g03.clock_rms_s
3240 );
3241 assert!(
3242 (g03.clock_max_s.unwrap() - 30.0e-6).abs() < 1.0e-15,
3243 "got clock max {:?}",
3244 g03.clock_max_s
3245 );
3246 for prn in [1u8, 2] {
3248 let m = report
3249 .agreement
3250 .iter()
3251 .find(|m| m.satellite == gps(prn))
3252 .expect("metric");
3253 assert!(m.clock_rms_s.unwrap().abs() < 1.0e-18, "prn {prn}");
3254 assert!(m.position_rms_m.abs() < 1.0e-9, "prn {prn}");
3256 }
3257
3258 let pooled = report.clock_agreement_rms_s().expect("clock pool");
3262 assert!(
3263 (pooled - expected_rms_s / 3.0_f64.sqrt()).abs() < 1.0e-15,
3264 "got pooled {pooled}"
3265 );
3266 assert!((report.clock_agreement_max_s().unwrap() - 30.0e-6).abs() < 1.0e-15);
3267 }
3268
3269 #[cfg(sidereon_repo_tests)]
3297 #[test]
3298 fn merge_agrees_with_published_igs_combined_within_cm() {
3299 fn load(name: &str) -> Sp3 {
3300 let path = format!("{}/tests/fixtures/sp3/{}", env!("CARGO_MANIFEST_DIR"), name);
3301 let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("read {path}: {e}"));
3302 Sp3::parse(&bytes).unwrap_or_else(|e| panic!("parse {name}: {e}"))
3303 }
3304
3305 let cod = load("COD0OPSFIN_20261200945_02H30M_15M_ORB_trim.SP3");
3306 let gfz = load("GFZ0OPSFIN_20261200945_02H30M_15M_ORB_trim.SP3");
3307 let jpl = load("JPL0OPSFIN_20261200945_02H30M_15M_ORB_trim.SP3");
3308 let igs = load("IGS0OPSFIN_20261200945_02H30M_15M_ORB.SP3");
3309
3310 let (merged, report) =
3311 merge(&[cod, gfz, jpl], &MergeOptions::default()).expect("multi-center merge");
3312
3313 assert!(
3316 report.quarantined.is_empty(),
3317 "centers should agree: {:?}",
3318 report.quarantined
3319 );
3320 assert!(
3323 report.single_source.is_empty(),
3324 "{:?}",
3325 report.single_source
3326 );
3327 assert!(
3328 report.position_outliers.is_empty(),
3329 "{:?}",
3330 report.position_outliers
3331 );
3332 assert!(
3333 report.agreement.iter().all(|a| a.position_members == 3),
3334 "every agreement cell should be a 3-source consensus"
3335 );
3336
3337 let mut igs_idx: std::collections::BTreeMap<i64, usize> = std::collections::BTreeMap::new();
3338 for (i, ep) in igs.epochs.iter().enumerate() {
3339 if let Some(s) = super::instant_to_j2000_seconds(ep) {
3340 igs_idx.insert(s.floor() as i64, i);
3341 }
3342 }
3343
3344 let mut sumsq = 0.0_f64;
3345 let mut max = 0.0_f64;
3346 let mut n = 0usize;
3347 for (mi, ep) in merged.epochs.iter().enumerate() {
3348 let key = super::instant_to_j2000_seconds(ep)
3349 .expect("merged epoch key")
3350 .floor() as i64;
3351 let ii = *igs_idx.get(&key).expect("IGS combined covers merged epoch");
3352 let merged_states = merged.states_at(mi).expect("merged states");
3353 let igs_states = igs.states_at(ii).expect("IGS states");
3354 for (sat, mst) in merged_states.iter() {
3355 let ist = igs_states
3356 .get(sat)
3357 .unwrap_or_else(|| panic!("merged sat {sat} missing from IGS combined"));
3358 let d = super::dist3(&mst.position.as_array(), &ist.position.as_array());
3359 sumsq += d * d;
3360 max = max.max(d);
3361 n += 1;
3362 }
3363 }
3364
3365 assert_eq!(n, 88, "expected exactly 88 compared cells, got {n}");
3368 let rms = (sumsq / n as f64).sqrt();
3369 assert!(
3371 rms < 0.02,
3372 "combine-vs-IGS RMS {:.4} m ({} cells) exceeds the 2 cm gate",
3373 rms,
3374 n
3375 );
3376 assert!(
3377 max < 0.05,
3378 "combine-vs-IGS max {max:.4} m exceeds the 5 cm gate"
3379 );
3380
3381 let dispersion = report
3383 .position_agreement_rms_m()
3384 .expect("multi-source cells present");
3385 assert!(
3386 dispersion < 0.05,
3387 "inter-center position dispersion {dispersion:.4} m"
3388 );
3389 }
3390}