Skip to main content

celestial_time/scales/conversions/
gps_tai.rs

1//! GPS and TAI time scale conversions.
2//!
3//! Provides bidirectional conversion between GPS Time and International Atomic Time (TAI).
4//! The relationship is a fixed offset: TAI = GPS + 19 seconds.
5//!
6//! # Background
7//!
8//! GPS Time started on January 6, 1980 (GPS epoch) when TAI-GPS was exactly 19 seconds.
9//! Unlike UTC, GPS does not include leap seconds, so this offset remains constant.
10//!
11//! ```text
12//! TAI = GPS + 19.0 seconds
13//! GPS = TAI - 19.0 seconds
14//! ```
15//!
16//! # Usage
17//!
18//! ```
19//! use celestial_time::{JulianDate, GPS, TAI};
20//! use celestial_time::scales::conversions::{ToGPS, ToTAI};
21//!
22//! let gps = GPS::from_julian_date(JulianDate::new(2451545.0, 0.0));
23//! let tai = gps.to_tai().unwrap();
24//!
25//! let back_to_gps = tai.to_gps().unwrap();
26//! ```
27//!
28//! # Precision
29//!
30//! Conversions are exact. Round-trip GPS -> TAI -> GPS preserves both JD components
31//! with no floating-point error, as only addition/subtraction of a fixed offset occurs.
32
33use super::{ToGPS, ToTAI};
34use crate::constants::GPS_TO_TAI_OFFSET_SECONDS;
35use crate::scales::{GPS, TAI};
36use crate::TimeResult;
37use celestial_core::constants::SECONDS_PER_DAY_F64;
38
39/// Identity conversion for GPS.
40impl ToGPS for GPS {
41    /// Returns self unchanged.
42    fn to_gps(&self) -> TimeResult<GPS> {
43        Ok(*self)
44    }
45}
46
47/// GPS to TAI conversion. Adds 19 seconds.
48impl ToTAI for GPS {
49    /// Converts GPS time to TAI by adding the fixed 19-second offset.
50    ///
51    /// The offset is added to the smaller-magnitude JD component to preserve precision.
52    fn to_tai(&self) -> TimeResult<TAI> {
53        let gps_jd = self.to_julian_date();
54        let offset_days = GPS_TO_TAI_OFFSET_SECONDS / SECONDS_PER_DAY_F64;
55
56        let (tai_jd1, tai_jd2) = if gps_jd.jd1().abs() > gps_jd.jd2().abs() {
57            (gps_jd.jd1(), gps_jd.jd2() + offset_days)
58        } else {
59            (gps_jd.jd1() + offset_days, gps_jd.jd2())
60        };
61
62        Ok(TAI::from_julian_date_raw(tai_jd1, tai_jd2))
63    }
64}
65
66/// TAI to GPS conversion. Subtracts 19 seconds.
67impl ToGPS for TAI {
68    /// Converts TAI to GPS time by subtracting the fixed 19-second offset.
69    ///
70    /// The offset is subtracted from the smaller-magnitude JD component to preserve precision.
71    fn to_gps(&self) -> TimeResult<GPS> {
72        let tai_jd = self.to_julian_date();
73        let offset_days = GPS_TO_TAI_OFFSET_SECONDS / SECONDS_PER_DAY_F64;
74
75        let (gps_jd1, gps_jd2) = if tai_jd.jd1().abs() > tai_jd.jd2().abs() {
76            (tai_jd.jd1(), tai_jd.jd2() - offset_days)
77        } else {
78            (tai_jd.jd1() - offset_days, tai_jd.jd2())
79        };
80
81        Ok(GPS::from_julian_date_raw(gps_jd1, gps_jd2))
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::constants::GPS_EPOCH_JD;
89    use crate::JulianDate;
90    use celestial_core::constants::J2000_JD;
91
92    #[test]
93    fn test_gps_identity_conversion() {
94        let gps = GPS::from_julian_date(JulianDate::new(J2000_JD, 0.999999999999999));
95        let identity_gps = gps.to_gps().unwrap();
96
97        assert_eq!(
98            gps.to_julian_date().jd1(),
99            identity_gps.to_julian_date().jd1(),
100            "GPS identity conversion should preserve JD1"
101        );
102        assert_eq!(
103            gps.to_julian_date().jd2(),
104            identity_gps.to_julian_date().jd2(),
105            "GPS identity conversion should preserve JD2"
106        );
107    }
108
109    #[test]
110    fn test_gps_tai_offset_19_seconds() {
111        let test_dates = [
112            (GPS_EPOCH_JD, "GPS epoch 1980-01-06"),
113            (J2000_JD, "J2000.0"),
114            (2455197.5, "2010-01-01"),
115            (2459580.5, "2022-01-01"),
116        ];
117
118        for (jd, description) in test_dates {
119            let gps = GPS::from_julian_date(JulianDate::new(jd, 0.0));
120            let tai = gps.to_tai().unwrap();
121
122            let gps_jd = gps.to_julian_date();
123            let tai_jd = tai.to_julian_date();
124
125            let offset_days = (tai_jd.jd1() - gps_jd.jd1()) + (tai_jd.jd2() - gps_jd.jd2());
126            let offset_seconds = offset_days * SECONDS_PER_DAY_F64;
127
128            assert_eq!(
129                offset_seconds, 19.0,
130                "{}: GPS->TAI offset must be exactly 19 seconds",
131                description
132            );
133
134            let tai = TAI::from_julian_date(JulianDate::new(jd, 0.0));
135            let gps = tai.to_gps().unwrap();
136
137            let tai_jd = tai.to_julian_date();
138            let gps_jd = gps.to_julian_date();
139
140            let offset_days = (tai_jd.jd1() - gps_jd.jd1()) + (tai_jd.jd2() - gps_jd.jd2());
141            let offset_seconds = offset_days * SECONDS_PER_DAY_F64;
142
143            assert_eq!(
144                offset_seconds, 19.0,
145                "{}: TAI->GPS means TAI is 19 seconds ahead",
146                description
147            );
148        }
149    }
150
151    #[test]
152    fn test_gps_tai_round_trip_precision() {
153        let test_jd2_values = [0.0, 0.5, 0.123456789012345, -0.123456789012345, 0.987654321];
154
155        for jd2 in test_jd2_values {
156            let original_gps = GPS::from_julian_date(JulianDate::new(J2000_JD, jd2));
157            let tai = original_gps.to_tai().unwrap();
158            let round_trip_gps = tai.to_gps().unwrap();
159
160            assert_eq!(
161                original_gps.to_julian_date().jd1(),
162                round_trip_gps.to_julian_date().jd1(),
163                "GPS->TAI->GPS JD1 must be exact for jd2={}",
164                jd2
165            );
166            assert_eq!(
167                original_gps.to_julian_date().jd2(),
168                round_trip_gps.to_julian_date().jd2(),
169                "GPS->TAI->GPS JD2 must be exact for jd2={}",
170                jd2
171            );
172
173            let original_tai = TAI::from_julian_date(JulianDate::new(J2000_JD, jd2));
174            let gps = original_tai.to_gps().unwrap();
175            let round_trip_tai = gps.to_tai().unwrap();
176
177            assert_eq!(
178                original_tai.to_julian_date().jd1(),
179                round_trip_tai.to_julian_date().jd1(),
180                "TAI->GPS->TAI JD1 must be exact for jd2={}",
181                jd2
182            );
183            assert_eq!(
184                original_tai.to_julian_date().jd2(),
185                round_trip_tai.to_julian_date().jd2(),
186                "TAI->GPS->TAI JD2 must be exact for jd2={}",
187                jd2
188            );
189        }
190
191        let alt_gps = GPS::from_julian_date(JulianDate::new(0.5, J2000_JD));
192        let alt_tai = alt_gps.to_tai().unwrap();
193        let alt_round_trip = alt_tai.to_gps().unwrap();
194
195        assert_eq!(
196            alt_gps.to_julian_date().jd1(),
197            alt_round_trip.to_julian_date().jd1(),
198            "Alternate split GPS->TAI->GPS JD1 must be exact"
199        );
200        assert_eq!(
201            alt_gps.to_julian_date().jd2(),
202            alt_round_trip.to_julian_date().jd2(),
203            "Alternate split GPS->TAI->GPS JD2 must be exact"
204        );
205    }
206}