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,1744 @@
1
+ """Tier 3 physics regression harness (SAD Section 11).
2
+
3
+ Pipeline role: runs the committed synthetic physics cases on a licensed
4
+ machine, compares every measured metric against its stored reference
5
+ inside WARN and FAIL tolerance bands, and writes the physics report
6
+ under ``reports/physics/``. References live as package data in
7
+ ``qa/references/`` and change only through :func:`update_reference`,
8
+ which demands a reason string; a reference update never shares a commit
9
+ with code changes (SAD Section 11).
10
+
11
+ The delivered cases share one synthetic NACA 0012 rectangular wing
12
+ (written by :mod:`pyflightstream.qa.geometry`, no research geometry).
13
+ PHY-01 is the wing polar: an angle-of-attack sweep whose metrics are
14
+ the total lift coefficient per angle, the lift slope, and the induced
15
+ drag at the reference angle. Physical anchor: finite-wing theory puts
16
+ the lift slope of an aspect-ratio-8 wing near 2*pi / (1 + 2/AR) = 5.0
17
+ per radian, so a grossly wrong slope points at a broken import or
18
+ symmetry setup rather than solver drift. PHY-02 is the symmetry
19
+ equivalence: the open-root half wing under MIRROR symmetry with
20
+ symmetry loads enabled must reproduce the full-span coefficients on
21
+ the same full planform reference area; its metrics are the two lift
22
+ coefficients and their near-zero deltas.
23
+
24
+ The SMI class (FR-27) adds local-only drift cases over the research
25
+ simulation files: they run only when an explicit ``smi_root`` is
26
+ given, the geometry never enters Git, and the committed reports carry
27
+ aggregated Total coefficients plus the sha256 of the opened file
28
+ (CLAUDE.md invariant 5).
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import datetime
34
+ import enum
35
+ import hashlib
36
+ from collections.abc import Callable
37
+ from dataclasses import dataclass, field
38
+ from importlib import resources
39
+ from pathlib import Path
40
+
41
+ import numpy as np
42
+ import yaml
43
+
44
+ import pyflightstream
45
+ from pyflightstream.qa.geometry import BladeSpec, WingSpec, generate_blade_stl, generate_wing_stl
46
+ from pyflightstream.results import IncompleteOutputError, LoadsReport, parse_loads
47
+ from pyflightstream.run import ExecutionResult, LocalExecutor
48
+ from pyflightstream.script import Script
49
+ from pyflightstream.versions import resolve
50
+
51
+ PHYSICS_SCHEMA = "pyflightstream-physics-report/1"
52
+ REFERENCE_SCHEMA = "pyflightstream-physics-reference/1"
53
+
54
+ __all__ = [
55
+ "Verdict",
56
+ "MetricSpec",
57
+ "ReferenceBand",
58
+ "CaseReference",
59
+ "PointResult",
60
+ "CaseResult",
61
+ "PhysicsRun",
62
+ "PhysicsEnvironmentError",
63
+ "PHYSICS_CASES",
64
+ "SMI_CASES",
65
+ "registered_cases",
66
+ "build_phy01_script",
67
+ "build_phy02_script",
68
+ "build_phy05_script",
69
+ "build_phy06_unsteady_script",
70
+ "build_smi_script",
71
+ "compare_metrics",
72
+ "load_reference",
73
+ "update_reference",
74
+ "run_physics",
75
+ "write_physics_report",
76
+ ]
77
+
78
+
79
+ class PhysicsEnvironmentError(RuntimeError):
80
+ """The physics run cannot start as configured.
81
+
82
+ Raised before any case runs: a missing executable or an unknown
83
+ case identifier must surface immediately, not after minutes of
84
+ solver time.
85
+ """
86
+
87
+
88
+ class Verdict(enum.StrEnum):
89
+ """Judgment of one measured metric against its stored reference.
90
+
91
+ ``PASS``: inside the WARN band. ``WARN``: outside WARN but inside
92
+ FAIL; the metric moved and deserves triage, the suite keeps going.
93
+ ``FAIL``: outside the FAIL band; a physics regression, a database
94
+ error, or a stale reference (the triage of the run-physics skill).
95
+ ``NO_REFERENCE``: no stored reference yet; the measured value is
96
+ reported so :func:`update_reference` can seed one.
97
+ """
98
+
99
+ PASS = "pass"
100
+ WARN = "warn"
101
+ FAIL = "fail"
102
+ NO_REFERENCE = "no_reference"
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class MetricSpec:
107
+ """Declaration of one case metric and its default tolerance bands.
108
+
109
+ Attributes
110
+ ----------
111
+ name : str
112
+ Metric identifier, for example ``"CL_a4"``.
113
+ description : str
114
+ What the metric measures, with units where applicable.
115
+ kind : str
116
+ ``"rel"``: bands are fractions of the reference magnitude,
117
+ for O(1) coefficients. ``"abs"``: bands are absolute half
118
+ widths in the metric's own unit, for near-zero metrics whose
119
+ relative error is meaningless (a symmetric section's CL at
120
+ zero incidence, for example).
121
+ warn : float
122
+ Default WARN half width used when seeding a reference.
123
+ fail : float
124
+ Default FAIL half width; must exceed ``warn``.
125
+ """
126
+
127
+ name: str
128
+ description: str
129
+ kind: str = "rel"
130
+ warn: float = 0.02
131
+ fail: float = 0.05
132
+
133
+ def __post_init__(self) -> None:
134
+ """Reject band declarations that could never judge coherently."""
135
+ if self.kind not in ("rel", "abs"):
136
+ raise ValueError(f"metric {self.name}: kind must be 'rel' or 'abs', got {self.kind!r}")
137
+ if not 0 < self.warn < self.fail:
138
+ raise ValueError(
139
+ f"metric {self.name}: bands need 0 < warn < fail, got {self.warn}, {self.fail}"
140
+ )
141
+
142
+
143
+ @dataclass(frozen=True)
144
+ class ReferenceBand:
145
+ """Stored reference value of one metric with its tolerance bands.
146
+
147
+ Attributes mirror :class:`MetricSpec` bands; ``value`` is the
148
+ accepted metric value the bands center on.
149
+ """
150
+
151
+ value: float
152
+ warn: float
153
+ fail: float
154
+ kind: str
155
+
156
+ def judge(self, measured: float) -> Verdict:
157
+ """Judge a measured value against this band."""
158
+ deviation = abs(measured - self.value)
159
+ scale = abs(self.value) if self.kind == "rel" else 1.0
160
+ if deviation <= self.warn * scale:
161
+ return Verdict.PASS
162
+ if deviation <= self.fail * scale:
163
+ return Verdict.WARN
164
+ return Verdict.FAIL
165
+
166
+
167
+ @dataclass(frozen=True)
168
+ class CaseReference:
169
+ """The committed reference of one physics case.
170
+
171
+ Attributes
172
+ ----------
173
+ case_id : str
174
+ Case identifier (``PHY-01`` .. ``PHY-06``).
175
+ fs_version_basis : str
176
+ Canonical FlightStream version the reference values came from.
177
+ updated : str
178
+ ISO date of the last reference update.
179
+ reason : str
180
+ The reason string recorded by :func:`update_reference`.
181
+ metrics : dict of str to ReferenceBand
182
+ Reference value and bands per metric.
183
+ """
184
+
185
+ case_id: str
186
+ fs_version_basis: str
187
+ updated: str
188
+ reason: str
189
+ metrics: dict[str, ReferenceBand]
190
+
191
+
192
+ @dataclass(frozen=True)
193
+ class PointResult:
194
+ """One solved sweep point of a physics case.
195
+
196
+ Attributes
197
+ ----------
198
+ alpha_deg : float
199
+ Angle of attack in degrees, positive nose up.
200
+ total : dict of str to float
201
+ The Total coefficient row of the loads spreadsheet (CL, CDi,
202
+ and companions), analysis-frame coefficients.
203
+ iterations : int
204
+ Solver iteration counter at export.
205
+ converged : bool
206
+ Whether the solver stopped below its iteration limit (the
207
+ steady-mode convergence signal of the M2 assessor).
208
+ label : str
209
+ Point identifier shown in reports; distinguishes points that
210
+ share an angle, such as the full and half models of PHY-02.
211
+ Empty defaults to the angle itself.
212
+ """
213
+
214
+ alpha_deg: float
215
+ total: dict[str, float]
216
+ iterations: int
217
+ converged: bool
218
+ label: str = ""
219
+
220
+
221
+ @dataclass(frozen=True)
222
+ class CaseResult:
223
+ """Everything one physics case produced in one run.
224
+
225
+ Attributes
226
+ ----------
227
+ case_id, title : str
228
+ Case identity.
229
+ geometry : str
230
+ One-line description of the generated geometry.
231
+ points : tuple of PointResult
232
+ Solved sweep points, in sweep order.
233
+ metrics : dict of str to float
234
+ Measured metric values.
235
+ verdicts : dict of str to Verdict
236
+ Judgment per metric (``NO_REFERENCE`` when no reference file
237
+ exists yet).
238
+ reference : CaseReference or None
239
+ The stored reference used, when one exists.
240
+ error : str or None
241
+ Failure description when the case aborted; a failed case
242
+ reports instead of raising so one broken case cannot hide the
243
+ others' evidence.
244
+ """
245
+
246
+ case_id: str
247
+ title: str
248
+ geometry: str
249
+ points: tuple[PointResult, ...] = ()
250
+ metrics: dict[str, float] = field(default_factory=dict)
251
+ verdicts: dict[str, Verdict] = field(default_factory=dict)
252
+ reference: CaseReference | None = None
253
+ error: str | None = None
254
+
255
+
256
+ @dataclass(frozen=True)
257
+ class PhysicsRun:
258
+ """One complete Tier 3 physics run on a licensed machine."""
259
+
260
+ version: str
261
+ fs_exe_name: str
262
+ package_version: str
263
+ results: tuple[CaseResult, ...]
264
+ solver_identity: tuple[str, ...] = ()
265
+
266
+ def verdict_counts(self) -> dict[str, int]:
267
+ """Count metric verdicts over every case, for the summary line."""
268
+ counts = {verdict.value: 0 for verdict in Verdict}
269
+ for result in self.results:
270
+ for verdict in result.verdicts.values():
271
+ counts[verdict.value] += 1
272
+ return counts
273
+
274
+
275
+ # --------------------------------------------------------------------------
276
+ # PHY-01 (NACA wing polar) and PHY-02 (half versus full equivalence)
277
+ # --------------------------------------------------------------------------
278
+
279
+ PHY01_WING = WingSpec(naca="0012", chord_m=1.0, span_m=8.0, n_chord=25, n_span=40)
280
+ PHY01_ALPHAS_DEG = (0.0, 2.0, 4.0, 6.0)
281
+ PHY01_VELOCITY_M_S = 30.0
282
+ PHY01_ITERATIONS = 500
283
+ PHY01_CONVERGENCE = 1.0e-5
284
+ PHY02_ALPHA_DEG = 4.0
285
+
286
+
287
+ def _build_wing_point_script(
288
+ case_id: str,
289
+ version: str,
290
+ alpha_deg: float,
291
+ stl_path: str | Path,
292
+ loads_name: str,
293
+ log_name: str,
294
+ symmetry: str = "NONE",
295
+ symmetry_loads: str | None = None,
296
+ unsteady: tuple[int, float] | None = None,
297
+ ) -> Script:
298
+ """Build the one-point wing script the PHY cases share.
299
+
300
+ Every command is validated against the version's database view;
301
+ the fluid state is set through FLUID_PROPERTIES (verified on
302
+ 26.120) rather than AIR_ALTITUDE, whose units argument the full
303
+ compat sweep judged broken (CMP-26120_2026-07-21_full). The
304
+ reference area is always the full planform, so a MIRROR half
305
+ model with symmetry loads enabled must reproduce the full-span
306
+ coefficients (the PHY-02 equivalence).
307
+ """
308
+ script = Script(version=version)
309
+ script.comment(
310
+ f"{case_id} NACA wing, alpha {alpha_deg:+.1f} deg, symmetry {symmetry} "
311
+ "(Tier 3, SAD Section 11)"
312
+ )
313
+ script.emit("NEW_SIMULATION")
314
+ script.emit("IMPORT", "METER", "STL", str(stl_path), clear=True)
315
+ script.emit("SET_SIMULATION_LENGTH_UNITS", "METER")
316
+ script.emit("AUTO_DETECT_TRAILING_EDGES")
317
+ script.emit("AUTO_DETECT_WAKE_TERMINATION_NODES")
318
+ script.emit(
319
+ "FLUID_PROPERTIES",
320
+ density=1.225,
321
+ pressure=101325.0,
322
+ temperature=288.15,
323
+ viscosity=1.7894e-05,
324
+ specific_heat_ratio=1.4,
325
+ )
326
+ script.emit("SET_FREESTREAM", "CONSTANT")
327
+ if unsteady is not None:
328
+ # Physical time stepping selected before initialization, the
329
+ # order the 2026-07-21 legacy-case reproduction proved.
330
+ script.emit("SET_SOLVER_UNSTEADY", unsteady[0], unsteady[1])
331
+ script.emit(
332
+ "INITIALIZE_SOLVER",
333
+ solver_model="INCOMPRESSIBLE",
334
+ surfaces=-1,
335
+ wake_termination_x="DEFAULT",
336
+ symmetry=symmetry,
337
+ wall_collision_avoidance="DISABLE",
338
+ )
339
+ script.emit("SOLVER_SET_AOA", alpha_deg)
340
+ script.emit("SOLVER_SET_VELOCITY", PHY01_VELOCITY_M_S)
341
+ script.emit("SOLVER_SET_REF_VELOCITY", PHY01_VELOCITY_M_S)
342
+ script.emit("SOLVER_SET_REF_AREA", PHY01_WING.area_m2)
343
+ script.emit("SOLVER_SET_REF_LENGTH", PHY01_WING.chord_m)
344
+ script.emit("SOLVER_SET_ITERATIONS", PHY01_ITERATIONS)
345
+ script.emit("SOLVER_SET_CONVERGENCE", PHY01_CONVERGENCE)
346
+ if symmetry_loads is not None:
347
+ # Phase init since the legacy-case reproduction of 2026-07-21:
348
+ # the setting is consumed during the solve by the unsteady
349
+ # per-step monitors, so it precedes START_SOLVER; the exported
350
+ # loads read the same state either way (HND-013 calibration).
351
+ script.emit("SET_ANALYSIS_SYMMETRY_LOADS", symmetry_loads)
352
+ script.emit("START_SOLVER")
353
+ script.emit("SET_VORTICITY_DRAG_BOUNDARIES", -1)
354
+ script.emit("SET_LOADS_AND_MOMENTS_UNITS", "COEFFICIENTS")
355
+ script.emit("EXPORT_SOLVER_ANALYSIS_SPREADSHEET", loads_name)
356
+ script.emit("EXPORT_LOG", log_name)
357
+ script.emit("CLOSE_FLIGHTSTREAM")
358
+ return script
359
+
360
+
361
+ def build_phy01_script(
362
+ version: str,
363
+ alpha_deg: float,
364
+ stl_path: str | Path,
365
+ loads_name: str,
366
+ log_name: str,
367
+ ) -> Script:
368
+ """Build the PHY-01 script for one angle of attack.
369
+
370
+ Parameters
371
+ ----------
372
+ version : str
373
+ Target FlightStream version, canonical or alias.
374
+ alpha_deg : float
375
+ Angle of attack in degrees, positive nose up.
376
+ stl_path : str or Path
377
+ The generated full-span wing STL to import (meters).
378
+ loads_name : str
379
+ File name of the loads spreadsheet exported into the working
380
+ directory.
381
+ log_name : str
382
+ File name of the exported solver log.
383
+
384
+ Returns
385
+ -------
386
+ Script
387
+ The validated script, ready to render.
388
+ """
389
+ return _build_wing_point_script("PHY-01", version, alpha_deg, stl_path, loads_name, log_name)
390
+
391
+
392
+ def build_phy02_script(
393
+ version: str,
394
+ half: bool,
395
+ stl_path: str | Path,
396
+ loads_name: str,
397
+ log_name: str,
398
+ ) -> Script:
399
+ """Build one PHY-02 script: the full baseline or the mirrored half.
400
+
401
+ The half model initializes with MIRROR symmetry and asks for the
402
+ symmetry-plane loads explicitly (SET_ANALYSIS_SYMMETRY_LOADS
403
+ ENABLE, SRC-003 p.350) rather than relying on the solver default,
404
+ which the 2026-07-21 calibration on 26.120 observed to be ENABLE
405
+ after a MIRROR initialization.
406
+
407
+ Parameters
408
+ ----------
409
+ version : str
410
+ Target FlightStream version, canonical or alias.
411
+ half : bool
412
+ Build the mirrored open-root half model instead of the
413
+ full-span baseline.
414
+ stl_path : str or Path
415
+ The matching generated STL (half or full) to import (meters).
416
+ loads_name, log_name : str
417
+ Output file names, written into the working directory.
418
+
419
+ Returns
420
+ -------
421
+ Script
422
+ The validated script, ready to render.
423
+ """
424
+ return _build_wing_point_script(
425
+ "PHY-02",
426
+ version,
427
+ PHY02_ALPHA_DEG,
428
+ stl_path,
429
+ loads_name,
430
+ log_name,
431
+ symmetry="MIRROR" if half else "NONE",
432
+ symmetry_loads="ENABLE" if half else None,
433
+ )
434
+
435
+
436
+ def phy01_metrics(points: list[PointResult]) -> dict[str, float]:
437
+ """Reduce the PHY-01 sweep points to the case metrics.
438
+
439
+ The lift slope comes from a least-squares line over the whole
440
+ sweep, in per-radian units so it lands next to the finite-wing
441
+ anchor 2*pi / (1 + 2/AR).
442
+ """
443
+ metrics: dict[str, float] = {}
444
+ for point in points:
445
+ tag = f"a{point.alpha_deg:g}"
446
+ metrics[f"CL_{tag}"] = point.total["CL"]
447
+ alphas_rad = np.radians([point.alpha_deg for point in points])
448
+ lifts = [point.total["CL"] for point in points]
449
+ metrics["CL_slope_per_rad"] = float(np.polyfit(alphas_rad, lifts, 1)[0])
450
+ reference_point = next(point for point in points if point.alpha_deg == 4.0)
451
+ metrics["CDi_a4"] = reference_point.total["CDi"]
452
+ return metrics
453
+
454
+
455
+ def _run_phy01(context: _CaseContext) -> CaseResult:
456
+ """Run the PHY-01 sweep end to end inside its scratch directory."""
457
+ # The executor sets the solver's working directory, so bare output
458
+ # names resolve there; the imported geometry path is absolute so it
459
+ # never depends on where the solver process was started.
460
+ stl_path = generate_wing_stl(PHY01_WING, (context.workdir / "naca0012_full.stl").resolve())
461
+ points: list[PointResult] = []
462
+ for alpha in PHY01_ALPHAS_DEG:
463
+ tag = f"a{alpha:g}".replace("-", "m").replace(".", "p")
464
+ loads_name = f"loads_{tag}.txt"
465
+ script = build_phy01_script(context.version, alpha, stl_path, loads_name, f"log_{tag}.txt")
466
+ report = context.solve_point(script, f"phy01_{tag}.txt", loads_name)
467
+ points.append(
468
+ PointResult(
469
+ alpha_deg=alpha,
470
+ total=dict(report.total),
471
+ iterations=report.current_iteration,
472
+ converged=report.current_iteration < report.requested_iterations,
473
+ label=tag,
474
+ )
475
+ )
476
+ context.stamp_solver(report)
477
+ geometry = (
478
+ f"NACA {PHY01_WING.naca} rectangular wing, chord {PHY01_WING.chord_m:g} m, "
479
+ f"span {PHY01_WING.span_m:g} m (AR {PHY01_WING.aspect_ratio:g}), full span, "
480
+ "generated by qa.geometry as ASCII STL"
481
+ )
482
+ return CaseResult(
483
+ case_id="PHY-01",
484
+ title=PHYSICS_CASES["PHY-01"].title,
485
+ geometry=geometry,
486
+ points=tuple(points),
487
+ metrics=phy01_metrics(points),
488
+ )
489
+
490
+
491
+ def phy02_metrics(full: PointResult, half: PointResult) -> dict[str, float]:
492
+ """Reduce the PHY-02 pair to the equivalence metrics.
493
+
494
+ Both models use the full planform reference area, so the mirrored
495
+ half must reproduce the full-span coefficients; the deltas are the
496
+ physics content and sit near zero, hence absolute bands.
497
+ """
498
+ return {
499
+ "CL_full_a4": full.total["CL"],
500
+ "CL_half_a4": half.total["CL"],
501
+ "delta_CL_a4": half.total["CL"] - full.total["CL"],
502
+ "delta_CDi_a4": half.total["CDi"] - full.total["CDi"],
503
+ }
504
+
505
+
506
+ def _run_phy02(context: _CaseContext) -> CaseResult:
507
+ """Run the PHY-02 full/half pair inside its scratch directory."""
508
+ points: list[PointResult] = []
509
+ for half in (False, True):
510
+ variant = "half" if half else "full"
511
+ stl_path = generate_wing_stl(
512
+ PHY01_WING, (context.workdir / f"naca0012_{variant}.stl").resolve(), half=half
513
+ )
514
+ loads_name = f"loads_{variant}.txt"
515
+ script = build_phy02_script(
516
+ context.version, half, stl_path, loads_name, f"log_{variant}.txt"
517
+ )
518
+ report = context.solve_point(script, f"phy02_{variant}.txt", loads_name)
519
+ points.append(
520
+ PointResult(
521
+ alpha_deg=PHY02_ALPHA_DEG,
522
+ total=dict(report.total),
523
+ iterations=report.current_iteration,
524
+ converged=report.current_iteration < report.requested_iterations,
525
+ label=variant,
526
+ )
527
+ )
528
+ context.stamp_solver(report)
529
+ full, half_point = points
530
+ geometry = (
531
+ f"NACA {PHY01_WING.naca} rectangular wing, chord {PHY01_WING.chord_m:g} m, "
532
+ f"span {PHY01_WING.span_m:g} m (AR {PHY01_WING.aspect_ratio:g}); full span "
533
+ "versus open-root MIRROR half with symmetry loads enabled, both on the "
534
+ "full planform reference area, generated by qa.geometry as ASCII STL"
535
+ )
536
+ return CaseResult(
537
+ case_id="PHY-02",
538
+ title=PHYSICS_CASES["PHY-02"].title,
539
+ geometry=geometry,
540
+ points=tuple(points),
541
+ metrics=phy02_metrics(full, half_point),
542
+ )
543
+
544
+
545
+ @dataclass(frozen=True)
546
+ class PhysicsCase:
547
+ """One entry of the physics case registry.
548
+
549
+ Attributes
550
+ ----------
551
+ case_id, title : str
552
+ Identity shown in reports.
553
+ metric_specs : tuple of MetricSpec
554
+ Declared metrics with the default bands used at reference
555
+ seeding time.
556
+ runner : callable
557
+ Case implementation; receives the run context and returns the
558
+ measured :class:`CaseResult`.
559
+ """
560
+
561
+ case_id: str
562
+ title: str
563
+ metric_specs: tuple[MetricSpec, ...]
564
+ runner: Callable[[_CaseContext], CaseResult]
565
+ versions: tuple[str, ...] | None = None
566
+
567
+ @property
568
+ def specs_by_name(self) -> dict[str, MetricSpec]:
569
+ """The metric specifications keyed by metric name."""
570
+ return {spec.name: spec for spec in self.metric_specs}
571
+
572
+ def supports(self, canonical: str) -> bool:
573
+ """Whether the case's command set has evidence for ``canonical``.
574
+
575
+ ``versions`` is None for cases whose commands are evidenced on
576
+ every registered version; a tuple restricts the case to the
577
+ listed canonical identifiers (the unsteady cases are 26.120
578
+ only until the motion and unsteady chapters are backfilled).
579
+ """
580
+ return self.versions is None or canonical in self.versions
581
+
582
+
583
+ PHY06_ALPHAS_DEG = (0.0, 2.0, 4.0, 6.0)
584
+ PHY06_TIME_ITERATIONS = 120
585
+ PHY06_DELTA_TIME_S = 0.01
586
+
587
+
588
+ def _phy06_metric_specs() -> tuple[MetricSpec, ...]:
589
+ """Declare the PHY-06 polar-equivalence metrics.
590
+
591
+ Per sweep angle, the steady-versus-unsteady deltas of CL, CD
592
+ (CDi + CDo; the loads Total row carries no combined column), and
593
+ CMy must sit near zero: the time march of the static wing
594
+ asymptotes to the steady solution at every incidence, which is
595
+ the per-alpha trend confirmation. The two lift slopes then pin
596
+ the trend itself against the finite-wing anchor
597
+ 2*pi/(1 + 2/AR) = 5.0 for AR 8, and the two pitching-moment
598
+ slopes pin the CMy trend without an analytic anchor.
599
+ """
600
+ specs: list[MetricSpec] = []
601
+ for alpha in PHY06_ALPHAS_DEG:
602
+ tag = f"a{alpha:g}"
603
+ specs.append(
604
+ MetricSpec(
605
+ f"delta_CL_{tag}",
606
+ f"CL(unsteady) - CL(steady) at {alpha:g} deg",
607
+ kind="abs",
608
+ warn=0.005,
609
+ fail=0.02,
610
+ )
611
+ )
612
+ specs.append(
613
+ MetricSpec(
614
+ f"delta_CD_{tag}",
615
+ f"CD(unsteady) - CD(steady) at {alpha:g} deg (CD = CDi + CDo)",
616
+ kind="abs",
617
+ warn=0.001,
618
+ fail=0.004,
619
+ )
620
+ )
621
+ specs.append(
622
+ MetricSpec(
623
+ f"delta_CMy_{tag}",
624
+ f"CMy(unsteady) - CMy(steady) at {alpha:g} deg",
625
+ kind="abs",
626
+ warn=0.005,
627
+ fail=0.02,
628
+ )
629
+ )
630
+ specs.append(
631
+ MetricSpec(
632
+ "CL_slope_steady_per_rad",
633
+ "least-squares lift slope of the steady polar, 1/rad "
634
+ "(finite-wing anchor 2*pi/(1 + 2/AR) = 5.0 for AR 8)",
635
+ )
636
+ )
637
+ specs.append(
638
+ MetricSpec(
639
+ "CL_slope_unsteady_per_rad",
640
+ "least-squares lift slope of the unsteady-final polar, 1/rad; "
641
+ "must match the steady slope inside the band",
642
+ )
643
+ )
644
+ specs.append(
645
+ MetricSpec(
646
+ "CMy_slope_steady_per_rad",
647
+ "least-squares pitching-moment slope of the steady polar, 1/rad",
648
+ )
649
+ )
650
+ specs.append(
651
+ MetricSpec(
652
+ "CMy_slope_unsteady_per_rad",
653
+ "least-squares pitching-moment slope of the unsteady-final polar, 1/rad",
654
+ )
655
+ )
656
+ return tuple(specs)
657
+
658
+
659
+ PHYSICS_CASES: dict[str, PhysicsCase] = {
660
+ "PHY-01": PhysicsCase(
661
+ case_id="PHY-01",
662
+ title="NACA wing polar (synthetic NACA 0012, AR 8)",
663
+ metric_specs=(
664
+ MetricSpec(
665
+ "CL_a0",
666
+ "total CL at 0 deg; 0 by symmetry, so the band is absolute",
667
+ kind="abs",
668
+ warn=0.005,
669
+ fail=0.02,
670
+ ),
671
+ MetricSpec("CL_a2", "total CL at 2 deg incidence"),
672
+ MetricSpec("CL_a4", "total CL at 4 deg incidence"),
673
+ MetricSpec("CL_a6", "total CL at 6 deg incidence"),
674
+ MetricSpec(
675
+ "CL_slope_per_rad",
676
+ "least-squares lift slope over the sweep, 1/rad "
677
+ "(finite-wing anchor 2*pi/(1 + 2/AR) = 5.0 for AR 8)",
678
+ ),
679
+ MetricSpec(
680
+ "CDi_a4",
681
+ "induced drag coefficient at 4 deg",
682
+ warn=0.05,
683
+ fail=0.15,
684
+ ),
685
+ ),
686
+ runner=_run_phy01,
687
+ ),
688
+ "PHY-02": PhysicsCase(
689
+ case_id="PHY-02",
690
+ title="Half versus full symmetry equivalence (NACA 0012, AR 8)",
691
+ metric_specs=(
692
+ MetricSpec("CL_full_a4", "total CL of the full-span baseline at 4 deg"),
693
+ MetricSpec(
694
+ "CL_half_a4",
695
+ "total CL of the mirrored half model at 4 deg, symmetry loads enabled",
696
+ ),
697
+ MetricSpec(
698
+ "delta_CL_a4",
699
+ "CL(half) - CL(full) at 4 deg; zero in exact equivalence",
700
+ kind="abs",
701
+ warn=0.005,
702
+ fail=0.02,
703
+ ),
704
+ MetricSpec(
705
+ "delta_CDi_a4",
706
+ "CDi(half) - CDi(full) at 4 deg; zero in exact equivalence",
707
+ kind="abs",
708
+ warn=0.0005,
709
+ fail=0.002,
710
+ ),
711
+ ),
712
+ runner=_run_phy02,
713
+ ),
714
+ "PHY-05": PhysicsCase(
715
+ case_id="PHY-05",
716
+ title="Rigid unsteady periodic propeller (generic BladeSpec blade)",
717
+ metric_specs=(
718
+ MetricSpec(
719
+ "CL",
720
+ "total CL of the blade sector at the final step; near zero "
721
+ "as the loads balance around the disc, so the band is absolute",
722
+ kind="abs",
723
+ warn=0.002,
724
+ fail=0.01,
725
+ ),
726
+ MetricSpec(
727
+ "CDi",
728
+ "total axial force coefficient (negative: net thrust) at the final of 54 steps",
729
+ warn=0.01,
730
+ fail=0.03,
731
+ ),
732
+ MetricSpec(
733
+ "CDo",
734
+ "total viscous drag coefficient at the final step; near zero",
735
+ kind="abs",
736
+ warn=0.0005,
737
+ fail=0.002,
738
+ ),
739
+ MetricSpec(
740
+ "CMy",
741
+ "total pitching moment coefficient at the final step",
742
+ warn=0.01,
743
+ fail=0.03,
744
+ ),
745
+ ),
746
+ runner=lambda context: _run_phy05(context),
747
+ versions=("26.120",),
748
+ ),
749
+ "PHY-06": PhysicsCase(
750
+ case_id="PHY-06",
751
+ title="Steady versus unsteady polar equivalence (NACA 0012, AR 8)",
752
+ metric_specs=_phy06_metric_specs(),
753
+ runner=lambda context: _run_phy06(context),
754
+ versions=("26.120",),
755
+ ),
756
+ }
757
+
758
+
759
+ # --------------------------------------------------------------------------
760
+ # PHY-05 (rigid unsteady periodic propeller) and PHY-06 (steady versus
761
+ # unsteady equivalence)
762
+ # --------------------------------------------------------------------------
763
+ #
764
+ # PHY-05 promotes the shareable generic-blade case into the matrix: the
765
+ # BladeSpec blade (public analytic shape laws, qa.geometry) under
766
+ # PERIODIC 6 with rotary motion and physical time stepping, the flow
767
+ # the 2026-07-21 legacy-case reproduction proved command by command.
768
+ # PHY-06 anchors the unsteady solver against the steady one: a time
769
+ # march of the static PHY-01 wing must asymptote to the steady
770
+ # solution. Both are 26.120-only until the motion, coordinate-system,
771
+ # unsteady, and advanced-settings chapters are backfilled for earlier
772
+ # versions.
773
+
774
+ PHY05_BLADE = BladeSpec()
775
+ PHY05_FLUID = {
776
+ "density": 1.225,
777
+ "pressure": 101325.0,
778
+ "temperature": 288.15,
779
+ "viscosity": 1.789e-05,
780
+ "specific_heat_ratio": 1.4,
781
+ }
782
+ PHY05_VELOCITY_M_S = 49.0
783
+ PHY05_REF_AREA_M2 = 10.0
784
+ PHY05_REF_LENGTH_M = 2.0
785
+ PHY05_N_BLADES = 6
786
+ PHY05_ADVANCE_RATIO = 1.7
787
+ # rpm = 60 V / (J D); dt spans 10 deg of rotation; 54 steps make 1.5
788
+ # revolutions and the wake terminates after one revolution.
789
+ PHY05_RPM = round(60.0 * PHY05_VELOCITY_M_S / (PHY05_ADVANCE_RATIO * 2.0 * PHY05_BLADE.r_tip_m), 2)
790
+ PHY05_DELTA_TIME_S = round(10.0 / (6.0 * PHY05_RPM), 6)
791
+ PHY05_TIME_ITERATIONS = 54
792
+ PHY05_WAKE_TERMINATION_STEPS = -36
793
+
794
+
795
+ def build_phy05_script(
796
+ version: str,
797
+ stl_path: str | Path,
798
+ loads_name: str,
799
+ log_name: str,
800
+ ) -> Script:
801
+ """Build the PHY-05 unsteady periodic propeller script.
802
+
803
+ The command content mirrors the proven generic-blade case: three
804
+ identity frames (analysis, rotation, blade axis), rotary motion on
805
+ the rotation frame with the blade-axis frame attached, physical
806
+ time stepping sized to 10 deg per step, PERIODIC 6 initialization
807
+ of the Prandtl-Glauert model, and the symmetry-loads state set
808
+ before the solve (in-solve consumers precede START_SOLVER).
809
+
810
+ Parameters
811
+ ----------
812
+ version : str
813
+ Target FlightStream version, canonical or alias.
814
+ stl_path : str or Path
815
+ The generated blade STL to import (meters, absolute path).
816
+ loads_name, log_name : str
817
+ Output file names, written into the working directory.
818
+
819
+ Returns
820
+ -------
821
+ Script
822
+ The validated script, ready to render.
823
+ """
824
+ script = Script(version=version)
825
+ script.comment("PHY-05 generic-blade unsteady periodic propeller (Tier 3, SAD Section 11)")
826
+ script.emit("NEW_SIMULATION")
827
+ script.emit("IMPORT", "METER", "STL", str(stl_path), clear=True)
828
+ script.emit("SET_SIMULATION_LENGTH_UNITS", "METER")
829
+ script.emit("AUTO_DETECT_TRAILING_EDGES")
830
+ script.emit("AUTO_DETECT_WAKE_TERMINATION_NODES")
831
+ script.emit("SET_SIGNIFICANT_DIGITS", 7)
832
+ for frame, name in ((2, "MRP"), (3, "PROP_MRP"), (4, "BladeAxis1")):
833
+ script.emit("CREATE_NEW_COORDINATE_SYSTEM")
834
+ script.emit(
835
+ "EDIT_COORDINATE_SYSTEM",
836
+ frame=frame,
837
+ name=name,
838
+ origin_x=0.0,
839
+ origin_y=0.0,
840
+ origin_z=0.0,
841
+ vector_x_x=1,
842
+ vector_x_y=0,
843
+ vector_x_z=0,
844
+ vector_y_x=0,
845
+ vector_y_y=1,
846
+ vector_y_z=0,
847
+ vector_z_x=0,
848
+ vector_z_y=0,
849
+ vector_z_z=1,
850
+ )
851
+ script.emit("ROTATE_COORDINATE_SYSTEM", frame=4, rotation_frame=3, rotation_axis="X", angle=0.0)
852
+ script.emit("FLUID_PROPERTIES", **PHY05_FLUID)
853
+ script.emit("SET_FREESTREAM", "CONSTANT")
854
+ script.emit("CREATE_NEW_MOTION", "ROTARY")
855
+ script.emit("SET_MOTION_COORDINATE_SYSTEM", 1, 3)
856
+ script.emit("SET_MOTION_MOVING_FRAMES", 1, 1, [4])
857
+ script.emit("SET_MOTION_ROTOR_RPM", 1, PHY05_RPM)
858
+ script.emit("SET_MOTION_BOUNDARIES", 1, 1, [1])
859
+ script.emit("SOLVER_SET_MESH_INDUCED_WAKE_VELOCITY", "ENABLE")
860
+ script.emit("SOLVER_SET_FARFIELD_LAYERS", 5)
861
+ script.emit("SET_SOLVER_UNSTEADY", PHY05_TIME_ITERATIONS, PHY05_DELTA_TIME_S)
862
+ script.emit("SET_WAKE_TERMINATION_TIME_STEPS", PHY05_WAKE_TERMINATION_STEPS)
863
+ script.emit(
864
+ "INITIALIZE_SOLVER",
865
+ solver_model="SUBSONIC_PRANDTL_GLAUERT",
866
+ surfaces=-1,
867
+ wake_termination_x="DEFAULT",
868
+ symmetry="PERIODIC",
869
+ symmetry_copies=PHY05_N_BLADES,
870
+ wall_collision_avoidance="DISABLE",
871
+ )
872
+ script.emit("SOLVER_SET_VELOCITY", PHY05_VELOCITY_M_S)
873
+ script.emit("SOLVER_SET_REF_VELOCITY", PHY05_VELOCITY_M_S)
874
+ script.emit("SOLVER_SET_ITERATIONS", PHY01_ITERATIONS)
875
+ script.emit("SOLVER_SET_CONVERGENCE", PHY01_CONVERGENCE)
876
+ script.emit("SOLVER_SET_REF_AREA", PHY05_REF_AREA_M2)
877
+ script.emit("SOLVER_SET_REF_LENGTH", PHY05_REF_LENGTH_M)
878
+ script.emit("SET_MAX_PARALLEL_THREADS", 8)
879
+ script.emit("SOLVER_SET_AOA", 0.0)
880
+ script.emit("SOLVER_SET_SIDESLIP", 0.0)
881
+ script.emit("SET_BOUNDARY_LAYER_TYPE", "TURBULENT")
882
+ script.emit("SET_SOLVER_VISCOUS_COUPLING", "DISABLE")
883
+ script.emit("SET_SOLVER_CONVERGENCE_ITERATIONS", 20)
884
+ script.emit("SOLVER_MINIMUM_CP", -100)
885
+ script.emit("ADDITIONAL_WAKE_RELAXATION_ITERATION", "DISABLE")
886
+ script.emit("REYNOLDS_AVERAGED_DRAG_FORCES", "DISABLE")
887
+ script.emit("SET_WAKE_ON_WAKE_INDUCTION", "ENABLE")
888
+ script.emit("SOLVER_UNSTEADY_PRESSURE_AND_KUTTA", "DISABLE")
889
+ script.emit("SET_ANALYSIS_SYMMETRY_LOADS", "DISABLE")
890
+ script.emit("START_SOLVER")
891
+ script.emit("SET_SOLVER_ANALYSIS_LOADS_FRAME", 2)
892
+ script.emit("SET_ANALYSIS_MOMENTS_MODEL", "PRESSURE")
893
+ script.emit("EXPORT_SOLVER_ANALYSIS_SPREADSHEET", loads_name)
894
+ script.emit("EXPORT_LOG", log_name)
895
+ script.emit("CLOSE_FLIGHTSTREAM")
896
+ return script
897
+
898
+
899
+ def _run_phy05(context: _CaseContext) -> CaseResult:
900
+ """Run PHY-05: generate the blade, march 1.5 revolutions, reduce."""
901
+ stl_path = generate_blade_stl(PHY05_BLADE, (context.workdir / "generic_blade.stl").resolve())
902
+ script = build_phy05_script(context.version, stl_path, "loads_prop.txt", "log_prop.txt")
903
+ report = context.solve_point(script, "phy05.txt", "loads_prop.txt")
904
+ point = PointResult(
905
+ alpha_deg=0.0,
906
+ total=dict(report.total),
907
+ iterations=report.current_iteration,
908
+ converged=True,
909
+ label="final_step",
910
+ )
911
+ context.stamp_solver(report)
912
+ spec = PHY05_BLADE
913
+ geometry = (
914
+ f"generic BladeSpec blade, NACA {spec.naca}, R {spec.r_tip_m:g} m, taper "
915
+ f"{spec.chord_root_ratio:g}R to {spec.chord_tip_ratio:g}R, ideal twist anchored "
916
+ f"at beta(0.75R) = {spec.beta_75_deg:g} deg for J {spec.advance_ratio_design:g}; "
917
+ f"one blade under PERIODIC {PHY05_N_BLADES}, {PHY05_TIME_ITERATIONS} steps of "
918
+ f"{PHY05_DELTA_TIME_S:g} s at {PHY05_RPM:g} rev/min"
919
+ )
920
+ metrics = {name: point.total[name] for name in ("CL", "CDi", "CDo", "CMy")}
921
+ return CaseResult(
922
+ case_id="PHY-05",
923
+ title=PHYSICS_CASES["PHY-05"].title,
924
+ geometry=geometry,
925
+ points=(point,),
926
+ metrics=metrics,
927
+ )
928
+
929
+
930
+ def build_phy06_unsteady_script(
931
+ version: str,
932
+ alpha_deg: float,
933
+ stl_path: str | Path,
934
+ loads_name: str,
935
+ log_name: str,
936
+ ) -> Script:
937
+ """Build one PHY-06 unsteady time march of the static PHY-01 wing.
938
+
939
+ Identical to the steady point script at the same angle except that
940
+ physical time stepping is selected before initialization: 120
941
+ steps of 0.01 s at 30 m/s sweep 36 chord lengths of wake past the
942
+ AR-8 wing, deep in the steady asymptote.
943
+ """
944
+ return _build_wing_point_script(
945
+ "PHY-06",
946
+ version,
947
+ alpha_deg,
948
+ stl_path,
949
+ loads_name,
950
+ log_name,
951
+ unsteady=(PHY06_TIME_ITERATIONS, PHY06_DELTA_TIME_S),
952
+ )
953
+
954
+
955
+ def _run_phy06(context: _CaseContext) -> CaseResult:
956
+ """Run PHY-06: the steady and unsteady polars of the same wing."""
957
+ stl_path = generate_wing_stl(PHY01_WING, (context.workdir / "naca0012_full.stl").resolve())
958
+ points: list[PointResult] = []
959
+ steady_by_alpha: dict[float, dict[str, float]] = {}
960
+ unsteady_by_alpha: dict[float, dict[str, float]] = {}
961
+ for alpha in PHY06_ALPHAS_DEG:
962
+ tag = f"a{alpha:g}".replace(".", "p")
963
+ steady_script = _build_wing_point_script(
964
+ "PHY-06",
965
+ context.version,
966
+ alpha,
967
+ stl_path,
968
+ f"loads_steady_{tag}.txt",
969
+ f"log_steady_{tag}.txt",
970
+ )
971
+ steady = context.solve_point(
972
+ steady_script, f"phy06_steady_{tag}.txt", f"loads_steady_{tag}.txt"
973
+ )
974
+ context.stamp_solver(steady)
975
+ steady_by_alpha[alpha] = dict(steady.total)
976
+ points.append(
977
+ PointResult(
978
+ alpha_deg=alpha,
979
+ total=dict(steady.total),
980
+ iterations=steady.current_iteration,
981
+ converged=steady.current_iteration < steady.requested_iterations,
982
+ label=f"steady_{tag}",
983
+ )
984
+ )
985
+ unsteady_script = build_phy06_unsteady_script(
986
+ context.version,
987
+ alpha,
988
+ stl_path,
989
+ f"loads_unsteady_{tag}.txt",
990
+ f"log_unsteady_{tag}.txt",
991
+ )
992
+ unsteady = context.solve_point(
993
+ unsteady_script, f"phy06_unsteady_{tag}.txt", f"loads_unsteady_{tag}.txt"
994
+ )
995
+ context.stamp_solver(unsteady)
996
+ unsteady_by_alpha[alpha] = dict(unsteady.total)
997
+ points.append(
998
+ PointResult(
999
+ alpha_deg=alpha,
1000
+ total=dict(unsteady.total),
1001
+ iterations=unsteady.current_iteration,
1002
+ converged=True,
1003
+ label=f"unsteady_final_{tag}",
1004
+ )
1005
+ )
1006
+ metrics: dict[str, float] = {}
1007
+ for alpha in PHY06_ALPHAS_DEG:
1008
+ tag = f"a{alpha:g}"
1009
+ steady_total = steady_by_alpha[alpha]
1010
+ unsteady_total = unsteady_by_alpha[alpha]
1011
+ cd_steady = steady_total["CDi"] + steady_total["CDo"]
1012
+ cd_unsteady = unsteady_total["CDi"] + unsteady_total["CDo"]
1013
+ metrics[f"delta_CL_{tag}"] = unsteady_total["CL"] - steady_total["CL"]
1014
+ metrics[f"delta_CD_{tag}"] = cd_unsteady - cd_steady
1015
+ metrics[f"delta_CMy_{tag}"] = unsteady_total["CMy"] - steady_total["CMy"]
1016
+ alphas_rad = np.radians(PHY06_ALPHAS_DEG)
1017
+ for label, series in (("steady", steady_by_alpha), ("unsteady", unsteady_by_alpha)):
1018
+ lifts = [series[alpha]["CL"] for alpha in PHY06_ALPHAS_DEG]
1019
+ moments = [series[alpha]["CMy"] for alpha in PHY06_ALPHAS_DEG]
1020
+ metrics[f"CL_slope_{label}_per_rad"] = float(np.polyfit(alphas_rad, lifts, 1)[0])
1021
+ metrics[f"CMy_slope_{label}_per_rad"] = float(np.polyfit(alphas_rad, moments, 1)[0])
1022
+ geometry = (
1023
+ f"NACA {PHY01_WING.naca} rectangular wing, chord {PHY01_WING.chord_m:g} m, "
1024
+ f"span {PHY01_WING.span_m:g} m (AR {PHY01_WING.aspect_ratio:g}), full span, "
1025
+ "generated by qa.geometry as ASCII STL; steady polar versus the "
1026
+ f"unsteady-final polar over alphas {PHY06_ALPHAS_DEG}, "
1027
+ f"{PHY06_TIME_ITERATIONS} steps of {PHY06_DELTA_TIME_S:g} s per march"
1028
+ )
1029
+ return CaseResult(
1030
+ case_id="PHY-06",
1031
+ title=PHYSICS_CASES["PHY-06"].title,
1032
+ geometry=geometry,
1033
+ points=tuple(points),
1034
+ metrics=metrics,
1035
+ )
1036
+
1037
+
1038
+ # --------------------------------------------------------------------------
1039
+ # SMI drift class: local-only cases over the research geometry
1040
+ # --------------------------------------------------------------------------
1041
+ #
1042
+ # The SMI simulation files live under _private/geometry/smi/ and never
1043
+ # enter Git (CLAUDE.md invariant 5); these cases run only when the run
1044
+ # is given an explicit --smi-root, and the committed reports carry the
1045
+ # aggregated Total coefficients plus the sha256 of the opened file,
1046
+ # never the geometry itself. Reference values use the unit reference
1047
+ # area and length convention (coefficients scale consistently on both
1048
+ # sides of any comparison, which is all drift needs).
1049
+
1050
+ SMI_ALPHA_DEG = 2.0
1051
+ SMI_VELOCITY_M_S = 30.0
1052
+
1053
+ _SMI_METRIC_NAMES = ("CL", "CDi", "CDo", "CMy")
1054
+
1055
+
1056
+ def _smi_metric_specs(kind: str) -> tuple[MetricSpec, ...]:
1057
+ """Build the four aggregated-coefficient specs of one SMI case.
1058
+
1059
+ Band kind is a per-case calibration decided from the first 26.120
1060
+ measurement (PHY-26120_2026-07-21_smi): the isolated body's
1061
+ coefficients sit near zero, where only absolute half widths make
1062
+ sense, while the full configuration's unit-reference coefficients
1063
+ are O(1..100), where the same absolute width would be absurdly
1064
+ tight and relative bands say what the research means by drift.
1065
+ """
1066
+ if kind == "abs":
1067
+ bands = {
1068
+ "CL": (0.005, 0.02),
1069
+ "CDi": (0.002, 0.01),
1070
+ "CDo": (0.002, 0.01),
1071
+ "CMy": (0.005, 0.02),
1072
+ }
1073
+ else:
1074
+ bands = {name: (0.005, 0.02) for name in _SMI_METRIC_NAMES}
1075
+ descriptions = {
1076
+ "CL": "aggregated total lift coefficient at 2 deg (unit reference area)",
1077
+ "CDi": "aggregated induced drag coefficient at 2 deg",
1078
+ "CDo": "aggregated viscous drag coefficient at 2 deg",
1079
+ "CMy": "aggregated pitching moment coefficient at 2 deg (unit reference length)",
1080
+ }
1081
+ return tuple(
1082
+ MetricSpec(name, descriptions[name], kind=kind, warn=bands[name][0], fail=bands[name][1])
1083
+ for name in _SMI_METRIC_NAMES
1084
+ )
1085
+
1086
+
1087
+ def build_smi_script(
1088
+ version: str,
1089
+ fsm_path: str | Path,
1090
+ loads_name: str,
1091
+ log_name: str,
1092
+ ) -> Script:
1093
+ """Build the one-point script an SMI drift case runs.
1094
+
1095
+ Opens the local simulation file and applies the minimal steady
1096
+ setup the M2 pipeline shaped and the M3 preludes proved on this
1097
+ corpus: constant free stream, sea-level ISA through
1098
+ FLUID_PROPERTIES (AIR_ALTITUDE is broken on 26.120), steady
1099
+ incompressible initialization over every boundary, and a converged
1100
+ solve at the fixed comparison point.
1101
+
1102
+ Parameters
1103
+ ----------
1104
+ version : str
1105
+ Target FlightStream version, canonical or alias.
1106
+ fsm_path : str or Path
1107
+ Local .fsm file; must be absolute (the solver runs inside the
1108
+ case scratch directory).
1109
+ loads_name, log_name : str
1110
+ Output file names, written into the working directory.
1111
+
1112
+ Returns
1113
+ -------
1114
+ Script
1115
+ The validated script, ready to render.
1116
+ """
1117
+ script = Script(version=version)
1118
+ script.comment(
1119
+ f"SMI drift point, alpha {SMI_ALPHA_DEG:+.1f} deg, unit references "
1120
+ "(Tier 3, SAD Section 11; geometry local only)"
1121
+ )
1122
+ script.emit("OPEN", str(fsm_path))
1123
+ script.emit(
1124
+ "FLUID_PROPERTIES",
1125
+ density=1.225,
1126
+ pressure=101325.0,
1127
+ temperature=288.15,
1128
+ viscosity=1.7894e-05,
1129
+ specific_heat_ratio=1.4,
1130
+ )
1131
+ script.emit("SET_FREESTREAM", "CONSTANT")
1132
+ script.emit("SET_SOLVER_STEADY")
1133
+ script.emit(
1134
+ "INITIALIZE_SOLVER",
1135
+ solver_model="INCOMPRESSIBLE",
1136
+ surfaces=-1,
1137
+ wake_termination_x="DEFAULT",
1138
+ symmetry="NONE",
1139
+ wall_collision_avoidance="DISABLE",
1140
+ )
1141
+ script.emit("SOLVER_SET_AOA", SMI_ALPHA_DEG)
1142
+ script.emit("SOLVER_SET_VELOCITY", SMI_VELOCITY_M_S)
1143
+ script.emit("SOLVER_SET_REF_VELOCITY", SMI_VELOCITY_M_S)
1144
+ script.emit("SOLVER_SET_REF_AREA", 1.0)
1145
+ script.emit("SOLVER_SET_REF_LENGTH", 1.0)
1146
+ script.emit("SOLVER_SET_ITERATIONS", PHY01_ITERATIONS)
1147
+ script.emit("SOLVER_SET_CONVERGENCE", PHY01_CONVERGENCE)
1148
+ script.emit("START_SOLVER")
1149
+ script.emit("SET_VORTICITY_DRAG_BOUNDARIES", -1)
1150
+ script.emit("SET_LOADS_AND_MOMENTS_UNITS", "COEFFICIENTS")
1151
+ script.emit("EXPORT_SOLVER_ANALYSIS_SPREADSHEET", loads_name)
1152
+ script.emit("EXPORT_LOG", log_name)
1153
+ script.emit("CLOSE_FLIGHTSTREAM")
1154
+ return script
1155
+
1156
+
1157
+ def smi_metrics(point: PointResult) -> dict[str, float]:
1158
+ """Reduce one SMI point to its aggregated coefficient metrics."""
1159
+ return {name: point.total[name] for name in _SMI_METRIC_NAMES}
1160
+
1161
+
1162
+ def _make_smi_runner(case_id: str, fsm_name: str, title: str):
1163
+ """Build the runner of one SMI case bound to its local file name."""
1164
+
1165
+ def run(context: _CaseContext) -> CaseResult:
1166
+ if context.smi_root is None:
1167
+ raise RuntimeError(
1168
+ f"{case_id} needs the local SMI geometry root; pass --smi-root "
1169
+ "(the files never enter Git, CLAUDE.md invariant 5)"
1170
+ )
1171
+ fsm_path = (Path(context.smi_root) / fsm_name).resolve()
1172
+ if not fsm_path.is_file():
1173
+ raise RuntimeError(f"{case_id}: {fsm_name} not found under {context.smi_root}")
1174
+ digest = hashlib.sha256(fsm_path.read_bytes()).hexdigest()
1175
+ script = build_smi_script(context.version, fsm_path, "loads_smi.txt", "log_smi.txt")
1176
+ report = context.solve_point(
1177
+ script, f"{case_id.lower().replace('-', '_')}.txt", "loads_smi.txt"
1178
+ )
1179
+ point = PointResult(
1180
+ alpha_deg=SMI_ALPHA_DEG,
1181
+ total=dict(report.total),
1182
+ iterations=report.current_iteration,
1183
+ converged=report.current_iteration < report.requested_iterations,
1184
+ label=fsm_name.removesuffix(".fsm"),
1185
+ )
1186
+ context.stamp_solver(report)
1187
+ geometry = (
1188
+ f"SMI case {fsm_name} (local, never committed), sha256 {digest}; "
1189
+ "unit reference area and length, aggregated coefficients only"
1190
+ )
1191
+ return CaseResult(
1192
+ case_id=case_id,
1193
+ title=title,
1194
+ geometry=geometry,
1195
+ points=(point,),
1196
+ metrics=smi_metrics(point),
1197
+ )
1198
+
1199
+ return run
1200
+
1201
+
1202
+ def _smi_case(case_id: str, fsm_name: str, title: str, band_kind: str) -> PhysicsCase:
1203
+ return PhysicsCase(
1204
+ case_id=case_id,
1205
+ title=title,
1206
+ metric_specs=_smi_metric_specs(band_kind),
1207
+ runner=_make_smi_runner(case_id, fsm_name, title),
1208
+ )
1209
+
1210
+
1211
+ SMI_CASES: dict[str, PhysicsCase] = {
1212
+ "SMI-01": _smi_case(
1213
+ "SMI-01",
1214
+ "28_B.fsm",
1215
+ "SMI isolated body (28_B, smallest corpus file)",
1216
+ band_kind="abs",
1217
+ ),
1218
+ "SMI-02": _smi_case(
1219
+ "SMI-02",
1220
+ "31_WBH_IH0.fsm",
1221
+ "SMI full configuration (31_WBH_IH0, wing-body-tail)",
1222
+ band_kind="rel",
1223
+ ),
1224
+ }
1225
+
1226
+
1227
+ # --------------------------------------------------------------------------
1228
+ # Run machinery
1229
+ # --------------------------------------------------------------------------
1230
+
1231
+
1232
+ class _CaseContext:
1233
+ """Execution context handed to case runners.
1234
+
1235
+ Owns the executor, the per-case scratch directory, and the loads
1236
+ parsing of each solved point, so case runners stay declarative.
1237
+ """
1238
+
1239
+ def __init__(
1240
+ self,
1241
+ version: str,
1242
+ executor: LocalExecutor,
1243
+ workdir: Path,
1244
+ timeout_s: float,
1245
+ smi_root: Path | None = None,
1246
+ ):
1247
+ self.version = version
1248
+ self.executor = executor
1249
+ self.workdir = workdir
1250
+ self.timeout_s = timeout_s
1251
+ self.smi_root = smi_root
1252
+ self.solver_identity: list[str] = []
1253
+
1254
+ def solve_point(self, script: Script, script_name: str, loads_name: str) -> LoadsReport:
1255
+ """Run one rendered script and parse its loads spreadsheet.
1256
+
1257
+ Raises
1258
+ ------
1259
+ RuntimeError
1260
+ When the solver fails, times out, or leaves the loads
1261
+ spreadsheet missing or unparseable; the message carries
1262
+ the failure evidence for the case error field.
1263
+ """
1264
+ # Absolute on purpose: the executor sets the solver's working
1265
+ # directory to workdir, so a workdir-relative script path would
1266
+ # be resolved against itself and FlightStream exits silently
1267
+ # (code 0, no outputs) when --script names a missing file.
1268
+ script_path = (self.workdir / script_name).resolve()
1269
+ script_path.write_text(script.render(), encoding="utf-8")
1270
+ result: ExecutionResult = self.executor.run_script(
1271
+ script_path, working_dir=self.workdir, timeout_s=self.timeout_s
1272
+ )
1273
+ if result.failed:
1274
+ evidence = result.log_text or result.stderr or f"return code {result.return_code}"
1275
+ if result.timed_out:
1276
+ evidence = f"timed out after {result.wall_time_s:.0f} s"
1277
+ raise RuntimeError(f"solver run {script_name} failed: {evidence}")
1278
+ loads_path = self.workdir / loads_name
1279
+ try:
1280
+ text = loads_path.read_text(encoding="utf-8", errors="replace")
1281
+ except OSError as error:
1282
+ raise RuntimeError(
1283
+ f"solver run {script_name} left no loads spreadsheet {loads_name}: {error}"
1284
+ ) from error
1285
+ try:
1286
+ return parse_loads(text, requested_version=self.version)
1287
+ except (IncompleteOutputError, ValueError) as error:
1288
+ raise RuntimeError(f"loads spreadsheet {loads_name} unusable: {error}") from error
1289
+
1290
+ def stamp_solver(self, report: LoadsReport) -> None:
1291
+ """Record the solver identity printed in a loads footer (FR-18)."""
1292
+ line = f"Flightstream version {report.fs_version_reported}, build {report.fs_build}"
1293
+ if line not in self.solver_identity:
1294
+ self.solver_identity.append(line)
1295
+
1296
+
1297
+ def _references_dir() -> Path:
1298
+ """Return the packaged reference directory (working tree when editable)."""
1299
+ return Path(str(resources.files("pyflightstream.qa"))) / "references"
1300
+
1301
+
1302
+ def load_reference(case_id: str, references_dir: str | Path | None = None) -> CaseReference | None:
1303
+ """Load the committed reference of one case, when it exists.
1304
+
1305
+ Parameters
1306
+ ----------
1307
+ case_id : str
1308
+ Case identifier, for example ``"PHY-01"``.
1309
+ references_dir : str or Path, optional
1310
+ Alternative directory, used by tests; defaults to the package
1311
+ data directory ``qa/references/``.
1312
+
1313
+ Returns
1314
+ -------
1315
+ CaseReference or None
1316
+ The stored reference, or None before the first seeding.
1317
+ """
1318
+ directory = Path(references_dir) if references_dir else _references_dir()
1319
+ path = directory / f"{case_id}.yaml"
1320
+ if not path.is_file():
1321
+ return None
1322
+ document = yaml.safe_load(path.read_text(encoding="utf-8"))
1323
+ if not isinstance(document, dict) or document.get("schema") != REFERENCE_SCHEMA:
1324
+ raise ValueError(
1325
+ f"{path} is not a physics reference (expected schema {REFERENCE_SCHEMA!r})"
1326
+ )
1327
+ metrics = {
1328
+ name: ReferenceBand(
1329
+ value=float(body["value"]),
1330
+ warn=float(body["warn"]),
1331
+ fail=float(body["fail"]),
1332
+ kind=str(body["kind"]),
1333
+ )
1334
+ for name, body in document["metrics"].items()
1335
+ }
1336
+ return CaseReference(
1337
+ case_id=document["case"],
1338
+ fs_version_basis=str(document["fs_version_basis"]),
1339
+ updated=str(document["updated"]),
1340
+ reason=str(document["reason"]),
1341
+ metrics=metrics,
1342
+ )
1343
+
1344
+
1345
+ def compare_metrics(
1346
+ measured: dict[str, float], reference: CaseReference | None
1347
+ ) -> dict[str, Verdict]:
1348
+ """Judge every measured metric against the stored reference bands.
1349
+
1350
+ A metric missing from the reference (new metric, old reference)
1351
+ judges ``NO_REFERENCE`` rather than guessing a band.
1352
+ """
1353
+ if reference is None:
1354
+ return {name: Verdict.NO_REFERENCE for name in measured}
1355
+ return {
1356
+ name: (
1357
+ reference.metrics[name].judge(value)
1358
+ if name in reference.metrics
1359
+ else Verdict.NO_REFERENCE
1360
+ )
1361
+ for name, value in measured.items()
1362
+ }
1363
+
1364
+
1365
+ def registered_cases(include_smi: bool = False) -> dict[str, PhysicsCase]:
1366
+ """Return the case registry, optionally including the SMI class.
1367
+
1368
+ The SMI cases join only when the caller can provide the local
1369
+ geometry root; they never run implicitly (CLAUDE.md invariant 5).
1370
+ """
1371
+ if include_smi:
1372
+ return {**PHYSICS_CASES, **SMI_CASES}
1373
+ return dict(PHYSICS_CASES)
1374
+
1375
+
1376
+ def run_physics(
1377
+ version: str,
1378
+ *,
1379
+ fs_exe: str | Path,
1380
+ workroot: str | Path,
1381
+ cases: list[str] | None = None,
1382
+ timeout_s: float = 900.0,
1383
+ references_dir: str | Path | None = None,
1384
+ smi_root: str | Path | None = None,
1385
+ ) -> PhysicsRun:
1386
+ """Run the Tier 3 physics matrix for one FlightStream version.
1387
+
1388
+ Parameters
1389
+ ----------
1390
+ version : str
1391
+ Target version, canonical or alias; every script is validated
1392
+ against this version's database view.
1393
+ fs_exe : str or Path
1394
+ Explicit FlightStream executable path (never guessed).
1395
+ workroot : str or Path
1396
+ Scratch root receiving per-case directories with geometry,
1397
+ scripts, and solver outputs; local, never committed.
1398
+ cases : list of str, optional
1399
+ Subset of case identifiers; defaults to every registered case
1400
+ (the SMI class joins the default only when ``smi_root`` is
1401
+ given).
1402
+ timeout_s : float
1403
+ Wall-clock limit per solver point.
1404
+ references_dir : str or Path, optional
1405
+ Alternative reference directory, used by tests.
1406
+ smi_root : str or Path, optional
1407
+ Local SMI geometry root (normally ``_private/geometry/smi``);
1408
+ enables the SMI drift class. Explicit input, never guessed;
1409
+ the geometry never enters Git.
1410
+
1411
+ Returns
1412
+ -------
1413
+ PhysicsRun
1414
+ Measured metrics and verdicts per case; a case that aborted
1415
+ carries its error text instead of hiding the rest of the run.
1416
+
1417
+ Raises
1418
+ ------
1419
+ PhysicsEnvironmentError
1420
+ When the executable is missing or a requested case is unknown.
1421
+ """
1422
+ canonical = resolve(version).canonical
1423
+ registry = registered_cases(include_smi=smi_root is not None)
1424
+ wanted = cases or sorted(
1425
+ case_id for case_id, case in registry.items() if case.supports(canonical)
1426
+ )
1427
+ unknown = [case_id for case_id in wanted if case_id not in registry]
1428
+ if unknown:
1429
+ raise PhysicsEnvironmentError(
1430
+ f"unknown physics case(s) {', '.join(unknown)}; registered: "
1431
+ f"{', '.join(sorted(registry))} (SMI cases need --smi-root)"
1432
+ )
1433
+ unsupported = [case_id for case_id in wanted if not registry[case_id].supports(canonical)]
1434
+ if unsupported:
1435
+ raise PhysicsEnvironmentError(
1436
+ f"case(s) {', '.join(unsupported)} have no command evidence for "
1437
+ f"FlightStream {canonical}; they are restricted until the backfill "
1438
+ "widens their version support (CLAUDE.md invariant 3)"
1439
+ )
1440
+ try:
1441
+ executor = LocalExecutor(fs_exe)
1442
+ except ValueError as error:
1443
+ raise PhysicsEnvironmentError(str(error)) from error
1444
+ results: list[CaseResult] = []
1445
+ identity: list[str] = []
1446
+ for case_id in wanted:
1447
+ case = registry[case_id]
1448
+ workdir = Path(workroot) / canonical / case_id.lower().replace("-", "_")
1449
+ workdir.mkdir(parents=True, exist_ok=True)
1450
+ context = _CaseContext(
1451
+ canonical,
1452
+ executor,
1453
+ workdir,
1454
+ timeout_s,
1455
+ smi_root=None if smi_root is None else Path(smi_root),
1456
+ )
1457
+ try:
1458
+ measured = case.runner(context)
1459
+ except RuntimeError as error:
1460
+ results.append(
1461
+ CaseResult(
1462
+ case_id=case_id,
1463
+ title=case.title,
1464
+ geometry="",
1465
+ error=str(error),
1466
+ )
1467
+ )
1468
+ continue
1469
+ reference = load_reference(case_id, references_dir)
1470
+ results.append(
1471
+ CaseResult(
1472
+ case_id=measured.case_id,
1473
+ title=measured.title,
1474
+ geometry=measured.geometry,
1475
+ points=measured.points,
1476
+ metrics=measured.metrics,
1477
+ verdicts=compare_metrics(measured.metrics, reference),
1478
+ reference=reference,
1479
+ )
1480
+ )
1481
+ identity.extend(line for line in context.solver_identity if line not in identity)
1482
+ return PhysicsRun(
1483
+ version=canonical,
1484
+ fs_exe_name=Path(fs_exe).name,
1485
+ package_version=pyflightstream.__version__,
1486
+ results=tuple(results),
1487
+ solver_identity=tuple(identity),
1488
+ )
1489
+
1490
+
1491
+ # --------------------------------------------------------------------------
1492
+ # Report and reference update
1493
+ # --------------------------------------------------------------------------
1494
+
1495
+
1496
+ def write_physics_report(
1497
+ run: PhysicsRun, out_dir: str | Path, *, date: str | None = None, label: str | None = None
1498
+ ) -> tuple[Path, Path]:
1499
+ """Write one physics run as a report pair (YAML plus Markdown).
1500
+
1501
+ Same evidence discipline as the compat reports: the stem is
1502
+ ``PHY-<version digits>_<date>`` plus the optional label, and an
1503
+ existing report is never overwritten.
1504
+
1505
+ Parameters
1506
+ ----------
1507
+ run : PhysicsRun
1508
+ The run to record.
1509
+ out_dir : str or Path
1510
+ Target directory, normally ``reports/physics/``.
1511
+ date : str, optional
1512
+ ISO date stamped into the report; defaults to today.
1513
+ label : str, optional
1514
+ Stem suffix distinguishing several reports on one day.
1515
+
1516
+ Returns
1517
+ -------
1518
+ tuple of Path
1519
+ The YAML path and the Markdown path, in that order.
1520
+ """
1521
+ out_dir = Path(out_dir)
1522
+ out_dir.mkdir(parents=True, exist_ok=True)
1523
+ date = date or datetime.date.today().isoformat()
1524
+ stem = f"PHY-{run.version.replace('.', '')}_{date}"
1525
+ if label:
1526
+ stem += f"_{label}"
1527
+ yaml_path = out_dir / f"{stem}.yaml"
1528
+ md_path = out_dir / f"{stem}.md"
1529
+ for path in (yaml_path, md_path):
1530
+ if path.exists():
1531
+ raise FileExistsError(
1532
+ f"{path} already exists; physics reports are evidence and are never "
1533
+ "overwritten. Pick another date or label."
1534
+ )
1535
+ counts = run.verdict_counts()
1536
+ document = {
1537
+ "schema": PHYSICS_SCHEMA,
1538
+ "fs_version": run.version,
1539
+ "date": date,
1540
+ "package_version": run.package_version,
1541
+ "fs_exe": run.fs_exe_name,
1542
+ "executor": "LocalExecutor, -hidden --script (SRC-003 pp.279-280)",
1543
+ "solver_identity": list(run.solver_identity),
1544
+ "summary": counts,
1545
+ "cases": {
1546
+ result.case_id: {
1547
+ "title": result.title,
1548
+ "geometry": result.geometry,
1549
+ "error": result.error,
1550
+ "points": [
1551
+ {
1552
+ "label": point.label or f"a{point.alpha_deg:g}",
1553
+ "alpha_deg": point.alpha_deg,
1554
+ "iterations": point.iterations,
1555
+ "converged": point.converged,
1556
+ "total": {key: float(value) for key, value in point.total.items()},
1557
+ }
1558
+ for point in result.points
1559
+ ],
1560
+ "metrics": {name: float(value) for name, value in result.metrics.items()},
1561
+ "verdicts": {name: verdict.value for name, verdict in result.verdicts.items()},
1562
+ "reference": None
1563
+ if result.reference is None
1564
+ else {
1565
+ "fs_version_basis": result.reference.fs_version_basis,
1566
+ "updated": result.reference.updated,
1567
+ "reason": result.reference.reason,
1568
+ },
1569
+ }
1570
+ for result in run.results
1571
+ },
1572
+ }
1573
+ yaml_path.write_text(yaml.safe_dump(document, sort_keys=False, width=100), encoding="utf-8")
1574
+ md_path.write_text(_render_markdown(run, date, counts), encoding="utf-8")
1575
+ return yaml_path, md_path
1576
+
1577
+
1578
+ def _render_markdown(run: PhysicsRun, date: str, counts: dict[str, int]) -> str:
1579
+ """Render the human-readable side of the physics report."""
1580
+ lines = [
1581
+ f"# Physics report: FlightStream {run.version} ({date})",
1582
+ "",
1583
+ "Tier 3 physics regression evidence produced by `pyfs-qa physics`",
1584
+ "(SAD Section 11): synthetic committed cases, measured metrics",
1585
+ "compared against stored references inside WARN and FAIL bands.",
1586
+ "References change only through `pyfs-qa update-reference`, which",
1587
+ "records a reason; geometry is generated by the suite and no",
1588
+ "research geometry is involved.",
1589
+ "",
1590
+ "## Setup",
1591
+ "",
1592
+ "| Item | Value |",
1593
+ "|---|---|",
1594
+ f"| Executable | {run.fs_exe_name} (local, `_private/exe/`, never committed) |",
1595
+ "| Executor | LocalExecutor, `-hidden --script` (SRC-003 pp.279-280) |",
1596
+ f"| Package | pyflightstream {run.package_version} |",
1597
+ f"| Solver identity | {'; '.join(run.solver_identity) or 'none captured'} |",
1598
+ "",
1599
+ "## Summary",
1600
+ "",
1601
+ f"{counts['pass']} pass, {counts['warn']} warn, {counts['fail']} fail, "
1602
+ f"{counts['no_reference']} without reference.",
1603
+ "",
1604
+ ]
1605
+ for result in run.results:
1606
+ lines.extend([f"## {result.case_id}: {result.title}", ""])
1607
+ if result.error is not None:
1608
+ lines.extend([f"Case aborted: {result.error}", ""])
1609
+ continue
1610
+ lines.extend([result.geometry, ""])
1611
+ if result.points:
1612
+ lines.extend(
1613
+ [
1614
+ "| point | alpha (deg) | CL | CDi | iterations | converged |",
1615
+ "|---|---|---|---|---|---|",
1616
+ ]
1617
+ )
1618
+ for point in result.points:
1619
+ label = point.label or f"a{point.alpha_deg:g}"
1620
+ lines.append(
1621
+ f"| {label} | {point.alpha_deg:+.1f} "
1622
+ f"| {point.total.get('CL', float('nan')):.5f} "
1623
+ f"| {point.total.get('CDi', float('nan')):.5f} | {point.iterations} "
1624
+ f"| {'yes' if point.converged else 'no'} |"
1625
+ )
1626
+ lines.append("")
1627
+ lines.extend(
1628
+ [
1629
+ "| Metric | Measured | Reference | Bands (warn/fail) | Verdict |",
1630
+ "|---|---|---|---|---|",
1631
+ ]
1632
+ )
1633
+ for name, value in result.metrics.items():
1634
+ band = result.reference.metrics.get(name) if result.reference else None
1635
+ if band is None:
1636
+ reference_cell, band_cell = "-", "-"
1637
+ else:
1638
+ reference_cell = f"{band.value:.5f}"
1639
+ band_cell = f"{band.warn:g}/{band.fail:g} ({band.kind})"
1640
+ verdict = result.verdicts.get(name, Verdict.NO_REFERENCE).value
1641
+ lines.append(f"| {name} | {value:.5f} | {reference_cell} | {band_cell} | {verdict} |")
1642
+ lines.append("")
1643
+ return "\n".join(lines)
1644
+
1645
+
1646
+ def read_physics_report(path: str | Path) -> dict:
1647
+ """Load and check a machine-readable physics report."""
1648
+ document = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
1649
+ if not isinstance(document, dict) or document.get("schema") != PHYSICS_SCHEMA:
1650
+ raise ValueError(
1651
+ f"{path} is not a physics report (expected schema {PHYSICS_SCHEMA!r}); "
1652
+ "references are seeded only from committed physics evidence"
1653
+ )
1654
+ return document
1655
+
1656
+
1657
+ def update_reference(
1658
+ case_id: str,
1659
+ report_path: str | Path,
1660
+ reason: str,
1661
+ *,
1662
+ references_dir: str | Path | None = None,
1663
+ date: str | None = None,
1664
+ ) -> Path:
1665
+ """Update (or seed) one case reference from a committed physics report.
1666
+
1667
+ The only write path into ``qa/references/`` (SAD Section 11): it
1668
+ demands a non-empty reason string, copies the measured metric
1669
+ values from the report, and keeps the curated bands of an existing
1670
+ reference; metrics new to the reference receive the default bands
1671
+ of the case's metric specifications. A reference update never
1672
+ shares a commit with code changes.
1673
+
1674
+ Parameters
1675
+ ----------
1676
+ case_id : str
1677
+ Case whose reference to update.
1678
+ report_path : str or Path
1679
+ Committed physics report YAML carrying the measured values.
1680
+ reason : str
1681
+ Why the reference moves (initial seeding, solver change
1682
+ accepted after triage, case redefinition, ...); recorded in
1683
+ the reference file.
1684
+ references_dir : str or Path, optional
1685
+ Alternative directory, used by tests.
1686
+ date : str, optional
1687
+ ISO date recorded as the update date; defaults to today.
1688
+
1689
+ Returns
1690
+ -------
1691
+ Path
1692
+ The written reference file.
1693
+
1694
+ Raises
1695
+ ------
1696
+ ValueError
1697
+ When the reason is empty, the case is unknown, the report is
1698
+ not a physics report, or the report carries no metrics for the
1699
+ case.
1700
+ """
1701
+ if not reason or not reason.strip():
1702
+ raise ValueError(
1703
+ "a reference update requires a reason string; references move only "
1704
+ "deliberately (SAD Section 11)"
1705
+ )
1706
+ registry = registered_cases(include_smi=True)
1707
+ case = registry.get(case_id)
1708
+ if case is None:
1709
+ raise ValueError(
1710
+ f"unknown physics case {case_id!r}; registered: {', '.join(sorted(registry))}"
1711
+ )
1712
+ report = read_physics_report(report_path)
1713
+ case_body = report.get("cases", {}).get(case_id)
1714
+ if not case_body or not case_body.get("metrics"):
1715
+ raise ValueError(f"{report_path} carries no measured metrics for {case_id}")
1716
+ existing = load_reference(case_id, references_dir)
1717
+ specs = case.specs_by_name
1718
+ metrics: dict[str, dict] = {}
1719
+ for name, value in case_body["metrics"].items():
1720
+ if existing is not None and name in existing.metrics:
1721
+ band = existing.metrics[name]
1722
+ warn, fail, kind = band.warn, band.fail, band.kind
1723
+ elif name in specs:
1724
+ warn, fail, kind = specs[name].warn, specs[name].fail, specs[name].kind
1725
+ else:
1726
+ raise ValueError(
1727
+ f"metric {name!r} is neither in the existing reference nor declared "
1728
+ f"by {case_id}; declare it in the case's metric specifications first"
1729
+ )
1730
+ metrics[name] = {"value": float(value), "warn": warn, "fail": fail, "kind": kind}
1731
+ directory = Path(references_dir) if references_dir else _references_dir()
1732
+ directory.mkdir(parents=True, exist_ok=True)
1733
+ document = {
1734
+ "schema": REFERENCE_SCHEMA,
1735
+ "case": case_id,
1736
+ "fs_version_basis": report["fs_version"],
1737
+ "updated": date or datetime.date.today().isoformat(),
1738
+ "reason": reason.strip(),
1739
+ "source_report": Path(report_path).as_posix(),
1740
+ "metrics": metrics,
1741
+ }
1742
+ path = directory / f"{case_id}.yaml"
1743
+ path.write_text(yaml.safe_dump(document, sort_keys=False, width=100), encoding="utf-8")
1744
+ return path