1use chrono::{DateTime, Utc};
13
14use crate::archive::time::TimeDataProvenance;
15use crate::data::runtime_data::{active_time_data, active_time_data_source};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ActiveTimeDataSource {
20 Bundled,
22 RuntimeCache,
28 Override,
30}
31
32#[derive(Debug, Clone, PartialEq)]
34pub struct TimeDataStatus {
35 pub provenance: TimeDataProvenance,
37 pub horizons: DataHorizons,
39 pub source: ActiveTimeDataSource,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct DataHorizons {
51 pub eop_start_mjd: Option<f64>,
53 pub eop_observed_end_mjd: Option<f64>,
55 pub eop_end_mjd: Option<f64>,
57 pub modern_delta_t_observed_end_mjd: f64,
59 pub delta_t_prediction_horizon_mjd: f64,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum FreshnessError {
66 MissingTimestamp,
68 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
96pub 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
116pub 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 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}