dynamic-systems-testing-framework 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dstf/__init__.py +41 -0
- dstf/__main__.py +10 -0
- dstf/authoring/__init__.py +13 -0
- dstf/authoring/builtin/__init__.py +32 -0
- dstf/authoring/builtin/accept.py +205 -0
- dstf/authoring/builtin/edit_criteria.py +479 -0
- dstf/authoring/builtin/edit_family.py +228 -0
- dstf/authoring/builtin/edit_on_plot.py +1354 -0
- dstf/authoring/builtin/edit_tree.py +1399 -0
- dstf/authoring/builtin/enqueue.py +144 -0
- dstf/authoring/capabilities.py +463 -0
- dstf/authoring/errors.py +49 -0
- dstf/authoring/family_rows.py +526 -0
- dstf/authoring/forms.py +631 -0
- dstf/authoring/layout.py +104 -0
- dstf/authoring/live_rows.py +337 -0
- dstf/authoring/live_runs.py +385 -0
- dstf/authoring/needle.py +127 -0
- dstf/authoring/picker_rows.py +92 -0
- dstf/authoring/queue_rows.py +354 -0
- dstf/authoring/session.py +3016 -0
- dstf/authoring/shell/__init__.py +9 -0
- dstf/authoring/shell/actions.py +702 -0
- dstf/authoring/shell/app.py +53 -0
- dstf/authoring/shell/drawer_controls.py +190 -0
- dstf/authoring/shell/family_fold.py +468 -0
- dstf/authoring/shell/figure_hook.py +249 -0
- dstf/authoring/shell/grips.js +1000 -0
- dstf/authoring/shell/header.py +783 -0
- dstf/authoring/shell/live_view.py +93 -0
- dstf/authoring/shell/picker.py +267 -0
- dstf/authoring/shell/placing.py +122 -0
- dstf/authoring/shell/plot_card.py +1171 -0
- dstf/authoring/shell/queue_panel.py +433 -0
- dstf/authoring/shell/rail.py +593 -0
- dstf/authoring/shell/shell.py +528 -0
- dstf/authoring/shell/stats_card.py +501 -0
- dstf/authoring/shell/strip.py +204 -0
- dstf/authoring/shell/style.py +721 -0
- dstf/authoring/shell/suite.py +277 -0
- dstf/authoring/shell/suite_grid.js +414 -0
- dstf/authoring/shell/suite_grid.py +373 -0
- dstf/authoring/shell/tab_theme.py +110 -0
- dstf/authoring/shell/tabstate.py +146 -0
- dstf/authoring/shell/test_list.py +179 -0
- dstf/authoring/shell/test_page.py +1404 -0
- dstf/authoring/shell/tree_card.py +1171 -0
- dstf/authoring/shell/variables_drawer.py +589 -0
- dstf/authoring/shell/widgets.py +188 -0
- dstf/authoring/situation.py +287 -0
- dstf/authoring/spec_document.py +215 -0
- dstf/authoring/spec_preview.py +83 -0
- dstf/authoring/suite_rows.py +99 -0
- dstf/authoring/tree_move.py +516 -0
- dstf/authoring/tree_pointer.py +33 -0
- dstf/authoring/tree_text.py +61 -0
- dstf/authoring/typed_number.js +64 -0
- dstf/authoring/typed_number.py +76 -0
- dstf/authoring/unscorable.py +141 -0
- dstf/authoring/variable_rows.py +216 -0
- dstf/backends/__init__.py +283 -0
- dstf/backends/base.py +1117 -0
- dstf/backends/common/__init__.py +1 -0
- dstf/backends/common/json_driver.py +484 -0
- dstf/backends/common/live_mat.py +369 -0
- dstf/backends/common/mat_reader.py +632 -0
- dstf/backends/common/proc_kill.py +238 -0
- dstf/backends/common/proc_output.py +20 -0
- dstf/backends/common/result_files.py +109 -0
- dstf/backends/common/result_variables.py +329 -0
- dstf/backends/common/statistics.py +39 -0
- dstf/backends/cross_backend.py +193 -0
- dstf/backends/dymola/__init__.py +5 -0
- dstf/backends/dymola/backend.py +776 -0
- dstf/backends/dymola/interface_loader.py +241 -0
- dstf/backends/dymola/log_parser.py +396 -0
- dstf/backends/dymola/persistent_backend.py +949 -0
- dstf/backends/fmpy/__init__.py +11 -0
- dstf/backends/fmpy/backend.py +443 -0
- dstf/backends/julia/__init__.py +27 -0
- dstf/backends/julia/backend.py +156 -0
- dstf/backends/julia/persistent_backend.py +541 -0
- dstf/backends/julia/run_persistent.jl +167 -0
- dstf/backends/julia/run_test.jl +146 -0
- dstf/backends/openmodelica/__init__.py +21 -0
- dstf/backends/openmodelica/backend.py +368 -0
- dstf/backends/openmodelica/init_xml.py +76 -0
- dstf/backends/openmodelica/log_parser.py +119 -0
- dstf/backends/openmodelica/mos_generator.py +228 -0
- dstf/backends/openmodelica/persistent_backend.py +1045 -0
- dstf/backends/openmodelica/session_loader.py +63 -0
- dstf/backends/persistent.py +1811 -0
- dstf/backends/pool.py +602 -0
- dstf/backends/progress.py +395 -0
- dstf/backends/python/__init__.py +11 -0
- dstf/backends/python/backend.py +141 -0
- dstf/backends/python/run_test.py +141 -0
- dstf/backends/run_metadata.py +103 -0
- dstf/backends/types.py +310 -0
- dstf/cli/__init__.py +12 -0
- dstf/cli/author_cmd.py +61 -0
- dstf/cli/baseline_cmds.py +341 -0
- dstf/cli/common.py +231 -0
- dstf/cli/diag_cmds.py +350 -0
- dstf/cli/main.py +561 -0
- dstf/cli/review.py +398 -0
- dstf/cli/run_cmds.py +589 -0
- dstf/cli/spec_cmds.py +123 -0
- dstf/comparison/__init__.py +13 -0
- dstf/comparison/algorithms.py +2127 -0
- dstf/comparison/builtins.py +291 -0
- dstf/comparison/comparator.py +1189 -0
- dstf/comparison/geometry.py +164 -0
- dstf/comparison/handles.py +1194 -0
- dstf/comparison/metric_tree.py +332 -0
- dstf/comparison/mode_schema.py +176 -0
- dstf/comparison/modes.py +788 -0
- dstf/comparison/registry.py +230 -0
- dstf/comparison/seeds.py +281 -0
- dstf/comparison/sides.py +158 -0
- dstf/comparison/snap.py +62 -0
- dstf/comparison/strict_keys.py +246 -0
- dstf/comparison/summaries.py +354 -0
- dstf/comparison/tree_eval.py +868 -0
- dstf/comparison/tree_spec.py +678 -0
- dstf/comparison/types.py +194 -0
- dstf/comparison/validator.py +232 -0
- dstf/config.py +815 -0
- dstf/discovery/__init__.py +0 -0
- dstf/discovery/json_recognizer.py +702 -0
- dstf/discovery/library_state.py +248 -0
- dstf/discovery/mo_parser.py +794 -0
- dstf/discovery/patch_apply.py +308 -0
- dstf/discovery/patch_envelope.py +63 -0
- dstf/discovery/plots.py +318 -0
- dstf/discovery/recognizer.py +103 -0
- dstf/discovery/scan_cache.py +154 -0
- dstf/discovery/spec_parser.py +538 -0
- dstf/discovery/spec_shape.py +122 -0
- dstf/discovery/spec_update.py +493 -0
- dstf/discovery/test_registry.py +488 -0
- dstf/discovery/unit_tests.py +95 -0
- dstf/figures/__init__.py +14 -0
- dstf/figures/assemble.py +48 -0
- dstf/figures/decimate.py +150 -0
- dstf/figures/families.py +289 -0
- dstf/figures/palette.py +170 -0
- dstf/figures/plotly.py +1413 -0
- dstf/figures/view.py +233 -0
- dstf/pipeline.py +435 -0
- dstf/plugins.py +104 -0
- dstf/reporting/__init__.py +0 -0
- dstf/reporting/console_report.py +187 -0
- dstf/reporting/context_builder.py +979 -0
- dstf/reporting/dashboard_render.py +534 -0
- dstf/reporting/junit_report.py +192 -0
- dstf/reporting/overlay_loader.py +708 -0
- dstf/reporting/report_suite.py +1059 -0
- dstf/reporting/rows.py +458 -0
- dstf/reporting/schema_export.py +413 -0
- dstf/reporting/stats_rows.py +321 -0
- dstf/reporting/status.py +388 -0
- dstf/reporting/templates/axes.js +61 -0
- dstf/reporting/templates/dashboard.html +284 -0
- dstf/reporting/templates/dashboard.js +604 -0
- dstf/reporting/templates/filters.js +100 -0
- dstf/reporting/templates/interactive.html +420 -0
- dstf/reporting/templates/report.js +116 -0
- dstf/reporting/templates/vendor/plotly.min.js +8 -0
- dstf/reporting/theme.py +324 -0
- dstf/reporting/tree_rows.py +73 -0
- dstf/reporting/verdicts.py +124 -0
- dstf/storage/__init__.py +0 -0
- dstf/storage/accept.py +216 -0
- dstf/storage/baseline.py +43 -0
- dstf/storage/batch_manifest.py +204 -0
- dstf/storage/ref_index.py +295 -0
- dstf/storage/reference_store.py +661 -0
- dstf/storage/roles.py +576 -0
- dstf/storage/run_stats.py +85 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Cross-backend shared helpers (MAT reader, etc.)."""
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"""Shared skeleton for JSON-driver subprocess backends.
|
|
2
|
+
|
|
3
|
+
Julia (D77) and Python (D80) are the same backend shape: spawn one subprocess
|
|
4
|
+
per test running a shipped driver script, hand it the user's source file plus
|
|
5
|
+
``(stop_time, tolerance, result_path)``, and read back a JSON payload of
|
|
6
|
+
``{"success", "time", "variables"}``. Everything that machinery does — source
|
|
7
|
+
resolution, the timeout mapping, the stdout/stderr artifacts, the failure-
|
|
8
|
+
message precedence, numeric validation of the payload — is identical, so it
|
|
9
|
+
lives here once (C-iii U-4). What genuinely differs is the class-level hooks
|
|
10
|
+
below: which driver, which binary, how the argv is built, and the labels that
|
|
11
|
+
go into messages and artifact filenames.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import subprocess
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from abc import abstractmethod
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import ClassVar
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
from ...discovery.test_registry import TestModel
|
|
29
|
+
from ..base import Backend
|
|
30
|
+
from ..types import (
|
|
31
|
+
TestResult,
|
|
32
|
+
TestRunResult,
|
|
33
|
+
VariableResult,
|
|
34
|
+
resolve_variable_patterns,
|
|
35
|
+
timeout_message,
|
|
36
|
+
)
|
|
37
|
+
from . import result_files
|
|
38
|
+
from .proc_output import decode_output
|
|
39
|
+
from .result_variables import criterion_variables
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def run_killable(
|
|
43
|
+
cmd: list[str],
|
|
44
|
+
*,
|
|
45
|
+
cwd: Path,
|
|
46
|
+
timeout: float,
|
|
47
|
+
on_spawn: Callable[[subprocess.Popen], None],
|
|
48
|
+
) -> subprocess.CompletedProcess:
|
|
49
|
+
"""``subprocess.run(cmd, capture_output=True, text=True, timeout=...)``
|
|
50
|
+
with the live ``Popen`` handed to *on_spawn*, so another thread can kill
|
|
51
|
+
it. Same contract otherwise: the child is killed and reaped on timeout
|
|
52
|
+
and ``TimeoutExpired`` carries what it had written.
|
|
53
|
+
"""
|
|
54
|
+
with subprocess.Popen(
|
|
55
|
+
cmd,
|
|
56
|
+
cwd=cwd,
|
|
57
|
+
stdout=subprocess.PIPE,
|
|
58
|
+
stderr=subprocess.PIPE,
|
|
59
|
+
text=True,
|
|
60
|
+
# review 2026-07-06 (finding 75): pin utf-8 + replace so a
|
|
61
|
+
# non-ASCII traceback can't crash decoding on cp1252 hosts.
|
|
62
|
+
encoding="utf-8",
|
|
63
|
+
errors="replace",
|
|
64
|
+
) as proc:
|
|
65
|
+
on_spawn(proc)
|
|
66
|
+
try:
|
|
67
|
+
stdout, stderr = proc.communicate(timeout=timeout)
|
|
68
|
+
except subprocess.TimeoutExpired as exc:
|
|
69
|
+
proc.kill()
|
|
70
|
+
exc.stdout, exc.stderr = proc.communicate()
|
|
71
|
+
raise
|
|
72
|
+
except BaseException:
|
|
73
|
+
proc.kill()
|
|
74
|
+
raise
|
|
75
|
+
return subprocess.CompletedProcess(cmd, proc.returncode, stdout, stderr)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
logger = logging.getLogger(__name__)
|
|
79
|
+
|
|
80
|
+
#: The reason of a failure JSON that gives none (no ``error``, an empty or a
|
|
81
|
+
#: null one) — the run's word and the read-back's.
|
|
82
|
+
NO_REASON = "the driver gave no reason"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class JsonDriverBackend(Backend):
|
|
86
|
+
"""Subprocess-per-test backend driving a shipped JSON-writing script."""
|
|
87
|
+
|
|
88
|
+
#: Shipped driver script handed to the tool binary.
|
|
89
|
+
driver_path: ClassVar[Path]
|
|
90
|
+
#: Human label for the tool in messages ("Julia", "Python").
|
|
91
|
+
tool_label: ClassVar[str]
|
|
92
|
+
#: What the tool does to a test, for messages ("simulation", "execution").
|
|
93
|
+
action_label: ClassVar[str]
|
|
94
|
+
#: Prefix of the stdout/stderr artifacts, also the binary name in the
|
|
95
|
+
#: generic non-zero-return message ("julia", "python").
|
|
96
|
+
stdio_prefix: ClassVar[str]
|
|
97
|
+
#: Extension of the user's source file, incl. the dot (".jl", ".py").
|
|
98
|
+
source_suffix: ClassVar[str]
|
|
99
|
+
#: Name the driver writes its JSON result under, inside the test dir.
|
|
100
|
+
result_filename: ClassVar[str] = "result.json"
|
|
101
|
+
|
|
102
|
+
def __init__(self, config):
|
|
103
|
+
super().__init__(config)
|
|
104
|
+
#: The driver subprocess of each test in flight, by test key, and the
|
|
105
|
+
#: tests told to stop — what :meth:`cancel_test` works on, from
|
|
106
|
+
#: another thread.
|
|
107
|
+
self._procs_lock = threading.Lock()
|
|
108
|
+
self._procs: dict[str, subprocess.Popen] = {}
|
|
109
|
+
self._cancelled: set[str] = set()
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
@abstractmethod
|
|
113
|
+
def tool_binary(self) -> Path:
|
|
114
|
+
"""The executable the driver script runs under."""
|
|
115
|
+
...
|
|
116
|
+
|
|
117
|
+
@abstractmethod
|
|
118
|
+
def build_command(
|
|
119
|
+
self,
|
|
120
|
+
binary: Path,
|
|
121
|
+
driver: Path,
|
|
122
|
+
user_file: Path,
|
|
123
|
+
stop_time: float,
|
|
124
|
+
tolerance: float,
|
|
125
|
+
result_path: Path,
|
|
126
|
+
) -> list[str]:
|
|
127
|
+
"""Full argv for one test. The driver's own four arguments must stay
|
|
128
|
+
in this order — every driver script reads them positionally."""
|
|
129
|
+
...
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
# Simulation
|
|
133
|
+
# ------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def run_single_test(
|
|
136
|
+
self,
|
|
137
|
+
test: TestModel,
|
|
138
|
+
test_key: str,
|
|
139
|
+
index: int,
|
|
140
|
+
total: int,
|
|
141
|
+
) -> TestRunResult:
|
|
142
|
+
user_file = self._resolve_source(test)
|
|
143
|
+
if user_file is None or not user_file.exists():
|
|
144
|
+
return TestRunResult(
|
|
145
|
+
model_id=test.model_id,
|
|
146
|
+
test_key=test_key,
|
|
147
|
+
success=False,
|
|
148
|
+
error_message=(
|
|
149
|
+
f"{self.tool_label} source not found for {test.model_id}. "
|
|
150
|
+
f"Ensure the test_spec.json entry has a 'source' field "
|
|
151
|
+
f"resolving to an existing {self.source_suffix} file under "
|
|
152
|
+
f"source_path."
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
test_dir = self.config.work_dir / test_key
|
|
157
|
+
test_dir.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
result_path = test_dir / self.result_filename
|
|
159
|
+
stdout_path = test_dir / f"{self.stdio_prefix}_stdout.txt"
|
|
160
|
+
stderr_path = test_dir / f"{self.stdio_prefix}_stderr.txt"
|
|
161
|
+
|
|
162
|
+
if self.progress:
|
|
163
|
+
self.progress.on_start(test_key)
|
|
164
|
+
self.progress.on_phase(test_key, "simulating")
|
|
165
|
+
|
|
166
|
+
timeout = float(
|
|
167
|
+
test.timeout if test.timeout is not None else self.config.timeout
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
cmd = self.build_command(
|
|
171
|
+
self.tool_binary,
|
|
172
|
+
self.driver_path,
|
|
173
|
+
user_file,
|
|
174
|
+
test.stop_time,
|
|
175
|
+
test.tolerance,
|
|
176
|
+
result_path,
|
|
177
|
+
)
|
|
178
|
+
logger.debug("%s cmd: %s", self.tool_label, " ".join(cmd))
|
|
179
|
+
|
|
180
|
+
wall_start = time.monotonic()
|
|
181
|
+
|
|
182
|
+
def _register(spawned: subprocess.Popen) -> None:
|
|
183
|
+
with self._procs_lock:
|
|
184
|
+
self._procs[test_key] = spawned
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
try:
|
|
188
|
+
proc = run_killable(
|
|
189
|
+
cmd, cwd=test_dir, timeout=timeout, on_spawn=_register
|
|
190
|
+
)
|
|
191
|
+
finally:
|
|
192
|
+
with self._procs_lock:
|
|
193
|
+
self._procs.pop(test_key, None)
|
|
194
|
+
cancelled = test_key in self._cancelled
|
|
195
|
+
self._cancelled.discard(test_key)
|
|
196
|
+
except subprocess.TimeoutExpired as exc:
|
|
197
|
+
elapsed = time.monotonic() - wall_start
|
|
198
|
+
# review 2026-07-06 (finding 23): TimeoutExpired.stdout/.stderr are
|
|
199
|
+
# bytes even with text=True — decode_output handles None/bytes/str.
|
|
200
|
+
stdout_path.write_text(decode_output(exc.stdout), encoding="utf-8")
|
|
201
|
+
stderr_path.write_text(decode_output(exc.stderr), encoding="utf-8")
|
|
202
|
+
msg = timeout_message(
|
|
203
|
+
f"{self.tool_label} {self.action_label} exceeded {timeout}s timeout"
|
|
204
|
+
)
|
|
205
|
+
if self.progress:
|
|
206
|
+
self.progress.on_finish(
|
|
207
|
+
test_key,
|
|
208
|
+
success=False,
|
|
209
|
+
elapsed=elapsed,
|
|
210
|
+
detail=msg,
|
|
211
|
+
timed_out=True,
|
|
212
|
+
)
|
|
213
|
+
return TestRunResult(
|
|
214
|
+
model_id=test.model_id,
|
|
215
|
+
test_key=test_key,
|
|
216
|
+
success=False,
|
|
217
|
+
elapsed=elapsed,
|
|
218
|
+
error_message=msg,
|
|
219
|
+
sim_wall=elapsed,
|
|
220
|
+
timed_out=True,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
elapsed = time.monotonic() - wall_start
|
|
224
|
+
stdout_path.write_text(proc.stdout or "", encoding="utf-8")
|
|
225
|
+
stderr_path.write_text(proc.stderr or "", encoding="utf-8")
|
|
226
|
+
|
|
227
|
+
if cancelled and proc.returncode != 0:
|
|
228
|
+
# Killed on request (cancel_test): neither a failure nor a
|
|
229
|
+
# result. A driver that had already finished keeps its result.
|
|
230
|
+
if self.progress:
|
|
231
|
+
self.progress.on_finish(
|
|
232
|
+
test_key, success=False, elapsed=elapsed, detail="Cancelled"
|
|
233
|
+
)
|
|
234
|
+
return TestRunResult(
|
|
235
|
+
model_id=test.model_id,
|
|
236
|
+
test_key=test_key,
|
|
237
|
+
success=False,
|
|
238
|
+
elapsed=elapsed,
|
|
239
|
+
error_message="Cancelled",
|
|
240
|
+
sim_wall=elapsed,
|
|
241
|
+
cancelled=True,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if proc.returncode != 0:
|
|
245
|
+
# The driver writes a structured failure JSON on exceptions; if
|
|
246
|
+
# present, we surface its 'error' message. Otherwise fall back
|
|
247
|
+
# to the raw stderr tail.
|
|
248
|
+
# review 2026-07-06 (finding 75): whitespace-only stderr made
|
|
249
|
+
# splitlines()[-1] IndexError inside the failure handler.
|
|
250
|
+
stderr_lines = (proc.stderr or "").strip().splitlines()
|
|
251
|
+
err = _read_failure_error(result_path)
|
|
252
|
+
if err is None:
|
|
253
|
+
err = (
|
|
254
|
+
stderr_lines[-1]
|
|
255
|
+
if stderr_lines
|
|
256
|
+
else f"{self.stdio_prefix} returned {proc.returncode}"
|
|
257
|
+
)
|
|
258
|
+
msg = self._failure(err)
|
|
259
|
+
if self.progress:
|
|
260
|
+
self.progress.on_finish(
|
|
261
|
+
test_key,
|
|
262
|
+
success=False,
|
|
263
|
+
elapsed=elapsed,
|
|
264
|
+
detail=msg,
|
|
265
|
+
)
|
|
266
|
+
return TestRunResult(
|
|
267
|
+
model_id=test.model_id,
|
|
268
|
+
test_key=test_key,
|
|
269
|
+
success=False,
|
|
270
|
+
elapsed=elapsed,
|
|
271
|
+
error_message=msg,
|
|
272
|
+
sim_wall=elapsed,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
if self.progress:
|
|
276
|
+
self.progress.on_finish(test_key, success=True, elapsed=elapsed)
|
|
277
|
+
|
|
278
|
+
return TestRunResult(
|
|
279
|
+
model_id=test.model_id,
|
|
280
|
+
test_key=test_key,
|
|
281
|
+
success=True,
|
|
282
|
+
elapsed=elapsed,
|
|
283
|
+
sim_wall=elapsed,
|
|
284
|
+
statistics={"simulation": {"wall_time": elapsed}},
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def _failure(self, reason: str) -> str:
|
|
288
|
+
"""The ONE message of a run whose driver failed, ``reason`` being
|
|
289
|
+
the driver's own word for why: what the run's result says, what
|
|
290
|
+
the progress row says, and what `read_result` says of the same
|
|
291
|
+
failure read back from the file the driver wrote it into — the
|
|
292
|
+
queue's row and, after a restart of the tool, the test's page. A
|
|
293
|
+
failure that left no such file (killed by its timeout, a driver
|
|
294
|
+
that died) reads back as the verdict its run recorded
|
|
295
|
+
(``backends.base._force_failed``)."""
|
|
296
|
+
return f"{self.tool_label} {self.action_label} failed: {reason}"
|
|
297
|
+
|
|
298
|
+
def cancel_test(self, test_key: str) -> bool:
|
|
299
|
+
"""Kill the driver subprocess running *test_key*; its
|
|
300
|
+
``run_single_test`` then returns a ``cancelled`` result."""
|
|
301
|
+
with self._procs_lock:
|
|
302
|
+
proc = self._procs.get(test_key)
|
|
303
|
+
if proc is None:
|
|
304
|
+
return False
|
|
305
|
+
self._cancelled.add(test_key)
|
|
306
|
+
try:
|
|
307
|
+
proc.kill()
|
|
308
|
+
except OSError:
|
|
309
|
+
pass # it ended on its own in the meantime
|
|
310
|
+
return True
|
|
311
|
+
|
|
312
|
+
# ------------------------------------------------------------------
|
|
313
|
+
# Reading
|
|
314
|
+
# ------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
def read_result(
|
|
317
|
+
self,
|
|
318
|
+
test: TestModel,
|
|
319
|
+
test_key: str,
|
|
320
|
+
run_result: TestRunResult,
|
|
321
|
+
) -> TestResult:
|
|
322
|
+
result_path = self.config.work_dir / test_key / self.result_filename
|
|
323
|
+
stats = run_result.statistics if run_result else None
|
|
324
|
+
if not result_path.exists():
|
|
325
|
+
return TestResult(
|
|
326
|
+
model_id=test.model_id,
|
|
327
|
+
success=False,
|
|
328
|
+
error_message=(
|
|
329
|
+
f"No {self.tool_label} result at {result_path} "
|
|
330
|
+
f"(did {self.action_label} run?)"
|
|
331
|
+
),
|
|
332
|
+
statistics=stats,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
with result_path.open(encoding="utf-8") as f:
|
|
337
|
+
payload = json.load(f)
|
|
338
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
339
|
+
return TestResult(
|
|
340
|
+
model_id=test.model_id,
|
|
341
|
+
success=False,
|
|
342
|
+
error_message=f"Failed to parse {result_path.name}: {exc}",
|
|
343
|
+
statistics=stats,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
if not payload.get("success", False):
|
|
347
|
+
return TestResult(
|
|
348
|
+
model_id=test.model_id,
|
|
349
|
+
success=False,
|
|
350
|
+
error_message=self._failure(payload.get("error") or NO_REASON),
|
|
351
|
+
statistics=stats,
|
|
352
|
+
run_reason=True,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
# review 2026-07-06 (finding 29): non-numeric values in a result
|
|
356
|
+
# payload (a user script returning strings, a tampered file) must
|
|
357
|
+
# fail THIS test, not raise through read_results and abort the whole
|
|
358
|
+
# read phase for every test. The drivers validate too; this is the
|
|
359
|
+
# backend-side belt.
|
|
360
|
+
try:
|
|
361
|
+
time_arr = np.asarray(payload.get("time", []), dtype=np.float64)
|
|
362
|
+
except (ValueError, TypeError) as exc:
|
|
363
|
+
return TestResult(
|
|
364
|
+
model_id=test.model_id,
|
|
365
|
+
success=False,
|
|
366
|
+
error_message=(
|
|
367
|
+
f"Non-numeric 'time' array in {result_path.name}: {exc}"
|
|
368
|
+
),
|
|
369
|
+
statistics=stats,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
available = list(payload.get("variables", {}).keys())
|
|
373
|
+
requested = resolve_variable_patterns(test.variable_patterns, available)
|
|
374
|
+
if not requested:
|
|
375
|
+
if "*" in test.variable_patterns:
|
|
376
|
+
requested = available
|
|
377
|
+
elif not test.variable_patterns:
|
|
378
|
+
requested = []
|
|
379
|
+
# The variables a criterion names, APPENDED after the patterns (their
|
|
380
|
+
# indices stay put); one the payload lacks is loud, as a missing
|
|
381
|
+
# UnitTests variable is on the .mat backends.
|
|
382
|
+
requested = list(requested)
|
|
383
|
+
for name in criterion_variables(test, self.config.diagnostic_variables):
|
|
384
|
+
if name in requested:
|
|
385
|
+
continue
|
|
386
|
+
if name in available:
|
|
387
|
+
requested.append(name)
|
|
388
|
+
else:
|
|
389
|
+
logger.warning(
|
|
390
|
+
"Variable %s not found in simulation output for %s",
|
|
391
|
+
name,
|
|
392
|
+
test.model_id,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
variables = []
|
|
396
|
+
# index is the position in the *requested* list, so a name the driver
|
|
397
|
+
# did not emit leaves a gap in the numbering rather than shifting the
|
|
398
|
+
# rest. The index is a display/ordering handle, not a key — reference
|
|
399
|
+
# matching is by name — so the gap is harmless and kept deliberately.
|
|
400
|
+
for i, name in enumerate(requested):
|
|
401
|
+
if name not in payload["variables"]:
|
|
402
|
+
continue
|
|
403
|
+
try:
|
|
404
|
+
values = np.asarray(payload["variables"][name], dtype=np.float64)
|
|
405
|
+
except (ValueError, TypeError) as exc:
|
|
406
|
+
return TestResult(
|
|
407
|
+
model_id=test.model_id,
|
|
408
|
+
success=False,
|
|
409
|
+
error_message=(
|
|
410
|
+
f"Variable '{name}' has non-numeric values in "
|
|
411
|
+
f"{result_path.name}: {exc}"
|
|
412
|
+
),
|
|
413
|
+
statistics=stats,
|
|
414
|
+
)
|
|
415
|
+
variables.append(
|
|
416
|
+
VariableResult(
|
|
417
|
+
index=i + 1,
|
|
418
|
+
name=name,
|
|
419
|
+
time=time_arr,
|
|
420
|
+
values=values,
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
return TestResult(
|
|
425
|
+
model_id=test.model_id,
|
|
426
|
+
success=True,
|
|
427
|
+
variables=variables,
|
|
428
|
+
diagnostics=[],
|
|
429
|
+
statistics=stats,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
@classmethod
|
|
433
|
+
def result_names(cls, test_dir: Path) -> list[str] | None:
|
|
434
|
+
return result_files.json_names(test_dir / cls.result_filename)
|
|
435
|
+
|
|
436
|
+
@classmethod
|
|
437
|
+
def result_series(cls, test_dir, names):
|
|
438
|
+
return result_files.json_series(test_dir / cls.result_filename, names)
|
|
439
|
+
|
|
440
|
+
# ------------------------------------------------------------------
|
|
441
|
+
# Helpers
|
|
442
|
+
# ------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
def _resolve_source(self, test: TestModel) -> Path | None:
|
|
445
|
+
return resolve_source_file(test, self.config, self.source_suffix)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def resolve_source_file(test: TestModel, config, suffix: str) -> Path | None:
|
|
449
|
+
"""Resolve the user's source file.
|
|
450
|
+
|
|
451
|
+
Priority: ``test.source_file`` (spec_parser fills this when the entry has
|
|
452
|
+
a ``"source"`` field) → ``<source_path>/<model_id><suffix>`` fallback.
|
|
453
|
+
|
|
454
|
+
Module-level rather than backend-only because the persistent Julia worker
|
|
455
|
+
resolves the same file without being a backend.
|
|
456
|
+
"""
|
|
457
|
+
if test.source_file is not None and str(test.source_file):
|
|
458
|
+
p = Path(test.source_file)
|
|
459
|
+
if not p.is_absolute() and config.source_path:
|
|
460
|
+
p = config.source_path / p
|
|
461
|
+
return p
|
|
462
|
+
# Fallback: model_id mapped to a filename.
|
|
463
|
+
if config.source_path:
|
|
464
|
+
return config.source_path / f"{test.model_id}{suffix}"
|
|
465
|
+
return None
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _read_failure_error(result_path: Path) -> str | None:
|
|
469
|
+
"""If the driver wrote a failure JSON, its 'error' message —
|
|
470
|
+
:data:`NO_REASON` for one that gives none, as `read_result` says of the
|
|
471
|
+
same file; None with no failure JSON."""
|
|
472
|
+
if not result_path.exists():
|
|
473
|
+
return None
|
|
474
|
+
try:
|
|
475
|
+
with result_path.open(encoding="utf-8") as f:
|
|
476
|
+
payload = json.load(f)
|
|
477
|
+
if not payload.get("success", True):
|
|
478
|
+
return payload.get("error") or NO_REASON
|
|
479
|
+
except Exception:
|
|
480
|
+
return None
|
|
481
|
+
return None
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
__all__ = ["JsonDriverBackend", "resolve_source_file"]
|