Skip to main content

deep_time/physics/
trajectory.rs

1//! Proper-time integration methods on [`Dt`] (see the public method docs).
2//!
3//! Overview and which-function guide:
4//! [docs/trajectory.md](https://github.com/ragardner/deep-time/blob/main/docs/trajectory.md).
5
6use crate::macros::from_sec_f;
7use crate::{C_SQUARED, Drift, Dt, DtErr, DtErrKind, Real, Spacetime, Velocity, an_err};
8
9impl Dt {
10    /// Integrate proper time along samples of time, velocity, and gravitational potential.
11    ///
12    /// Walks a list of vehicle states and estimates how much time a clock on that
13    /// path would accumulate over the **full** sample span (first time to last).
14    /// For a named arc inside a longer file, use
15    /// [`Dt::proper_time_from_states_between`](#method.proper_time_from_states_between).
16    ///
17    /// Guide: [docs/trajectory.md](https://github.com/ragardner/deep-time/blob/main/docs/trajectory.md).
18    ///
19    /// ## When to use it
20    ///
21    /// - Δτ over **exactly the samples you pass** (first sample to last).
22    /// - Not a sub-interval of a longer arc (use
23    ///   [`Dt::proper_time_from_states_between`](#method.proper_time_from_states_between)).
24    ///
25    /// ## Inputs
26    ///
27    /// Each sample is `(coordinate_time, velocity, gravitational_potential)`:
28    ///
29    /// - **time** — mission / ephemeris epoch as a [`Dt`]
30    /// - **velocity** — m/s in the same frame convention you used for potential
31    /// - **potential Φ** — SI units **m²/s²** (typically negative near a planet).
32    ///   Do **not** pass Φ/c² here; this API divides by \(c^2\) internally.
33    ///
34    /// Times must be non-decreasing. Empty or single-point paths yield zero.
35    /// Non-monotonic times yield [`DtErrKind::NonMonotonic`].
36    ///
37    /// ## `characteristic_length_scale`
38    ///
39    /// Pass **`0.0`** for Earth orbit, GNSS, cislunar, and similar work. That sets
40    /// curvature to zero and uses the usual weak-field clock rate from Φ and \(v\).
41    ///
42    /// Pass a positive length in meters only if you intentionally want the
43    /// library’s optional curvature estimate (see
44    /// [`Spacetime::kretschmann_from_potential_and_scale`]).
45    ///
46    /// ## Example
47    ///
48    /// ```rust
49    /// use deep_time::{Dt, Scale, Velocity};
50    ///
51    /// let t0 = Dt::from_sec(0, Scale::TAI, Scale::TAI);
52    /// let t1 = Dt::from_sec(3600, Scale::TAI, Scale::TAI);
53    /// // Example Earth-surface-scale |Φ| (m²/s²); use your model in production
54    /// let phi = -6.25e7;
55    /// let samples = [
56    ///     (t0, Velocity::ZERO, phi),
57    ///     (t1, Velocity::from_speed(0.0), phi),
58    /// ];
59    /// let dtau = Dt::proper_time_from_states(samples, 0.0).expect("monotonic");
60    /// assert!(dtau.to_sec_f() > 0.0 && dtau.to_sec_f() < 3600.0);
61    /// ```
62    ///
63    /// ## See also
64    ///
65    /// - [`Dt::proper_time_from_states_between`](#method.proper_time_from_states_between) — named interval `[start, end]`
66    /// - [`Dt::proper_time_drift_from_states`](#method.proper_time_drift_from_states) — gain/loss vs coordinate time
67    /// - [`Dt::proper_time_from_path`](#method.proper_time_from_path) — same integral if you already have [`Spacetime`]
68    pub fn proper_time_from_states<I>(
69        samples: I,
70        characteristic_length_scale: Real,
71    ) -> Result<Self, DtErr>
72    where
73        I: IntoIterator<Item = (Self, Velocity, Real)>,
74    {
75        Self::proper_time_from_path(Self::states_to_path(samples, characteristic_length_scale))
76    }
77
78    /// Proper time Δτ on a named mission arc `[start, end]`.
79    ///
80    /// Same idea as [`Dt::proper_time_from_states`](#method.proper_time_from_states), but only the window
81    /// `[start, end]` is integrated. Extra samples outside that window are
82    /// ignored except as neighbors for interpolation at the endpoints.
83    ///
84    /// Example question: how much proper time has the onboard clock accumulated
85    /// between two GET epochs when the trajectory file is longer than that arc.
86    ///
87    /// ## Coverage and errors
88    ///
89    /// Samples must **cover** `[start, end]`:
90    /// - at least one sample at or before `start`, and
91    /// - the path must reach at least as far as `end`.
92    ///
93    /// - [`DtErrKind::Incomplete`] — empty path (when `start ≠ end`) or incomplete coverage
94    /// - [`DtErrKind::OutOfRange`] — `end < start`
95    /// - [`DtErrKind::NonMonotonic`] — a later sample has an earlier time
96    ///
97    /// ## Example
98    ///
99    /// ```rust
100    /// use deep_time::{Dt, Scale, Velocity};
101    ///
102    /// let t0 = Dt::from_sec(0, Scale::TAI, Scale::TAI);
103    /// let t1 = Dt::from_sec(10_000, Scale::TAI, Scale::TAI);
104    /// // Flat spacetime via Φ = 0 → rate = 1
105    /// let samples = [
106    ///     (t0, Velocity::ZERO, 0.0),
107    ///     (t1, Velocity::ZERO, 0.0),
108    /// ];
109    /// let start = Dt::from_sec(1000, Scale::TAI, Scale::TAI);
110    /// let end = Dt::from_sec(4600, Scale::TAI, Scale::TAI);
111    /// let dtau = Dt::proper_time_from_states_between(start, end, samples, 0.0)
112    ///     .expect("samples cover the arc");
113    /// assert_eq!(dtau, Dt::from_sec(3600, Scale::TAI, Scale::TAI));
114    /// ```
115    ///
116    /// ## See also
117    ///
118    /// - [`Dt::proper_time_drift_from_states`](#method.proper_time_drift_from_states) — same window, but Δτ − Δt
119    /// - [`Dt::proper_time_from_path_between`](#method.proper_time_from_path_between) — if samples are already [`Spacetime`]
120    pub fn proper_time_from_states_between<I>(
121        start: Dt,
122        end: Dt,
123        states: I,
124        characteristic_length_scale: Real,
125    ) -> Result<Dt, DtErr>
126    where
127        I: IntoIterator<Item = (Self, Velocity, Real)>,
128    {
129        Self::proper_time_from_path_between(
130            start,
131            end,
132            Self::states_to_path(states, characteristic_length_scale),
133        )
134    }
135
136    /// Clock drift vs coordinate time on `[start, end]`: Δτ − (end − start).
137    ///
138    /// Did the vehicle clock run fast or slow compared to the mission timeline
139    /// over a chosen interval?
140    ///
141    /// - **Positive** — clock accumulated more time than the coordinate interval
142    ///   (ran fast).
143    /// - **Negative** — clock accumulated less (ran slow).
144    ///
145    /// Algebraically \(\int_{start}^{end}(r - 1)\,dt\). Implemented as
146    /// [`Dt::proper_time_from_states_between`](#method.proper_time_from_states_between) minus `(end − start)`.
147    ///
148    /// ## When to use it
149    ///
150    /// - Relativistic clock offset over an analysis arc
151    /// - Comparing an integrated model to a coordinate-time reference
152    /// - Not spacecraft-minus-ground (use
153    ///   [`Dt::proper_time_differential_vs_rate`](#method.proper_time_differential_vs_rate) or
154    ///   [`Dt::proper_time_differential_from_paths`](#method.proper_time_differential_from_paths))
155    ///
156    /// ## Inputs and errors
157    ///
158    /// Same sample layout as [`Dt::proper_time_from_states`](#method.proper_time_from_states):
159    /// `(time, velocity m/s, Φ m²/s²)`. Pass `characteristic_length_scale = 0.0`
160    /// for ordinary weak-field work. Coverage and error kinds match
161    /// [`Dt::proper_time_from_states_between`](#method.proper_time_from_states_between). `start == end` returns zero
162    /// without reading samples.
163    ///
164    /// ## Example
165    ///
166    /// ```rust
167    /// use deep_time::{Dt, Scale, Velocity};
168    ///
169    /// let t0 = Dt::from_sec(0, Scale::TAI, Scale::TAI);
170    /// let t1 = Dt::from_sec(86_400, Scale::TAI, Scale::TAI);
171    /// let phi = -6.25e7_f64;
172    /// let samples = [
173    ///     (t0, Velocity::ZERO, phi),
174    ///     (t1, Velocity::ZERO, phi),
175    /// ];
176    /// let drift = Dt::proper_time_drift_from_states(t0, t1, samples, 0.0).unwrap();
177    /// // Stationary in a potential well → clock runs slow vs coordinate time
178    /// assert!(drift.to_sec_f() < 0.0);
179    /// ```
180    pub fn proper_time_drift_from_states<I>(
181        start: Dt,
182        end: Dt,
183        states: I,
184        characteristic_length_scale: Real,
185    ) -> Result<Dt, DtErr>
186    where
187        I: IntoIterator<Item = (Self, Velocity, Real)>,
188    {
189        if start.eq(&end) {
190            return Ok(Dt::ZERO);
191        }
192        let dtau =
193            Self::proper_time_from_states_between(start, end, states, characteristic_length_scale)?;
194        Ok(dtau.sub(end.to_diff_raw(start)))
195    }
196
197    /// Integrate proper time along a path of [`Spacetime`] snapshots.
198    ///
199    /// Same as [`Dt::proper_time_from_states`](#method.proper_time_from_states), but each sample is already a
200    /// full local state `(α, β, curvature)` instead of `(v, Φ)`.
201    ///
202    /// ## When to use it
203    ///
204    /// - You already built [`Spacetime`] values (tests, precomputed rates, custom α/β).
205    /// - Prefer [`Dt::proper_time_from_states`](#method.proper_time_from_states) if you have velocity and potential.
206    ///
207    /// Integrates over the **full** sample span. For a named arc, use
208    /// [`Dt::proper_time_from_path_between`](#method.proper_time_from_path_between).
209    ///
210    /// Empty path or a single point → [`Dt::ZERO`]. Non-monotonic times →
211    /// [`DtErrKind::NonMonotonic`].
212    ///
213    /// ## Example
214    ///
215    /// ```rust
216    /// use deep_time::{Dt, Scale, Spacetime};
217    ///
218    /// let t0 = Dt::from_sec(0, Scale::TAI, Scale::TAI);
219    /// let t1 = Dt::from_sec(1000, Scale::TAI, Scale::TAI);
220    /// // α = 0.9, at rest → rate 0.9, Δτ = 900 s
221    /// let slow = Spacetime::new(0.9, 0.0, 0.0);
222    /// let dtau = Dt::proper_time_from_path([(t0, slow.clone()), (t1, slow)]).unwrap();
223    /// assert_eq!(dtau, Dt::from_sec(900, Scale::TAI, Scale::TAI));
224    /// ```
225    pub fn proper_time_from_path<I>(path: I) -> Result<Self, DtErr>
226    where
227        I: IntoIterator<Item = (Self, Spacetime)>,
228    {
229        let mut iter = path.into_iter();
230
231        let Some((mut prev_t, mut prev_ls)) = iter.next() else {
232            return Ok(Self::ZERO);
233        };
234
235        let mut accumulated = Self::ZERO;
236
237        for (t, ls) in iter {
238            if t.lt(&prev_t) {
239                return Err(an_err!(DtErrKind::NonMonotonic));
240            }
241
242            let rate0 = Self::rate_from_local(&prev_ls);
243            let rate1 = Self::rate_from_local(&ls);
244            accumulated = accumulated.add(Self::proper_time_segment(prev_t, rate0, t, rate1));
245
246            prev_t = t;
247            prev_ls = ls;
248        }
249
250        Ok(accumulated)
251    }
252
253    /// Proper time Δτ on `[start, end]` for a path of [`Spacetime`] samples.
254    ///
255    /// Like [`Dt::proper_time_from_path`](#method.proper_time_from_path), but only over a chosen time window.
256    /// Between samples the clock rate is treated as linear (trapezoidal rule);
257    /// if `start` or `end` falls between samples, the rate is interpolated.
258    ///
259    /// Use this when your pipeline already stores α, β, and curvature instead of
260    /// raw Φ and \(v\). Coverage and error kinds match
261    /// [`Dt::proper_time_from_states_between`](#method.proper_time_from_states_between).
262    ///
263    /// ## Example
264    ///
265    /// ```rust
266    /// use deep_time::{Dt, Scale, Spacetime};
267    ///
268    /// let path = [
269    ///     (Dt::from_sec(0, Scale::TAI, Scale::TAI), Spacetime::new(0.9, 0.0, 0.0)),
270    ///     (Dt::from_sec(1000, Scale::TAI, Scale::TAI), Spacetime::new(0.9, 0.0, 0.0)),
271    /// ];
272    /// let start = Dt::from_sec(100, Scale::TAI, Scale::TAI);
273    /// let end = Dt::from_sec(900, Scale::TAI, Scale::TAI);
274    /// // 0.9 × 800 s = 720 s
275    /// let dtau = Dt::proper_time_from_path_between(start, end, path).unwrap();
276    /// assert_eq!(dtau, Dt::from_sec(720, Scale::TAI, Scale::TAI));
277    /// ```
278    pub fn proper_time_from_path_between<I>(start: Dt, end: Dt, path: I) -> Result<Dt, DtErr>
279    where
280        I: IntoIterator<Item = (Self, Spacetime)>,
281    {
282        let rates = path
283            .into_iter()
284            .map(|(t, ls)| (t, Self::rate_from_local(&ls)));
285        Self::integrate_rates_between(start, end, rates)
286    }
287
288    /// Difference in proper time between two paths over the same interval.
289    ///
290    /// How much more (or less) time did clock A accumulate than clock B over
291    /// `[start, end]`?
292    ///
293    /// Returns \(\Delta\tau_A - \Delta\tau_B\). Positive means A’s clock ran
294    /// ahead of B’s over that coordinate interval.
295    ///
296    /// ## When to use it
297    ///
298    /// - Two vehicles or two reconstructed trajectories
299    /// - Spacecraft path vs a **sampled** ground path (both as [`Spacetime`] series)
300    ///
301    /// For spacecraft vs a **fixed** ground rate (single number), prefer
302    /// [`Dt::proper_time_differential_vs_rate`](#method.proper_time_differential_vs_rate).
303    ///
304    /// ## Errors
305    ///
306    /// Both paths must cover `[start, end]`. Same error kinds as
307    /// [`Dt::proper_time_from_path_between`](#method.proper_time_from_path_between) (`Incomplete`, `OutOfRange`,
308    /// `NonMonotonic`). `start == end` returns zero.
309    ///
310    /// ## Example
311    ///
312    /// ```rust
313    /// use deep_time::{Dt, Scale, Spacetime};
314    ///
315    /// let t0 = Dt::from_sec(0, Scale::TAI, Scale::TAI);
316    /// let t1 = Dt::from_sec(1000, Scale::TAI, Scale::TAI);
317    /// let high = Spacetime::new(0.95, 0.0, 0.0); // less redshifted
318    /// let low = Spacetime::new(0.90, 0.0, 0.0);
319    /// let path_a = [(t0, high.clone()), (t1, high)];
320    /// let path_b = [(t0, low.clone()), (t1, low)];
321    /// let diff = Dt::proper_time_differential_from_paths(t0, t1, path_a, path_b).unwrap();
322    /// // 950 − 900 = +50 s
323    /// assert_eq!(diff, Dt::from_sec(50, Scale::TAI, Scale::TAI));
324    /// ```
325    pub fn proper_time_differential_from_paths<Ia, Ib>(
326        start: Dt,
327        end: Dt,
328        path_a: Ia,
329        path_b: Ib,
330    ) -> Result<Dt, DtErr>
331    where
332        Ia: IntoIterator<Item = (Self, Spacetime)>,
333        Ib: IntoIterator<Item = (Self, Spacetime)>,
334    {
335        if start.eq(&end) {
336            return Ok(Dt::ZERO);
337        }
338        let dtau_a = Self::proper_time_from_path_between(start, end, path_a)?;
339        let dtau_b = Self::proper_time_from_path_between(start, end, path_b)?;
340        Ok(dtau_a.sub(dtau_b))
341    }
342
343    /// Proper time of a path minus a constant reference clock rate over `[start, end]`.
344    ///
345    /// How much did the spacecraft clock pull ahead of (or fall behind) a steady
346    /// ground or reference clock?
347    ///
348    /// Returns \(\Delta\tau_{\mathrm{path}} - r_{\mathrm{ref}}\,(end - start)\).
349    /// Positive means the path clock accumulated more proper time than the
350    /// reference over the interval.
351    ///
352    /// ## When to use it
353    ///
354    /// - Onboard vs Earth-surface rate (mission clock differentials)
355    /// - Satellite vs a fixed geoid rate
356    /// - Any reference well modeled as **constant** \(r_{\mathrm{ref}}\)
357    ///
358    /// Get \(r_{\mathrm{ref}}\) from [`Spacetime::proper_time_rate`] for a
359    /// stationary ground [`Spacetime`], or from a documented conventional value.
360    ///
361    /// ## Errors
362    ///
363    /// Path must cover `[start, end]`. Same error kinds as
364    /// [`Dt::proper_time_from_path_between`](#method.proper_time_from_path_between). `start == end` returns zero.
365    ///
366    /// ## Example
367    ///
368    /// ```rust
369    /// use deep_time::{Dt, Scale, Spacetime};
370    ///
371    /// let t0 = Dt::from_sec(0, Scale::TAI, Scale::TAI);
372    /// let t1 = Dt::from_sec(100_000, Scale::TAI, Scale::TAI);
373    /// // Slightly higher rate than a deeper potential well
374    /// let sc = Spacetime::new(0.999_999_999_9, 0.0, 0.0);
375    /// let ground = Spacetime::new(0.999_999_999_3, 0.0, 0.0);
376    /// let path = [(t0, sc.clone()), (t1, sc)];
377    /// let diff = Dt::proper_time_differential_vs_rate(
378    ///     t0,
379    ///     t1,
380    ///     path,
381    ///     ground.proper_time_rate(),
382    /// )
383    /// .unwrap();
384    /// assert!(diff.to_sec_f() > 0.0);
385    /// ```
386    pub fn proper_time_differential_vs_rate<I>(
387        start: Dt,
388        end: Dt,
389        path: I,
390        ref_rate: Real,
391    ) -> Result<Dt, DtErr>
392    where
393        I: IntoIterator<Item = (Self, Spacetime)>,
394    {
395        if start.eq(&end) {
396            return Ok(Dt::ZERO);
397        }
398        let dtau = Self::proper_time_from_path_between(start, end, path)?;
399        let ref_dtau = start.proper_time_between_constant_rate(end, ref_rate);
400        Ok(dtau.sub(ref_dtau))
401    }
402
403    /// Proper time when the rate \(d\tau/dt\) is constant over an interval.
404    ///
405    /// If conditions do not change (same speed, same gravity), proper time is
406    /// just **rate × elapsed coordinate time**. No sample list needed.
407    ///
408    /// ## When to use it
409    ///
410    /// - Fixed ground station
411    /// - Circular orbit approximated as constant rate
412    /// - Deep-space cruise with nearly constant \(v\) and Φ
413    /// - Building the reference leg for
414    ///   [`Dt::proper_time_differential_vs_rate`](#method.proper_time_differential_vs_rate)
415    ///
416    /// Called on the **start** time: `start.proper_time_between_constant_rate(end, rate)`.
417    /// If `end` is before `self`, the result is negative.
418    ///
419    /// ## Example
420    ///
421    /// ```rust
422    /// use deep_time::{Dt, Scale, Spacetime};
423    ///
424    /// let t0 = Dt::from_sec(0, Scale::TAI, Scale::TAI);
425    /// let t1 = Dt::from_sec(86_400, Scale::TAI, Scale::TAI);
426    /// let ground = Spacetime::new(0.999_999_999_3, 0.0, 0.0);
427    /// let dtau = t0.proper_time_between_constant_rate(t1, ground.proper_time_rate());
428    /// assert!(dtau.to_sec_f() > 0.0 && dtau.to_sec_f() < 86_400.0);
429    /// ```
430    #[inline]
431    pub const fn proper_time_between_constant_rate(self, end: Dt, dtau_dt: Real) -> Dt {
432        let dt_sec = end.to_diff_raw(self).to_sec_f();
433        from_sec_f!(dtau_dt * dt_sec)
434    }
435
436    // -----------------------------------------------------------------------
437    // Private helpers
438    // -----------------------------------------------------------------------
439
440    /// Maps `(t, velocity, Φ)` states to `(t, Spacetime)` using the library rate model.
441    fn states_to_path<I>(
442        samples: I,
443        characteristic_length_scale: Real,
444    ) -> impl Iterator<Item = (Self, Spacetime)>
445    where
446        I: IntoIterator<Item = (Self, Velocity, Real)>,
447    {
448        samples.into_iter().map(move |(t, vel, phi)| {
449            let phi_over_c2 = phi / C_SQUARED;
450            let ls = Spacetime::from_potential_velocity_and_scale(
451                phi_over_c2,
452                vel,
453                characteristic_length_scale,
454            );
455            (t, ls)
456        })
457    }
458
459    /// Shared kernel: integrate a piecewise-linear proper-time rate series over
460    /// the closed coordinate interval `[start, end]`.
461    ///
462    /// Returns absolute Δτ (not drift). Coverage and monotonicity rules match
463    /// the public `*_between` methods.
464    fn integrate_rates_between<I>(start: Dt, end: Dt, rates: I) -> Result<Dt, DtErr>
465    where
466        I: IntoIterator<Item = (Self, Real)>,
467    {
468        if start.eq(&end) {
469            return Ok(Dt::ZERO);
470        }
471        if end.lt(&start) {
472            return Err(an_err!(DtErrKind::OutOfRange));
473        }
474
475        let mut iter = rates.into_iter();
476
477        let Some((mut prev_t, mut prev_rate)) = iter.next() else {
478            return Err(an_err!(DtErrKind::Incomplete));
479        };
480
481        // Need a sample at or before `start` to evaluate the rate on the window.
482        if prev_t.gt(&start) {
483            return Err(an_err!(DtErrKind::Incomplete));
484        }
485
486        let mut accumulated = Self::ZERO;
487        // Once true, `(prev_t, prev_rate)` is the left endpoint of an open
488        // segment still inside the window (`start <= prev_t < end`).
489        let mut active = false;
490
491        for (t, rate) in iter {
492            if t.lt(&prev_t) {
493                return Err(an_err!(DtErrKind::NonMonotonic));
494            }
495
496            if !active {
497                if t.lt(&start) {
498                    // Entirely before the window; slide forward.
499                    prev_t = t;
500                    prev_rate = rate;
501                    continue;
502                }
503
504                // prev_t <= start <= t
505                let rate_start = if prev_t.eq(&start) {
506                    prev_rate
507                } else if t.eq(&start) {
508                    rate
509                } else {
510                    Self::lerp_rate(prev_t, prev_rate, t, rate, start)
511                };
512
513                if t.lt(&end) {
514                    accumulated =
515                        accumulated.add(Self::proper_time_segment(start, rate_start, t, rate));
516                    active = true;
517                    prev_t = t;
518                    prev_rate = rate;
519                    continue;
520                }
521
522                // t >= end: the whole window lies inside this bracketing segment.
523                let rate_end = if t.eq(&end) {
524                    rate
525                } else {
526                    Self::lerp_rate(prev_t, prev_rate, t, rate, end)
527                };
528                accumulated =
529                    accumulated.add(Self::proper_time_segment(start, rate_start, end, rate_end));
530                return Ok(accumulated);
531            }
532
533            // active: integrate from prev toward end
534            if t.lt(&end) {
535                accumulated =
536                    accumulated.add(Self::proper_time_segment(prev_t, prev_rate, t, rate));
537                prev_t = t;
538                prev_rate = rate;
539                continue;
540            }
541
542            // t >= end
543            let rate_end = if t.eq(&end) {
544                rate
545            } else {
546                Self::lerp_rate(prev_t, prev_rate, t, rate, end)
547            };
548            accumulated =
549                accumulated.add(Self::proper_time_segment(prev_t, prev_rate, end, rate_end));
550            return Ok(accumulated);
551        }
552
553        // Exhausted samples without reaching `end`.
554        Err(an_err!(DtErrKind::Incomplete))
555    }
556
557    /// Trapezoidal proper-time advance over one coordinate segment.
558    ///
559    /// Uses the compensated form
560    /// \(\Delta\tau = \Delta t + \tfrac12(r_0 + r_1 - 2)\,\Delta t\)
561    /// so that the large \(\approx 1\) part of the rate does not cancel against
562    /// \(\Delta t\) in floating point. Supports a negative segment
563    /// (`t1 < t0`) for symmetry; callers that enforce monotonic times only see
564    /// non-negative \(\Delta t\).
565    #[inline]
566    const fn proper_time_segment(t0: Dt, rate0: Real, t1: Dt, rate1: Real) -> Dt {
567        let dt = t1.to_diff_raw(t0);
568        if dt.is_zero() {
569            return Self::ZERO;
570        }
571
572        let sign = if dt.to_attos() < 0 { f!(-1.0) } else { f!(1.0) };
573        let dt_pos = if sign < f!(0.0) { dt.neg() } else { dt };
574        let dt_sec = dt_pos.to_sec_f();
575
576        let integral = f!(0.5) * (rate0 + rate1 - f!(2.0)) * dt_sec;
577        from_sec_f!(sign * (dt_sec + integral))
578    }
579
580    /// Linearly interpolates the proper-time rate at coordinate time `t`,
581    /// assuming a piecewise-linear rate between `(t0, rate0)` and `(t1, rate1)`.
582    ///
583    /// Caller must ensure `t0 < t1` (non-zero span) and typically
584    /// `t0 < t < t1`.
585    #[inline]
586    const fn lerp_rate(t0: Dt, rate0: Real, t1: Dt, rate1: Real, t: Dt) -> Real {
587        let span = t1.to_diff_raw(t0).to_sec_f();
588        let frac = t.to_diff_raw(t0).to_sec_f() / span;
589        rate0 + frac * (rate1 - rate0)
590    }
591
592    /// Returns the instantaneous proper-time rate (dτ/dt) from a local
593    /// spacetime state.
594    #[inline]
595    const fn rate_from_local(spacetime: &Spacetime) -> Real {
596        let drift = Drift::from_spacetime(spacetime);
597        f!(1.0) + drift.rate.to_sec_f()
598    }
599}