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, 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}