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