Skip to main content

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}