dynamic-systems-testing-framework 1.0.1__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.
- dstf/__init__.py +41 -0
- dstf/__main__.py +10 -0
- dstf/authoring/__init__.py +13 -0
- dstf/authoring/builtin/__init__.py +32 -0
- dstf/authoring/builtin/accept.py +205 -0
- dstf/authoring/builtin/edit_criteria.py +479 -0
- dstf/authoring/builtin/edit_family.py +228 -0
- dstf/authoring/builtin/edit_on_plot.py +1354 -0
- dstf/authoring/builtin/edit_tree.py +1399 -0
- dstf/authoring/builtin/enqueue.py +144 -0
- dstf/authoring/capabilities.py +463 -0
- dstf/authoring/errors.py +49 -0
- dstf/authoring/family_rows.py +526 -0
- dstf/authoring/forms.py +631 -0
- dstf/authoring/layout.py +104 -0
- dstf/authoring/live_rows.py +337 -0
- dstf/authoring/live_runs.py +385 -0
- dstf/authoring/needle.py +127 -0
- dstf/authoring/picker_rows.py +92 -0
- dstf/authoring/queue_rows.py +354 -0
- dstf/authoring/session.py +3016 -0
- dstf/authoring/shell/__init__.py +9 -0
- dstf/authoring/shell/actions.py +702 -0
- dstf/authoring/shell/app.py +53 -0
- dstf/authoring/shell/drawer_controls.py +190 -0
- dstf/authoring/shell/family_fold.py +468 -0
- dstf/authoring/shell/figure_hook.py +249 -0
- dstf/authoring/shell/grips.js +1000 -0
- dstf/authoring/shell/header.py +783 -0
- dstf/authoring/shell/live_view.py +93 -0
- dstf/authoring/shell/picker.py +267 -0
- dstf/authoring/shell/placing.py +122 -0
- dstf/authoring/shell/plot_card.py +1171 -0
- dstf/authoring/shell/queue_panel.py +433 -0
- dstf/authoring/shell/rail.py +593 -0
- dstf/authoring/shell/shell.py +528 -0
- dstf/authoring/shell/stats_card.py +501 -0
- dstf/authoring/shell/strip.py +204 -0
- dstf/authoring/shell/style.py +721 -0
- dstf/authoring/shell/suite.py +277 -0
- dstf/authoring/shell/suite_grid.js +414 -0
- dstf/authoring/shell/suite_grid.py +373 -0
- dstf/authoring/shell/tab_theme.py +110 -0
- dstf/authoring/shell/tabstate.py +146 -0
- dstf/authoring/shell/test_list.py +179 -0
- dstf/authoring/shell/test_page.py +1404 -0
- dstf/authoring/shell/tree_card.py +1171 -0
- dstf/authoring/shell/variables_drawer.py +589 -0
- dstf/authoring/shell/widgets.py +188 -0
- dstf/authoring/situation.py +287 -0
- dstf/authoring/spec_document.py +215 -0
- dstf/authoring/spec_preview.py +83 -0
- dstf/authoring/suite_rows.py +99 -0
- dstf/authoring/tree_move.py +516 -0
- dstf/authoring/tree_pointer.py +33 -0
- dstf/authoring/tree_text.py +61 -0
- dstf/authoring/typed_number.js +64 -0
- dstf/authoring/typed_number.py +76 -0
- dstf/authoring/unscorable.py +141 -0
- dstf/authoring/variable_rows.py +216 -0
- dstf/backends/__init__.py +283 -0
- dstf/backends/base.py +1117 -0
- dstf/backends/common/__init__.py +1 -0
- dstf/backends/common/json_driver.py +484 -0
- dstf/backends/common/live_mat.py +369 -0
- dstf/backends/common/mat_reader.py +632 -0
- dstf/backends/common/proc_kill.py +238 -0
- dstf/backends/common/proc_output.py +20 -0
- dstf/backends/common/result_files.py +109 -0
- dstf/backends/common/result_variables.py +329 -0
- dstf/backends/common/statistics.py +39 -0
- dstf/backends/cross_backend.py +193 -0
- dstf/backends/dymola/__init__.py +5 -0
- dstf/backends/dymola/backend.py +776 -0
- dstf/backends/dymola/interface_loader.py +241 -0
- dstf/backends/dymola/log_parser.py +396 -0
- dstf/backends/dymola/persistent_backend.py +949 -0
- dstf/backends/fmpy/__init__.py +11 -0
- dstf/backends/fmpy/backend.py +443 -0
- dstf/backends/julia/__init__.py +27 -0
- dstf/backends/julia/backend.py +156 -0
- dstf/backends/julia/persistent_backend.py +541 -0
- dstf/backends/julia/run_persistent.jl +167 -0
- dstf/backends/julia/run_test.jl +146 -0
- dstf/backends/openmodelica/__init__.py +21 -0
- dstf/backends/openmodelica/backend.py +368 -0
- dstf/backends/openmodelica/init_xml.py +76 -0
- dstf/backends/openmodelica/log_parser.py +119 -0
- dstf/backends/openmodelica/mos_generator.py +228 -0
- dstf/backends/openmodelica/persistent_backend.py +1045 -0
- dstf/backends/openmodelica/session_loader.py +63 -0
- dstf/backends/persistent.py +1811 -0
- dstf/backends/pool.py +602 -0
- dstf/backends/progress.py +395 -0
- dstf/backends/python/__init__.py +11 -0
- dstf/backends/python/backend.py +141 -0
- dstf/backends/python/run_test.py +141 -0
- dstf/backends/run_metadata.py +103 -0
- dstf/backends/types.py +310 -0
- dstf/cli/__init__.py +12 -0
- dstf/cli/author_cmd.py +61 -0
- dstf/cli/baseline_cmds.py +341 -0
- dstf/cli/common.py +231 -0
- dstf/cli/diag_cmds.py +350 -0
- dstf/cli/main.py +561 -0
- dstf/cli/review.py +398 -0
- dstf/cli/run_cmds.py +589 -0
- dstf/cli/spec_cmds.py +123 -0
- dstf/comparison/__init__.py +13 -0
- dstf/comparison/algorithms.py +2127 -0
- dstf/comparison/builtins.py +291 -0
- dstf/comparison/comparator.py +1189 -0
- dstf/comparison/geometry.py +164 -0
- dstf/comparison/handles.py +1194 -0
- dstf/comparison/metric_tree.py +332 -0
- dstf/comparison/mode_schema.py +176 -0
- dstf/comparison/modes.py +788 -0
- dstf/comparison/registry.py +230 -0
- dstf/comparison/seeds.py +281 -0
- dstf/comparison/sides.py +158 -0
- dstf/comparison/snap.py +62 -0
- dstf/comparison/strict_keys.py +246 -0
- dstf/comparison/summaries.py +354 -0
- dstf/comparison/tree_eval.py +868 -0
- dstf/comparison/tree_spec.py +678 -0
- dstf/comparison/types.py +194 -0
- dstf/comparison/validator.py +232 -0
- dstf/config.py +815 -0
- dstf/discovery/__init__.py +0 -0
- dstf/discovery/json_recognizer.py +702 -0
- dstf/discovery/library_state.py +248 -0
- dstf/discovery/mo_parser.py +794 -0
- dstf/discovery/patch_apply.py +308 -0
- dstf/discovery/patch_envelope.py +63 -0
- dstf/discovery/plots.py +318 -0
- dstf/discovery/recognizer.py +103 -0
- dstf/discovery/scan_cache.py +154 -0
- dstf/discovery/spec_parser.py +538 -0
- dstf/discovery/spec_shape.py +122 -0
- dstf/discovery/spec_update.py +493 -0
- dstf/discovery/test_registry.py +488 -0
- dstf/discovery/unit_tests.py +95 -0
- dstf/figures/__init__.py +14 -0
- dstf/figures/assemble.py +48 -0
- dstf/figures/decimate.py +150 -0
- dstf/figures/families.py +289 -0
- dstf/figures/palette.py +170 -0
- dstf/figures/plotly.py +1413 -0
- dstf/figures/view.py +233 -0
- dstf/pipeline.py +435 -0
- dstf/plugins.py +104 -0
- dstf/reporting/__init__.py +0 -0
- dstf/reporting/console_report.py +187 -0
- dstf/reporting/context_builder.py +979 -0
- dstf/reporting/dashboard_render.py +534 -0
- dstf/reporting/junit_report.py +192 -0
- dstf/reporting/overlay_loader.py +708 -0
- dstf/reporting/report_suite.py +1059 -0
- dstf/reporting/rows.py +458 -0
- dstf/reporting/schema_export.py +413 -0
- dstf/reporting/stats_rows.py +321 -0
- dstf/reporting/status.py +388 -0
- dstf/reporting/templates/axes.js +61 -0
- dstf/reporting/templates/dashboard.html +284 -0
- dstf/reporting/templates/dashboard.js +604 -0
- dstf/reporting/templates/filters.js +100 -0
- dstf/reporting/templates/interactive.html +420 -0
- dstf/reporting/templates/report.js +116 -0
- dstf/reporting/templates/vendor/plotly.min.js +8 -0
- dstf/reporting/theme.py +324 -0
- dstf/reporting/tree_rows.py +73 -0
- dstf/reporting/verdicts.py +124 -0
- dstf/storage/__init__.py +0 -0
- dstf/storage/accept.py +216 -0
- dstf/storage/baseline.py +43 -0
- dstf/storage/batch_manifest.py +204 -0
- dstf/storage/ref_index.py +295 -0
- dstf/storage/reference_store.py +661 -0
- dstf/storage/roles.py +576 -0
- dstf/storage/run_stats.py +85 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1059 @@
|
|
|
1
|
+
"""Render per-test interactive reports and orchestrate the report suite.
|
|
2
|
+
|
|
3
|
+
The rendering half of the per-test report: Jinja env, the figure dicts the
|
|
4
|
+
page paints, the ``comparison_data.json`` sidecar + size budget, and the
|
|
5
|
+
threaded per-suite orchestration. The context it renders is built by
|
|
6
|
+
:mod:`dstf.reporting.context_builder`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import os
|
|
12
|
+
import platform
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import uuid
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from ..comparison.comparator import VariableComparison
|
|
21
|
+
from ..comparison.types import TestComparison
|
|
22
|
+
from .context_builder import _build_template_context
|
|
23
|
+
from .rows import summary_fields
|
|
24
|
+
from .status import classify_with_class, refusal
|
|
25
|
+
from .tree_rows import criteria_rows
|
|
26
|
+
|
|
27
|
+
_TEMPLATE_DIR = Path(__file__).parent / "templates"
|
|
28
|
+
|
|
29
|
+
#: Vendored browser dependencies, shipped in the wheel under
|
|
30
|
+
#: ``reporting/templates/vendor/``. The report page used to pull Plotly
|
|
31
|
+
#: from a CDN, which renders a blank page in an air-gapped lab (and in
|
|
32
|
+
#: Playwright's offline mode).
|
|
33
|
+
#:
|
|
34
|
+
#: ONE copy per report suite, at the reports root — the directory holding
|
|
35
|
+
#: the per-test report dirs — reached from each page as
|
|
36
|
+
#: ``../plotly.min.js``. A copy per test dir cost 1.25 MB × N: 15 MB for
|
|
37
|
+
#: the 12-test demo, ~414 MB for the owner's 331-test TRANSFORM suite.
|
|
38
|
+
_VENDOR_DIR = _TEMPLATE_DIR / "vendor"
|
|
39
|
+
VENDOR_ASSETS: tuple[str, ...] = ("plotly.min.js",)
|
|
40
|
+
|
|
41
|
+
# The report page loads ONE ``interactive.js``: the axes script first (it
|
|
42
|
+
# defines what report.js calls), then report.js. The served name is
|
|
43
|
+
# historical.
|
|
44
|
+
INTERACTIVE_JS_PARTS: tuple[str, ...] = ("axes.js", "report.js")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def interactive_js_source() -> str:
|
|
48
|
+
"""Return the concatenated ``interactive.js`` served with a report."""
|
|
49
|
+
return "".join(
|
|
50
|
+
(_TEMPLATE_DIR / part).read_text(encoding="utf-8")
|
|
51
|
+
for part in INTERACTIVE_JS_PARTS
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def copy_vendor_assets(dest_root: Path) -> list[Path]:
|
|
56
|
+
"""Copy the vendored browser libraries into *dest_root*, once.
|
|
57
|
+
|
|
58
|
+
*dest_root* is the REPORTS ROOT (the directory holding the per-test
|
|
59
|
+
report dirs), not a page directory: every page reaches the asset as
|
|
60
|
+
``../plotly.min.js``.
|
|
61
|
+
|
|
62
|
+
Idempotent and safe to call from the report thread pool — every
|
|
63
|
+
per-test render calls it for the same destination. A same-size file
|
|
64
|
+
already there is left alone, and the copy itself lands on a unique
|
|
65
|
+
temp name before an atomic ``os.replace``, so a concurrent reader can
|
|
66
|
+
never see a half-written library.
|
|
67
|
+
"""
|
|
68
|
+
written = []
|
|
69
|
+
dest_root.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
for name in VENDOR_ASSETS:
|
|
71
|
+
src = _VENDOR_DIR / name
|
|
72
|
+
dest = dest_root / name
|
|
73
|
+
if dest.exists() and dest.stat().st_size == src.stat().st_size:
|
|
74
|
+
written.append(dest)
|
|
75
|
+
continue
|
|
76
|
+
tmp = dest.with_name(f"{name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
|
|
77
|
+
shutil.copyfile(src, tmp)
|
|
78
|
+
os.replace(tmp, dest)
|
|
79
|
+
written.append(dest)
|
|
80
|
+
return written
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def write_report_assets(page_dir: Path) -> Path:
|
|
84
|
+
"""Stage everything a page in *page_dir* loads by relative path.
|
|
85
|
+
|
|
86
|
+
The concatenated ``interactive.js`` lands IN *page_dir* (the page
|
|
87
|
+
loads it as ``interactive.js``); the vendored Plotly lands in
|
|
88
|
+
``page_dir.parent``, the reports root shared by every page of the
|
|
89
|
+
suite (``../plotly.min.js``). Single entry point for both the
|
|
90
|
+
production report copy and the test fixtures that stage a page for a
|
|
91
|
+
browser: a page written without one of these is a blank report, and a
|
|
92
|
+
second copy of the asset list is a second thing to forget.
|
|
93
|
+
|
|
94
|
+
Returns the JS path.
|
|
95
|
+
"""
|
|
96
|
+
dest = page_dir / "interactive.js"
|
|
97
|
+
# LF on every platform. Unlike the rest of a report, this
|
|
98
|
+
# file is byte-stable across runs, so an archived report tree
|
|
99
|
+
# regenerated from the other OS would otherwise diff wholly for
|
|
100
|
+
# nothing.
|
|
101
|
+
dest.write_text(interactive_js_source(), encoding="utf-8", newline="\n")
|
|
102
|
+
copy_vendor_assets(page_dir.parent)
|
|
103
|
+
return dest
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _json_sanitize(obj):
|
|
107
|
+
"""Recursively replace non-finite floats (inf / -inf / nan) with None.
|
|
108
|
+
|
|
109
|
+
review 2026-07-06 finding 68: ``comparison_data.json`` is the documented
|
|
110
|
+
downstream artifact and the same context is embedded into
|
|
111
|
+
interactive.html — dumping with ``allow_nan=True`` injected literal
|
|
112
|
+
``Infinity`` / ``NaN`` tokens (e.g. from missing-baseline sentinel
|
|
113
|
+
comparisons), which is invalid JSON for every strict consumer. Returns
|
|
114
|
+
new containers; the input is not mutated.
|
|
115
|
+
"""
|
|
116
|
+
if isinstance(obj, float):
|
|
117
|
+
return obj if math.isfinite(obj) else None
|
|
118
|
+
if isinstance(obj, dict):
|
|
119
|
+
return {k: _json_sanitize(v) for k, v in obj.items()}
|
|
120
|
+
if isinstance(obj, (list, tuple)):
|
|
121
|
+
return [_json_sanitize(v) for v in obj]
|
|
122
|
+
return obj
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _sanitize_filename(name: str) -> str:
|
|
126
|
+
"""Sanitize a MODEL ID for use in a directory name.
|
|
127
|
+
|
|
128
|
+
Collapses whitespace, replaces Modelica punctuation, and removes any
|
|
129
|
+
remaining filesystem-unsafe character. It took variable names once,
|
|
130
|
+
when a name could still be the text a multi-line `x={...}` spelled
|
|
131
|
+
(D132 made a variable's name the result file's); the one caller now
|
|
132
|
+
passes a model id.
|
|
133
|
+
"""
|
|
134
|
+
# Collapse all whitespace (newlines, spaces, tabs) into single underscore
|
|
135
|
+
name = re.sub(r"\s+", "_", name)
|
|
136
|
+
# Replace common Modelica punctuation
|
|
137
|
+
name = name.replace("[", "_").replace("]", "").replace(".", "_")
|
|
138
|
+
name = name.replace(",", "_").replace("(", "").replace(")", "")
|
|
139
|
+
# Remove anything that's not alphanumeric, underscore, or hyphen
|
|
140
|
+
name = re.sub(r"[^\w\-]", "", name)
|
|
141
|
+
# Collapse multiple underscores
|
|
142
|
+
name = re.sub(r"_+", "_", name).strip("_")
|
|
143
|
+
return name
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def plot_inputs(test, comp_variables, result, backend_cls, test_dir):
|
|
147
|
+
"""``(plots, extra_series)`` for one test's page: the plots it draws and
|
|
148
|
+
every series of theirs the page's scored and no-baseline entries do not
|
|
149
|
+
answer — context series and watch plots.
|
|
150
|
+
|
|
151
|
+
The plots are :func:`~dstf.discovery.plots.plots_of_test`' — the ONE rule
|
|
152
|
+
the authoring tool's `Session.plots` draws by, so both tiers draw the
|
|
153
|
+
same plots: derived from the criteria tree, decorated by the entry's
|
|
154
|
+
``plots`` extras, none for a failed run.
|
|
155
|
+
|
|
156
|
+
A test with no extras — or no backend class or test dir to read a
|
|
157
|
+
result file through — opens no result FILE: every derived subject is a
|
|
158
|
+
scored or tracked variable the render context already carries, so 331
|
|
159
|
+
TRANSFORM reports pay nothing, and what the result carries is what the
|
|
160
|
+
plots can draw. An extra's series is taken from the result in memory
|
|
161
|
+
when it is there, and read from the file only when it is not.
|
|
162
|
+
"""
|
|
163
|
+
from ..discovery.plots import plots_of_test
|
|
164
|
+
|
|
165
|
+
if test is None:
|
|
166
|
+
return (), {}
|
|
167
|
+
extras = tuple(test.plots or ())
|
|
168
|
+
readable = bool(extras) and backend_cls is not None and test_dir is not None
|
|
169
|
+
plots = plots_of_test(
|
|
170
|
+
test,
|
|
171
|
+
[v.name for v in comp_variables or ()],
|
|
172
|
+
result,
|
|
173
|
+
listed=backend_cls.result_names(test_dir) if readable else None,
|
|
174
|
+
)
|
|
175
|
+
derived = {name for plot in plots if not plot.watch for name in plot.subject}
|
|
176
|
+
wanted = [
|
|
177
|
+
name
|
|
178
|
+
for plot in plots
|
|
179
|
+
for name in plot.series
|
|
180
|
+
if name not in derived and name not in plot.missing
|
|
181
|
+
]
|
|
182
|
+
in_memory = {
|
|
183
|
+
var.name: (var.time, var.values)
|
|
184
|
+
for var in getattr(result, "variables", None) or ()
|
|
185
|
+
if var.name in wanted
|
|
186
|
+
}
|
|
187
|
+
extra_series = {
|
|
188
|
+
name: in_memory[name] for name in dict.fromkeys(wanted) if name in in_memory
|
|
189
|
+
}
|
|
190
|
+
unread = [name for name in dict.fromkeys(wanted) if name not in in_memory]
|
|
191
|
+
if readable and unread:
|
|
192
|
+
extra_series.update(backend_cls.result_series(test_dir, unread))
|
|
193
|
+
return plots, extra_series
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _context_labels(context: dict, key: str = "label") -> dict[str, str]:
|
|
197
|
+
"""Name → what a render context reads for every series it can draw.
|
|
198
|
+
|
|
199
|
+
``key`` picks which of the two readings
|
|
200
|
+
(:func:`~dstf.reporting.context_builder.test_texts`): ``"label"`` for
|
|
201
|
+
the surfaces that show the muted name beside it — the section heading,
|
|
202
|
+
the per-variable status badge — and ``"text"`` for every other one.
|
|
203
|
+
Element ids, ``data-variable``, the series lists and ``meta.id`` are
|
|
204
|
+
always the NAME.
|
|
205
|
+
|
|
206
|
+
ONE source: the ``variable_texts`` the context stamped, which is the
|
|
207
|
+
whole TEST's scope — every variable of the result, every scored
|
|
208
|
+
comparison and every plot series. Re-deriving it here from the entries
|
|
209
|
+
that happen to carry a label (the scored variables, the no-baseline
|
|
210
|
+
trajectories) gave a narrower scope that left a TRACKED but unscored
|
|
211
|
+
variable out, and it read its bare name on four surfaces of a page
|
|
212
|
+
where the tool read its expression. A context with no stamp — a
|
|
213
|
+
hand-built one — reads names, which is what a variable with no
|
|
214
|
+
expression reads anyway.
|
|
215
|
+
"""
|
|
216
|
+
return {
|
|
217
|
+
name: entry.get(key) or name
|
|
218
|
+
for name, entry in (context.get("variable_texts") or {}).items()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def plot_page_keys(
|
|
223
|
+
context, plots, extra_series, metric_tree, max_samples, theme: str = "default"
|
|
224
|
+
) -> dict:
|
|
225
|
+
"""``plots`` (the sidecar's record), ``plot_figures``, ``plot_series``
|
|
226
|
+
and ``plot_sections`` from a render context, one of each per plot of
|
|
227
|
+
``plots`` (:func:`plot_inputs`), in order. The figures are drawn in the
|
|
228
|
+
bundled theme ``theme`` — baked into the page, as its stylesheet is.
|
|
229
|
+
|
|
230
|
+
THREE sources answer a series name, in order: the scored variables
|
|
231
|
+
(``variables_by_name``), the no-baseline trajectories — a test with no
|
|
232
|
+
reference scores nothing, so its curves live only there — and
|
|
233
|
+
``extra_series``, the context and watch series read out of the result.
|
|
234
|
+
A name in none of the three draws nothing and joins ``missing``; so does
|
|
235
|
+
one the plot already calls missing, even when a source answers it — a
|
|
236
|
+
tree leaf the result lacks is scored (and fails), and its entry has no
|
|
237
|
+
curve to draw.
|
|
238
|
+
|
|
239
|
+
A SUBJECT draws as the variable it is — its reference, its criteria'
|
|
240
|
+
geometry and its overlays; a CONTEXT series as its actual curve alone,
|
|
241
|
+
grey, even when it is scored on a plot of its own (its criteria are
|
|
242
|
+
there).
|
|
243
|
+
|
|
244
|
+
The ONE producer of the page's plot keys: the template loops over plot
|
|
245
|
+
sections, so a caller or a fixture that hand-rolled them would be a
|
|
246
|
+
second layout nobody renders through.
|
|
247
|
+
"""
|
|
248
|
+
from ..figures.assemble import leaves_by_variable, view_from_trajectory
|
|
249
|
+
from ..figures.families import family_label, group_families
|
|
250
|
+
from ..figures.plotly import plot_figure
|
|
251
|
+
from ..figures.view import build_view
|
|
252
|
+
|
|
253
|
+
by_name = context.get("variables_by_name") or {}
|
|
254
|
+
nobaseline = {
|
|
255
|
+
t["name"]: t
|
|
256
|
+
for t in context.get("nobaseline_trajectories") or []
|
|
257
|
+
if t.get("name")
|
|
258
|
+
}
|
|
259
|
+
arrays = extra_series or {}
|
|
260
|
+
vidx = {n: i for i, n in enumerate(by_name)}
|
|
261
|
+
# Two readings of the same scope: `labels` where the muted name rides
|
|
262
|
+
# beside (the section heading), `texts` everywhere else.
|
|
263
|
+
labels = _context_labels(context)
|
|
264
|
+
texts = _context_labels(context, "text")
|
|
265
|
+
leaves_by_var = leaves_by_variable(metric_tree)
|
|
266
|
+
budget = max_samples if max_samples and max_samples > 0 else None
|
|
267
|
+
tree_view = context.get("tree_view")
|
|
268
|
+
|
|
269
|
+
def curve(n: str) -> tuple:
|
|
270
|
+
"""``(time, values)`` of a series' actual curve, from whichever
|
|
271
|
+
source answers it."""
|
|
272
|
+
if n in by_name:
|
|
273
|
+
traj = by_name[n].get("trajectory") or {}
|
|
274
|
+
return traj.get("act_time"), traj.get("act_values")
|
|
275
|
+
if n in nobaseline:
|
|
276
|
+
return nobaseline[n].get("time"), nobaseline[n].get("values")
|
|
277
|
+
return arrays[n][0], arrays[n][1]
|
|
278
|
+
|
|
279
|
+
def subject_view(n: str):
|
|
280
|
+
if n in by_name:
|
|
281
|
+
return view_from_trajectory(
|
|
282
|
+
n, by_name[n].get("trajectory") or {}, leaves_by_var.get(n, [])
|
|
283
|
+
)
|
|
284
|
+
if n in nobaseline:
|
|
285
|
+
t = nobaseline[n]
|
|
286
|
+
return build_view(
|
|
287
|
+
n,
|
|
288
|
+
label=texts.get(n, n),
|
|
289
|
+
act_time=t.get("time"),
|
|
290
|
+
act_values=t.get("values"),
|
|
291
|
+
overlays=t.get("overlays") or [],
|
|
292
|
+
)
|
|
293
|
+
time, values = curve(n)
|
|
294
|
+
return build_view(n, label=texts.get(n, n), act_time=time, act_values=values)
|
|
295
|
+
|
|
296
|
+
def context_view(n: str):
|
|
297
|
+
time, values = curve(n)
|
|
298
|
+
return build_view(n, label=texts.get(n, n), act_time=time, act_values=values)
|
|
299
|
+
|
|
300
|
+
def drawable(n: str) -> bool:
|
|
301
|
+
return n in by_name or n in nobaseline or n in arrays
|
|
302
|
+
|
|
303
|
+
records: list[dict] = []
|
|
304
|
+
figures: list[dict | None] = []
|
|
305
|
+
series: list[list[str]] = []
|
|
306
|
+
sections: list[dict] = []
|
|
307
|
+
for position, plot in enumerate(plots):
|
|
308
|
+
gone = set(plot.missing)
|
|
309
|
+
subject = [n for n in plot.subject if n not in gone and drawable(n)]
|
|
310
|
+
context_names = [n for n in plot.context if n not in gone and drawable(n)]
|
|
311
|
+
drawn = subject + context_names
|
|
312
|
+
missing = list(
|
|
313
|
+
dict.fromkeys([*plot.missing, *(n for n in plot.series if n not in drawn)])
|
|
314
|
+
)
|
|
315
|
+
# No drawable SUBJECT, no chart (the template's `plot.subject`),
|
|
316
|
+
# whatever the context: no figure either. The null keeps the
|
|
317
|
+
# section's place — report.js paints `PLOT_FIGURES[idx]` into
|
|
318
|
+
# `plot-${idx}`.
|
|
319
|
+
figures.append(
|
|
320
|
+
plot_figure(
|
|
321
|
+
[subject_view(n) for n in subject],
|
|
322
|
+
budget,
|
|
323
|
+
context=[context_view(n) for n in context_names],
|
|
324
|
+
theme=theme,
|
|
325
|
+
)
|
|
326
|
+
if subject
|
|
327
|
+
else None
|
|
328
|
+
)
|
|
329
|
+
variables = [
|
|
330
|
+
{
|
|
331
|
+
"name": n,
|
|
332
|
+
"vidx": vidx[n],
|
|
333
|
+
**{k: v for k, v in by_name[n].items() if k != "trajectory"},
|
|
334
|
+
"overlays": [
|
|
335
|
+
_overlay_row(ov) for ov in by_name[n].get("overlays") or []
|
|
336
|
+
],
|
|
337
|
+
}
|
|
338
|
+
# Every scored member's verdict, drawn or not: a leaf on a
|
|
339
|
+
# variable the result lacks failed, and its badge says so.
|
|
340
|
+
for n in plot.subject
|
|
341
|
+
if n in by_name
|
|
342
|
+
]
|
|
343
|
+
# Overlays and the error select: a subject of ONE variable (the
|
|
344
|
+
# context does not count), drawn. A no-baseline variable has
|
|
345
|
+
# overlays and no reference to take an error against.
|
|
346
|
+
single = len(plot.subject) == 1
|
|
347
|
+
lone = subject[0] if single and subject else None
|
|
348
|
+
overlays = (
|
|
349
|
+
by_name[lone].get("overlays")
|
|
350
|
+
if lone in by_name
|
|
351
|
+
else nobaseline[lone].get("overlays")
|
|
352
|
+
if lone in nobaseline
|
|
353
|
+
else None
|
|
354
|
+
) or []
|
|
355
|
+
criteria = criteria_rows(tree_view, plot.subject)
|
|
356
|
+
records.append(
|
|
357
|
+
{
|
|
358
|
+
"kind": plot.kind,
|
|
359
|
+
"series": drawn,
|
|
360
|
+
"subject": list(plot.subject),
|
|
361
|
+
"context": list(plot.context),
|
|
362
|
+
"watch": plot.watch,
|
|
363
|
+
"missing": missing,
|
|
364
|
+
}
|
|
365
|
+
)
|
|
366
|
+
series.append(drawn)
|
|
367
|
+
sections.append(
|
|
368
|
+
{
|
|
369
|
+
# By POSITION, not by ``plot.index``: report.js paints
|
|
370
|
+
# ``PLOT_FIGURES[idx]`` into ``plot-${idx}``, so a section
|
|
371
|
+
# numbered by anything else renders a blank chart.
|
|
372
|
+
"index": position,
|
|
373
|
+
# The title READS the plain labels of the SUBJECT; the
|
|
374
|
+
# names ride muted beside it, and only when one of them
|
|
375
|
+
# differs from its label — otherwise every title would say
|
|
376
|
+
# its variable twice. The muted name is what tells two
|
|
377
|
+
# series sharing one expression apart here, so the title
|
|
378
|
+
# does not repeat it. The fold is over the texts, as the
|
|
379
|
+
# tool's card title folds (`plot_card.folded`).
|
|
380
|
+
"title": ", ".join(
|
|
381
|
+
family_label(key, members, labels)
|
|
382
|
+
for key, members in group_families(list(plot.subject), texts)
|
|
383
|
+
),
|
|
384
|
+
"names": ""
|
|
385
|
+
if all(labels.get(n, n) == n for n in plot.subject)
|
|
386
|
+
else ", ".join(
|
|
387
|
+
family_label(key, members)
|
|
388
|
+
for key, members in group_families(list(plot.subject))
|
|
389
|
+
),
|
|
390
|
+
"series": drawn,
|
|
391
|
+
"subject": subject,
|
|
392
|
+
"missing": missing,
|
|
393
|
+
"single": single,
|
|
394
|
+
# The error select: ONE scored variable, drawn.
|
|
395
|
+
"errors": lone in by_name,
|
|
396
|
+
"watch": plot.watch,
|
|
397
|
+
"variables": variables,
|
|
398
|
+
"overlays": [_overlay_row(ov) for ov in overlays],
|
|
399
|
+
"criteria": criteria,
|
|
400
|
+
# Every crumb as wide as the longest, in ch: the leaf names
|
|
401
|
+
# start in one column.
|
|
402
|
+
"crumb_ch": max((len(row["crumbs"]) for row in criteria), default=0),
|
|
403
|
+
# Read as the texts: no muted name rides beside them.
|
|
404
|
+
"context": [texts.get(n, n) for n in plot.context],
|
|
405
|
+
}
|
|
406
|
+
)
|
|
407
|
+
return {
|
|
408
|
+
"plots": records,
|
|
409
|
+
"plot_figures": figures,
|
|
410
|
+
"plot_series": series,
|
|
411
|
+
"plot_sections": sections,
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _build_figures(context: dict, max_samples: int | None, theme: str) -> dict:
|
|
416
|
+
"""The page's diagnostic figures, in the theme the page is generated
|
|
417
|
+
in; the plots' are :func:`plot_page_keys`'.
|
|
418
|
+
Built from the FULL-resolution context — the view decimates under
|
|
419
|
+
``max_samples`` — and after the sidecar write, so
|
|
420
|
+
``comparison_data.json`` keeps its trajectory shape and no figure."""
|
|
421
|
+
from ..figures.plotly import variable_figure
|
|
422
|
+
from ..figures.view import build_view
|
|
423
|
+
|
|
424
|
+
budget = max_samples if max_samples and max_samples > 0 else None
|
|
425
|
+
diag = [
|
|
426
|
+
variable_figure(
|
|
427
|
+
build_view(
|
|
428
|
+
t["name"],
|
|
429
|
+
act_time=t.get("act_time"),
|
|
430
|
+
act_values=t.get("act_values"),
|
|
431
|
+
ref_time=t.get("ref_time"),
|
|
432
|
+
ref_values=t.get("ref_values"),
|
|
433
|
+
),
|
|
434
|
+
budget,
|
|
435
|
+
theme=theme,
|
|
436
|
+
)
|
|
437
|
+
for t in context.get("diag_trajectories") or []
|
|
438
|
+
]
|
|
439
|
+
return {"diag_figures": diag}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def generate_comparison_plots(
|
|
443
|
+
model_id: str,
|
|
444
|
+
ref_data: dict | None,
|
|
445
|
+
result,
|
|
446
|
+
comparisons: list[VariableComparison],
|
|
447
|
+
plot_dir: Path,
|
|
448
|
+
test_dir: Path | None = None,
|
|
449
|
+
test_model=None,
|
|
450
|
+
ref_file: Path | None = None,
|
|
451
|
+
warnings: list | None = None,
|
|
452
|
+
last_run_at: float | None = None,
|
|
453
|
+
metric_tree=None,
|
|
454
|
+
artifact_files: tuple[tuple[str, str], ...] = (),
|
|
455
|
+
# Must track Config.max_embedded_samples — both production callers pass
|
|
456
|
+
# it explicitly, so this default only fires for direct callers (tests).
|
|
457
|
+
max_embedded_samples: int = 1000,
|
|
458
|
+
# Config.theme, said by both production callers as the budget is: the
|
|
459
|
+
# bundled theme the page is generated in (D148).
|
|
460
|
+
theme: str = "default",
|
|
461
|
+
overlays: list | None = None,
|
|
462
|
+
comparison: TestComparison | None = None,
|
|
463
|
+
ref_id: str | None = None,
|
|
464
|
+
run_metadata: dict | None = None,
|
|
465
|
+
plots=None,
|
|
466
|
+
extra_series=None,
|
|
467
|
+
) -> Path | None:
|
|
468
|
+
"""Render this test's interactive.html (Plotly) + comparison_data.json sidecar.
|
|
469
|
+
|
|
470
|
+
*comparison* is the test's ``TestComparison``: the sidecar's verdict
|
|
471
|
+
comes from it. Without one (a direct caller holding only the leaves)
|
|
472
|
+
the sidecar carries the numbers and no verdict.
|
|
473
|
+
|
|
474
|
+
*plots* / *extra_series* are :func:`plot_inputs`' — the suite and the
|
|
475
|
+
review path resolve them with the backend's result-file readers; a
|
|
476
|
+
direct caller that passes none gets them from the same function over
|
|
477
|
+
*test_model*, with no file to read.
|
|
478
|
+
|
|
479
|
+
Returns the path to the rendered interactive.html. With neither
|
|
480
|
+
*plots* nor *test_model* there is nothing to derive the plots from, and
|
|
481
|
+
a page with none would say nothing about it: ValueError.
|
|
482
|
+
"""
|
|
483
|
+
if plots is None:
|
|
484
|
+
if test_model is None:
|
|
485
|
+
raise ValueError(
|
|
486
|
+
"generate_comparison_plots needs plots= or the test_model "
|
|
487
|
+
"they derive from"
|
|
488
|
+
)
|
|
489
|
+
plots, extra_series = plot_inputs(test_model, comparisons, result, None, None)
|
|
490
|
+
if plot_dir.exists():
|
|
491
|
+
shutil.rmtree(plot_dir)
|
|
492
|
+
plot_dir.mkdir(parents=True, exist_ok=True)
|
|
493
|
+
|
|
494
|
+
cur_stats = result.statistics if result else None
|
|
495
|
+
context = _build_template_context(
|
|
496
|
+
model_id,
|
|
497
|
+
comparisons,
|
|
498
|
+
ref_data,
|
|
499
|
+
cur_stats,
|
|
500
|
+
test_dir,
|
|
501
|
+
test_model,
|
|
502
|
+
result,
|
|
503
|
+
ref_file=ref_file,
|
|
504
|
+
warnings=warnings,
|
|
505
|
+
last_run_at=last_run_at,
|
|
506
|
+
metric_tree=metric_tree,
|
|
507
|
+
artifact_files=artifact_files,
|
|
508
|
+
overlays=overlays,
|
|
509
|
+
run_metadata=run_metadata,
|
|
510
|
+
# The page's label scope takes the plots too: a context or watch
|
|
511
|
+
# series named like another variable's expression is what decides
|
|
512
|
+
# whether that other one reads its name beside its label. Missing
|
|
513
|
+
# names included, as the tool's `Session.variable_texts` includes
|
|
514
|
+
# them: the plot still names them ("Not in this result: z").
|
|
515
|
+
plot_series=[n for p in plots for n in p.series],
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
# The page's plot keys, from the full-resolution context. Only the
|
|
519
|
+
# LAYOUT — names, no arrays — joins the sidecar; the figures are popped
|
|
520
|
+
# out and put back after the dump, where _build_figures is called.
|
|
521
|
+
page_keys = plot_page_keys(
|
|
522
|
+
context, plots, extra_series, metric_tree, max_embedded_samples, theme=theme
|
|
523
|
+
)
|
|
524
|
+
context["plots"] = page_keys.pop("plots")
|
|
525
|
+
|
|
526
|
+
# Write comparison_data.json alongside the HTML. This is the
|
|
527
|
+
# full-resolution data artifact for downstream tooling (notebooks,
|
|
528
|
+
# future JS lazy-fetch on zoom). ORDER CONSTRAINT: nothing that THINS
|
|
529
|
+
# the data may be IN the context at this point. The figures are built
|
|
530
|
+
# above (the layout record has to be in the sidecar, and it comes out
|
|
531
|
+
# of the same call) but are held OUT of ``context`` until after this
|
|
532
|
+
# write; only ``plots`` — names, no arrays — joins it. The page
|
|
533
|
+
# context, which drops the trajectory arrays, is built after it too.
|
|
534
|
+
data_path = plot_dir / "comparison_data.json"
|
|
535
|
+
# Summary block for the unified dashboard. The full context dict is
|
|
536
|
+
# the per-variable rendering input; the dashboard only needs row-level
|
|
537
|
+
# summary fields, exposed under "summary" so build_dashboard_context
|
|
538
|
+
# can read them without parsing the heavy variable arrays.
|
|
539
|
+
# `written_at` + `model_id` form a defensive double-entry-bookkeeping
|
|
540
|
+
# check for the dashboard's enricher: stale sidecars from a prior run
|
|
541
|
+
# (written_at < snapshot start_wall) and bookkeeping drift (sidecar's
|
|
542
|
+
# model_id ≠ row's model_id) are both filtered out so they can't
|
|
543
|
+
# override a fresh verdict.
|
|
544
|
+
import time as _time
|
|
545
|
+
|
|
546
|
+
# The one computation of a row's numbers (reporting.rows), shared with
|
|
547
|
+
# the dashboard's rows and, in the tool, the Suite table.
|
|
548
|
+
fields = summary_fields(
|
|
549
|
+
comparison
|
|
550
|
+
if comparison is not None
|
|
551
|
+
else TestComparison(
|
|
552
|
+
model_id=model_id, passed=True, variables=list(comparisons or ())
|
|
553
|
+
),
|
|
554
|
+
warnings=warnings,
|
|
555
|
+
stats=cur_stats if isinstance(cur_stats, dict) else None,
|
|
556
|
+
ref_id=ref_id,
|
|
557
|
+
ref_file=ref_file,
|
|
558
|
+
field_sources=getattr(test_model, "field_sources", None) or {},
|
|
559
|
+
)
|
|
560
|
+
if comparison is None:
|
|
561
|
+
fields.update(status=None, status_text=None, status_class=None)
|
|
562
|
+
context["summary"] = {
|
|
563
|
+
"model_id": model_id,
|
|
564
|
+
"written_at": _time.time(),
|
|
565
|
+
**fields,
|
|
566
|
+
}
|
|
567
|
+
# review 2026-07-06 finding 68: strict JSON — sanitize non-finite
|
|
568
|
+
# floats to null and forbid NaN/Infinity tokens at dump time.
|
|
569
|
+
data_path.write_text(
|
|
570
|
+
json.dumps(_json_sanitize(context), indent=2, default=str, allow_nan=False),
|
|
571
|
+
encoding="utf-8",
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
# Figures come from the full-resolution context and decimate themselves;
|
|
575
|
+
# they are added AFTER the sidecar write so the sidecar's shape (the
|
|
576
|
+
# handoff artifact dashboard_render reads) does not change.
|
|
577
|
+
context.update(page_keys)
|
|
578
|
+
context.update(_build_figures(context, max_embedded_samples, theme))
|
|
579
|
+
# Why the comparison refused the test, for the summary line: the page's
|
|
580
|
+
# own flags read a test with no scored variable as one with no baseline.
|
|
581
|
+
context["refused"] = refusal(comparison) if comparison is not None else None
|
|
582
|
+
|
|
583
|
+
# ORDER CONSTRAINT: the page context is built LAST — after the sidecar
|
|
584
|
+
# write (which needs the full-resolution arrays) and after the figure
|
|
585
|
+
# build (which reads them, and whose ``full_samples`` stamps are what
|
|
586
|
+
# ``decimated`` is computed from). Stripping any earlier would empty
|
|
587
|
+
# the artifact or the figures instead of just the page.
|
|
588
|
+
page_context = _page_context(context)
|
|
589
|
+
page_context["decimated"] = _decimated_traces(context)
|
|
590
|
+
|
|
591
|
+
# review 2026-07-06 finding 68: the embedded page context must be
|
|
592
|
+
# strict JSON too — Jinja's |tojson would otherwise emit Infinity/NaN
|
|
593
|
+
# literals into window.MT_REPORT.
|
|
594
|
+
page_context = _json_sanitize(page_context)
|
|
595
|
+
|
|
596
|
+
# Render interactive HTML (Plotly, inline figure dicts).
|
|
597
|
+
# Stage 5: the static ``comparison.html`` path was retired. The index
|
|
598
|
+
# page linked only to interactive.html; the per-variable PNGs still
|
|
599
|
+
# render next to it for direct reference.
|
|
600
|
+
interactive_path = plot_dir / "interactive.html"
|
|
601
|
+
_render_template("interactive.html", page_context, interactive_path, theme)
|
|
602
|
+
|
|
603
|
+
# Write the standalone interactive.js beside the HTML and the vendored
|
|
604
|
+
# Plotly one level up (shared by the suite) so the `<script src=...>`
|
|
605
|
+
# tags resolve. The JS is pure-static (no Jinja interpolations) — the
|
|
606
|
+
# HTML marshals the per-report data into window.MT_REPORT; the JS reads
|
|
607
|
+
# from there. Plotly ships with the report rather than loading from a
|
|
608
|
+
# CDN: an air-gapped lab otherwise gets no plots and no error.
|
|
609
|
+
write_report_assets(plot_dir)
|
|
610
|
+
|
|
611
|
+
return interactive_path
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _overlay_row(ov: dict) -> dict:
|
|
615
|
+
"""An overlay reduced to what the picker markup needs: its identity."""
|
|
616
|
+
return {"name": ov.get("name"), "role": ov.get("role"), "kind": ov.get("kind")}
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _page_context(context: dict) -> dict:
|
|
620
|
+
"""The page's copy of the render context, with no trajectory arrays.
|
|
621
|
+
|
|
622
|
+
The page draws figure dicts, so the arrays would only be a second
|
|
623
|
+
full-resolution copy of data the figures already carry. Built as a
|
|
624
|
+
COPY, after the sidecar write — ``comparison_data.json`` is the
|
|
625
|
+
documented downstream artifact and keeps its trajectory shape.
|
|
626
|
+
|
|
627
|
+
``variables_by_name`` is dropped whole: the page is laid out per PLOT,
|
|
628
|
+
and each section already carries its variables' verdicts, criteria and
|
|
629
|
+
overlay rows (:func:`plot_page_keys`). A no-baseline trajectory keeps
|
|
630
|
+
only its name: the summary line counts them, and its plot's section
|
|
631
|
+
carries the rest.
|
|
632
|
+
"""
|
|
633
|
+
page = dict(context)
|
|
634
|
+
page.pop("trajectories", None)
|
|
635
|
+
page.pop("variables_by_name", None)
|
|
636
|
+
page["diag_trajectories"] = [
|
|
637
|
+
{"name": t.get("name")} for t in context.get("diag_trajectories") or []
|
|
638
|
+
]
|
|
639
|
+
page["nobaseline_trajectories"] = [
|
|
640
|
+
{"name": t.get("name")} for t in context.get("nobaseline_trajectories") or []
|
|
641
|
+
]
|
|
642
|
+
return page
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _trace_label(tr: dict, texts: Mapping[str, str] | None) -> str | None:
|
|
646
|
+
"""What the banner calls one trace.
|
|
647
|
+
|
|
648
|
+
On a plot of one subject the trace's own name is right, and the banner
|
|
649
|
+
has always printed it; ``texts`` is None there. On a FAMILY's plot it
|
|
650
|
+
is not: a family's lead member carries the family's LEGEND label, so
|
|
651
|
+
the banner read ``pipe.T[1:4]`` where one member belonged, and a family
|
|
652
|
+
of one printed its name twice (once as the plot's title, once as the
|
|
653
|
+
trace). There the series comes off ``meta.id`` — the NAME — and
|
|
654
|
+
``texts`` turns it back into what the rest of the page calls it, so one
|
|
655
|
+
banner line does not pair a plot full of expressions with a trace
|
|
656
|
+
spelled as a result-file name. ``texts`` is the TEST's scope, like the
|
|
657
|
+
plot's own name in the row beside it. A derived trace — a band, an
|
|
658
|
+
error series, a spectrum — and a context trace keep their own names.
|
|
659
|
+
"""
|
|
660
|
+
name = tr.get("name")
|
|
661
|
+
if texts is None:
|
|
662
|
+
return name
|
|
663
|
+
trace_id = (tr.get("meta") or {}).get("id")
|
|
664
|
+
if not isinstance(trace_id, str):
|
|
665
|
+
return name
|
|
666
|
+
for prefix, suffix in (("actual/", ""), ("reference/", " (reference)")):
|
|
667
|
+
if trace_id.startswith(prefix):
|
|
668
|
+
series = trace_id[len(prefix) :]
|
|
669
|
+
return f"{texts.get(series, series)}{suffix}"
|
|
670
|
+
return name
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _decimated_traces(context: dict) -> list[dict]:
|
|
674
|
+
"""Every figure trace the sample budget shortened, named.
|
|
675
|
+
|
|
676
|
+
The figure layer stamps each trace's pre-decimation count in
|
|
677
|
+
``meta.full_samples`` and the count it actually carries in
|
|
678
|
+
``meta.samples``; a trace whose stamp exceeds what it carries was
|
|
679
|
+
decimated. Both counts are samples, not points on the wire — a band
|
|
680
|
+
trace is a closed polygon holding twice its sample count — so the
|
|
681
|
+
embedded side reads ``meta.samples`` and falls back to the x length
|
|
682
|
+
only for a trace the figure layer does not decimate. The template
|
|
683
|
+
renders this list as the banner, so the reader sees WHICH curves were
|
|
684
|
+
thinned rather than a page-wide total. A plot's trace is placed by its
|
|
685
|
+
PLOT's SUBJECT, folded the way the section heading folds it but read as
|
|
686
|
+
the TEXTS: the banner shows no muted name, so two plots of two
|
|
687
|
+
variables sharing one expression would otherwise be two identical rows.
|
|
688
|
+
A context trace is a trace of that plot, under its own name.
|
|
689
|
+
"""
|
|
690
|
+
out: list[dict] = []
|
|
691
|
+
texts = _context_labels(context, "text")
|
|
692
|
+
|
|
693
|
+
def scan(varname, fig: dict, *, texts: Mapping[str, str] | None = None) -> None:
|
|
694
|
+
for tr in fig.get("data") or []:
|
|
695
|
+
meta = tr.get("meta") or {}
|
|
696
|
+
full = meta.get("full_samples")
|
|
697
|
+
embedded = int(meta.get("samples", len(tr.get("x") or [])))
|
|
698
|
+
if full is None or full <= embedded:
|
|
699
|
+
continue
|
|
700
|
+
out.append(
|
|
701
|
+
{
|
|
702
|
+
"variable": varname,
|
|
703
|
+
"trace": _trace_label(tr, texts),
|
|
704
|
+
"full": int(full),
|
|
705
|
+
"embedded": embedded,
|
|
706
|
+
}
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
from ..figures.families import family_label, group_families
|
|
710
|
+
|
|
711
|
+
for section, fig in zip(
|
|
712
|
+
context.get("plot_sections") or [], context.get("plot_figures") or []
|
|
713
|
+
):
|
|
714
|
+
if fig is None:
|
|
715
|
+
continue # a section with no chart
|
|
716
|
+
subject = section.get("subject") or []
|
|
717
|
+
scan(
|
|
718
|
+
", ".join(
|
|
719
|
+
family_label(key, members, texts)
|
|
720
|
+
for key, members in group_families(subject, texts)
|
|
721
|
+
),
|
|
722
|
+
fig,
|
|
723
|
+
texts=texts if len(subject) > 1 else None,
|
|
724
|
+
)
|
|
725
|
+
for traj, fig in zip(
|
|
726
|
+
context.get("diag_trajectories") or [], context.get("diag_figures") or []
|
|
727
|
+
):
|
|
728
|
+
scan(traj.get("name"), fig)
|
|
729
|
+
return out
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def jinja_env(theme: str = "default"):
|
|
733
|
+
"""The per-test report's Jinja environment: the templates dir, autoescape,
|
|
734
|
+
and the token block every page starts its stylesheet with, in the
|
|
735
|
+
bundled theme ``theme``. One constructor, so a test that renders a page
|
|
736
|
+
gets the same globals."""
|
|
737
|
+
from jinja2 import Environment, FileSystemLoader
|
|
738
|
+
|
|
739
|
+
from .theme import css_variables
|
|
740
|
+
|
|
741
|
+
env = Environment(loader=FileSystemLoader(str(_TEMPLATE_DIR)), autoescape=True)
|
|
742
|
+
env.globals["dstf_css"] = css_variables(theme)
|
|
743
|
+
return env
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _render_template(
|
|
747
|
+
template_name: str, context: dict, output_path: Path, theme: str
|
|
748
|
+
) -> None:
|
|
749
|
+
"""Render a Jinja2 template with the given context."""
|
|
750
|
+
template = jinja_env(theme).get_template(template_name)
|
|
751
|
+
html = template.render(**context)
|
|
752
|
+
output_path.write_text(html, encoding="utf-8")
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _build_per_test_args(
|
|
756
|
+
comp,
|
|
757
|
+
results,
|
|
758
|
+
test_lookup,
|
|
759
|
+
store,
|
|
760
|
+
config,
|
|
761
|
+
manifest_meta_by_model,
|
|
762
|
+
artifact_files=(),
|
|
763
|
+
backend_cls=None,
|
|
764
|
+
):
|
|
765
|
+
"""Resolve all per-test inputs needed to render that test's report."""
|
|
766
|
+
from .overlay_loader import load_overlays
|
|
767
|
+
|
|
768
|
+
model_id = comp.model_id
|
|
769
|
+
result = results.get(model_id)
|
|
770
|
+
test = test_lookup.get(model_id)
|
|
771
|
+
ref_data = store.get_reference(model_id)
|
|
772
|
+
meta = manifest_meta_by_model.get(model_id, {})
|
|
773
|
+
test_key = meta.get("test_key")
|
|
774
|
+
last_run_at = meta.get("last_run_at")
|
|
775
|
+
# A2 — load every registered overlay for this model (soft_checks +
|
|
776
|
+
# companions). Failures are absorbed into Overlay.status so the
|
|
777
|
+
# report never breaks on a moved/renamed companion file.
|
|
778
|
+
# Passing ``config`` also auto-discovers peer-backend refs as
|
|
779
|
+
# sibling-backend companions (visual-only pre-accept cross-check).
|
|
780
|
+
overlays = load_overlays(store, model_id, config=config)
|
|
781
|
+
|
|
782
|
+
# One classifier for every surface (reporting.status). The private copy
|
|
783
|
+
# that used to live here existed only because simulate_only was readable
|
|
784
|
+
# from the TestModel and not from the comparison; TestComparison carries
|
|
785
|
+
# it now, so the carve-out is gone (D117 closed).
|
|
786
|
+
status_text, status_class = classify_with_class(comp)
|
|
787
|
+
|
|
788
|
+
test_dir = config.work_dir / test_key if test_key else None
|
|
789
|
+
|
|
790
|
+
# The page's plots, through the one helper (it derives them from the
|
|
791
|
+
# tree itself, so this path and cli/review.py cannot drift).
|
|
792
|
+
plots, extra_series = plot_inputs(
|
|
793
|
+
test, comp.variables, result, backend_cls, test_dir
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
ref_file = None
|
|
797
|
+
test_id = store.index.get_id(model_id)
|
|
798
|
+
if test_id:
|
|
799
|
+
from ..storage.ref_index import RefIndex
|
|
800
|
+
|
|
801
|
+
ref_file = store.ref_dir / RefIndex.ref_filename(test_id)
|
|
802
|
+
|
|
803
|
+
if comp.test_id:
|
|
804
|
+
report_id = f"ref_{comp.test_id}"
|
|
805
|
+
elif test_key:
|
|
806
|
+
report_id = test_key
|
|
807
|
+
else:
|
|
808
|
+
report_id = _sanitize_filename(model_id)
|
|
809
|
+
|
|
810
|
+
# The test's baseline, for its row's Reference: the one it was compared
|
|
811
|
+
# against, else the one the store holds for it. A comparison that
|
|
812
|
+
# compared nothing (a failed simulation's, a test's with no result)
|
|
813
|
+
# names none, and its sidecar said null beside that baseline's file.
|
|
814
|
+
ref_number = comp.test_id or test_id
|
|
815
|
+
|
|
816
|
+
return {
|
|
817
|
+
"model_id": model_id,
|
|
818
|
+
"ref_data": ref_data,
|
|
819
|
+
"result": result,
|
|
820
|
+
"test": test,
|
|
821
|
+
"test_dir": test_dir,
|
|
822
|
+
"ref_file": ref_file,
|
|
823
|
+
"warnings": comp.warnings,
|
|
824
|
+
"report_id": report_id,
|
|
825
|
+
"test_key": test_key,
|
|
826
|
+
"status_text": status_text,
|
|
827
|
+
"status_class": status_class,
|
|
828
|
+
"ref_id": f"ref_{ref_number}" if ref_number else None,
|
|
829
|
+
"last_run_at": last_run_at,
|
|
830
|
+
"comparison": comp,
|
|
831
|
+
"comp_variables": comp.variables,
|
|
832
|
+
"metric_tree": comp.metric_tree,
|
|
833
|
+
"artifact_files": artifact_files,
|
|
834
|
+
"max_embedded_samples": config.max_embedded_samples,
|
|
835
|
+
"theme": config.theme,
|
|
836
|
+
"overlays": overlays,
|
|
837
|
+
"plots": plots,
|
|
838
|
+
"extra_series": extra_series,
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _render_one_test(args: dict, report_dir: Path) -> dict:
|
|
843
|
+
"""Render a single test's report. Returns the index entry."""
|
|
844
|
+
import time as _time
|
|
845
|
+
|
|
846
|
+
t0 = _time.monotonic()
|
|
847
|
+
plot_dir = report_dir / args["report_id"]
|
|
848
|
+
html_path = generate_comparison_plots(
|
|
849
|
+
model_id=args["model_id"],
|
|
850
|
+
ref_data=args["ref_data"],
|
|
851
|
+
result=args["result"],
|
|
852
|
+
comparisons=args["comp_variables"],
|
|
853
|
+
plot_dir=plot_dir,
|
|
854
|
+
test_dir=args["test_dir"],
|
|
855
|
+
test_model=args["test"],
|
|
856
|
+
ref_file=args["ref_file"],
|
|
857
|
+
warnings=args["warnings"],
|
|
858
|
+
last_run_at=args["last_run_at"],
|
|
859
|
+
metric_tree=args.get("metric_tree"),
|
|
860
|
+
artifact_files=args.get("artifact_files", ()),
|
|
861
|
+
max_embedded_samples=args["max_embedded_samples"],
|
|
862
|
+
theme=args["theme"],
|
|
863
|
+
overlays=args.get("overlays"),
|
|
864
|
+
comparison=args.get("comparison"),
|
|
865
|
+
ref_id=args.get("ref_id"),
|
|
866
|
+
run_metadata=args.get("run_metadata"),
|
|
867
|
+
plots=args.get("plots"),
|
|
868
|
+
extra_series=args.get("extra_series"),
|
|
869
|
+
)
|
|
870
|
+
render_elapsed = _time.monotonic() - t0
|
|
871
|
+
return {
|
|
872
|
+
"model_id": args["model_id"],
|
|
873
|
+
"status_text": args["status_text"],
|
|
874
|
+
"status_class": args["status_class"],
|
|
875
|
+
"ref_id": args["ref_id"],
|
|
876
|
+
"test_key": args["test_key"],
|
|
877
|
+
"last_run_at": args["last_run_at"],
|
|
878
|
+
"report_path": f"{args['report_id']}/interactive.html" if html_path else None,
|
|
879
|
+
"_render_elapsed": render_elapsed,
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _read_run_metadata(work_dir: Path) -> dict | None:
|
|
884
|
+
"""Read + shape the run provenance from ``work_dir/status.json`` for the
|
|
885
|
+
per-test banner. Returns None (banner omitted) when status.json is absent,
|
|
886
|
+
unreadable, or predates the ``metadata`` field. Never raises."""
|
|
887
|
+
from ..backends.progress import read_status
|
|
888
|
+
from .dashboard_render import format_run_metadata
|
|
889
|
+
|
|
890
|
+
snapshot = read_status(work_dir)
|
|
891
|
+
if snapshot is None:
|
|
892
|
+
return None
|
|
893
|
+
return format_run_metadata(snapshot.get("metadata"))
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def generate_report_suite(
|
|
897
|
+
comparisons: list,
|
|
898
|
+
results: dict,
|
|
899
|
+
tests: list,
|
|
900
|
+
store,
|
|
901
|
+
config,
|
|
902
|
+
) -> Path:
|
|
903
|
+
"""Generate per-test comparison reports.
|
|
904
|
+
|
|
905
|
+
Per-test report rendering runs on a thread pool sized by config.parallel.
|
|
906
|
+
The hot work is Plotly trace JSON serialization + comparison_data.json
|
|
907
|
+
dump + decimation; numpy/json release the GIL well enough that threads
|
|
908
|
+
give a meaningful speedup without the pickling cost of a process pool.
|
|
909
|
+
|
|
910
|
+
Returns the path to the unified dashboard HTML file (rendered separately
|
|
911
|
+
by ``dashboard_render.render_final``).
|
|
912
|
+
"""
|
|
913
|
+
import time as _time
|
|
914
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
915
|
+
|
|
916
|
+
report_wall_start = _time.monotonic()
|
|
917
|
+
report_dir = config.work_dir / "reports"
|
|
918
|
+
report_dir.mkdir(parents=True, exist_ok=True)
|
|
919
|
+
|
|
920
|
+
test_lookup = {t.model_id: t for t in tests}
|
|
921
|
+
|
|
922
|
+
# Resolve the backend CLASS once up front — its artifact file list and
|
|
923
|
+
# the result-file readers a test's plot extras need are both class
|
|
924
|
+
# attributes, so no backend is instantiated (which would trigger the
|
|
925
|
+
# fmpy import if the extra isn't installed). An unregistered backend
|
|
926
|
+
# leaves both at their empty defaults rather than raising per test.
|
|
927
|
+
from ..backends import get_backend_class
|
|
928
|
+
|
|
929
|
+
try:
|
|
930
|
+
backend_cls = get_backend_class(config)
|
|
931
|
+
artifact_files = tuple(backend_cls.artifact_files)
|
|
932
|
+
except ValueError:
|
|
933
|
+
backend_cls = None
|
|
934
|
+
artifact_files = ()
|
|
935
|
+
|
|
936
|
+
# Cache test_key + last_run_at lookup once instead of scanning manifests per test
|
|
937
|
+
manifest_meta_by_model: dict[str, dict] = {}
|
|
938
|
+
manifest_paths = sorted(config.work_dir.glob("batch_manifest.json"))
|
|
939
|
+
if manifest_paths:
|
|
940
|
+
from ..backends import BatchManifest
|
|
941
|
+
|
|
942
|
+
for mp in manifest_paths:
|
|
943
|
+
bm = BatchManifest.load(mp)
|
|
944
|
+
for tk, entry in bm.manifest.items():
|
|
945
|
+
manifest_meta_by_model.setdefault(
|
|
946
|
+
entry["model_id"],
|
|
947
|
+
{
|
|
948
|
+
"test_key": tk,
|
|
949
|
+
"last_run_at": entry.get("last_run_at"),
|
|
950
|
+
},
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
# Run provenance for the per-test banner — read once from status.json and
|
|
954
|
+
# shaped by the same formatter the dashboard uses, so both surfaces agree.
|
|
955
|
+
# None when status.json predates the metadata field (banner is omitted).
|
|
956
|
+
run_metadata = _read_run_metadata(config.work_dir)
|
|
957
|
+
|
|
958
|
+
# Resolve per-test args sequentially (cheap dict/store lookups), then
|
|
959
|
+
# render reports in parallel
|
|
960
|
+
work = [
|
|
961
|
+
_build_per_test_args(
|
|
962
|
+
comp,
|
|
963
|
+
results,
|
|
964
|
+
test_lookup,
|
|
965
|
+
store,
|
|
966
|
+
config,
|
|
967
|
+
manifest_meta_by_model,
|
|
968
|
+
artifact_files,
|
|
969
|
+
backend_cls,
|
|
970
|
+
)
|
|
971
|
+
for comp in comparisons
|
|
972
|
+
]
|
|
973
|
+
for args in work:
|
|
974
|
+
args["run_metadata"] = run_metadata
|
|
975
|
+
|
|
976
|
+
n_workers = max(1, min(getattr(config, "parallel", 1) or 1, len(work)))
|
|
977
|
+
index_tests: list[dict] = []
|
|
978
|
+
total_reports = len(work)
|
|
979
|
+
|
|
980
|
+
from ..backends.base import _print_progress
|
|
981
|
+
|
|
982
|
+
print(f"Generating {total_reports} reports (parallel={n_workers})...")
|
|
983
|
+
|
|
984
|
+
if n_workers <= 1 or len(work) <= 1:
|
|
985
|
+
for i, args in enumerate(work, 1):
|
|
986
|
+
index_tests.append(_render_one_test(args, report_dir))
|
|
987
|
+
short = args["model_id"].rsplit(".", 1)[-1]
|
|
988
|
+
_print_progress(i, total_reports, short, "ok")
|
|
989
|
+
else:
|
|
990
|
+
# Preserve original test order in the index
|
|
991
|
+
results_by_model: dict[str, dict] = {}
|
|
992
|
+
completed = 0
|
|
993
|
+
with ThreadPoolExecutor(
|
|
994
|
+
max_workers=n_workers, thread_name_prefix="report"
|
|
995
|
+
) as pool:
|
|
996
|
+
futures = {
|
|
997
|
+
pool.submit(_render_one_test, args, report_dir): args["model_id"]
|
|
998
|
+
for args in work
|
|
999
|
+
}
|
|
1000
|
+
for future in as_completed(futures):
|
|
1001
|
+
entry = future.result()
|
|
1002
|
+
results_by_model[entry["model_id"]] = entry
|
|
1003
|
+
completed += 1
|
|
1004
|
+
short = entry["model_id"].rsplit(".", 1)[-1]
|
|
1005
|
+
_print_progress(completed, total_reports, short, "ok")
|
|
1006
|
+
index_tests = [results_by_model[args["model_id"]] for args in work]
|
|
1007
|
+
|
|
1008
|
+
# Per-test interactive.html + comparison_data.json sidecars are
|
|
1009
|
+
# written above. The unified work_dir/dashboard.html is rendered
|
|
1010
|
+
# by cli._generate_report_suite via dashboard_render.render_final;
|
|
1011
|
+
# the standalone index.html that used to live here is retired.
|
|
1012
|
+
|
|
1013
|
+
# Phase timing — exposes whether parallelism is helping
|
|
1014
|
+
wall = _time.monotonic() - report_wall_start
|
|
1015
|
+
elapsed = [t.get("_render_elapsed", 0.0) for t in index_tests]
|
|
1016
|
+
total_work = sum(elapsed)
|
|
1017
|
+
if elapsed:
|
|
1018
|
+
slowest = max(elapsed)
|
|
1019
|
+
avg = total_work / len(elapsed)
|
|
1020
|
+
speedup = (total_work / wall) if wall > 0 else 0.0
|
|
1021
|
+
print(
|
|
1022
|
+
f"Report phase: {wall:.0f}s wall, {total_work:.0f}s total work, "
|
|
1023
|
+
f"{speedup:.1f}x parallel speedup (avg {avg:.1f}s/test, slowest {slowest:.1f}s)"
|
|
1024
|
+
)
|
|
1025
|
+
return config.work_dir / "dashboard.html"
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def open_in_browser(path: Path, *, launch: bool = True) -> None:
|
|
1029
|
+
"""Open a file in the system's default browser/viewer.
|
|
1030
|
+
|
|
1031
|
+
Always prints a ``file://`` URL so the user has a clickable fallback
|
|
1032
|
+
(most terminals make it clickable); ``launch=False`` — ``run`` and
|
|
1033
|
+
``compare`` under ``--no-browser`` — stops there.
|
|
1034
|
+
"""
|
|
1035
|
+
print(f"View: {path.resolve().as_uri()}")
|
|
1036
|
+
if launch:
|
|
1037
|
+
_launch_opener(path)
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def _launch_opener(path: Path) -> None:
|
|
1041
|
+
"""Hand ``path`` to the system's opener. Subprocess stdout/stderr are
|
|
1042
|
+
silenced — on headless Linux/WSL ``xdg-open`` prints a long list of
|
|
1043
|
+
"Permission denied" candidates when no browser is registered, and
|
|
1044
|
+
that noise has no actionable signal for the user.
|
|
1045
|
+
|
|
1046
|
+
The one place a process is started for a browser: the test suite
|
|
1047
|
+
replaces it (``tests/conftest.py``), so no test opens one.
|
|
1048
|
+
"""
|
|
1049
|
+
system = platform.system()
|
|
1050
|
+
silent = {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL}
|
|
1051
|
+
try:
|
|
1052
|
+
if system == "Windows":
|
|
1053
|
+
subprocess.Popen(["start", "", str(path)], shell=True, **silent)
|
|
1054
|
+
elif system == "Darwin":
|
|
1055
|
+
subprocess.Popen(["open", str(path)], **silent)
|
|
1056
|
+
else:
|
|
1057
|
+
subprocess.Popen(["xdg-open", str(path)], **silent)
|
|
1058
|
+
except OSError:
|
|
1059
|
+
pass
|