Skip to main content

celestial_time/scales/
gps.rs

1//! GPS Time scale.
2//!
3//! GPS Time is the time standard used by GPS satellites. It is synchronized with TAI
4//! but offset by exactly 19 seconds: `TAI = GPS + 19s`.
5//!
6//! # Background
7//!
8//! GPS Time started on January 6, 1980 at 00:00:00 UTC. At that moment, GPS and UTC
9//! were synchronized, and TAI was already 19 seconds ahead of UTC. GPS does not
10//! include leap seconds, so the TAI-GPS offset remains constant while UTC-GPS
11//! diverges with each new leap second.
12//!
13//! As of 2024, UTC is 18 seconds behind GPS (37 seconds behind TAI).
14//!
15//! # Representation
16//!
17//! Internally stored as a split Julian Date for nanosecond-level precision.
18//! See [`JulianDate`] for details on the two-part representation.
19//!
20//! # Usage
21//!
22//! ```
23//! use celestial_time::{GPS, JulianDate};
24//!
25//! // From calendar date
26//! let gps = celestial_time::scales::gps::gps_from_calendar(2024, 3, 15, 12, 0, 0.0);
27//!
28//! // From Julian Date
29//! let gps = GPS::from_julian_date(JulianDate::j2000());
30//!
31//! // Arithmetic
32//! let later = gps.add_seconds(3600.0);
33//! let next_day = gps.add_days(1.0);
34//! ```
35//!
36//! # Conversions
37//!
38//! GPS converts to/from TAI via a fixed 19-second offset. See
39//! [`scales::conversions::gps_tai`](crate::scales::conversions) for the conversion traits.
40
41use crate::constants::UNIX_EPOCH_JD;
42use crate::julian::JulianDate;
43use crate::parsing::parse_iso8601;
44use crate::{TimeError, TimeResult};
45use celestial_core::constants::SECONDS_PER_DAY_F64;
46use std::fmt;
47use std::str::FromStr;
48
49/// GPS Time representation.
50///
51/// Wraps a [`JulianDate`] to provide type safety and GPS-specific operations.
52/// The inner Julian Date uses split storage (jd1 + jd2) to preserve precision.
53#[derive(Debug, Clone, Copy, PartialEq)]
54#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
55pub struct GPS(JulianDate);
56
57impl GPS {
58    /// Creates GPS time from Unix timestamp components.
59    ///
60    /// Converts seconds and nanoseconds since Unix epoch (1970-01-01 00:00:00)
61    /// to a Julian Date representation.
62    pub fn new(seconds: i64, nanos: u32) -> Self {
63        let total_seconds =
64            seconds as f64 + nanos as f64 / celestial_core::constants::NANOSECONDS_PER_SECOND_F64;
65        let jd = JulianDate::from_f64(UNIX_EPOCH_JD + total_seconds / SECONDS_PER_DAY_F64);
66        Self(jd)
67    }
68
69    /// Creates GPS time from a Julian Date.
70    pub fn from_julian_date(jd: JulianDate) -> Self {
71        Self(jd)
72    }
73
74    /// Creates GPS time from raw Julian Date components.
75    ///
76    /// Prefer this over `from_julian_date(JulianDate::new(...))` when you already
77    /// have the split components, as it avoids intermediate allocations.
78    pub fn from_julian_date_raw(jd1: f64, jd2: f64) -> Self {
79        Self(JulianDate::new(jd1, jd2))
80    }
81
82    /// Returns GPS time at J2000.0 epoch (2000-01-01 12:00:00 TT).
83    pub fn j2000() -> Self {
84        Self(JulianDate::j2000())
85    }
86
87    /// Returns the underlying Julian Date.
88    pub fn to_julian_date(&self) -> JulianDate {
89        self.0
90    }
91
92    /// Adds seconds to this GPS time, returning a new instance.
93    ///
94    /// Precision is preserved by adding to the smaller-magnitude JD component.
95    pub fn add_seconds(&self, seconds: f64) -> Self {
96        Self(self.0.add_seconds(seconds))
97    }
98
99    /// Adds days to this GPS time, returning a new instance.
100    ///
101    /// Precision is preserved by adding to the smaller-magnitude JD component.
102    pub fn add_days(&self, days: f64) -> Self {
103        Self(self.0.add_days(days))
104    }
105}
106
107/// Formats as "GPS <julian_date>".
108impl fmt::Display for GPS {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        write!(f, "GPS {}", self.0)
111    }
112}
113
114/// Converts a Julian Date to GPS time.
115impl From<JulianDate> for GPS {
116    fn from(jd: JulianDate) -> Self {
117        Self::from_julian_date(jd)
118    }
119}
120
121/// Parses an ISO 8601 datetime string as GPS time.
122///
123/// The parsed time is interpreted directly as GPS (no leap second handling).
124impl FromStr for GPS {
125    type Err = TimeError;
126
127    fn from_str(s: &str) -> TimeResult<Self> {
128        let parsed = parse_iso8601(s)?;
129        Ok(Self::from_julian_date(parsed.to_julian_date()))
130    }
131}
132
133/// Creates GPS time from calendar components.
134///
135/// Uses direct calendar-to-JD conversion with no leap second corrections.
136/// For UTC calendar dates that need leap second handling, parse as UTC first
137/// then convert to GPS via TAI.
138pub fn gps_from_calendar(year: i32, month: u8, day: u8, hour: u8, minute: u8, second: f64) -> GPS {
139    let jd = JulianDate::from_calendar(year, month, day, hour, minute, second);
140    GPS::from_julian_date(jd)
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::constants::UNIX_EPOCH_JD;
147    use celestial_core::constants::J2000_JD;
148
149    #[test]
150    fn test_gps_constructors() {
151        assert_eq!(GPS::new(0, 0).to_julian_date().to_f64(), UNIX_EPOCH_JD);
152        assert_eq!(GPS::j2000().to_julian_date().to_f64(), J2000_JD);
153        assert_eq!(
154            gps_from_calendar(2000, 1, 1, 12, 0, 0.0)
155                .to_julian_date()
156                .to_f64(),
157            J2000_JD
158        );
159
160        let jd = JulianDate::new(J2000_JD, 0.123456789);
161        let gps_direct = GPS::from_julian_date(jd);
162        let gps_from_trait: GPS = jd.into();
163        assert_eq!(
164            gps_direct.to_julian_date().jd1(),
165            gps_from_trait.to_julian_date().jd1()
166        );
167        assert_eq!(
168            gps_direct.to_julian_date().jd2(),
169            gps_from_trait.to_julian_date().jd2()
170        );
171    }
172
173    #[test]
174    fn test_gps_arithmetic() {
175        let gps = GPS::j2000();
176        assert_eq!(gps.add_days(1.0).to_julian_date().to_f64(), J2000_JD + 1.0);
177        assert_eq!(
178            gps.add_seconds(3600.0).to_julian_date().to_f64(),
179            J2000_JD + 1.0 / 24.0
180        );
181    }
182
183    #[test]
184    fn test_gps_display() {
185        let display_str = format!("{}", GPS::from_julian_date(JulianDate::new(J2000_JD, 0.5)));
186        assert!(display_str.starts_with("GPS"));
187        assert!(display_str.contains("2451545"));
188    }
189
190    #[test]
191    fn test_gps_string_parsing() {
192        assert_eq!(
193            GPS::from_str("2000-01-01T12:00:00")
194                .unwrap()
195                .to_julian_date()
196                .to_f64(),
197            GPS::j2000().to_julian_date().to_f64()
198        );
199
200        let result = GPS::from_str("2000-01-01T12:00:00.123").unwrap();
201        let expected_jd = J2000_JD + 0.123 / SECONDS_PER_DAY_F64;
202        let diff = (result.to_julian_date().to_f64() - expected_jd).abs();
203        assert!(diff < 1e-14, "fractional seconds diff: {:.2e}", diff);
204
205        assert!(GPS::from_str("invalid-date").is_err());
206    }
207
208    #[cfg(feature = "serde")]
209    #[test]
210    fn test_gps_serde_round_trip() {
211        let test_cases = [
212            GPS::j2000(),
213            GPS::new(0, 0),
214            gps_from_calendar(2024, 6, 15, 14, 30, 45.123),
215            gps_from_calendar(1990, 12, 31, 23, 59, 59.999999999),
216        ];
217
218        for original in test_cases {
219            let json = serde_json::to_string(&original).unwrap();
220            let deserialized: GPS = serde_json::from_str(&json).unwrap();
221
222            let total_diff =
223                (original.to_julian_date().to_f64() - deserialized.to_julian_date().to_f64()).abs();
224            assert!(
225                total_diff < 1e-14,
226                "serde precision loss: {:.2e}",
227                total_diff
228            );
229        }
230    }
231}