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,1045 @@
1
+ """Persistent-worker OpenModelica backend using OMPython (OMCSessionZMQ).
2
+
3
+ Unlike the default batched backend (which spawns one ``omc`` subprocess per
4
+ test driven by a generated ``simulate.mos``), this backend keeps N long-lived
5
+ ``OMCSessionZMQ`` processes alive for the whole run. Each worker loads MSL
6
+ + the library once and then pulls tests off a shared queue.
7
+
8
+ Benefits:
9
+ - MSL + library load paid once per worker (major win for TRANSFORM —
10
+ per-test compile was ~50–100s in the 2026-04-22 sweep; amortizing
11
+ library load gives a 5–10× wall-time cut)
12
+ - Per-test granularity: start/finish events fire as each test transitions
13
+ - Natural work-stealing: whichever worker finishes first grabs the next test
14
+ - Per-test timeout isolation: a hung test kills one worker, not the batch
15
+
16
+ The default CLI mode selects this backend; pass ``--batch`` to opt out and
17
+ use the subprocess-per-test backend, which doesn't require OMPython. There
18
+ is no automatic fallback: if OMPython can't be imported, ``preflight``
19
+ raises and the CLI aborts with a message naming both fixes (see
20
+ ``backends.select_backend``).
21
+
22
+ Phase labels are cosmetic — OM's ``simulate()`` bundles build + run in one
23
+ call with no mid-call progress hook, so we emit ``translating`` before the
24
+ call and ``simulating`` after (matching the existing batch backend pattern).
25
+ Per-phase wall timings are still accurate in the final report because they
26
+ come from the returned ``SimulationResult`` record.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import logging
32
+ import os
33
+ import re
34
+ import shutil
35
+ import time
36
+ from pathlib import Path
37
+
38
+ from ...config import Config
39
+ from ...discovery.library_state import LibraryChanges, dependency_root, under
40
+ from ...discovery.test_registry import TestModel
41
+ from ..common.mat_reader import mat_reaches_time
42
+ from ..common.proc_kill import live_children
43
+ from ..persistent import LibraryReloadError, PersistentBackendBase, Worker
44
+ from ..types import Capability, TestRunResult
45
+ from .backend import OpenModelicaBackend, OpenModelicaConfig
46
+ from .log_parser import _TIMING_KEYS, ParsedOmcOutput, parse_omc_stdout
47
+ from .mos_generator import build_simulate_args, classify_dependency
48
+ from .session_loader import load_omc_session
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+
53
+ # OMPython's pyparsing grammar (OMTypedParser) is a module-level singleton
54
+ # and is NOT thread-safe — concurrent ``sendExpression(parsed=True)`` calls
55
+ # across workers corrupt parse state and raise ``TypeError: convertString()
56
+ # missing 1 required positional argument``. We sidestep the issue entirely
57
+ # by always calling ``sendExpression(expr, parsed=False)`` and parsing the
58
+ # raw OMC wire format ourselves with thread-safe helpers. The ZMQ layer is
59
+ # per-instance and safe; only the pyparsing step was the bottleneck.
60
+
61
+
62
+ def _raw_exec_bool(session, expr: str) -> bool:
63
+ """Send an expression, parse raw OMC response as bool.
64
+
65
+ OMC returns ``"true\\n"`` / ``"false\\n"`` on the wire; anything else
66
+ (or an exception from the ZMQ layer) is treated as false.
67
+ """
68
+ try:
69
+ raw = session.sendExpression(expr, parsed=False)
70
+ except Exception:
71
+ return False
72
+ return (raw or "").strip() == "true"
73
+
74
+
75
+ def _raw_reply(session, expr: str) -> tuple[str, str | None]:
76
+ """Send an expression: ``(omc's raw reply, None)``, or ``("", omc's
77
+ message)`` when the call reported an error. OMPython raises with the
78
+ error's text then (and that empties ``getErrorString()``), so the
79
+ exception's text IS the message — less OMPython's own prefix."""
80
+ try:
81
+ raw = session.sendExpression(expr, parsed=False)
82
+ except Exception as exc:
83
+ # "[OMC log for 'sendExpression(...)']: <message>"
84
+ text = str(exc).split("']: ", 1)[-1].strip()
85
+ return "", text or type(exc).__name__
86
+ return (raw or "").strip(), None
87
+
88
+
89
+ #: One record of omc's ``getMessagesStringInternal()``: where the message
90
+ #: points, what it says, and its kind, level and id — the three OMPython
91
+ #: prints as ``[kind:level:id]``.
92
+ _OMC_MESSAGE = re.compile(
93
+ r"record OpenModelica\.Scripting\.ErrorMessage\s+"
94
+ r"info = record OpenModelica\.Scripting\.SourceInfo\s+"
95
+ r'filename = "(?P<file>.*?)",\s+readonly = \w+,\s+'
96
+ r"lineStart = (?P<line>\d+),.*?"
97
+ r"end OpenModelica\.Scripting\.SourceInfo;,\s+"
98
+ r'message = "(?P<message>.*?)",\n\s+'
99
+ r"kind = \.OpenModelica\.Scripting\.ErrorKind\.(?P<kind>\w+),\n\s+"
100
+ r"level = \.OpenModelica\.Scripting\.ErrorLevel\.(?P<level>\w+),\n\s+"
101
+ r"id = (?P<id>\d+)",
102
+ re.DOTALL,
103
+ )
104
+
105
+
106
+ def _last_error(messages: str) -> tuple[str, str, int] | None:
107
+ """The error among omc's *messages* that OMPython raises for them —
108
+ the last record of level ``error`` — as ``(its text, the file it
109
+ points at, the line)``: the text as OMPython words it (``[syntax:
110
+ error:2] No viable alternative near token: ;``), the file ``""`` and
111
+ the line 0 where omc names none. None without an error."""
112
+ for found in reversed(list(_OMC_MESSAGE.finditer(messages))):
113
+ if found["level"] != "error":
114
+ continue
115
+ message = found["message"].encode().decode("unicode_escape")
116
+ text = f"[{found['kind']}:{found['level']}:{found['id']}] {message}"
117
+ return text, found["file"], int(found["line"])
118
+ return None
119
+
120
+
121
+ def _raw_located(session, expr: str) -> tuple[str, str | None, tuple[str, int] | None]:
122
+ """:func:`_raw_reply`, with WHERE the error is when omc says: ``(reply,
123
+ error, (file, line))``. OMPython's own check of the messages keeps
124
+ what each says and drops where it points — a whole library that does
125
+ not load then names no file — so the expression and the read of the
126
+ messages are sent on the session's socket here, and the records are
127
+ read whole. A session without that socket (another OMPython) is asked
128
+ the usual way: the same error, no place."""
129
+ socket = getattr(session, "omc_zmq", None)
130
+ if socket is None:
131
+ return (*_raw_reply(session, expr), None)
132
+ try:
133
+ socket.send_string(expr)
134
+ raw = socket.recv_string()
135
+ socket.send_string("getMessagesStringInternal()")
136
+ messages = socket.recv_string()
137
+ except Exception as exc:
138
+ return "", str(exc) or type(exc).__name__, None
139
+ error = _last_error(messages)
140
+ if error is None:
141
+ return (raw or "").strip(), None, None
142
+ text, file, line = error
143
+ return "", text, (file, line) if file else None
144
+
145
+
146
+ def _place(where: tuple[str, int] | None, root: Path | None) -> str:
147
+ """``Examples/Pipe.mo:12: `` — the file an error points at, as its path
148
+ under the library's *root* (whole, for one outside it or with no root:
149
+ a library omc found by its name), and the line where omc gives one;
150
+ ``""`` without a place."""
151
+ if where is None:
152
+ return ""
153
+ file, line = Path(where[0]), where[1]
154
+ try:
155
+ if root is None:
156
+ raise ValueError
157
+ shown = file.resolve().relative_to(root.resolve()).as_posix()
158
+ except (OSError, ValueError):
159
+ shown = str(file)
160
+ return f"{shown}:{line}: " if line else f"{shown}: "
161
+
162
+
163
+ def _load_refused(session, expr: str, root: Path | None) -> str | None:
164
+ """Why omc refused the load *expr* (a ``loadFile`` or a ``loadModel``),
165
+ or None when it took it: omc's error, led by the file and the line it
166
+ points at — under *root*, the directory of what is loaded, where there
167
+ is one (:func:`_place`). THE one reading of a refused load, for a
168
+ worker's start and for its reload.
169
+
170
+ Asked through :func:`_raw_located`, never as a bool followed by
171
+ ``getErrorString()``: OMPython reads omc's messages to decide that the
172
+ call failed, which empties them, and a worker that could not start
173
+ said `failed to load library …/package.mo — ` and nothing after."""
174
+ reply, error, where = _raw_located(session, expr)
175
+ if error is None and reply != "true":
176
+ error = f"omc answered {reply or 'nothing'}"
177
+ if error is None:
178
+ return None
179
+ return _place(where, root) + error
180
+
181
+
182
+ def _raw_get_error_string(session) -> str:
183
+ """Call ``getErrorString()`` and unwrap the quoted string.
184
+
185
+ OMC's wire format for a string is ``"<escaped>"\\n`` — we strip the
186
+ surrounding quotes and undo ``\\"`` / ``\\\\`` escapes.
187
+ """
188
+ try:
189
+ raw = session.sendExpression("getErrorString()", parsed=False)
190
+ except Exception:
191
+ return ""
192
+ s = (raw or "").strip()
193
+ if len(s) >= 2 and s.startswith('"') and s.endswith('"'):
194
+ s = s[1:-1]
195
+ return s.replace('\\"', '"').replace("\\\\", "\\")
196
+
197
+
198
+ def _raw_send_simulate(session, expr: str) -> tuple[dict | None, str]:
199
+ """Run ``simulate(...)``, parse the echoed record via the regex parser.
200
+
201
+ Returns ``(record_dict_or_None, raw_string)``. Uses the existing
202
+ :func:`parse_omc_stdout` (pure ``re`` module — thread-safe) instead of
203
+ OMPython's pyparsing grammar. OMC's raw wire format for a record is
204
+ byte-identical to what omc REPL-echoes as stdout in batch mode.
205
+ """
206
+ raw = session.sendExpression(expr, parsed=False) or ""
207
+ parsed = parse_omc_stdout(raw)
208
+ if not parsed.result_file and not parsed.messages and not parsed.timings:
209
+ return None, raw
210
+ rec: dict = {
211
+ "resultFile": parsed.result_file,
212
+ "messages": parsed.messages,
213
+ }
214
+ # Back-translate our internal timing keys to OMC's field names so the
215
+ # rest of the pipeline (``_parsed_from_record``) can treat this dict
216
+ # identically to OMPython's parsed-mode output.
217
+ _reverse_timing = {v: k for k, v in _TIMING_KEYS.items()}
218
+ if parsed.timings is not None:
219
+ for our_key, val in parsed.timings.items():
220
+ om_key = _reverse_timing.get(our_key)
221
+ if om_key is not None:
222
+ rec[om_key] = val
223
+ return rec, raw
224
+
225
+
226
+ def _parsed_from_record(
227
+ rec: dict | None,
228
+ error_notices: list[str],
229
+ ) -> ParsedOmcOutput:
230
+ """Build a :class:`ParsedOmcOutput` from OMPython's returned dict.
231
+
232
+ OMPython converts OM records to Python dicts; this bypasses the regex
233
+ parsing the batch path does on textual stdout, producing the same
234
+ structured result.
235
+ """
236
+ if not rec or not isinstance(rec, dict):
237
+ return ParsedOmcOutput(success=False, error_notices=error_notices)
238
+
239
+ timings: dict[str, float] = {}
240
+ for om_key, our_key in _TIMING_KEYS.items():
241
+ if om_key in rec:
242
+ try:
243
+ timings[our_key] = float(rec[om_key])
244
+ except (TypeError, ValueError):
245
+ pass
246
+
247
+ result_file = str(rec.get("resultFile") or "")
248
+ messages = str(rec.get("messages") or "")
249
+ success = bool(result_file) and "Failed" not in messages
250
+ return ParsedOmcOutput(
251
+ success=success,
252
+ result_file=result_file,
253
+ messages=messages,
254
+ timings=timings or None,
255
+ error_notices=error_notices,
256
+ )
257
+
258
+
259
+ def _synthesize_stdout_artifact(
260
+ worker_id: int,
261
+ simulate_expr: str,
262
+ returned_record: dict | None,
263
+ error_string: str,
264
+ ) -> str:
265
+ """Render a text document equivalent to ``omc_stdout.txt`` so the report's
266
+ artifact list matches the batch-backend shape. Diagnostic only — nothing
267
+ downstream parses it.
268
+ """
269
+ parts = [
270
+ f"// PersistentOpenModelicaBackend worker={worker_id}",
271
+ f"// {time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())}",
272
+ "",
273
+ ">>> " + simulate_expr,
274
+ ]
275
+ if returned_record is not None:
276
+ parts.append("record SimulationResult")
277
+ for k, v in returned_record.items():
278
+ if isinstance(v, str):
279
+ parts.append(f' {k} = "{v}",')
280
+ else:
281
+ parts.append(f" {k} = {v},")
282
+ parts.append("end SimulationResult;")
283
+ else:
284
+ parts.append("(no record returned)")
285
+ if error_string:
286
+ parts.append("")
287
+ parts.append(">>> getErrorString()")
288
+ parts.append(error_string)
289
+ return "\n".join(parts) + "\n"
290
+
291
+
292
+ def _resolve_session_pid(session) -> int | None:
293
+ """The omc PID behind *session*, or ``None`` if it can't be resolved.
294
+
295
+ This PID is the watchdog's only kill target: without it a timeout
296
+ leaves omc (and the compiled model binary under it) running for the
297
+ rest of the machine's day. Two probes, because the session's API is
298
+ version-dependent — OMPython used to expose ``getpid()`` and the
299
+ build in use here does not, keeping its ``subprocess.Popen`` at
300
+ ``omc_process._omc_process`` instead. Both are private-ish, so every
301
+ step is guarded and the caller warns when both come up empty.
302
+ """
303
+ getpid = getattr(session, "getpid", None)
304
+ if callable(getpid):
305
+ try:
306
+ pid = getpid()
307
+ except Exception: # pragma: no cover — best-effort probe
308
+ pid = None
309
+ if pid:
310
+ return int(pid)
311
+ proc = getattr(getattr(session, "omc_process", None), "_omc_process", None)
312
+ pid = getattr(proc, "pid", None)
313
+ if pid:
314
+ return int(pid)
315
+ return None
316
+
317
+
318
+ #: What omc writes to its log as it comes up, on every start — the last
319
+ #: lines of the log of an omc that is merely busy, and no news about it.
320
+ _OMC_STARTUP_LINES = ("Created ZeroMQ Server", "Dumped server port in file")
321
+
322
+
323
+ def _last_log_line(log: str) -> str:
324
+ """The last line of omc's log that says something about omc: its
325
+ startup notices are skipped. ``""`` when there is none."""
326
+ for line in reversed(log.splitlines()):
327
+ line = line.strip()
328
+ if line and not line.startswith(_OMC_STARTUP_LINES):
329
+ return line[-300:]
330
+ return ""
331
+
332
+
333
+ def _is_notice_line(line: str) -> bool:
334
+ lower = line.lower()
335
+ return (
336
+ lower.startswith("error")
337
+ or lower.startswith("warning")
338
+ or lower.startswith("notification")
339
+ )
340
+
341
+
342
+ class OpenModelicaWorker(Worker):
343
+ """One live OMCSessionZMQ process wrapped as a test worker."""
344
+
345
+ kill_name_hint = "omc"
346
+ exec_thread_prefix = "om"
347
+
348
+ def __init__(
349
+ self,
350
+ worker_id: int,
351
+ config: Config,
352
+ om_config: OpenModelicaConfig,
353
+ session_cls,
354
+ ):
355
+ super().__init__(worker_id, config)
356
+ self.om_config = om_config
357
+ self._OMCSession = session_cls
358
+ self.session = None # the OMCSessionZMQ instance
359
+ self._n_tests_run = 0
360
+ #: Seconds the last whole ``loadFile(package.mo)`` of the library
361
+ #: took in this omc: what :meth:`reload` weighs a file-by-file
362
+ #: load against.
363
+ self._library_load_seconds = 0.0
364
+
365
+ def start(self) -> None:
366
+ """Launch omc via OMCSessionZMQ and apply the load/setup sequence.
367
+
368
+ Mirrors the ``.mos`` preamble that :func:`build_simulate_mos` emits:
369
+ ``setCommandLineOptions`` → dep loads (MSL auto-injected) →
370
+ library load → user setup commands.
371
+ """
372
+ # review 2026-07-06 (finding 26): OMCSessionZMQ() resolves omc via
373
+ # OPENMODELICAHOME / PATH and ignores the configured omc path that
374
+ # batch mode honors — silently running a different omc version
375
+ # against the same baseline partition. OMPython exposes no omc-path
376
+ # argument, so when a specific path IS configured we derive
377
+ # OPENMODELICAHOME from it (<home>/bin/omc → <home>) before the
378
+ # session spawns. Defensive: only acts on an explicit, existing path.
379
+ configured = getattr(self.config, "backend_path", None)
380
+ if configured:
381
+ omc_path = Path(configured).expanduser()
382
+ if omc_path.exists():
383
+ om_home = omc_path.parent.parent
384
+ if os.environ.get("OPENMODELICAHOME") != str(om_home):
385
+ os.environ["OPENMODELICAHOME"] = str(om_home)
386
+ logger.warning(
387
+ "Persistent OpenModelica resolves omc via "
388
+ "OPENMODELICAHOME; derived OPENMODELICAHOME=%s from "
389
+ "the configured omc path %s",
390
+ om_home,
391
+ omc_path,
392
+ )
393
+ self._step = "its launch"
394
+ self.session = self._OMCSession()
395
+ pid = _resolve_session_pid(self.session)
396
+ if pid is None:
397
+ logger.warning(
398
+ "Worker %s: could not resolve the omc PID — the timeout "
399
+ "watchdog has no kill target, so a hung omc will survive "
400
+ "the run. Report the OMPython version; the session exposes "
401
+ "neither getpid() nor a reachable Popen handle.",
402
+ self.worker_id,
403
+ )
404
+ # OMPython launches omc and waits for its port file inside one call
405
+ # (well under a second on a healthy install, 10 s at its own bound),
406
+ # so this is the first moment the PID can be read.
407
+ self._track_launch(pid)
408
+
409
+ self._exec(f'setCommandLineOptions("--std={self.om_config.std_version}")')
410
+
411
+ # Auto-inject MSL if the user didn't list it — matches the batch backend.
412
+ deps = list(self.config.dependencies)
413
+ if "Modelica" not in deps and not any(
414
+ str(d).rstrip("/\\").endswith("Modelica") for d in deps
415
+ ):
416
+ deps = ["Modelica"] + deps
417
+
418
+ for dep in deps:
419
+ kind, arg = classify_dependency(dep)
420
+ if kind == "loadModel":
421
+ why = self._load(f"loadModel({arg})", None)
422
+ else:
423
+ why = self._load(f'loadFile("{arg}")', Path(arg).parent)
424
+ if why is not None:
425
+ raise RuntimeError(
426
+ f"Worker {self.worker_id}: failed to load dependency "
427
+ f"{dep!r} — {why[:400]}"
428
+ )
429
+
430
+ main_pkg = self.config.library_dir / "package.mo"
431
+ main_pkg_fwd = str(main_pkg.resolve()).replace("\\", "/")
432
+ began = time.monotonic()
433
+ why = self._load(f'loadFile("{main_pkg_fwd}")', main_pkg.parent)
434
+ self._library_load_seconds = time.monotonic() - began
435
+ if why is not None:
436
+ raise RuntimeError(
437
+ f"Worker {self.worker_id}: failed to load library {main_pkg} "
438
+ f"— {why[:400]}"
439
+ )
440
+
441
+ # User setup commands — best-effort; warn on failure but don't abort.
442
+ for cmd in self.om_config.backend_setup or ():
443
+ c = cmd.strip().rstrip(";")
444
+ if not c:
445
+ continue
446
+ try:
447
+ self.session.sendExpression(c)
448
+ except Exception as exc: # pragma: no cover
449
+ logger.warning(
450
+ "Worker %s: setup command %r raised: %s",
451
+ self.worker_id,
452
+ cmd,
453
+ exc,
454
+ )
455
+ self._step = ""
456
+
457
+ def _exec(self, expr: str) -> bool:
458
+ """Send an expression that returns bool (loadModel / loadFile / setOption).
459
+
460
+ Uses :func:`_raw_exec_bool` so the response bypasses OMPython's
461
+ non-thread-safe pyparsing grammar. OMPython waits for the reply
462
+ with no bound, so an omc that died or stopped answering parks this
463
+ call for good; ``start_within`` is what gives up on it, naming the
464
+ expression recorded here.
465
+ """
466
+ assert self.session is not None
467
+ self._step = expr
468
+ return _raw_exec_bool(self.session, expr)
469
+
470
+ def _load(self, expr: str, root: Path | None) -> str | None:
471
+ """Send a ``loadFile`` / ``loadModel``: None when omc took it, else
472
+ why it did not (:func:`_load_refused`). The expression is recorded
473
+ as the step, as :meth:`_exec` records its own."""
474
+ assert self.session is not None
475
+ self._step = expr
476
+ return _load_refused(self.session, expr, root)
477
+
478
+ #: Seconds one file of the library costs to read again in an omc that
479
+ #: holds the library: ``parseFile`` (0.01 measured), ``deleteClass``
480
+ #: (0.08 to 0.13) and ``loadFile`` of the file (0.07 to 0.25), on a
481
+ #: native file system and on /mnt/d alike. The whole library again is
482
+ #: what its first load was — 1 s for a 3 270-file library on the first,
483
+ #: 92 s on the second — which is what :meth:`reload` weighs this with.
484
+ _FILE_LOAD_SECONDS = 0.4
485
+
486
+ def reload(self, changes: LibraryChanges | None) -> None:
487
+ """``loadFile`` again, in the live omc: each changed file of the
488
+ library by itself where that is enough and the cheaper way, else
489
+ its ``package.mo`` — the whole library; a dependency given as a
490
+ path that holds a changed file is loaded whole, and the classes
491
+ that must exist after it are the ones its file names.
492
+
493
+ omc KEEPS the classes it holds when a load fails — a run would
494
+ then pass on the old model — so every failure here raises
495
+ :class:`LibraryReloadError` with omc's message, and the caller
496
+ fails the test. A failure after some files were loaded leaves omc
497
+ holding a mix (a new class of one file, the old of another, none
498
+ of a third): the caller asks the next reload for everything
499
+ (``changes`` None), which is the whole library and replaces it all.
500
+
501
+ The whole library is what replaces everything: a deleted file's
502
+ class is gone after it, and a file whose class does not carry the
503
+ file's name is refused. It is used when a file was added or
504
+ removed, when a ``package.mo`` or a ``package.order`` changed (a
505
+ ``package.mo`` is not a file omc loads by itself), when nothing is
506
+ known of what changed, when the changed files are too many for
507
+ one-by-one to be cheaper, and when one of them is a file whose
508
+ path does not say its class (:meth:`_path_is_its_class`: a
509
+ directory with no ``package.mo``, or one reached through a link).
510
+ One file is read as strictly as that (:meth:`_load_file_again`)."""
511
+ assert self.session is not None
512
+ config = self.config
513
+ touched = (
514
+ None
515
+ if changes is None
516
+ else (*changes.changed, *changes.added, *changes.removed)
517
+ )
518
+ for dep in config.dependencies:
519
+ root = dependency_root(dep)
520
+ if root is None:
521
+ continue # by name: omc found it, and nothing swept it
522
+ if touched is None or any(under(path, root) for path in touched):
523
+ _, dep_file = classify_dependency(dep)
524
+ # Its classes are named by its file, never by its
525
+ # directory: `Lib 1.0.0`, a symbolic link of any name.
526
+ names, error, where = self._classes_in(dep_file)
527
+ if error is None and not names:
528
+ error = "it holds no class"
529
+ if error is not None:
530
+ place = _place(where, Path(dep_file).parent)
531
+ raise LibraryReloadError(
532
+ f"{root.name} does not load: {place}{error}"
533
+ )
534
+ self._load_whole(dep_file, *names)
535
+ files = self._files_to_load_again(changes)
536
+ if files is None:
537
+ began = time.monotonic()
538
+ main_pkg = str((config.library_dir / "package.mo").resolve())
539
+ self._load_whole(main_pkg.replace("\\", "/"), str(config.library_name))
540
+ self._library_load_seconds = time.monotonic() - began
541
+ return
542
+ for path in files:
543
+ self._load_file_again(path)
544
+
545
+ def _files_to_load_again(self, changes: LibraryChanges | None) -> list[Path] | None:
546
+ """The library's own files to read one by one for *changes*, in
547
+ order; ``None`` when the whole library is to be loaded."""
548
+ if changes is None:
549
+ return None
550
+ library_dir = self.config.library_dir
551
+ added_or_removed = [
552
+ path
553
+ for path in (*changes.added, *changes.removed)
554
+ if under(path, library_dir)
555
+ ]
556
+ changed = {path for path in changes.changed if under(path, library_dir)}
557
+ if added_or_removed or any(
558
+ path.name in ("package.mo", "package.order") for path in changed
559
+ ):
560
+ return None
561
+ if len(changed) * self._FILE_LOAD_SECONDS > self._library_load_seconds:
562
+ return None
563
+ if not all(self._path_is_its_class(path) for path in changed):
564
+ return None
565
+ return sorted(changed)
566
+
567
+ def _path_is_its_class(self, path: Path) -> bool:
568
+ """Whether a swept file of the library can be loaded by itself:
569
+ every directory from the library down to it holds a ``package.mo``
570
+ and none of them below the library's own is a symbolic link.
571
+
572
+ Only then is the class the path spells the one omc holds for the
573
+ file. omc loads no file of a directory without a ``package.mo``
574
+ (a fresh worker never reads it), and ``loadFile`` of such a file
575
+ fails with ``Failed to insert class``. And a directory two links
576
+ reach is swept through ONE of them (``library_state._followed``),
577
+ which need not be the one the file's ``within`` names — the file
578
+ was refused as holding another class than its path's. A file that
579
+ is itself a link has its directory's spelling, and is read."""
580
+ library_dir = self.config.library_dir
581
+ directory = path.parent
582
+ while True:
583
+ if not (directory / "package.mo").is_file():
584
+ return False
585
+ if directory == library_dir:
586
+ return True
587
+ if directory.is_symlink():
588
+ return False
589
+ directory = directory.parent
590
+
591
+ def _classes_in(
592
+ self, file: str
593
+ ) -> tuple[list[str], str | None, tuple[str, int] | None]:
594
+ """The top-level classes *file* holds, as ``parseFile`` names them
595
+ — which loads nothing — or omc's message for a file it cannot
596
+ parse, and where in it (:func:`_raw_located`)."""
597
+ self._step = f'parseFile("{file}")'
598
+ holds, error, where = _raw_located(self.session, self._step)
599
+ if error is not None:
600
+ return [], error, where
601
+ names = [n.strip() for n in holds.strip("{}").split(",") if n.strip()]
602
+ return names, None, None
603
+
604
+ def _load_whole(self, package_file: str, *names: str) -> None:
605
+ """``loadFile`` a library's file; each of *names* must be a class
606
+ after it (a load that fails leaves the old ones, and says so)."""
607
+ why = self._load(f'loadFile("{package_file}")', Path(package_file).parent)
608
+ if why is None:
609
+ missing = [
610
+ name
611
+ for name in names
612
+ if not _raw_exec_bool(self.session, f"existClass({name})")
613
+ ]
614
+ if missing:
615
+ why = f"loadFile left no class {', '.join(missing)}"
616
+ if why is not None:
617
+ raise LibraryReloadError(f"{', '.join(names)} does not load: {why}")
618
+
619
+ def _load_file_again(self, path: Path) -> None:
620
+ """Read one changed file of the library again, as strictly as the
621
+ whole library's load reads it: the file must hold exactly the
622
+ class its path names (``parseFile`` says what it holds, without
623
+ loading it), that class is deleted — so it cannot outlive a file
624
+ that fails to load — the file is loaded, and the class must then
625
+ exist. Measured against the library's whole load on a model file,
626
+ a component's and a base class's (the models using it saw the new
627
+ text), and on a syntax error, a renamed class, a wrong ``within``,
628
+ a second class in the file and an empty file: refused each time
629
+ the whole load refuses."""
630
+ session, config = self.session, self.config
631
+ library = str(config.library_name)
632
+ relative = path.relative_to(config.library_dir)
633
+ name = ".".join((library, *relative.with_suffix("").parts))
634
+ file = str(path.resolve()).replace("\\", "/")
635
+
636
+ def refuse(why: str, line: int = 0) -> LibraryReloadError:
637
+ at = f":{line}" if line else ""
638
+ return LibraryReloadError(
639
+ f"{library} does not load: {relative.as_posix()}{at}: {why}"
640
+ )
641
+
642
+ names, error, where = self._classes_in(file)
643
+ if error is not None:
644
+ # The class omc holds is the old file's and stays, and the
645
+ # caller fails the test.
646
+ raise refuse(error, where[1] if where is not None else 0)
647
+ if names != [name]:
648
+ raise refuse(f"it holds {', '.join(names) or 'no class'}, not {name}")
649
+ self._step = f"deleteClass({name})"
650
+ # false for a class omc does not hold: nothing to delete.
651
+ _raw_exec_bool(session, self._step)
652
+ self._step = f'loadFile("{file}")'
653
+ _, error, where = _raw_located(session, self._step)
654
+ if error is None and not _raw_exec_bool(session, f"existClass({name})"):
655
+ error = f"loadFile left no class {name}"
656
+ if error is not None:
657
+ raise refuse(error, where[1] if where is not None else 0)
658
+
659
+ def process_detail(self) -> str:
660
+ omc = getattr(self.session, "omc_process", None)
661
+ proc = getattr(omc, "_omc_process", None)
662
+ if proc is None:
663
+ return ""
664
+ code = proc.poll()
665
+ state = "omc is still running" if code is None else f"omc exited (code {code})"
666
+ if self._step:
667
+ state += f" during {self._step}"
668
+ try:
669
+ log = _last_log_line(omc.get_log() or "")
670
+ except Exception:
671
+ log = ""
672
+ return state + (f"; omc log: {log}" if log else "")
673
+
674
+ def cancel_running(self, test_key: str) -> bool:
675
+ """Kill what omc spawned for the test — make and the compiler during
676
+ the build, the compiled model during the run — and leave omc itself:
677
+ its ``simulate()`` returns a failed record at once.
678
+
679
+ ``False`` while omc has no child (its own frontend and backend
680
+ phases), which only killing omc ends; the watchdog does that, as it
681
+ does when omc has not come back within ``cancel_grace``.
682
+ """
683
+ killed = False
684
+ for child in live_children(self.pids, name_hint=self.kill_name_hint):
685
+ try:
686
+ child.kill()
687
+ killed = True
688
+ except Exception:
689
+ continue # already gone
690
+ return killed
691
+
692
+ def tool_version(self) -> str | None:
693
+ """Actual omc version via ``getVersion()`` (wire format is a quoted
694
+ string, same as an error string). Best-effort → ``None`` on failure."""
695
+ if self.session is None:
696
+ return None
697
+ try:
698
+ raw = self.session.sendExpression("getVersion()", parsed=False)
699
+ except Exception:
700
+ return None
701
+ s = (raw or "").strip()
702
+ if len(s) >= 2 and s.startswith('"') and s.endswith('"'):
703
+ s = s[1:-1]
704
+ s = s.replace('\\"', '"').replace("\\\\", "\\").strip()
705
+ return s or None
706
+
707
+ def library_versions(self) -> dict:
708
+ """Loaded Modelica Standard Library version via ``getVersion(Modelica)``.
709
+
710
+ Ensures MSL is loaded first (``loadModel(Modelica)`` is idempotent) so
711
+ the probe is robust even before any test has pulled it in. Wire format
712
+ is a quoted string. Best-effort → ``{}`` on failure."""
713
+ if self.session is None:
714
+ return {}
715
+ try:
716
+ self.session.sendExpression("loadModel(Modelica)", parsed=False)
717
+ raw = self.session.sendExpression("getVersion(Modelica)", parsed=False)
718
+ except Exception:
719
+ return {}
720
+ s = (raw or "").strip()
721
+ if len(s) >= 2 and s.startswith('"') and s.endswith('"'):
722
+ s = s[1:-1]
723
+ s = s.strip()
724
+ return {"Modelica": s} if s else {}
725
+
726
+ def run_test(
727
+ self,
728
+ test: TestModel,
729
+ test_key: str,
730
+ progress=None,
731
+ ) -> TestRunResult:
732
+ """Run one test in this worker. Returns a :class:`TestRunResult`.
733
+
734
+ Emits ``translating`` / ``simulating`` / ``finalizing`` phase events
735
+ around the single OMPython ``simulate()`` call. The record returned
736
+ by OMPython is a :class:`dict` — no stdout regex parsing needed;
737
+ wall-time splits come from the record's ``timeFrontend`` / etc.
738
+ """
739
+ test_dir = self.config.work_dir / test_key
740
+ # Defensive duplicate of cmd_run's central wipe (D91); kept here so a
741
+ # direct caller of run_tests outside cmd_run still gets clean state.
742
+ if test_dir.exists():
743
+ shutil.rmtree(test_dir)
744
+ test_dir.mkdir(parents=True, exist_ok=True)
745
+
746
+ # The variable filter (the UnitTests component's names included) is
747
+ # build_simulate_args' own business — same call as the batch backend.
748
+ sim_args = build_simulate_args(
749
+ test,
750
+ self.om_config.diagnostic_variables,
751
+ mat_sync=self.om_config.mat_sync,
752
+ )
753
+ simulate_expr = f"simulate({test.model_id}, {', '.join(sim_args)})"
754
+
755
+ # Bind the session once. run_test executes on a thread the watchdog
756
+ # abandons on timeout (`t.join(0.5)`); the pool then restarts this
757
+ # worker and `self.session` becomes a *new* OMCSessionZMQ. An
758
+ # abandoned thread that later unblocks must keep talking to the dead
759
+ # session — re-reading the attribute would let it drive the fresh one
760
+ # concurrently with the next test.
761
+ session = self.session
762
+ start = time.monotonic()
763
+ translation_wall: float | None = None
764
+ sim_wall: float | None = None
765
+ record_dict: dict | None = None
766
+ try:
767
+ test_dir_fwd = str(test_dir).replace("\\", "/")
768
+ # cd() returns the new cwd as a quoted string — raw mode is fine.
769
+ session.sendExpression(f'cd("{test_dir_fwd}")', parsed=False)
770
+
771
+ if progress is not None:
772
+ progress.on_phase(test_key, "translating")
773
+
774
+ self._step = f"simulate({test.model_id})"
775
+ record_dict, _raw_simulate_response = _raw_send_simulate(
776
+ session,
777
+ simulate_expr,
778
+ )
779
+ except Exception as exc:
780
+ elapsed = time.monotonic() - start
781
+ self._n_tests_run += 1
782
+ # Disk-fallback: OM may have written result_res.mat before failing.
783
+ disk_result = self._evaluate_from_disk(test, test_key, elapsed)
784
+ if disk_result.success:
785
+ return disk_result
786
+ return TestRunResult(
787
+ model_id=test.model_id,
788
+ test_key=test_key,
789
+ success=False,
790
+ elapsed=elapsed,
791
+ error_message=f"OMCSessionZMQ error: {type(exc).__name__}: {exc}",
792
+ translation_wall=translation_wall,
793
+ sim_wall=sim_wall,
794
+ )
795
+
796
+ error_string = _raw_get_error_string(session)
797
+ error_notices = [
798
+ line.strip()
799
+ for line in error_string.splitlines()
800
+ if line.strip() and _is_notice_line(line.strip())
801
+ ]
802
+ parsed = _parsed_from_record(record_dict, error_notices)
803
+
804
+ if parsed.timings is not None:
805
+ t = parsed.timings
806
+ translation_wall = (
807
+ t.get("frontend", 0.0)
808
+ + t.get("backend", 0.0)
809
+ + t.get("simcode", 0.0)
810
+ + t.get("templates", 0.0)
811
+ + t.get("compile", 0.0)
812
+ )
813
+ sim_wall = t.get("simulation")
814
+
815
+ if progress is not None:
816
+ progress.on_phase(test_key, "simulating")
817
+
818
+ try:
819
+ (test_dir / OpenModelicaBackend.STDOUT_FILENAME).write_text(
820
+ _synthesize_stdout_artifact(
821
+ self.worker_id,
822
+ simulate_expr,
823
+ record_dict,
824
+ error_string,
825
+ ),
826
+ encoding="utf-8",
827
+ )
828
+ except OSError as exc: # pragma: no cover
829
+ logger.debug(
830
+ "Worker %s: stdout artifact write failed: %s",
831
+ self.worker_id,
832
+ exc,
833
+ )
834
+
835
+ if progress is not None:
836
+ progress.on_phase(test_key, "finalizing")
837
+
838
+ self._n_tests_run += 1
839
+ elapsed = time.monotonic() - start
840
+
841
+ mat_path = test_dir / OpenModelicaBackend.RESULT_MAT_FILENAME
842
+ stats: dict = {}
843
+ if parsed.timings is not None:
844
+ stats["timing"] = dict(parsed.timings)
845
+
846
+ if parsed.success and mat_path.exists():
847
+ # Belt on the accept-integrity invariant (D93 precedent / D99):
848
+ # even a success-reporting simulate() record must show a
849
+ # trajectory that reached stop_time — a partial .mat blessed
850
+ # by --accept poisons the baseline. Same rule + tolerance as
851
+ # the disk rescue below, so the two paths can't disagree.
852
+ reached, last_time = mat_reaches_time(mat_path, float(test.stop_time))
853
+ if reached:
854
+ return TestRunResult(
855
+ model_id=test.model_id,
856
+ test_key=test_key,
857
+ success=True,
858
+ elapsed=elapsed,
859
+ translation_wall=translation_wall,
860
+ sim_wall=sim_wall,
861
+ statistics=stats or None,
862
+ )
863
+ return TestRunResult(
864
+ model_id=test.model_id,
865
+ test_key=test_key,
866
+ success=False,
867
+ elapsed=elapsed,
868
+ error_message=(
869
+ f"simulate() reported success but result_res.mat reaches "
870
+ f"t={last_time!r} of stop_time={test.stop_time} — "
871
+ f"partial result refused"
872
+ ),
873
+ )
874
+
875
+ # Disk-based cross-check — record may have misread success.
876
+ disk_ok = self._evaluate_from_disk(test, test_key, elapsed)
877
+ if disk_ok.success:
878
+ disk_ok.translation_wall = translation_wall
879
+ disk_ok.sim_wall = sim_wall
880
+ disk_ok.statistics = stats or None
881
+ return disk_ok
882
+
883
+ err_parts: list[str] = []
884
+ if error_notices:
885
+ err_parts.append("; ".join(error_notices[:5]))
886
+ if parsed.messages:
887
+ err_parts.append(parsed.messages.strip())
888
+ if not parsed.result_file:
889
+ err_parts.append("no result file produced")
890
+ msg = " | ".join(p for p in err_parts if p)[:2048] or "omc simulation failed"
891
+
892
+ if any("Failed to load package Modelica" in n for n in error_notices):
893
+ msg = (
894
+ "MSL not installed. Run: omc -e "
895
+ "'updatePackageIndex(); installPackage(Modelica); "
896
+ "getErrorString();' | " + msg
897
+ )
898
+
899
+ return TestRunResult(
900
+ model_id=test.model_id,
901
+ test_key=test_key,
902
+ success=False,
903
+ elapsed=elapsed,
904
+ error_message=msg,
905
+ translation_wall=translation_wall,
906
+ sim_wall=sim_wall,
907
+ statistics=stats or None,
908
+ )
909
+
910
+ def _evaluate_from_disk(
911
+ self,
912
+ test: TestModel,
913
+ test_key: str,
914
+ elapsed: float,
915
+ ) -> TestRunResult:
916
+ """Inspect ``result_res.mat`` on disk to produce a TestRunResult.
917
+
918
+ Used as fallback when the in-process ``simulate()`` call couldn't
919
+ finish cleanly (timeout / worker exception). OM doesn't have
920
+ Dymola's ``dsfinal.txt`` completion marker; we use MAT time extents
921
+ only.
922
+ """
923
+ test_dir = self.config.work_dir / test_key
924
+ mat_path = test_dir / OpenModelicaBackend.RESULT_MAT_FILENAME
925
+ if not mat_path.exists():
926
+ return TestRunResult(
927
+ model_id=test.model_id,
928
+ test_key=test_key,
929
+ success=False,
930
+ elapsed=elapsed,
931
+ error_message="No result file produced",
932
+ )
933
+ reached, last_time = mat_reaches_time(mat_path, float(test.stop_time))
934
+ if last_time is None:
935
+ return TestRunResult(
936
+ model_id=test.model_id,
937
+ test_key=test_key,
938
+ success=False,
939
+ elapsed=elapsed,
940
+ error_message="result_res.mat unreadable",
941
+ )
942
+ if reached:
943
+ return TestRunResult(
944
+ model_id=test.model_id,
945
+ test_key=test_key,
946
+ success=True,
947
+ elapsed=elapsed,
948
+ )
949
+ return TestRunResult(
950
+ model_id=test.model_id,
951
+ test_key=test_key,
952
+ success=False,
953
+ elapsed=elapsed,
954
+ error_message=(
955
+ f"Stopped early at T={last_time:.6g} of {float(test.stop_time):.6g}"
956
+ ),
957
+ )
958
+
959
+ def _detach_interface(self) -> object | None:
960
+ s = self.session
961
+ self.session = None
962
+ return s
963
+
964
+ def _shutdown_interface(self, handle: object, grace: float) -> None:
965
+ try:
966
+ handle.sendExpression("quit()") # type: ignore[attr-defined]
967
+ except Exception as exc:
968
+ if type(exc).__name__ != "ZMQError":
969
+ raise
970
+ # Expected on the timeout path: the abandoned exec thread left a
971
+ # simulate() in flight, so the REQ socket is still awaiting its
972
+ # reply and refuses the send with EFSM. The PID kill is the real
973
+ # teardown here; let the template's warning stay for genuinely
974
+ # unexpected failures. The socket is deliberately NOT closed —
975
+ # a zmq socket must not be closed under a thread parked in
976
+ # recv() on it, which is exactly where the exec thread is.
977
+ logger.debug(
978
+ "Worker %s: omc refused the graceful quit() (%s)",
979
+ self.worker_id,
980
+ exc,
981
+ )
982
+
983
+ def is_alive(self) -> bool:
984
+ return self.session is not None and not self.tool_gone()
985
+
986
+
987
+ # ---------------------------------------------------------------------------
988
+
989
+
990
+ class PersistentOpenModelicaBackend(PersistentBackendBase, OpenModelicaBackend):
991
+ """OpenModelica backend using persistent OMCSessionZMQ workers + a queue.
992
+
993
+ Inherits the dispatch-loop machinery from :class:`PersistentBackendBase`
994
+ and the batch :class:`OpenModelicaBackend` for ``read_result`` + config
995
+ extraction. Order matters — ``PersistentBackendBase`` first so its
996
+ ``run_tests`` template wins over the batch backend's per-test override.
997
+ """
998
+
999
+ worker_cls = OpenModelicaWorker
1000
+ backend_label = "OpenModelica"
1001
+
1002
+ #: CANCEL is the worker's: ``OpenModelicaWorker.cancel_running`` (omc's
1003
+ #: children) with the watchdog's kill behind it. The batch class, one
1004
+ #: ``omc`` subprocess per test, has no such stop.
1005
+ capabilities = OpenModelicaBackend.capabilities | {Capability.CANCEL}
1006
+
1007
+ @classmethod
1008
+ def preflight(cls, config) -> None:
1009
+ # Probes OMPython availability (the optional `[om]` extra). Cheap
1010
+ # — load_omc_session caches after first call. RuntimeError carries
1011
+ # the install hint that the CLI surfaces verbatim.
1012
+ # Lazy import inside the method so tests can monkeypatch
1013
+ # ``session_loader.load_omc_session`` and have the patch take
1014
+ # effect here without also having to re-bind this module's
1015
+ # already-imported reference.
1016
+ from .session_loader import load_omc_session as _load
1017
+
1018
+ _load()
1019
+
1020
+ def setup_before_workers(self) -> None:
1021
+ # Cache the OMCSessionZMQ class for make_worker. Called once per run;
1022
+ # load_omc_session is itself cached after the first call.
1023
+ self._session_cls = load_omc_session()
1024
+
1025
+ def make_worker(self, worker_id: int) -> OpenModelicaWorker:
1026
+ return OpenModelicaWorker(
1027
+ worker_id,
1028
+ self.config,
1029
+ self.om_config,
1030
+ self._session_cls,
1031
+ )
1032
+
1033
+ def _probe_worker_version(self, live_workers: list) -> str | None:
1034
+ for w in live_workers:
1035
+ v = w.tool_version()
1036
+ if v:
1037
+ return v
1038
+ return None
1039
+
1040
+ def _probe_library_versions(self, live_workers: list) -> dict | None:
1041
+ for w in live_workers:
1042
+ libs = w.library_versions()
1043
+ if libs:
1044
+ return libs
1045
+ return None