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::staleness::DegradationKind;
349
350 fn meta(kind: DegradationKind, staleness_s: f64) -> StalenessMetadata {
351 StalenessMetadata {
352 kind,
353 requested_epoch_j2000_s: 1000.0,
354 source_epoch_j2000_s: 1000.0 - staleness_s,
355 staleness_s,
356 staleness_days: staleness_s / 86_400.0,
357 }
358 }
359
360 #[test]
361 fn fix_source_precise_exact_classification() {
362 let exact = FixSource::Precise(meta(DegradationKind::Exact, 0.0));
363 assert!(exact.is_precise());
364 assert!(exact.is_precise_exact());
365 assert!(!exact.is_broadcast());
366 assert_eq!(exact.staleness().map(|m| m.staleness_s), Some(0.0));
367 }
368
369 #[test]
370 fn fix_source_precise_degraded_is_not_exact() {
371 let degraded = FixSource::Precise(meta(DegradationKind::NearestPrior, 3600.0));
372 assert!(degraded.is_precise());
373 assert!(!degraded.is_precise_exact());
374 assert_eq!(degraded.staleness().map(|m| m.staleness_s), Some(3600.0));
375 }
376
377 #[test]
378 fn fix_source_broadcast_unavailable_has_no_staleness_and_carries_reason() {
379 let broadcast = FixSource::Broadcast(BroadcastReason::PreciseUnavailable(
380 SelectionError::EmptyProductSet,
381 ));
382 assert!(broadcast.is_broadcast());
383 assert!(!broadcast.is_precise());
384 assert!(!broadcast.is_precise_exact());
385 assert_eq!(broadcast.staleness(), None);
386 assert!(matches!(
387 broadcast,
388 FixSource::Broadcast(BroadcastReason::PreciseUnavailable(
389 SelectionError::EmptyProductSet
390 ))
391 ));
392 }
393
394 #[test]
395 fn broadcast_degraded_reason_exposes_attempted_staleness() {
396 let staleness = meta(DegradationKind::NearestPrior, 7200.0);
397 let reason = BroadcastReason::PreciseDegradedUnusable {
398 staleness,
399 error: SppError::TooFewSatellites {
400 used: 0,
401 required: 4,
402 },
403 };
404 // The broadcast fix carries no precise staleness of its own, but the
405 // reason exposes the staleness of the precise product that was tried.
406 assert_eq!(
407 reason.attempted_staleness().map(|m| m.staleness_s),
408 Some(7200.0)
409 );
410 let unavailable = BroadcastReason::PreciseUnavailable(SelectionError::EmptyProductSet);
411 assert_eq!(unavailable.attempted_staleness(), None);
412 assert_eq!(FixSource::Broadcast(reason).staleness(), None);
413 }
414}