pyflightstream 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyflightstream/__init__.py +27 -0
- pyflightstream/cases/__init__.py +332 -0
- pyflightstream/cases/matrix_legacy.py +337 -0
- pyflightstream/commands/__init__.py +544 -0
- pyflightstream/commands/_meta.yaml +18 -0
- pyflightstream/commands/actuators.yaml +167 -0
- pyflightstream/commands/advanced_settings.yaml +136 -0
- pyflightstream/commands/aeroelastic_coupling.yaml +172 -0
- pyflightstream/commands/boundary_conditions.yaml +153 -0
- pyflightstream/commands/ccs_wing_mesh.yaml +74 -0
- pyflightstream/commands/coordinate_systems.yaml +123 -0
- pyflightstream/commands/file_io.yaml +82 -0
- pyflightstream/commands/mesh_import_export.yaml +72 -0
- pyflightstream/commands/motion_definitions.yaml +185 -0
- pyflightstream/commands/probe_points.yaml +116 -0
- pyflightstream/commands/runtime_settings.yaml +160 -0
- pyflightstream/commands/scenes.yaml +14 -0
- pyflightstream/commands/script_controls.yaml +39 -0
- pyflightstream/commands/simulation_controls.yaml +30 -0
- pyflightstream/commands/solver_analysis.yaml +118 -0
- pyflightstream/commands/solver_export.yaml +138 -0
- pyflightstream/commands/solver_initialization.yaml +95 -0
- pyflightstream/commands/solver_settings.yaml +120 -0
- pyflightstream/commands/streamlines.yaml +63 -0
- pyflightstream/commands/surface_sections.yaml +145 -0
- pyflightstream/commands/sweeper.yaml +115 -0
- pyflightstream/commands/unsteady_solver.yaml +68 -0
- pyflightstream/commands/volume_sections.yaml +148 -0
- pyflightstream/farfield/__init__.py +613 -0
- pyflightstream/files/__init__.py +375 -0
- pyflightstream/fsi/__init__.py +57 -0
- pyflightstream/fsi/beam.py +417 -0
- pyflightstream/fsi/centrifugal.py +418 -0
- pyflightstream/fsi/cli.py +206 -0
- pyflightstream/fsi/config.py +316 -0
- pyflightstream/fsi/driver.py +501 -0
- pyflightstream/fsi/kinematics.py +153 -0
- pyflightstream/fsi/loads.py +740 -0
- pyflightstream/fsi/nodes.py +463 -0
- pyflightstream/fsi/state.py +181 -0
- pyflightstream/post/__init__.py +18 -0
- pyflightstream/post/writers.py +195 -0
- pyflightstream/probes/__init__.py +453 -0
- pyflightstream/probes/geometry.py +281 -0
- pyflightstream/probes/planar.py +477 -0
- pyflightstream/qa/__init__.py +59 -0
- pyflightstream/qa/cli.py +364 -0
- pyflightstream/qa/compat.py +285 -0
- pyflightstream/qa/drift.py +406 -0
- pyflightstream/qa/geometry.py +421 -0
- pyflightstream/qa/physics.py +1744 -0
- pyflightstream/qa/probes.py +1023 -0
- pyflightstream/qa/references/PHY-01.yaml +38 -0
- pyflightstream/qa/references/PHY-02.yaml +28 -0
- pyflightstream/qa/references/PHY-05.yaml +29 -0
- pyflightstream/qa/references/PHY-06.yaml +90 -0
- pyflightstream/qa/references/SMI-01.yaml +28 -0
- pyflightstream/qa/references/SMI-02.yaml +28 -0
- pyflightstream/qa/specs.py +1405 -0
- pyflightstream/reference.py +465 -0
- pyflightstream/results/__init__.py +547 -0
- pyflightstream/run/__init__.py +677 -0
- pyflightstream/script/__init__.py +474 -0
- pyflightstream/script/helpers.py +844 -0
- pyflightstream/versions.py +191 -0
- pyflightstream-0.2.0.dist-info/METADATA +88 -0
- pyflightstream-0.2.0.dist-info/RECORD +71 -0
- pyflightstream-0.2.0.dist-info/WHEEL +5 -0
- pyflightstream-0.2.0.dist-info/entry_points.txt +3 -0
- pyflightstream-0.2.0.dist-info/licenses/LICENSE +21 -0
- pyflightstream-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|