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
@@ -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"]