sidereon-core 0.9.1

The complete Sidereon engine: numerical astrodynamics propagation core plus the GNSS domain layer (SP3, broadcast ephemeris, multi-GNSS positioning, RTK/PPP, ionosphere/troposphere, DOP) behind a default-on gnss feature
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
//! Product-staleness graceful degradation for time-varying GNSS products.
//!
//! Time-varying products (IONEX TEC maps, rapid/predicted SP3 orbit/clock files)
//! publish with latency and gaps, so the product for the exact requested epoch is
//! not always on hand. A direct lookup against a missing epoch is a hard failure,
//! which is brittle for real-time and operational use.
//!
//! This module sits on top of the [`Ionex`](crate::atmosphere::Ionex) and
//! [`Sp3`](crate::ephemeris::Sp3) parsers and adds a selection layer that
//! degrades gracefully instead of erroring: given a SET of available parsed
//! products and a requested epoch (or epoch range), it returns a usable handle,
//! falling back to the most-recent available product within a configurable
//! staleness cap. Every result carries [`StalenessMetadata`] describing which
//! source epoch was used, how stale it is, and the [`DegradationKind`], so a
//! degraded answer is never substituted silently. Only a request that exceeds the
//! staleness cap fails, with a typed [`SelectionError`].
//!
//! # Degradation paths
//!
//! - **Exact**: a product covering the requested epoch is present. The original
//!   product is returned untouched and the downstream evaluation is bit-for-bit
//!   identical to calling the parser/interpolator directly. Staleness is zero.
//! - **IONEX diurnal shift**: when no product covers the requested day, the
//!   most-recent prior day's grid is advanced by whole days onto the requested
//!   epoch ([`Ionex::with_map_epochs_shifted_days`](crate::atmosphere::Ionex)).
//!   TEC is approximately 24-hour periodic, so this is near-lossless for the
//!   boundary window. The grid values are unchanged; only the epoch axis moves.
//! - **SP3 nearest-prior**: when no product covers the requested epoch, the
//!   most-recent prior product is selected as-is, with the staleness measured
//!   from its last epoch.
//!
//! # Network
//!
//! This layer is pure and no-network: it selects among products the caller has
//! already parsed. Fetching the products is a per-binding concern.

use std::borrow::Cow;
use std::fmt;

use crate::astro::constants::time::{SECONDS_PER_DAY, SECONDS_PER_DAY_I64};
use crate::atmosphere::Ionex;
use crate::ephemeris::{EphemerisSource, Sp3, Sp3State};
use crate::frame::Wgs84Geodetic;
use crate::id::GnssSatelliteId;
use crate::ionex::ionex_slant_delay;

/// Default staleness cap, in whole days.
///
/// A request whose nearest usable product is older than this is rejected with
/// [`SelectionError::BeyondStalenessCap`]. Three days spans the typical
/// rapid/predicted product latency plus a weekend gap.
pub const DEFAULT_MAX_STALENESS_DAYS: u32 = 3;

/// How a selected product's source epoch relates to the requested epoch.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DegradationKind {
    /// A product covering the requested epoch was present; no degradation.
    Exact,
    /// No product covered the requested epoch; the most-recent prior product was
    /// used as-is (SP3 path).
    NearestPrior,
    /// No product covered the requested day; a prior day's IONEX grid was
    /// advanced by whole days onto the requested epoch (diurnal persistence).
    DiurnalShift,
}

impl DegradationKind {
    /// Whether this result used the exact present product (no degradation).
    pub fn is_exact(self) -> bool {
        matches!(self, DegradationKind::Exact)
    }
}

