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,418 @@
|
|
|
1
|
+
"""Centrifugal loads of the rotating blade: tension and propeller moment.
|
|
2
|
+
|
|
3
|
+
Pipeline role: WP4 of DLV-007. Computes the loads that rotation adds
|
|
4
|
+
on top of the aerodynamic ones at every coupling call: the axial
|
|
5
|
+
tension that stiffens bending (through P-Delta, FSI-R05) and the
|
|
6
|
+
propeller moment that twists the blade toward flat pitch (evaluated at
|
|
7
|
+
the current total pitch and re-solved in a small inner iteration,
|
|
8
|
+
FSI-R06 and FSI-R11). The frequency sweep of the Campbell diagram
|
|
9
|
+
(Gate 1) also lives here.
|
|
10
|
+
|
|
11
|
+
Evidence status (DLV-007 Section 2): the primary sources of the model
|
|
12
|
+
(Bielawa; Houbolt and Brooks, NACA Report 1346) have not yet been
|
|
13
|
+
independently verified against the plan formulas (TSR-014). Every
|
|
14
|
+
formula therefore lives in a small isolated function with the source
|
|
15
|
+
in the docstring, so a later correction is a localized change.
|
|
16
|
+
|
|
17
|
+
All quantities are in SI in the rotating blade frame of
|
|
18
|
+
:mod:`pyflightstream.fsi.config`.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import math
|
|
23
|
+
from collections.abc import Sequence
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
|
|
26
|
+
from pyflightstream.fsi import beam
|
|
27
|
+
from pyflightstream.fsi.config import FsiConfig
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# Twist stabilization tolerance of the inner iteration: three orders of
|
|
32
|
+
# magnitude below the phase 3 convergence criterion of 0.05 deg
|
|
33
|
+
# (8.7e-4 rad), reached in the 2 to 3 solves the plan expects for
|
|
34
|
+
# realistic stiffness ratios (contraction is roughly the ratio of the
|
|
35
|
+
# propeller moment stiffening to GJ).
|
|
36
|
+
_INNER_TOLERANCE_RAD = 1.0e-6
|
|
37
|
+
_INNER_MAX_SOLVES = 8
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def axial_load_distribution(cfg: FsiConfig) -> list[float]:
|
|
41
|
+
"""Distributed centrifugal axial force at every station [N/m].
|
|
42
|
+
|
|
43
|
+
f(r) = mu(r) Omega^2 r, pointing from root to tip. Applied as a
|
|
44
|
+
distributed axial member load, it makes the internal tension N(r)
|
|
45
|
+
emerge from the solver, and P-Delta turns it into bending
|
|
46
|
+
stiffness with no manual correction terms (FSI-R05).
|
|
47
|
+
|
|
48
|
+
Source: FSI Blade Coupling Plan rev. 2 (July 2026), Section on
|
|
49
|
+
centrifugal loads; elementary centrifugal force on a rotating
|
|
50
|
+
mass element.
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
cfg : FsiConfig
|
|
55
|
+
Configuration with mu(r) and Omega.
|
|
56
|
+
|
|
57
|
+
Returns
|
|
58
|
+
-------
|
|
59
|
+
list of float
|
|
60
|
+
f(r_i) at every station [N/m].
|
|
61
|
+
"""
|
|
62
|
+
omega_sq = cfg.omega_rad_per_s**2
|
|
63
|
+
return [
|
|
64
|
+
mu * omega_sq * r
|
|
65
|
+
for mu, r in zip(cfg.blade.mass_per_length_kg_per_m, cfg.blade.station_radii_m, strict=True)
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def axial_tension(cfg: FsiConfig) -> list[float]:
|
|
70
|
+
"""Return the internal centrifugal tension N(r) at every station [N].
|
|
71
|
+
|
|
72
|
+
N(r) = integral from r to R of mu(s) Omega^2 s ds, evaluated with
|
|
73
|
+
the trapezoid rule on the configured stations. This closed form is
|
|
74
|
+
the cross-check of what the P-Delta solve builds internally; the
|
|
75
|
+
solver never receives it directly.
|
|
76
|
+
|
|
77
|
+
Source: FSI Blade Coupling Plan rev. 2 (July 2026), Section on
|
|
78
|
+
centrifugal loads.
|
|
79
|
+
|
|
80
|
+
Parameters
|
|
81
|
+
----------
|
|
82
|
+
cfg : FsiConfig
|
|
83
|
+
Configuration with mu(r) and Omega.
|
|
84
|
+
|
|
85
|
+
Returns
|
|
86
|
+
-------
|
|
87
|
+
list of float
|
|
88
|
+
N(r_i) at every station [N]; zero at the tip.
|
|
89
|
+
"""
|
|
90
|
+
load = axial_load_distribution(cfg)
|
|
91
|
+
radii = cfg.blade.station_radii_m
|
|
92
|
+
n = len(radii)
|
|
93
|
+
tension = [0.0] * n
|
|
94
|
+
for i in range(n - 2, -1, -1):
|
|
95
|
+
bay = radii[i + 1] - radii[i]
|
|
96
|
+
tension[i] = tension[i + 1] + 0.5 * (load[i] + load[i + 1]) * bay
|
|
97
|
+
return tension
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def total_pitch_rad(cfg: FsiConfig, elastic_twist_rad: Sequence[float]) -> list[float]:
|
|
101
|
+
"""Total local pitch theta_tot [rad]: geometric plus elastic.
|
|
102
|
+
|
|
103
|
+
Source: FSI Blade Coupling Plan rev. 2 (July 2026), definition of
|
|
104
|
+
the total pitch entering the propeller moment (FSI-R06).
|
|
105
|
+
"""
|
|
106
|
+
return [
|
|
107
|
+
math.radians(geometric) + elastic
|
|
108
|
+
for geometric, elastic in zip(cfg.blade.geometric_pitch_deg, elastic_twist_rad, strict=True)
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def propeller_moment_distribution(
|
|
113
|
+
cfg: FsiConfig, elastic_twist_rad: Sequence[float]
|
|
114
|
+
) -> list[float]:
|
|
115
|
+
"""Distributed propeller moment about the elastic axis [N m / m].
|
|
116
|
+
|
|
117
|
+
m_theta(r) = -Omega^2 (I1(r) - I2(r)) sin(theta_tot) cos(theta_tot),
|
|
118
|
+
with theta_tot the total local pitch. The moment drives every
|
|
119
|
+
section toward flat pitch, so it depends on the deformation and is
|
|
120
|
+
re-evaluated inside the inner iteration (FSI-R06).
|
|
121
|
+
|
|
122
|
+
Source: FSI Blade Coupling Plan rev. 2 (July 2026), propeller
|
|
123
|
+
moment term after Houbolt and Brooks, NACA Report 1346 (primary
|
|
124
|
+
source verification pending, TSR-014).
|
|
125
|
+
|
|
126
|
+
Parameters
|
|
127
|
+
----------
|
|
128
|
+
cfg : FsiConfig
|
|
129
|
+
Configuration with I1(r), I2(r), geometric pitch, and Omega.
|
|
130
|
+
elastic_twist_rad : sequence of float
|
|
131
|
+
Current elastic twist at every station [rad].
|
|
132
|
+
|
|
133
|
+
Returns
|
|
134
|
+
-------
|
|
135
|
+
list of float
|
|
136
|
+
m_theta(r_i) at every station [N m / m].
|
|
137
|
+
"""
|
|
138
|
+
omega_sq = cfg.omega_rad_per_s**2
|
|
139
|
+
return [
|
|
140
|
+
-omega_sq * (i1 - i2) * math.sin(theta) * math.cos(theta)
|
|
141
|
+
for i1, i2, theta in zip(
|
|
142
|
+
cfg.blade.inertia_major_kg_m,
|
|
143
|
+
cfg.blade.inertia_minor_kg_m,
|
|
144
|
+
total_pitch_rad(cfg, elastic_twist_rad),
|
|
145
|
+
strict=True,
|
|
146
|
+
)
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def propeller_moment_twist_stiffness(
|
|
151
|
+
cfg: FsiConfig, elastic_twist_rad: Sequence[float]
|
|
152
|
+
) -> list[float]:
|
|
153
|
+
"""Lumped torsional stiffening of the propeller moment [N m / rad].
|
|
154
|
+
|
|
155
|
+
Linearizing m_theta about the current pitch gives the distributed
|
|
156
|
+
restoring stiffness k_theta(r) = Omega^2 (I1 - I2) cos(2 theta_tot),
|
|
157
|
+
lumped here over the station tributary lengths for the modal
|
|
158
|
+
problem. For a thin blade near flat pitch this term alone yields
|
|
159
|
+
the classic torsional Southwell coefficient near 1.
|
|
160
|
+
|
|
161
|
+
Source: FSI Blade Coupling Plan rev. 2 (July 2026), derivative of
|
|
162
|
+
the propeller moment term after Houbolt and Brooks, NACA Report
|
|
163
|
+
1346 (primary source verification pending, TSR-014).
|
|
164
|
+
|
|
165
|
+
Parameters
|
|
166
|
+
----------
|
|
167
|
+
cfg : FsiConfig
|
|
168
|
+
Configuration with I1(r), I2(r), geometric pitch, and Omega.
|
|
169
|
+
elastic_twist_rad : sequence of float
|
|
170
|
+
Twist state to linearize about [rad].
|
|
171
|
+
|
|
172
|
+
Returns
|
|
173
|
+
-------
|
|
174
|
+
list of float
|
|
175
|
+
Lumped stiffness at every station [N m / rad].
|
|
176
|
+
"""
|
|
177
|
+
omega_sq = cfg.omega_rad_per_s**2
|
|
178
|
+
tributary = beam.tributary_lengths(cfg.blade.station_radii_m)
|
|
179
|
+
return [
|
|
180
|
+
omega_sq * (i1 - i2) * math.cos(2.0 * theta) * length
|
|
181
|
+
for i1, i2, theta, length in zip(
|
|
182
|
+
cfg.blade.inertia_major_kg_m,
|
|
183
|
+
cfg.blade.inertia_minor_kg_m,
|
|
184
|
+
total_pitch_rad(cfg, elastic_twist_rad),
|
|
185
|
+
tributary,
|
|
186
|
+
strict=True,
|
|
187
|
+
)
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass(frozen=True)
|
|
192
|
+
class RotatingSolution:
|
|
193
|
+
"""Static solution of the rotating blade with iteration diagnostics.
|
|
194
|
+
|
|
195
|
+
Attributes
|
|
196
|
+
----------
|
|
197
|
+
solution : beam.StaticBeamSolution
|
|
198
|
+
Converged (w, theta) at the stations.
|
|
199
|
+
inner_solves : int
|
|
200
|
+
Beam solves spent in the inner twist iteration (FSI-R11).
|
|
201
|
+
twist_residual_rad : float
|
|
202
|
+
Largest twist change of the last inner iteration [rad].
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
solution: beam.StaticBeamSolution
|
|
206
|
+
inner_solves: int
|
|
207
|
+
twist_residual_rad: float
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def solve_rotating_static(
|
|
211
|
+
cfg: FsiConfig,
|
|
212
|
+
flap_load_n_per_m: Sequence[float] | None = None,
|
|
213
|
+
torsion_moment_n_m_per_m: Sequence[float] | None = None,
|
|
214
|
+
) -> RotatingSolution:
|
|
215
|
+
"""Solve the rotating blade statically with the inner twist iteration.
|
|
216
|
+
|
|
217
|
+
Each pass rebuilds the beam, applies the aerodynamic loads plus
|
|
218
|
+
the centrifugal tension and the propeller moment evaluated at the
|
|
219
|
+
current twist, and solves with P-Delta. The loop repeats until the
|
|
220
|
+
twist distribution stabilizes, converging the structural
|
|
221
|
+
nonlinearity implicitly at millisecond cost, decoupled from the
|
|
222
|
+
aerodynamic loop (FSI-R11; typically 2 to 3 solves).
|
|
223
|
+
|
|
224
|
+
Parameters
|
|
225
|
+
----------
|
|
226
|
+
cfg : FsiConfig
|
|
227
|
+
Configuration; Omega may be zero, which reduces to the plain
|
|
228
|
+
static solve in one pass.
|
|
229
|
+
flap_load_n_per_m : sequence of float, optional
|
|
230
|
+
Aerodynamic distributed flap load at the stations [N/m].
|
|
231
|
+
torsion_moment_n_m_per_m : sequence of float, optional
|
|
232
|
+
Aerodynamic distributed twisting moment about the elastic
|
|
233
|
+
axis at the stations [N m / m].
|
|
234
|
+
|
|
235
|
+
Returns
|
|
236
|
+
-------
|
|
237
|
+
RotatingSolution
|
|
238
|
+
Converged solution and iteration diagnostics.
|
|
239
|
+
"""
|
|
240
|
+
n = len(cfg.blade.station_radii_m)
|
|
241
|
+
aero_torsion = list(torsion_moment_n_m_per_m or [0.0] * n)
|
|
242
|
+
axial = axial_load_distribution(cfg)
|
|
243
|
+
twist = [0.0] * n
|
|
244
|
+
residual = math.inf
|
|
245
|
+
solve_count = 0
|
|
246
|
+
while solve_count < _INNER_MAX_SOLVES:
|
|
247
|
+
solve_count += 1
|
|
248
|
+
model = beam.build_beam_model(cfg)
|
|
249
|
+
propeller = propeller_moment_distribution(cfg, twist)
|
|
250
|
+
torsion = [aero + prop for aero, prop in zip(aero_torsion, propeller, strict=True)]
|
|
251
|
+
beam.apply_station_loads(
|
|
252
|
+
model,
|
|
253
|
+
cfg,
|
|
254
|
+
flap_load_n_per_m=flap_load_n_per_m,
|
|
255
|
+
torsion_moment_n_m_per_m=torsion,
|
|
256
|
+
axial_load_n_per_m=axial if cfg.omega_rad_per_s > 0.0 else None,
|
|
257
|
+
)
|
|
258
|
+
beam.solve_static(model, p_delta=cfg.omega_rad_per_s > 0.0)
|
|
259
|
+
solution = beam.extract_solution(model, cfg)
|
|
260
|
+
new_twist = list(solution.elastic_twist_rad)
|
|
261
|
+
residual = max(abs(a - b) for a, b in zip(new_twist, twist, strict=True))
|
|
262
|
+
twist = new_twist
|
|
263
|
+
if residual < _INNER_TOLERANCE_RAD:
|
|
264
|
+
break
|
|
265
|
+
if residual >= _INNER_TOLERANCE_RAD:
|
|
266
|
+
logger.warning(
|
|
267
|
+
"inner twist iteration hit %d solves with residual %.3e rad; the "
|
|
268
|
+
"propeller moment is unusually strong for this blade stiffness",
|
|
269
|
+
_INNER_MAX_SOLVES,
|
|
270
|
+
residual,
|
|
271
|
+
)
|
|
272
|
+
logger.debug("rotating static solve: %d inner solves", solve_count)
|
|
273
|
+
return RotatingSolution(
|
|
274
|
+
solution=solution, inner_solves=solve_count, twist_residual_rad=residual
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def rotating_frequencies(cfg: FsiConfig, n_modes: int = 6) -> beam.ModalResult:
|
|
279
|
+
"""Natural frequencies of the blade spinning at the configured Omega.
|
|
280
|
+
|
|
281
|
+
The blade is first solved statically under its centrifugal loads
|
|
282
|
+
(inner iteration included); the modal problem then adds PyNite's
|
|
283
|
+
geometric stiffness of that tension state and the lumped propeller
|
|
284
|
+
moment stiffening about the converged twist.
|
|
285
|
+
|
|
286
|
+
Parameters
|
|
287
|
+
----------
|
|
288
|
+
cfg : FsiConfig
|
|
289
|
+
Configuration; Omega taken from ``omega_rad_per_s``.
|
|
290
|
+
n_modes : int
|
|
291
|
+
Number of lowest modes to return.
|
|
292
|
+
|
|
293
|
+
Returns
|
|
294
|
+
-------
|
|
295
|
+
beam.ModalResult
|
|
296
|
+
Ascending frequencies with flap or torsion classification.
|
|
297
|
+
"""
|
|
298
|
+
rotating = solve_rotating_static(cfg)
|
|
299
|
+
model = beam.build_beam_model(cfg)
|
|
300
|
+
spinning = cfg.omega_rad_per_s > 0.0
|
|
301
|
+
if spinning:
|
|
302
|
+
beam.apply_station_loads(model, cfg, axial_load_n_per_m=axial_load_distribution(cfg))
|
|
303
|
+
beam.solve_static(model, p_delta=spinning)
|
|
304
|
+
twist_stiffness = (
|
|
305
|
+
propeller_moment_twist_stiffness(cfg, rotating.solution.elastic_twist_rad)
|
|
306
|
+
if spinning
|
|
307
|
+
else None
|
|
308
|
+
)
|
|
309
|
+
return beam.modal_frequencies(
|
|
310
|
+
model,
|
|
311
|
+
cfg,
|
|
312
|
+
n_modes=n_modes,
|
|
313
|
+
include_geometric_stiffness=spinning,
|
|
314
|
+
twist_stiffness_n_m_per_rad=twist_stiffness,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@dataclass(frozen=True)
|
|
319
|
+
class CampbellData:
|
|
320
|
+
"""Frequency tracks over a rotor speed sweep (the Campbell diagram).
|
|
321
|
+
|
|
322
|
+
Attributes
|
|
323
|
+
----------
|
|
324
|
+
omegas_rad_per_s : tuple of float
|
|
325
|
+
Rotor speeds of the sweep [rad/s].
|
|
326
|
+
modal_results : tuple of beam.ModalResult
|
|
327
|
+
Modal result at each rotor speed, same order.
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
omegas_rad_per_s: tuple[float, ...]
|
|
331
|
+
modal_results: tuple["beam.ModalResult", ...]
|
|
332
|
+
|
|
333
|
+
def family_track(self, kind: str) -> list[float]:
|
|
334
|
+
"""First frequency of a family (``"flap"`` or ``"torsion"``) per Omega."""
|
|
335
|
+
track = []
|
|
336
|
+
for result in self.modal_results:
|
|
337
|
+
track.append(
|
|
338
|
+
next(
|
|
339
|
+
f
|
|
340
|
+
for f, k in zip(result.frequencies_rad_per_s, result.kinds, strict=True)
|
|
341
|
+
if k == kind
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
return track
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def campbell_sweep(
|
|
348
|
+
cfg: FsiConfig, omegas_rad_per_s: Sequence[float], n_modes: int = 6
|
|
349
|
+
) -> CampbellData:
|
|
350
|
+
"""Sweep rotor speed and collect the natural frequencies (Gate 1).
|
|
351
|
+
|
|
352
|
+
Parameters
|
|
353
|
+
----------
|
|
354
|
+
cfg : FsiConfig
|
|
355
|
+
Base configuration; its Omega is overridden per sweep point.
|
|
356
|
+
omegas_rad_per_s : sequence of float
|
|
357
|
+
Rotor speeds to sweep [rad/s].
|
|
358
|
+
n_modes : int
|
|
359
|
+
Modes per point.
|
|
360
|
+
|
|
361
|
+
Returns
|
|
362
|
+
-------
|
|
363
|
+
CampbellData
|
|
364
|
+
Frequency tracks of the sweep.
|
|
365
|
+
"""
|
|
366
|
+
results = []
|
|
367
|
+
for omega in omegas_rad_per_s:
|
|
368
|
+
point = cfg.model_copy(update={"omega_rad_per_s": float(omega)})
|
|
369
|
+
results.append(rotating_frequencies(point, n_modes=n_modes))
|
|
370
|
+
return CampbellData(
|
|
371
|
+
omegas_rad_per_s=tuple(float(w) for w in omegas_rad_per_s),
|
|
372
|
+
modal_results=tuple(results),
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def southwell_fit(
|
|
377
|
+
omegas_rad_per_s: Sequence[float], frequencies_rad_per_s: Sequence[float]
|
|
378
|
+
) -> tuple[float, float, float]:
|
|
379
|
+
"""Fit omega_n^2 = omega_0^2 + S Omega^2 to a frequency track.
|
|
380
|
+
|
|
381
|
+
The Southwell coefficient S measures the centrifugal stiffening of
|
|
382
|
+
a mode; the fit quality measures how straight the track is in the
|
|
383
|
+
(Omega^2, omega_n^2) plane, which is the WP4 verification.
|
|
384
|
+
|
|
385
|
+
Source: Southwell's linear frequency-rise approximation for
|
|
386
|
+
rotating beams, as used in the FSI Blade Coupling Plan rev. 2
|
|
387
|
+
(July 2026; after Bielawa, "Rotary Wing Structural Dynamics",
|
|
388
|
+
primary source verification pending, TSR-014).
|
|
389
|
+
|
|
390
|
+
Parameters
|
|
391
|
+
----------
|
|
392
|
+
omegas_rad_per_s : sequence of float
|
|
393
|
+
Rotor speeds of the sweep [rad/s].
|
|
394
|
+
frequencies_rad_per_s : sequence of float
|
|
395
|
+
Natural frequency of one tracked mode at each speed [rad/s].
|
|
396
|
+
|
|
397
|
+
Returns
|
|
398
|
+
-------
|
|
399
|
+
tuple of float
|
|
400
|
+
(omega_0 [rad/s], Southwell coefficient S, r_squared of the fit).
|
|
401
|
+
"""
|
|
402
|
+
if len(omegas_rad_per_s) != len(frequencies_rad_per_s) or len(omegas_rad_per_s) < 3:
|
|
403
|
+
raise ValueError(
|
|
404
|
+
"the Southwell fit needs at least three sweep points with one frequency per rotor speed"
|
|
405
|
+
)
|
|
406
|
+
x = [w**2 for w in omegas_rad_per_s]
|
|
407
|
+
y = [f**2 for f in frequencies_rad_per_s]
|
|
408
|
+
n = len(x)
|
|
409
|
+
x_mean = sum(x) / n
|
|
410
|
+
y_mean = sum(y) / n
|
|
411
|
+
sxx = sum((xi - x_mean) ** 2 for xi in x)
|
|
412
|
+
sxy = sum((xi - x_mean) * (yi - y_mean) for xi, yi in zip(x, y, strict=True))
|
|
413
|
+
slope = sxy / sxx
|
|
414
|
+
intercept = y_mean - slope * x_mean
|
|
415
|
+
ss_res = sum((yi - (intercept + slope * xi)) ** 2 for xi, yi in zip(x, y, strict=True))
|
|
416
|
+
ss_tot = sum((yi - y_mean) ** 2 for yi in y)
|
|
417
|
+
r_squared = 1.0 - ss_res / ss_tot if ss_tot > 0.0 else 1.0
|
|
418
|
+
return math.sqrt(max(intercept, 0.0)), slope, r_squared
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""``pyfs-fsi`` console entry point: the FSI coupling executable.
|
|
2
|
+
|
|
3
|
+
Pipeline role: FlightStream's Aeroelastic Coupling Toolbox calls an
|
|
4
|
+
external executable bare, once per time step, in the directory set by
|
|
5
|
+
``SET_AEROELASTIC_WORKING_DIRECTORY`` (SRC-003 pp.375-376; evidence
|
|
6
|
+
reports/RPT-005). This module is that executable, with two modes
|
|
7
|
+
dispatched on the run folder's content:
|
|
8
|
+
|
|
9
|
+
* Coupled mode (WP7): a ``config.json`` in the working directory makes
|
|
10
|
+
every call run :func:`pyflightstream.fsi.driver.coupling_step`, the
|
|
11
|
+
complete four-phase machine of WP6. The loads and displacement
|
|
12
|
+
files of every call are archived under ``fsi_archive/`` so a run is
|
|
13
|
+
replayable offline afterwards.
|
|
14
|
+
* Dummy mode (WP1, kept as the fallback): with no ``config.json``,
|
|
15
|
+
write zero displacements so the blade stays rigid and archive every
|
|
16
|
+
interface file FlightStream produces; this is how the dry-run
|
|
17
|
+
fixtures were collected.
|
|
18
|
+
|
|
19
|
+
Nothing printed to stdout is visible from inside the solver, so both
|
|
20
|
+
modes log to files and any coupled-mode failure lands with its
|
|
21
|
+
traceback in ``pyfs_fsi_error.log`` instead of a silent nonzero exit.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import datetime
|
|
26
|
+
import json
|
|
27
|
+
import shutil
|
|
28
|
+
import sys
|
|
29
|
+
import traceback
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
COUPLED_CONFIG = "config.json"
|
|
33
|
+
|
|
34
|
+
DUMMY_CONFIG = "pyfs_fsi_dummy.json"
|
|
35
|
+
STATE_FILE = "pyfs_fsi_dummy_state.json"
|
|
36
|
+
DISPLACEMENT_FILE = "FSIDisp.txt"
|
|
37
|
+
CALL_LOG = "pyfs_fsi_calls.log"
|
|
38
|
+
ERROR_LOG = "pyfs_fsi_error.log"
|
|
39
|
+
ARCHIVE_DIR = "fsi_archive"
|
|
40
|
+
# Interface files worth archiving on every call. The exact export set
|
|
41
|
+
# and cadence are WP1 open questions; the directory listing recorded in
|
|
42
|
+
# every archive folder catches anything not matched here.
|
|
43
|
+
ARCHIVE_PATTERNS = ("FS_*.txt", "FSLoad*", "FSI_output.txt", "FSIDisp.txt", "*.sldl")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def init_dummy(directory: Path, node_count: int) -> None:
|
|
47
|
+
"""Write the dummy configuration into the future run directory.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
directory : Path
|
|
52
|
+
Directory FlightStream will run the executable in (the
|
|
53
|
+
simulation folder of the dry run).
|
|
54
|
+
node_count : int
|
|
55
|
+
Number of structural nodes of the imported node list; the
|
|
56
|
+
dummy writes one zero translation per node, in list order.
|
|
57
|
+
"""
|
|
58
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
config = {"node_count": node_count}
|
|
60
|
+
(directory / DUMMY_CONFIG).write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
|
61
|
+
print(f"dummy config written: {directory / DUMMY_CONFIG} (node_count {node_count})")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def coupled_step(cwd: Path, received_argv: tuple[str, ...] = ()) -> int:
|
|
65
|
+
"""Execute one real coupling call in ``cwd`` (WP7 wiring).
|
|
66
|
+
|
|
67
|
+
Runs :func:`pyflightstream.fsi.driver.coupling_step`, archives the
|
|
68
|
+
call's loads and displacement files for offline replay, and logs
|
|
69
|
+
the outcome. Any failure writes its traceback to the error log
|
|
70
|
+
and returns a nonzero exit code, which aborts the FlightStream
|
|
71
|
+
coupling instead of silently continuing rigid.
|
|
72
|
+
"""
|
|
73
|
+
stamp = datetime.datetime.now().isoformat(timespec="milliseconds")
|
|
74
|
+
argv_note = f"argv {list(received_argv)}" if received_argv else "argv none"
|
|
75
|
+
# PyNite lives behind the optional [fsi] extra; import at call time
|
|
76
|
+
# so the dummy mode keeps working without it.
|
|
77
|
+
from pyflightstream.fsi import driver
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
result = driver.coupling_step(cwd)
|
|
81
|
+
except Exception:
|
|
82
|
+
with (cwd / ERROR_LOG).open("a", encoding="utf-8") as log:
|
|
83
|
+
log.write(f"{stamp} coupled step failed in {cwd} ({argv_note})\n")
|
|
84
|
+
log.write(traceback.format_exc() + "\n")
|
|
85
|
+
return 1
|
|
86
|
+
archive = cwd / ARCHIVE_DIR / f"call_{result.call:04d}"
|
|
87
|
+
archive.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
for name in (driver.LOADS_FILE, driver.DISPLACEMENT_FILE):
|
|
89
|
+
source = cwd / name
|
|
90
|
+
if source.is_file():
|
|
91
|
+
shutil.copy2(source, archive / name)
|
|
92
|
+
with (cwd / CALL_LOG).open("a", encoding="utf-8") as log:
|
|
93
|
+
log.write(
|
|
94
|
+
f"{stamp} coupled call {result.call} (step {result.step}, phase "
|
|
95
|
+
f"{result.phase}, cwd {cwd}, {argv_note}): FSIDisp written\n"
|
|
96
|
+
)
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def dummy_step(cwd: Path, received_argv: tuple[str, ...] = ()) -> int:
|
|
101
|
+
"""Execute one dummy coupling call in ``cwd``.
|
|
102
|
+
|
|
103
|
+
Archives the visible interface files, writes zero displacements,
|
|
104
|
+
and appends to the call log. The working directory and any
|
|
105
|
+
received arguments are recorded on every call: both are open
|
|
106
|
+
questions of the WP1 dry run. Returns a process exit code.
|
|
107
|
+
"""
|
|
108
|
+
stamp = datetime.datetime.now().isoformat(timespec="milliseconds")
|
|
109
|
+
argv_note = f"argv {list(received_argv)}" if received_argv else "argv none"
|
|
110
|
+
config_path = cwd / DUMMY_CONFIG
|
|
111
|
+
if not config_path.is_file():
|
|
112
|
+
listing = "\n".join(sorted(p.name for p in cwd.iterdir()))
|
|
113
|
+
(cwd / ERROR_LOG).write_text(
|
|
114
|
+
f"{stamp} pyfs-fsi called without {DUMMY_CONFIG} in {cwd} ({argv_note})\n"
|
|
115
|
+
f"directory listing:\n{listing}\n",
|
|
116
|
+
encoding="utf-8",
|
|
117
|
+
)
|
|
118
|
+
return 1
|
|
119
|
+
node_count = json.loads(config_path.read_text(encoding="utf-8"))["node_count"]
|
|
120
|
+
|
|
121
|
+
state_path = cwd / STATE_FILE
|
|
122
|
+
if state_path.is_file():
|
|
123
|
+
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
124
|
+
else:
|
|
125
|
+
state = {"calls": 0}
|
|
126
|
+
call_number = state["calls"] + 1
|
|
127
|
+
|
|
128
|
+
archive = cwd / ARCHIVE_DIR / f"call_{call_number:04d}"
|
|
129
|
+
archive.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
copied = []
|
|
131
|
+
for pattern in ARCHIVE_PATTERNS:
|
|
132
|
+
for path in sorted(cwd.glob(pattern)):
|
|
133
|
+
shutil.copy2(path, archive / path.name)
|
|
134
|
+
copied.append(path.name)
|
|
135
|
+
listing_lines = [
|
|
136
|
+
f"{p.name}\t{p.stat().st_size}\t{datetime.datetime.fromtimestamp(p.stat().st_mtime).isoformat(timespec='seconds')}"
|
|
137
|
+
for p in sorted(cwd.iterdir())
|
|
138
|
+
if p.is_file()
|
|
139
|
+
]
|
|
140
|
+
(archive / "directory_listing.txt").write_text(
|
|
141
|
+
"\n".join(listing_lines) + "\n", encoding="utf-8"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Comma separated per the FSIDisp.txt format of SRC-003 p.273.
|
|
145
|
+
zero_line = "0.000000000000e+00,0.000000000000e+00,0.000000000000e+00"
|
|
146
|
+
(cwd / DISPLACEMENT_FILE).write_text(
|
|
147
|
+
"\n".join([zero_line] * node_count) + "\n", encoding="utf-8"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
state["calls"] = call_number
|
|
151
|
+
tmp = state_path.with_suffix(".tmp")
|
|
152
|
+
tmp.write_text(json.dumps(state) + "\n", encoding="utf-8")
|
|
153
|
+
tmp.replace(state_path)
|
|
154
|
+
|
|
155
|
+
with (cwd / CALL_LOG).open("a", encoding="utf-8") as log:
|
|
156
|
+
log.write(
|
|
157
|
+
f"{stamp} call {call_number} (cwd {cwd}, {argv_note}): wrote "
|
|
158
|
+
f"{node_count} zero displacement vectors; archived {copied or 'nothing'}\n"
|
|
159
|
+
)
|
|
160
|
+
return 0
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _step(cwd: Path, received_argv: tuple[str, ...] = ()) -> int:
|
|
164
|
+
"""Dispatch one coupling call: coupled if configured, dummy otherwise."""
|
|
165
|
+
if (cwd / COUPLED_CONFIG).is_file():
|
|
166
|
+
return coupled_step(cwd, received_argv=received_argv)
|
|
167
|
+
return dummy_step(cwd, received_argv=received_argv)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def main(argv: list[str] | None = None) -> int:
|
|
171
|
+
"""Entry point of the ``pyfs-fsi`` console script."""
|
|
172
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
173
|
+
if not argv:
|
|
174
|
+
# FlightStream calls the executable bare: one coupling step,
|
|
175
|
+
# coupled when the working directory carries a config.json.
|
|
176
|
+
return _step(Path.cwd())
|
|
177
|
+
if argv[0] not in ("init-dummy", "step", "-h", "--help"):
|
|
178
|
+
# Unknown call convention: the Toolbox may pass arguments of its
|
|
179
|
+
# own. Execute the coupling step anyway and record the arguments
|
|
180
|
+
# as evidence instead of dying on argparse.
|
|
181
|
+
return _step(Path.cwd(), received_argv=tuple(argv))
|
|
182
|
+
parser = argparse.ArgumentParser(
|
|
183
|
+
prog="pyfs-fsi",
|
|
184
|
+
description=(
|
|
185
|
+
"FSI coupling executable for the FlightStream Aeroelastic Toolbox. "
|
|
186
|
+
"Called with no arguments it executes one coupling step in the "
|
|
187
|
+
"current directory (dummy mode until the coupled driver lands)."
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
191
|
+
p_init = sub.add_parser("init-dummy", help="write the dummy configuration into a run directory")
|
|
192
|
+
p_init.add_argument("--node-count", type=int, required=True)
|
|
193
|
+
p_init.add_argument("--dir", type=Path, default=Path.cwd())
|
|
194
|
+
p_step = sub.add_parser("step", help="execute one coupling step explicitly")
|
|
195
|
+
p_step.add_argument("--dir", type=Path, default=Path.cwd())
|
|
196
|
+
args = parser.parse_args(argv)
|
|
197
|
+
if args.command == "init-dummy":
|
|
198
|
+
if args.node_count < 1:
|
|
199
|
+
parser.error("a structural node list has at least one node")
|
|
200
|
+
init_dummy(args.dir, args.node_count)
|
|
201
|
+
return 0
|
|
202
|
+
return _step(args.dir)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
if __name__ == "__main__":
|
|
206
|
+
raise SystemExit(main())
|