1use asdf_yaml::{Document, NodeData, NodeId, Resolved};
22
23use crate::error::{Result, err};
24
25#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
29#[repr(i32)]
30pub enum TimeFormat {
31 #[default]
33 Iso = 0,
34 Yday,
36 Byear,
38 Jyear,
40 DecimalYear,
42 Jd,
44 Mjd,
46 Gps,
48 Unix,
50 Utime,
52 TaiSeconds,
54 Cxcsec,
56 Galexsec,
58 UnixTai,
60 Reserved1,
62 ByearStr,
64 Datetime,
66 Fits,
68 Isot,
70 JyearStr,
72 PlotDate,
74 Ymdhms,
76 Datetime64,
78}
79
80#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
82#[repr(i32)]
83pub enum TimeScale {
84 #[default]
86 Utc = 0,
87 Tai,
89 Tcb,
91 Tcg,
93 Tdb,
95 Tt,
97 Ut1,
99}
100
101const 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 pub fn name(self) -> Option<&'static str> {
135 FORMAT_NAMES.get(self as usize).copied().flatten()
136 }
137
138 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 unsafe_transmute_format(index as i32)
150 })
151 }
152
153 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 pub fn is_other(self) -> bool {
173 self.standard() != self
174 }
175}
176
177fn 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 pub fn name(self) -> &'static str {
213 SCALE_NAMES[self as usize]
214 }
215
216 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 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#[derive(Clone, Copy, PartialEq, Debug, Default)]
246pub struct Location {
247 pub longitude: f64,
249 pub latitude: f64,
251 pub height: f64,
253}
254
255#[derive(Clone, Copy, PartialEq, Debug, Default)]
260pub struct Civil {
261 pub year: i32,
263 pub month: u32,
265 pub day: u32,
267 pub hour: u32,
269 pub minute: u32,
271 pub second: u32,
273 pub nanosecond: u32,
275 pub yday: u32,
277 pub wday: u32,
279 pub unix_seconds: i64,
281}
282
283const JD_UNIX_EPOCH: f64 = 2440587.5;
286const JD_MJD: f64 = 2400000.5;
287const JD_J2000: f64 = 2451545.0;
288const JD_B1900: f64 = 2415020.31352;
289const JD_PLOT_DATE_EPOCH: f64 = 1721424.5;
291const JD_GPS_EPOCH: f64 = 2444244.5 + 19.0 / 86400.0;
293const JD_GALEXSEC_EPOCH: f64 = 2444244.5;
295const JD_CXCSEC_EPOCH: f64 = 2450814.5;
297const JD_TAI_SECONDS_EPOCH: f64 = 2436204.5;
299const 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
306fn 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
317fn 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
335fn 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 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 civil.wday = (((days % 7) + 7 + 4) % 7) as u32;
360 civil
361}
362
363pub 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 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 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
405pub 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 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
441fn 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 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 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
473fn 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 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 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 Some(civil_from_unix_seconds(civil.unix_seconds - utc_offset, civil.nanosecond))
545}
546
547fn 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
565pub fn infer_format(value: &str) -> Option<TimeFormat> {
576 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 if matches_iso_shape(value, true) {
597 return Some(TimeFormat::Fits);
598 }
599 None
600}
601
602fn 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 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 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
649fn 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
658fn 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
687fn 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 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
724fn 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
734fn 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#[derive(Clone, PartialEq, Debug)]
743pub struct Time {
744 pub value: String,
746 pub format: TimeFormat,
748 pub scale: TimeScale,
750 pub location: Location,
752 pub civil: Option<Civil>,
754}
755
756impl Time {
757 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 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 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 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 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 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 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 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 TimeFormat::Jd => julian_to_civil(numeric()?),
896 TimeFormat::Mjd => julian_to_civil(numeric()? + JD_MJD),
897
898 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 TimeFormat::PlotDate => julian_to_civil(numeric()? + JD_PLOT_DATE_EPOCH),
919
920 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 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 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 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 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 assert_eq!(days_from_civil(1970, 1, 1), 0);
1033 }
1034
1035 #[test]
1038 fn utc_offsets_are_applied() {
1039 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 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 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 let shifted = east.compute_civil().unwrap();
1054 assert_eq!((shifted.hour, shifted.minute, shifted.second), (10, 56, 15));
1055
1056 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 #[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 #[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 #[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 ("+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 assert_eq!(civil.wday, 4);
1148 assert_eq!(civil.yday, 1);
1149 }
1150
1151 #[test]
1152 fn julian_dates_convert_both_ways() {
1153 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 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 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 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 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 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 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 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 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 #[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 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 #[test]
1293 fn julian_conversions_invert_each_other() {
1294 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 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; }
1312 assert!(checked > 700, "expected a wide sweep, got {checked}");
1313 }
1314
1315 #[test]
1318 fn the_gregorian_switch_is_where_meeus_puts_it() {
1319 let civil = julian_to_civil(2299160.5);
1321 assert_eq!((civil.year, civil.month, civil.day), (1582, 10, 15));
1322
1323 let civil = julian_to_civil(2299159.5);
1325 assert_eq!((civil.year, civil.month, civil.day), (1582, 10, 4));
1326 }
1327}