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/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)
|