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`] 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`] 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}