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