Skip to main content

celestial_time/scales/
tai.rs

1//! International Atomic Time (TAI) scale.
2//!
3//! TAI is the reference time scale for astronomical time conversions. It is maintained
4//! by the Bureau International des Poids et Mesures (BIPM) as a weighted average of
5//! over 400 atomic clocks worldwide.
6//!
7//! # Background
8//!
9//! TAI runs continuously without leap seconds. Its epoch is January 1, 1958, when
10//! TAI and UT1 were approximately synchronized. TAI now leads UTC by 37 seconds
11//! (as of 2017), with the difference increasing each time a leap second is added.
12//!
13//! Key relationships:
14//!
15//! ```text
16//! TT  = TAI + 32.184 seconds (fixed)
17//! GPS = TAI - 19 seconds (fixed)
18//! UTC = TAI - leap_seconds (variable, table-based)
19//! ```
20//!
21//! # Usage
22//!
23//! ```
24//! use celestial_time::{JulianDate, TAI};
25//! use celestial_time::scales::tai::tai_from_calendar;
26//!
27//! // From Julian Date
28//! let tai = TAI::j2000();
29//!
30//! // From calendar date
31//! let tai = tai_from_calendar(2024, 6, 15, 12, 30, 0.0);
32//!
33//! // Arithmetic
34//! let later = tai.add_seconds(3600.0);
35//! let next_day = tai.add_days(1.0);
36//! ```
37//!
38//! # Precision
39//!
40//! TAI stores time as a split Julian Date (jd1, jd2) to preserve full f64 precision.
41//! The split representation avoids precision loss when adding small time increments
42//! to large Julian Date values.
43
44use crate::constants::UNIX_EPOCH_JD;
45use crate::julian::JulianDate;
46use crate::parsing::parse_iso8601;
47use crate::{TimeError, TimeResult};
48use celestial_core::constants::SECONDS_PER_DAY_F64;
49use std::fmt;
50use std::str::FromStr;
51
52/// International Atomic Time representation.
53///
54/// Wraps a `JulianDate` to provide TAI-specific semantics. TAI serves as the
55/// hub for conversions between other time scales (UTC, TT, GPS, TDB, etc.).
56#[derive(Debug, Clone, Copy, PartialEq)]
57#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
58pub struct TAI(JulianDate);
59
60impl TAI {
61    /// Creates TAI from Unix timestamp components.
62    ///
63    /// Converts seconds since 1970-01-01 00:00:00 plus nanoseconds to TAI.
64    /// Note: This assumes the input is already in TAI, not UTC.
65    pub fn new(seconds: i64, nanos: u32) -> Self {
66        let total_seconds =
67            seconds as f64 + nanos as f64 / celestial_core::constants::NANOSECONDS_PER_SECOND_F64;
68        let jd = JulianDate::from_f64(UNIX_EPOCH_JD + total_seconds / SECONDS_PER_DAY_F64);
69        Self(jd)
70    }
71
72    /// Creates TAI from a JulianDate.
73    pub fn from_julian_date(jd: JulianDate) -> Self {
74        Self(jd)
75    }
76
77    /// Creates TAI from raw Julian Date components.
78    ///
79    /// Useful when you already have the split JD representation and want to
80    /// avoid the overhead of creating a JulianDate first.
81    pub fn from_julian_date_raw(jd1: f64, jd2: f64) -> Self {
82        Self(JulianDate::new(jd1, jd2))
83    }
84
85    /// Returns TAI at the J2000.0 epoch (2000-01-01 12:00:00 TT).
86    ///
87    /// JD = 2451545.0
88    pub fn j2000() -> Self {
89        Self(JulianDate::j2000())
90    }
91
92    /// Returns the underlying Julian Date.
93    pub fn to_julian_date(&self) -> JulianDate {
94        self.0
95    }
96
97    /// Returns a new TAI offset by the given number of seconds.
98    pub fn add_seconds(&self, seconds: f64) -> Self {
99        Self(self.0.add_seconds(seconds))
100    }
101
102    /// Returns a new TAI offset by the given number of days.
103    pub fn add_days(&self, days: f64) -> Self {
104        Self(self.0.add_days(days))
105    }
106}
107
108impl fmt::Display for TAI {
109    /// Formats as "TAI {julian_date}".
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        write!(f, "TAI {}", self.0)
112    }
113}
114
115impl From<JulianDate> for TAI {
116    fn from(jd: JulianDate) -> Self {
117        Self::from_julian_date(jd)
118    }
119}
120
121impl FromStr for TAI {
122    type Err = TimeError;
123
124    /// Parses an ISO 8601 datetime string as TAI.
125    ///
126    /// The input is interpreted directly as TAI with no UTC-to-TAI conversion.
127    /// For UTC input, parse as UTC first, then convert to TAI.
128    fn from_str(s: &str) -> TimeResult<Self> {
129        let parsed = parse_iso8601(s)?;
130        Ok(Self::from_julian_date(parsed.to_julian_date()))
131    }
132}
133
134/// Creates TAI from calendar components.
135///
136/// Converts a Gregorian calendar date and time directly to TAI. No leap second
137/// or timezone corrections are applied. The input is assumed to already be TAI.
138///
139/// # Arguments
140///
141/// * `year` - Gregorian year (negative for BCE)
142/// * `month` - Month (1-12)
143/// * `day` - Day of month (1-31)
144/// * `hour` - Hour (0-23)
145/// * `minute` - Minute (0-59)
146/// * `second` - Second with optional fractional part (0.0-60.0)
147pub fn tai_from_calendar(year: i32, month: u8, day: u8, hour: u8, minute: u8, second: f64) -> TAI {
148    let jd = JulianDate::from_calendar(year, month, day, hour, minute, second);
149    TAI::from_julian_date(jd)
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::constants::UNIX_EPOCH_JD;
156    use celestial_core::constants::J2000_JD;
157
158    #[test]
159    fn test_tai_constructors() {
160        assert_eq!(TAI::new(0, 0).to_julian_date().to_f64(), UNIX_EPOCH_JD);
161        assert_eq!(TAI::j2000().to_julian_date().to_f64(), J2000_JD);
162        assert_eq!(
163            tai_from_calendar(2000, 1, 1, 12, 0, 0.0)
164                .to_julian_date()
165                .to_f64(),
166            J2000_JD
167        );
168
169        let jd = JulianDate::new(J2000_JD, 0.123456789);
170        let tai_direct = TAI::from_julian_date(jd);
171        let tai_from_trait: TAI = jd.into();
172        assert_eq!(
173            tai_direct.to_julian_date().jd1(),
174            tai_from_trait.to_julian_date().jd1()
175        );
176        assert_eq!(
177            tai_direct.to_julian_date().jd2(),
178            tai_from_trait.to_julian_date().jd2()
179        );
180    }
181
182    #[test]
183    fn test_tai_arithmetic() {
184        let tai = TAI::j2000();
185        assert_eq!(tai.add_days(1.0).to_julian_date().to_f64(), J2000_JD + 1.0);
186        assert_eq!(
187            tai.add_seconds(3600.0).to_julian_date().to_f64(),
188            J2000_JD + 1.0 / 24.0
189        );
190    }
191
192    #[test]
193    fn test_tai_display() {
194        let display_str = format!("{}", TAI::from_julian_date(JulianDate::new(J2000_JD, 0.5)));
195        assert!(display_str.starts_with("TAI"));
196        assert!(display_str.contains("2451545"));
197    }
198
199    #[test]
200    fn test_tai_string_parsing() {
201        assert_eq!(
202            TAI::from_str("2000-01-01T12:00:00")
203                .unwrap()
204                .to_julian_date()
205                .to_f64(),
206            TAI::j2000().to_julian_date().to_f64()
207        );
208
209        let result = TAI::from_str("2000-01-01T12:00:00.123").unwrap();
210        let expected_jd = J2000_JD + 0.123 / SECONDS_PER_DAY_F64;
211        let diff = (result.to_julian_date().to_f64() - expected_jd).abs();
212        assert!(diff < 1e-14, "fractional seconds diff: {:.2e}", diff);
213
214        assert!(TAI::from_str("invalid-date").is_err());
215    }
216
217    #[cfg(feature = "serde")]
218    #[test]
219    fn test_tai_serde_round_trip() {
220        let test_cases = [
221            TAI::j2000(),
222            TAI::new(0, 0),
223            tai_from_calendar(2024, 6, 15, 14, 30, 45.123),
224            tai_from_calendar(1990, 12, 31, 23, 59, 59.999999999),
225        ];
226
227        for original in test_cases {
228            let json = serde_json::to_string(&original).unwrap();
229            let deserialized: TAI = serde_json::from_str(&json).unwrap();
230
231            let total_diff =
232                (original.to_julian_date().to_f64() - deserialized.to_julian_date().to_f64()).abs();
233            assert!(
234                total_diff < 1e-14,
235                "serde precision loss: {:.2e}",
236                total_diff
237            );
238        }
239    }
240}