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};
84use crate::sp3::samples::PreciseEphemerisSample;
85
86/// Orbit class supplying a physical earth-fixed displacement bound.
87///
88/// Each bound is `sqrt(mu / a_min) + omega_earth * r_max`, the inertial speed at
89/// the class's tightest published semi-major axis plus the largest possible
90/// earth-rotation transport term at its widest radius. That sum is a true upper
91/// bound on earth-fixed speed for any geometry in the class, so the gate cannot
92/// false-positive on physically real data. It is correspondingly loose - see the
93/// module docs for why that is the correct trade for this check and where the
94/// sensitivity actually comes from.
95///
96/// Bounds are constants of the orbit class. None of them is derived from the
97/// series being validated.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum OrbitClass {
100    /// GNSS MEO: GLONASS (a ~ 25 510 km) through Galileo (a ~ 29 600 km),
101    /// covering GPS, BeiDou MEO, NavIC MEO, and QZSS's MEO-like arcs.
102    MeoGnss,
103    /// Geostationary and inclined-geosynchronous (a ~ 42 164 km), including
104    /// BeiDou GEO/IGSO and QZSS.
105    Geosynchronous,
106    /// Low earth orbit from a ~ 6 678 km (300 km altitude) upward.
107    Leo,
108}
109
110impl OrbitClass {
111    /// Tightest published semi-major axis for the class, meters.
112    const fn min_semi_major_axis_m(self) -> f64 {
113        match self {
114            Self::MeoGnss => 25_510_000.0,
115            Self::Geosynchronous => 42_164_000.0,
116            Self::Leo => 6_678_000.0,
117        }
118    }
119
120    /// Widest radius for the class, meters, for the earth-rotation term.
121    const fn max_radius_m(self) -> f64 {
122        match self {
123            Self::MeoGnss => 29_600_000.0,
124            Self::Geosynchronous => 42_164_000.0,
125            Self::Leo => 8_378_000.0,
126        }
127    }
128
129    /// Physical earth-fixed speed bound for the class, meters per second.
130    pub fn max_earth_fixed_speed_m_s(self) -> f64 {
131        let mu_m3_s2 = MU_EARTH * KM_TO_M * KM_TO_M * KM_TO_M;
132        (mu_m3_s2 / self.min_semi_major_axis_m()).sqrt() + OMEGA_E_DOT_RAD_S * self.max_radius_m()
133    }
134}
135
136/// Which checks to run, and with what bounds.
137#[derive(Debug, Clone, PartialEq)]
138pub struct ContinuityOptions {
139    /// Earth-fixed speed bound for the adjacent-pair gate. `None` disables the
140    /// gate.
141    pub speed_bound: Option<SpeedBound>,
142    /// Hold-out interpolation residual tolerance in meters. `None` disables the
143    /// residual check.
144    ///
145    /// This is the sensitive check. A tolerance well above the interpolator's own
146    /// error at the product's sampling (centimetres for GNSS MEO at 5-15 minutes)
147    /// and well below the smallest splice worth reporting is the useful range;
148    /// 1.0 m is a defensible default for a merged GNSS orbit product.
149    pub residual_tolerance_m: Option<f64>,
150}
151
152/// Source of the adjacent-pair speed bound.
153#[derive(Debug, Clone, Copy, PartialEq)]
154pub enum SpeedBound {
155    /// Derive the bound from the orbit class.
156    OrbitClass(OrbitClass),
157    /// An explicit caller-supplied earth-fixed bound, meters per second.
158    ExplicitMaxSpeed(f64),
159}
160
161impl SpeedBound {
162    fn value_m_s(self) -> f64 {
163        match self {
164            Self::OrbitClass(class) => class.max_earth_fixed_speed_m_s(),
165            Self::ExplicitMaxSpeed(bound) => bound,
166        }
167    }
168}
169
170impl ContinuityOptions {
171    /// Both checks, with the class bound and a 1 m residual tolerance.
172    pub fn for_orbit_class(class: OrbitClass) -> Self {
173        Self {
174            speed_bound: Some(SpeedBound::OrbitClass(class)),
175            residual_tolerance_m: Some(1.0),
176        }
177    }
178}
179
180/// One continuity defect. Every variant names the satellite and locates itself
181/// in time.
182#[derive(Debug, Clone, PartialEq)]
183pub enum ContinuityDefect {
184    /// Two or more samples share one epoch. Reported, never deduplicated: which
185    /// record is authoritative is not this module's decision.
186    DuplicateEpoch {
187        /// The satellite.
188        sat: GnssSatelliteId,
189        /// The repeated epoch, seconds since J2000.
190        epoch_j2000_s: f64,
191        /// How many samples carried this epoch (>= 2).
192        occurrences: usize,
193    },
194    /// A satellite carried a single usable sample, so no adjacent pair and no
195    /// hold-out prediction exist. Not a pass.
196    SingleSampleSeries {
197        /// The satellite.
198        sat: GnssSatelliteId,
199    },
200    /// An adjacent pair implies an earth-fixed speed above the physical bound.
201    SpeedBound {
202        /// The satellite.
203        sat: GnssSatelliteId,
204        /// Earlier epoch of the pair, seconds since J2000.
205        from_j2000_s: f64,
206        /// Later epoch of the pair, seconds since J2000.
207        to_j2000_s: f64,
208        /// Elapsed interval, seconds. Strictly positive by construction.
209        interval_s: f64,
210        /// 3D chord displacement over the interval, meters.
211        displacement_m: f64,
212        /// Implied earth-fixed chord speed, meters per second.
213        implied_speed_m_s: f64,
214        /// The bound it exceeded, meters per second.
215        bound_m_s: f64,
216    },
217    /// A sample disagrees with the arc its neighbours describe. This is the
218    /// splice detector: `preceding_j2000_s` and `epoch_j2000_s` bracket the
219    /// offending pair.
220    HoldOutResidual {
221        /// The satellite.
222        sat: GnssSatelliteId,
223        /// Epoch of the held-out sample, seconds since J2000.
224        epoch_j2000_s: f64,
225        /// Epoch of the preceding sample, seconds since J2000 - the other side
226        /// of the offending pair.
227        preceding_j2000_s: f64,
228        /// 3D distance between the stored record and the value predicted from
229        /// its neighbours, meters.
230        residual_m: f64,
231        /// The tolerance it exceeded, meters.
232        tolerance_m: f64,
233    },
234}
235
236impl ContinuityDefect {
237    /// The satellite this defect concerns.
238    pub fn satellite(&self) -> GnssSatelliteId {
239        match self {
240            Self::DuplicateEpoch { sat, .. }
241            | Self::SingleSampleSeries { sat }
242            | Self::SpeedBound { sat, .. }
243            | Self::HoldOutResidual { sat, .. } => *sat,
244        }
245    }
246}
247
248/// Which check produced a defect, for callers filtering a report.
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub enum ContinuityCheck {
251    /// Input well-formedness: duplicate epochs, single-sample series.
252    Input,
253    /// The physical earth-fixed speed gate.
254    SpeedBound,
255    /// The hold-out interpolation residual check.
256    HoldOutResidual,
257}
258
259/// Result of a continuity check.
260///
261/// Absence of defects is the attestation; presence of defects is the structured
262/// report. Both are the same type so a caller cannot accidentally handle only
263/// one.
264#[derive(Debug, Clone, PartialEq, Default)]
265pub struct ContinuityReport {
266    /// Every defect found, ordered by satellite then epoch.
267    pub defects: Vec<ContinuityDefect>,
268    /// Adjacent pairs the speed gate examined.
269    pub pairs_checked: usize,
270    /// Samples the hold-out residual check examined.
271    pub residuals_checked: usize,
272    /// Samples the residual check could not evaluate because the held-out
273    /// neighbourhood was not interpolatable (a coverage gap, or too few
274    /// neighbours). Reported rather than silently dropped: a caller must be able
275    /// to tell "checked and clean" from "not checked".
276    pub residuals_skipped: usize,
277}
278
279impl ContinuityReport {
280    /// Whether the series is attested continuous: no defects of any class.
281    pub fn attested(&self) -> bool {
282        self.defects.is_empty()
283    }
284
285    /// Defects produced by one check.
286    pub fn defects_from(&self, check: ContinuityCheck) -> impl Iterator<Item = &ContinuityDefect> {
287        self.defects.iter().filter(move |defect| {
288            let source = match defect {
289                ContinuityDefect::DuplicateEpoch { .. }
290                | ContinuityDefect::SingleSampleSeries { .. } => ContinuityCheck::Input,
291                ContinuityDefect::SpeedBound { .. } => ContinuityCheck::SpeedBound,
292                ContinuityDefect::HoldOutResidual { .. } => ContinuityCheck::HoldOutResidual,
293            };
294            source == check
295        })
296    }
297}
298
299/// One satellite's samples, sorted by epoch with duplicates already extracted.
300///
301/// Construction is the only way to obtain one, and construction sorts. After it,
302/// `x` is strictly increasing, so every adjacent pair has a strictly positive
303/// interval *by construction* - a zero or negative interval is unrepresentable
304/// in the comparison path rather than checked for at each use.
305struct OrderedSeries {
306    /// Node epochs, seconds since J2000, strictly increasing.
307    x: Vec<f64>,
308    /// Node positions in file-native kilometres, matching the interpolation
309    /// substrate's fit units.
310    kx: Vec<f64>,
311    ky: Vec<f64>,
312    kz: Vec<f64>,
313    /// Node positions in SI meters, for displacement arithmetic.
314    pos_m: Vec<[f64; 3]>,
315}
316
317impl OrderedSeries {
318    /// Sort `samples` by epoch and split out duplicates as defects.
319    ///
320    /// Non-representable epochs are dropped from the comparison path and counted
321    /// as duplicates of nothing - they cannot be placed on the axis at all, so
322    /// they are excluded here and surface as a short series.
323    fn build(
324        sat: GnssSatelliteId,
325        samples: &[&PreciseEphemerisSample],
326        defects: &mut Vec<ContinuityDefect>,
327    ) -> Option<Self> {
328        let mut placed: Vec<(f64, [f64; 3])> = Vec::with_capacity(samples.len());
329        for sample in samples {
330            let Some(seconds) = instant_to_j2000_seconds(&sample.epoch) else {
331                continue;
332            };
333            if !seconds.is_finite() || !sample.position_ecef_m.iter().all(|c| c.is_finite()) {
334                continue;
335            }
336            let Some(node) = precise_node_j2000_seconds_from_instant(&sample.epoch) else {
337                continue;
338            };
339            placed.push((node, sample.position_ecef_m));
340        }
341
342        // The sort is this module's job, not the caller's: a shuffled input must
343        // reach the checks in the identical order a sorted one does.
344        placed.sort_by(|a, b| a.0.total_cmp(&b.0));
345
346        let mut series = Self {
347            x: Vec::with_capacity(placed.len()),
348            kx: Vec::with_capacity(placed.len()),
349            ky: Vec::with_capacity(placed.len()),
350            kz: Vec::with_capacity(placed.len()),
351            pos_m: Vec::with_capacity(placed.len()),
352        };
353
354        let mut index = 0usize;
355        while index < placed.len() {
356            let epoch = placed[index].0;
357            let mut run = 1usize;
358            while index + run < placed.len() && placed[index + run].0 == epoch {
359                run += 1;
360            }
361            if run > 1 {
362                // Duplicates are a defect of the data and are not resolved here.
363                // Every record for the epoch is withheld from the comparison
364                // path: silently keeping one would be picking an authoritative
365                // sample, which is the caller's decision.
366                defects.push(ContinuityDefect::DuplicateEpoch {
367                    sat,
368                    epoch_j2000_s: epoch,
369                    occurrences: run,
370                });
371            } else {
372                let (node, pos_m) = placed[index];
373                series.x.push(node);
374                series.kx.push(pos_m[0] / KM_TO_M);
375                series.ky.push(pos_m[1] / KM_TO_M);
376                series.kz.push(pos_m[2] / KM_TO_M);
377                series.pos_m.push(pos_m);
378            }
379            index += run;
380        }
381
382        if series.x.is_empty() {
383            return None;
384        }
385        Some(series)
386    }
387
388    fn len(&self) -> usize {
389        self.x.len()
390    }
391
392    /// Every adjacent pair, in time order. The only path to a comparison, and
393    /// every interval it yields is strictly positive.
394    fn pairs(&self) -> impl Iterator<Item = (usize, usize)> + '_ {
395        (1..self.x.len()).map(|index| (index - 1, index))
396    }
397}
398
399/// Check an ordered ephemeris sample sequence for continuity.
400///
401/// Samples for any number of satellites may be supplied in any order; they are
402/// grouped by satellite and sorted by epoch internally. The verdict is
403/// independent of the order they arrive in.
404///
405/// This never refuses a series. Every finding lands in
406/// [`ContinuityReport::defects`], and whether a product with defects is
407/// acceptable is the caller's decision.
408pub fn check_continuity(
409    samples: &[PreciseEphemerisSample],
410    options: &ContinuityOptions,
411) -> ContinuityReport {
412    let mut by_sat: BTreeMap<GnssSatelliteId, Vec<&PreciseEphemerisSample>> = BTreeMap::new();
413    for sample in samples {
414        by_sat.entry(sample.sat).or_default().push(sample);
415    }
416
417    let mut report = ContinuityReport::default();
418    for (sat, sat_samples) in by_sat {
419        // Each satellite's defects are collected and ordered on their own before
420        // joining the report, so the report reads as one timeline per satellite
421        // rather than interleaving satellites or check passes.
422        let mut sat_defects = Vec::new();
423        let Some(series) = OrderedSeries::build(sat, &sat_samples, &mut sat_defects) else {
424            report.defects.append(&mut sat_defects);
425            continue;
426        };
427        if series.len() < 2 {
428            sat_defects.push(ContinuityDefect::SingleSampleSeries { sat });
429            report.defects.append(&mut sat_defects);
430            continue;
431        }
432
433        if let Some(bound) = options.speed_bound {
434            check_speed_bound(sat, &series, bound, &mut sat_defects, &mut report);
435        }
436        if let Some(tolerance_m) = options.residual_tolerance_m {
437            check_hold_out_residual(sat, &series, tolerance_m, &mut sat_defects, &mut report);
438        }
439
440        sat_defects.sort_by(|a, b| defect_sort_key(a).total_cmp(&defect_sort_key(b)));
441        report.defects.append(&mut sat_defects);
442    }
443    report
444}
445
446fn check_speed_bound(
447    sat: GnssSatelliteId,
448    series: &OrderedSeries,
449    bound: SpeedBound,
450    defects: &mut Vec<ContinuityDefect>,
451    report: &mut ContinuityReport,
452) {
453    let bound_m_s = bound.value_m_s();
454    for (lo, hi) in series.pairs() {
455        let interval_s = series.x[hi] - series.x[lo];
456        let displacement_m = distance_m(series.pos_m[lo], series.pos_m[hi]);
457        report.pairs_checked += 1;
458
459        let implied_speed_m_s = displacement_m / interval_s;
460        if implied_speed_m_s > bound_m_s {
461            defects.push(ContinuityDefect::SpeedBound {
462                sat,
463                from_j2000_s: series.x[lo],
464                to_j2000_s: series.x[hi],
465                interval_s,
466                displacement_m,
467                implied_speed_m_s,
468                bound_m_s,
469            });
470        }
471    }
472}
473
474/// Hold out each interior sample and compare it against the arc its neighbours
475/// describe.
476///
477/// The prediction runs through the same sliding-window Lagrange substrate the
478/// product's own interpolator uses, so the residual on a clean arc is the
479/// interpolator's own error rather than a second, differently-wrong model of the
480/// orbit.
481///
482/// # Why the hold-out is by parity, not one node at a time
483///
484/// Deleting a single node from an otherwise uniform series does not leave a
485/// series that is merely one sample shorter: it leaves one interval of twice the
486/// nominal spacing, which the substrate correctly classifies as a *coverage gap*
487/// and refuses to interpolate across. The evaluation then degrades to a
488/// one-sided extrapolation, whose error grows with the polynomial degree and
489/// reaches hundreds of kilometres at the arc's end - it would measure the
490/// extrapolation, not the data.
491///
492/// Holding out every other sample instead keeps the retained series uniform (at
493/// twice the spacing), so the substrate sees no gap and each held-out epoch is a
494/// genuine interpolation bracketed by real neighbours. Two passes of opposite
495/// parity cover every interior sample exactly once. This is the same decimation
496/// hold-out the sample-source parity oracle uses.
497///
498/// Endpoints are never held out: with no neighbour on one side any evaluation
499/// there is an extrapolation regardless of scheme.
500fn check_hold_out_residual(
501    sat: GnssSatelliteId,
502    series: &OrderedSeries,
503    tolerance_m: f64,
504    defects: &mut Vec<ContinuityDefect>,
505    report: &mut ContinuityReport,
506) {
507    let interior = series.len().saturating_sub(2);
508    if interior == 0 {
509        // Only endpoints exist; nothing can be held out with a neighbour on both
510        // sides. Counted as skipped so the caller can see the check did not run.
511        report.residuals_skipped += series.len();
512        return;
513    }
514
515    for parity in [0usize, 1usize] {
516        // Retained nodes: every sample whose index shares this parity. Held-out
517        // nodes are the interior samples of the opposite parity.
518        let keep: Vec<usize> = (0..series.len())
519            .filter(|index| index % 2 == parity)
520            .collect();
521        let held: Vec<usize> = (1..series.len() - 1)
522            .filter(|index| index % 2 != parity)
523            .collect();
524        if held.is_empty() {
525            continue;
526        }
527        if keep.len() < 2 {
528            // Too few retained nodes to define any fit for this parity.
529            report.residuals_skipped += held.len();
530            continue;
531        }
532
533        let x: Vec<f64> = keep.iter().map(|&i| series.x[i]).collect();
534        let kx: Vec<f64> = keep.iter().map(|&i| series.kx[i]).collect();
535        let ky: Vec<f64> = keep.iter().map(|&i| series.ky[i]).collect();
536        let kz: Vec<f64> = keep.iter().map(|&i| series.kz[i]).collect();
537
538        for index in held {
539            let query = series.x[index];
540            match interpolate_precise_state(sat, &x, &kx, &ky, &kz, &[], query) {
541                Ok(state) => {
542                    report.residuals_checked += 1;
543                    let predicted = [state.position.x_m, state.position.y_m, state.position.z_m];
544                    let residual_m = distance_m(predicted, series.pos_m[index]);
545                    if residual_m > tolerance_m {
546                        defects.push(ContinuityDefect::HoldOutResidual {
547                            sat,
548                            epoch_j2000_s: query,
549                            preceding_j2000_s: series.x[index - 1],
550                            residual_m,
551                            tolerance_m,
552                        });
553                    }
554                }
555                Err(_) => {
556                    // The retained neighbourhood is not interpolatable at this
557                    // epoch - a real coverage gap in the product, not an artifact
558                    // of the hold-out. Not a defect of the data, but not a pass
559                    // either, so it is counted rather than dropped.
560                    report.residuals_skipped += 1;
561                }
562            }
563        }
564    }
565}
566
567/// Epoch a defect is anchored at, for ordering a report as a timeline.
568fn defect_sort_key(defect: &ContinuityDefect) -> f64 {
569    match defect {
570        ContinuityDefect::DuplicateEpoch { epoch_j2000_s, .. } => *epoch_j2000_s,
571        ContinuityDefect::SingleSampleSeries { .. } => f64::NEG_INFINITY,
572        ContinuityDefect::SpeedBound { from_j2000_s, .. } => *from_j2000_s,
573        ContinuityDefect::HoldOutResidual { epoch_j2000_s, .. } => *epoch_j2000_s,
574    }
575}
576
577fn distance_m(a: [f64; 3], b: [f64; 3]) -> f64 {
578    let dx = b[0] - a[0];
579    let dy = b[1] - a[1];
580    let dz = b[2] - a[2];
581    (dx * dx + dy * dy + dz * dz).sqrt()
582}