1use 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#[derive(Debug, Clone, Copy, PartialEq)]
66pub struct PreciseEphemerisSample {
67 pub sat: GnssSatelliteId,
69 pub epoch: Instant,
71 pub position_ecef_m: [f64; 3],
73 pub clock_s: Option<f64>,
75 pub clock_event: bool,
79}
80
81impl PreciseEphemerisSample {
82 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#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum PreciseSamplesError {
105 Empty,
107 SingleSampleSatellite(GnssSatelliteId),
109 NonMonotonicEpochs(GnssSatelliteId),
111 MixedTimeScales,
113 EpochNotRepresentable(GnssSatelliteId),
115 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#[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#[derive(Debug, Clone, PartialEq)]
164pub struct PreciseEphemerisSamples {
165 time_scale: TimeScale,
166 nodes: BTreeMap<GnssSatelliteId, SatSeries>,
167}
168
169impl PreciseEphemerisSamples {
170 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 let xi = instant_to_j2000_seconds(&sample.epoch)
203 .ok_or(PreciseSamplesError::EpochNotRepresentable(sample.sat))?
204 .floor();
205
206 let series = grouped.entry(sample.sat).or_insert_with(|| SatSeries {
210 x: Vec::new(),
211 kx: Vec::new(),
212 ky: Vec::new(),
213 kz: Vec::new(),
214 clk: Vec::new(),
215 });
216 series.x.push(xi);
217 series.kx.push(sample.position_ecef_m[0] / KM_TO_M);
218 series.ky.push(sample.position_ecef_m[1] / KM_TO_M);
219 series.kz.push(sample.position_ecef_m[2] / KM_TO_M);
220 if let Some(clock_s) = sample.clock_s {
221 series.clk.push((xi, clock_s / US_TO_S, sample.clock_event));
225 }
226 }
227
228 if grouped.is_empty() {
229 return Err(PreciseSamplesError::Empty);
230 }
231 for (&sat, series) in &grouped {
232 if series.x.len() < 2 {
233 return Err(PreciseSamplesError::SingleSampleSatellite(sat));
234 }
235 if series.x.windows(2).any(|w| w[1] <= w[0]) {
236 return Err(PreciseSamplesError::NonMonotonicEpochs(sat));
237 }
238 }
239
240 Ok(Self {
241 time_scale: time_scale.expect("non-empty group has a time scale"),
242 nodes: grouped,
243 })
244 }
245
246 pub fn time_scale(&self) -> TimeScale {
248 self.time_scale
249 }
250
251 pub fn satellites(&self) -> impl Iterator<Item = GnssSatelliteId> + '_ {
253 self.nodes.keys().copied()
254 }
255
256 pub fn position_at_j2000_seconds(&self, sat: GnssSatelliteId, query: f64) -> Result<Sp3State> {
263 static EMPTY_F64: [f64; 0] = [];
269 static EMPTY_CLK: [(f64, f64, bool); 0] = [];
270 match self.nodes.get(&sat) {
271 Some(series) => interpolate_precise_state(
272 sat,
273 &series.x,
274 &series.kx,
275 &series.ky,
276 &series.kz,
277 &series.clk,
278 query,
279 ),
280 None => interpolate_precise_state(
281 sat, &EMPTY_F64, &EMPTY_F64, &EMPTY_F64, &EMPTY_F64, &EMPTY_CLK, query,
282 ),
283 }
284 }
285
286 pub fn position(&self, sat: GnssSatelliteId, epoch: Instant) -> Result<Sp3State> {
290 if epoch.scale != self.time_scale {
291 return Err(Error::InvalidInput(format!(
292 "precise-sample query time scale {} does not match source time scale {}",
293 epoch.scale.abbrev(),
294 self.time_scale.abbrev()
295 )));
296 }
297 let query = instant_to_j2000_seconds(&epoch).ok_or(Error::EpochOutOfRange)?;
298 self.position_at_j2000_seconds(sat, query)
299 }
300}
301
302impl ObservableEphemerisSource for PreciseEphemerisSamples {
303 fn observable_state_at_j2000_s(
304 &self,
305 sat: GnssSatelliteId,
306 t_j2000_s: f64,
307 ) -> core::result::Result<ObservableState, ObservablesError> {
308 let state = self
309 .position_at_j2000_seconds(sat, t_j2000_s)
310 .map_err(ObservablesError::Ephemeris)?;
311 Ok(ObservableState {
312 position_ecef_m: state.position.as_array(),
313 clock_s: state.clock_s,
314 })
315 }
316}
317
318impl Sp3 {
319 pub fn precise_ephemeris_samples(&self) -> Vec<PreciseEphemerisSample> {
327 let mut out = Vec::new();
328 for (idx, &epoch) in self.epochs.iter().enumerate() {
329 if let Ok(states) = self.states_at(idx) {
330 for (&sat, state) in states {
331 out.push(PreciseEphemerisSample {
332 sat,
333 epoch,
334 position_ecef_m: state.position.as_array(),
335 clock_s: state.clock_s,
336 clock_event: state.flags.clock_event,
337 });
338 }
339 }
340 }
341 out
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use crate::astro::time::model::{InstantRepr, JulianDateSplit};
349 use crate::constants::SECONDS_PER_DAY;
350 use crate::GnssSystem;
351
352 const J2000_JD_WHOLE: f64 = 2_451_545.0;
353
354 fn gps(prn: u8) -> GnssSatelliteId {
355 GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
356 }
357
358 fn sample(
359 scale: TimeScale,
360 j2000_s: f64,
361 prn: u8,
362 pos: [f64; 3],
363 clk: Option<f64>,
364 ) -> PreciseEphemerisSample {
365 let split =
366 JulianDateSplit::new(J2000_JD_WHOLE, j2000_s / SECONDS_PER_DAY).expect("valid split");
367 PreciseEphemerisSample::new(
368 gps(prn),
369 Instant {
370 scale,
371 repr: InstantRepr::JulianDate(split),
372 },
373 pos,
374 clk,
375 )
376 }
377
378 #[test]
379 fn from_samples_rejects_empty() {
380 let err = PreciseEphemerisSamples::from_samples(std::iter::empty())
381 .expect_err("empty sample set must fail");
382 assert_eq!(err, PreciseSamplesError::Empty);
383 }
384
385 #[test]
386 fn from_samples_rejects_single_sample_satellite() {
387 let samples = vec![sample(
388 TimeScale::Gpst,
389 0.0,
390 21,
391 [20_000_000.0, 14_000_000.0, 21_000_000.0],
392 Some(1.0e-6),
393 )];
394 let err =
395 PreciseEphemerisSamples::from_samples(samples).expect_err("single sample must fail");
396 assert_eq!(err, PreciseSamplesError::SingleSampleSatellite(gps(21)));
397 }
398
399 #[test]
400 fn from_samples_rejects_non_monotonic_epochs() {
401 let samples = vec![
402 sample(TimeScale::Gpst, 900.0, 21, [1.0e7, 2.0e7, 3.0e7], None),
403 sample(TimeScale::Gpst, 900.0, 21, [1.0e7, 2.0e7, 3.0e7], None),
404 ];
405 let err = PreciseEphemerisSamples::from_samples(samples)
406 .expect_err("repeated epoch must fail as non-monotonic");
407 assert_eq!(err, PreciseSamplesError::NonMonotonicEpochs(gps(21)));
408
409 let descending = vec![
410 sample(TimeScale::Gpst, 1_800.0, 7, [1.0e7, 2.0e7, 3.0e7], None),
411 sample(TimeScale::Gpst, 900.0, 7, [1.1e7, 2.1e7, 3.1e7], None),
412 ];
413 let err = PreciseEphemerisSamples::from_samples(descending)
414 .expect_err("descending epochs must fail");
415 assert_eq!(err, PreciseSamplesError::NonMonotonicEpochs(gps(7)));
416 }
417
418 #[test]
419 fn from_samples_rejects_mixed_time_scales() {
420 let samples = vec![
421 sample(TimeScale::Gpst, 0.0, 21, [1.0e7, 2.0e7, 3.0e7], None),
422 sample(TimeScale::Utc, 900.0, 21, [1.0e7, 2.0e7, 3.0e7], None),
423 ];
424 let err = PreciseEphemerisSamples::from_samples(samples)
425 .expect_err("mixed time scales must fail");
426 assert_eq!(err, PreciseSamplesError::MixedTimeScales);
427 }
428
429 #[test]
430 fn from_samples_rejects_non_finite_sample() {
431 let samples = vec![
432 sample(TimeScale::Gpst, 0.0, 21, [f64::NAN, 2.0e7, 3.0e7], None),
433 sample(TimeScale::Gpst, 900.0, 21, [1.0e7, 2.0e7, 3.0e7], None),
434 ];
435 let err = PreciseEphemerisSamples::from_samples(samples).expect_err("non-finite must fail");
436 assert_eq!(err, PreciseSamplesError::NonFiniteSample(gps(21)));
437 }
438
439 #[test]
440 fn from_samples_out_of_range_query_errors() {
441 let samples = vec![
442 sample(
443 TimeScale::Gpst,
444 0.0,
445 21,
446 [2.0e7, 1.4e7, 2.1e7],
447 Some(1.0e-6),
448 ),
449 sample(
450 TimeScale::Gpst,
451 900.0,
452 21,
453 [2.0e7, 1.4e7, 2.1e7],
454 Some(1.0e-6),
455 ),
456 ];
457 let source = PreciseEphemerisSamples::from_samples(samples).expect("valid source");
458 let err = source
461 .position_at_j2000_seconds(gps(21), 1_000_000.0)
462 .expect_err("out-of-coverage query must fail");
463 assert_eq!(err, Error::EpochOutOfRange);
464 }
465
466 #[test]
467 fn unknown_sat_with_non_finite_query_is_invalid_input() {
468 let samples = vec![
469 sample(
470 TimeScale::Gpst,
471 0.0,
472 21,
473 [2.0e7, 1.4e7, 2.1e7],
474 Some(1.0e-6),
475 ),
476 sample(
477 TimeScale::Gpst,
478 900.0,
479 21,
480 [2.0e7, 1.4e7, 2.1e7],
481 Some(1.0e-6),
482 ),
483 ];
484 let source = PreciseEphemerisSamples::from_samples(samples).expect("valid source");
485
486 let err = source
490 .position_at_j2000_seconds(gps(7), f64::NAN)
491 .expect_err("non-finite query on unknown sat must fail");
492 assert!(
493 matches!(err, Error::InvalidInput(_)),
494 "expected InvalidInput, got {err:?}"
495 );
496
497 let err = source
499 .position_at_j2000_seconds(gps(7), 0.0)
500 .expect_err("finite query on unknown sat must fail");
501 assert_eq!(err, Error::UnknownSatellite(gps(7)));
502 }
503}
504
505#[cfg(all(test, sidereon_repo_tests))]
506mod parity_tests {
507 use super::*;
508 use crate::observables::{
509 predict, predict_ranges, PredictOptions, RangePrediction, RangePredictionRequest,
510 };
511 use crate::GnssSystem;
512
513 fn gps(prn: u8) -> GnssSatelliteId {
514 GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
515 }
516
517 fn round_trip_safe_km(km: f64) -> bool {
518 (km * KM_TO_M) / KM_TO_M == km
519 }
520 fn round_trip_safe_us(us: f64) -> bool {
521 (us * US_TO_S) / US_TO_S == us
522 }
523
524 fn authored_sp3() -> Sp3 {
528 let header_src = concat!(
529 env!("CARGO_MANIFEST_DIR"),
530 "/tests/fixtures/sp3/GAP_G01_20201760000_15M.sp3"
531 );
532 let gap = std::fs::read_to_string(header_src).expect("read header fixture");
533 let epoch_start = gap.find("\n* ").expect("first epoch line") + 1;
534 let header = &gap[..epoch_start];
535
536 let xs = [
539 26_000.0, 25_990.0, 25_960.0, 25_910.0, 25_840.0, 25_750.0, 25_640.0, 25_510.0,
540 25_360.0, 25_190.0, 25_000.0, 24_790.0,
541 ];
542 let ys = [
543 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,
544 9_360.0, 10_190.0, 11_000.0,
545 ];
546 let zs = [
547 -3_000.0, -3_050.0, -3_120.0, -3_210.0, -3_320.0, -3_450.0, -3_600.0, -3_770.0,
548 -3_960.0, -4_170.0, -4_400.0, -4_650.0,
549 ];
550 let cs = [
551 100.0, 142.0, -313.0, 6_159.0, 1_234.0, -884.0, 401.0, 862.0, -606.0, 10.0, -369.0,
552 3_654.0,
553 ];
554
555 let mut text = String::from(header);
556 for i in 0..xs.len() {
557 assert!(round_trip_safe_km(xs[i]), "xs[{i}] not round-trip-safe");
558 assert!(round_trip_safe_km(ys[i]), "ys[{i}] not round-trip-safe");
559 assert!(round_trip_safe_km(zs[i]), "zs[{i}] not round-trip-safe");
560 assert!(round_trip_safe_us(cs[i]), "cs[{i}] not round-trip-safe");
561 let total_min = i * 15;
562 let hour = total_min / 60;
563 let minute = total_min % 60;
564 text.push_str(&format!("* 2020 6 24 {hour:2} {minute:2} 0.00000000\n"));
565 text.push_str(&format!(
566 "PG01{:14.6}{:14.6}{:14.6}{:14.6}\n",
567 xs[i], ys[i], zs[i], cs[i]
568 ));
569 }
570 text.push_str("EOF\n");
571 Sp3::parse(text.as_bytes()).expect("parse authored SP3")
572 }
573
574 fn authored_sp3_with_clock_event(event_idx: usize) -> Sp3 {
579 let header_src = concat!(
580 env!("CARGO_MANIFEST_DIR"),
581 "/tests/fixtures/sp3/GAP_G01_20201760000_15M.sp3"
582 );
583 let gap = std::fs::read_to_string(header_src).expect("read header fixture");
584 let epoch_start = gap.find("\n* ").expect("first epoch line") + 1;
585 let header = &gap[..epoch_start];
586
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 = [
602 100.0, 142.0, 180.0, 210.0, 235.0, 260.0, -7_500.0, -7_550.0, -7_680.0, -7_790.0,
603 -7_880.0, -7_000.0,
604 ];
605
606 let mut text = String::from(header);
607 for i in 0..xs.len() {
608 assert!(round_trip_safe_km(xs[i]), "xs[{i}] not round-trip-safe");
609 assert!(round_trip_safe_km(ys[i]), "ys[{i}] not round-trip-safe");
610 assert!(round_trip_safe_km(zs[i]), "zs[{i}] not round-trip-safe");
611 assert!(round_trip_safe_us(cs[i]), "cs[{i}] not round-trip-safe");
612 let total_min = i * 15;
613 let hour = total_min / 60;
614 let minute = total_min % 60;
615 text.push_str(&format!("* 2020 6 24 {hour:2} {minute:2} 0.00000000\n"));
616 let mut record = format!(
618 "PG01{:14.6}{:14.6}{:14.6}{:14.6}",
619 xs[i], ys[i], zs[i], cs[i]
620 );
621 if i == event_idx {
622 while record.len() < 74 {
625 record.push(' ');
626 }
627 record.push('E');
628 }
629 record.push('\n');
630 text.push_str(&record);
631 }
632 text.push_str("EOF\n");
633 let sp3 = Sp3::parse(text.as_bytes()).expect("parse authored SP3");
634 let state = sp3.state(gps(1), event_idx).expect("event-epoch state");
636 assert!(
637 state.flags.clock_event,
638 "authored E flag did not parse at epoch {event_idx}"
639 );
640 sp3
641 }
642
643 #[test]
648 fn from_samples_preserves_clock_event_arc_split() {
649 let event_idx = 6usize;
650 let sp3 = authored_sp3_with_clock_event(event_idx);
651 let extracted = sp3.precise_ephemeris_samples();
652 assert!(
654 extracted.iter().any(|s| s.sat == gps(1) && s.clock_event),
655 "extracted samples dropped the clock-event flag"
656 );
657 let samples = PreciseEphemerisSamples::from_samples(extracted).expect("source");
658
659 let epochs = sp3.epochs_j2000_seconds();
660 assert!(epochs.len() > event_idx + 1);
661
662 let mut queries = Vec::new();
664 for w in epochs.windows(2) {
665 queries.push(w[0]);
666 queries.push(0.5 * (w[0] + w[1]));
667 }
668 queries.push(*epochs.last().unwrap());
669
670 let mut saw_some_clock = false;
671 for &q in &queries {
672 let a = sp3.position_at_j2000_seconds(gps(1), q).expect("sp3 state");
673 let b = samples
674 .position_at_j2000_seconds(gps(1), q)
675 .expect("samples state");
676 assert_eq!(
677 a.clock_s.map(f64::to_bits),
678 b.clock_s.map(f64::to_bits),
679 "clock bits differ at query {q} across the reset"
680 );
681 if a.clock_s.is_some() {
682 saw_some_clock = true;
683 }
684 }
685 assert!(saw_some_clock, "expected clock estimates across the grid");
686
687 let reset_epoch = epochs[event_idx];
692 let just_after = 0.5 * (epochs[event_idx] + epochs[event_idx + 1]);
693 let clk_after = sp3
694 .position_at_j2000_seconds(gps(1), just_after)
695 .expect("state after reset")
696 .clock_s
697 .expect("clock after reset");
698 assert!(
702 clk_after < -1.0e-3,
703 "post-reset clock {clk_after:e} s is not on the post-reset sub-arc; \
704 the arc split was not applied"
705 );
706 let _ = reset_epoch;
707 }
708
709 fn assert_state_bits_eq(a: &Sp3State, b: &Sp3State) {
710 assert_eq!(
711 a.position.as_array().map(f64::to_bits),
712 b.position.as_array().map(f64::to_bits),
713 "position bits differ"
714 );
715 assert_eq!(
716 a.clock_s.map(f64::to_bits),
717 b.clock_s.map(f64::to_bits),
718 "clock bits differ"
719 );
720 }
721
722 #[test]
723 fn from_samples_is_byte_identical_to_parsed_sp3() {
724 let sp3 = authored_sp3();
725 let samples =
726 PreciseEphemerisSamples::from_samples(sp3.precise_ephemeris_samples()).expect("source");
727
728 let epochs = sp3.epochs_j2000_seconds();
729 assert!(epochs.len() >= 4);
730
731 let mut queries = Vec::new();
733 for w in epochs.windows(2) {
734 queries.push(w[0]);
735 queries.push(0.5 * (w[0] + w[1]));
736 queries.push(0.75 * w[0] + 0.25 * w[1]);
737 }
738 queries.push(*epochs.last().unwrap());
739
740 for &q in &queries {
742 let a = sp3.position_at_j2000_seconds(gps(1), q).expect("sp3 state");
743 let b = samples
744 .position_at_j2000_seconds(gps(1), q)
745 .expect("samples state");
746 assert_state_bits_eq(&a, &b);
747 }
748
749 let receivers = [
751 [4_027_894.0, 307_046.0, 4_919_474.0],
752 [1_130_000.0, -4_830_000.0, 3_994_000.0],
753 [-2_700_000.0, -4_290_000.0, 3_855_000.0],
754 ];
755 let options = PredictOptions::default();
756 for &q in &queries {
757 for rx in receivers {
758 let requests = [RangePredictionRequest {
759 sat: gps(1),
760 receiver_ecef_m: rx,
761 t_rx_j2000_s: q,
762 }];
763 let mut a = [RangePrediction {
764 geometric_range_m: 0.0,
765 sat_clock_s: None,
766 transmit_time_j2000_s: 0.0,
767 sat_pos_ecef_m: [0.0; 3],
768 }; 1];
769 let mut b = a;
770 predict_ranges(&sp3, &requests, options, &mut a).expect("sp3 ranges");
771 predict_ranges(&samples, &requests, options, &mut b).expect("sample ranges");
772 assert_eq!(
773 a[0].geometric_range_m.to_bits(),
774 b[0].geometric_range_m.to_bits()
775 );
776 assert_eq!(
777 a[0].transmit_time_j2000_s.to_bits(),
778 b[0].transmit_time_j2000_s.to_bits()
779 );
780 assert_eq!(
781 a[0].sat_clock_s.map(f64::to_bits),
782 b[0].sat_clock_s.map(f64::to_bits)
783 );
784 assert_eq!(
785 a[0].sat_pos_ecef_m.map(f64::to_bits),
786 b[0].sat_pos_ecef_m.map(f64::to_bits)
787 );
788
789 let oa = predict(&sp3, gps(1), rx, q, options).expect("sp3 predict");
791 let ob = predict(&samples, gps(1), rx, q, options).expect("samples predict");
792 assert_eq!(
793 oa.geometric_range_m.to_bits(),
794 ob.geometric_range_m.to_bits()
795 );
796 assert_eq!(oa.doppler_hz.to_bits(), ob.doppler_hz.to_bits());
797 }
798 }
799 }
800
801 #[test]
802 fn from_samples_tracks_real_fixture_to_sub_micron() {
803 let path = concat!(
809 env!("CARGO_MANIFEST_DIR"),
810 "/tests/fixtures/sp3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3"
811 );
812 let bytes = std::fs::read(path).expect("read fixture");
813 let sp3 = Sp3::parse(&bytes).expect("parse fixture");
814 let samples =
815 PreciseEphemerisSamples::from_samples(sp3.precise_ephemeris_samples()).expect("source");
816
817 let epochs = sp3.epochs_j2000_seconds();
818 let sats: Vec<_> = sp3.satellites().to_vec();
819 let mut compared = 0u64;
820 let mut byte_identical = 0u64;
821 let mut max_abs_diff_m = 0.0f64;
822
823 for &sat in sats.iter().take(20) {
824 for w in epochs.windows(2) {
825 for q in [w[0], 0.5 * (w[0] + w[1])] {
826 let (Ok(a), Ok(b)) = (
827 sp3.position_at_j2000_seconds(sat, q),
828 samples.position_at_j2000_seconds(sat, q),
829 ) else {
830 continue;
831 };
832 let pa = a.position.as_array();
833 let pb = b.position.as_array();
834 for k in 0..3 {
835 compared += 1;
836 if pa[k].to_bits() == pb[k].to_bits() {
837 byte_identical += 1;
838 }
839 max_abs_diff_m = max_abs_diff_m.max((pa[k] - pb[k]).abs());
840 }
841 }
842 }
843 }
844
845 assert!(compared > 0);
846 assert!(
847 max_abs_diff_m < 1.0e-6,
848 "max divergence {max_abs_diff_m:e} m exceeds sub-micron bound"
849 );
850 assert!(
851 byte_identical * 100 >= compared * 90,
852 "expected the vast majority byte-identical, got {byte_identical}/{compared}"
853 );
854 }
855}