qplotter 1.6.0__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.
- qplot/__init__.py +46 -0
- qplot/__main__.py +232 -0
- qplot/_auxiliary_paths.py +67 -0
- qplot/_build_info.json +1 -0
- qplot/_metadata.py +75 -0
- qplot/_repair.py +14 -0
- qplot/_shutdown_supervisor.py +4049 -0
- qplot/_version.py +12 -0
- qplot/_windows_shutdown_job.py +1166 -0
- qplot/configuration/__init__.py +3 -0
- qplot/configuration/config.py +578 -0
- qplot/configuration/config_schema.json +357 -0
- qplot/configuration/fonts.py +3 -0
- qplot/configuration/scripts.py +255 -0
- qplot/configuration/themes/__init__.py +13 -0
- qplot/configuration/themes/_base.py +592 -0
- qplot/configuration/themes/blank.py +17 -0
- qplot/configuration/themes/dark.py +54 -0
- qplot/configuration/themes/light.py +54 -0
- qplot/datahandling/LoadFromDB.py +224 -0
- qplot/datahandling/__init__.py +47 -0
- qplot/datahandling/_trusted_live_helper.py +812 -0
- qplot/datahandling/_trusted_live_protocol.py +1745 -0
- qplot/datahandling/database.py +2340 -0
- qplot/datahandling/dimensions.py +43 -0
- qplot/datahandling/file_identity.py +461 -0
- qplot/datahandling/parameter_data.py +149 -0
- qplot/datahandling/plot_progress.py +17 -0
- qplot/datahandling/qcodes_cache.py +277 -0
- qplot/datahandling/qcodes_compat.py +14 -0
- qplot/datahandling/readSQL.py +2442 -0
- qplot/datahandling/readonly.py +2566 -0
- qplot/datahandling/source_activity.py +21 -0
- qplot/datahandling/trusted_array_preview.py +148 -0
- qplot/datahandling/trusted_derived_cache.py +1605 -0
- qplot/datahandling/trusted_derived_rendering.py +876 -0
- qplot/datahandling/trusted_heatmap.py +246 -0
- qplot/datahandling/trusted_live.py +2808 -0
- qplot/datahandling/trusted_live_queries.py +5907 -0
- qplot/datahandling/trusted_live_service.py +1919 -0
- qplot/datahandling/trusted_live_supervisor.py +2161 -0
- qplot/datahandling/trusted_plot.py +283 -0
- qplot/datahandling/trusted_presentation.py +1065 -0
- qplot/datahandling/trusted_snapshot.py +1132 -0
- qplot/datahandling/trusted_work_coordinator.py +2157 -0
- qplot/datahandling/trusted_work_scheduler.py +1204 -0
- qplot/diagnostics.py +241 -0
- qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
- qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
- qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
- qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
- qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
- qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
- qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
- qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
- qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
- qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
- qplot/testdata.py +1804 -0
- qplot/tools/__init__.py +13 -0
- qplot/tools/finite_means.py +123 -0
- qplot/tools/general.py +139 -0
- qplot/tools/heatmap_geometry.py +391 -0
- qplot/tools/operation_registry.py +179 -0
- qplot/tools/plot_tools.py +650 -0
- qplot/tools/sample_statistics.py +111 -0
- qplot/tools/worker.py +2838 -0
- qplot/windows/__init__.py +9 -0
- qplot/windows/_colorbar.py +422 -0
- qplot/windows/_commands.py +523 -0
- qplot/windows/_config_persistence.py +123 -0
- qplot/windows/_database_actions.py +4933 -0
- qplot/windows/_dataset_handle.py +221 -0
- qplot/windows/_display_precision.py +48 -0
- qplot/windows/_dragdrop.py +70 -0
- qplot/windows/_export_paths.py +1167 -0
- qplot/windows/_help.py +210 -0
- qplot/windows/_mixed_appearance.py +303 -0
- qplot/windows/_mixed_plots.py +461 -0
- qplot/windows/_native_averaging.py +323 -0
- qplot/windows/_native_transforms.py +551 -0
- qplot/windows/_plot1d_snap.py +612 -0
- qplot/windows/_plot1d_traces.py +2236 -0
- qplot/windows/_plot2d_colorbar.py +1127 -0
- qplot/windows/_plot2d_colorbar_dialog.py +786 -0
- qplot/windows/_plot2d_layers.py +2291 -0
- qplot/windows/_plot2d_sweeps.py +712 -0
- qplot/windows/_plotWin.py +2082 -0
- qplot/windows/_plot_actions.py +2703 -0
- qplot/windows/_plot_annotation.py +186 -0
- qplot/windows/_plot_appearance.py +283 -0
- qplot/windows/_plot_axis_scaling.py +1521 -0
- qplot/windows/_plot_data_viewbox.py +94 -0
- qplot/windows/_plot_drop_axes.py +224 -0
- qplot/windows/_plot_export.py +1445 -0
- qplot/windows/_plot_feedback.py +112 -0
- qplot/windows/_plot_marquee.py +659 -0
- qplot/windows/_plot_refresh.py +950 -0
- qplot/windows/_plot_source_promotion.py +178 -0
- qplot/windows/_plot_state.py +237 -0
- qplot/windows/_plot_status.py +115 -0
- qplot/windows/_plot_tiling.py +35 -0
- qplot/windows/_plot_transform_labels.py +60 -0
- qplot/windows/_preferences.py +724 -0
- qplot/windows/_refresh_interval.py +172 -0
- qplot/windows/_run_controls.py +789 -0
- qplot/windows/_shortcuts.py +47 -0
- qplot/windows/_subplots/__init__.py +8 -0
- qplot/windows/_subplots/subplot1d.py +532 -0
- qplot/windows/_subplots/subplot2d.py +919 -0
- qplot/windows/_svg_export.py +209 -0
- qplot/windows/_trusted_derived_qt.py +1759 -0
- qplot/windows/_widgets/__init__.py +23 -0
- qplot/windows/_widgets/_run_formatting.py +387 -0
- qplot/windows/_widgets/details_tables.py +1119 -0
- qplot/windows/_widgets/dropbox.py +290 -0
- qplot/windows/_widgets/operations.py +460 -0
- qplot/windows/_widgets/preview.py +2997 -0
- qplot/windows/_widgets/run_list_items.py +488 -0
- qplot/windows/_widgets/toolbar.py +111 -0
- qplot/windows/_widgets/treeWidgets.py +2572 -0
- qplot/windows/_window_controls.py +414 -0
- qplot/windows/main.py +1632 -0
- qplot/windows/plot1d.py +313 -0
- qplot/windows/plot2d.py +1935 -0
- qplotter-1.6.0.dist-info/METADATA +322 -0
- qplotter-1.6.0.dist-info/RECORD +130 -0
- qplotter-1.6.0.dist-info/WHEEL +5 -0
- qplotter-1.6.0.dist-info/entry_points.txt +4 -0
- qplotter-1.6.0.dist-info/licenses/LICENSE +21 -0
- qplotter-1.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
|
|
4
|
+
from PyQt6 import QtCore
|
|
5
|
+
|
|
6
|
+
from qplot.datahandling.file_identity import (
|
|
7
|
+
DatabaseFileIdentity,
|
|
8
|
+
DatabaseInstance,
|
|
9
|
+
canonical_database_path,
|
|
10
|
+
database_file_identity,
|
|
11
|
+
database_instance,
|
|
12
|
+
database_sidecar_identities,
|
|
13
|
+
logical_database_path,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_CAPTURE_CURRENT_SIDECARS: frozenset[DatabaseFileIdentity] = frozenset()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def close_dataset_connection(dataset: object) -> bool:
|
|
20
|
+
"""Close a dataset's backing connection when it exposes one."""
|
|
21
|
+
|
|
22
|
+
connection = getattr(dataset, "conn", None)
|
|
23
|
+
close = getattr(connection, "close", None)
|
|
24
|
+
if not callable(close):
|
|
25
|
+
return False
|
|
26
|
+
close()
|
|
27
|
+
return True
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class DatasetKey:
|
|
32
|
+
"""Identifies a dataset within one particular database file."""
|
|
33
|
+
|
|
34
|
+
database_path: str
|
|
35
|
+
guid: str
|
|
36
|
+
database_identity: DatabaseFileIdentity | None = None
|
|
37
|
+
resolved_database_path: str | None = None
|
|
38
|
+
sidecar_identities: frozenset[DatabaseFileIdentity] = field(
|
|
39
|
+
default=_CAPTURE_CURRENT_SIDECARS,
|
|
40
|
+
compare=False,
|
|
41
|
+
hash=False,
|
|
42
|
+
repr=False,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def __post_init__(self) -> None:
|
|
46
|
+
logical_path = logical_database_path(self.database_path)
|
|
47
|
+
object.__setattr__(self, "database_path", logical_path)
|
|
48
|
+
object.__setattr__(self, "guid", str(self.guid))
|
|
49
|
+
if self.resolved_database_path is None:
|
|
50
|
+
object.__setattr__(
|
|
51
|
+
self,
|
|
52
|
+
"resolved_database_path",
|
|
53
|
+
canonical_database_path(logical_path),
|
|
54
|
+
)
|
|
55
|
+
else:
|
|
56
|
+
object.__setattr__(
|
|
57
|
+
self,
|
|
58
|
+
"resolved_database_path",
|
|
59
|
+
canonical_database_path(self.resolved_database_path),
|
|
60
|
+
)
|
|
61
|
+
if self.database_identity is None:
|
|
62
|
+
resolved_path = self.resolved_database_path
|
|
63
|
+
assert resolved_path is not None
|
|
64
|
+
object.__setattr__(
|
|
65
|
+
self,
|
|
66
|
+
"database_identity",
|
|
67
|
+
database_file_identity(resolved_path),
|
|
68
|
+
)
|
|
69
|
+
# Identity distinguishes an omitted argument from an explicitly bound
|
|
70
|
+
# empty sidecar set. The latter must stay empty: recapturing a WAL/SHM
|
|
71
|
+
# that appeared later would silently rebind an existing request.
|
|
72
|
+
if self.sidecar_identities is _CAPTURE_CURRENT_SIDECARS:
|
|
73
|
+
object.__setattr__(
|
|
74
|
+
self,
|
|
75
|
+
"sidecar_identities",
|
|
76
|
+
database_sidecar_identities(
|
|
77
|
+
logical_path,
|
|
78
|
+
self.resolved_database_path,
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def dataset_key_matches_database_instance(
|
|
84
|
+
dataset_key: DatasetKey,
|
|
85
|
+
current_instance: DatabaseInstance,
|
|
86
|
+
*,
|
|
87
|
+
allow_new_sidecars: bool = False,
|
|
88
|
+
) -> bool:
|
|
89
|
+
"""Return whether an observation still represents ``dataset_key``.
|
|
90
|
+
|
|
91
|
+
A transient explicit-action key may allow the first WAL/SHM appearance
|
|
92
|
+
while its read-only DataSet is being materialised. A key retained by a
|
|
93
|
+
plot is final and therefore requires the exact observed sidecar set.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
if current_instance.logical_path != dataset_key.database_path:
|
|
97
|
+
return False
|
|
98
|
+
if current_instance.resolved_path != dataset_key.resolved_database_path:
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
expected_identity = dataset_key.database_identity
|
|
102
|
+
if expected_identity is None:
|
|
103
|
+
if current_instance.identity is not None or os.path.isfile(
|
|
104
|
+
dataset_key.database_path
|
|
105
|
+
):
|
|
106
|
+
return False
|
|
107
|
+
elif current_instance.identity != expected_identity:
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
expected_sidecars = dataset_key.sidecar_identities
|
|
111
|
+
if allow_new_sidecars:
|
|
112
|
+
return expected_sidecars.issubset(current_instance.sidecar_identities)
|
|
113
|
+
return expected_sidecars == current_instance.sidecar_identities
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def dataset_key_matches_current_source(
|
|
117
|
+
dataset_key: DatasetKey,
|
|
118
|
+
*,
|
|
119
|
+
allow_new_sidecars: bool = False,
|
|
120
|
+
) -> bool:
|
|
121
|
+
"""Inspect only filesystem identity metadata for a dataset source."""
|
|
122
|
+
|
|
123
|
+
return dataset_key_matches_database_instance(
|
|
124
|
+
dataset_key,
|
|
125
|
+
database_instance(dataset_key.database_path),
|
|
126
|
+
allow_new_sidecars=allow_new_sidecars,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def bind_dataset_key_to_current_source(
|
|
131
|
+
dataset_key: DatasetKey,
|
|
132
|
+
) -> DatasetKey | None:
|
|
133
|
+
"""Promote compatible first sidecars into an exact retained-plot key."""
|
|
134
|
+
|
|
135
|
+
current_instance = database_instance(dataset_key.database_path)
|
|
136
|
+
if not dataset_key_matches_database_instance(
|
|
137
|
+
dataset_key,
|
|
138
|
+
current_instance,
|
|
139
|
+
allow_new_sidecars=True,
|
|
140
|
+
):
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
bound_key = DatasetKey(
|
|
144
|
+
current_instance.logical_path,
|
|
145
|
+
dataset_key.guid,
|
|
146
|
+
current_instance.identity,
|
|
147
|
+
current_instance.resolved_path,
|
|
148
|
+
sidecar_identities=current_instance.sidecar_identities,
|
|
149
|
+
)
|
|
150
|
+
# Close the metadata-only capture race before returning a key that callers
|
|
151
|
+
# may retain for automatic refreshes.
|
|
152
|
+
if not dataset_key_matches_current_source(bound_key):
|
|
153
|
+
return None
|
|
154
|
+
return bound_key
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True, slots=True)
|
|
158
|
+
class TraceKey:
|
|
159
|
+
"""Identifies one trace source in one database-backed dataset."""
|
|
160
|
+
|
|
161
|
+
dataset_key: DatasetKey
|
|
162
|
+
parameter_name: str
|
|
163
|
+
sweep_id: int | None = None
|
|
164
|
+
|
|
165
|
+
def __post_init__(self) -> None:
|
|
166
|
+
object.__setattr__(self, "parameter_name", str(self.parameter_name))
|
|
167
|
+
if self.sweep_id is not None:
|
|
168
|
+
object.__setattr__(self, "sweep_id", int(self.sweep_id))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass
|
|
172
|
+
class DatasetHandle:
|
|
173
|
+
"""
|
|
174
|
+
Tracks a dataset held open by one or more plot windows.
|
|
175
|
+
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
dataset: object
|
|
179
|
+
users: int = 1
|
|
180
|
+
delete_timer: QtCore.QTimer | None = None
|
|
181
|
+
closed: bool = False
|
|
182
|
+
database_identity: DatabaseFileIdentity | None = None
|
|
183
|
+
sidecar_identities: frozenset[DatabaseFileIdentity] | None = None
|
|
184
|
+
|
|
185
|
+
def retain(self):
|
|
186
|
+
"""
|
|
187
|
+
Records another active user and cancels pending delayed deletion.
|
|
188
|
+
|
|
189
|
+
"""
|
|
190
|
+
self.users += 1
|
|
191
|
+
self.cancel_delete_timer()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def release(self):
|
|
195
|
+
"""
|
|
196
|
+
Records that one active user has closed.
|
|
197
|
+
|
|
198
|
+
"""
|
|
199
|
+
self.users -= 1
|
|
200
|
+
return self.users
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def cancel_delete_timer(self):
|
|
204
|
+
"""
|
|
205
|
+
Stops any pending delayed deletion timer.
|
|
206
|
+
|
|
207
|
+
"""
|
|
208
|
+
if self.delete_timer is not None:
|
|
209
|
+
self.delete_timer.stop()
|
|
210
|
+
self.delete_timer = None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def close(self):
|
|
214
|
+
"""Cancel pending eviction and deterministically close the dataset."""
|
|
215
|
+
|
|
216
|
+
self.cancel_delete_timer()
|
|
217
|
+
if self.closed:
|
|
218
|
+
return False
|
|
219
|
+
closed_connection = close_dataset_connection(self.dataset)
|
|
220
|
+
self.closed = True
|
|
221
|
+
return closed_connection
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Reject renderer conversions that merge distinct recorded values."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
DISPLAY_PRECISION_MESSAGE = (
|
|
6
|
+
"Distinct recorded values become identical at display precision. "
|
|
7
|
+
"Apply a subtraction operation to view relative values. "
|
|
8
|
+
"The original samples remain available for CSV export and processing."
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
HEATMAP_DISPLAY_RANGE_MESSAGE = (
|
|
12
|
+
"The heatmap coordinate range cannot be displayed safely. "
|
|
13
|
+
"Recorded samples remain available for CSV export and processing."
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def heatmap_display_range_is_finite(geometry, pixel_size=(1., 1.)):
|
|
18
|
+
"""Require finite image and viewport scales before installing Qt transforms."""
|
|
19
|
+
if not np.all(np.isfinite(geometry.rect)):
|
|
20
|
+
return False
|
|
21
|
+
spans = np.asarray(geometry.rect[2:])
|
|
22
|
+
cells = np.asarray((geometry.x.count, geometry.y.count))
|
|
23
|
+
with np.errstate(over="ignore", divide="ignore", invalid="ignore"):
|
|
24
|
+
return bool(np.all(np.isfinite(spans / cells))
|
|
25
|
+
and np.all(np.isfinite(np.maximum(pixel_size, cells) / spans)))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def float_display_loses_distinctions(values):
|
|
29
|
+
"""Check exact integer/object values before a renderer converts to float.
|
|
30
|
+
|
|
31
|
+
Ordinary float64 and narrower samples are already representable. Only
|
|
32
|
+
potentially inexact inputs need sorting; adjacent distinct exact values
|
|
33
|
+
must remain distinct after conversion, including fractional object cells.
|
|
34
|
+
"""
|
|
35
|
+
values = np.asarray(values)
|
|
36
|
+
if values.dtype.kind not in "iuO" or values.size < 2:
|
|
37
|
+
return False
|
|
38
|
+
if values.dtype.kind in "iu" and (
|
|
39
|
+
values.dtype.itemsize < 8
|
|
40
|
+
or (np.min(values) >= -(2**53) and np.max(values) <= 2**53)
|
|
41
|
+
):
|
|
42
|
+
return False
|
|
43
|
+
rendered = values.astype(float)
|
|
44
|
+
unique = np.unique(values[np.isfinite(rendered)])
|
|
45
|
+
if unique.size < 2:
|
|
46
|
+
return False
|
|
47
|
+
rendered_unique = unique.astype(float)
|
|
48
|
+
return bool(np.any(rendered_unique[1:] == rendered_unique[:-1]))
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from PyQt6 import QtCore
|
|
4
|
+
|
|
5
|
+
RUN_PREVIEW_MIME = "application/x-qplot-run-preview"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def make_run_preview_mime(
|
|
9
|
+
guid, parameter, axes=None, database_path=None, *, axes_pending=False):
|
|
10
|
+
payload: dict[str, str | list[str] | bool] = {
|
|
11
|
+
"guid": str(guid or ""),
|
|
12
|
+
"parameter": str(parameter or ""),
|
|
13
|
+
"axes": [str(axis) for axis in (axes or [])],
|
|
14
|
+
}
|
|
15
|
+
if database_path:
|
|
16
|
+
payload["database_path"] = str(database_path)
|
|
17
|
+
if axes_pending:
|
|
18
|
+
payload["axes_pending"] = True
|
|
19
|
+
mime_data = QtCore.QMimeData()
|
|
20
|
+
mime_data.setData(
|
|
21
|
+
RUN_PREVIEW_MIME,
|
|
22
|
+
json.dumps(payload).encode("utf-8")
|
|
23
|
+
)
|
|
24
|
+
mime_data.setText(f"{payload['parameter']} from {payload['guid']}")
|
|
25
|
+
return mime_data
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_preview_payload_from_mime(mime_data):
|
|
29
|
+
if mime_data is None or not mime_data.hasFormat(RUN_PREVIEW_MIME):
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
payload = json.loads(bytes(mime_data.data(RUN_PREVIEW_MIME)).decode("utf-8"))
|
|
34
|
+
except (TypeError, ValueError, json.JSONDecodeError):
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
guid = str(payload.get("guid") or "")
|
|
38
|
+
parameter = str(payload.get("parameter") or "")
|
|
39
|
+
if not guid or not parameter:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
axes = payload.get("axes") or []
|
|
43
|
+
if isinstance(axes, str):
|
|
44
|
+
axes = [axes]
|
|
45
|
+
elif not isinstance(axes, (list, tuple)):
|
|
46
|
+
axes = []
|
|
47
|
+
|
|
48
|
+
normalised: dict[str, str | list[str] | bool] = {
|
|
49
|
+
"guid": guid,
|
|
50
|
+
"parameter": parameter,
|
|
51
|
+
"axes": [str(axis) for axis in axes],
|
|
52
|
+
}
|
|
53
|
+
database_path = str(payload.get("database_path") or "")
|
|
54
|
+
if database_path:
|
|
55
|
+
normalised["database_path"] = database_path
|
|
56
|
+
if payload.get("axes_pending") is True:
|
|
57
|
+
normalised["axes_pending"] = True
|
|
58
|
+
return normalised
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def preview_drop_is_compatible(target_axes, payload):
|
|
62
|
+
"""Check plot dimensionality; physical-axis compatibility is handled on drop."""
|
|
63
|
+
axes = tuple(str(axis) for axis in (payload.get("axes") or []))
|
|
64
|
+
target_axes = tuple(str(axis) for axis in target_axes)
|
|
65
|
+
if not axes and payload.get("axes_pending") is True:
|
|
66
|
+
# Pending thumbnails have a run/parameter identity but no rendered
|
|
67
|
+
# axes yet. The add action loads the actual parameter before choosing
|
|
68
|
+
# axes and requesting approval for any remaining incompatibility.
|
|
69
|
+
return len(target_axes) in (1, 2)
|
|
70
|
+
return len(target_axes) in (1, 2) and len(axes) in (1, 2) and len(set(axes)) == len(axes)
|