Skip to main content

deep_time/
light_time.rs

1use crate::{
2    C, C_SQUARED, Drift, Dt, Position, Real, Spacetime, TWO_GM_SUN_OVER_C3, Velocity, log,
3};
4
5impl Dt {
6    /// Shapiro gravitational time scale for the Sun (`2 G M_☉ / c³`).
7    ///
8    /// Recommended value for the Sun when building the `bodies` slice passed to
9    /// [`ObserverState::shapiro_delay`], [`ObserverState::shapiro_delay`],
10    /// and related methods.
11    pub const SHAPIRO_SOLAR: Self = Self::from_sec_f(TWO_GM_SUN_OVER_C3);
12
13    /// Creates the Shapiro delay scale for an arbitrary central body
14    /// from its standard gravitational parameter `GM` (μ) in m³ s⁻².
15    ///
16    /// This produces the coefficient used in the Shapiro gravitational time delay
17    /// formula. It is the recommended way to create a custom Shapiro scale for
18    /// planets, stars, or other massive bodies.
19    ///
20    /// The returned value is intended to be used for the `bodies` parameter
21    /// when calling [`ObserverState::shapiro_delay`] or
22    /// [`ObserverState::shapiro_delay`].
23    #[inline]
24    pub const fn shapiro_from_grav_param(gm: Real) -> Self {
25        let secs = 2.0 * gm / (C * C_SQUARED);
26        Self::from_sec_f(secs)
27    }
28
29    /// Creates an [`ObserverState`] using this time value along with the
30    /// provided position, velocity, and gravitational information.
31    ///
32    /// An `ObserverState` represents a complete snapshot of an observer
33    /// (spacecraft, ground station, planet, etc.) at a specific moment.
34    /// It bundles together the time, position, velocity, and local
35    /// gravitational environment so that relativistic calculations
36    /// (light time, clock rates, Shapiro delay, etc.) can be performed.
37    ///
38    /// This method is a convenience constructor. It is useful when you
39    /// already have a [`Dt`] (a time value) and want to build an
40    /// `ObserverState` directly from it, rather than calling
41    /// [`ObserverState::new`] or [`ObserverState::new_strong_field`].
42    ///
43    /// ## Parameters
44    ///
45    /// - `position`: The observer’s position in meters (typically expressed
46    ///   in a barycentric or heliocentric frame).
47    /// - `velocity`: The observer’s velocity in meters per second.
48    /// - `grav_potential_m2_s2`: The total Newtonian gravitational potential
49    ///   (Φ) at the observer’s location, in m²/s². This is usually negative
50    ///   for bound orbits and is the sum of contributions from the Sun and
51    ///   planets.
52    /// - `characteristic_length_scale`: A length scale (in meters) over which
53    ///   gravity varies significantly at this location. Use `0.0` for normal
54    ///   solar-system and weak-field cases. Only provide a non-zero value when
55    ///   working in strong gravitational fields.
56    ///
57    /// ## When to use this method
58    ///
59    /// Use this method when you already have a time value as a [`Dt`] and
60    /// want to construct an `ObserverState` in one step. It is especially
61    /// convenient when working with time values that were previously
62    /// computed or converted.
63    ///
64    /// For most normal use, [`ObserverState::new`] is simpler. Use
65    /// [`ObserverState::new_strong_field`] instead if you need to specify
66    /// a non-zero `characteristic_length_scale`.
67    ///
68    /// ## Example
69    ///
70    /// ```ignore
71    /// let t = Dt::from_sec(1234.5);
72    ///
73    /// let state = t.to_observer_state(
74    ///     position,
75    ///     velocity,
76    ///     grav_potential,
77    ///     0.0, // normal solar-system use
78    /// );
79    /// ```
80    #[inline]
81    pub const fn to_observer_state(
82        self,
83        position: Position,
84        velocity: Velocity,
85        grav_potential_m2_s2: Real,
86        characteristic_length_scale: Real,
87    ) -> ObserverState {
88        ObserverState {
89            time: self,
90            position,
91            velocity,
92            grav_potential_m2_s2,
93            characteristic_length_scale,
94        }
95    }
96}
97
98/// A snapshot of an observer’s relativistic state at a specific instant.
99///
100/// `ObserverState` combines time, position, velocity, and local gravitational
101/// information. It is the main input type used by relativistic light-time
102/// methods in this library.
103#[derive(Clone, Copy, Debug, PartialEq)]
104#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
105#[cfg_attr(feature = "js", derive(tsify::Tsify))]
106pub struct ObserverState {
107    /// The time of this state.
108    ///
109    /// Any [`Scale`] is accepted. This time is treated as coordinate time
110    /// for light-time calculations.
111    pub time: Dt,
112
113    /// Position of the observer in meters.
114    ///
115    /// Typically expressed in a barycentric (solar-system barycenter) or
116    /// heliocentric frame, depending on the application.
117    pub position: Position,
118
119    /// Velocity of the observer in meters per second.
120    pub velocity: Velocity,
121
122    /// Newtonian gravitational potential Φ at the observer’s location
123    /// (in m² s⁻²).
124    ///
125    /// This value is usually negative for bound orbits. It should normally
126    /// include contributions from the Sun and all relevant planets.
127    pub grav_potential_m2_s2: Real,
128
129    /// Characteristic length scale (in meters) over which the gravitational
130    /// field varies significantly at this location.
131    ///
132    /// - Use `0.0` (the default) for all solar-system, GNSS, and weak-field
133    ///   applications.
134    /// - Provide a non-zero value only when working in strong gravitational
135    ///   fields (e.g. near neutron stars or black holes), where the library’s
136    ///   higher-order curvature terms become relevant.
137    pub characteristic_length_scale: Real,
138}
139
140impl ObserverState {
141    /// Creates a new `ObserverState` for typical solar-system, GNSS,
142    /// or weak-field use.
143    ///
144    /// This is the recommended constructor for most applications.
145    /// It sets the `characteristic_length_scale` to `0.0`, which disables
146    /// higher-order curvature terms in the proper-time model.
147    ///
148    /// ## Parameters
149    ///
150    /// - `time`: The time of the state.
151    /// - `position`: Position in meters (usually barycentric or heliocentric).
152    /// - `velocity`: Velocity in m/s.
153    /// - `grav_potential_m2_s2`: Newtonian gravitational potential Φ
154    ///   at the location (in m²/s²).
155    #[inline]
156    pub const fn new(
157        time: Dt,
158        position: Position,
159        velocity: Velocity,
160        grav_potential_m2_s2: Real,
161    ) -> Self {
162        Self {
163            time,
164            position,
165            velocity,
166            grav_potential_m2_s2,
167            characteristic_length_scale: 0.0,
168        }
169    }
170
171    /// Returns the instantaneous proper-time rate `dτ/dt` for this observer.
172    ///
173    /// This value indicates how fast a physical clock located at this state
174    /// would advance relative to the time used by this `ObserverState`.
175    /// A returned value of `1.0` means the clock advances at the same rate
176    /// as the state's time coordinate. Values are typically slightly different
177    /// from `1.0` due to the effects of velocity and gravitational potential.
178    ///
179    /// This rate is computed using the library’s unified proper-time model.
180    /// It is used internally for light-time corrections and Doppler calculations.
181    #[inline]
182    pub const fn proper_time_rate(&self) -> Real {
183        Spacetime::from_potential_velocity_and_scale(
184            self.grav_potential_m2_s2 / C_SQUARED,
185            self.velocity,
186            self.characteristic_length_scale,
187        )
188        .proper_time_rate()
189    }
190
191    /// Returns the ratio of proper time rates between the receiver and transmitter
192    /// for a one-way signal.
193    ///
194    /// This method computes:
195    ///
196    /// ```text
197    /// ratio = rx.proper_time_rate() / self.proper_time_rate()
198    /// ```
199    ///
200    /// ### Interpretation
201    ///
202    /// - A value of `1.0` indicates that both clocks run at the same rate.
203    /// - A value **less than `1.0`** means the receiver’s clock runs slower than
204    ///   the transmitter’s clock. The receiver will observe a lower frequency
205    ///   than was emitted.
206    /// - A value **greater than `1.0`** means the receiver’s clock runs faster
207    ///   than the transmitter’s clock. The receiver will observe a higher frequency
208    ///   than was emitted.
209    ///
210    /// The ratio captures the combined effect of special-relativistic time dilation
211    /// (due to velocity) and general-relativistic gravitational time dilation.
212    ///
213    /// ### Typical Usage (One-Way)
214    ///
215    /// This ratio is often combined with the classical kinematic Doppler term
216    /// to estimate the total one-way frequency shift:
217    ///
218    /// ```text
219    /// approximate_frequency_shift ≈ ratio * (1 - v_radial / C)
220    /// ```
221    ///
222    /// where `v_radial` is the radial velocity (positive when the receiver is
223    /// receding).
224    ///
225    /// ### Two-Way Usage
226    ///
227    /// For round-trip (two-way) measurements, square the one-way ratio:
228    ///
229    /// ```rust,ignore
230    /// let one_way_ratio = transmitter.relativistic_clock_rate_ratio(receiver);
231    /// let two_way_ratio = one_way_ratio * one_way_ratio;
232    /// ```
233    ///
234    /// This pattern is commonly used when correcting two-way Doppler (range-rate)
235    /// data for relativistic clock effects.
236    ///
237    /// ### Limitations
238    ///
239    /// - This method only accounts for the **difference in clock rates** between
240    ///   the two ends.
241    /// - It does **not** include Shapiro delay or higher-order relativistic effects
242    ///   on signal propagation.
243    /// - The combination with classical Doppler shown above is a first-order
244    ///   approximation.
245    ///
246    /// ## Parameters
247    ///
248    /// - `self` — Transmitter state at the time of transmission.
249    /// - `rx`   — Receiver state at the approximate time of reception.
250    ///
251    /// ## Example
252    ///
253    /// ```rust,ignore
254    /// let ratio = transmitter.relativistic_clock_rate_ratio(receiver);
255    ///
256    /// let v_radial = ...; // m/s, positive if receding
257    /// let classical_doppler = 1.0 - v_radial / C;
258    ///
259    /// let approx_frequency_shift = ratio * classical_doppler;
260    /// ```
261    #[inline]
262    pub const fn relativistic_clock_rate_ratio(&self, rx: ObserverState) -> Real {
263        rx.proper_time_rate() / self.proper_time_rate()
264    }
265
266    /// Computes the combined one-way relativistic correction for a signal
267    /// traveling from this observer (the transmitter) to a receiver.
268    ///
269    /// This value is the **total extra time** you should add to the Newtonian
270    /// geometric light travel time (`distance / speed of light`). It includes
271    /// **two** separate relativistic effects:
272    ///
273    /// 1. The gravitational propagation delay (Shapiro delay) caused by the
274    ///    Sun and other bodies slowing the signal.
275    /// 2. The differential clock-rate correction caused by the transmitter
276    ///    and receiver having slightly different proper-time rates (due to
277    ///    their velocities and gravitational potentials).
278    ///
279    /// In other words, this method gives you **propagation delay + clock-rate
280    /// correction** in one convenient call.
281    ///
282    /// **Important:** This is a convenience method. It is provided so you can
283    /// get the full one-way relativistic correction quickly. If you need
284    /// strict separation of the two effects (for example, to apply them at
285    /// different stages of your calculation), call
286    /// [`Self::shapiro_delay`] and [`Self::compute_differential_clock_correction`]
287    /// individually and add the results yourself.
288    ///
289    /// ## When to use this method
290    ///
291    /// Use this when you need the complete relativistic correction for
292    /// one-way light time in a single step — for example when:
293    /// - Computing high-precision one-way range or Doppler observables
294    /// - Building simplified navigation or orbit determination models
295    /// - You want the total effect without manually combining the pieces
296    ///
297    /// ## The `bodies` parameter – which masses to include
298    ///
299    /// Pass a slice of `(shapiro_coefficient, body_position)` pairs:
300    ///
301    /// - `shapiro_coefficient`: How strong the delay from this body should be.
302    ///   It equals `2GM / c³`. Use [`Dt::SHAPIRO_SOLAR`] for the Sun, or
303    ///   [`Dt::shapiro_from_grav_param(gm)`] for any other body.
304    /// - `body_position`: Where the center of that body is located at the
305    ///   relevant time.
306    ///
307    /// **Important: All positions must be measured the same way**
308    ///
309    /// The transmitter position (`self.position`), the receiver position
310    /// (`rx.position`), and every `body_position` you provide must all be
311    /// measured from the **same point in space**, and they must all use
312    /// the **same directions** for their X, Y, and Z axes.
313    ///
314    /// For example, if your transmitter position is measured from the center
315    /// of the solar system, then the receiver and body positions must also
316    /// be measured from the center of the solar system using the same
317    /// pointing directions for the coordinate axes.
318    ///
319    /// In most solar-system work, people use positions from JPL ephemerides
320    /// (which are measured from the center of the solar system).
321    ///
322    /// Pass an empty slice (`&[]`) to turn off the Shapiro (gravitational)
323    /// part of the correction.
324    ///
325    /// ## Parameters
326    ///
327    /// * `rx` — Receiver state at the approximate time the signal arrives.
328    /// * `bodies` — List of bodies that should contribute to the gravitational
329    ///   propagation delay.
330    ///
331    /// ## Returns
332    ///
333    /// The total one-way relativistic correction (Shapiro propagation delay
334    /// plus differential clock-rate correction), expressed as a `Dt` in the
335    /// same time scale as `self.time`.
336    ///
337    /// This value should normally be **added** to the Newtonian geometric
338    /// light time.
339    pub const fn one_way_relativistic_delay(
340        &self,
341        rx: ObserverState,
342        bodies: &[(Dt, Position)],
343    ) -> Dt {
344        let prop = self.shapiro_delay(rx, bodies);
345        let drift = self.compute_differential_clock_correction(rx);
346        prop.add(drift)
347    }
348
349    /// Iteratively solves the one-way light-time equation in coordinate time,
350    /// including relativistic propagation corrections, until convergence.
351    ///
352    /// This solver computes the receive epoch `t_rx` such that:
353    ///
354    /// ```text
355    /// t_rx = t_tx + |r_rx(t_rx) − r_tx(t_tx)| / c + Δt_shapiro(t_tx, t_rx)
356    /// ```
357    ///
358    /// It performs fixed-point iteration using the propagation delay returned by
359    /// [`Self::shapiro_delay`]. Clock-rate and proper-time effects
360    /// are **not** included in the iteration; they should be applied separately
361    /// when converting between coordinate time and proper time or when forming
362    /// observables.
363    ///
364    /// The solver is suitable for high-precision one-way light-time calculations
365    /// and works with any ephemeris source via the provided closure.
366    ///
367    /// ## Parameters
368    ///
369    /// * `rx_provider` — Closure returning the full [`ObserverState`] of the
370    ///   receiver at a given coordinate time.
371    /// * `bodies` — Slice of `(shapiro_coefficient, body_position)` pairs
372    ///   controlling the Shapiro contribution. Use `&[(Dt::SHAPIRO_SOLAR, sun_pos)]`
373    ///   for solar-system work; include additional bodies for higher precision.
374    ///   Pass `&[]` to disable Shapiro.
375    /// * `tolerance` — Maximum allowed change in receive time per iteration
376    ///   before declaring convergence (e.g. `Dt::from_ns(1, Scale::TAI)`).
377    /// * `max_iter` — Maximum number of iterations. Typical values are 12–20
378    ///   for solar-system geometries.
379    ///
380    /// ## Returns
381    ///
382    /// A tuple `(prop_correction, rx_time, final_state)` where:
383    /// - `prop_correction` is the converged Shapiro propagation delay,
384    /// - `rx_time` is the converged receive time (same scale as `self.time`),
385    /// - `final_state` is the receiver state at `rx_time`.
386    pub fn iterative_one_way_light_time_to<F>(
387        &self,
388        rx_provider: &mut F,
389        bodies: &[(Dt, Position)],
390        tolerance: Dt,
391        max_iter: usize,
392    ) -> (Dt, Dt, ObserverState)
393    where
394        F: FnMut(Dt) -> ObserverState,
395    {
396        // Initial geometric guess
397        let initial_rx = rx_provider(self.time);
398        let initial_r_sep = self.position.distance_to(initial_rx.position);
399        let initial_geometric = Dt::from_sec_f(initial_r_sep / C);
400
401        let mut rx_time = self.time.add(initial_geometric);
402        let mut prop_correction = Dt::ZERO;
403
404        for _ in 0..max_iter {
405            let rx = rx_provider(rx_time);
406
407            prop_correction = self.shapiro_delay(rx, bodies);
408
409            let r_sep = self.position.distance_to(rx.position);
410            let geometric = Dt::from_sec_f(r_sep / C);
411            let full_delay = geometric.add(prop_correction);
412
413            let new_rx_time = self.time.add(full_delay);
414            let change = new_rx_time.to_diff_raw(rx_time);
415
416            rx_time = new_rx_time;
417
418            if change.abs() < tolerance {
419                return (prop_correction, rx_time, rx);
420            }
421        }
422
423        // Fallback after max iterations
424        let final_rx = rx_provider(rx_time);
425        (prop_correction, rx_time, final_rx)
426    }
427
428    /// Computes the total Shapiro (gravitational propagation) delay for a
429    /// complete round-trip (two-way) signal.
430    ///
431    /// This method solves the uplink and downlink legs *separately and
432    /// independently* using the iterative light-time solver. This approach
433    /// is more accurate than older combined round-trip formulas when the
434    /// two ends have significantly different velocities or are in different
435    /// gravitational environments.
436    ///
437    /// The returned value is the **sum of the uplink and downlink Shapiro
438    /// delays only**. It does **not** include clock-rate or proper-time
439    /// corrections.
440    ///
441    /// ## When to use this method
442    ///
443    /// Use this when you need the total gravitational propagation correction
444    /// for two-way (round-trip) measurements, for example:
445    /// - Two-way range or range-rate (Doppler) data
446    /// - Transponded signals from spacecraft
447    /// - Any high-precision two-way light-time calculation
448    ///
449    /// For one-way signals, use [`Self::shapiro_delay`] or
450    /// [`Self::one_way_relativistic_delay`] instead.
451    ///
452    /// ## How the calculation works
453    ///
454    /// 1. Solves the uplink leg (from `self` to the remote receiver) using
455    ///    the `rx_provider` closure.
456    /// 2. Obtains the accurate receiver state at the uplink arrival time.
457    /// 3. Solves the downlink leg (from the receiver back to the local
458    ///    transmitter) using the `tx_provider` closure.
459    ///
460    /// ## The `bodies` parameter – which masses to include
461    ///
462    /// Pass a slice of `(shapiro_coefficient, body_position)` pairs (the
463    /// same slice is used for both legs). See [`Self::shapiro_delay`] for
464    /// details on how to build this slice.
465    ///
466    /// **Important: All states returned by the providers must be consistent**
467    /// with the same reference frame (same origin and same coordinate axes).
468    ///
469    /// ## Parameters
470    ///
471    /// * `rx_provider` — Closure that returns the full [`ObserverState`] of
472    ///   the remote receiver (planet, spacecraft, etc.) at any given
473    ///   coordinate time.
474    /// * `tx_provider` — Closure that returns the full [`ObserverState`] of
475    ///   the local transmitter at any given coordinate time (used only for
476    ///   the downlink leg).
477    /// * `bodies` — Slice of `(shapiro_coefficient, body_position)` pairs
478    ///   describing the gravitating bodies.
479    /// * `tolerance` — Convergence tolerance for each leg’s iterative solver
480    ///   (e.g. `Dt::from_ns(1, Scale::TAI)`).
481    /// * `max_iter` — Maximum number of iterations allowed per leg
482    ///   (typical values are 12–20).
483    ///
484    /// ## Returns
485    ///
486    /// The total round-trip Shapiro propagation delay (uplink + downlink)
487    /// as a `Dt`, in the same time scale as `self.time`.
488    ///
489    /// This value should normally be **added** to the Newtonian geometric
490    /// round-trip light time. Clock-rate corrections must still be applied
491    /// separately (e.g. by squaring the one-way clock-rate ratio).
492    pub fn round_trip_light_time_correction<RxF, TxF>(
493        &self,
494        mut rx_provider: RxF, // remote body (planet, spacecraft, etc.)
495        mut tx_provider: TxF, // local transmitter for the return leg (can move)
496        bodies: &[(Dt, Position)],
497        tolerance: Dt,
498        max_iter: usize,
499    ) -> Dt
500    where
501        RxF: FnMut(Dt) -> ObserverState,
502        TxF: FnMut(Dt) -> ObserverState,
503    {
504        // Uplink leg: transmitter → receiver
505        let (uplink_prop, rx_time, _rx_state) =
506            self.iterative_one_way_light_time_to(&mut rx_provider, bodies, tolerance, max_iter);
507
508        // Downlink leg: receiver → transmitter
509        let return_tx = rx_provider(rx_time); // accurate state at uplink arrival
510
511        let (downlink_prop, _return_rx_time, _return_rx_state) = return_tx
512            .iterative_one_way_light_time_to(&mut tx_provider, bodies, tolerance, max_iter);
513
514        uplink_prop.add(downlink_prop)
515    }
516
517    /// Computes the one-way gravitational propagation delay (Shapiro delay)
518    /// caused by massive bodies between this observer (the transmitter) and
519    /// a receiver.
520    ///
521    /// This value is the **extra time** a radio signal takes to travel because
522    /// gravity from the Sun and planets slightly slows it down. You normally
523    /// add this delay to the ordinary geometric light travel time
524    /// (`distance / speed of light`) to get a more accurate total one-way
525    /// signal travel time.
526    ///
527    /// **Important:** This method returns **only** the gravitational
528    /// propagation delay. It does **not** include clock-rate differences
529    /// between the transmitter and receiver caused by velocity or gravity.
530    /// Those effects are available separately through
531    /// [`Self::compute_differential_clock_correction`],
532    /// [`Self::proper_time_rate`], and [`Self::relativistic_clock_rate_ratio`].
533    ///
534    /// ## When to use this method
535    ///
536    /// Use this when you need the gravitational (Shapiro) contribution to
537    /// one-way light time — for example when building high-precision range,
538    /// Doppler, or orbit determination models.
539    ///
540    /// ## The `bodies` parameter – which masses to include
541    ///
542    /// Pass a slice of `(shapiro_coefficient, body_position)` pairs:
543    ///
544    /// - `shapiro_coefficient`: How strong the delay from this body should be.
545    ///   It equals `2GM / c³`. Use [`Dt::SHAPIRO_SOLAR`] for the Sun, or
546    ///   [`Dt::shapiro_from_grav_param(gm)`] for any other body.
547    /// - `body_position`: Where the center of that body is located at the
548    ///   relevant time.
549    ///
550    /// **Important: All positions must be measured the same way**
551    ///
552    /// The transmitter position (`self.position`), the receiver position
553    /// (`rx.position`), and every `body_position` you provide must all be
554    /// measured from the **same point in space**, and they must all use
555    /// the **same directions** for their X, Y, and Z axes.
556    ///
557    /// For example, if the transmitter position is measured from the center
558    /// of the solar system, then the receiver and body positions must also
559    /// be measured from the center of the solar system, using the same
560    /// pointing directions for the coordinate axes.
561    ///
562    /// If the positions come from different measurement systems, the
563    /// calculated delay will be wrong.
564    ///
565    /// In most solar-system work, people use positions from JPL ephemerides
566    /// (which are measured from the center of the solar system).
567    ///
568    /// Pass an empty slice (`&[]`) to turn off Shapiro delay entirely.
569    ///
570    /// ## Parameters
571    ///
572    /// * `rx` — Receiver state at the approximate time the signal arrives.
573    /// * `bodies` — List of bodies that should contribute to the delay.
574    ///
575    /// ## Returns
576    ///
577    /// The total one-way Shapiro gravitational propagation delay, in the
578    /// same time scale as `self.time`. This value should normally be
579    /// **added** to the Newtonian geometric light time.
580    pub const fn shapiro_delay(&self, rx: ObserverState, bodies: &[(Dt, Position)]) -> Dt {
581        let mut total = Dt::ZERO;
582        let mut i = 0;
583
584        while i < bodies.len() {
585            let (shapiro_coeff, body_pos) = bodies[i];
586            total = total.add(Self::shapiro_one_way_delay(
587                shapiro_coeff,
588                self.position,
589                rx.position,
590                body_pos,
591            ));
592            i += 1;
593        }
594
595        total
596    }
597
598    /// Computes the first-order one-way Shapiro gravitational time delay
599    /// due to a single central body using a numerically stable formulation.
600    ///
601    /// This is the **core low-level implementation** (pub(crate) const fn).
602    /// It replaces the classic radial formula with an algebraically equivalent
603    /// but cancellation-free form that is robust even for small impact parameters
604    /// (near-grazing / conjunction geometries).
605    ///
606    /// The algorithm uses the identity:
607    ///
608    /// ```ignore
609    ///   ln((r_tx + r_rx + r_sep) / (r_tx + r_rx - r_sep))
610    ///   ≡ 2·ln(num) − ln(denom_term)
611    /// ```
612    ///
613    /// where denom_term is computed from the dot-product identity
614    /// (r_tx + r_rx)² − r_sep² = 2(r_tx·r_rx + p_tx · p_rx).
615    /// This avoids the dangerous subtraction that loses precision when
616    /// the signal path passes close to the body.
617    ///
618    /// The result is equivalent (within floating-point) to the
619    /// classic Moyer/DSN-style formula while being far more stable.
620    /// Contributions from multiple bodies are summed at a higher level.
621    ///
622    /// ## Safety / Guards
623    ///
624    /// - Returns [`Dt::ZERO`](../struct.Dt.html#associatedconstant.ZERO)
625    ///   for any non-positive distance or zero Shapiro coefficient.
626    /// - Protects against invalid logarithm argument (`arg <= 1.0`).
627    /// - Designed for weak-field solar-system / cislunar use (monopole, straight-line approx).
628    pub(crate) const fn shapiro_one_way_delay(
629        shapiro: Dt,
630        tx_pos: Position,
631        rx_pos: Position,
632        body_pos: Position,
633    ) -> Dt {
634        let shapiro_sec = shapiro.to_sec_f();
635
636        // Distances relative to *this specific gravitating body*
637        let r_tx = tx_pos.distance_to(body_pos);
638        let r_rx = rx_pos.distance_to(body_pos);
639        let r_sep = tx_pos.distance_to(rx_pos);
640
641        if r_tx <= f!(0.0) || r_rx <= f!(0.0) || r_sep <= f!(0.0) || shapiro_sec == f!(0.0) {
642            return Dt::ZERO;
643        }
644
645        let s = r_tx + r_rx;
646        let num = s + r_sep; // (r_tx + r_rx + r_sep)
647
648        if num <= f!(0.0) {
649            return Dt::ZERO;
650        }
651
652        // Stable computation of (r_tx + r_rx)^2 − r_sep^2
653        // = 2 × (r_tx r_rx + \vec{p_tx} · \vec{p_rx})
654        let dot_term = (r_tx * r_tx + r_rx * r_rx - r_sep * r_sep) / f!(2.0);
655        let denom_term = f!(2.0) * (r_tx * r_rx + dot_term);
656
657        if denom_term <= f!(0.0) {
658            return Dt::ZERO;
659        }
660
661        let arg = (num * num) / denom_term;
662
663        if arg <= f!(1.0) {
664            return Dt::ZERO;
665        }
666
667        let delay_sec = shapiro_sec * log(arg);
668        Dt::from_sec_f(delay_sec)
669    }
670
671    /// Computes the differential proper-time correction between `self`
672    /// (transmitter) and `rx` (receiver) over the interval between their
673    /// time tags.
674    ///
675    /// This returns the difference in proper time advance between the two
676    /// observers. It does **not** include Shapiro propagation delay.
677    ///
678    /// The result can be added to the output of [`Self::shapiro_delay`]
679    /// or [`Self::iterative_one_way_light_time_to`] when a combined
680    /// relativistic correction (propagation + clock rate) is required.
681    ///
682    /// ## Parameters
683    ///
684    /// * `rx` — Receiver state at the approximate time of reception.
685    ///
686    /// ## Returns
687    ///
688    /// The differential clock-rate correction (`rx_proper_advance − tx_proper_advance`).
689    pub const fn compute_differential_clock_correction(&self, rx: ObserverState) -> Dt {
690        let span = rx.time.to_diff_raw(self.time);
691
692        let tx_drift = Drift::from_velocity_potential_and_scale(
693            self.velocity.speed(),
694            self.grav_potential_m2_s2,
695            self.characteristic_length_scale,
696        );
697        let rx_drift = Drift::from_velocity_potential_and_scale(
698            rx.velocity.speed(),
699            rx.grav_potential_m2_s2,
700            rx.characteristic_length_scale,
701        );
702
703        rx_drift
704            .time_diff_after(&span)
705            .sub(tx_drift.time_diff_after(&span))
706    }
707}