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}