1use std::collections::{BTreeMap, BTreeSet};
8use std::sync::Arc;
9
10use crate::astro::time::model::{GnssWeekTow, TimeScale};
11use crate::broadcast::satellite_state_unchecked;
12use crate::constants::{C_M_S, GPS_EPOCH_TO_J2000_S, SECONDS_PER_HOUR, SECONDS_PER_WEEK};
13use crate::ephemeris::{BroadcastEphemeris, BroadcastIssue, NavMessage};
14use crate::error::{Error, Result};
15use crate::id::{GnssSatelliteId, GnssSystem};
16use crate::observables::{ObservableEphemerisSource, ObservableState, ObservablesError};
17use crate::rinex_nav::is_beidou_geo;
18use crate::rtcm::{Message, SsrKind, SsrMessage};
19use crate::spp::EphemerisSource;
20use crate::staleness::StalenessPolicy;
21
22const DEFAULT_SSR_STALENESS_S: f64 = 90.0;
23const FD_HALF_S: f64 = 0.5;
24const RTCM_SSR_RADIAL_CLOCK_SCALE_M: f64 = 1.0e-4;
26const RTCM_SSR_ALONG_CROSS_SCALE_M: f64 = 4.0e-4;
28const RTCM_SSR_RADIAL_CLOCK_RATE_SCALE_M_S: f64 = 1.0e-6;
30const RTCM_SSR_ALONG_CROSS_RATE_SCALE_M_S: f64 = 4.0e-6;
32const RTCM_SSR_CLOCK_ACCEL_SCALE_M_S2: f64 = 2.0e-8;
34
35#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
37pub enum SsrSource {
38 RtcmSsr,
40 GalileoHas,
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
46pub enum OrbitBasis {
47 VelocityAligned,
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
53pub enum OrbitReferencePoint {
54 AntennaPhaseCenter,
56 CenterOfMass,
58}
59
60#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
62pub struct SsrSolution {
63 pub source: SsrSource,
65 pub provider_id: u16,
67 pub solution_id: u8,
69}
70
71#[derive(Clone, Copy, Debug, PartialEq)]
73pub struct SsrOrbitCorrection {
74 pub solution: SsrSolution,
76 pub iode: u32,
78 pub iod_ssr: u8,
80 pub basis: OrbitBasis,
82 pub crs_regional: bool,
84 pub reference_point: OrbitReferencePoint,
86 pub radial_m: f64,
88 pub along_m: f64,
90 pub cross_m: f64,
92 pub radial_rate_m_s: f64,
94 pub along_rate_m_s: f64,
96 pub cross_rate_m_s: f64,
98 pub ref_epoch_j2000_s: f64,
100 pub update_interval_s: f64,
102}
103
104#[derive(Clone, Copy, Debug, PartialEq)]
106pub struct SsrHighRateClock {
107 pub solution: SsrSolution,
109 pub iod_ssr: u8,
111 pub c0_m: f64,
113 pub ref_epoch_j2000_s: f64,
115 pub update_interval_s: f64,
117}
118
119#[derive(Clone, Copy, Debug, PartialEq)]
121pub struct SsrClockCorrection {
122 pub solution: SsrSolution,
124 pub iod_ssr: u8,
126 pub c0_m: f64,
128 pub c1_m_s: f64,
130 pub c2_m_s2: f64,
132 pub ref_epoch_j2000_s: f64,
134 pub update_interval_s: f64,
136 pub high_rate: Option<SsrHighRateClock>,
138}
139
140#[derive(Clone, Debug, Default, PartialEq)]
142pub struct SsrCodeBias {
143 biases_m: BTreeMap<u8, f64>,
144}
145
146#[derive(Clone, Debug, Default, PartialEq)]
148pub struct SsrPhaseBias {
149 biases_m: BTreeMap<u8, f64>,
150}
151
152#[derive(Clone, Debug, Default, PartialEq)]
153struct SatCorrections {
154 orbit: Option<SsrOrbitCorrection>,
155 clock: Option<SsrClockCorrection>,
156 pending_high_rate: Option<SsrHighRateClock>,
157 ura_index: Option<u8>,
158 code_bias: SsrCodeBias,
159 phase_bias: SsrPhaseBias,
160}
161
162#[derive(Clone, Debug, PartialEq)]
164pub struct SsrCorrectionStore {
165 corrections: BTreeMap<GnssSatelliteId, SatCorrections>,
166 reference_point: OrbitReferencePoint,
167 staleness: StalenessPolicy,
168}
169
170impl Default for SsrCorrectionStore {
171 fn default() -> Self {
172 Self::new()
173 }
174}
175
176impl SsrCorrectionStore {
177 pub fn new() -> Self {
179 Self {
180 corrections: BTreeMap::new(),
181 reference_point: OrbitReferencePoint::CenterOfMass,
182 staleness: StalenessPolicy::seconds(DEFAULT_SSR_STALENESS_S),
183 }
184 }
185
186 pub fn with_reference_point(mut self, reference_point: OrbitReferencePoint) -> Self {
188 self.reference_point = reference_point;
189 self
190 }
191
192 pub fn with_staleness(mut self, policy: StalenessPolicy) -> Self {
194 self.staleness = policy;
195 self
196 }
197
198 pub fn staleness(&self) -> StalenessPolicy {
200 self.staleness
201 }
202
203 pub fn ingest(&mut self, message: &Message, week: GnssWeekTow) -> Result<()> {
205 if let Message::Ssr(ssr) = message {
206 self.ingest_ssr(ssr, week)?;
207 }
208 Ok(())
209 }
210
211 pub fn ingest_ssr(&mut self, message: &SsrMessage, week: GnssWeekTow) -> Result<()> {
213 let update_interval_s = update_interval_s(message.header.update_interval);
214 let ref_epoch_j2000_s = ssr_epoch_j2000_s(
215 message.system,
216 week,
217 message.header.epoch_time_s,
218 update_interval_s,
219 )?;
220 let solution = SsrSolution {
221 source: SsrSource::RtcmSsr,
222 provider_id: message.header.provider_id,
223 solution_id: message.header.solution_id,
224 };
225
226 match message.kind {
227 SsrKind::Orbit => {
228 for record in &message.orbit {
229 let sat = ssr_satellite(message.system, record.satellite_id)?;
230 let orbit = orbit_from_rtcm(
231 self.reference_point,
232 message,
233 solution,
234 record,
235 ref_epoch_j2000_s,
236 update_interval_s,
237 );
238 let entry = self.corrections.entry(sat).or_default();
239 entry.orbit = Some(orbit);
240 }
241 }
242 SsrKind::Clock => {
243 for record in &message.clock {
244 let sat = ssr_satellite(message.system, record.satellite_id)?;
245 let entry = self.corrections.entry(sat).or_default();
246 let mut clock = SsrClockCorrection {
247 solution,
248 iod_ssr: message.header.iod_ssr,
249 c0_m: f64::from(record.c0) * RTCM_SSR_RADIAL_CLOCK_SCALE_M,
250 c1_m_s: f64::from(record.c1) * RTCM_SSR_RADIAL_CLOCK_RATE_SCALE_M_S,
251 c2_m_s2: f64::from(record.c2) * RTCM_SSR_CLOCK_ACCEL_SCALE_M_S2,
252 ref_epoch_j2000_s,
253 update_interval_s,
254 high_rate: None,
255 };
256 if let Some(hr) = entry.pending_high_rate {
257 if high_rate_matches(&clock, &hr) {
258 clock.high_rate = Some(hr);
259 }
260 }
261 entry.clock = Some(clock);
262 }
263 }
264 SsrKind::CombinedOrbitClock => {
265 for (orbit_record, clock_record) in message.orbit.iter().zip(&message.clock) {
266 let sat = ssr_satellite(message.system, orbit_record.satellite_id)?;
267 let orbit = orbit_from_rtcm(
268 self.reference_point,
269 message,
270 solution,
271 orbit_record,
272 ref_epoch_j2000_s,
273 update_interval_s,
274 );
275 let entry = self.corrections.entry(sat).or_default();
276 entry.orbit = Some(orbit);
277 entry.clock = Some(SsrClockCorrection {
278 solution,
279 iod_ssr: message.header.iod_ssr,
280 c0_m: f64::from(clock_record.c0) * RTCM_SSR_RADIAL_CLOCK_SCALE_M,
281 c1_m_s: f64::from(clock_record.c1) * RTCM_SSR_RADIAL_CLOCK_RATE_SCALE_M_S,
282 c2_m_s2: f64::from(clock_record.c2) * RTCM_SSR_CLOCK_ACCEL_SCALE_M_S2,
283 ref_epoch_j2000_s,
284 update_interval_s,
285 high_rate: entry.pending_high_rate,
286 });
287 }
288 }
289 SsrKind::Ura => {
290 for &(satellite_id, ura_index) in &message.ura {
291 let sat = ssr_satellite(message.system, satellite_id)?;
292 self.corrections.entry(sat).or_default().ura_index = Some(ura_index);
293 }
294 }
295 SsrKind::HighRateClock => {
296 for record in &message.clock {
297 let sat = ssr_satellite(message.system, record.satellite_id)?;
298 let high_rate = SsrHighRateClock {
299 solution,
300 iod_ssr: message.header.iod_ssr,
301 c0_m: f64::from(record.c0) * RTCM_SSR_RADIAL_CLOCK_SCALE_M,
302 ref_epoch_j2000_s,
303 update_interval_s,
304 };
305 let entry = self.corrections.entry(sat).or_default();
306 entry.pending_high_rate = Some(high_rate);
307 if let Some(clock) = &mut entry.clock {
308 if high_rate_matches(clock, &high_rate) {
309 clock.high_rate = Some(high_rate);
310 }
311 }
312 }
313 }
314 SsrKind::CodeBias | SsrKind::PhaseBias | SsrKind::Vtec => {}
315 }
316 Ok(())
317 }
318
319 pub fn orbit(&self, sat: GnssSatelliteId) -> Option<&SsrOrbitCorrection> {
321 self.corrections.get(&sat)?.orbit.as_ref()
322 }
323
324 pub fn clock(&self, sat: GnssSatelliteId) -> Option<&SsrClockCorrection> {
326 self.corrections.get(&sat)?.clock.as_ref()
327 }
328
329 pub fn ura_index(&self, sat: GnssSatelliteId) -> Option<u8> {
331 self.corrections.get(&sat)?.ura_index
332 }
333
334 pub fn code_bias(&self, sat: GnssSatelliteId, signal: u8) -> Option<f64> {
336 self.corrections
337 .get(&sat)?
338 .code_bias
339 .biases_m
340 .get(&signal)
341 .copied()
342 }
343
344 pub fn phase_bias(&self, sat: GnssSatelliteId, signal: u8) -> Option<f64> {
346 self.corrections
347 .get(&sat)?
348 .phase_bias
349 .biases_m
350 .get(&signal)
351 .copied()
352 }
353}
354
355fn orbit_from_rtcm(
356 reference_point: OrbitReferencePoint,
357 message: &SsrMessage,
358 solution: SsrSolution,
359 record: &crate::rtcm::SsrOrbitRecord,
360 ref_epoch_j2000_s: f64,
361 update_interval_s: f64,
362) -> SsrOrbitCorrection {
363 SsrOrbitCorrection {
364 solution,
365 iode: record.iode,
366 iod_ssr: message.header.iod_ssr,
367 basis: OrbitBasis::VelocityAligned,
368 crs_regional: message.header.satellite_reference_datum.unwrap_or(false),
369 reference_point,
370 radial_m: -f64::from(record.delta_radial) * RTCM_SSR_RADIAL_CLOCK_SCALE_M,
371 along_m: -f64::from(record.delta_along) * RTCM_SSR_ALONG_CROSS_SCALE_M,
372 cross_m: -f64::from(record.delta_cross) * RTCM_SSR_ALONG_CROSS_SCALE_M,
373 radial_rate_m_s: -f64::from(record.dot_delta_radial) * RTCM_SSR_RADIAL_CLOCK_RATE_SCALE_M_S,
374 along_rate_m_s: -f64::from(record.dot_delta_along) * RTCM_SSR_ALONG_CROSS_RATE_SCALE_M_S,
375 cross_rate_m_s: -f64::from(record.dot_delta_cross) * RTCM_SSR_ALONG_CROSS_RATE_SCALE_M_S,
376 ref_epoch_j2000_s,
377 update_interval_s,
378 }
379}
380
381#[derive(Clone, Copy, Debug, PartialEq, Eq)]
383pub enum MissingCorrectionAction {
384 Decline,
386 FallBackToBroadcast,
388}
389
390#[derive(Clone, Debug, PartialEq, Eq)]
392pub enum RegionalPolicy {
393 DeclineRegional,
395 AllowProviders(BTreeSet<u16>),
397}
398
399#[derive(Clone, Debug, PartialEq, Eq)]
401pub struct SsrFallbackPolicy {
402 pub on_missing_correction: MissingCorrectionAction,
404 pub regional: RegionalPolicy,
406}
407
408impl Default for SsrFallbackPolicy {
409 fn default() -> Self {
410 Self {
411 on_missing_correction: MissingCorrectionAction::Decline,
412 regional: RegionalPolicy::DeclineRegional,
413 }
414 }
415}
416
417#[derive(Clone)]
419pub struct SsrCorrectedEphemeris<'a> {
420 broadcast: &'a BroadcastEphemeris,
421 store: &'a SsrCorrectionStore,
422 staleness: StalenessPolicy,
423 fallback: SsrFallbackPolicy,
424}
425
426impl<'a> SsrCorrectedEphemeris<'a> {
427 pub fn new(broadcast: &'a BroadcastEphemeris, store: &'a SsrCorrectionStore) -> Self {
429 Self {
430 broadcast,
431 store,
432 staleness: store.staleness(),
433 fallback: SsrFallbackPolicy::default(),
434 }
435 }
436
437 pub fn with_staleness(mut self, policy: StalenessPolicy) -> Self {
439 self.staleness = policy;
440 self
441 }
442
443 pub fn with_fallback(mut self, policy: SsrFallbackPolicy) -> Self {
445 self.fallback = policy;
446 self
447 }
448
449 pub fn allow_regional_provider(mut self, provider_id: u16) -> Self {
451 match &mut self.fallback.regional {
452 RegionalPolicy::DeclineRegional => {
453 let mut providers = BTreeSet::new();
454 providers.insert(provider_id);
455 self.fallback.regional = RegionalPolicy::AllowProviders(providers);
456 }
457 RegionalPolicy::AllowProviders(providers) => {
458 providers.insert(provider_id);
459 }
460 }
461 self
462 }
463
464 pub fn corrected_state(&self, sat: GnssSatelliteId, t_j2000_s: f64) -> Option<([f64; 3], f64)> {
466 self.corrected_state_inner(sat, t_j2000_s)
467 .or_else(|| self.broadcast_fallback(sat, t_j2000_s))
468 }
469
470 fn corrected_state_inner(
471 &self,
472 sat: GnssSatelliteId,
473 t_j2000_s: f64,
474 ) -> Option<([f64; 3], f64)> {
475 let orbit = self.store.orbit(sat)?;
476 let clock = self.store.clock(sat)?;
477 if orbit.solution != clock.solution || orbit.iod_ssr != clock.iod_ssr {
478 return None;
479 }
480 if !self.correction_fresh(t_j2000_s, orbit.ref_epoch_j2000_s) {
481 return None;
482 }
483 if !self.correction_fresh(t_j2000_s, clock.ref_epoch_j2000_s) {
484 return None;
485 }
486 if orbit.crs_regional && !self.regional_allowed(orbit.solution.provider_id) {
487 return None;
488 }
489
490 let nav_message = default_nav_message(sat.system)?;
491 let issue = BroadcastIssue {
492 issue: orbit.iode,
493 message: nav_message,
494 };
495 let record = self
496 .broadcast
497 .select_by_issue_at(sat, issue, nav_message, t_j2000_s)?;
498 let (t_continuous_s, is_geo) = continuous_time_for_sat(sat, t_j2000_s)?;
499 let sow = t_continuous_s.rem_euclid(SECONDS_PER_WEEK);
500 let state = satellite_state_unchecked(
501 &record.elements,
502 &record.clock,
503 &record.constants(),
504 sow,
505 record.broadcast_clock_group_delay_s(),
506 is_geo,
507 );
508 let r = state.orbit.position().ok()?.as_array();
509 let v = broadcast_velocity(record, sat, t_j2000_s, is_geo)?;
510 let (er, ea, ec) = velocity_aligned_basis(r, v)?;
511 let dt_orbit = t_j2000_s - orbit.ref_epoch_j2000_s;
512 let radial = orbit.radial_m + orbit.radial_rate_m_s * dt_orbit;
513 let along = orbit.along_m + orbit.along_rate_m_s * dt_orbit;
514 let cross = orbit.cross_m + orbit.cross_rate_m_s * dt_orbit;
515 let corrected_position = [
516 r[0] + radial * er[0] + along * ea[0] + cross * ec[0],
517 r[1] + radial * er[1] + along * ea[1] + cross * ec[1],
518 r[2] + radial * er[2] + along * ea[2] + cross * ec[2],
519 ];
520
521 let dt_clock = t_j2000_s - clock.ref_epoch_j2000_s;
522 let mut dclock_m =
523 clock.c0_m + clock.c1_m_s * dt_clock + clock.c2_m_s2 * dt_clock * dt_clock;
524 if let Some(high_rate) = clock.high_rate {
525 if high_rate_matches(clock, &high_rate)
526 && self.correction_fresh(t_j2000_s, high_rate.ref_epoch_j2000_s)
527 {
528 dclock_m += high_rate.c0_m;
529 }
530 }
531 let corrected_clock_s = state.clock.dt_clock_total_s + dclock_m / C_M_S;
532 Some((corrected_position, corrected_clock_s))
533 }
534
535 fn correction_fresh(&self, t_j2000_s: f64, ref_epoch_j2000_s: f64) -> bool {
536 let age = (t_j2000_s - ref_epoch_j2000_s).abs();
537 age.is_finite() && age <= self.staleness.max_staleness_s
538 }
539
540 fn regional_allowed(&self, provider_id: u16) -> bool {
541 match &self.fallback.regional {
542 RegionalPolicy::DeclineRegional => false,
543 RegionalPolicy::AllowProviders(providers) => providers.contains(&provider_id),
544 }
545 }
546
547 fn broadcast_fallback(&self, sat: GnssSatelliteId, t_j2000_s: f64) -> Option<([f64; 3], f64)> {
548 if self.fallback.on_missing_correction == MissingCorrectionAction::FallBackToBroadcast {
549 self.broadcast.position_clock_at_j2000_s(sat, t_j2000_s)
550 } else {
551 None
552 }
553 }
554}
555
556impl EphemerisSource for SsrCorrectedEphemeris<'_> {
557 fn position_clock_at_j2000_s(
558 &self,
559 sat: GnssSatelliteId,
560 t_j2000_s: f64,
561 ) -> Option<([f64; 3], f64)> {
562 self.corrected_state(sat, t_j2000_s)
563 }
564}
565
566impl ObservableEphemerisSource for SsrCorrectedEphemeris<'_> {
567 fn observable_state_at_j2000_s(
568 &self,
569 sat: GnssSatelliteId,
570 t_j2000_s: f64,
571 ) -> std::result::Result<ObservableState, ObservablesError> {
572 let Some((position_ecef_m, clock_s)) = self.corrected_state(sat, t_j2000_s) else {
573 return Err(ObservablesError::NoEphemeris);
574 };
575 Ok(ObservableState {
576 position_ecef_m,
577 clock_s: Some(clock_s),
578 })
579 }
580}
581
582#[derive(Clone)]
584pub struct SsrCorrectedEphemerisOwned {
585 broadcast: Arc<BroadcastEphemeris>,
586 store: Arc<SsrCorrectionStore>,
587 staleness: StalenessPolicy,
588 fallback: SsrFallbackPolicy,
589}
590
591impl SsrCorrectedEphemerisOwned {
592 pub fn new(broadcast: Arc<BroadcastEphemeris>, store: Arc<SsrCorrectionStore>) -> Self {
594 let staleness = store.staleness();
595 Self {
596 broadcast,
597 store,
598 staleness,
599 fallback: SsrFallbackPolicy::default(),
600 }
601 }
602
603 pub fn with_staleness(mut self, policy: StalenessPolicy) -> Self {
605 self.staleness = policy;
606 self
607 }
608
609 pub fn with_fallback(mut self, policy: SsrFallbackPolicy) -> Self {
611 self.fallback = policy;
612 self
613 }
614
615 pub fn allow_regional_provider(mut self, provider_id: u16) -> Self {
617 match &mut self.fallback.regional {
618 RegionalPolicy::DeclineRegional => {
619 let mut providers = BTreeSet::new();
620 providers.insert(provider_id);
621 self.fallback.regional = RegionalPolicy::AllowProviders(providers);
622 }
623 RegionalPolicy::AllowProviders(providers) => {
624 providers.insert(provider_id);
625 }
626 }
627 self
628 }
629
630 fn borrowed(&self) -> SsrCorrectedEphemeris<'_> {
631 SsrCorrectedEphemeris::new(&self.broadcast, &self.store)
632 .with_staleness(self.staleness)
633 .with_fallback(self.fallback.clone())
634 }
635}
636
637impl EphemerisSource for SsrCorrectedEphemerisOwned {
638 fn position_clock_at_j2000_s(
639 &self,
640 sat: GnssSatelliteId,
641 t_j2000_s: f64,
642 ) -> Option<([f64; 3], f64)> {
643 self.borrowed().position_clock_at_j2000_s(sat, t_j2000_s)
644 }
645}
646
647impl ObservableEphemerisSource for SsrCorrectedEphemerisOwned {
648 fn observable_state_at_j2000_s(
649 &self,
650 sat: GnssSatelliteId,
651 t_j2000_s: f64,
652 ) -> std::result::Result<ObservableState, ObservablesError> {
653 self.borrowed().observable_state_at_j2000_s(sat, t_j2000_s)
654 }
655}
656
657fn update_interval_s(index: u8) -> f64 {
658 const TABLE: [f64; 16] = [
659 1.0,
660 2.0,
661 5.0,
662 10.0,
663 15.0,
664 30.0,
665 60.0,
666 120.0,
667 240.0,
668 300.0,
669 600.0,
670 900.0,
671 1800.0,
672 SECONDS_PER_HOUR,
673 7200.0,
674 10800.0,
675 ];
676 TABLE[usize::from(index)]
677}
678
679fn ssr_epoch_j2000_s(
680 system: GnssSystem,
681 week: GnssWeekTow,
682 epoch_time_s: u32,
683 update_interval_s: f64,
684) -> Result<f64> {
685 let scale = match system {
686 GnssSystem::Galileo => TimeScale::Gst,
687 GnssSystem::BeiDou => TimeScale::Bdt,
688 _ => TimeScale::Gpst,
689 };
690 let normalized = GnssWeekTow::new(
691 scale,
692 week.week,
693 f64::from(epoch_time_s) + update_interval_s / 2.0,
694 )
695 .and_then(GnssWeekTow::normalized)
696 .map_err(|_| Error::Parse("SSR epoch is out of range".to_string()))?;
697 let continuous = f64::from(normalized.week) * SECONDS_PER_WEEK + normalized.tow_s;
698 Ok(match system {
699 GnssSystem::BeiDou => {
700 continuous
701 + crate::constants::BDS_EPOCH_MINUS_GPS_EPOCH_S
702 + crate::constants::GPST_MINUS_BDT_S
703 - GPS_EPOCH_TO_J2000_S
704 }
705 _ => continuous - GPS_EPOCH_TO_J2000_S,
706 })
707}
708
709fn ssr_satellite(system: GnssSystem, satellite_id: u8) -> Result<GnssSatelliteId> {
710 GnssSatelliteId::new(system, satellite_id)
711 .map_err(|e| Error::Parse(format!("invalid SSR satellite id {satellite_id}: {e}")))
712}
713
714fn high_rate_matches(clock: &SsrClockCorrection, high_rate: &SsrHighRateClock) -> bool {
715 clock.solution == high_rate.solution && clock.iod_ssr == high_rate.iod_ssr
716}
717
718fn default_nav_message(system: GnssSystem) -> Option<NavMessage> {
719 match system {
720 GnssSystem::Gps => Some(NavMessage::GpsLnav),
721 GnssSystem::Galileo => Some(NavMessage::GalileoInav),
722 GnssSystem::BeiDou => Some(NavMessage::BeidouD1),
723 _ => None,
724 }
725}
726
727fn continuous_time_for_sat(sat: GnssSatelliteId, t_j2000_s: f64) -> Option<(f64, bool)> {
728 if !matches!(
729 sat.system,
730 GnssSystem::Gps | GnssSystem::Galileo | GnssSystem::BeiDou
731 ) {
732 return None;
733 }
734 let gpst_continuous = t_j2000_s + GPS_EPOCH_TO_J2000_S;
735 if sat.system == GnssSystem::BeiDou {
736 Some((
737 gpst_continuous
738 - crate::constants::GPST_MINUS_BDT_S
739 - crate::constants::BDS_EPOCH_MINUS_GPS_EPOCH_S,
740 is_beidou_geo(sat),
741 ))
742 } else {
743 Some((gpst_continuous, false))
744 }
745}
746
747fn broadcast_velocity(
748 record: &crate::rinex_nav::BroadcastRecord,
749 sat: GnssSatelliteId,
750 t_j2000_s: f64,
751 is_geo: bool,
752) -> Option<[f64; 3]> {
753 let p_plus = broadcast_position_from_record(record, sat, t_j2000_s + FD_HALF_S, is_geo)?;
754 let p_minus = broadcast_position_from_record(record, sat, t_j2000_s - FD_HALF_S, is_geo)?;
755 let denom = 2.0 * FD_HALF_S;
756 Some([
757 (p_plus[0] - p_minus[0]) / denom,
758 (p_plus[1] - p_minus[1]) / denom,
759 (p_plus[2] - p_minus[2]) / denom,
760 ])
761}
762
763fn broadcast_position_from_record(
764 record: &crate::rinex_nav::BroadcastRecord,
765 sat: GnssSatelliteId,
766 t_j2000_s: f64,
767 is_geo: bool,
768) -> Option<[f64; 3]> {
769 let (t_continuous_s, _) = continuous_time_for_sat(sat, t_j2000_s)?;
770 let sow = t_continuous_s.rem_euclid(SECONDS_PER_WEEK);
771 satellite_state_unchecked(
772 &record.elements,
773 &record.clock,
774 &record.constants(),
775 sow,
776 record.broadcast_clock_group_delay_s(),
777 is_geo,
778 )
779 .orbit
780 .position()
781 .ok()
782 .map(|p| p.as_array())
783}
784
785fn velocity_aligned_basis(r: [f64; 3], v: [f64; 3]) -> Option<([f64; 3], [f64; 3], [f64; 3])> {
786 let ea = normalize(v)?;
787 let rc = cross(r, v);
788 let ec = normalize(rc)?;
789 let er = cross(ea, ec);
790 Some((er, ea, ec))
791}
792
793fn normalize(v: [f64; 3]) -> Option<[f64; 3]> {
794 let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
795 if n > 0.0 && n.is_finite() {
796 Some([v[0] / n, v[1] / n, v[2] / n])
797 } else {
798 None
799 }
800}
801
802fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
803 [
804 a[1] * b[2] - a[2] * b[1],
805 a[2] * b[0] - a[0] * b[2],
806 a[0] * b[1] - a[1] * b[0],
807 ]
808}
809
810#[cfg(test)]
811mod tests {
812 use super::*;
813 use crate::rtcm::{
814 SsrClockRecord, SsrHeader, SsrOrbitRecord, SsrPhaseBiasRecord, SsrPhaseBiasSignal,
815 SsrStreamAssembler,
816 };
817 use crate::sp3::Sp3;
818
819 const REAL_SSRA02IGS0_1060_FRAME_HEX: &str = include_str!(concat!(
820 env!("CARGO_MANIFEST_DIR"),
821 "/tests/fixtures/ssr/SSRA02IGS0_2026181234930_1060.hex"
822 ));
823 const REAL_SSR_WEEK: u32 = 2425;
824 const REAL_SSR_EPOCH_TOW_S: f64 = 344_970.0;
825
826 fn header(kind: SsrKind) -> SsrHeader {
827 SsrHeader {
828 epoch_time_s: 100_000,
829 update_interval: 3,
830 multiple_message: false,
831 iod_ssr: 4,
832 provider_id: 7,
833 solution_id: 2,
834 satellite_reference_datum: matches!(kind, SsrKind::Orbit | SsrKind::CombinedOrbitClock)
835 .then_some(false),
836 dispersive_bias_consistency: None,
837 mw_consistency: None,
838 satellite_count: 1,
839 }
840 }
841
842 #[test]
843 fn rtcm_ingest_scales_orbit_and_clock() {
844 let message = SsrMessage {
845 message_number: 1060,
846 system: GnssSystem::Gps,
847 kind: SsrKind::CombinedOrbitClock,
848 header: header(SsrKind::CombinedOrbitClock),
849 orbit: vec![SsrOrbitRecord {
850 satellite_id: 1,
851 iode: 42,
852 delta_radial: 10_000,
853 delta_along: -20_000,
854 delta_cross: 30_000,
855 dot_delta_radial: 100,
856 dot_delta_along: -200,
857 dot_delta_cross: 300,
858 }],
859 clock: vec![SsrClockRecord {
860 satellite_id: 1,
861 c0: 10_000,
862 c1: -2_000,
863 c2: 300,
864 }],
865 code_bias: Vec::new(),
866 phase_bias: Vec::<SsrPhaseBiasRecord>::new(),
867 ura: Vec::new(),
868 padding_bits: Vec::new(),
869 };
870 let mut store = SsrCorrectionStore::new();
871 let week = GnssWeekTow::new(TimeScale::Gpst, 2_400, 100_000.0).unwrap();
872 store.ingest_ssr(&message, week).unwrap();
873 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).unwrap();
874 let orbit = store.orbit(sat).unwrap();
875 assert_eq!(orbit.iode, 42);
876 assert_eq!(orbit.radial_m.to_bits(), (-1.0_f64).to_bits());
877 assert_eq!(orbit.along_m.to_bits(), 8.0_f64.to_bits());
878 assert_eq!(orbit.cross_m.to_bits(), (-12.0_f64).to_bits());
879 assert!((orbit.radial_rate_m_s + 1.0e-4).abs() < 1.0e-18);
880 let clock = store.clock(sat).unwrap();
881 assert_eq!(clock.c0_m.to_bits(), 1.0_f64.to_bits());
882 assert!((clock.c1_m_s + 0.002).abs() < 1.0e-18);
883 assert!((clock.c2_m_s2 - 6.0e-6).abs() < 1.0e-18);
884 }
885
886 #[test]
887 fn high_rate_clock_is_additive_when_identity_matches() {
888 let mut store = SsrCorrectionStore::new();
889 let week = GnssWeekTow::new(TimeScale::Gpst, 2_400, 100_000.0).unwrap();
890 let low = SsrMessage {
891 message_number: 1058,
892 system: GnssSystem::Gps,
893 kind: SsrKind::Clock,
894 header: header(SsrKind::Clock),
895 orbit: Vec::new(),
896 clock: vec![SsrClockRecord {
897 satellite_id: 1,
898 c0: 1_000,
899 c1: 0,
900 c2: 0,
901 }],
902 code_bias: Vec::new(),
903 phase_bias: Vec::<SsrPhaseBiasRecord>::new(),
904 ura: Vec::new(),
905 padding_bits: Vec::new(),
906 };
907 let high = SsrMessage {
908 message_number: 1062,
909 system: GnssSystem::Gps,
910 kind: SsrKind::HighRateClock,
911 header: header(SsrKind::HighRateClock),
912 orbit: Vec::new(),
913 clock: vec![SsrClockRecord {
914 satellite_id: 1,
915 c0: 500,
916 c1: 0,
917 c2: 0,
918 }],
919 code_bias: Vec::new(),
920 phase_bias: vec![SsrPhaseBiasRecord {
921 satellite_id: 1,
922 yaw_angle: 0,
923 yaw_rate: 0,
924 biases: vec![SsrPhaseBiasSignal {
925 signal_id: 1,
926 integer_indicator: 0,
927 wide_lane_integer_indicator: 0,
928 discontinuity_counter: 0,
929 bias: 0,
930 }],
931 }],
932 ura: Vec::new(),
933 padding_bits: Vec::new(),
934 };
935 store.ingest_ssr(&high, week).unwrap();
936 store.ingest_ssr(&low, week).unwrap();
937 let sat = GnssSatelliteId::new(GnssSystem::Gps, 1).unwrap();
938 assert_eq!(
939 store.clock(sat).unwrap().high_rate.unwrap().c0_m.to_bits(),
940 0.05_f64.to_bits()
941 );
942 }
943
944 #[test]
945 fn corrected_ephemeris_uses_real_rtcm_broadcast_and_sp3_products() {
946 let nav_text = std::fs::read_to_string(concat!(
947 env!("CARGO_MANIFEST_DIR"),
948 "/tests/fixtures/ssr/BRDC00WRD_S_20261820000_G30_G31.rnx"
949 ))
950 .expect("read NAV fixture");
951 let broadcast = BroadcastEphemeris::from_nav(&nav_text).expect("parse NAV fixture");
952 let sp3_bytes = std::fs::read(concat!(
953 env!("CARGO_MANIFEST_DIR"),
954 "/tests/fixtures/ssr/IGS0OPSULT_20261811800_02D_15M_ORB.SP3"
955 ))
956 .expect("read SP3 fixture");
957 let sp3 = Sp3::parse(&sp3_bytes).expect("parse SP3 fixture");
958 let store = real_gps_ssr_store();
959 let source = SsrCorrectedEphemeris::new(&broadcast, &store)
960 .with_staleness(StalenessPolicy::seconds(60.0));
961 let t = ssr_j2000(REAL_SSR_EPOCH_TOW_S);
962
963 let mut orbit_error_sum_m2 = 0.0;
964 let mut clock_error_sum_ns2 = 0.0;
965 let mut count = 0_usize;
966 for sat in [
967 GnssSatelliteId::new(GnssSystem::Gps, 30).unwrap(),
968 GnssSatelliteId::new(GnssSystem::Gps, 31).unwrap(),
969 ] {
970 let (corrected_position, corrected_clock) =
971 source.corrected_state(sat, t).expect("corrected state");
972 let sp3_state = sp3.position_at_j2000_seconds(sat, t).expect("SP3 state");
973 let sp3_position = sp3_state.position.as_array();
974 let sp3_clock = sp3_state.clock_s.expect("SP3 clock");
975 let orbit_error_m = norm([
976 corrected_position[0] - sp3_position[0],
977 corrected_position[1] - sp3_position[1],
978 corrected_position[2] - sp3_position[2],
979 ]);
980 let clock_error_ns = (corrected_clock - sp3_clock) * 1.0e9;
981 orbit_error_sum_m2 += orbit_error_m * orbit_error_m;
982 clock_error_sum_ns2 += clock_error_ns * clock_error_ns;
983 count += 1;
984 }
985 assert_eq!(count, 2);
986 let orbit_rms_m = (orbit_error_sum_m2 / count as f64).sqrt();
987 let clock_rms_ns = (clock_error_sum_ns2 / count as f64).sqrt();
988
989 assert!(orbit_rms_m < 1.6, "{orbit_rms_m}");
993 assert!(clock_rms_ns < 22.0, "{clock_rms_ns}");
994 }
995
996 #[test]
997 fn corrected_position_matches_rtklib_satpos_ssr_oracle_for_one_epoch() {
998 let nav_text = std::fs::read_to_string(concat!(
999 env!("CARGO_MANIFEST_DIR"),
1000 "/tests/fixtures/ssr/BRDC00WRD_S_20261820000_G30_G31.rnx"
1001 ))
1002 .expect("read NAV fixture");
1003 let broadcast = BroadcastEphemeris::from_nav(&nav_text).expect("parse NAV fixture");
1004 let store = real_gps_ssr_store();
1005 let source = SsrCorrectedEphemeris::new(&broadcast, &store)
1006 .with_staleness(StalenessPolicy::seconds(60.0));
1007 let sat = GnssSatelliteId::new(GnssSystem::Gps, 30).unwrap();
1008 let (position, _clock) = source
1009 .corrected_state(sat, ssr_j2000(REAL_SSR_EPOCH_TOW_S))
1010 .expect("corrected state");
1011 let rtklib_position = [
1012 -6_327_381.424_159_626,
1013 15_802_129.789_888_298,
1014 -20_121_898.098_271_403,
1015 ];
1016 let position_error_m = norm([
1017 position[0] - rtklib_position[0],
1018 position[1] - rtklib_position[1],
1019 position[2] - rtklib_position[2],
1020 ]);
1021 assert!(position_error_m < 1.0e-6, "{position_error_m}");
1022 }
1023
1024 fn real_gps_ssr_store() -> SsrCorrectionStore {
1025 let mut assembler = SsrStreamAssembler::new();
1026 let mut store = SsrCorrectionStore::new();
1027 let week = GnssWeekTow::new(TimeScale::Gpst, REAL_SSR_WEEK, REAL_SSR_EPOCH_TOW_S)
1028 .expect("valid SSR week");
1029 for decoded in assembler.push(&hex_bytes(REAL_SSRA02IGS0_1060_FRAME_HEX)) {
1030 let message = decoded.expect("decode SSR frame");
1031 store.ingest(&message, week).expect("ingest SSR frame");
1032 }
1033 assert_eq!(assembler.retained_len(), 0);
1034 store
1035 }
1036
1037 fn ssr_j2000(tow_s: f64) -> f64 {
1038 f64::from(REAL_SSR_WEEK) * SECONDS_PER_WEEK + tow_s - GPS_EPOCH_TO_J2000_S
1039 }
1040
1041 fn hex_bytes(hex: &str) -> Vec<u8> {
1042 let compact: String = hex.chars().filter(|c| c.is_ascii_hexdigit()).collect();
1043 assert_eq!(compact.len() % 2, 0);
1044 compact
1045 .as_bytes()
1046 .chunks_exact(2)
1047 .map(|chunk| {
1048 let hi = (chunk[0] as char).to_digit(16).unwrap();
1049 let lo = (chunk[1] as char).to_digit(16).unwrap();
1050 ((hi << 4) | lo) as u8
1051 })
1052 .collect()
1053 }
1054
1055 fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
1056 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
1057 }
1058
1059 fn norm(v: [f64; 3]) -> f64 {
1060 dot(v, v).sqrt()
1061 }
1062}