dynamic-systems-testing-framework 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dstf/__init__.py +41 -0
- dstf/__main__.py +10 -0
- dstf/authoring/__init__.py +13 -0
- dstf/authoring/builtin/__init__.py +32 -0
- dstf/authoring/builtin/accept.py +205 -0
- dstf/authoring/builtin/edit_criteria.py +479 -0
- dstf/authoring/builtin/edit_family.py +228 -0
- dstf/authoring/builtin/edit_on_plot.py +1354 -0
- dstf/authoring/builtin/edit_tree.py +1399 -0
- dstf/authoring/builtin/enqueue.py +144 -0
- dstf/authoring/capabilities.py +463 -0
- dstf/authoring/errors.py +49 -0
- dstf/authoring/family_rows.py +526 -0
- dstf/authoring/forms.py +631 -0
- dstf/authoring/layout.py +104 -0
- dstf/authoring/live_rows.py +337 -0
- dstf/authoring/live_runs.py +385 -0
- dstf/authoring/needle.py +127 -0
- dstf/authoring/picker_rows.py +92 -0
- dstf/authoring/queue_rows.py +354 -0
- dstf/authoring/session.py +3016 -0
- dstf/authoring/shell/__init__.py +9 -0
- dstf/authoring/shell/actions.py +702 -0
- dstf/authoring/shell/app.py +53 -0
- dstf/authoring/shell/drawer_controls.py +190 -0
- dstf/authoring/shell/family_fold.py +468 -0
- dstf/authoring/shell/figure_hook.py +249 -0
- dstf/authoring/shell/grips.js +1000 -0
- dstf/authoring/shell/header.py +783 -0
- dstf/authoring/shell/live_view.py +93 -0
- dstf/authoring/shell/picker.py +267 -0
- dstf/authoring/shell/placing.py +122 -0
- dstf/authoring/shell/plot_card.py +1171 -0
- dstf/authoring/shell/queue_panel.py +433 -0
- dstf/authoring/shell/rail.py +593 -0
- dstf/authoring/shell/shell.py +528 -0
- dstf/authoring/shell/stats_card.py +501 -0
- dstf/authoring/shell/strip.py +204 -0
- dstf/authoring/shell/style.py +721 -0
- dstf/authoring/shell/suite.py +277 -0
- dstf/authoring/shell/suite_grid.js +414 -0
- dstf/authoring/shell/suite_grid.py +373 -0
- dstf/authoring/shell/tab_theme.py +110 -0
- dstf/authoring/shell/tabstate.py +146 -0
- dstf/authoring/shell/test_list.py +179 -0
- dstf/authoring/shell/test_page.py +1404 -0
- dstf/authoring/shell/tree_card.py +1171 -0
- dstf/authoring/shell/variables_drawer.py +589 -0
- dstf/authoring/shell/widgets.py +188 -0
- dstf/authoring/situation.py +287 -0
- dstf/authoring/spec_document.py +215 -0
- dstf/authoring/spec_preview.py +83 -0
- dstf/authoring/suite_rows.py +99 -0
- dstf/authoring/tree_move.py +516 -0
- dstf/authoring/tree_pointer.py +33 -0
- dstf/authoring/tree_text.py +61 -0
- dstf/authoring/typed_number.js +64 -0
- dstf/authoring/typed_number.py +76 -0
- dstf/authoring/unscorable.py +141 -0
- dstf/authoring/variable_rows.py +216 -0
- dstf/backends/__init__.py +283 -0
- dstf/backends/base.py +1117 -0
- dstf/backends/common/__init__.py +1 -0
- dstf/backends/common/json_driver.py +484 -0
- dstf/backends/common/live_mat.py +369 -0
- dstf/backends/common/mat_reader.py +632 -0
- dstf/backends/common/proc_kill.py +238 -0
- dstf/backends/common/proc_output.py +20 -0
- dstf/backends/common/result_files.py +109 -0
- dstf/backends/common/result_variables.py +329 -0
- dstf/backends/common/statistics.py +39 -0
- dstf/backends/cross_backend.py +193 -0
- dstf/backends/dymola/__init__.py +5 -0
- dstf/backends/dymola/backend.py +776 -0
- dstf/backends/dymola/interface_loader.py +241 -0
- dstf/backends/dymola/log_parser.py +396 -0
- dstf/backends/dymola/persistent_backend.py +949 -0
- dstf/backends/fmpy/__init__.py +11 -0
- dstf/backends/fmpy/backend.py +443 -0
- dstf/backends/julia/__init__.py +27 -0
- dstf/backends/julia/backend.py +156 -0
- dstf/backends/julia/persistent_backend.py +541 -0
- dstf/backends/julia/run_persistent.jl +167 -0
- dstf/backends/julia/run_test.jl +146 -0
- dstf/backends/openmodelica/__init__.py +21 -0
- dstf/backends/openmodelica/backend.py +368 -0
- dstf/backends/openmodelica/init_xml.py +76 -0
- dstf/backends/openmodelica/log_parser.py +119 -0
- dstf/backends/openmodelica/mos_generator.py +228 -0
- dstf/backends/openmodelica/persistent_backend.py +1045 -0
- dstf/backends/openmodelica/session_loader.py +63 -0
- dstf/backends/persistent.py +1811 -0
- dstf/backends/pool.py +602 -0
- dstf/backends/progress.py +395 -0
- dstf/backends/python/__init__.py +11 -0
- dstf/backends/python/backend.py +141 -0
- dstf/backends/python/run_test.py +141 -0
- dstf/backends/run_metadata.py +103 -0
- dstf/backends/types.py +310 -0
- dstf/cli/__init__.py +12 -0
- dstf/cli/author_cmd.py +61 -0
- dstf/cli/baseline_cmds.py +341 -0
- dstf/cli/common.py +231 -0
- dstf/cli/diag_cmds.py +350 -0
- dstf/cli/main.py +561 -0
- dstf/cli/review.py +398 -0
- dstf/cli/run_cmds.py +589 -0
- dstf/cli/spec_cmds.py +123 -0
- dstf/comparison/__init__.py +13 -0
- dstf/comparison/algorithms.py +2127 -0
- dstf/comparison/builtins.py +291 -0
- dstf/comparison/comparator.py +1189 -0
- dstf/comparison/geometry.py +164 -0
- dstf/comparison/handles.py +1194 -0
- dstf/comparison/metric_tree.py +332 -0
- dstf/comparison/mode_schema.py +176 -0
- dstf/comparison/modes.py +788 -0
- dstf/comparison/registry.py +230 -0
- dstf/comparison/seeds.py +281 -0
- dstf/comparison/sides.py +158 -0
- dstf/comparison/snap.py +62 -0
- dstf/comparison/strict_keys.py +246 -0
- dstf/comparison/summaries.py +354 -0
- dstf/comparison/tree_eval.py +868 -0
- dstf/comparison/tree_spec.py +678 -0
- dstf/comparison/types.py +194 -0
- dstf/comparison/validator.py +232 -0
- dstf/config.py +815 -0
- dstf/discovery/__init__.py +0 -0
- dstf/discovery/json_recognizer.py +702 -0
- dstf/discovery/library_state.py +248 -0
- dstf/discovery/mo_parser.py +794 -0
- dstf/discovery/patch_apply.py +308 -0
- dstf/discovery/patch_envelope.py +63 -0
- dstf/discovery/plots.py +318 -0
- dstf/discovery/recognizer.py +103 -0
- dstf/discovery/scan_cache.py +154 -0
- dstf/discovery/spec_parser.py +538 -0
- dstf/discovery/spec_shape.py +122 -0
- dstf/discovery/spec_update.py +493 -0
- dstf/discovery/test_registry.py +488 -0
- dstf/discovery/unit_tests.py +95 -0
- dstf/figures/__init__.py +14 -0
- dstf/figures/assemble.py +48 -0
- dstf/figures/decimate.py +150 -0
- dstf/figures/families.py +289 -0
- dstf/figures/palette.py +170 -0
- dstf/figures/plotly.py +1413 -0
- dstf/figures/view.py +233 -0
- dstf/pipeline.py +435 -0
- dstf/plugins.py +104 -0
- dstf/reporting/__init__.py +0 -0
- dstf/reporting/console_report.py +187 -0
- dstf/reporting/context_builder.py +979 -0
- dstf/reporting/dashboard_render.py +534 -0
- dstf/reporting/junit_report.py +192 -0
- dstf/reporting/overlay_loader.py +708 -0
- dstf/reporting/report_suite.py +1059 -0
- dstf/reporting/rows.py +458 -0
- dstf/reporting/schema_export.py +413 -0
- dstf/reporting/stats_rows.py +321 -0
- dstf/reporting/status.py +388 -0
- dstf/reporting/templates/axes.js +61 -0
- dstf/reporting/templates/dashboard.html +284 -0
- dstf/reporting/templates/dashboard.js +604 -0
- dstf/reporting/templates/filters.js +100 -0
- dstf/reporting/templates/interactive.html +420 -0
- dstf/reporting/templates/report.js +116 -0
- dstf/reporting/templates/vendor/plotly.min.js +8 -0
- dstf/reporting/theme.py +324 -0
- dstf/reporting/tree_rows.py +73 -0
- dstf/reporting/verdicts.py +124 -0
- dstf/storage/__init__.py +0 -0
- dstf/storage/accept.py +216 -0
- dstf/storage/baseline.py +43 -0
- dstf/storage/batch_manifest.py +204 -0
- dstf/storage/ref_index.py +295 -0
- dstf/storage/reference_store.py +661 -0
- dstf/storage/roles.py +576 -0
- dstf/storage/run_stats.py +85 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|