Skip to main content

celestial_time/scales/conversions/
utc_tai.rs

1//! Conversions between Coordinated Universal Time (UTC) and International Atomic Time (TAI).
2//!
3//! UTC and TAI are both atomic time scales, but UTC includes leap seconds to stay within
4//! 0.9 seconds of UT1 (Earth rotation time). TAI runs continuously without adjustments.
5//!
6//! # The UTC-TAI Relationship
7//!
8//! TAI is always ahead of UTC by an integer number of seconds (since 1972). The offset
9//! started at 10 seconds on 1972-01-01 and has grown to 37 seconds as of 2017-01-01:
10//!
11//! ```text
12//! TAI = UTC + (leap seconds accumulated)
13//! ```
14//!
15//! Before 1972, the relationship was more complex, involving both step offsets and
16//! continuous drift corrections.
17//!
18//! # Leap Seconds
19//!
20//! Leap seconds keep UTC synchronized with Earth's rotation:
21//!
22//! - The IERS monitors the difference between UT1 (Earth rotation) and UTC
23//! - When |UT1 - UTC| approaches 0.9 seconds, a leap second is announced
24//! - Leap seconds are inserted at the end of June 30 or December 31
25//! - Only positive leap seconds have occurred (Earth rotation is slowing)
26//! - Since 1972, leap seconds have been exactly 1 second adjustments
27//!
28//! The leap second table (`TAI_UTC_OFFSETS` in constants.rs) records all adjustments:
29//!
30//! | Date       | TAI-UTC (seconds) |
31//! |------------|-------------------|
32//! | 1972-01-01 | 10.0              |
33//! | 1972-07-01 | 11.0              |
34//! | ...        | ...               |
35//! | 2017-01-01 | 37.0              |
36//!
37//! # Pre-1972 Handling
38//!
39//! Before the modern leap second system, UTC used a different adjustment model:
40//!
41//! - Step offsets at irregular intervals (not exactly 1 second)
42//! - Continuous drift corrections between steps
43//! - The `UTC_DRIFT_CORRECTIONS` table provides (MJD reference, drift rate) pairs
44//!
45//! For pre-1972 dates, the offset is computed as:
46//!
47//! ```text
48//! TAI - UTC = base_offset + (MJD - reference_MJD) * drift_rate
49//! ```
50//!
51//! The first 14 entries in `TAI_UTC_OFFSETS` (indices 0-13) use this drift model.
52//!
53//! # TAI to UTC Conversion Algorithm
54//!
55//! Converting TAI to UTC requires finding which UTC day corresponds to a given TAI instant.
56//! This is non-trivial because leap seconds create discontinuities. The algorithm uses
57//! iterative refinement:
58//!
59//! 1. Start with a UTC guess equal to TAI
60//! 2. Convert the UTC guess to TAI
61//! 3. Compute the difference from the target TAI
62//! 4. Adjust the UTC guess by this difference
63//! 5. Repeat for 3 iterations (converges to sub-picosecond accuracy)
64//!
65//! Three iterations suffice because the leap second table lookup is stable once
66//! we're within a few seconds of the correct UTC.
67//!
68//! # UTC to TAI Conversion Algorithm
69//!
70//! The forward conversion (UTC to TAI) handles leap seconds and drift corrections:
71//!
72//! 1. Convert Julian Date to calendar date (year, month, day, fraction)
73//! 2. Look up the TAI-UTC offset at the start of the day (0h)
74//! 3. Look up the offset at mid-day (12h) to detect drift (pre-1972)
75//! 4. Look up the offset at the start of the next day to detect leap seconds
76//! 5. Apply drift and leap second corrections to the day fraction
77//! 6. Add the base offset to get TAI
78//!
79//! The three-point lookup (0h, 12h, next day 0h) correctly handles both:
80//! - Pre-1972 linear drift (detected by 0h vs 12h difference)
81//! - Leap seconds (detected by comparing end of day to start of next day)
82//!
83//! # Precision
84//!
85//! Round-trip conversions (UTC -> TAI -> UTC or TAI -> UTC -> TAI) achieve ~1 picosecond
86//! accuracy. The iterative refinement and careful handling of Julian Date components
87//! preserve floating-point precision.
88//!
89//! # Usage
90//!
91//! ```
92//! use celestial_time::scales::{UTC, TAI};
93//! use celestial_time::scales::conversions::{ToTAI, ToUTC};
94//! use celestial_time::julian::JulianDate;
95//! use celestial_core::constants::J2000_JD;
96//!
97//! // At J2000.0 (2000-01-01 12:00 TT), TAI-UTC = 32 seconds
98//! let utc = UTC::from_julian_date(JulianDate::new(J2000_JD, 0.0));
99//! let tai = utc.to_tai().unwrap();
100//!
101//! let offset_days = tai.to_julian_date().to_f64() - utc.to_julian_date().to_f64();
102//! let offset_seconds = offset_days * 86400.0;
103//! assert!((offset_seconds - 32.0).abs() < 0.001);
104//! ```
105//!
106//! # Helper Functions
107//!
108//! This module also provides calendar conversion utilities used by the UTC-TAI algorithms:
109//!
110//! - [`julian_to_calendar`]: Convert Julian Date to (year, month, day, day_fraction)
111//! - [`calendar_to_julian`]: Convert (year, month, day) to Julian Date
112//!
113//! These functions handle the full range of historical dates and use compensated
114//! summation (Kahan algorithm) to preserve precision when combining Julian Date components.
115//!
116//! # References
117//!
118//! - IERS Bulletins: Leap second announcements
119//! - USNO: History of leap seconds and TAI-UTC differences
120//! - ITU-R TF.460-6: Standard-frequency and time-signal emissions
121//! - Explanatory Supplement to the Astronomical Almanac, 3rd ed., Chapter 3
122
123use super::super::common::{get_tai_utc_offset, next_calendar_day};
124use super::{ToTAI, ToUTC};
125use crate::julian::JulianDate;
126use crate::scales::{TAI, UTC};
127use crate::{TimeError, TimeResult};
128use celestial_core::constants::{MJD_ZERO_POINT, SECONDS_PER_DAY_F64};
129
130impl ToTAI for UTC {
131    /// Convert UTC to TAI by adding the accumulated leap seconds.
132    ///
133    /// Looks up the TAI-UTC offset for the given date and applies drift corrections
134    /// for pre-1972 dates. Handles leap second boundaries correctly.
135    fn to_tai(&self) -> TimeResult<TAI> {
136        utc_to_tai(self.to_julian_date())
137    }
138}
139
140impl ToUTC for TAI {
141    /// Convert TAI to UTC using iterative refinement.
142    ///
143    /// Uses 3 iterations to converge on the correct UTC instant, handling
144    /// leap second boundaries where a single TAI instant may map to the
145    /// leap second itself.
146    fn to_utc(&self) -> TimeResult<UTC> {
147        tai_to_utc(self.to_julian_date())
148    }
149}
150
151impl ToUTC for UTC {
152    /// Identity conversion. Returns self unchanged.
153    fn to_utc(&self) -> TimeResult<UTC> {
154        Ok(*self)
155    }
156}
157
158/// Convert a UTC Julian Date to TAI.
159///
160/// This function handles both the modern leap second era (1972+) and the pre-1972
161/// drift correction era. The algorithm:
162///
163/// 1. Separates the Julian Date into integer and fractional parts, tracking which
164///    component has larger magnitude for precision preservation.
165///
166/// 2. Converts to calendar date to look up the appropriate TAI-UTC offset.
167///
168/// 3. Samples the offset at three points to detect drift and leap seconds:
169///    - Start of day (0h): base offset
170///    - Mid-day (12h): detects linear drift (pre-1972)
171///    - Start of next day: detects leap seconds
172///
173/// 4. Computes drift rate from the 0h/12h difference (zero for post-1972 dates).
174///
175/// 5. Computes leap second amount from the end-of-day discontinuity.
176///
177/// 6. Scales the day fraction to account for drift and leap seconds, then adds
178///    the base offset.
179///
180/// The correction is applied to the smaller-magnitude JD component to preserve
181/// floating-point precision.
182pub fn utc_to_tai(utc_jd: JulianDate) -> TimeResult<TAI> {
183    let (utc_int, utc_frac, big1) = if utc_jd.jd1().abs() >= utc_jd.jd2().abs() {
184        (utc_jd.jd1(), utc_jd.jd2(), true)
185    } else {
186        (utc_jd.jd2(), utc_jd.jd1(), false)
187    };
188
189    let (year, month, day, mut day_fraction) = julian_to_calendar(utc_int, utc_frac)?;
190
191    let offset_0h = get_tai_utc_offset(year, month, day, 0.0);
192
193    let offset_12h = get_tai_utc_offset(year, month, day, 0.5);
194
195    let (next_year, next_month, next_day) = next_calendar_day(year, month, day)?;
196    let offset_24h = get_tai_utc_offset(next_year, next_month, next_day, 0.0);
197
198    let drift_rate = 2.0 * (offset_12h - offset_0h);
199    let leap_amount = offset_24h - (offset_0h + drift_rate);
200
201    day_fraction *= (SECONDS_PER_DAY_F64 + leap_amount) / SECONDS_PER_DAY_F64;
202    day_fraction *= (SECONDS_PER_DAY_F64 + drift_rate) / SECONDS_PER_DAY_F64;
203
204    let (z1, z2) = calendar_to_julian(year, month, day);
205
206    let mut tai_frac = z1 - utc_int;
207    tai_frac += z2;
208    tai_frac += day_fraction + offset_0h / SECONDS_PER_DAY_F64;
209
210    let (tai_jd1, tai_jd2) = if big1 {
211        (utc_int, tai_frac)
212    } else {
213        (tai_frac, utc_int)
214    };
215
216    Ok(TAI::from_julian_date(JulianDate::new(tai_jd1, tai_jd2)))
217}
218
219/// Convert a TAI Julian Date to UTC using iterative refinement.
220///
221/// The inverse conversion (TAI to UTC) cannot be done with a simple table lookup
222/// because leap seconds create discontinuities: during a leap second, UTC stays
223/// at 23:59:60 while TAI advances. The algorithm uses Newton-like iteration:
224///
225/// 1. Initialize UTC guess = TAI (close enough to converge quickly).
226///
227/// 2. For each iteration:
228///    - Convert the UTC guess to TAI using `utc_to_tai`
229///    - Compute the residual: target_TAI - computed_TAI
230///    - Add the residual to the UTC guess
231///
232/// 3. After 3 iterations, the UTC value is accurate to ~1 picosecond.
233///
234/// The iteration count of 3 is sufficient because:
235/// - The initial guess is within ~37 seconds of the answer
236/// - Each iteration reduces the error by a factor of ~10^14
237/// - Floating-point precision limits further improvement
238///
239/// The algorithm correctly handles leap second boundaries where the UTC day
240/// "stretches" to include 86401 seconds.
241pub fn tai_to_utc(tai_jd: JulianDate) -> TimeResult<UTC> {
242    const TAI_TO_UTC_ITERATIONS: usize = 3;
243    let (tai_int, tai_frac, big1) = if tai_jd.jd1().abs() >= tai_jd.jd2().abs() {
244        (tai_jd.jd1(), tai_jd.jd2(), true)
245    } else {
246        (tai_jd.jd2(), tai_jd.jd1(), false)
247    };
248
249    let utc_int = tai_int;
250    let mut utc_frac = tai_frac;
251
252    for _ in 0..TAI_TO_UTC_ITERATIONS {
253        let guess_tai = utc_to_tai_jd(utc_int, utc_frac)?;
254        utc_frac += tai_int - guess_tai.jd1();
255        utc_frac += tai_frac - guess_tai.jd2();
256    }
257
258    let (utc_jd1, utc_jd2) = if big1 {
259        (utc_int, utc_frac)
260    } else {
261        (utc_frac, utc_int)
262    };
263
264    Ok(UTC::from_julian_date(JulianDate::new(utc_jd1, utc_jd2)))
265}
266
267/// Helper for iterative TAI->UTC conversion.
268///
269/// Wraps `utc_to_tai` to work with separate JD components, preserving the
270/// split-JD precision during iteration.
271fn utc_to_tai_jd(utc_int: f64, utc_frac: f64) -> TimeResult<JulianDate> {
272    let utc = UTC::from_julian_date(JulianDate::new(utc_int, utc_frac));
273    let tai = utc.to_tai()?;
274    Ok(tai.to_julian_date())
275}
276
277/// Convert a two-part Julian Date to calendar date with day fraction.
278///
279/// Returns `(year, month, day, day_fraction)` where:
280/// - `year`: Gregorian year (can be negative for BCE dates)
281/// - `month`: 1-12
282/// - `day`: 1-31
283/// - `day_fraction`: 0.0 to 1.0 (fraction of day from midnight)
284///
285/// # Algorithm
286///
287/// The conversion uses integer arithmetic for the calendar calculation (avoiding
288/// floating-point error accumulation) and Kahan compensated summation for the
289/// fractional day.
290///
291/// Steps:
292/// 1. Round each JD component to the nearest integer, keeping the fractional parts
293/// 2. Sum the fractional parts using Kahan summation (adds 0.5 to shift from noon to midnight)
294/// 3. Handle edge cases where the fraction overflows [0, 1)
295/// 4. Apply the standard algorithm for Julian Day Number to Gregorian calendar
296///
297/// The compensated summation is critical: without it, adding two fractional parts
298/// can lose precision when they have opposite signs or very different magnitudes.
299///
300/// # Valid Range
301///
302/// - Minimum: JD -68569.5 (around 4713 BCE, near the Julian epoch)
303/// - Maximum: JD 1e9 (far future)
304///
305/// # Errors
306///
307/// Returns `TimeError::ConversionError` if the Julian Date is outside the valid range.
308///
309/// # Example
310///
311/// ```ignore
312/// let (year, month, day, frac) = julian_to_calendar(2451545.0, 0.0)?;
313/// assert_eq!((year, month, day), (2000, 1, 1));  // J2000.0 epoch
314/// assert!((frac - 0.5).abs() < 1e-10);  // Noon
315/// ```
316pub fn julian_to_calendar(jd1: f64, jd2: f64) -> TimeResult<(i32, i32, i32, f64)> {
317    let dj = jd1 + jd2;
318    const DJMIN: f64 = -68569.5;
319    const DJMAX: f64 = 1e9;
320
321    if !(DJMIN..=DJMAX).contains(&dj) {
322        return Err(TimeError::ConversionError(format!(
323            "Julian Date {} out of valid range [{}, {}]",
324            dj, DJMIN, DJMAX
325        )));
326    }
327
328    fn nearest_int(a: f64) -> f64 {
329        if a.abs() < 0.5 {
330            0.0
331        } else if a < 0.0 {
332            libm::ceil(a - 0.5)
333        } else {
334            libm::floor(a + 0.5)
335        }
336    }
337
338    let day_int_1 = nearest_int(jd1);
339    let frac_1 = jd1 - day_int_1;
340    let mut jd = day_int_1 as i64;
341
342    let day_int_2 = nearest_int(jd2);
343    let frac_2 = jd2 - day_int_2;
344    jd += day_int_2 as i64;
345
346    let mut sum = 0.5;
347    let mut correction = 0.0;
348    let fractions = [frac_1, frac_2];
349
350    for frac in fractions.iter() {
351        let temp = sum + frac;
352        correction += if sum.abs() >= frac.abs() {
353            (sum - temp) + frac
354        } else {
355            (frac - temp) + sum
356        };
357        sum = temp;
358
359        if sum >= 1.0 {
360            jd += 1;
361            sum -= 1.0;
362        }
363    }
364    let mut fraction = sum + correction;
365    correction = fraction - sum;
366
367    if fraction < 0.0 {
368        fraction = sum + 1.0;
369        correction += (1.0 - fraction) + sum;
370        sum = fraction;
371        fraction = sum + correction;
372        correction = fraction - sum;
373        jd -= 1
374    }
375
376    #[allow(clippy::excessive_precision)]
377    const DBL_EPSILON: f64 = 2.220_446_049_250_313_1e-16;
378    if (fraction - 1.0) >= -DBL_EPSILON / 4.0 {
379        let temp = sum - 1.0;
380        correction += (sum - temp) - 1.0;
381        sum = temp;
382        fraction = sum + correction;
383
384        if (-DBL_EPSILON / 2.0) < fraction {
385            jd += 1;
386            fraction = if fraction > 0.0 { fraction } else { 0.0 };
387        }
388    }
389
390    let mut l = jd + 68569_i64;
391    let n = (4_i64 * l) / 146097_i64;
392    l -= (146097_i64 * n + 3_i64) / 4_i64;
393    let i = (4000_i64 * (l + 1_i64)) / 1461001_i64;
394    l -= (1461_i64 * i) / 4_i64 - 31_i64;
395    let k = (80_i64 * l) / 2447_i64;
396    let day = (l - (2447_i64 * k) / 80_i64) as i32;
397    let l_final = k / 11_i64;
398    let month = (k + 2_i64 - 12_i64 * l_final) as i32;
399    let year = (100_i64 * (n - 49_i64) + i + l_final) as i32;
400
401    Ok((year, month, day, fraction))
402}
403
404/// Convert a calendar date to a two-part Julian Date.
405///
406/// Returns `(jd1, jd2)` where:
407/// - `jd1` = MJD zero point (2400000.5)
408/// - `jd2` = Modified Julian Date for the given calendar date at midnight
409///
410/// This split preserves precision: the large constant is in jd1, and the
411/// smaller date-dependent value is in jd2.
412///
413/// # Algorithm
414///
415/// Uses the standard formula for Gregorian calendar to Julian Day Number,
416/// expressed as a Modified Julian Date for precision.
417///
418/// # Example
419///
420/// ```ignore
421/// let (jd1, jd2) = calendar_to_julian(2000, 1, 1);
422/// // jd1 + jd2 = JD 2451544.5 (midnight starting J2000.0)
423/// ```
424pub fn calendar_to_julian(year: i32, month: i32, day: i32) -> (f64, f64) {
425    let my = (month - 14) / 12;
426    let iypmy = year + my;
427
428    let modified_jd = ((1461 * (iypmy + 4800)) / 4 + (367 * (month - 2 - 12 * my)) / 12
429        - (3 * ((iypmy + 4900) / 100)) / 4
430        + day
431        - 2432076) as f64;
432
433    (MJD_ZERO_POINT, modified_jd)
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use celestial_core::constants::J2000_JD;
440
441    #[test]
442    fn test_identity_conversions() {
443        let utc = UTC::from_julian_date(JulianDate::new(J2000_JD, 0.5));
444        let result = utc.to_utc().unwrap();
445        assert_eq!(utc.to_julian_date().jd1(), result.to_julian_date().jd1());
446        assert_eq!(utc.to_julian_date().jd2(), result.to_julian_date().jd2());
447    }
448
449    #[test]
450    fn test_utc_tai_leap_second_offset() {
451        // Known leap second values at key dates (TAI-UTC in seconds)
452        assert_eq!(get_tai_utc_offset(1972, 1, 1, 0.0), 10.0); // First leap second era
453        assert_eq!(get_tai_utc_offset(1980, 1, 1, 0.0), 19.0);
454        assert_eq!(get_tai_utc_offset(1999, 1, 1, 0.0), 32.0);
455        assert_eq!(get_tai_utc_offset(2017, 1, 1, 0.0), 37.0); // Most recent
456
457        // Pre-1972 drift era (before discrete leap seconds)
458        let offset_1970 = get_tai_utc_offset(1970, 6, 15, 0.5);
459        assert!(offset_1970 > 0.0 && offset_1970 < 15.0);
460
461        // Edge cases returning 0.0
462        assert_eq!(get_tai_utc_offset(1959, 12, 31, 0.0), 0.0); // Before 1960
463        assert_eq!(get_tai_utc_offset(2000, 1, 1, 1.5), 0.0); // Invalid fraction > 1
464        assert_eq!(get_tai_utc_offset(2000, 1, 1, -0.5), 0.0); // Invalid fraction < 0
465        assert_eq!(get_tai_utc_offset(1960, 0, 1, 0.0), 0.0); // Loop exhaustion
466    }
467
468    #[test]
469    fn test_utc_tai_round_trip_precision() {
470        // Tolerance for iterative refinement (3 iterations in tai_to_utc)
471        // 1e-14 days ~ 1 picosecond, acceptable for iterative algorithm
472        const TOLERANCE: f64 = 1e-14;
473
474        let test_cases: &[(f64, f64)] = &[
475            (J2000_JD, 0.123456789),
476            (J2000_JD, 0.0),
477            (J2000_JD, 0.999999),
478            (0.5, J2000_JD), // jd2 > jd1 (alternate split in utc_to_tai)
479            (0.1, celestial_core::constants::J2000_JD), // jd2 > jd1 (alternate split in tai_to_utc)
480        ];
481
482        for &(jd1, jd2) in test_cases {
483            // UTC -> TAI -> UTC round trip
484            let utc = UTC::from_julian_date(JulianDate::new(jd1, jd2));
485            let tai = utc.to_tai().unwrap();
486            let utc_back = tai.to_utc().unwrap();
487            let diff_utc =
488                (utc.to_julian_date().to_f64() - utc_back.to_julian_date().to_f64()).abs();
489            assert!(
490                diff_utc < TOLERANCE,
491                "UTC->TAI->UTC failed for ({}, {}): diff={:.2e}",
492                jd1,
493                jd2,
494                diff_utc
495            );
496
497            // TAI -> UTC -> TAI round trip
498            let tai = TAI::from_julian_date(JulianDate::new(jd1, jd2));
499            let utc = tai.to_utc().unwrap();
500            let tai_back = utc.to_tai().unwrap();
501            let diff_tai =
502                (tai.to_julian_date().to_f64() - tai_back.to_julian_date().to_f64()).abs();
503            assert!(
504                diff_tai < TOLERANCE,
505                "TAI->UTC->TAI failed for ({}, {}): diff={:.2e}",
506                jd1,
507                jd2,
508                diff_tai
509            );
510        }
511    }
512
513    #[test]
514    fn test_calendar_helper_functions() {
515        // next_calendar_day: month transitions
516        assert_eq!(next_calendar_day(2000, 1, 31).unwrap(), (2000, 2, 1));
517        assert_eq!(next_calendar_day(2000, 12, 31).unwrap(), (2001, 1, 1));
518        assert_eq!(next_calendar_day(2000, 2, 29).unwrap(), (2000, 3, 1));
519
520        // next_calendar_day: leap year February
521        assert_eq!(next_calendar_day(2000, 2, 28).unwrap(), (2000, 2, 29));
522        assert_eq!(next_calendar_day(1900, 2, 28).unwrap(), (1900, 3, 1));
523
524        // next_calendar_day: 30-day months
525        assert_eq!(next_calendar_day(2000, 4, 30).unwrap(), (2000, 5, 1));
526        assert_eq!(next_calendar_day(2000, 6, 30).unwrap(), (2000, 7, 1));
527        assert_eq!(next_calendar_day(2000, 9, 30).unwrap(), (2000, 10, 1));
528        assert_eq!(next_calendar_day(2000, 11, 30).unwrap(), (2000, 12, 1));
529
530        // next_calendar_day: error cases
531        assert!(next_calendar_day(2000, 0, 1).is_err());
532        assert!(next_calendar_day(2000, 13, 1).is_err());
533        assert!(next_calendar_day(2000, -1, 1).is_err());
534
535        // julian_to_calendar: out of range errors
536        assert!(julian_to_calendar(1e10, 0.0).is_err());
537        assert!(julian_to_calendar(-1e6, 0.0).is_err());
538
539        // julian_to_calendar: negative fraction correction path
540        let (y, m, d, frac) =
541            julian_to_calendar(celestial_core::constants::J2000_JD, -0.6).unwrap();
542        assert!(y > 0 && (1..=12).contains(&m) && (1..=31).contains(&d) && frac >= 0.0);
543
544        // julian_to_calendar: Kahan summation else branch (|frac_2| > |sum|)
545        let (y, m, d, frac) = julian_to_calendar(2451544.6, 0.2).unwrap();
546        assert!(y > 0 && (1..=12).contains(&m) && (1..=31).contains(&d));
547        assert!(frac >= 0.0 && frac <= 1.0);
548
549        // julian_to_calendar: near-1.0 fraction correction
550        let (y, m, d, frac) = julian_to_calendar(2451544.75, 0.75).unwrap();
551        assert!(y > 0 && (1..=12).contains(&m) && (1..=31).contains(&d));
552        assert!(frac >= 0.0 && frac <= 1.0);
553    }
554}