Skip to main content

celestial_time/scales/conversions/
ut1_tai.rs

1//! Conversions between UT1, TAI, and TT time scales.
2//!
3//! UT1 (Universal Time 1) is tied to Earth's actual rotation. Unlike atomic time scales
4//! (TAI, TT), UT1 drifts unpredictably as Earth's rotation varies due to tidal friction,
5//! core-mantle coupling, and atmospheric effects.
6//!
7//! # Why External Offsets Are Required
8//!
9//! The relationship between UT1 and atomic scales cannot be computed from first principles.
10//! It must be measured by IERS (International Earth Rotation Service) and published as:
11//!
12//! - **UT1-TAI**:          Direct offset, typically around -37 seconds (as of 2024)
13//! - **Delta-T (TT-UT1)**: Historical parameter, ~69 seconds at J2000.0
14//!
15//! These values change continuously. IERS Bulletin A provides predictions; Bulletin B
16//! provides final values after the fact. The offset changes by roughly 1-2 ms/day.
17//!
18//! # Conversion Paths
19//!
20//! ```text
21//! UT1 <-(UT1-TAI offset)-> TAI
22//! UT1 <-----(Delta-T)-----> TT
23//! ```
24//!
25//! Both require externally-supplied offset values. This module provides the traits;
26//! you provide the offset from EOP (Earth Orientation Parameters) data.
27//!
28//! # Usage
29//!
30//! ```
31//! use celestial_time::scales::{TAI, TT, UT1};
32//! use celestial_time::scales::conversions::{ToUT1WithOffset, ToTAIWithOffset};
33//! use celestial_time::scales::conversions::{ToUT1WithDeltaT, ToTTWithDeltaT};
34//! use celestial_time::julian::JulianDate;
35//!
36//! // UT1-TAI offset from IERS Bulletin A (example: -37.0 seconds)
37//! let ut1_tai_offset = -37.0;
38//!
39//! let ut1 = UT1::from_julian_date(JulianDate::new(2451545.0, 0.0));
40//! let tai = ut1.to_tai_with_offset(ut1_tai_offset).unwrap();
41//! let back = tai.to_ut1_with_offset(ut1_tai_offset).unwrap();
42//!
43//! // Delta-T from historical tables or prediction models
44//! let delta_t = 69.0;  // seconds at J2000.0
45//!
46//! let tt = ut1.to_tt_with_delta_t(delta_t).unwrap();
47//! let back = tt.to_ut1_with_delta_t(delta_t).unwrap();
48//! ```
49//!
50//! # Precision Notes
51//!
52//! Offsets are applied to the smaller-magnitude Julian Date component to preserve
53//! precision. Round-trip conversions maintain sub-nanosecond accuracy.
54
55use super::ToUT1;
56use crate::julian::JulianDate;
57use crate::scales::{TAI, TT, UT1};
58use crate::TimeResult;
59use celestial_core::constants::SECONDS_PER_DAY_F64;
60
61impl ToUT1 for UT1 {
62    fn to_ut1(&self) -> TimeResult<UT1> {
63        Ok(*self)
64    }
65}
66
67/// Convert TAI to UT1 using a supplied UT1-TAI offset.
68///
69/// The offset comes from IERS Earth Orientation Parameters. Typical values
70/// are around -37 seconds (as of 2024), becoming more negative over time
71/// as leap seconds accumulate.
72///
73/// Note: The offset is UT1-TAI, so it's negative when UT1 is behind TAI.
74pub trait ToUT1WithOffset {
75    /// Convert to UT1 using the given UT1-TAI offset in seconds.
76    ///
77    /// The offset should be UT1-TAI (typically negative). To find UT1:
78    /// `UT1 = TAI + (UT1-TAI)`
79    fn to_ut1_with_offset(&self, ut1_tai_offset_seconds: f64) -> TimeResult<UT1>;
80}
81
82/// Convert UT1 to TAI using a supplied UT1-TAI offset.
83///
84/// The offset comes from IERS Earth Orientation Parameters. This is the
85/// inverse operation of [`ToUT1WithOffset`].
86pub trait ToTAIWithOffset {
87    /// Convert to TAI using the given UT1-TAI offset in seconds.
88    ///
89    /// The offset should be UT1-TAI (typically negative). To find TAI:
90    /// `TAI = UT1 - (UT1-TAI)`
91    fn to_tai_with_offset(&self, ut1_tai_offset_seconds: f64) -> TimeResult<TAI>;
92}
93
94impl ToTAIWithOffset for UT1 {
95    fn to_tai_with_offset(&self, ut1_tai_offset_seconds: f64) -> TimeResult<TAI> {
96        let ut1_jd = self.to_julian_date();
97        let offset_days = ut1_tai_offset_seconds / SECONDS_PER_DAY_F64;
98
99        // TAI = UT1 - (UT1-TAI), so subtract the offset.
100        // Apply to smaller-magnitude component for precision.
101        let (tai_jd1, tai_jd2) = if ut1_jd.jd1().abs() > ut1_jd.jd2().abs() {
102            (ut1_jd.jd1(), ut1_jd.jd2() - offset_days)
103        } else {
104            (ut1_jd.jd1() - offset_days, ut1_jd.jd2())
105        };
106
107        Ok(TAI::from_julian_date(JulianDate::new(tai_jd1, tai_jd2)))
108    }
109}
110
111impl ToUT1WithOffset for TAI {
112    fn to_ut1_with_offset(&self, ut1_tai_offset_seconds: f64) -> TimeResult<UT1> {
113        let tai_jd = self.to_julian_date();
114        let offset_days = ut1_tai_offset_seconds / SECONDS_PER_DAY_F64;
115
116        // UT1 = TAI + (UT1-TAI), so add the offset.
117        // Apply to smaller-magnitude component for precision.
118        let (ut1_jd1, ut1_jd2) = if tai_jd.jd1().abs() > tai_jd.jd2().abs() {
119            (tai_jd.jd1(), tai_jd.jd2() + offset_days)
120        } else {
121            (tai_jd.jd1() + offset_days, tai_jd.jd2())
122        };
123
124        Ok(UT1::from_julian_date(JulianDate::new(ut1_jd1, ut1_jd2)))
125    }
126}
127
128/// Convert UT1 to TT using Delta-T.
129///
130/// Delta-T is defined as TT - UT1. Unlike the fixed TAI-TT offset (32.184s),
131/// Delta-T varies with Earth's rotation:
132///
133/// - At J2000.0: ~63.8 seconds
134/// - In 2024: ~69 seconds
135/// - Historical values go back centuries (reconstructed from eclipse records)
136///
137/// Delta-T combines two effects:
138/// - The fixed TT-TAI offset (32.184s)
139/// - The variable TAI-UT1 difference (leap seconds + sub-second drift)
140///
141/// Use this for direct UT1 <-> TT conversion when you have Delta-T from
142/// historical tables or prediction models. For modern dates with EOP data,
143/// chaining through TAI may be more accurate.
144pub trait ToTTWithDeltaT {
145    /// Convert to TT using the given Delta-T in seconds.
146    ///
147    /// Delta-T = TT - UT1, so: `TT = UT1 + Delta-T`
148    fn to_tt_with_delta_t(&self, delta_t_seconds: f64) -> TimeResult<TT>;
149}
150
151/// Convert TT to UT1 using Delta-T.
152///
153/// This is the inverse of [`ToTTWithDeltaT`]. See that trait for Delta-T details.
154pub trait ToUT1WithDeltaT {
155    /// Convert to UT1 using the given Delta-T in seconds.
156    ///
157    /// Delta-T = TT - UT1, so: `UT1 = TT - Delta-T`
158    fn to_ut1_with_delta_t(&self, delta_t_seconds: f64) -> TimeResult<UT1>;
159}
160
161impl ToTTWithDeltaT for UT1 {
162    fn to_tt_with_delta_t(&self, delta_t_seconds: f64) -> TimeResult<TT> {
163        let ut1_jd = self.to_julian_date();
164        let delta_t_days = delta_t_seconds / SECONDS_PER_DAY_F64;
165
166        // TT = UT1 + Delta-T, so add.
167        // Apply to smaller-magnitude component for precision.
168        let (tt_jd1, tt_jd2) = if ut1_jd.jd1().abs() > ut1_jd.jd2().abs() {
169            (ut1_jd.jd1(), ut1_jd.jd2() + delta_t_days)
170        } else {
171            (ut1_jd.jd1() + delta_t_days, ut1_jd.jd2())
172        };
173
174        Ok(TT::from_julian_date(JulianDate::new(tt_jd1, tt_jd2)))
175    }
176}
177
178impl ToUT1WithDeltaT for TT {
179    fn to_ut1_with_delta_t(&self, delta_t_seconds: f64) -> TimeResult<UT1> {
180        let tt_jd = self.to_julian_date();
181        let delta_t_days = delta_t_seconds / SECONDS_PER_DAY_F64;
182
183        // UT1 = TT - Delta-T, so subtract.
184        // Apply to smaller-magnitude component for precision.
185        let (ut1_jd1, ut1_jd2) = if tt_jd.jd1().abs() > tt_jd.jd2().abs() {
186            (tt_jd.jd1(), tt_jd.jd2() - delta_t_days)
187        } else {
188            (tt_jd.jd1() - delta_t_days, tt_jd.jd2())
189        };
190
191        Ok(UT1::from_julian_date(JulianDate::new(ut1_jd1, ut1_jd2)))
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use celestial_core::constants::J2000_JD;
199
200    #[test]
201    fn test_ut1_identity_conversion() {
202        let ut1 = UT1::from_julian_date(JulianDate::new(J2000_JD, 0.999999999999999));
203        let identity_ut1 = ut1.to_ut1().unwrap();
204
205        assert_eq!(
206            ut1.to_julian_date().jd1(),
207            identity_ut1.to_julian_date().jd1(),
208            "UT1 identity conversion should preserve JD1 exactly"
209        );
210        assert_eq!(
211            ut1.to_julian_date().jd2(),
212            identity_ut1.to_julian_date().jd2(),
213            "UT1 identity conversion should preserve JD2 exactly"
214        );
215    }
216
217    #[test]
218    fn test_ut1_tai_offset_applied_correctly() {
219        let test_dates = [
220            (J2000_JD, "J2000.0"),
221            (2455197.5, "2010-01-01"),
222            (2459580.5, "2022-01-01"),
223        ];
224        let ut1_tai_offset = -32.3;
225        let delta_t = 69.0;
226
227        for (jd, description) in test_dates {
228            // UT1 -> TAI: TAI = UT1 - (UT1-TAI), so TAI should be ahead by 32.3s
229            let ut1 = UT1::from_julian_date(JulianDate::new(jd, 0.0));
230            let tai = ut1.to_tai_with_offset(ut1_tai_offset).unwrap();
231
232            let ut1_jd = ut1.to_julian_date();
233            let tai_jd = tai.to_julian_date();
234
235            let offset_days = (tai_jd.jd1() - ut1_jd.jd1()) + (tai_jd.jd2() - ut1_jd.jd2());
236            let offset_seconds = offset_days * SECONDS_PER_DAY_F64;
237
238            assert_eq!(
239                offset_seconds, -ut1_tai_offset,
240                "{}: UT1->TAI offset must be exactly {} seconds",
241                description, -ut1_tai_offset
242            );
243
244            // TAI -> UT1: UT1 = TAI + (UT1-TAI), so UT1 should be behind by 32.3s
245            let tai = TAI::from_julian_date(JulianDate::new(jd, 0.0));
246            let ut1 = tai.to_ut1_with_offset(ut1_tai_offset).unwrap();
247
248            let tai_jd = tai.to_julian_date();
249            let ut1_jd = ut1.to_julian_date();
250
251            let offset_days = (tai_jd.jd1() - ut1_jd.jd1()) + (tai_jd.jd2() - ut1_jd.jd2());
252            let offset_seconds = offset_days * SECONDS_PER_DAY_F64;
253
254            assert_eq!(
255                offset_seconds, -ut1_tai_offset,
256                "{}: TAI->UT1 means TAI is {} seconds ahead",
257                description, -ut1_tai_offset
258            );
259
260            // UT1 -> TT: TT = UT1 + Delta-T, so TT should be ahead by 69s
261            let ut1 = UT1::from_julian_date(JulianDate::new(jd, 0.0));
262            let tt = ut1.to_tt_with_delta_t(delta_t).unwrap();
263
264            let ut1_jd = ut1.to_julian_date();
265            let tt_jd = tt.to_julian_date();
266
267            let offset_days = (tt_jd.jd1() - ut1_jd.jd1()) + (tt_jd.jd2() - ut1_jd.jd2());
268            let offset_seconds = offset_days * SECONDS_PER_DAY_F64;
269
270            assert_eq!(
271                offset_seconds, delta_t,
272                "{}: UT1->TT offset must be exactly {} seconds",
273                description, delta_t
274            );
275
276            // TT -> UT1: UT1 = TT - Delta-T, so UT1 should be behind by 69s
277            let tt = TT::from_julian_date(JulianDate::new(jd, 0.0));
278            let ut1 = tt.to_ut1_with_delta_t(delta_t).unwrap();
279
280            let tt_jd = tt.to_julian_date();
281            let ut1_jd = ut1.to_julian_date();
282
283            let offset_days = (tt_jd.jd1() - ut1_jd.jd1()) + (tt_jd.jd2() - ut1_jd.jd2());
284            let offset_seconds = offset_days * SECONDS_PER_DAY_F64;
285
286            assert_eq!(
287                offset_seconds, delta_t,
288                "{}: TT->UT1 means TT is {} seconds ahead",
289                description, delta_t
290            );
291        }
292    }
293
294    #[test]
295    fn test_ut1_tai_round_trip_precision() {
296        // Division by SECONDS_PER_DAY introduces ~5 picosecond rounding.
297        // 1e-14 days = ~1 picosecond tolerance.
298        const TOLERANCE_DAYS: f64 = 1e-14;
299
300        let test_jd2_values = [0.0, 0.5, 0.123456789012345, -0.123456789012345, 0.987654321];
301        let test_offsets = [-32.0, -31.8, -32.5, -33.1, -30.9];
302
303        for jd2 in test_jd2_values {
304            for &offset in &test_offsets {
305                // UT1 -> TAI -> UT1
306                let original_ut1 = UT1::from_julian_date(JulianDate::new(J2000_JD, jd2));
307                let tai = original_ut1.to_tai_with_offset(offset).unwrap();
308                let round_trip_ut1 = tai.to_ut1_with_offset(offset).unwrap();
309
310                assert_eq!(
311                    original_ut1.to_julian_date().jd1(),
312                    round_trip_ut1.to_julian_date().jd1(),
313                    "UT1->TAI->UT1 JD1 must be exact for jd2={}, offset={}",
314                    jd2,
315                    offset
316                );
317                let jd2_diff = (original_ut1.to_julian_date().jd2()
318                    - round_trip_ut1.to_julian_date().jd2())
319                .abs();
320                assert!(
321                    jd2_diff <= TOLERANCE_DAYS,
322                    "UT1->TAI->UT1 JD2 diff {} exceeds tolerance {} for jd2={}, offset={}",
323                    jd2_diff,
324                    TOLERANCE_DAYS,
325                    jd2,
326                    offset
327                );
328
329                // TAI -> UT1 -> TAI
330                let original_tai = TAI::from_julian_date(JulianDate::new(J2000_JD, jd2));
331                let ut1 = original_tai.to_ut1_with_offset(offset).unwrap();
332                let round_trip_tai = ut1.to_tai_with_offset(offset).unwrap();
333
334                assert_eq!(
335                    original_tai.to_julian_date().jd1(),
336                    round_trip_tai.to_julian_date().jd1(),
337                    "TAI->UT1->TAI JD1 must be exact for jd2={}, offset={}",
338                    jd2,
339                    offset
340                );
341                let jd2_diff = (original_tai.to_julian_date().jd2()
342                    - round_trip_tai.to_julian_date().jd2())
343                .abs();
344                assert!(
345                    jd2_diff <= TOLERANCE_DAYS,
346                    "TAI->UT1->TAI JD2 diff {} exceeds tolerance {} for jd2={}, offset={}",
347                    jd2_diff,
348                    TOLERANCE_DAYS,
349                    jd2,
350                    offset
351                );
352            }
353        }
354
355        // Alternate JD split case (jd2 > jd1)
356        let alt_ut1 = UT1::from_julian_date(JulianDate::new(0.5, J2000_JD));
357        let alt_tai = alt_ut1.to_tai_with_offset(-32.0).unwrap();
358        let alt_round_trip = alt_tai.to_ut1_with_offset(-32.0).unwrap();
359
360        assert_eq!(
361            alt_ut1.to_julian_date().jd1(),
362            alt_round_trip.to_julian_date().jd1(),
363            "Alternate split UT1->TAI->UT1 JD1 must be exact"
364        );
365        let jd2_diff =
366            (alt_ut1.to_julian_date().jd2() - alt_round_trip.to_julian_date().jd2()).abs();
367        assert!(
368            jd2_diff <= TOLERANCE_DAYS,
369            "Alternate split UT1->TAI->UT1 JD2 diff {} exceeds tolerance {}",
370            jd2_diff,
371            TOLERANCE_DAYS
372        );
373    }
374
375    #[test]
376    fn test_ut1_tt_round_trip_precision() {
377        // Division by SECONDS_PER_DAY introduces ~5 picosecond rounding.
378        // 1e-14 days = ~1 picosecond tolerance.
379        const TOLERANCE_DAYS: f64 = 1e-14;
380
381        let test_jd2_values = [0.0, 0.5, 0.123456789012345, -0.123456789012345, 0.987654321];
382        let test_delta_t_values = [63.8, 69.0, 70.5, 65.2];
383
384        for jd2 in test_jd2_values {
385            for &delta_t in &test_delta_t_values {
386                // UT1 -> TT -> UT1
387                let original_ut1 = UT1::from_julian_date(JulianDate::new(J2000_JD, jd2));
388                let tt = original_ut1.to_tt_with_delta_t(delta_t).unwrap();
389                let round_trip_ut1 = tt.to_ut1_with_delta_t(delta_t).unwrap();
390
391                assert_eq!(
392                    original_ut1.to_julian_date().jd1(),
393                    round_trip_ut1.to_julian_date().jd1(),
394                    "UT1->TT->UT1 JD1 must be exact for jd2={}, delta_t={}",
395                    jd2,
396                    delta_t
397                );
398                let jd2_diff = (original_ut1.to_julian_date().jd2()
399                    - round_trip_ut1.to_julian_date().jd2())
400                .abs();
401                assert!(
402                    jd2_diff <= TOLERANCE_DAYS,
403                    "UT1->TT->UT1 JD2 diff {} exceeds tolerance {} for jd2={}, delta_t={}",
404                    jd2_diff,
405                    TOLERANCE_DAYS,
406                    jd2,
407                    delta_t
408                );
409
410                // TT -> UT1 -> TT
411                let original_tt = TT::from_julian_date(JulianDate::new(J2000_JD, jd2));
412                let ut1 = original_tt.to_ut1_with_delta_t(delta_t).unwrap();
413                let round_trip_tt = ut1.to_tt_with_delta_t(delta_t).unwrap();
414
415                assert_eq!(
416                    original_tt.to_julian_date().jd1(),
417                    round_trip_tt.to_julian_date().jd1(),
418                    "TT->UT1->TT JD1 must be exact for jd2={}, delta_t={}",
419                    jd2,
420                    delta_t
421                );
422                let jd2_diff = (original_tt.to_julian_date().jd2()
423                    - round_trip_tt.to_julian_date().jd2())
424                .abs();
425                assert!(
426                    jd2_diff <= TOLERANCE_DAYS,
427                    "TT->UT1->TT JD2 diff {} exceeds tolerance {} for jd2={}, delta_t={}",
428                    jd2_diff,
429                    TOLERANCE_DAYS,
430                    jd2,
431                    delta_t
432                );
433            }
434        }
435
436        // Alternate JD split case (jd2 > jd1)
437        let alt_ut1 = UT1::from_julian_date(JulianDate::new(0.5, J2000_JD));
438        let alt_tt = alt_ut1.to_tt_with_delta_t(69.0).unwrap();
439        let alt_round_trip = alt_tt.to_ut1_with_delta_t(69.0).unwrap();
440
441        assert_eq!(
442            alt_ut1.to_julian_date().jd1(),
443            alt_round_trip.to_julian_date().jd1(),
444            "Alternate split UT1->TT->UT1 JD1 must be exact"
445        );
446        let jd2_diff =
447            (alt_ut1.to_julian_date().jd2() - alt_round_trip.to_julian_date().jd2()).abs();
448        assert!(
449            jd2_diff <= TOLERANCE_DAYS,
450            "Alternate split UT1->TT->UT1 JD2 diff {} exceeds tolerance {}",
451            jd2_diff,
452            TOLERANCE_DAYS
453        );
454    }
455}