Skip to main content

lox_time/
offsets.rs

1// SPDX-FileCopyrightText: 2024 Helge Eichhorn <git@helgeeichhorn.de>
2//
3// SPDX-License-Identifier: MPL-2.0
4
5use core::convert::Infallible;
6
7use lox_core::math::float::sin;
8
9use crate::Time;
10use crate::deltas::TimeDelta;
11use crate::time_scales::{ContinuousTimeScale, Tai};
12use crate::utc::Utc;
13use crate::utc::leap_seconds::{DefaultLeapSecondsProvider, LeapSecondsProvider};
14
15mod impls;
16
17/// Fallible time scale offset computation.
18pub trait TryOffset<Origin, Target>
19where
20    Origin: ContinuousTimeScale,
21    Target: ContinuousTimeScale,
22{
23    /// The error type returned when the offset cannot be computed.
24    type Error: core::error::Error + Send + Sync + 'static;
25
26    /// Computes the offset from `origin` to `target` at the given `delta` since J2000.
27    fn try_offset(
28        &self,
29        origin: Origin,
30        target: Target,
31        delta: TimeDelta,
32    ) -> Result<TimeDelta, Self::Error>;
33}
34
35/// Infallible time scale offset computation.
36pub trait Offset<Origin, Target>
37where
38    Origin: ContinuousTimeScale,
39    Target: ContinuousTimeScale,
40{
41    /// Computes the offset from `origin` to `target` at the given `delta` since J2000.
42    fn offset(&self, origin: Origin, target: Target, delta: TimeDelta) -> TimeDelta;
43}
44
45impl<T, Origin, Target> Offset<Origin, Target> for T
46where
47    Origin: ContinuousTimeScale,
48    Target: ContinuousTimeScale,
49    T: TryOffset<Origin, Target, Error = Infallible>,
50{
51    fn offset(&self, origin: Origin, target: Target, delta: TimeDelta) -> TimeDelta {
52        self.try_offset(origin, target, delta).unwrap()
53    }
54}
55
56/// Provides time scale offset computations for all supported scale pairs.
57pub trait OffsetProvider {
58    /// The error type for fallible offset computations (e.g. UT1).
59    type Error: core::error::Error + Send + Sync + 'static;
60
61    /// Returns the TAI→UT1 offset at the given delta.
62    fn tai_to_ut1(&self, delta: TimeDelta) -> Result<TimeDelta, Self::Error>;
63    /// Returns the UT1→TAI offset at the given delta.
64    fn ut1_to_tai(&self, delta: TimeDelta) -> Result<TimeDelta, Self::Error>;
65
66    /// Returns the constant TAI→TT offset.
67    fn tai_to_tt(&self) -> TimeDelta {
68        D_TAI_TT
69    }
70
71    /// Returns the constant TT→TAI offset.
72    fn tt_to_tai(&self) -> TimeDelta {
73        -D_TAI_TT
74    }
75
76    /// Returns the constant TAI→GPS offset.
77    fn tai_to_gps(&self) -> TimeDelta {
78        D_TAI_GPS
79    }
80
81    /// Returns the constant GPS→TAI offset.
82    fn gps_to_tai(&self) -> TimeDelta {
83        -D_TAI_GPS
84    }
85
86    /// Returns the TT→TCG offset at the given delta.
87    fn tt_to_tcg(&self, delta: TimeDelta) -> TimeDelta {
88        tt_to_tcg(delta)
89    }
90
91    /// Returns the TCG→TT offset at the given delta.
92    fn tcg_to_tt(&self, delta: TimeDelta) -> TimeDelta {
93        tcg_to_tt(delta)
94    }
95
96    /// Returns the TDB→TCB offset at the given delta.
97    fn tdb_to_tcb(&self, delta: TimeDelta) -> TimeDelta {
98        tdb_to_tcb(delta)
99    }
100
101    /// Returns the TCB→TDB offset at the given delta.
102    fn tcb_to_tdb(&self, delta: TimeDelta) -> TimeDelta {
103        tcb_to_tdb(delta)
104    }
105
106    /// Returns the TT→TDB offset at the given delta.
107    fn tt_to_tdb(&self, delta: TimeDelta) -> TimeDelta {
108        tt_to_tdb(delta)
109    }
110
111    /// Returns the TDB→TT offset at the given delta.
112    fn tdb_to_tt(&self, delta: TimeDelta) -> TimeDelta {
113        tdb_to_tt(delta)
114    }
115}
116
117/// Default offset provider using built-in leap second tables and standard algorithms.
118#[derive(Debug, Clone, Copy, Default)]
119pub struct DefaultOffsetProvider;
120
121impl LeapSecondsProvider for DefaultOffsetProvider {}
122
123impl OffsetProvider for DefaultOffsetProvider {
124    type Error = Infallible;
125
126    fn tai_to_ut1(&self, delta: TimeDelta) -> Result<TimeDelta, Self::Error> {
127        // Without EOP data UT1 is approximated by UTC, good to
128        // |UT1 - UTC| <= 0.9 s. The contract is the offset *to* the target
129        // scale, i.e. UT1 - TAI, so the leap-second count must be negated.
130        let tai = Time::from_delta(Tai, delta);
131        Ok(-DefaultLeapSecondsProvider.delta_tai_utc(tai))
132    }
133
134    fn ut1_to_tai(&self, delta: TimeDelta) -> Result<TimeDelta, Self::Error> {
135        let utc = Utc::from_delta(delta);
136        Ok(-DefaultLeapSecondsProvider.delta_utc_tai(utc))
137    }
138}
139
140// TAI <-> TT
141
142/// The constant offset between TAI and TT.
143pub const D_TAI_TT: TimeDelta = TimeDelta::builder().seconds(32).milliseconds(184).build();
144
145// TAI <-> GPS
146
147/// Constant offset from TAI to GPS time: `GPS = TAI − 19s`.
148///
149/// This is the value added when converting TAI to GPS (negative 19
150/// seconds, since GPS-time labels are 19 seconds less than the
151/// corresponding TAI-time labels).
152pub const D_TAI_GPS: TimeDelta = TimeDelta::builder().seconds(-19).build();
153
154// TT <-> TCG
155
156/// The difference between J2000 TT and 1977 January 1.0 TAI as TT.
157const J77_TT: TimeDelta = TimeDelta::builder()
158    .seconds(-725803167)
159    .milliseconds(816)
160    .build();
161
162/// The rate of change of TCG with respect to TT.
163const LG: f64 = 6.969290134e-10;
164
165/// The rate of change of TT with respect to TCG.
166const INV_LG: f64 = LG / (1.0 - LG);
167
168fn tt_to_tcg(delta: TimeDelta) -> TimeDelta {
169    INV_LG * (delta - J77_TT)
170}
171
172fn tcg_to_tt(delta: TimeDelta) -> TimeDelta {
173    -LG * (delta - J77_TT)
174}
175
176// TDB <-> TCB
177
178/// The rate of change of TDB with respect to TCB.
179const LB: f64 = 1.550519768e-8;
180
181/// The rate of change of TCB with respect to TDB.
182const INV_LB: f64 = LB / (1.0 - LB);
183
184/// Scale factor for TDB to TCB constant term: 1 / (1 - LB).
185const ONE_MINUS_LB_INV: f64 = 1.0 / (1.0 - LB);
186
187/// Constant term of TDB − TT formula of Fairhead & Bretagnon (1990).
188// const TDB_0: f64 = -6.55e-5;
189const TDB_0: TimeDelta = TimeDelta::builder()
190    .seconds(0)
191    .microseconds(65)
192    .nanoseconds(500)
193    .negative()
194    .build();
195
196const TCB_77: TimeDelta = TDB_0.add_const(J77_TT.mul_const(LB));
197
198fn tdb_to_tcb(delta: TimeDelta) -> TimeDelta {
199    INV_LB * delta - ONE_MINUS_LB_INV * TCB_77
200}
201
202fn tcb_to_tdb(delta: TimeDelta) -> TimeDelta {
203    TCB_77 - LB * delta
204}
205
206// TT <-> TDB
207
208const K: f64 = 1.657e-3;
209const EB: f64 = 1.671e-2;
210const M_0: f64 = 6.239996;
211const M_1: f64 = 1.99096871e-7;
212
213fn tt_to_tdb(delta: TimeDelta) -> TimeDelta {
214    let tt = delta.to_seconds().to_f64();
215    let g = M_0 + M_1 * tt;
216    TimeDelta::from_seconds_f64(K * sin(g + EB * sin(g)))
217}
218
219fn tdb_to_tt(delta: TimeDelta) -> TimeDelta {
220    let tdb = delta.to_seconds().to_f64();
221    let mut offset = 0.0;
222    for _ in 1..3 {
223        let g = M_0 + M_1 * (tdb + offset);
224        offset = -K * sin(g + EB * sin(g));
225    }
226    TimeDelta::from_seconds_f64(offset)
227}
228
229// Two-step transformations
230
231fn two_step_offset<P, T1, T2, T3>(
232    provider: &P,
233    origin: T1,
234    via: T2,
235    target: T3,
236    delta: TimeDelta,
237) -> TimeDelta
238where
239    T1: ContinuousTimeScale,
240    T2: ContinuousTimeScale + Copy,
241    T3: ContinuousTimeScale,
242    P: Offset<T1, T2> + Offset<T2, T3>,
243{
244    let mut offset = provider.offset(origin, via, delta);
245    offset += provider.offset(via, target, delta + offset);
246    offset
247}
248
249#[cfg(test)]
250mod tests {
251    use lox_approx::assert_approx_eq;
252    use rstest::rstest;
253
254    use super::*;
255    use crate::offsets::TryOffset;
256    use crate::time_scales::TimeScale;
257    use crate::{Time, calendar_dates::Date, deltas::ToDelta, time_of_day::TimeOfDay};
258
259    /// Without EOP data UT1 is approximated by UTC, so the TAI->UT1 offset is
260    /// the *negated* leap-second count. Getting the sign wrong puts UT1 out by
261    /// twice the leap-second count (74 s in 2022) and silently rotates the
262    /// Earth too far in every UT1-dependent frame (ITRF, TIRF, PEF).
263    #[test]
264    fn test_default_provider_tai_to_ut1_sign() {
265        use crate::time_scales::Ut1;
266        use crate::utc::Utc;
267
268        let utc: Utc = "2022-02-01T00:00:00.000".parse().unwrap();
269        let tai = utc.to_time();
270        let offset = DefaultOffsetProvider
271            .try_offset(Tai, Ut1, tai.to_delta())
272            .unwrap();
273        assert_approx_eq!(offset.to_seconds().to_f64(), -37.0, rtol <= 1e-9);
274
275        // UT1 must land back on the UTC wall clock, not 74 s past it.
276        let ut1 = tai.to_scale(Ut1);
277        assert_approx_eq!(
278            ut1.to_delta().to_seconds().to_f64(),
279            utc.to_delta().to_seconds().to_f64(),
280            atol <= 1e-9
281        );
282    }
283
284    #[test]
285    fn test_default_provider_ut1_tai_round_trip() {
286        use crate::time_scales::Ut1;
287        use crate::utc::Utc;
288
289        let tai = "2022-02-01T00:00:00.000".parse::<Utc>().unwrap().to_time();
290        let back = tai.to_scale(Ut1).to_scale(Tai);
291        assert_eq!(tai.to_delta(), back.to_delta());
292    }
293
294    const DEFAULT_TOL: f64 = 1e-7;
295    const TCB_TOL: f64 = 1e-4;
296
297    // Reference values from Orekit
298    //
299    // Since we use different algorithms for TCB and UT1 we need to
300    // adjust the tolerances accordingly.
301    //
302    #[rstest]
303    #[case::tai_tai("TAI", "TAI", 0.0, None)]
304    #[case::tai_tcb("TAI", "TCB", 55.66851419888016, Some(TCB_TOL))]
305    #[case::tai_tcg("TAI", "TCG", 33.239589335894145, None)]
306    #[case::tai_tdb("TAI", "TDB", 32.183882324981056, None)]
307    #[case::tai_tt("TAI", "TT", 32.184, None)]
308    #[case::tcb_tai("TCB", "TAI", -55.668513317090046, Some(TCB_TOL))]
309    #[case::tcb_tcb("TCB", "TCB", 0.0, Some(TCB_TOL))]
310    #[case::tcb_tcg("TCB", "TCG", -22.4289240199929, Some(TCB_TOL))]
311    #[case::tcb_tdb("TCB", "TDB", -23.484631010747805, Some(TCB_TOL))]
312    #[case::tcb_tt("TCB", "TT", -23.484513317090048, Some(TCB_TOL))]
313    #[case::tcg_tai("TCG", "TAI", -33.23958931272851, None)]
314    #[case::tcg_tcb("TCG", "TCB", 22.428924359636042, Some(TCB_TOL))]
315    #[case::tcg_tcg("TCG", "TCG", 0.0, None)]
316    #[case::tcg_tdb("TCG", "TDB", -1.0557069988766656, None)]
317    #[case::tcg_tt("TCG", "TT", -1.0555893127285145, None)]
318    #[case::tdb_tai("TDB", "TAI", -32.18388231420531, None)]
319    #[case::tdb_tcb("TDB", "TCB", 23.48463137488165, Some(TCB_TOL))]
320    #[case::tdb_tcg("TDB", "TCG", 1.0557069992589518, None)]
321    #[case::tdb_tdb("TDB", "TDB", 0.0, None)]
322    #[case::tdb_tt("TDB", "TT", 1.176857946845189E-4, None)]
323    #[case::tt_tai("TT", "TAI", -32.184, None)]
324    #[case::tt_tcb("TT", "TCB", 23.484513689085105, Some(TCB_TOL))]
325    #[case::tt_tcg("TT", "TCG", 1.055589313464182, None)]
326    #[case::tt_tdb("TT", "TDB", -1.1768579472004603E-4, None)]
327    #[case::tt_tt("TT", "TT", 0.0, None)]
328    fn test_dynamic_time_scale_offsets_new(
329        #[case] scale1: &str,
330        #[case] scale2: &str,
331        #[case] exp: f64,
332        #[case] tol: Option<f64>,
333    ) {
334        let provider = &DefaultOffsetProvider;
335        let scale1: TimeScale = scale1.parse().unwrap();
336        let scale2: TimeScale = scale2.parse().unwrap();
337        let date = Date::new(2024, 12, 30).unwrap();
338        let time = TimeOfDay::from_hms(10, 27, 13.145).unwrap();
339        let dt = Time::from_date_and_time(scale1, date, time)
340            .unwrap()
341            .to_delta();
342        let act = provider
343            .try_offset(scale1, scale2, dt)
344            .unwrap()
345            .to_seconds()
346            .to_f64();
347        assert_approx_eq!(act, exp, atol <= tol.unwrap_or(DEFAULT_TOL));
348    }
349
350    // Test round-trip conversions for reversibility
351    #[rstest]
352    #[case::tt_tcg_tt("TT", "TCG", 1e-15)]
353    #[case::tcg_tt_tcg("TCG", "TT", 1e-15)]
354    #[case::tdb_tcb_tdb("TDB", "TCB", 1e-14)]
355    #[case::tcb_tdb_tcb("TCB", "TDB", 1e-14)]
356    fn test_time_scale_roundtrip(#[case] scale1: &str, #[case] scale2: &str, #[case] tol: f64) {
357        let provider = &DefaultOffsetProvider;
358        let scale1: TimeScale = scale1.parse().unwrap();
359        let scale2: TimeScale = scale2.parse().unwrap();
360        let date = Date::new(2024, 12, 30).unwrap();
361        let time = TimeOfDay::from_hms(10, 27, 13.145).unwrap();
362        let original_delta = Time::from_date_and_time(scale1, date, time)
363            .unwrap()
364            .to_delta();
365
366        // Forward conversion
367        let offset1 = provider.try_offset(scale1, scale2, original_delta).unwrap();
368        let intermediate_delta = original_delta + offset1;
369
370        // Reverse conversion
371        let offset2 = provider
372            .try_offset(scale2, scale1, intermediate_delta)
373            .unwrap();
374        let final_delta = intermediate_delta + offset2;
375
376        let diff = (final_delta - original_delta).to_seconds().to_f64().abs();
377        assert!(
378            diff < tol,
379            "Round-trip conversion {} -> {} -> {} failed: difference = {:.2e} seconds, tolerance = {:.2e} seconds",
380            scale1,
381            scale2,
382            scale1,
383            diff,
384            tol
385        );
386    }
387
388    #[test]
389    fn test_offset_constants() {
390        let tdb_0 = TDB_0.to_seconds();
391        assert!((tdb_0.to_f64() - (-6.55e-5)).abs() < 1e-15);
392
393        let j77 = J77_TT.to_seconds();
394        // For negative times, internal representation stores one less second
395        // and a positive subsecond fraction: -725803167.816 = -725803168 + 0.184
396        assert_eq!(j77.hi, -725803168.0);
397        assert!((j77.lo - 0.184).abs() < 1e-15);
398        // But the total should be correct
399        assert!((j77.to_f64() - (-725803167.816)).abs() < 1e-9);
400    }
401}