Skip to main content

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}