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
93#[cfg(feature = "std")]
94impl std::error::Error for FreshnessError {}
95
96/// Capture status for the currently active time-data bundle.
97///
98/// The returned provenance is the archive-owned
99/// [`TimeDataProvenance`](crate::archive::time::TimeDataProvenance), not a
100/// second `tempoch` provenance model.
101pub fn time_data_status() -> TimeDataStatus {
102    let bundle = active_time_data();
103    TimeDataStatus {
104        provenance: bundle.provenance().clone(),
105        horizons: DataHorizons {
106            eop_start_mjd: bundle.eop_start_mjd().map(|v| v as f64),
107            eop_observed_end_mjd: bundle.eop_observed_end_mjd().map(|v| v as f64),
108            eop_end_mjd: bundle.eop_end_mjd().map(|v| v as f64),
109            modern_delta_t_observed_end_mjd: crate::MODERN_DELTA_T_OBSERVED_END_MJD.value(),
110            delta_t_prediction_horizon_mjd: crate::DELTA_T_PREDICTION_HORIZON_MJD.value(),
111        },
112        source: active_time_data_source(),
113    }
114}
115
116/// Assert the active bundle is no older than `max_age` relative to `now`.
117///
118/// Freshness is based on `status.provenance.fetched_at()` from the archive
119/// provenance record.
120pub fn assert_fresh(now: DateTime<Utc>, max_age: chrono::Duration) -> Result<(), FreshnessError> {
121    let status = time_data_status();
122    let fetched = status
123        .provenance
124        .fetched_at()
125        .ok_or(FreshnessError::MissingTimestamp)?;
126    let age = now.signed_duration_since(fetched);
127    if age > max_age {
128        return Err(FreshnessError::Stale {
129            age_seconds: age.num_seconds(),
130            max_age_seconds: max_age.num_seconds(),
131        });
132    }
133    Ok(())
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::archive::time::{EopPoint, TimeDataBundle, TimeDataProvenance, UtcTaiSegment};
140    use crate::data::runtime_data::{with_runtime_data_lock, with_test_time_data};
141    use crate::qtty::{Arcsecond, Millisecond, Second};
142
143    fn eop_bundle_for_status_test(fetched_utc: &str) -> TimeDataBundle {
144        TimeDataBundle::new(
145            vec![UtcTaiSegment {
146                start_mjd: 41317,
147                end_mjd: None,
148                base: Second::new(37.0),
149                reference_mjd: 41317.0,
150                slope_seconds_per_day: 0.0,
151            }],
152            vec![(41714.0, 42.184), (42369.0, 45.0)],
153            41714.0,
154            vec![
155                EopPoint {
156                    mjd: 50000,
157                    pm_observed: true,
158                    ut1_observed: true,
159                    nutation_observed: true,
160                    pm_xp: Some(Arcsecond::new(0.1)),
161                    pm_yp: Some(Arcsecond::new(0.1)),
162                    ut1_minus_utc: Second::new(0.3),
163                    lod: Some(Millisecond::new(1.0)),
164                    dx: None,
165                    dy: None,
166                },
167                EopPoint {
168                    mjd: 50001,
169                    pm_observed: false,
170                    ut1_observed: false,
171                    nutation_observed: false,
172                    pm_xp: Some(Arcsecond::new(0.2)),
173                    pm_yp: Some(Arcsecond::new(0.2)),
174                    ut1_minus_utc: Second::new(0.4),
175                    lod: None,
176                    dx: None,
177                    dy: None,
178                },
179            ],
180            TimeDataProvenance::new(fetched_utc, "aaaa", "bbbb", "cccc", "dddd"),
181        )
182    }
183
184    #[test]
185    fn status_has_documented_horizons() {
186        let bundle = eop_bundle_for_status_test("2024-01-01T00:00:00");
187        with_test_time_data(bundle, || {
188            let status = time_data_status();
189            let eop_start = status
190                .horizons
191                .eop_start_mjd
192                .expect("EOP start should be Some");
193            let eop_end = status.horizons.eop_end_mjd.expect("EOP end should be Some");
194            let eop_obs_end = status
195                .horizons
196                .eop_observed_end_mjd
197                .expect("EOP observed end should be Some");
198            assert!(eop_end > eop_start);
199            assert!(eop_obs_end >= eop_start);
200            assert!(eop_obs_end <= eop_end);
201            assert!(status.horizons.delta_t_prediction_horizon_mjd > 0.0);
202        });
203    }
204
205    #[test]
206    fn compiled_bundle_eop_horizons_are_none() {
207        with_runtime_data_lock(|| {
208            // The compiled bundle intentionally has no EOP data.
209            // Operators must explicitly fetch EOP via TimeDataManager.
210            let status = time_data_status();
211            assert_eq!(status.source, ActiveTimeDataSource::Bundled);
212            assert!(status.horizons.eop_start_mjd.is_none());
213            assert!(status.horizons.eop_observed_end_mjd.is_none());
214            assert!(status.horizons.eop_end_mjd.is_none());
215            assert!(status.horizons.delta_t_prediction_horizon_mjd > 0.0);
216        });
217    }
218
219    #[test]
220    fn status_exposes_archive_provenance_without_copy_fields() {
221        let bundle = eop_bundle_for_status_test("2024-01-01T00:00:00");
222        with_test_time_data(bundle, || {
223            let status = time_data_status();
224            assert_eq!(status.source, ActiveTimeDataSource::Override);
225            assert_eq!(status.provenance.fetched_utc(), "2024-01-01T00:00:00");
226            assert_eq!(status.provenance.utc_tai_sha256(), "aaaa");
227            assert_eq!(status.provenance.delta_t_observed_sha256(), "bbbb");
228            assert_eq!(status.provenance.delta_t_predictions_sha256(), "cccc");
229            assert_eq!(status.provenance.eop_finals_sha256(), "dddd");
230        });
231    }
232
233    #[test]
234    fn assert_fresh_accepts_recent_archive_provenance() {
235        let bundle = eop_bundle_for_status_test("2024-01-01T00:00:00");
236        with_test_time_data(bundle, || {
237            let now = DateTime::parse_from_rfc3339("2024-01-01T00:10:00Z")
238                .unwrap()
239                .with_timezone(&Utc);
240            assert!(assert_fresh(now, chrono::Duration::minutes(15)).is_ok());
241        });
242    }
243
244    #[test]
245    fn assert_fresh_rejects_stale_archive_provenance() {
246        let bundle = eop_bundle_for_status_test("2024-01-01T00:00:00");
247        with_test_time_data(bundle, || {
248            let now = DateTime::parse_from_rfc3339("2024-01-01T01:00:00Z")
249                .unwrap()
250                .with_timezone(&Utc);
251            let res = assert_fresh(now, chrono::Duration::minutes(15));
252            assert!(matches!(res, Err(FreshnessError::Stale { .. })));
253        });
254    }
255
256    #[test]
257    fn freshness_error_implements_display_and_error() {
258        let e = FreshnessError::Stale {
259            age_seconds: 100,
260            max_age_seconds: 50,
261        };
262        let s = format!("{e}");
263        assert!(s.contains("100"));
264        let _: &dyn std::error::Error = &e;
265    }
266}