dynamic-systems-testing-framework 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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