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