Skip to main content

deep_time/
light_time.rs

1use crate::{
2    C, C_SQUARED, ClockDrift, Dt, LocalSpacetime, Position, Real, TSpan, TWO_GM_SUN_OVER_C3,
3    Velocity, log,
4};
5
6/// Configuration for the **Shapiro delay** — the extra time light (or radio signals)
7/// takes to travel near a massive body because gravity curves spacetime.
8///
9/// In simple terms: when a light beam or radar pulse passes close to a star or planet,
10/// general relativity makes the path take a tiny bit longer than it would in flat space.
11/// This struct holds the single number that controls how strong that delay is for a
12/// particular central body (the Sun, a planet, another star, etc.).
13///
14/// Used in high-precision light-time calculations, spacecraft ranging, pulsar timing,
15/// and very-long-baseline interferometry (VLBI).
16#[derive(Copy, Clone, Debug, PartialEq)]
17pub struct LightContext {
18    /// 2GM/c³ in seconds — the characteristic gravitational time scale used in the
19    /// one-way Shapiro delay formula.
20    ///
21    /// This is mathematically equal to `2 * GM / c³`, where:
22    /// - `G` is Newton’s gravitational constant,
23    /// - **M** is the mass of the central body,
24    /// - `c` is the speed of light.
25    ///
26    /// “GM” (often written as a single value called the **standard gravitational parameter** or μ)
27    /// is the product of G and M. In real-world astronomy it is measured very accurately as one
28    /// combined number (in m³/s²), so we keep it together here.
29    ///
30    /// It tells the light-propagation code “how strong the gravitational slowing is”
31    /// for this body. Bigger value = stronger delay effect.
32    ///
33    /// Most users never set this manually — just use `SOLAR` or `from_grav_param()`.
34    pub two_grav_param_over_c3: f64,
35}
36
37impl LightContext {
38    /// Ready-to-use value for our own Sun, based on the exact IAU 2015 recommended constants.
39    ///
40    /// Use this for any typical solar-system light-propagation calculation (radar to
41    /// planets, spacecraft tracking, etc.).
42    pub const SOLAR: Self = Self {
43        two_grav_param_over_c3: TWO_GM_SUN_OVER_C3,
44    };
45
46    /// No gravitational delay at all (flat spacetime approximation).
47    ///
48    /// Perfect for:
49    /// - interstellar or intergalactic distances,
50    /// - quick “ignore gravity” calculations,
51    /// - or when you want to apply your own custom gravitational model elsewhere.
52    pub const FLAT: Self = Self {
53        two_grav_param_over_c3: 0.0,
54    };
55
56    /// Creates a `LightContext` for any central body (planet, star, black hole, etc.).
57    ///
58    /// # Arguments
59    ///
60    /// * `grav_param` — the body’s **standard gravitational parameter** (GM or μ)
61    ///   in m³/s². This is the product of Newton’s gravitational constant and the body’s mass.
62    pub const fn from_grav_param(grav_param: f64) -> Self {
63        Self {
64            two_grav_param_over_c3: 2.0 * grav_param / (C * C_SQUARED),
65        }
66    }
67}
68
69/// A complete relativistic state of an observer (spacecraft, ground station,
70/// planet, etc.) at a specific instant.
71///
72/// This is the natural input type for all relativistic light-time calculations
73/// in the library. It bundles position, velocity, gravitational potential, and
74/// an optional length scale in convenient SI units.
75#[derive(Clone, Copy, Debug, PartialEq)]
76#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
77#[cfg_attr(feature = "js", derive(tsify::Tsify))]
78pub struct ObserverState {
79    /// Dt of this state (any [`Scale`] is accepted).
80    pub time: Dt,
81    /// Position in meters (typically barycentric or heliocentric).
82    pub position: Position,
83    /// Velocity in meters per second.
84    pub velocity: Velocity,
85    /// Local gravitational potential Φ in m² s⁻² (negative for bound orbits).
86    /// Usually the sum of contributions from the Sun and planets.
87    pub grav_potential_m2_s2: f64,
88    /// Characteristic length scale (in meters) over which gravity varies
89    /// significantly at the observer’s location.  
90    /// Pass `0.0` (the default) for all solar-system, GNSS, and weak-field cases.
91    pub characteristic_length_scale: f64,
92}
93
94impl ObserverState {
95    /// Creates a new state for typical solar-system or GNSS use.
96    #[inline]
97    pub const fn new(
98        time: Dt,
99        position: Position,
100        velocity: Velocity,
101        grav_potential_m2_s2: f64,
102    ) -> Self {
103        Self {
104            time,
105            position,
106            velocity,
107            grav_potential_m2_s2,
108            characteristic_length_scale: 0.0,
109        }
110    }
111
112    /// Creates a new state when strong-field or gravimeter data is available.
113    #[inline]
114    pub const fn new_strong_field(
115        time: Dt,
116        position: Position,
117        velocity: Velocity,
118        grav_potential_m2_s2: f64,
119        characteristic_length_scale: f64,
120    ) -> Self {
121        Self {
122            time,
123            position,
124            velocity,
125            grav_potential_m2_s2,
126            characteristic_length_scale,
127        }
128    }
129
130    /// Returns the instantaneous proper-time rate `dτ/dt` for this observer.
131    ///
132    /// This is the exact rate at which a real clock at the given position,
133    /// velocity, and gravitational environment would advance compared to
134    /// coordinate time. It is used internally by the library for proper-time
135    /// integration, light-time corrections, and Doppler calculations.
136    #[inline]
137    pub const fn proper_time_rate(&self) -> Real {
138        let ls = LocalSpacetime::from_potential_velocity_and_scale(
139            self.grav_potential_m2_s2 / C_SQUARED,
140            self.velocity,
141            self.characteristic_length_scale,
142        );
143        ls.proper_time_rate()
144    }
145
146    /// Returns the relativistic clock-rate Doppler factor for a one-way signal
147    /// sent from this transmitter to the given receiver.
148    ///
149    /// The factor is the ratio of the receiver’s proper-time rate to the
150    /// transmitter’s proper-time rate. It accounts for the fact that clocks
151    /// at the two locations run at slightly different speeds due to motion
152    /// and gravity.
153    ///
154    /// To obtain the full observed frequency shift, multiply this factor by
155    /// the classical kinematic Doppler term `(1 - v_radial / C)`, where
156    /// `v_radial` is the line-of-sight component of the relative velocity
157    /// (positive when the transmitter and receiver are moving apart).
158    ///
159    /// This value is used for deep-space tracking, GNSS range-rate measurements,
160    /// and pulsar timing.
161    ///
162    /// # Parameters
163    /// - `self` – transmitter state (position, velocity, gravitational potential,
164    ///   and length scale at the moment the signal is sent)
165    /// - `rx`   – receiver state (same information, evaluated at the approximate
166    ///   arrival time)
167    ///
168    /// # Example
169    /// ```rust
170    /// use deep_time::{ObserverState, Position, Velocity, Dt};
171    /// use deep_time::constants::C;
172    ///
173    /// # let tx_time = Dt::default();
174    /// # let rx_time = Dt::default();
175    /// # let tx_pos = Position::ZERO;
176    /// # let rx_pos = Position::ZERO;
177    /// # let tx_vel = Velocity::ZERO;
178    /// # let rx_vel = Velocity::ZERO;
179    /// # let phi = 0.0_f64; // gravitational potential
180    ///
181    /// let tx = ObserverState::new(tx_time, tx_pos, tx_vel, phi);
182    /// let rx = ObserverState::new(rx_time, rx_pos, rx_vel, phi);
183    ///
184    /// let factor = tx.relativistic_clock_doppler_factor(rx);
185    ///
186    /// // Full observed frequency shift (example only)
187    /// let v_radial = 0.0; // m/s, positive if receding
188    /// let classical_doppler = 1.0 - v_radial / C;
189    /// let total_frequency_shift = 1.0 * factor * classical_doppler;
190    /// ```
191    #[inline]
192    pub const fn relativistic_clock_doppler_factor(&self, rx: ObserverState) -> Real {
193        rx.proper_time_rate() / self.proper_time_rate()
194    }
195
196    /// Returns the two-way relativistic clock-rate Doppler factor for round-trip
197    /// ranging (transmit → receive → immediate transponder reply).
198    ///
199    /// This is the product of the one-way factors for the complete round trip
200    /// and is the value needed by deep-space networks when correcting measured
201    /// range-rate data.
202    #[inline]
203    pub const fn two_way_relativistic_doppler_factor(&self, rx: ObserverState) -> Real {
204        let one_way = self.relativistic_clock_doppler_factor(rx);
205        one_way * one_way
206    }
207
208    /// Computes the total relativistic correction that must be added to the Newtonian
209    /// geometric light time (`|r_rx − r_tx| / c`) for a one-way signal.
210    ///
211    /// This function accounts for two physical effects:
212    /// - Differential clock-rate drift between transmitter and receiver (special-relativistic
213    ///   velocity + general-relativistic gravitational time dilation) using the library’s
214    ///   unified master-Lagrangian proper-time model.
215    /// - Gravitational (Shapiro) delay caused by spacetime curvature near a central mass.
216    ///
217    /// Use cases include:
218    /// - Deep-space tracking and ranging (DSN, ESA, JPL)
219    /// - GNSS and satellite navigation
220    /// - Pulsar timing arrays
221    /// - Laser communication or ranging to distant spacecraft
222    /// - Future interstellar missions where signals pass near other stars or black holes
223    ///
224    /// # Parameters
225    /// - `self` – the transmitter’s full relativistic state at the moment the signal is sent
226    /// - `rx` – the receiver’s relativistic state at the approximate arrival time
227    /// - `context` – controls the gravitational (Shapiro) contribution. Use `LightContext::SOLAR`
228    ///   for solar-system work, `LightContext::FLAT` when you want no central-mass delay,
229    ///   or `LightContext::from_grav_param(your_gravitational_parameter)` for any other star, planet,
230    ///   or black hole.
231    ///
232    /// # Returns
233    /// A [`TSpan`] (in seconds) to be **added** to the Newtonian geometric light time.
234    ///
235    /// # Examples
236    ///
237    /// Basic usage for a solar-system one-way light-time correction (e.g. Earth to Mars):
238    ///
239    /// ```no_run
240    /// use deep_time::{
241    ///     ObserverState, Position, Velocity, Dt, TSpan, LightContext,
242    ///     // Assume you have ephemeris functions or constants available
243    /// };
244    ///
245    /// # let tx_time: Dt = todo!();
246    /// # let tx_pos: Position = todo!();
247    /// # let tx_vel: Velocity = todo!();
248    /// # let tx_potential: f64 = todo!();
249    /// # let rx_approx_time: Dt = todo!();
250    /// # let rx_pos: Position = todo!();
251    /// # let rx_vel: Velocity = todo!();
252    /// # let rx_potential: f64 = todo!();
253    ///
254    /// let transmitter = ObserverState::new(
255    ///     tx_time,
256    ///     tx_pos,
257    ///     tx_vel,
258    ///     tx_potential,
259    /// );
260    ///
261    /// let receiver_approx = ObserverState::new(
262    ///     rx_approx_time,
263    ///     rx_pos,
264    ///     rx_vel,
265    ///     rx_potential,
266    /// );
267    ///
268    /// // Use SOLAR for Sun-centered calculations
269    /// let correction: TSpan = transmitter
270    ///     .one_way_relativistic_delay_to(receiver_approx, LightContext::SOLAR);
271    ///
272    /// // The result should be added to the Newtonian geometric delay `r_sep / C`
273    /// ```
274    ///
275    /// For a custom body (e.g. Jupiter):
276    ///
277    /// ```ignore
278    /// let jupiter_context = LightContext::from_grav_param(jupiter_gm);  // GM in m³/s²
279    /// let correction = tx.one_way_relativistic_delay_to(rx, jupiter_context);
280    /// ```
281    ///
282    /// # Multi-body and exotic environments
283    ///
284    /// This function models the Shapiro delay from only a **single central mass**
285    /// via the supplied `LightContext`. For signals that pass near multiple massive
286    /// bodies (e.g. two stars, a star and a planet, or a binary black-hole system)
287    /// or in highly dynamic/strong-field regimes, the single-body approximation may
288    /// not be sufficient.
289    ///
290    /// In those cases consider using [`one_way_relativistic_delay_integrated`] instead,
291    /// which lets you supply your own full spacetime model along the entire path.
292    /// Alternatively, you can compute individual Shapiro contributions from each body
293    /// (using the helper `shapiro_one_way_for_body` if you add it) and manually combine
294    /// them with the result of this function.
295    pub const fn one_way_relativistic_delay_to(
296        &self,
297        rx: ObserverState,
298        context: LightContext,
299    ) -> TSpan {
300        let span = rx.time.to_tai_since(self.time);
301
302        let tx_drift = ClockDrift::from_velocity_potential_and_scale(
303            self.velocity.speed(),
304            self.grav_potential_m2_s2,
305            self.characteristic_length_scale,
306        );
307        let rx_drift = ClockDrift::from_velocity_potential_and_scale(
308            rx.velocity.speed(),
309            rx.grav_potential_m2_s2,
310            rx.characteristic_length_scale,
311        );
312
313        let drift_correction = rx_drift
314            .time_diff_after(&span)
315            .sub(tx_drift.time_diff_after(&span));
316
317        let r_tx = self.position.norm();
318        let r_rx = rx.position.norm();
319        let r_sep = self.position.distance_to(rx.position);
320        let shapiro = Self::shapiro_one_way_delay(context, r_tx, r_rx, r_sep);
321
322        drift_correction.add(shapiro)
323    }
324
325    /// Iteratively solves for the true receive time and the corresponding relativistic
326    /// correction for a one-way signal.
327    ///
328    /// Because the exact arrival time depends on the relativistic correction itself,
329    /// an iterative approach is required. The function typically converges in 3–5
330    /// iterations to sub-nanosecond accuracy, even for outer-solar-system distances.
331    ///
332    /// # Parameters
333    /// - `self` – the transmitter’s relativistic state (fixed)
334    /// - `rx_provider` – a closure that, given a guessed receive [`Dt`], returns
335    ///   the full [`ObserverState`] of the receiver at that time. You usually create
336    ///   this closure by calling your ephemeris/orbit propagator.
337    /// - `context` – gravitational context (`LightContext::SOLAR`, `LightContext::FLAT`,
338    ///   or a custom value). See [`one_way_relativistic_delay_to`] for details.
339    /// - `tolerance` – maximum allowed change in receive time between iterations
340    ///   (recommended `TSpan::from_ns(1)` or tighter)
341    /// - `max_iter` – safety limit on the number of iterations (recommended 8–12)
342    ///
343    /// # Returns
344    /// A tuple `(correction, final_rx_time)` where:
345    /// - `correction` is the relativistic delay (same as returned by [`one_way_relativistic_delay_to`])
346    /// - `final_rx_time` is the converged receive [`Dt`]
347    ///
348    /// # Examples
349    ///
350    /// ```no_run
351    /// use deep_time::{ObserverState, Dt, TSpan, LightContext, Position, Velocity};
352    ///
353    /// # // Assume these exist in your code (e.g. from an ephemeris library)
354    /// # let tx_time: Dt = todo!();
355    /// # let tx_pos: Position = todo!();
356    /// # let tx_vel: Velocity = todo!();
357    /// # let tx_potential: f64 = todo!();
358    /// # fn get_receiver_state_at(t: Dt) -> (Position, Velocity, f64) { todo!() }
359    ///
360    /// let transmitter = ObserverState::new(
361    ///     tx_time,
362    ///     tx_pos,
363    ///     tx_vel,
364    ///     tx_potential,
365    /// );
366    ///
367    /// let (correction, final_rx_time) = transmitter.iterative_one_way_relativistic_delay_to(
368    ///     |guessed_rx_time| {
369    ///         // Call your ephemeris / orbit propagator here
370    ///         let (pos, vel, potential) = get_receiver_state_at(guessed_rx_time);
371    ///         ObserverState::new(guessed_rx_time, pos, vel, potential)
372    ///     },
373    ///     LightContext::SOLAR,
374    ///     TSpan::from_ns(1),   // 1 nanosecond tolerance (recommended)
375    ///     12,                  // safety limit (recommended)
376    /// );
377    ///
378    /// // `correction` is the total relativistic delay (clock drift + Shapiro)
379    /// // to add to the Newtonian geometric light time.
380    /// // `final_rx_time` is the accurately converged signal arrival time.
381    /// ```
382    ///
383    /// Using a custom central body (e.g. near Jupiter) and a tighter tolerance:
384    ///
385    /// ```ignore
386    /// let jupiter_gm = 1.26686534e17_f64; // m³/s²
387    /// let context = LightContext::from_grav_param(jupiter_gm);
388    ///
389    /// let (correction, rx_time) = tx.iterative_one_way_relativistic_delay_to(
390    ///     rx_provider, context, TSpan::from_ns(0.1), 10
391    /// );
392    /// ```
393    pub fn iterative_one_way_relativistic_delay_to<F>(
394        &self,
395        mut rx_provider: F,
396        context: LightContext,
397        tolerance: TSpan,
398        max_iter: usize,
399    ) -> (TSpan, Dt)
400    where
401        F: FnMut(Dt) -> ObserverState,
402    {
403        let mut rx = rx_provider(self.time);
404        let mut rel_correction = TSpan::ZERO;
405
406        for _ in 0..max_iter {
407            rel_correction = self.one_way_relativistic_delay_to(rx, context);
408
409            let r_sep = self.position.distance_to(rx.position);
410            let geometric = TSpan::from_sec_f(r_sep / C);
411            let full_delay = geometric.add(rel_correction);
412
413            let new_rx_time = self.time.add(full_delay);
414            let change = new_rx_time.to_tai_since(rx.time);
415
416            rx = rx_provider(new_rx_time);
417            rx.time = new_rx_time;
418
419            if change < tolerance {
420                return (rel_correction, new_rx_time);
421            }
422        }
423        (rel_correction, rx.time)
424    }
425
426    /// Computes the relativistic correction using numerical quadrature (Simpson’s rule)
427    /// of the relative clock-rate offset along the entire straight-line light path.
428    ///
429    /// This is the most accurate method when the clock-rate offset varies continuously
430    /// along the path (long baselines, interstellar distances, or strong-field regions).
431    ///
432    /// # Parameters
433    /// - `self` – the transmitter’s relativistic state
434    /// - `rx` – the receiver’s relativistic state
435    /// - `context` – gravitational context for the Shapiro delay (see [`one_way_relativistic_delay_to`])
436    /// - `samples` – a slice of [`LocalSpacetime`] snapshots uniformly spaced along the
437    ///   path (λ ∈ [0, 1]). You build this slice by evaluating your spacetime model
438    ///   at several points between transmitter and receiver. Even 9–21 samples give
439    ///   excellent accuracy.
440    ///
441    /// # Returns
442    /// A [`TSpan`] containing the integrated clock-drift correction plus the Shapiro
443    /// delay from the supplied `context`.
444    ///
445    /// # Example of building `samples`
446    /// ```ignore
447    /// let samples: Vec<LocalSpacetime> = (0..=15)
448    ///     .map(|i| {
449    ///         let lambda = i as f64 / 15.0;
450    ///         let point = tx.position.lerp(rx.position, lambda);
451    ///         let phi_over_c2 = compute_total_potential_at(point); // your model
452    ///         LocalSpacetime::from_potential_velocity_and_scale(
453    ///             phi_over_c2,
454    ///             Velocity::ZERO,
455    ///             0.0, // weak-field
456    ///         )
457    ///     })
458    ///     .collect();
459    /// ```
460    ///
461    /// # Examples
462    ///
463    /// Full usage example for high-accuracy one-way light-time correction
464    /// (e.g. interstellar distances or strong gravitational fields):
465    ///
466    /// ```no_run
467    /// use deep_time::{
468    ///     ObserverState, LocalSpacetime, Position, Velocity, Dt,
469    ///     TSpan, LightContext,
470    /// };
471    ///
472    /// # let tx_time: Dt = todo!();
473    /// # let tx_pos: Position = todo!();
474    /// # let tx_vel: Velocity = todo!();
475    /// # let tx_potential: f64 = todo!();
476    /// # let rx_time: Dt = todo!();
477    /// # let rx_pos: Position = todo!();
478    /// # let rx_vel: Velocity = todo!();
479    /// # let rx_potential: f64 = todo!();
480    /// # fn compute_total_potential_at(pos: Position) -> f64 { todo!() }
481    ///
482    /// let transmitter = ObserverState::new(
483    ///     tx_time, tx_pos, tx_vel, tx_potential,
484    /// );
485    ///
486    /// let receiver = ObserverState::new(
487    ///     rx_time, rx_pos, rx_vel, rx_potential,
488    /// );
489    ///
490    /// // Build uniformly spaced samples along the straight-line path.
491    /// // 9–21 points are usually sufficient; use more for interstellar/strong-field cases.
492    /// let samples: Vec<LocalSpacetime> = (0..=21)
493    ///     .map(|i| {
494    ///         let lambda = i as f64 / 21.0;
495    ///         let point = transmitter.position.lerp(receiver.position, lambda);
496    ///         let phi_over_c2 = compute_total_potential_at(point);
497    ///
498    ///         LocalSpacetime::from_potential_velocity_and_scale(
499    ///             phi_over_c2,
500    ///             Velocity::ZERO,   // light itself carries no rest-mass velocity
501    ///             0.0,              // weak-field approximation
502    ///         )
503    ///     })
504    ///     .collect();
505    ///
506    /// let total_correction: TSpan = transmitter.one_way_relativistic_delay_integrated(
507    ///     receiver,
508    ///     LightContext::SOLAR,
509    ///     &samples,
510    /// );
511    ///
512    /// // `total_correction` is the integrated clock-drift + Shapiro delay
513    /// // to be added to the Newtonian geometric light time.
514    /// ```
515    ///
516    /// Using a custom central body (e.g. near another star or planet):
517    ///
518    /// ```ignore
519    /// let custom_context = LightContext::from_grav_param(star_gm); // GM in m³/s²
520    /// let correction = tx.one_way_relativistic_delay_integrated(
521    ///     rx, custom_context, &samples
522    /// );
523    /// ```
524    ///
525    /// # Multi-body and exotic environments
526    ///
527    /// This function is the recommended choice when a signal passes near multiple
528    /// massive bodies (two or more stars, a star and a planet, binary black holes,
529    /// etc.) or when operating in strong gravitational fields or highly dynamic
530    /// spacetimes. Because you supply your own [`LocalSpacetime`] snapshots, each
531    /// sample can incorporate the combined gravitational potential, velocity, and
532    /// curvature from every relevant body in your model.
533    ///
534    /// In contrast, the faster [`one_way_relativistic_delay_to`] function only
535    /// supports a single central mass via `LightContext`. For complex geometries
536    /// or high-fidelity simulations, this integrated method provides greater
537    /// accuracy and flexibility.
538    pub const fn one_way_relativistic_delay_integrated(
539        &self,
540        rx: ObserverState,
541        context: LightContext,
542        samples: &[LocalSpacetime],
543    ) -> TSpan {
544        if samples.is_empty() {
545            return self.one_way_relativistic_delay_to(rx, context);
546        }
547
548        let dt_sec = rx.time.to_tai_since(self.time).to_sec_f();
549
550        let num_samples = samples.len();
551        let n = f!(num_samples);
552        let h = f!(1.0) / n;
553        let mut s = f!(0.0);
554
555        let mut i = 0;
556        while i < num_samples {
557            let local = samples[i];
558            let drift = ClockDrift::from_local_spacetime(&local);
559            let rate_offset = drift.rate().to_sec_f();
560
561            let coeff = if i == 0 || i == num_samples - 1 {
562                f!(1.0)
563            } else if i % 2 == 0 {
564                f!(2.0)
565            } else {
566                f!(4.0)
567            };
568            s += coeff * rate_offset;
569
570            i += 1;
571        }
572
573        let integrated_drift_sec = (h / f!(3.0)) * s * dt_sec;
574
575        let r_tx = self.position.norm();
576        let r_rx = rx.position.norm();
577        let r_sep = self.position.distance_to(rx.position);
578        let shapiro = Self::shapiro_one_way_delay(context, r_tx, r_rx, r_sep);
579
580        TSpan::from_sec_f(integrated_drift_sec).add(shapiro)
581    }
582
583    /// Computes the relativistic correction for a two-way round-trip ranging measurement
584    /// (transmit → receive → immediate transponder reply).
585    ///
586    /// Deep-space networks measure distance by timing a round-trip signal. This function
587    /// returns the tiny relativistic adjustment that must be **subtracted** from the raw
588    /// measured round-trip time to recover the true geometric distance.
589    ///
590    /// # Parameters
591    /// - `self` – the transmitter’s relativistic state at send time
592    /// - `round_trip_measured` – the raw measured round-trip duration (in seconds)
593    /// - `rx` – the receiver’s relativistic state (its `time` field is ignored)
594    /// - `context` – gravitational context for the Shapiro delay (see [`one_way_relativistic_delay_to`])
595    ///
596    /// # Returns
597    /// A [`TSpan`] (in seconds) that must be **subtracted** from the measured round-trip time.
598    ///
599    /// # Examples
600    ///
601    /// Typical usage for deep-space two-way ranging (e.g. Earth to spacecraft or planet via DSN):
602    ///
603    /// ```no_run
604    /// use deep_time::{
605    ///     ObserverState, Position, Velocity, Dt, TSpan, LightContext,
606    /// };
607    ///
608    /// # let tx_time: Dt = todo!();
609    /// # let tx_pos: Position = todo!();
610    /// # let tx_vel: Velocity = todo!();
611    /// # let tx_potential: f64 = todo!();
612    /// # let rx_pos: Position = todo!();
613    /// # let rx_vel: Velocity = todo!();
614    /// # let rx_potential: f64 = todo!();
615    /// # let measured_round_trip: TSpan = todo!(); // from your ranging hardware / DSN
616    ///
617    /// let transmitter = ObserverState::new(
618    ///     tx_time,
619    ///     tx_pos,
620    ///     tx_vel,
621    ///     tx_potential,
622    /// );
623    ///
624    /// // Receiver state at approximate arrival time (its `.time` field is ignored)
625    /// let receiver_approx = ObserverState::new(
626    ///     Dt::default(), // dummy time - will be ignored
627    ///     rx_pos,
628    ///     rx_vel,
629    ///     rx_potential,
630    /// );
631    ///
632    /// let relativistic_correction = transmitter.round_trip_relativistic_correction(
633    ///     measured_round_trip,
634    ///     receiver_approx,
635    ///     LightContext::SOLAR,
636    /// );
637    ///
638    /// // Correct the measured round-trip time:
639    /// let corrected_round_trip = measured_round_trip.sub(relativistic_correction);
640    ///
641    /// // Then the true geometric one-way light time and distance can be computed from
642    /// // `corrected_round_trip / 2`.
643    /// ```
644    ///
645    /// Using a custom gravitational context (e.g. ranging to a probe near Jupiter):
646    ///
647    /// ```ignore
648    /// let jupiter_context = LightContext::from_grav_param(jupiter_gm); // GM in m³/s²
649    /// let correction = tx.round_trip_relativistic_correction(
650    ///     measured, rx_approx, jupiter_context
651    /// );
652    /// ```
653    pub const fn round_trip_relativistic_correction(
654        &self,
655        round_trip_measured: TSpan,
656        rx: ObserverState,
657        context: LightContext,
658    ) -> TSpan {
659        let one_way_approx = round_trip_measured.div_by_2();
660        let rx_approx = ObserverState {
661            time: self.time.add(one_way_approx),
662            ..rx
663        };
664
665        let one_way_delay = self.one_way_relativistic_delay_to(rx_approx, context);
666        one_way_delay.add(one_way_delay)
667    }
668
669    /// First-order one-way Shapiro delay (gravitational light-time delay) caused by a
670    /// central point mass.
671    ///
672    /// This is an internal helper used by the public delay functions. It implements the
673    /// standard logarithmic formula used in solar-system navigation and pulsar timing.
674    const fn shapiro_one_way_delay(
675        context: LightContext,
676        r_tx: Real,
677        r_rx: Real,
678        r_sep: Real,
679    ) -> TSpan {
680        if context.two_grav_param_over_c3 == f!(0.0)
681            || r_tx <= f!(0.0)
682            || r_rx <= f!(0.0)
683            || r_sep <= f!(0.0)
684        {
685            return TSpan::ZERO;
686        }
687
688        let arg = (r_tx + r_rx + r_sep) / (r_tx + r_rx - r_sep).max(f!(1.0));
689        let delay_sec = context.two_grav_param_over_c3 * log(arg);
690
691        TSpan::from_sec_f(delay_sec)
692    }
693}