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,238 @@
1
+ """Shared process-kill helpers for backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+
8
+
9
+ def kill_process_tree(
10
+ pid: int,
11
+ name_hint: str | None = None,
12
+ create_time: float | None = None,
13
+ ) -> None:
14
+ """Kill *pid* and its entire child tree, children first.
15
+
16
+ The tracked PID is never the process doing the simulating: dymola.exe
17
+ spawns dymosim, omc spawns the compiled model binary. Killing only the
18
+ parent leaves that child burning CPU and holding the result files open
19
+ (on Windows the next run's rmtree then fails with PermissionError)
20
+ (review 2026-07-06, finding 22).
21
+
22
+ Two independent guards against PID reuse, because a PID we recorded
23
+ seconds ago may belong to something else by now — on Windows freed
24
+ PIDs come back almost immediately:
25
+
26
+ * ``name_hint`` — nothing is killed unless the process's name still
27
+ contains the hint (``"dymola"``, ``"omc"``, ``"julia"``).
28
+ * ``create_time`` — the exact process-creation timestamp recorded when
29
+ the PID was captured. A recycled PID has a later one, and nothing is
30
+ killed. Pass it whenever the PID was recorded earlier and the caller
31
+ can't otherwise prove the process is the same one.
32
+
33
+ Children resolved from the live tree in this call need neither guard —
34
+ they are read from the process we just validated, and the hint would
35
+ wrongly exclude exactly the children we're after (dymosim under
36
+ dymola). Children recorded *earlier* do need both: see
37
+ :func:`snapshot_children`, which records them with their create times
38
+ for feeding back through this function.
39
+
40
+ A parent that already exited takes its children out of reach here,
41
+ which is what ``snapshot_children`` exists to cover.
42
+
43
+ Best-effort throughout: psutil may be missing and processes may vanish
44
+ or be inaccessible mid-walk.
45
+ """
46
+ try:
47
+ import psutil
48
+ except ImportError: # pragma: no cover — psutil is a hard dep in practice
49
+ return
50
+
51
+ try:
52
+ parent = psutil.Process(pid)
53
+ if name_hint and name_hint not in (parent.name() or "").lower():
54
+ return
55
+ if create_time is not None and parent.create_time() != create_time:
56
+ # PID reused since it was recorded — this is somebody else.
57
+ return
58
+ children = parent.children(recursive=True)
59
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
60
+ return
61
+ except Exception:
62
+ # A kill path must never raise: an unexpected psutil error here
63
+ # would surface as a bogus "worker exception" in the dispatch
64
+ # loop instead of the timeout that actually happened.
65
+ return
66
+
67
+ for proc in [*children, parent]:
68
+ try:
69
+ proc.kill()
70
+ except Exception:
71
+ pass
72
+
73
+
74
+ def snapshot_children(
75
+ pid: int,
76
+ name_hint: str | None = None,
77
+ ) -> set[tuple[int, float]]:
78
+ """Descendants of *pid* as ``(pid, create_time)`` pairs, recorded while
79
+ the parent is still alive.
80
+
81
+ Covers exactly one case: a graceful shutdown that succeeds. dymola.exe
82
+ exits on ``close()`` while dymosim keeps running and keeps the result
83
+ files open, and once the parent is gone nothing links the two, so
84
+ ``kill_process_tree`` can no longer find the child. The worker
85
+ therefore snapshots the tree immediately before its graceful attempt
86
+ and kills the recorded processes afterwards. (A parent that died
87
+ *before* ``close()`` was ever called has already orphaned its children
88
+ beyond the reach of this snapshot.)
89
+
90
+ The create times are what make the recorded PIDs safe to kill later:
91
+ the graceful window is seconds long, and a PID freed inside it can be
92
+ reissued to an unrelated process — or to another worker's fresh
93
+ dymosim. ``name_hint`` guards the parent the same way
94
+ :func:`kill_process_tree` does, so a recycled *parent* PID never hands
95
+ us a stranger's children in the first place.
96
+
97
+ Best-effort: an empty set on any failure.
98
+ """
99
+ try:
100
+ import psutil
101
+ except ImportError: # pragma: no cover — psutil is a hard dep in practice
102
+ return set()
103
+
104
+ try:
105
+ parent = psutil.Process(pid)
106
+ if name_hint and name_hint not in (parent.name() or "").lower():
107
+ return set()
108
+ children = parent.children(recursive=True)
109
+ except Exception:
110
+ # Snapshotting sits on the close path; it must never raise there.
111
+ return set()
112
+
113
+ snapshot: set[tuple[int, float]] = set()
114
+ for child in children:
115
+ try:
116
+ snapshot.add((child.pid, child.create_time()))
117
+ except Exception:
118
+ # Vanished or inaccessible between the walk and the read —
119
+ # a PID without its create time is not safe to kill later.
120
+ continue
121
+ return snapshot
122
+
123
+
124
+ def orphans_in(directory: str, born_after: float) -> set[tuple[int, float]]:
125
+ """Linux only: the processes a dead tool left behind in *directory*, as
126
+ ``(pid, create_time)`` pairs for :func:`kill_process_tree`.
127
+
128
+ A tool that dies by itself orphans its children, and a child nobody
129
+ recorded while the tool was alive cannot be found through it any more.
130
+ What it left is told from everything else on the machine by a mark of
131
+ three parts, ALL required:
132
+
133
+ * it runs in *directory* or below — the scratch directory of the one
134
+ test the dead tool was running, which the tool was told to work in
135
+ and no other worker uses;
136
+ * it was created no earlier than *born_after*, the tool's own creation
137
+ time on the same clock (a child cannot predate its parent);
138
+ * it has no parent of its own left: it hangs under init, or under a
139
+ reaper among this process's ancestors — where the kernel puts the
140
+ orphans of our descendants. A child of this process is not one, and
141
+ neither is a child of anybody else's live process.
142
+
143
+ Unreadable means unmarked: a process whose directory or parent cannot
144
+ be read is left alone. Other platforms report no working directory for
145
+ a foreign process without privileges, or do not reparent: ``set()``.
146
+ """
147
+ if not sys.platform.startswith("linux"):
148
+ return set()
149
+ try:
150
+ import psutil
151
+
152
+ reapers = {1} | {parent.pid for parent in psutil.Process().parents()}
153
+ root = os.path.realpath(directory)
154
+ except Exception:
155
+ return set()
156
+ found: set[tuple[int, float]] = set()
157
+ for proc in psutil.process_iter():
158
+ try:
159
+ if proc.ppid() not in reapers or proc.pid in reapers | {os.getpid()}:
160
+ continue
161
+ created = proc.create_time()
162
+ if created < born_after or proc.status() == psutil.STATUS_ZOMBIE:
163
+ continue
164
+ cwd = proc.cwd()
165
+ if cwd == root or cwd.startswith(root + os.sep):
166
+ found.add((proc.pid, created))
167
+ except Exception:
168
+ continue
169
+ return found
170
+
171
+
172
+ def created_at(pids: set[int]) -> float | None:
173
+ """When the earliest of *pids* was created (the clock of
174
+ :func:`orphans_in`), or ``None`` when that cannot be read."""
175
+ try:
176
+ import psutil
177
+
178
+ return min(psutil.Process(pid).create_time() for pid in pids)
179
+ except Exception:
180
+ return None
181
+
182
+
183
+ def process_gone(pids: set[int], name_hint: str | None = None) -> bool:
184
+ """True when *pids* names at least one process and none of them is
185
+ running any more — exited, or a zombie nobody has reaped yet. With
186
+ ``name_hint``, a PID that is no longer the tool's is not running
187
+ either: the process under it has another name (the guard of
188
+ :func:`kill_process_tree`, for a PID recorded long ago).
189
+
190
+ How a worker tells "the tool died" from "the tool is slow". Errs
191
+ towards alive: no PIDs, no psutil, or a process it cannot inspect all
192
+ read as not gone, since the answer triggers a kill and a restart.
193
+ """
194
+ if not pids:
195
+ return False
196
+ try:
197
+ import psutil
198
+
199
+ for pid in list(pids):
200
+ try:
201
+ proc = psutil.Process(pid)
202
+ if proc.status() == psutil.STATUS_ZOMBIE:
203
+ continue
204
+ if name_hint and name_hint not in (proc.name() or "").lower():
205
+ continue
206
+ return False
207
+ except psutil.NoSuchProcess:
208
+ continue
209
+ return True
210
+ except Exception:
211
+ return False
212
+
213
+
214
+ def live_children(pids: set[int], name_hint: str | None = None) -> list:
215
+ """Every descendant of *pids* alive right now, as ``psutil.Process``
216
+ objects — what a backend's soft cancel looks through for the process
217
+ doing the simulating. A zombie is not alive: a child killed a moment
218
+ ago stays in the table until its parent reaps it. ``name_hint`` guards
219
+ each of *pids* as :func:`kill_process_tree` does: a PID whose process
220
+ is no longer the tool (recycled) hands over nobody's children — what
221
+ comes back is killed, or told to stop. Best-effort: ``[]`` on any
222
+ failure."""
223
+ try:
224
+ import psutil
225
+ except ImportError: # pragma: no cover — psutil is a hard dep in practice
226
+ return []
227
+ children: list = []
228
+ for pid in list(pids):
229
+ try:
230
+ parent = psutil.Process(pid)
231
+ if name_hint and name_hint not in (parent.name() or "").lower():
232
+ continue
233
+ for child in parent.children(recursive=True):
234
+ if child.status() != psutil.STATUS_ZOMBIE:
235
+ children.append(child)
236
+ except Exception:
237
+ continue
238
+ return children
@@ -0,0 +1,20 @@
1
+ """Shared subprocess-output helpers for backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def decode_output(data: bytes | str | None) -> str:
7
+ """Normalize subprocess output (str, bytes, or None) to str.
8
+
9
+ review 2026-07-06 (finding 23): ``subprocess.TimeoutExpired.stdout`` /
10
+ ``.stderr`` are **bytes** even when the process was opened with
11
+ ``text=True``. Handlers that did ``(exc.stdout or "") + "..."`` or
12
+ ``write_text(exc.stdout)`` raised TypeError inside the timeout path —
13
+ aborting the whole run precisely when a test timed out after printing
14
+ output. Every backend's timeout handler funnels through this helper.
15
+ """
16
+ if data is None:
17
+ return ""
18
+ if isinstance(data, bytes):
19
+ return data.decode("utf-8", errors="replace")
20
+ return data
@@ -0,0 +1,109 @@
1
+ """Any variable of a result file, by name — the three on-disk formats the
2
+ bundled backends write, each as a pair: the names in the file (``None`` when
3
+ there is no readable file) and the named series as ``{name: (time, values)}``
4
+ (a name the file lacks is left out). A backend class points its
5
+ ``result_names`` / ``result_series`` at one pair. The tracked-variable read
6
+ (``read_result``) stays where it is: it resolves patterns, indexes and
7
+ diagnostics; this reads what a plot asks for."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import zipfile
13
+ from collections.abc import Collection
14
+ from pathlib import Path
15
+
16
+ import numpy as np
17
+
18
+ from .mat_reader import list_result_mat_variables, read_result_mat
19
+
20
+ SeriesByName = dict[str, tuple[np.ndarray, np.ndarray]]
21
+
22
+
23
+ def mat_names(path: Path) -> list[str] | None:
24
+ if not path.exists():
25
+ return None
26
+ names = list_result_mat_variables(path)
27
+ return None if names is None else [n for n in names if n.lower() != "time"]
28
+
29
+
30
+ def mat_series(path: Path, names: Collection[str]) -> SeriesByName:
31
+ if not path.exists() or not names:
32
+ return {}
33
+ # A partial or foreign .mat reads as None: the reader swallows its own
34
+ # errors.
35
+ data = read_result_mat(path, variable_names=set(names))
36
+ return {n: data[n] for n in names if data and n in data}
37
+
38
+
39
+ def _json_payload(path: Path) -> dict | None:
40
+ try:
41
+ payload = json.loads(path.read_text(encoding="utf-8"))
42
+ except (OSError, ValueError): # unreadable, not UTF-8, not JSON
43
+ return None
44
+ if not isinstance(payload, dict) or not isinstance(payload.get("variables"), dict):
45
+ return None
46
+ return payload
47
+
48
+
49
+ def json_names(path: Path) -> list[str] | None:
50
+ payload = _json_payload(path)
51
+ if payload is None:
52
+ return None
53
+ return [n for n in payload["variables"] if n != "time"]
54
+
55
+
56
+ def json_series(path: Path, names: Collection[str]) -> SeriesByName:
57
+ payload = _json_payload(path)
58
+ if payload is None:
59
+ return {}
60
+ time = _vector(payload.get("time"))
61
+ if time is None:
62
+ return {}
63
+ out: SeriesByName = {}
64
+ for n in names:
65
+ values = _vector(payload["variables"].get(n)) if n != "time" else None
66
+ if values is not None and values.shape == time.shape:
67
+ out[n] = (time, values)
68
+ return out
69
+
70
+
71
+ def _vector(raw: object) -> np.ndarray | None:
72
+ """``raw`` as a 1-D float array, or ``None`` for anything that is not a
73
+ list of numbers — a run killed mid-write, a driver that dumped strings."""
74
+ if not isinstance(raw, list):
75
+ return None
76
+ try:
77
+ arr = np.asarray(raw, dtype=float)
78
+ except (ValueError, TypeError):
79
+ return None
80
+ return arr if arr.ndim == 1 else None
81
+
82
+
83
+ def _npz_array(path: Path) -> np.ndarray | None:
84
+ try:
85
+ with np.load(path, allow_pickle=False) as npz:
86
+ return npz["result"]
87
+ except (OSError, KeyError, ValueError, EOFError, zipfile.BadZipFile):
88
+ return None # absent, or half-written: savez_compressed is not atomic
89
+
90
+
91
+ def npz_names(path: Path) -> list[str] | None:
92
+ arr = _npz_array(path)
93
+ if arr is None or arr.dtype.names is None:
94
+ return None
95
+ return [n for n in arr.dtype.names if n != "time"]
96
+
97
+
98
+ def npz_series(path: Path, names: Collection[str]) -> SeriesByName:
99
+ arr = _npz_array(path)
100
+ if arr is None or arr.dtype.names is None or "time" not in arr.dtype.names:
101
+ return {}
102
+ time = np.asarray(arr["time"], dtype=float)
103
+ out: SeriesByName = {}
104
+ for n in names:
105
+ if n in arr.dtype.names and n != "time":
106
+ values = np.asarray(arr[n], dtype=float)
107
+ if values.shape == time.shape: # a subarray column is not a series
108
+ out[n] = (time, values)
109
+ return out
@@ -0,0 +1,329 @@
1
+ """Shared result-extraction helpers for DSresult-MAT backends.
2
+
3
+ Dymola and OpenModelica write the same DSresult MAT layout and follow the
4
+ same ``<instance>.x[i]`` convention, so variable selection, extraction and the
5
+ diagnostics/timing fold into ``statistics`` are one implementation here rather
6
+ than a copy per backend. Backends keep their own MAT reading and their own
7
+ timing computation — only what is genuinely identical lives here.
8
+
9
+ Also here: :func:`tracked_results`, which of a read result's variables a test
10
+ TRACKS — the rule the comparator and the authoring Session both ask.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from collections.abc import Callable, Iterable
17
+ from pathlib import Path
18
+
19
+ import numpy as np
20
+
21
+ from ...discovery.test_registry import TestModel
22
+ from ...discovery.unit_tests import unit_test_variables
23
+ from ..types import (
24
+ TestResult,
25
+ TestRunResult,
26
+ VariableResult,
27
+ _pattern_to_regex,
28
+ is_glob_pattern,
29
+ resolve_variable_patterns,
30
+ )
31
+ from .mat_reader import list_result_mat_variables, mat_shortfall
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ def criterion_variables(test: TestModel, diagnostic_vars: Iterable[str]) -> list[str]:
37
+ """The variables ``test``'s criteria name (`tree_variables`): what every
38
+ backend records and extracts AFTER the UnitTests variables and the
39
+ ``variables`` patterns, so a criterion added later renumbers neither
40
+ (a stored baseline records each variable's index). Imported here, not at module level: importing
41
+ ``dstf.comparison`` runs its package init, which imports the comparator,
42
+ which imports ``dstf.backends`` — a cycle from any backend module."""
43
+ from ...comparison.tree_spec import tree_variables
44
+
45
+ return tree_variables(test.metric_tree_spec, diagnostic_vars)
46
+
47
+
48
+ def tracked_results(test: TestModel, result: TestResult) -> list[VariableResult]:
49
+ """The variables of ``result`` that ``test`` TRACKS: its UnitTests
50
+ variables and its ``variables`` patterns matched against the result's
51
+ names, ``"*"`` every one. What the flat path (no tree) scores, what a
52
+ tree warns of when no leaf covers it, and the Variables drawer's
53
+ Tracked group.
54
+
55
+ Never simply every variable the result holds. A tree names variables
56
+ too, and a backend records and extracts them beside the tracked ones:
57
+ a result read while a criterion was staged (a rerun runs with the
58
+ pending edits) keeps that variable after the criterion is discarded.
59
+ Scored, with no baseline, it failed the test for a variable the test
60
+ never asked about; under a saved tree it was named a tracked variable
61
+ without a criterion. On a result extracted for the test as it stands
62
+ with no tree this is every variable: extraction is exactly these two
63
+ groups.
64
+
65
+ Matched quietly: the backend that extracted the result already warned
66
+ about a pattern that matched nothing, and this runs on every rescore. A
67
+ variable with NO name is a backend bug: no name or pattern tracks it,
68
+ and leaving it out is said, not done quietly."""
69
+ if "*" in test.variable_patterns:
70
+ return list(result.variables)
71
+ for v in result.variables:
72
+ if not v.name:
73
+ logger.warning(
74
+ "%s: result variable %d has no name and is not tracked",
75
+ test.model_id,
76
+ v.index,
77
+ )
78
+ tracked = tracks(test)
79
+ return [v for v in result.variables if tracked(v.name)]
80
+
81
+
82
+ def tracks(test: TestModel) -> Callable[[str], bool]:
83
+ """Whether ``test`` TRACKS a variable of that name: one of its
84
+ UnitTests variables, a ``variables`` pattern that names it or a glob
85
+ that matches it (``"*"`` every name). THE one rule, asked of a result's
86
+ variables (:func:`tracked_results`) and of a baseline's (the
87
+ comparator: a tracked variable the baseline holds and the run does
88
+ not)."""
89
+ wanted = {v.name for v in unit_test_variables(test)}
90
+ globs = []
91
+ for pattern in test.variable_patterns:
92
+ if is_glob_pattern(pattern):
93
+ globs.append(_pattern_to_regex(pattern))
94
+ else:
95
+ wanted.add(pattern)
96
+ return lambda name: name in wanted or any(g.fullmatch(name) for g in globs)
97
+
98
+
99
+ def compute_needed_variables(
100
+ mat_path: Path,
101
+ test: TestModel,
102
+ diagnostic_vars: list[str],
103
+ ) -> set[str] | None:
104
+ """Determine which variables need to be extracted from a .mat file.
105
+
106
+ Returns a set of variable names, or None to load everything (fallback when
107
+ the name matrix cannot be read). This avoids loading all data for large
108
+ files with thousands of variables.
109
+ """
110
+ needed: set[str] = set()
111
+
112
+ # UnitTests component variables, under their result-file names
113
+ needed.update(v.name for v in unit_test_variables(test))
114
+
115
+ # Pattern-based variables — need the full name list to resolve globs
116
+ if test.variable_patterns:
117
+ all_names = list_result_mat_variables(mat_path)
118
+ if all_names is None:
119
+ return None # fallback: load everything
120
+ resolved = resolve_variable_patterns(test.variable_patterns, all_names)
121
+ needed.update(resolved)
122
+
123
+ # Variables a criterion names (literal: no name matrix needed)
124
+ needed.update(criterion_variables(test, diagnostic_vars))
125
+
126
+ needed.update(diagnostic_vars)
127
+
128
+ return needed if needed else None
129
+
130
+
131
+ def extract_variables(
132
+ mat_data: dict,
133
+ test: TestModel,
134
+ diagnostic_vars: list[str],
135
+ ) -> tuple[list[VariableResult], list[VariableResult]]:
136
+ """Extract tracked variables and diagnostic variables from parsed mat data.
137
+
138
+ Returns (variables, diagnostics).
139
+ """
140
+ results: list[VariableResult] = []
141
+ seen: set[str] = set()
142
+ idx = 1
143
+
144
+ # 1. UnitTests variables (source = "unit_tests" or "both"): the name is
145
+ # the result file's, the model's expression rides beside it as a label.
146
+ # ``seen`` therefore holds result-file names only — a pattern resolving
147
+ # to a DIFFERENT variable that an expression happens to spell is that
148
+ # other variable, and is extracted too.
149
+ for ut in unit_test_variables(test):
150
+ if ut.name in mat_data:
151
+ time_arr, values = mat_data[ut.name]
152
+ results.append(
153
+ VariableResult(
154
+ index=idx,
155
+ time=time_arr,
156
+ values=values,
157
+ name=ut.name,
158
+ expression=ut.expression,
159
+ )
160
+ )
161
+ seen.add(ut.name)
162
+ idx += 1
163
+ else:
164
+ # Loud on every backend: a requested variable absent from the
165
+ # output is a spec/model mismatch, not something to swallow.
166
+ logger.warning(
167
+ "Variable %s not found in simulation output for %s",
168
+ ut.name,
169
+ test.model_id,
170
+ )
171
+
172
+ # 2. Pattern-based variables (source = "spec" or "both")
173
+ if test.variable_patterns:
174
+ available = list(mat_data.keys())
175
+ resolved = resolve_variable_patterns(test.variable_patterns, available)
176
+ for var_name in resolved:
177
+ if var_name in seen:
178
+ continue
179
+ if var_name in mat_data:
180
+ time_arr, values = mat_data[var_name]
181
+ results.append(
182
+ VariableResult(
183
+ index=idx, time=time_arr, values=values, name=var_name
184
+ )
185
+ )
186
+ seen.add(var_name)
187
+ idx += 1
188
+
189
+ # 3. Variables a criterion names, APPENDED: a criterion added later
190
+ # renumbers neither of the first two groups (a stored baseline records
191
+ # each variable's index).
192
+ for var_name in criterion_variables(test, diagnostic_vars):
193
+ if var_name in seen:
194
+ continue
195
+ if var_name in mat_data:
196
+ time_arr, values = mat_data[var_name]
197
+ results.append(
198
+ VariableResult(index=idx, time=time_arr, values=values, name=var_name)
199
+ )
200
+ seen.add(var_name)
201
+ idx += 1
202
+ else:
203
+ logger.warning(
204
+ "Variable %s not found in simulation output for %s",
205
+ var_name,
206
+ test.model_id,
207
+ )
208
+
209
+ # 4. Diagnostic variables (auto-captured, not compared — D54–D55)
210
+ diagnostics: list[VariableResult] = []
211
+ diag_idx = 1
212
+ for var_name in diagnostic_vars:
213
+ if var_name in mat_data:
214
+ time_arr, values = mat_data[var_name]
215
+ diagnostics.append(
216
+ VariableResult(
217
+ index=diag_idx,
218
+ time=time_arr,
219
+ values=values,
220
+ name=var_name,
221
+ )
222
+ )
223
+ diag_idx += 1
224
+
225
+ return results, diagnostics
226
+
227
+
228
+ def unfinished_result(
229
+ test: TestModel,
230
+ mat_path: Path,
231
+ variables: list[VariableResult],
232
+ diagnostics: list[VariableResult],
233
+ stats: dict | None,
234
+ ) -> TestResult | None:
235
+ """The FAILED result an unfinished result file reads as — ``None`` for
236
+ a finished one, which the caller goes on to return as a success.
237
+
238
+ A file its writer did not finish (a killed or crashed run, a cut copy:
239
+ :func:`mat_shortfall`) is read like any other, so the rows it holds can
240
+ be SHOWN. Being a failure is what keeps them from being scored or
241
+ offered as a baseline: the reader takes its row count from the file's
242
+ size, so without this a partial trajectory would read as a result. A
243
+ file with no time point shows nothing.
244
+ """
245
+ shortfall = mat_shortfall(mat_path)
246
+ if shortfall is None:
247
+ return None
248
+ held = any(len(v.time) for v in variables)
249
+ return TestResult(
250
+ model_id=test.model_id,
251
+ success=False,
252
+ variables=variables if held else [],
253
+ diagnostics=diagnostics if held else [],
254
+ error_message=shortfall,
255
+ statistics=stats,
256
+ )
257
+
258
+
259
+ def fold_diagnostics_into_statistics(
260
+ stats: dict | None,
261
+ diagnostics: list[VariableResult],
262
+ ) -> dict | None:
263
+ """Add each diagnostic's final value to ``stats``, returning the dict.
264
+
265
+ Creates the dict if there is anything to fold and ``stats`` is None.
266
+ float32 trajectories are rounded to 7 significant digits (their real
267
+ precision) so the on-disk reference JSON does not carry binary noise.
268
+ """
269
+ if not diagnostics:
270
+ return stats
271
+ if stats is None:
272
+ stats = {}
273
+ for diag in diagnostics:
274
+ if len(diag.values) > 0:
275
+ sig = 7 if diag.values.dtype == np.float32 else 15
276
+ stats[diag.name] = float(f"%.{sig}g" % diag.values[-1])
277
+ return stats
278
+
279
+
280
+ def merge_timing_stats(stats: dict | None, timing: dict[str, float]) -> dict:
281
+ """Overlay wall-clock summary keys onto ``stats["timing"]``.
282
+
283
+ Non-destructive: whatever raw per-phase seconds a backend's log parser
284
+ already put under ``timing`` survive.
285
+ """
286
+ if stats is None:
287
+ stats = {}
288
+ stats.setdefault("timing", {}).update(timing)
289
+ return stats
290
+
291
+
292
+ def run_timing(rr: TestRunResult) -> dict[str, float]:
293
+ """The run's wall-clock phase summary — the ``statistics["timing"]`` keys
294
+ every backend's result carries (``translation_wall``, ``sim_wall``,
295
+ ``other_wall``, ``total_wall``): seconds, rounded to 2, only the keys the
296
+ run measured.
297
+
298
+ All are wall-clock seconds measured on the Python side, distinct from a
299
+ tool's own CPU-time statistics. ``{}`` when the run measured neither a
300
+ translation nor a simulation wall (Dymola batch: its ``elapsed`` is a
301
+ batch average, and a block built from it would show a time no test took).
302
+ ``other_wall`` — what the run spent outside translation and simulation —
303
+ only when both walls are known: a missing wall is not a 0.
304
+ """
305
+ if rr.translation_wall is None and rr.sim_wall is None:
306
+ return {}
307
+ timing: dict[str, float] = {}
308
+ if rr.translation_wall is not None:
309
+ timing["translation_wall"] = round(rr.translation_wall, 2)
310
+ if rr.sim_wall is not None:
311
+ timing["sim_wall"] = round(rr.sim_wall, 2)
312
+ if rr.elapsed:
313
+ if rr.translation_wall is not None and rr.sim_wall is not None:
314
+ other = rr.elapsed - rr.translation_wall - rr.sim_wall
315
+ timing["other_wall"] = round(max(0.0, other), 2)
316
+ timing["total_wall"] = round(rr.elapsed, 2)
317
+ return timing
318
+
319
+
320
+ __all__ = [
321
+ "compute_needed_variables",
322
+ "criterion_variables",
323
+ "extract_variables",
324
+ "fold_diagnostics_into_statistics",
325
+ "merge_timing_stats",
326
+ "run_timing",
327
+ "tracked_results",
328
+ "tracks",
329
+ ]