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,27 @@
|
|
|
1
|
+
"""pyflightstream: version-aware, didactic Python driver for FlightStream.
|
|
2
|
+
|
|
3
|
+
The package automates the FlightStream panel-method solver through its ASCII
|
|
4
|
+
scripting interface. The FlightStream version is an explicit input: every
|
|
5
|
+
command emitted is validated against the per-version command database in
|
|
6
|
+
``pyflightstream.commands``.
|
|
7
|
+
|
|
8
|
+
Pipeline layers, dependencies flowing strictly downward:
|
|
9
|
+
|
|
10
|
+
- ``versions``: canonical 26.XXX version identifiers and ordering.
|
|
11
|
+
- ``commands``: the command database and per-version registry.
|
|
12
|
+
- ``script``: the validating ASCII script builder.
|
|
13
|
+
- ``results``: anchor-based parsers for solver output files.
|
|
14
|
+
- ``cases``: simulation and campaign definitions.
|
|
15
|
+
- ``run`` and ``files``: execution, run manifest, managed file layout.
|
|
16
|
+
- ``post``: results into engineering data (sweep assembly, PLTET, exports).
|
|
17
|
+
- ``qa``: probe harness and physics regression tooling.
|
|
18
|
+
- ``fsi``: seam for the future fluid-structure interaction coupling.
|
|
19
|
+
|
|
20
|
+
Milestone M0: skeleton only; functionality arrives from milestone M1 on.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
__version__ = "0.0.1.dev0"
|
|
24
|
+
|
|
25
|
+
from pyflightstream.reference import help # noqa: E402
|
|
26
|
+
|
|
27
|
+
__all__ = ["__version__", "help"]
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Simulation and campaign definitions.
|
|
2
|
+
|
|
3
|
+
Pipeline role: describes what to run. A :class:`SimCase` (identified
|
|
4
|
+
by ``sim_id``) is one solver configuration with its sweep; a
|
|
5
|
+
:class:`Campaign` groups cases with the FlightStream version and the
|
|
6
|
+
executable path, both required and explicit: nothing is read from
|
|
7
|
+
environment variables or guessed (SAD Section 5). Native persistence
|
|
8
|
+
is ``campaign.toml``; the legacy pipe-delimited ``matriz.fs`` matrix
|
|
9
|
+
of the predecessor scripts will be read unchanged, forever, by the
|
|
10
|
+
legacy reader (FR-10, next step of milestone M2).
|
|
11
|
+
|
|
12
|
+
Script recipes are explicitly imported functions satisfying the
|
|
13
|
+
:class:`ScriptRecipe` protocol: ``build(case, script) -> None``. The
|
|
14
|
+
campaign loop specializes the case per sweep point (filling
|
|
15
|
+
:attr:`SimCase.point`) and the recipe translates it into script
|
|
16
|
+
emissions, usually through the curated helpers. Recipe references are
|
|
17
|
+
``"package.module:function"`` strings, replacing the historical
|
|
18
|
+
import-by-number system (PP-7, FR-12).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import tomllib
|
|
24
|
+
from collections.abc import Callable, Iterator
|
|
25
|
+
from importlib import import_module
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Literal, Protocol, runtime_checkable
|
|
28
|
+
|
|
29
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
30
|
+
|
|
31
|
+
from pyflightstream.script import Script
|
|
32
|
+
from pyflightstream.versions import resolve
|
|
33
|
+
|
|
34
|
+
_TAG_PREFIXES = (("alpha", "a"), ("beta", "b"), ("advance_ratio", "j"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@runtime_checkable
|
|
38
|
+
class ScriptRecipe(Protocol):
|
|
39
|
+
"""A function that turns one case point into script emissions.
|
|
40
|
+
|
|
41
|
+
Implementations receive the per-point specialized case (the
|
|
42
|
+
campaign loop fills :attr:`SimCase.point` and stages the geometry)
|
|
43
|
+
and an empty :class:`~pyflightstream.script.Script` bound to the
|
|
44
|
+
campaign's FlightStream version; they emit the whole script,
|
|
45
|
+
usually through the curated helpers. Output files must use paths
|
|
46
|
+
relative to the execution directory, so the collected evidence
|
|
47
|
+
stays inside the managed simulation folder.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __call__(self, case: SimCase, script: Script) -> None:
|
|
51
|
+
"""Emit the complete script for one case point."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SweepAxis(BaseModel):
|
|
56
|
+
"""The sweep of one case: which axis varies and its values.
|
|
57
|
+
|
|
58
|
+
Attributes
|
|
59
|
+
----------
|
|
60
|
+
type : str
|
|
61
|
+
``alpha`` (angle of attack, deg), ``beta`` (side slip, deg),
|
|
62
|
+
``alpha_beta`` (paired values), or ``advance_ratio``
|
|
63
|
+
(propeller advance ratio J, dimensionless).
|
|
64
|
+
values : list
|
|
65
|
+
Axis values; for ``alpha_beta`` each entry is an
|
|
66
|
+
``[alpha, beta]`` pair in deg.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
model_config = ConfigDict(extra="forbid")
|
|
70
|
+
|
|
71
|
+
type: Literal["alpha", "beta", "alpha_beta", "advance_ratio"]
|
|
72
|
+
values: list[float] | list[tuple[float, float]]
|
|
73
|
+
|
|
74
|
+
@model_validator(mode="after")
|
|
75
|
+
def _values_match_the_axis_type(self) -> SweepAxis:
|
|
76
|
+
pairs = self.type == "alpha_beta"
|
|
77
|
+
for value in self.values:
|
|
78
|
+
if pairs != isinstance(value, tuple):
|
|
79
|
+
expected = "[alpha, beta] pairs" if pairs else "scalar values"
|
|
80
|
+
raise ValueError(f"a {self.type} sweep takes {expected}, got {value!r}")
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
def points(self) -> Iterator[dict[str, float]]:
|
|
84
|
+
"""Iterate the sweep as named point coordinates.
|
|
85
|
+
|
|
86
|
+
Yields
|
|
87
|
+
------
|
|
88
|
+
dict of str to float
|
|
89
|
+
One mapping per point, keyed ``alpha``, ``beta``, or
|
|
90
|
+
``advance_ratio`` (both keys for ``alpha_beta``).
|
|
91
|
+
"""
|
|
92
|
+
for value in self.values:
|
|
93
|
+
if self.type == "alpha_beta":
|
|
94
|
+
alpha, beta = value
|
|
95
|
+
yield {"alpha": alpha, "beta": beta}
|
|
96
|
+
else:
|
|
97
|
+
yield {self.type: value}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def point_tag(point: dict[str, float]) -> str:
|
|
101
|
+
"""Return the stable file-name tag of one sweep point.
|
|
102
|
+
|
|
103
|
+
The tag encodes the point coordinates in a fixed axis order with
|
|
104
|
+
signed fixed-width values, for example ``a+02.0_b+00.0``; it names
|
|
105
|
+
the generated script and ends the ``run_id``.
|
|
106
|
+
|
|
107
|
+
Parameters
|
|
108
|
+
----------
|
|
109
|
+
point : dict of str to float
|
|
110
|
+
Point coordinates as produced by :meth:`SweepAxis.points`.
|
|
111
|
+
"""
|
|
112
|
+
parts = [f"{prefix}{point[axis]:+05.1f}" for axis, prefix in _TAG_PREFIXES if axis in point]
|
|
113
|
+
if not parts:
|
|
114
|
+
raise ValueError(f"point {point!r} has no known axis (alpha, beta, advance_ratio)")
|
|
115
|
+
return "_".join(parts)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ReferenceData(BaseModel):
|
|
119
|
+
"""Reference quantities for coefficient normalization.
|
|
120
|
+
|
|
121
|
+
Attributes
|
|
122
|
+
----------
|
|
123
|
+
area : float
|
|
124
|
+
Reference area S_ref in simulation length units squared.
|
|
125
|
+
length : float
|
|
126
|
+
Reference length L_ref in simulation length units.
|
|
127
|
+
velocity : float, optional
|
|
128
|
+
Reference velocity in m/s; None lets the recipe default it to
|
|
129
|
+
the free-stream velocity (steady runs) or a characteristic
|
|
130
|
+
velocity such as the rotor tip speed (SRC-003 p.201).
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
model_config = ConfigDict(extra="forbid")
|
|
134
|
+
|
|
135
|
+
area: float
|
|
136
|
+
length: float
|
|
137
|
+
velocity: float | None = None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class SolverSettings(BaseModel):
|
|
141
|
+
"""Solver runtime settings of one case.
|
|
142
|
+
|
|
143
|
+
Field names match the keyword arguments of
|
|
144
|
+
:func:`pyflightstream.script.helpers.solver_settings`, so recipes
|
|
145
|
+
can forward them directly.
|
|
146
|
+
|
|
147
|
+
Attributes
|
|
148
|
+
----------
|
|
149
|
+
iterations : int
|
|
150
|
+
Solver iteration limit.
|
|
151
|
+
convergence : float
|
|
152
|
+
Residual threshold declaring convergence (SRC-003 p.200).
|
|
153
|
+
forced_iterations : bool, optional
|
|
154
|
+
Run the full iteration count regardless of convergence.
|
|
155
|
+
boundary_layer : str, optional
|
|
156
|
+
``LAMINAR``, ``TRANSITIONAL``, or ``TURBULENT``.
|
|
157
|
+
viscous_coupling : bool, optional
|
|
158
|
+
Couple the boundary layer model to the potential solution.
|
|
159
|
+
max_threads : int, optional
|
|
160
|
+
Parallel core count.
|
|
161
|
+
timeout_s : float, optional
|
|
162
|
+
Wall-clock limit for one point's solver process; enforced by
|
|
163
|
+
the executor, not by FlightStream.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
model_config = ConfigDict(extra="forbid")
|
|
167
|
+
|
|
168
|
+
iterations: int = 500
|
|
169
|
+
convergence: float = 1e-5
|
|
170
|
+
forced_iterations: bool | None = None
|
|
171
|
+
boundary_layer: str | None = None
|
|
172
|
+
viscous_coupling: bool | None = None
|
|
173
|
+
max_threads: int | None = None
|
|
174
|
+
timeout_s: float | None = None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class SimCase(BaseModel):
|
|
178
|
+
"""One solver configuration with its sweep (SAD Section 5).
|
|
179
|
+
|
|
180
|
+
Attributes
|
|
181
|
+
----------
|
|
182
|
+
sim_id : str
|
|
183
|
+
Case identity; also names the managed folder
|
|
184
|
+
``sims/sim_<sim_id>``.
|
|
185
|
+
aircraft : str
|
|
186
|
+
Aircraft or configuration name.
|
|
187
|
+
description : str
|
|
188
|
+
Free-text description.
|
|
189
|
+
reynolds : float, optional
|
|
190
|
+
Chord Reynolds number of the condition.
|
|
191
|
+
mach : float, optional
|
|
192
|
+
Free-stream Mach number.
|
|
193
|
+
velocity : float, optional
|
|
194
|
+
Free-stream velocity in m/s.
|
|
195
|
+
geometry : str, optional
|
|
196
|
+
Path of the simulation (.fsm) file; the campaign loop stages
|
|
197
|
+
it into ``inputs/`` and rewrites this field to the staged
|
|
198
|
+
copy, so recipes OPEN exactly what the manifest hashed.
|
|
199
|
+
sweep : SweepAxis
|
|
200
|
+
The sweep of this case.
|
|
201
|
+
reference : ReferenceData, optional
|
|
202
|
+
Coefficient normalization references.
|
|
203
|
+
solver : SolverSettings
|
|
204
|
+
Runtime settings; defaults apply when omitted.
|
|
205
|
+
recipe : str
|
|
206
|
+
Script recipe reference, ``"package.module:function"``, or a
|
|
207
|
+
name registered with the campaign loop.
|
|
208
|
+
variables : dict
|
|
209
|
+
Free per-case variables for the recipe (strings, numbers, or
|
|
210
|
+
booleans), for example a symmetry declaration.
|
|
211
|
+
outputs : list of str
|
|
212
|
+
Output files the recipe's script exports, relative to the
|
|
213
|
+
execution directory; the loop collects them into ``raw/`` and
|
|
214
|
+
a missing one marks the point FAILED_INCOMPLETE_OUTPUT.
|
|
215
|
+
point : dict of str to float
|
|
216
|
+
The current sweep point; filled by the campaign loop before
|
|
217
|
+
the recipe builds, empty on the authored case.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
model_config = ConfigDict(extra="forbid")
|
|
221
|
+
|
|
222
|
+
sim_id: str
|
|
223
|
+
aircraft: str
|
|
224
|
+
description: str = ""
|
|
225
|
+
reynolds: float | None = None
|
|
226
|
+
mach: float | None = None
|
|
227
|
+
velocity: float | None = None
|
|
228
|
+
geometry: str | None = None
|
|
229
|
+
sweep: SweepAxis
|
|
230
|
+
reference: ReferenceData | None = None
|
|
231
|
+
solver: SolverSettings = Field(default_factory=SolverSettings)
|
|
232
|
+
recipe: str
|
|
233
|
+
variables: dict[str, str | float | int | bool] = Field(default_factory=dict)
|
|
234
|
+
outputs: list[str] = Field(default_factory=list)
|
|
235
|
+
point: dict[str, float] = Field(default_factory=dict)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class Campaign(BaseModel):
|
|
239
|
+
"""A named group of cases bound to one FlightStream installation.
|
|
240
|
+
|
|
241
|
+
Attributes
|
|
242
|
+
----------
|
|
243
|
+
name : str
|
|
244
|
+
Campaign name; prefixes every ``run_id``.
|
|
245
|
+
fs_version : str
|
|
246
|
+
FlightStream version, canonical or alias; validated against
|
|
247
|
+
the registered versions at load time, resolved to canonical in
|
|
248
|
+
the manifest.
|
|
249
|
+
fs_exe : str
|
|
250
|
+
Explicit path of the FlightStream executable; existence is
|
|
251
|
+
checked by the executor at construction, not here, so a
|
|
252
|
+
campaign file can be authored away from the licensed machine.
|
|
253
|
+
sims : list of SimCase
|
|
254
|
+
The cases of the campaign.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
model_config = ConfigDict(extra="forbid")
|
|
258
|
+
|
|
259
|
+
name: str
|
|
260
|
+
fs_version: str
|
|
261
|
+
fs_exe: str
|
|
262
|
+
sims: list[SimCase]
|
|
263
|
+
|
|
264
|
+
@field_validator("fs_version")
|
|
265
|
+
@classmethod
|
|
266
|
+
def _version_is_registered(cls, value: str) -> str:
|
|
267
|
+
resolve(value)
|
|
268
|
+
return value
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def load_campaign(path: str | Path) -> Campaign:
|
|
272
|
+
"""Load and validate a ``campaign.toml`` file.
|
|
273
|
+
|
|
274
|
+
The file holds one ``[campaign]`` table (name, fs_version,
|
|
275
|
+
fs_exe) and one ``[[sim]]`` array entry per case, as in SAD
|
|
276
|
+
Section 5.
|
|
277
|
+
|
|
278
|
+
Parameters
|
|
279
|
+
----------
|
|
280
|
+
path : str or Path
|
|
281
|
+
Location of the TOML file.
|
|
282
|
+
|
|
283
|
+
Returns
|
|
284
|
+
-------
|
|
285
|
+
Campaign
|
|
286
|
+
Validated campaign; version aliases are checked against the
|
|
287
|
+
registered versions immediately, so a typo fails at load
|
|
288
|
+
time, not at the first point.
|
|
289
|
+
"""
|
|
290
|
+
with open(path, "rb") as handle:
|
|
291
|
+
data = tomllib.load(handle)
|
|
292
|
+
if "campaign" not in data:
|
|
293
|
+
raise ValueError(
|
|
294
|
+
f"{path} has no [campaign] table; campaign.toml needs [campaign] with "
|
|
295
|
+
"name, fs_version, and fs_exe, plus one [[sim]] entry per case"
|
|
296
|
+
)
|
|
297
|
+
return Campaign(**data["campaign"], sims=data.get("sim", []))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def resolve_recipe(reference: str) -> Callable[[SimCase, Script], None]:
|
|
301
|
+
"""Import the recipe function a reference string names.
|
|
302
|
+
|
|
303
|
+
Parameters
|
|
304
|
+
----------
|
|
305
|
+
reference : str
|
|
306
|
+
``"package.module:function"``; the module must be importable
|
|
307
|
+
and the attribute callable. Explicit references replace the
|
|
308
|
+
historical import-by-number system (PP-7, FR-12).
|
|
309
|
+
|
|
310
|
+
Returns
|
|
311
|
+
-------
|
|
312
|
+
callable
|
|
313
|
+
The recipe function, satisfying :class:`ScriptRecipe`.
|
|
314
|
+
"""
|
|
315
|
+
module_name, separator, function_name = reference.partition(":")
|
|
316
|
+
if not separator or not module_name or not function_name:
|
|
317
|
+
raise ValueError(
|
|
318
|
+
f"recipe reference {reference!r} is not of the form 'package.module:function'"
|
|
319
|
+
)
|
|
320
|
+
try:
|
|
321
|
+
module = import_module(module_name)
|
|
322
|
+
except ImportError as error:
|
|
323
|
+
raise ValueError(
|
|
324
|
+
f"recipe module {module_name!r} cannot be imported: {error}. Recipes are "
|
|
325
|
+
"explicitly imported functions; check the module path and the environment."
|
|
326
|
+
) from error
|
|
327
|
+
recipe = getattr(module, function_name, None)
|
|
328
|
+
if not callable(recipe):
|
|
329
|
+
raise ValueError(
|
|
330
|
+
f"recipe {reference!r} does not name a callable in {module_name!r}; found {recipe!r}"
|
|
331
|
+
)
|
|
332
|
+
return recipe
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Reader for the legacy pipe-delimited run matrix (FR-10, FR-11).
|
|
2
|
+
|
|
3
|
+
Pipeline role: keeps the predecessor run-matrix workflow working
|
|
4
|
+
unchanged, forever (BRF-08). The verified 15-column layout is read as
|
|
5
|
+
is: POL, AIRCRAFT, DESCRIPTION, RE, MACH, SWEEP_TYPE, SWEEP_VALUES,
|
|
6
|
+
REF, SET, ENTRY, FS_SCRIPT, FS_BUILD, HIDDEN, RUN, VAR_NAMES_VALUES.
|
|
7
|
+
Rows with RUN = 1 are active. SWEEP_TYPE names its axes separated by
|
|
8
|
+
``/`` (verified codes: ``AL`` for alpha, ``BE`` for beta) and
|
|
9
|
+
SWEEP_VALUES carries one comma-separated value list per axis, also
|
|
10
|
+
``/``-separated; the legacy workflow varies one axis while the other
|
|
11
|
+
holds a single value, which broadcasts here. VAR_NAMES_VALUES holds
|
|
12
|
+
``/``-separated ``KEY:VALUE`` pairs, values may contain spaces.
|
|
13
|
+
|
|
14
|
+
The historical 3-digit codes (REF, SET, ENTRY, FS_SCRIPT) were
|
|
15
|
+
resolved to files by number at run time; that import-by-number system
|
|
16
|
+
is replaced (PP-7, FR-12): :func:`to_campaign` maps the FS_SCRIPT
|
|
17
|
+
code to a registered recipe name through an explicit mapping and
|
|
18
|
+
preserves all four codes in the case variables, so the conversion is
|
|
19
|
+
lossless. :func:`convert_matrix` (FR-11) emits the native
|
|
20
|
+
``campaign.toml`` equivalent; RE is stored in millions in the matrix
|
|
21
|
+
and converts to an absolute Reynolds number.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import Mapping
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from pyflightstream.cases import Campaign, SimCase, SweepAxis
|
|
31
|
+
|
|
32
|
+
_COLUMNS = (
|
|
33
|
+
"POL",
|
|
34
|
+
"AIRCRAFT",
|
|
35
|
+
"DESCRIPTION",
|
|
36
|
+
"RE",
|
|
37
|
+
"MACH",
|
|
38
|
+
"SWEEP_TYPE",
|
|
39
|
+
"SWEEP_VALUES",
|
|
40
|
+
"REF",
|
|
41
|
+
"SET",
|
|
42
|
+
"ENTRY",
|
|
43
|
+
"FS_SCRIPT",
|
|
44
|
+
"FS_BUILD",
|
|
45
|
+
"HIDDEN",
|
|
46
|
+
"RUN",
|
|
47
|
+
"VAR_NAMES_VALUES",
|
|
48
|
+
)
|
|
49
|
+
_SWEEP_CODES = {"AL": "alpha", "BE": "beta"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class LegacyMatrixError(ValueError):
|
|
53
|
+
"""A legacy matrix file does not match the verified layout.
|
|
54
|
+
|
|
55
|
+
The reader supports exactly the verified format (FR-10); a
|
|
56
|
+
deviation means the file is not a predecessor run matrix or was
|
|
57
|
+
edited beyond what the legacy workflow produced.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class LegacyRow:
|
|
63
|
+
"""One parsed row of the legacy run matrix.
|
|
64
|
+
|
|
65
|
+
Attributes
|
|
66
|
+
----------
|
|
67
|
+
pol : str
|
|
68
|
+
Legacy polar identifier; maps to the native ``sim_id``.
|
|
69
|
+
aircraft, description : str
|
|
70
|
+
Configuration name and free text.
|
|
71
|
+
re_millions : float
|
|
72
|
+
Reynolds number in millions, as stored in the matrix.
|
|
73
|
+
mach : float
|
|
74
|
+
Mach number.
|
|
75
|
+
sweep : SweepAxis
|
|
76
|
+
The sweep, already in native form.
|
|
77
|
+
ref_code, set_code, entry_code, script_code : str
|
|
78
|
+
The historical 3-digit codes (REF, SET, ENTRY, FS_SCRIPT).
|
|
79
|
+
fs_build : str
|
|
80
|
+
Legacy build column, kept verbatim.
|
|
81
|
+
hidden : bool
|
|
82
|
+
Legacy hidden-mode flag.
|
|
83
|
+
run : int
|
|
84
|
+
Activity flag; rows with 1 are active.
|
|
85
|
+
variables : dict
|
|
86
|
+
The KEY:VALUE variables, values kept as strings.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
pol: str
|
|
90
|
+
aircraft: str
|
|
91
|
+
description: str
|
|
92
|
+
re_millions: float
|
|
93
|
+
mach: float
|
|
94
|
+
sweep: SweepAxis
|
|
95
|
+
ref_code: str
|
|
96
|
+
set_code: str
|
|
97
|
+
entry_code: str
|
|
98
|
+
script_code: str
|
|
99
|
+
fs_build: str
|
|
100
|
+
hidden: bool
|
|
101
|
+
run: int
|
|
102
|
+
variables: dict[str, str]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _parse_sweep(sweep_type: str, sweep_values: str) -> SweepAxis:
|
|
106
|
+
axes = [token.strip() for token in sweep_type.split("/")]
|
|
107
|
+
groups = [token.strip() for token in sweep_values.split("/")]
|
|
108
|
+
unknown = [axis for axis in axes if axis not in _SWEEP_CODES]
|
|
109
|
+
if unknown:
|
|
110
|
+
raise LegacyMatrixError(
|
|
111
|
+
f"SWEEP_TYPE code(s) {', '.join(unknown)} are not among the verified legacy "
|
|
112
|
+
f"codes ({', '.join(sorted(_SWEEP_CODES))}); extending the mapping needs "
|
|
113
|
+
"evidence from a legacy matrix that uses the code"
|
|
114
|
+
)
|
|
115
|
+
if len(axes) != len(groups):
|
|
116
|
+
raise LegacyMatrixError(
|
|
117
|
+
f"SWEEP_TYPE names {len(axes)} axes but SWEEP_VALUES holds {len(groups)} "
|
|
118
|
+
"value groups; each axis takes one '/'-separated group"
|
|
119
|
+
)
|
|
120
|
+
values = {
|
|
121
|
+
_SWEEP_CODES[axis]: [float(token) for token in group.split(",")]
|
|
122
|
+
for axis, group in zip(axes, groups, strict=True)
|
|
123
|
+
}
|
|
124
|
+
if set(values) == {"alpha", "beta"}:
|
|
125
|
+
alpha, beta = values["alpha"], values["beta"]
|
|
126
|
+
if len(alpha) > 1 and len(beta) == 1:
|
|
127
|
+
beta = beta * len(alpha)
|
|
128
|
+
elif len(beta) > 1 and len(alpha) == 1:
|
|
129
|
+
alpha = alpha * len(beta)
|
|
130
|
+
elif len(alpha) != len(beta):
|
|
131
|
+
raise LegacyMatrixError(
|
|
132
|
+
"an AL/BE sweep varies one axis while the other holds a single "
|
|
133
|
+
f"value; got {len(alpha)} alpha and {len(beta)} beta values"
|
|
134
|
+
)
|
|
135
|
+
return SweepAxis(
|
|
136
|
+
type="alpha_beta", values=[list(pair) for pair in zip(alpha, beta, strict=True)]
|
|
137
|
+
)
|
|
138
|
+
axis_name, axis_values = next(iter(values.items()))
|
|
139
|
+
return SweepAxis(type=axis_name, values=axis_values)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _parse_variables(cell: str) -> dict[str, str]:
|
|
143
|
+
variables: dict[str, str] = {}
|
|
144
|
+
if not cell.strip():
|
|
145
|
+
return variables
|
|
146
|
+
for pair in cell.split("/"):
|
|
147
|
+
name, separator, value = pair.partition(":")
|
|
148
|
+
if not separator:
|
|
149
|
+
raise LegacyMatrixError(
|
|
150
|
+
f"variable {pair.strip()!r} is not a KEY:VALUE pair; VAR_NAMES_VALUES "
|
|
151
|
+
"holds '/'-separated KEY:VALUE entries"
|
|
152
|
+
)
|
|
153
|
+
variables[name.strip()] = value.strip()
|
|
154
|
+
return variables
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def read_matrix(path: str | Path, active_only: bool = True) -> list[LegacyRow]:
|
|
158
|
+
"""Read a legacy ``matriz.fs`` run matrix.
|
|
159
|
+
|
|
160
|
+
Parameters
|
|
161
|
+
----------
|
|
162
|
+
path : str or Path
|
|
163
|
+
Matrix file location.
|
|
164
|
+
active_only : bool
|
|
165
|
+
Keep only rows with RUN = 1, the legacy activity filter;
|
|
166
|
+
False returns every row.
|
|
167
|
+
|
|
168
|
+
Returns
|
|
169
|
+
-------
|
|
170
|
+
list of LegacyRow
|
|
171
|
+
Parsed rows in file order.
|
|
172
|
+
"""
|
|
173
|
+
lines = Path(path).read_text(encoding="utf-8", errors="replace").splitlines()
|
|
174
|
+
content = [line for line in lines if line.strip() and not set(line.strip()) <= {"-"}]
|
|
175
|
+
if not content:
|
|
176
|
+
raise LegacyMatrixError(f"{path} holds no matrix content")
|
|
177
|
+
header = tuple(cell.strip() for cell in content[0].split("|"))
|
|
178
|
+
if header != _COLUMNS:
|
|
179
|
+
raise LegacyMatrixError(
|
|
180
|
+
f"{path} header does not match the verified 15-column layout; expected "
|
|
181
|
+
f"{', '.join(_COLUMNS)} and found {', '.join(header)}"
|
|
182
|
+
)
|
|
183
|
+
rows: list[LegacyRow] = []
|
|
184
|
+
for line in content[1:]:
|
|
185
|
+
cells = [cell.strip() for cell in line.split("|")]
|
|
186
|
+
if len(cells) != len(_COLUMNS):
|
|
187
|
+
raise LegacyMatrixError(
|
|
188
|
+
f"matrix row holds {len(cells)} cells against the 15 verified columns: "
|
|
189
|
+
f"{line.strip()[:60]}..."
|
|
190
|
+
)
|
|
191
|
+
record = dict(zip(_COLUMNS, cells, strict=True))
|
|
192
|
+
row = LegacyRow(
|
|
193
|
+
pol=record["POL"],
|
|
194
|
+
aircraft=record["AIRCRAFT"],
|
|
195
|
+
description=record["DESCRIPTION"],
|
|
196
|
+
re_millions=float(record["RE"]),
|
|
197
|
+
mach=float(record["MACH"]),
|
|
198
|
+
sweep=_parse_sweep(record["SWEEP_TYPE"], record["SWEEP_VALUES"]),
|
|
199
|
+
ref_code=record["REF"],
|
|
200
|
+
set_code=record["SET"],
|
|
201
|
+
entry_code=record["ENTRY"],
|
|
202
|
+
script_code=record["FS_SCRIPT"],
|
|
203
|
+
fs_build=record["FS_BUILD"],
|
|
204
|
+
hidden=record["HIDDEN"] == "1",
|
|
205
|
+
run=int(record["RUN"]),
|
|
206
|
+
variables=_parse_variables(record["VAR_NAMES_VALUES"]),
|
|
207
|
+
)
|
|
208
|
+
if row.run == 1 or not active_only:
|
|
209
|
+
rows.append(row)
|
|
210
|
+
return rows
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def to_campaign(
|
|
214
|
+
path: str | Path,
|
|
215
|
+
*,
|
|
216
|
+
name: str,
|
|
217
|
+
fs_version: str,
|
|
218
|
+
fs_exe: str,
|
|
219
|
+
recipes: Mapping[str, str],
|
|
220
|
+
) -> Campaign:
|
|
221
|
+
"""Convert a legacy matrix into a native :class:`Campaign`.
|
|
222
|
+
|
|
223
|
+
Parameters
|
|
224
|
+
----------
|
|
225
|
+
path : str or Path
|
|
226
|
+
Legacy matrix location; only RUN = 1 rows convert.
|
|
227
|
+
name : str
|
|
228
|
+
Campaign name; the matrix has none, so it is explicit input.
|
|
229
|
+
fs_version : str
|
|
230
|
+
FlightStream version, canonical or alias; the legacy FS_BUILD
|
|
231
|
+
column does not identify one, so it is explicit input.
|
|
232
|
+
fs_exe : str
|
|
233
|
+
Explicit executable path (never guessed, SAD Section 5).
|
|
234
|
+
recipes : mapping of str to str
|
|
235
|
+
FS_SCRIPT code to recipe reference (``module:function`` or a
|
|
236
|
+
name registered with the campaign loop); replaces the
|
|
237
|
+
import-by-number system (PP-7, FR-12).
|
|
238
|
+
|
|
239
|
+
Returns
|
|
240
|
+
-------
|
|
241
|
+
Campaign
|
|
242
|
+
Native campaign; the historical codes survive in each case's
|
|
243
|
+
variables (``legacy_ref``, ``legacy_set``, ``legacy_entry``,
|
|
244
|
+
``legacy_fs_script``, ``legacy_fs_build``, ``legacy_hidden``)
|
|
245
|
+
so the conversion is lossless (FR-11).
|
|
246
|
+
"""
|
|
247
|
+
sims = []
|
|
248
|
+
for row in read_matrix(path):
|
|
249
|
+
if row.script_code not in recipes:
|
|
250
|
+
raise LegacyMatrixError(
|
|
251
|
+
f"FS_SCRIPT code {row.script_code!r} of POL {row.pol} has no recipe "
|
|
252
|
+
"mapping; the import-by-number system is replaced by explicit recipe "
|
|
253
|
+
"references, so pass recipes={code: 'package.module:function'}"
|
|
254
|
+
)
|
|
255
|
+
variables: dict[str, str | float | int | bool] = dict(row.variables)
|
|
256
|
+
variables.update(
|
|
257
|
+
legacy_ref=row.ref_code,
|
|
258
|
+
legacy_set=row.set_code,
|
|
259
|
+
legacy_entry=row.entry_code,
|
|
260
|
+
legacy_fs_script=row.script_code,
|
|
261
|
+
legacy_fs_build=row.fs_build,
|
|
262
|
+
legacy_hidden=row.hidden,
|
|
263
|
+
)
|
|
264
|
+
sims.append(
|
|
265
|
+
SimCase(
|
|
266
|
+
sim_id=row.pol,
|
|
267
|
+
aircraft=row.aircraft,
|
|
268
|
+
description=row.description,
|
|
269
|
+
reynolds=row.re_millions * 1e6,
|
|
270
|
+
mach=row.mach,
|
|
271
|
+
sweep=row.sweep,
|
|
272
|
+
recipe=recipes[row.script_code],
|
|
273
|
+
variables=variables,
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
return Campaign(name=name, fs_version=fs_version, fs_exe=fs_exe, sims=sims)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _toml_value(value: object) -> str:
|
|
280
|
+
if isinstance(value, bool):
|
|
281
|
+
return "true" if value else "false"
|
|
282
|
+
if isinstance(value, (int, float)):
|
|
283
|
+
return repr(value)
|
|
284
|
+
if isinstance(value, list):
|
|
285
|
+
return "[" + ", ".join(_toml_value(item) for item in value) + "]"
|
|
286
|
+
escaped = str(value).replace("\\", "\\\\").replace('"', '\\"')
|
|
287
|
+
return f'"{escaped}"'
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def convert_matrix(
|
|
291
|
+
path: str | Path,
|
|
292
|
+
*,
|
|
293
|
+
name: str,
|
|
294
|
+
fs_version: str,
|
|
295
|
+
fs_exe: str,
|
|
296
|
+
recipes: Mapping[str, str],
|
|
297
|
+
) -> str:
|
|
298
|
+
"""Emit the native ``campaign.toml`` text of a legacy matrix (FR-11).
|
|
299
|
+
|
|
300
|
+
Parameters are those of :func:`to_campaign`. The returned text
|
|
301
|
+
loads back through :func:`pyflightstream.cases.load_campaign`, so
|
|
302
|
+
migration is one call and reversible only in the sense that the
|
|
303
|
+
legacy file itself stays untouched and readable forever (FR-10).
|
|
304
|
+
"""
|
|
305
|
+
campaign = to_campaign(path, name=name, fs_version=fs_version, fs_exe=fs_exe, recipes=recipes)
|
|
306
|
+
lines = [
|
|
307
|
+
"[campaign]",
|
|
308
|
+
f"name = {_toml_value(campaign.name)}",
|
|
309
|
+
f"fs_version = {_toml_value(campaign.fs_version)}",
|
|
310
|
+
f"fs_exe = {_toml_value(campaign.fs_exe)}",
|
|
311
|
+
]
|
|
312
|
+
for sim in campaign.sims:
|
|
313
|
+
lines += [
|
|
314
|
+
"",
|
|
315
|
+
"[[sim]]",
|
|
316
|
+
f"sim_id = {_toml_value(sim.sim_id)}",
|
|
317
|
+
f"aircraft = {_toml_value(sim.aircraft)}",
|
|
318
|
+
]
|
|
319
|
+
if sim.description:
|
|
320
|
+
lines.append(f"description = {_toml_value(sim.description)}")
|
|
321
|
+
if sim.reynolds is not None:
|
|
322
|
+
lines.append(f"reynolds = {_toml_value(sim.reynolds)}")
|
|
323
|
+
if sim.mach is not None:
|
|
324
|
+
lines.append(f"mach = {_toml_value(sim.mach)}")
|
|
325
|
+
plain_values = [
|
|
326
|
+
list(value) if isinstance(value, tuple) else value for value in sim.sweep.values
|
|
327
|
+
]
|
|
328
|
+
lines.append(
|
|
329
|
+
f"sweep = {{type = {_toml_value(sim.sweep.type)}, "
|
|
330
|
+
f"values = {_toml_value(plain_values)}}}"
|
|
331
|
+
)
|
|
332
|
+
lines.append(f"recipe = {_toml_value(sim.recipe)}")
|
|
333
|
+
if sim.variables:
|
|
334
|
+
lines.append("[sim.variables]")
|
|
335
|
+
for key, value in sim.variables.items():
|
|
336
|
+
lines.append(f"{_toml_value(key)} = {_toml_value(value)}")
|
|
337
|
+
return "\n".join(lines) + "\n"
|