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,949 @@
1
+ """Persistent-worker Dymola backend using the Python interface.
2
+
3
+ Unlike the default batched backend (which launches Dymola per batch and drives
4
+ it via generated `.mos` scripts), this backend keeps N long-lived
5
+ `DymolaInterface` processes alive for the whole run. Each worker loads the
6
+ library once and then pulls tests off a shared queue.
7
+
8
+ Benefits:
9
+ - Library-load overhead paid once per worker, never again
10
+ - Per-test granularity: start/finish events fire as each test transitions,
11
+ no "batch jumps together" visibility gap
12
+ - Natural work-stealing: whichever worker finishes first grabs the next test
13
+ - Per-test timeout isolation: a hung test kills one worker (we restart it)
14
+ while the others keep running
15
+
16
+ This is the default backend; pass `--batch` to opt out and use the `.mos`
17
+ batched backend instead. There is no automatic fallback: if the Dymola
18
+ Python interface can't be imported, `preflight` raises and the CLI aborts
19
+ with a message naming both fixes (see `backends.select_backend`).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import contextlib
25
+ import logging
26
+ import shutil
27
+ import threading
28
+ import time
29
+ from pathlib import Path
30
+
31
+ from ...config import Config
32
+ from ...discovery.library_state import (
33
+ LibraryChanges,
34
+ dependency_file,
35
+ dependency_root,
36
+ under,
37
+ )
38
+ from ...discovery.test_registry import TestModel
39
+ from ..common.mat_reader import read_mat_time_extents
40
+ from ..common.proc_kill import live_children
41
+ from ..persistent import LibraryReloadError, PersistentBackendBase, Worker
42
+ from ..types import Capability, TestRunResult
43
+ from .backend import DymolaBackend, DymolaConfig
44
+ from .interface_loader import load_dymola_interface
45
+ from .log_parser import parse_test_logs
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Patches of Dymola's Python interface
52
+ # ---------------------------------------------------------------------------
53
+
54
+ _parallel_startup_patched = False
55
+
56
+
57
+ def _patch_dymola_for_parallel_startup() -> None:
58
+ """Narrow Dymola's broad startup lock so workers can launch in parallel.
59
+
60
+ `dymola.dymola_interface_internal.dymola_lock` is held for the entire
61
+ `DymolaInterface.__init__`, which includes `_check_dymola` — an HTTP
62
+ ping retry loop that waits for the just-spawned Dymola to come up
63
+ (~7s per worker). With this lock in place, N workers all serialize on
64
+ startup, so 10 workers take 10×7 = 70s instead of ~7s.
65
+
66
+ The genuinely shared state is just port selection (otherwise two workers
67
+ could pick the same random port and collide). We narrow the critical
68
+ section to just `_find_available_port` and replace the broad lock with
69
+ a no-op so the slow per-worker waits overlap.
70
+ """
71
+ global _parallel_startup_patched
72
+ if _parallel_startup_patched:
73
+ return
74
+ try:
75
+ import dymola.dymola_interface_internal as _di # type: ignore[import-not-found]
76
+ except ImportError:
77
+ return
78
+
79
+ _port_lock = threading.Lock()
80
+ _orig_find_port = _di.DymolaInterfaceInternal._find_available_port
81
+
82
+ def _safe_find_port(self):
83
+ with _port_lock:
84
+ return _orig_find_port(self)
85
+
86
+ _di.DymolaInterfaceInternal._find_available_port = _safe_find_port # type: ignore[method-assign]
87
+
88
+ class _NoLock:
89
+ def acquire(self, blocking=True, timeout=-1):
90
+ return True
91
+
92
+ def release(self):
93
+ pass
94
+
95
+ def __enter__(self):
96
+ return self
97
+
98
+ def __exit__(self, exc_type, exc, tb):
99
+ return False
100
+
101
+ _di.dymola_lock = _NoLock() # type: ignore[attr-defined]
102
+ _parallel_startup_patched = True
103
+
104
+
105
+ #: The interface whose request the current thread is inside. Dymola's logger
106
+ #: is static — a message does not say which Dymola it is about — so the
107
+ #: request method notes it here for the logger's filter.
108
+ _requesting = threading.local()
109
+ _dymola_logger_patched = False
110
+
111
+
112
+ def _install_dymola_log_filter() -> None:
113
+ """Monkey-patch Dymola's interface so the connection noise of a Dymola
114
+ THIS process closed is not printed.
115
+
116
+ The interface retries a request ten times, a second apart, and prints
117
+ every failed attempt. A thread still inside a call when its Dymola is
118
+ killed (a timeout, a cancel during translation, the pool closing over a
119
+ running test) prints "Remote end closed connection without response",
120
+ then a refused connection per second — and goes on to its next call,
121
+ which does the same. Measured on Dymola 2026x after a cancel during
122
+ translation: 10 lines, the thread retrying for 19.5 s after the close.
123
+
124
+ Muted by a window of seconds after a close, the first line (not among
125
+ the patterns) and every line past the window were printed, and for its
126
+ length the same lines of every OTHER worker's Dymola were not — where a
127
+ lost connection is a finding. So the mute is by interface and for good:
128
+ a worker's ``_quiet`` marks the one it is closing (:func:`_closing`),
129
+ ``_make_rpc_call`` notes whose request the thread is inside, and the
130
+ logger drops a connection error — an ``OSError``, as the request loop
131
+ logs it; nothing else — raised inside a marked one.
132
+
133
+ Dymola's ``DymolaLogger._PrintMessage`` uses ``print(msg)``, so the
134
+ lines go to stdout: patching the logger is cleaner than filtering a
135
+ stream.
136
+ """
137
+ global _dymola_logger_patched
138
+ if _dymola_logger_patched:
139
+ return
140
+ try:
141
+ import dymola.dymola_interface_internal as _di # type: ignore[import-not-found]
142
+ except ImportError:
143
+ return # interface not loaded yet; will be retried on next call
144
+ rpc = _di.DymolaInterfaceInternal._make_rpc_call
145
+ orig = _di.DymolaLogger._PrintMessage
146
+
147
+ def _noted_request(self, *args, **kwargs):
148
+ _requesting.interface = self
149
+ try:
150
+ return rpc(self, *args, **kwargs)
151
+ finally:
152
+ _requesting.interface = None
153
+
154
+ def _filtered_print(level, msg):
155
+ inside = getattr(_requesting, "interface", None)
156
+ if isinstance(msg, OSError) and getattr(inside, "_dstf_closing", False):
157
+ return
158
+ orig(level, msg)
159
+
160
+ _di.DymolaInterfaceInternal._make_rpc_call = _noted_request # type: ignore[method-assign]
161
+ _di.DymolaLogger._PrintMessage = staticmethod(_filtered_print) # type: ignore[method-assign]
162
+ _dymola_logger_patched = True
163
+
164
+
165
+ @contextlib.contextmanager
166
+ def _closing(interface):
167
+ """A worker's teardown of *interface*: from here on what it prints about
168
+ the connection it loses is this process's doing
169
+ (:func:`_install_dymola_log_filter`). The mark stays — a closed
170
+ interface is never used again, and the thread abandoned inside it may
171
+ retry for 20 s more. Nothing the teardown raises is caught here."""
172
+ _install_dymola_log_filter()
173
+ if interface is not None:
174
+ interface._dstf_closing = True
175
+ yield
176
+
177
+
178
+ #: The simulation executable Dymola builds and runs for a test, by PROCESS
179
+ #: NAME. Never matched as a substring of a command line: the compiler that
180
+ #: builds it is handed a ``dymosim`` argument too.
181
+ _DYMOSIM_NAMES = ("dymosim", "dymosim.exe")
182
+
183
+
184
+ def _classify_translation_error(log_path: Path) -> str:
185
+ """Best-effort classification of a Dymola translation failure.
186
+
187
+ The full ``translation_log.txt`` is preserved as a per-test artifact;
188
+ this helper extracts the most actionable summary line for the
189
+ dashboard / per-test-report ``error_message`` field. Falls back to
190
+ the generic "Translation failed" for unrecognized patterns. Pattern
191
+ list grows organically — when a TRANSFORM run on a new platform
192
+ surfaces a recurring failure category, add a clause here so users
193
+ don't have to crack open the log file just to read the headline.
194
+ """
195
+ try:
196
+ text = log_path.read_text(encoding="utf-8", errors="replace")
197
+ except OSError:
198
+ return "Translation failed"
199
+ if "model is too complex for the current license" in text:
200
+ return "Translation failed: license tier too small for this model"
201
+ lower = text.lower()
202
+ if "class not found" in lower or "could not find class" in lower:
203
+ return "Translation failed: missing class / undeclared dependency"
204
+ return "Translation failed"
205
+
206
+
207
+ def _open_model_step(package_file: Path) -> str:
208
+ """The step a worker is in while Dymola opens *package_file*, as the
209
+ queue panel and a start's failure say it: the call with its path
210
+ QUOTED, as OpenModelica's ``loadFile("…")`` is — the one form the
211
+ panel cuts to the path's last part (``queue_rows.starting_text``).
212
+ Unquoted, the whole path stood in the panel's line."""
213
+ return f'openModel("{package_file.as_posix()}")'
214
+
215
+
216
+ class DymolaWorker(Worker):
217
+ """One live Dymola process wrapped as a test worker."""
218
+
219
+ kill_name_hint = "dymola"
220
+ exec_thread_prefix = "dym"
221
+ # Give Dymola a real chance to flush its open dslog.txt handle before
222
+ # psutil escalates to SIGKILL — diagnostic loss on timeout was a real
223
+ # reported pain point on TRANSFORM/Linux.
224
+ timeout_kill_grace = 5.0
225
+
226
+ def __init__(
227
+ self,
228
+ worker_id: int,
229
+ config: Config,
230
+ dymola_config: DymolaConfig,
231
+ dymola_interface_cls,
232
+ ):
233
+ super().__init__(worker_id, config)
234
+ self.dymola_config = dymola_config
235
+ self._DI = dymola_interface_cls
236
+ self.dymola = None # the DymolaInterface instance
237
+ self._n_tests_run = 0
238
+
239
+ def start(self) -> None:
240
+ """Launch Dymola and apply startup: settings + library loads.
241
+
242
+ Launches run fully in parallel — PID attribution is read directly
243
+ from DymolaInterface's internal subprocess handle, so we never need
244
+ to serialize around a process-set snapshot.
245
+ """
246
+ self._step = "its launch"
247
+ kwargs: dict = {"showwindow": bool(self.dymola_config.show_ide)}
248
+ if self.config.backend_path:
249
+ # DEBT: on Linux, backend_path must point at the wrapper script
250
+ # (e.g. /usr/local/bin/dymola), not the bare bin64/dymola binary.
251
+ # The wrapper exports LD_LIBRARY_PATH so Dymola finds its bundled
252
+ # libgit2/Qt6/Qtitan libs. Pointing at bin64/dymola directly fails
253
+ # with confusing "shared library not found" + "Mismatching Dymola
254
+ # version" errors — DymolaInterface only sees an unstartable child
255
+ # process. Surface this as a friendlier check at config-resolution
256
+ # time once we have a Linux-aware backend-path validator.
257
+ kwargs["dymolapath"] = str(self.config.backend_path)
258
+
259
+ self.dymola = self._launch(kwargs)
260
+
261
+ # Establish working directory
262
+ self.dymola.cd(str(self.config.work_dir))
263
+
264
+ # Load dependencies
265
+ for dep_path in self.config.dependencies or []:
266
+ dep_pkg = dependency_file(dep_path)
267
+ self._step = _open_model_step(dep_pkg)
268
+ ok = self.dymola.openModel(str(dep_pkg))
269
+ if not ok:
270
+ raise RuntimeError(
271
+ f"Worker {self.worker_id}: failed to load dependency "
272
+ f"{dep_pkg} — {self._last_error()}"
273
+ )
274
+
275
+ # Load main library
276
+ main_pkg = self.config.library_dir / "package.mo"
277
+ self._step = _open_model_step(main_pkg)
278
+ ok = self.dymola.openModel(str(main_pkg))
279
+ if not ok:
280
+ raise RuntimeError(
281
+ f"Worker {self.worker_id}: failed to load library {main_pkg} "
282
+ f"— {self._last_error()}"
283
+ )
284
+
285
+ # Framework settings (mirror startup.mos). ExecuteCommand returns the
286
+ # value of the command (e.g., `true` for an assignment), not a
287
+ # success/failure indicator — we only detect a real failure by the
288
+ # Dymola error log.
289
+ self._exec_setting("OutputCPUtime := true;")
290
+ self._exec_setting("Advanced.UI.TranslationInCommandLog := true;")
291
+
292
+ # User setup commands — warn on failure, don't abort
293
+ for cmd in self.dymola_config.backend_setup or []:
294
+ cmd = cmd.strip()
295
+ if not cmd.endswith(";"):
296
+ cmd += ";"
297
+ self._exec_setting(cmd)
298
+ self._step = ""
299
+
300
+ def _launch(self, kwargs: dict):
301
+ """Construct the DymolaInterface, tracking the Dymola it launches
302
+ from the moment the process exists.
303
+
304
+ The constructor launches Dymola and then waits for it to answer —
305
+ seconds. Read off the finished interface, the PID arrives after
306
+ that wait, and a start given up DURING it (the bound, the pool
307
+ closing) had nothing to kill: Dymola stayed up for minutes, holding
308
+ its licence seat. So the launch step itself — ``_start_dymola``,
309
+ which stores the ``subprocess.Popen`` as ``_dymola_process`` — is
310
+ wrapped on the instance before ``__init__`` runs.
311
+
312
+ Should that step ever move, the PID is still read once the
313
+ constructor returns, as it used to be: the kill is then late for a
314
+ start given up mid-launch, and best-effort without the attribute.
315
+ """
316
+ interface = self._DI.__new__(self._DI)
317
+
318
+ def _pid() -> int | None:
319
+ proc = getattr(interface, "_dymola_process", None)
320
+ return getattr(proc, "pid", None)
321
+
322
+ launch = getattr(interface, "_start_dymola", None)
323
+ if callable(launch):
324
+
325
+ def _tracked_launch(*args, **kw):
326
+ try:
327
+ return launch(*args, **kw)
328
+ finally:
329
+ self._track_launch(_pid())
330
+
331
+ interface._start_dymola = _tracked_launch
332
+ interface.__init__(**kwargs)
333
+ self._track_launch(_pid())
334
+ return interface
335
+
336
+ def process_detail(self) -> str:
337
+ proc = getattr(self.dymola, "_dymola_process", None)
338
+ if proc is None:
339
+ return f"Dymola had not come up ({self._step})" if self._step else ""
340
+ code = proc.poll()
341
+ state = (
342
+ "Dymola is still running"
343
+ if code is None
344
+ else f"Dymola exited (code {code})"
345
+ )
346
+ return f"{state} during {self._step}" if self._step else state
347
+
348
+ def cancel_running(self, test_key: str) -> bool:
349
+ """dymosim's own stop request: a file named ``stop`` in its working
350
+ directory. An integration that LOOKS at it ends there and
351
+ ``simulateModel`` returns with Dymola intact — measured 0.16 to
352
+ 0.38 s on a CPU-bound model. One that does not look — a model
353
+ blocked in an external call (``Modelica.Utilities.System.command``)
354
+ never returns to the solver's loop — runs on: the file is written
355
+ at once, the watchdog's :attr:`cancel_grace` (10 s) runs out, and
356
+ the worker is killed and restarted, about 11.7 s after the request
357
+ (4 of 4). The request is ``True`` either way: it was made.
358
+
359
+ Only while a dymosim process runs under this Dymola. During
360
+ translation there is none, and nothing short of killing Dymola stops
361
+ the compile — ``False``, and the watchdog does that.
362
+ """
363
+ asked = False
364
+ for child in live_children(self.pids, name_hint=self.kill_name_hint):
365
+ try:
366
+ if child.name().lower() not in _DYMOSIM_NAMES:
367
+ continue
368
+ (Path(child.cwd()) / "stop").write_text("", encoding="utf-8")
369
+ asked = True
370
+ except Exception:
371
+ continue # gone, or not ours to look at
372
+ return asked
373
+
374
+ def run_test(
375
+ self,
376
+ test: TestModel,
377
+ test_key: str,
378
+ progress=None,
379
+ ) -> TestRunResult:
380
+ """Run one test in this worker. Returns the TestRunResult.
381
+
382
+ Splits translateModel + simulateModel into separate calls so we can
383
+ time them independently and emit per-phase progress events.
384
+ """
385
+ test_dir = self.config.work_dir / test_key
386
+ # Fresh dir each time — prevents stale dsres.mat / dslog.txt bleed-through.
387
+ # Defensive duplicate of cmd_run's central wipe (D91); kept here so a
388
+ # direct caller of run_tests outside cmd_run still gets clean state.
389
+ if test_dir.exists():
390
+ shutil.rmtree(test_dir)
391
+ test_dir.mkdir(parents=True, exist_ok=True)
392
+
393
+ # Bind the interface once. run_test executes on a thread the watchdog
394
+ # abandons on timeout (`t.join(0.5)`); the pool then restarts this
395
+ # worker and `self.dymola` becomes a *new* interface. An abandoned
396
+ # thread that later unblocks must keep talking to the dead interface —
397
+ # re-reading the attribute would let it issue RPCs against the fresh
398
+ # one concurrently with the next test, which is exactly the "Mismatch
399
+ # request/response ID" poisoning documented below.
400
+ dymola = self.dymola
401
+ start = time.monotonic()
402
+ translation_wall: float | None = None
403
+ sim_wall: float | None = None
404
+ try:
405
+ dymola.cd(str(test_dir))
406
+ dymola.clearlog()
407
+
408
+ # Phase 1: translate (may reuse cached translation — nearly free in that case)
409
+ if progress is not None:
410
+ progress.on_phase(test_key, "translating")
411
+ t_trans_start = time.monotonic()
412
+ self._step = f"translateModel({test.model_id})"
413
+ translation_ok = bool(dymola.translateModel(test.model_id))
414
+ translation_wall = time.monotonic() - t_trans_start
415
+
416
+ if not translation_ok:
417
+ # Save log, fail fast — skip the simulate call
418
+ try:
419
+ dymola.savelog(str(test_dir / "translation_log.txt"))
420
+ except Exception:
421
+ pass
422
+ elapsed = time.monotonic() - start
423
+ self._n_tests_run += 1
424
+
425
+ # Worker-health probe: a real translation failure always
426
+ # writes a non-trivial translation_log.txt (parse errors,
427
+ # license messages, etc. — typically hundreds of bytes).
428
+ # An empty / missing / very small log paired with a False
429
+ # return from translateModel is the signature of a
430
+ # broken-worker state we've seen on Dymola/Linux after a
431
+ # watchdog kill — the worker is alive enough to respond
432
+ # to RPCs but silently rejects every translateModel.
433
+ # Without this probe each broken-worker test wastes ~40s
434
+ # waiting for translateModel to time out internally, and
435
+ # the cascade silently fails every remaining test.
436
+ # Closing the worker here forces the dispatch loop's
437
+ # next iteration to call _try_restart, escalating to a
438
+ # real recovery attempt (or exhausting the budget if the
439
+ # broken state is permanent).
440
+ log_path = test_dir / "translation_log.txt"
441
+ if (not log_path.exists()) or log_path.stat().st_size < 50:
442
+ # Close only OUR interface: if the watchdog abandoned
443
+ # this thread and the pool already restarted the worker,
444
+ # self.dymola is a fresh interface serving the next test
445
+ # — closing it here would kill the new worker mid-test
446
+ # (the cross-thread poisoning the pinned local exists
447
+ # to prevent). self.close() reads self.*, so it is only
448
+ # safe while self.dymola is still the pinned instance.
449
+ if self.dymola is dymola:
450
+ try:
451
+ self.close(grace=1.0)
452
+ except Exception:
453
+ pass
454
+ return TestRunResult(
455
+ model_id=test.model_id,
456
+ test_key=test_key,
457
+ success=False,
458
+ elapsed=elapsed,
459
+ error_message=(
460
+ "Worker broken: translateModel returned False with "
461
+ "no diagnostic log (forcing restart)"
462
+ ),
463
+ translation_wall=translation_wall,
464
+ )
465
+
466
+ return TestRunResult(
467
+ model_id=test.model_id,
468
+ test_key=test_key,
469
+ success=False,
470
+ elapsed=elapsed,
471
+ error_message=_classify_translation_error(
472
+ test_dir / "translation_log.txt"
473
+ ),
474
+ translation_wall=translation_wall,
475
+ )
476
+
477
+ # Phase 2: simulate (uses cached translation)
478
+ if progress is not None:
479
+ progress.on_phase(test_key, "simulating")
480
+ call_kwargs: dict = {
481
+ "method": test.method,
482
+ "tolerance": test.tolerance,
483
+ "resultFile": "dsres",
484
+ }
485
+ if test.stop_time != 1.0:
486
+ call_kwargs["stopTime"] = test.stop_time
487
+ if test.number_of_intervals is not None:
488
+ call_kwargs["numberOfIntervals"] = test.number_of_intervals
489
+ elif test.output_interval is not None:
490
+ call_kwargs["outputInterval"] = test.output_interval
491
+
492
+ t_sim_start = time.monotonic()
493
+ # simulateModel returns bool; actual success/failure determined
494
+ # by on-disk artifacts in _evaluate_from_disk
495
+ self._step = f"simulateModel({test.model_id})"
496
+ _ = dymola.simulateModel(test.model_id, **call_kwargs)
497
+ sim_wall = time.monotonic() - t_sim_start
498
+
499
+ # Phase 3: finalize (savelog)
500
+ if progress is not None:
501
+ progress.on_phase(test_key, "finalizing")
502
+ dymola.savelog(str(test_dir / "translation_log.txt"))
503
+ except Exception as exc: # DymolaException or connection loss
504
+ elapsed = time.monotonic() - start
505
+ self._n_tests_run += 1
506
+ # Don't call savelog here — the JSON-RPC connection state is
507
+ # indeterminate after Dymola raises mid-call. An attempted
508
+ # savelog can complete partially, leaving the next test's RPC
509
+ # ID mismatched with the prior savelog's response, which
510
+ # poisons the worker for every subsequent test ("Mismatch
511
+ # request/response ID in JSON-RPC call" cascade observed on
512
+ # TRANSFORM/Linux). Translation-log preservation on this path
513
+ # would require either a connection-health probe before the
514
+ # savelog attempt or splitting the RPC client to discard
515
+ # in-flight state on exception — both bigger than this branch
516
+ # warrants. dslog.txt (written by Dymola itself during
517
+ # simulation) is still the available diagnostic.
518
+ msg = f"DymolaInterface error: {exc}"
519
+ return TestRunResult(
520
+ model_id=test.model_id,
521
+ test_key=test_key,
522
+ success=False,
523
+ elapsed=elapsed,
524
+ error_message=msg,
525
+ translation_wall=translation_wall,
526
+ sim_wall=sim_wall,
527
+ )
528
+
529
+ elapsed = time.monotonic() - start
530
+ self._n_tests_run += 1
531
+ result = self._evaluate_from_disk(test, test_key, elapsed)
532
+ # Attach phase timings to the result
533
+ result.translation_wall = translation_wall
534
+ result.sim_wall = sim_wall
535
+ return result
536
+
537
+ def export_fmu(self, test: TestModel, output_dir: Path) -> Path:
538
+ """Export ``test.model_id`` as an FMU into ``output_dir`` (4.B.2).
539
+
540
+ Uses Dymola's ``translateModelFMU`` API. Dymola produces the FMU in
541
+ the current working directory; we cd to ``output_dir`` so it lands
542
+ next to the chain's other artefacts, then return the resolved path.
543
+
544
+ Requires the FMI export option in the Dymola license; raises a clear
545
+ message on failure.
546
+ """
547
+ if self.dymola is None:
548
+ raise RuntimeError(
549
+ f"Worker {self.worker_id}: cannot export FMU before start()"
550
+ )
551
+ output_dir.mkdir(parents=True, exist_ok=True)
552
+ self.dymola.cd(str(output_dir))
553
+ # Dymola's translateModelFMU returns the FMU file path (without .fmu)
554
+ # on success, or an empty string on failure. fmiVersion="2" is the
555
+ # broadly compatible default; fmiType="all" gives both ME + CS.
556
+ try:
557
+ result = self.dymola.translateModelFMU(
558
+ test.model_id,
559
+ storeResult=False,
560
+ modelName="",
561
+ fmiVersion="2",
562
+ fmiType="all",
563
+ includeSource=False,
564
+ includeImage=0,
565
+ )
566
+ except Exception as exc:
567
+ raise RuntimeError(
568
+ f"DymolaInterface.translateModelFMU failed for {test.model_id}: {exc}"
569
+ ) from exc
570
+ if not result:
571
+ raise RuntimeError(
572
+ f"Dymola FMU export returned no path for {test.model_id} "
573
+ f"(check Dymola license includes FMI export, and the model "
574
+ f"translates successfully)"
575
+ )
576
+ # Dymola returns the basename without .fmu — append it.
577
+ fmu_path = output_dir / f"{result}.fmu"
578
+ if not fmu_path.exists():
579
+ # Fallback: scan for any .fmu Dymola may have produced.
580
+ candidates = list(output_dir.glob("*.fmu"))
581
+ if not candidates:
582
+ raise RuntimeError(
583
+ f"Dymola FMU export claimed success but no .fmu found "
584
+ f"in {output_dir} (returned: {result!r})"
585
+ )
586
+ fmu_path = candidates[0]
587
+ return fmu_path.resolve()
588
+
589
+ def _evaluate_from_disk(
590
+ self,
591
+ test: TestModel,
592
+ test_key: str,
593
+ elapsed: float,
594
+ ) -> TestRunResult:
595
+ """Inspect on-disk artifacts (dsres.mat + logs) and produce a
596
+ TestRunResult. Used both on the normal happy path and as a fallback
597
+ from `run_test_with_timeout` when the in-process flow couldn't
598
+ finish (timeout / worker exception) but the simulation may have
599
+ actually completed and written its mat file.
600
+ """
601
+ test_dir = self.config.work_dir / test_key
602
+ statistics = parse_test_logs(test_dir)
603
+
604
+ translation_failed = False
605
+ translation_log = test_dir / "translation_log.txt"
606
+ if translation_log.exists():
607
+ try:
608
+ tlog = translation_log.read_text(encoding="utf-8", errors="replace")
609
+ if "Translation aborted" in tlog or tlog.strip().endswith("= false"):
610
+ translation_failed = True
611
+ except OSError:
612
+ pass
613
+
614
+ # A simulation truly completed only if BOTH:
615
+ # 1. dsfinal.txt exists (Dymola writes it at end of successful sim)
616
+ # 2. mat file's last time reaches the requested stop_time
617
+ # dsres.mat existence alone is insufficient — Dymola writes it
618
+ # incrementally, so a killed-mid-sim leaves a partial file.
619
+ mat_path = test_dir / "dsres.mat"
620
+ dsfinal_path = test_dir / "dsfinal.txt"
621
+ success = False
622
+ completion_msg: str | None = None
623
+ if not translation_failed and mat_path.exists() and dsfinal_path.exists():
624
+ extents = read_mat_time_extents(mat_path)
625
+ stop_time = float(test.stop_time)
626
+ if extents is not None:
627
+ last_time = extents[1]
628
+ # Allow a tiny tolerance — output_interval may not align perfectly
629
+ tol = max(1e-6, abs(stop_time) * 1e-6)
630
+ if last_time + tol >= stop_time:
631
+ success = True
632
+ else:
633
+ completion_msg = (
634
+ f"Stopped early at T={last_time:.6g} of {stop_time:.6g}"
635
+ )
636
+ else:
637
+ completion_msg = "dsres.mat unreadable"
638
+
639
+ if success:
640
+ return TestRunResult(
641
+ model_id=test.model_id,
642
+ test_key=test_key,
643
+ success=True,
644
+ elapsed=elapsed,
645
+ statistics=statistics,
646
+ )
647
+
648
+ # Build the most informative failure message we can
649
+ if translation_failed:
650
+ msg = "Translation failed"
651
+ elif not mat_path.exists():
652
+ msg = "No result file produced"
653
+ elif not dsfinal_path.exists():
654
+ msg = "Simulation aborted (no dsfinal.txt)"
655
+ else:
656
+ msg = completion_msg or "Simulation incomplete"
657
+
658
+ dslog_path = test_dir / "dslog.txt"
659
+ if dslog_path.exists():
660
+ try:
661
+ log_text = dslog_path.read_text(encoding="utf-8", errors="replace")
662
+ if "ERROR" in log_text or "error" in log_text:
663
+ lines = log_text.strip().split("\n")
664
+ msg = msg + " | " + " | ".join(lines[-3:])
665
+ except OSError:
666
+ pass
667
+
668
+ return TestRunResult(
669
+ model_id=test.model_id,
670
+ test_key=test_key,
671
+ success=False,
672
+ elapsed=elapsed,
673
+ error_message=msg,
674
+ statistics=statistics,
675
+ )
676
+
677
+ def _detach_interface(self) -> object | None:
678
+ d = self.dymola
679
+ self.dymola = None
680
+ return d
681
+
682
+ def _shutdown_interface(self, handle: object, grace: float) -> None:
683
+ handle.close() # type: ignore[attr-defined]
684
+
685
+ def _quiet(self):
686
+ # The Dymola being torn down: the requests still in flight against
687
+ # it fail from here on, and that is not a finding.
688
+ return _closing(self.dymola)
689
+
690
+ def is_alive(self) -> bool:
691
+ return self.dymola is not None and not self.tool_gone()
692
+
693
+ # -----------------------------------------------------------------
694
+ def _exec_setting(self, cmd: str) -> None:
695
+ """Run a Modelica settings command. ExecuteCommand returns the value
696
+ of the command (e.g., `true` for an assignment), which we can't use
697
+ as a success indicator. Only log a warning if the Dymola error log
698
+ picks up an issue.
699
+ """
700
+ assert self.dymola is not None
701
+ try:
702
+ self.dymola.ExecuteCommand(cmd)
703
+ except Exception as exc: # pragma: no cover — diagnostic path
704
+ logger.warning(
705
+ "Worker %s: setting '%s' raised: %s",
706
+ self.worker_id,
707
+ cmd,
708
+ exc,
709
+ )
710
+ return
711
+ err = self._last_error()
712
+ if err and ("Error" in err or "error:" in err.lower()):
713
+ logger.warning(
714
+ "Worker %s: setting '%s' reported: %s",
715
+ self.worker_id,
716
+ cmd,
717
+ err.strip()[:200],
718
+ )
719
+
720
+ def reload(self, changes: LibraryChanges | None) -> None:
721
+ """``openModel(package.mo)`` again, in the live Dymola: it then
722
+ discards the library it holds and reads each file as it is next
723
+ needed — a changed model, a base class or a component of it, an
724
+ added file, a removed one (its class is gone, and its test fails to
725
+ translate). The library always, whatever changed, and before it
726
+ each dependency that holds a changed file (all of them when nothing
727
+ is known of what changed). Opening one file of a library costs what
728
+ opening its ``package.mo`` does (0.01 to 0.05 s on a native file
729
+ system, 8 to 9.5 s on /mnt/d for a 3 270-file library), so nothing
730
+ is opened file by file.
731
+
732
+ ``changeDirectory=False``: a reopen must not leave Dymola's working
733
+ directory in the library. A file Dymola cannot parse is NOT an
734
+ error of this call (it returns true): its test fails to translate,
735
+ with the parser's message in the translation log."""
736
+ touched = (
737
+ None
738
+ if changes is None
739
+ else (*changes.changed, *changes.added, *changes.removed)
740
+ )
741
+ #: (what the message calls it, the file it is opened by)
742
+ opened: list[tuple[str, Path]] = []
743
+ for entry in self.config.dependencies or []:
744
+ # Which files are the dependency's is asked as the sweep
745
+ # spelled them; what is opened is what ``start`` opened.
746
+ root = dependency_root(entry)
747
+ if root is None:
748
+ continue
749
+ if touched is None or any(under(path, root) for path in touched):
750
+ opened.append((root.name, dependency_file(entry)))
751
+ library_dir = self.config.library_dir
752
+ opened.append((library_dir.name, library_dir / "package.mo"))
753
+ for name, main_pkg in opened:
754
+ self._step = _open_model_step(main_pkg)
755
+ if not self.dymola.openModel(str(main_pkg), changeDirectory=False):
756
+ raise LibraryReloadError(
757
+ f"{name} does not load: {self._last_error().strip()[:400]}"
758
+ )
759
+
760
+ def _last_error(self) -> str:
761
+ try:
762
+ return str(self.dymola.getLastErrorLog()) if self.dymola else ""
763
+ except Exception:
764
+ return ""
765
+
766
+ def tool_version(self) -> str | None:
767
+ """Actual Dymola version via the built-in ``DymolaVersion()``.
768
+
769
+ ``ExecuteCommand`` returns the command's evaluated value (see
770
+ ``_exec_setting``), so calling it on a value-returning built-in yields
771
+ the version string (e.g. ``"Dymola 2026x Refresh 1"``). Best-effort:
772
+ returns ``None`` if the interface isn't up or the call yields no usable
773
+ string. This is what disambiguates a 2025x baseline from a 2026x
774
+ re-run when results drift.
775
+ """
776
+ if self.dymola is None:
777
+ return None
778
+ try:
779
+ val = self.dymola.ExecuteCommand("DymolaVersion()")
780
+ except Exception:
781
+ return None
782
+ if isinstance(val, str) and val.strip():
783
+ return val.strip()
784
+ return None
785
+
786
+ def library_versions(self) -> dict:
787
+ """Loaded Modelica Standard Library version.
788
+
789
+ Dymola exposes no scripting getter for a library's version, but it
790
+ bundles MSL on disk under ``$DYMOLA/Modelica/Library/Modelica X.Y.Z/``
791
+ and sets the ``DYMOLA`` env var (both platforms). We read that env var
792
+ via the interface, then glob the bundle dir for the version. Best-
793
+ effort → ``{}`` on any failure. The install path is local to the
794
+ machine running dstf, so the glob is a plain filesystem read.
795
+ """
796
+ if self.dymola is None:
797
+ return {}
798
+ try:
799
+ raw = self.dymola.ExecuteCommand(
800
+ 'Modelica.Utilities.System.getEnvironmentVariable("DYMOLA")'
801
+ )
802
+ except Exception:
803
+ return {}
804
+ # getEnvironmentVariable returns [value, exists] over the interface.
805
+ root = raw[0] if isinstance(raw, (list, tuple)) and raw else raw
806
+ if not isinstance(root, str) or not root.strip():
807
+ return {}
808
+ import glob
809
+ import os
810
+ import re
811
+
812
+ vers = sorted(
813
+ {
814
+ m.group(1)
815
+ for p in glob.glob(
816
+ os.path.join(root.strip(), "Modelica", "Library", "Modelica *")
817
+ )
818
+ if (m := re.search(r"Modelica ([\d][\d.]*)", os.path.basename(p)))
819
+ }
820
+ )
821
+ # If several MSL versions are bundled, report the newest (Dymola's
822
+ # default load) — max by dotted-int tuple, not string order.
823
+ if not vers:
824
+ return {}
825
+ best = max(vers, key=lambda v: tuple(int(x) for x in v.split(".")))
826
+ return {"Modelica": best}
827
+
828
+
829
+ # ---------------------------------------------------------------------------
830
+
831
+
832
+ class PersistentDymolaBackend(PersistentBackendBase, DymolaBackend):
833
+ """Dymola backend using persistent DymolaInterface workers + a queue.
834
+
835
+ Inherits the dispatch-loop machinery from :class:`PersistentBackendBase`
836
+ and the batched :class:`DymolaBackend` for ``read_result`` + config
837
+ extraction. Order matters — ``PersistentBackendBase`` first so its
838
+ ``run_tests`` template wins over the batch ``.mos`` backend's override.
839
+ """
840
+
841
+ worker_cls = DymolaWorker
842
+ backend_label = "Dymola"
843
+
844
+ #: FMU export lives HERE, not on the batch class: it needs a live
845
+ #: DymolaInterface (`translateModelFMU` over the running session).
846
+ #: Declared on the class the cross-backend chain actually holds an
847
+ #: instance of, so its `Capability.FMU_EXPORT` gate is truthful.
848
+ #: `_validate_capabilities` doesn't see this class (it isn't
849
+ #: `@register`-ed — it's reached via `DymolaBackend.persistent_backend_cls`),
850
+ #: so `tests/test_capability_gating.py` asserts the flag/override pairing.
851
+ #: Declared explicitly, not inherited-and-extended: this class does
852
+ #: persistent orchestration, so the batch class's BATCH_FALLBACK
853
+ #: would be a false claim here (B2 review finding 5).
854
+ #: CANCEL is the worker's: ``DymolaWorker.cancel_running`` (dymosim's
855
+ #: ``stop`` file) with the watchdog's kill behind it.
856
+ capabilities = frozenset(
857
+ {
858
+ Capability.PERSISTENT_WORKERS,
859
+ Capability.FMU_EXPORT,
860
+ Capability.LIVE_RESULTS,
861
+ Capability.CANCEL,
862
+ }
863
+ )
864
+
865
+ @classmethod
866
+ def preflight(cls, config) -> None:
867
+ # Probes the Dymola Python interface (the wheel/egg shipped under
868
+ # the Dymola install). Cheap — load_dymola_interface caches after
869
+ # the first call. RuntimeError carries an install hint that the
870
+ # CLI surfaces verbatim.
871
+ # Lazy import inside the method so tests can monkeypatch
872
+ # ``interface_loader.load_dymola_interface`` and have the patch
873
+ # take effect here.
874
+ from .interface_loader import load_dymola_interface as _load
875
+
876
+ _load(config.dymola_interface_path)
877
+
878
+ def setup_before_workers(self) -> None:
879
+ # Cache the DymolaInterface class for make_worker, then apply
880
+ # Dymola-specific runtime patches. These two patches must run
881
+ # before any worker spawns:
882
+ # * _install_dymola_log_filter — keeps the connection errors of
883
+ # a Dymola a worker closed out of the console.
884
+ # * _patch_dymola_for_parallel_startup — narrows DymolaInterface's
885
+ # module-level startup lock so workers can launch concurrently
886
+ # (~7s × N serialized → all workers in ~7s wall).
887
+ self._di_cls = load_dymola_interface(self.config.dymola_interface_path)
888
+ _install_dymola_log_filter()
889
+ _patch_dymola_for_parallel_startup()
890
+
891
+ def make_worker(self, worker_id: int) -> DymolaWorker:
892
+ return DymolaWorker(
893
+ worker_id,
894
+ self.config,
895
+ self.dymola_config,
896
+ self._di_cls,
897
+ )
898
+
899
+ def _probe_worker_version(self, live_workers: list) -> str | None:
900
+ # Ask live workers in turn; the first to name a version wins. Called
901
+ # once at startup (before dispatch) so the interface is idle and
902
+ # cross-thread access to worker.dymola is safe.
903
+ for w in live_workers:
904
+ v = w.tool_version()
905
+ if v:
906
+ return v
907
+ return None
908
+
909
+ def _probe_library_versions(self, live_workers: list) -> dict | None:
910
+ for w in live_workers:
911
+ libs = w.library_versions()
912
+ if libs:
913
+ return libs
914
+ return None
915
+
916
+ def export_fmu(self, test: TestModel, output_dir: Path) -> Path:
917
+ """Spin up a one-shot worker to export a single FMU (4.B.2).
918
+
919
+ Heavier than reusing a worker from the pool, but keeps the export
920
+ path independent of the pool's lifecycle (so 4.B's cross-backend
921
+ chain can call this from arbitrary places). Optimization: have the
922
+ chain orchestration reuse an idle worker via a future helper.
923
+
924
+ VALIDATION CAVEAT: requires Windows + Dymola + the FMI export
925
+ license option. Cannot be exercised in CI on Linux WSL; tests use
926
+ a mock DymolaInterface.
927
+ """
928
+ di_cls = load_dymola_interface(self.config.dymola_interface_path)
929
+ _patch_dymola_for_parallel_startup()
930
+ worker = DymolaWorker(
931
+ worker_id=-1,
932
+ config=self.config,
933
+ dymola_config=self.dymola_config,
934
+ dymola_interface_cls=di_cls,
935
+ )
936
+ worker.start()
937
+ try:
938
+ return worker.export_fmu(test, output_dir)
939
+ finally:
940
+ # review 2026-07-06 (finding 76): use the worker's guarded close()
941
+ # (graceful close + psutil hard-kill of tracked PIDs), not
942
+ # dymola.close() — a hung or raising interface would otherwise
943
+ # leak the one-shot Dymola process and its license seat.
944
+ try:
945
+ worker.close()
946
+ except Exception:
947
+ pass
948
+
949
+ # run_tests is inherited from PersistentBackendBase.