Skip to main content

sidereon_core/astro/
lambert.rs

1//! Lambert two-point boundary-value orbit solver (Battin's method).
2//!
3//! Given two position vectors and a time of flight, solve for the transfer
4//! orbit velocities at each endpoint. This is the authoritative implementation;
5//! language bindings are thin marshaling layers over it.
6//!
7//! Algorithm 61, Vallado, *Fundamentals of Astrodynamics and Applications*
8//! (2022), pp. 505-510. The velocity transfer uses the Thompson (2013/2018)
9//! hodograph formulation.
10//!
11//! ## Reference constants
12//!
13//! `VALLADO_MU` below is a reference-suite value, NOT the WGS84/EGM datum. It
14//! matches the Vallado worked examples and the `valladopy` reference suite that
15//! the unit tests validate against (`VALLADO_MU = 398600.4415`, not the
16//! WGS84/GM value in [`crate::astro::constants`]). It is kept local so the
17//! solver stays bit-exact with that published reference rather than drifting to
18//! a different datum. Callers needing the WGS84/GM datum must use the constants
19//! module, not this value.
20
21use std::f64::consts::PI;
22
23/// Earth gravitational parameter (km^3/s^2), Vallado reference suite value (not
24/// the WGS84/GM datum in [`crate::astro::constants`]).
25const VALLADO_MU: f64 = 398600.4415;
26const TWOPI: f64 = 2.0 * PI;
27const SMALL: f64 = 1e-10;
28
29fn cross(a: &[f64; 3], b: &[f64; 3]) -> [f64; 3] {
30    [
31        a[1] * b[2] - a[2] * b[1],
32        a[2] * b[0] - a[0] * b[2],
33        a[0] * b[1] - a[1] * b[0],
34    ]
35}
36
37fn dot(a: &[f64; 3], b: &[f64; 3]) -> f64 {
38    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
39}
40
41fn mag(a: &[f64; 3]) -> f64 {
42    dot(a, a).sqrt()
43}
44
45fn smul(s: f64, a: &[f64; 3]) -> [f64; 3] {
46    [s * a[0], s * a[1], s * a[2]]
47}
48
49fn vadd(a: &[f64; 3], b: &[f64; 3]) -> [f64; 3] {
50    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
51}
52
53/// Continued fraction for Battin's method (Vallado Eq. 7-69).
54fn seebatt(v: f64) -> f64 {
55    let c = [
56        9.0 / 7.0,
57        16.0 / 63.0,
58        25.0 / 99.0,
59        36.0 / 143.0,
60        49.0 / 195.0,
61        64.0 / 255.0,
62        81.0 / 323.0,
63        100.0 / 399.0,
64        121.0 / 483.0,
65        144.0 / 575.0,
66        169.0 / 675.0,
67        196.0 / 783.0,
68        225.0 / 899.0,
69        256.0 / 1023.0,
70        289.0 / 1155.0,
71        324.0 / 1295.0,
72        361.0 / 1443.0,
73        400.0 / 1599.0,
74        441.0 / 1763.0,
75        484.0 / 1935.0,
76    ];
77
78    let sqrtopv = (1.0 + v).sqrt();
79    let eta = v / (1.0 + sqrtopv).powi(2);
80
81    let mut term2 = 1.0 + c[19] * eta;
82    for j in (0..19).rev() {
83        term2 = 1.0 + c[j] * eta / term2;
84    }
85
86    8.0 * (1.0 + sqrtopv) / (3.0 + 1.0 / (5.0 + eta + (9.0 / 7.0) * eta / term2))
87}
88
89/// Continued fraction for Battin's method (Vallado Eq. 7-70).
90fn kbatt(v: f64) -> f64 {
91    let d = [
92        1.0 / 3.0,
93        4.0 / 27.0,
94        8.0 / 27.0,
95        2.0 / 9.0,
96        22.0 / 81.0,
97        208.0 / 891.0,
98        340.0 / 1287.0,
99        418.0 / 1755.0,
100        598.0 / 2295.0,
101        700.0 / 2907.0,
102        928.0 / 3591.0,
103        1054.0 / 4347.0,
104        1330.0 / 5175.0,
105        1480.0 / 6075.0,
106        1804.0 / 7047.0,
107        1978.0 / 8091.0,
108        2350.0 / 9207.0,
109        2548.0 / 10395.0,
110        2968.0 / 11655.0,
111        3190.0 / 12987.0,
112        3658.0 / 14391.0,
113    ];
114
115    // Forward pass
116    let mut sum1: f64 = d[0];
117    let mut delold: f64 = 1.0;
118    let mut termold: f64 = d[0];
119    let ktr = 21;
120
121    for di in d.iter().take(ktr).skip(1) {
122        if termold.abs() <= 1e-8 {
123            break;
124        }
125        let del = 1.0 / (1.0 + di * v * delold);
126        let term = termold * (del - 1.0);
127        sum1 += term;
128        delold = del;
129        termold = term;
130    }
131    let _ = sum1; // forward pass result not used in final; backward pass is
132
133    // Backward pass
134    let mut term2 = 1.0 + d[ktr - 1] * v;
135    for i in 0..(ktr - 2) {
136        let sum2 = d[ktr - i - 2] * v / term2;
137        term2 = 1.0 + sum2;
138    }
139
140    d[0] / term2
141}
142
143/// Hodograph velocity transfer (Thompson 2013/2018).
144fn hodograph(
145    r1: &[f64; 3],
146    r2: &[f64; 3],
147    v1: &[f64; 3],
148    p: f64,
149    ecc: f64,
150    dnu: f64,
151    dtsec: f64,
152) -> ([f64; 3], [f64; 3]) {
153    let magr1 = mag(r1);
154    let magr2 = mag(r2);
155
156    let a = VALLADO_MU * (1.0 / magr1 - 1.0 / p);
157    let b = (VALLADO_MU * ecc / p).powi(2) - a * a;
158    let x1_abs = if b <= 0.0 { 0.0 } else { b.sqrt() };
159    let mut x1 = -x1_abs;
160
161    let nvec;
162    if dnu.sin().abs() < SMALL {
163        // 180-degree transfer
164        let cp = cross(r1, v1);
165        let ncp = mag(&cp);
166        nvec = smul(1.0 / ncp, &cp);
167
168        if ecc < 1.0 {
169            let ptx = TWOPI * (p.powi(3) / (VALLADO_MU * (1.0 - ecc * ecc).powi(3))).sqrt();
170            if dtsec % ptx > ptx * 0.5 {
171                x1 = x1_abs;
172            }
173        }
174    } else {
175        // Common path
176        let y2a = VALLADO_MU / p - x1 * dnu.sin() + a * dnu.cos();
177        let y2b = VALLADO_MU / p + x1 * dnu.sin() + a * dnu.cos();
178        if (VALLADO_MU / magr2 - y2b).abs() < (VALLADO_MU / magr2 - y2a).abs() {
179            x1 = x1_abs;
180        }
181
182        let cp = cross(r1, r2);
183        let ncp = mag(&cp);
184        nvec = if dnu % TWOPI > PI {
185            smul(-1.0 / ncp, &cp)
186        } else {
187            smul(1.0 / ncp, &cp)
188        };
189    }
190
191    let sqrtmup = (VALLADO_MU * p).sqrt();
192    let nr1 = cross(&nvec, r1);
193    let v1t = smul(
194        sqrtmup / magr1,
195        &vadd(&smul(x1 / VALLADO_MU, r1), &smul(1.0 / magr1, &nr1)),
196    );
197
198    let x2 = x1 * dnu.cos() + a * dnu.sin();
199    let nr2 = cross(&nvec, r2);
200    let v2t = smul(
201        sqrtmup / magr2,
202        &vadd(&smul(x2 / VALLADO_MU, r2), &smul(1.0 / magr2, &nr2)),
203    );
204
205    (v1t, v2t)
206}
207
208/// Direction of motion for the Lambert transfer.
209#[derive(Clone, Copy, PartialEq, Eq, Debug)]
210pub enum DirectionOfMotion {
211    /// Short-way transfer (transfer angle < 180 degrees).
212    Short,
213    /// Long-way transfer (transfer angle > 180 degrees).
214    Long,
215}
216
217/// Direction of energy for the Lambert transfer.
218#[derive(Clone, Copy, PartialEq, Eq, Debug)]
219pub enum DirectionOfEnergy {
220    /// Low-energy branch.
221    Low,
222    /// High-energy branch.
223    High,
224}
225
226/// Error returned when the Lambert solver cannot produce a valid transfer.
227#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
228pub enum LambertError {
229    /// The Battin iteration did not reach the convergence tolerance.
230    #[error("Lambert solver did not converge")]
231    NoConvergence,
232    /// A supplied position vector has (near) zero magnitude.
233    #[error("position vector has near-zero magnitude")]
234    ZeroVector,
235    /// The time of flight is not strictly positive (or not finite).
236    #[error("time of flight must be strictly positive")]
237    NonPositiveTimeOfFlight,
238    /// The transfer-plane normal is undefined: the endpoints are collinear or
239    /// near-collinear (a near-zero-degree transfer, or a near-180-degree
240    /// transfer whose plane cannot be fixed from `v1`). Near-collinearity is
241    /// judged by `|sin(dnu)| < SMALL`, which is exactly when the endpoint cross
242    /// product `r1 x r2` degenerates.
243    #[error("transfer-plane geometry is degenerate")]
244    DegenerateGeometry,
245    /// An intermediate or output value was not finite (NaN or infinity).
246    #[error("non-finite value encountered")]
247    NonFiniteValue,
248}
249
250/// True when every component of a 3-vector is finite.
251fn all_finite(a: &[f64; 3]) -> bool {
252    a.iter().all(|x| x.is_finite())
253}
254
255/// Solve Lambert's problem using Battin's method.
256///
257/// Given two position vectors (km) and a time of flight (seconds), return the
258/// transfer velocity vectors at `r1` and `r2` (km/s). `v1` is only consulted for
259/// the degenerate 180-degree transfer where the transfer-plane normal is
260/// otherwise undefined.
261///
262/// Algorithm 61, Vallado 2022, pp. 505-510.
263pub fn battin(
264    r1: &[f64; 3],
265    r2: &[f64; 3],
266    v1: &[f64; 3],
267    dm: DirectionOfMotion,
268    de: DirectionOfEnergy,
269    nrev: i32,
270    dtsec: f64,
271) -> Result<([f64; 3], [f64; 3]), LambertError> {
272    // Validate inputs before any division or normalization so degenerate input
273    // surfaces as a typed error instead of an Ok(NaN/Inf) transfer.
274    if !all_finite(r1) || !all_finite(r2) || !all_finite(v1) {
275        return Err(LambertError::NonFiniteValue);
276    }
277
278    let magr1 = mag(r1);
279    let magr2 = mag(r2);
280    // Reject magnitudes that overflowed to infinity (finite but huge inputs);
281    // otherwise the plane normalization below collapses to zero and fabricates a
282    // finite transfer.
283    if !magr1.is_finite() || !magr2.is_finite() {
284        return Err(LambertError::NonFiniteValue);
285    }
286    if magr1 < SMALL || magr2 < SMALL {
287        return Err(LambertError::ZeroVector);
288    }
289
290    if !dtsec.is_finite() || dtsec <= 0.0 {
291        return Err(LambertError::NonPositiveTimeOfFlight);
292    }
293
294    // Angle between position vectors
295    let cosdeltanu = dot(r1, r2) / (magr1 * magr2);
296    let magrcrossr = mag(&cross(r1, r2));
297    // The endpoint normal `r1 x r2` is normalized in the hodograph; if its
298    // magnitude overflowed, that normalization would collapse to zero.
299    if !magrcrossr.is_finite() {
300        return Err(LambertError::NonFiniteValue);
301    }
302    let sign = if dm == DirectionOfMotion::Short {
303        1.0
304    } else {
305        -1.0
306    };
307    let sindeltanu = sign * magrcrossr / (magr1 * magr2);
308    let mut dnu = sindeltanu.atan2(cosdeltanu);
309    if dnu < 0.0 {
310        dnu += TWOPI;
311    }
312
313    // The transfer-plane normal must be well-defined. `|sin(dnu)| < SMALL` means
314    // the endpoints are (near-)collinear, so the endpoint normal `r1 x r2` is
315    // degenerate. That is recoverable only for a near-180-degree transfer
316    // (cosdeltanu < 0) whose plane can instead be fixed from `v1`. The `v1`
317    // normal must be finite and nonzero, since the hodograph normalizes it.
318    if dnu.sin().abs() < SMALL {
319        let n_v1 = mag(&cross(r1, v1));
320        let plane_from_v1 = cosdeltanu < 0.0 && n_v1.is_finite() && n_v1 >= SMALL;
321        if !plane_from_v1 {
322            return Err(LambertError::DegenerateGeometry);
323        }
324    }
325
326    // Chord and semiperimeter
327    let chord = (magr1 * magr1 + magr2 * magr2 - 2.0 * magr1 * magr2 * cosdeltanu).sqrt();
328    let s = (magr1 + magr2 + chord) * 0.5;
329    let eps = magr2 / magr1 - 1.0;
330
331    // Lambda, L, m
332    let lam = (magr1 * magr2).sqrt() / s * (dnu * 0.5).cos();
333    let l_ = ((1.0 - lam) / (1.0 + lam)).powi(2);
334    let m = 8.0 * VALLADO_MU * dtsec * dtsec / (s.powi(3) * (1.0 + lam).powi(6));
335
336    // Initial guess
337    let mut xn = if nrev > 0 { 1.0 + 4.0 * l_ } else { l_ };
338
339    if de == DirectionOfEnergy::High && nrev > 0 {
340        // High energy multi-rev case
341        xn = 1e-20;
342        let mut x = 10.0;
343        for _ in 0..20 {
344            if (xn - x).abs() < SMALL {
345                break;
346            }
347            x = xn;
348            let temp = 1.0 / (2.0 * (l_ - x * x));
349            let temp1 = x.sqrt();
350            let temp2 = (nrev as f64 * PI * 0.5 + temp1.atan()) / temp1;
351            let h1 = temp * (l_ + x) * (1.0 + 2.0 * x + l_);
352            let h2 = temp * m * temp1 * ((l_ - x * x) * temp2 - (l_ + x));
353
354            let b = 0.25 * 27.0 * h2 / (temp1 * (1.0 + h1)).powi(3);
355            let f = if b < 0.0 {
356                2.0 * ((b + 1.0).sqrt().acos() / 3.0).cos()
357            } else {
358                let a_ = (b.sqrt() + (b + 1.0).sqrt()).powf(1.0 / 3.0);
359                a_ + 1.0 / a_
360            };
361
362            let y = 2.0 / 3.0 * temp1 * (1.0 + h1) * ((b + 1.0).sqrt() / f + 1.0);
363            xn = 0.5
364                * ((m / (y * y) - (1.0 + l_))
365                    - ((m / (y * y) - (1.0 + l_)).powi(2) - 4.0 * l_).sqrt());
366        }
367
368        // Convergence is decided by the final step size, not the loop count, so
369        // an iterate that converges on the last allowed pass is not falsely
370        // reported as non-convergent. A diverging (NaN) step fails this test and
371        // is reported as non-convergence rather than propagated.
372        let converged = (xn - x).abs() < SMALL;
373        if !converged {
374            return Err(LambertError::NoConvergence);
375        }
376
377        let a_orbit = s * (1.0 + lam).powi(2) * (1.0 + xn) * (l_ + xn) / (8.0 * xn);
378        let p = 2.0 * magr1 * magr2 * (1.0 + xn) * (dnu * 0.5).sin().powi(2)
379            / (s * (1.0 + lam).powi(2) * (l_ + xn));
380        let ecc = (1.0 - p / a_orbit).abs().sqrt();
381        finite_or_err(hodograph(r1, r2, v1, p, ecc, dnu, dtsec))
382    } else {
383        // Standard / low energy case
384        let mut x = 10.0;
385        let max_loops = 30;
386        let mut loops = 0;
387        let mut y = 0.0;
388
389        while (xn - x).abs() >= SMALL && loops < max_loops {
390            x = xn;
391            loops += 1;
392
393            let (h1, h2) = if nrev > 0 {
394                let temp = 1.0 / ((1.0 + 2.0 * x + l_) * (4.0 * x * x));
395                let temp1 = (nrev as f64 * PI * 0.5 + x.sqrt().atan()) / x.sqrt();
396                let h1 =
397                    temp * (l_ + x).powi(2) * (3.0 * (1.0 + x).powi(2) * temp1 - (3.0 + 5.0 * x));
398                let h2 = temp * m * ((x * x - x * (1.0 + l_) - 3.0 * l_) * temp1 + (3.0 * l_ + x));
399                (h1, h2)
400            } else {
401                let tempx = seebatt(x);
402                let denom = 1.0 / ((1.0 + 2.0 * x + l_) * (4.0 * x + tempx * (3.0 + x)));
403                let h1 = (l_ + x).powi(2) * (1.0 + 3.0 * x + tempx) * denom;
404                let h2 = m * (x - l_ + tempx) * denom;
405                (h1, h2)
406            };
407
408            let b = 0.25 * 27.0 * h2 / (1.0 + h1).powi(3);
409            let u = 0.5 * b / (1.0 + (1.0 + b).sqrt());
410            let k2 = kbatt(u);
411            y = (1.0 + h1) / 3.0 * (2.0 + (1.0 + b).sqrt() / (1.0 + 2.0 * u * k2 * k2));
412            xn = (((1.0 - l_) * 0.5).powi(2) + m / (y * y)).sqrt() - (1.0 + l_) * 0.5;
413        }
414
415        // Decide convergence by the final step size, not the loop count, so an
416        // iterate that settles on the last allowed pass is not falsely rejected;
417        // a diverging (NaN) step also fails this test and reports NoConvergence.
418        let converged = (xn - x).abs() < SMALL;
419        if !converged {
420            return Err(LambertError::NoConvergence);
421        }
422
423        let p = 2.0 * magr1 * magr2 * y * y * (1.0 + x).powi(2) * (dnu * 0.5).sin().powi(2)
424            / (m * s * (1.0 + lam).powi(2));
425        let ecc = (eps * eps
426            + 4.0 * magr2 / magr1 * (dnu * 0.5).sin().powi(2) * ((l_ - x) / (l_ + x)).powi(2))
427            / (eps * eps + 4.0 * magr2 / magr1 * (dnu * 0.5).sin().powi(2));
428        let ecc = ecc.sqrt();
429
430        finite_or_err(hodograph(r1, r2, v1, p, ecc, dnu, dtsec))
431    }
432}
433
434/// Pass the transfer velocities through only if every component is finite,
435/// otherwise report a non-finite result as a typed error.
436fn finite_or_err(vels: ([f64; 3], [f64; 3])) -> Result<([f64; 3], [f64; 3]), LambertError> {
437    let (v1t, v2t) = vels;
438    if all_finite(&v1t) && all_finite(&v2t) {
439        Ok((v1t, v2t))
440    } else {
441        Err(LambertError::NonFiniteValue)
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    // Vallado reference: RE = 6378.1363 km, values via the valladopy suite.
450    const RE: f64 = 6378.1363;
451    const DTSEC: f64 = 92854.234;
452    const NREV: i32 = 1;
453
454    fn inputs() -> ([f64; 3], [f64; 3], [f64; 3]) {
455        let r1 = [2.5 * RE, 0.0, 0.0];
456        let r2 = [1.9151111 * RE, 1.6069690 * RE, 0.0];
457        let v1 = [0.0, 4.999792554221911, 0.0];
458        (r1, r2, v1)
459    }
460
461    fn assert_close(actual: f64, expected: f64, label: &str) {
462        if expected == 0.0 {
463            assert!(actual.abs() < 1e-10, "{label}: expected ~0, got {actual}");
464        } else {
465            let rel = ((actual - expected) / expected).abs();
466            assert!(rel < 1e-12, "{label}: relative error {rel:e} exceeds 1e-12");
467        }
468    }
469
470    #[test]
471    fn battin_short_high() {
472        let (r1, r2, v1) = inputs();
473        let (v1t, v2t) = battin(
474            &r1,
475            &r2,
476            &v1,
477            DirectionOfMotion::Short,
478            DirectionOfEnergy::High,
479            NREV,
480            DTSEC,
481        )
482        .unwrap();
483        assert_close(v1t[0], -0.8696153795282852, "v1t_x");
484        assert_close(v1t[1], 6.3351545812502374, "v1t_y");
485        assert_close(v1t[2], 0.0, "v1t_z");
486        assert_close(v2t[0], -3.405994961791248, "v2t_x");
487        assert_close(v2t[1], 5.41198791828363, "v2t_y");
488        assert_close(v2t[2], 0.0, "v2t_z");
489    }
490
491    #[test]
492    fn battin_short_low() {
493        let (r1, r2, v1) = inputs();
494        let (v1t, v2t) = battin(
495            &r1,
496            &r2,
497            &v1,
498            DirectionOfMotion::Short,
499            DirectionOfEnergy::Low,
500            NREV,
501            DTSEC,
502        )
503        .unwrap();
504        assert_close(v1t[0], 5.832522716212579, "v1t_x");
505        assert_close(v1t[1], 1.4319944881331306, "v1t_y");
506        assert_close(v2t[0], -5.388439978490882, "v2t_x");
507        assert_close(v2t[1], -2.652101898141935, "v2t_y");
508    }
509
510    #[test]
511    fn battin_long_high() {
512        let (r1, r2, v1) = inputs();
513        let (v1t, v2t) = battin(
514            &r1,
515            &r2,
516            &v1,
517            DirectionOfMotion::Long,
518            DirectionOfEnergy::High,
519            NREV,
520            DTSEC,
521        )
522        .unwrap();
523        assert_close(v1t[0], -6.241103309400493, "v1t_x");
524        assert_close(v1t[1], -1.351339299630816, "v1t_y");
525        assert_close(v2t[0], 5.649586715490154, "v2t_x");
526        assert_close(v2t[1], 2.976517897853268, "v2t_y");
527    }
528
529    #[test]
530    fn battin_long_low() {
531        let (r1, r2, v1) = inputs();
532        let (v1t, v2t) = battin(
533            &r1,
534            &r2,
535            &v1,
536            DirectionOfMotion::Long,
537            DirectionOfEnergy::Low,
538            NREV,
539            DTSEC,
540        )
541        .unwrap();
542        assert_close(v1t[0], 0.641119158614630, "v1t_x");
543        assert_close(v1t[1], -5.957501823796459, "v1t_y");
544        assert_close(v2t[0], 3.33828270226307, "v2t_x");
545        assert_close(v2t[1], -4.975814585231199, "v2t_y");
546    }
547
548    #[test]
549    fn battin_rejects_zero_position() {
550        let (_, r2, v1) = inputs();
551        let r1 = [0.0, 0.0, 0.0];
552        let err = battin(
553            &r1,
554            &r2,
555            &v1,
556            DirectionOfMotion::Short,
557            DirectionOfEnergy::Low,
558            NREV,
559            DTSEC,
560        )
561        .unwrap_err();
562        assert_eq!(err, LambertError::ZeroVector);
563    }
564
565    #[test]
566    fn battin_rejects_nonpositive_dtsec() {
567        let (r1, r2, v1) = inputs();
568        for bad in [0.0, -1.0] {
569            let err = battin(
570                &r1,
571                &r2,
572                &v1,
573                DirectionOfMotion::Short,
574                DirectionOfEnergy::Low,
575                NREV,
576                bad,
577            )
578            .unwrap_err();
579            assert_eq!(err, LambertError::NonPositiveTimeOfFlight);
580        }
581    }
582
583    #[test]
584    fn battin_rejects_overflowing_magnitude() {
585        // Finite but astronomically large inputs whose magnitudes/cross products
586        // overflow to infinity must not collapse into a fabricated finite Ok.
587        let r1 = [1e160, 0.0, 0.0];
588        let r2 = [0.0, 1e160, 0.0];
589        let v1 = [0.0, 5.0, 0.0];
590        let err = battin(
591            &r1,
592            &r2,
593            &v1,
594            DirectionOfMotion::Short,
595            DirectionOfEnergy::Low,
596            NREV,
597            DTSEC,
598        )
599        .unwrap_err();
600        assert_eq!(err, LambertError::NonFiniteValue);
601    }
602
603    #[test]
604    fn battin_rejects_collinear_endpoints() {
605        // r2 parallel to r1 (zero-degree transfer): transfer plane undefined.
606        let r1 = [2.5 * RE, 0.0, 0.0];
607        let r2 = [5.0 * RE, 0.0, 0.0];
608        let v1 = [0.0, 4.999792554221911, 0.0];
609        let err = battin(
610            &r1,
611            &r2,
612            &v1,
613            DirectionOfMotion::Short,
614            DirectionOfEnergy::Low,
615            NREV,
616            DTSEC,
617        )
618        .unwrap_err();
619        assert_eq!(err, LambertError::DegenerateGeometry);
620    }
621
622    #[test]
623    fn battin_rejects_bad_180_transfer() {
624        // 180-degree transfer (r2 anti-parallel to r1) with v1 collinear to r1,
625        // so the plane cannot be fixed from v1 either.
626        let r1 = [2.5 * RE, 0.0, 0.0];
627        let r2 = [-5.0 * RE, 0.0, 0.0];
628        let v1 = [3.0, 0.0, 0.0];
629        let err = battin(
630            &r1,
631            &r2,
632            &v1,
633            DirectionOfMotion::Short,
634            DirectionOfEnergy::Low,
635            NREV,
636            DTSEC,
637        )
638        .unwrap_err();
639        assert_eq!(err, LambertError::DegenerateGeometry);
640    }
641
642    #[test]
643    fn battin_rejects_nonfinite_input() {
644        let (_, r2, v1) = inputs();
645        let r1 = [f64::NAN, 0.0, 0.0];
646        let err = battin(
647            &r1,
648            &r2,
649            &v1,
650            DirectionOfMotion::Short,
651            DirectionOfEnergy::Low,
652            NREV,
653            DTSEC,
654        )
655        .unwrap_err();
656        assert_eq!(err, LambertError::NonFiniteValue);
657    }
658
659    #[test]
660    fn battin_high_energy_reports_nonconvergence() {
661        // A very short time of flight on the high-energy multi-rev branch does
662        // not converge within the iteration cap; surface it instead of
663        // returning the last iterate.
664        let (r1, r2, v1) = inputs();
665        let err = battin(
666            &r1,
667            &r2,
668            &v1,
669            DirectionOfMotion::Short,
670            DirectionOfEnergy::High,
671            5,
672            1.0,
673        )
674        .unwrap_err();
675        assert_eq!(err, LambertError::NoConvergence);
676    }
677}