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, PreciseSatSeries};
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)]
152pub struct PreciseEphemerisSamples {
153 time_scale: TimeScale,
154 nodes: BTreeMap<GnssSatelliteId, PreciseSatSeries>,
155}
156
157impl PreciseEphemerisSamples {
158 pub fn from_samples(
170 samples: impl IntoIterator<Item = PreciseEphemerisSample>,
171 ) -> core::result::Result<Self, PreciseSamplesError> {
172 let mut time_scale: Option<TimeScale> = None;
173 let mut grouped: BTreeMap<GnssSatelliteId, PreciseSatSeries> = BTreeMap::new();
174
175 for sample in samples {
176 match time_scale {
177 None => time_scale = Some(sample.epoch.scale),
178 Some(scale) if scale == sample.epoch.scale => {}
179 Some(_) => return Err(PreciseSamplesError::MixedTimeScales),
180 }
181
182 if !sample.position_ecef_m.iter().all(|c| c.is_finite())
183 || sample.clock_s.is_some_and(|c| !c.is_finite())
184 {
185 return Err(PreciseSamplesError::NonFiniteSample(sample.sat));
186 }
187
188 let seconds = instant_to_j2000_seconds(&sample.epoch)
195 .ok_or(PreciseSamplesError::EpochNotRepresentable(sample.sat))?;
196 if !seconds.is_finite() {
197 return Err(PreciseSamplesError::EpochNotRepresentable(sample.sat));
198 }
199 let xi = seconds.floor();
200
201 let series = grouped
205 .entry(sample.sat)
206 .or_insert_with(PreciseSatSeries::new);
207 series.x.push(xi);
208 series.kx.push(sample.position_ecef_m[0] / KM_TO_M);
209 series.ky.push(sample.position_ecef_m[1] / KM_TO_M);
210 series.kz.push(sample.position_ecef_m[2] / KM_TO_M);
211 if let Some(clock_s) = sample.clock_s {
212 let clock_us = clock_s / US_TO_S;
217 if !clock_us.is_finite() {
218 return Err(PreciseSamplesError::NonFiniteSample(sample.sat));
219 }
220 series.clk.push((xi, clock_us, sample.clock_event));
224 }
225 }
226
227 if grouped.is_empty() {
228 return Err(PreciseSamplesError::Empty);
229 }
230 for (&sat, series) in &grouped {
231 if series.x.len() < 2 {
232 return Err(PreciseSamplesError::SingleSampleSatellite(sat));
233 }
234 if series.x.windows(2).any(|w| w[1] <= w[0]) {
235 return Err(PreciseSamplesError::NonMonotonicEpochs(sat));
236 }
237 }
238
239 Ok(Self {
240 time_scale: time_scale.expect("non-empty group has a time scale"),
241 nodes: grouped,
242 })
243 }
244
245 pub fn time_scale(&self) -> TimeScale {
247 self.time_scale
248 }
249
250 pub fn satellites(&self) -> impl Iterator<Item = GnssSatelliteId> + '_ {
252 self.nodes.keys().copied()
253 }
254
255 pub(super) fn node_series(&self) -> &BTreeMap<GnssSatelliteId, PreciseSatSeries> {
256 &self.nodes
257 }
258
259 pub fn position_at_j2000_seconds(&self, sat: GnssSatelliteId, query: f64) -> Result<Sp3State> {
266 static EMPTY_F64: [f64; 0] = [];
272 static EMPTY_CLK: [(f64, f64, bool); 0] = [];
273 match self.nodes.get(&sat) {
274 Some(series) => interpolate_precise_state(
275 sat,
276 &series.x,
277 &series.kx,
278 &series.ky,
279 &series.kz,
280 &series.clk,
281 query,
282 ),
283 None => interpolate_precise_state(
284 sat, &EMPTY_F64, &EMPTY_F64, &EMPTY_F64, &EMPTY_F64, &EMPTY_CLK, query,
285 ),
286 }
287 }
288
289 pub fn position(&self, sat: GnssSatelliteId, epoch: Instant) -> Result<Sp3State> {
293 if epoch.scale != self.time_scale {
294 return Err(Error::InvalidInput(format!(
295 "precise-sample query time scale {} does not match source time scale {}",
296 epoch.scale.abbrev(),
297 self.time_scale.abbrev()
298 )));
299 }
300 let query = instant_to_j2000_seconds(&epoch).ok_or(Error::EpochOutOfRange)?;
301 self.position_at_j2000_seconds(sat, query)
302 }
303}
304
305impl ObservableEphemerisSource for PreciseEphemerisSamples {
306 fn observable_state_at_j2000_s(
307 &self,
308 sat: GnssSatelliteId,
309 t_j2000_s: f64,
310 ) -> core::result::Result<ObservableState, ObservablesError> {
311 let state = self
312 .position_at_j2000_seconds(sat, t_j2000_s)
313 .map_err(ObservablesError::Ephemeris)?;
314 Ok(ObservableState {
315 position_ecef_m: state.position.as_array(),
316 clock_s: state.clock_s,
317 })
318 }
319}
320
321impl Sp3 {
322 pub fn precise_ephemeris_samples(&self) -> Vec<PreciseEphemerisSample> {
330 let mut out = Vec::new();
331 for (idx, &epoch) in self.epochs.iter().enumerate() {
332 if let Ok(states) = self.states_at(idx) {
333 for (&sat, state) in states {
334 out.push(PreciseEphemerisSample {
335 sat,
336 epoch,
337 position_ecef_m: state.position.as_array(),
338 clock_s: state.clock_s,
339 clock_event: state.flags.clock_event,
340 });
341 }
342 }
343 }
344 out
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use crate::astro::time::model::{InstantRepr, JulianDateSplit};
352 use crate::constants::SECONDS_PER_DAY;
353 use crate::GnssSystem;
354
355 const J2000_JD_WHOLE: f64 = 2_451_545.0;
356
357 fn gps(prn: u8) -> GnssSatelliteId {
358 GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
359 }
360
361 fn sample(
362 scale: TimeScale,
363 j2000_s: f64,
364 prn: u8,
365 pos: [f64; 3],
366 clk: Option<f64>,
367 ) -> PreciseEphemerisSample {
368 let split =
369 JulianDateSplit::new(J2000_JD_WHOLE, j2000_s / SECONDS_PER_DAY).expect("valid split");
370 PreciseEphemerisSample::new(
371 gps(prn),
372 Instant {
373 scale,
374 repr: InstantRepr::JulianDate(split),
375 },
376 pos,
377 clk,
378 )
379 }
380
381 #[test]
382 fn from_samples_rejects_empty() {
383 let err = PreciseEphemerisSamples::from_samples(std::iter::empty())
384 .expect_err("empty sample set must fail");
385 assert_eq!(err, PreciseSamplesError::Empty);
386 }
387
388 #[test]
389 fn from_samples_rejects_single_sample_satellite() {
390 let samples = vec![sample(
391 TimeScale::Gpst,
392 0.0,
393 21,
394 [20_000_000.0, 14_000_000.0, 21_000_000.0],
395 Some(1.0e-6),
396 )];
397 let err =
398 PreciseEphemerisSamples::from_samples(samples).expect_err("single sample must fail");
399 assert_eq!(err, PreciseSamplesError::SingleSampleSatellite(gps(21)));
400 }
401
402 #[test]
403 fn from_samples_rejects_non_monotonic_epochs() {
404 let samples = vec![
405 sample(TimeScale::Gpst, 900.0, 21, [1.0e7, 2.0e7, 3.0e7], None),
406 sample(TimeScale::Gpst, 900.0, 21, [1.0e7, 2.0e7, 3.0e7], None),
407 ];
408 let err = PreciseEphemerisSamples::from_samples(samples)
409 .expect_err("repeated epoch must fail as non-monotonic");
410 assert_eq!(err, PreciseSamplesError::NonMonotonicEpochs(gps(21)));
411
412 let descending = vec![
413 sample(TimeScale::Gpst, 1_800.0, 7, [1.0e7, 2.0e7, 3.0e7], None),
414 sample(TimeScale::Gpst, 900.0, 7, [1.1e7, 2.1e7, 3.1e7], None),
415 ];
416 let err = PreciseEphemerisSamples::from_samples(descending)
417 .expect_err("descending epochs must fail");
418 assert_eq!(err, PreciseSamplesError::NonMonotonicEpochs(gps(7)));
419 }
420
421 #[test]
422 fn from_samples_rejects_mixed_time_scales() {
423 let samples = vec![
424 sample(TimeScale::Gpst, 0.0, 21, [1.0e7, 2.0e7, 3.0e7], None),
425 sample(TimeScale::Utc, 900.0, 21, [1.0e7, 2.0e7, 3.0e7], None),
426 ];
427 let err = PreciseEphemerisSamples::from_samples(samples)
428 .expect_err("mixed time scales must fail");
429 assert_eq!(err, PreciseSamplesError::MixedTimeScales);
430 }
431
432 #[test]
433 fn from_samples_rejects_non_finite_sample() {
434 let samples = vec![
435 sample(TimeScale::Gpst, 0.0, 21, [f64::NAN, 2.0e7, 3.0e7], None),
436 sample(TimeScale::Gpst, 900.0, 21, [1.0e7, 2.0e7, 3.0e7], None),
437 ];
438 let err = PreciseEphemerisSamples::from_samples(samples).expect_err("non-finite must fail");
439 assert_eq!(err, PreciseSamplesError::NonFiniteSample(gps(21)));
440 }
441
442 #[test]
443 fn from_samples_rejects_epoch_with_non_finite_j2000_seconds() {
444 let split = JulianDateSplit::new(f64::MAX, 0.0).expect("finite split Julian date");
445 let samples = vec![PreciseEphemerisSample::new(
446 gps(21),
447 Instant {
448 scale: TimeScale::Gpst,
449 repr: InstantRepr::JulianDate(split),
450 },
451 [1.0e7, 2.0e7, 3.0e7],
452 None,
453 )];
454 let err = PreciseEphemerisSamples::from_samples(samples)
455 .expect_err("non-finite J2000 seconds must fail");
456 assert_eq!(err, PreciseSamplesError::EpochNotRepresentable(gps(21)));
457 }
458
459 #[test]
460 fn from_samples_rejects_clock_that_overflows_native_microseconds() {
461 let samples = vec![
462 sample(
463 TimeScale::Gpst,
464 0.0,
465 21,
466 [1.0e7, 2.0e7, 3.0e7],
467 Some(f64::MAX),
468 ),
469 sample(TimeScale::Gpst, 900.0, 21, [1.1e7, 2.1e7, 3.1e7], None),
470 ];
471 let err = PreciseEphemerisSamples::from_samples(samples)
472 .expect_err("overflowed native clock must fail");
473 assert_eq!(err, PreciseSamplesError::NonFiniteSample(gps(21)));
474 }
475
476 #[test]
477 fn from_samples_out_of_range_query_errors() {
478 let samples = vec![
479 sample(
480 TimeScale::Gpst,
481 0.0,
482 21,
483 [2.0e7, 1.4e7, 2.1e7],
484 Some(1.0e-6),
485 ),
486 sample(
487 TimeScale::Gpst,
488 900.0,
489 21,
490 [2.0e7, 1.4e7, 2.1e7],
491 Some(1.0e-6),
492 ),
493 ];
494 let source = PreciseEphemerisSamples::from_samples(samples).expect("valid source");
495 let err = source
498 .position_at_j2000_seconds(gps(21), 1_000_000.0)
499 .expect_err("out-of-coverage query must fail");
500 assert_eq!(err, Error::EpochOutOfRange);
501 }
502
503 #[test]
504 fn unknown_sat_with_non_finite_query_is_invalid_input() {
505 let samples = vec![
506 sample(
507 TimeScale::Gpst,
508 0.0,
509 21,
510 [2.0e7, 1.4e7, 2.1e7],
511 Some(1.0e-6),
512 ),
513 sample(
514 TimeScale::Gpst,
515 900.0,
516 21,
517 [2.0e7, 1.4e7, 2.1e7],
518 Some(1.0e-6),
519 ),
520 ];
521 let source = PreciseEphemerisSamples::from_samples(samples).expect("valid source");
522
523 let err = source
527 .position_at_j2000_seconds(gps(7), f64::NAN)
528 .expect_err("non-finite query on unknown sat must fail");
529 assert!(
530 matches!(err, Error::InvalidInput(_)),
531 "expected InvalidInput, got {err:?}"
532 );
533
534 let err = source
536 .position_at_j2000_seconds(gps(7), 0.0)
537 .expect_err("finite query on unknown sat must fail");
538 assert_eq!(err, Error::UnknownSatellite(gps(7)));
539 }
540}
541
542#[cfg(all(test, sidereon_repo_tests))]
543mod parity_tests {
544 use super::*;
545 use crate::observables::{
546 predict, predict_ranges, PredictOptions, RangePrediction, RangePredictionRequest,
547 };
548 use crate::GnssSystem;
549
550 fn gps(prn: u8) -> GnssSatelliteId {
551 GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
552 }
553
554 fn round_trip_safe_km(km: f64) -> bool {
555 (km * KM_TO_M) / KM_TO_M == km
556 }
557 fn round_trip_safe_us(us: f64) -> bool {
558 (us * US_TO_S) / US_TO_S == us
559 }
560
561 fn authored_sp3() -> Sp3 {
565 let header_src = concat!(
566 env!("CARGO_MANIFEST_DIR"),
567 "/tests/fixtures/sp3/GAP_G01_20201760000_15M.sp3"
568 );
569 let gap = std::fs::read_to_string(header_src).expect("read header fixture");
570 let epoch_start = gap.find("\n* ").expect("first epoch line") + 1;
571 let header = &gap[..epoch_start];
572
573 let xs = [
576 26_000.0, 25_990.0, 25_960.0, 25_910.0, 25_840.0, 25_750.0, 25_640.0, 25_510.0,
577 25_360.0, 25_190.0, 25_000.0, 24_790.0,
578 ];
579 let ys = [
580 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,
581 9_360.0, 10_190.0, 11_000.0,
582 ];
583 let zs = [
584 -3_000.0, -3_050.0, -3_120.0, -3_210.0, -3_320.0, -3_450.0, -3_600.0, -3_770.0,
585 -3_960.0, -4_170.0, -4_400.0, -4_650.0,
586 ];
587 let cs = [
588 100.0, 142.0, -313.0, 6_159.0, 1_234.0, -884.0, 401.0, 862.0, -606.0, 10.0, -369.0,
589 3_654.0,
590 ];
591
592 let mut text = String::from(header);
593 for i in 0..xs.len() {
594 assert!(round_trip_safe_km(xs[i]), "xs[{i}] not round-trip-safe");
595 assert!(round_trip_safe_km(ys[i]), "ys[{i}] not round-trip-safe");
596 assert!(round_trip_safe_km(zs[i]), "zs[{i}] not round-trip-safe");
597 assert!(round_trip_safe_us(cs[i]), "cs[{i}] not round-trip-safe");
598 let total_min = i * 15;
599 let hour = total_min / 60;
600 let minute = total_min % 60;
601 text.push_str(&format!("* 2020 6 24 {hour:2} {minute:2} 0.00000000\n"));
602 text.push_str(&format!(
603 "PG01{:14.6}{:14.6}{:14.6}{:14.6}\n",
604 xs[i], ys[i], zs[i], cs[i]
605 ));
606 }
607 text.push_str("EOF\n");
608 Sp3::parse(text.as_bytes()).expect("parse authored SP3")
609 }
610
611 fn authored_sp3_with_clock_event(event_idx: usize) -> Sp3 {
616 let header_src = concat!(
617 env!("CARGO_MANIFEST_DIR"),
618 "/tests/fixtures/sp3/GAP_G01_20201760000_15M.sp3"
619 );
620 let gap = std::fs::read_to_string(header_src).expect("read header fixture");
621 let epoch_start = gap.find("\n* ").expect("first epoch line") + 1;
622 let header = &gap[..epoch_start];
623
624 let xs = [
625 26_000.0, 25_990.0, 25_960.0, 25_910.0, 25_840.0, 25_750.0, 25_640.0, 25_510.0,
626 25_360.0, 25_190.0, 25_000.0, 24_790.0,
627 ];
628 let ys = [
629 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,
630 9_360.0, 10_190.0, 11_000.0,
631 ];
632 let zs = [
633 -3_000.0, -3_050.0, -3_120.0, -3_210.0, -3_320.0, -3_450.0, -3_600.0, -3_770.0,
634 -3_960.0, -4_170.0, -4_400.0, -4_650.0,
635 ];
636 let cs = [
639 100.0, 142.0, 180.0, 210.0, 235.0, 260.0, -7_500.0, -7_550.0, -7_680.0, -7_790.0,
640 -7_880.0, -7_000.0,
641 ];
642
643 let mut text = String::from(header);
644 for i in 0..xs.len() {
645 assert!(round_trip_safe_km(xs[i]), "xs[{i}] not round-trip-safe");
646 assert!(round_trip_safe_km(ys[i]), "ys[{i}] not round-trip-safe");
647 assert!(round_trip_safe_km(zs[i]), "zs[{i}] not round-trip-safe");
648 assert!(round_trip_safe_us(cs[i]), "cs[{i}] not round-trip-safe");
649 let total_min = i * 15;
650 let hour = total_min / 60;
651 let minute = total_min % 60;
652 text.push_str(&format!("* 2020 6 24 {hour:2} {minute:2} 0.00000000\n"));
653 let mut record = format!(
655 "PG01{:14.6}{:14.6}{:14.6}{:14.6}",
656 xs[i], ys[i], zs[i], cs[i]
657 );
658 if i == event_idx {
659 while record.len() < 74 {
662 record.push(' ');
663 }
664 record.push('E');
665 }
666 record.push('\n');
667 text.push_str(&record);
668 }
669 text.push_str("EOF\n");
670 let sp3 = Sp3::parse(text.as_bytes()).expect("parse authored SP3");
671 let state = sp3.state(gps(1), event_idx).expect("event-epoch state");
673 assert!(
674 state.flags.clock_event,
675 "authored E flag did not parse at epoch {event_idx}"
676 );
677 sp3
678 }
679
680 #[test]
685 fn from_samples_preserves_clock_event_arc_split() {
686 let event_idx = 6usize;
687 let sp3 = authored_sp3_with_clock_event(event_idx);
688 let extracted = sp3.precise_ephemeris_samples();
689 assert!(
691 extracted.iter().any(|s| s.sat == gps(1) && s.clock_event),
692 "extracted samples dropped the clock-event flag"
693 );
694 let samples = PreciseEphemerisSamples::from_samples(extracted).expect("source");
695
696 let epochs = sp3.epochs_j2000_seconds();
697 assert!(epochs.len() > event_idx + 1);
698
699 let mut queries = Vec::new();
701 for w in epochs.windows(2) {
702 queries.push(w[0]);
703 queries.push(0.5 * (w[0] + w[1]));
704 }
705 queries.push(*epochs.last().unwrap());
706
707 let mut saw_some_clock = false;
708 for &q in &queries {
709 let a = sp3.position_at_j2000_seconds(gps(1), q).expect("sp3 state");
710 let b = samples
711 .position_at_j2000_seconds(gps(1), q)
712 .expect("samples state");
713 assert_eq!(
714 a.clock_s.map(f64::to_bits),
715 b.clock_s.map(f64::to_bits),
716 "clock bits differ at query {q} across the reset"
717 );
718 if a.clock_s.is_some() {
719 saw_some_clock = true;
720 }
721 }
722 assert!(saw_some_clock, "expected clock estimates across the grid");
723
724 let reset_epoch = epochs[event_idx];
729 let just_after = 0.5 * (epochs[event_idx] + epochs[event_idx + 1]);
730 let clk_after = sp3
731 .position_at_j2000_seconds(gps(1), just_after)
732 .expect("state after reset")
733 .clock_s
734 .expect("clock after reset");
735 assert!(
739 clk_after < -1.0e-3,
740 "post-reset clock {clk_after:e} s is not on the post-reset sub-arc; \
741 the arc split was not applied"
742 );
743 let _ = reset_epoch;
744 }
745
746 fn assert_state_bits_eq(a: &Sp3State, b: &Sp3State) {
747 assert_eq!(
748 a.position.as_array().map(f64::to_bits),
749 b.position.as_array().map(f64::to_bits),
750 "position bits differ"
751 );
752 assert_eq!(
753 a.clock_s.map(f64::to_bits),
754 b.clock_s.map(f64::to_bits),
755 "clock bits differ"
756 );
757 }
758
759 #[test]
760 fn from_samples_is_byte_identical_to_parsed_sp3() {
761 let sp3 = authored_sp3();
762 let samples =
763 PreciseEphemerisSamples::from_samples(sp3.precise_ephemeris_samples()).expect("source");
764
765 let epochs = sp3.epochs_j2000_seconds();
766 assert!(epochs.len() >= 4);
767
768 let mut queries = Vec::new();
770 for w in epochs.windows(2) {
771 queries.push(w[0]);
772 queries.push(0.5 * (w[0] + w[1]));
773 queries.push(0.75 * w[0] + 0.25 * w[1]);
774 }
775 queries.push(*epochs.last().unwrap());
776
777 for &q in &queries {
779 let a = sp3.position_at_j2000_seconds(gps(1), q).expect("sp3 state");
780 let b = samples
781 .position_at_j2000_seconds(gps(1), q)
782 .expect("samples state");
783 assert_state_bits_eq(&a, &b);
784 }
785
786 let receivers = [
788 [4_027_894.0, 307_046.0, 4_919_474.0],
789 [1_130_000.0, -4_830_000.0, 3_994_000.0],
790 [-2_700_000.0, -4_290_000.0, 3_855_000.0],
791 ];
792 let options = PredictOptions::default();
793 for &q in &queries {
794 for rx in receivers {
795 let requests = [RangePredictionRequest {
796 sat: gps(1),
797 receiver_ecef_m: rx,
798 t_rx_j2000_s: q,
799 }];
800 let mut a = [RangePrediction {
801 geometric_range_m: 0.0,
802 sat_clock_s: None,
803 transmit_time_j2000_s: 0.0,
804 sat_pos_ecef_m: [0.0; 3],
805 }; 1];
806 let mut b = a;
807 predict_ranges(&sp3, &requests, options, &mut a).expect("sp3 ranges");
808 predict_ranges(&samples, &requests, options, &mut b).expect("sample ranges");
809 assert_eq!(
810 a[0].geometric_range_m.to_bits(),
811 b[0].geometric_range_m.to_bits()
812 );
813 assert_eq!(
814 a[0].transmit_time_j2000_s.to_bits(),
815 b[0].transmit_time_j2000_s.to_bits()
816 );
817 assert_eq!(
818 a[0].sat_clock_s.map(f64::to_bits),
819 b[0].sat_clock_s.map(f64::to_bits)
820 );
821 assert_eq!(
822 a[0].sat_pos_ecef_m.map(f64::to_bits),
823 b[0].sat_pos_ecef_m.map(f64::to_bits)
824 );
825
826 let oa = predict(&sp3, gps(1), rx, q, options).expect("sp3 predict");
828 let ob = predict(&samples, gps(1), rx, q, options).expect("samples predict");
829 assert_eq!(
830 oa.geometric_range_m.to_bits(),
831 ob.geometric_range_m.to_bits()
832 );
833 assert_eq!(oa.doppler_hz.to_bits(), ob.doppler_hz.to_bits());
834 }
835 }
836 }
837
838 #[test]
839 fn from_samples_tracks_real_fixture_to_sub_micron() {
840 let path = concat!(
846 env!("CARGO_MANIFEST_DIR"),
847 "/tests/fixtures/sp3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3"
848 );
849 let bytes = std::fs::read(path).expect("read fixture");
850 let sp3 = Sp3::parse(&bytes).expect("parse fixture");
851 let samples =
852 PreciseEphemerisSamples::from_samples(sp3.precise_ephemeris_samples()).expect("source");
853
854 let epochs = sp3.epochs_j2000_seconds();
855 let sats: Vec<_> = sp3.satellites().to_vec();
856 let mut compared = 0u64;
857 let mut byte_identical = 0u64;
858 let mut max_abs_diff_m = 0.0f64;
859
860 for &sat in sats.iter().take(20) {
861 for w in epochs.windows(2) {
862 for q in [w[0], 0.5 * (w[0] + w[1])] {
863 let (Ok(a), Ok(b)) = (
864 sp3.position_at_j2000_seconds(sat, q),
865 samples.position_at_j2000_seconds(sat, q),
866 ) else {
867 continue;
868 };
869 let pa = a.position.as_array();
870 let pb = b.position.as_array();
871 for k in 0..3 {
872 compared += 1;
873 if pa[k].to_bits() == pb[k].to_bits() {
874 byte_identical += 1;
875 }
876 max_abs_diff_m = max_abs_diff_m.max((pa[k] - pb[k]).abs());
877 }
878 }
879 }
880 }
881
882 assert!(compared > 0);
883 assert!(
884 max_abs_diff_m < 1.0e-6,
885 "max divergence {max_abs_diff_m:e} m exceeds sub-micron bound"
886 );
887 assert!(
888 byte_identical * 100 >= compared * 90,
889 "expected the vast majority byte-identical, got {byte_identical}/{compared}"
890 );
891 }
892}