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