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
dstf/authoring/layout.py
ADDED
|
@@ -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
|
+
}
|