Skip to main content

celestial_time/scales/
utc.rs

1//! Coordinated Universal Time (UTC) representation.
2//!
3//! UTC is the primary civil time standard. It tracks TAI but is adjusted with leap seconds
4//! to stay within 0.9 seconds of UT1 (Earth rotation time). This module provides the UTC
5//! time scale type and calendar-based construction.
6//!
7//! # Background
8//!
9//! UTC was introduced in 1960 and has used its current leap second system since 1972.
10//! The offset TAI-UTC grows by 1 second each time a leap second is inserted (typically
11//! June 30 or December 31 at 23:59:60 UTC). As of 2024, TAI-UTC = 37 seconds.
12//!
13//! ```text
14//! TAI = UTC + (TAI-UTC offset from leap second table)
15//! UTC day length = 86400s (normal) or 86401s (positive leap second)
16//! ```
17//!
18//! # Usage
19//!
20//! ```
21//! use celestial_time::{JulianDate, UTC};
22//! use celestial_time::scales::utc::utc_from_calendar;
23//!
24//! // From Unix timestamp
25//! let utc = UTC::new(1704067200, 0); // 2024-01-01 00:00:00 UTC
26//!
27//! // From calendar components
28//! let utc = utc_from_calendar(2024, 1, 1, 12, 30, 45.5);
29//!
30//! // From Julian Date
31//! let utc = UTC::from_julian_date(JulianDate::j2000());
32//! ```
33//!
34//! # Leap Second Handling
35//!
36//! The `utc_from_calendar` function adjusts day length when a leap second occurs.
37//! It queries the TAI-UTC offset at multiple points within the day to detect
38//! the discontinuity and scales the time fraction accordingly.
39//!
40//! # Precision
41//!
42//! Internally stores time as a split Julian Date for nanosecond-level precision.
43//! The `new()` constructor separates days from sub-day time to preserve all
44//! significant digits in the fractional portion.
45
46use super::common::{get_tai_utc_offset, next_calendar_day};
47use crate::constants::UNIX_EPOCH_JD;
48use crate::julian::JulianDate;
49use crate::parsing::parse_iso8601;
50use crate::{TimeError, TimeResult};
51use celestial_core::constants::SECONDS_PER_DAY_F64;
52use std::fmt;
53use std::str::FromStr;
54
55/// UTC time scale backed by a split Julian Date.
56///
57/// Wraps `JulianDate` to represent Coordinated Universal Time. Supports
58/// construction from Unix timestamps, calendar components, or raw Julian Dates.
59#[derive(Debug, Clone, Copy, PartialEq)]
60#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
61pub struct UTC(JulianDate);
62
63impl UTC {
64    /// Creates UTC from Unix timestamp (seconds and nanoseconds since 1970-01-01 00:00:00).
65    ///
66    /// Days are computed separately from sub-day time to preserve precision.
67    /// The resulting Julian Date uses jd1 for whole days and jd2 for the fractional part.
68    pub fn new(seconds: i64, nanos: u32) -> Self {
69        let days = seconds / celestial_core::constants::SECONDS_PER_DAY;
70        let remainder_seconds = seconds % celestial_core::constants::SECONDS_PER_DAY;
71        let jd1 = UNIX_EPOCH_JD + days as f64;
72        let jd2 = (remainder_seconds as f64
73            + nanos as f64 / celestial_core::constants::NANOSECONDS_PER_SECOND_F64)
74            / SECONDS_PER_DAY_F64;
75        Self(JulianDate::new(jd1, jd2))
76    }
77
78    /// Creates UTC from a Julian Date.
79    pub fn from_julian_date(jd: JulianDate) -> Self {
80        Self(jd)
81    }
82
83    /// Returns UTC at the J2000.0 epoch (2000-01-01 12:00:00 TT, JD 2451545.0).
84    pub fn j2000() -> Self {
85        Self(JulianDate::j2000())
86    }
87
88    /// Returns the underlying Julian Date.
89    pub fn to_julian_date(&self) -> JulianDate {
90        self.0
91    }
92
93    /// Returns a new UTC offset by the given seconds.
94    pub fn add_seconds(&self, seconds: f64) -> Self {
95        Self(self.0.add_seconds(seconds))
96    }
97
98    /// Returns a new UTC offset by the given days.
99    pub fn add_days(&self, days: f64) -> Self {
100        Self(self.0.add_days(days))
101    }
102
103    /// Returns the current UTC time from the system clock.
104    pub fn now() -> Self {
105        use std::time::{SystemTime, UNIX_EPOCH};
106        let duration = SystemTime::now()
107            .duration_since(UNIX_EPOCH)
108            .unwrap_or_default();
109        Self::new(duration.as_secs() as i64, duration.subsec_nanos())
110    }
111
112    /// Formats as ISO 8601 string (YYYY-MM-DDTHH:MM:SS.sss).
113    ///
114    /// Falls back to "JD{value}" if calendar conversion fails.
115    pub fn to_iso8601(&self) -> String {
116        use crate::scales::conversions::utc_tai::julian_to_calendar;
117        let jd = self.to_julian_date();
118        if let Ok((year, month, day, frac)) = julian_to_calendar(jd.jd1(), jd.jd2()) {
119            let total_seconds = frac * SECONDS_PER_DAY_F64;
120            let hour = (total_seconds / 3600.0) as u8;
121            let minute = ((total_seconds % 3600.0) / 60.0) as u8;
122            let second = total_seconds % 60.0;
123            format!(
124                "{:04}-{:02}-{:02}T{:02}:{:02}:{:06.3}",
125                year, month, day, hour, minute, second
126            )
127        } else {
128            format!("JD{:.6}", jd.jd1() + jd.jd2())
129        }
130    }
131}
132
133/// Creates UTC from calendar components, handling leap seconds.
134///
135/// Computes the TAI-UTC offset at the start, middle, and end of the day
136/// to detect leap second insertions. If a leap second occurs, the day
137/// is treated as 86401 seconds instead of 86400.
138///
139/// # Panics
140///
141/// Panics if the month is invalid (not 1-12).
142pub fn utc_from_calendar(year: i32, month: u8, day: u8, hour: u8, minute: u8, second: f64) -> UTC {
143    let base_jd = JulianDate::from_calendar(year, month, day, 0, 0, 0.0);
144
145    let mut day_length = SECONDS_PER_DAY_F64;
146
147    let dat0 = get_tai_utc_offset(year, month as i32, day as i32, 0.0);
148    let dat12 = get_tai_utc_offset(year, month as i32, day as i32, 0.5);
149
150    let (next_year, next_month, next_day) = next_calendar_day(year, month as i32, day as i32)
151        .expect("Invalid month in UTC calendar conversion");
152    let dat24 = get_tai_utc_offset(next_year, next_month, next_day, 0.0);
153
154    let dleap = dat24 - (2.0 * dat12 - dat0);
155    day_length += dleap;
156
157    let time_fraction = (60.0 * (60 * hour as i32 + minute as i32) as f64 + second) / day_length;
158
159    UTC::from_julian_date(JulianDate::new(
160        base_jd.jd1(),
161        base_jd.jd2() + time_fraction,
162    ))
163}
164
165/// Displays as "UTC {julian_date}".
166impl fmt::Display for UTC {
167    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168        write!(f, "UTC {}", self.0)
169    }
170}
171
172/// Converts JulianDate to UTC.
173impl From<JulianDate> for UTC {
174    fn from(jd: JulianDate) -> Self {
175        Self::from_julian_date(jd)
176    }
177}
178
179/// Parses ISO 8601 formatted strings into UTC.
180impl FromStr for UTC {
181    type Err = TimeError;
182
183    fn from_str(s: &str) -> TimeResult<Self> {
184        let parsed = parse_iso8601(s)?;
185        Ok(Self::from_julian_date(parsed.to_julian_date()))
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::constants::UNIX_EPOCH_JD;
193    use celestial_core::constants::J2000_JD;
194
195    #[test]
196    fn test_utc_constructors() {
197        assert_eq!(UTC::new(0, 0).to_julian_date().to_f64(), UNIX_EPOCH_JD);
198        assert_eq!(UTC::j2000().to_julian_date().to_f64(), J2000_JD);
199        assert_eq!(
200            utc_from_calendar(2000, 1, 1, 12, 0, 0.0)
201                .to_julian_date()
202                .to_f64(),
203            J2000_JD
204        );
205
206        let jd = JulianDate::new(J2000_JD, 0.123456789);
207        let utc_direct = UTC::from_julian_date(jd);
208        let utc_from_trait: UTC = jd.into();
209        assert_eq!(utc_direct, utc_from_trait);
210    }
211
212    #[test]
213    fn test_utc_arithmetic() {
214        let utc = UTC::j2000();
215        assert_eq!(utc.add_days(1.0).to_julian_date().to_f64(), J2000_JD + 1.0);
216        assert_eq!(
217            utc.add_seconds(3600.0).to_julian_date().to_f64(),
218            J2000_JD + 1.0 / 24.0
219        );
220    }
221
222    #[test]
223    fn test_utc_display() {
224        let display_str = format!("{}", UTC::from_julian_date(JulianDate::new(J2000_JD, 0.5)));
225        assert!(display_str.starts_with("UTC"));
226        assert!(display_str.contains("2451545"));
227    }
228
229    #[test]
230    fn test_utc_string_parsing() {
231        assert_eq!(
232            UTC::from_str("2000-01-01T12:00:00")
233                .unwrap()
234                .to_julian_date()
235                .to_f64(),
236            J2000_JD
237        );
238
239        let result = UTC::from_str("2000-01-01T12:00:00.123").unwrap();
240        let expected_jd = J2000_JD + 0.123 / SECONDS_PER_DAY_F64;
241        let diff = (result.to_julian_date().to_f64() - expected_jd).abs();
242        assert!(diff < 1e-14, "fractional seconds diff: {:.2e}", diff);
243
244        assert!(UTC::from_str("invalid-date").is_err());
245    }
246
247    #[test]
248    fn test_utc_new_precision_preservation() {
249        let seconds_50_years = 50 * 365 * celestial_core::constants::SECONDS_PER_DAY as u32;
250        let nanos = 123456789u32;
251
252        let utc = UTC::new(seconds_50_years as i64, nanos);
253        let jd = utc.to_julian_date();
254
255        let expected_days = seconds_50_years / celestial_core::constants::SECONDS_PER_DAY as u32;
256        let remainder_secs = seconds_50_years % celestial_core::constants::SECONDS_PER_DAY as u32;
257        let expected_jd1 = UNIX_EPOCH_JD + expected_days as f64;
258        let expected_jd2 = (remainder_secs as f64
259            + nanos as f64 / celestial_core::constants::NANOSECONDS_PER_SECOND_F64)
260            / SECONDS_PER_DAY_F64;
261
262        assert_eq!(jd.jd1(), expected_jd1);
263        assert_eq!(jd.jd2(), expected_jd2);
264    }
265
266    #[test]
267    fn test_tai_utc_offset_edge_cases() {
268        assert_eq!(get_tai_utc_offset(2000, 1, 1, -0.5), 0.0);
269        assert_eq!(get_tai_utc_offset(2000, 1, 1, 1.5), 0.0);
270        assert_eq!(get_tai_utc_offset(1950, 6, 15, 0.5), 0.0);
271        assert!(get_tai_utc_offset(1960, 1, 1, 0.0) > 0.0);
272    }
273
274    #[test]
275    fn test_next_calendar_day() {
276        assert!(next_calendar_day(2000, 13, 15).is_err());
277
278        let cases: &[(i32, i32, i32, (i32, i32, i32))] = &[
279            (2000, 2, 28, (2000, 2, 29)),
280            (1999, 2, 28, (1999, 3, 1)),
281            (2000, 4, 30, (2000, 5, 1)),
282            (2000, 12, 31, (2001, 1, 1)),
283        ];
284
285        for &(y, m, d, expected) in cases {
286            assert_eq!(next_calendar_day(y, m, d).unwrap(), expected);
287        }
288    }
289
290    #[cfg(feature = "serde")]
291    #[test]
292    fn test_utc_serde_round_trip() {
293        let test_cases = [
294            UTC::j2000(),
295            UTC::new(0, 0),
296            utc_from_calendar(2024, 6, 15, 14, 30, 45.123),
297            utc_from_calendar(1990, 12, 31, 23, 59, 59.0),
298            utc_from_calendar(2015, 6, 30, 23, 59, 59.999),
299        ];
300
301        for original in test_cases {
302            let json = serde_json::to_string(&original).unwrap();
303            let deserialized: UTC = serde_json::from_str(&json).unwrap();
304            assert_eq!(original, deserialized);
305        }
306    }
307}