Skip to main content

sidereon_core/sp3/
combine.rs

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