/// Structured description of the product staleness behind a selection result.
///
/// Attached to every [`IonexSelection`] / [`Sp3Selection`]; a degraded result is
/// never produced without it. Epoch fields are seconds since the J2000 epoch
/// (2000-01-01 12:00:00). `staleness_s` is `requested - source` and is never
/// negative. This is the public type the language bindings wrap (Python
/// dataclass, Elixir struct, C handle) and the broadcast-fallback path reads.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StalenessMetadata {
    /// Which degradation path produced the result.
    pub kind: DegradationKind,
    /// The requested epoch, J2000 seconds. For a range request this is the
    /// latest (most-stale) epoch of the range.
    pub requested_epoch_j2000_s: f64,
    /// The source product epoch the result is backed by, J2000 seconds. For
    /// [`DegradationKind::Exact`] this equals the requested epoch; for a diurnal
    /// shift it is the same time-of-day a whole number of days earlier; for
    /// nearest-prior it is the source product's last epoch.
    pub source_epoch_j2000_s: f64,
    /// Staleness `requested - source`, seconds. Zero for an exact result; never
    /// negative.
    pub staleness_s: f64,
    /// Staleness in days (`staleness_s / 86400`). For a diurnal shift this is the
    /// integer day offset applied.
    pub staleness_days: f64,
}

impl StalenessMetadata {
    /// Metadata for a present, exact result (zero staleness) at `epoch_j2000_s`.
    fn exact(epoch_j2000_s: f64) -> Self {
        Self {
            kind: DegradationKind::Exact,
            requested_epoch_j2000_s: epoch_j2000_s,
            source_epoch_j2000_s: epoch_j2000_s,
            staleness_s: 0.0,
            staleness_days: 0.0,
        }
    }
}

/// Configurable staleness cap for product selection.
///
/// A selection that would rely on a product older than `max_staleness_s` fails
/// with [`SelectionError::BeyondStalenessCap`] rather than returning data past
/// the cap. The [`Default`] is [`DEFAULT_MAX_STALENESS_DAYS`].
///
/// ```
/// use sidereon_core::staleness::StalenessPolicy;
/// let policy = StalenessPolicy::default();
/// assert_eq!(policy.max_staleness_s, 3.0 * 86_400.0);
/// assert_eq!(StalenessPolicy::days(1.0).max_staleness_s, 86_400.0);
/// ```
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StalenessPolicy {
    /// Maximum tolerated staleness, seconds.
    pub max_staleness_s: f64,
}

impl StalenessPolicy {
    /// A policy with a cap expressed in days.
    pub fn days(days: f64) -> Self {
        Self {
            max_staleness_s: days * SECONDS_PER_DAY,
        }
    }

    /// A policy with a cap expressed in seconds.
    pub fn seconds(seconds: f64) -> Self {
        Self {
            max_staleness_s: seconds,
        }
    }
}

impl Default for StalenessPolicy {
    fn default() -> Self {
        Self::days(f64::from(DEFAULT_MAX_STALENESS_DAYS))
    }
}

/// Error returned when no product can satisfy a request.
///
/// No degraded data is ever returned through this type: a successful selection
/// always carries [`StalenessMetadata`], and these variants are the only outcomes
/// where the layer declines to produce a result.
#[derive(Debug, Clone, PartialEq)]
pub enum SelectionError {
    /// The product set was empty.
    EmptyProductSet,
    /// The requested range was malformed (non-finite, or end before start).
    InvalidRange {
        /// Range start, J2000 seconds.
        start_epoch_j2000_s: f64,
        /// Range end, J2000 seconds.
        end_epoch_j2000_s: f64,
    },
    /// No product covers or precedes the requested epoch; only later products
    /// are available, so there is nothing to degrade to.
    NoPriorProduct {
        /// The requested epoch, J2000 seconds.
        requested_epoch_j2000_s: f64,
    },
    /// The most-recent usable product is older than the staleness cap.
    BeyondStalenessCap {
        /// The requested epoch, J2000 seconds.
        requested_epoch_j2000_s: f64,
        /// The source epoch that would have been used, J2000 seconds.
        source_epoch_j2000_s: f64,
        /// How stale that source is, seconds.
        staleness_s: f64,
        /// The cap that was exceeded, seconds.
        max_staleness_s: f64,
    },
    /// A product in the set was malformed (e.g. no epochs, or an epoch that
    /// cannot be projected onto the J2000 axis), or the only prior product
    /// cannot cover the requested range even after a whole-day diurnal shift.
    InvalidProduct(String),
    /// The staleness policy cap was non-finite or negative. A cap that is not a
    /// finite, non-negative number of seconds cannot bound degradation:
    /// comparisons such as `staleness_s > NaN` are always false, which would
    /// admit arbitrarily stale data without surfacing it. The selection layer
    /// rejects such a policy rather than masking the failure it exists to catch.
    InvalidPolicy {
        /// The rejected cap, seconds.
        max_staleness_s: f64,
    },
    /// An epoch computation overflowed the i64 J2000-second axis for an extreme
    /// requested range. No usable result can be produced without wrapping, so
    /// the request is declined rather than returning a wrapped epoch.
    Overflow {
        /// Which computation overflowed.
        context: &'static str,
    },
}

