Skip to main content

sidereon_core/reduced_orbit/
time.rs

1//! Time-scale bridge for reduced-orbit fitting/evaluation.
2
3use crate::astro::constants::time::{SECONDS_PER_DAY, TT_MINUS_TAI_S};
4use crate::astro::time::model::TimeScale;
5use crate::astro::time::scales::{find_leap_seconds, julian_day_number, TimeScales};
6
7use crate::constants::{BDT_MINUS_TAI_S, GPST_MINUS_TAI_S};
8
9/// A UTC calendar instant `(year, month, day, hour, minute, second)`, the form
10/// the core [`TimeScales::from_utc`] consumes. The Elixir layer produces these
11/// from each sample/query epoch; no `Instant`->`TimeScales` bridge exists in the
12/// core crate, so the calendar tuple is carried explicitly to the boundary.
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub struct CalendarEpoch {
15    /// Calendar year.
16    pub year: i32,
17    /// Calendar month, 1-12.
18    pub month: i32,
19    /// Calendar day of month, 1-31.
20    pub day: i32,
21    /// Hour of day, 0-23.
22    pub hour: i32,
23    /// Minute of hour, 0-59.
24    pub minute: i32,
25    /// Second of minute, fractional.
26    pub second: f64,
27}
28
29impl CalendarEpoch {
30    /// Construct a calendar epoch from its components.
31    pub const fn new(year: i32, month: i32, day: i32, hour: i32, minute: i32, second: f64) -> Self {
32        Self {
33            year,
34            month,
35            day,
36            hour,
37            minute,
38            second,
39        }
40    }
41
42    /// Build the core [`TimeScales`] for this instant, interpreted in `scale`.
43    ///
44    /// Non-UTC scales (GPST/GST/BDT/TAI/TT) are converted to UTC before the
45    /// Skyfield split is built, so the Earth orientation used by the frame
46    /// transforms is correct rather than offset by the scale's leap-second gap.
47    pub(crate) fn time_scales(self, scale: TimeScale) -> TimeScales {
48        let utc = scale_calendar_to_utc(self, scale);
49        TimeScales::from_utc(
50            utc.year, utc.month, utc.day, utc.hour, utc.minute, utc.second,
51        )
52        .expect("calendar epoch has a finite second")
53    }
54}
55
56fn scale_calendar_to_utc(cal: CalendarEpoch, scale: TimeScale) -> CalendarEpoch {
57    if matches!(scale, TimeScale::Utc) {
58        return cal;
59    }
60    let tai = normalize_calendar_seconds(cal, cal.second + tai_minus_scale_seconds(scale));
61    tai_calendar_to_utc(tai)
62}
63
64fn tai_minus_scale_seconds(scale: TimeScale) -> f64 {
65    match scale {
66        TimeScale::Utc => 0.0,
67        TimeScale::Tai => 0.0,
68        TimeScale::Tt | TimeScale::Tdb => -TT_MINUS_TAI_S,
69        TimeScale::Gpst | TimeScale::Gst => GPST_MINUS_TAI_S,
70        TimeScale::Bdt => BDT_MINUS_TAI_S,
71    }
72}
73
74fn tai_calendar_to_utc(tai: CalendarEpoch) -> CalendarEpoch {
75    if let Some(utc) = positive_leap_second_utc_label(tai) {
76        return utc;
77    }
78
79    let mut leap = leap_seconds_at_utc_label(tai);
80    let mut utc = normalize_calendar_seconds(tai, tai.second - leap);
81    for _ in 0..3 {
82        let next_leap = leap_seconds_at_utc_label(utc);
83        if next_leap == leap {
84            return utc;
85        }
86        leap = next_leap;
87        utc = normalize_calendar_seconds(tai, tai.second - leap);
88    }
89    utc
90}
91
92fn positive_leap_second_utc_label(tai: CalendarEpoch) -> Option<CalendarEpoch> {
93    let tai_sod = seconds_of_day(tai);
94    let utc_midnight = CalendarEpoch::new(tai.year, tai.month, tai.day, 0, 0, 0.0);
95    let previous_second = normalize_calendar_seconds(utc_midnight, -1.0);
96    let old_leap = leap_seconds_at_utc_label(previous_second);
97    let new_leap = leap_seconds_at_utc_label(utc_midnight);
98    if new_leap <= old_leap || !(old_leap..new_leap).contains(&tai_sod) {
99        return None;
100    }
101
102    let mut utc = previous_second;
103    utc.second = 60.0 + (tai_sod - old_leap);
104    Some(utc)
105}
106
107fn leap_seconds_at_utc_label(cal: CalendarEpoch) -> f64 {
108    let jd1 = julian_day_number(cal.year, cal.month, cal.day) as f64 - 0.5;
109    let lookup_second = if cal.second >= 60.0 { 59.0 } else { cal.second };
110    let jd2 =
111        (cal.hour as f64 * 3600.0 + cal.minute as f64 * 60.0 + lookup_second) / SECONDS_PER_DAY;
112    find_leap_seconds(jd1 + jd2)
113}
114
115fn seconds_of_day(cal: CalendarEpoch) -> f64 {
116    cal.hour as f64 * 3600.0 + cal.minute as f64 * 60.0 + cal.second
117}
118
119fn normalize_calendar_seconds(mut cal: CalendarEpoch, second: f64) -> CalendarEpoch {
120    cal.second = second;
121    while cal.second < 0.0 {
122        cal.second += 60.0;
123        cal.minute -= 1;
124    }
125    while cal.second >= 60.0 {
126        cal.second -= 60.0;
127        cal.minute += 1;
128    }
129    while cal.minute < 0 {
130        cal.minute += 60;
131        cal.hour -= 1;
132    }
133    while cal.minute > 59 {
134        cal.minute -= 60;
135        cal.hour += 1;
136    }
137    while cal.hour < 0 {
138        cal.hour += 24;
139        cal.day -= 1;
140    }
141    while cal.hour > 23 {
142        cal.hour -= 24;
143        cal.day += 1;
144    }
145    while cal.day < 1 {
146        cal.month -= 1;
147        if cal.month < 1 {
148            cal.month = 12;
149            cal.year -= 1;
150        }
151        cal.day = days_in_month(cal.year, cal.month);
152    }
153    loop {
154        let month_days = days_in_month(cal.year, cal.month);
155        if cal.day <= month_days {
156            break;
157        }
158        cal.day -= month_days;
159        cal.month += 1;
160        if cal.month > 12 {
161            cal.month = 1;
162            cal.year += 1;
163        }
164    }
165    cal
166}
167
168fn days_in_month(year: i32, month: i32) -> i32 {
169    match month {
170        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
171        4 | 6 | 9 | 11 => 30,
172        2 if is_leap_year(year) => 29,
173        2 => 28,
174        _ => 31,
175    }
176}
177
178fn is_leap_year(year: i32) -> bool {
179    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
180}
181
182/// Seconds between two calendar epochs via their J2000-TT split day numbers.
183pub(crate) fn dt_seconds(t0: &TimeScales, t: &TimeScales) -> f64 {
184    let dwhole = t.jd_whole - t0.jd_whole;
185    let dfrac = t.tt_fraction - t0.tt_fraction;
186    (dwhole + dfrac) * SECONDS_PER_DAY
187}