Skip to main content

deep_time/physics/observer/
light_time.rs

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