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