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
93impl std::error::Error for FreshnessError {}
94
95pub 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
115pub 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 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}