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,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
+ )