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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1354 @@
1
+ """Edit on plot (§4.6 table, §4.7): the CRITERION placement — the open
2
+ criterion's handles, and the grips on each side of its tolerances, drag on
3
+ the plot its row sits under, and the op targets that leaf; a handle of any
4
+ other leaf is refused by name. In the editor, a
5
+ table per handle-bearing list field of that leaf (the Config fields that
6
+ declare ``columns``: ``tube_points``, ``points``, ``events``, ``peaks``)
7
+ with add and remove rows.
8
+
9
+ Every staged edit is one ``replace`` of the whole leaf — the op Edit
10
+ criteria stages — so consecutive drags on one leaf collapse into one
11
+ pending op, and a drag and a form edit of the same leaf read as "leaf ->
12
+ new leaf" in the drawer.
13
+
14
+ A plot takes three more gestures for the open criterion (D149), each a pure
15
+ function here over the Session's view and each ending in the same one
16
+ ``replace``: a HELD handle previews the leaf its release would stage
17
+ (:func:`dragged` — the release's own function, so the fields show what will
18
+ be written); a Ctrl+click adds a row that settles on the data
19
+ (:func:`clicked_row`); and a click with a field armed sets that field
20
+ (:func:`placed`) — through the handle that sets it, where there is one, so
21
+ a click is refused and clamped as that handle's drag is. A ⌖ is drawn only
22
+ where :func:`placeable` says a click can set its field: one per table row,
23
+ and on a form field in the state the criterion is in.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import copy
29
+ import dataclasses
30
+ import math
31
+ from collections.abc import Callable, Mapping
32
+ from dataclasses import dataclass
33
+ from functools import partial
34
+ from typing import TYPE_CHECKING, Any
35
+
36
+ from ...comparison.geometry import HLine, Marker, VLine, Window, grip_axis
37
+ from ...comparison.handles import (
38
+ HandleEdit,
39
+ HandleEditError,
40
+ RowPlace,
41
+ _written,
42
+ handle_fields,
43
+ )
44
+ from ...comparison.mode_schema import SIDE_LABELS
45
+ from ...comparison.registry import DEFAULT_METRIC, MetricDescriptor, metric
46
+ from ...comparison.sides import parse_sides
47
+ from ...comparison.snap import snapped
48
+ from ...figures.assemble import leaves_by_variable
49
+ from ..capabilities import Panel, Patch, Placement
50
+ from ..session import no_tree_error
51
+ from ..spec_document import PatchOp
52
+ from ..tree_pointer import node_at
53
+ from ..unscorable import Refused, refuse_bounds, refuse_negative
54
+
55
+ if TYPE_CHECKING:
56
+ from ...figures.view import LeafView, VariableView
57
+ from ..session import SessionView
58
+
59
+ # NO module-level `nicegui` import: `import dstf.authoring` runs
60
+ # register_builtins(), and the Session must import without the extra.
61
+
62
+ #: A list field's ``columns`` metadata: per row key its kind — ``"float"``,
63
+ #: ``"float?"``, ``"sides"``, ``"sides?"`` or a tuple of choices — and, for a
64
+ #: ``sides`` kind, optionally what its two sides are called. Read by INDEX:
65
+ #: an entry is two or three long.
66
+ Columns = tuple[tuple[Any, ...], ...]
67
+
68
+
69
+ #: What a ⌖ names (D149): a field of the open leaf by its keys from the
70
+ #: raw leaf down to the value — ``("min_value",)``, ``("window", "start")``,
71
+ #: one side of a tolerance ending in ``"lower"`` or ``"upper"`` whether the
72
+ #: file holds a number or a pair (`handles.handle_fields` names a handle's
73
+ #: fields the same way) — or a ROW of a table, ``("points", 3)``.
74
+ Pointer = tuple[Any, ...]
75
+
76
+ #: The tables whose rows have no ring: the column a click sets directly.
77
+ _RINGLESS = {"events": "time"}
78
+
79
+ #: A bound and the other one: a BLANK bound is placed where the leaf draws
80
+ #: its other.
81
+ _BOUNDS = {"min_value": "max_value", "max_value": "min_value"}
82
+
83
+ #: Why the place is a sentence on a test with nothing scored BECAUSE OF A
84
+ #: RUN (D151), ``{}`` being the word its criteria say in place of a verdict
85
+ #: (`live_rows.WAITING`, `NOT_SCORED`): a handle is drawn from a scored
86
+ #: criterion's geometry, and the table is edited beside its handles.
87
+ UNSCORED = "{} — its table and its handles on the plot are edited once it is scored"
88
+
89
+
90
+ def _selected(s: SessionView) -> tuple[str, str, str] | None:
91
+ """``(model_id, variable, leaf_path)`` for exactly one test, a variable
92
+ and the open criterion."""
93
+ sel = s.selection
94
+ if len(sel.tests) != 1 or sel.variable is None or sel.leaf_path is None:
95
+ return None
96
+ (model_id,) = tuple(sel.tests)
97
+ return model_id, sel.variable, sel.leaf_path
98
+
99
+
100
+ def _leaf_paths(s: SessionView, model_id: str, variable: str) -> list[tuple[str, str]]:
101
+ """``(leaf_path, metric)`` for every scored leaf on ``variable``, in tree
102
+ order. Cheap: no figure, no view."""
103
+ comp = s.comparisons.get(model_id)
104
+ tree = comp.metric_tree if comp is not None else None
105
+ return [(path, vc.mode) for path, vc in leaves_by_variable(tree).get(variable, [])]
106
+
107
+
108
+ def _list_fields(name: str) -> list[tuple[str, Columns]]:
109
+ """The Config list fields of metric ``name`` that declare their row
110
+ ``columns`` (Task 6's metadata), read from the dataclass fields — not
111
+ through ``derive_schema``, whose ``FieldSpec`` does not carry them."""
112
+ desc = metric(name)
113
+ if desc is None or not dataclasses.is_dataclass(desc.config_cls):
114
+ return []
115
+ return [
116
+ (f.name, f.metadata["columns"])
117
+ for f in dataclasses.fields(desc.config_cls)
118
+ if "columns" in f.metadata
119
+ ]
120
+
121
+
122
+ def _field_label(name: str, field: str) -> str:
123
+ """One table's heading: the Config field's own label, else its name. It
124
+ said `{path} · {metric} · {field}` — every word of which the criterion's
125
+ row says directly above the editor (the Task 6 screenshot)."""
126
+ desc = metric(name)
127
+ if desc is None or not dataclasses.is_dataclass(desc.config_cls):
128
+ return field
129
+ for f in dataclasses.fields(desc.config_cls):
130
+ if f.name == field:
131
+ return str(f.metadata.get("label") or field)
132
+ return field
133
+
134
+
135
+ def _leaf_at(s: SessionView, model_id: str, path: str) -> dict:
136
+ """The raw leaf at ``path`` of ``model_id``'s tree, or a ValueError
137
+ naming what ``path`` or ``model_id`` is instead — the Session's refusal
138
+ (`no_tree_error`) where it has no tree."""
139
+ try:
140
+ tree, why = s.tree_or_reason(model_id)
141
+ except KeyError:
142
+ raise ValueError(f"{model_id} is not a test of this project") from None
143
+ if tree is None:
144
+ assert why is not None # `tree` is None
145
+ raise no_tree_error(model_id, why)
146
+ tokens = [t for t in path.split("/") if t]
147
+ node: Any = None
148
+ if tokens[:1] == ["metrics"]:
149
+ try:
150
+ node = node_at(tree, path)
151
+ except (KeyError, IndexError, TypeError, ValueError):
152
+ node = None
153
+ if not isinstance(node, dict):
154
+ raise ValueError(f"{path} names no leaf of {model_id}")
155
+ if "combinator" in node:
156
+ raise ValueError(f"{path} is a combinator, not a leaf")
157
+ return node
158
+
159
+
160
+ def _has_handles(name: str) -> bool:
161
+ desc = metric(name)
162
+ return desc is not None and (
163
+ desc.edit_handle is not None or bool(_list_fields(name))
164
+ )
165
+
166
+
167
+ def _leaf_path_of(handle: str, paths: list[str]) -> str | None:
168
+ """The longest leaf path ``handle`` starts with FOLLOWED BY ``/``: a
169
+ handle of ``/metrics/children/10`` never resolves to
170
+ ``/metrics/children/1``."""
171
+ matches = [p for p in paths if handle.startswith(p + "/")]
172
+ return max(matches, key=len) if matches else None
173
+
174
+
175
+ def _draggable(primitive: object) -> bool:
176
+ """Whether a drag of ``primitive`` is an edit: a handle's ring or line,
177
+ or a grip's arrow. By TYPE as well as by kind: a box (`points/box/i`,
178
+ `peaks/window/i`) is `kind="handle"` too, and no editor moves one."""
179
+ if isinstance(primitive, (Marker, HLine)) and primitive.kind == "handle":
180
+ return True
181
+ return isinstance(primitive, (Marker, VLine)) and primitive.kind == "grip"
182
+
183
+
184
+ def _grip_position(
185
+ grip: Marker | VLine, shape: dict
186
+ ) -> tuple[float | None, float | None] | None:
187
+ """Where a dragged grip was released: its OWN axis from the payload,
188
+ the other from the grip itself. A release carries both anchors, and the
189
+ one off the grip's axis is not where the pointer went — Plotly re-snaps
190
+ it, and past 8 px of sideways drift moves it in full (Chromium,
191
+ 2026-10-02); an event's or a peak's arrow is anchored to the paper in y,
192
+ which a diagonal drag moves too. None when its own anchor is absent."""
193
+ x = grip.time if isinstance(grip, VLine) else grip.x
194
+ y = None if isinstance(grip, VLine) else grip.y
195
+ if grip_axis(grip) == "x":
196
+ moved = shape.get("xanchor")
197
+ return None if moved is None else (moved, y)
198
+ moved = shape.get("yanchor")
199
+ return None if moved is None else (x, moved)
200
+
201
+
202
+ def _position(
203
+ primitive: Marker | HLine | VLine, shape: dict
204
+ ) -> tuple[float | None, float | None] | None:
205
+ """The dragged position the hook's per-shape payload means for the
206
+ handle's PRIMITIVE TYPE, or None when it is no move. A grip moves on
207
+ one axis (:func:`_grip_position`). A Marker's ring
208
+ moves only by ``xanchor``/``yanchor`` — the ``x0``..``y1`` of a
209
+ pixel-sized shape are offsets about its anchor, never a position (a
210
+ ring is a pixel ``path``, which sends none). An HLine moves by
211
+ ``y0``/``y1``: both when the whole line was dragged, the one present
212
+ when one end was, neither (a horizontal-only drag) is no move. Any
213
+ other primitive never gets here: `_act_handle` snaps it back."""
214
+ if primitive.kind == "grip":
215
+ assert isinstance(primitive, (Marker, VLine)) # `_draggable`
216
+ return _grip_position(primitive, shape)
217
+ if isinstance(primitive, Marker):
218
+ x, y = shape.get("xanchor"), shape.get("yanchor")
219
+ if x is None and y is None:
220
+ return None
221
+ return x, y
222
+ y0, y1 = shape.get("y0"), shape.get("y1")
223
+ if y0 is None and y1 is None:
224
+ return None
225
+ return None, (y0 if y0 is not None else y1)
226
+
227
+
228
+ def _run_span(view: VariableView, leaf: LeafView) -> tuple[float, float] | None:
229
+ """The ACTUAL series' first and last time as ``leaf``'s scorer received
230
+ it: the evaluator slices the run to the leaf's window (inclusive) first
231
+ and says so in the leaf's geometry (its ``Window``), so the span is the
232
+ samples inside that. None with no actual series, or none inside."""
233
+ time = view.act_time
234
+ if time is None:
235
+ return None
236
+ window = next((p for p in leaf.geometry if isinstance(p, Window)), None)
237
+ if window is not None and window.start is not None:
238
+ time = time[time >= window.start]
239
+ if window is not None and window.end is not None:
240
+ time = time[time <= window.end]
241
+ if len(time) == 0:
242
+ return None
243
+ return float(time[0]), float(time[-1])
244
+
245
+
246
+ def _is_number(value: object) -> bool:
247
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
248
+
249
+
250
+ def _new_time(rows: list[dict], span: tuple[float, float] | None) -> float:
251
+ """Where ``Add row`` puts a new row's ``time``: never on an existing one
252
+ (two tube points sharing a time are refused on the plot, and the later
253
+ one wins the interpolation). The midpoint of the LARGEST gap — the first
254
+ such — among the span's start, the rows' numeric times and the span's
255
+ end, sorted; with no reference, among the rows' times alone; with no
256
+ reference and fewer than two timed rows, the last row's time ``+ 1.0``
257
+ (``0.0`` on a table with none)."""
258
+ times = [float(r["time"]) for r in rows if _is_number(r.get("time"))]
259
+ if span is not None:
260
+ marks = sorted([span[0], *times, span[1]])
261
+ elif len(times) >= 2:
262
+ marks = sorted(times)
263
+ else:
264
+ return times[-1] + 1.0 if times else 0.0
265
+ gaps = [(b - a, (a + b) / 2) for a, b in zip(marks, marks[1:])]
266
+ width = max(gap for gap, _ in gaps)
267
+ return next(mid for gap, mid in gaps if gap == width)
268
+
269
+
270
+ #: A tube's constant width, by the width mode that reads it: what a tube
271
+ #: with no control point is scored by. ``abs`` has none — its rows are
272
+ #: literal bounds, and it cannot be scored without one.
273
+ _CONSTANT_WIDTH = {"band": "tube_abs", "rel": "tube_rel"}
274
+
275
+
276
+ def _first_tube_widths(leaf: Mapping | None) -> dict[str, float]:
277
+ """``upper`` and ``lower`` of a tube's FIRST control point: the width
278
+ in effect at that moment — the constant its width mode reads, a number
279
+ or a lower/upper pair, in the unit that mode reads a row's widths in —
280
+ so that adding the point alone changes no verdict. ``0.0`` each where
281
+ there is none to take: no leaf, the ``abs`` mode, a constant that is
282
+ absent or cannot be read (the reader refuses that one, not this)."""
283
+ key = _CONSTANT_WIDTH.get((leaf or {}).get("tube_width_mode", ""))
284
+ try:
285
+ sides = parse_sides((leaf or {}).get(key, 0.0), key) if key else None
286
+ except ValueError:
287
+ sides = None
288
+ if sides is None:
289
+ return {"upper": 0.0, "lower": 0.0}
290
+ return {"upper": sides.upper, "lower": sides.lower}
291
+
292
+
293
+ def _new_row(
294
+ columns: Columns,
295
+ rows: list[dict],
296
+ span: tuple[float, float] | None,
297
+ leaf: Mapping | None = None,
298
+ ) -> dict:
299
+ """The row ``Add row`` appends; its ``time`` (when the table has one) is
300
+ :func:`_new_time`'s. After existing rows: a copy of the last one — a
301
+ pair it holds copied too, never shared. On an empty table: any other
302
+ required float or tolerance ``0.0``; any other optional one blank, so
303
+ omitted (a new checkpoint follows the baseline, and the criterion's
304
+ tolerance); a choice its first. The one exception: a ``tube_points``
305
+ row gets ``upper`` and ``lower`` — optional in the reader, which
306
+ refuses a tube with no width at all — and they are the width ``leaf``,
307
+ the criterion the table is of, has in effect
308
+ (:func:`_first_tube_widths`): written ``0.0``, the first control point
309
+ of a tube of constant width collapsed it, and a passing criterion read
310
+ "0.0% inside" at one click."""
311
+ keys = [column[0] for column in columns]
312
+ if rows:
313
+ last = copy.deepcopy(rows[-1])
314
+ if "time" in keys:
315
+ last["time"] = _new_time(rows, span)
316
+ return last
317
+ row: dict[str, Any] = {}
318
+ for key, kind, *_ in columns:
319
+ if key == "time":
320
+ row[key] = _new_time(rows, span)
321
+ elif isinstance(kind, tuple):
322
+ row[key] = kind[0]
323
+ elif kind in _REQUIRED:
324
+ row[key] = 0.0
325
+ if keys == ["time", "upper", "lower"]: # tube_points
326
+ row.update(_first_tube_widths(leaf))
327
+ return row
328
+
329
+
330
+ #: The column kinds a row cannot leave blank, and those that are a
331
+ #: tolerance with two sides (a number, or a lower/upper pair).
332
+ _REQUIRED = ("float", "sides")
333
+ _SIDES = ("sides", "sides?")
334
+
335
+ #: A table cell reserves no line under it: a validated field holds one for
336
+ #: its message from its first validation on (Quasar: any `error` prop, set
337
+ #: or not), which made a row half again as tall the moment a cell was
338
+ #: typed in. A cell grows that line only while it says "required".
339
+ _NO_BOTTOM = "hide-bottom-space"
340
+
341
+ _OUTSIDE = "/outside"
342
+
343
+
344
+ def _visible(said: object) -> tuple[float | None, float | None]:
345
+ """What the page says its axes SHOWED — ``{"x": span, "y": span}``, at
346
+ the press of a drag or at a click — as `HandleEdit.visible`. An axis it
347
+ does not say (an arrow anchored to the paper has no y; a caller that is
348
+ no page says none) is None, and nothing placed on it is snapped."""
349
+ spans = said if isinstance(said, dict) else {}
350
+
351
+ def span(value: object) -> float | None:
352
+ return float(value) if _is_number(value) and value > 0 else None # type: ignore[arg-type,operator]
353
+
354
+ return span(spans.get("x")), span(spans.get("y"))
355
+
356
+
357
+ def _click(said: object) -> tuple[str, float, float, tuple[float | None, float | None]]:
358
+ """A click as the page reports it (`grips.js`): the panel it is on, its
359
+ position in that panel's axes, and what those axes showed. The spectrum
360
+ is the one panel on a second x axis (`figures.plotly._xref`)."""
361
+ click = said if isinstance(said, dict) else {}
362
+ x, y = click.get("x"), click.get("y")
363
+ if (
364
+ not isinstance(x, (int, float))
365
+ or not isinstance(y, (int, float))
366
+ or not (math.isfinite(x) and math.isfinite(y))
367
+ ):
368
+ raise ValueError(f"a click at ({x!r}, {y!r}) names no place on the plot")
369
+
370
+ def span(ends: object) -> float | None:
371
+ if (
372
+ isinstance(ends, (list, tuple))
373
+ and len(ends) == 2
374
+ and all(map(_is_number, ends))
375
+ ):
376
+ return abs(ends[1] - ends[0])
377
+ return None
378
+
379
+ panel = "spectrum" if click.get("xaxis") == "x2" else "time"
380
+ visible = _visible({"x": span(click.get("xrange")), "y": span(click.get("yrange"))})
381
+ return panel, float(x), float(y), visible
382
+
383
+
384
+ def _scored(
385
+ s: SessionView, model_id: str, variable: str, path: str, view: VariableView | None
386
+ ) -> tuple[dict, MetricDescriptor | None, VariableView, LeafView | None]:
387
+ """The open leaf as every gesture on the plot reads it: its raw node,
388
+ its metric's descriptor, the variable's view (``view`` when the caller
389
+ holds one: a held drag asks many times of a Session that does not
390
+ move) and the leaf as it was scored — None where it was not."""
391
+ raw = _leaf_at(s, model_id, path)
392
+ desc = metric(raw.get("metric", DEFAULT_METRIC))
393
+ if view is None:
394
+ view = s.view(model_id, variable) # full resolution, not the figure
395
+ leaf = next((lv for lv in view.leaves if lv.path == path), None)
396
+ return raw, desc, view, leaf
397
+
398
+
399
+ def _edit(
400
+ path: str,
401
+ raw: dict,
402
+ view: VariableView,
403
+ leaf: LeafView | None,
404
+ local: str,
405
+ x: float | None,
406
+ y: float | None,
407
+ visible: tuple[float | None, float | None],
408
+ ) -> HandleEdit:
409
+ """One position of the handle ``local`` of the leaf at ``path``, with
410
+ everything an editor measures from."""
411
+ geometry = leaf.geometry if leaf is not None else ()
412
+ # The number a tolerance side is measured from, where the leaf's own
413
+ # geometry holds one: a scalar's baseline scalar.
414
+ target = next(
415
+ (
416
+ p.value
417
+ for p in geometry
418
+ if isinstance(p, HLine) and p.id == f"{path}/scalar/reference"
419
+ ),
420
+ None,
421
+ )
422
+ return HandleEdit(
423
+ leaf=raw,
424
+ local=local,
425
+ x=x,
426
+ y=y,
427
+ reference=_reference(view),
428
+ target=target,
429
+ # What a time may be dragged to where the leaf is scored against
430
+ # the RUN and not the baseline (a checkpoint with a value of its
431
+ # own): the page holds a release to the axis, which runs past the
432
+ # data.
433
+ span=_run_span(view, leaf) if leaf is not None else None,
434
+ visible=visible,
435
+ )
436
+
437
+
438
+ def _reference(view: VariableView) -> tuple[Any, Any] | None:
439
+ if view.ref_time is None or view.ref_values is None:
440
+ return None
441
+ return view.ref_time, view.ref_values
442
+
443
+
444
+ def dragged(
445
+ s: SessionView,
446
+ *,
447
+ model_id: str,
448
+ variable: str,
449
+ handle: str,
450
+ shape: dict,
451
+ view: VariableView | None = None,
452
+ ) -> tuple[str, dict, dict] | None:
453
+ """What a drag of ``handle`` to ``shape`` — the figure hook's per-shape
454
+ payload — MEANS: ``(leaf path, the raw leaf, the leaf the drag
455
+ writes)``, or None for a shape no editor moves and for no move at all.
456
+ It reads and stages nothing, so the release of a handle and the preview
457
+ of one still held are this one function over the same payload (D149):
458
+ the fields show, snapped, what the release will write. Refused as the
459
+ drag is, each refusal naming its leaf."""
460
+ if not handle:
461
+ return None
462
+ handle = _handle_id(handle)
463
+ paths = [p for p, _ in _leaf_paths(s, model_id, variable)]
464
+ path = _leaf_path_of(handle, paths)
465
+ if path is None:
466
+ raise ValueError(f"{handle} names no leaf on {variable} of {model_id}")
467
+ if view is None:
468
+ view = s.view(model_id, variable) # full resolution, not the figure
469
+ leaf = next((lv for lv in view.leaves if lv.path == path), None)
470
+ primitive = next(
471
+ (p for p in (leaf.geometry if leaf else ()) if p.id == handle), None
472
+ )
473
+ # A window, a box, a result line is drawn, not editable. The page
474
+ # takes the pointer off it (the figure's `POINTER`, `grips.js`), so
475
+ # no drag of one should arrive; one that does is answered with
476
+ # nothing, and the figure hook's redraw snaps it back. Resolved
477
+ # before the open-leaf check: whichever criterion is open, such a
478
+ # drag is not an error the user made.
479
+ if not _draggable(primitive):
480
+ return None
481
+ assert isinstance(primitive, (Marker, HLine, VLine)) # `_draggable`
482
+ assert leaf is not None # the primitive is its
483
+ open_leaf = s.selection.leaf_path
484
+ if open_leaf is not None and path != open_leaf:
485
+ # Only the open leaf's rings and grips are drawn
486
+ # (`Session.figure`'s `editing`), and every other leaf's bound
487
+ # LINES, on the plot too, are inert in the page — but Plotly
488
+ # itself would drag them, and a release can cross a redraw
489
+ # (`FigureHook._shape_name`): one that reaches here is not this
490
+ # editor's.
491
+ raise HandleEditError(
492
+ f"{handle} is not a handle of {open_leaf}",
493
+ reason="Open that criterion to drag its lines.",
494
+ )
495
+ tree, why = s.tree_or_reason(model_id)
496
+ if tree is None:
497
+ assert why is not None # `tree` is None
498
+ raise no_tree_error(model_id, why)
499
+ raw = node_at(tree, path)
500
+ name = raw.get("metric", DEFAULT_METRIC)
501
+ desc = metric(name)
502
+ if desc is None or desc.edit_handle is None:
503
+ raise HandleEditError(
504
+ f"{path}: {name} has no on-plot handles",
505
+ reason=f"{name} has no on-plot handles",
506
+ )
507
+ shape = shape if isinstance(shape, dict) else {}
508
+ # Several leaves on one variable can refuse the same local id: every
509
+ # refusal below names its leaf.
510
+ try:
511
+ position = _position(primitive, shape)
512
+ if position is None:
513
+ return None
514
+ x, y = position
515
+ local = handle[len(path) + 1 :]
516
+ visible = _visible(shape.get("visible"))
517
+ new = desc.edit_handle(_edit(path, raw, view, leaf, local, x, y, visible))
518
+ except HandleEditError as e:
519
+ raise HandleEditError(f"{path}: {e}", reason=e.reason) from None
520
+ return path, raw, new
521
+
522
+
523
+ def clicked_row(
524
+ s: SessionView, *, model_id: str, variable: str, path: str, click: dict
525
+ ) -> dict | None:
526
+ """The leaf at ``path`` with the row a Ctrl+click at ``click`` adds to
527
+ its table (D149) — the metric's own placer, which settles the row on
528
+ the data; every field the click does not set is what ``Add row`` gives
529
+ today. None where the metric has no rows to add to, and where the
530
+ placer adds none (a tube of constant width)."""
531
+ raw, desc, view, leaf = _scored(s, model_id, variable, path, None)
532
+ if desc is None or desc.place_row is None:
533
+ return None
534
+ panel, x, y, visible = _click(click)
535
+ reference = _reference(view)
536
+ span = None
537
+ if reference is not None and len(reference[0]):
538
+ span = float(reference[0][0]), float(reference[0][-1])
539
+ # No leaf is handed over: it decides only a tube's FIRST control point,
540
+ # and a click adds none to a tube without one (`place_tube_row`).
541
+ blanks = {
542
+ field: _new_row(columns, copy.deepcopy(raw.get(field) or []), span)
543
+ for field, columns in _list_fields(desc.name)
544
+ }
545
+ actual = None
546
+ if view.act_time is not None and view.act_values is not None:
547
+ actual = view.act_time, view.act_values
548
+ try:
549
+ new = desc.place_row(
550
+ RowPlace(
551
+ leaf=raw,
552
+ blanks=blanks,
553
+ panel=panel,
554
+ x=x,
555
+ y=y,
556
+ reference=reference,
557
+ actual=actual,
558
+ geometry=leaf.geometry if leaf is not None else (),
559
+ visible=visible,
560
+ span=_run_span(view, leaf) if leaf is not None else None,
561
+ )
562
+ )
563
+ except HandleEditError as e:
564
+ raise HandleEditError(f"{path}: {e}", reason=e.reason) from None
565
+ return None if new == raw else new
566
+
567
+
568
+ @dataclass(frozen=True)
569
+ class _Target:
570
+ """How a click sets one field of the open leaf (:func:`_target`): on
571
+ which ``panel``, and either through ``handles`` that write it — each
572
+ where it stands along x (None: nowhere), its local id and the click's
573
+ coordinates it is handed — or ``direct``: the field's own keys and the
574
+ axis of the click read into them."""
575
+
576
+ panel: str
577
+ handles: tuple[tuple[float | None, str, tuple[str, ...]], ...] = ()
578
+ direct: tuple[Pointer, str] | None = None
579
+
580
+
581
+ def _target(
582
+ raw: dict, path: str, leaf: LeafView | None, pointer: Pointer
583
+ ) -> _Target | None:
584
+ """How a click sets ``pointer`` in the criterion's CURRENT state, or
585
+ None where none can — the ONE rule a ⌖ is drawn by (:func:`placeable`)
586
+ and a click acts by (:func:`placed`), so no ⌖ stands on a field a click
587
+ would refuse.
588
+
589
+ A ROW of a table, ``(table, i)``, is placed as its ring let go at the
590
+ click: handed the click's x — its time, a peak's frequency — and the
591
+ click's y only where that ring writes the row's ``value``, which the
592
+ ring's own editor does only for a row that HOLDS one. A declared event
593
+ has no ring: its time is the click's x. A window's ends are the click's
594
+ x. Any other field, where handles of the leaf write it: an arrow is
595
+ handed both coordinates (it writes along its own axis and only
596
+ MEASURES along the other — a constant tube's side from the reference
597
+ at the click's time), a line the one on its axis. And a BLANK bound of
598
+ a leaf that draws its other one — a range, a scalar scored by its
599
+ bounds — is the click's y."""
600
+ if len(pointer) == 2 and pointer[0] == "window" and pointer[1] in ("start", "end"):
601
+ return _Target("time", direct=(pointer, "x"))
602
+ handles = [
603
+ (p, _handle_id(p.id)[len(path) + 1 :])
604
+ for p in (leaf.geometry if leaf is not None else ())
605
+ if _draggable(p) and p.id.startswith(path + "/")
606
+ ]
607
+ if len(pointer) == 2 and type(pointer[1]) is int:
608
+ table, i = pointer
609
+ rows = raw.get(table)
610
+ if not isinstance(rows, list) or not 0 <= i < len(rows):
611
+ return None
612
+ for p, local in handles:
613
+ fields = handle_fields(raw, local)
614
+ at = fields.get("x", ())
615
+ if isinstance(p, Marker) and p.kind == "handle" and at[:2] == pointer:
616
+ valued = fields.get("y") == (*pointer, "value")
617
+ return _Target(
618
+ p.panel, ((None, local, ("x", "y") if valued else ("x",)),)
619
+ )
620
+ if table in _RINGLESS:
621
+ return _Target("time", direct=((table, i, _RINGLESS[table]), "x"))
622
+ return None
623
+ found = [
624
+ (p.panel, (_stands_at(p), local, ("x", "y") if p.kind == "grip" else (axis,)))
625
+ for p, local in handles
626
+ for axis, field in handle_fields(raw, local).items()
627
+ # A criterion's own field: a cell of a row is no place, its row is.
628
+ if field == pointer and all(isinstance(key, str) for key in pointer)
629
+ ]
630
+ if found:
631
+ return _Target(found[0][0], tuple(handle for _panel, handle in found))
632
+ if len(pointer) == 1 and pointer[0] in _BOUNDS and raw.get(pointer[0]) is None:
633
+ other = (_BOUNDS[pointer[0]],)
634
+ if any(other in handle_fields(raw, local).values() for _p, local in handles):
635
+ return _Target("time", direct=(pointer, "y"))
636
+ return None
637
+
638
+
639
+ def placeable(
640
+ s: SessionView, *, model_id: str, variable: str, path: str
641
+ ) -> Callable[[Pointer], bool]:
642
+ """Whether a click on the plot can set a field of the leaf at ``path``
643
+ as it now is (:func:`_target`): what a panel asks before it draws a ⌖.
644
+ The leaf is read on the first question and once."""
645
+ read: list[Any] = []
646
+
647
+ def can(pointer: Pointer) -> bool:
648
+ if not read:
649
+ try:
650
+ read.append(_scored(s, model_id, variable, path, None))
651
+ except (KeyError, ValueError):
652
+ read.append(None) # no such leaf: nothing of it is placed
653
+ if read[0] is None:
654
+ return False
655
+ raw, _desc, _view, leaf = read[0]
656
+ return _target(raw, path, leaf, tuple(pointer)) is not None
657
+
658
+ return can
659
+
660
+
661
+ def _stands_at(primitive: object) -> float | None:
662
+ """Where a handle stands along x, to tell which of several a click is
663
+ nearest; a bound line stands at none."""
664
+ if isinstance(primitive, VLine):
665
+ return primitive.time
666
+ return primitive.x if isinstance(primitive, Marker) else None
667
+
668
+
669
+ def placed(
670
+ s: SessionView,
671
+ *,
672
+ model_id: str,
673
+ variable: str,
674
+ path: str,
675
+ pointer: Pointer,
676
+ click: dict,
677
+ ) -> dict | None:
678
+ """The leaf at ``path`` with the field ``pointer`` set by a click
679
+ (D149: arm a field, then click the plot), or None where it is as it
680
+ was. How is :func:`_target`'s to say; where it says None the click is
681
+ refused — and no ⌖ was drawn. A click IS a drag of the field's handle
682
+ released at the click, so it is snapped, clamped and refused as that
683
+ drag is; of several handles that write the field (a criterion's
684
+ tolerance its rows defer to) the one nearest the click is used, and a
685
+ tolerance side's EDGE lands on the click. A field set directly is
686
+ snapped, and a bound then refused as a typed one is.
687
+
688
+ Every field belongs to ONE panel — the axes its handle, or its
689
+ meaning, lives on — and a click on the other is refused in words that
690
+ name where to click (``click["label"]`` is what the field is called):
691
+ the spectrum's magnitude was written as a bound, a frequency as a
692
+ time."""
693
+ pointer = tuple(pointer)
694
+ raw, desc, view, leaf = _scored(s, model_id, variable, path, None)
695
+ panel, x, y, visible = _click(click)
696
+ target = _target(raw, path, leaf, pointer)
697
+ if target is None:
698
+ raise Refused("nothing on this plot sets that field")
699
+ if panel != target.panel:
700
+ label = click.get("label") or "this field"
701
+ where = "spectrum" if target.panel == "spectrum" else "time plot"
702
+ reason = f"{label} is set on the {where}"
703
+ raise HandleEditError(f"{path}: {reason}", reason=reason)
704
+ if target.direct is not None:
705
+ field, axis = target.direct
706
+ across = axis == "x"
707
+ new = copy.deepcopy(raw)
708
+ node: Any = new
709
+ for key in field[:-1]:
710
+ node = node.setdefault(key, {}) if isinstance(node, dict) else node[key]
711
+ node[field[-1]] = _written(
712
+ snapped(x if across else y, visible[0 if across else 1])
713
+ )
714
+ refuse_bounds(str(new.get("metric", DEFAULT_METRIC)), new)
715
+ return None if new == raw else new
716
+ if desc is None or desc.edit_handle is None:
717
+ raise ValueError(f"{raw.get('metric')} has no on-plot handles")
718
+ _at, local, axes = min(
719
+ target.handles, key=lambda one: abs(one[0] - x) if one[0] is not None else 0.0
720
+ )
721
+ at_x = x if "x" in axes else None
722
+ at_y = y if "y" in axes else None
723
+ try:
724
+ new = desc.edit_handle(_edit(path, raw, view, leaf, local, at_x, at_y, visible))
725
+ except HandleEditError as e:
726
+ raise HandleEditError(f"{path}: {e}", reason=e.reason) from None
727
+ return None if new == raw else new
728
+
729
+
730
+ def _handle_id(shape_name: str) -> str:
731
+ """The primitive a dragged shape stands for: a bound's dotted
732
+ continuation outside its window (`_hline`'s ``<id>/outside``, full
733
+ width, so it also covers the bound inside the window) is the bound."""
734
+ return shape_name[: -len(_OUTSIDE)] if shape_name.endswith(_OUTSIDE) else shape_name
735
+
736
+
737
+ class EditOnPlot:
738
+ """Drag a handle on the plot, or edit its table; one replace of the leaf
739
+ per edit."""
740
+
741
+ name = "edit-on-plot"
742
+ label = "Edit on plot"
743
+ placements = frozenset({Placement.CRITERION})
744
+
745
+ def caption(self, s: SessionView) -> str:
746
+ return self.label
747
+
748
+ def available(self, s: SessionView) -> bool | str:
749
+ selected = _selected(s)
750
+ if selected is None:
751
+ return "open one criterion"
752
+ model_id, variable, leaf_path = selected
753
+ # The handles are drawn from the comparison's tree; every edit of
754
+ # one goes through the Session's, which may have none.
755
+ tree, why = s.tree_or_reason(model_id)
756
+ if tree is None:
757
+ assert why is not None # `tree` is None
758
+ return why
759
+ metric_name = dict(_leaf_paths(s, model_id, variable)).get(leaf_path)
760
+ if metric_name is None:
761
+ # Nothing of the test is scored while it runs, or after a run
762
+ # that did not finish (D151): the criterion is there, and the
763
+ # page's word for it is the reason.
764
+ word = (s.tree_view(model_id) or {}).get("unscored")
765
+ if word:
766
+ return UNSCORED.format(word)
767
+ return f"{leaf_path} is not a criterion on {variable}"
768
+ if not _has_handles(metric_name):
769
+ return f"{metric_name} has no on-plot handles"
770
+ return True
771
+
772
+ def panel(self, s: SessionView, ui_panel: Panel) -> None:
773
+ from nicegui import ui
774
+
775
+ selected = _selected(s)
776
+ assert selected is not None # `available` said so
777
+ model_id, variable, leaf_path = selected
778
+
779
+ def on_relayout(handle: str, payload: dict) -> None:
780
+ if handle:
781
+ ui_panel.submit(
782
+ kind="handle",
783
+ model_id=model_id,
784
+ variable=variable,
785
+ handle=handle,
786
+ shape=payload,
787
+ )
788
+ # A release ends its preview: staged, the editor was
789
+ # rebuilt from the leaf; refused or no move at all, the
790
+ # fields go back to what is staged.
791
+ ui_panel.preview(None)
792
+
793
+ ui_panel.figure.on("plotly_relayout", on_relayout)
794
+
795
+ #: The drag being held and the variable's view it is previewed
796
+ #: over: one read for the whole of a drag — the Session does not
797
+ #: move under a held handle, and a view is every trajectory read
798
+ #: again.
799
+ held: dict[str, Any] = {"drag": None, "view": None}
800
+
801
+ def on_move(handle: str, payload: dict) -> None:
802
+ """A held handle's would-be position (D149): the leaf its
803
+ release would stage, shown in the fields and staged nowhere. A
804
+ position the release would refuse previews nothing: the
805
+ release says why."""
806
+ if not handle or payload.get("end"):
807
+ ui_panel.preview(None)
808
+ return
809
+ drag = payload.get("drag")
810
+ if held["drag"] != drag or held["view"] is None:
811
+ held.update(drag=drag, view=s.view(model_id, variable))
812
+ try:
813
+ found = dragged(
814
+ s,
815
+ model_id=model_id,
816
+ variable=variable,
817
+ handle=handle,
818
+ shape=payload,
819
+ view=held["view"],
820
+ )
821
+ except ValueError:
822
+ found = None
823
+ revision = drag if isinstance(drag, int) else 0
824
+ ui_panel.preview(None if found is None else found[2], revision)
825
+
826
+ ui_panel.figure.on("dstf_move", on_move)
827
+
828
+ def on_click(_handle: str, click: dict) -> None:
829
+ """Ctrl+click adds a row (D149); ⌘ is Ctrl where Ctrl+click is
830
+ the context menu. A plain click is an armed field's
831
+ (`on_place`), and any other modifier's is nothing."""
832
+ if click.get("ctrl") or click.get("meta"):
833
+ ui_panel.submit(
834
+ kind="click",
835
+ model_id=model_id,
836
+ variable=variable,
837
+ path=leaf_path,
838
+ click=click,
839
+ )
840
+
841
+ ui_panel.figure.on("dstf_click", on_click)
842
+ ui_panel.on_place(
843
+ lambda pointer, click: ui_panel.submit(
844
+ kind="place",
845
+ model_id=model_id,
846
+ variable=variable,
847
+ path=leaf_path,
848
+ pointer=pointer,
849
+ click=click,
850
+ )
851
+ )
852
+ tree = s.tree(model_id)
853
+ if tree is None:
854
+ return
855
+
856
+ def span() -> tuple[float, float] | None:
857
+ view = s.view(model_id, variable)
858
+ if view.ref_time is None or len(view.ref_time) == 0:
859
+ return None
860
+ return float(view.ref_time[0]), float(view.ref_time[-1])
861
+
862
+ for path, _mode in _leaf_paths(s, model_id, variable):
863
+ if path != leaf_path: # the editor is the open criterion's
864
+ continue
865
+ raw = node_at(tree, path)
866
+ name = raw.get("metric", DEFAULT_METRIC)
867
+ tables = _list_fields(name)
868
+ if tables:
869
+ # Said where a table is drawn: a range or a scalar has lines
870
+ # on the plot and no table, and the editor's own hint line
871
+ # says what its plot drags.
872
+ ui.label("drag on the plot, or edit the table").classes("text-xs")
873
+ shows = []
874
+ can = placeable(s, model_id=model_id, variable=variable, path=path)
875
+ for field, columns in tables:
876
+ ui.label(_field_label(name, field)).classes("text-xs text-bold")
877
+ rows = copy.deepcopy(raw.get(field) or [])
878
+ show = _table(
879
+ ui, path, field, columns, rows, span, ui_panel, model_id, can, raw
880
+ )
881
+ shows.append((field, show))
882
+
883
+ def preview(leaf: dict | None, revision: int, shows: list = shows) -> None:
884
+ for field, show in shows:
885
+ show(None if leaf is None else leaf.get(field), revision)
886
+
887
+ ui_panel.on_preview(preview)
888
+
889
+ def act(self, s: SessionView, **inputs: Any) -> Patch | None:
890
+ kind = inputs["kind"]
891
+ if kind == "handle":
892
+ return self._act_handle(s, **inputs)
893
+ if kind == "rows":
894
+ return self._act_rows(s, **inputs)
895
+ if kind == "click":
896
+ new = clicked_row(
897
+ s,
898
+ model_id=inputs["model_id"],
899
+ variable=inputs["variable"],
900
+ path=inputs["path"],
901
+ click=inputs["click"],
902
+ )
903
+ return _replace(inputs["model_id"], inputs["path"], new)
904
+ if kind == "place":
905
+ new = placed(
906
+ s,
907
+ model_id=inputs["model_id"],
908
+ variable=inputs["variable"],
909
+ path=inputs["path"],
910
+ pointer=inputs["pointer"],
911
+ click=inputs["click"],
912
+ )
913
+ return _replace(inputs["model_id"], inputs["path"], new)
914
+ raise ValueError(f"unknown kind {kind!r}")
915
+
916
+ def _act_handle(
917
+ self,
918
+ s: SessionView,
919
+ *,
920
+ model_id: str,
921
+ variable: str,
922
+ handle: str,
923
+ shape: dict,
924
+ **_: Any,
925
+ ) -> Patch | None:
926
+ found = dragged(
927
+ s, model_id=model_id, variable=variable, handle=handle, shape=shape
928
+ )
929
+ if found is None:
930
+ return None
931
+ path, raw, new = found
932
+ return _replace(model_id, path, None if new == raw else new)
933
+
934
+ def _act_rows(
935
+ self,
936
+ s: SessionView,
937
+ *,
938
+ model_id: str,
939
+ path: str,
940
+ field: str,
941
+ rows: list[dict],
942
+ **_: Any,
943
+ ) -> Patch | None:
944
+ raw = _leaf_at(s, model_id, path)
945
+ name = raw.get("metric", DEFAULT_METRIC)
946
+ columns = dict(_list_fields(name)).get(field)
947
+ if columns is None:
948
+ raise ValueError(f"{name} has no table {field!r}")
949
+ required = [column[0] for column in columns if column[1] in _REQUIRED]
950
+ #: A tolerance column -> what its two sides are called, where the
951
+ #: column says (a time tolerance's `before` / `after`).
952
+ sided = {
953
+ column[0]: (column[2] if len(column) > 2 else None)
954
+ for column in columns
955
+ if column[1] in _SIDES
956
+ }
957
+ cleaned: list[dict] = []
958
+ for i, row in enumerate(rows):
959
+ for key in required:
960
+ if row.get(key) is None:
961
+ raise Refused(f"row {i}: {key} is required")
962
+ for key, value in row.items():
963
+ if _is_number(value) and not math.isfinite(value):
964
+ raise Refused(f"row {i}: {key} = {value!r} is not a finite number")
965
+ if key in sided and value is not None:
966
+ # The one reader of a number-or-pair; its ValueError —
967
+ # a missing side, a third key, a side that is no finite
968
+ # number — is the refusal.
969
+ try:
970
+ parse_sides(value, f"row {i}: {key}")
971
+ except ValueError as e:
972
+ raise Refused(str(e)) from None
973
+ # ... and the editor's own: a tolerance below zero can
974
+ # never be met (`..unscorable`).
975
+ refuse_negative(key, value, sided[key], where=f"row {i}: ")
976
+ cleaned.append({k: v for k, v in row.items() if v is not None})
977
+ # The reader's own refusal (`algorithms._interpolate_tube_widths`),
978
+ # raised here by name rather than as the rescore's config error.
979
+ is_tube = [column[0] for column in columns] == ["time", "upper", "lower"]
980
+ if (
981
+ is_tube
982
+ and cleaned
983
+ and not any("upper" in r or "lower" in r for r in cleaned)
984
+ ):
985
+ raise Refused(f"{field}: no row has an upper or a lower width")
986
+ new = copy.deepcopy(raw)
987
+ new[field] = cleaned
988
+ if new == raw:
989
+ return None
990
+ return Patch(ops=[PatchOp(model_id, "replace", path, new)])
991
+
992
+
993
+ def _replace(model_id: str, path: str, new: dict | None) -> Patch | None:
994
+ """The one op every gesture on the plot stages — the whole leaf
995
+ replaced — or None where the gesture left the leaf as it was."""
996
+ if new is None:
997
+ return None
998
+ return Patch(ops=[PatchOp(model_id, "replace", path, new)])
999
+
1000
+
1001
+ def _table(
1002
+ ui: Any,
1003
+ path: str,
1004
+ field: str,
1005
+ columns: Columns,
1006
+ rows: list[dict],
1007
+ span: Any,
1008
+ ui_panel: Panel,
1009
+ model_id: str,
1010
+ can: Callable[[Pointer], bool],
1011
+ leaf: Mapping,
1012
+ ) -> Callable[[list | None, int], None]:
1013
+ """One list field's rows of the criterion ``leaf`` (as the spec holds
1014
+ it when this panel is built: what a first row is made from,
1015
+ :func:`_new_row`), a control per column, ``Remove row`` per row
1016
+ and ``Add row``. Returns the table's PREVIEW (D149): called with the
1017
+ rows a held drag would write — and the drag's number — every number
1018
+ cell and tolerance side whose value would change shows that value,
1019
+ staging nothing (`TypedNumber.preview`); called with None, or with a
1020
+ list of another length, they show what they hold again. A row the
1021
+ plot can place (``can``: `placeable`) carries ONE ⌖, in its first cell
1022
+ (`Panel.place_button`), naming it by the table and its index: armed,
1023
+ the click is its ring let go there. A row added or removed moves what
1024
+ an index names, so it disarms (`Panel.disarm`). The rows live here: neither a refused submit nor a
1025
+ successful one rebuilds this panel — ``InlineHost`` shows a refusal above
1026
+ it and leaves it alone, so that a control being typed in survives — so
1027
+ the table re-renders its own container after adding or removing a row.
1028
+ Only the ROWS scroll: ``Add row`` sits after their container, where a
1029
+ long table cannot scroll it out of reach.
1030
+
1031
+ A number cell reports every keystroke, so clearing a REQUIRED cell to
1032
+ retype it passes through blank. Submitted, that blank is refused and the
1033
+ error stands above a table whose cell says nothing; so while any required
1034
+ cell is blank the table keeps its rows here, marks the blank cells and
1035
+ submits nothing — the keystroke that fills the last one submits the
1036
+ whole list. (``act`` still refuses a blank, for a direct call.)
1037
+
1038
+ A tolerance column (``sides`` / ``sides?``) is the form's control,
1039
+ `forms.sides_control`: two fields and a link toggle, named by the
1040
+ column's own side labels. With ONE side of a pair blank the control
1041
+ hands the table nothing — the row still holds the pair it had — so a
1042
+ half pair holds the whole list back as a blank required cell does.
1043
+ The one thing a half cell does hand over is the pair it held when its
1044
+ field took the focus, where values typed on the way to blank were
1045
+ staged (`0.2` backspaced away is `0.`, `0`, then nothing): that list
1046
+ is staged, so the row holds no pair nobody meant, unless ANOTHER cell
1047
+ holds the list back — then those values were never staged either.
1048
+ ``Add row`` and ``Remove row`` too: they rebuild the rows, and a half
1049
+ cell is rebuilt as it stands — blank and marked — not from the pair
1050
+ its row still holds, so nothing is staged until the blank is filled
1051
+ (or its row is the one removed).
1052
+
1053
+ This PANEL is rebuilt too, by edits it did not make and is not told
1054
+ of: a drag on the plot rebuilds the whole editor, an edit in the
1055
+ criterion's form this panel. Built from the spec alone, a blank cell
1056
+ came back holding its old value, unmarked, and held nothing back. So
1057
+ every cell that shows what the spec does not hold — a required cell
1058
+ blank, a tolerance half typed — is written, as it becomes one, where
1059
+ it outlives the panel (`remember`, into ``Panel.kept``), and read back
1060
+ here: by field, the row's index in the SPEC's list and column, with the
1061
+ value the spec's row held under it. A cell whose row has since changed
1062
+ in the spec — that side dragged on the plot — shows the spec's again.
1063
+ What is not kept is anything else a held-back list holds: another
1064
+ cell's value typed meanwhile, a row added or removed. Rebuilt, the
1065
+ table shows the spec's rows, the blank cell still blank.
1066
+
1067
+ A value ``act`` REFUSES is typed by way of others it takes — `-0.5`
1068
+ over a tolerance is nothing, `-0`, `-0.`, then `-0.5` — and each of
1069
+ those was staged: refused, the list stayed as the last of them left
1070
+ it, a tolerance of 0 beside a field reading −0.5. So the cell goes
1071
+ back to what it held when it was entered (`enter`), as a refused field
1072
+ of the criterion's form does: the list is staged with that one cell
1073
+ put back, and the refusal said again. The field keeps what was typed,
1074
+ and while it does the list is refused whole, like a blank required
1075
+ cell holds it back. "What it held" is what the STAGED list holds for
1076
+ it, never what the cell shows: a refused cell shows a value the spec
1077
+ never took, and entered again with that for its start, its put-back
1078
+ was refused in turn and the `-0` stayed. And whichever way a put-back
1079
+ goes, the list as shown is handed over once more after it, so the
1080
+ sentence left standing is the first refusal of the rows AS SHOWN — not
1081
+ the put-back's, of a value no cell shows any more."""
1082
+ from ..forms import SidesControl, label_width, sides_control
1083
+ from ..typed_number import TypedNumber
1084
+
1085
+ mark = f"{path}:{field}"
1086
+ container = ui.column().classes("w-full").mark(mark)
1087
+ required = [column[0] for column in columns if column[1] in _REQUIRED]
1088
+ cells: dict[tuple[int, str], Any] = {} # the required cells, as rendered
1089
+ numbers: dict[tuple[int, str], Any] = {} # every number cell, as rendered
1090
+ #: The tolerance cells, as rendered: the row each is on, its column and
1091
+ #: the control.
1092
+ pairs: list[tuple[dict, str, SidesControl]] = []
1093
+ #: The list as the spec holds it — what this table was built on, then
1094
+ #: what it last staged — and, per row shown, where that row comes from:
1095
+ #: its index in that list, or, for a row added while a cell held the
1096
+ #: list back, the row as it was added (a copy: the spec holds no row
1097
+ #: for it yet, and the index it is shown at may be ANOTHER row's there
1098
+ #: — one removed while the list was held back).
1099
+ spec: list[dict] = copy.deepcopy(rows)
1100
+ origin: list[int | dict] = list(range(len(rows)))
1101
+ #: ``(field, index in the spec's list, column)`` -> ``{"base": what the
1102
+ #: spec's row holds there, "shown": None for a blank cell, else the
1103
+ #: half pair its fields show}``: `Panel.kept`, the one record of what
1104
+ #: this table's cells show that the spec does not hold.
1105
+ kept = ui_panel.kept
1106
+ #: The half pairs read back from it, by ``(id(row), column)``.
1107
+ restored: dict[tuple[int, str], dict] = {}
1108
+ for name, at, key in [k for k in kept if k[0] == field]:
1109
+ cell = kept[name, at, key]
1110
+ if at >= len(rows) or rows[at].get(key) != cell["base"]:
1111
+ del kept[name, at, key] # the spec's row moved under it
1112
+ elif isinstance(cell["shown"], dict):
1113
+ restored[id(rows[at]), key] = cell["shown"]
1114
+ else:
1115
+ rows[at][key] = None
1116
+
1117
+ def remember() -> None:
1118
+ """Rewrite this table's part of ``kept`` from its cells as they
1119
+ stand: called after every edit of one, whatever it staged."""
1120
+ for k in [k for k in kept if k[0] == field]:
1121
+ del kept[k]
1122
+ at_of = {id(row): at for row, at in zip(rows, origin) if isinstance(at, int)}
1123
+ for row in rows:
1124
+ at = at_of.get(id(row))
1125
+ if at is None:
1126
+ continue # a row the spec does not hold yet
1127
+ for key in required:
1128
+ if row.get(key) is None:
1129
+ base = copy.deepcopy(spec[at].get(key))
1130
+ kept[field, at, key] = {"base": base, "shown": None}
1131
+ for row, key, group in pairs:
1132
+ at = at_of.get(id(row))
1133
+ if at is not None and group.half():
1134
+ base = copy.deepcopy(spec[at].get(key))
1135
+ kept[field, at, key] = {"base": base, "shown": group.shown()}
1136
+
1137
+ def blanks(but: SidesControl | None = None) -> bool:
1138
+ """Mark each required cell blank or not; True when any is, or when
1139
+ a tolerance cell other than ``but`` is half a pair (it marks its
1140
+ own fields). The mark is the cell's own validation (`missing`):
1141
+ written as an `error` prop it never showed — NiceGUI validates a
1142
+ field after its change handlers and resets an `error` it did not
1143
+ set itself."""
1144
+ for cell in cells.values():
1145
+ cell.validate(return_result=False)
1146
+ return any(row.get(key) is None for row in rows for key in required) or any(
1147
+ group.half() for _row, _key, group in pairs if group is not but
1148
+ )
1149
+
1150
+ def missing(i: int, key: str) -> str | None:
1151
+ return f"{key} is required" if rows[i].get(key) is None else None
1152
+
1153
+ #: The cell being edited — ``(row shown, column)``, None before any is
1154
+ #: and after the table heard a focus or changed its rows — and what the
1155
+ #: staged list held there when it was entered: what a refused edit puts
1156
+ #: back.
1157
+ taken: dict[str, Any] = {"cell": None, "entered": None}
1158
+
1159
+ def enter(i: int, key: str) -> None:
1160
+ """``(i, key)`` is the first cell edited since another was, or
1161
+ since a focus was heard: what the STAGED list holds for it now is
1162
+ its start (`spec`, by the row's `origin`) — as the criterion's form
1163
+ starts a field from the leaf its last accepted submit left
1164
+ (`edit_criteria`'s ``taken["leaf"]``). A row the spec does not hold
1165
+ yet (added while the list was held back) has no staged value: it
1166
+ starts from what it was added with (its `origin`) — every time it
1167
+ is entered, not the first alone. Started from what the row SHOWS,
1168
+ a cell left on a refused value and entered again went back to that
1169
+ value: the put-back was refused in turn, and a `-0` typed on the
1170
+ way stayed in the list once the rest of it could be staged."""
1171
+ if taken["cell"] != (i, key):
1172
+ taken["cell"] = (i, key)
1173
+ at = origin[i]
1174
+ start = spec[at] if isinstance(at, int) else at
1175
+ taken["entered"] = copy.deepcopy(start.get(key))
1176
+
1177
+ def left() -> None:
1178
+ """A focus in the table, or its rows moved: whichever cell is
1179
+ edited next starts from what the staged list holds for it then."""
1180
+ taken["cell"] = None
1181
+
1182
+ def held(listed: list[dict]) -> list[dict]:
1183
+ """``listed`` as the spec would hold it: a blank optional cell left
1184
+ out, as `act` leaves it out."""
1185
+ return [
1186
+ {k: copy.deepcopy(v) for k, v in row.items() if v is not None}
1187
+ for row in listed
1188
+ ]
1189
+
1190
+ def stage(listed: list[dict]) -> bool:
1191
+ """Submit ``listed`` — the rows shown, or those with one cell put
1192
+ back; True when it went through, and the spec holds it."""
1193
+ if not ui_panel.submit(
1194
+ kind="rows",
1195
+ model_id=model_id,
1196
+ path=path,
1197
+ field=field,
1198
+ rows=copy.deepcopy(listed),
1199
+ ):
1200
+ return False
1201
+ spec[:] = held(listed)
1202
+ origin[:] = range(len(rows))
1203
+ return True
1204
+
1205
+ def submit(but: SidesControl | None = None) -> None:
1206
+ held_back = blanks(but)
1207
+ if not held_back and not stage(rows) and taken["cell"] is not None:
1208
+ # Refused. The cell goes back to what the spec held for it
1209
+ # when it was entered — where values typed on the way to this
1210
+ # one were staged — and the refusal is said again: a put-back
1211
+ # that went through hid it, and one refused in its turn (another
1212
+ # cell shows a refused value too) said its own, of a value this
1213
+ # cell no longer shows.
1214
+ i, key = taken["cell"]
1215
+ back = copy.deepcopy(rows)
1216
+ back[i][key] = copy.deepcopy(taken["entered"])
1217
+ if held(back) != spec:
1218
+ stage(back)
1219
+ stage(rows)
1220
+ remember()
1221
+
1222
+ def set_cell(i: int, key: str, value: Any) -> None:
1223
+ enter(i, key)
1224
+ rows[i][key] = value
1225
+ submit()
1226
+
1227
+ def set_sides(i: int, key: str, value: Any) -> None:
1228
+ """A tolerance cell handed ``value`` over. Half as it does so, it
1229
+ is putting back what it held when its field took the focus: its
1230
+ own blank side does not hold THAT list back."""
1231
+ enter(i, key)
1232
+ rows[i][key] = value
1233
+ own = next(g for row, k, g in pairs if row is rows[i] and k == key)
1234
+ submit(but=own)
1235
+
1236
+ def remove(i: int) -> None:
1237
+ ui_panel.disarm() # a ⌖ names its row by index
1238
+ left()
1239
+ rows.pop(i)
1240
+ origin.pop(i)
1241
+ render()
1242
+ submit()
1243
+
1244
+ def add() -> None:
1245
+ ui_panel.disarm()
1246
+ left()
1247
+ rows.append(_new_row(columns, rows, span(), leaf))
1248
+ origin.append(copy.deepcopy(rows[-1]))
1249
+ render()
1250
+ submit()
1251
+
1252
+ def render(halves: dict[tuple[int, str], dict] | None = None) -> None:
1253
+ # A half pair lives in its control alone: rebuilt from its row, the
1254
+ # blank side came back holding the pair's old value, no cell was
1255
+ # half any more, and the list was staged with that value in it.
1256
+ # ``halves``: those of the panel before this one, read from `kept`.
1257
+ if halves is None:
1258
+ halves = {
1259
+ (id(row), key): group.shown()
1260
+ for row, key, group in pairs
1261
+ if group.half() and any(row is still for still in rows)
1262
+ }
1263
+ container.clear()
1264
+ cells.clear()
1265
+ numbers.clear()
1266
+ pairs.clear()
1267
+ with container:
1268
+ # The rows alone scroll; `Add row` follows their container.
1269
+ scroll = ui.column().classes("w-full dstf-table-scroll")
1270
+ for i, row in enumerate(rows):
1271
+ # A row wraps INSIDE itself where it is wider than the
1272
+ # editor (a points row is ten controls), never sideways.
1273
+ with scroll, ui.row().classes("dstf-table-row"):
1274
+ for key, kind, *labels in columns:
1275
+ if kind in _SIDES:
1276
+ group = sides_control(
1277
+ key,
1278
+ row.get(key),
1279
+ partial(set_sides, i, key),
1280
+ side_labels=labels[0] if labels else SIDE_LABELS,
1281
+ mark=f"{mark}:{i}:{key}",
1282
+ required=key in required,
1283
+ shown=halves.get((id(row), key)),
1284
+ on_edit=remember,
1285
+ )
1286
+ for side in (group.lower, group.upper):
1287
+ side.props(_NO_BOTTOM)
1288
+ # At least as wide as its label, which an
1289
+ # EMPTY cell draws full size: the cells'
1290
+ # 120 px cut "before time_tolerance".
1291
+ width = label_width(side.props["label"])
1292
+ side.style(f"min-width: {width}px")
1293
+ pairs.append((row, key, group))
1294
+ elif isinstance(kind, tuple):
1295
+ value = row.get(key, kind[0])
1296
+ # A value the author wrote outside the choices
1297
+ # stays selectable rather than break the build.
1298
+ options = list(kind) + ([] if value in kind else [value])
1299
+ ui.select(
1300
+ options,
1301
+ label=key,
1302
+ value=value,
1303
+ on_change=lambda e, i=i, key=key: set_cell(
1304
+ i, key, e.value
1305
+ ),
1306
+ ).props("dense").mark(f"{mark}:{i}:{key}")
1307
+ else:
1308
+ cell = (
1309
+ TypedNumber(
1310
+ label=key,
1311
+ value=row.get(key),
1312
+ on_change=lambda e, i=i, key=key: set_cell(
1313
+ i, key, e.value
1314
+ ),
1315
+ validation=(
1316
+ (lambda _v, i=i, key=key: missing(i, key))
1317
+ if key in required
1318
+ else None
1319
+ ),
1320
+ )
1321
+ .props(f"dense {_NO_BOTTOM}")
1322
+ .mark(f"{mark}:{i}:{key}")
1323
+ )
1324
+ if key in required:
1325
+ cells[(i, key)] = cell
1326
+ numbers[(i, key)] = cell
1327
+ if key == columns[0][0] and can((field, i)):
1328
+ with cell.add_slot("append"):
1329
+ ui_panel.place_button((field, i), f"row {i + 1}")
1330
+ ui.button("Remove row", on_click=lambda i=i: remove(i)).props(
1331
+ "flat dense no-caps"
1332
+ ).mark(f"{mark}:{i}:remove")
1333
+ ui.button("Add row", on_click=add).props("flat dense no-caps").mark(
1334
+ f"{mark}:add"
1335
+ )
1336
+
1337
+ # Whichever cell it is: what its row holds now is its start.
1338
+ container.on("focusin", lambda _e: left(), [])
1339
+ render(restored)
1340
+ if any(k[0] == field for k in kept):
1341
+ blanks() # a blank cell read back says so from the start
1342
+
1343
+ def preview(shown: list | None, revision: int) -> None:
1344
+ if shown is not None and len(shown) != len(rows):
1345
+ shown = None # no drag adds or removes a row
1346
+ for (i, key), cell in numbers.items():
1347
+ would = None if shown is None else shown[i].get(key)
1348
+ cell.preview(None if would == rows[i].get(key) else would, revision)
1349
+ for row, key, group in pairs:
1350
+ at = next((n for n, still in enumerate(rows) if still is row), None)
1351
+ would = None if shown is None or at is None else shown[at].get(key)
1352
+ group.preview(None if would == row.get(key) else would, revision)
1353
+
1354
+ return preview