use asdf_yaml::{Document, NodeData, NodeId, Resolved};
use crate::error::{Result, err};
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
#[repr(i32)]
pub enum TimeFormat {
#[default]
Iso = 0,
Yday,
Byear,
Jyear,
DecimalYear,
Jd,
Mjd,
Gps,
Unix,
Utime,
TaiSeconds,
Cxcsec,
Galexsec,
UnixTai,
Reserved1,
ByearStr,
Datetime,
Fits,
Isot,
JyearStr,
PlotDate,
Ymdhms,
Datetime64,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
#[repr(i32)]
pub enum TimeScale {
#[default]
Utc = 0,
Tai,
Tcb,
Tcg,
Tdb,
Tt,
Ut1,
}
const FORMAT_NAMES: [Option<&str>; 23] = [
Some("iso"),
Some("yday"),
Some("byear"),
Some("jyear"),
Some("decimalyear"),
Some("jd"),
Some("mjd"),
Some("gps"),
Some("unix"),
Some("utime"),
Some("tai_seconds"),
Some("cxcsec"),
Some("galexsec"),
Some("unix_tai"),
None,
Some("byear_str"),
Some("datetime"),
Some("fits"),
Some("isot"),
Some("jyear_str"),
Some("plot_date"),
Some("ymdhms"),
Some("datetime64"),
];
const SCALE_NAMES: [&str; 7] = ["utc", "tai", "tcb", "tcg", "tdb", "tt", "ut1"];
impl TimeFormat {
pub fn name(self) -> Option<&'static str> {
FORMAT_NAMES.get(self as usize).copied().flatten()
}
pub fn from_name(name: &str) -> Option<Self> {
FORMAT_NAMES
.iter()
.position(|candidate| *candidate == Some(name))
.and_then(Self::from_index)
}
fn from_index(index: usize) -> Option<Self> {
(index < FORMAT_NAMES.len()).then(|| {
unsafe_transmute_format(index as i32)
})
}
pub fn standard(self) -> Self {
match self {
TimeFormat::Isot
| TimeFormat::Fits
| TimeFormat::Datetime
| TimeFormat::PlotDate
| TimeFormat::Ymdhms
| TimeFormat::Datetime64 => TimeFormat::Iso,
TimeFormat::JyearStr => TimeFormat::Jyear,
TimeFormat::ByearStr => TimeFormat::Byear,
other => other,
}
}
pub fn is_other(self) -> bool {
self.standard() != self
}
}
fn unsafe_transmute_format(value: i32) -> TimeFormat {
match value {
0 => TimeFormat::Iso,
1 => TimeFormat::Yday,
2 => TimeFormat::Byear,
3 => TimeFormat::Jyear,
4 => TimeFormat::DecimalYear,
5 => TimeFormat::Jd,
6 => TimeFormat::Mjd,
7 => TimeFormat::Gps,
8 => TimeFormat::Unix,
9 => TimeFormat::Utime,
10 => TimeFormat::TaiSeconds,
11 => TimeFormat::Cxcsec,
12 => TimeFormat::Galexsec,
13 => TimeFormat::UnixTai,
14 => TimeFormat::Reserved1,
15 => TimeFormat::ByearStr,
16 => TimeFormat::Datetime,
17 => TimeFormat::Fits,
18 => TimeFormat::Isot,
19 => TimeFormat::JyearStr,
20 => TimeFormat::PlotDate,
21 => TimeFormat::Ymdhms,
22 => TimeFormat::Datetime64,
_ => TimeFormat::Iso,
}
}
impl TimeScale {
pub fn name(self) -> &'static str {
SCALE_NAMES[self as usize]
}
pub fn from_name(name: &str) -> Option<Self> {
Some(match name {
"utc" => TimeScale::Utc,
"tai" => TimeScale::Tai,
"tcb" => TimeScale::Tcb,
"tcg" => TimeScale::Tcg,
"tdb" => TimeScale::Tdb,
"tt" => TimeScale::Tt,
"ut1" => TimeScale::Ut1,
_ => return None,
})
}
pub fn from_i32(value: i32) -> Self {
match value {
1 => TimeScale::Tai,
2 => TimeScale::Tcb,
3 => TimeScale::Tcg,
4 => TimeScale::Tdb,
5 => TimeScale::Tt,
6 => TimeScale::Ut1,
_ => TimeScale::Utc,
}
}
}
#[derive(Clone, Copy, PartialEq, Debug, Default)]
pub struct Location {
pub longitude: f64,
pub latitude: f64,
pub height: f64,
}
#[derive(Clone, Copy, PartialEq, Debug, Default)]
pub struct Civil {
pub year: i32,
pub month: u32,
pub day: u32,
pub hour: u32,
pub minute: u32,
pub second: u32,
pub nanosecond: u32,
pub yday: u32,
pub wday: u32,
pub unix_seconds: i64,
}
const JD_UNIX_EPOCH: f64 = 2440587.5;
const JD_MJD: f64 = 2400000.5;
const JD_J2000: f64 = 2451545.0;
const JD_B1900: f64 = 2415020.31352;
const JD_PLOT_DATE_EPOCH: f64 = 1721424.5;
const JD_GPS_EPOCH: f64 = 2444244.5 + 19.0 / 86400.0;
const JD_GALEXSEC_EPOCH: f64 = 2444244.5;
const JD_CXCSEC_EPOCH: f64 = 2450814.5;
const JD_TAI_SECONDS_EPOCH: f64 = 2436204.5;
const JD_UTIME_EPOCH: f64 = 2443874.5;
const JULIAN_YEAR_DAYS: f64 = 365.25;
const BESSELIAN_YEAR_DAYS: f64 = 365.242198781;
const SECONDS_PER_DAY: f64 = 86400.0;
fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
let year = i64::from(year) - i64::from(month <= 2);
let era = if year >= 0 { year } else { year - 399 } / 400;
let year_of_era = year - era * 400;
let month = i64::from(month);
let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + i64::from(day) - 1;
let doe = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + doy;
era * 146_097 + doe - 719_468
}
fn civil_from_days(days: i64) -> (i32, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let year = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
let month = (mp + if mp < 10 { 3 } else { -9 }) as u32;
((year + i64::from(month <= 2)) as i32, month, day)
}
fn is_leap(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
fn complete(mut civil: Civil) -> Civil {
let days = days_from_civil(civil.year, civil.month.max(1), civil.day.max(1));
civil.unix_seconds = days * 86_400
+ i64::from(civil.hour) * 3600
+ i64::from(civil.minute) * 60
+ i64::from(civil.second);
let month_lengths =
[31, if is_leap(civil.year) { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut yday = civil.day;
for length in month_lengths.iter().take(civil.month.saturating_sub(1) as usize) {
yday += length;
}
civil.yday = yday;
civil.wday = (((days % 7) + 7 + 4) % 7) as u32;
civil
}
pub fn julian_to_civil(jd: f64) -> Civil {
let shifted = jd + 0.5;
let z = shifted.floor();
let fraction = shifted - z;
let a = if z < 2299161.0 {
z
} else {
let alpha = ((z - 1867216.25) / 36524.25).floor();
z + 1.0 + alpha - (alpha / 4.0).floor()
};
let b = a + 1524.0;
let c = ((b - 122.1) / 365.25).floor();
let d = (365.25 * c).floor();
let e = ((b - d) / 30.6001).floor();
let day_with_fraction = b - d - (30.6001 * e).floor() + fraction;
let day = day_with_fraction.floor();
let month = if e < 14.0 { e - 1.0 } else { e - 13.0 };
let year = if month > 2.0 { c - 4716.0 } else { c - 4715.0 };
let seconds_in_day = (day_with_fraction - day) * SECONDS_PER_DAY;
let total_nanos = (seconds_in_day * 1e9).round().max(0.0) as i64;
let whole_seconds = total_nanos / 1_000_000_000;
let nanosecond = (total_nanos % 1_000_000_000) as u32;
complete(Civil {
year: year as i32,
month: month as u32,
day: day as u32,
hour: (whole_seconds / 3600) as u32,
minute: ((whole_seconds / 60) % 60) as u32,
second: (whole_seconds % 60) as u32,
nanosecond,
..Default::default()
})
}
pub fn civil_to_julian(civil: &Civil) -> f64 {
let (mut year, mut month) = (civil.year, civil.month as i32);
if month <= 2 {
year -= 1;
month += 12;
}
let gregorian = (civil.year, civil.month, civil.day) >= (1582, 10, 15);
let b = if gregorian {
let a = (year as f64 / 100.0).floor();
2.0 - a + (a / 4.0).floor()
} else {
0.0
};
let seconds = f64::from(civil.hour) * 3600.0
+ f64::from(civil.minute) * 60.0
+ f64::from(civil.second)
+ f64::from(civil.nanosecond) / 1e9;
(365.25 * (f64::from(year) + 4716.0)).floor()
+ (30.6001 * (f64::from(month) + 1.0)).floor()
+ f64::from(civil.day)
+ b
- 1524.5
+ seconds / SECONDS_PER_DAY
}
fn split_utc_offset(time: &str) -> (&str, i64) {
let time = time.trim();
if let Some(rest) = time.strip_suffix(['Z', 'z']) {
return (rest.trim_end(), 0);
}
let Some(index) = time.rfind(['+', '-']).filter(|i| *i > 0) else {
return (time, 0);
};
let sign = if time.as_bytes()[index] == b'-' { -1 } else { 1 };
let designator = &time[index + 1..];
let (hours, minutes) = match designator.split_once(':') {
Some((h, m)) => (h, m),
None if designator.len() == 4 => designator.split_at(2),
None => (designator, "0"),
};
let (Ok(hours), Ok(minutes)) = (hours.parse::<i64>(), minutes.parse::<i64>()) else {
return (time, 0);
};
(&time[..index], sign * (hours * 3600 + minutes * 60))
}
fn parse_datetime(text: &str) -> Option<Civil> {
let text = text.trim();
let (date, time) = match text.find(['T', ' ']) {
Some(index) => (&text[..index], Some(&text[index + 1..])),
None => (text, None),
};
let (negative, date) = match date.strip_prefix('-') {
Some(rest) => (true, rest),
None => (false, date.strip_prefix('+').unwrap_or(date)),
};
let mut parts = date.split('-');
let year: i32 = parts.next()?.parse().ok()?;
let month: u32 = parts.next().unwrap_or("1").parse().ok()?;
let day: u32 = parts.next().unwrap_or("1").parse().ok()?;
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return None;
}
let mut utc_offset = 0i64;
let (hour, minute, second, nanosecond) = match time {
None => (0, 0, 0, 0),
Some(time) => {
let (time, offset) = split_utc_offset(time);
utc_offset = offset;
let mut parts = time.split(':');
let hour: u32 = parts.next()?.parse().ok()?;
let minute: u32 = parts.next().unwrap_or("0").parse().ok()?;
let seconds_text = parts.next().unwrap_or("0");
let (whole, fraction) = match seconds_text.split_once('.') {
Some((whole, fraction)) => (whole, fraction),
None => (seconds_text, ""),
};
let second: u32 = whole.parse().ok()?;
let mut nanos = 0u32;
for (index, digit) in fraction.chars().take(9).enumerate() {
let value = digit.to_digit(10)?;
nanos += value * 10u32.pow(8 - index as u32);
}
if hour > 23 || minute > 59 || second > 60 {
return None;
}
(hour, minute, second, nanos)
}
};
let civil = complete(Civil {
year: if negative { -year } else { year },
month,
day,
hour,
minute,
second,
nanosecond,
..Default::default()
});
if utc_offset == 0 {
return Some(civil);
}
Some(civil_from_unix_seconds(civil.unix_seconds - utc_offset, civil.nanosecond))
}
fn civil_from_unix_seconds(unix_seconds: i64, nanosecond: u32) -> Civil {
let days = unix_seconds.div_euclid(86_400);
let seconds_of_day = unix_seconds.rem_euclid(86_400);
let (year, month, day) = civil_from_days(days);
complete(Civil {
year,
month,
day,
hour: (seconds_of_day / 3600) as u32,
minute: ((seconds_of_day % 3600) / 60) as u32,
second: (seconds_of_day % 60) as u32,
nanosecond,
..Default::default()
})
}
pub fn infer_format(value: &str) -> Option<TimeFormat> {
if matches_iso_shape(value, false) {
return Some(TimeFormat::Iso);
}
if let Some(rest) = value.strip_prefix('B')
&& matches_year_shape(rest)
{
return Some(TimeFormat::Byear);
}
if let Some(rest) = value.strip_prefix('J')
&& matches_year_shape(rest)
{
return Some(TimeFormat::Jyear);
}
if matches_yday_shape(value) {
return Some(TimeFormat::Yday);
}
if matches_iso_shape(value, true) {
return Some(TimeFormat::Fits);
}
None
}
fn matches_iso_shape(value: &str, long_year: bool) -> bool {
let bytes = value.as_bytes();
let digits = |at: usize, count: usize| -> bool {
bytes.len() >= at + count && bytes[at..at + count].iter().all(u8::is_ascii_digit)
};
let mut at = if long_year {
if !matches!(bytes.first(), Some(b'+' | b'-')) || !digits(1, 5) {
return false;
}
6
} else {
if !digits(0, 4) {
return false;
}
4
};
for _ in 0..2 {
if bytes.get(at) != Some(&b'-') || !digits(at + 1, 2) {
return false;
}
at += 3;
}
if !matches!(bytes.get(at), Some(b'T' | b' ')) {
return true;
}
at += 1;
if !digits(at, 2) {
return false;
}
at += 2;
for _ in 0..2 {
if bytes.get(at) != Some(&b':') || !digits(at + 1, 2) {
return false;
}
at += 3;
}
true
}
fn matches_year_shape(value: &str) -> bool {
let mut chars = value.chars();
if !chars.next().is_some_and(|c| c.is_ascii_digit()) {
return false;
}
true
}
fn matches_yday_shape(value: &str) -> bool {
let bytes = value.as_bytes();
let digits = |at: usize, count: usize| -> bool {
bytes.len() >= at + count && bytes[at..at + count].iter().all(u8::is_ascii_digit)
};
if !digits(0, 4) || bytes.get(4) != Some(&b':') {
return false;
}
if !digits(5, 3) || bytes.get(8) != Some(&b':') {
return false;
}
let mut at = 9;
for step in 0..3 {
if !digits(at, 2) {
return false;
}
at += 2;
if step < 2 {
if bytes.get(at) != Some(&b':') {
return false;
}
at += 1;
}
}
true
}
fn parse_yday(text: &str) -> Option<Civil> {
let mut parts = text.trim().split(':');
let year: i32 = parts.next()?.parse().ok()?;
let yday: u32 = parts.next()?.parse().ok()?;
let hour: u32 = parts.next().unwrap_or("0").parse().ok()?;
let minute: u32 = parts.next().unwrap_or("0").parse().ok()?;
let seconds_text = parts.next().unwrap_or("0");
let (whole, fraction) = match seconds_text.split_once('.') {
Some(split) => split,
None => (seconds_text, ""),
};
let second: u32 = whole.parse().ok()?;
let mut nanosecond = 0u32;
for (index, digit) in fraction.chars().take(9).enumerate() {
nanosecond += digit.to_digit(10)? * 10u32.pow(8 - index as u32);
}
if yday == 0 || yday > if is_leap(year) { 366 } else { 365 } {
return None;
}
let days = days_from_civil(year, 1, 1) + i64::from(yday) - 1;
let (year, month, day) = civil_from_days(days);
Some(complete(Civil {
year,
month,
day,
hour,
minute,
second,
nanosecond,
..Default::default()
}))
}
fn scalar_kind(doc: &Document, node: NodeId) -> Resolved {
match &doc.resolved(node).data {
NodeData::Scalar { value, style } => {
asdf_yaml::resolve(value, *style, asdf_yaml::Schema::Libasdf)
}
_ => Resolved::Null,
}
}
fn parse_epoch_year(text: &str) -> Option<f64> {
let text = text.trim();
let body = text.strip_prefix('B').or_else(|| text.strip_prefix('J')).unwrap_or(text);
body.parse().ok()
}
#[derive(Clone, PartialEq, Debug)]
pub struct Time {
pub value: String,
pub format: TimeFormat,
pub scale: TimeScale,
pub location: Location,
pub civil: Option<Civil>,
}
impl Time {
pub fn new(value: impl Into<String>, format: TimeFormat, scale: TimeScale) -> Self {
Self { value: value.into(), format, scale, location: Location::default(), civil: None }
}
pub fn parse(doc: &Document, id: NodeId) -> Result<Self> {
let node = doc.resolved(id);
let mapping = node.is_mapping();
let value_node = if mapping {
let Some(found) = doc.mapping_get(id, "value") else {
return Err(err!(InvalidArgument, "a time mapping needs a 'value'"));
};
doc.resolve(found)
} else {
doc.resolve(id)
};
let Some(value) = doc.resolved(value_node).as_str().map(str::to_string) else {
return Err(err!(InvalidArgument, "a time's value must be a scalar"));
};
let value_is_string = matches!(scalar_kind(doc, value_node), Resolved::String);
let field = |key: &str| -> Option<String> {
doc.mapping_get(id, key).and_then(|n| doc.resolved(n).as_str().map(str::to_string))
};
let (explicit, base, scale, location) = if mapping {
let scale = field("scale")
.and_then(|name| TimeScale::from_name(&name))
.unwrap_or(TimeScale::Utc);
let mut location = Location::default();
if let Some(loc) = doc.mapping_get(id, "location") {
let number = |key: &str| {
doc.mapping_get(loc, key)
.and_then(|n| doc.resolved(n).as_str())
.and_then(|text| text.parse::<f64>().ok())
.unwrap_or(0.0)
};
location.longitude = number("longitude");
location.latitude = number("latitude");
location.height = number("height");
}
(field("format"), field("base_format"), scale, location)
} else {
(None, None, TimeScale::Utc, Location::default())
};
let wire = match &explicit {
Some(name) => TimeFormat::from_name(name)
.ok_or_else(|| err!(InvalidArgument, "unknown time format {name:?}"))?,
None => {
if !value_is_string {
return Err(err!(
InvalidArgument,
"a numeric time value needs an explicit format; {value:?} is ambiguous"
));
}
infer_format(&value).ok_or_else(|| {
err!(InvalidArgument, "could not guess the format of time {value:?}")
})?
}
};
if matches!(wire, TimeFormat::JyearStr | TimeFormat::ByearStr) {
let prefix = if wire == TimeFormat::JyearStr { ['J', 'j'] } else { ['B', 'b'] };
if !value_is_string || !value.starts_with(prefix) {
return Err(err!(
InvalidArgument,
"time format {:?} needs a value starting with {:?}",
wire.name(),
prefix[0]
));
}
}
let effective = base.as_deref().and_then(TimeFormat::from_name).unwrap_or(wire);
let mut time = Time::new(value, wire, scale);
time.location = location;
let civil = time.compute_civil().ok();
Ok(Time { format: effective, civil, ..time })
}
pub fn compute_civil(&mut self) -> Result<Civil> {
let civil = self.derive_civil()?;
self.civil = Some(civil);
Ok(civil)
}
fn derive_civil(&self) -> Result<Civil> {
let text = self.value.trim();
let numeric = || -> Result<f64> {
text.parse::<f64>()
.map_err(|_| err!(InvalidArgument, "time value {text:?} is not numeric"))
};
let civil = match self.format {
TimeFormat::Iso
| TimeFormat::Isot
| TimeFormat::Fits
| TimeFormat::Datetime
| TimeFormat::Datetime64
| TimeFormat::Ymdhms => parse_datetime(text)
.ok_or_else(|| err!(InvalidArgument, "could not parse {text:?} as a date-time"))?,
TimeFormat::Yday => parse_yday(text)
.ok_or_else(|| err!(InvalidArgument, "could not parse {text:?} as a yday time"))?,
TimeFormat::Jd => julian_to_civil(numeric()?),
TimeFormat::Mjd => julian_to_civil(numeric()? + JD_MJD),
TimeFormat::Jyear | TimeFormat::JyearStr => {
let year = parse_epoch_year(text)
.ok_or_else(|| err!(InvalidArgument, "bad Julian epoch {text:?}"))?;
julian_to_civil(JD_J2000 + JULIAN_YEAR_DAYS * (year - 2000.0))
}
TimeFormat::Byear | TimeFormat::ByearStr => {
let year = parse_epoch_year(text)
.ok_or_else(|| err!(InvalidArgument, "bad Besselian epoch {text:?}"))?;
julian_to_civil(JD_B1900 + BESSELIAN_YEAR_DAYS * (year - 1900.0))
}
TimeFormat::DecimalYear => {
let year = numeric()?;
let whole = year.floor();
let days_in_year = if is_leap(whole as i32) { 366.0 } else { 365.0 };
let start = days_from_civil(whole as i32, 1, 1) as f64;
julian_to_civil(JD_UNIX_EPOCH + start + (year - whole) * days_in_year)
}
TimeFormat::PlotDate => julian_to_civil(numeric()? + JD_PLOT_DATE_EPOCH),
TimeFormat::Unix => julian_to_civil(JD_UNIX_EPOCH + numeric()? / SECONDS_PER_DAY),
TimeFormat::UnixTai => julian_to_civil(JD_UNIX_EPOCH + numeric()? / SECONDS_PER_DAY),
TimeFormat::Gps => julian_to_civil(JD_GPS_EPOCH + numeric()? / SECONDS_PER_DAY),
TimeFormat::Galexsec => {
julian_to_civil(JD_GALEXSEC_EPOCH + numeric()? / SECONDS_PER_DAY)
}
TimeFormat::Cxcsec => julian_to_civil(JD_CXCSEC_EPOCH + numeric()? / SECONDS_PER_DAY),
TimeFormat::TaiSeconds => {
julian_to_civil(JD_TAI_SECONDS_EPOCH + numeric()? / SECONDS_PER_DAY)
}
TimeFormat::Utime => julian_to_civil(JD_UTIME_EPOCH + numeric()? / SECONDS_PER_DAY),
TimeFormat::Reserved1 => {
return Err(err!(InvalidArgument, "the reserved time format is not usable"));
}
};
Ok(civil)
}
pub fn wire_formats(&self) -> (TimeFormat, Option<TimeFormat>) {
if self.format.is_other() {
(self.format.standard(), Some(self.format))
} else {
(self.format, None)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_discriminants_match_the_c_abi() {
assert_eq!(TimeFormat::Iso as i32, 0);
assert_eq!(TimeFormat::Yday as i32, 1);
assert_eq!(TimeFormat::UnixTai as i32, 13);
assert_eq!(TimeFormat::Reserved1 as i32, 14);
assert_eq!(TimeFormat::ByearStr as i32, 15);
assert_eq!(TimeFormat::Datetime64 as i32, 22);
assert_eq!(TimeScale::Utc as i32, 0);
assert_eq!(TimeScale::Ut1 as i32, 6);
}
#[test]
fn format_names_round_trip() {
for index in 0..23 {
let format = TimeFormat::from_index(index).unwrap();
match format.name() {
Some(name) => assert_eq!(TimeFormat::from_name(name), Some(format), "{name}"),
None => assert_eq!(format, TimeFormat::Reserved1),
}
}
assert_eq!(TimeFormat::from_name("nonsense"), None);
}
#[test]
fn scale_names_round_trip() {
for scale in [
TimeScale::Utc,
TimeScale::Tai,
TimeScale::Tcb,
TimeScale::Tcg,
TimeScale::Tdb,
TimeScale::Tt,
TimeScale::Ut1,
] {
assert_eq!(TimeScale::from_name(scale.name()), Some(scale));
assert_eq!(TimeScale::from_i32(scale as i32), scale);
}
}
#[test]
fn other_formats_split_into_base_format() {
for (other, standard) in [
(TimeFormat::Isot, TimeFormat::Iso),
(TimeFormat::Fits, TimeFormat::Iso),
(TimeFormat::PlotDate, TimeFormat::Iso),
(TimeFormat::Ymdhms, TimeFormat::Iso),
(TimeFormat::Datetime64, TimeFormat::Iso),
(TimeFormat::JyearStr, TimeFormat::Jyear),
(TimeFormat::ByearStr, TimeFormat::Byear),
] {
assert!(other.is_other(), "{other:?}");
assert_eq!(other.standard(), standard);
let time = Time::new("x", other, TimeScale::Utc);
assert_eq!(time.wire_formats(), (standard, Some(other)));
}
let time = Time::new("2026-01-01", TimeFormat::Iso, TimeScale::Utc);
assert_eq!(time.wire_formats(), (TimeFormat::Iso, None));
assert!(!TimeFormat::Iso.is_other());
}
#[test]
fn civil_day_arithmetic_round_trips() {
for (year, month, day) in
[(1970, 1, 1), (2000, 2, 29), (1999, 12, 31), (2026, 9, 4), (1582, 10, 15), (1, 1, 1)]
{
let days = days_from_civil(year, month, day);
assert_eq!(civil_from_days(days), (year, month, day), "{year}-{month}-{day}");
}
assert_eq!(days_from_civil(1970, 1, 1), 0);
}
#[test]
fn utc_offsets_are_applied() {
let mut t = Time::new("2025-07-23 11:56:15+00:00", TimeFormat::Iso, TimeScale::Utc);
assert_eq!(t.compute_civil().unwrap().unix_seconds, 1_753_271_775);
let mut east = Time::new("2025-07-23T11:56:15+01:00", TimeFormat::Iso, TimeScale::Utc);
assert_eq!(east.compute_civil().unwrap().unix_seconds, 1_753_271_775 - 3600);
let mut west = Time::new("2025-07-23T11:56:15-01:00", TimeFormat::Iso, TimeScale::Utc);
assert_eq!(west.compute_civil().unwrap().unix_seconds, 1_753_271_775 + 3600);
let shifted = east.compute_civil().unwrap();
assert_eq!((shifted.hour, shifted.minute, shifted.second), (10, 56, 15));
for text in ["2025-07-23T11:56:15Z", "2025-07-23T11:56:15"] {
let mut t = Time::new(text, TimeFormat::Iso, TimeScale::Utc);
assert_eq!(t.compute_civil().unwrap().unix_seconds, 1_753_271_775, "{text}");
}
}
#[test]
fn offset_designators_come_in_several_shapes() {
for (text, expected) in [
("2025-07-23T11:56:15+01:30", 1_753_271_775 - 5400),
("2025-07-23T11:56:15+0130", 1_753_271_775 - 5400),
("2025-07-23T11:56:15+01", 1_753_271_775 - 3600),
("2025-07-23T11:56:15-0130", 1_753_271_775 + 5400),
] {
let mut t = Time::new(text, TimeFormat::Iso, TimeScale::Utc);
assert_eq!(t.compute_civil().unwrap().unix_seconds, expected, "{text}");
}
}
#[test]
fn a_negative_year_is_not_an_offset() {
let mut t = Time::new("-0044-03-15T12:00:00", TimeFormat::Iso, TimeScale::Utc);
let civil = t.compute_civil().unwrap();
assert_eq!(civil.year, -44);
assert_eq!((civil.month, civil.day, civil.hour), (3, 15, 12));
}
#[test]
fn a_folded_time_string_still_parses() {
let doc = asdf_yaml::parse_document("time: '2025-07-23\n 11:56:15+00:00'\n").unwrap();
let root = doc.root().unwrap();
let node = doc.mapping_get(root, "time").unwrap();
let text = doc.resolved(node).as_str().unwrap();
assert_eq!(text, "2025-07-23 11:56:15+00:00", "the fold should become one space");
let mut t = Time::new(text, TimeFormat::Iso, TimeScale::Utc);
assert_eq!(t.compute_civil().unwrap().unix_seconds, 1_753_271_775);
}
#[test]
fn formats_are_inferred_from_the_value_string() {
use TimeFormat as F;
let cases = [
("2025-10-14T13:26:41.0000", Some(F::Iso)),
("2025-10-14 13:26:41", Some(F::Iso)),
("2025-10-14", Some(F::Iso)),
("B2025.78707178", Some(F::Byear)),
("J2025.78707178", Some(F::Jyear)),
("2025:287:13:26:41.0000", Some(F::Yday)),
("+12025-10-14T13:26:41.0000", Some(F::Fits)),
("-12025-10-14T13:26:41.0000", Some(F::Fits)),
("not a time at all", None),
("2025-13", None),
("B", None),
("2025:287", None),
];
for (text, expected) in cases {
assert_eq!(infer_format(text), expected, "{text}");
}
}
#[test]
fn parses_iso_times() {
let mut time = Time::new("2026-09-04T12:34:56.5", TimeFormat::Iso, TimeScale::Utc);
let civil = time.compute_civil().unwrap();
assert_eq!((civil.year, civil.month, civil.day), (2026, 9, 4));
assert_eq!((civil.hour, civil.minute, civil.second), (12, 34, 56));
assert_eq!(civil.nanosecond, 500_000_000);
}
#[test]
fn a_date_without_a_time_is_midnight() {
let mut time = Time::new("2026-09-04", TimeFormat::Iso, TimeScale::Utc);
let civil = time.compute_civil().unwrap();
assert_eq!((civil.hour, civil.minute, civil.second), (0, 0, 0));
assert_eq!(civil.unix_seconds, days_from_civil(2026, 9, 4) * 86_400);
}
#[test]
fn the_unix_epoch_is_the_anchor() {
let mut time = Time::new("1970-01-01T00:00:00", TimeFormat::Iso, TimeScale::Utc);
let civil = time.compute_civil().unwrap();
assert_eq!(civil.unix_seconds, 0);
assert_eq!(civil.wday, 4);
assert_eq!(civil.yday, 1);
}
#[test]
fn julian_dates_convert_both_ways() {
let civil = julian_to_civil(JD_J2000);
assert_eq!((civil.year, civil.month, civil.day), (2000, 1, 1));
assert_eq!(civil.hour, 12);
let back = civil_to_julian(&civil);
assert!((back - JD_J2000).abs() < 1e-6, "{back} != {JD_J2000}");
}
#[test]
fn numeric_formats_land_on_their_epochs() {
let cases = [
(TimeFormat::Unix, "0", (1970, 1, 1)),
(TimeFormat::Galexsec, "0", (1980, 1, 6)),
(TimeFormat::Cxcsec, "0", (1998, 1, 1)),
(TimeFormat::TaiSeconds, "0", (1958, 1, 1)),
(TimeFormat::Utime, "0", (1979, 1, 1)),
(TimeFormat::Mjd, "0", (1858, 11, 17)),
];
for (format, value, expected) in cases {
let mut time = Time::new(value, format, TimeScale::Utc);
let civil = time.compute_civil().unwrap();
assert_eq!((civil.year, civil.month, civil.day), expected, "{format:?} epoch");
}
}
#[test]
fn unix_seconds_are_recovered_from_a_unix_time() {
let seconds = days_from_civil(2026, 9, 4) * 86_400;
let mut time = Time::new(seconds.to_string(), TimeFormat::Unix, TimeScale::Utc);
let civil = time.compute_civil().unwrap();
assert_eq!((civil.year, civil.month, civil.day), (2026, 9, 4));
assert_eq!(civil.unix_seconds, seconds);
}
#[test]
fn epoch_year_formats_parse_their_prefixes() {
let mut time = Time::new("J2000.0", TimeFormat::JyearStr, TimeScale::Utc);
let civil = time.compute_civil().unwrap();
assert_eq!((civil.year, civil.month, civil.day), (2000, 1, 1));
let mut time = Time::new("B1950.0", TimeFormat::ByearStr, TimeScale::Utc);
let civil = time.compute_civil().unwrap();
assert_eq!(civil.year, 1949, "B1950.0 falls in late 1949");
assert_eq!(civil.month, 12);
let mut time = Time::new("2000.0", TimeFormat::Jyear, TimeScale::Utc);
assert_eq!(time.compute_civil().unwrap().year, 2000);
}
#[test]
fn yday_times_parse() {
let yday = days_from_civil(2026, 9, 4) - days_from_civil(2026, 1, 1) + 1;
let mut time =
Time::new(format!("2026:{yday:03}:12:00:00"), TimeFormat::Yday, TimeScale::Utc);
let civil = time.compute_civil().unwrap();
assert_eq!((civil.year, civil.month, civil.day), (2026, 9, 4));
assert_eq!(civil.hour, 12);
assert_eq!(civil.yday, yday as u32);
}
#[test]
fn a_leap_year_february_has_29_days() {
let mut time = Time::new("2000-02-29T00:00:00", TimeFormat::Iso, TimeScale::Utc);
let civil = time.compute_civil().unwrap();
assert_eq!(civil.day, 29);
assert_eq!(civil.yday, 60);
assert!(is_leap(2000));
assert!(!is_leap(1900), "1900 is not a leap year");
assert!(is_leap(2024));
}
#[test]
fn fits_long_years_and_negatives_parse() {
let mut time = Time::new("-0500-01-01T00:00:00", TimeFormat::Fits, TimeScale::Utc);
let civil = time.compute_civil().unwrap();
assert_eq!(civil.year, -500);
}
#[test]
fn a_leap_second_is_accepted() {
let mut time = Time::new("2016-12-31T23:59:60", TimeFormat::Iso, TimeScale::Utc);
assert!(time.compute_civil().is_ok());
}
#[test]
fn malformed_values_are_errors_not_panics() {
for (value, format) in [
("not a date", TimeFormat::Iso),
("2026-13-45", TimeFormat::Iso),
("2026-01-01T25:00:00", TimeFormat::Iso),
("not a number", TimeFormat::Unix),
("", TimeFormat::Iso),
("2026:400:00:00:00", TimeFormat::Yday),
] {
let mut time = Time::new(value, format, TimeScale::Utc);
assert!(time.compute_civil().is_err(), "{value:?} as {format:?}");
}
}
#[test]
fn the_reserved_format_is_refused() {
let mut time = Time::new("0", TimeFormat::Reserved1, TimeScale::Utc);
assert!(time.compute_civil().is_err());
assert_eq!(TimeFormat::Reserved1.name(), None);
}
#[test]
fn plot_date_counts_from_its_own_epoch() {
let mut time = Time::new("1.0", TimeFormat::PlotDate, TimeScale::Utc);
let civil = time.compute_civil().unwrap();
assert_eq!((civil.year, civil.month, civil.day), (1, 1, 3));
let jd = civil_to_julian(&civil);
let back = julian_to_civil(jd);
assert_eq!((back.year, back.month, back.day), (1, 1, 3));
}
#[test]
fn julian_conversions_invert_each_other() {
let mut jd = 1_721_400.5;
let mut checked = 0;
while jd < 2_500_000.5 {
let civil = julian_to_civil(jd);
let back = civil_to_julian(&civil);
assert!((back - jd).abs() < 1e-6, "JD {jd} became {civil:?} and back to {back}");
let again = julian_to_civil(back);
assert_eq!(
(again.year, again.month, again.day, again.hour),
(civil.year, civil.month, civil.day, civil.hour),
"JD {jd} did not survive two conversions"
);
checked += 1;
jd += 977.0; }
assert!(checked > 700, "expected a wide sweep, got {checked}");
}
#[test]
fn the_gregorian_switch_is_where_meeus_puts_it() {
let civil = julian_to_civil(2299160.5);
assert_eq!((civil.year, civil.month, civil.day), (1582, 10, 15));
let civil = julian_to_civil(2299159.5);
assert_eq!((civil.year, civil.month, civil.day), (1582, 10, 4));
}
}