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,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"]