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,241 @@
|
|
|
1
|
+
"""Locate and load Dymola's Python interface (DymolaInterface).
|
|
2
|
+
|
|
3
|
+
Dymola ships its Python integration under
|
|
4
|
+
<Dymola>/Modelica/Library/python_interface/
|
|
5
|
+
as either `dymola.egg` (older) or `dymola-<version>-py3-none-any.whl` (newer).
|
|
6
|
+
|
|
7
|
+
Both are zip archives. `.egg` is directly importable via Python's zipimport by
|
|
8
|
+
appending to `sys.path`. `.whl` is not — Python only imports `.whl` content
|
|
9
|
+
after `pip install` extracts it. To avoid a per-Dymola-version install step,
|
|
10
|
+
we extract the wheel once to a user cache dir and add that dir to `sys.path`.
|
|
11
|
+
|
|
12
|
+
Discovery order:
|
|
13
|
+
1. Explicit path (CLI flag / config `dymola_interface_path`)
|
|
14
|
+
2. `DYMOLA_INTERFACE_PATH` env var
|
|
15
|
+
3. Standard locations per platform (globs all Dymola versions)
|
|
16
|
+
|
|
17
|
+
The explicit path may be either the archive itself or the directory containing
|
|
18
|
+
it (we look inside).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import shutil
|
|
26
|
+
import sys
|
|
27
|
+
import zipfile
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _dymola_sort_key(path: Path) -> tuple:
|
|
32
|
+
"""Sort Dymola install paths so newer versions come first.
|
|
33
|
+
|
|
34
|
+
Extracts the year/refresh from names like 'Dymola 2026x' or
|
|
35
|
+
'Dymola 2024x Refresh 1' and sorts descending.
|
|
36
|
+
"""
|
|
37
|
+
name = str(path)
|
|
38
|
+
m = re.search(
|
|
39
|
+
r"Dymola[\s_]?(\d{4})x(?:[\s_]+Refresh[\s_]*(\d+))?", name, re.IGNORECASE
|
|
40
|
+
)
|
|
41
|
+
if not m:
|
|
42
|
+
return (0, 0, name)
|
|
43
|
+
year = int(m.group(1))
|
|
44
|
+
refresh = int(m.group(2)) if m.group(2) else 0
|
|
45
|
+
# Negate for descending sort
|
|
46
|
+
return (-year, -refresh, name)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _platform_glob_bases() -> list[Path]:
|
|
50
|
+
"""Base directories to glob for installed Dymola versions."""
|
|
51
|
+
if sys.platform.startswith("win"):
|
|
52
|
+
candidates = [
|
|
53
|
+
Path(r"C:\Program Files"),
|
|
54
|
+
Path(r"C:\Program Files (x86)"),
|
|
55
|
+
]
|
|
56
|
+
elif sys.platform == "darwin":
|
|
57
|
+
candidates = [Path("/Applications")]
|
|
58
|
+
else:
|
|
59
|
+
candidates = [Path("/opt"), Path("/usr/local")]
|
|
60
|
+
return [p for p in candidates if p.exists()]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _find_python_interface_dirs() -> list[Path]:
|
|
64
|
+
"""Return all known python_interface directories, newest-first."""
|
|
65
|
+
found: list[Path] = []
|
|
66
|
+
for base in _platform_glob_bases():
|
|
67
|
+
for p in base.glob("Dymola*/Modelica/Library/python_interface"):
|
|
68
|
+
if p.is_dir():
|
|
69
|
+
found.append(p)
|
|
70
|
+
# Linux convention
|
|
71
|
+
for p in base.glob("dymola*/Modelica/Library/python_interface"):
|
|
72
|
+
if p.is_dir():
|
|
73
|
+
found.append(p)
|
|
74
|
+
found.sort(key=_dymola_sort_key)
|
|
75
|
+
return found
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _archive_in_dir(d: Path) -> Path | None:
|
|
79
|
+
"""Find a dymola .whl or .egg inside a python_interface directory."""
|
|
80
|
+
wheels = sorted(d.glob("dymola-*.whl"), reverse=True)
|
|
81
|
+
if wheels:
|
|
82
|
+
return wheels[0]
|
|
83
|
+
egg = d / "dymola.egg"
|
|
84
|
+
if egg.exists():
|
|
85
|
+
return egg
|
|
86
|
+
eggs = sorted(d.glob("dymola*.egg"), reverse=True)
|
|
87
|
+
if eggs:
|
|
88
|
+
return eggs[0]
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def find_dymola_interface_archive(
|
|
93
|
+
override: Path | None = None,
|
|
94
|
+
) -> Path | None:
|
|
95
|
+
"""Find the Dymola Python interface archive. Returns the Path or None.
|
|
96
|
+
|
|
97
|
+
Accepts either a direct archive path or a directory containing one.
|
|
98
|
+
"""
|
|
99
|
+
explicit_candidates: list[Path] = []
|
|
100
|
+
if override:
|
|
101
|
+
explicit_candidates.append(Path(override))
|
|
102
|
+
env = os.environ.get("DYMOLA_INTERFACE_PATH")
|
|
103
|
+
if env:
|
|
104
|
+
explicit_candidates.append(Path(env))
|
|
105
|
+
|
|
106
|
+
for cand in explicit_candidates:
|
|
107
|
+
if cand.is_file() and cand.suffix in (".whl", ".egg"):
|
|
108
|
+
return cand
|
|
109
|
+
if cand.is_dir():
|
|
110
|
+
a = _archive_in_dir(cand)
|
|
111
|
+
if a:
|
|
112
|
+
return a
|
|
113
|
+
|
|
114
|
+
# Auto-discovery
|
|
115
|
+
for d in _find_python_interface_dirs():
|
|
116
|
+
a = _archive_in_dir(d)
|
|
117
|
+
if a:
|
|
118
|
+
return a
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _cache_root() -> Path:
|
|
123
|
+
"""User cache directory for extracted wheels."""
|
|
124
|
+
if sys.platform.startswith("win"):
|
|
125
|
+
root = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
|
126
|
+
else:
|
|
127
|
+
root = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
|
|
128
|
+
return root / "dstf" / "dymola-interface"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _cache_signature(archive: Path) -> str:
|
|
132
|
+
"""Signature stored in the cache marker so we can invalidate stale or
|
|
133
|
+
corrupt extractions. Pairs source archive size + mtime — cheap to compute,
|
|
134
|
+
catches both wheel updates (Dymola refresh) and partial/interrupted
|
|
135
|
+
extractions that left bogus content (a 32-byte stub `dymola_interface.py`
|
|
136
|
+
actually shipped in our cache once; the touch-only marker happily reused
|
|
137
|
+
it). If the wheel is replaced byte-for-byte the mtime still changes, so
|
|
138
|
+
this is conservative-correct without needing a hash.
|
|
139
|
+
"""
|
|
140
|
+
st = archive.stat()
|
|
141
|
+
return f"{archive}|{st.st_size}|{st.st_mtime_ns}"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _prepare_sys_path(archive: Path) -> Path:
|
|
145
|
+
"""Make `import dymola` work. Returns the path added to sys.path."""
|
|
146
|
+
if archive.suffix == ".egg":
|
|
147
|
+
# zipimport supports .egg directly
|
|
148
|
+
entry = str(archive)
|
|
149
|
+
else:
|
|
150
|
+
# Extract .whl to cache once, then add the extract dir.
|
|
151
|
+
extract_dir = _cache_root() / archive.stem
|
|
152
|
+
marker = extract_dir / ".extracted"
|
|
153
|
+
signature = _cache_signature(archive)
|
|
154
|
+
cached_signature = marker.read_text() if marker.exists() else None
|
|
155
|
+
if cached_signature != signature:
|
|
156
|
+
# Wipe and re-extract on first use OR on signature drift (wheel
|
|
157
|
+
# updated, mtime changed, prior extraction corrupted).
|
|
158
|
+
if extract_dir.exists():
|
|
159
|
+
shutil.rmtree(extract_dir)
|
|
160
|
+
extract_dir.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
with zipfile.ZipFile(archive) as z:
|
|
162
|
+
z.extractall(extract_dir)
|
|
163
|
+
marker.write_text(signature)
|
|
164
|
+
entry = str(extract_dir)
|
|
165
|
+
if entry not in sys.path:
|
|
166
|
+
sys.path.insert(0, entry)
|
|
167
|
+
return Path(entry)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
_NOT_FOUND_MESSAGE = """\
|
|
171
|
+
Could not find Dymola's Python interface (dymola.egg or dymola-*.whl).
|
|
172
|
+
|
|
173
|
+
Searched:
|
|
174
|
+
- Explicit --dymola-interface / dymola_interface_path
|
|
175
|
+
- DYMOLA_INTERFACE_PATH env var
|
|
176
|
+
- Auto-discovery under platform install roots:
|
|
177
|
+
{bases}
|
|
178
|
+
|
|
179
|
+
Typical location (Windows):
|
|
180
|
+
C:\\Program Files\\Dymola 2026x\\Modelica\\Library\\python_interface\\dymola-2026.0-py3-none-any.whl
|
|
181
|
+
|
|
182
|
+
Fix one of:
|
|
183
|
+
1. Pass --dymola-interface <path-to-archive-or-dir>
|
|
184
|
+
2. Set environment variable DYMOLA_INTERFACE_PATH
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
_cached_class = None
|
|
189
|
+
_cached_path: Path | None = None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def load_dymola_interface(override_path: Path | None = None):
|
|
193
|
+
"""Load and return the DymolaInterface class. Cached after first call."""
|
|
194
|
+
global _cached_class, _cached_path
|
|
195
|
+
if _cached_class is not None:
|
|
196
|
+
return _cached_class
|
|
197
|
+
archive = find_dymola_interface_archive(override_path)
|
|
198
|
+
if archive is None:
|
|
199
|
+
bases = "\n ".join(str(b) for b in _platform_glob_bases())
|
|
200
|
+
raise RuntimeError(_NOT_FOUND_MESSAGE.format(bases=bases or "(none)"))
|
|
201
|
+
_cached_path = _prepare_sys_path(archive)
|
|
202
|
+
try:
|
|
203
|
+
from dymola.dymola_interface import (
|
|
204
|
+
DymolaInterface, # type: ignore[import-not-found]
|
|
205
|
+
)
|
|
206
|
+
except ImportError as e:
|
|
207
|
+
raise RuntimeError(
|
|
208
|
+
f"Found Dymola interface archive at {archive}, but `import dymola` failed: {e}\n"
|
|
209
|
+
f"The archive may be incomplete or incompatible with this Python "
|
|
210
|
+
f"({sys.version.split()[0]})."
|
|
211
|
+
) from e
|
|
212
|
+
_cached_class = DymolaInterface
|
|
213
|
+
return DymolaInterface
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def describe_dymola_interface(override_path: Path | None = None) -> dict:
|
|
217
|
+
"""Run the loader in a reporting mode — no raises. Useful for diagnostics."""
|
|
218
|
+
archive = find_dymola_interface_archive(override_path)
|
|
219
|
+
info: dict = {
|
|
220
|
+
"archive": str(archive) if archive else None,
|
|
221
|
+
"format": archive.suffix.lstrip(".") if archive else None,
|
|
222
|
+
"search_roots": [str(b) for b in _platform_glob_bases()],
|
|
223
|
+
"discovered_dirs": [str(p) for p in _find_python_interface_dirs()],
|
|
224
|
+
"sys_path_entry": None,
|
|
225
|
+
"import_ok": False,
|
|
226
|
+
"error": None,
|
|
227
|
+
}
|
|
228
|
+
if archive is None:
|
|
229
|
+
info["error"] = "No archive found"
|
|
230
|
+
return info
|
|
231
|
+
try:
|
|
232
|
+
info["sys_path_entry"] = str(_prepare_sys_path(archive))
|
|
233
|
+
from dymola.dymola_interface import (
|
|
234
|
+
DymolaInterface, # type: ignore[import-not-found]
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
info["import_ok"] = True
|
|
238
|
+
info["dymola_interface_module"] = DymolaInterface.__module__
|
|
239
|
+
except Exception as e: # pragma: no cover — diagnostic helper
|
|
240
|
+
info["error"] = f"{type(e).__name__}: {e}"
|
|
241
|
+
return info
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""Parse Dymola's dslog.txt and translation_log.txt for statistics."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..common.statistics import summaries
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_dslog(path: Path) -> dict | None:
|
|
11
|
+
"""Parse a Dymola log file and extract all available statistics.
|
|
12
|
+
|
|
13
|
+
Works with both dslog.txt (simulation runtime stats) and
|
|
14
|
+
translation_log.txt (translation/structural stats).
|
|
15
|
+
|
|
16
|
+
Returns a dict with sub-dicts for different stat categories,
|
|
17
|
+
or None if the file cannot be read or has no stats.
|
|
18
|
+
"""
|
|
19
|
+
try:
|
|
20
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
21
|
+
except OSError:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
stats = {}
|
|
25
|
+
|
|
26
|
+
# --- Translation statistics (from translation_log.txt) ---
|
|
27
|
+
_parse_translation_stats(text, stats)
|
|
28
|
+
|
|
29
|
+
# --- Simulation runtime statistics (from dslog.txt) ---
|
|
30
|
+
_parse_simulation_stats(text, stats)
|
|
31
|
+
|
|
32
|
+
return stats if stats else None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_test_logs(test_dir: Path) -> dict | None:
|
|
36
|
+
"""The statistics of the two logs Dymola leaves in a test's directory,
|
|
37
|
+
merged: ``dslog.txt`` (``simulation.*``) and ``translation_log.txt``
|
|
38
|
+
(``translation.*``). ``None`` when neither holds any.
|
|
39
|
+
|
|
40
|
+
The one reader of both — the batch run, the persistent worker's disk
|
|
41
|
+
read and :meth:`DymolaBackend.read_result`'s fallback all call it, so
|
|
42
|
+
the merge cannot differ between a run and a later reload.
|
|
43
|
+
|
|
44
|
+
A log that does not parse costs this test that log's statistics and
|
|
45
|
+
one ``# WARN:`` line naming the file: its number that is no number
|
|
46
|
+
(``1.2.3 s``) must not abort the read of the suite.
|
|
47
|
+
"""
|
|
48
|
+
statistics = _parse_or_warn(test_dir / "dslog.txt")
|
|
49
|
+
translation_stats = _parse_or_warn(test_dir / "translation_log.txt")
|
|
50
|
+
if not translation_stats:
|
|
51
|
+
return statistics
|
|
52
|
+
if statistics is None:
|
|
53
|
+
return translation_stats
|
|
54
|
+
# Each log produces different top-level keys (dslog -> "simulation",
|
|
55
|
+
# translation -> "translation"); a shared category merges key by key.
|
|
56
|
+
for key, value in translation_stats.items():
|
|
57
|
+
if isinstance(value, dict) and isinstance(statistics.get(key), dict):
|
|
58
|
+
statistics[key].update(value)
|
|
59
|
+
else:
|
|
60
|
+
statistics[key] = value
|
|
61
|
+
return statistics
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _parse_or_warn(path: Path) -> dict | None:
|
|
65
|
+
""":func:`parse_dslog`, or None with a warning when the log's text
|
|
66
|
+
breaks the parse. A log is data a simulator wrote, so an error its text
|
|
67
|
+
raises is that file's, not the read's — and the only one it can raise
|
|
68
|
+
is a ValueError (``int`` / ``float`` over a matched number: ``1.2.3``,
|
|
69
|
+
or past ``int``'s digit limit on 3.11+). Anything else is the parser's
|
|
70
|
+
own bug, and propagates."""
|
|
71
|
+
try:
|
|
72
|
+
return parse_dslog(path)
|
|
73
|
+
except ValueError as exc:
|
|
74
|
+
print(
|
|
75
|
+
f"# WARN: could not parse {path}: {type(exc).__name__}: {exc}",
|
|
76
|
+
file=sys.stderr,
|
|
77
|
+
)
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _parse_translation_stats(text: str, stats: dict) -> None:
|
|
82
|
+
"""Extract translation statistics from a Dymola log."""
|
|
83
|
+
|
|
84
|
+
# DAE size
|
|
85
|
+
m = re.search(r"The DAE has (\d+) scalar unknowns and (\d+) scalar equations", text)
|
|
86
|
+
if m:
|
|
87
|
+
stats.setdefault("translation", {})
|
|
88
|
+
stats["translation"]["scalar_unknowns"] = int(m.group(1))
|
|
89
|
+
stats["translation"]["scalar_equations"] = int(m.group(2))
|
|
90
|
+
|
|
91
|
+
# Original Model section
|
|
92
|
+
orig = re.search(r"Original Model\s*\n((?:\s+.+\n)+)", text)
|
|
93
|
+
if orig:
|
|
94
|
+
orig_text = orig.group(1)
|
|
95
|
+
_extract_int(
|
|
96
|
+
orig_text,
|
|
97
|
+
r"Number of components:\s*(\d+)",
|
|
98
|
+
stats,
|
|
99
|
+
"translation",
|
|
100
|
+
"original_components",
|
|
101
|
+
)
|
|
102
|
+
_extract_int(
|
|
103
|
+
orig_text, r"Variables:\s*(\d+)", stats, "translation", "original_variables"
|
|
104
|
+
)
|
|
105
|
+
_extract_int(
|
|
106
|
+
orig_text,
|
|
107
|
+
r"Unknowns:.*?(\d+)\s+scalars",
|
|
108
|
+
stats,
|
|
109
|
+
"translation",
|
|
110
|
+
"original_unknowns_scalars",
|
|
111
|
+
)
|
|
112
|
+
_extract_int(
|
|
113
|
+
orig_text,
|
|
114
|
+
r"Differentiated variables:\s*(\d+)",
|
|
115
|
+
stats,
|
|
116
|
+
"translation",
|
|
117
|
+
"differentiated_variables",
|
|
118
|
+
)
|
|
119
|
+
_extract_int(
|
|
120
|
+
orig_text, r"Equations:\s*(\d+)", stats, "translation", "original_equations"
|
|
121
|
+
)
|
|
122
|
+
_extract_int(
|
|
123
|
+
orig_text,
|
|
124
|
+
r"Nontrivial:\s*(\d+)",
|
|
125
|
+
stats,
|
|
126
|
+
"translation",
|
|
127
|
+
"nontrivial_equations",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Translated Model section — need to separate simulation vs initialization
|
|
131
|
+
# The section structure is:
|
|
132
|
+
# Translated Model
|
|
133
|
+
# <simulation-level stats>
|
|
134
|
+
# Initialization problem
|
|
135
|
+
# <initialization-level stats>
|
|
136
|
+
trans_match = re.search(r"Translated Model\s*\n((?:\s+.+\n)+)", text)
|
|
137
|
+
if trans_match:
|
|
138
|
+
full_trans_text = trans_match.group(1)
|
|
139
|
+
|
|
140
|
+
# Split at "Initialization problem" if present
|
|
141
|
+
init_split = re.split(
|
|
142
|
+
r"^\s+Initialization problem\s*$", full_trans_text, flags=re.MULTILINE
|
|
143
|
+
)
|
|
144
|
+
sim_text = init_split[0]
|
|
145
|
+
init_text = init_split[1] if len(init_split) > 1 else ""
|
|
146
|
+
|
|
147
|
+
# --- Simulation-level translated model stats ---
|
|
148
|
+
_extract_int(sim_text, r"Constants:\s*(\d+)", stats, "translation", "constants")
|
|
149
|
+
_extract_int(
|
|
150
|
+
sim_text,
|
|
151
|
+
r"Free parameters:\s*(\d+)",
|
|
152
|
+
stats,
|
|
153
|
+
"translation",
|
|
154
|
+
"free_parameters",
|
|
155
|
+
)
|
|
156
|
+
_extract_int(
|
|
157
|
+
sim_text,
|
|
158
|
+
r"Parameter depending:\s*(\d+)",
|
|
159
|
+
stats,
|
|
160
|
+
"translation",
|
|
161
|
+
"parameter_depending",
|
|
162
|
+
)
|
|
163
|
+
_extract_int(sim_text, r"Outputs:\s*(\d+)", stats, "translation", "outputs")
|
|
164
|
+
_extract_int(
|
|
165
|
+
sim_text,
|
|
166
|
+
r"Continuous time states:\s*(\d+)",
|
|
167
|
+
stats,
|
|
168
|
+
"translation",
|
|
169
|
+
"continuous_time_states",
|
|
170
|
+
)
|
|
171
|
+
_extract_int(
|
|
172
|
+
sim_text,
|
|
173
|
+
r"Time-varying variables:\s*(\d+)",
|
|
174
|
+
stats,
|
|
175
|
+
"translation",
|
|
176
|
+
"time_varying_variables",
|
|
177
|
+
)
|
|
178
|
+
_extract_int(
|
|
179
|
+
sim_text,
|
|
180
|
+
r"Alias variables:\s*(\d+)",
|
|
181
|
+
stats,
|
|
182
|
+
"translation",
|
|
183
|
+
"alias_variables",
|
|
184
|
+
)
|
|
185
|
+
_extract_int(
|
|
186
|
+
sim_text,
|
|
187
|
+
r"Number of mixed real/discrete systems of equations:\s*(\d+)",
|
|
188
|
+
stats,
|
|
189
|
+
"translation",
|
|
190
|
+
"mixed_systems",
|
|
191
|
+
)
|
|
192
|
+
_extract_int(
|
|
193
|
+
sim_text,
|
|
194
|
+
r"Number of numerical Jacobians:\s*(\d+)",
|
|
195
|
+
stats,
|
|
196
|
+
"translation",
|
|
197
|
+
"numerical_jacobians",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Simulation-level equation system sizes
|
|
201
|
+
_extract_system_sizes(sim_text, stats, "translation", prefix="")
|
|
202
|
+
|
|
203
|
+
# --- Initialization problem stats ---
|
|
204
|
+
if init_text:
|
|
205
|
+
_extract_int(
|
|
206
|
+
init_text,
|
|
207
|
+
r"Number of mixed real/discrete systems of equations:\s*(\d+)",
|
|
208
|
+
stats,
|
|
209
|
+
"translation",
|
|
210
|
+
"init_mixed_systems",
|
|
211
|
+
)
|
|
212
|
+
_extract_int(
|
|
213
|
+
init_text,
|
|
214
|
+
r"Number of numerical Jacobians:\s*(\d+)",
|
|
215
|
+
stats,
|
|
216
|
+
"translation",
|
|
217
|
+
"init_numerical_jacobians",
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
_extract_system_sizes(init_text, stats, "translation", prefix="init_")
|
|
221
|
+
|
|
222
|
+
# Homotopy nonlinear systems (initialization only)
|
|
223
|
+
_extract_system_pair(
|
|
224
|
+
init_text,
|
|
225
|
+
r"Sizes of simplified homotopy nonlinear systems of equations",
|
|
226
|
+
r"Sizes after manipulation of the simplified homotopy nonlinear systems",
|
|
227
|
+
stats,
|
|
228
|
+
"translation",
|
|
229
|
+
"init_homotopy_nonlinear",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Continuous time states — fallback if not found in Translated Model section
|
|
233
|
+
if "translation" not in stats or "continuous_time_states" not in stats.get(
|
|
234
|
+
"translation", {}
|
|
235
|
+
):
|
|
236
|
+
m = re.search(r"Continuous time states:\s*(\d+)", text)
|
|
237
|
+
if not m:
|
|
238
|
+
m = re.search(r"(\d+)\s+continuous time states", text)
|
|
239
|
+
if m:
|
|
240
|
+
stats.setdefault("translation", {})
|
|
241
|
+
stats["translation"]["continuous_time_states"] = int(m.group(1))
|
|
242
|
+
|
|
243
|
+
# Selected continuous time states (list of state variable names)
|
|
244
|
+
# Capture lines after "Statically selected continuous time states" that look
|
|
245
|
+
# like Modelica variable names (start with a letter, contain dots/brackets).
|
|
246
|
+
# Stop at "Dynamically selected", "Warning:", blank lines, or non-variable lines.
|
|
247
|
+
states_match = re.search(
|
|
248
|
+
r"Statically selected continuous time states\s*\n((?:(?!Dynamically|Warning|There are|From set|=)[a-zA-Z][\w.\[\], ]*\n)+)",
|
|
249
|
+
text,
|
|
250
|
+
)
|
|
251
|
+
if states_match:
|
|
252
|
+
state_names = [
|
|
253
|
+
line.strip()
|
|
254
|
+
for line in states_match.group(1).strip().split("\n")
|
|
255
|
+
if line.strip()
|
|
256
|
+
]
|
|
257
|
+
if state_names:
|
|
258
|
+
stats.setdefault("translation", {})
|
|
259
|
+
stats["translation"]["state_names"] = state_names
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _extract_system_sizes(
|
|
263
|
+
text: str,
|
|
264
|
+
stats: dict,
|
|
265
|
+
category: str,
|
|
266
|
+
prefix: str,
|
|
267
|
+
) -> None:
|
|
268
|
+
"""Extract nonlinear and linear system sizes from a log section.
|
|
269
|
+
|
|
270
|
+
Parses both original sizes and after-manipulation sizes, stores as
|
|
271
|
+
integer lists with summary fields (count, total, max).
|
|
272
|
+
"""
|
|
273
|
+
for sys_type in ("nonlinear", "linear"):
|
|
274
|
+
# Original sizes
|
|
275
|
+
_extract_system_pair_simple(
|
|
276
|
+
text,
|
|
277
|
+
sys_type,
|
|
278
|
+
stats,
|
|
279
|
+
category,
|
|
280
|
+
f"{prefix}{sys_type}",
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _extract_system_pair_simple(
|
|
285
|
+
text: str,
|
|
286
|
+
sys_type: str,
|
|
287
|
+
stats: dict,
|
|
288
|
+
category: str,
|
|
289
|
+
key_base: str,
|
|
290
|
+
) -> None:
|
|
291
|
+
"""Extract original + after-manipulation sizes for a system type."""
|
|
292
|
+
# Original sizes: "Sizes of <type> systems of equations: {N, N, ...}"
|
|
293
|
+
m = re.search(
|
|
294
|
+
rf"Sizes of {sys_type} systems of equations\s*:\s*\{{([^}}]*)\}}", text
|
|
295
|
+
)
|
|
296
|
+
if m:
|
|
297
|
+
values = _parse_int_list(m.group(1))
|
|
298
|
+
stats.setdefault(category, {})
|
|
299
|
+
stats[category][key_base] = values
|
|
300
|
+
stats[category].update(summaries(key_base, values))
|
|
301
|
+
|
|
302
|
+
# After manipulation: "Sizes after manipulation of the <type> systems: {N, N, ...}"
|
|
303
|
+
m = re.search(
|
|
304
|
+
rf"Sizes after manipulation of the {sys_type} systems\s*:\s*\{{([^}}]*)\}}",
|
|
305
|
+
text,
|
|
306
|
+
)
|
|
307
|
+
if m:
|
|
308
|
+
values = _parse_int_list(m.group(1))
|
|
309
|
+
key = f"{key_base}_after_manipulation"
|
|
310
|
+
stats.setdefault(category, {})
|
|
311
|
+
stats[category][key] = values
|
|
312
|
+
stats[category].update(summaries(key, values))
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _extract_system_pair(
|
|
316
|
+
text: str,
|
|
317
|
+
original_pattern: str,
|
|
318
|
+
manipulation_pattern: str,
|
|
319
|
+
stats: dict,
|
|
320
|
+
category: str,
|
|
321
|
+
key_base: str,
|
|
322
|
+
) -> None:
|
|
323
|
+
"""Extract a pair of system size lists (original + after manipulation)."""
|
|
324
|
+
m = re.search(rf"{original_pattern}\s*:\s*\{{([^}}]*)\}}", text)
|
|
325
|
+
if m:
|
|
326
|
+
values = _parse_int_list(m.group(1))
|
|
327
|
+
stats.setdefault(category, {})
|
|
328
|
+
stats[category][key_base] = values
|
|
329
|
+
stats[category].update(summaries(key_base, values))
|
|
330
|
+
|
|
331
|
+
m = re.search(rf"{manipulation_pattern}\s*:\s*\{{([^}}]*)\}}", text)
|
|
332
|
+
if m:
|
|
333
|
+
values = _parse_int_list(m.group(1))
|
|
334
|
+
key = f"{key_base}_after_manipulation"
|
|
335
|
+
stats.setdefault(category, {})
|
|
336
|
+
stats[category][key] = values
|
|
337
|
+
stats[category].update(summaries(key, values))
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _parse_simulation_stats(text: str, stats: dict) -> None:
|
|
341
|
+
"""Extract simulation runtime statistics from a Dymola log."""
|
|
342
|
+
|
|
343
|
+
# CPU time for integration (from dslog — just the integration step,
|
|
344
|
+
# excludes init / output writing. Distinct from the `CPUtime` diagnostic
|
|
345
|
+
# variable's final value which covers the full simulation.)
|
|
346
|
+
m = re.search(
|
|
347
|
+
r"CPU-time for (?:integration|simulation)\s*[=:]\s*([\d.eE+\-]+)\s*s",
|
|
348
|
+
text,
|
|
349
|
+
re.IGNORECASE,
|
|
350
|
+
)
|
|
351
|
+
if m:
|
|
352
|
+
stats.setdefault("simulation", {})
|
|
353
|
+
stats["simulation"]["cpu_time_integration"] = float(m.group(1))
|
|
354
|
+
|
|
355
|
+
# All other simulation stats use the same pattern: label with possible
|
|
356
|
+
# whitespace padding before the colon
|
|
357
|
+
_SIM_PATTERNS = [
|
|
358
|
+
(r"Number of result points\s*:\s*(\d+)", "result_points"),
|
|
359
|
+
(r"Number of accepted steps\s*:\s*(\d+)", "accepted_steps"),
|
|
360
|
+
(r"Number of f-evaluations.*?:\s*(\d+)", "f_evaluations"),
|
|
361
|
+
(r"Number of Jacobian-evaluations\s*:\s*(\d+)", "jacobian_evaluations"),
|
|
362
|
+
(r"Number of state\s+events\s*:\s*(\d+)", "state_events"),
|
|
363
|
+
(r"Number of step\s+events\s*:\s*(\d+)", "step_events"),
|
|
364
|
+
(r"Number of model time events\s*:\s*(\d+)", "model_time_events"),
|
|
365
|
+
(r"Number of input time events\s*:\s*(\d+)", "input_time_events"),
|
|
366
|
+
]
|
|
367
|
+
|
|
368
|
+
for pattern, key in _SIM_PATTERNS:
|
|
369
|
+
m = re.search(pattern, text)
|
|
370
|
+
if m:
|
|
371
|
+
stats.setdefault("simulation", {})
|
|
372
|
+
stats["simulation"][key] = int(m.group(1))
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _extract_int(text: str, pattern: str, stats: dict, category: str, key: str) -> None:
|
|
376
|
+
"""Extract an integer value and store it in stats[category][key]."""
|
|
377
|
+
m = re.search(pattern, text)
|
|
378
|
+
if m:
|
|
379
|
+
stats.setdefault(category, {})
|
|
380
|
+
stats[category][key] = int(m.group(1))
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _parse_int_list(text: str) -> list[int]:
|
|
384
|
+
"""Parse a comma-separated integer list from Dymola log.
|
|
385
|
+
|
|
386
|
+
Dymola formats these as e.g. '{7, 3, 1}' — we parse to a list of ints.
|
|
387
|
+
Returns empty list for empty/whitespace-only content (e.g., '{ }').
|
|
388
|
+
"""
|
|
389
|
+
parts = [p.strip() for p in text.split(",") if p.strip()]
|
|
390
|
+
result = []
|
|
391
|
+
for p in parts:
|
|
392
|
+
try:
|
|
393
|
+
result.append(int(p))
|
|
394
|
+
except ValueError:
|
|
395
|
+
pass
|
|
396
|
+
return result
|