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}