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