1use chrono::{DateTime, NaiveDate, Utc};
5use std::fmt;
6
7pub mod generated;
8
9pub const TEMPOCH_DATA_DIR_ENV: &str = "TEMPOCH_DATA_DIR";
10pub const UTC_TAI_HISTORY_URL: &str = "https://hpiers.obspm.fr/eoppc/bul/bulc/UTC-TAI.history";
11pub const DELTA_T_OBSERVED_URL: &str = "https://maia.usno.navy.mil/ser7/deltat.data";
12pub const DELTA_T_PREDICTIONS_URL: &str = "https://maia.usno.navy.mil/ser7/deltat.preds";
13pub const EOP_FINALS_URL: &str = "https://datacenter.iers.org/data/9/finals2000A.all";
14pub const PRE_1961_TAI_MINUS_UTC_APPROX: f64 = 10.0;
15
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub struct UtcTaiSegment {
18 pub start_mjd: i32,
19 pub end_mjd: Option<i32>,
20 pub base_seconds: f64,
21 pub reference_mjd: f64,
22 pub slope_seconds_per_day: f64,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct EopPoint {
27 pub mjd: i32,
28 pub pm_observed: bool,
29 pub ut1_observed: bool,
30 pub nutation_observed: bool,
31 pub pm_xp_arcsec: Option<f64>,
32 pub pm_yp_arcsec: Option<f64>,
33 pub ut1_minus_utc_seconds: f64,
34 pub lod_milliseconds: Option<f64>,
35 pub dx_milliarcsec: Option<f64>,
36 pub dy_milliarcsec: Option<f64>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct TimeDataProvenance {
41 fetched_utc: String,
42 utc_tai_sha256: String,
43 delta_t_observed_sha256: String,
44 delta_t_predictions_sha256: String,
45 eop_finals_sha256: String,
46}
47
48impl TimeDataProvenance {
49 pub fn new(
50 fetched_utc: impl Into<String>,
51 utc_tai_sha256: impl Into<String>,
52 delta_t_observed_sha256: impl Into<String>,
53 delta_t_predictions_sha256: impl Into<String>,
54 eop_finals_sha256: impl Into<String>,
55 ) -> Self {
56 Self {
57 fetched_utc: fetched_utc.into(),
58 utc_tai_sha256: utc_tai_sha256.into(),
59 delta_t_observed_sha256: delta_t_observed_sha256.into(),
60 delta_t_predictions_sha256: delta_t_predictions_sha256.into(),
61 eop_finals_sha256: eop_finals_sha256.into(),
62 }
63 }
64
65 pub fn fetched_utc(&self) -> &str {
66 &self.fetched_utc
67 }
68
69 pub fn fetched_at(&self) -> Option<DateTime<Utc>> {
70 chrono::NaiveDateTime::parse_from_str(&self.fetched_utc, "%Y-%m-%dT%H:%M:%S")
71 .ok()
72 .map(|dt| dt.and_utc())
73 }
74
75 pub fn utc_tai_sha256(&self) -> &str {
76 &self.utc_tai_sha256
77 }
78
79 pub fn delta_t_observed_sha256(&self) -> &str {
80 &self.delta_t_observed_sha256
81 }
82
83 pub fn delta_t_predictions_sha256(&self) -> &str {
84 &self.delta_t_predictions_sha256
85 }
86
87 pub fn eop_finals_sha256(&self) -> &str {
88 &self.eop_finals_sha256
89 }
90}
91
92#[derive(Debug, Clone)]
93pub struct TimeDataBundle {
94 utc_tai_segments: Vec<UtcTaiSegment>,
95 modern_delta_t_points: Vec<(f64, f64)>,
96 modern_delta_t_observed_end_mjd: f64,
97 eop_points: Vec<EopPoint>,
98 provenance: TimeDataProvenance,
99}
100
101impl TimeDataBundle {
102 pub fn new(
103 utc_tai_segments: Vec<UtcTaiSegment>,
104 modern_delta_t_points: Vec<(f64, f64)>,
105 modern_delta_t_observed_end_mjd: f64,
106 eop_points: Vec<EopPoint>,
107 provenance: TimeDataProvenance,
108 ) -> Self {
109 Self {
110 utc_tai_segments,
111 modern_delta_t_points,
112 modern_delta_t_observed_end_mjd,
113 eop_points,
114 provenance,
115 }
116 }
117
118 pub fn utc_tai_segments(&self) -> &[UtcTaiSegment] {
119 &self.utc_tai_segments
120 }
121
122 pub fn modern_delta_t_points(&self) -> &[(f64, f64)] {
123 &self.modern_delta_t_points
124 }
125
126 pub fn modern_delta_t_observed_end_mjd(&self) -> f64 {
127 self.modern_delta_t_observed_end_mjd
128 }
129
130 pub fn eop_points(&self) -> &[EopPoint] {
131 &self.eop_points
132 }
133
134 pub fn provenance(&self) -> &TimeDataProvenance {
135 &self.provenance
136 }
137
138 pub fn eop_observed_end_mjd(&self) -> i32 {
139 observed_end_mjd(&self.eop_points)
140 }
141
142 pub fn eop_end_mjd(&self) -> i32 {
143 self.eop_points
144 .last()
145 .map(|point| point.mjd)
146 .unwrap_or_default()
147 }
148
149 #[cfg(feature = "fetch")]
150 fn from_raw_sources(
151 utc_tai_history: &str,
152 delta_t_observed: &str,
153 delta_t_predictions: &str,
154 eop_finals: &str,
155 provenance: TimeDataProvenance,
156 ) -> Result<Self, TimeDataError> {
157 let utc_tai_segments =
158 parse_utc_tai_segments(utc_tai_history).map_err(TimeDataError::Parse)?;
159 let observed = parse_delta_t_observed(delta_t_observed).map_err(TimeDataError::Parse)?;
160 let predicted =
161 parse_delta_t_predictions(delta_t_predictions).map_err(TimeDataError::Parse)?;
162 let (modern_delta_t_points, modern_delta_t_observed_end_mjd) =
163 build_modern_delta_t_points(&observed, &predicted).map_err(TimeDataError::Parse)?;
164 let eop_points = parse_eop_finals(eop_finals).map_err(TimeDataError::Parse)?;
165 Ok(Self::new(
166 utc_tai_segments,
167 modern_delta_t_points,
168 modern_delta_t_observed_end_mjd,
169 eop_points,
170 provenance,
171 ))
172 }
173}
174
175pub fn bundled_time_data() -> TimeDataBundle {
177 TimeDataBundle::new(
178 generated::time_data::UTC_TAI_SEGMENTS
179 .iter()
180 .map(|segment| UtcTaiSegment {
181 start_mjd: segment.start_mjd,
182 end_mjd: segment.end_mjd,
183 base_seconds: segment.base_seconds,
184 reference_mjd: segment.reference_mjd,
185 slope_seconds_per_day: segment.slope_seconds_per_day,
186 })
187 .collect(),
188 generated::time_data::MODERN_DELTA_T_POINTS.to_vec(),
189 generated::MODERN_DELTA_T_OBSERVED_END_MJD.value(),
190 generated::eop_data::EOP_POINTS
191 .iter()
192 .map(|point| EopPoint {
193 mjd: point.mjd,
194 pm_observed: point.pm_observed,
195 ut1_observed: point.ut1_observed,
196 nutation_observed: point.nutation_observed,
197 pm_xp_arcsec: point.pm_xp_arcsec,
198 pm_yp_arcsec: point.pm_yp_arcsec,
199 ut1_minus_utc_seconds: point.ut1_minus_utc_seconds,
200 lod_milliseconds: point.lod_milliseconds,
201 dx_milliarcsec: point.dx_milliarcsec,
202 dy_milliarcsec: point.dy_milliarcsec,
203 })
204 .collect(),
205 TimeDataProvenance::new("compiled", "compiled", "compiled", "compiled", "compiled"),
206 )
207}
208
209#[derive(Debug)]
210pub enum TimeDataError {
211 Io(std::io::Error),
212 Download(String),
213 Parse(String),
214 Integrity(String),
215}
216
217impl fmt::Display for TimeDataError {
218 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219 match self {
220 Self::Io(err) => write!(f, "I/O error: {err}"),
221 Self::Download(msg) => write!(f, "download error: {msg}"),
222 Self::Parse(msg) => write!(f, "parse error: {msg}"),
223 Self::Integrity(msg) => write!(f, "integrity error: {msg}"),
224 }
225 }
226}
227
228impl std::error::Error for TimeDataError {
229 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
230 match self {
231 Self::Io(err) => Some(err),
232 _ => None,
233 }
234 }
235}
236
237impl From<std::io::Error> for TimeDataError {
238 fn from(value: std::io::Error) -> Self {
239 Self::Io(value)
240 }
241}
242
243#[cfg(feature = "fetch")]
244mod fetch_support {
245 use super::*;
246 use serde_json::Value;
247 use sha2::{Digest, Sha256};
248 use std::fs;
249 use std::io::Read;
250 use std::path::{Path, PathBuf};
251 use std::time::{SystemTime, UNIX_EPOCH};
252
253 const DEFAULT_SUBDIR: &str = ".tempoch/data";
254 const BUNDLE_DIR_NAME: &str = "bundle";
255 const PROVENANCE_FILE: &str = "time_data.provenance.json";
256 const UTC_TAI_HISTORY_FILE: &str = "UTC-TAI.history";
257 const DELTA_T_OBSERVED_FILE: &str = "deltat.data";
258 const DELTA_T_PREDICTIONS_FILE: &str = "deltat.preds";
259 const EOP_FINALS_FILE: &str = "finals2000A.all";
260 const FETCH_TIMEOUT_SECS: u64 = 60;
261
262 pub struct TimeDataManager {
263 data_dir: PathBuf,
264 }
265
266 impl TimeDataManager {
267 pub fn new() -> Result<Self, TimeDataError> {
268 let data_dir = resolve_data_dir()?;
269 fs::create_dir_all(&data_dir)?;
270 Ok(Self { data_dir })
271 }
272
273 pub fn with_dir(dir: impl Into<PathBuf>) -> Result<Self, TimeDataError> {
274 let data_dir = dir.into();
275 fs::create_dir_all(&data_dir)?;
276 Ok(Self { data_dir })
277 }
278
279 pub fn data_dir(&self) -> &Path {
280 &self.data_dir
281 }
282
283 pub fn load_cached(&self) -> Result<TimeDataBundle, TimeDataError> {
284 load_cached_bundle(self.bundle_dir())
285 }
286
287 pub fn refresh(&self) -> Result<(), TimeDataError> {
288 fs::create_dir_all(&self.data_dir)?;
289 let staging_dir = self.staging_dir();
290 if staging_dir.exists() {
291 fs::remove_dir_all(&staging_dir)?;
292 }
293 fs::create_dir_all(&staging_dir)?;
294
295 let fetch_ts = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S").to_string();
296 let utc_tai = fetch_text(UTC_TAI_HISTORY_URL)?;
297 let delta_obs = fetch_text(DELTA_T_OBSERVED_URL)?;
298 let delta_pred = fetch_text(DELTA_T_PREDICTIONS_URL)?;
299 let eop = fetch_text(EOP_FINALS_URL)?;
300
301 fs::write(staging_dir.join(UTC_TAI_HISTORY_FILE), &utc_tai.text)?;
302 fs::write(staging_dir.join(DELTA_T_OBSERVED_FILE), &delta_obs.text)?;
303 fs::write(staging_dir.join(DELTA_T_PREDICTIONS_FILE), &delta_pred.text)?;
304 fs::write(staging_dir.join(EOP_FINALS_FILE), &eop.text)?;
305
306 let provenance = TimeDataProvenance::new(
307 fetch_ts,
308 utc_tai.sha256,
309 delta_obs.sha256,
310 delta_pred.sha256,
311 eop.sha256,
312 );
313 fs::write(
314 staging_dir.join(PROVENANCE_FILE),
315 render_provenance_json(&provenance),
316 )?;
317
318 load_cached_bundle(staging_dir.clone())?;
319 swap_bundle_dirs(&staging_dir, self.bundle_dir())?;
320 Ok(())
321 }
322
323 pub fn refresh_and_load(&self) -> Result<TimeDataBundle, TimeDataError> {
324 self.refresh()?;
325 self.load_cached()
326 }
327
328 fn bundle_dir(&self) -> PathBuf {
329 self.data_dir.join(BUNDLE_DIR_NAME)
330 }
331
332 fn staging_dir(&self) -> PathBuf {
333 let nonce = SystemTime::now()
334 .duration_since(UNIX_EPOCH)
335 .unwrap_or_default()
336 .as_nanos();
337 self.data_dir.join(format!(
338 ".{BUNDLE_DIR_NAME}.staging-{}-{nonce}",
339 std::process::id()
340 ))
341 }
342 }
343
344 struct DownloadedText {
345 text: String,
346 sha256: String,
347 }
348
349 fn resolve_data_dir() -> Result<PathBuf, TimeDataError> {
350 if let Ok(dir) = std::env::var(TEMPOCH_DATA_DIR_ENV) {
351 let trimmed = dir.trim();
352 if !trimmed.is_empty() {
353 return Ok(PathBuf::from(trimmed));
354 }
355 }
356
357 let home = std::env::var("HOME")
358 .or_else(|_| std::env::var("USERPROFILE"))
359 .map_err(|_| {
360 TimeDataError::Io(std::io::Error::new(
361 std::io::ErrorKind::NotFound,
362 "Cannot determine home directory. Set TEMPOCH_DATA_DIR explicitly.",
363 ))
364 })?;
365
366 Ok(PathBuf::from(home).join(DEFAULT_SUBDIR))
367 }
368
369 fn load_cached_bundle(bundle_dir: PathBuf) -> Result<TimeDataBundle, TimeDataError> {
370 if !bundle_dir.exists() {
371 return Err(TimeDataError::Integrity(format!(
372 "cached bundle not found at {}",
373 bundle_dir.display()
374 )));
375 }
376
377 let utc_tai_history = read_text(bundle_dir.join(UTC_TAI_HISTORY_FILE))?;
378 let delta_t_observed = read_text(bundle_dir.join(DELTA_T_OBSERVED_FILE))?;
379 let delta_t_predictions = read_text(bundle_dir.join(DELTA_T_PREDICTIONS_FILE))?;
380 let eop_finals = read_text(bundle_dir.join(EOP_FINALS_FILE))?;
381 let provenance_text = read_text(bundle_dir.join(PROVENANCE_FILE))?;
382 let provenance = parse_provenance_json(&provenance_text)?;
383
384 verify_sha256(
385 "UTC-TAI history",
386 &utc_tai_history,
387 provenance.utc_tai_sha256(),
388 )?;
389 verify_sha256(
390 "Delta T observed",
391 &delta_t_observed,
392 provenance.delta_t_observed_sha256(),
393 )?;
394 verify_sha256(
395 "Delta T predictions",
396 &delta_t_predictions,
397 provenance.delta_t_predictions_sha256(),
398 )?;
399 verify_sha256("EOP finals", &eop_finals, provenance.eop_finals_sha256())?;
400
401 TimeDataBundle::from_raw_sources(
402 &utc_tai_history,
403 &delta_t_observed,
404 &delta_t_predictions,
405 &eop_finals,
406 provenance,
407 )
408 }
409
410 fn swap_bundle_dirs(staging_dir: &Path, live_dir: PathBuf) -> Result<(), TimeDataError> {
411 let backup_dir = live_dir.with_extension("backup");
412 if backup_dir.exists() {
413 fs::remove_dir_all(&backup_dir)?;
414 }
415 if live_dir.exists() {
416 fs::rename(&live_dir, &backup_dir)?;
417 }
418 match fs::rename(staging_dir, &live_dir) {
419 Ok(()) => {
420 if backup_dir.exists() {
421 fs::remove_dir_all(&backup_dir)?;
422 }
423 Ok(())
424 }
425 Err(err) => {
426 if backup_dir.exists() && !live_dir.exists() {
427 let _ = fs::rename(&backup_dir, &live_dir);
428 }
429 Err(TimeDataError::Io(err))
430 }
431 }
432 }
433
434 fn read_text(path: PathBuf) -> Result<String, TimeDataError> {
435 fs::read_to_string(&path).map_err(|err| {
436 TimeDataError::Io(std::io::Error::new(
437 err.kind(),
438 format!("{}: {err}", path.display()),
439 ))
440 })
441 }
442
443 fn fetch_text(url: &str) -> Result<DownloadedText, TimeDataError> {
444 let response = ureq::get(url)
445 .set("User-Agent", "tempoch-runtime-data/1.0")
446 .timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
447 .call()
448 .map_err(|err| TimeDataError::Download(format!("fetch {url} failed: {err}")))?;
449 let bytes = {
450 let mut buf = Vec::new();
451 let mut reader = response.into_reader();
452 reader
453 .read_to_end(&mut buf)
454 .map_err(|err| TimeDataError::Download(format!("read {url} body failed: {err}")))?;
455 buf
456 };
457 let text = String::from_utf8(bytes.clone())
458 .map_err(|err| TimeDataError::Download(format!("{url} is not UTF-8: {err}")))?;
459 Ok(DownloadedText {
460 text,
461 sha256: sha256_bytes(&bytes),
462 })
463 }
464
465 fn render_provenance_json(provenance: &TimeDataProvenance) -> String {
466 let value = serde_json::json!({
467 "fetched_utc": provenance.fetched_utc(),
468 "utc_tai_sha256": provenance.utc_tai_sha256(),
469 "delta_t_observed_sha256": provenance.delta_t_observed_sha256(),
470 "delta_t_predictions_sha256": provenance.delta_t_predictions_sha256(),
471 "eop_finals_sha256": provenance.eop_finals_sha256(),
472 });
473 let mut rendered = serde_json::to_string_pretty(&value)
474 .expect("serializing time-data provenance should work");
475 rendered.push('\n');
476 rendered
477 }
478
479 fn parse_provenance_json(text: &str) -> Result<TimeDataProvenance, TimeDataError> {
480 let json: Value =
481 serde_json::from_str(text).map_err(|err| TimeDataError::Integrity(err.to_string()))?;
482 let string_field = |name: &str| -> Result<String, TimeDataError> {
483 json.get(name)
484 .and_then(Value::as_str)
485 .map(str::to_owned)
486 .ok_or_else(|| TimeDataError::Integrity(format!("missing provenance field {name}")))
487 };
488 Ok(TimeDataProvenance::new(
489 string_field("fetched_utc")?,
490 string_field("utc_tai_sha256")?,
491 string_field("delta_t_observed_sha256")?,
492 string_field("delta_t_predictions_sha256")?,
493 string_field("eop_finals_sha256")?,
494 ))
495 }
496
497 fn sha256_bytes(bytes: &[u8]) -> String {
498 let mut hasher = Sha256::new();
499 hasher.update(bytes);
500 let digest = hasher.finalize();
501 let mut out = String::with_capacity(digest.len() * 2);
502 for byte in digest {
503 out.push_str(&format!("{byte:02x}"));
504 }
505 out
506 }
507
508 fn verify_sha256(label: &str, text: &str, expected: &str) -> Result<(), TimeDataError> {
509 let actual = sha256_bytes(text.as_bytes());
510 if actual != expected {
511 return Err(TimeDataError::Integrity(format!(
512 "{label} SHA-256 mismatch: expected {expected}, got {actual}"
513 )));
514 }
515 Ok(())
516 }
517}
518
519#[cfg(feature = "fetch")]
520pub use fetch_support::TimeDataManager;
521
522fn mjd_epoch() -> NaiveDate {
523 NaiveDate::from_ymd_opt(1858, 11, 17).unwrap()
524}
525
526fn mjd_from_date(d: NaiveDate) -> i32 {
527 (d - mjd_epoch()).num_days() as i32
528}
529
530fn normalize_ws(s: &str) -> String {
531 s.replace('\t', " ")
532 .split_whitespace()
533 .collect::<Vec<_>>()
534 .join(" ")
535}
536
537fn parse_month(token: &str) -> Result<u32, String> {
538 let key: String = token
539 .chars()
540 .filter(|c| c.is_ascii_alphabetic())
541 .map(|c| c.to_ascii_lowercase())
542 .collect();
543 let month = match key.as_str() {
544 "jan" | "january" => 1,
545 "feb" | "february" => 2,
546 "mar" | "march" => 3,
547 "apr" | "april" => 4,
548 "may" => 5,
549 "jun" | "june" => 6,
550 "jul" | "july" => 7,
551 "aug" | "august" => 8,
552 "sep" | "sept" | "september" => 9,
553 "oct" | "october" => 10,
554 "nov" | "november" => 11,
555 "dec" | "december" => 12,
556 _ => return Err(format!("unknown month token: {token:?}")),
557 };
558 Ok(month)
559}
560
561fn parse_date_fragment(fragment: &str, default_year: Option<i32>) -> Result<NaiveDate, String> {
562 let normalized = normalize_ws(fragment);
563 let normalized = normalized.trim_end_matches('.').trim();
564 let tokens: Vec<&str> = normalized.split_whitespace().collect();
565 let (year, month_token, day_token) = match tokens.as_slice() {
566 [year, month, day] if year.len() == 4 && year.chars().all(|c| c.is_ascii_digit()) => (
567 year.parse::<i32>().map_err(|err| err.to_string())?,
568 *month,
569 *day,
570 ),
571 [month, day] => (
572 default_year.ok_or_else(|| format!("missing year for fragment: {fragment:?}"))?,
573 *month,
574 *day,
575 ),
576 _ => return Err(format!("unable to parse date fragment: {fragment:?}")),
577 };
578 let month = parse_month(month_token)?;
579 let day = day_token
580 .parse::<u32>()
581 .map_err(|_| format!("bad day in fragment: {fragment:?}"))?;
582 NaiveDate::from_ymd_opt(year, month, day)
583 .ok_or_else(|| format!("invalid calendar date in fragment: {fragment:?}"))
584}
585
586fn compact_number(s: &str) -> Result<f64, String> {
587 s.replace(' ', "")
588 .parse::<f64>()
589 .map_err(|err| format!("bad number {s:?}: {err}"))
590}
591
592fn extract_base_seconds(formula: &str) -> Result<f64, String> {
593 let bytes = formula.as_bytes();
594 let mut index = 0usize;
595 while index < bytes.len() {
596 if bytes[index] == b's' {
597 let mut start = index;
598 while start > 0 {
599 let c = bytes[start - 1];
600 if c.is_ascii_digit() || c == b'.' || c == b' ' {
601 start -= 1;
602 } else {
603 break;
604 }
605 }
606 let candidate = &formula[start..index];
607 if candidate.chars().any(|c| c.is_ascii_digit()) {
608 return compact_number(candidate);
609 }
610 }
611 index += 1;
612 }
613 Err(format!("unable to parse TAI-UTC base from {formula:?}"))
614}
615
616fn extract_slope(formula: &str) -> Result<Option<(f64, f64)>, String> {
617 let Some(mjd_idx) = formula.find("MJD") else {
618 return Ok(None);
619 };
620 let rest = &formula[mjd_idx + 3..];
621 let rest = rest.trim_start();
622 if !rest.starts_with('-') {
623 return Ok(None);
624 }
625 let after_dash = rest[1..].trim_start();
626 let ref_end = after_dash
627 .char_indices()
628 .find(|(_, c)| !(c.is_ascii_digit() || *c == ' '))
629 .map(|(idx, _)| idx)
630 .unwrap_or(after_dash.len());
631 let ref_str = after_dash[..ref_end].trim();
632 if ref_str.is_empty() {
633 return Ok(None);
634 }
635 let reference_mjd = compact_number(ref_str)?;
636 let after_ref = after_dash[ref_end..].trim_start();
637 let after_paren = after_ref
638 .strip_prefix(')')
639 .unwrap_or(after_ref)
640 .trim_start();
641 let after_x = match after_paren.strip_prefix('x') {
642 Some(rest) => rest.trim_start(),
643 None => return Ok(None),
644 };
645 let slope_end = after_x
646 .char_indices()
647 .find(|(_, c)| !(c.is_ascii_digit() || *c == '.' || *c == ' '))
648 .map(|(idx, _)| idx)
649 .unwrap_or(after_x.len());
650 let slope_str = after_x[..slope_end].trim();
651 if slope_str.is_empty() {
652 return Ok(None);
653 }
654 let rest_after_slope = after_x[slope_end..].trim_start();
655 if !rest_after_slope.starts_with('s') {
656 return Ok(None);
657 }
658 let slope = compact_number(slope_str)?;
659 Ok(Some((reference_mjd, slope)))
660}
661
662pub fn parse_utc_tai_segments(text: &str) -> Result<Vec<UtcTaiSegment>, String> {
663 let mut segments = Vec::new();
664 let mut previous_end: Option<NaiveDate> = None;
665 let mut previous_reference_mjd: Option<f64> = None;
666 let mut previous_slope: Option<f64> = None;
667
668 for raw_line in text.lines() {
669 let line = raw_line.trim_end();
670 if !line.contains('-')
671 || line.contains("UTC-TAI.history")
672 || line.contains("Limits of validity")
673 {
674 continue;
675 }
676 if !line.chars().any(|c| c.is_ascii_digit()) {
677 continue;
678 }
679
680 let dash_idx = line.find('-').unwrap();
681 let (left, right) = line.split_at(dash_idx);
682 let right = &right[1..];
683 if !left.chars().any(|c| c.is_ascii_alphabetic()) {
684 continue;
685 }
686
687 let default_start_year = previous_end.map(date_year);
688 let start_date = parse_date_fragment(left, default_start_year)?;
689 let right_normalized = normalize_ws(right);
690 let (end_date, formula) = match parse_end_and_formula(&right_normalized, start_date) {
691 Some((end_date, formula)) => (Some(end_date), formula),
692 None => (None, right_normalized.clone()),
693 };
694
695 let base_seconds = extract_base_seconds(&formula)?;
696 let (reference_mjd, slope_seconds_per_day) =
697 if let Some((reference_mjd, slope)) = extract_slope(&formula)? {
698 (reference_mjd, slope)
699 } else if formula.contains("\"\"") {
700 match (previous_reference_mjd, previous_slope) {
701 (Some(reference_mjd), Some(slope)) => (reference_mjd, slope),
702 _ => {
703 return Err(format!(
704 "repeated UTC formula without previous state: {formula:?}"
705 ))
706 }
707 }
708 } else {
709 (mjd_from_date(start_date) as f64, 0.0)
710 };
711
712 segments.push(UtcTaiSegment {
713 start_mjd: mjd_from_date(start_date),
714 end_mjd: end_date.map(mjd_from_date),
715 base_seconds,
716 reference_mjd,
717 slope_seconds_per_day,
718 });
719
720 previous_end = end_date;
721 previous_reference_mjd = Some(reference_mjd);
722 previous_slope = Some(slope_seconds_per_day);
723 }
724
725 validate_utc_tai_segments(&segments)?;
726 Ok(segments)
727}
728
729fn date_year(date: NaiveDate) -> i32 {
730 use chrono::Datelike;
731 date.year()
732}
733
734fn parse_end_and_formula(
735 right_normalized: &str,
736 start_date: NaiveDate,
737) -> Option<(NaiveDate, String)> {
738 let tokens: Vec<&str> = right_normalized.splitn(4, ' ').collect();
739 if tokens.len() < 3 {
740 return None;
741 }
742 if tokens.len() == 4
743 && tokens[0].len() == 4
744 && tokens[0].chars().all(|c| c.is_ascii_digit())
745 && parse_month(tokens[1]).is_ok()
746 && !tokens[2].is_empty()
747 && tokens[2].chars().all(|c| c.is_ascii_digit())
748 {
749 let year = tokens[0].parse::<i32>().ok()?;
750 let month = parse_month(tokens[1]).ok()?;
751 let day = tokens[2].parse::<u32>().ok()?;
752 let end_date = NaiveDate::from_ymd_opt(year, month, day)?;
753 return Some((end_date, tokens[3].to_string()));
754 }
755 if parse_month(tokens[0]).is_ok()
756 && !tokens[1].is_empty()
757 && tokens[1].chars().all(|c| c.is_ascii_digit())
758 {
759 let month = parse_month(tokens[0]).ok()?;
760 let day = tokens[1].parse::<u32>().ok()?;
761 let end_date = NaiveDate::from_ymd_opt(date_year(start_date), month, day)?;
762 let rest = right_normalized
763 .splitn(3, ' ')
764 .nth(2)
765 .unwrap_or("")
766 .to_string();
767 return Some((end_date, rest));
768 }
769 None
770}
771
772fn validate_utc_tai_segments(segments: &[UtcTaiSegment]) -> Result<(), String> {
773 if segments.is_empty() {
774 return Err("UTC-TAI history parsing produced no segments".into());
775 }
776 for (idx, segment) in segments.iter().enumerate() {
777 if let Some(end_mjd) = segment.end_mjd {
778 if end_mjd <= segment.start_mjd {
779 return Err(format!(
780 "UTC-TAI segment ending at MJD {end_mjd} does not extend past start {}",
781 segment.start_mjd
782 ));
783 }
784 }
785 let Some(next) = segments.get(idx + 1) else {
786 continue;
787 };
788 if next.start_mjd == segment.start_mjd {
789 return Err(format!(
790 "UTC-TAI segment list contains duplicate start MJD {}",
791 segment.start_mjd
792 ));
793 }
794 if next.start_mjd < segment.start_mjd {
795 return Err(format!(
796 "UTC-TAI segment list is not strictly increasing near {} -> {}",
797 segment.start_mjd, next.start_mjd
798 ));
799 }
800 match segment.end_mjd {
801 Some(end_mjd) if end_mjd == next.start_mjd => {}
802 Some(end_mjd) => {
803 return Err(format!(
804 "UTC-TAI segment boundary mismatch near {} -> {}",
805 end_mjd, next.start_mjd
806 ))
807 }
808 None => {
809 return Err(format!(
810 "UTC-TAI segment starting at MJD {} is open-ended before the next segment {}",
811 segment.start_mjd, next.start_mjd
812 ))
813 }
814 }
815 }
816 Ok(())
817}
818
819fn validate_strictly_increasing_mjds(label: &str, points: &[(f64, f64)]) -> Result<(), String> {
820 for window in points.windows(2) {
821 let current = window[0].0;
822 let next = window[1].0;
823 if next == current {
824 return Err(format!(
825 "{label} MJD column contains duplicate entry at {current:.3}"
826 ));
827 }
828 if next < current {
829 return Err(format!(
830 "{label} MJD column is not strictly increasing near {current:.3} -> {next:.3}"
831 ));
832 }
833 }
834 Ok(())
835}
836
837fn validate_eop_points(points: &[EopPoint]) -> Result<(), String> {
838 if points.len() < 2 {
839 return Err("EOP finals parsing produced fewer than two usable rows".into());
840 }
841 for window in points.windows(2) {
842 let current = window[0].mjd;
843 let next = window[1].mjd;
844 if next == current {
845 return Err(format!(
846 "EOP finals MJD column contains duplicate entry at {current}"
847 ));
848 }
849 if next < current {
850 return Err(format!(
851 "EOP finals MJD column is not strictly increasing near {current} -> {next}"
852 ));
853 }
854 if next != current + 1 {
855 return Err(format!(
856 "EOP finals MJD column has a daily gap near {current} -> {next}"
857 ));
858 }
859 }
860 Ok(())
861}
862
863pub fn parse_delta_t_observed(text: &str) -> Result<Vec<(f64, f64)>, String> {
864 let mut points = Vec::new();
865 for raw_line in text.lines() {
866 let parts: Vec<&str> = raw_line.split_whitespace().collect();
867 if parts.len() != 4 {
868 continue;
869 }
870 if !parts[0].chars().all(|c| c.is_ascii_digit()) {
871 continue;
872 }
873 let year = parts[0]
874 .parse::<i32>()
875 .map_err(|err: std::num::ParseIntError| err.to_string())?;
876 let month = parts[1]
877 .parse::<u32>()
878 .map_err(|err: std::num::ParseIntError| err.to_string())?;
879 let day = parts[2]
880 .parse::<u32>()
881 .map_err(|err: std::num::ParseIntError| err.to_string())?;
882 let delta_t = parts[3]
883 .parse::<f64>()
884 .map_err(|err: std::num::ParseFloatError| err.to_string())?;
885 let date = NaiveDate::from_ymd_opt(year, month, day)
886 .ok_or_else(|| format!("invalid date in observed Delta T: {raw_line:?}"))?;
887 points.push((mjd_from_date(date) as f64, delta_t));
888 }
889 if points.is_empty() {
890 return Err("observed Delta T parsing produced no points".into());
891 }
892 validate_strictly_increasing_mjds("observed Delta T", &points)?;
893 Ok(points)
894}
895
896pub fn parse_delta_t_predictions(text: &str) -> Result<Vec<(f64, f64)>, String> {
897 let mut points = Vec::new();
898 for raw_line in text.lines() {
899 let parts: Vec<&str> = raw_line.split_whitespace().collect();
900 if parts.is_empty() || parts[0] == "MJD" || parts.len() < 3 {
901 continue;
902 }
903 let Ok(mjd) = parts[0].parse::<f64>() else {
904 continue;
905 };
906 let Ok(delta_t) = parts[2].parse::<f64>() else {
907 continue;
908 };
909 points.push((mjd, delta_t));
910 }
911 if points.is_empty() {
912 return Err("predicted Delta T parsing produced no points".into());
913 }
914 validate_strictly_increasing_mjds("predicted Delta T", &points)?;
915 Ok(points)
916}
917
918pub fn build_modern_delta_t_points(
919 observed_points: &[(f64, f64)],
920 predicted_points: &[(f64, f64)],
921) -> Result<(Vec<(f64, f64)>, f64), String> {
922 let (last_obs_mjd, last_obs_dt) = *observed_points.last().ok_or("observed Delta T is empty")?;
923 validate_strictly_increasing_mjds("observed Delta T", observed_points)?;
924 validate_strictly_increasing_mjds("predicted Delta T", predicted_points)?;
925 let mut future: Vec<(f64, f64)> = predicted_points
926 .iter()
927 .copied()
928 .filter(|(mjd, _)| *mjd > last_obs_mjd)
929 .collect();
930
931 if !future.is_empty() {
932 let (m0, d0) = future[0];
933 let (m1, d1) = if future.len() >= 2 {
934 future[1]
935 } else {
936 (m0, d0)
937 };
938 let frac = if m1 != m0 {
939 (last_obs_mjd - m0) / (m1 - m0)
940 } else {
941 0.0
942 };
943 let pred_at_stitch = d0 + frac * (d1 - d0);
944 let continuity_offset = last_obs_dt - pred_at_stitch;
945 for point in &mut future {
946 point.1 += continuity_offset;
947 }
948 }
949
950 let mut combined = Vec::with_capacity(observed_points.len() + future.len());
951 combined.extend_from_slice(observed_points);
952 combined.extend_from_slice(&future);
953 if combined.len() < 2 {
954 return Err("modern Delta T series must contain at least two points".into());
955 }
956 validate_strictly_increasing_mjds("modern Delta T", &combined)?;
957 Ok((combined, last_obs_mjd))
958}
959
960pub fn parse_eop_finals(text: &str) -> Result<Vec<EopPoint>, String> {
961 let mut points = Vec::new();
962
963 for line in text.lines() {
964 if line.len() < 68 {
965 continue;
966 }
967 let Some(mjd_f) = col(line, 8, 15).and_then(parse_f64) else {
968 continue;
969 };
970 let mjd = mjd_f.round() as i32;
971 let Some(ut1_flag) = col(line, 58, 58).and_then(parse_flag) else {
972 continue;
973 };
974 if !matches!(ut1_flag, 'I' | 'P') {
975 continue;
976 }
977 let Some(ut1_minus_utc_seconds) = col(line, 59, 68).and_then(parse_f64) else {
978 continue;
979 };
980
981 let pm_flag = col(line, 17, 17).and_then(parse_flag);
982 let nutation_flag = col(line, 96, 96).and_then(parse_flag);
983 points.push(EopPoint {
984 mjd,
985 pm_observed: matches!(pm_flag, Some('I')),
986 ut1_observed: ut1_flag == 'I',
987 nutation_observed: matches!(nutation_flag, Some('I')),
988 pm_xp_arcsec: col(line, 19, 27).and_then(parse_f64),
989 pm_yp_arcsec: col(line, 38, 46).and_then(parse_f64),
990 ut1_minus_utc_seconds,
991 lod_milliseconds: col(line, 80, 86).and_then(parse_f64),
992 dx_milliarcsec: col(line, 98, 106).and_then(parse_f64),
993 dy_milliarcsec: col(line, 117, 125).and_then(parse_f64),
994 });
995 }
996
997 validate_eop_points(&points)?;
998 Ok(points)
999}
1000
1001pub fn observed_end_mjd(points: &[EopPoint]) -> i32 {
1002 points
1003 .iter()
1004 .rev()
1005 .find(|point| point.ut1_observed)
1006 .map(|point| point.mjd)
1007 .unwrap_or(points[0].mjd)
1008}
1009
1010fn col(line: &str, start_1based: usize, end_1based_inclusive: usize) -> Option<&str> {
1011 let start = start_1based.checked_sub(1)?;
1012 let end = end_1based_inclusive;
1013 if line.len() < end {
1014 return None;
1015 }
1016 Some(&line[start..end])
1017}
1018
1019fn parse_f64(slice: &str) -> Option<f64> {
1020 let trimmed = slice.trim();
1021 if trimmed.is_empty() {
1022 return None;
1023 }
1024 trimmed.parse::<f64>().ok()
1025}
1026
1027fn parse_flag(slice: &str) -> Option<char> {
1028 slice.trim().chars().next()
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033 use super::*;
1034
1035 fn sample_utc_tai_history() -> &'static str {
1036 "1961 Jan. 1 - Aug. 1 1.4228180s + (MJD - 37300) x 0.001296s\n\
1037 Aug. 1 - 1962 Jan. 1 1.3728180s + \"\"\n\
1038 1962 Jan. 1 - 10s\n"
1039 }
1040
1041 fn set_field(line: &mut [u8], start_1based: usize, end_1based_inclusive: usize, value: &str) {
1042 let start = start_1based - 1;
1043 let width = end_1based_inclusive - start_1based + 1;
1044 let bytes = value.as_bytes();
1045 assert!(
1046 bytes.len() <= width,
1047 "{value:?} does not fit in width {width}"
1048 );
1049 let offset = width - bytes.len();
1050 line[start + offset..start + offset + bytes.len()].copy_from_slice(bytes);
1051 }
1052
1053 #[allow(clippy::too_many_arguments)]
1054 fn sample_eop_line(
1055 mjd: i32,
1056 ut1_flag: char,
1057 ut1_minus_utc_seconds: f64,
1058 pm_xp_arcsec: Option<f64>,
1059 pm_yp_arcsec: Option<f64>,
1060 lod_milliseconds: Option<f64>,
1061 dx_milliarcsec: Option<f64>,
1062 dy_milliarcsec: Option<f64>,
1063 ) -> String {
1064 let mut line = vec![b' '; 125];
1065 set_field(&mut line, 8, 15, &format!("{:8.2}", mjd as f64));
1066 line[16] = b'I';
1067 if let Some(value) = pm_xp_arcsec {
1068 set_field(&mut line, 19, 27, &format!("{value:>9.6}"));
1069 }
1070 if let Some(value) = pm_yp_arcsec {
1071 set_field(&mut line, 38, 46, &format!("{value:>9.6}"));
1072 }
1073 line[57] = ut1_flag as u8;
1074 set_field(&mut line, 59, 68, &format!("{ut1_minus_utc_seconds:>10.7}"));
1075 if let Some(value) = lod_milliseconds {
1076 set_field(&mut line, 80, 86, &format!("{value:>7.4}"));
1077 }
1078 line[95] = b'I';
1079 if let Some(value) = dx_milliarcsec {
1080 set_field(&mut line, 98, 106, &format!("{value:>9.3}"));
1081 }
1082 if let Some(value) = dy_milliarcsec {
1083 set_field(&mut line, 117, 125, &format!("{value:>9.3}"));
1084 }
1085 String::from_utf8(line).expect("sample EOP line must stay ASCII")
1086 }
1087
1088 #[test]
1089 fn parse_utc_tai_segments_reads_piecewise_rules() {
1090 let segments = parse_utc_tai_segments(sample_utc_tai_history()).unwrap();
1091 assert_eq!(segments.len(), 3);
1092 assert_eq!(segments[0].start_mjd, 37_300);
1093 assert_eq!(segments[0].end_mjd, Some(37_512));
1094 assert_eq!(segments[0].reference_mjd, 37_300.0);
1095 assert_eq!(segments[0].slope_seconds_per_day, 0.001_296);
1096 assert_eq!(segments[1].reference_mjd, segments[0].reference_mjd);
1097 assert_eq!(
1098 segments[1].slope_seconds_per_day,
1099 segments[0].slope_seconds_per_day
1100 );
1101 assert_eq!(segments[2].end_mjd, None);
1102 assert_eq!(segments[2].base_seconds, 10.0);
1103 }
1104
1105 #[test]
1106 fn parse_delta_t_observed_reads_representative_rows() {
1107 let points = parse_delta_t_observed(
1108 "2024 01 01 69.1000\n\
1109 2024 02 01 69.2000\n",
1110 )
1111 .unwrap();
1112 assert_eq!(points.len(), 2);
1113 assert_eq!(
1114 points[0].0,
1115 mjd_from_date(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()) as f64
1116 );
1117 assert_eq!(points[1].1, 69.2);
1118 }
1119
1120 #[test]
1121 fn parse_delta_t_predictions_reads_representative_rows() {
1122 let points = parse_delta_t_predictions(
1123 "MJD YEAR DELTAT\n\
1124 60310 2024.1 69.4000\n\
1125 60341 2024.2 69.5000\n",
1126 )
1127 .unwrap();
1128 assert_eq!(points, vec![(60_310.0, 69.4), (60_341.0, 69.5)]);
1129 }
1130
1131 #[test]
1132 fn build_modern_delta_t_points_applies_continuity_offset() {
1133 let observed = [(60_000.0, 69.8), (60_030.0, 71.0)];
1134 let predicted = [(60_040.0, 70.0), (60_050.0, 72.0)];
1135 let (combined, observed_end_mjd) =
1136 build_modern_delta_t_points(&observed, &predicted).unwrap();
1137
1138 assert_eq!(observed_end_mjd, 60_030.0);
1139 assert_eq!(combined.len(), 4);
1140 let (m0, d0) = combined[2];
1141 let (m1, d1) = combined[3];
1142 let frac = (observed_end_mjd - m0) / (m1 - m0);
1143 let stitched_value = d0 + frac * (d1 - d0);
1144 assert!((stitched_value - 71.0).abs() < 1e-12);
1145 }
1146
1147 #[test]
1148 fn build_modern_delta_t_points_rejects_duplicate_input_mjds() {
1149 let observed = [(60_000.0, 69.8), (60_000.0, 69.9)];
1150 let predicted = [(60_031.0, 70.0), (60_062.0, 70.2)];
1151 let err = build_modern_delta_t_points(&observed, &predicted).unwrap_err();
1152 assert!(err.contains("observed Delta T"));
1153 assert!(err.contains("duplicate"));
1154 }
1155
1156 #[test]
1157 fn parse_delta_t_predictions_rejects_non_increasing_mjds() {
1158 let err = parse_delta_t_predictions(
1159 "MJD YEAR DELTAT\n\
1160 60341 2024.2 69.5000\n\
1161 60310 2024.1 69.4000\n",
1162 )
1163 .unwrap_err();
1164 assert!(err.contains("predicted Delta T"));
1165 assert!(err.contains("not strictly increasing"));
1166 }
1167
1168 #[test]
1169 fn parse_eop_finals_reads_representative_rows() {
1170 let text = format!(
1171 "{}\n{}\n",
1172 sample_eop_line(
1173 60_000,
1174 'I',
1175 -0.123_456_7,
1176 Some(0.123_456),
1177 Some(-0.234_567),
1178 Some(1.2345),
1179 Some(0.321),
1180 Some(-0.111),
1181 ),
1182 sample_eop_line(60_001, 'P', -0.223_456_7, None, None, None, None, None,),
1183 );
1184 let points = parse_eop_finals(&text).unwrap();
1185 assert_eq!(points.len(), 2);
1186 assert_eq!(points[0].mjd, 60_000);
1187 assert!(points[0].ut1_observed);
1188 assert_eq!(points[0].pm_xp_arcsec, Some(0.123_456));
1189 assert_eq!(points[0].lod_milliseconds, Some(1.2345));
1190 assert_eq!(points[0].dx_milliarcsec, Some(0.321));
1191 assert_eq!(points[1].mjd, 60_001);
1192 assert!(!points[1].ut1_observed);
1193 assert_eq!(points[1].pm_xp_arcsec, None);
1194 assert_eq!(points[1].dx_milliarcsec, None);
1195 }
1196
1197 #[test]
1198 fn parse_eop_finals_rejects_duplicate_mjds() {
1199 let text = format!(
1200 "{}\n{}\n",
1201 sample_eop_line(60_000, 'I', -0.1, Some(0.1), Some(0.2), None, None, None),
1202 sample_eop_line(60_000, 'P', -0.2, Some(0.1), Some(0.2), None, None, None),
1203 );
1204 let err = parse_eop_finals(&text).unwrap_err();
1205 assert!(err.contains("duplicate"));
1206 }
1207
1208 #[test]
1209 fn parse_eop_finals_rejects_daily_gaps() {
1210 let text = format!(
1211 "{}\n{}\n",
1212 sample_eop_line(60_000, 'I', -0.1, Some(0.1), Some(0.2), None, None, None),
1213 sample_eop_line(60_002, 'P', -0.2, Some(0.1), Some(0.2), None, None, None),
1214 );
1215 let err = parse_eop_finals(&text).unwrap_err();
1216 assert!(err.contains("daily gap"));
1217 }
1218
1219 #[test]
1222 fn time_data_error_display_io() {
1223 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
1224 let err = TimeDataError::Io(io_err);
1225 assert!(err.to_string().contains("I/O error"));
1226 assert!(err.to_string().contains("file not found"));
1227 }
1228
1229 #[test]
1230 fn time_data_error_display_download() {
1231 let err = TimeDataError::Download("timeout".into());
1232 assert!(err.to_string().contains("download error"));
1233 assert!(err.to_string().contains("timeout"));
1234 }
1235
1236 #[test]
1237 fn time_data_error_display_parse() {
1238 let err = TimeDataError::Parse("bad line".into());
1239 assert!(err.to_string().contains("parse error"));
1240 assert!(err.to_string().contains("bad line"));
1241 }
1242
1243 #[test]
1244 fn time_data_error_display_integrity() {
1245 let err = TimeDataError::Integrity("hash mismatch".into());
1246 assert!(err.to_string().contains("integrity error"));
1247 assert!(err.to_string().contains("hash mismatch"));
1248 }
1249
1250 #[test]
1251 fn time_data_error_source_io_is_some() {
1252 use std::error::Error;
1253 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
1254 let err = TimeDataError::Io(io_err);
1255 assert!(err.source().is_some());
1256 }
1257
1258 #[test]
1259 fn time_data_error_source_non_io_is_none() {
1260 use std::error::Error;
1261 assert!(TimeDataError::Download("x".into()).source().is_none());
1262 assert!(TimeDataError::Parse("x".into()).source().is_none());
1263 assert!(TimeDataError::Integrity("x".into()).source().is_none());
1264 }
1265
1266 #[test]
1267 fn time_data_error_from_io_error() {
1268 let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe");
1269 let err = TimeDataError::from(io_err);
1270 assert!(matches!(err, TimeDataError::Io(_)));
1271 }
1272
1273 #[test]
1276 fn parse_utc_tai_segments_with_sep_oct_nov_dec() {
1277 let history = "\
12791961 Sep. 1 - Oct. 1 0.5s\n\
1280Oct. 1 - Nov. 1 0.6s\n\
1281Nov. 1 - Dec. 1 0.7s\n\
1282Dec. 1 - 1962 Jan. 1 0.8s\n\
12831962 Jan. 1 - 1.0s\n";
1284 let segments = parse_utc_tai_segments(history).unwrap();
1285 assert!(segments.len() >= 5);
1286 }
1287
1288 #[test]
1291 fn parse_utc_tai_segments_rejects_bad_date_fragment() {
1292 let history = "baddate foo bar baz qux - 1962 Jan. 1 1.0s\n1962 Jan. 1 - 2.0s\n";
1295 let result = parse_utc_tai_segments(history);
1296 assert!(result.is_err());
1297 }
1298
1299 #[test]
1302 fn parse_utc_tai_segments_constant_offset_formula() {
1303 let history = "1972 Jan. 1 - 1973 Jan. 1 10s\n1973 Jan. 1 - 11s\n";
1304 let segments = parse_utc_tai_segments(history).unwrap();
1305 assert_eq!(segments.len(), 2);
1306 assert_eq!(segments[0].base_seconds, 10.0);
1307 assert_eq!(segments[0].slope_seconds_per_day, 0.0);
1308 assert_eq!(segments[1].base_seconds, 11.0);
1309 assert_eq!(segments[1].slope_seconds_per_day, 0.0);
1310 assert!(segments[1].end_mjd.is_none());
1311 }
1312}