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,740 @@
|
|
|
1
|
+
"""Sectional loads parser and elastic-axis moment transfer (WP2, DLV-007).
|
|
2
|
+
|
|
3
|
+
Pipeline role: per coupling call FlightStream's post-processing script
|
|
4
|
+
exports ``FS_SurfaceSection_Loads.txt`` (sectional forces in Newtons
|
|
5
|
+
and moments about the quarter chord); this module parses that file,
|
|
6
|
+
splits the flat table into per-blade blocks, and transfers the moments
|
|
7
|
+
from the pitch axis to the elastic axis, delivering the aerodynamic
|
|
8
|
+
load set the beam solve consumes.
|
|
9
|
+
|
|
10
|
+
Everything here is anchored on the WP1 dry-run evidence
|
|
11
|
+
(reports/RPT-005, fixtures in ``tests/fixtures/fsi/``):
|
|
12
|
+
|
|
13
|
+
* The file carries the standard labeled FlightStream header; the SI
|
|
14
|
+
assertion (FSI-R03) anchors on the unit-carrying labels
|
|
15
|
+
(``Freestream velocity (m/s)``, ``Reference area (m^2)``) and on the
|
|
16
|
+
``Force Units`` / ``Moment Units`` footer, which must read Newtons
|
|
17
|
+
and Newton-Meter because the post-processing script computes the
|
|
18
|
+
loads with ``COMPUTE_SURFACE_SECTIONAL_LOADS NEWTONS``.
|
|
19
|
+
* The data table is ``Offset, Chord, X_QC, Z_QC, Fx, Fz, Moment`` per
|
|
20
|
+
section; the moment is about the quarter chord, the pitch axis
|
|
21
|
+
reference of DLV-007 Section 4.3. The rows are line densities along
|
|
22
|
+
the span: despite the footer naming the computation unit (Newtons
|
|
23
|
+
versus coefficients), integrating the force columns over the
|
|
24
|
+
tributary widths reproduces the integrated axial force of the same
|
|
25
|
+
run to a few percent, while summing them overshoots by the inverse
|
|
26
|
+
width (WP7 near-rigid pilot evidence, RPT-006). Forces are therefore
|
|
27
|
+
[N/m] and moments [N m / m], and the totals cross-check integrates,
|
|
28
|
+
never sums.
|
|
29
|
+
* Section-plane axes, on the same pilot evidence: the export ``Fx``
|
|
30
|
+
is the axial (rotor axis) component, invariant under the blade
|
|
31
|
+
rotation and matching the integrated Cx, and ``Fz`` is the in-plane
|
|
32
|
+
component in the rotating blade frame. The mapping onto the
|
|
33
|
+
chordwise/normal axes of :mod:`pyflightstream.fsi.config` therefore
|
|
34
|
+
involves the local blade angle; the deliberate elastic-axis offset
|
|
35
|
+
check of the soft-blade pilot is the planned sign confirmation.
|
|
36
|
+
* Blade attribution follows the author's family-per-blade convention
|
|
37
|
+
(RPT-005 finding 6): one geometry family per blade, one section
|
|
38
|
+
distribution per blade boundary, and the flat export concatenates
|
|
39
|
+
the families in creation order. Attribution is therefore bookkeeping
|
|
40
|
+
owned by the code that creates the distributions, serialized as a
|
|
41
|
+
:class:`SectionFamilyMap`; the offset and chord discontinuities at
|
|
42
|
+
the block boundaries are the parser's cross-check.
|
|
43
|
+
|
|
44
|
+
Unlike the loads spreadsheet, this export's footer carries no version
|
|
45
|
+
or build line (observed in the committed fixtures); build traceability
|
|
46
|
+
of a coupled run lives in the run manifest (FR-19).
|
|
47
|
+
|
|
48
|
+
Parsing is anchor-based on the primitives of
|
|
49
|
+
:mod:`pyflightstream.results` (FR-16): labels and header rows, never
|
|
50
|
+
line offsets, and a missing structural terminator raises instead of
|
|
51
|
+
returning a silently shorter table (FR-17).
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
from dataclasses import dataclass
|
|
57
|
+
|
|
58
|
+
import numpy as np
|
|
59
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
60
|
+
|
|
61
|
+
from pyflightstream.fsi.config import FsiConfig, frame_embedding
|
|
62
|
+
from pyflightstream.results import (
|
|
63
|
+
AnchorNotFoundError,
|
|
64
|
+
IncompleteOutputError,
|
|
65
|
+
delimited_table,
|
|
66
|
+
labeled_value,
|
|
67
|
+
parse_number,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
_TABLE_ANCHOR = "Offset,"
|
|
71
|
+
EXPECTED_COLUMNS = ("Offset", "Chord", "X_QC", "Z_QC", "Fx", "Fz", "Moment")
|
|
72
|
+
# Fraction of the blade span the parsed section radii may exceed the
|
|
73
|
+
# configured [root, tip] interval before the config is rejected as not
|
|
74
|
+
# describing the blade the sections were cut on.
|
|
75
|
+
_SPAN_TOLERANCE = 0.01
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class UnitsError(ValueError):
|
|
79
|
+
"""The export does not carry the asserted SI units (FSI-R03).
|
|
80
|
+
|
|
81
|
+
Unit errors in the coupling loop are silent and produce plausible
|
|
82
|
+
wrong answers, so the parser refuses the file instead of scaling:
|
|
83
|
+
the post-processing script must compute the sectional loads with
|
|
84
|
+
``COMPUTE_SURFACE_SECTIONAL_LOADS NEWTONS`` and the solver setup
|
|
85
|
+
must stay in SI.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SectionFamily(BaseModel):
|
|
90
|
+
"""One section distribution of the flat export, in creation order.
|
|
91
|
+
|
|
92
|
+
Attributes
|
|
93
|
+
----------
|
|
94
|
+
name : str
|
|
95
|
+
Family label, for example the blade name; unique within a map.
|
|
96
|
+
count : int
|
|
97
|
+
Number of sections the distribution creates (its block size in
|
|
98
|
+
the flat export).
|
|
99
|
+
is_blade : bool
|
|
100
|
+
Whether the family is a blade the structural solve consumes;
|
|
101
|
+
non-blade families (a hub, a nacelle) are split and
|
|
102
|
+
cross-checked but never fed to a beam.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
model_config = ConfigDict(extra="forbid")
|
|
106
|
+
|
|
107
|
+
name: str = Field(min_length=1)
|
|
108
|
+
count: int = Field(ge=1)
|
|
109
|
+
is_blade: bool = True
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class SectionFamilyMap(BaseModel):
|
|
113
|
+
"""Creation-order bookkeeping of the section distributions.
|
|
114
|
+
|
|
115
|
+
The flat loads export concatenates the per-boundary families in
|
|
116
|
+
creation order (RPT-005 finding 6), so the code that creates the
|
|
117
|
+
distributions is the single source of truth for attribution. That
|
|
118
|
+
code emits this map; the parser only splits and cross-checks.
|
|
119
|
+
|
|
120
|
+
Attributes
|
|
121
|
+
----------
|
|
122
|
+
families : list of SectionFamily
|
|
123
|
+
Families in creation order.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
model_config = ConfigDict(extra="forbid")
|
|
127
|
+
|
|
128
|
+
families: list[SectionFamily] = Field(min_length=1)
|
|
129
|
+
|
|
130
|
+
@field_validator("families")
|
|
131
|
+
@classmethod
|
|
132
|
+
def _unique_names(cls, families: list[SectionFamily]) -> list[SectionFamily]:
|
|
133
|
+
"""Attribution by name requires unique family names."""
|
|
134
|
+
names = [family.name for family in families]
|
|
135
|
+
if len(set(names)) != len(names):
|
|
136
|
+
raise ValueError(
|
|
137
|
+
"family names must be unique; a duplicated name makes the "
|
|
138
|
+
f"per-blade attribution ambiguous (got {names})"
|
|
139
|
+
)
|
|
140
|
+
return families
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def uniform(cls, blade_count: int, sections_per_blade: int) -> SectionFamilyMap:
|
|
144
|
+
"""Build the map of identical per-blade distributions.
|
|
145
|
+
|
|
146
|
+
Parameters
|
|
147
|
+
----------
|
|
148
|
+
blade_count : int
|
|
149
|
+
Number of blades, one family each, created in blade order.
|
|
150
|
+
sections_per_blade : int
|
|
151
|
+
Sections of every distribution.
|
|
152
|
+
"""
|
|
153
|
+
return cls(
|
|
154
|
+
families=[
|
|
155
|
+
SectionFamily(name=f"blade_{i + 1}", count=sections_per_blade)
|
|
156
|
+
for i in range(blade_count)
|
|
157
|
+
]
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def total_sections(self) -> int:
|
|
162
|
+
"""Sum of the family block sizes."""
|
|
163
|
+
return sum(family.count for family in self.families)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True)
|
|
167
|
+
class SectionBlock:
|
|
168
|
+
"""The rows of one family, split out of the flat export.
|
|
169
|
+
|
|
170
|
+
All arrays share the family's section count. The force and moment
|
|
171
|
+
rows are line densities along the span (RPT-006, module
|
|
172
|
+
docstring): the SI assertion fixes the unit basis (Newtons), and
|
|
173
|
+
integrating over the tributary widths recovers the integrated
|
|
174
|
+
loads.
|
|
175
|
+
|
|
176
|
+
Attributes
|
|
177
|
+
----------
|
|
178
|
+
family : str
|
|
179
|
+
Family name from the :class:`SectionFamilyMap`.
|
|
180
|
+
offset_m : numpy.ndarray
|
|
181
|
+
Spanwise section positions [m], the radius from the rotation
|
|
182
|
+
axis along the pitch axis.
|
|
183
|
+
chord_m : numpy.ndarray
|
|
184
|
+
Local chord [m].
|
|
185
|
+
x_qc_m, z_qc_m : numpy.ndarray
|
|
186
|
+
Quarter-chord position in the section plane [m], export axes.
|
|
187
|
+
fx_n_per_m, fz_n_per_m : numpy.ndarray
|
|
188
|
+
Sectional force densities [N/m] along the two in-plane axes of
|
|
189
|
+
the distribution's cut plane, in plane-name order (for the
|
|
190
|
+
blade-frame XY distribution: axial, then in-plane).
|
|
191
|
+
moment_qc_nm_per_m : numpy.ndarray
|
|
192
|
+
Sectional moment density about the quarter chord [N m / m],
|
|
193
|
+
the pitch axis reference (DLV-007 Section 4.3).
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
family: str
|
|
197
|
+
offset_m: np.ndarray
|
|
198
|
+
chord_m: np.ndarray
|
|
199
|
+
x_qc_m: np.ndarray
|
|
200
|
+
z_qc_m: np.ndarray
|
|
201
|
+
fx_n_per_m: np.ndarray
|
|
202
|
+
fz_n_per_m: np.ndarray
|
|
203
|
+
moment_qc_nm_per_m: np.ndarray
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(frozen=True)
|
|
207
|
+
class SectionalLoadsReport:
|
|
208
|
+
"""Typed content of one ``FS_SurfaceSection_Loads.txt`` export.
|
|
209
|
+
|
|
210
|
+
Attributes
|
|
211
|
+
----------
|
|
212
|
+
angle_of_attack_deg, sideslip_deg : float
|
|
213
|
+
Freestream angles [deg].
|
|
214
|
+
freestream_velocity_m_s : float
|
|
215
|
+
Freestream velocity [m/s]; its label is an SI anchor.
|
|
216
|
+
time_increment_s : float or None
|
|
217
|
+
Unsteady time step [s], when printed.
|
|
218
|
+
solver_mode : str
|
|
219
|
+
``Steady`` or ``Unsteady`` as printed.
|
|
220
|
+
current_iteration : int
|
|
221
|
+
Solver iteration at export time; advancing values across
|
|
222
|
+
coupling calls are the per-step freshness evidence (RPT-005).
|
|
223
|
+
reference_velocity_m_s, reference_length_m, reference_area_m2 : float or None
|
|
224
|
+
Coefficient normalization references, when printed.
|
|
225
|
+
declared_section_count : int
|
|
226
|
+
Count printed in the header; asserted equal to the table rows.
|
|
227
|
+
force_units, moment_units : str
|
|
228
|
+
Units printed in the footer, asserted SI at parse time.
|
|
229
|
+
columns : tuple of str
|
|
230
|
+
Table column names as printed.
|
|
231
|
+
values : numpy.ndarray
|
|
232
|
+
Full table, shape ``(count, len(columns))``, in printed order.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
angle_of_attack_deg: float
|
|
236
|
+
sideslip_deg: float
|
|
237
|
+
freestream_velocity_m_s: float
|
|
238
|
+
time_increment_s: float | None
|
|
239
|
+
solver_mode: str
|
|
240
|
+
current_iteration: int
|
|
241
|
+
reference_velocity_m_s: float | None
|
|
242
|
+
reference_length_m: float | None
|
|
243
|
+
reference_area_m2: float | None
|
|
244
|
+
declared_section_count: int
|
|
245
|
+
force_units: str
|
|
246
|
+
moment_units: str
|
|
247
|
+
columns: tuple[str, ...]
|
|
248
|
+
values: np.ndarray
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def count(self) -> int:
|
|
252
|
+
"""Number of section rows."""
|
|
253
|
+
return len(self.values)
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def offset_m(self) -> np.ndarray:
|
|
257
|
+
"""Spanwise section positions [m]."""
|
|
258
|
+
return self.values[:, 0]
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def chord_m(self) -> np.ndarray:
|
|
262
|
+
"""Local chords [m]."""
|
|
263
|
+
return self.values[:, 1]
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def fx_n_per_m(self) -> np.ndarray:
|
|
267
|
+
"""Sectional force densities [N/m], first cut-plane axis."""
|
|
268
|
+
return self.values[:, 4]
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def fz_n_per_m(self) -> np.ndarray:
|
|
272
|
+
"""Sectional force densities [N/m], second cut-plane axis."""
|
|
273
|
+
return self.values[:, 5]
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def moment_qc_nm_per_m(self) -> np.ndarray:
|
|
277
|
+
"""Sectional quarter-chord moment densities [N m / m]."""
|
|
278
|
+
return self.values[:, 6]
|
|
279
|
+
|
|
280
|
+
def split(self, family_map: SectionFamilyMap) -> dict[str, SectionBlock]:
|
|
281
|
+
"""Split the flat table into per-family blocks and cross-check.
|
|
282
|
+
|
|
283
|
+
The map's counts partition the rows in creation order; the
|
|
284
|
+
offset and chord discontinuities expected at every block
|
|
285
|
+
boundary are validated, so a map that disagrees with the
|
|
286
|
+
actual creation order fails loudly instead of silently
|
|
287
|
+
attributing sections to the wrong blade (RPT-005 finding 6).
|
|
288
|
+
|
|
289
|
+
Parameters
|
|
290
|
+
----------
|
|
291
|
+
family_map : SectionFamilyMap
|
|
292
|
+
Creation-order bookkeeping from the distribution-creating
|
|
293
|
+
code.
|
|
294
|
+
|
|
295
|
+
Returns
|
|
296
|
+
-------
|
|
297
|
+
dict of str to SectionBlock
|
|
298
|
+
Blocks keyed by family name, in creation order.
|
|
299
|
+
"""
|
|
300
|
+
if family_map.total_sections != self.count:
|
|
301
|
+
counts = [family.count for family in family_map.families]
|
|
302
|
+
raise ValueError(
|
|
303
|
+
f"the family map accounts for {family_map.total_sections} sections "
|
|
304
|
+
f"({counts}) but the export holds {self.count}; the map does not "
|
|
305
|
+
"describe the distributions of this run"
|
|
306
|
+
)
|
|
307
|
+
blocks: dict[str, SectionBlock] = {}
|
|
308
|
+
start = 0
|
|
309
|
+
for family in family_map.families:
|
|
310
|
+
rows = self.values[start : start + family.count]
|
|
311
|
+
blocks[family.name] = SectionBlock(
|
|
312
|
+
family=family.name,
|
|
313
|
+
offset_m=rows[:, 0],
|
|
314
|
+
chord_m=rows[:, 1],
|
|
315
|
+
x_qc_m=rows[:, 2],
|
|
316
|
+
z_qc_m=rows[:, 3],
|
|
317
|
+
fx_n_per_m=rows[:, 4],
|
|
318
|
+
fz_n_per_m=rows[:, 5],
|
|
319
|
+
moment_qc_nm_per_m=rows[:, 6],
|
|
320
|
+
)
|
|
321
|
+
start += family.count
|
|
322
|
+
_validate_block_boundaries(list(blocks.values()))
|
|
323
|
+
return blocks
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _smooth_step(values: np.ndarray, boundary_jump: float, *, directional: bool) -> bool:
|
|
327
|
+
"""Judge whether a boundary jump continues a block's march.
|
|
328
|
+
|
|
329
|
+
A jump is a smooth continuation when it is within three times the
|
|
330
|
+
block's median absolute step (and marching the same way, for the
|
|
331
|
+
directional offset check). Blocks of one row cannot be judged and
|
|
332
|
+
report not-smooth.
|
|
333
|
+
"""
|
|
334
|
+
if len(values) < 2:
|
|
335
|
+
return False
|
|
336
|
+
steps = np.diff(values)
|
|
337
|
+
median_step = float(np.median(np.abs(steps)))
|
|
338
|
+
if abs(boundary_jump) > 3.0 * median_step:
|
|
339
|
+
return False
|
|
340
|
+
if directional:
|
|
341
|
+
march = float(np.median(steps))
|
|
342
|
+
if march != 0.0 and np.sign(boundary_jump) != np.sign(march):
|
|
343
|
+
return False
|
|
344
|
+
return True
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _validate_block_boundaries(blocks: list[SectionBlock]) -> None:
|
|
348
|
+
"""Cross-check the family split against the export's geometry.
|
|
349
|
+
|
|
350
|
+
Each distribution marches its sections along the span, so inside a
|
|
351
|
+
block the offsets are strictly monotonic, and at a true family
|
|
352
|
+
boundary the offset restarts or the chord jumps (RPT-005 finding
|
|
353
|
+
6). A boundary where both offset and chord continue smoothly means
|
|
354
|
+
the family map disagrees with the creation order.
|
|
355
|
+
"""
|
|
356
|
+
for block in blocks:
|
|
357
|
+
steps = np.diff(block.offset_m)
|
|
358
|
+
if len(steps) and not (np.all(steps > 0.0) or np.all(steps < 0.0)):
|
|
359
|
+
raise ValueError(
|
|
360
|
+
f"the offsets of family {block.family!r} are not monotonic along "
|
|
361
|
+
"the span; a section distribution marches root to tip, so a "
|
|
362
|
+
"non-monotonic block means the family map splits the export at "
|
|
363
|
+
"the wrong rows"
|
|
364
|
+
)
|
|
365
|
+
for before, after in zip(blocks, blocks[1:], strict=False):
|
|
366
|
+
offset_jump = float(after.offset_m[0] - before.offset_m[-1])
|
|
367
|
+
chord_jump = float(after.chord_m[0] - before.chord_m[-1])
|
|
368
|
+
offset_smooth = _smooth_step(before.offset_m, offset_jump, directional=True)
|
|
369
|
+
chord_smooth = _smooth_step(before.chord_m, chord_jump, directional=False)
|
|
370
|
+
if offset_smooth and chord_smooth:
|
|
371
|
+
raise ValueError(
|
|
372
|
+
f"families {before.family!r} and {after.family!r} continue smoothly "
|
|
373
|
+
f"across their block boundary (offset jump {offset_jump:.4g} m, "
|
|
374
|
+
f"chord jump {chord_jump:.4g} m); a true family boundary shows an "
|
|
375
|
+
"offset restart or a chord discontinuity, so the family map "
|
|
376
|
+
"disagrees with the creation order of the distributions "
|
|
377
|
+
"(RPT-005 finding 6)"
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _si_labeled_number(text: str, label: str) -> float:
|
|
382
|
+
"""Read a unit-carrying labeled value, asserting its SI label."""
|
|
383
|
+
try:
|
|
384
|
+
return parse_number(labeled_value(text, label))
|
|
385
|
+
except AnchorNotFoundError as error:
|
|
386
|
+
raise UnitsError(
|
|
387
|
+
f"the export header does not carry the SI label {label!r}; the "
|
|
388
|
+
"sectional loads parser asserts SI units on the labeled header "
|
|
389
|
+
"(FSI-R03), so a missing unit label means the solver setup is not "
|
|
390
|
+
"in SI or the export format changed"
|
|
391
|
+
) from error
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def parse_sectional_loads(text: str) -> SectionalLoadsReport:
|
|
395
|
+
"""Parse one ``FS_SurfaceSection_Loads.txt`` export.
|
|
396
|
+
|
|
397
|
+
Parameters
|
|
398
|
+
----------
|
|
399
|
+
text : str
|
|
400
|
+
Complete file text.
|
|
401
|
+
|
|
402
|
+
Returns
|
|
403
|
+
-------
|
|
404
|
+
SectionalLoadsReport
|
|
405
|
+
Typed table plus metadata. The SI assertions (FSI-R03) and the
|
|
406
|
+
structural completeness checks (declared count, closing
|
|
407
|
+
separator, units footer) run here; a file failing any of them
|
|
408
|
+
raises instead of returning less.
|
|
409
|
+
"""
|
|
410
|
+
freestream = _si_labeled_number(text, "Freestream velocity (m/s)")
|
|
411
|
+
reference_area = _si_labeled_number(text, "Reference area (m^2)")
|
|
412
|
+
try:
|
|
413
|
+
force_units = labeled_value(text, "Force Units:")
|
|
414
|
+
moment_units = labeled_value(text, "Moment Units:")
|
|
415
|
+
except AnchorNotFoundError as error:
|
|
416
|
+
raise IncompleteOutputError(
|
|
417
|
+
"the sectional loads export has no units footer; the file ends "
|
|
418
|
+
"before the closing block, so the solver stopped before finishing "
|
|
419
|
+
"this export"
|
|
420
|
+
) from error
|
|
421
|
+
if force_units.strip().lower() != "newtons":
|
|
422
|
+
raise UnitsError(
|
|
423
|
+
f"the export carries forces in {force_units!r}, not Newtons; the "
|
|
424
|
+
"post-processing script must compute the sectional loads with "
|
|
425
|
+
"COMPUTE_SURFACE_SECTIONAL_LOADS NEWTONS (FSI-R03), because any "
|
|
426
|
+
"other unit would silently rescale the structural loads"
|
|
427
|
+
)
|
|
428
|
+
if moment_units.strip().lower() != "newton-meter":
|
|
429
|
+
raise UnitsError(
|
|
430
|
+
f"the export carries moments in {moment_units!r}, not Newton-Meter; "
|
|
431
|
+
"a non-SI moment unit would silently rescale the elastic twist "
|
|
432
|
+
"(FSI-R03)"
|
|
433
|
+
)
|
|
434
|
+
header_line = next(
|
|
435
|
+
(line.strip() for line in text.splitlines() if line.strip().startswith(_TABLE_ANCHOR)),
|
|
436
|
+
None,
|
|
437
|
+
)
|
|
438
|
+
if header_line is None:
|
|
439
|
+
raise AnchorNotFoundError(
|
|
440
|
+
f"the sectional loads table header {_TABLE_ANCHOR!r} was not found; "
|
|
441
|
+
"the file is not an EXPORT_SURFACE_SECTIONAL_LOADS output or its "
|
|
442
|
+
"format changed"
|
|
443
|
+
)
|
|
444
|
+
columns = tuple(cell.strip() for cell in header_line.split(",") if cell.strip())
|
|
445
|
+
if columns != EXPECTED_COLUMNS:
|
|
446
|
+
raise ValueError(
|
|
447
|
+
f"the sectional loads table names columns {columns}, expected "
|
|
448
|
+
f"{EXPECTED_COLUMNS}; the layout changed and the blade-frame "
|
|
449
|
+
"mapping of the force columns must be re-verified before parsing"
|
|
450
|
+
)
|
|
451
|
+
declared = int(parse_number(labeled_value(text, "Number of Surface Sections:")))
|
|
452
|
+
rows = delimited_table(text, _TABLE_ANCHOR)
|
|
453
|
+
parsed_rows: list[list[float]] = []
|
|
454
|
+
for row in rows:
|
|
455
|
+
cells = [cell for cell in row if cell]
|
|
456
|
+
if len(cells) != len(columns):
|
|
457
|
+
raise ValueError(
|
|
458
|
+
f"a sectional loads row holds {len(cells)} values but the header "
|
|
459
|
+
f"names {len(columns)} columns; the table layout changed"
|
|
460
|
+
)
|
|
461
|
+
parsed_rows.append([parse_number(cell) for cell in cells])
|
|
462
|
+
if len(parsed_rows) != declared:
|
|
463
|
+
raise IncompleteOutputError(
|
|
464
|
+
f"the export declares {declared} surface sections but the table "
|
|
465
|
+
f"holds {len(parsed_rows)} rows; the solver stopped mid-write"
|
|
466
|
+
)
|
|
467
|
+
return SectionalLoadsReport(
|
|
468
|
+
angle_of_attack_deg=parse_number(labeled_value(text, "Angle of attack (Deg)")),
|
|
469
|
+
sideslip_deg=parse_number(labeled_value(text, "Side-slip angle (Deg)")),
|
|
470
|
+
freestream_velocity_m_s=freestream,
|
|
471
|
+
time_increment_s=_optional_number(text, "Time increment (sec)"),
|
|
472
|
+
solver_mode=labeled_value(text, "Solver mode:"),
|
|
473
|
+
current_iteration=int(
|
|
474
|
+
parse_number(labeled_value(text, "Current solver iteration number:"))
|
|
475
|
+
),
|
|
476
|
+
reference_velocity_m_s=_optional_number(text, "Reference velocity (m/s)"),
|
|
477
|
+
reference_length_m=_optional_number(text, "Reference length (m)"),
|
|
478
|
+
reference_area_m2=reference_area,
|
|
479
|
+
declared_section_count=declared,
|
|
480
|
+
force_units=force_units,
|
|
481
|
+
moment_units=moment_units,
|
|
482
|
+
columns=columns,
|
|
483
|
+
values=np.asarray(parsed_rows, dtype=float),
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _optional_number(text: str, label: str) -> float | None:
|
|
488
|
+
try:
|
|
489
|
+
return parse_number(labeled_value(text, label))
|
|
490
|
+
except AnchorNotFoundError:
|
|
491
|
+
return None
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def transfer_moment_to_elastic_axis(
|
|
495
|
+
moment_pa_nm: np.ndarray,
|
|
496
|
+
force_chordwise_n: np.ndarray,
|
|
497
|
+
force_normal_n: np.ndarray,
|
|
498
|
+
ea_offset_chordwise_m: np.ndarray,
|
|
499
|
+
ea_offset_normal_m: np.ndarray,
|
|
500
|
+
) -> np.ndarray:
|
|
501
|
+
"""Transfer a sectional pitch-axis moment to the elastic axis.
|
|
502
|
+
|
|
503
|
+
M_EA = M_PA + e_c F_n - e_n F_c: the spanwise component of
|
|
504
|
+
M_PA + e x F for the section-plane offset e = (e_c, e_n) from the
|
|
505
|
+
pitch axis to the elastic axis and the section force
|
|
506
|
+
F = (F_c, F_n), in the right-handed blade triad of
|
|
507
|
+
:mod:`pyflightstream.fsi.config` (chordwise toward the leading
|
|
508
|
+
edge, normal completing the triad, moments positive nose up about
|
|
509
|
+
the spanwise axis). All inputs broadcast. The identity holds per
|
|
510
|
+
unit span exactly as for totals, so it applies unchanged to the
|
|
511
|
+
line densities of the sectional export (forces in N/m, moments in
|
|
512
|
+
N m / m, offsets in m).
|
|
513
|
+
|
|
514
|
+
Source: DLV-007 Section 4.3 (FSI-R04); pitch-axis moment reference
|
|
515
|
+
confirmed by the WP1 dry run (reports/RPT-005 finding 4).
|
|
516
|
+
"""
|
|
517
|
+
return (
|
|
518
|
+
np.asarray(moment_pa_nm, dtype=float)
|
|
519
|
+
+ np.asarray(ea_offset_chordwise_m, dtype=float) * np.asarray(force_normal_n, dtype=float)
|
|
520
|
+
- np.asarray(ea_offset_normal_m, dtype=float) * np.asarray(force_chordwise_n, dtype=float)
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def project_rotor_frame_loads(
|
|
525
|
+
fx_n_per_m: np.ndarray,
|
|
526
|
+
fz_n_per_m: np.ndarray,
|
|
527
|
+
moment_qc_nm_per_m: np.ndarray,
|
|
528
|
+
blade_angle_rad: np.ndarray,
|
|
529
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
530
|
+
"""Project cut-plane load densities onto the section axes (rotor frame).
|
|
531
|
+
|
|
532
|
+
f_chordwise = -Fx sin b - Fz cos b (toward the leading edge),
|
|
533
|
+
f_normal = -Fx cos b + Fz sin b (toward the suction side), and
|
|
534
|
+
m_nose_up = -Moment. In the rotor import frame (X axial, Y
|
|
535
|
+
in-plane, Z span; RPT-006 finding 3) the section chordwise and
|
|
536
|
+
suction directions at local blade angle b are (-sin b, -cos b) and
|
|
537
|
+
(-cos b, sin b); nose-up is the rotation about -Z for this
|
|
538
|
+
geometry, and the export's moment column is positive about +Z,
|
|
539
|
+
physically nose-down (the dry-run fixture's +7 to +13 N m/m match
|
|
540
|
+
the nose-down |Cm| q c^2 of the cambered generic section). The
|
|
541
|
+
sign is corroborated by the soft-blade pilot response (RPT-007).
|
|
542
|
+
Inputs broadcast; densities in N/m and N m / m, angles in rad.
|
|
543
|
+
|
|
544
|
+
Source: rigid-section geometry of the rotor-frame embedding
|
|
545
|
+
(RPT-006 finding 3); load assembly of DLV-007 Section 4.2.
|
|
546
|
+
"""
|
|
547
|
+
fx = np.asarray(fx_n_per_m, dtype=float)
|
|
548
|
+
fz = np.asarray(fz_n_per_m, dtype=float)
|
|
549
|
+
beta = np.asarray(blade_angle_rad, dtype=float)
|
|
550
|
+
chordwise = -fx * np.sin(beta) - fz * np.cos(beta)
|
|
551
|
+
normal = -fx * np.cos(beta) + fz * np.sin(beta)
|
|
552
|
+
return chordwise, normal, -np.asarray(moment_qc_nm_per_m, dtype=float)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@dataclass(frozen=True)
|
|
556
|
+
class ElasticAxisLoads:
|
|
557
|
+
"""Per-section aerodynamic load densities of one blade, about its EA.
|
|
558
|
+
|
|
559
|
+
All arrays share the block's section count and are line densities
|
|
560
|
+
along the span (RPT-006), ready for the beam's distributed-load
|
|
561
|
+
interface; the tributary widths integrate them back to totals.
|
|
562
|
+
|
|
563
|
+
Attributes
|
|
564
|
+
----------
|
|
565
|
+
radius_m : numpy.ndarray
|
|
566
|
+
Section radii [m] from the rotation axis along the pitch axis.
|
|
567
|
+
chord_m : numpy.ndarray
|
|
568
|
+
Local chords [m].
|
|
569
|
+
force_chordwise_n_per_m : numpy.ndarray
|
|
570
|
+
Chordwise force densities [N/m] (export Fx column; see
|
|
571
|
+
:func:`to_elastic_axis` for the axes caveat).
|
|
572
|
+
force_normal_n_per_m : numpy.ndarray
|
|
573
|
+
Normal (flap-direction) force densities [N/m] (export Fz).
|
|
574
|
+
moment_pa_nm_per_m : numpy.ndarray
|
|
575
|
+
Moment densities about the pitch axis [N m / m], as exported.
|
|
576
|
+
moment_ea_nm_per_m : numpy.ndarray
|
|
577
|
+
Moment densities about the elastic axis [N m / m] (FSI-R04).
|
|
578
|
+
ea_offset_chordwise_m, ea_offset_normal_m : numpy.ndarray
|
|
579
|
+
Interpolated elastic-axis offsets e(r) [m] used in the
|
|
580
|
+
transfer.
|
|
581
|
+
tributary_width_m : numpy.ndarray
|
|
582
|
+
Spanwise width [m] of each section's midpoint strip;
|
|
583
|
+
integrates the densities back to totals for cross-checks.
|
|
584
|
+
"""
|
|
585
|
+
|
|
586
|
+
radius_m: np.ndarray
|
|
587
|
+
chord_m: np.ndarray
|
|
588
|
+
force_chordwise_n_per_m: np.ndarray
|
|
589
|
+
force_normal_n_per_m: np.ndarray
|
|
590
|
+
moment_pa_nm_per_m: np.ndarray
|
|
591
|
+
moment_ea_nm_per_m: np.ndarray
|
|
592
|
+
ea_offset_chordwise_m: np.ndarray
|
|
593
|
+
ea_offset_normal_m: np.ndarray
|
|
594
|
+
tributary_width_m: np.ndarray
|
|
595
|
+
|
|
596
|
+
@property
|
|
597
|
+
def flap_load_n_per_m(self) -> np.ndarray:
|
|
598
|
+
"""Normal force densities [N/m] at the section radii."""
|
|
599
|
+
return self.force_normal_n_per_m
|
|
600
|
+
|
|
601
|
+
@property
|
|
602
|
+
def torsion_moment_nm_per_m(self) -> np.ndarray:
|
|
603
|
+
"""Elastic-axis moment densities [N m / m] at the section radii."""
|
|
604
|
+
return self.moment_ea_nm_per_m
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _tributary_widths(radii: np.ndarray) -> np.ndarray:
|
|
608
|
+
"""Midpoint strip widths of sorted section radii (sum = span covered)."""
|
|
609
|
+
edges = np.concatenate(([radii[0]], 0.5 * (radii[1:] + radii[:-1]), [radii[-1]]))
|
|
610
|
+
return np.diff(edges)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def to_elastic_axis(block: SectionBlock, cfg: FsiConfig) -> ElasticAxisLoads:
|
|
614
|
+
"""Transfer one blade block to elastic-axis load densities (FSI-R04).
|
|
615
|
+
|
|
616
|
+
The elastic-axis offsets e(r) come from the configuration,
|
|
617
|
+
interpolated linearly at the section radii, so refining the
|
|
618
|
+
elastic axis estimate never touches the FlightStream setup
|
|
619
|
+
(DLV-007 Section 4.3).
|
|
620
|
+
|
|
621
|
+
Axes (RPT-006 finding 3): the export columns are the cut-plane
|
|
622
|
+
axes. At Omega zero (``section_frame`` embedding, the wing case)
|
|
623
|
+
they are the section chordwise/normal axes and pass through
|
|
624
|
+
unchanged. On a spinning blade (``rotor_frame``) they are the
|
|
625
|
+
axial and in-plane axes, so the loads are projected onto the
|
|
626
|
+
section axes with the local blade angle interpolated from the
|
|
627
|
+
geometric pitch distribution
|
|
628
|
+
(:func:`project_rotor_frame_loads`); the embedding rule is shared
|
|
629
|
+
with the node generator through
|
|
630
|
+
:func:`pyflightstream.fsi.config.frame_embedding`, so loads and
|
|
631
|
+
displacements always live in the same triad.
|
|
632
|
+
|
|
633
|
+
Parameters
|
|
634
|
+
----------
|
|
635
|
+
block : SectionBlock
|
|
636
|
+
One blade family from :meth:`SectionalLoadsReport.split`.
|
|
637
|
+
cfg : FsiConfig
|
|
638
|
+
Configuration whose blade the sections were cut on.
|
|
639
|
+
|
|
640
|
+
Returns
|
|
641
|
+
-------
|
|
642
|
+
ElasticAxisLoads
|
|
643
|
+
Load densities about the elastic axis at the section radii,
|
|
644
|
+
in section components.
|
|
645
|
+
"""
|
|
646
|
+
stations = np.asarray(cfg.blade.station_radii_m, dtype=float)
|
|
647
|
+
radii = block.offset_m
|
|
648
|
+
span = stations[-1] - stations[0]
|
|
649
|
+
tolerance = _SPAN_TOLERANCE * span
|
|
650
|
+
if radii.min() < stations[0] - tolerance or radii.max() > stations[-1] + tolerance:
|
|
651
|
+
raise ValueError(
|
|
652
|
+
f"the sections of family {block.family!r} span "
|
|
653
|
+
f"[{radii.min():.4g}, {radii.max():.4g}] m but the configured blade "
|
|
654
|
+
f"spans [{stations[0]:.4g}, {stations[-1]:.4g}] m; this configuration "
|
|
655
|
+
"does not describe the blade these sections were cut on"
|
|
656
|
+
)
|
|
657
|
+
e_chordwise = np.interp(radii, stations, cfg.blade.elastic_axis_offset_chordwise_m)
|
|
658
|
+
e_normal = np.interp(radii, stations, cfg.blade.elastic_axis_offset_normal_m)
|
|
659
|
+
ascending = radii if radii[0] <= radii[-1] else radii[::-1]
|
|
660
|
+
widths = _tributary_widths(ascending)
|
|
661
|
+
if radii[0] > radii[-1]:
|
|
662
|
+
widths = widths[::-1]
|
|
663
|
+
if frame_embedding(cfg) == "rotor_frame":
|
|
664
|
+
beta = np.radians(np.interp(radii, stations, cfg.blade.geometric_pitch_deg))
|
|
665
|
+
chordwise, normal, moment_pa = project_rotor_frame_loads(
|
|
666
|
+
block.fx_n_per_m, block.fz_n_per_m, block.moment_qc_nm_per_m, beta
|
|
667
|
+
)
|
|
668
|
+
else:
|
|
669
|
+
chordwise, normal = block.fx_n_per_m, block.fz_n_per_m
|
|
670
|
+
moment_pa = block.moment_qc_nm_per_m
|
|
671
|
+
return ElasticAxisLoads(
|
|
672
|
+
radius_m=radii,
|
|
673
|
+
chord_m=block.chord_m,
|
|
674
|
+
force_chordwise_n_per_m=chordwise,
|
|
675
|
+
force_normal_n_per_m=normal,
|
|
676
|
+
moment_pa_nm_per_m=moment_pa,
|
|
677
|
+
moment_ea_nm_per_m=transfer_moment_to_elastic_axis(
|
|
678
|
+
moment_pa, chordwise, normal, e_chordwise, e_normal
|
|
679
|
+
),
|
|
680
|
+
ea_offset_chordwise_m=e_chordwise,
|
|
681
|
+
ea_offset_normal_m=e_normal,
|
|
682
|
+
tributary_width_m=widths,
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def cross_check_totals(
|
|
687
|
+
block: SectionBlock,
|
|
688
|
+
integrated_fx_n: float,
|
|
689
|
+
integrated_fz_n: float,
|
|
690
|
+
rel_tol: float = 0.05,
|
|
691
|
+
) -> dict[str, float]:
|
|
692
|
+
"""Cross-check a block's integrated densities against total forces.
|
|
693
|
+
|
|
694
|
+
The force densities integrated over the tributary widths must
|
|
695
|
+
reproduce the loads FlightStream reports for the same boundary in
|
|
696
|
+
the same run, in Newtons and in a comparable frame component
|
|
697
|
+
(RPT-006: the axial component is the frame-invariant one for a
|
|
698
|
+
rotating blade); a disagreement beyond ``rel_tol`` means the
|
|
699
|
+
sections do not cover the boundary, the attribution is wrong, or
|
|
700
|
+
the per-span unit finding no longer holds, and raises instead of
|
|
701
|
+
letting a mis-scaled load set into the structural solve.
|
|
702
|
+
|
|
703
|
+
Parameters
|
|
704
|
+
----------
|
|
705
|
+
block : SectionBlock
|
|
706
|
+
One family block.
|
|
707
|
+
integrated_fx_n, integrated_fz_n : float
|
|
708
|
+
Integrated forces [N] of the matching boundary from the same
|
|
709
|
+
run, in the components matching the export axes.
|
|
710
|
+
rel_tol : float
|
|
711
|
+
Allowed relative disagreement, on the larger of the compared
|
|
712
|
+
magnitudes; the default reflects the few-percent closure of
|
|
713
|
+
the pilot evidence (tip and root strips, frame effects).
|
|
714
|
+
|
|
715
|
+
Returns
|
|
716
|
+
-------
|
|
717
|
+
dict of str to float
|
|
718
|
+
Relative deltas per component (``"fx"``, ``"fz"``).
|
|
719
|
+
"""
|
|
720
|
+
ascending = np.sort(block.offset_m)
|
|
721
|
+
widths = _tributary_widths(ascending)
|
|
722
|
+
order = np.argsort(block.offset_m)
|
|
723
|
+
deltas: dict[str, float] = {}
|
|
724
|
+
for name, density, integrated in (
|
|
725
|
+
("fx", block.fx_n_per_m, integrated_fx_n),
|
|
726
|
+
("fz", block.fz_n_per_m, integrated_fz_n),
|
|
727
|
+
):
|
|
728
|
+
total = float((density[order] * widths).sum())
|
|
729
|
+
scale = max(abs(total), abs(integrated), 1e-9)
|
|
730
|
+
deltas[name] = abs(total - integrated) / scale
|
|
731
|
+
if deltas[name] > rel_tol:
|
|
732
|
+
raise ValueError(
|
|
733
|
+
f"the sectional {name.upper()} of family {block.family!r} "
|
|
734
|
+
f"integrates to {total:.6g} N but the integrated export reports "
|
|
735
|
+
f"{integrated:.6g} N ({deltas[name]:.2%} apart, tolerance "
|
|
736
|
+
f"{rel_tol:.2%}); the sections do not cover the boundary, the "
|
|
737
|
+
"attribution is wrong, or the per-span unit finding (RPT-006) "
|
|
738
|
+
"no longer holds"
|
|
739
|
+
)
|
|
740
|
+
return deltas
|