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,385 @@
|
|
|
1
|
+
"""The runs whose result the Session does not have (D151): a test the queue
|
|
2
|
+
is running, and one whose last run ended without finishing and left a
|
|
3
|
+
partial result file. The Session's own tier — it holds the readers and
|
|
4
|
+
opens the files; what their snapshots become is `authoring.live_rows`.
|
|
5
|
+
|
|
6
|
+
A run that STARTS takes its test's result out of memory (D150: the run
|
|
7
|
+
cleared it on disk). What the page was laid out by goes with it — the
|
|
8
|
+
scored names its plots derive from, the tracked names, the labels — so the
|
|
9
|
+
run's entry here keeps the comparison and the result it replaced, for the
|
|
10
|
+
LAYOUT alone: no verdict and no sample of them is shown.
|
|
11
|
+
|
|
12
|
+
While it runs, on a backend whose result file grows
|
|
13
|
+
(``Capability.LIVE_RESULTS``), :meth:`LiveRuns.poll` reads the file once a
|
|
14
|
+
second — for the names the open pages asked for (:meth:`LiveRuns.ask`), and
|
|
15
|
+
with none, the time it has reached, which the queue panel's row says. One
|
|
16
|
+
reader per run, polled from the UI loop: two tabs on one test share it.
|
|
17
|
+
|
|
18
|
+
"In progress" is the queue's word, never the reader's: a finished file
|
|
19
|
+
reads ``running`` too. An entry is in progress until the Session says its
|
|
20
|
+
item ended (:meth:`LiveRuns.ended`); then it goes — or, where the run did
|
|
21
|
+
not finish and its file holds a row, stays as a :class:`~.live_rows.Partial`,
|
|
22
|
+
read by the size-based reader's partial read. A partial result is shown:
|
|
23
|
+
nothing here hands it to the comparator or to the store.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from collections.abc import Callable, Collection
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import TYPE_CHECKING
|
|
32
|
+
|
|
33
|
+
import numpy as np
|
|
34
|
+
|
|
35
|
+
from ..backends.common.live_mat import RUNNING, LiveMatReader
|
|
36
|
+
from ..backends.common.mat_reader import ResultMatFile, read_mat_last_time
|
|
37
|
+
from ..backends.pool import ItemState
|
|
38
|
+
from ..backends.types import Capability, TestResult, VariableResult
|
|
39
|
+
from ..discovery.spec_update import is_literal_variable
|
|
40
|
+
from ..discovery.unit_tests import unit_test_variables
|
|
41
|
+
from .live_rows import (
|
|
42
|
+
NOT_SCORED,
|
|
43
|
+
STARTING_WORD,
|
|
44
|
+
WAITING,
|
|
45
|
+
LiveTrace,
|
|
46
|
+
Partial,
|
|
47
|
+
how_ended,
|
|
48
|
+
progress,
|
|
49
|
+
run_revision,
|
|
50
|
+
spanned,
|
|
51
|
+
stopped_marker,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if TYPE_CHECKING:
|
|
55
|
+
from ..backends.pool import QueueItem
|
|
56
|
+
from ..comparison.types import TestComparison
|
|
57
|
+
from ..discovery.test_registry import TestModel
|
|
58
|
+
from .session import Session
|
|
59
|
+
|
|
60
|
+
#: Polls a page's ask outlives: a page that stopped asking (closed, moved to
|
|
61
|
+
#: another test) costs its names two more reads.
|
|
62
|
+
ASK_LIFE = 2
|
|
63
|
+
|
|
64
|
+
Series = tuple[np.ndarray, np.ndarray]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def expected_result(test: TestModel) -> TestResult | None:
|
|
68
|
+
"""What a test that never had a result is laid out by while its first
|
|
69
|
+
run is in progress, and after one that left a partial file: the
|
|
70
|
+
variables its model and its spec NAME — the UnitTests variables with
|
|
71
|
+
their expressions, then every ``variables`` entry that names one (a
|
|
72
|
+
pattern names none until a file is matched against it). No sample of
|
|
73
|
+
any: a layout, never a trace. None for a test that names none."""
|
|
74
|
+
named = {v.name: v.expression for v in unit_test_variables(test)}
|
|
75
|
+
for pattern in test.variable_patterns:
|
|
76
|
+
if is_literal_variable(pattern):
|
|
77
|
+
named.setdefault(pattern, "")
|
|
78
|
+
if not named:
|
|
79
|
+
return None
|
|
80
|
+
empty = np.empty(0)
|
|
81
|
+
return TestResult(
|
|
82
|
+
model_id=test.model_id,
|
|
83
|
+
success=True,
|
|
84
|
+
variables=[
|
|
85
|
+
VariableResult(
|
|
86
|
+
index=index, time=empty, values=empty, name=name, expression=text
|
|
87
|
+
)
|
|
88
|
+
for index, (name, text) in enumerate(named.items(), start=1)
|
|
89
|
+
],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class _Run:
|
|
95
|
+
"""One model's run with no result: in progress, or (``partial``) ended
|
|
96
|
+
without finishing."""
|
|
97
|
+
|
|
98
|
+
item: QueueItem
|
|
99
|
+
#: What the run replaced, for the page's layout.
|
|
100
|
+
comparison: TestComparison | None
|
|
101
|
+
result: TestResult | None
|
|
102
|
+
#: Whether the backend's result file grows while the run is in progress.
|
|
103
|
+
live: bool
|
|
104
|
+
trace: LiveTrace
|
|
105
|
+
#: The ``uirevision`` of this run's figures, its partial result's too.
|
|
106
|
+
revision: str
|
|
107
|
+
reader: LiveMatReader | None = None
|
|
108
|
+
#: The names each page asked for, and the poll it last asked at.
|
|
109
|
+
asked: dict[object, tuple[frozenset[str], int]] = field(default_factory=dict)
|
|
110
|
+
#: The reader's ``run`` whose file the pages were told can be read.
|
|
111
|
+
announced: int | None = None
|
|
112
|
+
partial: Partial | None = None
|
|
113
|
+
#: A partial file, its header parsed once for every name asked of it,
|
|
114
|
+
#: and its series by name; None is a name it lacks.
|
|
115
|
+
file: ResultMatFile | None = None
|
|
116
|
+
series: dict[str, Series | None] = field(default_factory=dict)
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def stop_time(self) -> float | None:
|
|
120
|
+
return self.item.test.stop_time
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class LiveRuns:
|
|
124
|
+
def __init__(
|
|
125
|
+
self, session: Session, emit: Callable[[str, frozenset[str]], None]
|
|
126
|
+
) -> None:
|
|
127
|
+
"""``emit(state, models)`` says a `queue` event the Session's
|
|
128
|
+
way: a run's file became readable, and its pages re-read what they
|
|
129
|
+
list of it."""
|
|
130
|
+
self._session = session
|
|
131
|
+
self._emit = emit
|
|
132
|
+
self._runs: dict[str, _Run] = {}
|
|
133
|
+
self._polls = 0
|
|
134
|
+
self._starts = 0
|
|
135
|
+
|
|
136
|
+
# -- the Session's calls ---------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def started(self, item: QueueItem) -> None:
|
|
139
|
+
"""``item`` started — called BEFORE the Session drops its test's
|
|
140
|
+
result. A test started again after a run that left none (a cancel)
|
|
141
|
+
keeps the layout that run kept; one that never had a result is laid
|
|
142
|
+
out by what it names (`expected_result`)."""
|
|
143
|
+
session = self._session
|
|
144
|
+
project = session.project
|
|
145
|
+
if project is None:
|
|
146
|
+
return
|
|
147
|
+
model_id = item.model_id
|
|
148
|
+
comparison = session.comparisons.get(model_id)
|
|
149
|
+
result = project.results.get(model_id)
|
|
150
|
+
before = self._runs.get(model_id)
|
|
151
|
+
if result is None or not result.success:
|
|
152
|
+
if before is not None:
|
|
153
|
+
comparison, result = before.comparison, before.result
|
|
154
|
+
else:
|
|
155
|
+
result = None # a failed run's: it holds no variable
|
|
156
|
+
if result is None:
|
|
157
|
+
result = expected_result(item.test)
|
|
158
|
+
self._starts += 1
|
|
159
|
+
self._runs[model_id] = _Run(
|
|
160
|
+
item=item,
|
|
161
|
+
comparison=comparison,
|
|
162
|
+
result=result,
|
|
163
|
+
live=self._live(),
|
|
164
|
+
trace=self._trace(),
|
|
165
|
+
revision=run_revision(self._starts),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def ended(self, item: QueueItem) -> None:
|
|
169
|
+
"""``item`` ended, as the QUEUE says — the reader's state is not
|
|
170
|
+
asked. A finished run's entry goes: the page shows its result. So
|
|
171
|
+
does an unfinished one's that left no row; one that left a partial
|
|
172
|
+
file stays, to be shown and never scored."""
|
|
173
|
+
model_id = item.model_id
|
|
174
|
+
run = self._runs.get(model_id)
|
|
175
|
+
if run is None or run.item is not item:
|
|
176
|
+
return
|
|
177
|
+
run.reader, run.trace, run.asked = None, self._trace(), {}
|
|
178
|
+
path = self._path(run)
|
|
179
|
+
reached = (
|
|
180
|
+
read_mat_last_time(path)
|
|
181
|
+
if item.state is not ItemState.FINISHED and path is not None
|
|
182
|
+
else None
|
|
183
|
+
)
|
|
184
|
+
if reached is None:
|
|
185
|
+
del self._runs[model_id]
|
|
186
|
+
return
|
|
187
|
+
outcome = item.run_result
|
|
188
|
+
run.partial = Partial(
|
|
189
|
+
how_ended(
|
|
190
|
+
cancelled=item.state is ItemState.CANCELLED,
|
|
191
|
+
timed_out=outcome is not None and outcome.timed_out,
|
|
192
|
+
),
|
|
193
|
+
reached,
|
|
194
|
+
run.stop_time,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def clear(self) -> None:
|
|
198
|
+
"""The project is closing: nothing of its runs is kept."""
|
|
199
|
+
self._runs.clear()
|
|
200
|
+
|
|
201
|
+
# -- what a page asks ------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
def ask(self, model_id: str, page: object, names: Collection[str]) -> None:
|
|
204
|
+
"""``page`` draws ``names`` of the running ``model_id``: the next
|
|
205
|
+
polls read them. Asked again every second it still does."""
|
|
206
|
+
run = self._runs.get(model_id)
|
|
207
|
+
if run is not None and run.partial is None and run.live:
|
|
208
|
+
run.asked[page] = (frozenset(names), self._polls)
|
|
209
|
+
|
|
210
|
+
def poll(self) -> None:
|
|
211
|
+
"""Read every run in progress whose file grows, once: the names its
|
|
212
|
+
pages asked for, and how far it has got. On the UI loop."""
|
|
213
|
+
self._polls += 1
|
|
214
|
+
for model_id, run in list(self._runs.items()):
|
|
215
|
+
if run.partial is not None or not run.live:
|
|
216
|
+
continue
|
|
217
|
+
if run.reader is None:
|
|
218
|
+
path = self._path(run)
|
|
219
|
+
if path is None:
|
|
220
|
+
continue # its directory is not keyed yet
|
|
221
|
+
run.reader = LiveMatReader(path, max_rows=run.trace.cap)
|
|
222
|
+
run.asked = {
|
|
223
|
+
page: (names, at)
|
|
224
|
+
for page, (names, at) in run.asked.items()
|
|
225
|
+
if self._polls - at <= ASK_LIFE
|
|
226
|
+
}
|
|
227
|
+
names = frozenset().union(*(n for n, _ in run.asked.values()))
|
|
228
|
+
run.trace.take(run.reader.poll(sorted(names)))
|
|
229
|
+
if run.trace.run != run.announced and run.trace.state == RUNNING:
|
|
230
|
+
# This file's header is whole: its variables can be listed,
|
|
231
|
+
# and a file that replaced another's is listed again.
|
|
232
|
+
run.announced = run.trace.run
|
|
233
|
+
self._emit("live", frozenset({model_id}))
|
|
234
|
+
|
|
235
|
+
def polled(self, model_id: str) -> bool:
|
|
236
|
+
"""Whether ``model_id`` is running with a live view."""
|
|
237
|
+
run = self._runs.get(model_id)
|
|
238
|
+
return run is not None and run.partial is None and run.live
|
|
239
|
+
|
|
240
|
+
def moved(self, model_id: str, name: str) -> bool:
|
|
241
|
+
"""Whether the last poll brought ``name`` of ``model_id`` a sample."""
|
|
242
|
+
run = self._runs.get(model_id)
|
|
243
|
+
return run is not None and run.partial is None and run.trace.moved(name)
|
|
244
|
+
|
|
245
|
+
# -- what the Session reads ------------------------------------------------
|
|
246
|
+
|
|
247
|
+
def kept(
|
|
248
|
+
self, model_id: str
|
|
249
|
+
) -> tuple[TestComparison | None, TestResult | None] | None:
|
|
250
|
+
"""The comparison and the result ``model_id``'s run replaced — what
|
|
251
|
+
its page is laid out by while the Session has no result of it — or
|
|
252
|
+
None for a model with no entry here."""
|
|
253
|
+
run = self._runs.get(model_id)
|
|
254
|
+
return None if run is None else (run.comparison, run.result)
|
|
255
|
+
|
|
256
|
+
def word(self, model_id: str) -> str | None:
|
|
257
|
+
"""What every criterion of ``model_id`` says in place of a verdict:
|
|
258
|
+
waiting while its run is in progress, not scored after one that did
|
|
259
|
+
not finish; None for a model with no entry here."""
|
|
260
|
+
run = self._runs.get(model_id)
|
|
261
|
+
if run is None:
|
|
262
|
+
return None
|
|
263
|
+
return WAITING if run.partial is None else NOT_SCORED
|
|
264
|
+
|
|
265
|
+
def partial(self, model_id: str) -> Partial | None:
|
|
266
|
+
run = self._runs.get(model_id)
|
|
267
|
+
return None if run is None else run.partial
|
|
268
|
+
|
|
269
|
+
def progress(self, item: QueueItem) -> str | None:
|
|
270
|
+
"""``t = 42 / 100 s`` (or ``starting``) for a running ``item`` on a
|
|
271
|
+
backend with a live view; None on one without."""
|
|
272
|
+
if not self._live():
|
|
273
|
+
return None
|
|
274
|
+
run = self._runs.get(item.model_id)
|
|
275
|
+
if run is None or run.item is not item or run.partial is not None:
|
|
276
|
+
# Running by the queue's word, its start still on its way to
|
|
277
|
+
# the Session (a tick): nothing is read yet.
|
|
278
|
+
return STARTING_WORD
|
|
279
|
+
return progress(run.trace.state, run.trace.time, run.stop_time)
|
|
280
|
+
|
|
281
|
+
def names(self, model_id: str) -> tuple[str, ...] | None:
|
|
282
|
+
"""The variables of ``model_id``'s file where the Session has no
|
|
283
|
+
result of it: a running test's growing file, once its header is
|
|
284
|
+
whole — the reader's own, from the header it reads the rows
|
|
285
|
+
through — and a partial result's, from the one parse its series
|
|
286
|
+
are read through. None before a header is whole, on a backend
|
|
287
|
+
without a live view, and for a model with no entry here."""
|
|
288
|
+
run = self._runs.get(model_id)
|
|
289
|
+
if run is None:
|
|
290
|
+
return None
|
|
291
|
+
if run.partial is not None:
|
|
292
|
+
file = self._file(run)
|
|
293
|
+
return None if file is None else file.names
|
|
294
|
+
if run.announced is None or run.reader is None:
|
|
295
|
+
return None
|
|
296
|
+
return run.reader.names
|
|
297
|
+
|
|
298
|
+
def series(self, model_id: str, name: str) -> tuple[Series | None, int] | None:
|
|
299
|
+
"""What ``model_id``'s page draws as ``name``'s Actual in place of a
|
|
300
|
+
result's: ``(series, samples)`` — the samples read so far while its
|
|
301
|
+
run is in progress (none yet: empty; None on a backend without a
|
|
302
|
+
live view), a partial file's after one that did not finish
|
|
303
|
+
(``samples`` 0: the whole series, counted by whoever decimates it).
|
|
304
|
+
None for a model with no entry here."""
|
|
305
|
+
run = self._runs.get(model_id)
|
|
306
|
+
if run is None:
|
|
307
|
+
return None
|
|
308
|
+
if run.partial is None:
|
|
309
|
+
if not run.live:
|
|
310
|
+
return None, 0
|
|
311
|
+
# Empty before the first sample, never absent: the Actual trace
|
|
312
|
+
# keeps its place in the figure from the run's first second, so
|
|
313
|
+
# the legend's choices stay on the traces they were made on.
|
|
314
|
+
series = run.trace.series(name)
|
|
315
|
+
return series or (np.empty(0), np.empty(0)), run.trace.samples(name)
|
|
316
|
+
if name not in run.series:
|
|
317
|
+
# A name at a time, as the page asks — through one parse of the
|
|
318
|
+
# file's header, and with one time array for them all.
|
|
319
|
+
file = self._file(run)
|
|
320
|
+
read = None if file is None else file.series([name])
|
|
321
|
+
run.series[name] = (read or {}).get(name)
|
|
322
|
+
return run.series[name], 0
|
|
323
|
+
|
|
324
|
+
def dress(self, model_id: str, figure: dict, colour: str) -> dict:
|
|
325
|
+
"""``figure`` as ``model_id``'s page draws it: one of its run's own
|
|
326
|
+
revision, the time axis spanning the whole run while a live view
|
|
327
|
+
grows into it, and after an unfinished run the marker where it
|
|
328
|
+
stopped, in ``colour``."""
|
|
329
|
+
run = self._runs.get(model_id)
|
|
330
|
+
if run is None or not run.live:
|
|
331
|
+
return figure
|
|
332
|
+
figure = spanned(figure, run.stop_time, run.revision)
|
|
333
|
+
if run.partial is None:
|
|
334
|
+
return figure
|
|
335
|
+
return stopped_marker(figure, run.partial, colour)
|
|
336
|
+
|
|
337
|
+
# -- internals ---------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
def _live(self) -> bool:
|
|
340
|
+
"""Whether the project's backend writes a result file that grows
|
|
341
|
+
while a run is in progress."""
|
|
342
|
+
project = self._session.project
|
|
343
|
+
return (
|
|
344
|
+
project is not None
|
|
345
|
+
and Capability.LIVE_RESULTS in project.backend.capabilities
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
def _trace(self) -> LiveTrace:
|
|
349
|
+
project = self._session.project
|
|
350
|
+
budget = project.config.max_embedded_samples if project is not None else 0
|
|
351
|
+
return LiveTrace(cap=budget if budget and budget > 0 else 1000)
|
|
352
|
+
|
|
353
|
+
def _backend(self):
|
|
354
|
+
project = self._session.project
|
|
355
|
+
assert project is not None # an entry exists only under a project
|
|
356
|
+
return project.backend
|
|
357
|
+
|
|
358
|
+
def _dir(self, run: _Run) -> Path | None:
|
|
359
|
+
"""The run's directory, once it is keyed — a moment after its item
|
|
360
|
+
turns RUNNING, its last run's files cleared by then."""
|
|
361
|
+
project = self._session.project
|
|
362
|
+
key = run.item.test_key
|
|
363
|
+
if project is None or key is None:
|
|
364
|
+
return None
|
|
365
|
+
work_dir = project.config.work_dir
|
|
366
|
+
assert work_dir is not None # resolved in Config.__post_init__
|
|
367
|
+
return work_dir / key
|
|
368
|
+
|
|
369
|
+
def _file(self, run: _Run) -> ResultMatFile | None:
|
|
370
|
+
"""A partial result's file, opened once; None while it is not
|
|
371
|
+
there (its directory wiped for the next run)."""
|
|
372
|
+
if run.file is None:
|
|
373
|
+
path = self._path(run)
|
|
374
|
+
if path is not None:
|
|
375
|
+
run.file = ResultMatFile(path)
|
|
376
|
+
file = run.file
|
|
377
|
+
return file if file is not None and file.path.exists() else None
|
|
378
|
+
|
|
379
|
+
def _path(self, run: _Run) -> Path | None:
|
|
380
|
+
"""The run's growing result file; None on a backend without one,
|
|
381
|
+
and until its directory is keyed."""
|
|
382
|
+
test_dir = self._dir(run)
|
|
383
|
+
if test_dir is None or not run.live:
|
|
384
|
+
return None
|
|
385
|
+
return type(self._backend()).live_result_path(test_dir)
|
dstf/authoring/needle.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""The filter both drawers of the Test page ask (Tests and Variables): a
|
|
2
|
+
needle as a substring, a glob or a typed regex, the regex held to a budget
|
|
3
|
+
of its own CPU and of the UI loop's wall time."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from time import monotonic, thread_time
|
|
10
|
+
|
|
11
|
+
from ..backends.types import is_glob_pattern
|
|
12
|
+
|
|
13
|
+
#: Seconds of the searching thread's CPU (`thread_time`) ONE typed regex
|
|
14
|
+
#: may search, over every name of one filter: the verdict "the pattern took
|
|
15
|
+
#: too long" is the filter's own CPU, so waits for the GIL never make a
|
|
16
|
+
#: plain pattern too long. An ordinary search costs ~2.5 us a name and
|
|
17
|
+
#: reading that clock ~0.85 us more (a system call here; `monotonic` is
|
|
18
|
+
#: ~0.075 us): on an idle machine TRANSFORM's largest result (76,992 names)
|
|
19
|
+
#: takes ~0.27 s, and 0.25 s timed out a plain `T` over 100,000. 1 s leaves
|
|
20
|
+
#: ~3.5x there; a loaded WSL2 host measured up to 0.99 s of thread CPU for
|
|
21
|
+
#: the same filter, its `thread_time` counting time the host took the vCPU
|
|
22
|
+
#: away. The clock is the thread's own because between searches the filter
|
|
23
|
+
#: waits for the GIL on any busy thread of the tool (its overlay index
|
|
24
|
+
#: json.loads for ~15 s after a page opens): in wall time those names took
|
|
25
|
+
#: 0.4-0.9 s beside one or two such threads, two to three times their own
|
|
26
|
+
#: CPU. Each search holds the GIL (``concurrent=False``): released, every
|
|
27
|
+
#: search waited on a busy thread again, 0.3-0.9 ms a name beside one, and
|
|
28
|
+
#: `T` on a wall-clock budget ran out after 1,200-3,600 names.
|
|
29
|
+
#: The package times each search's ``timeout`` with C ``clock()``: on
|
|
30
|
+
#: Linux and macOS the CPU time of the whole process, every thread summed;
|
|
31
|
+
#: on Windows, wall time.
|
|
32
|
+
REGEX_BUDGET = 1.0
|
|
33
|
+
#: Seconds of WALL time (`monotonic`) the same filter may take. It runs on
|
|
34
|
+
#: the UI event loop, so every tab is frozen for its wall time, which the
|
|
35
|
+
#: CPU budget does not bound: beside a thread holding the GIL ~0.2 s at a
|
|
36
|
+
#: time, a backtracking pattern froze the tool 17.4 s on 1 s of its own
|
|
37
|
+
#: CPU, and a plain `T` over TRANSFORM's largest result took 10.7-20.5 s
|
|
38
|
+
#: and never ran out. This is what bounds the freeze every tab sees. It is
|
|
39
|
+
#: checked between searches: a search is given the smaller time left, but
|
|
40
|
+
#: `regex` counts it on ``clock()`` — the process's CPU on Linux and macOS —
|
|
41
|
+
#: so one search is bounded by the CPU left and can pass the wall time left.
|
|
42
|
+
REGEX_WALL_BACKSTOP = 5 * REGEX_BUDGET
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def matcher(needle: str, *, regex: bool, case: bool) -> Callable[[str], bool]:
|
|
46
|
+
"""What the filter box keeps. A regex (``.*`` on) searches; a glob (a
|
|
47
|
+
``*`` or ``?`` in it) matches the WHOLE text with ``[ ]`` literal — the
|
|
48
|
+
rule a spec's ``variables`` list uses, so what you type here means what
|
|
49
|
+
it means there (`_glob`); anything else is a substring. ``regex.error``
|
|
50
|
+
for a bad regex, and ``TimeoutError`` from the returned closure once the
|
|
51
|
+
regex has searched `REGEX_BUDGET` seconds of its thread's CPU in all,
|
|
52
|
+
for the drawer to say.
|
|
53
|
+
|
|
54
|
+
A typed regex is the `regex` package's, never `re`'s: `re` has no
|
|
55
|
+
timeout, so a pattern that backtracks runs to its end (`^(a|aa)+$` over
|
|
56
|
+
60 a's and a ``b``: days), and `regex` stops a search at its
|
|
57
|
+
``timeout``. Both hold the GIL for a whole search, `regex` because
|
|
58
|
+
``concurrent=False`` asks it to, and the filter runs on the UI event
|
|
59
|
+
loop, so what bounds the freeze every tab sees is
|
|
60
|
+
`REGEX_WALL_BACKSTOP`'s wall time, not a worker thread; `REGEX_BUDGET`,
|
|
61
|
+
the filter's own CPU, decides a pattern that took too long. It is imported here, not at the
|
|
62
|
+
top: it comes with the ``[author]`` extra, and this module imports
|
|
63
|
+
without it. A glob is not left to a regex either — compiled to one,
|
|
64
|
+
``*a*a*a*a*a*a*a*b`` over one 60-character name took over 20 s on the
|
|
65
|
+
UI event loop."""
|
|
66
|
+
flags = 0 if case else re.IGNORECASE
|
|
67
|
+
if not needle:
|
|
68
|
+
return lambda text: True
|
|
69
|
+
if regex:
|
|
70
|
+
import regex as regex_package
|
|
71
|
+
|
|
72
|
+
# unbounded: `(a{5000}){5000}` compiles for seconds, or out of memory
|
|
73
|
+
pattern = regex_package.compile(needle, 0 if case else regex_package.IGNORECASE)
|
|
74
|
+
# CPU, then wall; set at the first name, not at compile. The closure
|
|
75
|
+
# is one thread's: `thread_time` readings from two threads cannot be
|
|
76
|
+
# compared, and each drawer builds and uses it within one pass
|
|
77
|
+
# (`VariablesDrawer._build`, `TestsDrawer._kept`).
|
|
78
|
+
deadlines: list[float] = []
|
|
79
|
+
|
|
80
|
+
def search(text: str) -> bool:
|
|
81
|
+
cpu, wall = thread_time(), monotonic()
|
|
82
|
+
if not deadlines:
|
|
83
|
+
deadlines.extend((cpu + REGEX_BUDGET, wall + REGEX_WALL_BACKSTOP))
|
|
84
|
+
left = min(deadlines[0] - cpu, deadlines[1] - wall)
|
|
85
|
+
if left <= 0:
|
|
86
|
+
raise TimeoutError("the pattern took too long")
|
|
87
|
+
return pattern.search(text, timeout=left, concurrent=False) is not None
|
|
88
|
+
|
|
89
|
+
return search
|
|
90
|
+
if is_glob_pattern(needle):
|
|
91
|
+
return _glob(needle, flags)
|
|
92
|
+
folded = needle if case else needle.lower()
|
|
93
|
+
return lambda text: folded in (text if case else text.lower())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _glob(needle: str, flags: int) -> Callable[[str], bool]:
|
|
97
|
+
"""``needle`` matched against the whole text as `_pattern_to_regex`'s
|
|
98
|
+
anchored regex matches it, without backtracking. Split on ``*``, each
|
|
99
|
+
piece is a fixed-length regex (``?`` is ``.``, everything else literal)
|
|
100
|
+
with no quantifier: the first must match at the start, the last at the
|
|
101
|
+
end, and each middle one is found left to right — the leftmost place
|
|
102
|
+
leaves the most text for the rest, so no other place is ever tried. A
|
|
103
|
+
``*`` spans no newline and ``?`` matches none, as the regex's ``.``
|
|
104
|
+
does not (`test_the_glob_means_what_the_specs_regex_means`)."""
|
|
105
|
+
pieces = [
|
|
106
|
+
re.compile("".join("." if ch == "?" else re.escape(ch) for ch in p), flags)
|
|
107
|
+
for p in needle.split("*")
|
|
108
|
+
]
|
|
109
|
+
if len(pieces) == 1: # a ``?`` and no ``*``
|
|
110
|
+
(only,) = pieces
|
|
111
|
+
return lambda text: only.fullmatch(text) is not None
|
|
112
|
+
head, *middle, tail = pieces
|
|
113
|
+
width = len(needle.rsplit("*", 1)[1]) # the last piece's, one per character
|
|
114
|
+
|
|
115
|
+
def keep(text: str) -> bool:
|
|
116
|
+
start, end = head.match(text), len(text) - width
|
|
117
|
+
if start is None or end < start.end() or tail.fullmatch(text, end) is None:
|
|
118
|
+
return False
|
|
119
|
+
at = start.end()
|
|
120
|
+
for piece in middle:
|
|
121
|
+
found = piece.search(text, at, end)
|
|
122
|
+
if found is None or text.find("\n", at, found.start()) != -1:
|
|
123
|
+
return False
|
|
124
|
+
at = found.end()
|
|
125
|
+
return text.find("\n", at, end) == -1
|
|
126
|
+
|
|
127
|
+
return keep
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""The add form's variable picker as data (the plan's K2): every name a
|
|
2
|
+
criterion may score, in three sections — the plot's own series, every
|
|
3
|
+
other variable of the result file, the diagnostics the run recorded —
|
|
4
|
+
filtered on what a row reads as OR its result-file name, at most `CAP`
|
|
5
|
+
rows. The shell draws them (`shell/picker.py`) and knows none of the
|
|
6
|
+
rules, as the Variables drawer draws `variable_rows`."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from ..backends.types import VariableText
|
|
14
|
+
|
|
15
|
+
#: The drawer's cap (`variables_drawer.ROWS`): a result of 76,992 names
|
|
16
|
+
#: drew 76,992 rows otherwise.
|
|
17
|
+
CAP = 200
|
|
18
|
+
ON_PLOT, OTHER, DIAGNOSTICS = "On this plot", "Other variables", "Diagnostics"
|
|
19
|
+
SECTIONS = (ON_PLOT, OTHER, DIAGNOSTICS)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class PickRow:
|
|
24
|
+
name: str #: the result-file name — what a leaf is written against
|
|
25
|
+
text: str #: what it reads as in the test's scope, else the name
|
|
26
|
+
section: str
|
|
27
|
+
#: the plain label (`VariableText.label`): what the row shows, the
|
|
28
|
+
#: muted name beside it
|
|
29
|
+
label: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Picked:
|
|
34
|
+
sections: list[tuple[str, list[PickRow]]]
|
|
35
|
+
shown: int
|
|
36
|
+
total: int
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def capped(self) -> str | None:
|
|
40
|
+
"""The line under a capped list, else None."""
|
|
41
|
+
if self.shown >= self.total:
|
|
42
|
+
return None
|
|
43
|
+
return f"Showing {self.shown} of {self.total:,} — narrow the search"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def picker_rows(
|
|
47
|
+
*,
|
|
48
|
+
on_plot: Sequence[str],
|
|
49
|
+
names: Sequence[str],
|
|
50
|
+
diagnostics: Sequence[str],
|
|
51
|
+
texts: Mapping[str, VariableText],
|
|
52
|
+
keep: Callable[[str], bool],
|
|
53
|
+
cap: int = CAP,
|
|
54
|
+
) -> Picked:
|
|
55
|
+
"""The sections in the order On this plot / Other variables /
|
|
56
|
+
Diagnostics, each name once (in the first section that has it); an
|
|
57
|
+
empty section is absent. ``names`` is every name of the result file
|
|
58
|
+
(`Session.variable_names`); ``texts`` the test's
|
|
59
|
+
(`Session.variable_texts`), a name it lacks reading as itself;
|
|
60
|
+
``diagnostics`` what the run RECORDED as
|
|
61
|
+
one — each is listed there and only there, whether or not the file
|
|
62
|
+
listing names it too (a Dymola file holds `CPUtime` as a variable),
|
|
63
|
+
unless the plot draws it: a diagnostic on the plot is listed under On
|
|
64
|
+
this plot, as every series of the plot is.
|
|
65
|
+
``keep`` is `needle.matcher`'s closure: its ``TimeoutError`` reaches
|
|
66
|
+
the caller, which says it."""
|
|
67
|
+
first = list(dict.fromkeys(on_plot))
|
|
68
|
+
plotted = set(first)
|
|
69
|
+
diag = [d for d in dict.fromkeys(diagnostics) if d not in plotted]
|
|
70
|
+
taken = plotted | set(diag)
|
|
71
|
+
groups = (
|
|
72
|
+
(ON_PLOT, first),
|
|
73
|
+
(OTHER, [n for n in dict.fromkeys(names) if n not in taken]),
|
|
74
|
+
(DIAGNOSTICS, diag),
|
|
75
|
+
)
|
|
76
|
+
out: list[tuple[str, list[PickRow]]] = []
|
|
77
|
+
total = shown = 0
|
|
78
|
+
for title, members in groups:
|
|
79
|
+
rows = []
|
|
80
|
+
for name in members:
|
|
81
|
+
entry = texts.get(name)
|
|
82
|
+
text = entry.text if entry is not None else name
|
|
83
|
+
if not (keep(text) or (text != name and keep(name))):
|
|
84
|
+
continue
|
|
85
|
+
total += 1
|
|
86
|
+
if shown < cap:
|
|
87
|
+
label = entry.label if entry is not None else name
|
|
88
|
+
rows.append(PickRow(name, text, title, label))
|
|
89
|
+
shown += 1
|
|
90
|
+
if rows:
|
|
91
|
+
out.append((title, rows))
|
|
92
|
+
return Picked(out, shown, total)
|