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