qplotter 1.6.0b3__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 +63 -0
- qplot/_metadata.py +12 -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 +573 -0
- qplot/configuration/config_schema.json +319 -0
- qplot/configuration/scripts.py +255 -0
- qplot/configuration/themes/__init__.py +13 -0
- qplot/configuration/themes/_base.py +579 -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 +2330 -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 +2561 -0
- qplot/datahandling/source_activity.py +21 -0
- qplot/datahandling/trusted_array_preview.py +127 -0
- qplot/datahandling/trusted_derived_cache.py +1605 -0
- qplot/datahandling/trusted_derived_rendering.py +868 -0
- qplot/datahandling/trusted_heatmap.py +241 -0
- qplot/datahandling/trusted_live.py +2808 -0
- qplot/datahandling/trusted_live_queries.py +5907 -0
- qplot/datahandling/trusted_live_service.py +1894 -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 +1733 -0
- qplot/tools/__init__.py +13 -0
- qplot/tools/finite_means.py +114 -0
- qplot/tools/general.py +134 -0
- qplot/tools/heatmap_geometry.py +381 -0
- qplot/tools/operation_registry.py +179 -0
- qplot/tools/plot_tools.py +625 -0
- qplot/tools/sample_statistics.py +56 -0
- qplot/tools/worker.py +2787 -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 +4904 -0
- qplot/windows/_dataset_handle.py +221 -0
- qplot/windows/_dragdrop.py +71 -0
- qplot/windows/_export_paths.py +1167 -0
- qplot/windows/_help.py +169 -0
- qplot/windows/_native_averaging.py +173 -0
- qplot/windows/_native_transforms.py +389 -0
- qplot/windows/_plot1d_snap.py +608 -0
- qplot/windows/_plot1d_traces.py +2183 -0
- qplot/windows/_plot2d_colorbar.py +1126 -0
- qplot/windows/_plot2d_colorbar_dialog.py +786 -0
- qplot/windows/_plot2d_layers.py +2197 -0
- qplot/windows/_plot2d_sweeps.py +712 -0
- qplot/windows/_plotWin.py +1993 -0
- qplot/windows/_plot_actions.py +2695 -0
- qplot/windows/_plot_appearance.py +276 -0
- qplot/windows/_plot_axis_scaling.py +1485 -0
- qplot/windows/_plot_export.py +1445 -0
- qplot/windows/_plot_feedback.py +110 -0
- qplot/windows/_plot_marquee.py +656 -0
- qplot/windows/_plot_refresh.py +942 -0
- qplot/windows/_plot_state.py +239 -0
- qplot/windows/_plot_transform_labels.py +60 -0
- qplot/windows/_preferences.py +635 -0
- qplot/windows/_refresh_interval.py +70 -0
- qplot/windows/_run_controls.py +828 -0
- qplot/windows/_shortcuts.py +47 -0
- qplot/windows/_subplots/__init__.py +8 -0
- qplot/windows/_subplots/subplot1d.py +531 -0
- qplot/windows/_subplots/subplot2d.py +918 -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 +413 -0
- qplot/windows/main.py +1635 -0
- qplot/windows/plot1d.py +311 -0
- qplot/windows/plot2d.py +1901 -0
- qplotter-1.6.0b3.dist-info/METADATA +318 -0
- qplotter-1.6.0b3.dist-info/RECORD +119 -0
- qplotter-1.6.0b3.dist-info/WHEEL +5 -0
- qplotter-1.6.0b3.dist-info/entry_points.txt +4 -0
- qplotter-1.6.0b3.dist-info/licenses/LICENSE +21 -0
- qplotter-1.6.0b3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2695 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from functools import wraps
|
|
3
|
+
from inspect import signature
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
from PyQt6 import QtCore
|
|
8
|
+
|
|
9
|
+
from qplot.datahandling.dimensions import (
|
|
10
|
+
MAX_SUPPORTED_PLOT_DIMENSIONS,
|
|
11
|
+
unsupported_plot_message,
|
|
12
|
+
)
|
|
13
|
+
from qplot.datahandling.file_identity import (
|
|
14
|
+
DatabaseInstance,
|
|
15
|
+
logical_database_path,
|
|
16
|
+
)
|
|
17
|
+
from qplot.datahandling.parameter_data import (
|
|
18
|
+
concatenate_record_samples,
|
|
19
|
+
parameter_data_for_export,
|
|
20
|
+
)
|
|
21
|
+
from qplot.datahandling.readonly import (
|
|
22
|
+
DatabaseInstanceChangedError,
|
|
23
|
+
load_by_guid_read_only,
|
|
24
|
+
)
|
|
25
|
+
from qplot.datahandling.trusted_live_service import (
|
|
26
|
+
SNAPSHOT_FALLBACK_MODE,
|
|
27
|
+
TRUSTED_LIVE_MODE,
|
|
28
|
+
)
|
|
29
|
+
from qplot.diagnostics import log_exception
|
|
30
|
+
|
|
31
|
+
from ._dataset_handle import (
|
|
32
|
+
DatasetHandle,
|
|
33
|
+
DatasetKey,
|
|
34
|
+
TraceKey,
|
|
35
|
+
bind_dataset_key_to_current_source,
|
|
36
|
+
close_dataset_connection,
|
|
37
|
+
dataset_key_matches_current_source,
|
|
38
|
+
)
|
|
39
|
+
from ._export_paths import choose_export_path, write_export_atomically
|
|
40
|
+
from ._plot2d_layers import _heatmap_layer_compatibility, _window_display_unit
|
|
41
|
+
from ._plot_refresh import plot_refresh_required
|
|
42
|
+
from ._refresh_interval import refresh_interval_value
|
|
43
|
+
from ._subplots.subplot1d import (
|
|
44
|
+
_subplot_axis_order,
|
|
45
|
+
_subplot_shared_parameter,
|
|
46
|
+
)
|
|
47
|
+
from .plot1d import plot1d
|
|
48
|
+
from .plot2d import plot2d
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _trusted_plot_action(method):
|
|
52
|
+
"""Resume an explicit UI action only after broker metadata is available."""
|
|
53
|
+
call_signature = signature(method)
|
|
54
|
+
|
|
55
|
+
@wraps(method)
|
|
56
|
+
def invoke(self, *args, **kwargs):
|
|
57
|
+
arguments = call_signature.bind(self, *args, **kwargs).arguments
|
|
58
|
+
target = arguments.get("source_identity", arguments.get("guid"))
|
|
59
|
+
if self._defer_trusted_plot_action(
|
|
60
|
+
target, lambda: invoke(self, *args, **kwargs),
|
|
61
|
+
):
|
|
62
|
+
return
|
|
63
|
+
return method(self, *args, **kwargs)
|
|
64
|
+
return invoke
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _plot_has_trace_window(target, candidate):
|
|
68
|
+
"""Return whether a source window is already represented on a plot."""
|
|
69
|
+
|
|
70
|
+
checker = getattr(target, "_has_trace_window", None)
|
|
71
|
+
if callable(checker):
|
|
72
|
+
return checker(candidate)
|
|
73
|
+
return candidate.label in target.lines
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _combo_index_for_data(combo, value):
|
|
77
|
+
"""Return the first combo row whose Python user data equals ``value``."""
|
|
78
|
+
|
|
79
|
+
count = getattr(combo, "count", None)
|
|
80
|
+
item_data = getattr(combo, "itemData", None)
|
|
81
|
+
if not callable(count) or not callable(item_data):
|
|
82
|
+
return -1
|
|
83
|
+
|
|
84
|
+
for index in range(count()):
|
|
85
|
+
if item_data(index) == value:
|
|
86
|
+
return index
|
|
87
|
+
return -1
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _exportable_measurement_params(dataset):
|
|
91
|
+
"""Return dependent values, then standalone values, in run order."""
|
|
92
|
+
parameters = dataset.get_parameters()
|
|
93
|
+
setpoint_names = {
|
|
94
|
+
name
|
|
95
|
+
for param in parameters
|
|
96
|
+
for name in param.depends_on_
|
|
97
|
+
}
|
|
98
|
+
dependent = [param for param in parameters if param.depends_on_]
|
|
99
|
+
standalone = [
|
|
100
|
+
param for param in parameters
|
|
101
|
+
if not param.depends_on_ and param.name not in setpoint_names
|
|
102
|
+
]
|
|
103
|
+
return dependent + standalone
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _csv_scalar_samples(values):
|
|
107
|
+
"""Flatten a record, including arrays held in NumPy object cells."""
|
|
108
|
+
array = np.asarray(values)
|
|
109
|
+
if not array.dtype.hasobject:
|
|
110
|
+
return array.ravel()
|
|
111
|
+
samples = []
|
|
112
|
+
for value in array.ravel():
|
|
113
|
+
if isinstance(value, (np.ndarray, list, tuple)):
|
|
114
|
+
samples.extend(_csv_scalar_samples(value))
|
|
115
|
+
else:
|
|
116
|
+
samples.append(value)
|
|
117
|
+
# These cells can mix signed/unsigned integers (or integers and floats).
|
|
118
|
+
# Inferring a dtype again would undo extraction's precision protection.
|
|
119
|
+
return np.asarray(samples, dtype=object)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _csv_scalar_columns(param_data):
|
|
123
|
+
"""Align samples within each QCoDeS record before joining records."""
|
|
124
|
+
arrays = {name: np.asarray(values) for name, values in param_data.items()}
|
|
125
|
+
if not arrays:
|
|
126
|
+
return {}
|
|
127
|
+
# Regular, already aligned columns need no record-by-record expansion.
|
|
128
|
+
if (
|
|
129
|
+
len({values.shape for values in arrays.values()}) == 1
|
|
130
|
+
and all(not values.dtype.hasobject for values in arrays.values())
|
|
131
|
+
):
|
|
132
|
+
return {name: values.ravel() for name, values in arrays.items()}
|
|
133
|
+
|
|
134
|
+
chunks = {name: [] for name in arrays}
|
|
135
|
+
for record in zip(*arrays.values(), strict=True):
|
|
136
|
+
samples = {
|
|
137
|
+
name: _csv_scalar_samples(values)
|
|
138
|
+
for name, values in zip(arrays, record, strict=True)
|
|
139
|
+
}
|
|
140
|
+
sample_count = max(values.size for values in samples.values())
|
|
141
|
+
for name, values in samples.items():
|
|
142
|
+
if values.size == sample_count:
|
|
143
|
+
chunks[name].append(values)
|
|
144
|
+
elif values.size == 1:
|
|
145
|
+
chunks[name].append(np.repeat(values, sample_count))
|
|
146
|
+
else:
|
|
147
|
+
raise ValueError(
|
|
148
|
+
f"Cannot align CSV record column {name}: "
|
|
149
|
+
f"{values.size} samples instead of {sample_count}."
|
|
150
|
+
)
|
|
151
|
+
return {
|
|
152
|
+
name: concatenate_record_samples(parts) if parts else np.array([], dtype=arrays[name].dtype)
|
|
153
|
+
for name, parts in chunks.items()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class PlotActionsMixin:
|
|
158
|
+
"""
|
|
159
|
+
Plot launching, preview plotting, CSV export, and plot dataset tracking.
|
|
160
|
+
|
|
161
|
+
The mixin expects the owning window to provide MainWindow's widgets and
|
|
162
|
+
state, including windows, dataset_holder, config, threadPool, and status
|
|
163
|
+
helpers.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
_selected_dataset_key: DatasetKey | None
|
|
167
|
+
|
|
168
|
+
def _defer_trusted_plot_action(self, target, resume):
|
|
169
|
+
if getattr(self, "_database_access_mode", None) != TRUSTED_LIVE_MODE:
|
|
170
|
+
return False
|
|
171
|
+
# Qt's str signal converts the run table's None argument to "".
|
|
172
|
+
# Both spellings select the current run, just as openPlot does.
|
|
173
|
+
if target == "":
|
|
174
|
+
target = None
|
|
175
|
+
selection = ((getattr(self, "selected_run_id", None),
|
|
176
|
+
getattr(self, "_selected_run_guid", None))
|
|
177
|
+
if target is None else None)
|
|
178
|
+
if target is None:
|
|
179
|
+
metadata = self._run_metadata_for_id(getattr(self, "selected_run_id", None))
|
|
180
|
+
target = metadata.get("guid") or getattr(self, "_selected_run_guid", None)
|
|
181
|
+
if not target:
|
|
182
|
+
return False
|
|
183
|
+
key = target if isinstance(target, DatasetKey) else self._current_dataset_key(target)
|
|
184
|
+
if (getattr(self, "ds", None) is not None
|
|
185
|
+
and getattr(self, "_selected_dataset_key", None) == key):
|
|
186
|
+
return False
|
|
187
|
+
if not self._generation_gate_allows_action(key.database_path, "opening plots"):
|
|
188
|
+
return True
|
|
189
|
+
try:
|
|
190
|
+
self._ensure_dataset_key_can_be_read(key)
|
|
191
|
+
except Exception as error:
|
|
192
|
+
self.show_error("Plot Load Failed", "Could not open the selected source.", str(error))
|
|
193
|
+
return True
|
|
194
|
+
ready = getattr(self, "_trusted_plot_ready", {})
|
|
195
|
+
if key in ready or self._dataset_handle_for_key(key) is not None:
|
|
196
|
+
return False
|
|
197
|
+
service = getattr(self, "_trusted_read_service", None)
|
|
198
|
+
if (service is None or service.closing or service.closed
|
|
199
|
+
or service.database_instance.identity != key.database_identity):
|
|
200
|
+
self.show_error("Plot Load Failed", "The trusted plot session is unavailable.",
|
|
201
|
+
"Reopen the source to start a new trusted session.")
|
|
202
|
+
return True
|
|
203
|
+
pending = getattr(self, "_trusted_plot_pending", None)
|
|
204
|
+
if pending is None:
|
|
205
|
+
pending = self._trusted_plot_pending = set()
|
|
206
|
+
if len(pending) >= 16:
|
|
207
|
+
self.show_status("Wait for the pending plots to open.", 5000)
|
|
208
|
+
return True
|
|
209
|
+
try:
|
|
210
|
+
request = service.submit_plot_dataset(key.guid)
|
|
211
|
+
except Exception as error:
|
|
212
|
+
self.show_error("Plot Load Failed", "Could not load plot metadata.", str(error))
|
|
213
|
+
return True
|
|
214
|
+
pending.add(request)
|
|
215
|
+
timer = QtCore.QTimer(self)
|
|
216
|
+
timer.setInterval(10)
|
|
217
|
+
|
|
218
|
+
def poll():
|
|
219
|
+
stale = (getattr(self, "_shutdown_started", False)
|
|
220
|
+
or service is not getattr(self, "_trusted_read_service", None)
|
|
221
|
+
or (selection is not None and selection != (
|
|
222
|
+
getattr(self, "selected_run_id", None),
|
|
223
|
+
getattr(self, "_selected_run_guid", None)))
|
|
224
|
+
or not self._dataset_key_is_current(key))
|
|
225
|
+
if stale:
|
|
226
|
+
request.cancel()
|
|
227
|
+
if not stale and not request.done:
|
|
228
|
+
return
|
|
229
|
+
timer.stop()
|
|
230
|
+
timer.deleteLater()
|
|
231
|
+
pending.discard(request)
|
|
232
|
+
if stale:
|
|
233
|
+
return
|
|
234
|
+
try:
|
|
235
|
+
dataset = request.wait(0)
|
|
236
|
+
ready = self.__dict__.setdefault("_trusted_plot_ready", {})
|
|
237
|
+
ready[key] = dataset
|
|
238
|
+
try:
|
|
239
|
+
resume()
|
|
240
|
+
finally:
|
|
241
|
+
ready.pop(key, None)
|
|
242
|
+
except Exception as error:
|
|
243
|
+
log_exception("Trusted plot launch failed", error, __name__)
|
|
244
|
+
self.show_error("Plot Load Failed", "Could not load the selected run.", str(error))
|
|
245
|
+
|
|
246
|
+
timer.timeout.connect(poll)
|
|
247
|
+
timer.start()
|
|
248
|
+
self.show_status("Loading plot metadata...", 0)
|
|
249
|
+
return True
|
|
250
|
+
|
|
251
|
+
def _generation_gate_allows_action(self, database_path=None, operation=None):
|
|
252
|
+
gate = getattr(self, "_database_generation_read_allowed", None)
|
|
253
|
+
if not callable(gate):
|
|
254
|
+
return True
|
|
255
|
+
return gate(
|
|
256
|
+
database_path,
|
|
257
|
+
operation=operation or "using run data",
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
@staticmethod
|
|
261
|
+
def _dataset_key_is_current(dataset_key):
|
|
262
|
+
return dataset_key_matches_current_source(
|
|
263
|
+
dataset_key,
|
|
264
|
+
allow_new_sidecars=True,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def _bound_plot_dataset_key_is_current(dataset_key):
|
|
270
|
+
"""Require the exact source identities retained by a plot key."""
|
|
271
|
+
|
|
272
|
+
return dataset_key_matches_current_source(dataset_key)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _request_plot_source_recovery(self, dataset_key):
|
|
276
|
+
"""Retire a plot source without trusting the controller baseline."""
|
|
277
|
+
|
|
278
|
+
replacement_handler = getattr(
|
|
279
|
+
self,
|
|
280
|
+
"_handle_plot_database_replaced",
|
|
281
|
+
None,
|
|
282
|
+
)
|
|
283
|
+
if callable(replacement_handler):
|
|
284
|
+
replacement_handler(dataset_key.database_path)
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
reload_replaced = getattr(self, "_reload_replaced_database", None)
|
|
288
|
+
if callable(reload_replaced):
|
|
289
|
+
reload_replaced(dataset_key.database_path)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _ensure_bound_plot_dataset_key_can_be_read(self, dataset_key):
|
|
293
|
+
"""Reject a retained plot key unless every accepted identity matches."""
|
|
294
|
+
|
|
295
|
+
if not PlotActionsMixin._generation_gate_allows_action(
|
|
296
|
+
self,
|
|
297
|
+
dataset_key.database_path,
|
|
298
|
+
"using run data",
|
|
299
|
+
):
|
|
300
|
+
raise RuntimeError(
|
|
301
|
+
"Test-database generation owns this database path until its "
|
|
302
|
+
"released view has recovered."
|
|
303
|
+
)
|
|
304
|
+
if PlotActionsMixin._bound_plot_dataset_key_is_current(dataset_key):
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
PlotActionsMixin._request_plot_source_recovery(self, dataset_key)
|
|
308
|
+
raise RuntimeError(
|
|
309
|
+
"The database or its SQLite sidecars were replaced before the "
|
|
310
|
+
"plot could use its dataset."
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _rekey_dataset_handle_for_bound_plot(self, prior_key, bound_key):
|
|
315
|
+
"""Replace an equal transient dict key with its exact plot binding."""
|
|
316
|
+
|
|
317
|
+
for held_key in list(self.dataset_holder):
|
|
318
|
+
if held_key != prior_key:
|
|
319
|
+
continue
|
|
320
|
+
handle = self.dataset_holder.pop(held_key)
|
|
321
|
+
held_sidecars = handle.sidecar_identities
|
|
322
|
+
if (
|
|
323
|
+
held_sidecars is not None
|
|
324
|
+
and not held_sidecars.issubset(bound_key.sidecar_identities)
|
|
325
|
+
):
|
|
326
|
+
# This handle came from a different exact sidecar lineage even
|
|
327
|
+
# though DatasetKey deliberately excludes sidecars from hash
|
|
328
|
+
# equality. Do not let it satisfy the promoted plot key.
|
|
329
|
+
self.dataset_holder[held_key] = handle
|
|
330
|
+
return False
|
|
331
|
+
handle.sidecar_identities = bound_key.sidecar_identities
|
|
332
|
+
self.dataset_holder[bound_key] = handle
|
|
333
|
+
return True
|
|
334
|
+
return False
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _bind_plot_dataset_key(self, dataset_key):
|
|
338
|
+
"""Finish first-sidecar binding before retaining a plot source."""
|
|
339
|
+
|
|
340
|
+
bound_key = bind_dataset_key_to_current_source(dataset_key)
|
|
341
|
+
if bound_key is None:
|
|
342
|
+
PlotActionsMixin._request_plot_source_recovery(self, dataset_key)
|
|
343
|
+
return None
|
|
344
|
+
PlotActionsMixin._rekey_dataset_handle_for_bound_plot(
|
|
345
|
+
self,
|
|
346
|
+
dataset_key,
|
|
347
|
+
bound_key,
|
|
348
|
+
)
|
|
349
|
+
try:
|
|
350
|
+
PlotActionsMixin._ensure_bound_plot_dataset_key_can_be_read(
|
|
351
|
+
self,
|
|
352
|
+
bound_key,
|
|
353
|
+
)
|
|
354
|
+
except RuntimeError:
|
|
355
|
+
return None
|
|
356
|
+
return bound_key
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _dataset_key_targets_loaded_database(self, dataset_key):
|
|
360
|
+
"""Return whether a key names the database currently shown in the UI."""
|
|
361
|
+
|
|
362
|
+
file_textbox = getattr(self, "fileTextbox", None)
|
|
363
|
+
current_path = file_textbox.text() if file_textbox is not None else ""
|
|
364
|
+
return bool(
|
|
365
|
+
current_path
|
|
366
|
+
and logical_database_path(current_path) == dataset_key.database_path
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _ensure_dataset_key_can_be_read(self, dataset_key):
|
|
371
|
+
"""Reject a plot read until an atomically replaced source is reloaded."""
|
|
372
|
+
|
|
373
|
+
if not PlotActionsMixin._generation_gate_allows_action(
|
|
374
|
+
self,
|
|
375
|
+
dataset_key.database_path,
|
|
376
|
+
"using run data",
|
|
377
|
+
):
|
|
378
|
+
raise RuntimeError(
|
|
379
|
+
"Test-database generation owns this database path until its "
|
|
380
|
+
"released view has recovered."
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
if self._dataset_key_targets_loaded_database(dataset_key):
|
|
384
|
+
reload_if_changed = getattr(
|
|
385
|
+
self,
|
|
386
|
+
"_reload_if_database_instance_changed",
|
|
387
|
+
None,
|
|
388
|
+
)
|
|
389
|
+
if callable(reload_if_changed) and reload_if_changed(
|
|
390
|
+
dataset_key.database_path,
|
|
391
|
+
):
|
|
392
|
+
raise RuntimeError(
|
|
393
|
+
"The database was replaced and is being reloaded before "
|
|
394
|
+
"its dataset can be opened."
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
if not self._dataset_key_is_current(dataset_key):
|
|
398
|
+
raise RuntimeError(
|
|
399
|
+
"The database was replaced before its dataset could be loaded."
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _discard_dataset_handle(self, dataset_key, handle):
|
|
404
|
+
"""Remove and close one handle that cannot represent its key safely."""
|
|
405
|
+
if self.dataset_holder.get(dataset_key) is handle:
|
|
406
|
+
self.dataset_holder.pop(dataset_key, None)
|
|
407
|
+
if handle.dataset is getattr(self, "ds", None):
|
|
408
|
+
self.ds = None
|
|
409
|
+
self._selected_dataset_key = None
|
|
410
|
+
self.selected_run_id = None
|
|
411
|
+
try:
|
|
412
|
+
handle.close()
|
|
413
|
+
except Exception as err:
|
|
414
|
+
log_exception("Stale plot dataset cleanup failed", err, __name__)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _dataset_handle_for_key(self, dataset_key):
|
|
418
|
+
"""Return only a handle opened for the key's current file instance."""
|
|
419
|
+
key_is_current = self._dataset_key_is_current(dataset_key)
|
|
420
|
+
if not key_is_current:
|
|
421
|
+
stale_handle = self.dataset_holder.get(dataset_key)
|
|
422
|
+
if stale_handle is not None:
|
|
423
|
+
self._discard_dataset_handle(dataset_key, stale_handle)
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
for cached_key in list(self.dataset_holder):
|
|
427
|
+
if (
|
|
428
|
+
cached_key != dataset_key
|
|
429
|
+
and cached_key.database_path == dataset_key.database_path
|
|
430
|
+
and cached_key.guid == dataset_key.guid
|
|
431
|
+
):
|
|
432
|
+
superseded = self.dataset_holder.get(cached_key)
|
|
433
|
+
if superseded is not None:
|
|
434
|
+
self._discard_dataset_handle(cached_key, superseded)
|
|
435
|
+
|
|
436
|
+
handle = self.dataset_holder.get(dataset_key)
|
|
437
|
+
if handle is None:
|
|
438
|
+
return None
|
|
439
|
+
if (
|
|
440
|
+
handle.closed
|
|
441
|
+
or (
|
|
442
|
+
handle.database_identity is not None
|
|
443
|
+
and handle.database_identity != dataset_key.database_identity
|
|
444
|
+
)
|
|
445
|
+
or (
|
|
446
|
+
handle.sidecar_identities is not None
|
|
447
|
+
and handle.sidecar_identities
|
|
448
|
+
!= dataset_key.sidecar_identities
|
|
449
|
+
)
|
|
450
|
+
):
|
|
451
|
+
self._discard_dataset_handle(dataset_key, handle)
|
|
452
|
+
return None
|
|
453
|
+
if handle.database_identity is None:
|
|
454
|
+
handle.database_identity = dataset_key.database_identity
|
|
455
|
+
if handle.sidecar_identities is None:
|
|
456
|
+
handle.sidecar_identities = dataset_key.sidecar_identities
|
|
457
|
+
return handle
|
|
458
|
+
|
|
459
|
+
def _dataset_is_held(self, dataset):
|
|
460
|
+
return dataset is not None and any(
|
|
461
|
+
handle.dataset is dataset
|
|
462
|
+
for handle in self.dataset_holder.values()
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _close_dataset_if_unowned(self, dataset, context="Dataset cleanup failed"):
|
|
467
|
+
if dataset is None or dataset is getattr(self, "ds", None):
|
|
468
|
+
return False
|
|
469
|
+
if self._dataset_is_held(dataset):
|
|
470
|
+
return False
|
|
471
|
+
try:
|
|
472
|
+
return close_dataset_connection(dataset)
|
|
473
|
+
except Exception as err:
|
|
474
|
+
log_exception(context, err, __name__)
|
|
475
|
+
return False
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _replace_selected_dataset(self, dataset, dataset_key):
|
|
479
|
+
previous = getattr(self, "ds", None)
|
|
480
|
+
self.ds = dataset
|
|
481
|
+
self._selected_dataset_key = dataset_key
|
|
482
|
+
self._selected_run_guid = dataset_key.guid
|
|
483
|
+
if previous is not dataset:
|
|
484
|
+
self._close_dataset_if_unowned(
|
|
485
|
+
previous,
|
|
486
|
+
context="Previous selected dataset cleanup failed",
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _release_selected_dataset(self):
|
|
491
|
+
previous = getattr(self, "ds", None)
|
|
492
|
+
self.ds = None
|
|
493
|
+
self._selected_dataset_key = None
|
|
494
|
+
return self._close_dataset_if_unowned(
|
|
495
|
+
previous,
|
|
496
|
+
context="Selected dataset cleanup failed",
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _release_trusted_action_dataset(self, dataset):
|
|
501
|
+
"""End an explicit trusted-mode DataSet's GUI-thread ownership.
|
|
502
|
+
|
|
503
|
+
Stage 4 permits legacy DataSet materialisation only at an explicit plot
|
|
504
|
+
or export boundary. A plot window retains its DataSet through
|
|
505
|
+
``dataset_holder``; an unused or failed action must close its unowned
|
|
506
|
+
handle here so a later ordinary row selection has no SQLite cleanup to
|
|
507
|
+
perform.
|
|
508
|
+
"""
|
|
509
|
+
if (
|
|
510
|
+
dataset is None
|
|
511
|
+
or getattr(self, "_database_access_mode", None)
|
|
512
|
+
!= TRUSTED_LIVE_MODE
|
|
513
|
+
):
|
|
514
|
+
return False
|
|
515
|
+
if getattr(self, "ds", None) is dataset:
|
|
516
|
+
self.ds = None
|
|
517
|
+
self._selected_dataset_key = None
|
|
518
|
+
return self._close_dataset_if_unowned(
|
|
519
|
+
dataset,
|
|
520
|
+
context="Explicit plot dataset cleanup failed",
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _clear_selected_run_state(self):
|
|
525
|
+
"""Clear run actions and UI after a selected dataset cannot be loaded."""
|
|
526
|
+
cancel_detail = getattr(self, "_cancel_selected_run_detail", None)
|
|
527
|
+
if callable(cancel_detail):
|
|
528
|
+
cancel_detail()
|
|
529
|
+
self._release_selected_dataset()
|
|
530
|
+
self.selected_run_id = None
|
|
531
|
+
self._selected_run_guid = None
|
|
532
|
+
|
|
533
|
+
run_id_box = getattr(self, "run_idBox", None)
|
|
534
|
+
if run_id_box is not None:
|
|
535
|
+
signals_blocked = run_id_box.blockSignals(True)
|
|
536
|
+
try:
|
|
537
|
+
run_id_box.setText("")
|
|
538
|
+
finally:
|
|
539
|
+
run_id_box.blockSignals(signals_blocked)
|
|
540
|
+
|
|
541
|
+
run_list = getattr(self, "RunList", None)
|
|
542
|
+
if run_list is not None:
|
|
543
|
+
signals_blocked = run_list.blockSignals(True)
|
|
544
|
+
try:
|
|
545
|
+
run_list.clearSelection()
|
|
546
|
+
set_current_item = getattr(run_list, "setCurrentItem", None)
|
|
547
|
+
if callable(set_current_item):
|
|
548
|
+
set_current_item(None)
|
|
549
|
+
finally:
|
|
550
|
+
run_list.blockSignals(signals_blocked)
|
|
551
|
+
|
|
552
|
+
info_box = getattr(self, "infoBox", None)
|
|
553
|
+
if info_box is not None:
|
|
554
|
+
info_box.clear()
|
|
555
|
+
derived_bridge = getattr(self, "_trusted_derived_bridge", None)
|
|
556
|
+
if derived_bridge is not None:
|
|
557
|
+
derived_bridge.select_run(None)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
@QtCore.pyqtSlot()
|
|
561
|
+
def clear_non_single_run_selection(self):
|
|
562
|
+
"""Drop the single-run action target for an empty or multi-selection."""
|
|
563
|
+
self._clear_selected_run_state()
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _evict_dataset_handle(self, dataset_key):
|
|
567
|
+
handle = self.dataset_holder.pop(dataset_key, None)
|
|
568
|
+
if handle is None:
|
|
569
|
+
return False
|
|
570
|
+
|
|
571
|
+
handle.cancel_delete_timer()
|
|
572
|
+
if handle.dataset is getattr(self, "ds", None):
|
|
573
|
+
return True
|
|
574
|
+
try:
|
|
575
|
+
handle.close()
|
|
576
|
+
except Exception as err:
|
|
577
|
+
log_exception("Plot dataset cleanup failed", err, __name__)
|
|
578
|
+
return True
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _close_all_dataset_handles(self):
|
|
582
|
+
handles = list(self.dataset_holder.values())
|
|
583
|
+
self.dataset_holder.clear()
|
|
584
|
+
for handle in handles:
|
|
585
|
+
try:
|
|
586
|
+
handle.close()
|
|
587
|
+
except Exception as err:
|
|
588
|
+
log_exception("Plot dataset cleanup failed", err, __name__)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
@staticmethod
|
|
592
|
+
def _dataset_key_matches_replaced_instance(dataset_key, database_source):
|
|
593
|
+
"""Match a key against saved old-instance information."""
|
|
594
|
+
|
|
595
|
+
if dataset_key is None:
|
|
596
|
+
return False
|
|
597
|
+
if isinstance(database_source, DatasetKey):
|
|
598
|
+
logical_path = database_source.database_path
|
|
599
|
+
resolved_path = database_source.resolved_database_path
|
|
600
|
+
identity = database_source.database_identity
|
|
601
|
+
elif isinstance(database_source, DatabaseInstance):
|
|
602
|
+
logical_path = database_source.logical_path
|
|
603
|
+
resolved_path = database_source.resolved_path
|
|
604
|
+
identity = database_source.identity
|
|
605
|
+
else:
|
|
606
|
+
return dataset_key.database_path == logical_database_path(database_source)
|
|
607
|
+
|
|
608
|
+
if dataset_key.database_path != logical_path:
|
|
609
|
+
return False
|
|
610
|
+
if identity is not None and dataset_key.database_identity is not None:
|
|
611
|
+
return dataset_key.database_identity == identity
|
|
612
|
+
return dataset_key.resolved_database_path == resolved_path
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _detach_replaced_secondary_traces(self, database_source):
|
|
616
|
+
"""Remove secondary plots whose source belongs to a replaced database.
|
|
617
|
+
|
|
618
|
+
A merged source can be closed and therefore absent from ``windows``
|
|
619
|
+
while its layer still owns a dataset handle and keeps its monitor
|
|
620
|
+
alive. Retire those consumers before evicting the source handle so a
|
|
621
|
+
plot backed by another database cannot retain or refresh stale data.
|
|
622
|
+
"""
|
|
623
|
+
def source_matches(dataset_key):
|
|
624
|
+
return self._dataset_key_matches_replaced_instance(
|
|
625
|
+
dataset_key,
|
|
626
|
+
database_source,
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
for host in list(getattr(self, "windows", [])):
|
|
630
|
+
host_key = getattr(host, "_dataset_key", None)
|
|
631
|
+
# Hosts from the replaced database are closed below, and their
|
|
632
|
+
# close event already detaches every secondary line.
|
|
633
|
+
if (
|
|
634
|
+
host_key is not None
|
|
635
|
+
and source_matches(host_key)
|
|
636
|
+
):
|
|
637
|
+
continue
|
|
638
|
+
|
|
639
|
+
for registry_name, main_item_name, remove_name in (
|
|
640
|
+
("lines", "line", "remove_line"),
|
|
641
|
+
("heatmaps", None, "remove_heatmap"),
|
|
642
|
+
):
|
|
643
|
+
registry = getattr(host, registry_name, None)
|
|
644
|
+
remover = getattr(host, remove_name, None)
|
|
645
|
+
if not isinstance(registry, dict) or not callable(remover):
|
|
646
|
+
continue
|
|
647
|
+
|
|
648
|
+
main_item = host if main_item_name is None else getattr(
|
|
649
|
+
host,
|
|
650
|
+
main_item_name,
|
|
651
|
+
None,
|
|
652
|
+
)
|
|
653
|
+
stale_layers = [
|
|
654
|
+
(trace_key, layer)
|
|
655
|
+
for trace_key, layer in list(registry.items())
|
|
656
|
+
if (
|
|
657
|
+
layer is not None
|
|
658
|
+
and layer is not main_item
|
|
659
|
+
and getattr(
|
|
660
|
+
getattr(layer, "from_win", None),
|
|
661
|
+
"_dataset_key",
|
|
662
|
+
None,
|
|
663
|
+
) is not None
|
|
664
|
+
and source_matches(layer.from_win._dataset_key)
|
|
665
|
+
)
|
|
666
|
+
]
|
|
667
|
+
for trace_key, layer in stale_layers:
|
|
668
|
+
source = layer.from_win
|
|
669
|
+
try:
|
|
670
|
+
remover(
|
|
671
|
+
getattr(source, "label", ""),
|
|
672
|
+
trace_key=trace_key,
|
|
673
|
+
)
|
|
674
|
+
except Exception as err:
|
|
675
|
+
log_exception(
|
|
676
|
+
"Replaced database secondary-plot cleanup failed",
|
|
677
|
+
err,
|
|
678
|
+
__name__,
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _invalidate_database_runtime_state(self, database_source):
|
|
683
|
+
"""Close datasets and plot windows backed by a replaced database."""
|
|
684
|
+
self._detach_replaced_secondary_traces(database_source)
|
|
685
|
+
selected_key = getattr(self, "_selected_dataset_key", None)
|
|
686
|
+
if (
|
|
687
|
+
selected_key is not None
|
|
688
|
+
and self._dataset_key_matches_replaced_instance(
|
|
689
|
+
selected_key,
|
|
690
|
+
database_source,
|
|
691
|
+
)
|
|
692
|
+
):
|
|
693
|
+
self._release_selected_dataset()
|
|
694
|
+
self.selected_run_id = None
|
|
695
|
+
|
|
696
|
+
for win in list(getattr(self, "windows", [])):
|
|
697
|
+
dataset_key = getattr(win, "_dataset_key", None)
|
|
698
|
+
if (
|
|
699
|
+
dataset_key is None
|
|
700
|
+
or not self._dataset_key_matches_replaced_instance(
|
|
701
|
+
dataset_key,
|
|
702
|
+
database_source,
|
|
703
|
+
)
|
|
704
|
+
):
|
|
705
|
+
continue
|
|
706
|
+
try:
|
|
707
|
+
win.close()
|
|
708
|
+
except Exception as err:
|
|
709
|
+
log_exception("Replaced database plot cleanup failed", err, __name__)
|
|
710
|
+
|
|
711
|
+
for dataset_key in list(self.dataset_holder):
|
|
712
|
+
if self._dataset_key_matches_replaced_instance(
|
|
713
|
+
dataset_key,
|
|
714
|
+
database_source,
|
|
715
|
+
):
|
|
716
|
+
self._evict_dataset_handle(dataset_key)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _handle_plot_database_replaced(self, database_path, source_window=None):
|
|
720
|
+
"""Reload the current source, or retire a stale plot from another DB."""
|
|
721
|
+
|
|
722
|
+
file_textbox = getattr(self, "fileTextbox", None)
|
|
723
|
+
if file_textbox is None:
|
|
724
|
+
# Lightweight PlotActionsMixin hosts do not own MainWindow's path
|
|
725
|
+
# field. Let their normal instance-change hook perform recovery
|
|
726
|
+
# instead of entering controller-specific invalidation code.
|
|
727
|
+
reload_if_changed = getattr(
|
|
728
|
+
self,
|
|
729
|
+
"_reload_if_database_instance_changed",
|
|
730
|
+
None,
|
|
731
|
+
)
|
|
732
|
+
if callable(reload_if_changed):
|
|
733
|
+
return reload_if_changed(database_path)
|
|
734
|
+
return False
|
|
735
|
+
|
|
736
|
+
current_path = file_textbox.text()
|
|
737
|
+
source_key = getattr(source_window, "_dataset_key", None)
|
|
738
|
+
logical_source_path = (
|
|
739
|
+
source_key.database_path
|
|
740
|
+
if source_key is not None
|
|
741
|
+
else logical_database_path(database_path)
|
|
742
|
+
)
|
|
743
|
+
if (
|
|
744
|
+
current_path
|
|
745
|
+
and logical_database_path(current_path) == logical_source_path
|
|
746
|
+
):
|
|
747
|
+
return self._reload_replaced_database(current_path)
|
|
748
|
+
|
|
749
|
+
# qPlot may retain plots from a database that is no longer selected in
|
|
750
|
+
# MainWindow. A replacement there must not switch the main UI back to
|
|
751
|
+
# that path; discard only those stale runtime objects instead.
|
|
752
|
+
self._invalidate_database_runtime_state(source_key or database_path)
|
|
753
|
+
return False
|
|
754
|
+
|
|
755
|
+
@QtCore.pyqtSlot(object)
|
|
756
|
+
def onClose(self, win):
|
|
757
|
+
"""
|
|
758
|
+
Event handler for closing a Plot window.
|
|
759
|
+
|
|
760
|
+
"""
|
|
761
|
+
self.windows.remove(win)
|
|
762
|
+
self.remove_ds_at(win._dataset_key)
|
|
763
|
+
self.post_admin()
|
|
764
|
+
self.show_status(f"Closed {win.label}", 3000)
|
|
765
|
+
del win
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _publish_displayed_plot_preview(self, plot, worker):
|
|
769
|
+
"""Show one displayed measurement while run previews are queued."""
|
|
770
|
+
|
|
771
|
+
preview = getattr(getattr(self, "infoBox", None), "preview", None)
|
|
772
|
+
if preview is None or getattr(preview, "_shutting_down", False):
|
|
773
|
+
return
|
|
774
|
+
key = getattr(plot, "_dataset_key", None)
|
|
775
|
+
if not isinstance(key, DatasetKey) or key.guid not in preview.run_metadata:
|
|
776
|
+
return
|
|
777
|
+
if getattr(worker, "dataset_completed", None) is not True:
|
|
778
|
+
# For an active run, reuse only the same row prefix currently
|
|
779
|
+
# advertised by the run list. Its next metadata update will
|
|
780
|
+
# discard the provisional image and request fresh derived work.
|
|
781
|
+
try:
|
|
782
|
+
displayed_count = int(worker.dataset_length_at_start)
|
|
783
|
+
listed_count = int(
|
|
784
|
+
preview.run_metadata[key.guid]["result_count"]
|
|
785
|
+
)
|
|
786
|
+
except (AttributeError, KeyError, TypeError, ValueError):
|
|
787
|
+
return
|
|
788
|
+
if displayed_count != listed_count:
|
|
789
|
+
return
|
|
790
|
+
if not dataset_key_matches_current_source(key):
|
|
791
|
+
return
|
|
792
|
+
if getattr(preview, "_trusted_derived_mode", False):
|
|
793
|
+
bridge = getattr(self, "_trusted_derived_bridge", None)
|
|
794
|
+
instance = getattr(bridge, "_database_instance", None)
|
|
795
|
+
if not getattr(bridge, "_accepting_publications", False):
|
|
796
|
+
return
|
|
797
|
+
else:
|
|
798
|
+
instance = getattr(preview, "database_instance", None)
|
|
799
|
+
if (
|
|
800
|
+
instance is None
|
|
801
|
+
or instance.logical_path != key.database_path
|
|
802
|
+
or instance.resolved_path != key.resolved_database_path
|
|
803
|
+
or instance.identity != key.database_identity
|
|
804
|
+
):
|
|
805
|
+
return
|
|
806
|
+
preview_generation = preview.generation
|
|
807
|
+
|
|
808
|
+
from ._widgets.preview import render_displayed_plot_preview
|
|
809
|
+
|
|
810
|
+
plot_preview = render_displayed_plot_preview(
|
|
811
|
+
worker,
|
|
812
|
+
getattr(plot, "param", None),
|
|
813
|
+
preview.preview_size,
|
|
814
|
+
)
|
|
815
|
+
if (
|
|
816
|
+
plot_preview is None
|
|
817
|
+
or preview.generation != preview_generation
|
|
818
|
+
or not dataset_key_matches_current_source(key)
|
|
819
|
+
):
|
|
820
|
+
return
|
|
821
|
+
if getattr(preview, "_trusted_derived_mode", False):
|
|
822
|
+
bridge = getattr(self, "_trusted_derived_bridge", None)
|
|
823
|
+
if (
|
|
824
|
+
not getattr(bridge, "_accepting_publications", False)
|
|
825
|
+
or getattr(bridge, "_database_instance", None) != instance
|
|
826
|
+
):
|
|
827
|
+
return
|
|
828
|
+
elif preview.database_instance != instance:
|
|
829
|
+
return
|
|
830
|
+
preview.publish_plot_preview(key.guid, plot_preview)
|
|
831
|
+
|
|
832
|
+
@QtCore.pyqtSlot(object, object, tuple)
|
|
833
|
+
def openWin(
|
|
834
|
+
self,
|
|
835
|
+
widget,
|
|
836
|
+
key_or_ds,
|
|
837
|
+
*args,
|
|
838
|
+
show=True,
|
|
839
|
+
dataset_key: DatasetKey | None = None,
|
|
840
|
+
**kargs,
|
|
841
|
+
):
|
|
842
|
+
"""
|
|
843
|
+
Handles opening Plot window, widget.
|
|
844
|
+
|
|
845
|
+
"""
|
|
846
|
+
if len(args) == 1 and isinstance(args[0], (tuple, list)):
|
|
847
|
+
args = tuple(args[0])
|
|
848
|
+
|
|
849
|
+
if isinstance(key_or_ds, DatasetKey):
|
|
850
|
+
ds = None
|
|
851
|
+
if dataset_key is not None and dataset_key != key_or_ds:
|
|
852
|
+
raise ValueError("Conflicting dataset keys supplied while opening a plot.")
|
|
853
|
+
dataset_key = key_or_ds
|
|
854
|
+
else:
|
|
855
|
+
ds = key_or_ds
|
|
856
|
+
if dataset_key is None:
|
|
857
|
+
dataset_key = self._current_dataset_key(ds.guid)
|
|
858
|
+
|
|
859
|
+
assert dataset_key is not None
|
|
860
|
+
self._ensure_bound_plot_dataset_key_can_be_read(dataset_key)
|
|
861
|
+
shared_handle = self._dataset_handle_for_key(dataset_key)
|
|
862
|
+
loaded_for_construction = shared_handle is None and ds is None
|
|
863
|
+
if shared_handle is not None:
|
|
864
|
+
construction_ds = shared_handle.dataset
|
|
865
|
+
elif loaded_for_construction:
|
|
866
|
+
construction_ds = self._load_dataset(dataset_key)
|
|
867
|
+
else:
|
|
868
|
+
construction_ds = ds
|
|
869
|
+
|
|
870
|
+
assert construction_ds.guid == dataset_key.guid
|
|
871
|
+
construction_holder = {
|
|
872
|
+
held_key: DatasetHandle(
|
|
873
|
+
dataset=handle.dataset,
|
|
874
|
+
users=handle.users,
|
|
875
|
+
database_identity=handle.database_identity,
|
|
876
|
+
sidecar_identities=handle.sidecar_identities,
|
|
877
|
+
)
|
|
878
|
+
for held_key, handle in self.dataset_holder.items()
|
|
879
|
+
}
|
|
880
|
+
construction_holder[dataset_key] = DatasetHandle(
|
|
881
|
+
dataset=construction_ds,
|
|
882
|
+
database_identity=dataset_key.database_identity,
|
|
883
|
+
sidecar_identities=dataset_key.sidecar_identities,
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
try:
|
|
887
|
+
win = widget(
|
|
888
|
+
dataset_key,
|
|
889
|
+
*args,
|
|
890
|
+
self.config,
|
|
891
|
+
self.threadPool,
|
|
892
|
+
construction_holder,
|
|
893
|
+
show=show,
|
|
894
|
+
**kargs,
|
|
895
|
+
)
|
|
896
|
+
window_type = getattr(win, "operation_kind", None)
|
|
897
|
+
if window_type is None:
|
|
898
|
+
legacy_window_type = win.__class__.__name__
|
|
899
|
+
if legacy_window_type in {"plot1d", "plot2d", "sweeper"}:
|
|
900
|
+
window_type = legacy_window_type
|
|
901
|
+
if window_type not in {"plot1d", "plot2d", "sweeper"}:
|
|
902
|
+
raise TypeError(f"Unknown window of type: {win.__class__.__name__}")
|
|
903
|
+
self._ensure_bound_plot_dataset_key_can_be_read(dataset_key)
|
|
904
|
+
except Exception:
|
|
905
|
+
if "win" in locals():
|
|
906
|
+
worker = getattr(win, "worker", None)
|
|
907
|
+
cancel = getattr(worker, "cancel", None)
|
|
908
|
+
if callable(cancel):
|
|
909
|
+
cancel()
|
|
910
|
+
close = getattr(win, "close", None)
|
|
911
|
+
if callable(close):
|
|
912
|
+
close()
|
|
913
|
+
if loaded_for_construction:
|
|
914
|
+
try:
|
|
915
|
+
close_dataset_connection(construction_ds)
|
|
916
|
+
except Exception as err:
|
|
917
|
+
log_exception("Unused plot dataset cleanup failed", err, __name__)
|
|
918
|
+
raise
|
|
919
|
+
|
|
920
|
+
win._dataset_holder = self.dataset_holder
|
|
921
|
+
self.add_ds_at(dataset_key, ds=construction_ds)
|
|
922
|
+
|
|
923
|
+
self.windows.append(win)
|
|
924
|
+
win._plot_preview_sink = self._publish_displayed_plot_preview
|
|
925
|
+
|
|
926
|
+
win.closed.connect(self.onClose)
|
|
927
|
+
database_replaced = getattr(win, "database_replaced", None)
|
|
928
|
+
connect_replacement = getattr(database_replaced, "connect", None)
|
|
929
|
+
if callable(connect_replacement):
|
|
930
|
+
connect_replacement(
|
|
931
|
+
lambda database_path, source_window=win: (
|
|
932
|
+
self._handle_plot_database_replaced(
|
|
933
|
+
database_path,
|
|
934
|
+
source_window,
|
|
935
|
+
)
|
|
936
|
+
)
|
|
937
|
+
)
|
|
938
|
+
win.make_ds.connect(self.add_ds_at)
|
|
939
|
+
win.previewTraceDropRequested.connect(self.add_dropped_preview_to_plot)
|
|
940
|
+
if window_type == "plot1d":
|
|
941
|
+
win.get_mergables.connect(lambda: self.get_1d_wins(win))
|
|
942
|
+
win.remove_dataset.connect(self.remove_ds_at)
|
|
943
|
+
win._trace_candidate_provider = (
|
|
944
|
+
lambda target_window=win: self._trace_candidates_for_plot(target_window)
|
|
945
|
+
)
|
|
946
|
+
win._trace_add_request = (
|
|
947
|
+
lambda source_key, parameter_name, target_window=win:
|
|
948
|
+
self.add_trace_to_plot(target_window, source_key, parameter_name)
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
elif window_type == "plot2d":
|
|
952
|
+
win.open_subplot.connect(self.openWin)
|
|
953
|
+
win.close_sweeps_requested.connect(self.close_sweeps_from_plot)
|
|
954
|
+
win._heatmap_candidate_provider = (
|
|
955
|
+
lambda target_window=win: self._heatmap_candidates_for_plot(
|
|
956
|
+
target_window
|
|
957
|
+
)
|
|
958
|
+
)
|
|
959
|
+
win._heatmap_add_request = (
|
|
960
|
+
lambda source_key, parameter_name, target_window=win:
|
|
961
|
+
self.add_heatmap_to_plot(
|
|
962
|
+
target_window,
|
|
963
|
+
source_key,
|
|
964
|
+
parameter_name,
|
|
965
|
+
)
|
|
966
|
+
)
|
|
967
|
+
remove_dataset = getattr(win, "remove_dataset", None)
|
|
968
|
+
if remove_dataset is not None:
|
|
969
|
+
remove_dataset.connect(self.remove_ds_at)
|
|
970
|
+
|
|
971
|
+
elif window_type == "sweeper":
|
|
972
|
+
win.merge_compatibility_changed.connect(self.post_admin)
|
|
973
|
+
for item in self.windows:
|
|
974
|
+
if item.ds == win.ds and item.param == win.param and isinstance(item, plot2d):
|
|
975
|
+
win.sweep_moved.connect(item.update_sweep_line)
|
|
976
|
+
win.remove_sweep.connect(item.remove_sweep)
|
|
977
|
+
item.sweep_moved.connect(win.update_sweep_line)
|
|
978
|
+
break
|
|
979
|
+
self.post_admin()
|
|
980
|
+
|
|
981
|
+
if show:
|
|
982
|
+
win.update_theme(self.config)
|
|
983
|
+
|
|
984
|
+
win.move(self._next_plot_x, self._next_plot_y)
|
|
985
|
+
win.show()
|
|
986
|
+
|
|
987
|
+
tolerance = 30
|
|
988
|
+
plot_width = win.width()
|
|
989
|
+
plot_height = win.height()
|
|
990
|
+
self._next_plot_x += plot_width
|
|
991
|
+
if self._next_plot_x + plot_width - tolerance > self.screenrect.right():
|
|
992
|
+
self._next_plot_x = self.screenrect.left()
|
|
993
|
+
self._next_plot_y += plot_height
|
|
994
|
+
|
|
995
|
+
if self._next_plot_y + plot_height - tolerance > self.screenrect.bottom():
|
|
996
|
+
self._next_plot_y = self.screenrect.top()
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
@QtCore.pyqtSlot(object, object)
|
|
1000
|
+
def close_sweeps_from_plot(self, source_win, sweep_ids):
|
|
1001
|
+
"""
|
|
1002
|
+
Close cut windows associated with a heatmap cut-line action.
|
|
1003
|
+
|
|
1004
|
+
"""
|
|
1005
|
+
target_ids = set(sweep_ids)
|
|
1006
|
+
if not target_ids:
|
|
1007
|
+
return
|
|
1008
|
+
|
|
1009
|
+
for item in list(self.windows):
|
|
1010
|
+
window_type = getattr(item, "operation_kind", item.__class__.__name__)
|
|
1011
|
+
if window_type != "sweeper":
|
|
1012
|
+
continue
|
|
1013
|
+
|
|
1014
|
+
try:
|
|
1015
|
+
same_source = item.ds == source_win.ds and item.param == source_win.param
|
|
1016
|
+
should_close = same_source and item.sweep_id in target_ids
|
|
1017
|
+
except (AttributeError, RuntimeError):
|
|
1018
|
+
continue
|
|
1019
|
+
|
|
1020
|
+
if should_close:
|
|
1021
|
+
item.close()
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
@QtCore.pyqtSlot(str)
|
|
1025
|
+
def updateSelected(self, guid):
|
|
1026
|
+
"""
|
|
1027
|
+
Dispatch ordinary selection to one accepted, DataSet-free reader.
|
|
1028
|
+
|
|
1029
|
+
"""
|
|
1030
|
+
if not PlotActionsMixin._generation_gate_allows_action(
|
|
1031
|
+
self,
|
|
1032
|
+
operation="selecting a run",
|
|
1033
|
+
):
|
|
1034
|
+
return
|
|
1035
|
+
if getattr(self, "_database_access_mode", None) == TRUSTED_LIVE_MODE:
|
|
1036
|
+
update_trusted = getattr(self, "_update_trusted_selected_run", None)
|
|
1037
|
+
if callable(update_trusted):
|
|
1038
|
+
update_trusted(guid)
|
|
1039
|
+
else:
|
|
1040
|
+
self.show_error(
|
|
1041
|
+
"Trusted Selection Unavailable",
|
|
1042
|
+
"The accepted trusted live-reader selection path is unavailable.",
|
|
1043
|
+
"Reload the database to start a new trusted session. qPlot did "
|
|
1044
|
+
"not fall back to loading a snapshot DataSet.",
|
|
1045
|
+
)
|
|
1046
|
+
return
|
|
1047
|
+
if (
|
|
1048
|
+
getattr(self, "_database_access_mode", None)
|
|
1049
|
+
== SNAPSHOT_FALLBACK_MODE
|
|
1050
|
+
):
|
|
1051
|
+
update_snapshot = getattr(self, "_update_snapshot_selected_run", None)
|
|
1052
|
+
if callable(update_snapshot):
|
|
1053
|
+
update_snapshot(guid)
|
|
1054
|
+
else:
|
|
1055
|
+
self.show_error(
|
|
1056
|
+
"Snapshot Selection Unavailable",
|
|
1057
|
+
"The accepted snapshot selection path is unavailable.",
|
|
1058
|
+
"Reload the database to start a new snapshot session. qPlot "
|
|
1059
|
+
"did not load a QCoDeS DataSet for ordinary selection.",
|
|
1060
|
+
)
|
|
1061
|
+
return
|
|
1062
|
+
self.show_error(
|
|
1063
|
+
"Selection Unavailable",
|
|
1064
|
+
"No accepted database reader is bound to the current selection.",
|
|
1065
|
+
"Reload the database to establish a trusted live or snapshot "
|
|
1066
|
+
"session. qPlot did not load a QCoDeS DataSet.",
|
|
1067
|
+
)
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def _run_metadata_for_guid(self, guid):
|
|
1071
|
+
run_list = getattr(self, "RunList", None)
|
|
1072
|
+
if run_list is None or not hasattr(run_list, "_item_for_guid"):
|
|
1073
|
+
return {}
|
|
1074
|
+
|
|
1075
|
+
item = run_list._item_for_guid(guid)
|
|
1076
|
+
if item is None:
|
|
1077
|
+
return {}
|
|
1078
|
+
return dict(getattr(item, "run_metadata", {}) or {})
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def _run_point_count(self, metadata):
|
|
1082
|
+
for key in ("result_count", "setpoint_count", "expected_results"):
|
|
1083
|
+
value = metadata.get(key)
|
|
1084
|
+
if value is None:
|
|
1085
|
+
continue
|
|
1086
|
+
try:
|
|
1087
|
+
return int(value)
|
|
1088
|
+
except (TypeError, ValueError):
|
|
1089
|
+
continue
|
|
1090
|
+
return None
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
@QtCore.pyqtSlot()
|
|
1094
|
+
@_trusted_plot_action
|
|
1095
|
+
def openRun(self):
|
|
1096
|
+
"""
|
|
1097
|
+
Plots the requested measurement for the requested run.
|
|
1098
|
+
|
|
1099
|
+
"""
|
|
1100
|
+
if not PlotActionsMixin._generation_gate_allows_action(
|
|
1101
|
+
self,
|
|
1102
|
+
operation="opening a plot",
|
|
1103
|
+
):
|
|
1104
|
+
return
|
|
1105
|
+
ds = self._dataset_for_plot_target()
|
|
1106
|
+
if ds is None:
|
|
1107
|
+
return
|
|
1108
|
+
|
|
1109
|
+
try:
|
|
1110
|
+
params = self._selected_measurement_params(ds)
|
|
1111
|
+
except Exception as error:
|
|
1112
|
+
log_exception("Run parameter enumeration failed", error, __name__)
|
|
1113
|
+
self._close_dataset_if_unowned(ds)
|
|
1114
|
+
self.show_error(
|
|
1115
|
+
"Run Load Failed",
|
|
1116
|
+
"Could not read the selected run's measurements.",
|
|
1117
|
+
str(error),
|
|
1118
|
+
)
|
|
1119
|
+
return
|
|
1120
|
+
if params is None:
|
|
1121
|
+
self._close_dataset_if_unowned(ds)
|
|
1122
|
+
return
|
|
1123
|
+
|
|
1124
|
+
dataset_key = self._accepted_plot_target_key(ds)
|
|
1125
|
+
if dataset_key is None:
|
|
1126
|
+
self._close_dataset_if_unowned(ds)
|
|
1127
|
+
return
|
|
1128
|
+
self._replace_selected_dataset(ds, dataset_key)
|
|
1129
|
+
try:
|
|
1130
|
+
self.openPlot(params=params)
|
|
1131
|
+
finally:
|
|
1132
|
+
self._release_trusted_action_dataset(ds)
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
@QtCore.pyqtSlot()
|
|
1136
|
+
@_trusted_plot_action
|
|
1137
|
+
def open_selected_run_all(self):
|
|
1138
|
+
"""
|
|
1139
|
+
Opens every plottable measurement in the currently selected table row.
|
|
1140
|
+
|
|
1141
|
+
"""
|
|
1142
|
+
if not PlotActionsMixin._generation_gate_allows_action(
|
|
1143
|
+
self,
|
|
1144
|
+
operation="opening plots",
|
|
1145
|
+
):
|
|
1146
|
+
return
|
|
1147
|
+
guid = getattr(self, "_selected_run_guid", None)
|
|
1148
|
+
if self.ds is None and not guid:
|
|
1149
|
+
self.show_status("Select a run before plotting all measurements.", 5000)
|
|
1150
|
+
return
|
|
1151
|
+
|
|
1152
|
+
self.openPlot(guid)
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
@QtCore.pyqtSlot()
|
|
1156
|
+
def exportRunCsv(self):
|
|
1157
|
+
"""
|
|
1158
|
+
Exports the requested run and measurement data to a CSV file.
|
|
1159
|
+
|
|
1160
|
+
"""
|
|
1161
|
+
if not PlotActionsMixin._generation_gate_allows_action(
|
|
1162
|
+
self,
|
|
1163
|
+
operation="exporting run data",
|
|
1164
|
+
):
|
|
1165
|
+
return
|
|
1166
|
+
request = self._run_csv_export_request()
|
|
1167
|
+
if request is None:
|
|
1168
|
+
return
|
|
1169
|
+
dataset_key, parameter_names, run_id = request
|
|
1170
|
+
self._export_measurement_csv(
|
|
1171
|
+
dataset_key,
|
|
1172
|
+
parameter_names,
|
|
1173
|
+
run_id=run_id,
|
|
1174
|
+
)
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
@QtCore.pyqtSlot(str)
|
|
1178
|
+
def export_preview_csv(self, parameter_name):
|
|
1179
|
+
"""
|
|
1180
|
+
Exports the measurement represented by a selected-run preview image.
|
|
1181
|
+
|
|
1182
|
+
"""
|
|
1183
|
+
if not PlotActionsMixin._generation_gate_allows_action(
|
|
1184
|
+
self,
|
|
1185
|
+
operation="exporting preview data",
|
|
1186
|
+
):
|
|
1187
|
+
return
|
|
1188
|
+
guid = getattr(self, "_selected_run_guid", None)
|
|
1189
|
+
if self.ds is None and not guid:
|
|
1190
|
+
self.show_status("Select a run before exporting a preview.", 5000)
|
|
1191
|
+
return
|
|
1192
|
+
|
|
1193
|
+
dataset_key = getattr(self, "_selected_dataset_key", None)
|
|
1194
|
+
if dataset_key is None:
|
|
1195
|
+
dataset_key = self._current_dataset_key(guid or self.ds.guid)
|
|
1196
|
+
self._export_preview_csv(dataset_key, parameter_name)
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
@QtCore.pyqtSlot(str, str)
|
|
1200
|
+
def export_run_preview_csv(self, guid, parameter_name):
|
|
1201
|
+
"""
|
|
1202
|
+
Exports the measurement represented by a run-table preview image.
|
|
1203
|
+
|
|
1204
|
+
"""
|
|
1205
|
+
if not PlotActionsMixin._generation_gate_allows_action(
|
|
1206
|
+
self,
|
|
1207
|
+
operation="exporting preview data",
|
|
1208
|
+
):
|
|
1209
|
+
return
|
|
1210
|
+
if not guid:
|
|
1211
|
+
self.show_status("Select a run before exporting a preview.", 5000)
|
|
1212
|
+
return
|
|
1213
|
+
|
|
1214
|
+
self._export_preview_csv(self._current_dataset_key(guid), parameter_name)
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
def _export_preview_csv(self, dataset_key, parameter_name):
|
|
1218
|
+
"""Export one preview through a fresh, action-owned dataset view."""
|
|
1219
|
+
default_name = self._default_preview_export_filename(
|
|
1220
|
+
dataset_key,
|
|
1221
|
+
parameter_name,
|
|
1222
|
+
)
|
|
1223
|
+
filename = self._choose_csv_export_filename(default_name)
|
|
1224
|
+
if filename is None:
|
|
1225
|
+
return
|
|
1226
|
+
|
|
1227
|
+
dataset = None
|
|
1228
|
+
original_dataset_key = dataset_key
|
|
1229
|
+
try:
|
|
1230
|
+
# Preview and plot datasets intentionally remain frozen at their
|
|
1231
|
+
# original read-only WAL snapshots. Export needs the newest
|
|
1232
|
+
# committed rows, so choose the destination first and only then
|
|
1233
|
+
# bypass DatasetHandle caching. No action-owned snapshot waits
|
|
1234
|
+
# behind the modal save dialog.
|
|
1235
|
+
try:
|
|
1236
|
+
dataset_key = self._bind_one_shot_dataset_key(dataset_key)
|
|
1237
|
+
dataset = self._load_run_csv_dataset(dataset_key)
|
|
1238
|
+
param = self._measurement_param_by_name(dataset, parameter_name)
|
|
1239
|
+
except DatabaseInstanceChangedError as err:
|
|
1240
|
+
self._handle_run_csv_source_replaced(
|
|
1241
|
+
dataset_key or original_dataset_key,
|
|
1242
|
+
)
|
|
1243
|
+
log_exception("Preview CSV run load failed", err, __name__)
|
|
1244
|
+
self.show_error(
|
|
1245
|
+
"Run Load Failed",
|
|
1246
|
+
f"Could not load run with GUID {original_dataset_key.guid}.",
|
|
1247
|
+
str(err),
|
|
1248
|
+
)
|
|
1249
|
+
return
|
|
1250
|
+
except Exception as err:
|
|
1251
|
+
log_exception("Preview CSV run load failed", err, __name__)
|
|
1252
|
+
self.show_error(
|
|
1253
|
+
"Run Load Failed",
|
|
1254
|
+
f"Could not load run with GUID {dataset_key.guid}.",
|
|
1255
|
+
str(err),
|
|
1256
|
+
)
|
|
1257
|
+
return
|
|
1258
|
+
|
|
1259
|
+
if param is None:
|
|
1260
|
+
self.show_status(
|
|
1261
|
+
f"No preview export found for {parameter_name}.",
|
|
1262
|
+
5000,
|
|
1263
|
+
)
|
|
1264
|
+
return
|
|
1265
|
+
|
|
1266
|
+
try:
|
|
1267
|
+
frame = self._measurement_dataframe(dataset, [param])
|
|
1268
|
+
# The read-only connection intentionally keeps its original
|
|
1269
|
+
# SQLite snapshot readable if the path is replaced. Reject
|
|
1270
|
+
# that obsolete frame before it reaches the destination.
|
|
1271
|
+
self._require_run_csv_source_current(dataset_key)
|
|
1272
|
+
self._publish_preview_csv(frame, filename, dataset_key)
|
|
1273
|
+
except DatabaseInstanceChangedError as err:
|
|
1274
|
+
self._handle_run_csv_source_replaced(dataset_key)
|
|
1275
|
+
log_exception("Preview CSV export failed", err, __name__)
|
|
1276
|
+
self.show_error(
|
|
1277
|
+
"CSV Export Failed",
|
|
1278
|
+
"Could not export the selected measurement data.",
|
|
1279
|
+
str(err),
|
|
1280
|
+
)
|
|
1281
|
+
return
|
|
1282
|
+
except Exception as err:
|
|
1283
|
+
log_exception("Preview CSV export failed", err, __name__)
|
|
1284
|
+
self.show_error(
|
|
1285
|
+
"CSV Export Failed",
|
|
1286
|
+
"Could not export the selected measurement data.",
|
|
1287
|
+
str(err),
|
|
1288
|
+
)
|
|
1289
|
+
return
|
|
1290
|
+
|
|
1291
|
+
self.show_status(f"Exported CSV: {filename}", 5000)
|
|
1292
|
+
finally:
|
|
1293
|
+
if dataset is not None:
|
|
1294
|
+
try:
|
|
1295
|
+
close_dataset_connection(dataset)
|
|
1296
|
+
except Exception as err:
|
|
1297
|
+
log_exception(
|
|
1298
|
+
"Preview CSV dataset cleanup failed",
|
|
1299
|
+
err,
|
|
1300
|
+
__name__,
|
|
1301
|
+
)
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def _export_measurement_csv(
|
|
1305
|
+
self,
|
|
1306
|
+
dataset_key,
|
|
1307
|
+
parameter_names,
|
|
1308
|
+
*,
|
|
1309
|
+
run_id=None,
|
|
1310
|
+
):
|
|
1311
|
+
"""Export a captured run request through a fresh read-only dataset."""
|
|
1312
|
+
parameter_names = tuple(str(name) for name in parameter_names)
|
|
1313
|
+
if not parameter_names:
|
|
1314
|
+
self.show_status("No plottable measurements to export for this run.", 5000)
|
|
1315
|
+
return
|
|
1316
|
+
|
|
1317
|
+
default_name = self._default_run_csv_export_filename(
|
|
1318
|
+
dataset_key,
|
|
1319
|
+
parameter_names,
|
|
1320
|
+
run_id=run_id,
|
|
1321
|
+
)
|
|
1322
|
+
filename = self._choose_csv_export_filename(default_name)
|
|
1323
|
+
if filename is None:
|
|
1324
|
+
return
|
|
1325
|
+
|
|
1326
|
+
dataset = None
|
|
1327
|
+
original_dataset_key = dataset_key
|
|
1328
|
+
try:
|
|
1329
|
+
# The destination is approved before this action-owned view is
|
|
1330
|
+
# opened, so a modal save dialog cannot retain an obsolete SQLite
|
|
1331
|
+
# snapshot. The read layer also binds acquisition to the database
|
|
1332
|
+
# identity captured in ``dataset_key``.
|
|
1333
|
+
dataset_key = self._bind_one_shot_dataset_key(dataset_key)
|
|
1334
|
+
dataset = self._load_run_csv_dataset(dataset_key)
|
|
1335
|
+
params = self._measurement_params_by_names(dataset, parameter_names)
|
|
1336
|
+
frame = self._measurement_dataframe(dataset, params)
|
|
1337
|
+
self._require_run_csv_source_current(dataset_key)
|
|
1338
|
+
write_export_atomically(
|
|
1339
|
+
filename,
|
|
1340
|
+
lambda temporary: frame.to_csv(temporary, index=False),
|
|
1341
|
+
before_publish=(
|
|
1342
|
+
lambda: self._require_run_csv_source_current(dataset_key)
|
|
1343
|
+
),
|
|
1344
|
+
)
|
|
1345
|
+
except DatabaseInstanceChangedError:
|
|
1346
|
+
self._handle_run_csv_source_replaced(
|
|
1347
|
+
dataset_key or original_dataset_key,
|
|
1348
|
+
)
|
|
1349
|
+
return
|
|
1350
|
+
except Exception as err:
|
|
1351
|
+
log_exception("CSV export failed", err, __name__)
|
|
1352
|
+
self.show_error(
|
|
1353
|
+
"CSV Export Failed",
|
|
1354
|
+
"Could not export the selected measurement data.",
|
|
1355
|
+
str(err),
|
|
1356
|
+
)
|
|
1357
|
+
return
|
|
1358
|
+
finally:
|
|
1359
|
+
if dataset is not None:
|
|
1360
|
+
try:
|
|
1361
|
+
close_dataset_connection(dataset)
|
|
1362
|
+
except Exception as err:
|
|
1363
|
+
log_exception("CSV dataset cleanup failed", err, __name__)
|
|
1364
|
+
|
|
1365
|
+
self.show_status(f"Exported CSV: {filename}", 5000)
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def _load_run_csv_dataset(self, dataset_key):
|
|
1369
|
+
"""Fresh-load one export view for an exact database instance."""
|
|
1370
|
+
self._require_run_csv_source_current(dataset_key)
|
|
1371
|
+
load_kwargs = {}
|
|
1372
|
+
if dataset_key.database_identity is not None:
|
|
1373
|
+
load_kwargs["expected_database_identity"] = (
|
|
1374
|
+
dataset_key.database_identity
|
|
1375
|
+
)
|
|
1376
|
+
dataset = load_by_guid_read_only(
|
|
1377
|
+
dataset_key.guid,
|
|
1378
|
+
dataset_key.database_path,
|
|
1379
|
+
**load_kwargs,
|
|
1380
|
+
)
|
|
1381
|
+
try:
|
|
1382
|
+
self._require_run_csv_source_current(dataset_key)
|
|
1383
|
+
except Exception:
|
|
1384
|
+
try:
|
|
1385
|
+
close_dataset_connection(dataset)
|
|
1386
|
+
finally:
|
|
1387
|
+
raise
|
|
1388
|
+
return dataset
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
def _bind_one_shot_dataset_key(self, dataset_key):
|
|
1392
|
+
"""Bind compatible first sidecars before an action-owned read."""
|
|
1393
|
+
|
|
1394
|
+
bound_key = bind_dataset_key_to_current_source(dataset_key)
|
|
1395
|
+
if bound_key is None:
|
|
1396
|
+
raise DatabaseInstanceChangedError(
|
|
1397
|
+
"The database or its SQLite sidecars were replaced before qPlot "
|
|
1398
|
+
"could open the export view."
|
|
1399
|
+
)
|
|
1400
|
+
return bound_key
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
def _require_run_csv_source_current(self, dataset_key):
|
|
1404
|
+
"""Reject an export when its captured database instance was replaced."""
|
|
1405
|
+
if not self._bound_plot_dataset_key_is_current(dataset_key):
|
|
1406
|
+
raise DatabaseInstanceChangedError(
|
|
1407
|
+
"The database or its SQLite sidecars were replaced while "
|
|
1408
|
+
"qPlot was exporting run data."
|
|
1409
|
+
)
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
def _handle_run_csv_source_replaced(self, dataset_key):
|
|
1413
|
+
"""Request replacement recovery without reporting a generic CSV error."""
|
|
1414
|
+
reload_if_changed = getattr(
|
|
1415
|
+
self,
|
|
1416
|
+
"_reload_if_database_instance_changed",
|
|
1417
|
+
None,
|
|
1418
|
+
)
|
|
1419
|
+
if callable(reload_if_changed):
|
|
1420
|
+
reload_if_changed(dataset_key.database_path)
|
|
1421
|
+
self.show_status(
|
|
1422
|
+
"CSV export stopped because the database was replaced; "
|
|
1423
|
+
"reloading the source. No CSV was written.",
|
|
1424
|
+
5000,
|
|
1425
|
+
)
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
def _choose_csv_export_filename(self, default_name):
|
|
1429
|
+
try:
|
|
1430
|
+
destination = choose_export_path(
|
|
1431
|
+
self,
|
|
1432
|
+
caption="Export CSV",
|
|
1433
|
+
suggested_path=default_name,
|
|
1434
|
+
name_filter="CSV files (*.csv)",
|
|
1435
|
+
required_suffix=".csv",
|
|
1436
|
+
replace_title="Replace CSV File?",
|
|
1437
|
+
file_description="CSV file",
|
|
1438
|
+
)
|
|
1439
|
+
except Exception as err:
|
|
1440
|
+
log_exception("CSV export destination rejected", err, __name__)
|
|
1441
|
+
self.show_error(
|
|
1442
|
+
"CSV Export Failed",
|
|
1443
|
+
"qPlot could not safely use the selected CSV destination.",
|
|
1444
|
+
str(err),
|
|
1445
|
+
)
|
|
1446
|
+
return None
|
|
1447
|
+
if destination is None:
|
|
1448
|
+
self.show_status("CSV export cancelled.", 3000)
|
|
1449
|
+
return None
|
|
1450
|
+
return destination
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
def _publish_preview_csv(self, frame, filename, dataset_key):
|
|
1454
|
+
"""Stage a preview CSV and publish it only for the requested source."""
|
|
1455
|
+
# The source must still be the captured database instance immediately
|
|
1456
|
+
# before the staged file atomically replaces the requested destination.
|
|
1457
|
+
write_export_atomically(
|
|
1458
|
+
filename,
|
|
1459
|
+
lambda temporary: frame.to_csv(temporary, index=False),
|
|
1460
|
+
before_publish=(
|
|
1461
|
+
lambda: self._require_run_csv_source_current(dataset_key)
|
|
1462
|
+
),
|
|
1463
|
+
)
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
@QtCore.pyqtSlot(str)
|
|
1467
|
+
@_trusted_plot_action
|
|
1468
|
+
def openPlot(self, guid: str | DatasetKey = None, params: list = None, show: bool = True):
|
|
1469
|
+
"""
|
|
1470
|
+
Opens plot windows for the selected or requested run.
|
|
1471
|
+
|
|
1472
|
+
"""
|
|
1473
|
+
retained_dataset_key = isinstance(guid, DatasetKey)
|
|
1474
|
+
target_path = guid.database_path if retained_dataset_key else None
|
|
1475
|
+
if not PlotActionsMixin._generation_gate_allows_action(
|
|
1476
|
+
self,
|
|
1477
|
+
target_path,
|
|
1478
|
+
"opening plots",
|
|
1479
|
+
):
|
|
1480
|
+
return
|
|
1481
|
+
|
|
1482
|
+
# ``DataSet`` implements truthiness through ``__len__``, which queries
|
|
1483
|
+
# its SQLite connection. A Windows replacement test (and a real
|
|
1484
|
+
# replacement race) may have deliberately released that old read-only
|
|
1485
|
+
# connection before this requested DatasetKey is invalidated. Presence
|
|
1486
|
+
# of a selected dataset is the relevant check here, not its row count.
|
|
1487
|
+
selected_guid = getattr(self, "_selected_run_guid", None)
|
|
1488
|
+
if self.ds is None and not guid and not selected_guid:
|
|
1489
|
+
self.show_status("Select a run before opening plots.", 5000)
|
|
1490
|
+
return
|
|
1491
|
+
|
|
1492
|
+
self.show_status("Opening plots...", 0)
|
|
1493
|
+
|
|
1494
|
+
if isinstance(guid, DatasetKey):
|
|
1495
|
+
dataset_key = guid
|
|
1496
|
+
guid = dataset_key.guid
|
|
1497
|
+
else:
|
|
1498
|
+
target_guid = guid or selected_guid or getattr(self.ds, "guid", "")
|
|
1499
|
+
dataset_key = self._current_dataset_key(target_guid)
|
|
1500
|
+
|
|
1501
|
+
loaded_by_open_plot = False
|
|
1502
|
+
try:
|
|
1503
|
+
if retained_dataset_key:
|
|
1504
|
+
self._ensure_bound_plot_dataset_key_can_be_read(dataset_key)
|
|
1505
|
+
else:
|
|
1506
|
+
self._ensure_dataset_key_can_be_read(dataset_key)
|
|
1507
|
+
if (
|
|
1508
|
+
self.ds is None
|
|
1509
|
+
or getattr(self, "_selected_dataset_key", None) != dataset_key
|
|
1510
|
+
):
|
|
1511
|
+
handle = self._dataset_handle_for_key(dataset_key)
|
|
1512
|
+
if handle is None:
|
|
1513
|
+
ds = self._load_dataset(dataset_key)
|
|
1514
|
+
loaded_by_open_plot = True
|
|
1515
|
+
else:
|
|
1516
|
+
ds = handle.dataset
|
|
1517
|
+
else:
|
|
1518
|
+
ds = self.ds
|
|
1519
|
+
|
|
1520
|
+
prior_key = dataset_key
|
|
1521
|
+
if retained_dataset_key:
|
|
1522
|
+
self._ensure_bound_plot_dataset_key_can_be_read(dataset_key)
|
|
1523
|
+
else:
|
|
1524
|
+
dataset_key = self._bind_plot_dataset_key(dataset_key)
|
|
1525
|
+
if dataset_key is None:
|
|
1526
|
+
raise RuntimeError(
|
|
1527
|
+
"The database or its SQLite sidecars changed while "
|
|
1528
|
+
"the plot dataset was being acquired."
|
|
1529
|
+
)
|
|
1530
|
+
if (
|
|
1531
|
+
self.ds is ds
|
|
1532
|
+
and getattr(self, "_selected_dataset_key", None)
|
|
1533
|
+
== prior_key
|
|
1534
|
+
):
|
|
1535
|
+
self._selected_dataset_key = dataset_key
|
|
1536
|
+
self._plot_target_dataset_key = dataset_key
|
|
1537
|
+
except Exception as err:
|
|
1538
|
+
if loaded_by_open_plot and "ds" in locals():
|
|
1539
|
+
self._close_dataset_if_unowned(
|
|
1540
|
+
ds,
|
|
1541
|
+
context="Unused plot dataset cleanup failed",
|
|
1542
|
+
)
|
|
1543
|
+
log_exception("Plot run load failed", err, __name__)
|
|
1544
|
+
self.show_error("Run Load Failed", "Could not load the selected run.", str(err))
|
|
1545
|
+
return
|
|
1546
|
+
|
|
1547
|
+
opened = 0
|
|
1548
|
+
skipped = 0
|
|
1549
|
+
unsupported = []
|
|
1550
|
+
try:
|
|
1551
|
+
if not params:
|
|
1552
|
+
params = ds.get_parameters()
|
|
1553
|
+
|
|
1554
|
+
for param in params:
|
|
1555
|
+
if param.depends_on == "":
|
|
1556
|
+
continue
|
|
1557
|
+
|
|
1558
|
+
depends_on = param.depends_on_
|
|
1559
|
+
if len(depends_on) > MAX_SUPPORTED_PLOT_DIMENSIONS:
|
|
1560
|
+
unsupported.append(
|
|
1561
|
+
unsupported_plot_message(param.name, depends_on)
|
|
1562
|
+
)
|
|
1563
|
+
continue
|
|
1564
|
+
skip = False
|
|
1565
|
+
|
|
1566
|
+
if len(depends_on) == 1:
|
|
1567
|
+
for win in self.windows:
|
|
1568
|
+
if (
|
|
1569
|
+
win._dataset_key == dataset_key
|
|
1570
|
+
and win.param == param
|
|
1571
|
+
and isinstance(win, plot1d)
|
|
1572
|
+
):
|
|
1573
|
+
skipped += 1
|
|
1574
|
+
skip = True
|
|
1575
|
+
break
|
|
1576
|
+
if skip:
|
|
1577
|
+
continue
|
|
1578
|
+
|
|
1579
|
+
self.openWin(
|
|
1580
|
+
plot1d,
|
|
1581
|
+
ds,
|
|
1582
|
+
param,
|
|
1583
|
+
refrate=refresh_interval_value(self.spinBox),
|
|
1584
|
+
show=show,
|
|
1585
|
+
dataset_key=dataset_key,
|
|
1586
|
+
)
|
|
1587
|
+
opened += 1
|
|
1588
|
+
|
|
1589
|
+
else:
|
|
1590
|
+
for win in self.windows:
|
|
1591
|
+
if (
|
|
1592
|
+
win._dataset_key == dataset_key
|
|
1593
|
+
and win.param == param
|
|
1594
|
+
and isinstance(win, plot2d)
|
|
1595
|
+
):
|
|
1596
|
+
skipped += 1
|
|
1597
|
+
skip = True
|
|
1598
|
+
break
|
|
1599
|
+
if skip:
|
|
1600
|
+
continue
|
|
1601
|
+
|
|
1602
|
+
self.openWin(
|
|
1603
|
+
plot2d,
|
|
1604
|
+
ds,
|
|
1605
|
+
param,
|
|
1606
|
+
refrate=refresh_interval_value(self.spinBox),
|
|
1607
|
+
show=show,
|
|
1608
|
+
dataset_key=dataset_key,
|
|
1609
|
+
)
|
|
1610
|
+
opened += 1
|
|
1611
|
+
|
|
1612
|
+
self.post_admin()
|
|
1613
|
+
|
|
1614
|
+
if opened and show:
|
|
1615
|
+
preview = getattr(getattr(self, "infoBox", None), "preview", None)
|
|
1616
|
+
request_previews = getattr(preview, "request_guids", None)
|
|
1617
|
+
if callable(request_previews):
|
|
1618
|
+
request_previews([dataset_key.guid])
|
|
1619
|
+
|
|
1620
|
+
if opened:
|
|
1621
|
+
noun = "plot" if opened == 1 else "plots"
|
|
1622
|
+
message = f"Opened {opened} {noun}."
|
|
1623
|
+
if unsupported:
|
|
1624
|
+
message += f" Skipped {len(unsupported)} unsupported measurement(s)."
|
|
1625
|
+
self.show_status(message, 8000 if unsupported else 5000)
|
|
1626
|
+
elif skipped:
|
|
1627
|
+
self.show_status("Selected plot windows are already open.", 5000)
|
|
1628
|
+
elif unsupported:
|
|
1629
|
+
self.show_status(unsupported[0], 10_000)
|
|
1630
|
+
else:
|
|
1631
|
+
self.show_status("No plottable parameters found for this run.", 5000)
|
|
1632
|
+
|
|
1633
|
+
except Exception as err:
|
|
1634
|
+
log_exception("Plot open failed", err, __name__)
|
|
1635
|
+
self.show_error("Plot Open Failed", "Could not open plot windows.", str(err))
|
|
1636
|
+
finally:
|
|
1637
|
+
dataset_is_held = dataset_key in self.dataset_holder or any(
|
|
1638
|
+
handle.dataset is ds for handle in self.dataset_holder.values()
|
|
1639
|
+
)
|
|
1640
|
+
if loaded_by_open_plot and self.ds is not ds and not dataset_is_held:
|
|
1641
|
+
self._close_dataset_if_unowned(
|
|
1642
|
+
ds,
|
|
1643
|
+
context="Unused plot dataset cleanup failed",
|
|
1644
|
+
)
|
|
1645
|
+
|
|
1646
|
+
|
|
1647
|
+
@_trusted_plot_action
|
|
1648
|
+
def open_param_by_index(self, index: int):
|
|
1649
|
+
"""
|
|
1650
|
+
Open the indexed dependent parameter for the selected run.
|
|
1651
|
+
|
|
1652
|
+
"""
|
|
1653
|
+
if not PlotActionsMixin._generation_gate_allows_action(
|
|
1654
|
+
self,
|
|
1655
|
+
operation="opening a plot",
|
|
1656
|
+
):
|
|
1657
|
+
return
|
|
1658
|
+
if self.ds is None:
|
|
1659
|
+
dataset = self._dataset_for_plot_target()
|
|
1660
|
+
if dataset is None:
|
|
1661
|
+
return
|
|
1662
|
+
dataset_key = self._accepted_plot_target_key(dataset)
|
|
1663
|
+
if dataset_key is None:
|
|
1664
|
+
self._close_dataset_if_unowned(dataset)
|
|
1665
|
+
return
|
|
1666
|
+
self._replace_selected_dataset(
|
|
1667
|
+
dataset,
|
|
1668
|
+
dataset_key,
|
|
1669
|
+
)
|
|
1670
|
+
action_dataset = self.ds
|
|
1671
|
+
try:
|
|
1672
|
+
params = [
|
|
1673
|
+
param
|
|
1674
|
+
for param in action_dataset.get_parameters()
|
|
1675
|
+
if param.depends_on != ""
|
|
1676
|
+
]
|
|
1677
|
+
if index >= len(params):
|
|
1678
|
+
self.show_status(f"Run has no parameter {index + 1}.", 5000)
|
|
1679
|
+
return
|
|
1680
|
+
|
|
1681
|
+
self.openPlot(params=[params[index]])
|
|
1682
|
+
finally:
|
|
1683
|
+
self._release_trusted_action_dataset(action_dataset)
|
|
1684
|
+
|
|
1685
|
+
|
|
1686
|
+
@QtCore.pyqtSlot(str)
|
|
1687
|
+
@_trusted_plot_action
|
|
1688
|
+
def open_preview_plot(self, parameter_name):
|
|
1689
|
+
"""
|
|
1690
|
+
Open the plot represented by a double-clicked preview image.
|
|
1691
|
+
|
|
1692
|
+
"""
|
|
1693
|
+
if not PlotActionsMixin._generation_gate_allows_action(
|
|
1694
|
+
self,
|
|
1695
|
+
operation="opening a preview plot",
|
|
1696
|
+
):
|
|
1697
|
+
return
|
|
1698
|
+
if self.ds is None:
|
|
1699
|
+
dataset = self._dataset_for_plot_target()
|
|
1700
|
+
if dataset is None:
|
|
1701
|
+
return
|
|
1702
|
+
dataset_key = self._accepted_plot_target_key(dataset)
|
|
1703
|
+
if dataset_key is None:
|
|
1704
|
+
self._close_dataset_if_unowned(dataset)
|
|
1705
|
+
return
|
|
1706
|
+
self._replace_selected_dataset(
|
|
1707
|
+
dataset,
|
|
1708
|
+
dataset_key,
|
|
1709
|
+
)
|
|
1710
|
+
action_dataset = self.ds
|
|
1711
|
+
try:
|
|
1712
|
+
for param in action_dataset.get_parameters():
|
|
1713
|
+
if param.name == parameter_name and param.depends_on != "":
|
|
1714
|
+
self.openPlot(params=[param])
|
|
1715
|
+
return
|
|
1716
|
+
|
|
1717
|
+
self.show_status(f"No preview plot found for {parameter_name}.", 5000)
|
|
1718
|
+
finally:
|
|
1719
|
+
self._release_trusted_action_dataset(action_dataset)
|
|
1720
|
+
|
|
1721
|
+
|
|
1722
|
+
def open_selected_measurement(self, parameter_name):
|
|
1723
|
+
"""Materialise the selected run only after an explicit menu action."""
|
|
1724
|
+
return self.open_preview_plot(str(parameter_name))
|
|
1725
|
+
|
|
1726
|
+
|
|
1727
|
+
@QtCore.pyqtSlot(str, str)
|
|
1728
|
+
@_trusted_plot_action
|
|
1729
|
+
def open_run_preview_plot(self, guid, parameter_name):
|
|
1730
|
+
"""
|
|
1731
|
+
Open the plot represented by a double-clicked run-table preview image.
|
|
1732
|
+
|
|
1733
|
+
"""
|
|
1734
|
+
if not PlotActionsMixin._generation_gate_allows_action(
|
|
1735
|
+
self,
|
|
1736
|
+
operation="opening a preview plot",
|
|
1737
|
+
):
|
|
1738
|
+
return
|
|
1739
|
+
if not guid:
|
|
1740
|
+
self.show_status("Select a run before opening a preview plot.", 5000)
|
|
1741
|
+
return
|
|
1742
|
+
|
|
1743
|
+
dataset_key = self._current_dataset_key(guid)
|
|
1744
|
+
if (
|
|
1745
|
+
self.ds is None
|
|
1746
|
+
or getattr(self, "_selected_dataset_key", None) != dataset_key
|
|
1747
|
+
):
|
|
1748
|
+
try:
|
|
1749
|
+
handle = self._dataset_handle_for_key(dataset_key)
|
|
1750
|
+
if handle is None:
|
|
1751
|
+
dataset = self._load_dataset(dataset_key)
|
|
1752
|
+
else:
|
|
1753
|
+
dataset = handle.dataset
|
|
1754
|
+
self._replace_selected_dataset(dataset, dataset_key)
|
|
1755
|
+
except Exception as err:
|
|
1756
|
+
log_exception("Preview plot run load failed", err, __name__)
|
|
1757
|
+
self.show_error("Run Load Failed", f"Could not load run with GUID {guid}.", str(err))
|
|
1758
|
+
return
|
|
1759
|
+
|
|
1760
|
+
self.open_preview_plot(parameter_name)
|
|
1761
|
+
|
|
1762
|
+
|
|
1763
|
+
@QtCore.pyqtSlot(object, object, str)
|
|
1764
|
+
def add_dropped_preview_to_plot(self, target_win, source_identity, parameter_name):
|
|
1765
|
+
"""
|
|
1766
|
+
Add a run-table preview to the plot it was dropped onto.
|
|
1767
|
+
|
|
1768
|
+
"""
|
|
1769
|
+
# The plot is normally in front of the main window when a drop occurs.
|
|
1770
|
+
# Keep its status bar in sync with the main-window feedback produced by
|
|
1771
|
+
# ``add_trace_to_plot`` so a post-drop rejection is visible where the
|
|
1772
|
+
# user performed the action.
|
|
1773
|
+
previous_target = getattr(self, "_preview_drop_feedback_window", None)
|
|
1774
|
+
self._preview_drop_feedback_window = target_win
|
|
1775
|
+
try:
|
|
1776
|
+
return self.add_trace_to_plot(
|
|
1777
|
+
target_win,
|
|
1778
|
+
source_identity,
|
|
1779
|
+
parameter_name,
|
|
1780
|
+
)
|
|
1781
|
+
finally:
|
|
1782
|
+
self._preview_drop_feedback_window = previous_target
|
|
1783
|
+
|
|
1784
|
+
|
|
1785
|
+
@_trusted_plot_action
|
|
1786
|
+
def add_trace_to_plot(self, target_win, source_identity, parameter_name, param=None):
|
|
1787
|
+
"""
|
|
1788
|
+
Add a compatible parameter to an existing plot.
|
|
1789
|
+
|
|
1790
|
+
The historical method name remains the entry point used by run-table
|
|
1791
|
+
menus and preview drops. Heatmap targets dispatch to the corresponding
|
|
1792
|
+
layer workflow; line targets retain the existing secondary-trace path.
|
|
1793
|
+
|
|
1794
|
+
"""
|
|
1795
|
+
if getattr(target_win, "operation_kind", None) == "plot2d":
|
|
1796
|
+
return self.add_heatmap_to_plot(
|
|
1797
|
+
target_win,
|
|
1798
|
+
source_identity,
|
|
1799
|
+
parameter_name,
|
|
1800
|
+
param=param,
|
|
1801
|
+
)
|
|
1802
|
+
|
|
1803
|
+
source_path = (
|
|
1804
|
+
source_identity.database_path
|
|
1805
|
+
if isinstance(source_identity, DatasetKey)
|
|
1806
|
+
else None
|
|
1807
|
+
)
|
|
1808
|
+
if not PlotActionsMixin._generation_gate_allows_action(
|
|
1809
|
+
self,
|
|
1810
|
+
source_path,
|
|
1811
|
+
"adding a preview trace",
|
|
1812
|
+
):
|
|
1813
|
+
return False
|
|
1814
|
+
if target_win is None or not hasattr(target_win, "option_boxes"):
|
|
1815
|
+
self.show_status("Drop traces onto a compatible line plot.", 5000)
|
|
1816
|
+
return False
|
|
1817
|
+
|
|
1818
|
+
if isinstance(source_identity, DatasetKey):
|
|
1819
|
+
source_key = source_identity
|
|
1820
|
+
else:
|
|
1821
|
+
source_key = self._current_dataset_key(source_identity)
|
|
1822
|
+
source_guid = source_key.guid
|
|
1823
|
+
if param is None:
|
|
1824
|
+
try:
|
|
1825
|
+
param = self._parameter_from_key(source_key, parameter_name)
|
|
1826
|
+
except Exception as err:
|
|
1827
|
+
log_exception("Trace source run load failed", err, __name__)
|
|
1828
|
+
self.show_error(
|
|
1829
|
+
"Run Load Failed",
|
|
1830
|
+
f"Could not load run with GUID {source_guid}.",
|
|
1831
|
+
str(err),
|
|
1832
|
+
)
|
|
1833
|
+
return False
|
|
1834
|
+
|
|
1835
|
+
if param is None or not getattr(param, "depends_on", ""):
|
|
1836
|
+
self.show_status(f"No preview plot found for {parameter_name}.", 5000)
|
|
1837
|
+
return False
|
|
1838
|
+
|
|
1839
|
+
if len(getattr(param, "depends_on_", ())) != 1:
|
|
1840
|
+
self.show_status("Only 1D measurements can be added as traces.", 5000)
|
|
1841
|
+
return False
|
|
1842
|
+
|
|
1843
|
+
if tuple(param.depends_on_) != tuple(target_win.param.depends_on_):
|
|
1844
|
+
self.show_status(
|
|
1845
|
+
f"Cannot add {parameter_name}; the plot axes do not match.",
|
|
1846
|
+
5000,
|
|
1847
|
+
)
|
|
1848
|
+
return False
|
|
1849
|
+
|
|
1850
|
+
from_win = self._plot_window_for_param(source_key, param)
|
|
1851
|
+
if from_win == target_win:
|
|
1852
|
+
self.show_status(f"Skipped {target_win.label}; source and target are the same.", 5000)
|
|
1853
|
+
return False
|
|
1854
|
+
|
|
1855
|
+
if from_win is None:
|
|
1856
|
+
from_win = self._open_hidden_trace_window(source_key, param, target_win)
|
|
1857
|
+
if from_win is None:
|
|
1858
|
+
return False
|
|
1859
|
+
|
|
1860
|
+
self.get_1d_wins(target_win)
|
|
1861
|
+
if target_win.option_boxes[-1].isEnabled():
|
|
1862
|
+
box = target_win.option_boxes[-1]
|
|
1863
|
+
else:
|
|
1864
|
+
target_win.add_option_box()
|
|
1865
|
+
box = target_win.option_boxes[-1]
|
|
1866
|
+
|
|
1867
|
+
source_trace_key = getattr(from_win, "_trace_key", from_win.label)
|
|
1868
|
+
index = _combo_index_for_data(box.option_box, source_trace_key)
|
|
1869
|
+
if index < 0:
|
|
1870
|
+
index = box.option_box.findText(from_win.label)
|
|
1871
|
+
if index < 0:
|
|
1872
|
+
self.show_status(
|
|
1873
|
+
f"Cannot add {parameter_name}; it is already shown or incompatible.",
|
|
1874
|
+
5000,
|
|
1875
|
+
)
|
|
1876
|
+
if not from_win.visible:
|
|
1877
|
+
from_win.close()
|
|
1878
|
+
return False
|
|
1879
|
+
|
|
1880
|
+
box.option_box.setCurrentIndex(index)
|
|
1881
|
+
if not getattr(from_win, "visible", False):
|
|
1882
|
+
from_win.close()
|
|
1883
|
+
return True
|
|
1884
|
+
|
|
1885
|
+
|
|
1886
|
+
def _trace_candidates_for_plot(self, target_win):
|
|
1887
|
+
"""Return loaded-database 1D measurements compatible with a plot."""
|
|
1888
|
+
|
|
1889
|
+
target_axes = tuple(
|
|
1890
|
+
getattr(getattr(target_win, "param", None), "depends_on_", ())
|
|
1891
|
+
)
|
|
1892
|
+
if len(target_axes) != 1:
|
|
1893
|
+
return []
|
|
1894
|
+
|
|
1895
|
+
run_list = getattr(self, "RunList", None)
|
|
1896
|
+
all_run_metadata = getattr(run_list, "all_run_metadata", None)
|
|
1897
|
+
if not callable(all_run_metadata):
|
|
1898
|
+
return []
|
|
1899
|
+
|
|
1900
|
+
candidates = []
|
|
1901
|
+
primary_trace_key = TraceKey(target_win._dataset_key, target_win.param.name)
|
|
1902
|
+
for run_id, metadata in all_run_metadata().items():
|
|
1903
|
+
if tuple(metadata.get("sweep_parameters") or ()) != target_axes:
|
|
1904
|
+
continue
|
|
1905
|
+
guid = metadata.get("guid")
|
|
1906
|
+
if not guid:
|
|
1907
|
+
continue
|
|
1908
|
+
source_key = self._current_dataset_key(guid)
|
|
1909
|
+
for parameter_name in metadata.get("measure_parameters") or ():
|
|
1910
|
+
trace_key = TraceKey(source_key, parameter_name)
|
|
1911
|
+
if (
|
|
1912
|
+
trace_key == primary_trace_key
|
|
1913
|
+
or trace_key in getattr(target_win, "lines", {})
|
|
1914
|
+
):
|
|
1915
|
+
continue
|
|
1916
|
+
candidates.append((f"ID:{run_id} {parameter_name}", trace_key))
|
|
1917
|
+
return candidates
|
|
1918
|
+
|
|
1919
|
+
|
|
1920
|
+
def _heatmap_candidates_for_plot(self, target_win):
|
|
1921
|
+
"""Return loaded-database 2D measurements compatible with a heatmap."""
|
|
1922
|
+
|
|
1923
|
+
target_axes = tuple(
|
|
1924
|
+
getattr(getattr(target_win, "param", None), "depends_on_", ())
|
|
1925
|
+
)
|
|
1926
|
+
if len(target_axes) != 2:
|
|
1927
|
+
return []
|
|
1928
|
+
|
|
1929
|
+
run_list = getattr(self, "RunList", None)
|
|
1930
|
+
all_run_metadata = getattr(run_list, "all_run_metadata", None)
|
|
1931
|
+
if not callable(all_run_metadata):
|
|
1932
|
+
return []
|
|
1933
|
+
|
|
1934
|
+
candidates = []
|
|
1935
|
+
primary_key = TraceKey(target_win._dataset_key, target_win.param.name)
|
|
1936
|
+
plotted = getattr(target_win, "heatmaps", {})
|
|
1937
|
+
for run_id, metadata in all_run_metadata().items():
|
|
1938
|
+
axes = tuple(metadata.get("sweep_parameters") or ())
|
|
1939
|
+
if len(axes) != 2 or set(axes) != set(target_axes):
|
|
1940
|
+
continue
|
|
1941
|
+
guid = metadata.get("guid")
|
|
1942
|
+
if not guid:
|
|
1943
|
+
continue
|
|
1944
|
+
source_key = self._current_dataset_key(guid)
|
|
1945
|
+
for parameter_name in metadata.get("measure_parameters") or ():
|
|
1946
|
+
heatmap_key = TraceKey(source_key, parameter_name)
|
|
1947
|
+
if heatmap_key == primary_key or heatmap_key in plotted:
|
|
1948
|
+
continue
|
|
1949
|
+
candidates.append(
|
|
1950
|
+
(f"ID:{run_id} {parameter_name}", heatmap_key)
|
|
1951
|
+
)
|
|
1952
|
+
return candidates
|
|
1953
|
+
|
|
1954
|
+
|
|
1955
|
+
@_trusted_plot_action
|
|
1956
|
+
def add_heatmap_to_plot(
|
|
1957
|
+
self,
|
|
1958
|
+
target_win,
|
|
1959
|
+
source_identity,
|
|
1960
|
+
parameter_name,
|
|
1961
|
+
param=None,
|
|
1962
|
+
):
|
|
1963
|
+
"""Add a compatible 2D parameter as a layer on an existing heatmap."""
|
|
1964
|
+
|
|
1965
|
+
source_path = (
|
|
1966
|
+
source_identity.database_path
|
|
1967
|
+
if isinstance(source_identity, DatasetKey)
|
|
1968
|
+
else None
|
|
1969
|
+
)
|
|
1970
|
+
if not PlotActionsMixin._generation_gate_allows_action(
|
|
1971
|
+
self,
|
|
1972
|
+
source_path,
|
|
1973
|
+
"adding a preview heatmap",
|
|
1974
|
+
):
|
|
1975
|
+
return False
|
|
1976
|
+
|
|
1977
|
+
add_heatmap = getattr(target_win, "add_heatmap", None)
|
|
1978
|
+
if (
|
|
1979
|
+
target_win is None
|
|
1980
|
+
or getattr(target_win, "operation_kind", None) != "plot2d"
|
|
1981
|
+
or not callable(add_heatmap)
|
|
1982
|
+
):
|
|
1983
|
+
self.show_status("Drop heatmaps onto a compatible heatmap plot.", 5000)
|
|
1984
|
+
return False
|
|
1985
|
+
|
|
1986
|
+
if isinstance(source_identity, DatasetKey):
|
|
1987
|
+
source_key = source_identity
|
|
1988
|
+
else:
|
|
1989
|
+
source_key = self._current_dataset_key(source_identity)
|
|
1990
|
+
source_guid = source_key.guid
|
|
1991
|
+
|
|
1992
|
+
if param is None:
|
|
1993
|
+
try:
|
|
1994
|
+
param = self._parameter_from_key(source_key, parameter_name)
|
|
1995
|
+
except Exception as err:
|
|
1996
|
+
log_exception("Heatmap source run load failed", err, __name__)
|
|
1997
|
+
self.show_error(
|
|
1998
|
+
"Run Load Failed",
|
|
1999
|
+
f"Could not load run with GUID {source_guid}.",
|
|
2000
|
+
str(err),
|
|
2001
|
+
)
|
|
2002
|
+
return False
|
|
2003
|
+
|
|
2004
|
+
if param is None or not getattr(param, "depends_on", ""):
|
|
2005
|
+
self.show_status(f"No preview plot found for {parameter_name}.", 5000)
|
|
2006
|
+
return False
|
|
2007
|
+
|
|
2008
|
+
source_axes = tuple(getattr(param, "depends_on_", ()))
|
|
2009
|
+
target_axes = tuple(getattr(target_win.param, "depends_on_", ()))
|
|
2010
|
+
if len(source_axes) != 2:
|
|
2011
|
+
self.show_status("Only 2D measurements can be added as heatmaps.", 5000)
|
|
2012
|
+
return False
|
|
2013
|
+
if len(target_axes) != 2 or set(source_axes) != set(target_axes):
|
|
2014
|
+
self.show_status(
|
|
2015
|
+
f"Cannot add {parameter_name}; the heatmap axes do not match.",
|
|
2016
|
+
5000,
|
|
2017
|
+
)
|
|
2018
|
+
return False
|
|
2019
|
+
|
|
2020
|
+
source_trace_key = TraceKey(source_key, param.name)
|
|
2021
|
+
heatmaps = getattr(target_win, "heatmaps", {})
|
|
2022
|
+
if source_trace_key in heatmaps:
|
|
2023
|
+
self.show_status(
|
|
2024
|
+
f"Skipped {parameter_name}; that heatmap is already shown.",
|
|
2025
|
+
5000,
|
|
2026
|
+
)
|
|
2027
|
+
return False
|
|
2028
|
+
if source_trace_key == getattr(target_win, "_trace_key", None):
|
|
2029
|
+
self.show_status(
|
|
2030
|
+
f"Skipped {target_win.label}; source and target are the same.",
|
|
2031
|
+
5000,
|
|
2032
|
+
)
|
|
2033
|
+
return False
|
|
2034
|
+
|
|
2035
|
+
from_win = self._plot_window_for_param(
|
|
2036
|
+
source_key,
|
|
2037
|
+
param,
|
|
2038
|
+
operation_kind="plot2d",
|
|
2039
|
+
)
|
|
2040
|
+
if from_win == target_win:
|
|
2041
|
+
self.show_status(
|
|
2042
|
+
f"Skipped {target_win.label}; source and target are the same.",
|
|
2043
|
+
5000,
|
|
2044
|
+
)
|
|
2045
|
+
return False
|
|
2046
|
+
|
|
2047
|
+
if from_win is None:
|
|
2048
|
+
from_win = self._open_hidden_trace_window(source_key, param, target_win)
|
|
2049
|
+
if from_win is None:
|
|
2050
|
+
return False
|
|
2051
|
+
|
|
2052
|
+
_axis_order, compatibility_error = _heatmap_layer_compatibility(
|
|
2053
|
+
target_win,
|
|
2054
|
+
from_win,
|
|
2055
|
+
)
|
|
2056
|
+
if compatibility_error is not None:
|
|
2057
|
+
if compatibility_error == "the displayed value units do not match":
|
|
2058
|
+
compatibility_error += (
|
|
2059
|
+
f" (source: {_window_display_unit(from_win) or 'unitless'};"
|
|
2060
|
+
f" target: {_window_display_unit(target_win) or 'unitless'})"
|
|
2061
|
+
)
|
|
2062
|
+
self.show_status(
|
|
2063
|
+
f"Cannot add {parameter_name}; {compatibility_error}.",
|
|
2064
|
+
5000,
|
|
2065
|
+
)
|
|
2066
|
+
if not getattr(from_win, "visible", False):
|
|
2067
|
+
from_win.close()
|
|
2068
|
+
return False
|
|
2069
|
+
|
|
2070
|
+
try:
|
|
2071
|
+
added = bool(add_heatmap(from_win))
|
|
2072
|
+
except Exception as err:
|
|
2073
|
+
log_exception("Heatmap layer construction failed", err, __name__)
|
|
2074
|
+
self.show_error(
|
|
2075
|
+
"Heatmap Add Failed",
|
|
2076
|
+
f"Could not add {parameter_name} to the heatmap.",
|
|
2077
|
+
str(err),
|
|
2078
|
+
)
|
|
2079
|
+
added = False
|
|
2080
|
+
|
|
2081
|
+
if not getattr(from_win, "visible", False):
|
|
2082
|
+
from_win.close()
|
|
2083
|
+
|
|
2084
|
+
if not added:
|
|
2085
|
+
self.show_status(
|
|
2086
|
+
f"Cannot add {parameter_name}; it is already shown or incompatible.",
|
|
2087
|
+
5000,
|
|
2088
|
+
)
|
|
2089
|
+
return False
|
|
2090
|
+
|
|
2091
|
+
sync_layer_views = getattr(
|
|
2092
|
+
target_win,
|
|
2093
|
+
"_sync_secondary_heatmap_view_ranges",
|
|
2094
|
+
None,
|
|
2095
|
+
)
|
|
2096
|
+
if callable(sync_layer_views):
|
|
2097
|
+
sync_layer_views()
|
|
2098
|
+
|
|
2099
|
+
self.show_status(f"Added {from_win.label} to {target_win.label}.", 3000)
|
|
2100
|
+
return True
|
|
2101
|
+
|
|
2102
|
+
|
|
2103
|
+
def _parameter_from_guid(self, guid, parameter_name):
|
|
2104
|
+
return self._parameter_from_key(self._current_dataset_key(guid), parameter_name)
|
|
2105
|
+
|
|
2106
|
+
|
|
2107
|
+
def _parameter_from_key(self, dataset_key, parameter_name):
|
|
2108
|
+
ds = self._dataset_for_key(dataset_key)
|
|
2109
|
+
try:
|
|
2110
|
+
return self._measurement_param_by_name(ds, parameter_name)
|
|
2111
|
+
finally:
|
|
2112
|
+
self._close_dataset_if_unowned(
|
|
2113
|
+
ds,
|
|
2114
|
+
context="Parameter lookup dataset cleanup failed",
|
|
2115
|
+
)
|
|
2116
|
+
|
|
2117
|
+
|
|
2118
|
+
def _measurement_param_by_name(self, dataset, parameter_name):
|
|
2119
|
+
for param in dataset.get_parameters():
|
|
2120
|
+
if param.name == parameter_name:
|
|
2121
|
+
return param
|
|
2122
|
+
return None
|
|
2123
|
+
|
|
2124
|
+
|
|
2125
|
+
def _dataset_for_guid(self, guid):
|
|
2126
|
+
return self._dataset_for_key(self._current_dataset_key(guid))
|
|
2127
|
+
|
|
2128
|
+
|
|
2129
|
+
def _dataset_for_key(self, dataset_key):
|
|
2130
|
+
handle = self._dataset_handle_for_key(dataset_key)
|
|
2131
|
+
if handle is not None:
|
|
2132
|
+
return handle.dataset
|
|
2133
|
+
return self._load_dataset(dataset_key)
|
|
2134
|
+
|
|
2135
|
+
|
|
2136
|
+
def _plot_window_for_param(self, dataset_key, param, operation_kind=None):
|
|
2137
|
+
for win in self.windows:
|
|
2138
|
+
try:
|
|
2139
|
+
window_kind = getattr(
|
|
2140
|
+
win,
|
|
2141
|
+
"operation_kind",
|
|
2142
|
+
win.__class__.__name__,
|
|
2143
|
+
)
|
|
2144
|
+
if (
|
|
2145
|
+
win._dataset_key == dataset_key
|
|
2146
|
+
and win.param.name == param.name
|
|
2147
|
+
and (
|
|
2148
|
+
operation_kind is None
|
|
2149
|
+
or window_kind == operation_kind
|
|
2150
|
+
)
|
|
2151
|
+
):
|
|
2152
|
+
return win
|
|
2153
|
+
except AttributeError:
|
|
2154
|
+
continue
|
|
2155
|
+
return None
|
|
2156
|
+
|
|
2157
|
+
|
|
2158
|
+
def _open_hidden_trace_window(self, source_key, param, target_win):
|
|
2159
|
+
before = set(id(win) for win in self.windows)
|
|
2160
|
+
self.openPlot(guid=source_key, params=[param], show=False)
|
|
2161
|
+
|
|
2162
|
+
for win in reversed(self.windows):
|
|
2163
|
+
if id(win) in before:
|
|
2164
|
+
continue
|
|
2165
|
+
try:
|
|
2166
|
+
if win._dataset_key == source_key and win.param.name == param.name:
|
|
2167
|
+
if plot_refresh_required(win):
|
|
2168
|
+
target_win.toolbarRef.show()
|
|
2169
|
+
return win
|
|
2170
|
+
except AttributeError:
|
|
2171
|
+
continue
|
|
2172
|
+
|
|
2173
|
+
self.show_status(f"Could not prepare {param.name} for adding to the plot.", 5000)
|
|
2174
|
+
return None
|
|
2175
|
+
|
|
2176
|
+
|
|
2177
|
+
def _selected_measurement_params(self, dataset):
|
|
2178
|
+
"""
|
|
2179
|
+
Returns the measurement parameters requested by the Measurement field.
|
|
2180
|
+
|
|
2181
|
+
"""
|
|
2182
|
+
params = [param for param in dataset.get_parameters() if param.depends_on != ""]
|
|
2183
|
+
names = self._selected_measurement_names(
|
|
2184
|
+
tuple(param.name for param in params),
|
|
2185
|
+
dataset.run_id,
|
|
2186
|
+
)
|
|
2187
|
+
if names is None:
|
|
2188
|
+
return None
|
|
2189
|
+
selected_names = set(names)
|
|
2190
|
+
return [param for param in params if param.name in selected_names]
|
|
2191
|
+
|
|
2192
|
+
|
|
2193
|
+
def _selected_measurement_names(self, parameter_names, run_id):
|
|
2194
|
+
"""Apply the Measurement field to immutable parameter names."""
|
|
2195
|
+
measurement_box = getattr(self, "measurementBox", None)
|
|
2196
|
+
measurement = (
|
|
2197
|
+
measurement_box.text().strip()
|
|
2198
|
+
if measurement_box is not None
|
|
2199
|
+
else "*"
|
|
2200
|
+
)
|
|
2201
|
+
|
|
2202
|
+
if measurement in ("", "*"):
|
|
2203
|
+
return tuple(parameter_names)
|
|
2204
|
+
|
|
2205
|
+
try:
|
|
2206
|
+
index = int(measurement)
|
|
2207
|
+
except ValueError:
|
|
2208
|
+
self.show_status("Measurement must be a number or *.", 5000)
|
|
2209
|
+
return None
|
|
2210
|
+
|
|
2211
|
+
if index < 1 or index > len(parameter_names):
|
|
2212
|
+
self.show_status(
|
|
2213
|
+
f"Run {run_id} has no measurement {index}.",
|
|
2214
|
+
5000,
|
|
2215
|
+
)
|
|
2216
|
+
return None
|
|
2217
|
+
|
|
2218
|
+
return (parameter_names[index - 1],)
|
|
2219
|
+
|
|
2220
|
+
|
|
2221
|
+
def _run_csv_export_request(self):
|
|
2222
|
+
"""Capture a run key and names without opening an export dataset."""
|
|
2223
|
+
database_path = self.fileTextbox.text()
|
|
2224
|
+
if not database_path:
|
|
2225
|
+
self.show_status("Load a database before plotting or exporting.", 5000)
|
|
2226
|
+
return None
|
|
2227
|
+
|
|
2228
|
+
run_id = self.selected_run_id
|
|
2229
|
+
if run_id is None:
|
|
2230
|
+
self.show_status("Enter a Run ID before plotting or exporting.", 5000)
|
|
2231
|
+
return None
|
|
2232
|
+
|
|
2233
|
+
reload_if_changed = getattr(
|
|
2234
|
+
self,
|
|
2235
|
+
"_reload_if_database_instance_changed",
|
|
2236
|
+
None,
|
|
2237
|
+
)
|
|
2238
|
+
if callable(reload_if_changed) and reload_if_changed(database_path):
|
|
2239
|
+
self.show_status(
|
|
2240
|
+
"Database was replaced; reloading before exporting the run.",
|
|
2241
|
+
5000,
|
|
2242
|
+
)
|
|
2243
|
+
return None
|
|
2244
|
+
|
|
2245
|
+
selected_dataset = getattr(self, "ds", None)
|
|
2246
|
+
selected_key = getattr(self, "_selected_dataset_key", None)
|
|
2247
|
+
if (
|
|
2248
|
+
selected_dataset is not None
|
|
2249
|
+
and getattr(selected_dataset, "run_id", None) == run_id
|
|
2250
|
+
and selected_key is not None
|
|
2251
|
+
and selected_key.database_path
|
|
2252
|
+
== logical_database_path(database_path)
|
|
2253
|
+
):
|
|
2254
|
+
try:
|
|
2255
|
+
parameter_names = tuple(
|
|
2256
|
+
param.name
|
|
2257
|
+
for param in _exportable_measurement_params(selected_dataset)
|
|
2258
|
+
)
|
|
2259
|
+
except Exception as error:
|
|
2260
|
+
log_exception("Run parameter enumeration failed", error, __name__)
|
|
2261
|
+
self.show_error(
|
|
2262
|
+
"Run Load Failed",
|
|
2263
|
+
"Could not read the selected run's measurements.",
|
|
2264
|
+
str(error),
|
|
2265
|
+
)
|
|
2266
|
+
return None
|
|
2267
|
+
# Parameter enumeration may reuse an explicit-action dataset, but
|
|
2268
|
+
# this new export request is rebound to the currently accepted full
|
|
2269
|
+
# DatabaseInstance after the replacement guard above.
|
|
2270
|
+
dataset_key = self._current_dataset_key(selected_key.guid)
|
|
2271
|
+
else:
|
|
2272
|
+
metadata = self._run_metadata_for_id(run_id)
|
|
2273
|
+
guid = metadata.get("guid")
|
|
2274
|
+
if not guid:
|
|
2275
|
+
self.show_status(
|
|
2276
|
+
f"Run {run_id} is not available in the loaded database.",
|
|
2277
|
+
5000,
|
|
2278
|
+
)
|
|
2279
|
+
return None
|
|
2280
|
+
dataset_key = self._current_dataset_key(str(guid))
|
|
2281
|
+
parameter_names = tuple(
|
|
2282
|
+
str(name)
|
|
2283
|
+
for name in metadata.get("measure_parameters") or ()
|
|
2284
|
+
)
|
|
2285
|
+
if getattr(self, "_database_access_mode", None) == TRUSTED_LIVE_MODE:
|
|
2286
|
+
# The trusted run list is populated progressively. Its initial
|
|
2287
|
+
# measurement names may omit standalones until derived metadata
|
|
2288
|
+
# arrives. An explicit export can enumerate a short-lived
|
|
2289
|
+
# action view now, before the modal destination dialog.
|
|
2290
|
+
dataset = None
|
|
2291
|
+
try:
|
|
2292
|
+
dataset_key = self._bind_one_shot_dataset_key(dataset_key)
|
|
2293
|
+
dataset = self._load_run_csv_dataset(dataset_key)
|
|
2294
|
+
parameter_names = tuple(
|
|
2295
|
+
param.name
|
|
2296
|
+
for param in _exportable_measurement_params(dataset)
|
|
2297
|
+
)
|
|
2298
|
+
self._require_run_csv_source_current(dataset_key)
|
|
2299
|
+
except DatabaseInstanceChangedError:
|
|
2300
|
+
self._handle_run_csv_source_replaced(dataset_key)
|
|
2301
|
+
return None
|
|
2302
|
+
except Exception as error:
|
|
2303
|
+
log_exception("Run parameter enumeration failed", error, __name__)
|
|
2304
|
+
self.show_error(
|
|
2305
|
+
"Run Load Failed",
|
|
2306
|
+
"Could not read the selected run's measurements.",
|
|
2307
|
+
str(error),
|
|
2308
|
+
)
|
|
2309
|
+
return None
|
|
2310
|
+
finally:
|
|
2311
|
+
if dataset is not None:
|
|
2312
|
+
try:
|
|
2313
|
+
close_dataset_connection(dataset)
|
|
2314
|
+
except Exception as error:
|
|
2315
|
+
log_exception(
|
|
2316
|
+
"CSV parameter dataset cleanup failed",
|
|
2317
|
+
error,
|
|
2318
|
+
__name__,
|
|
2319
|
+
)
|
|
2320
|
+
|
|
2321
|
+
requested_names = self._selected_measurement_names(
|
|
2322
|
+
parameter_names,
|
|
2323
|
+
run_id,
|
|
2324
|
+
)
|
|
2325
|
+
if requested_names is None:
|
|
2326
|
+
return None
|
|
2327
|
+
return dataset_key, requested_names, run_id
|
|
2328
|
+
|
|
2329
|
+
|
|
2330
|
+
def _run_metadata_for_id(self, run_id):
|
|
2331
|
+
"""Return already-loaded run metadata without opening SQLite."""
|
|
2332
|
+
run_list = getattr(self, "RunList", None)
|
|
2333
|
+
all_run_metadata = getattr(run_list, "all_run_metadata", None)
|
|
2334
|
+
if not callable(all_run_metadata):
|
|
2335
|
+
return {}
|
|
2336
|
+
runs = all_run_metadata()
|
|
2337
|
+
metadata = runs.get(run_id)
|
|
2338
|
+
if metadata is None:
|
|
2339
|
+
metadata = runs.get(str(run_id))
|
|
2340
|
+
return dict(metadata or {})
|
|
2341
|
+
|
|
2342
|
+
|
|
2343
|
+
def _measurement_params_by_names(self, dataset, parameter_names):
|
|
2344
|
+
"""Resolve captured measurement names against a freshly loaded run."""
|
|
2345
|
+
parameters = {
|
|
2346
|
+
param.name: param for param in _exportable_measurement_params(dataset)
|
|
2347
|
+
}
|
|
2348
|
+
resolved = []
|
|
2349
|
+
for name in parameter_names:
|
|
2350
|
+
param = parameters.get(name)
|
|
2351
|
+
if param is None:
|
|
2352
|
+
raise ValueError(
|
|
2353
|
+
f"Measurement parameter {name!r} is not exportable in the "
|
|
2354
|
+
"freshly loaded run."
|
|
2355
|
+
)
|
|
2356
|
+
resolved.append(param)
|
|
2357
|
+
return resolved
|
|
2358
|
+
|
|
2359
|
+
|
|
2360
|
+
def _dataset_for_plot_target(self):
|
|
2361
|
+
"""
|
|
2362
|
+
Loads the dataset requested by the Run field.
|
|
2363
|
+
|
|
2364
|
+
"""
|
|
2365
|
+
if not PlotActionsMixin._generation_gate_allows_action(
|
|
2366
|
+
self,
|
|
2367
|
+
operation="loading the selected run",
|
|
2368
|
+
):
|
|
2369
|
+
return None
|
|
2370
|
+
if not self.fileTextbox.text():
|
|
2371
|
+
self.show_status("Load a database before plotting or exporting.", 5000)
|
|
2372
|
+
return None
|
|
2373
|
+
|
|
2374
|
+
if getattr(self, "selected_run_id", None) is None:
|
|
2375
|
+
self.show_status("Enter a Run ID before plotting or exporting.", 5000)
|
|
2376
|
+
return None
|
|
2377
|
+
|
|
2378
|
+
reload_if_changed = getattr(
|
|
2379
|
+
self,
|
|
2380
|
+
"_reload_if_database_instance_changed",
|
|
2381
|
+
None,
|
|
2382
|
+
)
|
|
2383
|
+
if callable(reload_if_changed) and reload_if_changed(self.fileTextbox.text()):
|
|
2384
|
+
self.show_status(
|
|
2385
|
+
"Database was replaced; reloading before opening the run.",
|
|
2386
|
+
5000,
|
|
2387
|
+
)
|
|
2388
|
+
return None
|
|
2389
|
+
|
|
2390
|
+
run_id = self.selected_run_id
|
|
2391
|
+
metadata = self._run_metadata_for_id(run_id)
|
|
2392
|
+
guid = metadata.get("guid")
|
|
2393
|
+
if not guid:
|
|
2394
|
+
self.show_status(
|
|
2395
|
+
f"Run ID {run_id} is not available in the loaded database.",
|
|
2396
|
+
5000,
|
|
2397
|
+
)
|
|
2398
|
+
return None
|
|
2399
|
+
dataset_key = self._current_dataset_key(str(guid))
|
|
2400
|
+
self._plot_target_dataset_key = None
|
|
2401
|
+
try:
|
|
2402
|
+
dataset = self._load_dataset(dataset_key)
|
|
2403
|
+
if getattr(dataset, "guid", None) != dataset_key.guid or int(
|
|
2404
|
+
getattr(dataset, "run_id", -1)
|
|
2405
|
+
) != int(run_id):
|
|
2406
|
+
self._close_dataset_if_unowned(dataset)
|
|
2407
|
+
raise RuntimeError(
|
|
2408
|
+
"The selected GUID did not resolve to the requested run ID."
|
|
2409
|
+
)
|
|
2410
|
+
self._plot_target_dataset_key = dataset_key
|
|
2411
|
+
return dataset
|
|
2412
|
+
except DatabaseInstanceChangedError:
|
|
2413
|
+
if callable(reload_if_changed):
|
|
2414
|
+
reload_if_changed(self.fileTextbox.text())
|
|
2415
|
+
self.show_status(
|
|
2416
|
+
"Database was replaced; reloading before opening the run.",
|
|
2417
|
+
5000,
|
|
2418
|
+
)
|
|
2419
|
+
return None
|
|
2420
|
+
except Exception as error:
|
|
2421
|
+
if not self._dataset_key_is_current(dataset_key):
|
|
2422
|
+
if callable(reload_if_changed):
|
|
2423
|
+
reload_if_changed(self.fileTextbox.text())
|
|
2424
|
+
self.show_status(
|
|
2425
|
+
"Database was replaced; reloading before opening the run.",
|
|
2426
|
+
5000,
|
|
2427
|
+
)
|
|
2428
|
+
return None
|
|
2429
|
+
log_exception("Run ID load failed", error, __name__)
|
|
2430
|
+
self.show_error(
|
|
2431
|
+
"Run Load Failed",
|
|
2432
|
+
f"Could not load Run ID {self.selected_run_id}.",
|
|
2433
|
+
str(error),
|
|
2434
|
+
)
|
|
2435
|
+
return None
|
|
2436
|
+
|
|
2437
|
+
|
|
2438
|
+
def _accepted_plot_target_key(self, dataset):
|
|
2439
|
+
"""Bind first sidecars and recheck before plot UI publication."""
|
|
2440
|
+
|
|
2441
|
+
dataset_key = getattr(self, "_plot_target_dataset_key", None)
|
|
2442
|
+
if not isinstance(dataset_key, DatasetKey) or dataset_key.guid != str(
|
|
2443
|
+
getattr(dataset, "guid", "")
|
|
2444
|
+
):
|
|
2445
|
+
dataset_key = self._current_dataset_key(str(dataset.guid))
|
|
2446
|
+
bound_key = PlotActionsMixin._bind_plot_dataset_key(self, dataset_key)
|
|
2447
|
+
if bound_key is not None:
|
|
2448
|
+
self._plot_target_dataset_key = bound_key
|
|
2449
|
+
return bound_key
|
|
2450
|
+
self.show_status(
|
|
2451
|
+
"Database was replaced; reloading before opening the run.",
|
|
2452
|
+
5000,
|
|
2453
|
+
)
|
|
2454
|
+
return None
|
|
2455
|
+
|
|
2456
|
+
|
|
2457
|
+
def _measurement_dataframe(self, dataset, params):
|
|
2458
|
+
"""
|
|
2459
|
+
Builds a flat CSV-friendly dataframe for the selected measurement data.
|
|
2460
|
+
|
|
2461
|
+
"""
|
|
2462
|
+
frames = []
|
|
2463
|
+
prefix_columns = len(params) > 1
|
|
2464
|
+
for param in params:
|
|
2465
|
+
param_data = parameter_data_for_export(dataset, param.name)
|
|
2466
|
+
columns = {}
|
|
2467
|
+
for name, values in _csv_scalar_columns(param_data).items():
|
|
2468
|
+
column_name = f"{param.name}.{name}" if prefix_columns else name
|
|
2469
|
+
# Object samples may need both uint64 and negative int64, or
|
|
2470
|
+
# exact integers alongside floats. Disable pandas' inference.
|
|
2471
|
+
dtype: str | type[object] | None = object if values.dtype.hasobject else None
|
|
2472
|
+
if values.dtype.kind in "iu":
|
|
2473
|
+
# Alignment adds blanks to shorter columns. Nullable
|
|
2474
|
+
# integers retain exact values instead of becoming floats.
|
|
2475
|
+
prefix = "U" if values.dtype.kind == "u" else ""
|
|
2476
|
+
dtype = f"{prefix}Int{values.dtype.itemsize * 8}"
|
|
2477
|
+
columns[column_name] = pd.Series(values, dtype=dtype)
|
|
2478
|
+
frames.append(pd.DataFrame(columns))
|
|
2479
|
+
|
|
2480
|
+
return pd.concat(frames, axis=1) if frames else pd.DataFrame()
|
|
2481
|
+
|
|
2482
|
+
|
|
2483
|
+
def _default_export_filename(self, dataset, params):
|
|
2484
|
+
"""
|
|
2485
|
+
Returns a default CSV export path.
|
|
2486
|
+
|
|
2487
|
+
"""
|
|
2488
|
+
database_folder = os.path.dirname(self.fileTextbox.text())
|
|
2489
|
+
measurement = "all" if len(params) != 1 else params[0].name
|
|
2490
|
+
filename = self._safe_filename(f"run_{dataset.run_id}_{measurement}.csv")
|
|
2491
|
+
return os.path.join(database_folder or os.getcwd(), filename)
|
|
2492
|
+
|
|
2493
|
+
|
|
2494
|
+
def _default_run_csv_export_filename(
|
|
2495
|
+
self,
|
|
2496
|
+
dataset_key,
|
|
2497
|
+
parameter_names,
|
|
2498
|
+
*,
|
|
2499
|
+
run_id=None,
|
|
2500
|
+
):
|
|
2501
|
+
"""Return a CSV suggestion from a captured export request."""
|
|
2502
|
+
if run_id is None:
|
|
2503
|
+
run_list = getattr(self, "RunList", None)
|
|
2504
|
+
run_id_for_guid = getattr(run_list, "run_id_for_guid", None)
|
|
2505
|
+
if callable(run_id_for_guid):
|
|
2506
|
+
run_id = run_id_for_guid(dataset_key.guid)
|
|
2507
|
+
run_identifier = dataset_key.guid if run_id is None else run_id
|
|
2508
|
+
measurement = (
|
|
2509
|
+
"all" if len(parameter_names) != 1 else parameter_names[0]
|
|
2510
|
+
)
|
|
2511
|
+
filename = self._safe_filename(
|
|
2512
|
+
f"run_{run_identifier}_{measurement}.csv"
|
|
2513
|
+
)
|
|
2514
|
+
database_folder = os.path.dirname(dataset_key.database_path)
|
|
2515
|
+
return os.path.join(database_folder or os.getcwd(), filename)
|
|
2516
|
+
|
|
2517
|
+
|
|
2518
|
+
def _default_preview_export_filename(self, dataset_key, parameter_name):
|
|
2519
|
+
"""Return a preview destination suggestion without opening its dataset."""
|
|
2520
|
+
run_id = None
|
|
2521
|
+
run_list = getattr(self, "RunList", None)
|
|
2522
|
+
run_id_for_guid = getattr(run_list, "run_id_for_guid", None)
|
|
2523
|
+
if callable(run_id_for_guid):
|
|
2524
|
+
run_id = run_id_for_guid(dataset_key.guid)
|
|
2525
|
+
|
|
2526
|
+
selected_key = getattr(self, "_selected_dataset_key", None)
|
|
2527
|
+
selected_dataset = getattr(self, "ds", None)
|
|
2528
|
+
if run_id is None and selected_key == dataset_key and selected_dataset is not None:
|
|
2529
|
+
run_id = getattr(selected_dataset, "run_id", None)
|
|
2530
|
+
|
|
2531
|
+
run_identifier = dataset_key.guid if run_id is None else run_id
|
|
2532
|
+
filename = self._safe_filename(
|
|
2533
|
+
f"run_{run_identifier}_{parameter_name}.csv",
|
|
2534
|
+
)
|
|
2535
|
+
database_folder = os.path.dirname(dataset_key.database_path)
|
|
2536
|
+
return os.path.join(database_folder or os.getcwd(), filename)
|
|
2537
|
+
|
|
2538
|
+
|
|
2539
|
+
def _safe_filename(self, filename):
|
|
2540
|
+
"""
|
|
2541
|
+
Replaces path-hostile characters in a suggested filename.
|
|
2542
|
+
|
|
2543
|
+
"""
|
|
2544
|
+
return "".join(char if char.isalnum() or char in "._-" else "_" for char in filename)
|
|
2545
|
+
|
|
2546
|
+
|
|
2547
|
+
@QtCore.pyqtSlot(object)
|
|
2548
|
+
def add_ds_at(self, dataset_key: DatasetKey, ds=None):
|
|
2549
|
+
"""
|
|
2550
|
+
Tracks a dataset used by one or more plot windows.
|
|
2551
|
+
|
|
2552
|
+
"""
|
|
2553
|
+
self._ensure_bound_plot_dataset_key_can_be_read(dataset_key)
|
|
2554
|
+
handle = self._dataset_handle_for_key(dataset_key)
|
|
2555
|
+
if handle is None:
|
|
2556
|
+
ds = self._load_dataset(dataset_key) if ds is None else ds
|
|
2557
|
+
assert ds.guid == dataset_key.guid
|
|
2558
|
+
|
|
2559
|
+
if not self._bound_plot_dataset_key_is_current(dataset_key):
|
|
2560
|
+
self._close_dataset_if_unowned(
|
|
2561
|
+
ds,
|
|
2562
|
+
context="Replaced database dataset cleanup failed",
|
|
2563
|
+
)
|
|
2564
|
+
raise RuntimeError(
|
|
2565
|
+
"The database was replaced while its dataset was being acquired."
|
|
2566
|
+
)
|
|
2567
|
+
self.dataset_holder[dataset_key] = DatasetHandle(
|
|
2568
|
+
dataset=ds,
|
|
2569
|
+
database_identity=dataset_key.database_identity,
|
|
2570
|
+
sidecar_identities=dataset_key.sidecar_identities,
|
|
2571
|
+
)
|
|
2572
|
+
else:
|
|
2573
|
+
handle.retain()
|
|
2574
|
+
|
|
2575
|
+
|
|
2576
|
+
@QtCore.pyqtSlot(object)
|
|
2577
|
+
def remove_ds_at(self, dataset_key: DatasetKey):
|
|
2578
|
+
"""
|
|
2579
|
+
Decreases the user count for a cached plot dataset.
|
|
2580
|
+
|
|
2581
|
+
"""
|
|
2582
|
+
handle = self.dataset_holder.get(dataset_key)
|
|
2583
|
+
if handle is None:
|
|
2584
|
+
self.show_status("Trying to remove dataset that does not exist.", 5000)
|
|
2585
|
+
return
|
|
2586
|
+
|
|
2587
|
+
handle.release()
|
|
2588
|
+
|
|
2589
|
+
if handle.users <= 0:
|
|
2590
|
+
del_time = self.config.get("runtime_settings.del_grace_period")
|
|
2591
|
+
|
|
2592
|
+
if del_time == 0:
|
|
2593
|
+
self._evict_dataset_handle(dataset_key)
|
|
2594
|
+
|
|
2595
|
+
elif handle.delete_timer is None:
|
|
2596
|
+
del_timer = QtCore.QTimer()
|
|
2597
|
+
del_timer.setSingleShot(True)
|
|
2598
|
+
handle.delete_timer = del_timer
|
|
2599
|
+
del_timer.timeout.connect(
|
|
2600
|
+
lambda key=dataset_key: self._evict_dataset_handle(key)
|
|
2601
|
+
)
|
|
2602
|
+
del_timer.start(int(del_time * 1000))
|
|
2603
|
+
|
|
2604
|
+
|
|
2605
|
+
def _current_dataset_key(self, guid: str) -> DatasetKey:
|
|
2606
|
+
database_path = self.fileTextbox.text()
|
|
2607
|
+
loaded_instance = getattr(self, "_loaded_database_instance", None)
|
|
2608
|
+
if (
|
|
2609
|
+
isinstance(loaded_instance, DatabaseInstance)
|
|
2610
|
+
and loaded_instance.logical_path
|
|
2611
|
+
== logical_database_path(database_path)
|
|
2612
|
+
):
|
|
2613
|
+
return DatasetKey(
|
|
2614
|
+
loaded_instance.logical_path,
|
|
2615
|
+
guid,
|
|
2616
|
+
loaded_instance.identity,
|
|
2617
|
+
loaded_instance.resolved_path,
|
|
2618
|
+
sidecar_identities=loaded_instance.sidecar_identities,
|
|
2619
|
+
)
|
|
2620
|
+
return DatasetKey(database_path, guid)
|
|
2621
|
+
|
|
2622
|
+
|
|
2623
|
+
def _load_dataset(self, dataset_key: DatasetKey):
|
|
2624
|
+
self._ensure_dataset_key_can_be_read(dataset_key)
|
|
2625
|
+
|
|
2626
|
+
prepared = getattr(self, "_trusted_plot_ready", {}).get(dataset_key)
|
|
2627
|
+
if prepared is not None:
|
|
2628
|
+
return prepared
|
|
2629
|
+
if getattr(self, "_database_access_mode", None) == TRUSTED_LIVE_MODE:
|
|
2630
|
+
handle = self._dataset_handle_for_key(dataset_key)
|
|
2631
|
+
if handle is not None:
|
|
2632
|
+
return handle.dataset
|
|
2633
|
+
raise RuntimeError("Trusted plot metadata must be requested asynchronously.")
|
|
2634
|
+
|
|
2635
|
+
try:
|
|
2636
|
+
load_kwargs = {}
|
|
2637
|
+
if dataset_key.database_identity is not None:
|
|
2638
|
+
load_kwargs["expected_database_identity"] = (
|
|
2639
|
+
dataset_key.database_identity
|
|
2640
|
+
)
|
|
2641
|
+
dataset = load_by_guid_read_only(
|
|
2642
|
+
dataset_key.guid,
|
|
2643
|
+
dataset_key.database_path,
|
|
2644
|
+
**load_kwargs,
|
|
2645
|
+
)
|
|
2646
|
+
except Exception:
|
|
2647
|
+
# A replacement can race the check above. The read layer rejects
|
|
2648
|
+
# that identity change without consuming its sidecar; this second
|
|
2649
|
+
# check starts MainWindow's replacement reload when applicable.
|
|
2650
|
+
self._ensure_dataset_key_can_be_read(dataset_key)
|
|
2651
|
+
raise
|
|
2652
|
+
try:
|
|
2653
|
+
self._ensure_dataset_key_can_be_read(dataset_key)
|
|
2654
|
+
except Exception:
|
|
2655
|
+
try:
|
|
2656
|
+
close_dataset_connection(dataset)
|
|
2657
|
+
finally:
|
|
2658
|
+
raise
|
|
2659
|
+
return dataset
|
|
2660
|
+
|
|
2661
|
+
|
|
2662
|
+
def post_admin(self):
|
|
2663
|
+
"""
|
|
2664
|
+
Updates the plot windows' internal track of other open windows.
|
|
2665
|
+
|
|
2666
|
+
"""
|
|
2667
|
+
for item in self.windows:
|
|
2668
|
+
if isinstance(item, plot1d):
|
|
2669
|
+
self.get_1d_wins(item)
|
|
2670
|
+
|
|
2671
|
+
|
|
2672
|
+
def get_1d_wins(self, win):
|
|
2673
|
+
"""
|
|
2674
|
+
Finds compatible 1D plot windows for adding secondary traces to win.
|
|
2675
|
+
|
|
2676
|
+
"""
|
|
2677
|
+
wins = []
|
|
2678
|
+
|
|
2679
|
+
for item in self.windows:
|
|
2680
|
+
try:
|
|
2681
|
+
compatible = _subplot_axis_order(
|
|
2682
|
+
win.axis_options,
|
|
2683
|
+
item.axis_options,
|
|
2684
|
+
source_is_cut=(
|
|
2685
|
+
getattr(item, "operation_kind", item.__class__.__name__)
|
|
2686
|
+
== "sweeper"
|
|
2687
|
+
),
|
|
2688
|
+
shared_parameter=_subplot_shared_parameter(win),
|
|
2689
|
+
)
|
|
2690
|
+
if compatible is not None and not _plot_has_trace_window(win, item):
|
|
2691
|
+
wins.append(item)
|
|
2692
|
+
except (AttributeError, RuntimeError):
|
|
2693
|
+
continue
|
|
2694
|
+
|
|
2695
|
+
win.update_line_picker(wins)
|