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
dstf/cli/run_cmds.py
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
"""The `run` and `compare` commands, plus the backend selection and report
|
|
2
|
+
routing they share."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..backends import dataset_kind_for
|
|
9
|
+
from ..backends.progress import recorded_backend
|
|
10
|
+
from ..config import DEFAULT_STOP_TIME, ConfigError
|
|
11
|
+
from ..discovery.test_registry import discover_tests
|
|
12
|
+
from ..pipeline import run_pipeline, wipe_stale_state_for_scope, write_id_mapping
|
|
13
|
+
from .common import (
|
|
14
|
+
_build_config,
|
|
15
|
+
_exit_code,
|
|
16
|
+
_filter_tests,
|
|
17
|
+
_find_orphan_manifest_entries,
|
|
18
|
+
_parse_review_filter,
|
|
19
|
+
_should_review,
|
|
20
|
+
)
|
|
21
|
+
from .review import _interactive_review
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _notify_orphans(config, all_tests: list) -> None:
|
|
25
|
+
"""One-line notice if orphan manifest entries exist."""
|
|
26
|
+
orphans = _find_orphan_manifest_entries(config, all_tests)
|
|
27
|
+
if orphans:
|
|
28
|
+
print(
|
|
29
|
+
f"Note: {len(orphans)} orphan manifest entries (models no longer in "
|
|
30
|
+
f"discovery). Run 'manifest cleanup --orphans' to list/prune."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _get_backend(config, persistent: bool = False):
|
|
35
|
+
"""The CLI's wrapper over :func:`backends.select_backend`.
|
|
36
|
+
|
|
37
|
+
The choice itself — persistent variant when the backend declares
|
|
38
|
+
:attr:`Capability.PERSISTENT_WORKERS` and its dependency probe passes,
|
|
39
|
+
batch otherwise — lives in ``backends``, where callers that are not a
|
|
40
|
+
CLI can reach it. Backend-agnostic: no string matching on backend
|
|
41
|
+
names; new persistent backends plug in by declaring the capability and
|
|
42
|
+
overriding ``persistent_backend_cls`` + ``preflight``.
|
|
43
|
+
|
|
44
|
+
This layer is only the CLI's answer to "the backend is unavailable":
|
|
45
|
+
print the message and exit ``1``, rather than fall back silently or
|
|
46
|
+
traceback.
|
|
47
|
+
"""
|
|
48
|
+
from ..backends import BackendUnavailableError, select_backend
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
return select_backend(config, persistent=persistent)
|
|
52
|
+
except BackendUnavailableError as exc:
|
|
53
|
+
print(f"{exc} Aborting.", file=sys.stderr)
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _report_accept(
|
|
58
|
+
config,
|
|
59
|
+
stored: int,
|
|
60
|
+
skipped: list[tuple[str, str]],
|
|
61
|
+
warnings: list[str],
|
|
62
|
+
notes: list[str],
|
|
63
|
+
) -> int:
|
|
64
|
+
"""Print the accept outcome; nonzero when anything was skipped (review
|
|
65
|
+
2026-07-06 finding 30: a partial accept is a failure CI must see). A
|
|
66
|
+
warning — a large baseline file, which IS stored — changes no exit code,
|
|
67
|
+
and neither does a note: the simulate-only tests, which have no baseline
|
|
68
|
+
to store, are named, where `Accepted 0 test baselines` said nothing of
|
|
69
|
+
them."""
|
|
70
|
+
print(f"\nAccepted {stored} test baselines to {config.reference_dir}")
|
|
71
|
+
for line in notes:
|
|
72
|
+
print(line)
|
|
73
|
+
for line in warnings:
|
|
74
|
+
print(f"Warning: {line}")
|
|
75
|
+
if skipped:
|
|
76
|
+
print(f"Skipped {len(skipped)} test(s):")
|
|
77
|
+
for model_id, reason in skipped:
|
|
78
|
+
print(f" {model_id}: {reason}")
|
|
79
|
+
return 1
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _browser(args: argparse.Namespace) -> bool:
|
|
84
|
+
"""Whether this command opens a browser tab on the pages it writes:
|
|
85
|
+
yes, unless ``--no-browser``."""
|
|
86
|
+
return not getattr(args, "no_browser", False)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _say_removed_pages(n: int) -> None:
|
|
90
|
+
"""One line for the per-test report pages a run or compare removed and
|
|
91
|
+
is not writing again. The removal is intended — a stale page must not
|
|
92
|
+
survive the run that outdated it — but silent, it read as lost work.
|
|
93
|
+
`compare --report` is what writes them again from EVERY caller: under
|
|
94
|
+
`--accept --report` and `-i … --report` the line used to say "--report
|
|
95
|
+
writes them again", of the flag that had just been given and wrote
|
|
96
|
+
none."""
|
|
97
|
+
if n:
|
|
98
|
+
pages, them = ("page", "it") if n == 1 else ("pages", "them")
|
|
99
|
+
print(
|
|
100
|
+
f"Removed {n} per-test report {pages} written by an earlier --report "
|
|
101
|
+
f"(now stale); `compare --report` writes {them} again."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _default_stop_time_notes(tests: list, config) -> list[str]:
|
|
106
|
+
"""One line per test about to run to the framework's default stop time
|
|
107
|
+
because nothing gave it one: it runs for 1 s, which is rarely what its
|
|
108
|
+
author meant. Why nothing did is said as it is:
|
|
109
|
+
|
|
110
|
+
- a Modelica model that was found, with no ``experiment`` annotation
|
|
111
|
+
and no ``simulation.stop_time`` in its entry — neither layer gives one;
|
|
112
|
+
- a Modelica model that was NOT found under ``source_path`` (a
|
|
113
|
+
dependency's: ``Modelica.Blocks.Examples.PID_Controller``, whose
|
|
114
|
+
annotation says ``StopTime=4``) — its annotation was never read, and
|
|
115
|
+
"the model's annotation gives no stop time" was false of it;
|
|
116
|
+
- any other source type, which has no annotation layer: the entry
|
|
117
|
+
gives none. Every backend runs such a test to the same default."""
|
|
118
|
+
ran = f"it runs to the default, {DEFAULT_STOP_TIME:g} s."
|
|
119
|
+
|
|
120
|
+
def why(test) -> str:
|
|
121
|
+
if config.source_type != "modelica":
|
|
122
|
+
return "test_spec.json gives no stop time"
|
|
123
|
+
if test.source_file == Path(""):
|
|
124
|
+
return (
|
|
125
|
+
"not found under source_path, so its experiment annotation was not read"
|
|
126
|
+
)
|
|
127
|
+
return (
|
|
128
|
+
"neither test_spec.json nor the model's experiment annotation "
|
|
129
|
+
"gives a stop time"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return [
|
|
133
|
+
f"{t.model_id}: {why(t)}; {ran}"
|
|
134
|
+
for t in tests
|
|
135
|
+
if t.field_sources.get("stop_time") == "default"
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
140
|
+
"""Run tests and compare/accept results."""
|
|
141
|
+
config = _build_config(args)
|
|
142
|
+
all_tests = discover_tests(config)
|
|
143
|
+
tests = _filter_tests(all_tests, args.filter, args.package)
|
|
144
|
+
_notify_orphans(config, all_tests)
|
|
145
|
+
|
|
146
|
+
from ..storage.reference_store import ReferenceStore
|
|
147
|
+
|
|
148
|
+
store = ReferenceStore(config)
|
|
149
|
+
if not args.accept:
|
|
150
|
+
# A route that scores stops at a baseline it cannot read (`Config
|
|
151
|
+
# error: <reference file>: …`, exit 2) BEFORE it simulates: the
|
|
152
|
+
# reference files are there now. `--merge` and `--rerun` score every
|
|
153
|
+
# discovered test's last result, any other run the tests it runs.
|
|
154
|
+
from ..comparison.comparator import refuse_stored_overrides
|
|
155
|
+
|
|
156
|
+
merged = getattr(args, "merge", False) or (
|
|
157
|
+
getattr(args, "rerun", None) is not None
|
|
158
|
+
)
|
|
159
|
+
refuse_stored_overrides(all_tests if merged else tests, store)
|
|
160
|
+
write_id_mapping(store, config)
|
|
161
|
+
|
|
162
|
+
backend = _get_backend(config, persistent=not getattr(args, "batch", False))
|
|
163
|
+
|
|
164
|
+
# --rerun selects tests by status from prior comparisons (no new sim yet).
|
|
165
|
+
# Default category: failed. Implies --merge so the report covers the full suite.
|
|
166
|
+
if getattr(args, "rerun", None) is not None:
|
|
167
|
+
from ..comparison.comparator import compare_all
|
|
168
|
+
|
|
169
|
+
rerun_filter = args.rerun or "failed"
|
|
170
|
+
rerun_categories = _parse_review_filter(rerun_filter)
|
|
171
|
+
prior_results = backend.read_last_results(all_tests)
|
|
172
|
+
if not prior_results:
|
|
173
|
+
print(
|
|
174
|
+
"--rerun requires prior results in the work directory. Run a full pass first."
|
|
175
|
+
)
|
|
176
|
+
return 1
|
|
177
|
+
prior_comps = compare_all(
|
|
178
|
+
all_tests,
|
|
179
|
+
prior_results,
|
|
180
|
+
store,
|
|
181
|
+
config.tolerance,
|
|
182
|
+
config.default_points,
|
|
183
|
+
dataset_kind=dataset_kind_for(backend),
|
|
184
|
+
)
|
|
185
|
+
rerun_models = {
|
|
186
|
+
c.model_id for c in prior_comps if _should_review(c, rerun_categories)
|
|
187
|
+
}
|
|
188
|
+
tests = [t for t in tests if t.model_id in rerun_models]
|
|
189
|
+
args.merge = True
|
|
190
|
+
print(
|
|
191
|
+
f"--rerun {rerun_filter}: selected {len(tests)} of {len(prior_comps)} tests"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if not tests:
|
|
195
|
+
# review 2026-07-06 finding 59: an empty rerun set means the
|
|
196
|
+
# prior run already satisfies the requested categories — that
|
|
197
|
+
# is SUCCESS (a green prior run must not fail CI), unlike an
|
|
198
|
+
# empty user --filter below. Still refresh the merged report so
|
|
199
|
+
# the dashboard reflects the (unchanged) full-suite state.
|
|
200
|
+
print(f"Nothing to rerun (no tests in categories: {rerun_filter})")
|
|
201
|
+
config.work_dir.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
from ..reporting.dashboard_render import build_rerun_prefix, render_final
|
|
203
|
+
|
|
204
|
+
render_final(
|
|
205
|
+
config.work_dir,
|
|
206
|
+
rerun_prefix=build_rerun_prefix(config),
|
|
207
|
+
theme=config.theme,
|
|
208
|
+
comparisons=prior_comps,
|
|
209
|
+
results=prior_results,
|
|
210
|
+
)
|
|
211
|
+
# The verdicts as they stand, on whatever was asked for. The
|
|
212
|
+
# exit code stays 0 regardless of them: "nothing to rerun" is
|
|
213
|
+
# this invocation's whole outcome.
|
|
214
|
+
scope_tests = [t for t in all_tests if t.model_id in prior_results]
|
|
215
|
+
_show_reports(prior_comps, prior_results, scope_tests, store, config, args)
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
if not tests:
|
|
219
|
+
print("No tests matched the filter.")
|
|
220
|
+
return 1
|
|
221
|
+
|
|
222
|
+
for line in _default_stop_time_notes(tests, config):
|
|
223
|
+
print(line)
|
|
224
|
+
|
|
225
|
+
# Open the unified dashboard *before* the run starts so the user sees
|
|
226
|
+
# live progress as tests register. The first ProgressReporter.register
|
|
227
|
+
# call will overwrite this initial empty page; subsequent meta-refresh
|
|
228
|
+
# ticks pick up updates. The page stays live through the backend's
|
|
229
|
+
# finalize; every way out of this command below renders the final page
|
|
230
|
+
# at the same URL, or a watching browser refreshes forever.
|
|
231
|
+
# The page has to exist before the tab opens on it — run_pipeline paints
|
|
232
|
+
# it again a moment later, which is idempotent.
|
|
233
|
+
from ..reporting.dashboard_render import (
|
|
234
|
+
build_rerun_prefix,
|
|
235
|
+
render_final,
|
|
236
|
+
render_live,
|
|
237
|
+
)
|
|
238
|
+
from ..reporting.report_suite import open_in_browser
|
|
239
|
+
|
|
240
|
+
config.work_dir.mkdir(parents=True, exist_ok=True)
|
|
241
|
+
render_live(
|
|
242
|
+
config.work_dir, rerun_prefix=build_rerun_prefix(config), theme=config.theme
|
|
243
|
+
)
|
|
244
|
+
# A path that scores renders the final page itself, with its
|
|
245
|
+
# comparisons, before the work that follows (the review, the report
|
|
246
|
+
# suite), and sets ``final_rendered``. Every other way out ends in the
|
|
247
|
+
# ``finally``: an aborted run, ``--accept`` (accepted without scoring:
|
|
248
|
+
# the rows read SIMULATED, which is true), and any exception after the
|
|
249
|
+
# page went live — Ctrl+C mid-simulation or in the compare phase, an
|
|
250
|
+
# error in read_results, compare_all or the accept — which renders
|
|
251
|
+
# with whatever comparisons exist and lets the exception propagate.
|
|
252
|
+
final_rendered = False
|
|
253
|
+
comparisons = None
|
|
254
|
+
results = None
|
|
255
|
+
try:
|
|
256
|
+
open_in_browser(config.work_dir / "dashboard.html", launch=_browser(args))
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
outcome = run_pipeline(
|
|
260
|
+
config,
|
|
261
|
+
tests,
|
|
262
|
+
backend=backend,
|
|
263
|
+
store=store,
|
|
264
|
+
merge=getattr(args, "merge", False),
|
|
265
|
+
candidates=all_tests,
|
|
266
|
+
)
|
|
267
|
+
except RuntimeError as exc:
|
|
268
|
+
# Backends raise on unrecoverable startup conditions (e.g. all persistent
|
|
269
|
+
# workers failed to spawn). Convert to a clean exit instead of dumping a
|
|
270
|
+
# traceback — the backend has typically already printed the cause.
|
|
271
|
+
print(f"\nRun aborted: {exc}", file=sys.stderr)
|
|
272
|
+
return 1
|
|
273
|
+
|
|
274
|
+
for line in outcome.notices:
|
|
275
|
+
print(line)
|
|
276
|
+
results, scope_tests = outcome.results, outcome.scope_tests
|
|
277
|
+
# Only the scoring route below writes pages; --accept and -i return
|
|
278
|
+
# without, whatever --report says.
|
|
279
|
+
if not (args.report and not args.accept and args.interactive is None):
|
|
280
|
+
_say_removed_pages(outcome.report_pages_removed)
|
|
281
|
+
|
|
282
|
+
if args.accept:
|
|
283
|
+
# review 2026-07-06 finding 30 (part 2): accept ONLY the tests
|
|
284
|
+
# simulated in THIS run (`tests`), never the merged --rerun/--merge
|
|
285
|
+
# scope (`scope_tests`) — the merged scope's results for
|
|
286
|
+
# out-of-scope tests are stale on-disk artifacts from older runs,
|
|
287
|
+
# and stale results must never become baselines.
|
|
288
|
+
from ..storage.accept import accept_run_results
|
|
289
|
+
|
|
290
|
+
return _report_accept(
|
|
291
|
+
config, *accept_run_results(store, tests, results, config.work_dir)
|
|
292
|
+
)
|
|
293
|
+
elif args.interactive is not None:
|
|
294
|
+
from ..comparison.comparator import compare_all
|
|
295
|
+
|
|
296
|
+
comparisons = compare_all(
|
|
297
|
+
scope_tests,
|
|
298
|
+
results,
|
|
299
|
+
store,
|
|
300
|
+
config.tolerance,
|
|
301
|
+
config.default_points,
|
|
302
|
+
dataset_kind=dataset_kind_for(backend),
|
|
303
|
+
)
|
|
304
|
+
render_final(
|
|
305
|
+
config.work_dir,
|
|
306
|
+
rerun_prefix=build_rerun_prefix(config),
|
|
307
|
+
theme=config.theme,
|
|
308
|
+
comparisons=comparisons,
|
|
309
|
+
results=results,
|
|
310
|
+
)
|
|
311
|
+
final_rendered = True
|
|
312
|
+
return _interactive_review(
|
|
313
|
+
scope_tests,
|
|
314
|
+
results,
|
|
315
|
+
comparisons,
|
|
316
|
+
store,
|
|
317
|
+
config,
|
|
318
|
+
review_filter=args.interactive,
|
|
319
|
+
browser=_browser(args),
|
|
320
|
+
)
|
|
321
|
+
else:
|
|
322
|
+
from ..comparison.comparator import compare_all
|
|
323
|
+
|
|
324
|
+
comparisons = compare_all(
|
|
325
|
+
scope_tests,
|
|
326
|
+
results,
|
|
327
|
+
store,
|
|
328
|
+
config.tolerance,
|
|
329
|
+
config.default_points,
|
|
330
|
+
dataset_kind=dataset_kind_for(backend),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# Always refresh the unified dashboard before any --report work.
|
|
334
|
+
# Without --report the per-test sidecars don't exist: each row's
|
|
335
|
+
# verdict, Worst NRMSE, Variables and Warnings come from its
|
|
336
|
+
# comparison, the walls from the run's run_stats.json, a CPU
|
|
337
|
+
# time where it kept none from its result; the Reference link
|
|
338
|
+
# is the sidecar's, and stays empty.
|
|
339
|
+
render_final(
|
|
340
|
+
config.work_dir,
|
|
341
|
+
rerun_prefix=build_rerun_prefix(config),
|
|
342
|
+
theme=config.theme,
|
|
343
|
+
comparisons=comparisons,
|
|
344
|
+
results=results,
|
|
345
|
+
)
|
|
346
|
+
final_rendered = True
|
|
347
|
+
|
|
348
|
+
_show_reports(comparisons, results, scope_tests, store, config, args)
|
|
349
|
+
return _exit_code(comparisons)
|
|
350
|
+
finally:
|
|
351
|
+
if not final_rendered:
|
|
352
|
+
# A page is not worth the run's own exception: a failing render
|
|
353
|
+
# here replaced a Ctrl+C in flight with its OSError.
|
|
354
|
+
try:
|
|
355
|
+
render_final(
|
|
356
|
+
config.work_dir,
|
|
357
|
+
rerun_prefix=build_rerun_prefix(config),
|
|
358
|
+
theme=config.theme,
|
|
359
|
+
comparisons=comparisons or (),
|
|
360
|
+
results=results,
|
|
361
|
+
)
|
|
362
|
+
except Exception as e:
|
|
363
|
+
print(
|
|
364
|
+
f"# WARN: could not write the final dashboard: {e}", file=sys.stderr
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def cmd_compare(args: argparse.Namespace) -> int:
|
|
369
|
+
"""Compare last run results against stored references."""
|
|
370
|
+
config = _build_config(args)
|
|
371
|
+
|
|
372
|
+
# Without --backend, `compare` used to auto-detect a backend from the
|
|
373
|
+
# environment — which on a machine with Dymola installed meant comparing
|
|
374
|
+
# an OpenModelica work dir against the Dymola reference partition and
|
|
375
|
+
# reporting SIM_FAIL for every test. The work dir knows what produced it:
|
|
376
|
+
# prefer the run's own recorded backend. An explicit --backend still
|
|
377
|
+
# wins, and a work dir with no metadata keeps the auto-detect behaviour.
|
|
378
|
+
if not getattr(args, "backend", None):
|
|
379
|
+
work_dir = config.work_dir
|
|
380
|
+
recorded, warning = recorded_backend(work_dir)
|
|
381
|
+
if warning:
|
|
382
|
+
print(warning, file=sys.stderr)
|
|
383
|
+
if recorded and recorded != config.backend_name:
|
|
384
|
+
args.backend = recorded
|
|
385
|
+
config = _build_config(args)
|
|
386
|
+
# status.json is data, and data can name a backend this install
|
|
387
|
+
# can't resolve — a plug-in that isn't loaded here, a snapshot
|
|
388
|
+
# copied from another machine. get_backend_class raises a bare
|
|
389
|
+
# ValueError that main() doesn't catch, so translate it before it
|
|
390
|
+
# becomes a traceback.
|
|
391
|
+
from ..backends import get_backend_class
|
|
392
|
+
|
|
393
|
+
try:
|
|
394
|
+
get_backend_class(config)
|
|
395
|
+
except ValueError as exc:
|
|
396
|
+
raise ConfigError(
|
|
397
|
+
f"The last run in {work_dir} recorded backend "
|
|
398
|
+
f"'{recorded}', which is not registered here: {exc} "
|
|
399
|
+
f"Pass --backend to choose one explicitly."
|
|
400
|
+
) from exc
|
|
401
|
+
print(
|
|
402
|
+
f"Using backend '{recorded}' recorded by the last run in "
|
|
403
|
+
f"{work_dir} (pass --backend to override)."
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
all_tests = discover_tests(config)
|
|
407
|
+
tests = _filter_tests(all_tests, args.filter, args.package)
|
|
408
|
+
_notify_orphans(config, all_tests)
|
|
409
|
+
|
|
410
|
+
from ..comparison.comparator import compare_all
|
|
411
|
+
from ..storage.reference_store import ReferenceStore
|
|
412
|
+
|
|
413
|
+
backend = _get_backend(config)
|
|
414
|
+
store = ReferenceStore(config)
|
|
415
|
+
if not getattr(args, "accept", False):
|
|
416
|
+
# As `run` does, before anything is wiped or stamped.
|
|
417
|
+
from ..comparison.comparator import refuse_stored_overrides
|
|
418
|
+
|
|
419
|
+
refuse_stored_overrides(tests, store)
|
|
420
|
+
write_id_mapping(store, config)
|
|
421
|
+
|
|
422
|
+
# Pre-compute ref_id_map so the wipe + downstream report rendering can
|
|
423
|
+
# find each test's `ref_NNNN/` report dir. Compare reads prior sim
|
|
424
|
+
# outputs from disk, so the sim work dirs must NOT be wiped here —
|
|
425
|
+
# only the (now-stale) per-test report sidecars + HTML.
|
|
426
|
+
for test in tests:
|
|
427
|
+
ref_id = store.index.get_id(test.model_id)
|
|
428
|
+
if ref_id:
|
|
429
|
+
backend.ref_id_map[test.model_id] = f"ref_{ref_id}"
|
|
430
|
+
config.work_dir.mkdir(parents=True, exist_ok=True)
|
|
431
|
+
pages_removed = wipe_stale_state_for_scope(
|
|
432
|
+
config,
|
|
433
|
+
tests,
|
|
434
|
+
backend.ref_id_map,
|
|
435
|
+
wipe_sim_dirs=False,
|
|
436
|
+
)
|
|
437
|
+
# --accept returns without a report, whatever --report says.
|
|
438
|
+
if getattr(args, "accept", False) or not getattr(args, "report", False):
|
|
439
|
+
_say_removed_pages(pages_removed)
|
|
440
|
+
# The verdicts status.json holds for these tests are of an earlier
|
|
441
|
+
# compare: cleared here, and stamped again below by a route that scores
|
|
442
|
+
# (an --accept scores nothing, and leaves none standing).
|
|
443
|
+
from ..backends.progress import stamp_verdicts
|
|
444
|
+
|
|
445
|
+
stamp_verdicts(config.work_dir, {t.model_id: None for t in tests})
|
|
446
|
+
|
|
447
|
+
results = backend.read_last_results(tests)
|
|
448
|
+
|
|
449
|
+
if getattr(args, "accept", False):
|
|
450
|
+
# Accept-without-rerun: store the last run's on-disk results as
|
|
451
|
+
# baselines. read_last_results already forced any test whose run the
|
|
452
|
+
# batch manifest records as failed/timed_out to success=False (falling
|
|
453
|
+
# back to the status.json cross-check only for manifest entries
|
|
454
|
+
# written before that field existed), so accept_results skips them —
|
|
455
|
+
# a partial/stale .mat can't become a baseline. The pending-entry
|
|
456
|
+
# refusal `accept_run_results` adds on top is documented there.
|
|
457
|
+
# Terminal, mirroring `run --accept` (finding 30: partial accept
|
|
458
|
+
# exits nonzero).
|
|
459
|
+
from ..storage.accept import accept_run_results
|
|
460
|
+
|
|
461
|
+
return _report_accept(
|
|
462
|
+
config, *accept_run_results(store, tests, results, config.work_dir)
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
comparisons = compare_all(
|
|
466
|
+
tests,
|
|
467
|
+
results,
|
|
468
|
+
store,
|
|
469
|
+
config.tolerance,
|
|
470
|
+
config.default_points,
|
|
471
|
+
dataset_kind=dataset_kind_for(backend),
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
# Always refresh the unified dashboard, even without --report. Without
|
|
475
|
+
# per-test sidecars (no --report run yet), each row's verdict, Worst
|
|
476
|
+
# NRMSE, Variables and Warnings come from its comparison, the walls
|
|
477
|
+
# from the run's run_stats.json, a CPU time where it kept none from its
|
|
478
|
+
# result; the Reference link is the sidecar's, and stays empty.
|
|
479
|
+
from ..reporting.dashboard_render import build_rerun_prefix, render_final
|
|
480
|
+
|
|
481
|
+
render_final(
|
|
482
|
+
config.work_dir,
|
|
483
|
+
rerun_prefix=build_rerun_prefix(config),
|
|
484
|
+
theme=config.theme,
|
|
485
|
+
comparisons=comparisons,
|
|
486
|
+
results=results,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
_show_reports(comparisons, results, tests, store, config, args)
|
|
490
|
+
return _exit_code(comparisons)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _show_reports(comparisons, results, tests, store, config, args) -> None:
|
|
494
|
+
"""What a scored ``run`` or ``compare`` shows — three independent
|
|
495
|
+
things, and any combination does all of what it asks:
|
|
496
|
+
|
|
497
|
+
* ``verdicts.json`` in the work dir and the console verdict table,
|
|
498
|
+
ALWAYS;
|
|
499
|
+
* ``--report``: the per-test pages;
|
|
500
|
+
* ``--report-format junit|html``: that file, at ``--output``.
|
|
501
|
+
|
|
502
|
+
In that order: the verdict first, and the HTML file after the pages its
|
|
503
|
+
rows link. (``--report`` used to return before the other two, and a
|
|
504
|
+
``--report-format`` printed no table — `compare --report
|
|
505
|
+
--report-format junit` wrote no file and said nothing.) The exit code
|
|
506
|
+
is the caller's: ``_exit_code``.
|
|
507
|
+
"""
|
|
508
|
+
from ..reporting.console_report import print_report
|
|
509
|
+
from ..reporting.verdicts import write_verdicts
|
|
510
|
+
|
|
511
|
+
# What was decided, for a script: one file, on every scoring route, and
|
|
512
|
+
# each test's verdict beside its simulation status in status.json.
|
|
513
|
+
write_verdicts(config.work_dir, comparisons, tests, results)
|
|
514
|
+
print_report(comparisons)
|
|
515
|
+
if getattr(args, "report", False):
|
|
516
|
+
_generate_report_suite(
|
|
517
|
+
comparisons, results, tests, store, config, browser=_browser(args)
|
|
518
|
+
)
|
|
519
|
+
_write_report_file(comparisons, args, config, results)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _generate_report_suite(
|
|
523
|
+
comparisons, results, tests, store, config, *, browser: bool
|
|
524
|
+
) -> None:
|
|
525
|
+
"""Generate per-test reports + refresh the unified dashboard, and open
|
|
526
|
+
it in a browser tab when ``browser``.
|
|
527
|
+
|
|
528
|
+
generate_report_suite (in report_suite) writes per-test
|
|
529
|
+
interactive.html + comparison_data.json sidecars under
|
|
530
|
+
work_dir/reports/. dashboard_render.render_final then reads
|
|
531
|
+
those sidecars and produces the top-level work_dir/dashboard.html.
|
|
532
|
+
"""
|
|
533
|
+
from ..reporting.dashboard_render import build_rerun_prefix, render_final
|
|
534
|
+
from ..reporting.report_suite import generate_report_suite, open_in_browser
|
|
535
|
+
|
|
536
|
+
generate_report_suite(comparisons, results, tests, store, config)
|
|
537
|
+
render_final(
|
|
538
|
+
config.work_dir,
|
|
539
|
+
rerun_prefix=build_rerun_prefix(config),
|
|
540
|
+
theme=config.theme,
|
|
541
|
+
comparisons=comparisons,
|
|
542
|
+
results=results,
|
|
543
|
+
)
|
|
544
|
+
dashboard_path = config.work_dir / "dashboard.html"
|
|
545
|
+
print(f"Report: {dashboard_path}")
|
|
546
|
+
open_in_browser(dashboard_path, launch=browser)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _write_report_file(
|
|
550
|
+
comparisons: list, args: argparse.Namespace, config, results: dict
|
|
551
|
+
) -> None:
|
|
552
|
+
"""Write the file ``--report-format junit|html`` asks for (``console``,
|
|
553
|
+
the default, asks for none); ``results`` give the HTML page its CPU
|
|
554
|
+
times, as they gave ``render_final``."""
|
|
555
|
+
fmt = getattr(args, "report_format", "console")
|
|
556
|
+
if fmt == "junit":
|
|
557
|
+
from ..reporting.junit_report import generate_junit_report
|
|
558
|
+
|
|
559
|
+
out = Path(getattr(args, "output", None) or "test-results.xml")
|
|
560
|
+
generate_junit_report(comparisons, out)
|
|
561
|
+
print(f"JUnit report written to {out}")
|
|
562
|
+
elif fmt == "html":
|
|
563
|
+
# One HTML surface, not three: --report-format html writes the same
|
|
564
|
+
# dashboard page render_final produces (both callers have already
|
|
565
|
+
# refreshed it), just to the requested path.
|
|
566
|
+
from ..reporting.dashboard_render import build_rerun_prefix, render_final_to
|
|
567
|
+
|
|
568
|
+
out = Path(getattr(args, "output", None) or "test-report.html")
|
|
569
|
+
render_final_to(
|
|
570
|
+
config.work_dir,
|
|
571
|
+
out,
|
|
572
|
+
rerun_prefix=build_rerun_prefix(config),
|
|
573
|
+
theme=config.theme,
|
|
574
|
+
comparisons=comparisons,
|
|
575
|
+
results=results,
|
|
576
|
+
)
|
|
577
|
+
print(f"HTML report written to {out}")
|
|
578
|
+
if out.resolve().parent != config.work_dir.resolve():
|
|
579
|
+
# The page's per-test rows link reports/<dir>/interactive.html
|
|
580
|
+
# relative to the work dir; from anywhere else they dangle —
|
|
581
|
+
# and so does the Plotly those pages load from the reports
|
|
582
|
+
# root. The dashboard itself needs no Plotly (it draws no
|
|
583
|
+
# charts), so this page renders fully wherever it is written;
|
|
584
|
+
# nothing is copied alongside it.
|
|
585
|
+
print(
|
|
586
|
+
f"note: per-test report links (and the Plotly they load from "
|
|
587
|
+
f"reports/) resolve only inside {config.work_dir}",
|
|
588
|
+
file=sys.stderr,
|
|
589
|
+
)
|