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/figures/plotly.py
ADDED
|
@@ -0,0 +1,1413 @@
|
|
|
1
|
+
"""Render a :class:`~.view.VariableView` to a Plotly figure dict.
|
|
2
|
+
|
|
3
|
+
Plain dicts, JSON-ready, no ``plotly.py``. One builder per primitive type in
|
|
4
|
+
:data:`TRACE_BUILDERS`; assembly adds the actual / reference / overlay
|
|
5
|
+
traces, the error axis and the spectrum panel. No mode name appears here —
|
|
6
|
+
a new metric is drawn by emitting primitives, a new primitive by adding one
|
|
7
|
+
builder.
|
|
8
|
+
|
|
9
|
+
Every trace carries the primitive id in ``meta`` (grouped marker traces
|
|
10
|
+
carry the ids in ``customdata`` and the family in ``meta``); every shape
|
|
11
|
+
carries it as ``name``. Those are the handles on-plot editing attaches to.
|
|
12
|
+
|
|
13
|
+
The editable figure (``editable=True``, the tool's; never the report's) adds
|
|
14
|
+
one pixel-sized ring shape (a ``path``) per ``kind="handle"`` marker and one
|
|
15
|
+
arrow per ``kind="grip"`` primitive — a side of a tolerance — turns on
|
|
16
|
+
``config.edits.shapePosition`` and turns OFF ``scrollZoom`` (:func:`_config`). That makes every shape draggable — plotly.js
|
|
17
|
+
offers no per-shape switch that works (measured on plotly.js 3.1.1, the
|
|
18
|
+
build NiceGUI 3.16 bundles; the bundle's "3.8.2" string is d3's) — and the
|
|
19
|
+
rings are what an editor attaches to, since Plotly drags shapes, not trace
|
|
20
|
+
points. So the editable figure also SAYS which of its shapes an editor
|
|
21
|
+
moves, in ``layout.meta`` (:data:`POINTER`): the page takes the pointer off
|
|
22
|
+
every other one.
|
|
23
|
+
|
|
24
|
+
Both builders take a ``theme`` — the name of a bundled theme (D148) — and
|
|
25
|
+
read its :class:`~.palette.Palette`: the figure's chrome, the few series
|
|
26
|
+
colours that sink into a dark plot, and the layout template. The colours
|
|
27
|
+
that are literals HERE carry meaning and are the same in every theme.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import copy
|
|
33
|
+
import html
|
|
34
|
+
from collections.abc import Callable, Collection, Iterable, Mapping, Sequence
|
|
35
|
+
from dataclasses import dataclass, replace
|
|
36
|
+
from typing import Literal
|
|
37
|
+
|
|
38
|
+
import numpy as np
|
|
39
|
+
|
|
40
|
+
from ..comparison.geometry import (
|
|
41
|
+
Band,
|
|
42
|
+
Box,
|
|
43
|
+
HLine,
|
|
44
|
+
Marker,
|
|
45
|
+
Primitive,
|
|
46
|
+
Series,
|
|
47
|
+
VLine,
|
|
48
|
+
Window,
|
|
49
|
+
grip_axis,
|
|
50
|
+
grip_group,
|
|
51
|
+
grip_label,
|
|
52
|
+
grip_outward,
|
|
53
|
+
)
|
|
54
|
+
from .families import family_label, group_families, ramp
|
|
55
|
+
from .palette import Palette, palette_of
|
|
56
|
+
from .view import LeafView, VariableView, decimate_view
|
|
57
|
+
|
|
58
|
+
PLOT_CONFIG = {"responsive": True, "displaylogo": False, "scrollZoom": True}
|
|
59
|
+
|
|
60
|
+
ACTUAL = "#2196F3"
|
|
61
|
+
REFERENCE = "#FF9800"
|
|
62
|
+
PASS = "rgba(76,175,80,0.15)"
|
|
63
|
+
PASS_LINE = "rgba(76,175,80,0.6)"
|
|
64
|
+
FAIL = "rgba(244,67,54,0.15)"
|
|
65
|
+
FAIL_LINE = "rgba(244,67,54,0.8)"
|
|
66
|
+
MUTED = "rgba(117,117,117,0.35)"
|
|
67
|
+
WINDOW_FILL = "rgba(33,150,243,0.08)"
|
|
68
|
+
WINDOW_LINE = "rgba(33,150,243,0.4)"
|
|
69
|
+
ERROR = "#f44336"
|
|
70
|
+
#: Which right-hand axis an error series rides on — one axis per unit family
|
|
71
|
+
#: (brief §3.4): signal units for the signed and absolute error, dimensionless
|
|
72
|
+
#: for the per-point NRMSE. A lookup, not a default: an error series whose
|
|
73
|
+
#: family is not here raises rather than being drawn against the wrong unit.
|
|
74
|
+
ERROR_AXES = {"error/signed": "y3", "error/abs": "y3", "error/nrmse": "y4"}
|
|
75
|
+
|
|
76
|
+
#: Vertical extent of the two panels when a spectrum is present.
|
|
77
|
+
TIME_DOMAIN = (0.45, 1.0)
|
|
78
|
+
SPECTRUM_DOMAIN = (0.0, 0.32)
|
|
79
|
+
|
|
80
|
+
#: Where the legend sits, on both tiers: above the plot area, one row that
|
|
81
|
+
#: wraps to more when its entries don't fit (a report legend of 8 entries is
|
|
82
|
+
#: 2 rows at 900 px). Plotly grows the top margin to fit it (measured: 25 ->
|
|
83
|
+
#: 41 px for one row), so it never sits on the trace start or on a ring (the
|
|
84
|
+
#: 2026-09-21 board, option b).
|
|
85
|
+
LEGEND = {
|
|
86
|
+
"orientation": "h",
|
|
87
|
+
"x": 0,
|
|
88
|
+
"xanchor": "left",
|
|
89
|
+
"y": 1,
|
|
90
|
+
"yanchor": "bottom",
|
|
91
|
+
"bgcolor": "rgba(0,0,0,0)",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class Visibility:
|
|
97
|
+
"""What the tool's page chose to show on one plot (legend b).
|
|
98
|
+
|
|
99
|
+
``hidden``: the leaf paths whose drawing is off. ``error``: the one error
|
|
100
|
+
series shown — a key of :data:`ERROR_AXES` — or ``None``. A figure built
|
|
101
|
+
WITH a Visibility puts only signals in its legend: the criteria are
|
|
102
|
+
toggled from their rows and the errors from one select. A figure built
|
|
103
|
+
without one (the report) keeps every legend entry."""
|
|
104
|
+
|
|
105
|
+
hidden: frozenset[str] = frozenset()
|
|
106
|
+
error: str | None = None
|
|
107
|
+
|
|
108
|
+
def __post_init__(self) -> None:
|
|
109
|
+
if isinstance(self.hidden, str):
|
|
110
|
+
raise TypeError(
|
|
111
|
+
"hidden takes a collection of leaf paths, not a str "
|
|
112
|
+
"(a str matches by substring)"
|
|
113
|
+
)
|
|
114
|
+
if self.error is not None and self.error not in ERROR_AXES:
|
|
115
|
+
raise ValueError(f"error {self.error!r} is none of {', '.join(ERROR_AXES)}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
_LABELS = {
|
|
119
|
+
"tube/band": "Tube",
|
|
120
|
+
"tube/upper": "Tube upper points",
|
|
121
|
+
"tube/lower": "Tube lower points",
|
|
122
|
+
"points/target": "Checkpoints",
|
|
123
|
+
"points/box": "Checkpoint boxes",
|
|
124
|
+
"range/min": "Min bound",
|
|
125
|
+
"range/max": "Max bound",
|
|
126
|
+
"scalar/actual": "Actual (scalar)",
|
|
127
|
+
"scalar/reference": "Reference (scalar)",
|
|
128
|
+
"scalar/min": "Min bound",
|
|
129
|
+
"scalar/max": "Max bound",
|
|
130
|
+
"events/ref": "Reference events",
|
|
131
|
+
"events/act": "Actual events",
|
|
132
|
+
"events/window": "Event windows",
|
|
133
|
+
"spectrum/ref": "Reference spectrum",
|
|
134
|
+
"spectrum/act": "Actual spectrum",
|
|
135
|
+
"peaks/declared": "Declared peaks",
|
|
136
|
+
"peaks/matched": "Matched peaks",
|
|
137
|
+
"peaks/detected": "Reference peaks",
|
|
138
|
+
"peaks/window": "Peak windows",
|
|
139
|
+
"error/signed": "Signed error",
|
|
140
|
+
"error/abs": "Abs error",
|
|
141
|
+
"error/nrmse": "NRMSE (per-point)",
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
_MARKER_SYMBOLS = {
|
|
145
|
+
"tube/upper": "triangle-up",
|
|
146
|
+
"tube/lower": "triangle-down",
|
|
147
|
+
"points/target": "diamond",
|
|
148
|
+
"peaks/declared": "diamond",
|
|
149
|
+
"peaks/matched": "x",
|
|
150
|
+
"peaks/detected": "circle-open",
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True)
|
|
155
|
+
class _Ctx:
|
|
156
|
+
x_min: float
|
|
157
|
+
x_max: float
|
|
158
|
+
has_spectrum: bool
|
|
159
|
+
#: Pre-decimation sample counts by primitive id (see ``decimate_view``).
|
|
160
|
+
full_samples: dict[str, int]
|
|
161
|
+
#: What the figure takes from its theme.
|
|
162
|
+
palette: Palette
|
|
163
|
+
#: The spectrum panel's x range where the layout SETS one
|
|
164
|
+
#: (:func:`_spectrum_range`), else None: what :func:`_grip_way` reads.
|
|
165
|
+
spectrum_range: tuple[float, float] | None = None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _full(ctx: _Ctx, prim_id: str, n_now: int) -> int:
|
|
169
|
+
return ctx.full_samples.get(prim_id, n_now)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def family(prim: Primitive, leaf: LeafView) -> str:
|
|
173
|
+
"""``tube/upper`` from ``/metrics/children/0/tube/upper/3``."""
|
|
174
|
+
local = (
|
|
175
|
+
prim.id[len(leaf.path) + 1 :]
|
|
176
|
+
if prim.id.startswith(leaf.path + "/")
|
|
177
|
+
else prim.id
|
|
178
|
+
)
|
|
179
|
+
return "/".join(local.split("/")[:2])
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _label(fam: str) -> str:
|
|
183
|
+
return _LABELS.get(fam, fam)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def as_text(label: str) -> str:
|
|
187
|
+
"""A label as Plotly must receive it to PRINT it.
|
|
188
|
+
|
|
189
|
+
Trace names, hover templates and axis titles are rendered through
|
|
190
|
+
plotly.js's small HTML subset (``<b>``, ``<br>``, ``<sub>`` …), so a
|
|
191
|
+
label holding ``<`` is read as markup: an expression the model wrote as
|
|
192
|
+
``a<b`` lost its tail, and one holding ``<br>`` broke the hover over two
|
|
193
|
+
lines. Escaped here — plotly.js decodes the entities back, so an
|
|
194
|
+
ordinary label is unchanged on the page.
|
|
195
|
+
|
|
196
|
+
Applied to every LABEL that enters a trace name, a hover or a title,
|
|
197
|
+
and to nothing that enters ``meta.id``, ``legendgroup`` or a shape
|
|
198
|
+
name: those carry NAMES, which the page, the picker and every test
|
|
199
|
+
look traces up by.
|
|
200
|
+
"""
|
|
201
|
+
return html.escape(label, quote=False)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def hover_of(label: str) -> dict:
|
|
205
|
+
"""The trace keys that make a hover READ ``label``, plus the value.
|
|
206
|
+
|
|
207
|
+
A hovertemplate is a format string: plotly.js substitutes every
|
|
208
|
+
``%{…}`` in it from the trace's data, and there is no escape for a
|
|
209
|
+
literal one. So the label does not enter the template at all — it rides
|
|
210
|
+
in the trace's ``meta`` and the template references it as
|
|
211
|
+
``%{meta.label}``.
|
|
212
|
+
|
|
213
|
+
NOT ``text``: plotly.js substitutes ``%{text}`` only from a PER-POINT
|
|
214
|
+
array. A scalar string is left alone, so every labelled hover on both
|
|
215
|
+
tiers rendered the literal ``%{text}: 1.234`` — read off a real mouse
|
|
216
|
+
hover in Chromium, which is the only place it shows (the figure dict
|
|
217
|
+
looked right). An array would be the label copied once per sample into
|
|
218
|
+
the page; ``meta`` is one object per trace, and ``%{meta.label}``
|
|
219
|
+
substitutes from it.
|
|
220
|
+
|
|
221
|
+
The ``meta`` here is MERGED into the trace's own, which already carries
|
|
222
|
+
its id and its sample counts — :func:`with_hover` does that, and a
|
|
223
|
+
plain spread beside a ``"meta"`` key would not.
|
|
224
|
+
"""
|
|
225
|
+
return {
|
|
226
|
+
"meta": {"label": as_text(label)},
|
|
227
|
+
# `.4~g` like the y axes' `hoverformat` (`_Y_LABELS`): a template
|
|
228
|
+
# overrides it, so it says the same itself.
|
|
229
|
+
"hovertemplate": "%{meta.label}: %{y:.4~g}<extra></extra>",
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def with_hover(trace: dict, label: str | None) -> dict:
|
|
234
|
+
"""``trace`` with a hover reading ``label``; ``None`` leaves it alone.
|
|
235
|
+
|
|
236
|
+
The one way :func:`hover_of` is applied: its ``meta`` folds into the
|
|
237
|
+
trace's own rather than replacing it, so the hover cannot cost a trace
|
|
238
|
+
its ``meta.id`` — the key the page, the overlay picker and every test
|
|
239
|
+
address a trace by.
|
|
240
|
+
"""
|
|
241
|
+
if label is None:
|
|
242
|
+
return trace
|
|
243
|
+
hover = hover_of(label)
|
|
244
|
+
trace["hovertemplate"] = hover["hovertemplate"]
|
|
245
|
+
trace["meta"] = {**trace.get("meta", {}), **hover["meta"]}
|
|
246
|
+
return trace
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _axes(prim: Primitive) -> dict:
|
|
250
|
+
return {"xaxis": "x2", "yaxis": "y2"} if prim.panel == "spectrum" else {}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _xref(prim: Primitive) -> str:
|
|
254
|
+
return "x2" if prim.panel == "spectrum" else "x"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _paper_span(prim: Primitive, ctx: _Ctx) -> tuple[float, float]:
|
|
258
|
+
if not ctx.has_spectrum:
|
|
259
|
+
return (0.0, 1.0)
|
|
260
|
+
return SPECTRUM_DOMAIN if prim.panel == "spectrum" else TIME_DOMAIN
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _window_span(window: Window | None, ctx: _Ctx) -> tuple[float, float]:
|
|
264
|
+
if window is None:
|
|
265
|
+
return ctx.x_min, ctx.x_max
|
|
266
|
+
return (
|
|
267
|
+
ctx.x_min if window.start is None else float(window.start),
|
|
268
|
+
ctx.x_max if window.end is None else float(window.end),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _band(prim: Band, leaf: LeafView, window: Window | None, ctx: _Ctx):
|
|
273
|
+
color = PASS if leaf.passed else FAIL
|
|
274
|
+
return [
|
|
275
|
+
{
|
|
276
|
+
"type": "scatter",
|
|
277
|
+
"x": [*prim.time.tolist(), *prim.time[::-1].tolist()],
|
|
278
|
+
"y": [*prim.upper.tolist(), *prim.lower[::-1].tolist()],
|
|
279
|
+
"fill": "toself",
|
|
280
|
+
"fillcolor": color,
|
|
281
|
+
"line": {"color": "rgba(0,0,0,0)", "width": 0},
|
|
282
|
+
"name": _label(family(prim, leaf)),
|
|
283
|
+
"hoverinfo": "skip",
|
|
284
|
+
"showlegend": True,
|
|
285
|
+
"meta": {
|
|
286
|
+
"id": prim.id,
|
|
287
|
+
"kind": prim.kind,
|
|
288
|
+
# A band is a closed polygon (2N points for N samples), so the
|
|
289
|
+
# banner cannot read its sample count off the trace's x length.
|
|
290
|
+
"samples": len(prim.time),
|
|
291
|
+
"full_samples": _full(ctx, prim.id, len(prim.time)),
|
|
292
|
+
},
|
|
293
|
+
**_axes(prim),
|
|
294
|
+
}
|
|
295
|
+
], []
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _box(prim: Box, leaf: LeafView, window: Window | None, ctx: _Ctx):
|
|
299
|
+
full_height = prim.y0 is None or prim.y1 is None
|
|
300
|
+
y0, y1 = _paper_span(prim, ctx) if full_height else (prim.y0, prim.y1)
|
|
301
|
+
ok = prim.passed is not False
|
|
302
|
+
return [], [
|
|
303
|
+
{
|
|
304
|
+
"type": "rect",
|
|
305
|
+
"xref": _xref(prim),
|
|
306
|
+
"yref": "paper"
|
|
307
|
+
if full_height
|
|
308
|
+
else ("y2" if prim.panel == "spectrum" else "y"),
|
|
309
|
+
"x0": prim.t0,
|
|
310
|
+
"x1": prim.t1,
|
|
311
|
+
"y0": y0,
|
|
312
|
+
"y1": y1,
|
|
313
|
+
"fillcolor": (PASS if ok else FAIL)
|
|
314
|
+
if not full_height
|
|
315
|
+
else ctx.palette.span_fill,
|
|
316
|
+
"line": {
|
|
317
|
+
"color": PASS_LINE if ok else FAIL_LINE,
|
|
318
|
+
"width": 1,
|
|
319
|
+
"dash": "dot",
|
|
320
|
+
},
|
|
321
|
+
"name": prim.id,
|
|
322
|
+
}
|
|
323
|
+
]
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _hline(prim: HLine, leaf: LeafView, window: Window | None, ctx: _Ctx):
|
|
327
|
+
ok = prim.passed is not False
|
|
328
|
+
color = (
|
|
329
|
+
(PASS_LINE if ok else FAIL_LINE)
|
|
330
|
+
if prim.kind in ("handle", "result", "act")
|
|
331
|
+
else MUTED
|
|
332
|
+
)
|
|
333
|
+
x0, x1 = _window_span(window, ctx)
|
|
334
|
+
shapes = [
|
|
335
|
+
{
|
|
336
|
+
"type": "line",
|
|
337
|
+
"xref": _xref(prim),
|
|
338
|
+
"yref": "y2" if prim.panel == "spectrum" else "y",
|
|
339
|
+
"x0": x0,
|
|
340
|
+
"x1": x1,
|
|
341
|
+
"y0": prim.value,
|
|
342
|
+
"y1": prim.value,
|
|
343
|
+
"line": {"color": color, "width": 1.5, "dash": "dash"},
|
|
344
|
+
"name": prim.id,
|
|
345
|
+
}
|
|
346
|
+
]
|
|
347
|
+
if window is not None:
|
|
348
|
+
shapes.append(
|
|
349
|
+
{
|
|
350
|
+
"type": "line",
|
|
351
|
+
"xref": "paper",
|
|
352
|
+
"yref": "y2" if prim.panel == "spectrum" else "y",
|
|
353
|
+
"x0": 0,
|
|
354
|
+
"x1": 1,
|
|
355
|
+
"y0": prim.value,
|
|
356
|
+
"y1": prim.value,
|
|
357
|
+
"line": {"color": MUTED, "width": 1, "dash": "dot"},
|
|
358
|
+
"name": f"{prim.id}/outside",
|
|
359
|
+
}
|
|
360
|
+
)
|
|
361
|
+
anchor = {
|
|
362
|
+
"type": "scatter",
|
|
363
|
+
"x": [ctx.x_min],
|
|
364
|
+
"y": [prim.value],
|
|
365
|
+
"mode": "markers",
|
|
366
|
+
"marker": {"opacity": 0},
|
|
367
|
+
"showlegend": False,
|
|
368
|
+
"hoverinfo": "skip",
|
|
369
|
+
"meta": {"anchor_for": prim.id},
|
|
370
|
+
**_axes(prim),
|
|
371
|
+
}
|
|
372
|
+
return [anchor], shapes
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _vline(prim: VLine, leaf: LeafView, window: Window | None, ctx: _Ctx):
|
|
376
|
+
y0, y1 = _paper_span(prim, ctx)
|
|
377
|
+
ref = prim.kind == "ref"
|
|
378
|
+
return [], [
|
|
379
|
+
{
|
|
380
|
+
"type": "line",
|
|
381
|
+
"xref": _xref(prim),
|
|
382
|
+
"yref": "paper",
|
|
383
|
+
"x0": prim.time,
|
|
384
|
+
"x1": prim.time,
|
|
385
|
+
"y0": y0,
|
|
386
|
+
"y1": y1,
|
|
387
|
+
"line": {
|
|
388
|
+
"color": ctx.palette.reference_event
|
|
389
|
+
if ref
|
|
390
|
+
else ctx.palette.actual_event,
|
|
391
|
+
"width": 1 if ref else 1.5,
|
|
392
|
+
"dash": "dash" if ref else "solid",
|
|
393
|
+
},
|
|
394
|
+
"name": prim.id,
|
|
395
|
+
}
|
|
396
|
+
]
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _series(prim: Series, leaf: LeafView, window: Window | None, ctx: _Ctx):
|
|
400
|
+
fam = family(prim, leaf)
|
|
401
|
+
color = REFERENCE if prim.kind == "ref" else ACTUAL
|
|
402
|
+
return [
|
|
403
|
+
{
|
|
404
|
+
"type": "scatter",
|
|
405
|
+
"mode": "lines",
|
|
406
|
+
"x": prim.x.tolist(),
|
|
407
|
+
"y": prim.y.tolist(),
|
|
408
|
+
"name": _label(fam),
|
|
409
|
+
"line": {
|
|
410
|
+
"color": color,
|
|
411
|
+
"width": 1.2,
|
|
412
|
+
"dash": "dash" if prim.kind == "ref" else "solid",
|
|
413
|
+
},
|
|
414
|
+
"meta": {
|
|
415
|
+
"id": prim.id,
|
|
416
|
+
"kind": prim.kind,
|
|
417
|
+
"samples": len(prim.x),
|
|
418
|
+
"full_samples": _full(ctx, prim.id, len(prim.x)),
|
|
419
|
+
},
|
|
420
|
+
**_axes(prim),
|
|
421
|
+
}
|
|
422
|
+
], []
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _window(prim: Window, leaf: LeafView, window: Window | None, ctx: _Ctx):
|
|
426
|
+
x0, x1 = _window_span(prim, ctx)
|
|
427
|
+
y0, y1 = _paper_span(prim, ctx)
|
|
428
|
+
return [], [
|
|
429
|
+
{
|
|
430
|
+
"type": "rect",
|
|
431
|
+
"xref": "x",
|
|
432
|
+
"yref": "paper",
|
|
433
|
+
"x0": x0,
|
|
434
|
+
"x1": x1,
|
|
435
|
+
"y0": y0,
|
|
436
|
+
"y1": y1,
|
|
437
|
+
"fillcolor": WINDOW_FILL,
|
|
438
|
+
"line": {"color": WINDOW_LINE, "width": 1, "dash": "dot"},
|
|
439
|
+
"name": prim.id,
|
|
440
|
+
}
|
|
441
|
+
]
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
Builder = Callable[
|
|
445
|
+
[Primitive, LeafView, "Window | None", _Ctx], "tuple[list[dict], list[dict]]"
|
|
446
|
+
]
|
|
447
|
+
|
|
448
|
+
#: One builder per primitive type. ``Marker`` is grouped per family in
|
|
449
|
+
#: :func:`variable_figure` rather than built one trace at a time.
|
|
450
|
+
TRACE_BUILDERS: dict[type, Builder] = {
|
|
451
|
+
Band: _band, # type: ignore[dict-item]
|
|
452
|
+
Box: _box, # type: ignore[dict-item]
|
|
453
|
+
HLine: _hline, # type: ignore[dict-item]
|
|
454
|
+
VLine: _vline, # type: ignore[dict-item]
|
|
455
|
+
Series: _series, # type: ignore[dict-item]
|
|
456
|
+
Window: _window, # type: ignore[dict-item]
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _marker_traces(
|
|
461
|
+
markers: list[Marker], leaf: LeafView, palette: Palette
|
|
462
|
+
) -> list[dict]:
|
|
463
|
+
by_family: dict[str, list[Marker]] = {}
|
|
464
|
+
for m in markers:
|
|
465
|
+
by_family.setdefault(family(m, leaf), []).append(m)
|
|
466
|
+
traces = []
|
|
467
|
+
for fam, group in by_family.items():
|
|
468
|
+
# One colour PER marker: a leaf with one passed and one failed
|
|
469
|
+
# checkpoint must not paint both red (the boxes beside them carry
|
|
470
|
+
# their own verdicts, and the two would contradict).
|
|
471
|
+
ok_colour = palette.ring if group[0].kind == "handle" else ACTUAL
|
|
472
|
+
traces.append(
|
|
473
|
+
{
|
|
474
|
+
"type": "scatter",
|
|
475
|
+
"mode": "markers",
|
|
476
|
+
"x": [m.x for m in group],
|
|
477
|
+
"y": [m.y for m in group],
|
|
478
|
+
"customdata": [m.id for m in group],
|
|
479
|
+
"name": _label(fam),
|
|
480
|
+
"marker": {
|
|
481
|
+
"symbol": _MARKER_SYMBOLS.get(fam, "circle"),
|
|
482
|
+
"size": 10,
|
|
483
|
+
"color": [
|
|
484
|
+
FAIL_LINE if m.passed is False else ok_colour for m in group
|
|
485
|
+
],
|
|
486
|
+
"line": {"color": palette.marker_outline, "width": 1},
|
|
487
|
+
},
|
|
488
|
+
"hoverinfo": "x+y",
|
|
489
|
+
"showlegend": True,
|
|
490
|
+
"meta": {"family": fam, "kind": group[0].kind},
|
|
491
|
+
**_axes(group[0]),
|
|
492
|
+
}
|
|
493
|
+
)
|
|
494
|
+
return traces
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
#: A ring, in pixels about its anchor: a circle of radius 7 as four cubic
|
|
498
|
+
#: arcs. A ``path``, not a ``circle``: plotly.js gives a pixel-sized circle
|
|
499
|
+
#: or rect larger than 10 px in both dimensions RESIZE zones, so a ring
|
|
500
|
+
#: pressed 3 px off its centre was resized and staged nothing; a path has
|
|
501
|
+
#: none and moves from wherever it is pressed (Chromium, plotly.js 3.1.1,
|
|
502
|
+
#: 2026-10-02).
|
|
503
|
+
RING_PATH = (
|
|
504
|
+
"M 7,0 C 7,3.866 3.866,7 0,7 C -3.866,7 -7,3.866 -7,0 "
|
|
505
|
+
"C -7,-3.866 -3.866,-7 0,-7 C 3.866,-7 7,-3.866 7,0 Z"
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _handle_rings(markers: list[Marker], palette: Palette) -> list[dict]:
|
|
510
|
+
"""One draggable ring per handle marker, named with the marker's id, in
|
|
511
|
+
the theme's ring colour.
|
|
512
|
+
|
|
513
|
+
The fill is visible on purpose: a shape drags by its painted area, and a
|
|
514
|
+
transparent fill is not guaranteed to receive the pointer.
|
|
515
|
+
"""
|
|
516
|
+
return [
|
|
517
|
+
{
|
|
518
|
+
"type": "path",
|
|
519
|
+
"xref": _xref(m),
|
|
520
|
+
"yref": "y2" if m.panel == "spectrum" else "y",
|
|
521
|
+
"xsizemode": "pixel",
|
|
522
|
+
"ysizemode": "pixel",
|
|
523
|
+
"xanchor": m.x,
|
|
524
|
+
"yanchor": m.y,
|
|
525
|
+
"path": RING_PATH,
|
|
526
|
+
"line": {"color": palette.ring, "width": 2},
|
|
527
|
+
"fillcolor": palette.ring_fill,
|
|
528
|
+
"name": m.id,
|
|
529
|
+
}
|
|
530
|
+
for m in markers
|
|
531
|
+
if m.kind == "handle"
|
|
532
|
+
]
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
#: A grip's colour: an orange apart from the rings' green — the default
|
|
536
|
+
#: theme's ``no-ref`` dark, as a literal, and the same on a dark plot, where
|
|
537
|
+
#: it reads 4.4:1 (`test_the_grip_colour_is_a_token_of_the_theme_table`
|
|
538
|
+
#: pins it to the table).
|
|
539
|
+
GRIP = "#E65100"
|
|
540
|
+
|
|
541
|
+
#: A grip, in pixels about its anchor (positive y is UP), by the axis it
|
|
542
|
+
#: moves on and the way it is drawn (:func:`_grip_way` — outward, but for
|
|
543
|
+
#: an arrow that would leave a set range): a triangle whose apex stands
|
|
544
|
+
#: 10 px off the edge it is anchored on and points at it. It clears a ring of
|
|
545
|
+
#: radius 7 on that edge whatever the box's size — rings on the edges buried
|
|
546
|
+
#: the target under a box of 16 px or less, and a sideways drag on the
|
|
547
|
+
#: checkpoint then rewrote its tolerance — and, a ``path``, it never resizes.
|
|
548
|
+
_GRIP_PATHS = {
|
|
549
|
+
("y", 1): "M 0,10 L 6,17 L -6,17 Z",
|
|
550
|
+
("y", -1): "M 0,-10 L 6,-17 L -6,-17 Z",
|
|
551
|
+
("x", -1): "M -10,0 L -17,6 L -17,-6 Z",
|
|
552
|
+
("x", 1): "M 10,0 L 17,6 L 17,-6 Z",
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
#: An arrow's far end, px from its anchor (:data:`_GRIP_PATHS`), and the
|
|
557
|
+
#: narrowest axis the figure plans for, px: the figure layer does not know
|
|
558
|
+
#: how wide the page draws a plot, and an arrow that would pass the end of
|
|
559
|
+
#: a set range on an axis this narrow is drawn inward (:func:`_grip_way`).
|
|
560
|
+
#: On a wider plot some arrows that had the room are drawn inward too; on
|
|
561
|
+
#: a narrower one, one that had not is still clipped.
|
|
562
|
+
GRIP_FAR_END = 17
|
|
563
|
+
NARROWEST_AXIS = 500
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _grip_way(grip: Marker | VLine, ctx: _Ctx) -> int:
|
|
567
|
+
"""``+1`` or ``-1``: the way along its axis ``grip``'s arrow is DRAWN
|
|
568
|
+
from its anchor — outward, away from its target
|
|
569
|
+
(:func:`~dstf.comparison.geometry.grip_outward`), except where the axis
|
|
570
|
+
it moves on has a SET range and the arrow's far end would pass the end
|
|
571
|
+
of it: the plot area clips shapes, and with a peak window's lower edge
|
|
572
|
+
next to 0 Hz the arrow was drawn left of the panel, where a press
|
|
573
|
+
landed on the axis. There it is drawn on the inside of its edge,
|
|
574
|
+
mirrored about the same anchor.
|
|
575
|
+
|
|
576
|
+
The one axis whose range the layout sets is the spectrum's x
|
|
577
|
+
(:func:`_spectrum_range`); every other is autoranged — over its shapes
|
|
578
|
+
too, a pixel-sized path padded by its pixel extent (plotly.js 3.1.1),
|
|
579
|
+
which is room for the arrow with nothing added here — and its arrows
|
|
580
|
+
are always outward."""
|
|
581
|
+
way = grip_outward(grip)
|
|
582
|
+
if grip.panel != "spectrum" or grip_axis(grip) != "x":
|
|
583
|
+
return way
|
|
584
|
+
if ctx.spectrum_range is None:
|
|
585
|
+
return way
|
|
586
|
+
low, high = ctx.spectrum_range
|
|
587
|
+
at = grip.time if isinstance(grip, VLine) else grip.x
|
|
588
|
+
room = (at - low) if way < 0 else (high - at)
|
|
589
|
+
needed = GRIP_FAR_END / NARROWEST_AXIS * (high - low)
|
|
590
|
+
return -way if room < needed else way
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _grip_shapes(grips: list[Primitive], ctx: _Ctx) -> list[dict]:
|
|
594
|
+
"""One draggable arrow per grip, named with the grip's id and anchored
|
|
595
|
+
at the tolerance edge it stands for. A marker's is anchored in data on
|
|
596
|
+
both axes; a line's — the edge of a window that spans its panel — in
|
|
597
|
+
data on x and on the PAPER at the panel's mid-height. It is drawn the
|
|
598
|
+
way :func:`_grip_way` says.
|
|
599
|
+
|
|
600
|
+
The page shows a grip only while the pointer is near its checkpoint,
|
|
601
|
+
or one that stands close to it (``authoring/shell/grips.js``); the
|
|
602
|
+
figure holds them all, always — a
|
|
603
|
+
drag names a shape by its index."""
|
|
604
|
+
shapes = []
|
|
605
|
+
for grip in grips:
|
|
606
|
+
if isinstance(grip, Marker):
|
|
607
|
+
anchor = {
|
|
608
|
+
"xanchor": grip.x,
|
|
609
|
+
"yref": "y2" if grip.panel == "spectrum" else "y",
|
|
610
|
+
"yanchor": grip.y,
|
|
611
|
+
}
|
|
612
|
+
elif isinstance(grip, VLine):
|
|
613
|
+
y0, y1 = _paper_span(grip, ctx)
|
|
614
|
+
anchor = {"xanchor": grip.time, "yref": "paper", "yanchor": (y0 + y1) / 2}
|
|
615
|
+
else:
|
|
616
|
+
raise TypeError(
|
|
617
|
+
f"no grip shape for {type(grip).__name__} (id {grip.id!r}): "
|
|
618
|
+
"a grip is a Marker or a VLine"
|
|
619
|
+
)
|
|
620
|
+
shapes.append(
|
|
621
|
+
{
|
|
622
|
+
"type": "path",
|
|
623
|
+
"xref": _xref(grip),
|
|
624
|
+
"xsizemode": "pixel",
|
|
625
|
+
"ysizemode": "pixel",
|
|
626
|
+
**anchor,
|
|
627
|
+
"path": _GRIP_PATHS[grip_axis(grip), _grip_way(grip, ctx)],
|
|
628
|
+
"line": {"color": GRIP, "width": 1},
|
|
629
|
+
"fillcolor": GRIP,
|
|
630
|
+
"name": grip.id,
|
|
631
|
+
}
|
|
632
|
+
)
|
|
633
|
+
return shapes
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _base_traces(view: VariableView, palette: Palette) -> list[dict]:
|
|
637
|
+
traces = []
|
|
638
|
+
|
|
639
|
+
# A one-variable plot names its traces "Actual" / "Reference" — the
|
|
640
|
+
# overlay picker and report.js find them by those names, and the title
|
|
641
|
+
# above says which variable it is. When the label is not the name the
|
|
642
|
+
# hover says the label too: on a `unitTests.x[3]` card, "Actual" alone
|
|
643
|
+
# names nothing a person recognises. A variable whose label IS its name
|
|
644
|
+
# gets no hover it did not have.
|
|
645
|
+
def _hover(suffix: str = "") -> str | None:
|
|
646
|
+
if view.label == view.name:
|
|
647
|
+
return None
|
|
648
|
+
return f"{view.label}{suffix}"
|
|
649
|
+
|
|
650
|
+
if view.act_time is not None and view.act_values is not None:
|
|
651
|
+
traces.append(
|
|
652
|
+
with_hover(
|
|
653
|
+
{
|
|
654
|
+
"type": "scatter",
|
|
655
|
+
"mode": "lines",
|
|
656
|
+
"name": "Actual",
|
|
657
|
+
"x": view.act_time.tolist(),
|
|
658
|
+
"y": view.act_values.tolist(),
|
|
659
|
+
"line": {"color": ACTUAL, "width": 1.5},
|
|
660
|
+
"meta": {
|
|
661
|
+
"id": "actual",
|
|
662
|
+
"kind": "act",
|
|
663
|
+
"samples": len(view.act_time),
|
|
664
|
+
"full_samples": view.full_samples.get(
|
|
665
|
+
"act", len(view.act_time)
|
|
666
|
+
),
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
_hover(),
|
|
670
|
+
)
|
|
671
|
+
)
|
|
672
|
+
if view.ref_time is not None and view.ref_values is not None:
|
|
673
|
+
traces.append(
|
|
674
|
+
with_hover(
|
|
675
|
+
{
|
|
676
|
+
"type": "scatter",
|
|
677
|
+
"mode": "lines",
|
|
678
|
+
"name": "Reference",
|
|
679
|
+
"x": view.ref_time.tolist(),
|
|
680
|
+
"y": view.ref_values.tolist(),
|
|
681
|
+
"line": {"color": REFERENCE, "width": 1.5, "dash": "dash"},
|
|
682
|
+
"meta": {
|
|
683
|
+
"id": "reference",
|
|
684
|
+
"kind": "ref",
|
|
685
|
+
"samples": len(view.ref_time),
|
|
686
|
+
"full_samples": view.full_samples.get(
|
|
687
|
+
"ref", len(view.ref_time)
|
|
688
|
+
),
|
|
689
|
+
},
|
|
690
|
+
},
|
|
691
|
+
_hover(" (reference)"),
|
|
692
|
+
)
|
|
693
|
+
)
|
|
694
|
+
for ov in view.overlays:
|
|
695
|
+
if ov.role == "soft_check":
|
|
696
|
+
color, dash = palette.soft_check, "dot"
|
|
697
|
+
elif ov.kind == "sibling-backend":
|
|
698
|
+
color, dash = palette.sibling, "dash"
|
|
699
|
+
else:
|
|
700
|
+
color, dash = palette.companion, "dashdot"
|
|
701
|
+
traces.append(
|
|
702
|
+
{
|
|
703
|
+
"type": "scatter",
|
|
704
|
+
"mode": "lines",
|
|
705
|
+
# The page's overlay picker finds a trace by this exact
|
|
706
|
+
# name (report.js overlayTraceName); keep the two in step.
|
|
707
|
+
"name": f"Overlay: {ov.role}/{ov.name}",
|
|
708
|
+
"x": ov.time.tolist(),
|
|
709
|
+
"y": ov.values.tolist(),
|
|
710
|
+
"line": {"color": color, "width": 1.2, "dash": dash},
|
|
711
|
+
"opacity": 0.85,
|
|
712
|
+
"visible": "legendonly",
|
|
713
|
+
"meta": {
|
|
714
|
+
"id": f"overlay/{ov.role}/{ov.name}",
|
|
715
|
+
"kind": "overlay",
|
|
716
|
+
"role": ov.role,
|
|
717
|
+
"samples": len(ov.time),
|
|
718
|
+
"full_samples": (
|
|
719
|
+
len(ov.time) if ov.full_samples is None else ov.full_samples
|
|
720
|
+
),
|
|
721
|
+
},
|
|
722
|
+
}
|
|
723
|
+
)
|
|
724
|
+
return traces
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _error_trace(s: Series, ctx: _Ctx, visibility: Visibility | None) -> dict:
|
|
728
|
+
trace = {
|
|
729
|
+
"type": "scatter",
|
|
730
|
+
"mode": "lines",
|
|
731
|
+
"x": s.x.tolist(),
|
|
732
|
+
"y": s.y.tolist(),
|
|
733
|
+
"name": _label(s.id),
|
|
734
|
+
"line": {"color": ERROR, "width": 1},
|
|
735
|
+
"opacity": 0.6,
|
|
736
|
+
"yaxis": ERROR_AXES[s.id],
|
|
737
|
+
"visible": "legendonly",
|
|
738
|
+
# `.4~g` like the y axes' `hoverformat` (`_Y_LABELS`).
|
|
739
|
+
"hovertemplate": f"{_label(s.id)}: %{{y:.4~g}}<extra></extra>",
|
|
740
|
+
"meta": {
|
|
741
|
+
"id": s.id,
|
|
742
|
+
"kind": "error",
|
|
743
|
+
"samples": len(s.x),
|
|
744
|
+
"full_samples": _full(ctx, s.id, len(s.x)),
|
|
745
|
+
},
|
|
746
|
+
}
|
|
747
|
+
if visibility is not None:
|
|
748
|
+
trace["showlegend"] = False
|
|
749
|
+
trace["visible"] = s.id == visibility.error
|
|
750
|
+
return trace
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _time_span(view: VariableView) -> tuple[float, float]:
|
|
754
|
+
xs = [a for a in (view.act_time, view.ref_time) if a is not None and len(a)]
|
|
755
|
+
if not xs:
|
|
756
|
+
return 0.0, 1.0
|
|
757
|
+
return float(min(a[0] for a in xs)), float(max(a[-1] for a in xs))
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
#: How every y axis prints its numbers, on both tiers: Plotly's automatic
|
|
761
|
+
#: tick labels with `e` exponents, and 4 significant digits on a hover with
|
|
762
|
+
#: the trailing zeros trimmed (d3 `.4~g`: 0.6 reads `0.6`, 2.5e-5 reads
|
|
763
|
+
#: `0.000025`, where `.4g` printed `0.6000` and `0.00002500`; the same in
|
|
764
|
+
#: plotly.js 2.35.0 and 3.1.1). A trace's own hovertemplate overrides it,
|
|
765
|
+
#: so `hover_of` and `_error_trace` say `.4~g` too. A fixed `.4g`
|
|
766
|
+
#: tickformat printed `0.2000` for 0.2 and `0.00002000` for an error around
|
|
767
|
+
#: 1e-5, the size a passing test's error usually is (54 px against 23 for
|
|
768
|
+
#: `2e−5`).
|
|
769
|
+
_Y_LABELS = {"exponentformat": "e", "hoverformat": ".4~g"}
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
#: How far past its peak of interest the spectrum's x axis runs.
|
|
773
|
+
SPECTRUM_HEADROOM = 3.0
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _spectrum_range(view: VariableView) -> list[float] | None:
|
|
777
|
+
"""``[0, 3 x the peak of interest]``, clamped to Nyquist but never short
|
|
778
|
+
of the peak. The peak of interest is the highest declared or matched
|
|
779
|
+
peak (its window's upper edge where it has one), else the STRONGEST
|
|
780
|
+
detected one: the reference's ten detected peaks include leakage
|
|
781
|
+
sidelobes, and on a 2 Hz sine the highest of them put the axis at
|
|
782
|
+
40.8 Hz. None — autorange — with no peak above zero.
|
|
783
|
+
|
|
784
|
+
Autoranged, the axis ran 0..Nyquist, and Nyquist follows the sample
|
|
785
|
+
count, not the physics: TRANSFORM's peaks near 1.2e-5 Hz sat in the
|
|
786
|
+
leftmost ~10 px of an axis to ~1.7e-3 Hz (2026-09-26)."""
|
|
787
|
+
declared: list[float] = []
|
|
788
|
+
detected: list[tuple[float, float]] = [] # (frequency, magnitude)
|
|
789
|
+
nyquist = 0.0
|
|
790
|
+
for leaf in view.leaves:
|
|
791
|
+
for p in leaf.geometry:
|
|
792
|
+
if p.panel != "spectrum":
|
|
793
|
+
continue
|
|
794
|
+
if isinstance(p, Series):
|
|
795
|
+
if len(p.x):
|
|
796
|
+
nyquist = max(nyquist, float(np.nanmax(p.x)))
|
|
797
|
+
elif isinstance(p, Marker):
|
|
798
|
+
if p.kind == "detected":
|
|
799
|
+
detected.append((float(p.x), float(p.y)))
|
|
800
|
+
else:
|
|
801
|
+
declared.append(float(p.x))
|
|
802
|
+
elif isinstance(p, Box):
|
|
803
|
+
declared.append(float(p.t1))
|
|
804
|
+
if declared:
|
|
805
|
+
top = max(declared)
|
|
806
|
+
elif detected:
|
|
807
|
+
top = max(detected, key=lambda d: d[1])[0]
|
|
808
|
+
else:
|
|
809
|
+
return None
|
|
810
|
+
if not top > 0:
|
|
811
|
+
return None
|
|
812
|
+
hi = SPECTRUM_HEADROOM * top
|
|
813
|
+
# A declared peak above Nyquist stays on the axis.
|
|
814
|
+
return [0.0, max(min(hi, nyquist), top) if nyquist > 0 else hi]
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _set_spectrum_range(view: VariableView) -> tuple[float, float] | None:
|
|
818
|
+
"""The spectrum's x range as :func:`_layout` sets it for ``view``, or
|
|
819
|
+
None where it sets none (no spectrum panel, or an autoranged one)."""
|
|
820
|
+
found = _spectrum_range(view) if view.has_spectrum else None
|
|
821
|
+
return None if found is None else (found[0], found[1])
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _layout(
|
|
825
|
+
view: VariableView, shapes: list[dict], has_errors: bool, palette: Palette
|
|
826
|
+
) -> dict:
|
|
827
|
+
# Area shapes under lines: under `edits.shapePosition` every shape takes
|
|
828
|
+
# a drag and plotly.js has no per-shape switch that works (`layer` only
|
|
829
|
+
# restacks — measured in Chromium on 3.1.1, 2026-09-26), so the one that
|
|
830
|
+
# is on top wins. A window's or a box's rect drawn after a bound line
|
|
831
|
+
# swallowed every drag on the bound inside it.
|
|
832
|
+
shapes = [s for s in shapes if s.get("type") == "rect"] + [
|
|
833
|
+
s for s in shapes if s.get("type") != "rect"
|
|
834
|
+
]
|
|
835
|
+
layout: dict = {
|
|
836
|
+
"xaxis": {
|
|
837
|
+
"title": {"text": "Time"},
|
|
838
|
+
"hoverformat": ".6~g",
|
|
839
|
+
"uirevision": "keep",
|
|
840
|
+
},
|
|
841
|
+
"yaxis": {
|
|
842
|
+
"title": {"text": "Value", "standoff": 10},
|
|
843
|
+
**_Y_LABELS,
|
|
844
|
+
"automargin": True,
|
|
845
|
+
"nticks": 6,
|
|
846
|
+
"uirevision": "keep",
|
|
847
|
+
},
|
|
848
|
+
"margin": {"t": 25, "b": 45, "l": 60, "r": 20},
|
|
849
|
+
"legend": dict(LEGEND),
|
|
850
|
+
# The hover toolbar stands on the right edge: laid across the top it
|
|
851
|
+
# covered the legend above the plot area once that wrapped to a
|
|
852
|
+
# second row (the owner's 2026-09-24 ruling, both tiers).
|
|
853
|
+
"modebar": {"orientation": "v"},
|
|
854
|
+
"hovermode": "x unified",
|
|
855
|
+
"shapes": shapes,
|
|
856
|
+
"uirevision": "keep",
|
|
857
|
+
}
|
|
858
|
+
if palette.template is not None:
|
|
859
|
+
# The theme's ground, grid, axes, legend, hover label and toolbar,
|
|
860
|
+
# through Plotly's own template: read for whatever the layout
|
|
861
|
+
# above does not set. The default theme has none — its plot is
|
|
862
|
+
# plotly.js's defaults, and its figure is the one it always was.
|
|
863
|
+
layout["template"] = {"layout": copy.deepcopy(palette.template)}
|
|
864
|
+
if view.has_spectrum:
|
|
865
|
+
layout["yaxis"]["domain"] = list(TIME_DOMAIN)
|
|
866
|
+
layout["xaxis2"] = {
|
|
867
|
+
"title": {"text": "Frequency [Hz]"},
|
|
868
|
+
"anchor": "y2",
|
|
869
|
+
# A zoom into the spectrum survives the page's redraws, like
|
|
870
|
+
# the time axis's.
|
|
871
|
+
"uirevision": "keep",
|
|
872
|
+
}
|
|
873
|
+
spectrum_range = _spectrum_range(view)
|
|
874
|
+
if spectrum_range is not None:
|
|
875
|
+
layout["xaxis2"]["range"] = spectrum_range
|
|
876
|
+
layout["yaxis2"] = {
|
|
877
|
+
"title": {"text": "Magnitude"},
|
|
878
|
+
**_Y_LABELS,
|
|
879
|
+
"domain": list(SPECTRUM_DOMAIN),
|
|
880
|
+
"anchor": "x2",
|
|
881
|
+
}
|
|
882
|
+
if has_errors:
|
|
883
|
+
# Both axes are laid out hidden: a laid-out axis with only
|
|
884
|
+
# legend-only traces gets a placeholder range and draws its zero
|
|
885
|
+
# line as a stray black rule across the plot. In the report,
|
|
886
|
+
# templates/axes.js shows an axis while a series on it is visible,
|
|
887
|
+
# following the select and legend clicks; the tool's figure, built
|
|
888
|
+
# with a Visibility, sets its one chosen axis visible itself
|
|
889
|
+
# (`variable_figure`) and loads no axis script.
|
|
890
|
+
layout["yaxis3"] = _secondary_axis("Error [signal units]")
|
|
891
|
+
layout["yaxis4"] = {
|
|
892
|
+
**_secondary_axis("NRMSE [–]"),
|
|
893
|
+
# The report's legend can show signed and NRMSE together, and a
|
|
894
|
+
# second right axis anchored to x would draw over the first;
|
|
895
|
+
# free + autoshift stacks it outward (plotly.js >= 2.25; the
|
|
896
|
+
# report vendors 2.35.0, NiceGUI 3.16 bundles 3.1.1). The tool
|
|
897
|
+
# shows one at a time and anchors it (`variable_figure`).
|
|
898
|
+
"anchor": "free",
|
|
899
|
+
"autoshift": True,
|
|
900
|
+
}
|
|
901
|
+
return layout
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _secondary_axis(title: str) -> dict:
|
|
905
|
+
return {
|
|
906
|
+
"title": {"text": title, "font": {"color": ERROR, "size": 10}},
|
|
907
|
+
**_Y_LABELS,
|
|
908
|
+
"nticks": 4,
|
|
909
|
+
"automargin": True,
|
|
910
|
+
"overlaying": "y",
|
|
911
|
+
"side": "right",
|
|
912
|
+
"showgrid": False,
|
|
913
|
+
"tickfont": {"color": ERROR, "size": 10},
|
|
914
|
+
"zeroline": False,
|
|
915
|
+
"visible": False,
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
#: The key of the editable figure's ``layout.meta`` that says which of its
|
|
920
|
+
#: shapes take the pointer — read by the page's ``grips.js``, which derives
|
|
921
|
+
#: nothing from a shape's name:
|
|
922
|
+
#:
|
|
923
|
+
#: * ``grips``: per grip shape, by name, the ``group`` it is of
|
|
924
|
+
#: (:func:`~dstf.comparison.geometry.grip_group` — a checkpoint, an event,
|
|
925
|
+
#: a peak: the page shows a group's arrows together, and with them those
|
|
926
|
+
#: of every group whose hover area overlaps its own), the ``axis`` it
|
|
927
|
+
#: moves on (the page's cursor over it), and the ``label`` it carries as
|
|
928
|
+
#: its tooltip (:func:`~dstf.comparison.geometry.grip_label` — the words
|
|
929
|
+
#: are Python's). Which way along that axis its arrow is DRAWN
|
|
930
|
+
#: (:func:`_grip_way`) is in its shape's path and nowhere here: the page
|
|
931
|
+
#: reads the arrow's box as drawn;
|
|
932
|
+
#: * ``targets``: per ring a group's grips stand around, by name, that
|
|
933
|
+
#: group — the ring is part of its group's hover area, and the plot's
|
|
934
|
+
#: hover label gives way next to it;
|
|
935
|
+
#: * ``draggable``: the names an editor moves — the ringed leaves' rings,
|
|
936
|
+
#: grips and ``kind="handle"`` lines. Every OTHER shape of the figure (a
|
|
937
|
+
#: box, a window, a result line, a leaf that is not open) the page makes
|
|
938
|
+
#: inert: under ``edits.shapePosition`` Plotly would drag it all the same.
|
|
939
|
+
#:
|
|
940
|
+
#: IN the figure, not beside it: whatever figure the browser holds, this is
|
|
941
|
+
#: the map of that figure — the two cannot arrive out of step.
|
|
942
|
+
POINTER = "dstf"
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def _no_pointer() -> dict:
|
|
946
|
+
return {"grips": {}, "targets": {}, "draggable": []}
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
#: :func:`drags_of`'s words.
|
|
950
|
+
Drags = Literal["rings+arrows", "arrows", "rings", "lines", ""]
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def drags_of(geometry: Iterable[Primitive]) -> Drags:
|
|
954
|
+
"""What the editable figure gives a ringed leaf of ``geometry`` to be
|
|
955
|
+
dragged by, in a word — :func:`_leaf_traces`' own rule, what
|
|
956
|
+
:data:`POINTER` holds for that leaf, for a caller that has to SAY it
|
|
957
|
+
(the editor's hint line): ``"rings+arrows"`` where it draws a ring (a
|
|
958
|
+
handle marker) and an arrow (a grip) — a checkpoint, a declared peak;
|
|
959
|
+
``"arrows"`` where it draws arrows and no ring — a tube of constant
|
|
960
|
+
width, an event's window; ``"rings"`` where rings and no arrow — a
|
|
961
|
+
tube's control points; each of those with or without lines.
|
|
962
|
+
``"lines"`` where the handle lines are all an editor moves; ``""``
|
|
963
|
+
where nothing is. Read off the geometry, so the words follow what a
|
|
964
|
+
metric draws and no list of metric names."""
|
|
965
|
+
rings = arrows = lines = False
|
|
966
|
+
for prim in geometry:
|
|
967
|
+
arrows = arrows or prim.kind == "grip"
|
|
968
|
+
rings = rings or (isinstance(prim, Marker) and prim.kind == "handle")
|
|
969
|
+
lines = lines or (isinstance(prim, HLine) and prim.kind == "handle")
|
|
970
|
+
if rings and arrows:
|
|
971
|
+
return "rings+arrows"
|
|
972
|
+
if arrows:
|
|
973
|
+
return "arrows"
|
|
974
|
+
if rings:
|
|
975
|
+
return "rings"
|
|
976
|
+
return "lines" if lines else ""
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def _leaf_traces(
|
|
980
|
+
view: VariableView,
|
|
981
|
+
ctx: _Ctx,
|
|
982
|
+
*,
|
|
983
|
+
editable: bool,
|
|
984
|
+
rings_for: Collection[str] | None = None,
|
|
985
|
+
visibility: Visibility | None = None,
|
|
986
|
+
) -> tuple[list[dict], list[dict], list[dict], dict]:
|
|
987
|
+
"""Every leaf of one view, drawn: ``(traces, shapes, rings, pointer)``
|
|
988
|
+
— the rings holding the open leaves' grips too, ``pointer`` what
|
|
989
|
+
:data:`POINTER` says of them.
|
|
990
|
+
|
|
991
|
+
The one loop both figure builders run — so a new primitive type, or a
|
|
992
|
+
change to what a handle ring is, lands in a single place. A
|
|
993
|
+
``kind="grip"`` primitive is never a trace and never one of the
|
|
994
|
+
figure's own shapes: it is drawn with its leaf's rings or not at all,
|
|
995
|
+
so a figure that is not editable is what the same geometry without its
|
|
996
|
+
grips draws.
|
|
997
|
+
"""
|
|
998
|
+
if isinstance(rings_for, str):
|
|
999
|
+
raise TypeError(
|
|
1000
|
+
"rings_for takes a collection of leaf paths, not a str "
|
|
1001
|
+
"(a str matches by substring)"
|
|
1002
|
+
)
|
|
1003
|
+
data: list[dict] = []
|
|
1004
|
+
shapes: list[dict] = []
|
|
1005
|
+
rings: list[dict] = []
|
|
1006
|
+
pointer = _no_pointer()
|
|
1007
|
+
for leaf in view.leaves:
|
|
1008
|
+
window = next((p for p in leaf.geometry if isinstance(p, Window)), None)
|
|
1009
|
+
markers: list[Marker] = []
|
|
1010
|
+
grips: list[Primitive] = []
|
|
1011
|
+
leaf_data: list[dict] = []
|
|
1012
|
+
leaf_shapes: list[dict] = []
|
|
1013
|
+
lines: list[str] = [] # the shapes of this leaf's handle lines
|
|
1014
|
+
for prim in leaf.geometry:
|
|
1015
|
+
if prim.kind == "grip":
|
|
1016
|
+
grips.append(prim)
|
|
1017
|
+
continue
|
|
1018
|
+
if isinstance(prim, Marker):
|
|
1019
|
+
markers.append(prim)
|
|
1020
|
+
continue
|
|
1021
|
+
builder = TRACE_BUILDERS.get(type(prim))
|
|
1022
|
+
if builder is None:
|
|
1023
|
+
raise TypeError(
|
|
1024
|
+
f"no figure builder for {type(prim).__name__} "
|
|
1025
|
+
f"(id {prim.id!r}); add one to TRACE_BUILDERS"
|
|
1026
|
+
)
|
|
1027
|
+
traces, new_shapes = builder(prim, leaf, window, ctx)
|
|
1028
|
+
leaf_data.extend(traces)
|
|
1029
|
+
leaf_shapes.extend(new_shapes)
|
|
1030
|
+
if isinstance(prim, HLine) and prim.kind == "handle":
|
|
1031
|
+
lines.extend(s["name"] for s in new_shapes)
|
|
1032
|
+
leaf_data.extend(_marker_traces(markers, leaf, ctx.palette))
|
|
1033
|
+
ringed = editable and (rings_for is None or leaf.path in rings_for)
|
|
1034
|
+
leaf_rings = (
|
|
1035
|
+
_handle_rings(markers, ctx.palette) + _grip_shapes(grips, ctx)
|
|
1036
|
+
if ringed
|
|
1037
|
+
else []
|
|
1038
|
+
)
|
|
1039
|
+
if ringed:
|
|
1040
|
+
for grip in grips:
|
|
1041
|
+
assert isinstance(grip, (Marker, VLine)) # `_grip_shapes`
|
|
1042
|
+
pointer["grips"][grip.id] = {
|
|
1043
|
+
"group": grip_group(grip),
|
|
1044
|
+
"axis": grip_axis(grip),
|
|
1045
|
+
"label": grip_label(grip),
|
|
1046
|
+
}
|
|
1047
|
+
groups = {said["group"] for said in pointer["grips"].values()}
|
|
1048
|
+
for m in markers:
|
|
1049
|
+
if m.kind == "handle" and grip_group(m) in groups:
|
|
1050
|
+
pointer["targets"][m.id] = grip_group(m)
|
|
1051
|
+
pointer["draggable"] += [s["name"] for s in leaf_rings] + lines
|
|
1052
|
+
for trace in leaf_data:
|
|
1053
|
+
trace["meta"] = {**trace.get("meta", {}), "leaf": leaf.path}
|
|
1054
|
+
if visibility is not None:
|
|
1055
|
+
for trace in leaf_data:
|
|
1056
|
+
trace["showlegend"] = False
|
|
1057
|
+
if leaf.path in visibility.hidden:
|
|
1058
|
+
# `visible: false`, never removed: a shape's INDEX is what a
|
|
1059
|
+
# drag's relayout names, and the hook resolves it against
|
|
1060
|
+
# the full list (the 2026-09-24 spike, fact 2-3).
|
|
1061
|
+
for item in (*leaf_data, *leaf_shapes, *leaf_rings):
|
|
1062
|
+
item["visible"] = False
|
|
1063
|
+
data.extend(leaf_data)
|
|
1064
|
+
shapes.extend(leaf_shapes)
|
|
1065
|
+
rings.extend(leaf_rings)
|
|
1066
|
+
return data, shapes, rings, pointer
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def _config(editable: bool) -> dict:
|
|
1070
|
+
"""The figure's ``config``, the same for both builders. The tool's plots
|
|
1071
|
+
are stacked full-width down the Test page, where ``scrollZoom`` costs the
|
|
1072
|
+
page its wheel: one notch puts a plot under the cursor and the page never
|
|
1073
|
+
scrolls again, so the cards below are never drawn (measured in Chromium).
|
|
1074
|
+
The report draws one figure per page and keeps it. And the tool's show
|
|
1075
|
+
no tip: plotly.js says "Double-click to zoom back out" in a box fixed to
|
|
1076
|
+
the window's top right after a zoom, which on the Test page is the
|
|
1077
|
+
sticky strip's buttons; the double-click itself stays. It does not
|
|
1078
|
+
depend on ``rings_for`` (decision 7): opening a criterion stays a
|
|
1079
|
+
`Plotly.react`."""
|
|
1080
|
+
config: dict = dict(PLOT_CONFIG)
|
|
1081
|
+
if editable:
|
|
1082
|
+
config["edits"] = {"shapePosition": True}
|
|
1083
|
+
config["scrollZoom"] = False
|
|
1084
|
+
config["showTips"] = False
|
|
1085
|
+
return config
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def variable_figure(
|
|
1089
|
+
view: VariableView,
|
|
1090
|
+
max_samples: int | None = None,
|
|
1091
|
+
*,
|
|
1092
|
+
editable: bool = False,
|
|
1093
|
+
rings_for: Collection[str] | None = None,
|
|
1094
|
+
visibility: Visibility | None = None,
|
|
1095
|
+
theme: str = "default",
|
|
1096
|
+
) -> dict:
|
|
1097
|
+
"""The one figure both tiers draw. ``max_samples`` decimates the view first.
|
|
1098
|
+
|
|
1099
|
+
``theme``: the bundled theme the figure is drawn in (:mod:`.palette`) —
|
|
1100
|
+
the project's for a report page, the tab's in the tool. The default
|
|
1101
|
+
theme's figure carries no template.
|
|
1102
|
+
|
|
1103
|
+
``editable`` adds a draggable ring per handle marker, an arrow per grip
|
|
1104
|
+
and ``config.edits.shapePosition``; the report's figure never passes it.
|
|
1105
|
+
``rings_for``: the leaf paths whose handles get a draggable ring, and
|
|
1106
|
+
whose grips their arrows; ``None`` rings every leaf. The Test page names the open criterion, so two leaves'
|
|
1107
|
+
rings never overlap; ``config`` does not depend on it. A path is matched
|
|
1108
|
+
whole, and a bare ``str`` is a ``TypeError`` — its membership test is by
|
|
1109
|
+
substring, which would ring leaves nobody named.
|
|
1110
|
+
|
|
1111
|
+
``visibility`` is what the tool's page chose: with one, only signals are
|
|
1112
|
+
in the legend, a hidden leaf's traces and shapes are ``visible: False``
|
|
1113
|
+
(kept in place, so shape indices do not move), and only the chosen error
|
|
1114
|
+
series shows, with its axis. Without one, the figure is the report's.
|
|
1115
|
+
"""
|
|
1116
|
+
if max_samples:
|
|
1117
|
+
view = decimate_view(view, max_samples)
|
|
1118
|
+
palette = palette_of(theme)
|
|
1119
|
+
x_min, x_max = _time_span(view)
|
|
1120
|
+
ctx = _Ctx(
|
|
1121
|
+
x_min=x_min,
|
|
1122
|
+
x_max=x_max,
|
|
1123
|
+
has_spectrum=view.has_spectrum,
|
|
1124
|
+
full_samples=view.full_samples,
|
|
1125
|
+
palette=palette,
|
|
1126
|
+
spectrum_range=_set_spectrum_range(view),
|
|
1127
|
+
)
|
|
1128
|
+
data: list[dict] = _base_traces(view, palette)
|
|
1129
|
+
leaf_data, shapes, rings, pointer = _leaf_traces(
|
|
1130
|
+
view, ctx, editable=editable, rings_for=rings_for, visibility=visibility
|
|
1131
|
+
)
|
|
1132
|
+
data.extend(leaf_data)
|
|
1133
|
+
for s in view.errors:
|
|
1134
|
+
data.append(_error_trace(s, ctx, visibility))
|
|
1135
|
+
config: dict = _config(editable)
|
|
1136
|
+
layout = _layout(view, shapes + rings, bool(view.errors), palette)
|
|
1137
|
+
if editable:
|
|
1138
|
+
layout["meta"] = {POINTER: pointer}
|
|
1139
|
+
if visibility is not None and view.errors:
|
|
1140
|
+
shown = ERROR_AXES.get(visibility.error or "")
|
|
1141
|
+
for axis in ("y3", "y4"):
|
|
1142
|
+
key = axis.replace("y", "yaxis") # "y3" -> "yaxis3"
|
|
1143
|
+
layout[key]["visible"] = axis == shown
|
|
1144
|
+
# A hidden axis left at automargin kept the right margin it had
|
|
1145
|
+
# grown while shown, through every later react (Chromium: 55 px
|
|
1146
|
+
# after Abs error, 103 after NRMSE, against 20). The report keeps
|
|
1147
|
+
# both at True: its axes.js flips `visible` in the page.
|
|
1148
|
+
layout[key]["automargin"] = axis == shown
|
|
1149
|
+
# A box zoom made while the axis hides scales it too (it
|
|
1150
|
+
# overlays `y`), and the figure's `uirevision: "keep"` kept
|
|
1151
|
+
# that range: Abs error then drew as a flat line on the stale
|
|
1152
|
+
# [-0.49, 3.51] (the TRANSFORM gate, 2026-09-25). Keyed by the
|
|
1153
|
+
# chosen series, a new choice autoranges the axis, and a zoom
|
|
1154
|
+
# made while it shows is kept through a redraw.
|
|
1155
|
+
layout[key]["uirevision"] = visibility.error or "none"
|
|
1156
|
+
# One error axis at a time here, so NRMSE's sits on the plot's edge
|
|
1157
|
+
# like the other's: left free + autoshift, it floated off the plot
|
|
1158
|
+
# once Abs error had shown.
|
|
1159
|
+
layout["yaxis4"]["anchor"] = "x"
|
|
1160
|
+
del layout["yaxis4"]["autoshift"]
|
|
1161
|
+
return {"data": data, "layout": layout, "config": config}
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def _context_trace(view: VariableView, palette: Palette) -> dict | None:
|
|
1165
|
+
"""One context series: grey — the theme's ``text-muted`` — and thinner,
|
|
1166
|
+
on the subject's axes, with no reference and no criteria geometry —
|
|
1167
|
+
drawn beside the plot's subject, never scored there. ``None`` for a
|
|
1168
|
+
series the result lacks."""
|
|
1169
|
+
if not _draws_actual(view):
|
|
1170
|
+
return None
|
|
1171
|
+
assert view.act_time is not None and view.act_values is not None
|
|
1172
|
+
return with_hover(
|
|
1173
|
+
{
|
|
1174
|
+
"type": "scatter",
|
|
1175
|
+
"mode": "lines",
|
|
1176
|
+
"name": f"{as_text(view.label)} · context",
|
|
1177
|
+
"xaxis": "x",
|
|
1178
|
+
"yaxis": "y",
|
|
1179
|
+
"x": view.act_time.tolist(),
|
|
1180
|
+
"y": view.act_values.tolist(),
|
|
1181
|
+
"line": {"color": palette.context, "width": 1.0},
|
|
1182
|
+
"meta": {
|
|
1183
|
+
"id": f"context/{view.name}",
|
|
1184
|
+
"kind": "context",
|
|
1185
|
+
"samples": len(view.act_time),
|
|
1186
|
+
"full_samples": view.full_samples.get("act", len(view.act_time)),
|
|
1187
|
+
},
|
|
1188
|
+
},
|
|
1189
|
+
view.label,
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def plot_figure(
|
|
1194
|
+
views: Sequence[VariableView],
|
|
1195
|
+
max_samples: int | None = None,
|
|
1196
|
+
*,
|
|
1197
|
+
context: Sequence[VariableView] = (),
|
|
1198
|
+
editable: bool = False,
|
|
1199
|
+
labels: Mapping[str, str] | None = None,
|
|
1200
|
+
rings_for: Collection[str] | None = None,
|
|
1201
|
+
visibility: Visibility | None = None,
|
|
1202
|
+
theme: str = "default",
|
|
1203
|
+
) -> dict:
|
|
1204
|
+
"""The figure for one plot of a test: ``views`` are its SUBJECT (one
|
|
1205
|
+
variable, or the members of one array family), ``context`` the grey
|
|
1206
|
+
series drawn beside it on the same y axis (:func:`_context_trace`).
|
|
1207
|
+
Context changes nothing the subject draws — a one-variable subject
|
|
1208
|
+
keeps its overlays and error traces — and brings no leaves: a context
|
|
1209
|
+
variable's criteria are on its own plot. ``theme`` as in
|
|
1210
|
+
:func:`variable_figure`."""
|
|
1211
|
+
figure = _subject_figure(
|
|
1212
|
+
views,
|
|
1213
|
+
max_samples,
|
|
1214
|
+
editable=editable,
|
|
1215
|
+
labels=labels,
|
|
1216
|
+
rings_for=rings_for,
|
|
1217
|
+
visibility=visibility,
|
|
1218
|
+
theme=theme,
|
|
1219
|
+
)
|
|
1220
|
+
palette = palette_of(theme)
|
|
1221
|
+
for view in context:
|
|
1222
|
+
if max_samples:
|
|
1223
|
+
view = decimate_view(view, max_samples)
|
|
1224
|
+
trace = _context_trace(view, palette)
|
|
1225
|
+
if trace is not None:
|
|
1226
|
+
figure["data"].append(trace)
|
|
1227
|
+
return figure
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def _subject_figure(
|
|
1231
|
+
views: Sequence[VariableView],
|
|
1232
|
+
max_samples: int | None = None,
|
|
1233
|
+
*,
|
|
1234
|
+
editable: bool = False,
|
|
1235
|
+
labels: Mapping[str, str] | None = None,
|
|
1236
|
+
rings_for: Collection[str] | None = None,
|
|
1237
|
+
visibility: Visibility | None = None,
|
|
1238
|
+
theme: str = "default",
|
|
1239
|
+
) -> dict:
|
|
1240
|
+
"""The figure of a plot's subject (DESIGN.md "Plots are modular").
|
|
1241
|
+
|
|
1242
|
+
One view is :func:`variable_figure`, unchanged. Several draw every
|
|
1243
|
+
series in its family's hue (the theme's ramps) — actual solid, reference
|
|
1244
|
+
dashed in the same colour and out of the legend, a family folded to one
|
|
1245
|
+
legend entry — with the geometry of every leaf of every series, and NO
|
|
1246
|
+
overlays or error traces: both are per-variable pickers keyed by trace
|
|
1247
|
+
name. ``labels`` overrides a family's legend text by family key.
|
|
1248
|
+
``rings_for`` and ``visibility`` are :func:`variable_figure`'s, reaching
|
|
1249
|
+
every series' leaves; a plot of several has no error traces, so
|
|
1250
|
+
``visibility.error`` has nothing to act on there.
|
|
1251
|
+
"""
|
|
1252
|
+
if len(views) == 1:
|
|
1253
|
+
return variable_figure(
|
|
1254
|
+
views[0],
|
|
1255
|
+
max_samples,
|
|
1256
|
+
editable=editable,
|
|
1257
|
+
rings_for=rings_for,
|
|
1258
|
+
visibility=visibility,
|
|
1259
|
+
theme=theme,
|
|
1260
|
+
)
|
|
1261
|
+
palette = palette_of(theme)
|
|
1262
|
+
if max_samples:
|
|
1263
|
+
views = [decimate_view(v, max_samples) for v in views]
|
|
1264
|
+
by_name = {v.name: v for v in views}
|
|
1265
|
+
texts = series_texts(views)
|
|
1266
|
+
spans = [
|
|
1267
|
+
_time_span(v) for v in views if v.act_time is not None or v.ref_time is not None
|
|
1268
|
+
]
|
|
1269
|
+
x_min = min((s[0] for s in spans), default=0.0)
|
|
1270
|
+
x_max = max((s[1] for s in spans), default=1.0)
|
|
1271
|
+
data: list[dict] = []
|
|
1272
|
+
shapes: list[dict] = []
|
|
1273
|
+
rings: list[dict] = []
|
|
1274
|
+
pointer = _no_pointer()
|
|
1275
|
+
has_spectrum = any(v.has_spectrum for v in views)
|
|
1276
|
+
# Families fold over what each series READS as: `unitTests.x[1..3]` is a
|
|
1277
|
+
# position in a component, not an array — three unrelated expressions
|
|
1278
|
+
# under one legend entry and one colour ramp said they were one
|
|
1279
|
+
# quantity. Two series sharing one expression read as `label (name)`
|
|
1280
|
+
# (:func:`series_texts`), which belongs to no family either, so they
|
|
1281
|
+
# stay two legend entries and two hues.
|
|
1282
|
+
for h, (key, members) in enumerate(group_families(list(by_name), texts)):
|
|
1283
|
+
colors = ramp(palette.hues[h % len(palette.hues)], len(members))
|
|
1284
|
+
label = (labels or {}).get(key) or family_label(key, members, texts)
|
|
1285
|
+
# The legend entry rides on the first member that DRAWS: a family whose
|
|
1286
|
+
# lowest index has no result would otherwise have none.
|
|
1287
|
+
lead = next((n for n in members if _draws_actual(by_name[n])), None)
|
|
1288
|
+
for name, color in zip(members, colors):
|
|
1289
|
+
data.extend(
|
|
1290
|
+
_series_traces(
|
|
1291
|
+
by_name[name],
|
|
1292
|
+
color,
|
|
1293
|
+
key,
|
|
1294
|
+
label,
|
|
1295
|
+
texts[name],
|
|
1296
|
+
first=name == lead,
|
|
1297
|
+
)
|
|
1298
|
+
)
|
|
1299
|
+
# One view stands in for the layout, carrying EVERY view's leaves, so
|
|
1300
|
+
# `_layout` sees the spectrum panel wherever it comes from.
|
|
1301
|
+
if views:
|
|
1302
|
+
all_leaves = tuple(leaf for v in views for leaf in v.leaves)
|
|
1303
|
+
layout_view = replace(views[0], leaves=all_leaves)
|
|
1304
|
+
else:
|
|
1305
|
+
layout_view = VariableView(name="")
|
|
1306
|
+
spectrum_range = _set_spectrum_range(layout_view)
|
|
1307
|
+
for view in views:
|
|
1308
|
+
ctx = _Ctx(
|
|
1309
|
+
x_min=x_min,
|
|
1310
|
+
x_max=x_max,
|
|
1311
|
+
has_spectrum=has_spectrum,
|
|
1312
|
+
full_samples=view.full_samples,
|
|
1313
|
+
palette=palette,
|
|
1314
|
+
spectrum_range=spectrum_range,
|
|
1315
|
+
)
|
|
1316
|
+
leaf_data, leaf_shapes, leaf_rings, leaf_pointer = _leaf_traces(
|
|
1317
|
+
view, ctx, editable=editable, rings_for=rings_for, visibility=visibility
|
|
1318
|
+
)
|
|
1319
|
+
data.extend(leaf_data)
|
|
1320
|
+
shapes.extend(leaf_shapes)
|
|
1321
|
+
rings.extend(leaf_rings)
|
|
1322
|
+
pointer["grips"].update(leaf_pointer["grips"])
|
|
1323
|
+
pointer["targets"].update(leaf_pointer["targets"])
|
|
1324
|
+
pointer["draggable"] += leaf_pointer["draggable"]
|
|
1325
|
+
config: dict = _config(editable)
|
|
1326
|
+
layout = _layout(layout_view, shapes + rings, False, palette)
|
|
1327
|
+
if editable:
|
|
1328
|
+
layout["meta"] = {POINTER: pointer}
|
|
1329
|
+
return {"data": data, "layout": layout, "config": config}
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
def _draws_actual(view: VariableView) -> bool:
|
|
1333
|
+
return view.act_time is not None and view.act_values is not None
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
def series_texts(views: Sequence[VariableView]) -> dict[str, str]:
|
|
1337
|
+
"""``{name: what that series reads as}`` on a plot drawing several.
|
|
1338
|
+
|
|
1339
|
+
Each view's own ``label``, which is already what its TEST's page reads
|
|
1340
|
+
for it (:func:`~dstf.backends.types.variable_texts`, applied where the
|
|
1341
|
+
views are built). Disambiguating HERE, over the plot's series alone,
|
|
1342
|
+
was a scope of its own: a variable read ``a.Q`` in this legend and
|
|
1343
|
+
``a.Q (unitTests.x[1])`` in its criteria row on the same page.
|
|
1344
|
+
"""
|
|
1345
|
+
return {v.name: v.label for v in views}
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
def _series_traces(
|
|
1349
|
+
view: VariableView,
|
|
1350
|
+
color: str,
|
|
1351
|
+
group: str,
|
|
1352
|
+
label: str,
|
|
1353
|
+
text: str,
|
|
1354
|
+
*,
|
|
1355
|
+
first: bool,
|
|
1356
|
+
) -> list[dict]:
|
|
1357
|
+
traces = []
|
|
1358
|
+
# Read as `text` — the label, with the name beside it when another
|
|
1359
|
+
# variable of this TEST carries the same label — and keyed by the name:
|
|
1360
|
+
# `meta.id` is what the page, the picker and every test address a trace
|
|
1361
|
+
# by.
|
|
1362
|
+
#
|
|
1363
|
+
# Spelled out, not `_draws_actual(view)`: mypy narrows the arrays here.
|
|
1364
|
+
if view.act_time is not None and view.act_values is not None:
|
|
1365
|
+
traces.append(
|
|
1366
|
+
with_hover(
|
|
1367
|
+
{
|
|
1368
|
+
"type": "scatter",
|
|
1369
|
+
"mode": "lines",
|
|
1370
|
+
"name": as_text(label) if first else as_text(text),
|
|
1371
|
+
"legendgroup": group,
|
|
1372
|
+
"showlegend": first,
|
|
1373
|
+
"x": view.act_time.tolist(),
|
|
1374
|
+
"y": view.act_values.tolist(),
|
|
1375
|
+
"line": {"color": color, "width": 1.5},
|
|
1376
|
+
"meta": {
|
|
1377
|
+
"id": f"actual/{view.name}",
|
|
1378
|
+
"kind": "act",
|
|
1379
|
+
"samples": len(view.act_time),
|
|
1380
|
+
"full_samples": view.full_samples.get(
|
|
1381
|
+
"act", len(view.act_time)
|
|
1382
|
+
),
|
|
1383
|
+
},
|
|
1384
|
+
},
|
|
1385
|
+
text,
|
|
1386
|
+
)
|
|
1387
|
+
)
|
|
1388
|
+
if view.ref_time is not None and view.ref_values is not None:
|
|
1389
|
+
traces.append(
|
|
1390
|
+
with_hover(
|
|
1391
|
+
{
|
|
1392
|
+
"type": "scatter",
|
|
1393
|
+
"mode": "lines",
|
|
1394
|
+
"name": f"{as_text(text)} (reference)",
|
|
1395
|
+
"legendgroup": group,
|
|
1396
|
+
"showlegend": False,
|
|
1397
|
+
"x": view.ref_time.tolist(),
|
|
1398
|
+
"y": view.ref_values.tolist(),
|
|
1399
|
+
"line": {"color": color, "width": 1.2, "dash": "dash"},
|
|
1400
|
+
"opacity": 0.7,
|
|
1401
|
+
"meta": {
|
|
1402
|
+
"id": f"reference/{view.name}",
|
|
1403
|
+
"kind": "ref",
|
|
1404
|
+
"samples": len(view.ref_time),
|
|
1405
|
+
"full_samples": view.full_samples.get(
|
|
1406
|
+
"ref", len(view.ref_time)
|
|
1407
|
+
),
|
|
1408
|
+
},
|
|
1409
|
+
},
|
|
1410
|
+
f"{text} (reference)",
|
|
1411
|
+
)
|
|
1412
|
+
)
|
|
1413
|
+
return traces
|