Skip to main content

asdf_core/core/
time.rs

1//! The `core/time` schema.
2//!
3//! # What is authoritative, and what is not
4//!
5//! A time's meaning is carried entirely by its `value`, `format` and
6//! `scale`, and those three round-trip losslessly. The calendar breakdown
7//! this module also computes is a *convenience*, and for anything not on the
8//! UTC scale it is approximate: converting exactly requires a leap-second
9//! table, which this library does not carry, so the atomic-scale formats
10//! (`gps`, `tai_seconds`, `unix_tai`, `cxcsec`) and the non-UTC scales come
11//! out offset by the relevant amount. libasdf documents the same caveat.
12//!
13//! # The `format` / `base_format` split
14//!
15//! The schema's `format` field admits only a subset of astropy's formats.
16//! The rest -- `isot`, `fits`, `datetime`, `plot_date`, `ymdhms`,
17//! `datetime64`, `jyear_str`, `byear_str` -- may appear only in
18//! `base_format`. Reading collapses the pair into one effective format;
19//! writing splits it back out.
20
21use asdf_yaml::{Document, NodeData, NodeId, Resolved};
22
23use crate::error::{Result, err};
24
25/// How a time is written, mirroring `asdf_time_format_t`.
26///
27/// The discriminants are part of the C ABI.
28#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
29#[repr(i32)]
30pub enum TimeFormat {
31    /// ISO 8601 date and time, the default.
32    #[default]
33    Iso = 0,
34    /// Year, day-of-year and time.
35    Yday,
36    /// Besselian epoch year.
37    Byear,
38    /// Julian epoch year.
39    Jyear,
40    /// Decimal year.
41    DecimalYear,
42    /// Julian Date.
43    Jd,
44    /// Modified Julian Date.
45    Mjd,
46    /// Seconds from the GPS epoch.
47    Gps,
48    /// Seconds from the Unix epoch, ignoring leap seconds.
49    Unix,
50    /// UT seconds from 1979-01-01.
51    Utime,
52    /// SI seconds from 1958-01-01, including leap seconds.
53    TaiSeconds,
54    /// Chandra X-ray Center seconds from 1998-01-01 TT.
55    Cxcsec,
56    /// GALEX seconds from 1980-01-06.
57    Galexsec,
58    /// SI seconds from 1970-01-01 TAI.
59    UnixTai,
60    /// Reserved; not a usable format.
61    Reserved1,
62    /// Besselian epoch in string form.
63    ByearStr,
64    /// A Python `datetime.datetime`.
65    Datetime,
66    /// FITS date-time string.
67    Fits,
68    /// ISO 8601 with a literal `T` separator.
69    Isot,
70    /// Julian epoch in string form.
71    JyearStr,
72    /// matplotlib ordinal days.
73    PlotDate,
74    /// Year/month/day/hour/minute/second fields.
75    Ymdhms,
76    /// NumPy `datetime64`.
77    Datetime64,
78}
79
80/// The time scale, mirroring `asdf_time_scale_t`.
81#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
82#[repr(i32)]
83pub enum TimeScale {
84    /// Coordinated Universal Time, the default.
85    #[default]
86    Utc = 0,
87    /// International Atomic Time.
88    Tai,
89    /// Barycentric Coordinate Time.
90    Tcb,
91    /// Geocentric Coordinate Time.
92    Tcg,
93    /// Barycentric Dynamical Time.
94    Tdb,
95    /// Terrestrial Time.
96    Tt,
97    /// Universal Time.
98    Ut1,
99}
100
101/// Format names as they appear in a file, indexed by discriminant.
102///
103/// `Reserved1` has no name, matching upstream's `NULL` entry.
104const FORMAT_NAMES: [Option<&str>; 23] = [
105    Some("iso"),
106    Some("yday"),
107    Some("byear"),
108    Some("jyear"),
109    Some("decimalyear"),
110    Some("jd"),
111    Some("mjd"),
112    Some("gps"),
113    Some("unix"),
114    Some("utime"),
115    Some("tai_seconds"),
116    Some("cxcsec"),
117    Some("galexsec"),
118    Some("unix_tai"),
119    None,
120    Some("byear_str"),
121    Some("datetime"),
122    Some("fits"),
123    Some("isot"),
124    Some("jyear_str"),
125    Some("plot_date"),
126    Some("ymdhms"),
127    Some("datetime64"),
128];
129
130const SCALE_NAMES: [&str; 7] = ["utc", "tai", "tcb", "tcg", "tdb", "tt", "ut1"];
131
132impl TimeFormat {
133    /// The name written to a file, or `None` for the reserved slot.
134    pub fn name(self) -> Option<&'static str> {
135        FORMAT_NAMES.get(self as usize).copied().flatten()
136    }
137
138    /// Parse a format name.
139    pub fn from_name(name: &str) -> Option<Self> {
140        FORMAT_NAMES
141            .iter()
142            .position(|candidate| *candidate == Some(name))
143            .and_then(Self::from_index)
144    }
145
146    fn from_index(index: usize) -> Option<Self> {
147        (index < FORMAT_NAMES.len()).then(|| {
148            // Every index below the table's length is a valid discriminant.
149            unsafe_transmute_format(index as i32)
150        })
151    }
152
153    /// The format written to the wire `format` field.
154    ///
155    /// The schema only permits a subset there; an "other" format maps to the
156    /// standard one it is a spelling of, and goes in `base_format` instead.
157    pub fn standard(self) -> Self {
158        match self {
159            TimeFormat::Isot
160            | TimeFormat::Fits
161            | TimeFormat::Datetime
162            | TimeFormat::PlotDate
163            | TimeFormat::Ymdhms
164            | TimeFormat::Datetime64 => TimeFormat::Iso,
165            TimeFormat::JyearStr => TimeFormat::Jyear,
166            TimeFormat::ByearStr => TimeFormat::Byear,
167            other => other,
168        }
169    }
170
171    /// Whether this format may appear only in `base_format`.
172    pub fn is_other(self) -> bool {
173        self.standard() != self
174    }
175}
176
177/// Convert an index to a format without `unsafe`.
178///
179/// A plain match keeps the mapping explicit and checkable, which matters
180/// because the discriminants are ABI.
181fn unsafe_transmute_format(value: i32) -> TimeFormat {
182    match value {
183        0 => TimeFormat::Iso,
184        1 => TimeFormat::Yday,
185        2 => TimeFormat::Byear,
186        3 => TimeFormat::Jyear,
187        4 => TimeFormat::DecimalYear,
188        5 => TimeFormat::Jd,
189        6 => TimeFormat::Mjd,
190        7 => TimeFormat::Gps,
191        8 => TimeFormat::Unix,
192        9 => TimeFormat::Utime,
193        10 => TimeFormat::TaiSeconds,
194        11 => TimeFormat::Cxcsec,
195        12 => TimeFormat::Galexsec,
196        13 => TimeFormat::UnixTai,
197        14 => TimeFormat::Reserved1,
198        15 => TimeFormat::ByearStr,
199        16 => TimeFormat::Datetime,
200        17 => TimeFormat::Fits,
201        18 => TimeFormat::Isot,
202        19 => TimeFormat::JyearStr,
203        20 => TimeFormat::PlotDate,
204        21 => TimeFormat::Ymdhms,
205        22 => TimeFormat::Datetime64,
206        _ => TimeFormat::Iso,
207    }
208}
209
210impl TimeScale {
211    /// The name written to a file.
212    pub fn name(self) -> &'static str {
213        SCALE_NAMES[self as usize]
214    }
215
216    /// Parse a scale name.
217    pub fn from_name(name: &str) -> Option<Self> {
218        Some(match name {
219            "utc" => TimeScale::Utc,
220            "tai" => TimeScale::Tai,
221            "tcb" => TimeScale::Tcb,
222            "tcg" => TimeScale::Tcg,
223            "tdb" => TimeScale::Tdb,
224            "tt" => TimeScale::Tt,
225            "ut1" => TimeScale::Ut1,
226            _ => return None,
227        })
228    }
229
230    /// Convert from the ABI discriminant.
231    pub fn from_i32(value: i32) -> Self {
232        match value {
233            1 => TimeScale::Tai,
234            2 => TimeScale::Tcb,
235            3 => TimeScale::Tcg,
236            4 => TimeScale::Tdb,
237            5 => TimeScale::Tt,
238            6 => TimeScale::Ut1,
239            _ => TimeScale::Utc,
240        }
241    }
242}
243
244/// An observer's location, for the location-sensitive scales.
245#[derive(Clone, Copy, PartialEq, Debug, Default)]
246pub struct Location {
247    /// Degrees east.
248    pub longitude: f64,
249    /// Degrees north.
250    pub latitude: f64,
251    /// Metres above the reference ellipsoid.
252    pub height: f64,
253}
254
255/// A calendar breakdown, in the format's own timescale.
256///
257/// Deliberately free of C types, so the engine stays platform-neutral; the
258/// FFI layer converts this to `struct tm` and `struct timespec`.
259#[derive(Clone, Copy, PartialEq, Debug, Default)]
260pub struct Civil {
261    /// Full year, e.g. 2026.
262    pub year: i32,
263    /// Month, 1 to 12.
264    pub month: u32,
265    /// Day of the month, 1 to 31.
266    pub day: u32,
267    /// Hour, 0 to 23.
268    pub hour: u32,
269    /// Minute, 0 to 59.
270    pub minute: u32,
271    /// Second, 0 to 60 to allow for a leap second in the source text.
272    pub second: u32,
273    /// Nanoseconds within the second.
274    pub nanosecond: u32,
275    /// Day of the year, 1 to 366.
276    pub yday: u32,
277    /// Day of the week, 0 being Sunday.
278    pub wday: u32,
279    /// Seconds from the Unix epoch, ignoring leap seconds.
280    pub unix_seconds: i64,
281}
282
283// Julian Dates of the epochs each numeric format counts from. Taken from
284// astropy's `TimeFromEpoch` subclasses, as libasdf's are.
285const JD_UNIX_EPOCH: f64 = 2440587.5;
286const JD_MJD: f64 = 2400000.5;
287const JD_J2000: f64 = 2451545.0;
288const JD_B1900: f64 = 2415020.31352;
289/// matplotlib counts days from 0001-01-01 UTC *plus one*.
290const JD_PLOT_DATE_EPOCH: f64 = 1721424.5;
291/// 1980-01-06 00:00:19 TAI.
292const JD_GPS_EPOCH: f64 = 2444244.5 + 19.0 / 86400.0;
293/// 1980-01-06 00:00:00 UTC.
294const JD_GALEXSEC_EPOCH: f64 = 2444244.5;
295/// 1998-01-01 00:00:00 TT.
296const JD_CXCSEC_EPOCH: f64 = 2450814.5;
297/// 1958-01-01 00:00:00 TAI.
298const JD_TAI_SECONDS_EPOCH: f64 = 2436204.5;
299/// 1979-01-01 00:00:00 UTC.
300const JD_UTIME_EPOCH: f64 = 2443874.5;
301
302const JULIAN_YEAR_DAYS: f64 = 365.25;
303const BESSELIAN_YEAR_DAYS: f64 = 365.242198781;
304const SECONDS_PER_DAY: f64 = 86400.0;
305
306/// Days from 1970-01-01 for a civil date, by Howard Hinnant's algorithm.
307fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
308    let year = i64::from(year) - i64::from(month <= 2);
309    let era = if year >= 0 { year } else { year - 399 } / 400;
310    let year_of_era = year - era * 400;
311    let month = i64::from(month);
312    let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + i64::from(day) - 1;
313    let doe = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + doy;
314    era * 146_097 + doe - 719_468
315}
316
317/// The inverse of `days_from_civil`.
318fn civil_from_days(days: i64) -> (i32, u32, u32) {
319    let z = days + 719_468;
320    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
321    let doe = z - era * 146_097;
322    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
323    let year = yoe + era * 400;
324    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
325    let mp = (5 * doy + 2) / 153;
326    let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
327    let month = (mp + if mp < 10 { 3 } else { -9 }) as u32;
328    ((year + i64::from(month <= 2)) as i32, month, day)
329}
330
331fn is_leap(year: i32) -> bool {
332    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
333}
334
335/// Fill in the derived fields of a partially-built breakdown.
336///
337/// `unix_seconds` is computed in the **proleptic Gregorian** calendar, which
338/// is what `timegm` gives libasdf and what a caller treating it as a Unix
339/// timestamp expects. For a date before 1582-10-15 that disagrees with the
340/// Julian-calendar breakdown by a growing number of days; such dates are far
341/// outside what a Unix timestamp is meaningful for.
342fn complete(mut civil: Civil) -> Civil {
343    let days = days_from_civil(civil.year, civil.month.max(1), civil.day.max(1));
344    civil.unix_seconds = days * 86_400
345        + i64::from(civil.hour) * 3600
346        + i64::from(civil.minute) * 60
347        + i64::from(civil.second);
348
349    // Day of the year.
350    let month_lengths =
351        [31, if is_leap(civil.year) { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
352    let mut yday = civil.day;
353    for length in month_lengths.iter().take(civil.month.saturating_sub(1) as usize) {
354        yday += length;
355    }
356    civil.yday = yday;
357
358    // 1970-01-01 was a Thursday, which is weekday 4 counting Sunday as 0.
359    civil.wday = (((days % 7) + 7 + 4) % 7) as u32;
360    civil
361}
362
363/// Convert a Julian Date to a calendar breakdown, by Meeus' algorithm.
364pub fn julian_to_civil(jd: f64) -> Civil {
365    let shifted = jd + 0.5;
366    let z = shifted.floor();
367    let fraction = shifted - z;
368
369    // The Gregorian correction applies from 1582-10-15 onwards.
370    let a = if z < 2299161.0 {
371        z
372    } else {
373        let alpha = ((z - 1867216.25) / 36524.25).floor();
374        z + 1.0 + alpha - (alpha / 4.0).floor()
375    };
376    let b = a + 1524.0;
377    let c = ((b - 122.1) / 365.25).floor();
378    let d = (365.25 * c).floor();
379    let e = ((b - d) / 30.6001).floor();
380
381    let day_with_fraction = b - d - (30.6001 * e).floor() + fraction;
382    let day = day_with_fraction.floor();
383    let month = if e < 14.0 { e - 1.0 } else { e - 13.0 };
384    let year = if month > 2.0 { c - 4716.0 } else { c - 4715.0 };
385
386    // Split the day fraction into a time of day, rounding to the nearest
387    // nanosecond so a value that is exact in seconds does not drift.
388    let seconds_in_day = (day_with_fraction - day) * SECONDS_PER_DAY;
389    let total_nanos = (seconds_in_day * 1e9).round().max(0.0) as i64;
390    let whole_seconds = total_nanos / 1_000_000_000;
391    let nanosecond = (total_nanos % 1_000_000_000) as u32;
392
393    complete(Civil {
394        year: year as i32,
395        month: month as u32,
396        day: day as u32,
397        hour: (whole_seconds / 3600) as u32,
398        minute: ((whole_seconds / 60) % 60) as u32,
399        second: (whole_seconds % 60) as u32,
400        nanosecond,
401        ..Default::default()
402    })
403}
404
405/// The inverse of [`julian_to_civil`], by Meeus' algorithm.
406///
407/// Uses the same calendar convention as the forward direction -- Julian
408/// before 1582-10-15, Gregorian from then on -- so the two are mutual
409/// inverses across the whole range. Deriving this from
410/// `days_from_civil` instead would be proleptic Gregorian and disagree
411/// with the forward conversion for any date before the switch.
412pub fn civil_to_julian(civil: &Civil) -> f64 {
413    let (mut year, mut month) = (civil.year, civil.month as i32);
414    if month <= 2 {
415        year -= 1;
416        month += 12;
417    }
418
419    // The Gregorian correction applies from 1582-10-15 onwards.
420    let gregorian = (civil.year, civil.month, civil.day) >= (1582, 10, 15);
421    let b = if gregorian {
422        let a = (year as f64 / 100.0).floor();
423        2.0 - a + (a / 4.0).floor()
424    } else {
425        0.0
426    };
427
428    let seconds = f64::from(civil.hour) * 3600.0
429        + f64::from(civil.minute) * 60.0
430        + f64::from(civil.second)
431        + f64::from(civil.nanosecond) / 1e9;
432
433    (365.25 * (f64::from(year) + 4716.0)).floor()
434        + (30.6001 * (f64::from(month) + 1.0)).floor()
435        + f64::from(civil.day)
436        + b
437        - 1524.5
438        + seconds / SECONDS_PER_DAY
439}
440
441/// Split a trailing UTC offset off a time-of-day.
442///
443/// Returns the time without it and the offset in seconds. `Z` means zero,
444/// and so does no designator at all -- ASDF carries the scale separately, so
445/// an unqualified time is read in its own scale.
446///
447/// The forms are ISO 8601's: `+HH:MM`, `-HH:MM`, `+HHMM` and `+HH`.
448fn split_utc_offset(time: &str) -> (&str, i64) {
449    let time = time.trim();
450    if let Some(rest) = time.strip_suffix(['Z', 'z']) {
451        return (rest.trim_end(), 0);
452    }
453    // The sign cannot be the first character: that would be part of the time
454    // itself, not an offset.
455    let Some(index) = time.rfind(['+', '-']).filter(|i| *i > 0) else {
456        return (time, 0);
457    };
458    let sign = if time.as_bytes()[index] == b'-' { -1 } else { 1 };
459    let designator = &time[index + 1..];
460
461    let (hours, minutes) = match designator.split_once(':') {
462        Some((h, m)) => (h, m),
463        // `+HHMM` packs both without a separator; `+HH` has only hours.
464        None if designator.len() == 4 => designator.split_at(2),
465        None => (designator, "0"),
466    };
467    let (Ok(hours), Ok(minutes)) = (hours.parse::<i64>(), minutes.parse::<i64>()) else {
468        return (time, 0);
469    };
470    (&time[..index], sign * (hours * 3600 + minutes * 60))
471}
472
473/// Parse an ISO 8601 or FITS date-time.
474///
475/// Accepts `YYYY-MM-DD[T ]HH:MM:SS[.frac]`, with the time optional, and a
476/// signed five-digit "long year" as FITS permits. A trailing UTC offset is
477/// applied, so `11:56:15+01:00` is 10:56:15 UTC.
478fn parse_datetime(text: &str) -> Option<Civil> {
479    let text = text.trim();
480    let (date, time) = match text.find(['T', ' ']) {
481        Some(index) => (&text[..index], Some(&text[index + 1..])),
482        None => (text, None),
483    };
484
485    let (negative, date) = match date.strip_prefix('-') {
486        Some(rest) => (true, rest),
487        None => (false, date.strip_prefix('+').unwrap_or(date)),
488    };
489
490    let mut parts = date.split('-');
491    let year: i32 = parts.next()?.parse().ok()?;
492    let month: u32 = parts.next().unwrap_or("1").parse().ok()?;
493    let day: u32 = parts.next().unwrap_or("1").parse().ok()?;
494    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
495        return None;
496    }
497
498    let mut utc_offset = 0i64;
499    let (hour, minute, second, nanosecond) = match time {
500        None => (0, 0, 0, 0),
501        Some(time) => {
502            let (time, offset) = split_utc_offset(time);
503            utc_offset = offset;
504            let mut parts = time.split(':');
505            let hour: u32 = parts.next()?.parse().ok()?;
506            let minute: u32 = parts.next().unwrap_or("0").parse().ok()?;
507            let seconds_text = parts.next().unwrap_or("0");
508
509            let (whole, fraction) = match seconds_text.split_once('.') {
510                Some((whole, fraction)) => (whole, fraction),
511                None => (seconds_text, ""),
512            };
513            let second: u32 = whole.parse().ok()?;
514            // Scale the fractional digits to nanoseconds.
515            let mut nanos = 0u32;
516            for (index, digit) in fraction.chars().take(9).enumerate() {
517                let value = digit.to_digit(10)?;
518                nanos += value * 10u32.pow(8 - index as u32);
519            }
520            // A leap second is 60, which the calendar arithmetic folds over.
521            if hour > 23 || minute > 59 || second > 60 {
522                return None;
523            }
524            (hour, minute, second, nanos)
525        }
526    };
527
528    let civil = complete(Civil {
529        year: if negative { -year } else { year },
530        month,
531        day,
532        hour,
533        minute,
534        second,
535        nanosecond,
536        ..Default::default()
537    });
538    if utc_offset == 0 {
539        return Some(civil);
540    }
541    // Re-derive the whole breakdown from the corrected instant rather than
542    // adjusting `unix_seconds` alone, so the calendar fields and the
543    // timestamp cannot disagree.
544    Some(civil_from_unix_seconds(civil.unix_seconds - utc_offset, civil.nanosecond))
545}
546
547/// The calendar breakdown for an instant, in the proleptic Gregorian
548/// calendar that [`complete`] uses.
549fn civil_from_unix_seconds(unix_seconds: i64, nanosecond: u32) -> Civil {
550    let days = unix_seconds.div_euclid(86_400);
551    let seconds_of_day = unix_seconds.rem_euclid(86_400);
552    let (year, month, day) = civil_from_days(days);
553    complete(Civil {
554        year,
555        month,
556        day,
557        hour: (seconds_of_day / 3600) as u32,
558        minute: ((seconds_of_day % 3600) / 60) as u32,
559        second: (seconds_of_day % 60) as u32,
560        nanosecond,
561        ..Default::default()
562    })
563}
564
565/// Guess a time's format from the shape of its value string.
566///
567/// A `time/time` value need not say what format it is in, and the schema's
568/// string forms are distinguishable, so the format is read off the value.
569/// libasdf does this with five regexes tried in a fixed order; the order is
570/// what makes an ordinary four-digit year ISO rather than FITS, since the
571/// FITS pattern also admits one.
572///
573/// Returns `None` when the value matches none of them, which is an error at
574/// the call site rather than a silent fall back to ISO.
575pub fn infer_format(value: &str) -> Option<TimeFormat> {
576    // Each pattern anchors at the start and may leave a tail, as libasdf's
577    // do.
578    if matches_iso_shape(value, false) {
579        return Some(TimeFormat::Iso);
580    }
581    if let Some(rest) = value.strip_prefix('B')
582        && matches_year_shape(rest)
583    {
584        return Some(TimeFormat::Byear);
585    }
586    if let Some(rest) = value.strip_prefix('J')
587        && matches_year_shape(rest)
588    {
589        return Some(TimeFormat::Jyear);
590    }
591    if matches_yday_shape(value) {
592        return Some(TimeFormat::Yday);
593    }
594    // Reached only for the signed five-digit "long year", since the plain
595    // four-digit form was already claimed by ISO above.
596    if matches_iso_shape(value, true) {
597        return Some(TimeFormat::Fits);
598    }
599    None
600}
601
602/// `\d{4}-\d\d-\d\d([T ]\d\d:\d\d:\d\d(.\d+)?)?`, optionally
603/// allowing FITS's signed five-digit year.
604fn matches_iso_shape(value: &str, long_year: bool) -> bool {
605    let bytes = value.as_bytes();
606    let digits = |at: usize, count: usize| -> bool {
607        bytes.len() >= at + count && bytes[at..at + count].iter().all(u8::is_ascii_digit)
608    };
609
610    let mut at = if long_year {
611        // Only the signed five-digit branch: the four-digit one is ISO's.
612        if !matches!(bytes.first(), Some(b'+' | b'-')) || !digits(1, 5) {
613            return false;
614        }
615        6
616    } else {
617        if !digits(0, 4) {
618            return false;
619        }
620        4
621    };
622
623    for _ in 0..2 {
624        if bytes.get(at) != Some(&b'-') || !digits(at + 1, 2) {
625            return false;
626        }
627        at += 3;
628    }
629
630    // The time of day is optional; anything else trailing is ignored, as
631    // these are prefix matches.
632    if !matches!(bytes.get(at), Some(b'T' | b' ')) {
633        return true;
634    }
635    at += 1;
636    if !digits(at, 2) {
637        return false;
638    }
639    at += 2;
640    for _ in 0..2 {
641        if bytes.get(at) != Some(&b':') || !digits(at + 1, 2) {
642            return false;
643        }
644        at += 3;
645    }
646    true
647}
648
649/// `\d+(.\d+)?`, the tail of a `B`/`J` epoch-year value.
650fn matches_year_shape(value: &str) -> bool {
651    let mut chars = value.chars();
652    if !chars.next().is_some_and(|c| c.is_ascii_digit()) {
653        return false;
654    }
655    true
656}
657
658/// `\d{4}:\d{3}:\d\d:\d\d:\d\d(.\d+)?`.
659fn matches_yday_shape(value: &str) -> bool {
660    let bytes = value.as_bytes();
661    let digits = |at: usize, count: usize| -> bool {
662        bytes.len() >= at + count && bytes[at..at + count].iter().all(u8::is_ascii_digit)
663    };
664
665    if !digits(0, 4) || bytes.get(4) != Some(&b':') {
666        return false;
667    }
668    if !digits(5, 3) || bytes.get(8) != Some(&b':') {
669        return false;
670    }
671    let mut at = 9;
672    for step in 0..3 {
673        if !digits(at, 2) {
674            return false;
675        }
676        at += 2;
677        if step < 2 {
678            if bytes.get(at) != Some(&b':') {
679                return false;
680            }
681            at += 1;
682        }
683    }
684    true
685}
686
687/// Parse a `YYYY:DDD:HH:MM:SS` day-of-year time.
688fn parse_yday(text: &str) -> Option<Civil> {
689    let mut parts = text.trim().split(':');
690    let year: i32 = parts.next()?.parse().ok()?;
691    let yday: u32 = parts.next()?.parse().ok()?;
692    let hour: u32 = parts.next().unwrap_or("0").parse().ok()?;
693    let minute: u32 = parts.next().unwrap_or("0").parse().ok()?;
694    let seconds_text = parts.next().unwrap_or("0");
695    let (whole, fraction) = match seconds_text.split_once('.') {
696        Some(split) => split,
697        None => (seconds_text, ""),
698    };
699    let second: u32 = whole.parse().ok()?;
700    let mut nanosecond = 0u32;
701    for (index, digit) in fraction.chars().take(9).enumerate() {
702        nanosecond += digit.to_digit(10)? * 10u32.pow(8 - index as u32);
703    }
704
705    if yday == 0 || yday > if is_leap(year) { 366 } else { 365 } {
706        return None;
707    }
708    // Turn the day-of-year back into a month and day.
709    let days = days_from_civil(year, 1, 1) + i64::from(yday) - 1;
710    let (year, month, day) = civil_from_days(days);
711
712    Some(complete(Civil {
713        year,
714        month,
715        day,
716        hour,
717        minute,
718        second,
719        nanosecond,
720        ..Default::default()
721    }))
722}
723
724/// How YAML reads a scalar, which decides whether its format may be guessed.
725fn scalar_kind(doc: &Document, node: NodeId) -> Resolved {
726    match &doc.resolved(node).data {
727        NodeData::Scalar { value, style } => {
728            asdf_yaml::resolve(value, *style, asdf_yaml::Schema::Libasdf)
729        }
730        _ => Resolved::Null,
731    }
732}
733
734/// Strip the `B`/`J` prefix from an epoch-year string.
735fn parse_epoch_year(text: &str) -> Option<f64> {
736    let text = text.trim();
737    let body = text.strip_prefix('B').or_else(|| text.strip_prefix('J')).unwrap_or(text);
738    body.parse().ok()
739}
740
741/// A time as it appears in a file.
742#[derive(Clone, PartialEq, Debug)]
743pub struct Time {
744    /// The value exactly as written.
745    pub value: String,
746    /// The effective format, with `format` and `base_format` collapsed.
747    pub format: TimeFormat,
748    /// The time scale.
749    pub scale: TimeScale,
750    /// The observer's location, for location-sensitive scales.
751    pub location: Location,
752    /// The derived calendar breakdown, once computed.
753    pub civil: Option<Civil>,
754}
755
756impl Time {
757    /// A time with the given value and format.
758    pub fn new(value: impl Into<String>, format: TimeFormat, scale: TimeScale) -> Self {
759        Self { value: value.into(), format, scale, location: Location::default(), civil: None }
760    }
761
762    /// Read a `time/time` value from the tree.
763    ///
764    /// The schema allows two shapes: the whole tagged value as a string, or
765    /// a mapping with the string under `value` and optional `format`,
766    /// `base_format`, `scale` and `location`.
767    ///
768    /// The *wire* format says how to read the value; `base_format` records
769    /// the object's real format and overrides only what it reports
770    /// afterwards. An astropy `plot_date` is stored as an ISO string with
771    /// `base_format: plot_date`, so collapsing the two before parsing would
772    /// try to read a date as a matplotlib ordinal.
773    ///
774    /// The calendar breakdown is computed where the value allows it; a value
775    /// that will not parse is not an error, since the value, format and
776    /// scale round-trip regardless and the breakdown is a convenience.
777    pub fn parse(doc: &Document, id: NodeId) -> Result<Self> {
778        let node = doc.resolved(id);
779        let mapping = node.is_mapping();
780
781        let value_node = if mapping {
782            let Some(found) = doc.mapping_get(id, "value") else {
783                return Err(err!(InvalidArgument, "a time mapping needs a 'value'"));
784            };
785            doc.resolve(found)
786        } else {
787            doc.resolve(id)
788        };
789
790        // The raw text is what the format parsers want, even where YAML
791        // would read it as a number.
792        let Some(value) = doc.resolved(value_node).as_str().map(str::to_string) else {
793            return Err(err!(InvalidArgument, "a time's value must be a scalar"));
794        };
795        // Whether YAML read it *as* a string decides what may be guessed: a
796        // bare number is ambiguous and cannot be.
797        let value_is_string = matches!(scalar_kind(doc, value_node), Resolved::String);
798
799        let field = |key: &str| -> Option<String> {
800            doc.mapping_get(id, key).and_then(|n| doc.resolved(n).as_str().map(str::to_string))
801        };
802
803        let (explicit, base, scale, location) = if mapping {
804            let scale = field("scale")
805                .and_then(|name| TimeScale::from_name(&name))
806                .unwrap_or(TimeScale::Utc);
807
808            let mut location = Location::default();
809            if let Some(loc) = doc.mapping_get(id, "location") {
810                let number = |key: &str| {
811                    doc.mapping_get(loc, key)
812                        .and_then(|n| doc.resolved(n).as_str())
813                        .and_then(|text| text.parse::<f64>().ok())
814                        .unwrap_or(0.0)
815                };
816                location.longitude = number("longitude");
817                location.latitude = number("latitude");
818                location.height = number("height");
819            }
820            (field("format"), field("base_format"), scale, location)
821        } else {
822            (None, None, TimeScale::Utc, Location::default())
823        };
824
825        let wire = match &explicit {
826            Some(name) => TimeFormat::from_name(name)
827                .ok_or_else(|| err!(InvalidArgument, "unknown time format {name:?}"))?,
828            None => {
829                if !value_is_string {
830                    return Err(err!(
831                        InvalidArgument,
832                        "a numeric time value needs an explicit format; {value:?} is ambiguous"
833                    ));
834                }
835                infer_format(&value).ok_or_else(|| {
836                    err!(InvalidArgument, "could not guess the format of time {value:?}")
837                })?
838            }
839        };
840
841        // `jyear_str` and `byear_str` exist to make the `J`/`B` prefix
842        // mandatory, so a bare number under either is not a time.
843        if matches!(wire, TimeFormat::JyearStr | TimeFormat::ByearStr) {
844            let prefix = if wire == TimeFormat::JyearStr { ['J', 'j'] } else { ['B', 'b'] };
845            if !value_is_string || !value.starts_with(prefix) {
846                return Err(err!(
847                    InvalidArgument,
848                    "time format {:?} needs a value starting with {:?}",
849                    wire.name(),
850                    prefix[0]
851                ));
852            }
853        }
854
855        // An unrecognised `base_format` is a label we do not know; the value
856        // is still readable without it.
857        let effective = base.as_deref().and_then(TimeFormat::from_name).unwrap_or(wire);
858
859        let mut time = Time::new(value, wire, scale);
860        time.location = location;
861        let civil = time.compute_civil().ok();
862        Ok(Time { format: effective, civil, ..time })
863    }
864
865    /// Compute the calendar breakdown from the value, format and scale.
866    ///
867    /// Approximate for anything off the UTC scale; see the module comment.
868    pub fn compute_civil(&mut self) -> Result<Civil> {
869        let civil = self.derive_civil()?;
870        self.civil = Some(civil);
871        Ok(civil)
872    }
873
874    fn derive_civil(&self) -> Result<Civil> {
875        let text = self.value.trim();
876        let numeric = || -> Result<f64> {
877            text.parse::<f64>()
878                .map_err(|_| err!(InvalidArgument, "time value {text:?} is not numeric"))
879        };
880
881        let civil = match self.format {
882            // String forms are parsed directly.
883            TimeFormat::Iso
884            | TimeFormat::Isot
885            | TimeFormat::Fits
886            | TimeFormat::Datetime
887            | TimeFormat::Datetime64
888            | TimeFormat::Ymdhms => parse_datetime(text)
889                .ok_or_else(|| err!(InvalidArgument, "could not parse {text:?} as a date-time"))?,
890
891            TimeFormat::Yday => parse_yday(text)
892                .ok_or_else(|| err!(InvalidArgument, "could not parse {text:?} as a yday time"))?,
893
894            // Julian and modified Julian dates convert directly.
895            TimeFormat::Jd => julian_to_civil(numeric()?),
896            TimeFormat::Mjd => julian_to_civil(numeric()? + JD_MJD),
897
898            // Epoch years.
899            TimeFormat::Jyear | TimeFormat::JyearStr => {
900                let year = parse_epoch_year(text)
901                    .ok_or_else(|| err!(InvalidArgument, "bad Julian epoch {text:?}"))?;
902                julian_to_civil(JD_J2000 + JULIAN_YEAR_DAYS * (year - 2000.0))
903            }
904            TimeFormat::Byear | TimeFormat::ByearStr => {
905                let year = parse_epoch_year(text)
906                    .ok_or_else(|| err!(InvalidArgument, "bad Besselian epoch {text:?}"))?;
907                julian_to_civil(JD_B1900 + BESSELIAN_YEAR_DAYS * (year - 1900.0))
908            }
909            TimeFormat::DecimalYear => {
910                let year = numeric()?;
911                let whole = year.floor();
912                let days_in_year = if is_leap(whole as i32) { 366.0 } else { 365.0 };
913                let start = days_from_civil(whole as i32, 1, 1) as f64;
914                julian_to_civil(JD_UNIX_EPOCH + start + (year - whole) * days_in_year)
915            }
916
917            // matplotlib's ordinal.
918            TimeFormat::PlotDate => julian_to_civil(numeric()? + JD_PLOT_DATE_EPOCH),
919
920            // Seconds from an epoch.
921            TimeFormat::Unix => julian_to_civil(JD_UNIX_EPOCH + numeric()? / SECONDS_PER_DAY),
922            TimeFormat::UnixTai => julian_to_civil(JD_UNIX_EPOCH + numeric()? / SECONDS_PER_DAY),
923            TimeFormat::Gps => julian_to_civil(JD_GPS_EPOCH + numeric()? / SECONDS_PER_DAY),
924            TimeFormat::Galexsec => {
925                julian_to_civil(JD_GALEXSEC_EPOCH + numeric()? / SECONDS_PER_DAY)
926            }
927            TimeFormat::Cxcsec => julian_to_civil(JD_CXCSEC_EPOCH + numeric()? / SECONDS_PER_DAY),
928            TimeFormat::TaiSeconds => {
929                julian_to_civil(JD_TAI_SECONDS_EPOCH + numeric()? / SECONDS_PER_DAY)
930            }
931            TimeFormat::Utime => julian_to_civil(JD_UTIME_EPOCH + numeric()? / SECONDS_PER_DAY),
932
933            TimeFormat::Reserved1 => {
934                return Err(err!(InvalidArgument, "the reserved time format is not usable"));
935            }
936        };
937        Ok(civil)
938    }
939
940    /// The pair of values to write: the wire `format`, and `base_format`
941    /// when the effective format may not appear in `format`.
942    pub fn wire_formats(&self) -> (TimeFormat, Option<TimeFormat>) {
943        if self.format.is_other() {
944            (self.format.standard(), Some(self.format))
945        } else {
946            (self.format, None)
947        }
948    }
949}
950
951#[cfg(test)]
952mod tests {
953    use super::*;
954
955    #[test]
956    fn format_discriminants_match_the_c_abi() {
957        assert_eq!(TimeFormat::Iso as i32, 0);
958        assert_eq!(TimeFormat::Yday as i32, 1);
959        assert_eq!(TimeFormat::UnixTai as i32, 13);
960        assert_eq!(TimeFormat::Reserved1 as i32, 14);
961        assert_eq!(TimeFormat::ByearStr as i32, 15);
962        assert_eq!(TimeFormat::Datetime64 as i32, 22);
963
964        assert_eq!(TimeScale::Utc as i32, 0);
965        assert_eq!(TimeScale::Ut1 as i32, 6);
966    }
967
968    #[test]
969    fn format_names_round_trip() {
970        for index in 0..23 {
971            let format = TimeFormat::from_index(index).unwrap();
972            match format.name() {
973                Some(name) => assert_eq!(TimeFormat::from_name(name), Some(format), "{name}"),
974                // Only the reserved slot has no name, as upstream.
975                None => assert_eq!(format, TimeFormat::Reserved1),
976            }
977        }
978        assert_eq!(TimeFormat::from_name("nonsense"), None);
979    }
980
981    #[test]
982    fn scale_names_round_trip() {
983        for scale in [
984            TimeScale::Utc,
985            TimeScale::Tai,
986            TimeScale::Tcb,
987            TimeScale::Tcg,
988            TimeScale::Tdb,
989            TimeScale::Tt,
990            TimeScale::Ut1,
991        ] {
992            assert_eq!(TimeScale::from_name(scale.name()), Some(scale));
993            assert_eq!(TimeScale::from_i32(scale as i32), scale);
994        }
995    }
996
997    #[test]
998    fn other_formats_split_into_base_format() {
999        // The schema only permits a subset in `format`; the rest go in
1000        // `base_format` with a standard stand-in.
1001        for (other, standard) in [
1002            (TimeFormat::Isot, TimeFormat::Iso),
1003            (TimeFormat::Fits, TimeFormat::Iso),
1004            (TimeFormat::PlotDate, TimeFormat::Iso),
1005            (TimeFormat::Ymdhms, TimeFormat::Iso),
1006            (TimeFormat::Datetime64, TimeFormat::Iso),
1007            (TimeFormat::JyearStr, TimeFormat::Jyear),
1008            (TimeFormat::ByearStr, TimeFormat::Byear),
1009        ] {
1010            assert!(other.is_other(), "{other:?}");
1011            assert_eq!(other.standard(), standard);
1012
1013            let time = Time::new("x", other, TimeScale::Utc);
1014            assert_eq!(time.wire_formats(), (standard, Some(other)));
1015        }
1016
1017        // A standard format needs no base_format.
1018        let time = Time::new("2026-01-01", TimeFormat::Iso, TimeScale::Utc);
1019        assert_eq!(time.wire_formats(), (TimeFormat::Iso, None));
1020        assert!(!TimeFormat::Iso.is_other());
1021    }
1022
1023    #[test]
1024    fn civil_day_arithmetic_round_trips() {
1025        for (year, month, day) in
1026            [(1970, 1, 1), (2000, 2, 29), (1999, 12, 31), (2026, 9, 4), (1582, 10, 15), (1, 1, 1)]
1027        {
1028            let days = days_from_civil(year, month, day);
1029            assert_eq!(civil_from_days(days), (year, month, day), "{year}-{month}-{day}");
1030        }
1031        // The Unix epoch is day zero, by definition.
1032        assert_eq!(days_from_civil(1970, 1, 1), 0);
1033    }
1034
1035    /// A trailing UTC offset shifts the instant, as ISO 8601 says and as
1036    /// libasdf's own parser does.
1037    #[test]
1038    fn utc_offsets_are_applied() {
1039        // The exact value upstream's `test-core-extensions` expects from
1040        // `fixtures/255.asdf`.
1041        let mut t = Time::new("2025-07-23 11:56:15+00:00", TimeFormat::Iso, TimeScale::Utc);
1042        assert_eq!(t.compute_civil().unwrap().unix_seconds, 1_753_271_775);
1043
1044        // An hour east is an hour earlier in UTC.
1045        let mut east = Time::new("2025-07-23T11:56:15+01:00", TimeFormat::Iso, TimeScale::Utc);
1046        assert_eq!(east.compute_civil().unwrap().unix_seconds, 1_753_271_775 - 3600);
1047
1048        // And an hour west is an hour later.
1049        let mut west = Time::new("2025-07-23T11:56:15-01:00", TimeFormat::Iso, TimeScale::Utc);
1050        assert_eq!(west.compute_civil().unwrap().unix_seconds, 1_753_271_775 + 3600);
1051
1052        // The calendar fields follow the instant, not the written text.
1053        let shifted = east.compute_civil().unwrap();
1054        assert_eq!((shifted.hour, shifted.minute, shifted.second), (10, 56, 15));
1055
1056        // `Z` and a bare time both mean no offset.
1057        for text in ["2025-07-23T11:56:15Z", "2025-07-23T11:56:15"] {
1058            let mut t = Time::new(text, TimeFormat::Iso, TimeScale::Utc);
1059            assert_eq!(t.compute_civil().unwrap().unix_seconds, 1_753_271_775, "{text}");
1060        }
1061    }
1062
1063    #[test]
1064    fn offset_designators_come_in_several_shapes() {
1065        for (text, expected) in [
1066            ("2025-07-23T11:56:15+01:30", 1_753_271_775 - 5400),
1067            ("2025-07-23T11:56:15+0130", 1_753_271_775 - 5400),
1068            ("2025-07-23T11:56:15+01", 1_753_271_775 - 3600),
1069            ("2025-07-23T11:56:15-0130", 1_753_271_775 + 5400),
1070        ] {
1071            let mut t = Time::new(text, TimeFormat::Iso, TimeScale::Utc);
1072            assert_eq!(t.compute_civil().unwrap().unix_seconds, expected, "{text}");
1073        }
1074    }
1075
1076    /// A negative year's leading sign must not be read as an offset.
1077    #[test]
1078    fn a_negative_year_is_not_an_offset() {
1079        let mut t = Time::new("-0044-03-15T12:00:00", TimeFormat::Iso, TimeScale::Utc);
1080        let civil = t.compute_civil().unwrap();
1081        assert_eq!(civil.year, -44);
1082        assert_eq!((civil.month, civil.day, civil.hour), (3, 15, 12));
1083    }
1084
1085    /// The fixture upstream's `test-core-extensions` reads, whose time is a
1086    /// single-quoted scalar folded across two lines.
1087    #[test]
1088    fn a_folded_time_string_still_parses() {
1089        let doc = asdf_yaml::parse_document("time: '2025-07-23\n      11:56:15+00:00'\n").unwrap();
1090        let root = doc.root().unwrap();
1091        let node = doc.mapping_get(root, "time").unwrap();
1092        let text = doc.resolved(node).as_str().unwrap();
1093        assert_eq!(text, "2025-07-23 11:56:15+00:00", "the fold should become one space");
1094
1095        let mut t = Time::new(text, TimeFormat::Iso, TimeScale::Utc);
1096        assert_eq!(t.compute_civil().unwrap().unix_seconds, 1_753_271_775);
1097    }
1098
1099    /// The shapes libasdf's five auto-detect patterns match, in its order.
1100    #[test]
1101    fn formats_are_inferred_from_the_value_string() {
1102        use TimeFormat as F;
1103        let cases = [
1104            ("2025-10-14T13:26:41.0000", Some(F::Iso)),
1105            ("2025-10-14 13:26:41", Some(F::Iso)),
1106            ("2025-10-14", Some(F::Iso)),
1107            ("B2025.78707178", Some(F::Byear)),
1108            ("J2025.78707178", Some(F::Jyear)),
1109            ("2025:287:13:26:41.0000", Some(F::Yday)),
1110            // The signed five-digit "long year" is the only thing that
1111            // reaches the FITS pattern; a four-digit year is ISO first.
1112            ("+12025-10-14T13:26:41.0000", Some(F::Fits)),
1113            ("-12025-10-14T13:26:41.0000", Some(F::Fits)),
1114            ("not a time at all", None),
1115            ("2025-13", None),
1116            ("B", None),
1117            ("2025:287", None),
1118        ];
1119        for (text, expected) in cases {
1120            assert_eq!(infer_format(text), expected, "{text}");
1121        }
1122    }
1123
1124    #[test]
1125    fn parses_iso_times() {
1126        let mut time = Time::new("2026-09-04T12:34:56.5", TimeFormat::Iso, TimeScale::Utc);
1127        let civil = time.compute_civil().unwrap();
1128        assert_eq!((civil.year, civil.month, civil.day), (2026, 9, 4));
1129        assert_eq!((civil.hour, civil.minute, civil.second), (12, 34, 56));
1130        assert_eq!(civil.nanosecond, 500_000_000);
1131    }
1132
1133    #[test]
1134    fn a_date_without_a_time_is_midnight() {
1135        let mut time = Time::new("2026-09-04", TimeFormat::Iso, TimeScale::Utc);
1136        let civil = time.compute_civil().unwrap();
1137        assert_eq!((civil.hour, civil.minute, civil.second), (0, 0, 0));
1138        assert_eq!(civil.unix_seconds, days_from_civil(2026, 9, 4) * 86_400);
1139    }
1140
1141    #[test]
1142    fn the_unix_epoch_is_the_anchor() {
1143        let mut time = Time::new("1970-01-01T00:00:00", TimeFormat::Iso, TimeScale::Utc);
1144        let civil = time.compute_civil().unwrap();
1145        assert_eq!(civil.unix_seconds, 0);
1146        // 1970-01-01 was a Thursday.
1147        assert_eq!(civil.wday, 4);
1148        assert_eq!(civil.yday, 1);
1149    }
1150
1151    #[test]
1152    fn julian_dates_convert_both_ways() {
1153        // J2000.0 is 2000-01-01 12:00 TT, JD 2451545.0.
1154        let civil = julian_to_civil(JD_J2000);
1155        assert_eq!((civil.year, civil.month, civil.day), (2000, 1, 1));
1156        assert_eq!(civil.hour, 12);
1157
1158        // And back again.
1159        let back = civil_to_julian(&civil);
1160        assert!((back - JD_J2000).abs() < 1e-6, "{back} != {JD_J2000}");
1161    }
1162
1163    #[test]
1164    fn numeric_formats_land_on_their_epochs() {
1165        // Each format's zero must be its documented epoch instant.
1166        let cases = [
1167            (TimeFormat::Unix, "0", (1970, 1, 1)),
1168            (TimeFormat::Galexsec, "0", (1980, 1, 6)),
1169            (TimeFormat::Cxcsec, "0", (1998, 1, 1)),
1170            (TimeFormat::TaiSeconds, "0", (1958, 1, 1)),
1171            (TimeFormat::Utime, "0", (1979, 1, 1)),
1172            (TimeFormat::Mjd, "0", (1858, 11, 17)),
1173        ];
1174        for (format, value, expected) in cases {
1175            let mut time = Time::new(value, format, TimeScale::Utc);
1176            let civil = time.compute_civil().unwrap();
1177            assert_eq!((civil.year, civil.month, civil.day), expected, "{format:?} epoch");
1178        }
1179    }
1180
1181    #[test]
1182    fn unix_seconds_are_recovered_from_a_unix_time() {
1183        // A known instant: 2026-09-04T00:00:00Z.
1184        let seconds = days_from_civil(2026, 9, 4) * 86_400;
1185        let mut time = Time::new(seconds.to_string(), TimeFormat::Unix, TimeScale::Utc);
1186        let civil = time.compute_civil().unwrap();
1187        assert_eq!((civil.year, civil.month, civil.day), (2026, 9, 4));
1188        assert_eq!(civil.unix_seconds, seconds);
1189    }
1190
1191    #[test]
1192    fn epoch_year_formats_parse_their_prefixes() {
1193        // J2000.0 is the Julian epoch's anchor.
1194        let mut time = Time::new("J2000.0", TimeFormat::JyearStr, TimeScale::Utc);
1195        let civil = time.compute_civil().unwrap();
1196        assert_eq!((civil.year, civil.month, civil.day), (2000, 1, 1));
1197
1198        // B1950.0 is the classic Besselian epoch.
1199        let mut time = Time::new("B1950.0", TimeFormat::ByearStr, TimeScale::Utc);
1200        let civil = time.compute_civil().unwrap();
1201        assert_eq!(civil.year, 1949, "B1950.0 falls in late 1949");
1202        assert_eq!(civil.month, 12);
1203
1204        // The bare numeric forms work too.
1205        let mut time = Time::new("2000.0", TimeFormat::Jyear, TimeScale::Utc);
1206        assert_eq!(time.compute_civil().unwrap().year, 2000);
1207    }
1208
1209    #[test]
1210    fn yday_times_parse() {
1211        // 2026 is not a leap year, so day 247 is 4 September.
1212        let yday = days_from_civil(2026, 9, 4) - days_from_civil(2026, 1, 1) + 1;
1213        let mut time =
1214            Time::new(format!("2026:{yday:03}:12:00:00"), TimeFormat::Yday, TimeScale::Utc);
1215        let civil = time.compute_civil().unwrap();
1216        assert_eq!((civil.year, civil.month, civil.day), (2026, 9, 4));
1217        assert_eq!(civil.hour, 12);
1218        assert_eq!(civil.yday, yday as u32);
1219    }
1220
1221    #[test]
1222    fn a_leap_year_february_has_29_days() {
1223        let mut time = Time::new("2000-02-29T00:00:00", TimeFormat::Iso, TimeScale::Utc);
1224        let civil = time.compute_civil().unwrap();
1225        assert_eq!(civil.day, 29);
1226        assert_eq!(civil.yday, 60);
1227        assert!(is_leap(2000));
1228        assert!(!is_leap(1900), "1900 is not a leap year");
1229        assert!(is_leap(2024));
1230    }
1231
1232    #[test]
1233    fn fits_long_years_and_negatives_parse() {
1234        let mut time = Time::new("-0500-01-01T00:00:00", TimeFormat::Fits, TimeScale::Utc);
1235        let civil = time.compute_civil().unwrap();
1236        assert_eq!(civil.year, -500);
1237    }
1238
1239    #[test]
1240    fn a_leap_second_is_accepted() {
1241        // 60 appears in real UTC timestamps; it must parse rather than fail.
1242        let mut time = Time::new("2016-12-31T23:59:60", TimeFormat::Iso, TimeScale::Utc);
1243        assert!(time.compute_civil().is_ok());
1244    }
1245
1246    #[test]
1247    fn malformed_values_are_errors_not_panics() {
1248        for (value, format) in [
1249            ("not a date", TimeFormat::Iso),
1250            ("2026-13-45", TimeFormat::Iso),
1251            ("2026-01-01T25:00:00", TimeFormat::Iso),
1252            ("not a number", TimeFormat::Unix),
1253            ("", TimeFormat::Iso),
1254            ("2026:400:00:00:00", TimeFormat::Yday),
1255        ] {
1256            let mut time = Time::new(value, format, TimeScale::Utc);
1257            assert!(time.compute_civil().is_err(), "{value:?} as {format:?}");
1258        }
1259    }
1260
1261    #[test]
1262    fn the_reserved_format_is_refused() {
1263        let mut time = Time::new("0", TimeFormat::Reserved1, TimeScale::Utc);
1264        assert!(time.compute_civil().is_err());
1265        assert_eq!(TimeFormat::Reserved1.name(), None);
1266    }
1267
1268    /// matplotlib's ordinal counts days from 0001-01-01 *plus one*, so
1269    /// `1.0` is that date in the proleptic Gregorian calendar.
1270    ///
1271    /// The breakdown reports 0001-01-03 rather than 0001-01-01, and that is
1272    /// correct rather than an off-by-two. Meeus' algorithm -- which libasdf
1273    /// uses too -- switches to the **Julian** calendar for instants before
1274    /// 1582-10-15, and proleptic-Gregorian 0001-01-01 is Julian 0001-01-03.
1275    /// A date after the switch has no such ambiguity, as the `mjd` epoch
1276    /// case above shows.
1277    #[test]
1278    fn plot_date_counts_from_its_own_epoch() {
1279        let mut time = Time::new("1.0", TimeFormat::PlotDate, TimeScale::Utc);
1280        let civil = time.compute_civil().unwrap();
1281        assert_eq!((civil.year, civil.month, civil.day), (1, 1, 3));
1282
1283        // The two conversions are mutual inverses even here, because both
1284        // use the same calendar convention.
1285        let jd = civil_to_julian(&civil);
1286        let back = julian_to_civil(jd);
1287        assert_eq!((back.year, back.month, back.day), (1, 1, 3));
1288    }
1289
1290    /// The two Julian Date conversions must invert each other across the
1291    /// whole range, including either side of the calendar switch.
1292    #[test]
1293    fn julian_conversions_invert_each_other() {
1294        // A sweep of Julian Dates spanning year 1 to well past 2100.
1295        let mut jd = 1_721_400.5;
1296        let mut checked = 0;
1297        while jd < 2_500_000.5 {
1298            let civil = julian_to_civil(jd);
1299            let back = civil_to_julian(&civil);
1300            assert!((back - jd).abs() < 1e-6, "JD {jd} became {civil:?} and back to {back}");
1301
1302            // And the calendar breakdown itself must be self-consistent.
1303            let again = julian_to_civil(back);
1304            assert_eq!(
1305                (again.year, again.month, again.day, again.hour),
1306                (civil.year, civil.month, civil.day, civil.hour),
1307                "JD {jd} did not survive two conversions"
1308            );
1309            checked += 1;
1310            jd += 977.0; // a prime stride, so months and years vary
1311        }
1312        assert!(checked > 700, "expected a wide sweep, got {checked}");
1313    }
1314
1315    /// Dates after the Gregorian switch are unambiguous, which is where the
1316    /// calendar boundary itself can be checked.
1317    #[test]
1318    fn the_gregorian_switch_is_where_meeus_puts_it() {
1319        // 1582-10-15 is the first Gregorian day; JD 2299160.5 is its start.
1320        let civil = julian_to_civil(2299160.5);
1321        assert_eq!((civil.year, civil.month, civil.day), (1582, 10, 15));
1322
1323        // The day before it, in the Julian calendar, is 1582-10-04.
1324        let civil = julian_to_civil(2299159.5);
1325        assert_eq!((civil.year, civil.month, civil.day), (1582, 10, 4));
1326    }
1327}