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,677 @@
|
|
|
1
|
+
"""Execution of FlightStream and the campaign loop.
|
|
2
|
+
|
|
3
|
+
Pipeline role: runs the solver headless on rendered scripts and lands
|
|
4
|
+
every campaign point in the manifest with exactly one terminal status.
|
|
5
|
+
:func:`run_campaign` composes an :class:`Executor` with the managed
|
|
6
|
+
workspace of :mod:`pyflightstream.files`; there is no code path from
|
|
7
|
+
"point started" to "loop continued" that does not write a status, so
|
|
8
|
+
silent skips are structurally impossible (PP-5, FR-14). Failures
|
|
9
|
+
accumulate into :class:`CampaignErrors`, raised after the loop.
|
|
10
|
+
|
|
11
|
+
The local mechanism is the documented command-line script execution:
|
|
12
|
+
``FlightStream.exe --script <file>`` (SRC-003 p.279), with the
|
|
13
|
+
``-hidden`` flag for windowless batch runs; in hidden mode an
|
|
14
|
+
abnormal termination writes ``FlightStreamLog.txt`` into the command
|
|
15
|
+
execution directory, which is why the executor runs the solver inside
|
|
16
|
+
the simulation folder and captures that file (SRC-003 p.280). An HPC
|
|
17
|
+
executor with the same interface is deferred (FR-15).
|
|
18
|
+
|
|
19
|
+
Judging solver quality (converged, iteration limited, diverged) needs
|
|
20
|
+
the solver outputs, so :func:`run_campaign` takes an
|
|
21
|
+
:class:`OutcomeAssessor`; the standard implementation is
|
|
22
|
+
:class:`LoadsAssessor`, built on the anchor-based parsers of
|
|
23
|
+
:mod:`pyflightstream.results`.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import subprocess
|
|
29
|
+
import time
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Protocol
|
|
33
|
+
|
|
34
|
+
import pyflightstream
|
|
35
|
+
from pyflightstream.cases import Campaign, ScriptRecipe, SimCase, point_tag, resolve_recipe
|
|
36
|
+
from pyflightstream.files import CampaignWorkspace, RunRecord, RunStatus, WorkspaceError
|
|
37
|
+
from pyflightstream.results import (
|
|
38
|
+
IncompleteOutputError,
|
|
39
|
+
parse_loads,
|
|
40
|
+
parse_residual_history,
|
|
41
|
+
)
|
|
42
|
+
from pyflightstream.script import Script
|
|
43
|
+
from pyflightstream.versions import FsVersion, resolve
|
|
44
|
+
|
|
45
|
+
_LOG_NAME = "FlightStreamLog.txt"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ExecutorConfigurationError(ValueError):
|
|
49
|
+
"""The executor cannot run as configured.
|
|
50
|
+
|
|
51
|
+
Raised at construction time, because a missing solver executable
|
|
52
|
+
must surface before a campaign starts, not at its first point.
|
|
53
|
+
The FlightStream path is always explicit input (SAD Section 5):
|
|
54
|
+
nothing is read from environment variables or guessed.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class ExecutionResult:
|
|
60
|
+
"""Typed outcome of one solver process.
|
|
61
|
+
|
|
62
|
+
Attributes
|
|
63
|
+
----------
|
|
64
|
+
return_code : int or None
|
|
65
|
+
Process return code; None when the run timed out and the
|
|
66
|
+
process was killed.
|
|
67
|
+
wall_time_s : float
|
|
68
|
+
Wall-clock duration of the process in seconds.
|
|
69
|
+
timed_out : bool
|
|
70
|
+
Whether the timeout expired before the process finished.
|
|
71
|
+
log_text : str or None
|
|
72
|
+
Content of ``FlightStreamLog.txt`` from the execution
|
|
73
|
+
directory when the solver wrote one (hidden-mode abnormal
|
|
74
|
+
termination, SRC-003 p.280); None otherwise.
|
|
75
|
+
stdout : str
|
|
76
|
+
Captured standard output of the process.
|
|
77
|
+
stderr : str
|
|
78
|
+
Captured standard error of the process.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
return_code: int | None
|
|
82
|
+
wall_time_s: float
|
|
83
|
+
timed_out: bool
|
|
84
|
+
log_text: str | None
|
|
85
|
+
stdout: str
|
|
86
|
+
stderr: str
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def failed(self) -> bool:
|
|
90
|
+
"""Whether the process timed out or returned a nonzero code."""
|
|
91
|
+
return self.timed_out or self.return_code != 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Executor(Protocol):
|
|
95
|
+
"""Anything that can run one rendered script to completion.
|
|
96
|
+
|
|
97
|
+
Implementations must be interchangeable without touching the
|
|
98
|
+
campaign model (FR-15): :class:`LocalExecutor` today, an HPC
|
|
99
|
+
submission executor later.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def run_script(
|
|
103
|
+
self, script_path: Path, working_dir: Path, timeout_s: float | None = None
|
|
104
|
+
) -> ExecutionResult:
|
|
105
|
+
"""Run one script and return the typed outcome."""
|
|
106
|
+
...
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class LocalExecutor:
|
|
110
|
+
"""Runs FlightStream as a local subprocess (SRC-003 pp.279-280).
|
|
111
|
+
|
|
112
|
+
Parameters
|
|
113
|
+
----------
|
|
114
|
+
fs_exe : str or Path
|
|
115
|
+
Explicit path of the FlightStream executable; it must exist.
|
|
116
|
+
Never read from environment variables or guessed.
|
|
117
|
+
hidden : bool
|
|
118
|
+
Pass the ``-hidden`` flag for a windowless run; this is the
|
|
119
|
+
batch mode that writes ``FlightStreamLog.txt`` on abnormal
|
|
120
|
+
termination (SRC-003 p.280). Disable only for local debugging
|
|
121
|
+
with the interface visible.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(self, fs_exe: str | Path, hidden: bool = True):
|
|
125
|
+
self.fs_exe = Path(fs_exe)
|
|
126
|
+
self.hidden = hidden
|
|
127
|
+
if not self.fs_exe.is_file():
|
|
128
|
+
raise ExecutorConfigurationError(
|
|
129
|
+
f"FlightStream executable not found at {self.fs_exe}. The path is "
|
|
130
|
+
"explicit campaign input (fs_exe); check the installation folder of "
|
|
131
|
+
"the version the campaign requests."
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def _argv(self, script_path: Path) -> list[str]:
|
|
135
|
+
argv = [str(self.fs_exe)]
|
|
136
|
+
if self.hidden:
|
|
137
|
+
argv.append("-hidden")
|
|
138
|
+
argv.extend(["--script", str(script_path)])
|
|
139
|
+
return argv
|
|
140
|
+
|
|
141
|
+
def run_script(
|
|
142
|
+
self, script_path: Path, working_dir: Path, timeout_s: float | None = None
|
|
143
|
+
) -> ExecutionResult:
|
|
144
|
+
"""Run one rendered script to completion.
|
|
145
|
+
|
|
146
|
+
The process runs inside ``working_dir`` so that the hidden-mode
|
|
147
|
+
error log lands next to the run's files and can be captured.
|
|
148
|
+
|
|
149
|
+
Parameters
|
|
150
|
+
----------
|
|
151
|
+
script_path : Path
|
|
152
|
+
Rendered ASCII script to execute.
|
|
153
|
+
working_dir : Path
|
|
154
|
+
Execution directory of the process; also where
|
|
155
|
+
``FlightStreamLog.txt`` appears on abnormal termination.
|
|
156
|
+
timeout_s : float, optional
|
|
157
|
+
Wall-clock limit; on expiry the process is killed and the
|
|
158
|
+
result reports ``timed_out``.
|
|
159
|
+
|
|
160
|
+
Returns
|
|
161
|
+
-------
|
|
162
|
+
ExecutionResult
|
|
163
|
+
Typed outcome; no exception is raised for solver failure,
|
|
164
|
+
the campaign loop decides the manifest status.
|
|
165
|
+
"""
|
|
166
|
+
argv = self._argv(script_path)
|
|
167
|
+
start = time.perf_counter()
|
|
168
|
+
timed_out = False
|
|
169
|
+
return_code: int | None = None
|
|
170
|
+
stdout = ""
|
|
171
|
+
stderr = ""
|
|
172
|
+
try:
|
|
173
|
+
completed = subprocess.run(
|
|
174
|
+
argv,
|
|
175
|
+
cwd=working_dir,
|
|
176
|
+
capture_output=True,
|
|
177
|
+
text=True,
|
|
178
|
+
timeout=timeout_s,
|
|
179
|
+
check=False,
|
|
180
|
+
)
|
|
181
|
+
return_code = completed.returncode
|
|
182
|
+
stdout = completed.stdout or ""
|
|
183
|
+
stderr = completed.stderr or ""
|
|
184
|
+
except subprocess.TimeoutExpired as expired:
|
|
185
|
+
timed_out = True
|
|
186
|
+
stdout = _decode(expired.stdout)
|
|
187
|
+
stderr = _decode(expired.stderr)
|
|
188
|
+
wall_time_s = time.perf_counter() - start
|
|
189
|
+
log_path = Path(working_dir) / _LOG_NAME
|
|
190
|
+
log_text = None
|
|
191
|
+
if log_path.is_file():
|
|
192
|
+
log_text = log_path.read_text(encoding="utf-8", errors="replace")
|
|
193
|
+
return ExecutionResult(
|
|
194
|
+
return_code=return_code,
|
|
195
|
+
wall_time_s=wall_time_s,
|
|
196
|
+
timed_out=timed_out,
|
|
197
|
+
log_text=log_text,
|
|
198
|
+
stdout=stdout,
|
|
199
|
+
stderr=stderr,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _decode(stream: str | bytes | None) -> str:
|
|
204
|
+
if stream is None:
|
|
205
|
+
return ""
|
|
206
|
+
if isinstance(stream, bytes):
|
|
207
|
+
return stream.decode(errors="replace")
|
|
208
|
+
return stream
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass(frozen=True)
|
|
212
|
+
class Assessment:
|
|
213
|
+
"""Judgment of one successfully executed point.
|
|
214
|
+
|
|
215
|
+
Attributes
|
|
216
|
+
----------
|
|
217
|
+
status : RunStatus
|
|
218
|
+
``CONVERGED``, ``COMPLETED_MAX_ITER``, or ``FAILED_DIVERGED``;
|
|
219
|
+
execution and completeness failures are decided by the loop
|
|
220
|
+
before the assessor runs.
|
|
221
|
+
iterations : int, optional
|
|
222
|
+
Solver iterations reached, when the assessor parsed them.
|
|
223
|
+
residual : float, optional
|
|
224
|
+
Final residual, when parsed.
|
|
225
|
+
error : str, optional
|
|
226
|
+
Explanation for a diverged judgment.
|
|
227
|
+
fs_version_reported : str, optional
|
|
228
|
+
Version string printed in the assessed output, verbatim
|
|
229
|
+
(FR-18).
|
|
230
|
+
fs_build : str, optional
|
|
231
|
+
Build number printed in the assessed output.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
status: RunStatus
|
|
235
|
+
iterations: int | None = None
|
|
236
|
+
residual: float | None = None
|
|
237
|
+
error: str | None = None
|
|
238
|
+
fs_version_reported: str | None = None
|
|
239
|
+
fs_build: str | None = None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class OutcomeAssessor(Protocol):
|
|
243
|
+
"""Judges solver quality from the outputs of one executed point.
|
|
244
|
+
|
|
245
|
+
The campaign loop already handled execution failure and missing
|
|
246
|
+
declared outputs; the assessor inspects the collected outputs (in
|
|
247
|
+
``sim_dir / "raw"``) and decides between converged, iteration
|
|
248
|
+
limited, and diverged. The standard implementation lands with the
|
|
249
|
+
results parsers.
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
def __call__(self, case: SimCase, execution: ExecutionResult, sim_dir: Path) -> Assessment:
|
|
253
|
+
"""Return the judgment of one executed point."""
|
|
254
|
+
...
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class LoadsAssessor:
|
|
258
|
+
"""The standard solver-quality judgment, built on the run outputs.
|
|
259
|
+
|
|
260
|
+
Reads the collected loads spreadsheet and, when available, the
|
|
261
|
+
exported solver log, and decides between CONVERGED,
|
|
262
|
+
COMPLETED_MAX_ITER, and FAILED_DIVERGED:
|
|
263
|
+
|
|
264
|
+
- NaN or infinite Total coefficients: FAILED_DIVERGED.
|
|
265
|
+
- With a log: the final velocity and pressure residuals against
|
|
266
|
+
the run's convergence limit (SRC-003 p.200); NaN residuals are
|
|
267
|
+
a divergence.
|
|
268
|
+
- Without a log, steady mode: an iteration counter below the
|
|
269
|
+
requested limit means the threshold stopped the solver
|
|
270
|
+
(CONVERGED); reaching the limit means COMPLETED_MAX_ITER.
|
|
271
|
+
- Without a log, unsteady mode: the time loop always runs to its
|
|
272
|
+
prescribed end, so completion is recorded as
|
|
273
|
+
COMPLETED_MAX_ITER; declare the log export to get a residual
|
|
274
|
+
judgment.
|
|
275
|
+
|
|
276
|
+
An unparseable or truncated loads file is FAILED_INCOMPLETE_OUTPUT.
|
|
277
|
+
|
|
278
|
+
Parameters
|
|
279
|
+
----------
|
|
280
|
+
loads_file : str
|
|
281
|
+
Name of the loads spreadsheet among the case's declared
|
|
282
|
+
outputs (collected into ``raw/``).
|
|
283
|
+
log_file : str, optional
|
|
284
|
+
Name of the exported solver log (EXPORT_LOG), when the recipe
|
|
285
|
+
declares one; enables the residual-based judgment.
|
|
286
|
+
requested_version : str or FsVersion, optional
|
|
287
|
+
Version the campaign requested; enables the FR-18 cross-check
|
|
288
|
+
against the version printed in the loads footer.
|
|
289
|
+
"""
|
|
290
|
+
|
|
291
|
+
def __init__(
|
|
292
|
+
self,
|
|
293
|
+
loads_file: str,
|
|
294
|
+
log_file: str | None = None,
|
|
295
|
+
requested_version: str | FsVersion | None = None,
|
|
296
|
+
):
|
|
297
|
+
self.loads_file = loads_file
|
|
298
|
+
self.log_file = log_file
|
|
299
|
+
self.requested_version = requested_version
|
|
300
|
+
|
|
301
|
+
def __call__(self, case: SimCase, execution: ExecutionResult, sim_dir: Path) -> Assessment:
|
|
302
|
+
"""Judge one executed point from its collected outputs."""
|
|
303
|
+
raw = Path(sim_dir) / "raw"
|
|
304
|
+
try:
|
|
305
|
+
text = (raw / self.loads_file).read_text(encoding="utf-8", errors="replace")
|
|
306
|
+
report = parse_loads(text, requested_version=self.requested_version)
|
|
307
|
+
except (OSError, IncompleteOutputError, ValueError) as error:
|
|
308
|
+
return Assessment(
|
|
309
|
+
status=RunStatus.FAILED_INCOMPLETE_OUTPUT,
|
|
310
|
+
error=f"loads spreadsheet unusable: {error}",
|
|
311
|
+
)
|
|
312
|
+
stamp = {
|
|
313
|
+
"fs_version_reported": report.fs_version_reported,
|
|
314
|
+
"fs_build": report.fs_build,
|
|
315
|
+
}
|
|
316
|
+
diverged = report.diverged_columns()
|
|
317
|
+
if diverged:
|
|
318
|
+
return Assessment(
|
|
319
|
+
status=RunStatus.FAILED_DIVERGED,
|
|
320
|
+
iterations=report.current_iteration,
|
|
321
|
+
error=f"non-finite Total coefficients: {', '.join(diverged)}",
|
|
322
|
+
**stamp,
|
|
323
|
+
)
|
|
324
|
+
if self.log_file is not None and (raw / self.log_file).is_file():
|
|
325
|
+
log_text = (raw / self.log_file).read_text(encoding="utf-8", errors="replace")
|
|
326
|
+
try:
|
|
327
|
+
final = parse_residual_history(log_text)[-1]
|
|
328
|
+
except (IncompleteOutputError, ValueError) as error:
|
|
329
|
+
return Assessment(
|
|
330
|
+
status=RunStatus.FAILED_INCOMPLETE_OUTPUT,
|
|
331
|
+
error=f"solver log unusable: {error}",
|
|
332
|
+
**stamp,
|
|
333
|
+
)
|
|
334
|
+
residual = max(final.velocity_residual, final.pressure_residual)
|
|
335
|
+
if residual != residual: # NaN
|
|
336
|
+
return Assessment(
|
|
337
|
+
status=RunStatus.FAILED_DIVERGED,
|
|
338
|
+
iterations=final.iteration,
|
|
339
|
+
error="final residuals are NaN",
|
|
340
|
+
**stamp,
|
|
341
|
+
)
|
|
342
|
+
converged = residual <= report.convergence_limit
|
|
343
|
+
return Assessment(
|
|
344
|
+
status=RunStatus.CONVERGED if converged else RunStatus.COMPLETED_MAX_ITER,
|
|
345
|
+
iterations=final.iteration,
|
|
346
|
+
residual=residual,
|
|
347
|
+
**stamp,
|
|
348
|
+
)
|
|
349
|
+
if report.solver_mode.strip().lower() == "steady":
|
|
350
|
+
stopped_early = report.current_iteration < report.requested_iterations
|
|
351
|
+
return Assessment(
|
|
352
|
+
status=RunStatus.CONVERGED if stopped_early else RunStatus.COMPLETED_MAX_ITER,
|
|
353
|
+
iterations=report.current_iteration,
|
|
354
|
+
**stamp,
|
|
355
|
+
)
|
|
356
|
+
return Assessment(
|
|
357
|
+
status=RunStatus.COMPLETED_MAX_ITER,
|
|
358
|
+
iterations=report.current_iteration,
|
|
359
|
+
error=None,
|
|
360
|
+
**stamp,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class CampaignErrors(RuntimeError): # noqa: N818 (the SAD Section 7 name)
|
|
365
|
+
"""One or more campaign points failed; raised after the loop.
|
|
366
|
+
|
|
367
|
+
Every failed point is listed with its status and error text, and
|
|
368
|
+
all points, failed or not, are already in the manifest: the
|
|
369
|
+
exception reports, it never hides.
|
|
370
|
+
|
|
371
|
+
Attributes
|
|
372
|
+
----------
|
|
373
|
+
failures : list of RunRecord
|
|
374
|
+
The manifest records of the failed points.
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
def __init__(self, failures: list[RunRecord]):
|
|
378
|
+
self.failures = failures
|
|
379
|
+
lines = "\n".join(
|
|
380
|
+
f" {record.run_id}: {record.status} ({record.error or 'no error text'})"
|
|
381
|
+
for record in failures
|
|
382
|
+
)
|
|
383
|
+
super().__init__(
|
|
384
|
+
f"{len(failures)} campaign point(s) failed; every point is recorded in "
|
|
385
|
+
f"the manifest:\n{lines}"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def run_campaign(
|
|
390
|
+
campaign: Campaign,
|
|
391
|
+
executor: Executor,
|
|
392
|
+
workspace: CampaignWorkspace,
|
|
393
|
+
assess: OutcomeAssessor,
|
|
394
|
+
recipes: dict[str, ScriptRecipe] | None = None,
|
|
395
|
+
) -> list[RunRecord]:
|
|
396
|
+
"""Run every point of a campaign, recording each in the manifest.
|
|
397
|
+
|
|
398
|
+
Per point, in order: specialize the case (sweep point and staged
|
|
399
|
+
geometry), build the script through the recipe (failure:
|
|
400
|
+
FAILED_SCRIPT), execute it (failure or timeout:
|
|
401
|
+
FAILED_EXECUTION), collect the declared outputs into ``raw/``
|
|
402
|
+
(missing output: FAILED_INCOMPLETE_OUTPUT), and judge the solver
|
|
403
|
+
quality through ``assess`` (CONVERGED, COMPLETED_MAX_ITER, or
|
|
404
|
+
FAILED_DIVERGED). Exactly one record per point is appended to the
|
|
405
|
+
manifest; an unexpected internal error crashes the loop loudly
|
|
406
|
+
instead of masquerading as a solver status.
|
|
407
|
+
|
|
408
|
+
Parameters
|
|
409
|
+
----------
|
|
410
|
+
campaign : Campaign
|
|
411
|
+
What to run; its ``fs_version`` is resolved to canonical for
|
|
412
|
+
the manifest.
|
|
413
|
+
executor : Executor
|
|
414
|
+
How to run it, for example :class:`LocalExecutor` built from
|
|
415
|
+
``campaign.fs_exe``.
|
|
416
|
+
workspace : CampaignWorkspace
|
|
417
|
+
The managed campaign root receiving folders, scripts, outputs,
|
|
418
|
+
and the manifest. Re-running into the same root fails on the
|
|
419
|
+
duplicate ``run_id``; archive the sims or choose a new root.
|
|
420
|
+
assess : OutcomeAssessor
|
|
421
|
+
Solver-quality judgment; required because the loop refuses to
|
|
422
|
+
invent convergence evidence it cannot see.
|
|
423
|
+
recipes : dict of str to ScriptRecipe, optional
|
|
424
|
+
Named recipe registry consulted before treating
|
|
425
|
+
:attr:`SimCase.recipe` as a ``module:function`` reference;
|
|
426
|
+
the legacy matrix reader will register its recipe names here.
|
|
427
|
+
|
|
428
|
+
Returns
|
|
429
|
+
-------
|
|
430
|
+
list of RunRecord
|
|
431
|
+
All records of this run, in execution order.
|
|
432
|
+
|
|
433
|
+
Raises
|
|
434
|
+
------
|
|
435
|
+
CampaignErrors
|
|
436
|
+
After the loop, when at least one point failed.
|
|
437
|
+
"""
|
|
438
|
+
canonical = resolve(campaign.fs_version).canonical
|
|
439
|
+
records: list[RunRecord] = []
|
|
440
|
+
failures: list[RunRecord] = []
|
|
441
|
+
for case in campaign.sims:
|
|
442
|
+
sim_dir = workspace.create_sim(case.sim_id)
|
|
443
|
+
recipe, preparation_error, inputs_sha256, staged_geometry = _prepare_case(
|
|
444
|
+
case, workspace, recipes
|
|
445
|
+
)
|
|
446
|
+
for point in case.sweep.points():
|
|
447
|
+
record = _execute_point(
|
|
448
|
+
campaign=campaign,
|
|
449
|
+
canonical=canonical,
|
|
450
|
+
case=case,
|
|
451
|
+
point=point,
|
|
452
|
+
recipe=recipe,
|
|
453
|
+
preparation_error=preparation_error,
|
|
454
|
+
inputs_sha256=inputs_sha256,
|
|
455
|
+
staged_geometry=staged_geometry,
|
|
456
|
+
executor=executor,
|
|
457
|
+
workspace=workspace,
|
|
458
|
+
sim_dir=sim_dir,
|
|
459
|
+
assess=assess,
|
|
460
|
+
)
|
|
461
|
+
workspace.append_record(record)
|
|
462
|
+
records.append(record)
|
|
463
|
+
if record.status.startswith("FAILED"):
|
|
464
|
+
failures.append(record)
|
|
465
|
+
if failures:
|
|
466
|
+
raise CampaignErrors(failures)
|
|
467
|
+
return records
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _prepare_case(
|
|
471
|
+
case: SimCase,
|
|
472
|
+
workspace: CampaignWorkspace,
|
|
473
|
+
recipes: dict[str, ScriptRecipe] | None,
|
|
474
|
+
) -> tuple[ScriptRecipe | None, str | None, dict[str, str], str | None]:
|
|
475
|
+
"""Resolve the recipe and stage the geometry of one case.
|
|
476
|
+
|
|
477
|
+
Returns the recipe, a preparation error (which sends every point
|
|
478
|
+
of the case to FAILED_SCRIPT instead of skipping it silently),
|
|
479
|
+
the staged input hashes, and the staged geometry path.
|
|
480
|
+
"""
|
|
481
|
+
try:
|
|
482
|
+
if recipes and case.recipe in recipes:
|
|
483
|
+
recipe = recipes[case.recipe]
|
|
484
|
+
else:
|
|
485
|
+
recipe = resolve_recipe(case.recipe)
|
|
486
|
+
except ValueError as error:
|
|
487
|
+
return None, str(error), {}, None
|
|
488
|
+
inputs_sha256: dict[str, str] = {}
|
|
489
|
+
staged_geometry: str | None = None
|
|
490
|
+
if case.geometry is not None:
|
|
491
|
+
try:
|
|
492
|
+
inputs_sha256 = workspace.stage_inputs(case.sim_id, [case.geometry])
|
|
493
|
+
except WorkspaceError as error:
|
|
494
|
+
return recipe, str(error), {}, None
|
|
495
|
+
staged = workspace.sim_dir(case.sim_id) / "inputs" / Path(case.geometry).name
|
|
496
|
+
staged_geometry = str(staged)
|
|
497
|
+
return recipe, None, inputs_sha256, staged_geometry
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _execute_point(
|
|
501
|
+
*,
|
|
502
|
+
campaign: Campaign,
|
|
503
|
+
canonical: str,
|
|
504
|
+
case: SimCase,
|
|
505
|
+
point: dict[str, float],
|
|
506
|
+
recipe: ScriptRecipe | None,
|
|
507
|
+
preparation_error: str | None,
|
|
508
|
+
inputs_sha256: dict[str, str],
|
|
509
|
+
staged_geometry: str | None,
|
|
510
|
+
executor: Executor,
|
|
511
|
+
workspace: CampaignWorkspace,
|
|
512
|
+
sim_dir: Path,
|
|
513
|
+
assess: OutcomeAssessor,
|
|
514
|
+
) -> RunRecord:
|
|
515
|
+
"""Take one point from sweep coordinates to its manifest record."""
|
|
516
|
+
tag = point_tag(point)
|
|
517
|
+
base = {
|
|
518
|
+
"run_id": f"{campaign.name}/sim_{case.sim_id}/{tag}",
|
|
519
|
+
"sim_id": case.sim_id,
|
|
520
|
+
"point": dict(point),
|
|
521
|
+
"fs_version_requested": canonical,
|
|
522
|
+
"package_version": pyflightstream.__version__,
|
|
523
|
+
"inputs_sha256": inputs_sha256,
|
|
524
|
+
"script_sha256": "",
|
|
525
|
+
"raw_flag": False,
|
|
526
|
+
}
|
|
527
|
+
if preparation_error is not None or recipe is None:
|
|
528
|
+
error = preparation_error or "recipe resolution failed"
|
|
529
|
+
return RunRecord(**base, status=RunStatus.FAILED_SCRIPT, error=error)
|
|
530
|
+
|
|
531
|
+
update: dict[str, object] = {"point": dict(point)}
|
|
532
|
+
if staged_geometry is not None:
|
|
533
|
+
update["geometry"] = staged_geometry
|
|
534
|
+
point_case = case.model_copy(update=update)
|
|
535
|
+
script = Script(version=campaign.fs_version)
|
|
536
|
+
try:
|
|
537
|
+
recipe(point_case, script)
|
|
538
|
+
except Exception as error: # recipes are user code; any failure is a build failure
|
|
539
|
+
return RunRecord(
|
|
540
|
+
**base,
|
|
541
|
+
status=RunStatus.FAILED_SCRIPT,
|
|
542
|
+
error=f"{type(error).__name__}: {error}",
|
|
543
|
+
)
|
|
544
|
+
script_path, script_sha = workspace.write_script(case.sim_id, f"{tag}.txt", script.render())
|
|
545
|
+
base["script_sha256"] = script_sha
|
|
546
|
+
base["raw_flag"] = script.raw_flag
|
|
547
|
+
|
|
548
|
+
result = executor.run_script(script_path, working_dir=sim_dir, timeout_s=case.solver.timeout_s)
|
|
549
|
+
if result.failed:
|
|
550
|
+
if result.timed_out:
|
|
551
|
+
error = f"timed out after {result.wall_time_s:.1f} s and was killed"
|
|
552
|
+
else:
|
|
553
|
+
error = result.log_text or result.stderr or f"return code {result.return_code}"
|
|
554
|
+
return RunRecord(
|
|
555
|
+
**base,
|
|
556
|
+
status=RunStatus.FAILED_EXECUTION,
|
|
557
|
+
wall_time_s=result.wall_time_s,
|
|
558
|
+
error=error,
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
try:
|
|
562
|
+
collected = workspace.collect_outputs(
|
|
563
|
+
case.sim_id, [sim_dir / name for name in case.outputs]
|
|
564
|
+
)
|
|
565
|
+
except WorkspaceError as error:
|
|
566
|
+
return RunRecord(
|
|
567
|
+
**base,
|
|
568
|
+
status=RunStatus.FAILED_INCOMPLETE_OUTPUT,
|
|
569
|
+
wall_time_s=result.wall_time_s,
|
|
570
|
+
error=str(error),
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
assessment = assess(point_case, result, sim_dir)
|
|
574
|
+
return RunRecord(
|
|
575
|
+
**base,
|
|
576
|
+
status=assessment.status,
|
|
577
|
+
iterations=assessment.iterations,
|
|
578
|
+
residual=assessment.residual,
|
|
579
|
+
fs_version_reported=assessment.fs_version_reported,
|
|
580
|
+
fs_build=assessment.fs_build,
|
|
581
|
+
wall_time_s=result.wall_time_s,
|
|
582
|
+
outputs=collected,
|
|
583
|
+
error=assessment.error,
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
class SurfaceMeshExportError(RuntimeError):
|
|
588
|
+
"""The pre-processing surface-mesh export did not produce its file.
|
|
589
|
+
|
|
590
|
+
Raised by :func:`export_surface_mesh` when the solver run failed
|
|
591
|
+
or finished without writing the requested mesh file; the message
|
|
592
|
+
carries the process outcome and the captured log excerpt, because
|
|
593
|
+
hidden-mode failures are otherwise silent (SRC-003 p.280).
|
|
594
|
+
"""
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def export_surface_mesh(
|
|
598
|
+
fsm_path: str | Path,
|
|
599
|
+
workdir: str | Path,
|
|
600
|
+
*,
|
|
601
|
+
version: str | FsVersion,
|
|
602
|
+
executor: Executor | None = None,
|
|
603
|
+
fs_exe: str | Path | None = None,
|
|
604
|
+
file_type: str = "OBJ",
|
|
605
|
+
surface: int = -1,
|
|
606
|
+
timeout_s: float | None = 600.0,
|
|
607
|
+
) -> Path:
|
|
608
|
+
"""Export the simulation surface mesh in a pre-processing solver run.
|
|
609
|
+
|
|
610
|
+
Builds and runs the minimal version-validated script (OPEN the
|
|
611
|
+
simulation, EXPORT_SURFACE_MESH, close), so the probe planner's
|
|
612
|
+
geometry gate can test candidate probes against the real body when
|
|
613
|
+
no mesh file exists yet (SRC-003 pp.282, 307-308). When a mesh
|
|
614
|
+
file already exists, skip this and hand it to the gate directly.
|
|
615
|
+
|
|
616
|
+
Parameters
|
|
617
|
+
----------
|
|
618
|
+
fsm_path : str or pathlib.Path
|
|
619
|
+
Input simulation file to open.
|
|
620
|
+
workdir : str or pathlib.Path
|
|
621
|
+
Execution directory; the script, the exported mesh, and any
|
|
622
|
+
hidden-mode log land here.
|
|
623
|
+
version : str or FsVersion
|
|
624
|
+
Target FlightStream version; emission is validated against it.
|
|
625
|
+
executor : Executor, optional
|
|
626
|
+
Executor to run the script with; alternatively give
|
|
627
|
+
``fs_exe`` to build a :class:`LocalExecutor`.
|
|
628
|
+
fs_exe : str or pathlib.Path, optional
|
|
629
|
+
FlightStream executable path (explicit input, never guessed).
|
|
630
|
+
file_type : str
|
|
631
|
+
Export format token, one of STL, TRI, OBJ (SRC-003 p.307);
|
|
632
|
+
OBJ is the geometry gate default.
|
|
633
|
+
surface : int
|
|
634
|
+
Surface index to export; -1 exports all geometry surfaces.
|
|
635
|
+
timeout_s : float, optional
|
|
636
|
+
Wall-clock limit of the pre-processing run.
|
|
637
|
+
|
|
638
|
+
Returns
|
|
639
|
+
-------
|
|
640
|
+
pathlib.Path
|
|
641
|
+
The exported mesh file.
|
|
642
|
+
|
|
643
|
+
Raises
|
|
644
|
+
------
|
|
645
|
+
ExecutorConfigurationError
|
|
646
|
+
If neither executor nor a valid ``fs_exe`` is given.
|
|
647
|
+
SurfaceMeshExportError
|
|
648
|
+
If the run fails or leaves no mesh file behind.
|
|
649
|
+
"""
|
|
650
|
+
if executor is None:
|
|
651
|
+
if fs_exe is None:
|
|
652
|
+
raise ExecutorConfigurationError(
|
|
653
|
+
"export_surface_mesh needs a way to run FlightStream: pass an "
|
|
654
|
+
"executor or the explicit fs_exe path"
|
|
655
|
+
)
|
|
656
|
+
executor = LocalExecutor(fs_exe)
|
|
657
|
+
workdir = Path(workdir)
|
|
658
|
+
workdir.mkdir(parents=True, exist_ok=True)
|
|
659
|
+
mesh_path = workdir / f"surface_mesh.{file_type.lower()}"
|
|
660
|
+
|
|
661
|
+
script = Script(version)
|
|
662
|
+
script.emit("OPEN", str(Path(fsm_path)))
|
|
663
|
+
script.emit("EXPORT_SURFACE_MESH", file_type, surface, str(mesh_path))
|
|
664
|
+
script.emit("CLOSE_FLIGHTSTREAM")
|
|
665
|
+
script_path = workdir / "export_surface_mesh.txt"
|
|
666
|
+
script_path.write_text(script.render(), encoding="utf-8")
|
|
667
|
+
|
|
668
|
+
result = executor.run_script(script_path, working_dir=workdir, timeout_s=timeout_s)
|
|
669
|
+
if result.failed or not mesh_path.is_file():
|
|
670
|
+
outcome = "timed out" if result.timed_out else f"returned {result.return_code}"
|
|
671
|
+
log_excerpt = (result.log_text or "")[-2000:]
|
|
672
|
+
raise SurfaceMeshExportError(
|
|
673
|
+
f"the pre-processing run {outcome} and the mesh file "
|
|
674
|
+
f"{mesh_path.name} {'exists' if mesh_path.is_file() else 'was not written'}; "
|
|
675
|
+
f"check the simulation file and the log excerpt: {log_excerpt!r}"
|
|
676
|
+
)
|
|
677
|
+
return mesh_path
|