Simulates and optimizes powered descent for a non-returning lunar lander. The
terminal-descent model is a 2D flat-Moon plane (downrange x, altitude h) with
constant g = 1.62 m/s²; optional --from-orbit mode adds a curvilinear 2D
polar descent from parking orbit. Python 3.11+, NumPy/SciPy (matplotlib for
plots).
# single optimization (fixed propellant load)
python -m lunar_lander --h0 2500 --vx0 150 --m-prop 250 --plot
# outer propellant-sizing loop (finds the m_prop_usable that lands with
# exactly the reserve remaining)
python -m lunar_lander --h0 2500 --vx0 150 --size
# tests
python -m pytestFive phases coast | burn 1 (braking) | coast | burn 2 | linear ramp-down,
parameterized by durations x = [t0, d1, dc, d2, dr]. SLSQP minimizes the
propellant fraction consumed. During optimization it integrates the smooth
model to scheduled terminal time t4, enforces h(t4) = 0, touchdown velocity
windows, the propellant reserve, and a minimum-altitude path constraint. Final
acceptance is verified with the ground-contact event. A Nelder–Mead penalty
formulation is available with --penalty-fallback for debugging.
The mass model is ported from the audited reference in mass_estimate/
(2026-07-24 plausibility audit against Huzel & Huang 1992, Sutton 2017, SMAD
3rd ed. and Gamgami 2024 — see mass_estimate/README.md). Tanks are a
thin-wall Ti-sphere physics build-up (the earlier linear coefficient 299.8·V
was a helium-COPV mass class, ~8–10× too heavy for 16 bar liquid tanks);
structure/thermal/harness are fractions of the pre-margin dry mass, gear 6 %
of MECO mass, margin 20 %, plus bottom-up pressurization, RCS, avionics and
power. Validation anchors (200 kg payload, 800 kg usable): dry 612.8 kg,
wet 1452.8 kg, Δv 2447.7 m/s, σ = 0.491 — pinned in
tests/test_mass_model.py together with an exact cross-check against
mass_estimate/main.py.
- Payload sets feasibility. The default mission carries 80 kg (feasible range ≈ 70–90 kg). At the full 1000 kg propellant load this gives T/W(ignition) ≈ 1.02 — hover-marginal, a known open design point: the intended resolutions are a sixth engine or letting the sizing loop trade propellant. A 500 kg payload with 1000 kg of propellant (the original spec guess) has T/W(ignition) ≈ 0.70 and cannot land from 2500 m under any schedule: the hover mass is T/g ≈ 1512 kg and the vehicle would need ≈ 660 kg burned (≈ 825 s at full throttle) before it can arrest its own descent, while it reaches the ground in well under two minutes even thrusting straight up.
- The flat-Moon terminal model provides no centrifugal relief at near-orbital
speed; use
--from-orbitfor the curvilinear 2D descent-from-orbit model.
The tool handles infeasibility honestly:
mass_model.estimate_masswarns loudly when T/W(ignition) < 1;optimize.feasibility_precheckintegrates a dominating full-vertical-thrust profile (an upper bound on every steering/throttle policy's ability to arrest descent while it is still burning) and reports a clear infeasibility message instead of a bad trajectory;- the outer sizing loop (
--size) shrinks m_prop_usable until the mission closes: smaller propellant load → lighter vehicle → higher T/W.
For a comfortable fixed-load run at the default payload, use e.g.
--m-prop 250 (T/W(ignition) ≈ 2.4).
The optimizer now uses rtol/atol = 1e-8, an SLSQP finite-difference step tied
to that tolerance, shared finite-difference caching, and deterministic warm
starts. Fixed-load and sizing runs should complete in seconds on the benchmark
cases; full descent-from-orbit runs in tens of seconds. See
OPTIMIZER_PERFORMANCE.md for measured before/after timings and the remaining
cold-start limitation near the critical orbit load.
The optimizer enforces h(t4) = 0 on a smooth no-ground-event terminal run and
treats touchdown velocities as acceptance-window inequalities. Final acceptance
is judged on a separate ground-contact verification run: a trajectory that
touches down softly (|vh| ≤ 1 m/s, |vx| ≤ 0.5 m/s, reserve intact) before the
scheduled t4 is a landed mission. Engines cut at contact, and any unflown
ramp tail is reported as schedule_tail_s.
Default steering is retrograde with a smooth blend to vertical thrust below
v_min = 3 m/s. Two deliberate refinements over a naive angle interpolation
(both documented in dynamics.RetrogradeSteering): the retrograde vertical
component is clamped to ≥ 0 (never thrust toward the ground), and the
low-speed blend interpolates the direction vector with a Lipschitz-bounded
normalization. Without these, optimizer trial schedules that burn past descent
arrest (vehicle briefly ascending) hit a 180° thrust-direction discontinuity
at vx = 0, which stalls the integrator. In every descending state the law is
exactly retrograde / vertical-guard per the spec.
lunar_lander/
config.py # frozen Config dataclass (all defaults from the spec)
mass_model.py # fixed-point mass estimate (tanks, gear, margin)
dynamics.py # RHS, steering laws, throttle schedule
simulate.py # phase-wise RK45 driver, Trajectory object
optimize.py # SLSQP wrapper, analytic guesses, scaling, diagnostics
size_propellant.py # outer propellant sizing (margin walk, warm starts)
full_descent.py # circular-orbit deorbit + curvilinear descent model
plots.py # trajectory, time-history and thrust-angle figures
cli.py # argparse entry point
tests/ # pytest suite (analytic pins and regressions)
- No throttle floor yet: real hypergolic engines cannot throttle continuously to 0 — a minimum-throttle constraint on the ramp phase is a likely follow-up.
- Neither model includes Moon rotation, attitude dynamics, gimbal limits, 3D, or dispersion analysis.