Skip to main content

sidereon_core/sp3/
combine.rs

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