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