Skip to main content

sidereon_core/
ephemeris.rs

1//! Ephemeris products and satellite orbit/clock evaluation.
2//!
3//! This is the main public home for loaded GNSS ephemeris data. Use
4//! [`Sp3`] for precise SP3 products and [`BroadcastEphemeris`] for broadcast
5//! navigation products parsed from RINEX NAV files (or built from records decoded
6//! off the air with [`BroadcastRecord::from_lnav`]). Both implement
7//! [`EphemerisSource`] so they can feed [`crate::positioning::solve`]; the
8//! broadcast-only real-time/offline path is
9//! [`solve_broadcast`](crate::positioning::solve_broadcast) and the
10//! precise-with-broadcast-fallback path is
11//! [`solve_with_fallback`](crate::positioning::solve_with_fallback).
12
13pub use crate::broadcast::{
14    eccentric_anomaly, relativistic_clock_correction_s, satellite_clock_offset_s,
15    satellite_position_ecef, satellite_position_ecef_cnav, satellite_state, satellite_state_cnav,
16    ClockOffset, ClockPolynomial, CnavRates, ConstellationConstants, EccentricAnomaly,
17    KeplerianElements, OrbitState, SatelliteState,
18};
19pub use crate::observables::{
20    is_observable_state_gap, observable_states_at_j2000_s, observable_states_at_shared_j2000_s,
21    ObservableEphemerisSource, ObservableStateBatch, ObservableStateElementStatus,
22    ObservablesError, OBSERVABLE_STATE_MISSING_POSITION_ECEF_M,
23};
24use crate::observables::{ObservableState, ObservablesInputErrorKind};
25pub use crate::orbit_determination::{
26    fit_all_sp3_ecef_precise_orbits, fit_all_sp3_precise_orbits,
27    fit_precise_ephemeris_sample_orbit, fit_precise_ephemeris_sample_orbit_with_initial_state,
28    fit_precise_ephemeris_sample_orbits, fit_precise_ephemeris_state_sample_orbit,
29    fit_precise_ephemeris_state_sample_orbits, fit_sp3_ecef_precise_orbit,
30    fit_sp3_ecef_precise_orbits, fit_sp3_precise_orbit, fit_sp3_precise_orbit_with_initial_state,
31    fit_sp3_precise_orbits, OrbitArcSpan, OrbitFitCovariance, OrbitFitError, OrbitFitOptions,
32    OrbitFitReport, OrbitFitSolution, OrbitResidualLedger, OrbitResidualStats,
33    OrientedPreciseEphemerisStateSample,
34};
35pub use crate::rinex_nav::{
36    cnav_ura_ned_m, cnav_ura_nominal_m, is_beidou_geo, BroadcastGroupDelayTerm,
37    BroadcastGroupDelays, BroadcastIssue, BroadcastRecord, CnavParameters, CnavSignal,
38    GlonassRecord, IonoCorrections, KlobucharAlphaBeta, LnavRecordError, NavMessage,
39    NavMessagePreference,
40};
41pub use crate::sp3::{
42    align_clock_reference, clock_reference_offset, compare_position_series, merge,
43    sp3_ecef_state_to_eci, AgreementMetric, ClockReferenceOffset, EpochAgreement,
44    InterpolationComparison, InterpolationDivergence, MergeCombine, MergeFlag, MergeOptions,
45    MergeReport, PreciseEphemerisInterpolant, PreciseEphemerisSample, PreciseEphemerisSamples,
46    PreciseEphemerisStateSample, PreciseInterpolantError, PreciseSamplesError, ReferenceState, Sp3,
47    Sp3DataType, Sp3Flags, Sp3Header, Sp3State, Sp3TimeSystem, Sp3Version,
48};
49pub use crate::spp::EphemerisSource;
50use crate::{validate, GnssSatelliteId, GnssSystem};
51
52/// Broadcast navigation ephemeris store selected by satellite and query epoch.
53///
54/// The underlying implementation type is `BroadcastStore`; the public alias
55/// names the role rather than the storage detail.
56pub type BroadcastEphemeris = crate::rinex_nav::BroadcastStore;
57
58/// Acronym-preserving alias for users who prefer the format name spelling.
59///
60/// Rust item names normally use `Sp3`; this alias keeps `SP3` available without
61/// making the implementation type fight Rust naming conventions.
62#[allow(clippy::upper_case_acronyms)]
63pub type SP3 = Sp3;
64
65/// Status for one source-agnostic ephemeris sample row.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum EphemerisSampleStatus {
68    /// The source returned a usable ECEF position for this satellite and epoch.
69    Valid,
70    /// The source had no data, or the query fell outside a valid fit interval.
71    Gap,
72}
73
74/// One satellite and epoch in a regular ephemeris sample grid.
75///
76/// For [`EphemerisSampleStatus::Valid`], `position_ecef_m` is `Some` and
77/// `clock_s` carries the source clock when available. For
78/// [`EphemerisSampleStatus::Gap`], both value fields are `None`.
79#[derive(Debug, Clone, Copy, PartialEq)]
80pub struct EphemerisSampleRow {
81    /// The sampled satellite.
82    pub sat: GnssSatelliteId,
83    /// Query epoch, seconds since J2000 in the source time scale.
84    pub epoch_j2000_s: f64,
85    /// Whether this row carries data or a gap marker.
86    pub status: EphemerisSampleStatus,
87    /// Satellite ECEF position, meters, when `status` is `Valid`.
88    pub position_ecef_m: Option<[f64; 3]>,
89    /// Satellite clock offset, seconds, when supplied by the source.
90    pub clock_s: Option<f64>,
91}
92
93impl EphemerisSampleRow {
94    /// Build a valid ephemeris sample row.
95    pub const fn valid(
96        sat: GnssSatelliteId,
97        epoch_j2000_s: f64,
98        position_ecef_m: [f64; 3],
99        clock_s: Option<f64>,
100    ) -> Self {
101        Self {
102            sat,
103            epoch_j2000_s,
104            status: EphemerisSampleStatus::Valid,
105            position_ecef_m: Some(position_ecef_m),
106            clock_s,
107        }
108    }
109
110    /// Build a gap ephemeris sample row.
111    pub const fn gap(sat: GnssSatelliteId, epoch_j2000_s: f64) -> Self {
112        Self {
113            sat,
114            epoch_j2000_s,
115            status: EphemerisSampleStatus::Gap,
116            position_ecef_m: None,
117            clock_s: None,
118        }
119    }
120
121    /// Whether this row is a gap marker.
122    pub const fn is_gap(&self) -> bool {
123        matches!(self.status, EphemerisSampleStatus::Gap)
124    }
125}
126
127/// Sample an ephemeris source on a regular inclusive time grid.
128///
129/// Rows are returned in satellite-major order: for each satellite in
130/// `satellites`, the function emits `start_j2000_s`, `start_j2000_s + step_s`,
131/// and so on up to `stop_j2000_s`, with a final row snapped to `stop_j2000_s`
132/// when the step does not land exactly on the end. If `start_j2000_s >
133/// stop_j2000_s`, the returned grid is empty.
134///
135/// Missing satellite data and out-of-fit-interval epochs are represented as
136/// [`EphemerisSampleStatus::Gap`] rows, not as errors. Invalid inputs and invalid
137/// source states remain errors.
138pub fn sample(
139    source: &dyn ObservableEphemerisSource,
140    satellites: &[GnssSatelliteId],
141    start_j2000_s: f64,
142    stop_j2000_s: f64,
143    step_s: f64,
144) -> core::result::Result<Vec<EphemerisSampleRow>, ObservablesError> {
145    let epochs = sample_epochs(start_j2000_s, stop_j2000_s, step_s)?;
146    let mut rows = Vec::with_capacity(satellites.len().saturating_mul(epochs.len()));
147    for &sat in satellites {
148        for &epoch_j2000_s in &epochs {
149            rows.push(sample_one(source, sat, epoch_j2000_s)?);
150        }
151    }
152    Ok(rows)
153}
154
155/// Select a broadcast TGD/BGD value from a parsed delay set.
156///
157/// This is a function-form accessor for [`BroadcastGroupDelays::get`], useful
158/// when composing the group-delay term independently from a full broadcast clock
159/// evaluation.
160pub const fn broadcast_group_delay_s(
161    delays: &BroadcastGroupDelays,
162    term: BroadcastGroupDelayTerm,
163) -> Option<f64> {
164    delays.get(term)
165}
166
167/// Select the broadcast group delay used by a navigation message's clock model.
168///
169/// This is a function-form accessor for [`BroadcastGroupDelays::for_message`].
170/// It returns `None` when the supplied delay set does not carry a term for the
171/// requested constellation/message pair.
172pub const fn broadcast_message_group_delay_s(
173    delays: BroadcastGroupDelays,
174    system: GnssSystem,
175    message: NavMessage,
176) -> Option<f64> {
177    delays.for_message(system, message)
178}
179
180/// Group delay selected by a parsed broadcast navigation record's clock model.
181///
182/// This is a function-form accessor for
183/// [`BroadcastRecord::broadcast_clock_group_delay_s`]. It returns zero when the
184/// record carries no applicable TGD/BGD term, matching the broadcast clock
185/// evaluator.
186pub fn broadcast_record_group_delay_s(record: &BroadcastRecord) -> f64 {
187    record.broadcast_clock_group_delay_s()
188}
189
190fn sample_epochs(
191    start_j2000_s: f64,
192    stop_j2000_s: f64,
193    step_s: f64,
194) -> core::result::Result<Vec<f64>, ObservablesError> {
195    validate::finite(start_j2000_s, "start_j2000_s").map_err(map_sample_input_error)?;
196    validate::finite(stop_j2000_s, "stop_j2000_s").map_err(map_sample_input_error)?;
197    validate::finite_positive(step_s, "step_s").map_err(map_sample_input_error)?;
198
199    if start_j2000_s > stop_j2000_s {
200        return Ok(Vec::new());
201    }
202
203    let mut epochs = Vec::new();
204    let mut step_index = 0usize;
205    loop {
206        let epoch = start_j2000_s + step_s * step_index as f64;
207        if epoch > stop_j2000_s {
208            break;
209        }
210        if epochs.last().is_some_and(|last| epoch <= *last) {
211            return Err(sample_input_error(
212                "step_s",
213                ObservablesInputErrorKind::OutOfRange,
214            ));
215        }
216        epochs.push(epoch);
217        step_index = step_index
218            .checked_add(1)
219            .ok_or_else(|| sample_input_error("step_s", ObservablesInputErrorKind::OutOfRange))?;
220    }
221
222    if let Some(&last) = epochs.last() {
223        if last < stop_j2000_s {
224            epochs.push(stop_j2000_s);
225        }
226    }
227
228    Ok(epochs)
229}
230
231fn sample_one(
232    source: &dyn ObservableEphemerisSource,
233    sat: GnssSatelliteId,
234    epoch_j2000_s: f64,
235) -> core::result::Result<EphemerisSampleRow, ObservablesError> {
236    match source.observable_state_at_j2000_s(sat, epoch_j2000_s) {
237        Ok(state) => sample_row_from_state(sat, epoch_j2000_s, state),
238        Err(error) if is_observable_state_gap(&error) => {
239            Ok(EphemerisSampleRow::gap(sat, epoch_j2000_s))
240        }
241        Err(error) => Err(error),
242    }
243}
244
245fn sample_row_from_state(
246    sat: GnssSatelliteId,
247    epoch_j2000_s: f64,
248    state: ObservableState,
249) -> core::result::Result<EphemerisSampleRow, ObservablesError> {
250    validate::finite_vec3(state.position_ecef_m, "observable state position_ecef_m")
251        .map_err(map_sample_input_error)?;
252    if let Some(clock_s) = state.clock_s {
253        validate::finite(clock_s, "observable state clock_s").map_err(map_sample_input_error)?;
254    }
255    Ok(EphemerisSampleRow::valid(
256        sat,
257        epoch_j2000_s,
258        state.position_ecef_m,
259        state.clock_s,
260    ))
261}
262
263fn map_sample_input_error(error: validate::FieldError) -> ObservablesError {
264    sample_input_error(error.field(), ObservablesInputErrorKind::from(&error))
265}
266
267fn sample_input_error(field: &'static str, kind: ObservablesInputErrorKind) -> ObservablesError {
268    ObservablesError::InvalidInput { field, kind }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn broadcast_group_delay_free_functions_delegate_to_delay_set() {
277        let gps = BroadcastGroupDelays::gps_lnav(-2.3e-9);
278        assert_eq!(
279            broadcast_group_delay_s(&gps, BroadcastGroupDelayTerm::GpsTgd),
280            Some(-2.3e-9)
281        );
282        assert_eq!(
283            broadcast_message_group_delay_s(gps, GnssSystem::Gps, NavMessage::GpsLnav),
284            Some(-2.3e-9)
285        );
286
287        let galileo = BroadcastGroupDelays::galileo(1.0e-9, 2.0e-9);
288        assert_eq!(
289            broadcast_message_group_delay_s(galileo, GnssSystem::Galileo, NavMessage::GalileoFnav),
290            Some(1.0e-9)
291        );
292        assert_eq!(
293            broadcast_message_group_delay_s(galileo, GnssSystem::Galileo, NavMessage::GalileoInav),
294            Some(2.0e-9)
295        );
296    }
297
298    #[test]
299    fn broadcast_record_group_delay_free_function_delegates_to_record() {
300        let record = BroadcastRecord {
301            satellite_id: crate::GnssSatelliteId::new(GnssSystem::Galileo, 1)
302                .expect("valid satellite"),
303            message: NavMessage::GalileoInav,
304            issue_of_data: BroadcastIssue {
305                issue: 0,
306                message: NavMessage::GalileoInav,
307            },
308            week: 2_400,
309            toe: crate::astro::time::model::GnssWeekTow::new(
310                crate::astro::time::model::TimeScale::Gst,
311                2_400,
312                100_000.0,
313            )
314            .expect("valid toe"),
315            toc: crate::astro::time::model::GnssWeekTow::new(
316                crate::astro::time::model::TimeScale::Gst,
317                2_400,
318                100_000.0,
319            )
320            .expect("valid toc"),
321            elements: KeplerianElements {
322                sqrt_a: 5_440.0,
323                e: 0.01,
324                m0: 0.1,
325                delta_n: 0.0,
326                omega0: 0.2,
327                i0: 0.94,
328                omega: 0.3,
329                omega_dot: -8.0e-9,
330                idot: 0.0,
331                cuc: 0.0,
332                cus: 0.0,
333                crc: 0.0,
334                crs: 0.0,
335                cic: 0.0,
336                cis: 0.0,
337                toe_sow: 100_000.0,
338            },
339            clock: ClockPolynomial {
340                af0: 0.0,
341                af1: 0.0,
342                af2: 0.0,
343                toc_sow: 100_000.0,
344            },
345            group_delays: BroadcastGroupDelays::galileo(1.0e-9, 2.5e-9),
346            cnav: None,
347            sv_health: 0.0,
348            sv_accuracy_m: 1.0,
349            fit_interval_s: None,
350        };
351
352        assert_eq!(
353            broadcast_record_group_delay_s(&record).to_bits(),
354            record.broadcast_clock_group_delay_s().to_bits()
355        );
356        assert_eq!(
357            broadcast_record_group_delay_s(&record).to_bits(),
358            2.5e-9_f64.to_bits()
359        );
360    }
361}