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 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