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,501 @@
|
|
|
1
|
+
"""Coupling driver: the four-phase state machine of one FSI call (WP6).
|
|
2
|
+
|
|
3
|
+
Pipeline role: this is the brain of the coupling executable. Per call
|
|
4
|
+
FlightStream has already run its post-processing script, so the run
|
|
5
|
+
folder holds a fresh ``FS_SurfaceSection_Loads.txt``; the driver
|
|
6
|
+
parses it, assembles per-blade loads about the elastic axis, solves
|
|
7
|
+
the rotating beam per blade, relaxes the displacements against the
|
|
8
|
+
previous call, writes ``FSIDisp.txt`` in the single-source node order,
|
|
9
|
+
appends the convergence log, and persists ``state.json`` atomically.
|
|
10
|
+
Everything is file-driven, so the offline replay harness of the tier 1
|
|
11
|
+
suite exercises the complete machine on archived fixtures with no
|
|
12
|
+
FlightStream in the loop (DLV-007 Section 8).
|
|
13
|
+
|
|
14
|
+
The phase machine is keyed on the step counter (DLV-007 Section 4.5),
|
|
15
|
+
with the step-to-revolution conversion taken from the configured Omega
|
|
16
|
+
and the time increment printed in the loads file itself:
|
|
17
|
+
|
|
18
|
+
1. Wake development: zero displacements while the wake develops on
|
|
19
|
+
the rigid blade.
|
|
20
|
+
2. Averaged coupling: loads averaged over the configured window,
|
|
21
|
+
relaxed updates (FSI-R07).
|
|
22
|
+
3. Convergence watch: as phase 2; per completed revolution the tip
|
|
23
|
+
response enters the log, and convergence is declared when the tip
|
|
24
|
+
elastic twist change per revolution drops below the configured
|
|
25
|
+
tolerance (FSI-R09; the revolution-averaged thrust stability is
|
|
26
|
+
judged from the same log downstream).
|
|
27
|
+
4. Recording: instantaneous loads, no relaxation (lambda = 1 by
|
|
28
|
+
design: relaxing here would low-pass exactly the 1P amplitude and
|
|
29
|
+
phase being measured), twist distributions recorded per step.
|
|
30
|
+
|
|
31
|
+
Frozen mode (FSI-R10) is first class: when the run folder holds a
|
|
32
|
+
``fsi_frozen_displacements.txt``, every call replays it verbatim, with
|
|
33
|
+
no loads parsing and no solve, so a stored deformation can be held
|
|
34
|
+
fixed for sensitivity runs and Gate 2.
|
|
35
|
+
|
|
36
|
+
The convergence log carries the config hash on every row (FSI-R15)
|
|
37
|
+
and states the quasi-steady validity boundary in its header (DLV-007
|
|
38
|
+
Section 4.1).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import logging
|
|
44
|
+
import math
|
|
45
|
+
from dataclasses import dataclass
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
|
|
48
|
+
import numpy as np
|
|
49
|
+
|
|
50
|
+
from pyflightstream.fsi import beam, centrifugal, kinematics, nodes
|
|
51
|
+
from pyflightstream.fsi.config import FsiConfig, config_hash, load_config
|
|
52
|
+
from pyflightstream.fsi.loads import (
|
|
53
|
+
ElasticAxisLoads,
|
|
54
|
+
SectionFamilyMap,
|
|
55
|
+
parse_sectional_loads,
|
|
56
|
+
to_elastic_axis,
|
|
57
|
+
)
|
|
58
|
+
from pyflightstream.fsi.state import (
|
|
59
|
+
FsiState,
|
|
60
|
+
LoadSample,
|
|
61
|
+
RecordedTwist,
|
|
62
|
+
RevolutionSample,
|
|
63
|
+
initial_state,
|
|
64
|
+
load_state,
|
|
65
|
+
write_state_atomic,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
logger = logging.getLogger(__name__)
|
|
69
|
+
|
|
70
|
+
CONFIG_FILE = "config.json"
|
|
71
|
+
STATE_FILE = "state.json"
|
|
72
|
+
LOADS_FILE = "FS_SurfaceSection_Loads.txt"
|
|
73
|
+
DISPLACEMENT_FILE = "FSIDisp.txt"
|
|
74
|
+
FAMILY_MAP_FILE = "fsi_family_map.json"
|
|
75
|
+
LOG_FILE = "fsi_convergence_log.csv"
|
|
76
|
+
FROZEN_FILE = "fsi_frozen_displacements.txt"
|
|
77
|
+
|
|
78
|
+
_LOG_HEADER = (
|
|
79
|
+
"# pyflightstream FSI convergence log (FSI-R09, FSI-R15)\n"
|
|
80
|
+
"# quasi-steady model: azimuthal (1P) content is trustworthy only where\n"
|
|
81
|
+
"# n Omega / omega_n stays at or below about 0.3 (DLV-007 Section 4.1)\n"
|
|
82
|
+
"call,step,phase,revolutions,solver_iteration,total_normal_force_n,"
|
|
83
|
+
"tip_flap_m,tip_twist_deg,inner_solves,relaxation,config_hash\n"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class StaleLoadsError(ValueError):
|
|
88
|
+
"""The loads file did not advance between calls (FSI-R12).
|
|
89
|
+
|
|
90
|
+
A call receiving the same solver iteration as the previous one is
|
|
91
|
+
a second FSI iteration inside one time step: the Toolbox is not
|
|
92
|
+
configured with ``SET_AEROELASTIC_ITERATIONS 1``, and continuing
|
|
93
|
+
would average duplicated loads and desynchronize the call and step
|
|
94
|
+
counters.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class StepResult:
|
|
100
|
+
"""Summary of one executed coupling call.
|
|
101
|
+
|
|
102
|
+
Attributes
|
|
103
|
+
----------
|
|
104
|
+
call, step : int
|
|
105
|
+
Counters after the call (equal while iterations stay 1).
|
|
106
|
+
phase : int or str
|
|
107
|
+
Executed phase (1 to 4) or ``"frozen"``.
|
|
108
|
+
revolutions : float or None
|
|
109
|
+
Rotor revolutions completed at this step; None in frozen mode.
|
|
110
|
+
relaxation : float or None
|
|
111
|
+
Relaxation factor applied to the displacement update; None in
|
|
112
|
+
phase 1 (zeros are written unconditionally) and frozen mode.
|
|
113
|
+
displacements : numpy.ndarray
|
|
114
|
+
The FSIDisp rows written, shape ``(total_nodes, 3)`` [m].
|
|
115
|
+
solutions : tuple of beam.StaticBeamSolution or None
|
|
116
|
+
Per-blade beam solutions of this call; None when no solve ran
|
|
117
|
+
(phase 1 and frozen mode).
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
call: int
|
|
121
|
+
step: int
|
|
122
|
+
phase: int | str
|
|
123
|
+
revolutions: float | None
|
|
124
|
+
relaxation: float | None
|
|
125
|
+
displacements: np.ndarray
|
|
126
|
+
solutions: tuple[beam.StaticBeamSolution, ...] | None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def revolutions_per_step(omega_rad_per_s: float, time_increment_s: float) -> float:
|
|
130
|
+
"""Rotor revolutions swept by one unsteady time step.
|
|
131
|
+
|
|
132
|
+
Omega dt / (2 pi): constant-speed rotation kinematics, the
|
|
133
|
+
conversion the phase schedule of DLV-007 Section 4.5 is keyed on.
|
|
134
|
+
Omega comes from the configuration and dt from the loads file of
|
|
135
|
+
the run itself, so a config/run mismatch shows up as a wrong phase
|
|
136
|
+
schedule instead of hiding.
|
|
137
|
+
|
|
138
|
+
Source: DLV-007 Section 4.5 (phase schedule in revolutions);
|
|
139
|
+
elementary kinematics of rotation at constant angular speed.
|
|
140
|
+
"""
|
|
141
|
+
if omega_rad_per_s <= 0.0 or time_increment_s <= 0.0:
|
|
142
|
+
raise ValueError(
|
|
143
|
+
"revolutions need a spinning rotor and an advancing clock: got "
|
|
144
|
+
f"Omega {omega_rad_per_s} rad/s and dt {time_increment_s} s; the "
|
|
145
|
+
"coupled unsteady driver schedules its phases in revolutions "
|
|
146
|
+
"(DLV-007 Section 4.5)"
|
|
147
|
+
)
|
|
148
|
+
return omega_rad_per_s * time_increment_s / (2.0 * math.pi)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def relax_displacements(
|
|
152
|
+
previous: np.ndarray, computed: np.ndarray, relaxation: float
|
|
153
|
+
) -> np.ndarray:
|
|
154
|
+
"""Relaxed displacement update d_new = d_old + lambda (d_calc - d_old).
|
|
155
|
+
|
|
156
|
+
Under-relaxation (lambda below 1) damps the aeroelastic feedback
|
|
157
|
+
of the averaged coupling phases; lambda = 1 returns the computed
|
|
158
|
+
displacements unchanged, the phase 4 behavior (FSI-R07).
|
|
159
|
+
|
|
160
|
+
Source: DLV-007 Section 4.5 (relaxation and phases).
|
|
161
|
+
"""
|
|
162
|
+
previous = np.asarray(previous, dtype=float)
|
|
163
|
+
computed = np.asarray(computed, dtype=float)
|
|
164
|
+
return previous + relaxation * (computed - previous)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _blade_densities(
|
|
168
|
+
ea_loads: ElasticAxisLoads, station_radii_m: list[float]
|
|
169
|
+
) -> tuple[list[float], list[float]]:
|
|
170
|
+
"""Interpolate one blade's load densities at the config stations.
|
|
171
|
+
|
|
172
|
+
The export rows already are line densities (RPT-006), so this is
|
|
173
|
+
pure resampling; constant extrapolation covers the small root and
|
|
174
|
+
tip margins the section distribution does not reach.
|
|
175
|
+
"""
|
|
176
|
+
order = np.argsort(ea_loads.radius_m)
|
|
177
|
+
radii = ea_loads.radius_m[order]
|
|
178
|
+
flap = np.interp(station_radii_m, radii, ea_loads.flap_load_n_per_m[order])
|
|
179
|
+
torsion = np.interp(station_radii_m, radii, ea_loads.torsion_moment_nm_per_m[order])
|
|
180
|
+
return flap.tolist(), torsion.tolist()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _schedule_phase(cfg: FsiConfig, state: FsiState, revolutions: float) -> int:
|
|
184
|
+
"""Phase of the current call from the schedule and the state."""
|
|
185
|
+
if state.phase == 4:
|
|
186
|
+
return 4
|
|
187
|
+
schedule = cfg.phases
|
|
188
|
+
if revolutions < schedule.wake_development_revolutions:
|
|
189
|
+
return 1
|
|
190
|
+
if revolutions < schedule.wake_development_revolutions + schedule.averaging_window_revolutions:
|
|
191
|
+
return 2
|
|
192
|
+
return 3
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _averaged_history(history: list[LoadSample]) -> tuple[np.ndarray, np.ndarray]:
|
|
196
|
+
"""Mean flap and torsion densities over the buffered samples."""
|
|
197
|
+
flap = np.mean([sample.flap_n_per_m for sample in history], axis=0)
|
|
198
|
+
torsion = np.mean([sample.torsion_nm_per_m for sample in history], axis=0)
|
|
199
|
+
return flap, torsion
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _append_log(run_dir: Path, row: dict[str, object]) -> None:
|
|
203
|
+
"""Append one convergence-log row, writing the header on first use."""
|
|
204
|
+
path = run_dir / LOG_FILE
|
|
205
|
+
line = (
|
|
206
|
+
f"{row['call']},{row['step']},{row['phase']},{row['revolutions']},"
|
|
207
|
+
f"{row['solver_iteration']},{row['total_normal_force_n']},"
|
|
208
|
+
f"{row['tip_flap_m']},{row['tip_twist_deg']},{row['inner_solves']},"
|
|
209
|
+
f"{row['relaxation']},{row['config_hash']}\n"
|
|
210
|
+
)
|
|
211
|
+
if not path.is_file():
|
|
212
|
+
path.write_text(_LOG_HEADER + line, encoding="utf-8")
|
|
213
|
+
else:
|
|
214
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
215
|
+
handle.write(line)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _verified_layout(cfg: FsiConfig, run_dir: Path) -> nodes.NodeOrderingMap:
|
|
219
|
+
"""Regenerate the node layout and hold the staged map to it (FSI-R14).
|
|
220
|
+
|
|
221
|
+
The layout is always regenerated from the configuration (the
|
|
222
|
+
single source); a serialized map in the run folder must agree with
|
|
223
|
+
it, and a missing one is written so downstream consumers read the
|
|
224
|
+
same bookkeeping.
|
|
225
|
+
"""
|
|
226
|
+
layout = nodes.generate_node_layout(cfg)
|
|
227
|
+
map_path = run_dir / cfg.node_map_file
|
|
228
|
+
if map_path.is_file():
|
|
229
|
+
staged = nodes.load_node_map(map_path)
|
|
230
|
+
if staged != layout:
|
|
231
|
+
raise ValueError(
|
|
232
|
+
f"the staged node map {map_path.name} disagrees with the layout "
|
|
233
|
+
"generated from config.json; the imported node file and the "
|
|
234
|
+
"FSIDisp ordering would desynchronize, which is exactly the "
|
|
235
|
+
"corruption FSI-R14 forbids. Regenerate the run folder from one "
|
|
236
|
+
"configuration."
|
|
237
|
+
)
|
|
238
|
+
else:
|
|
239
|
+
nodes.write_node_map(layout, map_path)
|
|
240
|
+
return layout
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _frozen_step(run_dir: Path, cfg: FsiConfig, state: FsiState) -> StepResult:
|
|
244
|
+
"""Replay the stored deformation without coupling (FSI-R10)."""
|
|
245
|
+
layout = _verified_layout(cfg, run_dir)
|
|
246
|
+
translations = nodes.read_fsidisp(run_dir / FROZEN_FILE, expected_rows=layout.total_nodes)
|
|
247
|
+
nodes.write_fsidisp(run_dir / DISPLACEMENT_FILE, translations)
|
|
248
|
+
state.call_count += 1
|
|
249
|
+
state.step_count += 1
|
|
250
|
+
state.previous_displacements = translations.tolist()
|
|
251
|
+
_append_log(
|
|
252
|
+
run_dir,
|
|
253
|
+
{
|
|
254
|
+
"call": state.call_count,
|
|
255
|
+
"step": state.step_count,
|
|
256
|
+
"phase": "frozen",
|
|
257
|
+
"revolutions": "",
|
|
258
|
+
"solver_iteration": "",
|
|
259
|
+
"total_normal_force_n": "",
|
|
260
|
+
"tip_flap_m": "",
|
|
261
|
+
"tip_twist_deg": "",
|
|
262
|
+
"inner_solves": "",
|
|
263
|
+
"relaxation": "",
|
|
264
|
+
"config_hash": config_hash(cfg),
|
|
265
|
+
},
|
|
266
|
+
)
|
|
267
|
+
write_state_atomic(state, run_dir / STATE_FILE)
|
|
268
|
+
logger.info("frozen replay: call %d wrote the stored deformation", state.call_count)
|
|
269
|
+
return StepResult(
|
|
270
|
+
call=state.call_count,
|
|
271
|
+
step=state.step_count,
|
|
272
|
+
phase="frozen",
|
|
273
|
+
revolutions=None,
|
|
274
|
+
relaxation=None,
|
|
275
|
+
displacements=translations,
|
|
276
|
+
solutions=None,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def coupling_step(run_dir: str | Path) -> StepResult:
|
|
281
|
+
"""Execute one coupling call inside a run folder.
|
|
282
|
+
|
|
283
|
+
Reads ``config.json``, ``state.json`` (or starts fresh),
|
|
284
|
+
``fsi_family_map.json``, and the loads export; writes
|
|
285
|
+
``FSIDisp.txt``, the convergence log row, and the atomically
|
|
286
|
+
updated ``state.json``. With a ``fsi_frozen_displacements.txt``
|
|
287
|
+
present the call replays it instead (FSI-R10).
|
|
288
|
+
|
|
289
|
+
Parameters
|
|
290
|
+
----------
|
|
291
|
+
run_dir : str or Path
|
|
292
|
+
Working directory set by ``SET_AEROELASTIC_WORKING_DIRECTORY``.
|
|
293
|
+
|
|
294
|
+
Returns
|
|
295
|
+
-------
|
|
296
|
+
StepResult
|
|
297
|
+
Summary of the executed call.
|
|
298
|
+
"""
|
|
299
|
+
run_dir = Path(run_dir)
|
|
300
|
+
cfg = load_config(run_dir / CONFIG_FILE)
|
|
301
|
+
state_path = run_dir / STATE_FILE
|
|
302
|
+
state = load_state(state_path) if state_path.is_file() else initial_state()
|
|
303
|
+
|
|
304
|
+
if (run_dir / FROZEN_FILE).is_file():
|
|
305
|
+
return _frozen_step(run_dir, cfg, state)
|
|
306
|
+
|
|
307
|
+
layout = _verified_layout(cfg, run_dir)
|
|
308
|
+
report = parse_sectional_loads((run_dir / LOADS_FILE).read_text(encoding="utf-8"))
|
|
309
|
+
if state.last_solver_iteration is not None and (
|
|
310
|
+
report.current_iteration <= state.last_solver_iteration
|
|
311
|
+
):
|
|
312
|
+
raise StaleLoadsError(
|
|
313
|
+
f"call {state.call_count + 1} received solver iteration "
|
|
314
|
+
f"{report.current_iteration}, not ahead of the previous "
|
|
315
|
+
f"{state.last_solver_iteration}: FlightStream is running more than "
|
|
316
|
+
"one FSI iteration per time step; SET_AEROELASTIC_ITERATIONS must "
|
|
317
|
+
"stay 1 (FSI-R12)"
|
|
318
|
+
)
|
|
319
|
+
if report.time_increment_s is None:
|
|
320
|
+
raise ValueError(
|
|
321
|
+
"the loads export carries no time increment, so it comes from a "
|
|
322
|
+
"steady solve; the coupled driver runs inside the unsteady solver "
|
|
323
|
+
"(SET_AEROELASTIC_COUPLING_IN_UNSTEADY, RPT-005)"
|
|
324
|
+
)
|
|
325
|
+
# The header prints the increment with three decimals only (RPT-006),
|
|
326
|
+
# so a configured dt drives the revolution bookkeeping and the printed
|
|
327
|
+
# value is held to it at print precision.
|
|
328
|
+
time_increment_s = report.time_increment_s
|
|
329
|
+
if cfg.time_increment_s is not None:
|
|
330
|
+
if abs(cfg.time_increment_s - report.time_increment_s) > 5.0e-4:
|
|
331
|
+
raise ValueError(
|
|
332
|
+
f"the loads export prints a time increment of "
|
|
333
|
+
f"{report.time_increment_s} s but the configuration declares "
|
|
334
|
+
f"{cfg.time_increment_s} s; beyond the header's three-decimal "
|
|
335
|
+
"print precision this is a different run than the "
|
|
336
|
+
"configuration describes (RPT-006)"
|
|
337
|
+
)
|
|
338
|
+
time_increment_s = cfg.time_increment_s
|
|
339
|
+
state.call_count += 1
|
|
340
|
+
state.step_count += 1
|
|
341
|
+
state.last_solver_iteration = report.current_iteration
|
|
342
|
+
|
|
343
|
+
family_map = SectionFamilyMap.model_validate_json(
|
|
344
|
+
(run_dir / FAMILY_MAP_FILE).read_text(encoding="utf-8")
|
|
345
|
+
)
|
|
346
|
+
blade_families = [family.name for family in family_map.families if family.is_blade]
|
|
347
|
+
if len(blade_families) != cfg.blade_count:
|
|
348
|
+
raise ValueError(
|
|
349
|
+
f"the family map marks {len(blade_families)} blade families "
|
|
350
|
+
f"({blade_families}) but the configuration expects {cfg.blade_count} "
|
|
351
|
+
"blades; attribution is single-sourced in the map (RPT-005 finding 6)"
|
|
352
|
+
)
|
|
353
|
+
blocks = report.split(family_map)
|
|
354
|
+
|
|
355
|
+
rev_per_step = revolutions_per_step(cfg.omega_rad_per_s, time_increment_s)
|
|
356
|
+
steps_per_rev = 1.0 / rev_per_step
|
|
357
|
+
revolutions = state.step_count * rev_per_step
|
|
358
|
+
phase = _schedule_phase(cfg, state, revolutions)
|
|
359
|
+
|
|
360
|
+
stations = cfg.blade.station_radii_m
|
|
361
|
+
flap_per_blade, torsion_per_blade = [], []
|
|
362
|
+
total_normal_force = 0.0
|
|
363
|
+
for name in blade_families:
|
|
364
|
+
ea_loads = to_elastic_axis(blocks[name], cfg)
|
|
365
|
+
flap, torsion = _blade_densities(ea_loads, stations)
|
|
366
|
+
flap_per_blade.append(flap)
|
|
367
|
+
torsion_per_blade.append(torsion)
|
|
368
|
+
total_normal_force += float(
|
|
369
|
+
(ea_loads.force_normal_n_per_m * ea_loads.tributary_width_m).sum()
|
|
370
|
+
)
|
|
371
|
+
state.load_history.append(
|
|
372
|
+
LoadSample(
|
|
373
|
+
step=state.step_count,
|
|
374
|
+
flap_n_per_m=flap_per_blade,
|
|
375
|
+
torsion_nm_per_m=torsion_per_blade,
|
|
376
|
+
)
|
|
377
|
+
)
|
|
378
|
+
window_steps = max(1, math.ceil(cfg.phases.averaging_window_revolutions * steps_per_rev))
|
|
379
|
+
state.load_history = state.load_history[-window_steps:]
|
|
380
|
+
|
|
381
|
+
zeros = np.zeros((layout.total_nodes, 3))
|
|
382
|
+
le = np.asarray(layout.le_offset_m)
|
|
383
|
+
te = np.asarray(layout.te_offset_m)
|
|
384
|
+
if phase == 1:
|
|
385
|
+
relaxation = None
|
|
386
|
+
solutions: tuple[beam.StaticBeamSolution, ...] | None = None
|
|
387
|
+
written = zeros
|
|
388
|
+
inner_solves = 0
|
|
389
|
+
else:
|
|
390
|
+
if phase == 4:
|
|
391
|
+
relaxation = 1.0
|
|
392
|
+
flap_solve = np.asarray(flap_per_blade)
|
|
393
|
+
torsion_solve = np.asarray(torsion_per_blade)
|
|
394
|
+
else:
|
|
395
|
+
relaxation = cfg.phases.coupling_relaxation
|
|
396
|
+
flap_solve, torsion_solve = _averaged_history(state.load_history)
|
|
397
|
+
solved = [
|
|
398
|
+
centrifugal.solve_rotating_static(
|
|
399
|
+
cfg,
|
|
400
|
+
flap_load_n_per_m=list(flap_solve[i]),
|
|
401
|
+
torsion_moment_n_m_per_m=list(torsion_solve[i]),
|
|
402
|
+
)
|
|
403
|
+
for i in range(cfg.blade_count)
|
|
404
|
+
]
|
|
405
|
+
solutions = tuple(result.solution for result in solved)
|
|
406
|
+
computed = nodes.flatten_blade_translations(
|
|
407
|
+
layout,
|
|
408
|
+
[
|
|
409
|
+
kinematics.encode_station_translations(
|
|
410
|
+
np.asarray(sol.flap_deflection_m),
|
|
411
|
+
np.asarray(sol.elastic_twist_rad),
|
|
412
|
+
le,
|
|
413
|
+
te,
|
|
414
|
+
)
|
|
415
|
+
for sol in solutions
|
|
416
|
+
],
|
|
417
|
+
)
|
|
418
|
+
previous = (
|
|
419
|
+
np.asarray(state.previous_displacements, dtype=float)
|
|
420
|
+
if state.previous_displacements is not None
|
|
421
|
+
else zeros
|
|
422
|
+
)
|
|
423
|
+
written = relax_displacements(previous, computed, relaxation)
|
|
424
|
+
state.previous_twist_rad = [list(sol.elastic_twist_rad) for sol in solutions]
|
|
425
|
+
inner_solves = max(result.inner_solves for result in solved)
|
|
426
|
+
nodes.write_fsidisp(run_dir / DISPLACEMENT_FILE, written)
|
|
427
|
+
state.previous_displacements = written.tolist()
|
|
428
|
+
|
|
429
|
+
tip_twist_deg = [math.degrees(sol.elastic_twist_rad[-1]) for sol in (solutions or ())] or [
|
|
430
|
+
0.0
|
|
431
|
+
] * cfg.blade_count
|
|
432
|
+
tip_flap_m = [sol.flap_deflection_m[-1] for sol in (solutions or ())] or [0.0] * cfg.blade_count
|
|
433
|
+
|
|
434
|
+
completed = math.floor(revolutions + 1e-9)
|
|
435
|
+
if completed > state.completed_revolutions:
|
|
436
|
+
state.revolution_history.append(
|
|
437
|
+
RevolutionSample(
|
|
438
|
+
revolution=completed, tip_twist_deg=tip_twist_deg, tip_flap_m=tip_flap_m
|
|
439
|
+
)
|
|
440
|
+
)
|
|
441
|
+
if phase == 3 and len(state.revolution_history) >= 2:
|
|
442
|
+
last, previous_rev = state.revolution_history[-1], state.revolution_history[-2]
|
|
443
|
+
change = max(
|
|
444
|
+
abs(a - b)
|
|
445
|
+
for a, b in zip(last.tip_twist_deg, previous_rev.tip_twist_deg, strict=True)
|
|
446
|
+
)
|
|
447
|
+
if change < cfg.phases.tip_twist_tolerance_deg:
|
|
448
|
+
state.phase = 4
|
|
449
|
+
state.phase4_start_step = state.step_count + 1
|
|
450
|
+
logger.info(
|
|
451
|
+
"convergence declared at revolution %d (tip twist change "
|
|
452
|
+
"%.4f deg < %.4f deg); phase 4 recording starts next step",
|
|
453
|
+
completed,
|
|
454
|
+
change,
|
|
455
|
+
cfg.phases.tip_twist_tolerance_deg,
|
|
456
|
+
)
|
|
457
|
+
if phase != 4 and state.phase != 4:
|
|
458
|
+
state.phase = phase
|
|
459
|
+
if phase == 4 and state.phase4_start_step is not None:
|
|
460
|
+
recording_steps = math.ceil(cfg.phases.recording_revolutions * steps_per_rev)
|
|
461
|
+
if state.step_count - state.phase4_start_step < recording_steps:
|
|
462
|
+
state.recorded_twist.append(
|
|
463
|
+
RecordedTwist(
|
|
464
|
+
step=state.step_count,
|
|
465
|
+
elastic_twist_rad=[list(sol.elastic_twist_rad) for sol in solutions],
|
|
466
|
+
)
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
_append_log(
|
|
470
|
+
run_dir,
|
|
471
|
+
{
|
|
472
|
+
"call": state.call_count,
|
|
473
|
+
"step": state.step_count,
|
|
474
|
+
"phase": phase,
|
|
475
|
+
"revolutions": f"{revolutions:.6f}",
|
|
476
|
+
"solver_iteration": report.current_iteration,
|
|
477
|
+
"total_normal_force_n": f"{total_normal_force:.6f}",
|
|
478
|
+
"tip_flap_m": f"{max(abs(v) for v in tip_flap_m):.6e}",
|
|
479
|
+
"tip_twist_deg": f"{max(abs(v) for v in tip_twist_deg):.6e}",
|
|
480
|
+
"inner_solves": inner_solves,
|
|
481
|
+
"relaxation": "" if relaxation is None else f"{relaxation:.3f}",
|
|
482
|
+
"config_hash": config_hash(cfg),
|
|
483
|
+
},
|
|
484
|
+
)
|
|
485
|
+
write_state_atomic(state, run_dir / STATE_FILE)
|
|
486
|
+
logger.info(
|
|
487
|
+
"coupling call %d (step %d, phase %s, %.3f rev) written",
|
|
488
|
+
state.call_count,
|
|
489
|
+
state.step_count,
|
|
490
|
+
phase,
|
|
491
|
+
revolutions,
|
|
492
|
+
)
|
|
493
|
+
return StepResult(
|
|
494
|
+
call=state.call_count,
|
|
495
|
+
step=state.step_count,
|
|
496
|
+
phase=phase,
|
|
497
|
+
revolutions=revolutions,
|
|
498
|
+
relaxation=relaxation,
|
|
499
|
+
displacements=written,
|
|
500
|
+
solutions=solutions,
|
|
501
|
+
)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Rigid-section kinematics: (w, theta) to node translations and back (WP5).
|
|
2
|
+
|
|
3
|
+
Pipeline role: ``FSIDisp.txt`` carries translations only, no
|
|
4
|
+
rotational degrees of freedom (DLV-007 Section 3), so the elastic
|
|
5
|
+
twist must be encoded geometrically: three structural nodes per radial
|
|
6
|
+
station, one on the elastic axis and one offset toward each of the
|
|
7
|
+
leading and trailing edges. The beam solution (w, theta) maps to nodal
|
|
8
|
+
translations by rigid section kinematics, and FlightStream
|
|
9
|
+
interpolates the surface deflection from them, so the twist field
|
|
10
|
+
emerges from differential translations (DLV-007 Section 4.4).
|
|
11
|
+
|
|
12
|
+
Frames: everything lives in the rotating blade frame of
|
|
13
|
+
:mod:`pyflightstream.fsi.config`: X chordwise toward the leading edge,
|
|
14
|
+
Y normal (the flap direction), Z spanwise from root to tip. The flap
|
|
15
|
+
deflection w translates a section along +Y; the elastic twist theta is
|
|
16
|
+
positive nose up about +Z, so a node at chordwise offset d from the
|
|
17
|
+
elastic axis translates by theta d along +Y (linearized rotation,
|
|
18
|
+
consistent with the quasi-steady small-deformation model). The
|
|
19
|
+
encoding is exactly linear by design: translations are w + theta d
|
|
20
|
+
along the normal axis and nothing else, so the inverse map is exact
|
|
21
|
+
and the round trip closes at machine precision.
|
|
22
|
+
|
|
23
|
+
The node ordering that turns per-station translations into the flat
|
|
24
|
+
``FSIDisp.txt`` rows is owned by :mod:`pyflightstream.fsi.nodes`
|
|
25
|
+
(FSI-R14); this module only maps solutions to per-station node
|
|
26
|
+
translations and back.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
|
|
33
|
+
#: Per-station node roles, in the encoding order used everywhere.
|
|
34
|
+
NODE_ROLES = ("elastic_axis", "leading_edge", "trailing_edge")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def station_normal_translation(
|
|
38
|
+
flap_deflection_m: np.ndarray,
|
|
39
|
+
elastic_twist_rad: np.ndarray,
|
|
40
|
+
chordwise_offset_m: np.ndarray,
|
|
41
|
+
) -> np.ndarray:
|
|
42
|
+
"""Return the normal translation of a node offset chordwise from the EA.
|
|
43
|
+
|
|
44
|
+
dy = w + theta d: the linearized rigid-section displacement along
|
|
45
|
+
the blade-frame normal axis of a node at chordwise offset d from
|
|
46
|
+
the elastic axis, under flap deflection w and elastic twist theta
|
|
47
|
+
(positive nose up about the spanwise axis, so a leading-edge node,
|
|
48
|
+
d > 0, moves up under positive twist). Inputs broadcast; lengths
|
|
49
|
+
in m, angles in rad.
|
|
50
|
+
|
|
51
|
+
Source: DLV-007 Section 4.4 (twist encoded as differential
|
|
52
|
+
translations, EA node receives w, offset nodes w plus theta cross
|
|
53
|
+
d).
|
|
54
|
+
"""
|
|
55
|
+
return np.asarray(flap_deflection_m, dtype=float) + np.asarray(
|
|
56
|
+
elastic_twist_rad, dtype=float
|
|
57
|
+
) * np.asarray(chordwise_offset_m, dtype=float)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def twist_from_node_translations(
|
|
61
|
+
normal_translation_le_m: np.ndarray,
|
|
62
|
+
normal_translation_te_m: np.ndarray,
|
|
63
|
+
le_offset_m: np.ndarray,
|
|
64
|
+
te_offset_m: np.ndarray,
|
|
65
|
+
) -> np.ndarray:
|
|
66
|
+
"""Elastic twist reconstructed from the offset-node translations.
|
|
67
|
+
|
|
68
|
+
theta = (dy_LE - dy_TE) / (d_LE - d_TE): the exact inverse of the
|
|
69
|
+
linear encoding of :func:`station_normal_translation`, independent
|
|
70
|
+
of the flap deflection, which cancels in the difference. Inputs
|
|
71
|
+
broadcast; the two chordwise offsets must differ.
|
|
72
|
+
|
|
73
|
+
Source: DLV-007 Section 4.4 (inverse of the differential
|
|
74
|
+
translation encoding).
|
|
75
|
+
"""
|
|
76
|
+
return (
|
|
77
|
+
np.asarray(normal_translation_le_m, dtype=float)
|
|
78
|
+
- np.asarray(normal_translation_te_m, dtype=float)
|
|
79
|
+
) / (np.asarray(le_offset_m, dtype=float) - np.asarray(te_offset_m, dtype=float))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def encode_station_translations(
|
|
83
|
+
flap_deflection_m: np.ndarray,
|
|
84
|
+
elastic_twist_rad: np.ndarray,
|
|
85
|
+
le_offset_m: np.ndarray,
|
|
86
|
+
te_offset_m: np.ndarray,
|
|
87
|
+
) -> np.ndarray:
|
|
88
|
+
"""Encode a beam solution as per-station three-node translations.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
flap_deflection_m : numpy.ndarray
|
|
93
|
+
Flap deflection w per station [m], positive +Y.
|
|
94
|
+
elastic_twist_rad : numpy.ndarray
|
|
95
|
+
Elastic twist theta per station [rad], positive nose up.
|
|
96
|
+
le_offset_m, te_offset_m : numpy.ndarray
|
|
97
|
+
Chordwise offsets [m] of the leading-edge and trailing-edge
|
|
98
|
+
nodes from the elastic axis (leading edge positive, trailing
|
|
99
|
+
edge negative).
|
|
100
|
+
|
|
101
|
+
Returns
|
|
102
|
+
-------
|
|
103
|
+
numpy.ndarray
|
|
104
|
+
Translations, shape ``(n_stations, 3, 3)``: stations by node
|
|
105
|
+
role (:data:`NODE_ROLES` order) by (dx, dy, dz) in the blade
|
|
106
|
+
frame. Only dy is nonzero under the linear encoding.
|
|
107
|
+
"""
|
|
108
|
+
w = np.asarray(flap_deflection_m, dtype=float)
|
|
109
|
+
theta = np.asarray(elastic_twist_rad, dtype=float)
|
|
110
|
+
translations = np.zeros((len(w), len(NODE_ROLES), 3))
|
|
111
|
+
translations[:, 0, 1] = w
|
|
112
|
+
translations[:, 1, 1] = station_normal_translation(w, theta, le_offset_m)
|
|
113
|
+
translations[:, 2, 1] = station_normal_translation(w, theta, te_offset_m)
|
|
114
|
+
return translations
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def decode_station_translations(
|
|
118
|
+
translations: np.ndarray,
|
|
119
|
+
le_offset_m: np.ndarray,
|
|
120
|
+
te_offset_m: np.ndarray,
|
|
121
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
122
|
+
"""Reconstruct (w, theta) from per-station three-node translations.
|
|
123
|
+
|
|
124
|
+
The exact inverse of :func:`encode_station_translations`: the flap
|
|
125
|
+
deflection is the elastic-axis node's normal translation and the
|
|
126
|
+
twist comes from the offset-node difference, so an
|
|
127
|
+
encode-decode round trip closes at machine precision (the WP5
|
|
128
|
+
verification).
|
|
129
|
+
|
|
130
|
+
Parameters
|
|
131
|
+
----------
|
|
132
|
+
translations : numpy.ndarray
|
|
133
|
+
Shape ``(n_stations, 3, 3)`` as produced by the encoder.
|
|
134
|
+
le_offset_m, te_offset_m : numpy.ndarray
|
|
135
|
+
The chordwise node offsets [m] the encoding used.
|
|
136
|
+
|
|
137
|
+
Returns
|
|
138
|
+
-------
|
|
139
|
+
tuple of numpy.ndarray
|
|
140
|
+
``(flap_deflection_m, elastic_twist_rad)`` per station.
|
|
141
|
+
"""
|
|
142
|
+
translations = np.asarray(translations, dtype=float)
|
|
143
|
+
if translations.ndim != 3 or translations.shape[1:] != (len(NODE_ROLES), 3):
|
|
144
|
+
raise ValueError(
|
|
145
|
+
f"station translations must have shape (n_stations, {len(NODE_ROLES)}, 3), "
|
|
146
|
+
f"got {translations.shape}; rows are stations, then the node roles "
|
|
147
|
+
f"{NODE_ROLES}, then (dx, dy, dz)"
|
|
148
|
+
)
|
|
149
|
+
flap = translations[:, 0, 1]
|
|
150
|
+
twist = twist_from_node_translations(
|
|
151
|
+
translations[:, 1, 1], translations[:, 2, 1], le_offset_m, te_offset_m
|
|
152
|
+
)
|
|
153
|
+
return flap, twist
|