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