Skip to main content

sidereon_core/sp3/
continuity.rs

1//! Continuity attestation for precise-ephemeris sample series.
2//!
3//! A merged orbit product is assembled per `(epoch, satellite)` cell from
4//! several analysis centers. That is exactly the operation that can splice two
5//! physically inconsistent arcs together while every input remains individually
6//! well-formed. This module attests against that: it takes an ordered sample
7//! series and either attests that it is continuous or reports each violation
8//! with the epochs, the interval, and the magnitude that exceeded its bound.
9//!
10//! # Two checks, two jobs
11//!
12//! A single displacement-per-interval gate cannot do this work alone, and the
13//! reason is quantitative rather than stylistic. Measured on a real GFZ ultra
14//! product (satellite G01, 576 epochs at 300 s), adjacent-epoch ECEF chord
15//! distances run 827-956 km, so the implied chord speed is 2757-3187 m/s. A
16//! defensible upper bound for the class sits near 6 km/s (see [`OrbitClass`]),
17//! which leaves several hundred kilometres of displacement per epoch pair
18//! underneath the bound. A 500 m splice - a serious defect - moves the implied
19//! speed by under 2 m/s, roughly 0.05% of the observed chord speed. It is
20//! invisible to a speed gate by four orders of magnitude.
21//!
22//! So the two checks are deliberately separated:
23//!
24//! - [`ContinuityCheck::SpeedBound`] is a *gross corruption* gate. Its bound is
25//!   a true physical upper bound for the orbit class (see [`OrbitClass`]), so it
26//!   cannot false-positive on real data; it catches a record from the wrong
27//!   satellite, the wrong day, or a corrupt field. It is insensitive by
28//!   construction and is not asked to be otherwise.
29//! - [`ContinuityCheck::HoldOutResidual`] supplies the sensitivity. Each interior
30//!   sample is held out, predicted from its neighbours through the same
31//!   sliding-window Lagrange substrate the product's own interpolator uses
32//!   ([`super::interp::interpolate_precise_state`]), and compared against the
33//!   stored record. On a clean arc the residual is the interpolator's own
34//!   error - centimetres for GNSS MEO at 5-15 minute spacing. At a splice it
35//!   jumps to the magnitude of the splice, which is what localizes the offending
36//!   epoch pair.
37//!
38//! Run both. The bound gate is nearly free and rules out nonsense; the residual
39//! check is the one that finds a spliced arc.
40//!
41//! # Frame
42//!
43//! Samples are ITRF/IGS ECEF, matching [`PreciseEphemerisSample`]. The bound is
44//! therefore an *earth-fixed* bound, and [`OrbitClass`] derives it as such. Using
45//! an inertial orbital speed here would be a category error: for a prograde MEO
46//! satellite the earth-fixed speed is materially lower than the inertial speed
47//! (the measurement above versus an inertial 3874 m/s for GPS), and for a
48//! geostationary satellite it is near zero.
49//!
50//! # Ordering is this module's responsibility
51//!
52//! [`check_continuity`] sorts internally. A caller-ordered sequence that is
53//! trusted is the failure this module exists to prevent, so shuffled input and
54//! sorted input produce the identical verdict - there is a test that pins
55//! exactly that. One ordered structure ([`OrderedSeries`]) feeds every check, and
56//! it is built so that a zero or negative interval is *unrepresentable* in the
57//! comparison path rather than merely rejected: duplicate epochs are split out
58//! into [`ContinuityDefect::DuplicateEpoch`] during construction, after which
59//! adjacent pairs are strictly increasing by construction and the pair iterator
60//! is the only way to reach a comparison.
61//!
62//! Duplicates are reported, never silently deduplicated: two records for one
63//! epoch is a real defect of the data, and which of them is "the" sample is not
64//! this module's call to make.
65//!
66//! # This reports; it does not refuse
67//!
68//! [`check_continuity`] returns a [`ContinuityReport`] whether or not the series
69//! is continuous. A caller may legitimately want the product together with its
70//! defects - refusing is the caller's decision, made by consulting
71//! [`ContinuityReport::attested`]. The bounds themselves are physical and are
72//! never inferred from the data being validated: a check that can be widened
73//! until it passes is not a check.
74
75use std::collections::BTreeMap;
76
77use crate::astro::constants::earth::OMEGA_E_DOT_RAD_S;
78use crate::astro::constants::MU_EARTH;
79use crate::constants::KM_TO_M;
80use crate::id::GnssSatelliteId;
81use crate::sp3::interp::{
82    instant_to_j2000_seconds, interpolate_precise_state, precise_node_j2000_seconds_from_instant,
83    NEVILLE_POINTS,
84};
85use crate::sp3::samples::PreciseEphemerisSample;
86use crate::sp3::Sp3;
87use crate::{Error, Result};
88
89/// Inclusive evaluation window on the SP3 seconds-since-J2000 axis.
90///
91/// This identifies epochs the caller intends to evaluate. Continuity findings
92/// outside the window can still influence it through the interpolation
93/// neighbourhood, which is why queries also require a [`StencilExtent`].
94#[derive(Debug, Clone, Copy, PartialEq)]
95pub struct EpochWindow {
96    from_j2000_s: f64,
97    through_j2000_s: f64,
98}
99
100impl EpochWindow {
101    /// Construct an inclusive evaluation window.
102    ///
103    /// Both endpoints must be finite and `from_j2000_s` must not be later than
104    /// `through_j2000_s`.
105    pub fn new(from_j2000_s: f64, through_j2000_s: f64) -> Result<Self> {
106        if !from_j2000_s.is_finite() || !through_j2000_s.is_finite() {
107            return Err(Error::InvalidInput(
108                "SP3 continuity window endpoints must be finite".to_string(),
109            ));
110        }
111        if from_j2000_s > through_j2000_s {
112            return Err(Error::InvalidInput(
113                "SP3 continuity window start must not follow its end".to_string(),
114            ));
115        }
116        Ok(Self {
117            from_j2000_s,
118            through_j2000_s,
119        })
120    }
121
122    /// Inclusive first evaluation epoch, seconds since J2000.
123    pub fn from_j2000_s(self) -> f64 {
124        self.from_j2000_s
125    }
126
127    /// Inclusive last evaluation epoch, seconds since J2000.
128    pub fn through_j2000_s(self) -> f64 {
129        self.through_j2000_s
130    }
131}
132
133/// Time reach of the SP3 position interpolator's sliding node stencil.
134///
135/// Construct this with [`StencilExtent::for_sp3`]. The extent is derived from
136/// the product interval and the same 11-node constant used by the position
137/// interpolator, rather than accepted as a caller-supplied duration.
138#[derive(Debug, Clone, Copy, PartialEq)]
139pub struct StencilExtent {
140    grid_origin_j2000_s: f64,
141    interval_s: f64,
142    before_s: f64,
143    after_s: f64,
144}
145
146impl StencilExtent {
147    /// Derive the interpolation reach for an SP3 product.
148    ///
149    /// The degree-10 Lagrange substrate uses 11 nodes centered on the query, so
150    /// its nominal reach is five product intervals on either side. A non-finite
151    /// or non-positive declared interval is rejected.
152    pub fn for_sp3(sp3: &Sp3) -> Result<Self> {
153        let interval_s = sp3.header.epoch_interval_s;
154        if !interval_s.is_finite() || interval_s <= 0.0 {
155            return Err(Error::InvalidInput(
156                "SP3 stencil extent requires a positive finite epoch interval".to_string(),
157            ));
158        }
159        let grid_origin_j2000_s = sp3
160            .epochs_j2000_seconds()
161            .first()
162            .copied()
163            .filter(|epoch| epoch.is_finite())
164            .ok_or_else(|| {
165                Error::InvalidInput(
166                    "SP3 stencil extent requires at least one representable epoch".to_string(),
167                )
168            })?;
169        let half_nodes = (NEVILLE_POINTS / 2) as f64;
170        let half_width_s = half_nodes * interval_s;
171        Ok(Self {
172            grid_origin_j2000_s,
173            interval_s,
174            before_s: half_width_s,
175            after_s: half_width_s,
176        })
177    }
178
179    /// Nominal reach before an evaluated epoch, seconds.
180    pub fn before_s(self) -> f64 {
181        self.before_s
182    }
183
184    /// Nominal reach after an evaluated epoch, seconds.
185    pub fn after_s(self) -> f64 {
186        self.after_s
187    }
188
189    /// Union of nominal grid nodes the interpolator can select for any query in
190    /// `window`.
191    fn influence_bounds(self, window: EpochWindow) -> (f64, f64) {
192        let pivot_at_or_before = |query: f64| {
193            self.grid_origin_j2000_s
194                + ((query - self.grid_origin_j2000_s) / self.interval_s).floor() * self.interval_s
195        };
196        (
197            pivot_at_or_before(window.from_j2000_s) - self.before_s,
198            pivot_at_or_before(window.through_j2000_s) + self.after_s,
199        )
200    }
201}
202
203/// Window-scoped continuity decision.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum WindowContinuityDecision {
206    /// No recorded finding can enter an interpolation stencil for the window.
207    Accept,
208    /// At least one recorded finding can enter an interpolation stencil.
209    Refuse,
210}
211
212/// A window-scoped decision that retains both influencing and global findings.
213///
214/// `all_defects` remains available for logging findings the caller accepted
215/// around. Merge reports additionally populate `influencing_splices` and
216/// `all_splices` through their own verdict helper.
217#[derive(Debug, Clone, PartialEq)]
218pub struct WindowContinuityVerdict<'a> {
219    /// Accept or refuse the requested evaluation window.
220    pub decision: WindowContinuityDecision,
221    /// Defects whose time support intersects a stencil used by the window.
222    pub influencing_defects: Vec<&'a ContinuityDefect>,
223    /// Contributor-changing violations influencing the window.
224    pub influencing_splices: Vec<&'a super::combine::MergeContinuityViolation>,
225    /// Every defect in the underlying continuity report.
226    pub all_defects: &'a [ContinuityDefect],
227    /// Every contributor-changing violation in the merge report.
228    pub all_splices: Vec<&'a super::combine::MergeContinuityViolation>,
229}
230
231impl WindowContinuityVerdict<'_> {
232    /// Whether the requested window is accepted.
233    pub fn accepted(&self) -> bool {
234        self.decision == WindowContinuityDecision::Accept
235    }
236}
237
238/// Orbit class supplying a physical earth-fixed displacement bound.
239///
240/// Each bound is `sqrt(mu / a_min) + omega_earth * r_max`, the inertial speed at
241/// the class's tightest published semi-major axis plus the largest possible
242/// earth-rotation transport term at its widest radius. That sum is a true upper
243/// bound on earth-fixed speed for any geometry in the class, so the gate cannot
244/// false-positive on physically real data. It is correspondingly loose - see the
245/// module docs for why that is the correct trade for this check and where the
246/// sensitivity actually comes from.
247///
248/// Bounds are constants of the orbit class. None of them is derived from the
249/// series being validated.
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum OrbitClass {
252    /// GNSS MEO: GLONASS (a ~ 25 510 km) through Galileo (a ~ 29 600 km),
253    /// covering GPS, BeiDou MEO, NavIC MEO, and QZSS's MEO-like arcs.
254    MeoGnss,
255    /// Geostationary and inclined-geosynchronous (a ~ 42 164 km), including
256    /// BeiDou GEO/IGSO and QZSS.
257    Geosynchronous,
258    /// Low earth orbit from a ~ 6 678 km (300 km altitude) upward.
259    Leo,
260}
261
262impl OrbitClass {
263    /// Tightest published semi-major axis for the class, meters.
264    const fn min_semi_major_axis_m(self) -> f64 {
265        match self {
266            Self::MeoGnss => 25_510_000.0,
267            Self::Geosynchronous => 42_164_000.0,
268            Self::Leo => 6_678_000.0,
269        }
270    }
271
272    /// Widest radius for the class, meters, for the earth-rotation term.
273    const fn max_radius_m(self) -> f64 {
274        match self {
275            Self::MeoGnss => 29_600_000.0,
276            Self::Geosynchronous => 42_164_000.0,
277            Self::Leo => 8_378_000.0,
278        }
279    }
280
281    /// Physical earth-fixed speed bound for the class, meters per second.
282    pub fn max_earth_fixed_speed_m_s(self) -> f64 {
283        let mu_m3_s2 = MU_EARTH * KM_TO_M * KM_TO_M * KM_TO_M;
284        (mu_m3_s2 / self.min_semi_major_axis_m()).sqrt() + OMEGA_E_DOT_RAD_S * self.max_radius_m()
285    }
286}
287
288/// Which checks to run, and with what bounds.
289#[derive(Debug, Clone, PartialEq)]
290pub struct ContinuityOptions {
291    /// Earth-fixed speed bound for the adjacent-pair gate. `None` disables the
292    /// gate.
293    pub speed_bound: Option<SpeedBound>,
294    /// Hold-out interpolation residual tolerance in meters. `None` disables the
295    /// residual check.
296    ///
297    /// This is the sensitive check. A tolerance well above the interpolator's own
298    /// error at the product's sampling (centimetres for GNSS MEO at 5-15 minutes)
299    /// and well below the smallest splice worth reporting is the useful range;
300    /// 1.0 m is a defensible default for a merged GNSS orbit product.
301    pub residual_tolerance_m: Option<f64>,
302}
303
304/// Source of the adjacent-pair speed bound.
305#[derive(Debug, Clone, Copy, PartialEq)]
306pub enum SpeedBound {
307    /// Derive the bound from the orbit class.
308    OrbitClass(OrbitClass),
309    /// An explicit caller-supplied earth-fixed bound, meters per second.
310    ExplicitMaxSpeed(f64),
311}
312
313impl SpeedBound {
314    fn value_m_s(self) -> f64 {
315        match self {
316            Self::OrbitClass(class) => class.max_earth_fixed_speed_m_s(),
317            Self::ExplicitMaxSpeed(bound) => bound,
318        }
319    }
320}
321
322impl ContinuityOptions {
323    /// Both checks, with the class bound and a 1 m residual tolerance.
324    pub fn for_orbit_class(class: OrbitClass) -> Self {
325        Self {
326            speed_bound: Some(SpeedBound::OrbitClass(class)),
327            residual_tolerance_m: Some(1.0),
328        }
329    }
330}
331
332/// One continuity defect. Every variant names the satellite and locates itself
333/// in time.
334#[derive(Debug, Clone, PartialEq)]
335pub enum ContinuityDefect {
336    /// Two or more samples share one epoch. Reported, never deduplicated: which
337    /// record is authoritative is not this module's decision.
338    DuplicateEpoch {
339        /// The satellite.
340        sat: GnssSatelliteId,
341        /// The repeated epoch, seconds since J2000.
342        epoch_j2000_s: f64,
343        /// How many samples carried this epoch (>= 2).
344        occurrences: usize,
345    },
346    /// A satellite carried a single usable sample, so no adjacent pair and no
347    /// hold-out prediction exist. Not a pass.
348    SingleSampleSeries {
349        /// The satellite.
350        sat: GnssSatelliteId,
351    },
352    /// An adjacent pair implies an earth-fixed speed above the physical bound.
353    SpeedBound {
354        /// The satellite.
355        sat: GnssSatelliteId,
356        /// Earlier epoch of the pair, seconds since J2000.
357        from_j2000_s: f64,
358        /// Later epoch of the pair, seconds since J2000.
359        to_j2000_s: f64,
360        /// Elapsed interval, seconds. Strictly positive by construction.
361        interval_s: f64,
362        /// 3D chord displacement over the interval, meters.
363        displacement_m: f64,
364        /// Implied earth-fixed chord speed, meters per second.
365        implied_speed_m_s: f64,
366        /// The bound it exceeded, meters per second.
367        bound_m_s: f64,
368    },
369    /// A sample disagrees with the arc its neighbours describe. This is the
370    /// splice detector: `preceding_j2000_s` and `epoch_j2000_s` bracket the
371    /// offending pair.
372    HoldOutResidual {
373        /// The satellite.
374        sat: GnssSatelliteId,
375        /// Epoch of the held-out sample, seconds since J2000.
376        epoch_j2000_s: f64,
377        /// Epoch of the preceding sample, seconds since J2000 - the other side
378        /// of the offending pair.
379        preceding_j2000_s: f64,
380        /// 3D distance between the stored record and the value predicted from
381        /// its neighbours, meters.
382        residual_m: f64,
383        /// The tolerance it exceeded, meters.
384        tolerance_m: f64,
385    },
386}
387
388impl ContinuityDefect {
389    /// The satellite this defect concerns.
390    pub fn satellite(&self) -> GnssSatelliteId {
391        match self {
392            Self::DuplicateEpoch { sat, .. }
393            | Self::SingleSampleSeries { sat }
394            | Self::SpeedBound { sat, .. }
395            | Self::HoldOutResidual { sat, .. } => *sat,
396        }
397    }
398
399    /// Whether this finding can enter any interpolation stencil used by the
400    /// evaluation window.
401    pub(super) fn influences(&self, window: EpochWindow, stencil: StencilExtent) -> bool {
402        let (needed_from, needed_through) = stencil.influence_bounds(window);
403        let support = match self {
404            Self::DuplicateEpoch { epoch_j2000_s, .. } => Some((*epoch_j2000_s, *epoch_j2000_s)),
405            Self::SingleSampleSeries { .. } => None,
406            Self::SpeedBound {
407                from_j2000_s,
408                to_j2000_s,
409                ..
410            } => Some((from_j2000_s.min(*to_j2000_s), from_j2000_s.max(*to_j2000_s))),
411            Self::HoldOutResidual {
412                epoch_j2000_s,
413                preceding_j2000_s,
414                ..
415            } => Some((
416                epoch_j2000_s.min(*preceding_j2000_s),
417                epoch_j2000_s.max(*preceding_j2000_s),
418            )),
419        };
420
421        match support {
422            Some((from, through)) => from <= needed_through && through >= needed_from,
423            // The existing variant has no epoch. Querying the existing report
424            // must therefore treat it conservatively rather than invent a
425            // location or hide an unresolved input defect.
426            None => true,
427        }
428    }
429}
430
431/// Which check produced a defect, for callers filtering a report.
432#[derive(Debug, Clone, Copy, PartialEq, Eq)]
433pub enum ContinuityCheck {
434    /// Input well-formedness: duplicate epochs, single-sample series.
435    Input,
436    /// The physical earth-fixed speed gate.
437    SpeedBound,
438    /// The hold-out interpolation residual check.
439    HoldOutResidual,
440}
441
442/// Result of a continuity check.
443///
444/// Absence of defects is the attestation; presence of defects is the structured
445/// report. Both are the same type so a caller cannot accidentally handle only
446/// one.
447#[derive(Debug, Clone, PartialEq, Default)]
448pub struct ContinuityReport {
449    /// Every defect found, ordered by satellite then epoch.
450    pub defects: Vec<ContinuityDefect>,
451    /// Adjacent pairs the speed gate examined.
452    pub pairs_checked: usize,
453    /// Samples the hold-out residual check examined.
454    pub residuals_checked: usize,
455    /// Samples the residual check could not evaluate because the held-out
456    /// neighbourhood was not interpolatable (a coverage gap, or too few
457    /// neighbours). Reported rather than silently dropped: a caller must be able
458    /// to tell "checked and clean" from "not checked".
459    pub residuals_skipped: usize,
460}
461
462impl ContinuityReport {
463    /// Whether the series is attested continuous: no defects of any class.
464    pub fn attested(&self) -> bool {
465        self.defects.is_empty()
466    }
467
468    /// Defects produced by one check.
469    pub fn defects_from(&self, check: ContinuityCheck) -> impl Iterator<Item = &ContinuityDefect> {
470        self.defects.iter().filter(move |defect| {
471            let source = match defect {
472                ContinuityDefect::DuplicateEpoch { .. }
473                | ContinuityDefect::SingleSampleSeries { .. } => ContinuityCheck::Input,
474                ContinuityDefect::SpeedBound { .. } => ContinuityCheck::SpeedBound,
475                ContinuityDefect::HoldOutResidual { .. } => ContinuityCheck::HoldOutResidual,
476            };
477            source == check
478        })
479    }
480
481    /// Findings that can influence evaluation in `window` through `stencil`.
482    ///
483    /// This filters the existing report. It does not rerun continuity checks or
484    /// alter their defaults. A [`ContinuityDefect::SingleSampleSeries`] has no
485    /// stored epoch, so it conservatively influences every window.
486    pub fn defects_influencing(
487        &self,
488        window: EpochWindow,
489        stencil: StencilExtent,
490    ) -> Vec<&ContinuityDefect> {
491        self.defects
492            .iter()
493            .filter(|defect| defect.influences(window, stencil))
494            .collect()
495    }
496
497    /// Decide whether recorded defects can influence an evaluation window.
498    ///
499    /// The full report remains available in [`WindowContinuityVerdict::all_defects`]
500    /// whether the result accepts or refuses the window.
501    pub fn verdict_for_window(
502        &self,
503        window: EpochWindow,
504        stencil: StencilExtent,
505    ) -> WindowContinuityVerdict<'_> {
506        let influencing_defects = self.defects_influencing(window, stencil);
507        let decision = if influencing_defects.is_empty() {
508            WindowContinuityDecision::Accept
509        } else {
510            WindowContinuityDecision::Refuse
511        };
512        WindowContinuityVerdict {
513            decision,
514            influencing_defects,
515            influencing_splices: Vec::new(),
516            all_defects: &self.defects,
517            all_splices: Vec::new(),
518        }
519    }
520}
521
522/// One satellite's samples, sorted by epoch with duplicates already extracted.
523///
524/// Construction is the only way to obtain one, and construction sorts. After it,
525/// `x` is strictly increasing, so every adjacent pair has a strictly positive
526/// interval *by construction* - a zero or negative interval is unrepresentable
527/// in the comparison path rather than checked for at each use.
528struct OrderedSeries {
529    /// Node epochs, seconds since J2000, strictly increasing.
530    x: Vec<f64>,
531    /// Node positions in file-native kilometres, matching the interpolation
532    /// substrate's fit units.
533    kx: Vec<f64>,
534    ky: Vec<f64>,
535    kz: Vec<f64>,
536    /// Node positions in SI meters, for displacement arithmetic.
537    pos_m: Vec<[f64; 3]>,
538}
539
540impl OrderedSeries {
541    /// Sort `samples` by epoch and split out duplicates as defects.
542    ///
543    /// Non-representable epochs are dropped from the comparison path and counted
544    /// as duplicates of nothing - they cannot be placed on the axis at all, so
545    /// they are excluded here and surface as a short series.
546    fn build(
547        sat: GnssSatelliteId,
548        samples: &[&PreciseEphemerisSample],
549        defects: &mut Vec<ContinuityDefect>,
550    ) -> Option<Self> {
551        let mut placed: Vec<(f64, [f64; 3])> = Vec::with_capacity(samples.len());
552        for sample in samples {
553            let Some(seconds) = instant_to_j2000_seconds(&sample.epoch) else {
554                continue;
555            };
556            if !seconds.is_finite() || !sample.position_ecef_m.iter().all(|c| c.is_finite()) {
557                continue;
558            }
559            let Some(node) = precise_node_j2000_seconds_from_instant(&sample.epoch) else {
560                continue;
561            };
562            placed.push((node, sample.position_ecef_m));
563        }
564
565        // The sort is this module's job, not the caller's: a shuffled input must
566        // reach the checks in the identical order a sorted one does.
567        placed.sort_by(|a, b| a.0.total_cmp(&b.0));
568
569        let mut series = Self {
570            x: Vec::with_capacity(placed.len()),
571            kx: Vec::with_capacity(placed.len()),
572            ky: Vec::with_capacity(placed.len()),
573            kz: Vec::with_capacity(placed.len()),
574            pos_m: Vec::with_capacity(placed.len()),
575        };
576
577        let mut index = 0usize;
578        while index < placed.len() {
579            let epoch = placed[index].0;
580            let mut run = 1usize;
581            while index + run < placed.len() && placed[index + run].0 == epoch {
582                run += 1;
583            }
584            if run > 1 {
585                // Duplicates are a defect of the data and are not resolved here.
586                // Every record for the epoch is withheld from the comparison
587                // path: silently keeping one would be picking an authoritative
588                // sample, which is the caller's decision.
589                defects.push(ContinuityDefect::DuplicateEpoch {
590                    sat,
591                    epoch_j2000_s: epoch,
592                    occurrences: run,
593                });
594            } else {
595                let (node, pos_m) = placed[index];
596                series.x.push(node);
597                series.kx.push(pos_m[0] / KM_TO_M);
598                series.ky.push(pos_m[1] / KM_TO_M);
599                series.kz.push(pos_m[2] / KM_TO_M);
600                series.pos_m.push(pos_m);
601            }
602            index += run;
603        }
604
605        if series.x.is_empty() {
606            return None;
607        }
608        Some(series)
609    }
610
611    fn len(&self) -> usize {
612        self.x.len()
613    }
614
615    /// Every adjacent pair, in time order. The only path to a comparison, and
616    /// every interval it yields is strictly positive.
617    fn pairs(&self) -> impl Iterator<Item = (usize, usize)> + '_ {
618        (1..self.x.len()).map(|index| (index - 1, index))
619    }
620}
621
622/// Check an ordered ephemeris sample sequence for continuity.
623///
624/// Samples for any number of satellites may be supplied in any order; they are
625/// grouped by satellite and sorted by epoch internally. The verdict is
626/// independent of the order they arrive in.
627///
628/// This never refuses a series. Every finding lands in
629/// [`ContinuityReport::defects`], and whether a product with defects is
630/// acceptable is the caller's decision.
631pub fn check_continuity(
632    samples: &[PreciseEphemerisSample],
633    options: &ContinuityOptions,
634) -> ContinuityReport {
635    let mut by_sat: BTreeMap<GnssSatelliteId, Vec<&PreciseEphemerisSample>> = BTreeMap::new();
636    for sample in samples {
637        by_sat.entry(sample.sat).or_default().push(sample);
638    }
639
640    let mut report = ContinuityReport::default();
641    for (sat, sat_samples) in by_sat {
642        // Each satellite's defects are collected and ordered on their own before
643        // joining the report, so the report reads as one timeline per satellite
644        // rather than interleaving satellites or check passes.
645        let mut sat_defects = Vec::new();
646        let Some(series) = OrderedSeries::build(sat, &sat_samples, &mut sat_defects) else {
647            report.defects.append(&mut sat_defects);
648            continue;
649        };
650        if series.len() < 2 {
651            sat_defects.push(ContinuityDefect::SingleSampleSeries { sat });
652            report.defects.append(&mut sat_defects);
653            continue;
654        }
655
656        if let Some(bound) = options.speed_bound {
657            check_speed_bound(sat, &series, bound, &mut sat_defects, &mut report);
658        }
659        if let Some(tolerance_m) = options.residual_tolerance_m {
660            check_hold_out_residual(sat, &series, tolerance_m, &mut sat_defects, &mut report);
661        }
662
663        sat_defects.sort_by(|a, b| defect_sort_key(a).total_cmp(&defect_sort_key(b)));
664        report.defects.append(&mut sat_defects);
665    }
666    report
667}
668
669fn check_speed_bound(
670    sat: GnssSatelliteId,
671    series: &OrderedSeries,
672    bound: SpeedBound,
673    defects: &mut Vec<ContinuityDefect>,
674    report: &mut ContinuityReport,
675) {
676    let bound_m_s = bound.value_m_s();
677    for (lo, hi) in series.pairs() {
678        let interval_s = series.x[hi] - series.x[lo];
679        let displacement_m = distance_m(series.pos_m[lo], series.pos_m[hi]);
680        report.pairs_checked += 1;
681
682        let implied_speed_m_s = displacement_m / interval_s;
683        if implied_speed_m_s > bound_m_s {
684            defects.push(ContinuityDefect::SpeedBound {
685                sat,
686                from_j2000_s: series.x[lo],
687                to_j2000_s: series.x[hi],
688                interval_s,
689                displacement_m,
690                implied_speed_m_s,
691                bound_m_s,
692            });
693        }
694    }
695}
696
697/// Hold out each interior sample and compare it against the arc its neighbours
698/// describe.
699///
700/// The prediction runs through the same sliding-window Lagrange substrate the
701/// product's own interpolator uses, so the residual on a clean arc is the
702/// interpolator's own error rather than a second, differently-wrong model of the
703/// orbit.
704///
705/// # Why the hold-out is by parity, not one node at a time
706///
707/// Deleting a single node from an otherwise uniform series does not leave a
708/// series that is merely one sample shorter: it leaves one interval of twice the
709/// nominal spacing, which the substrate correctly classifies as a *coverage gap*
710/// and refuses to interpolate across. The evaluation then degrades to a
711/// one-sided extrapolation, whose error grows with the polynomial degree and
712/// reaches hundreds of kilometres at the arc's end - it would measure the
713/// extrapolation, not the data.
714///
715/// Holding out every other sample instead keeps the retained series uniform (at
716/// twice the spacing), so the substrate sees no gap and each held-out epoch is a
717/// genuine interpolation bracketed by real neighbours. Two passes of opposite
718/// parity cover every interior sample exactly once. This is the same decimation
719/// hold-out the sample-source parity oracle uses.
720///
721/// Endpoints are never held out: with no neighbour on one side any evaluation
722/// there is an extrapolation regardless of scheme.
723fn check_hold_out_residual(
724    sat: GnssSatelliteId,
725    series: &OrderedSeries,
726    tolerance_m: f64,
727    defects: &mut Vec<ContinuityDefect>,
728    report: &mut ContinuityReport,
729) {
730    let interior = series.len().saturating_sub(2);
731    if interior == 0 {
732        // Only endpoints exist; nothing can be held out with a neighbour on both
733        // sides. Counted as skipped so the caller can see the check did not run.
734        report.residuals_skipped += series.len();
735        return;
736    }
737
738    for parity in [0usize, 1usize] {
739        // Retained nodes: every sample whose index shares this parity. Held-out
740        // nodes are the interior samples of the opposite parity.
741        let keep: Vec<usize> = (0..series.len())
742            .filter(|index| index % 2 == parity)
743            .collect();
744        let held: Vec<usize> = (1..series.len() - 1)
745            .filter(|index| index % 2 != parity)
746            .collect();
747        if held.is_empty() {
748            continue;
749        }
750        if keep.len() < 2 {
751            // Too few retained nodes to define any fit for this parity.
752            report.residuals_skipped += held.len();
753            continue;
754        }
755
756        let x: Vec<f64> = keep.iter().map(|&i| series.x[i]).collect();
757        let kx: Vec<f64> = keep.iter().map(|&i| series.kx[i]).collect();
758        let ky: Vec<f64> = keep.iter().map(|&i| series.ky[i]).collect();
759        let kz: Vec<f64> = keep.iter().map(|&i| series.kz[i]).collect();
760
761        for index in held {
762            let query = series.x[index];
763            match interpolate_precise_state(sat, &x, &kx, &ky, &kz, &[], query) {
764                Ok(state) => {
765                    report.residuals_checked += 1;
766                    let predicted = [state.position.x_m, state.position.y_m, state.position.z_m];
767                    let residual_m = distance_m(predicted, series.pos_m[index]);
768                    if residual_m > tolerance_m {
769                        defects.push(ContinuityDefect::HoldOutResidual {
770                            sat,
771                            epoch_j2000_s: query,
772                            preceding_j2000_s: series.x[index - 1],
773                            residual_m,
774                            tolerance_m,
775                        });
776                    }
777                }
778                Err(_) => {
779                    // The retained neighbourhood is not interpolatable at this
780                    // epoch - a real coverage gap in the product, not an artifact
781                    // of the hold-out. Not a defect of the data, but not a pass
782                    // either, so it is counted rather than dropped.
783                    report.residuals_skipped += 1;
784                }
785            }
786        }
787    }
788}
789
790/// Epoch a defect is anchored at, for ordering a report as a timeline.
791fn defect_sort_key(defect: &ContinuityDefect) -> f64 {
792    match defect {
793        ContinuityDefect::DuplicateEpoch { epoch_j2000_s, .. } => *epoch_j2000_s,
794        ContinuityDefect::SingleSampleSeries { .. } => f64::NEG_INFINITY,
795        ContinuityDefect::SpeedBound { from_j2000_s, .. } => *from_j2000_s,
796        ContinuityDefect::HoldOutResidual { epoch_j2000_s, .. } => *epoch_j2000_s,
797    }
798}
799
800fn distance_m(a: [f64; 3], b: [f64; 3]) -> f64 {
801    let dx = b[0] - a[0];
802    let dy = b[1] - a[1];
803    let dz = b[2] - a[2];
804    (dx * dx + dy * dy + dz * dz).sqrt()
805}