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,1194 @@
1
+ """The inverse of each mode's handles: a dragged position back to a raw leaf.
2
+
3
+ A mode emits its editable points as ``kind="handle"`` primitives whose ids
4
+ the evaluator prefixes with the leaf's RFC 6902 path. An editor here takes
5
+ the raw leaf (as the spec's ``metrics`` holds it), the handle's id WITHOUT
6
+ that path (its ``local``: ``"tube/upper/3"``) and the position it was
7
+ dragged to, and returns the leaf the drag means. It is attached to the
8
+ mode's :class:`~.registry.MetricDescriptor` as ``edit_handle``, beside
9
+ ``summarize``; a third-party metric supplies its own.
10
+
11
+ Every editor deep-copies the leaf and returns the copy — the input is not
12
+ mutated — and refuses with :class:`HandleEditError`, naming the handle and
13
+ the reason, rather than return a leaf the drag cannot mean: a non-finite
14
+ or non-numeric position, a local it does not name, or a rule below broken. The locals are
15
+ the ids the modes emit (``algorithms._tube_geometry`` and its siblings).
16
+ Every number an editor writes is rounded to 12 significant digits
17
+ (:func:`_written`) — after it is SNAPPED, where it is a value the pointer
18
+ placed and the caller says what its axis showed (:func:`_placed`, D149).
19
+
20
+ A ``kind="grip"`` primitive is one SIDE of a tolerance, and its editor
21
+ writes that side through :func:`_set_side`: the value is the distance from
22
+ the target to the dragged edge — in ``rel``, over the target's magnitude —
23
+ and the holder is the row's own tolerance where the row has one, else the
24
+ criterion's. A grip moves on ONE axis; the other reaches the editor as the
25
+ grip's own resolved position, never as something to write.
26
+
27
+ Two more inverses stand beside the editors (D149). :func:`handle_fields`
28
+ says which FIELD of the leaf a handle writes on each axis it moves on —
29
+ what "arm a field, then click the plot" finds a field's handle by. And a
30
+ PLACER takes a Ctrl+click (:class:`RowPlace`) to the leaf with one more row
31
+ in its table, settled on the data — a checkpoint on the reference, an event
32
+ on the nearest actual event, a peak on the nearest spectral peak, a tube
33
+ control point on the tube as it stands — attached to the descriptor as
34
+ ``place_row``.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import copy
40
+ import math
41
+ from collections.abc import Mapping, Sequence
42
+ from dataclasses import dataclass
43
+
44
+ import numpy as np
45
+
46
+ from .algorithms import (
47
+ _PEAK_MIN_REL_AMPLITUDE,
48
+ _find_event_boundaries,
49
+ _interpolate_tube_widths,
50
+ _peak_window,
51
+ )
52
+ from .geometry import Primitive, Series, VLine
53
+ from .sides import parse_sides
54
+ from .snap import snap_step, snapped
55
+ from .types import _EPS
56
+
57
+
58
+ @dataclass(frozen=True, kw_only=True)
59
+ class HandleEdit:
60
+ """One drag, as an editor receives it."""
61
+
62
+ #: The raw leaf, as ``metrics`` holds it; never mutated.
63
+ leaf: dict
64
+ #: The handle's id without its leaf path: ``"tube/upper/3"``.
65
+ local: str
66
+ #: The dragged position; ``None`` where that axis did not move. For a
67
+ #: grip — which moves on one axis — the other axis carries where the
68
+ #: grip stands (a constant tube's side is measured from the reference
69
+ #: at the grip's own time).
70
+ x: float | None
71
+ y: float | None
72
+ #: The primary baseline ``(time, values)``, full resolution — unwindowed.
73
+ reference: tuple[np.ndarray, np.ndarray] | None
74
+ #: The number a tolerance side is measured from where the leaf's own
75
+ #: geometry holds it: a scalar leaf's baseline scalar (its
76
+ #: ``scalar/reference`` line). ``None`` where there is none.
77
+ target: float | None = None
78
+ #: The ACTUAL series' first and last time as the scorer receives it —
79
+ #: sliced to the leaf's ``window`` — which bounds the time of a
80
+ #: checkpoint (:func:`edit_points_handle`): it is scored against the
81
+ #: run, with a value of its own or the baseline's. ``None`` where the
82
+ #: caller has no actual series: no such bound.
83
+ span: tuple[float, float] | None = None
84
+ #: What the x and the y axis the position was read on SHOWED — each
85
+ #: one's visible span, the page's, as it stood at the press or the
86
+ #: click: what a placed value is snapped by (:func:`_placed`). ``None``
87
+ #: for an axis whose span the caller does not know: nothing placed on
88
+ #: it is snapped.
89
+ visible: tuple[float | None, float | None] = (None, None)
90
+
91
+
92
+ class HandleEditError(ValueError):
93
+ """A drag the leaf cannot take; the message names the handle and why,
94
+ ``reason`` only why — the tool shows it in the open criterion's editor,
95
+ which already says which handle it is."""
96
+
97
+ def __init__(self, message: str, *, reason: str | None = None):
98
+ self.reason = reason if reason is not None else message
99
+ super().__init__(message)
100
+
101
+
102
+ def _refuse(edit: HandleEdit, reason: str) -> HandleEditError:
103
+ return HandleEditError(f"{edit.local}: {reason}", reason=reason)
104
+
105
+
106
+ def _unnamed(edit: HandleEdit) -> HandleEditError:
107
+ return HandleEditError(
108
+ f"{edit.local} cannot be moved on the plot",
109
+ reason="this cannot be moved on the plot",
110
+ )
111
+
112
+
113
+ def _num(value: float) -> str:
114
+ """A number for a refusal message, to 12 significant digits: enough
115
+ that a value just outside a bound never prints as a value on it (``:g``
116
+ prints ``2.0000001`` as ``2``), few enough that float noise from the
117
+ width arithmetic does not (``repr`` prints ``-1.5270000000000001``)."""
118
+ return f"{float(value):.12g}"
119
+
120
+
121
+ def _written(value: float) -> float:
122
+ """A number an editor WRITES into the leaf, to 12 significant digits —
123
+ the precision :func:`_num` prints: the arithmetic from a drag's position
124
+ (``0.8 - 0.5``) otherwise lands ``0.30000000000000004`` in the spec file
125
+ the author reads and version-controls."""
126
+ return float(f"{float(value):.12g}")
127
+
128
+
129
+ def _placed(
130
+ edit: HandleEdit, axis: str, value: float, *, fraction: bool = False
131
+ ) -> float:
132
+ """A number the POINTER placed, as an editor writes it: a dragged or
133
+ clicked coordinate on ``axis``, or a tolerance side measured from one
134
+ — snapped to a readable step of what that axis showed
135
+ (:func:`~.snap.snapped`; a ``fraction`` of its target to 3 significant
136
+ digits), then written as every number is (:func:`_written`). Snapped
137
+ FIRST: every clamp and refusal reads the value that would be written.
138
+ A caller that names no span (``edit.visible``) is no pointer, and gets
139
+ the 12 digits alone."""
140
+ span = edit.visible[0 if axis == "x" else 1]
141
+ return _written(snapped(float(value), span, fraction=fraction))
142
+
143
+
144
+ def _is_number(value: object) -> bool:
145
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
146
+
147
+
148
+ def _check_position(edit: HandleEdit) -> None:
149
+ for axis, value in (("x", edit.x), ("y", edit.y)):
150
+ if value is None:
151
+ continue
152
+ if not _is_number(value):
153
+ raise _refuse(edit, f"{axis} = {value!r} is not a number")
154
+ if not math.isfinite(value):
155
+ raise _refuse(edit, f"{axis} = {value!r} is not a finite number")
156
+
157
+
158
+ def _authored_number(edit: HandleEdit, value: object, what: str) -> float:
159
+ """A number the leaf's author wrote, or a refusal naming it."""
160
+ if not _is_number(value):
161
+ raise _refuse(edit, f"{what} is {value!r}, not a number")
162
+ return float(value) # type: ignore[arg-type]
163
+
164
+
165
+ def _parse(edit: HandleEdit, family: str, parts: int) -> list[str]:
166
+ """Split ``edit.local`` into ``parts`` segments under ``family``, or
167
+ refuse it as a local this editor does not name."""
168
+ segments = edit.local.split("/")
169
+ if len(segments) != parts or segments[0] != family:
170
+ raise _unnamed(edit)
171
+ return segments
172
+
173
+
174
+ def _index(edit: HandleEdit, segment: str, rows: object) -> int:
175
+ """The list index a local's last segment addresses in ``rows``: a
176
+ canonical non-negative integer naming an existing dict entry. ASCII
177
+ digits only — ``str.isdigit`` also accepts ``"²"``, which ``int``
178
+ refuses."""
179
+ if not (segment.isascii() and segment.isdigit()) or str(int(segment)) != segment:
180
+ raise _unnamed(edit)
181
+ i = int(segment)
182
+ if not isinstance(rows, list) or i >= len(rows) or not isinstance(rows[i], dict):
183
+ raise _refuse(edit, f"there is no entry {i} to move")
184
+ return i
185
+
186
+
187
+ def _reference(edit: HandleEdit) -> tuple[np.ndarray, np.ndarray] | None:
188
+ """``edit.reference``, or ``None`` when either array is empty: an empty
189
+ baseline has nothing to measure from, and reading it as one would reach
190
+ numpy's own errors (``np.interp`` on no samples) instead of a refusal."""
191
+ if edit.reference is None:
192
+ return None
193
+ ref_time, ref_values = edit.reference
194
+ if len(ref_time) == 0 or len(ref_values) == 0:
195
+ return None
196
+ return edit.reference
197
+
198
+
199
+ def _time_move(edit: HandleEdit, t: float) -> float | None:
200
+ """``edit.x`` as a time move from the handle's current time ``t``, or
201
+ ``None`` when it is not one. The ring always sends its ``x``, and a
202
+ vertical drag sends it back off ``t`` by the data→pixel→data round trip;
203
+ read as a move, that offset was clamped (moving a value checkpoint drawn
204
+ outside the span) or refused (an ``against``-other leaf, a time shared
205
+ with another point), so a vertical drag changed or lost a time. Decided
206
+ before any clamp, ``against`` or shared-time check.
207
+
208
+ The tolerance is pixel-scale, not float noise: a vertical drag in a real
209
+ browser on a TRANSFORM baseline (time 0..10, 2000 samples, ~800 px plot)
210
+ sent ``4.99998293759`` for a point at 5 — about 1.7e-6 of the span, far
211
+ above ``1e-9``. ``1e-5`` of the reference's time span is well under one
212
+ pixel at default zoom and still under one at 100x zoom, so no real drag
213
+ falls inside it; the float-noise term remains the floor, and is all
214
+ there is with no reference."""
215
+ if edit.x is None:
216
+ return None
217
+ tol = 1e-9 * max(1.0, abs(t))
218
+ reference = _reference(edit)
219
+ if reference is not None:
220
+ ref_time = reference[0]
221
+ tol = max(tol, 1e-5 * float(ref_time[-1] - ref_time[0]))
222
+ if abs(edit.x - t) <= tol:
223
+ return None
224
+ return float(edit.x)
225
+
226
+
227
+ def _refuse_other_baseline(edit: HandleEdit, leaf: dict, what: str) -> None:
228
+ """A leaf scored against a named baseline takes its bounds from that
229
+ baseline; the plot's reference is the primary one, so ``what`` would be
230
+ measured from the wrong curve."""
231
+ against = leaf.get("against", "primary")
232
+ if against != "primary":
233
+ raise _refuse(
234
+ edit,
235
+ f"this leaf scores against {against!r}; the plot's reference is "
236
+ f"the primary baseline, so {what} cannot be dragged",
237
+ )
238
+
239
+
240
+ def _time_span(
241
+ edit: HandleEdit, leaf: dict, reference: tuple[np.ndarray, np.ndarray] | None
242
+ ) -> tuple[float, float]:
243
+ """Where the forward geometry draws a time handle: the evaluator slices
244
+ the reference to the samples inside the leaf's ``window`` (inclusive),
245
+ so the span runs from the first reference sample ``>= window.start`` to
246
+ the last ``<= window.end``, either end absent meaning the reference's
247
+ own. With no reference, only the window's own ends apply."""
248
+ if reference is None:
249
+ start, end = _window_ends(edit, leaf)
250
+ lo = -math.inf if start is None else start
251
+ hi = math.inf if end is None else end
252
+ return lo, hi
253
+ inside, _ = _windowed(edit, leaf, reference)
254
+ return float(inside[0]), float(inside[-1])
255
+
256
+
257
+ def _window_ends(edit: HandleEdit, leaf: dict) -> tuple[float | None, float | None]:
258
+ window = leaf.get("window") or {}
259
+ start = window.get("start")
260
+ end = window.get("end")
261
+ if start is not None:
262
+ start = _authored_number(edit, start, "window.start")
263
+ if end is not None:
264
+ end = _authored_number(edit, end, "window.end")
265
+ return start, end
266
+
267
+
268
+ def _windowed(
269
+ edit: HandleEdit, leaf: dict, reference: tuple[np.ndarray, np.ndarray]
270
+ ) -> tuple[np.ndarray, np.ndarray]:
271
+ """``reference`` as the SCORER receives it: the evaluator slices it to
272
+ the samples inside the leaf's ``window`` (inclusive) first, so whatever
273
+ a mode reads off the reference itself — its final value, its events —
274
+ it reads off this. Refused where the window holds no sample."""
275
+ start, end = _window_ends(edit, leaf)
276
+ ref_time = np.asarray(reference[0], dtype=float)
277
+ mask = np.ones(len(ref_time), dtype=bool)
278
+ if start is not None:
279
+ mask &= ref_time >= start
280
+ if end is not None:
281
+ mask &= ref_time <= end
282
+ if not mask.any():
283
+ raise _refuse(edit, "the leaf's window holds no reference sample")
284
+ return ref_time[mask], np.asarray(reference[1], dtype=float)[mask]
285
+
286
+
287
+ def _measured_reference(
288
+ edit: HandleEdit, leaf: dict, what: str
289
+ ) -> tuple[np.ndarray, np.ndarray]:
290
+ """The primary baseline a tolerance side is measured from, as the
291
+ scorer receives it (:func:`_windowed`) — refused on a leaf scored
292
+ against another baseline, and where there is none."""
293
+ _refuse_other_baseline(edit, leaf, what)
294
+ reference = _reference(edit)
295
+ if reference is None:
296
+ raise _refuse(edit, f"{what} needs a reference to measure from")
297
+ return _windowed(edit, leaf, reference)
298
+
299
+
300
+ def _clamped_time(
301
+ edit: HandleEdit,
302
+ leaf: dict,
303
+ x: float,
304
+ reference: tuple[np.ndarray, np.ndarray] | None,
305
+ ) -> float:
306
+ """``x`` clamped into :func:`_time_span` over ``reference`` (``None``:
307
+ the window's own ends alone). Clamped, not refused: a real drag past the
308
+ span's edge lands on the edge, where the handle is still drawn."""
309
+ lo, hi = _time_span(edit, leaf, reference)
310
+ return float(min(max(float(x), lo), hi))
311
+
312
+
313
+ def _fraction(edit: HandleEdit, width: float, of: float, zero: str) -> float:
314
+ """``width`` as a fraction of ``|of|`` — a ``rel`` width back from a
315
+ dragged edge, the ONE rule for a tube's and a checkpoint's. Refused
316
+ (``zero`` says of what) where the magnitude is under ``types._EPS``,
317
+ not only at exactly 0: a checkpoint's target of 1e-20 divided, and the
318
+ drag wrote a tolerance of 5e18."""
319
+ if abs(of) < _EPS:
320
+ raise _refuse(edit, zero)
321
+ return width / abs(of)
322
+
323
+
324
+ # -- tube --------------------------------------------------------------------
325
+
326
+
327
+ def edit_tube_handle(edit: HandleEdit) -> dict:
328
+ """``tube/upper/i`` and ``tube/lower/i`` → ``tube_points[i]``.
329
+
330
+ The index addresses ``tube_points`` in authored order, as
331
+ ``algorithms._tube_geometry`` enumerates it. ``x`` sets the point's
332
+ ``time`` — unless it lies within a sub-pixel tolerance of the current
333
+ time, which is no time move at all (:func:`_time_move`) — clamped into the span
334
+ where the handle is drawn (the first reference sample
335
+ ``>= window.start`` to the last ``<= window.end``, either end absent
336
+ meaning the reference's own; with no reference, the window's ends
337
+ alone). ``y`` sets that side's width at the point's (new)
338
+ time ``t``, where ``ref(t)`` interpolates the reference:
339
+
340
+ * ``abs`` — the value itself; refused when upper would fall below the
341
+ point's lower, or lower above its upper (a missing side reads as
342
+ ``0.0``, as ``_interpolate_tube_widths`` reads it). Needs no reference.
343
+ * ``band`` — ``y − ref(t)`` for upper, ``ref(t) − y`` for lower.
344
+ * ``rel`` — the band width divided by ``|ref(t)|``; refused where the
345
+ reference is zero (below ``types._EPS``).
346
+
347
+ ``band`` and ``rel`` refuse a negative width, a missing reference, and a
348
+ leaf whose ``against`` is not ``"primary"`` (the view's reference is the
349
+ primary one); ``abs`` refuses only a time drag on such a leaf (the span
350
+ would come from the wrong reference). A point sharing its time with
351
+ another is refused, and so is a time drag landing on another point's
352
+ time: the forward interpolation reads the later point there, so the
353
+ handle would not follow the edit. A refusal after a clamp says so,
354
+ naming the clamped time. A width under ``tube_min_width`` is
355
+ written as dragged; the band then draws at the minimum, so the handle
356
+ lands on the band's real edge.
357
+ """
358
+ _check_position(edit)
359
+ _, side, segment = _parse(edit, "tube", 3)
360
+ if side not in ("upper", "lower"):
361
+ raise _unnamed(edit)
362
+ leaf = copy.deepcopy(edit.leaf)
363
+ if segment == "const": # before `_index`: it is no index
364
+ return _edit_constant_tube(edit, leaf, side)
365
+ i = _index(edit, segment, leaf.get("tube_points"))
366
+ rows: list = leaf["tube_points"]
367
+ point = rows[i]
368
+ mode = leaf.get("tube_width_mode")
369
+ if mode not in ("abs", "band", "rel"):
370
+ raise _refuse(edit, f"tube_width_mode {mode!r} has no draggable widths")
371
+ reference = _reference(edit)
372
+ if mode != "abs":
373
+ _refuse_other_baseline(edit, leaf, "its widths")
374
+ if reference is None:
375
+ raise _refuse(edit, f"a {mode} tube needs a reference to measure from")
376
+
377
+ t = _authored_number(edit, point.get("time"), f"tube_points[{i}].time")
378
+ _refuse_shared_time(edit, rows, i, t)
379
+ x = _time_move(edit, t)
380
+ if x is None:
381
+ return _set_tube_width(edit, leaf, point, i, side, mode, t, reference)
382
+ _refuse_other_baseline(edit, leaf, "its time")
383
+ x = _placed(edit, "x", x)
384
+ t = _written(_clamped_time(edit, leaf, x, reference))
385
+ try:
386
+ _refuse_shared_time(edit, rows, i, t)
387
+ point["time"] = t
388
+ return _set_tube_width(edit, leaf, point, i, side, mode, t, reference)
389
+ except HandleEditError as err:
390
+ if t == x:
391
+ raise
392
+ clamp = f"(x={_num(x)} was clamped to t={_num(t)})"
393
+ raise HandleEditError(
394
+ f"{err} {clamp}", reason=f"{err.reason} {clamp}"
395
+ ) from None
396
+
397
+
398
+ def _edit_constant_tube(edit: HandleEdit, leaf: dict, side: str) -> dict:
399
+ """``tube/upper/const`` and ``tube/lower/const`` — the grips of a tube
400
+ of constant width — → that side of ``tube_abs`` (``band``) or
401
+ ``tube_rel`` (``rel``), by :func:`_set_side`: ``y − ref(x)`` for upper,
402
+ ``ref(x) − y`` for lower, ``x`` being where the grip stands; in ``rel``
403
+ over ``|ref(x)|``, refused where the reference is zero there (below
404
+ ``types._EPS``, the control points' rule). A tube with control points
405
+ has no such grip: its widths are theirs."""
406
+ if leaf.get("tube_points"):
407
+ raise _unnamed(edit)
408
+ mode = leaf.get("tube_width_mode")
409
+ if mode not in ("band", "rel"):
410
+ raise _refuse(edit, f"tube_width_mode {mode!r} has no constant width")
411
+ ref_time, ref_values = _measured_reference(edit, leaf, "its width")
412
+ if edit.y is None or edit.x is None:
413
+ return leaf
414
+ ref_x = float(np.interp(float(edit.x), ref_time, ref_values))
415
+ width = edit.y - ref_x if side == "upper" else ref_x - edit.y
416
+ if mode == "rel":
417
+ zero = f"the reference is zero at t={_num(edit.x)}"
418
+ width = _fraction(edit, width, ref_x, zero)
419
+ key = "tube_abs" if mode == "band" else "tube_rel"
420
+ _set_side(edit, leaf, key, side, width, axis="y", fraction=mode == "rel")
421
+ return leaf
422
+
423
+
424
+ def _set_tube_width(
425
+ edit: HandleEdit,
426
+ leaf: dict,
427
+ point: dict,
428
+ i: int,
429
+ side: str,
430
+ mode: str,
431
+ t: float,
432
+ reference: tuple[np.ndarray, np.ndarray] | None,
433
+ ) -> dict:
434
+ """``edit.y`` written as ``point``'s ``side`` width at time ``t``."""
435
+ if edit.y is None:
436
+ return leaf
437
+ if mode == "abs":
438
+ y = _placed(edit, "y", edit.y)
439
+ if side == "upper":
440
+ lower = _authored_number(
441
+ edit, point.get("lower", 0.0), f"tube_points[{i}].lower"
442
+ )
443
+ if y < lower:
444
+ raise _refuse(
445
+ edit, f"upper {_num(y)} would fall below lower {_num(lower)}"
446
+ )
447
+ else:
448
+ upper = _authored_number(
449
+ edit, point.get("upper", 0.0), f"tube_points[{i}].upper"
450
+ )
451
+ if y > upper:
452
+ raise _refuse(
453
+ edit, f"lower {_num(y)} would rise above upper {_num(upper)}"
454
+ )
455
+ point[side] = y
456
+ return leaf
457
+ assert reference is not None # refused above for band / rel
458
+ ref_t = float(np.interp(t, reference[0], reference[1]))
459
+ width = edit.y - ref_t if side == "upper" else ref_t - edit.y
460
+ if mode == "band":
461
+ width = _placed(edit, "y", width)
462
+ if width < 0:
463
+ raise _refuse(edit, f"{side} width {_num(width)} would be negative")
464
+ if mode == "rel":
465
+ width = _fraction(edit, width, ref_t, f"the reference is zero at t={_num(t)}")
466
+ width = _placed(edit, "y", width, fraction=True)
467
+ point[side] = _written(width)
468
+ return leaf
469
+
470
+
471
+ def _refuse_shared_time(edit: HandleEdit, rows: list, i: int, t: float) -> None:
472
+ """Refuse when another control point sits at ``t``."""
473
+ for j, other in enumerate(rows):
474
+ if j != i and isinstance(other, dict) and other.get("time") == t:
475
+ a, b = sorted((i, j))
476
+ raise _refuse(
477
+ edit,
478
+ f"control points {a} and {b} share time {_num(t)}; "
479
+ "separate them in the table",
480
+ )
481
+
482
+
483
+ # -- range / scalar bounds -----------------------------------------------------
484
+
485
+
486
+ def edit_range_handle(edit: HandleEdit) -> dict:
487
+ """``range/min`` and ``range/max`` → ``min_value`` / ``max_value``, from
488
+ ``y``; ``x`` is ignored. Refused when the result has ``min_value >
489
+ max_value``; equal bounds are accepted."""
490
+ return _edit_bounds(edit, strict=False)
491
+
492
+
493
+ def edit_scalar_handle(edit: HandleEdit) -> dict:
494
+ """``scalar/min`` and ``scalar/max`` → ``min_value`` / ``max_value``, from
495
+ ``y``; ``x`` is ignored. Refused when the result has ``min_value >=
496
+ max_value`` — ``modes._validate_scalar_config`` requires them strictly
497
+ ordered.
498
+
499
+ ``scalar/upper`` and ``scalar/lower`` — the two lines of what the
500
+ baseline form allows — → that side of the leaf's ``tolerance``, from
501
+ ``y`` measured against the baseline's scalar (``edit.target``):
502
+ ``y − target`` for upper, ``target − y`` for lower, divided by
503
+ ``|target|`` in ``rel`` (refused where the baseline scalar is zero, or
504
+ where there is none). The side is written by :func:`_set_side`."""
505
+ segments = edit.local.split("/")
506
+ if len(segments) == 2 and segments[1] in ("upper", "lower"):
507
+ return _edit_scalar_side(edit, segments[1])
508
+ return _edit_bounds(edit, strict=True)
509
+
510
+
511
+ def _edit_scalar_side(edit: HandleEdit, side: str) -> dict:
512
+ _check_position(edit)
513
+ _parse(edit, "scalar", 2)
514
+ leaf = copy.deepcopy(edit.leaf)
515
+ if edit.y is None:
516
+ return leaf
517
+ if edit.target is None:
518
+ raise _refuse(edit, "there is no baseline scalar to measure from")
519
+ target = float(edit.target)
520
+ value = edit.y - target if side == "upper" else target - edit.y
521
+ if leaf.get("tolerance_mode", "abs") == "rel":
522
+ # The scorer's condition (`_compare_scalar`'s ``abs_fallback``):
523
+ # only at exactly 0 does it leave the fraction for the absolute
524
+ # test. A tiny baseline is scored, and drawn, as a fraction of it.
525
+ if abs(target) == 0.0:
526
+ raise _refuse(edit, "the baseline scalar is zero")
527
+ value = value / abs(target)
528
+ rel = leaf.get("tolerance_mode", "abs") == "rel"
529
+ _set_side(edit, leaf, "tolerance", side, value, axis="y", fraction=rel)
530
+ return leaf
531
+
532
+
533
+ def _set_side(
534
+ edit: HandleEdit,
535
+ holder: dict,
536
+ key: str,
537
+ side: str,
538
+ value: float,
539
+ *,
540
+ axis: str,
541
+ fraction: bool = False,
542
+ named: str | None = None,
543
+ crosses: str = "target",
544
+ ) -> None:
545
+ """Write ``value`` as the ``side`` (``"lower"`` / ``"upper"``) of the
546
+ tolerance ``holder[key]`` — the one rule for a dragged tolerance side.
547
+
548
+ The shape in the spec is the link state: where ``holder[key]`` is
549
+ absent or a number the result is the NUMBER ``value`` (linked: both
550
+ sides move); where it is a pair only ``side`` changes, and it stays a
551
+ pair. A negative side — one dragged across its target — is refused, in
552
+ the side's own name as its editor's fields say it (``named``: a time
553
+ side is ``before`` or ``early``, not ``lower``) and naming what it
554
+ would cross (``crosses``); a zero one is not. A tolerance the scorer
555
+ would refuse is refused in the scorer's words, and one that is no
556
+ finite number — a rel side over a tiny baseline overflows — is refused
557
+ before it is written.
558
+
559
+ ``value`` is a side the pointer placed, measured along ``axis`` — a
560
+ ``fraction`` of its target where the tolerance is relative: it is
561
+ snapped (:func:`_placed`) before anything here reads it."""
562
+ if not math.isfinite(value):
563
+ raise _refuse(edit, "the tolerance would not be a finite number")
564
+ value = _placed(edit, axis, value, fraction=fraction)
565
+ if value < 0:
566
+ raise _refuse(edit, f"the {named or side} side would cross its {crosses}")
567
+ current = holder.get(key)
568
+ if current is None:
569
+ holder[key] = _written(value)
570
+ return
571
+ try:
572
+ sides = parse_sides(current, key)
573
+ except ValueError as err:
574
+ raise _refuse(edit, str(err)) from None
575
+ if isinstance(current, dict):
576
+ pair = {"lower": sides.lower, "upper": sides.upper}
577
+ pair[side] = _written(value)
578
+ holder[key] = pair
579
+ else:
580
+ holder[key] = _written(value)
581
+
582
+
583
+ def _edit_bounds(edit: HandleEdit, *, strict: bool) -> dict:
584
+ """The shared rule; ``<metric>`` in the local id is the leaf's own
585
+ metric."""
586
+ _check_position(edit)
587
+ family = edit.leaf.get("metric")
588
+ if not isinstance(family, str):
589
+ raise _unnamed(edit)
590
+ _, which = _parse(edit, family, 2)
591
+ if which not in ("min", "max"):
592
+ raise _unnamed(edit)
593
+ leaf = copy.deepcopy(edit.leaf)
594
+ if edit.y is None:
595
+ return leaf
596
+ leaf[f"{which}_value"] = _placed(edit, "y", edit.y)
597
+ lo, hi = leaf.get("min_value"), leaf.get("max_value")
598
+ if lo is not None and hi is not None:
599
+ lo = _authored_number(edit, lo, "min_value")
600
+ hi = _authored_number(edit, hi, "max_value")
601
+ if lo > hi:
602
+ raise _refuse(
603
+ edit, f"min_value {_num(lo)} would exceed max_value {_num(hi)}"
604
+ )
605
+ if strict and lo == hi:
606
+ raise _refuse(
607
+ edit,
608
+ f"min_value {_num(lo)} would equal max_value {_num(hi)}; "
609
+ "a scalar leaf needs min_value < max_value",
610
+ )
611
+ return leaf
612
+
613
+
614
+ # -- points --------------------------------------------------------------------
615
+
616
+
617
+ def edit_points_handle(edit: HandleEdit) -> dict:
618
+ """``points/target/i`` → ``points[i]``.
619
+
620
+ ``x`` sets ``time`` — unless it lies within a sub-pixel tolerance of the
621
+ current time (:func:`_time_move`) — and is refused on a leaf whose ``against``
622
+ is not ``"primary"``. The time is clamped: for a checkpoint that follows
623
+ the baseline, into the span where it has a target (the tube rule: the
624
+ first reference sample ``>= window.start`` to the last ``<=
625
+ window.end``); for one with an explicit ``value``, into the window's
626
+ own ends; and for either, into the actual trajectory's span
627
+ (``edit.span``) besides — the forward code scores and draws a
628
+ checkpoint only where ``t ± time_tolerance`` meets that trajectory, so
629
+ a ring let go a little past the run's end (the time axis runs past the
630
+ data; a run shorter than its baseline ends well inside it) read "not
631
+ scored" and was no longer drawn. So a checkpoint is held to where its
632
+ own span and the run's OVERLAP — a single time where they only touch;
633
+ where they do not (a run that never meets its baseline) to its own
634
+ alone, as with no ``span`` (a caller that has no actual series). A
635
+ checkpoint whose ``time``
636
+ is absent or null — the trace-end sentinel — keeps it: ``x`` is ignored,
637
+ a time is typed in the table. ``y`` sets ``value`` only when the point
638
+ has a non-null one — ``point.get("value") is not None``, the forward
639
+ code's test in ``algorithms._compare_points`` — so a checkpoint that
640
+ follows the baseline (``value`` absent or null) moves in time only and a
641
+ drag never makes it baseline-free. ``points/target/final`` (the final-value check
642
+ of an empty list) is refused: there is no declared point to move.
643
+
644
+ ``points/upper|lower|before|after/i`` and ``points/upper|lower/final``
645
+ are the sides of a tolerance: :func:`_edit_points_side`."""
646
+ _check_position(edit)
647
+ _, role, segment = _parse(edit, "points", 3)
648
+ if role in _POINT_SIDES:
649
+ return _edit_points_side(edit, role, segment)
650
+ if role != "target":
651
+ raise _unnamed(edit)
652
+ if segment == "final":
653
+ raise _refuse(edit, "there is no declared point to move (final-value check)")
654
+ leaf = copy.deepcopy(edit.leaf)
655
+ i = _index(edit, segment, leaf.get("points"))
656
+ point = leaf["points"][i]
657
+ time = point.get("time")
658
+ x = None
659
+ if time is not None:
660
+ x = _time_move(edit, _authored_number(edit, time, f"points[{i}].time"))
661
+ if x is not None:
662
+ _refuse_other_baseline(edit, leaf, "its time")
663
+ follows = point.get("value") is None
664
+ point["time"] = _checkpoint_time(edit, leaf, _placed(edit, "x", x), follows)
665
+ if edit.y is not None and point.get("value") is not None:
666
+ point["value"] = _placed(edit, "y", edit.y)
667
+ return leaf
668
+
669
+
670
+ def _checkpoint_time(
671
+ edit: HandleEdit, leaf: dict, x: float, follows_baseline: bool
672
+ ) -> float:
673
+ """``x`` as a checkpoint's time: held to where the checkpoint is scored
674
+ and drawn (:func:`edit_points_handle` says where), then written."""
675
+ reference = _reference(edit) if follows_baseline else None
676
+ lo, hi = _time_span(edit, leaf, reference)
677
+ t = float(min(max(float(x), lo), hi))
678
+ if edit.span is not None:
679
+ first, last = edit.span
680
+ if max(lo, first) <= min(hi, last):
681
+ t = min(max(t, first), last)
682
+ return _written(t)
683
+
684
+
685
+ #: A point's grips: the side each writes, and of which tolerance.
686
+ _POINT_SIDES = {
687
+ "upper": ("upper", "tolerance"),
688
+ "lower": ("lower", "tolerance"),
689
+ "before": ("lower", "time_tolerance"),
690
+ "after": ("upper", "time_tolerance"),
691
+ }
692
+
693
+
694
+ def _edit_points_side(edit: HandleEdit, role: str, segment: str) -> dict:
695
+ """One side of a checkpoint's tolerance, by :func:`_set_side`.
696
+
697
+ ``upper`` / ``lower`` → the value tolerance the row is held to: its own
698
+ ``tolerance`` where the row has one (a ``null`` is an absent key, as
699
+ the scorer reads it), else the CRITERION's — so every row that defers
700
+ to it moves with it. That holds while every deferring row reads it in
701
+ the dragged row's ``tolerance_mode`` (:func:`_deferring_modes`). Where
702
+ they differ — a ``rel`` row and an ``abs`` row both defer — the number
703
+ dragged means a fraction to one and signal units to the other (0.634
704
+ of a target became ±0.634), so the dragged ROW takes a tolerance of
705
+ its own, seeded from the criterion's, and the criterion's stays what
706
+ the other rows read. From ``y``: ``y − target`` for upper, ``target −
707
+ y`` for lower, over ``|target|`` where the row's ``tolerance_mode`` is
708
+ ``rel`` (refused at a target of zero — below ``types._EPS``, the
709
+ tube's rule: :func:`_fraction`). The target is the row's
710
+ ``value``, else the reference at the row's time — refused on a leaf
711
+ scored against another baseline, and with no reference.
712
+
713
+ ``before`` / ``after`` → the row's ``time_tolerance``, from ``x``:
714
+ ``time − x`` and ``x − time``. Only a row that holds one has these.
715
+
716
+ ``upper/final`` / ``lower/final`` — the final-value check of an empty
717
+ list — → the criterion's ``tolerance``, measured from the reference's
718
+ final value AS WINDOWED (the scorer's)."""
719
+ side, key = _POINT_SIDES[role]
720
+ leaf = copy.deepcopy(edit.leaf)
721
+ if segment == "final":
722
+ if key != "tolerance":
723
+ raise _unnamed(edit)
724
+ _, ref_values = _measured_reference(edit, leaf, "the final value's tolerance")
725
+ if edit.y is None:
726
+ return leaf
727
+ target = float(ref_values[-1])
728
+ value = edit.y - target if side == "upper" else target - edit.y
729
+ _set_side(edit, leaf, "tolerance", side, value, axis="y")
730
+ return leaf
731
+ i = _index(edit, segment, leaf.get("points"))
732
+ point = leaf["points"][i]
733
+ if key == "time_tolerance":
734
+ if point.get("time_tolerance") is None:
735
+ raise _refuse(edit, f"points[{i}] holds no time_tolerance")
736
+ if edit.x is None:
737
+ return leaf
738
+ t = _point_time(edit, leaf, point, i)
739
+ value = t - edit.x if side == "lower" else edit.x - t
740
+ _set_side(
741
+ edit,
742
+ point,
743
+ "time_tolerance",
744
+ side,
745
+ value,
746
+ axis="x",
747
+ named=role,
748
+ crosses="point's time",
749
+ )
750
+ return leaf
751
+ if point.get("value") is not None:
752
+ target = _authored_number(edit, point["value"], f"points[{i}].value")
753
+ else:
754
+ ref_time, ref_values = _measured_reference(edit, leaf, "its tolerance")
755
+ t = _point_time(edit, leaf, point, i)
756
+ target = float(np.interp(t, ref_time, ref_values))
757
+ if edit.y is None:
758
+ return leaf
759
+ value = edit.y - target if side == "upper" else target - edit.y
760
+ rel = point.get("tolerance_mode", "abs") == "rel"
761
+ if rel:
762
+ # The scorer's arithmetic, inverted: each side is that fraction of
763
+ # |target| — by the tube's rule, refused under `_EPS`.
764
+ value = _fraction(edit, value, target, "the target is zero")
765
+ holder = point
766
+ if point.get("tolerance") is None:
767
+ if _defers(leaf, point):
768
+ holder = leaf
769
+ elif leaf.get("tolerance") is not None:
770
+ # It stops deferring: from the tolerance it was held to, so a
771
+ # pair keeps its other side where the box's other edge stands.
772
+ point["tolerance"] = copy.deepcopy(leaf["tolerance"])
773
+ _set_side(edit, holder, "tolerance", side, value, axis="y", fraction=rel)
774
+ return leaf
775
+
776
+
777
+ def _defers(leaf: dict, point: dict) -> bool:
778
+ """Whether a drag on ``point``'s value tolerance writes the
779
+ CRITERION's: the row holds none of its own, and every row that defers
780
+ reads the criterion's in this row's ``tolerance_mode``
781
+ (:func:`_edit_points_side` says why)."""
782
+ return point.get("tolerance") is None and _deferring_modes(leaf) == {
783
+ point.get("tolerance_mode", "abs")
784
+ }
785
+
786
+
787
+ def _deferring_modes(leaf: dict) -> set[str]:
788
+ """The ``tolerance_mode`` of every row of ``leaf`` that holds no
789
+ ``tolerance`` of its own (a ``null`` is an absent key): the modes the
790
+ CRITERION's tolerance is read in — each deferring row reads the one
791
+ number or pair in its own."""
792
+ return {
793
+ row.get("tolerance_mode", "abs")
794
+ for row in leaf.get("points") or []
795
+ if isinstance(row, dict) and row.get("tolerance") is None
796
+ }
797
+
798
+
799
+ def _point_time(edit: HandleEdit, leaf: dict, point: dict, i: int) -> float:
800
+ """Checkpoint ``i``'s time: its own, or — absent or null, the trace-end
801
+ sentinel — the last time of the reference as the scorer receives it."""
802
+ time = point.get("time")
803
+ if time is not None:
804
+ return _authored_number(edit, time, f"points[{i}].time")
805
+ ref_time, _ = _measured_reference(edit, leaf, "the trace's end")
806
+ return float(ref_time[-1])
807
+
808
+
809
+ # -- event-timing --------------------------------------------------------------
810
+
811
+
812
+ def edit_events_handle(edit: HandleEdit) -> dict:
813
+ """``events/early/i`` and ``events/late/i`` — the edges of event ``i``'s
814
+ window — → that side of the tolerance the event is held to, by
815
+ :func:`_set_side`, from ``x``: ``time − x`` for early (``lower``),
816
+ ``x − time`` for late (``upper``); ``y`` is ignored.
817
+
818
+ A DECLARED event (the leaf holds ``events``): its time is its row's,
819
+ and the holder its row's ``tolerance`` where the row has one, else the
820
+ criterion's ``time_tolerance``. AUTO-DETECTED events (no ``events``):
821
+ event ``i`` is the ``i``-th duplicated time of the reference as the
822
+ scorer receives it — windowed — found by the scorer's own detection;
823
+ the holder is the criterion's ``time_tolerance``, so every window
824
+ moves. Refused on a leaf scored against another baseline, and with no
825
+ reference. An event's own time has no handle."""
826
+ _check_position(edit)
827
+ _, role, segment = _parse(edit, "events", 3)
828
+ if role not in ("early", "late"):
829
+ raise _unnamed(edit)
830
+ side = "lower" if role == "early" else "upper"
831
+ leaf = copy.deepcopy(edit.leaf)
832
+ holder = leaf
833
+ key = "time_tolerance"
834
+ if leaf.get("events") is not None:
835
+ i = _index(edit, segment, leaf["events"])
836
+ event = leaf["events"][i]
837
+ t = _authored_number(edit, event.get("time"), f"events[{i}].time")
838
+ if event.get("tolerance") is not None:
839
+ holder, key = event, "tolerance"
840
+ else:
841
+ ref_time, _ = _measured_reference(edit, leaf, "an event's window")
842
+ events = [float(ref_time[b[0]]) for b in _find_event_boundaries(ref_time)]
843
+ if not (segment.isascii() and segment.isdigit()):
844
+ raise _unnamed(edit)
845
+ i = int(segment)
846
+ if i >= len(events):
847
+ raise _refuse(edit, f"there is no event {i} to move")
848
+ t = events[i]
849
+ if edit.x is None:
850
+ return leaf
851
+ value = t - edit.x if side == "lower" else edit.x - t
852
+ _set_side(
853
+ edit, holder, key, side, value, axis="x", named=role, crosses="event's time"
854
+ )
855
+ return leaf
856
+
857
+
858
+ # -- dominant-frequency --------------------------------------------------------
859
+
860
+
861
+ def edit_peaks_handle(edit: HandleEdit) -> dict:
862
+ """``peaks/declared/i`` → ``peaks[i]["freq"]`` from ``x``, refused
863
+ ``<= 0``; ``y`` (the spectrum's amplitude) is ignored. An ``abs``
864
+ window keeps its width in Hz and moves with its ring: a ring dragged
865
+ to where that window's lower edge (:func:`~.algorithms._peak_window`,
866
+ the scorer's) is at 0 Hz or below is refused as the grips are — 5 Hz
867
+ with 3 Hz either side, dragged to 2 Hz, was a window from −1 Hz. Where
868
+ that edge was at 0 Hz or below BEFORE the drag (a window written by
869
+ hand: 5 Hz with 9 Hz below it) the ring did not bring it there, and the
870
+ refusal says "already", in the upper grip's words for the same state.
871
+ A ``rel`` window is a fraction of its frequency and no ring brings it
872
+ there.
873
+
874
+ ``peaks/lower/i`` and ``peaks/upper/i`` — the edges of the peak's
875
+ window — → that side of ``peaks[i]["tolerance"]``, by
876
+ :func:`_set_side` (a row without one gets it on its first drag): in
877
+ ``rel`` (the default) ``1 − x/freq`` and ``x/freq − 1``, refused at a
878
+ declared frequency of zero; in ``abs`` ``freq − x`` and ``x − freq``.
879
+ A write that would leave the window's LOWER edge at 0 Hz or below — a
880
+ lower side of 1 or more in ``rel``, of the frequency or more in
881
+ ``abs`` — is refused, whichever grip moved: no window reaches 0 Hz. A
882
+ lower edge dragged there is one; so is the UPPER edge of a row holding
883
+ a number, dragged past twice the frequency, since the number is both
884
+ sides (2.61 was written to both). The upper grip of a PAIR writes the
885
+ upper side alone: where the lower one reaches 0 Hz it did so before the
886
+ drag, and the refusal says that — "already", not "would"."""
887
+ _check_position(edit)
888
+ _, role, segment = _parse(edit, "peaks", 3)
889
+ if role not in ("declared", "lower", "upper"):
890
+ raise _unnamed(edit)
891
+ leaf = copy.deepcopy(edit.leaf)
892
+ i = _index(edit, segment, leaf.get("peaks"))
893
+ if role != "declared":
894
+ peak = leaf["peaks"][i]
895
+ if edit.x is None:
896
+ return leaf
897
+ freq = _authored_number(edit, peak.get("freq"), f"peaks[{i}].freq")
898
+ value = freq - edit.x if role == "lower" else edit.x - freq
899
+ rel = peak.get("tolerance_mode", "rel") == "rel"
900
+ if rel:
901
+ if freq == 0.0:
902
+ raise _refuse(edit, "the declared frequency is zero")
903
+ value = value / abs(freq)
904
+ # A pair's upper grip leaves its lower side as it was.
905
+ kept = role == "upper" and isinstance(peak.get("tolerance"), dict)
906
+ _set_side(edit, peak, "tolerance", role, value, axis="x", fraction=rel)
907
+ # `_set_side` wrote what it parsed: a number or a whole pair.
908
+ lower = parse_sides(peak["tolerance"], "tolerance").lower
909
+ if lower >= (1.0 if rel else freq):
910
+ if kept:
911
+ raise _refuse(edit, "the window's lower side already reaches 0 Hz")
912
+ raise _refuse(edit, "the window would reach 0 Hz")
913
+ return leaf
914
+ if edit.x is not None:
915
+ x = _placed(edit, "x", edit.x)
916
+ if x <= 0:
917
+ raise _refuse(edit, f"frequency {_num(x)} must be positive")
918
+ peak = leaf["peaks"][i]
919
+ was = peak.get("freq")
920
+ peak["freq"] = x
921
+ if peak.get("tolerance_mode", "rel") != "rel":
922
+ try:
923
+ low, _high, sides, _mode = _peak_window(peak, i)
924
+ except ValueError as err:
925
+ raise _refuse(edit, str(err)) from None
926
+ if low <= 0:
927
+ # The same width about the frequency it had: where the
928
+ # window's lower edge stood before the ring moved.
929
+ if _is_number(was) and was - sides.lower <= 0:
930
+ raise _refuse(edit, "the window's lower side already reaches 0 Hz")
931
+ raise _refuse(edit, "the window would reach 0 Hz")
932
+ return leaf
933
+
934
+
935
+ # -- which field a handle writes (D149) ----------------------------------------
936
+
937
+
938
+ def _row_index(segment: str) -> int | None:
939
+ return int(segment) if segment.isascii() and segment.isdigit() else None
940
+
941
+
942
+ def handle_fields(leaf: dict, local: str) -> dict[str, tuple[str | int, ...]]:
943
+ """Which field of ``leaf`` a move of the handle ``local`` writes, per
944
+ axis it moves on: ``{"x": ("points", 3, "time"), "y": ("points", 3,
945
+ "value")}`` for a checkpoint's ring. A field is a path of keys into the
946
+ raw leaf; one side of a tolerance ends in ``"lower"`` or ``"upper"``
947
+ whether the file holds a number or a pair. It is the editors' own
948
+ choice of holder, said as data — a checkpoint's arrow names the
949
+ criterion's ``tolerance`` while its row defers to it, an event's the
950
+ criterion's ``time_tolerance`` — so "arm a field, then click the plot"
951
+ finds the handle that sets a field without a second rule. ``{}`` for a
952
+ local no editor names."""
953
+ parts = local.split("/")
954
+ if len(parts) == 2:
955
+ family, which = parts
956
+ if which in ("min", "max"):
957
+ return {"y": (f"{which}_value",)}
958
+ if family == "scalar" and which in ("upper", "lower"):
959
+ return {"y": ("tolerance", which)}
960
+ return {}
961
+ if len(parts) != 3:
962
+ return {}
963
+ family, role, key = parts
964
+ i = _row_index(key)
965
+ if family == "tube" and role in ("upper", "lower"):
966
+ if key == "const":
967
+ band = leaf.get("tube_width_mode") == "band"
968
+ return {"y": ("tube_abs" if band else "tube_rel", role)}
969
+ if i is not None:
970
+ return {"x": ("tube_points", i, "time"), "y": ("tube_points", i, role)}
971
+ if family == "points" and role == "target" and i is not None:
972
+ return {"x": ("points", i, "time"), "y": ("points", i, "value")}
973
+ if family == "points" and role in _POINT_SIDES:
974
+ side, tolerance = _POINT_SIDES[role]
975
+ if tolerance == "time_tolerance":
976
+ return {} if i is None else {"x": ("points", i, tolerance, side)}
977
+ rows = leaf.get("points") or []
978
+ if i is not None and i < len(rows) and not _defers(leaf, rows[i]):
979
+ return {"y": ("points", i, tolerance, side)}
980
+ return {"y": (tolerance, side)} if key == "final" or i is not None else {}
981
+ if family == "events" and role in ("early", "late") and i is not None:
982
+ side = "lower" if role == "early" else "upper"
983
+ events = leaf.get("events") or []
984
+ own = i < len(events) and isinstance(events[i], dict)
985
+ if own and events[i].get("tolerance") is not None:
986
+ return {"x": ("events", i, "tolerance", side)}
987
+ return {"x": ("time_tolerance", side)}
988
+ if family == "peaks" and i is not None:
989
+ if role == "declared":
990
+ return {"x": ("peaks", i, "freq")}
991
+ if role in ("lower", "upper"):
992
+ return {"x": ("peaks", i, "tolerance", role)}
993
+ return {}
994
+
995
+
996
+ # -- a Ctrl+click adds a row that settles on the data (D149) -------------------
997
+
998
+
999
+ @dataclass(frozen=True, kw_only=True)
1000
+ class RowPlace:
1001
+ """One Ctrl+click on the plot, as a placer receives it. A placer returns
1002
+ the leaf with one more row in its table — the input is not mutated —
1003
+ and refuses with :class:`HandleEditError` where the click names no
1004
+ place for one."""
1005
+
1006
+ #: The raw leaf, as ``metrics`` holds it; never mutated.
1007
+ leaf: dict
1008
+ #: Per list field of the metric, the row the editor's ``Add row`` would
1009
+ #: append to it now: every field of the new row the click does not set.
1010
+ blanks: Mapping[str, dict]
1011
+ #: The panel that was clicked — ``"time"`` or ``"spectrum"`` — and the
1012
+ #: click in that panel's own axes.
1013
+ panel: str
1014
+ x: float
1015
+ y: float
1016
+ #: The primary baseline and the run, ``(time, values)``, full
1017
+ #: resolution and unwindowed; ``None`` where there is none.
1018
+ reference: tuple[np.ndarray, np.ndarray] | None = None
1019
+ actual: tuple[np.ndarray, np.ndarray] | None = None
1020
+ #: What the plot draws for this leaf: the geometry it was scored with.
1021
+ geometry: Sequence[Primitive] = ()
1022
+ #: As :attr:`HandleEdit.visible` and :attr:`HandleEdit.span`.
1023
+ visible: tuple[float | None, float | None] = (None, None)
1024
+ span: tuple[float, float] | None = None
1025
+
1026
+
1027
+ def _place_edit(place: RowPlace, panel: str, noun: str) -> HandleEdit:
1028
+ """``place`` as the editors' helpers read a drag — the clamps and the
1029
+ refusals of a row added by a click are a dragged row's — refused where
1030
+ the click is not on ``panel``, the one a row of this kind is drawn on."""
1031
+ edit = HandleEdit(
1032
+ leaf=place.leaf,
1033
+ local=f"new {noun}",
1034
+ x=place.x,
1035
+ y=place.y,
1036
+ reference=place.reference,
1037
+ span=place.span,
1038
+ visible=place.visible,
1039
+ )
1040
+ _check_position(edit)
1041
+ if place.panel != panel:
1042
+ where = "spectrum" if panel == "spectrum" else "time plot"
1043
+ raise _refuse(edit, f"{noun} is added by a Ctrl+click on the {where}")
1044
+ return edit
1045
+
1046
+
1047
+ def _refuse_standing(
1048
+ edit: HandleEdit, rows: list, key: str, at: float, noun: str, where: str
1049
+ ) -> None:
1050
+ """Refuse a row placed ON one that stands there — the one rule for all
1051
+ four tables: a row of the table already holds ``key`` within ONE snap
1052
+ step of ``at`` (the step of what the x axis showed; with no span, the
1053
+ same number), or, for a peak, ``at`` lies inside that row's own window.
1054
+ Two Ctrl+clicks on one spot left two rows, and a click a few pixels
1055
+ beside a checkpoint a second one a step away. ``where`` says the place
1056
+ (``"t={}"``); the sentence names the row as the table numbers it."""
1057
+ step = snap_step(edit.visible[0]) or 0.0
1058
+ for i, row in enumerate(rows):
1059
+ if not isinstance(row, dict) or not _is_number(row.get(key)):
1060
+ continue
1061
+ low = high = float(row[key])
1062
+ if key == "freq":
1063
+ try:
1064
+ low, high, _sides, _mode = _peak_window(row, i)
1065
+ except ValueError:
1066
+ pass # a window that cannot be read: the frequency alone
1067
+ else:
1068
+ low, high = low - step * (1 + 1e-9), high + step * (1 + 1e-9)
1069
+ if low <= at <= high:
1070
+ stands = where.format(_num(row[key]))
1071
+ raise _refuse(edit, f"{noun} already stands at {stands} (row {i + 1})")
1072
+
1073
+
1074
+ def place_points_row(place: RowPlace) -> dict:
1075
+ """A checkpoint at the clicked TIME — snapped, and held to where a
1076
+ checkpoint is scored and drawn, as a dragged one is — with no value of
1077
+ its own: it follows the baseline there. Where there is no baseline to
1078
+ follow it takes the RUN's value at that time as its ``value``, as the
1079
+ data has it (not snapped: it was not placed, it was read). Refused on a
1080
+ leaf scored against another baseline, as a time drag is."""
1081
+ edit = _place_edit(place, "time", "a point")
1082
+ leaf = copy.deepcopy(place.leaf)
1083
+ _refuse_other_baseline(edit, leaf, "its time")
1084
+ row = copy.deepcopy(dict(place.blanks["points"]))
1085
+ follows = _reference(edit) is not None
1086
+ row["time"] = _checkpoint_time(edit, leaf, _placed(edit, "x", place.x), follows)
1087
+ rows = list(leaf.get("points") or [])
1088
+ _refuse_standing(edit, rows, "time", row["time"], "a checkpoint", "t={}")
1089
+ row.pop("value", None)
1090
+ if not follows:
1091
+ actual = place.actual
1092
+ if actual is None or len(actual[0]) == 0:
1093
+ raise _refuse(edit, "there is no signal to place a point on")
1094
+ row["value"] = _written(float(np.interp(row["time"], actual[0], actual[1])))
1095
+ leaf["points"] = [*rows, row]
1096
+ return leaf
1097
+
1098
+
1099
+ def place_events_row(place: RowPlace) -> dict:
1100
+ """A declared event at the run's event NEAREST the click — the actual
1101
+ events the plot draws for the leaf — at that event's own time. Refused
1102
+ where the run has none, and where a declared event already stands
1103
+ there (:func:`_refuse_standing`): a second row on it would say nothing
1104
+ more."""
1105
+ edit = _place_edit(place, "time", "an event")
1106
+ leaf = copy.deepcopy(place.leaf)
1107
+ actual = [
1108
+ p.time
1109
+ for p in place.geometry
1110
+ if isinstance(p, VLine) and p.kind == "act" and p.panel == "time"
1111
+ ]
1112
+ if not actual:
1113
+ raise _refuse(edit, "the run has no event to place one on")
1114
+ t = _written(min(actual, key=lambda at: abs(at - place.x)))
1115
+ rows = list(leaf.get("events") or [])
1116
+ _refuse_standing(edit, rows, "time", t, "an event", "t={}")
1117
+ row = copy.deepcopy(dict(place.blanks["events"]))
1118
+ row["time"] = t
1119
+ leaf["events"] = [*rows, row]
1120
+ return leaf
1121
+
1122
+
1123
+ def place_peaks_row(place: RowPlace) -> dict:
1124
+ """A declared peak at the peak of the run's spectrum NEAREST the click
1125
+ — a local maximum of the spectrum the plot draws, above the noise floor
1126
+ the scorer matches peaks over — at that peak's own frequency. A click
1127
+ on the time plot is refused: a frequency is read off the spectrum."""
1128
+ edit = _place_edit(place, "spectrum", "a peak")
1129
+ leaf = copy.deepcopy(place.leaf)
1130
+ spectrum = next(
1131
+ (
1132
+ p
1133
+ for p in place.geometry
1134
+ if isinstance(p, Series) and p.panel == "spectrum" and p.kind == "act"
1135
+ ),
1136
+ None,
1137
+ )
1138
+ peaks: list[float] = []
1139
+ if spectrum is not None and len(spectrum.x) >= 3:
1140
+ freqs = np.asarray(spectrum.x, dtype=float)
1141
+ amps = np.asarray(spectrum.y, dtype=float)
1142
+ inner = np.arange(1, len(amps) - 1)
1143
+ tops = inner[
1144
+ (amps[inner] > amps[inner - 1])
1145
+ & (amps[inner] > amps[inner + 1])
1146
+ & (amps[inner] >= _PEAK_MIN_REL_AMPLITUDE * float(np.max(amps)))
1147
+ ]
1148
+ peaks = [float(freqs[k]) for k in tops if freqs[k] > 0]
1149
+ if not peaks:
1150
+ raise _refuse(edit, "the run's spectrum has no peak to place one on")
1151
+ freq = _written(min(peaks, key=lambda f: abs(f - place.x)))
1152
+ rows = list(leaf.get("peaks") or [])
1153
+ _refuse_standing(edit, rows, "freq", freq, "a peak", "{} Hz")
1154
+ row = copy.deepcopy(dict(place.blanks["peaks"]))
1155
+ row["freq"] = freq
1156
+ leaf["peaks"] = [*rows, row]
1157
+ return leaf
1158
+
1159
+
1160
+ def place_tube_row(place: RowPlace) -> dict:
1161
+ """A control point at the clicked time — snapped, and clamped into the
1162
+ span a control point is drawn in — whose widths are the tube's own
1163
+ THERE, interpolated as the scorer interpolates them: adding it changes
1164
+ nothing until it is dragged. A tube of constant width has no rows, and
1165
+ a click adds none: the leaf comes back as it is. Refused where a
1166
+ control point already stands at that time, and — like a time drag — on
1167
+ a leaf scored against another baseline."""
1168
+ leaf = copy.deepcopy(place.leaf)
1169
+ rows = leaf.get("tube_points")
1170
+ if not rows:
1171
+ return leaf
1172
+ edit = _place_edit(place, "time", "a control point")
1173
+ _refuse_other_baseline(edit, leaf, "its time")
1174
+ x = _placed(edit, "x", place.x)
1175
+ t = _written(_clamped_time(edit, leaf, x, _reference(edit)))
1176
+ _refuse_standing(edit, rows, "time", t, "a control point", "t={}")
1177
+ try:
1178
+ upper, lower = _interpolate_tube_widths(
1179
+ np.array([t]), rows, leaf.get("tube_interpolation", "linear")
1180
+ )
1181
+ except (KeyError, TypeError, ValueError) as err:
1182
+ raise _refuse(
1183
+ edit, f"the tube's control points cannot be read: {err}"
1184
+ ) from None
1185
+ row = copy.deepcopy(dict(place.blanks["tube_points"]))
1186
+ row.update(time=t, upper=_written(upper[0]), lower=_written(lower[0]))
1187
+ leaf["tube_points"] = [*rows, row]
1188
+ return leaf
1189
+
1190
+
1191
+ def tube_row_noun(leaf: dict) -> str | None:
1192
+ """What a Ctrl+click adds to a tube: a control point, where it has
1193
+ any; nothing to one of constant width."""
1194
+ return "a control point" if leaf.get("tube_points") else None