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,776 @@
1
+ """Dymola-specific backend with batch execution."""
2
+
3
+ import logging
4
+ import math
5
+ import subprocess
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from ...config import Config
11
+ from ...discovery.library_state import dependency_file
12
+ from ...discovery.test_registry import TestModel
13
+ from ...storage.batch_manifest import BatchManifest
14
+ from .. import register
15
+ from ..base import Backend, _print_run_header
16
+ from ..common import result_files
17
+ from ..common.mat_reader import read_result_mat
18
+ from ..common.proc_kill import kill_process_tree
19
+ from ..common.result_variables import (
20
+ compute_needed_variables,
21
+ extract_variables,
22
+ fold_diagnostics_into_statistics,
23
+ unfinished_result,
24
+ )
25
+ from ..types import (
26
+ Capability,
27
+ DatasetType,
28
+ TestResult,
29
+ TestRunResult,
30
+ timeout_message,
31
+ )
32
+ from .log_parser import parse_test_logs
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class DymolaConfig:
39
+ """Dymola-specific settings extracted from the universal Config."""
40
+
41
+ show_ide: bool = False
42
+ backend_setup: list[str] = field(default_factory=list)
43
+ diagnostic_variables: list[str] = field(
44
+ default_factory=lambda: ["CPUtime", "EventCounter"],
45
+ )
46
+
47
+ @classmethod
48
+ def from_config(cls, config: Config) -> "DymolaConfig":
49
+ """Extract Dymola-specific fields from the universal Config."""
50
+ return cls(
51
+ show_ide=config.show_ide,
52
+ backend_setup=list(config.backend_setup),
53
+ diagnostic_variables=list(config.diagnostic_variables),
54
+ )
55
+
56
+
57
+ @register("Dymola")
58
+ class DymolaBackend(Backend):
59
+ """Runs Modelica simulations using Dymola with batch execution.
60
+
61
+ Instead of launching a separate Dymola process per test, tests are
62
+ grouped into batches. Each batch runs in a single Dymola session:
63
+ load libraries once, run N tests, exit. This dramatically reduces
64
+ startup overhead for large test suites.
65
+ """
66
+
67
+ # NOTE: no Capability.FMU_EXPORT here. Dymola *can* export FMUs, but only
68
+ # through the DymolaInterface — see PersistentDymolaBackend, which declares
69
+ # the flag and implements export_fmu(). Batch mode would need a
70
+ # `translateModelFMU(...)` .mos script + `dymola -nowindow` spawn + FMU
71
+ # locator; nobody has needed it (persistent is the default path), so
72
+ # rather than declare a capability we don't have, we don't declare it and
73
+ # the cross-backend chain skips batch-mode Dymola with a clear message.
74
+ capabilities = frozenset(
75
+ {
76
+ Capability.PERSISTENT_WORKERS, # DymolaInterface-based workers (default)
77
+ Capability.BATCH_FALLBACK, # .mos script backend (--batch flag)
78
+ Capability.LIVE_RESULTS, # dymosim appends dsres.mat as it goes
79
+ }
80
+ )
81
+ produced_datasets = frozenset({DatasetType.TIME_SERIES})
82
+ artifact_files = (
83
+ ("dslog.txt", "Simulation log"),
84
+ ("translation_log.txt", "Translation log"),
85
+ ("dsin.txt", "Simulation input"),
86
+ ("dsfinal.txt", "Final values"),
87
+ ("simulate.mos", "Simulation script"),
88
+ ("dsres.mat", "Result file"),
89
+ )
90
+
91
+ def __init__(self, config: Config):
92
+ super().__init__(config)
93
+ self.dymola_config = DymolaConfig.from_config(config)
94
+
95
+ @classmethod
96
+ def persistent_backend_cls(cls):
97
+ from .persistent_backend import PersistentDymolaBackend
98
+
99
+ return PersistentDymolaBackend
100
+
101
+ def run_tests(self, tests: list[TestModel]) -> list[BatchManifest]:
102
+ """Run tests in batches with parallelism."""
103
+ if not tests:
104
+ return []
105
+
106
+ setup = self._prepare_run(tests)
107
+ total = setup.total
108
+ manifest = setup.manifest
109
+ test_items = setup.keyed_tests
110
+
111
+ # Clean and recreate per-test directories ONLY for tests being run
112
+ # this batch (preserves prior dirs for tests not in this run).
113
+ # Note (D91): cmd_run.cli now centrally wipes in-scope test_dirs
114
+ # before backend.run_tests; this in-backend wipe is a defensive
115
+ # duplicate that protects against future callers of run_tests
116
+ # outside cmd_run (e.g., direct test harness calls). Cheap (rmtree
117
+ # on empty) and self-documenting at the point freshness matters.
118
+ import shutil
119
+
120
+ for test, test_key in test_items:
121
+ test_dir = self.config.work_dir / test_key
122
+ if test_dir.exists():
123
+ shutil.rmtree(test_dir)
124
+ test_dir.mkdir(parents=True, exist_ok=True)
125
+ _generate_test_mos(test, test_key, test_dir)
126
+
127
+ # Generate shared startup and shutdown scripts
128
+ startup_path = _generate_startup_mos(self.config, self.dymola_config)
129
+ shutdown_path = _generate_shutdown_mos(self.config)
130
+
131
+ # Split tests into batches for parallel workers.
132
+ # If batch_size is set, chunks are that size (many small batches, queue-dispatched
133
+ # to workers — better load balancing, smaller crash blast radius).
134
+ # Otherwise use one big batch per worker (original behavior, minimizes library reloads).
135
+ n_workers = max(1, self.config.parallel)
136
+ if self.config.batch_size and self.config.batch_size > 0:
137
+ batch_size = self.config.batch_size
138
+ else:
139
+ batch_size = math.ceil(total / n_workers)
140
+ batches = []
141
+ for i in range(0, total, batch_size):
142
+ batches.append(test_items[i : i + batch_size])
143
+
144
+ _print_run_header(
145
+ total,
146
+ f"in {len(batches)} batch(es) of up to {batch_size}",
147
+ n_workers,
148
+ self.config.timeout,
149
+ self.config.work_dir,
150
+ timeout_per_test=True,
151
+ metadata=setup.run_metadata,
152
+ quiet=self.quiet,
153
+ )
154
+
155
+ # Generate and run batch scripts
156
+ all_results: list[TestRunResult] = []
157
+ wall_start = time.monotonic()
158
+
159
+ # startup.mos reported the real Dymola version; publish it into the
160
+ # run provenance as soon as a batch has come back (the persistent
161
+ # lane does the equivalent via _probe_worker_version). Idempotent —
162
+ # only the first batch that yields a version stamps it.
163
+ stamped = [False]
164
+
165
+ def _stamp_tool_version() -> None:
166
+ if stamped[0]:
167
+ return
168
+ version = _read_startup_tool_version(self.config.work_dir)
169
+ if version:
170
+ stamped[0] = True
171
+ setup.progress.update_metadata(tool_version=version)
172
+
173
+ def _land(results: list[TestRunResult]) -> None:
174
+ """Take delivery of one batch's results.
175
+
176
+ Persisting each verdict here is what gives this lane the same
177
+ abort-proof run record the default loop and the persistent
178
+ dispatcher have: ``_prepare_run`` stamped every in-scope entry
179
+ ``pending``, and only ``record_result`` clears it. Without this
180
+ a completed ``run --batch`` left the whole scope ``pending`` and
181
+ the manifest-wins protection never materialized for it.
182
+ """
183
+ all_results.extend(results)
184
+ for r in results:
185
+ manifest.record_result(r)
186
+ _stamp_tool_version()
187
+
188
+ if n_workers <= 1:
189
+ # Sequential: run all batches in order on a single worker
190
+ offset = 0
191
+ for batch in batches:
192
+ results = self._run_batch(
193
+ batch,
194
+ startup_path,
195
+ shutdown_path,
196
+ offset,
197
+ total,
198
+ worker_id=0,
199
+ )
200
+ _land(results)
201
+ offset += len(batch)
202
+ else:
203
+ # Parallel: submit every batch to the pool; workers pull next
204
+ # batch as they become free (natural load balancing + crash isolation).
205
+ # worker_id attribution comes from the thread name so the dashboard can
206
+ # group tests by actual worker slot rather than batch index.
207
+ import re
208
+ import threading
209
+ from concurrent.futures import ThreadPoolExecutor, as_completed
210
+
211
+ def _worker_slot() -> int:
212
+ m = re.search(r"(\d+)$", threading.current_thread().name)
213
+ return int(m.group(1)) if m else 0
214
+
215
+ def _run(batch, offset):
216
+ return self._run_batch(
217
+ batch,
218
+ startup_path,
219
+ shutdown_path,
220
+ offset,
221
+ total,
222
+ worker_id=_worker_slot(),
223
+ )
224
+
225
+ offset = 0
226
+ futures = {}
227
+ with ThreadPoolExecutor(
228
+ max_workers=n_workers, thread_name_prefix="dym-worker"
229
+ ) as pool:
230
+ for batch in batches:
231
+ future = pool.submit(_run, batch, offset)
232
+ futures[future] = offset
233
+ offset += len(batch)
234
+
235
+ for future in as_completed(futures):
236
+ _land(future.result())
237
+
238
+ self._print_run_summary(all_results, total, time.monotonic() - wall_start)
239
+
240
+ manifest.results = all_results
241
+ setup.progress.finalize()
242
+ return [manifest]
243
+
244
+ def _run_batch(
245
+ self,
246
+ test_items: list[tuple[TestModel, str]],
247
+ startup_path: Path,
248
+ shutdown_path: Path,
249
+ index_offset: int,
250
+ total: int,
251
+ worker_id: int | None = None,
252
+ ) -> list[TestRunResult]:
253
+ """Run a batch of tests in a single Dymola session."""
254
+ work_dir = self.config.work_dir
255
+ batch_id = index_offset # Use offset as unique batch ID
256
+
257
+ # Emit start events for all tests in this batch — we can't observe
258
+ # individual test transitions within a batched Dymola session, but the
259
+ # user at least knows which tests are currently in flight.
260
+ if self.progress is not None:
261
+ for _, test_key in test_items:
262
+ self.progress.on_start(test_key, worker_id=worker_id)
263
+
264
+ # Generate the batch script
265
+ batch_script = _generate_batch_mos(
266
+ test_items, startup_path, shutdown_path, work_dir, batch_id
267
+ )
268
+
269
+ # Calculate total timeout: per-test timeout * number of tests + startup overhead
270
+ startup_overhead = 120 # seconds for library loading
271
+ total_timeout = (
272
+ sum(
273
+ t.timeout if t.timeout is not None else self.config.timeout
274
+ for t, _ in test_items
275
+ )
276
+ + startup_overhead
277
+ )
278
+
279
+ # Run the batch
280
+ start_time = time.monotonic()
281
+
282
+ try:
283
+ cmd = [self.config.backend_path]
284
+ if not self.dymola_config.show_ide:
285
+ cmd.append("-nowindow")
286
+ cmd.append(str(batch_script))
287
+
288
+ proc = subprocess.Popen(
289
+ cmd,
290
+ stdout=subprocess.PIPE,
291
+ stderr=subprocess.PIPE,
292
+ cwd=str(work_dir),
293
+ )
294
+ stdout, stderr = proc.communicate(timeout=total_timeout)
295
+ batch_elapsed = time.monotonic() - start_time
296
+
297
+ except subprocess.TimeoutExpired:
298
+ batch_elapsed = time.monotonic() - start_time
299
+ # review 2026-07-06 (finding 22): dymola.exe's dymosim child
300
+ # inherits the pipes, so a bare kill()+communicate() blocks until
301
+ # dymosim exits — the timeout path itself hung. Kill the whole
302
+ # process tree first, then drain pipes with a bounded wait.
303
+ kill_process_tree(proc.pid, name_hint="dymola")
304
+ if proc.poll() is None:
305
+ # The tree kill is a no-op when psutil is missing, the name
306
+ # guard misses (a wrapper script's process name is not
307
+ # "dymola") or the walk hits AccessDenied. We own this
308
+ # Popen — kill it directly rather than fall into the
309
+ # blocking communicate() finding 22 documents.
310
+ try:
311
+ proc.kill()
312
+ except (ProcessLookupError, OSError):
313
+ pass
314
+ try:
315
+ proc.communicate(timeout=10)
316
+ except (subprocess.TimeoutExpired, OSError, ValueError):
317
+ # Last resort: close pipes without blocking on stragglers.
318
+ for stream in (proc.stdout, proc.stderr):
319
+ if stream is not None:
320
+ try:
321
+ stream.close()
322
+ except OSError:
323
+ pass
324
+ # review 2026-07-06 (finding 76): salvage tests that verifiably
325
+ # completed on disk before the batch deadline (Dymola runs tests
326
+ # sequentially — earlier tests in the batch usually finished);
327
+ # only tests without a readable, complete result are timed out.
328
+ results = []
329
+ per_elapsed = batch_elapsed / len(test_items)
330
+ for test, test_key in test_items:
331
+ test_dir = work_dir / test_key
332
+ completed, _why = _sim_completed_on_disk(
333
+ test_dir, float(test.stop_time)
334
+ )
335
+ if completed:
336
+ results.append(
337
+ TestRunResult(
338
+ model_id=test.model_id,
339
+ test_key=test_key,
340
+ success=True,
341
+ elapsed=per_elapsed,
342
+ statistics=parse_test_logs(test_dir),
343
+ )
344
+ )
345
+ if self.progress is not None:
346
+ self.progress.on_finish(
347
+ test_key,
348
+ success=True,
349
+ elapsed=per_elapsed,
350
+ )
351
+ continue
352
+ results.append(
353
+ TestRunResult(
354
+ model_id=test.model_id,
355
+ test_key=test_key,
356
+ success=False,
357
+ elapsed=per_elapsed,
358
+ error_message=timeout_message(
359
+ f"Batch timed out after {total_timeout}s"
360
+ ),
361
+ timed_out=True,
362
+ )
363
+ )
364
+ if self.progress is not None:
365
+ self.progress.on_finish(
366
+ test_key,
367
+ success=False,
368
+ elapsed=per_elapsed,
369
+ detail="Batch timed out",
370
+ timed_out=True,
371
+ )
372
+ return results
373
+
374
+ except FileNotFoundError:
375
+ results = []
376
+ for test, test_key in test_items:
377
+ results.append(
378
+ TestRunResult(
379
+ model_id=test.model_id,
380
+ test_key=test_key,
381
+ success=False,
382
+ elapsed=0.0,
383
+ error_message=f"Dymola not found: {self.config.backend_path}",
384
+ )
385
+ )
386
+ if self.progress is not None:
387
+ self.progress.on_finish(
388
+ test_key,
389
+ success=False,
390
+ elapsed=0.0,
391
+ detail="Dymola not found",
392
+ )
393
+ return results
394
+
395
+ # Evaluate results per test: check if .mat file was produced
396
+ results = []
397
+ from ..base import _print_progress
398
+
399
+ for i, (test, test_key) in enumerate(test_items):
400
+ test_dir = work_dir / test_key
401
+ mat_path = test_dir / "dsres.mat"
402
+ short_name = test.model_id.rsplit(".", 1)[-1]
403
+ label = f"{test_key} {short_name}"
404
+
405
+ # Parse both dslog.txt (simulation runtime) and translation_log.txt (structural)
406
+ statistics = parse_test_logs(test_dir)
407
+
408
+ # Check for translation failure (defense in depth)
409
+ translation_failed = False
410
+ translation_log = test_dir / "translation_log.txt"
411
+ if translation_log.exists():
412
+ try:
413
+ tlog = translation_log.read_text(encoding="utf-8", errors="replace")
414
+ if "Translation aborted" in tlog or tlog.strip().endswith(
415
+ "= false"
416
+ ):
417
+ translation_failed = True
418
+ except OSError:
419
+ pass
420
+
421
+ # A simulation truly completed only if BOTH:
422
+ # 1. dsfinal.txt exists (Dymola writes it at end of successful sim)
423
+ # 2. mat file's last time reaches the requested stop_time
424
+ # dsres.mat existence alone is insufficient — Dymola writes
425
+ # incrementally, so a killed/aborted sim can leave a partial file.
426
+ # Shared with the batch-timeout salvage path (finding 76).
427
+ dsfinal_path = test_dir / "dsfinal.txt"
428
+ sim_completed = False
429
+ completion_msg: str | None = None
430
+ if not translation_failed:
431
+ sim_completed, completion_msg = _sim_completed_on_disk(
432
+ test_dir, float(test.stop_time)
433
+ )
434
+
435
+ per_elapsed = batch_elapsed / len(test_items)
436
+ if sim_completed:
437
+ _print_progress(
438
+ index_offset + i + 1,
439
+ total,
440
+ label,
441
+ "ok",
442
+ elapsed=per_elapsed,
443
+ quiet=self.quiet,
444
+ )
445
+ results.append(
446
+ TestRunResult(
447
+ model_id=test.model_id,
448
+ test_key=test_key,
449
+ success=True,
450
+ elapsed=per_elapsed,
451
+ statistics=statistics,
452
+ )
453
+ )
454
+ if self.progress is not None:
455
+ self.progress.on_finish(
456
+ test_key,
457
+ success=True,
458
+ elapsed=per_elapsed,
459
+ )
460
+ else:
461
+ if translation_failed:
462
+ msg = "Translation failed"
463
+ elif not mat_path.exists():
464
+ msg = "No result file produced"
465
+ elif not dsfinal_path.exists():
466
+ msg = "Simulation aborted (no dsfinal.txt)"
467
+ else:
468
+ msg = completion_msg or "Simulation incomplete"
469
+ # Try to get error from dslog
470
+ dslog_path = test_dir / "dslog.txt"
471
+ if dslog_path.exists():
472
+ try:
473
+ log_text = dslog_path.read_text(
474
+ encoding="utf-8", errors="replace"
475
+ )
476
+ if "ERROR" in log_text or "error" in log_text:
477
+ lines = log_text.strip().split("\n")
478
+ msg = msg + " | " + " | ".join(lines[-3:])
479
+ except OSError:
480
+ pass
481
+
482
+ _print_progress(
483
+ index_offset + i + 1,
484
+ total,
485
+ label,
486
+ "FAIL",
487
+ elapsed=per_elapsed,
488
+ detail=msg[:80],
489
+ quiet=self.quiet,
490
+ )
491
+ results.append(
492
+ TestRunResult(
493
+ model_id=test.model_id,
494
+ test_key=test_key,
495
+ success=False,
496
+ elapsed=per_elapsed,
497
+ error_message=msg,
498
+ statistics=statistics,
499
+ )
500
+ )
501
+ if self.progress is not None:
502
+ self.progress.on_finish(
503
+ test_key,
504
+ success=False,
505
+ elapsed=per_elapsed,
506
+ detail=msg[:120],
507
+ )
508
+
509
+ return results
510
+
511
+ def read_result(
512
+ self,
513
+ test: TestModel,
514
+ test_key: str,
515
+ run_result: TestRunResult | None,
516
+ ) -> TestResult:
517
+ test_dir = self.config.work_dir / test_key
518
+ # The run's own statistics (in process, or kept in run_stats.json)
519
+ # win. Without them the logs Dymola left beside the result are read:
520
+ # code that reads files tolerates a result written before
521
+ # run_stats.json existed (D138), and such a result still has both.
522
+ # They hold no phase walls, so nothing here invents a timing block.
523
+ stats = (
524
+ dict(run_result.statistics)
525
+ if run_result and run_result.statistics
526
+ else parse_test_logs(test_dir)
527
+ )
528
+ mat_path = test_dir / "dsres.mat"
529
+
530
+ if not mat_path.exists():
531
+ return TestResult(
532
+ model_id=test.model_id,
533
+ success=False,
534
+ error_message=f"Result file not found: {mat_path}",
535
+ statistics=stats,
536
+ )
537
+
538
+ # Phase 1: Get variable names (fast — reads only the name matrix)
539
+ # Then resolve patterns to determine which variables we actually need.
540
+ needed_vars = compute_needed_variables(
541
+ mat_path,
542
+ test,
543
+ self.dymola_config.diagnostic_variables,
544
+ )
545
+
546
+ # Phase 2: Load only needed variables from the .mat file
547
+ mat_data = read_result_mat(mat_path, variable_names=needed_vars)
548
+ if mat_data is None:
549
+ return TestResult(
550
+ model_id=test.model_id,
551
+ success=False,
552
+ error_message=f"Failed to parse: {mat_path}",
553
+ statistics=stats,
554
+ )
555
+
556
+ variables, diagnostics = extract_variables(
557
+ mat_data,
558
+ test,
559
+ self.dymola_config.diagnostic_variables,
560
+ )
561
+ stats = fold_diagnostics_into_statistics(stats, diagnostics)
562
+
563
+ unfinished = unfinished_result(test, mat_path, variables, diagnostics, stats)
564
+ if unfinished is not None:
565
+ return unfinished
566
+
567
+ return TestResult(
568
+ model_id=test.model_id,
569
+ success=True,
570
+ variables=variables,
571
+ diagnostics=diagnostics,
572
+ statistics=stats,
573
+ )
574
+
575
+ @classmethod
576
+ def result_names(cls, test_dir: Path) -> list[str] | None:
577
+ return result_files.mat_names(test_dir / "dsres.mat")
578
+
579
+ @classmethod
580
+ def result_series(cls, test_dir, names):
581
+ return result_files.mat_series(test_dir / "dsres.mat", names)
582
+
583
+ @classmethod
584
+ def live_result_path(cls, test_dir: Path) -> Path:
585
+ return test_dir / "dsres.mat"
586
+
587
+
588
+ # ---------------------------------------------------------------------------
589
+ # .mos script generation
590
+ # ---------------------------------------------------------------------------
591
+
592
+
593
+ #: startup.mos writes ``DymolaVersion()`` here. The batch lane has no live
594
+ #: session to ask for a version (that is what the persistent lane's
595
+ #: ``_probe_worker_version`` is for), and launching a Dymola purely to probe
596
+ #: would cost a licence seat and tens of seconds — so the script that is
597
+ #: already being run does the reporting, and the backend reads the file back
598
+ #: once a batch has returned. No file (MSL not loaded, Dymola never started)
599
+ #: leaves ``tool_version`` null, exactly as before.
600
+ TOOL_VERSION_FILENAME = "dymola_version.txt"
601
+
602
+
603
+ def _read_startup_tool_version(work_dir: Path) -> str | None:
604
+ """First non-empty line of the version file startup.mos wrote, if any.
605
+
606
+ Every worker's startup appends the same string, so the first line is the
607
+ answer; a missing or unreadable file means "no honest version available".
608
+ """
609
+ try:
610
+ text = (work_dir / TOOL_VERSION_FILENAME).read_text(
611
+ encoding="utf-8", errors="replace"
612
+ )
613
+ except OSError:
614
+ return None
615
+ for line in text.splitlines():
616
+ if line.strip():
617
+ return line.strip()
618
+ return None
619
+
620
+
621
+ def _generate_startup_mos(config: Config, dymola_config: DymolaConfig) -> Path:
622
+ """Generate startup.mos: load dependencies, main library, and setup commands."""
623
+ work_dir = config.work_dir
624
+ # Drop any version file a previous run in this work dir left behind — a
625
+ # stale line would be read back as THIS run's provenance.
626
+ (work_dir / TOOL_VERSION_FILENAME).unlink(missing_ok=True)
627
+ lines = [
628
+ "// Startup: load libraries and configure environment",
629
+ f'cd("{work_dir.as_posix()}");',
630
+ ]
631
+
632
+ # Load dependencies first
633
+ for dep_path in config.dependencies:
634
+ dep_pkg = dependency_file(dep_path)
635
+ lines.append(f'openModel("{dep_pkg.as_posix()}");')
636
+
637
+ # Load main library
638
+ lines.append(f'openModel("{(config.library_dir / "package.mo").as_posix()}");')
639
+
640
+ # Dymola-specific framework settings (always enabled)
641
+ lines.append("")
642
+ lines.append("// Framework settings")
643
+ lines.append("OutputCPUtime := true;")
644
+ lines.append("Advanced.UI.TranslationInCommandLog := true;")
645
+
646
+ # Run provenance: report the actual tool version to a file the backend
647
+ # reads back (see TOOL_VERSION_FILENAME). Cheap — it rides along in a
648
+ # script that has to run anyway.
649
+ version_file = (work_dir / TOOL_VERSION_FILENAME).as_posix()
650
+ lines.append("")
651
+ lines.append("// Run provenance (read back by the backend)")
652
+ lines.append(
653
+ f'Modelica.Utilities.Streams.print(DymolaVersion(), "{version_file}");'
654
+ )
655
+
656
+ # Backend setup commands (e.g., OutputCPUtime = true)
657
+ if dymola_config.backend_setup:
658
+ lines.append("")
659
+ lines.append("// Backend setup")
660
+ for cmd in dymola_config.backend_setup:
661
+ # Ensure command ends with semicolon
662
+ cmd = cmd.strip()
663
+ if not cmd.endswith(";"):
664
+ cmd += ";"
665
+ lines.append(cmd)
666
+
667
+ script_path = work_dir / "startup.mos"
668
+ script_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
669
+ return script_path
670
+
671
+
672
+ def _generate_shutdown_mos(config: Config) -> Path:
673
+ """Generate shutdown.mos: exit Dymola."""
674
+ lines = [
675
+ "// Shutdown",
676
+ "Modelica.Utilities.System.exit();",
677
+ ]
678
+ script_path = config.work_dir / "shutdown.mos"
679
+ script_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
680
+ return script_path
681
+
682
+
683
+ def _generate_test_mos(
684
+ test: TestModel,
685
+ test_key: str,
686
+ test_dir: Path,
687
+ ) -> Path:
688
+ """Generate a per-test .mos script: cd to test dir and simulate."""
689
+ parts = [f'"{test.model_id}"']
690
+
691
+ if test.stop_time != 1.0:
692
+ if test.stop_time == int(test.stop_time):
693
+ parts.append(f"stopTime={int(test.stop_time)}")
694
+ else:
695
+ parts.append(f"stopTime={test.stop_time}")
696
+
697
+ # numberOfIntervals takes precedence over outputInterval if both are set
698
+ if test.number_of_intervals is not None:
699
+ parts.append(f"numberOfIntervals={test.number_of_intervals}")
700
+ elif test.output_interval is not None:
701
+ parts.append(f"outputInterval={test.output_interval}")
702
+
703
+ parts.append(f'method="{test.method}"')
704
+ parts.append(f"tolerance={test.tolerance}")
705
+ parts.append('resultFile="dsres"')
706
+
707
+ lines = [
708
+ f"// {test.model_id}",
709
+ f'cd("{test_dir.as_posix()}");',
710
+ "clearlog();",
711
+ f"simulateModel({','.join(parts)});",
712
+ 'savelog("translation_log.txt");',
713
+ ]
714
+
715
+ script_path = test_dir / "simulate.mos"
716
+ script_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
717
+ return script_path
718
+
719
+
720
+ def _generate_batch_mos(
721
+ test_items: list[tuple[TestModel, str]],
722
+ startup_path: Path,
723
+ shutdown_path: Path,
724
+ work_dir: Path,
725
+ batch_id: int,
726
+ ) -> Path:
727
+ """Generate a batch .mos script that runs startup, all tests, then shutdown."""
728
+ lines = [
729
+ f"// Batch {batch_id}: {len(test_items)} tests",
730
+ f'RunScript("{startup_path.as_posix()}");',
731
+ ]
732
+
733
+ for _test, test_key in test_items:
734
+ test_mos = work_dir / test_key / "simulate.mos"
735
+ lines.append(f'RunScript("{test_mos.as_posix()}");')
736
+
737
+ lines.append(f'RunScript("{shutdown_path.as_posix()}");')
738
+
739
+ script_path = work_dir / f"batch_{batch_id:04d}.mos"
740
+ script_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
741
+ return script_path
742
+
743
+
744
+ # ---------------------------------------------------------------------------
745
+ # Process / on-disk state helpers
746
+ # ---------------------------------------------------------------------------
747
+
748
+
749
+ def _sim_completed_on_disk(
750
+ test_dir: Path,
751
+ stop_time: float,
752
+ ) -> tuple[bool, str | None]:
753
+ """Check on-disk artifacts for a truly completed simulation.
754
+
755
+ Completed iff dsfinal.txt exists AND dsres.mat's last time reaches
756
+ stop_time (dsres.mat alone is insufficient — Dymola writes incrementally,
757
+ so a killed sim leaves a partial file). Returns ``(completed, why_not)``;
758
+ ``why_not`` is only set when artifacts exist but are short/unreadable.
759
+ Used by both the normal batch evaluation and the batch-timeout salvage
760
+ path (review 2026-07-06, finding 76).
761
+ """
762
+ from ..common.mat_reader import read_mat_time_extents
763
+
764
+ mat_path = test_dir / "dsres.mat"
765
+ dsfinal_path = test_dir / "dsfinal.txt"
766
+ if not mat_path.exists() or not dsfinal_path.exists():
767
+ return False, None
768
+ extents = read_mat_time_extents(mat_path)
769
+ if extents is None:
770
+ return False, "dsres.mat unreadable"
771
+ last_time = extents[1]
772
+ # Allow a tiny tolerance — output_interval may not align perfectly
773
+ tol = max(1e-6, abs(stop_time) * 1e-6)
774
+ if last_time + tol >= stop_time:
775
+ return True, None
776
+ return False, f"Stopped early at T={last_time:.6g} of {stop_time:.6g}"