mako_sgp4/time.rs
1//! Module for datetime representation and calendar conversions used by GP
2//! parsing and SGP4 propagation (UTC Julian / Modified Julian dates).
3
4// ------------------
5// External Libraries
6// ------------------
7
8// ------------------
9// Internal Libraries
10// ------------------
11
12// -------
13// Structs
14// -------
15
16/// A datetime structure
17///
18/// Represents a point in time with year, month, day, hour, minute, second
19/// components, and an associated timezone.
20///
21/// # Examples
22/// ```rust
23/// use mako_sgp4::time::{DateTime, Timezone};
24///
25/// // Define a UTC datetime (required by Julian-date conversions)
26/// let datetime = DateTime {
27/// year: 2000,
28/// month: 1,
29/// day: 1,
30/// hour: 12,
31/// minute: 0,
32/// second: 0.0,
33/// timezone: Timezone::UTC,
34/// };
35///
36/// // Assert the calendar components
37/// assert_eq!(datetime.year, 2000);
38/// assert_eq!(datetime.timezone, Timezone::UTC);
39/// ```
40///
41/// # References
42#[derive(Default, Debug, Clone, PartialEq, Copy)]
43pub struct DateTime {
44 /// The year
45 pub year: i32,
46
47 /// The month (1-12)
48 pub month: i32,
49
50 /// The day of month (1-31)
51 pub day: i32,
52
53 /// The hour (0-23)
54 pub hour: i32,
55
56 /// The minute (0-59)
57 pub minute: i32,
58
59 /// The second with fractional component (0.0-59.999...)
60 pub second: f64,
61
62 /// The timezone associated with this datetime
63 pub timezone: Timezone,
64}
65
66// -----
67// Enums
68// -----
69
70/// Date conversion errors
71///
72/// Errors that can occur during Julian-date and day-of-year conversions.
73///
74/// # Examples
75/// ```rust
76/// use mako_sgp4::time::{DateError, DateTime, Timezone, utc2jday};
77///
78/// // Julian conversion requires UTC
79/// let datetime = DateTime {
80/// year: 2000,
81/// month: 1,
82/// day: 1,
83/// hour: 12,
84/// minute: 0,
85/// second: 0.0,
86/// timezone: Timezone::UT1,
87/// };
88///
89/// // Assert the non-UTC error
90/// let err = utc2jday(&datetime).unwrap_err();
91/// assert_eq!(err, DateError::DateNotUTC);
92/// ```
93///
94/// # References
95#[derive(Debug, Clone, PartialEq)]
96pub enum DateError {
97 /// The provided date is before October 10th, 1582 (Gregorian calendar adoption)
98 DateTooEarly,
99
100 /// The day of year is invalid (less than 1 or greater than 365/366)
101 InvalidDayOfYear,
102
103 /// The datetime is not in UTC
104 DateNotUTC,
105
106 /// The month is outside 1-12 or the day is outside the days in that month
107 InvalidCalendarDate,
108
109 /// The hour, minute, or second is outside its range (0-23, 0-59, 0.0 to less than 61.0)
110 InvalidTimeOfDay,
111}
112
113/// Timezone options for datetime representation
114///
115/// Represents the time scale used for the datetime.
116///
117/// # Examples
118/// ```rust
119/// use mako_sgp4::time::Timezone;
120///
121/// // UTC is the default and the time scale used by GP / SGP4
122/// let tz_utc = Timezone::UTC;
123/// let tz_ut1 = Timezone::UT1;
124///
125/// assert_eq!(tz_utc, Timezone::default());
126/// assert_ne!(tz_utc, tz_ut1);
127/// ```
128///
129/// # References
130#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
131pub enum Timezone {
132 /// Coordinated Universal Time (UTC)
133 ///
134 /// UTC is the primary time standard by which the world regulates clocks and time.
135 /// It is within about 1 second of mean solar time at 0 deg longitude.
136 #[default]
137 UTC,
138
139 /// Universal Time 1 (UT1)
140 ///
141 /// UT1 is a form of Universal Time that is directly related to the rotation of the Earth.
142 /// It is based on the Earth's rotation and is used in astronomical calculations.
143 /// UT1 differs from UTC by up to 0.9 seconds due to variations in Earth's rotation.
144 UT1,
145}
146
147// ------
148// Traits
149// ------
150
151// ---------
152// Constants
153// ---------
154
155// ---------
156// Functions
157// ---------
158
159/// Validate the calendar and clock fields of a datetime
160///
161/// Checks that the month is 1-12, the day exists in that month (Gregorian leap
162/// years), the hour is 0-23, the minute is 0-59, and the second is finite and in
163/// the range 0.0 to less than 61.0 (a UTC leap second may reach 60.999...).
164///
165/// # Arguments
166/// * `datetime` - The datetime as a [`DateTime`] structure
167///
168/// # Returns
169/// * `Ok(())` - If every field is in range
170/// * `Err(DateError)` - If a field is out of range
171///
172/// # Errors
173/// * `DateError::InvalidCalendarDate` - If the month or day is out of range
174/// * `DateError::InvalidTimeOfDay` - If the hour, minute, or second is out of range
175///
176/// # Examples
177/// ```rust
178/// use mako_sgp4::time::{DateError, DateTime, Timezone, validate_datetime};
179///
180/// // February 29th only exists in leap years
181/// let mut datetime = DateTime {
182/// year: 2024,
183/// month: 2,
184/// day: 29,
185/// hour: 23,
186/// minute: 59,
187/// second: 59.5,
188/// timezone: Timezone::UTC,
189/// };
190/// assert!(validate_datetime(&datetime).is_ok());
191///
192/// // Assert 2023-02-29 is rejected
193/// datetime.year = 2023;
194/// assert_eq!(validate_datetime(&datetime), Err(DateError::InvalidCalendarDate));
195/// ```
196///
197/// # References
198/// - [Fundamentals of Astrodynamics and Applications by Vallado et al](https://celestrak.org/software/vallado-sw.php)
199pub fn validate_datetime(datetime: &DateTime) -> Result<(), DateError> {
200 // Validate month and day of month
201 if !(1..=12).contains(&datetime.month)
202 || datetime.day < 1
203 || datetime.day > days_in_month(datetime.year, datetime.month)
204 {
205 return Err(DateError::InvalidCalendarDate);
206 }
207
208 // Validate time of day. Allow second 60 for UTC leap seconds
209 if !(0..=23).contains(&datetime.hour)
210 || !(0..=59).contains(&datetime.minute)
211 || !(0.0..61.0).contains(&datetime.second)
212 {
213 return Err(DateError::InvalidTimeOfDay);
214 }
215
216 Ok(())
217}
218
219/// Check whether a year is a Gregorian leap year
220///
221/// # Arguments
222/// * `year` - The year
223///
224/// # Returns
225/// * `is_leap` - True if the year has 366 days
226fn is_leap_year(year: i32) -> bool {
227 (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
228}
229
230/// Number of days in a month of a given year
231///
232/// # Arguments
233/// * `year` - The year
234/// * `month` - The month (1-12)
235///
236/// # Returns
237/// * `days` - The number of days in the month, or 0 if the month is out of range
238fn days_in_month(year: i32, month: i32) -> i32 {
239 match month {
240 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
241 4 | 6 | 9 | 11 => 30,
242 2 if is_leap_year(year) => 29,
243 2 => 28,
244 _ => 0,
245 }
246}
247
248/// Convert a datetime in UTC to Julian date (JD) format.
249///
250/// The Julian date is a continuous count of days since 4713-01-01 12:00:00 BCE (Julian calendar).
251/// This function converts a UTC datetime to Julian date format, valid for any date after
252/// October 10th, 1582 (Gregorian calendar adoption).
253///
254/// # Arguments
255/// * `utc_datetime` - The datetime as a [`DateTime`] structure (in UTC)
256///
257/// # Returns
258/// * `Ok((jd, jdfrac))` - Julian day (integer part) and day fraction
259/// * `Err(DateError)` - If the datetime is invalid for conversion
260///
261/// # Errors
262/// * `DateError::DateTooEarly` - If the date is before October 10th, 1582
263/// * `DateError::DateNotUTC` - If the datetime is not in UTC
264/// * `DateError::InvalidCalendarDate` - If the month or day is out of range
265/// * `DateError::InvalidTimeOfDay` - If the hour, minute, or second is out of range
266///
267/// # Examples
268/// ```rust
269/// use mako_sgp4::time::utc2jday;
270/// use mako_sgp4::time::{DateTime, Timezone};
271///
272/// // Define J2000.0: 2000-01-01 12:00:00 UTC
273/// let datetime = DateTime {
274/// year: 2000,
275/// month: 1,
276/// day: 1,
277/// hour: 12,
278/// minute: 0,
279/// second: 0.0,
280/// timezone: Timezone::UTC,
281/// };
282///
283/// // Convert to Julian date (integer day and day fraction)
284/// let (jd, jdfrac) = utc2jday(&datetime).unwrap();
285///
286/// // Assert JD 2451545.0
287/// assert!(((jd + jdfrac) - 2_451_545.0).abs() < 1e-8);
288/// assert!((0.0..1.0).contains(&jdfrac));
289/// ```
290///
291/// # References
292/// - [Fundamentals of Astrodynamics and Applications by Vallado et al](https://celestrak.org/software/vallado-sw.php)
293/// - [Satellite Orbits by Montenbruck et al](https://link.springer.com/book/10.1007/978-3-642-58351-3)
294pub fn utc2jday(utc_datetime: &DateTime) -> Result<(f64, f64), DateError> {
295 // Calculate the MJD
296 let (mjd, mjdfrac) = utc2mjday(utc_datetime)?;
297
298 // Modify MJD to be JD
299 let mut jd: f64 = mjd + 2400000.5;
300 let mut jdfrac: f64 = mjdfrac;
301
302 // Make JD whole
303 if !(0.0..1.0).contains(&jdfrac) {
304 jd += jdfrac.floor();
305 jdfrac = jdfrac - jdfrac.floor();
306 }
307
308 Ok((jd, jdfrac))
309}
310
311/// Convert a datetime in UTC to Modified Julian date (MJD) format.
312///
313/// The Modified Julian date is a continuous count of days since 1858-11-17 00:00:00 CE.
314/// MJD is related to Julian Date (JD) by: MJD = JD - 2400000.5
315/// This function is valid for any date after October 10th, 1582 (Gregorian calendar adoption).
316///
317/// # Arguments
318/// * `utc_datetime` - The datetime as a [`DateTime`] structure (in UTC)
319///
320/// # Returns
321/// * `Ok((mjd, mjdfrac))` - Modified Julian day (integer part) and day fraction
322/// * `Err(DateError)` - If the datetime is invalid for conversion
323///
324/// # Errors
325/// * `DateError::DateTooEarly` - If the date is before October 10th, 1582
326/// * `DateError::DateNotUTC` - If the datetime is not in UTC
327/// * `DateError::InvalidCalendarDate` - If the month or day is out of range
328/// * `DateError::InvalidTimeOfDay` - If the hour, minute, or second is out of range
329///
330/// # Examples
331/// ```rust
332/// use mako_sgp4::time::utc2mjday;
333/// use mako_sgp4::time::{DateTime, Timezone};
334///
335/// // Define J2000.0: 2000-01-01 12:00:00 UTC
336/// let datetime = DateTime {
337/// year: 2000,
338/// month: 1,
339/// day: 1,
340/// hour: 12,
341/// minute: 0,
342/// second: 0.0,
343/// timezone: Timezone::UTC,
344/// };
345///
346/// // Convert to Modified Julian date (MJD = JD - 2400000.5)
347/// let (mjd, mjdfrac) = utc2mjday(&datetime).unwrap();
348///
349/// // Assert MJD 51544.5
350/// assert!(((mjd + mjdfrac) - 51_544.5).abs() < 1e-8);
351/// assert!((0.0..1.0).contains(&mjdfrac));
352/// ```
353///
354/// # References
355/// - [Fundamentals of Astrodynamics and Applications by Vallado et al](https://celestrak.org/software/vallado-sw.php)
356/// - [Satellite Orbits by Montenbruck et al](https://link.springer.com/book/10.1007/978-3-642-58351-3)
357pub fn utc2mjday(utc_datetime: &DateTime) -> Result<(f64, f64), DateError> {
358 // Verify that datetime is UTC
359 if utc_datetime.timezone != Timezone::UTC {
360 return Err(DateError::DateNotUTC);
361 }
362
363 // Verify calendar and clock fields are in range
364 validate_datetime(utc_datetime)?;
365
366 // Verify date is after Oct 10th, 1582
367 if utc_datetime.year < 1582
368 || (utc_datetime.year == 1582 && utc_datetime.month < 10)
369 || (utc_datetime.year == 1582 && utc_datetime.month == 10 && utc_datetime.day < 10)
370 {
371 return Err(DateError::DateTooEarly);
372 }
373
374 // Cast inputs as f64
375 let year = utc_datetime.year as f64;
376 let month = utc_datetime.month as f64;
377 let day = utc_datetime.day as f64;
378 let hour = utc_datetime.hour as f64;
379 let minute = utc_datetime.minute as f64;
380 let second = utc_datetime.second;
381
382 // Modify month and year to account for leap years, start year in March instead of January
383 let year_leap: f64;
384 let month_leap: f64;
385 if month <= 2. {
386 year_leap = year - 1.;
387 month_leap = month + 12.;
388 } else {
389 year_leap = year;
390 month_leap = month;
391 }
392
393 // Account for leap days with B auxilary quantity
394 let b_leap: f64 =
395 (year_leap / 400.).floor() - (year_leap / 100.).floor() + (year_leap / 4.).floor();
396
397 // Calculate the modified Julian date
398 let mut mjd = 365. * year_leap - 679004. + b_leap + (30.6001 * (month_leap + 1.)).floor() + day;
399 let mut mjdfrac = (second + minute * 60. + hour * 3600.) / 86400.;
400
401 // Validate mjdfrac
402 if !(0.0..1.0).contains(&mjdfrac) {
403 mjd += mjdfrac.floor();
404 mjdfrac = mjdfrac - mjdfrac.floor();
405 }
406
407 Ok((mjd, mjdfrac))
408}
409
410/// Convert a year and day of year to a UTC datetime
411///
412/// Converts a year and day of year (with fractional day) into a full UTC datetime.
413/// The day of year is 1-based (1 = January 1st, 365/366 = December 31st).
414///
415/// # Arguments
416/// * `year` - The year
417/// * `dayofyr` - The day of year with fractional component (e.g., 123.5 = day 123 at 12:00:00 UTC)
418///
419/// # Returns
420/// * `Ok(DateTime)` - The datetime as a [`DateTime`] structure (in UTC)
421/// * `Err(DateError)` - If the day of year is out of range
422///
423/// # Errors
424/// * `DateError::InvalidDayOfYear` - If the day of year is not finite, is less than 1, or exceeds the number of days in the year
425///
426/// # Examples
427/// ```rust
428/// use mako_sgp4::time::{Timezone, dayofyr2utc};
429///
430/// // Define a TLE-style epoch: day 123.5 of 2024 is May 2 at 12:00:00 UTC
431/// let datetime = dayofyr2utc(2024, 123.5).unwrap();
432///
433/// // Assert the calendar date, noon, and UTC
434/// assert_eq!(datetime.year, 2024);
435/// assert_eq!(datetime.month, 5);
436/// assert_eq!(datetime.day, 2);
437/// assert_eq!(datetime.hour, 12);
438/// assert_eq!(datetime.timezone, Timezone::UTC);
439/// ```
440///
441/// # References
442/// - [Fundamentals of Astrodynamics and Applications by Vallado et al](https://celestrak.org/software/vallado-sw.php)
443/// - [Satellite Orbits by Montenbruck et al](https://link.springer.com/book/10.1007/978-3-642-58351-3)
444pub fn dayofyr2utc(year: i32, dayofyr: f64) -> Result<DateTime, DateError> {
445 // Validate day of year is finite and positive
446 if !dayofyr.is_finite() || dayofyr < 1.0 {
447 return Err(DateError::InvalidDayOfYear);
448 }
449
450 // Check for leap year
451 let is_leap = is_leap_year(year);
452
453 // Days per month (non-leap year)
454 let days_per_month = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
455
456 // Adjust February for leap year
457 let max_days = if is_leap { 366 } else { 365 };
458
459 // Extract integer and fractional parts
460 let day_int = dayofyr.floor() as i32;
461
462 // Validate day of year doesn't exceed days in year (check integer part)
463 if day_int > max_days {
464 return Err(DateError::InvalidDayOfYear);
465 }
466 let day_frac = dayofyr - day_int as f64;
467
468 // Find which month the day falls in and calculate day of month
469 let mut day_count = 0;
470 let mut month = 1;
471 let mut day = 1;
472
473 for (idx, &days_in_month) in days_per_month.iter().enumerate() {
474 let days_this_month = if idx == 1 && is_leap {
475 29 // February in leap year
476 } else {
477 days_in_month
478 };
479
480 if day_int <= day_count + days_this_month {
481 month = (idx + 1) as i32;
482 day = day_int - day_count;
483 break;
484 }
485 day_count += days_this_month;
486 }
487
488 // Convert fractional day to hours, minutes, seconds
489 let total_seconds = day_frac * 86400.0;
490 let hour = (total_seconds / 3600.0).floor() as i32;
491 let remaining_seconds = total_seconds - (hour as f64 * 3600.0);
492 let minute = (remaining_seconds / 60.0).floor() as i32;
493 let second = remaining_seconds - (minute as f64 * 60.0);
494
495 // Protect against rounding errors and handle overflow
496 let mut final_second = second;
497 let mut final_minute = minute;
498 let mut final_hour = hour;
499 let mut final_day = day;
500 let mut final_month = month;
501 let mut final_year = year;
502
503 // Handle second overflow
504 if final_second >= 60.0 {
505 final_second -= 60.0;
506 final_minute += 1;
507 }
508
509 // Handle minute overflow
510 if final_minute >= 60 {
511 final_minute -= 60;
512 final_hour += 1;
513 }
514
515 // Handle hour overflow
516 if final_hour >= 24 {
517 final_hour -= 24;
518 final_day += 1;
519 }
520
521 // Handle day overflow (check if day exceeds days in current month)
522 let days_in_current_month = if final_month == 2 && is_leap {
523 29 // February in leap year
524 } else {
525 days_per_month[(final_month - 1) as usize]
526 };
527
528 if final_day > days_in_current_month {
529 final_day -= days_in_current_month;
530 final_month += 1;
531 }
532
533 // Handle month overflow
534 if final_month > 12 {
535 final_month -= 12;
536 final_year += 1;
537 }
538
539 // Store as DateTime
540 let datetime = DateTime {
541 year: final_year,
542 month: final_month,
543 day: final_day,
544 hour: final_hour,
545 minute: final_minute,
546 second: final_second,
547 timezone: Timezone::UTC,
548 };
549
550 Ok(datetime)
551}
552
553// ----------
554// Unit Tests
555// ----------
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560
561 /// Validate calendar and clock ranges
562 ///
563 /// Covers leap years (including the century rule), month and day bounds,
564 /// time-of-day bounds, leap seconds, non-finite seconds, and non-finite day of year.
565 ///
566 /// # Panics
567 /// * If a field is accepted or rejected incorrectly
568 #[test]
569 fn test_validate_datetime() {
570 let valid = DateTime {
571 year: 2024,
572 month: 2,
573 day: 29,
574 hour: 23,
575 minute: 59,
576 second: 60.5,
577 timezone: Timezone::UTC,
578 };
579 assert_eq!(validate_datetime(&valid), Ok(()));
580
581 // Century rule: 1900 is not a leap year, 2000 is
582 let cases = [
583 (
584 DateTime {
585 year: 1900,
586 ..valid
587 },
588 Err(DateError::InvalidCalendarDate),
589 ),
590 (
591 DateTime {
592 year: 2000,
593 ..valid
594 },
595 Ok(()),
596 ),
597 (
598 DateTime { month: 0, ..valid },
599 Err(DateError::InvalidCalendarDate),
600 ),
601 (
602 DateTime { month: 13, ..valid },
603 Err(DateError::InvalidCalendarDate),
604 ),
605 (
606 DateTime {
607 month: 4,
608 day: 31,
609 ..valid
610 },
611 Err(DateError::InvalidCalendarDate),
612 ),
613 (
614 DateTime { day: 0, ..valid },
615 Err(DateError::InvalidCalendarDate),
616 ),
617 (
618 DateTime { hour: -1, ..valid },
619 Err(DateError::InvalidTimeOfDay),
620 ),
621 (
622 DateTime { hour: 24, ..valid },
623 Err(DateError::InvalidTimeOfDay),
624 ),
625 (
626 DateTime {
627 minute: 60,
628 ..valid
629 },
630 Err(DateError::InvalidTimeOfDay),
631 ),
632 (
633 DateTime {
634 second: -0.1,
635 ..valid
636 },
637 Err(DateError::InvalidTimeOfDay),
638 ),
639 (
640 DateTime {
641 second: 61.0,
642 ..valid
643 },
644 Err(DateError::InvalidTimeOfDay),
645 ),
646 (
647 DateTime {
648 second: f64::NAN,
649 ..valid
650 },
651 Err(DateError::InvalidTimeOfDay),
652 ),
653 ];
654 for (datetime, expected) in cases {
655 assert_eq!(validate_datetime(&datetime), expected, "{datetime:?}");
656 }
657
658 // Julian-date conversion rejects out-of-range fields
659 assert_eq!(
660 utc2jday(&DateTime { month: 13, ..valid }),
661 Err(DateError::InvalidCalendarDate)
662 );
663
664 // Day of year must be finite
665 assert_eq!(
666 dayofyr2utc(2024, f64::NAN),
667 Err(DateError::InvalidDayOfYear)
668 );
669 assert_eq!(
670 dayofyr2utc(2024, f64::INFINITY),
671 Err(DateError::InvalidDayOfYear)
672 );
673 }
674
675 /// Convert known UTC datetimes to Julian dates
676 ///
677 /// Covers a 20th century date, a 21st century date, the year 2000, a date
678 /// before Gregorian adoption, and a non-UTC timezone.
679 ///
680 /// # Panics
681 /// * If a conversion misses the expected Julian date or the expected error
682 #[test]
683 fn test_utc2jday() {
684 // Make a test date in 20th century
685 let datetime1 = DateTime {
686 year: 1959,
687 month: 3,
688 day: 25,
689 hour: 12,
690 minute: 34,
691 second: 49.123,
692 timezone: Timezone::UTC,
693 };
694
695 let (jd1, jdfrac1) = utc2jday(&datetime1).unwrap();
696 let jd1_total = jd1 + jdfrac1;
697 let jd1_expect = 2_436_653.024_179_664_4;
698 assert!(
699 (jd1_total - jd1_expect).abs() < 1e-8,
700 "Julian Date Test failed: expected {}, got {}",
701 jd1_expect,
702 jd1_total
703 );
704
705 // Make a test date in 21st century
706 let datetime2 = DateTime {
707 year: 2026,
708 month: 1,
709 day: 23,
710 hour: 0,
711 minute: 10,
712 second: 32.999,
713 timezone: Timezone::UTC,
714 };
715
716 let (jd2, jdfrac2) = utc2jday(&datetime2).unwrap();
717 let jd2_total = jd2 + jdfrac2;
718 let jd2_expect = 2_461_063.507_326_377;
719 assert!(
720 (jd2_total - jd2_expect).abs() < 1e-8,
721 "Julian Date Test failed: expected {}, got {}",
722 jd2_expect,
723 jd2_total
724 );
725
726 // Make a test date around year 2000
727 let datetime3 = DateTime {
728 year: 2000,
729 month: 1,
730 day: 1,
731 hour: 0,
732 minute: 0,
733 second: 0.0,
734 timezone: Timezone::UTC,
735 };
736
737 let (jd3, jdfrac3) = utc2jday(&datetime3).unwrap();
738 let jd3_total = jd3 + jdfrac3;
739 let jd3_expect = 2451544.5;
740 assert!(
741 (jd3_total - jd3_expect).abs() < 1e-8,
742 "Julian Date Test failed: expected {}, got {}",
743 jd3_expect,
744 jd3_total
745 );
746
747 // Test date too early (before Oct 10, 1582)
748 let datetime4 = DateTime {
749 year: 1582,
750 month: 10,
751 day: 9,
752 hour: 12,
753 minute: 0,
754 second: 0.0,
755 timezone: Timezone::UTC,
756 };
757
758 let result = utc2jday(&datetime4);
759 assert!(
760 result.is_err(),
761 "Should return error for date before Oct 10, 1582"
762 );
763 assert_eq!(result.unwrap_err(), DateError::DateTooEarly);
764
765 // Test non-UTC datetime
766 let datetime5 = DateTime {
767 year: 1990,
768 month: 10,
769 day: 9,
770 hour: 12,
771 minute: 0,
772 second: 0.0,
773 timezone: Timezone::UT1,
774 };
775
776 let result = utc2jday(&datetime5);
777 assert!(result.is_err(), "Should return error for non-UTC date");
778 assert_eq!(result.unwrap_err(), DateError::DateNotUTC);
779 }
780
781 /// Convert known UTC datetimes to Modified Julian dates
782 ///
783 /// Covers a 20th century date, a 21st century date, the year 2000, a date
784 /// before Gregorian adoption, and a non-UTC timezone.
785 ///
786 /// # Panics
787 /// * If a conversion misses the expected Modified Julian date or the expected error
788 #[test]
789 fn test_utc2mjday() {
790 // Make a test date in 20th century
791 let datetime1 = DateTime {
792 year: 1997,
793 month: 4,
794 day: 2,
795 hour: 16,
796 minute: 12,
797 second: 35.505,
798 timezone: Timezone::UTC,
799 };
800
801 let (mjd1, mjdfrac1) = utc2mjday(&datetime1).unwrap();
802 let mjd1_total = mjd1 + mjdfrac1;
803 let mjd1_expect = 50_540.675_410_937_5;
804 assert!(
805 (mjd1_total - mjd1_expect).abs() < 1e-8,
806 "Modified Julian Date Test failed: expected {}, got {}",
807 mjd1_expect,
808 mjd1_total
809 );
810
811 // Make a test date in 21st century
812 let datetime2 = DateTime {
813 year: 2013,
814 month: 8,
815 day: 12,
816 hour: 2,
817 minute: 49,
818 second: 57.623,
819 timezone: Timezone::UTC,
820 };
821
822 let (mjd2, mjdfrac2) = utc2mjday(&datetime2).unwrap();
823 let mjd2_total = mjd2 + mjdfrac2;
824 let mjd2_expect = 56_516.118_028_043_97;
825 assert!(
826 (mjd2_total - mjd2_expect).abs() < 1e-8,
827 "Modified Julian Date Test failed: expected {}, got {}",
828 mjd2_expect,
829 mjd2_total
830 );
831
832 // Make a test date around year 2000
833 let datetime3 = DateTime {
834 year: 2000,
835 month: 1,
836 day: 1,
837 hour: 0,
838 minute: 0,
839 second: 0.0,
840 timezone: Timezone::UTC,
841 };
842
843 let (mjd3, mjdfrac3) = utc2mjday(&datetime3).unwrap();
844 let mjd3_total = mjd3 + mjdfrac3;
845 let mjd3_expect = 51544.0;
846 assert!(
847 (mjd3_total - mjd3_expect).abs() < 1e-8,
848 "Modified Julian Date Test failed: expected {}, got {}",
849 mjd3_expect,
850 mjd3_total
851 );
852
853 // Test date too early (before Oct 10, 1582)
854 let datetime4 = DateTime {
855 year: 1582,
856 month: 10,
857 day: 9,
858 hour: 12,
859 minute: 0,
860 second: 0.0,
861 timezone: Timezone::UTC,
862 };
863 let result = utc2mjday(&datetime4);
864 assert!(
865 result.is_err(),
866 "Should return error for date before Oct 10, 1582"
867 );
868 assert_eq!(result.unwrap_err(), DateError::DateTooEarly);
869
870 // Test non-UTC datetime
871 let datetime5 = DateTime {
872 year: 1990,
873 month: 10,
874 day: 9,
875 hour: 12,
876 minute: 0,
877 second: 0.0,
878 timezone: Timezone::UT1,
879 };
880
881 let result = utc2mjday(&datetime5);
882 assert!(result.is_err(), "Should return error for non-UTC date");
883 assert_eq!(result.unwrap_err(), DateError::DateNotUTC);
884 }
885
886 /// Convert a year and day-of-year into a UTC datetime
887 ///
888 /// Checks a fractional day, a time near the next year, and day 366 of a leap year.
889 ///
890 /// # Panics
891 /// * If a converted calendar field is wrong
892 #[test]
893 fn test_dayofyr_rounding() {
894 // Test date in 20th century - Day 100.5 of 1959 (April 10, 1959 at 12:00:00)
895 let datetime1 = dayofyr2utc(1959, 100.5).unwrap();
896 assert_eq!(
897 datetime1.year, 1959,
898 "Day 100.5 of 1959: year should be 1959, got {}",
899 datetime1.year
900 );
901 assert_eq!(
902 datetime1.month, 4,
903 "Day 100.5 of 1959: month should be 4 (April), got {}",
904 datetime1.month
905 );
906 assert_eq!(
907 datetime1.day, 10,
908 "Day 100.5 of 1959: day should be 10, got {}",
909 datetime1.day
910 );
911 assert_eq!(
912 datetime1.hour, 12,
913 "Day 100.5 of 1959: hour should be 12, got {}",
914 datetime1.hour
915 );
916 assert_eq!(
917 datetime1.minute, 0,
918 "Day 100.5 of 1959: minute should be 0, got {}",
919 datetime1.minute
920 );
921 assert!(
922 (datetime1.second - 0.0).abs() < 1e-6,
923 "Day 100.5 of 1959: second should be 0.0, got {}",
924 datetime1.second
925 );
926
927 // Test date in 21st century - Day 200.75 of 2024 (July 18, 2024 at 18:00:00)
928 let datetime2 = dayofyr2utc(2024, 200.75).unwrap();
929 assert_eq!(
930 datetime2.year, 2024,
931 "Day 200.75 of 2024: year should be 2024, got {}",
932 datetime2.year
933 );
934 assert_eq!(
935 datetime2.month, 7,
936 "Day 200.75 of 2024: month should be 7 (July), got {}",
937 datetime2.month
938 );
939 assert_eq!(
940 datetime2.day, 18,
941 "Day 200.75 of 2024: day should be 18, got {}",
942 datetime2.day
943 );
944 assert_eq!(
945 datetime2.hour, 18,
946 "Day 200.75 of 2024: hour should be 18, got {}",
947 datetime2.hour
948 );
949 assert_eq!(
950 datetime2.minute, 0,
951 "Day 200.75 of 2024: minute should be 0, got {}",
952 datetime2.minute
953 );
954 assert!(
955 (datetime2.second - 0.0).abs() < 1e-6,
956 "Day 200.75 of 2024: second should be 0.0, got {}",
957 datetime2.second
958 );
959
960 // Test year rollover - Day 365.9999999999 of 2023 (very close to midnight of 2024)
961 let datetime3 = dayofyr2utc(2023, 365.9999999999).unwrap();
962 assert_eq!(
963 datetime3.year, 2023,
964 "Day 365.9999999999 of 2023: year should be 2023, got {}",
965 datetime3.year
966 );
967 assert_eq!(
968 datetime3.month, 12,
969 "Day 365.9999999999 of 2023: month should be 12 (December), got {}",
970 datetime3.month
971 );
972 assert_eq!(
973 datetime3.day, 31,
974 "Day 365.9999999999 of 2023: day should be 31, got {}",
975 datetime3.day
976 );
977 assert_eq!(
978 datetime3.hour, 23,
979 "Day 365.9999999999 of 2023: hour should be 23, got {}",
980 datetime3.hour
981 );
982 assert_eq!(
983 datetime3.minute, 59,
984 "Day 365.9999999999 of 2023: minute should be 59, got {}",
985 datetime3.minute
986 );
987 assert!(
988 datetime3.second >= 59.0 && datetime3.second < 60.0,
989 "Day 365.9999999999 of 2023: second should be between 59.0 and 60.0, got {}",
990 datetime3.second
991 );
992
993 // Test leap year day of year - Day 366 of 2024 (December 31, 2024)
994 let datetime4 = dayofyr2utc(2024, 366.0).unwrap();
995 assert_eq!(
996 datetime4.year, 2024,
997 "Day 366 of 2024: year should be 2024, got {}",
998 datetime4.year
999 );
1000 assert_eq!(
1001 datetime4.month, 12,
1002 "Day 366 of 2024: month should be 12 (December), got {}",
1003 datetime4.month
1004 );
1005 assert_eq!(
1006 datetime4.day, 31,
1007 "Day 366 of 2024: day should be 31, got {}",
1008 datetime4.day
1009 );
1010 assert_eq!(
1011 datetime4.hour, 0,
1012 "Day 366 of 2024: hour should be 0, got {}",
1013 datetime4.hour
1014 );
1015 assert_eq!(
1016 datetime4.minute, 0,
1017 "Day 366 of 2024: minute should be 0, got {}",
1018 datetime4.minute
1019 );
1020 assert!(
1021 (datetime4.second - 0.0).abs() < 1e-6,
1022 "Day 366 of 2024: second should be 0.0, got {}",
1023 datetime4.second
1024 );
1025
1026 // Test leap year with fractional day - Day 60.5 of 2024 (February 29, 2024 at 12:00:00)
1027 let datetime5 = dayofyr2utc(2024, 60.5).unwrap();
1028 assert_eq!(
1029 datetime5.year, 2024,
1030 "Day 60.5 of 2024: year should be 2024, got {}",
1031 datetime5.year
1032 );
1033 assert_eq!(
1034 datetime5.month, 2,
1035 "Day 60.5 of 2024: month should be 2 (February), got {}",
1036 datetime5.month
1037 );
1038 assert_eq!(
1039 datetime5.day, 29,
1040 "Day 60.5 of 2024: day should be 29 (leap day), got {}",
1041 datetime5.day
1042 );
1043 assert_eq!(
1044 datetime5.hour, 12,
1045 "Day 60.5 of 2024: hour should be 12, got {}",
1046 datetime5.hour
1047 );
1048 assert_eq!(
1049 datetime5.minute, 0,
1050 "Day 60.5 of 2024: minute should be 0, got {}",
1051 datetime5.minute
1052 );
1053 assert!(
1054 (datetime5.second - 0.0).abs() < 1e-6,
1055 "Day 60.5 of 2024: second should be 0.0, got {}",
1056 datetime5.second
1057 );
1058
1059 // Test day of year too high - Day 366.1 of non-leap year 2023
1060 let result = dayofyr2utc(2023, 366.1);
1061 assert!(
1062 result.is_err(),
1063 "Day 366.1 of 2023 (non-leap year): should return error for day of year exceeding 365, got Ok({:?})",
1064 result
1065 );
1066 assert_eq!(
1067 result.unwrap_err(),
1068 DateError::InvalidDayOfYear,
1069 "Day 366.1 of 2023: error should be InvalidDayOfYear"
1070 );
1071
1072 // Test day of year too high - Day 367 of leap year 2024
1073 let result = dayofyr2utc(2024, 367.0);
1074 assert!(
1075 result.is_err(),
1076 "Day 367 of 2024 (leap year): should return error for day of year exceeding 366, got Ok({:?})",
1077 result
1078 );
1079 assert_eq!(
1080 result.unwrap_err(),
1081 DateError::InvalidDayOfYear,
1082 "Day 367 of 2024: error should be InvalidDayOfYear"
1083 );
1084
1085 // Test day of year too low - Day 0.5
1086 let result = dayofyr2utc(2024, 0.5);
1087 assert!(
1088 result.is_err(),
1089 "Day 0.5 of 2024: should return error for day of year less than 1, got Ok({:?})",
1090 result
1091 );
1092 assert_eq!(
1093 result.unwrap_err(),
1094 DateError::InvalidDayOfYear,
1095 "Day 0.5 of 2024: error should be InvalidDayOfYear"
1096 );
1097
1098 // Test day of year too low - Day 0.0
1099 let result = dayofyr2utc(2024, 0.0);
1100 assert!(
1101 result.is_err(),
1102 "Day 0.0 of 2024: should return error for day of year equal to 0, got Ok({:?})",
1103 result
1104 );
1105 assert_eq!(
1106 result.unwrap_err(),
1107 DateError::InvalidDayOfYear,
1108 "Day 0.0 of 2024: error should be InvalidDayOfYear"
1109 );
1110 }
1111}