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::has::{has_mt1_reference_j2000_s, has_validity_interval_s, HasMt1Message};
21use crate::id::{GnssSatelliteId, GnssSystem};
22use crate::observables::{ObservableEphemerisSource, ObservableState, ObservablesError};
23use crate::ppp_corrections::satellite_body_pco_to_ecef;
24use crate::rinex_nav::is_beidou_geo;
25use crate::rtcm::{Message, SsrKind, SsrMessage};
26use crate::spp::EphemerisSource;
27use crate::staleness::StalenessPolicy;
28
29const DEFAULT_SSR_STALENESS_S: f64 = 90.0;
30const FD_HALF_S: f64 = 0.5;
31const RTCM_SSR_RADIAL_CLOCK_SCALE_M: f64 = 1.0e-4;
33const RTCM_SSR_ALONG_CROSS_SCALE_M: f64 = 4.0e-4;
35const RTCM_SSR_RADIAL_CLOCK_RATE_SCALE_M_S: f64 = 1.0e-6;
37const RTCM_SSR_ALONG_CROSS_RATE_SCALE_M_S: f64 = 4.0e-6;
39const RTCM_SSR_CLOCK_ACCEL_SCALE_M_S2: f64 = 2.0e-8;
41const RTCM_SSR_CODE_BIAS_SCALE_M: f64 = 1.0e-2;
43const RTCM_SSR_PHASE_BIAS_SCALE_M: f64 = 1.0e-4;
45
46#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
48pub enum SsrSource {
49 RtcmSsr,
51 GalileoHas,
53}
54
55#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
57pub enum OrbitBasis {
58 VelocityAligned,
60}
61
62#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
75pub enum SsrReferencePoint {
76 AntennaPhaseCenter,
78 CenterOfMass,
80}
81
82impl SsrReferencePoint {
83 pub const fn rtcm_ssr_default() -> Self {
85 Self::AntennaPhaseCenter
86 }
87
88 pub const fn igs_ssr_default() -> Self {
90 Self::CenterOfMass
91 }
92
93 pub const fn tag(self) -> u8 {
95 match self {
96 Self::AntennaPhaseCenter => 0,
97 Self::CenterOfMass => 1,
98 }
99 }
100
101 pub const fn from_tag(tag: u8) -> Option<Self> {
103 match tag {
104 0 => Some(Self::AntennaPhaseCenter),
105 1 => Some(Self::CenterOfMass),
106 _ => None,
107 }
108 }
109}
110
111pub type OrbitReferencePoint = SsrReferencePoint;
113
114#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
116pub enum SsrSatelliteAttitude {
117 #[default]
119 Unavailable,
120 NominalSunFixed,
126}
127
128#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
130pub struct SsrSolution {
131 pub source: SsrSource,
133 pub provider_id: u16,
135 pub solution_id: u8,
137}
138
139#[derive(Clone, Copy, Debug, PartialEq)]
141pub struct SsrOrbitCorrection {
142 pub solution: SsrSolution,
144 pub iode: u32,
146 pub iod_ssr: u8,
148 pub basis: OrbitBasis,
150 pub crs_regional: bool,
152 pub reference_point: SsrReferencePoint,
154 pub radial_m: f64,
156 pub along_m: f64,
158 pub cross_m: f64,
160 pub radial_rate_m_s: f64,
162 pub along_rate_m_s: f64,
164 pub cross_rate_m_s: f64,
166 pub ref_epoch_j2000_s: f64,
168 pub update_interval_s: f64,
170}
171
172#[derive(Clone, Copy, Debug, PartialEq)]
174pub struct SsrHighRateClock {
175 pub solution: SsrSolution,
177 pub iod_ssr: u8,
179 pub c0_m: f64,
181 pub ref_epoch_j2000_s: f64,
183 pub update_interval_s: f64,
185}
186
187#[derive(Clone, Copy, Debug, PartialEq)]
189pub struct SsrClockCorrection {
190 pub solution: SsrSolution,
192 pub iod_ssr: u8,
194 pub c0_m: f64,
196 pub c1_m_s: f64,
198 pub c2_m_s2: f64,
200 pub ref_epoch_j2000_s: f64,
202 pub update_interval_s: f64,
204 pub high_rate: Option<SsrHighRateClock>,
206}
207
208#[derive(Clone, Debug, Default, PartialEq)]
210pub struct SsrCodeBias {
211 biases_m: BTreeMap<u8, f64>,
212}
213
214#[derive(Clone, Debug, Default, PartialEq)]
216pub struct SsrPhaseBias {
217 biases_m: BTreeMap<u8, f64>,
218}
219
220#[derive(Clone, Debug, Default, PartialEq)]
221struct SatCorrections {
222 orbit: Option<SsrOrbitCorrection>,
223 clock: Option<SsrClockCorrection>,
224 pending_high_rate: Option<SsrHighRateClock>,
225 ura_index: Option<u8>,
226 code_bias: SsrCodeBias,
227 phase_bias: SsrPhaseBias,
228}
229
230#[derive(Clone, Debug, PartialEq)]
232pub struct SsrCorrectionStore {
233 corrections: BTreeMap<GnssSatelliteId, SatCorrections>,
234 reference_point: SsrReferencePoint,
235 staleness: StalenessPolicy,
236}
237
238impl Default for SsrCorrectionStore {
239 fn default() -> Self {
240 Self::new()
241 }
242}
243
244impl SsrCorrectionStore {
245 pub fn new() -> Self {
247 Self {
248 corrections: BTreeMap::new(),
249 reference_point: SsrReferencePoint::rtcm_ssr_default(),
250 staleness: StalenessPolicy::seconds(DEFAULT_SSR_STALENESS_S),
251 }
252 }
253
254 pub fn with_reference_point(mut self, reference_point: SsrReferencePoint) -> Self {
256 self.reference_point = reference_point;
257 self
258 }
259
260 pub fn reference_point(&self) -> SsrReferencePoint {
262 self.reference_point
263 }
264
265 pub fn with_staleness(mut self, policy: StalenessPolicy) -> Self {
267 self.staleness = policy;
268 self
269 }
270
271 pub fn staleness(&self) -> StalenessPolicy {
273 self.staleness
274 }
275
276 pub fn ingest(&mut self, message: &Message, week: GnssWeekTow) -> Result<()> {
278 if let Message::Ssr(ssr) = message {
279 self.ingest_ssr(ssr, week)?;
280 }
281 Ok(())
282 }
283
284 pub fn ingest_ssr(&mut self, message: &SsrMessage, week: GnssWeekTow) -> Result<()> {
286 let update_interval_s = update_interval_s(message.header.update_interval);
287 let ref_epoch_j2000_s = ssr_epoch_j2000_s(
288 message.system,
289 week,
290 message.header.epoch_time_s,
291 message.header.update_interval,
292 update_interval_s,
293 )?;
294 let solution = SsrSolution {
295 source: SsrSource::RtcmSsr,
296 provider_id: message.header.provider_id,
297 solution_id: message.header.solution_id,
298 };
299
300 match message.kind {
301 SsrKind::Orbit => {
302 for record in &message.orbit {
303 let sat = ssr_satellite(message.system, record.satellite_id)?;
304 let orbit = orbit_from_rtcm(
305 self.reference_point,
306 message,
307 solution,
308 record,
309 ref_epoch_j2000_s,
310 update_interval_s,
311 );
312 let entry = self.corrections.entry(sat).or_default();
313 entry.orbit = Some(orbit);
314 }
315 }
316 SsrKind::Clock => {
317 for record in &message.clock {
318 let sat = ssr_satellite(message.system, record.satellite_id)?;
319 let entry = self.corrections.entry(sat).or_default();
320 let mut clock = SsrClockCorrection {
321 solution,
322 iod_ssr: message.header.iod_ssr,
323 c0_m: f64::from(record.c0) * RTCM_SSR_RADIAL_CLOCK_SCALE_M,
324 c1_m_s: f64::from(record.c1) * RTCM_SSR_RADIAL_CLOCK_RATE_SCALE_M_S,
325 c2_m_s2: f64::from(record.c2) * RTCM_SSR_CLOCK_ACCEL_SCALE_M_S2,
326 ref_epoch_j2000_s,
327 update_interval_s,
328 high_rate: None,
329 };
330 if let Some(hr) = entry.pending_high_rate {
331 if high_rate_matches(&clock, &hr) {
332 clock.high_rate = Some(hr);
333 }
334 }
335 entry.clock = Some(clock);
336 }
337 }
338 SsrKind::CombinedOrbitClock => {
339 for (orbit_record, clock_record) in message.orbit.iter().zip(&message.clock) {
340 let sat = ssr_satellite(message.system, orbit_record.satellite_id)?;
341 let orbit = orbit_from_rtcm(
342 self.reference_point,
343 message,
344 solution,
345 orbit_record,
346 ref_epoch_j2000_s,
347 update_interval_s,
348 );
349 let entry = self.corrections.entry(sat).or_default();
350 entry.orbit = Some(orbit);
351 entry.clock = Some(SsrClockCorrection {
352 solution,
353 iod_ssr: message.header.iod_ssr,
354 c0_m: f64::from(clock_record.c0) * RTCM_SSR_RADIAL_CLOCK_SCALE_M,
355 c1_m_s: f64::from(clock_record.c1) * RTCM_SSR_RADIAL_CLOCK_RATE_SCALE_M_S,
356 c2_m_s2: f64::from(clock_record.c2) * RTCM_SSR_CLOCK_ACCEL_SCALE_M_S2,
357 ref_epoch_j2000_s,
358 update_interval_s,
359 high_rate: entry.pending_high_rate,
360 });
361 }
362 }
363 SsrKind::Ura => {
364 for &(satellite_id, ura_index) in &message.ura {
365 let sat = ssr_satellite(message.system, satellite_id)?;
366 self.corrections.entry(sat).or_default().ura_index = Some(ura_index);
367 }
368 }
369 SsrKind::CodeBias => {
370 for record in &message.code_bias {
371 let sat = ssr_satellite(message.system, record.satellite_id)?;
372 let entry = self.corrections.entry(sat).or_default();
373 for &(signal, bias) in &record.biases {
374 entry
375 .code_bias
376 .biases_m
377 .insert(signal, f64::from(bias) * RTCM_SSR_CODE_BIAS_SCALE_M);
378 }
379 }
380 }
381 SsrKind::HighRateClock => {
382 for record in &message.clock {
383 let sat = ssr_satellite(message.system, record.satellite_id)?;
384 let high_rate = SsrHighRateClock {
385 solution,
386 iod_ssr: message.header.iod_ssr,
387 c0_m: f64::from(record.c0) * RTCM_SSR_RADIAL_CLOCK_SCALE_M,
388 ref_epoch_j2000_s,
389 update_interval_s,
390 };
391 let entry = self.corrections.entry(sat).or_default();
392 entry.pending_high_rate = Some(high_rate);
393 if let Some(clock) = &mut entry.clock {
394 if high_rate_matches(clock, &high_rate) {
395 clock.high_rate = Some(high_rate);
396 }
397 }
398 }
399 }
400 SsrKind::PhaseBias => {
401 for record in &message.phase_bias {
402 let sat = ssr_satellite(message.system, record.satellite_id)?;
403 let entry = self.corrections.entry(sat).or_default();
404 for bias in &record.biases {
405 entry.phase_bias.biases_m.insert(
406 bias.signal_id,
407 f64::from(bias.bias) * RTCM_SSR_PHASE_BIAS_SCALE_M,
408 );
409 }
410 }
411 }
412 SsrKind::Vtec => {}
413 }
414 Ok(())
415 }
416
417 pub fn ingest_has_mt1(
419 &mut self,
420 message: &HasMt1Message,
421 reception_gst: GnssWeekTow,
422 ) -> Result<()> {
423 let ref_epoch_j2000_s = has_mt1_reference_j2000_s(reception_gst, message.header.toh_s)?;
424 let solution = SsrSolution {
425 source: SsrSource::GalileoHas,
426 provider_id: u16::from(message.header.mask_id),
427 solution_id: message.header.iod_set_id,
428 };
429 if let Some(orbit) = &message.orbit {
430 let update_interval_s = has_validity_interval_s(orbit.validity_interval)
431 .ok_or_else(|| Error::Parse("HAS orbit VI is reserved".to_string()))?;
432 for record in &orbit.records {
433 self.corrections.entry(record.sat).or_default().orbit = Some(SsrOrbitCorrection {
434 solution,
435 iode: record.iode,
436 iod_ssr: message.header.iod_set_id,
437 basis: OrbitBasis::VelocityAligned,
438 crs_regional: false,
439 reference_point: SsrReferencePoint::AntennaPhaseCenter,
440 radial_m: record.radial_m,
441 along_m: record.along_m,
442 cross_m: record.cross_m,
443 radial_rate_m_s: 0.0,
444 along_rate_m_s: 0.0,
445 cross_rate_m_s: 0.0,
446 ref_epoch_j2000_s,
447 update_interval_s,
448 });
449 }
450 }
451 for clock in [
452 message.clock_full_set.as_ref(),
453 message.clock_subset.as_ref(),
454 ]
455 .into_iter()
456 .flatten()
457 {
458 let update_interval_s = has_validity_interval_s(clock.validity_interval)
459 .ok_or_else(|| Error::Parse("HAS clock VI is reserved".to_string()))?;
460 for record in &clock.records {
461 self.corrections.entry(record.sat).or_default().clock = Some(SsrClockCorrection {
462 solution,
463 iod_ssr: message.header.iod_set_id,
464 c0_m: record.correction_m,
465 c1_m_s: 0.0,
466 c2_m_s2: 0.0,
467 ref_epoch_j2000_s,
468 update_interval_s,
469 high_rate: None,
470 });
471 }
472 }
473 if let Some(code_bias) = &message.code_bias {
474 for record in &code_bias.records {
475 self.corrections
476 .entry(record.sat)
477 .or_default()
478 .code_bias
479 .biases_m
480 .insert(record.signal_id, record.bias_m);
481 }
482 }
483 if let Some(phase_bias) = &message.phase_bias {
484 for record in &phase_bias.records {
485 self.corrections
486 .entry(record.sat)
487 .or_default()
488 .phase_bias
489 .biases_m
490 .insert(record.signal_id, record.bias_m);
491 }
492 }
493 Ok(())
494 }
495
496 pub fn orbit(&self, sat: GnssSatelliteId) -> Option<&SsrOrbitCorrection> {
498 self.corrections.get(&sat)?.orbit.as_ref()
499 }
500
501 pub fn clock(&self, sat: GnssSatelliteId) -> Option<&SsrClockCorrection> {
503 self.corrections.get(&sat)?.clock.as_ref()
504 }
505
506 pub fn ura_index(&self, sat: GnssSatelliteId) -> Option<u8> {
508 self.corrections.get(&sat)?.ura_index
509 }
510
511 pub fn code_bias(&self, sat: GnssSatelliteId, signal: u8) -> Option<f64> {
513 self.corrections
514 .get(&sat)?
515 .code_bias
516 .biases_m
517 .get(&signal)
518 .copied()
519 }
520
521 pub fn phase_bias(&self, sat: GnssSatelliteId, signal: u8) -> Option<f64> {
523 self.corrections
524 .get(&sat)?
525 .phase_bias
526 .biases_m
527 .get(&signal)
528 .copied()
529 }
530}
531
532fn orbit_from_rtcm(
533 reference_point: SsrReferencePoint,
534 message: &SsrMessage,
535 solution: SsrSolution,
536 record: &crate::rtcm::SsrOrbitRecord,
537 ref_epoch_j2000_s: f64,
538 update_interval_s: f64,
539) -> SsrOrbitCorrection {
540 SsrOrbitCorrection {
541 solution,
542 iode: record.iode,
543 iod_ssr: message.header.iod_ssr,
544 basis: OrbitBasis::VelocityAligned,
545 crs_regional: message.header.satellite_reference_datum.unwrap_or(false),
546 reference_point,
547 radial_m: -f64::from(record.delta_radial) * RTCM_SSR_RADIAL_CLOCK_SCALE_M,
548 along_m: -f64::from(record.delta_along) * RTCM_SSR_ALONG_CROSS_SCALE_M,
549 cross_m: -f64::from(record.delta_cross) * RTCM_SSR_ALONG_CROSS_SCALE_M,
550 radial_rate_m_s: -f64::from(record.dot_delta_radial) * RTCM_SSR_RADIAL_CLOCK_RATE_SCALE_M_S,
551 along_rate_m_s: -f64::from(record.dot_delta_along) * RTCM_SSR_ALONG_CROSS_RATE_SCALE_M_S,
552 cross_rate_m_s: -f64::from(record.dot_delta_cross) * RTCM_SSR_ALONG_CROSS_RATE_SCALE_M_S,
553 ref_epoch_j2000_s,
554 update_interval_s,
555 }
556}
557
558#[derive(Clone, Copy, Debug, PartialEq, Eq)]
560pub enum MissingCorrectionAction {
561 Decline,
563 FallBackToBroadcast,
565}
566
567#[derive(Clone, Debug, PartialEq, Eq)]
569pub enum RegionalPolicy {
570 DeclineRegional,
572 AllowProviders(BTreeSet<u16>),
574}
575
576#[derive(Clone, Debug, PartialEq, Eq)]
578pub struct SsrFallbackPolicy {
579 pub on_missing_correction: MissingCorrectionAction,
581 pub regional: RegionalPolicy,
583}
584
585impl Default for SsrFallbackPolicy {
586 fn default() -> Self {
587 Self {
588 on_missing_correction: MissingCorrectionAction::Decline,
589 regional: RegionalPolicy::DeclineRegional,
590 }
591 }
592}
593
594#[derive(Clone)]
596pub struct SsrCorrectedEphemeris<'a> {
597 broadcast: &'a BroadcastEphemeris,
598 store: &'a SsrCorrectionStore,
599 antex: Option<&'a Antex>,
600 attitude: SsrSatelliteAttitude,
601 staleness: StalenessPolicy,
602 fallback: SsrFallbackPolicy,
603}
604
605impl<'a> SsrCorrectedEphemeris<'a> {
606 pub fn new(broadcast: &'a BroadcastEphemeris, store: &'a SsrCorrectionStore) -> Self {
608 Self {
609 broadcast,
610 store,
611 antex: None,
612 attitude: SsrSatelliteAttitude::Unavailable,
613 staleness: store.staleness(),
614 fallback: SsrFallbackPolicy::default(),
615 }
616 }
617
618 pub fn with_satellite_antennas(mut self, antex: &'a Antex) -> Self {
620 self.antex = Some(antex);
621 self
622 }
623
624 pub fn with_satellite_attitude(mut self, attitude: SsrSatelliteAttitude) -> Self {
626 self.attitude = attitude;
627 self
628 }
629
630 pub fn with_staleness(mut self, policy: StalenessPolicy) -> Self {
632 self.staleness = policy;
633 self
634 }
635
636 pub fn with_fallback(mut self, policy: SsrFallbackPolicy) -> Self {
638 self.fallback = policy;
639 self
640 }
641
642 pub fn allow_regional_provider(mut self, provider_id: u16) -> Self {
644 match &mut self.fallback.regional {
645 RegionalPolicy::DeclineRegional => {
646 let mut providers = BTreeSet::new();
647 providers.insert(provider_id);
648 self.fallback.regional = RegionalPolicy::AllowProviders(providers);
649 }
650 RegionalPolicy::AllowProviders(providers) => {
651 providers.insert(provider_id);
652 }
653 }
654 self
655 }
656
657 pub fn corrected_state(&self, sat: GnssSatelliteId, t_j2000_s: f64) -> Option<([f64; 3], f64)> {
659 self.corrected_state_inner(sat, t_j2000_s)
660 .or_else(|| self.broadcast_fallback_after_failure(sat, t_j2000_s))
661 }
662
663 fn corrected_state_inner(
664 &self,
665 sat: GnssSatelliteId,
666 t_j2000_s: f64,
667 ) -> Option<([f64; 3], f64)> {
668 let orbit = self.store.orbit(sat)?;
669 let clock = self.store.clock(sat)?;
670 if orbit.solution != clock.solution || orbit.iod_ssr != clock.iod_ssr {
671 return None;
672 }
673 if !self.correction_fresh(t_j2000_s, orbit.ref_epoch_j2000_s, orbit.update_interval_s) {
674 return None;
675 }
676 if !self.correction_fresh(t_j2000_s, clock.ref_epoch_j2000_s, clock.update_interval_s) {
677 return None;
678 }
679 if orbit.crs_regional && !self.regional_allowed(orbit.solution.provider_id) {
680 return None;
681 }
682
683 let nav_message = default_nav_message(sat.system)?;
684 let issue = BroadcastIssue {
685 issue: orbit.iode,
686 message: nav_message,
687 };
688 let record = self
689 .broadcast
690 .select_by_issue_at(sat, issue, nav_message, t_j2000_s)?;
691 let (t_continuous_s, is_geo) = continuous_time_for_sat(sat, t_j2000_s)?;
692 let sow = t_continuous_s.rem_euclid(SECONDS_PER_WEEK);
693 let state = satellite_state_unchecked(
694 &record.elements,
695 &record.clock,
696 &record.constants(),
697 sow,
698 record.broadcast_clock_group_delay_s(),
699 is_geo,
700 );
701 let r = state.orbit.position().ok()?.as_array();
702 let v = broadcast_velocity(record, sat, t_j2000_s, is_geo)?;
703 let (er, ea, ec) = velocity_aligned_basis(r, v)?;
704 let dt_orbit = t_j2000_s - orbit.ref_epoch_j2000_s;
705 let radial = orbit.radial_m + orbit.radial_rate_m_s * dt_orbit;
706 let along = orbit.along_m + orbit.along_rate_m_s * dt_orbit;
707 let cross = orbit.cross_m + orbit.cross_rate_m_s * dt_orbit;
708 let mut corrected_position = [
709 r[0] + radial * er[0] + along * ea[0] + cross * ec[0],
710 r[1] + radial * er[1] + along * ea[1] + cross * ec[1],
711 r[2] + radial * er[2] + along * ea[2] + cross * ec[2],
712 ];
713 if orbit.reference_point == SsrReferencePoint::CenterOfMass {
714 let pco_ecef_m = self.satellite_pco_to_apc(sat, t_j2000_s, corrected_position)?;
715 corrected_position = add3(corrected_position, pco_ecef_m);
716 }
717
718 let dt_clock = t_j2000_s - clock.ref_epoch_j2000_s;
719 let mut dclock_m =
720 clock.c0_m + clock.c1_m_s * dt_clock + clock.c2_m_s2 * dt_clock * dt_clock;
721 if let Some(high_rate) = clock.high_rate {
722 if high_rate_matches(clock, &high_rate)
723 && self.correction_fresh(
724 t_j2000_s,
725 high_rate.ref_epoch_j2000_s,
726 high_rate.update_interval_s,
727 )
728 {
729 dclock_m += high_rate.c0_m;
730 }
731 }
732 let corrected_clock_s = match clock.solution.source {
733 SsrSource::RtcmSsr => state.clock.dt_clock_total_s - dclock_m / C_M_S,
734 SsrSource::GalileoHas => state.clock.dt_clock_total_s + dclock_m / C_M_S,
735 };
736 Some((corrected_position, corrected_clock_s))
737 }
738
739 fn correction_fresh(
740 &self,
741 t_j2000_s: f64,
742 ref_epoch_j2000_s: f64,
743 update_interval_s: f64,
744 ) -> bool {
745 let age = (t_j2000_s - ref_epoch_j2000_s).abs();
746 age.is_finite()
747 && update_interval_s.is_finite()
748 && update_interval_s >= 0.0
749 && age <= self.staleness.max_staleness_s.min(update_interval_s)
750 }
751
752 fn regional_allowed(&self, provider_id: u16) -> bool {
753 match &self.fallback.regional {
754 RegionalPolicy::DeclineRegional => false,
755 RegionalPolicy::AllowProviders(providers) => providers.contains(&provider_id),
756 }
757 }
758
759 fn broadcast_fallback_after_failure(
760 &self,
761 sat: GnssSatelliteId,
762 t_j2000_s: f64,
763 ) -> Option<([f64; 3], f64)> {
764 if self
765 .store
766 .orbit(sat)
767 .is_some_and(|orbit| orbit.reference_point == SsrReferencePoint::CenterOfMass)
768 {
769 return None;
770 }
771 if self.fallback.on_missing_correction == MissingCorrectionAction::FallBackToBroadcast {
772 self.broadcast.position_clock_at_j2000_s(sat, t_j2000_s)
773 } else {
774 None
775 }
776 }
777
778 fn satellite_pco_to_apc(
779 &self,
780 sat: GnssSatelliteId,
781 t_j2000_s: f64,
782 sat_position_ecef_m: [f64; 3],
783 ) -> Option<[f64; 3]> {
784 if self.attitude != SsrSatelliteAttitude::NominalSunFixed {
785 return None;
786 }
787 let antex = self.antex?;
788 let epoch = antex_epoch_from_j2000_gpst(t_j2000_s)?;
789 let antenna = antex.satellite_antenna(&sat.to_string(), epoch)?;
790 let frequency = ssr_apc_frequency(sat.system)?;
791 let pco_body_m = antenna.pco(frequency).ok()?;
792 let ts = time_scales_from_j2000_gpst(t_j2000_s)?;
793 let sun_ecef_m = sun_moon_ecef(&ts).ok()?.sun;
794 satellite_body_pco_to_ecef(pco_body_m, sat_position_ecef_m, sun_ecef_m)
795 }
796}
797
798impl EphemerisSource for SsrCorrectedEphemeris<'_> {
799 fn position_clock_at_j2000_s(
800 &self,
801 sat: GnssSatelliteId,
802 t_j2000_s: f64,
803 ) -> Option<([f64; 3], f64)> {
804 self.corrected_state(sat, t_j2000_s)
805 }
806}
807
808impl ObservableEphemerisSource for SsrCorrectedEphemeris<'_> {
809 fn observable_state_at_j2000_s(
810 &self,
811 sat: GnssSatelliteId,
812 t_j2000_s: f64,
813 ) -> std::result::Result<ObservableState, ObservablesError> {
814 let Some((position_ecef_m, clock_s)) = self.corrected_state(sat, t_j2000_s) else {
815 return Err(ObservablesError::NoEphemeris);
816 };
817 Ok(ObservableState {
818 position_ecef_m,
819 clock_s: Some(clock_s),
820 })
821 }
822}
823
824#[derive(Clone)]
826pub struct SsrCorrectedEphemerisOwned {
827 broadcast: Arc<BroadcastEphemeris>,
828 store: Arc<SsrCorrectionStore>,
829 antex: Option<Arc<Antex>>,
830 attitude: SsrSatelliteAttitude,
831 staleness: StalenessPolicy,
832 fallback: SsrFallbackPolicy,
833}
834
835impl SsrCorrectedEphemerisOwned {
836 pub fn new(broadcast: Arc<BroadcastEphemeris>, store: Arc<SsrCorrectionStore>) -> Self {
838 let staleness = store.staleness();
839 Self {
840 broadcast,
841 store,
842 antex: None,
843 attitude: SsrSatelliteAttitude::Unavailable,
844 staleness,
845 fallback: SsrFallbackPolicy::default(),
846 }
847 }
848
849 pub fn with_satellite_antennas(mut self, antex: Arc<Antex>) -> Self {
851 self.antex = Some(antex);
852 self
853 }
854
855 pub fn with_satellite_attitude(mut self, attitude: SsrSatelliteAttitude) -> Self {
857 self.attitude = attitude;
858 self
859 }
860
861 pub fn with_staleness(mut self, policy: StalenessPolicy) -> Self {
863 self.staleness = policy;
864 self
865 }
866
867 pub fn with_fallback(mut self, policy: SsrFallbackPolicy) -> Self {
869 self.fallback = policy;
870 self
871 }
872
873 pub fn allow_regional_provider(mut self, provider_id: u16) -> Self {
875 match &mut self.fallback.regional {
876 RegionalPolicy::DeclineRegional => {
877 let mut providers = BTreeSet::new();
878 providers.insert(provider_id);
879 self.fallback.regional = RegionalPolicy::AllowProviders(providers);
880 }
881 RegionalPolicy::AllowProviders(providers) => {
882 providers.insert(provider_id);
883 }
884 }
885 self
886 }
887
888 fn borrowed(&self) -> SsrCorrectedEphemeris<'_> {
889 let source = SsrCorrectedEphemeris::new(&self.broadcast, &self.store)
890 .with_staleness(self.staleness)
891 .with_fallback(self.fallback.clone())
892 .with_satellite_attitude(self.attitude);
893 if let Some(antex) = &self.antex {
894 source.with_satellite_antennas(antex)
895 } else {
896 source
897 }
898 }
899}
900
901impl EphemerisSource for SsrCorrectedEphemerisOwned {
902 fn position_clock_at_j2000_s(
903 &self,
904 sat: GnssSatelliteId,
905 t_j2000_s: f64,
906 ) -> Option<([f64; 3], f64)> {
907 self.borrowed().position_clock_at_j2000_s(sat, t_j2000_s)
908 }
909}
910
911impl ObservableEphemerisSource for SsrCorrectedEphemerisOwned {
912 fn observable_state_at_j2000_s(
913 &self,
914 sat: GnssSatelliteId,
915 t_j2000_s: f64,
916 ) -> std::result::Result<ObservableState, ObservablesError> {
917 self.borrowed().observable_state_at_j2000_s(sat, t_j2000_s)
918 }
919}
920
921fn update_interval_s(index: u8) -> f64 {
922 const TABLE: [f64; 16] = [
923 1.0,
924 2.0,
925 5.0,
926 10.0,
927 15.0,
928 30.0,
929 60.0,
930 120.0,
931 240.0,
932 300.0,
933 600.0,
934 900.0,
935 1800.0,
936 SECONDS_PER_HOUR,
937 7200.0,
938 10800.0,
939 ];
940 TABLE[usize::from(index)]
941}
942
943fn ssr_epoch_j2000_s(
944 system: GnssSystem,
945 week: GnssWeekTow,
946 epoch_time_s: u32,
947 update_interval: u8,
948 update_interval_s: f64,
949) -> Result<f64> {
950 let scale = match system {
951 GnssSystem::Galileo => TimeScale::Gst,
952 GnssSystem::BeiDou => TimeScale::Bdt,
953 _ => TimeScale::Gpst,
954 };
955 let epoch_offset_s = if update_interval == 0 {
956 0.0
957 } else {
958 update_interval_s / 2.0
959 };
960 let normalized = GnssWeekTow::new(scale, week.week, f64::from(epoch_time_s) + epoch_offset_s)
961 .and_then(GnssWeekTow::normalized)
962 .map_err(|_| Error::Parse("SSR epoch is out of range".to_string()))?;
963 let continuous = f64::from(normalized.week) * SECONDS_PER_WEEK + normalized.tow_s;
964 Ok(match system {
965 GnssSystem::BeiDou => {
966 continuous
967 + crate::constants::BDS_EPOCH_MINUS_GPS_EPOCH_S
968 + crate::constants::GPST_MINUS_BDT_S
969 - GPS_EPOCH_TO_J2000_S
970 }
971 _ => continuous - GPS_EPOCH_TO_J2000_S,
972 })
973}
974
975fn ssr_satellite(system: GnssSystem, satellite_id: u8) -> Result<GnssSatelliteId> {
976 GnssSatelliteId::new(system, satellite_id)
977 .map_err(|e| Error::Parse(format!("invalid SSR satellite id {satellite_id}: {e}")))
978}
979
980fn high_rate_matches(clock: &SsrClockCorrection, high_rate: &SsrHighRateClock) -> bool {
981 clock.solution == high_rate.solution && clock.iod_ssr == high_rate.iod_ssr
982}
983
984fn default_nav_message(system: GnssSystem) -> Option<NavMessage> {
985 match system {
986 GnssSystem::Gps => Some(NavMessage::GpsLnav),
987 GnssSystem::Galileo => Some(NavMessage::GalileoInav),
988 GnssSystem::BeiDou => Some(NavMessage::BeidouD1),
989 _ => None,
990 }
991}
992
993fn ssr_apc_frequency(system: GnssSystem) -> Option<&'static str> {
994 match system {
995 GnssSystem::Gps => Some("G01"),
996 GnssSystem::Glonass => Some("R01"),
997 GnssSystem::Galileo => Some("E01"),
998 GnssSystem::BeiDou => Some("C02"),
999 GnssSystem::Qzss => Some("J01"),
1000 GnssSystem::Navic => Some("I05"),
1001 GnssSystem::Sbas => Some("S01"),
1002 }
1003}
1004
1005fn antex_epoch_from_j2000_gpst(t_j2000_s: f64) -> Option<AntexDateTime> {
1006 let (year, month, day, hour, minute, second) = civil_fields_from_j2000_gpst(t_j2000_s)?;
1007 AntexDateTime::new(year, month, day, hour, minute, second.trunc() as u8).ok()
1008}
1009
1010fn time_scales_from_j2000_gpst(t_j2000_s: f64) -> Option<TimeScales> {
1011 let (year, month, day, hour, minute, second) = civil_fields_from_j2000_gpst(t_j2000_s)?;
1012 TimeScales::from_scale(
1013 TimeScale::Gpst,
1014 year,
1015 i32::from(month),
1016 i32::from(day),
1017 i32::from(hour),
1018 i32::from(minute),
1019 second,
1020 )
1021 .ok()
1022}
1023
1024fn civil_fields_from_j2000_gpst(t_j2000_s: f64) -> Option<(i32, u8, u8, u8, u8, f64)> {
1025 if !t_j2000_s.is_finite() {
1026 return None;
1027 }
1028 let whole = t_j2000_s.floor();
1029 if whole < i64::MIN as f64 || whole > i64::MAX as f64 {
1030 return None;
1031 }
1032 let fraction = t_j2000_s - whole;
1033 let (year, month, day, hour, minute, second) = civil_from_j2000_seconds(whole as i64);
1034 Some((
1035 i32::try_from(year).ok()?,
1036 u8::try_from(month).ok()?,
1037 u8::try_from(day).ok()?,
1038 u8::try_from(hour).ok()?,
1039 u8::try_from(minute).ok()?,
1040 second as f64 + fraction,
1041 ))
1042}
1043
1044fn continuous_time_for_sat(sat: GnssSatelliteId, t_j2000_s: f64) -> Option<(f64, bool)> {
1045 if !matches!(
1046 sat.system,
1047 GnssSystem::Gps | GnssSystem::Galileo | GnssSystem::BeiDou
1048 ) {
1049 return None;
1050 }
1051 let gpst_continuous = t_j2000_s + GPS_EPOCH_TO_J2000_S;
1052 if sat.system == GnssSystem::BeiDou {
1053 Some((
1054 gpst_continuous
1055 - crate::constants::GPST_MINUS_BDT_S
1056 - crate::constants::BDS_EPOCH_MINUS_GPS_EPOCH_S,
1057 is_beidou_geo(sat),
1058 ))
1059 } else {
1060 Some((gpst_continuous, false))
1061 }
1062}
1063
1064fn broadcast_velocity(
1065 record: &crate::rinex_nav::BroadcastRecord,
1066 sat: GnssSatelliteId,
1067 t_j2000_s: f64,
1068 is_geo: bool,
1069) -> Option<[f64; 3]> {
1070 let p_plus = broadcast_position_from_record(record, sat, t_j2000_s + FD_HALF_S, is_geo)?;
1071 let p_minus = broadcast_position_from_record(record, sat, t_j2000_s - FD_HALF_S, is_geo)?;
1072 let denom = 2.0 * FD_HALF_S;
1073 Some([
1074 (p_plus[0] - p_minus[0]) / denom,
1075 (p_plus[1] - p_minus[1]) / denom,
1076 (p_plus[2] - p_minus[2]) / denom,
1077 ])
1078}
1079
1080fn broadcast_position_from_record(
1081 record: &crate::rinex_nav::BroadcastRecord,
1082 sat: GnssSatelliteId,
1083 t_j2000_s: f64,
1084 is_geo: bool,
1085) -> Option<[f64; 3]> {
1086 let (t_continuous_s, _) = continuous_time_for_sat(sat, t_j2000_s)?;
1087 let sow = t_continuous_s.rem_euclid(SECONDS_PER_WEEK);
1088 satellite_state_unchecked(
1089 &record.elements,
1090 &record.clock,
1091 &record.constants(),
1092 sow,
1093 record.broadcast_clock_group_delay_s(),
1094 is_geo,
1095 )
1096 .orbit
1097 .position()
1098 .ok()
1099 .map(|p| p.as_array())
1100}
1101
1102fn velocity_aligned_basis(r: [f64; 3], v: [f64; 3]) -> Option<([f64; 3], [f64; 3], [f64; 3])> {
1103 let ea = normalize(v)?;
1104 let rc = cross(r, v);
1105 let ec = normalize(rc)?;
1106 let er = cross(ea, ec);
1107 Some((er, ea, ec))
1108}
1109
1110fn normalize(v: [f64; 3]) -> Option<[f64; 3]> {
1111 let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
1112 if n > 0.0 && n.is_finite() {
1113 Some([v[0] / n, v[1] / n, v[2] / n])
1114 } else {
1115 None
1116 }
1117}
1118
1119fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
1120 [
1121 a[1] * b[2] - a[2] * b[1],
1122 a[2] * b[0] - a[0] * b[2],
1123 a[0] * b[1] - a[1] * b[0],
1124 ]
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129 use super::*;
1130 use crate::astro::math::vec3::dot3;
1131 use crate::constants::{F_L1_HZ, F_L2_HZ};
1132 use crate::has::{
1133 HasClockBlock, HasClockCorrection, HasCodeBias, HasCodeBiasBlock, HasGnssMask,
1134 HasMaskBlock, HasMt1Header, HasMt1Message, HasOrbitBlock, HasOrbitCorrection, HasPhaseBias,
1135 HasPhaseBiasBlock,
1136 };
1137 use crate::rtcm::{
1138 Message, SsrClockRecord, SsrHeader, SsrOrbitRecord, SsrPhaseBiasRecord, SsrPhaseBiasSignal,
1139 SsrStreamAssembler,
1140 };
1141 use crate::sp3::Sp3;
1142
1143 const REAL_SSRA02IGS0_1060_FRAME_HEX: &str = include_str!(concat!(
1144 env!("CARGO_MANIFEST_DIR"),
1145 "/tests/fixtures/ssr/SSRA02IGS0_2026181234930_1060.hex"
1146 ));
1147 const REAL_SSR_WEEK: u32 = 2425;
1148 const REAL_SSR_EPOCH_TOW_S: f64 = 344_970.0;
1149 const GPS_ANTEX_TEXT: &str = include_str!(concat!(
1150 env!("CARGO_MANIFEST_DIR"),
1151 "/tests/fixtures/antex/igs20_wettzell_trim.atx"
1152 ));
1153
1154 fn header(kind: SsrKind) -> SsrHeader {
1155 SsrHeader {
1156 epoch_time_s: 100_000,
1157 update_interval: 3,
1158 multiple_message: false,
1159 iod_ssr: 4,
1160 provider_id: 7,
1161 solution_id: 2,
1162 satellite_reference_datum: matches!(kind, SsrKind::Orbit | SsrKind::CombinedOrbitClock)
1163 .then_some(false),
1164 dispersive_bias_consistency: None,
1165 mw_consistency: None,
1166 satellite_count: 1,
1167 }
1168 }
1169
1170 #[test]
1171 fn rtcm_ingest_scales_orbit_and_clock() {
1172 let message = SsrMessage {
1173 message_number: 1060,
1174 system: GnssSystem::Gps,
1175 kind: SsrKind::CombinedOrbitClock,
1176 header: header(SsrKind::CombinedOrbitClock),
1177 orbit: vec![SsrOrbitRecord {
1178 satellite_id: 1,
1179 iode: 42,
1180 delta_radial: 10_000,
1181 delta_along: -20_000,
1182 delta_cross: 30_000,
1183 dot_delta_radial: 100,
1184 dot_delta_along: -200,
1185 dot_delta_cross: 300,
1186 }],
1187 clock: vec![SsrClockRecord {
1188 satellite_id: 1,
1189 c0: 10_000,
1190 c1: -2_000,
1191 c2: 300,
1192 }],
1193 code_bias: Vec::new(),
1194 phase_bias: Vec::<SsrPhaseBiasRecord>::new(),
1195 ura: Vec::new(),
1196 padding_bits: Vec::new(),
1197 };
1198 let mut store = SsrCorrectionStore::new();
1199 let week = GnssWeekTow::new(TimeScale::Gpst, 2_400, 100_000.0).unwrap();
1200 store.ingest_ssr(&message, week).unwrap();
1201 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).unwrap();
1202 let orbit = store.orbit(sat).unwrap();
1203 assert_eq!(orbit.iode, 42);
1204 assert_eq!(orbit.reference_point, SsrReferencePoint::rtcm_ssr_default());
1205 assert_eq!(orbit.radial_m.to_bits(), (-1.0_f64).to_bits());
1206 assert_eq!(orbit.along_m.to_bits(), 8.0_f64.to_bits());
1207 assert_eq!(orbit.cross_m.to_bits(), (-12.0_f64).to_bits());
1208 assert!((orbit.radial_rate_m_s + 1.0e-4).abs() < 1.0e-18);
1209 let clock = store.clock(sat).unwrap();
1210 assert_eq!(clock.c0_m.to_bits(), 1.0_f64.to_bits());
1211 assert!((clock.c1_m_s + 0.002).abs() < 1.0e-18);
1212 assert!((clock.c2_m_s2 - 6.0e-6).abs() < 1.0e-18);
1213 }
1214
1215 #[test]
1216 fn reference_point_tag_round_trips_through_store_ingest() {
1217 let message = SsrMessage {
1218 message_number: 1057,
1219 system: GnssSystem::Gps,
1220 kind: SsrKind::Orbit,
1221 header: header(SsrKind::Orbit),
1222 orbit: vec![SsrOrbitRecord {
1223 satellite_id: 1,
1224 iode: 42,
1225 delta_radial: 0,
1226 delta_along: 0,
1227 delta_cross: 0,
1228 dot_delta_radial: 0,
1229 dot_delta_along: 0,
1230 dot_delta_cross: 0,
1231 }],
1232 clock: Vec::new(),
1233 code_bias: Vec::new(),
1234 phase_bias: Vec::<SsrPhaseBiasRecord>::new(),
1235 ura: Vec::new(),
1236 padding_bits: Vec::new(),
1237 };
1238 let mut store =
1239 SsrCorrectionStore::new().with_reference_point(SsrReferencePoint::igs_ssr_default());
1240 let week = GnssWeekTow::new(TimeScale::Gpst, 2_400, 100_000.0).unwrap();
1241 store.ingest_ssr(&message, week).unwrap();
1242 let tag = store.reference_point().tag();
1243 assert_eq!(
1244 SsrReferencePoint::from_tag(tag),
1245 Some(SsrReferencePoint::CenterOfMass)
1246 );
1247 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).unwrap();
1248 assert_eq!(
1249 store.clone().orbit(sat).unwrap().reference_point,
1250 SsrReferencePoint::CenterOfMass
1251 );
1252 }
1253
1254 #[test]
1255 fn interval_zero_epoch_uses_transmitted_time_not_midpoint() {
1256 let week = GnssWeekTow::new(TimeScale::Gpst, 2_400, 0.0).unwrap();
1257 let got_zero =
1258 ssr_epoch_j2000_s(GnssSystem::Gps, week, 100_000, 0, update_interval_s(0)).unwrap();
1259 let expected = f64::from(week.week) * SECONDS_PER_WEEK + 100_000.0 - GPS_EPOCH_TO_J2000_S;
1260 assert_eq!(got_zero.to_bits(), expected.to_bits());
1261
1262 let got_nonzero =
1263 ssr_epoch_j2000_s(GnssSystem::Gps, week, 100_000, 1, update_interval_s(1)).unwrap();
1264 assert_eq!(got_nonzero.to_bits(), (expected + 1.0).to_bits());
1265 }
1266
1267 #[test]
1268 fn satellite_pco_projection_matches_hand_geometry() {
1269 let sat_position_ecef_m = [1.0, 0.0, 0.0];
1270 let sun_ecef_m = [1.0, 1.0, 0.0];
1271 let pco_body_m = [0.25, 0.5, 0.75];
1272 let shift = satellite_body_pco_to_ecef(pco_body_m, sat_position_ecef_m, sun_ecef_m)
1273 .expect("non-degenerate satellite-Sun axes");
1274 assert_eq!(
1275 shift.map(f64::to_bits),
1276 [
1277 (-0.75_f64).to_bits(),
1278 0.25_f64.to_bits(),
1279 (-0.5_f64).to_bits()
1280 ]
1281 );
1282 let los_unit = [0.0, 0.0, 1.0];
1283 assert_eq!(dot3(shift, los_unit).to_bits(), (-0.5_f64).to_bits());
1284 }
1285
1286 #[test]
1287 fn satellite_pco_projection_declines_near_degenerate_axes() {
1288 assert!(satellite_body_pco_to_ecef(
1289 [0.25, 0.5, 0.75],
1290 [1.0, 0.0, 0.0],
1291 [0.0, 1.0e-15, 0.0],
1292 )
1293 .is_none());
1294 }
1295
1296 #[test]
1297 fn high_rate_clock_is_additive_when_identity_matches() {
1298 let mut store = SsrCorrectionStore::new();
1299 let week = GnssWeekTow::new(TimeScale::Gpst, 2_400, 100_000.0).unwrap();
1300 let low = SsrMessage {
1301 message_number: 1058,
1302 system: GnssSystem::Gps,
1303 kind: SsrKind::Clock,
1304 header: header(SsrKind::Clock),
1305 orbit: Vec::new(),
1306 clock: vec![SsrClockRecord {
1307 satellite_id: 1,
1308 c0: 1_000,
1309 c1: 0,
1310 c2: 0,
1311 }],
1312 code_bias: Vec::new(),
1313 phase_bias: Vec::<SsrPhaseBiasRecord>::new(),
1314 ura: Vec::new(),
1315 padding_bits: Vec::new(),
1316 };
1317 let high = SsrMessage {
1318 message_number: 1062,
1319 system: GnssSystem::Gps,
1320 kind: SsrKind::HighRateClock,
1321 header: header(SsrKind::HighRateClock),
1322 orbit: Vec::new(),
1323 clock: vec![SsrClockRecord {
1324 satellite_id: 1,
1325 c0: 500,
1326 c1: 0,
1327 c2: 0,
1328 }],
1329 code_bias: Vec::new(),
1330 phase_bias: vec![SsrPhaseBiasRecord {
1331 satellite_id: 1,
1332 yaw_angle: 0,
1333 yaw_rate: 0,
1334 biases: vec![SsrPhaseBiasSignal {
1335 signal_id: 1,
1336 integer_indicator: 0,
1337 wide_lane_integer_indicator: 0,
1338 discontinuity_counter: 0,
1339 bias: 0,
1340 }],
1341 }],
1342 ura: Vec::new(),
1343 padding_bits: Vec::new(),
1344 };
1345 store.ingest_ssr(&high, week).unwrap();
1346 store.ingest_ssr(&low, week).unwrap();
1347 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).unwrap();
1348 assert_eq!(
1349 store.clock(sat).unwrap().high_rate.unwrap().c0_m.to_bits(),
1350 0.05_f64.to_bits()
1351 );
1352 }
1353
1354 #[test]
1355 fn rtcm_binary_orbit_clock_correction_matches_analytic_rac_application() {
1356 let nav_text = std::fs::read_to_string(concat!(
1357 env!("CARGO_MANIFEST_DIR"),
1358 "/tests/fixtures/ssr/BRDC00WRD_S_20261820000_G30_G31.rnx"
1359 ))
1360 .expect("read NAV fixture");
1361 let broadcast = BroadcastEphemeris::from_nav(&nav_text).expect("parse NAV fixture");
1362 let sat = GnssSatelliteId::new(GnssSystem::Gps, 30).unwrap();
1363 let t = ssr_j2000(REAL_SSR_EPOCH_TOW_S);
1364 let record = broadcast
1365 .select_record_at(sat, t)
1366 .expect("broadcast record at SSR epoch");
1367 let iode = record.issue_of_data.issue;
1368
1369 let wanted_rac_m = [2.0, -4.0, 1.2];
1370 let clock_correction_m = 0.5;
1371 let message = Message::Ssr(SsrMessage {
1372 message_number: 1060,
1373 system: GnssSystem::Gps,
1374 kind: SsrKind::CombinedOrbitClock,
1375 header: SsrHeader {
1376 epoch_time_s: REAL_SSR_EPOCH_TOW_S as u32,
1377 update_interval: 0,
1378 multiple_message: false,
1379 iod_ssr: 3,
1380 provider_id: 9,
1381 solution_id: 1,
1382 satellite_reference_datum: Some(false),
1383 dispersive_bias_consistency: None,
1384 mw_consistency: None,
1385 satellite_count: 1,
1386 },
1387 orbit: vec![SsrOrbitRecord {
1388 satellite_id: sat.prn,
1389 iode,
1390 delta_radial: -20_000,
1391 delta_along: 10_000,
1392 delta_cross: -3_000,
1393 dot_delta_radial: 0,
1394 dot_delta_along: 0,
1395 dot_delta_cross: 0,
1396 }],
1397 clock: vec![SsrClockRecord {
1398 satellite_id: sat.prn,
1399 c0: 5_000,
1400 c1: 0,
1401 c2: 0,
1402 }],
1403 code_bias: Vec::new(),
1404 phase_bias: Vec::<SsrPhaseBiasRecord>::new(),
1405 ura: Vec::new(),
1406 padding_bits: Vec::new(),
1407 });
1408 let frame = message.to_frame().expect("frame RTCM SSR");
1409 let mut assembler = SsrStreamAssembler::new();
1410 let mut store = SsrCorrectionStore::new();
1411 let week = GnssWeekTow::new(TimeScale::Gpst, REAL_SSR_WEEK, REAL_SSR_EPOCH_TOW_S)
1412 .expect("valid SSR week");
1413 for decoded in assembler.push(&frame) {
1414 let decoded = decoded.expect("decode RTCM SSR frame");
1415 store.ingest(&decoded, week).expect("ingest RTCM SSR frame");
1416 }
1417
1418 let source = SsrCorrectedEphemeris::new(&broadcast, &store);
1419 let (corrected_position, corrected_clock) =
1420 source.corrected_state(sat, t).expect("corrected state");
1421 let (broadcast_position, broadcast_clock) = broadcast
1422 .position_clock_at_j2000_s(sat, t)
1423 .expect("broadcast state");
1424 let velocity = finite_difference_broadcast_velocity(&broadcast, sat, t);
1425 let (er, ea, ec) = analytic_velocity_aligned_basis(broadcast_position, velocity);
1426 let expected_position = [
1427 broadcast_position[0]
1428 + wanted_rac_m[0] * er[0]
1429 + wanted_rac_m[1] * ea[0]
1430 + wanted_rac_m[2] * ec[0],
1431 broadcast_position[1]
1432 + wanted_rac_m[0] * er[1]
1433 + wanted_rac_m[1] * ea[1]
1434 + wanted_rac_m[2] * ec[1],
1435 broadcast_position[2]
1436 + wanted_rac_m[0] * er[2]
1437 + wanted_rac_m[1] * ea[2]
1438 + wanted_rac_m[2] * ec[2],
1439 ];
1440 assert_vector_close(corrected_position, expected_position, 2.0e-9);
1441 assert!((corrected_clock - (broadcast_clock - clock_correction_m / C_M_S)).abs() < 1.0e-18);
1442 }
1443
1444 #[test]
1445 fn rtcm_iode_mismatch_declines_or_falls_back_without_applying_correction() {
1446 let nav_text = std::fs::read_to_string(concat!(
1447 env!("CARGO_MANIFEST_DIR"),
1448 "/tests/fixtures/ssr/BRDC00WRD_S_20261820000_G30_G31.rnx"
1449 ))
1450 .expect("read NAV fixture");
1451 let broadcast = BroadcastEphemeris::from_nav(&nav_text).expect("parse NAV fixture");
1452 let sat = GnssSatelliteId::new(GnssSystem::Gps, 30).unwrap();
1453 let t = ssr_j2000(REAL_SSR_EPOCH_TOW_S);
1454 let record = broadcast
1455 .select_record_at(sat, t)
1456 .expect("broadcast record at SSR epoch");
1457 let stale_iode = (record.issue_of_data.issue + 1) & 0xff;
1458 let message = Message::Ssr(SsrMessage {
1459 message_number: 1060,
1460 system: GnssSystem::Gps,
1461 kind: SsrKind::CombinedOrbitClock,
1462 header: SsrHeader {
1463 epoch_time_s: REAL_SSR_EPOCH_TOW_S as u32,
1464 update_interval: 0,
1465 multiple_message: false,
1466 iod_ssr: 4,
1467 provider_id: 9,
1468 solution_id: 1,
1469 satellite_reference_datum: Some(false),
1470 dispersive_bias_consistency: None,
1471 mw_consistency: None,
1472 satellite_count: 1,
1473 },
1474 orbit: vec![SsrOrbitRecord {
1475 satellite_id: sat.prn,
1476 iode: stale_iode,
1477 delta_radial: -20_000,
1478 delta_along: 0,
1479 delta_cross: 0,
1480 dot_delta_radial: 0,
1481 dot_delta_along: 0,
1482 dot_delta_cross: 0,
1483 }],
1484 clock: vec![SsrClockRecord {
1485 satellite_id: sat.prn,
1486 c0: 5_000,
1487 c1: 0,
1488 c2: 0,
1489 }],
1490 code_bias: Vec::new(),
1491 phase_bias: Vec::<SsrPhaseBiasRecord>::new(),
1492 ura: Vec::new(),
1493 padding_bits: Vec::new(),
1494 });
1495 let mut store = SsrCorrectionStore::new();
1496 let week = GnssWeekTow::new(TimeScale::Gpst, REAL_SSR_WEEK, REAL_SSR_EPOCH_TOW_S)
1497 .expect("valid SSR week");
1498 let mut assembler = SsrStreamAssembler::new();
1499 for decoded in assembler.push(&message.to_frame().expect("frame RTCM SSR")) {
1500 store
1501 .ingest(&decoded.expect("decode RTCM SSR frame"), week)
1502 .expect("ingest SSR");
1503 }
1504
1505 let strict = SsrCorrectedEphemeris::new(&broadcast, &store);
1506 assert!(strict.corrected_state(sat, t).is_none());
1507
1508 let fallback =
1509 SsrCorrectedEphemeris::new(&broadcast, &store).with_fallback(SsrFallbackPolicy {
1510 on_missing_correction: MissingCorrectionAction::FallBackToBroadcast,
1511 regional: RegionalPolicy::DeclineRegional,
1512 });
1513 let got = fallback
1514 .corrected_state(sat, t)
1515 .expect("broadcast fallback");
1516 let expected = broadcast
1517 .position_clock_at_j2000_s(sat, t)
1518 .expect("broadcast state");
1519 assert_eq!(got.0.map(f64::to_bits), expected.0.map(f64::to_bits));
1520 assert_eq!(got.1.to_bits(), expected.1.to_bits());
1521 }
1522
1523 #[test]
1524 fn rtcm_update_interval_staleness_is_enforced_before_store_cap() {
1525 let nav_text = std::fs::read_to_string(concat!(
1526 env!("CARGO_MANIFEST_DIR"),
1527 "/tests/fixtures/ssr/BRDC00WRD_S_20261820000_G30_G31.rnx"
1528 ))
1529 .expect("read NAV fixture");
1530 let broadcast = BroadcastEphemeris::from_nav(&nav_text).expect("parse NAV fixture");
1531 let sat = GnssSatelliteId::new(GnssSystem::Gps, 30).unwrap();
1532 let t = ssr_j2000(REAL_SSR_EPOCH_TOW_S);
1533 let record = broadcast
1534 .select_record_at(sat, t)
1535 .expect("broadcast record at SSR epoch");
1536 let message = Message::Ssr(SsrMessage {
1537 message_number: 1060,
1538 system: GnssSystem::Gps,
1539 kind: SsrKind::CombinedOrbitClock,
1540 header: SsrHeader {
1541 epoch_time_s: REAL_SSR_EPOCH_TOW_S as u32,
1542 update_interval: 0,
1543 multiple_message: false,
1544 iod_ssr: 6,
1545 provider_id: 9,
1546 solution_id: 1,
1547 satellite_reference_datum: Some(false),
1548 dispersive_bias_consistency: None,
1549 mw_consistency: None,
1550 satellite_count: 1,
1551 },
1552 orbit: vec![SsrOrbitRecord {
1553 satellite_id: sat.prn,
1554 iode: record.issue_of_data.issue,
1555 delta_radial: 0,
1556 delta_along: 0,
1557 delta_cross: 0,
1558 dot_delta_radial: 0,
1559 dot_delta_along: 0,
1560 dot_delta_cross: 0,
1561 }],
1562 clock: vec![SsrClockRecord {
1563 satellite_id: sat.prn,
1564 c0: 0,
1565 c1: 0,
1566 c2: 0,
1567 }],
1568 code_bias: Vec::new(),
1569 phase_bias: Vec::<SsrPhaseBiasRecord>::new(),
1570 ura: Vec::new(),
1571 padding_bits: Vec::new(),
1572 });
1573 let mut store = SsrCorrectionStore::new().with_staleness(StalenessPolicy::seconds(90.0));
1574 let week = GnssWeekTow::new(TimeScale::Gpst, REAL_SSR_WEEK, REAL_SSR_EPOCH_TOW_S)
1575 .expect("valid SSR week");
1576 let mut assembler = SsrStreamAssembler::new();
1577 for decoded in assembler.push(&message.to_frame().expect("frame RTCM SSR")) {
1578 store
1579 .ingest(&decoded.expect("decode RTCM SSR frame"), week)
1580 .expect("ingest SSR");
1581 }
1582
1583 let strict = SsrCorrectedEphemeris::new(&broadcast, &store);
1584 assert!(strict.corrected_state(sat, t + 1.0).is_some());
1585 assert!(strict.corrected_state(sat, t + 1.25).is_none());
1586
1587 let fallback =
1588 SsrCorrectedEphemeris::new(&broadcast, &store).with_fallback(SsrFallbackPolicy {
1589 on_missing_correction: MissingCorrectionAction::FallBackToBroadcast,
1590 regional: RegionalPolicy::DeclineRegional,
1591 });
1592 let got = fallback
1593 .corrected_state(sat, t + 1.25)
1594 .expect("broadcast fallback after update interval expiry");
1595 let expected = broadcast
1596 .position_clock_at_j2000_s(sat, t + 1.25)
1597 .expect("broadcast state");
1598 assert_eq!(got.0.map(f64::to_bits), expected.0.map(f64::to_bits));
1599 assert_eq!(got.1.to_bits(), expected.1.to_bits());
1600 }
1601
1602 #[test]
1603 fn rtcm_binary_phase_bias_ingests_for_ppp_bias_lookup() {
1604 let sat = GnssSatelliteId::new(GnssSystem::Gps, 30).unwrap();
1605 let message = Message::Ssr(SsrMessage {
1606 message_number: 1265,
1607 system: GnssSystem::Gps,
1608 kind: SsrKind::PhaseBias,
1609 header: SsrHeader {
1610 epoch_time_s: REAL_SSR_EPOCH_TOW_S as u32,
1611 update_interval: 0,
1612 multiple_message: false,
1613 iod_ssr: 7,
1614 provider_id: 9,
1615 solution_id: 1,
1616 satellite_reference_datum: None,
1617 dispersive_bias_consistency: Some(true),
1618 mw_consistency: Some(false),
1619 satellite_count: 1,
1620 },
1621 orbit: Vec::new(),
1622 clock: Vec::new(),
1623 code_bias: Vec::new(),
1624 phase_bias: vec![SsrPhaseBiasRecord {
1625 satellite_id: sat.prn,
1626 yaw_angle: 127,
1627 yaw_rate: -12,
1628 biases: vec![
1629 SsrPhaseBiasSignal {
1630 signal_id: 0,
1631 integer_indicator: 1,
1632 wide_lane_integer_indicator: 2,
1633 discontinuity_counter: 3,
1634 bias: 1_250,
1635 },
1636 SsrPhaseBiasSignal {
1637 signal_id: 9,
1638 integer_indicator: 0,
1639 wide_lane_integer_indicator: 1,
1640 discontinuity_counter: 4,
1641 bias: -2_500,
1642 },
1643 ],
1644 }],
1645 ura: Vec::new(),
1646 padding_bits: Vec::new(),
1647 });
1648 let mut store = SsrCorrectionStore::new();
1649 let week = GnssWeekTow::new(TimeScale::Gpst, REAL_SSR_WEEK, REAL_SSR_EPOCH_TOW_S)
1650 .expect("valid SSR week");
1651 let mut assembler = SsrStreamAssembler::new();
1652 for decoded in assembler.push(&message.to_frame().expect("frame RTCM phase bias")) {
1653 store
1654 .ingest(&decoded.expect("decode RTCM phase-bias frame"), week)
1655 .expect("ingest SSR phase bias");
1656 }
1657
1658 assert_eq!(
1659 store.phase_bias(sat, 0).unwrap().to_bits(),
1660 0.125_f64.to_bits()
1661 );
1662 assert_eq!(
1663 store.phase_bias(sat, 9).unwrap().to_bits(),
1664 (-0.25_f64).to_bits()
1665 );
1666 }
1667
1668 #[test]
1669 fn has_binary_orbit_clock_and_biases_ingest_with_additive_convention() {
1670 let nav_text = std::fs::read_to_string(concat!(
1671 env!("CARGO_MANIFEST_DIR"),
1672 "/tests/fixtures/ssr/BRDC00WRD_S_20261820000_G30_G31.rnx"
1673 ))
1674 .expect("read NAV fixture");
1675 let broadcast = BroadcastEphemeris::from_nav(&nav_text).expect("parse NAV fixture");
1676 let sat = GnssSatelliteId::new(GnssSystem::Gps, 30).unwrap();
1677 let t = ssr_j2000(REAL_SSR_EPOCH_TOW_S);
1678 let record = broadcast
1679 .select_record_at(sat, t)
1680 .expect("broadcast record at SSR epoch");
1681 let message = HasMt1Message {
1682 header: HasMt1Header {
1683 toh_s: (REAL_SSR_EPOCH_TOW_S as u32 % 3600) as u16,
1684 mask: true,
1685 orbit: true,
1686 clock_full_set: true,
1687 clock_subset: false,
1688 code_bias: true,
1689 phase_bias: true,
1690 reserved: 0,
1691 mask_id: 2,
1692 iod_set_id: 5,
1693 },
1694 mask: Some(HasMaskBlock {
1695 systems: vec![HasGnssMask {
1696 system: GnssSystem::Gps,
1697 satellites: vec![sat.prn],
1698 signals: vec![0, 9],
1699 cell_mask: None,
1700 nav_message: 0,
1701 }],
1702 }),
1703 orbit: Some(HasOrbitBlock {
1704 validity_interval: 5,
1705 records: vec![HasOrbitCorrection {
1706 sat,
1707 iode: record.issue_of_data.issue,
1708 radial_m: 1.25,
1709 along_m: -2.0,
1710 cross_m: 3.0,
1711 }],
1712 }),
1713 clock_full_set: Some(HasClockBlock {
1714 validity_interval: 5,
1715 records: vec![HasClockCorrection {
1716 sat,
1717 correction_m: -0.75,
1718 }],
1719 }),
1720 clock_subset: None,
1721 code_bias: Some(HasCodeBiasBlock {
1722 validity_interval: 5,
1723 records: vec![
1724 HasCodeBias {
1725 sat,
1726 signal_id: 0,
1727 bias_m: 0.24,
1728 },
1729 HasCodeBias {
1730 sat,
1731 signal_id: 9,
1732 bias_m: -0.46,
1733 },
1734 ],
1735 }),
1736 phase_bias: Some(HasPhaseBiasBlock {
1737 validity_interval: 5,
1738 records: vec![
1739 HasPhaseBias {
1740 sat,
1741 signal_id: 0,
1742 bias_cycles: 1.25,
1743 bias_m: 1.25 * C_M_S / F_L1_HZ,
1744 discontinuity_indicator: 1,
1745 },
1746 HasPhaseBias {
1747 sat,
1748 signal_id: 9,
1749 bias_cycles: -2.5,
1750 bias_m: -2.5 * C_M_S / F_L2_HZ,
1751 discontinuity_indicator: 2,
1752 },
1753 ],
1754 }),
1755 padding_bits: Vec::new(),
1756 };
1757 let decoded = HasMt1Message::decode(&message.encode()).expect("decode HAS MT1");
1758 let mut store = SsrCorrectionStore::new();
1759 let reception = GnssWeekTow::new(TimeScale::Gst, REAL_SSR_WEEK, REAL_SSR_EPOCH_TOW_S)
1760 .expect("GST reception");
1761 store
1762 .ingest_has_mt1(&decoded, reception)
1763 .expect("ingest HAS MT1");
1764 let source = SsrCorrectedEphemeris::new(&broadcast, &store);
1765 let (position, clock) = source.corrected_state(sat, t).expect("HAS corrected state");
1766 let (broadcast_position, broadcast_clock) = broadcast
1767 .position_clock_at_j2000_s(sat, t)
1768 .expect("broadcast state");
1769 let velocity = finite_difference_broadcast_velocity(&broadcast, sat, t);
1770 let (er, ea, ec) = analytic_velocity_aligned_basis(broadcast_position, velocity);
1771 let expected_position = [
1772 broadcast_position[0] + 1.25 * er[0] - 2.0 * ea[0] + 3.0 * ec[0],
1773 broadcast_position[1] + 1.25 * er[1] - 2.0 * ea[1] + 3.0 * ec[1],
1774 broadcast_position[2] + 1.25 * er[2] - 2.0 * ea[2] + 3.0 * ec[2],
1775 ];
1776 assert_vector_close(position, expected_position, 2.0e-9);
1777 assert!((clock - (broadcast_clock - 0.75 / C_M_S)).abs() < 1.0e-18);
1778 assert_eq!(
1779 store.code_bias(sat, 0).unwrap().to_bits(),
1780 0.24_f64.to_bits()
1781 );
1782 assert_eq!(
1783 store.code_bias(sat, 9).unwrap().to_bits(),
1784 (-0.46_f64).to_bits()
1785 );
1786 assert_eq!(
1787 store.phase_bias(sat, 0).unwrap().to_bits(),
1788 (1.25 * C_M_S / F_L1_HZ).to_bits()
1789 );
1790 assert_eq!(
1791 store.phase_bias(sat, 9).unwrap().to_bits(),
1792 (-2.5 * C_M_S / F_L2_HZ).to_bits()
1793 );
1794 }
1795
1796 #[test]
1797 fn corrected_ephemeris_uses_real_rtcm_broadcast_and_sp3_products() {
1798 let nav_text = std::fs::read_to_string(concat!(
1799 env!("CARGO_MANIFEST_DIR"),
1800 "/tests/fixtures/ssr/BRDC00WRD_S_20261820000_G30_G31.rnx"
1801 ))
1802 .expect("read NAV fixture");
1803 let broadcast = BroadcastEphemeris::from_nav(&nav_text).expect("parse NAV fixture");
1804 let sp3_bytes = std::fs::read(concat!(
1805 env!("CARGO_MANIFEST_DIR"),
1806 "/tests/fixtures/ssr/IGS0OPSULT_20261811800_02D_15M_ORB.SP3"
1807 ))
1808 .expect("read SP3 fixture");
1809 let sp3 = Sp3::parse(&sp3_bytes).expect("parse SP3 fixture");
1810 let store = real_gps_ssr_store();
1811 let source = SsrCorrectedEphemeris::new(&broadcast, &store)
1812 .with_staleness(StalenessPolicy::seconds(60.0));
1813 let t = ssr_j2000(REAL_SSR_EPOCH_TOW_S);
1814
1815 let mut orbit_error_sum_m2 = 0.0;
1816 let mut clock_error_sum_ns2 = 0.0;
1817 let mut count = 0_usize;
1818 for sat in [
1819 GnssSatelliteId::new(GnssSystem::Gps, 30).unwrap(),
1820 GnssSatelliteId::new(GnssSystem::Gps, 31).unwrap(),
1821 ] {
1822 let (corrected_position, corrected_clock) =
1823 source.corrected_state(sat, t).expect("corrected state");
1824 let sp3_state = sp3.position_at_j2000_seconds(sat, t).expect("SP3 state");
1825 let sp3_position = sp3_state.position.as_array();
1826 let sp3_clock = sp3_state.clock_s.expect("SP3 clock");
1827 let orbit_error_m = norm([
1828 corrected_position[0] - sp3_position[0],
1829 corrected_position[1] - sp3_position[1],
1830 corrected_position[2] - sp3_position[2],
1831 ]);
1832 let clock_error_ns = (corrected_clock - sp3_clock) * 1.0e9;
1833 orbit_error_sum_m2 += orbit_error_m * orbit_error_m;
1834 clock_error_sum_ns2 += clock_error_ns * clock_error_ns;
1835 count += 1;
1836 }
1837 assert_eq!(count, 2);
1838 let orbit_rms_m = (orbit_error_sum_m2 / count as f64).sqrt();
1839 let clock_rms_ns = (clock_error_sum_ns2 / count as f64).sqrt();
1840
1841 assert!(orbit_rms_m < 1.6, "{orbit_rms_m}");
1845 assert!(clock_rms_ns < 22.0, "{clock_rms_ns}");
1846 }
1847
1848 #[test]
1849 fn corrected_position_matches_rtklib_satpos_ssr_oracle_for_one_epoch() {
1850 let nav_text = std::fs::read_to_string(concat!(
1851 env!("CARGO_MANIFEST_DIR"),
1852 "/tests/fixtures/ssr/BRDC00WRD_S_20261820000_G30_G31.rnx"
1853 ))
1854 .expect("read NAV fixture");
1855 let broadcast = BroadcastEphemeris::from_nav(&nav_text).expect("parse NAV fixture");
1856 let store = real_gps_ssr_store();
1857 let source = SsrCorrectedEphemeris::new(&broadcast, &store)
1858 .with_staleness(StalenessPolicy::seconds(60.0));
1859 let sat = GnssSatelliteId::new(GnssSystem::Gps, 30).unwrap();
1860 let (position, clock) = source
1861 .corrected_state(sat, ssr_j2000(REAL_SSR_EPOCH_TOW_S))
1862 .expect("corrected state");
1863 assert_eq!(
1864 position.map(f64::to_bits),
1865 [
1866 13_931_924_021_901_094_572,
1867 4_714_745_314_434_008_008,
1868 13_939_538_677_975_909_636,
1869 ]
1870 );
1871 assert_eq!(clock.to_bits(), 4_553_802_228_904_002_216);
1872 let rtklib_position = [
1873 -6_327_381.424_159_626,
1874 15_802_129.789_888_298,
1875 -20_121_898.098_271_403,
1876 ];
1877 let position_error_m = norm([
1878 position[0] - rtklib_position[0],
1879 position[1] - rtklib_position[1],
1880 position[2] - rtklib_position[2],
1881 ]);
1882 assert!(position_error_m < 1.0e-6, "{position_error_m}");
1883 }
1884
1885 #[test]
1886 fn com_reference_point_requires_explicit_attitude_model() {
1887 let nav_text = std::fs::read_to_string(concat!(
1888 env!("CARGO_MANIFEST_DIR"),
1889 "/tests/fixtures/ssr/BRDC00WRD_S_20261820000_G30_G31.rnx"
1890 ))
1891 .expect("read NAV fixture");
1892 let broadcast = BroadcastEphemeris::from_nav(&nav_text).expect("parse NAV fixture");
1893 let antex = Antex::parse(GPS_ANTEX_TEXT).expect("parse ANTEX fixture");
1894 let store = real_gps_ssr_store_with_reference_point(SsrReferencePoint::CenterOfMass);
1895 let sat = GnssSatelliteId::new(GnssSystem::Gps, 30).unwrap();
1896 let t = ssr_j2000(REAL_SSR_EPOCH_TOW_S);
1897
1898 let no_attitude = SsrCorrectedEphemeris::new(&broadcast, &store)
1899 .with_staleness(StalenessPolicy::seconds(60.0))
1900 .with_satellite_antennas(&antex);
1901 assert!(no_attitude.corrected_state(sat, t).is_none());
1902
1903 let fallback = SsrCorrectedEphemeris::new(&broadcast, &store)
1904 .with_staleness(StalenessPolicy::seconds(60.0))
1905 .with_satellite_antennas(&antex)
1906 .with_fallback(SsrFallbackPolicy {
1907 on_missing_correction: MissingCorrectionAction::FallBackToBroadcast,
1908 regional: RegionalPolicy::DeclineRegional,
1909 });
1910 assert!(fallback.corrected_state(sat, t).is_none());
1911
1912 let nominal = SsrCorrectedEphemeris::new(&broadcast, &store)
1913 .with_staleness(StalenessPolicy::seconds(60.0))
1914 .with_satellite_antennas(&antex)
1915 .with_satellite_attitude(SsrSatelliteAttitude::NominalSunFixed);
1916 assert!(nominal.corrected_state(sat, t).is_some());
1917 }
1918
1919 #[test]
1920 fn com_orbit_tag_blocks_fallback_after_store_default_changes_to_apc() {
1921 let nav_text = std::fs::read_to_string(concat!(
1922 env!("CARGO_MANIFEST_DIR"),
1923 "/tests/fixtures/ssr/BRDC00WRD_S_20261820000_G30_G31.rnx"
1924 ))
1925 .expect("read NAV fixture");
1926 let broadcast = BroadcastEphemeris::from_nav(&nav_text).expect("parse NAV fixture");
1927 let antex = Antex::parse(GPS_ANTEX_TEXT).expect("parse ANTEX fixture");
1928 let mut store = real_gps_ssr_store_with_reference_point(SsrReferencePoint::CenterOfMass);
1929 store = store.with_reference_point(SsrReferencePoint::AntennaPhaseCenter);
1930 let sat = GnssSatelliteId::new(GnssSystem::Gps, 30).unwrap();
1931 let t = ssr_j2000(REAL_SSR_EPOCH_TOW_S);
1932 assert_eq!(
1933 store.reference_point(),
1934 SsrReferencePoint::AntennaPhaseCenter
1935 );
1936 assert_eq!(
1937 store.orbit(sat).unwrap().reference_point,
1938 SsrReferencePoint::CenterOfMass
1939 );
1940
1941 let source = SsrCorrectedEphemeris::new(&broadcast, &store)
1942 .with_staleness(StalenessPolicy::seconds(60.0))
1943 .with_satellite_antennas(&antex)
1944 .with_fallback(SsrFallbackPolicy {
1945 on_missing_correction: MissingCorrectionAction::FallBackToBroadcast,
1946 regional: RegionalPolicy::DeclineRegional,
1947 });
1948
1949 assert_eq!(
1950 source.observable_state_at_j2000_s(sat, t),
1951 Err(ObservablesError::NoEphemeris)
1952 );
1953 }
1954
1955 fn real_gps_ssr_store() -> SsrCorrectionStore {
1956 real_gps_ssr_store_with_reference_point(SsrReferencePoint::rtcm_ssr_default())
1957 }
1958
1959 fn real_gps_ssr_store_with_reference_point(
1960 reference_point: SsrReferencePoint,
1961 ) -> SsrCorrectionStore {
1962 let mut assembler = SsrStreamAssembler::new();
1963 let mut store = SsrCorrectionStore::new().with_reference_point(reference_point);
1964 let week = GnssWeekTow::new(TimeScale::Gpst, REAL_SSR_WEEK, REAL_SSR_EPOCH_TOW_S)
1965 .expect("valid SSR week");
1966 for decoded in assembler.push(&hex_bytes(REAL_SSRA02IGS0_1060_FRAME_HEX)) {
1967 let message = decoded.expect("decode SSR frame");
1968 store.ingest(&message, week).expect("ingest SSR frame");
1969 }
1970 assert_eq!(assembler.retained_len(), 0);
1971 store
1972 }
1973
1974 fn ssr_j2000(tow_s: f64) -> f64 {
1975 f64::from(REAL_SSR_WEEK) * SECONDS_PER_WEEK + tow_s - GPS_EPOCH_TO_J2000_S
1976 }
1977
1978 fn hex_bytes(hex: &str) -> Vec<u8> {
1979 let compact: String = hex.chars().filter(|c| c.is_ascii_hexdigit()).collect();
1980 assert_eq!(compact.len() % 2, 0);
1981 compact
1982 .as_bytes()
1983 .chunks_exact(2)
1984 .map(|chunk| {
1985 let hi = (chunk[0] as char).to_digit(16).unwrap();
1986 let lo = (chunk[1] as char).to_digit(16).unwrap();
1987 ((hi << 4) | lo) as u8
1988 })
1989 .collect()
1990 }
1991
1992 fn finite_difference_broadcast_velocity(
1993 broadcast: &BroadcastEphemeris,
1994 sat: GnssSatelliteId,
1995 t_j2000_s: f64,
1996 ) -> [f64; 3] {
1997 let p_plus = broadcast
1998 .position_clock_at_j2000_s(sat, t_j2000_s + FD_HALF_S)
1999 .expect("broadcast plus state")
2000 .0;
2001 let p_minus = broadcast
2002 .position_clock_at_j2000_s(sat, t_j2000_s - FD_HALF_S)
2003 .expect("broadcast minus state")
2004 .0;
2005 [
2006 (p_plus[0] - p_minus[0]) / (2.0 * FD_HALF_S),
2007 (p_plus[1] - p_minus[1]) / (2.0 * FD_HALF_S),
2008 (p_plus[2] - p_minus[2]) / (2.0 * FD_HALF_S),
2009 ]
2010 }
2011
2012 fn analytic_velocity_aligned_basis(
2013 position: [f64; 3],
2014 velocity: [f64; 3],
2015 ) -> ([f64; 3], [f64; 3], [f64; 3]) {
2016 let along = unit(velocity);
2017 let cross_track = unit(cross3(position, velocity));
2018 let radial = cross3(along, cross_track);
2019 (radial, along, cross_track)
2020 }
2021
2022 fn assert_vector_close(actual: [f64; 3], expected: [f64; 3], tolerance_m: f64) {
2023 let error = norm([
2024 actual[0] - expected[0],
2025 actual[1] - expected[1],
2026 actual[2] - expected[2],
2027 ]);
2028 assert!(
2029 error <= tolerance_m,
2030 "error {error}, expected {expected:?}, got {actual:?}"
2031 );
2032 }
2033
2034 fn unit(v: [f64; 3]) -> [f64; 3] {
2035 let n = norm(v);
2036 [v[0] / n, v[1] / n, v[2] / n]
2037 }
2038
2039 fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
2040 [
2041 a[1] * b[2] - a[2] * b[1],
2042 a[2] * b[0] - a[0] * b[2],
2043 a[0] * b[1] - a[1] * b[0],
2044 ]
2045 }
2046
2047 fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
2048 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
2049 }
2050
2051 fn norm(v: [f64; 3]) -> f64 {
2052 dot(v, v).sqrt()
2053 }
2054}