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}