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