impl fmt::Display for SelectionError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            SelectionError::EmptyProductSet => write!(f, "product set is empty"),
            SelectionError::InvalidRange {
                start_epoch_j2000_s,
                end_epoch_j2000_s,
            } => write!(
                f,
                "invalid epoch range [{start_epoch_j2000_s}, {end_epoch_j2000_s}]"
            ),
            SelectionError::NoPriorProduct {
                requested_epoch_j2000_s,
            } => write!(
                f,
                "no product at or before requested epoch {requested_epoch_j2000_s} J2000 s"
            ),
            SelectionError::BeyondStalenessCap {
                requested_epoch_j2000_s,
                source_epoch_j2000_s,
                staleness_s,
                max_staleness_s,
            } => write!(
                f,
                "nearest product (epoch {source_epoch_j2000_s} J2000 s) is {staleness_s} s stale \
                 for requested epoch {requested_epoch_j2000_s} J2000 s, over the {max_staleness_s} s cap"
            ),
            SelectionError::InvalidProduct(msg) => write!(f, "invalid product in set: {msg}"),
            SelectionError::InvalidPolicy { max_staleness_s } => write!(
                f,
                "staleness cap {max_staleness_s} s is not a finite, non-negative number of seconds"
            ),
            SelectionError::Overflow { context } => {
                write!(f, "epoch arithmetic overflow: {context}")
            }
        }
    }
}

/// Reject a staleness cap that cannot bound degradation.
///
/// A cap must be a finite, non-negative number of seconds. A non-finite or
/// negative cap is the silent-masking hazard this layer exists to prevent
/// (`staleness_s > NaN` is always false), so it is a typed error, not a default.
fn validate_policy(policy: StalenessPolicy) -> Result<(), SelectionError> {
    if policy.max_staleness_s.is_finite() && policy.max_staleness_s >= 0.0 {
        Ok(())
    } else {
        Err(SelectionError::InvalidPolicy {
            max_staleness_s: policy.max_staleness_s,
        })
    }
}

impl std::error::Error for SelectionError {}

/// A selected IONEX product plus its staleness metadata.
///
/// Obtain one from [`select_ionex`] or [`select_ionex_over_range`]. The inner
/// product is either the present product (borrowed, byte-identical to the
/// caller's) or a diurnal-shifted copy; [`IonexSelection::ionex`] exposes it and
/// [`IonexSelection::slant_delay`] runs the standard slant-delay evaluation on
/// it.
#[derive(Debug, Clone, PartialEq)]
pub struct IonexSelection<'a> {
    ionex: Cow<'a, Ionex>,
    metadata: StalenessMetadata,
}

