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