lambert_izzo
A Rust port of Dario Izzo's revisited Lambert solver from the 2014 paper
"Revisiting Lambert's Problem"
(arXiv:1403.2705 / Celestial Mechanics &
Dynamical Astronomy). A local copy of the paper lives at
docs/izzo.pdf;
for a friendly intro to the problem, read
docs/concepts.md
first.
Stable at 1.0.0. Breaking changes follow strict semver; the
CHANGELOG.md
is the source of truth for migration notes between releases.
What this solves (and what it doesn't)
| Capability | Supported |
|---|---|
| Single-revolution two-body transfers (elliptic, parabolic, hyperbolic) | ✅ |
| Multi-revolution transfers, both long- and short-period branches | ✅ |
| Short-way and long-way arc selection | ✅ |
| Frame-invariant inputs/outputs (any inertial frame the caller chooses) | ✅ |
no_std and WASM (wasm32-unknown-unknown) builds |
✅ |
| Optional Rayon-backed parallel batch evaluation | ✅ |
| Patched-conic / sphere-of-influence transitions | ❌ caller |
| Lunar swing-by, n-body perturbations, J2/J3 effects | ❌ caller |
| Low-thrust / continuous-thrust transfers | ❌ |
| Outer-loop optimization (porkchop-min, primer-vector, …) | ❌ caller |
"❌ caller" means this is the right Lambert solver to call from inside those higher-level routines — but the routine itself isn't part of this crate.
Supports:
- Single-revolution transfers
- Multi-revolution transfers (long-period and short-period branches per revolution count)
- Short-way and long-way transfers via
TransferWay::Short/TransferWay::Long. Prograde vs retrograde is the caller's choice through the(r1, r2)ordering, sincer1 × r2defines the resulting orbit's angular-momentum direction - Hyperbolic transfers on the single-rev branch
no_std-friendly — pulls onlyarrayvec,num-traits(withlibm), andthiserror(std-feature off) at runtime- WASM-compatible math kernel
(
cargo build --target wasm32-unknown-unknown -p lambert_izzo --lib) - Zero hard math-library dependency — public surface is
[f64; 3]
Features
| Feature | Default | Effect |
|---|---|---|
serde |
off | Adds Serialize/Deserialize derives on every public type, including LambertError. Stays no_std-friendly. |
rayon |
off | Enables lambert_par for parallel batch evaluation. Pulls in std transitively — incompatible with no_std. |
Need a Kepler propagator to round-trip Lambert solutions in your own tests? It used to live behind a
test-utilsfeature here; it now sits in the workspace-internallambert_izzo_test_supportcrate (publish = false). External consumers should vendor or reimplement the propagator — it's ~80 lines of universal-variable / Stumpff math.
MSRV: Rust 1.85 (the first release with edition 2024 stable).
Units
The crate is unit-agnostic at the type level — every quantity is plain
f64 or [f64; 3]. The convention used in the docs and examples is SI
for astrodynamics work:
| Quantity | Unit |
|---|---|
| Position | km |
| Velocity | km/s |
| Time of flight | s |
| Gravitational parameter | km³/s² |
Any consistent unit system works — pass r in meters and mu in m³/s²
and you get velocities in m/s. The math is dimensionally homogeneous;
unit safety is the caller's responsibility, helped by your own variable
names (r1_eci_km, mu_earth_km3_s2, etc.) at the call site.
The algorithm is also frame-invariant under any inertial frame — pass
r1, r2 in the same inertial frame (ECI, HCRS, MCI, …) and the
returned velocities are in that same frame. The function signature is
frame-agnostic; the calling code's variable names carry the frame info.
Usage
use ;
// LEO → MEO Hohmann transfer.
let mu = 398_600.441_8;
let r1 = ;
let r2 = ; // 1 km off-axis avoids colinearity
let a = f64midpoint;
let tof = PI * .sqrt;
let solutions = lambert.unwrap;
let v1 = solutions.single.v1;
let v2 = solutions.single.v2;
let iters = solutions.diagnostics.single.iters; // Householder iter count
The signature:
LambertInput carries the boundary problem and budget:
The returned LambertSolutions carries the single-revolution trajectory,
every reachable multi-rev pair, and per-branch diagnostics in one shape:
Diagnostics ride along inside every successful solve — there is no
separate solve_with_diagnostics entry point. Iterate the multi-rev
branches alongside their per-branch diagnostics:
let solutions = lambert?;
println!;
for in solutions
.multi
.iter
.zip
# Ok::
For the porkchop-plot pattern (you want both transfer directions), call
lambert twice with the two TransferWay values:
let short = lambert?;
let long = lambert?;
LambertError is a thiserror enum with structured fields:
match lambert
Revolution budget
For multi-rev requests, construct the budget through the validated
BoundedRevs type. Out-of-range counts (0 or > 32) fail at
construction time with RevsOutOfRange, never at solver runtime:
use ;
// Common path: ergonomic fallible constructor.
let revolutions = try_up_to?; // 5 ∈ 1..=32
// Explicit: skip multi-rev entirely.
let single_only = SingleOnly;
// Out-of-range request: fails before any solver work.
let bad: = try_up_to;
assert!;
# Ok::
The cap is BoundedRevs::MAX = 32, type-equal to the
MAX_MULTI_REV_PAIRS capacity of the bounded return collection. The
validation round-trips all the way through: MultiRevPair::n_revs and
MultiRevPairDiagnostics::n_revs are themselves BoundedRevs, so any
revolution count appearing in a returned solution is statically
guaranteed to lie in 1..=BoundedRevs::MAX. Call .get() to extract
the raw u32 when needed.
When the solver silently drops higher-M branches (their T_min(M)
exceeds the requested tof), call solutions.max_feasible_revs() to get
the highest M that actually produced a (long_period, short_period)
pair. It returns Some(b) equal to solutions.multi.last().unwrap().n_revs,
and None for RevolutionBudget::SingleOnly or when no multi-rev branch
was feasible at all. Use it to detect the silent-skip boundary without
having to compare requested vs returned counts by hand.
Math-library interop
The crate has no hard math-library dependency. Both
nalgebra::Vector3<f64> and glam::DVec3 already convert to/from
[f64; 3] natively, so callers using either library can pass and
receive vectors without any feature flag:
// nalgebra:
let r1: = new.into;
let v1: Vector3 = solutions.single.v1.into;
// glam:
let r2 = new.to_array;
let v2 = from_array;
Validation
The stress example runs 100,000 random Earth-orbit geometries each for
single-rev and multi-rev (up to M = 5), checking vis-viva energy and
angular-momentum conservation between the returned (v1, v2) pair.
Random ranges:
- Single-rev:
r ∈ [3500, 28_000]km,tof ∈ [100, 50_000]s - Multi-rev:
r ∈ [5600, 10_500]km,tof ∈ [10_000, 250_000]s
| Mode | Convergence | Avg iters | Paper avg | Max iters | Max ΔE/E | Max ΔL/L |
|---|---|---|---|---|---|---|
| Single-rev | 100% | 2.083 | 2.1 | 3 | 1.44e-12 | 4.14e-12 |
| Multi-rev | 100% | 2.992 | 3.3 | 7 | 3.02e-14 | 5.63e-14 |
Iteration counts match the paper's reported figures; conservation residuals sit at f64 round-off.
Performance
Criterion benchmarks under crates/lambert_izzo/benches/. Numbers below
are from an Apple Silicon laptop (release profile, single thread except
for the parallel batch row).
| Workload | Throughput | Per call |
|---|---|---|
| Single-rev (random Earth orbits) | ~3.1 M calls/s | ~320 ns |
Multi-rev M=1 (Earth orbits) |
~1.5 M calls/s | ~650 ns |
Multi-rev M=3 (Earth orbits) |
~770 K calls/s | ~1.3 µs |
Multi-rev M=5 (Earth orbits) |
~520 K calls/s | ~1.9 µs |
| Battin near-parabolic (177°, single-rev) | ~4.2 M calls/s | ~240 ns |
Sequential batch (loop over lambert) |
~1.2 M calls/s | ~830 ns |
Parallel batch via lambert_par (rayon) |
~8.8 M calls/s | ~114 ns |
The parallel batch shows ~7.3× speedup over sequential on this machine. Reproduce with:
cargo bench --bench single_rev
cargo bench --bench multi_rev
cargo bench --bench batch --features rayon
The multi_rev bench contains two Criterion groups: multi_rev
(parametrized across M ∈ {1, 3, 5}) and multi_rev_battin (a
deterministic ~177° geometry whose solutions land in the Battin
near-parabolic regime, |x − 1| < BATTIN_THRESHOLD). The
"Battin near-parabolic" row above comes from the latter; filter to
just that group with
cargo bench --bench multi_rev -- multi_rev_battin.
Batch / streaming API
For porkchop plots, multi-shooter loops, or any workload with thousands
of Lambert calls, build a Vec<LambertInput> and either iterate
sequentially or — under the rayon feature — use lambert_par for
parallel evaluation:
use ;
let inputs: =
.map
.collect;
// Sequential:
let total_dv: f64 = inputs
.iter
.filter_map
.map
.sum;
With --features rayon, the work fans out across the thread pool
through lambert_par:
use lambert_par;
use ParallelIterator;
let total_dv: f64 = lambert_par
.filter_map
.map
.sum;
Building
cargo build --release
cargo test --release
cargo run --release --example demo
cargo run --release --example stress
cargo run --release --example errors
cargo run --release --example batch --features rayon
cargo run --release --example serde --features serde
The errors example walks every LambertError variant — useful as a
template for caller-side error handling. The batch example demonstrates
lambert_par over 10 000 randomized inputs and reports throughput. The
serde example shows the JSON shape of LambertSolutions and
LambertError and round-trips both through serde_json.
Toolchain pinned via rust-toolchain.toml (1.88.0) for development;
MSRV declared in Cargo.toml is 1.85. Edition 2024. Runtime
dependencies are thiserror (no_std mode)
for the error type, arrayvec (no_std) for
the bounded multi-rev return, and
num-traits (with libm) for no_std
math.
Implementation notes
- Modular layout under
src/:constants.rs— every named tolerance with rationale.error.rs— structuredLambertErrorenum.vec3.rs— minimal internal[f64; 3]helpers:dot,cross,norm,scale. Trivial component-wise operations are inlined at their call sites.geometry.rs— chord, semi-perimeter, λ, transfer-plane basis; constructed once per call.tof.rs— three-regime TOF dispatch + analytic derivatives (Eq. 22). The_with_yvariants accept a precomputedy = √(1 − λ²(1 − x²))so the Householder loop computes it once per step instead of twice.root_finding.rs— Householder (Eq. 30/31 starters) + HalleyT_minsearch.bounded_revs.rs—BoundedRevsnewtype +RevsOutOfRangeconstruction error.multi_rev.rs—MultiRevSetandMultiRevDiagnostics, bounded-collection newtypes wrappingArrayVecso it stays out of the public API.lib.rs— public API surface and thelambertentry point.tests/— per-scenario test modules (single_rev,multi_rev,errors,regimes,interop,kepler_roundtrip).
- TOF evaluation blends Battin's series (Eq. 20) for
|x − 1| ≤ 0.01, Lancaster–Blanchard (Eq. 18) for0.01 < |x − 1| ≤ 0.2, and Lagrange (Eq. 9) elsewhere. The Battin path uses a direct series sum of2F1(3, 1; 5/2; S1). - Root finding uses Householder's third-order method per the paper, with
separate tolerances
1e-5forM = 0and1e-8forM > 0. - For multi-rev,
T_minis found via Halley's method ondT/dx = 0before deciding which revolution counts admit solutions. - Initial guesses follow Eq. 30 (single-rev) and Eq. 31 (multi-rev).
- Velocity reconstruction follows Algorithm 1.
- Multi-rev branches are bounded by
MAX_MULTI_REV_PAIRS = 32, with the cap enforced at the type level viaBoundedRevs(out-of-range requests fail withRevsOutOfRangeat construction time). The boundedArrayVecreturn meanslambert(...)does no heap allocation on any code path (MAX_MULTI_REV_PAIRS * sizeof(MultiRevPair)lives on the stack).
License
MIT OR Apache-2.0