Skip to main content

sidereon_core/sp3/
interpolant.rs

1//! Cached precise-ephemeris interpolant.
2
3use std::collections::BTreeMap;
4
5use crate::astro::time::model::{Instant, TimeScale};
6use crate::id::GnssSatelliteId;
7use crate::observables::{
8    ObservableEphemerisSource, ObservableState, ObservableStateBatch, ObservablesError,
9};
10use crate::sp3::interp::{
11    fit_clock_spline_arcs, gather_sp3_precise_series, instant_to_j2000_seconds,
12    interpolate_precise_state, interpolate_precise_state_with_clock_arcs, ClockSplineArc,
13    PreciseSatSeries,
14};
15use crate::sp3::{
16    PreciseEphemerisSample, PreciseEphemerisSamples, PreciseSamplesError, Sp3, Sp3State,
17};
18use crate::{Error, Result};
19
20/// Error returned while building a [`PreciseEphemerisInterpolant`].
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum PreciseInterpolantError {
23    /// A sample-backed build failed sample validation.
24    Samples(PreciseSamplesError),
25}
26
27impl core::fmt::Display for PreciseInterpolantError {
28    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
29        match self {
30            Self::Samples(err) => write!(f, "{err}"),
31        }
32    }
33}
34
35impl std::error::Error for PreciseInterpolantError {}
36
37impl From<PreciseSamplesError> for PreciseInterpolantError {
38    fn from(error: PreciseSamplesError) -> Self {
39        Self::Samples(error)
40    }
41}
42
43/// A reusable precise-ephemeris interpolant with cached per-satellite nodes.
44///
45/// The handle owns the same native-unit node vectors the scalar SP3 evaluator
46/// gathers on each call. Evaluation still uses the shared precise interpolation
47/// substrate, so changing from a parsed [`Sp3`] to this handle changes only when
48/// nodes are gathered, not the arithmetic recipe.
49#[derive(Debug, Clone, PartialEq)]
50pub struct PreciseEphemerisInterpolant {
51    time_scale: TimeScale,
52    pub(super) nodes: BTreeMap<GnssSatelliteId, FittedPreciseSatSeries>,
53}
54
55#[derive(Debug, Clone, PartialEq)]
56pub(super) struct FittedPreciseSatSeries {
57    pub(super) series: PreciseSatSeries,
58    pub(super) clock_arcs: Vec<ClockSplineArc>,
59}
60
61impl FittedPreciseSatSeries {
62    pub(super) fn new(series: PreciseSatSeries) -> Self {
63        let clock_arcs = fit_clock_spline_arcs(&series.clk);
64        Self { series, clock_arcs }
65    }
66}
67
68impl PreciseEphemerisInterpolant {
69    /// Build a cached interpolant from a parsed SP3 product.
70    ///
71    /// Nodes are copied from the product's native SP3 kilometer and microsecond
72    /// values, matching the scalar [`Sp3::position_at_j2000_seconds`] gather.
73    /// The real-product decimation hold-out oracle pins the parsed-text versus
74    /// sample-backed 3D difference at no more than `4.020965667248365e-8` m
75    /// over interior held-out 5-minute records bracketed by 15-minute nodes.
76    pub fn from_sp3(source: &Sp3) -> Self {
77        let mut nodes = BTreeMap::new();
78        for &sat in source.satellites() {
79            let series = gather_sp3_precise_series(source, sat);
80            if !series.x.is_empty() {
81                nodes.insert(sat, FittedPreciseSatSeries::new(series));
82            }
83        }
84        Self {
85            time_scale: source.header.time_scale,
86            nodes,
87        }
88    }
89
90    /// Build a cached interpolant from precise samples.
91    ///
92    /// This validates samples through [`PreciseEphemerisSamples::from_samples`]
93    /// and then copies the prepared native-unit node series into this handle.
94    /// The real-product decimation hold-out oracle pins the sample-backed versus
95    /// parsed-text 3D difference at no more than `4.020965667248365e-8` m over
96    /// interior held-out 5-minute records bracketed by 15-minute nodes.
97    pub fn from_samples(
98        samples: impl IntoIterator<Item = PreciseEphemerisSample>,
99    ) -> core::result::Result<Self, PreciseInterpolantError> {
100        let source = PreciseEphemerisSamples::from_samples(samples)?;
101        Ok(Self::from_precise_ephemeris_samples(&source))
102    }
103
104    /// Build a cached interpolant from an existing sample-backed source.
105    pub fn from_precise_ephemeris_samples(source: &PreciseEphemerisSamples) -> Self {
106        Self {
107            time_scale: source.time_scale(),
108            nodes: source
109                .node_series()
110                .iter()
111                .map(|(&sat, series)| (sat, FittedPreciseSatSeries::new(series.clone())))
112                .collect(),
113        }
114    }
115
116    /// The time scale of the source epochs used to build this handle.
117    pub fn time_scale(&self) -> TimeScale {
118        self.time_scale
119    }
120
121    pub(super) fn node_series(&self) -> &BTreeMap<GnssSatelliteId, FittedPreciseSatSeries> {
122        &self.nodes
123    }
124
125    /// The satellites this handle can interpolate, in ascending order.
126    pub fn satellites(&self) -> impl Iterator<Item = GnssSatelliteId> + '_ {
127        self.nodes.keys().copied()
128    }
129
130    /// Interpolate the state of `sat` at an arbitrary J2000-second epoch.
131    ///
132    /// The error surface matches [`Sp3::position_at_j2000_seconds`]:
133    /// [`Error::UnknownSatellite`] for a satellite with no nodes,
134    /// [`Error::EpochOutOfRange`] for an out-of-coverage query, and
135    /// [`Error::InvalidInput`] for a non-finite query.
136    pub fn position_at_j2000_seconds(&self, sat: GnssSatelliteId, query: f64) -> Result<Sp3State> {
137        static EMPTY_F64: [f64; 0] = [];
138        static EMPTY_CLK: [(f64, f64, bool); 0] = [];
139        match self.nodes.get(&sat) {
140            Some(fitted) => interpolate_precise_state_with_clock_arcs(
141                sat,
142                &fitted.series.x,
143                &fitted.series.kx,
144                &fitted.series.ky,
145                &fitted.series.kz,
146                &fitted.clock_arcs,
147                query,
148            ),
149            None => interpolate_precise_state(
150                sat, &EMPTY_F64, &EMPTY_F64, &EMPTY_F64, &EMPTY_F64, &EMPTY_CLK, query,
151            ),
152        }
153    }
154
155    /// Interpolate the state of `sat` at an arbitrary [`Instant`].
156    ///
157    /// The query instant must use the same time scale as the source used to
158    /// build this handle.
159    pub fn position(&self, sat: GnssSatelliteId, epoch: Instant) -> Result<Sp3State> {
160        if epoch.scale != self.time_scale {
161            return Err(Error::InvalidInput(format!(
162                "precise-interpolant query time scale {} does not match source time scale {}",
163                epoch.scale.abbrev(),
164                self.time_scale.abbrev()
165            )));
166        }
167        let query = instant_to_j2000_seconds(&epoch).ok_or(Error::EpochOutOfRange)?;
168        self.position_at_j2000_seconds(sat, query)
169    }
170
171    /// ECEF states for parallel satellite and epoch arrays.
172    ///
173    /// This is the same output contract as
174    /// [`ObservableEphemerisSource::observable_states_at_j2000_s`].
175    pub fn observable_states_at_j2000_s(
176        &self,
177        satellites: &[GnssSatelliteId],
178        epochs_j2000_s: &[f64],
179    ) -> core::result::Result<ObservableStateBatch, ObservablesError> {
180        <Self as ObservableEphemerisSource>::observable_states_at_j2000_s(
181            self,
182            satellites,
183            epochs_j2000_s,
184        )
185    }
186
187    /// ECEF states for many satellites at one shared epoch.
188    ///
189    /// This is the same output contract as
190    /// [`ObservableEphemerisSource::observable_states_at_shared_j2000_s`].
191    pub fn observable_states_at_shared_j2000_s(
192        &self,
193        satellites: &[GnssSatelliteId],
194        epoch_j2000_s: f64,
195    ) -> ObservableStateBatch {
196        <Self as ObservableEphemerisSource>::observable_states_at_shared_j2000_s(
197            self,
198            satellites,
199            epoch_j2000_s,
200        )
201    }
202}
203
204impl ObservableEphemerisSource for PreciseEphemerisInterpolant {
205    fn observable_state_at_j2000_s(
206        &self,
207        sat: GnssSatelliteId,
208        t_j2000_s: f64,
209    ) -> core::result::Result<ObservableState, ObservablesError> {
210        let state = self
211            .position_at_j2000_seconds(sat, t_j2000_s)
212            .map_err(ObservablesError::Ephemeris)?;
213        Ok(ObservableState {
214            position_ecef_m: state.position.as_array(),
215            clock_s: state.clock_s,
216        })
217    }
218}