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