dynbem 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dynbem/__init__.py +212 -0
- dynbem/_bem_common.py +143 -0
- dynbem/bem.py +401 -0
- dynbem/cyclic.py +59 -0
- dynbem/oye.py +392 -0
- dynbem/pitt_peters.py +395 -0
- dynbem/pitt_peters_jit.py +307 -0
- dynbem/polar.py +121 -0
- dynbem/rotor_definition.py +283 -0
- dynbem/rotor_state.py +178 -0
- dynbem/trim.py +278 -0
- dynbem-0.1.0.dist-info/METADATA +513 -0
- dynbem-0.1.0.dist-info/RECORD +16 -0
- dynbem-0.1.0.dist-info/WHEEL +5 -0
- dynbem-0.1.0.dist-info/licenses/LICENSE +21 -0
- dynbem-0.1.0.dist-info/top_level.txt +1 -0
dynbem/__init__.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Aerodynamic interfaces and factory hooks."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from .rotor_state import PittPetersRotorState, QuasiStaticRotorState, RotorState
|
|
8
|
+
from .polar import AirfoilPolar, LinearPolar
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class AeroResult:
|
|
13
|
+
"""Return type for compute_forces().
|
|
14
|
+
|
|
15
|
+
Fields
|
|
16
|
+
------
|
|
17
|
+
F_world [3] Net aerodynamic force on the rotor in world (NED) frame, N.
|
|
18
|
+
Equal to ``-T_total * hub_axis_ned``; for a level rotor with
|
|
19
|
+
positive thrust, ``F_world[2] < 0`` (upward = −Z).
|
|
20
|
+
M_orbital [3] In-plane hub moments from non-axisymmetric thrust, world
|
|
21
|
+
frame. Accumulated per-element as ``r · dT · [sin ψ, cos ψ, 0]``
|
|
22
|
+
in hub frame and rotated via ``R_hub``. Non-zero in forward
|
|
23
|
+
flight (advancing/retreating velocity asymmetry) and under
|
|
24
|
+
cyclic input. Zero in axisymmetric hover. See CLAUDE.md.
|
|
25
|
+
Q_spin Shaft drag torque magnitude, N·m. Positive in powered hover
|
|
26
|
+
(aero drag opposes rotor motion). Drives the rotor speed ODE
|
|
27
|
+
via ``d_omega = (-Q_spin + motor_torque) / I_ode``.
|
|
28
|
+
M_spin [3] Reaction torque on the airframe from the rotor system
|
|
29
|
+
(shaft + motor stator), world frame. For our CCW-from-above
|
|
30
|
+
convention this is ``+Q_spin * hub_axis_ned`` — airframe is
|
|
31
|
+
pushed to spin CW from above (American helicopter yaw-right
|
|
32
|
+
tendency without tail rotor).
|
|
33
|
+
"""
|
|
34
|
+
F_world: np.ndarray # [3]
|
|
35
|
+
M_orbital: np.ndarray # [3]
|
|
36
|
+
Q_spin: float
|
|
37
|
+
M_spin: np.ndarray # [3]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class RotorInputs:
|
|
42
|
+
"""External driving conditions imposed on the rotor each timestep.
|
|
43
|
+
|
|
44
|
+
These are quantities the vehicle or environment sets — nothing here
|
|
45
|
+
has a derivative owned by the aero model. Rotor mechanical and inflow
|
|
46
|
+
states (omega, spin_angle, lambda) live in RotorState instead.
|
|
47
|
+
|
|
48
|
+
Fields
|
|
49
|
+
------
|
|
50
|
+
collective_rad collective pitch angle, rad
|
|
51
|
+
tilt_lon longitudinal swashplate tilt, rad. Helicopter-standard
|
|
52
|
+
sign: positive → nose-down (forward stick). Mapped to
|
|
53
|
+
blade pitch via dynbem.cyclic.cyclic_coeffs() using the
|
|
54
|
+
rotor's ControlProperties (gain, phase). With default
|
|
55
|
+
gain=1, phase=0 (control=None) this acts as the
|
|
56
|
+
direct θ_1c blade-pitch amplitude with helicopter signs.
|
|
57
|
+
tilt_lat lateral swashplate tilt, rad. Helicopter-standard sign:
|
|
58
|
+
positive → roll right.
|
|
59
|
+
R_hub [3x3] rotation matrix, hub frame to world frame
|
|
60
|
+
v_hub_world [3] hub velocity in world frame, m/s
|
|
61
|
+
wind_world [3] wind velocity in world frame, m/s
|
|
62
|
+
t simulation time, s
|
|
63
|
+
rho_kg_m3 air density, kg/m³ (default ISA sea level)
|
|
64
|
+
motor_torque_Nm shaft torque applied by motor/generator, N·m
|
|
65
|
+
positive = driving rotor, negative = braking
|
|
66
|
+
zero (default) = pure autorotation
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
collective_rad: float
|
|
70
|
+
tilt_lon: float
|
|
71
|
+
tilt_lat: float
|
|
72
|
+
R_hub: np.ndarray
|
|
73
|
+
v_hub_world: np.ndarray
|
|
74
|
+
wind_world: np.ndarray
|
|
75
|
+
t: float
|
|
76
|
+
rho_kg_m3: float = field(default=1.225)
|
|
77
|
+
motor_torque_Nm: float = field(default=0.0)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AeroBase(ABC):
|
|
81
|
+
"""Abstract interface for aero models."""
|
|
82
|
+
|
|
83
|
+
def initial_rotor_state(self) -> RotorState:
|
|
84
|
+
"""Return the zero rotor state for this model.
|
|
85
|
+
|
|
86
|
+
Override in subclasses that use dynamic inflow (e.g. return
|
|
87
|
+
PittPetersRotorState() for a Pitt-Peters model). The integrator
|
|
88
|
+
calls this once at initialisation to allocate the right state type.
|
|
89
|
+
"""
|
|
90
|
+
return QuasiStaticRotorState()
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
def compute_forces(
|
|
94
|
+
self,
|
|
95
|
+
inputs: RotorInputs,
|
|
96
|
+
state: RotorState,
|
|
97
|
+
) -> "tuple[AeroResult, RotorState]":
|
|
98
|
+
"""Compute aerodynamic forces and rotor state derivatives.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
inputs External driving conditions for this timestep.
|
|
103
|
+
state Current rotor state (inflow + omega + spin_angle).
|
|
104
|
+
|
|
105
|
+
Returns
|
|
106
|
+
-------
|
|
107
|
+
result Forces and moments.
|
|
108
|
+
derivative dstate/dt as a RotorState — the caller integrates.
|
|
109
|
+
"""
|
|
110
|
+
...
|
|
111
|
+
|
|
112
|
+
def inflow_taus(
|
|
113
|
+
self,
|
|
114
|
+
inputs: RotorInputs,
|
|
115
|
+
state: RotorState,
|
|
116
|
+
) -> np.ndarray:
|
|
117
|
+
"""Time constants per state component for semi-implicit integration.
|
|
118
|
+
|
|
119
|
+
Returns an array of the same length as ``state.to_array()``. Each
|
|
120
|
+
element is the time constant τ of that state's first-order lag
|
|
121
|
+
(used by the envelope integrator's semi-implicit damping
|
|
122
|
+
``damp = 1/(1 + dt/τ)``). Use ``np.inf`` for states that should
|
|
123
|
+
be integrated as plain explicit Euler (mechanical ω, ψ, and any
|
|
124
|
+
quasi-static states with no dynamics).
|
|
125
|
+
|
|
126
|
+
Default implementation: all-infinity (plain explicit Euler for
|
|
127
|
+
every state). Models with stiff dynamic inflow override this so
|
|
128
|
+
the envelope can damp the explicit step.
|
|
129
|
+
"""
|
|
130
|
+
return np.full(state.to_array().shape, np.inf)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
from . import rotor_definition # noqa: F401, E402
|
|
135
|
+
from .bem import ( # noqa: F401, E402
|
|
136
|
+
BEMModel,
|
|
137
|
+
prandtl_hub_loss,
|
|
138
|
+
prandtl_tip_loss,
|
|
139
|
+
solve_bem_element,
|
|
140
|
+
)
|
|
141
|
+
from .pitt_peters import PittPetersModel, vrs_lambda1 # noqa: F401, E402
|
|
142
|
+
from .oye import OyeBEMModel # noqa: F401, E402
|
|
143
|
+
from .rotor_state import OyeRotorState # noqa: F401, E402
|
|
144
|
+
from .trim import TrimResult, relax_inflow, solve_trim_cyclic # noqa: F401, E402
|
|
145
|
+
from .rotor_definition import ( # noqa: F401, E402
|
|
146
|
+
AirfoilProperties,
|
|
147
|
+
AutorotationProperties,
|
|
148
|
+
BladeGeometry,
|
|
149
|
+
ControlProperties,
|
|
150
|
+
InertiaProperties,
|
|
151
|
+
KamanFlap,
|
|
152
|
+
RotorDefinition,
|
|
153
|
+
ValidationIssue,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
__all__ = [
|
|
157
|
+
"AeroResult",
|
|
158
|
+
"AeroBase",
|
|
159
|
+
"BEMModel",
|
|
160
|
+
"PittPetersModel",
|
|
161
|
+
"OyeBEMModel",
|
|
162
|
+
"vrs_lambda1",
|
|
163
|
+
"RotorInputs",
|
|
164
|
+
"RotorState",
|
|
165
|
+
"PittPetersRotorState",
|
|
166
|
+
"OyeRotorState",
|
|
167
|
+
"QuasiStaticRotorState",
|
|
168
|
+
"AirfoilPolar",
|
|
169
|
+
"LinearPolar",
|
|
170
|
+
"create_aero",
|
|
171
|
+
"rotor_definition",
|
|
172
|
+
"AirfoilProperties",
|
|
173
|
+
"AutorotationProperties",
|
|
174
|
+
"BladeGeometry",
|
|
175
|
+
"ControlProperties",
|
|
176
|
+
"InertiaProperties",
|
|
177
|
+
"RotorDefinition",
|
|
178
|
+
"KamanFlap",
|
|
179
|
+
"ValidationIssue",
|
|
180
|
+
"TrimResult",
|
|
181
|
+
"solve_trim_cyclic",
|
|
182
|
+
"relax_inflow",
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def create_aero(defn: "RotorDefinition", model: str = "pitt_peters_jit") -> AeroBase:
|
|
187
|
+
"""Factory for the aero models in this package.
|
|
188
|
+
|
|
189
|
+
model
|
|
190
|
+
"bem" BEMModel (Level 1, quasi-static inflow)
|
|
191
|
+
"pitt_peters" PittPetersModel (Level 2, Pitt-Peters L-matrix, numpy)
|
|
192
|
+
"pitt_peters_jit" PittPetersModelJIT (Level 2, JIT-compiled — default)
|
|
193
|
+
"oye" OyeBEMModel (Level 2, Øye 2-stage annular inflow,
|
|
194
|
+
JIT-compiled). Stable alternative to Pitt-Peters
|
|
195
|
+
at high advance ratios / descent + edgewise wind.
|
|
196
|
+
"""
|
|
197
|
+
if model == "bem":
|
|
198
|
+
from .bem import BEMModel
|
|
199
|
+
return BEMModel(defn=defn)
|
|
200
|
+
if model in ("pitt_peters", "pitt_peters_numpy"):
|
|
201
|
+
from .pitt_peters import PittPetersModel
|
|
202
|
+
return PittPetersModel(defn=defn)
|
|
203
|
+
if model in ("pitt_peters_jit", "jit"):
|
|
204
|
+
from .pitt_peters_jit import PittPetersModelJIT
|
|
205
|
+
return PittPetersModelJIT(defn=defn)
|
|
206
|
+
if model in ("oye", "oye_bem"):
|
|
207
|
+
from .oye import OyeBEMModel
|
|
208
|
+
return OyeBEMModel(defn=defn)
|
|
209
|
+
raise ValueError(
|
|
210
|
+
f"Unknown aero model {model!r}. "
|
|
211
|
+
f"Choose 'bem', 'pitt_peters', 'pitt_peters_jit', or 'oye'."
|
|
212
|
+
)
|
dynbem/_bem_common.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Shared infrastructure for the Level-2 dynamic-inflow BEM models.
|
|
2
|
+
|
|
3
|
+
Holds the pieces that ``PittPetersModel(JIT)`` and ``OyeBEMModel`` would
|
|
4
|
+
otherwise duplicate or cross-import:
|
|
5
|
+
|
|
6
|
+
* ``vrs_lambda1`` — Leishman VRS empirical polynomial
|
|
7
|
+
* ``_interp_polar`` — JIT polar (cl, cd) lookup
|
|
8
|
+
* ``build_polar_arrays`` — one-time sampling of any AirfoilPolar onto
|
|
9
|
+
contiguous numba arrays
|
|
10
|
+
* ``radial_grid`` — one-time radial geometry caching
|
|
11
|
+
|
|
12
|
+
Hot-path kinematics and result-assembly are deliberately *not* here —
|
|
13
|
+
they're a few cheap numpy ops and live inline in each model's
|
|
14
|
+
``compute_forces`` to avoid the Python function-call overhead.
|
|
15
|
+
|
|
16
|
+
Lets the two models stay as peers (Øye no longer imports from
|
|
17
|
+
Pitt-Peters) and the ψ-loop kernels stay model-specific (each has its
|
|
18
|
+
own ``lam_local(r, ψ)`` formula and can't share a numba-compiled body
|
|
19
|
+
cleanly — closures don't compose under ``@njit``).
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import math
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
from numba import njit
|
|
28
|
+
|
|
29
|
+
from .polar import AirfoilPolar, TabulatedPolar
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from .rotor_definition import BladeGeometry
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# VRS empirical polynomial (Leishman 2000, §12.7)
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# λ_1/V_h = 1 + C[0]·λ₂ + C[1]·λ₂² + C[2]·λ₂³ + C[3]·λ₂⁴
|
|
39
|
+
# where λ₂ = V_descent / V_h > 0. Valid for 0 ≤ λ₂ ≤ 2.
|
|
40
|
+
# Fit to Castles-Gray (NACA TN-2474) and Coleman (1945) measured data.
|
|
41
|
+
_VRS_C = (1.125, -1.372, 1.718, -0.655)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def vrs_lambda1(lambda2: float) -> float:
|
|
45
|
+
"""Normalised induced velocity λ₁ = v_i/V_h from Leishman VRS polynomial.
|
|
46
|
+
|
|
47
|
+
lambda2 V_descent / V_h, must be in [0, 2].
|
|
48
|
+
Returns v_i / V_h (= 1.0 at λ₂=0 hover; ≈1.0 at λ₂=2 WBS boundary).
|
|
49
|
+
"""
|
|
50
|
+
k = lambda2
|
|
51
|
+
return 1.0 + _VRS_C[0]*k + _VRS_C[1]*k**2 + _VRS_C[2]*k**3 + _VRS_C[3]*k**4
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# JIT polar interpolator (cl, cd at angle α)
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
@njit(cache=True, fastmath=True)
|
|
59
|
+
def _interp_polar(alpha, alpha_tab, cl_tab, cd_tab):
|
|
60
|
+
"""Linear-interp (cl, cd) at angle alpha (rad). Binary-search lookup;
|
|
61
|
+
clamps to endpoints outside the tabulated range. Matches np.interp.
|
|
62
|
+
"""
|
|
63
|
+
n = alpha_tab.shape[0]
|
|
64
|
+
if alpha <= alpha_tab[0]:
|
|
65
|
+
return cl_tab[0], cd_tab[0]
|
|
66
|
+
if alpha >= alpha_tab[n - 1]:
|
|
67
|
+
return cl_tab[n - 1], cd_tab[n - 1]
|
|
68
|
+
lo = 0
|
|
69
|
+
hi = n - 1
|
|
70
|
+
while hi - lo > 1:
|
|
71
|
+
mid = (lo + hi) >> 1
|
|
72
|
+
if alpha_tab[mid] <= alpha:
|
|
73
|
+
lo = mid
|
|
74
|
+
else:
|
|
75
|
+
hi = mid
|
|
76
|
+
a_lo = alpha_tab[lo]
|
|
77
|
+
a_hi = alpha_tab[hi]
|
|
78
|
+
t = (alpha - a_lo) / (a_hi - a_lo)
|
|
79
|
+
return (
|
|
80
|
+
cl_tab[lo] + t * (cl_tab[hi] - cl_tab[lo]),
|
|
81
|
+
cd_tab[lo] + t * (cd_tab[hi] - cd_tab[lo]),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Polar tabulation for the JIT kernels
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def build_polar_arrays(polar: AirfoilPolar
|
|
90
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
91
|
+
"""Sample any AirfoilPolar onto contiguous numba arrays for _interp_polar.
|
|
92
|
+
|
|
93
|
+
TabulatedPolar passes through its existing arrays; analytical polars
|
|
94
|
+
(LinearPolar etc.) are sampled to 4001 points over [−π/2, π/2].
|
|
95
|
+
"""
|
|
96
|
+
if isinstance(polar, TabulatedPolar):
|
|
97
|
+
return (
|
|
98
|
+
np.ascontiguousarray(polar._alpha),
|
|
99
|
+
np.ascontiguousarray(polar._cl),
|
|
100
|
+
np.ascontiguousarray(polar._cd),
|
|
101
|
+
)
|
|
102
|
+
n = 4001
|
|
103
|
+
a = np.linspace(-math.pi / 2, math.pi / 2, n)
|
|
104
|
+
cl = np.empty(n)
|
|
105
|
+
cd = np.empty(n)
|
|
106
|
+
for i in range(n):
|
|
107
|
+
cl_i, cd_i = polar.cl_cd(float(a[i]))
|
|
108
|
+
cl[i] = cl_i
|
|
109
|
+
cd[i] = cd_i
|
|
110
|
+
return (
|
|
111
|
+
np.ascontiguousarray(a),
|
|
112
|
+
np.ascontiguousarray(cl),
|
|
113
|
+
np.ascontiguousarray(cd),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Radial grid caching
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def radial_grid(blade: "BladeGeometry"
|
|
122
|
+
) -> tuple[float, np.ndarray, np.ndarray, float, float]:
|
|
123
|
+
"""Cache the fixed radial geometry for a JIT BEM kernel.
|
|
124
|
+
|
|
125
|
+
Returns
|
|
126
|
+
-------
|
|
127
|
+
dr width of each radial element (m)
|
|
128
|
+
r_mid (n,) midpoint radius per element (m), contiguous
|
|
129
|
+
x_mid (n,) midpoint r/R, contiguous
|
|
130
|
+
x_hub root-cutout/R (dimensionless)
|
|
131
|
+
twist_rad uniform twist (rad). Per-section twist not yet supported.
|
|
132
|
+
"""
|
|
133
|
+
R = blade.radius_m
|
|
134
|
+
n = blade.n_elements
|
|
135
|
+
r0 = blade.root_cutout_m
|
|
136
|
+
dr = (R - r0) / n
|
|
137
|
+
r_mid = np.ascontiguousarray(
|
|
138
|
+
np.linspace(r0 + 0.5 * dr, R - 0.5 * dr, n)
|
|
139
|
+
)
|
|
140
|
+
x_mid = np.ascontiguousarray(r_mid / R)
|
|
141
|
+
x_hub = float(r0 / R) if R > 0.0 else 0.0
|
|
142
|
+
twist_rad = math.radians(blade.twist_deg)
|
|
143
|
+
return dr, r_mid, x_mid, x_hub, twist_rad
|