1use crate::error::FitsError;
12use crate::error::Result;
13use crate::header::Header;
14use crate::keyword::key;
15
16const MJD0: f64 = 2_400_000.5;
18const T1977_JD: f64 = 2_443_144.5;
20const TT_TAI: f64 = 32.184;
22const TAI_GPS: f64 = 19.0;
24const L_G: f64 = 6.969_290_134e-10;
26const L_B: f64 = 1.550_519_768e-8;
28const TDB_0: f64 = -6.55e-5;
30const SEC_PER_DAY: f64 = 86_400.0;
31
32#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct Datetime {
36 pub year: i64,
37 pub month: u32,
38 pub day: u32,
39 pub hour: u32,
40 pub minute: u32,
41 pub second: f64,
42}
43
44impl Datetime {
45 pub fn parse(s: &str) -> Result<Datetime> {
49 let invalid = || FitsError::InvalidValue {
50 card: format!("DATE '{s}'"),
51 };
52 let s = s.trim();
53 if s.contains(['Z', 'z']) {
55 return Err(invalid());
56 }
57 let (date, time) = match s.split_once('T') {
58 Some((d, t)) => (d, Some(t)),
59 None => (s, None),
60 };
61 let (sign, rest) = match date.strip_prefix('-') {
64 Some(r) => (-1, r),
65 None => (1, date.strip_prefix('+').unwrap_or(date)),
66 };
67 let mut dp = rest.split('-');
68 let y_str = dp.next().ok_or_else(invalid)?;
69 let m_str = dp.next().ok_or_else(invalid)?;
70 let d_str = dp.next().ok_or_else(invalid)?;
71 if dp.next().is_some() || y_str.len() < 4 || !all_digits(y_str) {
72 return Err(invalid());
73 }
74 let year = sign * y_str.parse::<i64>().map_err(|_| invalid())?;
75 if year.unsigned_abs() > 1_000_000 {
78 return Err(invalid());
79 }
80 let month = parse_fixed(m_str, 2).ok_or_else(invalid)?;
81 let day = parse_fixed(d_str, 2).ok_or_else(invalid)?;
82 if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
83 return Err(invalid());
84 }
85
86 let (mut hour, mut minute, mut second) = (0u32, 0u32, 0.0f64);
87 if let Some(t) = time {
88 let mut tp = t.split(':');
89 hour = parse_fixed(tp.next().ok_or_else(invalid)?, 2).ok_or_else(invalid)?;
90 minute = parse_fixed(tp.next().ok_or_else(invalid)?, 2).ok_or_else(invalid)?;
91 if let Some(sec) = tp.next() {
92 second = parse_seconds(sec).ok_or_else(invalid)?;
93 }
94 if tp.next().is_some() || hour >= 24 || minute >= 60 || !(0.0..61.0).contains(&second) {
97 return Err(invalid());
98 }
99 }
100 Ok(Datetime {
101 year,
102 month,
103 day,
104 hour,
105 minute,
106 second,
107 })
108 }
109
110 pub fn to_jd(&self) -> f64 {
113 let day_fraction =
114 (self.hour as f64 * 3600.0 + self.minute as f64 * 60.0 + self.second) / SEC_PER_DAY;
115 gregorian_to_jdn(self.year, self.month as i64, self.day as i64) as f64 - 0.5 + day_fraction
116 }
117
118 pub fn to_mjd(&self) -> f64 {
120 self.to_jd() - MJD0
121 }
122
123 pub fn from_jd(jd: f64) -> Datetime {
127 let z = (jd + 0.5).floor();
129 let frac = (jd + 0.5) - z;
130 let date = jdn_to_gregorian(z as i64);
131 let mut secs = frac * SEC_PER_DAY;
132 let hour = (secs / 3600.0).floor();
133 secs -= hour * 3600.0;
134 let minute = (secs / 60.0).floor();
135 secs -= minute * 60.0;
136 Datetime {
137 year: date.year,
138 month: date.month,
139 day: date.day,
140 hour: hour as u32,
141 minute: minute as u32,
142 second: secs,
143 }
144 }
145}
146
147fn all_digits(s: &str) -> bool {
149 !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
150}
151
152fn parse_fixed(s: &str, width: usize) -> Option<u32> {
155 (s.len() == width && all_digits(s))
156 .then(|| s.parse().ok())
157 .flatten()
158}
159
160fn parse_seconds(s: &str) -> Option<f64> {
162 let (int, frac) = s.split_once('.').map_or((s, None), |(i, f)| (i, Some(f)));
163 if int.len() != 2 || !all_digits(int) || frac.is_some_and(|f| !all_digits(f)) {
164 return None;
165 }
166 s.parse().ok()
167}
168
169#[derive(Debug, Clone, Copy, PartialEq)]
171pub enum Epoch {
172 Julian(f64),
174 Besselian(f64),
176}
177
178impl Epoch {
179 pub fn parse(s: &str) -> Result<Epoch> {
181 let s = s.trim();
182 let invalid = || FitsError::InvalidValue {
183 card: format!("epoch '{s}'"),
184 };
185 let (tag, rest) = s.split_at(
186 s.char_indices()
187 .next()
188 .map(|(_, c)| c.len_utf8())
189 .unwrap_or(0),
190 );
191 let year: f64 = rest.parse().map_err(|_| invalid())?;
192 match tag {
193 "J" | "j" => Ok(Epoch::Julian(year)),
194 "B" | "b" => Ok(Epoch::Besselian(year)),
195 _ => Err(invalid()),
196 }
197 }
198
199 pub fn to_jd(self) -> f64 {
202 match self {
203 Epoch::Julian(y) => 2_451_545.0 + (y - 2000.0) * 365.25,
204 Epoch::Besselian(y) => 2_415_020.313_52 + (y - 1900.0) * 365.242_198_781,
205 }
206 }
207
208 pub fn to_mjd(self) -> f64 {
210 self.to_jd() - MJD0
211 }
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum TimeScale {
217 Utc,
219 Ut1,
221 Tai,
223 Tt,
225 Tcg,
227 Tdb,
229 Tcb,
231 Gps,
233 Local,
235}
236
237impl TimeScale {
238 pub fn parse(s: &str) -> TimeScale {
241 let base = s.trim().split('(').next().unwrap_or("").trim();
244 match base.to_ascii_uppercase().as_str() {
245 "UTC" | "GMT" => TimeScale::Utc, "UT1" | "UT" => TimeScale::Ut1,
247 "TAI" | "IAT" => TimeScale::Tai,
248 "TT" | "TDT" | "ET" => TimeScale::Tt,
249 "TCG" => TimeScale::Tcg,
250 "TDB" => TimeScale::Tdb,
251 "TCB" => TimeScale::Tcb,
252 "GPS" => TimeScale::Gps,
253 _ => TimeScale::Local,
254 }
255 }
256
257 pub fn convert(self, jd: f64, target: TimeScale) -> f64 {
260 self.convert_dut1(jd, target, 0.0)
261 }
262
263 pub fn convert_dut1(self, jd: f64, target: TimeScale, dut1: f64) -> f64 {
266 if self == target || self == TimeScale::Local || target == TimeScale::Local {
267 return jd;
268 }
269 from_tt(self.to_tt(jd, dut1), target, dut1)
270 }
271
272 fn to_tt(self, jd: f64, dut1: f64) -> f64 {
274 match self {
275 TimeScale::Tt => jd,
276 TimeScale::Tai => jd + TT_TAI / SEC_PER_DAY,
277 TimeScale::Gps => jd + (TT_TAI + TAI_GPS) / SEC_PER_DAY,
278 TimeScale::Utc => jd + (TT_TAI + leap_seconds(jd - MJD0)) / SEC_PER_DAY,
279 TimeScale::Ut1 => {
281 let utc = jd - dut1 / SEC_PER_DAY;
282 utc + (TT_TAI + leap_seconds(utc - MJD0)) / SEC_PER_DAY
283 }
284 TimeScale::Tcg => jd - L_G * (jd - T1977_JD),
285 TimeScale::Tdb => jd - tdb_minus_tt(jd) / SEC_PER_DAY,
286 TimeScale::Tcb => {
287 let tdb = jd - L_B * (jd - T1977_JD) + TDB_0 / SEC_PER_DAY;
288 tdb - tdb_minus_tt(tdb) / SEC_PER_DAY
289 }
290 TimeScale::Local => unreachable!("convert_dut1 short-circuits Local"),
292 }
293 }
294}
295
296fn from_tt(tt: f64, target: TimeScale, dut1: f64) -> f64 {
298 match target {
299 TimeScale::Tt => tt,
300 TimeScale::Tai => tt - TT_TAI / SEC_PER_DAY,
301 TimeScale::Gps => tt - (TT_TAI + TAI_GPS) / SEC_PER_DAY,
302 TimeScale::Utc => {
303 let tai = tt - TT_TAI / SEC_PER_DAY;
304 tai - leap_seconds(tai - MJD0) / SEC_PER_DAY
307 }
308 TimeScale::Ut1 => {
310 let tai = tt - TT_TAI / SEC_PER_DAY;
311 let utc = tai - leap_seconds(tai - MJD0) / SEC_PER_DAY;
312 utc + dut1 / SEC_PER_DAY
313 }
314 TimeScale::Tcg => tt + L_G * (tt - T1977_JD),
315 TimeScale::Tdb => tt + tdb_minus_tt(tt) / SEC_PER_DAY,
316 TimeScale::Tcb => {
317 let tdb = tt + tdb_minus_tt(tt) / SEC_PER_DAY;
318 tdb + L_B * (tdb - T1977_JD) - TDB_0 / SEC_PER_DAY
319 }
320 TimeScale::Local => unreachable!("convert_dut1 short-circuits Local"),
322 }
323}
324
325fn tdb_minus_tt(jd_tt: f64) -> f64 {
328 let g = (357.53 + 0.985_600_3 * (jd_tt - 2_451_545.0)).to_radians();
329 0.001_658 * g.sin() + 0.000_014 * (2.0 * g).sin()
330}
331
332fn leap_seconds(mjd: f64) -> f64 {
341 const TABLE: &[(i64, i64, i64, f64)] = &[
343 (1972, 1, 1, 10.0),
344 (1972, 7, 1, 11.0),
345 (1973, 1, 1, 12.0),
346 (1974, 1, 1, 13.0),
347 (1975, 1, 1, 14.0),
348 (1976, 1, 1, 15.0),
349 (1977, 1, 1, 16.0),
350 (1978, 1, 1, 17.0),
351 (1979, 1, 1, 18.0),
352 (1980, 1, 1, 19.0),
353 (1981, 7, 1, 20.0),
354 (1982, 7, 1, 21.0),
355 (1983, 7, 1, 22.0),
356 (1985, 7, 1, 23.0),
357 (1988, 1, 1, 24.0),
358 (1990, 1, 1, 25.0),
359 (1991, 1, 1, 26.0),
360 (1992, 7, 1, 27.0),
361 (1993, 7, 1, 28.0),
362 (1994, 7, 1, 29.0),
363 (1996, 1, 1, 30.0),
364 (1997, 7, 1, 31.0),
365 (1999, 1, 1, 32.0),
366 (2006, 1, 1, 33.0),
367 (2009, 1, 1, 34.0),
368 (2012, 7, 1, 35.0),
369 (2015, 7, 1, 36.0),
370 (2017, 1, 1, 37.0),
371 ];
372 let mut leap = TABLE[0].3;
373 for &(y, m, d, l) in TABLE {
374 let threshold = gregorian_to_jdn(y, m, d) as f64 - 0.5 - MJD0;
375 if mjd >= threshold {
376 leap = l;
377 } else {
378 break;
379 }
380 }
381 leap
382}
383
384fn is_leap_year(year: i64) -> bool {
388 year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
389}
390
391fn days_in_month(year: i64, month: u32) -> u32 {
393 match month {
394 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
395 4 | 6 | 9 | 11 => 30,
396 2 if is_leap_year(year) => 29,
397 2 => 28,
398 _ => 0,
399 }
400}
401
402fn gregorian_to_jdn(year: i64, month: i64, day: i64) -> i64 {
403 let a = (14 - month) / 12;
404 let y = year + 4800 - a;
405 let m = month + 12 * a - 3;
406 day + (153 * m + 2) / 5 + 365 * y + y / 4 - y / 100 + y / 400 - 32045
407}
408
409#[derive(Debug, Clone, Copy, PartialEq)]
411struct CalendarDate {
412 year: i64,
413 month: u32,
414 day: u32,
415}
416
417fn jdn_to_gregorian(jdn: i64) -> CalendarDate {
419 let a = jdn + 32044;
420 let b = (4 * a + 3) / 146097;
421 let c = a - (146097 * b) / 4;
422 let d = (4 * c + 3) / 1461;
423 let e = c - (1461 * d) / 4;
424 let m = (5 * e + 2) / 153;
425 let day = e - (153 * m + 2) / 5 + 1;
426 let month = m + 3 - 12 * (m / 10);
427 let year = 100 * b + d - 4800 + m / 10;
428 CalendarDate {
429 year,
430 month: month as u32,
431 day: day as u32,
432 }
433}
434
435#[derive(Debug, Clone, Copy, PartialEq)]
438pub struct EpochTime {
439 pub mjd: f64,
440 pub scale: TimeScale,
441}
442
443#[derive(Debug, Clone, Copy, PartialEq)]
447pub struct TimeBounds {
448 pub beg_mjd: Option<f64>,
450 pub end_mjd: Option<f64>,
452 pub avg_mjd: Option<f64>,
454 pub xposure: Option<f64>,
456 pub telapse: Option<f64>,
458 pub timedel: Option<f64>,
460 pub timepixr: f64,
462 pub timsyer: Option<f64>,
464 pub timrder: Option<f64>,
466}
467
468#[derive(Debug, Clone, Copy, PartialEq)]
470pub struct GtiInterval {
471 pub start_mjd: f64,
472 pub stop_mjd: f64,
473}
474
475#[derive(Debug, Clone, Copy, PartialEq)]
479pub struct PhaseAxis {
480 pub zero_phase: f64,
481 pub period: f64,
482}
483
484impl PhaseAxis {
485 pub fn fold(&self, time: f64) -> f64 {
488 if self.period == 0.0 {
489 return 0.0;
490 }
491 ((time - self.zero_phase) / self.period).rem_euclid(1.0)
492 }
493}
494
495#[derive(Debug, Clone)]
498pub struct FitsTime {
499 pub scale: TimeScale,
501 pub mjdref: f64,
504 pub timeunit: String,
506 pub timeoffs: f64,
509 pub trefpos: Option<String>,
511}
512
513impl FitsTime {
514 pub(crate) fn from_header(header: &Header) -> FitsTime {
517 let scale = header
518 .get_text("TIMESYS")
519 .map(TimeScale::parse)
520 .unwrap_or(TimeScale::Utc);
521 let timeunit = header.get_text("TIMEUNIT").unwrap_or("s").to_string();
522 let trefpos = header.get_text("TREFPOS").map(str::to_string);
523 FitsTime {
524 scale,
525 mjdref: reference_mjd(header),
526 timeunit,
527 timeoffs: header.get_real("TIMEOFFS").unwrap_or(0.0),
528 trefpos,
529 }
530 }
531
532 pub fn unit_seconds(&self) -> f64 {
534 match self.timeunit.trim() {
537 "s" => 1.0,
538 "min" => 60.0,
539 "h" => 3600.0,
540 "d" | "day" => SEC_PER_DAY,
541 "a" | "yr" | "y" => 365.25 * SEC_PER_DAY, "cy" => 36525.0 * SEC_PER_DAY, "ta" => 365.24219 * SEC_PER_DAY, "Ba" => 365.2421988 * SEC_PER_DAY, _ => 1.0,
546 }
547 }
548
549 pub fn relative_to_mjd(&self, value: f64) -> f64 {
553 self.mjdref + (value + self.timeoffs) * self.unit_seconds() / SEC_PER_DAY
554 }
555
556 pub(crate) fn obs_mjd(header: &Header) -> Option<f64> {
560 if let Some(mjd) = header.get_real("MJD-OBS") {
561 return Some(mjd);
562 }
563 if let Some(d) = header
564 .get_text("DATE-OBS")
565 .and_then(|s| Datetime::parse(s).ok())
566 {
567 return Some(d.to_mjd());
568 }
569 Self::epoch(header).map(|e| e.mjd)
571 }
572
573 pub(crate) fn epoch(header: &Header) -> Option<EpochTime> {
577 if let Some(j) = header.get_real("JEPOCH") {
578 return Some(EpochTime {
579 mjd: Epoch::Julian(j).to_mjd(),
580 scale: TimeScale::Tdb,
581 });
582 }
583 let b = header.get_real("BEPOCH")?;
584 Some(EpochTime {
585 mjd: Epoch::Besselian(b).to_mjd(),
586 scale: TimeScale::Tt, })
588 }
589
590 pub(crate) fn bounds(header: &Header) -> TimeBounds {
595 let mjd_or_date = |mjd: &str, date: &str| {
596 header.get_real(mjd).or_else(|| {
597 header
598 .get_text(date)
599 .and_then(|s| Datetime::parse(s).ok())
600 .map(|d| d.to_mjd())
601 })
602 };
603 TimeBounds {
604 beg_mjd: mjd_or_date("MJD-BEG", "DATE-BEG"),
605 end_mjd: mjd_or_date("MJD-END", "DATE-END"),
606 avg_mjd: mjd_or_date("MJD-AVG", "DATE-AVG"),
607 xposure: header.get_real("XPOSURE"),
608 telapse: header.get_real("TELAPSE"),
609 timedel: header.get_real("TIMEDEL"),
610 timepixr: header.get_real("TIMEPIXR").unwrap_or(0.5), timsyer: header.get_real("TIMSYER"),
612 timrder: header.get_real("TIMRDER"),
613 }
614 }
615
616 pub fn gti_intervals(&self, starts: &[f64], stops: &[f64]) -> Vec<GtiInterval> {
620 starts
621 .iter()
622 .zip(stops)
623 .map(|(&s, &e)| GtiInterval {
624 start_mjd: self.relative_to_mjd(s),
625 stop_mjd: self.relative_to_mjd(e),
626 })
627 .collect()
628 }
629
630 pub fn time_axis_mjd(&self, header: &Header, axis: usize, pixel: f64) -> Option<f64> {
635 let ctype = header.get_text(key!("CTYPE{axis}").as_str())?;
636 if !is_time_ctype(ctype) {
637 return None;
638 }
639 let crpix = header.get_real(key!("CRPIX{axis}").as_str()).unwrap_or(0.0);
640 let crval = header.get_real(key!("CRVAL{axis}").as_str()).unwrap_or(0.0);
641 let cdelt = header.get_real(key!("CDELT{axis}").as_str()).unwrap_or(1.0);
642 Some(self.relative_to_mjd(crval + cdelt * (pixel - crpix)))
643 }
644
645 pub(crate) fn phase_axis(header: &Header, axis: usize) -> Option<PhaseAxis> {
649 let ctype = header.get_text(key!("CTYPE{axis}").as_str())?;
650 if TimeAxisKind::from_ctype(ctype) != Some(TimeAxisKind::Phase) {
651 return None;
652 }
653 Some(PhaseAxis {
654 zero_phase: header.get_real(key!("CZPHS{axis}").as_str()).unwrap_or(0.0),
655 period: header.get_real(key!("CPERI{axis}").as_str()).unwrap_or(0.0),
656 })
657 }
658}
659
660#[derive(Debug, Clone, Copy, PartialEq, Eq)]
662pub enum TimeAxisKind {
663 Time,
665 Phase,
667 Timelag,
669 Frequency,
671}
672
673impl TimeAxisKind {
674 pub fn from_ctype(ctype: &str) -> Option<TimeAxisKind> {
676 let head = ctype.split('-').next().unwrap_or("").trim();
677 match head {
678 "TIME" => Some(TimeAxisKind::Time),
679 "PHASE" => Some(TimeAxisKind::Phase),
680 "TIMELAG" => Some(TimeAxisKind::Timelag),
681 "FREQUENCY" => Some(TimeAxisKind::Frequency),
682 _ if !matches!(TimeScale::parse(head), TimeScale::Local) => Some(TimeAxisKind::Time),
683 _ => None,
684 }
685 }
686}
687
688fn is_time_ctype(ctype: &str) -> bool {
691 TimeAxisKind::from_ctype(ctype) == Some(TimeAxisKind::Time)
692}
693
694fn reference_mjd(header: &Header) -> f64 {
697 if let Some(mjd) = resolve_split_ref(header, "MJDREF", "MJDREFI", "MJDREFF") {
698 return mjd;
699 }
700 if let Some(jd) = resolve_split_ref(header, "JDREF", "JDREFI", "JDREFF") {
701 return jd - MJD0;
702 }
703 header
704 .get_text("DATEREF")
705 .and_then(|s| Datetime::parse(s).ok())
706 .map(|d| d.to_mjd())
707 .unwrap_or(0.0)
708}
709
710fn resolve_split_ref(header: &Header, single: &str, int: &str, frac: &str) -> Option<f64> {
715 let i = header.get_real(int);
716 let f = header.get_real(frac);
717 match (i, f) {
718 (Some(i), Some(f)) => Some(i + f),
719 _ => header.get_real(single).or_else(|| match (i, f) {
720 (None, None) => None,
721 _ => Some(i.unwrap_or(0.0) + f.unwrap_or(0.0)),
722 }),
723 }
724}
725
726#[cfg(test)]
727mod tests;