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