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,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
|