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,534 @@
|
|
|
1
|
+
"""Unified dashboard renderer.
|
|
2
|
+
|
|
3
|
+
One Jinja template (`dashboard.html`) feeds both live progress
|
|
4
|
+
during a run and the post-comparison report. The template renders
|
|
5
|
+
gracefully whether comparison data is present (post-run) or absent
|
|
6
|
+
(during run).
|
|
7
|
+
|
|
8
|
+
Live mode re-renders the whole page to disk on every state change
|
|
9
|
+
(driven by ProgressReporter) and emits `<meta http-equiv="refresh"
|
|
10
|
+
content="5">`, so the browser full-reloads every 5s. JS-fetch was
|
|
11
|
+
tried first and is silently blocked on `file://` URLs — see the
|
|
12
|
+
comment in `templates/dashboard.js`. Between reloads a 1s JS timer
|
|
13
|
+
ticks the elapsed counters so they don't look frozen.
|
|
14
|
+
|
|
15
|
+
Final mode is rendered by the command that ends a run (cli.cmd_run /
|
|
16
|
+
cmd_compare, the tool's queue as it drains); it drops the refresh meta tag (the page
|
|
17
|
+
becomes static) and adds each test's verdict and numbers (from the
|
|
18
|
+
comparisons the caller holds), per-test report links + post-run columns
|
|
19
|
+
(worst score, warnings, translate/sim/total wall times) from the
|
|
20
|
+
sidecars.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import time
|
|
29
|
+
import uuid
|
|
30
|
+
from collections.abc import Iterable, Mapping
|
|
31
|
+
from dataclasses import replace
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
35
|
+
|
|
36
|
+
from ..backends.base import kept_run_result
|
|
37
|
+
from ..backends.common.result_variables import run_timing
|
|
38
|
+
from ..backends.progress import read_status
|
|
39
|
+
from ..backends.types import TestResult
|
|
40
|
+
from ..comparison.types import TestComparison
|
|
41
|
+
from .rows import (
|
|
42
|
+
COLUMNS,
|
|
43
|
+
FILTER_HINTS,
|
|
44
|
+
TestRow,
|
|
45
|
+
cpu_time,
|
|
46
|
+
enrich_row,
|
|
47
|
+
row_from_status,
|
|
48
|
+
summary_fields,
|
|
49
|
+
)
|
|
50
|
+
from .status import (
|
|
51
|
+
CANCELLED,
|
|
52
|
+
PILL_STATUSES,
|
|
53
|
+
STATUS_CLASSES,
|
|
54
|
+
STATUS_LABELS,
|
|
55
|
+
refusal,
|
|
56
|
+
scored_status,
|
|
57
|
+
)
|
|
58
|
+
from .theme import css_variables
|
|
59
|
+
|
|
60
|
+
_TEMPLATE_DIR = Path(__file__).parent / "templates"
|
|
61
|
+
_env = Environment(
|
|
62
|
+
loader=FileSystemLoader(_TEMPLATE_DIR),
|
|
63
|
+
autoescape=select_autoescape(["html"]),
|
|
64
|
+
)
|
|
65
|
+
# A column filter's placeholder and tooltip, by kind — the Suite table's too.
|
|
66
|
+
_env.globals["filter_hints"] = FILTER_HINTS
|
|
67
|
+
# A sort value as a data-sort-* attribute: None is the empty string, which
|
|
68
|
+
# dashboard.js sorts last in both directions.
|
|
69
|
+
_env.filters["sortval"] = lambda v: "" if v is None else v
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _read_comparison_sidecar(work_dir: Path, report_dir: str) -> dict | None:
|
|
73
|
+
"""Read per-test comparison_data.json if present.
|
|
74
|
+
|
|
75
|
+
Per-test reports are at <work_dir>/reports/<report_dir>/comparison_data.json
|
|
76
|
+
(matches generate_comparison_plots layout).
|
|
77
|
+
"""
|
|
78
|
+
p = work_dir / "reports" / report_dir / "comparison_data.json"
|
|
79
|
+
if not p.exists():
|
|
80
|
+
return None
|
|
81
|
+
try:
|
|
82
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
83
|
+
except json.JSONDecodeError:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_rerun_prefix(config) -> str:
|
|
88
|
+
"""Build the CLI prefix the dashboard uses in its rerun-command builder.
|
|
89
|
+
|
|
90
|
+
Produces e.g. `dstf --config "/abs/path/testing.json"` — the global
|
|
91
|
+
flags only, NO subcommand: the dashboard's command menu picks the
|
|
92
|
+
subcommand (`run`, `compare`, `compare --accept`,
|
|
93
|
+
`run --rerun failed,sim-failed`) and appends ` --filter ...`, so the
|
|
94
|
+
user can paste the result into any terminal regardless of CWD.
|
|
95
|
+
Prefers --config when available; otherwise falls back to
|
|
96
|
+
--source-path (+ optional --reference-root).
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def q(p) -> str:
|
|
100
|
+
s = str(p)
|
|
101
|
+
return f'"{s}"' if " " in s else s
|
|
102
|
+
|
|
103
|
+
if getattr(config, "config_file", None):
|
|
104
|
+
return f"dstf --config {q(config.config_file)}"
|
|
105
|
+
|
|
106
|
+
parts = ["dstf"]
|
|
107
|
+
if getattr(config, "source_path", None):
|
|
108
|
+
parts += ["--source-path", q(config.source_path)]
|
|
109
|
+
if getattr(config, "reference_root", None):
|
|
110
|
+
parts += ["--reference-root", q(config.reference_root)]
|
|
111
|
+
return " ".join(parts)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def format_run_metadata(meta: dict | None) -> dict | None:
|
|
115
|
+
"""Turn the raw ``status.json`` ``metadata`` block into a template-friendly
|
|
116
|
+
provenance dict, or ``None`` when no metadata was recorded.
|
|
117
|
+
|
|
118
|
+
Adds a display ``label`` (the configured backend name, or "unknown"
|
|
119
|
+
when a stale stamp does not record one) and a human-readable ``generated_str`` from the epoch
|
|
120
|
+
timestamp. Kept separate from ``build_dashboard_context`` so the per-test
|
|
121
|
+
interactive report can reuse the exact same shaping.
|
|
122
|
+
"""
|
|
123
|
+
if not meta:
|
|
124
|
+
return None
|
|
125
|
+
label = meta.get("backend_name") or "unknown"
|
|
126
|
+
generated_at = meta.get("generated_at")
|
|
127
|
+
generated_str = None
|
|
128
|
+
if generated_at:
|
|
129
|
+
try:
|
|
130
|
+
generated_str = time.strftime(
|
|
131
|
+
"%Y-%m-%d %H:%M", time.localtime(generated_at)
|
|
132
|
+
)
|
|
133
|
+
except Exception: # pragma: no cover — defensive against bad epoch
|
|
134
|
+
generated_str = None
|
|
135
|
+
# Surface the Modelica Standard Library version prominently (the second
|
|
136
|
+
# drift suspect); keep the full library_versions dict for completeness.
|
|
137
|
+
lib_versions = meta.get("library_versions") or {}
|
|
138
|
+
msl = lib_versions.get("Modelica") if isinstance(lib_versions, dict) else None
|
|
139
|
+
return {
|
|
140
|
+
"label": label,
|
|
141
|
+
"backend": meta.get("backend"),
|
|
142
|
+
"os": meta.get("os"),
|
|
143
|
+
"tool_version": meta.get("tool_version"),
|
|
144
|
+
"dstf_version": meta.get("dstf_version"),
|
|
145
|
+
"generated_str": generated_str,
|
|
146
|
+
"msl": msl,
|
|
147
|
+
"library_versions": lib_versions or None,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
#: A tool version string → its short form, first match wins: Dymola's
|
|
152
|
+
#: ``DymolaVersion()`` ("Dymola Version 2026x, 2025-10-10") and omc's
|
|
153
|
+
#: ``getVersion()`` ("OpenModelica 1.27.0~dev-...", "OpenModelica v1.27.0").
|
|
154
|
+
_SHORT_TOOL_VERSION = (
|
|
155
|
+
(re.compile(r"^Dymola Version ([^,]+)"), "Dymola {}"),
|
|
156
|
+
(re.compile(r"^OpenModelica v?(\d+(?:\.\d+)*)"), "OpenModelica {}"),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def short_tool_version(
|
|
161
|
+
tool_version: str | None, backend_name: str | None
|
|
162
|
+
) -> str | None:
|
|
163
|
+
"""What a narrow place names the backend by: the tool and its release
|
|
164
|
+
from *tool_version* ("Dymola 2026x", "OpenModelica 1.27.0"), or
|
|
165
|
+
*backend_name* when the string is none of those — a version this does
|
|
166
|
+
not know how to shorten reads as the backend's name, never cut."""
|
|
167
|
+
for pattern, form in _SHORT_TOOL_VERSION:
|
|
168
|
+
m = pattern.match(tool_version or "")
|
|
169
|
+
if m:
|
|
170
|
+
return form.format(m.group(1).strip())
|
|
171
|
+
return backend_name
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _with_kept_timing(work_dir: Path, row: TestRow) -> TestRow:
|
|
175
|
+
"""*row* with the phase walls its run kept in
|
|
176
|
+
``<work_dir>/<test_key>/run_stats.json``, through the same
|
|
177
|
+
:func:`run_timing` a result is read with; *row* unchanged when the run
|
|
178
|
+
kept none (a run from before the file existed)."""
|
|
179
|
+
assert row.test_key
|
|
180
|
+
kept = kept_run_result(work_dir / row.test_key, row.model_id, row.test_key)
|
|
181
|
+
if kept is None:
|
|
182
|
+
return row
|
|
183
|
+
timing = run_timing(kept)
|
|
184
|
+
return replace(
|
|
185
|
+
row,
|
|
186
|
+
translation_wall=timing.get("translation_wall"),
|
|
187
|
+
sim_wall=timing.get("sim_wall"),
|
|
188
|
+
total_wall=timing.get("total_wall"),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _with_verdict(row: TestRow, comp: TestComparison) -> TestRow:
|
|
193
|
+
"""*row* with *comp*'s verdict and score numbers, through
|
|
194
|
+
``summary_fields`` — the computation the sidecar is written with.
|
|
195
|
+
|
|
196
|
+
Applied AFTER a sidecar's enrichment: the comparison held in hand is
|
|
197
|
+
this invocation's verdict, and a sidecar from an earlier ``--report``
|
|
198
|
+
(fresh against this run's start, because nothing re-ran) says what the
|
|
199
|
+
spec said then. The verdict, its numbers and the baseline it was scored
|
|
200
|
+
against are the comparison's; what only the sidecar or the run's kept
|
|
201
|
+
file knows (the phase walls) and what only the live row knows (its
|
|
202
|
+
provenance) survive.
|
|
203
|
+
|
|
204
|
+
The ref_id is the comparison's when it names one — ``compare --backend
|
|
205
|
+
<other>`` on another backend's work dir scores against a baseline the
|
|
206
|
+
report_dir and the sidecar do not name. The sidecar's reference link
|
|
207
|
+
survives only when its ref_id is the comparison's: otherwise it opens
|
|
208
|
+
the other baseline. A comparison with no stored baseline names none,
|
|
209
|
+
and the row keeps whatever it had.
|
|
210
|
+
|
|
211
|
+
The Message is the run's (status.json's ``detail``) — but for a test
|
|
212
|
+
the comparison refused, whose reason is the one thing to read of it.
|
|
213
|
+
|
|
214
|
+
The status is the verdict as a row reads it (``scored_status``): a row
|
|
215
|
+
whose run was stopped keeps CANCELLED over the NOT RUN it scores.
|
|
216
|
+
"""
|
|
217
|
+
summary = summary_fields(comp)
|
|
218
|
+
ref_id, ref_file = row.ref_id, row.ref_file
|
|
219
|
+
if comp.test_id:
|
|
220
|
+
ref_id = f"ref_{comp.test_id}"
|
|
221
|
+
if ref_id != row.ref_id:
|
|
222
|
+
ref_file = None
|
|
223
|
+
return replace(
|
|
224
|
+
row,
|
|
225
|
+
status=scored_status(summary["status"], cancelled=row.status == CANCELLED),
|
|
226
|
+
worst_score=summary["worst_nrmse"],
|
|
227
|
+
n_unscored=summary["n_unscored"],
|
|
228
|
+
n_vars=summary["n_vars"],
|
|
229
|
+
n_vars_passed=summary["n_vars_passed"],
|
|
230
|
+
n_warnings=summary["n_warnings"],
|
|
231
|
+
ref_id=ref_id,
|
|
232
|
+
ref_file=ref_file,
|
|
233
|
+
message=refusal(comp) or row.message,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _manifest_rows(work_dir: Path) -> dict[str, tuple[TestRow, float | None]]:
|
|
238
|
+
"""``{model_id: (row, last_run_at)}`` for every test
|
|
239
|
+
``batch_manifest.json`` keys, in its order: the work-dir key and the
|
|
240
|
+
baseline the run named, and the report dir a run gives such a row
|
|
241
|
+
(``ref_NNNN``, else the key); beside it the wall clock that test's last
|
|
242
|
+
run started at (``assign_test_keys`` stamps ``last_run_at`` then, with
|
|
243
|
+
the clock a sidecar's ``written_at`` uses), ``None`` when the entry
|
|
244
|
+
holds no number. Read as data — never quarantined, as
|
|
245
|
+
``BatchManifest.load`` does a corrupt one: a renderer moves no file. A
|
|
246
|
+
model under two keys takes the last, as ``last_test_keys`` does."""
|
|
247
|
+
try:
|
|
248
|
+
entries = json.loads(
|
|
249
|
+
(work_dir / "batch_manifest.json").read_text(encoding="utf-8")
|
|
250
|
+
)
|
|
251
|
+
except (OSError, ValueError):
|
|
252
|
+
return {}
|
|
253
|
+
rows: dict[str, tuple[TestRow, float | None]] = {}
|
|
254
|
+
for key, entry in entries.items() if isinstance(entries, dict) else ():
|
|
255
|
+
model_id = entry.get("model_id") if isinstance(entry, dict) else None
|
|
256
|
+
if not isinstance(model_id, str):
|
|
257
|
+
continue
|
|
258
|
+
ref_id = entry.get("ref_id")
|
|
259
|
+
ref_id = ref_id if isinstance(ref_id, str) else None
|
|
260
|
+
last_run_at = entry.get("last_run_at")
|
|
261
|
+
if isinstance(last_run_at, bool) or not isinstance(last_run_at, int | float):
|
|
262
|
+
last_run_at = None
|
|
263
|
+
rows[model_id] = (
|
|
264
|
+
TestRow(
|
|
265
|
+
model_id=model_id, test_key=key, ref_id=ref_id, report_dir=ref_id or key
|
|
266
|
+
),
|
|
267
|
+
last_run_at,
|
|
268
|
+
)
|
|
269
|
+
return rows
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def build_dashboard_context(
|
|
273
|
+
work_dir: Path,
|
|
274
|
+
mode: str,
|
|
275
|
+
rerun_prefix: str | None = None,
|
|
276
|
+
comparisons: Iterable[TestComparison] = (),
|
|
277
|
+
results: Mapping[str, TestResult] | None = None,
|
|
278
|
+
) -> dict:
|
|
279
|
+
"""Build the Jinja context for dashboard.html.
|
|
280
|
+
|
|
281
|
+
mode='live' — auto_refresh=True, post-run fields stay None
|
|
282
|
+
mode='final' — auto_refresh=False, a fresh sidecar (when --report wrote
|
|
283
|
+
one) enriches each row — a row without one takes the phase walls its
|
|
284
|
+
run kept (``run_stats.json``) — then its comparison (when the caller
|
|
285
|
+
holds one) sets its verdict and numbers. The rows are status.json's —
|
|
286
|
+
the last run's scope, which each run rewrites — then every other test
|
|
287
|
+
the caller holds a comparison for, in ``batch_manifest.json``'s order,
|
|
288
|
+
then by model id (D136's amendment: ``compare`` after a ``--filter``
|
|
289
|
+
run, a ``--merge`` and the tool's queue list every test they
|
|
290
|
+
scored). Such a row takes its key and baseline from the manifest.
|
|
291
|
+
A row whose result the caller holds (``results``, by model id) takes
|
|
292
|
+
its CPU time from it: Sim (s) shows that, marked, where no wall time
|
|
293
|
+
was kept (``TestRow.sim_source``).
|
|
294
|
+
|
|
295
|
+
The same template renders both; JS reads `DASHBOARD_MODE` to
|
|
296
|
+
decide whether to start the fetch loop.
|
|
297
|
+
"""
|
|
298
|
+
snapshot = read_status(work_dir) or {
|
|
299
|
+
"total": 0,
|
|
300
|
+
"elapsed": 0.0,
|
|
301
|
+
"eta_seconds": None,
|
|
302
|
+
"counts": {},
|
|
303
|
+
"tests": [],
|
|
304
|
+
"updated_at": 0.0,
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
# Wall-clock anchor for the stale-sidecar guard. Sidecars written
|
|
308
|
+
# before this run's start are ignored by `rows.enrich_row` so a prior
|
|
309
|
+
# run's verdict can't override the current run's sim result.
|
|
310
|
+
# Falls back to None when status.json predates the field — guard
|
|
311
|
+
# silently passes through (legacy sidecars never get filtered).
|
|
312
|
+
snapshot_start_wall = snapshot.get("start_wall")
|
|
313
|
+
|
|
314
|
+
# `dstf run` / `compare` without --report write no sidecars; the
|
|
315
|
+
# comparisons they hold are what gives such a page its verdicts.
|
|
316
|
+
by_model = {c.model_id: c for c in comparisons} if mode == "final" else {}
|
|
317
|
+
# (row, the wall clock its sidecar must postdate, what that clock is)
|
|
318
|
+
base = [
|
|
319
|
+
(row_from_status(t), snapshot_start_wall, "start_wall")
|
|
320
|
+
for t in snapshot.get("tests", [])
|
|
321
|
+
]
|
|
322
|
+
listed = {r.model_id for r, _, _ in base}
|
|
323
|
+
rest = set(by_model) - listed
|
|
324
|
+
if rest:
|
|
325
|
+
# A row the last run did not run is judged against ITS OWN last run
|
|
326
|
+
# — the start its manifest entry keeps: against the last run's
|
|
327
|
+
# start, every sidecar outside its scope read stale (638 warnings
|
|
328
|
+
# and no Reference link on a 331-test page).
|
|
329
|
+
manifest = _manifest_rows(work_dir)
|
|
330
|
+
base += [
|
|
331
|
+
(row, last_run_at, "last_run_at")
|
|
332
|
+
for m, (row, last_run_at) in manifest.items()
|
|
333
|
+
if m in rest
|
|
334
|
+
]
|
|
335
|
+
base += [
|
|
336
|
+
(TestRow(model_id=m), None, "last_run_at")
|
|
337
|
+
for m in sorted(rest - set(manifest))
|
|
338
|
+
]
|
|
339
|
+
rows = []
|
|
340
|
+
for row, start_wall, anchor in base:
|
|
341
|
+
before = row
|
|
342
|
+
comp = by_model.get(row.model_id)
|
|
343
|
+
# Where this test's report page may be. The suite writes it under
|
|
344
|
+
# the baseline it was compared against (``ref_NNNN``), else under
|
|
345
|
+
# its test key — a failed simulation's too. The name its RUN gave
|
|
346
|
+
# it is a guess made before either was known: a test accepted
|
|
347
|
+
# since has a baseline its run did not name.
|
|
348
|
+
compared = f"ref_{comp.test_id}" if comp is not None and comp.test_id else None
|
|
349
|
+
places = dict.fromkeys(d for d in (compared, row.report_dir, row.test_key) if d)
|
|
350
|
+
for place in places if mode == "final" else ():
|
|
351
|
+
sidecar = _read_comparison_sidecar(work_dir, place)
|
|
352
|
+
if not sidecar:
|
|
353
|
+
continue
|
|
354
|
+
# Sidecars written before the summary block carry the
|
|
355
|
+
# fields at the top level.
|
|
356
|
+
row = enrich_row(
|
|
357
|
+
row,
|
|
358
|
+
sidecar.get("summary", sidecar),
|
|
359
|
+
start_wall=start_wall,
|
|
360
|
+
anchor=anchor,
|
|
361
|
+
)
|
|
362
|
+
if row is not before:
|
|
363
|
+
# A sidecar of THIS run's verdict was read: the page it
|
|
364
|
+
# was written beside is there to open. Nothing else says
|
|
365
|
+
# a page exists — a run wipes its tests' report dirs.
|
|
366
|
+
row = replace(row, report_page=place)
|
|
367
|
+
break
|
|
368
|
+
# enrich_row hands back the same row when a guard refuses the
|
|
369
|
+
# sidecar. With no fresh sidecar (a run without --report, or a stale
|
|
370
|
+
# one), the walls are the ones the run kept beside the result
|
|
371
|
+
# (storage.run_stats).
|
|
372
|
+
if mode == "final" and row is before and row.test_key:
|
|
373
|
+
row = _with_kept_timing(work_dir, row)
|
|
374
|
+
if comp is not None:
|
|
375
|
+
row = _with_verdict(row, comp)
|
|
376
|
+
result = (results or {}).get(row.model_id) if mode == "final" else None
|
|
377
|
+
cpu = cpu_time(result.statistics) if result is not None else None
|
|
378
|
+
if cpu is not None:
|
|
379
|
+
row = replace(row, sim_cpu=cpu)
|
|
380
|
+
rows.append(row)
|
|
381
|
+
|
|
382
|
+
# rerun_prefix precedence: explicit kwarg > snapshot's prefix > "dstf"
|
|
383
|
+
# fallback. The kwarg path lets the CLI override the snapshot prefix even
|
|
384
|
+
# when status.json was written by a prior run with a different config
|
|
385
|
+
# (e.g. dstf compare reading work_dir from a different invocation).
|
|
386
|
+
prefix = rerun_prefix or snapshot.get("rerun_prefix") or "dstf"
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
"mode": mode,
|
|
390
|
+
"auto_refresh": mode == "live",
|
|
391
|
+
"title": "Test progress" if mode == "live" else "Test report",
|
|
392
|
+
# A final page counts the rows it lists (status.json's scope and
|
|
393
|
+
# every test scored); a live one, the run's.
|
|
394
|
+
"total": len(rows) if mode == "final" else snapshot.get("total", 0),
|
|
395
|
+
# What the last run ran, for a final page that lists more: its
|
|
396
|
+
# status bar says so rather than "N tests · ran for 0s".
|
|
397
|
+
"run_total": len(snapshot.get("tests", [])),
|
|
398
|
+
"elapsed": snapshot.get("elapsed", 0.0),
|
|
399
|
+
"eta_seconds": snapshot.get("eta_seconds"),
|
|
400
|
+
"counts": snapshot.get("counts", {}),
|
|
401
|
+
"tests": rows,
|
|
402
|
+
"columns": COLUMNS,
|
|
403
|
+
# The count pills, in PILL_STATUSES' order: [status class, the
|
|
404
|
+
# word a person reads]. The class is the pill's key and the row's
|
|
405
|
+
# data-status.
|
|
406
|
+
"statuses": [[STATUS_CLASSES[s], STATUS_LABELS[s]] for s in PILL_STATUSES],
|
|
407
|
+
"rerun_prefix": prefix,
|
|
408
|
+
"updated_at": snapshot.get("updated_at", time.time()),
|
|
409
|
+
# Wall-clock anchor for the dashboard's live elapsed clock. JS reads
|
|
410
|
+
# SNAPSHOT_WALL (= updated_at, when the snapshot was written) and
|
|
411
|
+
# SNAPSHOT_ELAPSED (= elapsed at that moment) to compute a live-
|
|
412
|
+
# ticking elapsed = SNAPSHOT_ELAPSED + (Date.now()/1000 - SNAPSHOT_WALL).
|
|
413
|
+
# This works between meta-refresh ticks (when the snapshot is stale)
|
|
414
|
+
# and right after a refresh (when it's fresh).
|
|
415
|
+
"start_wall": snapshot.get("start_wall"),
|
|
416
|
+
# Identity of the report this page IS: work dir + the wall clock
|
|
417
|
+
# the run started at. The dashboard's localStorage state is keyed
|
|
418
|
+
# by location.pathname, which two successive runs into the same
|
|
419
|
+
# work_dir share — so a selection made in run N was restored into
|
|
420
|
+
# run N+1's page and silently joined the rerun command the user
|
|
421
|
+
# built there. JS scopes the selection to this key and drops a
|
|
422
|
+
# selection stamped with any other.
|
|
423
|
+
"report_key": (
|
|
424
|
+
f"{work_dir.resolve().as_posix()}@"
|
|
425
|
+
f"{snapshot.get('start_wall') or snapshot.get('updated_at') or 0}"
|
|
426
|
+
),
|
|
427
|
+
# Run provenance (backend/simulator/version/os) for the banner. None
|
|
428
|
+
# for snapshots written before this field existed.
|
|
429
|
+
"run_metadata": format_run_metadata(snapshot.get("metadata")),
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _atomic_write(path: Path, text: str) -> None:
|
|
434
|
+
"""Atomic file write — Windows file-locking workaround.
|
|
435
|
+
|
|
436
|
+
Same retry logic as ProgressReporter._atomic_write — uses unique
|
|
437
|
+
tmp name so concurrent writers can't share the same tmp path.
|
|
438
|
+
"""
|
|
439
|
+
tmp = path.with_name(f"{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
|
|
440
|
+
tmp.write_text(text, encoding="utf-8")
|
|
441
|
+
last_err: OSError | None = None
|
|
442
|
+
for delay in (0, 0.05, 0.1, 0.2, 0.5):
|
|
443
|
+
if delay:
|
|
444
|
+
time.sleep(delay)
|
|
445
|
+
try:
|
|
446
|
+
tmp.replace(path)
|
|
447
|
+
return
|
|
448
|
+
except OSError as e:
|
|
449
|
+
last_err = e
|
|
450
|
+
try:
|
|
451
|
+
tmp.unlink()
|
|
452
|
+
except OSError:
|
|
453
|
+
pass
|
|
454
|
+
if not (path.parent / path.name).exists():
|
|
455
|
+
raise last_err
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _render(
|
|
459
|
+
work_dir: Path,
|
|
460
|
+
mode: str,
|
|
461
|
+
rerun_prefix: str | None = None,
|
|
462
|
+
output_path: Path | None = None,
|
|
463
|
+
comparisons: Iterable[TestComparison] = (),
|
|
464
|
+
results: Mapping[str, TestResult] | None = None,
|
|
465
|
+
theme: str = "default",
|
|
466
|
+
) -> None:
|
|
467
|
+
ctx = build_dashboard_context(
|
|
468
|
+
work_dir,
|
|
469
|
+
mode=mode,
|
|
470
|
+
rerun_prefix=rerun_prefix,
|
|
471
|
+
comparisons=comparisons,
|
|
472
|
+
results=results,
|
|
473
|
+
)
|
|
474
|
+
template = _env.get_template("dashboard.html")
|
|
475
|
+
# The token block per render, never at import: the theme is the
|
|
476
|
+
# project's (`Config.theme`), and every caller in `src` says it —
|
|
477
|
+
# `tests/test_themes.py` reads the call sites.
|
|
478
|
+
html = template.render(**ctx, dstf_css=css_variables(theme))
|
|
479
|
+
dest = output_path or (work_dir / "dashboard.html")
|
|
480
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
481
|
+
_atomic_write(dest, html)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def render_live(
|
|
485
|
+
work_dir: Path, rerun_prefix: str | None = None, theme: str = "default"
|
|
486
|
+
) -> None:
|
|
487
|
+
"""Render dashboard.html in live mode (auto-refreshes via meta tag)."""
|
|
488
|
+
_render(work_dir, mode="live", rerun_prefix=rerun_prefix, theme=theme)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def render_final(
|
|
492
|
+
work_dir: Path,
|
|
493
|
+
rerun_prefix: str | None = None,
|
|
494
|
+
comparisons: Iterable[TestComparison] = (),
|
|
495
|
+
results: Mapping[str, TestResult] | None = None,
|
|
496
|
+
theme: str = "default",
|
|
497
|
+
) -> None:
|
|
498
|
+
"""Render dashboard.html in final mode (refresh stripped, the
|
|
499
|
+
comparisons' verdicts and the sidecars merged; the results' CPU
|
|
500
|
+
times where no wall time was kept)."""
|
|
501
|
+
_render(
|
|
502
|
+
work_dir,
|
|
503
|
+
mode="final",
|
|
504
|
+
rerun_prefix=rerun_prefix,
|
|
505
|
+
comparisons=comparisons,
|
|
506
|
+
results=results,
|
|
507
|
+
theme=theme,
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def render_final_to(
|
|
512
|
+
work_dir: Path,
|
|
513
|
+
output_path: Path,
|
|
514
|
+
rerun_prefix: str | None = None,
|
|
515
|
+
comparisons: Iterable[TestComparison] = (),
|
|
516
|
+
results: Mapping[str, TestResult] | None = None,
|
|
517
|
+
theme: str = "default",
|
|
518
|
+
) -> None:
|
|
519
|
+
"""Render the final-mode dashboard page to a caller-chosen path.
|
|
520
|
+
|
|
521
|
+
Backs ``--report-format html``: the same page ``render_final`` writes,
|
|
522
|
+
just to ``output_path``. The page is self-contained (dashboard.js is
|
|
523
|
+
``{% include %}``d), but its per-test links are relative to ``work_dir``
|
|
524
|
+
— they only resolve when the output lands inside the work dir.
|
|
525
|
+
"""
|
|
526
|
+
_render(
|
|
527
|
+
work_dir,
|
|
528
|
+
mode="final",
|
|
529
|
+
rerun_prefix=rerun_prefix,
|
|
530
|
+
output_path=output_path,
|
|
531
|
+
comparisons=comparisons,
|
|
532
|
+
results=results,
|
|
533
|
+
theme=theme,
|
|
534
|
+
)
|