dynamic-systems-testing-framework 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dstf/__init__.py +41 -0
- dstf/__main__.py +10 -0
- dstf/authoring/__init__.py +13 -0
- dstf/authoring/builtin/__init__.py +32 -0
- dstf/authoring/builtin/accept.py +205 -0
- dstf/authoring/builtin/edit_criteria.py +479 -0
- dstf/authoring/builtin/edit_family.py +228 -0
- dstf/authoring/builtin/edit_on_plot.py +1354 -0
- dstf/authoring/builtin/edit_tree.py +1399 -0
- dstf/authoring/builtin/enqueue.py +144 -0
- dstf/authoring/capabilities.py +463 -0
- dstf/authoring/errors.py +49 -0
- dstf/authoring/family_rows.py +526 -0
- dstf/authoring/forms.py +631 -0
- dstf/authoring/layout.py +104 -0
- dstf/authoring/live_rows.py +337 -0
- dstf/authoring/live_runs.py +385 -0
- dstf/authoring/needle.py +127 -0
- dstf/authoring/picker_rows.py +92 -0
- dstf/authoring/queue_rows.py +354 -0
- dstf/authoring/session.py +3016 -0
- dstf/authoring/shell/__init__.py +9 -0
- dstf/authoring/shell/actions.py +702 -0
- dstf/authoring/shell/app.py +53 -0
- dstf/authoring/shell/drawer_controls.py +190 -0
- dstf/authoring/shell/family_fold.py +468 -0
- dstf/authoring/shell/figure_hook.py +249 -0
- dstf/authoring/shell/grips.js +1000 -0
- dstf/authoring/shell/header.py +783 -0
- dstf/authoring/shell/live_view.py +93 -0
- dstf/authoring/shell/picker.py +267 -0
- dstf/authoring/shell/placing.py +122 -0
- dstf/authoring/shell/plot_card.py +1171 -0
- dstf/authoring/shell/queue_panel.py +433 -0
- dstf/authoring/shell/rail.py +593 -0
- dstf/authoring/shell/shell.py +528 -0
- dstf/authoring/shell/stats_card.py +501 -0
- dstf/authoring/shell/strip.py +204 -0
- dstf/authoring/shell/style.py +721 -0
- dstf/authoring/shell/suite.py +277 -0
- dstf/authoring/shell/suite_grid.js +414 -0
- dstf/authoring/shell/suite_grid.py +373 -0
- dstf/authoring/shell/tab_theme.py +110 -0
- dstf/authoring/shell/tabstate.py +146 -0
- dstf/authoring/shell/test_list.py +179 -0
- dstf/authoring/shell/test_page.py +1404 -0
- dstf/authoring/shell/tree_card.py +1171 -0
- dstf/authoring/shell/variables_drawer.py +589 -0
- dstf/authoring/shell/widgets.py +188 -0
- dstf/authoring/situation.py +287 -0
- dstf/authoring/spec_document.py +215 -0
- dstf/authoring/spec_preview.py +83 -0
- dstf/authoring/suite_rows.py +99 -0
- dstf/authoring/tree_move.py +516 -0
- dstf/authoring/tree_pointer.py +33 -0
- dstf/authoring/tree_text.py +61 -0
- dstf/authoring/typed_number.js +64 -0
- dstf/authoring/typed_number.py +76 -0
- dstf/authoring/unscorable.py +141 -0
- dstf/authoring/variable_rows.py +216 -0
- dstf/backends/__init__.py +283 -0
- dstf/backends/base.py +1117 -0
- dstf/backends/common/__init__.py +1 -0
- dstf/backends/common/json_driver.py +484 -0
- dstf/backends/common/live_mat.py +369 -0
- dstf/backends/common/mat_reader.py +632 -0
- dstf/backends/common/proc_kill.py +238 -0
- dstf/backends/common/proc_output.py +20 -0
- dstf/backends/common/result_files.py +109 -0
- dstf/backends/common/result_variables.py +329 -0
- dstf/backends/common/statistics.py +39 -0
- dstf/backends/cross_backend.py +193 -0
- dstf/backends/dymola/__init__.py +5 -0
- dstf/backends/dymola/backend.py +776 -0
- dstf/backends/dymola/interface_loader.py +241 -0
- dstf/backends/dymola/log_parser.py +396 -0
- dstf/backends/dymola/persistent_backend.py +949 -0
- dstf/backends/fmpy/__init__.py +11 -0
- dstf/backends/fmpy/backend.py +443 -0
- dstf/backends/julia/__init__.py +27 -0
- dstf/backends/julia/backend.py +156 -0
- dstf/backends/julia/persistent_backend.py +541 -0
- dstf/backends/julia/run_persistent.jl +167 -0
- dstf/backends/julia/run_test.jl +146 -0
- dstf/backends/openmodelica/__init__.py +21 -0
- dstf/backends/openmodelica/backend.py +368 -0
- dstf/backends/openmodelica/init_xml.py +76 -0
- dstf/backends/openmodelica/log_parser.py +119 -0
- dstf/backends/openmodelica/mos_generator.py +228 -0
- dstf/backends/openmodelica/persistent_backend.py +1045 -0
- dstf/backends/openmodelica/session_loader.py +63 -0
- dstf/backends/persistent.py +1811 -0
- dstf/backends/pool.py +602 -0
- dstf/backends/progress.py +395 -0
- dstf/backends/python/__init__.py +11 -0
- dstf/backends/python/backend.py +141 -0
- dstf/backends/python/run_test.py +141 -0
- dstf/backends/run_metadata.py +103 -0
- dstf/backends/types.py +310 -0
- dstf/cli/__init__.py +12 -0
- dstf/cli/author_cmd.py +61 -0
- dstf/cli/baseline_cmds.py +341 -0
- dstf/cli/common.py +231 -0
- dstf/cli/diag_cmds.py +350 -0
- dstf/cli/main.py +561 -0
- dstf/cli/review.py +398 -0
- dstf/cli/run_cmds.py +589 -0
- dstf/cli/spec_cmds.py +123 -0
- dstf/comparison/__init__.py +13 -0
- dstf/comparison/algorithms.py +2127 -0
- dstf/comparison/builtins.py +291 -0
- dstf/comparison/comparator.py +1189 -0
- dstf/comparison/geometry.py +164 -0
- dstf/comparison/handles.py +1194 -0
- dstf/comparison/metric_tree.py +332 -0
- dstf/comparison/mode_schema.py +176 -0
- dstf/comparison/modes.py +788 -0
- dstf/comparison/registry.py +230 -0
- dstf/comparison/seeds.py +281 -0
- dstf/comparison/sides.py +158 -0
- dstf/comparison/snap.py +62 -0
- dstf/comparison/strict_keys.py +246 -0
- dstf/comparison/summaries.py +354 -0
- dstf/comparison/tree_eval.py +868 -0
- dstf/comparison/tree_spec.py +678 -0
- dstf/comparison/types.py +194 -0
- dstf/comparison/validator.py +232 -0
- dstf/config.py +815 -0
- dstf/discovery/__init__.py +0 -0
- dstf/discovery/json_recognizer.py +702 -0
- dstf/discovery/library_state.py +248 -0
- dstf/discovery/mo_parser.py +794 -0
- dstf/discovery/patch_apply.py +308 -0
- dstf/discovery/patch_envelope.py +63 -0
- dstf/discovery/plots.py +318 -0
- dstf/discovery/recognizer.py +103 -0
- dstf/discovery/scan_cache.py +154 -0
- dstf/discovery/spec_parser.py +538 -0
- dstf/discovery/spec_shape.py +122 -0
- dstf/discovery/spec_update.py +493 -0
- dstf/discovery/test_registry.py +488 -0
- dstf/discovery/unit_tests.py +95 -0
- dstf/figures/__init__.py +14 -0
- dstf/figures/assemble.py +48 -0
- dstf/figures/decimate.py +150 -0
- dstf/figures/families.py +289 -0
- dstf/figures/palette.py +170 -0
- dstf/figures/plotly.py +1413 -0
- dstf/figures/view.py +233 -0
- dstf/pipeline.py +435 -0
- dstf/plugins.py +104 -0
- dstf/reporting/__init__.py +0 -0
- dstf/reporting/console_report.py +187 -0
- dstf/reporting/context_builder.py +979 -0
- dstf/reporting/dashboard_render.py +534 -0
- dstf/reporting/junit_report.py +192 -0
- dstf/reporting/overlay_loader.py +708 -0
- dstf/reporting/report_suite.py +1059 -0
- dstf/reporting/rows.py +458 -0
- dstf/reporting/schema_export.py +413 -0
- dstf/reporting/stats_rows.py +321 -0
- dstf/reporting/status.py +388 -0
- dstf/reporting/templates/axes.js +61 -0
- dstf/reporting/templates/dashboard.html +284 -0
- dstf/reporting/templates/dashboard.js +604 -0
- dstf/reporting/templates/filters.js +100 -0
- dstf/reporting/templates/interactive.html +420 -0
- dstf/reporting/templates/report.js +116 -0
- dstf/reporting/templates/vendor/plotly.min.js +8 -0
- dstf/reporting/theme.py +324 -0
- dstf/reporting/tree_rows.py +73 -0
- dstf/reporting/verdicts.py +124 -0
- dstf/storage/__init__.py +0 -0
- dstf/storage/accept.py +216 -0
- dstf/storage/baseline.py +43 -0
- dstf/storage/batch_manifest.py +204 -0
- dstf/storage/ref_index.py +295 -0
- dstf/storage/reference_store.py +661 -0
- dstf/storage/roles.py +576 -0
- dstf/storage/run_stats.py +85 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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
|