Skip to main content

sidereon_core/sp3/
samples.rs

1//! Sample-backed precise-ephemeris source.
2//!
3//! The canonical precise-ephemeris intermediate representation is a set of
4//! per-satellite ECEF position (+ optional clock) samples on a time axis. SP3
5//! text is one serialization of that IR; [`super::Sp3`] is the parser. This
6//! module builds the same interpolatable source directly from samples, with no
7//! text in the loop, and drives the exact same interpolation substrate the
8//! SP3-parsed source uses ([`super::interp::interpolate_precise_state`]).
9//!
10//! # Byte-identical parity with the SP3 path
11//!
12//! [`PreciseEphemerisSamples::from_samples`] gathers the same node vectors the
13//! SP3 gather builds (shared whole-second J2000 axis; file-native km position;
14//! native microsecond clock) and feeds the shared interpolator. The byte-identity
15//! contract is precise:
16//!
17//! - **Byte-identical** holds when the samples are the faithful image of the
18//!   interpolation fit nodes, that is the round-trip case: samples obtained from
19//!   [`Sp3::precise_ephemeris_samples`] on a parsed source, rebuilt via
20//!   `from_samples`, interpolate to byte-identical states and predicted ranges.
21//!   The SI value each sample carries is the exact `km * KM_TO_M` /
22//!   `us * US_TO_S` image of a fit node, and the single reconstructing divide is
23//!   its correctly-rounded inverse.
24//! - **At the sample's own precision** otherwise. Samples carrying lower
25//!   precision (for example reconstructed from 6-decimal SP3 *text*, which
26//!   serializes only 6 fractional digits, or externally rounded) interpolate at
27//!   that precision, not to the full-precision fit. This is why the contract is
28//!   stated against the fit nodes and NOT against serialize-then-reparse: SP3
29//!   text carries ~0.5 mm less than the SI sample, so a source rebuilt from
30//!   re-serialized text can differ from the parsed source at that scale.
31//!
32//! One further numeric caveat is inherent even in the round-trip case and
33//! documented rather than hidden: the SP3 interpolator fits in the file-native
34//! units (km / microseconds), while a [`PreciseEphemerisSample`] carries SI
35//! meters / seconds. The `km -> m` map (`km * 1000`) is not injective on
36//! IEEE-754 doubles: distinct adjacent km floats can round to the same meters
37//! value. So a sample whose meters came from a km node that shares its meters
38//! image with an adjacent km float reconstructs to the correctly-rounded km,
39//! which may differ from the original by <= 1 ULP (a few nanometres). For
40//! samples whose SI values are the faithful image of the fit nodes (the common,
41//! round-trip case) the reconstruction is exact and parity is byte-identical.
42
43use std::collections::BTreeMap;
44
45use crate::astro::frames::orientation::EarthOrientation;
46use crate::astro::frames::transforms::FrameTransformError;
47use crate::astro::state::CartesianState;
48use crate::astro::time::model::{Instant, TimeScale};
49use crate::constants::{KM_TO_M, US_TO_S};
50use crate::id::GnssSatelliteId;
51use crate::observables::{ObservableEphemerisSource, ObservableState, ObservablesError};
52use crate::sp3::interp::{
53    instant_to_j2000_seconds, interpolate_precise_state, precise_node_j2000_seconds_from_instant,
54    PreciseSatSeries,
55};
56use crate::sp3::{Sp3, Sp3State};
57use crate::{Error, Result};
58
59/// One precise-ephemeris sample: a satellite's ECEF position (and optional
60/// clock) at one epoch, in SI units.
61///
62/// This is the canonical serialization-independent IR element. `position_ecef_m`
63/// is the ITRF/IGS ECEF position in meters; `clock_s` is the satellite clock
64/// offset in seconds, `None` when the source carried no clock estimate.
65///
66/// `clock_event` mirrors the SP3 `E` clock-event flag: when `true` this epoch
67/// marks a clock discontinuity, and the interpolator splits the clock arc there
68/// (it never interpolates a clock across a reset). The common case carries no
69/// event; use [`PreciseEphemerisSample::new`] for it and set the field directly
70/// when reconstructing a flagged epoch.
71#[derive(Debug, Clone, Copy, PartialEq)]
72pub struct PreciseEphemerisSample {
73    /// The satellite this sample describes.
74    pub sat: GnssSatelliteId,
75    /// The sample epoch, tagged with its time scale.
76    pub epoch: Instant,
77    /// Satellite position in the ITRF/IGS ECEF frame, meters.
78    pub position_ecef_m: [f64; 3],
79    /// Satellite clock offset in seconds (`None` when no clock estimate exists).
80    pub clock_s: Option<f64>,
81    /// Whether this epoch carries the SP3 `E` clock-event flag: `true` splits
82    /// the clock interpolation arc here (a clock reset takes effect at this
83    /// epoch), matching [`super::Sp3Flags::clock_event`]. Defaults to `false`.
84    pub clock_event: bool,
85}
86
87impl PreciseEphemerisSample {
88    /// Build a sample with no clock-event flag (the common, no-reset case).
89    ///
90    /// `clock_event` defaults to `false`. For an epoch that carries an SP3 `E`
91    /// clock reset, construct with this and then set `clock_event = true`.
92    pub fn new(
93        sat: GnssSatelliteId,
94        epoch: Instant,
95        position_ecef_m: [f64; 3],
96        clock_s: Option<f64>,
97    ) -> Self {
98        Self {
99            sat,
100            epoch,
101            position_ecef_m,
102            clock_s,
103            clock_event: false,
104        }
105    }
106}
107
108/// One precise-ephemeris state sample with ECEF position and velocity.
109///
110/// This is the state-bearing companion to [`PreciseEphemerisSample`]. It is
111/// emitted only when a source carries a real SP3 velocity record. Positions are
112/// ITRF/IGS ECEF meters and velocities are ITRF/IGS ECEF meters per second.
113#[derive(Debug, Clone, Copy, PartialEq)]
114pub struct PreciseEphemerisStateSample {
115    /// The satellite this sample describes.
116    pub sat: GnssSatelliteId,
117    /// The sample epoch, tagged with its time scale.
118    pub epoch: Instant,
119    /// Satellite position in the ITRF/IGS ECEF frame, meters.
120    pub position_ecef_m: [f64; 3],
121    /// Satellite velocity in the ITRF/IGS ECEF frame, meters per second.
122    pub velocity_ecef_m_s: [f64; 3],
123    /// Satellite clock offset in seconds (`None` when no clock estimate exists).
124    pub clock_s: Option<f64>,
125    /// Satellite clock-rate in seconds per second, when supplied by the source.
126    pub clock_rate_s_s: Option<f64>,
127    /// Whether this epoch carries the SP3 `E` clock-event flag.
128    pub clock_event: bool,
129}
130
131impl PreciseEphemerisStateSample {
132    /// Build a state sample with no clock-event flag.
133    pub fn new(
134        sat: GnssSatelliteId,
135        epoch: Instant,
136        position_ecef_m: [f64; 3],
137        velocity_ecef_m_s: [f64; 3],
138        clock_s: Option<f64>,
139        clock_rate_s_s: Option<f64>,
140    ) -> Self {
141        Self {
142            sat,
143            epoch,
144            position_ecef_m,
145            velocity_ecef_m_s,
146            clock_s,
147            clock_rate_s_s,
148            clock_event: false,
149        }
150    }
151}
152
153/// Convert one SP3 ECEF state sample into the propagator's inertial state.
154///
155/// The supplied [`EarthOrientation`] is the single evaluated frame state for the
156/// sample epoch. The velocity uses the full rotating-frame transport
157/// `v_gcrf = R^T (v_itrf + omega_itrf x r_itrf)`.
158pub fn sp3_ecef_state_to_eci(
159    sample: &PreciseEphemerisStateSample,
160    orientation: &EarthOrientation,
161) -> core::result::Result<CartesianState, FrameTransformError> {
162    validate_state_sample(sample)?;
163    let epoch_j2000_s = instant_to_j2000_seconds(&sample.epoch)
164        .ok_or_else(|| frame_error("epoch", "must be a split Julian date"))?;
165    if !epoch_j2000_s.is_finite() {
166        return Err(frame_error("epoch", "J2000 seconds must be finite"));
167    }
168    let position_itrf_km = [
169        sample.position_ecef_m[0] / KM_TO_M,
170        sample.position_ecef_m[1] / KM_TO_M,
171        sample.position_ecef_m[2] / KM_TO_M,
172    ];
173    let velocity_itrf_km_s = [
174        sample.velocity_ecef_m_s[0] / KM_TO_M,
175        sample.velocity_ecef_m_s[1] / KM_TO_M,
176        sample.velocity_ecef_m_s[2] / KM_TO_M,
177    ];
178    let (position_gcrf_km, velocity_gcrf_km_s) =
179        orientation.itrf_to_gcrf_state_km(position_itrf_km, velocity_itrf_km_s)?;
180    Ok(CartesianState::new(
181        epoch_j2000_s,
182        position_gcrf_km,
183        velocity_gcrf_km_s,
184    ))
185}
186
187/// Validation failure building a [`PreciseEphemerisSamples`] source.
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub enum PreciseSamplesError {
190    /// No samples were supplied.
191    Empty,
192    /// A satellite has only a single sample; interpolation needs at least two.
193    SingleSampleSatellite(GnssSatelliteId),
194    /// A satellite's sample epochs are not strictly increasing in time.
195    NonMonotonicEpochs(GnssSatelliteId),
196    /// Samples carry more than one time scale; a source is a single time axis.
197    MixedTimeScales,
198    /// A sample epoch cannot be expressed as seconds since J2000.
199    EpochNotRepresentable(GnssSatelliteId),
200    /// A sample position or clock value was not finite.
201    NonFiniteSample(GnssSatelliteId),
202}
203
204impl core::fmt::Display for PreciseSamplesError {
205    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
206        match self {
207            Self::Empty => write!(f, "no precise-ephemeris samples supplied"),
208            Self::SingleSampleSatellite(sat) => {
209                write!(f, "satellite {sat} has a single sample; need at least two")
210            }
211            Self::NonMonotonicEpochs(sat) => {
212                write!(
213                    f,
214                    "satellite {sat} sample epochs are not strictly increasing"
215                )
216            }
217            Self::MixedTimeScales => write!(f, "samples carry more than one time scale"),
218            Self::EpochNotRepresentable(sat) => {
219                write!(
220                    f,
221                    "satellite {sat} sample epoch is not representable as J2000 seconds"
222                )
223            }
224            Self::NonFiniteSample(sat) => write!(f, "satellite {sat} has a non-finite sample"),
225        }
226    }
227}
228
229impl std::error::Error for PreciseSamplesError {}
230
231/// A precise-ephemeris source built from samples rather than parsed text.
232///
233/// Implements [`crate::observables::ObservableEphemerisSource`] and exposes the
234/// same [`PreciseEphemerisSamples::position_at_j2000_seconds`] query as the
235/// SP3-parsed source, sharing its interpolation substrate.
236#[derive(Debug, Clone, PartialEq)]
237pub struct PreciseEphemerisSamples {
238    time_scale: TimeScale,
239    nodes: BTreeMap<GnssSatelliteId, PreciseSatSeries>,
240}
241
242impl PreciseEphemerisSamples {
243    /// Build a source from an iterator of samples.
244    ///
245    /// Samples are grouped by satellite, keeping their supplied order. Each
246    /// satellite's series is validated to be strictly increasing in epoch and to
247    /// carry at least two nodes. All samples must share one time scale. The node
248    /// substrate is prepared exactly as the SP3 gather prepares it (shared
249    /// whole-second J2000 axis; native km position; native microsecond clock),
250    /// so the interpolation is byte-identical to the SP3 path for samples that are the
251    /// faithful image of the fit nodes (the round-trip case); samples carrying
252    /// lower precision interpolate at that precision. See the module docs for the
253    /// precise byte-identity contract and the SI-vs-native reconstruction caveat.
254    ///
255    /// The real-product decimation hold-out oracle pins the sample-backed versus
256    /// parsed-text 3D difference at no more than `4.020965667248365e-8` m over
257    /// interior held-out 5-minute records bracketed by 15-minute nodes.
258    pub fn from_samples(
259        samples: impl IntoIterator<Item = PreciseEphemerisSample>,
260    ) -> core::result::Result<Self, PreciseSamplesError> {
261        let mut time_scale: Option<TimeScale> = None;
262        let mut grouped: BTreeMap<GnssSatelliteId, PreciseSatSeries> = BTreeMap::new();
263
264        for sample in samples {
265            match time_scale {
266                None => time_scale = Some(sample.epoch.scale),
267                Some(scale) if scale == sample.epoch.scale => {}
268                Some(_) => return Err(PreciseSamplesError::MixedTimeScales),
269            }
270
271            if !sample.position_ecef_m.iter().all(|c| c.is_finite())
272                || sample.clock_s.is_some_and(|c| !c.is_finite())
273            {
274                return Err(PreciseSamplesError::NonFiniteSample(sample.sat));
275            }
276
277            // Node axis: shared whole-second construction, matching the SP3
278            // gather. A finite `Instant` can still map to a non-finite J2000
279            // second (an extreme Julian date overflowing the difference), which
280            // would poison the node axis and slip past the `w[1] <= w[0]`
281            // monotonicity check (NaN comparisons are false); reject it as
282            // unrepresentable.
283            let seconds = instant_to_j2000_seconds(&sample.epoch)
284                .ok_or(PreciseSamplesError::EpochNotRepresentable(sample.sat))?;
285            if !seconds.is_finite() {
286                return Err(PreciseSamplesError::EpochNotRepresentable(sample.sat));
287            }
288            let xi = precise_node_j2000_seconds_from_instant(&sample.epoch)
289                .ok_or(PreciseSamplesError::EpochNotRepresentable(sample.sat))?;
290
291            // SI -> file-native fit units. The single divide is the correctly
292            // rounded inverse of the SP3 parser's `km * KM_TO_M` / `us * US_TO_S`
293            // (see the module docs for the non-injective boundary).
294            let series = grouped
295                .entry(sample.sat)
296                .or_insert_with(PreciseSatSeries::new);
297            series.x.push(xi);
298            series.kx.push(sample.position_ecef_m[0] / KM_TO_M);
299            series.ky.push(sample.position_ecef_m[1] / KM_TO_M);
300            series.kz.push(sample.position_ecef_m[2] / KM_TO_M);
301            if let Some(clock_s) = sample.clock_s {
302                // A finite `clock_s` can still overflow to a non-finite value in
303                // native microseconds (`clock_s / US_TO_S`); the shared clock
304                // interpolator returns that value unvalidated, so reject it here
305                // rather than emit `Some(inf)`/`Some(NaN)` downstream.
306                let clock_us = clock_s / US_TO_S;
307                if !clock_us.is_finite() {
308                    return Err(PreciseSamplesError::NonFiniteSample(sample.sat));
309                }
310                // Carry the clock-event flag onto the node so the shared
311                // interpolator splits the clock arc at an `E` reset exactly as
312                // the SP3 path does (see `interp::interpolate_clock`).
313                series.clk.push((xi, clock_us, sample.clock_event));
314            }
315        }
316
317        if grouped.is_empty() {
318            return Err(PreciseSamplesError::Empty);
319        }
320        for (&sat, series) in &grouped {
321            if series.x.len() < 2 {
322                return Err(PreciseSamplesError::SingleSampleSatellite(sat));
323            }
324            if series.x.windows(2).any(|w| w[1] <= w[0]) {
325                return Err(PreciseSamplesError::NonMonotonicEpochs(sat));
326            }
327        }
328
329        Ok(Self {
330            time_scale: time_scale.expect("non-empty group has a time scale"),
331            nodes: grouped,
332        })
333    }
334
335    /// The time scale every sample epoch is expressed in.
336    pub fn time_scale(&self) -> TimeScale {
337        self.time_scale
338    }
339
340    /// The satellites this source can interpolate, in ascending order.
341    pub fn satellites(&self) -> impl Iterator<Item = GnssSatelliteId> + '_ {
342        self.nodes.keys().copied()
343    }
344
345    pub(super) fn node_series(&self) -> &BTreeMap<GnssSatelliteId, PreciseSatSeries> {
346        &self.nodes
347    }
348
349    /// Interpolate the state of `sat` at an arbitrary J2000-second epoch.
350    ///
351    /// Identical recipe, substrate, and error surface as
352    /// [`Sp3::position_at_j2000_seconds`]: [`Error::UnknownSatellite`] for a
353    /// satellite with no nodes, [`Error::EpochOutOfRange`] for an out-of-coverage
354    /// query, [`Error::InvalidInput`] for a non-finite query.
355    pub fn position_at_j2000_seconds(&self, sat: GnssSatelliteId, query: f64) -> Result<Sp3State> {
356        // Drive the shared interpolator even for a missing satellite (empty node
357        // slices) so the validation order matches the SP3 path exactly: the
358        // interpolator validates the query (finite) BEFORE it reports
359        // `UnknownSatellite` for an empty node set. A missing-satellite lookup
360        // here must not shadow an `InvalidInput` for a non-finite query.
361        static EMPTY_F64: [f64; 0] = [];
362        static EMPTY_CLK: [(f64, f64, bool); 0] = [];
363        match self.nodes.get(&sat) {
364            Some(series) => interpolate_precise_state(
365                sat,
366                &series.x,
367                &series.kx,
368                &series.ky,
369                &series.kz,
370                &series.clk,
371                query,
372            ),
373            None => interpolate_precise_state(
374                sat, &EMPTY_F64, &EMPTY_F64, &EMPTY_F64, &EMPTY_F64, &EMPTY_CLK, query,
375            ),
376        }
377    }
378
379    /// Interpolate the state of `sat` at an arbitrary [`Instant`].
380    ///
381    /// The query instant must be tagged with the same time scale as the samples.
382    pub fn position(&self, sat: GnssSatelliteId, epoch: Instant) -> Result<Sp3State> {
383        if epoch.scale != self.time_scale {
384            return Err(Error::InvalidInput(format!(
385                "precise-sample query time scale {} does not match source time scale {}",
386                epoch.scale.abbrev(),
387                self.time_scale.abbrev()
388            )));
389        }
390        let query = instant_to_j2000_seconds(&epoch).ok_or(Error::EpochOutOfRange)?;
391        self.position_at_j2000_seconds(sat, query)
392    }
393}
394
395impl ObservableEphemerisSource for PreciseEphemerisSamples {
396    fn observable_state_at_j2000_s(
397        &self,
398        sat: GnssSatelliteId,
399        t_j2000_s: f64,
400    ) -> core::result::Result<ObservableState, ObservablesError> {
401        let state = self
402            .position_at_j2000_seconds(sat, t_j2000_s)
403            .map_err(ObservablesError::Ephemeris)?;
404        Ok(ObservableState {
405            position_ecef_m: state.position.as_array(),
406            clock_s: state.clock_s,
407        })
408    }
409}
410
411impl Sp3 {
412    /// Extract this product as the canonical precise-ephemeris samples, in SI
413    /// units, one per real position record in ascending epoch order.
414    ///
415    /// Round-tripping `PreciseEphemerisSamples::from_samples(sp3.
416    /// precise_ephemeris_samples())` rebuilds the same interpolatable source
417    /// (byte-identical for samples whose meters are the faithful image of the fit
418    /// km; see the module docs).
419    pub fn precise_ephemeris_samples(&self) -> Vec<PreciseEphemerisSample> {
420        let mut out = Vec::new();
421        for (idx, &epoch) in self.epochs.iter().enumerate() {
422            if let Ok(states) = self.states_at(idx) {
423                for (&sat, state) in states {
424                    out.push(PreciseEphemerisSample {
425                        sat,
426                        epoch,
427                        position_ecef_m: state.position.as_array(),
428                        clock_s: state.clock_s,
429                        clock_event: state.flags.clock_event,
430                    });
431                }
432            }
433        }
434        out
435    }
436
437    /// Extract this product as ECEF position and velocity samples.
438    ///
439    /// Samples are emitted only for epochs where the parsed product carries a
440    /// real velocity record. Position-only SP3 products therefore return an
441    /// empty vector rather than fabricating zero velocity.
442    pub fn precise_ephemeris_state_samples(&self) -> Vec<PreciseEphemerisStateSample> {
443        let mut out = Vec::new();
444        for (idx, &epoch) in self.epochs.iter().enumerate() {
445            if let Ok(states) = self.states_at(idx) {
446                for (&sat, state) in states {
447                    if let Some(velocity) = state.velocity {
448                        out.push(PreciseEphemerisStateSample {
449                            sat,
450                            epoch,
451                            position_ecef_m: state.position.as_array(),
452                            velocity_ecef_m_s: velocity.as_array(),
453                            clock_s: state.clock_s,
454                            clock_rate_s_s: state.clock_rate_s_s,
455                            clock_event: state.flags.clock_event,
456                        });
457                    }
458                }
459            }
460        }
461        out
462    }
463}
464
465fn validate_state_sample(
466    sample: &PreciseEphemerisStateSample,
467) -> core::result::Result<(), FrameTransformError> {
468    if !sample.position_ecef_m.iter().all(|value| value.is_finite()) {
469        return Err(frame_error("position_ecef_m", "components must be finite"));
470    }
471    if !sample
472        .velocity_ecef_m_s
473        .iter()
474        .all(|value| value.is_finite())
475    {
476        return Err(frame_error(
477            "velocity_ecef_m_s",
478            "components must be finite",
479        ));
480    }
481    if sample.clock_s.is_some_and(|value| !value.is_finite()) {
482        return Err(frame_error("clock_s", "must be finite"));
483    }
484    if sample
485        .clock_rate_s_s
486        .is_some_and(|value| !value.is_finite())
487    {
488        return Err(frame_error("clock_rate_s_s", "must be finite"));
489    }
490    Ok(())
491}
492
493fn frame_error(field: &'static str, reason: &'static str) -> FrameTransformError {
494    FrameTransformError::InvalidInput { field, reason }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use crate::astro::time::model::{InstantRepr, JulianDateSplit};
501    use crate::constants::SECONDS_PER_DAY;
502    use crate::GnssSystem;
503
504    const J2000_JD_WHOLE: f64 = 2_451_545.0;
505
506    fn gps(prn: u8) -> GnssSatelliteId {
507        GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
508    }
509
510    fn sample(
511        scale: TimeScale,
512        j2000_s: f64,
513        prn: u8,
514        pos: [f64; 3],
515        clk: Option<f64>,
516    ) -> PreciseEphemerisSample {
517        let split =
518            JulianDateSplit::new(J2000_JD_WHOLE, j2000_s / SECONDS_PER_DAY).expect("valid split");
519        PreciseEphemerisSample::new(
520            gps(prn),
521            Instant {
522                scale,
523                repr: InstantRepr::JulianDate(split),
524            },
525            pos,
526            clk,
527        )
528    }
529
530    #[test]
531    fn from_samples_rejects_empty() {
532        let err = PreciseEphemerisSamples::from_samples(std::iter::empty())
533            .expect_err("empty sample set must fail");
534        assert_eq!(err, PreciseSamplesError::Empty);
535    }
536
537    #[test]
538    fn from_samples_rejects_single_sample_satellite() {
539        let samples = vec![sample(
540            TimeScale::Gpst,
541            0.0,
542            21,
543            [20_000_000.0, 14_000_000.0, 21_000_000.0],
544            Some(1.0e-6),
545        )];
546        let err =
547            PreciseEphemerisSamples::from_samples(samples).expect_err("single sample must fail");
548        assert_eq!(err, PreciseSamplesError::SingleSampleSatellite(gps(21)));
549    }
550
551    #[test]
552    fn from_samples_rejects_non_monotonic_epochs() {
553        let samples = vec![
554            sample(TimeScale::Gpst, 900.0, 21, [1.0e7, 2.0e7, 3.0e7], None),
555            sample(TimeScale::Gpst, 900.0, 21, [1.0e7, 2.0e7, 3.0e7], None),
556        ];
557        let err = PreciseEphemerisSamples::from_samples(samples)
558            .expect_err("repeated epoch must fail as non-monotonic");
559        assert_eq!(err, PreciseSamplesError::NonMonotonicEpochs(gps(21)));
560
561        let descending = vec![
562            sample(TimeScale::Gpst, 1_800.0, 7, [1.0e7, 2.0e7, 3.0e7], None),
563            sample(TimeScale::Gpst, 900.0, 7, [1.1e7, 2.1e7, 3.1e7], None),
564        ];
565        let err = PreciseEphemerisSamples::from_samples(descending)
566            .expect_err("descending epochs must fail");
567        assert_eq!(err, PreciseSamplesError::NonMonotonicEpochs(gps(7)));
568    }
569
570    #[test]
571    fn from_samples_rejects_mixed_time_scales() {
572        let samples = vec![
573            sample(TimeScale::Gpst, 0.0, 21, [1.0e7, 2.0e7, 3.0e7], None),
574            sample(TimeScale::Utc, 900.0, 21, [1.0e7, 2.0e7, 3.0e7], None),
575        ];
576        let err = PreciseEphemerisSamples::from_samples(samples)
577            .expect_err("mixed time scales must fail");
578        assert_eq!(err, PreciseSamplesError::MixedTimeScales);
579    }
580
581    #[test]
582    fn from_samples_rejects_non_finite_sample() {
583        let samples = vec![
584            sample(TimeScale::Gpst, 0.0, 21, [f64::NAN, 2.0e7, 3.0e7], None),
585            sample(TimeScale::Gpst, 900.0, 21, [1.0e7, 2.0e7, 3.0e7], None),
586        ];
587        let err = PreciseEphemerisSamples::from_samples(samples).expect_err("non-finite must fail");
588        assert_eq!(err, PreciseSamplesError::NonFiniteSample(gps(21)));
589    }
590
591    #[test]
592    fn from_samples_rejects_epoch_with_non_finite_j2000_seconds() {
593        let split = JulianDateSplit::new(f64::MAX, 0.0).expect("finite split Julian date");
594        let samples = vec![PreciseEphemerisSample::new(
595            gps(21),
596            Instant {
597                scale: TimeScale::Gpst,
598                repr: InstantRepr::JulianDate(split),
599            },
600            [1.0e7, 2.0e7, 3.0e7],
601            None,
602        )];
603        let err = PreciseEphemerisSamples::from_samples(samples)
604            .expect_err("non-finite J2000 seconds must fail");
605        assert_eq!(err, PreciseSamplesError::EpochNotRepresentable(gps(21)));
606    }
607
608    #[test]
609    fn from_samples_rejects_clock_that_overflows_native_microseconds() {
610        let samples = vec![
611            sample(
612                TimeScale::Gpst,
613                0.0,
614                21,
615                [1.0e7, 2.0e7, 3.0e7],
616                Some(f64::MAX),
617            ),
618            sample(TimeScale::Gpst, 900.0, 21, [1.1e7, 2.1e7, 3.1e7], None),
619        ];
620        let err = PreciseEphemerisSamples::from_samples(samples)
621            .expect_err("overflowed native clock must fail");
622        assert_eq!(err, PreciseSamplesError::NonFiniteSample(gps(21)));
623    }
624
625    #[test]
626    fn from_samples_out_of_range_query_errors() {
627        let samples = vec![
628            sample(
629                TimeScale::Gpst,
630                0.0,
631                21,
632                [2.0e7, 1.4e7, 2.1e7],
633                Some(1.0e-6),
634            ),
635            sample(
636                TimeScale::Gpst,
637                900.0,
638                21,
639                [2.0e7, 1.4e7, 2.1e7],
640                Some(1.0e-6),
641            ),
642        ];
643        let source = PreciseEphemerisSamples::from_samples(samples).expect("valid source");
644        // A query far past the node span (many node spacings) is refused, exactly
645        // like the SP3 path.
646        let err = source
647            .position_at_j2000_seconds(gps(21), 1_000_000.0)
648            .expect_err("out-of-coverage query must fail");
649        assert_eq!(err, Error::EpochOutOfRange);
650    }
651
652    #[test]
653    fn unknown_sat_with_non_finite_query_is_invalid_input() {
654        let samples = vec![
655            sample(
656                TimeScale::Gpst,
657                0.0,
658                21,
659                [2.0e7, 1.4e7, 2.1e7],
660                Some(1.0e-6),
661            ),
662            sample(
663                TimeScale::Gpst,
664                900.0,
665                21,
666                [2.0e7, 1.4e7, 2.1e7],
667                Some(1.0e-6),
668            ),
669        ];
670        let source = PreciseEphemerisSamples::from_samples(samples).expect("valid source");
671
672        // The query is validated (finite) BEFORE the satellite-map lookup, so a
673        // non-finite query on a missing satellite returns InvalidInput, matching
674        // the SP3 path (not UnknownSatellite).
675        let err = source
676            .position_at_j2000_seconds(gps(7), f64::NAN)
677            .expect_err("non-finite query on unknown sat must fail");
678        assert!(
679            matches!(err, Error::InvalidInput(_)),
680            "expected InvalidInput, got {err:?}"
681        );
682
683        // A finite query on a missing satellite still reports UnknownSatellite.
684        let err = source
685            .position_at_j2000_seconds(gps(7), 0.0)
686            .expect_err("finite query on unknown sat must fail");
687        assert_eq!(err, Error::UnknownSatellite(gps(7)));
688    }
689}
690
691#[cfg(all(test, sidereon_repo_tests))]
692mod parity_tests {
693    use super::*;
694    use crate::observables::{
695        predict, predict_ranges, PredictOptions, RangePrediction, RangePredictionRequest,
696    };
697    use crate::GnssSystem;
698
699    fn gps(prn: u8) -> GnssSatelliteId {
700        GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
701    }
702
703    fn round_trip_safe_km(km: f64) -> bool {
704        (km * KM_TO_M) / KM_TO_M == km
705    }
706    fn round_trip_safe_us(us: f64) -> bool {
707        (us * US_TO_S) / US_TO_S == us
708    }
709
710    /// Author an SP3-c product from round-trip-safe km/us values, reusing a real
711    /// fixture's header. The samples this parses to serialize losslessly, so the
712    /// sample-backed source is byte-identical to this parsed source.
713    fn authored_sp3() -> Sp3 {
714        let header_src = concat!(
715            env!("CARGO_MANIFEST_DIR"),
716            "/tests/fixtures/sp3/GAP_G01_20201760000_15M.sp3"
717        );
718        let gap = std::fs::read_to_string(header_src).expect("read header fixture");
719        let epoch_start = gap.find("\n*  ").expect("first epoch line") + 1;
720        let header = &gap[..epoch_start];
721
722        // A gentle, non-collinear integer-km path (radius ~26000 km), integer-us
723        // clock; every value is round-trip-safe, asserted below.
724        let xs = [
725            26_000.0, 25_990.0, 25_960.0, 25_910.0, 25_840.0, 25_750.0, 25_640.0, 25_510.0,
726            25_360.0, 25_190.0, 25_000.0, 24_790.0,
727        ];
728        let ys = [
729            1_000.0, 2_000.0, 2_990.0, 3_960.0, 4_910.0, 5_840.0, 6_750.0, 7_640.0, 8_510.0,
730            9_360.0, 10_190.0, 11_000.0,
731        ];
732        let zs = [
733            -3_000.0, -3_050.0, -3_120.0, -3_210.0, -3_320.0, -3_450.0, -3_600.0, -3_770.0,
734            -3_960.0, -4_170.0, -4_400.0, -4_650.0,
735        ];
736        let cs = [
737            100.0, 142.0, -313.0, 6_159.0, 1_234.0, -884.0, 401.0, 862.0, -606.0, 10.0, -369.0,
738            3_654.0,
739        ];
740
741        let mut text = String::from(header);
742        for i in 0..xs.len() {
743            assert!(round_trip_safe_km(xs[i]), "xs[{i}] not round-trip-safe");
744            assert!(round_trip_safe_km(ys[i]), "ys[{i}] not round-trip-safe");
745            assert!(round_trip_safe_km(zs[i]), "zs[{i}] not round-trip-safe");
746            assert!(round_trip_safe_us(cs[i]), "cs[{i}] not round-trip-safe");
747            let total_min = i * 15;
748            let hour = total_min / 60;
749            let minute = total_min % 60;
750            text.push_str(&format!("*  2020  6 24 {hour:2} {minute:2}  0.00000000\n"));
751            text.push_str(&format!(
752                "PG01{:14.6}{:14.6}{:14.6}{:14.6}\n",
753                xs[i], ys[i], zs[i], cs[i]
754            ));
755        }
756        text.push_str("EOF\n");
757        Sp3::parse(text.as_bytes()).expect("parse authored SP3")
758    }
759
760    /// Author an SP3-c product whose epoch at index `event_idx` carries the `E`
761    /// clock-event flag on PG01's position record, so the clock arc is split
762    /// there. Values are round-trip-safe (asserted), so the parsed samples are
763    /// the faithful image of the fit nodes.
764    fn authored_sp3_with_clock_event(event_idx: usize) -> Sp3 {
765        let header_src = concat!(
766            env!("CARGO_MANIFEST_DIR"),
767            "/tests/fixtures/sp3/GAP_G01_20201760000_15M.sp3"
768        );
769        let gap = std::fs::read_to_string(header_src).expect("read header fixture");
770        let epoch_start = gap.find("\n*  ").expect("first epoch line") + 1;
771        let header = &gap[..epoch_start];
772
773        let xs = [
774            26_000.0, 25_990.0, 25_960.0, 25_910.0, 25_840.0, 25_750.0, 25_640.0, 25_510.0,
775            25_360.0, 25_190.0, 25_000.0, 24_790.0,
776        ];
777        let ys = [
778            1_000.0, 2_000.0, 2_990.0, 3_960.0, 4_910.0, 5_840.0, 6_750.0, 7_640.0, 8_510.0,
779            9_360.0, 10_190.0, 11_000.0,
780        ];
781        let zs = [
782            -3_000.0, -3_050.0, -3_120.0, -3_210.0, -3_320.0, -3_450.0, -3_600.0, -3_770.0,
783            -3_960.0, -4_170.0, -4_400.0, -4_650.0,
784        ];
785        // A clock series with a hard reset at the event epoch, so the split
786        // (vs a spline fit across it) actually moves the interpolated value.
787        let cs = [
788            100.0, 142.0, 180.0, 210.0, 235.0, 260.0, -7_500.0, -7_550.0, -7_680.0, -7_790.0,
789            -7_880.0, -7_000.0,
790        ];
791
792        let mut text = String::from(header);
793        for i in 0..xs.len() {
794            assert!(round_trip_safe_km(xs[i]), "xs[{i}] not round-trip-safe");
795            assert!(round_trip_safe_km(ys[i]), "ys[{i}] not round-trip-safe");
796            assert!(round_trip_safe_km(zs[i]), "zs[{i}] not round-trip-safe");
797            assert!(round_trip_safe_us(cs[i]), "cs[{i}] not round-trip-safe");
798            let total_min = i * 15;
799            let hour = total_min / 60;
800            let minute = total_min % 60;
801            text.push_str(&format!("*  2020  6 24 {hour:2} {minute:2}  0.00000000\n"));
802            // Position record: PG01 + 4 fixed-width fields (through column 60).
803            let mut record = format!(
804                "PG01{:14.6}{:14.6}{:14.6}{:14.6}",
805                xs[i], ys[i], zs[i], cs[i]
806            );
807            if i == event_idx {
808                // The clock-event `E` flag lives at column 74 (see
809                // `sp3::parse_flags`). Pad out to it, then place `E`.
810                while record.len() < 74 {
811                    record.push(' ');
812                }
813                record.push('E');
814            }
815            record.push('\n');
816            text.push_str(&record);
817        }
818        text.push_str("EOF\n");
819        let sp3 = Sp3::parse(text.as_bytes()).expect("parse authored SP3");
820        // Confirm the flag actually parsed onto the intended epoch.
821        let state = sp3.state(gps(1), event_idx).expect("event-epoch state");
822        assert!(
823            state.flags.clock_event,
824            "authored E flag did not parse at epoch {event_idx}"
825        );
826        sp3
827    }
828
829    /// An SP3 product with an `E` clock-event epoch, extracted to samples and
830    /// rebuilt via `from_samples`, must interpolate byte-identical clocks across
831    /// the reset. The clock arc split is only preserved if the per-sample
832    /// `clock_event` flag survives the round trip.
833    #[test]
834    fn from_samples_preserves_clock_event_arc_split() {
835        let event_idx = 6usize;
836        let sp3 = authored_sp3_with_clock_event(event_idx);
837        let extracted = sp3.precise_ephemeris_samples();
838        // The flag must be carried on the extracted sample at the event epoch.
839        assert!(
840            extracted.iter().any(|s| s.sat == gps(1) && s.clock_event),
841            "extracted samples dropped the clock-event flag"
842        );
843        let samples = PreciseEphemerisSamples::from_samples(extracted).expect("source");
844
845        let epochs = sp3.epochs_j2000_seconds();
846        assert!(epochs.len() > event_idx + 1);
847
848        // A grid spanning the reset: nodes and interior midpoints on both sides.
849        let mut queries = Vec::new();
850        for w in epochs.windows(2) {
851            queries.push(w[0]);
852            queries.push(0.5 * (w[0] + w[1]));
853        }
854        queries.push(*epochs.last().unwrap());
855
856        let mut saw_some_clock = false;
857        for &q in &queries {
858            let a = sp3.position_at_j2000_seconds(gps(1), q).expect("sp3 state");
859            let b = samples
860                .position_at_j2000_seconds(gps(1), q)
861                .expect("samples state");
862            assert_eq!(
863                a.clock_s.map(f64::to_bits),
864                b.clock_s.map(f64::to_bits),
865                "clock bits differ at query {q} across the reset"
866            );
867            if a.clock_s.is_some() {
868                saw_some_clock = true;
869            }
870        }
871        assert!(saw_some_clock, "expected clock estimates across the grid");
872
873        // Sanity: the split genuinely changes the clock near the reset. Fitting
874        // one spline across the reset (ignoring the event) would give a very
875        // different value; assert the arc-split clock stays near the local
876        // sub-arc data rather than being pulled across the discontinuity.
877        let reset_epoch = epochs[event_idx];
878        let just_after = 0.5 * (epochs[event_idx] + epochs[event_idx + 1]);
879        let clk_after = sp3
880            .position_at_j2000_seconds(gps(1), just_after)
881            .expect("state after reset")
882            .clock_s
883            .expect("clock after reset");
884        // The post-reset sub-arc clocks are around -7500 to -8000 us
885        // (-7.5e-3 to -8.0e-3 s); a spline crossing the reset would land far
886        // from that. This confirms the split is in force on both paths.
887        assert!(
888            clk_after < -1.0e-3,
889            "post-reset clock {clk_after:e} s is not on the post-reset sub-arc; \
890             the arc split was not applied"
891        );
892        let _ = reset_epoch;
893    }
894
895    fn assert_state_bits_eq(a: &Sp3State, b: &Sp3State) {
896        assert_eq!(
897            a.position.as_array().map(f64::to_bits),
898            b.position.as_array().map(f64::to_bits),
899            "position bits differ"
900        );
901        assert_eq!(
902            a.clock_s.map(f64::to_bits),
903            b.clock_s.map(f64::to_bits),
904            "clock bits differ"
905        );
906    }
907
908    #[test]
909    fn from_samples_is_byte_identical_to_parsed_sp3() {
910        let sp3 = authored_sp3();
911        let samples =
912            PreciseEphemerisSamples::from_samples(sp3.precise_ephemeris_samples()).expect("source");
913
914        let epochs = sp3.epochs_j2000_seconds();
915        assert!(epochs.len() >= 4);
916
917        // Query grid: nodes, interior midpoints, quarter points.
918        let mut queries = Vec::new();
919        for w in epochs.windows(2) {
920            queries.push(w[0]);
921            queries.push(0.5 * (w[0] + w[1]));
922            queries.push(0.75 * w[0] + 0.25 * w[1]);
923        }
924        queries.push(*epochs.last().unwrap());
925
926        // Interpolated-state parity.
927        for &q in &queries {
928            let a = sp3.position_at_j2000_seconds(gps(1), q).expect("sp3 state");
929            let b = samples
930                .position_at_j2000_seconds(gps(1), q)
931                .expect("samples state");
932            assert_state_bits_eq(&a, &b);
933        }
934
935        // Predicted-range parity via the batch hot path, over a receiver grid.
936        let receivers = [
937            [4_027_894.0, 307_046.0, 4_919_474.0],
938            [1_130_000.0, -4_830_000.0, 3_994_000.0],
939            [-2_700_000.0, -4_290_000.0, 3_855_000.0],
940        ];
941        let options = PredictOptions::default();
942        for &q in &queries {
943            for rx in receivers {
944                let requests = [RangePredictionRequest {
945                    sat: gps(1),
946                    receiver_ecef_m: rx,
947                    t_rx_j2000_s: q,
948                }];
949                let mut a = [RangePrediction {
950                    geometric_range_m: 0.0,
951                    sat_clock_s: None,
952                    transmit_time_j2000_s: 0.0,
953                    sat_pos_ecef_m: [0.0; 3],
954                }; 1];
955                let mut b = a;
956                predict_ranges(&sp3, &requests, options, &mut a).expect("sp3 ranges");
957                predict_ranges(&samples, &requests, options, &mut b).expect("sample ranges");
958                assert_eq!(
959                    a[0].geometric_range_m.to_bits(),
960                    b[0].geometric_range_m.to_bits()
961                );
962                assert_eq!(
963                    a[0].transmit_time_j2000_s.to_bits(),
964                    b[0].transmit_time_j2000_s.to_bits()
965                );
966                assert_eq!(
967                    a[0].sat_clock_s.map(f64::to_bits),
968                    b[0].sat_clock_s.map(f64::to_bits)
969                );
970                assert_eq!(
971                    a[0].sat_pos_ecef_m.map(f64::to_bits),
972                    b[0].sat_pos_ecef_m.map(f64::to_bits)
973                );
974
975                // Full forward observables agree too.
976                let oa = predict(&sp3, gps(1), rx, q, options).expect("sp3 predict");
977                let ob = predict(&samples, gps(1), rx, q, options).expect("samples predict");
978                assert_eq!(
979                    oa.geometric_range_m.to_bits(),
980                    ob.geometric_range_m.to_bits()
981                );
982                assert_eq!(oa.doppler_hz.to_bits(), ob.doppler_hz.to_bits());
983            }
984        }
985    }
986
987    #[test]
988    fn from_samples_tracks_real_fixture_to_sub_micron() {
989        // On a real product the km -> meters map is not injective (see module
990        // docs), so a meters-carrying sample reconstructs to the correctly-rounded
991        // km, within <= 1 ULP of the fit node. This bounds the resulting
992        // divergence far below any physical threshold and confirms the vast
993        // majority of grid points are still byte-identical.
994        let path = concat!(
995            env!("CARGO_MANIFEST_DIR"),
996            "/tests/fixtures/sp3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3"
997        );
998        let bytes = std::fs::read(path).expect("read fixture");
999        let sp3 = Sp3::parse(&bytes).expect("parse fixture");
1000        let samples =
1001            PreciseEphemerisSamples::from_samples(sp3.precise_ephemeris_samples()).expect("source");
1002
1003        let epochs = sp3.epochs_j2000_seconds();
1004        let sats: Vec<_> = sp3.satellites().to_vec();
1005        let mut compared = 0u64;
1006        let mut byte_identical = 0u64;
1007        let mut max_abs_diff_m = 0.0f64;
1008
1009        for &sat in sats.iter().take(20) {
1010            for w in epochs.windows(2) {
1011                for q in [w[0], 0.5 * (w[0] + w[1])] {
1012                    let (Ok(a), Ok(b)) = (
1013                        sp3.position_at_j2000_seconds(sat, q),
1014                        samples.position_at_j2000_seconds(sat, q),
1015                    ) else {
1016                        continue;
1017                    };
1018                    let pa = a.position.as_array();
1019                    let pb = b.position.as_array();
1020                    for k in 0..3 {
1021                        compared += 1;
1022                        if pa[k].to_bits() == pb[k].to_bits() {
1023                            byte_identical += 1;
1024                        }
1025                        max_abs_diff_m = max_abs_diff_m.max((pa[k] - pb[k]).abs());
1026                    }
1027                }
1028            }
1029        }
1030
1031        assert!(compared > 0);
1032        assert!(
1033            max_abs_diff_m < 1.0e-6,
1034            "max divergence {max_abs_diff_m:e} m exceeds sub-micron bound"
1035        );
1036        assert!(
1037            byte_identical * 100 >= compared * 90,
1038            "expected the vast majority byte-identical, got {byte_identical}/{compared}"
1039        );
1040    }
1041}