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