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 mandatory_header_lines = 5.max(header.satellites.len().div_ceil(17));
1131 let declared_satellite_tokens = header
1132 .satellites
1133 .iter()
1134 .map(ToString::to_string)
1135 .collect::<Vec<_>>();
1136 let epoch_position_tokens = vec![declared_satellite_tokens.clone(); out_epochs.len()];
1137 let epoch_state_record_sequence = epoch_position_tokens
1138 .iter()
1139 .map(|tokens| {
1140 tokens
1141 .iter()
1142 .cloned()
1143 .map(|token| ('P', token))
1144 .collect::<Vec<_>>()
1145 })
1146 .collect::<Vec<_>>();
1147 let merged = Sp3 {
1148 header,
1149 epochs: out_epochs,
1150 declared_num_epochs: out_epoch_j2000_s.len() as u64,
1151 declared_start_j2000_s: out_epoch_j2000_s.first().copied(),
1152 had_eof: true,
1153 trailing_content_after_eof: false,
1154 satellite_header_lines: mandatory_header_lines,
1155 accuracy_header_lines: mandatory_header_lines,
1156 time_system_header_lines: 2,
1157 float_header_lines: 2,
1158 integer_header_lines: 2,
1159 header_comment_lines: 4,
1160 declared_satellite_count: Some(declared_satellite_tokens.len()),
1161 declared_satellite_tokens,
1162 epoch_velocity_tokens: vec![Vec::new(); epoch_position_tokens.len()],
1163 epoch_position_tokens,
1164 epoch_state_record_sequence,
1165 epoch_j2000_s: out_epoch_j2000_s,
1166 states: out_states,
1167 interp_raw: out_raw,
1168 comments: vec![format!("MERGED from {} SP3 products", sources.len())],
1169 skipped_records: sources.iter().map(|s| s.skipped_records).sum(),
1170 };
1171
1172 Ok((merged, report))
1173}
1174
1175fn reconcile_sp3_coordinate_labels(
1176 sources: &[Sp3],
1177 opts: &MergeOptions,
1178) -> Result<(Vec<Sp3>, Vec<Sp3FrameReconciliation>)> {
1179 let target_label = normalized_sp3_frame_label(&sources[0].header.coordinate_system);
1180 let mut prepared = sources.to_vec();
1181 let mut report = Vec::new();
1182
1183 for idx in 1..sources.len() {
1184 let source_label = normalized_sp3_frame_label(&sources[idx].header.coordinate_system);
1185 if source_label == target_label {
1186 continue;
1187 }
1188
1189 if let Some(asserted) = asserted_frame_label_set(
1190 &source_label,
1191 &target_label,
1192 &opts.frame_reconciliation.asserted_equivalent_label_sets,
1193 ) {
1194 prepared[idx].header.coordinate_system = target_label.clone();
1195 report.push(Sp3FrameReconciliation {
1196 source_index: idx,
1197 source_label,
1198 target_label: target_label.clone(),
1199 method: Sp3FrameReconciliationMethod::AssertedEquivalence,
1200 asserted_label_set: Some(asserted),
1201 source_frame: None,
1202 target_frame: None,
1203 catalog_source_frame: None,
1204 catalog_target_frame: None,
1205 catalog_inverse: false,
1206 reference_epoch_year: None,
1207 parameters: None,
1208 rates: None,
1209 provenance: None,
1210 epoch_year_span: None,
1211 records_affected: count_position_records(&sources[idx]),
1212 identity: true,
1213 });
1214 continue;
1215 }
1216
1217 if opts.frame_reconciliation.helmert {
1218 let from = sp3_coordinate_label_frame(&source_label).ok_or_else(|| {
1219 Error::InvalidInput(format!(
1220 "merge inputs have mismatched coordinate systems ({:?} vs {:?}); source label {:?} is not a known ITRF/IGS realization",
1221 sources[0].header.coordinate_system,
1222 sources[idx].header.coordinate_system,
1223 sources[idx].header.coordinate_system
1224 ))
1225 })?;
1226 let to = sp3_coordinate_label_frame(&target_label).ok_or_else(|| {
1227 Error::InvalidInput(format!(
1228 "merge inputs have mismatched coordinate systems ({:?} vs {:?}); target label {:?} is not a known ITRF/IGS realization",
1229 sources[0].header.coordinate_system,
1230 sources[idx].header.coordinate_system,
1231 sources[0].header.coordinate_system
1232 ))
1233 })?;
1234
1235 let transform_report = reconcile_source_by_helmert(
1236 &mut prepared[idx],
1237 idx,
1238 source_label,
1239 target_label.clone(),
1240 from,
1241 to,
1242 )?;
1243 report.push(transform_report);
1244 continue;
1245 }
1246
1247 return Err(Error::InvalidInput(format!(
1248 "merge inputs have mismatched coordinate systems ({:?} vs {:?})",
1249 sources[0].header.coordinate_system, sources[idx].header.coordinate_system
1250 )));
1251 }
1252
1253 Ok((prepared, report))
1254}
1255
1256fn asserted_frame_label_set(
1257 source_label: &str,
1258 target_label: &str,
1259 label_sets: &[Sp3FrameLabelSet],
1260) -> Option<Vec<String>> {
1261 label_sets.iter().find_map(|set| {
1262 if set.labels.contains(source_label) && set.labels.contains(target_label) {
1263 Some(set.labels.iter().cloned().collect())
1264 } else {
1265 None
1266 }
1267 })
1268}
1269
1270fn reconcile_source_by_helmert(
1271 source: &mut Sp3,
1272 source_index: usize,
1273 source_label: String,
1274 target_label: String,
1275 from: TerrestrialFrame,
1276 to: TerrestrialFrame,
1277) -> Result<Sp3FrameReconciliation> {
1278 let records_affected = count_position_records(source);
1279 let epoch_year_span = epoch_year_span(source);
1280 let identity = from == to;
1281
1282 if !identity {
1283 transform_sp3_positions(source, from, to)?;
1284 }
1285 source.header.coordinate_system = target_label.clone();
1286
1287 let published = published_transform_for_report(from, to);
1288 Ok(Sp3FrameReconciliation {
1289 source_index,
1290 source_label,
1291 target_label,
1292 method: Sp3FrameReconciliationMethod::Helmert,
1293 asserted_label_set: None,
1294 source_frame: Some(from),
1295 target_frame: Some(to),
1296 catalog_source_frame: published.map(|published| published.entry.from),
1297 catalog_target_frame: published.map(|published| published.entry.to),
1298 catalog_inverse: published.is_some_and(|published| published.inverse),
1299 reference_epoch_year: published.map(|published| published.entry.reference_epoch_year),
1300 parameters: published.map(|published| published.entry.parameters),
1301 rates: published.map(|published| published.entry.rates),
1302 provenance: published.map(|published| published.entry.provenance.to_string()),
1303 epoch_year_span,
1304 records_affected,
1305 identity,
1306 })
1307}
1308
1309fn transform_sp3_positions(
1310 source: &mut Sp3,
1311 from: TerrestrialFrame,
1312 to: TerrestrialFrame,
1313) -> Result<()> {
1314 let seconds_per_julian_year = DAYS_PER_JULIAN_YEAR * SECONDS_PER_DAY;
1315 for epoch_idx in 0..source.epochs.len() {
1316 let epoch_year = decimal_year(source.epochs[epoch_idx]);
1317 let states = &mut source.states[epoch_idx];
1318 let raw_nodes = &mut source.interp_raw[epoch_idx];
1319 for (sat, state) in states.iter_mut() {
1320 let position = TerrestrialPositionM::from_itrf(state.position);
1321 let velocity = state
1322 .velocity
1323 .map(|velocity| {
1324 let [vx, vy, vz] = velocity.as_array();
1325 TerrestrialVelocityMPerYear::new(
1326 vx * seconds_per_julian_year,
1327 vy * seconds_per_julian_year,
1328 vz * seconds_per_julian_year,
1329 )
1330 })
1331 .transpose()
1332 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1333 let transformed = frame_catalog::transform(position, velocity, from, to, epoch_year)
1334 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1335 let [x, y, z] = transformed.position.as_array();
1336 state.position = ItrfPositionM::new(x, y, z)
1337 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1338 state.velocity = transformed
1339 .velocity
1340 .map(|velocity| {
1341 let [vx, vy, vz] = velocity.as_array();
1342 ItrfVelocityMS::new(
1343 vx / seconds_per_julian_year,
1344 vy / seconds_per_julian_year,
1345 vz / seconds_per_julian_year,
1346 )
1347 })
1348 .transpose()
1349 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1350 if let Some(raw) = raw_nodes.get_mut(sat) {
1351 raw.km = [x / KM_TO_M, y / KM_TO_M, z / KM_TO_M];
1352 }
1353 }
1354 }
1355 Ok(())
1356}
1357
1358fn count_position_records(source: &Sp3) -> usize {
1359 source.states.iter().map(BTreeMap::len).sum()
1360}
1361
1362fn epoch_year_span(source: &Sp3) -> Option<[f64; 2]> {
1363 let first = source.epochs.first().copied().map(decimal_year)?;
1364 let last = source.epochs.last().copied().map(decimal_year)?;
1365 Some([first, last])
1366}
1367
1368fn decimal_year(epoch: Instant) -> f64 {
1369 let jd_midnight = julian_date_from_instant(epoch) + 0.5;
1370 let (year, _, _) = civil_from_julian_day_number(jd_midnight.floor() as i64);
1371 let days = if is_leap_year(year) { 366.0 } else { 365.0 };
1372 year as f64 + (fractional_day_of_year_from_instant(epoch) - 1.0) / days
1373}
1374
1375fn normalized_sp3_frame_label(label: &str) -> String {
1376 label.trim().to_string()
1377}
1378
1379fn sp3_coordinate_label_frame(label: &str) -> Option<TerrestrialFrame> {
1380 match label.trim() {
1381 "ITRF2020" | "ITRF20" | "IGS20" | "IGc20" => Some(TerrestrialFrame::Itrf2020),
1382 "ITRF2014" | "ITRF14" | "IGS14" | "IGb14" => Some(TerrestrialFrame::Itrf2014),
1383 "ITRF2008" | "ITRF08" | "IGS08" | "IGb08" => Some(TerrestrialFrame::Itrf2008),
1384 _ => None,
1385 }
1386}
1387
1388fn published_transform_for_report(
1389 from: TerrestrialFrame,
1390 to: TerrestrialFrame,
1391) -> Option<PublishedTransformForReport> {
1392 frame_catalog::catalog_entry(from, to)
1393 .map(|entry| PublishedTransformForReport {
1394 entry,
1395 inverse: false,
1396 })
1397 .or_else(|| {
1398 frame_catalog::catalog_entry(to, from).map(|entry| PublishedTransformForReport {
1399 entry,
1400 inverse: true,
1401 })
1402 })
1403}
1404
1405#[derive(Debug, Clone, Copy)]
1406struct PublishedTransformForReport {
1407 entry: &'static frame_catalog::HelmertTransform,
1408 inverse: bool,
1409}
1410
1411#[derive(Debug, Clone, Copy)]
1412struct FirstEpochHeaderFields {
1413 gnss_week: u32,
1414 seconds_of_week: f64,
1415 mjd: u32,
1416 mjd_fraction: f64,
1417}
1418
1419fn first_epoch_header_fields(epoch: &Instant) -> Option<FirstEpochHeaderFields> {
1420 let split = epoch.julian_date()?;
1421
1422 let mjd_day = mjd_from_jd(split.jd_whole);
1423 let mut mjd = mjd_day.floor();
1424 let mut mjd_fraction = split.fraction + (mjd_day - mjd);
1425 let fraction_days = mjd_fraction.floor();
1426 if fraction_days != 0.0 {
1427 mjd += fraction_days;
1428 mjd_fraction -= fraction_days;
1429 }
1430 if !(0.0..=u32::MAX as f64).contains(&mjd) {
1431 return None;
1432 }
1433
1434 let gps_seconds = instant_to_j2000_seconds(epoch)? + GPS_EPOCH_TO_J2000_S;
1435 let (gnss_week, seconds_of_week) = gnss::week_and_seconds_of_week(gps_seconds);
1436 if !(0.0..=u32::MAX as f64).contains(&gnss_week) {
1437 return None;
1438 }
1439
1440 Some(FirstEpochHeaderFields {
1441 gnss_week: gnss_week as u32,
1442 seconds_of_week,
1443 mjd: mjd as u32,
1444 mjd_fraction,
1445 })
1446}
1447
1448fn dist3(a: &[f64; 3], b: &[f64; 3]) -> f64 {
1449 vec3::norm3(vec3::sub3(*a, *b))
1450}
1451
1452fn position_dispersion(
1455 pos: &[(usize, [f64; 3], Sp3Flags)],
1456 members: &[usize],
1457 combined: &[f64; 3],
1458) -> (f64, f64) {
1459 let mut sumsq = 0.0;
1460 let mut max = 0.0_f64;
1461 for &i in members {
1462 let d = dist3(&pos[i].1, combined);
1463 sumsq += d * d;
1464 max = max.max(d);
1465 }
1466 ((sumsq / members.len().max(1) as f64).sqrt(), max)
1467}
1468
1469fn clock_dispersion(
1472 clk: &[(usize, f64, Sp3Flags)],
1473 members: &[usize],
1474 combined: f64,
1475) -> (f64, f64) {
1476 let mut sumsq = 0.0;
1477 let mut max = 0.0_f64;
1478 for &i in members {
1479 let d = (clk[i].1 - combined).abs();
1480 sumsq += d * d;
1481 max = max.max(d);
1482 }
1483 ((sumsq / members.len().max(1) as f64).sqrt(), max)
1484}
1485
1486fn clock_offset_at(offsets: &BTreeMap<i64, f64>, key: i64) -> Option<f64> {
1490 if let Some(offset) = offsets.get(&key) {
1491 return Some(*offset);
1492 }
1493 let (&before_key, &before) = offsets.range(..key).next_back()?;
1494 let (&after_key, &after) = offsets.range(key..).next()?;
1495 if after_key <= before_key {
1496 return None;
1497 }
1498 let fraction = (key - before_key) as f64 / (after_key - before_key) as f64;
1499 Some(before + fraction * (after - before))
1500}
1501
1502fn precedence_sources_for_satellites(
1503 sources: &[Sp3],
1504 epoch_index: &[BTreeMap<i64, usize>],
1505 epoch_keys: &BTreeMap<i64, Instant>,
1506 systems: Option<&BTreeSet<GnssSystem>>,
1507) -> BTreeMap<GnssSatelliteId, usize> {
1508 let mut by_sat = BTreeMap::new();
1509
1510 for (idx, source) in sources.iter().enumerate() {
1511 for key in epoch_keys.keys() {
1512 let Some(&epoch_idx) = epoch_index[idx].get(key) else {
1513 continue;
1514 };
1515 let Ok(states) = source.states_at(epoch_idx) else {
1516 continue;
1517 };
1518
1519 for sat in states.keys() {
1520 if systems.is_none_or(|allowed| allowed.contains(&sat.system)) {
1521 by_sat.entry(*sat).or_insert(idx);
1522 }
1523 }
1524 }
1525 }
1526
1527 by_sat
1528}
1529
1530fn validate_merge_options(opts: &MergeOptions) -> Result<()> {
1531 validate::finite_nonneg(opts.position_tolerance_m, "merge position tolerance meters")
1532 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1533 validate::finite_nonneg(opts.clock_tolerance_s, "merge clock tolerance seconds")
1534 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1535 if opts.min_agree == 0 {
1536 return Err(Error::InvalidInput(
1537 "merge minimum agreement must be at least one".into(),
1538 ));
1539 }
1540 if opts.clock_min_common == 0 {
1541 return Err(Error::InvalidInput(
1542 "merge minimum common clock satellites must be at least one".into(),
1543 ));
1544 }
1545 if let Some(reject) = opts.outlier_reject {
1546 validate::finite_nonneg(
1547 reject.position_tolerance_m,
1548 "merge outlier position tolerance meters",
1549 )
1550 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1551 validate::finite_nonneg(
1552 reject.clock_tolerance_s,
1553 "merge outlier clock tolerance seconds",
1554 )
1555 .map_err(|error| Error::InvalidInput(error.to_string()))?;
1556 }
1557 if opts
1558 .systems
1559 .as_ref()
1560 .is_some_and(|systems| systems.is_empty())
1561 {
1562 return Err(Error::InvalidInput(
1563 "merge systems filter must not be empty".into(),
1564 ));
1565 }
1566 for labels in &opts.frame_reconciliation.asserted_equivalent_label_sets {
1567 if labels.labels.len() < 2 || labels.labels.iter().any(|label| label.trim().is_empty()) {
1568 return Err(Error::InvalidInput(
1569 "merge asserted frame label sets require at least two non-empty labels".into(),
1570 ));
1571 }
1572 }
1573 Ok(())
1574}
1575
1576fn resolve_common_epoch_interval(sources: &[Sp3], target: Option<f64>) -> Result<f64> {
1585 let intervals: Vec<f64> = sources
1586 .iter()
1587 .enumerate()
1588 .map(|(idx, source)| {
1589 effective_epoch_interval_s(source)?.ok_or_else(|| {
1590 Error::InvalidInput(format!(
1591 "merge input {idx} has no usable positive epoch interval"
1592 ))
1593 })
1594 })
1595 .collect::<Result<Vec<_>>>()?;
1596
1597 let common = match target {
1598 Some(t) if t.is_finite() && t > 0.0 => t,
1599 Some(t) => {
1600 return Err(Error::InvalidInput(format!(
1601 "merge target epoch interval must be positive and finite, got {t}"
1602 )))
1603 }
1604 None => intervals.iter().copied().fold(f64::INFINITY, f64::min),
1605 };
1606
1607 if (common - common.round()).abs() > WHOLE_SECOND_EPS_S || common.round() < 1.0 {
1612 return Err(Error::InvalidInput(format!(
1613 "merge common epoch interval {common:.6} s must be a positive whole number of seconds"
1614 )));
1615 }
1616
1617 for (idx, interval) in intervals.iter().copied().enumerate() {
1618 if !divides_evenly(interval, common) && !divides_evenly(common, interval) {
1619 return Err(Error::InvalidInput(format!(
1620 "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)"
1621 )));
1622 }
1623 }
1624
1625 Ok(common)
1626}
1627
1628fn divides_evenly(interval: f64, common: f64) -> bool {
1631 if !(interval.is_finite() && interval > 0.0 && common.is_finite() && common > 0.0) {
1632 return false;
1633 }
1634 let k = (common / interval).round();
1635 k >= 1.0 && same_interval(k * interval, common)
1636}
1637
1638fn effective_epoch_interval_s(source: &Sp3) -> Result<Option<f64>> {
1639 let secs: Vec<f64> = source
1640 .epochs
1641 .iter()
1642 .filter_map(instant_to_j2000_seconds)
1643 .collect();
1644 validate::require_strictly_increasing(secs.iter().copied(), "merge input epochs").map_err(
1645 |error| Error::InvalidInput(format!("{} must be strictly increasing", error.field())),
1646 )?;
1647 let gaps: Vec<f64> = secs.windows(2).map(|w| w[1] - w[0]).collect();
1648
1649 if gaps.is_empty() {
1650 let header = source.header.epoch_interval_s;
1651 return Ok((header.is_finite() && header > 0.0).then_some(header));
1652 }
1653
1654 let interval = gaps[0];
1655 if gaps.iter().all(|g| same_interval(*g, interval)) {
1656 Ok(Some(interval))
1657 } else {
1658 Ok(None)
1659 }
1660}
1661
1662fn same_interval(a: f64, b: f64) -> bool {
1663 (a - b).abs() <= WHOLE_SECOND_EPS_S
1664}
1665
1666fn largest_within<T>(items: &[T], within: impl Fn(&T, &T) -> bool) -> Vec<usize> {
1671 let n = items.len();
1672 if n <= 1 {
1673 return (0..n).collect();
1674 }
1675 let graph = agreement_graph(items, within);
1676 if n > MAX_EXACT_CLIQUE_NODES {
1677 return greedy_largest_clique(&graph);
1678 }
1679 let mut best = vec![0];
1680 let mut current = Vec::new();
1681 max_clique_search(&graph, &mut current, (0..n).collect(), &mut best);
1682 best
1683}
1684
1685fn largest_within_containing<T>(
1686 items: &[T],
1687 required: usize,
1688 within: impl Fn(&T, &T) -> bool,
1689) -> Vec<usize> {
1690 let n = items.len();
1691 if n == 0 || required >= n {
1692 return Vec::new();
1693 }
1694 if n == 1 {
1695 return vec![required];
1696 }
1697
1698 let graph = agreement_graph(items, within);
1699 if n > MAX_EXACT_CLIQUE_NODES {
1700 return greedy_largest_clique_containing(&graph, required);
1701 }
1702 let candidates = (0..n)
1703 .filter(|&idx| idx != required && graph[required][idx])
1704 .collect();
1705 let mut best = vec![required];
1706 let mut current = vec![required];
1707 max_clique_search(&graph, &mut current, candidates, &mut best);
1708 best
1709}
1710
1711fn agreement_graph<T>(items: &[T], within: impl Fn(&T, &T) -> bool) -> Vec<Vec<bool>> {
1712 let n = items.len();
1713 let mut graph = vec![vec![false; n]; n];
1714 for i in 0..n {
1715 graph[i][i] = true;
1716 for j in i + 1..n {
1717 let agrees = within(&items[i], &items[j]);
1718 graph[i][j] = agrees;
1719 graph[j][i] = agrees;
1720 }
1721 }
1722 graph
1723}
1724
1725fn greedy_largest_clique(graph: &[Vec<bool>]) -> Vec<usize> {
1726 let mut best = Vec::new();
1727 for seed in 0..graph.len() {
1728 let candidate = greedy_clique_from_seed(graph, seed);
1729 update_best_clique(&candidate, &mut best);
1730 }
1731 best
1732}
1733
1734fn greedy_largest_clique_containing(graph: &[Vec<bool>], required: usize) -> Vec<usize> {
1735 if required >= graph.len() {
1736 return Vec::new();
1737 }
1738 greedy_clique_from_seed(graph, required)
1739}
1740
1741fn greedy_clique_from_seed(graph: &[Vec<bool>], seed: usize) -> Vec<usize> {
1742 let mut clique = vec![seed];
1743 for (idx, _) in graph.iter().enumerate() {
1744 if idx == seed {
1745 continue;
1746 }
1747 if clique.iter().all(|&member| graph[member][idx]) {
1748 clique.push(idx);
1749 }
1750 }
1751 clique.sort_unstable();
1752 clique
1753}
1754
1755fn max_clique_search(
1756 graph: &[Vec<bool>],
1757 current: &mut Vec<usize>,
1758 mut candidates: Vec<usize>,
1759 best: &mut Vec<usize>,
1760) {
1761 candidates.sort_unstable();
1762 for (pos, &candidate) in candidates.iter().enumerate() {
1763 let remaining = candidates.len() - pos;
1764 if current.len() + remaining < best.len() {
1765 break;
1766 }
1767
1768 let next_candidates = candidates[pos + 1..]
1769 .iter()
1770 .copied()
1771 .filter(|&idx| graph[candidate][idx])
1772 .collect();
1773
1774 current.push(candidate);
1775 update_best_clique(current, best);
1776 max_clique_search(graph, current, next_candidates, best);
1777 current.pop();
1778 }
1779}
1780
1781fn update_best_clique(current: &[usize], best: &mut Vec<usize>) {
1782 let mut candidate = current.to_vec();
1783 candidate.sort_unstable();
1784 if candidate.len() > best.len()
1785 || (candidate.len() == best.len() && candidate.as_slice() < best.as_slice())
1786 {
1787 *best = candidate;
1788 }
1789}
1790
1791fn combine3(members: &[(usize, [f64; 3])], how: MergeCombine) -> [f64; 3] {
1792 [0usize, 1, 2].map(|axis| {
1793 let axis_members: Vec<(usize, f64)> = members.iter().map(|(s, v)| (*s, v[axis])).collect();
1794 combine_axis(&axis_members, how)
1795 })
1796}
1797
1798fn combine_axis(members: &[(usize, f64)], how: MergeCombine) -> f64 {
1799 match how {
1800 MergeCombine::Mean => members.iter().map(|(_, v)| *v).sum::<f64>() / members.len() as f64,
1801 MergeCombine::Median => {
1802 let mut vals: Vec<f64> = members.iter().map(|(_, v)| *v).collect();
1803 median(&mut vals).expect("consensus cluster is non-empty")
1804 }
1805 MergeCombine::Precedence => members
1806 .iter()
1807 .min_by_key(|(s, _)| *s)
1808 .map(|(_, v)| *v)
1809 .expect("consensus cluster is non-empty"),
1810 }
1811}
1812
1813pub fn align_clock_reference(reference: &Sp3, other: &Sp3, min_common: usize) -> Sp3 {
1827 let offsets: BTreeMap<i64, f64> = clock_reference_offset(reference, other, min_common)
1828 .into_iter()
1829 .filter_map(|o| {
1830 instant_to_j2000_seconds(&o.epoch).map(|sec| (sec.floor() as i64, o.offset_s))
1831 })
1832 .collect();
1833
1834 let mut aligned = other.clone();
1835 for ei in 0..aligned.epochs.len() {
1836 let Some(sec) = sp3_epoch_j2000_seconds(&aligned, ei, &aligned.epochs[ei]) else {
1837 continue;
1838 };
1839 let Some(&off) = offsets.get(&(sec.floor() as i64)) else {
1840 continue;
1841 };
1842 for state in aligned.states[ei].values_mut() {
1843 if let Some(c) = state.clock_s.as_mut() {
1844 *c -= off;
1845 }
1846 }
1847 for node in aligned.interp_raw[ei].values_mut() {
1848 if let Some(us) = node.clock_us.as_mut() {
1849 *us -= off * 1.0e6;
1850 }
1851 }
1852 }
1853 aligned
1854}
1855
1856#[cfg(test)]
1857mod tests {
1858 use super::super::Sp3;
1859 use super::{
1860 align_clock_reference, clock_reference_offset, merge, MergeCombine, MergeOptions,
1861 MergePrecedenceScope, MergeReport, OutlierRejectOptions, Sp3FrameLabelSet,
1862 Sp3FrameReconciliationMethod, Sp3FrameReconciliationOptions,
1863 };
1864 use crate::constants::SECONDS_PER_DAY;
1865 use crate::id::{GnssSatelliteId, GnssSystem};
1866 use std::collections::BTreeSet;
1867
1868 type SatSample<'a> = (&'a str, [f64; 3], Option<f64>);
1871
1872 fn gps(prn: u8) -> GnssSatelliteId {
1873 GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
1874 }
1875
1876 fn sp3_build(records: &[(&str, [f64; 3], Option<f64>, &str)], cs: &str) -> Sp3 {
1882 let n = records.len();
1883 let mut sats = String::new();
1884 for (sat, _, _, _) in records {
1885 sats.push_str(sat);
1886 }
1887 for _ in n..17 {
1888 sats.push_str(" 0");
1889 }
1890 let mut body = String::new();
1891 body.push_str(&format!(
1892 "#cP2020 6 25 0 0 0.00000000 1 ORBIT {cs} FIT TST\n"
1893 ));
1894 body.push_str("## 2111 432000.00000000 900.00000000 59025 0.0000000000000\n");
1895 body.push_str(&format!("+ {n:2} {sats}\n"));
1896 body.push_str("++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n");
1897 body.push_str("%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1898 body.push_str("%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1899 body.push_str("%f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n");
1900 body.push_str("%f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n");
1901 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1902 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1903 body.push_str("/* TEST SP3-c FIXTURE\n");
1904 body.push_str("* 2020 6 25 0 0 0.00000000\n");
1905 for (sat, p, clk, flags) in records {
1906 let c = clk.unwrap_or(999_999.999_999);
1907 body.push_str(&format!(
1908 "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}{flags}\n",
1909 p[0], p[1], p[2]
1910 ));
1911 }
1912 body.push_str("EOF\n");
1913 Sp3::parse(body.as_bytes()).expect("parse test sp3")
1914 }
1915
1916 fn sp3_records(records: &[(&str, [f64; 3], Option<f64>)]) -> Sp3 {
1918 let full: Vec<(&str, [f64; 3], Option<f64>, &str)> =
1919 records.iter().map(|(s, p, c)| (*s, *p, *c, "")).collect();
1920 sp3_build(&full, "IGS14")
1921 }
1922
1923 fn sp3_two_epochs(
1924 epoch0: &[(&str, [f64; 3], Option<f64>)],
1925 epoch1: &[(&str, [f64; 3], Option<f64>)],
1926 interval_s: f64,
1927 cs: &str,
1928 ) -> Sp3 {
1929 let mut sats: Vec<&str> = epoch0
1930 .iter()
1931 .chain(epoch1.iter())
1932 .map(|(sat, _, _)| *sat)
1933 .collect();
1934 sats.sort_unstable();
1935 sats.dedup();
1936 let n = sats.len();
1937 let mut sat_field = String::new();
1938 for sat in &sats {
1939 sat_field.push_str(sat);
1940 }
1941 for _ in n..17 {
1942 sat_field.push_str(" 0");
1943 }
1944
1945 let mut body = String::new();
1946 body.push_str(&format!(
1947 "#cP2020 6 25 0 0 0.00000000 2 ORBIT {cs} FIT TST\n"
1948 ));
1949 body.push_str(&format!(
1950 "## 2111 432000.00000000 {interval_s:14.8} 59025 0.0000000000000\n"
1951 ));
1952 body.push_str(&format!("+ {n:2} {sat_field}\n"));
1953 body.push_str("++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n");
1954 body.push_str("%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1955 body.push_str("%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1956 body.push_str("%f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n");
1957 body.push_str("%f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n");
1958 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1959 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1960 body.push_str("/* TEST SP3-c FIXTURE\n");
1961 body.push_str("* 2020 6 25 0 0 0.00000000\n");
1962 for (sat, p, clk) in epoch0 {
1963 let c = clk.unwrap_or(999_999.999_999);
1964 body.push_str(&format!(
1965 "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}\n",
1966 p[0], p[1], p[2]
1967 ));
1968 }
1969 let second_hour = (interval_s as i64) / 3600;
1970 let second_minute = ((interval_s as i64) % 3600) / 60;
1971 let second_second = (interval_s as i64) % 60;
1972 body.push_str(&format!(
1973 "* 2020 6 25 {second_hour:2} {second_minute:2} {second_second:2}.00000000\n"
1974 ));
1975 for (sat, p, clk) in epoch1 {
1976 let c = clk.unwrap_or(999_999.999_999);
1977 body.push_str(&format!(
1978 "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}\n",
1979 p[0], p[1], p[2]
1980 ));
1981 }
1982 body.push_str("EOF\n");
1983 Sp3::parse(body.as_bytes()).expect("parse test sp3")
1984 }
1985
1986 fn sp3_epochs(
1988 start_offset_s: f64,
1989 epochs: &[&[SatSample<'_>]],
1990 interval_s: f64,
1991 cs: &str,
1992 ) -> Sp3 {
1993 let mut sats: Vec<&str> = epochs
1994 .iter()
1995 .flat_map(|e| e.iter().map(|(sat, _, _)| *sat))
1996 .collect();
1997 sats.sort_unstable();
1998 sats.dedup();
1999 let n = sats.len();
2000 let mut sat_field = String::new();
2001 for sat in &sats {
2002 sat_field.push_str(sat);
2003 }
2004 for _ in n..17 {
2005 sat_field.push_str(" 0");
2006 }
2007
2008 let hms = |t: i64| (t / 3600, (t % 3600) / 60, t % 60);
2009 let start = start_offset_s as i64;
2010 let (sh, sm, ss0) = hms(start);
2011
2012 let mut body = String::new();
2013 body.push_str(&format!(
2014 "#cP2020 6 25 {sh:2} {sm:2} {ss0:2}.00000000 {:2} ORBIT {cs} FIT TST\n",
2015 epochs.len()
2016 ));
2017 let sow = 432_000.0 + start_offset_s;
2019 let mjd_frac = start_offset_s / SECONDS_PER_DAY;
2020 body.push_str(&format!(
2021 "## 2111 {sow:15.8} {interval_s:14.8} 59025 {mjd_frac:.13}\n"
2022 ));
2023 body.push_str(&format!("+ {n:2} {sat_field}\n"));
2024 body.push_str("++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n");
2025 body.push_str("%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
2026 body.push_str("%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
2027 body.push_str("%f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n");
2028 body.push_str("%f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n");
2029 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
2030 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
2031 body.push_str("/* TEST SP3-c FIXTURE\n");
2032 for (k, recs) in epochs.iter().enumerate() {
2033 let (hh, mm, ss) = hms(start + (k as i64) * (interval_s as i64));
2034 body.push_str(&format!("* 2020 6 25 {hh:2} {mm:2} {ss:2}.00000000\n"));
2035 for (sat, p, clk) in recs.iter() {
2036 let c = clk.unwrap_or(999_999.999_999);
2037 body.push_str(&format!(
2038 "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}\n",
2039 p[0], p[1], p[2]
2040 ));
2041 }
2042 }
2043 body.push_str("EOF\n");
2044 Sp3::parse(body.as_bytes()).expect("parse test sp3")
2045 }
2046
2047 #[test]
2048 fn merge_unions_coverage_when_one_center_misses_a_satellite() {
2049 let a = sp3_records(&[
2052 ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
2053 ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
2054 ("G03", [17000.0, -22000.0, 7000.0], Some(300.0)),
2055 ]);
2056 let b = sp3_records(&[
2057 ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
2058 ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
2059 ]);
2060
2061 let (merged, report) = merge(&[a, b], &MergeOptions::default()).expect("merge");
2062
2063 let states = merged.states_at(0).expect("epoch 0");
2064 assert!(
2065 states.contains_key(&gps(3)),
2066 "merged output must cover G03 from the center that has it"
2067 );
2068 assert_eq!(states.len(), 3, "union is G01/G02/G03");
2069 let g01 = states[&gps(1)];
2071 assert!((g01.clock_s.unwrap() - 100.0e-6).abs() < 1.0e-15);
2072 assert!(report.quarantined.is_empty());
2074 assert_eq!(report.single_source.len(), 1);
2075 assert_eq!(report.single_source[0].satellite, gps(3));
2076
2077 let frac = report
2081 .single_source_fraction()
2082 .expect("accepted cells present");
2083 assert!(
2084 (frac - 1.0 / 3.0).abs() < 1.0e-12,
2085 "single-source fraction {frac}"
2086 );
2087 assert_eq!(MergeReport::default().single_source_fraction(), None);
2088 }
2089
2090 #[test]
2091 fn merge_rejects_non_executable_system_and_frame_policies() {
2092 let source = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))]);
2093
2094 let empty_systems = MergeOptions {
2095 systems: Some(BTreeSet::new()),
2096 ..MergeOptions::default()
2097 };
2098 let error = merge(std::slice::from_ref(&source), &empty_systems).unwrap_err();
2099 assert!(error
2100 .to_string()
2101 .contains("systems filter must not be empty"));
2102
2103 let incomplete_frame_set = MergeOptions {
2104 frame_reconciliation: Sp3FrameReconciliationOptions {
2105 asserted_equivalent_label_sets: vec![Sp3FrameLabelSet::new(["IGS20"])],
2106 helmert: false,
2107 },
2108 ..MergeOptions::default()
2109 };
2110 let error = merge(&[source], &incomplete_frame_set).unwrap_err();
2111 assert!(error.to_string().contains("at least two non-empty labels"));
2112 }
2113
2114 #[test]
2115 fn merge_combines_two_of_three_agreeing_sources_and_rejects_the_outlier() {
2116 let a = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))]);
2118 let b = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))]);
2119 let c = sp3_records(&[("G01", [15000.010, -20000.0, 5000.0], Some(100.0))]);
2120
2121 let (merged, report) = merge(&[a, b, c], &MergeOptions::default()).expect("merge");
2122
2123 let states = merged.states_at(0).expect("epoch 0");
2124 let g01 = states[&gps(1)];
2125 assert!(
2127 (g01.position.as_array()[0] - 15_000_000.0).abs() < 1.0e-3,
2128 "got {}",
2129 g01.position.as_array()[0]
2130 );
2131 assert_eq!(report.position_outliers.len(), 1);
2133 assert_eq!(report.position_outliers[0].sources, vec![2]);
2134 assert!(report.quarantined.is_empty());
2135 }
2136
2137 #[test]
2138 fn guarded_precedence_replaces_a_corrupt_preferred_position() {
2139 let preferred = sp3_records(&[("G01", [16000.0, -20000.0, 5000.0], None)]);
2140 let agreeing_a = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], None)]);
2141 let agreeing_b = sp3_records(&[("G01", [15000.0002, -20000.0, 5000.0], None)]);
2142 let opts = MergeOptions {
2143 combine: MergeCombine::Precedence,
2144 min_agree: 1,
2145 outlier_reject: Some(OutlierRejectOptions {
2146 position_tolerance_m: 0.5,
2147 clock_tolerance_s: 5.0e-9,
2148 }),
2149 ..MergeOptions::default()
2150 };
2151
2152 let (merged, report) = merge(&[preferred, agreeing_a, agreeing_b], &opts).expect("merge");
2153
2154 let x = merged.states_at(0).expect("epoch")[&gps(1)]
2155 .position
2156 .as_array()[0];
2157 assert_eq!(
2158 x, 15_000_000.0,
2159 "earliest member of the 2-source cluster wins"
2160 );
2161 assert_eq!(report.position_outliers.len(), 1);
2162 assert_eq!(report.position_outliers[0].sources, vec![0]);
2163 }
2164
2165 #[test]
2166 fn unguarded_precedence_preserves_the_existing_preferred_value_behavior() {
2167 let preferred = sp3_records(&[("G01", [16000.0, -20000.0, 5000.0], None)]);
2168 let agreeing_a = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], None)]);
2169 let agreeing_b = sp3_records(&[("G01", [15000.0002, -20000.0, 5000.0], None)]);
2170 let opts = MergeOptions {
2171 combine: MergeCombine::Precedence,
2172 min_agree: 1,
2173 outlier_reject: None,
2174 ..MergeOptions::default()
2175 };
2176
2177 let (merged, report) = merge(&[preferred, agreeing_a, agreeing_b], &opts).expect("merge");
2178
2179 let x = merged.states_at(0).expect("epoch")[&gps(1)]
2180 .position
2181 .as_array()[0];
2182 assert_eq!(x, 16_000_000.0);
2183 assert_eq!(report.position_outliers[0].sources, vec![1, 2]);
2184 }
2185
2186 #[test]
2187 fn guarded_precedence_keeps_a_preferred_member_of_the_majority() {
2188 let preferred = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], None)]);
2189 let agreeing = sp3_records(&[("G01", [15000.0002, -20000.0, 5000.0], None)]);
2190 let outlier = sp3_records(&[("G01", [16000.0, -20000.0, 5000.0], None)]);
2191 let opts = MergeOptions {
2192 combine: MergeCombine::Precedence,
2193 min_agree: 1,
2194 outlier_reject: Some(OutlierRejectOptions {
2195 position_tolerance_m: 0.5,
2196 clock_tolerance_s: 5.0e-9,
2197 }),
2198 ..MergeOptions::default()
2199 };
2200
2201 let (merged, report) = merge(&[preferred, agreeing, outlier], &opts).expect("merge");
2202
2203 let x = merged.states_at(0).expect("epoch")[&gps(1)]
2204 .position
2205 .as_array()[0];
2206 assert_eq!(x, 15_000_000.0);
2207 assert_eq!(report.position_outliers[0].sources, vec![2]);
2208 }
2209
2210 #[test]
2211 fn guarded_precedence_keeps_a_single_source_cell() {
2212 let only = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], None)]);
2213 let opts = MergeOptions {
2214 combine: MergeCombine::Precedence,
2215 min_agree: 1,
2216 outlier_reject: Some(OutlierRejectOptions {
2217 position_tolerance_m: 0.5,
2218 clock_tolerance_s: 5.0e-9,
2219 }),
2220 ..MergeOptions::default()
2221 };
2222
2223 let (merged, report) = merge(&[only], &opts).expect("merge");
2224
2225 assert!(merged.states_at(0).expect("epoch").contains_key(&gps(1)));
2226 assert_eq!(report.single_source.len(), 1);
2227 assert!(report.quarantined.is_empty());
2228 }
2229
2230 #[test]
2231 fn guarded_precedence_position_tolerance_is_inclusive() {
2232 for (delta_km, accepted) in [(0.000_499, true), (0.000_501, false)] {
2233 let a = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], None)]);
2234 let b = sp3_records(&[("G01", [15000.0 + delta_km, -20000.0, 5000.0], None)]);
2235 let opts = MergeOptions {
2236 combine: MergeCombine::Precedence,
2237 min_agree: 1,
2238 outlier_reject: Some(OutlierRejectOptions {
2239 position_tolerance_m: 0.5,
2240 clock_tolerance_s: 5.0e-9,
2241 }),
2242 ..MergeOptions::default()
2243 };
2244
2245 let (merged, report) = merge(&[a, b], &opts).expect("merge");
2246 assert_eq!(
2247 merged.states_at(0).expect("epoch").contains_key(&gps(1)),
2248 accepted,
2249 "delta {delta_km} km"
2250 );
2251 assert_eq!(report.quarantined.is_empty(), accepted);
2252 }
2253
2254 assert_eq!(
2255 super::largest_within(&[0.0_f64, 0.5_f64], |a, b| (*a - *b).abs() <= 0.5).len(),
2256 2,
2257 "the tolerance boundary itself is accepted"
2258 );
2259 }
2260
2261 #[test]
2262 fn guarded_precedence_replaces_a_corrupt_preferred_clock() {
2263 let positions = |clock_g01: f64| {
2264 sp3_records(&[
2265 ("G01", [15000.0, -20000.0, 5000.0], Some(clock_g01)),
2266 ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
2267 ("G03", [17000.0, -22000.0, 7000.0], Some(300.0)),
2268 ("G04", [18000.0, -23000.0, 8000.0], Some(400.0)),
2269 ("G05", [19000.0, -24000.0, 9000.0], Some(500.0)),
2270 ])
2271 };
2272 let opts = MergeOptions {
2273 combine: MergeCombine::Precedence,
2274 min_agree: 1,
2275 outlier_reject: Some(OutlierRejectOptions {
2276 position_tolerance_m: 0.5,
2277 clock_tolerance_s: 5.0e-9,
2278 }),
2279 ..MergeOptions::default()
2280 };
2281
2282 let (merged, report) = merge(
2283 &[positions(1100.0), positions(100.0), positions(100.0)],
2284 &opts,
2285 )
2286 .expect("merge");
2287
2288 let clock = merged.states_at(0).expect("epoch")[&gps(1)]
2289 .clock_s
2290 .expect("consensus clock");
2291 assert!((clock - 100.0e-6).abs() < 1.0e-15, "clock {clock}");
2292 let rejected = report
2293 .clock_outliers
2294 .iter()
2295 .find(|entry| entry.satellite == gps(1))
2296 .expect("clock outlier provenance");
2297 assert_eq!(rejected.sources, vec![0]);
2298 }
2299
2300 #[test]
2301 fn merge_consensus_handles_more_than_u32_mask_bits() {
2302 let sources: Vec<Sp3> = (0..33)
2305 .map(|idx| {
2306 let x_km = if idx < 32 { 15000.0 } else { 15000.010 };
2307 sp3_records(&[("G01", [x_km, -20000.0, 5000.0], Some(100.0))])
2308 })
2309 .collect();
2310
2311 for combine in [MergeCombine::Mean, MergeCombine::Precedence] {
2312 let opts = MergeOptions {
2313 combine,
2314 min_agree: 32,
2315 ..MergeOptions::default()
2316 };
2317
2318 let (merged, report) = merge(&sources, &opts).expect("33-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 "{combine:?}: got {}",
2325 g01.position.as_array()[0]
2326 );
2327 assert_eq!(
2328 report.position_outliers.len(),
2329 1,
2330 "{combine:?}: expected one outlier report"
2331 );
2332 assert_eq!(report.position_outliers[0].sources, vec![32]);
2333 assert!(report.quarantined.is_empty(), "{combine:?}");
2334 }
2335 }
2336
2337 #[test]
2338 fn merge_bounds_large_overlap_clique_search() {
2339 let sources: Vec<Sp3> = (0..40)
2340 .map(|idx| {
2341 let x_km = if idx % 2 == 0 { 15000.0 } else { 15000.010 };
2342 sp3_records(&[("G01", [x_km, -20000.0, 5000.0], Some(100.0))])
2343 })
2344 .collect();
2345 let opts = MergeOptions {
2346 min_agree: 20,
2347 ..MergeOptions::default()
2348 };
2349
2350 let (merged, report) = merge(&sources, &opts).expect("bounded large-source merge");
2351
2352 let states = merged.states_at(0).expect("epoch 0");
2353 let g01 = states[&gps(1)];
2354 assert!(
2355 (g01.position.as_array()[0] - 15_000_000.0).abs() < 1.0e-3,
2356 "got {}",
2357 g01.position.as_array()[0]
2358 );
2359 assert_eq!(report.position_outliers.len(), 1);
2360 assert_eq!(
2361 report.position_outliers[0].sources,
2362 (1..40).step_by(2).collect::<Vec<_>>()
2363 );
2364 assert!(report.quarantined.is_empty());
2365 }
2366
2367 #[test]
2368 fn merge_quarantines_a_satellite_all_centers_disagree_on() {
2369 let a = sp3_records(&[("G01", [15000.000, -20000.0, 5000.0], Some(100.0))]);
2371 let b = sp3_records(&[("G01", [15000.010, -20000.0, 5000.0], Some(100.0))]);
2372 let c = sp3_records(&[("G01", [15000.020, -20000.0, 5000.0], Some(100.0))]);
2373
2374 let (merged, report) = merge(&[a, b, c], &MergeOptions::default()).expect("merge");
2375
2376 assert!(
2377 merged.states_at(0).expect("epoch 0").is_empty(),
2378 "no consensus -> G01 omitted, not averaged across disagreeing centers"
2379 );
2380 assert_eq!(report.quarantined.len(), 1);
2381 assert_eq!(report.quarantined[0].satellite, gps(1));
2382 }
2383
2384 #[test]
2385 fn merge_rejects_an_empty_input() {
2386 assert!(merge(&[], &MergeOptions::default()).is_err());
2387 }
2388
2389 #[test]
2390 fn merge_omits_an_unalignable_secondary_clock() {
2391 let a = sp3_records(&[
2396 ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
2397 ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
2398 ("G03", [17000.0, -22000.0, 7000.0], Some(300.0)),
2399 ]);
2400 let b = sp3_records(&[
2401 ("G01", [15000.0, -20000.0, 5000.0], Some(150.0)),
2402 ("G02", [16000.0, -21000.0, 6000.0], Some(250.0)),
2403 ("G03", [17000.0, -22000.0, 7000.0], Some(350.0)),
2404 ("G04", [18000.0, -23000.0, 8000.0], Some(450.0)),
2405 ]);
2406
2407 let (merged, _) = merge(&[a, b], &MergeOptions::default()).expect("merge");
2408 let states = merged.states_at(0).expect("epoch 0");
2409
2410 assert!(states.contains_key(&gps(4)));
2412 assert!(
2413 states[&gps(4)].clock_s.is_none(),
2414 "an unalignable secondary clock must be dropped, not emitted raw"
2415 );
2416 let g01_clock = states[&gps(1)]
2418 .clock_s
2419 .expect("G01 carries the reference clock");
2420 assert!((g01_clock - 100.0e-6).abs() < 1.0e-12, "got {g01_clock}");
2421 }
2422
2423 #[test]
2424 fn merge_rejects_mismatched_coordinate_systems() {
2425 let a = sp3_build(
2426 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2427 "IGS14",
2428 );
2429 let b = sp3_build(
2430 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2431 "IGS20",
2432 );
2433
2434 assert!(merge(&[a, b], &MergeOptions::default()).is_err());
2435 }
2436
2437 #[test]
2438 fn merge_rejects_different_igs_frame_labels_without_a_transform() {
2439 let a = sp3_build(
2440 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2441 "IGS20",
2442 );
2443 let b = sp3_build(
2444 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2445 "IGc20",
2446 );
2447
2448 let err = merge(&[a, b], &MergeOptions::default()).expect_err("frame mismatch");
2449 assert!(
2450 err.to_string().contains("mismatched coordinate systems"),
2451 "{err}"
2452 );
2453 }
2454
2455 #[test]
2456 fn merge_accepts_asserted_equivalent_labels_and_reports_assertion() {
2457 for (a_label, b_label) in [("IGS14", "ITRF2"), ("ITRF2", "IGS14")] {
2458 let a = sp3_build(
2459 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2460 a_label,
2461 );
2462 let b = sp3_build(
2463 &[("G02", [16000.0, -21000.0, 6000.0], Some(200.0), "")],
2464 b_label,
2465 );
2466 let opts = MergeOptions {
2467 frame_reconciliation: super::Sp3FrameReconciliationOptions {
2468 asserted_equivalent_label_sets: vec![Sp3FrameLabelSet::pair("IGS14", "ITRF2")],
2469 helmert: false,
2470 },
2471 ..MergeOptions::default()
2472 };
2473
2474 let (merged, report) = merge(&[a, b], &opts).expect("asserted frame merge");
2475
2476 let states = merged.states_at(0).expect("epoch 0");
2477 assert!(states.contains_key(&gps(1)));
2478 assert!(states.contains_key(&gps(2)));
2479 assert_eq!(merged.header.coordinate_system, a_label);
2480 assert_eq!(report.frame_reconciliations.len(), 1);
2481 let reconciliation = &report.frame_reconciliations[0];
2482 assert_eq!(
2483 reconciliation.method,
2484 Sp3FrameReconciliationMethod::AssertedEquivalence
2485 );
2486 assert_eq!(reconciliation.source_index, 1);
2487 assert_eq!(reconciliation.source_label, b_label);
2488 assert_eq!(reconciliation.target_label, a_label);
2489 assert_eq!(reconciliation.records_affected, 1);
2490 assert!(reconciliation.parameters.is_none());
2491 assert!(reconciliation.rates.is_none());
2492 assert_eq!(
2493 reconciliation
2494 .asserted_label_set
2495 .as_ref()
2496 .expect("assertion set"),
2497 &vec!["IGS14".to_string(), "ITRF2".to_string()]
2498 );
2499 }
2500 }
2501
2502 #[test]
2503 fn merge_applies_helmert_reconciliation_to_resolved_labels() {
2504 let a = sp3_build(
2509 &[("G01", [14000.0, -19000.0, 4000.0], Some(100.0), "")],
2510 "IGS14",
2511 );
2512 let b = sp3_build(
2513 &[("G02", [15000.0, -20000.0, 5000.0], Some(200.0), "")],
2514 "IGS20",
2515 );
2516 let opts = MergeOptions {
2517 min_agree: 1,
2518 frame_reconciliation: super::Sp3FrameReconciliationOptions::helmert(),
2519 ..MergeOptions::default()
2520 };
2521
2522 let (merged, report) = merge(&[a, b], &opts).expect("helmert frame merge");
2523
2524 let g02 = merged.states_at(0).expect("epoch 0")[&gps(2)];
2525 let got = g02.position.as_array();
2526 let expected = [
2527 14_999_999.992_3,
2528 -19_999_999.993_048_087,
2529 5_000_000.000_396_175,
2530 ];
2531 for axis in 0..3 {
2532 assert!(
2533 (got[axis] - expected[axis]).abs() < 2.0e-9,
2534 "axis {axis}: got {}, expected {}",
2535 got[axis],
2536 expected[axis]
2537 );
2538 }
2539 assert_eq!(merged.header.coordinate_system, "IGS14");
2540 assert_eq!(report.frame_reconciliations.len(), 1);
2541 let reconciliation = &report.frame_reconciliations[0];
2542 assert_eq!(reconciliation.method, Sp3FrameReconciliationMethod::Helmert);
2543 assert_eq!(reconciliation.source_label, "IGS20");
2544 assert_eq!(reconciliation.target_label, "IGS14");
2545 assert_eq!(reconciliation.records_affected, 1);
2546 assert_eq!(
2547 reconciliation
2548 .parameters
2549 .expect("published parameters")
2550 .translation_mm,
2551 [-1.4, -0.9, 1.4]
2552 );
2553 assert_eq!(
2554 reconciliation.catalog_source_frame,
2555 Some(crate::frame_catalog::TerrestrialFrame::Itrf2020)
2556 );
2557 assert_eq!(
2558 reconciliation.catalog_target_frame,
2559 Some(crate::frame_catalog::TerrestrialFrame::Itrf2014)
2560 );
2561 assert!(!reconciliation.catalog_inverse);
2562 assert_eq!(
2563 reconciliation
2564 .rates
2565 .expect("published rates")
2566 .translation_mm_per_year,
2567 [0.0, -0.1, 0.2]
2568 );
2569 assert!(reconciliation
2570 .provenance
2571 .as_ref()
2572 .expect("provenance")
2573 .contains("ITRF2020 to past ITRFs"));
2574 }
2575
2576 #[test]
2577 fn merge_reports_inverse_helmert_catalog_direction() {
2578 let a = sp3_build(
2579 &[("G01", [14000.0, -19000.0, 4000.0], Some(100.0), "")],
2580 "IGS20",
2581 );
2582 let b = sp3_build(
2583 &[("G02", [15000.0, -20000.0, 5000.0], Some(200.0), "")],
2584 "IGS14",
2585 );
2586 let opts = MergeOptions {
2587 min_agree: 1,
2588 frame_reconciliation: super::Sp3FrameReconciliationOptions::helmert(),
2589 ..MergeOptions::default()
2590 };
2591
2592 let (_merged, report) = merge(&[a, b], &opts).expect("inverse helmert frame merge");
2593
2594 let reconciliation = &report.frame_reconciliations[0];
2595 assert_eq!(reconciliation.method, Sp3FrameReconciliationMethod::Helmert);
2596 assert_eq!(
2597 reconciliation.source_frame,
2598 Some(crate::frame_catalog::TerrestrialFrame::Itrf2014)
2599 );
2600 assert_eq!(
2601 reconciliation.target_frame,
2602 Some(crate::frame_catalog::TerrestrialFrame::Itrf2020)
2603 );
2604 assert_eq!(
2605 reconciliation.catalog_source_frame,
2606 Some(crate::frame_catalog::TerrestrialFrame::Itrf2020)
2607 );
2608 assert_eq!(
2609 reconciliation.catalog_target_frame,
2610 Some(crate::frame_catalog::TerrestrialFrame::Itrf2014)
2611 );
2612 assert!(reconciliation.catalog_inverse);
2613 assert_eq!(
2614 reconciliation
2615 .parameters
2616 .expect("published parameters")
2617 .translation_mm,
2618 [-1.4, -0.9, 1.4]
2619 );
2620 }
2621
2622 #[test]
2623 fn helmert_identity_label_reconciliation_is_bit_equal() {
2624 let a = sp3_build(
2625 &[("G01", [14000.0, -19000.0, 4000.0], Some(100.0), "")],
2626 "IGS20",
2627 );
2628 let b = sp3_build(
2629 &[("G02", [15000.125, -20000.5, 5000.25], Some(200.0), "")],
2630 "IGc20",
2631 );
2632 let original = b.states_at(0).expect("epoch 0")[&gps(2)].position;
2633 let opts = MergeOptions {
2634 min_agree: 1,
2635 frame_reconciliation: super::Sp3FrameReconciliationOptions::helmert(),
2636 ..MergeOptions::default()
2637 };
2638
2639 let (merged, report) = merge(&[a, b], &opts).expect("identity frame merge");
2640
2641 let g02 = merged.states_at(0).expect("epoch 0")[&gps(2)].position;
2642 for axis in 0..3 {
2643 assert_eq!(
2644 g02.as_array()[axis].to_bits(),
2645 original.as_array()[axis].to_bits()
2646 );
2647 }
2648 assert_eq!(report.frame_reconciliations.len(), 1);
2649 assert!(report.frame_reconciliations[0].identity);
2650 assert!(report.frame_reconciliations[0].parameters.is_none());
2651 }
2652
2653 #[test]
2654 fn helmert_reconciliation_rejects_unknown_labels() {
2655 let a = sp3_build(
2656 &[("G01", [14000.0, -19000.0, 4000.0], Some(100.0), "")],
2657 "ITRF2",
2658 );
2659 let b = sp3_build(
2660 &[("G02", [15000.0, -20000.0, 5000.0], Some(200.0), "")],
2661 "IGS20",
2662 );
2663 let opts = MergeOptions {
2664 frame_reconciliation: super::Sp3FrameReconciliationOptions::helmert(),
2665 ..MergeOptions::default()
2666 };
2667
2668 let err = merge(&[a, b], &opts).expect_err("unknown frame label");
2669
2670 assert!(
2671 err.to_string().contains("target label"),
2672 "unknown labels must not be guessed: {err}"
2673 );
2674 }
2675
2676 #[test]
2677 fn merge_uses_finest_union_grid_and_fills_sparse_precedence_cells() {
2678 let a = sp3_two_epochs(
2682 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2683 &[("G01", [15003.0, -20003.0, 5003.0], Some(103.0))],
2684 900.0,
2685 "IGS14",
2686 );
2687 let b = sp3_epochs(
2688 0.0,
2689 &[
2690 &[("G01", [26000.0, -20000.0, 5000.0], Some(200.0))],
2691 &[("G01", [26001.0, -20001.0, 5001.0], Some(201.0))],
2692 &[("G01", [26002.0, -20002.0, 5002.0], Some(202.0))],
2693 &[("G01", [26003.0, -20003.0, 5003.0], Some(203.0))],
2694 ],
2695 300.0,
2696 "IGS14",
2697 );
2698
2699 let opts = MergeOptions {
2700 combine: MergeCombine::Precedence,
2701 min_agree: 1,
2702 ..MergeOptions::default()
2703 };
2704 let (merged, _report) = merge(&[a, b], &opts).expect("mixed-interval union merge");
2705
2706 assert_eq!(
2707 merged.header.epoch_interval_s, 300.0,
2708 "output is on the finest (300 s) input grid"
2709 );
2710 assert_eq!(
2711 merged.epochs.len(),
2712 4,
2713 "B fills the :05 and :10 epochs between A's samples"
2714 );
2715 let xs: Vec<f64> = (0..4)
2716 .map(|idx| {
2717 merged.states_at(idx).expect("epoch")[&gps(1)]
2718 .position
2719 .as_array()[0]
2720 })
2721 .collect();
2722 assert_eq!(
2723 xs,
2724 vec![15_000_000.0, 26_001_000.0, 26_002_000.0, 15_003_000.0]
2725 );
2726 }
2727
2728 #[test]
2729 fn mixed_cadence_interpolates_only_the_clock_datum_for_filled_cells() {
2730 let reference_epoch: Vec<SatSample<'_>> = vec![
2731 ("G01", [15_001.0, -20_000.0, 5_000.0], Some(100.0)),
2732 ("G02", [15_002.0, -20_000.0, 5_000.0], Some(200.0)),
2733 ("G03", [15_003.0, -20_000.0, 5_000.0], Some(300.0)),
2734 ("G04", [15_004.0, -20_000.0, 5_000.0], Some(400.0)),
2735 ("G05", [15_005.0, -20_000.0, 5_000.0], Some(500.0)),
2736 ];
2737 let shifted_epoch: Vec<SatSample<'_>> = reference_epoch
2738 .iter()
2739 .map(|(sat, position, clock)| (*sat, *position, clock.map(|value| value + 50.0)))
2740 .collect();
2741 let a = sp3_epochs(
2742 0.0,
2743 &[reference_epoch.as_slice(), reference_epoch.as_slice()],
2744 900.0,
2745 "IGS14",
2746 );
2747 let b = sp3_epochs(
2748 0.0,
2749 &[
2750 shifted_epoch.as_slice(),
2751 shifted_epoch.as_slice(),
2752 shifted_epoch.as_slice(),
2753 shifted_epoch.as_slice(),
2754 ],
2755 300.0,
2756 "IGS14",
2757 );
2758 let opts = MergeOptions {
2759 combine: MergeCombine::Precedence,
2760 min_agree: 1,
2761 ..MergeOptions::default()
2762 };
2763
2764 let (merged, _) = merge(&[a, b], &opts).expect("mixed-cadence clock merge");
2765
2766 assert_eq!(merged.epochs.len(), 4);
2767 for epoch_index in 0..4 {
2768 let clock = merged.states_at(epoch_index).expect("epoch")[&gps(1)]
2769 .clock_s
2770 .expect("aligned clock");
2771 assert!(
2772 (clock - 100.0e-6).abs() < 1.0e-15,
2773 "epoch {epoch_index}: {clock}"
2774 );
2775 }
2776 }
2777
2778 #[test]
2779 fn merge_decimates_with_explicit_coarser_target_interval() {
2780 let recs = |x: f64| vec![("G01", [x, -20000.0, 5000.0], Some(100.0))];
2782 let make = || {
2783 sp3_epochs(
2784 0.0,
2785 &[
2786 &recs(15000.0),
2787 &recs(15001.0),
2788 &recs(15002.0),
2789 &recs(15003.0),
2790 ],
2791 300.0,
2792 "IGS14",
2793 )
2794 };
2795 let opts = MergeOptions {
2796 min_agree: 1,
2797 target_epoch_interval_s: Some(900.0),
2798 ..MergeOptions::default()
2799 };
2800 let (merged, _) = merge(&[make(), make()], &opts).expect("explicit coarse target");
2801 assert_eq!(merged.header.epoch_interval_s, 900.0);
2802 assert_eq!(
2803 merged.epochs.len(),
2804 2,
2805 "decimated 5-min inputs to the 900 s target"
2806 );
2807 }
2808
2809 #[test]
2810 fn merge_rejects_non_divisible_epoch_intervals() {
2811 let a = sp3_two_epochs(
2815 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2816 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2817 900.0,
2818 "IGS14",
2819 );
2820 let b = sp3_two_epochs(
2821 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2822 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2823 400.0,
2824 "IGS14",
2825 );
2826
2827 let err = merge(&[a, b], &MergeOptions::default()).expect_err("non-divisible intervals");
2828 assert!(
2829 err.to_string().contains("mismatched epoch intervals"),
2830 "{err}"
2831 );
2832 }
2833
2834 #[test]
2835 fn merge_rejects_a_non_whole_second_common_interval() {
2836 let mk = || {
2839 sp3_two_epochs(
2840 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2841 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2842 900.0,
2843 "IGS14",
2844 )
2845 };
2846 let opts = MergeOptions {
2847 target_epoch_interval_s: Some(450.5),
2848 ..MergeOptions::default()
2849 };
2850 let err = merge(&[mk(), mk()], &opts).expect_err("fractional target");
2851 assert!(err.to_string().contains("whole number of seconds"), "{err}");
2852 }
2853
2854 #[test]
2855 fn merge_header_first_epoch_describes_the_union_grid_start() {
2856 let a = sp3_epochs(
2859 0.0,
2860 &[
2861 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2862 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2863 &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
2864 ],
2865 900.0,
2866 "IGS14",
2867 );
2868 let b = sp3_epochs(
2869 900.0,
2870 &[
2871 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2872 &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
2873 &[("G01", [15003.0, -20003.0, 5003.0], Some(103.0))],
2874 ],
2875 900.0,
2876 "IGS14",
2877 );
2878
2879 let opts = MergeOptions {
2880 min_agree: 1,
2881 ..MergeOptions::default()
2882 };
2883 let (merged, _) = merge(&[a, b], &opts).expect("merge");
2884
2885 assert_eq!(
2886 merged.epochs.len(),
2887 4,
2888 "union epochs run from 00:00 to 00:45"
2889 );
2890 assert!(
2891 (merged.header.seconds_of_week - 345_600.0).abs() < 1.0e-6,
2892 "header sow must describe the union's first epoch 00:00 (345600 s), got {}",
2893 merged.header.seconds_of_week
2894 );
2895 assert!(
2896 merged.header.mjd_fraction.abs() < 1.0e-9,
2897 "header MJD fraction must describe 00:00, got {}",
2898 merged.header.mjd_fraction
2899 );
2900 }
2901
2902 #[test]
2903 fn merge_writer_recomputes_header_for_a_fine_union_grid() {
2904 let a = sp3_epochs(
2908 0.0,
2909 &[
2910 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2911 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2912 &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
2913 ],
2914 900.0,
2915 "IGS14",
2916 );
2917 let b = sp3_epochs(
2918 450.0,
2919 &[
2920 &[("G01", [15010.0, -20010.0, 5010.0], Some(110.0))],
2921 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2922 &[("G01", [15011.0, -20011.0, 5011.0], Some(111.0))],
2923 &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
2924 ],
2925 450.0,
2926 "IGS14",
2927 );
2928
2929 let opts = MergeOptions {
2930 min_agree: 1,
2931 ..MergeOptions::default()
2932 };
2933 let (merged, _) = merge(&[a, b], &opts).expect("mixed-cadence merge");
2934
2935 assert_eq!(merged.epochs.len(), 5, "union epochs run every 7.5 minutes");
2936 let text = merged.to_sp3_string();
2937 let header = text
2938 .lines()
2939 .find(|line| line.starts_with("## "))
2940 .expect("written ## header");
2941 let first_epoch = text
2942 .lines()
2943 .find(|line| line.starts_with("* "))
2944 .expect("written first epoch");
2945
2946 assert_eq!(first_epoch, "* 2020 6 25 0 0 0.00000000");
2947 assert_eq!(
2948 header,
2949 "## 2111 345600.00000000 450.00000000 59025 0.0000000000000"
2950 );
2951 }
2952
2953 #[test]
2954 fn precedence_merge_never_switches_source_within_one_satellite_arc() {
2955 let a = sp3_two_epochs(
2956 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2957 &[],
2958 900.0,
2959 "IGS14",
2960 );
2961 let b = sp3_two_epochs(
2962 &[("G01", [15000.001, -20000.0, 5000.0], Some(100.0))],
2963 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2964 900.0,
2965 "IGS14",
2966 );
2967 let opts = MergeOptions {
2968 combine: MergeCombine::Precedence,
2969 min_agree: 1,
2970 precedence_scope: MergePrecedenceScope::SatelliteArc,
2971 ..MergeOptions::default()
2972 };
2973
2974 let (merged, _report) = merge(&[a, b], &opts).expect("merge");
2975 let epoch0 = merged.states_at(0).expect("epoch 0");
2976 let epoch1 = merged.states_at(1).expect("epoch 1");
2977
2978 assert!(epoch0.contains_key(&gps(1)));
2979 assert!(
2980 !epoch1.contains_key(&gps(1)),
2981 "G01 must not switch from source 0 at epoch 0 to source 1 at epoch 1"
2982 );
2983 assert_eq!(merged.header.epoch_interval_s, 900.0);
2984 }
2985
2986 #[test]
2987 fn cell_precedence_fills_a_preferred_source_dropout() {
2988 let a = sp3_two_epochs(
2989 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2990 &[],
2991 900.0,
2992 "IGS14",
2993 );
2994 let b = sp3_two_epochs(
2995 &[("G01", [15000.001, -20000.0, 5000.0], Some(100.0))],
2996 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2997 900.0,
2998 "IGS14",
2999 );
3000 let opts = MergeOptions {
3001 combine: MergeCombine::Precedence,
3002 min_agree: 1,
3003 ..MergeOptions::default()
3004 };
3005
3006 let (merged, report) = merge(&[a, b], &opts).expect("merge");
3007
3008 assert!(merged.states_at(0).expect("epoch 0").contains_key(&gps(1)));
3009 let epoch1 = merged.states_at(1).expect("epoch 1");
3010 assert!(
3011 epoch1.contains_key(&gps(1)),
3012 "source 1 must fill source 0's dropout"
3013 );
3014 assert_eq!(epoch1[&gps(1)].position.as_array()[0], 15_001_000.0);
3015 assert!(report
3016 .single_source
3017 .iter()
3018 .any(|entry| entry.satellite == gps(1) && entry.sources == vec![1]));
3019 }
3020
3021 #[test]
3022 fn merge_filters_requested_constellations_and_header_satellites() {
3023 let a = sp3_two_epochs(
3024 &[
3025 ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
3026 ("E01", [21000.0, -1000.0, 13000.0], Some(120.0)),
3027 ],
3028 &[
3029 ("G01", [15001.0, -20001.0, 5001.0], Some(101.0)),
3030 ("E01", [21001.0, -1001.0, 13001.0], Some(121.0)),
3031 ],
3032 900.0,
3033 "IGS14",
3034 );
3035 let systems = BTreeSet::from([GnssSystem::Gps]);
3036 let opts = MergeOptions {
3037 systems: Some(systems),
3038 ..MergeOptions::default()
3039 };
3040
3041 let (merged, _report) = merge(&[a], &opts).expect("merge");
3042
3043 assert_eq!(merged.header.satellites, vec![gps(1)]);
3044 for idx in 0..merged.epochs.len() {
3045 let states = merged.states_at(idx).expect("epoch");
3046 assert_eq!(states.keys().copied().collect::<Vec<_>>(), vec![gps(1)]);
3047 }
3048 }
3049
3050 #[test]
3051 fn merge_preserves_a_clock_event_flag() {
3052 let a = sp3_build(
3055 &[(
3056 "G01",
3057 [15000.0, -20000.0, 5000.0],
3058 Some(100.0),
3059 " E",
3060 )],
3061 "IGS14",
3062 );
3063 let b = sp3_build(
3064 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
3065 "IGS14",
3066 );
3067
3068 let (merged, _) = merge(&[a, b], &MergeOptions::default()).expect("merge");
3069 let g01 = merged.states_at(0).expect("epoch 0")[&gps(1)];
3070
3071 assert!(
3072 g01.flags.clock_event,
3073 "merged cell must preserve a contributing source's clock-event flag"
3074 );
3075 }
3076
3077 #[test]
3078 fn merge_reports_effective_epoch_interval_from_actual_epochs() {
3079 let body = "#cP2020 6 25 0 0 0.00000000 2 ORBIT IGS14 FIT TST\n\
3083 ## 2111 432000.00000000 300.00000000 59025 0.0000000000000\n\
3084 + 1 G01 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
3085 ++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
3086 %c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3087 %c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3088 %f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n\
3089 %f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n\
3090 %i 0 0 0 0 0 0 0 0 0\n\
3091 %i 0 0 0 0 0 0 0 0 0\n\
3092 /* TEST SP3-c FIXTURE\n\
3093 * 2020 6 25 0 0 0.00000000\n\
3094 PG01 15000.000000 -20000.000000 5000.000000 100.000000\n\
3095 * 2020 6 25 0 15 0.00000000\n\
3096 PG01 15001.000000 -20001.000000 5001.000000 101.000000\n\
3097 EOF\n";
3098 let a = Sp3::parse(body.as_bytes()).expect("parse test sp3");
3099
3100 let (merged, _) = merge(&[a], &MergeOptions::default()).expect("merge");
3101
3102 assert!(
3103 (merged.header.epoch_interval_s - 900.0).abs() < 1.0e-6,
3104 "got {}",
3105 merged.header.epoch_interval_s
3106 );
3107 }
3108
3109 #[test]
3110 fn merge_rejects_unsorted_input_epochs_before_cadence_inference() {
3111 let body = "#cP2020 6 25 0 0 0.00000000 2 ORBIT IGS14 FIT TST\n\
3112 ## 2111 432000.00000000 900.00000000 59025 0.0000000000000\n\
3113 + 1 G01 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
3114 ++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
3115 %c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3116 %c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3117 %f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n\
3118 %f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n\
3119 %i 0 0 0 0 0 0 0 0 0\n\
3120 %i 0 0 0 0 0 0 0 0 0\n\
3121 /* TEST SP3-c FIXTURE\n\
3122 * 2020 6 25 0 15 0.00000000\n\
3123 PG01 15001.000000 -20001.000000 5001.000000 101.000000\n\
3124 * 2020 6 25 0 0 0.00000000\n\
3125 PG01 15000.000000 -20000.000000 5000.000000 100.000000\n\
3126 EOF\n";
3127 let source = Sp3::parse(body.as_bytes()).expect("parse unsorted test sp3");
3128
3129 let err = merge(&[source], &MergeOptions::default()).expect_err("unsorted epochs");
3130
3131 assert!(
3132 err.to_string()
3133 .contains("merge input epochs must be strictly increasing"),
3134 "{err}"
3135 );
3136 }
3137
3138 #[test]
3139 fn align_clock_reference_puts_other_on_the_reference_datum() {
3140 let reference = sp3([100.0, 200.0, 300.0]);
3143 let other = sp3([150.0, 250.0, 350.0]);
3144
3145 let aligned = align_clock_reference(&reference, &other, 3);
3146
3147 let g01 = aligned.states_at(0).expect("epoch 0")[&gps(1)];
3148 assert!(
3149 (g01.clock_s.unwrap() - 100.0e-6).abs() < 1.0e-15,
3150 "got {}",
3151 g01.clock_s.unwrap()
3152 );
3153 let original = other.states_at(0).expect("epoch 0")[&gps(1)];
3155 assert_eq!(g01.position.as_array(), original.position.as_array());
3156 }
3157
3158 fn sp3(clocks_us: [f64; 3]) -> Sp3 {
3162 let body = format!(
3163 "#cP2020 6 25 0 0 0.00000000 1 ORBIT IGS14 FIT TST\n\
3164 ## 2111 432000.00000000 900.00000000 59025 0.0000000000000\n\
3165 + 3 G01G02G03 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
3166 ++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
3167 %c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3168 %c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3169 %f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n\
3170 %f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n\
3171 %i 0 0 0 0 0 0 0 0 0\n\
3172 %i 0 0 0 0 0 0 0 0 0\n\
3173 /* TEST SP3-c FIXTURE\n\
3174 * 2020 6 25 0 0 0.00000000\n\
3175 PG01 15000.000000 -20000.000000 5000.000000 {:13.6}\n\
3176 PG02 -1234.567890 2345.678901 -3456.789012 {:13.6}\n\
3177 PG03 8000.000000 12000.000000 -19000.000000 {:13.6}\n\
3178 EOF\n",
3179 clocks_us[0], clocks_us[1], clocks_us[2]
3180 );
3181 Sp3::parse(body.as_bytes()).expect("parse test sp3")
3182 }
3183
3184 #[test]
3185 fn recovers_a_uniform_datum_shift() {
3186 let reference = sp3([100.0, 200.0, 300.0]);
3188 let other = sp3([150.0, 250.0, 350.0]);
3189
3190 let offsets = clock_reference_offset(&reference, &other, 3);
3191
3192 assert_eq!(offsets.len(), 1);
3193 assert_eq!(offsets[0].satellites, 3);
3194 assert!(
3195 (offsets[0].offset_s - 5.0e-5).abs() < 1.0e-12,
3196 "got {}",
3197 offsets[0].offset_s
3198 );
3199 }
3200
3201 #[test]
3202 fn median_rejects_a_single_outlier_clock() {
3203 let reference = sp3([100.0, 200.0, 300.0]);
3206 let other = sp3([150.0, 250.0, 9_300.0]);
3207
3208 let offsets = clock_reference_offset(&reference, &other, 3);
3209
3210 assert_eq!(offsets.len(), 1);
3211 assert!(
3212 (offsets[0].offset_s - 5.0e-5).abs() < 1.0e-12,
3213 "got {}",
3214 offsets[0].offset_s
3215 );
3216 }
3217
3218 #[test]
3219 fn omits_epochs_below_min_common() {
3220 let reference = sp3([100.0, 200.0, 300.0]);
3223 let other = sp3([150.0, 250.0, 350.0]);
3224
3225 assert!(clock_reference_offset(&reference, &other, 4).is_empty());
3226 }
3227
3228 #[test]
3229 fn merge_agreement_metric_reports_known_position_dispersion() {
3230 let a = sp3_records(&[("G01", [15000.000, -20000.0, 5000.0], Some(100.0))]);
3235 let b = sp3_records(&[("G01", [15000.003, -20000.0, 5000.0], Some(100.0))]);
3236 let c = sp3_records(&[("G01", [15000.006, -20000.0, 5000.0], Some(100.0))]);
3237 let opts = MergeOptions {
3238 position_tolerance_m: 10.0,
3239 min_agree: 3,
3240 combine: MergeCombine::Mean,
3241 ..MergeOptions::default()
3242 };
3243
3244 let (_merged, report) = merge(&[a, b, c], &opts).expect("merge");
3245
3246 assert_eq!(report.agreement.len(), 1, "one accepted cell");
3247 let m = report.agreement[0];
3248 assert_eq!(m.satellite, gps(1));
3249 assert_eq!(m.position_members, 3);
3250 assert!(
3251 (m.position_rms_m - 6.0_f64.sqrt()).abs() < 1.0e-6,
3252 "got rms {}",
3253 m.position_rms_m
3254 );
3255 assert!(
3256 (m.position_max_m - 3.0).abs() < 1.0e-6,
3257 "got max {}",
3258 m.position_max_m
3259 );
3260
3261 assert!((report.position_agreement_rms_m().unwrap() - 6.0_f64.sqrt()).abs() < 1.0e-6);
3263 assert!((report.position_agreement_max_m().unwrap() - 3.0).abs() < 1.0e-6);
3264
3265 let per_epoch = report.per_epoch_agreement();
3267 assert_eq!(per_epoch.len(), 1);
3268 assert_eq!(per_epoch[0].satellites, 1);
3269 assert!((per_epoch[0].position_rms_m - 6.0_f64.sqrt()).abs() < 1.0e-6);
3270 assert!((per_epoch[0].position_max_m - 3.0).abs() < 1.0e-6);
3271 }
3272
3273 #[test]
3274 fn merge_agreement_metric_reports_known_clock_dispersion() {
3275 let a = sp3([100.0, 200.0, 300.0]);
3281 let b = sp3([100.0, 200.0, 330.0]);
3282 let c = sp3([100.0, 200.0, 270.0]);
3283 let opts = MergeOptions {
3284 clock_min_common: 1,
3285 clock_tolerance_s: 1.0e-3,
3286 min_agree: 3,
3287 combine: MergeCombine::Mean,
3288 ..MergeOptions::default()
3289 };
3290
3291 let (_merged, report) = merge(&[a, b, c], &opts).expect("merge");
3292
3293 let g03 = report
3294 .agreement
3295 .iter()
3296 .find(|m| m.satellite == gps(3))
3297 .expect("G03 agreement metric");
3298 assert_eq!(g03.clock_members, 3);
3299 let expected_rms_s = 600.0_f64.sqrt() * 1.0e-6;
3300 assert!(
3301 (g03.clock_rms_s.unwrap() - expected_rms_s).abs() < 1.0e-15,
3302 "got clock rms {:?}",
3303 g03.clock_rms_s
3304 );
3305 assert!(
3306 (g03.clock_max_s.unwrap() - 30.0e-6).abs() < 1.0e-15,
3307 "got clock max {:?}",
3308 g03.clock_max_s
3309 );
3310 for prn in [1u8, 2] {
3312 let m = report
3313 .agreement
3314 .iter()
3315 .find(|m| m.satellite == gps(prn))
3316 .expect("metric");
3317 assert!(m.clock_rms_s.unwrap().abs() < 1.0e-18, "prn {prn}");
3318 assert!(m.position_rms_m.abs() < 1.0e-9, "prn {prn}");
3320 }
3321
3322 let pooled = report.clock_agreement_rms_s().expect("clock pool");
3326 assert!(
3327 (pooled - expected_rms_s / 3.0_f64.sqrt()).abs() < 1.0e-15,
3328 "got pooled {pooled}"
3329 );
3330 assert!((report.clock_agreement_max_s().unwrap() - 30.0e-6).abs() < 1.0e-15);
3331 }
3332
3333 #[cfg(sidereon_repo_tests)]
3361 #[test]
3362 fn merge_agrees_with_published_igs_combined_within_cm() {
3363 fn load(name: &str) -> Sp3 {
3364 let path = format!("{}/tests/fixtures/sp3/{}", env!("CARGO_MANIFEST_DIR"), name);
3365 let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("read {path}: {e}"));
3366 Sp3::parse(&bytes).unwrap_or_else(|e| panic!("parse {name}: {e}"))
3367 }
3368
3369 let cod = load("COD0OPSFIN_20261200945_02H30M_15M_ORB_trim.SP3");
3370 let gfz = load("GFZ0OPSFIN_20261200945_02H30M_15M_ORB_trim.SP3");
3371 let jpl = load("JPL0OPSFIN_20261200945_02H30M_15M_ORB_trim.SP3");
3372 let igs = load("IGS0OPSFIN_20261200945_02H30M_15M_ORB.SP3");
3373
3374 let (merged, report) =
3375 merge(&[cod, gfz, jpl], &MergeOptions::default()).expect("multi-center merge");
3376
3377 assert!(
3380 report.quarantined.is_empty(),
3381 "centers should agree: {:?}",
3382 report.quarantined
3383 );
3384 assert!(
3387 report.single_source.is_empty(),
3388 "{:?}",
3389 report.single_source
3390 );
3391 assert!(
3392 report.position_outliers.is_empty(),
3393 "{:?}",
3394 report.position_outliers
3395 );
3396 assert!(
3397 report.agreement.iter().all(|a| a.position_members == 3),
3398 "every agreement cell should be a 3-source consensus"
3399 );
3400
3401 let mut igs_idx: std::collections::BTreeMap<i64, usize> = std::collections::BTreeMap::new();
3402 for (i, ep) in igs.epochs.iter().enumerate() {
3403 if let Some(s) = super::instant_to_j2000_seconds(ep) {
3404 igs_idx.insert(s.floor() as i64, i);
3405 }
3406 }
3407
3408 let mut sumsq = 0.0_f64;
3409 let mut max = 0.0_f64;
3410 let mut n = 0usize;
3411 for (mi, ep) in merged.epochs.iter().enumerate() {
3412 let key = super::instant_to_j2000_seconds(ep)
3413 .expect("merged epoch key")
3414 .floor() as i64;
3415 let ii = *igs_idx.get(&key).expect("IGS combined covers merged epoch");
3416 let merged_states = merged.states_at(mi).expect("merged states");
3417 let igs_states = igs.states_at(ii).expect("IGS states");
3418 for (sat, mst) in merged_states.iter() {
3419 let ist = igs_states
3420 .get(sat)
3421 .unwrap_or_else(|| panic!("merged sat {sat} missing from IGS combined"));
3422 let d = super::dist3(&mst.position.as_array(), &ist.position.as_array());
3423 sumsq += d * d;
3424 max = max.max(d);
3425 n += 1;
3426 }
3427 }
3428
3429 assert_eq!(n, 88, "expected exactly 88 compared cells, got {n}");
3432 let rms = (sumsq / n as f64).sqrt();
3433 assert!(
3435 rms < 0.02,
3436 "combine-vs-IGS RMS {:.4} m ({} cells) exceeds the 2 cm gate",
3437 rms,
3438 n
3439 );
3440 assert!(
3441 max < 0.05,
3442 "combine-vs-IGS max {max:.4} m exceeds the 5 cm gate"
3443 );
3444
3445 let dispersion = report
3447 .position_agreement_rms_m()
3448 .expect("multi-source cells present");
3449 assert!(
3450 dispersion < 0.05,
3451 "inter-center position dispersion {dispersion:.4} m"
3452 );
3453 }
3454}