1use std::collections::BTreeMap;
44
45use crate::astro::frames::orientation::EarthOrientation;
46use crate::astro::frames::transforms::FrameTransformError;
47use crate::astro::state::CartesianState;
48use crate::astro::time::model::{Instant, TimeScale};
49use crate::constants::{KM_TO_M, US_TO_S};
50use crate::id::GnssSatelliteId;
51use crate::observables::{ObservableEphemerisSource, ObservableState, ObservablesError};
52use crate::sp3::interp::{
53 instant_to_j2000_seconds, interpolate_precise_state, precise_node_j2000_seconds_from_instant,
54 PreciseSatSeries,
55};
56use crate::sp3::{Sp3, Sp3State};
57use crate::{Error, Result};
58
59#[derive(Debug, Clone, Copy, PartialEq)]
72pub struct PreciseEphemerisSample {
73 pub sat: GnssSatelliteId,
75 pub epoch: Instant,
77 pub position_ecef_m: [f64; 3],
79 pub clock_s: Option<f64>,
81 pub clock_event: bool,
85}
86
87impl PreciseEphemerisSample {
88 pub fn new(
93 sat: GnssSatelliteId,
94 epoch: Instant,
95 position_ecef_m: [f64; 3],
96 clock_s: Option<f64>,
97 ) -> Self {
98 Self {
99 sat,
100 epoch,
101 position_ecef_m,
102 clock_s,
103 clock_event: false,
104 }
105 }
106}
107
108#[derive(Debug, Clone, Copy, PartialEq)]
114pub struct PreciseEphemerisStateSample {
115 pub sat: GnssSatelliteId,
117 pub epoch: Instant,
119 pub position_ecef_m: [f64; 3],
121 pub velocity_ecef_m_s: [f64; 3],
123 pub clock_s: Option<f64>,
125 pub clock_rate_s_s: Option<f64>,
127 pub clock_event: bool,
129}
130
131impl PreciseEphemerisStateSample {
132 pub fn new(
134 sat: GnssSatelliteId,
135 epoch: Instant,
136 position_ecef_m: [f64; 3],
137 velocity_ecef_m_s: [f64; 3],
138 clock_s: Option<f64>,
139 clock_rate_s_s: Option<f64>,
140 ) -> Self {
141 Self {
142 sat,
143 epoch,
144 position_ecef_m,
145 velocity_ecef_m_s,
146 clock_s,
147 clock_rate_s_s,
148 clock_event: false,
149 }
150 }
151}
152
153pub fn sp3_ecef_state_to_eci(
159 sample: &PreciseEphemerisStateSample,
160 orientation: &EarthOrientation,
161) -> core::result::Result<CartesianState, FrameTransformError> {
162 validate_state_sample(sample)?;
163 let epoch_j2000_s = instant_to_j2000_seconds(&sample.epoch)
164 .ok_or_else(|| frame_error("epoch", "must be a split Julian date"))?;
165 if !epoch_j2000_s.is_finite() {
166 return Err(frame_error("epoch", "J2000 seconds must be finite"));
167 }
168 let position_itrf_km = [
169 sample.position_ecef_m[0] / KM_TO_M,
170 sample.position_ecef_m[1] / KM_TO_M,
171 sample.position_ecef_m[2] / KM_TO_M,
172 ];
173 let velocity_itrf_km_s = [
174 sample.velocity_ecef_m_s[0] / KM_TO_M,
175 sample.velocity_ecef_m_s[1] / KM_TO_M,
176 sample.velocity_ecef_m_s[2] / KM_TO_M,
177 ];
178 let (position_gcrf_km, velocity_gcrf_km_s) =
179 orientation.itrf_to_gcrf_state_km(position_itrf_km, velocity_itrf_km_s)?;
180 Ok(CartesianState::new(
181 epoch_j2000_s,
182 position_gcrf_km,
183 velocity_gcrf_km_s,
184 ))
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
189pub enum PreciseSamplesError {
190 Empty,
192 SingleSampleSatellite(GnssSatelliteId),
194 NonMonotonicEpochs(GnssSatelliteId),
196 MixedTimeScales,
198 EpochNotRepresentable(GnssSatelliteId),
200 NonFiniteSample(GnssSatelliteId),
202}
203
204impl core::fmt::Display for PreciseSamplesError {
205 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
206 match self {
207 Self::Empty => write!(f, "no precise-ephemeris samples supplied"),
208 Self::SingleSampleSatellite(sat) => {
209 write!(f, "satellite {sat} has a single sample; need at least two")
210 }
211 Self::NonMonotonicEpochs(sat) => {
212 write!(
213 f,
214 "satellite {sat} sample epochs are not strictly increasing"
215 )
216 }
217 Self::MixedTimeScales => write!(f, "samples carry more than one time scale"),
218 Self::EpochNotRepresentable(sat) => {
219 write!(
220 f,
221 "satellite {sat} sample epoch is not representable as J2000 seconds"
222 )
223 }
224 Self::NonFiniteSample(sat) => write!(f, "satellite {sat} has a non-finite sample"),
225 }
226 }
227}
228
229impl std::error::Error for PreciseSamplesError {}
230
231#[derive(Debug, Clone, PartialEq)]
237pub struct PreciseEphemerisSamples {
238 time_scale: TimeScale,
239 nodes: BTreeMap<GnssSatelliteId, PreciseSatSeries>,
240}
241
242impl PreciseEphemerisSamples {
243 pub fn from_samples(
259 samples: impl IntoIterator<Item = PreciseEphemerisSample>,
260 ) -> core::result::Result<Self, PreciseSamplesError> {
261 let mut time_scale: Option<TimeScale> = None;
262 let mut grouped: BTreeMap<GnssSatelliteId, PreciseSatSeries> = BTreeMap::new();
263
264 for sample in samples {
265 match time_scale {
266 None => time_scale = Some(sample.epoch.scale),
267 Some(scale) if scale == sample.epoch.scale => {}
268 Some(_) => return Err(PreciseSamplesError::MixedTimeScales),
269 }
270
271 if !sample.position_ecef_m.iter().all(|c| c.is_finite())
272 || sample.clock_s.is_some_and(|c| !c.is_finite())
273 {
274 return Err(PreciseSamplesError::NonFiniteSample(sample.sat));
275 }
276
277 let seconds = instant_to_j2000_seconds(&sample.epoch)
284 .ok_or(PreciseSamplesError::EpochNotRepresentable(sample.sat))?;
285 if !seconds.is_finite() {
286 return Err(PreciseSamplesError::EpochNotRepresentable(sample.sat));
287 }
288 let xi = precise_node_j2000_seconds_from_instant(&sample.epoch)
289 .ok_or(PreciseSamplesError::EpochNotRepresentable(sample.sat))?;
290
291 let series = grouped
295 .entry(sample.sat)
296 .or_insert_with(PreciseSatSeries::new);
297 series.x.push(xi);
298 series.kx.push(sample.position_ecef_m[0] / KM_TO_M);
299 series.ky.push(sample.position_ecef_m[1] / KM_TO_M);
300 series.kz.push(sample.position_ecef_m[2] / KM_TO_M);
301 if let Some(clock_s) = sample.clock_s {
302 let clock_us = clock_s / US_TO_S;
307 if !clock_us.is_finite() {
308 return Err(PreciseSamplesError::NonFiniteSample(sample.sat));
309 }
310 series.clk.push((xi, clock_us, sample.clock_event));
314 }
315 }
316
317 if grouped.is_empty() {
318 return Err(PreciseSamplesError::Empty);
319 }
320 for (&sat, series) in &grouped {
321 if series.x.len() < 2 {
322 return Err(PreciseSamplesError::SingleSampleSatellite(sat));
323 }
324 if series.x.windows(2).any(|w| w[1] <= w[0]) {
325 return Err(PreciseSamplesError::NonMonotonicEpochs(sat));
326 }
327 }
328
329 Ok(Self {
330 time_scale: time_scale.expect("non-empty group has a time scale"),
331 nodes: grouped,
332 })
333 }
334
335 pub fn time_scale(&self) -> TimeScale {
337 self.time_scale
338 }
339
340 pub fn satellites(&self) -> impl Iterator<Item = GnssSatelliteId> + '_ {
342 self.nodes.keys().copied()
343 }
344
345 pub(super) fn node_series(&self) -> &BTreeMap<GnssSatelliteId, PreciseSatSeries> {
346 &self.nodes
347 }
348
349 pub fn position_at_j2000_seconds(&self, sat: GnssSatelliteId, query: f64) -> Result<Sp3State> {
356 static EMPTY_F64: [f64; 0] = [];
362 static EMPTY_CLK: [(f64, f64, bool); 0] = [];
363 match self.nodes.get(&sat) {
364 Some(series) => interpolate_precise_state(
365 sat,
366 &series.x,
367 &series.kx,
368 &series.ky,
369 &series.kz,
370 &series.clk,
371 query,
372 ),
373 None => interpolate_precise_state(
374 sat, &EMPTY_F64, &EMPTY_F64, &EMPTY_F64, &EMPTY_F64, &EMPTY_CLK, query,
375 ),
376 }
377 }
378
379 pub fn position(&self, sat: GnssSatelliteId, epoch: Instant) -> Result<Sp3State> {
383 if epoch.scale != self.time_scale {
384 return Err(Error::InvalidInput(format!(
385 "precise-sample query time scale {} does not match source time scale {}",
386 epoch.scale.abbrev(),
387 self.time_scale.abbrev()
388 )));
389 }
390 let query = instant_to_j2000_seconds(&epoch).ok_or(Error::EpochOutOfRange)?;
391 self.position_at_j2000_seconds(sat, query)
392 }
393}
394
395impl ObservableEphemerisSource for PreciseEphemerisSamples {
396 fn observable_state_at_j2000_s(
397 &self,
398 sat: GnssSatelliteId,
399 t_j2000_s: f64,
400 ) -> core::result::Result<ObservableState, ObservablesError> {
401 let state = self
402 .position_at_j2000_seconds(sat, t_j2000_s)
403 .map_err(ObservablesError::Ephemeris)?;
404 Ok(ObservableState {
405 position_ecef_m: state.position.as_array(),
406 clock_s: state.clock_s,
407 })
408 }
409}
410
411impl Sp3 {
412 pub fn precise_ephemeris_samples(&self) -> Vec<PreciseEphemerisSample> {
420 let mut out = Vec::new();
421 for (idx, &epoch) in self.epochs.iter().enumerate() {
422 if let Ok(states) = self.states_at(idx) {
423 for (&sat, state) in states {
424 out.push(PreciseEphemerisSample {
425 sat,
426 epoch,
427 position_ecef_m: state.position.as_array(),
428 clock_s: state.clock_s,
429 clock_event: state.flags.clock_event,
430 });
431 }
432 }
433 }
434 out
435 }
436
437 pub fn precise_ephemeris_state_samples(&self) -> Vec<PreciseEphemerisStateSample> {
443 let mut out = Vec::new();
444 for (idx, &epoch) in self.epochs.iter().enumerate() {
445 if let Ok(states) = self.states_at(idx) {
446 for (&sat, state) in states {
447 if let Some(velocity) = state.velocity {
448 out.push(PreciseEphemerisStateSample {
449 sat,
450 epoch,
451 position_ecef_m: state.position.as_array(),
452 velocity_ecef_m_s: velocity.as_array(),
453 clock_s: state.clock_s,
454 clock_rate_s_s: state.clock_rate_s_s,
455 clock_event: state.flags.clock_event,
456 });
457 }
458 }
459 }
460 }
461 out
462 }
463}
464
465fn validate_state_sample(
466 sample: &PreciseEphemerisStateSample,
467) -> core::result::Result<(), FrameTransformError> {
468 if !sample.position_ecef_m.iter().all(|value| value.is_finite()) {
469 return Err(frame_error("position_ecef_m", "components must be finite"));
470 }
471 if !sample
472 .velocity_ecef_m_s
473 .iter()
474 .all(|value| value.is_finite())
475 {
476 return Err(frame_error(
477 "velocity_ecef_m_s",
478 "components must be finite",
479 ));
480 }
481 if sample.clock_s.is_some_and(|value| !value.is_finite()) {
482 return Err(frame_error("clock_s", "must be finite"));
483 }
484 if sample
485 .clock_rate_s_s
486 .is_some_and(|value| !value.is_finite())
487 {
488 return Err(frame_error("clock_rate_s_s", "must be finite"));
489 }
490 Ok(())
491}
492
493fn frame_error(field: &'static str, reason: &'static str) -> FrameTransformError {
494 FrameTransformError::InvalidInput { field, reason }
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500 use crate::astro::time::model::{InstantRepr, JulianDateSplit};
501 use crate::constants::SECONDS_PER_DAY;
502 use crate::GnssSystem;
503
504 const J2000_JD_WHOLE: f64 = 2_451_545.0;
505
506 fn gps(prn: u8) -> GnssSatelliteId {
507 GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
508 }
509
510 fn sample(
511 scale: TimeScale,
512 j2000_s: f64,
513 prn: u8,
514 pos: [f64; 3],
515 clk: Option<f64>,
516 ) -> PreciseEphemerisSample {
517 let split =
518 JulianDateSplit::new(J2000_JD_WHOLE, j2000_s / SECONDS_PER_DAY).expect("valid split");
519 PreciseEphemerisSample::new(
520 gps(prn),
521 Instant {
522 scale,
523 repr: InstantRepr::JulianDate(split),
524 },
525 pos,
526 clk,
527 )
528 }
529
530 #[test]
531 fn from_samples_rejects_empty() {
532 let err = PreciseEphemerisSamples::from_samples(std::iter::empty())
533 .expect_err("empty sample set must fail");
534 assert_eq!(err, PreciseSamplesError::Empty);
535 }
536
537 #[test]
538 fn from_samples_rejects_single_sample_satellite() {
539 let samples = vec![sample(
540 TimeScale::Gpst,
541 0.0,
542 21,
543 [20_000_000.0, 14_000_000.0, 21_000_000.0],
544 Some(1.0e-6),
545 )];
546 let err =
547 PreciseEphemerisSamples::from_samples(samples).expect_err("single sample must fail");
548 assert_eq!(err, PreciseSamplesError::SingleSampleSatellite(gps(21)));
549 }
550
551 #[test]
552 fn from_samples_rejects_non_monotonic_epochs() {
553 let samples = vec![
554 sample(TimeScale::Gpst, 900.0, 21, [1.0e7, 2.0e7, 3.0e7], None),
555 sample(TimeScale::Gpst, 900.0, 21, [1.0e7, 2.0e7, 3.0e7], None),
556 ];
557 let err = PreciseEphemerisSamples::from_samples(samples)
558 .expect_err("repeated epoch must fail as non-monotonic");
559 assert_eq!(err, PreciseSamplesError::NonMonotonicEpochs(gps(21)));
560
561 let descending = vec![
562 sample(TimeScale::Gpst, 1_800.0, 7, [1.0e7, 2.0e7, 3.0e7], None),
563 sample(TimeScale::Gpst, 900.0, 7, [1.1e7, 2.1e7, 3.1e7], None),
564 ];
565 let err = PreciseEphemerisSamples::from_samples(descending)
566 .expect_err("descending epochs must fail");
567 assert_eq!(err, PreciseSamplesError::NonMonotonicEpochs(gps(7)));
568 }
569
570 #[test]
571 fn from_samples_rejects_mixed_time_scales() {
572 let samples = vec![
573 sample(TimeScale::Gpst, 0.0, 21, [1.0e7, 2.0e7, 3.0e7], None),
574 sample(TimeScale::Utc, 900.0, 21, [1.0e7, 2.0e7, 3.0e7], None),
575 ];
576 let err = PreciseEphemerisSamples::from_samples(samples)
577 .expect_err("mixed time scales must fail");
578 assert_eq!(err, PreciseSamplesError::MixedTimeScales);
579 }
580
581 #[test]
582 fn from_samples_rejects_non_finite_sample() {
583 let samples = vec![
584 sample(TimeScale::Gpst, 0.0, 21, [f64::NAN, 2.0e7, 3.0e7], None),
585 sample(TimeScale::Gpst, 900.0, 21, [1.0e7, 2.0e7, 3.0e7], None),
586 ];
587 let err = PreciseEphemerisSamples::from_samples(samples).expect_err("non-finite must fail");
588 assert_eq!(err, PreciseSamplesError::NonFiniteSample(gps(21)));
589 }
590
591 #[test]
592 fn from_samples_rejects_epoch_with_non_finite_j2000_seconds() {
593 let split = JulianDateSplit::new(f64::MAX, 0.0).expect("finite split Julian date");
594 let samples = vec![PreciseEphemerisSample::new(
595 gps(21),
596 Instant {
597 scale: TimeScale::Gpst,
598 repr: InstantRepr::JulianDate(split),
599 },
600 [1.0e7, 2.0e7, 3.0e7],
601 None,
602 )];
603 let err = PreciseEphemerisSamples::from_samples(samples)
604 .expect_err("non-finite J2000 seconds must fail");
605 assert_eq!(err, PreciseSamplesError::EpochNotRepresentable(gps(21)));
606 }
607
608 #[test]
609 fn from_samples_rejects_clock_that_overflows_native_microseconds() {
610 let samples = vec![
611 sample(
612 TimeScale::Gpst,
613 0.0,
614 21,
615 [1.0e7, 2.0e7, 3.0e7],
616 Some(f64::MAX),
617 ),
618 sample(TimeScale::Gpst, 900.0, 21, [1.1e7, 2.1e7, 3.1e7], None),
619 ];
620 let err = PreciseEphemerisSamples::from_samples(samples)
621 .expect_err("overflowed native clock must fail");
622 assert_eq!(err, PreciseSamplesError::NonFiniteSample(gps(21)));
623 }
624
625 #[test]
626 fn from_samples_out_of_range_query_errors() {
627 let samples = vec![
628 sample(
629 TimeScale::Gpst,
630 0.0,
631 21,
632 [2.0e7, 1.4e7, 2.1e7],
633 Some(1.0e-6),
634 ),
635 sample(
636 TimeScale::Gpst,
637 900.0,
638 21,
639 [2.0e7, 1.4e7, 2.1e7],
640 Some(1.0e-6),
641 ),
642 ];
643 let source = PreciseEphemerisSamples::from_samples(samples).expect("valid source");
644 let err = source
647 .position_at_j2000_seconds(gps(21), 1_000_000.0)
648 .expect_err("out-of-coverage query must fail");
649 assert_eq!(err, Error::EpochOutOfRange);
650 }
651
652 #[test]
653 fn unknown_sat_with_non_finite_query_is_invalid_input() {
654 let samples = vec![
655 sample(
656 TimeScale::Gpst,
657 0.0,
658 21,
659 [2.0e7, 1.4e7, 2.1e7],
660 Some(1.0e-6),
661 ),
662 sample(
663 TimeScale::Gpst,
664 900.0,
665 21,
666 [2.0e7, 1.4e7, 2.1e7],
667 Some(1.0e-6),
668 ),
669 ];
670 let source = PreciseEphemerisSamples::from_samples(samples).expect("valid source");
671
672 let err = source
676 .position_at_j2000_seconds(gps(7), f64::NAN)
677 .expect_err("non-finite query on unknown sat must fail");
678 assert!(
679 matches!(err, Error::InvalidInput(_)),
680 "expected InvalidInput, got {err:?}"
681 );
682
683 let err = source
685 .position_at_j2000_seconds(gps(7), 0.0)
686 .expect_err("finite query on unknown sat must fail");
687 assert_eq!(err, Error::UnknownSatellite(gps(7)));
688 }
689}
690
691#[cfg(all(test, sidereon_repo_tests))]
692mod parity_tests {
693 use super::*;
694 use crate::observables::{
695 predict, predict_ranges, PredictOptions, RangePrediction, RangePredictionRequest,
696 };
697 use crate::GnssSystem;
698
699 fn gps(prn: u8) -> GnssSatelliteId {
700 GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
701 }
702
703 fn round_trip_safe_km(km: f64) -> bool {
704 (km * KM_TO_M) / KM_TO_M == km
705 }
706 fn round_trip_safe_us(us: f64) -> bool {
707 (us * US_TO_S) / US_TO_S == us
708 }
709
710 fn authored_sp3() -> Sp3 {
714 let header_src = concat!(
715 env!("CARGO_MANIFEST_DIR"),
716 "/tests/fixtures/sp3/GAP_G01_20201760000_15M.sp3"
717 );
718 let gap = std::fs::read_to_string(header_src).expect("read header fixture");
719 let epoch_start = gap.find("\n* ").expect("first epoch line") + 1;
720 let header = &gap[..epoch_start];
721
722 let xs = [
725 26_000.0, 25_990.0, 25_960.0, 25_910.0, 25_840.0, 25_750.0, 25_640.0, 25_510.0,
726 25_360.0, 25_190.0, 25_000.0, 24_790.0,
727 ];
728 let ys = [
729 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,
730 9_360.0, 10_190.0, 11_000.0,
731 ];
732 let zs = [
733 -3_000.0, -3_050.0, -3_120.0, -3_210.0, -3_320.0, -3_450.0, -3_600.0, -3_770.0,
734 -3_960.0, -4_170.0, -4_400.0, -4_650.0,
735 ];
736 let cs = [
737 100.0, 142.0, -313.0, 6_159.0, 1_234.0, -884.0, 401.0, 862.0, -606.0, 10.0, -369.0,
738 3_654.0,
739 ];
740
741 let mut text = String::from(header);
742 for i in 0..xs.len() {
743 assert!(round_trip_safe_km(xs[i]), "xs[{i}] not round-trip-safe");
744 assert!(round_trip_safe_km(ys[i]), "ys[{i}] not round-trip-safe");
745 assert!(round_trip_safe_km(zs[i]), "zs[{i}] not round-trip-safe");
746 assert!(round_trip_safe_us(cs[i]), "cs[{i}] not round-trip-safe");
747 let total_min = i * 15;
748 let hour = total_min / 60;
749 let minute = total_min % 60;
750 text.push_str(&format!("* 2020 6 24 {hour:2} {minute:2} 0.00000000\n"));
751 text.push_str(&format!(
752 "PG01{:14.6}{:14.6}{:14.6}{:14.6}\n",
753 xs[i], ys[i], zs[i], cs[i]
754 ));
755 }
756 text.push_str("EOF\n");
757 Sp3::parse(text.as_bytes()).expect("parse authored SP3")
758 }
759
760 fn authored_sp3_with_clock_event(event_idx: usize) -> Sp3 {
765 let header_src = concat!(
766 env!("CARGO_MANIFEST_DIR"),
767 "/tests/fixtures/sp3/GAP_G01_20201760000_15M.sp3"
768 );
769 let gap = std::fs::read_to_string(header_src).expect("read header fixture");
770 let epoch_start = gap.find("\n* ").expect("first epoch line") + 1;
771 let header = &gap[..epoch_start];
772
773 let xs = [
774 26_000.0, 25_990.0, 25_960.0, 25_910.0, 25_840.0, 25_750.0, 25_640.0, 25_510.0,
775 25_360.0, 25_190.0, 25_000.0, 24_790.0,
776 ];
777 let ys = [
778 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,
779 9_360.0, 10_190.0, 11_000.0,
780 ];
781 let zs = [
782 -3_000.0, -3_050.0, -3_120.0, -3_210.0, -3_320.0, -3_450.0, -3_600.0, -3_770.0,
783 -3_960.0, -4_170.0, -4_400.0, -4_650.0,
784 ];
785 let cs = [
788 100.0, 142.0, 180.0, 210.0, 235.0, 260.0, -7_500.0, -7_550.0, -7_680.0, -7_790.0,
789 -7_880.0, -7_000.0,
790 ];
791
792 let mut text = String::from(header);
793 for i in 0..xs.len() {
794 assert!(round_trip_safe_km(xs[i]), "xs[{i}] not round-trip-safe");
795 assert!(round_trip_safe_km(ys[i]), "ys[{i}] not round-trip-safe");
796 assert!(round_trip_safe_km(zs[i]), "zs[{i}] not round-trip-safe");
797 assert!(round_trip_safe_us(cs[i]), "cs[{i}] not round-trip-safe");
798 let total_min = i * 15;
799 let hour = total_min / 60;
800 let minute = total_min % 60;
801 text.push_str(&format!("* 2020 6 24 {hour:2} {minute:2} 0.00000000\n"));
802 let mut record = format!(
804 "PG01{:14.6}{:14.6}{:14.6}{:14.6}",
805 xs[i], ys[i], zs[i], cs[i]
806 );
807 if i == event_idx {
808 while record.len() < 74 {
811 record.push(' ');
812 }
813 record.push('E');
814 }
815 record.push('\n');
816 text.push_str(&record);
817 }
818 text.push_str("EOF\n");
819 let sp3 = Sp3::parse(text.as_bytes()).expect("parse authored SP3");
820 let state = sp3.state(gps(1), event_idx).expect("event-epoch state");
822 assert!(
823 state.flags.clock_event,
824 "authored E flag did not parse at epoch {event_idx}"
825 );
826 sp3
827 }
828
829 #[test]
834 fn from_samples_preserves_clock_event_arc_split() {
835 let event_idx = 6usize;
836 let sp3 = authored_sp3_with_clock_event(event_idx);
837 let extracted = sp3.precise_ephemeris_samples();
838 assert!(
840 extracted.iter().any(|s| s.sat == gps(1) && s.clock_event),
841 "extracted samples dropped the clock-event flag"
842 );
843 let samples = PreciseEphemerisSamples::from_samples(extracted).expect("source");
844
845 let epochs = sp3.epochs_j2000_seconds();
846 assert!(epochs.len() > event_idx + 1);
847
848 let mut queries = Vec::new();
850 for w in epochs.windows(2) {
851 queries.push(w[0]);
852 queries.push(0.5 * (w[0] + w[1]));
853 }
854 queries.push(*epochs.last().unwrap());
855
856 let mut saw_some_clock = false;
857 for &q in &queries {
858 let a = sp3.position_at_j2000_seconds(gps(1), q).expect("sp3 state");
859 let b = samples
860 .position_at_j2000_seconds(gps(1), q)
861 .expect("samples state");
862 assert_eq!(
863 a.clock_s.map(f64::to_bits),
864 b.clock_s.map(f64::to_bits),
865 "clock bits differ at query {q} across the reset"
866 );
867 if a.clock_s.is_some() {
868 saw_some_clock = true;
869 }
870 }
871 assert!(saw_some_clock, "expected clock estimates across the grid");
872
873 let reset_epoch = epochs[event_idx];
878 let just_after = 0.5 * (epochs[event_idx] + epochs[event_idx + 1]);
879 let clk_after = sp3
880 .position_at_j2000_seconds(gps(1), just_after)
881 .expect("state after reset")
882 .clock_s
883 .expect("clock after reset");
884 assert!(
888 clk_after < -1.0e-3,
889 "post-reset clock {clk_after:e} s is not on the post-reset sub-arc; \
890 the arc split was not applied"
891 );
892 let _ = reset_epoch;
893 }
894
895 fn assert_state_bits_eq(a: &Sp3State, b: &Sp3State) {
896 assert_eq!(
897 a.position.as_array().map(f64::to_bits),
898 b.position.as_array().map(f64::to_bits),
899 "position bits differ"
900 );
901 assert_eq!(
902 a.clock_s.map(f64::to_bits),
903 b.clock_s.map(f64::to_bits),
904 "clock bits differ"
905 );
906 }
907
908 #[test]
909 fn from_samples_is_byte_identical_to_parsed_sp3() {
910 let sp3 = authored_sp3();
911 let samples =
912 PreciseEphemerisSamples::from_samples(sp3.precise_ephemeris_samples()).expect("source");
913
914 let epochs = sp3.epochs_j2000_seconds();
915 assert!(epochs.len() >= 4);
916
917 let mut queries = Vec::new();
919 for w in epochs.windows(2) {
920 queries.push(w[0]);
921 queries.push(0.5 * (w[0] + w[1]));
922 queries.push(0.75 * w[0] + 0.25 * w[1]);
923 }
924 queries.push(*epochs.last().unwrap());
925
926 for &q in &queries {
928 let a = sp3.position_at_j2000_seconds(gps(1), q).expect("sp3 state");
929 let b = samples
930 .position_at_j2000_seconds(gps(1), q)
931 .expect("samples state");
932 assert_state_bits_eq(&a, &b);
933 }
934
935 let receivers = [
937 [4_027_894.0, 307_046.0, 4_919_474.0],
938 [1_130_000.0, -4_830_000.0, 3_994_000.0],
939 [-2_700_000.0, -4_290_000.0, 3_855_000.0],
940 ];
941 let options = PredictOptions::default();
942 for &q in &queries {
943 for rx in receivers {
944 let requests = [RangePredictionRequest {
945 sat: gps(1),
946 receiver_ecef_m: rx,
947 t_rx_j2000_s: q,
948 }];
949 let mut a = [RangePrediction {
950 geometric_range_m: 0.0,
951 sat_clock_s: None,
952 transmit_time_j2000_s: 0.0,
953 sat_pos_ecef_m: [0.0; 3],
954 }; 1];
955 let mut b = a;
956 predict_ranges(&sp3, &requests, options, &mut a).expect("sp3 ranges");
957 predict_ranges(&samples, &requests, options, &mut b).expect("sample ranges");
958 assert_eq!(
959 a[0].geometric_range_m.to_bits(),
960 b[0].geometric_range_m.to_bits()
961 );
962 assert_eq!(
963 a[0].transmit_time_j2000_s.to_bits(),
964 b[0].transmit_time_j2000_s.to_bits()
965 );
966 assert_eq!(
967 a[0].sat_clock_s.map(f64::to_bits),
968 b[0].sat_clock_s.map(f64::to_bits)
969 );
970 assert_eq!(
971 a[0].sat_pos_ecef_m.map(f64::to_bits),
972 b[0].sat_pos_ecef_m.map(f64::to_bits)
973 );
974
975 let oa = predict(&sp3, gps(1), rx, q, options).expect("sp3 predict");
977 let ob = predict(&samples, gps(1), rx, q, options).expect("samples predict");
978 assert_eq!(
979 oa.geometric_range_m.to_bits(),
980 ob.geometric_range_m.to_bits()
981 );
982 assert_eq!(oa.doppler_hz.to_bits(), ob.doppler_hz.to_bits());
983 }
984 }
985 }
986
987 #[test]
988 fn from_samples_tracks_real_fixture_to_sub_micron() {
989 let path = concat!(
995 env!("CARGO_MANIFEST_DIR"),
996 "/tests/fixtures/sp3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3"
997 );
998 let bytes = std::fs::read(path).expect("read fixture");
999 let sp3 = Sp3::parse(&bytes).expect("parse fixture");
1000 let samples =
1001 PreciseEphemerisSamples::from_samples(sp3.precise_ephemeris_samples()).expect("source");
1002
1003 let epochs = sp3.epochs_j2000_seconds();
1004 let sats: Vec<_> = sp3.satellites().to_vec();
1005 let mut compared = 0u64;
1006 let mut byte_identical = 0u64;
1007 let mut max_abs_diff_m = 0.0f64;
1008
1009 for &sat in sats.iter().take(20) {
1010 for w in epochs.windows(2) {
1011 for q in [w[0], 0.5 * (w[0] + w[1])] {
1012 let (Ok(a), Ok(b)) = (
1013 sp3.position_at_j2000_seconds(sat, q),
1014 samples.position_at_j2000_seconds(sat, q),
1015 ) else {
1016 continue;
1017 };
1018 let pa = a.position.as_array();
1019 let pb = b.position.as_array();
1020 for k in 0..3 {
1021 compared += 1;
1022 if pa[k].to_bits() == pb[k].to_bits() {
1023 byte_identical += 1;
1024 }
1025 max_abs_diff_m = max_abs_diff_m.max((pa[k] - pb[k]).abs());
1026 }
1027 }
1028 }
1029 }
1030
1031 assert!(compared > 0);
1032 assert!(
1033 max_abs_diff_m < 1.0e-6,
1034 "max divergence {max_abs_diff_m:e} m exceeds sub-micron bound"
1035 );
1036 assert!(
1037 byte_identical * 100 >= compared * 90,
1038 "expected the vast majority byte-identical, got {byte_identical}/{compared}"
1039 );
1040 }
1041}