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