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,632 @@
|
|
|
1
|
+
"""Reader for DSresult-format MAT files (Dymola / OpenModelica shared output).
|
|
2
|
+
|
|
3
|
+
Uses a custom MAT4 binary parser with numpy.memmap for selective variable
|
|
4
|
+
reading. This avoids loading the entire data_2 matrix (which can be hundreds
|
|
5
|
+
of MB for large models) when only a few variables are needed.
|
|
6
|
+
|
|
7
|
+
**How many time points a file holds comes from its SIZE, never from the
|
|
8
|
+
count in its header.** The trajectory matrix ``data_2`` is the file's last
|
|
9
|
+
matrix and both tools write its header BEFORE its rows: Dymola's count
|
|
10
|
+
starts at 1 and lags the rows by tens to hundreds of them while a run is in
|
|
11
|
+
progress, OpenModelica's stays 0 until the run ends. Neither is completed
|
|
12
|
+
when a run is killed. So the reader takes the whole time points the file
|
|
13
|
+
holds — a file cut mid-point yields the points before the cut — and a
|
|
14
|
+
crashed or timed-out run's rows are readable to where they stop.
|
|
15
|
+
|
|
16
|
+
Readable is not finished. A VERDICT (:func:`read_mat_time_extents`,
|
|
17
|
+
:func:`mat_reaches_time`) and a scored read (:func:`mat_shortfall`) still
|
|
18
|
+
ask for a finished file, and what says a file is finished is the header's
|
|
19
|
+
own count: it equals the points present and nothing follows them. A partial
|
|
20
|
+
result is shown, never scored and never rescued as a pass.
|
|
21
|
+
|
|
22
|
+
The anchor for "``data_2`` is last": every result both tools were seen to
|
|
23
|
+
write (Dymola 2026x, omc 1.27) ends with it, with no byte after its last
|
|
24
|
+
point. A writer that appended a matrix after ``data_2`` would make this
|
|
25
|
+
reader count that matrix's bytes as time points; such a file is then not
|
|
26
|
+
"finished" by the rule above (its header counts fewer), so it would fail
|
|
27
|
+
loudly rather than score.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
import os
|
|
32
|
+
import struct
|
|
33
|
+
from collections.abc import Collection
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import NamedTuple
|
|
36
|
+
|
|
37
|
+
import numpy as np
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
# MAT4 data type codes (P field from mopt)
|
|
42
|
+
_MAT4_DTYPES = {
|
|
43
|
+
0: np.dtype("<f8"), # 64-bit float
|
|
44
|
+
1: np.dtype("<f4"), # 32-bit float
|
|
45
|
+
2: np.dtype("<i4"), # 32-bit int
|
|
46
|
+
3: np.dtype("<i2"), # 16-bit int
|
|
47
|
+
4: np.dtype("<u2"), # 16-bit unsigned
|
|
48
|
+
5: np.dtype("<u1"), # 8-bit unsigned
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
#: The trajectory matrix: the one whose column count is taken from the size.
|
|
53
|
+
_TRAJECTORY = "data_2"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _Scan(NamedTuple):
|
|
57
|
+
"""What a file's headers and its size say it holds."""
|
|
58
|
+
|
|
59
|
+
#: name → (data_offset, dtype, mrows, ncols) of every matrix the file
|
|
60
|
+
#: holds whole, and of the trajectory with the columns PRESENT.
|
|
61
|
+
blocks: dict
|
|
62
|
+
#: The trajectory header's own column count; ``None`` without one.
|
|
63
|
+
declared: int | None
|
|
64
|
+
#: Bytes after the trajectory's last whole column.
|
|
65
|
+
trailing: int
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def finished(self) -> bool:
|
|
69
|
+
"""Did its writer finish this file: a trajectory of at least one
|
|
70
|
+
time point, as many as its header counts, and nothing after."""
|
|
71
|
+
block = self.blocks.get(_TRAJECTORY)
|
|
72
|
+
return (
|
|
73
|
+
block is not None
|
|
74
|
+
and block[3] > 0
|
|
75
|
+
and block[3] == self.declared
|
|
76
|
+
and self.trailing == 0
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _scan_mat4_headers(f) -> _Scan:
|
|
81
|
+
"""Scan a MAT4 file and return metadata for each top-level variable.
|
|
82
|
+
|
|
83
|
+
Only reads headers + variable names, skips all data payloads via seek().
|
|
84
|
+
|
|
85
|
+
The trajectory's column count — its time points — is worked out from
|
|
86
|
+
the file's size: the whole columns present (see the module docstring).
|
|
87
|
+
The scan stops there, and at the first matrix before it that the file
|
|
88
|
+
does not hold whole yet; a caller missing a matrix it needs then has
|
|
89
|
+
nothing to read.
|
|
90
|
+
"""
|
|
91
|
+
size = os.fstat(f.fileno()).st_size
|
|
92
|
+
variables = {}
|
|
93
|
+
while True:
|
|
94
|
+
hdr_bytes = f.read(20)
|
|
95
|
+
if len(hdr_bytes) < 20:
|
|
96
|
+
break
|
|
97
|
+
|
|
98
|
+
mopt, mrows, ncols, imagf, namlen = struct.unpack("<5i", hdr_bytes)
|
|
99
|
+
if namlen <= 0 or mrows < 0 or ncols < 0:
|
|
100
|
+
break # not a header: a cut or foreign file
|
|
101
|
+
|
|
102
|
+
# Extract data type from mopt: P = (mopt % 100) // 10
|
|
103
|
+
p_code = (mopt % 100) // 10
|
|
104
|
+
dtype = _MAT4_DTYPES.get(p_code, np.dtype("<f8"))
|
|
105
|
+
|
|
106
|
+
# Read variable name
|
|
107
|
+
name_bytes = f.read(namlen)
|
|
108
|
+
if len(name_bytes) < namlen:
|
|
109
|
+
break
|
|
110
|
+
name = name_bytes.rstrip(b"\x00").decode("latin1")
|
|
111
|
+
|
|
112
|
+
data_offset = f.tell()
|
|
113
|
+
|
|
114
|
+
if name == _TRAJECTORY:
|
|
115
|
+
col_bytes = mrows * dtype.itemsize
|
|
116
|
+
payload = max(0, size - data_offset)
|
|
117
|
+
present = payload // col_bytes if col_bytes else 0
|
|
118
|
+
variables[name] = (data_offset, dtype, mrows, present)
|
|
119
|
+
return _Scan(variables, ncols, payload - present * col_bytes)
|
|
120
|
+
|
|
121
|
+
data_size = mrows * ncols * dtype.itemsize
|
|
122
|
+
if data_offset + data_size > size:
|
|
123
|
+
break # this matrix is not all there yet
|
|
124
|
+
|
|
125
|
+
variables[name] = (data_offset, dtype, mrows, ncols)
|
|
126
|
+
|
|
127
|
+
# Skip past the data payload
|
|
128
|
+
f.seek(data_offset + data_size)
|
|
129
|
+
|
|
130
|
+
return _Scan(variables, None, 0)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _read_mat4_block(f, info) -> np.ndarray:
|
|
134
|
+
"""Read a full MAT4 data block into a numpy array.
|
|
135
|
+
|
|
136
|
+
Data is stored column-major (Fortran order), so a (mrows, ncols) matrix
|
|
137
|
+
is stored as ncols consecutive columns of mrows elements each.
|
|
138
|
+
Returns array with shape (mrows, ncols).
|
|
139
|
+
"""
|
|
140
|
+
data_offset, dtype, mrows, ncols = info
|
|
141
|
+
f.seek(data_offset)
|
|
142
|
+
raw = f.read(mrows * ncols * dtype.itemsize)
|
|
143
|
+
return np.frombuffer(raw, dtype=dtype).reshape((mrows, ncols), order="F")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _raise_if_binnormal(f, blocks, mat_path: Path) -> None:
|
|
147
|
+
"""Refuse binNormal-layout DSresult files.
|
|
148
|
+
|
|
149
|
+
review 2026-07-06 (finding 74): DSresult MATs come in two layouts —
|
|
150
|
+
binTrans (the Dymola/OM default; matrices stored transposed) declared in
|
|
151
|
+
the Aclass matrix, and binNormal. This reader only decodes binTrans; a
|
|
152
|
+
binNormal file (user-supplied .mat baseline) previously decoded as
|
|
153
|
+
transposed garbage silently returned as success. The raise is caught by
|
|
154
|
+
the callers' blanket handler and surfaced as a clear per-file error.
|
|
155
|
+
"""
|
|
156
|
+
if "Aclass" not in blocks:
|
|
157
|
+
return # no metadata — assume binTrans (historic behavior)
|
|
158
|
+
aclass = _read_mat4_block(f, blocks["Aclass"])
|
|
159
|
+
try:
|
|
160
|
+
text_f = "".join(chr(int(c)) for c in np.asarray(aclass).flatten(order="F"))
|
|
161
|
+
text_c = "".join(chr(int(c)) for c in np.asarray(aclass).flatten(order="C"))
|
|
162
|
+
except (ValueError, OverflowError, TypeError):
|
|
163
|
+
return # unreadable Aclass — don't block the historic path
|
|
164
|
+
if "binNormal" in text_f or "binNormal" in text_c:
|
|
165
|
+
raise ValueError(
|
|
166
|
+
f"{mat_path}: binNormal .mat layout not supported — "
|
|
167
|
+
f"re-export with Dymola/OM default binTrans"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def list_result_mat_variables(mat_path: Path) -> list[str] | None:
|
|
172
|
+
"""Read only the variable names from a Dymola .mat file (no data loaded).
|
|
173
|
+
|
|
174
|
+
This is fast even for large files — it reads headers and the name matrix only.
|
|
175
|
+
"""
|
|
176
|
+
try:
|
|
177
|
+
with open(mat_path, "rb") as f:
|
|
178
|
+
blocks = _scan_mat4_headers(f).blocks
|
|
179
|
+
|
|
180
|
+
if "name" not in blocks:
|
|
181
|
+
logger.error("No 'name' matrix in %s", mat_path)
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
# review 2026-07-06 (finding 74): binNormal names are stored
|
|
185
|
+
# untransposed — decoding them with the binTrans transpose below
|
|
186
|
+
# yields garbage names.
|
|
187
|
+
_raise_if_binnormal(f, blocks, mat_path)
|
|
188
|
+
|
|
189
|
+
# Dymola stores names as (max_name_len, n_vars) — transpose to
|
|
190
|
+
# get (n_vars, max_name_len) so each row is one variable name.
|
|
191
|
+
name_matrix = _read_mat4_block(f, blocks["name"]).T
|
|
192
|
+
return _parse_name_matrix(name_matrix)
|
|
193
|
+
except Exception as e:
|
|
194
|
+
logger.error("Failed to read %s: %s", mat_path, e)
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _read_tables(f, blocks, mat_path: Path):
|
|
199
|
+
"""The header matrices a trajectory is read through: every variable's
|
|
200
|
+
name, its ``dataInfo`` entry as one row ``(matrix, column, interpolation,
|
|
201
|
+
protected)``, and ``data_1`` (``None`` when the file has none).
|
|
202
|
+
"""
|
|
203
|
+
# review 2026-07-06 (finding 74): binNormal files would decode
|
|
204
|
+
# as transposed garbage returned as success. Refuse loudly.
|
|
205
|
+
_raise_if_binnormal(f, blocks, mat_path)
|
|
206
|
+
|
|
207
|
+
# Dymola stores names as (max_name_len, n_vars) — transpose so
|
|
208
|
+
# each row is one variable name.
|
|
209
|
+
name_matrix = _read_mat4_block(f, blocks["name"]).T
|
|
210
|
+
data_info = _read_mat4_block(f, blocks["dataInfo"])
|
|
211
|
+
|
|
212
|
+
data_1 = None
|
|
213
|
+
if "data_1" in blocks:
|
|
214
|
+
data_1 = _read_mat4_block(f, blocks["data_1"])
|
|
215
|
+
|
|
216
|
+
# MAT4 stores dataInfo column-major as (4, n_vars) — each column is one
|
|
217
|
+
# variable's {data_matrix, col, interp, protected} quad. We want to
|
|
218
|
+
# index by variable, so transpose to (n_vars, 4). The 4-row invariant
|
|
219
|
+
# is fixed by the DSresult format; an earlier shape[0]<shape[1]
|
|
220
|
+
# heuristic silently broke for MATs with n_vars <= 4 (only surfaced
|
|
221
|
+
# on OM where tight variableFilter often leaves a handful of vars).
|
|
222
|
+
if (
|
|
223
|
+
data_info.ndim == 2
|
|
224
|
+
and data_info.shape[0] == 4
|
|
225
|
+
or data_info.ndim == 2
|
|
226
|
+
and data_info.shape[0] < data_info.shape[1]
|
|
227
|
+
):
|
|
228
|
+
data_info = data_info.T
|
|
229
|
+
|
|
230
|
+
return _parse_name_matrix(name_matrix), data_info, data_1
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _variable_sources(
|
|
234
|
+
names: list[str],
|
|
235
|
+
data_info: np.ndarray,
|
|
236
|
+
data_1: np.ndarray | None,
|
|
237
|
+
n_trajectories: int,
|
|
238
|
+
mat_path: Path,
|
|
239
|
+
wanted: set[str] | None = None,
|
|
240
|
+
):
|
|
241
|
+
"""Where each readable variable's values are: yields ``(name, row,
|
|
242
|
+
negate, constant)`` — ``row`` of ``data_2`` for a trajectory, or
|
|
243
|
+
``constant`` (``row`` is then ``None``) for a parameter of ``data_1``;
|
|
244
|
+
``negate`` for an alias stored with the opposite sign. A variable the
|
|
245
|
+
file gives no data for is left out, as is one not in ``wanted``.
|
|
246
|
+
"""
|
|
247
|
+
for i, name in enumerate(names):
|
|
248
|
+
if i >= len(data_info):
|
|
249
|
+
break
|
|
250
|
+
|
|
251
|
+
# Skip variables we don't need
|
|
252
|
+
if wanted is not None and name not in wanted:
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
info = data_info[i]
|
|
256
|
+
data_matrix_idx = int(info[0])
|
|
257
|
+
col_idx = int(info[1])
|
|
258
|
+
if col_idx == 0:
|
|
259
|
+
# review 2026-07-06 (finding 74): dataInfo column 0 means "not
|
|
260
|
+
# stored in a data matrix" (abscissa marker); abs(0)-1 == -1
|
|
261
|
+
# previously wrapped to the LAST row — returning the wrong
|
|
262
|
+
# variable's data under the requested name.
|
|
263
|
+
logger.debug(
|
|
264
|
+
"Variable %s has dataInfo column 0 in %s; skipping",
|
|
265
|
+
name,
|
|
266
|
+
mat_path,
|
|
267
|
+
)
|
|
268
|
+
continue
|
|
269
|
+
negate = col_idx < 0
|
|
270
|
+
col = abs(col_idx) - 1
|
|
271
|
+
|
|
272
|
+
if data_matrix_idx == 1 and data_1 is not None:
|
|
273
|
+
if col >= data_1.shape[0]:
|
|
274
|
+
continue
|
|
275
|
+
# data_1 stores constants/parameters. Dymola typically uses
|
|
276
|
+
# 2 columns (start_value, end_value). Use the first value.
|
|
277
|
+
val = data_1[col, 0] if data_1.ndim == 2 else data_1[col]
|
|
278
|
+
yield name, None, negate, float(val)
|
|
279
|
+
elif data_matrix_idx == 2:
|
|
280
|
+
if col >= n_trajectories:
|
|
281
|
+
continue
|
|
282
|
+
yield name, col, negate, None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class ResultMatFile:
|
|
286
|
+
"""A result file's header matrices, parsed ONCE, and any of its
|
|
287
|
+
variables read through them after that: :meth:`series` as often as
|
|
288
|
+
asked costs one parse — a quarter of a second for 20,000 variables —
|
|
289
|
+
and every series it hands out shares one time array.
|
|
290
|
+
|
|
291
|
+
For a file that no longer changes (a finished result, or what a run
|
|
292
|
+
that was stopped left). One that does change between two reads — its
|
|
293
|
+
identity, size or modification time — is parsed again, so a file
|
|
294
|
+
replaced under the path is never read through the header of the one
|
|
295
|
+
before.
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
def __init__(self, mat_path: Path) -> None:
|
|
299
|
+
self.path = Path(mat_path)
|
|
300
|
+
self._stamp: tuple[int, int, int, int] | None = None
|
|
301
|
+
#: The trajectory's ``(offset, dtype, mrows, ncols)``.
|
|
302
|
+
self._block: tuple | None = None
|
|
303
|
+
#: name → (row of ``data_2`` or None, negate, constant), in the
|
|
304
|
+
#: file's order (:func:`_variable_sources`).
|
|
305
|
+
self._sources: dict[str, tuple[int | None, bool, float | None]] = {}
|
|
306
|
+
self._order: dict[str, int] = {}
|
|
307
|
+
self._names: tuple[str, ...] = ()
|
|
308
|
+
self._time: np.ndarray | None = None
|
|
309
|
+
|
|
310
|
+
@property
|
|
311
|
+
def names(self) -> tuple[str, ...] | None:
|
|
312
|
+
"""Every variable the file names, in its order, the abscissa left
|
|
313
|
+
out — what :func:`list_result_mat_variables` reads of the same file
|
|
314
|
+
(``result_files.mat_names``), from the one parse. None for a file
|
|
315
|
+
that cannot be read."""
|
|
316
|
+
return None if self.series(()) is None else self._names
|
|
317
|
+
|
|
318
|
+
@staticmethod
|
|
319
|
+
def _stamp_of(st: os.stat_result) -> tuple[int, int, int, int]:
|
|
320
|
+
return st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns
|
|
321
|
+
|
|
322
|
+
def _parse(self) -> bool:
|
|
323
|
+
"""Read the header matrices; False (logged) for a file without
|
|
324
|
+
them."""
|
|
325
|
+
self._block, self._sources, self._order, self._time = None, {}, {}, None
|
|
326
|
+
self._names = ()
|
|
327
|
+
with open(self.path, "rb") as f:
|
|
328
|
+
self._stamp = self._stamp_of(os.fstat(f.fileno()))
|
|
329
|
+
blocks = _scan_mat4_headers(f).blocks
|
|
330
|
+
|
|
331
|
+
if "name" not in blocks:
|
|
332
|
+
logger.error("No 'name' matrix in %s", self.path)
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
if "dataInfo" not in blocks or "data_2" not in blocks:
|
|
336
|
+
logger.error("Missing dataInfo or data_2 in %s", self.path)
|
|
337
|
+
return False
|
|
338
|
+
|
|
339
|
+
# Load small matrices eagerly.
|
|
340
|
+
all_var_names, data_info, data_1 = _read_tables(f, blocks, self.path)
|
|
341
|
+
|
|
342
|
+
block = blocks["data_2"]
|
|
343
|
+
for name, row, negate, constant in _variable_sources(
|
|
344
|
+
all_var_names, data_info, data_1, block[2], self.path
|
|
345
|
+
):
|
|
346
|
+
self._sources[name] = (row, negate, constant)
|
|
347
|
+
self._order = {name: i for i, name in enumerate(self._sources)}
|
|
348
|
+
self._names = tuple(n for n in all_var_names if n.lower() != "time")
|
|
349
|
+
self._block = block
|
|
350
|
+
return True
|
|
351
|
+
|
|
352
|
+
def series(self, variable_names: Collection[str] | None = None) -> dict | None:
|
|
353
|
+
"""``{name: (time, values)}`` for ``variable_names`` (every
|
|
354
|
+
variable for None), in the file's order — what
|
|
355
|
+
:func:`read_result_mat` returns, which is this on a file opened for
|
|
356
|
+
the one read. None, with the reason logged, for a file that cannot
|
|
357
|
+
be read."""
|
|
358
|
+
try:
|
|
359
|
+
changed = self._stamp is None or self._stamp != self._stamp_of(
|
|
360
|
+
os.stat(self.path)
|
|
361
|
+
)
|
|
362
|
+
if changed and not self._parse():
|
|
363
|
+
return None
|
|
364
|
+
if self._block is None:
|
|
365
|
+
return None # parsed before, and it held no header matrices
|
|
366
|
+
|
|
367
|
+
# Memory-map data_2 for selective row access.
|
|
368
|
+
# MAT4 is column-major: Fortran order gives correct (mrows, ncols) indexing.
|
|
369
|
+
# For Dymola, mrows = n_trajectory_vars, ncols = n_timesteps (but transposed
|
|
370
|
+
# from MATLAB convention). Dymola stores data_2 as (n_timesteps, n_vars) in
|
|
371
|
+
# MATLAB convention, so mrows=n_timesteps, ncols=n_vars.
|
|
372
|
+
d2_offset, d2_dtype, d2_mrows, d2_ncols = self._block
|
|
373
|
+
if d2_mrows == 0 or d2_ncols == 0:
|
|
374
|
+
# No time point yet: there is nothing to map.
|
|
375
|
+
data_2 = np.empty((max(d2_mrows, 1), 0), dtype=d2_dtype)
|
|
376
|
+
else:
|
|
377
|
+
data_2 = np.memmap(
|
|
378
|
+
self.path,
|
|
379
|
+
dtype=d2_dtype,
|
|
380
|
+
mode="r",
|
|
381
|
+
offset=d2_offset,
|
|
382
|
+
shape=(d2_mrows, d2_ncols),
|
|
383
|
+
order="F",
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
if self._time is None:
|
|
387
|
+
self._time = np.array(data_2[0, :]) # Copy time out of memmap
|
|
388
|
+
time = self._time
|
|
389
|
+
|
|
390
|
+
if variable_names is None:
|
|
391
|
+
picked = list(self._sources)
|
|
392
|
+
else:
|
|
393
|
+
# The few asked for, not a walk over every name the file has.
|
|
394
|
+
picked = sorted(
|
|
395
|
+
self._order.keys() & set(variable_names),
|
|
396
|
+
key=self._order.__getitem__,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
result = {}
|
|
400
|
+
for name in picked:
|
|
401
|
+
row, negate, constant = self._sources[name]
|
|
402
|
+
if row is None:
|
|
403
|
+
# A parameter: its one value at every time point.
|
|
404
|
+
values = np.full_like(time, constant)
|
|
405
|
+
else:
|
|
406
|
+
# Only this row is actually read from disk (memmap)
|
|
407
|
+
values = np.array(data_2[row, :])
|
|
408
|
+
|
|
409
|
+
if negate:
|
|
410
|
+
values = -values
|
|
411
|
+
|
|
412
|
+
result[name] = (time, values)
|
|
413
|
+
|
|
414
|
+
return result
|
|
415
|
+
|
|
416
|
+
except Exception as e:
|
|
417
|
+
self._stamp = None # nothing of a failed read is kept
|
|
418
|
+
logger.error("Failed to read %s: %s", self.path, e)
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def read_result_mat(
|
|
423
|
+
mat_path: Path,
|
|
424
|
+
variable_names: set[str] | None = None,
|
|
425
|
+
) -> dict | None:
|
|
426
|
+
"""Read a Dymola-format .mat file with selective variable loading.
|
|
427
|
+
|
|
428
|
+
Dymola uses a specific MAT4 format with:
|
|
429
|
+
- 'Aclass': metadata matrix
|
|
430
|
+
- 'name': character matrix of variable names
|
|
431
|
+
- 'data_1': parameter/constant values
|
|
432
|
+
- 'data_2': trajectory values (time-varying)
|
|
433
|
+
- 'dataInfo': mapping from name index to data matrix and column
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
mat_path: Path to the .mat file.
|
|
437
|
+
variable_names: If provided, only extract these variables.
|
|
438
|
+
Uses numpy.memmap on data_2 to read only the needed rows,
|
|
439
|
+
avoiding loading the entire trajectory matrix into memory.
|
|
440
|
+
|
|
441
|
+
Returns dict with variable names as keys and (time, values) tuples —
|
|
442
|
+
over the whole time points the file HOLDS, which for a run in progress
|
|
443
|
+
or a killed one is fewer than the run asked for (and none at all before
|
|
444
|
+
its first point: every series is then empty). Whether the file is a
|
|
445
|
+
finished result is :func:`mat_shortfall`'s question, not this one's.
|
|
446
|
+
|
|
447
|
+
One read of one file: a caller that reads the same file again and again
|
|
448
|
+
keeps a :class:`ResultMatFile`, which parses its header once.
|
|
449
|
+
"""
|
|
450
|
+
return ResultMatFile(mat_path).series(variable_names)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _time_at(f, block, point: int) -> float:
|
|
454
|
+
"""The time of one time point: row 0 of the trajectory's column."""
|
|
455
|
+
offset, dtype, mrows, _ = block
|
|
456
|
+
f.seek(offset + point * mrows * dtype.itemsize)
|
|
457
|
+
return float(np.frombuffer(f.read(dtype.itemsize), dtype=dtype)[0])
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def read_mat_time_extents(mat_path: Path) -> tuple[float, float] | None:
|
|
461
|
+
"""Cheaply read just the first and last time values of a FINISHED
|
|
462
|
+
Dymola .mat.
|
|
463
|
+
|
|
464
|
+
Useful for verifying a simulation reached its requested stop time.
|
|
465
|
+
Returns None if the file is unreadable, contains no time data, or was
|
|
466
|
+
not finished by its writer (``_Scan.finished``): the rows of a killed
|
|
467
|
+
run are readable to where they stop, and a run whose last row reached
|
|
468
|
+
the stop time under a header that was never completed still did not
|
|
469
|
+
finish. This is the verdict's reading; :func:`read_result_mat` is the
|
|
470
|
+
one that shows what is there.
|
|
471
|
+
|
|
472
|
+
Bypasses the full variable-iteration in read_result_mat — Dymola stores
|
|
473
|
+
time as row 0 of data_2, so we only need two values from disk.
|
|
474
|
+
"""
|
|
475
|
+
try:
|
|
476
|
+
with open(mat_path, "rb") as f:
|
|
477
|
+
scan = _scan_mat4_headers(f)
|
|
478
|
+
# review 2026-07-06 (finding 74) applies here too: in a binNormal
|
|
479
|
+
# file data_2 is stored untransposed, so row 0 is not time — the
|
|
480
|
+
# extents would be garbage. The raise lands in the handler below.
|
|
481
|
+
_raise_if_binnormal(f, scan.blocks, mat_path)
|
|
482
|
+
if not scan.finished:
|
|
483
|
+
return None
|
|
484
|
+
block = scan.blocks[_TRAJECTORY]
|
|
485
|
+
return _time_at(f, block, 0), _time_at(f, block, block[3] - 1)
|
|
486
|
+
except Exception:
|
|
487
|
+
return None
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def read_mat_last_time(mat_path: Path) -> float | None:
|
|
491
|
+
"""The time of the last whole time point ``mat_path`` HOLDS, finished
|
|
492
|
+
or not: how far a killed run got. ``None`` for a file that is
|
|
493
|
+
unreadable or holds no point. Never a verdict —
|
|
494
|
+
:func:`read_mat_time_extents` is, and answers only a finished file."""
|
|
495
|
+
try:
|
|
496
|
+
with open(mat_path, "rb") as f:
|
|
497
|
+
scan = _scan_mat4_headers(f)
|
|
498
|
+
_raise_if_binnormal(f, scan.blocks, mat_path)
|
|
499
|
+
block = scan.blocks.get(_TRAJECTORY)
|
|
500
|
+
if block is None or block[3] == 0:
|
|
501
|
+
return None
|
|
502
|
+
return _time_at(f, block, block[3] - 1)
|
|
503
|
+
except Exception:
|
|
504
|
+
return None
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def mat_reaches_time(
|
|
508
|
+
mat_path: Path,
|
|
509
|
+
target_time: float,
|
|
510
|
+
) -> tuple[bool, float | None]:
|
|
511
|
+
"""Did the trajectory in ``mat_path`` reach ``target_time``?
|
|
512
|
+
|
|
513
|
+
Returns ``(reached, last_time)``; ``last_time`` is None when the file
|
|
514
|
+
is unreadable or unfinished (:func:`read_mat_time_extents`). The
|
|
515
|
+
relative tolerance mirrors the persistent-OM disk-rescue rule so
|
|
516
|
+
success-path and rescue-path verdicts can't disagree about the same
|
|
517
|
+
file (D99).
|
|
518
|
+
"""
|
|
519
|
+
extents = read_mat_time_extents(mat_path)
|
|
520
|
+
if extents is None:
|
|
521
|
+
return False, None
|
|
522
|
+
last_time = extents[1]
|
|
523
|
+
tol = max(1e-6, abs(target_time) * 1e-6)
|
|
524
|
+
return last_time + tol >= target_time, last_time
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def mat_shortfall(mat_path: Path) -> str | None:
|
|
528
|
+
"""Why ``mat_path`` is not a finished result, in words for a person —
|
|
529
|
+
``None`` when it is one.
|
|
530
|
+
|
|
531
|
+
The scored read asks this beside :func:`read_result_mat`: the rows of
|
|
532
|
+
an unfinished file are returned to be SHOWN, and this is what keeps
|
|
533
|
+
them from being scored or accepted as a baseline.
|
|
534
|
+
"""
|
|
535
|
+
name = Path(mat_path).name
|
|
536
|
+
try:
|
|
537
|
+
with open(mat_path, "rb") as f:
|
|
538
|
+
scan = _scan_mat4_headers(f)
|
|
539
|
+
_raise_if_binnormal(f, scan.blocks, mat_path)
|
|
540
|
+
if scan.finished:
|
|
541
|
+
return None
|
|
542
|
+
block = scan.blocks.get(_TRAJECTORY)
|
|
543
|
+
if block is None:
|
|
544
|
+
return f"{name} unreadable"
|
|
545
|
+
points = block[3]
|
|
546
|
+
if points == 0:
|
|
547
|
+
return f"Empty result: {name} holds no time point"
|
|
548
|
+
held = f"{points} time point{'s' if points != 1 else ''}"
|
|
549
|
+
held += f" up to t={_time_at(f, block, points - 1):.6g}"
|
|
550
|
+
if scan.trailing:
|
|
551
|
+
held += f" and {scan.trailing} bytes of a further one"
|
|
552
|
+
return (
|
|
553
|
+
f"Partial result: {name} holds {held}, its header counts "
|
|
554
|
+
f"{scan.declared} — the run did not finish writing it"
|
|
555
|
+
)
|
|
556
|
+
except Exception:
|
|
557
|
+
return f"{name} unreadable"
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _as_bytes(codes: np.ndarray) -> np.ndarray | None:
|
|
561
|
+
"""A 2-D matrix of character codes as one byte a code, where every code
|
|
562
|
+
— truncated, as ``int()`` truncates a float one — is a byte's (0..255):
|
|
563
|
+
Latin-1 decodes those exactly as ``chr`` does. None for any other
|
|
564
|
+
matrix, an empty one included; it is then read a character at a time."""
|
|
565
|
+
if codes.size == 0 or codes.shape[1] == 0:
|
|
566
|
+
return None
|
|
567
|
+
if codes.dtype.kind == "f":
|
|
568
|
+
if not np.isfinite(codes).all():
|
|
569
|
+
return None
|
|
570
|
+
codes = np.trunc(codes)
|
|
571
|
+
if codes.min() < 0 or codes.max() > 255:
|
|
572
|
+
return None
|
|
573
|
+
return np.ascontiguousarray(codes, dtype=np.uint8)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _parse_name_matrix(name_matrix: np.ndarray) -> list[str]:
|
|
577
|
+
"""Parse Dymola's name matrix into a list of variable name strings.
|
|
578
|
+
|
|
579
|
+
Dymola stores names as a 2D integer array (n_vars x max_name_len)
|
|
580
|
+
where each row contains character codes for one variable name.
|
|
581
|
+
"""
|
|
582
|
+
if name_matrix.ndim == 2 and name_matrix.dtype.kind in ("u", "i", "f"):
|
|
583
|
+
# 2D integer/float array — each row is char codes for one name
|
|
584
|
+
packed = _as_bytes(name_matrix)
|
|
585
|
+
if packed is not None:
|
|
586
|
+
# A row at a time: a character at a time was 0.3 s of a
|
|
587
|
+
# 20,000-variable file's header, for every read of it.
|
|
588
|
+
raw, width = packed.tobytes(), packed.shape[1]
|
|
589
|
+
return [
|
|
590
|
+
raw[start : start + width].decode("latin1").strip("\x00").strip()
|
|
591
|
+
for start in range(0, len(raw), width)
|
|
592
|
+
]
|
|
593
|
+
var_names = []
|
|
594
|
+
for row in name_matrix:
|
|
595
|
+
name = "".join(chr(int(c)) for c in row).strip("\x00").strip()
|
|
596
|
+
var_names.append(name)
|
|
597
|
+
return var_names
|
|
598
|
+
|
|
599
|
+
if name_matrix.ndim == 1 and name_matrix.dtype.kind == "U":
|
|
600
|
+
# 1D array of unicode strings — column-major character matrix
|
|
601
|
+
n_cols = len(name_matrix)
|
|
602
|
+
if n_cols == 0:
|
|
603
|
+
return []
|
|
604
|
+
n_vars = len(name_matrix[0])
|
|
605
|
+
var_names = []
|
|
606
|
+
for j in range(n_vars):
|
|
607
|
+
name = "".join(
|
|
608
|
+
name_matrix[i][j] for i in range(n_cols) if j < len(name_matrix[i])
|
|
609
|
+
)
|
|
610
|
+
name = name.strip("\x00").strip()
|
|
611
|
+
if name:
|
|
612
|
+
var_names.append(name)
|
|
613
|
+
return var_names
|
|
614
|
+
|
|
615
|
+
if name_matrix.ndim == 2 and name_matrix.dtype.kind in ("U", "S"):
|
|
616
|
+
# 2D array of single-char strings — each row is one name
|
|
617
|
+
var_names = []
|
|
618
|
+
for row in name_matrix:
|
|
619
|
+
name = "".join(str(c) for c in row).strip("\x00").strip()
|
|
620
|
+
var_names.append(name)
|
|
621
|
+
return var_names
|
|
622
|
+
|
|
623
|
+
# Fallback
|
|
624
|
+
logger.warning(
|
|
625
|
+
"Unexpected name matrix format: dtype=%s shape=%s",
|
|
626
|
+
name_matrix.dtype,
|
|
627
|
+
name_matrix.shape,
|
|
628
|
+
)
|
|
629
|
+
var_names = []
|
|
630
|
+
for item in name_matrix.flat:
|
|
631
|
+
var_names.append(str(item).strip("\x00").strip())
|
|
632
|
+
return var_names
|