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,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