Skip to main content

sidereon_core/sp3/
combine.rs

1//! Multi-source SP3 combination: clock-datum alignment across analysis centers.
2//!
3//! Precise clock products from different analysis centers are referenced to
4//! different station/ensemble clocks, so their raw clock values differ by a
5//! per-epoch common offset - the reference-clock difference - that drifts over
6//! the day. Before clocks from two centers can be compared or combined, that
7//! datum must be removed. [`clock_reference_offset`] estimates it robustly (the
8//! median, over the satellites both products report at each epoch, of
9//! `other - reference`); subtract it from `other`'s clocks to put both products
10//! on `reference`'s datum.
11//!
12//! Orbit positions need no such treatment: every center reports ITRF
13//! center-of-mass coordinates, so cross-center position differences are already
14//! directly comparable.
15
16use 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/// One epoch's reference-clock offset of `other` relative to `reference`.
32#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct ClockReferenceOffset {
34    /// The matched epoch.
35    pub epoch: Instant,
36    /// `other - reference` clock datum at this epoch, in seconds. Positive means
37    /// `other`'s clock datum runs ahead of `reference`'s; subtract it from
38    /// `other`'s clocks to align them to `reference`.
39    pub offset_s: f64,
40    /// Number of satellites that contributed to the (median) estimate.
41    pub satellites: usize,
42}
43
44/// Estimate the per-epoch reference-clock offset of `other` relative to
45/// `reference`.
46///
47/// For each epoch present in both products, the offset is the median over the
48/// satellites both report (each with a finite clock) of
49/// `other_clock - reference_clock`. The median makes the estimate robust to a
50/// single satellite whose clock one center has wrong - but only with enough
51/// satellites, so `min_common` is the minimum number of common clocked
52/// satellites required to emit an offset for an epoch (a sound robust median
53/// wants at least three, so one outlier can be outvoted). Epochs with fewer
54/// common clocks are omitted rather than reported as a fragile one- or
55/// two-satellite estimate.
56///
57/// Epochs are matched by their J2000 second floored to a whole second (the same
58/// node-axis convention the interpolator uses). Non-finite clock differences are
59/// skipped. Epochs present in only one product, or below `min_common`, are
60/// omitted from the result.
61pub 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                    // SP3 should not carry NaN/inf clocks, but the parser can
98                    // accept them; merge infrastructure must not panic on data.
99                    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    // Inputs are pre-filtered to finite values; total_cmp never panics regardless.
126    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// ===========================================================================
137// Multi-source merge
138// ===========================================================================
139
140/// How the agreeing (consensus) sources for a cell are combined into the merged
141/// value.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum MergeCombine {
144    /// Arithmetic mean of the consensus sources. The clustering step has already
145    /// removed outliers, so the mean uses every agreeing measurement. Default.
146    Mean,
147    /// Component-wise median of the consensus sources.
148    Median,
149    /// The value from the highest-precedence (earliest-listed) consensus source.
150    Precedence,
151}
152
153/// Options for [`merge`].
154#[derive(Debug, Clone, PartialEq)]
155pub struct MergeOptions {
156    /// Maximum 3D position difference (meters) for two sources to be in
157    /// agreement.
158    pub position_tolerance_m: f64,
159    /// Maximum clock difference (seconds, after datum alignment) for two sources
160    /// to be in agreement.
161    pub clock_tolerance_s: f64,
162    /// Minimum number of mutually-agreeing sources required to accept a cell that
163    /// has two or more sources. A cell with a single source is always carried
164    /// through (gap fill, recorded as `single_source`); a cell with several
165    /// sources but no agreeing subset this large is quarantined rather than
166    /// averaged across disagreeing centers.
167    pub min_agree: usize,
168    /// Minimum common clocked satellites for the per-epoch clock-datum estimate
169    /// between two sources (see [`clock_reference_offset`]).
170    pub clock_min_common: usize,
171    /// How to combine the agreeing sources.
172    pub combine: MergeCombine,
173    /// Optional target epoch interval, in seconds. When unset the coarsest input
174    /// interval is used. Finer inputs are decimated onto this grid by exact
175    /// subset selection (never interpolated); inputs whose interval does not
176    /// evenly divide it are rejected.
177    pub target_epoch_interval_s: Option<f64>,
178    /// Optional constellation/system filter. When set, only satellites whose
179    /// system is in this set are considered for the merged product.
180    pub systems: Option<BTreeSet<GnssSystem>>,
181}
182
183impl Default for MergeOptions {
184    /// Defaults tuned for the common case of ~3 analysis centers: agreement is a
185    /// 2-of-3 majority (`min_agree = 2`); combine the agreeing subset by mean.
186    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/// One (epoch, satellite) cell the merge handled with a caveat. Nothing is
200/// dropped or averaged silently - every such cell is recorded here.
201#[derive(Debug, Clone, PartialEq)]
202pub struct MergeFlag {
203    /// The epoch.
204    pub epoch: Instant,
205    /// The satellite.
206    pub satellite: GnssSatelliteId,
207    /// The source indices (into the input slice) this flag refers to: for
208    /// `single_source`, the lone contributor; for `quarantined`, all sources
209    /// that disagreed; for `position_outliers`, the sources rejected from an
210    /// otherwise-accepted consensus.
211    pub sources: Vec<usize>,
212}
213
214/// Audit trail for a [`merge`].
215#[derive(Debug, Clone, Default, PartialEq)]
216pub struct MergeReport {
217    /// Cells where two or more sources disagreed beyond tolerance with no
218    /// agreeing subset of `min_agree` - omitted from the merged product.
219    pub quarantined: Vec<MergeFlag>,
220    /// Cells carried from a single source (no cross-check was possible).
221    pub single_source: Vec<MergeFlag>,
222    /// Cells accepted by consensus where one or more sources were rejected as
223    /// position outliers.
224    pub position_outliers: Vec<MergeFlag>,
225}
226
227/// Merge several SP3 products from different analysis centers into one
228/// consistent precise-ephemeris dataset.
229///
230/// Orthogonal to time-stitching: this combines providers at the **same** epochs.
231/// Inputs must be on one common, uniform epoch grid. Mixed-cadence products are
232/// rejected rather than unioned onto a finer grid; callers that need that must
233/// resample first. For every (epoch, satellite) cell on the common grid:
234///
235/// - **Union satellite coverage.** A satellite present in any input may appear
236///   in the output, but only on the shared grid and only when doing so preserves
237///   a coherent source/consensus arc.
238/// - **Position consensus.** With one source the value is carried through
239///   (`single_source`). With several, the largest subset of sources mutually
240///   within `position_tolerance_m` is found; if it has at least `min_agree`
241///   members it is combined per `combine` and any sources outside it are recorded
242///   as `position_outliers`. If no such subset exists the cell is `quarantined`
243///   (omitted) - never averaged across disagreeing centers.
244/// - **Clock consensus.** Clocks are first put on a common datum (each source
245///   aligned to the first via [`clock_reference_offset`]), then combined by the
246///   same agreement rule; a cell with no clock consensus carries no clock. A
247///   non-reference source whose datum cannot be estimated at an epoch (below
248///   `clock_min_common` common clocks) contributes **no** clock there rather than
249///   an unaligned one - its position is still merged.
250///
251/// `Precedence` is resolved per satellite arc: once a satellite is assigned to
252/// the highest-precedence source that carries it, that satellite never switches
253/// centers at adjacent epochs. If that source is missing a cell, the cell is
254/// omitted rather than filled from a lower-precedence source.
255///
256/// All inputs must share an exact SP3 time-system label and exact
257/// coordinate-system label (epochs are matched across products in that scale);
258/// mismatches are rejected. The merged record flags are the union (OR) of the
259/// contributing sources' flags - in particular a `clock_event` on any
260/// clock-consensus member is preserved, so the interpolator still splits the
261/// clock arc. The merged header is **synthetic**: its first-epoch fields
262/// describe the union's first epoch and its data type is position-only.
263///
264/// Pure and deterministic: order the inputs by center precedence and ties (equal
265/// cluster sizes, `Precedence` combine) resolve to the earliest-listed source.
266/// The merged product's interpolation nodes are the consensus values, so it
267/// samples and interpolates like any other [`Sp3`] (it is a derived combination,
268/// not a byte-faithful copy of any one center). Consensus is exact max-clique for
269/// normal source counts and uses a deterministic greedy fallback above the exact
270/// search cap, so hostile disagreement graphs remain bounded.
271pub 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    // Inputs must be combinable: epochs are matched in one exact product time
279    // system, and positions are only comparable in an exactly common coordinate
280    // system / frame. Do not silently alias labels such as QZS/GPS or
281    // IGS20/IGc20 here: without an explicit transform, a differently labeled
282    // product contract is a different product contract.
283    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    // floored-J2000-second -> epoch index, per source.
300    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    // Per-source per-epoch clock-datum offset relative to source 0. Source 0 is
316    // the datum, so its offset is identically zero.
317    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    // Intersection of epochs (by floored second), keeping source 0's
336    // representative Instant. Mixing a 15-minute product with 5-minute products
337    // must not emit the union grid; if all inputs share a cadence but differ in
338    // coverage, only the epochs present in every product are combined.
339    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    // Decimate onto the resolved common-interval grid (anchored at the earliest
350    // common epoch): keep only epochs that land on the grid, dropping off-grid
351    // epochs by exact subset selection (never interpolation). A no-op when the
352    // inputs already share the interval; the real decimation when finer inputs
353    // are mixed with a coarser one, or an explicit coarser target is requested.
354    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        // Satellites present at this epoch in any source, after any requested
406        // constellation filter.
407        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            // (source_idx, position_m, flags) and (source_idx, datum-aligned
418            // clock_s, flags). A non-reference source contributes a clock only
419            // when its datum offset could be estimated at this epoch; otherwise
420            // its clock would be unaligned, so it is omitted (the position is
421            // still gathered).
422            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            // Position consensus -> the merged position and the indices (into
457            // `pos`) of the sources that contributed it. In precedence mode the
458            // preferred source is fixed per satellite arc; never switch to a
459            // lower-precedence source just because the preferred source is
460            // missing or outside a different consensus cluster at this epoch.
461            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            // Clock consensus, independent of position -> the merged clock and the
521            // indices (into `clk`) of the sources that contributed it.
522            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            // Preserve record flags: OR the orbit flags across the position
559            // members and the clock flags across the clock members, so a
560            // `clock_event` (clock reset) or maneuver on any contributing source
561            // survives into the merged product.
562            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    // Base the non-epoch metadata on a source product, but derive the first-epoch
603    // header fields from the merged grid itself. Mixed cadence / coverage can make
604    // the merged first epoch later than every input's first epoch, so cloning
605    // those fields from any input would make the `##` line stale.
606    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
753/// Resolve the common (output) epoch interval and validate that every input can
754/// be decimated onto it without interpolation.
755///
756/// The common interval is the caller's `target` if given, otherwise the
757/// **coarsest** native interval among the inputs (the finest grid every input
758/// can supply). An input is compatible only when the common interval is a
759/// positive-integer multiple of that input's native interval: then the
760/// common-grid epochs are an exact subset of the input's epochs, and the merge's
761/// epoch intersection performs the decimation (e.g. a 5-minute product
762/// contributes its :00/:15/:30/:45 epochs to a 15-minute merge - no orbit/clock
763/// interpolation is introduced). Inputs whose interval does not evenly divide the
764/// common interval - a coarser input than the requested grid, or a non-divisible
765/// cadence - are rejected as incompatible. Equal-interval inputs (multiple 1) are
766/// the same-interval fast path and behave exactly as before.
767fn 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    // The merge matches and decimates epochs on whole-second J2000 keys, so the
791    // common grid must fall on whole seconds for the decimation lattice to be
792    // exact. SP3 grids are integer-second; reject a fractional common interval
793    // rather than decimate on a mismatched (rounded) lattice.
794    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
811/// True when `common` is a positive-integer multiple of `interval` (within the
812/// interval tolerance), i.e. `interval`'s grid is a superset of the common grid.
813fn 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
849/// Indices of the largest subset of `items` whose members are *mutually* within
850/// `within`. Exact max-clique over normal source counts; deterministic greedy
851/// fallback above [`MAX_EXACT_CLIQUE_NODES`] keeps hostile overlap graphs bounded.
852/// Ties resolve to the lowest-indexed subset (precedence).
853fn 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
996/// Return a copy of `other` with its clocks shifted onto `reference`'s clock
997/// datum.
998///
999/// This applies the per-epoch reference-clock offset from
1000/// [`clock_reference_offset`]: at each epoch where the offset could be estimated
1001/// (at least `min_common` common clocked satellites), every clocked satellite's
1002/// offset has the datum subtracted, so the result's clocks are directly
1003/// comparable to `reference`'s. Positions are untouched (already comparable).
1004///
1005/// Epochs where the offset could not be estimated are left unchanged - they are
1006/// *not* on `reference`'s datum, so a caller mixing aligned and unaligned epochs
1007/// should consult [`clock_reference_offset`] to see which epochs were aligned.
1008/// The returned product interpolates like any other [`Sp3`].
1009pub 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    /// One satellite sample in a synthetic SP3 epoch: token, ECEF position
1048    /// (km), and optional clock (microseconds).
1049    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    // Single-epoch SP3-c from explicit `(satellite, [x,y,z] km, clock us, flag
1056    // suffix)` records under coordinate system `cs` (5 chars, e.g. `"IGS14"`).
1057    // `flags` is appended verbatim after the 60-column record body, so a test can
1058    // place an SP3 flag (e.g. `"              E"` -> the `E` clock-event flag at
1059    // column 75). A `None` clock writes the SP3 bad-clock sentinel.
1060    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    // The common case: IGS14, no flags.
1096    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    // N consecutive epochs spaced `interval_s` apart from 2020-06-25 00:00:00.
1166    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        // Seconds-of-week and MJD fraction of the first epoch shift with the start.
1197        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        // Center A reports G01/G02/G03; center B is missing G03. The merged
1229        // product must still cover G03 at that epoch (filled from A).
1230        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        // G01 agreed across both centers -> consensus clock is their value.
1249        let g01 = states[&gps(1)];
1250        assert!((g01.clock_s.unwrap() - 100.0e-6).abs() < 1.0e-15);
1251        // G03 had a single source -> carried through, recorded, not quarantined.
1252        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        // A and B agree on G01; C is 10 m off in X (> the default 0.5 m tolerance).
1260        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        // Consensus is A/B (15000 km == 1.5e7 m); not dragged toward C.
1269        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        // C is source index 2 -> recorded as the rejected position outlier.
1275        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        // Thirty-two centers agree and the 33rd is 10 m off in X. This used to
1283        // overflow the u32 subset mask before any consensus could be found.
1284        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        // Three sources, mutually beyond tolerance on G01: no 2-of-3 consensus.
1350        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        // Only 3 common satellites, but the default clock datum needs 5, so
1372        // center B's clocks cannot be put on A's datum. They must be dropped
1373        // rather than emitted raw, and a B-only satellite gets a position but no
1374        // clock.
1375        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        // G04 is B-only (gap fill): position carried, clock unalignable -> dropped.
1391        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        // G01's clock comes from the reference (source 0), which is on its own datum.
1397        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        // 15-min (900 s) center A and 5-min (300 s) center B over the same span.
1438        // The merge must decimate B onto the 900 s grid (exact subset of B's :00
1439        // and :15 epochs; the :05/:10 epochs are dropped, not interpolated),
1440        // output at 900 s. Under precedence, A (source 0) wins G01's whole arc and
1441        // B's distinct values must never be substituted mid-arc.
1442        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        // Per-arc precedence intact across the decimated grid: A (source 0) wins
1478        // both epochs; B's :00/:15 values (26000xxx km) are never substituted.
1479        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        // Two 5-min inputs, explicit 900 s target: both decimate to the 15-min grid.
1494        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        // 900 s and 400 s: 900 is not an integer multiple of 400, so no exact
1525        // subset of the 400 s grid lands on the 900 s grid -> still rejected
1526        // (positional interpolation is never performed).
1527        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        // The decimation lattice is whole-second J2000 keys, so a fractional
1550        // common interval must be rejected rather than silently rounded.
1551        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        // Source A starts at 00:00, source B at 00:15 (both 15-min). The merged
1570        // grid's first epoch is the first COMMON epoch, 00:15, so the output
1571        // header's seconds-of-week / MJD fraction must describe 00:15 (source B),
1572        // not source A's earlier 00:00 start.
1573        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        // A starts on the 15-minute grid at 00:00. B starts on a 7.5-minute grid
1616        // at 00:07:30. Their coarsened common grid starts at 00:15, which is not
1617        // the first epoch of either input, so the merged `##` header must be
1618        // derived from the output epoch rather than cloned from a source header.
1619        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        // Source A carries an `E` clock-event flag on G01 (column 75); the merged
1729        // product must keep it so the interpolator still splits the clock arc.
1730        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        // The header DECLARES a 300 s interval, but the two epochs are 15 min
1756        // (900 s) apart. The synthetic merged header must report the spacing of
1757        // the actual merged epochs, not inherit the stale declared value.
1758        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        // `other`'s clocks all run +50 us ahead; after alignment they should sit
1817        // on `reference`'s datum (G01: 150 us - 50 us = 100 us = 1e-4 s).
1818        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        // Positions are untouched by clock alignment.
1830        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    // Minimal single-epoch SP3-c with three satellites; each `clocks_us` entry is
1835    // that satellite's clock in microseconds (positions are arbitrary but non-zero
1836    // so they parse as valid records).
1837    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        // every `other` clock is +50 us (= 5e-5 s) from `reference`.
1863        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        // Two satellites agree (+50 us); one is a wild outlier (+9000 us). The
1880        // median over the three tracks the consensus instead of being dragged out.
1881        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        // Three common clocked satellites, but require four: the fragile estimate
1897        // is omitted rather than reported.
1898        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}