Skip to main content

sidereon_core/astro/time/
eop.rs

1//! Time / EOP validity + provenance API.
2//!
3//! Leap-second and UT1/EOP tables carry source + effective date + coverage
4//! interval, and the library exposes a strict vs permissive mode. Strict mode
5//! errors outside table coverage; permissive mode may clamp/extrapolate and
6//! marks the result degraded.
7//!
8//! The delta-T / UT1-UTC interpolation in [`crate::astro::time::scales`] clamps to the
9//! embedded table edges outside its range. That clamp is required (and bit-exact)
10//! for Skyfield parity, so the numerics are never altered. Instead, the policy is
11//! ENFORCED on the public conversion path
12//! [`crate::astro::time::scales::TimeScales::from_utc_validated`], which classifies the
13//! result against coverage under a [`ValidityMode`]:
14//!
15//! - malformed or non-finite inputs return [`CoverageError::InvalidInput`]
16//!   before the parity-critical arithmetic runs,
17//! - [`ValidityMode::Strict`] returns [`CoverageError`] outside coverage (the
18//!   clamped value is never handed back), and
19//! - [`ValidityMode::Permissive`] returns the clamped value paired with a
20//!   [`DegradeReason`] marker.
21//!
22//! [`check_ut1_coverage`] is the pure policy hook both modes share; it does not
23//! touch any delta-T value, so the parity-critical math is unaffected.
24
25/// Provenance + coverage of the embedded leap-second (TAI-UTC) table.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub struct LeapSecondTable {
28    /// Human-readable source / bulletin identifier.
29    pub source: &'static str,
30    /// First Modified Julian Date covered by the table.
31    pub first_mjd: i32,
32    /// Last (most recent) Modified Julian Date with a leap-second step.
33    pub last_mjd: i32,
34    /// Number of table entries.
35    pub entries: usize,
36}
37
38/// Provenance + coverage of the embedded UT1-UTC / delta-T table.
39#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct Ut1Provenance {
41    /// Human-readable source identifier.
42    pub source: &'static str,
43    /// First Modified Julian Date in the UT1 table.
44    pub first_mjd: i32,
45    /// Last Modified Julian Date in the UT1 table.
46    pub last_mjd: i32,
47    /// First covered instant, expressed as a TT Julian date.
48    pub first_jd_tt: f64,
49    /// Last covered instant, expressed as a TT Julian date.
50    pub last_jd_tt: f64,
51    /// Number of table entries.
52    pub entries: usize,
53}
54
55impl Ut1Provenance {
56    /// True if `jd_tt` falls inside the table's covered interval (inclusive).
57    pub fn covers_jd_tt(&self, jd_tt: f64) -> bool {
58        jd_tt.is_finite() && jd_tt >= self.first_jd_tt && jd_tt <= self.last_jd_tt
59    }
60}
61
62/// Validity policy applied when an instant falls outside table coverage.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64pub enum ValidityMode {
65    /// Errors outside table coverage. Use for precision GNSS pipelines.
66    Strict,
67    /// Clamps/extrapolates outside coverage and marks the result degraded.
68    /// This is the historical Skyfield-parity behaviour and the default.
69    #[default]
70    Permissive,
71}
72
73/// Reason a result was marked degraded under [`ValidityMode::Permissive`].
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum DegradeReason {
76    /// Instant precedes the first covered table entry; value clamped to the edge.
77    BeforeCoverage,
78    /// Instant follows the last covered table entry; value clamped to the edge.
79    AfterCoverage,
80}
81
82/// Invalid civil-time input kind for time-scale conversion boundaries.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum TimeScaleInputErrorKind {
85    Missing,
86    NonFinite,
87    NotPositive,
88    Negative,
89    OutOfRange,
90    FloatParse,
91    IntParse,
92    InvalidCivilDate,
93    InvalidCivilTime,
94}
95
96/// A value paired with whether it was produced inside valid table coverage.
97#[derive(Debug, Clone, Copy, PartialEq)]
98pub struct Validated<T> {
99    /// The computed value (clamped/extrapolated if `degraded` is `Some`).
100    pub value: T,
101    /// `None` if produced inside coverage; otherwise why it is degraded.
102    pub degraded: Option<DegradeReason>,
103}
104
105impl<T> Validated<T> {
106    /// A value produced inside valid coverage.
107    pub fn ok(value: T) -> Self {
108        Self {
109            value,
110            degraded: None,
111        }
112    }
113
114    /// A value produced outside coverage (clamped/extrapolated).
115    pub fn degraded(value: T, reason: DegradeReason) -> Self {
116        Self {
117            value,
118            degraded: Some(reason),
119        }
120    }
121
122    /// True if the value was produced inside valid coverage.
123    pub fn is_valid(&self) -> bool {
124        self.degraded.is_none()
125    }
126}
127
128/// Error returned by strict-mode coverage checks.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum CoverageError {
131    /// Time-scale conversion input is malformed or outside its accepted domain.
132    InvalidInput {
133        field: &'static str,
134        kind: TimeScaleInputErrorKind,
135    },
136    /// Instant is outside the table's covered interval.
137    OutsideCoverage(DegradeReason),
138}
139
140impl core::fmt::Display for CoverageError {
141    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
142        match self {
143            CoverageError::InvalidInput { field, kind } => {
144                write!(f, "invalid time-scale input {field}: {kind:?}")
145            }
146            CoverageError::OutsideCoverage(DegradeReason::BeforeCoverage) => {
147                write!(f, "instant precedes EOP/UT1 table coverage")
148            }
149            CoverageError::OutsideCoverage(DegradeReason::AfterCoverage) => {
150                write!(f, "instant follows EOP/UT1 table coverage")
151            }
152        }
153    }
154}
155
156impl std::error::Error for CoverageError {}
157
158/// Classify `jd_tt` against UT1 coverage under the given [`ValidityMode`].
159///
160/// In [`ValidityMode::Strict`] this returns `Err` outside coverage. In
161/// [`ValidityMode::Permissive`] it returns `Ok` with a [`DegradeReason`] flag
162/// when outside coverage. This is a pure policy hook: it does NOT change any
163/// delta-T value, preserving Skyfield parity.
164pub fn check_ut1_coverage(
165    prov: &Ut1Provenance,
166    jd_tt: f64,
167    mode: ValidityMode,
168) -> Result<Option<DegradeReason>, CoverageError> {
169    if !jd_tt.is_finite() {
170        return Err(CoverageError::InvalidInput {
171            field: "jd_tt",
172            kind: TimeScaleInputErrorKind::NonFinite,
173        });
174    }
175
176    let reason = if jd_tt < prov.first_jd_tt {
177        Some(DegradeReason::BeforeCoverage)
178    } else if jd_tt > prov.last_jd_tt {
179        Some(DegradeReason::AfterCoverage)
180    } else {
181        None
182    };
183
184    match (mode, reason) {
185        (ValidityMode::Strict, Some(r)) => Err(CoverageError::OutsideCoverage(r)),
186        (_, r) => Ok(r),
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    fn provenance() -> Ut1Provenance {
195        Ut1Provenance {
196            source: "test",
197            first_mjd: 0,
198            last_mjd: 1,
199            first_jd_tt: 2_451_545.0,
200            last_jd_tt: 2_451_546.0,
201            entries: 2,
202        }
203    }
204
205    #[test]
206    fn ut1_coverage_rejects_nonfinite_query() {
207        let prov = provenance();
208        let expected = Err(CoverageError::InvalidInput {
209            field: "jd_tt",
210            kind: TimeScaleInputErrorKind::NonFinite,
211        });
212        assert_eq!(
213            check_ut1_coverage(&prov, f64::NAN, ValidityMode::Strict),
214            expected
215        );
216        assert_eq!(
217            check_ut1_coverage(&prov, f64::INFINITY, ValidityMode::Permissive),
218            expected
219        );
220        assert!(!prov.covers_jd_tt(f64::NAN));
221    }
222
223    #[test]
224    fn ut1_coverage_valid_query_is_unchanged() {
225        let prov = provenance();
226        assert!(prov.covers_jd_tt(2_451_545.5));
227        assert_eq!(
228            check_ut1_coverage(&prov, 2_451_545.5, ValidityMode::Strict),
229            Ok(None)
230        );
231    }
232}