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