Skip to main content

deep_time/physics/
drift.rs

1//! Clock polynomial for the difference between proper time and a coordinate
2//! time.
3//!
4//! Instantaneous proper-time rates from [`Spacetime`] use the 3+1 interval
5//! \(d\tau/dt=\alpha\sqrt{1-\beta^2}\). With α and β from Φ and \(v\), the
6//! \(O(c^{-2})\) expansion of that interval is IERS Conventions (2010)
7//! eqs. (10.6)–(10.7) / Ashby (2003). A [`Drift`] can also hold measured clock
8//! bias, aging, or other steering that is not that interval. See
9//! [docs/relativity.md](https://github.com/ragardner/deep-time/blob/main/docs/relativity.md).
10
11use crate::{ATTOS_PER_SEC_I128, Dt, Real, Scale, dt};
12
13use super::Spacetime;
14
15/// Quadratic polynomial for the accumulated difference between an observer’s
16/// proper time (what a real clock measures) and a chosen coordinate time such
17/// as TT, TAI, or any other [`Scale`].
18///
19/// The form is \(\mathrm{offset} = a_0 + a_1 s + a_2 s^2\), where \(s\) is
20/// elapsed coordinate time. The three coefficients are a fixed offset, a
21/// constant fractional rate, and a quadratic term (aging, or a changing rate).
22/// GNSS and spacecraft clock steering use this polynomial.
23///
24/// All three coefficients are stored as [`Dt`]. The rate coefficient is
25/// dimensionless (seconds per second); the acceleration coefficient is seconds
26/// per second squared. [`from_spacetime`](Self::from_spacetime) fills only the
27/// linear term from the general-relativity interval.
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29#[cfg_attr(feature = "tsify", derive(tsify::Tsify))]
30#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
31pub struct Drift {
32    /// Constant term \(a_0\), a fixed time offset between proper time and the
33    /// chosen coordinate time.
34    pub constant: Dt,
35
36    /// Linear coefficient \(a_1\), a fractional rate in seconds per second
37    /// (for example a clock that runs steadily fast or slow).
38    pub rate: Dt,
39
40    /// Quadratic coefficient \(a_2\), in seconds per second squared (aging, or
41    /// a rate that itself changes). [`from_spacetime`](Self::from_spacetime)
42    /// leaves this at zero.
43    pub accel: Dt,
44}
45
46impl Drift {
47    /// Creates a `Drift` polynomial from its three coefficients.
48    ///
49    /// `constant` is a time offset, `rate` is dimensionless (seconds per
50    /// second), and `accel` is seconds per second squared. All three are
51    /// stored as [`Dt`].
52    #[inline]
53    pub const fn new(constant: Dt, rate: Dt, accel: Dt) -> Drift {
54        Self {
55            constant,
56            rate,
57            accel,
58        }
59    }
60
61    /// Polynomial with all coefficients zero, meaning no correction at all.
62    ///
63    /// Use this when the observer’s clock is already synchronized with the
64    /// chosen coordinate time.
65    pub const ZERO: Self = Self::new(Dt::ZERO, Dt::ZERO, Dt::ZERO);
66
67    /// Creates a [`Drift`] consisting of a pure constant offset.
68    ///
69    /// This is the usual constructor when only a fixed time bias is known
70    /// (for example after a one-time clock synchronization).
71    #[inline]
72    pub const fn from_constant(c: Dt) -> Drift {
73        Self::new(c, Dt::ZERO, Dt::ZERO)
74    }
75
76    /// Creates a [`Drift`] consisting of a constant offset together with a
77    /// constant linear drift rate.  
78    ///
79    /// This form is very common for GNSS receivers and spacecraft clock steering,
80    /// where a steady fractional frequency offset must be corrected in addition
81    /// to any fixed bias.
82    #[inline]
83    pub const fn from_offset_and_rate(offset: Dt, rate: Dt) -> Drift {
84        Self::new(offset, rate, Dt::ZERO)
85    }
86
87    /// Instantaneous rate \(d\tau/dt\) implied by this polynomial’s linear
88    /// term (`1 + rate`, dimensionless).
89    ///
90    /// When this `Drift` was built with [`from_spacetime`](Self::from_spacetime),
91    /// that term is the general-relativity interval. Otherwise it is the rate
92    /// you stored (steering, a measured frequency offset, aging). `1.0` means
93    /// the linear term ticks in step with the coordinate time the polynomial
94    /// is written against. The constant and quadratic coefficients do not
95    /// enter this value.
96    #[inline]
97    pub const fn proper_time_rate(&self) -> Real {
98        f!(1.0) + self.rate.to_sec_f()
99    }
100
101    /// Evaluates the polynomial after `span` of coordinate time.
102    ///
103    /// The result is \(a_0 + a_1 s + a_2 s^2\) as a [`Dt`]. When this
104    /// polynomial was built with [`from_spacetime`](Self::from_spacetime),
105    /// that value is \(\Delta\tau - \Delta t\). Otherwise it is whatever
106    /// offset, rate, and aging you stored (steering, a measured frequency
107    /// offset, and so on).
108    ///
109    /// Arithmetic saturates like [`Dt`] add and mul. Scaled products
110    /// \((a\cdot b)/10^{18}\) avoid wrapping or early-clamping the
111    /// intermediate \(a\cdot b\) when it exceeds `i128` but the result still
112    /// fits.
113    pub const fn time_diff_after(&self, span: &Dt) -> Dt {
114        let dt_attos = span.to_attos();
115        let mut total_attos = self.constant.to_attos();
116
117        if !self.rate.is_zero() || !self.accel.is_zero() {
118            // Linear: rate * dt  →  (rate_attos * dt_attos) / 10¹⁸
119            let rate_term = saturating_mul_div_attos_per_sec(self.rate.to_attos(), dt_attos);
120            total_attos = total_attos.saturating_add(rate_term);
121
122            // Quadratic: accel * dt²  →  two successive scaled multiplies
123            let accel_dt = saturating_mul_div_attos_per_sec(self.accel.to_attos(), dt_attos);
124            let accel_term = saturating_mul_div_attos_per_sec(accel_dt, dt_attos);
125            total_attos = total_attos.saturating_add(accel_term);
126        }
127
128        dt!(total_attos)
129    }
130
131    /// Adds `stochastic_offset_sec` to the result of
132    /// [`time_diff_after`](Self::time_diff_after).
133    ///
134    /// The polynomial is left as you stored it. Pass noise at evaluation time
135    /// (measured residuals, a Monte-Carlo draw, and so on). Pass `0.0` to get
136    /// the same result as [`time_diff_after`](Self::time_diff_after).
137    #[inline]
138    pub fn time_diff_after_with_noise(&self, span: &Dt, stochastic_offset_sec: Real) -> Dt {
139        self.time_diff_after(span).add(Dt::from_sec_f(
140            stochastic_offset_sec,
141            Scale::TAI,
142            Scale::TAI,
143        ))
144    }
145
146    /// Builds a linear [`Drift`] from a [`Spacetime`] snapshot.
147    ///
148    /// The linear coefficient is the general-relativity tick-rate offset
149    /// [`Spacetime::proper_time_rate_offset`] (\(d\tau/dt-1\)). The constant
150    /// and quadratic terms are zero. Build the [`Spacetime`] from whichever
151    /// potential, velocity, or metric lapse you have, then call this.
152    #[inline]
153    pub const fn from_spacetime(spacetime: &Spacetime) -> Drift {
154        Self::from_offset_and_rate(
155            Dt::ZERO,
156            Dt::from_sec_f(spacetime.proper_time_rate_offset(), Scale::TAI, Scale::TAI),
157        )
158    }
159}
160
161impl Dt {
162    /// Builds a clock-drift model in which this [`Dt`] is treated as the
163    /// initial fixed time difference between the observer’s proper time and
164    /// the chosen coordinate time.
165    ///
166    /// In practice you often compute or measure a one-time offset (for example
167    /// after a clock synchronization) and then want to combine it with a
168    /// steady rate difference and any quadratic change.
169    /// This method lets you do that directly from a [`Dt`] without having to
170    /// call the more verbose [`Drift::new`].
171    ///
172    /// The other two arguments describe how the difference between the two
173    /// clocks will evolve:
174    /// - `rate` — the constant fractional rate difference (how much faster or
175    ///   slower one clock ticks compared with the other).
176    /// - `accel` — how quickly that rate difference itself is changing (for
177    ///   example because the spacecraft is moving through a varying gravitational
178    ///   field).
179    ///
180    /// See [`Drift`] and [`Drift::from_offset_and_rate`] for more background on
181    /// why these three numbers are used to model real clocks.
182    #[inline]
183    pub const fn to_drift_as_constant(self, rate: Dt, accel: Dt) -> Drift {
184        Drift::new(self, rate, accel)
185    }
186
187    /// Builds a clock-drift model in which this [`Dt`] supplies the constant
188    /// fractional rate difference between the observer’s proper time and the
189    /// chosen coordinate time.
190    ///
191    /// If you have already calculated (or measured) a steady rate offset as a
192    /// [`Dt`], you can use this method to attach an initial time offset and a
193    /// quadratic term and obtain a complete [`Drift`] polynomial.
194    ///
195    /// Physically, the rate term captures the fact that two clocks with
196    /// different spatial velocities or different gravitational potentials
197    /// will accumulate a steadily growing time difference. The other two
198    /// parameters let you also describe any starting bias and any change in
199    /// that rate over time.
200    ///
201    /// See the documentation on [`Drift`] for the meaning of the three
202    /// coefficients in a relativistic timing context.
203    #[inline]
204    pub const fn to_drift_as_rate(self, constant: Dt, accel: Dt) -> Drift {
205        Drift::new(constant, self, accel)
206    }
207
208    /// Builds a clock-drift model in which this [`Dt`] supplies the quadratic
209    /// term that describes how the rate difference itself is changing.
210    ///
211    /// Some situations (a spacecraft on a highly elliptical orbit, a clock
212    /// whose frequency is aging, or a path that takes it through regions
213    /// of changing gravitational potential) cause the *rate* at which two
214    /// clocks diverge to change over time. If you have computed that changing
215    /// rate as a [`Dt`], this method lets you combine it with an initial offset
216    /// and a base rate to form a full [`Drift`].
217    ///
218    /// The other two arguments are:
219    /// - `constant` — any fixed time bias present at the start.
220    /// - `rate` — the base fractional rate difference that will itself be
221    ///   modified by the quadratic term supplied by `self`.
222    ///
223    /// See [`Drift`] for more explanation of why a quadratic model is used for
224    /// relativistic clock predictions.
225    #[inline]
226    pub const fn to_drift_as_accel(self, constant: Dt, rate: Dt) -> Drift {
227        Drift::new(constant, rate, self)
228    }
229
230    /// Advances this instant by the proper time that elapses during the
231    /// coordinate interval `elapsed` at `spacetime`.
232    ///
233    /// Adds \(\Delta\tau = \Delta t + (r-1)\Delta t\), where \(r\) is
234    /// [`Spacetime::proper_time_rate`]. For a clock that already ticks proper
235    /// time, use the plain `add` methods instead.
236    #[inline]
237    pub const fn adjusted_advance(&mut self, elapsed: &Dt, spacetime: &Spacetime) {
238        let dtau = elapsed.add(Drift::from_spacetime(spacetime).time_diff_after(elapsed));
239        *self = self.add(dtau);
240    }
241
242    /// Advances this instant by `elapsed` plus the [`Drift`] polynomial
243    /// evaluated at `elapsed`.
244    ///
245    /// When `drift` came from [`Drift::from_spacetime`], this matches
246    /// [`adjusted_advance`](Self::adjusted_advance). A polynomial with a
247    /// constant or quadratic term is applied in full. The constant term is
248    /// added on every call, so a stepping loop should use a polynomial whose
249    /// constant is zero, or [`adjusted_advance`](Self::adjusted_advance).
250    #[inline]
251    pub const fn adjusted_advance_using_drift(&mut self, elapsed: &Dt, drift: &Drift) {
252        let dtau = elapsed.add(drift.time_diff_after(elapsed));
253        *self = self.add(dtau);
254    }
255
256    /// Adds the [`Drift`] polynomial evaluated at `(self − reference)` to this
257    /// instant.
258    ///
259    /// GNSS broadcast clock corrections and other quadratic steering use this.
260    pub const fn convert_using_drift(self, reference: Dt, drift: &Drift) -> Dt {
261        let span = self.to_diff_raw(reference);
262        let correction = drift.time_diff_after(&span);
263        self.add(correction)
264    }
265
266    /// Inverse of [`convert_using_drift`](Self::convert_using_drift).
267    ///
268    /// Recovers the instant that would produce `self` after adding the
269    /// polynomial relative to `reference`. A fixed-point iteration (at most 16
270    /// steps) solves the implicit equation. If the polynomial is a pure
271    /// constant offset, the result is returned immediately.
272    pub const fn convert_back_using_drift(self, reference: Dt, drift: &Drift) -> Dt {
273        if drift.rate.is_zero() && drift.accel.is_zero() {
274            return self.sub(drift.constant);
275        }
276        let mut guess = self;
277        let mut i = 0u32;
278        while i < 16 {
279            let span = guess.to_diff_raw(reference);
280            let correction = drift.time_diff_after(&span);
281            guess = self.sub(correction);
282            i += 1;
283        }
284        guess
285    }
286}
287
288/// Fixed-point product `(a * b) / ATTOS_PER_SEC`, saturating on true result overflow.
289///
290/// Drift coefficients and spans are both attosecond-scaled, so applying rate or
291/// accel needs `(a·b)/10¹⁸`. The raw product `a·b` can exceed `i128` even when
292/// that scaled result still fits; this helper avoids wrapping or early clamp.
293///
294/// 1. Uses `checked_mul` when the intermediate product fits (common path).
295/// 2. Otherwise splits `a = a_hi·D + a_lo` so
296///    `(a·b)/D = a_hi·b + (a_lo·b)/D`, with a second split on `b` if needed.
297/// 3. Combines parts with saturating arithmetic so extreme inputs clamp like
298///    the rest of [`Dt`] rather than wrapping.
299const fn saturating_mul_div_attos_per_sec(a: i128, b: i128) -> i128 {
300    if a == 0 || b == 0 {
301        return 0;
302    }
303
304    if let Some(product) = a.checked_mul(b) {
305        return product / ATTOS_PER_SEC_I128;
306    }
307
308    // a = a_hi * D + a_lo  (Rust truncating division; identity holds for negatives)
309    let a_hi = a / ATTOS_PER_SEC_I128;
310    let a_lo = a % ATTOS_PER_SEC_I128;
311    // (a_hi * D + a_lo) * b / D = a_hi * b + (a_lo * b) / D
312    let hi = a_hi.saturating_mul(b);
313
314    let lo = match a_lo.checked_mul(b) {
315        Some(product) => product / ATTOS_PER_SEC_I128,
316        None => {
317            // |a_lo| < D; split b the same way:
318            // a_lo * b / D = a_lo * b_hi + (a_lo * b_lo) / D
319            // |a_lo * b_lo| < D² = 10³⁶ < i128::MAX, so the cross term is exact.
320            let b_hi = b / ATTOS_PER_SEC_I128;
321            let b_lo = b % ATTOS_PER_SEC_I128;
322            let cross = (a_lo * b_lo) / ATTOS_PER_SEC_I128;
323            a_lo.saturating_mul(b_hi).saturating_add(cross)
324        }
325    };
326
327    hi.saturating_add(lo)
328}
329
330#[cfg(feature = "wire")]
331impl Drift {
332    /// Wire format version for this type’s outer envelope.
333    ///
334    /// Independent of nested
335    /// [`Dt::WIRE_VERSION`](../struct.Dt.html#associatedconstant.WIRE_VERSION).
336    pub const WIRE_VERSION: u8 = 1;
337
338    /// Size of the canonical wire representation in bytes.
339    ///
340    /// One version byte plus three
341    /// [`Dt::WIRE_SIZE`](../struct.Dt.html#associatedconstant.WIRE_SIZE)
342    /// records (`constant`, `rate`, `accel`).
343    pub const WIRE_SIZE: usize = 1 + 3 * Dt::WIRE_SIZE;
344
345    /// Serializes this polynomial into a fixed buffer.
346    ///
347    /// ## Wire format
348    ///
349    /// - Byte `0`: [`WIRE_VERSION`](Self::WIRE_VERSION)
350    /// - Next [`Dt::WIRE_SIZE`](../struct.Dt.html#associatedconstant.WIRE_SIZE)
351    ///   bytes: `constant`
352    /// - Next [`Dt::WIRE_SIZE`](../struct.Dt.html#associatedconstant.WIRE_SIZE)
353    ///   bytes: `rate`
354    /// - Next [`Dt::WIRE_SIZE`](../struct.Dt.html#associatedconstant.WIRE_SIZE)
355    ///   bytes: `accel`
356    pub fn to_wire_bytes(&self) -> [u8; Self::WIRE_SIZE] {
357        let mut buf = [0u8; Self::WIRE_SIZE];
358        buf[0] = Self::WIRE_VERSION;
359
360        let n = Dt::WIRE_SIZE;
361        let c = self.constant.to_wire_bytes();
362        let r = self.rate.to_wire_bytes();
363        let a = self.accel.to_wire_bytes();
364
365        buf[1..1 + n].copy_from_slice(&c);
366        buf[1 + n..1 + 2 * n].copy_from_slice(&r);
367        buf[1 + 2 * n..1 + 3 * n].copy_from_slice(&a);
368        buf
369    }
370
371    /// Deserializes from exactly [`WIRE_SIZE`](Self::WIRE_SIZE) bytes.
372    ///
373    /// ## Errors
374    ///
375    /// Returns `None` only when:
376    /// - `bytes` is not exactly [`WIRE_SIZE`](Self::WIRE_SIZE) long,
377    /// - the version byte is not [`WIRE_VERSION`](Self::WIRE_VERSION), or
378    /// - any nested
379    ///   [`Dt::from_wire_bytes`](../struct.Dt.html#method.from_wire_bytes)
380    ///   fails (unknown
381    ///   [`Dt::WIRE_VERSION`](../struct.Dt.html#associatedconstant.WIRE_VERSION)).
382    ///
383    /// Nested scale/target bytes never fail decode (see
384    /// [`Dt::from_wire_bytes`](../struct.Dt.html#method.from_wire_bytes)).
385    ///
386    /// ## Security
387    ///
388    /// Composes the safety guarantees of
389    /// [`Dt::from_wire_bytes`](../struct.Dt.html#method.from_wire_bytes).
390    /// Safe for untrusted input.
391    pub fn from_wire_bytes(bytes: &[u8]) -> Option<Self> {
392        if bytes.len() != Self::WIRE_SIZE {
393            return None;
394        }
395
396        if bytes[0] != Self::WIRE_VERSION {
397            return None;
398        }
399
400        let n = Dt::WIRE_SIZE;
401        let constant = Dt::from_wire_bytes(&bytes[1..1 + n])?;
402        let rate = Dt::from_wire_bytes(&bytes[1 + n..1 + 2 * n])?;
403        let accel = Dt::from_wire_bytes(&bytes[1 + 2 * n..1 + 3 * n])?;
404
405        Some(Self::new(constant, rate, accel))
406    }
407}