sidereon_core/reduced_orbit/time.rs
1//! Time-scale bridge for reduced-orbit fitting/evaluation.
2
3use crate::astro::time::civil;
4use crate::astro::time::model::TimeScale;
5use crate::astro::time::scales::TimeScales;
6
7/// A UTC calendar instant `(year, month, day, hour, minute, second)`, the form
8/// the core [`TimeScales::from_utc`] consumes. The Elixir layer produces these
9/// from each sample/query epoch; no `Instant`->`TimeScales` bridge exists in the
10/// core crate, so the calendar tuple is carried explicitly to the boundary.
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct CalendarEpoch {
13 /// Calendar year.
14 pub year: i32,
15 /// Calendar month, 1-12.
16 pub month: i32,
17 /// Calendar day of month, 1-31.
18 pub day: i32,
19 /// Hour of day, 0-23.
20 pub hour: i32,
21 /// Minute of hour, 0-59.
22 pub minute: i32,
23 /// Second of minute, fractional.
24 pub second: f64,
25}
26
27impl CalendarEpoch {
28 /// Construct a calendar epoch from its components.
29 pub const fn new(year: i32, month: i32, day: i32, hour: i32, minute: i32, second: f64) -> Self {
30 Self {
31 year,
32 month,
33 day,
34 hour,
35 minute,
36 second,
37 }
38 }
39
40 /// Build the core [`TimeScales`] for this instant, interpreted in `scale`.
41 ///
42 /// Delegates to the canonical [`TimeScales::from_scale`]: non-UTC scales are
43 /// converted to the UTC calendar label before the Skyfield split is built,
44 /// so the Earth orientation used by the frame transforms is correct rather
45 /// than offset by the scale's leap-second gap.
46 pub(crate) fn time_scales(self, scale: TimeScale) -> TimeScales {
47 TimeScales::from_scale(
48 scale,
49 self.year,
50 self.month,
51 self.day,
52 self.hour,
53 self.minute,
54 self.second,
55 )
56 .expect("calendar epoch has a finite second")
57 }
58}
59
60/// Seconds between two calendar epochs via their J2000-TT split day numbers.
61pub(crate) fn dt_seconds(t0: &TimeScales, t: &TimeScales) -> f64 {
62 civil::seconds_between_splits(t.jd_whole, t.tt_fraction, t0.jd_whole, t0.tt_fraction)
63}
64
65#[cfg(test)]
66mod tests {
67 use super::*;
68
69 /// GLONASST = UTC(SU) + 3 h: a GLONASST calendar instant resolves to the
70 /// same TT scales as the UTC instant three hours earlier (no leap term in
71 /// the 3 h shift).
72 #[test]
73 fn glonasst_resolves_as_utc_plus_three_hours() {
74 // 2020-06-15 03:00:00 GLONASST == 2020-06-15 00:00:00 UTC.
75 let glo = CalendarEpoch::new(2020, 6, 15, 3, 0, 0.0).time_scales(TimeScale::Glonasst);
76 let utc = CalendarEpoch::new(2020, 6, 15, 0, 0, 0.0).time_scales(TimeScale::Utc);
77 assert_eq!(glo, utc);
78 }
79
80 /// QZSST is synchronous with GPST, so a QZSST calendar instant resolves to
81 /// the same scales as the identically-labelled GPST instant.
82 #[test]
83 fn qzsst_resolves_identically_to_gpst() {
84 let qzs = CalendarEpoch::new(2020, 6, 15, 12, 0, 0.0).time_scales(TimeScale::Qzsst);
85 let gps = CalendarEpoch::new(2020, 6, 15, 12, 0, 0.0).time_scales(TimeScale::Gpst);
86 assert_eq!(qzs, gps);
87 }
88
89 /// The 3 h GLONASST->UTC shift correctly crosses the day/year boundary
90 /// around the 2017 leap. (Inputs are regular seconds; positive-leap `:60`
91 /// labels in GLONASST are not a bridge input - the leap-aware reasoning lives
92 /// in the offset helpers, which key off the UTC leap table.)
93 #[test]
94 fn glonasst_three_hour_shift_crosses_2017_boundary() {
95 // 2017-01-01 03:00:00 GLONASST == 2017-01-01 00:00:00 UTC (post-leap).
96 let post = CalendarEpoch::new(2017, 1, 1, 3, 0, 0.0).time_scales(TimeScale::Glonasst);
97 let post_utc = CalendarEpoch::new(2017, 1, 1, 0, 0, 0.0).time_scales(TimeScale::Utc);
98 assert_eq!(post, post_utc);
99
100 // 2017-01-01 02:59:59 GLONASST == 2016-12-31 23:59:59 UTC (pre-leap).
101 let pre = CalendarEpoch::new(2017, 1, 1, 2, 59, 59.0).time_scales(TimeScale::Glonasst);
102 let pre_utc = CalendarEpoch::new(2016, 12, 31, 23, 59, 59.0).time_scales(TimeScale::Utc);
103 assert_eq!(pre, pre_utc);
104 }
105}