pyflightstream 0.2.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.
- pyflightstream/__init__.py +27 -0
- pyflightstream/cases/__init__.py +332 -0
- pyflightstream/cases/matrix_legacy.py +337 -0
- pyflightstream/commands/__init__.py +544 -0
- pyflightstream/commands/_meta.yaml +18 -0
- pyflightstream/commands/actuators.yaml +167 -0
- pyflightstream/commands/advanced_settings.yaml +136 -0
- pyflightstream/commands/aeroelastic_coupling.yaml +172 -0
- pyflightstream/commands/boundary_conditions.yaml +153 -0
- pyflightstream/commands/ccs_wing_mesh.yaml +74 -0
- pyflightstream/commands/coordinate_systems.yaml +123 -0
- pyflightstream/commands/file_io.yaml +82 -0
- pyflightstream/commands/mesh_import_export.yaml +72 -0
- pyflightstream/commands/motion_definitions.yaml +185 -0
- pyflightstream/commands/probe_points.yaml +116 -0
- pyflightstream/commands/runtime_settings.yaml +160 -0
- pyflightstream/commands/scenes.yaml +14 -0
- pyflightstream/commands/script_controls.yaml +39 -0
- pyflightstream/commands/simulation_controls.yaml +30 -0
- pyflightstream/commands/solver_analysis.yaml +118 -0
- pyflightstream/commands/solver_export.yaml +138 -0
- pyflightstream/commands/solver_initialization.yaml +95 -0
- pyflightstream/commands/solver_settings.yaml +120 -0
- pyflightstream/commands/streamlines.yaml +63 -0
- pyflightstream/commands/surface_sections.yaml +145 -0
- pyflightstream/commands/sweeper.yaml +115 -0
- pyflightstream/commands/unsteady_solver.yaml +68 -0
- pyflightstream/commands/volume_sections.yaml +148 -0
- pyflightstream/farfield/__init__.py +613 -0
- pyflightstream/files/__init__.py +375 -0
- pyflightstream/fsi/__init__.py +57 -0
- pyflightstream/fsi/beam.py +417 -0
- pyflightstream/fsi/centrifugal.py +418 -0
- pyflightstream/fsi/cli.py +206 -0
- pyflightstream/fsi/config.py +316 -0
- pyflightstream/fsi/driver.py +501 -0
- pyflightstream/fsi/kinematics.py +153 -0
- pyflightstream/fsi/loads.py +740 -0
- pyflightstream/fsi/nodes.py +463 -0
- pyflightstream/fsi/state.py +181 -0
- pyflightstream/post/__init__.py +18 -0
- pyflightstream/post/writers.py +195 -0
- pyflightstream/probes/__init__.py +453 -0
- pyflightstream/probes/geometry.py +281 -0
- pyflightstream/probes/planar.py +477 -0
- pyflightstream/qa/__init__.py +59 -0
- pyflightstream/qa/cli.py +364 -0
- pyflightstream/qa/compat.py +285 -0
- pyflightstream/qa/drift.py +406 -0
- pyflightstream/qa/geometry.py +421 -0
- pyflightstream/qa/physics.py +1744 -0
- pyflightstream/qa/probes.py +1023 -0
- pyflightstream/qa/references/PHY-01.yaml +38 -0
- pyflightstream/qa/references/PHY-02.yaml +28 -0
- pyflightstream/qa/references/PHY-05.yaml +29 -0
- pyflightstream/qa/references/PHY-06.yaml +90 -0
- pyflightstream/qa/references/SMI-01.yaml +28 -0
- pyflightstream/qa/references/SMI-02.yaml +28 -0
- pyflightstream/qa/specs.py +1405 -0
- pyflightstream/reference.py +465 -0
- pyflightstream/results/__init__.py +547 -0
- pyflightstream/run/__init__.py +677 -0
- pyflightstream/script/__init__.py +474 -0
- pyflightstream/script/helpers.py +844 -0
- pyflightstream/versions.py +191 -0
- pyflightstream-0.2.0.dist-info/METADATA +88 -0
- pyflightstream-0.2.0.dist-info/RECORD +71 -0
- pyflightstream-0.2.0.dist-info/WHEEL +5 -0
- pyflightstream-0.2.0.dist-info/entry_points.txt +3 -0
- pyflightstream-0.2.0.dist-info/licenses/LICENSE +21 -0
- pyflightstream-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"""PyNite beam model of one blade: static solve and modal frequencies.
|
|
2
|
+
|
|
3
|
+
Pipeline role: WP3 of DLV-007. Builds the finite element beam of one
|
|
4
|
+
blade on the elastic axis from :class:`~pyflightstream.fsi.config.FsiConfig`,
|
|
5
|
+
solves it statically under sectional loads (with P-Delta when axial
|
|
6
|
+
tension is present, FSI-R05), and extracts flap and torsion natural
|
|
7
|
+
frequencies for the Campbell diagram (Gate 1).
|
|
8
|
+
|
|
9
|
+
Model and frame: the beam lies along the global X axis (spanwise, root
|
|
10
|
+
to tip), flap deflection w is global Y, and elastic twist theta is the
|
|
11
|
+
rotation about X. The structural model is (w, theta) per the coupling
|
|
12
|
+
plan: only flap translation and torsion carry mass; the remaining
|
|
13
|
+
degrees of freedom are exactly condensed out of the eigenproblem.
|
|
14
|
+
Stiffness is encoded with unit elastic moduli (E = G = 1) so the
|
|
15
|
+
section constants are numerically EI and GJ; the blade root station is
|
|
16
|
+
clamped.
|
|
17
|
+
|
|
18
|
+
PyNite (PyPI ``PyNiteFEA``, import name ``Pynite``) is required; it is
|
|
19
|
+
installed by the ``[fsi]`` extra only.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
from collections.abc import Sequence
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from math import sqrt
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
from pyflightstream.fsi.config import FsiConfig
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
from Pynite import FEModel3D
|
|
33
|
+
except ModuleNotFoundError as exc: # pragma: no cover - exercised only without the extra
|
|
34
|
+
raise ModuleNotFoundError(
|
|
35
|
+
"pyflightstream.fsi.beam needs the PyNite structural backend, which is "
|
|
36
|
+
"not installed; install the optional extra with: pip install pyflightstream[fsi]"
|
|
37
|
+
) from exc
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
_COMBO = "structural"
|
|
42
|
+
_MATERIAL = "blade_unit_moduli"
|
|
43
|
+
# Fake axial area: with E = 1 this is the axial stiffness EA [N]. It only
|
|
44
|
+
# has to dwarf the bending stiffness so the spanwise DOF is quasi rigid;
|
|
45
|
+
# the axial DOF carries no mass and is condensed out of the eigenproblem.
|
|
46
|
+
_AXIAL_AREA = 1.0e9
|
|
47
|
+
|
|
48
|
+
# Per-node degree of freedom order in PyNite's global matrices.
|
|
49
|
+
_DOF_ORDER = ("DX", "DY", "DZ", "RX", "RY", "RZ")
|
|
50
|
+
_FLAP_DOF = _DOF_ORDER.index("DY")
|
|
51
|
+
_TWIST_DOF = _DOF_ORDER.index("RX")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def station_name(i: int) -> str:
|
|
55
|
+
"""Return the model node name of radial station ``i`` (root is 0)."""
|
|
56
|
+
return f"S{i:03d}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class StaticBeamSolution:
|
|
61
|
+
"""Beam solution sampled at the radial stations.
|
|
62
|
+
|
|
63
|
+
Attributes
|
|
64
|
+
----------
|
|
65
|
+
station_radii_m : tuple of float
|
|
66
|
+
Radial stations [m], as in the configuration.
|
|
67
|
+
flap_deflection_m : tuple of float
|
|
68
|
+
Flap deflection w at each station [m], positive along +Y of
|
|
69
|
+
the rotating blade frame.
|
|
70
|
+
elastic_twist_rad : tuple of float
|
|
71
|
+
Elastic twist theta at each station [rad], positive nose up
|
|
72
|
+
about the spanwise axis.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
station_radii_m: tuple[float, ...]
|
|
76
|
+
flap_deflection_m: tuple[float, ...]
|
|
77
|
+
elastic_twist_rad: tuple[float, ...]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class ModalResult:
|
|
82
|
+
"""Natural frequencies of the (w, theta) beam.
|
|
83
|
+
|
|
84
|
+
Attributes
|
|
85
|
+
----------
|
|
86
|
+
frequencies_rad_per_s : tuple of float
|
|
87
|
+
Undamped natural frequencies omega_n [rad/s], ascending.
|
|
88
|
+
kinds : tuple of str
|
|
89
|
+
Per mode, ``"flap"`` or ``"torsion"``: the family holding the
|
|
90
|
+
larger share of the mode's generalized mass.
|
|
91
|
+
flap_mass_fractions : tuple of float
|
|
92
|
+
Per mode, fraction of generalized mass in the flap DOFs; near
|
|
93
|
+
1 for pure flap, near 0 for pure torsion, intermediate when
|
|
94
|
+
offsets couple the families.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
frequencies_rad_per_s: tuple[float, ...]
|
|
98
|
+
kinds: tuple[str, ...]
|
|
99
|
+
flap_mass_fractions: tuple[float, ...]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_beam_model(cfg: FsiConfig) -> "FEModel3D":
|
|
103
|
+
"""Build the PyNite model of one blade on the elastic axis.
|
|
104
|
+
|
|
105
|
+
One node per radial station along global X, one member per bay
|
|
106
|
+
with bay-averaged EI and GJ (times the stiffness scale factor),
|
|
107
|
+
root station clamped.
|
|
108
|
+
|
|
109
|
+
Parameters
|
|
110
|
+
----------
|
|
111
|
+
cfg : FsiConfig
|
|
112
|
+
Validated configuration; only the blade distributions and the
|
|
113
|
+
stiffness scale factor are used here.
|
|
114
|
+
|
|
115
|
+
Returns
|
|
116
|
+
-------
|
|
117
|
+
FEModel3D
|
|
118
|
+
Model ready for loading and analysis; the load combination
|
|
119
|
+
``"structural"`` is registered.
|
|
120
|
+
"""
|
|
121
|
+
blade = cfg.blade
|
|
122
|
+
model = FEModel3D()
|
|
123
|
+
model.add_material(_MATERIAL, E=1.0, G=1.0, nu=0.3, rho=0.0)
|
|
124
|
+
for i, r in enumerate(blade.station_radii_m):
|
|
125
|
+
model.add_node(station_name(i), r, 0.0, 0.0)
|
|
126
|
+
scale = cfg.stiffness_scale_factor
|
|
127
|
+
for i in range(len(blade.station_radii_m) - 1):
|
|
128
|
+
ei = 0.5 * (blade.bending_stiffness_n_m2[i] + blade.bending_stiffness_n_m2[i + 1])
|
|
129
|
+
gj = 0.5 * (blade.torsion_stiffness_n_m2[i] + blade.torsion_stiffness_n_m2[i + 1])
|
|
130
|
+
section = f"bay{i:03d}"
|
|
131
|
+
model.add_section(section, A=_AXIAL_AREA, Iy=ei * scale, Iz=ei * scale, J=gj * scale)
|
|
132
|
+
model.add_member(f"B{i:03d}", station_name(i), station_name(i + 1), _MATERIAL, section)
|
|
133
|
+
model.def_support(
|
|
134
|
+
station_name(0),
|
|
135
|
+
support_DX=True,
|
|
136
|
+
support_DY=True,
|
|
137
|
+
support_DZ=True,
|
|
138
|
+
support_RX=True,
|
|
139
|
+
support_RY=True,
|
|
140
|
+
support_RZ=True,
|
|
141
|
+
)
|
|
142
|
+
model.add_load_combo(_COMBO, {"Case 1": 1.0})
|
|
143
|
+
return model
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def apply_station_loads(
|
|
147
|
+
model: "FEModel3D",
|
|
148
|
+
cfg: FsiConfig,
|
|
149
|
+
flap_load_n_per_m: Sequence[float] | None = None,
|
|
150
|
+
torsion_moment_n_m_per_m: Sequence[float] | None = None,
|
|
151
|
+
axial_load_n_per_m: Sequence[float] | None = None,
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Apply per-station distributed loads to a built model.
|
|
154
|
+
|
|
155
|
+
Distributed flap and axial loads become trapezoidal member loads
|
|
156
|
+
between stations. The distributed torsion moment is lumped to
|
|
157
|
+
nodal torques over tributary lengths, which reproduces the exact
|
|
158
|
+
tip twist of a uniform distributed torque on a uniform beam.
|
|
159
|
+
|
|
160
|
+
Parameters
|
|
161
|
+
----------
|
|
162
|
+
model : FEModel3D
|
|
163
|
+
Model from :func:`build_beam_model`.
|
|
164
|
+
cfg : FsiConfig
|
|
165
|
+
Configuration providing the station radii.
|
|
166
|
+
flap_load_n_per_m : sequence of float, optional
|
|
167
|
+
Distributed flap force per unit span at each station [N/m],
|
|
168
|
+
positive +Y.
|
|
169
|
+
torsion_moment_n_m_per_m : sequence of float, optional
|
|
170
|
+
Distributed torque about the elastic axis at each station
|
|
171
|
+
[N m / m], positive nose up.
|
|
172
|
+
axial_load_n_per_m : sequence of float, optional
|
|
173
|
+
Distributed axial (spanwise) force at each station [N/m],
|
|
174
|
+
positive toward the tip; the centrifugal tension enters here
|
|
175
|
+
and stiffens the beam through P-Delta (FSI-R05).
|
|
176
|
+
"""
|
|
177
|
+
radii = cfg.blade.station_radii_m
|
|
178
|
+
n = len(radii)
|
|
179
|
+
for name, values, direction in (
|
|
180
|
+
("flap_load_n_per_m", flap_load_n_per_m, "FY"),
|
|
181
|
+
("axial_load_n_per_m", axial_load_n_per_m, "FX"),
|
|
182
|
+
):
|
|
183
|
+
if values is None:
|
|
184
|
+
continue
|
|
185
|
+
if len(values) != n:
|
|
186
|
+
raise ValueError(
|
|
187
|
+
f"{name} has {len(values)} entries for {n} stations; sectional "
|
|
188
|
+
"loads must be sampled at the configuration stations"
|
|
189
|
+
)
|
|
190
|
+
for i in range(n - 1):
|
|
191
|
+
model.add_member_dist_load(f"B{i:03d}", direction, values[i], values[i + 1])
|
|
192
|
+
if torsion_moment_n_m_per_m is not None:
|
|
193
|
+
if len(torsion_moment_n_m_per_m) != n:
|
|
194
|
+
raise ValueError(
|
|
195
|
+
f"torsion_moment_n_m_per_m has {len(torsion_moment_n_m_per_m)} "
|
|
196
|
+
f"entries for {n} stations; sectional loads must be sampled at "
|
|
197
|
+
"the configuration stations"
|
|
198
|
+
)
|
|
199
|
+
for i, torque in enumerate(tributary_lengths(radii)):
|
|
200
|
+
lumped = torsion_moment_n_m_per_m[i] * torque
|
|
201
|
+
if lumped != 0.0:
|
|
202
|
+
model.add_node_load(station_name(i), "MX", lumped)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def solve_static(model: "FEModel3D", p_delta: bool = False) -> None:
|
|
206
|
+
"""Run the static analysis on the ``"structural"`` combination.
|
|
207
|
+
|
|
208
|
+
Parameters
|
|
209
|
+
----------
|
|
210
|
+
model : FEModel3D
|
|
211
|
+
Loaded model.
|
|
212
|
+
p_delta : bool
|
|
213
|
+
Use the geometrically nonlinear P-Delta analysis so axial
|
|
214
|
+
tension stiffens bending with no manual correction terms
|
|
215
|
+
(FSI-R05). The linear analysis is the right choice only when
|
|
216
|
+
no axial load is present.
|
|
217
|
+
"""
|
|
218
|
+
if p_delta:
|
|
219
|
+
model.analyze_PDelta(log=False, sparse=True)
|
|
220
|
+
else:
|
|
221
|
+
model.analyze_linear(log=False, sparse=True)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def extract_solution(model: "FEModel3D", cfg: FsiConfig) -> StaticBeamSolution:
|
|
225
|
+
"""Read (w, theta) at every station from an analyzed model."""
|
|
226
|
+
radii = cfg.blade.station_radii_m
|
|
227
|
+
w = []
|
|
228
|
+
theta = []
|
|
229
|
+
for i in range(len(radii)):
|
|
230
|
+
node = model.nodes[station_name(i)]
|
|
231
|
+
w.append(node.DY[_COMBO])
|
|
232
|
+
theta.append(node.RX[_COMBO])
|
|
233
|
+
return StaticBeamSolution(
|
|
234
|
+
station_radii_m=tuple(radii),
|
|
235
|
+
flap_deflection_m=tuple(w),
|
|
236
|
+
elastic_twist_rad=tuple(theta),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def modal_frequencies(
|
|
241
|
+
model: "FEModel3D",
|
|
242
|
+
cfg: FsiConfig,
|
|
243
|
+
n_modes: int = 6,
|
|
244
|
+
include_geometric_stiffness: bool = False,
|
|
245
|
+
twist_stiffness_n_m_per_rad: Sequence[float] | None = None,
|
|
246
|
+
) -> ModalResult:
|
|
247
|
+
"""Extract flap and torsion natural frequencies of the blade.
|
|
248
|
+
|
|
249
|
+
The stiffness matrix comes from PyNite (elastic, plus the
|
|
250
|
+
geometric stiffness of the analyzed axial state when requested);
|
|
251
|
+
the mass matrix is the lumped (w, theta) matrix of
|
|
252
|
+
:func:`lumped_station_masses`. Massless degrees of freedom are
|
|
253
|
+
condensed out exactly, so the eigenproblem is small and free of
|
|
254
|
+
artifacts from the fake axial and chordwise stiffness.
|
|
255
|
+
|
|
256
|
+
Parameters
|
|
257
|
+
----------
|
|
258
|
+
model : FEModel3D
|
|
259
|
+
Analyzed model (run :func:`solve_static` first; the analysis
|
|
260
|
+
assigns the global degree of freedom numbering and, for the
|
|
261
|
+
rotating case, the axial force state behind the geometric
|
|
262
|
+
stiffness).
|
|
263
|
+
cfg : FsiConfig
|
|
264
|
+
Configuration providing mass and inertia distributions.
|
|
265
|
+
n_modes : int
|
|
266
|
+
Number of lowest modes to return.
|
|
267
|
+
include_geometric_stiffness : bool
|
|
268
|
+
Add PyNite's geometric stiffness of the ``"structural"``
|
|
269
|
+
combination, so centrifugal tension raises the flap
|
|
270
|
+
frequencies (the Southwell effect probed by WP4).
|
|
271
|
+
twist_stiffness_n_m_per_rad : sequence of float, optional
|
|
272
|
+
Additional lumped torsional stiffness per station
|
|
273
|
+
[N m / rad], added on the twist degrees of freedom. The
|
|
274
|
+
centrifugal (propeller moment) torsional stiffening of
|
|
275
|
+
:mod:`pyflightstream.fsi.centrifugal` enters here; PyNite's
|
|
276
|
+
geometric stiffness covers bending only.
|
|
277
|
+
|
|
278
|
+
Returns
|
|
279
|
+
-------
|
|
280
|
+
ModalResult
|
|
281
|
+
Ascending frequencies with flap or torsion classification.
|
|
282
|
+
"""
|
|
283
|
+
stiffness = np.array(model.Ke(_COMBO, log=False, check_stability=False, sparse=True).todense())
|
|
284
|
+
if include_geometric_stiffness:
|
|
285
|
+
stiffness = stiffness + np.array(
|
|
286
|
+
model.Kg(_COMBO, log=False, sparse=True, first_step=False).todense()
|
|
287
|
+
)
|
|
288
|
+
node_ids = {name: node.ID for name, node in model.nodes.items()}
|
|
289
|
+
n_stations = len(cfg.blade.station_radii_m)
|
|
290
|
+
flap_mass, twist_inertia = lumped_station_masses(cfg)
|
|
291
|
+
|
|
292
|
+
if twist_stiffness_n_m_per_rad is not None and len(twist_stiffness_n_m_per_rad) != n_stations:
|
|
293
|
+
raise ValueError(
|
|
294
|
+
f"twist_stiffness_n_m_per_rad has {len(twist_stiffness_n_m_per_rad)} "
|
|
295
|
+
f"entries for {n_stations} stations; the lumped torsional stiffening "
|
|
296
|
+
"must be sampled at the configuration stations"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
free_rows: list[int] = []
|
|
300
|
+
masses: list[float] = []
|
|
301
|
+
is_flap: list[bool] = []
|
|
302
|
+
twist_master_station: dict[int, int] = {}
|
|
303
|
+
slave_rows: list[int] = []
|
|
304
|
+
root_id = node_ids[station_name(0)]
|
|
305
|
+
for i in range(n_stations):
|
|
306
|
+
node_id = node_ids[station_name(i)]
|
|
307
|
+
if node_id == root_id:
|
|
308
|
+
continue # clamped: all six DOFs are supported
|
|
309
|
+
for dof in range(6):
|
|
310
|
+
row = node_id * 6 + dof
|
|
311
|
+
if dof == _FLAP_DOF:
|
|
312
|
+
free_rows.append(row)
|
|
313
|
+
masses.append(flap_mass[i])
|
|
314
|
+
is_flap.append(True)
|
|
315
|
+
elif dof == _TWIST_DOF:
|
|
316
|
+
twist_master_station[len(free_rows)] = i
|
|
317
|
+
free_rows.append(row)
|
|
318
|
+
masses.append(twist_inertia[i])
|
|
319
|
+
is_flap.append(False)
|
|
320
|
+
else:
|
|
321
|
+
slave_rows.append(row)
|
|
322
|
+
condensed = _condense_massless(stiffness, free_rows, slave_rows)
|
|
323
|
+
if twist_stiffness_n_m_per_rad is not None:
|
|
324
|
+
for master_index, i in twist_master_station.items():
|
|
325
|
+
condensed[master_index, master_index] += twist_stiffness_n_m_per_rad[i]
|
|
326
|
+
|
|
327
|
+
mass_vector = np.array(masses)
|
|
328
|
+
# Symmetric standard form: L^-1 K L^-T with L = sqrt(M) (M is diagonal).
|
|
329
|
+
inv_sqrt_m = 1.0 / np.sqrt(mass_vector)
|
|
330
|
+
sym = condensed * inv_sqrt_m[:, None] * inv_sqrt_m[None, :]
|
|
331
|
+
eigenvalues, eigenvectors = np.linalg.eigh(sym)
|
|
332
|
+
|
|
333
|
+
frequencies = []
|
|
334
|
+
kinds = []
|
|
335
|
+
fractions = []
|
|
336
|
+
flap_mask = np.array(is_flap)
|
|
337
|
+
for k in range(min(n_modes, len(eigenvalues))):
|
|
338
|
+
lam = eigenvalues[k]
|
|
339
|
+
if lam < 0.0:
|
|
340
|
+
raise ValueError(
|
|
341
|
+
"negative eigenvalue in the modal problem: the axial state is "
|
|
342
|
+
"beyond buckling of this blade, or the model is not analyzed"
|
|
343
|
+
)
|
|
344
|
+
shape = eigenvectors[:, k]
|
|
345
|
+
flap_fraction = float(np.sum(shape[flap_mask] ** 2) / np.sum(shape**2))
|
|
346
|
+
frequencies.append(sqrt(lam))
|
|
347
|
+
kinds.append("flap" if flap_fraction >= 0.5 else "torsion")
|
|
348
|
+
fractions.append(flap_fraction)
|
|
349
|
+
return ModalResult(
|
|
350
|
+
frequencies_rad_per_s=tuple(frequencies),
|
|
351
|
+
kinds=tuple(kinds),
|
|
352
|
+
flap_mass_fractions=tuple(fractions),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def lumped_station_masses(cfg: FsiConfig) -> tuple[list[float], list[float]]:
|
|
357
|
+
"""Lump running mass and torsional inertia to the stations.
|
|
358
|
+
|
|
359
|
+
Each station receives the tributary half lengths of its adjacent
|
|
360
|
+
bays: translational mass mu(r_i) l_i [kg] for the flap DOF and
|
|
361
|
+
polar inertia (I1(r_i) + I2(r_i)) l_i [kg m^2] for the twist DOF.
|
|
362
|
+
|
|
363
|
+
Source: standard tributary (lumped) mass matrix for beam finite
|
|
364
|
+
elements, Cook, Malkus, Plesha, Witt, "Concepts and Applications
|
|
365
|
+
of Finite Element Analysis", 4th ed., Section 11.4.
|
|
366
|
+
|
|
367
|
+
Parameters
|
|
368
|
+
----------
|
|
369
|
+
cfg : FsiConfig
|
|
370
|
+
Configuration providing mu(r), I1(r), I2(r) and the stations.
|
|
371
|
+
|
|
372
|
+
Returns
|
|
373
|
+
-------
|
|
374
|
+
tuple of (list of float, list of float)
|
|
375
|
+
Per-station flap masses [kg] and twist inertias [kg m^2].
|
|
376
|
+
"""
|
|
377
|
+
blade = cfg.blade
|
|
378
|
+
tributary = tributary_lengths(blade.station_radii_m)
|
|
379
|
+
flap_mass = [
|
|
380
|
+
mu * length for mu, length in zip(blade.mass_per_length_kg_per_m, tributary, strict=True)
|
|
381
|
+
]
|
|
382
|
+
twist_inertia = [
|
|
383
|
+
(i1 + i2) * length
|
|
384
|
+
for i1, i2, length in zip(
|
|
385
|
+
blade.inertia_major_kg_m, blade.inertia_minor_kg_m, tributary, strict=True
|
|
386
|
+
)
|
|
387
|
+
]
|
|
388
|
+
return flap_mass, twist_inertia
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def tributary_lengths(radii: Sequence[float]) -> list[float]:
|
|
392
|
+
"""Return the half-bay tributary length of every station [m]."""
|
|
393
|
+
n = len(radii)
|
|
394
|
+
lengths = []
|
|
395
|
+
for i in range(n):
|
|
396
|
+
left = radii[i] - radii[i - 1] if i > 0 else 0.0
|
|
397
|
+
right = radii[i + 1] - radii[i] if i < n - 1 else 0.0
|
|
398
|
+
lengths.append(0.5 * (left + right))
|
|
399
|
+
return lengths
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _condense_massless(
|
|
403
|
+
stiffness: np.ndarray, master_rows: list[int], slave_rows: list[int]
|
|
404
|
+
) -> np.ndarray:
|
|
405
|
+
"""Condense massless degrees of freedom out of a stiffness matrix.
|
|
406
|
+
|
|
407
|
+
Static (Guyan) condensation is exact, not approximate, when the
|
|
408
|
+
condensed degrees of freedom carry no mass, which is the case here
|
|
409
|
+
by construction of the lumped (w, theta) mass matrix.
|
|
410
|
+
|
|
411
|
+
Source: R. J. Guyan, "Reduction of stiffness and mass matrices",
|
|
412
|
+
AIAA Journal 3(2), 1965, p.380.
|
|
413
|
+
"""
|
|
414
|
+
kmm = stiffness[np.ix_(master_rows, master_rows)]
|
|
415
|
+
kms = stiffness[np.ix_(master_rows, slave_rows)]
|
|
416
|
+
kss = stiffness[np.ix_(slave_rows, slave_rows)]
|
|
417
|
+
return kmm - kms @ np.linalg.solve(kss, kms.T)
|