Skip to main content

deep_time/
light_time.rs

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