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 are directly comparable only when the SP3 coordinate-system
13//! labels match, or when the caller explicitly opts into an audited label
14//! assertion or terrestrial Helmert reconciliation.
15
16use std::collections::{BTreeMap, BTreeSet};
17
18use crate::astro::math::vec3;
19use crate::astro::time::civil::{
20    civil_from_julian_day_number, fractional_day_of_year_from_instant, is_leap_year,
21    julian_date_from_instant, mjd_from_jd,
22};
23use crate::astro::time::gnss;
24use crate::astro::time::model::Instant;
25
26use super::interp::{instant_to_j2000_seconds, sp3_epoch_j2000_seconds};
27use super::{RawNode, Sp3, Sp3DataType, Sp3Flags, Sp3Header, Sp3State};
28use crate::constants::{DAYS_PER_JULIAN_YEAR, GPS_EPOCH_TO_J2000_S, KM_TO_M, SECONDS_PER_DAY};
29use crate::frame::{ItrfPositionM, ItrfVelocityMS};
30use crate::frame_catalog::{
31    self, HelmertParameters, HelmertRates, TerrestrialFrame, TerrestrialPositionM,
32    TerrestrialVelocityMPerYear,
33};
34use crate::id::{GnssSatelliteId, GnssSystem};
35use crate::tolerances::WHOLE_SECOND_EPS_S;
36use crate::validate;
37use crate::{Error, Result};
38
39const MAX_EXACT_CLIQUE_NODES: usize = 32;
40
41/// One epoch's reference-clock offset of `other` relative to `reference`.
42#[derive(Debug, Clone, Copy, PartialEq)]
43pub struct ClockReferenceOffset {
44    /// The matched epoch.
45    pub epoch: Instant,
46    /// `other - reference` clock datum at this epoch, in seconds. Positive means
47    /// `other`'s clock datum runs ahead of `reference`'s; subtract it from
48    /// `other`'s clocks to align them to `reference`.
49    pub offset_s: f64,
50    /// Number of satellites that contributed to the (median) estimate.
51    pub satellites: usize,
52}
53
54/// Estimate the per-epoch reference-clock offset of `other` relative to
55/// `reference`.
56///
57/// For each epoch present in both products, the offset is the median over the
58/// satellites both report (each with a finite clock) of
59/// `other_clock - reference_clock`. The median makes the estimate robust to a
60/// single satellite whose clock one center has wrong - but only with enough
61/// satellites, so `min_common` is the minimum number of common clocked
62/// satellites required to emit an offset for an epoch (a sound robust median
63/// wants at least three, so one outlier can be outvoted). Epochs with fewer
64/// common clocks are omitted rather than reported as a fragile one- or
65/// two-satellite estimate.
66///
67/// Epochs are matched by their J2000 second floored to a whole second (the same
68/// node-axis convention the interpolator uses). Non-finite clock differences are
69/// skipped. Epochs present in only one product, or below `min_common`, are
70/// omitted from the result.
71///
72/// The floored-whole-second key assumes the input cadence is at least one second,
73/// which holds for every standard SP3 product (15 min, 5 min, 1 min, ... down to
74/// 1 s). Two distinct epochs less than a second apart would collapse onto the
75/// same key and be matched as one; the same applies to the floored key in
76/// [`MergeReport::per_epoch_agreement`]. This is kept deliberately aligned with
77/// the interpolator's node axis rather than refined to sub-second resolution, so
78/// that matching here and interpolation downstream use one consistent grid.
79pub fn clock_reference_offset(
80    reference: &Sp3,
81    other: &Sp3,
82    min_common: usize,
83) -> Vec<ClockReferenceOffset> {
84    let mut other_index: std::collections::HashMap<i64, usize> = std::collections::HashMap::new();
85    for (idx, epoch) in other.epochs.iter().enumerate() {
86        if let Some(seconds) = sp3_epoch_j2000_seconds(other, idx, epoch) {
87            other_index.insert(seconds.floor() as i64, idx);
88        }
89    }
90
91    let mut offsets = Vec::new();
92
93    for (ref_idx, epoch) in reference.epochs.iter().enumerate() {
94        let Some(ref_seconds) = sp3_epoch_j2000_seconds(reference, ref_idx, epoch) else {
95            continue;
96        };
97        let Some(&other_idx) = other_index.get(&(ref_seconds.floor() as i64)) else {
98            continue;
99        };
100
101        let (Ok(ref_states), Ok(other_states)) =
102            (reference.states_at(ref_idx), other.states_at(other_idx))
103        else {
104            continue;
105        };
106
107        let mut diffs: Vec<f64> = Vec::new();
108        for (sat, ref_state) in ref_states.iter() {
109            let Some(ref_clock) = ref_state.clock_s else {
110                continue;
111            };
112            if let Some(other_state) = other_states.get(sat) {
113                if let Some(other_clock) = other_state.clock_s {
114                    let diff = other_clock - ref_clock;
115                    // SP3 should not carry NaN/inf clocks, but the parser can
116                    // accept them; merge infrastructure must not panic on data.
117                    if diff.is_finite() {
118                        diffs.push(diff);
119                    }
120                }
121            }
122        }
123
124        if diffs.len() >= min_common.max(1) {
125            if let Some(offset_s) = median(&mut diffs) {
126                offsets.push(ClockReferenceOffset {
127                    epoch: *epoch,
128                    offset_s,
129                    satellites: diffs.len(),
130                });
131            }
132        }
133    }
134
135    offsets
136}
137
138fn median(values: &mut [f64]) -> Option<f64> {
139    // Inputs are pre-filtered to finite values; total_cmp never panics regardless.
140    crate::astro::math::robust::median_sorting_in_place(values)
141}
142
143// ===========================================================================
144// Multi-source merge
145// ===========================================================================
146
147/// How the agreeing (consensus) sources for a cell are combined into the merged
148/// value.
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum MergeCombine {
151    /// Arithmetic mean of the consensus sources. The clustering step has already
152    /// removed outliers, so the mean uses every agreeing measurement. Default.
153    Mean,
154    /// Component-wise median of the consensus sources.
155    Median,
156    /// The value from the highest-precedence (earliest-listed) consensus source.
157    Precedence,
158}
159
160/// Scope used by [`MergeCombine::Precedence`].
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum MergePrecedenceScope {
163    /// Select the earliest-listed source that actually carries each individual
164    /// `(epoch, satellite)` cell. This maximizes coverage and is the default.
165    Cell,
166    /// Select one earliest-listed source for the whole satellite arc. Missing
167    /// cells in that source remain holes even when a later source has them.
168    SatelliteArc,
169}
170
171/// Optional consensus guard for precedence selection.
172///
173/// With this guard disabled, precedence retains its historical behavior: the
174/// preferred source wins a contested cell whenever `min_agree` permits it. With
175/// it enabled, contested positions and clocks must contain a mutually agreeing
176/// cluster of at least `max(min_agree, 2)` sources. The preferred value is kept
177/// when it belongs to that cluster; otherwise the earliest-listed member of the
178/// deterministic largest cluster replaces it and the rejected source is
179/// recorded in the merge report.
180#[derive(Debug, Clone, Copy, PartialEq)]
181pub struct OutlierRejectOptions {
182    /// Maximum 3D position separation inside the accepted cluster, meters.
183    pub position_tolerance_m: f64,
184    /// Maximum aligned-clock separation inside the accepted cluster, seconds.
185    pub clock_tolerance_s: f64,
186}
187
188/// Options for [`merge`].
189#[derive(Debug, Clone, PartialEq)]
190pub struct MergeOptions {
191    /// Maximum 3D position difference (meters) for two sources to be in
192    /// agreement.
193    pub position_tolerance_m: f64,
194    /// Maximum clock difference (seconds, after datum alignment) for two sources
195    /// to be in agreement.
196    pub clock_tolerance_s: f64,
197    /// Minimum number of mutually-agreeing sources required to accept a cell that
198    /// has two or more sources. A cell with a single source is always carried
199    /// through (gap fill, recorded as `single_source`); a cell with several
200    /// sources but no agreeing subset this large is quarantined rather than
201    /// averaged across disagreeing centers.
202    pub min_agree: usize,
203    /// Minimum common clocked satellites for the per-epoch clock-datum estimate
204    /// between two sources (see [`clock_reference_offset`]).
205    pub clock_min_common: usize,
206    /// How to combine the agreeing sources.
207    pub combine: MergeCombine,
208    /// Whether precedence is selected independently for each cell or fixed for
209    /// a whole satellite arc. Ignored for mean and median combination.
210    pub precedence_scope: MergePrecedenceScope,
211    /// Optional consensus guard for precedence-selected values. `None` preserves
212    /// the historical contested-cell behavior.
213    pub outlier_reject: Option<OutlierRejectOptions>,
214    /// Optional target epoch interval, in seconds. When unset the finest input
215    /// interval is used. Coarser inputs contribute at the target-grid epochs
216    /// they actually carry; values are never interpolated. Input and target
217    /// intervals must be integer-commensurate.
218    pub target_epoch_interval_s: Option<f64>,
219    /// Optional constellation/system filter. When set, only satellites whose
220    /// system is in this set are considered for the merged product.
221    pub systems: Option<BTreeSet<GnssSystem>>,
222    /// Explicit coordinate-label reconciliation rules. Default is disabled, so
223    /// mismatched coordinate-system labels are rejected.
224    pub frame_reconciliation: Sp3FrameReconciliationOptions,
225}
226
227impl Default for MergeOptions {
228    /// Defaults tuned for the common case of ~3 analysis centers: agreement is a
229    /// 2-of-3 majority (`min_agree = 2`); combine the agreeing subset by mean.
230    fn default() -> Self {
231        Self {
232            position_tolerance_m: 0.5,
233            clock_tolerance_s: 5.0e-9,
234            min_agree: 2,
235            clock_min_common: 5,
236            combine: MergeCombine::Mean,
237            precedence_scope: MergePrecedenceScope::Cell,
238            outlier_reject: None,
239            target_epoch_interval_s: None,
240            systems: None,
241            frame_reconciliation: Sp3FrameReconciliationOptions::default(),
242        }
243    }
244}
245
246/// Explicit opt-in rules for reconciling mismatched SP3 coordinate labels.
247#[derive(Debug, Clone, Default, PartialEq, Eq)]
248pub struct Sp3FrameReconciliationOptions {
249    /// Caller-asserted label sets that may be treated as physically equivalent
250    /// without applying any coordinate transform.
251    pub asserted_equivalent_label_sets: Vec<Sp3FrameLabelSet>,
252    /// Whether to apply catalog Helmert transforms between known ITRF/IGS
253    /// realizations when labels differ and no assertion covers the pair.
254    pub helmert: bool,
255}
256
257impl Sp3FrameReconciliationOptions {
258    /// Construct disabled reconciliation options.
259    pub const fn disabled() -> Self {
260        Self {
261            asserted_equivalent_label_sets: Vec::new(),
262            helmert: false,
263        }
264    }
265
266    /// Construct options that enable catalog Helmert reconciliation.
267    pub const fn helmert() -> Self {
268        Self {
269            asserted_equivalent_label_sets: Vec::new(),
270            helmert: true,
271        }
272    }
273}
274
275/// A caller-asserted set of SP3 coordinate labels that may be merged as one
276/// physical frame with no coordinate math.
277#[derive(Debug, Clone, PartialEq, Eq)]
278pub struct Sp3FrameLabelSet {
279    /// Exact trimmed labels in this asserted-equivalent set.
280    pub labels: BTreeSet<String>,
281}
282
283impl Sp3FrameLabelSet {
284    /// Construct an asserted-equivalent label set from an iterator of labels.
285    pub fn new(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
286        Self {
287            labels: labels
288                .into_iter()
289                .map(|label| label.into().trim().to_string())
290                .collect(),
291        }
292    }
293
294    /// Construct a two-label asserted-equivalent set.
295    pub fn pair(a: impl Into<String>, b: impl Into<String>) -> Self {
296        Self::new([a.into(), b.into()])
297    }
298}
299
300/// One (epoch, satellite) cell the merge handled with a caveat. Nothing is
301/// dropped or averaged silently - every such cell is recorded here.
302#[derive(Debug, Clone, PartialEq)]
303pub struct MergeFlag {
304    /// The epoch.
305    pub epoch: Instant,
306    /// The satellite.
307    pub satellite: GnssSatelliteId,
308    /// The source indices (into the input slice) this flag refers to: for
309    /// `single_source`, the lone contributor; for `quarantined`, all sources
310    /// that disagreed; for `position_outliers` or `clock_outliers`, the sources
311    /// rejected from an otherwise-accepted consensus.
312    pub sources: Vec<usize>,
313}
314
315/// Per-(epoch, satellite) agreement statistics for one accepted consensus cell:
316/// how tightly the consensus member values cluster about the combined value that
317/// was actually written to the merged product.
318///
319/// The dispersion is measured about the *combined* value (the mean, median, or
320/// precedence pick - whatever the strategy wrote), not about the cluster centroid,
321/// so it reflects the agreement of the product the merge emitted. A single-source
322/// cell has one member and zero dispersion.
323#[derive(Debug, Clone, Copy, PartialEq)]
324pub struct AgreementMetric {
325    /// The epoch.
326    pub epoch: Instant,
327    /// The satellite.
328    pub satellite: GnssSatelliteId,
329    /// Number of sources in the accepted position consensus (>= 1).
330    pub position_members: usize,
331    /// RMS, over the position-consensus members, of the 3D distance from the
332    /// combined position, meters. Zero for a single-source cell.
333    pub position_rms_m: f64,
334    /// Largest 3D distance of any position-consensus member from the combined
335    /// position, meters.
336    pub position_max_m: f64,
337    /// Number of sources in the accepted clock consensus (0 when the cell carries
338    /// no clock).
339    pub clock_members: usize,
340    /// RMS, over the clock-consensus members, of the deviation from the combined
341    /// clock, seconds; `None` when the cell carries no clock.
342    pub clock_rms_s: Option<f64>,
343    /// Largest absolute clock deviation from the combined clock, seconds; `None`
344    /// when the cell carries no clock.
345    pub clock_max_s: Option<f64>,
346}
347
348/// Per-epoch aggregate of [`AgreementMetric`] over the satellites combined at that
349/// epoch, restricted to cells with a *multi-source* consensus (a single source
350/// has no measurable dispersion, so it is excluded from the aggregate spread).
351#[derive(Debug, Clone, Copy, PartialEq)]
352pub struct EpochAgreement {
353    /// The epoch.
354    pub epoch: Instant,
355    /// Satellites at this epoch with a multi-source position consensus.
356    pub satellites: usize,
357    /// Member-count-weighted pooled RMS of the per-cell position dispersion over
358    /// those satellites, meters (i.e. the RMS of every member-to-combined 3D
359    /// distance pooled across the epoch).
360    pub position_rms_m: f64,
361    /// Worst per-cell position dispersion at this epoch, meters.
362    pub position_max_m: f64,
363    /// As `position_rms_m` for the clock channel; `None` when no multi-source
364    /// clock consensus existed at this epoch.
365    pub clock_rms_s: Option<f64>,
366    /// Worst per-cell clock dispersion at this epoch, seconds; `None` as above.
367    pub clock_max_s: Option<f64>,
368}
369
370/// Mechanism used to reconcile one non-reference source's coordinate label.
371#[derive(Debug, Clone, Copy, PartialEq, Eq)]
372pub enum Sp3FrameReconciliationMethod {
373    /// Caller asserted the labels are physically equivalent; no coordinate math
374    /// was applied.
375    AssertedEquivalence,
376    /// A catalog Helmert transform, or exact identity for the same resolved
377    /// realization, reconciled the source to the target label.
378    Helmert,
379}
380
381/// Audit record for one reconciled SP3 source coordinate label.
382#[derive(Debug, Clone, PartialEq)]
383pub struct Sp3FrameReconciliation {
384    /// Source index in the input slice.
385    pub source_index: usize,
386    /// Original coordinate-system label on that source.
387    pub source_label: String,
388    /// Target coordinate-system label, taken from source 0.
389    pub target_label: String,
390    /// Mechanism selected by the explicit caller options.
391    pub method: Sp3FrameReconciliationMethod,
392    /// Caller-asserted label set used for [`Sp3FrameReconciliationMethod::AssertedEquivalence`].
393    pub asserted_label_set: Option<Vec<String>>,
394    /// Resolved source terrestrial realization for Helmert reconciliation.
395    pub source_frame: Option<TerrestrialFrame>,
396    /// Resolved target terrestrial realization for Helmert reconciliation.
397    pub target_frame: Option<TerrestrialFrame>,
398    /// Source realization of the published catalog row used for Helmert
399    /// reconciliation.
400    pub catalog_source_frame: Option<TerrestrialFrame>,
401    /// Target realization of the published catalog row used for Helmert
402    /// reconciliation.
403    pub catalog_target_frame: Option<TerrestrialFrame>,
404    /// Whether the published catalog row was applied in reverse.
405    pub catalog_inverse: bool,
406    /// Published transform reference epoch, when a non-identity catalog entry was
407    /// used.
408    pub reference_epoch_year: Option<f64>,
409    /// Published seven Helmert parameters at the reference epoch, when a
410    /// non-identity catalog entry was used.
411    pub parameters: Option<HelmertParameters>,
412    /// Published parameter rates, when a non-identity catalog entry was used.
413    pub rates: Option<HelmertRates>,
414    /// Published-table provenance for the catalog entry, when available.
415    pub provenance: Option<String>,
416    /// Decimal-year span of transformed records, inclusive, when Helmert
417    /// reconciliation was applied.
418    pub epoch_year_span: Option<[f64; 2]>,
419    /// Number of satellite position records covered by the reconciliation.
420    pub records_affected: usize,
421    /// Whether the resolved source and target realizations were identical, so
422    /// the Helmert path left coordinates bit-equal.
423    pub identity: bool,
424}
425
426/// Audit trail for a [`merge`].
427#[derive(Debug, Clone, Default, PartialEq)]
428pub struct MergeReport {
429    /// Coordinate-label reconciliations applied before source consensus.
430    pub frame_reconciliations: Vec<Sp3FrameReconciliation>,
431    /// Cells where two or more sources disagreed beyond tolerance with no
432    /// agreeing subset of `min_agree` - omitted from the merged product.
433    pub quarantined: Vec<MergeFlag>,
434    /// Cells carried from a single source (no cross-check was possible).
435    pub single_source: Vec<MergeFlag>,
436    /// Cells accepted by consensus where one or more sources were rejected as
437    /// position outliers.
438    pub position_outliers: Vec<MergeFlag>,
439    /// Clock contributors rejected from an accepted clock consensus, or every
440    /// clock contributor when an enabled consensus guard found no cluster.
441    pub clock_outliers: Vec<MergeFlag>,
442    /// Per-(epoch, satellite) agreement statistics for every accepted cell, in
443    /// output (epoch, then satellite) order - one entry per cell written to the
444    /// merged product. Quantifies how tightly the consensus sources clustered
445    /// about the combined value (Gap: per-epoch quality metrics).
446    pub agreement: Vec<AgreementMetric>,
447}
448
449impl MergeReport {
450    /// Fraction of accepted cells that were carried from a single source, in
451    /// `0.0..=1.0`; `None` when no cells were accepted.
452    ///
453    /// This is the blind-spot companion to the agreement-RMS accessors, which
454    /// quantify dispersion only over *multi-source* cells. A product can show a
455    /// tight (or `None`) agreement RMS yet be largely un-cross-checked: those
456    /// gap-fill cells (also enumerated in [`MergeReport::single_source`]) had no
457    /// second source to compare against. Read this alongside the RMS so a clean
458    /// dispersion is not mistaken for a fully corroborated product.
459    pub fn single_source_fraction(&self) -> Option<f64> {
460        let accepted = self.agreement.len();
461        (accepted > 0).then(|| self.single_source.len() as f64 / accepted as f64)
462    }
463
464    /// Member-count-weighted pooled RMS of the per-cell position dispersion over
465    /// every accepted cell with a multi-source consensus, meters. `None` when no
466    /// cell had two or more position-consensus members.
467    ///
468    /// The pool is exact: each cell contributes its summed squared member-to-
469    /// combined distances (`position_rms_m^2 * position_members`), normalised by
470    /// the total member count, so the result is the RMS of all member-to-combined
471    /// distances across the whole product.
472    ///
473    /// This covers only multi-source cells; single-source gap-fill cells are
474    /// excluded (they have no dispersion). A small or `None` result therefore does
475    /// not by itself mean the whole product was corroborated - check
476    /// [`MergeReport::single_source_fraction`] for the un-cross-checked share.
477    pub fn position_agreement_rms_m(&self) -> Option<f64> {
478        pooled_rms(
479            self.agreement
480                .iter()
481                .filter(|m| m.position_members >= 2)
482                .map(|m| (m.position_rms_m, m.position_members)),
483        )
484    }
485
486    /// Largest single-cell position dispersion over all accepted cells, meters.
487    /// `None` when there are no accepted cells.
488    pub fn position_agreement_max_m(&self) -> Option<f64> {
489        self.agreement
490            .iter()
491            .map(|m| m.position_max_m)
492            .fold(None, |acc, v| Some(fold_max(acc, v)))
493    }
494
495    /// As [`Self::position_agreement_rms_m`] for the clock channel, seconds.
496    pub fn clock_agreement_rms_s(&self) -> Option<f64> {
497        pooled_rms(self.agreement.iter().filter_map(|m| {
498            m.clock_rms_s
499                .filter(|_| m.clock_members >= 2)
500                .map(|rms| (rms, m.clock_members))
501        }))
502    }
503
504    /// Largest single-cell clock dispersion over all accepted cells, seconds.
505    pub fn clock_agreement_max_s(&self) -> Option<f64> {
506        self.agreement
507            .iter()
508            .filter_map(|m| m.clock_max_s)
509            .fold(None, |acc, v| Some(fold_max(acc, v)))
510    }
511
512    /// Per-epoch aggregate agreement, in output-epoch order. Each entry pools the
513    /// multi-source cells at that epoch (see [`EpochAgreement`]); epochs whose
514    /// cells were all single-source are still listed with `satellites == 0` and a
515    /// zero position spread so the caller sees every output epoch.
516    pub fn per_epoch_agreement(&self) -> Vec<EpochAgreement> {
517        let mut out: Vec<EpochAgreement> = Vec::new();
518        let mut current_key: Option<i64> = None;
519        for m in &self.agreement {
520            let key = instant_to_j2000_seconds(&m.epoch).map(|s| s.floor() as i64);
521            if current_key != key || out.is_empty() {
522                out.push(EpochAgreement {
523                    epoch: m.epoch,
524                    satellites: 0,
525                    position_rms_m: 0.0,
526                    position_max_m: 0.0,
527                    clock_rms_s: None,
528                    clock_max_s: None,
529                });
530                current_key = key;
531            }
532            let agg = out.last_mut().expect("just pushed");
533            agg.position_max_m = agg.position_max_m.max(m.position_max_m);
534            if m.position_members >= 2 {
535                agg.satellites += 1;
536            }
537            // Only multi-source clock cells contribute to the epoch clock max,
538            // matching the RMS path: a single-member cell has zero dispersion and
539            // must not leave clock_max_s = Some(0.0) while clock_rms_s is None.
540            if let Some(max) = m.clock_max_s.filter(|_| m.clock_members >= 2) {
541                agg.clock_max_s = Some(fold_max(agg.clock_max_s, max));
542            }
543        }
544
545        // Pooled RMS per epoch needs the sum of squared distances, which the per
546        // entry RMS encodes; recompute it in a second pass grouped by epoch key.
547        for agg in &mut out {
548            let key = instant_to_j2000_seconds(&agg.epoch).map(|s| s.floor() as i64);
549            agg.position_rms_m = pooled_rms(
550                self.agreement
551                    .iter()
552                    .filter(|m| {
553                        m.position_members >= 2
554                            && instant_to_j2000_seconds(&m.epoch).map(|s| s.floor() as i64) == key
555                    })
556                    .map(|m| (m.position_rms_m, m.position_members)),
557            )
558            .unwrap_or(0.0);
559            agg.clock_rms_s = pooled_rms(
560                self.agreement
561                    .iter()
562                    .filter(|m| instant_to_j2000_seconds(&m.epoch).map(|s| s.floor() as i64) == key)
563                    .filter_map(|m| {
564                        m.clock_rms_s
565                            .filter(|_| m.clock_members >= 2)
566                            .map(|rms| (rms, m.clock_members))
567                    }),
568            );
569        }
570
571        out
572    }
573}
574
575/// Pool per-cell RMS values weighted by member count into one RMS:
576/// `sqrt(sum(rms_i^2 * n_i) / sum(n_i))`. `None` when the iterator is empty.
577fn pooled_rms(cells: impl Iterator<Item = (f64, usize)>) -> Option<f64> {
578    let mut sumsq = 0.0_f64;
579    let mut total = 0_usize;
580    for (rms, n) in cells {
581        sumsq += rms * rms * n as f64;
582        total += n;
583    }
584    (total > 0).then(|| (sumsq / total as f64).sqrt())
585}
586
587/// `max` reduction over an `Option` accumulator (`None` is the empty identity).
588fn fold_max(acc: Option<f64>, value: f64) -> f64 {
589    match acc {
590        Some(current) if current >= value => current,
591        _ => value,
592    }
593}
594
595/// Merge several SP3 products from different analysis centers into one
596/// consistent precise-ephemeris dataset.
597///
598/// Orthogonal to time-stitching: this combines providers at the **same** epochs.
599/// Inputs must each have a uniform epoch grid. Mixed-cadence products are
600/// unioned onto the finest input cadence by default (or an explicit compatible
601/// target cadence), using only epochs actually present in an input and never
602/// interpolating. For every (epoch, satellite) cell on that union grid:
603///
604/// - **Union satellite coverage.** A satellite present in any input may appear
605///   in the output at every union-grid epoch where an input carries that cell.
606/// - **Position consensus.** With one source the value is carried through
607///   (`single_source`). With several, the largest subset of sources mutually
608///   within `position_tolerance_m` is found; if it has at least `min_agree`
609///   members it is combined per `combine` and any sources outside it are recorded
610///   as `position_outliers`. If no such subset exists the cell is `quarantined`
611///   (omitted) - never averaged across disagreeing centers.
612/// - **Clock consensus.** Clocks are first put on a common datum (each source
613///   aligned to the first via [`clock_reference_offset`]), then combined by the
614///   same agreement rule; a cell with no clock consensus carries no clock. A
615///   non-reference source's datum offset is linearly interpolated between
616///   bracketing epochs where at least `clock_min_common` common clocks made it
617///   observable. Outside that bracket, or when no bracket exists, the source
618///   contributes **no** clock rather than an unaligned one; its position is
619///   still merged.
620///
621/// `Precedence` is resolved per cell by default, so a lower-precedence source
622/// fills a cell missing from all earlier sources. Whole-satellite-arc ownership
623/// remains available through [`MergePrecedenceScope::SatelliteArc`]. The
624/// optional [`OutlierRejectOptions`] independently guards contested precedence
625/// cells: the deterministic largest mutually-agreeing cluster must contain at
626/// least `max(min_agree, 2)` sources. The preferred source is retained when it
627/// belongs to that cluster; otherwise the earliest-listed cluster member wins.
628///
629/// All inputs must share an exact SP3 time-system label. Coordinate-system
630/// labels must also match unless [`MergeOptions::frame_reconciliation`] opts
631/// into a caller assertion or catalog Helmert reconciliation; every such
632/// reconciliation is recorded in [`MergeReport::frame_reconciliations`].
633/// Otherwise coordinate-label mismatches are rejected. The merged record flags
634/// are the union (OR) of the contributing sources' flags - in particular a
635/// `clock_event` on any clock-consensus member is preserved, so the interpolator
636/// still splits the clock arc. The merged header is **synthetic**: its
637/// first-epoch fields describe the union's first epoch and its data type is
638/// position-only.
639///
640/// Pure and deterministic: order the inputs by center precedence and ties (equal
641/// cluster sizes, `Precedence` combine) resolve to the earliest-listed source.
642/// The merged product's interpolation nodes are the consensus values, so it
643/// samples and interpolates like any other [`Sp3`] (it is a derived combination,
644/// not a byte-faithful copy of any one center). Consensus is exact max-clique for
645/// normal source counts and uses a deterministic greedy fallback above the exact
646/// search cap, so hostile disagreement graphs remain bounded.
647pub fn merge(sources: &[Sp3], opts: &MergeOptions) -> Result<(Sp3, MergeReport)> {
648    if sources.is_empty() {
649        return Err(Error::InvalidInput(
650            "merge requires at least one SP3 product".into(),
651        ));
652    }
653
654    validate_merge_options(opts)?;
655
656    // Inputs must be combinable: epochs are matched in one exact product time
657    // system, and positions are only comparable in an exactly common coordinate
658    // system / frame unless the caller explicitly opted into one of the audited
659    // reconciliation mechanisms below.
660    let base = &sources[0].header;
661    for s in &sources[1..] {
662        if s.header.time_system != base.time_system {
663            return Err(Error::InvalidInput(format!(
664                "merge inputs have mismatched SP3 time systems ({:?} vs {:?})",
665                base.time_system, s.header.time_system
666            )));
667        }
668    }
669
670    let (prepared_sources, frame_reconciliations) = reconcile_sp3_coordinate_labels(sources, opts)?;
671    let sources = prepared_sources.as_slice();
672
673    // floored-J2000-second -> epoch index, per source.
674    let epoch_index: Vec<BTreeMap<i64, usize>> = sources
675        .iter()
676        .map(|s| {
677            s.epochs
678                .iter()
679                .enumerate()
680                .filter_map(|(i, ep)| {
681                    sp3_epoch_j2000_seconds(s, i, ep).map(|sec| (sec.floor() as i64, i))
682                })
683                .collect()
684        })
685        .collect();
686
687    let epoch_interval_s = resolve_common_epoch_interval(sources, opts.target_epoch_interval_s)?;
688
689    // Per-source per-epoch clock-datum offset relative to source 0. Source 0 is
690    // the datum, so its offset is identically zero.
691    let clock_offset: Vec<BTreeMap<i64, f64>> = sources
692        .iter()
693        .enumerate()
694        .map(|(idx, s)| {
695            if idx == 0 {
696                BTreeMap::new()
697            } else {
698                clock_reference_offset(&sources[0], s, opts.clock_min_common)
699                    .into_iter()
700                    .filter_map(|o| {
701                        instant_to_j2000_seconds(&o.epoch)
702                            .map(|sec| (sec.floor() as i64, o.offset_s))
703                    })
704                    .collect()
705            }
706        })
707        .collect();
708
709    // Union of epochs (by floored second), retaining the representative Instant
710    // from the earliest-listed source on duplicate keys. This is what lets a
711    // dense source fill cells absent from a sparse preferred source.
712    let mut epoch_keys: BTreeMap<i64, Instant> = BTreeMap::new();
713    for source in sources {
714        for (idx, ep) in source.epochs.iter().enumerate() {
715            if let Some(sec) = sp3_epoch_j2000_seconds(source, idx, ep) {
716                epoch_keys.entry(sec.floor() as i64).or_insert(*ep);
717            }
718        }
719    }
720
721    // Restrict the union to the resolved output grid (anchored at the earliest
722    // union epoch), dropping off-grid epochs by exact subset selection. This is
723    // a no-op at the default finest cadence and performs deterministic
724    // decimation for an explicit coarser target.
725    if let Some((&anchor, _)) = epoch_keys.iter().next() {
726        let step = epoch_interval_s.round() as i64;
727        if step > 0 {
728            epoch_keys.retain(|&key, _| (key - anchor).rem_euclid(step) == 0);
729        }
730    }
731
732    if epoch_keys.is_empty() {
733        return Err(Error::InvalidInput(
734            "merge inputs have no epochs on the requested time grid".into(),
735        ));
736    }
737
738    let precedence_source_for_sat = if opts.combine == MergeCombine::Precedence
739        && opts.precedence_scope == MergePrecedenceScope::SatelliteArc
740    {
741        Some(precedence_sources_for_satellites(
742            sources,
743            &epoch_index,
744            &epoch_keys,
745            opts.systems.as_ref(),
746        ))
747    } else {
748        None
749    };
750
751    let allowed_system = |sat: &GnssSatelliteId| {
752        opts.systems
753            .as_ref()
754            .is_none_or(|systems| systems.contains(&sat.system))
755    };
756
757    let mut out_epochs: Vec<Instant> = Vec::with_capacity(epoch_keys.len());
758    let mut out_epoch_j2000_s: Vec<f64> = Vec::with_capacity(epoch_keys.len());
759    let mut out_states: Vec<BTreeMap<GnssSatelliteId, Sp3State>> =
760        Vec::with_capacity(epoch_keys.len());
761    let mut out_raw: Vec<BTreeMap<GnssSatelliteId, RawNode>> = Vec::with_capacity(epoch_keys.len());
762    let mut report = MergeReport {
763        frame_reconciliations,
764        ..MergeReport::default()
765    };
766    let mut all_sats: BTreeSet<GnssSatelliteId> = BTreeSet::new();
767
768    for (&key, &epoch) in &epoch_keys {
769        out_epochs.push(epoch);
770        out_epoch_j2000_s.push(key as f64);
771        let mut states: BTreeMap<GnssSatelliteId, Sp3State> = BTreeMap::new();
772        let mut raws: BTreeMap<GnssSatelliteId, RawNode> = BTreeMap::new();
773
774        // Satellites present at this epoch in any source, after any requested
775        // constellation filter.
776        let mut sats: BTreeSet<GnssSatelliteId> = BTreeSet::new();
777        for (idx, s) in sources.iter().enumerate() {
778            if let Some(&ei) = epoch_index[idx].get(&key) {
779                if let Ok(map) = s.states_at(ei) {
780                    sats.extend(map.keys().copied().filter(|sat| allowed_system(sat)));
781                }
782            }
783        }
784
785        for sat in sats {
786            // (source_idx, position_m, flags) and (source_idx, datum-aligned
787            // clock_s, flags). A non-reference source contributes a clock only
788            // when its datum offset can be estimated exactly or between
789            // bracketing estimates; otherwise its clock would be unaligned, so
790            // it is omitted (the position is still gathered).
791            let arc_preferred_source = precedence_source_for_sat
792                .as_ref()
793                .and_then(|by_sat| by_sat.get(&sat).copied());
794
795            let mut pos: Vec<(usize, [f64; 3], Sp3Flags)> = Vec::new();
796            let mut clk: Vec<(usize, f64, Sp3Flags)> = Vec::new();
797            for (idx, s) in sources.iter().enumerate() {
798                let Some(&ei) = epoch_index[idx].get(&key) else {
799                    continue;
800                };
801                let Ok(map) = s.states_at(ei) else { continue };
802                let Some(state) = map.get(&sat) else { continue };
803                pos.push((idx, state.position.as_array(), state.flags));
804                if let Some(c) = state.clock_s {
805                    let offset = if idx == 0 {
806                        Some(0.0)
807                    } else {
808                        clock_offset_at(&clock_offset[idx], key)
809                    };
810                    if let Some(off) = offset {
811                        let aligned = c - off;
812                        if aligned.is_finite() {
813                            clk.push((idx, aligned, state.flags));
814                        }
815                    }
816                }
817            }
818
819            let position_preferred_source = match opts.precedence_scope {
820                MergePrecedenceScope::Cell => pos.first().map(|(source, _, _)| *source),
821                MergePrecedenceScope::SatelliteArc => arc_preferred_source,
822            };
823            let clock_preferred_source = match opts.precedence_scope {
824                MergePrecedenceScope::Cell => clk.first().map(|(source, _, _)| *source),
825                MergePrecedenceScope::SatelliteArc => arc_preferred_source,
826            };
827
828            let flag = |srcs: Vec<usize>| MergeFlag {
829                epoch,
830                satellite: sat,
831                sources: srcs,
832            };
833
834            // Position consensus -> the merged position and the indices (into
835            // `pos`) of the sources that contributed it. Cell precedence selects
836            // the first source present here; satellite-arc precedence can leave
837            // a deliberate hole when the arc owner is missing.
838            let (position_m, pos_members) = if opts.combine == MergeCombine::Precedence {
839                let Some(preferred_source) = position_preferred_source else {
840                    continue;
841                };
842                let Some(preferred_idx) =
843                    pos.iter().position(|(src, _, _)| *src == preferred_source)
844                else {
845                    continue;
846                };
847
848                if pos.len() == 1 {
849                    report.single_source.push(flag(vec![pos[preferred_idx].0]));
850                    (pos[preferred_idx].1, vec![preferred_idx])
851                } else if let Some(reject) = opts.outlier_reject {
852                    let pts: Vec<[f64; 3]> = pos.iter().map(|(_, p, _)| *p).collect();
853                    let cluster =
854                        largest_within(&pts, |a, b| dist3(a, b) <= reject.position_tolerance_m);
855                    if cluster.len() >= opts.min_agree.max(2) {
856                        let selected_idx = if cluster.contains(&preferred_idx) {
857                            preferred_idx
858                        } else {
859                            cluster[0]
860                        };
861                        let rejected: Vec<usize> = (0..pos.len())
862                            .filter(|i| !cluster.contains(i))
863                            .map(|i| pos[i].0)
864                            .collect();
865                        if !rejected.is_empty() {
866                            report.position_outliers.push(flag(rejected));
867                        }
868                        (pos[selected_idx].1, cluster)
869                    } else {
870                        report
871                            .quarantined
872                            .push(flag(pos.iter().map(|(i, _, _)| *i).collect()));
873                        continue;
874                    }
875                } else {
876                    let pts: Vec<[f64; 3]> = pos.iter().map(|(_, p, _)| *p).collect();
877                    let cluster = largest_within_containing(&pts, preferred_idx, |a, b| {
878                        dist3(a, b) <= opts.position_tolerance_m
879                    });
880                    if cluster.len() >= opts.min_agree {
881                        let rejected: Vec<usize> = (0..pos.len())
882                            .filter(|i| !cluster.contains(i))
883                            .map(|i| pos[i].0)
884                            .collect();
885                        if !rejected.is_empty() {
886                            report.position_outliers.push(flag(rejected));
887                        }
888                        (pos[preferred_idx].1, cluster)
889                    } else {
890                        report
891                            .quarantined
892                            .push(flag(pos.iter().map(|(i, _, _)| *i).collect()));
893                        continue;
894                    }
895                }
896            } else if pos.len() == 1 {
897                report.single_source.push(flag(vec![pos[0].0]));
898                (pos[0].1, vec![0usize])
899            } else {
900                let pts: Vec<[f64; 3]> = pos.iter().map(|(_, p, _)| *p).collect();
901                let cluster = largest_within(&pts, |a, b| dist3(a, b) <= opts.position_tolerance_m);
902                if cluster.len() >= opts.min_agree {
903                    let rejected: Vec<usize> = (0..pos.len())
904                        .filter(|i| !cluster.contains(i))
905                        .map(|i| pos[i].0)
906                        .collect();
907                    if !rejected.is_empty() {
908                        report.position_outliers.push(flag(rejected));
909                    }
910                    let members: Vec<(usize, [f64; 3])> =
911                        cluster.iter().map(|&i| (pos[i].0, pos[i].1)).collect();
912                    (combine3(&members, opts.combine), cluster)
913                } else {
914                    report
915                        .quarantined
916                        .push(flag(pos.iter().map(|(i, _, _)| *i).collect()));
917                    continue;
918                }
919            };
920
921            // Clock consensus, independent of position -> the merged clock and the
922            // indices (into `clk`) of the sources that contributed it.
923            let (clock_s, clk_members): (Option<f64>, Vec<usize>) = if clk.is_empty() {
924                (None, Vec::new())
925            } else if opts.combine == MergeCombine::Precedence {
926                match clock_preferred_source
927                    .and_then(|src| clk.iter().position(|(clock_src, _, _)| *clock_src == src))
928                {
929                    None => (None, Vec::new()),
930                    Some(preferred_idx) if clk.len() == 1 => {
931                        (Some(clk[preferred_idx].1), vec![preferred_idx])
932                    }
933                    Some(preferred_idx) if opts.outlier_reject.is_some() => {
934                        let reject = opts.outlier_reject.expect("checked above");
935                        let vals: Vec<f64> = clk.iter().map(|(_, c, _)| *c).collect();
936                        let cluster =
937                            largest_within(&vals, |a, b| (a - b).abs() <= reject.clock_tolerance_s);
938                        if cluster.len() >= opts.min_agree.max(2) {
939                            let selected_idx = if cluster.contains(&preferred_idx) {
940                                preferred_idx
941                            } else {
942                                cluster[0]
943                            };
944                            let rejected: Vec<usize> = (0..clk.len())
945                                .filter(|i| !cluster.contains(i))
946                                .map(|i| clk[i].0)
947                                .collect();
948                            if !rejected.is_empty() {
949                                report.clock_outliers.push(flag(rejected));
950                            }
951                            (Some(clk[selected_idx].1), cluster)
952                        } else {
953                            report
954                                .clock_outliers
955                                .push(flag(clk.iter().map(|(source, _, _)| *source).collect()));
956                            (None, Vec::new())
957                        }
958                    }
959                    Some(preferred_idx) => {
960                        let vals: Vec<f64> = clk.iter().map(|(_, c, _)| *c).collect();
961                        let cluster = largest_within_containing(&vals, preferred_idx, |a, b| {
962                            (a - b).abs() <= opts.clock_tolerance_s
963                        });
964                        if cluster.len() >= opts.min_agree {
965                            let rejected: Vec<usize> = (0..clk.len())
966                                .filter(|i| !cluster.contains(i))
967                                .map(|i| clk[i].0)
968                                .collect();
969                            if !rejected.is_empty() {
970                                report.clock_outliers.push(flag(rejected));
971                            }
972                            (Some(clk[preferred_idx].1), cluster)
973                        } else {
974                            (None, Vec::new())
975                        }
976                    }
977                }
978            } else if clk.len() == 1 {
979                (Some(clk[0].1), vec![0usize])
980            } else {
981                let vals: Vec<f64> = clk.iter().map(|(_, c, _)| *c).collect();
982                let cluster = largest_within(&vals, |a, b| (a - b).abs() <= opts.clock_tolerance_s);
983                if cluster.len() >= opts.min_agree {
984                    let rejected: Vec<usize> = (0..clk.len())
985                        .filter(|i| !cluster.contains(i))
986                        .map(|i| clk[i].0)
987                        .collect();
988                    if !rejected.is_empty() {
989                        report.clock_outliers.push(flag(rejected));
990                    }
991                    let members: Vec<(usize, f64)> =
992                        cluster.iter().map(|&i| (clk[i].0, clk[i].1)).collect();
993                    (Some(combine_axis(&members, opts.combine)), cluster)
994                } else {
995                    (None, Vec::new())
996                }
997            };
998
999            // Preserve record flags: OR the orbit flags across the position
1000            // members and the clock flags across the clock members, so a
1001            // `clock_event` (clock reset) or maneuver on any contributing source
1002            // survives into the merged product.
1003            let mut flags = Sp3Flags::default();
1004            for &i in &pos_members {
1005                flags.maneuver |= pos[i].2.maneuver;
1006                flags.orbit_predicted |= pos[i].2.orbit_predicted;
1007            }
1008            for &i in &clk_members {
1009                flags.clock_event |= clk[i].2.clock_event;
1010                flags.clock_predicted |= clk[i].2.clock_predicted;
1011            }
1012
1013            // Per-cell agreement: dispersion of the accepted consensus members
1014            // about the combined value actually written below.
1015            let (position_rms_m, position_max_m) =
1016                position_dispersion(&pos, &pos_members, &position_m);
1017            let (clock_members_n, clock_rms_s, clock_max_s) = match clock_s {
1018                Some(c) => {
1019                    let (rms, max) = clock_dispersion(&clk, &clk_members, c);
1020                    (clk_members.len(), Some(rms), Some(max))
1021                }
1022                None => (0, None, None),
1023            };
1024            report.agreement.push(AgreementMetric {
1025                epoch,
1026                satellite: sat,
1027                position_members: pos_members.len(),
1028                position_rms_m,
1029                position_max_m,
1030                clock_members: clock_members_n,
1031                clock_rms_s,
1032                clock_max_s,
1033            });
1034
1035            all_sats.insert(sat);
1036            states.insert(
1037                sat,
1038                Sp3State {
1039                    position: ItrfPositionM::new(position_m[0], position_m[1], position_m[2])
1040                        .expect("valid ITRF position"),
1041                    clock_s,
1042                    velocity: None,
1043                    clock_rate_s_s: None,
1044                    flags,
1045                },
1046            );
1047            raws.insert(
1048                sat,
1049                RawNode {
1050                    km: [
1051                        position_m[0] / KM_TO_M,
1052                        position_m[1] / KM_TO_M,
1053                        position_m[2] / KM_TO_M,
1054                    ],
1055                    clock_us: clock_s.map(|c| c * 1.0e6),
1056                    clock_event: flags.clock_event,
1057                },
1058            );
1059        }
1060
1061        out_states.push(states);
1062        out_raw.push(raws);
1063    }
1064
1065    // Base the non-epoch metadata on a source product, but derive the first-epoch
1066    // header fields from the merged grid itself. Mixed cadence / coverage can make
1067    // the merged first epoch later than every input's first epoch, so cloning
1068    // those fields from any input would make the `##` line stale.
1069    let first_key = Some(out_epoch_j2000_s[0].floor() as i64);
1070    let base_idx = sources
1071        .iter()
1072        .position(|s| {
1073            s.epochs
1074                .first()
1075                .and_then(|ep| sp3_epoch_j2000_seconds(s, 0, ep))
1076                .map(|sec| sec.floor() as i64)
1077                == first_key
1078        })
1079        .or_else(|| {
1080            sources
1081                .iter()
1082                .enumerate()
1083                .filter_map(|(i, s)| {
1084                    s.epochs
1085                        .first()
1086                        .and_then(|ep| sp3_epoch_j2000_seconds(s, 0, ep))
1087                        .map(|sec| (sec, i))
1088                })
1089                .min_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)))
1090                .map(|(_, i)| i)
1091        })
1092        .unwrap_or(0);
1093    let first_epoch_header = first_epoch_header_fields(&out_epochs[0]).ok_or_else(|| {
1094        Error::InvalidInput("merged SP3 first epoch cannot be represented in header fields".into())
1095    })?;
1096
1097    let satellites: Vec<_> = all_sats.into_iter().collect();
1098    let satellite_accuracy_codes = satellites
1099        .iter()
1100        .map(|sat| {
1101            sources[base_idx]
1102                .header
1103                .satellites
1104                .iter()
1105                .position(|base_sat| base_sat == sat)
1106                .and_then(|idx| {
1107                    sources[base_idx]
1108                        .header
1109                        .satellite_accuracy_codes
1110                        .get(idx)
1111                        .copied()
1112                })
1113                .unwrap_or(0)
1114        })
1115        .collect();
1116
1117    let header = Sp3Header {
1118        num_epochs: out_epochs.len() as u64,
1119        satellites,
1120        satellite_accuracy_codes,
1121        data_type: Sp3DataType::Position,
1122        gnss_week: first_epoch_header.gnss_week,
1123        seconds_of_week: first_epoch_header.seconds_of_week,
1124        epoch_interval_s,
1125        mjd: first_epoch_header.mjd,
1126        mjd_fraction: first_epoch_header.mjd_fraction,
1127        ..sources[base_idx].header.clone()
1128    };
1129
1130    let mandatory_header_lines = 5.max(header.satellites.len().div_ceil(17));
1131    let declared_satellite_tokens = header
1132        .satellites
1133        .iter()
1134        .map(ToString::to_string)
1135        .collect::<Vec<_>>();
1136    let epoch_position_tokens = vec![declared_satellite_tokens.clone(); out_epochs.len()];
1137    let epoch_state_record_sequence = epoch_position_tokens
1138        .iter()
1139        .map(|tokens| {
1140            tokens
1141                .iter()
1142                .cloned()
1143                .map(|token| ('P', token))
1144                .collect::<Vec<_>>()
1145        })
1146        .collect::<Vec<_>>();
1147    let merged = Sp3 {
1148        header,
1149        epochs: out_epochs,
1150        declared_num_epochs: out_epoch_j2000_s.len() as u64,
1151        declared_start_j2000_s: out_epoch_j2000_s.first().copied(),
1152        had_eof: true,
1153        trailing_content_after_eof: false,
1154        satellite_header_lines: mandatory_header_lines,
1155        accuracy_header_lines: mandatory_header_lines,
1156        time_system_header_lines: 2,
1157        float_header_lines: 2,
1158        integer_header_lines: 2,
1159        header_comment_lines: 4,
1160        declared_satellite_count: Some(declared_satellite_tokens.len()),
1161        declared_satellite_tokens,
1162        epoch_velocity_tokens: vec![Vec::new(); epoch_position_tokens.len()],
1163        epoch_position_tokens,
1164        epoch_state_record_sequence,
1165        epoch_j2000_s: out_epoch_j2000_s,
1166        states: out_states,
1167        interp_raw: out_raw,
1168        comments: vec![format!("MERGED from {} SP3 products", sources.len())],
1169        skipped_records: sources.iter().map(|s| s.skipped_records).sum(),
1170    };
1171
1172    Ok((merged, report))
1173}
1174
1175fn reconcile_sp3_coordinate_labels(
1176    sources: &[Sp3],
1177    opts: &MergeOptions,
1178) -> Result<(Vec<Sp3>, Vec<Sp3FrameReconciliation>)> {
1179    let target_label = normalized_sp3_frame_label(&sources[0].header.coordinate_system);
1180    let mut prepared = sources.to_vec();
1181    let mut report = Vec::new();
1182
1183    for idx in 1..sources.len() {
1184        let source_label = normalized_sp3_frame_label(&sources[idx].header.coordinate_system);
1185        if source_label == target_label {
1186            continue;
1187        }
1188
1189        if let Some(asserted) = asserted_frame_label_set(
1190            &source_label,
1191            &target_label,
1192            &opts.frame_reconciliation.asserted_equivalent_label_sets,
1193        ) {
1194            prepared[idx].header.coordinate_system = target_label.clone();
1195            report.push(Sp3FrameReconciliation {
1196                source_index: idx,
1197                source_label,
1198                target_label: target_label.clone(),
1199                method: Sp3FrameReconciliationMethod::AssertedEquivalence,
1200                asserted_label_set: Some(asserted),
1201                source_frame: None,
1202                target_frame: None,
1203                catalog_source_frame: None,
1204                catalog_target_frame: None,
1205                catalog_inverse: false,
1206                reference_epoch_year: None,
1207                parameters: None,
1208                rates: None,
1209                provenance: None,
1210                epoch_year_span: None,
1211                records_affected: count_position_records(&sources[idx]),
1212                identity: true,
1213            });
1214            continue;
1215        }
1216
1217        if opts.frame_reconciliation.helmert {
1218            let from = sp3_coordinate_label_frame(&source_label).ok_or_else(|| {
1219                Error::InvalidInput(format!(
1220                    "merge inputs have mismatched coordinate systems ({:?} vs {:?}); source label {:?} is not a known ITRF/IGS realization",
1221                    sources[0].header.coordinate_system,
1222                    sources[idx].header.coordinate_system,
1223                    sources[idx].header.coordinate_system
1224                ))
1225            })?;
1226            let to = sp3_coordinate_label_frame(&target_label).ok_or_else(|| {
1227                Error::InvalidInput(format!(
1228                    "merge inputs have mismatched coordinate systems ({:?} vs {:?}); target label {:?} is not a known ITRF/IGS realization",
1229                    sources[0].header.coordinate_system,
1230                    sources[idx].header.coordinate_system,
1231                    sources[0].header.coordinate_system
1232                ))
1233            })?;
1234
1235            let transform_report = reconcile_source_by_helmert(
1236                &mut prepared[idx],
1237                idx,
1238                source_label,
1239                target_label.clone(),
1240                from,
1241                to,
1242            )?;
1243            report.push(transform_report);
1244            continue;
1245        }
1246
1247        return Err(Error::InvalidInput(format!(
1248            "merge inputs have mismatched coordinate systems ({:?} vs {:?})",
1249            sources[0].header.coordinate_system, sources[idx].header.coordinate_system
1250        )));
1251    }
1252
1253    Ok((prepared, report))
1254}
1255
1256fn asserted_frame_label_set(
1257    source_label: &str,
1258    target_label: &str,
1259    label_sets: &[Sp3FrameLabelSet],
1260) -> Option<Vec<String>> {
1261    label_sets.iter().find_map(|set| {
1262        if set.labels.contains(source_label) && set.labels.contains(target_label) {
1263            Some(set.labels.iter().cloned().collect())
1264        } else {
1265            None
1266        }
1267    })
1268}
1269
1270fn reconcile_source_by_helmert(
1271    source: &mut Sp3,
1272    source_index: usize,
1273    source_label: String,
1274    target_label: String,
1275    from: TerrestrialFrame,
1276    to: TerrestrialFrame,
1277) -> Result<Sp3FrameReconciliation> {
1278    let records_affected = count_position_records(source);
1279    let epoch_year_span = epoch_year_span(source);
1280    let identity = from == to;
1281
1282    if !identity {
1283        transform_sp3_positions(source, from, to)?;
1284    }
1285    source.header.coordinate_system = target_label.clone();
1286
1287    let published = published_transform_for_report(from, to);
1288    Ok(Sp3FrameReconciliation {
1289        source_index,
1290        source_label,
1291        target_label,
1292        method: Sp3FrameReconciliationMethod::Helmert,
1293        asserted_label_set: None,
1294        source_frame: Some(from),
1295        target_frame: Some(to),
1296        catalog_source_frame: published.map(|published| published.entry.from),
1297        catalog_target_frame: published.map(|published| published.entry.to),
1298        catalog_inverse: published.is_some_and(|published| published.inverse),
1299        reference_epoch_year: published.map(|published| published.entry.reference_epoch_year),
1300        parameters: published.map(|published| published.entry.parameters),
1301        rates: published.map(|published| published.entry.rates),
1302        provenance: published.map(|published| published.entry.provenance.to_string()),
1303        epoch_year_span,
1304        records_affected,
1305        identity,
1306    })
1307}
1308
1309fn transform_sp3_positions(
1310    source: &mut Sp3,
1311    from: TerrestrialFrame,
1312    to: TerrestrialFrame,
1313) -> Result<()> {
1314    let seconds_per_julian_year = DAYS_PER_JULIAN_YEAR * SECONDS_PER_DAY;
1315    for epoch_idx in 0..source.epochs.len() {
1316        let epoch_year = decimal_year(source.epochs[epoch_idx]);
1317        let states = &mut source.states[epoch_idx];
1318        let raw_nodes = &mut source.interp_raw[epoch_idx];
1319        for (sat, state) in states.iter_mut() {
1320            let position = TerrestrialPositionM::from_itrf(state.position);
1321            let velocity = state
1322                .velocity
1323                .map(|velocity| {
1324                    let [vx, vy, vz] = velocity.as_array();
1325                    TerrestrialVelocityMPerYear::new(
1326                        vx * seconds_per_julian_year,
1327                        vy * seconds_per_julian_year,
1328                        vz * seconds_per_julian_year,
1329                    )
1330                })
1331                .transpose()
1332                .map_err(|error| Error::InvalidInput(error.to_string()))?;
1333            let transformed = frame_catalog::transform(position, velocity, from, to, epoch_year)
1334                .map_err(|error| Error::InvalidInput(error.to_string()))?;
1335            let [x, y, z] = transformed.position.as_array();
1336            state.position = ItrfPositionM::new(x, y, z)
1337                .map_err(|error| Error::InvalidInput(error.to_string()))?;
1338            state.velocity = transformed
1339                .velocity
1340                .map(|velocity| {
1341                    let [vx, vy, vz] = velocity.as_array();
1342                    ItrfVelocityMS::new(
1343                        vx / seconds_per_julian_year,
1344                        vy / seconds_per_julian_year,
1345                        vz / seconds_per_julian_year,
1346                    )
1347                })
1348                .transpose()
1349                .map_err(|error| Error::InvalidInput(error.to_string()))?;
1350            if let Some(raw) = raw_nodes.get_mut(sat) {
1351                raw.km = [x / KM_TO_M, y / KM_TO_M, z / KM_TO_M];
1352            }
1353        }
1354    }
1355    Ok(())
1356}
1357
1358fn count_position_records(source: &Sp3) -> usize {
1359    source.states.iter().map(BTreeMap::len).sum()
1360}
1361
1362fn epoch_year_span(source: &Sp3) -> Option<[f64; 2]> {
1363    let first = source.epochs.first().copied().map(decimal_year)?;
1364    let last = source.epochs.last().copied().map(decimal_year)?;
1365    Some([first, last])
1366}
1367
1368fn decimal_year(epoch: Instant) -> f64 {
1369    let jd_midnight = julian_date_from_instant(epoch) + 0.5;
1370    let (year, _, _) = civil_from_julian_day_number(jd_midnight.floor() as i64);
1371    let days = if is_leap_year(year) { 366.0 } else { 365.0 };
1372    year as f64 + (fractional_day_of_year_from_instant(epoch) - 1.0) / days
1373}
1374
1375fn normalized_sp3_frame_label(label: &str) -> String {
1376    label.trim().to_string()
1377}
1378
1379fn sp3_coordinate_label_frame(label: &str) -> Option<TerrestrialFrame> {
1380    match label.trim() {
1381        "ITRF2020" | "ITRF20" | "IGS20" | "IGc20" => Some(TerrestrialFrame::Itrf2020),
1382        "ITRF2014" | "ITRF14" | "IGS14" | "IGb14" => Some(TerrestrialFrame::Itrf2014),
1383        "ITRF2008" | "ITRF08" | "IGS08" | "IGb08" => Some(TerrestrialFrame::Itrf2008),
1384        _ => None,
1385    }
1386}
1387
1388fn published_transform_for_report(
1389    from: TerrestrialFrame,
1390    to: TerrestrialFrame,
1391) -> Option<PublishedTransformForReport> {
1392    frame_catalog::catalog_entry(from, to)
1393        .map(|entry| PublishedTransformForReport {
1394            entry,
1395            inverse: false,
1396        })
1397        .or_else(|| {
1398            frame_catalog::catalog_entry(to, from).map(|entry| PublishedTransformForReport {
1399                entry,
1400                inverse: true,
1401            })
1402        })
1403}
1404
1405#[derive(Debug, Clone, Copy)]
1406struct PublishedTransformForReport {
1407    entry: &'static frame_catalog::HelmertTransform,
1408    inverse: bool,
1409}
1410
1411#[derive(Debug, Clone, Copy)]
1412struct FirstEpochHeaderFields {
1413    gnss_week: u32,
1414    seconds_of_week: f64,
1415    mjd: u32,
1416    mjd_fraction: f64,
1417}
1418
1419fn first_epoch_header_fields(epoch: &Instant) -> Option<FirstEpochHeaderFields> {
1420    let split = epoch.julian_date()?;
1421
1422    let mjd_day = mjd_from_jd(split.jd_whole);
1423    let mut mjd = mjd_day.floor();
1424    let mut mjd_fraction = split.fraction + (mjd_day - mjd);
1425    let fraction_days = mjd_fraction.floor();
1426    if fraction_days != 0.0 {
1427        mjd += fraction_days;
1428        mjd_fraction -= fraction_days;
1429    }
1430    if !(0.0..=u32::MAX as f64).contains(&mjd) {
1431        return None;
1432    }
1433
1434    let gps_seconds = instant_to_j2000_seconds(epoch)? + GPS_EPOCH_TO_J2000_S;
1435    let (gnss_week, seconds_of_week) = gnss::week_and_seconds_of_week(gps_seconds);
1436    if !(0.0..=u32::MAX as f64).contains(&gnss_week) {
1437        return None;
1438    }
1439
1440    Some(FirstEpochHeaderFields {
1441        gnss_week: gnss_week as u32,
1442        seconds_of_week,
1443        mjd: mjd as u32,
1444        mjd_fraction,
1445    })
1446}
1447
1448fn dist3(a: &[f64; 3], b: &[f64; 3]) -> f64 {
1449    vec3::norm3(vec3::sub3(*a, *b))
1450}
1451
1452/// RMS and max of the 3D distance of each `members` position (indices into `pos`)
1453/// from `combined`. `members` is the accepted consensus, always non-empty.
1454fn position_dispersion(
1455    pos: &[(usize, [f64; 3], Sp3Flags)],
1456    members: &[usize],
1457    combined: &[f64; 3],
1458) -> (f64, f64) {
1459    let mut sumsq = 0.0;
1460    let mut max = 0.0_f64;
1461    for &i in members {
1462        let d = dist3(&pos[i].1, combined);
1463        sumsq += d * d;
1464        max = max.max(d);
1465    }
1466    ((sumsq / members.len().max(1) as f64).sqrt(), max)
1467}
1468
1469/// RMS and max of the absolute deviation of each `members` clock (indices into
1470/// `clk`) from `combined`. `members` is the accepted consensus, always non-empty.
1471fn clock_dispersion(
1472    clk: &[(usize, f64, Sp3Flags)],
1473    members: &[usize],
1474    combined: f64,
1475) -> (f64, f64) {
1476    let mut sumsq = 0.0;
1477    let mut max = 0.0_f64;
1478    for &i in members {
1479        let d = (clk[i].1 - combined).abs();
1480        sumsq += d * d;
1481        max = max.max(d);
1482    }
1483    ((sumsq / members.len().max(1) as f64).sqrt(), max)
1484}
1485
1486/// Datum offset at `key`, using an exact estimate when available or linear
1487/// interpolation between the nearest bracketing estimates. Never extrapolates
1488/// beyond the observed offset interval.
1489fn clock_offset_at(offsets: &BTreeMap<i64, f64>, key: i64) -> Option<f64> {
1490    if let Some(offset) = offsets.get(&key) {
1491        return Some(*offset);
1492    }
1493    let (&before_key, &before) = offsets.range(..key).next_back()?;
1494    let (&after_key, &after) = offsets.range(key..).next()?;
1495    if after_key <= before_key {
1496        return None;
1497    }
1498    let fraction = (key - before_key) as f64 / (after_key - before_key) as f64;
1499    Some(before + fraction * (after - before))
1500}
1501
1502fn precedence_sources_for_satellites(
1503    sources: &[Sp3],
1504    epoch_index: &[BTreeMap<i64, usize>],
1505    epoch_keys: &BTreeMap<i64, Instant>,
1506    systems: Option<&BTreeSet<GnssSystem>>,
1507) -> BTreeMap<GnssSatelliteId, usize> {
1508    let mut by_sat = BTreeMap::new();
1509
1510    for (idx, source) in sources.iter().enumerate() {
1511        for key in epoch_keys.keys() {
1512            let Some(&epoch_idx) = epoch_index[idx].get(key) else {
1513                continue;
1514            };
1515            let Ok(states) = source.states_at(epoch_idx) else {
1516                continue;
1517            };
1518
1519            for sat in states.keys() {
1520                if systems.is_none_or(|allowed| allowed.contains(&sat.system)) {
1521                    by_sat.entry(*sat).or_insert(idx);
1522                }
1523            }
1524        }
1525    }
1526
1527    by_sat
1528}
1529
1530fn validate_merge_options(opts: &MergeOptions) -> Result<()> {
1531    validate::finite_nonneg(opts.position_tolerance_m, "merge position tolerance meters")
1532        .map_err(|error| Error::InvalidInput(error.to_string()))?;
1533    validate::finite_nonneg(opts.clock_tolerance_s, "merge clock tolerance seconds")
1534        .map_err(|error| Error::InvalidInput(error.to_string()))?;
1535    if opts.min_agree == 0 {
1536        return Err(Error::InvalidInput(
1537            "merge minimum agreement must be at least one".into(),
1538        ));
1539    }
1540    if opts.clock_min_common == 0 {
1541        return Err(Error::InvalidInput(
1542            "merge minimum common clock satellites must be at least one".into(),
1543        ));
1544    }
1545    if let Some(reject) = opts.outlier_reject {
1546        validate::finite_nonneg(
1547            reject.position_tolerance_m,
1548            "merge outlier position tolerance meters",
1549        )
1550        .map_err(|error| Error::InvalidInput(error.to_string()))?;
1551        validate::finite_nonneg(
1552            reject.clock_tolerance_s,
1553            "merge outlier clock tolerance seconds",
1554        )
1555        .map_err(|error| Error::InvalidInput(error.to_string()))?;
1556    }
1557    if opts
1558        .systems
1559        .as_ref()
1560        .is_some_and(|systems| systems.is_empty())
1561    {
1562        return Err(Error::InvalidInput(
1563            "merge systems filter must not be empty".into(),
1564        ));
1565    }
1566    for labels in &opts.frame_reconciliation.asserted_equivalent_label_sets {
1567        if labels.labels.len() < 2 || labels.labels.iter().any(|label| label.trim().is_empty()) {
1568            return Err(Error::InvalidInput(
1569                "merge asserted frame label sets require at least two non-empty labels".into(),
1570            ));
1571        }
1572    }
1573    Ok(())
1574}
1575
1576/// Resolve the common (output) epoch interval and validate that every input can
1577/// contribute to it without interpolation.
1578///
1579/// The common interval is the caller's `target` if given, otherwise the
1580/// **finest** native interval among the inputs. An input is compatible when its
1581/// native interval and the output interval are integer-commensurate: a finer
1582/// input can be decimated, while a coarser input contributes only at the epochs
1583/// it actually contains. No orbit or clock interpolation is introduced.
1584fn resolve_common_epoch_interval(sources: &[Sp3], target: Option<f64>) -> Result<f64> {
1585    let intervals: Vec<f64> = sources
1586        .iter()
1587        .enumerate()
1588        .map(|(idx, source)| {
1589            effective_epoch_interval_s(source)?.ok_or_else(|| {
1590                Error::InvalidInput(format!(
1591                    "merge input {idx} has no usable positive epoch interval"
1592                ))
1593            })
1594        })
1595        .collect::<Result<Vec<_>>>()?;
1596
1597    let common = match target {
1598        Some(t) if t.is_finite() && t > 0.0 => t,
1599        Some(t) => {
1600            return Err(Error::InvalidInput(format!(
1601                "merge target epoch interval must be positive and finite, got {t}"
1602            )))
1603        }
1604        None => intervals.iter().copied().fold(f64::INFINITY, f64::min),
1605    };
1606
1607    // The merge matches and decimates epochs on whole-second J2000 keys, so the
1608    // common grid must fall on whole seconds for the decimation lattice to be
1609    // exact. SP3 grids are integer-second; reject a fractional common interval
1610    // rather than decimate on a mismatched (rounded) lattice.
1611    if (common - common.round()).abs() > WHOLE_SECOND_EPS_S || common.round() < 1.0 {
1612        return Err(Error::InvalidInput(format!(
1613            "merge common epoch interval {common:.6} s must be a positive whole number of seconds"
1614        )));
1615    }
1616
1617    for (idx, interval) in intervals.iter().copied().enumerate() {
1618        if !divides_evenly(interval, common) && !divides_evenly(common, interval) {
1619            return Err(Error::InvalidInput(format!(
1620                "merge inputs have mismatched epoch intervals: output {common:.6} s and input {idx} {interval:.6} s are not integer-commensurate (positional interpolation is not performed)"
1621            )));
1622        }
1623    }
1624
1625    Ok(common)
1626}
1627
1628/// True when `common` is a positive-integer multiple of `interval` (within the
1629/// interval tolerance), i.e. `interval`'s grid is a superset of the common grid.
1630fn divides_evenly(interval: f64, common: f64) -> bool {
1631    if !(interval.is_finite() && interval > 0.0 && common.is_finite() && common > 0.0) {
1632        return false;
1633    }
1634    let k = (common / interval).round();
1635    k >= 1.0 && same_interval(k * interval, common)
1636}
1637
1638fn effective_epoch_interval_s(source: &Sp3) -> Result<Option<f64>> {
1639    let secs: Vec<f64> = source
1640        .epochs
1641        .iter()
1642        .filter_map(instant_to_j2000_seconds)
1643        .collect();
1644    validate::require_strictly_increasing(secs.iter().copied(), "merge input epochs").map_err(
1645        |error| Error::InvalidInput(format!("{} must be strictly increasing", error.field())),
1646    )?;
1647    let gaps: Vec<f64> = secs.windows(2).map(|w| w[1] - w[0]).collect();
1648
1649    if gaps.is_empty() {
1650        let header = source.header.epoch_interval_s;
1651        return Ok((header.is_finite() && header > 0.0).then_some(header));
1652    }
1653
1654    let interval = gaps[0];
1655    if gaps.iter().all(|g| same_interval(*g, interval)) {
1656        Ok(Some(interval))
1657    } else {
1658        Ok(None)
1659    }
1660}
1661
1662fn same_interval(a: f64, b: f64) -> bool {
1663    (a - b).abs() <= WHOLE_SECOND_EPS_S
1664}
1665
1666/// Indices of the largest subset of `items` whose members are *mutually* within
1667/// `within`. Exact max-clique over normal source counts; deterministic greedy
1668/// fallback above [`MAX_EXACT_CLIQUE_NODES`] keeps hostile overlap graphs bounded.
1669/// Ties resolve to the lowest-indexed subset (precedence).
1670fn largest_within<T>(items: &[T], within: impl Fn(&T, &T) -> bool) -> Vec<usize> {
1671    let n = items.len();
1672    if n <= 1 {
1673        return (0..n).collect();
1674    }
1675    let graph = agreement_graph(items, within);
1676    if n > MAX_EXACT_CLIQUE_NODES {
1677        return greedy_largest_clique(&graph);
1678    }
1679    let mut best = vec![0];
1680    let mut current = Vec::new();
1681    max_clique_search(&graph, &mut current, (0..n).collect(), &mut best);
1682    best
1683}
1684
1685fn largest_within_containing<T>(
1686    items: &[T],
1687    required: usize,
1688    within: impl Fn(&T, &T) -> bool,
1689) -> Vec<usize> {
1690    let n = items.len();
1691    if n == 0 || required >= n {
1692        return Vec::new();
1693    }
1694    if n == 1 {
1695        return vec![required];
1696    }
1697
1698    let graph = agreement_graph(items, within);
1699    if n > MAX_EXACT_CLIQUE_NODES {
1700        return greedy_largest_clique_containing(&graph, required);
1701    }
1702    let candidates = (0..n)
1703        .filter(|&idx| idx != required && graph[required][idx])
1704        .collect();
1705    let mut best = vec![required];
1706    let mut current = vec![required];
1707    max_clique_search(&graph, &mut current, candidates, &mut best);
1708    best
1709}
1710
1711fn agreement_graph<T>(items: &[T], within: impl Fn(&T, &T) -> bool) -> Vec<Vec<bool>> {
1712    let n = items.len();
1713    let mut graph = vec![vec![false; n]; n];
1714    for i in 0..n {
1715        graph[i][i] = true;
1716        for j in i + 1..n {
1717            let agrees = within(&items[i], &items[j]);
1718            graph[i][j] = agrees;
1719            graph[j][i] = agrees;
1720        }
1721    }
1722    graph
1723}
1724
1725fn greedy_largest_clique(graph: &[Vec<bool>]) -> Vec<usize> {
1726    let mut best = Vec::new();
1727    for seed in 0..graph.len() {
1728        let candidate = greedy_clique_from_seed(graph, seed);
1729        update_best_clique(&candidate, &mut best);
1730    }
1731    best
1732}
1733
1734fn greedy_largest_clique_containing(graph: &[Vec<bool>], required: usize) -> Vec<usize> {
1735    if required >= graph.len() {
1736        return Vec::new();
1737    }
1738    greedy_clique_from_seed(graph, required)
1739}
1740
1741fn greedy_clique_from_seed(graph: &[Vec<bool>], seed: usize) -> Vec<usize> {
1742    let mut clique = vec![seed];
1743    for (idx, _) in graph.iter().enumerate() {
1744        if idx == seed {
1745            continue;
1746        }
1747        if clique.iter().all(|&member| graph[member][idx]) {
1748            clique.push(idx);
1749        }
1750    }
1751    clique.sort_unstable();
1752    clique
1753}
1754
1755fn max_clique_search(
1756    graph: &[Vec<bool>],
1757    current: &mut Vec<usize>,
1758    mut candidates: Vec<usize>,
1759    best: &mut Vec<usize>,
1760) {
1761    candidates.sort_unstable();
1762    for (pos, &candidate) in candidates.iter().enumerate() {
1763        let remaining = candidates.len() - pos;
1764        if current.len() + remaining < best.len() {
1765            break;
1766        }
1767
1768        let next_candidates = candidates[pos + 1..]
1769            .iter()
1770            .copied()
1771            .filter(|&idx| graph[candidate][idx])
1772            .collect();
1773
1774        current.push(candidate);
1775        update_best_clique(current, best);
1776        max_clique_search(graph, current, next_candidates, best);
1777        current.pop();
1778    }
1779}
1780
1781fn update_best_clique(current: &[usize], best: &mut Vec<usize>) {
1782    let mut candidate = current.to_vec();
1783    candidate.sort_unstable();
1784    if candidate.len() > best.len()
1785        || (candidate.len() == best.len() && candidate.as_slice() < best.as_slice())
1786    {
1787        *best = candidate;
1788    }
1789}
1790
1791fn combine3(members: &[(usize, [f64; 3])], how: MergeCombine) -> [f64; 3] {
1792    [0usize, 1, 2].map(|axis| {
1793        let axis_members: Vec<(usize, f64)> = members.iter().map(|(s, v)| (*s, v[axis])).collect();
1794        combine_axis(&axis_members, how)
1795    })
1796}
1797
1798fn combine_axis(members: &[(usize, f64)], how: MergeCombine) -> f64 {
1799    match how {
1800        MergeCombine::Mean => members.iter().map(|(_, v)| *v).sum::<f64>() / members.len() as f64,
1801        MergeCombine::Median => {
1802            let mut vals: Vec<f64> = members.iter().map(|(_, v)| *v).collect();
1803            median(&mut vals).expect("consensus cluster is non-empty")
1804        }
1805        MergeCombine::Precedence => members
1806            .iter()
1807            .min_by_key(|(s, _)| *s)
1808            .map(|(_, v)| *v)
1809            .expect("consensus cluster is non-empty"),
1810    }
1811}
1812
1813/// Return a copy of `other` with its clocks shifted onto `reference`'s clock
1814/// datum.
1815///
1816/// This applies the per-epoch reference-clock offset from
1817/// [`clock_reference_offset`]: at each epoch where the offset could be estimated
1818/// (at least `min_common` common clocked satellites), every clocked satellite's
1819/// offset has the datum subtracted, so the result's clocks are directly
1820/// comparable to `reference`'s. Positions are untouched (already comparable).
1821///
1822/// Epochs where the offset could not be estimated are left unchanged - they are
1823/// *not* on `reference`'s datum, so a caller mixing aligned and unaligned epochs
1824/// should consult [`clock_reference_offset`] to see which epochs were aligned.
1825/// The returned product interpolates like any other [`Sp3`].
1826pub fn align_clock_reference(reference: &Sp3, other: &Sp3, min_common: usize) -> Sp3 {
1827    let offsets: BTreeMap<i64, f64> = clock_reference_offset(reference, other, min_common)
1828        .into_iter()
1829        .filter_map(|o| {
1830            instant_to_j2000_seconds(&o.epoch).map(|sec| (sec.floor() as i64, o.offset_s))
1831        })
1832        .collect();
1833
1834    let mut aligned = other.clone();
1835    for ei in 0..aligned.epochs.len() {
1836        let Some(sec) = sp3_epoch_j2000_seconds(&aligned, ei, &aligned.epochs[ei]) else {
1837            continue;
1838        };
1839        let Some(&off) = offsets.get(&(sec.floor() as i64)) else {
1840            continue;
1841        };
1842        for state in aligned.states[ei].values_mut() {
1843            if let Some(c) = state.clock_s.as_mut() {
1844                *c -= off;
1845            }
1846        }
1847        for node in aligned.interp_raw[ei].values_mut() {
1848            if let Some(us) = node.clock_us.as_mut() {
1849                *us -= off * 1.0e6;
1850            }
1851        }
1852    }
1853    aligned
1854}
1855
1856#[cfg(test)]
1857mod tests {
1858    use super::super::Sp3;
1859    use super::{
1860        align_clock_reference, clock_reference_offset, merge, MergeCombine, MergeOptions,
1861        MergePrecedenceScope, MergeReport, OutlierRejectOptions, Sp3FrameLabelSet,
1862        Sp3FrameReconciliationMethod, Sp3FrameReconciliationOptions,
1863    };
1864    use crate::constants::SECONDS_PER_DAY;
1865    use crate::id::{GnssSatelliteId, GnssSystem};
1866    use std::collections::BTreeSet;
1867
1868    /// One satellite sample in a synthetic SP3 epoch: token, ECEF position
1869    /// (km), and optional clock (microseconds).
1870    type SatSample<'a> = (&'a str, [f64; 3], Option<f64>);
1871
1872    fn gps(prn: u8) -> GnssSatelliteId {
1873        GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
1874    }
1875
1876    // Single-epoch SP3-c from explicit `(satellite, [x,y,z] km, clock us, flag
1877    // suffix)` records under coordinate system `cs` (5 chars, e.g. `"IGS14"`).
1878    // `flags` is appended verbatim after the 60-column record body, so a test can
1879    // place an SP3 flag (e.g. `"              E"` -> the `E` clock-event flag at
1880    // column 75). A `None` clock writes the SP3 bad-clock sentinel.
1881    fn sp3_build(records: &[(&str, [f64; 3], Option<f64>, &str)], cs: &str) -> Sp3 {
1882        let n = records.len();
1883        let mut sats = String::new();
1884        for (sat, _, _, _) in records {
1885            sats.push_str(sat);
1886        }
1887        for _ in n..17 {
1888            sats.push_str("  0");
1889        }
1890        let mut body = String::new();
1891        body.push_str(&format!(
1892            "#cP2020  6 25  0  0  0.00000000       1 ORBIT {cs} FIT  TST\n"
1893        ));
1894        body.push_str("## 2111 432000.00000000   900.00000000 59025 0.0000000000000\n");
1895        body.push_str(&format!("+   {n:2}   {sats}\n"));
1896        body.push_str("++         0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0\n");
1897        body.push_str("%c G  cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1898        body.push_str("%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1899        body.push_str("%f  1.2500000  1.025000000  0.00000000000  0.000000000000000\n");
1900        body.push_str("%f  0.0000000  0.000000000  0.00000000000  0.000000000000000\n");
1901        body.push_str("%i    0    0    0    0      0      0      0      0         0\n");
1902        body.push_str("%i    0    0    0    0      0      0      0      0         0\n");
1903        body.push_str("/* TEST SP3-c FIXTURE\n");
1904        body.push_str("*  2020  6 25  0  0  0.00000000\n");
1905        for (sat, p, clk, flags) in records {
1906            let c = clk.unwrap_or(999_999.999_999);
1907            body.push_str(&format!(
1908                "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}{flags}\n",
1909                p[0], p[1], p[2]
1910            ));
1911        }
1912        body.push_str("EOF\n");
1913        Sp3::parse(body.as_bytes()).expect("parse test sp3")
1914    }
1915
1916    // The common case: IGS14, no flags.
1917    fn sp3_records(records: &[(&str, [f64; 3], Option<f64>)]) -> Sp3 {
1918        let full: Vec<(&str, [f64; 3], Option<f64>, &str)> =
1919            records.iter().map(|(s, p, c)| (*s, *p, *c, "")).collect();
1920        sp3_build(&full, "IGS14")
1921    }
1922
1923    fn sp3_two_epochs(
1924        epoch0: &[(&str, [f64; 3], Option<f64>)],
1925        epoch1: &[(&str, [f64; 3], Option<f64>)],
1926        interval_s: f64,
1927        cs: &str,
1928    ) -> Sp3 {
1929        let mut sats: Vec<&str> = epoch0
1930            .iter()
1931            .chain(epoch1.iter())
1932            .map(|(sat, _, _)| *sat)
1933            .collect();
1934        sats.sort_unstable();
1935        sats.dedup();
1936        let n = sats.len();
1937        let mut sat_field = String::new();
1938        for sat in &sats {
1939            sat_field.push_str(sat);
1940        }
1941        for _ in n..17 {
1942            sat_field.push_str("  0");
1943        }
1944
1945        let mut body = String::new();
1946        body.push_str(&format!(
1947            "#cP2020  6 25  0  0  0.00000000       2 ORBIT {cs} FIT  TST\n"
1948        ));
1949        body.push_str(&format!(
1950            "## 2111 432000.00000000 {interval_s:14.8} 59025 0.0000000000000\n"
1951        ));
1952        body.push_str(&format!("+   {n:2}   {sat_field}\n"));
1953        body.push_str("++         0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0\n");
1954        body.push_str("%c G  cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1955        body.push_str("%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
1956        body.push_str("%f  1.2500000  1.025000000  0.00000000000  0.000000000000000\n");
1957        body.push_str("%f  0.0000000  0.000000000  0.00000000000  0.000000000000000\n");
1958        body.push_str("%i    0    0    0    0      0      0      0      0         0\n");
1959        body.push_str("%i    0    0    0    0      0      0      0      0         0\n");
1960        body.push_str("/* TEST SP3-c FIXTURE\n");
1961        body.push_str("*  2020  6 25  0  0  0.00000000\n");
1962        for (sat, p, clk) in epoch0 {
1963            let c = clk.unwrap_or(999_999.999_999);
1964            body.push_str(&format!(
1965                "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}\n",
1966                p[0], p[1], p[2]
1967            ));
1968        }
1969        let second_hour = (interval_s as i64) / 3600;
1970        let second_minute = ((interval_s as i64) % 3600) / 60;
1971        let second_second = (interval_s as i64) % 60;
1972        body.push_str(&format!(
1973            "*  2020  6 25 {second_hour:2} {second_minute:2} {second_second:2}.00000000\n"
1974        ));
1975        for (sat, p, clk) in epoch1 {
1976            let c = clk.unwrap_or(999_999.999_999);
1977            body.push_str(&format!(
1978                "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}\n",
1979                p[0], p[1], p[2]
1980            ));
1981        }
1982        body.push_str("EOF\n");
1983        Sp3::parse(body.as_bytes()).expect("parse test sp3")
1984    }
1985
1986    // N consecutive epochs spaced `interval_s` apart from 2020-06-25 00:00:00.
1987    fn sp3_epochs(
1988        start_offset_s: f64,
1989        epochs: &[&[SatSample<'_>]],
1990        interval_s: f64,
1991        cs: &str,
1992    ) -> Sp3 {
1993        let mut sats: Vec<&str> = epochs
1994            .iter()
1995            .flat_map(|e| e.iter().map(|(sat, _, _)| *sat))
1996            .collect();
1997        sats.sort_unstable();
1998        sats.dedup();
1999        let n = sats.len();
2000        let mut sat_field = String::new();
2001        for sat in &sats {
2002            sat_field.push_str(sat);
2003        }
2004        for _ in n..17 {
2005            sat_field.push_str("  0");
2006        }
2007
2008        let hms = |t: i64| (t / 3600, (t % 3600) / 60, t % 60);
2009        let start = start_offset_s as i64;
2010        let (sh, sm, ss0) = hms(start);
2011
2012        let mut body = String::new();
2013        body.push_str(&format!(
2014            "#cP2020  6 25 {sh:2} {sm:2} {ss0:2}.00000000      {:2} ORBIT {cs} FIT  TST\n",
2015            epochs.len()
2016        ));
2017        // Seconds-of-week and MJD fraction of the first epoch shift with the start.
2018        let sow = 432_000.0 + start_offset_s;
2019        let mjd_frac = start_offset_s / SECONDS_PER_DAY;
2020        body.push_str(&format!(
2021            "## 2111 {sow:15.8} {interval_s:14.8} 59025 {mjd_frac:.13}\n"
2022        ));
2023        body.push_str(&format!("+   {n:2}   {sat_field}\n"));
2024        body.push_str("++         0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0\n");
2025        body.push_str("%c G  cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
2026        body.push_str("%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
2027        body.push_str("%f  1.2500000  1.025000000  0.00000000000  0.000000000000000\n");
2028        body.push_str("%f  0.0000000  0.000000000  0.00000000000  0.000000000000000\n");
2029        body.push_str("%i    0    0    0    0      0      0      0      0         0\n");
2030        body.push_str("%i    0    0    0    0      0      0      0      0         0\n");
2031        body.push_str("/* TEST SP3-c FIXTURE\n");
2032        for (k, recs) in epochs.iter().enumerate() {
2033            let (hh, mm, ss) = hms(start + (k as i64) * (interval_s as i64));
2034            body.push_str(&format!("*  2020  6 25 {hh:2} {mm:2} {ss:2}.00000000\n"));
2035            for (sat, p, clk) in recs.iter() {
2036                let c = clk.unwrap_or(999_999.999_999);
2037                body.push_str(&format!(
2038                    "P{sat}{:14.6}{:14.6}{:14.6}{c:14.6}\n",
2039                    p[0], p[1], p[2]
2040                ));
2041            }
2042        }
2043        body.push_str("EOF\n");
2044        Sp3::parse(body.as_bytes()).expect("parse test sp3")
2045    }
2046
2047    #[test]
2048    fn merge_unions_coverage_when_one_center_misses_a_satellite() {
2049        // Center A reports G01/G02/G03; center B is missing G03. The merged
2050        // product must still cover G03 at that epoch (filled from A).
2051        let a = sp3_records(&[
2052            ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
2053            ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
2054            ("G03", [17000.0, -22000.0, 7000.0], Some(300.0)),
2055        ]);
2056        let b = sp3_records(&[
2057            ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
2058            ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
2059        ]);
2060
2061        let (merged, report) = merge(&[a, b], &MergeOptions::default()).expect("merge");
2062
2063        let states = merged.states_at(0).expect("epoch 0");
2064        assert!(
2065            states.contains_key(&gps(3)),
2066            "merged output must cover G03 from the center that has it"
2067        );
2068        assert_eq!(states.len(), 3, "union is G01/G02/G03");
2069        // G01 agreed across both centers -> consensus clock is their value.
2070        let g01 = states[&gps(1)];
2071        assert!((g01.clock_s.unwrap() - 100.0e-6).abs() < 1.0e-15);
2072        // G03 had a single source -> carried through, recorded, not quarantined.
2073        assert!(report.quarantined.is_empty());
2074        assert_eq!(report.single_source.len(), 1);
2075        assert_eq!(report.single_source[0].satellite, gps(3));
2076
2077        // The un-cross-checked share is surfaced: 1 of 3 accepted cells (G03) was
2078        // single-source, so a clean multi-source agreement RMS is not the whole
2079        // story. An empty report reports None.
2080        let frac = report
2081            .single_source_fraction()
2082            .expect("accepted cells present");
2083        assert!(
2084            (frac - 1.0 / 3.0).abs() < 1.0e-12,
2085            "single-source fraction {frac}"
2086        );
2087        assert_eq!(MergeReport::default().single_source_fraction(), None);
2088    }
2089
2090    #[test]
2091    fn merge_rejects_non_executable_system_and_frame_policies() {
2092        let source = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))]);
2093
2094        let empty_systems = MergeOptions {
2095            systems: Some(BTreeSet::new()),
2096            ..MergeOptions::default()
2097        };
2098        let error = merge(std::slice::from_ref(&source), &empty_systems).unwrap_err();
2099        assert!(error
2100            .to_string()
2101            .contains("systems filter must not be empty"));
2102
2103        let incomplete_frame_set = MergeOptions {
2104            frame_reconciliation: Sp3FrameReconciliationOptions {
2105                asserted_equivalent_label_sets: vec![Sp3FrameLabelSet::new(["IGS20"])],
2106                helmert: false,
2107            },
2108            ..MergeOptions::default()
2109        };
2110        let error = merge(&[source], &incomplete_frame_set).unwrap_err();
2111        assert!(error.to_string().contains("at least two non-empty labels"));
2112    }
2113
2114    #[test]
2115    fn merge_combines_two_of_three_agreeing_sources_and_rejects_the_outlier() {
2116        // A and B agree on G01; C is 10 m off in X (> the default 0.5 m tolerance).
2117        let a = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))]);
2118        let b = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))]);
2119        let c = sp3_records(&[("G01", [15000.010, -20000.0, 5000.0], Some(100.0))]);
2120
2121        let (merged, report) = merge(&[a, b, c], &MergeOptions::default()).expect("merge");
2122
2123        let states = merged.states_at(0).expect("epoch 0");
2124        let g01 = states[&gps(1)];
2125        // Consensus is A/B (15000 km == 1.5e7 m); not dragged toward C.
2126        assert!(
2127            (g01.position.as_array()[0] - 15_000_000.0).abs() < 1.0e-3,
2128            "got {}",
2129            g01.position.as_array()[0]
2130        );
2131        // C is source index 2 -> recorded as the rejected position outlier.
2132        assert_eq!(report.position_outliers.len(), 1);
2133        assert_eq!(report.position_outliers[0].sources, vec![2]);
2134        assert!(report.quarantined.is_empty());
2135    }
2136
2137    #[test]
2138    fn guarded_precedence_replaces_a_corrupt_preferred_position() {
2139        let preferred = sp3_records(&[("G01", [16000.0, -20000.0, 5000.0], None)]);
2140        let agreeing_a = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], None)]);
2141        let agreeing_b = sp3_records(&[("G01", [15000.0002, -20000.0, 5000.0], None)]);
2142        let opts = MergeOptions {
2143            combine: MergeCombine::Precedence,
2144            min_agree: 1,
2145            outlier_reject: Some(OutlierRejectOptions {
2146                position_tolerance_m: 0.5,
2147                clock_tolerance_s: 5.0e-9,
2148            }),
2149            ..MergeOptions::default()
2150        };
2151
2152        let (merged, report) = merge(&[preferred, agreeing_a, agreeing_b], &opts).expect("merge");
2153
2154        let x = merged.states_at(0).expect("epoch")[&gps(1)]
2155            .position
2156            .as_array()[0];
2157        assert_eq!(
2158            x, 15_000_000.0,
2159            "earliest member of the 2-source cluster wins"
2160        );
2161        assert_eq!(report.position_outliers.len(), 1);
2162        assert_eq!(report.position_outliers[0].sources, vec![0]);
2163    }
2164
2165    #[test]
2166    fn unguarded_precedence_preserves_the_existing_preferred_value_behavior() {
2167        let preferred = sp3_records(&[("G01", [16000.0, -20000.0, 5000.0], None)]);
2168        let agreeing_a = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], None)]);
2169        let agreeing_b = sp3_records(&[("G01", [15000.0002, -20000.0, 5000.0], None)]);
2170        let opts = MergeOptions {
2171            combine: MergeCombine::Precedence,
2172            min_agree: 1,
2173            outlier_reject: None,
2174            ..MergeOptions::default()
2175        };
2176
2177        let (merged, report) = merge(&[preferred, agreeing_a, agreeing_b], &opts).expect("merge");
2178
2179        let x = merged.states_at(0).expect("epoch")[&gps(1)]
2180            .position
2181            .as_array()[0];
2182        assert_eq!(x, 16_000_000.0);
2183        assert_eq!(report.position_outliers[0].sources, vec![1, 2]);
2184    }
2185
2186    #[test]
2187    fn guarded_precedence_keeps_a_preferred_member_of_the_majority() {
2188        let preferred = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], None)]);
2189        let agreeing = sp3_records(&[("G01", [15000.0002, -20000.0, 5000.0], None)]);
2190        let outlier = sp3_records(&[("G01", [16000.0, -20000.0, 5000.0], None)]);
2191        let opts = MergeOptions {
2192            combine: MergeCombine::Precedence,
2193            min_agree: 1,
2194            outlier_reject: Some(OutlierRejectOptions {
2195                position_tolerance_m: 0.5,
2196                clock_tolerance_s: 5.0e-9,
2197            }),
2198            ..MergeOptions::default()
2199        };
2200
2201        let (merged, report) = merge(&[preferred, agreeing, outlier], &opts).expect("merge");
2202
2203        let x = merged.states_at(0).expect("epoch")[&gps(1)]
2204            .position
2205            .as_array()[0];
2206        assert_eq!(x, 15_000_000.0);
2207        assert_eq!(report.position_outliers[0].sources, vec![2]);
2208    }
2209
2210    #[test]
2211    fn guarded_precedence_keeps_a_single_source_cell() {
2212        let only = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], None)]);
2213        let opts = MergeOptions {
2214            combine: MergeCombine::Precedence,
2215            min_agree: 1,
2216            outlier_reject: Some(OutlierRejectOptions {
2217                position_tolerance_m: 0.5,
2218                clock_tolerance_s: 5.0e-9,
2219            }),
2220            ..MergeOptions::default()
2221        };
2222
2223        let (merged, report) = merge(&[only], &opts).expect("merge");
2224
2225        assert!(merged.states_at(0).expect("epoch").contains_key(&gps(1)));
2226        assert_eq!(report.single_source.len(), 1);
2227        assert!(report.quarantined.is_empty());
2228    }
2229
2230    #[test]
2231    fn guarded_precedence_position_tolerance_is_inclusive() {
2232        for (delta_km, accepted) in [(0.000_499, true), (0.000_501, false)] {
2233            let a = sp3_records(&[("G01", [15000.0, -20000.0, 5000.0], None)]);
2234            let b = sp3_records(&[("G01", [15000.0 + delta_km, -20000.0, 5000.0], None)]);
2235            let opts = MergeOptions {
2236                combine: MergeCombine::Precedence,
2237                min_agree: 1,
2238                outlier_reject: Some(OutlierRejectOptions {
2239                    position_tolerance_m: 0.5,
2240                    clock_tolerance_s: 5.0e-9,
2241                }),
2242                ..MergeOptions::default()
2243            };
2244
2245            let (merged, report) = merge(&[a, b], &opts).expect("merge");
2246            assert_eq!(
2247                merged.states_at(0).expect("epoch").contains_key(&gps(1)),
2248                accepted,
2249                "delta {delta_km} km"
2250            );
2251            assert_eq!(report.quarantined.is_empty(), accepted);
2252        }
2253
2254        assert_eq!(
2255            super::largest_within(&[0.0_f64, 0.5_f64], |a, b| (*a - *b).abs() <= 0.5).len(),
2256            2,
2257            "the tolerance boundary itself is accepted"
2258        );
2259    }
2260
2261    #[test]
2262    fn guarded_precedence_replaces_a_corrupt_preferred_clock() {
2263        let positions = |clock_g01: f64| {
2264            sp3_records(&[
2265                ("G01", [15000.0, -20000.0, 5000.0], Some(clock_g01)),
2266                ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
2267                ("G03", [17000.0, -22000.0, 7000.0], Some(300.0)),
2268                ("G04", [18000.0, -23000.0, 8000.0], Some(400.0)),
2269                ("G05", [19000.0, -24000.0, 9000.0], Some(500.0)),
2270            ])
2271        };
2272        let opts = MergeOptions {
2273            combine: MergeCombine::Precedence,
2274            min_agree: 1,
2275            outlier_reject: Some(OutlierRejectOptions {
2276                position_tolerance_m: 0.5,
2277                clock_tolerance_s: 5.0e-9,
2278            }),
2279            ..MergeOptions::default()
2280        };
2281
2282        let (merged, report) = merge(
2283            &[positions(1100.0), positions(100.0), positions(100.0)],
2284            &opts,
2285        )
2286        .expect("merge");
2287
2288        let clock = merged.states_at(0).expect("epoch")[&gps(1)]
2289            .clock_s
2290            .expect("consensus clock");
2291        assert!((clock - 100.0e-6).abs() < 1.0e-15, "clock {clock}");
2292        let rejected = report
2293            .clock_outliers
2294            .iter()
2295            .find(|entry| entry.satellite == gps(1))
2296            .expect("clock outlier provenance");
2297        assert_eq!(rejected.sources, vec![0]);
2298    }
2299
2300    #[test]
2301    fn merge_consensus_handles_more_than_u32_mask_bits() {
2302        // Thirty-two centers agree and the 33rd is 10 m off in X. This used to
2303        // overflow the u32 subset mask before any consensus could be found.
2304        let sources: Vec<Sp3> = (0..33)
2305            .map(|idx| {
2306                let x_km = if idx < 32 { 15000.0 } else { 15000.010 };
2307                sp3_records(&[("G01", [x_km, -20000.0, 5000.0], Some(100.0))])
2308            })
2309            .collect();
2310
2311        for combine in [MergeCombine::Mean, MergeCombine::Precedence] {
2312            let opts = MergeOptions {
2313                combine,
2314                min_agree: 32,
2315                ..MergeOptions::default()
2316            };
2317
2318            let (merged, report) = merge(&sources, &opts).expect("33-source merge");
2319
2320            let states = merged.states_at(0).expect("epoch 0");
2321            let g01 = states[&gps(1)];
2322            assert!(
2323                (g01.position.as_array()[0] - 15_000_000.0).abs() < 1.0e-3,
2324                "{combine:?}: got {}",
2325                g01.position.as_array()[0]
2326            );
2327            assert_eq!(
2328                report.position_outliers.len(),
2329                1,
2330                "{combine:?}: expected one outlier report"
2331            );
2332            assert_eq!(report.position_outliers[0].sources, vec![32]);
2333            assert!(report.quarantined.is_empty(), "{combine:?}");
2334        }
2335    }
2336
2337    #[test]
2338    fn merge_bounds_large_overlap_clique_search() {
2339        let sources: Vec<Sp3> = (0..40)
2340            .map(|idx| {
2341                let x_km = if idx % 2 == 0 { 15000.0 } else { 15000.010 };
2342                sp3_records(&[("G01", [x_km, -20000.0, 5000.0], Some(100.0))])
2343            })
2344            .collect();
2345        let opts = MergeOptions {
2346            min_agree: 20,
2347            ..MergeOptions::default()
2348        };
2349
2350        let (merged, report) = merge(&sources, &opts).expect("bounded large-source merge");
2351
2352        let states = merged.states_at(0).expect("epoch 0");
2353        let g01 = states[&gps(1)];
2354        assert!(
2355            (g01.position.as_array()[0] - 15_000_000.0).abs() < 1.0e-3,
2356            "got {}",
2357            g01.position.as_array()[0]
2358        );
2359        assert_eq!(report.position_outliers.len(), 1);
2360        assert_eq!(
2361            report.position_outliers[0].sources,
2362            (1..40).step_by(2).collect::<Vec<_>>()
2363        );
2364        assert!(report.quarantined.is_empty());
2365    }
2366
2367    #[test]
2368    fn merge_quarantines_a_satellite_all_centers_disagree_on() {
2369        // Three sources, mutually beyond tolerance on G01: no 2-of-3 consensus.
2370        let a = sp3_records(&[("G01", [15000.000, -20000.0, 5000.0], Some(100.0))]);
2371        let b = sp3_records(&[("G01", [15000.010, -20000.0, 5000.0], Some(100.0))]);
2372        let c = sp3_records(&[("G01", [15000.020, -20000.0, 5000.0], Some(100.0))]);
2373
2374        let (merged, report) = merge(&[a, b, c], &MergeOptions::default()).expect("merge");
2375
2376        assert!(
2377            merged.states_at(0).expect("epoch 0").is_empty(),
2378            "no consensus -> G01 omitted, not averaged across disagreeing centers"
2379        );
2380        assert_eq!(report.quarantined.len(), 1);
2381        assert_eq!(report.quarantined[0].satellite, gps(1));
2382    }
2383
2384    #[test]
2385    fn merge_rejects_an_empty_input() {
2386        assert!(merge(&[], &MergeOptions::default()).is_err());
2387    }
2388
2389    #[test]
2390    fn merge_omits_an_unalignable_secondary_clock() {
2391        // Only 3 common satellites, but the default clock datum needs 5, so
2392        // center B's clocks cannot be put on A's datum. They must be dropped
2393        // rather than emitted raw, and a B-only satellite gets a position but no
2394        // clock.
2395        let a = sp3_records(&[
2396            ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
2397            ("G02", [16000.0, -21000.0, 6000.0], Some(200.0)),
2398            ("G03", [17000.0, -22000.0, 7000.0], Some(300.0)),
2399        ]);
2400        let b = sp3_records(&[
2401            ("G01", [15000.0, -20000.0, 5000.0], Some(150.0)),
2402            ("G02", [16000.0, -21000.0, 6000.0], Some(250.0)),
2403            ("G03", [17000.0, -22000.0, 7000.0], Some(350.0)),
2404            ("G04", [18000.0, -23000.0, 8000.0], Some(450.0)),
2405        ]);
2406
2407        let (merged, _) = merge(&[a, b], &MergeOptions::default()).expect("merge");
2408        let states = merged.states_at(0).expect("epoch 0");
2409
2410        // G04 is B-only (gap fill): position carried, clock unalignable -> dropped.
2411        assert!(states.contains_key(&gps(4)));
2412        assert!(
2413            states[&gps(4)].clock_s.is_none(),
2414            "an unalignable secondary clock must be dropped, not emitted raw"
2415        );
2416        // G01's clock comes from the reference (source 0), which is on its own datum.
2417        let g01_clock = states[&gps(1)]
2418            .clock_s
2419            .expect("G01 carries the reference clock");
2420        assert!((g01_clock - 100.0e-6).abs() < 1.0e-12, "got {g01_clock}");
2421    }
2422
2423    #[test]
2424    fn merge_rejects_mismatched_coordinate_systems() {
2425        let a = sp3_build(
2426            &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2427            "IGS14",
2428        );
2429        let b = sp3_build(
2430            &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2431            "IGS20",
2432        );
2433
2434        assert!(merge(&[a, b], &MergeOptions::default()).is_err());
2435    }
2436
2437    #[test]
2438    fn merge_rejects_different_igs_frame_labels_without_a_transform() {
2439        let a = sp3_build(
2440            &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2441            "IGS20",
2442        );
2443        let b = sp3_build(
2444            &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2445            "IGc20",
2446        );
2447
2448        let err = merge(&[a, b], &MergeOptions::default()).expect_err("frame mismatch");
2449        assert!(
2450            err.to_string().contains("mismatched coordinate systems"),
2451            "{err}"
2452        );
2453    }
2454
2455    #[test]
2456    fn merge_accepts_asserted_equivalent_labels_and_reports_assertion() {
2457        for (a_label, b_label) in [("IGS14", "ITRF2"), ("ITRF2", "IGS14")] {
2458            let a = sp3_build(
2459                &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
2460                a_label,
2461            );
2462            let b = sp3_build(
2463                &[("G02", [16000.0, -21000.0, 6000.0], Some(200.0), "")],
2464                b_label,
2465            );
2466            let opts = MergeOptions {
2467                frame_reconciliation: super::Sp3FrameReconciliationOptions {
2468                    asserted_equivalent_label_sets: vec![Sp3FrameLabelSet::pair("IGS14", "ITRF2")],
2469                    helmert: false,
2470                },
2471                ..MergeOptions::default()
2472            };
2473
2474            let (merged, report) = merge(&[a, b], &opts).expect("asserted frame merge");
2475
2476            let states = merged.states_at(0).expect("epoch 0");
2477            assert!(states.contains_key(&gps(1)));
2478            assert!(states.contains_key(&gps(2)));
2479            assert_eq!(merged.header.coordinate_system, a_label);
2480            assert_eq!(report.frame_reconciliations.len(), 1);
2481            let reconciliation = &report.frame_reconciliations[0];
2482            assert_eq!(
2483                reconciliation.method,
2484                Sp3FrameReconciliationMethod::AssertedEquivalence
2485            );
2486            assert_eq!(reconciliation.source_index, 1);
2487            assert_eq!(reconciliation.source_label, b_label);
2488            assert_eq!(reconciliation.target_label, a_label);
2489            assert_eq!(reconciliation.records_affected, 1);
2490            assert!(reconciliation.parameters.is_none());
2491            assert!(reconciliation.rates.is_none());
2492            assert_eq!(
2493                reconciliation
2494                    .asserted_label_set
2495                    .as_ref()
2496                    .expect("assertion set"),
2497                &vec!["IGS14".to_string(), "ITRF2".to_string()]
2498            );
2499        }
2500    }
2501
2502    #[test]
2503    fn merge_applies_helmert_reconciliation_to_resolved_labels() {
2504        // Source 0 sets the target label. Source 1 is IGS20, which resolves to
2505        // ITRF2020 and is transformed into IGS14/ITRF2014 at the record epoch.
2506        // Expected coordinates duplicate the ITRF/IGN 2020->2014 table values:
2507        // T=(-1.4,-0.9,1.4) mm, dT=(0,-0.1,0.2) mm/year, D=-0.42 ppb.
2508        let a = sp3_build(
2509            &[("G01", [14000.0, -19000.0, 4000.0], Some(100.0), "")],
2510            "IGS14",
2511        );
2512        let b = sp3_build(
2513            &[("G02", [15000.0, -20000.0, 5000.0], Some(200.0), "")],
2514            "IGS20",
2515        );
2516        let opts = MergeOptions {
2517            min_agree: 1,
2518            frame_reconciliation: super::Sp3FrameReconciliationOptions::helmert(),
2519            ..MergeOptions::default()
2520        };
2521
2522        let (merged, report) = merge(&[a, b], &opts).expect("helmert frame merge");
2523
2524        let g02 = merged.states_at(0).expect("epoch 0")[&gps(2)];
2525        let got = g02.position.as_array();
2526        let expected = [
2527            14_999_999.992_3,
2528            -19_999_999.993_048_087,
2529            5_000_000.000_396_175,
2530        ];
2531        for axis in 0..3 {
2532            assert!(
2533                (got[axis] - expected[axis]).abs() < 2.0e-9,
2534                "axis {axis}: got {}, expected {}",
2535                got[axis],
2536                expected[axis]
2537            );
2538        }
2539        assert_eq!(merged.header.coordinate_system, "IGS14");
2540        assert_eq!(report.frame_reconciliations.len(), 1);
2541        let reconciliation = &report.frame_reconciliations[0];
2542        assert_eq!(reconciliation.method, Sp3FrameReconciliationMethod::Helmert);
2543        assert_eq!(reconciliation.source_label, "IGS20");
2544        assert_eq!(reconciliation.target_label, "IGS14");
2545        assert_eq!(reconciliation.records_affected, 1);
2546        assert_eq!(
2547            reconciliation
2548                .parameters
2549                .expect("published parameters")
2550                .translation_mm,
2551            [-1.4, -0.9, 1.4]
2552        );
2553        assert_eq!(
2554            reconciliation.catalog_source_frame,
2555            Some(crate::frame_catalog::TerrestrialFrame::Itrf2020)
2556        );
2557        assert_eq!(
2558            reconciliation.catalog_target_frame,
2559            Some(crate::frame_catalog::TerrestrialFrame::Itrf2014)
2560        );
2561        assert!(!reconciliation.catalog_inverse);
2562        assert_eq!(
2563            reconciliation
2564                .rates
2565                .expect("published rates")
2566                .translation_mm_per_year,
2567            [0.0, -0.1, 0.2]
2568        );
2569        assert!(reconciliation
2570            .provenance
2571            .as_ref()
2572            .expect("provenance")
2573            .contains("ITRF2020 to past ITRFs"));
2574    }
2575
2576    #[test]
2577    fn merge_reports_inverse_helmert_catalog_direction() {
2578        let a = sp3_build(
2579            &[("G01", [14000.0, -19000.0, 4000.0], Some(100.0), "")],
2580            "IGS20",
2581        );
2582        let b = sp3_build(
2583            &[("G02", [15000.0, -20000.0, 5000.0], Some(200.0), "")],
2584            "IGS14",
2585        );
2586        let opts = MergeOptions {
2587            min_agree: 1,
2588            frame_reconciliation: super::Sp3FrameReconciliationOptions::helmert(),
2589            ..MergeOptions::default()
2590        };
2591
2592        let (_merged, report) = merge(&[a, b], &opts).expect("inverse helmert frame merge");
2593
2594        let reconciliation = &report.frame_reconciliations[0];
2595        assert_eq!(reconciliation.method, Sp3FrameReconciliationMethod::Helmert);
2596        assert_eq!(
2597            reconciliation.source_frame,
2598            Some(crate::frame_catalog::TerrestrialFrame::Itrf2014)
2599        );
2600        assert_eq!(
2601            reconciliation.target_frame,
2602            Some(crate::frame_catalog::TerrestrialFrame::Itrf2020)
2603        );
2604        assert_eq!(
2605            reconciliation.catalog_source_frame,
2606            Some(crate::frame_catalog::TerrestrialFrame::Itrf2020)
2607        );
2608        assert_eq!(
2609            reconciliation.catalog_target_frame,
2610            Some(crate::frame_catalog::TerrestrialFrame::Itrf2014)
2611        );
2612        assert!(reconciliation.catalog_inverse);
2613        assert_eq!(
2614            reconciliation
2615                .parameters
2616                .expect("published parameters")
2617                .translation_mm,
2618            [-1.4, -0.9, 1.4]
2619        );
2620    }
2621
2622    #[test]
2623    fn helmert_identity_label_reconciliation_is_bit_equal() {
2624        let a = sp3_build(
2625            &[("G01", [14000.0, -19000.0, 4000.0], Some(100.0), "")],
2626            "IGS20",
2627        );
2628        let b = sp3_build(
2629            &[("G02", [15000.125, -20000.5, 5000.25], Some(200.0), "")],
2630            "IGc20",
2631        );
2632        let original = b.states_at(0).expect("epoch 0")[&gps(2)].position;
2633        let opts = MergeOptions {
2634            min_agree: 1,
2635            frame_reconciliation: super::Sp3FrameReconciliationOptions::helmert(),
2636            ..MergeOptions::default()
2637        };
2638
2639        let (merged, report) = merge(&[a, b], &opts).expect("identity frame merge");
2640
2641        let g02 = merged.states_at(0).expect("epoch 0")[&gps(2)].position;
2642        for axis in 0..3 {
2643            assert_eq!(
2644                g02.as_array()[axis].to_bits(),
2645                original.as_array()[axis].to_bits()
2646            );
2647        }
2648        assert_eq!(report.frame_reconciliations.len(), 1);
2649        assert!(report.frame_reconciliations[0].identity);
2650        assert!(report.frame_reconciliations[0].parameters.is_none());
2651    }
2652
2653    #[test]
2654    fn helmert_reconciliation_rejects_unknown_labels() {
2655        let a = sp3_build(
2656            &[("G01", [14000.0, -19000.0, 4000.0], Some(100.0), "")],
2657            "ITRF2",
2658        );
2659        let b = sp3_build(
2660            &[("G02", [15000.0, -20000.0, 5000.0], Some(200.0), "")],
2661            "IGS20",
2662        );
2663        let opts = MergeOptions {
2664            frame_reconciliation: super::Sp3FrameReconciliationOptions::helmert(),
2665            ..MergeOptions::default()
2666        };
2667
2668        let err = merge(&[a, b], &opts).expect_err("unknown frame label");
2669
2670        assert!(
2671            err.to_string().contains("target label"),
2672            "unknown labels must not be guessed: {err}"
2673        );
2674    }
2675
2676    #[test]
2677    fn merge_uses_finest_union_grid_and_fills_sparse_precedence_cells() {
2678        // 15-min (900 s) center A and 5-min (300 s) center B over the same span.
2679        // The default output uses the 5-min union grid. Under cell precedence A
2680        // wins the epochs it carries, and B fills A's :05/:10 holes.
2681        let a = sp3_two_epochs(
2682            &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2683            &[("G01", [15003.0, -20003.0, 5003.0], Some(103.0))],
2684            900.0,
2685            "IGS14",
2686        );
2687        let b = sp3_epochs(
2688            0.0,
2689            &[
2690                &[("G01", [26000.0, -20000.0, 5000.0], Some(200.0))],
2691                &[("G01", [26001.0, -20001.0, 5001.0], Some(201.0))],
2692                &[("G01", [26002.0, -20002.0, 5002.0], Some(202.0))],
2693                &[("G01", [26003.0, -20003.0, 5003.0], Some(203.0))],
2694            ],
2695            300.0,
2696            "IGS14",
2697        );
2698
2699        let opts = MergeOptions {
2700            combine: MergeCombine::Precedence,
2701            min_agree: 1,
2702            ..MergeOptions::default()
2703        };
2704        let (merged, _report) = merge(&[a, b], &opts).expect("mixed-interval union merge");
2705
2706        assert_eq!(
2707            merged.header.epoch_interval_s, 300.0,
2708            "output is on the finest (300 s) input grid"
2709        );
2710        assert_eq!(
2711            merged.epochs.len(),
2712            4,
2713            "B fills the :05 and :10 epochs between A's samples"
2714        );
2715        let xs: Vec<f64> = (0..4)
2716            .map(|idx| {
2717                merged.states_at(idx).expect("epoch")[&gps(1)]
2718                    .position
2719                    .as_array()[0]
2720            })
2721            .collect();
2722        assert_eq!(
2723            xs,
2724            vec![15_000_000.0, 26_001_000.0, 26_002_000.0, 15_003_000.0]
2725        );
2726    }
2727
2728    #[test]
2729    fn mixed_cadence_interpolates_only_the_clock_datum_for_filled_cells() {
2730        let reference_epoch: Vec<SatSample<'_>> = vec![
2731            ("G01", [15_001.0, -20_000.0, 5_000.0], Some(100.0)),
2732            ("G02", [15_002.0, -20_000.0, 5_000.0], Some(200.0)),
2733            ("G03", [15_003.0, -20_000.0, 5_000.0], Some(300.0)),
2734            ("G04", [15_004.0, -20_000.0, 5_000.0], Some(400.0)),
2735            ("G05", [15_005.0, -20_000.0, 5_000.0], Some(500.0)),
2736        ];
2737        let shifted_epoch: Vec<SatSample<'_>> = reference_epoch
2738            .iter()
2739            .map(|(sat, position, clock)| (*sat, *position, clock.map(|value| value + 50.0)))
2740            .collect();
2741        let a = sp3_epochs(
2742            0.0,
2743            &[reference_epoch.as_slice(), reference_epoch.as_slice()],
2744            900.0,
2745            "IGS14",
2746        );
2747        let b = sp3_epochs(
2748            0.0,
2749            &[
2750                shifted_epoch.as_slice(),
2751                shifted_epoch.as_slice(),
2752                shifted_epoch.as_slice(),
2753                shifted_epoch.as_slice(),
2754            ],
2755            300.0,
2756            "IGS14",
2757        );
2758        let opts = MergeOptions {
2759            combine: MergeCombine::Precedence,
2760            min_agree: 1,
2761            ..MergeOptions::default()
2762        };
2763
2764        let (merged, _) = merge(&[a, b], &opts).expect("mixed-cadence clock merge");
2765
2766        assert_eq!(merged.epochs.len(), 4);
2767        for epoch_index in 0..4 {
2768            let clock = merged.states_at(epoch_index).expect("epoch")[&gps(1)]
2769                .clock_s
2770                .expect("aligned clock");
2771            assert!(
2772                (clock - 100.0e-6).abs() < 1.0e-15,
2773                "epoch {epoch_index}: {clock}"
2774            );
2775        }
2776    }
2777
2778    #[test]
2779    fn merge_decimates_with_explicit_coarser_target_interval() {
2780        // Two 5-min inputs, explicit 900 s target: both decimate to the 15-min grid.
2781        let recs = |x: f64| vec![("G01", [x, -20000.0, 5000.0], Some(100.0))];
2782        let make = || {
2783            sp3_epochs(
2784                0.0,
2785                &[
2786                    &recs(15000.0),
2787                    &recs(15001.0),
2788                    &recs(15002.0),
2789                    &recs(15003.0),
2790                ],
2791                300.0,
2792                "IGS14",
2793            )
2794        };
2795        let opts = MergeOptions {
2796            min_agree: 1,
2797            target_epoch_interval_s: Some(900.0),
2798            ..MergeOptions::default()
2799        };
2800        let (merged, _) = merge(&[make(), make()], &opts).expect("explicit coarse target");
2801        assert_eq!(merged.header.epoch_interval_s, 900.0);
2802        assert_eq!(
2803            merged.epochs.len(),
2804            2,
2805            "decimated 5-min inputs to the 900 s target"
2806        );
2807    }
2808
2809    #[test]
2810    fn merge_rejects_non_divisible_epoch_intervals() {
2811        // 900 s and 400 s: 900 is not an integer multiple of 400, so no exact
2812        // subset of the 400 s grid lands on the 900 s grid -> still rejected
2813        // (positional interpolation is never performed).
2814        let a = sp3_two_epochs(
2815            &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2816            &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2817            900.0,
2818            "IGS14",
2819        );
2820        let b = sp3_two_epochs(
2821            &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2822            &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2823            400.0,
2824            "IGS14",
2825        );
2826
2827        let err = merge(&[a, b], &MergeOptions::default()).expect_err("non-divisible intervals");
2828        assert!(
2829            err.to_string().contains("mismatched epoch intervals"),
2830            "{err}"
2831        );
2832    }
2833
2834    #[test]
2835    fn merge_rejects_a_non_whole_second_common_interval() {
2836        // The decimation lattice is whole-second J2000 keys, so a fractional
2837        // common interval must be rejected rather than silently rounded.
2838        let mk = || {
2839            sp3_two_epochs(
2840                &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2841                &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2842                900.0,
2843                "IGS14",
2844            )
2845        };
2846        let opts = MergeOptions {
2847            target_epoch_interval_s: Some(450.5),
2848            ..MergeOptions::default()
2849        };
2850        let err = merge(&[mk(), mk()], &opts).expect_err("fractional target");
2851        assert!(err.to_string().contains("whole number of seconds"), "{err}");
2852    }
2853
2854    #[test]
2855    fn merge_header_first_epoch_describes_the_union_grid_start() {
2856        // Source A starts at 00:00, source B at 00:15 (both 15-min). The union
2857        // begins at 00:00 and ends at 00:45, and the synthetic header must agree.
2858        let a = sp3_epochs(
2859            0.0,
2860            &[
2861                &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2862                &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2863                &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
2864            ],
2865            900.0,
2866            "IGS14",
2867        );
2868        let b = sp3_epochs(
2869            900.0,
2870            &[
2871                &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2872                &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
2873                &[("G01", [15003.0, -20003.0, 5003.0], Some(103.0))],
2874            ],
2875            900.0,
2876            "IGS14",
2877        );
2878
2879        let opts = MergeOptions {
2880            min_agree: 1,
2881            ..MergeOptions::default()
2882        };
2883        let (merged, _) = merge(&[a, b], &opts).expect("merge");
2884
2885        assert_eq!(
2886            merged.epochs.len(),
2887            4,
2888            "union epochs run from 00:00 to 00:45"
2889        );
2890        assert!(
2891            (merged.header.seconds_of_week - 345_600.0).abs() < 1.0e-6,
2892            "header sow must describe the union's first epoch 00:00 (345600 s), got {}",
2893            merged.header.seconds_of_week
2894        );
2895        assert!(
2896            merged.header.mjd_fraction.abs() < 1.0e-9,
2897            "header MJD fraction must describe 00:00, got {}",
2898            merged.header.mjd_fraction
2899        );
2900    }
2901
2902    #[test]
2903    fn merge_writer_recomputes_header_for_a_fine_union_grid() {
2904        // A starts on a 15-minute grid at 00:00. B starts on a 7.5-minute grid at
2905        // 00:07:30. The output is the 7.5-minute union grid, and the writer must
2906        // use that derived interval and first epoch in its `##` header.
2907        let a = sp3_epochs(
2908            0.0,
2909            &[
2910                &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2911                &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2912                &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
2913            ],
2914            900.0,
2915            "IGS14",
2916        );
2917        let b = sp3_epochs(
2918            450.0,
2919            &[
2920                &[("G01", [15010.0, -20010.0, 5010.0], Some(110.0))],
2921                &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2922                &[("G01", [15011.0, -20011.0, 5011.0], Some(111.0))],
2923                &[("G01", [15002.0, -20002.0, 5002.0], Some(102.0))],
2924            ],
2925            450.0,
2926            "IGS14",
2927        );
2928
2929        let opts = MergeOptions {
2930            min_agree: 1,
2931            ..MergeOptions::default()
2932        };
2933        let (merged, _) = merge(&[a, b], &opts).expect("mixed-cadence merge");
2934
2935        assert_eq!(merged.epochs.len(), 5, "union epochs run every 7.5 minutes");
2936        let text = merged.to_sp3_string();
2937        let header = text
2938            .lines()
2939            .find(|line| line.starts_with("## "))
2940            .expect("written ## header");
2941        let first_epoch = text
2942            .lines()
2943            .find(|line| line.starts_with("*  "))
2944            .expect("written first epoch");
2945
2946        assert_eq!(first_epoch, "*  2020  6 25  0  0  0.00000000");
2947        assert_eq!(
2948            header,
2949            "## 2111 345600.00000000   450.00000000 59025 0.0000000000000"
2950        );
2951    }
2952
2953    #[test]
2954    fn precedence_merge_never_switches_source_within_one_satellite_arc() {
2955        let a = sp3_two_epochs(
2956            &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2957            &[],
2958            900.0,
2959            "IGS14",
2960        );
2961        let b = sp3_two_epochs(
2962            &[("G01", [15000.001, -20000.0, 5000.0], Some(100.0))],
2963            &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2964            900.0,
2965            "IGS14",
2966        );
2967        let opts = MergeOptions {
2968            combine: MergeCombine::Precedence,
2969            min_agree: 1,
2970            precedence_scope: MergePrecedenceScope::SatelliteArc,
2971            ..MergeOptions::default()
2972        };
2973
2974        let (merged, _report) = merge(&[a, b], &opts).expect("merge");
2975        let epoch0 = merged.states_at(0).expect("epoch 0");
2976        let epoch1 = merged.states_at(1).expect("epoch 1");
2977
2978        assert!(epoch0.contains_key(&gps(1)));
2979        assert!(
2980            !epoch1.contains_key(&gps(1)),
2981            "G01 must not switch from source 0 at epoch 0 to source 1 at epoch 1"
2982        );
2983        assert_eq!(merged.header.epoch_interval_s, 900.0);
2984    }
2985
2986    #[test]
2987    fn cell_precedence_fills_a_preferred_source_dropout() {
2988        let a = sp3_two_epochs(
2989            &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0))],
2990            &[],
2991            900.0,
2992            "IGS14",
2993        );
2994        let b = sp3_two_epochs(
2995            &[("G01", [15000.001, -20000.0, 5000.0], Some(100.0))],
2996            &[("G01", [15001.0, -20001.0, 5001.0], Some(101.0))],
2997            900.0,
2998            "IGS14",
2999        );
3000        let opts = MergeOptions {
3001            combine: MergeCombine::Precedence,
3002            min_agree: 1,
3003            ..MergeOptions::default()
3004        };
3005
3006        let (merged, report) = merge(&[a, b], &opts).expect("merge");
3007
3008        assert!(merged.states_at(0).expect("epoch 0").contains_key(&gps(1)));
3009        let epoch1 = merged.states_at(1).expect("epoch 1");
3010        assert!(
3011            epoch1.contains_key(&gps(1)),
3012            "source 1 must fill source 0's dropout"
3013        );
3014        assert_eq!(epoch1[&gps(1)].position.as_array()[0], 15_001_000.0);
3015        assert!(report
3016            .single_source
3017            .iter()
3018            .any(|entry| entry.satellite == gps(1) && entry.sources == vec![1]));
3019    }
3020
3021    #[test]
3022    fn merge_filters_requested_constellations_and_header_satellites() {
3023        let a = sp3_two_epochs(
3024            &[
3025                ("G01", [15000.0, -20000.0, 5000.0], Some(100.0)),
3026                ("E01", [21000.0, -1000.0, 13000.0], Some(120.0)),
3027            ],
3028            &[
3029                ("G01", [15001.0, -20001.0, 5001.0], Some(101.0)),
3030                ("E01", [21001.0, -1001.0, 13001.0], Some(121.0)),
3031            ],
3032            900.0,
3033            "IGS14",
3034        );
3035        let systems = BTreeSet::from([GnssSystem::Gps]);
3036        let opts = MergeOptions {
3037            systems: Some(systems),
3038            ..MergeOptions::default()
3039        };
3040
3041        let (merged, _report) = merge(&[a], &opts).expect("merge");
3042
3043        assert_eq!(merged.header.satellites, vec![gps(1)]);
3044        for idx in 0..merged.epochs.len() {
3045            let states = merged.states_at(idx).expect("epoch");
3046            assert_eq!(states.keys().copied().collect::<Vec<_>>(), vec![gps(1)]);
3047        }
3048    }
3049
3050    #[test]
3051    fn merge_preserves_a_clock_event_flag() {
3052        // Source A carries an `E` clock-event flag on G01 (column 75); the merged
3053        // product must keep it so the interpolator still splits the clock arc.
3054        let a = sp3_build(
3055            &[(
3056                "G01",
3057                [15000.0, -20000.0, 5000.0],
3058                Some(100.0),
3059                "              E",
3060            )],
3061            "IGS14",
3062        );
3063        let b = sp3_build(
3064            &[("G01", [15000.0, -20000.0, 5000.0], Some(100.0), "")],
3065            "IGS14",
3066        );
3067
3068        let (merged, _) = merge(&[a, b], &MergeOptions::default()).expect("merge");
3069        let g01 = merged.states_at(0).expect("epoch 0")[&gps(1)];
3070
3071        assert!(
3072            g01.flags.clock_event,
3073            "merged cell must preserve a contributing source's clock-event flag"
3074        );
3075    }
3076
3077    #[test]
3078    fn merge_reports_effective_epoch_interval_from_actual_epochs() {
3079        // The header DECLARES a 300 s interval, but the two epochs are 15 min
3080        // (900 s) apart. The synthetic merged header must report the spacing of
3081        // the actual merged epochs, not inherit the stale declared value.
3082        let body = "#cP2020  6 25  0  0  0.00000000       2 ORBIT IGS14 FIT  TST\n\
3083            ## 2111 432000.00000000   300.00000000 59025 0.0000000000000\n\
3084            +    1   G01  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0\n\
3085            ++         0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0\n\
3086            %c G  cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3087            %c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3088            %f  1.2500000  1.025000000  0.00000000000  0.000000000000000\n\
3089            %f  0.0000000  0.000000000  0.00000000000  0.000000000000000\n\
3090            %i    0    0    0    0      0      0      0      0         0\n\
3091            %i    0    0    0    0      0      0      0      0         0\n\
3092            /* TEST SP3-c FIXTURE\n\
3093            *  2020  6 25  0  0  0.00000000\n\
3094            PG01  15000.000000 -20000.000000   5000.000000    100.000000\n\
3095            *  2020  6 25  0 15  0.00000000\n\
3096            PG01  15001.000000 -20001.000000   5001.000000    101.000000\n\
3097            EOF\n";
3098        let a = Sp3::parse(body.as_bytes()).expect("parse test sp3");
3099
3100        let (merged, _) = merge(&[a], &MergeOptions::default()).expect("merge");
3101
3102        assert!(
3103            (merged.header.epoch_interval_s - 900.0).abs() < 1.0e-6,
3104            "got {}",
3105            merged.header.epoch_interval_s
3106        );
3107    }
3108
3109    #[test]
3110    fn merge_rejects_unsorted_input_epochs_before_cadence_inference() {
3111        let body = "#cP2020  6 25  0  0  0.00000000       2 ORBIT IGS14 FIT  TST\n\
3112            ## 2111 432000.00000000   900.00000000 59025 0.0000000000000\n\
3113            +    1   G01  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0\n\
3114            ++         0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0\n\
3115            %c G  cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3116            %c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3117            %f  1.2500000  1.025000000  0.00000000000  0.000000000000000\n\
3118            %f  0.0000000  0.000000000  0.00000000000  0.000000000000000\n\
3119            %i    0    0    0    0      0      0      0      0         0\n\
3120            %i    0    0    0    0      0      0      0      0         0\n\
3121            /* TEST SP3-c FIXTURE\n\
3122            *  2020  6 25  0 15  0.00000000\n\
3123            PG01  15001.000000 -20001.000000   5001.000000    101.000000\n\
3124            *  2020  6 25  0  0  0.00000000\n\
3125            PG01  15000.000000 -20000.000000   5000.000000    100.000000\n\
3126            EOF\n";
3127        let source = Sp3::parse(body.as_bytes()).expect("parse unsorted test sp3");
3128
3129        let err = merge(&[source], &MergeOptions::default()).expect_err("unsorted epochs");
3130
3131        assert!(
3132            err.to_string()
3133                .contains("merge input epochs must be strictly increasing"),
3134            "{err}"
3135        );
3136    }
3137
3138    #[test]
3139    fn align_clock_reference_puts_other_on_the_reference_datum() {
3140        // `other`'s clocks all run +50 us ahead; after alignment they should sit
3141        // on `reference`'s datum (G01: 150 us - 50 us = 100 us = 1e-4 s).
3142        let reference = sp3([100.0, 200.0, 300.0]);
3143        let other = sp3([150.0, 250.0, 350.0]);
3144
3145        let aligned = align_clock_reference(&reference, &other, 3);
3146
3147        let g01 = aligned.states_at(0).expect("epoch 0")[&gps(1)];
3148        assert!(
3149            (g01.clock_s.unwrap() - 100.0e-6).abs() < 1.0e-15,
3150            "got {}",
3151            g01.clock_s.unwrap()
3152        );
3153        // Positions are untouched by clock alignment.
3154        let original = other.states_at(0).expect("epoch 0")[&gps(1)];
3155        assert_eq!(g01.position.as_array(), original.position.as_array());
3156    }
3157
3158    // Minimal single-epoch SP3-c with three satellites; each `clocks_us` entry is
3159    // that satellite's clock in microseconds (positions are arbitrary but non-zero
3160    // so they parse as valid records).
3161    fn sp3(clocks_us: [f64; 3]) -> Sp3 {
3162        let body = format!(
3163            "#cP2020  6 25  0  0  0.00000000       1 ORBIT IGS14 FIT  TST\n\
3164             ## 2111 432000.00000000   900.00000000 59025 0.0000000000000\n\
3165             +    3   G01G02G03  0  0  0  0  0  0  0  0  0  0  0  0  0  0\n\
3166             ++         0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0\n\
3167             %c G  cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3168             %c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
3169             %f  1.2500000  1.025000000  0.00000000000  0.000000000000000\n\
3170             %f  0.0000000  0.000000000  0.00000000000  0.000000000000000\n\
3171             %i    0    0    0    0      0      0      0      0         0\n\
3172             %i    0    0    0    0      0      0      0      0         0\n\
3173             /* TEST SP3-c FIXTURE\n\
3174             *  2020  6 25  0  0  0.00000000\n\
3175             PG01  15000.000000 -20000.000000   5000.000000 {:13.6}\n\
3176             PG02  -1234.567890   2345.678901  -3456.789012 {:13.6}\n\
3177             PG03   8000.000000  12000.000000 -19000.000000 {:13.6}\n\
3178             EOF\n",
3179            clocks_us[0], clocks_us[1], clocks_us[2]
3180        );
3181        Sp3::parse(body.as_bytes()).expect("parse test sp3")
3182    }
3183
3184    #[test]
3185    fn recovers_a_uniform_datum_shift() {
3186        // every `other` clock is +50 us (= 5e-5 s) from `reference`.
3187        let reference = sp3([100.0, 200.0, 300.0]);
3188        let other = sp3([150.0, 250.0, 350.0]);
3189
3190        let offsets = clock_reference_offset(&reference, &other, 3);
3191
3192        assert_eq!(offsets.len(), 1);
3193        assert_eq!(offsets[0].satellites, 3);
3194        assert!(
3195            (offsets[0].offset_s - 5.0e-5).abs() < 1.0e-12,
3196            "got {}",
3197            offsets[0].offset_s
3198        );
3199    }
3200
3201    #[test]
3202    fn median_rejects_a_single_outlier_clock() {
3203        // Two satellites agree (+50 us); one is a wild outlier (+9000 us). The
3204        // median over the three tracks the consensus instead of being dragged out.
3205        let reference = sp3([100.0, 200.0, 300.0]);
3206        let other = sp3([150.0, 250.0, 9_300.0]);
3207
3208        let offsets = clock_reference_offset(&reference, &other, 3);
3209
3210        assert_eq!(offsets.len(), 1);
3211        assert!(
3212            (offsets[0].offset_s - 5.0e-5).abs() < 1.0e-12,
3213            "got {}",
3214            offsets[0].offset_s
3215        );
3216    }
3217
3218    #[test]
3219    fn omits_epochs_below_min_common() {
3220        // Three common clocked satellites, but require four: the fragile estimate
3221        // is omitted rather than reported.
3222        let reference = sp3([100.0, 200.0, 300.0]);
3223        let other = sp3([150.0, 250.0, 350.0]);
3224
3225        assert!(clock_reference_offset(&reference, &other, 4).is_empty());
3226    }
3227
3228    #[test]
3229    fn merge_agreement_metric_reports_known_position_dispersion() {
3230        // Three centers place G01 on a line, 0 / +3 m / +6 m in X, all within a
3231        // wide consensus tolerance. The mean combine writes +3 m, so the member
3232        // distances from the combined value are {3, 0, 3} m:
3233        //   RMS = sqrt((9 + 0 + 9) / 3) = sqrt(6) m,  max = 3 m.
3234        let a = sp3_records(&[("G01", [15000.000, -20000.0, 5000.0], Some(100.0))]);
3235        let b = sp3_records(&[("G01", [15000.003, -20000.0, 5000.0], Some(100.0))]);
3236        let c = sp3_records(&[("G01", [15000.006, -20000.0, 5000.0], Some(100.0))]);
3237        let opts = MergeOptions {
3238            position_tolerance_m: 10.0,
3239            min_agree: 3,
3240            combine: MergeCombine::Mean,
3241            ..MergeOptions::default()
3242        };
3243
3244        let (_merged, report) = merge(&[a, b, c], &opts).expect("merge");
3245
3246        assert_eq!(report.agreement.len(), 1, "one accepted cell");
3247        let m = report.agreement[0];
3248        assert_eq!(m.satellite, gps(1));
3249        assert_eq!(m.position_members, 3);
3250        assert!(
3251            (m.position_rms_m - 6.0_f64.sqrt()).abs() < 1.0e-6,
3252            "got rms {}",
3253            m.position_rms_m
3254        );
3255        assert!(
3256            (m.position_max_m - 3.0).abs() < 1.0e-6,
3257            "got max {}",
3258            m.position_max_m
3259        );
3260
3261        // The pooled summaries over the single cell reproduce the cell values.
3262        assert!((report.position_agreement_rms_m().unwrap() - 6.0_f64.sqrt()).abs() < 1.0e-6);
3263        assert!((report.position_agreement_max_m().unwrap() - 3.0).abs() < 1.0e-6);
3264
3265        // Per-epoch aggregate: one epoch, one multi-source satellite.
3266        let per_epoch = report.per_epoch_agreement();
3267        assert_eq!(per_epoch.len(), 1);
3268        assert_eq!(per_epoch[0].satellites, 1);
3269        assert!((per_epoch[0].position_rms_m - 6.0_f64.sqrt()).abs() < 1.0e-6);
3270        assert!((per_epoch[0].position_max_m - 3.0).abs() < 1.0e-6);
3271    }
3272
3273    #[test]
3274    fn merge_agreement_metric_reports_known_clock_dispersion() {
3275        // Same positions across A/B/C (zero position spread); the three centers
3276        // share a clock datum (G01/G02 identical) so the per-epoch datum offset is
3277        // zero and G03's clocks stay as authored: 300 / 330 / 270 us. The mean
3278        // combine writes 300 us, so the deviations are {0, +30, -30} us:
3279        //   RMS = sqrt((0 + 30^2 + 30^2)/3) us = sqrt(600) us,  max = 30 us.
3280        let a = sp3([100.0, 200.0, 300.0]);
3281        let b = sp3([100.0, 200.0, 330.0]);
3282        let c = sp3([100.0, 200.0, 270.0]);
3283        let opts = MergeOptions {
3284            clock_min_common: 1,
3285            clock_tolerance_s: 1.0e-3,
3286            min_agree: 3,
3287            combine: MergeCombine::Mean,
3288            ..MergeOptions::default()
3289        };
3290
3291        let (_merged, report) = merge(&[a, b, c], &opts).expect("merge");
3292
3293        let g03 = report
3294            .agreement
3295            .iter()
3296            .find(|m| m.satellite == gps(3))
3297            .expect("G03 agreement metric");
3298        assert_eq!(g03.clock_members, 3);
3299        let expected_rms_s = 600.0_f64.sqrt() * 1.0e-6;
3300        assert!(
3301            (g03.clock_rms_s.unwrap() - expected_rms_s).abs() < 1.0e-15,
3302            "got clock rms {:?}",
3303            g03.clock_rms_s
3304        );
3305        assert!(
3306            (g03.clock_max_s.unwrap() - 30.0e-6).abs() < 1.0e-15,
3307            "got clock max {:?}",
3308            g03.clock_max_s
3309        );
3310        // G01/G02 agree exactly -> zero clock dispersion.
3311        for prn in [1u8, 2] {
3312            let m = report
3313                .agreement
3314                .iter()
3315                .find(|m| m.satellite == gps(prn))
3316                .expect("metric");
3317            assert!(m.clock_rms_s.unwrap().abs() < 1.0e-18, "prn {prn}");
3318            // Positions identical across centers -> zero position dispersion too.
3319            assert!(m.position_rms_m.abs() < 1.0e-9, "prn {prn}");
3320        }
3321
3322        // The clock pooled summary is the RMS over the three multi-source cells
3323        // (G01=0, G02=0, G03), each with 3 members:
3324        //   sqrt((0 + 0 + 3*expected^2) / 9) = expected / sqrt(3).
3325        let pooled = report.clock_agreement_rms_s().expect("clock pool");
3326        assert!(
3327            (pooled - expected_rms_s / 3.0_f64.sqrt()).abs() < 1.0e-15,
3328            "got pooled {pooled}"
3329        );
3330        assert!((report.clock_agreement_max_s().unwrap() - 30.0e-6).abs() < 1.0e-15);
3331    }
3332
3333    // Real-data oracle: combine published individual analysis-center final
3334    // products (COD/GFZ/JPL, 2026-04-30, GPS week 2416 DOY 120) and compare to the
3335    // published IGS official combined for the same day. The IGS combination is a
3336    // specific weighted algorithm, so the crate's mean combine is not a bit-match;
3337    // the gate is agreement at the inter-center spread level (cm-level bound), gated
3338    // at RMS < 2 cm and max < 5 cm (observed RMS ~0.7 cm, max ~1.6 cm over 88 cells).
3339    //
3340    // Fixture provenance: the COD/GFZ/JPL `_trim.SP3` files are the final precise
3341    // orbit products of CODE (AIUB Bern), GFZ Potsdam, and JPL, all frame IGc20 /
3342    // time system GPS (ESA/GRG excluded for IGS20 frame labelling). From the Wuhan
3343    // University IGS mirror `ftp://igs.gnsswhu.cn/pub/gps/products/2416/`, full-day
3344    // `.gz`: COD0OPSFIN_20261200000_01D_05M_ORB.SP3.gz (634569 B, sha256
3345    // 90393acaed691cd4d19cd4ade7153873eb41ef38585df177d9d540eac6316112);
3346    // GFZ0OPSFIN…05M_ORB.SP3.gz (647028 B, sha256
3347    // a51a04ab283a981ddec20ae77d575cd05f4f8249202e0ee4f73e7243b7817e88);
3348    // JPL0OPSFIN…05M_ORB.SP3.gz (482973 B, sha256
3349    // 3a39ccb2d097eddb139047532b2b93c5d538abc39255fc779278ac64f10cd185). Each trim
3350    // keeps the verbatim header and only the 11 epochs 09:45..12:15 landing on the
3351    // combined's 900 s grid plus the 8-sat subset common to all three centers and
3352    // the combined (G02,G03,G04,G05,G09,G17,G25,G31); velocity/correlation records
3353    // dropped, no values altered. Trim sha256: COD…_trim.SP3 (7227 B)
3354    // f3ad3f637134651d086815345f3e5f531a9dbacb6f739b7dddf664e0ab3a1795;
3355    // GFZ…_trim.SP3 (9805 B)
3356    // 9e50edc53ac42791923fd71c39b49a97bf516084f1d2b1dcb260685d2a8f11cc;
3357    // JPL…_trim.SP3 (8210 B)
3358    // 9ac5aafdabed38679892f57b42864cc3716d997400280f29ee8049a37057adf4. The oracle
3359    // IGS0OPSFIN combined product provenance is in `sp3/tests.rs`.
3360    #[cfg(sidereon_repo_tests)]
3361    #[test]
3362    fn merge_agrees_with_published_igs_combined_within_cm() {
3363        fn load(name: &str) -> Sp3 {
3364            let path = format!("{}/tests/fixtures/sp3/{}", env!("CARGO_MANIFEST_DIR"), name);
3365            let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("read {path}: {e}"));
3366            Sp3::parse(&bytes).unwrap_or_else(|e| panic!("parse {name}: {e}"))
3367        }
3368
3369        let cod = load("COD0OPSFIN_20261200945_02H30M_15M_ORB_trim.SP3");
3370        let gfz = load("GFZ0OPSFIN_20261200945_02H30M_15M_ORB_trim.SP3");
3371        let jpl = load("JPL0OPSFIN_20261200945_02H30M_15M_ORB_trim.SP3");
3372        let igs = load("IGS0OPSFIN_20261200945_02H30M_15M_ORB.SP3");
3373
3374        let (merged, report) =
3375            merge(&[cod, gfz, jpl], &MergeOptions::default()).expect("multi-center merge");
3376
3377        // All three centers agree at the 0.5 m position tolerance: nothing
3378        // quarantined, every cell a 3-source consensus.
3379        assert!(
3380            report.quarantined.is_empty(),
3381            "centers should agree: {:?}",
3382            report.quarantined
3383        );
3384        // A clean 3-source consensus everywhere: no gap-fills, no rejected
3385        // outliers, and every accepted cell backed by all three centers.
3386        assert!(
3387            report.single_source.is_empty(),
3388            "{:?}",
3389            report.single_source
3390        );
3391        assert!(
3392            report.position_outliers.is_empty(),
3393            "{:?}",
3394            report.position_outliers
3395        );
3396        assert!(
3397            report.agreement.iter().all(|a| a.position_members == 3),
3398            "every agreement cell should be a 3-source consensus"
3399        );
3400
3401        let mut igs_idx: std::collections::BTreeMap<i64, usize> = std::collections::BTreeMap::new();
3402        for (i, ep) in igs.epochs.iter().enumerate() {
3403            if let Some(s) = super::instant_to_j2000_seconds(ep) {
3404                igs_idx.insert(s.floor() as i64, i);
3405            }
3406        }
3407
3408        let mut sumsq = 0.0_f64;
3409        let mut max = 0.0_f64;
3410        let mut n = 0usize;
3411        for (mi, ep) in merged.epochs.iter().enumerate() {
3412            let key = super::instant_to_j2000_seconds(ep)
3413                .expect("merged epoch key")
3414                .floor() as i64;
3415            let ii = *igs_idx.get(&key).expect("IGS combined covers merged epoch");
3416            let merged_states = merged.states_at(mi).expect("merged states");
3417            let igs_states = igs.states_at(ii).expect("IGS states");
3418            for (sat, mst) in merged_states.iter() {
3419                let ist = igs_states
3420                    .get(sat)
3421                    .unwrap_or_else(|| panic!("merged sat {sat} missing from IGS combined"));
3422                let d = super::dist3(&mst.position.as_array(), &ist.position.as_array());
3423                sumsq += d * d;
3424                max = max.max(d);
3425                n += 1;
3426            }
3427        }
3428
3429        // Exact coverage: 8 satellites x 11 epochs, every merged cell present in
3430        // the IGS combined (proves same epochs/sats, not a lucky subset).
3431        assert_eq!(n, 88, "expected exactly 88 compared cells, got {n}");
3432        let rms = (sumsq / n as f64).sqrt();
3433        // Observed on this day: RMS ~0.7 cm, max ~1.6 cm. Gate at a cm-level bound.
3434        assert!(
3435            rms < 0.02,
3436            "combine-vs-IGS RMS {:.4} m ({} cells) exceeds the 2 cm gate",
3437            rms,
3438            n
3439        );
3440        assert!(
3441            max < 0.05,
3442            "combine-vs-IGS max {max:.4} m exceeds the 5 cm gate"
3443        );
3444
3445        // The internal inter-center agreement metric is also cm-level.
3446        let dispersion = report
3447            .position_agreement_rms_m()
3448            .expect("multi-source cells present");
3449        assert!(
3450            dispersion < 0.05,
3451            "inter-center position dispersion {dispersion:.4} m"
3452        );
3453    }
3454}