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 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 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 let clock_us = clock_s / US_TO_S;
233 if !clock_us.is_finite() {
234 return Err(PreciseSamplesError::NonFiniteSample(sample.sat));
235 }
236 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 pub fn time_scale(&self) -> TimeScale {
263 self.time_scale
264 }
265
266 pub fn satellites(&self) -> impl Iterator<Item = GnssSatelliteId> + '_ {
268 self.nodes.keys().copied()
269 }
270
271 pub fn position_at_j2000_seconds(&self, sat: GnssSatelliteId, query: f64) -> Result<Sp3State> {
278 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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}