sidereon_core/sp3/interp.rs
1//! SP3 arbitrary-epoch position/clock interpolation.
2//!
3//! For a consumer-facing summary of the interpolation contract, a utility to
4//! verify sidereon's interpolation against a supplied reference series
5//! ([`compare_position_series`](crate::sp3::compare_position_series)), and the
6//! recommendation on configurable interpolation modes, see that function's
7//! module documentation.
8//!
9//! Two channels with different recipes, each validated against its correct
10//! external reference (the two-bars doctrine: capability vs the deployed
11//! reference, not a bit-exact port of a convenient primitive):
12//!
13//! # Position channel: sliding-window Lagrange/Neville (RTKLIB recipe)
14//!
15//! The satellite position is interpolated with a sliding-window high-degree
16//! Lagrange (Neville) polynomial matching RTKLIB `preceph.c` pephpos/interppol:
17//! the contiguous run of nodes bracketing the query, the RTKLIB window of up to
18//! 11 nodes (degree 10) centred on the query, an `OMEGA_E_DOT` per-node
19//! earth-rotation correction into the query-epoch frame, then Neville evaluation
20//! per axis. This is the IGS-standard orbit interpolation. It replaced a global
21//! not-a-knot cubic spline, which is only degree 3 over the whole day and erred
22//! ~200 m at the day boundary and across coverage gaps (a query deep inside a
23//! coverage gap is now rejected, never interpolated across). Validated against
24//! the RTKLIB reference (`interp_tests`) and end-to-end against ZIM2 PPP truth.
25//!
26//! # Clock channel: not-a-knot cubic spline (gnssanalysis recipe)
27//!
28//! The clock is locally smooth, so it keeps the not-a-knot cubic spline matching
29//! `scipy.interpolate.CubicSpline(x, y)` with gnssanalysis defaults
30//! (`bc_type="not-a-knot"`, `extrapolate=true`), evaluated at the query, with
31//! clock-event (`E`) arc splitting. BLAS-free (the not-a-knot solve dispatches
32//! to LAPACK `dgtsv`), a legitimate 0-ULP target against scipy.
33//!
34//! # Node substrate (load-bearing for 0-ULP)
35//!
36//! Nodes are **integer seconds since J2000** (2000-01-01 12:00:00 in the file's
37//! own time scale), exactly as gnssanalysis builds them in `datetime2j2000`
38//! (`gn_datetime.py:286-288`): epochs quantized to whole seconds, differenced
39//! against the J2000 origin, kept as `i64`, then promoted to `f64` on entry to
40//! the spline. The parser stores that seconds axis from the epoch record's civil
41//! fields with integer whole-second arithmetic, so node bucketing does not
42//! depend on a split-Julian floating round trip. If a caller mutates the public
43//! [`Sp3::epochs`] vector, interpolation falls back to the public epoch when it
44//! no longer matches the parsed axis within the shared whole-second tolerance.
45//!
46//! J2000 = JD 2451545.0. Query seconds from [`Sp3::position`] are computed from
47//! the supplied [`Instant`] in a cancellation-safe way and are never quantized.
48//!
49//! # Units
50//!
51//! The spline is fit in the SP3-native units the reference carries -
52//! **kilometers** for position, **microseconds** for clock - and the evaluated
53//! result is converted to the public API boundary (**meters**, **seconds**) by a
54//! **single final multiply** (`* 1000.0`, `* 1e-6`). The conversion happens
55//! AFTER evaluation, never before the fit; this operation order is pinned.
56//!
57//! # Clock interpolation near gaps / discontinuities
58//!
59//! gnssanalysis defines none, so the policy is authored in the canonical recipe
60//! and matched here:
61//!
62//! - Clock uses the **same** `CubicSpline` construction over the nodes that have
63//! a clock estimate (the bad-clock sentinel yields no clock node).
64//! - Position and clock node sets are independent.
65//! - Position is never split (orbits are continuous through clock resets).
66//! - Clock interpolation does **not** cross a clock-event (`E`) epoch: the arc
67//! is split at each `E`-flagged epoch and the clock spline is fit on the
68//! contiguous sub-arc containing the query epoch.
69
70use crate::astro::time::model::{Instant, InstantRepr, JulianDateSplit};
71
72use crate::astro::time::civil::j2000_seconds_from_split;
73use crate::constants::{J2000_JD, KM_TO_M, OMEGA_E_DOT_RAD_S, SECONDS_PER_DAY, US_TO_S};
74use crate::frame::ItrfPositionM;
75use crate::id::GnssSatelliteId;
76use crate::sp3::{Sp3, Sp3State};
77use crate::tolerances::WHOLE_SECOND_EPS_S;
78use crate::validate;
79use crate::{Error, Result};
80
81/// Per-satellite precise node series in native fit units.
82#[derive(Debug, Clone, PartialEq)]
83pub(super) struct PreciseSatSeries {
84 /// Whole-second J2000 node axis.
85 pub(super) x: Vec<f64>,
86 /// X position nodes in SP3-native kilometers.
87 pub(super) kx: Vec<f64>,
88 /// Y position nodes in SP3-native kilometers.
89 pub(super) ky: Vec<f64>,
90 /// Z position nodes in SP3-native kilometers.
91 pub(super) kz: Vec<f64>,
92 /// Clock nodes as `(x_seconds, clock_us, clock_event)`.
93 pub(super) clk: Vec<(f64, f64, bool)>,
94}
95
96impl PreciseSatSeries {
97 pub(super) fn new() -> Self {
98 Self {
99 x: Vec::new(),
100 kx: Vec::new(),
101 ky: Vec::new(),
102 kz: Vec::new(),
103 clk: Vec::new(),
104 }
105 }
106}
107
108/// One clock sub-arc with precomputed not-a-knot cubic coefficients.
109#[derive(Debug, Clone, PartialEq)]
110pub(super) struct ClockSplineArc {
111 /// Clock node axis for this sub-arc.
112 pub(super) x: Vec<f64>,
113 /// Cubic coefficient for `(query - x[i])^3`, in native microseconds.
114 pub(super) c0: Vec<f64>,
115 /// Cubic coefficient for `(query - x[i])^2`, in native microseconds.
116 pub(super) c1: Vec<f64>,
117 /// Cubic coefficient for `(query - x[i])`, in native microseconds.
118 pub(super) c2: Vec<f64>,
119 /// Constant coefficient, in native microseconds.
120 pub(super) c3: Vec<f64>,
121}
122
123impl ClockSplineArc {
124 fn from_nodes(nodes: &[(f64, f64, bool)]) -> Self {
125 let x: Vec<_> = nodes.iter().map(|node| node.0).collect();
126 if nodes.len() < 2 {
127 return Self {
128 x,
129 c0: Vec::new(),
130 c1: Vec::new(),
131 c2: Vec::new(),
132 c3: Vec::new(),
133 };
134 }
135
136 let y: Vec<_> = nodes.iter().map(|node| node.1).collect();
137 let dydx = solve_not_a_knot_slopes(&x, &y);
138 let (c0, c1, c2, c3) = hermite_segment_coeffs(&x, &y, &dydx);
139 Self { x, c0, c1, c2, c3 }
140 }
141}
142
143impl Sp3 {
144 /// The product's parsed epochs as seconds since J2000, in the file's own time
145 /// scale, ascending.
146 ///
147 /// This is the exact query axis [`Sp3::position_at_j2000_seconds`] uses for
148 /// parsed record nodes, so a caller can read the grid here, form query times
149 /// on it, and feed them straight back without a Julian-date round trip. An
150 /// unmodified parsed product returns one value per epoch.
151 pub fn epochs_j2000_seconds(&self) -> Vec<f64> {
152 self.epochs
153 .iter()
154 .enumerate()
155 .filter_map(|(idx, epoch)| sp3_epoch_j2000_seconds(self, idx, epoch))
156 .collect()
157 }
158
159 /// Interpolate the state of `sat` at an arbitrary `epoch`.
160 ///
161 /// Reproduces the pinned `scipy.interpolate.CubicSpline` recipe (see module
162 /// docs) bit-for-bit: a per-axis not-a-knot cubic spline over the
163 /// J2000-integer-second node axis, evaluated at `epoch`, with the unit
164 /// conversion as a single final multiply.
165 ///
166 /// - `position` is always returned (interpolated from all position nodes of
167 /// `sat`), in meters, ITRF/IGS ECEF.
168 /// - `clock_s` is `Some` when `sat` has at least two clock nodes in the
169 /// clock sub-arc containing `epoch` (after clock-event splitting); `None`
170 /// otherwise.
171 /// - `velocity` / `clock_rate_s_s` are `None` (this API interpolates the
172 /// position/clock product; velocity products are a separate concern).
173 /// - `flags` are defaulted (an interpolated state is synthetic, not a record).
174 ///
175 /// Errors:
176 /// - [`Error::UnknownSatellite`] if `sat` has no position nodes.
177 /// - [`Error::EpochOutOfRange`] if fewer than two position nodes exist (a
178 /// spline needs at least two points) or the epoch is not representable.
179 /// - [`Error::InvalidInput`] if `epoch` is tagged with a different time
180 /// scale than the SP3 product.
181 pub fn position(&self, sat: GnssSatelliteId, epoch: Instant) -> Result<Sp3State> {
182 if epoch.scale != self.header.time_scale {
183 return Err(Error::InvalidInput(format!(
184 "SP3 query time scale {} does not match product time scale {}",
185 epoch.scale.abbrev(),
186 self.header.time_scale.abbrev()
187 )));
188 }
189 let query = instant_to_j2000_seconds(&epoch).ok_or(Error::EpochOutOfRange)?;
190 self.position_at_j2000_seconds(sat, query)
191 }
192
193 /// Interpolate the state of `sat` at an arbitrary J2000-second epoch
194 /// supplied directly as an `f64`.
195 ///
196 /// Identical to [`Sp3::position`] except the query is the seconds-since-J2000
197 /// value as already computed by the caller, rather than derived from an
198 /// [`Instant`]. The transmit-time iteration of the SPP residual carries the
199 /// epoch as a J2000-second `f64` (`t_tx = t_rx - rho/c`) and must feed that
200 /// exact value to the spline, with no Julian-date round-trip in the loop, so
201 /// the interpolated position/clock match the reference recipe bit-for-bit.
202 /// The real-product decimation hold-out oracle pins the parsed-text versus
203 /// sample-backed 3D difference at no more than `4.020965667248365e-8` m
204 /// over interior held-out 5-minute records bracketed by 15-minute nodes.
205 ///
206 /// Errors:
207 /// - [`Error::InvalidInput`] if `query` is NaN or infinite.
208 pub fn position_at_j2000_seconds(&self, sat: GnssSatelliteId, query: f64) -> Result<Sp3State> {
209 let series = gather_sp3_precise_series(self, sat);
210 interpolate_precise_state(
211 sat,
212 &series.x,
213 &series.kx,
214 &series.ky,
215 &series.kz,
216 &series.clk,
217 query,
218 )
219 }
220}
221
222/// Gather one satellite's SP3-native node series in ascending epoch order.
223pub(super) fn gather_sp3_precise_series(source: &Sp3, sat: GnssSatelliteId) -> PreciseSatSeries {
224 let mut series = PreciseSatSeries::new();
225
226 for (idx, ep) in source.epochs.iter().enumerate() {
227 // Node axis: shared whole-second construction. The query is never
228 // quantized.
229 let xi = match sp3_epoch_j2000_seconds(source, idx, ep) {
230 Some(v) => precise_node_j2000_seconds(v),
231 None => continue,
232 };
233 // Use the parser's native km/us node values. Reconstructing km from the
234 // public meters can drift by 1 ULP; unit conversion stays after eval.
235 let Some(raw) = source.interp_raw[idx].get(&sat) else {
236 continue;
237 };
238 series.x.push(xi);
239 series.kx.push(raw.km[0]);
240 series.ky.push(raw.km[1]);
241 series.kz.push(raw.km[2]);
242
243 if let Some(clk_us) = raw.clock_us {
244 series.clk.push((xi, clk_us, raw.clock_event));
245 }
246 }
247
248 series
249}
250
251pub(super) fn sp3_epoch_j2000_seconds(source: &Sp3, idx: usize, epoch: &Instant) -> Option<f64> {
252 let public_seconds = instant_to_j2000_seconds(epoch)?;
253 let parsed_seconds = *source.epoch_j2000_s.get(idx)?;
254 if (public_seconds - parsed_seconds).abs() <= WHOLE_SECOND_EPS_S {
255 Some(parsed_seconds)
256 } else {
257 Some(public_seconds)
258 }
259}
260
261/// Convert already-reduced J2000 seconds onto the SP3 interpolation node axis.
262///
263/// This keeps the gnssanalysis truncation policy for genuinely fractional node
264/// epochs. Callers that still have a split-Julian instant should use
265/// [`precise_node_j2000_seconds_from_instant`] so whole-second record nodes can
266/// be reconstructed before reducing the epoch to one large `f64`.
267pub(super) fn precise_node_j2000_seconds(seconds: f64) -> f64 {
268 seconds.floor()
269}
270
271/// Convert a split-aware instant onto the SP3 interpolation node axis.
272///
273/// SP3 record epochs are whole-second in normal products. A split-Julian sample
274/// can reduce to the adjacent `f64` below an integer J2000 second when the day
275/// term is large. Recover the whole-second candidate from the day term and
276/// within-day second first, then accept it only when the residual is within two
277/// ULPs at the candidate epoch. Genuinely fractional epochs still use the
278/// truncating policy.
279pub(super) fn precise_node_j2000_seconds_from_instant(instant: &Instant) -> Option<f64> {
280 match instant.repr {
281 InstantRepr::JulianDate(split) => Some(precise_node_j2000_seconds_from_split(split)),
282 InstantRepr::Nanos(_) => instant_to_j2000_seconds(instant).map(precise_node_j2000_seconds),
283 }
284}
285
286fn precise_node_j2000_seconds_from_split(split: JulianDateSplit) -> f64 {
287 let seconds = j2000_seconds_from_split(split.jd_whole, split.fraction);
288 let day_seconds = (split.jd_whole - J2000_JD) * SECONDS_PER_DAY;
289 let within_day_seconds = split.fraction * SECONDS_PER_DAY;
290 let nearest_within_day = within_day_seconds.round();
291 let candidate = day_seconds + nearest_within_day;
292 if candidate.is_finite()
293 && (within_day_seconds - nearest_within_day).abs() <= 2.0 * epoch_ulp(candidate)
294 {
295 candidate
296 } else {
297 precise_node_j2000_seconds(seconds)
298 }
299}
300
301fn epoch_ulp(value: f64) -> f64 {
302 if !value.is_finite() {
303 return 0.0;
304 }
305 (next_up(value) - value)
306 .abs()
307 .max((value - next_down(value)).abs())
308}
309
310fn next_up(value: f64) -> f64 {
311 if value.is_nan() || value == f64::INFINITY {
312 return value;
313 }
314 if value == -0.0 {
315 return f64::from_bits(1);
316 }
317 let bits = value.to_bits();
318 if value.is_sign_negative() {
319 f64::from_bits(bits - 1)
320 } else {
321 f64::from_bits(bits + 1)
322 }
323}
324
325fn next_down(value: f64) -> f64 {
326 if value.is_nan() || value == f64::NEG_INFINITY {
327 return value;
328 }
329 if value == 0.0 {
330 return -f64::from_bits(1);
331 }
332 let bits = value.to_bits();
333 if value.is_sign_negative() {
334 f64::from_bits(bits + 1)
335 } else {
336 f64::from_bits(bits - 1)
337 }
338}
339
340/// Interpolate a satellite state from already-gathered native-unit nodes.
341///
342/// This is the shared interpolation substrate. Both the SP3-parsed source
343/// ([`Sp3::position_at_j2000_seconds`]) and the sample-backed source
344/// ([`crate::sp3::PreciseEphemerisSamples`]) gather the same node vectors
345/// (ascending whole-second J2000 seconds `x`; native km `kx/ky/kz`; native
346/// `(x, clock_us, clock_event)` clock nodes) and drive this one function, so a
347/// source built from samples produces byte-identical states to the SP3 source
348/// those samples serialize to.
349///
350/// Inputs are the file-native units the reference recipes consume (km for
351/// position, microseconds for clock); the single final unit multiply to meters /
352/// seconds happens inside the position/clock evaluators, exactly as the SP3 path
353/// requires for 0-ULP parity.
354pub(super) fn interpolate_precise_state(
355 sat: GnssSatelliteId,
356 pos_x: &[f64],
357 pos_kx: &[f64],
358 pos_ky: &[f64],
359 pos_kz: &[f64],
360 clk_nodes: &[(f64, f64, bool)],
361 query: f64,
362) -> Result<Sp3State> {
363 let (x_m, y_m, z_m) = interpolate_precise_position(sat, pos_x, pos_kx, pos_ky, pos_kz, query)?;
364 let clock_s = interpolate_clock(clk_nodes, query);
365
366 Ok(Sp3State {
367 position: ItrfPositionM::new(x_m, y_m, z_m).expect("valid ITRF position"),
368 clock_s,
369 velocity: None,
370 clock_rate_s_s: None,
371 flags: crate::sp3::Sp3Flags::default(),
372 })
373}
374
375pub(super) fn interpolate_precise_state_with_clock_arcs(
376 sat: GnssSatelliteId,
377 pos_x: &[f64],
378 pos_kx: &[f64],
379 pos_ky: &[f64],
380 pos_kz: &[f64],
381 clock_arcs: &[ClockSplineArc],
382 query: f64,
383) -> Result<Sp3State> {
384 let (x_m, y_m, z_m) = interpolate_precise_position(sat, pos_x, pos_kx, pos_ky, pos_kz, query)?;
385 let clock_s = interpolate_fitted_clock(clock_arcs, query);
386
387 Ok(Sp3State {
388 position: ItrfPositionM::new(x_m, y_m, z_m).expect("valid ITRF position"),
389 clock_s,
390 velocity: None,
391 clock_rate_s_s: None,
392 flags: crate::sp3::Sp3Flags::default(),
393 })
394}
395
396fn interpolate_precise_position(
397 sat: GnssSatelliteId,
398 pos_x: &[f64],
399 pos_kx: &[f64],
400 pos_ky: &[f64],
401 pos_kz: &[f64],
402 query: f64,
403) -> Result<(f64, f64, f64)> {
404 let query = validate::finite(query, "query_j2000_s").map_err(map_query_input)?;
405
406 if pos_x.is_empty() {
407 return Err(Error::UnknownSatellite(sat));
408 }
409 if pos_x.len() < 2 {
410 // A cubic spline needs >= 2 points; a single node cannot define one.
411 return Err(Error::EpochOutOfRange);
412 }
413 validate_strictly_increasing_nodes(pos_x)?;
414
415 // Refuse grossly out-of-coverage queries instead of silently returning a
416 // diverging extrapolation. The underlying cubic spline mirrors scipy
417 // CubicSpline(extrapolate=True): a query well past the node span runs off
418 // to nonsense (megametres and worse). We allow up to one node spacing of
419 // edge extrapolation (the end cubic is still physically reasonable that
420 // close to the data) and reject anything beyond. In-coverage interpolation
421 // is bit-for-bit unchanged, so 0-ULP parity is preserved. Nodes are in
422 // ascending epoch order.
423 // Reject a query that lands deep inside an interior coverage gap rather
424 // than interpolating across it. Nominal spacing is the smallest
425 // consecutive node gap; a bracketing interval far larger than that is a
426 // gap. One nominal spacing of interpolation past either edge node is
427 // allowed (the near-gap edge stays usable); beyond that the query is in
428 // the gap and is refused.
429 let nominal = nominal_positive_spacing(pos_x).ok_or(Error::EpochOutOfRange)?;
430 let first = pos_x[0];
431 let last = pos_x[pos_x.len() - 1];
432 if query < first - nominal || query > last + nominal {
433 return Err(Error::EpochOutOfRange);
434 }
435
436 let gap_thresh = 1.5 * nominal;
437 let mut bi = 0usize;
438 while bi + 1 < pos_x.len() && pos_x[bi + 1] <= query {
439 bi += 1;
440 }
441 if bi + 1 < pos_x.len() {
442 let (lo, hi) = (pos_x[bi], pos_x[bi + 1]);
443 if hi - lo > gap_thresh && query > lo + nominal && query < hi - nominal {
444 return Err(Error::EpochOutOfRange);
445 }
446 }
447
448 Ok(interpolate_position_neville(
449 pos_x, pos_kx, pos_ky, pos_kz, query,
450 ))
451}
452
453fn map_query_input(error: validate::FieldError) -> Error {
454 Error::InvalidInput(format!("{} {}", error.field(), error.reason()))
455}
456
457fn nominal_positive_spacing(x: &[f64]) -> Option<f64> {
458 let nominal = x
459 .windows(2)
460 .map(|w| w[1] - w[0])
461 .filter(|&d| d > 0.0)
462 .fold(f64::INFINITY, f64::min);
463 if nominal.is_finite() {
464 Some(nominal)
465 } else {
466 None
467 }
468}
469
470fn validate_strictly_increasing_nodes(x: &[f64]) -> Result<()> {
471 for window in x.windows(2) {
472 if window[1] <= window[0] {
473 return Err(Error::InvalidInput(
474 "SP3 interpolation epochs must be strictly increasing".to_string(),
475 ));
476 }
477 }
478 Ok(())
479}
480
481/// Interpolate the clock channel with the clock-event-split policy.
482///
483/// Splits the clock node arc at each clock-event (`E`) epoch and fits the
484/// not-a-knot spline on the contiguous sub-arc containing `query`. Returns
485/// `None` if that sub-arc has fewer than two nodes.
486fn interpolate_clock(clk_nodes: &[(f64, f64, bool)], query: f64) -> Option<f64> {
487 let arcs = fit_clock_spline_arcs(clk_nodes);
488 interpolate_fitted_clock(&arcs, query)
489}
490
491pub(super) fn fit_clock_spline_arcs(clk_nodes: &[(f64, f64, bool)]) -> Vec<ClockSplineArc> {
492 if clk_nodes.is_empty() {
493 return Vec::new();
494 }
495
496 // Partition into contiguous sub-arcs split at clock-event epochs. A
497 // clock-event epoch marks a discontinuity *at* that epoch, so it ends the
498 // sub-arc before it and starts a new one (the flagged node belongs to the
499 // new sub-arc, since the reset takes effect there).
500 let mut sub_start = 0usize;
501 let mut arcs = Vec::new();
502 for i in 0..clk_nodes.len() {
503 let is_break = clk_nodes[i].2 && i > sub_start;
504 if is_break {
505 // Sub-arc [sub_start, i) ends here.
506 arcs.push(ClockSplineArc::from_nodes(&clk_nodes[sub_start..i]));
507 sub_start = i;
508 }
509 }
510 // Trailing sub-arc [sub_start, len).
511 arcs.push(ClockSplineArc::from_nodes(&clk_nodes[sub_start..]));
512 arcs
513}
514
515/// Interpolate the clock channel from precomputed clock spline sub-arcs.
516pub(super) fn interpolate_fitted_clock(arcs: &[ClockSplineArc], query: f64) -> Option<f64> {
517 let mut chosen: Option<usize> = None;
518 for (idx, arc) in arcs.iter().enumerate() {
519 if arc_contains_query(arc, query) {
520 chosen = Some(idx);
521 break;
522 }
523 }
524 // If the query is outside every sub-arc span (extrapolation), use the
525 // sub-arc nearest the query so the default extrapolate=True behavior holds
526 // within the contiguous piece on that side.
527 let arc = match chosen {
528 Some(idx) => &arcs[idx],
529 None => nearest_fitted_subarc(arcs, query)?,
530 };
531
532 if arc.x.len() < 2 {
533 return None;
534 }
535 Some(evaluate_ppoly(&arc.x, &arc.c0, &arc.c1, &arc.c2, &arc.c3, query) * US_TO_S)
536}
537
538/// Whether `query` lies within the closed node-span of a fitted sub-arc.
539fn arc_contains_query(arc: &ClockSplineArc, query: f64) -> bool {
540 if arc.x.is_empty() {
541 return false;
542 }
543 let lo = arc.x[0];
544 let hi = arc.x[arc.x.len() - 1];
545 query >= lo && query <= hi
546}
547
548/// Find the sub-arc (split at clock-event epochs) whose node-span is nearest to
549/// `query` for extrapolation. Returns `[start, end)` or `None` if empty.
550fn nearest_fitted_subarc(arcs: &[ClockSplineArc], query: f64) -> Option<&ClockSplineArc> {
551 arcs.iter()
552 .filter(|arc| arc.x.len() >= 2)
553 .min_by(|arc1, arc2| {
554 let d1 = fitted_span_distance(arc1, query);
555 let d2 = fitted_span_distance(arc2, query);
556 d1.partial_cmp(&d2).unwrap_or(core::cmp::Ordering::Equal)
557 })
558}
559
560fn fitted_span_distance(arc: &ClockSplineArc, query: f64) -> f64 {
561 let lo = arc.x[0];
562 let hi = arc.x[arc.x.len() - 1];
563 if query < lo {
564 lo - query
565 } else if query > hi {
566 query - hi
567 } else {
568 0.0
569 }
570}
571
572/// Convert a parser [`Instant`] to seconds since J2000, as `f64`, **exact**
573/// (not quantized).
574///
575/// The split-JD difference is taken whole-part first to avoid cancellation.
576/// This returns the precise instant; whole-second quantization belongs to the
577/// *node axis* only:
578///
579/// - **Node epochs** are quantized to whole seconds at the call site to mirror
580/// gnssanalysis `datetime2j2000` (`datetime64[s]` truncation). Parsed SP3
581/// nodes use the parser's exact civil-second axis, and sample nodes with a
582/// split-Julian representation reconstruct whole-second candidates before
583/// reducing the epoch to this continuous `f64` value.
584/// - The **query** is evaluated at this exact value, never quantized: truncating
585/// a sub-second query epoch would discard up to ~1 s, a kilometre-scale
586/// position error at orbital speed (this was a real bug - the node and query
587/// conversions must NOT share quantization).
588pub(super) fn instant_to_j2000_seconds(instant: &Instant) -> Option<f64> {
589 match instant.repr {
590 InstantRepr::JulianDate(split) => {
591 // (jd - J2000_JD) days -> seconds, whole/fraction kept separate to
592 // avoid cancellation (canonical split-to-J2000-seconds reduction).
593 Some(j2000_seconds_from_split(split.jd_whole, split.fraction))
594 }
595 InstantRepr::Nanos(ns) => {
596 // Integer ns since the scale epoch - but the parser stores SP3
597 // epochs as JulianDate, so this path is not exercised by SP3.
598 // J2000 is JD 2451545.0; without a fixed ns-origin convention here
599 // we cannot map ns->J2000-seconds unambiguously, so decline.
600 let _ = ns;
601 None
602 }
603 }
604}
605
606/// Number of nodes in the sliding interpolation window (RTKLIB `NMAX`=10 ->
607/// degree-10 polynomial, 11 nodes).
608pub(super) const NEVILLE_POINTS: usize = 11;
609
610/// Sliding-window Lagrange (Neville) satellite-POSITION interpolation, matching
611/// RTKLIB `preceph.c` pephpos/interppol. Replaces the global not-a-knot cubic
612/// spline, which is degree-3 over the whole day and errs ~200 m at the day
613/// boundary and across coverage gaps; SP3 15-minute orbit nodes need local
614/// ~degree-10 interpolation for sub-cm accuracy. Validated against the external
615/// RTKLIB reference and the ZIM2 PPP truth (two-bars doctrine: this channel is a
616/// capability gated on the deployed reference, not a bit-exact port of a scipy
617/// primitive). The CLOCK channel keeps its cubic spline (locally smooth, matched
618/// to the 30 s clock product at the cm level).
619///
620/// Recipe: restrict to the contiguous run of nodes bracketing `query` (never
621/// interpolate across a coverage gap), take the RTKLIB window of up to
622/// `NEVILLE_POINTS` nodes centred on the query (shifted inward at run edges),
623/// rotate each node's ECEF position about +z by `OMEGA_E_DOT * (t_node - query)`
624/// into the query-epoch earth-fixed frame, then Neville-interpolate each axis at
625/// the query. Inputs are ascending J2000 seconds (`x`) and km (`kx/ky/kz`).
626fn interpolate_position_neville(
627 x: &[f64],
628 kx: &[f64],
629 ky: &[f64],
630 kz: &[f64],
631 query: f64,
632) -> (f64, f64, f64) {
633 let n = x.len();
634
635 // Nominal node spacing = smallest positive consecutive gap (robust to one
636 // large coverage gap); the gap threshold marks a non-contiguous jump.
637 let nominal = nominal_positive_spacing(x).unwrap_or(1.0);
638 let gap_thresh = 1.5 * nominal;
639
640 // Last node at or before the query (clamped into range).
641 let mut pivot = 0usize;
642 while pivot + 1 < n && x[pivot + 1] <= query {
643 pivot += 1;
644 }
645 // The gap policy admits one nominal spacing of extrapolation from either
646 // arc. Near the next arc, anchor the window there instead of extrapolating
647 // the previous arc across the whole gap.
648 if pivot + 1 < n && (x[pivot + 1] - x[pivot]) > gap_thresh && query >= x[pivot + 1] - nominal {
649 pivot += 1;
650 }
651
652 // Contiguous run [run_lo, run_hi) around the pivot: extend while the
653 // neighbour gap stays within the threshold (do not cross a coverage gap).
654 let mut run_lo = pivot;
655 while run_lo > 0 && (x[run_lo] - x[run_lo - 1]) <= gap_thresh {
656 run_lo -= 1;
657 }
658 let mut run_hi = pivot + 1;
659 while run_hi < n && (x[run_hi] - x[run_hi - 1]) <= gap_thresh {
660 run_hi += 1;
661 }
662 let run_len = run_hi - run_lo;
663
664 // RTKLIB window: centre on the pivot, width = min(NEVILLE_POINTS, run_len),
665 // clamped to the run.
666 let win = NEVILLE_POINTS.min(run_len);
667 let half = (NEVILLE_POINTS / 2) as isize;
668 let mut start = pivot as isize - half;
669 if start < run_lo as isize {
670 start = run_lo as isize;
671 }
672 if start + win as isize > run_hi as isize {
673 start = run_hi as isize - win as isize;
674 }
675 let start = start as usize;
676
677 // Windowed nodes on the (t = node - query) abscissa, earth-rotation-corrected
678 // into the query-epoch frame; query is t = 0.
679 let mut t = [0.0f64; NEVILLE_POINTS];
680 let mut px = [0.0f64; NEVILLE_POINTS];
681 let mut py = [0.0f64; NEVILLE_POINTS];
682 let mut pz = [0.0f64; NEVILLE_POINTS];
683 for j in 0..win {
684 let k = start + j;
685 let tj = x[k] - query;
686 let theta = OMEGA_E_DOT_RAD_S * tj;
687 let s = libm::sin(theta);
688 let c = libm::cos(theta);
689 t[j] = tj;
690 px[j] = c * kx[k] - s * ky[k];
691 py[j] = s * kx[k] + c * ky[k];
692 pz[j] = kz[k];
693 }
694
695 let x_km = neville(&t[..win], &px[..win]);
696 let y_km = neville(&t[..win], &py[..win]);
697 let z_km = neville(&t[..win], &pz[..win]);
698 (x_km * KM_TO_M, y_km * KM_TO_M, z_km * KM_TO_M)
699}
700
701/// Neville's algorithm evaluated at 0, reproducing RTKLIB `rtkcmn.c` interppol
702/// (the abscissa `x` carries node-minus-query offsets, so the query is 0).
703pub(super) fn neville(x: &[f64], y: &[f64]) -> f64 {
704 let n = y.len();
705 let mut c: [f64; NEVILLE_POINTS] = [0.0; NEVILLE_POINTS];
706 c[..n].copy_from_slice(&y[..n]);
707 for j in 1..n {
708 for i in 0..(n - j) {
709 c[i] = (x[i + j] * c[i] - x[i] * c[i + 1]) / (x[i + j] - x[i]);
710 }
711 }
712 c[0]
713}
714
715/// Evaluate a not-a-knot cubic spline at `query`, reproducing
716/// `scipy.interpolate.CubicSpline(x, y)(query)` bit-for-bit.
717///
718/// `x` must be strictly increasing with `x.len() == y.len() >= 2`.
719#[cfg(all(test, sidereon_repo_tests))]
720fn eval_cubic_spline(x: &[f64], y: &[f64], query: f64) -> f64 {
721 let n = x.len();
722 debug_assert_eq!(n, y.len());
723 debug_assert!(n >= 2);
724
725 let dydx = solve_not_a_knot_slopes(x, y);
726 let (c0, c1, c2, c3) = hermite_segment_coeffs(x, y, &dydx);
727 evaluate_ppoly(x, &c0, &c1, &c2, &c3, query)
728}
729
730/// Solve the not-a-knot tridiagonal system for the derivative values `s[i]` at
731/// each node, exactly as `scipy.interpolate.CubicSpline.__init__` assembles it
732/// (`_cubic.py`, scipy 1.17.1) and `scipy.linalg.solve_banded((1,1), ...)`
733/// solves it via LAPACK `dgtsv`.
734///
735/// Banded layout mirrors scipy's `A` of shape `(3, n)`:
736/// - `A[1, :]` diagonal `d`
737/// - `A[0, 1:]` upper diagonal `du` (i.e. `du[j]` couples row `j` to `j+1`)
738/// - `A[2, :-1]` lower diagonal `dl` (i.e. `dl[j]` couples row `j+1` to `j`)
739fn solve_not_a_knot_slopes(x: &[f64], y: &[f64]) -> Vec<f64> {
740 let n = x.len();
741
742 // dx[i] = x[i+1]-x[i]; slope[i] = (y[i+1]-y[i])/dx[i]. (scipy: np.diff / dxr)
743 let mut dx = vec![0.0; n - 1];
744 let mut slope = vec![0.0; n - 1];
745 for i in 0..n - 1 {
746 dx[i] = x[i + 1] - x[i];
747 slope[i] = (y[i + 1] - y[i]) / dx[i];
748 }
749
750 // Special case n == 2: not-a-knot is replaced by clamped to the secant
751 // slope on both ends (scipy `_cubic.py`: bc -> (1, slope[0])), giving the
752 // straight-line Hermite - both derivatives equal slope[0].
753 if n == 2 {
754 return vec![slope[0], slope[0]];
755 }
756
757 // Special case n == 3 with not-a-knot on both ends: scipy builds a 3x3 dense
758 // system (a parabola through the points) and solves with LAPACK `gesv`.
759 if n == 3 {
760 return solve_n3_parabola(&dx, &slope, y);
761 }
762
763 // General n >= 4: tridiagonal banded system.
764 // Diagonal/off-diagonals as scipy fills them.
765 // Interior rows i=1..n-2:
766 // d[i] = 2*(dx[i-1]+dx[i])
767 // du[i] (A[0, i+1]) = dx[i-1]
768 // dl[i-1](A[2, i-1]) = dx[i]
769 // b[i] = 3*(dx[i]*slope[i-1] + dx[i-1]*slope[i])
770 let mut d = vec![0.0; n];
771 // upper diagonal du[j] for j in 0..n-1 couples row j -> j+1 (A[0, j+1]).
772 let mut du = vec![0.0; n - 1];
773 // lower diagonal dl[j] for j in 0..n-1 couples row j+1 -> j (A[2, j]).
774 let mut dl = vec![0.0; n - 1];
775 let mut b = vec![0.0; n];
776
777 for i in 1..n - 1 {
778 d[i] = 2.0 * (dx[i - 1] + dx[i]); // A[1, i]
779 du[i] = dx[i - 1]; // A[0, i+1] -> our du index i (couples i->i+1)
780 dl[i - 1] = dx[i]; // A[2, i-1] -> our dl index i-1 (couples i->i-1)
781 b[i] = 3.0 * (dx[i] * slope[i - 1] + dx[i - 1] * slope[i]);
782 }
783
784 // not-a-knot start (scipy):
785 // A[1,0]=dx[1]; A[0,1]=x[2]-x[0]; d=x[2]-x[0];
786 // b[0]=((dx[0]+2*d)*dx[1]*slope[0] + dx[0]^2*slope[1]) / d
787 {
788 let dd = x[2] - x[0];
789 d[0] = dx[1]; // A[1,0]
790 du[0] = dd; // A[0,1] couples row 0->1
791 b[0] = ((dx[0] + 2.0 * dd) * dx[1] * slope[0] + dx[0] * dx[0] * slope[1]) / dd;
792 }
793 // not-a-knot end (scipy):
794 // A[1,-1]=dx[-2]; A[-1,-2]=x[-1]-x[-3]; d=x[-1]-x[-3];
795 // b[-1]=(dx[-1]^2*slope[-2] + (2*d+dx[-1])*dx[-2]*slope[-1]) / d
796 {
797 let dd = x[n - 1] - x[n - 3];
798 d[n - 1] = dx[n - 2]; // A[1,-1]
799 dl[n - 2] = dd; // A[-1,-2] couples row n-1 -> n-2
800 b[n - 1] = (dx[n - 2] * dx[n - 2] * slope[n - 3]
801 + (2.0 * dd + dx[n - 2]) * dx[n - 3] * slope[n - 2])
802 / dd;
803 }
804
805 dgtsv(dl, d, du, b)
806}
807
808/// n == 3 not-a-knot special case: scipy solves a dense 3x3 `A s = b` via
809/// LAPACK `gesv` (partial-pivot LU). Reproduced with the same partial-pivoting
810/// Gaussian elimination operation order.
811fn solve_n3_parabola(dx: &[f64], slope: &[f64], _y: &[f64]) -> Vec<f64> {
812 // A (scipy `_cubic.py` n==3 branch):
813 // A[0,0]=1 A[0,1]=1
814 // A[1,0]=dx[1] A[1,1]=2*(dx[0]+dx[1]) A[1,2]=dx[0]
815 // A[2,1]=1 A[2,2]=1
816 // b:
817 // b[0]=2*slope[0]
818 // b[1]=3*(dx[0]*slope[1] + dx[1]*slope[0])
819 // b[2]=2*slope[1]
820 let mut a = [
821 [1.0, 1.0, 0.0],
822 [dx[1], 2.0 * (dx[0] + dx[1]), dx[0]],
823 [0.0, 1.0, 1.0],
824 ];
825 let mut b = [
826 2.0 * slope[0],
827 3.0 * (dx[0] * slope[1] + dx[1] * slope[0]),
828 2.0 * slope[1],
829 ];
830 gesv3(&mut a, &mut b);
831 b.to_vec()
832}
833
834/// LAPACK `dgtsv`-equivalent tridiagonal solve (scipy `solve_banded((1,1),...)`
835/// dispatch). Partial pivoting, scalar arithmetic, NRHS=1.
836///
837/// `dl[i]` = sub-diagonal coupling row `i+1`->`i`; `d[i]` = diagonal; `du[i]` =
838/// super-diagonal coupling row `i`->`i+1`. Reproduces the Reference-LAPACK
839/// `dgtsv.f` operation order, **with one pinned-environment subtlety**: the
840/// certified parity target's LAPACK is **Apple Accelerate** (macOS arm64; scipy
841/// 1.17.1, `detection method: extraframeworks`), whose `dgtsv` contracts each
842/// `acc - fact*x` update into a **fused multiply-add**. So every `y - a*x`
843/// elimination/back-substitution update here uses [`f64::mul_add`]
844/// (`(-a).mul_add(x, y)`), NOT a separate multiply then subtract - the
845/// per-function FMA-contraction discipline the parity contract requires.
846/// Verified 0-ULP against `scipy.linalg.lapack.dgtsv` on this target; on a
847/// non-FMA LAPACK build the last bits differ (the portable-mode reality, where
848/// 0 ULP is not promised across platforms).
849fn dgtsv(mut dl: Vec<f64>, mut d: Vec<f64>, mut du: Vec<f64>, mut b: Vec<f64>) -> Vec<f64> {
850 let n = d.len();
851
852 if n == 1 {
853 b[0] /= d[0];
854 return b;
855 }
856
857 // Forward elimination, rows i = 0 .. n-3 (Fortran 1..N-2). On a pivot, the
858 // fill-in second super-diagonal is stored back into `dl[i]` (NOT a separate
859 // du2 array) - exactly as Reference-LAPACK dgtsv.f does; the back
860 // substitution reads it as the B(I+2) coefficient.
861 for i in 0..n.saturating_sub(2) {
862 if d[i].abs() >= dl[i].abs() {
863 // No pivot.
864 let fact = dl[i] / d[i];
865 d[i + 1] = (-fact).mul_add(du[i], d[i + 1]);
866 b[i + 1] = (-fact).mul_add(b[i], b[i + 1]);
867 dl[i] = 0.0;
868 } else {
869 // Pivot (swap rows i and i+1). Note `dl[i] = du[i+1]` happens
870 // BEFORE `du[i+1] = -fact*dl[i]`, so the latter uses the new dl[i]
871 // (= old du[i+1]).
872 let fact = d[i] / dl[i];
873 d[i] = dl[i];
874 let temp = d[i + 1];
875 d[i + 1] = (-fact).mul_add(temp, du[i]);
876 dl[i] = du[i + 1];
877 du[i + 1] = -fact * dl[i];
878 du[i] = temp;
879 let tb = b[i];
880 b[i] = b[i + 1];
881 b[i + 1] = (-fact).mul_add(b[i + 1], tb);
882 }
883 }
884
885 // Row i = n-2 (Fortran I = N-1) - no du2 fill-in.
886 if n > 1 {
887 let i = n - 2;
888 if d[i].abs() >= dl[i].abs() {
889 let fact = dl[i] / d[i];
890 d[i + 1] = (-fact).mul_add(du[i], d[i + 1]);
891 b[i + 1] = (-fact).mul_add(b[i], b[i + 1]);
892 } else {
893 let fact = d[i] / dl[i];
894 d[i] = dl[i];
895 let temp = d[i + 1];
896 d[i + 1] = (-fact).mul_add(temp, du[i]);
897 du[i] = temp;
898 let tb = b[i];
899 b[i] = b[i + 1];
900 b[i + 1] = (-fact).mul_add(b[i + 1], tb);
901 }
902 }
903
904 // Back substitution (dgtsv), FMA-contracted as above.
905 b[n - 1] /= d[n - 1];
906 if n > 1 {
907 b[n - 2] = (-du[n - 2]).mul_add(b[n - 1], b[n - 2]) / d[n - 2];
908 }
909 for i in (0..n.saturating_sub(2)).rev() {
910 // (b[i] - du[i]*b[i+1] - dl[i]*b[i+2]) / d[i], each subtraction fused.
911 let t = (-du[i]).mul_add(b[i + 1], b[i]);
912 b[i] = (-dl[i]).mul_add(b[i + 2], t) / d[i];
913 }
914
915 b
916}
917
918/// 3x3 dense solve with partial-pivot LU, matching LAPACK `gesv` (`dgesv`) for
919/// the n==3 not-a-knot parabola case. As with [`dgtsv`], the certified parity
920/// target is Apple Accelerate, whose `dgesv` contracts the `acc - factor*x`
921/// elimination and substitution updates into fused multiply-adds; this routine
922/// uses [`f64::mul_add`] to match it bit-for-bit.
923#[allow(clippy::needless_range_loop)]
924fn gesv3(a: &mut [[f64; 3]; 3], b: &mut [f64; 3]) {
925 let mut perm = [0usize, 1, 2];
926 // LU with partial pivoting (column-major in LAPACK; we keep row-major but
927 // pivot by largest |a[col]| in the column, matching the same pivot choice).
928 for k in 0..3 {
929 // Find pivot row in column k at or below k.
930 let mut piv = k;
931 let mut best = a[k][k].abs();
932 for r in (k + 1)..3 {
933 let v = a[r][k].abs();
934 if v > best {
935 best = v;
936 piv = r;
937 }
938 }
939 if piv != k {
940 a.swap(k, piv);
941 perm.swap(k, piv);
942 }
943 for r in (k + 1)..3 {
944 let factor = a[r][k] / a[k][k];
945 a[r][k] = factor;
946 for c in (k + 1)..3 {
947 a[r][c] = (-factor).mul_add(a[k][c], a[r][c]);
948 }
949 }
950 }
951 // Apply row permutation to b.
952 let pb = [b[perm[0]], b[perm[1]], b[perm[2]]];
953 // Forward solve Ly = Pb (unit lower).
954 let mut yv = [0.0; 3];
955 for r in 0..3 {
956 let mut s = pb[r];
957 for c in 0..r {
958 s = (-a[r][c]).mul_add(yv[c], s);
959 }
960 yv[r] = s;
961 }
962 // Back solve Ux = y.
963 for r in (0..3).rev() {
964 let mut s = yv[r];
965 for c in (r + 1)..3 {
966 s = (-a[r][c]).mul_add(b[c], s);
967 }
968 b[r] = s / a[r][r];
969 }
970}
971
972/// Build the per-segment PPoly coefficients exactly as
973/// `scipy.interpolate.CubicHermiteSpline.__init__` (scipy 1.17.1):
974///
975/// ```text
976/// dxr = x[i+1]-x[i]
977/// slope = (y[i+1]-y[i])/dxr
978/// t = (dydx[i] + dydx[i+1] - 2*slope)/dxr
979/// c0 = t/dxr
980/// c1 = (slope - dydx[i])/dxr - t
981/// c2 = dydx[i]
982/// c3 = y[i]
983/// ```
984///
985/// for segment `i` between `x[i]` and `x[i+1]`, with local variable
986/// `s = xval - x[i]`.
987fn hermite_segment_coeffs(
988 x: &[f64],
989 y: &[f64],
990 dydx: &[f64],
991) -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
992 let n = x.len();
993 let mut c0 = vec![0.0; n - 1];
994 let mut c1 = vec![0.0; n - 1];
995 let mut c2 = vec![0.0; n - 1];
996 let mut c3 = vec![0.0; n - 1];
997 for i in 0..n - 1 {
998 let dxr = x[i + 1] - x[i];
999 let slope = (y[i + 1] - y[i]) / dxr;
1000 let t = (dydx[i] + dydx[i + 1] - 2.0 * slope) / dxr;
1001 c0[i] = t / dxr;
1002 c1[i] = (slope - dydx[i]) / dxr - t;
1003 c2[i] = dydx[i];
1004 c3[i] = y[i];
1005 }
1006 (c0, c1, c2, c3)
1007}
1008
1009/// Evaluate the PPoly at `query`, reproducing scipy `_ppoly.evaluate` /
1010/// `find_interval_ascending` (extrapolate=True) and `evaluate_poly1` (dx=0).
1011///
1012/// Interval selection: the largest `i` with `x[i] <= query`, clamped to
1013/// `[0, n-2]`; `query == x[n-1]` maps to interval `n-2` (right-closed); out of
1014/// bounds extrapolates from interval 0 (below) or `n-2` (above).
1015///
1016/// Evaluation order (`evaluate_poly1`, dx=0): with `s = query - x[i]` and
1017/// `z` accumulating powers via repeated `z *= s`,
1018/// `res = c3 + c2*s + c1*s^2 + c0*s^3` summed low-power-first.
1019fn evaluate_ppoly(x: &[f64], c0: &[f64], c1: &[f64], c2: &[f64], c3: &[f64], query: f64) -> f64 {
1020 let n = x.len();
1021 let last = n - 2; // last interval index
1022
1023 // find_interval_ascending with extrapolate=True.
1024 let interval = if query.is_nan() {
1025 // scipy returns -1 -> NaN out; propagate NaN.
1026 return f64::NAN;
1027 } else if query < x[0] {
1028 0
1029 } else if query > x[n - 1] {
1030 last
1031 } else {
1032 // x[0] <= query <= x[n-1]: binary search for i with x[i] <= query < x[i+1];
1033 // query == x[n-1] -> n-2.
1034 if query == x[n - 1] {
1035 last
1036 } else {
1037 let mut lo = 0usize;
1038 let mut hi = n - 1;
1039 while hi - lo > 1 {
1040 let mid = (lo + hi) / 2;
1041 if x[mid] <= query {
1042 lo = mid;
1043 } else {
1044 hi = mid;
1045 }
1046 }
1047 lo
1048 }
1049 };
1050
1051 // evaluate_poly1 (dx=0): res = sum_{kp} c[K-kp-1] * z, z = s^kp built by *=.
1052 let s = query - x[interval];
1053 let mut res = 0.0;
1054 let mut z = 1.0;
1055 // kp = 0 -> coefficient c3 (lowest power), kp=1 -> c2, kp=2 -> c1, kp=3 -> c0.
1056 res += c3[interval] * z;
1057 z *= s;
1058 res += c2[interval] * z;
1059 z *= s;
1060 res += c1[interval] * z;
1061 z *= s;
1062 res += c0[interval] * z;
1063 res
1064}
1065
1066/// Test-only re-export of the core spline evaluator so the parity test can
1067/// drive it directly against the scipy golden fixture.
1068#[cfg(all(test, sidereon_repo_tests))]
1069pub(super) fn eval_cubic_spline_for_test(x: &[f64], y: &[f64], query: f64) -> f64 {
1070 eval_cubic_spline(x, y, query)
1071}
1072
1073#[cfg(all(test, sidereon_repo_tests))]
1074mod interp_tests;