nyx_space/tools/lambert/
izzo.rs

1/*
2    Nyx, blazing fast astrodynamics
3    Copyright (C) 2018-onwards Christopher Rabotin <christopher.rabotin@gmail.com>
4
5    This program is free software: you can redistribute it and/or modify
6    it under the terms of the GNU Affero General Public License as published
7    by the Free Software Foundation, either version 3 of the License, or
8    (at your option) any later version.
9
10    This program is distributed in the hope that it will be useful,
11    but WITHOUT ANY WARRANTY; without even the implied warranty of
12    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13    GNU Affero General Public License for more details.
14
15    You should have received a copy of the GNU Affero General Public License
16    along with this program.  If not, see <https://www.gnu.org/licenses/>.
17*/
18
19use core::f64::consts::PI;
20
21use crate::errors::LambertError;
22
23use super::{LambertInput, LambertSolution, TransferKind, LAMBERT_EPSILON, MAX_ITERATIONS};
24
25/// Solve the Lambert boundary problem using Izzo's method.
26///
27/// This is an implementation of D. Izzo's method for solving Lambert's problem, as described in "Revisiting Lambert’s problem".
28/// The code was adapted from the Python version available in jorgepiloto's [lamberthub](https://github.com/jorgepiloto/lamberthub/blob/main/src/lamberthub/universal_solvers/izzo.py),
29/// which is released under the GPL v3 license, compatible with Nyx's AGPL v3 license.
30///
31/// Given the initial and final radii, a time of flight, and a gravitational parameters, it returns the needed initial and final velocities.
32///
33/// # Arguments
34///
35/// * `r_init` - The initial radius vector.
36/// * `r_final` - The final radius vector.
37/// * `tof_s` - The time of flight in seconds.
38/// * `mu_km3_s2` - The gravitational parameter in km^3/s^2.
39/// * `kind` - The kind of transfer (auto, short way, long way, or number of revolutions).
40///
41/// # Returns
42///
43/// `Result<LambertSolution, NyxError>` - The solution to the Lambert problem or an error if the problem could not be solved.
44pub fn izzo(input: LambertInput, kind: TransferKind) -> Result<LambertSolution, LambertError> {
45    let r_init = input.initial_state.radius_km;
46    let r_final = input.final_state.radius_km;
47    let tof_s = (input.final_state.epoch - input.initial_state.epoch).to_seconds();
48    let mu_km3_s2 = input.mu_km2_s3();
49
50    let ri_norm = r_init.norm();
51    let rf_norm = r_final.norm();
52
53    let chord = r_final - r_init;
54    let c_norm = chord.norm();
55
56    // Semi parameter
57    let s = (ri_norm + rf_norm + c_norm) * 0.5;
58
59    // Versors
60    let i_r1 = r_init / ri_norm;
61    let i_r2 = r_final / rf_norm;
62
63    let mut i_h = i_r1.cross(&i_r2);
64    i_h /= i_h.norm(); // Ensure normalization
65
66    // Geometry of the problem
67    let lambda = 1.0 - c_norm / s;
68    let mut m_lambda = lambda.sqrt();
69
70    let retrograde = matches!(kind, TransferKind::LongWay);
71
72    let (mut i_t1, mut i_t2) = if i_h.z < 0.0 || retrograde {
73        // Transfer angle greater than 180 degrees
74        m_lambda = -m_lambda;
75        (i_r1.cross(&i_h), i_r2.cross(&i_h))
76    } else {
77        (i_h.cross(&i_r1), i_h.cross(&i_r2))
78    };
79    // Ensure unit vector
80    i_t1 /= i_t1.norm();
81    i_t2 /= i_t2.norm();
82
83    // Always assume prograde for now
84    let t = (2.0 * mu_km3_s2 / s.powi(3)).sqrt() * tof_s;
85
86    // Find then filter solutions.
87    let (x, y) = find_xy(
88        m_lambda,
89        t,
90        match kind {
91            TransferKind::NRevs(revs) => revs.into(),
92            _ => 0,
93        },
94        MAX_ITERATIONS,
95        LAMBERT_EPSILON,
96        LAMBERT_EPSILON,
97        true,
98    )?;
99    // Reconstruct
100    let gamma = (mu_km3_s2 * s / 2.0).sqrt();
101    let rho = (ri_norm - rf_norm) / c_norm;
102    let sigma = (1.0 - rho.powi(2)).sqrt();
103
104    // Compute the radial and tangential components at initial and final
105    // position vectors
106    let (v_r1, v_r2, v_t1, v_t2) = reconstruct(x, y, ri_norm, rf_norm, m_lambda, gamma, rho, sigma);
107
108    // Solve for the initial and final velocity
109    let v_init = v_r1 * (r_init / ri_norm) + v_t1 * i_t1;
110    let v_final = v_r2 * (r_final / rf_norm) + v_t2 * i_t2;
111
112    Ok(LambertSolution {
113        v_init_km_s: v_init,
114        v_final_km_s: v_final,
115        phi_rad: 0.0,
116        input,
117    })
118}
119
120/// Reconstructs solution velocity vectors.
121/// Returns a tuple of (V_r1, V_r2, V_t1, V_t2).
122pub fn reconstruct(
123    x: f64,
124    y: f64,
125    r1: f64,
126    r2: f64,
127    ll: f64,
128    gamma: f64,
129    rho: f64,
130    sigma: f64,
131) -> (f64, f64, f64, f64) {
132    let v_r1 = gamma * ((ll * y - x) - rho * (ll * y + x)) / r1;
133    let v_r2 = -gamma * ((ll * y - x) + rho * (ll * y + x)) / r2;
134    let v_t1 = gamma * sigma * (y + ll * x) / r1;
135    let v_t2 = gamma * sigma * (y + ll * x) / r2;
136
137    (v_r1, v_r2, v_t1, v_t2)
138}
139
140/// Computes x and y for a given number of revolutions.
141///
142/// This function orchestrates the solving process, from refining the number
143/// of possible revolutions to running the Householder root-finding method.
144/// It returns a Result containing a tuple of (x, y) on success.
145pub fn find_xy(
146    ll: f64,
147    t: f64,
148    m: u32,
149    maxiter: usize,
150    atol: f64,
151    rtol: f64,
152    low_path: bool,
153) -> Result<(f64, f64), LambertError> {
154    // For abs(ll) == 1 the derivative is not continuous.
155    // An assertion is used for documenting an unrecoverable precondition.
156    assert!(ll.abs() < 1.0, "|ll| must be less than 1");
157
158    let mut m_max = (t / PI).floor() as u32;
159    let t_00 = ll.acos() + ll * (1.0 - ll.powi(2)).sqrt(); // T_xM
160
161    // Refine maximum number of revolutions if necessary
162    if t < t_00 + (m_max as f64) * PI && m_max > 0 {
163        let (_, t_min) = compute_t_min(ll, m_max, maxiter, atol, rtol)?;
164        if t < t_min {
165            m_max -= 1;
166        }
167    }
168
169    // Check if a feasible solution exists for the given number of revolutions
170    if m > m_max {
171        return Err(LambertError::MultiRevNotFeasible { m, m_max });
172    }
173
174    // Initial guess
175    let x_0 = initial_guess(t, ll, m, low_path);
176
177    // Start Householder iterations from x_0 to find x.
178    // The '?' will propagate any error from the solver.
179    let x = householder(x_0, t, ll, m, atol, rtol, maxiter)?;
180    let y = compute_y(x, ll);
181
182    Ok((x, y))
183}
184
185fn compute_y(x: f64, ll: f64) -> f64 {
186    (1.0 - ll.powi(2) * (1.0 - x.powi(2))).sqrt()
187}
188
189/// Computes psi.
190/// "The auxiliary angle psi is computed using Eq.(17) by the appropriate
191/// inverse function"
192fn compute_psi(x: f64, y: f64, ll: f64) -> f64 {
193    if x > 1.0 {
194        // Hyperbolic motion
195        ((y - x * ll) * (x.powi(2) - 1.0).sqrt()).asinh()
196    } else if x < 1.0 {
197        // Catches the original `-1 <= x < 1`
198        // Elliptic motion
199        (x * y + ll * (1.0 - x.powi(2))).acos()
200    } else {
201        // Parabolic motion (x = 1.0)
202        0.0
203    }
204}
205
206/// Time of flight equation.
207fn tof_equation(x: f64, t0: f64, ll: f64, m: u32) -> f64 {
208    let y = compute_y(x, ll);
209    tof_equation_y(x, y, t0, ll, m)
210}
211
212/// Time of flight equation with externally computed y.
213fn tof_equation_y(x: f64, y: f64, t0: f64, ll: f64, m: u32) -> f64 {
214    let t_ = if m == 0 && x.powi(2) > 0.6 && x.powi(2) < 1.4 {
215        let eta = y - ll * x;
216        let s_1 = (1.0 - ll - x * eta) * 0.5;
217        let q = 4.0 / 3.0 * hyp2f1b(s_1);
218        (eta.powi(3) * q + 4.0 * ll * eta) * 0.5
219    } else {
220        let psi = compute_psi(x, y, ll);
221        let m_float = m as f64;
222        let den = 1.0 - x.powi(2);
223
224        // In Rust, division by zero on floats results in `inf` or `NaN`,
225        // which matches the behavior of `np.divide` in this context.
226        ((psi + m_float * PI) / den.abs().sqrt() - x + ll * y) / den
227    };
228
229    t_ - t0
230}
231
232/// Derivative of the time of flight equation.
233fn tof_equation_p(x: f64, y: f64, t: f64, ll: f64) -> f64 {
234    (3.0 * t * x - 2.0 + 2.0 * ll.powi(3) * x / y) / (1.0 - x.powi(2))
235}
236
237/// Second derivative of the time of flight equation.
238fn tof_equation_p2(x: f64, y: f64, t: f64, dt: f64, ll: f64) -> f64 {
239    (3.0 * t + 5.0 * x * dt + 2.0 * (1.0 - ll.powi(2)) * ll.powi(3) / y.powi(3)) / (1.0 - x.powi(2))
240}
241
242/// Third derivative of the time of flight equation.
243fn tof_equation_p3(x: f64, y: f64, _t: f64, dt: f64, ddt: f64, ll: f64) -> f64 {
244    (7.0 * x * ddt + 8.0 * dt - 6.0 * (1.0 - ll.powi(2)) * ll.powi(5) * x / y.powi(5))
245        / (1.0 - x.powi(2))
246}
247
248/// Computes minimum T.
249/// Returns a tuple of `(x_T_min, T_min)`.
250fn compute_t_min(
251    ll: f64,
252    m: u32,
253    maxiter: usize,
254    atol: f64,
255    rtol: f64,
256) -> Result<(f64, f64), LambertError> {
257    // Use an epsilon for floating point comparison
258    if (ll - 1.0).abs() < 1e-9 {
259        let x_t_min = 0.0;
260        let t_min = tof_equation(x_t_min, 0.0, ll, m);
261        Ok((x_t_min, t_min))
262    } else if m == 0 {
263        let x_t_min = f64::INFINITY;
264        let t_min = 0.0;
265        Ok((x_t_min, t_min))
266    } else {
267        // Set x_i > 0 to avoid problems at ll = -1
268        let x_i = 0.1;
269        let t_i = tof_equation(x_i, 0.0, ll, m);
270        let x_t_min = halley(x_i, t_i, ll, atol, rtol, maxiter)?;
271        let t_min = tof_equation(x_t_min, 0.0, ll, m);
272        Ok((x_t_min, t_min))
273    }
274}
275
276/// Calculates the initial guess for the Lambert solver.
277///
278/// This function is a Rust translation of the Python `_initial_guess` method,
279/// using f64 for floating-point calculations.
280///
281/// # Arguments
282///
283/// * `t_of_f` - The time of flight.
284/// * `ll` - The lambda parameter, related to the geometry of the transfer.
285/// * `m` - The number of complete revolutions (0 for the short path).
286/// * `low_path` - A boolean indicating whether to select the low-path solution for multi-revolution cases.
287///
288/// # Returns
289///
290/// An `f64` value representing the initial guess `x_0`.
291///
292fn initial_guess(tof_s: f64, ll: f64, m: u32, low_path: bool) -> f64 {
293    if m == 0 {
294        // --- Single revolution case ---
295        let t_0 = ll.acos() + ll * (1.0 - ll.powi(2)).sqrt(); // Eq. 19 (for m=0)
296        let t_1 = 2.0 * (1.0 - ll.powi(3)) / 3.0; // Eq. 21
297
298        // The if/else block is an expression that returns a value directly.
299        if tof_s >= t_0 {
300            (t_0 / tof_s).powf(2.0 / 3.0) - 1.0
301        } else if tof_s < t_1 {
302            5.0 / 2.0 * t_1 / tof_s * (t_1 - tof_s) / (1.0 - ll.powi(5)) + 1.0
303        } else {
304            // Condition for T_1 <= T < T_0
305            // Uses the corrected initial guess formula.
306            (2.0f64.ln() * (tof_s / t_0).ln() / (t_1 / t_0).ln()).exp() - 1.0
307        }
308    } else {
309        // --- Multiple revolution case ---
310        let m_float = m as f64;
311
312        // The Python code calculates a common term twice for both x_0l and x_0r.
313        // We can pre-calculate these terms.
314        let term_l = ((m_float * PI + PI) / (8.0 * tof_s)).powf(2.0 / 3.0);
315        let x_0l = (term_l - 1.0) / (term_l + 1.0);
316
317        let term_r = ((8.0 * tof_s) / (m_float * PI)).powf(2.0 / 3.0);
318        let x_0r = (term_r - 1.0) / (term_r + 1.0);
319
320        // Filter out the solution using .max() and .min() methods
321        if low_path {
322            x_0l.max(x_0r)
323        } else {
324            x_0l.min(x_0r)
325        }
326    }
327}
328
329/// Hypergeometric function 2F1(3, 1, 5/2, x), see [Battin].
330pub fn hyp2f1b(x: f64) -> f64 {
331    if x >= 1.0 {
332        return f64::INFINITY;
333    }
334
335    let mut res = 1.0;
336    let mut term = 1.0;
337    let mut ii = 0.0_f64; // Use float for loop counter to avoid casting
338    loop {
339        term *= (3.0 + ii) * (1.0 + ii) / (2.5 + ii) * x / (ii + 1.0);
340        let res_old = res;
341        res += term;
342
343        // Convergence is reached when the result no longer changes.
344        if res == res_old {
345            return res;
346        }
347        ii += 1.0;
348    }
349}
350
351// --- Solvers and High-Level Functions ---
352
353/// Finds a minimum of time of flight equation using Halley's method.
354/// Returns a Result, which is Ok(value) on success or Err(message) on failure.
355pub fn halley(
356    mut p0: f64,
357    t0: f64,
358    ll: f64,
359    atol: f64,
360    rtol: f64,
361    maxiter: usize,
362) -> Result<f64, LambertError> {
363    for _ in 1..=maxiter {
364        let y = compute_y(p0, ll);
365        let fder = tof_equation_p(p0, y, t0, ll);
366        let fder2 = tof_equation_p2(p0, y, t0, fder, ll);
367
368        // Avoid division by zero
369        if fder2.abs() < 1e-14 {
370            return Err(LambertError::TargetsTooClose);
371        }
372
373        let fder3 = tof_equation_p3(p0, y, t0, fder, fder2, ll);
374
375        // Halley step (cubic)
376        let p = p0 - 2.0 * fder * fder2 / (2.0 * fder2.powi(2) - fder * fder3);
377
378        if (p - p0).abs() < rtol * p0.abs() + atol {
379            return Ok(p);
380        }
381        p0 = p;
382    }
383
384    Err(LambertError::SolverMaxIter { maxiter })
385}
386
387/// Finds a zero of time of flight equation using Householder's method.
388/// Returns a Result, which is Ok(value) on success or Err(message) on failure.
389pub fn householder(
390    mut p0: f64,
391    t0: f64,
392    ll: f64,
393    m: u32,
394    atol: f64,
395    rtol: f64,
396    maxiter: usize,
397) -> Result<f64, LambertError> {
398    for _ in 1..=maxiter {
399        let y = compute_y(p0, ll);
400        let fval = tof_equation_y(p0, y, t0, ll, m);
401        let t = fval + t0;
402        let fder = tof_equation_p(p0, y, t, ll);
403        let fder2 = tof_equation_p2(p0, y, t, fder, ll);
404        let fder3 = tof_equation_p3(p0, y, t, fder, fder2, ll);
405
406        // Householder step (quartic)
407        let num = fder.powi(2) - fval * fder2 / 2.0;
408        let den = fder * (fder.powi(2) - fval * fder2) + fder3 * fval.powi(2) / 6.0;
409
410        if den.abs() < 1e-14 {
411            return Err(LambertError::TargetsTooClose);
412        }
413
414        let p = p0 - fval * (num / den);
415
416        if (p - p0).abs() < rtol * p0.abs() + atol {
417            return Ok(p);
418        }
419        p0 = p;
420    }
421    Err(LambertError::SolverMaxIter { maxiter })
422}
423
424#[cfg(test)]
425mod ut_lambert_izzo {
426    use super::{izzo, LambertInput, TransferKind};
427    use crate::linalg::Vector3;
428    use anise::prelude::{Frame, Orbit};
429    use hifitime::{Epoch, Unit};
430
431    #[test]
432    fn test_lambert_izzo_shortway() {
433        // Test case from Vallado, Example 7-1, p. 462
434
435        let frame = Frame {
436            ephemeris_id: 301,
437            orientation_id: 0,
438            mu_km3_s2: Some(3.98600433e5),
439            shape: None,
440        };
441        let initial_state = Orbit {
442            radius_km: Vector3::new(15945.34, 0.0, 0.0),
443            velocity_km_s: Vector3::zeros(),
444            epoch: Epoch::from_gregorian_utc_at_midnight(2025, 1, 1),
445            frame,
446        };
447        let final_state = Orbit {
448            radius_km: Vector3::new(12214.83899, 10249.46731, 0.0),
449            velocity_km_s: Vector3::zeros(),
450            epoch: Epoch::from_gregorian_utc_at_midnight(2025, 1, 1) + Unit::Minute * 76.0,
451            frame,
452        };
453
454        let input = LambertInput::from_planetary_states(initial_state, final_state).unwrap();
455
456        let exp_vi = Vector3::new(2.058913, 2.915965, 0.0);
457        let exp_vf = Vector3::new(-3.451565, 0.910315, 0.0);
458
459        let sol = izzo(input, TransferKind::ShortWay).unwrap();
460
461        println!("{sol:?}\t{exp_vi}\t{exp_vf}");
462
463        assert!((sol.v_init_km_s - exp_vi).norm() < 1e-5);
464        assert!((sol.v_final_km_s - exp_vf).norm() < 1e-5);
465    }
466
467    #[test]
468    fn test_lambert_izzo_longway() {
469        // Test case from Vallado, Example 7-1, p. 462
470        let frame = Frame {
471            ephemeris_id: 301,
472            orientation_id: 0,
473            mu_km3_s2: Some(3.98600433e5),
474            shape: None,
475        };
476        let initial_state = Orbit {
477            radius_km: Vector3::new(15945.34, 0.0, 0.0),
478            velocity_km_s: Vector3::zeros(),
479            epoch: Epoch::from_gregorian_utc_at_midnight(2025, 1, 1),
480            frame,
481        };
482        let final_state = Orbit {
483            radius_km: Vector3::new(12214.83899, 10249.46731, 0.0),
484            velocity_km_s: Vector3::zeros(),
485            epoch: Epoch::from_gregorian_utc_at_midnight(2025, 1, 1) + Unit::Minute * 76.0,
486            frame,
487        };
488
489        let input = LambertInput::from_planetary_states(initial_state, final_state).unwrap();
490
491        let exp_vi = Vector3::new(-3.811158, -2.003854, 0.0);
492        let exp_vf = Vector3::new(4.207569, 0.914724, 0.0);
493
494        let sol = izzo(input, TransferKind::LongWay).unwrap();
495
496        assert!((sol.v_init_km_s - exp_vi).norm() < 1e-5);
497        assert!((sol.v_final_km_s - exp_vf).norm() < 1e-5);
498    }
499}