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}