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,406 @@
1
+ """Cross-version drift suite (FR-27, SAD Section 11).
2
+
3
+ Pipeline role: runs the same physics case set on two FlightStream
4
+ versions and diffs the aggregated coefficients, so a solver release
5
+ cannot silently move the physics the research depends on. The case
6
+ set is exactly the Tier 3 registry of :mod:`pyflightstream.qa.physics`
7
+ (synthetic committed geometry; the SMI class joins later as more
8
+ registry cases, geometry staying local under ``_private/``).
9
+
10
+ Drift needs no stored references: version A is the baseline and
11
+ version B is judged against it inside the same WARN and FAIL half
12
+ widths the case metrics declare, so "the physics moved more than a
13
+ reference regression would tolerate" means the same thing in both
14
+ suites. The degenerate self-comparison (same version twice) exercises
15
+ the whole machinery and must land every delta at zero, since repeat
16
+ runs on 26.120 proved bit-identical (PHY-26120_2026-07-21_full).
17
+
18
+ Every executable path is explicit input, one per version, never
19
+ guessed (SAD Section 5).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import datetime
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+
28
+ import yaml
29
+
30
+ import pyflightstream
31
+ from pyflightstream.qa.physics import (
32
+ PhysicsRun,
33
+ ReferenceBand,
34
+ Verdict,
35
+ registered_cases,
36
+ run_physics,
37
+ )
38
+ from pyflightstream.versions import resolve
39
+
40
+ DRIFT_SCHEMA = "pyflightstream-drift-report/1"
41
+
42
+ __all__ = [
43
+ "DriftMetric",
44
+ "DriftCaseResult",
45
+ "DriftRun",
46
+ "diff_runs",
47
+ "run_drift",
48
+ "write_drift_report",
49
+ ]
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class DriftMetric:
54
+ """One metric compared across the two versions.
55
+
56
+ Attributes
57
+ ----------
58
+ value_a, value_b : float
59
+ Measured values on the baseline and the compared version.
60
+ delta : float
61
+ ``value_b - value_a``.
62
+ verdict : Verdict
63
+ Judgment of ``value_b`` against the case's declared bands
64
+ centered on ``value_a`` (``NO_REFERENCE`` when the metric is
65
+ missing from the case's specifications).
66
+ warn, fail : float
67
+ The half widths applied, from the case metric specification.
68
+ kind : str
69
+ Band kind applied (``rel`` or ``abs``).
70
+ """
71
+
72
+ value_a: float
73
+ value_b: float
74
+ delta: float
75
+ verdict: Verdict
76
+ warn: float
77
+ fail: float
78
+ kind: str
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class DriftCaseResult:
83
+ """Drift outcome of one physics case.
84
+
85
+ ``error`` carries the failure text when the case aborted on either
86
+ version; a failed case reports instead of hiding the others.
87
+ """
88
+
89
+ case_id: str
90
+ title: str
91
+ metrics: dict[str, DriftMetric]
92
+ error: str | None = None
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class DriftRun:
97
+ """One complete cross-version drift comparison."""
98
+
99
+ version_a: str
100
+ version_b: str
101
+ fs_exe_names: dict[str, str]
102
+ package_version: str
103
+ results: tuple[DriftCaseResult, ...]
104
+ solver_identity: tuple[str, ...] = ()
105
+
106
+ def verdict_counts(self) -> dict[str, int]:
107
+ """Count metric verdicts over every case, for the summary line."""
108
+ counts = {verdict.value: 0 for verdict in Verdict}
109
+ for result in self.results:
110
+ for metric in result.metrics.values():
111
+ counts[metric.verdict.value] += 1
112
+ return counts
113
+
114
+
115
+ def diff_runs(run_a: PhysicsRun, run_b: PhysicsRun) -> DriftRun:
116
+ """Diff two physics runs case by case and metric by metric.
117
+
118
+ Pure reduction over already-measured runs, so the drift judgment
119
+ is testable without a solver. Metrics are judged with the case's
120
+ declared bands centered on the version-A value; a metric absent
121
+ from the case specifications judges ``NO_REFERENCE`` rather than
122
+ inventing a band.
123
+
124
+ Parameters
125
+ ----------
126
+ run_a, run_b : PhysicsRun
127
+ The baseline and compared runs, normally of two different
128
+ versions on the same case set.
129
+
130
+ Returns
131
+ -------
132
+ DriftRun
133
+ The comparison, one entry per case present in both runs.
134
+ """
135
+ cases_b = {result.case_id: result for result in run_b.results}
136
+ results: list[DriftCaseResult] = []
137
+ for result_a in run_a.results:
138
+ result_b = cases_b.get(result_a.case_id)
139
+ if result_b is None:
140
+ continue
141
+ title = result_a.title or result_b.title
142
+ errors = [
143
+ f"{version}: {error}"
144
+ for version, error in (
145
+ (run_a.version, result_a.error),
146
+ (run_b.version, result_b.error),
147
+ )
148
+ if error is not None
149
+ ]
150
+ if errors:
151
+ results.append(
152
+ DriftCaseResult(
153
+ case_id=result_a.case_id, title=title, metrics={}, error="; ".join(errors)
154
+ )
155
+ )
156
+ continue
157
+ case = registered_cases(include_smi=True).get(result_a.case_id)
158
+ specs = case.specs_by_name if case is not None else {}
159
+ metrics: dict[str, DriftMetric] = {}
160
+ for name, value_a in result_a.metrics.items():
161
+ if name not in result_b.metrics:
162
+ continue
163
+ value_b = result_b.metrics[name]
164
+ spec = specs.get(name)
165
+ if spec is None:
166
+ metrics[name] = DriftMetric(
167
+ value_a=value_a,
168
+ value_b=value_b,
169
+ delta=value_b - value_a,
170
+ verdict=Verdict.NO_REFERENCE,
171
+ warn=float("nan"),
172
+ fail=float("nan"),
173
+ kind="abs",
174
+ )
175
+ continue
176
+ band = ReferenceBand(value=value_a, warn=spec.warn, fail=spec.fail, kind=spec.kind)
177
+ metrics[name] = DriftMetric(
178
+ value_a=value_a,
179
+ value_b=value_b,
180
+ delta=value_b - value_a,
181
+ verdict=band.judge(value_b),
182
+ warn=spec.warn,
183
+ fail=spec.fail,
184
+ kind=spec.kind,
185
+ )
186
+ results.append(DriftCaseResult(case_id=result_a.case_id, title=title, metrics=metrics))
187
+ identity = list(run_a.solver_identity)
188
+ identity.extend(line for line in run_b.solver_identity if line not in identity)
189
+ return DriftRun(
190
+ version_a=run_a.version,
191
+ version_b=run_b.version,
192
+ fs_exe_names={run_a.version: run_a.fs_exe_name, run_b.version: run_b.fs_exe_name},
193
+ package_version=pyflightstream.__version__,
194
+ results=tuple(results),
195
+ solver_identity=tuple(identity),
196
+ )
197
+
198
+
199
+ def run_drift(
200
+ version_a: str,
201
+ version_b: str,
202
+ *,
203
+ fs_exes: dict[str, str | Path],
204
+ workroot: str | Path,
205
+ cases: list[str] | None = None,
206
+ timeout_s: float = 900.0,
207
+ smi_root: str | Path | None = None,
208
+ ) -> DriftRun:
209
+ """Run the physics case set on both versions and diff the results.
210
+
211
+ Parameters
212
+ ----------
213
+ version_a, version_b : str
214
+ Baseline and compared versions, canonical or alias. The same
215
+ version twice is the degenerate self-comparison that proves
216
+ the machinery.
217
+ fs_exes : dict of str to path
218
+ Explicit executable per canonical version (never guessed);
219
+ must cover both versions.
220
+ workroot : str or Path
221
+ Scratch root; each version nests its own per-case directories
222
+ under its canonical name.
223
+ cases : list of str, optional
224
+ Case subset; defaults to every registered case (the SMI class
225
+ joins the default only when ``smi_root`` is given).
226
+ timeout_s : float
227
+ Wall-clock limit per solver point.
228
+ smi_root : str or Path, optional
229
+ Local SMI geometry root; enables the SMI drift class on both
230
+ versions. Explicit input, never guessed.
231
+
232
+ Returns
233
+ -------
234
+ DriftRun
235
+ The comparison.
236
+
237
+ Raises
238
+ ------
239
+ PhysicsEnvironmentError
240
+ When an executable is missing for either version or a case is
241
+ unknown (raised by the underlying physics runs).
242
+ KeyError
243
+ When ``fs_exes`` does not cover a requested version.
244
+ """
245
+ canonical_a = resolve(version_a).canonical
246
+ canonical_b = resolve(version_b).canonical
247
+ run_a = run_physics(
248
+ canonical_a,
249
+ fs_exe=fs_exes[canonical_a],
250
+ workroot=workroot,
251
+ cases=cases,
252
+ timeout_s=timeout_s,
253
+ smi_root=smi_root,
254
+ )
255
+ if canonical_b != canonical_a:
256
+ run_b = run_physics(
257
+ canonical_b,
258
+ fs_exe=fs_exes[canonical_b],
259
+ workroot=workroot,
260
+ cases=cases,
261
+ timeout_s=timeout_s,
262
+ smi_root=smi_root,
263
+ )
264
+ else:
265
+ # Degenerate self-comparison: run the case set a second time so
266
+ # the diff really compares two independent solver executions.
267
+ run_b = run_physics(
268
+ canonical_b,
269
+ fs_exe=fs_exes[canonical_b],
270
+ workroot=Path(workroot) / "self_b",
271
+ cases=cases,
272
+ timeout_s=timeout_s,
273
+ smi_root=smi_root,
274
+ )
275
+ return diff_runs(run_a, run_b)
276
+
277
+
278
+ def write_drift_report(
279
+ run: DriftRun, out_dir: str | Path, *, date: str | None = None, label: str | None = None
280
+ ) -> tuple[Path, Path]:
281
+ """Write one drift comparison as a report pair (YAML plus Markdown).
282
+
283
+ Same evidence discipline as the physics reports: the stem is
284
+ ``DRF-<A digits>-<B digits>_<date>`` plus the optional label, under
285
+ ``reports/physics/``, and an existing report is never overwritten.
286
+
287
+ Parameters
288
+ ----------
289
+ run : DriftRun
290
+ The comparison to record.
291
+ out_dir : str or Path
292
+ Target directory, normally ``reports/physics/``.
293
+ date : str, optional
294
+ ISO date stamped into the report; defaults to today.
295
+ label : str, optional
296
+ Stem suffix distinguishing several reports on one day.
297
+
298
+ Returns
299
+ -------
300
+ tuple of Path
301
+ The YAML path and the Markdown path, in that order.
302
+ """
303
+ out_dir = Path(out_dir)
304
+ out_dir.mkdir(parents=True, exist_ok=True)
305
+ date = date or datetime.date.today().isoformat()
306
+ stem = f"DRF-{run.version_a.replace('.', '')}-{run.version_b.replace('.', '')}_{date}"
307
+ if label:
308
+ stem += f"_{label}"
309
+ yaml_path = out_dir / f"{stem}.yaml"
310
+ md_path = out_dir / f"{stem}.md"
311
+ for path in (yaml_path, md_path):
312
+ if path.exists():
313
+ raise FileExistsError(
314
+ f"{path} already exists; drift reports are evidence and are never "
315
+ "overwritten. Pick another date or label."
316
+ )
317
+ counts = run.verdict_counts()
318
+ document = {
319
+ "schema": DRIFT_SCHEMA,
320
+ "fs_version_a": run.version_a,
321
+ "fs_version_b": run.version_b,
322
+ "date": date,
323
+ "package_version": run.package_version,
324
+ "fs_exes": dict(run.fs_exe_names),
325
+ "executor": "LocalExecutor, -hidden --script (SRC-003 pp.279-280)",
326
+ "solver_identity": list(run.solver_identity),
327
+ "summary": counts,
328
+ "cases": {
329
+ result.case_id: {
330
+ "title": result.title,
331
+ "error": result.error,
332
+ "metrics": {
333
+ name: {
334
+ "value_a": float(metric.value_a),
335
+ "value_b": float(metric.value_b),
336
+ "delta": float(metric.delta),
337
+ "warn": float(metric.warn),
338
+ "fail": float(metric.fail),
339
+ "kind": metric.kind,
340
+ "verdict": metric.verdict.value,
341
+ }
342
+ for name, metric in result.metrics.items()
343
+ },
344
+ }
345
+ for result in run.results
346
+ },
347
+ }
348
+ yaml_path.write_text(yaml.safe_dump(document, sort_keys=False, width=100), encoding="utf-8")
349
+ md_path.write_text(_render_markdown(run, date, counts), encoding="utf-8")
350
+ return yaml_path, md_path
351
+
352
+
353
+ def _render_markdown(run: DriftRun, date: str, counts: dict[str, int]) -> str:
354
+ """Render the human-readable side of the drift report."""
355
+ a, b = run.version_a, run.version_b
356
+ lines = [
357
+ f"# Drift report: FlightStream {a} versus {b} ({date})",
358
+ "",
359
+ "Cross-version drift evidence produced by `pyfs-qa drift` (FR-27,",
360
+ "SAD Section 11): the committed synthetic physics cases run on both",
361
+ "versions, aggregated coefficients diffed, version B judged against",
362
+ "version A inside the WARN and FAIL half widths the case metrics",
363
+ "declare. Geometry is generated by the suite; no research geometry",
364
+ "is involved.",
365
+ "",
366
+ "## Setup",
367
+ "",
368
+ "| Item | Value |",
369
+ "|---|---|",
370
+ f"| Baseline (A) | FlightStream {a}, {run.fs_exe_names.get(a, '?')} "
371
+ "(local, `_private/exe/`) |",
372
+ f"| Compared (B) | FlightStream {b}, {run.fs_exe_names.get(b, '?')} "
373
+ "(local, `_private/exe/`) |",
374
+ "| Executor | LocalExecutor, `-hidden --script` (SRC-003 pp.279-280) |",
375
+ f"| Package | pyflightstream {run.package_version} |",
376
+ f"| Solver identity | {'; '.join(run.solver_identity) or 'none captured'} |",
377
+ "",
378
+ "## Summary",
379
+ "",
380
+ f"{counts['pass']} pass, {counts['warn']} warn, {counts['fail']} fail, "
381
+ f"{counts['no_reference']} without declared bands.",
382
+ "",
383
+ ]
384
+ for result in run.results:
385
+ lines.extend([f"## {result.case_id}: {result.title}", ""])
386
+ if result.error is not None:
387
+ lines.extend([f"Case aborted: {result.error}", ""])
388
+ continue
389
+ lines.extend(
390
+ [
391
+ f"| Metric | {a} (A) | {b} (B) | delta | Bands (warn/fail) | Verdict |",
392
+ "|---|---|---|---|---|---|",
393
+ ]
394
+ )
395
+ for name, metric in result.metrics.items():
396
+ band_cell = (
397
+ "-"
398
+ if metric.verdict is Verdict.NO_REFERENCE
399
+ else f"{metric.warn:g}/{metric.fail:g} ({metric.kind})"
400
+ )
401
+ lines.append(
402
+ f"| {name} | {metric.value_a:.5f} | {metric.value_b:.5f} "
403
+ f"| {metric.delta:+.5f} | {band_cell} | {metric.verdict.value} |"
404
+ )
405
+ lines.append("")
406
+ return "\n".join(lines)