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.
Files changed (71) hide show
  1. pyflightstream/__init__.py +27 -0
  2. pyflightstream/cases/__init__.py +332 -0
  3. pyflightstream/cases/matrix_legacy.py +337 -0
  4. pyflightstream/commands/__init__.py +544 -0
  5. pyflightstream/commands/_meta.yaml +18 -0
  6. pyflightstream/commands/actuators.yaml +167 -0
  7. pyflightstream/commands/advanced_settings.yaml +136 -0
  8. pyflightstream/commands/aeroelastic_coupling.yaml +172 -0
  9. pyflightstream/commands/boundary_conditions.yaml +153 -0
  10. pyflightstream/commands/ccs_wing_mesh.yaml +74 -0
  11. pyflightstream/commands/coordinate_systems.yaml +123 -0
  12. pyflightstream/commands/file_io.yaml +82 -0
  13. pyflightstream/commands/mesh_import_export.yaml +72 -0
  14. pyflightstream/commands/motion_definitions.yaml +185 -0
  15. pyflightstream/commands/probe_points.yaml +116 -0
  16. pyflightstream/commands/runtime_settings.yaml +160 -0
  17. pyflightstream/commands/scenes.yaml +14 -0
  18. pyflightstream/commands/script_controls.yaml +39 -0
  19. pyflightstream/commands/simulation_controls.yaml +30 -0
  20. pyflightstream/commands/solver_analysis.yaml +118 -0
  21. pyflightstream/commands/solver_export.yaml +138 -0
  22. pyflightstream/commands/solver_initialization.yaml +95 -0
  23. pyflightstream/commands/solver_settings.yaml +120 -0
  24. pyflightstream/commands/streamlines.yaml +63 -0
  25. pyflightstream/commands/surface_sections.yaml +145 -0
  26. pyflightstream/commands/sweeper.yaml +115 -0
  27. pyflightstream/commands/unsteady_solver.yaml +68 -0
  28. pyflightstream/commands/volume_sections.yaml +148 -0
  29. pyflightstream/farfield/__init__.py +613 -0
  30. pyflightstream/files/__init__.py +375 -0
  31. pyflightstream/fsi/__init__.py +57 -0
  32. pyflightstream/fsi/beam.py +417 -0
  33. pyflightstream/fsi/centrifugal.py +418 -0
  34. pyflightstream/fsi/cli.py +206 -0
  35. pyflightstream/fsi/config.py +316 -0
  36. pyflightstream/fsi/driver.py +501 -0
  37. pyflightstream/fsi/kinematics.py +153 -0
  38. pyflightstream/fsi/loads.py +740 -0
  39. pyflightstream/fsi/nodes.py +463 -0
  40. pyflightstream/fsi/state.py +181 -0
  41. pyflightstream/post/__init__.py +18 -0
  42. pyflightstream/post/writers.py +195 -0
  43. pyflightstream/probes/__init__.py +453 -0
  44. pyflightstream/probes/geometry.py +281 -0
  45. pyflightstream/probes/planar.py +477 -0
  46. pyflightstream/qa/__init__.py +59 -0
  47. pyflightstream/qa/cli.py +364 -0
  48. pyflightstream/qa/compat.py +285 -0
  49. pyflightstream/qa/drift.py +406 -0
  50. pyflightstream/qa/geometry.py +421 -0
  51. pyflightstream/qa/physics.py +1744 -0
  52. pyflightstream/qa/probes.py +1023 -0
  53. pyflightstream/qa/references/PHY-01.yaml +38 -0
  54. pyflightstream/qa/references/PHY-02.yaml +28 -0
  55. pyflightstream/qa/references/PHY-05.yaml +29 -0
  56. pyflightstream/qa/references/PHY-06.yaml +90 -0
  57. pyflightstream/qa/references/SMI-01.yaml +28 -0
  58. pyflightstream/qa/references/SMI-02.yaml +28 -0
  59. pyflightstream/qa/specs.py +1405 -0
  60. pyflightstream/reference.py +465 -0
  61. pyflightstream/results/__init__.py +547 -0
  62. pyflightstream/run/__init__.py +677 -0
  63. pyflightstream/script/__init__.py +474 -0
  64. pyflightstream/script/helpers.py +844 -0
  65. pyflightstream/versions.py +191 -0
  66. pyflightstream-0.2.0.dist-info/METADATA +88 -0
  67. pyflightstream-0.2.0.dist-info/RECORD +71 -0
  68. pyflightstream-0.2.0.dist-info/WHEEL +5 -0
  69. pyflightstream-0.2.0.dist-info/entry_points.txt +3 -0
  70. pyflightstream-0.2.0.dist-info/licenses/LICENSE +21 -0
  71. 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)