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}