1use std::collections::{BTreeMap, BTreeSet};
8use std::sync::Arc;
9
10use crate::antex::{Antex, AntexDateTime};
11use crate::astro::bodies::sun_moon_ecef;
12use crate::astro::math::vec3::add3;
13use crate::astro::time::civil::civil_from_j2000_seconds;
14use crate::astro::time::model::{GnssWeekTow, TimeScale};
15use crate::astro::time::scales::TimeScales;
16use crate::broadcast::satellite_state_unchecked;
17use crate::constants::{C_M_S, GPS_EPOCH_TO_J2000_S, SECONDS_PER_HOUR, SECONDS_PER_WEEK};
18use crate::ephemeris::{BroadcastEphemeris, BroadcastIssue, NavMessage};
19use crate::error::{Error, Result};
20use crate::id::{GnssSatelliteId, GnssSystem};
21use crate::observables::{ObservableEphemerisSource, ObservableState, ObservablesError};
22use crate::ppp_corrections::satellite_body_pco_to_ecef;
23use crate::rinex_nav::is_beidou_geo;
24use crate::rtcm::{Message, SsrKind, SsrMessage};
25use crate::spp::EphemerisSource;
26use crate::staleness::StalenessPolicy;
27
28const DEFAULT_SSR_STALENESS_S: f64 = 90.0;
29const FD_HALF_S: f64 = 0.5;
30const RTCM_SSR_RADIAL_CLOCK_SCALE_M: f64 = 1.0e-4;
32const RTCM_SSR_ALONG_CROSS_SCALE_M: f64 = 4.0e-4;
34const RTCM_SSR_RADIAL_CLOCK_RATE_SCALE_M_S: f64 = 1.0e-6;
36const RTCM_SSR_ALONG_CROSS_RATE_SCALE_M_S: f64 = 4.0e-6;
38const RTCM_SSR_CLOCK_ACCEL_SCALE_M_S2: f64 = 2.0e-8;
40
41#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
43pub enum SsrSource {
44 RtcmSsr,
46 GalileoHas,
48}
49
50#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
52pub enum OrbitBasis {
53 VelocityAligned,
55}
56
57#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
72pub enum SsrReferencePoint {
73 AntennaPhaseCenter,
75 CenterOfMass,
77}
78
79impl SsrReferencePoint {
80 pub const fn rtcm_ssr_default() -> Self {
82 Self::AntennaPhaseCenter
83 }
84
85 pub const fn igs_ssr_default() -> Self {
87 Self::CenterOfMass
88 }
89
90 pub const fn tag(self) -> u8 {
92 match self {
93 Self::AntennaPhaseCenter => 0,
94 Self::CenterOfMass => 1,
95 }
96 }
97
98 pub const fn from_tag(tag: u8) -> Option<Self> {
100 match tag {
101 0 => Some(Self::AntennaPhaseCenter),
102 1 => Some(Self::CenterOfMass),
103 _ => None,
104 }
105 }
106}
107
108pub type OrbitReferencePoint = SsrReferencePoint;
110
111#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
113pub enum SsrSatelliteAttitude {
114 #[default]
116 Unavailable,
117 NominalSunFixed,
123}
124
125#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
127pub struct SsrSolution {
128 pub source: SsrSource,
130 pub provider_id: u16,
132 pub solution_id: u8,
134}
135
136#[derive(Clone, Copy, Debug, PartialEq)]
138pub struct SsrOrbitCorrection {
139 pub solution: SsrSolution,
141 pub iode: u32,
143 pub iod_ssr: u8,
145 pub basis: OrbitBasis,
147 pub crs_regional: bool,
149 pub reference_point: SsrReferencePoint,
151 pub radial_m: f64,
153 pub along_m: f64,
155 pub cross_m: f64,
157 pub radial_rate_m_s: f64,
159 pub along_rate_m_s: f64,
161 pub cross_rate_m_s: f64,
163 pub ref_epoch_j2000_s: f64,
165 pub update_interval_s: f64,
167}
168
169#[derive(Clone, Copy, Debug, PartialEq)]
171pub struct SsrHighRateClock {
172 pub solution: SsrSolution,
174 pub iod_ssr: u8,
176 pub c0_m: f64,
178 pub ref_epoch_j2000_s: f64,
180 pub update_interval_s: f64,
182}
183
184#[derive(Clone, Copy, Debug, PartialEq)]
186pub struct SsrClockCorrection {
187 pub solution: SsrSolution,
189 pub iod_ssr: u8,
191 pub c0_m: f64,
193 pub c1_m_s: f64,
195 pub c2_m_s2: f64,
197 pub ref_epoch_j2000_s: f64,
199 pub update_interval_s: f64,
201 pub high_rate: Option<SsrHighRateClock>,
203}
204
205#[derive(Clone, Debug, Default, PartialEq)]
207pub struct SsrCodeBias {
208 biases_m: BTreeMap<u8, f64>,
209}
210
211#[derive(Clone, Debug, Default, PartialEq)]
213pub struct SsrPhaseBias {
214 biases_m: BTreeMap<u8, f64>,
215}
216
217#[derive(Clone, Debug, Default, PartialEq)]
218struct SatCorrections {
219 orbit: Option<SsrOrbitCorrection>,
220 clock: Option<SsrClockCorrection>,
221 pending_high_rate: Option<SsrHighRateClock>,
222 ura_index: Option<u8>,
223 code_bias: SsrCodeBias,
224 phase_bias: SsrPhaseBias,
225}
226
227#[derive(Clone, Debug, PartialEq)]
229pub struct SsrCorrectionStore {
230 corrections: BTreeMap<GnssSatelliteId, SatCorrections>,
231 reference_point: SsrReferencePoint,
232 staleness: StalenessPolicy,
233}
234
235impl Default for SsrCorrectionStore {
236 fn default() -> Self {
237 Self::new()
238 }
239}
240
241impl SsrCorrectionStore {
242 pub fn new() -> Self {
244 Self {
245 corrections: BTreeMap::new(),
246 reference_point: SsrReferencePoint::rtcm_ssr_default(),
247 staleness: StalenessPolicy::seconds(DEFAULT_SSR_STALENESS_S),
248 }
249 }
250
251 pub fn with_reference_point(mut self, reference_point: SsrReferencePoint) -> Self {
253 self.reference_point = reference_point;
254 self
255 }
256
257 pub fn reference_point(&self) -> SsrReferencePoint {
259 self.reference_point
260 }
261
262 pub fn with_staleness(mut self, policy: StalenessPolicy) -> Self {
264 self.staleness = policy;
265 self
266 }
267
268 pub fn staleness(&self) -> StalenessPolicy {
270 self.staleness
271 }
272
273 pub fn ingest(&mut self, message: &Message, week: GnssWeekTow) -> Result<()> {
275 if let Message::Ssr(ssr) = message {
276 self.ingest_ssr(ssr, week)?;
277 }
278 Ok(())
279 }
280
281 pub fn ingest_ssr(&mut self, message: &SsrMessage, week: GnssWeekTow) -> Result<()> {
283 let update_interval_s = update_interval_s(message.header.update_interval);
284 let ref_epoch_j2000_s = ssr_epoch_j2000_s(
285 message.system,
286 week,
287 message.header.epoch_time_s,
288 message.header.update_interval,
289 update_interval_s,
290 )?;
291 let solution = SsrSolution {
292 source: SsrSource::RtcmSsr,
293 provider_id: message.header.provider_id,
294 solution_id: message.header.solution_id,
295 };
296
297 match message.kind {
298 SsrKind::Orbit => {
299 for record in &message.orbit {
300 let sat = ssr_satellite(message.system, record.satellite_id)?;
301 let orbit = orbit_from_rtcm(
302 self.reference_point,
303 message,
304 solution,
305 record,
306 ref_epoch_j2000_s,
307 update_interval_s,
308 );
309 let entry = self.corrections.entry(sat).or_default();
310 entry.orbit = Some(orbit);
311 }
312 }
313 SsrKind::Clock => {
314 for record in &message.clock {
315 let sat = ssr_satellite(message.system, record.satellite_id)?;
316 let entry = self.corrections.entry(sat).or_default();
317 let mut clock = SsrClockCorrection {
318 solution,
319 iod_ssr: message.header.iod_ssr,
320 c0_m: f64::from(record.c0) * RTCM_SSR_RADIAL_CLOCK_SCALE_M,
321 c1_m_s: f64::from(record.c1) * RTCM_SSR_RADIAL_CLOCK_RATE_SCALE_M_S,
322 c2_m_s2: f64::from(record.c2) * RTCM_SSR_CLOCK_ACCEL_SCALE_M_S2,
323 ref_epoch_j2000_s,
324 update_interval_s,
325 high_rate: None,
326 };
327 if let Some(hr) = entry.pending_high_rate {
328 if high_rate_matches(&clock, &hr) {
329 clock.high_rate = Some(hr);
330 }
331 }
332 entry.clock = Some(clock);
333 }
334 }
335 SsrKind::CombinedOrbitClock => {
336 for (orbit_record, clock_record) in message.orbit.iter().zip(&message.clock) {
337 let sat = ssr_satellite(message.system, orbit_record.satellite_id)?;
338 let orbit = orbit_from_rtcm(
339 self.reference_point,
340 message,
341 solution,
342 orbit_record,
343 ref_epoch_j2000_s,
344 update_interval_s,
345 );
346 let entry = self.corrections.entry(sat).or_default();
347 entry.orbit = Some(orbit);
348 entry.clock = Some(SsrClockCorrection {
349 solution,
350 iod_ssr: message.header.iod_ssr,
351 c0_m: f64::from(clock_record.c0) * RTCM_SSR_RADIAL_CLOCK_SCALE_M,
352 c1_m_s: f64::from(clock_record.c1) * RTCM_SSR_RADIAL_CLOCK_RATE_SCALE_M_S,
353 c2_m_s2: f64::from(clock_record.c2) * RTCM_SSR_CLOCK_ACCEL_SCALE_M_S2,
354 ref_epoch_j2000_s,
355 update_interval_s,
356 high_rate: entry.pending_high_rate,
357 });
358 }
359 }
360 SsrKind::Ura => {
361 for &(satellite_id, ura_index) in &message.ura {
362 let sat = ssr_satellite(message.system, satellite_id)?;
363 self.corrections.entry(sat).or_default().ura_index = Some(ura_index);
364 }
365 }
366 SsrKind::HighRateClock => {
367 for record in &message.clock {
368 let sat = ssr_satellite(message.system, record.satellite_id)?;
369 let high_rate = SsrHighRateClock {
370 solution,
371 iod_ssr: message.header.iod_ssr,
372 c0_m: f64::from(record.c0) * RTCM_SSR_RADIAL_CLOCK_SCALE_M,
373 ref_epoch_j2000_s,
374 update_interval_s,
375 };
376 let entry = self.corrections.entry(sat).or_default();
377 entry.pending_high_rate = Some(high_rate);
378 if let Some(clock) = &mut entry.clock {
379 if high_rate_matches(clock, &high_rate) {
380 clock.high_rate = Some(high_rate);
381 }
382 }
383 }
384 }
385 SsrKind::CodeBias | SsrKind::PhaseBias | SsrKind::Vtec => {}
386 }
387 Ok(())
388 }
389
390 pub fn orbit(&self, sat: GnssSatelliteId) -> Option<&SsrOrbitCorrection> {
392 self.corrections.get(&sat)?.orbit.as_ref()
393 }
394
395 pub fn clock(&self, sat: GnssSatelliteId) -> Option<&SsrClockCorrection> {
397 self.corrections.get(&sat)?.clock.as_ref()
398 }
399
400 pub fn ura_index(&self, sat: GnssSatelliteId) -> Option<u8> {
402 self.corrections.get(&sat)?.ura_index
403 }
404
405 pub fn code_bias(&self, sat: GnssSatelliteId, signal: u8) -> Option<f64> {
407 self.corrections
408 .get(&sat)?
409 .code_bias
410 .biases_m
411 .get(&signal)
412 .copied()
413 }
414
415 pub fn phase_bias(&self, sat: GnssSatelliteId, signal: u8) -> Option<f64> {
417 self.corrections
418 .get(&sat)?
419 .phase_bias
420 .biases_m
421 .get(&signal)
422 .copied()
423 }
424}
425
426fn orbit_from_rtcm(
427 reference_point: SsrReferencePoint,
428 message: &SsrMessage,
429 solution: SsrSolution,
430 record: &crate::rtcm::SsrOrbitRecord,
431 ref_epoch_j2000_s: f64,
432 update_interval_s: f64,
433) -> SsrOrbitCorrection {
434 SsrOrbitCorrection {
435 solution,
436 iode: record.iode,
437 iod_ssr: message.header.iod_ssr,
438 basis: OrbitBasis::VelocityAligned,
439 crs_regional: message.header.satellite_reference_datum.unwrap_or(false),
440 reference_point,
441 radial_m: -f64::from(record.delta_radial) * RTCM_SSR_RADIAL_CLOCK_SCALE_M,
442 along_m: -f64::from(record.delta_along) * RTCM_SSR_ALONG_CROSS_SCALE_M,
443 cross_m: -f64::from(record.delta_cross) * RTCM_SSR_ALONG_CROSS_SCALE_M,
444 radial_rate_m_s: -f64::from(record.dot_delta_radial) * RTCM_SSR_RADIAL_CLOCK_RATE_SCALE_M_S,
445 along_rate_m_s: -f64::from(record.dot_delta_along) * RTCM_SSR_ALONG_CROSS_RATE_SCALE_M_S,
446 cross_rate_m_s: -f64::from(record.dot_delta_cross) * RTCM_SSR_ALONG_CROSS_RATE_SCALE_M_S,
447 ref_epoch_j2000_s,
448 update_interval_s,
449 }
450}
451
452#[derive(Clone, Copy, Debug, PartialEq, Eq)]
454pub enum MissingCorrectionAction {
455 Decline,
457 FallBackToBroadcast,
459}
460
461#[derive(Clone, Debug, PartialEq, Eq)]
463pub enum RegionalPolicy {
464 DeclineRegional,
466 AllowProviders(BTreeSet<u16>),
468}
469
470#[derive(Clone, Debug, PartialEq, Eq)]
472pub struct SsrFallbackPolicy {
473 pub on_missing_correction: MissingCorrectionAction,
475 pub regional: RegionalPolicy,
477}
478
479impl Default for SsrFallbackPolicy {
480 fn default() -> Self {
481 Self {
482 on_missing_correction: MissingCorrectionAction::Decline,
483 regional: RegionalPolicy::DeclineRegional,
484 }
485 }
486}
487
488#[derive(Clone)]
490pub struct SsrCorrectedEphemeris<'a> {
491 broadcast: &'a BroadcastEphemeris,
492 store: &'a SsrCorrectionStore,
493 antex: Option<&'a Antex>,
494 attitude: SsrSatelliteAttitude,
495 staleness: StalenessPolicy,
496 fallback: SsrFallbackPolicy,
497}
498
499impl<'a> SsrCorrectedEphemeris<'a> {
500 pub fn new(broadcast: &'a BroadcastEphemeris, store: &'a SsrCorrectionStore) -> Self {
502 Self {
503 broadcast,
504 store,
505 antex: None,
506 attitude: SsrSatelliteAttitude::Unavailable,
507 staleness: store.staleness(),
508 fallback: SsrFallbackPolicy::default(),
509 }
510 }
511
512 pub fn with_satellite_antennas(mut self, antex: &'a Antex) -> Self {
514 self.antex = Some(antex);
515 self
516 }
517
518 pub fn with_satellite_attitude(mut self, attitude: SsrSatelliteAttitude) -> Self {
520 self.attitude = attitude;
521 self
522 }
523
524 pub fn with_staleness(mut self, policy: StalenessPolicy) -> Self {
526 self.staleness = policy;
527 self
528 }
529
530 pub fn with_fallback(mut self, policy: SsrFallbackPolicy) -> Self {
532 self.fallback = policy;
533 self
534 }
535
536 pub fn allow_regional_provider(mut self, provider_id: u16) -> Self {
538 match &mut self.fallback.regional {
539 RegionalPolicy::DeclineRegional => {
540 let mut providers = BTreeSet::new();
541 providers.insert(provider_id);
542 self.fallback.regional = RegionalPolicy::AllowProviders(providers);
543 }
544 RegionalPolicy::AllowProviders(providers) => {
545 providers.insert(provider_id);
546 }
547 }
548 self
549 }
550
551 pub fn corrected_state(&self, sat: GnssSatelliteId, t_j2000_s: f64) -> Option<([f64; 3], f64)> {
553 self.corrected_state_inner(sat, t_j2000_s)
554 .or_else(|| self.broadcast_fallback_after_failure(sat, t_j2000_s))
555 }
556
557 fn corrected_state_inner(
558 &self,
559 sat: GnssSatelliteId,
560 t_j2000_s: f64,
561 ) -> Option<([f64; 3], f64)> {
562 let orbit = self.store.orbit(sat)?;
563 let clock = self.store.clock(sat)?;
564 if orbit.solution != clock.solution || orbit.iod_ssr != clock.iod_ssr {
565 return None;
566 }
567 if !self.correction_fresh(t_j2000_s, orbit.ref_epoch_j2000_s) {
568 return None;
569 }
570 if !self.correction_fresh(t_j2000_s, clock.ref_epoch_j2000_s) {
571 return None;
572 }
573 if orbit.crs_regional && !self.regional_allowed(orbit.solution.provider_id) {
574 return None;
575 }
576
577 let nav_message = default_nav_message(sat.system)?;
578 let issue = BroadcastIssue {
579 issue: orbit.iode,
580 message: nav_message,
581 };
582 let record = self
583 .broadcast
584 .select_by_issue_at(sat, issue, nav_message, t_j2000_s)?;
585 let (t_continuous_s, is_geo) = continuous_time_for_sat(sat, t_j2000_s)?;
586 let sow = t_continuous_s.rem_euclid(SECONDS_PER_WEEK);
587 let state = satellite_state_unchecked(
588 &record.elements,
589 &record.clock,
590 &record.constants(),
591 sow,
592 record.broadcast_clock_group_delay_s(),
593 is_geo,
594 );
595 let r = state.orbit.position().ok()?.as_array();
596 let v = broadcast_velocity(record, sat, t_j2000_s, is_geo)?;
597 let (er, ea, ec) = velocity_aligned_basis(r, v)?;
598 let dt_orbit = t_j2000_s - orbit.ref_epoch_j2000_s;
599 let radial = orbit.radial_m + orbit.radial_rate_m_s * dt_orbit;
600 let along = orbit.along_m + orbit.along_rate_m_s * dt_orbit;
601 let cross = orbit.cross_m + orbit.cross_rate_m_s * dt_orbit;
602 let mut corrected_position = [
603 r[0] + radial * er[0] + along * ea[0] + cross * ec[0],
604 r[1] + radial * er[1] + along * ea[1] + cross * ec[1],
605 r[2] + radial * er[2] + along * ea[2] + cross * ec[2],
606 ];
607 if orbit.reference_point == SsrReferencePoint::CenterOfMass {
608 let pco_ecef_m = self.satellite_pco_to_apc(sat, t_j2000_s, corrected_position)?;
609 corrected_position = add3(corrected_position, pco_ecef_m);
610 }
611
612 let dt_clock = t_j2000_s - clock.ref_epoch_j2000_s;
613 let mut dclock_m =
614 clock.c0_m + clock.c1_m_s * dt_clock + clock.c2_m_s2 * dt_clock * dt_clock;
615 if let Some(high_rate) = clock.high_rate {
616 if high_rate_matches(clock, &high_rate)
617 && self.correction_fresh(t_j2000_s, high_rate.ref_epoch_j2000_s)
618 {
619 dclock_m += high_rate.c0_m;
620 }
621 }
622 let corrected_clock_s = state.clock.dt_clock_total_s + dclock_m / C_M_S;
623 Some((corrected_position, corrected_clock_s))
624 }
625
626 fn correction_fresh(&self, t_j2000_s: f64, ref_epoch_j2000_s: f64) -> bool {
627 let age = (t_j2000_s - ref_epoch_j2000_s).abs();
628 age.is_finite() && age <= self.staleness.max_staleness_s
629 }
630
631 fn regional_allowed(&self, provider_id: u16) -> bool {
632 match &self.fallback.regional {
633 RegionalPolicy::DeclineRegional => false,
634 RegionalPolicy::AllowProviders(providers) => providers.contains(&provider_id),
635 }
636 }
637
638 fn broadcast_fallback_after_failure(
639 &self,
640 sat: GnssSatelliteId,
641 t_j2000_s: f64,
642 ) -> Option<([f64; 3], f64)> {
643 if self
644 .store
645 .orbit(sat)
646 .is_some_and(|orbit| orbit.reference_point == SsrReferencePoint::CenterOfMass)
647 {
648 return None;
649 }
650 if self.fallback.on_missing_correction == MissingCorrectionAction::FallBackToBroadcast {
651 self.broadcast.position_clock_at_j2000_s(sat, t_j2000_s)
652 } else {
653 None
654 }
655 }
656
657 fn satellite_pco_to_apc(
658 &self,
659 sat: GnssSatelliteId,
660 t_j2000_s: f64,
661 sat_position_ecef_m: [f64; 3],
662 ) -> Option<[f64; 3]> {
663 if self.attitude != SsrSatelliteAttitude::NominalSunFixed {
664 return None;
665 }
666 let antex = self.antex?;
667 let epoch = antex_epoch_from_j2000_gpst(t_j2000_s)?;
668 let antenna = antex.satellite_antenna(&sat.to_string(), epoch)?;
669 let frequency = ssr_apc_frequency(sat.system)?;
670 let pco_body_m = antenna.pco(frequency).ok()?;
671 let ts = time_scales_from_j2000_gpst(t_j2000_s)?;
672 let sun_ecef_m = sun_moon_ecef(&ts).ok()?.sun;
673 satellite_body_pco_to_ecef(pco_body_m, sat_position_ecef_m, sun_ecef_m)
674 }
675}
676
677impl EphemerisSource for SsrCorrectedEphemeris<'_> {
678 fn position_clock_at_j2000_s(
679 &self,
680 sat: GnssSatelliteId,
681 t_j2000_s: f64,
682 ) -> Option<([f64; 3], f64)> {
683 self.corrected_state(sat, t_j2000_s)
684 }
685}
686
687impl ObservableEphemerisSource for SsrCorrectedEphemeris<'_> {
688 fn observable_state_at_j2000_s(
689 &self,
690 sat: GnssSatelliteId,
691 t_j2000_s: f64,
692 ) -> std::result::Result<ObservableState, ObservablesError> {
693 let Some((position_ecef_m, clock_s)) = self.corrected_state(sat, t_j2000_s) else {
694 return Err(ObservablesError::NoEphemeris);
695 };
696 Ok(ObservableState {
697 position_ecef_m,
698 clock_s: Some(clock_s),
699 })
700 }
701}
702
703#[derive(Clone)]
705pub struct SsrCorrectedEphemerisOwned {
706 broadcast: Arc<BroadcastEphemeris>,
707 store: Arc<SsrCorrectionStore>,
708 antex: Option<Arc<Antex>>,
709 attitude: SsrSatelliteAttitude,
710 staleness: StalenessPolicy,
711 fallback: SsrFallbackPolicy,
712}
713
714impl SsrCorrectedEphemerisOwned {
715 pub fn new(broadcast: Arc<BroadcastEphemeris>, store: Arc<SsrCorrectionStore>) -> Self {
717 let staleness = store.staleness();
718 Self {
719 broadcast,
720 store,
721 antex: None,
722 attitude: SsrSatelliteAttitude::Unavailable,
723 staleness,
724 fallback: SsrFallbackPolicy::default(),
725 }
726 }
727
728 pub fn with_satellite_antennas(mut self, antex: Arc<Antex>) -> Self {
730 self.antex = Some(antex);
731 self
732 }
733
734 pub fn with_satellite_attitude(mut self, attitude: SsrSatelliteAttitude) -> Self {
736 self.attitude = attitude;
737 self
738 }
739
740 pub fn with_staleness(mut self, policy: StalenessPolicy) -> Self {
742 self.staleness = policy;
743 self
744 }
745
746 pub fn with_fallback(mut self, policy: SsrFallbackPolicy) -> Self {
748 self.fallback = policy;
749 self
750 }
751
752 pub fn allow_regional_provider(mut self, provider_id: u16) -> Self {
754 match &mut self.fallback.regional {
755 RegionalPolicy::DeclineRegional => {
756 let mut providers = BTreeSet::new();
757 providers.insert(provider_id);
758 self.fallback.regional = RegionalPolicy::AllowProviders(providers);
759 }
760 RegionalPolicy::AllowProviders(providers) => {
761 providers.insert(provider_id);
762 }
763 }
764 self
765 }
766
767 fn borrowed(&self) -> SsrCorrectedEphemeris<'_> {
768 let source = SsrCorrectedEphemeris::new(&self.broadcast, &self.store)
769 .with_staleness(self.staleness)
770 .with_fallback(self.fallback.clone())
771 .with_satellite_attitude(self.attitude);
772 if let Some(antex) = &self.antex {
773 source.with_satellite_antennas(antex)
774 } else {
775 source
776 }
777 }
778}
779
780impl EphemerisSource for SsrCorrectedEphemerisOwned {
781 fn position_clock_at_j2000_s(
782 &self,
783 sat: GnssSatelliteId,
784 t_j2000_s: f64,
785 ) -> Option<([f64; 3], f64)> {
786 self.borrowed().position_clock_at_j2000_s(sat, t_j2000_s)
787 }
788}
789
790impl ObservableEphemerisSource for SsrCorrectedEphemerisOwned {
791 fn observable_state_at_j2000_s(
792 &self,
793 sat: GnssSatelliteId,
794 t_j2000_s: f64,
795 ) -> std::result::Result<ObservableState, ObservablesError> {
796 self.borrowed().observable_state_at_j2000_s(sat, t_j2000_s)
797 }
798}
799
800fn update_interval_s(index: u8) -> f64 {
801 const TABLE: [f64; 16] = [
802 1.0,
803 2.0,
804 5.0,
805 10.0,
806 15.0,
807 30.0,
808 60.0,
809 120.0,
810 240.0,
811 300.0,
812 600.0,
813 900.0,
814 1800.0,
815 SECONDS_PER_HOUR,
816 7200.0,
817 10800.0,
818 ];
819 TABLE[usize::from(index)]
820}
821
822fn ssr_epoch_j2000_s(
823 system: GnssSystem,
824 week: GnssWeekTow,
825 epoch_time_s: u32,
826 update_interval: u8,
827 update_interval_s: f64,
828) -> Result<f64> {
829 let scale = match system {
830 GnssSystem::Galileo => TimeScale::Gst,
831 GnssSystem::BeiDou => TimeScale::Bdt,
832 _ => TimeScale::Gpst,
833 };
834 let epoch_offset_s = if update_interval == 0 {
835 0.0
836 } else {
837 update_interval_s / 2.0
838 };
839 let normalized = GnssWeekTow::new(scale, week.week, f64::from(epoch_time_s) + epoch_offset_s)
840 .and_then(GnssWeekTow::normalized)
841 .map_err(|_| Error::Parse("SSR epoch is out of range".to_string()))?;
842 let continuous = f64::from(normalized.week) * SECONDS_PER_WEEK + normalized.tow_s;
843 Ok(match system {
844 GnssSystem::BeiDou => {
845 continuous
846 + crate::constants::BDS_EPOCH_MINUS_GPS_EPOCH_S
847 + crate::constants::GPST_MINUS_BDT_S
848 - GPS_EPOCH_TO_J2000_S
849 }
850 _ => continuous - GPS_EPOCH_TO_J2000_S,
851 })
852}
853
854fn ssr_satellite(system: GnssSystem, satellite_id: u8) -> Result<GnssSatelliteId> {
855 GnssSatelliteId::new(system, satellite_id)
856 .map_err(|e| Error::Parse(format!("invalid SSR satellite id {satellite_id}: {e}")))
857}
858
859fn high_rate_matches(clock: &SsrClockCorrection, high_rate: &SsrHighRateClock) -> bool {
860 clock.solution == high_rate.solution && clock.iod_ssr == high_rate.iod_ssr
861}
862
863fn default_nav_message(system: GnssSystem) -> Option<NavMessage> {
864 match system {
865 GnssSystem::Gps => Some(NavMessage::GpsLnav),
866 GnssSystem::Galileo => Some(NavMessage::GalileoInav),
867 GnssSystem::BeiDou => Some(NavMessage::BeidouD1),
868 _ => None,
869 }
870}
871
872fn ssr_apc_frequency(system: GnssSystem) -> Option<&'static str> {
873 match system {
874 GnssSystem::Gps => Some("G01"),
875 GnssSystem::Glonass => Some("R01"),
876 GnssSystem::Galileo => Some("E01"),
877 GnssSystem::BeiDou => Some("C02"),
878 GnssSystem::Qzss => Some("J01"),
879 GnssSystem::Navic => Some("I05"),
880 GnssSystem::Sbas => Some("S01"),
881 }
882}
883
884fn antex_epoch_from_j2000_gpst(t_j2000_s: f64) -> Option<AntexDateTime> {
885 let (year, month, day, hour, minute, second) = civil_fields_from_j2000_gpst(t_j2000_s)?;
886 AntexDateTime::new(year, month, day, hour, minute, second.trunc() as u8).ok()
887}
888
889fn time_scales_from_j2000_gpst(t_j2000_s: f64) -> Option<TimeScales> {
890 let (year, month, day, hour, minute, second) = civil_fields_from_j2000_gpst(t_j2000_s)?;
891 TimeScales::from_scale(
892 TimeScale::Gpst,
893 year,
894 i32::from(month),
895 i32::from(day),
896 i32::from(hour),
897 i32::from(minute),
898 second,
899 )
900 .ok()
901}
902
903fn civil_fields_from_j2000_gpst(t_j2000_s: f64) -> Option<(i32, u8, u8, u8, u8, f64)> {
904 if !t_j2000_s.is_finite() {
905 return None;
906 }
907 let whole = t_j2000_s.floor();
908 if whole < i64::MIN as f64 || whole > i64::MAX as f64 {
909 return None;
910 }
911 let fraction = t_j2000_s - whole;
912 let (year, month, day, hour, minute, second) = civil_from_j2000_seconds(whole as i64);
913 Some((
914 i32::try_from(year).ok()?,
915 u8::try_from(month).ok()?,
916 u8::try_from(day).ok()?,
917 u8::try_from(hour).ok()?,
918 u8::try_from(minute).ok()?,
919 second as f64 + fraction,
920 ))
921}
922
923fn continuous_time_for_sat(sat: GnssSatelliteId, t_j2000_s: f64) -> Option<(f64, bool)> {
924 if !matches!(
925 sat.system,
926 GnssSystem::Gps | GnssSystem::Galileo | GnssSystem::BeiDou
927 ) {
928 return None;
929 }
930 let gpst_continuous = t_j2000_s + GPS_EPOCH_TO_J2000_S;
931 if sat.system == GnssSystem::BeiDou {
932 Some((
933 gpst_continuous
934 - crate::constants::GPST_MINUS_BDT_S
935 - crate::constants::BDS_EPOCH_MINUS_GPS_EPOCH_S,
936 is_beidou_geo(sat),
937 ))
938 } else {
939 Some((gpst_continuous, false))
940 }
941}
942
943fn broadcast_velocity(
944 record: &crate::rinex_nav::BroadcastRecord,
945 sat: GnssSatelliteId,
946 t_j2000_s: f64,
947 is_geo: bool,
948) -> Option<[f64; 3]> {
949 let p_plus = broadcast_position_from_record(record, sat, t_j2000_s + FD_HALF_S, is_geo)?;
950 let p_minus = broadcast_position_from_record(record, sat, t_j2000_s - FD_HALF_S, is_geo)?;
951 let denom = 2.0 * FD_HALF_S;
952 Some([
953 (p_plus[0] - p_minus[0]) / denom,
954 (p_plus[1] - p_minus[1]) / denom,
955 (p_plus[2] - p_minus[2]) / denom,
956 ])
957}
958
959fn broadcast_position_from_record(
960 record: &crate::rinex_nav::BroadcastRecord,
961 sat: GnssSatelliteId,
962 t_j2000_s: f64,
963 is_geo: bool,
964) -> Option<[f64; 3]> {
965 let (t_continuous_s, _) = continuous_time_for_sat(sat, t_j2000_s)?;
966 let sow = t_continuous_s.rem_euclid(SECONDS_PER_WEEK);
967 satellite_state_unchecked(
968 &record.elements,
969 &record.clock,
970 &record.constants(),
971 sow,
972 record.broadcast_clock_group_delay_s(),
973 is_geo,
974 )
975 .orbit
976 .position()
977 .ok()
978 .map(|p| p.as_array())
979}
980
981fn velocity_aligned_basis(r: [f64; 3], v: [f64; 3]) -> Option<([f64; 3], [f64; 3], [f64; 3])> {
982 let ea = normalize(v)?;
983 let rc = cross(r, v);
984 let ec = normalize(rc)?;
985 let er = cross(ea, ec);
986 Some((er, ea, ec))
987}
988
989fn normalize(v: [f64; 3]) -> Option<[f64; 3]> {
990 let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
991 if n > 0.0 && n.is_finite() {
992 Some([v[0] / n, v[1] / n, v[2] / n])
993 } else {
994 None
995 }
996}
997
998fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
999 [
1000 a[1] * b[2] - a[2] * b[1],
1001 a[2] * b[0] - a[0] * b[2],
1002 a[0] * b[1] - a[1] * b[0],
1003 ]
1004}
1005
1006#[cfg(test)]
1007mod tests {
1008 use super::*;
1009 use crate::astro::math::vec3::dot3;
1010 use crate::rtcm::{
1011 SsrClockRecord, SsrHeader, SsrOrbitRecord, SsrPhaseBiasRecord, SsrPhaseBiasSignal,
1012 SsrStreamAssembler,
1013 };
1014 use crate::sp3::Sp3;
1015
1016 const REAL_SSRA02IGS0_1060_FRAME_HEX: &str = include_str!(concat!(
1017 env!("CARGO_MANIFEST_DIR"),
1018 "/tests/fixtures/ssr/SSRA02IGS0_2026181234930_1060.hex"
1019 ));
1020 const REAL_SSR_WEEK: u32 = 2425;
1021 const REAL_SSR_EPOCH_TOW_S: f64 = 344_970.0;
1022 const GPS_ANTEX_TEXT: &str = include_str!(concat!(
1023 env!("CARGO_MANIFEST_DIR"),
1024 "/tests/fixtures/antex/igs20_wettzell_trim.atx"
1025 ));
1026
1027 fn header(kind: SsrKind) -> SsrHeader {
1028 SsrHeader {
1029 epoch_time_s: 100_000,
1030 update_interval: 3,
1031 multiple_message: false,
1032 iod_ssr: 4,
1033 provider_id: 7,
1034 solution_id: 2,
1035 satellite_reference_datum: matches!(kind, SsrKind::Orbit | SsrKind::CombinedOrbitClock)
1036 .then_some(false),
1037 dispersive_bias_consistency: None,
1038 mw_consistency: None,
1039 satellite_count: 1,
1040 }
1041 }
1042
1043 #[test]
1044 fn rtcm_ingest_scales_orbit_and_clock() {
1045 let message = SsrMessage {
1046 message_number: 1060,
1047 system: GnssSystem::Gps,
1048 kind: SsrKind::CombinedOrbitClock,
1049 header: header(SsrKind::CombinedOrbitClock),
1050 orbit: vec![SsrOrbitRecord {
1051 satellite_id: 1,
1052 iode: 42,
1053 delta_radial: 10_000,
1054 delta_along: -20_000,
1055 delta_cross: 30_000,
1056 dot_delta_radial: 100,
1057 dot_delta_along: -200,
1058 dot_delta_cross: 300,
1059 }],
1060 clock: vec![SsrClockRecord {
1061 satellite_id: 1,
1062 c0: 10_000,
1063 c1: -2_000,
1064 c2: 300,
1065 }],
1066 code_bias: Vec::new(),
1067 phase_bias: Vec::<SsrPhaseBiasRecord>::new(),
1068 ura: Vec::new(),
1069 padding_bits: Vec::new(),
1070 };
1071 let mut store = SsrCorrectionStore::new();
1072 let week = GnssWeekTow::new(TimeScale::Gpst, 2_400, 100_000.0).unwrap();
1073 store.ingest_ssr(&message, week).unwrap();
1074 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).unwrap();
1075 let orbit = store.orbit(sat).unwrap();
1076 assert_eq!(orbit.iode, 42);
1077 assert_eq!(orbit.reference_point, SsrReferencePoint::rtcm_ssr_default());
1078 assert_eq!(orbit.radial_m.to_bits(), (-1.0_f64).to_bits());
1079 assert_eq!(orbit.along_m.to_bits(), 8.0_f64.to_bits());
1080 assert_eq!(orbit.cross_m.to_bits(), (-12.0_f64).to_bits());
1081 assert!((orbit.radial_rate_m_s + 1.0e-4).abs() < 1.0e-18);
1082 let clock = store.clock(sat).unwrap();
1083 assert_eq!(clock.c0_m.to_bits(), 1.0_f64.to_bits());
1084 assert!((clock.c1_m_s + 0.002).abs() < 1.0e-18);
1085 assert!((clock.c2_m_s2 - 6.0e-6).abs() < 1.0e-18);
1086 }
1087
1088 #[test]
1089 fn reference_point_tag_round_trips_through_store_ingest() {
1090 let message = SsrMessage {
1091 message_number: 1057,
1092 system: GnssSystem::Gps,
1093 kind: SsrKind::Orbit,
1094 header: header(SsrKind::Orbit),
1095 orbit: vec![SsrOrbitRecord {
1096 satellite_id: 1,
1097 iode: 42,
1098 delta_radial: 0,
1099 delta_along: 0,
1100 delta_cross: 0,
1101 dot_delta_radial: 0,
1102 dot_delta_along: 0,
1103 dot_delta_cross: 0,
1104 }],
1105 clock: Vec::new(),
1106 code_bias: Vec::new(),
1107 phase_bias: Vec::<SsrPhaseBiasRecord>::new(),
1108 ura: Vec::new(),
1109 padding_bits: Vec::new(),
1110 };
1111 let mut store =
1112 SsrCorrectionStore::new().with_reference_point(SsrReferencePoint::igs_ssr_default());
1113 let week = GnssWeekTow::new(TimeScale::Gpst, 2_400, 100_000.0).unwrap();
1114 store.ingest_ssr(&message, week).unwrap();
1115 let tag = store.reference_point().tag();
1116 assert_eq!(
1117 SsrReferencePoint::from_tag(tag),
1118 Some(SsrReferencePoint::CenterOfMass)
1119 );
1120 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).unwrap();
1121 assert_eq!(
1122 store.clone().orbit(sat).unwrap().reference_point,
1123 SsrReferencePoint::CenterOfMass
1124 );
1125 }
1126
1127 #[test]
1128 fn interval_zero_epoch_uses_transmitted_time_not_midpoint() {
1129 let week = GnssWeekTow::new(TimeScale::Gpst, 2_400, 0.0).unwrap();
1130 let got_zero =
1131 ssr_epoch_j2000_s(GnssSystem::Gps, week, 100_000, 0, update_interval_s(0)).unwrap();
1132 let expected = f64::from(week.week) * SECONDS_PER_WEEK + 100_000.0 - GPS_EPOCH_TO_J2000_S;
1133 assert_eq!(got_zero.to_bits(), expected.to_bits());
1134
1135 let got_nonzero =
1136 ssr_epoch_j2000_s(GnssSystem::Gps, week, 100_000, 1, update_interval_s(1)).unwrap();
1137 assert_eq!(got_nonzero.to_bits(), (expected + 1.0).to_bits());
1138 }
1139
1140 #[test]
1141 fn satellite_pco_projection_matches_hand_geometry() {
1142 let sat_position_ecef_m = [1.0, 0.0, 0.0];
1143 let sun_ecef_m = [1.0, 1.0, 0.0];
1144 let pco_body_m = [0.25, 0.5, 0.75];
1145 let shift = satellite_body_pco_to_ecef(pco_body_m, sat_position_ecef_m, sun_ecef_m)
1146 .expect("non-degenerate satellite-Sun axes");
1147 assert_eq!(
1148 shift.map(f64::to_bits),
1149 [
1150 (-0.75_f64).to_bits(),
1151 0.25_f64.to_bits(),
1152 (-0.5_f64).to_bits()
1153 ]
1154 );
1155 let los_unit = [0.0, 0.0, 1.0];
1156 assert_eq!(dot3(shift, los_unit).to_bits(), (-0.5_f64).to_bits());
1157 }
1158
1159 #[test]
1160 fn satellite_pco_projection_declines_near_degenerate_axes() {
1161 assert!(satellite_body_pco_to_ecef(
1162 [0.25, 0.5, 0.75],
1163 [1.0, 0.0, 0.0],
1164 [0.0, 1.0e-15, 0.0],
1165 )
1166 .is_none());
1167 }
1168
1169 #[test]
1170 fn high_rate_clock_is_additive_when_identity_matches() {
1171 let mut store = SsrCorrectionStore::new();
1172 let week = GnssWeekTow::new(TimeScale::Gpst, 2_400, 100_000.0).unwrap();
1173 let low = SsrMessage {
1174 message_number: 1058,
1175 system: GnssSystem::Gps,
1176 kind: SsrKind::Clock,
1177 header: header(SsrKind::Clock),
1178 orbit: Vec::new(),
1179 clock: vec![SsrClockRecord {
1180 satellite_id: 1,
1181 c0: 1_000,
1182 c1: 0,
1183 c2: 0,
1184 }],
1185 code_bias: Vec::new(),
1186 phase_bias: Vec::<SsrPhaseBiasRecord>::new(),
1187 ura: Vec::new(),
1188 padding_bits: Vec::new(),
1189 };
1190 let high = SsrMessage {
1191 message_number: 1062,
1192 system: GnssSystem::Gps,
1193 kind: SsrKind::HighRateClock,
1194 header: header(SsrKind::HighRateClock),
1195 orbit: Vec::new(),
1196 clock: vec![SsrClockRecord {
1197 satellite_id: 1,
1198 c0: 500,
1199 c1: 0,
1200 c2: 0,
1201 }],
1202 code_bias: Vec::new(),
1203 phase_bias: vec![SsrPhaseBiasRecord {
1204 satellite_id: 1,
1205 yaw_angle: 0,
1206 yaw_rate: 0,
1207 biases: vec![SsrPhaseBiasSignal {
1208 signal_id: 1,
1209 integer_indicator: 0,
1210 wide_lane_integer_indicator: 0,
1211 discontinuity_counter: 0,
1212 bias: 0,
1213 }],
1214 }],
1215 ura: Vec::new(),
1216 padding_bits: Vec::new(),
1217 };
1218 store.ingest_ssr(&high, week).unwrap();
1219 store.ingest_ssr(&low, week).unwrap();
1220 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).unwrap();
1221 assert_eq!(
1222 store.clock(sat).unwrap().high_rate.unwrap().c0_m.to_bits(),
1223 0.05_f64.to_bits()
1224 );
1225 }
1226
1227 #[test]
1228 fn corrected_ephemeris_uses_real_rtcm_broadcast_and_sp3_products() {
1229 let nav_text = std::fs::read_to_string(concat!(
1230 env!("CARGO_MANIFEST_DIR"),
1231 "/tests/fixtures/ssr/BRDC00WRD_S_20261820000_G30_G31.rnx"
1232 ))
1233 .expect("read NAV fixture");
1234 let broadcast = BroadcastEphemeris::from_nav(&nav_text).expect("parse NAV fixture");
1235 let sp3_bytes = std::fs::read(concat!(
1236 env!("CARGO_MANIFEST_DIR"),
1237 "/tests/fixtures/ssr/IGS0OPSULT_20261811800_02D_15M_ORB.SP3"
1238 ))
1239 .expect("read SP3 fixture");
1240 let sp3 = Sp3::parse(&sp3_bytes).expect("parse SP3 fixture");
1241 let store = real_gps_ssr_store();
1242 let source = SsrCorrectedEphemeris::new(&broadcast, &store)
1243 .with_staleness(StalenessPolicy::seconds(60.0));
1244 let t = ssr_j2000(REAL_SSR_EPOCH_TOW_S);
1245
1246 let mut orbit_error_sum_m2 = 0.0;
1247 let mut clock_error_sum_ns2 = 0.0;
1248 let mut count = 0_usize;
1249 for sat in [
1250 GnssSatelliteId::new(GnssSystem::Gps, 30).unwrap(),
1251 GnssSatelliteId::new(GnssSystem::Gps, 31).unwrap(),
1252 ] {
1253 let (corrected_position, corrected_clock) =
1254 source.corrected_state(sat, t).expect("corrected state");
1255 let sp3_state = sp3.position_at_j2000_seconds(sat, t).expect("SP3 state");
1256 let sp3_position = sp3_state.position.as_array();
1257 let sp3_clock = sp3_state.clock_s.expect("SP3 clock");
1258 let orbit_error_m = norm([
1259 corrected_position[0] - sp3_position[0],
1260 corrected_position[1] - sp3_position[1],
1261 corrected_position[2] - sp3_position[2],
1262 ]);
1263 let clock_error_ns = (corrected_clock - sp3_clock) * 1.0e9;
1264 orbit_error_sum_m2 += orbit_error_m * orbit_error_m;
1265 clock_error_sum_ns2 += clock_error_ns * clock_error_ns;
1266 count += 1;
1267 }
1268 assert_eq!(count, 2);
1269 let orbit_rms_m = (orbit_error_sum_m2 / count as f64).sqrt();
1270 let clock_rms_ns = (clock_error_sum_ns2 / count as f64).sqrt();
1271
1272 assert!(orbit_rms_m < 1.6, "{orbit_rms_m}");
1276 assert!(clock_rms_ns < 22.0, "{clock_rms_ns}");
1277 }
1278
1279 #[test]
1280 fn corrected_position_matches_rtklib_satpos_ssr_oracle_for_one_epoch() {
1281 let nav_text = std::fs::read_to_string(concat!(
1282 env!("CARGO_MANIFEST_DIR"),
1283 "/tests/fixtures/ssr/BRDC00WRD_S_20261820000_G30_G31.rnx"
1284 ))
1285 .expect("read NAV fixture");
1286 let broadcast = BroadcastEphemeris::from_nav(&nav_text).expect("parse NAV fixture");
1287 let store = real_gps_ssr_store();
1288 let source = SsrCorrectedEphemeris::new(&broadcast, &store)
1289 .with_staleness(StalenessPolicy::seconds(60.0));
1290 let sat = GnssSatelliteId::new(GnssSystem::Gps, 30).unwrap();
1291 let (position, clock) = source
1292 .corrected_state(sat, ssr_j2000(REAL_SSR_EPOCH_TOW_S))
1293 .expect("corrected state");
1294 assert_eq!(
1295 position.map(f64::to_bits),
1296 [
1297 13_931_924_021_901_094_572,
1298 4_714_745_314_434_008_008,
1299 13_939_538_677_975_909_636,
1300 ]
1301 );
1302 assert_eq!(clock.to_bits(), 4_553_802_230_946_855_152);
1303 let rtklib_position = [
1304 -6_327_381.424_159_626,
1305 15_802_129.789_888_298,
1306 -20_121_898.098_271_403,
1307 ];
1308 let position_error_m = norm([
1309 position[0] - rtklib_position[0],
1310 position[1] - rtklib_position[1],
1311 position[2] - rtklib_position[2],
1312 ]);
1313 assert!(position_error_m < 1.0e-6, "{position_error_m}");
1314 }
1315
1316 #[test]
1317 fn com_reference_point_requires_explicit_attitude_model() {
1318 let nav_text = std::fs::read_to_string(concat!(
1319 env!("CARGO_MANIFEST_DIR"),
1320 "/tests/fixtures/ssr/BRDC00WRD_S_20261820000_G30_G31.rnx"
1321 ))
1322 .expect("read NAV fixture");
1323 let broadcast = BroadcastEphemeris::from_nav(&nav_text).expect("parse NAV fixture");
1324 let antex = Antex::parse(GPS_ANTEX_TEXT).expect("parse ANTEX fixture");
1325 let store = real_gps_ssr_store_with_reference_point(SsrReferencePoint::CenterOfMass);
1326 let sat = GnssSatelliteId::new(GnssSystem::Gps, 30).unwrap();
1327 let t = ssr_j2000(REAL_SSR_EPOCH_TOW_S);
1328
1329 let no_attitude = SsrCorrectedEphemeris::new(&broadcast, &store)
1330 .with_staleness(StalenessPolicy::seconds(60.0))
1331 .with_satellite_antennas(&antex);
1332 assert!(no_attitude.corrected_state(sat, t).is_none());
1333
1334 let fallback = SsrCorrectedEphemeris::new(&broadcast, &store)
1335 .with_staleness(StalenessPolicy::seconds(60.0))
1336 .with_satellite_antennas(&antex)
1337 .with_fallback(SsrFallbackPolicy {
1338 on_missing_correction: MissingCorrectionAction::FallBackToBroadcast,
1339 regional: RegionalPolicy::DeclineRegional,
1340 });
1341 assert!(fallback.corrected_state(sat, t).is_none());
1342
1343 let nominal = SsrCorrectedEphemeris::new(&broadcast, &store)
1344 .with_staleness(StalenessPolicy::seconds(60.0))
1345 .with_satellite_antennas(&antex)
1346 .with_satellite_attitude(SsrSatelliteAttitude::NominalSunFixed);
1347 assert!(nominal.corrected_state(sat, t).is_some());
1348 }
1349
1350 #[test]
1351 fn com_orbit_tag_blocks_fallback_after_store_default_changes_to_apc() {
1352 let nav_text = std::fs::read_to_string(concat!(
1353 env!("CARGO_MANIFEST_DIR"),
1354 "/tests/fixtures/ssr/BRDC00WRD_S_20261820000_G30_G31.rnx"
1355 ))
1356 .expect("read NAV fixture");
1357 let broadcast = BroadcastEphemeris::from_nav(&nav_text).expect("parse NAV fixture");
1358 let antex = Antex::parse(GPS_ANTEX_TEXT).expect("parse ANTEX fixture");
1359 let mut store = real_gps_ssr_store_with_reference_point(SsrReferencePoint::CenterOfMass);
1360 store = store.with_reference_point(SsrReferencePoint::AntennaPhaseCenter);
1361 let sat = GnssSatelliteId::new(GnssSystem::Gps, 30).unwrap();
1362 let t = ssr_j2000(REAL_SSR_EPOCH_TOW_S);
1363 assert_eq!(
1364 store.reference_point(),
1365 SsrReferencePoint::AntennaPhaseCenter
1366 );
1367 assert_eq!(
1368 store.orbit(sat).unwrap().reference_point,
1369 SsrReferencePoint::CenterOfMass
1370 );
1371
1372 let source = SsrCorrectedEphemeris::new(&broadcast, &store)
1373 .with_staleness(StalenessPolicy::seconds(60.0))
1374 .with_satellite_antennas(&antex)
1375 .with_fallback(SsrFallbackPolicy {
1376 on_missing_correction: MissingCorrectionAction::FallBackToBroadcast,
1377 regional: RegionalPolicy::DeclineRegional,
1378 });
1379
1380 assert_eq!(
1381 source.observable_state_at_j2000_s(sat, t),
1382 Err(ObservablesError::NoEphemeris)
1383 );
1384 }
1385
1386 fn real_gps_ssr_store() -> SsrCorrectionStore {
1387 real_gps_ssr_store_with_reference_point(SsrReferencePoint::rtcm_ssr_default())
1388 }
1389
1390 fn real_gps_ssr_store_with_reference_point(
1391 reference_point: SsrReferencePoint,
1392 ) -> SsrCorrectionStore {
1393 let mut assembler = SsrStreamAssembler::new();
1394 let mut store = SsrCorrectionStore::new().with_reference_point(reference_point);
1395 let week = GnssWeekTow::new(TimeScale::Gpst, REAL_SSR_WEEK, REAL_SSR_EPOCH_TOW_S)
1396 .expect("valid SSR week");
1397 for decoded in assembler.push(&hex_bytes(REAL_SSRA02IGS0_1060_FRAME_HEX)) {
1398 let message = decoded.expect("decode SSR frame");
1399 store.ingest(&message, week).expect("ingest SSR frame");
1400 }
1401 assert_eq!(assembler.retained_len(), 0);
1402 store
1403 }
1404
1405 fn ssr_j2000(tow_s: f64) -> f64 {
1406 f64::from(REAL_SSR_WEEK) * SECONDS_PER_WEEK + tow_s - GPS_EPOCH_TO_J2000_S
1407 }
1408
1409 fn hex_bytes(hex: &str) -> Vec<u8> {
1410 let compact: String = hex.chars().filter(|c| c.is_ascii_hexdigit()).collect();
1411 assert_eq!(compact.len() % 2, 0);
1412 compact
1413 .as_bytes()
1414 .chunks_exact(2)
1415 .map(|chunk| {
1416 let hi = (chunk[0] as char).to_digit(16).unwrap();
1417 let lo = (chunk[1] as char).to_digit(16).unwrap();
1418 ((hi << 4) | lo) as u8
1419 })
1420 .collect()
1421 }
1422
1423 fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
1424 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
1425 }
1426
1427 fn norm(v: [f64; 3]) -> f64 {
1428 dot(v, v).sqrt()
1429 }
1430}