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,547 @@
|
|
|
1
|
+
"""Anchor-based parsers for FlightStream output files.
|
|
2
|
+
|
|
3
|
+
Pipeline role: reads solver output text files into typed results.
|
|
4
|
+
Values are located by their printed labels (:func:`labeled_value`) and
|
|
5
|
+
tables by their header rows (:func:`delimited_table`), never by fixed
|
|
6
|
+
line numbers, so cosmetic layout changes between FlightStream versions
|
|
7
|
+
do not silently corrupt data (SAD Section 8, PP-4). Completeness is
|
|
8
|
+
structural: a missing footer or table terminator raises
|
|
9
|
+
:class:`IncompleteOutputError`, never a silently shorter table
|
|
10
|
+
(FR-17).
|
|
11
|
+
|
|
12
|
+
The FlightStream version printed in each output is cross-checked
|
|
13
|
+
against the requested version (FR-18). The printed string is coarser
|
|
14
|
+
than the canonical scheme: the 26.120 build reports itself as
|
|
15
|
+
``Flightstream version 26.1, build #7012026`` (observed in the
|
|
16
|
+
committed fixtures), so the check compares by alias prefix and records
|
|
17
|
+
the reported string and build verbatim; the build number is the
|
|
18
|
+
precise discriminator.
|
|
19
|
+
|
|
20
|
+
Number forms follow the solver's printing: ``.000`` (no leading
|
|
21
|
+
zero), ``4380000.`` (trailing point), and ``1.000E-05`` all parse.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import math
|
|
27
|
+
import re
|
|
28
|
+
import warnings
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
|
|
33
|
+
from pyflightstream.versions import FsVersion, resolve
|
|
34
|
+
|
|
35
|
+
_DASHED_LINE = re.compile(r"^-{4,}$")
|
|
36
|
+
_SOFTWARE_LINE = re.compile(
|
|
37
|
+
r"Software\s*:\s*Flightstream version\s+(?P<version>\S+),\s*build\s*#(?P<build>\d+)",
|
|
38
|
+
re.IGNORECASE,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AnchorNotFoundError(ValueError):
|
|
43
|
+
"""A printed label or table header was not found in the output.
|
|
44
|
+
|
|
45
|
+
Anchor-based parsing refuses to fall back to line offsets; a
|
|
46
|
+
missing anchor means the file is not the expected kind of output
|
|
47
|
+
or the format changed, and both must surface loudly.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class IncompleteOutputError(ValueError):
|
|
52
|
+
"""The output file ends before its structural terminator.
|
|
53
|
+
|
|
54
|
+
A loads spreadsheet without its footer or a table without its
|
|
55
|
+
closing dashed line means the solver stopped mid-write; the
|
|
56
|
+
campaign records the point as FAILED_INCOMPLETE_OUTPUT instead of
|
|
57
|
+
consuming a silently shorter table (FR-17).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class VersionMismatchWarning(UserWarning):
|
|
62
|
+
"""The version printed in an output disagrees with the requested one.
|
|
63
|
+
|
|
64
|
+
Warned, not raised: the run evidence is still recorded, with the
|
|
65
|
+
reported string and build stored verbatim in the manifest (FR-18).
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def labeled_value(text: str, label: str) -> str:
|
|
70
|
+
"""Return the value printed after a label, located by the label itself.
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
text : str
|
|
75
|
+
Complete output file text.
|
|
76
|
+
label : str
|
|
77
|
+
Printed label, for example ``"Angle of attack (Deg)"``; the
|
|
78
|
+
first line whose content starts with it provides the value.
|
|
79
|
+
|
|
80
|
+
Returns
|
|
81
|
+
-------
|
|
82
|
+
str
|
|
83
|
+
The remainder of the line after the label, stripped.
|
|
84
|
+
"""
|
|
85
|
+
for line in text.splitlines():
|
|
86
|
+
stripped = line.strip()
|
|
87
|
+
if stripped.startswith(label):
|
|
88
|
+
return stripped[len(label) :].strip()
|
|
89
|
+
raise AnchorNotFoundError(
|
|
90
|
+
f"label {label!r} was not found in the output; anchor-based parsing refuses "
|
|
91
|
+
"line offsets, so a missing label means the file is not the expected output "
|
|
92
|
+
"kind or its format changed"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _optional_labeled_value(text: str, label: str) -> str | None:
|
|
97
|
+
try:
|
|
98
|
+
return labeled_value(text, label)
|
|
99
|
+
except AnchorNotFoundError:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def parse_number(token: str) -> float:
|
|
104
|
+
"""Parse one solver-printed number.
|
|
105
|
+
|
|
106
|
+
Accepts the solver's forms: ``.000``, ``4380000.``, ``1.000E-05``,
|
|
107
|
+
and signed values such as ``+0.0002056``.
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
return float(token)
|
|
111
|
+
except ValueError as error:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"{token!r} is not a solver-printed number; expected forms like "
|
|
114
|
+
"'.000', '4380000.', or '1.000E-05'"
|
|
115
|
+
) from error
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def delimited_table(text: str, header_anchor: str, delimiter: str | None = ",") -> list[list[str]]:
|
|
119
|
+
"""Read a table's data rows, from its header row to its terminator.
|
|
120
|
+
|
|
121
|
+
The table is located by the first line starting with
|
|
122
|
+
``header_anchor``; dashed separator lines after the header are
|
|
123
|
+
skipped, and rows accumulate until the closing dashed line. The
|
|
124
|
+
terminator is structural: reaching the end of the text without it
|
|
125
|
+
raises :class:`IncompleteOutputError` (FR-17).
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
text : str
|
|
130
|
+
Complete output file text.
|
|
131
|
+
header_anchor : str
|
|
132
|
+
Start of the header row, for example ``"Surface,"`` for the
|
|
133
|
+
loads table or ``"Iteration"`` for the log residual table.
|
|
134
|
+
delimiter : str or None
|
|
135
|
+
Cell separator of the data rows; None splits on any
|
|
136
|
+
whitespace (the log tables are tab separated).
|
|
137
|
+
|
|
138
|
+
Returns
|
|
139
|
+
-------
|
|
140
|
+
list of list of str
|
|
141
|
+
One list of stripped cells per data row.
|
|
142
|
+
"""
|
|
143
|
+
lines = iter(text.splitlines())
|
|
144
|
+
for line in lines:
|
|
145
|
+
if line.strip().startswith(header_anchor):
|
|
146
|
+
break
|
|
147
|
+
else:
|
|
148
|
+
raise AnchorNotFoundError(
|
|
149
|
+
f"table header {header_anchor!r} was not found in the output; tables are "
|
|
150
|
+
"located by their header rows, never by line numbers"
|
|
151
|
+
)
|
|
152
|
+
rows: list[list[str]] = []
|
|
153
|
+
for line in lines:
|
|
154
|
+
stripped = line.strip()
|
|
155
|
+
if not stripped:
|
|
156
|
+
continue
|
|
157
|
+
if _DASHED_LINE.match(stripped):
|
|
158
|
+
if rows:
|
|
159
|
+
return rows
|
|
160
|
+
continue
|
|
161
|
+
cells = stripped.split(delimiter) if delimiter else stripped.split()
|
|
162
|
+
rows.append([cell.strip() for cell in cells])
|
|
163
|
+
raise IncompleteOutputError(
|
|
164
|
+
f"the table under {header_anchor!r} has no closing separator line; the file "
|
|
165
|
+
"ends mid-table, so the solver stopped before finishing this output"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True)
|
|
170
|
+
class LoadsReport:
|
|
171
|
+
"""Typed content of one aerodynamic loads spreadsheet.
|
|
172
|
+
|
|
173
|
+
The spreadsheet is the primary quantitative output of a run
|
|
174
|
+
(EXPORT_SOLVER_ANALYSIS_SPREADSHEET, SRC-003 p.352). Coefficients
|
|
175
|
+
are expressed in the analysis frame named by ``frame``; forces
|
|
176
|
+
follow ``force_units`` and moments ``moment_units``.
|
|
177
|
+
|
|
178
|
+
Attributes
|
|
179
|
+
----------
|
|
180
|
+
angle_of_attack_deg : float
|
|
181
|
+
Angle of attack in deg.
|
|
182
|
+
sideslip_deg : float
|
|
183
|
+
Side-slip angle in deg.
|
|
184
|
+
freestream_velocity_m_s : float
|
|
185
|
+
Free-stream velocity in m/s.
|
|
186
|
+
requested_iterations : int
|
|
187
|
+
Solver iteration limit of the run.
|
|
188
|
+
convergence_limit : float
|
|
189
|
+
Residual threshold declaring convergence.
|
|
190
|
+
solver_mode : str
|
|
191
|
+
``Steady`` or ``Unsteady`` as printed.
|
|
192
|
+
current_iteration : int
|
|
193
|
+
Iteration counter at export time.
|
|
194
|
+
solver_model : str or None
|
|
195
|
+
Solver model as printed, when present.
|
|
196
|
+
forced_iterations : bool or None
|
|
197
|
+
Whether the solver was forced to run all iterations.
|
|
198
|
+
reference_velocity_m_s, reference_length, reference_area : float or None
|
|
199
|
+
Coefficient normalization references, in the printed units.
|
|
200
|
+
reynolds : float or None
|
|
201
|
+
Reynolds number of the condition.
|
|
202
|
+
frame : str or None
|
|
203
|
+
Coordinate frame of the analysis.
|
|
204
|
+
surfaces : dict of str to dict of str to float
|
|
205
|
+
Per-surface coefficients, keyed surface name then column name
|
|
206
|
+
(Cx, Cy, Cz, CL, CDi, CDo, CMx, CMy, CMz).
|
|
207
|
+
total : dict of str to float
|
|
208
|
+
The Total row, same columns.
|
|
209
|
+
force_units, moment_units : str
|
|
210
|
+
Units of the force and moment columns as printed.
|
|
211
|
+
fs_version_reported : str
|
|
212
|
+
Version string printed in the footer, verbatim.
|
|
213
|
+
fs_build : str
|
|
214
|
+
Build number printed in the footer, verbatim.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
angle_of_attack_deg: float
|
|
218
|
+
sideslip_deg: float
|
|
219
|
+
freestream_velocity_m_s: float
|
|
220
|
+
requested_iterations: int
|
|
221
|
+
convergence_limit: float
|
|
222
|
+
solver_mode: str
|
|
223
|
+
current_iteration: int
|
|
224
|
+
solver_model: str | None
|
|
225
|
+
forced_iterations: bool | None
|
|
226
|
+
reference_velocity_m_s: float | None
|
|
227
|
+
reference_length: float | None
|
|
228
|
+
reference_area: float | None
|
|
229
|
+
reynolds: float | None
|
|
230
|
+
frame: str | None
|
|
231
|
+
surfaces: dict[str, dict[str, float]]
|
|
232
|
+
total: dict[str, float]
|
|
233
|
+
force_units: str
|
|
234
|
+
moment_units: str
|
|
235
|
+
fs_version_reported: str
|
|
236
|
+
fs_build: str
|
|
237
|
+
|
|
238
|
+
def diverged_columns(self) -> list[str]:
|
|
239
|
+
"""Return the Total columns holding NaN or infinite values."""
|
|
240
|
+
return [
|
|
241
|
+
column for column, value in self.total.items() if math.isnan(value) or math.isinf(value)
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def parse_loads(text: str, requested_version: str | FsVersion | None = None) -> LoadsReport:
|
|
246
|
+
"""Parse one aerodynamic loads spreadsheet.
|
|
247
|
+
|
|
248
|
+
Parameters
|
|
249
|
+
----------
|
|
250
|
+
text : str
|
|
251
|
+
Complete file text.
|
|
252
|
+
requested_version : str, FsVersion, or None
|
|
253
|
+
When given, the version printed in the footer is cross-checked
|
|
254
|
+
against it by alias prefix (the printed string is coarser than
|
|
255
|
+
the canonical scheme; see the module docstring) and a
|
|
256
|
+
:class:`VersionMismatchWarning` is issued on inconsistency
|
|
257
|
+
(FR-18).
|
|
258
|
+
|
|
259
|
+
Returns
|
|
260
|
+
-------
|
|
261
|
+
LoadsReport
|
|
262
|
+
Typed report; the footer and the table terminator are
|
|
263
|
+
structural, so an incomplete file raises
|
|
264
|
+
:class:`IncompleteOutputError` instead of returning less.
|
|
265
|
+
"""
|
|
266
|
+
software = _SOFTWARE_LINE.search(text)
|
|
267
|
+
if software is None:
|
|
268
|
+
raise IncompleteOutputError(
|
|
269
|
+
"the loads spreadsheet has no software footer; the file ends before the "
|
|
270
|
+
"closing block, so the solver stopped before finishing this export"
|
|
271
|
+
)
|
|
272
|
+
header_cells = labeled_value(text, "Surface,")
|
|
273
|
+
columns = [cell.strip() for cell in header_cells.split(",") if cell.strip()]
|
|
274
|
+
rows = delimited_table(text, "Surface,")
|
|
275
|
+
surfaces: dict[str, dict[str, float]] = {}
|
|
276
|
+
total: dict[str, float] | None = None
|
|
277
|
+
for row in rows:
|
|
278
|
+
name, values = row[0], row[1:]
|
|
279
|
+
if len(values) != len(columns):
|
|
280
|
+
raise ValueError(
|
|
281
|
+
f"loads row for {name!r} holds {len(values)} values but the header "
|
|
282
|
+
f"names {len(columns)} columns; the table layout changed"
|
|
283
|
+
)
|
|
284
|
+
parsed = {
|
|
285
|
+
column: parse_number(value) for column, value in zip(columns, values, strict=True)
|
|
286
|
+
}
|
|
287
|
+
if name.lower() == "total":
|
|
288
|
+
total = parsed
|
|
289
|
+
else:
|
|
290
|
+
surfaces[name] = parsed
|
|
291
|
+
if total is None:
|
|
292
|
+
raise IncompleteOutputError(
|
|
293
|
+
"the loads table has no Total row; per-surface rows without the closing "
|
|
294
|
+
"Total mean the export stopped mid-table"
|
|
295
|
+
)
|
|
296
|
+
forced = _optional_labeled_value(text, "Force solver to run all iterations")
|
|
297
|
+
reported = software.group("version")
|
|
298
|
+
if requested_version is not None:
|
|
299
|
+
_cross_check_version(reported, requested_version)
|
|
300
|
+
return LoadsReport(
|
|
301
|
+
angle_of_attack_deg=parse_number(labeled_value(text, "Angle of attack (Deg)")),
|
|
302
|
+
sideslip_deg=parse_number(labeled_value(text, "Side-slip angle (Deg)")),
|
|
303
|
+
freestream_velocity_m_s=parse_number(labeled_value(text, "Freestream velocity (m/s)")),
|
|
304
|
+
requested_iterations=int(parse_number(labeled_value(text, "Requested solver iterations"))),
|
|
305
|
+
convergence_limit=parse_number(labeled_value(text, "Solver convergence limit")),
|
|
306
|
+
solver_mode=labeled_value(text, "Solver mode:"),
|
|
307
|
+
current_iteration=int(
|
|
308
|
+
parse_number(labeled_value(text, "Current solver iteration number:"))
|
|
309
|
+
),
|
|
310
|
+
solver_model=_optional_labeled_value(text, "Solver model:"),
|
|
311
|
+
forced_iterations=None if forced is None else forced.upper().startswith("T"),
|
|
312
|
+
reference_velocity_m_s=_optional_number(text, "Reference velocity (m/s)"),
|
|
313
|
+
reference_length=_optional_number(text, "Reference length (m)"),
|
|
314
|
+
reference_area=_optional_number(text, "Reference area (m^2)"),
|
|
315
|
+
reynolds=_optional_number(text, "Reynolds Number"),
|
|
316
|
+
frame=_optional_labeled_value(text, "Coordinate frame for analysis:"),
|
|
317
|
+
surfaces=surfaces,
|
|
318
|
+
total=total,
|
|
319
|
+
force_units=labeled_value(text, "Force Units:"),
|
|
320
|
+
moment_units=labeled_value(text, "Moment Units:"),
|
|
321
|
+
fs_version_reported=reported,
|
|
322
|
+
fs_build=software.group("build"),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _optional_number(text: str, label: str) -> float | None:
|
|
327
|
+
value = _optional_labeled_value(text, label)
|
|
328
|
+
return None if value is None else parse_number(value)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _cross_check_version(reported: str, requested: str | FsVersion) -> None:
|
|
332
|
+
alias = resolve(requested).alias
|
|
333
|
+
consistent = alias == reported or alias.startswith(reported) or reported.startswith(alias)
|
|
334
|
+
if not consistent:
|
|
335
|
+
warnings.warn(
|
|
336
|
+
f"the output reports FlightStream {reported!r} but the run requested "
|
|
337
|
+
f"{alias!r}; the wrong executable may have run. The reported string and "
|
|
338
|
+
"build are recorded verbatim in the manifest (FR-18).",
|
|
339
|
+
VersionMismatchWarning,
|
|
340
|
+
stacklevel=3,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@dataclass(frozen=True)
|
|
345
|
+
class ResidualSample:
|
|
346
|
+
"""One row of the solver residual history.
|
|
347
|
+
|
|
348
|
+
Attributes
|
|
349
|
+
----------
|
|
350
|
+
iteration : int
|
|
351
|
+
Solver iteration number.
|
|
352
|
+
velocity_residual : float
|
|
353
|
+
Surface velocity residual, dimensionless.
|
|
354
|
+
pressure_residual : float
|
|
355
|
+
Surface pressure residual, dimensionless.
|
|
356
|
+
"""
|
|
357
|
+
|
|
358
|
+
iteration: int
|
|
359
|
+
velocity_residual: float
|
|
360
|
+
pressure_residual: float
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def parse_residual_history(text: str) -> list[ResidualSample]:
|
|
364
|
+
"""Parse the residual table of an exported solver log.
|
|
365
|
+
|
|
366
|
+
The log's iteration table carries the velocity and pressure
|
|
367
|
+
residuals the convergence threshold applies to (SRC-003 p.200);
|
|
368
|
+
the final row is the convergence evidence of the run.
|
|
369
|
+
|
|
370
|
+
Parameters
|
|
371
|
+
----------
|
|
372
|
+
text : str
|
|
373
|
+
Complete log text (EXPORT_LOG output or captured log file).
|
|
374
|
+
|
|
375
|
+
Returns
|
|
376
|
+
-------
|
|
377
|
+
list of ResidualSample
|
|
378
|
+
The history in iteration order; the first three columns of
|
|
379
|
+
each row (iteration, velocity residual, pressure residual)
|
|
380
|
+
are parsed, further columns vary with the run setup.
|
|
381
|
+
"""
|
|
382
|
+
# Real hidden-mode log exports carry stray NUL bytes between lines
|
|
383
|
+
# (observed on 26.120 build 7012026); scrub them before parsing.
|
|
384
|
+
rows = delimited_table(text.replace("\x00", ""), "Iteration", delimiter=None)
|
|
385
|
+
history: list[ResidualSample] = []
|
|
386
|
+
for row in rows:
|
|
387
|
+
if len(row) < 3:
|
|
388
|
+
raise ValueError(
|
|
389
|
+
f"residual row {row!r} holds fewer than three columns (iteration, "
|
|
390
|
+
"velocity residual, pressure residual); the log table layout changed"
|
|
391
|
+
)
|
|
392
|
+
history.append(
|
|
393
|
+
ResidualSample(
|
|
394
|
+
iteration=int(parse_number(row[0])),
|
|
395
|
+
velocity_residual=parse_number(row[1]),
|
|
396
|
+
pressure_residual=parse_number(row[2]),
|
|
397
|
+
)
|
|
398
|
+
)
|
|
399
|
+
if not history:
|
|
400
|
+
raise IncompleteOutputError("the log residual table is empty")
|
|
401
|
+
return history
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@dataclass(frozen=True)
|
|
405
|
+
class ProbePointsReport:
|
|
406
|
+
"""Parsed EXPORT_PROBE_POINTS output (SRC-003 pp.362-363, p.249).
|
|
407
|
+
|
|
408
|
+
Rows follow the probe creation order: the 26.120 round-trip
|
|
409
|
+
evidence (reports/RPT-004) shows the solver preserves the count
|
|
410
|
+
and row order of imported probes, which is what lets a
|
|
411
|
+
:class:`~pyflightstream.probes.planar.PlannedProbes` plan map rows
|
|
412
|
+
back to grid nodes.
|
|
413
|
+
|
|
414
|
+
Attributes
|
|
415
|
+
----------
|
|
416
|
+
columns : tuple of str
|
|
417
|
+
Column names as printed, starting with X, Y, Z (simulation
|
|
418
|
+
length units, reference frame).
|
|
419
|
+
values : numpy.ndarray
|
|
420
|
+
The full table, shape ``(count, len(columns))``, in printed
|
|
421
|
+
order.
|
|
422
|
+
angle_of_attack_deg : float
|
|
423
|
+
Angle of attack of the exported solution (deg).
|
|
424
|
+
freestream_velocity_m_s : float
|
|
425
|
+
Free-stream velocity (m/s).
|
|
426
|
+
current_iteration : int
|
|
427
|
+
Solver iteration the export reflects.
|
|
428
|
+
reported_version : str
|
|
429
|
+
Version string printed in the footer, verbatim.
|
|
430
|
+
reported_build : str
|
|
431
|
+
Build number printed in the footer, verbatim (the precise
|
|
432
|
+
discriminator, FR-18).
|
|
433
|
+
"""
|
|
434
|
+
|
|
435
|
+
columns: tuple[str, ...]
|
|
436
|
+
values: np.ndarray
|
|
437
|
+
angle_of_attack_deg: float
|
|
438
|
+
freestream_velocity_m_s: float
|
|
439
|
+
current_iteration: int
|
|
440
|
+
reported_version: str
|
|
441
|
+
reported_build: str
|
|
442
|
+
|
|
443
|
+
@property
|
|
444
|
+
def count(self) -> int:
|
|
445
|
+
"""Number of probe rows."""
|
|
446
|
+
return len(self.values)
|
|
447
|
+
|
|
448
|
+
@property
|
|
449
|
+
def positions(self) -> np.ndarray:
|
|
450
|
+
"""Probe positions, shape ``(count, 3)``: the X, Y, Z columns."""
|
|
451
|
+
return self.values[:, :3]
|
|
452
|
+
|
|
453
|
+
def field(self, name: str) -> np.ndarray:
|
|
454
|
+
"""Return one named column as an array.
|
|
455
|
+
|
|
456
|
+
Parameters
|
|
457
|
+
----------
|
|
458
|
+
name : str
|
|
459
|
+
Printed column name, for example ``"vtot"`` or ``"Cp"``.
|
|
460
|
+
"""
|
|
461
|
+
try:
|
|
462
|
+
index = self.columns.index(name)
|
|
463
|
+
except ValueError as error:
|
|
464
|
+
raise KeyError(
|
|
465
|
+
f"column {name!r} is not in this export; available: {', '.join(self.columns)}"
|
|
466
|
+
) from error
|
|
467
|
+
return self.values[:, index]
|
|
468
|
+
|
|
469
|
+
def fields(self) -> dict[str, np.ndarray]:
|
|
470
|
+
"""All non-coordinate columns, keyed by printed name.
|
|
471
|
+
|
|
472
|
+
Drops straight into the flow-visualization writers of
|
|
473
|
+
:mod:`pyflightstream.post`.
|
|
474
|
+
"""
|
|
475
|
+
return {name: self.field(name) for name in self.columns[3:]}
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def parse_probe_points(text: str, requested_version=None) -> ProbePointsReport:
|
|
479
|
+
"""Parse an EXPORT_PROBE_POINTS file into a typed report.
|
|
480
|
+
|
|
481
|
+
Anchor-based like every parser here: the point count is read from
|
|
482
|
+
its printed label, the table from its ``X, Y, Z,`` header to the
|
|
483
|
+
closing dashed line, and a declared-versus-parsed row mismatch
|
|
484
|
+
raises instead of returning less (FR-17). The boundary-layer
|
|
485
|
+
columns are part of the table; with the viscous coupling off they
|
|
486
|
+
are inert zeros, and asserting that is the caller's business
|
|
487
|
+
(DLV-006 Sec. 2.3).
|
|
488
|
+
|
|
489
|
+
Parameters
|
|
490
|
+
----------
|
|
491
|
+
text : str
|
|
492
|
+
Complete export file text.
|
|
493
|
+
requested_version : str or FsVersion, optional
|
|
494
|
+
Version the run requested; when given, the printed version is
|
|
495
|
+
cross-checked and a mismatch warns (FR-18).
|
|
496
|
+
|
|
497
|
+
Returns
|
|
498
|
+
-------
|
|
499
|
+
ProbePointsReport
|
|
500
|
+
Typed table plus the solution metadata.
|
|
501
|
+
"""
|
|
502
|
+
text = text.replace("\x00", "")
|
|
503
|
+
software = _SOFTWARE_LINE.search(text)
|
|
504
|
+
if software is None:
|
|
505
|
+
raise IncompleteOutputError(
|
|
506
|
+
"the probe export has no software footer; the file ends before the "
|
|
507
|
+
"closing block, so the solver stopped before finishing this export"
|
|
508
|
+
)
|
|
509
|
+
declared = int(parse_number(labeled_value(text, "Number of Probe Points:")))
|
|
510
|
+
header_line = next(
|
|
511
|
+
(line.strip() for line in text.splitlines() if line.strip().startswith("X, Y, Z,")),
|
|
512
|
+
None,
|
|
513
|
+
)
|
|
514
|
+
if header_line is None:
|
|
515
|
+
raise AnchorNotFoundError(
|
|
516
|
+
"the probe table header 'X, Y, Z,' was not found; the file is not an "
|
|
517
|
+
"EXPORT_PROBE_POINTS output or its format changed"
|
|
518
|
+
)
|
|
519
|
+
columns = tuple(cell.strip() for cell in header_line.split(",") if cell.strip())
|
|
520
|
+
rows = delimited_table(text, "X, Y, Z,")
|
|
521
|
+
parsed_rows = []
|
|
522
|
+
for row in rows:
|
|
523
|
+
cells = [cell for cell in row if cell]
|
|
524
|
+
if len(cells) != len(columns):
|
|
525
|
+
raise ValueError(
|
|
526
|
+
f"a probe row holds {len(cells)} values but the header names "
|
|
527
|
+
f"{len(columns)} columns; the table layout changed"
|
|
528
|
+
)
|
|
529
|
+
parsed_rows.append([parse_number(cell) for cell in cells])
|
|
530
|
+
if len(parsed_rows) != declared:
|
|
531
|
+
raise IncompleteOutputError(
|
|
532
|
+
f"the export declares {declared} probe points but the table holds "
|
|
533
|
+
f"{len(parsed_rows)} rows; the solver stopped mid-write"
|
|
534
|
+
)
|
|
535
|
+
if requested_version is not None:
|
|
536
|
+
_cross_check_version(software.group("version"), requested_version)
|
|
537
|
+
return ProbePointsReport(
|
|
538
|
+
columns=columns,
|
|
539
|
+
values=np.asarray(parsed_rows, dtype=float),
|
|
540
|
+
angle_of_attack_deg=parse_number(labeled_value(text, "Angle of attack (Deg)")),
|
|
541
|
+
freestream_velocity_m_s=parse_number(labeled_value(text, "Freestream velocity (m/s)")),
|
|
542
|
+
current_iteration=int(
|
|
543
|
+
parse_number(labeled_value(text, "Current solver iteration number:"))
|
|
544
|
+
),
|
|
545
|
+
reported_version=software.group("version"),
|
|
546
|
+
reported_build=software.group("build"),
|
|
547
|
+
)
|