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}