impl IonexSelection<'_> {
    /// The staleness metadata for this selection.
    pub fn metadata(&self) -> StalenessMetadata {
        self.metadata
    }

    /// The usable IONEX product: the present product for an exact result, or the
    /// diurnal-shifted copy for a degraded one.
    pub fn ionex(&self) -> &Ionex {
        self.ionex.as_ref()
    }

    /// Slant ionospheric group delay (positive meters) from the selected product.
    ///
    /// Delegates to [`ionex_slant_delay`](crate::atmosphere::ionex_slant_delay)
    /// on the inner product. For an exact selection this is the inner product
    /// untouched, so the result is bit-for-bit identical to calling
    /// `ionex_slant_delay` on the caller's product directly.
    pub fn slant_delay(
        &self,
        receiver: Wgs84Geodetic,
        elevation_rad: f64,
        azimuth_rad: f64,
        epoch_j2000_s: i64,
        frequency_hz: f64,
    ) -> crate::Result<f64> {
        ionex_slant_delay(
            self.ionex.as_ref(),
            receiver,
            elevation_rad,
            azimuth_rad,
            epoch_j2000_s,
            frequency_hz,
        )
    }
}

/// A selected SP3 product plus its staleness metadata.
///
/// Obtain one from [`select_sp3`] or [`select_sp3_over_range`]. The product is
/// borrowed from the caller's set; the interpolation entry points and the
/// [`EphemerisSource`] impl delegate straight to it, so an exact selection is
/// bit-for-bit identical to interpolating the caller's product directly.
#[derive(Debug, Clone, PartialEq)]
pub struct Sp3Selection<'a> {
    sp3: &'a Sp3,
    metadata: StalenessMetadata,
}

impl Sp3Selection<'_> {
    /// The staleness metadata for this selection.
    pub fn metadata(&self) -> StalenessMetadata {
        self.metadata
    }

    /// The selected SP3 product.
    pub fn sp3(&self) -> &Sp3 {
        self.sp3
    }

    /// Interpolate `sat` at a J2000-second epoch on the selected product.
    ///
    /// Delegates to [`Sp3::position_at_j2000_seconds`](crate::ephemeris::Sp3),
    /// so an exact selection is bit-for-bit identical to calling it on the
    /// caller's product.
    pub fn position_at_j2000_seconds(
        &self,
        sat: GnssSatelliteId,
        query_j2000_s: f64,
    ) -> crate::Result<Sp3State> {
        self.sp3.position_at_j2000_seconds(sat, query_j2000_s)
    }
}

impl EphemerisSource for Sp3Selection<'_> {
    fn position_clock_at_j2000_s(
        &self,
        sat: GnssSatelliteId,
        t_j2000_s: f64,
    ) -> Option<([f64; 3], f64)> {
        self.sp3.position_clock_at_j2000_s(sat, t_j2000_s)
    }
}

/// Select an IONEX product usable at `requested_epoch_j2000_s`, degrading to a
/// diurnal-shifted prior product within `policy` when the exact day is absent.
///
/// See [`select_ionex_over_range`]; this is the single-epoch case.
pub fn select_ionex(
    products: &[Ionex],
    requested_epoch_j2000_s: i64,
    policy: StalenessPolicy,
) -> Result<IonexSelection<'_>, SelectionError> {
    select_ionex_over_range(
        products,
        requested_epoch_j2000_s,
        requested_epoch_j2000_s,
        policy,
    )
}

