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,369 @@
1
+ """A DSresult ``.mat`` read WHILE its run writes it.
2
+
3
+ :class:`LiveMatReader` is built for one result-file path and polled about
4
+ once a second; every :meth:`~LiveMatReader.poll` returns a
5
+ :class:`LiveSnapshot` — how far the file has got and, for the names asked
6
+ for, the samples that are NEW since those names were last served. It reads
7
+ the file the way :mod:`.mat_reader` does (the row count from the file's
8
+ size, a variable resolved by the same code: an alias's sign, a parameter as
9
+ a constant), so what it returns, concatenated, is what the finished file
10
+ reads as.
11
+
12
+ What it is built around, each measured on Dymola 2026x / omc 1.27 under WSL:
13
+
14
+ * **Nothing is held between polls.** A poll opens the file, reads, and
15
+ closes it. A persistent worker wipes a test's directory before each run;
16
+ on Windows a handle held open makes that removal fail.
17
+ * **No memory map.** A map over a file that is then truncated in place
18
+ kills the process (SIGBUS).
19
+ * **Only the new rows are read.** Reading a whole 300 MB result costs over a
20
+ second on a Windows mount; its last rows cost milliseconds. In the steady
21
+ state a poll is one read: the last row already served (to see that the
22
+ file is still the same run) and the rows after it.
23
+ * **It never raises** on a file that is short, vanishing or being recreated.
24
+ A poll that finds no file says ``"gone"``; one that finds a file whose
25
+ header matrices are not whole yet says ``"starting"``.
26
+
27
+ A NEW RUN is a different file under the same path. It is recognised by any
28
+ of: the file was gone at a poll, its identity (device, inode) changed, it
29
+ got smaller, the last row served no longer reads back as it did — or, while
30
+ no row was served yet, its header names another structure than the one
31
+ read before (other names, other columns, another row shape).
32
+ The last two because a file removed and recreated takes the same inode on
33
+ ext4, and can have outgrown the old one before the next poll. The reader
34
+ then starts over, and :attr:`LiveSnapshot.run` counts up so its caller
35
+ drops what it had accumulated.
36
+
37
+ Not recognised: a file put in place of one whose rows were served, no
38
+ smaller than it, whose row at the last index served is the same bytes. Its
39
+ earlier rows are then the old file's on the caller's trace until the run
40
+ ends and the result is read whole.
41
+
42
+ A poll returns at most ``max_rows`` samples per name — fewer for a file
43
+ with very wide rows, so that no read of rows exceeds 16 MB (one row, where a
44
+ single row is larger). When more are new — the
45
+ first poll of a run already long under way, a name first asked for late —
46
+ they are thinned to that many, evenly spaced and always ending on the newest
47
+ row. Thinned rows are not served later: the live trace is a preview, and the
48
+ finished file is read by :func:`.mat_reader.read_result_mat`.
49
+
50
+ A reader belongs to one poller; it is not safe to poll from two threads.
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import os
56
+ from collections.abc import Collection, Mapping
57
+ from dataclasses import dataclass, field
58
+ from pathlib import Path
59
+ from types import MappingProxyType
60
+ from typing import Literal, NamedTuple
61
+
62
+ import numpy as np
63
+
64
+ from .mat_reader import (
65
+ _TRAJECTORY,
66
+ _read_tables,
67
+ _scan_mat4_headers,
68
+ _variable_sources,
69
+ )
70
+
71
+ LiveState = Literal["starting", "running", "gone"]
72
+
73
+ #: A file is there and its header matrices are not whole yet: nothing to read.
74
+ STARTING: LiveState = "starting"
75
+ #: The header matrices are read; rows are served as they arrive. Also the
76
+ #: state of a file whose run has ended — the reader does not judge that.
77
+ RUNNING: LiveState = "running"
78
+ #: No file at the path: not created yet, or removed.
79
+ GONE: LiveState = "gone"
80
+
81
+ #: Samples one poll returns per name at most (see the module docstring).
82
+ MAX_ROWS = 2000
83
+
84
+ #: No read of rows exceeds this many bytes (one row, where a single row is
85
+ #: larger). The rows a poll returns are capped to fit; a thinned backlog
86
+ #: spanning less is read in one piece and thinned in memory, a longer one a
87
+ #: row at a time.
88
+ _READ_BYTES = 16 << 20
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class LiveSeries:
93
+ """One variable's new samples, in the file's own precision. Read-only."""
94
+
95
+ time: np.ndarray
96
+ values: np.ndarray
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class LiveSnapshot:
101
+ """What one poll found. Immutable."""
102
+
103
+ #: ``"starting"`` / ``"running"`` / ``"gone"``.
104
+ state: LiveState
105
+ #: Which file under the path this is, counted from 0: it goes up each
106
+ #: time the reader starts over on a new run. A caller that accumulates
107
+ #: series drops them when this differs from the snapshot before.
108
+ run: int = 0
109
+ #: Whole rows (time points) the file holds.
110
+ rows: int = 0
111
+ #: The time of the last whole row; ``None`` before the first.
112
+ time: float | None = None
113
+ #: The new samples of each requested name the file has (possibly none:
114
+ #: empty arrays). A name the file lacks is left out, as is every name
115
+ #: until the state is ``"running"``.
116
+ series: Mapping[str, LiveSeries] = field(
117
+ default_factory=lambda: MappingProxyType({})
118
+ )
119
+
120
+
121
+ class _Layout(NamedTuple):
122
+ """The header matrices of one run's file, parsed once."""
123
+
124
+ offset: int # where the trajectory's rows start
125
+ dtype: np.dtype
126
+ n_values: int # values in a row; the first is the time
127
+ #: name → (index in a row, negate, constant) — :func:`_variable_sources`
128
+ sources: dict[str, tuple[int | None, bool, float | None]]
129
+ #: Every variable the file names, in its order, the abscissa left out:
130
+ #: what `result_files.mat_names` lists of the same file.
131
+ names: tuple[str, ...]
132
+ #: The file's STRUCTURE, hashed: where the rows start, their shape,
133
+ #: every name and where it is stored. Two headers with one key are one
134
+ #: file to a reader that has served no row. The parameters' VALUES
135
+ #: (``data_1``) are not in it: a tool may write them again under the
136
+ #: same structure, and nothing of them was served.
137
+ key: int
138
+
139
+ @property
140
+ def row_bytes(self) -> int:
141
+ return self.n_values * self.dtype.itemsize
142
+
143
+ def cap(self, max_rows: int) -> int:
144
+ """Rows one poll returns per name: ``max_rows``, or as many as
145
+ ``_READ_BYTES`` holds when that is fewer."""
146
+ return max(1, min(max_rows, _READ_BYTES // self.row_bytes))
147
+
148
+
149
+ def _read(f, offset: int, n: int) -> bytes:
150
+ f.seek(offset)
151
+ return f.read(n)
152
+
153
+
154
+ class LiveMatReader:
155
+ """Polls one result file for the rows its run has written so far."""
156
+
157
+ def __init__(self, path: Path, *, max_rows: int = MAX_ROWS) -> None:
158
+ if max_rows < 1:
159
+ raise ValueError(f"max_rows must be at least 1, got {max_rows}")
160
+ self.path = Path(path)
161
+ self.max_rows = max_rows
162
+ self._run = 0
163
+ self._forget()
164
+
165
+ def _forget(self) -> None:
166
+ self._identity: tuple[int, int] | None = None
167
+ self._size = 0
168
+ self._layout: _Layout | None = None
169
+ self._rows = 0
170
+ self._time: float | None = None
171
+ self._last_row = b""
172
+ self._served: dict[str, int] = {}
173
+
174
+ def _start_over(self) -> None:
175
+ self._run += 1
176
+ self._forget()
177
+
178
+ @property
179
+ def names(self) -> tuple[str, ...] | None:
180
+ """Every variable the file names, from the header the last poll
181
+ read rows through — no second parse of it; None until a poll found
182
+ the header whole."""
183
+ return None if self._layout is None else self._layout.names
184
+
185
+ def poll(self, names: Collection[str] = ()) -> LiveSnapshot:
186
+ """The file's state now, and each of ``names``' samples that are new
187
+ since that name was last served (all of them the first time, within
188
+ ``max_rows``). Opens, reads and closes the file; never raises for
189
+ what the file is or is not."""
190
+ try:
191
+ with open(self.path, "rb") as f:
192
+ return self._poll(f, list(dict.fromkeys(names)))
193
+ except (FileNotFoundError, NotADirectoryError):
194
+ if self._identity is not None:
195
+ self._start_over() # whatever comes next is another run
196
+ return LiveSnapshot(GONE, self._run)
197
+ except OSError:
198
+ # Unreadable right now (a sharing violation, a mount hiccup):
199
+ # no news, and no reason to think the run changed.
200
+ return self._snapshot({})
201
+
202
+ def _snapshot(self, series: dict[str, LiveSeries]) -> LiveSnapshot:
203
+ return LiveSnapshot(
204
+ state=STARTING if self._layout is None else RUNNING,
205
+ run=self._run,
206
+ rows=self._rows,
207
+ time=self._time,
208
+ series=MappingProxyType(series),
209
+ )
210
+
211
+ def _poll(self, f, names: list[str]) -> LiveSnapshot:
212
+ st = os.fstat(f.fileno())
213
+ identity = (st.st_dev, st.st_ino)
214
+ if self._identity is not None and (
215
+ identity != self._identity or st.st_size < self._size
216
+ ):
217
+ self._start_over()
218
+ self._identity, self._size = identity, st.st_size
219
+
220
+ if self._layout is None or self._rows == 0:
221
+ # Until a row was served nothing but the header itself says the
222
+ # file is still the one it was read from: a file put in its
223
+ # place — in place, and no smaller — would be read through the
224
+ # other's offsets and columns. So it is read again each poll
225
+ # (the parameters' values with it), and another STRUCTURE is
226
+ # another run.
227
+ layout = self._read_layout(f)
228
+ if self._layout is not None and (
229
+ layout is None or layout.key != self._layout.key
230
+ ):
231
+ self._start_over()
232
+ self._identity, self._size = identity, st.st_size
233
+ self._layout = layout
234
+ if layout is None:
235
+ return self._snapshot({})
236
+ layout = self._layout
237
+ present = max(0, st.st_size - layout.offset) // layout.row_bytes
238
+ cap = layout.cap(self.max_rows)
239
+
240
+ # Each name's unserved rows, the names grouped by where theirs
241
+ # start: one group in the steady state, a second when a name is
242
+ # asked for the first time.
243
+ groups: dict[int, list[str]] = {}
244
+ for name in names:
245
+ if name in layout.sources:
246
+ groups.setdefault(self._served.get(name, 0), []).append(name)
247
+
248
+ series: dict[str, LiveSeries] = {}
249
+ last_row: bytes | None = None
250
+ checked = self._rows == 0 # nothing served yet: nothing to check
251
+ for first, group in sorted(groups.items()):
252
+ # The steady state's one read: from the last row served, which
253
+ # must still read back as it did.
254
+ overlap = not checked and first == self._rows and present - first < cap
255
+ start = first - 1 if overlap else first
256
+ table, got = self._rows_between(f, layout, start, present, cap)
257
+ if overlap:
258
+ if len(table) == 0 or table[0].tobytes() != self._last_row:
259
+ return self._another_run(f, names)
260
+ checked = True
261
+ table = table[1:]
262
+ if got == present and len(table):
263
+ last_row = table[-1].tobytes()
264
+ for name in group:
265
+ series[name] = self._series(layout, name, table)
266
+ self._served[name] = max(got, first)
267
+
268
+ if (
269
+ not checked
270
+ and _read(
271
+ f, layout.offset + (self._rows - 1) * layout.row_bytes, layout.row_bytes
272
+ )
273
+ != self._last_row
274
+ ):
275
+ return self._another_run(f, names)
276
+ if present > self._rows:
277
+ if last_row is None:
278
+ last_row = _read(
279
+ f,
280
+ layout.offset + (present - 1) * layout.row_bytes,
281
+ layout.row_bytes,
282
+ )
283
+ if len(last_row) == layout.row_bytes:
284
+ self._rows, self._last_row = present, last_row
285
+ self._time = float(np.frombuffer(last_row, layout.dtype, 1)[0])
286
+ return self._snapshot(series)
287
+
288
+ def _another_run(self, f, names: list[str]) -> LiveSnapshot:
289
+ """The file is not the one the last poll read: start over on it."""
290
+ self._start_over()
291
+ return self._poll(f, names)
292
+
293
+ def _read_layout(self, f) -> _Layout | None:
294
+ """The header matrices, or ``None`` while the file does not hold
295
+ them whole (or holds something this reader cannot follow)."""
296
+ try:
297
+ f.seek(0) # a poll that started over has read rows already
298
+ blocks = _scan_mat4_headers(f).blocks
299
+ if not {"name", "dataInfo", _TRAJECTORY} <= blocks.keys():
300
+ return None
301
+ names, data_info, data_1 = _read_tables(f, blocks, self.path)
302
+ offset, dtype, n_values, _ = blocks[_TRAJECTORY]
303
+ if n_values == 0:
304
+ return None
305
+ sources = {
306
+ name: (index, negate, constant)
307
+ for name, index, negate, constant in _variable_sources(
308
+ names, data_info, data_1, n_values, self.path
309
+ )
310
+ }
311
+ except Exception:
312
+ # Cut between the scan and the read, or not a binTrans result:
313
+ # whatever a foreign file's bytes decode to, it is not a layout.
314
+ return None
315
+ listed = tuple(n for n in names if n.lower() != "time")
316
+ key = hash((offset, dtype.str, n_values, tuple(names), data_info.tobytes()))
317
+ return _Layout(offset, dtype, n_values, sources, listed, key)
318
+
319
+ @staticmethod
320
+ def _rows_between(
321
+ f, layout: _Layout, first: int, present: int, cap: int
322
+ ) -> tuple[np.ndarray, int]:
323
+ """Rows ``[first, present)`` as a ``(rows, values)`` table — every
324
+ one, or ``cap`` of them evenly spaced and ending on the last — and
325
+ the row count the table reaches (short of ``present`` only when the
326
+ file was cut under the read)."""
327
+ row_bytes = layout.row_bytes
328
+ count = max(0, present - first)
329
+ if count <= cap:
330
+ picks = range(first, first + count)
331
+ else:
332
+ step = -(-count // cap)
333
+ picks = range(present - 1 - step * ((count - 1) // step), present, step)
334
+ if not picks:
335
+ return np.empty((0, layout.n_values), dtype=layout.dtype), first
336
+ span = (picks[-1] - picks[0] + 1) * row_bytes
337
+ if picks.step == 1 or span <= _READ_BYTES:
338
+ raw = _read(f, layout.offset + picks[0] * row_bytes, span)
339
+ whole = len(raw) // row_bytes # a short read: its whole rows
340
+ table = np.frombuffer(
341
+ raw, dtype=layout.dtype, count=whole * layout.n_values
342
+ ).reshape(whole, layout.n_values)[:: picks.step]
343
+ else:
344
+ rows = []
345
+ for pick in picks:
346
+ raw = _read(f, layout.offset + pick * row_bytes, row_bytes)
347
+ if len(raw) < row_bytes:
348
+ break
349
+ rows.append(raw)
350
+ table = np.frombuffer(b"".join(rows), dtype=layout.dtype).reshape(
351
+ len(rows), layout.n_values
352
+ )
353
+ reached = picks[len(table) - 1] + 1 if len(table) else first
354
+ return table, reached
355
+
356
+ @staticmethod
357
+ def _series(layout: _Layout, name: str, table: np.ndarray) -> LiveSeries:
358
+ index, negate, constant = layout.sources[name]
359
+ time = table[:, 0].copy()
360
+ if index is None:
361
+ # A parameter: its one value at every time point.
362
+ values = np.full_like(time, constant)
363
+ else:
364
+ values = table[:, index].copy()
365
+ if negate:
366
+ values = -values
367
+ time.flags.writeable = False
368
+ values.flags.writeable = False
369
+ return LiveSeries(time, values)