Skip to main content

sidereon_core/spp/
fallback.rs

1//! First-class broadcast-ephemeris positioning and a precise-with-broadcast
2//! fallback entry that carries source and staleness provenance.
3//!
4//! Precise products (SP3 orbit and clock) deliver the most accurate satellite
5//! positions, but they publish with latency and require a network fetch, so the
6//! product for the exact requested epoch is not always on hand. Broadcast
7//! ephemeris, decoded from the navigation message a receiver already tracks, is
8//! always available and needs no network, which is what enables real-time and
9//! offline positioning. The accuracy gap between the two is bounded and well
10//! characterized (see the accuracy-delta note below), so a system that prefers
11//! precise when it is fresh and degrades to broadcast otherwise gets the best
12//! available fix at every epoch without ever stalling.
13//!
14//! This module wires those two paths into the public surface:
15//!
16//! - [`solve_broadcast`] is the explicit broadcast-only SPP entry. A
17//!   [`BroadcastEphemeris`] is an [`EphemerisSource`], so feeding it to the
18//!   generic [`solve`](crate::positioning::solve) already works; this is the
19//!   supported, named real-time/offline mode rather than that fact left implicit.
20//!   The decode-to-source half of the pipeline is
21//!   [`BroadcastRecord::from_lnav`](crate::ephemeris::BroadcastRecord::from_lnav),
22//!   which turns decoded GPS LNAV subframes into a record a
23//!   [`BroadcastEphemeris`] can hold, so the full chain is
24//!   `lnav::decode -> BroadcastRecord::from_lnav -> BroadcastStore -> solve_broadcast`.
25//! - [`solve_with_fallback`] is the unified entry: try the precise path through
26//!   the product-staleness selection layer ([`select_sp3`]); if no precise
27//!   product covers the epoch or the nearest one is beyond the staleness cap,
28//!   fall back to the broadcast path. The result is a [`SourcedSolution`] whose
29//!   [`FixSource`] names which source produced the fix (precise-exact,
30//!   precise-degraded, or broadcast) and carries the [`StalenessMetadata`] /
31//!   rejection reason, so a degraded or substituted answer is never silent.
32//!
33//! # Correctness
34//!
35//! When a precise product covers the requested epoch, the selection layer returns
36//! the caller's product untouched and the fallback solve is bit-for-bit identical
37//! to calling [`solve`](crate::positioning::solve) on that SP3 directly: the
38//! broadcast path is purely additive and changes no precise-present output bit. A
39//! solve failure on a product that covers the exact epoch is a genuine error,
40//! surfaced as [`FallbackError::Precise`] rather than masked by silently
41//! re-solving on broadcast. Broadcast is used when the staleness selection
42//! declines outright, or when a stale-but-within-cap product is selected and then
43//! cannot serve the epoch; in both cases the result's [`FixSource::Broadcast`]
44//! records the reason ([`BroadcastReason`]), so the source is never substituted
45//! silently.
46//!
47//! # Expected broadcast-vs-precise accuracy delta
48//!
49//! The broadcast and precise SPP solutions differ by the broadcast signal-in-space
50//! range error (SISRE): the broadcast orbit and clock are a least-squares fit and
51//! a polynomial extrapolation, where the precise product is a post-processed
52//! estimate. For healthy GPS the broadcast orbit error is roughly 1-2 m RMS (3D),
53//! dominated by the along-track and radial components, and the broadcast satellite
54//! clock adds a comparable error (see [`crate::broadcast_comparison`], which
55//! measures exactly this on a committed reference arc). A common per-epoch clock
56//! offset absorbs into the estimated receiver clock, but the per-satellite orbit
57//! error and clock scatter do not, and on an L1-only solve the broadcast clock
58//! (which subtracts TGD for the single-frequency user) differs from the precise
59//! ionosphere-free SP3 clock (no TGD) by a further per-satellite amount. Mapped
60//! through the geometry, the *position* difference between a broadcast-only and a
61//! precise SPP fix on the same pseudoranges is therefore at the ~10 m level at a
62//! single epoch (not merely the orbit RMS). The reference-arc integration test
63//! `broadcast_spp_fallback_arc` measures ~13 m and asserts agreement within a
64//! labeled 20 m bound; that bound is the documented accuracy delta, not a
65//! bit-exact claim (two orbit/clock sources legitimately differ at the meter
66//! level).
67//!
68//! # Network
69//!
70//! This module is pure and no-network, like the rest of `sidereon-core`: it
71//! selects among products the caller has already parsed and solves in memory.
72//! Fetching SP3/clock products or collecting the navigation message is a
73//! per-binding concern.
74
75use crate::ephemeris::{BroadcastEphemeris, Sp3};
76use crate::staleness::{select_sp3, SelectionError, StalenessMetadata, StalenessPolicy};
77
78use super::{solve, EphemerisSource, ReceiverSolution, SolveInputs, SppError};
79
80/// Which ephemeris source produced a [`SourcedSolution`], with its provenance.
81///
82/// A fallback solve never substitutes a source silently: this enum is always
83/// present on the result and records both which source was used and how it
84/// related to the requested epoch.
85///
86/// This is not [`PartialEq`] because its broadcast reason can carry an
87/// [`SppError`], which is not comparable; classify with the `is_*` accessors or
88/// match on the variant.
89#[derive(Debug, Clone)]
90pub enum FixSource {
91    /// A precise SP3 product produced the fix. The carried [`StalenessMetadata`]
92    /// distinguishes a precise-exact result
93    /// ([`DegradationKind::Exact`](crate::staleness::DegradationKind::Exact),
94    /// zero staleness) from a precise-degraded one
95    /// ([`DegradationKind::NearestPrior`](crate::staleness::DegradationKind::NearestPrior),
96    /// nonzero staleness) and reports the source epoch and staleness.
97    Precise(StalenessMetadata),
98    /// The broadcast ephemeris path produced the fix because the precise path was
99    /// not used. The carried [`BroadcastReason`] explains why, so the substitution
100    /// is always explicit.
101    Broadcast(BroadcastReason),
102}
103
104/// Why [`solve_with_fallback`] produced a fix from broadcast ephemeris.
105///
106/// A broadcast fix is never substituted silently: the result records whether the
107/// precise selection was declined outright, or a stale-but-within-cap precise
108/// product was selected and then turned out unusable for the requested epoch.
109#[derive(Debug, Clone)]
110pub enum BroadcastReason {
111    /// The precise product staleness selection declined: there was no precise
112    /// product set, none covering or preceding the epoch, or the nearest product
113    /// was beyond the staleness cap. The selection layer's [`SelectionError`] is
114    /// the exact reason.
115    PreciseUnavailable(SelectionError),
116    /// A stale (within-cap) precise product was selected, but it could not produce
117    /// a fix for the requested epoch -- typically its coverage does not reach the
118    /// epoch (an SP3 nearest-prior product ends before it). This is the
119    /// "precise unavailable for this epoch" condition the fallback exists for, so
120    /// broadcast was used; the selected product's staleness and the precise solve
121    /// error are carried so the degraded-then-fell-back path is explicit. A solve
122    /// failure on a product that DOES cover the epoch is a genuine error and is
123    /// returned as [`FallbackError::Precise`] instead, not turned into this.
124    PreciseDegradedUnusable {
125        /// Staleness of the degraded precise product that was tried.
126        staleness: StalenessMetadata,
127        /// The precise solve error that triggered the fallback.
128        error: SppError,
129    },
130}
131
132impl BroadcastReason {
133    /// The precise selection's staleness for the degraded-then-fell-back case, or
134    /// `None` when the precise selection was declined outright. This is the
135    /// staleness of the precise product that was *not* used; the broadcast fix
136    /// itself carries no precise staleness.
137    pub fn attempted_staleness(&self) -> Option<StalenessMetadata> {
138        match self {
139            BroadcastReason::PreciseUnavailable(_) => None,
140            BroadcastReason::PreciseDegradedUnusable { staleness, .. } => Some(*staleness),
141        }
142    }
143}
144
145impl FixSource {
146    /// Whether a precise SP3 product produced the fix (exact or degraded).
147    pub fn is_precise(&self) -> bool {
148        matches!(self, FixSource::Precise(_))
149    }
150
151    /// Whether the broadcast path produced the fix.
152    pub fn is_broadcast(&self) -> bool {
153        matches!(self, FixSource::Broadcast(_))
154    }
155
156    /// Whether a precise product covering the exact epoch produced the fix (no
157    /// degradation, zero staleness).
158    pub fn is_precise_exact(&self) -> bool {
159        matches!(self, FixSource::Precise(meta) if meta.kind.is_exact())
160    }
161
162    /// The staleness metadata of the source that produced the fix: the precise
163    /// product's staleness for a precise fix, or `None` for a broadcast fix (the
164    /// broadcast fix is not backed by a precise product). For the
165    /// degraded-then-fell-back case, the staleness of the precise product that was
166    /// *tried* is available via
167    /// [`BroadcastReason::attempted_staleness`].
168    pub fn staleness(&self) -> Option<StalenessMetadata> {
169        match self {
170            FixSource::Precise(meta) => Some(*meta),
171            FixSource::Broadcast(_) => None,
172        }
173    }
174}
175
176/// A receiver solution paired with the provenance of the ephemeris that produced
177/// it.
178///
179/// Returned by [`solve_with_fallback`]. The public language bindings wrap this as
180/// the real-time positioning result so callers always see which source and how
181/// stale the fix is.
182#[derive(Debug, Clone)]
183pub struct SourcedSolution {
184    /// The solved receiver position/clock with its geometry diagnostics.
185    pub solution: ReceiverSolution,
186    /// Which ephemeris source produced the fix, with its staleness/rejection
187    /// provenance.
188    pub source: FixSource,
189}
190
191/// Error from [`solve_with_fallback`], tagged with which path failed.
192#[derive(Debug, Clone)]
193pub enum FallbackError {
194    /// A usable precise product was selected but its SPP solve failed. The
195    /// fallback does not silently re-solve on broadcast in this case, since the
196    /// precise product was fresh enough to use; the underlying solve error is
197    /// surfaced.
198    Precise(SppError),
199    /// The broadcast fallback path was taken (the precise selection was declined)
200    /// and its SPP solve failed.
201    Broadcast(SppError),
202}
203
204impl core::fmt::Display for FallbackError {
205    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
206        match self {
207            FallbackError::Precise(error) => write!(f, "precise SPP solve failed: {error}"),
208            FallbackError::Broadcast(error) => {
209                write!(f, "broadcast-fallback SPP solve failed: {error}")
210            }
211        }
212    }
213}
214
215impl std::error::Error for FallbackError {
216    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
217        match self {
218            FallbackError::Precise(error) | FallbackError::Broadcast(error) => Some(error),
219        }
220    }
221}
222
223/// Solve a receiver position from broadcast ephemeris ALONE: the supported
224/// real-time / offline single-point-positioning mode.
225///
226/// This is the explicit broadcast-only entry point. Broadcast ephemeris decoded
227/// from the navigation message is always available and needs no network, so this
228/// is the path a receiver uses when no precise product is on hand. It is a thin,
229/// named wrapper over the generic [`solve`](crate::positioning::solve): a
230/// [`BroadcastEphemeris`] is an [`EphemerisSource`], so the result is bit-for-bit
231/// identical to calling `solve(&broadcast, inputs, with_geodetic)`. Taking the
232/// concrete [`BroadcastEphemeris`] makes the broadcast-only contract explicit in
233/// the type system rather than relying on the caller to pass the right source.
234///
235/// The store can come from a parsed RINEX navigation file
236/// ([`BroadcastEphemeris::from_nav`](crate::ephemeris::BroadcastEphemeris::from_nav))
237/// or from records decoded straight off the air via
238/// [`BroadcastRecord::from_lnav`](crate::ephemeris::BroadcastRecord::from_lnav)
239/// and [`BroadcastEphemeris::new`](crate::ephemeris::BroadcastEphemeris::new),
240/// which closes the `lnav::decode -> broadcast source` half of the real-time
241/// pipeline.
242pub fn solve_broadcast(
243    broadcast: &BroadcastEphemeris,
244    inputs: &SolveInputs,
245    with_geodetic: bool,
246) -> Result<ReceiverSolution, SppError> {
247    solve(broadcast, inputs, with_geodetic)
248}
249
250/// Solve a receiver position, preferring precise products and falling back to
251/// broadcast ephemeris, reporting which source was used and how stale it is.
252///
253/// The precise path is tried first through the product-staleness selection layer
254/// ([`select_sp3`]) at the receive epoch (`inputs.t_rx_j2000_s`):
255///
256/// - If a precise product covers the epoch ([`DegradationKind::Exact`]) it is
257///   used. The solve is bit-for-bit identical to
258///   [`solve`](crate::positioning::solve) on that SP3 (the selection layer borrows
259///   the caller's product untouched), and the result is
260///   [`FixSource::Precise`] with zero staleness. A solve failure here is a genuine
261///   error (the data covers the epoch), returned as [`FallbackError::Precise`],
262///   never masked by a silent broadcast re-solve.
263/// - If a stale-but-within-cap precise product is selected
264///   ([`DegradationKind::NearestPrior`]) and it actually produces a fix, the
265///   result is [`FixSource::Precise`] carrying the nonzero
266///   [`StalenessMetadata`]. If instead it cannot serve the requested epoch (its
267///   coverage ends before it, so the solve fails on missing ephemeris), broadcast
268///   produces the fix and the result is
269///   [`FixSource::Broadcast`]`(`[`BroadcastReason::PreciseDegradedUnusable`]`)`,
270///   carrying the tried product's staleness and the precise solve error. This is
271///   the "precise unavailable for this epoch" condition the fallback exists for.
272/// - If the precise selection is declined outright (no product set, none covering
273///   or preceding the epoch, or the nearest beyond the staleness cap), broadcast
274///   produces the fix and the result is
275///   [`FixSource::Broadcast`]`(`[`BroadcastReason::PreciseUnavailable`]`)` carrying
276///   the selection layer's [`SelectionError`].
277///
278/// A broadcast fix is therefore never substituted silently: its [`BroadcastReason`]
279/// always records why precise was not used.
280///
281/// `policy` bounds how stale a precise product may be before broadcast is
282/// preferred; a generous cap keeps precise in use across normal product latency,
283/// a zero cap forces broadcast whenever no product covers the exact epoch.
284pub fn solve_with_fallback(
285    precise: &[Sp3],
286    broadcast: &dyn EphemerisSource,
287    inputs: &SolveInputs,
288    policy: StalenessPolicy,
289    with_geodetic: bool,
290) -> Result<SourcedSolution, FallbackError> {
291    match select_sp3(precise, inputs.t_rx_j2000_s, policy) {
292        Ok(selection) => {
293            let metadata = selection.metadata();
294            match solve(&selection, inputs, with_geodetic) {
295                Ok(solution) => Ok(SourcedSolution {
296                    solution,
297                    source: FixSource::Precise(metadata),
298                }),
299                Err(error) if metadata.kind.is_exact() => {
300                    // The product covers the exact epoch, so a solve failure is a
301                    // genuine error (geometry, inputs, or a real ephemeris gap),
302                    // not staleness. Surface it rather than masking it on broadcast.
303                    Err(FallbackError::Precise(error))
304                }
305                Err(error) => {
306                    // A degraded (stale, within-cap) product was selected but could
307                    // not produce a fix for the requested epoch. That is exactly the
308                    // condition the fallback exists for, so use broadcast and record
309                    // the degraded-then-fell-back provenance.
310                    broadcast_fix(
311                        broadcast,
312                        inputs,
313                        with_geodetic,
314                        BroadcastReason::PreciseDegradedUnusable {
315                            staleness: metadata,
316                            error,
317                        },
318                    )
319                }
320            }
321        }
322        Err(precise_rejection) => broadcast_fix(
323            broadcast,
324            inputs,
325            with_geodetic,
326            BroadcastReason::PreciseUnavailable(precise_rejection),
327        ),
328    }
329}
330
331/// Solve on the broadcast source and tag the result with `reason`.
332fn broadcast_fix(
333    broadcast: &dyn EphemerisSource,
334    inputs: &SolveInputs,
335    with_geodetic: bool,
336    reason: BroadcastReason,
337) -> Result<SourcedSolution, FallbackError> {
338    let solution = solve(broadcast, inputs, with_geodetic).map_err(FallbackError::Broadcast)?;
339    Ok(SourcedSolution {
340        solution,
341        source: FixSource::Broadcast(reason),
342    })
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use crate::constants::{SECONDS_PER_DAY, SECONDS_PER_HOUR};
349    use crate::staleness::DegradationKind;
350
351    fn meta(kind: DegradationKind, staleness_s: f64) -> StalenessMetadata {
352        StalenessMetadata {
353            kind,
354            requested_epoch_j2000_s: 1000.0,
355            source_epoch_j2000_s: 1000.0 - staleness_s,
356            staleness_s,
357            staleness_days: staleness_s / SECONDS_PER_DAY,
358        }
359    }
360
361    #[test]
362    fn fix_source_precise_exact_classification() {
363        let exact = FixSource::Precise(meta(DegradationKind::Exact, 0.0));
364        assert!(exact.is_precise());
365        assert!(exact.is_precise_exact());
366        assert!(!exact.is_broadcast());
367        assert_eq!(exact.staleness().map(|m| m.staleness_s), Some(0.0));
368    }
369
370    #[test]
371    fn fix_source_precise_degraded_is_not_exact() {
372        let degraded = FixSource::Precise(meta(DegradationKind::NearestPrior, SECONDS_PER_HOUR));
373        assert!(degraded.is_precise());
374        assert!(!degraded.is_precise_exact());
375        assert_eq!(
376            degraded.staleness().map(|m| m.staleness_s),
377            Some(SECONDS_PER_HOUR)
378        );
379    }
380
381    #[test]
382    fn fix_source_broadcast_unavailable_has_no_staleness_and_carries_reason() {
383        let broadcast = FixSource::Broadcast(BroadcastReason::PreciseUnavailable(
384            SelectionError::EmptyProductSet,
385        ));
386        assert!(broadcast.is_broadcast());
387        assert!(!broadcast.is_precise());
388        assert!(!broadcast.is_precise_exact());
389        assert_eq!(broadcast.staleness(), None);
390        assert!(matches!(
391            broadcast,
392            FixSource::Broadcast(BroadcastReason::PreciseUnavailable(
393                SelectionError::EmptyProductSet
394            ))
395        ));
396    }
397
398    #[test]
399    fn broadcast_degraded_reason_exposes_attempted_staleness() {
400        let staleness = meta(DegradationKind::NearestPrior, 7200.0);
401        let reason = BroadcastReason::PreciseDegradedUnusable {
402            staleness,
403            error: SppError::TooFewSatellites {
404                used: 0,
405                required: 4,
406            },
407        };
408        // The broadcast fix carries no precise staleness of its own, but the
409        // reason exposes the staleness of the precise product that was tried.
410        assert_eq!(
411            reason.attempted_staleness().map(|m| m.staleness_s),
412            Some(7200.0)
413        );
414        let unavailable = BroadcastReason::PreciseUnavailable(SelectionError::EmptyProductSet);
415        assert_eq!(unavailable.attempted_staleness(), None);
416        assert_eq!(FixSource::Broadcast(reason).staleness(), None);
417    }
418}