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.
Files changed (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. 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
+ )