Dynamic blade-element momentum rotor aerodynamics — helicopter and wind-turbine modes in one code path.
dynbem is a rotor-aerodynamics library built around a multi-element
blade-element-momentum (BEM) solver coupled to dynamic-inflow models. It is
designed to be numerically valid across the full operating envelope —
helicopter hover, axial climb, axial descent, vortex-ring state (VRS),
windmill-brake state (WBS), autorotation, and wind-turbine power extraction
— without switching equations or sign conventions between regimes.
The math core is a pure-Rust crate (dynbem_rs/, no pyo3 /
numpy / file IO) wrapped by a thin PyO3 + maturin binding crate
(dynbem/) which is the publishable Python package.
Two dynamic-inflow models are provided:
- Pitt-Peters (three-state global ν₀/ν_s/ν_c) — with the Peters L-matrix, Glauert wake-skew via the off-diagonal coupling, and the Leishman empirical VRS polynomial baked into the uniform-inflow state.
- Øye 2-stage annular — per-annulus filtered momentum inflow (the OpenFAST DBEMT formulation), independent across radii and numerically stable at high advance ratios where Pitt-Peters becomes stiff.
Both models share a tabulated polar interpolator and a common BEM ψ-loop
kernel (dynbem_rs/src/bem_common.rs) and
plug into the same AeroModel trait (Rust).
The repo also includes a flight-envelope sweep driver
(envelope/compute_map.py), a cyclic-trim solver
(dynbem_rs/src/trim.rs), and a point-mass +
cyclic-pitch attitude simulator.
For empirical validation against published rotor data (Castles-Gray
TN-2474 vertical descent, Caradonna-Tung TM-81232 hover CT and
spanwise CL, Harrington TN-2318 full-scale hover, Wheatley & Hood
TR 515 forward-flight autorotation), see
EMPIRICAL_VALIDATION.md.
Coordinates are NED throughout; rotor rotation is CCW-from-above (American helicopter convention).
Quick start (all platforms):
./setup.sh # POSIX shell or Windows git-bash/WSL
setup.cmd # Windows cmd/PowerShell thin wrapper (calls bash internally)
Both scripts check prerequisites (Python 3.10+, cargo, C compiler) upfront, create a virtual environment, install dependencies, and build the Rust extension via maturin. See setup.sh for details on supported platforms.
Manual setup (if you prefer not to use the setup scripts):
The repo is a uv workspace + Cargo workspace. The Rust extension is built
automatically (via maturin) by uv sync:
uv sync # builds dynbem (Rust extension) editable
uv sync --group dev # also installs pytest, maturin, build, twine
uv run pytest tests/ # run the test suite
The publishable Python package is dynbem/ (Rust-backed via
PyO3). Inside a non-uv environment you can still pip install -e dynbem/
— maturin will pick up dynbem/pyproject.toml and compile the extension
against the sibling dynbem_rs/ crate. Requires a working
Rust toolchain (rustup stable).
import numpy as np
import dynbem
# Load rotor definition from YAML. Parsing happens in Rust (dynbem_rs)
# via PyO3 bindings; pure-Rust callers can use RotorDefinition::from_yaml_file(path).
defn = dynbem.rotor_definition.load("rotors/castles_gray_6ft/rotor.yaml")
model = dynbem.create_aero(defn, model="pitt_peters") # or "oye", "bem"
state = model.initial_rotor_state()
omega = 125.7 # rad/s -- caller owns mechanical state
inputs = dynbem.RotorInputs(
collective_rad=0.14,
tilt_lon=0.0, tilt_lat=0.0, # swashplate (helicopter-standard signs)
R_hub=np.eye(3),
v_hub_world=np.zeros(3),
wind_world=np.zeros(3),
t=0.0,
omega_rad_s=omega, # rotor speed passed in each call
)
result, derivative = model.compute_forces(inputs, state)
# result.F_world, result.M_orbital, result.M_spin, result.Q_spin
# derivative carries d/dt of the dynamic-inflow states (lambda_0/c/s or W/W_int)
# Mechanical ODE lives in the caller:
# from dynbem.mechanical import omega_derivative
# omega += dt * omega_derivative(result.Q_spin, motor_torque_Nm, I_ode_kgm2)For the full API reference — all classes, fields, keyword arguments, and return types — see API.md.
run_map.cmd # quick grid, saves to out\map.npz, plots to out\
run_map.cmd --full --save out\map.npz --plot out\ # full grid
uv run python -m envelope.compute_map --help
uv run pytest tests/ -q
If uv is not on PATH in your shell, run pytest with the workspace
interpreter directly:
c:/repos/aero/.venv/Scripts/python.exe -m pytest tests/ -q
The tests/ directory contains unit tests, validation scripts against
published rotor data, and end-to-end force-balance / frame-transform
checks. Whole-dataset validation sweeps against each paper live in
verification/ and are imported by the matching
tests/test_<paper>_<quantity>.py in sampled mode -- one source of
BEM-driver logic, fast tests, and a full-sweep script you can re-run to
refresh aggregate bounds. For which papers and tables the models are
checked against, what the achieved variance is, and the physical
reasons for any residual bias, see
EMPIRICAL_VALIDATION.md.
The Rust crate includes a stable Criterion benchmark suite at
dynbem_rs/benches/model_kernels.rs.
Use this as the baseline before and after any performance refactor.
solve_bem_element: single-element BEM hot kernel costsweep_scalar/prescribed/...: scalar psi-loop sweep at fixed(n_psi, n_elements)models_compute_forces/{bem,pitt_peters,oye}: model-levelcompute_forcescost
cargo bench -p dynbem_rs --bench model_kernels --no-run
cargo bench -p dynbem_rs --bench model_kernels -- "sweep_scalar|models_compute_forces" --sample-size 20 --measurement-time 3
For change validation, run the same command before/after your patch and compare medians in each benchmark group.
A standalone profiling binary is included at
dynbem_rs/bin/profile_kernels.rs.
It builds with normal package builds (see [[bin]] in
dynbem_rs/Cargo.toml).
cargo build --release -p dynbem_rs
./target/release/profile_kernels.exe oye
./target/release/profile_kernels.exe pitt_peters
./target/release/profile_kernels.exe solve_bem_element
Release profiles in the workspace keep debuginfo enabled so external profilers can resolve symbols.
CLAUDE.mdis the Claude Code instruction file and remains useful if Claude-based agents are in your workflow.- GitHub tooling (Copilot coding agent / GitHub CLI agent workflows)
does not use
CLAUDE.mdas its default instruction file. - For GitHub-side defaults, use
AGENTS.md(repo-level coding-agent instructions), and optionally.github/copilot-instructions.mdfor Copilot-specific repository guidance.
Recommendation: keep CLAUDE.md if you use Claude tools, but add and
maintain an AGENTS.md so GitHub agent flows pick up the same policy.
This project uses NED (North-East-Down) throughout, without exception:
- X = North, Y = East, Z = Down
- Gravity acts in the +Z direction
- Rotor thrust (upward lift) is negative Z in world frame:
F_world[2] < 0 - Wind blowing upward (driving a flying turbine) is negative Z in world frame
R_hubrotates from hub frame → NED world frame
Most helicopter and wind-turbine literature uses one of:
- SAE / helicopter: X forward, Y right, Z down (body frame, not world NED)
- Wind-turbine (IEC 61400): X downwind, Y lateral, Z up (ENU-like)
- Aeronautics (NED): X North, Y East, Z Down
When adapting equations or sign conventions from papers, always check which frame the authors use. Windmill-brake-state and axial-induction literature (Glauert, Buhl) often defines positive inflow upward (opposing thrust), which is negative Z here. Flip signs accordingly.
For a rotor disk lying in the XY-plane (hub pointing down):
lambda(inflow ratio) is positive when flow passes through the disk from above (downward, +Z direction), i.e. in normal rotor mode (helicopter hover).- In windmill / autorotation mode the wind drives flow upward (−Z),
so
lambdais negative when the rotor is in energy-harvesting mode. - Collective pitch
theta_0 > 0pitches blade leading edge up (toward −Z thrust).
The model is built in phases from simple to state-of-the-art, each a
drop-in upgrade behind the same AeroModel interface.
- Multi-element BEM loop (radial quadrature over
n_elementsannuli) - Hover-safe inflow iteration on
λ_r(not wind-turbine induction factora) - Per-element Prandtl tip + hub loss
F = F_tip · F_hub(both factors exported fromdynbem.bem) - Glauert / Buhl Windmill Brake State correction (quadratic root selection)
- Forward-flight ψ-loop: per-azimuth blade pitch (cyclic), tangential wind projection (advancing/retreating), and in-plane hub moment accumulation (M_orbital)
- Pure-aero interface:
omega_rad_sis passed inRotorInputsevery call; the model returns no mechanical derivative. The caller integrates rotor speed externally viadynbem.mechanical.omega_derivative. - Returns
QuasiStaticRotorStatederivative (empty — inflow is quasi-static; the state carries no fields) - Validation: see EMPIRICAL_VALIDATION.md.
PittPetersModelimplemented in Rust atdynbem_rs/src/pitt_peters.rs, exposed to Python via the maturin extension indynbem/- Prescribed-inflow blade element loop with per-element Prandtl tip + hub
loss; blade sees
λ_total = λ_0 + v_climb/ΩR(induced state + freestream), so WBS and autorotation work correctly - Pitt-Peters ODE in matrix form (Peters 2009 Eq 7, hub axes):
[M] dλ/dt + V·[L]⁻¹ λ = forcingwithM = diag(8/(3π), 16/(45π), 16/(45π))→τ_0 = 8R/(3πV_T),τ_cs = 16R/(45πV_T). - Steady-state targets follow the canonical L matrix (Peters Eq 10) with
X = tan(χ/2), translated to our ψ=0-at-+X convention:
The
λ_0_ss = C_T/(2·µ_T) + (15π·X/64) · C_M_hub / µ_T λ_c_ss = −(15π·X/64) · C_T + 4·cos(χ)/(1+cos χ) · C_M_hub) / µ_T λ_s_ss = 4/(1+cos χ) · C_L_hub / µ_T−(15π·X/64)·C_Tcross-coupling in λ_c_ss is the Pitt-Peters term that produces Glauert wake-skew naturally from thrust forcing — no closed-form Glauert tilt needed. - Cyclic input (
tilt_lon,tilt_lat) wired through both models: blade pitchθ(ψ) = collective + θ_1c·cos(ψ) + θ_1s·sin(ψ)with helicopter-standard signs (tilt_lon > 0→ nose-down,tilt_lat > 0→ roll right). Seedynbem_rs/src/cyclic.rs. - In-plane hub moments returned via
AeroResult.M_orbital(Mx_hub, My_hubaccumulated in the ψ-loop) — needed for cyclic to produce vehicle attitude response in the outer loop. - VRS empirical correction (Leishman 2000, fit to Castles-Gray
data): in 0 < λ₂ < 2,
λ_0_sscomes from the polynomialλ₁/V_h = 1 + 1.125λ₂ − 1.372λ₂² + 1.718λ₂³ − 0.655λ₂⁴rather than momentum theory, preventing the Level-1 CT blow-up in VRS. Cross-coupling is also skipped in the VRS regime. - Canonical reference: Peters, D.A. (2009), "How Dynamic Inflow
Survives in the Competitive World of Rotorcraft Aerodynamics: The
Alexander Nikolsky Honorary Lecture," JAHS 54(1):011001. PDF and
extraction notes in
Research/Peters_Nikolsky_2008/. - Validation: see EMPIRICAL_VALIDATION.md.
- Known limitations:
- VRS CT still rises to ~2× nominal in deep VRS (λ₂ ≈ 1.5–2) at fixed θ; real rotor stays near nominal (paper: θ barely adjusts). The Leishman polynomial shifts the operating point but doesn't fully suppress it.
- Autorotation torque crossing at V/ΩR ≈ 0.14 vs paper's 0.083.
- Mass-flow scaling uses
µ_T = √(µ²+λ²)(classical Glauert) rather than Peters' Eq 8V = (µ²+(λ+ν)(λ+2ν))/√(µ²+(λ+ν)²). They agree in high-speed forward flight but differ by 2× in hover. Switching would need validation against a hover dataset. - Wind-axis rotation of the L-matrix is NOT applied; oblique flight
µ_y ≠ 0is approximate. Exact for axial and pure-longitudinal flight. A previous implementation was reverted because it destabilised the tethered-rotor envelope.
OyeBEMModelindynbem_rs/src/oye.rs(pure-Rust ψ-loop), exposed to Python via the maturin extension- Annulus-local inflow: each radial annulus has its own pair of
first-order lag filters
(W_int, W)chasing the quasi-steady momentum targetW_qs. No global L-matrix; no λ_c/λ_s harmonic states. - Two time constants per annulus (Øye 1990, OpenFAST AD Theory §6.3.4):
with empirical coupling
τ₁ = 1.1 / (1 − 1.3·min(a, 0.5)) · R / V_∞ τ₂(r) = (0.39 − 0.26·(r/R)²) · τ₁ τ₁·dW_int/dt + W_int = W_qs + k·τ₁·dW_qs/dt τ₂·dW/dt + W = W_intk = 0.6. DBEMT_Mod=1 equivalent (dW_qs/dt = 0across each outer step — exact for envelope sweeps). - W_qs per annulus from Glauert momentum balance using rotor-mean
µ_T = V_T / Ω·R:W_qs[i] = dCT/dx[i] / (4·x[i]·µ_T). - Same VRS override (Leishman polynomial) as Pitt-Peters for
0 < V_descent/V_h < 2— applied uniformly across annuli. - Same cyclic-pitch wiring (
tilt_lon/tilt_lat→ per-ψ blade pitch) and same in-plane hub moments returned viaM_orbital. - Why this alongside Pitt-Peters: Pitt-Peters' L-matrix couples thrust + hub moments back into all three inflow harmonics globally, which produces a stiff BEM-driven feedback at high advance ratios and in descent + edgewise wind. Øye's annulus-local filters are independent → no feedback loop → numerically stable in the same regimes that needed adaptive time-stepping with Pitt-Peters. OpenFAST's DBEMT uses the same Øye-style formulation for this reason.
- Trade-off: no harmonic inflow states means the inflow doesn't
develop a
λ_c-like tilt in response to cyclic pitching moments, sotests/test_cyclic.py::test_cyclic_inflow_reduces_hub_moment(which checks PP's specific feedback mechanism) doesn't apply. Cyclic control still works (hub moments respond correctly to swashplate inputs), but cyclic inflow feedback is absent. - Validation: see EMPIRICAL_VALIDATION.md.
- References:
- Øye, S. (1990). A simple vortex model. IEA Symposium.
- Snel, H. & Schepers, J.G. (1995). Joint investigation of dynamic inflow effects. ECN.
- OpenFAST AeroDyn Theory v3.5, §6.3.4 (DBEMT).
Wired into all BEM and dynamic-inflow models above:
- Oblique inflow: advance ratio
µ = V_edge / (Ω·R)≠ 0 - Blade azimuth-dependent ψ-loop velocity (
n_psi=36stations by default, automatically triggered whenµ > 0.01, cyclic input is nonzero, or cyclic inflow state is nonzero) - Per-azimuth cyclic blade pitch:
θ(ψ) = collective + θ_1c·cos(ψ) + θ_1s·sin(ψ) - In-plane hub moments
Mx_hub,My_hubreturned viaAeroResult.M_orbital(needed for vehicle roll/pitch response to cyclic control) - Pitt-Peters L matrix off-diagonal
−L_off·C_Tproduces Glauert wake-skew naturally from thrust forcing (exact for axial and pure-longitudinal flight; approximate for obliqueµ_y ≠ 0)
- 9-state (or higher-order) Peters-He inflow model
- Requires new
PetersHeRotorStatedataclass - Captures higher harmonics of the inflow distribution
- Best accuracy for maneuvering flight and aeroelastic coupling
- Validation: see EMPIRICAL_VALIDATION.md.
The standard wind-turbine BEM uses the induction factor a = v_i / V_inf,
which collapses to zero in hover (V_inf = 0). This code instead
iterates on the total inflow ratio λ_r = v_a / (Ω·R), where v_a
is the total axial velocity at the disk (external freestream + induced).
The combined momentum-BEM equation at each annulus is:
k·(λ_r² + x²) = λ_r·(λ_r − λ_c)
where k = σ_r·cn / (8·F), x = r/R, and λ_c = v_climb / (Ω·R).
This quadratic is solved per iteration step; v_climb = 0 in hover is
handled naturally (gives the standard hover solution
λ_r = x·sqrt(k/(1−k))).
v_climb = dot(v_rel_world, hub_axis_ned) (no negation):
v_climb > 0: air flows downward through disk (helicopter climb / normal inflow)v_climb = 0: hoverv_climb < 0: air flows upward through disk (autorotation / flying wind turbine)
The quadratic has two roots. Selection is by operating mode:
- Helicopter / hover (
λ_c ≥ 0): take the positive root (λ_r > 0) - Turbine / autorotation (
λ_c < 0): take the negative root (λ_r < 0)
In autorotation (upward wind, λ_c < 0):
λ_r < 0→φ < 0→ct = cl·sin(φ) − cd·cos(φ) < 0→Q_total < 0d_omega = (−Q_total + Q_motor) / I→ positive angular acceleration ✓
In powered/hover mode (λ_c ≥ 0):
Q_total > 0(aerodynamic drag on rotor) →d_omega < 0without motor torque ✓
F_world = −T_total · hub_axis_ned
T_total is always positive for a rotor generating lift (cn > 0 in
both modes). With hub_axis_ned = [0, 0, 1] for a level rotor:
F_world[2] = −T_total < 0 (upward). ✓
λ_0 (and λ_c, λ_s) is the induced inflow ratio v_i / (ΩR),
not the total inflow. The total axial flow seen by each blade element
is:
λ_total = λ_0 + λ_climb where λ_climb = v_climb / (ΩR) < 0 in descent
This must be computed inside the blade element loop — do not pass
only λ_0. Without the freestream term the blade never sees
net-upward flow in WBS, so CQ never goes negative and autorotation is
suppressed entirely.
The Leishman (2000) polynomial uses descent-positive λ₂ = V_descent / V_h:
λ₁/V_h = 1 + 1.125·λ₂ − 1.372·λ₂² + 1.718·λ₂³ − 0.655·λ₂⁴
This is NOT the form with coefficients (−1.125, −1.372, −1.718, −0.655), which applies when the argument is V_climb/V_h (negative for descent). The two forms are equivalent; this code uses descent-positive throughout.
V_T = |v_climb + v_0| → 0 in the middle of VRS (upward freestream ≈
downward induced). A floor of 1e-2 · max(ΩR, 1) prevents
τ_0 → ∞ and division by zero. This is physically reasonable:
τ_0 → large in VRS is correct (slow, unsteady response), and the
exact floor value doesn't matter for stability.
At λ₂ ≈ 1.5, the Leishman polynomial gives
λ_0_ss ≈ 2 · V_h/ΩR. Combined with λ_climb ≈ −1.5 · V_h/ΩR,
the net blade inflow λ_total ≈ 0.5 · V_h/ΩR is less than hover, so
AoA increases and CT rises. The real VRS has recirculating wakes that
further restrict net throughflow; the 1-D polynomial captures the mean
induced velocity but not the 3-D blockage. This is a known limitation
of all momentum-based VRS models.
W[i] and W_int[i] are induced inflow ratios v_i / (Ω·R) per
annulus, not global harmonics. The total axial flow at annulus i
seen by the blade is λ_total[i] = λ_climb + W[i] (compare with
Pitt-Peters' λ_total = λ_climb + λ_0 + x·(λ_c·cos ψ + λ_s·sin ψ)).
W is what the blade actually reads in the ψ-loop. W_int is the
intermediate filter stage between the quasi-steady target W_qs[i]
and W. Both arrays have length n_elements.
W_qs[i] is solved per annulus from Glauert momentum balance using
the rotor-mean µ_T = V_T / Ω·R:
W_qs[i] = dCT/dx[i] / (4·x[i]·µ_T)
where V_T = √(v_edge² + (v_climb + v_0_mean)²)
This linear (in W_qs) form is what Pitt-Peters effectively uses in
its aggregate λ_0_ss = T / (2ρA·V_T·ΩR). The pure axial-momentum
form 4·x·λ_r·W = dCT/dx is unstable in forward flight (small λ_r in
descent makes W blow up) and was rejected during development.
Annulus-local: each W[i] evolves independently, driven only by
W_qs[i] from its own annulus. Cross-annulus coupling happens only
through the rotor-mean µ_T in the τ formulas and V_h in the VRS
override. There's no analogue of Pitt-Peters' −L_off·C_T term that
feeds total thrust into the cyclic harmonics, so no BEM-driven
feedback loop and no associated stiffness — at the cost of not
modelling cyclic inflow harmonics at all.
τ₁ is rotor-mean (depends on a_avg, not per-annulus); τ₂(r)
varies with radius. With dt = 5 ms and a 1 m rotor at V_∞ ~ 10 m/s,
τ₁ ~ 0.1 s and τ₂ ~ 0.04 s — both well above the envelope's outer
dt, so the semi-implicit Euler in envelope/point_mass.py is gentle
damping at most.
Cyclic pitch flows through the same cyclic_coeffs → θ(ψ) = collective + θ_1c·cos ψ + θ_1s·sin ψ path as Pitt-Peters; the ψ-loop
produces correct hub moments. What's missing compared to
Pitt-Peters: the cyclic-driven hub moment doesn't develop a
counter-acting inflow harmonic (no λ_c/λ_s states), so the
steady-state moment is over-predicted vs Pitt-Peters at hover.
Cyclic control (sign and order-of-magnitude) is right; cyclic
inflow damping is absent.
Extracted tables and figures from primary literature live under
Research/. Each paper subfolder uses the convention
page_NN_<description>.md so extractions trace back to their source
page image.
- CaradonnaTung/ — NASA TM-81232 (1981). 2-blade NACA 0012 hover CT data at θc = 5°/8°/12°. Primary BEM validation source. No CP / torque data.
- Buhl_NREL_TP500_36834/ — NREL TP-500-36834 (2005). Windmill Brake State correction extending Glauert. Used for the WBS quadratic.
- Castles_TN2474/ — NACA TN-2474 (Castles & Gray, 1951). Induced velocity in hover/descent — experimental basis for the Leishman VRS polynomial.
- Harrington_TN2318/ — NACA TN-2318 (Harrington, 1951). Hover CT vs CP polars for two full-scale rotors. Candidate dataset for CP-CT polar validation.
MIT — see LICENSE.