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