Skip to main content

gooding_lambert/
gooding.rs

1//! Gooding's (1990) Lambert algorithm: tlamb → xlamb → vlamb → lambert.
2
3use std::f64::consts::PI;
4
5use crate::{Direction, LambertError, LambertSolution};
6
7/// Multi-revolution period selection for [`lambert()`].
8///
9/// When `nrev >= 1`, two distinct solution families exist for the same
10/// geometry and time of flight: *long-period* (lower energy, more circular)
11/// and *short-period* (higher energy, more eccentric). This enum selects
12/// which family to return.
13///
14/// For `nrev == 0` (single-revolution), there is only one solution and
15/// this parameter is ignored.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum MultiRevPeriod {
18    /// Long-period (lower energy) multi-revolution solution.
19    ///
20    /// This is the solution on the more circular orbit that completes
21    /// the requested number of revolutions.
22    LongPeriod,
23    /// Short-period (higher energy) multi-revolution solution.
24    ///
25    /// This is the solution on the more eccentric orbit that completes
26    /// the requested number of revolutions.
27    ShortPeriod,
28}
29
30const TWOPI: f64 = 2.0 * PI;
31const SW: f64 = 0.4;
32const TOL: f64 = 1e-12;
33const C0: f64 = 1.7;
34const C1: f64 = 0.5;
35const C2: f64 = 0.03;
36const C3: f64 = 0.15;
37const C41: f64 = 1.0;
38const C42: f64 = 0.24;
39
40/// Euclidean magnitude of a 3-vector.
41fn mag3(v: &[f64; 3]) -> f64 {
42    (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
43}
44
45/// Dot product of two 3-vectors.
46fn dot3(a: &[f64; 3], b: &[f64; 3]) -> f64 {
47    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
48}
49
50/// Cross product of two 3-vectors.
51fn cross3(a: &[f64; 3], b: &[f64; 3]) -> [f64; 3] {
52    [
53        a[1] * b[2] - a[2] * b[1],
54        a[2] * b[0] - a[0] * b[2],
55        a[0] * b[1] - a[1] * b[0],
56    ]
57}
58
59/// Dimensionless time-of-flight T(x) and analytic derivatives up to order `n`.
60///
61/// Returns `(t, dt, d2t, d3t)`.
62/// - `n == -1`: velocity recovery mode — returns `(0, qzminx, qzplx, zplqx)`.
63/// - `n == 0`: T only (dt, d2t, d3t are 0).
64/// - `n == 1`: T and dT/dx.
65/// - `n == 2`: T, dT/dx, d²T/dx².
66/// - `n == 3`: T, dT/dx, d²T/dx², d³T/dx³.
67fn tlamb(
68    m: i32,
69    q: f64,
70    qsqfm1: f64,
71    x: f64,
72    n: i32,
73) -> Result<(f64, f64, f64, f64), LambertError> {
74    let m_f64 = m as f64;
75
76    let qsq = q * q;
77    let xsq = x * x;
78    let u = (1.0 - x) * (1.0 + x); // = 1 - x²
79
80    // Guard: the direct path divides by u. The series path (m=0, x≥0, n≠-1)
81    // handles u→0 correctly; all other cases bail to avoid division by near-zero.
82    // FORTRAN-origin: 1e-10 near-zero guard for u = 1-x² singularity
83    if u.abs() < 1e-10 && (n == -1 || m != 0 || x < 0.0) {
84        return Err(LambertError::ConvergenceFailed);
85    }
86
87    // Series path: only for small-x elliptic region near parabolic limit.
88    // Direct path used for: n=-1, m>0, x<0, or |u|>SW.
89    if n != -1 && m == 0 && x >= 0.0 && u.abs() <= SW {
90        // Power-series expansion (near parabolic limit)
91        let mut u0i = 1.0;
92        let mut u1i = 1.0;
93        let mut u2i = 1.0;
94        let mut u3i = 1.0;
95        let mut term = 4.0;
96        let mut tq = q * qsqfm1;
97        let mut tqsum = if q < 0.5 {
98            1.0 - q * qsq
99        } else {
100            (1.0 / (1.0 + q) + q) * qsqfm1
101        };
102        let mut ttmold = term / 3.0;
103        let mut t = ttmold * tqsum;
104        let mut dt = 0.0;
105        let mut d2t = 0.0;
106        let mut d3t = 0.0;
107
108        let mut i: i32 = 0;
109        loop {
110            i += 1;
111            let p = i as f64;
112            u0i *= u;
113            if n >= 1 && i > 1 {
114                u1i *= u;
115            }
116            if n >= 2 && i > 2 {
117                u2i *= u;
118            }
119            if n >= 3 && i > 3 {
120                u3i *= u;
121            }
122            term *= (p - 0.5) / p;
123            tq *= qsq;
124            tqsum += tq;
125            let told = t;
126            let tterm = term / (2.0 * p + 3.0);
127            let tqterm_base = tterm * tqsum;
128            t -= u0i * ((1.5 * p + 0.25) * tqterm_base / (p * p - 0.25) - ttmold * tq);
129            ttmold = tterm;
130            let tqterm = tqterm_base * p;
131            if n >= 1 {
132                dt += tqterm * u1i;
133            }
134            if n >= 2 {
135                d2t += tqterm * u2i * (p - 1.0);
136            }
137            if n >= 3 {
138                d3t += tqterm * u3i * (p - 1.0) * (p - 2.0);
139            }
140            // Loop runs at least n times, then until convergence
141            // FORTRAN-origin: 1e-15 convergence criterion for power-series accumulation
142            if i >= n && (t - told).abs() < 1e-15 {
143                break;
144            }
145            if i > 200 {
146                return Err(LambertError::ConvergenceFailed);
147            }
148        }
149
150        if n >= 3 {
151            d3t = 8.0 * x * (1.5 * d2t - xsq * d3t);
152        }
153        if n >= 2 {
154            d2t = 2.0 * (2.0 * xsq * d2t - dt);
155        }
156        if n >= 1 {
157            dt *= -2.0 * x;
158        }
159        t /= xsq;
160
161        return Ok((t, dt, d2t, d3t));
162    }
163
164    // Direct computation (trig for elliptic, log/series for hyperbolic)
165    let y = u.abs().sqrt();
166    let z = (qsqfm1 + qsq * xsq).sqrt();
167    let qx = q * x;
168
169    // Compute a, b (and aa, bb for n=-1 velocity mode)
170    let (a, b) = if qx <= 0.0 {
171        (z - qx, q * z - x)
172    } else {
173        let aa = z + qx;
174        let bb = q * z + x;
175        (qsqfm1 / aa, qsqfm1 * (qsq * u - xsq) / bb)
176    };
177
178    if n == -1 {
179        // Velocity recovery mode: return (unused, qzminx, qzplx, zplqx).
180        // aa_v and bb_v are the "alternate" pair (opposite of a,b).
181        let (aa_v, bb_v) = if qx < 0.0 {
182            (qsqfm1 / a, qsqfm1 * (qsq * u - xsq) / b)
183        } else {
184            // qx >= 0: aa_v = z+qx, bb_v = q*z+x
185            (z + qx, q * z + x)
186        };
187        // qzminx = b, qzplx = bb_v, zplqx = aa_v
188        return Ok((0.0, b, bb_v, aa_v));
189    }
190
191    // Compute T
192    let f = a * y;
193    let t = if x <= 1.0 {
194        // Elliptic / parabolic
195        let g = if qx * u >= 0.0 {
196            x * z + q * u
197        } else {
198            (xsq - qsq * u) / (x * z - q * u)
199        };
200        m_f64 * PI + f.atan2(g)
201    } else if f > SW {
202        // Hyperbolic, large argument
203        (f + x * z + q * u).ln()
204    } else {
205        // Hyperbolic, small argument — log series to avoid cancellation
206        let g = x * z + q * u;
207        let fg1 = f / (g + 1.0);
208        let mut term = 2.0 * fg1;
209        let fg1sq = fg1 * fg1;
210        let mut t_acc = term;
211        let mut twoi1 = 1.0;
212        let mut i: i32 = 0;
213        loop {
214            i += 1;
215            twoi1 += 2.0;
216            term *= fg1sq;
217            let told = t_acc;
218            t_acc += term / twoi1;
219            // FORTRAN-origin: 1e-15 convergence criterion for log-series accumulation
220            if (t_acc - told).abs() < 1e-15 {
221                break;
222            }
223            if i > 200 {
224                return Err(LambertError::ConvergenceFailed);
225            }
226        }
227        t_acc
228    };
229
230    let t = 2.0 * (t / y + b) / u;
231    let mut dt = 0.0;
232    let mut d2t = 0.0;
233    let mut d3t = 0.0;
234
235    // FORTRAN-origin: 1e-30 near-zero guard to skip derivative computation when z is degenerate
236    if n >= 1 && z.abs() > 1e-30 {
237        let qz = q / z;
238        let qz2 = qz * qz;
239        let qz3 = qz * qz2;
240        dt = (3.0 * x * t - 4.0 * (a + qx * qsqfm1) / z) / u;
241        if n >= 2 {
242            d2t = (3.0 * t + 5.0 * x * dt + 4.0 * qz3 * qsqfm1) / u;
243        }
244        if n >= 3 {
245            d3t = (8.0 * dt + 7.0 * x * d2t - 12.0 * qz3 * qz2 * x * qsqfm1) / u;
246        }
247    }
248
249    Ok((t, dt, d2t, d3t))
250}
251
252/// Find Gooding's orbit parameter `x` for dimensionless time `tin`.
253///
254/// `m` is the number of complete revolutions (0 for single arc).
255/// `nrev` selects long-period (> 0) or short-period (≤ 0) solution for m > 0.
256/// Returns `x` on success.
257fn xlamb(m: i32, q: f64, qsqfm1: f64, tin: f64, nrev: i32) -> Result<f64, LambertError> {
258    let thr2 = qsqfm1.atan2(2.0 * q) / PI;
259    let m_f64 = m as f64;
260
261    if m == 0 {
262        // Single-rev: bilinear initial guess
263        let (t0, _, _, _) = tlamb(m, q, qsqfm1, 0.0, 0)?;
264        let tdiff = tin - t0;
265
266        let mut x = if tdiff <= 0.0 {
267            // x is on the elliptic side near parabolic
268            t0 * tdiff / (-4.0 * tin)
269        } else {
270            let mut x = -tdiff / (tdiff + 4.0);
271            let w = x + C0 * (2.0 * (1.0 - thr2)).sqrt();
272            if w < 0.0 {
273                x -= (-w).sqrt().sqrt().sqrt().sqrt() * (x + (tdiff / (tdiff + 1.5 * t0)).sqrt());
274            }
275            let w = 4.0 / (4.0 + tdiff);
276            x * (1.0 + x * (C1 * w - C2 * x * w.sqrt()))
277        };
278
279        // Householder order-2 refinement (3 iterations, matching C)
280        for _ in 0..3 {
281            let (t, dt, d2t, _) = tlamb(m, q, qsqfm1, x, 2)?;
282            let t_err = tin - t;
283            // FORTRAN-origin: 1e-30 near-zero guard to avoid division by zero in Householder step
284            if dt.abs() > 1e-30 {
285                x += t_err * dt / (dt * dt + t_err * d2t / 2.0);
286            }
287        }
288
289        return Ok(x);
290    }
291
292    // Multi-rev: find T_min at x_min via Householder order-3 on d²T/dx² = 0
293    let mut xm = 1.0 / (1.5 * (m_f64 + 0.5) * PI);
294    if thr2 < 0.5 {
295        xm *= (2.0 * thr2).sqrt().sqrt().sqrt();
296    } else if thr2 > 0.5 {
297        xm *= 2.0 - (2.0 - 2.0 * thr2).sqrt().sqrt().sqrt();
298    }
299
300    let mut d2t_xm = 0.0;
301    for count in 0..16 {
302        let (_, dt, d2t, d3t) = tlamb(m, q, qsqfm1, xm, 3)?;
303        d2t_xm = d2t;
304        // FORTRAN-origin: 1e-30 near-zero guard for d²T convergence check
305        if d2t.abs() < 1e-30 {
306            break;
307        }
308        let xm_old = xm;
309        xm -= dt * d2t / (d2t * d2t - dt * d3t / 2.0);
310        if (xm_old / xm - 1.0).abs() <= TOL {
311            break;
312        }
313        if count == 15 {
314            return Err(LambertError::ConvergenceFailed);
315        }
316    }
317
318    let (tmin, _, _, _) = tlamb(m, q, qsqfm1, xm, 0)?;
319    let tdiffm = tin - tmin;
320
321    if tdiffm < 0.0 {
322        return Err(LambertError::NoSolution);
323    }
324    // FORTRAN-origin: 1e-14 near-zero tolerance for T ≈ T_min early exit
325    if tdiffm.abs() < 1e-14 {
326        return Ok(xm);
327    }
328
329    // Two solutions exist; select based on nrev sign
330    // FORTRAN-origin: 1e-30 near-zero guard for d²T minimum fallback
331    let d2t2 = if d2t_xm.abs() < 1e-30 {
332        3.0 * m_f64 * PI // = 6*m*PI/2
333    } else {
334        d2t_xm / 2.0
335    };
336
337    let mut x = if nrev > 0 {
338        // Long-period solution (x closer to xm from above)
339        let mut x = (tdiffm / (d2t2 + tdiffm / ((1.0 - xm).powi(2)))).sqrt();
340        let w = xm + x;
341        let w = w * 4.0 / (4.0 + tdiffm) + (1.0 - w).powi(2);
342        x = x
343            * (1.0
344                - (1.0 + m_f64 + C41 * (thr2 - 0.5)) / (1.0 + C3 * m_f64)
345                    * x
346                    * (C1 * w + C2 * x * w.sqrt()))
347            + xm;
348        x
349    } else {
350        // Short-period solution (x further from xm)
351        let (t0, _, _, _) = tlamb(m, q, qsqfm1, 0.0, 0)?;
352        let tdiff0 = t0 - tmin;
353        let tdiff = tin - t0;
354        if tdiff <= 0.0 {
355            xm - (tdiffm / (d2t2 - tdiffm * (d2t2 / tdiff0 - 1.0 / (xm * xm)))).sqrt()
356        } else {
357            let mut x = -tdiff / (tdiff + 4.0);
358            let w = x + C0 * (2.0 * (1.0 - thr2)).sqrt();
359            if w < 0.0 {
360                x -= (-w).sqrt().sqrt().sqrt().sqrt() * (x + (tdiff / (tdiff + 1.5 * t0)).sqrt());
361            }
362            let w = 4.0 / (4.0 + tdiff);
363            x * (1.0
364                + (1.0 + m_f64 + C42 * (thr2 - 0.5)) / (1.0 + C3 * m_f64)
365                    * x
366                    * (C1 * w - C2 * x * w.sqrt()))
367        }
368    };
369
370    // Householder order-2 refinement
371    for _ in 0..3 {
372        let (t, dt, d2t, _) = tlamb(m, q, qsqfm1, x, 2)?;
373        let t_err = tin - t;
374        // FORTRAN-origin: 1e-30 near-zero guard to avoid division by zero in Householder step
375        if dt.abs() > 1e-30 {
376            x += t_err * dt / (dt * dt + t_err * d2t / 2.0);
377        }
378    }
379
380    // Convergence check
381    let (t_final, _, _, _) = tlamb(m, q, qsqfm1, x, 0)?;
382    if (tin - t_final).abs() < TOL * tin.max(1.0) {
383        Ok(x)
384    } else {
385        Err(LambertError::ConvergenceFailed)
386    }
387}
388
389/// Solve Lambert's problem using Gooding's (1990) method.
390///
391/// This is the primary validated API. For the low-level C-matching interface
392/// (useful for cross-validation with the reference implementation), see
393/// [`gooding_lambert()`].
394///
395/// # Parameters
396///
397/// - `mu`: gravitational parameter (m³/s² or km³/s², consistent with position/time units)
398/// - `r1`, `r2`: position vectors at departure and arrival
399/// - `tof`: time of flight (must be positive)
400/// - `nrev`: number of complete revolutions before arrival (0 = single arc)
401/// - `dir`: [`Direction::Prograde`] or [`Direction::Retrograde`]
402/// - `period`: [`MultiRevPeriod::LongPeriod`] or [`MultiRevPeriod::ShortPeriod`] —
403///   selects which solution family to return for multi-revolution transfers
404///   (`nrev >= 1`). Ignored when `nrev == 0`.
405///
406/// # Errors
407///
408/// - [`LambertError::InvalidInput`] — non-positive tof, zero position magnitude, or non-finite input
409/// - [`LambertError::SingularTransfer`] — transfer angle is exactly 180° (plane undefined)
410/// - [`LambertError::NoSolution`] — TOF below minimum for the requested revolution count
411/// - [`LambertError::ConvergenceFailed`] — Householder iteration did not converge
412pub fn lambert(
413    mu: f64,
414    r1: [f64; 3],
415    r2: [f64; 3],
416    tof: f64,
417    nrev: u32,
418    dir: Direction,
419    period: MultiRevPeriod,
420) -> Result<LambertSolution, LambertError> {
421    // --- Input validation (keep in wrapper, not in gooding_lambert) ---
422    if !mu.is_finite() || mu <= 0.0 {
423        return Err(LambertError::InvalidInput("mu must be finite and positive"));
424    }
425    if !tof.is_finite() || tof <= 0.0 {
426        return Err(LambertError::InvalidInput(
427            "tof must be finite and positive",
428        ));
429    }
430    for &v in r1.iter().chain(r2.iter()) {
431        if !v.is_finite() {
432            return Err(LambertError::InvalidInput(
433                "position vector contains non-finite value",
434            ));
435        }
436    }
437    let r1_mag = mag3(&r1);
438    let r2_mag = mag3(&r2);
439    // FORTRAN-origin: 1e-10 relative tolerance for near-zero position magnitude
440    if r1_mag < 1e-10 * r2_mag.max(1.0) {
441        return Err(LambertError::InvalidInput(
442            "r1 has zero or near-zero magnitude",
443        ));
444    }
445    // FORTRAN-origin: 1e-10 relative tolerance for near-zero position magnitude
446    if r2_mag < 1e-10 * r1_mag.max(1.0) {
447        return Err(LambertError::InvalidInput(
448            "r2 has zero or near-zero magnitude",
449        ));
450    }
451
452    // Check for 180-degree singularity (gooding_lambert uses fallback z=[0,0,1]
453    // which gives a "solution" that is physically meaningless — catch it here)
454    // FORTRAN-origin: 1e-10 geometric tolerance for near-180° singularity detection
455    let cos_th = dot3(&r1, &r2) / (r1_mag * r2_mag);
456    if cos_th <= -1.0 + 1e-10 {
457        return Err(LambertError::SingularTransfer);
458    }
459    let cross_z = cross3(&r1, &r2);
460    let cross_z_mag = mag3(&cross_z);
461    // FORTRAN-origin: 1e-10 geometric tolerance for near-collinear transfer detection
462    if cross_z_mag < 1e-10 * r1_mag * r2_mag {
463        return Err(LambertError::SingularTransfer);
464    }
465
466    // Convert nrev count to signed i32 for gooding_lambert's C convention:
467    //   positive nrev -> long-period solution
468    //   negative nrev -> short-period solution
469    // Safe conversion: u32 revolution counts beyond i32::MAX are physically
470    // unreasonable; reject them rather than silently wrapping.
471    let nrev_abs: i32 = i32::try_from(nrev).map_err(|_| {
472        LambertError::InvalidInput("nrev exceeds maximum supported revolution count")
473    })?;
474    let nrev_signed = match period {
475        MultiRevPeriod::LongPeriod => nrev_abs,
476        MultiRevPeriod::ShortPeriod => -nrev_abs,
477    };
478
479    // Encode direction as sign of tdelt: positive = prograde, negative = retrograde.
480    // gooding_lambert handles retrograde internally (supplementary angle + flipped normal).
481    let signed_tdelt = match dir {
482        Direction::Prograde => tof,
483        Direction::Retrograde => -tof,
484    };
485
486    let mut v1 = [0.0_f64; 3];
487    let mut v2 = [0.0_f64; 3];
488    let code = gooding_lambert(mu, &r1, &r2, nrev_signed, signed_tdelt, &mut v1, &mut v2);
489    match code {
490        c if c > 0 => Ok(LambertSolution { v1, v2 }),
491        0 => Err(LambertError::NoSolution),
492        _ => Err(LambertError::ConvergenceFailed),
493    }
494}
495
496/// Internal helper for [`gooding_lambert`]: solve in the 2D radial-transverse frame
497/// using C-matching signed conventions for `nrev` and `tdelt`.
498///
499/// The argument list intentionally matches the original FORTRAN subroutine VLAMB
500/// for cross-validation fidelity with the reference implementation. This function
501/// is test-only infrastructure (the public API uses `lambert()` with Rust idioms);
502/// do not refactor the argument list.
503#[allow(clippy::too_many_arguments)]
504fn vlamb_c(
505    gm: f64,
506    r1: f64,
507    r2: f64,
508    th: f64,
509    nrev: i32,
510    tdelt: f64,
511    v1: &mut [f64; 2],
512    v2: &mut [f64; 2],
513) -> i32 {
514    let m = nrev.unsigned_abs() as i32;
515    let thr2 = th / 2.0;
516    let dr = r1 - r2;
517    let r1r2th = 4.0 * r1 * r2 * thr2.sin().powi(2);
518    let csq = dr * dr + r1r2th;
519    let c = csq.sqrt();
520    let s = (r1 + r2 + c) / 2.0;
521    let gms = (gm * s / 2.0).sqrt();
522    let qsqfm1 = c / s;
523    let q = (r1 * r2).sqrt() * thr2.cos() / s;
524
525    // FORTRAN-origin: 1e-14 near-zero guard for chord length c (equal-radius case)
526    let (rho, sig) = if c > 1e-14 {
527        (dr / c, r1r2th / csq)
528    } else {
529        (0.0, 1.0)
530    };
531
532    let t = 4.0 * gms * tdelt / (s * s);
533
534    let x = match xlamb(m, q, qsqfm1, t, nrev) {
535        Ok(x) => x,
536        Err(LambertError::NoSolution) => return 0,
537        Err(_) => return -1,
538    };
539
540    let (_, qzminx, qzplx, zplqx) = match tlamb(m, q, qsqfm1, x, -1) {
541        Ok(v) => v,
542        Err(_) => return -1,
543    };
544
545    // C evaluation order: compute intermediate, then divide
546    v1[1] = gms * zplqx * sig.sqrt();
547    v2[1] = v1[1] / r2;
548    v1[1] /= r1;
549    v1[0] = gms * (qzminx - qzplx * rho) / r1;
550    v2[0] = -gms * (qzminx + qzplx * rho) / r2;
551
552    1
553}
554
555/// Low-level Lambert solver matching the C/FORTRAN calling convention.
556///
557/// # Warning: No input validation
558///
559/// This function performs **no input validation**. It does not check for
560/// non-positive `gm`, zero-length position vectors, non-finite values, or
561/// 180-degree singular transfers. Invalid inputs produce undefined numerical
562/// results (NaN, infinity, or meaningless velocities) without any error signal.
563///
564/// **Use [`lambert()`] instead** for a validated, idiomatic Rust API. This
565/// function is public for two purposes:
566///
567/// 1. **Cross-validation** with the original C/FORTRAN implementation — the
568///    argument list intentionally mirrors the C `lambert()` function, using
569///    signed `nrev` and signed `tdelt` with the same conventions.
570/// 2. **Advanced use** where callers have already validated inputs and need
571///    direct access to the C-matching interface.
572///
573/// # Calling convention (C/FORTRAN-matching)
574///
575/// - `nrev`: signed revolution count. `|nrev|` is the number of complete
576///   revolutions. The sign selects the solution family for multi-rev cases:
577///   positive = long-period, negative = short-period. For `nrev == 0`, the
578///   sign is irrelevant.
579/// - `tdelt`: signed time of flight. Positive = prograde transfer,
580///   negative = retrograde transfer.
581///
582/// Returns a status code: 1 on success, 0 if no solution exists, -1 on error.
583pub fn gooding_lambert(
584    gm: f64,
585    r1: &[f64; 3],
586    r2: &[f64; 3],
587    nrev: i32,
588    tdelt: f64,
589    v1: &mut [f64; 3],
590    v2: &mut [f64; 3],
591) -> i32 {
592    let rad1 = mag3(r1);
593    let rad2 = mag3(r2);
594
595    let dot = dot3(r1, r2);
596    let th = (dot / (rad1 * rad2)).clamp(-1.0, 1.0).acos();
597
598    let mut va1 = [0.0_f64; 2];
599    let mut va2 = [0.0_f64; 2];
600
601    // Retrograde convention (C-matching): negative tdelt requests retrograde.
602    // Use supplementary angle so cos(th/2) goes negative (flips q in vlamb_c),
603    // pass |tdelt| since time-of-flight is always positive, and flip the orbit
604    // normal for 3D reconstruction.
605    let retrograde = tdelt < 0.0;
606    let (th, tdelt) = if retrograde {
607        (TWOPI - th, tdelt.abs())
608    } else {
609        (th, tdelt)
610    };
611
612    let code = vlamb_c(gm, rad1, rad2, th, nrev, tdelt, &mut va1, &mut va2);
613
614    if code > 0 {
615        let x1 = [r1[0] / rad1, r1[1] / rad1, r1[2] / rad1];
616        let x2 = [r2[0] / rad2, r2[1] / rad2, r2[2] / rad2];
617
618        let mut z = cross3(&x1, &x2);
619        let zm = mag3(&z);
620
621        // FORTRAN-origin: 1e-10 near-zero guard for orbit normal magnitude (fallback to z-axis)
622        if zm < 1e-10 {
623            z = [0.0, 0.0, 1.0];
624        } else {
625            z[0] /= zm;
626            z[1] /= zm;
627            z[2] /= zm;
628        }
629
630        // Retrograde: flip orbit normal so angular momentum is antiparallel to r1 × r2.
631        if retrograde {
632            z[0] = -z[0];
633            z[1] = -z[1];
634            z[2] = -z[2];
635        }
636
637        let y1 = cross3(&z, &x1);
638        let y2 = cross3(&z, &x2);
639
640        v1[0] = va1[0] * x1[0] + va1[1] * y1[0];
641        v1[1] = va1[0] * x1[1] + va1[1] * y1[1];
642        v1[2] = va1[0] * x1[2] + va1[1] * y1[2];
643        v2[0] = va2[0] * x2[0] + va2[1] * y2[0];
644        v2[1] = va2[0] * x2[1] + va2[1] * y2[1];
645        v2[2] = va2[0] * x2[2] + va2[1] * y2[2];
646    }
647
648    code
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654    use std::f64::consts::PI;
655
656    fn mag(v: [f64; 3]) -> f64 {
657        mag3(&v)
658    }
659
660    fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
661        cross3(&a, &b)
662    }
663
664    // Specific orbital energy: v²/2 - mu/r
665    fn energy(r: [f64; 3], v: [f64; 3], mu: f64) -> f64 {
666        let v_sq = v[0] * v[0] + v[1] * v[1] + v[2] * v[2];
667        v_sq / 2.0 - mu / mag(r)
668    }
669
670    #[test]
671    fn tlamb_at_zero() {
672        // At x=0, T should be finite and positive for prograde (m=0, q>0)
673        let (t, dt, d2t, _) = tlamb(0, 0.5, 0.5, 0.0, 2).unwrap();
674        assert!(t.is_finite() && t > 0.0, "T(0) should be positive, got {t}");
675        assert!(dt.is_finite(), "dT/dx at 0 should be finite, got {dt}");
676        assert!(d2t.is_finite(), "d²T/dx² at 0 should be finite, got {d2t}");
677    }
678
679    #[test]
680    fn tlamb_velocity_mode() {
681        // n=-1 returns (0, qzminx, qzplx, zplqx) — all finite
682        let (t0, qzminx, qzplx, zplqx) = tlamb(0, 0.5, 0.5, 0.3, -1).unwrap();
683        assert_eq!(t0, 0.0);
684        assert!(qzminx.is_finite());
685        assert!(qzplx.is_finite());
686        assert!(zplqx.is_finite());
687    }
688
689    #[test]
690    fn energy_conserved_prograde() {
691        // Energy must be equal at both endpoints (same orbit)
692        let mu = 1.0;
693        let r1 = [1.0, 0.0, 0.0];
694        let r2 = [0.0, 1.0, 0.0];
695        let sol = lambert(mu, r1, r2, PI / 2.0, 0, Direction::Prograde, MultiRevPeriod::LongPeriod).unwrap();
696        let e1 = energy(r1, sol.v1, mu);
697        let e2 = energy(r2, sol.v2, mu);
698        // Lambert converges to |δT| < 1e-12; energy error ~ v·δv ~ 1e-12
699        assert!((e1 - e2).abs() < 1e-12, "energy: {e1} vs {e2}");
700    }
701
702    #[test]
703    fn angular_momentum_conserved() {
704        // h = r × v must be the same at both endpoints
705        let mu = 1.0;
706        let r1 = [1.0, 0.0, 0.0];
707        let r2 = [0.0, 1.5, 0.0];
708        let sol = lambert(mu, r1, r2, 2.0, 0, Direction::Prograde, MultiRevPeriod::LongPeriod).unwrap();
709        let h1 = cross(r1, sol.v1);
710        let h2 = cross(r2, sol.v2);
711        for i in 0..3 {
712            // |h| ~ 1; δh ~ |r|·δv ~ 1e-12
713            assert!(
714                (h1[i] - h2[i]).abs() < 1e-12,
715                "h[{i}]: {:.6e} vs {:.6e}",
716                h1[i],
717                h2[i]
718            );
719        }
720    }
721
722    #[test]
723    fn prograde_positive_hz() {
724        // Prograde orbit: hz = (r × v).z > 0 for r1 in +x, r2 in +y
725        let mu = 1.0;
726        let r1 = [1.0, 0.0, 0.0];
727        let r2 = [0.0, 1.0, 0.0];
728        let sol = lambert(mu, r1, r2, PI / 2.0, 0, Direction::Prograde, MultiRevPeriod::LongPeriod).unwrap();
729        let h = cross(r1, sol.v1);
730        assert!(
731            h[2] > 0.0,
732            "hz should be positive for prograde, got {}",
733            h[2]
734        );
735    }
736
737    #[test]
738    fn retrograde_negative_hz() {
739        // Retrograde orbit: hz = dot(r1×r2, r1×v1) < 0
740        // Same geometric angle as prograde (90°), but opposite direction of travel.
741        let mu = 1.0;
742        let r1 = [1.0, 0.0, 0.0];
743        let r2 = [0.0, 1.0, 0.0];
744        let sol = lambert(mu, r1, r2, PI / 2.0, 0, Direction::Retrograde, MultiRevPeriod::LongPeriod).unwrap();
745        let h = cross(r1, sol.v1);
746        assert!(
747            h[2] < 0.0,
748            "hz should be negative for retrograde, got {}",
749            h[2]
750        );
751    }
752
753    #[test]
754    fn leo_to_geo() {
755        // Physical sanity: LEO→GEO departure speed should be 7–12 km/s
756        let mu = 398600.4418;
757        let r1 = [6678.0, 0.0, 0.0];
758        let r2 = [0.0, 42164.0, 0.0];
759        let sol = lambert(mu, r1, r2, 5.0 * 3600.0, 0, Direction::Prograde, MultiRevPeriod::LongPeriod).unwrap();
760        let speed = mag(sol.v1);
761        assert!(
762            (7.0..=12.0).contains(&speed),
763            "LEO departure speed {speed:.2} km/s out of range"
764        );
765    }
766
767    #[test]
768    fn singular_transfer_180_deg() {
769        // 180° transfer (collinear with opposite signs) should fail
770        let mu = 1.0;
771        let r1 = [1.0, 0.0, 0.0];
772        let r2 = [-1.0, 0.0, 0.0];
773        assert_eq!(
774            lambert(mu, r1, r2, 1.0, 0, Direction::Prograde, MultiRevPeriod::LongPeriod),
775            Err(LambertError::SingularTransfer)
776        );
777    }
778
779    #[test]
780    fn invalid_mu() {
781        let r1 = [1.0, 0.0, 0.0];
782        let r2 = [0.0, 1.0, 0.0];
783        assert!(matches!(
784            lambert(0.0, r1, r2, 1.0, 0, Direction::Prograde, MultiRevPeriod::LongPeriod),
785            Err(LambertError::InvalidInput(_))
786        ));
787        assert!(matches!(
788            lambert(-1.0, r1, r2, 1.0, 0, Direction::Prograde, MultiRevPeriod::LongPeriod),
789            Err(LambertError::InvalidInput(_))
790        ));
791        assert!(matches!(
792            lambert(f64::NAN, r1, r2, 1.0, 0, Direction::Prograde, MultiRevPeriod::LongPeriod),
793            Err(LambertError::InvalidInput(_))
794        ));
795    }
796
797    #[test]
798    fn invalid_tof() {
799        let mu = 1.0;
800        let r1 = [1.0, 0.0, 0.0];
801        let r2 = [0.0, 1.0, 0.0];
802        assert!(matches!(
803            lambert(mu, r1, r2, 0.0, 0, Direction::Prograde, MultiRevPeriod::LongPeriod),
804            Err(LambertError::InvalidInput(_))
805        ));
806        assert!(matches!(
807            lambert(mu, r1, r2, -1.0, 0, Direction::Prograde, MultiRevPeriod::LongPeriod),
808            Err(LambertError::InvalidInput(_))
809        ));
810    }
811
812    #[test]
813    fn hyperbolic_positive_energy() {
814        // Very short TOF => hyperbolic transfer
815        let mu = 1.0;
816        let r1 = [1.0, 0.0, 0.0];
817        let r2 = [0.0, 1.0, 0.0];
818        let sol = lambert(mu, r1, r2, 0.1, 0, Direction::Prograde, MultiRevPeriod::LongPeriod).unwrap();
819        let e = energy(r1, sol.v1, mu);
820        assert!(e > 0.0, "Short TOF should give hyperbolic (e>0), got e={e}");
821    }
822
823    #[test]
824    fn three_dimensional_transfer() {
825        // Out-of-plane transfer: v1 must have non-zero z component
826        let mu = 1.0;
827        let r1 = [1.0, 0.0, 0.0];
828        let r2 = [0.0, 0.8, 0.6];
829        let sol = lambert(mu, r1, r2, PI / 2.0, 0, Direction::Prograde, MultiRevPeriod::LongPeriod).unwrap();
830        assert!(
831            sol.v1[2].abs() > 1e-6,
832            "Expected 3D velocity, got v1z={}",
833            sol.v1[2]
834        );
835        // Energy still conserved
836        let e1 = energy(r1, sol.v1, mu);
837        let e2 = energy(r2, sol.v2, mu);
838        assert!((e1 - e2).abs() < 1e-12, "3D energy: {e1} vs {e2}");
839    }
840
841    #[test]
842    fn multi_rev_longer_tof() {
843        // Multi-rev solution requires a larger TOF than single-rev
844        let mu = 1.0;
845        let r1 = [1.0, 0.0, 0.0];
846        let r2 = [-1.0, 0.1, 0.0];
847        // Single-rev
848        let sol0 = lambert(mu, r1, r2, 20.0, 0, Direction::Prograde, MultiRevPeriod::LongPeriod).unwrap();
849        // 1-rev long period
850        let sol1 = lambert(mu, r1, r2, 20.0, 1, Direction::Prograde, MultiRevPeriod::LongPeriod).unwrap();
851        // Energy: 1-rev should be on a different (more circular) orbit
852        let e0 = energy(r1, sol0.v1, mu);
853        let e1 = energy(r1, sol1.v1, mu);
854        assert!(
855            (e0 - e1).abs() > 1e-6,
856            "0-rev and 1-rev should have different energies"
857        );
858    }
859
860    #[test]
861    fn lambert_wraps_gooding_lambert() {
862        // Verify lambert() and gooding_lambert() produce identical results
863        // for the same physical inputs.
864        let mu = 398600.4418;
865        let r1 = [6678.0, 0.0, 0.0];
866        let r2 = [0.0, 42164.0, 0.0];
867        let tof = 5.0 * 3600.0;
868
869        // lambert() via wrapper
870        let sol = lambert(mu, r1, r2, tof, 0, Direction::Prograde, MultiRevPeriod::LongPeriod).unwrap();
871
872        // gooding_lambert() directly
873        let mut v1 = [0.0_f64; 3];
874        let mut v2 = [0.0_f64; 3];
875        let code = gooding_lambert(mu, &r1, &r2, 0, tof, &mut v1, &mut v2);
876        assert!(code > 0);
877
878        // Must be bitwise identical (same code path, no floating-point divergence)
879        assert_eq!(
880            sol.v1, v1,
881            "v1 mismatch between lambert() and gooding_lambert()"
882        );
883        assert_eq!(
884            sol.v2, v2,
885            "v2 mismatch between lambert() and gooding_lambert()"
886        );
887    }
888
889    #[test]
890    fn lambert_retrograde_physics() {
891        // Retrograde orbit: verify energy conservation, angular momentum
892        // conservation, and angular momentum direction (hz < 0).
893        let mu = 1.0;
894        let r1 = [1.0, 0.0, 0.0];
895        let r2 = [0.0, 1.0, 0.0];
896        let tof = PI / 2.0;
897
898        let sol = lambert(mu, r1, r2, tof, 0, Direction::Retrograde, MultiRevPeriod::LongPeriod).unwrap();
899
900        // Energy conserved between endpoints
901        let e1 = energy(r1, sol.v1, mu);
902        let e2 = energy(r2, sol.v2, mu);
903        assert!((e1 - e2).abs() < 1e-12, "energy: {e1} vs {e2}");
904
905        // Angular momentum conserved
906        let h1 = cross(r1, sol.v1);
907        let h2 = cross(r2, sol.v2);
908        for i in 0..3 {
909            assert!(
910                (h1[i] - h2[i]).abs() < 1e-12,
911                "h[{i}]: {:.6e} vs {:.6e}",
912                h1[i],
913                h2[i]
914            );
915        }
916
917        // h_ref = r1 × r2 points in +z; retrograde means dot(h_ref, h_actual) < 0
918        let h_ref = cross(r1, r2);
919        let dot_h = h_ref[0] * h1[0] + h_ref[1] * h1[1] + h_ref[2] * h1[2];
920        assert!(
921            dot_h < 0.0,
922            "dot(r1×r2, r1×v1) should be negative for retrograde, got {}",
923            dot_h
924        );
925    }
926
927    #[test]
928    fn lambert_wraps_gooding_lambert_multirev() {
929        let mu = 1.0;
930        let r1 = [1.0, 0.0, 0.0];
931        let r2 = [-1.0, 0.1, 0.0];
932        let tof = 20.0;
933
934        let sol = lambert(mu, r1, r2, tof, 1, Direction::Prograde, MultiRevPeriod::LongPeriod).unwrap();
935
936        let mut v1 = [0.0_f64; 3];
937        let mut v2 = [0.0_f64; 3];
938        let code = gooding_lambert(mu, &r1, &r2, 1, tof, &mut v1, &mut v2);
939        assert!(code > 0);
940
941        assert_eq!(sol.v1, v1);
942        assert_eq!(sol.v2, v2);
943    }
944
945    // ====================================================================
946    // GL-13: Unit tests for untested tlamb paths
947    // ====================================================================
948
949    #[test]
950    fn tlamb_sw_boundary_series_path() {
951        // Exercise the power-series expansion for x near parabolic limit.
952        // Series path triggers when: n != -1, m == 0, x >= 0, |u| <= SW (0.4).
953        // x = 0.8 => u = 1 - 0.64 = 0.36, which is <= SW.
954        let q = 0.5;
955        let qsqfm1 = 1.0 - q * q; // 0.75
956        let x = 0.8;
957
958        let (t, dt, d2t, d3t) = tlamb(0, q, qsqfm1, x, 3).unwrap();
959        assert!(t.is_finite() && t > 0.0, "T should be positive on series path, got {t}");
960        assert!(dt.is_finite(), "dT should be finite on series path, got {dt}");
961        assert!(d2t.is_finite(), "d2T should be finite on series path, got {d2t}");
962        assert!(d3t.is_finite(), "d3T should be finite on series path, got {d3t}");
963    }
964
965    #[test]
966    fn tlamb_sw_boundary_series_vs_direct_continuity() {
967        // At x just below the SW boundary, the series and direct paths should
968        // give similar T values, confirming continuity across the branch.
969        // SW = 0.4, so u = SW when x = sqrt(1 - 0.4) ≈ 0.7746.
970        // x = 0.77 => u ≈ 0.4071 (direct path, just above SW).
971        // x = 0.78 => u ≈ 0.3916 (series path, just below SW).
972        let q = 0.5;
973        let qsqfm1 = 1.0 - q * q;
974
975        let (t_direct, _, _, _) = tlamb(0, q, qsqfm1, 0.77, 0).unwrap();
976        let (t_series, _, _, _) = tlamb(0, q, qsqfm1, 0.78, 0).unwrap();
977
978        // T(x) is continuous, so nearby x values should give nearby T values.
979        // The difference should be small (both values are O(1)).
980        let rel_diff = (t_direct - t_series).abs() / t_direct.max(t_series);
981        assert!(
982            rel_diff < 0.1,
983            "Series and direct paths should be continuous at SW boundary: \
984             T(0.77)={t_direct}, T(0.78)={t_series}, rel_diff={rel_diff}"
985        );
986    }
987
988    #[test]
989    fn tlamb_sw_boundary_large_q() {
990        // Series path with q >= 0.5 exercises the alternate tqsum formula:
991        //   tqsum = (1/(1+q) + q) * qsqfm1
992        let q = 0.8;
993        let qsqfm1 = 1.0 - q * q; // 0.36
994        let x = 0.85; // u = 1 - 0.7225 = 0.2775, well within SW
995
996        let (t, _, _, _) = tlamb(0, q, qsqfm1, x, 0).unwrap();
997        assert!(t.is_finite() && t > 0.0, "T should be positive for large-q series, got {t}");
998    }
999
1000    #[test]
1001    fn tlamb_hyperbolic_large_f() {
1002        // x > 1 takes the hyperbolic branch. Large x gives large f,
1003        // exercising the `f > SW` log-branch: ln(f + x*z + q*u).
1004        let q = 0.5;
1005        let qsqfm1 = 1.0 - q * q;
1006        let x = 2.0; // u = 1-4 = -3, deeply hyperbolic
1007
1008        let (t, _, _, _) = tlamb(0, q, qsqfm1, x, 0).unwrap();
1009        assert!(t.is_finite() && t > 0.0, "T should be positive on hyperbolic branch, got {t}");
1010    }
1011
1012    #[test]
1013    fn tlamb_hyperbolic_with_derivatives() {
1014        // Hyperbolic branch with all derivatives requested (n=3).
1015        let q = 0.5;
1016        let qsqfm1 = 1.0 - q * q;
1017        let x = 1.5; // hyperbolic
1018
1019        let (t, dt, d2t, d3t) = tlamb(0, q, qsqfm1, x, 3).unwrap();
1020        assert!(t.is_finite(), "T should be finite, got {t}");
1021        assert!(dt.is_finite(), "dT should be finite, got {dt}");
1022        assert!(d2t.is_finite(), "d2T should be finite, got {d2t}");
1023        assert!(d3t.is_finite(), "d3T should be finite, got {d3t}");
1024        // dT/dx should be negative on the hyperbolic branch (T decreases with x for x>1)
1025        assert!(dt < 0.0, "dT/dx should be negative for hyperbolic x, got {dt}");
1026    }
1027
1028    #[test]
1029    fn tlamb_hyperbolic_small_f_log_series() {
1030        // Hyperbolic branch with small f exercises the log-series path
1031        // (f <= SW, the "small argument" hyperbolic sub-branch).
1032        // x slightly > 1 gives small y = sqrt(|u|) = sqrt(x²-1), hence small f = a*y.
1033        let q = 0.5;
1034        let qsqfm1 = 1.0 - q * q;
1035        let x = 1.01; // u = 1-1.0201 = -0.0201, |u| = 0.0201, y ≈ 0.1418
1036
1037        let (t, _, _, _) = tlamb(0, q, qsqfm1, x, 0).unwrap();
1038        assert!(t.is_finite() && t > 0.0, "T should be positive on hyp small-f path, got {t}");
1039    }
1040
1041    #[test]
1042    fn tlamb_multi_rev() {
1043        // m > 0 always uses the direct computation path (never the series).
1044        // Verify T is finite and increases with m (more revolutions = more time).
1045        let q = 0.5;
1046        let qsqfm1 = 1.0 - q * q;
1047        let x = 0.3;
1048
1049        let (t0, _, _, _) = tlamb(0, q, qsqfm1, x, 0).unwrap();
1050        let (t1, _, _, _) = tlamb(1, q, qsqfm1, x, 0).unwrap();
1051        let (t2, _, _, _) = tlamb(2, q, qsqfm1, x, 0).unwrap();
1052
1053        assert!(t0.is_finite() && t0 > 0.0, "T(m=0) should be positive, got {t0}");
1054        assert!(t1.is_finite() && t1 > 0.0, "T(m=1) should be positive, got {t1}");
1055        assert!(t2.is_finite() && t2 > 0.0, "T(m=2) should be positive, got {t2}");
1056        // Each additional revolution adds ~pi to T
1057        assert!(t1 > t0, "T(m=1)={t1} should exceed T(m=0)={t0}");
1058        assert!(t2 > t1, "T(m=2)={t2} should exceed T(m=1)={t1}");
1059    }
1060
1061    #[test]
1062    fn tlamb_multi_rev_with_derivatives() {
1063        // Multi-rev path with all derivatives (n=3) on the direct path.
1064        let q = 0.5;
1065        let qsqfm1 = 1.0 - q * q;
1066        let x = 0.3;
1067
1068        let (t, dt, d2t, d3t) = tlamb(1, q, qsqfm1, x, 3).unwrap();
1069        assert!(t.is_finite(), "T should be finite for m=1, got {t}");
1070        assert!(dt.is_finite(), "dT should be finite for m=1, got {dt}");
1071        assert!(d2t.is_finite(), "d2T should be finite for m=1, got {d2t}");
1072        assert!(d3t.is_finite(), "d3T should be finite for m=1, got {d3t}");
1073    }
1074
1075    #[test]
1076    fn tlamb_u_near_zero_guard_velocity_mode() {
1077        // x ≈ 1 makes u = 1-x² ≈ 0. With n=-1 (velocity mode), the guard
1078        // should fire and return Err(ConvergenceFailed).
1079        let q = 0.5;
1080        let qsqfm1 = 1.0 - q * q;
1081        let x = 1.0 - 1e-12; // u ≈ 2e-12, well below 1e-10
1082
1083        let result = tlamb(0, q, qsqfm1, x, -1);
1084        assert!(
1085            matches!(result, Err(LambertError::ConvergenceFailed)),
1086            "u-near-zero guard should fire for n=-1, got {result:?}"
1087        );
1088    }
1089
1090    #[test]
1091    fn tlamb_u_near_zero_guard_multi_rev() {
1092        // x ≈ 1 with m > 0: the guard should fire because m != 0.
1093        let q = 0.5;
1094        let qsqfm1 = 1.0 - q * q;
1095        let x = 1.0 - 1e-12;
1096
1097        let result = tlamb(1, q, qsqfm1, x, 0);
1098        assert!(
1099            matches!(result, Err(LambertError::ConvergenceFailed)),
1100            "u-near-zero guard should fire for m=1, got {result:?}"
1101        );
1102    }
1103
1104    #[test]
1105    fn tlamb_u_near_zero_guard_negative_x() {
1106        // x = -(1 - eps) makes u ≈ 0. With x < 0, the guard should fire.
1107        let q = 0.5;
1108        let qsqfm1 = 1.0 - q * q;
1109        let x = -(1.0 - 1e-12);
1110
1111        let result = tlamb(0, q, qsqfm1, x, 0);
1112        assert!(
1113            matches!(result, Err(LambertError::ConvergenceFailed)),
1114            "u-near-zero guard should fire for negative x near -1, got {result:?}"
1115        );
1116    }
1117
1118    #[test]
1119    fn tlamb_u_near_zero_safe_series_path() {
1120        // x ≈ 1 with m=0, x>=0, n>=0 takes the series path, which handles
1121        // u→0 correctly. This should succeed, NOT trigger the guard.
1122        let q = 0.5;
1123        let qsqfm1 = 1.0 - q * q;
1124        // x close to 1 so u = (1-x)(1+x) < 1e-10, but the series path handles it.
1125        let x = 1.0 - 1e-12; // u ≈ 2e-12
1126
1127        // m=0, x>=0, n=0: series path condition is met (n!=-1 && m==0 && x>=0)
1128        // but |u| <= SW is also met, so series path runs.
1129        // The guard condition is: |u| < 1e-10 && (n==-1 || m!=0 || x<0)
1130        // Since n=0, m=0, x>0, the guard does NOT fire. Series path runs.
1131        let result = tlamb(0, q, qsqfm1, x, 0);
1132        assert!(
1133            result.is_ok(),
1134            "Series path should handle u→0 safely for m=0, x>=0, n=0, got {result:?}"
1135        );
1136    }
1137
1138    // ====================================================================
1139    // GL-14: Unit tests for xlamb root finder
1140    // ====================================================================
1141
1142    #[test]
1143    fn xlamb_single_rev_converges() {
1144        // Single-rev (m=0) root finder should converge for a typical geometry.
1145        // Use the same q,qsqfm1 and compute a target T from a known x.
1146        let q = 0.5;
1147        let qsqfm1 = 1.0 - q * q;
1148        let x_ref = 0.3;
1149
1150        let (t_target, _, _, _) = tlamb(0, q, qsqfm1, x_ref, 0).unwrap();
1151        let x_found = xlamb(0, q, qsqfm1, t_target, 0).unwrap();
1152
1153        assert!(
1154            (x_found - x_ref).abs() < 1e-8,
1155            "xlamb should recover x={x_ref}, got {x_found}"
1156        );
1157    }
1158
1159    #[test]
1160    fn xlamb_single_rev_elliptic_side() {
1161        // tdiff <= 0 means x is on the elliptic side near parabolic (x close to 0).
1162        // T(0) > tin forces the elliptic initial guess.
1163        let q = 0.5;
1164        let qsqfm1 = 1.0 - q * q;
1165
1166        // T(0) for these parameters
1167        let (t0, _, _, _) = tlamb(0, q, qsqfm1, 0.0, 0).unwrap();
1168        // Pick a target T slightly above T(0) — x will be slightly positive
1169        // (elliptic side). Wait: tdiff = tin - t0. For tdiff <= 0 we need tin <= t0.
1170        let tin = t0 * 0.8; // below T(0), so tdiff < 0 -> elliptic initial guess
1171        let x = xlamb(0, q, qsqfm1, tin, 0).unwrap();
1172
1173        assert!(x.is_finite(), "Should converge on elliptic side, got {x}");
1174        // Verify by evaluating T at the found x
1175        let (t_check, _, _, _) = tlamb(0, q, qsqfm1, x, 0).unwrap();
1176        assert!(
1177            (t_check - tin).abs() < 1e-10 * tin,
1178            "T(x_found) should match target: T={t_check}, target={tin}"
1179        );
1180    }
1181
1182    #[test]
1183    fn xlamb_single_rev_hyperbolic_side() {
1184        // tdiff > 0 means x is on the hyperbolic side (x negative or beyond parabolic).
1185        // T(0) < tin forces the hyperbolic initial guess path.
1186        let q = 0.5;
1187        let qsqfm1 = 1.0 - q * q;
1188
1189        let (t0, _, _, _) = tlamb(0, q, qsqfm1, 0.0, 0).unwrap();
1190        // Pick a target T above T(0) — tdiff > 0 -> hyperbolic side
1191        let tin = t0 * 1.5;
1192        let x = xlamb(0, q, qsqfm1, tin, 0).unwrap();
1193
1194        assert!(x.is_finite(), "Should converge on hyperbolic side, got {x}");
1195        let (t_check, _, _, _) = tlamb(0, q, qsqfm1, x, 0).unwrap();
1196        assert!(
1197            (t_check - tin).abs() < 1e-10 * tin,
1198            "T(x_found) should match target: T={t_check}, target={tin}"
1199        );
1200    }
1201
1202    #[test]
1203    fn xlamb_multi_rev_long_period() {
1204        // Multi-rev (m=1) with nrev > 0 selects the long-period solution.
1205        let q = 0.5;
1206        let qsqfm1 = 1.0 - q * q;
1207        let x_ref = 0.3;
1208
1209        // Compute T for m=1 at a reference x, then ask xlamb to find it.
1210        let (t_target, _, _, _) = tlamb(1, q, qsqfm1, x_ref, 0).unwrap();
1211        let x_found = xlamb(1, q, qsqfm1, t_target, 1).unwrap(); // nrev=1 -> long period
1212
1213        assert!(
1214            (x_found - x_ref).abs() < 1e-6,
1215            "xlamb long-period should recover x={x_ref}, got {x_found}"
1216        );
1217    }
1218
1219    #[test]
1220    fn xlamb_multi_rev_short_period() {
1221        // Multi-rev (m=1) with nrev <= 0 selects the short-period solution.
1222        let q = 0.5;
1223        let qsqfm1 = 1.0 - q * q;
1224
1225        // For multi-rev, two solutions exist at the same T. Use a T above T_min.
1226        // Find T_min first by evaluating at xm (the minimum), then pick T > T_min.
1227        // We use a known geometry that produces a valid multi-rev solution.
1228        let x_ref = -0.3; // negative x for short-period
1229
1230        let (t_target, _, _, _) = tlamb(1, q, qsqfm1, x_ref, 0).unwrap();
1231        let x_found = xlamb(1, q, qsqfm1, t_target, -1).unwrap(); // nrev=-1 -> short period
1232
1233        assert!(
1234            (x_found - x_ref).abs() < 1e-6,
1235            "xlamb short-period should recover x={x_ref}, got {x_found}"
1236        );
1237    }
1238
1239    #[test]
1240    fn xlamb_multi_rev_bifurcation_two_solutions() {
1241        // For m > 0, the same T (above T_min) yields two distinct solutions:
1242        // long-period (x closer to xm from above) and short-period (x further away).
1243        let q = 0.5;
1244        let qsqfm1 = 1.0 - q * q;
1245
1246        // Pick a T well above T_min for m=1.
1247        // First, find T_min by finding xm and evaluating T there.
1248        // xlamb handles this internally; we just need a T large enough.
1249        let x_ref_long = 0.3;
1250        let (t_target, _, _, _) = tlamb(1, q, qsqfm1, x_ref_long, 0).unwrap();
1251
1252        let x_long = xlamb(1, q, qsqfm1, t_target, 1).unwrap();
1253        let x_short = xlamb(1, q, qsqfm1, t_target, -1).unwrap();
1254
1255        // Both should be valid roots
1256        let (t_long, _, _, _) = tlamb(1, q, qsqfm1, x_long, 0).unwrap();
1257        let (t_short, _, _, _) = tlamb(1, q, qsqfm1, x_short, 0).unwrap();
1258        assert!(
1259            (t_long - t_target).abs() < 1e-8 * t_target,
1260            "Long-period T should match: {t_long} vs {t_target}"
1261        );
1262        assert!(
1263            (t_short - t_target).abs() < 1e-8 * t_target,
1264            "Short-period T should match: {t_short} vs {t_target}"
1265        );
1266
1267        // The two x values should be distinct
1268        assert!(
1269            (x_long - x_short).abs() > 1e-6,
1270            "Long and short period should give different x: long={x_long}, short={x_short}"
1271        );
1272    }
1273
1274    #[test]
1275    fn xlamb_multi_rev_no_solution_below_tmin() {
1276        // For m > 0, if tin < T_min, no solution exists -> NoSolution error.
1277        let q = 0.5;
1278        let qsqfm1 = 1.0 - q * q;
1279
1280        // Use a very small T that is certainly below T_min for m=1.
1281        let result = xlamb(1, q, qsqfm1, 0.1, 1);
1282        assert!(
1283            matches!(result, Err(LambertError::NoSolution)),
1284            "Below T_min should return NoSolution, got {result:?}"
1285        );
1286    }
1287
1288    #[test]
1289    fn xlamb_multi_rev_at_tmin_returns_xm() {
1290        // When tin is exactly T_min, xlamb should return xm (within tolerance).
1291        let q = 0.5;
1292        let qsqfm1 = 1.0 - q * q;
1293
1294        // Manually find T_min: run Householder on d²T/dx² = 0 to find xm,
1295        // then evaluate T(xm). We replicate xlamb's own logic here.
1296        let mut xm = 1.0 / (1.5 * 1.5 * PI); // m=1
1297        for _ in 0..20 {
1298            let (_, dt, d2t, d3t) = tlamb(1, q, qsqfm1, xm, 3).unwrap();
1299            if d2t.abs() < 1e-30 {
1300                break;
1301            }
1302            xm -= dt * d2t / (d2t * d2t - dt * d3t / 2.0);
1303        }
1304        let (tmin, _, _, _) = tlamb(1, q, qsqfm1, xm, 0).unwrap();
1305
1306        // Request at T_min: should succeed and return xm
1307        let x_found = xlamb(1, q, qsqfm1, tmin, 1).unwrap();
1308        assert!(
1309            (x_found - xm).abs() < 1e-6,
1310            "At T_min, xlamb should return xm={xm}, got {x_found}"
1311        );
1312    }
1313
1314    #[test]
1315    fn xlamb_single_rev_roundtrip_via_lambert() {
1316        // End-to-end: use lambert() to get a solution, then verify xlamb
1317        // is exercised by checking that the result is physically consistent.
1318        // This tests xlamb indirectly through the full solver pipeline.
1319        let mu = 1.0;
1320        let r1 = [1.0, 0.0, 0.0];
1321        let r2 = [0.0, 2.0, 0.0];
1322        let tof = 3.0;
1323
1324        let sol = lambert(mu, r1, r2, tof, 0, Direction::Prograde, MultiRevPeriod::LongPeriod).unwrap();
1325        let e1 = energy(r1, sol.v1, mu);
1326        let e2 = energy(r2, sol.v2, mu);
1327        assert!(
1328            (e1 - e2).abs() < 1e-11,
1329            "Energy conservation verifies xlamb convergence: {e1} vs {e2}"
1330        );
1331    }
1332
1333    #[test]
1334    fn xlamb_multi_rev_short_period_tdiff_positive() {
1335        // Short-period branch has a sub-case where tdiff > 0 (tin > T(0)).
1336        // This exercises the negative-x initial guess within the short-period logic.
1337        let q = 0.5;
1338        let qsqfm1 = 1.0 - q * q;
1339
1340        // T(m=1, x=0) — the "zero reference"
1341        let (t0, _, _, _) = tlamb(1, q, qsqfm1, 0.0, 0).unwrap();
1342        // Pick a target T well above T(0) for m=1; this puts the short-period
1343        // root in the tdiff > 0 sub-branch.
1344        let tin = t0 * 1.2;
1345        let result = xlamb(1, q, qsqfm1, tin, -1);
1346
1347        // Should either converge or return NoSolution — never panic.
1348        assert!(
1349            result.is_ok() || matches!(result, Err(LambertError::NoSolution | LambertError::ConvergenceFailed)),
1350            "Short-period tdiff>0 branch should be handled, got {result:?}"
1351        );
1352        // If it converged, verify the root
1353        if let Ok(x) = result {
1354            let (t_check, _, _, _) = tlamb(1, q, qsqfm1, x, 0).unwrap();
1355            assert!(
1356                (t_check - tin).abs() < 1e-8 * tin,
1357                "Root should satisfy T(x)=tin: T={t_check}, tin={tin}"
1358            );
1359        }
1360    }
1361}