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    /// This is the recommended value to pass as the `shapiro` parameter to
9    /// [`ObserverState::one_way_relativistic_delay`] (and [`ObserverState::shapiro_delay_to`])
10    /// for all normal solar-system work.
11    ///
12    /// It corresponds to the one-way Shapiro coefficient for the Sun.
13    pub const SHAPIRO_SOLAR: Self = Self::from_sec_f(TWO_GM_SUN_OVER_C3);
14
15    /// Creates the Shapiro delay scale for an arbitrary central body
16    /// from its standard gravitational parameter `GM` (μ) in m³ s⁻².
17    ///
18    /// This produces the coefficient used in the Shapiro gravitational time delay
19    /// formula. It is the recommended way to create a custom Shapiro scale for
20    /// planets, stars, or other massive bodies.
21    ///
22    /// Pass the resulting value to [`ObserverState::one_way_relativistic_delay`]
23    /// or [`ObserverState::shapiro_delay_to`].
24    #[inline]
25    pub const fn shapiro_from_grav_param(gm: Real) -> Self {
26        let secs = 2.0 * gm / (C * C_SQUARED);
27        Self::from_sec_f(secs)
28    }
29
30    /// Creates an [`ObserverState`] using this time value along with the
31    /// provided position, velocity, and gravitational information.
32    ///
33    /// An `ObserverState` represents a complete snapshot of an observer
34    /// (spacecraft, ground station, planet, etc.) at a specific moment.
35    /// It bundles together the time, position, velocity, and local
36    /// gravitational environment so that relativistic calculations
37    /// (light time, clock rates, Shapiro delay, etc.) can be performed.
38    ///
39    /// This method is a convenience constructor. It is useful when you
40    /// already have a [`Dt`] (a time value) and want to build an
41    /// `ObserverState` directly from it, rather than calling
42    /// [`ObserverState::new`] or [`ObserverState::new_strong_field`].
43    ///
44    /// # Parameters
45    ///
46    /// - `position`: The observer’s position in meters (typically expressed
47    ///   in a barycentric or heliocentric frame).
48    /// - `velocity`: The observer’s velocity in meters per second.
49    /// - `grav_potential_m2_s2`: The total Newtonian gravitational potential
50    ///   (Φ) at the observer’s location, in m²/s². This is usually negative
51    ///   for bound orbits and is the sum of contributions from the Sun and
52    ///   planets.
53    /// - `characteristic_length_scale`: A length scale (in meters) over which
54    ///   gravity varies significantly at this location. Use `0.0` for normal
55    ///   solar-system and weak-field cases. Only provide a non-zero value when
56    ///   working in strong gravitational fields.
57    ///
58    /// # When to use this method
59    ///
60    /// Use this method when you already have a time value as a [`Dt`] and
61    /// want to construct an `ObserverState` in one step. It is especially
62    /// convenient when working with time values that were previously
63    /// computed or converted.
64    ///
65    /// For most normal use, [`ObserverState::new`] is simpler. Use
66    /// [`ObserverState::new_strong_field`] instead if you need to specify
67    /// a non-zero `characteristic_length_scale`.
68    ///
69    /// # Example
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    /// - `time`: The time of the state.
150    /// - `position`: Position in meters (usually barycentric or heliocentric).
151    /// - `velocity`: Velocity in m/s.
152    /// - `grav_potential_m2_s2`: Newtonian gravitational potential Φ
153    ///   at the location (in m²/s²).
154    #[inline]
155    pub const fn new(
156        time: Dt,
157        position: Position,
158        velocity: Velocity,
159        grav_potential_m2_s2: Real,
160    ) -> Self {
161        Self {
162            time,
163            position,
164            velocity,
165            grav_potential_m2_s2,
166            characteristic_length_scale: 0.0,
167        }
168    }
169
170    /// Creates a new `ObserverState` when strong-field effects or a
171    /// non-zero characteristic length scale are relevant.
172    ///
173    /// Use this constructor when you have gravimeter data or are working
174    /// in regions where spacetime curvature varies significantly over
175    /// short distances (e.g. near compact objects). The
176    /// `characteristic_length_scale` parameter controls whether the
177    /// library activates higher-order terms in the proper-time calculation.
178    ///
179    /// For normal solar-system work, use [`Self::new`] instead.
180    ///
181    /// # Parameters
182    /// - `time`: The time of the state.
183    /// - `position`: Position in meters.
184    /// - `velocity`: Velocity in m/s.
185    /// - `grav_potential_m2_s2`: Newtonian gravitational potential Φ
186    ///   at the location (in m²/s²).
187    /// - `characteristic_length_scale`: Length scale (in meters) over which
188    ///   gravity varies at this location. Must be positive to have an effect.
189    #[inline]
190    pub const fn new_strong_field(
191        time: Dt,
192        position: Position,
193        velocity: Velocity,
194        grav_potential_m2_s2: Real,
195        characteristic_length_scale: Real,
196    ) -> Self {
197        Self {
198            time,
199            position,
200            velocity,
201            grav_potential_m2_s2,
202            characteristic_length_scale,
203        }
204    }
205
206    /// Returns the instantaneous proper-time rate `dτ/dt` for this observer.
207    ///
208    /// This value indicates how fast a physical clock located at this state
209    /// would advance relative to the time used by this `ObserverState`.
210    /// A returned value of `1.0` means the clock advances at the same rate
211    /// as the state's time coordinate. Values are typically slightly different
212    /// from `1.0` due to the effects of velocity and gravitational potential.
213    ///
214    /// This rate is computed using the library’s unified proper-time model.
215    /// It is used internally for light-time corrections and Doppler calculations.
216    #[inline]
217    pub const fn proper_time_rate(&self) -> Real {
218        Spacetime::from_potential_velocity_and_scale(
219            self.grav_potential_m2_s2 / C_SQUARED,
220            self.velocity,
221            self.characteristic_length_scale,
222        )
223        .proper_time_rate()
224    }
225
226    /// Returns the ratio of proper time rates between the receiver and transmitter
227    /// for a one-way signal.
228    ///
229    /// This method computes:
230    ///
231    /// ```text
232    /// ratio = rx.proper_time_rate() / self.proper_time_rate()
233    /// ```
234    ///
235    /// ### Interpretation
236    ///
237    /// - A value of `1.0` indicates that both clocks run at the same rate.
238    /// - A value **less than `1.0`** means the receiver’s clock runs slower than
239    ///   the transmitter’s clock. The receiver will observe a lower frequency
240    ///   than was emitted.
241    /// - A value **greater than `1.0`** means the receiver’s clock runs faster
242    ///   than the transmitter’s clock. The receiver will observe a higher frequency
243    ///   than was emitted.
244    ///
245    /// The ratio captures the combined effect of special-relativistic time dilation
246    /// (due to velocity) and general-relativistic gravitational time dilation.
247    ///
248    /// ### Typical Usage (One-Way)
249    ///
250    /// This ratio is often combined with the classical kinematic Doppler term
251    /// to estimate the total one-way frequency shift:
252    ///
253    /// ```text
254    /// approximate_frequency_shift ≈ ratio * (1 - v_radial / C)
255    /// ```
256    ///
257    /// where `v_radial` is the radial velocity (positive when the receiver is
258    /// receding).
259    ///
260    /// ### Two-Way Usage
261    ///
262    /// For round-trip (two-way) measurements, square the one-way ratio:
263    ///
264    /// ```rust,ignore
265    /// let one_way_ratio = transmitter.relativistic_clock_rate_ratio(receiver);
266    /// let two_way_ratio = one_way_ratio * one_way_ratio;
267    /// ```
268    ///
269    /// This pattern is commonly used when correcting two-way Doppler (range-rate)
270    /// data for relativistic clock effects.
271    ///
272    /// ### Limitations
273    ///
274    /// - This method only accounts for the **difference in clock rates** between
275    ///   the two ends.
276    /// - It does **not** include Shapiro delay or higher-order relativistic effects
277    ///   on signal propagation.
278    /// - The combination with classical Doppler shown above is a first-order
279    ///   approximation.
280    ///
281    /// # Parameters
282    /// - `self` — Transmitter state at the time of transmission.
283    /// - `rx`   — Receiver state at the approximate time of reception.
284    ///
285    /// # Example
286    /// ```rust,ignore
287    /// let ratio = transmitter.relativistic_clock_rate_ratio(receiver);
288    ///
289    /// let v_radial = ...; // m/s, positive if receding
290    /// let classical_doppler = 1.0 - v_radial / C;
291    ///
292    /// let approx_frequency_shift = ratio * classical_doppler;
293    /// ```
294    #[inline]
295    pub const fn relativistic_clock_rate_ratio(&self, rx: ObserverState) -> Real {
296        rx.proper_time_rate() / self.proper_time_rate()
297    }
298
299    /// Computes the additional delay that must be added to the Newtonian
300    /// geometric light time `|r_rx − r_tx| / c`.
301    ///
302    /// In simple terms, this method calculates the extra time delay caused by
303    /// differences in clock rates between the transmitter and receiver (due to
304    /// their relativistic states, including velocity and gravity) plus the
305    /// gravitational Shapiro delay.
306    ///
307    /// The returned value is a **hybrid correction** consisting of two parts:
308    ///
309    /// - The difference in accumulated proper time between the transmitter and
310    ///   receiver (clock-rate effects).
311    /// - The gravitational (Shapiro) delay caused by spacetime curvature near
312    ///   a central mass.
313    ///
314    /// This is the primary method for relativistic light-time calculations.
315    ///
316    /// ### Optional Endpoint States
317    ///
318    /// You may optionally supply a slice of [`Spacetime`] samples. When two or
319    /// more samples are provided, only the first and last elements are used:
320    /// the first becomes the effective transmitter state, and the last becomes
321    /// the effective receiver state for the clock-rate correction.
322    ///
323    /// This is useful when you have more accurate information about the local
324    /// spacetime conditions exactly at transmission and reception.
325    ///
326    /// If fewer than two samples are provided, the method falls back to using
327    /// `self` and `rx` directly.
328    ///
329    /// **Note**: This is an endpoint-based model. It does **not** perform
330    /// numerical integration of proper time along the signal path.
331    ///
332    /// ### Custom Shapiro / Propagation Delay
333    ///
334    /// You can provide your own delay value via `custom_shapiro_delay`. When
335    /// supplied, this value is used directly instead of the internally computed
336    /// Shapiro delay.
337    ///
338    /// This allows you to pass:
339    /// - A delay computed with a different Shapiro model
340    /// - A delay that includes additional propagation effects (such as solar plasma)
341    /// - A delay obtained from an external calculation
342    ///
343    /// # Parameters
344    ///
345    /// * `rx` — Receiver state at the approximate arrival time.
346    /// * `shapiro` — Shapiro scale factor. Use [`Dt::SHAPIRO_SOLAR`] for normal
347    ///   solar-system work.
348    /// * `samples` — Optional [`Spacetime`] samples. Only the first and last
349    ///   are used when provided.
350    /// * `custom_shapiro_delay` — Optional custom delay to use instead of the
351    ///   standard Shapiro calculation.
352    ///
353    /// # Returns
354    ///
355    /// The total relativistic correction to be added to the geometric light time.
356    ///
357    /// # Example
358    ///
359    /// ```rust,ignore
360    /// // Basic usage
361    /// let correction = tx.one_way_relativistic_delay(
362    ///     rx_approx,
363    ///     Dt::SHAPIRO_SOLAR,
364    ///     &[],
365    ///     None,
366    /// );
367    ///
368    /// // With custom endpoint states and a custom delay
369    /// let path_samples = vec![tx_spacetime, rx_spacetime];
370    /// let custom_delay = compute_custom_delay(...);
371    ///
372    /// let correction = tx.one_way_relativistic_delay(
373    ///     rx_approx,
374    ///     Dt::SHAPIRO_SOLAR,
375    ///     &path_samples,
376    ///     Some(custom_delay),
377    /// );
378    /// ```
379    pub const fn one_way_relativistic_delay(
380        &self,
381        rx: ObserverState,
382        shapiro: Dt,
383        samples: &[Spacetime],
384        custom_shapiro_delay: Option<Dt>,
385    ) -> Dt {
386        // Compute the differential clock-rate correction
387        let drift_correction = if samples.len() >= 2 {
388            let tx_local = samples[0];
389            let tx_drift = Drift::from_spacetime(&tx_local);
390
391            let rx_local = samples[samples.len() - 1];
392            let rx_drift = Drift::from_spacetime(&rx_local);
393
394            let span = rx.time.to_diff_raw(self.time);
395
396            rx_drift
397                .time_diff_after(&span)
398                .sub(tx_drift.time_diff_after(&span))
399        } else {
400            // Fallback to using self and rx directly
401            self.compute_differential_clock_correction(rx)
402        };
403
404        // Determine which Shapiro value to use
405        let shapiro_delay = match custom_shapiro_delay {
406            Some(custom) => custom,
407            None => self.shapiro_delay_to(rx, shapiro),
408        };
409
410        drift_correction.add(shapiro_delay)
411    }
412
413    /// Iteratively solves the one-way light-time equation including relativistic
414    /// corrections until the receive time converges to the requested tolerance.
415    ///
416    /// This is the recommended high-precision solver for one-way light-time
417    /// computations in modern deep-space navigation. It follows the formulation
418    /// described in Moyer (2003) and works with any ephemeris source (SPICE
419    /// kernels, numerical integrators, or analytical propagators).
420    ///
421    /// The solver performs a fixed-point iteration on the light-time equation:
422    ///
423    /// ```text
424    /// t_rx = t_tx + |r_rx(t_rx) − r_tx(t_tx)| / c + Δt_rel(t_tx, t_rx)
425    /// ```
426    ///
427    /// where `Δt_rel` is the relativistic correction returned by
428    /// [`one_way_relativistic_delay`]. The iteration continues until the change
429    /// in the estimated receive time falls below `tolerance`.
430    ///
431    /// # Parameters
432    ///
433    /// * `rx_provider` — A mutable closure that returns the full relativistic
434    ///   state of the receiver at a given coordinate time. This allows the solver
435    ///   to work with any ephemeris or propagator without requiring a specific
436    ///   data structure.
437    /// * `shapiro` — Controls the gravitational (Shapiro) contribution. Use
438    ///   [`Dt::SHAPIRO_SOLAR`] for solar-system work or
439    ///   [`Dt::shapiro_from_grav_param`] for other central bodies.
440    /// * `tolerance` — Convergence tolerance on the change in receive time.
441    ///   A typical value for high-precision work is `Dt::from_ns(1, Scale::TAI)`.
442    /// * `max_iter` — Maximum number of iterations before falling back. A value
443    ///   of 12–20 is usually sufficient for solar-system geometries.
444    ///
445    /// # Returns
446    ///
447    /// A tuple containing:
448    /// * `rel_correction` — The final relativistic correction (clock-rate
449    ///   correction + Shapiro) evaluated at convergence.
450    /// * `rx_time` — The converged receive time in the coordinate scale of
451    ///   the transmitter.
452    /// * `final_state` — The receiver state at the converged receive time.
453    ///
454    /// # Examples
455    ///
456    /// ```rust,ignore
457    /// use deep_time::{Dt, ObserverState, Scale};
458    ///
459    /// # let tx = /* transmitter state */;
460    /// let tolerance = Dt::from_ns(1, Scale::TAI);
461    ///
462    /// let (correction, rx_time, rx_state) = tx.iterative_one_way_relativistic_delay_to(
463    ///     &mut |t| {
464    ///         // Example: call into SPICE or your own propagator
465    ///         get_receiver_state_at(t)
466    ///     },
467    ///     Dt::SHAPIRO_SOLAR,
468    ///     tolerance,
469    ///     20,
470    /// );
471    ///
472    /// // The total light time is:
473    /// let total_light_time = rx_time.to_diff_raw(tx.time);
474    /// ```
475    ///
476    /// # References
477    ///
478    /// * Moyer, T.D. (2003). *Formulation for Observed and Computed Values of
479    ///   Deep Space Network Data Types for Navigation*. JPL DESCANSO Vol. 2,
480    ///   Section 8 (Light-Time Solution).
481    /// * IAU Resolution B1.5 (2000) and subsequent updates on relativistic
482    ///   reference systems and time scales.
483    /// * Ashby, N. (2003). "Relativity in the Global Positioning System".
484    ///   *Living Reviews in Relativity*.
485    ///
486    /// # Notes
487    ///
488    /// The solver uses simple fixed-point iteration. For most solar-system
489    /// geometries, convergence occurs within 3–6 iterations. The function always
490    /// returns a result. If `max_iter` is reached, the last computed values are
491    /// returned. The returned `ObserverState` is guaranteed to be consistent with
492    /// the final `rx_time`.
493    pub fn iterative_one_way_relativistic_delay_to<F>(
494        &self,
495        rx_provider: &mut F,
496        shapiro: Dt,
497        tolerance: Dt,
498        max_iter: usize,
499    ) -> (Dt, Dt, ObserverState)
500    where
501        F: FnMut(Dt) -> ObserverState,
502    {
503        // Initial geometric guess
504        let initial_rx = rx_provider(self.time);
505        let initial_r_sep = self.position.distance_to(initial_rx.position);
506        let initial_geometric = Dt::from_sec_f(initial_r_sep / C);
507
508        let mut rx_time = self.time.add(initial_geometric);
509        let mut rel_correction = Dt::ZERO;
510
511        for _ in 0..max_iter {
512            let rx = rx_provider(rx_time);
513
514            rel_correction = self.one_way_relativistic_delay(rx, shapiro, &[], None);
515
516            let r_sep = self.position.distance_to(rx.position);
517            let geometric = Dt::from_sec_f(r_sep / C);
518            let full_delay = geometric.add(rel_correction);
519
520            let new_rx_time = self.time.add(full_delay);
521            let change = new_rx_time.to_diff_raw(rx_time);
522
523            rx_time = new_rx_time;
524
525            if change.abs() < tolerance {
526                return (rel_correction, rx_time, rx);
527            }
528        }
529
530        // Fallback after max iterations
531        let final_rx = rx_provider(rx_time);
532        (rel_correction, rx_time, final_rx)
533    }
534
535    /// Computes the total relativistic correction for a two-way round-trip
536    /// ranging measurement.
537    ///
538    /// This method solves the uplink and downlink legs **independently** using
539    /// the iterative light-time solver. This is the modern approach recommended
540    /// by Moyer (2003) and used by deep-space networks (DSN, ESA, JPL).
541    ///
542    /// Solving the legs separately is more accurate than older combined
543    /// round-trip formulas when the two ends are in different gravitational
544    /// environments or have significantly different velocities.
545    ///
546    /// The returned value must be **subtracted** from the raw measured
547    /// round-trip time to recover the geometric light time.
548    ///
549    /// # Parameters
550    ///
551    /// * `rx_provider` — Closure that returns the relativistic state of the
552    ///   remote body (planet, spacecraft, etc.) at a given coordinate time.
553    ///   Used for both the uplink solution and to obtain the accurate state
554    ///   at uplink arrival for the downlink leg.
555    /// * `tx_provider` — Closure that returns the relativistic state of the
556    ///   local transmitter at a given coordinate time (e.g. a moving ground
557    ///   station or another spacecraft). Used for the downlink leg.
558    /// * `shapiro` — Shapiro scale factor applied to both legs. Use
559    ///   [`Dt::SHAPIRO_SOLAR`] for normal solar-system work.
560    /// * `tolerance` — Convergence tolerance for the underlying iterative
561    ///   solver (recommended: `Dt::from_ns(1, Scale::TAI)`).
562    /// * `max_iter` — Maximum iterations per leg (typically 12–20).
563    ///
564    /// # Returns
565    ///
566    /// The total relativistic correction for the complete round trip.
567    /// This value should be subtracted from the observed round-trip time.
568    ///
569    /// # Examples
570    ///
571    /// ```rust,ignore
572    /// use deep_time::{Dt, Scale};
573    ///
574    /// # let earth_station = /* local transmitter state */;
575    /// let tolerance = Dt::from_ns(1, Scale::TAI);
576    ///
577    /// let correction = earth_station.round_trip_relativistic_correction(
578    ///     &mut |t| get_spacecraft_state(t),      // remote body
579    ///     &mut |t| get_earth_station_state(t),   // local transmitter
580    ///     Dt::SHAPIRO_SOLAR,
581    ///     tolerance,
582    ///     15,
583    /// );
584    ///
585    /// // Geometric light time = measured_round_trip_time - correction
586    /// ```
587    ///
588    /// # References
589    ///
590    /// * Moyer, T.D. (2003). *Formulation for Observed and Computed Values of
591    ///   Deep Space Network Data Types for Navigation*. JPL DESCANSO Vol. 2,
592    ///   Sections 8 and 11 (Two-Way Light Time).
593    /// * IAU Resolution B1.5 (2000) and updates on relativistic reference systems.
594    /// * Modern DSN and ESA ranging implementations.
595    ///
596    /// # Notes
597    ///
598    /// This method performs two independent iterative solutions. The downlink
599    /// leg uses the precise receiver state obtained at the end of the uplink
600    /// solution, ensuring consistency between the two legs.
601    ///
602    /// The method is suitable for Earth–Mars, Jupiter, Kuiper Belt, and
603    /// interstellar-class distances.
604    pub fn round_trip_relativistic_correction<RxF, TxF>(
605        &self,
606        mut rx_provider: RxF, // remote body (planet, spacecraft, etc.)
607        mut tx_provider: TxF, // local transmitter for the return leg (can move)
608        shapiro: Dt,
609        tolerance: Dt,
610        max_iter: usize,
611    ) -> Dt
612    where
613        RxF: FnMut(Dt) -> ObserverState,
614        TxF: FnMut(Dt) -> ObserverState,
615    {
616        // Uplink leg: transmitter → receiver
617        let (uplink_rel, rx_time, _rx_state) = self.iterative_one_way_relativistic_delay_to(
618            &mut rx_provider,
619            shapiro,
620            tolerance,
621            max_iter,
622        );
623
624        // Downlink leg: receiver → transmitter
625        let return_tx = rx_provider(rx_time); // accurate state at uplink arrival
626
627        let (downlink_rel, _return_rx_time, _return_rx_state) = return_tx
628            .iterative_one_way_relativistic_delay_to(
629                &mut tx_provider,
630                shapiro,
631                tolerance,
632                max_iter,
633            );
634
635        uplink_rel.add(downlink_rel)
636    }
637
638    /// Computes **only** the gravitational (Shapiro) delay for a one-way signal.
639    ///
640    /// This method returns the classical coordinate-time Shapiro delay caused by
641    /// spacetime curvature near a central mass. It deliberately excludes any
642    /// differential proper-time (clock-rate) correction.
643    ///
644    /// Use this method when you need only the traditional relativistic propagation
645    /// delay used in most deep-space navigation, ranging, and orbit determination
646    /// systems (consistent with Moyer 2003 DSN formulations).
647    ///
648    /// If you need both the Shapiro delay **and** the differential clock-rate
649    /// correction, use [`Self::one_way_relativistic_delay`] instead.
650    ///
651    /// # Parameters
652    /// - `rx` — Receiver state at the approximate arrival time.
653    /// - `shapiro` — Shapiro scale factor. Use [`Dt::SHAPIRO_SOLAR`] for normal
654    ///   solar-system work or [`Dt::shapiro_from_grav_param`] for other central
655    ///   bodies. Pass `Dt::ZERO` to disable the Shapiro contribution.
656    ///
657    /// # Returns
658    /// The Shapiro delay (`Dt`) to be added to the Newtonian geometric light time.
659    ///
660    /// # Example
661    /// ```rust,ignore
662    /// let shapiro_correction = transmitter.shapiro_delay_to(receiver_approx, Dt::SHAPIRO_SOLAR);
663    /// ```
664    #[inline]
665    pub const fn shapiro_delay_to(&self, rx: ObserverState, shapiro: Dt) -> Dt {
666        let r_tx = self.position.norm();
667        let r_rx = rx.position.norm();
668        let r_sep = self.position.distance_to(rx.position);
669        Self::shapiro_one_way_delay(shapiro, r_tx, r_rx, r_sep)
670    }
671
672    /// Computes the first-order one-way Shapiro delay caused by a central point mass.
673    ///
674    /// This is an internal helper used by `shapiro_delay_to` and
675    /// `one_way_relativistic_delay`.
676    ///
677    /// The implementation uses the standard analytic approximation:
678    ///
679    /// ```text
680    /// Δt_Shapiro = (2GM/c³) × ln((r_tx + r_rx + r_sep) / (r_tx + r_rx - r_sep))
681    /// ```
682    ///
683    /// # Numerical Notes
684    ///
685    /// - Returns `Dt::ZERO` if any of `r_tx`, `r_rx`, `r_sep`, or `shapiro` are
686    ///   zero or negative.
687    /// - Uses a small epsilon (`1e-6` m) as a safety floor on the denominator to
688    ///   avoid division by zero or taking the logarithm of an invalid value.
689    /// - When the signal path is nearly collinear with the central body (very
690    ///   small impact parameter), the result can become large. This function does
691    ///   **not** model the finite size of the Sun (or other body) nor higher-order
692    ///   relativistic effects.
693    /// - For high-precision work during deep solar conjunctions, consider using
694    ///   a numerically integrated or impact-parameter-based Shapiro model instead.
695    pub(crate) const fn shapiro_one_way_delay(
696        shapiro: Dt,
697        r_tx: Real,
698        r_rx: Real,
699        r_sep: Real,
700    ) -> Dt {
701        let shapiro_sec = shapiro.to_sec_f();
702
703        if r_tx <= f!(0.0) || r_rx <= f!(0.0) || r_sep <= f!(0.0) || shapiro_sec == f!(0.0) {
704            return Dt::ZERO;
705        }
706
707        // Safety floor to avoid division by zero or log of invalid argument
708        // in near-grazing geometries.
709        const EPS: Real = f!(1e-6);
710
711        let denom = (r_tx + r_rx - r_sep).max(EPS);
712        let arg = (r_tx + r_rx + r_sep) / denom;
713
714        // Additional guard against invalid logarithm argument
715        if arg <= f!(1.0) {
716            return Dt::ZERO;
717        }
718
719        let delay_sec = shapiro_sec * log(arg);
720        Dt::from_sec_f(delay_sec)
721    }
722
723    /// Computes only the differential clock-rate correction between `self`
724    /// (transmitter) and `rx` (receiver). Does **not** include any Shapiro delay.
725    ///
726    /// Internal helper.
727    pub const fn compute_differential_clock_correction(&self, rx: ObserverState) -> Dt {
728        let span = rx.time.to_diff_raw(self.time);
729
730        let tx_drift = Drift::from_velocity_potential_and_scale(
731            self.velocity.speed(),
732            self.grav_potential_m2_s2,
733            self.characteristic_length_scale,
734        );
735        let rx_drift = Drift::from_velocity_potential_and_scale(
736            rx.velocity.speed(),
737            rx.grav_potential_m2_s2,
738            rx.characteristic_length_scale,
739        );
740
741        rx_drift
742            .time_diff_after(&span)
743            .sub(tx_drift.time_diff_after(&span))
744    }
745}