Skip to main content

sidereon_core/ssr/
mod.rs

1//! Engineering-unit State Space Representation corrections.
2//!
3//! The RTCM module stores raw transmitted integers. This module stores scaled
4//! correction values keyed by satellite, with enough provider and issue metadata
5//! to apply orbit and clock corrections on top of broadcast ephemerides.
6
7use 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;
24/// RTCM 10403.x SSR radial orbit and clock C0 resolution, meters.
25const RTCM_SSR_RADIAL_CLOCK_SCALE_M: f64 = 1.0e-4;
26/// RTCM 10403.x SSR along-track and cross-track orbit resolution, meters.
27const RTCM_SSR_ALONG_CROSS_SCALE_M: f64 = 4.0e-4;
28/// RTCM 10403.x SSR radial-rate and clock C1 resolution, meters per second.
29const RTCM_SSR_RADIAL_CLOCK_RATE_SCALE_M_S: f64 = 1.0e-6;
30/// RTCM 10403.x SSR along/cross-rate resolution, meters per second.
31const RTCM_SSR_ALONG_CROSS_RATE_SCALE_M_S: f64 = 4.0e-6;
32/// RTCM 10403.x SSR clock C2 resolution, meters per second squared.
33const RTCM_SSR_CLOCK_ACCEL_SCALE_M_S2: f64 = 2.0e-8;
34
35/// Which stream produced a correction.
36#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
37pub enum SsrSource {
38    /// RTCM SSR messages.
39    RtcmSsr,
40    /// Galileo HAS messages.
41    GalileoHas,
42}
43
44/// Orbital basis used by stored RAC components.
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
46pub enum OrbitBasis {
47    /// Velocity-aligned basis used by RTCM SSR and HAS.
48    VelocityAligned,
49}
50
51/// Reference point of the corrected orbit.
52#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
53pub enum OrbitReferencePoint {
54    /// Satellite antenna phase center.
55    AntennaPhaseCenter,
56    /// Satellite center of mass.
57    CenterOfMass,
58}
59
60/// Provider and solution identity.
61#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
62pub struct SsrSolution {
63    /// Correction source.
64    pub source: SsrSource,
65    /// Provider id.
66    pub provider_id: u16,
67    /// Solution id.
68    pub solution_id: u8,
69}
70
71/// Orbit correction for one satellite.
72#[derive(Clone, Copy, Debug, PartialEq)]
73pub struct SsrOrbitCorrection {
74    /// Provider and solution identity.
75    pub solution: SsrSolution,
76    /// Referenced broadcast issue.
77    pub iode: u32,
78    /// IOD SSR.
79    pub iod_ssr: u8,
80    /// Orbit basis.
81    pub basis: OrbitBasis,
82    /// True when the RTCM reference datum bit marks a regional CRS.
83    pub crs_regional: bool,
84    /// Orbit reference point policy.
85    pub reference_point: OrbitReferencePoint,
86    /// Radial delta to add, meters.
87    pub radial_m: f64,
88    /// Along-track delta to add, meters.
89    pub along_m: f64,
90    /// Cross-track delta to add, meters.
91    pub cross_m: f64,
92    /// Radial delta rate to add, meters per second.
93    pub radial_rate_m_s: f64,
94    /// Along-track delta rate to add, meters per second.
95    pub along_rate_m_s: f64,
96    /// Cross-track delta rate to add, meters per second.
97    pub cross_rate_m_s: f64,
98    /// Reference epoch, seconds since J2000.
99    pub ref_epoch_j2000_s: f64,
100    /// Update interval, seconds.
101    pub update_interval_s: f64,
102}
103
104/// High-rate clock correction for one satellite.
105#[derive(Clone, Copy, Debug, PartialEq)]
106pub struct SsrHighRateClock {
107    /// Provider and solution identity.
108    pub solution: SsrSolution,
109    /// IOD SSR.
110    pub iod_ssr: u8,
111    /// Additive C0 term, meters.
112    pub c0_m: f64,
113    /// Reference epoch, seconds since J2000.
114    pub ref_epoch_j2000_s: f64,
115    /// Update interval, seconds.
116    pub update_interval_s: f64,
117}
118
119/// Clock correction for one satellite.
120#[derive(Clone, Copy, Debug, PartialEq)]
121pub struct SsrClockCorrection {
122    /// Provider and solution identity.
123    pub solution: SsrSolution,
124    /// IOD SSR.
125    pub iod_ssr: u8,
126    /// C0 term, meters.
127    pub c0_m: f64,
128    /// C1 term, meters per second.
129    pub c1_m_s: f64,
130    /// C2 term, meters per second squared.
131    pub c2_m_s2: f64,
132    /// Reference epoch, seconds since J2000.
133    pub ref_epoch_j2000_s: f64,
134    /// Update interval, seconds.
135    pub update_interval_s: f64,
136    /// Matching high-rate correction, when present.
137    pub high_rate: Option<SsrHighRateClock>,
138}
139
140/// Code-bias correction placeholder for Phase B.
141#[derive(Clone, Debug, Default, PartialEq)]
142pub struct SsrCodeBias {
143    biases_m: BTreeMap<u8, f64>,
144}
145
146/// Phase-bias correction placeholder for Phase B.
147#[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/// Active SSR corrections keyed by satellite.
163#[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    /// Build an empty correction store.
178    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    /// Set the orbit reference point policy for later ingests.
187    pub fn with_reference_point(mut self, reference_point: OrbitReferencePoint) -> Self {
188        self.reference_point = reference_point;
189        self
190    }
191
192    /// Set the store staleness policy.
193    pub fn with_staleness(mut self, policy: StalenessPolicy) -> Self {
194        self.staleness = policy;
195        self
196    }
197
198    /// The store-level staleness policy.
199    pub fn staleness(&self) -> StalenessPolicy {
200        self.staleness
201    }
202
203    /// Ingest one RTCM message, ignoring non-SSR messages.
204    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    /// Ingest one decoded RTCM SSR message.
212    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    /// Orbit correction for a satellite.
320    pub fn orbit(&self, sat: GnssSatelliteId) -> Option<&SsrOrbitCorrection> {
321        self.corrections.get(&sat)?.orbit.as_ref()
322    }
323
324    /// Clock correction for a satellite.
325    pub fn clock(&self, sat: GnssSatelliteId) -> Option<&SsrClockCorrection> {
326        self.corrections.get(&sat)?.clock.as_ref()
327    }
328
329    /// URA index for a satellite.
330    pub fn ura_index(&self, sat: GnssSatelliteId) -> Option<u8> {
331        self.corrections.get(&sat)?.ura_index
332    }
333
334    /// Code bias in meters for a satellite and raw signal id.
335    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    /// Phase bias for a satellite.
345    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/// Behavior when a correction is missing or stale.
382#[derive(Clone, Copy, Debug, PartialEq, Eq)]
383pub enum MissingCorrectionAction {
384    /// Decline the satellite.
385    Decline,
386    /// Return the plain broadcast state.
387    FallBackToBroadcast,
388}
389
390/// Regional CRS handling.
391#[derive(Clone, Debug, PartialEq, Eq)]
392pub enum RegionalPolicy {
393    /// Decline regional corrections.
394    DeclineRegional,
395    /// Allow regional corrections from these provider ids.
396    AllowProviders(BTreeSet<u16>),
397}
398
399/// Missing-correction and regional policy for corrected ephemerides.
400#[derive(Clone, Debug, PartialEq, Eq)]
401pub struct SsrFallbackPolicy {
402    /// Missing or stale correction behavior.
403    pub on_missing_correction: MissingCorrectionAction,
404    /// Regional CRS behavior.
405    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/// Broadcast ephemeris corrected by an SSR store.
418#[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    /// Build a corrected source from borrowed broadcast and SSR stores.
428    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    /// Set the staleness policy.
438    pub fn with_staleness(mut self, policy: StalenessPolicy) -> Self {
439        self.staleness = policy;
440        self
441    }
442
443    /// Set missing-correction and regional behavior.
444    pub fn with_fallback(mut self, policy: SsrFallbackPolicy) -> Self {
445        self.fallback = policy;
446        self
447    }
448
449    /// Mark one regional provider as applicable.
450    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    /// Corrected ECEF position and satellite clock at a J2000 epoch.
465    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/// Owned corrected ephemeris source.
583#[derive(Clone)]
584pub struct SsrCorrectedEphemerisOwned {
585    broadcast: Arc<BroadcastEphemeris>,
586    store: Arc<SsrCorrectionStore>,
587    staleness: StalenessPolicy,
588    fallback: SsrFallbackPolicy,
589}
590
591impl SsrCorrectedEphemerisOwned {
592    /// Build an owned corrected source.
593    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    /// Set the staleness policy.
604    pub fn with_staleness(mut self, policy: StalenessPolicy) -> Self {
605        self.staleness = policy;
606        self
607    }
608
609    /// Set missing-correction and regional behavior.
610    pub fn with_fallback(mut self, policy: SsrFallbackPolicy) -> Self {
611        self.fallback = policy;
612        self
613    }
614
615    /// Mark one regional provider as applicable.
616    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        // The fixture is the closest public triple assembled on 2026-07-02:
990        // captured SSRA02IGS0, matching broadcast records, and ultra-rapid SP3.
991        // A rapid or final SP3 for this UTC day was not available at capture time.
992        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}