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,104 @@
1
+ """A test's plot extras as the tool edits them: the `plots` key's entries —
2
+ context series on a plot, and watch plots — changed one gesture at a time
3
+ and staged as ONE op on `/plots` (`Session.stage` collapses a run of them,
4
+ Convention 8). UI-free: the Variables drawer, a plot card and the tree
5
+ card's Reset layout all call in, so the rules below hold on every surface
6
+ at once.
7
+
8
+ The plots themselves are derived from the criteria tree
9
+ (:func:`~dstf.discovery.plots.derive_plots`): nothing here can remove a
10
+ derived plot or take its subject."""
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Sequence
15
+
16
+ from ..discovery.plots import PlotExtra, ResolvedPlot, plots_to_raw
17
+ from .spec_document import PatchOp
18
+
19
+ Extras = tuple[PlotExtra, ...]
20
+
21
+
22
+ def _entry_of(extras: Sequence[PlotExtra], plot: ResolvedPlot) -> int | None:
23
+ """The index of the entry on any member of ``plot``'s subject: a
24
+ family's entry is whichever member has one."""
25
+ return next((i for i, e in enumerate(extras) if e.var in plot.subject), None)
26
+
27
+
28
+ def add_context(
29
+ extras: Sequence[PlotExtra], plot: ResolvedPlot, names: Sequence[str]
30
+ ) -> Extras:
31
+ """``names`` drawn as context on ``plot``, in order, minus its subject's
32
+ members and what it has. The entry is the one on any subject member,
33
+ else a new one on the subject's first member, at the end."""
34
+ extras = tuple(extras)
35
+ at = _entry_of(extras, plot)
36
+ entry = extras[at] if at is not None else PlotExtra(plot.subject[0])
37
+ added = tuple(
38
+ n
39
+ for n in dict.fromkeys(names)
40
+ if n not in plot.subject and n not in entry.context
41
+ )
42
+ new = PlotExtra(entry.var, entry.context + added)
43
+ if at is None:
44
+ return extras + (new,)
45
+ return extras[:at] + (new,) + extras[at + 1 :]
46
+
47
+
48
+ def remove_context(
49
+ extras: Sequence[PlotExtra], plot: ResolvedPlot, name: str
50
+ ) -> Extras:
51
+ """``plot`` without context ``name``, off EVERY entry on its subject: a
52
+ family's plot draws the context of each member's entry — a watch plot
53
+ with context that later got a criterion (Q14) keeps its entry beside
54
+ the family's own — and a `×` that took it off one of them left it
55
+ drawn. A derived plot's entry left with no context goes — it records
56
+ nothing; a watch plot's stays (it IS the plot)."""
57
+ out: list[PlotExtra] = []
58
+ for entry in extras:
59
+ if entry.var in plot.subject:
60
+ kept = tuple(n for n in entry.context if n != name)
61
+ if not kept and entry.context and not plot.watch:
62
+ continue
63
+ entry = PlotExtra(entry.var, kept)
64
+ out.append(entry)
65
+ return tuple(out)
66
+
67
+
68
+ def new_watch(extras: Sequence[PlotExtra], var: str) -> Extras:
69
+ """A watch plot of ``var`` after the others. A second entry on one
70
+ variable is refused, as `parse_plots` refuses it."""
71
+ if any(e.var == var for e in extras):
72
+ raise ValueError(f"{var!r} already has an entry")
73
+ return (*extras, PlotExtra(var))
74
+
75
+
76
+ def remove_watch(extras: Sequence[PlotExtra], var: str) -> Extras:
77
+ """The watch plot of ``var`` gone (KeyError when there is none)."""
78
+ kept = tuple(e for e in extras if e.var != var)
79
+ if len(kept) == len(extras):
80
+ raise KeyError(var)
81
+ return kept
82
+
83
+
84
+ def layout_op(
85
+ model_id: str, entry: dict | None, extras: Sequence[PlotExtra]
86
+ ) -> PatchOp | None:
87
+ """The ONE op a gesture stages: ``add`` when the effective entry has no
88
+ ``plots`` key (or no entry: the op creates it), ``replace`` when it has.
89
+ No extras left is ``remove`` — the key goes rather than staying ``[]``,
90
+ so Reset layout hides again — and ``None`` when there is no key to
91
+ remove: nothing to stage (a `remove` of a member that is not there is
92
+ refused by the patch)."""
93
+ if not extras:
94
+ if entry is None or "plots" not in entry:
95
+ return None
96
+ return reset_op(model_id)
97
+ verb = "replace" if entry is not None and "plots" in entry else "add"
98
+ return PatchOp(model_id, verb, "/plots", plots_to_raw(extras))
99
+
100
+
101
+ def reset_op(model_id: str) -> PatchOp:
102
+ """Reset layout: the key goes, and with it every context series and
103
+ watch plot; the derived plots stay."""
104
+ return PatchOp(model_id, "remove", "/plots")
@@ -0,0 +1,337 @@
1
+ """A running test's page as data (D151): what the live reader's snapshots
2
+ become on the way to a plot, and the words every state of a run that has no
3
+ result yet — or will never have one — is said in. Pure: no Session, no UI,
4
+ no file. `authoring.live_runs` polls the files and holds the traces; the
5
+ Test page, the strip and the queue panel draw from what is here.
6
+
7
+ :class:`LiveTrace` is what a poller keeps of one run: each asked-for name's
8
+ samples so far, dropped when the reader starts over on another file
9
+ (``LiveSnapshot.run``), and handed to a plot under the figure layer's sample
10
+ cap. The decimation is the figure layer's own (LTTB) and costs the same
11
+ whatever it is given, so it is not run on every poll: a series past the cap
12
+ is decimated once to three quarters of it and its newer samples are drawn
13
+ after that as they are, until they fill the last quarter.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Mapping
19
+ from dataclasses import dataclass
20
+ from typing import Any
21
+
22
+ import numpy as np
23
+
24
+ from ..backends.common.live_mat import GONE, RUNNING, LiveSnapshot, LiveState
25
+ from ..backends.types import VariableText
26
+ from ..comparison.tree_spec import SpecNode, spec_to_view
27
+ from ..figures.decimate import lttb
28
+
29
+ #: A criterion of a test whose run is in progress: its row's readout, the
30
+ #: tree card's line. No verdict stands beside it.
31
+ WAITING = "waiting for result"
32
+ #: A criterion of a test whose last run did not finish.
33
+ NOT_SCORED = "not scored"
34
+ #: Under the strip of a running test on a backend whose result file does
35
+ #: not grow while it runs (no ``Capability.LIVE_RESULTS``).
36
+ NOT_AVAILABLE = "live view not available for this backend"
37
+ #: A run that has written no row yet: no file, or its header only.
38
+ STARTING_WORD = "starting"
39
+
40
+ #: How an unfinished run ended, as its strip starts.
41
+ CANCELLED = "Cancelled"
42
+ TIMED_OUT = "Timed out"
43
+ FAILED = "Failed"
44
+
45
+ #: The name of the shape :func:`stopped_marker` adds.
46
+ STOPPED = "stopped"
47
+
48
+
49
+ def run_revision(number: int) -> str:
50
+ """The ``uirevision`` of the figures :func:`spanned` over the
51
+ ``number``-th run a tool started, in place of the figure layer's own:
52
+ what the browser keeps of a plot — a zoom, the legend's choices — it
53
+ keeps while this stands. One per RUN, and its partial result's too: a
54
+ run that follows another's partial result is another revision, as one
55
+ that follows a result is."""
56
+ return f"run {number}"
57
+
58
+
59
+ #: Samples a :class:`LiveTrace` holds per name before it thins them: an
60
+ #: hour of a run that writes faster than the reader serves is 7 million
61
+ #: rows a name.
62
+ BOUND = 200_000
63
+
64
+
65
+ def seconds(value: float) -> str:
66
+ """A time in seconds as a person reads it: three figures, the whole
67
+ number from 100 up, and no exponent — ``42``, ``19.2``, ``5.85``,
68
+ ``1235``."""
69
+ if abs(value) >= 100:
70
+ return f"{value:.0f}"
71
+ return np.format_float_positional(
72
+ value, precision=3, unique=False, fractional=False, trim="-"
73
+ )
74
+
75
+
76
+ def progress(state: LiveState, time: float | None, stop_time: float | None) -> str:
77
+ """How far a running test has got, from its file: ``t = 42 / 100 s``,
78
+ ``t = 42 s`` where the stop time is not known, ``starting`` until the
79
+ file holds a row."""
80
+ if state != RUNNING or time is None:
81
+ return STARTING_WORD
82
+ if stop_time is None:
83
+ return f"t = {seconds(time)} s"
84
+ return f"t = {seconds(time)} / {seconds(stop_time)} s"
85
+
86
+
87
+ def how_ended(*, cancelled: bool, timed_out: bool) -> str:
88
+ """The word for a run that ended without finishing. A stop that was
89
+ asked for is a cancel whatever the backend then reported (the pool's
90
+ rule)."""
91
+ if cancelled:
92
+ return CANCELLED
93
+ return TIMED_OUT if timed_out else FAILED
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class Partial:
98
+ """What an unfinished run left: how it ended, the time its result file
99
+ reached, and the stop time it was asked for (None when not known)."""
100
+
101
+ how: str
102
+ time: float
103
+ stop_time: float | None = None
104
+
105
+ @property
106
+ def sentence(self) -> str:
107
+ """The strip's: ``Cancelled at t = 19.2 of 200 s — not scored``."""
108
+ of = "" if self.stop_time is None else f" of {seconds(self.stop_time)}"
109
+ return f"{self.how} at t = {seconds(self.time)}{of} s — {NOT_SCORED}"
110
+
111
+ @property
112
+ def marker(self) -> str:
113
+ """The plot's: ``stopped at t = 19.2 s``."""
114
+ return f"{STOPPED} at t = {seconds(self.time)} s"
115
+
116
+
117
+ # -- snapshots to series -----------------------------------------------------------
118
+
119
+
120
+ class _Samples:
121
+ """One name's samples of one run, bounded: past ``bound`` they are
122
+ thinned to every second one and the stride doubles, for the samples
123
+ still to come too — so what is held stays evenly spaced over the run.
124
+ The newest sample is held beside them when the stride skipped it: a
125
+ trace always ends where the run has got to."""
126
+
127
+ def __init__(self, bound: int) -> None:
128
+ self.bound = bound
129
+ self.time = np.empty(0)
130
+ self.values = np.empty(0)
131
+ self.taken = 0 # samples handed in, before any thinning
132
+ self._stride = 1
133
+ self._skip = 0 # incoming samples to pass over before the next kept
134
+ self._newest: tuple[float, float] | None = None
135
+ #: The decimated head (`drawn`) and how many HELD samples it covers:
136
+ #: the newest one beside them is never in it.
137
+ self._head: tuple[np.ndarray, np.ndarray] | None = None
138
+ self._head_of = 0
139
+
140
+ def add(self, time: np.ndarray, values: np.ndarray) -> None:
141
+ n = len(time)
142
+ if n == 0:
143
+ return
144
+ self.taken += n
145
+ kept = range(self._skip, n, self._stride)
146
+ if kept:
147
+ self.time = np.concatenate([self.time, time[kept.start :: kept.step]])
148
+ self.values = np.concatenate([self.values, values[kept.start :: kept.step]])
149
+ self._skip = kept[-1] + self._stride - n
150
+ else:
151
+ self._skip -= n
152
+ last_kept = bool(kept) and kept[-1] == n - 1
153
+ self._newest = None if last_kept else (float(time[-1]), float(values[-1]))
154
+ if len(self.time) > self.bound:
155
+ self._thin()
156
+
157
+ def _thin(self) -> None:
158
+ odd = len(self.time) % 2 == 1 # whether the last held sample stays
159
+ if not odd and self._newest is None:
160
+ self._newest = (float(self.time[-1]), float(self.values[-1]))
161
+ self.time, self.values = self.time[::2], self.values[::2]
162
+ if odd:
163
+ # The next sample of the old stride is now one to pass over.
164
+ self._skip += self._stride
165
+ self._stride *= 2
166
+ self._head = None
167
+
168
+ def arrays(self) -> tuple[np.ndarray, np.ndarray]:
169
+ """Everything held, the newest sample last."""
170
+ if self._newest is None:
171
+ return self.time, self.values
172
+ t, v = self._newest
173
+ return np.append(self.time, t), np.append(self.values, v)
174
+
175
+ def drawn(self, cap: int) -> tuple[np.ndarray, np.ndarray]:
176
+ time, values = self.arrays()
177
+ if len(time) <= cap:
178
+ return time, values
179
+ head = cap - cap // 4
180
+ if self._head is None or len(time) - self._head_of > cap - head:
181
+ self._head = lttb(self.time, self.values, head)
182
+ self._head_of = len(self.time)
183
+ return (
184
+ np.concatenate([self._head[0], time[self._head_of :]]),
185
+ np.concatenate([self._head[1], values[self._head_of :]]),
186
+ )
187
+
188
+
189
+ class LiveTrace:
190
+ """One run's file as its poller has seen it: the reader's state, the
191
+ time it reached, and each asked-for name's samples so far — every
192
+ snapshot goes through :meth:`take`. ``cap`` is the most samples a
193
+ series is DRAWN with (the figure layer's budget)."""
194
+
195
+ def __init__(self, cap: int, *, bound: int = BOUND) -> None:
196
+ self.cap = max(4, int(cap))
197
+ self.bound = bound
198
+ self.run: int | None = None
199
+ self.state: LiveState = GONE
200
+ self.time: float | None = None
201
+ self._names: dict[str, _Samples] = {}
202
+ self._moved: set[str] = set()
203
+
204
+ def take(self, snapshot: LiveSnapshot) -> None:
205
+ """One poll's findings. A snapshot of another run than the last
206
+ (the reader started over: a rerun wiped the file) drops every
207
+ sample first."""
208
+ if snapshot.run != self.run:
209
+ self.run = snapshot.run
210
+ self._names.clear()
211
+ self.state, self.time = snapshot.state, snapshot.time
212
+ self._moved = set()
213
+ for name, series in snapshot.series.items():
214
+ samples = self._names.get(name)
215
+ if samples is None:
216
+ samples = self._names[name] = _Samples(self.bound)
217
+ if len(series.time):
218
+ samples.add(series.time, series.values)
219
+ self._moved.add(name)
220
+
221
+ def moved(self, name: str) -> bool:
222
+ """Whether the last snapshot brought ``name`` a sample."""
223
+ return name in self._moved
224
+
225
+ def series(self, name: str) -> tuple[np.ndarray, np.ndarray] | None:
226
+ """``name``'s samples so far as a plot draws them — at most ``cap``,
227
+ the last the newest — or None for a name no snapshot has served."""
228
+ samples = self._names.get(name)
229
+ return None if samples is None else samples.drawn(self.cap)
230
+
231
+ def samples(self, name: str) -> int:
232
+ """How many samples of ``name`` the snapshots held, drawn or not."""
233
+ samples = self._names.get(name)
234
+ return 0 if samples is None else samples.taken
235
+
236
+ def held(self, name: str) -> int:
237
+ samples = self._names.get(name)
238
+ return 0 if samples is None else len(samples.arrays()[0])
239
+
240
+
241
+ # -- the criteria, unscored --------------------------------------------------------
242
+
243
+
244
+ def unscored_view(spec: SpecNode, texts: Mapping[str, VariableText], word: str) -> dict:
245
+ """The criteria tree of a test that has nothing scored and is not
246
+ without a reason — its run is in progress (``word``: :data:`WAITING`)
247
+ or did not finish (:data:`NOT_SCORED`) — in the shape the report's
248
+ `build_tree_view` gives a scored one, so the page's rows draw from it:
249
+ every node carries ``unscored`` (the word) and no ``passed``, and a leaf
250
+ says the word where a verdict's readout goes (``criterion``)."""
251
+ view = spec_to_view(spec)
252
+
253
+ def stamp(node: dict, under_warn: bool) -> None:
254
+ node["unscored"] = word
255
+ if node.get("kind") == "leaf":
256
+ name = str(node.get("variable"))
257
+ entry = texts.get(name)
258
+ node.update(
259
+ name=name,
260
+ label=entry.text if entry is not None else name,
261
+ criterion=word,
262
+ advisory=under_warn,
263
+ )
264
+ return
265
+ warn = under_warn or node.get("combinator") == "warn"
266
+ for child in node.get("children") or []:
267
+ stamp(child, warn)
268
+
269
+ stamp(view, False)
270
+ return view
271
+
272
+
273
+ # -- the figure ----------------------------------------------------------------------
274
+
275
+
276
+ def spanned(figure: dict, stop_time: float | None, revision: str) -> dict:
277
+ """``figure`` as one of a run's: under the run's ``revision``
278
+ (:func:`run_revision`) in place of the figure layer's, and with its
279
+ time axis spanning the whole run, 0 to ``stop_time``, so a growing
280
+ trace grows INTO the plot — the axis stays the data's with no stop
281
+ time. Both are the same in every figure of the run: a zoom made while
282
+ the test runs survives every update, and the plot starts over — both
283
+ axes, not the time axis alone — when the run's first figure replaces
284
+ what stood there (the last result's, or another run's partial one) and
285
+ when the next result's replaces the run's. A copy: nothing is written
286
+ into the figure handed in."""
287
+
288
+ def revised(value: Any) -> Any:
289
+ if isinstance(value, dict) and value.get("uirevision") == "keep":
290
+ return {**value, "uirevision": revision}
291
+ return value
292
+
293
+ layout: dict = {
294
+ key: revised(value) if key[1:5] == "axis" else value
295
+ for key, value in revised(figure["layout"]).items()
296
+ }
297
+ if stop_time is not None and stop_time > 0:
298
+ layout["xaxis"] = {**layout.get("xaxis", {}), "range": [0.0, float(stop_time)]}
299
+ return {**figure, "layout": layout}
300
+
301
+
302
+ def stopped_marker(figure: dict, partial: Partial, colour: str) -> dict:
303
+ """``figure`` with a vertical line where an unfinished run stopped and
304
+ its words beside it, in ``colour`` — the theme's muted text. A copy."""
305
+ layout = figure["layout"]
306
+ line = {
307
+ "type": "line",
308
+ "name": STOPPED,
309
+ "xref": "x",
310
+ "yref": "paper",
311
+ "x0": partial.time,
312
+ "x1": partial.time,
313
+ "y0": 0,
314
+ "y1": 1,
315
+ "line": {"color": colour, "width": 1, "dash": "dash"},
316
+ "editable": False,
317
+ }
318
+ note = {
319
+ "name": STOPPED,
320
+ "xref": "x",
321
+ "yref": "paper",
322
+ "x": partial.time,
323
+ "y": 1,
324
+ "xanchor": "right",
325
+ "yanchor": "bottom",
326
+ "text": partial.marker,
327
+ "showarrow": False,
328
+ "font": {"color": colour, "size": 11},
329
+ }
330
+ return {
331
+ **figure,
332
+ "layout": {
333
+ **layout,
334
+ "shapes": [*layout.get("shapes", []), line],
335
+ "annotations": [*layout.get("annotations", []), note],
336
+ },
337
+ }