Skip to main content

sidereon_core/sp3/
combine.rs

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