Skip to main content

celestial_time/scales/conversions/
utc_ut1.rs

1//! Conversions between Coordinated Universal Time (UTC) and Universal Time (UT1).
2//!
3//! UT1 is the principal form of Universal Time, directly tied to Earth's rotation angle.
4//! UTC is the civil time standard maintained by atomic clocks. The difference between
5//! them, called DUT1 (= UT1 - UTC), is published by the IERS in Bulletin A.
6//!
7//! # The DUT1 Offset
8//!
9//! DUT1 measures how much Earth's actual rotation deviates from the uniform UTC clock:
10//!
11//! ```text
12//! UT1 = UTC + DUT1
13//! ```
14//!
15//! The IERS keeps |DUT1| < 0.9 seconds by inserting leap seconds into UTC. DUT1 values
16//! are published weekly in IERS Bulletin A with ~1 ms precision. For high-precision
17//! applications (astrometry, VLBI, satellite tracking), the correct DUT1 must be
18//! obtained from IERS data for the specific date.
19//!
20//! # Conversion Paths
21//!
22//! This module provides two paths from UT1 to UTC:
23//!
24//! ```text
25//! Direct:      UT1 ←→ UTC    (via DUT1 offset)
26//! Via TAI:     UT1 → TAI → UTC    (for verification)
27//! ```
28//!
29//! The direct path (`ToUTCWithDUT1`) handles leap second boundaries correctly by
30//! adjusting the effective DUT1 value near discontinuities. The TAI path
31//! (`ToUTCViaTAI`) chains through intermediate time scales and serves as a
32//! verification mechanism.
33//!
34//! # Leap Second Handling
35//!
36//! The UT1→UTC conversion is complicated by leap seconds: when UTC inserts a leap
37//! second, there's a discontinuity in the UTC-TAI offset. The `adjust_dut1_for_leap_second`
38//! function scans nearby days for offset changes and smoothly interpolates the
39//! correction across the leap second boundary.
40//!
41//! # Precision
42//!
43//! Round-trip conversions (UTC → UT1 → UTC or UT1 → UTC → UT1) achieve ~1 picosecond
44//! accuracy when using consistent DUT1 values. The two-part Julian Date representation
45//! preserves full f64 precision throughout.
46//!
47//! # Usage
48//!
49//! ```
50//! use celestial_time::scales::{UTC, UT1};
51//! use celestial_time::scales::conversions::{ToUT1WithDUT1, ToUTCWithDUT1};
52//! use celestial_time::julian::JulianDate;
53//! use celestial_core::constants::J2000_JD;
54//!
55//! // DUT1 for 2000-01-01 was approximately +0.3 seconds (from IERS Bulletin A)
56//! let dut1 = 0.3;
57//!
58//! let utc = UTC::from_julian_date(JulianDate::new(J2000_JD, 0.0));
59//! let ut1 = utc.to_ut1_with_dut1(dut1).unwrap();
60//!
61//! // UT1 should be DUT1 seconds ahead of UTC
62//! let diff_days = ut1.to_julian_date().to_f64() - utc.to_julian_date().to_f64();
63//! let diff_seconds = diff_days * 86400.0;
64//! assert!((diff_seconds - dut1).abs() < 0.01);
65//!
66//! // Round-trip preserves the original time
67//! let utc_back = ut1.to_utc_with_dut1(dut1).unwrap();
68//! let round_trip_diff = (utc.to_julian_date().to_f64()
69//!     - utc_back.to_julian_date().to_f64()).abs();
70//! assert!(round_trip_diff < 1e-14);  // ~1 picosecond
71//! ```
72//!
73//! # References
74//!
75//! - IERS Bulletin A: Weekly publication of UT1-UTC values
76//! - IERS Conventions (2010): Chapter 5, Earth Rotation
77//! - USNO Earth Orientation Parameters
78
79use super::super::common::get_tai_utc_offset; // Direct import from common
80use super::ut1_tai::{ToTAIWithOffset, ToUT1WithOffset};
81use super::utc_tai::{calendar_to_julian, julian_to_calendar};
82use super::{ToTAI, ToUTC};
83use crate::julian::JulianDate;
84use crate::scales::{UT1, UTC};
85use crate::TimeResult;
86use celestial_core::constants::SECONDS_PER_DAY_F64;
87
88/// Convert to UT1 using a known DUT1 (UT1-UTC) offset.
89///
90/// DUT1 values must be obtained from IERS Bulletin A for the specific date.
91/// The offset is typically in the range -0.9 to +0.9 seconds.
92pub trait ToUT1WithDUT1 {
93    /// Convert to UT1 given the DUT1 offset in seconds.
94    ///
95    /// # Arguments
96    ///
97    /// * `dut1_seconds` - The UT1-UTC offset in seconds (from IERS Bulletin A)
98    ///
99    /// # Returns
100    ///
101    /// The corresponding UT1 instant. The conversion chains through TAI:
102    /// UTC → TAI → UT1, computing the UT1-TAI offset from DUT1 and the
103    /// TAI-UTC offset for the date.
104    fn to_ut1_with_dut1(&self, dut1_seconds: f64) -> TimeResult<UT1>;
105}
106
107/// Convert to UTC using a known DUT1 (UT1-UTC) offset.
108///
109/// This is the inverse of `ToUT1WithDUT1`. Given a UT1 instant and the
110/// DUT1 offset, computes the corresponding UTC instant.
111pub trait ToUTCWithDUT1 {
112    /// Convert to UTC given the DUT1 offset in seconds.
113    ///
114    /// # Arguments
115    ///
116    /// * `dut1_seconds` - The UT1-UTC offset in seconds (from IERS Bulletin A)
117    ///
118    /// # Returns
119    ///
120    /// The corresponding UTC instant. Handles leap second boundaries by
121    /// adjusting the effective DUT1 value near discontinuities.
122    fn to_utc_with_dut1(&self, dut1_seconds: f64) -> TimeResult<UTC>;
123}
124
125impl ToUT1WithDUT1 for UTC {
126    /// Convert UTC to UT1 by computing UT1-TAI from DUT1 and TAI-UTC.
127    ///
128    /// The conversion uses the relationship:
129    ///
130    /// ```text
131    /// UT1 - TAI = DUT1 - (TAI - UTC) = DUT1 - TAI_UTC_offset
132    /// ```
133    ///
134    /// The TAI-UTC offset is looked up from the leap second table for the
135    /// specific date. The result chains: UTC → TAI → UT1.
136    fn to_ut1_with_dut1(&self, dut1_seconds: f64) -> TimeResult<UT1> {
137        let tai = self.to_tai()?;
138
139        let utc_jd = self.to_julian_date();
140        let (year, month, day, day_fraction) = julian_to_calendar(utc_jd.jd1(), utc_jd.jd2())?;
141        let tai_utc_seconds = get_tai_utc_offset(year, month, day, day_fraction);
142        let ut1_tai_offset = dut1_seconds - tai_utc_seconds;
143
144        tai.to_ut1_with_offset(ut1_tai_offset)
145    }
146}
147
148/// Adjust DUT1 for leap second discontinuities near the given Julian Date.
149///
150/// When converting UT1 to UTC near a leap second boundary, the naive subtraction
151/// of DUT1 can place the result on the wrong side of the discontinuity. This
152/// function detects nearby leap seconds and adjusts the effective DUT1 value
153/// to smoothly interpolate across the boundary.
154///
155/// # Algorithm
156///
157/// 1. Scan days from (JD - 1) to (JD + 3) looking for TAI-UTC offset changes
158/// 2. If a change > 0.5 seconds is found, a leap second occurred
159/// 3. If the leap second and DUT1 have the same sign, subtract the leap from DUT1
160/// 4. Compute the fraction of the way past the leap second boundary
161/// 5. Gradually add back the leap second contribution based on that fraction
162///
163/// The range [-1, +3] days ensures leap seconds are detected whether the input
164/// time is just before, during, or just after the discontinuity.
165///
166/// # Arguments
167///
168/// * `jd_big` - Larger magnitude component of the Julian Date
169/// * `jd_small` - Smaller magnitude component of the Julian Date
170/// * `dut1` - The raw DUT1 offset in seconds
171///
172/// # Returns
173///
174/// The adjusted DUT1 value that accounts for any nearby leap second.
175fn adjust_dut1_for_leap_second(jd_big: f64, jd_small: f64, dut1: f64) -> TimeResult<f64> {
176    let mut duts = dut1;
177    let mut prev_offset = 0.0;
178
179    for i in -1..=3 {
180        let jd_frac = jd_small + i as f64;
181        let (year, month, day, _) = julian_to_calendar(jd_big, jd_frac)?;
182        let curr_offset = get_tai_utc_offset(year, month, day, 0.0);
183
184        if i == -1 {
185            prev_offset = curr_offset;
186            continue;
187        }
188
189        let delta = curr_offset - prev_offset;
190        if delta.abs() < 0.5 {
191            prev_offset = curr_offset;
192            continue;
193        }
194
195        // Found leap second boundary
196        if delta * duts >= 0.0 {
197            duts -= delta;
198        }
199
200        let (leap_d1, leap_d2) = calendar_to_julian(year, month, day);
201        let time_past_leap =
202            (jd_big - leap_d1) + (jd_small - (leap_d2 - 1.0 + duts / SECONDS_PER_DAY_F64));
203
204        if time_past_leap > 0.0 {
205            let fraction =
206                (time_past_leap * SECONDS_PER_DAY_F64 / (SECONDS_PER_DAY_F64 + delta)).min(1.0);
207            duts += delta * fraction;
208        }
209        break;
210    }
211
212    Ok(duts)
213}
214
215impl ToUTCWithDUT1 for UT1 {
216    /// Convert UT1 to UTC by subtracting the adjusted DUT1 offset.
217    ///
218    /// The conversion:
219    ///
220    /// 1. Determines which JD component has larger magnitude (for precision)
221    /// 2. Adjusts DUT1 for any nearby leap second boundaries
222    /// 3. Subtracts the adjusted DUT1 from the smaller-magnitude component
223    /// 4. Preserves the original JD component ordering
224    ///
225    /// The leap second adjustment ensures correct behavior at discontinuities
226    /// where the UTC scale gains an extra second.
227    fn to_utc_with_dut1(&self, dut1_seconds: f64) -> TimeResult<UTC> {
228        let ut1_jd = self.to_julian_date();
229        let (big, small, big_first) = if ut1_jd.jd1().abs() >= ut1_jd.jd2().abs() {
230            (ut1_jd.jd1(), ut1_jd.jd2(), true)
231        } else {
232            (ut1_jd.jd2(), ut1_jd.jd1(), false)
233        };
234
235        let adjusted_dut1 = adjust_dut1_for_leap_second(big, small, dut1_seconds)?;
236        let small_corrected = small - adjusted_dut1 / SECONDS_PER_DAY_F64;
237
238        let (utc_jd1, utc_jd2) = if big_first {
239            (big, small_corrected)
240        } else {
241            (small_corrected, big)
242        };
243        Ok(UTC::from_julian_date(JulianDate::new(utc_jd1, utc_jd2)))
244    }
245}
246
247/// Alternative UT1 to UTC conversion path that chains through TAI.
248///
249/// This trait provides a verification mechanism: the direct path (`ToUTCWithDUT1`)
250/// and the TAI path should produce identical results. Any discrepancy indicates
251/// a bug in one of the conversion implementations.
252pub trait ToUTCViaTAI {
253    /// Convert UT1 to UTC by chaining: UT1 → TAI → UTC.
254    ///
255    /// # Arguments
256    ///
257    /// * `dut1_seconds` - The UT1-UTC offset in seconds (from IERS Bulletin A)
258    ///
259    /// # Algorithm
260    ///
261    /// 1. Compute UT1-TAI offset from DUT1 and TAI-UTC for the date
262    /// 2. Convert UT1 to TAI using the computed offset
263    /// 3. Convert TAI to UTC using the leap second table
264    ///
265    /// This path uses the same TAI-UTC lookup as the direct conversion but
266    /// exercises different code paths, making it useful for testing.
267    fn to_utc_via_tai_with_dut1(&self, dut1_seconds: f64) -> TimeResult<UTC>;
268}
269
270impl ToUTCViaTAI for UT1 {
271    fn to_utc_via_tai_with_dut1(&self, dut1_seconds: f64) -> TimeResult<UTC> {
272        let ut1_jd = self.to_julian_date();
273        let (year, month, day, day_fraction) = julian_to_calendar(ut1_jd.jd1(), ut1_jd.jd2())?;
274        let tai_utc_seconds = get_tai_utc_offset(year, month, day, day_fraction);
275        let ut1_tai_offset = dut1_seconds - tai_utc_seconds;
276
277        let tai = self.to_tai_with_offset(ut1_tai_offset)?;
278
279        tai.to_utc()
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::super::{ToUT1, ToUTC};
286    use super::*;
287    use celestial_core::constants::J2000_JD;
288
289    #[test]
290    fn test_identity_conversions() {
291        let ut1 = UT1::from_julian_date(JulianDate::new(J2000_JD, 0.5));
292        let identity_ut1 = ut1.to_ut1().unwrap();
293        assert_eq!(
294            ut1.to_julian_date().jd1(),
295            identity_ut1.to_julian_date().jd1()
296        );
297        assert_eq!(
298            ut1.to_julian_date().jd2(),
299            identity_ut1.to_julian_date().jd2()
300        );
301
302        let utc = UTC::from_julian_date(JulianDate::new(J2000_JD, 0.5));
303        let identity_utc = utc.to_utc().unwrap();
304        assert_eq!(
305            utc.to_julian_date().jd1(),
306            identity_utc.to_julian_date().jd1()
307        );
308        assert_eq!(
309            utc.to_julian_date().jd2(),
310            identity_utc.to_julian_date().jd2()
311        );
312    }
313
314    #[test]
315    fn test_dut1_offset_relationship() {
316        let dut1_values = [-0.9, 0.0, 0.9];
317
318        for dut1 in dut1_values {
319            let utc = UTC::from_julian_date(JulianDate::new(J2000_JD, 0.0));
320            let ut1 = utc.to_ut1_with_dut1(dut1).unwrap();
321
322            let ut1_jd = ut1.to_julian_date().to_f64();
323            let diff_seconds = (ut1_jd - J2000_JD) * SECONDS_PER_DAY_F64;
324
325            assert!(
326                diff_seconds > -1.0 && diff_seconds < 1.0,
327                "DUT1={}: UT1-UTC difference should be within 1 second: {} seconds",
328                dut1,
329                diff_seconds
330            );
331
332            let ut1_reverse = UT1::from_julian_date(JulianDate::new(J2000_JD, 0.0));
333            let utc_reverse = ut1_reverse.to_utc_with_dut1(dut1).unwrap();
334            let utc_jd = utc_reverse.to_julian_date().to_f64();
335            let reverse_diff = (J2000_JD - utc_jd) * SECONDS_PER_DAY_F64;
336
337            assert!(
338                (reverse_diff - dut1).abs() < 0.1,
339                "DUT1={}: UTC should be behind UT1 by ~DUT1: {} seconds",
340                dut1,
341                reverse_diff
342            );
343        }
344
345        let ut1_normal = UT1::from_julian_date(JulianDate::new(J2000_JD, 0.5));
346        let utc_normal = ut1_normal.to_utc_with_dut1(0.3).unwrap();
347        assert!(
348            utc_normal.to_julian_date().jd1().abs() > utc_normal.to_julian_date().jd2().abs(),
349            "Should preserve larger JD1 component"
350        );
351
352        let ut1_flipped = UT1::from_julian_date(JulianDate::new(0.1, J2000_JD));
353        let utc_flipped = ut1_flipped.to_utc_with_dut1(0.3).unwrap();
354        assert!(
355            utc_flipped.to_julian_date().jd2().abs() > utc_flipped.to_julian_date().jd1().abs(),
356            "Should preserve larger JD2 component"
357        );
358    }
359
360    #[test]
361    fn test_utc_ut1_round_trip_precision() {
362        let tolerance = 1e-14; // ~1 picosecond
363
364        let jd_splits = [
365            (J2000_JD, 0.123456789),
366            (J2000_JD, 0.5),
367            (0.1, J2000_JD),
368            (J2000_JD, 0.999999999),
369            (J2000_JD, 0.25),
370        ];
371
372        let dut1_values = [-0.9, 0.0, 0.9];
373
374        for (jd1, jd2) in jd_splits {
375            for dut1 in dut1_values {
376                let original_utc = UTC::from_julian_date(JulianDate::new(jd1, jd2));
377                let ut1 = original_utc.to_ut1_with_dut1(dut1).unwrap();
378                let round_trip_utc = ut1.to_utc_with_dut1(dut1).unwrap();
379
380                let diff = (original_utc.to_julian_date().to_f64()
381                    - round_trip_utc.to_julian_date().to_f64())
382                .abs();
383                assert!(
384                    diff < tolerance,
385                    "UTC->UT1->UTC round trip (jd1={}, jd2={}, dut1={}): {:.2e} days exceeds {:.0e}",
386                    jd1,
387                    jd2,
388                    dut1,
389                    diff,
390                    tolerance
391                );
392
393                let original_ut1 = UT1::from_julian_date(JulianDate::new(jd1, jd2));
394                let utc = original_ut1.to_utc_with_dut1(dut1).unwrap();
395                let round_trip_ut1 = utc.to_ut1_with_dut1(dut1).unwrap();
396
397                let diff_reverse = (original_ut1.to_julian_date().to_f64()
398                    - round_trip_ut1.to_julian_date().to_f64())
399                .abs();
400                assert!(
401                    diff_reverse < tolerance,
402                    "UT1->UTC->UT1 round trip (jd1={}, jd2={}, dut1={}): {:.2e} days exceeds {:.0e}",
403                    jd1,
404                    jd2,
405                    dut1,
406                    diff_reverse,
407                    tolerance
408                );
409            }
410        }
411    }
412
413    #[test]
414    fn test_leap_second_boundary_handling() {
415        let leap_dates = [
416            (2441499.5, "1972-07-01"),
417            (2441683.5, "1973-01-01"),
418            (2442048.5, "1974-01-01"),
419        ];
420
421        for (jd, description) in leap_dates {
422            let ut1_at_leap = UT1::from_julian_date(JulianDate::new(jd, 0.0));
423            let utc = ut1_at_leap.to_utc_with_dut1(0.0).unwrap();
424            assert!(
425                utc.to_julian_date().to_f64() > 0.0,
426                "{}: Should produce valid UTC at leap second",
427                description
428            );
429
430            let ut1_after_leap = UT1::from_julian_date(JulianDate::new(jd, 0.001));
431            let utc_after = ut1_after_leap.to_utc_with_dut1(0.0).unwrap();
432            assert!(
433                utc_after.to_julian_date().to_f64() > jd,
434                "{}: UTC should be after leap second start",
435                description
436            );
437
438            let utc_direct = ut1_at_leap.to_utc_with_dut1(0.0).unwrap();
439            let utc_via_tai = ut1_at_leap.to_utc_via_tai_with_dut1(0.0).unwrap();
440            let diff = (utc_direct.to_julian_date().to_f64()
441                - utc_via_tai.to_julian_date().to_f64())
442            .abs();
443            assert!(
444                diff < 1e-14,
445                "{}: Direct vs TAI-intermediate should match: {:.2e} days",
446                description,
447                diff
448            );
449        }
450    }
451}