dynamic-systems-testing-framework 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
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