Skip to main content

sidereon_core/sp3/
combine.rs

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