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