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,11 @@
|
|
|
1
|
+
"""FMPy backend.
|
|
2
|
+
|
|
3
|
+
Phase 2 adds FMPy as the second backend — the first that exercises the
|
|
4
|
+
``Capability`` + ``DatasetType`` + ``Baseline`` abstractions declared on the
|
|
5
|
+
``Backend`` ABC. Importing this module registers ``FmpyBackend``
|
|
6
|
+
under the name ``"FMPy"``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .backend import FmpyBackend
|
|
10
|
+
|
|
11
|
+
__all__ = ["FmpyBackend"]
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""FMPy-backed backend.
|
|
2
|
+
|
|
3
|
+
Consumes prebuilt FMUs via the FMPy Python library. Produces the same
|
|
4
|
+
``TestResult`` / ``VariableResult`` shape the Dymola backend produces, so the
|
|
5
|
+
comparator, storage, and reporting layers work unchanged.
|
|
6
|
+
|
|
7
|
+
Simulation flow per test (``run_single_test``):
|
|
8
|
+
1. Resolve the FMU path from ``test.source_file`` (spec_parser puts it
|
|
9
|
+
there when the spec entry has a ``"source"`` field).
|
|
10
|
+
2. Read the model description to discover scalar variables.
|
|
11
|
+
3. Resolve ``test.variable_patterns`` against the declared names.
|
|
12
|
+
4. Call ``fmpy.simulate_fmu`` with the resolved settings.
|
|
13
|
+
5. Persist the returned structured array to ``<test_dir>/result.npz`` so a
|
|
14
|
+
later ``compare`` pass can re-read without re-simulating — matches the
|
|
15
|
+
on-disk pattern the Dymola backend uses for ``dsres.mat``.
|
|
16
|
+
|
|
17
|
+
``read_result`` loads the .npz and builds a ``TestResult``.
|
|
18
|
+
|
|
19
|
+
Limitations (D65 — "FMU-path semantic gap"):
|
|
20
|
+
The current ``fmpy.simulate_fmu`` call passes only ``filename`` /
|
|
21
|
+
``stop_time`` / ``solver`` / ``relative_tolerance`` / ``output`` /
|
|
22
|
+
``output_interval``. Specifically, the following are **not yet supported**:
|
|
23
|
+
- ``input=`` — no way to drive an external input schedule into the FMU.
|
|
24
|
+
- ``fmi_type=`` — FMPy auto-picks (co-simulation if present, else
|
|
25
|
+
model-exchange); tests cannot select explicitly.
|
|
26
|
+
- ``start_values=`` — cannot override FMU default start values from a
|
|
27
|
+
test spec.
|
|
28
|
+
- "Python-driver" tests — where the *test* is a script that steps the
|
|
29
|
+
FMU and sets inputs over time, rather than a single ``simulate_fmu``
|
|
30
|
+
call.
|
|
31
|
+
This pathway is therefore scoped to **autonomous FMUs** (inputs baked in,
|
|
32
|
+
co-sim/ME distinction irrelevant to the test's semantics). The Reference
|
|
33
|
+
FMUs shipped under ``examples/fmu/`` (BouncingBall / Dahlquist / VanDerPol)
|
|
34
|
+
are the validated shape. Generalizing beyond autonomous FMUs is deferred
|
|
35
|
+
to a dedicated future phase.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import concurrent.futures
|
|
41
|
+
import logging
|
|
42
|
+
import time
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
import numpy as np
|
|
46
|
+
|
|
47
|
+
from ...config import Config
|
|
48
|
+
from ...discovery.test_registry import TestModel
|
|
49
|
+
from ...discovery.unit_tests import unit_test_variables
|
|
50
|
+
from .. import register
|
|
51
|
+
from ..base import Backend
|
|
52
|
+
from ..common import result_files
|
|
53
|
+
from ..common.result_variables import criterion_variables
|
|
54
|
+
from ..types import (
|
|
55
|
+
Capability,
|
|
56
|
+
DatasetType,
|
|
57
|
+
TestResult,
|
|
58
|
+
TestRunResult,
|
|
59
|
+
VariableResult,
|
|
60
|
+
resolve_variable_patterns,
|
|
61
|
+
timeout_message,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
logger = logging.getLogger(__name__)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# FMPy solver names. Dymola's default ``Dassl`` maps to FMPy's ``CVode``
|
|
68
|
+
# (both BDF-family; closest semantic match). ``Euler`` is also available.
|
|
69
|
+
_SOLVER_MAP = {
|
|
70
|
+
"Dassl": "CVode",
|
|
71
|
+
"Cvode": "CVode",
|
|
72
|
+
"CVode": "CVode",
|
|
73
|
+
"Euler": "Euler",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@register("FMPy")
|
|
78
|
+
class FmpyBackend(Backend):
|
|
79
|
+
"""Runs FMU simulations using the FMPy Python library.
|
|
80
|
+
|
|
81
|
+
Consumes prebuilt FMUs — does not compile from Modelica sources. For
|
|
82
|
+
Modelica → FMU conversion, a backend declaring ``Capability.FMU_EXPORT``
|
|
83
|
+
(today: :class:`PersistentDymolaBackend`) chains into this one for
|
|
84
|
+
cross-backend verification — see ``backends/cross_backend.py``.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
# Deliberately empty:
|
|
88
|
+
# PERSISTENT_WORKERS — review 2026-07-06 (finding 28): the flag was
|
|
89
|
+
# aspirational (no persistent FMPy backend exists) and tripped the
|
|
90
|
+
# repaired capability-honesty check. Re-add when one actually ships.
|
|
91
|
+
# BATCH_FALLBACK — FMPy *is* the Python path; no script fallback exists
|
|
92
|
+
# FMU_EXPORT — FMPy consumes FMUs, doesn't produce them
|
|
93
|
+
capabilities: frozenset[Capability] = frozenset()
|
|
94
|
+
produced_datasets = frozenset({DatasetType.TIME_SERIES})
|
|
95
|
+
artifact_files = (("result.npz", "Result file"),)
|
|
96
|
+
|
|
97
|
+
#: Filename used for the on-disk structured-array cache per test.
|
|
98
|
+
RESULT_FILENAME = "result.npz"
|
|
99
|
+
|
|
100
|
+
def __init__(self, config: Config):
|
|
101
|
+
super().__init__(config)
|
|
102
|
+
# Defer the fmpy import so a bare import of this module doesn't error
|
|
103
|
+
# when fmpy isn't installed — the backend only errors if actually used.
|
|
104
|
+
try:
|
|
105
|
+
import fmpy # noqa: F401
|
|
106
|
+
except ImportError as exc:
|
|
107
|
+
# RuntimeError, not ImportError: `Backend.preflight` names it as
|
|
108
|
+
# the contract for "this backend cannot be built here", and it is
|
|
109
|
+
# what `backends._instantiate` translates into
|
|
110
|
+
# `BackendUnavailableError`. The OpenModelica and Dymola
|
|
111
|
+
# dependency probes convert the same way.
|
|
112
|
+
raise RuntimeError(
|
|
113
|
+
"The FMPy backend requires the optional 'fmpy' extra.\n"
|
|
114
|
+
" source checkout: uv sync --extra fmpy --locked --inexact\n"
|
|
115
|
+
" installed tool: reinstall with the [fmpy] extra"
|
|
116
|
+
) from exc
|
|
117
|
+
|
|
118
|
+
def describe_tool_version(self) -> str | None:
|
|
119
|
+
"""The fmpy library version drives simulation, so it's the run's
|
|
120
|
+
'tool version'. (Per-FMU ``generationTool`` metadata is richer but
|
|
121
|
+
varies test-to-test; the library version is the run-level constant.)"""
|
|
122
|
+
try:
|
|
123
|
+
import fmpy
|
|
124
|
+
|
|
125
|
+
return f"FMPy {fmpy.__version__}"
|
|
126
|
+
except Exception: # pragma: no cover — __init__ already validated import
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
# Simulation
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def run_single_test(
|
|
134
|
+
self,
|
|
135
|
+
test: TestModel,
|
|
136
|
+
test_key: str,
|
|
137
|
+
index: int,
|
|
138
|
+
total: int,
|
|
139
|
+
) -> TestRunResult:
|
|
140
|
+
import fmpy
|
|
141
|
+
|
|
142
|
+
fmu_path = _resolve_fmu_path(test)
|
|
143
|
+
if fmu_path is None or not fmu_path.exists():
|
|
144
|
+
return TestRunResult(
|
|
145
|
+
model_id=test.model_id,
|
|
146
|
+
test_key=test_key,
|
|
147
|
+
success=False,
|
|
148
|
+
error_message=(
|
|
149
|
+
f"FMU file not found for {test.model_id}. "
|
|
150
|
+
f"Ensure the test_spec.json entry has a 'source' field "
|
|
151
|
+
f"— a path relative to the spec file — resolving to an "
|
|
152
|
+
f"existing .fmu file."
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
test_dir = self.config.work_dir / test_key
|
|
157
|
+
test_dir.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
|
|
159
|
+
if self.progress:
|
|
160
|
+
self.progress.on_start(test_key)
|
|
161
|
+
|
|
162
|
+
timeout = float(
|
|
163
|
+
test.timeout if test.timeout is not None else self.config.timeout
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
wall_start = time.monotonic()
|
|
167
|
+
try:
|
|
168
|
+
if self.progress:
|
|
169
|
+
self.progress.on_phase(test_key, "loading")
|
|
170
|
+
md = fmpy.read_model_description(str(fmu_path))
|
|
171
|
+
available = _list_scalar_variables(md)
|
|
172
|
+
requested = _tracked(
|
|
173
|
+
test,
|
|
174
|
+
_resolve_requested_outputs(test, available),
|
|
175
|
+
available,
|
|
176
|
+
self.config.diagnostic_variables,
|
|
177
|
+
)
|
|
178
|
+
if requested and not test.variable_patterns:
|
|
179
|
+
# No patterns: what fmpy records by default stays recorded
|
|
180
|
+
# beside the UnitTests and criteria variables — requesting
|
|
181
|
+
# those alone took the FMU's outputs off the plot. Only the
|
|
182
|
+
# tracked are read (`read_result`).
|
|
183
|
+
requested = list(
|
|
184
|
+
dict.fromkeys([*_default_outputs(fmpy, md), *requested])
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
solver = _SOLVER_MAP.get(test.method, "CVode")
|
|
188
|
+
sim_kwargs = {
|
|
189
|
+
"filename": str(fmu_path),
|
|
190
|
+
"validate": False, # trust Reference-FMUs; validate adds startup cost
|
|
191
|
+
"stop_time": test.stop_time,
|
|
192
|
+
"solver": solver,
|
|
193
|
+
"relative_tolerance": test.tolerance,
|
|
194
|
+
# None records fmpy's default set (0.3.29's `Recorder` ->
|
|
195
|
+
# `_get_output_variables`: outputs, else up to five states,
|
|
196
|
+
# else local variables) — never every variable. Empty:
|
|
197
|
+
# nothing is compared, so what fmpy picks is only plotted.
|
|
198
|
+
"output": requested or None,
|
|
199
|
+
}
|
|
200
|
+
if test.output_interval is not None:
|
|
201
|
+
sim_kwargs["output_interval"] = test.output_interval
|
|
202
|
+
elif test.number_of_intervals is not None and test.number_of_intervals > 0:
|
|
203
|
+
sim_kwargs["output_interval"] = (
|
|
204
|
+
test.stop_time / test.number_of_intervals
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if self.progress:
|
|
208
|
+
self.progress.on_phase(test_key, "simulating")
|
|
209
|
+
|
|
210
|
+
# FMPy runs in-process; can't kill the C-level FMU compute. Run in a
|
|
211
|
+
# thread and treat result(timeout=...) as the deadline. On timeout
|
|
212
|
+
# the worker thread is left to finish in background — acceptable for
|
|
213
|
+
# the typical sub-second FMU; documented trade-off.
|
|
214
|
+
executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
|
215
|
+
future = executor.submit(fmpy.simulate_fmu, **sim_kwargs)
|
|
216
|
+
try:
|
|
217
|
+
result = future.result(timeout=timeout)
|
|
218
|
+
except concurrent.futures.TimeoutError:
|
|
219
|
+
elapsed = time.monotonic() - wall_start
|
|
220
|
+
msg = timeout_message(
|
|
221
|
+
f"FMPy simulation exceeded {timeout}s timeout; the worker "
|
|
222
|
+
f"thread continues until the FMU returns and can't be "
|
|
223
|
+
f"force-killed in-process"
|
|
224
|
+
)
|
|
225
|
+
logger.warning("Test %s: %s", test.model_id, msg)
|
|
226
|
+
if self.progress:
|
|
227
|
+
self.progress.on_finish(
|
|
228
|
+
test_key,
|
|
229
|
+
success=False,
|
|
230
|
+
elapsed=elapsed,
|
|
231
|
+
detail=msg,
|
|
232
|
+
timed_out=True,
|
|
233
|
+
)
|
|
234
|
+
return TestRunResult(
|
|
235
|
+
model_id=test.model_id,
|
|
236
|
+
test_key=test_key,
|
|
237
|
+
success=False,
|
|
238
|
+
elapsed=elapsed,
|
|
239
|
+
error_message=msg,
|
|
240
|
+
sim_wall=elapsed,
|
|
241
|
+
# review 2026-07-06 (finding 73): without this flag the
|
|
242
|
+
# summary counted the timeout as a plain failure while
|
|
243
|
+
# the dashboard (progress) showed timed_out — disagreeing.
|
|
244
|
+
timed_out=True,
|
|
245
|
+
)
|
|
246
|
+
finally:
|
|
247
|
+
executor.shutdown(wait=False)
|
|
248
|
+
|
|
249
|
+
# review 2026-07-06 (finding 73): save INSIDE the try so an
|
|
250
|
+
# OSError (disk full, permissions) fails this one test via the
|
|
251
|
+
# handler below instead of propagating and aborting the run.
|
|
252
|
+
_save_result(test_dir / self.RESULT_FILENAME, result)
|
|
253
|
+
|
|
254
|
+
except Exception as exc: # FMPy raises various exception types
|
|
255
|
+
elapsed = time.monotonic() - wall_start
|
|
256
|
+
msg = f"FMPy simulation failed: {type(exc).__name__}: {exc}"
|
|
257
|
+
logger.warning("Test %s: %s", test.model_id, msg)
|
|
258
|
+
if self.progress:
|
|
259
|
+
self.progress.on_finish(
|
|
260
|
+
test_key,
|
|
261
|
+
success=False,
|
|
262
|
+
elapsed=elapsed,
|
|
263
|
+
detail=msg,
|
|
264
|
+
)
|
|
265
|
+
return TestRunResult(
|
|
266
|
+
model_id=test.model_id,
|
|
267
|
+
test_key=test_key,
|
|
268
|
+
success=False,
|
|
269
|
+
elapsed=elapsed,
|
|
270
|
+
error_message=msg,
|
|
271
|
+
sim_wall=elapsed,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
elapsed = time.monotonic() - wall_start
|
|
275
|
+
|
|
276
|
+
if self.progress:
|
|
277
|
+
self.progress.on_finish(test_key, success=True, elapsed=elapsed)
|
|
278
|
+
|
|
279
|
+
return TestRunResult(
|
|
280
|
+
model_id=test.model_id,
|
|
281
|
+
test_key=test_key,
|
|
282
|
+
success=True,
|
|
283
|
+
elapsed=elapsed,
|
|
284
|
+
sim_wall=elapsed,
|
|
285
|
+
statistics={"simulation": {"wall_time": elapsed}},
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# ------------------------------------------------------------------
|
|
289
|
+
# Reading
|
|
290
|
+
# ------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
def read_result(
|
|
293
|
+
self,
|
|
294
|
+
test: TestModel,
|
|
295
|
+
test_key: str,
|
|
296
|
+
run_result: TestRunResult,
|
|
297
|
+
) -> TestResult:
|
|
298
|
+
result_path = self.config.work_dir / test_key / self.RESULT_FILENAME
|
|
299
|
+
if not result_path.exists():
|
|
300
|
+
return TestResult(
|
|
301
|
+
model_id=test.model_id,
|
|
302
|
+
success=False,
|
|
303
|
+
error_message=f"No FMPy result at {result_path} (did simulation run?)",
|
|
304
|
+
statistics=run_result.statistics if run_result else None,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
arr = _load_result(result_path)
|
|
308
|
+
available = [name for name in arr.dtype.names if name != "time"]
|
|
309
|
+
|
|
310
|
+
# Resolve patterns (spec can use globs like "h" or "*"); for Modelica
|
|
311
|
+
# flows we re-use the same mechanism as DymolaBackend.
|
|
312
|
+
requested = resolve_variable_patterns(test.variable_patterns, available)
|
|
313
|
+
if not requested:
|
|
314
|
+
# Default: all non-time columns (parity with Dymola default behavior
|
|
315
|
+
# when variables=[] is "simulate only" and variables=["*"] is "all").
|
|
316
|
+
if "*" in test.variable_patterns:
|
|
317
|
+
requested = available
|
|
318
|
+
elif not test.variable_patterns:
|
|
319
|
+
requested = []
|
|
320
|
+
requested = _tracked(
|
|
321
|
+
test, requested, available, self.config.diagnostic_variables
|
|
322
|
+
)
|
|
323
|
+
# Loud on every backend (`extract_variables`): a variable the
|
|
324
|
+
# UnitTests or a criterion names that the saved columns lack is a
|
|
325
|
+
# spec/model mismatch, not something to swallow.
|
|
326
|
+
for name in dict.fromkeys(
|
|
327
|
+
[
|
|
328
|
+
*(v.name for v in unit_test_variables(test)),
|
|
329
|
+
*criterion_variables(test, self.config.diagnostic_variables),
|
|
330
|
+
]
|
|
331
|
+
):
|
|
332
|
+
if name not in requested:
|
|
333
|
+
logger.warning(
|
|
334
|
+
"Variable %s not found in simulation output for %s",
|
|
335
|
+
name,
|
|
336
|
+
test.model_id,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
time_arr = np.asarray(arr["time"], dtype=np.float64)
|
|
340
|
+
variables = [
|
|
341
|
+
VariableResult(
|
|
342
|
+
index=i + 1,
|
|
343
|
+
name=name,
|
|
344
|
+
time=time_arr,
|
|
345
|
+
values=np.asarray(arr[name], dtype=np.float64),
|
|
346
|
+
)
|
|
347
|
+
for i, name in enumerate(requested)
|
|
348
|
+
]
|
|
349
|
+
|
|
350
|
+
return TestResult(
|
|
351
|
+
model_id=test.model_id,
|
|
352
|
+
success=True,
|
|
353
|
+
variables=variables,
|
|
354
|
+
diagnostics=[], # FMPy doesn't surface CPUtime/EventCounter equivalents today
|
|
355
|
+
statistics=run_result.statistics if run_result else None,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
@classmethod
|
|
359
|
+
def result_names(cls, test_dir: Path) -> list[str] | None:
|
|
360
|
+
return result_files.npz_names(test_dir / cls.RESULT_FILENAME)
|
|
361
|
+
|
|
362
|
+
@classmethod
|
|
363
|
+
def result_series(cls, test_dir, names):
|
|
364
|
+
return result_files.npz_series(test_dir / cls.RESULT_FILENAME, names)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# ---------------------------------------------------------------------------
|
|
368
|
+
# Helpers (free functions for testability)
|
|
369
|
+
# ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _resolve_fmu_path(test: TestModel) -> Path | None:
|
|
373
|
+
"""Get the FMU binary path for a test.
|
|
374
|
+
|
|
375
|
+
spec_parser stores the resolved path in ``test.source_file`` when the
|
|
376
|
+
spec entry has a ``"source"`` field.
|
|
377
|
+
"""
|
|
378
|
+
p = test.source_file
|
|
379
|
+
return p if p and str(p) else None
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _list_scalar_variables(md) -> list[str]:
|
|
383
|
+
"""Return all scalar variable names from an FMPy model description."""
|
|
384
|
+
return [sv.name for sv in md.modelVariables]
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _resolve_requested_outputs(test: TestModel, available: list[str]) -> list[str]:
|
|
388
|
+
"""Resolve the test's variable patterns against the FMU's declared variables.
|
|
389
|
+
|
|
390
|
+
When the spec asks for everything (``"*"``), every declared variable is
|
|
391
|
+
requested explicitly: FMPy's ``output=None`` records the OUTPUTS only.
|
|
392
|
+
"""
|
|
393
|
+
if not test.variable_patterns:
|
|
394
|
+
return []
|
|
395
|
+
if test.variable_patterns == ["*"]:
|
|
396
|
+
return available
|
|
397
|
+
return resolve_variable_patterns(test.variable_patterns, available)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _default_outputs(fmpy, md) -> list[str]:
|
|
401
|
+
"""What fmpy records for ``output=None``: its own rule
|
|
402
|
+
(`fmpy.simulation._get_output_variables`, private — asked, not copied,
|
|
403
|
+
so it cannot drift from what fmpy does)."""
|
|
404
|
+
return [v.name for v in fmpy.simulation._get_output_variables(md)]
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _tracked(
|
|
408
|
+
test: TestModel,
|
|
409
|
+
requested: list[str],
|
|
410
|
+
available: list[str],
|
|
411
|
+
diagnostic_vars: list[str],
|
|
412
|
+
) -> list[str]:
|
|
413
|
+
"""The tracked names, in Dymola's and OpenModelica's order
|
|
414
|
+
(`extract_variables`): the UnitTests variables ``available`` has,
|
|
415
|
+
then ``requested`` (the resolved patterns), then the variables a
|
|
416
|
+
criterion names that ``available`` has — so indices 1..N are the
|
|
417
|
+
UnitTests variables, as a stored baseline records them. The simulate side
|
|
418
|
+
(``available``: the model description's variables) and the read side
|
|
419
|
+
(the saved columns) both go through it, so what is tracked is what is
|
|
420
|
+
read."""
|
|
421
|
+
have = set(available)
|
|
422
|
+
out = [v.name for v in unit_test_variables(test) if v.name in have]
|
|
423
|
+
out += [name for name in requested if name not in out]
|
|
424
|
+
for name in criterion_variables(test, diagnostic_vars):
|
|
425
|
+
if name in have and name not in out:
|
|
426
|
+
out.append(name)
|
|
427
|
+
return out
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _save_result(path: Path, arr: np.ndarray) -> None:
|
|
431
|
+
"""Save an FMPy structured array to an .npz file.
|
|
432
|
+
|
|
433
|
+
FMPy's output is a structured record array. We keep the structured dtype
|
|
434
|
+
to preserve column names, using ``np.savez_compressed`` for a small
|
|
435
|
+
on-disk footprint.
|
|
436
|
+
"""
|
|
437
|
+
np.savez_compressed(path, result=arr)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _load_result(path: Path) -> np.ndarray:
|
|
441
|
+
"""Load a structured array previously saved by ``_save_result``."""
|
|
442
|
+
with np.load(path, allow_pickle=False) as npz:
|
|
443
|
+
return npz["result"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Julia / ModelingToolkit backend (D77).
|
|
2
|
+
|
|
3
|
+
Fourth :class:`Backend` alongside Dymola, FMPy, and OpenModelica.
|
|
4
|
+
Invokes a framework-supplied Julia driver script (``run_test.jl``) via
|
|
5
|
+
subprocess; the driver loads the user's ``.jl`` file (which exports
|
|
6
|
+
``build_mtk_system()``), simulates the MTK system via ``OrdinaryDiffEq``,
|
|
7
|
+
and writes a JSON result file the backend reads back.
|
|
8
|
+
|
|
9
|
+
That subprocess-per-test path is the batch lane, reached with ``--batch``.
|
|
10
|
+
The default is :class:`~.persistent_backend.PersistentJuliaBackend` (D78),
|
|
11
|
+
which keeps long-lived stdin-driven Julia workers alive so the package-load
|
|
12
|
+
and JIT cost is paid once per worker instead of once per test. Startup cost
|
|
13
|
+
is front-loaded either way: Julia's first-run precompile of MTK +
|
|
14
|
+
OrdinaryDiffEq is minutes; subsequent runs are seconds. Contrast Dymola / OM
|
|
15
|
+
which paid the library-load cost on EVERY test in batch mode.
|
|
16
|
+
|
|
17
|
+
Not yet supported:
|
|
18
|
+
* Dyad (compiles to MTK — likely works via the same path, not tested).
|
|
19
|
+
* FMU export from MTK (``ModelingToolkit.generate_fmu``).
|
|
20
|
+
* Per-test ``start_values`` overrides (user authors inside the ``.jl``).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from .backend import JuliaBackend, JuliaConfig # noqa: F401
|
|
26
|
+
|
|
27
|
+
__all__ = ["JuliaBackend", "JuliaConfig"]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Julia / ModelingToolkit subprocess backend (D77).
|
|
2
|
+
|
|
3
|
+
Invokes a shipped driver script (``run_test.jl``) via ``julia --project``.
|
|
4
|
+
The driver loads the user's ``.jl`` file — which exports ``build_mtk_system()``
|
|
5
|
+
returning a ``NamedTuple{sys, u0, ps}`` — simulates via ``OrdinaryDiffEq``,
|
|
6
|
+
and writes a JSON result. The run/read machinery is the shared JSON-driver
|
|
7
|
+
skeleton (:class:`~..common.json_driver.JsonDriverBackend`); only the Julia
|
|
8
|
+
specifics live here.
|
|
9
|
+
|
|
10
|
+
This is the batch path — one Julia subprocess per test — reached with
|
|
11
|
+
``--batch``. The default is :class:`~.persistent_backend.PersistentJuliaBackend`
|
|
12
|
+
(D78), which keeps long-lived stdin-driven Julia workers alive so the
|
|
13
|
+
package-load and JIT cost is paid once per worker. On the batch path that
|
|
14
|
+
cost is paid per test: Julia's first-run JIT + precompile is several minutes
|
|
15
|
+
for MTK + OrdinaryDiffEq, seconds per test afterwards.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from ...config import Config
|
|
27
|
+
from .. import register
|
|
28
|
+
from ..common.json_driver import JsonDriverBackend
|
|
29
|
+
from ..types import Capability, DatasetType
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Shipped driver script; copied/resolved at runtime.
|
|
35
|
+
_DRIVER_PATH = Path(__file__).resolve().parent / "run_test.jl"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class JuliaConfig:
|
|
40
|
+
"""Julia-specific settings extracted from the unified :class:`Config`.
|
|
41
|
+
|
|
42
|
+
``julia_binary`` is the absolute path to the ``julia`` executable.
|
|
43
|
+
``project_dir`` is the Julia project directory (contains ``Project.toml``
|
|
44
|
+
and ``Manifest.toml``); defaults to the same directory the test sources
|
|
45
|
+
live in (``config.source_path``). That way a user ships a self-contained
|
|
46
|
+
Julia project with MTK pinned.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
julia_binary: Path
|
|
50
|
+
project_dir: Path
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_config(cls, config: Config) -> JuliaConfig:
|
|
54
|
+
# Config.backend_path is already resolved in __post_init__ via the
|
|
55
|
+
# backends map + PATH fallback. If that lookup didn't produce
|
|
56
|
+
# anything (e.g., user omitted the path and PATH doesn't include
|
|
57
|
+
# julia), try shutil.which() once more before giving up.
|
|
58
|
+
resolved: Path | None = None
|
|
59
|
+
if config.backend_path:
|
|
60
|
+
p = Path(config.backend_path).expanduser()
|
|
61
|
+
if p.exists():
|
|
62
|
+
resolved = p
|
|
63
|
+
if resolved is None:
|
|
64
|
+
on_path = shutil.which("julia")
|
|
65
|
+
if on_path:
|
|
66
|
+
resolved = Path(on_path)
|
|
67
|
+
if resolved is None:
|
|
68
|
+
raise RuntimeError(
|
|
69
|
+
"Julia binary not found. Install Julia (https://julialang.org/"
|
|
70
|
+
"downloads/) and ensure 'julia' is on PATH, or set an explicit "
|
|
71
|
+
"path under testing.json's 'backends' map."
|
|
72
|
+
)
|
|
73
|
+
project = config.source_path or Path.cwd()
|
|
74
|
+
return cls(julia_binary=resolved, project_dir=project)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@register("Julia")
|
|
78
|
+
class JuliaBackend(JsonDriverBackend):
|
|
79
|
+
"""Subprocess-per-test Julia/MTK backend."""
|
|
80
|
+
|
|
81
|
+
capabilities = frozenset(
|
|
82
|
+
{
|
|
83
|
+
Capability.BATCH_FALLBACK, # subprocess-per-test (this class)
|
|
84
|
+
Capability.PERSISTENT_WORKERS, # via PersistentJuliaBackend (D78)
|
|
85
|
+
# Deliberately absent:
|
|
86
|
+
# FMU_EXPORT — MTK.generate_fmu deferred
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
produced_datasets = frozenset({DatasetType.TIME_SERIES})
|
|
90
|
+
artifact_files = (
|
|
91
|
+
("result.json", "Simulation result (time + variables)"),
|
|
92
|
+
("julia_stdout.txt", "Julia stdout"),
|
|
93
|
+
("julia_stderr.txt", "Julia stderr"),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
driver_path = _DRIVER_PATH
|
|
97
|
+
tool_label = "Julia"
|
|
98
|
+
action_label = "simulation"
|
|
99
|
+
stdio_prefix = "julia"
|
|
100
|
+
source_suffix = ".jl"
|
|
101
|
+
|
|
102
|
+
def __init__(self, config: Config):
|
|
103
|
+
super().__init__(config)
|
|
104
|
+
# Resolve once so run_single_test errors are loud + early when
|
|
105
|
+
# the binary is missing; lets --filter walk the test set quickly.
|
|
106
|
+
self.julia_config = JuliaConfig.from_config(config)
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def persistent_backend_cls(cls):
|
|
110
|
+
from .persistent_backend import PersistentJuliaBackend
|
|
111
|
+
|
|
112
|
+
return PersistentJuliaBackend
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def tool_binary(self) -> Path:
|
|
116
|
+
return self.julia_config.julia_binary
|
|
117
|
+
|
|
118
|
+
def build_command(
|
|
119
|
+
self,
|
|
120
|
+
binary: Path,
|
|
121
|
+
driver: Path,
|
|
122
|
+
user_file: Path,
|
|
123
|
+
stop_time: float,
|
|
124
|
+
tolerance: float,
|
|
125
|
+
result_path: Path,
|
|
126
|
+
) -> list[str]:
|
|
127
|
+
return [
|
|
128
|
+
str(binary),
|
|
129
|
+
f"--project={self.julia_config.project_dir}",
|
|
130
|
+
"--startup-file=no",
|
|
131
|
+
"--color=no",
|
|
132
|
+
str(driver),
|
|
133
|
+
str(user_file),
|
|
134
|
+
str(stop_time),
|
|
135
|
+
str(tolerance),
|
|
136
|
+
str(result_path),
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
def describe_tool_version(self) -> str | None:
|
|
140
|
+
"""Version of the configured ``julia`` binary. It is a property of the
|
|
141
|
+
binary, not of a running session, so both the batch and the persistent
|
|
142
|
+
lane get it from here — the persistent template stamps provenance
|
|
143
|
+
through ``describe_tool_version`` before its workers are up, so no
|
|
144
|
+
``_probe_worker_version`` override is needed. Best-effort: ``None`` on
|
|
145
|
+
any failure."""
|
|
146
|
+
try:
|
|
147
|
+
out = subprocess.run(
|
|
148
|
+
[str(self.julia_config.julia_binary), "--version"],
|
|
149
|
+
capture_output=True,
|
|
150
|
+
text=True,
|
|
151
|
+
timeout=15,
|
|
152
|
+
)
|
|
153
|
+
banner = (out.stdout or out.stderr or "").strip()
|
|
154
|
+
return banner or None
|
|
155
|
+
except Exception:
|
|
156
|
+
return None
|