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;