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