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,375 @@
1
+ """Managed run file layout and the campaign manifest.
2
+
3
+ Pipeline role: owns where run files live. Folder layout, staging of solver
4
+ inputs, collection of outputs, and archiving are managed by the package,
5
+ not by the user: folder identity mistakes were a recurring failure mode in
6
+ the predecessor toolchain. Run identity lives in the manifest
7
+ (``runs.json``), never in folder names; folder names are generated,
8
+ English, and stable, and are never parsed for meaning (SAD Section 6).
9
+
10
+ The managed layout under a user-chosen campaign root:
11
+
12
+ - ``runs.json``: the authoritative manifest, one record per executed
13
+ point.
14
+ - ``sims/sim_<sim_id>/``: per-simulation folder with ``inputs/``
15
+ (staged copies with recorded sha256), ``scripts/`` (generated script
16
+ text per point), ``raw/`` (solver outputs as produced), and
17
+ ``parsed/`` (typed extracts).
18
+ - ``archive/``: zipped completed simulations, manifest-driven.
19
+
20
+ Archiving and cleaning refuse to act when the manifest is missing or
21
+ does not record the target simulation, so file management can never
22
+ destroy an unrecorded run.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import enum
28
+ import hashlib
29
+ import json
30
+ import re
31
+ import shutil
32
+ import zipfile
33
+ from collections.abc import Sequence
34
+ from pathlib import Path
35
+
36
+ from pydantic import BaseModel, ConfigDict, Field
37
+
38
+ _SIM_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
39
+ _SIM_SUBDIRS = ("inputs", "scripts", "raw", "parsed")
40
+
41
+
42
+ class WorkspaceError(RuntimeError):
43
+ """A file-management operation was refused or impossible.
44
+
45
+ The refusals protect run evidence: archiving or cleaning without a
46
+ manifest record would destroy a run the manifest cannot account
47
+ for, and collection of a declared output that the solver never
48
+ produced points at an incomplete run.
49
+ """
50
+
51
+
52
+ class RunStatus(enum.StrEnum):
53
+ """Terminal status of one executed campaign point (SAD Section 7).
54
+
55
+ Every executed point lands in exactly one of these; a silent skip
56
+ is structurally impossible in the campaign loop.
57
+ """
58
+
59
+ CONVERGED = "CONVERGED"
60
+ COMPLETED_MAX_ITER = "COMPLETED_MAX_ITER"
61
+ FAILED_EXECUTION = "FAILED_EXECUTION"
62
+ FAILED_SCRIPT = "FAILED_SCRIPT"
63
+ FAILED_INCOMPLETE_OUTPUT = "FAILED_INCOMPLETE_OUTPUT"
64
+ FAILED_DIVERGED = "FAILED_DIVERGED"
65
+
66
+
67
+ class RunRecord(BaseModel):
68
+ """One manifest record: a single executed campaign point.
69
+
70
+ The record plus the staged inputs reproduce the run (NFR-07).
71
+
72
+ Attributes
73
+ ----------
74
+ run_id : str
75
+ Unique identity of the executed point, for example
76
+ ``"campaign/sim_9001/a+02.0_b+00.0"``; the manifest rejects
77
+ duplicates.
78
+ sim_id : str
79
+ Simulation identity; ties the record to ``sims/sim_<sim_id>``.
80
+ point : dict of str to float
81
+ Sweep point coordinates, for example alpha and beta in deg.
82
+ fs_version_requested : str
83
+ Canonical FlightStream version the script was built for.
84
+ fs_version_reported : str, optional
85
+ Version printed in the solver outputs; filled by the parsers
86
+ and cross-checked against the requested one (FR-18).
87
+ fs_build : str, optional
88
+ Build string reported by the solver, when available.
89
+ package_version : str
90
+ pyflightstream version that produced the run.
91
+ script_sha256 : str
92
+ Hash of the executed script text.
93
+ inputs_sha256 : dict of str to str
94
+ Hash per staged input file name, recorded at staging time.
95
+ raw_flag : bool
96
+ True when the script used the ``raw()`` escape hatch and its
97
+ content bypassed database validation (FR-07).
98
+ status : RunStatus
99
+ Terminal status of the point.
100
+ iterations : int, optional
101
+ Solver iterations reached, when parsed.
102
+ residual : float, optional
103
+ Final residual, when parsed.
104
+ wall_time_s : float, optional
105
+ Wall-clock duration of the solver process in seconds.
106
+ outputs : list of str
107
+ Collected output files, relative to the simulation folder
108
+ (for example ``"raw/loads.txt"``).
109
+ error : str, optional
110
+ Error text for failed points.
111
+ """
112
+
113
+ model_config = ConfigDict(extra="forbid")
114
+
115
+ run_id: str
116
+ sim_id: str
117
+ point: dict[str, float] = Field(default_factory=dict)
118
+ fs_version_requested: str
119
+ fs_version_reported: str | None = None
120
+ fs_build: str | None = None
121
+ package_version: str
122
+ script_sha256: str
123
+ inputs_sha256: dict[str, str] = Field(default_factory=dict)
124
+ raw_flag: bool
125
+ status: RunStatus
126
+ iterations: int | None = None
127
+ residual: float | None = None
128
+ wall_time_s: float | None = None
129
+ outputs: list[str] = Field(default_factory=list)
130
+ error: str | None = None
131
+
132
+
133
+ def _sha256(path: Path) -> str:
134
+ digest = hashlib.sha256()
135
+ with open(path, "rb") as handle:
136
+ for block in iter(lambda: handle.read(65536), b""):
137
+ digest.update(block)
138
+ return digest.hexdigest()
139
+
140
+
141
+ class CampaignWorkspace:
142
+ """The managed folder layout of one campaign root.
143
+
144
+ Parameters
145
+ ----------
146
+ root : str or Path
147
+ User-chosen campaign root; everything below it is managed by
148
+ this class and never hand-built.
149
+
150
+ Attributes
151
+ ----------
152
+ root : Path
153
+ The campaign root.
154
+ """
155
+
156
+ def __init__(self, root: str | Path):
157
+ self.root = Path(root)
158
+
159
+ @property
160
+ def manifest_path(self) -> Path:
161
+ """Location of the authoritative manifest, ``runs.json``."""
162
+ return self.root / "runs.json"
163
+
164
+ def sim_dir(self, sim_id: str) -> Path:
165
+ """Return the managed folder of one simulation.
166
+
167
+ Parameters
168
+ ----------
169
+ sim_id : str
170
+ Simulation identity; letters, digits, underscore, and
171
+ hyphen only, so the derived folder name is stable and
172
+ portable (NFR-10).
173
+ """
174
+ if not _SIM_ID_PATTERN.match(sim_id):
175
+ raise WorkspaceError(
176
+ f"sim_id {sim_id!r} cannot name a managed folder: use letters, digits, "
177
+ "underscore, or hyphen. Folder names derive from sim_id and must stay "
178
+ "stable and portable; identity lives in the manifest, not in names."
179
+ )
180
+ return self.root / "sims" / f"sim_{sim_id}"
181
+
182
+ def create_sim(self, sim_id: str) -> Path:
183
+ """Create the managed subfolders of one simulation and return its path.
184
+
185
+ Creates ``inputs/``, ``scripts/``, ``raw/``, and ``parsed/``;
186
+ existing folders are kept, so the call is idempotent.
187
+ """
188
+ sim = self.sim_dir(sim_id)
189
+ for name in _SIM_SUBDIRS:
190
+ (sim / name).mkdir(parents=True, exist_ok=True)
191
+ return sim
192
+
193
+ def stage_inputs(self, sim_id: str, sources: Sequence[str | Path]) -> dict[str, str]:
194
+ """Copy input files into ``inputs/`` and record their hashes.
195
+
196
+ Staging happens before execution so the manifest can tie the
197
+ run to the exact input content (NFR-07).
198
+
199
+ Parameters
200
+ ----------
201
+ sim_id : str
202
+ Target simulation.
203
+ sources : sequence of str or Path
204
+ Files to copy; each must exist.
205
+
206
+ Returns
207
+ -------
208
+ dict of str to str
209
+ sha256 per staged file name, ready for
210
+ :attr:`RunRecord.inputs_sha256`.
211
+ """
212
+ sim = self.create_sim(sim_id)
213
+ hashes: dict[str, str] = {}
214
+ for source in sources:
215
+ origin = Path(source)
216
+ if not origin.is_file():
217
+ raise WorkspaceError(
218
+ f"cannot stage {origin}: the file does not exist. Staging copies "
219
+ "inputs before execution so the manifest records what actually ran."
220
+ )
221
+ target = sim / "inputs" / origin.name
222
+ shutil.copy2(origin, target)
223
+ hashes[origin.name] = _sha256(target)
224
+ return hashes
225
+
226
+ def write_script(self, sim_id: str, name: str, text: str) -> tuple[Path, str]:
227
+ """Write one generated script into ``scripts/`` and hash it.
228
+
229
+ Parameters
230
+ ----------
231
+ sim_id : str
232
+ Target simulation.
233
+ name : str
234
+ Script file name, for example ``"a+02.0_b+00.0.txt"``.
235
+ text : str
236
+ Rendered script text from the builder.
237
+
238
+ Returns
239
+ -------
240
+ Path
241
+ Location of the written script.
242
+ str
243
+ sha256 of the written text, for
244
+ :attr:`RunRecord.script_sha256`.
245
+ """
246
+ sim = self.create_sim(sim_id)
247
+ target = sim / "scripts" / name
248
+ target.write_text(text, encoding="utf-8")
249
+ return target, _sha256(target)
250
+
251
+ def collect_outputs(self, sim_id: str, produced: Sequence[str | Path]) -> list[str]:
252
+ """Move declared solver outputs into ``raw/``.
253
+
254
+ Parameters
255
+ ----------
256
+ sim_id : str
257
+ Target simulation.
258
+ produced : sequence of str or Path
259
+ Output files the run declared it would produce, wherever
260
+ the script wrote them.
261
+
262
+ Returns
263
+ -------
264
+ list of str
265
+ Collected names relative to the simulation folder
266
+ (``"raw/<name>"``), ready for :attr:`RunRecord.outputs`.
267
+
268
+ Raises
269
+ ------
270
+ WorkspaceError
271
+ If a declared output does not exist; the campaign loop
272
+ turns this into FAILED_INCOMPLETE_OUTPUT, never into a
273
+ silently shorter output set.
274
+ """
275
+ sim = self.create_sim(sim_id)
276
+ missing = [str(path) for path in produced if not Path(path).is_file()]
277
+ if missing:
278
+ raise WorkspaceError(
279
+ f"declared outputs were not produced: {', '.join(missing)}. A missing "
280
+ "declared output marks the point FAILED_INCOMPLETE_OUTPUT; outputs are "
281
+ "never silently dropped."
282
+ )
283
+ collected: list[str] = []
284
+ for path in produced:
285
+ origin = Path(path)
286
+ shutil.move(str(origin), sim / "raw" / origin.name)
287
+ collected.append(f"raw/{origin.name}")
288
+ return collected
289
+
290
+ def read_manifest(self) -> list[RunRecord]:
291
+ """Read and validate every record of ``runs.json``.
292
+
293
+ Returns an empty list when the manifest does not exist yet.
294
+ """
295
+ if not self.manifest_path.is_file():
296
+ return []
297
+ entries = json.loads(self.manifest_path.read_text(encoding="utf-8"))
298
+ return [RunRecord.model_validate(entry) for entry in entries]
299
+
300
+ def append_record(self, record: RunRecord) -> None:
301
+ """Append one record to the manifest, atomically.
302
+
303
+ The manifest is rewritten through a temporary file and an
304
+ atomic replace, so a crash never leaves it half-written; a
305
+ duplicate ``run_id`` is rejected because the manifest is the
306
+ run identity (PP-6).
307
+ """
308
+ records = self.read_manifest()
309
+ if any(existing.run_id == record.run_id for existing in records):
310
+ raise WorkspaceError(
311
+ f"run_id {record.run_id!r} is already in the manifest; run identity "
312
+ "must be unique. Use a new run_id or archive the campaign first."
313
+ )
314
+ records.append(record)
315
+ self.root.mkdir(parents=True, exist_ok=True)
316
+ payload = json.dumps([entry.model_dump(mode="json") for entry in records], indent=2)
317
+ temporary = self.manifest_path.with_suffix(".json.tmp")
318
+ temporary.write_text(payload + "\n", encoding="utf-8")
319
+ temporary.replace(self.manifest_path)
320
+
321
+ def archive_sim(self, sim_id: str) -> Path:
322
+ """Zip one recorded simulation into ``archive/`` and remove its folder.
323
+
324
+ Returns
325
+ -------
326
+ Path
327
+ Location of the written zip file.
328
+
329
+ Raises
330
+ ------
331
+ WorkspaceError
332
+ If the manifest is missing, does not record ``sim_id``, or
333
+ the simulation folder does not exist: file management
334
+ never destroys an unrecorded run.
335
+ """
336
+ sim = self._recorded_sim(sim_id, operation="archive")
337
+ archive_dir = self.root / "archive"
338
+ archive_dir.mkdir(parents=True, exist_ok=True)
339
+ target = archive_dir / f"sim_{sim_id}.zip"
340
+ with zipfile.ZipFile(target, "w", compression=zipfile.ZIP_DEFLATED) as bundle:
341
+ for path in sorted(sim.rglob("*")):
342
+ if path.is_file():
343
+ bundle.write(path, path.relative_to(sim))
344
+ shutil.rmtree(sim)
345
+ return target
346
+
347
+ def clean_sim(self, sim_id: str) -> None:
348
+ """Remove one recorded simulation folder without archiving it.
349
+
350
+ Raises
351
+ ------
352
+ WorkspaceError
353
+ Same refusals as :meth:`archive_sim`.
354
+ """
355
+ sim = self._recorded_sim(sim_id, operation="clean")
356
+ shutil.rmtree(sim)
357
+
358
+ def _recorded_sim(self, sim_id: str, operation: str) -> Path:
359
+ sim = self.sim_dir(sim_id)
360
+ if not self.manifest_path.is_file():
361
+ raise WorkspaceError(
362
+ f"refusing to {operation} sim_{sim_id}: no manifest (runs.json) exists "
363
+ "in this campaign root. Without the manifest the folder content cannot "
364
+ "be accounted for, and file management never destroys an unrecorded run."
365
+ )
366
+ if not any(record.sim_id == sim_id for record in self.read_manifest()):
367
+ raise WorkspaceError(
368
+ f"refusing to {operation} sim_{sim_id}: the manifest has no record of "
369
+ "this simulation, so its folder would be destroyed unaccounted."
370
+ )
371
+ if not sim.is_dir():
372
+ raise WorkspaceError(
373
+ f"cannot {operation} sim_{sim_id}: the folder {sim} does not exist."
374
+ )
375
+ return sim
@@ -0,0 +1,57 @@
1
+ """Fluid-structure interaction coupling for rotating blades.
2
+
3
+ Pipeline role: this subpackage implements the external structural
4
+ executable of the FlightStream Aeroelastic Toolbox loop (M6, DLV-007;
5
+ FR-23a). Per coupling call FlightStream exports sectional loads on
6
+ user-defined surface sections; the executable reads them together with
7
+ its configuration and persisted state, solves one beam per blade,
8
+ converts the solution to nodal translations, and writes the
9
+ displacement file back for the solver to deform the mesh. All exchange
10
+ happens in the rotating blade frames; this package never handles
11
+ azimuth or global-frame transforms (FSI-R02).
12
+
13
+ The structural backend is PyNite (PyPI distribution ``PyNiteFEA``,
14
+ import name ``Pynite``), pulled in only by the optional ``[fsi]``
15
+ extra; importing :mod:`pyflightstream.fsi` itself stays dependency
16
+ free so the core package works without the extra installed.
17
+
18
+ The evidence status of the structural model is recorded in DLV-007
19
+ Section 2: formulas live in small isolated functions with their source
20
+ cited in the docstring, so a later primary-source correction stays a
21
+ localized change.
22
+ """
23
+
24
+ from pathlib import Path
25
+ from typing import Protocol, runtime_checkable
26
+
27
+ from pyflightstream.fsi.config import FsiConfig, config_hash, load_config
28
+
29
+ __all__ = ["FsiConfig", "StructuralSolver", "config_hash", "load_config"]
30
+
31
+
32
+ @runtime_checkable
33
+ class StructuralSolver(Protocol):
34
+ """Contract of one structural coupling call (HND-008 loop).
35
+
36
+ FlightStream calls the executable between coupling iterations; the
37
+ executable is stateless per call (FSI-R01) and all state persists
38
+ in files inside the run folder. An implementation reads the
39
+ exported sectional loads, ``config.json`` and ``state.json`` from
40
+ ``run_dir``, solves the structure, and writes ``FSIDisp.txt`` plus
41
+ the updated ``state.json`` atomically (FSI-R13).
42
+ """
43
+
44
+ def step(self, run_dir: Path) -> None:
45
+ """Execute one coupling call inside ``run_dir``.
46
+
47
+ Parameters
48
+ ----------
49
+ run_dir : Path
50
+ Run folder managed by :mod:`pyflightstream.files` (FR-28)
51
+ containing the FlightStream loads export, ``config.json``
52
+ and ``state.json``. The written ``FSIDisp.txt`` carries one
53
+ translation vector (dx, dy, dz) per structural node, in
54
+ meters, in the rotating blade frame, in exactly the node
55
+ order imported into FlightStream (FSI-R14).
56
+ """
57
+ ...