use crate::error::FitsError;
use crate::error::Result;
use crate::header::Header;
use crate::keyword::key;
const MJD0: f64 = 2_400_000.5;
const T1977_JD: f64 = 2_443_144.5;
const TT_TAI: f64 = 32.184;
const TAI_GPS: f64 = 19.0;
const L_G: f64 = 6.969_290_134e-10;
const L_B: f64 = 1.550_519_768e-8;
const TDB_0: f64 = -6.55e-5;
const SEC_PER_DAY: f64 = 86_400.0;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Datetime {
pub year: i64,
pub month: u32,
pub day: u32,
pub hour: u32,
pub minute: u32,
pub second: f64,
}
impl Datetime {
pub fn parse(s: &str) -> Result<Datetime> {
let invalid = || FitsError::InvalidValue {
card: format!("DATE '{s}'"),
};
let s = s.trim();
if s.contains(['Z', 'z']) {
return Err(invalid());
}
let (date, time) = match s.split_once('T') {
Some((d, t)) => (d, Some(t)),
None => (s, None),
};
let (sign, rest) = match date.strip_prefix('-') {
Some(r) => (-1, r),
None => (1, date.strip_prefix('+').unwrap_or(date)),
};
let mut dp = rest.split('-');
let y_str = dp.next().ok_or_else(invalid)?;
let m_str = dp.next().ok_or_else(invalid)?;
let d_str = dp.next().ok_or_else(invalid)?;
if dp.next().is_some() || y_str.len() < 4 || !all_digits(y_str) {
return Err(invalid());
}
let year = sign * y_str.parse::<i64>().map_err(|_| invalid())?;
if year.unsigned_abs() > 1_000_000 {
return Err(invalid());
}
let month = parse_fixed(m_str, 2).ok_or_else(invalid)?;
let day = parse_fixed(d_str, 2).ok_or_else(invalid)?;
if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
return Err(invalid());
}
let (mut hour, mut minute, mut second) = (0u32, 0u32, 0.0f64);
if let Some(t) = time {
let mut tp = t.split(':');
hour = parse_fixed(tp.next().ok_or_else(invalid)?, 2).ok_or_else(invalid)?;
minute = parse_fixed(tp.next().ok_or_else(invalid)?, 2).ok_or_else(invalid)?;
if let Some(sec) = tp.next() {
second = parse_seconds(sec).ok_or_else(invalid)?;
}
if tp.next().is_some() || hour >= 24 || minute >= 60 || !(0.0..61.0).contains(&second) {
return Err(invalid());
}
}
Ok(Datetime {
year,
month,
day,
hour,
minute,
second,
})
}
pub fn to_jd(&self) -> f64 {
let day_fraction =
(self.hour as f64 * 3600.0 + self.minute as f64 * 60.0 + self.second) / SEC_PER_DAY;
gregorian_to_jdn(self.year, self.month as i64, self.day as i64) as f64 - 0.5 + day_fraction
}
pub fn to_mjd(&self) -> f64 {
self.to_jd() - MJD0
}
pub fn from_jd(jd: f64) -> Datetime {
let z = (jd + 0.5).floor();
let frac = (jd + 0.5) - z;
let date = jdn_to_gregorian(z as i64);
let mut secs = frac * SEC_PER_DAY;
let hour = (secs / 3600.0).floor();
secs -= hour * 3600.0;
let minute = (secs / 60.0).floor();
secs -= minute * 60.0;
Datetime {
year: date.year,
month: date.month,
day: date.day,
hour: hour as u32,
minute: minute as u32,
second: secs,
}
}
}
fn all_digits(s: &str) -> bool {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}
fn parse_fixed(s: &str, width: usize) -> Option<u32> {
(s.len() == width && all_digits(s))
.then(|| s.parse().ok())
.flatten()
}
fn parse_seconds(s: &str) -> Option<f64> {
let (int, frac) = s.split_once('.').map_or((s, None), |(i, f)| (i, Some(f)));
if int.len() != 2 || !all_digits(int) || frac.is_some_and(|f| !all_digits(f)) {
return None;
}
s.parse().ok()
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Epoch {
Julian(f64),
Besselian(f64),
}
impl Epoch {
pub fn parse(s: &str) -> Result<Epoch> {
let s = s.trim();
let invalid = || FitsError::InvalidValue {
card: format!("epoch '{s}'"),
};
let (tag, rest) = s.split_at(
s.char_indices()
.next()
.map(|(_, c)| c.len_utf8())
.unwrap_or(0),
);
let year: f64 = rest.parse().map_err(|_| invalid())?;
match tag {
"J" | "j" => Ok(Epoch::Julian(year)),
"B" | "b" => Ok(Epoch::Besselian(year)),
_ => Err(invalid()),
}
}
pub fn to_jd(self) -> f64 {
match self {
Epoch::Julian(y) => 2_451_545.0 + (y - 2000.0) * 365.25,
Epoch::Besselian(y) => 2_415_020.313_52 + (y - 1900.0) * 365.242_198_781,
}
}
pub fn to_mjd(self) -> f64 {
self.to_jd() - MJD0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TimeScale {
Utc,
Ut1,
Tai,
Tt,
Tcg,
Tdb,
Tcb,
Gps,
Local,
}
impl TimeScale {
pub fn parse(s: &str) -> TimeScale {
let base = s.trim().split('(').next().unwrap_or("").trim();
match base.to_ascii_uppercase().as_str() {
"UTC" | "GMT" => TimeScale::Utc, "UT1" | "UT" => TimeScale::Ut1,
"TAI" | "IAT" => TimeScale::Tai,
"TT" | "TDT" | "ET" => TimeScale::Tt,
"TCG" => TimeScale::Tcg,
"TDB" => TimeScale::Tdb,
"TCB" => TimeScale::Tcb,
"GPS" => TimeScale::Gps,
_ => TimeScale::Local,
}
}
pub fn convert(self, jd: f64, target: TimeScale) -> f64 {
self.convert_dut1(jd, target, 0.0)
}
pub fn convert_dut1(self, jd: f64, target: TimeScale, dut1: f64) -> f64 {
if self == target || self == TimeScale::Local || target == TimeScale::Local {
return jd;
}
from_tt(self.to_tt(jd, dut1), target, dut1)
}
fn to_tt(self, jd: f64, dut1: f64) -> f64 {
match self {
TimeScale::Tt => jd,
TimeScale::Tai => jd + TT_TAI / SEC_PER_DAY,
TimeScale::Gps => jd + (TT_TAI + TAI_GPS) / SEC_PER_DAY,
TimeScale::Utc => jd + (TT_TAI + leap_seconds(jd - MJD0)) / SEC_PER_DAY,
TimeScale::Ut1 => {
let utc = jd - dut1 / SEC_PER_DAY;
utc + (TT_TAI + leap_seconds(utc - MJD0)) / SEC_PER_DAY
}
TimeScale::Tcg => jd - L_G * (jd - T1977_JD),
TimeScale::Tdb => jd - tdb_minus_tt(jd) / SEC_PER_DAY,
TimeScale::Tcb => {
let tdb = jd - L_B * (jd - T1977_JD) + TDB_0 / SEC_PER_DAY;
tdb - tdb_minus_tt(tdb) / SEC_PER_DAY
}
TimeScale::Local => unreachable!("convert_dut1 short-circuits Local"),
}
}
}
fn from_tt(tt: f64, target: TimeScale, dut1: f64) -> f64 {
match target {
TimeScale::Tt => tt,
TimeScale::Tai => tt - TT_TAI / SEC_PER_DAY,
TimeScale::Gps => tt - (TT_TAI + TAI_GPS) / SEC_PER_DAY,
TimeScale::Utc => {
let tai = tt - TT_TAI / SEC_PER_DAY;
tai - leap_seconds(tai - MJD0) / SEC_PER_DAY
}
TimeScale::Ut1 => {
let tai = tt - TT_TAI / SEC_PER_DAY;
let utc = tai - leap_seconds(tai - MJD0) / SEC_PER_DAY;
utc + dut1 / SEC_PER_DAY
}
TimeScale::Tcg => tt + L_G * (tt - T1977_JD),
TimeScale::Tdb => tt + tdb_minus_tt(tt) / SEC_PER_DAY,
TimeScale::Tcb => {
let tdb = tt + tdb_minus_tt(tt) / SEC_PER_DAY;
tdb + L_B * (tdb - T1977_JD) - TDB_0 / SEC_PER_DAY
}
TimeScale::Local => unreachable!("convert_dut1 short-circuits Local"),
}
}
fn tdb_minus_tt(jd_tt: f64) -> f64 {
let g = (357.53 + 0.985_600_3 * (jd_tt - 2_451_545.0)).to_radians();
0.001_658 * g.sin() + 0.000_014 * (2.0 * g).sin()
}
fn leap_seconds(mjd: f64) -> f64 {
const TABLE: &[(i64, i64, i64, f64)] = &[
(1972, 1, 1, 10.0),
(1972, 7, 1, 11.0),
(1973, 1, 1, 12.0),
(1974, 1, 1, 13.0),
(1975, 1, 1, 14.0),
(1976, 1, 1, 15.0),
(1977, 1, 1, 16.0),
(1978, 1, 1, 17.0),
(1979, 1, 1, 18.0),
(1980, 1, 1, 19.0),
(1981, 7, 1, 20.0),
(1982, 7, 1, 21.0),
(1983, 7, 1, 22.0),
(1985, 7, 1, 23.0),
(1988, 1, 1, 24.0),
(1990, 1, 1, 25.0),
(1991, 1, 1, 26.0),
(1992, 7, 1, 27.0),
(1993, 7, 1, 28.0),
(1994, 7, 1, 29.0),
(1996, 1, 1, 30.0),
(1997, 7, 1, 31.0),
(1999, 1, 1, 32.0),
(2006, 1, 1, 33.0),
(2009, 1, 1, 34.0),
(2012, 7, 1, 35.0),
(2015, 7, 1, 36.0),
(2017, 1, 1, 37.0),
];
let mut leap = TABLE[0].3;
for &(y, m, d, l) in TABLE {
let threshold = gregorian_to_jdn(y, m, d) as f64 - 0.5 - MJD0;
if mjd >= threshold {
leap = l;
} else {
break;
}
}
leap
}
fn is_leap_year(year: i64) -> bool {
year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
}
fn days_in_month(year: i64, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap_year(year) => 29,
2 => 28,
_ => 0,
}
}
fn gregorian_to_jdn(year: i64, month: i64, day: i64) -> i64 {
let a = (14 - month) / 12;
let y = year + 4800 - a;
let m = month + 12 * a - 3;
day + (153 * m + 2) / 5 + 365 * y + y / 4 - y / 100 + y / 400 - 32045
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct CalendarDate {
year: i64,
month: u32,
day: u32,
}
fn jdn_to_gregorian(jdn: i64) -> CalendarDate {
let a = jdn + 32044;
let b = (4 * a + 3) / 146097;
let c = a - (146097 * b) / 4;
let d = (4 * c + 3) / 1461;
let e = c - (1461 * d) / 4;
let m = (5 * e + 2) / 153;
let day = e - (153 * m + 2) / 5 + 1;
let month = m + 3 - 12 * (m / 10);
let year = 100 * b + d - 4800 + m / 10;
CalendarDate {
year,
month: month as u32,
day: day as u32,
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EpochTime {
pub mjd: f64,
pub scale: TimeScale,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TimeBounds {
pub beg_mjd: Option<f64>,
pub end_mjd: Option<f64>,
pub avg_mjd: Option<f64>,
pub xposure: Option<f64>,
pub telapse: Option<f64>,
pub timedel: Option<f64>,
pub timepixr: f64,
pub timsyer: Option<f64>,
pub timrder: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GtiInterval {
pub start_mjd: f64,
pub stop_mjd: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PhaseAxis {
pub zero_phase: f64,
pub period: f64,
}
impl PhaseAxis {
pub fn fold(&self, time: f64) -> f64 {
if self.period == 0.0 {
return 0.0;
}
((time - self.zero_phase) / self.period).rem_euclid(1.0)
}
}
#[derive(Debug, Clone)]
pub struct FitsTime {
pub scale: TimeScale,
pub mjdref: f64,
pub timeunit: String,
pub timeoffs: f64,
pub trefpos: Option<String>,
}
impl FitsTime {
pub(crate) fn from_header(header: &Header) -> FitsTime {
let scale = header
.get_text("TIMESYS")
.map(TimeScale::parse)
.unwrap_or(TimeScale::Utc);
let timeunit = header.get_text("TIMEUNIT").unwrap_or("s").to_string();
let trefpos = header.get_text("TREFPOS").map(str::to_string);
FitsTime {
scale,
mjdref: reference_mjd(header),
timeunit,
timeoffs: header.get_real("TIMEOFFS").unwrap_or(0.0),
trefpos,
}
}
pub fn unit_seconds(&self) -> f64 {
match self.timeunit.trim() {
"s" => 1.0,
"min" => 60.0,
"h" => 3600.0,
"d" | "day" => SEC_PER_DAY,
"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,
}
}
pub fn relative_to_mjd(&self, value: f64) -> f64 {
self.mjdref + (value + self.timeoffs) * self.unit_seconds() / SEC_PER_DAY
}
pub(crate) fn obs_mjd(header: &Header) -> Option<f64> {
if let Some(mjd) = header.get_real("MJD-OBS") {
return Some(mjd);
}
if let Some(d) = header
.get_text("DATE-OBS")
.and_then(|s| Datetime::parse(s).ok())
{
return Some(d.to_mjd());
}
Self::epoch(header).map(|e| e.mjd)
}
pub(crate) fn epoch(header: &Header) -> Option<EpochTime> {
if let Some(j) = header.get_real("JEPOCH") {
return Some(EpochTime {
mjd: Epoch::Julian(j).to_mjd(),
scale: TimeScale::Tdb,
});
}
let b = header.get_real("BEPOCH")?;
Some(EpochTime {
mjd: Epoch::Besselian(b).to_mjd(),
scale: TimeScale::Tt, })
}
pub(crate) fn bounds(header: &Header) -> TimeBounds {
let mjd_or_date = |mjd: &str, date: &str| {
header.get_real(mjd).or_else(|| {
header
.get_text(date)
.and_then(|s| Datetime::parse(s).ok())
.map(|d| d.to_mjd())
})
};
TimeBounds {
beg_mjd: mjd_or_date("MJD-BEG", "DATE-BEG"),
end_mjd: mjd_or_date("MJD-END", "DATE-END"),
avg_mjd: mjd_or_date("MJD-AVG", "DATE-AVG"),
xposure: header.get_real("XPOSURE"),
telapse: header.get_real("TELAPSE"),
timedel: header.get_real("TIMEDEL"),
timepixr: header.get_real("TIMEPIXR").unwrap_or(0.5), timsyer: header.get_real("TIMSYER"),
timrder: header.get_real("TIMRDER"),
}
}
pub fn gti_intervals(&self, starts: &[f64], stops: &[f64]) -> Vec<GtiInterval> {
starts
.iter()
.zip(stops)
.map(|(&s, &e)| GtiInterval {
start_mjd: self.relative_to_mjd(s),
stop_mjd: self.relative_to_mjd(e),
})
.collect()
}
pub fn time_axis_mjd(&self, header: &Header, axis: usize, pixel: f64) -> Option<f64> {
let ctype = header.get_text(key!("CTYPE{axis}").as_str())?;
if !is_time_ctype(ctype) {
return None;
}
let crpix = header.get_real(key!("CRPIX{axis}").as_str()).unwrap_or(0.0);
let crval = header.get_real(key!("CRVAL{axis}").as_str()).unwrap_or(0.0);
let cdelt = header.get_real(key!("CDELT{axis}").as_str()).unwrap_or(1.0);
Some(self.relative_to_mjd(crval + cdelt * (pixel - crpix)))
}
pub(crate) fn phase_axis(header: &Header, axis: usize) -> Option<PhaseAxis> {
let ctype = header.get_text(key!("CTYPE{axis}").as_str())?;
if TimeAxisKind::from_ctype(ctype) != Some(TimeAxisKind::Phase) {
return None;
}
Some(PhaseAxis {
zero_phase: header.get_real(key!("CZPHS{axis}").as_str()).unwrap_or(0.0),
period: header.get_real(key!("CPERI{axis}").as_str()).unwrap_or(0.0),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TimeAxisKind {
Time,
Phase,
Timelag,
Frequency,
}
impl TimeAxisKind {
pub fn from_ctype(ctype: &str) -> Option<TimeAxisKind> {
let head = ctype.split('-').next().unwrap_or("").trim();
match head {
"TIME" => Some(TimeAxisKind::Time),
"PHASE" => Some(TimeAxisKind::Phase),
"TIMELAG" => Some(TimeAxisKind::Timelag),
"FREQUENCY" => Some(TimeAxisKind::Frequency),
_ if !matches!(TimeScale::parse(head), TimeScale::Local) => Some(TimeAxisKind::Time),
_ => None,
}
}
}
fn is_time_ctype(ctype: &str) -> bool {
TimeAxisKind::from_ctype(ctype) == Some(TimeAxisKind::Time)
}
fn reference_mjd(header: &Header) -> f64 {
if let Some(mjd) = resolve_split_ref(header, "MJDREF", "MJDREFI", "MJDREFF") {
return mjd;
}
if let Some(jd) = resolve_split_ref(header, "JDREF", "JDREFI", "JDREFF") {
return jd - MJD0;
}
header
.get_text("DATEREF")
.and_then(|s| Datetime::parse(s).ok())
.map(|d| d.to_mjd())
.unwrap_or(0.0)
}
fn resolve_split_ref(header: &Header, single: &str, int: &str, frac: &str) -> Option<f64> {
let i = header.get_real(int);
let f = header.get_real(frac);
match (i, f) {
(Some(i), Some(f)) => Some(i + f),
_ => header.get_real(single).or_else(|| match (i, f) {
(None, None) => None,
_ => Some(i.unwrap_or(0.0) + f.unwrap_or(0.0)),
}),
}
}
#[cfg(test)]
mod tests;