/// Select an IONEX product usable across `[start, end]` (J2000 seconds).
///
/// Resolution order:
/// 1. If a product covers the whole range, it is returned unchanged
///    ([`DegradationKind::Exact`], zero staleness). When several products cover
///    the range the choice is deterministic: the one with the latest start epoch
///    (freshest), ties broken by the smallest last epoch (tightest span), then
///    by slice order.
/// 2. Otherwise the prior products (last epoch before `start`) are tried
///    freshest-first. Each is advanced by whole days so its grid lands on the
///    range end (the most-stale point), via diurnal persistence
///    ([`DegradationKind::DiurnalShift`]); the first whose shifted grid actually
///    covers the whole range and fits the cap is returned. Trying candidates in
///    order means a partial freshest product cannot mask an older, wider product
///    that does cover.
/// 3. If no prior product covers the range after shifting, or the freshest prior
///    already exceeds the staleness cap, or no prior product exists, a typed
///    [`SelectionError`] is returned.
pub fn select_ionex_over_range(
    products: &[Ionex],
    start_epoch_j2000_s: i64,
    end_epoch_j2000_s: i64,
    policy: StalenessPolicy,
) -> Result<IonexSelection<'_>, SelectionError> {
    validate_policy(policy)?;
    if products.is_empty() {
        return Err(SelectionError::EmptyProductSet);
    }
    if end_epoch_j2000_s < start_epoch_j2000_s {
        return Err(SelectionError::InvalidRange {
            start_epoch_j2000_s: start_epoch_j2000_s as f64,
            end_epoch_j2000_s: end_epoch_j2000_s as f64,
        });
    }

    // 1. Exact coverage of the whole range, with a deterministic tie-break:
    //    latest start (freshest), then smallest last epoch (tightest span).
    let mut exact: Option<(&Ionex, i64, i64)> = None;
    for product in products {
        let (lo, hi) = ionex_span(product)?;
        if lo <= start_epoch_j2000_s && end_epoch_j2000_s <= hi {
            let better = match exact {
                None => true,
                Some((_, best_lo, best_hi)) => lo > best_lo || (lo == best_lo && hi < best_hi),
            };
            if better {
                exact = Some((product, lo, hi));
            }
        }
    }
    if let Some((product, _, _)) = exact {
        return Ok(IonexSelection {
            ionex: Cow::Borrowed(product),
            metadata: StalenessMetadata::exact(end_epoch_j2000_s as f64),
        });
    }

    // 2. Diurnal-shift from a prior product (last epoch before the range start),
    //    tried freshest-first. A partial freshest product whose shifted grid does
    //    not cover the range must not mask an older, wider product that does, so
    //    the candidates are walked in order and the first that both fits the cap
    //    and covers the range wins.
    let mut priors: Vec<(&Ionex, i64, i64)> = products
        .iter()
        .filter_map(|product| match ionex_span(product) {
            Ok((lo, hi)) if hi < start_epoch_j2000_s => Some(Ok((product, lo, hi))),
            Ok(_) => None,
            Err(error) => Some(Err(error)),
        })
        .collect::<Result<_, _>>()?;
    if priors.is_empty() {
        return Err(SelectionError::NoPriorProduct {
            requested_epoch_j2000_s: end_epoch_j2000_s as f64,
        });
    }
    // Freshest (largest last epoch) first; ties broken by the widest span
    // (smallest first epoch), which is the most likely to cover after shifting.
    // Staleness is then monotonically non-decreasing down the list.
    priors.sort_by(|a, b| b.2.cmp(&a.2).then(a.1.cmp(&b.1)));

    // Every arithmetic step is checked, and a candidate that cannot be evaluated
    // (or shifted) within the i64 axis is skipped rather than aborting the scan,
    // so a fresher non-representable candidate cannot mask an older usable one.
    // The terminal error reflects the first binding reason a usable result was
    // not produced: cap exceedance, then overflow, then no covering grid.
    let mut beyond_cap: Option<(i64, i64)> = None; // (source_epoch, staleness)
    let mut overflow_ctx: Option<&'static str> = None;
    for (product, lo, hi) in priors {
        // Whole-day shift that brings the source grid up onto the range end.
        // Ceil division avoids the `gap + 86399` term, which could overflow even
        // when the shifted epoch itself would fit.
        let Some(gap_s) = end_epoch_j2000_s.checked_sub(hi) else {
            overflow_ctx.get_or_insert("end - hi");
            continue;
        }; // > 0 by selection
        let days = gap_s / SECONDS_PER_DAY_I64 + i64::from(gap_s % SECONDS_PER_DAY_I64 != 0); // >= 1
        let Some(staleness_s) = days.checked_mul(SECONDS_PER_DAY_I64) else {
            overflow_ctx.get_or_insert("days * 86400");
            continue;
        };
        let Some(source_epoch_j2000_s) = end_epoch_j2000_s.checked_sub(staleness_s) else {
            overflow_ctx.get_or_insert("end - staleness");
            continue;
        };

        // Staleness is non-decreasing down the list, so once one candidate
        // exceeds the cap every remaining (older) candidate does too. Record the
        // freshest (least-stale) exceedance and stop.
        if staleness_s as f64 > policy.max_staleness_s {
            beyond_cap = Some((source_epoch_j2000_s, staleness_s));
            break;
        }

        // The shifted grid is the source span advanced by `staleness_s`; compute
        // its bounds with checked arithmetic so an unrepresentable shift skips to
        // the next candidate instead of failing the whole request.
        let (Some(shifted_lo), Some(shifted_hi)) =
            (lo.checked_add(staleness_s), hi.checked_add(staleness_s))
        else {
            overflow_ctx.get_or_insert("epoch + staleness");
            continue;
        };

        // The ceil shift lands the grid's last epoch at or past the range end,
        // but a partial product can still start after the range start once
        // shifted. Only a grid that actually covers the request is usable; if it
        // does not, fall through to the next (older, wider) candidate.
        if shifted_lo <= start_epoch_j2000_s && end_epoch_j2000_s <= shifted_hi {
            // Bounds are representable, so the full shift cannot overflow.
            let shifted = product
                .with_map_epochs_shifted_days(days)
                .map_err(|error| SelectionError::InvalidProduct(error.to_string()))?;
            return Ok(IonexSelection {
                ionex: Cow::Owned(shifted),
                metadata: StalenessMetadata {
                    kind: DegradationKind::DiurnalShift,
                    requested_epoch_j2000_s: end_epoch_j2000_s as f64,
                    source_epoch_j2000_s: source_epoch_j2000_s as f64,
                    staleness_s: staleness_s as f64,
                    staleness_days: days as f64,
                },
            });
        }
    }

    if let Some((source_epoch_j2000_s, staleness_s)) = beyond_cap {
        return Err(SelectionError::BeyondStalenessCap {
            requested_epoch_j2000_s: end_epoch_j2000_s as f64,
            source_epoch_j2000_s: source_epoch_j2000_s as f64,
            staleness_s: staleness_s as f64,
            max_staleness_s: policy.max_staleness_s,
        });
    }
    if let Some(context) = overflow_ctx {
        return Err(SelectionError::Overflow { context });
    }
    // Every prior product within the cap was too partial to cover the range once
    // shifted onto it.
    Err(SelectionError::InvalidProduct(format!(
        "no prior IONEX product covers requested range \
         [{start_epoch_j2000_s}, {end_epoch_j2000_s}] J2000 s after a whole-day diurnal shift"
    )))
}

