Skip to main content

deep_time/physics/observer/
light_time.rs

1//! Light-time implementations for [`Observer`].
2
3use crate::{
4    C, C_SQUARED, Drift, Dt, Position, Real, Scale, TWO_GM_SUN_OVER_C3, Velocity, log,
5};
6
7use super::Observer;
8
9impl Dt {
10    /// Shapiro gravitational time scale for the Sun (`2 G M_☉ / c³`).
11    ///
12    /// Recommended value for the Sun when building the `bodies` slice passed to
13    /// [`Observer::shapiro_delay`], [`Observer::shapiro_delay`],
14    /// and related methods.
15    pub const SHAPIRO_SOLAR: Self = Self::from_sec_f(TWO_GM_SUN_OVER_C3, Scale::TAI);
16
17    /// Creates the Shapiro delay scale for an arbitrary central body
18    /// from its standard gravitational parameter `GM` (μ) in m³ s⁻².
19    ///
20    /// This produces the coefficient used in the Shapiro gravitational time delay
21    /// formula. It is the recommended way to create a custom Shapiro scale for
22    /// planets, stars, or other massive bodies.
23    ///
24    /// The returned value is intended to be used for the `bodies` parameter
25    /// when calling [`Observer::shapiro_delay`] or
26    /// [`Observer::shapiro_delay`].
27    #[inline]
28    pub const fn shapiro_from_grav_param(gm: Real) -> Dt {
29        let secs = 2.0 * gm / (C * C_SQUARED);
30        Self::from_sec_f(secs, Scale::TAI)
31    }
32
33    /// Creates an [`Observer`] using this time value along with the
34    /// provided position, velocity, and gravitational information.
35    ///
36    /// An [`Observer`] represents a complete snapshot of an observer
37    /// (spacecraft, ground station, planet, person, etc.) at a
38    /// specific moment.
39    ///
40    /// It bundles together the time, position, velocity, and local
41    /// gravitational environment so that relativistic calculations
42    /// (light time, clock rates, Shapiro delay, etc.) can be performed.
43    ///
44    /// This method is a convenience constructor. It is useful when you
45    /// already have a [`Dt`] (a time value) and want to build an
46    /// [`Observer`] directly from it.
47    ///
48    /// ## Parameters
49    ///
50    /// - `position`: The observer’s position in meters (typically expressed
51    ///   in a barycentric or heliocentric frame).
52    /// - `velocity`: The observer’s velocity in meters per second.
53    /// - `grav_potential_m2_s2`: The total Newtonian gravitational potential
54    ///   (Φ) at the observer’s location, in m²/s². This is usually negative
55    ///   for bound orbits and is the sum of contributions from the Sun and
56    ///   planets.
57    /// - `characteristic_length_scale`: A length scale (in meters) over which
58    ///   gravity varies significantly at this location. Use `0.0` for normal
59    ///   solar-system and weak-field cases. Only provide a non-zero value when
60    ///   working in strong gravitational fields.
61    ///
62    /// ## Examples
63    ///
64    /// ```
65    /// use deep_time::{Dt, Position, Spacetime, Velocity};
66    ///
67    /// let bodies = [
68    ///     (Position::from_au(0.0, 0.0, 0.0), 1.3271244e20),     // Sun
69    ///     (Position::from_au(1.0, 0.0, 0.0), 3.9860044e14),     // Earth
70    ///     (Position::from_au(1.00257, 0.0, 0.0), 4.9048695e12), // Moon
71    /// ];
72    ///
73    /// let position = Position::from_au(1.001, 0.001, 0.0); // e.g. spacecraft, asteroid, etc.
74    ///
75    /// let grav_potential = Spacetime::grav_potential_from_point_masses(
76    ///     position,
77    ///     bodies.iter().copied(),
78    /// );
79    ///
80    /// let t = Dt::span_f(1234.5);
81    ///
82    /// let state = t.to_observer(
83    ///     Position::ZERO,
84    ///     Velocity::ZERO,
85    ///     grav_potential,
86    ///     0.0, // normal solar-system use
87    /// );
88    /// ```
89    #[inline]
90    pub const fn to_observer(
91        self,
92        position: Position,
93        velocity: Velocity,
94        grav_potential_m2_s2: Real,
95        characteristic_length_scale: Real,
96    ) -> Observer {
97        Observer {
98            time: self,
99            position,
100            velocity,
101            grav_potential_m2_s2,
102            characteristic_length_scale,
103        }
104    }
105}
106
107impl Observer {
108    /// Computes the combined one-way relativistic correction for a signal
109    /// traveling from this observer (the transmitter) to a receiver.
110    ///
111    /// This value is the **total extra time** you should add to the Newtonian
112    /// geometric light travel time (`distance / speed of light`). It includes
113    /// **two** separate relativistic effects:
114    ///
115    /// 1. The gravitational propagation delay (Shapiro delay) caused by the
116    ///    Sun and other bodies slowing the signal.
117    /// 2. The differential clock-rate correction caused by the transmitter
118    ///    and receiver having slightly different proper-time rates (due to
119    ///    their velocities and gravitational potentials).
120    ///
121    /// In other words, this method gives you **propagation delay + clock-rate
122    /// correction** in one convenient call.
123    ///
124    /// **Important:** This is a convenience method. It is provided so you can
125    /// get the full one-way relativistic correction quickly. If you need
126    /// strict separation of the two effects (for example, to apply them at
127    /// different stages of your calculation), call
128    /// [`Self::shapiro_delay`] and [`Self::compute_differential_clock_correction`]
129    /// individually and add the results yourself.
130    ///
131    /// ## When to use this method
132    ///
133    /// Use this when you need the complete relativistic correction for
134    /// one-way light time in a single step — for example when:
135    /// - Computing high-precision one-way range or Doppler observables
136    /// - Building simplified navigation or orbit determination models
137    /// - You want the total effect without manually combining the pieces
138    ///
139    /// ## The `bodies` parameter – which masses to include
140    ///
141    /// Pass a slice of `(shapiro_coefficient, body_position)` pairs:
142    ///
143    /// - `shapiro_coefficient`: How strong the delay from this body should be.
144    ///   It equals `2GM / c³`. Use [`Dt::SHAPIRO_SOLAR`] for the Sun, or
145    ///   [`Dt::shapiro_from_grav_param(gm)`] for any other body.
146    /// - `body_position`: Where the center of that body is located at the
147    ///   relevant time.
148    ///
149    /// **Important: All positions must be measured the same way**
150    ///
151    /// The transmitter position (`self.position`), the receiver position
152    /// (`rx.position`), and every `body_position` you provide must all be
153    /// measured from the **same point in space**, and they must all use
154    /// the **same directions** for their X, Y, and Z axes.
155    ///
156    /// For example, if your transmitter position is measured from the center
157    /// of the solar system, then the receiver and body positions must also
158    /// be measured from the center of the solar system using the same
159    /// pointing directions for the coordinate axes.
160    ///
161    /// In most solar-system work, people use positions from JPL ephemerides
162    /// (which are measured from the center of the solar system).
163    ///
164    /// Pass an empty slice (`&[]`) to turn off the Shapiro (gravitational)
165    /// part of the correction.
166    ///
167    /// ## Parameters
168    ///
169    /// * `rx` — Receiver state at the approximate time the signal arrives.
170    /// * `bodies` — List of bodies that should contribute to the gravitational
171    ///   propagation delay.
172    ///
173    /// ## Returns
174    ///
175    /// The total one-way relativistic correction (Shapiro propagation delay
176    /// plus differential clock-rate correction), expressed as a `Dt` in the
177    /// same time scale as `self.time`.
178    ///
179    /// This value should normally be **added** to the Newtonian geometric
180    /// light time.
181    pub const fn one_way_relativistic_delay(&self, rx: Observer, bodies: &[(Dt, Position)]) -> Dt {
182        let prop = self.shapiro_delay(rx, bodies);
183        let drift = self.compute_differential_clock_correction(rx);
184        prop.add(drift)
185    }
186
187    /// Iteratively solves the one-way light-time equation in coordinate time,
188    /// including relativistic propagation corrections, until convergence.
189    ///
190    /// This solver computes the receive epoch `t_rx` such that:
191    ///
192    /// ```text
193    /// t_rx = t_tx + |r_rx(t_rx) − r_tx(t_tx)| / c + Δt_shapiro(t_tx, t_rx)
194    /// ```
195    ///
196    /// It performs fixed-point iteration using the propagation delay returned by
197    /// [`Self::shapiro_delay`]. Clock-rate and proper-time effects
198    /// are **not** included in the iteration; they should be applied separately
199    /// when converting between coordinate time and proper time or when forming
200    /// observables.
201    ///
202    /// The solver is suitable for high-precision one-way light-time calculations
203    /// and works with any ephemeris source via the provided closure.
204    ///
205    /// ## Parameters
206    ///
207    /// * `rx_provider` — Closure returning the full [`Observer`] of the
208    ///   receiver at a given coordinate time.
209    /// * `bodies` — Slice of `(shapiro_coefficient, body_position)` pairs
210    ///   controlling the Shapiro contribution. Use `&[(Dt::SHAPIRO_SOLAR, sun_pos)]`
211    ///   for solar-system work; include additional bodies for higher precision.
212    ///   Pass `&[]` to disable Shapiro.
213    /// * `tolerance` — Maximum allowed change in receive time per iteration
214    ///   before declaring convergence (e.g. `Dt::from_ns(1, Scale::TAI)`).
215    /// * `max_iter` — Maximum number of iterations. Typical values are 12–20
216    ///   for solar-system geometries.
217    ///
218    /// ## Returns
219    ///
220    /// A tuple `(prop_correction, rx_time, final_state)` where:
221    /// - `prop_correction` is the converged Shapiro propagation delay,
222    /// - `rx_time` is the converged receive time (same scale as `self.time`),
223    /// - `final_state` is the receiver state at `rx_time`.
224    pub fn iterative_one_way_light_time_to<F>(
225        &self,
226        rx_provider: &mut F,
227        bodies: &[(Dt, Position)],
228        tolerance: Dt,
229        max_iter: usize,
230    ) -> (Dt, Dt, Observer)
231    where
232        F: FnMut(Dt) -> Observer,
233    {
234        // Initial geometric guess
235        let initial_rx = rx_provider(self.time);
236        let initial_r_sep = self.position.distance_to(initial_rx.position);
237        let initial_geometric = Dt::from_sec_f(initial_r_sep / C, Scale::TAI);
238
239        let mut rx_time = self.time.add(initial_geometric);
240        let mut prop_correction = Dt::ZERO;
241
242        for _ in 0..max_iter {
243            let rx = rx_provider(rx_time);
244
245            prop_correction = self.shapiro_delay(rx, bodies);
246
247            let r_sep = self.position.distance_to(rx.position);
248            let geometric = Dt::from_sec_f(r_sep / C, Scale::TAI);
249            let full_delay = geometric.add(prop_correction);
250
251            let new_rx_time = self.time.add(full_delay);
252            let change = new_rx_time.to_diff_raw(rx_time);
253
254            rx_time = new_rx_time;
255
256            if change.abs() < tolerance {
257                return (prop_correction, rx_time, rx);
258            }
259        }
260
261        // Fallback after max iterations
262        let final_rx = rx_provider(rx_time);
263        (prop_correction, rx_time, final_rx)
264    }
265
266    /// Computes the total Shapiro (gravitational propagation) delay for a
267    /// complete round-trip (two-way) signal.
268    ///
269    /// This method solves the uplink and downlink legs *separately and
270    /// independently* using the iterative light-time solver. This approach
271    /// is more accurate than older combined round-trip formulas when the
272    /// two ends have significantly different velocities or are in different
273    /// gravitational environments.
274    ///
275    /// The returned value is the **sum of the uplink and downlink Shapiro
276    /// delays only**. It does **not** include clock-rate or proper-time
277    /// corrections.
278    ///
279    /// ## When to use this method
280    ///
281    /// Use this when you need the total gravitational propagation correction
282    /// for two-way (round-trip) measurements, for example:
283    /// - Two-way range or range-rate (Doppler) data
284    /// - Transponded signals from spacecraft
285    /// - Any high-precision two-way light-time calculation
286    ///
287    /// For one-way signals, use [`Self::shapiro_delay`] or
288    /// [`Self::one_way_relativistic_delay`] instead.
289    ///
290    /// ## How the calculation works
291    ///
292    /// 1. Solves the uplink leg (from `self` to the remote receiver) using
293    ///    the `rx_provider` closure.
294    /// 2. Obtains the accurate receiver state at the uplink arrival time.
295    /// 3. Solves the downlink leg (from the receiver back to the local
296    ///    transmitter) using the `tx_provider` closure.
297    ///
298    /// ## The `bodies` parameter – which masses to include
299    ///
300    /// Pass a slice of `(shapiro_coefficient, body_position)` pairs (the
301    /// same slice is used for both legs). See [`Self::shapiro_delay`] for
302    /// details on how to build this slice.
303    ///
304    /// **Important: All states returned by the providers must be consistent**
305    /// with the same reference frame (same origin and same coordinate axes).
306    ///
307    /// ## Parameters
308    ///
309    /// * `rx_provider` — Closure that returns the full [`Observer`] of
310    ///   the remote receiver (planet, spacecraft, etc.) at any given
311    ///   coordinate time.
312    /// * `tx_provider` — Closure that returns the full [`Observer`] of
313    ///   the local transmitter at any given coordinate time (used only for
314    ///   the downlink leg).
315    /// * `bodies` — Slice of `(shapiro_coefficient, body_position)` pairs
316    ///   describing the gravitating bodies.
317    /// * `tolerance` — Convergence tolerance for each leg’s iterative solver
318    ///   (e.g. `Dt::from_ns(1, Scale::TAI)`).
319    /// * `max_iter` — Maximum number of iterations allowed per leg
320    ///   (typical values are 12–20).
321    ///
322    /// ## Returns
323    ///
324    /// The total round-trip Shapiro propagation delay (uplink + downlink)
325    /// as a `Dt`, in the same time scale as `self.time`.
326    ///
327    /// This value should normally be **added** to the Newtonian geometric
328    /// round-trip light time. Clock-rate corrections must still be applied
329    /// separately (e.g. by squaring the one-way clock-rate ratio).
330    pub fn round_trip_light_time_correction<RxF, TxF>(
331        &self,
332        mut rx_provider: RxF, // remote body (planet, spacecraft, etc.)
333        mut tx_provider: TxF, // local transmitter for the return leg (can move)
334        bodies: &[(Dt, Position)],
335        tolerance: Dt,
336        max_iter: usize,
337    ) -> Dt
338    where
339        RxF: FnMut(Dt) -> Observer,
340        TxF: FnMut(Dt) -> Observer,
341    {
342        // Uplink leg: transmitter → receiver
343        let (uplink_prop, rx_time, _rx_state) =
344            self.iterative_one_way_light_time_to(&mut rx_provider, bodies, tolerance, max_iter);
345
346        // Downlink leg: receiver → transmitter
347        let return_tx = rx_provider(rx_time); // accurate state at uplink arrival
348
349        let (downlink_prop, _return_rx_time, _return_rx_state) = return_tx
350            .iterative_one_way_light_time_to(&mut tx_provider, bodies, tolerance, max_iter);
351
352        uplink_prop.add(downlink_prop)
353    }
354
355    /// Computes the one-way gravitational propagation delay (Shapiro delay)
356    /// caused by massive bodies between this observer (the transmitter) and
357    /// a receiver.
358    ///
359    /// This value is the **extra time** a radio signal takes to travel because
360    /// gravity from the Sun and planets slightly slows it down. You normally
361    /// add this delay to the ordinary geometric light travel time
362    /// (`distance / speed of light`) to get a more accurate total one-way
363    /// signal travel time.
364    ///
365    /// **Important:** This method returns **only** the gravitational
366    /// propagation delay. It does **not** include clock-rate differences
367    /// between the transmitter and receiver caused by velocity or gravity.
368    /// Those effects are available separately through
369    /// [`Self::compute_differential_clock_correction`],
370    /// [`Self::proper_time_rate`], and [`Self::relativistic_clock_rate_ratio`].
371    ///
372    /// ## When to use this method
373    ///
374    /// Use this when you need the gravitational (Shapiro) contribution to
375    /// one-way light time — for example when building high-precision range,
376    /// Doppler, or orbit determination models.
377    ///
378    /// ## The `bodies` parameter – which masses to include
379    ///
380    /// Pass a slice of `(shapiro_coefficient, body_position)` pairs:
381    ///
382    /// - `shapiro_coefficient`: How strong the delay from this body should be.
383    ///   It equals `2GM / c³`. Use [`Dt::SHAPIRO_SOLAR`] for the Sun, or
384    ///   [`Dt::shapiro_from_grav_param(gm)`] for any other body.
385    /// - `body_position`: Where the center of that body is located at the
386    ///   relevant time.
387    ///
388    /// **Important: All positions must be measured the same way**
389    ///
390    /// The transmitter position (`self.position`), the receiver position
391    /// (`rx.position`), and every `body_position` you provide must all be
392    /// measured from the **same point in space**, and they must all use
393    /// the **same directions** for their X, Y, and Z axes.
394    ///
395    /// For example, if the transmitter position is measured from the center
396    /// of the solar system, then the receiver and body positions must also
397    /// be measured from the center of the solar system, using the same
398    /// pointing directions for the coordinate axes.
399    ///
400    /// If the positions come from different measurement systems, the
401    /// calculated delay will be wrong.
402    ///
403    /// In most solar-system work, people use positions from JPL ephemerides
404    /// (which are measured from the center of the solar system).
405    ///
406    /// Pass an empty slice (`&[]`) to turn off Shapiro delay entirely.
407    ///
408    /// ## Parameters
409    ///
410    /// * `rx` — Receiver state at the approximate time the signal arrives.
411    /// * `bodies` — List of bodies that should contribute to the delay.
412    ///
413    /// ## Returns
414    ///
415    /// The total one-way Shapiro gravitational propagation delay, in the
416    /// same time scale as `self.time`. This value should normally be
417    /// **added** to the Newtonian geometric light time.
418    pub const fn shapiro_delay(&self, rx: Observer, bodies: &[(Dt, Position)]) -> Dt {
419        let mut total = Dt::ZERO;
420        let mut i = 0;
421
422        while i < bodies.len() {
423            let (shapiro_coeff, body_pos) = bodies[i];
424            total = total.add(Self::shapiro_one_way_delay(
425                shapiro_coeff,
426                self.position,
427                rx.position,
428                body_pos,
429            ));
430            i += 1;
431        }
432
433        total
434    }
435
436    /// Computes the first-order one-way Shapiro gravitational time delay
437    /// due to a single central body using a numerically stable formulation.
438    ///
439    /// This is the **core low-level implementation** (pub(crate) const fn).
440    /// It replaces the classic radial formula with an algebraically equivalent
441    /// but cancellation-free form that is robust even for small impact parameters
442    /// (near-grazing / conjunction geometries).
443    ///
444    /// The algorithm uses the identity:
445    ///
446    ///
447    ///   ln((r_tx + r_rx + r_sep) / (r_tx + r_rx - r_sep))
448    ///   ≡ 2·ln(num) − ln(denom_term)
449    ///
450    ///
451    /// where denom_term is computed from the dot-product identity
452    /// (r_tx + r_rx)² − r_sep² = 2(r_tx·r_rx + p_tx · p_rx).
453    /// This avoids the dangerous subtraction that loses precision when
454    /// the signal path passes close to the body.
455    ///
456    /// The result is equivalent (within floating-point) to the
457    /// classic Moyer/DSN-style formula while being far more stable.
458    /// Contributions from multiple bodies are summed at a higher level.
459    ///
460    /// ## Safety / Guards
461    ///
462    /// - Returns [`Dt::ZERO`](../struct.Dt.html#associatedconstant.ZERO)
463    ///   for any non-positive distance or zero Shapiro coefficient.
464    /// - Protects against invalid logarithm argument (`arg <= 1.0`).
465    /// - Designed for weak-field solar-system / cislunar use (monopole, straight-line approx).
466    pub(crate) const fn shapiro_one_way_delay(
467        shapiro: Dt,
468        tx_pos: Position,
469        rx_pos: Position,
470        body_pos: Position,
471    ) -> Dt {
472        let shapiro_sec = shapiro.to_sec_f();
473
474        // Distances relative to *this specific gravitating body*
475        let r_tx = tx_pos.distance_to(body_pos);
476        let r_rx = rx_pos.distance_to(body_pos);
477        let r_sep = tx_pos.distance_to(rx_pos);
478
479        if r_tx <= f!(0.0) || r_rx <= f!(0.0) || r_sep <= f!(0.0) || shapiro_sec == f!(0.0) {
480            return Dt::ZERO;
481        }
482
483        let s = r_tx + r_rx;
484        let num = s + r_sep; // (r_tx + r_rx + r_sep)
485
486        if num <= f!(0.0) {
487            return Dt::ZERO;
488        }
489
490        // Stable computation of (r_tx + r_rx)^2 − r_sep^2
491        // = 2 × (r_tx r_rx + \vec{p_tx} · \vec{p_rx})
492        let dot_term = (r_tx * r_tx + r_rx * r_rx - r_sep * r_sep) / f!(2.0);
493        let denom_term = f!(2.0) * (r_tx * r_rx + dot_term);
494
495        if denom_term <= f!(0.0) {
496            return Dt::ZERO;
497        }
498
499        let arg = (num * num) / denom_term;
500
501        if arg <= f!(1.0) {
502            return Dt::ZERO;
503        }
504
505        let delay_sec = shapiro_sec * log(arg);
506        Dt::from_sec_f(delay_sec, Scale::TAI)
507    }
508
509    /// Computes the differential proper-time correction between `self`
510    /// (transmitter) and `rx` (receiver) over the interval between their
511    /// time tags.
512    ///
513    /// This returns the difference in proper time advance between the two
514    /// observers. It does **not** include Shapiro propagation delay.
515    ///
516    /// The result can be added to the output of [`Self::shapiro_delay`]
517    /// or [`Self::iterative_one_way_light_time_to`] when a combined
518    /// relativistic correction (propagation + clock rate) is required.
519    ///
520    /// ## Parameters
521    ///
522    /// * `rx` — Receiver state at the approximate time of reception.
523    ///
524    /// ## Returns
525    ///
526    /// The differential clock-rate correction (`rx_proper_advance − tx_proper_advance`).
527    pub const fn compute_differential_clock_correction(&self, rx: Observer) -> Dt {
528        let span = rx.time.to_diff_raw(self.time);
529
530        let tx_drift = Drift::from_velocity_potential_and_scale(
531            self.velocity.speed(),
532            self.grav_potential_m2_s2,
533            self.characteristic_length_scale,
534        );
535        let rx_drift = Drift::from_velocity_potential_and_scale(
536            rx.velocity.speed(),
537            rx.grav_potential_m2_s2,
538            rx.characteristic_length_scale,
539        );
540
541        rx_drift
542            .time_diff_after(&span)
543            .sub(tx_drift.time_diff_after(&span))
544    }
545}