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