pyflightstream 0.2.0__py3-none-any.whl

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