sidereon_core/sp3/verify.rs
1//! Precise-ephemeris interpolation contract, verification, and policy.
2//!
3//! # Interpolation contract (for consumers migrating from another interpolator)
4//!
5//! Sidereon interpolates a precise-ephemeris product with two independent,
6//! separately-referenced channels (full detail on
7//! [`Sp3::position_at_j2000_seconds`](crate::sp3::Sp3::position_at_j2000_seconds)):
8//!
9//! - **Position**: a sliding-window Lagrange (Neville) polynomial matching the
10//! RTKLIB `preceph.c` recipe. The window is up to **11 nodes (degree 10)**
11//! centred on the query and clamped to the contiguous run of nodes bracketing
12//! it (never interpolating across a coverage gap). Each node's ECEF position
13//! is rotated about `+z` by `OMEGA_E_DOT * (t_node - query)` into the
14//! query-epoch earth-fixed frame before evaluation.
15//! - **Clock**: a not-a-knot cubic spline matching
16//! `scipy.interpolate.CubicSpline(x, y)` with `bc_type="not-a-knot"`,
17//! `extrapolate=True`, fit on the contiguous clock sub-arc containing the
18//! query (the arc is split at each `E` clock-event epoch).
19//!
20//! **Node axis**: integer seconds since J2000, floored to whole seconds (the
21//! query epoch is **not** floored). **Units**: the fit is in the file-native
22//! units the references consume, kilometers for position and microseconds for
23//! clock, and the single unit multiply to SI (meters `* 1000`, seconds `* 1e-6`)
24//! happens **after** evaluation. **Coverage**: a query more than one nominal node
25//! spacing beyond the node span, or deep inside an interior gap, is rejected
26//! ([`crate::Error::EpochOutOfRange`]) rather than extrapolated.
27//!
28//! A consumer migrating from a global cubic spline over SP3 (for example scipy
29//! `CubicSpline` over the whole day) will see the **largest divergence
30//! mid-interval** and near-zero divergence at the nodes: a global degree-3 spline
31//! and a local degree-10 Lagrange window agree at the sample points but differ by
32//! up to hundreds of meters between them, especially near day boundaries and
33//! gaps. [`compare_position_series`] quantifies this per epoch so a migration is
34//! measured, not guessed.
35//!
36//! # Recommendation: assert the canonical interpolation; do not add a
37//! configurable spline mode
38//!
39//! A configurable interpolation policy (e.g. a switchable "global cubic-spline
40//! position mode") is **not recommended** for sidereon:
41//!
42//! - **The position recipe is a validated capability, not a free parameter.** The
43//! Neville window is pinned to the RTKLIB reference and validated end-to-end
44//! against PPP truth; a global cubic-spline alternative is a *worse* orbit
45//! interpolator (~200 m error at day boundaries and across gaps) that sidereon
46//! deliberately replaced. Exposing it as a mode would re-introduce a known-bad
47//! result as a supported option.
48//! - **It conflicts with the bit-exact philosophy.** Sidereon's contract is one
49//! canonical, byte-reproducible answer per query (the clock channel is a
50//! 0-ULP `scipy.CubicSpline` target; the position channel is bit-stable against
51//! RTKLIB). A per-call mode flag multiplies the outputs a downstream must pin
52//! and undermines "there is exactly one number here".
53//! - **The real migration need is a bridge, not a mode.** Consumers coming from a
54//! different interpolator do not need sidereon to *emulate* it; they need to
55//! quantify the difference and satisfy themselves the canonical recipe is at
56//! least as good. That is a verification tool ([`compare_position_series`]),
57//! which this module provides, rather than a configuration surface.
58//!
59//! If a genuinely distinct product (e.g. a documented cubic-spline field for a
60//! non-IGS use case) is ever required, it should be a **separate, explicitly
61//! named source type** with its own validated reference and its own bit-exact
62//! contract, never a mode flag mutating the meaning of the existing
63//! `position_at_j2000_seconds`.
64
65use crate::id::GnssSatelliteId;
66use crate::observables::{ObservableEphemerisSource, ObservablesError};
67
68/// One epoch of a supplied reference series to compare against.
69///
70/// `position_ecef_m` is the reference ITRF/IGS ECEF position in meters at
71/// `query_j2000_s` (seconds since J2000, in the source's time scale); `clock_s`
72/// is the reference satellite clock in seconds, `None` if the reference carries
73/// no clock at this epoch.
74#[derive(Debug, Clone, Copy, PartialEq)]
75pub struct ReferenceState {
76 /// Query epoch, seconds since J2000 (source time scale).
77 pub query_j2000_s: f64,
78 /// Reference ECEF position, meters.
79 pub position_ecef_m: [f64; 3],
80 /// Reference satellite clock offset, seconds (`None` if absent).
81 pub clock_s: Option<f64>,
82}
83
84/// Per-epoch divergence between sidereon's interpolation and a reference state.
85#[derive(Debug, Clone, Copy, PartialEq)]
86pub struct InterpolationDivergence {
87 /// Query epoch, seconds since J2000.
88 pub query_j2000_s: f64,
89 /// Sidereon's interpolated ECEF position, meters.
90 pub interpolated_position_m: [f64; 3],
91 /// The supplied reference ECEF position, meters.
92 pub reference_position_m: [f64; 3],
93 /// `interpolated - reference` per axis, meters.
94 pub position_diff_m: [f64; 3],
95 /// Euclidean norm of `position_diff_m`, meters.
96 pub position_norm_m: f64,
97 /// Sidereon's interpolated clock, seconds (`None` if the source had none).
98 pub interpolated_clock_s: Option<f64>,
99 /// The supplied reference clock, seconds (`None` if the reference had none).
100 pub reference_clock_s: Option<f64>,
101 /// `interpolated - reference` clock, seconds; `Some` only when both clocks
102 /// are present.
103 pub clock_diff_s: Option<f64>,
104}
105
106/// Aggregate report over a compared reference series.
107#[derive(Debug, Clone, PartialEq)]
108pub struct InterpolationComparison {
109 /// Per-epoch divergence, in the order of the supplied reference series.
110 pub per_epoch: Vec<InterpolationDivergence>,
111 /// Largest per-epoch position-difference norm, meters (`0.0` if empty).
112 pub max_position_norm_m: f64,
113 /// Root-mean-square of the per-epoch position-difference norms, meters
114 /// (`0.0` if empty).
115 pub rms_position_norm_m: f64,
116 /// Largest absolute clock difference over epochs where both clocks exist,
117 /// seconds; `None` if no epoch had both.
118 pub max_abs_clock_diff_s: Option<f64>,
119}
120
121/// Compare sidereon's interpolation of `sat` against a supplied `reference`
122/// series, reporting per-epoch and aggregate divergence.
123///
124/// The source is any [`ObservableEphemerisSource`] (a parsed [`crate::sp3::Sp3`]
125/// or a sample-backed [`crate::sp3::PreciseEphemerisSamples`]); it is evaluated
126/// at each `reference[i].query_j2000_s` and differenced against
127/// `reference[i]`. This lets a consumer migrating from another SP3 interpolator
128/// validate the divergence per epoch (largest mid-interval, ~0 at nodes) instead
129/// of guessing it.
130///
131/// The first epoch the source cannot evaluate (out of coverage, unknown
132/// satellite, non-finite query) aborts with that [`ObservablesError`]; supply
133/// in-coverage epochs. An empty `reference` yields an empty, zero-valued report.
134pub fn compare_position_series(
135 source: &dyn ObservableEphemerisSource,
136 sat: GnssSatelliteId,
137 reference: &[ReferenceState],
138) -> Result<InterpolationComparison, ObservablesError> {
139 let mut per_epoch = Vec::with_capacity(reference.len());
140 let mut max_position_norm_m = 0.0f64;
141 let mut sum_sq_norm = 0.0f64;
142 let mut max_abs_clock_diff_s: Option<f64> = None;
143
144 for reference_state in reference {
145 let state = source.observable_state_at_j2000_s(sat, reference_state.query_j2000_s)?;
146 let interpolated_position_m = state.position_ecef_m;
147 let reference_position_m = reference_state.position_ecef_m;
148 let position_diff_m = [
149 interpolated_position_m[0] - reference_position_m[0],
150 interpolated_position_m[1] - reference_position_m[1],
151 interpolated_position_m[2] - reference_position_m[2],
152 ];
153 let position_norm_m = (position_diff_m[0] * position_diff_m[0]
154 + position_diff_m[1] * position_diff_m[1]
155 + position_diff_m[2] * position_diff_m[2])
156 .sqrt();
157
158 let clock_diff_s = match (state.clock_s, reference_state.clock_s) {
159 (Some(a), Some(b)) => {
160 let diff = a - b;
161 let abs = diff.abs();
162 max_abs_clock_diff_s = Some(max_abs_clock_diff_s.map_or(abs, |m| m.max(abs)));
163 Some(diff)
164 }
165 _ => None,
166 };
167
168 max_position_norm_m = max_position_norm_m.max(position_norm_m);
169 sum_sq_norm += position_norm_m * position_norm_m;
170
171 per_epoch.push(InterpolationDivergence {
172 query_j2000_s: reference_state.query_j2000_s,
173 interpolated_position_m,
174 reference_position_m,
175 position_diff_m,
176 position_norm_m,
177 interpolated_clock_s: state.clock_s,
178 reference_clock_s: reference_state.clock_s,
179 clock_diff_s,
180 });
181 }
182
183 let rms_position_norm_m = if per_epoch.is_empty() {
184 0.0
185 } else {
186 (sum_sq_norm / per_epoch.len() as f64).sqrt()
187 };
188
189 Ok(InterpolationComparison {
190 per_epoch,
191 max_position_norm_m,
192 rms_position_norm_m,
193 max_abs_clock_diff_s,
194 })
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use crate::astro::time::model::{Instant, InstantRepr, JulianDateSplit, TimeScale};
201 use crate::sp3::{PreciseEphemerisSample, PreciseEphemerisSamples};
202 use crate::GnssSystem;
203
204 const J2000_JD_WHOLE: f64 = 2_451_545.0;
205 const SECONDS_PER_DAY: f64 = 86_400.0;
206
207 fn gps(prn: u8) -> GnssSatelliteId {
208 GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
209 }
210
211 fn sample(j2000_s: f64, pos: [f64; 3], clk: Option<f64>) -> PreciseEphemerisSample {
212 let split =
213 JulianDateSplit::new(J2000_JD_WHOLE, j2000_s / SECONDS_PER_DAY).expect("valid split");
214 PreciseEphemerisSample::new(
215 gps(21),
216 Instant {
217 scale: TimeScale::Gpst,
218 repr: InstantRepr::JulianDate(split),
219 },
220 pos,
221 clk,
222 )
223 }
224
225 fn source() -> PreciseEphemerisSamples {
226 let samples: Vec<_> = (0..12)
227 .map(|i| {
228 let t = i as f64 * 900.0;
229 sample(
230 t,
231 [
232 26_000_000.0 - 5.0 * t,
233 1_000_000.0 + 7.0 * t,
234 -3_000_000.0 - 2.0 * t,
235 ],
236 Some(1.0e-6 + 1.0e-10 * t),
237 )
238 })
239 .collect();
240 PreciseEphemerisSamples::from_samples(samples).expect("valid source")
241 }
242
243 #[test]
244 fn self_comparison_is_zero_divergence() {
245 let source = source();
246 // Reference = the source's own interpolation at a set of covered epochs.
247 let epochs = [0.0, 450.0, 900.0, 1_350.0, 4_500.0, 9_900.0];
248 let reference: Vec<ReferenceState> = epochs
249 .iter()
250 .map(|&q| {
251 let state = source
252 .observable_state_at_j2000_s(gps(21), q)
253 .expect("covered query");
254 ReferenceState {
255 query_j2000_s: q,
256 position_ecef_m: state.position_ecef_m,
257 clock_s: state.clock_s,
258 }
259 })
260 .collect();
261
262 let report = compare_position_series(&source, gps(21), &reference).expect("comparison");
263 assert_eq!(report.per_epoch.len(), epochs.len());
264 assert_eq!(report.max_position_norm_m.to_bits(), 0.0f64.to_bits());
265 assert_eq!(report.rms_position_norm_m.to_bits(), 0.0f64.to_bits());
266 assert_eq!(report.max_abs_clock_diff_s, Some(0.0));
267 for d in &report.per_epoch {
268 assert_eq!(d.position_norm_m.to_bits(), 0.0f64.to_bits());
269 assert_eq!(d.clock_diff_s, Some(0.0));
270 }
271 }
272
273 #[test]
274 fn perturbed_reference_reports_the_offset() {
275 let source = source();
276 let q = 450.0;
277 let state = source
278 .observable_state_at_j2000_s(gps(21), q)
279 .expect("covered query");
280 // Offset the reference by a known vector; the norm must come back exact.
281 let offset = [3.0, -4.0, 12.0]; // norm 13
282 let reference = [ReferenceState {
283 query_j2000_s: q,
284 position_ecef_m: [
285 state.position_ecef_m[0] - offset[0],
286 state.position_ecef_m[1] - offset[1],
287 state.position_ecef_m[2] - offset[2],
288 ],
289 clock_s: state.clock_s.map(|c| c - 5.0e-9),
290 }];
291
292 let report = compare_position_series(&source, gps(21), &reference).expect("comparison");
293 let d = report.per_epoch[0];
294 assert_eq!(d.position_diff_m, offset);
295 assert!((d.position_norm_m - 13.0).abs() < 1e-9);
296 assert_eq!(report.max_position_norm_m, d.position_norm_m);
297 assert!((report.max_abs_clock_diff_s.unwrap() - 5.0e-9).abs() < 1e-18);
298 }
299
300 #[test]
301 fn empty_reference_is_zeroed_report() {
302 let source = source();
303 let report = compare_position_series(&source, gps(21), &[]).expect("comparison");
304 assert!(report.per_epoch.is_empty());
305 assert_eq!(report.max_position_norm_m, 0.0);
306 assert_eq!(report.rms_position_norm_m, 0.0);
307 assert_eq!(report.max_abs_clock_diff_s, None);
308 }
309
310 #[test]
311 fn out_of_coverage_reference_epoch_errors() {
312 let source = source();
313 let reference = [ReferenceState {
314 query_j2000_s: 1_000_000.0,
315 position_ecef_m: [0.0; 3],
316 clock_s: None,
317 }];
318 assert!(compare_position_series(&source, gps(21), &reference).is_err());
319 }
320}