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,463 @@
|
|
|
1
|
+
"""Structural node file and FSIDisp ordering map, from one generator (WP5).
|
|
2
|
+
|
|
3
|
+
Pipeline role: FlightStream imports a structural node list per blade
|
|
4
|
+
and later applies ``FSIDisp.txt`` translations to the nodes in exactly
|
|
5
|
+
the import order (RPT-005 finding 5). Any disagreement between the
|
|
6
|
+
file that was imported and the order the displacements are written in
|
|
7
|
+
is a silent geometry corruption, so both come from the same generator
|
|
8
|
+
reading the same configuration: :func:`generate_node_layout` builds
|
|
9
|
+
the :class:`NodeOrderingMap`, and the node file, the serialized map,
|
|
10
|
+
and the ``FSIDisp.txt`` writer all derive from it (FSI-R14).
|
|
11
|
+
|
|
12
|
+
Layout: three nodes per radial station (elastic axis, leading-edge
|
|
13
|
+
offset, trailing-edge offset; DLV-007 Section 4.4), stations from root
|
|
14
|
+
to tip. Every blade shares the same local coordinates in its own
|
|
15
|
+
rotating frame, so one node file serves all blades: the import is
|
|
16
|
+
repeated once per blade frame, in blade order, and the flat
|
|
17
|
+
``FSIDisp.txt`` rows follow that same blade-major order.
|
|
18
|
+
|
|
19
|
+
Geometric embedding: the beam solution lives in the section axes of
|
|
20
|
+
:mod:`pyflightstream.fsi.config` (chordwise toward the leading edge,
|
|
21
|
+
normal toward the suction side, span root to tip), but the import
|
|
22
|
+
frame of a spinning blade is the rotor convention of the WP7 pilot
|
|
23
|
+
evidence (RPT-006 finding 3): X along the rotor axis, Y in-plane, Z
|
|
24
|
+
spanwise, with the section rotated by the local blade angle beta(r)
|
|
25
|
+
about the span axis. The per-station triad (toward-LE, toward-suction,
|
|
26
|
+
span) is therefore ``(-sin b, -cos b, 0)``, ``(-cos b, sin b, 0)``,
|
|
27
|
+
``(0, 0, 1)``; positions and translations embed as components along
|
|
28
|
+
it, which keeps the scalar twist encoding dy = w + theta d exact
|
|
29
|
+
(nose-up is the rotation about -Z for this geometry, and
|
|
30
|
+
-theta z x (d toward-LE) = +theta d toward-suction). At Omega zero the
|
|
31
|
+
embedding is the identity (``section_frame``), the frame of the wing
|
|
32
|
+
case and of the dry-run node fixture. The rule choosing the embedding
|
|
33
|
+
is :func:`pyflightstream.fsi.config.frame_embedding`, shared with the
|
|
34
|
+
loads projection so both sides of the interface always agree.
|
|
35
|
+
|
|
36
|
+
File formats, per the dry-run evidence (RPT-005 finding 5): both the
|
|
37
|
+
node CSV and ``FSIDisp.txt`` are comma separated three-column files,
|
|
38
|
+
one row per node, no header.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
import numpy as np
|
|
46
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
47
|
+
|
|
48
|
+
from pyflightstream.fsi.config import FsiConfig, frame_embedding
|
|
49
|
+
from pyflightstream.fsi.kinematics import NODE_ROLES
|
|
50
|
+
|
|
51
|
+
#: Recognized geometric embeddings of the section frame (module docstring).
|
|
52
|
+
EMBEDDINGS = ("section_frame", "rotor_frame")
|
|
53
|
+
|
|
54
|
+
#: Node CSV number format: micrometer resolution, the decimal style of
|
|
55
|
+
#: the dry-run import evidence (structural_nodes.csv fixture).
|
|
56
|
+
_NODE_FORMAT = "{:.6f}"
|
|
57
|
+
#: FSIDisp number format: 17 significant digits, so a written float64
|
|
58
|
+
#: reads back bit-identical and the WP5 round trip closes at machine
|
|
59
|
+
#: precision through the file.
|
|
60
|
+
_DISP_FORMAT = "{:.16e}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class NodeOrderingMap(BaseModel):
|
|
64
|
+
"""Single source of truth for node identity and FSIDisp row order.
|
|
65
|
+
|
|
66
|
+
Serialized into the run folder under the ``node_map_file`` name of
|
|
67
|
+
the configuration, so every consumer (the driver, the replay
|
|
68
|
+
harness, post-processing) reads the same bookkeeping that
|
|
69
|
+
generated the imported node file (FSI-R14).
|
|
70
|
+
|
|
71
|
+
Attributes
|
|
72
|
+
----------
|
|
73
|
+
blade_count : int
|
|
74
|
+
Number of blades; the node file is imported once per blade, in
|
|
75
|
+
blade order, and the FSIDisp rows are blade-major.
|
|
76
|
+
station_radii_m : list of float
|
|
77
|
+
Radial stations [m], root to tip, as in the configuration.
|
|
78
|
+
roles : list of str
|
|
79
|
+
Per-station node roles in row order; fixed to
|
|
80
|
+
:data:`~pyflightstream.fsi.kinematics.NODE_ROLES` and stored
|
|
81
|
+
so the serialized map is self-describing.
|
|
82
|
+
ea_offset_chordwise_m, ea_offset_normal_m : list of float
|
|
83
|
+
Elastic-axis position per station [m] in the section plane;
|
|
84
|
+
the elastic-axis node sits there.
|
|
85
|
+
le_offset_m, te_offset_m : list of float
|
|
86
|
+
Chordwise offsets [m] of the leading-edge (positive) and
|
|
87
|
+
trailing-edge (negative) nodes from the elastic axis.
|
|
88
|
+
embedding : str
|
|
89
|
+
Geometric embedding of the section frame in the import frame
|
|
90
|
+
(module docstring): ``"section_frame"`` (identity, Omega zero)
|
|
91
|
+
or ``"rotor_frame"`` (section rotated by the local blade
|
|
92
|
+
angle).
|
|
93
|
+
blade_angle_deg : list of float or None
|
|
94
|
+
Local blade angle beta per station [deg] of the rotor-frame
|
|
95
|
+
embedding; None normalizes to zeros (identity sections).
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
model_config = ConfigDict(extra="forbid")
|
|
99
|
+
|
|
100
|
+
blade_count: int = Field(ge=1)
|
|
101
|
+
station_radii_m: list[float] = Field(min_length=2)
|
|
102
|
+
roles: list[str]
|
|
103
|
+
ea_offset_chordwise_m: list[float]
|
|
104
|
+
ea_offset_normal_m: list[float]
|
|
105
|
+
le_offset_m: list[float]
|
|
106
|
+
te_offset_m: list[float]
|
|
107
|
+
embedding: str = "section_frame"
|
|
108
|
+
blade_angle_deg: list[float] | None = None
|
|
109
|
+
|
|
110
|
+
@model_validator(mode="after")
|
|
111
|
+
def _consistent(self) -> NodeOrderingMap:
|
|
112
|
+
"""Reject maps too inconsistent to order any node row."""
|
|
113
|
+
if list(self.roles) != list(NODE_ROLES):
|
|
114
|
+
raise ValueError(
|
|
115
|
+
f"the node roles must be {list(NODE_ROLES)} in that order; a map "
|
|
116
|
+
f"with roles {self.roles} was not written by this generator and "
|
|
117
|
+
"cannot order FSIDisp rows (FSI-R14)"
|
|
118
|
+
)
|
|
119
|
+
if self.embedding not in EMBEDDINGS:
|
|
120
|
+
raise ValueError(
|
|
121
|
+
f"unknown embedding {self.embedding!r}; the recognized geometric "
|
|
122
|
+
f"embeddings are {EMBEDDINGS} (module docstring)"
|
|
123
|
+
)
|
|
124
|
+
n = len(self.station_radii_m)
|
|
125
|
+
if self.blade_angle_deg is None:
|
|
126
|
+
object.__setattr__(self, "blade_angle_deg", [0.0] * n)
|
|
127
|
+
for name in ("ea_offset_chordwise_m", "ea_offset_normal_m", "le_offset_m", "te_offset_m"):
|
|
128
|
+
if len(getattr(self, name)) != n:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
f"'{name}' has {len(getattr(self, name))} entries for {n} "
|
|
131
|
+
"stations; every per-station list must match station_radii_m"
|
|
132
|
+
)
|
|
133
|
+
if len(self.blade_angle_deg) != n:
|
|
134
|
+
raise ValueError(
|
|
135
|
+
f"'blade_angle_deg' has {len(self.blade_angle_deg)} entries for "
|
|
136
|
+
f"{n} stations; every per-station list must match station_radii_m"
|
|
137
|
+
)
|
|
138
|
+
for le, te in zip(self.le_offset_m, self.te_offset_m, strict=True):
|
|
139
|
+
if le <= 0.0 or te >= 0.0:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
"leading-edge offsets must be positive (toward the leading "
|
|
142
|
+
"edge) and trailing-edge offsets negative; equal or crossed "
|
|
143
|
+
f"offsets (le {le} m, te {te} m) cannot encode twist as a "
|
|
144
|
+
"translation difference"
|
|
145
|
+
)
|
|
146
|
+
return self
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def station_count(self) -> int:
|
|
150
|
+
"""Number of radial stations."""
|
|
151
|
+
return len(self.station_radii_m)
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def nodes_per_blade(self) -> int:
|
|
155
|
+
"""Node rows of one blade's import."""
|
|
156
|
+
return self.station_count * len(self.roles)
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def total_nodes(self) -> int:
|
|
160
|
+
"""FSIDisp row count across all blades."""
|
|
161
|
+
return self.blade_count * self.nodes_per_blade
|
|
162
|
+
|
|
163
|
+
def row_index(self, blade: int, station: int, role: str) -> int:
|
|
164
|
+
"""Flat FSIDisp row of one node (blade-major, station, role).
|
|
165
|
+
|
|
166
|
+
Parameters
|
|
167
|
+
----------
|
|
168
|
+
blade : int
|
|
169
|
+
Blade index, 0-based, in import (creation) order.
|
|
170
|
+
station : int
|
|
171
|
+
Station index, 0-based from the root.
|
|
172
|
+
role : str
|
|
173
|
+
One of :data:`~pyflightstream.fsi.kinematics.NODE_ROLES`.
|
|
174
|
+
"""
|
|
175
|
+
if not 0 <= blade < self.blade_count:
|
|
176
|
+
raise ValueError(f"blade {blade} outside 0..{self.blade_count - 1}")
|
|
177
|
+
if not 0 <= station < self.station_count:
|
|
178
|
+
raise ValueError(f"station {station} outside 0..{self.station_count - 1}")
|
|
179
|
+
return blade * self.nodes_per_blade + station * len(self.roles) + self.roles.index(role)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def generate_node_layout(cfg: FsiConfig) -> NodeOrderingMap:
|
|
183
|
+
"""Build the node layout of a configuration (FSI-R14 single source).
|
|
184
|
+
|
|
185
|
+
Three nodes per station: the elastic-axis node at e(r) in the
|
|
186
|
+
section plane, and the leading-edge and trailing-edge nodes offset
|
|
187
|
+
chordwise from it by plus and minus ``node_offset_chord_fraction``
|
|
188
|
+
of the local chord (DLV-007 Section 4.4). The embedding follows
|
|
189
|
+
:func:`pyflightstream.fsi.config.frame_embedding`: a spinning
|
|
190
|
+
blade embeds its sections at the local blade angle, taken from the
|
|
191
|
+
geometric pitch distribution.
|
|
192
|
+
|
|
193
|
+
Parameters
|
|
194
|
+
----------
|
|
195
|
+
cfg : FsiConfig
|
|
196
|
+
Validated configuration.
|
|
197
|
+
|
|
198
|
+
Returns
|
|
199
|
+
-------
|
|
200
|
+
NodeOrderingMap
|
|
201
|
+
The map every node-related file derives from.
|
|
202
|
+
"""
|
|
203
|
+
blade = cfg.blade
|
|
204
|
+
fraction = cfg.node_offset_chord_fraction
|
|
205
|
+
embedding = frame_embedding(cfg)
|
|
206
|
+
return NodeOrderingMap(
|
|
207
|
+
blade_count=cfg.blade_count,
|
|
208
|
+
station_radii_m=list(blade.station_radii_m),
|
|
209
|
+
roles=list(NODE_ROLES),
|
|
210
|
+
ea_offset_chordwise_m=list(blade.elastic_axis_offset_chordwise_m),
|
|
211
|
+
ea_offset_normal_m=list(blade.elastic_axis_offset_normal_m),
|
|
212
|
+
le_offset_m=[fraction * c for c in blade.chord_m],
|
|
213
|
+
te_offset_m=[-fraction * c for c in blade.chord_m],
|
|
214
|
+
embedding=embedding,
|
|
215
|
+
blade_angle_deg=(list(blade.geometric_pitch_deg) if embedding == "rotor_frame" else None),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def station_triads(node_map: NodeOrderingMap) -> np.ndarray:
|
|
220
|
+
"""Per-station section axes in the import frame (module docstring).
|
|
221
|
+
|
|
222
|
+
Parameters
|
|
223
|
+
----------
|
|
224
|
+
node_map : NodeOrderingMap
|
|
225
|
+
Layout from :func:`generate_node_layout`.
|
|
226
|
+
|
|
227
|
+
Returns
|
|
228
|
+
-------
|
|
229
|
+
numpy.ndarray
|
|
230
|
+
Shape ``(station_count, 3, 3)``: per station the rows are the
|
|
231
|
+
toward-leading-edge, toward-suction, and span unit vectors.
|
|
232
|
+
Identity axes for the section-frame embedding; rotated by the
|
|
233
|
+
local blade angle for the rotor frame. The chordwise/suction
|
|
234
|
+
pair may form a left-handed triad with the span axis (a
|
|
235
|
+
mirrored section); the scalar beam kinematics are mirror
|
|
236
|
+
invariant, so components still embed and extract by dot
|
|
237
|
+
products.
|
|
238
|
+
"""
|
|
239
|
+
n = node_map.station_count
|
|
240
|
+
triads = np.zeros((n, 3, 3))
|
|
241
|
+
if node_map.embedding == "section_frame":
|
|
242
|
+
triads[:] = np.eye(3)
|
|
243
|
+
return triads
|
|
244
|
+
beta = np.radians(np.asarray(node_map.blade_angle_deg, dtype=float))
|
|
245
|
+
triads[:, 0, 0] = -np.sin(beta)
|
|
246
|
+
triads[:, 0, 1] = -np.cos(beta)
|
|
247
|
+
triads[:, 1, 0] = -np.cos(beta)
|
|
248
|
+
triads[:, 1, 1] = np.sin(beta)
|
|
249
|
+
triads[:, 2, 2] = 1.0
|
|
250
|
+
return triads
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def node_positions(node_map: NodeOrderingMap) -> np.ndarray:
|
|
254
|
+
"""Local node coordinates of one blade, in file row order.
|
|
255
|
+
|
|
256
|
+
Parameters
|
|
257
|
+
----------
|
|
258
|
+
node_map : NodeOrderingMap
|
|
259
|
+
Layout from :func:`generate_node_layout`.
|
|
260
|
+
|
|
261
|
+
Returns
|
|
262
|
+
-------
|
|
263
|
+
numpy.ndarray
|
|
264
|
+
Shape ``(nodes_per_blade, 3)`` [m] in the import frame:
|
|
265
|
+
section components (chordwise offset, normal offset, radius)
|
|
266
|
+
embedded along the station triads of :func:`station_triads`,
|
|
267
|
+
stations root to tip, roles in
|
|
268
|
+
:data:`~pyflightstream.fsi.kinematics.NODE_ROLES` order.
|
|
269
|
+
"""
|
|
270
|
+
triads = station_triads(node_map)
|
|
271
|
+
rows = []
|
|
272
|
+
for i, radius in enumerate(node_map.station_radii_m):
|
|
273
|
+
e_c = node_map.ea_offset_chordwise_m[i]
|
|
274
|
+
e_n = node_map.ea_offset_normal_m[i]
|
|
275
|
+
toward_le, toward_suction, span = triads[i]
|
|
276
|
+
for chordwise in (0.0, node_map.le_offset_m[i], node_map.te_offset_m[i]):
|
|
277
|
+
rows.append((e_c + chordwise) * toward_le + e_n * toward_suction + radius * span)
|
|
278
|
+
return np.asarray(rows, dtype=float)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def write_node_file(node_map: NodeOrderingMap, path: str | Path) -> None:
|
|
282
|
+
"""Write the structural node CSV FlightStream imports per blade.
|
|
283
|
+
|
|
284
|
+
Comma separated X,Y,Z per node, no header, the format the dry run
|
|
285
|
+
imported cleanly (RPT-005 finding 5). The same file is imported
|
|
286
|
+
once per blade, each time in that blade's rotating frame.
|
|
287
|
+
|
|
288
|
+
Parameters
|
|
289
|
+
----------
|
|
290
|
+
node_map : NodeOrderingMap
|
|
291
|
+
Layout from :func:`generate_node_layout`.
|
|
292
|
+
path : str or Path
|
|
293
|
+
Destination CSV; overwritten if present.
|
|
294
|
+
"""
|
|
295
|
+
lines = [",".join(_NODE_FORMAT.format(v) for v in row) for row in node_positions(node_map)]
|
|
296
|
+
Path(path).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def write_node_map(node_map: NodeOrderingMap, path: str | Path) -> None:
|
|
300
|
+
"""Serialize the ordering map next to the node file (FSI-R14).
|
|
301
|
+
|
|
302
|
+
Parameters
|
|
303
|
+
----------
|
|
304
|
+
node_map : NodeOrderingMap
|
|
305
|
+
Layout to persist.
|
|
306
|
+
path : str or Path
|
|
307
|
+
Destination JSON, normally the ``node_map_file`` name of the
|
|
308
|
+
configuration inside the run folder.
|
|
309
|
+
"""
|
|
310
|
+
Path(path).write_text(node_map.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def load_node_map(path: str | Path) -> NodeOrderingMap:
|
|
314
|
+
"""Load and validate a serialized ordering map.
|
|
315
|
+
|
|
316
|
+
Parameters
|
|
317
|
+
----------
|
|
318
|
+
path : str or Path
|
|
319
|
+
JSON written by :func:`write_node_map`.
|
|
320
|
+
"""
|
|
321
|
+
return NodeOrderingMap.model_validate_json(Path(path).read_text(encoding="utf-8"))
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def flatten_blade_translations(
|
|
325
|
+
node_map: NodeOrderingMap, per_blade_translations: list[np.ndarray]
|
|
326
|
+
) -> np.ndarray:
|
|
327
|
+
"""Order per-blade station translations into flat FSIDisp rows.
|
|
328
|
+
|
|
329
|
+
Parameters
|
|
330
|
+
----------
|
|
331
|
+
node_map : NodeOrderingMap
|
|
332
|
+
Layout defining the row order.
|
|
333
|
+
per_blade_translations : list of numpy.ndarray
|
|
334
|
+
One array per blade in import order, each shaped
|
|
335
|
+
``(n_stations, 3, 3)`` as produced by
|
|
336
|
+
:func:`pyflightstream.fsi.kinematics.encode_station_translations`.
|
|
337
|
+
|
|
338
|
+
Returns
|
|
339
|
+
-------
|
|
340
|
+
numpy.ndarray
|
|
341
|
+
Shape ``(total_nodes, 3)``: blade-major, then station, then
|
|
342
|
+
role, matching the import order (FSI-R14), with the section
|
|
343
|
+
components embedded along the station triads into the import
|
|
344
|
+
frame.
|
|
345
|
+
"""
|
|
346
|
+
if len(per_blade_translations) != node_map.blade_count:
|
|
347
|
+
raise ValueError(
|
|
348
|
+
f"got translations for {len(per_blade_translations)} blades but the "
|
|
349
|
+
f"map describes {node_map.blade_count}; every blade writes its rows "
|
|
350
|
+
"on every call"
|
|
351
|
+
)
|
|
352
|
+
expected = (node_map.station_count, len(node_map.roles), 3)
|
|
353
|
+
triads = station_triads(node_map)
|
|
354
|
+
flat_blocks = []
|
|
355
|
+
for i, translations in enumerate(per_blade_translations):
|
|
356
|
+
translations = np.asarray(translations, dtype=float)
|
|
357
|
+
if translations.shape != expected:
|
|
358
|
+
raise ValueError(
|
|
359
|
+
f"blade {i} translations have shape {translations.shape}, "
|
|
360
|
+
f"expected {expected} (stations, roles, components)"
|
|
361
|
+
)
|
|
362
|
+
embedded = np.einsum("srk,skm->srm", translations, triads)
|
|
363
|
+
flat_blocks.append(embedded.reshape(node_map.nodes_per_blade, 3))
|
|
364
|
+
return np.concatenate(flat_blocks, axis=0)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def unflatten_translations(node_map: NodeOrderingMap, flat: np.ndarray) -> list[np.ndarray]:
|
|
368
|
+
"""Split flat FSIDisp rows back into per-blade station translations.
|
|
369
|
+
|
|
370
|
+
The exact inverse of :func:`flatten_blade_translations`; the
|
|
371
|
+
replay harness and the round-trip verification use it to
|
|
372
|
+
reconstruct (w, theta) from an archived ``FSIDisp.txt``.
|
|
373
|
+
|
|
374
|
+
Parameters
|
|
375
|
+
----------
|
|
376
|
+
node_map : NodeOrderingMap
|
|
377
|
+
Layout defining the row order.
|
|
378
|
+
flat : numpy.ndarray
|
|
379
|
+
Shape ``(total_nodes, 3)`` in FSIDisp row order.
|
|
380
|
+
|
|
381
|
+
Returns
|
|
382
|
+
-------
|
|
383
|
+
list of numpy.ndarray
|
|
384
|
+
One ``(n_stations, 3, 3)`` array per blade in import order,
|
|
385
|
+
back in section components (the exact inverse of the
|
|
386
|
+
embedding: components extract by dot products with the
|
|
387
|
+
station triads).
|
|
388
|
+
"""
|
|
389
|
+
flat = np.asarray(flat, dtype=float)
|
|
390
|
+
if flat.shape != (node_map.total_nodes, 3):
|
|
391
|
+
raise ValueError(
|
|
392
|
+
f"FSIDisp rows have shape {flat.shape}, but the map orders "
|
|
393
|
+
f"{node_map.total_nodes} nodes of 3 components; the displacement "
|
|
394
|
+
"file does not belong to this node layout (FSI-R14)"
|
|
395
|
+
)
|
|
396
|
+
triads = station_triads(node_map)
|
|
397
|
+
blades = []
|
|
398
|
+
for block in np.split(flat, node_map.blade_count, axis=0):
|
|
399
|
+
embedded = block.reshape(node_map.station_count, len(node_map.roles), 3)
|
|
400
|
+
blades.append(np.einsum("srm,skm->srk", embedded, triads))
|
|
401
|
+
return blades
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def write_fsidisp(path: str | Path, translations: np.ndarray) -> None:
|
|
405
|
+
"""Write ``FSIDisp.txt``: one dx,dy,dz row per node, import order.
|
|
406
|
+
|
|
407
|
+
Comma separated per the dry-run evidence (RPT-005 finding 5);
|
|
408
|
+
written at full float64 precision so an archived file replays the
|
|
409
|
+
exact displacements.
|
|
410
|
+
|
|
411
|
+
Parameters
|
|
412
|
+
----------
|
|
413
|
+
path : str or Path
|
|
414
|
+
Destination file inside the working directory.
|
|
415
|
+
translations : numpy.ndarray
|
|
416
|
+
Shape ``(total_nodes, 3)`` from
|
|
417
|
+
:func:`flatten_blade_translations`, in meters, blade frame.
|
|
418
|
+
"""
|
|
419
|
+
translations = np.asarray(translations, dtype=float)
|
|
420
|
+
if translations.ndim != 2 or translations.shape[1] != 3:
|
|
421
|
+
raise ValueError(
|
|
422
|
+
f"FSIDisp rows must be (n_nodes, 3) translation vectors, got {translations.shape}"
|
|
423
|
+
)
|
|
424
|
+
lines = [",".join(_DISP_FORMAT.format(v) for v in row) for row in translations]
|
|
425
|
+
Path(path).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def read_fsidisp(path: str | Path, expected_rows: int | None = None) -> np.ndarray:
|
|
429
|
+
"""Read an ``FSIDisp.txt`` back into translation rows.
|
|
430
|
+
|
|
431
|
+
Parameters
|
|
432
|
+
----------
|
|
433
|
+
path : str or Path
|
|
434
|
+
Displacement file, comma separated dx,dy,dz per node.
|
|
435
|
+
expected_rows : int, optional
|
|
436
|
+
When given (normally ``NodeOrderingMap.total_nodes``), a row
|
|
437
|
+
count mismatch raises: applying displacements to the wrong
|
|
438
|
+
node count is exactly the silent corruption FSI-R14 exists to
|
|
439
|
+
prevent.
|
|
440
|
+
|
|
441
|
+
Returns
|
|
442
|
+
-------
|
|
443
|
+
numpy.ndarray
|
|
444
|
+
Shape ``(n_rows, 3)`` in file order.
|
|
445
|
+
"""
|
|
446
|
+
rows = []
|
|
447
|
+
for line_number, line in enumerate(Path(path).read_text(encoding="utf-8").splitlines(), 1):
|
|
448
|
+
if not line.strip():
|
|
449
|
+
continue
|
|
450
|
+
cells = [cell.strip() for cell in line.split(",") if cell.strip()]
|
|
451
|
+
if len(cells) != 3:
|
|
452
|
+
raise ValueError(
|
|
453
|
+
f"FSIDisp line {line_number} holds {len(cells)} values, expected "
|
|
454
|
+
"the dx,dy,dz triple of one node (RPT-005 finding 5)"
|
|
455
|
+
)
|
|
456
|
+
rows.append([float(cell) for cell in cells])
|
|
457
|
+
if expected_rows is not None and len(rows) != expected_rows:
|
|
458
|
+
raise ValueError(
|
|
459
|
+
f"FSIDisp holds {len(rows)} rows but the node map orders "
|
|
460
|
+
f"{expected_rows} nodes; the file does not belong to this layout "
|
|
461
|
+
"(FSI-R14)"
|
|
462
|
+
)
|
|
463
|
+
return np.asarray(rows, dtype=float)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Persistent coupling state: ``state.json`` model and atomic IO (WP6).
|
|
2
|
+
|
|
3
|
+
Pipeline role: the coupling executable is stateless per call (FSI-R01);
|
|
4
|
+
everything the next call needs survives in ``state.json`` inside the
|
|
5
|
+
run folder. This module owns that file: the validated model of its
|
|
6
|
+
content, atomic writes (temporary file plus rename, FSI-R13, so a call
|
|
7
|
+
killed mid-write leaves the previous state intact and the loop is
|
|
8
|
+
crash-recoverable), and the call/step counter bookkeeping (FSI-R12).
|
|
9
|
+
|
|
10
|
+
Counters: the Toolbox FSI iteration count is fixed at 1 per time step,
|
|
11
|
+
so calls and time steps advance together. The two counters are kept
|
|
12
|
+
separate anyway, with the consistency assertion living in the driver:
|
|
13
|
+
a call that receives the same solver iteration as the previous one is
|
|
14
|
+
a second FSI iteration inside one time step, which means
|
|
15
|
+
``SET_AEROELASTIC_ITERATIONS`` is not 1, and the driver refuses to
|
|
16
|
+
continue instead of averaging over duplicated loads.
|
|
17
|
+
|
|
18
|
+
All stored arrays are plain JSON lists in SI units in the rotating
|
|
19
|
+
blade frames; numpy conversion happens at the edges.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LoadSample(BaseModel):
|
|
33
|
+
"""One call's aerodynamic load densities, in the averaging buffer.
|
|
34
|
+
|
|
35
|
+
Attributes
|
|
36
|
+
----------
|
|
37
|
+
step : int
|
|
38
|
+
Time step the sample belongs to.
|
|
39
|
+
flap_n_per_m : list of list of float
|
|
40
|
+
Distributed flap load per blade at the config stations [N/m].
|
|
41
|
+
torsion_nm_per_m : list of list of float
|
|
42
|
+
Distributed elastic-axis moment per blade at the config
|
|
43
|
+
stations [N m / m].
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
model_config = ConfigDict(extra="forbid")
|
|
47
|
+
|
|
48
|
+
step: int = Field(ge=1)
|
|
49
|
+
flap_n_per_m: list[list[float]]
|
|
50
|
+
torsion_nm_per_m: list[list[float]]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RevolutionSample(BaseModel):
|
|
54
|
+
"""Tip response recorded at one completed revolution (FSI-R09).
|
|
55
|
+
|
|
56
|
+
Attributes
|
|
57
|
+
----------
|
|
58
|
+
revolution : int
|
|
59
|
+
Completed revolution count.
|
|
60
|
+
tip_twist_deg : list of float
|
|
61
|
+
Tip elastic twist per blade [deg] at the completing call.
|
|
62
|
+
tip_flap_m : list of float
|
|
63
|
+
Tip flap deflection per blade [m] at the completing call.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
model_config = ConfigDict(extra="forbid")
|
|
67
|
+
|
|
68
|
+
revolution: int = Field(ge=1)
|
|
69
|
+
tip_twist_deg: list[float]
|
|
70
|
+
tip_flap_m: list[float]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class RecordedTwist(BaseModel):
|
|
74
|
+
"""One phase 4 recording entry: the twist distribution of one step.
|
|
75
|
+
|
|
76
|
+
The azimuth of a step is not stored (FSI-R02: the structural code
|
|
77
|
+
never handles azimuth); it is reconstructed downstream from the
|
|
78
|
+
step index, the time increment, and Omega.
|
|
79
|
+
|
|
80
|
+
Attributes
|
|
81
|
+
----------
|
|
82
|
+
step : int
|
|
83
|
+
Time step of the record.
|
|
84
|
+
elastic_twist_rad : list of list of float
|
|
85
|
+
Elastic twist per blade at the config stations [rad].
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
model_config = ConfigDict(extra="forbid")
|
|
89
|
+
|
|
90
|
+
step: int = Field(ge=1)
|
|
91
|
+
elastic_twist_rad: list[list[float]]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class FsiState(BaseModel):
|
|
95
|
+
"""Complete persisted state of the coupling loop (DLV-007 Section 5).
|
|
96
|
+
|
|
97
|
+
Attributes
|
|
98
|
+
----------
|
|
99
|
+
call_count : int
|
|
100
|
+
Executable invocations so far.
|
|
101
|
+
step_count : int
|
|
102
|
+
Time steps with fresh loads so far; equals ``call_count``
|
|
103
|
+
while ``SET_AEROELASTIC_ITERATIONS`` is 1 (FSI-R12).
|
|
104
|
+
phase : int
|
|
105
|
+
Phase of the last executed coupling call (1 to 4, DLV-007
|
|
106
|
+
Section 4.5).
|
|
107
|
+
last_solver_iteration : int or None
|
|
108
|
+
Solver iteration of the last parsed loads file; the freshness
|
|
109
|
+
anchor of the counter assertion.
|
|
110
|
+
previous_displacements : list of list of float or None
|
|
111
|
+
Last written FSIDisp rows [m], the relaxation memory (FSI-R07).
|
|
112
|
+
previous_twist_rad : list of list of float or None
|
|
113
|
+
Elastic twist per blade at the config stations [rad] of the
|
|
114
|
+
last solve, kept for the propeller-moment continuity and as a
|
|
115
|
+
warm start.
|
|
116
|
+
load_history : list of LoadSample
|
|
117
|
+
Averaging buffer of phases 2 and 3, trimmed to the configured
|
|
118
|
+
window.
|
|
119
|
+
revolution_history : list of RevolutionSample
|
|
120
|
+
Per-revolution tip response, the convergence-log source
|
|
121
|
+
(FSI-R09).
|
|
122
|
+
recorded_twist : list of RecordedTwist
|
|
123
|
+
Phase 4 recording of the twist distributions.
|
|
124
|
+
phase4_start_step : int or None
|
|
125
|
+
Step at which phase 4 recording began.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
model_config = ConfigDict(extra="forbid")
|
|
129
|
+
|
|
130
|
+
call_count: int = Field(default=0, ge=0)
|
|
131
|
+
step_count: int = Field(default=0, ge=0)
|
|
132
|
+
phase: int = Field(default=1, ge=1, le=4)
|
|
133
|
+
last_solver_iteration: int | None = None
|
|
134
|
+
previous_displacements: list[list[float]] | None = None
|
|
135
|
+
previous_twist_rad: list[list[float]] | None = None
|
|
136
|
+
load_history: list[LoadSample] = Field(default_factory=list)
|
|
137
|
+
revolution_history: list[RevolutionSample] = Field(default_factory=list)
|
|
138
|
+
recorded_twist: list[RecordedTwist] = Field(default_factory=list)
|
|
139
|
+
phase4_start_step: int | None = None
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def completed_revolutions(self) -> int:
|
|
143
|
+
"""Revolutions completed so far, from the recorded history."""
|
|
144
|
+
return len(self.revolution_history)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def initial_state() -> FsiState:
|
|
148
|
+
"""Return the state of a run before its first coupling call."""
|
|
149
|
+
return FsiState()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def load_state(path: str | Path) -> FsiState:
|
|
153
|
+
"""Load and validate a ``state.json``.
|
|
154
|
+
|
|
155
|
+
Parameters
|
|
156
|
+
----------
|
|
157
|
+
path : str or Path
|
|
158
|
+
State file written by :func:`write_state_atomic`.
|
|
159
|
+
"""
|
|
160
|
+
return FsiState.model_validate_json(Path(path).read_text(encoding="utf-8"))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def write_state_atomic(state: FsiState, path: str | Path) -> None:
|
|
164
|
+
"""Persist the state atomically: temporary file plus rename (FSI-R13).
|
|
165
|
+
|
|
166
|
+
A call killed between the write and the rename leaves the previous
|
|
167
|
+
``state.json`` intact, so the loop resumes from the last completed
|
|
168
|
+
call instead of consuming a torn file.
|
|
169
|
+
|
|
170
|
+
Parameters
|
|
171
|
+
----------
|
|
172
|
+
state : FsiState
|
|
173
|
+
State after the current call.
|
|
174
|
+
path : str or Path
|
|
175
|
+
Destination ``state.json``.
|
|
176
|
+
"""
|
|
177
|
+
path = Path(path)
|
|
178
|
+
temporary = path.with_name(path.name + ".tmp")
|
|
179
|
+
temporary.write_text(state.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
180
|
+
temporary.replace(path)
|
|
181
|
+
logger.debug("state written atomically: call %d step %d", state.call_count, state.step_count)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Results into engineering data.
|
|
2
|
+
|
|
3
|
+
Pipeline role: assembles parsed results into sweep tables and labeled
|
|
4
|
+
arrays, applies axis transformations, performs blade-passage averaging
|
|
5
|
+
for unsteady runs, and writes flow-visualization exports. Implemented
|
|
6
|
+
incrementally: the probe-data writers (VTK legacy ASCII and Tecplot
|
|
7
|
+
ASCII, :mod:`pyflightstream.post.writers`) are the first inhabitants;
|
|
8
|
+
the ``ResultArray`` facade (``interp_along``, ``reparametrize``,
|
|
9
|
+
``trim``) stays planned.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from pyflightstream.post.writers import (
|
|
13
|
+
dataset_to_points,
|
|
14
|
+
write_tecplot_points,
|
|
15
|
+
write_vtk_points,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = ["write_vtk_points", "write_tecplot_points", "dataset_to_points"]
|