Skip to main content

sidereon_core/staleness/
mod.rs

1//! Product-staleness graceful degradation for time-varying GNSS products.
2//!
3//! Time-varying products (IONEX TEC maps, rapid/predicted SP3 orbit/clock files)
4//! publish with latency and gaps, so the product for the exact requested epoch is
5//! not always on hand. A direct lookup against a missing epoch is a hard failure,
6//! which is brittle for real-time and operational use.
7//!
8//! This module sits on top of the [`Ionex`](crate::atmosphere::Ionex) and
9//! [`Sp3`](crate::ephemeris::Sp3) parsers and adds a selection layer that
10//! degrades gracefully instead of erroring: given a SET of available parsed
11//! products and a requested epoch (or epoch range), it returns a usable handle,
12//! falling back to the most-recent available product within a configurable
13//! staleness cap. Every result carries [`StalenessMetadata`] describing which
14//! source epoch was used, how stale it is, and the [`DegradationKind`], so a
15//! degraded answer is never substituted silently. Only a request that exceeds the
16//! staleness cap fails, with a typed [`SelectionError`].
17//!
18//! # Degradation paths
19//!
20//! - **Exact**: a product covering the requested epoch is present. The original
21//!   product is returned untouched and the downstream evaluation is bit-for-bit
22//!   identical to calling the parser/interpolator directly. Staleness is zero.
23//! - **IONEX diurnal shift**: when no product covers the requested day, the
24//!   most-recent prior day's grid is advanced by whole days onto the requested
25//!   epoch ([`Ionex::with_map_epochs_shifted_days`](crate::atmosphere::Ionex)).
26//!   TEC is approximately 24-hour periodic, so this is near-lossless for the
27//!   boundary window. The grid values are unchanged; only the epoch axis moves.
28//! - **SP3 nearest-prior**: when no product covers the requested epoch, the
29//!   most-recent prior product is selected as-is, with the staleness measured
30//!   from its last epoch.
31//!
32//! # Network
33//!
34//! This layer is pure and no-network: it selects among products the caller has
35//! already parsed. Fetching the products is a per-binding concern.
36
37use std::borrow::Cow;
38use std::fmt;
39
40use crate::astro::constants::time::{SECONDS_PER_DAY, SECONDS_PER_DAY_I64};
41use crate::atmosphere::Ionex;
42use crate::ephemeris::{EphemerisSource, Sp3, Sp3State};
43use crate::frame::Wgs84Geodetic;
44use crate::id::GnssSatelliteId;
45use crate::ionex::ionex_slant_delay;
46
47/// Default staleness cap, in whole days.
48///
49/// A request whose nearest usable product is older than this is rejected with
50/// [`SelectionError::BeyondStalenessCap`]. Three days spans the typical
51/// rapid/predicted product latency plus a weekend gap.
52pub const DEFAULT_MAX_STALENESS_DAYS: u32 = 3;
53
54/// How a selected product's source epoch relates to the requested epoch.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum DegradationKind {
57    /// A product covering the requested epoch was present; no degradation.
58    Exact,
59    /// No product covered the requested epoch; the most-recent prior product was
60    /// used as-is (SP3 path).
61    NearestPrior,
62    /// No product covered the requested day; a prior day's IONEX grid was
63    /// advanced by whole days onto the requested epoch (diurnal persistence).
64    DiurnalShift,
65}
66
67impl DegradationKind {
68    /// Whether this result used the exact present product (no degradation).
69    pub fn is_exact(self) -> bool {
70        matches!(self, DegradationKind::Exact)
71    }
72}
73
74/// Structured description of the product staleness behind a selection result.
75///
76/// Attached to every [`IonexSelection`] / [`Sp3Selection`]; a degraded result is
77/// never produced without it. Epoch fields are seconds since the J2000 epoch
78/// (2000-01-01 12:00:00). `staleness_s` is `requested - source` and is never
79/// negative. This is the public type the language bindings wrap (Python
80/// dataclass, Elixir struct, C handle) and the broadcast-fallback path reads.
81#[derive(Debug, Clone, Copy, PartialEq)]
82pub struct StalenessMetadata {
83    /// Which degradation path produced the result.
84    pub kind: DegradationKind,
85    /// The requested epoch, J2000 seconds. For a range request this is the
86    /// latest (most-stale) epoch of the range.
87    pub requested_epoch_j2000_s: f64,
88    /// The source product epoch the result is backed by, J2000 seconds. For
89    /// [`DegradationKind::Exact`] this equals the requested epoch; for a diurnal
90    /// shift it is the same time-of-day a whole number of days earlier; for
91    /// nearest-prior it is the source product's last epoch.
92    pub source_epoch_j2000_s: f64,
93    /// Staleness `requested - source`, seconds. Zero for an exact result; never
94    /// negative.
95    pub staleness_s: f64,
96    /// Staleness in days (`staleness_s / 86400`). For a diurnal shift this is the
97    /// integer day offset applied.
98    pub staleness_days: f64,
99}
100
101impl StalenessMetadata {
102    /// Metadata for a present, exact result (zero staleness) at `epoch_j2000_s`.
103    fn exact(epoch_j2000_s: f64) -> Self {
104        Self {
105            kind: DegradationKind::Exact,
106            requested_epoch_j2000_s: epoch_j2000_s,
107            source_epoch_j2000_s: epoch_j2000_s,
108            staleness_s: 0.0,
109            staleness_days: 0.0,
110        }
111    }
112}
113
114/// Configurable staleness cap for product selection.
115///
116/// A selection that would rely on a product older than `max_staleness_s` fails
117/// with [`SelectionError::BeyondStalenessCap`] rather than returning data past
118/// the cap. The [`Default`] is [`DEFAULT_MAX_STALENESS_DAYS`].
119///
120/// ```
121/// use sidereon_core::staleness::StalenessPolicy;
122/// use sidereon_core::constants::SECONDS_PER_DAY;
123/// let policy = StalenessPolicy::default();
124/// assert_eq!(policy.max_staleness_s, 3.0 * SECONDS_PER_DAY);
125/// assert_eq!(StalenessPolicy::days(1.0).max_staleness_s, SECONDS_PER_DAY);
126/// ```
127#[derive(Debug, Clone, Copy, PartialEq)]
128pub struct StalenessPolicy {
129    /// Maximum tolerated staleness, seconds.
130    pub max_staleness_s: f64,
131}
132
133impl StalenessPolicy {
134    /// A policy with a cap expressed in days.
135    pub fn days(days: f64) -> Self {
136        Self {
137            max_staleness_s: days * SECONDS_PER_DAY,
138        }
139    }
140
141    /// A policy with a cap expressed in seconds.
142    pub fn seconds(seconds: f64) -> Self {
143        Self {
144            max_staleness_s: seconds,
145        }
146    }
147}
148
149impl Default for StalenessPolicy {
150    fn default() -> Self {
151        Self::days(f64::from(DEFAULT_MAX_STALENESS_DAYS))
152    }
153}
154
155/// Error returned when no product can satisfy a request.
156///
157/// No degraded data is ever returned through this type: a successful selection
158/// always carries [`StalenessMetadata`], and these variants are the only outcomes
159/// where the layer declines to produce a result.
160#[derive(Debug, Clone, PartialEq)]
161pub enum SelectionError {
162    /// The product set was empty.
163    EmptyProductSet,
164    /// The requested range was malformed (non-finite, or end before start).
165    InvalidRange {
166        /// Range start, J2000 seconds.
167        start_epoch_j2000_s: f64,
168        /// Range end, J2000 seconds.
169        end_epoch_j2000_s: f64,
170    },
171    /// No product covers or precedes the requested epoch; only later products
172    /// are available, so there is nothing to degrade to.
173    NoPriorProduct {
174        /// The requested epoch, J2000 seconds.
175        requested_epoch_j2000_s: f64,
176    },
177    /// The most-recent usable product is older than the staleness cap.
178    BeyondStalenessCap {
179        /// The requested epoch, J2000 seconds.
180        requested_epoch_j2000_s: f64,
181        /// The source epoch that would have been used, J2000 seconds.
182        source_epoch_j2000_s: f64,
183        /// How stale that source is, seconds.
184        staleness_s: f64,
185        /// The cap that was exceeded, seconds.
186        max_staleness_s: f64,
187    },
188    /// A product in the set was malformed (e.g. no epochs, or an epoch that
189    /// cannot be projected onto the J2000 axis), or the only prior product
190    /// cannot cover the requested range even after a whole-day diurnal shift.
191    InvalidProduct(String),
192    /// The staleness policy cap was non-finite or negative. A cap that is not a
193    /// finite, non-negative number of seconds cannot bound degradation:
194    /// comparisons such as `staleness_s > NaN` are always false, which would
195    /// admit arbitrarily stale data without surfacing it. The selection layer
196    /// rejects such a policy rather than masking the failure it exists to catch.
197    InvalidPolicy {
198        /// The rejected cap, seconds.
199        max_staleness_s: f64,
200    },
201    /// An epoch computation overflowed the i64 J2000-second axis for an extreme
202    /// requested range. No usable result can be produced without wrapping, so
203    /// the request is declined rather than returning a wrapped epoch.
204    Overflow {
205        /// Which computation overflowed.
206        context: &'static str,
207    },
208}
209
210impl fmt::Display for SelectionError {
211    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212        match self {
213            SelectionError::EmptyProductSet => write!(f, "product set is empty"),
214            SelectionError::InvalidRange {
215                start_epoch_j2000_s,
216                end_epoch_j2000_s,
217            } => write!(
218                f,
219                "invalid epoch range [{start_epoch_j2000_s}, {end_epoch_j2000_s}]"
220            ),
221            SelectionError::NoPriorProduct {
222                requested_epoch_j2000_s,
223            } => write!(
224                f,
225                "no product at or before requested epoch {requested_epoch_j2000_s} J2000 s"
226            ),
227            SelectionError::BeyondStalenessCap {
228                requested_epoch_j2000_s,
229                source_epoch_j2000_s,
230                staleness_s,
231                max_staleness_s,
232            } => write!(
233                f,
234                "nearest product (epoch {source_epoch_j2000_s} J2000 s) is {staleness_s} s stale \
235                 for requested epoch {requested_epoch_j2000_s} J2000 s, over the {max_staleness_s} s cap"
236            ),
237            SelectionError::InvalidProduct(msg) => write!(f, "invalid product in set: {msg}"),
238            SelectionError::InvalidPolicy { max_staleness_s } => write!(
239                f,
240                "staleness cap {max_staleness_s} s is not a finite, non-negative number of seconds"
241            ),
242            SelectionError::Overflow { context } => {
243                write!(f, "epoch arithmetic overflow: {context}")
244            }
245        }
246    }
247}
248
249/// Reject a staleness cap that cannot bound degradation.
250///
251/// A cap must be a finite, non-negative number of seconds. A non-finite or
252/// negative cap is the silent-masking hazard this layer exists to prevent
253/// (`staleness_s > NaN` is always false), so it is a typed error, not a default.
254fn validate_policy(policy: StalenessPolicy) -> Result<(), SelectionError> {
255    if policy.max_staleness_s.is_finite() && policy.max_staleness_s >= 0.0 {
256        Ok(())
257    } else {
258        Err(SelectionError::InvalidPolicy {
259            max_staleness_s: policy.max_staleness_s,
260        })
261    }
262}
263
264impl std::error::Error for SelectionError {}
265
266/// A selected IONEX product plus its staleness metadata.
267///
268/// Obtain one from [`select_ionex`] or [`select_ionex_over_range`]. The inner
269/// product is either the present product (borrowed, byte-identical to the
270/// caller's) or a diurnal-shifted copy; [`IonexSelection::ionex`] exposes it and
271/// [`IonexSelection::slant_delay`] runs the standard slant-delay evaluation on
272/// it.
273#[derive(Debug, Clone, PartialEq)]
274pub struct IonexSelection<'a> {
275    ionex: Cow<'a, Ionex>,
276    metadata: StalenessMetadata,
277}
278
279impl IonexSelection<'_> {
280    /// The staleness metadata for this selection.
281    pub fn metadata(&self) -> StalenessMetadata {
282        self.metadata
283    }
284
285    /// The usable IONEX product: the present product for an exact result, or the
286    /// diurnal-shifted copy for a degraded one.
287    pub fn ionex(&self) -> &Ionex {
288        self.ionex.as_ref()
289    }
290
291    /// Slant ionospheric group delay (positive meters) from the selected product.
292    ///
293    /// Delegates to [`ionex_slant_delay`](crate::atmosphere::ionex_slant_delay)
294    /// on the inner product. For an exact selection this is the inner product
295    /// untouched, so the result is bit-for-bit identical to calling
296    /// `ionex_slant_delay` on the caller's product directly.
297    pub fn slant_delay(
298        &self,
299        receiver: Wgs84Geodetic,
300        elevation_rad: f64,
301        azimuth_rad: f64,
302        epoch_j2000_s: i64,
303        frequency_hz: f64,
304    ) -> crate::Result<f64> {
305        ionex_slant_delay(
306            self.ionex.as_ref(),
307            receiver,
308            elevation_rad,
309            azimuth_rad,
310            epoch_j2000_s,
311            frequency_hz,
312        )
313    }
314}
315
316/// A selected SP3 product plus its staleness metadata.
317///
318/// Obtain one from [`select_sp3`] or [`select_sp3_over_range`]. The product is
319/// borrowed from the caller's set; the interpolation entry points and the
320/// [`EphemerisSource`] impl delegate straight to it, so an exact selection is
321/// bit-for-bit identical to interpolating the caller's product directly.
322#[derive(Debug, Clone, PartialEq)]
323pub struct Sp3Selection<'a> {
324    sp3: &'a Sp3,
325    metadata: StalenessMetadata,
326}
327
328impl Sp3Selection<'_> {
329    /// The staleness metadata for this selection.
330    pub fn metadata(&self) -> StalenessMetadata {
331        self.metadata
332    }
333
334    /// The selected SP3 product.
335    pub fn sp3(&self) -> &Sp3 {
336        self.sp3
337    }
338
339    /// Interpolate `sat` at a J2000-second epoch on the selected product.
340    ///
341    /// Delegates to [`Sp3::position_at_j2000_seconds`](crate::ephemeris::Sp3),
342    /// so an exact selection is bit-for-bit identical to calling it on the
343    /// caller's product.
344    pub fn position_at_j2000_seconds(
345        &self,
346        sat: GnssSatelliteId,
347        query_j2000_s: f64,
348    ) -> crate::Result<Sp3State> {
349        self.sp3.position_at_j2000_seconds(sat, query_j2000_s)
350    }
351}
352
353impl EphemerisSource for Sp3Selection<'_> {
354    fn position_clock_at_j2000_s(
355        &self,
356        sat: GnssSatelliteId,
357        t_j2000_s: f64,
358    ) -> Option<([f64; 3], f64)> {
359        self.sp3.position_clock_at_j2000_s(sat, t_j2000_s)
360    }
361}
362
363/// Select an IONEX product usable at `requested_epoch_j2000_s`, degrading to a
364/// diurnal-shifted prior product within `policy` when the exact day is absent.
365///
366/// See [`select_ionex_over_range`]; this is the single-epoch case.
367pub fn select_ionex(
368    products: &[Ionex],
369    requested_epoch_j2000_s: i64,
370    policy: StalenessPolicy,
371) -> Result<IonexSelection<'_>, SelectionError> {
372    select_ionex_over_range(
373        products,
374        requested_epoch_j2000_s,
375        requested_epoch_j2000_s,
376        policy,
377    )
378}
379
380/// Select an IONEX product usable across `[start, end]` (J2000 seconds).
381///
382/// Resolution order:
383/// 1. If a product covers the whole range, it is returned unchanged
384///    ([`DegradationKind::Exact`], zero staleness). When several products cover
385///    the range the choice is deterministic: the one with the latest start epoch
386///    (freshest), ties broken by the smallest last epoch (tightest span), then
387///    by slice order.
388/// 2. Otherwise the prior products (last epoch before `start`) are tried
389///    freshest-first. Each is advanced by whole days so its grid lands on the
390///    range end (the most-stale point), via diurnal persistence
391///    ([`DegradationKind::DiurnalShift`]); the first whose shifted grid actually
392///    covers the whole range and fits the cap is returned. Trying candidates in
393///    order means a partial freshest product cannot mask an older, wider product
394///    that does cover.
395/// 3. If no prior product covers the range after shifting, or the freshest prior
396///    already exceeds the staleness cap, or no prior product exists, a typed
397///    [`SelectionError`] is returned.
398pub fn select_ionex_over_range(
399    products: &[Ionex],
400    start_epoch_j2000_s: i64,
401    end_epoch_j2000_s: i64,
402    policy: StalenessPolicy,
403) -> Result<IonexSelection<'_>, SelectionError> {
404    validate_policy(policy)?;
405    if products.is_empty() {
406        return Err(SelectionError::EmptyProductSet);
407    }
408    if end_epoch_j2000_s < start_epoch_j2000_s {
409        return Err(SelectionError::InvalidRange {
410            start_epoch_j2000_s: start_epoch_j2000_s as f64,
411            end_epoch_j2000_s: end_epoch_j2000_s as f64,
412        });
413    }
414
415    // 1. Exact coverage of the whole range, with a deterministic tie-break:
416    //    latest start (freshest), then smallest last epoch (tightest span).
417    let mut exact: Option<(&Ionex, i64, i64)> = None;
418    for product in products {
419        let (lo, hi) = ionex_span(product)?;
420        if lo <= start_epoch_j2000_s && end_epoch_j2000_s <= hi {
421            let better = match exact {
422                None => true,
423                Some((_, best_lo, best_hi)) => lo > best_lo || (lo == best_lo && hi < best_hi),
424            };
425            if better {
426                exact = Some((product, lo, hi));
427            }
428        }
429    }
430    if let Some((product, _, _)) = exact {
431        return Ok(IonexSelection {
432            ionex: Cow::Borrowed(product),
433            metadata: StalenessMetadata::exact(end_epoch_j2000_s as f64),
434        });
435    }
436
437    // 2. Diurnal-shift from a prior product (last epoch before the range start),
438    //    tried freshest-first. A partial freshest product whose shifted grid does
439    //    not cover the range must not mask an older, wider product that does, so
440    //    the candidates are walked in order and the first that both fits the cap
441    //    and covers the range wins.
442    let mut priors: Vec<(&Ionex, i64, i64)> = products
443        .iter()
444        .filter_map(|product| match ionex_span(product) {
445            Ok((lo, hi)) if hi < start_epoch_j2000_s => Some(Ok((product, lo, hi))),
446            Ok(_) => None,
447            Err(error) => Some(Err(error)),
448        })
449        .collect::<Result<_, _>>()?;
450    if priors.is_empty() {
451        return Err(SelectionError::NoPriorProduct {
452            requested_epoch_j2000_s: end_epoch_j2000_s as f64,
453        });
454    }
455    // Freshest (largest last epoch) first; ties broken by the widest span
456    // (smallest first epoch), which is the most likely to cover after shifting.
457    // Staleness is then monotonically non-decreasing down the list.
458    priors.sort_by(|a, b| b.2.cmp(&a.2).then(a.1.cmp(&b.1)));
459
460    // Every arithmetic step is checked, and a candidate that cannot be evaluated
461    // (or shifted) within the i64 axis is skipped rather than aborting the scan,
462    // so a fresher non-representable candidate cannot mask an older usable one.
463    // The terminal error reflects the first binding reason a usable result was
464    // not produced: cap exceedance, then overflow, then no covering grid.
465    let mut beyond_cap: Option<(i64, i64)> = None; // (source_epoch, staleness)
466    let mut overflow_ctx: Option<&'static str> = None;
467    for (product, lo, hi) in priors {
468        // Whole-day shift that brings the source grid up onto the range end.
469        // Ceil division avoids the `gap + 86399` term, which could overflow even
470        // when the shifted epoch itself would fit.
471        let Some(gap_s) = end_epoch_j2000_s.checked_sub(hi) else {
472            overflow_ctx.get_or_insert("end - hi");
473            continue;
474        }; // > 0 by selection
475        let days = gap_s / SECONDS_PER_DAY_I64 + i64::from(gap_s % SECONDS_PER_DAY_I64 != 0); // >= 1
476        let Some(staleness_s) = days.checked_mul(SECONDS_PER_DAY_I64) else {
477            overflow_ctx.get_or_insert("days * 86400");
478            continue;
479        };
480        let Some(source_epoch_j2000_s) = end_epoch_j2000_s.checked_sub(staleness_s) else {
481            overflow_ctx.get_or_insert("end - staleness");
482            continue;
483        };
484
485        // Staleness is non-decreasing down the list, so once one candidate
486        // exceeds the cap every remaining (older) candidate does too. Record the
487        // freshest (least-stale) exceedance and stop.
488        if staleness_s as f64 > policy.max_staleness_s {
489            beyond_cap = Some((source_epoch_j2000_s, staleness_s));
490            break;
491        }
492
493        // The shifted grid is the source span advanced by `staleness_s`; compute
494        // its bounds with checked arithmetic so an unrepresentable shift skips to
495        // the next candidate instead of failing the whole request.
496        let (Some(shifted_lo), Some(shifted_hi)) =
497            (lo.checked_add(staleness_s), hi.checked_add(staleness_s))
498        else {
499            overflow_ctx.get_or_insert("epoch + staleness");
500            continue;
501        };
502
503        // The ceil shift lands the grid's last epoch at or past the range end,
504        // but a partial product can still start after the range start once
505        // shifted. Only a grid that actually covers the request is usable; if it
506        // does not, fall through to the next (older, wider) candidate.
507        if shifted_lo <= start_epoch_j2000_s && end_epoch_j2000_s <= shifted_hi {
508            // Bounds are representable, so the full shift cannot overflow.
509            let shifted = product
510                .with_map_epochs_shifted_days(days)
511                .map_err(|error| SelectionError::InvalidProduct(error.to_string()))?;
512            return Ok(IonexSelection {
513                ionex: Cow::Owned(shifted),
514                metadata: StalenessMetadata {
515                    kind: DegradationKind::DiurnalShift,
516                    requested_epoch_j2000_s: end_epoch_j2000_s as f64,
517                    source_epoch_j2000_s: source_epoch_j2000_s as f64,
518                    staleness_s: staleness_s as f64,
519                    staleness_days: days as f64,
520                },
521            });
522        }
523    }
524
525    if let Some((source_epoch_j2000_s, staleness_s)) = beyond_cap {
526        return Err(SelectionError::BeyondStalenessCap {
527            requested_epoch_j2000_s: end_epoch_j2000_s as f64,
528            source_epoch_j2000_s: source_epoch_j2000_s as f64,
529            staleness_s: staleness_s as f64,
530            max_staleness_s: policy.max_staleness_s,
531        });
532    }
533    if let Some(context) = overflow_ctx {
534        return Err(SelectionError::Overflow { context });
535    }
536    // Every prior product within the cap was too partial to cover the range once
537    // shifted onto it.
538    Err(SelectionError::InvalidProduct(format!(
539        "no prior IONEX product covers requested range \
540         [{start_epoch_j2000_s}, {end_epoch_j2000_s}] J2000 s after a whole-day diurnal shift"
541    )))
542}
543
544/// Select an SP3 product usable at `requested_epoch_j2000_s`, degrading to the
545/// most-recent prior product within `policy`.
546///
547/// See [`select_sp3_over_range`]; this is the single-epoch case.
548pub fn select_sp3(
549    products: &[Sp3],
550    requested_epoch_j2000_s: f64,
551    policy: StalenessPolicy,
552) -> Result<Sp3Selection<'_>, SelectionError> {
553    select_sp3_over_range(
554        products,
555        requested_epoch_j2000_s,
556        requested_epoch_j2000_s,
557        policy,
558    )
559}
560
561/// Select an SP3 product usable across `[start, end]` (J2000 seconds).
562///
563/// Resolution order:
564/// 1. If a product covers the whole range, it is returned unchanged
565///    ([`DegradationKind::Exact`], zero staleness). When several products cover
566///    the range the choice is deterministic: the one with the latest start epoch
567///    (freshest), ties broken by the smallest last epoch (tightest span), then
568///    by slice order.
569/// 2. Otherwise the most-recent product that covers the range start but ends
570///    before the range end is selected as-is ([`DegradationKind::NearestPrior`]),
571///    with staleness measured from that last epoch to the range end (the
572///    most-stale point). Requiring it to cover the start (`lo <= start`) keeps
573///    out a product beginning after the range start, which could not serve the
574///    start; a product entirely before the range qualifies trivially. This also
575///    admits a product that covers the start but ends before the end. That is
576///    the nearest-prior source for the worst-case end.
577/// 3. If that staleness exceeds the cap, or no prior product exists, a typed
578///    [`SelectionError`] is returned.
579pub fn select_sp3_over_range(
580    products: &[Sp3],
581    start_epoch_j2000_s: f64,
582    end_epoch_j2000_s: f64,
583    policy: StalenessPolicy,
584) -> Result<Sp3Selection<'_>, SelectionError> {
585    validate_policy(policy)?;
586    if products.is_empty() {
587        return Err(SelectionError::EmptyProductSet);
588    }
589    if !start_epoch_j2000_s.is_finite()
590        || !end_epoch_j2000_s.is_finite()
591        || end_epoch_j2000_s < start_epoch_j2000_s
592    {
593        return Err(SelectionError::InvalidRange {
594            start_epoch_j2000_s,
595            end_epoch_j2000_s,
596        });
597    }
598
599    // 1. Exact coverage of the whole range, with a deterministic tie-break:
600    //    latest start (freshest), then smallest last epoch (tightest span).
601    let mut exact: Option<(&Sp3, f64, f64)> = None;
602    for product in products {
603        let (lo, hi) = sp3_span(product)?;
604        if lo <= start_epoch_j2000_s && end_epoch_j2000_s <= hi {
605            let better = match exact {
606                None => true,
607                Some((_, best_lo, best_hi)) => lo > best_lo || (lo == best_lo && hi < best_hi),
608            };
609            if better {
610                exact = Some((product, lo, hi));
611            }
612        }
613    }
614    if let Some((product, _, _)) = exact {
615        return Ok(Sp3Selection {
616            sp3: product,
617            metadata: StalenessMetadata::exact(end_epoch_j2000_s),
618        });
619    }
620
621    // 2. Most-recent product that covers the range start but ends before the
622    //    range end: it is the nearest-prior source for the worst-case end. The
623    //    `lo <= start` guard keeps out a product that begins after the range
624    //    start (it cannot serve the start at all, so it is not a usable prior);
625    //    a product entirely before the range satisfies it trivially.
626    let mut best: Option<(&Sp3, f64)> = None;
627    for product in products {
628        let (lo, hi) = sp3_span(product)?;
629        if lo <= start_epoch_j2000_s
630            && hi < end_epoch_j2000_s
631            && best.is_none_or(|(_, best_hi)| hi > best_hi)
632        {
633            best = Some((product, hi));
634        }
635    }
636    let (product, hi) = best.ok_or(SelectionError::NoPriorProduct {
637        requested_epoch_j2000_s: end_epoch_j2000_s,
638    })?;
639
640    let staleness_s = end_epoch_j2000_s - hi; // > 0 by selection
641    if staleness_s > policy.max_staleness_s {
642        return Err(SelectionError::BeyondStalenessCap {
643            requested_epoch_j2000_s: end_epoch_j2000_s,
644            source_epoch_j2000_s: hi,
645            staleness_s,
646            max_staleness_s: policy.max_staleness_s,
647        });
648    }
649
650    Ok(Sp3Selection {
651        sp3: product,
652        metadata: StalenessMetadata {
653            kind: DegradationKind::NearestPrior,
654            requested_epoch_j2000_s: end_epoch_j2000_s,
655            source_epoch_j2000_s: hi,
656            staleness_s,
657            staleness_days: staleness_s / SECONDS_PER_DAY,
658        },
659    })
660}
661
662/// The `[first, last]` IONEX map-epoch span in J2000 seconds.
663fn ionex_span(product: &Ionex) -> Result<(i64, i64), SelectionError> {
664    let epochs = product.map_epochs_s();
665    let first = *epochs
666        .first()
667        .ok_or_else(|| SelectionError::InvalidProduct("IONEX product has no maps".into()))?;
668    let last = *epochs.last().expect("non-empty epochs has a last element");
669    Ok((first, last))
670}
671
672/// The `[first, last]` SP3 epoch span in J2000 seconds.
673fn sp3_span(product: &Sp3) -> Result<(f64, f64), SelectionError> {
674    let epochs = product.epochs_j2000_seconds();
675    let first = *epochs
676        .first()
677        .ok_or_else(|| SelectionError::InvalidProduct("SP3 product has no epochs".into()))?;
678    let last = *epochs.last().expect("non-empty epochs has a last element");
679    Ok((first, last))
680}
681
682#[cfg(test)]
683mod tests;