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/backends/base.py ADDED
@@ -0,0 +1,1117 @@
1
+ """Abstract backend interface and the shared run-loop scaffolding.
2
+
3
+ :class:`Backend` is the backend contract plus the default per-test
4
+ batch loop; :meth:`Backend._prepare_run` is the one prologue every
5
+ ``run_tests`` implementation goes through. The persistent-worker lane
6
+ (:class:`~dstf.backends.persistent.Worker` +
7
+ :class:`~dstf.backends.persistent.PersistentBackendBase`) builds on this
8
+ module; the value types it passes around live in
9
+ :mod:`dstf.backends.types`.
10
+ """
11
+
12
+ import logging
13
+ import sys
14
+ import time
15
+ from abc import ABC, abstractmethod
16
+ from collections.abc import Callable, Collection
17
+ from concurrent.futures import ThreadPoolExecutor, as_completed
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from threading import Lock
21
+ from typing import TYPE_CHECKING
22
+
23
+ import numpy as np
24
+
25
+ from ..config import Config
26
+ from ..discovery.test_registry import TestModel
27
+ from ..storage.batch_manifest import (
28
+ CANCELLED_RUN_STATUS,
29
+ PENDING_RUN_STATUS,
30
+ RECORDED_RUN_STATUSES,
31
+ BatchManifest,
32
+ )
33
+ from ..storage.run_stats import read_run_stats
34
+ from .common.result_variables import merge_timing_stats, run_timing
35
+ from .types import Capability, DatasetType, TestResult, TestRunResult
36
+
37
+ if TYPE_CHECKING:
38
+ from ..discovery.library_state import LibraryState
39
+ from .progress import ProgressReporter
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ def assign_test_keys(
45
+ work_dir: Path,
46
+ tests: list,
47
+ ) -> tuple[dict[str, dict], list[tuple]]:
48
+ """Assign test_keys for the given tests, reusing existing manifest entries.
49
+
50
+ Returns (merged_manifest_map, [(test, test_key)]).
51
+ Existing entries for tests not in the input list are preserved
52
+ so the manifest accumulates across runs (incremental workflow).
53
+ Each entry gets a `last_run_at` timestamp updated for tests being run.
54
+ """
55
+ manifest_path = work_dir / "batch_manifest.json"
56
+ if manifest_path.exists():
57
+ existing = BatchManifest.load(manifest_path).manifest
58
+ else:
59
+ existing = {}
60
+
61
+ model_to_key = {entry["model_id"]: tk for tk, entry in existing.items()}
62
+ used_nums = set()
63
+ for tk in existing:
64
+ try:
65
+ used_nums.add(int(tk.split("_")[-1]))
66
+ except (ValueError, IndexError):
67
+ pass
68
+ next_num = max(used_nums) + 1 if used_nums else 1
69
+
70
+ timestamp = time.time()
71
+ test_items = []
72
+ for test in tests:
73
+ if test.model_id in model_to_key:
74
+ test_key = model_to_key[test.model_id]
75
+ else:
76
+ test_key = f"test_{next_num:04d}"
77
+ next_num += 1
78
+ existing[test_key] = {"model_id": test.model_id, "ref_id": None}
79
+ existing[test_key]["last_run_at"] = timestamp
80
+ test_items.append((test, test_key))
81
+
82
+ return existing, test_items
83
+
84
+
85
+ def last_test_keys(work_dir: Path) -> dict[str, str]:
86
+ """``{model_id: test_key}`` from the work dir's manifest; ``{}`` when
87
+ there is none."""
88
+ manifest_path = work_dir / "batch_manifest.json"
89
+ if not manifest_path.exists():
90
+ return {}
91
+ manifest = BatchManifest.load(manifest_path).manifest
92
+ return {entry["model_id"]: key for key, entry in manifest.items()}
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Progress output (shared across backends)
97
+ # ---------------------------------------------------------------------------
98
+
99
+ _print_lock = Lock()
100
+
101
+
102
+ def _print_run_header(
103
+ total: int,
104
+ suffix_phrase: str,
105
+ parallel: int,
106
+ timeout_s: float,
107
+ work_dir: Path | None,
108
+ *,
109
+ timeout_per_test: bool = False,
110
+ metadata: dict | None = None,
111
+ quiet: bool = False,
112
+ ) -> None:
113
+ """Print the standard "Running N tests..." header + dashboard URL.
114
+
115
+ Single source of truth for the header style across all backends (default
116
+ batch loop, persistent template, batch Dymola's batched-mos override).
117
+ *suffix_phrase* differentiates modes — empty for the default loop,
118
+ ``"via persistent <Backend> workers"`` for persistent backends, or
119
+ ``"in M batch(es) of up to K"`` for the legacy Dymola batch path.
120
+
121
+ *timeout_per_test* toggles the units in the timeout label: the default
122
+ batch loop applies one timeout per test, while batch Dymola and the
123
+ persistent template apply it per worker-test, so both prefer the
124
+ ``s/test`` rendering.
125
+
126
+ *quiet* (``Backend.quiet``) drops the whole header: a run driven from a
127
+ UI process narrates a console nobody is reading.
128
+ """
129
+ if quiet:
130
+ return
131
+ timeout_label = (
132
+ f"timeout={timeout_s}s/test" if timeout_per_test else f"timeout={timeout_s}s"
133
+ )
134
+ parts = [f"Running {total} tests"]
135
+ if suffix_phrase:
136
+ parts.append(f" {suffix_phrase}")
137
+ parts.append(f" (parallel={parallel}, {timeout_label})")
138
+ print("".join(parts), file=sys.stderr)
139
+ if metadata:
140
+ # Provenance line so the terminal (like the dashboard) says which
141
+ # backend/version produced these results — the tool_version may still
142
+ # be None here for persistent backends and is filled in on the report.
143
+ label = metadata.get("backend_name") or "unknown"
144
+ bits = [label]
145
+ if metadata.get("os"):
146
+ bits.append(metadata["os"])
147
+ if metadata.get("dstf_version"):
148
+ bits.append(f"DSTF {metadata['dstf_version']}")
149
+ print(f"Backend: {' · '.join(bits)}", file=sys.stderr)
150
+ if work_dir is not None:
151
+ dashboard = work_dir / "dashboard.html"
152
+ print(f"Live progress: {dashboard.resolve().as_uri()}", file=sys.stderr)
153
+
154
+
155
+ def _print_progress(
156
+ index: int,
157
+ total: int,
158
+ name: str,
159
+ status: str,
160
+ elapsed: float | None = None,
161
+ detail: str | None = None,
162
+ *,
163
+ quiet: bool = False,
164
+ ):
165
+ """Thread-safe progress output; *quiet* (``Backend.quiet``) prints nothing."""
166
+ if quiet:
167
+ return
168
+ with _print_lock:
169
+ pct = (index / total) * 100
170
+ bar_len = 30
171
+ filled = int(bar_len * index / total)
172
+ bar = "=" * filled + "-" * (bar_len - filled)
173
+
174
+ parts = [f" [{bar}] {index}/{total} ({pct:3.0f}%) {name}"]
175
+
176
+ if status == "running":
177
+ parts.append("...")
178
+ print("".join(parts), end="\r", flush=True, file=sys.stderr)
179
+ return
180
+
181
+ parts.append(f" -> {status}")
182
+ if elapsed is not None:
183
+ parts.append(f" ({elapsed:.1f}s)")
184
+ if detail:
185
+ parts.append(f" [{detail}]")
186
+
187
+ print("".join(parts), file=sys.stderr)
188
+
189
+
190
+ def no_worker_message(label: str, failures: dict[int, str]) -> str:
191
+ """The sentence every waiting test fails with when no worker of a
192
+ backend could start: each worker's reason in the backend's own words,
193
+ e.g. ``No OpenModelica worker could start — worker 0: omc exited (code
194
+ -9) during loadModel(Modelica)``. A reason that already names its worker
195
+ (``Worker 0: failed to load ...``) is not named twice."""
196
+ parts = []
197
+ for worker_id, reason in sorted(failures.items()):
198
+ prefix = f"worker {worker_id}: "
199
+ if reason.lower().startswith(prefix):
200
+ reason = reason[len(prefix) :]
201
+ parts.append(prefix + reason)
202
+ return f"No {label} worker could start — " + "; ".join(parts)
203
+
204
+
205
+ #: Run outcomes that make a result on disk untrustworthy: the ``.mat`` (if
206
+ #: any) is partial or left over from an earlier, better run.
207
+ FAILED_RUN_STATUSES = ("failed", "timed_out")
208
+
209
+
210
+ def _force_failed(result: "TestResult | None", status: str) -> None:
211
+ """Mark a result read back from disk as failed because its run says so
212
+ — and say that the run says so before the reader's own sentence, where
213
+ the reader already failed it over what it found: a run killed by its
214
+ timeout leaves a partial file or none, and "Partial result: …" or "No
215
+ Python result at …" alone does not say why. Whether or not the file
216
+ held a row. A failure in the run's own words (``TestResult.run_reason``:
217
+ the driver wrote why into its result file) is left as it is — it reads
218
+ as the run said it."""
219
+ if result is None:
220
+ return
221
+ recorded = f"Run recorded {status}"
222
+ if result.success:
223
+ result.success = False
224
+ result.error_message = result.error_message or recorded
225
+ elif not result.error_message:
226
+ result.error_message = recorded
227
+ elif not result.run_reason:
228
+ result.error_message = f"{recorded} — {result.error_message}"
229
+
230
+
231
+ def _with_run_timing(stats: dict | None, rr: TestRunResult) -> dict | None:
232
+ """*stats* with the run's wall-clock block (:func:`run_timing`) merged
233
+ into its ``timing``, or *stats* as it was when the run measured no wall.
234
+
235
+ Built on a copy: ``merge_timing_stats`` updates ``stats["timing"]`` in
236
+ place, and a backend's ``stats`` may share that dict with the run
237
+ result's own statistics (a shallow ``dict(run_result.statistics)``).
238
+ """
239
+ timing = run_timing(rr)
240
+ if not timing:
241
+ return stats
242
+ out = dict(stats or {})
243
+ out["timing"] = dict(out.get("timing") or {})
244
+ return merge_timing_stats(out, timing)
245
+
246
+
247
+ def failed_run_result(rr: TestRunResult) -> TestResult:
248
+ """The result of a run that failed: its message and its statistics, with
249
+ nothing read from disk — whatever file it left is partial."""
250
+ return TestResult(
251
+ model_id=rr.model_id,
252
+ success=False,
253
+ error_message=rr.error_message or "Simulation failed",
254
+ statistics=_with_run_timing(rr.statistics, rr),
255
+ run_reason=True,
256
+ )
257
+
258
+
259
+ def kept_run_result(
260
+ test_dir: Path, model_id: str, test_key: str
261
+ ) -> TestRunResult | None:
262
+ """The run result a past run kept in *test_dir* (``run_stats.json``,
263
+ :mod:`dstf.storage.run_stats`), rebuilt so ``read_result`` and
264
+ :func:`run_timing` read it as they read an in-process one; ``None`` when
265
+ none was kept.
266
+
267
+ ``success=True`` whatever that run's verdict: the verdict is the
268
+ manifest's (:func:`run_verdicts`), applied after the read, exactly as for
269
+ a reload without the file.
270
+ """
271
+ kept = read_run_stats(test_dir, model_id)
272
+ if kept is None:
273
+ return None
274
+ return TestRunResult(
275
+ model_id=model_id,
276
+ test_key=test_key,
277
+ success=True,
278
+ elapsed=kept["elapsed"],
279
+ statistics=kept["statistics"],
280
+ translation_wall=kept["translation_wall"],
281
+ sim_wall=kept["sim_wall"],
282
+ )
283
+
284
+
285
+ def run_verdicts(work_dir: Path, manifests: list[BatchManifest]) -> dict[str, str]:
286
+ """``model_id -> failed run status`` for every test whose last run the
287
+ manifests record as failed or timed out.
288
+
289
+ The on-disk result read can be fooled by a partial or stale ``.mat`` into
290
+ reporting success, which would let ``compare`` show a green test whose run
291
+ actually failed and let ``compare --accept`` bless a garbage baseline. The
292
+ run's verdict is authoritative, and it is persisted per test in the batch
293
+ manifest (``last_run_status``, written by
294
+ :meth:`BatchManifest.record_result` as each result lands).
295
+
296
+ An entry is authoritative ONLY when it carries one of
297
+ :data:`RECORDED_RUN_STATUSES`. Two cases fall through to the ``status.json``
298
+ cross-check instead:
299
+
300
+ * no field at all — a manifest written before this record existed (old
301
+ data, not old code: the writer always writes it now);
302
+ * :data:`~dstf.storage.batch_manifest.PENDING_RUN_STATUS` — a run started this test and never reached a
303
+ verdict for it, i.e. it was aborted. Trusting a pending entry would mean
304
+ trusting nothing at all; trusting a *carried-forward* verdict, which is
305
+ what pending replaced, would mean reporting a previous run's green over
306
+ this run's partial ``.mat``.
307
+
308
+ A work dir with neither record (status.json absent or unreadable) yields
309
+ no verdicts — the disk read stands.
310
+ """
311
+ from .progress import read_status
312
+
313
+ verdicts: dict[str, str] = {}
314
+ covered: set[str] = set()
315
+ for manifest in manifests:
316
+ for entry in manifest.manifest.values():
317
+ status = entry.get("last_run_status")
318
+ if status not in RECORDED_RUN_STATUSES:
319
+ continue
320
+ covered.add(entry["model_id"])
321
+ if status in FAILED_RUN_STATUSES:
322
+ verdicts[entry["model_id"]] = status
323
+ snap = read_status(work_dir)
324
+ for row in (snap or {}).get("tests", []):
325
+ model_id = row.get("model_id")
326
+ if model_id in covered:
327
+ continue
328
+ if row.get("status") in FAILED_RUN_STATUSES:
329
+ verdicts[model_id] = row["status"]
330
+ return verdicts
331
+
332
+
333
+ # ---------------------------------------------------------------------------
334
+ # Abstract backend interface
335
+ # ---------------------------------------------------------------------------
336
+
337
+
338
+ @dataclass(frozen=True)
339
+ class RunSetup:
340
+ """Everything :meth:`Backend._prepare_run` produces for a run.
341
+
342
+ One prologue for all three ``run_tests`` implementations (default batch
343
+ loop, persistent template, batch Dymola). The copies drifted before: the
344
+ batch Dymola path silently lost run provenance (C-9/D96), so the shared
345
+ ``run_metadata`` stamp lives here and nowhere else.
346
+ """
347
+
348
+ total: int
349
+ run_metadata: dict
350
+ progress: "ProgressReporter"
351
+ #: ``(test, test_key)`` in the caller's test order.
352
+ keyed_tests: list[tuple[TestModel, str]]
353
+ manifest: BatchManifest
354
+
355
+
356
+ class ItemRunner:
357
+ """What ONE thread of the simulation pool drives: bring the thing that
358
+ runs tests up, run a test on it, stop a running one, let it go.
359
+
360
+ The strategy seam between the pool (:mod:`dstf.backends.pool`) and how a
361
+ backend runs a single test, obtained from :meth:`Backend.item_runner`.
362
+ This base is the runner of a backend WITHOUT persistent workers: nothing
363
+ to start, and a test goes through the backend's own single-test path
364
+ (:meth:`Backend.run_single_test`) on the pool's thread. A persistent
365
+ backend's is :class:`~dstf.backends.persistent.WorkerRunner`, over one
366
+ :class:`~dstf.backends.persistent.Worker`.
367
+
368
+ A subclass overrides the hooks (``start``, ``_run``, ``_stop_test``,
369
+ ``stop``, ``close``); ``expect`` / ``run`` / ``cancel`` are the template
370
+ that makes a cancel land whenever it arrives after ``expect``.
371
+ """
372
+
373
+ #: Set when this runner can take no more tests: the sentence why. The
374
+ #: pool then lets its thread go.
375
+ retired: str | None = None
376
+
377
+ #: The state of the source files that the thing that runs tests has
378
+ #: read AT LEAST, for a runner whose tool stays alive between tests;
379
+ #: ``None`` while nobody said (it is then read again for the first test
380
+ #: that names a state).
381
+ library: "LibraryState | None" = None
382
+
383
+ def __init__(self, backend: "Backend", worker_id: int):
384
+ self.backend = backend
385
+ self.worker_id = worker_id
386
+ self._lock = Lock()
387
+ #: The test this runner is on, from ``expect`` to the end of ``run``,
388
+ #: the token of that run (``expect``), and whether a stop was asked
389
+ #: of it.
390
+ self._key: str | None = None
391
+ self._token: object | None = None
392
+ self._cancel_asked = False
393
+
394
+ # -- hooks ---------------------------------------------------------
395
+
396
+ def start(self) -> None:
397
+ """Bring the runner up. Raises ``RuntimeError`` whose text is the
398
+ reason, in the backend's own words, when it cannot."""
399
+
400
+ def starting(self) -> tuple[str, float] | None:
401
+ """While the thing that runs tests is being brought up — the first
402
+ time, or back after a death: ``(phase, seconds so far)``, the phase
403
+ in the backend's own words. ``None`` otherwise, and always here:
404
+ this runner has nothing to start."""
405
+ return None
406
+
407
+ def reloading(self) -> tuple[str, float] | None:
408
+ """While a tool that is up reads the library again for the test it
409
+ is about to run: ``(phase, seconds so far)``, as :meth:`starting`.
410
+ ``None`` otherwise, and always here: this runner keeps no tool."""
411
+ return None
412
+
413
+ def _run(
414
+ self, test: TestModel, test_key: str, library: "LibraryState | None"
415
+ ) -> TestRunResult:
416
+ """Run *test* to its end on the calling thread. *library* is
417
+ nothing to this runner: its backend starts a process per test,
418
+ which reads the source as it is."""
419
+ if self._cancel_asked:
420
+ return TestRunResult(
421
+ model_id=test.model_id,
422
+ test_key=test_key,
423
+ success=False,
424
+ error_message="Cancelled",
425
+ cancelled=True,
426
+ )
427
+ return self.backend.run_single_test(test, test_key, 1, 1)
428
+
429
+ def _stop_test(self, test_key: str) -> bool:
430
+ """Stop *test_key* now; ``False`` while there is nothing to stop yet
431
+ (its process does not exist) — ``cancel`` asks again."""
432
+ return self.backend.cancel_test(test_key)
433
+
434
+ def stop(self, test_key: str | None) -> None:
435
+ """The pool is closing: from another thread, end whatever is in
436
+ flight as fast as the backend can — *test_key* when a test runs
437
+ here, a start otherwise. A backend that cannot stop a test lets it
438
+ finish."""
439
+ if test_key is not None and Capability.CANCEL in self.backend.capabilities:
440
+ with self._lock:
441
+ token = self._token if self._key == test_key else None
442
+ self.cancel(token)
443
+
444
+ def close(self) -> None:
445
+ """Let go of the runner, on its own thread; idempotent."""
446
+
447
+ # -- template ------------------------------------------------------
448
+
449
+ def expect(self, test_key: str, token: object) -> None:
450
+ """*test_key* is about to run here, as *token*: a ``cancel(token)``
451
+ is heard from now on. The token is the caller's own name for this
452
+ ONE run (the pool's is the queue item) — a test's key comes back
453
+ with every rerun of the test, so a stop named by the key could land
454
+ on the run after the one it was meant for. The pool calls this under
455
+ its own lock, as it publishes the key, so no cancel can fall between
456
+ the two."""
457
+ with self._lock:
458
+ self._key, self._token, self._cancel_asked = test_key, token, False
459
+
460
+ def run(
461
+ self,
462
+ test: TestModel,
463
+ test_key: str,
464
+ library: "LibraryState | None" = None,
465
+ ) -> TestRunResult:
466
+ """Run *test* to its end on the calling thread. Never raises on a
467
+ test-level failure; a ``cancel`` makes it return promptly. A run
468
+ nobody announced (``expect``) has no token and cannot be
469
+ cancelled.
470
+
471
+ With *library* — the source files as swept when the test was queued
472
+ — the test runs against those files as they were then or later: a
473
+ runner whose live tool read them earlier reads them again first,
474
+ and a library that does not load is the test's failure, with the
475
+ tool's message. Without one (a CLI run, whose tools started for it)
476
+ nothing is asked."""
477
+ with self._lock:
478
+ if self._key != test_key:
479
+ self._key, self._token, self._cancel_asked = test_key, None, False
480
+ try:
481
+ return self._run(test, test_key, library)
482
+ finally:
483
+ with self._lock:
484
+ self._key = self._token = None
485
+
486
+ def cancel(self, token: object) -> bool:
487
+ """Stop the run announced as *token*, from another thread; ``False``
488
+ when this runner is not on it — it has ended, or never was here.
489
+ Only called on a backend that declares :attr:`Capability.CANCEL`.
490
+ :meth:`run` then returns a failed or ``cancelled`` result — or the
491
+ test's own, if it finished anyway."""
492
+ # The test may be a few statements short of having a process to
493
+ # stop; ask until it has one or has ended. Bounded all the same: a
494
+ # stop that cannot be delivered must not hold the caller.
495
+ deadline = time.monotonic() + 5.0
496
+ asked = False
497
+ while True:
498
+ # Looked at and stopped under one lock: the run cannot end, and
499
+ # the next one begin under the same key, between the two.
500
+ with self._lock:
501
+ if token is None or self._token is not token or self._key is None:
502
+ return asked
503
+ self._cancel_asked = asked = True
504
+ if self._stop_test(self._key):
505
+ return True
506
+ if time.monotonic() >= deadline:
507
+ return True
508
+ time.sleep(0.01)
509
+
510
+
511
+ class Backend(ABC):
512
+ """Abstract interface for running simulations across backends.
513
+
514
+ Concrete backends today: Dymola, FMPy. The abstraction is dataset-shape
515
+ agnostic — a backend declares what it produces (``produced_datasets``)
516
+ and what it can do (``capabilities``).
517
+
518
+ Subclasses must implement read_result(). They should override
519
+ run_tests() for batch execution, or implement run_single_test()
520
+ to use the default per-process execution.
521
+
522
+ Subclasses should override ``capabilities`` and ``produced_datasets`` to
523
+ declare what they support. The framework gates features on these
524
+ declarations rather than on backend class (see docs/extensibility.md).
525
+ """
526
+
527
+ #: Capabilities the backend declares (populate in subclass).
528
+ capabilities: frozenset[Capability] = frozenset()
529
+ #: Dataset types the backend can produce (populate in subclass).
530
+ produced_datasets: frozenset[DatasetType] = frozenset({DatasetType.TIME_SERIES})
531
+ #: Per-test files the backend produces that should appear in the report's
532
+ #: "Simulation Artifacts" section as ``(filename, label)`` pairs.
533
+ #: Backend-agnostic reporter walks this list and skips files that don't
534
+ #: exist — lets each backend own its artifact names without the reporter
535
+ #: hardcoding anyone's on-disk conventions.
536
+ artifact_files: tuple[tuple[str, str], ...] = ()
537
+ #: Suppress every console line a run narrates (header, progress bar,
538
+ #: summary, read phase, worker lifecycle). The CLI leaves it False; the
539
+ #: authoring tool sets it, because its backend's console is nobody's.
540
+ #: Declared on the class as well as set in ``__init__`` so a test double
541
+ #: that never calls ``__init__`` still reads a value.
542
+ quiet: bool = False
543
+
544
+ def __init__(self, config: Config):
545
+ self.config = config
546
+ self.quiet: bool = False
547
+ self.progress = None # set to a ProgressReporter during run_tests()
548
+ # Optional model_id → "ref_NNNN" map for dashboard report links
549
+ # (set by CLI before run_tests if reference IDs are known)
550
+ self.ref_id_map: dict[str, str | None] = {}
551
+
552
+ @classmethod
553
+ def persistent_backend_cls(cls) -> type["Backend"] | None:
554
+ """Return the persistent-worker variant of this backend, or ``None``
555
+ if the backend is batch-only.
556
+
557
+ Override on backends that ship a persistent variant; the override
558
+ lazy-imports its sibling persistent module so the optional dep
559
+ (DymolaInterface, OMPython, ``julia`` binary) is not pulled in
560
+ during a plain batch run. The CLI calls this — keeping the lookup
561
+ on the backend class rather than in CLI code means a new persistent
562
+ backend is purely a backend-module change with no CLI edit.
563
+ """
564
+ return None
565
+
566
+ @classmethod
567
+ def preflight(cls, config: Config) -> None:
568
+ """Cheap pre-startup dependency probe.
569
+
570
+ Override on persistent backends that have an external dependency
571
+ (Python module, native binary, license file) to raise
572
+ :class:`RuntimeError` with an install hint when it's missing.
573
+ Cheap probes only — full failure modes (worker compile errors,
574
+ license issues) still surface from ``run_tests``. Default is a
575
+ no-op for backends with no extra dependencies beyond what the
576
+ package declares.
577
+ """
578
+ return None
579
+
580
+ def describe_tool_version(self) -> str | None:
581
+ """Best-effort actual tool version (e.g. ``"Dymola 2026x"``, an omc
582
+ version, a Python version), or ``None`` when the backend can't cheaply
583
+ report one without a live session.
584
+
585
+ Called eagerly by ``run_tests`` to stamp run provenance. A backend
586
+ whose version is cheap to probe (a subprocess ``--version``) should
587
+ implement it here even when it also has a persistent lane — the
588
+ persistent classes inherit it (Julia does this). Only a version that
589
+ NEEDS a live session belongs in :meth:`_probe_worker_version`, which
590
+ the persistent template calls after startup. MUST NOT raise — a failed
591
+ version probe degrades to the configured backend label, never an
592
+ aborted run.
593
+ """
594
+ return None
595
+
596
+ def _safe_tool_version(self) -> str | None:
597
+ """``describe_tool_version()`` with a blanket guard — provenance is
598
+ never allowed to abort a run, so any probe failure degrades to
599
+ ``None`` (the reporter then falls back to the configured label)."""
600
+ try:
601
+ return self.describe_tool_version()
602
+ except Exception:
603
+ logger.debug("tool-version probe failed", exc_info=True)
604
+ return None
605
+
606
+ def _build_run_metadata(
607
+ self,
608
+ tool_version: str | None = None,
609
+ library_versions: dict | None = None,
610
+ ) -> dict:
611
+ """Assemble the run-provenance dict handed to ``ProgressReporter``.
612
+
613
+ Kept as a helper (not inlined) so both the batch and persistent
614
+ ``run_tests`` templates build identical provenance from ``self.config``.
615
+ """
616
+ from .run_metadata import RunMetadata
617
+
618
+ return RunMetadata.from_config(
619
+ self.config,
620
+ tool_version=tool_version,
621
+ library_versions=library_versions,
622
+ ).as_dict()
623
+
624
+ def export_fmu(self, test: TestModel, output_dir: "Path") -> "Path":
625
+ """Export the test's model as an FMU into ``output_dir``.
626
+
627
+ Backends must declare ``Capability.FMU_EXPORT`` in their
628
+ ``capabilities`` to call this; the default raises
629
+ :class:`NotImplementedError`. Used by 4.B's cross-backend chain
630
+ (e.g., Dymola export → FMPy simulate as a second baseline).
631
+
632
+ Returns the absolute path to the produced ``.fmu`` file.
633
+ """
634
+ raise NotImplementedError(
635
+ f"{type(self).__name__} does not support FMU export. "
636
+ f"Backend must declare Capability.FMU_EXPORT and override export_fmu()."
637
+ )
638
+
639
+ def run_single_test(
640
+ self,
641
+ test: TestModel,
642
+ test_key: str,
643
+ index: int,
644
+ total: int,
645
+ ) -> TestRunResult:
646
+ """Run a single test and return the result.
647
+
648
+ Override this for per-process execution (default run_tests uses this).
649
+ Not needed if run_tests is overridden entirely (e.g., batch execution).
650
+ """
651
+ raise NotImplementedError("Override run_tests() or run_single_test()")
652
+
653
+ @property
654
+ def label(self) -> str:
655
+ """The backend's name in a sentence a person reads ("Dymola")."""
656
+ return self.config.backend
657
+
658
+ def prepare_item_runners(self) -> None:
659
+ """Once per pool, before the first :meth:`item_runner`. Raises
660
+ ``RuntimeError`` when this backend cannot run one test at a time."""
661
+ if type(self).run_single_test is Backend.run_single_test:
662
+ raise RuntimeError(
663
+ f"{type(self).__name__} runs whole batches and has no "
664
+ f"single-test path; the simulation queue needs its "
665
+ f"persistent workers (backends.select_backend(config, "
666
+ f"persistent=True))."
667
+ )
668
+
669
+ def item_runner(self, worker_id: int) -> ItemRunner:
670
+ """The runner one pool thread drives (see :class:`ItemRunner`)."""
671
+ return ItemRunner(self, worker_id)
672
+
673
+ def cancel_test(self, test_key: str) -> bool:
674
+ """Stop the test :meth:`run_single_test` is running under *test_key*,
675
+ from another thread; ``False`` when none is. That call then returns
676
+ a ``cancelled`` result. Backends whose single-test path can be
677
+ stopped declare :attr:`Capability.CANCEL` and override this."""
678
+ raise NotImplementedError(
679
+ f"{type(self).__name__} declares no Capability.CANCEL: its "
680
+ f"single-test path cannot be stopped."
681
+ )
682
+
683
+ def run_versions(self) -> dict:
684
+ """``tool_version`` / ``library_versions`` for run provenance, as far
685
+ as this backend can name them now (see :meth:`_build_run_metadata`)."""
686
+ version = self._safe_tool_version()
687
+ return {"tool_version": version} if version else {}
688
+
689
+ @abstractmethod
690
+ def read_result(
691
+ self,
692
+ test: TestModel,
693
+ test_key: str,
694
+ run_result: TestRunResult,
695
+ ) -> TestResult:
696
+ """Read simulation output for a completed test."""
697
+ ...
698
+
699
+ @classmethod
700
+ def result_names(cls, test_dir: Path) -> list[str] | None:
701
+ """Every variable name in the result file under ``test_dir``
702
+ (``work_dir/<test_key>``), tracked or not; ``None`` when this backend
703
+ keeps no file it can list. A classmethod: the report reads a test's
704
+ files without instantiating a backend."""
705
+ return None
706
+
707
+ @classmethod
708
+ def result_series(
709
+ cls, test_dir: Path, names: Collection[str]
710
+ ) -> dict[str, tuple[np.ndarray, np.ndarray]]:
711
+ """``{name: (time, values)}`` for the ``names`` the file has."""
712
+ return {}
713
+
714
+ @classmethod
715
+ def live_result_path(cls, test_dir: Path) -> Path | None:
716
+ """The result file of the test run in ``test_dir``
717
+ (``work_dir/<test_key>``) that GROWS while the run is in progress —
718
+ the path a :class:`~.common.live_mat.LiveMatReader` is built for.
719
+ ``None`` for a backend that writes its result once, at the end.
720
+
721
+ Overridden by exactly the backends that declare
722
+ ``Capability.LIVE_RESULTS``. The file need not exist yet: a
723
+ persistent worker wipes the directory before each run."""
724
+ return None
725
+
726
+ def _prepare_run(self, tests: list[TestModel]) -> RunSetup:
727
+ """Shared ``run_tests`` prologue: work dir, provenance, progress
728
+ reporter, test keys and the saved batch manifest.
729
+
730
+ Every ``run_tests`` implementation MUST go through here — the three
731
+ copies of these steps drifted once already (C-9: batch Dymola lost the
732
+ run-provenance stamp). Backend-specific staging (persistent
733
+ ``setup_before_workers``, Dymola's per-test ``.mos`` generation) and
734
+ the run header stay at the call sites.
735
+ """
736
+ self.config.work_dir.mkdir(parents=True, exist_ok=True)
737
+ total = len(tests)
738
+
739
+ from ..reporting.dashboard_render import build_rerun_prefix
740
+ from .progress import ProgressReporter
741
+
742
+ # Cheap version probes run here, eagerly (subprocess --version);
743
+ # a version that needs a live session arrives later via
744
+ # _probe_worker_version once a worker is up.
745
+ run_metadata = self._build_run_metadata(self._safe_tool_version())
746
+ progress = ProgressReporter(
747
+ self.config.work_dir,
748
+ total,
749
+ rerun_prefix=build_rerun_prefix(self.config),
750
+ metadata=run_metadata,
751
+ theme=self.config.theme,
752
+ )
753
+ self.progress = progress
754
+
755
+ manifest, keyed_tests = self.key_tests(tests)
756
+ for test, test_key in keyed_tests:
757
+ report_dir = self.ref_id_map.get(test.model_id) or test_key
758
+ progress.register(
759
+ test_key,
760
+ test.model_id,
761
+ report_dir=report_dir,
762
+ field_sources=test.field_sources,
763
+ )
764
+
765
+ return RunSetup(
766
+ total=total,
767
+ run_metadata=run_metadata,
768
+ progress=progress,
769
+ keyed_tests=keyed_tests,
770
+ manifest=manifest,
771
+ )
772
+
773
+ def key_tests(
774
+ self, tests: list[TestModel]
775
+ ) -> tuple[BatchManifest, list[tuple[TestModel, str]]]:
776
+ """Key *tests* in the work dir's manifest and stamp them pending:
777
+ the saved manifest and ``(test, test_key)`` in the caller's order.
778
+
779
+ The half of the prologue a run of ONE test needs as much as a run of
780
+ a list (:meth:`_prepare_run` is the other caller).
781
+ """
782
+ self.config.work_dir.mkdir(parents=True, exist_ok=True)
783
+ # Assign test_keys (reuse existing from prior runs if present — supports
784
+ # incremental workflows where the manifest accumulates known tests).
785
+ manifest_map, keyed_tests = assign_test_keys(self.config.work_dir, tests)
786
+ manifest = BatchManifest(
787
+ work_dir=self.config.work_dir,
788
+ manifest=manifest_map,
789
+ )
790
+ # Clear this run's scope back to "pending" before a single test
791
+ # starts. assign_test_keys carries prior entries forward wholesale, so
792
+ # without this a previous run's "passed" would sit on a test this run
793
+ # fails and then aborts before recording — and `compare` would trust
794
+ # it over the partial .mat on disk. Out-of-scope entries keep their
795
+ # own last verdict (that is what --merge/--rerun report over).
796
+ manifest.mark_pending({t.model_id for t in tests})
797
+ manifest.save()
798
+ return manifest, keyed_tests
799
+
800
+ @property
801
+ def run_summary_phrase(self) -> str:
802
+ """Leading phrase of the end-of-run summary line
803
+ (see :meth:`_print_run_summary`)."""
804
+ return "Simulations complete"
805
+
806
+ def _print_run_summary(
807
+ self,
808
+ results: list[TestRunResult],
809
+ total: int,
810
+ wall: float,
811
+ ) -> None:
812
+ """End-of-run counts + timing, identical in shape for every backend."""
813
+ if self.quiet:
814
+ return
815
+ total_work = sum(r.elapsed for r in results)
816
+ speedup = (total_work / wall) if wall > 0 else 0.0
817
+ n_ok = sum(1 for r in results if r.success)
818
+ n_timeout = sum(1 for r in results if r.timed_out)
819
+ n_fail = total - n_ok - n_timeout
820
+ print(file=sys.stderr)
821
+ print(
822
+ f"{self.run_summary_phrase}: "
823
+ f"{n_ok} ok, {n_fail} failed, {n_timeout} timed out "
824
+ f"({wall:.0f}s wall, {total_work:.0f}s total work, "
825
+ f"{speedup:.1f}x parallel speedup)",
826
+ file=sys.stderr,
827
+ )
828
+
829
+ def run_tests(self, tests: list[TestModel]) -> list[BatchManifest]:
830
+ """Run all tests with progress, timeouts, and parallelism.
831
+
832
+ Subclasses generally don't need to override this — override
833
+ run_single_test and read_result instead.
834
+ """
835
+ if not tests:
836
+ return []
837
+
838
+ setup = self._prepare_run(tests)
839
+ total = setup.total
840
+ manifest = setup.manifest
841
+ test_items = [
842
+ (test, test_key, i + 1)
843
+ for i, (test, test_key) in enumerate(setup.keyed_tests)
844
+ ]
845
+
846
+ _print_run_header(
847
+ total,
848
+ "",
849
+ self.config.parallel,
850
+ self.config.timeout,
851
+ self.config.work_dir,
852
+ metadata=setup.run_metadata,
853
+ quiet=self.quiet,
854
+ )
855
+
856
+ def _result_status(rr: TestRunResult) -> str:
857
+ if rr.timed_out:
858
+ return "timeout"
859
+ return "ok" if rr.success else "fail"
860
+
861
+ def _end_row(test_key: str, rr: TestRunResult) -> None:
862
+ # A result that came back without a word to the reporter — a
863
+ # test its backend could not start — ends its row here.
864
+ setup.progress.on_result(
865
+ test_key,
866
+ rr.success,
867
+ rr.elapsed,
868
+ detail=rr.error_message,
869
+ timed_out=rr.timed_out,
870
+ )
871
+
872
+ run_results: list[TestRunResult] = []
873
+ wall_start = time.monotonic()
874
+ completed = 0
875
+ key_lookup = {t.model_id: tk for t, tk, _ in test_items}
876
+
877
+ if self.config.parallel <= 1:
878
+ for test, test_key, idx in test_items:
879
+ result = self.run_single_test(test, test_key, idx, total)
880
+ run_results.append(result)
881
+ # Persist the verdict as it lands, not in an epilogue an
882
+ # abort would skip (see BatchManifest.record_result).
883
+ manifest.record_result(result)
884
+ _end_row(test_key, result)
885
+ completed += 1
886
+ _print_progress(
887
+ completed,
888
+ total,
889
+ f"{test_key} {test.model_id}",
890
+ _result_status(result),
891
+ elapsed=result.elapsed,
892
+ quiet=self.quiet,
893
+ )
894
+ else:
895
+ futures = {}
896
+ with ThreadPoolExecutor(max_workers=self.config.parallel) as pool:
897
+ for test, test_key, idx in test_items:
898
+ future = pool.submit(
899
+ self.run_single_test, test, test_key, idx, total
900
+ )
901
+ futures[future] = test.model_id
902
+
903
+ for future in as_completed(futures):
904
+ result = future.result()
905
+ run_results.append(result)
906
+ manifest.record_result(result)
907
+ completed += 1
908
+ model_id = futures[future]
909
+ test_key = key_lookup.get(model_id, "")
910
+ _end_row(test_key, result)
911
+ _print_progress(
912
+ completed,
913
+ total,
914
+ f"{test_key} {model_id}",
915
+ _result_status(result),
916
+ elapsed=result.elapsed,
917
+ quiet=self.quiet,
918
+ )
919
+
920
+ self._print_run_summary(run_results, total, time.monotonic() - wall_start)
921
+
922
+ manifest.results = run_results
923
+ setup.progress.finalize()
924
+ return [manifest]
925
+
926
+ def read_results(
927
+ self,
928
+ manifests: list[BatchManifest],
929
+ tests: list[TestModel],
930
+ on_result: Callable[[str, TestResult], None] | None = None,
931
+ ) -> dict[str, TestResult]:
932
+ """Read all simulation results from completed batch runs (parallel).
933
+ ``on_result(model_id, result)`` is called on the calling thread as
934
+ each result is stored.
935
+
936
+ Every result carries the run's verdict. A test whose last run is
937
+ recorded as failed or timed out — the manifest's ``last_run_status``,
938
+ or the ``status.json`` row for an entry with no recorded verdict
939
+ (:func:`run_verdicts`) — is forced to ``success=False`` before it is
940
+ stored and before the
941
+ callback sees it, whatever its result file reads back as — a
942
+ partial ``.mat`` from a timed-out run parses as a success. The
943
+ verdict is applied in this one reader and not by its callers
944
+ because the caller that forgot was ``run_pipeline``: ``run --merge``
945
+ / ``--rerun`` reported PASS for a merged-in test whose run timed
946
+ out while ``compare`` on the same work dir reported SIM_FAIL, and
947
+ ``run --rerun failed -i`` then offered that partial run for
948
+ acceptance as the primary baseline.
949
+
950
+ An entry the last run left :data:`PENDING_RUN_STATUS` — it started
951
+ the test and never finished it — with no result from this process's
952
+ run and no recorded failure (:func:`run_verdicts`) yields NO result:
953
+ its result file is not read, whatever it holds. This is the one
954
+ place "no result exists" is told from "the simulation failed"
955
+ (D145): the comparator reads a test with no result as NOT RUN, and a
956
+ test whose run recorded a failure — here, or in ``status.json`` for a
957
+ pending entry — as SIM_FAIL.
958
+
959
+ A run that was cancelled (:data:`CANCELLED_RUN_STATUS`, or a
960
+ cancelled run result in hand) yields no result either: it was
961
+ stopped on request, and what it left on disk is partial.
962
+ """
963
+ work_dir = self.config.work_dir
964
+ assert work_dir is not None # resolved in Config.__post_init__
965
+ verdicts = run_verdicts(work_dir, manifests)
966
+ test_lookup = {t.model_id: t for t in tests}
967
+ results: dict[str, TestResult] = {}
968
+
969
+ def _store(model_id: str, result: TestResult) -> None:
970
+ if model_id in verdicts:
971
+ _force_failed(result, verdicts[model_id])
972
+ results[model_id] = result
973
+ if on_result is not None:
974
+ on_result(model_id, result)
975
+
976
+ run_results = {}
977
+ for manifest in manifests:
978
+ for rr in manifest.results:
979
+ run_results[rr.model_id] = rr
980
+
981
+ # Build work items: (test_key, model_id, test_model, run_result)
982
+ work_items = []
983
+ for manifest in manifests:
984
+ for test_key, entry in manifest.manifest.items():
985
+ model_id = entry["model_id"]
986
+ rr = run_results.get(model_id)
987
+ test_model = test_lookup.get(model_id)
988
+
989
+ if rr is not None and rr.cancelled:
990
+ continue # stopped on request: no result
991
+ if rr and not rr.success:
992
+ _store(model_id, failed_run_result(rr))
993
+ continue
994
+
995
+ if rr is None and entry.get("last_run_status") == CANCELLED_RUN_STATUS:
996
+ continue # stopped on request: no result
997
+ if (
998
+ rr is None
999
+ and entry.get("last_run_status") == PENDING_RUN_STATUS
1000
+ and model_id not in verdicts
1001
+ ):
1002
+ continue # never finished: no result (D145)
1003
+
1004
+ if test_model is None:
1005
+ _store(
1006
+ model_id,
1007
+ TestResult(
1008
+ model_id=model_id,
1009
+ success=False,
1010
+ error_message="Test model not found in discovery",
1011
+ ),
1012
+ )
1013
+ continue
1014
+
1015
+ work_items.append((test_key, model_id, test_model, rr))
1016
+
1017
+ if not work_items:
1018
+ return results
1019
+
1020
+ total = len(work_items)
1021
+ completed = 0
1022
+ if not self.quiet:
1023
+ print(f"\nReading {total} result files...")
1024
+ wall_start = time.monotonic()
1025
+ per_test_elapsed: list[float] = []
1026
+ elapsed_lock = Lock()
1027
+
1028
+ def _read_one(item):
1029
+ test_key, model_id, test_model, rr = item
1030
+ t0 = time.monotonic()
1031
+ res = self.read_run_result(test_model, test_key, rr)
1032
+ return test_key, model_id, res, time.monotonic() - t0
1033
+
1034
+ # Parallelize reads with thread pool
1035
+ with ThreadPoolExecutor() as executor:
1036
+ futures = {executor.submit(_read_one, item): item for item in work_items}
1037
+ for future in as_completed(futures):
1038
+ test_key, model_id, result, dt = future.result()
1039
+ _store(model_id, result)
1040
+ with elapsed_lock:
1041
+ per_test_elapsed.append(dt)
1042
+ completed += 1
1043
+ short = model_id.rsplit(".", 1)[-1]
1044
+ _print_progress(
1045
+ completed, total, f"{test_key} {short}", "read", quiet=self.quiet
1046
+ )
1047
+
1048
+ wall = time.monotonic() - wall_start
1049
+ total_work = sum(per_test_elapsed)
1050
+ if per_test_elapsed and not self.quiet:
1051
+ speedup = (total_work / wall) if wall > 0 else 0.0
1052
+ avg = total_work / len(per_test_elapsed)
1053
+ slowest = max(per_test_elapsed)
1054
+ print(
1055
+ f"Read phase: {wall:.0f}s wall, {total_work:.0f}s total work, "
1056
+ f"{speedup:.1f}x parallel speedup (avg {avg:.1f}s/test, slowest {slowest:.1f}s)"
1057
+ )
1058
+ return results
1059
+
1060
+ def result_of_run(
1061
+ self, test: TestModel, test_key: str, run_result: TestRunResult
1062
+ ) -> TestResult | None:
1063
+ """What ONE run that just ended produced, from its run result in
1064
+ hand: ``None`` for a cancelled run, the failure for a run that
1065
+ failed, else the result read back (:meth:`read_run_result`).
1066
+
1067
+ :meth:`read_results`' per-test step for a caller holding one run
1068
+ result — the simulation queue reads each test back as it finishes.
1069
+ """
1070
+ if run_result.cancelled:
1071
+ return None
1072
+ if not run_result.success:
1073
+ return failed_run_result(run_result)
1074
+ return self.read_run_result(test, test_key, run_result)
1075
+
1076
+ def read_run_result(
1077
+ self,
1078
+ test: TestModel,
1079
+ test_key: str,
1080
+ run_result: TestRunResult | None,
1081
+ ) -> TestResult:
1082
+ """:meth:`read_result` with the run's timing block (:func:`run_timing`)
1083
+ merged into the result's statistics — the one place it is built, for
1084
+ every backend.
1085
+
1086
+ With no run result in hand (a reload, a ``compare``, a test ``--merge``
1087
+ did not re-run), the one the test's last run kept beside the result
1088
+ (:func:`kept_run_result`): its walls, and the statistics the backend
1089
+ parsed from the run, which a reload otherwise loses.
1090
+ """
1091
+ if run_result is None:
1092
+ run_result = kept_run_result(
1093
+ self.config.work_dir / test_key, test.model_id, test_key
1094
+ )
1095
+ res = self.read_result(test, test_key, run_result)
1096
+ if run_result is not None:
1097
+ res.statistics = _with_run_timing(res.statistics, run_result)
1098
+ return res
1099
+
1100
+ def read_last_results(
1101
+ self,
1102
+ tests: list[TestModel],
1103
+ on_result: Callable[[str, TestResult], None] | None = None,
1104
+ ) -> dict[str, TestResult]:
1105
+ """Read results from the most recent batch run in the work
1106
+ directory: :meth:`read_results` over the manifest on disk, so each
1107
+ result reaches ``on_result`` with its run verdict already applied."""
1108
+ work_dir = self.config.work_dir
1109
+ if not work_dir.exists():
1110
+ return {}
1111
+
1112
+ manifest_paths = sorted(work_dir.glob("batch_manifest.json"))
1113
+ if not manifest_paths:
1114
+ return {}
1115
+
1116
+ manifests = [BatchManifest.load(p) for p in manifest_paths]
1117
+ return self.read_results(manifests, tests, on_result=on_result)