/// Select an SP3 product usable at `requested_epoch_j2000_s`, degrading to the
/// most-recent prior product within `policy`.
///
/// See [`select_sp3_over_range`]; this is the single-epoch case.
pub fn select_sp3(
    products: &[Sp3],
    requested_epoch_j2000_s: f64,
    policy: StalenessPolicy,
) -> Result<Sp3Selection<'_>, SelectionError> {
    select_sp3_over_range(
        products,
        requested_epoch_j2000_s,
        requested_epoch_j2000_s,
        policy,
    )
}

/// Select an SP3 product usable across `[start, end]` (J2000 seconds).
///
/// Resolution order:
/// 1. If a product covers the whole range, it is returned unchanged
///    ([`DegradationKind::Exact`], zero staleness). When several products cover
///    the range the choice is deterministic: the one with the latest start epoch
///    (freshest), ties broken by the smallest last epoch (tightest span), then
///    by slice order.
/// 2. Otherwise the most-recent product that covers the range start but ends
///    before the range end is selected as-is ([`DegradationKind::NearestPrior`]),
///    with staleness measured from that last epoch to the range end (the
///    most-stale point). Requiring it to cover the start (`lo <= start`) keeps
///    out a product beginning after the range start, which could not serve the
///    start; a product entirely before the range qualifies trivially. This also
///    admits a product that covers the start but ends before the end — the
///    nearest-prior source for the worst-case end.
/// 3. If that staleness exceeds the cap, or no prior product exists, a typed
///    [`SelectionError`] is returned.
pub fn select_sp3_over_range(
    products: &[Sp3],
    start_epoch_j2000_s: f64,
    end_epoch_j2000_s: f64,
    policy: StalenessPolicy,
) -> Result<Sp3Selection<'_>, SelectionError> {
    validate_policy(policy)?;
    if products.is_empty() {
        return Err(SelectionError::EmptyProductSet);
    }
    if !start_epoch_j2000_s.is_finite()
        || !end_epoch_j2000_s.is_finite()
        || end_epoch_j2000_s < start_epoch_j2000_s
    {
        return Err(SelectionError::InvalidRange {
            start_epoch_j2000_s,
            end_epoch_j2000_s,
        });
    }

    // 1. Exact coverage of the whole range, with a deterministic tie-break:
    //    latest start (freshest), then smallest last epoch (tightest span).
    let mut exact: Option<(&Sp3, f64, f64)> = None;
    for product in products {
        let (lo, hi) = sp3_span(product)?;
        if lo <= start_epoch_j2000_s && end_epoch_j2000_s <= hi {
            let better = match exact {
                None => true,
                Some((_, best_lo, best_hi)) => lo > best_lo || (lo == best_lo && hi < best_hi),
            };
            if better {
                exact = Some((product, lo, hi));
            }
        }
    }
    if let Some((product, _, _)) = exact {
        return Ok(Sp3Selection {
            sp3: product,
            metadata: StalenessMetadata::exact(end_epoch_j2000_s),
        });
    }

    // 2. Most-recent product that covers the range start but ends before the
    //    range end: it is the nearest-prior source for the worst-case end. The
    //    `lo <= start` guard keeps out a product that begins after the range
    //    start (it cannot serve the start at all, so it is not a usable prior);
    //    a product entirely before the range satisfies it trivially.
    let mut best: Option<(&Sp3, f64)> = None;
    for product in products {
        let (lo, hi) = sp3_span(product)?;
        if lo <= start_epoch_j2000_s
            && hi < end_epoch_j2000_s
            && best.is_none_or(|(_, best_hi)| hi > best_hi)
        {
            best = Some((product, hi));
        }
    }
    let (product, hi) = best.ok_or(SelectionError::NoPriorProduct {
        requested_epoch_j2000_s: end_epoch_j2000_s,
    })?;

    let staleness_s = end_epoch_j2000_s - hi; // > 0 by selection
    if staleness_s > policy.max_staleness_s {
        return Err(SelectionError::BeyondStalenessCap {
            requested_epoch_j2000_s: end_epoch_j2000_s,
            source_epoch_j2000_s: hi,
            staleness_s,
            max_staleness_s: policy.max_staleness_s,
        });
    }

    Ok(Sp3Selection {
        sp3: product,
        metadata: StalenessMetadata {
            kind: DegradationKind::NearestPrior,
            requested_epoch_j2000_s: end_epoch_j2000_s,
            source_epoch_j2000_s: hi,
            staleness_s,
            staleness_days: staleness_s / SECONDS_PER_DAY,
        },
    })
}

/// The `[first, last]` IONEX map-epoch span in J2000 seconds.
fn ionex_span(product: &Ionex) -> Result<(i64, i64), SelectionError> {
    let epochs = product.map_epochs_s();
    let first = *epochs
        .first()
        .ok_or_else(|| SelectionError::InvalidProduct("IONEX product has no maps".into()))?;
    let last = *epochs.last().expect("non-empty epochs has a last element");
    Ok((first, last))
}

/// The `[first, last]` SP3 epoch span in J2000 seconds.
fn sp3_span(product: &Sp3) -> Result<(f64, f64), SelectionError> {
    let epochs = product.epochs_j2000_seconds();
    let first = *epochs
        .first()
        .ok_or_else(|| SelectionError::InvalidProduct("SP3 product has no epochs".into()))?;
    let last = *epochs.last().expect("non-empty epochs has a last element");
    Ok((first, last))
}

#[cfg(test)]
mod tests;