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}