Skip to main content

tempoch_core/data/
status.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (C) 2026 Vallés Puig, Ramon
3
4//! Active time-data status and freshness diagnostics.
5//!
6//! `siderust-archive` owns IERS/USNO time-data provenance, source URLs,
7//! checksums, parsing, download, and integrity validation.  `tempoch-core`
8//! exposes a thin diagnostic view over whichever archive bundle is currently
9//! active: the archive provenance record, validity horizons relevant to time
10//! conversions, and the source of the active bundle.
11
12use chrono::{DateTime, Utc};
13
14use crate::archive::time::TimeDataProvenance;
15use crate::data::runtime_data::{active_time_data, active_time_data_source};
16
17/// Source of the currently active time-data bundle.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ActiveTimeDataSource {
20    /// The compiled archive snapshot bundled into `siderust-archive`.
21    Bundled,
22    /// A bundle loaded through the runtime fetch/cache path.
23    ///
24    /// This value intentionally does not distinguish cache hit from fresh
25    /// download; `siderust-archive` owns the fetch/cache mechanics and
26    /// provenance timestamp.
27    RuntimeCache,
28    /// A test or caller-provided override is active.
29    Override,
30}
31
32/// Active time-data status captured from the runtime store.
33#[derive(Debug, Clone, PartialEq)]
34pub struct TimeDataStatus {
35    /// Archive-owned provenance and checksum metadata for the active bundle.
36    pub provenance: TimeDataProvenance,
37    /// Validity horizons relevant to `tempoch` conversions.
38    pub horizons: DataHorizons,
39    /// Where the active bundle came from.
40    pub source: ActiveTimeDataSource,
41}
42
43/// Documented validity horizons of the currently active time-data bundle,
44/// expressed in MJD UTC days.
45///
46/// EOP horizon fields are `None` when no EOP data has been loaded into the
47/// active bundle. UTC-TAI and Delta T horizons are always present through the
48/// bundled archive snapshot.
49#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct DataHorizons {
51    /// First MJD covered by the EOP series, or `None` when no EOP is loaded.
52    pub eop_start_mjd: Option<f64>,
53    /// Last observed (non-predicted) EOP MJD, or `None` when no EOP is loaded.
54    pub eop_observed_end_mjd: Option<f64>,
55    /// Last EOP MJD including predictions, or `None` when no EOP is loaded.
56    pub eop_end_mjd: Option<f64>,
57    /// Last MJD with observed Delta T in the archive-provided modern table.
58    pub modern_delta_t_observed_end_mjd: f64,
59    /// Last MJD covered by the Delta T prediction table.
60    pub delta_t_prediction_horizon_mjd: f64,
61}
62
63/// Errors raised by freshness checks.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum FreshnessError {
66    /// The active bundle has no parseable archive provenance timestamp.
67    MissingTimestamp,
68    /// The bundle is older than `max_age` relative to `now`. Carries the
69    /// observed age in seconds.
70    Stale {
71        age_seconds: i64,
72        max_age_seconds: i64,
73    },
74}
75
76impl core::fmt::Display for FreshnessError {
77    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
78        match self {
79            Self::MissingTimestamp => {
80                f.write_str("time-data bundle has no parseable fetched_at timestamp")
81            }
82            Self::Stale {
83                age_seconds,
84                max_age_seconds,
85            } => write!(
86                f,
87                "time-data bundle is {age_seconds}s old; max allowed is {max_age_seconds}s",
88            ),
89        }
90    }
91}
92
93impl std::error::Error for FreshnessError {}
94
95/// Capture status for the currently active time-data bundle.
96///
97/// The returned provenance is the archive-owned
98/// [`TimeDataProvenance`](crate::archive::time::TimeDataProvenance), not a
99/// second `tempoch` provenance model.
100pub fn time_data_status() -> TimeDataStatus {
101    let bundle = active_time_data();
102    TimeDataStatus {
103        provenance: bundle.provenance().clone(),
104        horizons: DataHorizons {
105            eop_start_mjd: bundle.eop_start_mjd().map(|v| v as f64),
106            eop_observed_end_mjd: bundle.eop_observed_end_mjd().map(|v| v as f64),
107            eop_end_mjd: bundle.eop_end_mjd().map(|v| v as f64),
108            modern_delta_t_observed_end_mjd: crate::MODERN_DELTA_T_OBSERVED_END_MJD.value(),
109            delta_t_prediction_horizon_mjd: crate::DELTA_T_PREDICTION_HORIZON_MJD.value(),
110        },
111        source: active_time_data_source(),
112    }
113}
114
115/// Assert the active bundle is no older than `max_age` relative to `now`.
116///
117/// Freshness is based on `status.provenance.fetched_at()` from the archive
118/// provenance record.
119pub fn assert_fresh(now: DateTime<Utc>, max_age: chrono::Duration) -> Result<(), FreshnessError> {
120    let status = time_data_status();
121    let fetched = status
122        .provenance
123        .fetched_at()
124        .ok_or(FreshnessError::MissingTimestamp)?;
125    let age = now.signed_duration_since(fetched);
126    if age > max_age {
127        return Err(FreshnessError::Stale {
128            age_seconds: age.num_seconds(),
129            max_age_seconds: max_age.num_seconds(),
130        });
131    }
132    Ok(())
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::archive::time::{EopPoint, TimeDataBundle, TimeDataProvenance, UtcTaiSegment};
139    use crate::data::runtime_data::{with_runtime_data_lock, with_test_time_data};
140    use qtty::{Arcsecond, Millisecond, Second};
141
142    fn eop_bundle_for_status_test(fetched_utc: &str) -> TimeDataBundle {
143        TimeDataBundle::new(
144            vec![UtcTaiSegment {
145                start_mjd: 41317,
146                end_mjd: None,
147                base: Second::new(37.0),
148                reference_mjd: 41317.0,
149                slope_seconds_per_day: 0.0,
150            }],
151            vec![(41714.0, 42.184), (42369.0, 45.0)],
152            41714.0,
153            vec![
154                EopPoint {
155                    mjd: 50000,
156                    pm_observed: true,
157                    ut1_observed: true,
158                    nutation_observed: true,
159                    pm_xp: Some(Arcsecond::new(0.1)),
160                    pm_yp: Some(Arcsecond::new(0.1)),
161                    ut1_minus_utc: Second::new(0.3),
162                    lod: Some(Millisecond::new(1.0)),
163                    dx: None,
164                    dy: None,
165                },
166                EopPoint {
167                    mjd: 50001,
168                    pm_observed: false,
169                    ut1_observed: false,
170                    nutation_observed: false,
171                    pm_xp: Some(Arcsecond::new(0.2)),
172                    pm_yp: Some(Arcsecond::new(0.2)),
173                    ut1_minus_utc: Second::new(0.4),
174                    lod: None,
175                    dx: None,
176                    dy: None,
177                },
178            ],
179            TimeDataProvenance::new(fetched_utc, "aaaa", "bbbb", "cccc", "dddd"),
180        )
181    }
182
183    #[test]
184    fn status_has_documented_horizons() {
185        let bundle = eop_bundle_for_status_test("2024-01-01T00:00:00");
186        with_test_time_data(bundle, || {
187            let status = time_data_status();
188            let eop_start = status
189                .horizons
190                .eop_start_mjd
191                .expect("EOP start should be Some");
192            let eop_end = status.horizons.eop_end_mjd.expect("EOP end should be Some");
193            let eop_obs_end = status
194                .horizons
195                .eop_observed_end_mjd
196                .expect("EOP observed end should be Some");
197            assert!(eop_end > eop_start);
198            assert!(eop_obs_end >= eop_start);
199            assert!(eop_obs_end <= eop_end);
200            assert!(status.horizons.delta_t_prediction_horizon_mjd > 0.0);
201        });
202    }
203
204    #[test]
205    fn compiled_bundle_eop_horizons_are_none() {
206        with_runtime_data_lock(|| {
207            // The compiled bundle intentionally has no EOP data.
208            // Operators must explicitly fetch EOP via TimeDataManager.
209            let status = time_data_status();
210            assert_eq!(status.source, ActiveTimeDataSource::Bundled);
211            assert!(status.horizons.eop_start_mjd.is_none());
212            assert!(status.horizons.eop_observed_end_mjd.is_none());
213            assert!(status.horizons.eop_end_mjd.is_none());
214            assert!(status.horizons.delta_t_prediction_horizon_mjd > 0.0);
215        });
216    }
217
218    #[test]
219    fn status_exposes_archive_provenance_without_copy_fields() {
220        let bundle = eop_bundle_for_status_test("2024-01-01T00:00:00");
221        with_test_time_data(bundle, || {
222            let status = time_data_status();
223            assert_eq!(status.source, ActiveTimeDataSource::Override);
224            assert_eq!(status.provenance.fetched_utc(), "2024-01-01T00:00:00");
225            assert_eq!(status.provenance.utc_tai_sha256(), "aaaa");
226            assert_eq!(status.provenance.delta_t_observed_sha256(), "bbbb");
227            assert_eq!(status.provenance.delta_t_predictions_sha256(), "cccc");
228            assert_eq!(status.provenance.eop_finals_sha256(), "dddd");
229        });
230    }
231
232    #[test]
233    fn assert_fresh_accepts_recent_archive_provenance() {
234        let bundle = eop_bundle_for_status_test("2024-01-01T00:00:00");
235        with_test_time_data(bundle, || {
236            let now = DateTime::parse_from_rfc3339("2024-01-01T00:10:00Z")
237                .unwrap()
238                .with_timezone(&Utc);
239            assert!(assert_fresh(now, chrono::Duration::minutes(15)).is_ok());
240        });
241    }
242
243    #[test]
244    fn assert_fresh_rejects_stale_archive_provenance() {
245        let bundle = eop_bundle_for_status_test("2024-01-01T00:00:00");
246        with_test_time_data(bundle, || {
247            let now = DateTime::parse_from_rfc3339("2024-01-01T01:00:00Z")
248                .unwrap()
249                .with_timezone(&Utc);
250            let res = assert_fresh(now, chrono::Duration::minutes(15));
251            assert!(matches!(res, Err(FreshnessError::Stale { .. })));
252        });
253    }
254
255    #[test]
256    fn freshness_error_implements_display_and_error() {
257        let e = FreshnessError::Stale {
258            age_seconds: 100,
259            max_age_seconds: 50,
260        };
261        let s = format!("{e}");
262        assert!(s.contains("100"));
263        let _: &dyn std::error::Error = &e;
264    }
265}