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;