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