1use std::collections::{BTreeMap, BTreeSet};
17
18use crate::astro::time::model::Instant;
19
20use super::interp::instant_to_j2000_seconds;
21use super::{RawNode, Sp3, Sp3DataType, Sp3Flags, Sp3Header, Sp3State};
22use crate::constants::{GPS_EPOCH_TO_J2000_S, KM_TO_M, SECONDS_PER_WEEK};
23use crate::frame::ItrfPositionM;
24use crate::id::{GnssSatelliteId, GnssSystem};
25use crate::tolerances::WHOLE_SECOND_EPS_S;
26use crate::validate;
27use crate::{Error, Result};
28
29const MAX_EXACT_CLIQUE_NODES: usize = 32;
30
31#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct ClockReferenceOffset {
34 pub epoch: Instant,
36 pub offset_s: f64,
40 pub satellites: usize,
42}
43
44pub fn clock_reference_offset(
62 reference: &Sp3,
63 other: &Sp3,
64 min_common: usize,
65) -> Vec<ClockReferenceOffset> {
66 let mut other_index: std::collections::HashMap<i64, usize> = std::collections::HashMap::new();
67 for (idx, epoch) in other.epochs.iter().enumerate() {
68 if let Some(seconds) = instant_to_j2000_seconds(epoch) {
69 other_index.insert(seconds.floor() as i64, idx);
70 }
71 }
72
73 let mut offsets = Vec::new();
74
75 for (ref_idx, epoch) in reference.epochs.iter().enumerate() {
76 let Some(ref_seconds) = instant_to_j2000_seconds(epoch) else {
77 continue;
78 };
79 let Some(&other_idx) = other_index.get(&(ref_seconds.floor() as i64)) else {
80 continue;
81 };
82
83 let (Ok(ref_states), Ok(other_states)) =
84 (reference.states_at(ref_idx), other.states_at(other_idx))
85 else {
86 continue;
87 };
88
89 let mut diffs: Vec<f64> = Vec::new();
90 for (sat, ref_state) in ref_states.iter() {
91 let Some(ref_clock) = ref_state.clock_s else {
92 continue;
93 };
94 if let Some(other_state) = other_states.get(sat) {
95 if let Some(other_clock) = other_state.clock_s {
96 let diff = other_clock - ref_clock;
97 if diff.is_finite() {
100 diffs.push(diff);
101 }
102 }
103 }
104 }
105
106 if diffs.len() >= min_common.max(1) {
107 if let Some(offset_s) = median(&mut diffs) {
108 offsets.push(ClockReferenceOffset {
109 epoch: *epoch,
110 offset_s,
111 satellites: diffs.len(),
112 });
113 }
114 }
115 }
116
117 offsets
118}
119
120fn median(values: &mut [f64]) -> Option<f64> {
121 if values.is_empty() {
122 return None;
123 }
124
125 values.sort_by(f64::total_cmp);
127
128 let n = values.len();
129 if n % 2 == 1 {
130 Some(values[n / 2])
131 } else {
132 Some((values[n / 2 - 1] + values[n / 2]) / 2.0)
133 }
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum MergeCombine {
144 Mean,
147 Median,
149 Precedence,
151}
152
153#[derive(Debug, Clone, PartialEq)]
155pub struct MergeOptions {
156 pub position_tolerance_m: f64,
159 pub clock_tolerance_s: f64,
162 pub min_agree: usize,
168 pub clock_min_common: usize,
171 pub combine: MergeCombine,
173 pub target_epoch_interval_s: Option<f64>,
178 pub systems: Option<BTreeSet<GnssSystem>>,
181}
182
183impl Default for MergeOptions {
184 fn default() -> Self {
187 Self {
188 position_tolerance_m: 0.5,
189 clock_tolerance_s: 5.0e-9,
190 min_agree: 2,
191 clock_min_common: 5,
192 combine: MergeCombine::Mean,
193 target_epoch_interval_s: None,
194 systems: None,
195 }
196 }
197}
198
199#[derive(Debug, Clone, PartialEq)]
202pub struct MergeFlag {
203 pub epoch: Instant,
205 pub satellite: GnssSatelliteId,
207 pub sources: Vec<usize>,
212}
213
214#[derive(Debug, Clone, Default, PartialEq)]
216pub struct MergeReport {
217 pub quarantined: Vec<MergeFlag>,
220 pub single_source: Vec<MergeFlag>,
222 pub position_outliers: Vec<MergeFlag>,
225}
226
227pub fn merge(sources: &[Sp3], opts: &MergeOptions) -> Result<(Sp3, MergeReport)> {
272 if sources.is_empty() {
273 return Err(Error::InvalidInput(
274 "merge requires at least one SP3 product".into(),
275 ));
276 }
277
278 let base = &sources[0].header;
284 for s in &sources[1..] {
285 if s.header.time_system != base.time_system {
286 return Err(Error::InvalidInput(format!(
287 "merge inputs have mismatched SP3 time systems ({:?} vs {:?})",
288 base.time_system, s.header.time_system
289 )));
290 }
291 if s.header.coordinate_system.trim() != base.coordinate_system.trim() {
292 return Err(Error::InvalidInput(format!(
293 "merge inputs have mismatched coordinate systems ({:?} vs {:?})",
294 base.coordinate_system, s.header.coordinate_system
295 )));
296 }
297 }
298
299 let epoch_index: Vec<BTreeMap<i64, usize>> = sources
301 .iter()
302 .map(|s| {
303 s.epochs
304 .iter()
305 .enumerate()
306 .filter_map(|(i, ep)| {
307 instant_to_j2000_seconds(ep).map(|sec| (sec.floor() as i64, i))
308 })
309 .collect()
310 })
311 .collect();
312
313 let epoch_interval_s = resolve_common_epoch_interval(sources, opts.target_epoch_interval_s)?;
314
315 let clock_offset: Vec<BTreeMap<i64, f64>> = sources
318 .iter()
319 .enumerate()
320 .map(|(idx, s)| {
321 if idx == 0 {
322 BTreeMap::new()
323 } else {
324 clock_reference_offset(&sources[0], s, opts.clock_min_common)
325 .into_iter()
326 .filter_map(|o| {
327 instant_to_j2000_seconds(&o.epoch)
328 .map(|sec| (sec.floor() as i64, o.offset_s))
329 })
330 .collect()
331 }
332 })
333 .collect();
334
335 let mut epoch_keys: BTreeMap<i64, Instant> = sources[0]
340 .epochs
341 .iter()
342 .filter_map(|ep| instant_to_j2000_seconds(ep).map(|sec| (sec.floor() as i64, *ep)))
343 .collect();
344
345 for index in epoch_index.iter().skip(1) {
346 epoch_keys.retain(|key, _| index.contains_key(key));
347 }
348
349 if let Some((&anchor, _)) = epoch_keys.iter().next() {
355 let step = epoch_interval_s.round() as i64;
356 if step > 0 {
357 epoch_keys.retain(|&key, _| (key - anchor).rem_euclid(step) == 0);
358 }
359 }
360
361 if epoch_keys.is_empty() {
362 return Err(Error::InvalidInput(
363 "merge inputs have no common epochs on a shared time grid".into(),
364 ));
365 }
366
367 let precedence_source_for_sat = if opts.combine == MergeCombine::Precedence {
368 Some(precedence_sources_for_satellites(
369 sources,
370 &epoch_index,
371 &epoch_keys,
372 opts.systems.as_ref(),
373 ))
374 } else {
375 None
376 };
377
378 let allowed_system = |sat: &GnssSatelliteId| {
379 opts.systems
380 .as_ref()
381 .map(|systems| systems.contains(&sat.system))
382 .unwrap_or(true)
383 };
384
385 if let Some(systems) = &opts.systems {
386 if systems.is_empty() {
387 return Err(Error::InvalidInput(
388 "merge systems filter must not be empty".into(),
389 ));
390 }
391 }
392
393 let mut out_epochs: Vec<Instant> = Vec::with_capacity(epoch_keys.len());
394 let mut out_states: Vec<BTreeMap<GnssSatelliteId, Sp3State>> =
395 Vec::with_capacity(epoch_keys.len());
396 let mut out_raw: Vec<BTreeMap<GnssSatelliteId, RawNode>> = Vec::with_capacity(epoch_keys.len());
397 let mut report = MergeReport::default();
398 let mut all_sats: BTreeSet<GnssSatelliteId> = BTreeSet::new();
399
400 for (&key, &epoch) in &epoch_keys {
401 out_epochs.push(epoch);
402 let mut states: BTreeMap<GnssSatelliteId, Sp3State> = BTreeMap::new();
403 let mut raws: BTreeMap<GnssSatelliteId, RawNode> = BTreeMap::new();
404
405 let mut sats: BTreeSet<GnssSatelliteId> = BTreeSet::new();
408 for (idx, s) in sources.iter().enumerate() {
409 if let Some(&ei) = epoch_index[idx].get(&key) {
410 if let Ok(map) = s.states_at(ei) {
411 sats.extend(map.keys().copied().filter(|sat| allowed_system(sat)));
412 }
413 }
414 }
415
416 for sat in sats {
417 let preferred_source = precedence_source_for_sat
423 .as_ref()
424 .and_then(|by_sat| by_sat.get(&sat).copied());
425
426 let mut pos: Vec<(usize, [f64; 3], Sp3Flags)> = Vec::new();
427 let mut clk: Vec<(usize, f64, Sp3Flags)> = Vec::new();
428 for (idx, s) in sources.iter().enumerate() {
429 let Some(&ei) = epoch_index[idx].get(&key) else {
430 continue;
431 };
432 let Ok(map) = s.states_at(ei) else { continue };
433 let Some(state) = map.get(&sat) else { continue };
434 pos.push((idx, state.position.as_array(), state.flags));
435 if let Some(c) = state.clock_s {
436 let offset = if idx == 0 {
437 Some(0.0)
438 } else {
439 clock_offset[idx].get(&key).copied()
440 };
441 if let Some(off) = offset {
442 let aligned = c - off;
443 if aligned.is_finite() {
444 clk.push((idx, aligned, state.flags));
445 }
446 }
447 }
448 }
449
450 let flag = |srcs: Vec<usize>| MergeFlag {
451 epoch,
452 satellite: sat,
453 sources: srcs,
454 };
455
456 let (position_m, pos_members) = if opts.combine == MergeCombine::Precedence {
462 let Some(preferred_source) = preferred_source else {
463 continue;
464 };
465 let Some(preferred_idx) =
466 pos.iter().position(|(src, _, _)| *src == preferred_source)
467 else {
468 continue;
469 };
470
471 if pos.len() == 1 {
472 report.single_source.push(flag(vec![pos[preferred_idx].0]));
473 (pos[preferred_idx].1, vec![preferred_idx])
474 } else {
475 let pts: Vec<[f64; 3]> = pos.iter().map(|(_, p, _)| *p).collect();
476 let cluster = largest_within_containing(&pts, preferred_idx, |a, b| {
477 dist3(a, b) <= opts.position_tolerance_m
478 });
479 if cluster.len() >= opts.min_agree {
480 let rejected: Vec<usize> = (0..pos.len())
481 .filter(|i| !cluster.contains(i))
482 .map(|i| pos[i].0)
483 .collect();
484 if !rejected.is_empty() {
485 report.position_outliers.push(flag(rejected));
486 }
487 (pos[preferred_idx].1, cluster)
488 } else {
489 report
490 .quarantined
491 .push(flag(pos.iter().map(|(i, _, _)| *i).collect()));
492 continue;
493 }
494 }
495 } else if pos.len() == 1 {
496 report.single_source.push(flag(vec![pos[0].0]));
497 (pos[0].1, vec![0usize])
498 } else {
499 let pts: Vec<[f64; 3]> = pos.iter().map(|(_, p, _)| *p).collect();
500 let cluster = largest_within(&pts, |a, b| dist3(a, b) <= opts.position_tolerance_m);
501 if cluster.len() >= opts.min_agree {
502 let rejected: Vec<usize> = (0..pos.len())
503 .filter(|i| !cluster.contains(i))
504 .map(|i| pos[i].0)
505 .collect();
506 if !rejected.is_empty() {
507 report.position_outliers.push(flag(rejected));
508 }
509 let members: Vec<(usize, [f64; 3])> =
510 cluster.iter().map(|&i| (pos[i].0, pos[i].1)).collect();
511 (combine3(&members, opts.combine), cluster)
512 } else {
513 report
514 .quarantined
515 .push(flag(pos.iter().map(|(i, _, _)| *i).collect()));
516 continue;
517 }
518 };
519
520 let (clock_s, clk_members): (Option<f64>, Vec<usize>) = if clk.is_empty() {
523 (None, Vec::new())
524 } else if opts.combine == MergeCombine::Precedence {
525 match preferred_source
526 .and_then(|src| clk.iter().position(|(clock_src, _, _)| *clock_src == src))
527 {
528 None => (None, Vec::new()),
529 Some(preferred_idx) if clk.len() == 1 => {
530 (Some(clk[preferred_idx].1), vec![preferred_idx])
531 }
532 Some(preferred_idx) => {
533 let vals: Vec<f64> = clk.iter().map(|(_, c, _)| *c).collect();
534 let cluster = largest_within_containing(&vals, preferred_idx, |a, b| {
535 (a - b).abs() <= opts.clock_tolerance_s
536 });
537 if cluster.len() >= opts.min_agree {
538 (Some(clk[preferred_idx].1), cluster)
539 } else {
540 (None, Vec::new())
541 }
542 }
543 }
544 } else if clk.len() == 1 {
545 (Some(clk[0].1), vec![0usize])
546 } else {
547 let vals: Vec<f64> = clk.iter().map(|(_, c, _)| *c).collect();
548 let cluster = largest_within(&vals, |a, b| (a - b).abs() <= opts.clock_tolerance_s);
549 if cluster.len() >= opts.min_agree {
550 let members: Vec<(usize, f64)> =
551 cluster.iter().map(|&i| (clk[i].0, clk[i].1)).collect();
552 (Some(combine_axis(&members, opts.combine)), cluster)
553 } else {
554 (None, Vec::new())
555 }
556 };
557
558 let mut flags = Sp3Flags::default();
563 for &i in &pos_members {
564 flags.maneuver |= pos[i].2.maneuver;
565 flags.orbit_predicted |= pos[i].2.orbit_predicted;
566 }
567 for &i in &clk_members {
568 flags.clock_event |= clk[i].2.clock_event;
569 flags.clock_predicted |= clk[i].2.clock_predicted;
570 }
571
572 all_sats.insert(sat);
573 states.insert(
574 sat,
575 Sp3State {
576 position: ItrfPositionM::new(position_m[0], position_m[1], position_m[2])
577 .expect("valid ITRF position"),
578 clock_s,
579 velocity: None,
580 clock_rate_s_s: None,
581 flags,
582 },
583 );
584 raws.insert(
585 sat,
586 RawNode {
587 km: [
588 position_m[0] / KM_TO_M,
589 position_m[1] / KM_TO_M,
590 position_m[2] / KM_TO_M,
591 ],
592 clock_us: clock_s.map(|c| c * 1.0e6),
593 clock_event: flags.clock_event,
594 },
595 );
596 }
597
598 out_states.push(states);
599 out_raw.push(raws);
600 }
601
602 let first_key = instant_to_j2000_seconds(&out_epochs[0]).map(|s| s.floor() as i64);
607 let base_idx = sources
608 .iter()
609 .position(|s| {
610 s.epochs
611 .first()
612 .and_then(instant_to_j2000_seconds)
613 .map(|s| s.floor() as i64)
614 == first_key
615 })
616 .or_else(|| {
617 sources
618 .iter()
619 .enumerate()
620 .filter_map(|(i, s)| {
621 s.epochs
622 .first()
623 .and_then(instant_to_j2000_seconds)
624 .map(|sec| (sec, i))
625 })
626 .min_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)))
627 .map(|(_, i)| i)
628 })
629 .unwrap_or(0);
630 let first_epoch_header = first_epoch_header_fields(&out_epochs[0]).ok_or_else(|| {
631 Error::InvalidInput("merged SP3 first epoch cannot be represented in header fields".into())
632 })?;
633
634 let satellites: Vec<_> = all_sats.into_iter().collect();
635 let satellite_accuracy_codes = satellites
636 .iter()
637 .map(|sat| {
638 sources[base_idx]
639 .header
640 .satellites
641 .iter()
642 .position(|base_sat| base_sat == sat)
643 .and_then(|idx| {
644 sources[base_idx]
645 .header
646 .satellite_accuracy_codes
647 .get(idx)
648 .copied()
649 })
650 .unwrap_or(0)
651 })
652 .collect();
653
654 let header = Sp3Header {
655 num_epochs: out_epochs.len() as u64,
656 satellites,
657 satellite_accuracy_codes,
658 data_type: Sp3DataType::Position,
659 gnss_week: first_epoch_header.gnss_week,
660 seconds_of_week: first_epoch_header.seconds_of_week,
661 epoch_interval_s,
662 mjd: first_epoch_header.mjd,
663 mjd_fraction: first_epoch_header.mjd_fraction,
664 ..sources[base_idx].header.clone()
665 };
666
667 let merged = Sp3 {
668 header,
669 epochs: out_epochs,
670 states: out_states,
671 interp_raw: out_raw,
672 comments: vec![format!("MERGED from {} SP3 products", sources.len())],
673 };
674
675 Ok((merged, report))
676}
677
678#[derive(Debug, Clone, Copy)]
679struct FirstEpochHeaderFields {
680 gnss_week: u32,
681 seconds_of_week: f64,
682 mjd: u32,
683 mjd_fraction: f64,
684}
685
686fn first_epoch_header_fields(epoch: &Instant) -> Option<FirstEpochHeaderFields> {
687 let split = epoch.julian_date()?;
688
689 let mjd_day = split.jd_whole - 2_400_000.5;
690 let mut mjd = mjd_day.floor();
691 let mut mjd_fraction = split.fraction + (mjd_day - mjd);
692 let fraction_days = mjd_fraction.floor();
693 if fraction_days != 0.0 {
694 mjd += fraction_days;
695 mjd_fraction -= fraction_days;
696 }
697 if !(0.0..=u32::MAX as f64).contains(&mjd) {
698 return None;
699 }
700
701 let gps_seconds = instant_to_j2000_seconds(epoch)? + GPS_EPOCH_TO_J2000_S;
702 let gnss_week = (gps_seconds / SECONDS_PER_WEEK).floor();
703 if !(0.0..=u32::MAX as f64).contains(&gnss_week) {
704 return None;
705 }
706
707 Some(FirstEpochHeaderFields {
708 gnss_week: gnss_week as u32,
709 seconds_of_week: gps_seconds - gnss_week * SECONDS_PER_WEEK,
710 mjd: mjd as u32,
711 mjd_fraction,
712 })
713}
714
715fn dist3(a: &[f64; 3], b: &[f64; 3]) -> f64 {
716 let dx = a[0] - b[0];
717 let dy = a[1] - b[1];
718 let dz = a[2] - b[2];
719 (dx * dx + dy * dy + dz * dz).sqrt()
720}
721
722fn precedence_sources_for_satellites(
723 sources: &[Sp3],
724 epoch_index: &[BTreeMap<i64, usize>],
725 epoch_keys: &BTreeMap<i64, Instant>,
726 systems: Option<&BTreeSet<GnssSystem>>,
727) -> BTreeMap<GnssSatelliteId, usize> {
728 let mut by_sat = BTreeMap::new();
729
730 for (idx, source) in sources.iter().enumerate() {
731 for key in epoch_keys.keys() {
732 let Some(&epoch_idx) = epoch_index[idx].get(key) else {
733 continue;
734 };
735 let Ok(states) = source.states_at(epoch_idx) else {
736 continue;
737 };
738
739 for sat in states.keys() {
740 if systems
741 .map(|allowed| allowed.contains(&sat.system))
742 .unwrap_or(true)
743 {
744 by_sat.entry(*sat).or_insert(idx);
745 }
746 }
747 }
748 }
749
750 by_sat
751}
752
753fn resolve_common_epoch_interval(sources: &[Sp3], target: Option<f64>) -> Result<f64> {
768 let intervals: Vec<f64> = sources
769 .iter()
770 .enumerate()
771 .map(|(idx, source)| {
772 effective_epoch_interval_s(source)?.ok_or_else(|| {
773 Error::InvalidInput(format!(
774 "merge input {idx} has no usable positive epoch interval"
775 ))
776 })
777 })
778 .collect::<Result<Vec<_>>>()?;
779
780 let common = match target {
781 Some(t) if t.is_finite() && t > 0.0 => t,
782 Some(t) => {
783 return Err(Error::InvalidInput(format!(
784 "merge target epoch interval must be positive and finite, got {t}"
785 )))
786 }
787 None => intervals.iter().copied().fold(0.0_f64, f64::max),
788 };
789
790 if (common - common.round()).abs() > WHOLE_SECOND_EPS_S || common.round() < 1.0 {
795 return Err(Error::InvalidInput(format!(
796 "merge common epoch interval {common:.6} s must be a positive whole number of seconds"
797 )));
798 }
799
800 for (idx, interval) in intervals.iter().copied().enumerate() {
801 if !divides_evenly(interval, common) {
802 return Err(Error::InvalidInput(format!(
803 "merge inputs have mismatched epoch intervals: common {common:.6} s is not an integer multiple of input {idx} {interval:.6} s (no exact-subset decimation; positional interpolation is not performed)"
804 )));
805 }
806 }
807
808 Ok(common)
809}
810
811fn divides_evenly(interval: f64, common: f64) -> bool {
814 if !(interval.is_finite() && interval > 0.0 && common.is_finite() && common > 0.0) {
815 return false;
816 }
817 let k = (common / interval).round();
818 k >= 1.0 && same_interval(k * interval, common)
819}
820
821fn effective_epoch_interval_s(source: &Sp3) -> Result<Option<f64>> {
822 let secs: Vec<f64> = source
823 .epochs
824 .iter()
825 .filter_map(instant_to_j2000_seconds)
826 .collect();
827 validate::require_strictly_increasing(secs.iter().copied(), "merge input epochs").map_err(
828 |error| Error::InvalidInput(format!("{} must be strictly increasing", error.field())),
829 )?;
830 let gaps: Vec<f64> = secs.windows(2).map(|w| w[1] - w[0]).collect();
831
832 if gaps.is_empty() {
833 let header = source.header.epoch_interval_s;
834 return Ok((header.is_finite() && header > 0.0).then_some(header));
835 }
836
837 let interval = gaps[0];
838 if gaps.iter().all(|g| same_interval(*g, interval)) {
839 Ok(Some(interval))
840 } else {
841 Ok(None)
842 }
843}
844
845fn same_interval(a: f64, b: f64) -> bool {
846 (a - b).abs() <= WHOLE_SECOND_EPS_S
847}
848
849fn largest_within<T>(items: &[T], within: impl Fn(&T, &T) -> bool) -> Vec<usize> {
854 let n = items.len();
855 if n <= 1 {
856 return (0..n).collect();
857 }
858 let graph = agreement_graph(items, within);
859 if n > MAX_EXACT_CLIQUE_NODES {
860 return greedy_largest_clique(&graph);
861 }
862 let mut best = vec![0];
863 let mut current = Vec::new();
864 max_clique_search(&graph, &mut current, (0..n).collect(), &mut best);
865 best
866}
867
868fn largest_within_containing<T>(
869 items: &[T],
870 required: usize,
871 within: impl Fn(&T, &T) -> bool,
872) -> Vec<usize> {
873 let n = items.len();
874 if n == 0 || required >= n {
875 return Vec::new();
876 }
877 if n == 1 {
878 return vec![required];
879 }
880
881 let graph = agreement_graph(items, within);
882 if n > MAX_EXACT_CLIQUE_NODES {
883 return greedy_largest_clique_containing(&graph, required);
884 }
885 let candidates = (0..n)
886 .filter(|&idx| idx != required && graph[required][idx])
887 .collect();
888 let mut best = vec![required];
889 let mut current = vec![required];
890 max_clique_search(&graph, &mut current, candidates, &mut best);
891 best
892}
893
894fn agreement_graph<T>(items: &[T], within: impl Fn(&T, &T) -> bool) -> Vec<Vec<bool>> {
895 let n = items.len();
896 let mut graph = vec![vec![false; n]; n];
897 for i in 0..n {
898 graph[i][i] = true;
899 for j in i + 1..n {
900 let agrees = within(&items[i], &items[j]);
901 graph[i][j] = agrees;
902 graph[j][i] = agrees;
903 }
904 }
905 graph
906}
907
908fn greedy_largest_clique(graph: &[Vec<bool>]) -> Vec<usize> {
909 let mut best = Vec::new();
910 for seed in 0..graph.len() {
911 let candidate = greedy_clique_from_seed(graph, seed);
912 update_best_clique(&candidate, &mut best);
913 }
914 best
915}
916
917fn greedy_largest_clique_containing(graph: &[Vec<bool>], required: usize) -> Vec<usize> {
918 if required >= graph.len() {
919 return Vec::new();
920 }
921 greedy_clique_from_seed(graph, required)
922}
923
924fn greedy_clique_from_seed(graph: &[Vec<bool>], seed: usize) -> Vec<usize> {
925 let mut clique = vec![seed];
926 for (idx, _) in graph.iter().enumerate() {
927 if idx == seed {
928 continue;
929 }
930 if clique.iter().all(|&member| graph[member][idx]) {
931 clique.push(idx);
932 }
933 }
934 clique.sort_unstable();
935 clique
936}
937
938fn max_clique_search(
939 graph: &[Vec<bool>],
940 current: &mut Vec<usize>,
941 mut candidates: Vec<usize>,
942 best: &mut Vec<usize>,
943) {
944 candidates.sort_unstable();
945 for (pos, &candidate) in candidates.iter().enumerate() {
946 let remaining = candidates.len() - pos;
947 if current.len() + remaining < best.len() {
948 break;
949 }
950
951 let next_candidates = candidates[pos + 1..]
952 .iter()
953 .copied()
954 .filter(|&idx| graph[candidate][idx])
955 .collect();
956
957 current.push(candidate);
958 update_best_clique(current, best);
959 max_clique_search(graph, current, next_candidates, best);
960 current.pop();
961 }
962}
963
964fn update_best_clique(current: &[usize], best: &mut Vec<usize>) {
965 let mut candidate = current.to_vec();
966 candidate.sort_unstable();
967 if candidate.len() > best.len()
968 || (candidate.len() == best.len() && candidate.as_slice() < best.as_slice())
969 {
970 *best = candidate;
971 }
972}
973
974fn combine3(members: &[(usize, [f64; 3])], how: MergeCombine) -> [f64; 3] {
975 [0usize, 1, 2].map(|axis| {
976 let axis_members: Vec<(usize, f64)> = members.iter().map(|(s, v)| (*s, v[axis])).collect();
977 combine_axis(&axis_members, how)
978 })
979}
980
981fn combine_axis(members: &[(usize, f64)], how: MergeCombine) -> f64 {
982 match how {
983 MergeCombine::Mean => members.iter().map(|(_, v)| *v).sum::<f64>() / members.len() as f64,
984 MergeCombine::Median => {
985 let mut vals: Vec<f64> = members.iter().map(|(_, v)| *v).collect();
986 median(&mut vals).expect("consensus cluster is non-empty")
987 }
988 MergeCombine::Precedence => members
989 .iter()
990 .min_by_key(|(s, _)| *s)
991 .map(|(_, v)| *v)
992 .expect("consensus cluster is non-empty"),
993 }
994}
995
996pub fn align_clock_reference(reference: &Sp3, other: &Sp3, min_common: usize) -> Sp3 {
1010 let offsets: BTreeMap<i64, f64> = clock_reference_offset(reference, other, min_common)
1011 .into_iter()
1012 .filter_map(|o| {
1013 instant_to_j2000_seconds(&o.epoch).map(|sec| (sec.floor() as i64, o.offset_s))
1014 })
1015 .collect();
1016
1017 let mut aligned = other.clone();
1018 for ei in 0..aligned.epochs.len() {
1019 let Some(sec) = instant_to_j2000_seconds(&aligned.epochs[ei]) else {
1020 continue;
1021 };
1022 let Some(&off) = offsets.get(&(sec.floor() as i64)) else {
1023 continue;
1024 };
1025 for state in aligned.states[ei].values_mut() {
1026 if let Some(c) = state.clock_s.as_mut() {
1027 *c -= off;
1028 }
1029 }
1030 for node in aligned.interp_raw[ei].values_mut() {
1031 if let Some(us) = node.clock_us.as_mut() {
1032 *us -= off * 1.0e6;
1033 }
1034 }
1035 }
1036 aligned
1037}
1038
1039#[cfg(test)]
1040mod tests {
1041 use super::super::Sp3;
1042 use super::{align_clock_reference, clock_reference_offset, merge, MergeCombine, MergeOptions};
1043 use crate::constants::SECONDS_PER_DAY;
1044 use crate::id::{GnssSatelliteId, GnssSystem};
1045 use std::collections::BTreeSet;
1046
1047 type SatSample<'a> = (&'a str, [f64; 3], Option<f64>);
1050
1051 fn gps(prn: u8) -> GnssSatelliteId {
1052 GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
1053 }
1054
1055 fn sp3_build(records: &[(&str, [f64; 3], Option<f64>, &str)], cs: &str) -> Sp3 {
1061 let n = records.len();
1062 let mut sats = String::new();
1063 for (sat, _, _, _) in records {
1064 sats.push_str(sat);
1065 }
1066 for _ in n..17 {
1067 sats.push_str(" 0");
1068 }
1069 let mut body = String::new();
1070 body.push_str(&format!(
1071 "#cP2020 6 25 0 0 0.00000000 1 ORBIT {cs} FIT TST\n"
1072 ));
1073 body.push_str("## 2111 432000.00000000 900.00000000 59025 0.0000000000000\n");
1074 body.push_str(&format!("+ {n:2} {sats}\n"));
1075 body.push_str("++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n");
1076 body.push_str("%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1077 body.push_str("%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1078 body.push_str("%f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n");
1079 body.push_str("%f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n");
1080 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1081 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1082 body.push_str("/* TEST SP3-c FIXTURE\n");
1083 body.push_str("* 2020 6 25 0 0 0.00000000\n");
1084 for (sat, p, clk, flags) in records {
1085 let c = clk.unwrap_or(999_999.999_999);
1086 body.push_str(&format!(
1087 "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}{flags}\n",
1088 p[0], p[1], p[2]
1089 ));
1090 }
1091 body.push_str("EOF\n");
1092 Sp3::parse(body.as_bytes()).expect("parse test sp3")
1093 }
1094
1095 fn sp3_records(records: &[(&str, [f64; 3], Option<f64>)]) -> Sp3 {
1097 let full: Vec<(&str, [f64; 3], Option<f64>, &str)> =
1098 records.iter().map(|(s, p, c)| (*s, *p, *c, "")).collect();
1099 sp3_build(&full, "IGS14")
1100 }
1101
1102 fn sp3_two_epochs(
1103 epoch0: &[(&str, [f64; 3], Option<f64>)],
1104 epoch1: &[(&str, [f64; 3], Option<f64>)],
1105 interval_s: f64,
1106 cs: &str,
1107 ) -> Sp3 {
1108 let mut sats: Vec<&str> = epoch0
1109 .iter()
1110 .chain(epoch1.iter())
1111 .map(|(sat, _, _)| *sat)
1112 .collect();
1113 sats.sort_unstable();
1114 sats.dedup();
1115 let n = sats.len();
1116 let mut sat_field = String::new();
1117 for sat in &sats {
1118 sat_field.push_str(sat);
1119 }
1120 for _ in n..17 {
1121 sat_field.push_str(" 0");
1122 }
1123
1124 let mut body = String::new();
1125 body.push_str(&format!(
1126 "#cP2020 6 25 0 0 0.00000000 2 ORBIT {cs} FIT TST\n"
1127 ));
1128 body.push_str(&format!(
1129 "## 2111 432000.00000000 {interval_s:14.8} 59025 0.0000000000000\n"
1130 ));
1131 body.push_str(&format!("+ {n:2} {sat_field}\n"));
1132 body.push_str("++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n");
1133 body.push_str("%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1134 body.push_str("%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1135 body.push_str("%f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n");
1136 body.push_str("%f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n");
1137 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1138 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1139 body.push_str("/* TEST SP3-c FIXTURE\n");
1140 body.push_str("* 2020 6 25 0 0 0.00000000\n");
1141 for (sat, p, clk) in epoch0 {
1142 let c = clk.unwrap_or(999_999.999_999);
1143 body.push_str(&format!(
1144 "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}\n",
1145 p[0], p[1], p[2]
1146 ));
1147 }
1148 let second_hour = (interval_s as i64) / 3600;
1149 let second_minute = ((interval_s as i64) % 3600) / 60;
1150 let second_second = (interval_s as i64) % 60;
1151 body.push_str(&format!(
1152 "* 2020 6 25 {second_hour:2} {second_minute:2} {second_second:2}.00000000\n"
1153 ));
1154 for (sat, p, clk) in epoch1 {
1155 let c = clk.unwrap_or(999_999.999_999);
1156 body.push_str(&format!(
1157 "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}\n",
1158 p[0], p[1], p[2]
1159 ));
1160 }
1161 body.push_str("EOF\n");
1162 Sp3::parse(body.as_bytes()).expect("parse test sp3")
1163 }
1164
1165 fn sp3_epochs(
1167 start_offset_s: f64,
1168 epochs: &[&[SatSample<'_>]],
1169 interval_s: f64,
1170 cs: &str,
1171 ) -> Sp3 {
1172 let mut sats: Vec<&str> = epochs
1173 .iter()
1174 .flat_map(|e| e.iter().map(|(sat, _, _)| *sat))
1175 .collect();
1176 sats.sort_unstable();
1177 sats.dedup();
1178 let n = sats.len();
1179 let mut sat_field = String::new();
1180 for sat in &sats {
1181 sat_field.push_str(sat);
1182 }
1183 for _ in n..17 {
1184 sat_field.push_str(" 0");
1185 }
1186
1187 let hms = |t: i64| (t / 3600, (t % 3600) / 60, t % 60);
1188 let start = start_offset_s as i64;
1189 let (sh, sm, ss0) = hms(start);
1190
1191 let mut body = String::new();
1192 body.push_str(&format!(
1193 "#cP2020 6 25 {sh:2} {sm:2} {ss0:2}.00000000 {:2} ORBIT {cs} FIT TST\n",
1194 epochs.len()
1195 ));
1196 let sow = 432_000.0 + start_offset_s;
1198 let mjd_frac = start_offset_s / SECONDS_PER_DAY;
1199 body.push_str(&format!(
1200 "## 2111 {sow:15.8} {interval_s:14.8} 59025 {mjd_frac:.13}\n"
1201 ));
1202 body.push_str(&format!("+ {n:2} {sat_field}\n"));
1203 body.push_str("++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n");
1204 body.push_str("%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1205 body.push_str("%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1206 body.push_str("%f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n");
1207 body.push_str("%f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n");
1208 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1209 body.push_str("%i 0 0 0 0 0 0 0 0 0\n");
1210 body.push_str("/* TEST SP3-c FIXTURE\n");
1211 for (k, recs) in epochs.iter().enumerate() {
1212 let (hh, mm, ss) = hms(start + (k as i64) * (interval_s as i64));
1213 body.push_str(&format!("* 2020 6 25 {hh:2} {mm:2} {ss:2}.00000000\n"));
1214 for (sat, p, clk) in recs.iter() {
1215 let c = clk.unwrap_or(999_999.999_999);
1216 body.push_str(&format!(
1217 "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}\n",
1218 p[0], p[1], p[2]
1219 ));
1220 }
1221 }
1222 body.push_str("EOF\n");
1223 Sp3::parse(body.as_bytes()).expect("parse test sp3")
1224 }
1225
1226 #[test]
1227 fn merge_unions_coverage_when_one_center_misses_a_satellite() {
1228 let a = sp3_records(&[
1231 ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
1232 ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
1233 ("G03", [17000.0, -22000.0, 7000.0], Some(300.0)),
1234 ]);
1235 let b = sp3_records(&[
1236 ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
1237 ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
1238 ]);
1239
1240 let (merged, report) = merge(&[a, b], &MergeOptions::default()).expect("merge");
1241
1242 let states = merged.states_at(0).expect("epoch 0");
1243 assert!(
1244 states.contains_key(&gps(3)),
1245 "merged output must cover G03 from the center that has it"
1246 );
1247 assert_eq!(states.len(), 3, "union is G01/G02/G03");
1248 let g01 = states[&gps(1)];
1250 assert!((g01.clock_s.unwrap() - 100.0e-6).abs() < 1.0e-15);
1251 assert!(report.quarantined.is_empty());
1253 assert_eq!(report.single_source.len(), 1);
1254 assert_eq!(report.single_source[0].satellite, gps(3));
1255 }
1256
1257 #[test]
1258 fn merge_combines_two_of_three_agreeing_sources_and_rejects_the_outlier() {
1259 let a = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))]);
1261 let b = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))]);
1262 let c = sp3_records(&[("G01", [15000.010, -20000.0, 5000.0], Some(100.0))]);
1263
1264 let (merged, report) = merge(&[a, b, c], &MergeOptions::default()).expect("merge");
1265
1266 let states = merged.states_at(0).expect("epoch 0");
1267 let g01 = states[&gps(1)];
1268 assert!(
1270 (g01.position.as_array()[0] - 15_000_000.0).abs() < 1.0e-3,
1271 "got {}",
1272 g01.position.as_array()[0]
1273 );
1274 assert_eq!(report.position_outliers.len(), 1);
1276 assert_eq!(report.position_outliers[0].sources, vec![2]);
1277 assert!(report.quarantined.is_empty());
1278 }
1279
1280 #[test]
1281 fn merge_consensus_handles_more_than_u32_mask_bits() {
1282 let sources: Vec<Sp3> = (0..33)
1285 .map(|idx| {
1286 let x_km = if idx < 32 { 15000.0 } else { 15000.010 };
1287 sp3_records(&[("G01", [x_km, -20000.0, 5000.0], Some(100.0))])
1288 })
1289 .collect();
1290
1291 for combine in [MergeCombine::Mean, MergeCombine::Precedence] {
1292 let opts = MergeOptions {
1293 combine,
1294 min_agree: 32,
1295 ..MergeOptions::default()
1296 };
1297
1298 let (merged, report) = merge(&sources, &opts).expect("33-source merge");
1299
1300 let states = merged.states_at(0).expect("epoch 0");
1301 let g01 = states[&gps(1)];
1302 assert!(
1303 (g01.position.as_array()[0] - 15_000_000.0).abs() < 1.0e-3,
1304 "{combine:?}: got {}",
1305 g01.position.as_array()[0]
1306 );
1307 assert_eq!(
1308 report.position_outliers.len(),
1309 1,
1310 "{combine:?}: expected one outlier report"
1311 );
1312 assert_eq!(report.position_outliers[0].sources, vec![32]);
1313 assert!(report.quarantined.is_empty(), "{combine:?}");
1314 }
1315 }
1316
1317 #[test]
1318 fn merge_bounds_large_overlap_clique_search() {
1319 let sources: Vec<Sp3> = (0..40)
1320 .map(|idx| {
1321 let x_km = if idx % 2 == 0 { 15000.0 } else { 15000.010 };
1322 sp3_records(&[("G01", [x_km, -20000.0, 5000.0], Some(100.0))])
1323 })
1324 .collect();
1325 let opts = MergeOptions {
1326 min_agree: 20,
1327 ..MergeOptions::default()
1328 };
1329
1330 let (merged, report) = merge(&sources, &opts).expect("bounded large-source merge");
1331
1332 let states = merged.states_at(0).expect("epoch 0");
1333 let g01 = states[&gps(1)];
1334 assert!(
1335 (g01.position.as_array()[0] - 15_000_000.0).abs() < 1.0e-3,
1336 "got {}",
1337 g01.position.as_array()[0]
1338 );
1339 assert_eq!(report.position_outliers.len(), 1);
1340 assert_eq!(
1341 report.position_outliers[0].sources,
1342 (1..40).step_by(2).collect::<Vec<_>>()
1343 );
1344 assert!(report.quarantined.is_empty());
1345 }
1346
1347 #[test]
1348 fn merge_quarantines_a_satellite_all_centers_disagree_on() {
1349 let a = sp3_records(&[("G01", [15000.000, -20000.0, 5000.0], Some(100.0))]);
1351 let b = sp3_records(&[("G01", [15000.010, -20000.0, 5000.0], Some(100.0))]);
1352 let c = sp3_records(&[("G01", [15000.020, -20000.0, 5000.0], Some(100.0))]);
1353
1354 let (merged, report) = merge(&[a, b, c], &MergeOptions::default()).expect("merge");
1355
1356 assert!(
1357 merged.states_at(0).expect("epoch 0").is_empty(),
1358 "no consensus -> G01 omitted, not averaged across disagreeing centers"
1359 );
1360 assert_eq!(report.quarantined.len(), 1);
1361 assert_eq!(report.quarantined[0].satellite, gps(1));
1362 }
1363
1364 #[test]
1365 fn merge_rejects_an_empty_input() {
1366 assert!(merge(&[], &MergeOptions::default()).is_err());
1367 }
1368
1369 #[test]
1370 fn merge_omits_an_unalignable_secondary_clock() {
1371 let a = sp3_records(&[
1376 ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
1377 ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
1378 ("G03", [17000.0, -22000.0, 7000.0], Some(300.0)),
1379 ]);
1380 let b = sp3_records(&[
1381 ("G01", [15000.0, -20000.0, 5000.0], Some(150.0)),
1382 ("G02", [16000.0, -21000.0, 6000.0], Some(250.0)),
1383 ("G03", [17000.0, -22000.0, 7000.0], Some(350.0)),
1384 ("G04", [18000.0, -23000.0, 8000.0], Some(450.0)),
1385 ]);
1386
1387 let (merged, _) = merge(&[a, b], &MergeOptions::default()).expect("merge");
1388 let states = merged.states_at(0).expect("epoch 0");
1389
1390 assert!(states.contains_key(&gps(4)));
1392 assert!(
1393 states[&gps(4)].clock_s.is_none(),
1394 "an unalignable secondary clock must be dropped, not emitted raw"
1395 );
1396 let g01_clock = states[&gps(1)]
1398 .clock_s
1399 .expect("G01 carries the reference clock");
1400 assert!((g01_clock - 100.0e-6).abs() < 1.0e-12, "got {g01_clock}");
1401 }
1402
1403 #[test]
1404 fn merge_rejects_mismatched_coordinate_systems() {
1405 let a = sp3_build(
1406 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
1407 "IGS14",
1408 );
1409 let b = sp3_build(
1410 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
1411 "IGS20",
1412 );
1413
1414 assert!(merge(&[a, b], &MergeOptions::default()).is_err());
1415 }
1416
1417 #[test]
1418 fn merge_rejects_different_igs_frame_labels_without_a_transform() {
1419 let a = sp3_build(
1420 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
1421 "IGS20",
1422 );
1423 let b = sp3_build(
1424 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
1425 "IGc20",
1426 );
1427
1428 let err = merge(&[a, b], &MergeOptions::default()).expect_err("frame mismatch");
1429 assert!(
1430 err.to_string().contains("mismatched coordinate systems"),
1431 "{err}"
1432 );
1433 }
1434
1435 #[test]
1436 fn merge_decimates_finer_interval_onto_coarse_common_grid() {
1437 let a = sp3_two_epochs(
1443 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
1444 &[("G01", [15003.0, -20003.0, 5003.0], Some(103.0))],
1445 900.0,
1446 "IGS14",
1447 );
1448 let b = sp3_epochs(
1449 0.0,
1450 &[
1451 &[("G01", [26000.0, -20000.0, 5000.0], Some(200.0))],
1452 &[("G01", [26001.0, -20001.0, 5001.0], Some(201.0))],
1453 &[("G01", [26002.0, -20002.0, 5002.0], Some(202.0))],
1454 &[("G01", [26003.0, -20003.0, 5003.0], Some(203.0))],
1455 ],
1456 300.0,
1457 "IGS14",
1458 );
1459
1460 let opts = MergeOptions {
1461 combine: MergeCombine::Precedence,
1462 min_agree: 1,
1463 ..MergeOptions::default()
1464 };
1465 let (merged, _report) =
1466 merge(&[a, b], &opts).expect("mixed-interval merge decimates onto the coarse grid");
1467
1468 assert_eq!(
1469 merged.header.epoch_interval_s, 900.0,
1470 "output is on the coarse (900 s) common grid"
1471 );
1472 assert_eq!(
1473 merged.epochs.len(),
1474 2,
1475 "only the two aligned epochs (:00, :15), not B's four"
1476 );
1477 for idx in 0..2 {
1480 let g01 = merged.states_at(idx).expect("epoch")[&gps(1)];
1481 assert!(
1482 (g01.position.as_array()[0] - 15_000_000.0 - (idx as f64) * 3000.0).abs() < 1.0,
1483 "epoch {idx}: expected A's value, got {}",
1484 g01.position.as_array()[0]
1485 );
1486 }
1487 assert!(merged.states_at(0).expect("epoch 0").contains_key(&gps(1)));
1488 assert!(merged.states_at(1).expect("epoch 1").contains_key(&gps(1)));
1489 }
1490
1491 #[test]
1492 fn merge_decimates_with_explicit_coarser_target_interval() {
1493 let recs = |x: f64| vec![("G01", [x, -20000.0, 5000.0], Some(100.0))];
1495 let make = || {
1496 sp3_epochs(
1497 0.0,
1498 &[
1499 &recs(15000.0),
1500 &recs(15001.0),
1501 &recs(15002.0),
1502 &recs(15003.0),
1503 ],
1504 300.0,
1505 "IGS14",
1506 )
1507 };
1508 let opts = MergeOptions {
1509 min_agree: 1,
1510 target_epoch_interval_s: Some(900.0),
1511 ..MergeOptions::default()
1512 };
1513 let (merged, _) = merge(&[make(), make()], &opts).expect("explicit coarse target");
1514 assert_eq!(merged.header.epoch_interval_s, 900.0);
1515 assert_eq!(
1516 merged.epochs.len(),
1517 2,
1518 "decimated 5-min inputs to the 900 s target"
1519 );
1520 }
1521
1522 #[test]
1523 fn merge_rejects_non_divisible_epoch_intervals() {
1524 let a = sp3_two_epochs(
1528 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
1529 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
1530 900.0,
1531 "IGS14",
1532 );
1533 let b = sp3_two_epochs(
1534 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
1535 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
1536 400.0,
1537 "IGS14",
1538 );
1539
1540 let err = merge(&[a, b], &MergeOptions::default()).expect_err("non-divisible intervals");
1541 assert!(
1542 err.to_string().contains("mismatched epoch intervals"),
1543 "{err}"
1544 );
1545 }
1546
1547 #[test]
1548 fn merge_rejects_a_non_whole_second_common_interval() {
1549 let mk = || {
1552 sp3_two_epochs(
1553 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
1554 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
1555 900.0,
1556 "IGS14",
1557 )
1558 };
1559 let opts = MergeOptions {
1560 target_epoch_interval_s: Some(450.5),
1561 ..MergeOptions::default()
1562 };
1563 let err = merge(&[mk(), mk()], &opts).expect_err("fractional target");
1564 assert!(err.to_string().contains("whole number of seconds"), "{err}");
1565 }
1566
1567 #[test]
1568 fn merge_header_first_epoch_describes_the_decimated_grid_start() {
1569 let a = sp3_epochs(
1574 0.0,
1575 &[
1576 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
1577 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
1578 &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
1579 ],
1580 900.0,
1581 "IGS14",
1582 );
1583 let b = sp3_epochs(
1584 900.0,
1585 &[
1586 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
1587 &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
1588 &[("G01", [15003.0, -20003.0, 5003.0], Some(103.0))],
1589 ],
1590 900.0,
1591 "IGS14",
1592 );
1593
1594 let opts = MergeOptions {
1595 min_agree: 1,
1596 ..MergeOptions::default()
1597 };
1598 let (merged, _) = merge(&[a, b], &opts).expect("merge");
1599
1600 assert_eq!(merged.epochs.len(), 2, "common epochs are 00:15 and 00:30");
1601 assert!(
1602 (merged.header.seconds_of_week - 346_500.0).abs() < 1.0e-6,
1603 "header sow must describe the merged first epoch 00:15 (346500 s), got {}",
1604 merged.header.seconds_of_week
1605 );
1606 assert!(
1607 (merged.header.mjd_fraction - 900.0 / 86_400.0).abs() < 1.0e-9,
1608 "header MJD fraction must describe 00:15, got {}",
1609 merged.header.mjd_fraction
1610 );
1611 }
1612
1613 #[test]
1614 fn merge_writer_recomputes_header_when_common_grid_starts_after_all_inputs() {
1615 let a = sp3_epochs(
1620 0.0,
1621 &[
1622 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
1623 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
1624 &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
1625 ],
1626 900.0,
1627 "IGS14",
1628 );
1629 let b = sp3_epochs(
1630 450.0,
1631 &[
1632 &[("G01", [15010.0, -20010.0, 5010.0], Some(110.0))],
1633 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
1634 &[("G01", [15011.0, -20011.0, 5011.0], Some(111.0))],
1635 &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
1636 ],
1637 450.0,
1638 "IGS14",
1639 );
1640
1641 let opts = MergeOptions {
1642 min_agree: 1,
1643 ..MergeOptions::default()
1644 };
1645 let (merged, _) = merge(&[a, b], &opts).expect("mixed-cadence merge");
1646
1647 assert_eq!(merged.epochs.len(), 2, "common epochs are 00:15 and 00:30");
1648 let text = merged.to_sp3_string();
1649 let header = text
1650 .lines()
1651 .find(|line| line.starts_with("## "))
1652 .expect("written ## header");
1653 let first_epoch = text
1654 .lines()
1655 .find(|line| line.starts_with("* "))
1656 .expect("written first epoch");
1657
1658 assert_eq!(first_epoch, "* 2020 6 25 0 15 0.00000000");
1659 assert_eq!(
1660 header,
1661 "## 2111 346500.00000000 900.00000000 59025 0.0104166666667"
1662 );
1663 }
1664
1665 #[test]
1666 fn precedence_merge_never_switches_source_within_one_satellite_arc() {
1667 let a = sp3_two_epochs(
1668 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
1669 &[],
1670 900.0,
1671 "IGS14",
1672 );
1673 let b = sp3_two_epochs(
1674 &[("G01", [15000.001, -20000.0, 5000.0], Some(100.0))],
1675 &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
1676 900.0,
1677 "IGS14",
1678 );
1679 let opts = MergeOptions {
1680 combine: MergeCombine::Precedence,
1681 min_agree: 1,
1682 ..MergeOptions::default()
1683 };
1684
1685 let (merged, _report) = merge(&[a, b], &opts).expect("merge");
1686 let epoch0 = merged.states_at(0).expect("epoch 0");
1687 let epoch1 = merged.states_at(1).expect("epoch 1");
1688
1689 assert!(epoch0.contains_key(&gps(1)));
1690 assert!(
1691 !epoch1.contains_key(&gps(1)),
1692 "G01 must not switch from source 0 at epoch 0 to source 1 at epoch 1"
1693 );
1694 assert_eq!(merged.header.epoch_interval_s, 900.0);
1695 }
1696
1697 #[test]
1698 fn merge_filters_requested_constellations_and_header_satellites() {
1699 let a = sp3_two_epochs(
1700 &[
1701 ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
1702 ("E01", [21000.0, -1000.0, 13000.0], Some(120.0)),
1703 ],
1704 &[
1705 ("G01", [15001.0, -20001.0, 5001.0], Some(101.0)),
1706 ("E01", [21001.0, -1001.0, 13001.0], Some(121.0)),
1707 ],
1708 900.0,
1709 "IGS14",
1710 );
1711 let systems = BTreeSet::from([GnssSystem::Gps]);
1712 let opts = MergeOptions {
1713 systems: Some(systems),
1714 ..MergeOptions::default()
1715 };
1716
1717 let (merged, _report) = merge(&[a], &opts).expect("merge");
1718
1719 assert_eq!(merged.header.satellites, vec![gps(1)]);
1720 for idx in 0..merged.epochs.len() {
1721 let states = merged.states_at(idx).expect("epoch");
1722 assert_eq!(states.keys().copied().collect::<Vec<_>>(), vec![gps(1)]);
1723 }
1724 }
1725
1726 #[test]
1727 fn merge_preserves_a_clock_event_flag() {
1728 let a = sp3_build(
1731 &[(
1732 "G01",
1733 [15000.0, -20000.0, 5000.0],
1734 Some(100.0),
1735 " E",
1736 )],
1737 "IGS14",
1738 );
1739 let b = sp3_build(
1740 &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
1741 "IGS14",
1742 );
1743
1744 let (merged, _) = merge(&[a, b], &MergeOptions::default()).expect("merge");
1745 let g01 = merged.states_at(0).expect("epoch 0")[&gps(1)];
1746
1747 assert!(
1748 g01.flags.clock_event,
1749 "merged cell must preserve a contributing source's clock-event flag"
1750 );
1751 }
1752
1753 #[test]
1754 fn merge_reports_effective_epoch_interval_from_actual_epochs() {
1755 let body = "#cP2020 6 25 0 0 0.00000000 2 ORBIT IGS14 FIT TST\n\
1759 ## 2111 432000.00000000 300.00000000 59025 0.0000000000000\n\
1760 + 1 G01 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
1761 ++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
1762 %c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
1763 %c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
1764 %f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n\
1765 %f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n\
1766 %i 0 0 0 0 0 0 0 0 0\n\
1767 %i 0 0 0 0 0 0 0 0 0\n\
1768 /* TEST SP3-c FIXTURE\n\
1769 * 2020 6 25 0 0 0.00000000\n\
1770 PG01 15000.000000 -20000.000000 5000.000000 100.000000\n\
1771 * 2020 6 25 0 15 0.00000000\n\
1772 PG01 15001.000000 -20001.000000 5001.000000 101.000000\n\
1773 EOF\n";
1774 let a = Sp3::parse(body.as_bytes()).expect("parse test sp3");
1775
1776 let (merged, _) = merge(&[a], &MergeOptions::default()).expect("merge");
1777
1778 assert!(
1779 (merged.header.epoch_interval_s - 900.0).abs() < 1.0e-6,
1780 "got {}",
1781 merged.header.epoch_interval_s
1782 );
1783 }
1784
1785 #[test]
1786 fn merge_rejects_unsorted_input_epochs_before_cadence_inference() {
1787 let body = "#cP2020 6 25 0 0 0.00000000 2 ORBIT IGS14 FIT TST\n\
1788 ## 2111 432000.00000000 900.00000000 59025 0.0000000000000\n\
1789 + 1 G01 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
1790 ++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
1791 %c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
1792 %c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
1793 %f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n\
1794 %f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n\
1795 %i 0 0 0 0 0 0 0 0 0\n\
1796 %i 0 0 0 0 0 0 0 0 0\n\
1797 /* TEST SP3-c FIXTURE\n\
1798 * 2020 6 25 0 15 0.00000000\n\
1799 PG01 15001.000000 -20001.000000 5001.000000 101.000000\n\
1800 * 2020 6 25 0 0 0.00000000\n\
1801 PG01 15000.000000 -20000.000000 5000.000000 100.000000\n\
1802 EOF\n";
1803 let source = Sp3::parse(body.as_bytes()).expect("parse unsorted test sp3");
1804
1805 let err = merge(&[source], &MergeOptions::default()).expect_err("unsorted epochs");
1806
1807 assert!(
1808 err.to_string()
1809 .contains("merge input epochs must be strictly increasing"),
1810 "{err}"
1811 );
1812 }
1813
1814 #[test]
1815 fn align_clock_reference_puts_other_on_the_reference_datum() {
1816 let reference = sp3([100.0, 200.0, 300.0]);
1819 let other = sp3([150.0, 250.0, 350.0]);
1820
1821 let aligned = align_clock_reference(&reference, &other, 3);
1822
1823 let g01 = aligned.states_at(0).expect("epoch 0")[&gps(1)];
1824 assert!(
1825 (g01.clock_s.unwrap() - 100.0e-6).abs() < 1.0e-15,
1826 "got {}",
1827 g01.clock_s.unwrap()
1828 );
1829 let original = other.states_at(0).expect("epoch 0")[&gps(1)];
1831 assert_eq!(g01.position.as_array(), original.position.as_array());
1832 }
1833
1834 fn sp3(clocks_us: [f64; 3]) -> Sp3 {
1838 let body = format!(
1839 "#cP2020 6 25 0 0 0.00000000 1 ORBIT IGS14 FIT TST\n\
1840 ## 2111 432000.00000000 900.00000000 59025 0.0000000000000\n\
1841 + 3 G01G02G03 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
1842 ++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
1843 %c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
1844 %c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
1845 %f 1.2500000 1.025000000 0.00000000000 0.000000000000000\n\
1846 %f 0.0000000 0.000000000 0.00000000000 0.000000000000000\n\
1847 %i 0 0 0 0 0 0 0 0 0\n\
1848 %i 0 0 0 0 0 0 0 0 0\n\
1849 /* TEST SP3-c FIXTURE\n\
1850 * 2020 6 25 0 0 0.00000000\n\
1851 PG01 15000.000000 -20000.000000 5000.000000 {:13.6}\n\
1852 PG02 -1234.567890 2345.678901 -3456.789012 {:13.6}\n\
1853 PG03 8000.000000 12000.000000 -19000.000000 {:13.6}\n\
1854 EOF\n",
1855 clocks_us[0], clocks_us[1], clocks_us[2]
1856 );
1857 Sp3::parse(body.as_bytes()).expect("parse test sp3")
1858 }
1859
1860 #[test]
1861 fn recovers_a_uniform_datum_shift() {
1862 let reference = sp3([100.0, 200.0, 300.0]);
1864 let other = sp3([150.0, 250.0, 350.0]);
1865
1866 let offsets = clock_reference_offset(&reference, &other, 3);
1867
1868 assert_eq!(offsets.len(), 1);
1869 assert_eq!(offsets[0].satellites, 3);
1870 assert!(
1871 (offsets[0].offset_s - 5.0e-5).abs() < 1.0e-12,
1872 "got {}",
1873 offsets[0].offset_s
1874 );
1875 }
1876
1877 #[test]
1878 fn median_rejects_a_single_outlier_clock() {
1879 let reference = sp3([100.0, 200.0, 300.0]);
1882 let other = sp3([150.0, 250.0, 9_300.0]);
1883
1884 let offsets = clock_reference_offset(&reference, &other, 3);
1885
1886 assert_eq!(offsets.len(), 1);
1887 assert!(
1888 (offsets[0].offset_s - 5.0e-5).abs() < 1.0e-12,
1889 "got {}",
1890 offsets[0].offset_s
1891 );
1892 }
1893
1894 #[test]
1895 fn omits_epochs_below_min_common() {
1896 let reference = sp3([100.0, 200.0, 300.0]);
1899 let other = sp3([150.0, 250.0, 350.0]);
1900
1901 assert!(clock_reference_offset(&reference, &other, 4).is_empty());
1902 }
1903}