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,2997 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sqlite3
|
|
3
|
+
import threading
|
|
4
|
+
from collections import OrderedDict
|
|
5
|
+
from time import monotonic
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from PyQt6 import QtCore, QtGui
|
|
9
|
+
from PyQt6 import QtWidgets as qtw
|
|
10
|
+
|
|
11
|
+
from qplot.datahandling.dimensions import (
|
|
12
|
+
MAX_SUPPORTED_PLOT_DIMENSIONS,
|
|
13
|
+
unsupported_plot_message,
|
|
14
|
+
)
|
|
15
|
+
from qplot.datahandling.file_identity import (
|
|
16
|
+
DatabaseInstance,
|
|
17
|
+
database_instances_differ,
|
|
18
|
+
)
|
|
19
|
+
from qplot.datahandling.file_identity import (
|
|
20
|
+
database_instance as capture_database_instance,
|
|
21
|
+
)
|
|
22
|
+
from qplot.datahandling.readonly import (
|
|
23
|
+
DatabaseInstanceChangedError,
|
|
24
|
+
quarantine_wal_for_replaced_database,
|
|
25
|
+
sqlite_read_only_connection,
|
|
26
|
+
)
|
|
27
|
+
from qplot.datahandling.trusted_presentation import bounded_presentation_error
|
|
28
|
+
from qplot.diagnostics import log_exception
|
|
29
|
+
|
|
30
|
+
from .._dragdrop import make_run_preview_mime
|
|
31
|
+
|
|
32
|
+
PREVIEW_SIZE = 200
|
|
33
|
+
PREVIEW_THUMBNAIL_SIZE = 64
|
|
34
|
+
PREVIEW_BACKGROUND_COLOR = "#f4f7fb"
|
|
35
|
+
PREVIEW_HEIGHT_PADDING = 48
|
|
36
|
+
COLLAPSE_MINIMUM_RATIO = 0.25
|
|
37
|
+
MAX_PREVIEW_ROWS = 50_000
|
|
38
|
+
MAX_PREVIEW_GRID_CELLS = 250_000
|
|
39
|
+
PREVIEW_SAMPLES_PER_CELL = 4
|
|
40
|
+
PREVIEW_ROWID_CHUNK = 900
|
|
41
|
+
PREVIEW_FILL_EMPTY_MIN_COVERAGE = 0.75
|
|
42
|
+
PREVIEW_REMAINING_PRIORITY = 0
|
|
43
|
+
PREVIEW_VISIBLE_PRIORITY = 100
|
|
44
|
+
PREVIEW_SELECTED_PRIORITY = 125
|
|
45
|
+
PREVIEW_SELECTED_THUMBNAIL_PRIORITY = 150
|
|
46
|
+
PREVIEW_PLOTTED_PRIORITY = 50
|
|
47
|
+
PREVIEW_MAX_ACTIVE_WORKERS = 2
|
|
48
|
+
PREVIEW_SQL_PROGRESS_OPCODES = 1_000
|
|
49
|
+
PREVIEW_SELECTED_PROPERTY = "previewSelected"
|
|
50
|
+
PREVIEW_CACHE_MAX_BYTES = 128 * 1024 * 1024
|
|
51
|
+
PREVIEW_CACHE_MAX_ENTRIES = 512
|
|
52
|
+
VIRIDIS_STOPS = np.asarray([
|
|
53
|
+
(68, 1, 84),
|
|
54
|
+
(72, 35, 116),
|
|
55
|
+
(64, 67, 135),
|
|
56
|
+
(52, 94, 141),
|
|
57
|
+
(41, 120, 142),
|
|
58
|
+
(32, 144, 140),
|
|
59
|
+
(34, 167, 132),
|
|
60
|
+
(68, 190, 112),
|
|
61
|
+
(121, 209, 81),
|
|
62
|
+
(189, 223, 38),
|
|
63
|
+
(253, 231, 37),
|
|
64
|
+
], dtype=np.float64)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _bounded_preview_error(error):
|
|
68
|
+
"""Detach traceback state before a preview failure crosses into Qt."""
|
|
69
|
+
|
|
70
|
+
if error is None:
|
|
71
|
+
return None
|
|
72
|
+
message = bounded_presentation_error(error)
|
|
73
|
+
if isinstance(error, DatabaseInstanceChangedError):
|
|
74
|
+
return DatabaseInstanceChangedError(message)
|
|
75
|
+
return message
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _raise_if_preview_aborted(is_cancelled=None, deadline=None):
|
|
79
|
+
if is_cancelled is not None and is_cancelled():
|
|
80
|
+
raise InterruptedError("Preview generation cancelled.")
|
|
81
|
+
if deadline is not None and monotonic() >= deadline:
|
|
82
|
+
raise TimeoutError("Preview generation deadline exceeded.")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _preview_abort_requested(is_cancelled=None, deadline=None):
|
|
86
|
+
return bool(
|
|
87
|
+
(is_cancelled is not None and is_cancelled())
|
|
88
|
+
or (deadline is not None and monotonic() >= deadline)
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _PreviewDatabaseBinding:
|
|
93
|
+
"""Share one monotonically promoted source baseline across preview workers."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, database_instance):
|
|
96
|
+
self._database_instance = database_instance
|
|
97
|
+
self._lock = threading.RLock()
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def database_instance(self):
|
|
101
|
+
with self._lock:
|
|
102
|
+
return self._database_instance
|
|
103
|
+
|
|
104
|
+
def require_current(self):
|
|
105
|
+
with self._lock:
|
|
106
|
+
self._database_instance = _require_current_preview_database_instance(
|
|
107
|
+
self._database_instance,
|
|
108
|
+
)
|
|
109
|
+
return self._database_instance
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class PreviewTab(qtw.QWidget):
|
|
113
|
+
"""
|
|
114
|
+
Displays background-generated preview images for database runs.
|
|
115
|
+
|
|
116
|
+
"""
|
|
117
|
+
plotRequested = QtCore.pyqtSignal(str)
|
|
118
|
+
exportRequested = QtCore.pyqtSignal(str)
|
|
119
|
+
previewsReady = QtCore.pyqtSignal(str, object)
|
|
120
|
+
previewGenerationChanged = QtCore.pyqtSignal(str, bool)
|
|
121
|
+
databaseReplaced = QtCore.pyqtSignal(str)
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
*args,
|
|
126
|
+
preview_size=PREVIEW_SIZE,
|
|
127
|
+
cache_max_entries=None,
|
|
128
|
+
cache_max_bytes=None,
|
|
129
|
+
):
|
|
130
|
+
super().__init__(*args)
|
|
131
|
+
|
|
132
|
+
self.preview_size = int(preview_size or PREVIEW_SIZE)
|
|
133
|
+
self._cache_max_entries = (
|
|
134
|
+
None if cache_max_entries is None else int(cache_max_entries)
|
|
135
|
+
)
|
|
136
|
+
self._cache_max_bytes = (
|
|
137
|
+
None if cache_max_bytes is None else int(cache_max_bytes)
|
|
138
|
+
)
|
|
139
|
+
if self._cache_max_entries is not None and self._cache_max_entries < 1:
|
|
140
|
+
raise ValueError("cache_max_entries must be positive")
|
|
141
|
+
if self._cache_max_bytes is not None and self._cache_max_bytes < 1:
|
|
142
|
+
raise ValueError("cache_max_bytes must be positive")
|
|
143
|
+
self._update_minimum_height()
|
|
144
|
+
self.database_path = ""
|
|
145
|
+
self.database_instance: DatabaseInstance | None = None
|
|
146
|
+
self._database_binding: _PreviewDatabaseBinding | None = None
|
|
147
|
+
self.generation = 0
|
|
148
|
+
self.current_guid = None
|
|
149
|
+
self.run_metadata = {}
|
|
150
|
+
self.cache = OrderedDict()
|
|
151
|
+
self.thumbnail_cache = OrderedDict()
|
|
152
|
+
self._plot_previews = {}
|
|
153
|
+
self.cache_bytes = 0
|
|
154
|
+
self.errors = {}
|
|
155
|
+
self.thumbnail_errors = {}
|
|
156
|
+
self.queue = {}
|
|
157
|
+
self._explicit_guids: set[str] = set()
|
|
158
|
+
self.active: set[tuple[int, str]] = set()
|
|
159
|
+
self._active_priorities: dict[tuple[int, str], int] = {}
|
|
160
|
+
self._workers = {}
|
|
161
|
+
self.metadata_signatures = {}
|
|
162
|
+
self._start_scheduled = False
|
|
163
|
+
self._shutting_down = False
|
|
164
|
+
self._preview_active = True
|
|
165
|
+
self._trusted_derived_mode = False
|
|
166
|
+
|
|
167
|
+
# A widget-owned QThreadPool waits for its runnables in the QObject
|
|
168
|
+
# destructor. If a Python QRunnable needs the GIL while SIP is
|
|
169
|
+
# deleting this widget, both threads can wait forever. The shared Qt
|
|
170
|
+
# pool outlives the widget, so deletion never waits on preview work.
|
|
171
|
+
thread_pool = QtCore.QThreadPool.globalInstance()
|
|
172
|
+
if thread_pool is None:
|
|
173
|
+
raise RuntimeError("Qt global thread pool is unavailable")
|
|
174
|
+
self.thread_pool = thread_pool
|
|
175
|
+
|
|
176
|
+
self.scroll_area = qtw.QScrollArea()
|
|
177
|
+
self.scroll_area.setWidgetResizable(True)
|
|
178
|
+
|
|
179
|
+
self.content = qtw.QWidget()
|
|
180
|
+
self.content_layout = qtw.QHBoxLayout()
|
|
181
|
+
self.content_layout.setContentsMargins(8, 8, 8, 8)
|
|
182
|
+
self.content_layout.setSpacing(8)
|
|
183
|
+
self.content_layout.addStretch()
|
|
184
|
+
self.content.setLayout(self.content_layout)
|
|
185
|
+
self.scroll_area.setWidget(self.content)
|
|
186
|
+
|
|
187
|
+
layout = qtw.QVBoxLayout()
|
|
188
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
189
|
+
layout.addWidget(self.scroll_area)
|
|
190
|
+
self.setLayout(layout)
|
|
191
|
+
|
|
192
|
+
self._show_message("Select a run")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def shutdown(self):
|
|
196
|
+
"""Stop scheduling preview work without waiting for active queries."""
|
|
197
|
+
if self._shutting_down:
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
self._shutting_down = True
|
|
201
|
+
self.generation += 1
|
|
202
|
+
self.queue = {}
|
|
203
|
+
self._explicit_guids = set()
|
|
204
|
+
self.active = set()
|
|
205
|
+
self._active_priorities = {}
|
|
206
|
+
self._start_scheduled = False
|
|
207
|
+
for worker in tuple(self._workers.values()):
|
|
208
|
+
worker.cancel()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def closeEvent(self, event):
|
|
212
|
+
self.shutdown()
|
|
213
|
+
super().closeEvent(event)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def preferred_tab_height(self):
|
|
217
|
+
return self.preview_size + PREVIEW_HEIGHT_PADDING
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _update_minimum_height(self):
|
|
221
|
+
self.setMinimumHeight(
|
|
222
|
+
max(1, round(self.preferred_tab_height() * COLLAPSE_MINIMUM_RATIO))
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def has_database(self, database_path):
|
|
227
|
+
return bool(database_path) and self.database_path == database_path
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def set_preview_size(self, preview_size):
|
|
231
|
+
preview_size = int(preview_size)
|
|
232
|
+
if preview_size == self.preview_size:
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
self.preview_size = preview_size
|
|
236
|
+
self._plot_previews = {}
|
|
237
|
+
self._update_minimum_height()
|
|
238
|
+
if self._trusted_derived_mode:
|
|
239
|
+
self.cache = OrderedDict()
|
|
240
|
+
self.cache_bytes = 0
|
|
241
|
+
self.errors = {}
|
|
242
|
+
if self.current_guid:
|
|
243
|
+
self._show_message("Generating preview...")
|
|
244
|
+
return
|
|
245
|
+
self._cancel_workers()
|
|
246
|
+
self.generation += 1
|
|
247
|
+
self.cache = OrderedDict()
|
|
248
|
+
self.cache_bytes = 0
|
|
249
|
+
self.errors = {}
|
|
250
|
+
self.queue = {}
|
|
251
|
+
self.active = set()
|
|
252
|
+
self._active_priorities = {}
|
|
253
|
+
self.metadata_signatures = {
|
|
254
|
+
guid: self._metadata_signature(metadata)
|
|
255
|
+
for guid, metadata in self.run_metadata.items()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if self.current_guid:
|
|
259
|
+
self._show_message("Generating preview...")
|
|
260
|
+
self._enqueue(
|
|
261
|
+
self.current_guid,
|
|
262
|
+
priority=PREVIEW_SELECTED_PRIORITY,
|
|
263
|
+
allow_active=True,
|
|
264
|
+
)
|
|
265
|
+
for guid in self._explicit_guids:
|
|
266
|
+
self._enqueue(guid, priority=PREVIEW_PLOTTED_PRIORITY)
|
|
267
|
+
self._schedule_start_next()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def set_database_runs(self, database_path, runs):
|
|
271
|
+
self._cancel_workers()
|
|
272
|
+
self.generation += 1
|
|
273
|
+
self._trusted_derived_mode = False
|
|
274
|
+
self.database_instance = _preview_database_instance(database_path)
|
|
275
|
+
self._database_binding = (
|
|
276
|
+
_PreviewDatabaseBinding(self.database_instance)
|
|
277
|
+
if self.database_instance is not None
|
|
278
|
+
else None
|
|
279
|
+
)
|
|
280
|
+
self.database_path = (
|
|
281
|
+
self.database_instance.logical_path
|
|
282
|
+
if self.database_instance is not None
|
|
283
|
+
else ""
|
|
284
|
+
)
|
|
285
|
+
self.current_guid = None
|
|
286
|
+
self.run_metadata = self._normalise_runs(runs)
|
|
287
|
+
self.cache = OrderedDict()
|
|
288
|
+
self.thumbnail_cache = OrderedDict()
|
|
289
|
+
self._plot_previews = {}
|
|
290
|
+
self.cache_bytes = 0
|
|
291
|
+
self.errors = {}
|
|
292
|
+
self.thumbnail_errors = {}
|
|
293
|
+
self.queue = {}
|
|
294
|
+
self._explicit_guids = set()
|
|
295
|
+
self.active = set()
|
|
296
|
+
self._active_priorities = {}
|
|
297
|
+
self.metadata_signatures = {
|
|
298
|
+
guid: self._metadata_signature(metadata)
|
|
299
|
+
for guid, metadata in self.run_metadata.items()
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
self._show_message("Select a run")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def set_trusted_derived_runs(self, runs):
|
|
306
|
+
"""Bind plain run identities without enabling the legacy producer."""
|
|
307
|
+
|
|
308
|
+
self._cancel_workers()
|
|
309
|
+
self.generation += 1
|
|
310
|
+
self._trusted_derived_mode = True
|
|
311
|
+
self.database_instance = None
|
|
312
|
+
self._database_binding = None
|
|
313
|
+
self.database_path = ""
|
|
314
|
+
self.current_guid = None
|
|
315
|
+
self.run_metadata = self._normalise_runs(runs)
|
|
316
|
+
self.cache = OrderedDict()
|
|
317
|
+
self.thumbnail_cache = OrderedDict()
|
|
318
|
+
self._plot_previews = {}
|
|
319
|
+
self.cache_bytes = 0
|
|
320
|
+
self.errors = {}
|
|
321
|
+
self.thumbnail_errors = {}
|
|
322
|
+
self.queue = {}
|
|
323
|
+
self._explicit_guids = set()
|
|
324
|
+
self.active = set()
|
|
325
|
+
self._active_priorities = {}
|
|
326
|
+
self._workers = {}
|
|
327
|
+
self.metadata_signatures = {}
|
|
328
|
+
self._start_scheduled = False
|
|
329
|
+
self._show_message("Select a run")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def add_trusted_derived_runs(self, runs):
|
|
333
|
+
"""Append bridge-owned run metadata without scheduling legacy work."""
|
|
334
|
+
|
|
335
|
+
if not self._trusted_derived_mode:
|
|
336
|
+
return
|
|
337
|
+
for guid, metadata in self._normalise_runs(runs).items():
|
|
338
|
+
old = self.run_metadata.get(guid)
|
|
339
|
+
if old is not None and self._metadata_signature(old) != self._metadata_signature(metadata):
|
|
340
|
+
self._plot_previews.pop(guid, None)
|
|
341
|
+
self.run_metadata[guid] = metadata
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def refresh_trusted_derived_runs(self, runs):
|
|
345
|
+
"""Refresh an unchanged trusted binding without discarding its LRU."""
|
|
346
|
+
|
|
347
|
+
if not self._trusted_derived_mode:
|
|
348
|
+
self.set_trusted_derived_runs(runs)
|
|
349
|
+
return
|
|
350
|
+
normalised = self._normalise_runs(runs)
|
|
351
|
+
valid_guids = set(normalised)
|
|
352
|
+
for guid in tuple(self.cache):
|
|
353
|
+
if guid not in valid_guids:
|
|
354
|
+
self._drop_cached(guid)
|
|
355
|
+
for guid in tuple(self._plot_previews):
|
|
356
|
+
if (
|
|
357
|
+
guid not in valid_guids
|
|
358
|
+
or self._metadata_signature(self.run_metadata.get(guid, {}))
|
|
359
|
+
!= self._metadata_signature(normalised[guid])
|
|
360
|
+
):
|
|
361
|
+
self._plot_previews.pop(guid, None)
|
|
362
|
+
self.errors = {
|
|
363
|
+
guid: error
|
|
364
|
+
for guid, error in self.errors.items()
|
|
365
|
+
if guid in valid_guids
|
|
366
|
+
}
|
|
367
|
+
self.run_metadata = normalised
|
|
368
|
+
if self.current_guid not in valid_guids:
|
|
369
|
+
self.clear_current_run()
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def trusted_preview_needs_replay(self, guid):
|
|
373
|
+
"""Return whether a selected trusted preview was evicted or never seen."""
|
|
374
|
+
|
|
375
|
+
exact_guid = str(guid or "")
|
|
376
|
+
return bool(
|
|
377
|
+
self._trusted_derived_mode
|
|
378
|
+
and exact_guid in self.run_metadata
|
|
379
|
+
and exact_guid not in self.cache
|
|
380
|
+
and exact_guid not in self.errors
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def discard_trusted_previews(self):
|
|
385
|
+
"""Release decoded previews after a trusted render-format change."""
|
|
386
|
+
|
|
387
|
+
if not self._trusted_derived_mode:
|
|
388
|
+
return
|
|
389
|
+
self.cache = OrderedDict()
|
|
390
|
+
self._plot_previews = {}
|
|
391
|
+
self.cache_bytes = 0
|
|
392
|
+
self.errors = {}
|
|
393
|
+
if self.current_guid:
|
|
394
|
+
self._show_message("Generating preview...")
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def publish_trusted_previews(self, guid, previews, *, error=None):
|
|
398
|
+
"""Accept GUI-thread decoded Stage 5B images through one UI path."""
|
|
399
|
+
|
|
400
|
+
if not self._trusted_derived_mode or self._shutting_down:
|
|
401
|
+
return
|
|
402
|
+
guid = str(guid or "")
|
|
403
|
+
if not guid or guid not in self.run_metadata:
|
|
404
|
+
return
|
|
405
|
+
if error:
|
|
406
|
+
self.errors[guid] = str(error)
|
|
407
|
+
self._drop_cached(guid)
|
|
408
|
+
else:
|
|
409
|
+
self.errors.pop(guid, None)
|
|
410
|
+
self._store_cached(guid, list(previews or []))
|
|
411
|
+
self._plot_previews.pop(guid, None)
|
|
412
|
+
if guid != self.current_guid:
|
|
413
|
+
return
|
|
414
|
+
if error:
|
|
415
|
+
self._show_message("Preview unavailable", str(error))
|
|
416
|
+
else:
|
|
417
|
+
self._show_previews(previews)
|
|
418
|
+
|
|
419
|
+
def publish_plot_preview(self, guid, preview):
|
|
420
|
+
"""Display one plotted measurement before the run job finishes."""
|
|
421
|
+
|
|
422
|
+
guid = str(guid or "")
|
|
423
|
+
parameter = str(preview.get("parameter") or "")
|
|
424
|
+
if (
|
|
425
|
+
self._shutting_down or not guid or not parameter
|
|
426
|
+
or guid not in self.run_metadata or guid in self.cache
|
|
427
|
+
):
|
|
428
|
+
return
|
|
429
|
+
partial = self._plot_previews.setdefault(guid, {})
|
|
430
|
+
partial[parameter] = preview
|
|
431
|
+
previews = list(partial.values())
|
|
432
|
+
generation = self.generation
|
|
433
|
+
self.previewsReady.emit(guid, previews)
|
|
434
|
+
if (
|
|
435
|
+
generation == self.generation
|
|
436
|
+
and self._plot_previews.get(guid) is partial
|
|
437
|
+
and guid == self.current_guid
|
|
438
|
+
):
|
|
439
|
+
self._show_previews(previews)
|
|
440
|
+
|
|
441
|
+
def discard_plot_previews(self, guid):
|
|
442
|
+
"""Forget a plot image after its trusted source changes."""
|
|
443
|
+
|
|
444
|
+
guid = str(guid or "")
|
|
445
|
+
if self._plot_previews.pop(guid, None) is None:
|
|
446
|
+
return False
|
|
447
|
+
if guid == self.current_guid and guid not in self.cache:
|
|
448
|
+
self._show_message("Generating preview...")
|
|
449
|
+
return True
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def add_runs(self, runs, queue_previews=True):
|
|
453
|
+
if self._trusted_derived_mode:
|
|
454
|
+
self.add_trusted_derived_runs(runs)
|
|
455
|
+
return
|
|
456
|
+
if not self.database_path:
|
|
457
|
+
return
|
|
458
|
+
|
|
459
|
+
for guid, metadata in self._normalise_runs(runs).items():
|
|
460
|
+
signature = self._metadata_signature(metadata)
|
|
461
|
+
old_signature = self.metadata_signatures.get(guid)
|
|
462
|
+
changed = old_signature is not None and signature != old_signature
|
|
463
|
+
self.run_metadata[guid] = metadata
|
|
464
|
+
self.metadata_signatures[guid] = signature
|
|
465
|
+
|
|
466
|
+
if changed:
|
|
467
|
+
self._drop_cached(guid)
|
|
468
|
+
self._plot_previews.pop(guid, None)
|
|
469
|
+
self.thumbnail_cache.pop(guid, None)
|
|
470
|
+
self.errors.pop(guid, None)
|
|
471
|
+
self.thumbnail_errors.pop(guid, None)
|
|
472
|
+
active_worker = self._workers.get((self.generation, guid))
|
|
473
|
+
if active_worker is not None:
|
|
474
|
+
active_worker.cancel()
|
|
475
|
+
|
|
476
|
+
if queue_previews and guid == self.current_guid:
|
|
477
|
+
self._enqueue(
|
|
478
|
+
guid,
|
|
479
|
+
priority=PREVIEW_REMAINING_PRIORITY,
|
|
480
|
+
allow_active=changed,
|
|
481
|
+
)
|
|
482
|
+
self._start_next()
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def set_current_run(self, dataset):
|
|
486
|
+
self.set_current_guid(getattr(dataset, "guid", None))
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def set_current_guid(self, guid):
|
|
490
|
+
"""Select a preview by its plain run GUID without a DataSet object."""
|
|
491
|
+
if not guid:
|
|
492
|
+
self.clear_current_run()
|
|
493
|
+
return
|
|
494
|
+
|
|
495
|
+
guid = str(guid)
|
|
496
|
+
self.current_guid = guid
|
|
497
|
+
if self._trusted_derived_mode:
|
|
498
|
+
if guid not in self.run_metadata:
|
|
499
|
+
self._show_message("No preview available")
|
|
500
|
+
elif guid in self.cache:
|
|
501
|
+
self._show_previews(self._cached_previews(guid))
|
|
502
|
+
elif guid in self._plot_previews:
|
|
503
|
+
self._show_previews(list(self._plot_previews[guid].values()))
|
|
504
|
+
elif guid in self.errors:
|
|
505
|
+
self._show_message("Preview unavailable", self.errors[guid])
|
|
506
|
+
else:
|
|
507
|
+
self._show_message("Generating preview...")
|
|
508
|
+
return
|
|
509
|
+
if not self.database_path or guid not in self.run_metadata:
|
|
510
|
+
self._show_message("No preview available")
|
|
511
|
+
return
|
|
512
|
+
|
|
513
|
+
if guid in self.cache:
|
|
514
|
+
self._show_previews(self._cached_previews(guid))
|
|
515
|
+
return
|
|
516
|
+
|
|
517
|
+
if guid in self._plot_previews:
|
|
518
|
+
self._show_previews(list(self._plot_previews[guid].values()))
|
|
519
|
+
self._enqueue(guid, priority=PREVIEW_SELECTED_PRIORITY)
|
|
520
|
+
self._start_next()
|
|
521
|
+
return
|
|
522
|
+
|
|
523
|
+
if guid in self.errors:
|
|
524
|
+
self.errors.pop(guid, None)
|
|
525
|
+
self._show_message("Retrying preview...")
|
|
526
|
+
else:
|
|
527
|
+
self._show_message("Generating preview...")
|
|
528
|
+
|
|
529
|
+
self._enqueue(
|
|
530
|
+
guid,
|
|
531
|
+
priority=(
|
|
532
|
+
PREVIEW_SELECTED_PRIORITY
|
|
533
|
+
if guid in self.thumbnail_cache
|
|
534
|
+
else PREVIEW_SELECTED_THUMBNAIL_PRIORITY
|
|
535
|
+
),
|
|
536
|
+
)
|
|
537
|
+
self._start_next()
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def set_preview_active(self, active):
|
|
541
|
+
"""Generate the selected full-size preview only when it can be seen."""
|
|
542
|
+
self._preview_active = bool(active)
|
|
543
|
+
if not self._preview_active or not self.current_guid:
|
|
544
|
+
return
|
|
545
|
+
self.set_current_guid(self.current_guid)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def clear_current_run(self):
|
|
549
|
+
self.current_guid = None
|
|
550
|
+
self._show_message("Select a run")
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def show_trusted_live_placeholder(self):
|
|
554
|
+
"""Retain the trusted selection while its bridge result is pending."""
|
|
555
|
+
|
|
556
|
+
if self._trusted_derived_mode and self.current_guid:
|
|
557
|
+
self._show_message("Generating preview...")
|
|
558
|
+
return
|
|
559
|
+
self.current_guid = None
|
|
560
|
+
self._show_message("Preview deferred for trusted live access until Stage 5")
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _normalise_runs(self, runs):
|
|
564
|
+
if not runs:
|
|
565
|
+
return {}
|
|
566
|
+
|
|
567
|
+
out = {}
|
|
568
|
+
for run_id, metadata in runs.items():
|
|
569
|
+
guid = metadata.get("guid")
|
|
570
|
+
if not guid:
|
|
571
|
+
continue
|
|
572
|
+
|
|
573
|
+
run_metadata = dict(metadata)
|
|
574
|
+
run_metadata["run_id"] = run_id
|
|
575
|
+
out[guid] = run_metadata
|
|
576
|
+
return out
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _metadata_signature(self, metadata):
|
|
580
|
+
return tuple(
|
|
581
|
+
_signature_value(metadata.get(key))
|
|
582
|
+
for key in (
|
|
583
|
+
"result_table_name",
|
|
584
|
+
"result_count",
|
|
585
|
+
"run_description",
|
|
586
|
+
"measure_parameters",
|
|
587
|
+
"sweep_parameters",
|
|
588
|
+
"setpoint_shape",
|
|
589
|
+
"point_shape",
|
|
590
|
+
)
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def prioritize_runs(self, selected_run_ids=None, visible_run_ids=None):
|
|
595
|
+
if not self.database_path:
|
|
596
|
+
return
|
|
597
|
+
|
|
598
|
+
selected_guids = self._guids_for_run_ids(selected_run_ids)
|
|
599
|
+
if self.current_guid and self.current_guid not in selected_guids:
|
|
600
|
+
selected_guids.insert(0, self.current_guid)
|
|
601
|
+
visible_guids = [
|
|
602
|
+
guid for guid in self._guids_for_run_ids(visible_run_ids)
|
|
603
|
+
if guid not in selected_guids
|
|
604
|
+
]
|
|
605
|
+
selected_thumbnail_guids = [
|
|
606
|
+
guid for guid in selected_guids
|
|
607
|
+
if guid not in self.thumbnail_cache
|
|
608
|
+
]
|
|
609
|
+
selected_preview_guids = [
|
|
610
|
+
guid for guid in selected_guids
|
|
611
|
+
if guid in self.thumbnail_cache
|
|
612
|
+
]
|
|
613
|
+
visible_thumbnail_guids = [
|
|
614
|
+
guid for guid in visible_guids
|
|
615
|
+
if guid not in self.thumbnail_cache
|
|
616
|
+
]
|
|
617
|
+
visible_preview_guids = [
|
|
618
|
+
guid for guid in visible_guids
|
|
619
|
+
if guid in self.thumbnail_cache
|
|
620
|
+
]
|
|
621
|
+
|
|
622
|
+
requested_priorities = {
|
|
623
|
+
guid: PREVIEW_VISIBLE_PRIORITY
|
|
624
|
+
for guid in visible_thumbnail_guids
|
|
625
|
+
}
|
|
626
|
+
requested_priorities.update({
|
|
627
|
+
guid: PREVIEW_SELECTED_PRIORITY
|
|
628
|
+
for guid in selected_preview_guids
|
|
629
|
+
})
|
|
630
|
+
requested_priorities.update({
|
|
631
|
+
guid: PREVIEW_SELECTED_THUMBNAIL_PRIORITY
|
|
632
|
+
for guid in selected_thumbnail_guids
|
|
633
|
+
})
|
|
634
|
+
requested_priorities.update({
|
|
635
|
+
guid: PREVIEW_REMAINING_PRIORITY
|
|
636
|
+
for guid in visible_preview_guids
|
|
637
|
+
})
|
|
638
|
+
for guid in self._explicit_guids:
|
|
639
|
+
requested_priorities[guid] = max(
|
|
640
|
+
PREVIEW_PLOTTED_PRIORITY,
|
|
641
|
+
requested_priorities.get(guid, PREVIEW_PLOTTED_PRIORITY),
|
|
642
|
+
)
|
|
643
|
+
requested_guids = set(requested_priorities)
|
|
644
|
+
for guid in list(self.queue):
|
|
645
|
+
if guid not in requested_guids:
|
|
646
|
+
self.queue.pop(guid, None)
|
|
647
|
+
|
|
648
|
+
for active_key in tuple(self.active):
|
|
649
|
+
generation, guid = active_key
|
|
650
|
+
if generation != self.generation:
|
|
651
|
+
continue
|
|
652
|
+
|
|
653
|
+
requested_priority = requested_priorities.get(guid)
|
|
654
|
+
worker = self._workers.get(active_key)
|
|
655
|
+
if requested_priority is None:
|
|
656
|
+
if worker is not None:
|
|
657
|
+
worker.cancel()
|
|
658
|
+
continue
|
|
659
|
+
|
|
660
|
+
if (
|
|
661
|
+
worker is not None
|
|
662
|
+
and getattr(worker, "is_cancelled", lambda: False)()
|
|
663
|
+
):
|
|
664
|
+
self._active_priorities[active_key] = requested_priority
|
|
665
|
+
self._enqueue(
|
|
666
|
+
guid,
|
|
667
|
+
priority=requested_priority,
|
|
668
|
+
allow_active=True,
|
|
669
|
+
)
|
|
670
|
+
continue
|
|
671
|
+
|
|
672
|
+
active_priority = self._active_priorities.get(
|
|
673
|
+
active_key,
|
|
674
|
+
PREVIEW_VISIBLE_PRIORITY,
|
|
675
|
+
)
|
|
676
|
+
if requested_priority > active_priority:
|
|
677
|
+
# A visible job that becomes selected already contains exactly
|
|
678
|
+
# the work the interactive slot would perform.
|
|
679
|
+
self._active_priorities[active_key] = requested_priority
|
|
680
|
+
elif requested_priority < active_priority:
|
|
681
|
+
# Release the interactive role when selection moves. Queue
|
|
682
|
+
# the old run again as background work because cancellation
|
|
683
|
+
# can interrupt its current SQL statement.
|
|
684
|
+
if worker is not None:
|
|
685
|
+
worker.cancel()
|
|
686
|
+
self._active_priorities[active_key] = requested_priority
|
|
687
|
+
self._enqueue(
|
|
688
|
+
guid,
|
|
689
|
+
priority=requested_priority,
|
|
690
|
+
allow_active=True,
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
for guid in selected_thumbnail_guids:
|
|
694
|
+
self._enqueue(guid, priority=PREVIEW_SELECTED_THUMBNAIL_PRIORITY)
|
|
695
|
+
for guid in selected_preview_guids:
|
|
696
|
+
self._enqueue(guid, priority=PREVIEW_SELECTED_PRIORITY)
|
|
697
|
+
for guid in visible_thumbnail_guids:
|
|
698
|
+
self._enqueue(guid, priority=PREVIEW_VISIBLE_PRIORITY)
|
|
699
|
+
for guid in visible_preview_guids:
|
|
700
|
+
self._enqueue(guid, priority=PREVIEW_REMAINING_PRIORITY)
|
|
701
|
+
for guid in self._explicit_guids:
|
|
702
|
+
self._enqueue(guid, priority=PREVIEW_PLOTTED_PRIORITY)
|
|
703
|
+
|
|
704
|
+
# Preserve the run-list viewport order for equal-priority visible
|
|
705
|
+
# requests. This also removes any accidental duplicate queue entries.
|
|
706
|
+
ordered_guids = [
|
|
707
|
+
*selected_thumbnail_guids,
|
|
708
|
+
*selected_preview_guids,
|
|
709
|
+
*visible_thumbnail_guids,
|
|
710
|
+
*visible_preview_guids,
|
|
711
|
+
]
|
|
712
|
+
prioritized_guids = set(ordered_guids)
|
|
713
|
+
ordered_guids.extend(
|
|
714
|
+
guid for guid in self._explicit_guids if guid not in prioritized_guids
|
|
715
|
+
)
|
|
716
|
+
self.queue = {
|
|
717
|
+
guid: self.queue[guid]
|
|
718
|
+
for guid in ordered_guids
|
|
719
|
+
if guid in self.queue
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
self._preempt_for_higher_priority_request()
|
|
723
|
+
self._start_next()
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def request_guids(self, guids):
|
|
727
|
+
"""Queue plotted runs until their preview generation completes."""
|
|
728
|
+
if not self.database_path:
|
|
729
|
+
return
|
|
730
|
+
if isinstance(guids, (str, bytes)) or not hasattr(guids, "__iter__"):
|
|
731
|
+
guids = [guids]
|
|
732
|
+
|
|
733
|
+
for guid in guids:
|
|
734
|
+
if not guid or guid not in self.run_metadata or guid in self.cache:
|
|
735
|
+
continue
|
|
736
|
+
|
|
737
|
+
self.errors.pop(guid, None)
|
|
738
|
+
self._explicit_guids.add(guid)
|
|
739
|
+
active_key = (self.generation, guid)
|
|
740
|
+
worker = self._workers.get(active_key)
|
|
741
|
+
if active_key not in self.active:
|
|
742
|
+
self._enqueue(guid, priority=PREVIEW_PLOTTED_PRIORITY)
|
|
743
|
+
elif (
|
|
744
|
+
worker is not None
|
|
745
|
+
and getattr(worker, "is_cancelled", lambda: False)()
|
|
746
|
+
):
|
|
747
|
+
self._enqueue(
|
|
748
|
+
guid,
|
|
749
|
+
priority=PREVIEW_PLOTTED_PRIORITY,
|
|
750
|
+
allow_active=True,
|
|
751
|
+
)
|
|
752
|
+
else:
|
|
753
|
+
self._active_priorities[active_key] = PREVIEW_PLOTTED_PRIORITY
|
|
754
|
+
|
|
755
|
+
self._start_next()
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _guids_for_run_ids(self, run_ids):
|
|
759
|
+
if run_ids is None:
|
|
760
|
+
return []
|
|
761
|
+
if isinstance(run_ids, (str, bytes)) or not hasattr(run_ids, "__iter__"):
|
|
762
|
+
run_ids = [run_ids]
|
|
763
|
+
|
|
764
|
+
lookup = self._run_id_lookup()
|
|
765
|
+
guids = []
|
|
766
|
+
seen = set()
|
|
767
|
+
for run_id in run_ids:
|
|
768
|
+
for key in self._run_id_keys(run_id):
|
|
769
|
+
guid = lookup.get(key)
|
|
770
|
+
if guid is None or guid in seen:
|
|
771
|
+
continue
|
|
772
|
+
guids.append(guid)
|
|
773
|
+
seen.add(guid)
|
|
774
|
+
break
|
|
775
|
+
return guids
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _run_id_lookup(self):
|
|
779
|
+
lookup: dict[int | str, str] = {}
|
|
780
|
+
for guid, metadata in self.run_metadata.items():
|
|
781
|
+
for key in self._run_id_keys(metadata.get("run_id")):
|
|
782
|
+
lookup.setdefault(key, guid)
|
|
783
|
+
return lookup
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _run_id_keys(self, run_id):
|
|
787
|
+
if run_id is None:
|
|
788
|
+
return []
|
|
789
|
+
|
|
790
|
+
keys = []
|
|
791
|
+
|
|
792
|
+
def add(value):
|
|
793
|
+
if value not in keys:
|
|
794
|
+
keys.append(value)
|
|
795
|
+
|
|
796
|
+
add(run_id)
|
|
797
|
+
try:
|
|
798
|
+
int_run_id = int(run_id)
|
|
799
|
+
except (TypeError, ValueError):
|
|
800
|
+
add(str(run_id))
|
|
801
|
+
else:
|
|
802
|
+
add(int_run_id)
|
|
803
|
+
add(str(int_run_id))
|
|
804
|
+
return keys
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _enqueue_all(self, priority=PREVIEW_REMAINING_PRIORITY):
|
|
808
|
+
for guid in self.run_metadata:
|
|
809
|
+
self._enqueue(guid, priority=priority)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _enqueue(self, guid, priority=0, allow_active=False):
|
|
813
|
+
if self._shutting_down:
|
|
814
|
+
return
|
|
815
|
+
is_thumbnail = priority in (
|
|
816
|
+
PREVIEW_VISIBLE_PRIORITY,
|
|
817
|
+
PREVIEW_SELECTED_THUMBNAIL_PRIORITY,
|
|
818
|
+
)
|
|
819
|
+
if is_thumbnail and guid in self.thumbnail_cache:
|
|
820
|
+
return
|
|
821
|
+
if not is_thumbnail and guid in self.cache:
|
|
822
|
+
return
|
|
823
|
+
if guid in (self.thumbnail_errors if is_thumbnail else self.errors):
|
|
824
|
+
return
|
|
825
|
+
if (self.generation, guid) in self.active and not allow_active:
|
|
826
|
+
return
|
|
827
|
+
if guid not in self.run_metadata:
|
|
828
|
+
return
|
|
829
|
+
if not self.database_path:
|
|
830
|
+
return
|
|
831
|
+
|
|
832
|
+
self.queue[guid] = max(priority, self.queue.get(guid, priority))
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _cancel_workers(self):
|
|
836
|
+
for worker in tuple(self._workers.values()):
|
|
837
|
+
worker.cancel()
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _preempt_for_higher_priority_request(self):
|
|
841
|
+
"""Cancel one lower-priority job when all slots block urgent work."""
|
|
842
|
+
active_keys = [
|
|
843
|
+
active_key
|
|
844
|
+
for active_key in self.active
|
|
845
|
+
if active_key[0] == self.generation
|
|
846
|
+
]
|
|
847
|
+
if len(active_keys) < PREVIEW_MAX_ACTIVE_WORKERS or not self.queue:
|
|
848
|
+
return
|
|
849
|
+
|
|
850
|
+
highest_queued = max(self.queue.values())
|
|
851
|
+
lowest_active_key = min(
|
|
852
|
+
active_keys,
|
|
853
|
+
key=lambda key: self._active_priorities.get(
|
|
854
|
+
key,
|
|
855
|
+
PREVIEW_REMAINING_PRIORITY,
|
|
856
|
+
),
|
|
857
|
+
)
|
|
858
|
+
if (
|
|
859
|
+
highest_queued
|
|
860
|
+
<= self._active_priorities.get(
|
|
861
|
+
lowest_active_key,
|
|
862
|
+
PREVIEW_REMAINING_PRIORITY,
|
|
863
|
+
)
|
|
864
|
+
):
|
|
865
|
+
return
|
|
866
|
+
worker = self._workers.get(lowest_active_key)
|
|
867
|
+
if worker is not None:
|
|
868
|
+
worker.cancel()
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _cached_previews(self, guid):
|
|
872
|
+
previews = self.cache.pop(guid)
|
|
873
|
+
self.cache[guid] = previews
|
|
874
|
+
return previews
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _drop_cached(self, guid):
|
|
878
|
+
previews = self.cache.pop(guid, None)
|
|
879
|
+
if previews is not None:
|
|
880
|
+
self.cache_bytes = max(
|
|
881
|
+
0,
|
|
882
|
+
self.cache_bytes - self._preview_bytes(previews),
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _store_cached(self, guid, previews):
|
|
887
|
+
self._drop_cached(guid)
|
|
888
|
+
self.cache[guid] = previews
|
|
889
|
+
self.cache_bytes += self._preview_bytes(previews)
|
|
890
|
+
max_entries = (
|
|
891
|
+
PREVIEW_CACHE_MAX_ENTRIES
|
|
892
|
+
if self._cache_max_entries is None
|
|
893
|
+
else self._cache_max_entries
|
|
894
|
+
)
|
|
895
|
+
max_bytes = (
|
|
896
|
+
PREVIEW_CACHE_MAX_BYTES
|
|
897
|
+
if self._cache_max_bytes is None
|
|
898
|
+
else self._cache_max_bytes
|
|
899
|
+
)
|
|
900
|
+
while (
|
|
901
|
+
len(self.cache) > max_entries
|
|
902
|
+
or self.cache_bytes > max_bytes
|
|
903
|
+
):
|
|
904
|
+
evict_guid = next(
|
|
905
|
+
(cached_guid for cached_guid in self.cache if cached_guid != self.current_guid),
|
|
906
|
+
None,
|
|
907
|
+
)
|
|
908
|
+
if evict_guid is None:
|
|
909
|
+
evict_guid = next(iter(self.cache), None)
|
|
910
|
+
if evict_guid is None:
|
|
911
|
+
break
|
|
912
|
+
self._drop_cached(evict_guid)
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
@staticmethod
|
|
916
|
+
def _preview_bytes(previews):
|
|
917
|
+
total = 0
|
|
918
|
+
for preview in previews or []:
|
|
919
|
+
image = preview.get("image") if isinstance(preview, dict) else None
|
|
920
|
+
size_in_bytes = getattr(image, "sizeInBytes", None)
|
|
921
|
+
if callable(size_in_bytes):
|
|
922
|
+
total += max(0, int(size_in_bytes()))
|
|
923
|
+
return total
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _schedule_start_next(self):
|
|
927
|
+
if self._shutting_down or self._start_scheduled:
|
|
928
|
+
return
|
|
929
|
+
|
|
930
|
+
self._start_scheduled = True
|
|
931
|
+
QtCore.QTimer.singleShot(0, self._scheduled_start_next)
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _scheduled_start_next(self):
|
|
935
|
+
self._start_scheduled = False
|
|
936
|
+
self._start_next()
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _start_next(self):
|
|
940
|
+
if self._shutting_down or not self.queue:
|
|
941
|
+
return
|
|
942
|
+
|
|
943
|
+
active_keys = {
|
|
944
|
+
active_key
|
|
945
|
+
for active_key in self.active
|
|
946
|
+
if active_key[0] == self.generation
|
|
947
|
+
}
|
|
948
|
+
available_slots = max(0, PREVIEW_MAX_ACTIVE_WORKERS - len(active_keys))
|
|
949
|
+
if available_slots == 0:
|
|
950
|
+
return
|
|
951
|
+
active_guids = {guid for _generation, guid in active_keys}
|
|
952
|
+
while available_slots:
|
|
953
|
+
guid = self._next_queued_guid(
|
|
954
|
+
lambda _priority: True,
|
|
955
|
+
active_guids,
|
|
956
|
+
)
|
|
957
|
+
if guid is None:
|
|
958
|
+
break
|
|
959
|
+
self._start_worker(guid)
|
|
960
|
+
active_guids.add(guid)
|
|
961
|
+
available_slots -= 1
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _next_queued_guid(self, accepts_priority, active_guids):
|
|
965
|
+
candidates = [
|
|
966
|
+
guid
|
|
967
|
+
for guid, priority in self.queue.items()
|
|
968
|
+
if accepts_priority(priority) and guid not in active_guids
|
|
969
|
+
]
|
|
970
|
+
if not candidates:
|
|
971
|
+
return None
|
|
972
|
+
return max(
|
|
973
|
+
candidates,
|
|
974
|
+
key=lambda guid: self.queue[guid],
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def _start_worker(self, guid):
|
|
979
|
+
binding = self._database_binding
|
|
980
|
+
if binding is None:
|
|
981
|
+
self.queue.pop(guid, None)
|
|
982
|
+
return
|
|
983
|
+
try:
|
|
984
|
+
self.database_instance = binding.require_current()
|
|
985
|
+
except DatabaseInstanceChangedError:
|
|
986
|
+
self._database_was_replaced(binding.database_instance)
|
|
987
|
+
return
|
|
988
|
+
|
|
989
|
+
priority = self.queue.pop(guid)
|
|
990
|
+
active_key = (self.generation, guid)
|
|
991
|
+
self.active.add(active_key)
|
|
992
|
+
self._active_priorities[active_key] = priority
|
|
993
|
+
self.previewGenerationChanged.emit(guid, True)
|
|
994
|
+
|
|
995
|
+
worker = PreviewWorker(
|
|
996
|
+
self.generation,
|
|
997
|
+
binding,
|
|
998
|
+
guid,
|
|
999
|
+
self.run_metadata[guid],
|
|
1000
|
+
(
|
|
1001
|
+
PREVIEW_THUMBNAIL_SIZE
|
|
1002
|
+
if priority in (
|
|
1003
|
+
PREVIEW_VISIBLE_PRIORITY,
|
|
1004
|
+
PREVIEW_SELECTED_THUMBNAIL_PRIORITY,
|
|
1005
|
+
)
|
|
1006
|
+
else self.preview_size
|
|
1007
|
+
),
|
|
1008
|
+
)
|
|
1009
|
+
worker.signals.finished.connect(self._worker_signal_finished)
|
|
1010
|
+
self._workers[active_key] = worker
|
|
1011
|
+
self.thread_pool.start(worker)
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
@QtCore.pyqtSlot(object, int, str, object, object)
|
|
1015
|
+
def _worker_signal_finished(self, worker, generation, guid, previews, error):
|
|
1016
|
+
self._worker_finished(generation, guid, previews, error, worker)
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _worker_finished(
|
|
1020
|
+
self,
|
|
1021
|
+
generation,
|
|
1022
|
+
guid,
|
|
1023
|
+
previews,
|
|
1024
|
+
error,
|
|
1025
|
+
completed_worker=None,
|
|
1026
|
+
):
|
|
1027
|
+
active_key = (generation, guid)
|
|
1028
|
+
current_worker = self._workers.get(active_key)
|
|
1029
|
+
if completed_worker is not None and completed_worker is not current_worker:
|
|
1030
|
+
return
|
|
1031
|
+
worker = self._workers.pop(active_key, None)
|
|
1032
|
+
was_cancelled = bool(
|
|
1033
|
+
worker is not None
|
|
1034
|
+
and getattr(worker, "is_cancelled", lambda: False)()
|
|
1035
|
+
)
|
|
1036
|
+
was_active = active_key in self.active
|
|
1037
|
+
self.active.discard(active_key)
|
|
1038
|
+
completed_priority = self._active_priorities.pop(active_key, None)
|
|
1039
|
+
if self._shutting_down:
|
|
1040
|
+
return
|
|
1041
|
+
if was_active and generation == self.generation:
|
|
1042
|
+
self.previewGenerationChanged.emit(guid, False)
|
|
1043
|
+
|
|
1044
|
+
if generation != self.generation:
|
|
1045
|
+
self._start_next()
|
|
1046
|
+
return
|
|
1047
|
+
|
|
1048
|
+
if worker is None:
|
|
1049
|
+
self._start_next()
|
|
1050
|
+
return
|
|
1051
|
+
|
|
1052
|
+
if isinstance(error, DatabaseInstanceChangedError):
|
|
1053
|
+
self._database_was_replaced(worker.database_instance)
|
|
1054
|
+
return
|
|
1055
|
+
|
|
1056
|
+
if was_cancelled:
|
|
1057
|
+
self._start_next()
|
|
1058
|
+
return
|
|
1059
|
+
|
|
1060
|
+
try:
|
|
1061
|
+
self.database_instance = worker.require_current_database_instance()
|
|
1062
|
+
except DatabaseInstanceChangedError:
|
|
1063
|
+
self._database_was_replaced(worker.database_instance)
|
|
1064
|
+
return
|
|
1065
|
+
|
|
1066
|
+
is_thumbnail = worker.preview_size == PREVIEW_THUMBNAIL_SIZE
|
|
1067
|
+
if not is_thumbnail:
|
|
1068
|
+
self._explicit_guids.discard(guid)
|
|
1069
|
+
if error:
|
|
1070
|
+
(self.thumbnail_errors if is_thumbnail else self.errors)[guid] = str(error)
|
|
1071
|
+
else:
|
|
1072
|
+
if is_thumbnail:
|
|
1073
|
+
self.thumbnail_cache[guid] = previews
|
|
1074
|
+
if (
|
|
1075
|
+
guid == self.current_guid
|
|
1076
|
+
or completed_priority
|
|
1077
|
+
== PREVIEW_SELECTED_THUMBNAIL_PRIORITY
|
|
1078
|
+
):
|
|
1079
|
+
self._enqueue(guid, priority=PREVIEW_SELECTED_PRIORITY)
|
|
1080
|
+
else:
|
|
1081
|
+
self._enqueue(guid, priority=PREVIEW_REMAINING_PRIORITY)
|
|
1082
|
+
else:
|
|
1083
|
+
self._store_cached(guid, previews)
|
|
1084
|
+
self._plot_previews.pop(guid, None)
|
|
1085
|
+
|
|
1086
|
+
# Cache/error mutation can yield to test hooks and future Qt-facing
|
|
1087
|
+
# helpers. Revalidate at the actual publication boundary so a sidecar
|
|
1088
|
+
# swap in that window clears the just-staged state before any signal or
|
|
1089
|
+
# selected-preview render can expose it.
|
|
1090
|
+
try:
|
|
1091
|
+
self.database_instance = worker.require_current_database_instance()
|
|
1092
|
+
except DatabaseInstanceChangedError:
|
|
1093
|
+
self._database_was_replaced(worker.database_instance)
|
|
1094
|
+
return
|
|
1095
|
+
|
|
1096
|
+
if not error:
|
|
1097
|
+
self.previewsReady.emit(guid, previews)
|
|
1098
|
+
|
|
1099
|
+
# Qt signals are direct for the ordinary same-thread connection.
|
|
1100
|
+
# A receiver can therefore change the selected database generation
|
|
1101
|
+
# or replace a sidecar before control returns here. Revalidate at
|
|
1102
|
+
# that yield boundary before rendering the selected preview.
|
|
1103
|
+
if generation != self.generation:
|
|
1104
|
+
return
|
|
1105
|
+
try:
|
|
1106
|
+
current_instance = worker.require_current_database_instance()
|
|
1107
|
+
except DatabaseInstanceChangedError:
|
|
1108
|
+
self._database_was_replaced(worker.database_instance)
|
|
1109
|
+
return
|
|
1110
|
+
if generation != self.generation:
|
|
1111
|
+
return
|
|
1112
|
+
self.database_instance = current_instance
|
|
1113
|
+
|
|
1114
|
+
if guid == self.current_guid and not is_thumbnail:
|
|
1115
|
+
if error:
|
|
1116
|
+
if guid in self._plot_previews:
|
|
1117
|
+
self._show_previews(list(self._plot_previews[guid].values()))
|
|
1118
|
+
else:
|
|
1119
|
+
self._show_message("Preview failed", str(error))
|
|
1120
|
+
else:
|
|
1121
|
+
self._show_previews(previews)
|
|
1122
|
+
|
|
1123
|
+
# Rendering mutates several widgets. Treat it as another
|
|
1124
|
+
# publication boundary so a replacement observed from a widget
|
|
1125
|
+
# hook cannot leave stale pixels/cache visible when this callback
|
|
1126
|
+
# returns.
|
|
1127
|
+
if generation != self.generation:
|
|
1128
|
+
return
|
|
1129
|
+
try:
|
|
1130
|
+
current_instance = worker.require_current_database_instance()
|
|
1131
|
+
except DatabaseInstanceChangedError:
|
|
1132
|
+
self._database_was_replaced(worker.database_instance)
|
|
1133
|
+
return
|
|
1134
|
+
if generation != self.generation:
|
|
1135
|
+
return
|
|
1136
|
+
self.database_instance = current_instance
|
|
1137
|
+
|
|
1138
|
+
self._start_next()
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def _database_was_replaced(self, replaced_instance):
|
|
1142
|
+
"""Discard stale previews and ask MainWindow to reload the source."""
|
|
1143
|
+
replaced_path = replaced_instance.logical_path
|
|
1144
|
+
self._cancel_workers()
|
|
1145
|
+
self.generation += 1
|
|
1146
|
+
self.database_path = ""
|
|
1147
|
+
self.database_instance = None
|
|
1148
|
+
self._database_binding = None
|
|
1149
|
+
self.current_guid = None
|
|
1150
|
+
self.run_metadata = {}
|
|
1151
|
+
self.cache = OrderedDict()
|
|
1152
|
+
self.thumbnail_cache = OrderedDict()
|
|
1153
|
+
self._plot_previews = {}
|
|
1154
|
+
self.cache_bytes = 0
|
|
1155
|
+
self.errors = {}
|
|
1156
|
+
self.thumbnail_errors = {}
|
|
1157
|
+
self.queue = {}
|
|
1158
|
+
self._explicit_guids = set()
|
|
1159
|
+
self.active = set()
|
|
1160
|
+
self._active_priorities = {}
|
|
1161
|
+
self._workers = {}
|
|
1162
|
+
self.metadata_signatures = {}
|
|
1163
|
+
self._start_scheduled = False
|
|
1164
|
+
self._show_message("Database replaced; reloading previews...")
|
|
1165
|
+
self.databaseReplaced.emit(replaced_path)
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def _clear_layout(self):
|
|
1169
|
+
while self.content_layout.count():
|
|
1170
|
+
item = self.content_layout.takeAt(0)
|
|
1171
|
+
if item is None:
|
|
1172
|
+
continue
|
|
1173
|
+
widget = item.widget()
|
|
1174
|
+
if widget is not None:
|
|
1175
|
+
widget.deleteLater()
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def _show_message(self, message, tooltip=None):
|
|
1179
|
+
self._clear_layout()
|
|
1180
|
+
if message in ("Generating preview...", "Retrying preview..."):
|
|
1181
|
+
metadata = self.run_metadata.get(self.current_guid, {})
|
|
1182
|
+
for dimension in preview_placeholder_dimensions(metadata) or [None]:
|
|
1183
|
+
placeholder = qtw.QLabel(f"{dimension}D" if dimension else "")
|
|
1184
|
+
placeholder.setObjectName("previewPlaceholder")
|
|
1185
|
+
placeholder.setFixedSize(self.preview_size, self.preview_size)
|
|
1186
|
+
placeholder.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
|
1187
|
+
placeholder.setFrameShape(qtw.QFrame.Shape.Box)
|
|
1188
|
+
placeholder.setToolTip(message)
|
|
1189
|
+
placeholder.setAccessibleName(
|
|
1190
|
+
f"{dimension}D preview pending" if dimension else "Preview pending"
|
|
1191
|
+
)
|
|
1192
|
+
self.content_layout.addWidget(placeholder)
|
|
1193
|
+
label = qtw.QLabel(message)
|
|
1194
|
+
label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
|
1195
|
+
label.setMinimumHeight(120)
|
|
1196
|
+
if tooltip:
|
|
1197
|
+
label.setToolTip(tooltip)
|
|
1198
|
+
self.content_layout.addWidget(label)
|
|
1199
|
+
self.content_layout.addStretch()
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
def _show_previews(self, previews):
|
|
1203
|
+
self._clear_layout()
|
|
1204
|
+
|
|
1205
|
+
if not previews:
|
|
1206
|
+
self._show_message("No 1D or 2D previews")
|
|
1207
|
+
return
|
|
1208
|
+
|
|
1209
|
+
for preview in previews:
|
|
1210
|
+
card = PreviewCard(preview, self.preview_size, self.current_guid, self)
|
|
1211
|
+
card.plotRequested.connect(self.plotRequested)
|
|
1212
|
+
card.exportRequested.connect(self.exportRequested)
|
|
1213
|
+
self.content_layout.addWidget(card)
|
|
1214
|
+
self.content_layout.addStretch()
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
class PreviewCard(qtw.QWidget):
|
|
1218
|
+
plotRequested = QtCore.pyqtSignal(str)
|
|
1219
|
+
exportRequested = QtCore.pyqtSignal(str)
|
|
1220
|
+
|
|
1221
|
+
def __init__(self, preview, preview_size, guid=None, *args):
|
|
1222
|
+
super().__init__(*args)
|
|
1223
|
+
self.parameter = preview.get("parameter", "")
|
|
1224
|
+
|
|
1225
|
+
if preview.get("unsupported"):
|
|
1226
|
+
label = unsupported_preview_label(
|
|
1227
|
+
preview,
|
|
1228
|
+
preview_size,
|
|
1229
|
+
"previewUnsupported",
|
|
1230
|
+
)
|
|
1231
|
+
layout = qtw.QHBoxLayout()
|
|
1232
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
1233
|
+
layout.addWidget(label)
|
|
1234
|
+
self.setLayout(layout)
|
|
1235
|
+
return
|
|
1236
|
+
|
|
1237
|
+
image = DraggablePreviewImageLabel(
|
|
1238
|
+
guid,
|
|
1239
|
+
self.parameter,
|
|
1240
|
+
preview.get("axes") or [],
|
|
1241
|
+
)
|
|
1242
|
+
image.setObjectName("previewImage")
|
|
1243
|
+
image.setFixedSize(preview_size, preview_size)
|
|
1244
|
+
image.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
|
1245
|
+
image.setPixmap(QtGui.QPixmap.fromImage(preview["image"]))
|
|
1246
|
+
image.setToolTip(preview["title"])
|
|
1247
|
+
image.set_preview_accessibility(preview["title"])
|
|
1248
|
+
image.plotRequested.connect(self.plotRequested)
|
|
1249
|
+
image.exportRequested.connect(self.exportRequested)
|
|
1250
|
+
|
|
1251
|
+
layout = qtw.QHBoxLayout()
|
|
1252
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
1253
|
+
layout.addWidget(image)
|
|
1254
|
+
self.setLayout(layout)
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
class PreviewImageLabel(qtw.QLabel):
|
|
1258
|
+
plotRequested = QtCore.pyqtSignal(str)
|
|
1259
|
+
exportRequested = QtCore.pyqtSignal(str)
|
|
1260
|
+
|
|
1261
|
+
def __init__(self, parameter, *args):
|
|
1262
|
+
super().__init__(*args)
|
|
1263
|
+
self.parameter = parameter
|
|
1264
|
+
self._selected = False
|
|
1265
|
+
self.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus)
|
|
1266
|
+
self.setProperty(PREVIEW_SELECTED_PROPERTY, False)
|
|
1267
|
+
self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.DefaultContextMenu)
|
|
1268
|
+
self.set_preview_accessibility(parameter)
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
def set_preview_accessibility(self, title=None):
|
|
1272
|
+
title = str(title or self.parameter or "").strip()
|
|
1273
|
+
accessible_name = f"Plot preview: {title}" if title else "Plot preview"
|
|
1274
|
+
self.setAccessibleName(accessible_name)
|
|
1275
|
+
self.setAccessibleDescription(
|
|
1276
|
+
"Press Enter or Space to plot. Press Menu or Shift+F10 for plot and "
|
|
1277
|
+
"export actions."
|
|
1278
|
+
)
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def set_selected(self, selected):
|
|
1282
|
+
self._selected = bool(selected)
|
|
1283
|
+
self.setProperty(PREVIEW_SELECTED_PROPERTY, self._selected)
|
|
1284
|
+
self.update()
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
def select_preview(self):
|
|
1288
|
+
scope = self._selection_scope()
|
|
1289
|
+
if scope is not None:
|
|
1290
|
+
for label in scope.findChildren(PreviewImageLabel):
|
|
1291
|
+
if label is not self:
|
|
1292
|
+
label.set_selected(False)
|
|
1293
|
+
self.set_selected(True)
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
def _selection_scope(self):
|
|
1297
|
+
parent = self.parentWidget()
|
|
1298
|
+
while parent is not None:
|
|
1299
|
+
if isinstance(parent, (PreviewTab, qtw.QTreeWidget)):
|
|
1300
|
+
return parent
|
|
1301
|
+
parent = parent.parentWidget()
|
|
1302
|
+
return self.window()
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def mousePressEvent(self, event):
|
|
1306
|
+
if event.button() == QtCore.Qt.MouseButton.LeftButton and self.parameter:
|
|
1307
|
+
self.select_preview()
|
|
1308
|
+
super().mousePressEvent(event)
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def focusInEvent(self, event):
|
|
1312
|
+
if self.parameter:
|
|
1313
|
+
self.select_preview()
|
|
1314
|
+
super().focusInEvent(event)
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def paintEvent(self, event):
|
|
1318
|
+
super().paintEvent(event)
|
|
1319
|
+
|
|
1320
|
+
if not self._selected:
|
|
1321
|
+
return
|
|
1322
|
+
|
|
1323
|
+
painter = QtGui.QPainter(self)
|
|
1324
|
+
color = self.palette().color(QtGui.QPalette.ColorRole.Highlight)
|
|
1325
|
+
rect = self.rect()
|
|
1326
|
+
border_width = 2
|
|
1327
|
+
painter.fillRect(rect.left(), rect.top(), rect.width(), border_width, color)
|
|
1328
|
+
painter.fillRect(
|
|
1329
|
+
rect.left(), rect.bottom() - border_width + 1,
|
|
1330
|
+
rect.width(), border_width, color,
|
|
1331
|
+
)
|
|
1332
|
+
painter.fillRect(rect.left(), rect.top(), border_width, rect.height(), color)
|
|
1333
|
+
painter.fillRect(
|
|
1334
|
+
rect.right() - border_width + 1, rect.top(),
|
|
1335
|
+
border_width, rect.height(), color,
|
|
1336
|
+
)
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
def mouseDoubleClickEvent(self, event):
|
|
1340
|
+
if event.button() == QtCore.Qt.MouseButton.LeftButton and self.parameter:
|
|
1341
|
+
self.plotRequested.emit(self.parameter)
|
|
1342
|
+
event.accept()
|
|
1343
|
+
return
|
|
1344
|
+
|
|
1345
|
+
super().mouseDoubleClickEvent(event)
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
def keyPressEvent(self, event):
|
|
1349
|
+
key = event.key()
|
|
1350
|
+
if self.parameter and key in (
|
|
1351
|
+
QtCore.Qt.Key.Key_Return,
|
|
1352
|
+
QtCore.Qt.Key.Key_Enter,
|
|
1353
|
+
QtCore.Qt.Key.Key_Space,
|
|
1354
|
+
):
|
|
1355
|
+
self.select_preview()
|
|
1356
|
+
self.plotRequested.emit(self.parameter)
|
|
1357
|
+
event.accept()
|
|
1358
|
+
return
|
|
1359
|
+
|
|
1360
|
+
context_menu_key = key == QtCore.Qt.Key.Key_Menu
|
|
1361
|
+
shift_f10 = (
|
|
1362
|
+
key == QtCore.Qt.Key.Key_F10
|
|
1363
|
+
and bool(event.modifiers() & QtCore.Qt.KeyboardModifier.ShiftModifier)
|
|
1364
|
+
)
|
|
1365
|
+
if self.parameter and (context_menu_key or shift_f10):
|
|
1366
|
+
self.select_preview()
|
|
1367
|
+
self._show_context_menu(self.mapToGlobal(self.rect().center()))
|
|
1368
|
+
event.accept()
|
|
1369
|
+
return
|
|
1370
|
+
|
|
1371
|
+
super().keyPressEvent(event)
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
def contextMenuEvent(self, event):
|
|
1375
|
+
if not self.parameter:
|
|
1376
|
+
super().contextMenuEvent(event)
|
|
1377
|
+
return
|
|
1378
|
+
|
|
1379
|
+
self.select_preview()
|
|
1380
|
+
self._show_context_menu(event.globalPos())
|
|
1381
|
+
event.accept()
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def _show_context_menu(self, global_position):
|
|
1385
|
+
menu = qtw.QMenu(self)
|
|
1386
|
+
|
|
1387
|
+
plot_action = QtGui.QAction("&Plot", menu)
|
|
1388
|
+
plot_action.triggered.connect(lambda: self.plotRequested.emit(self.parameter))
|
|
1389
|
+
menu.addAction(plot_action)
|
|
1390
|
+
|
|
1391
|
+
export_action = QtGui.QAction("&Export CSV...", menu)
|
|
1392
|
+
export_action.triggered.connect(lambda: self.exportRequested.emit(self.parameter))
|
|
1393
|
+
menu.addAction(export_action)
|
|
1394
|
+
|
|
1395
|
+
menu.exec(global_position)
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
class DraggablePreviewImageLabel(PreviewImageLabel):
|
|
1399
|
+
dragFinished = QtCore.pyqtSignal()
|
|
1400
|
+
|
|
1401
|
+
def __init__(self, guid, parameter, axes=None, *args, axes_pending=False):
|
|
1402
|
+
super().__init__(parameter, *args)
|
|
1403
|
+
self.guid = guid or ""
|
|
1404
|
+
self.axes = list(axes or [])
|
|
1405
|
+
self.axes_pending = axes_pending
|
|
1406
|
+
self.drag_active = False
|
|
1407
|
+
self._drag_start_pos = None
|
|
1408
|
+
if self.guid:
|
|
1409
|
+
self.setCursor(QtCore.Qt.CursorShape.OpenHandCursor)
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
def mousePressEvent(self, event):
|
|
1413
|
+
if event.button() == QtCore.Qt.MouseButton.LeftButton:
|
|
1414
|
+
self._drag_start_pos = event.pos()
|
|
1415
|
+
super().mousePressEvent(event)
|
|
1416
|
+
|
|
1417
|
+
|
|
1418
|
+
def mouseMoveEvent(self, event):
|
|
1419
|
+
if not (
|
|
1420
|
+
event.buttons() & QtCore.Qt.MouseButton.LeftButton
|
|
1421
|
+
and self._drag_start_pos is not None
|
|
1422
|
+
and self.guid
|
|
1423
|
+
and self.parameter
|
|
1424
|
+
):
|
|
1425
|
+
super().mouseMoveEvent(event)
|
|
1426
|
+
return
|
|
1427
|
+
|
|
1428
|
+
distance = (event.pos() - self._drag_start_pos).manhattanLength()
|
|
1429
|
+
if distance < qtw.QApplication.startDragDistance():
|
|
1430
|
+
super().mouseMoveEvent(event)
|
|
1431
|
+
return
|
|
1432
|
+
|
|
1433
|
+
self._start_drag()
|
|
1434
|
+
event.accept()
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
def _start_drag(self):
|
|
1438
|
+
drag = QtGui.QDrag(self)
|
|
1439
|
+
drag.setMimeData(make_run_preview_mime(
|
|
1440
|
+
self.guid, self.parameter, self.axes, axes_pending=self.axes_pending,
|
|
1441
|
+
))
|
|
1442
|
+
|
|
1443
|
+
pixmap = self.pixmap()
|
|
1444
|
+
if pixmap is not None and not pixmap.isNull():
|
|
1445
|
+
drag.setPixmap(pixmap)
|
|
1446
|
+
drag.setHotSpot(QtCore.QPoint(pixmap.width() // 2, pixmap.height() // 2))
|
|
1447
|
+
else:
|
|
1448
|
+
drag.setPixmap(self.grab())
|
|
1449
|
+
drag.setHotSpot(self.rect().center())
|
|
1450
|
+
|
|
1451
|
+
self.drag_active = True
|
|
1452
|
+
try:
|
|
1453
|
+
drag.exec(QtCore.Qt.DropAction.CopyAction)
|
|
1454
|
+
finally:
|
|
1455
|
+
self.drag_active = False
|
|
1456
|
+
self._drag_start_pos = None
|
|
1457
|
+
self.dragFinished.emit()
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
class PreviewWorker(QtCore.QRunnable):
|
|
1461
|
+
def __init__(
|
|
1462
|
+
self,
|
|
1463
|
+
generation,
|
|
1464
|
+
database_path,
|
|
1465
|
+
guid,
|
|
1466
|
+
metadata,
|
|
1467
|
+
preview_size,
|
|
1468
|
+
*,
|
|
1469
|
+
deadline=None,
|
|
1470
|
+
):
|
|
1471
|
+
super().__init__()
|
|
1472
|
+
self.signals = PreviewSignals()
|
|
1473
|
+
self.generation = generation
|
|
1474
|
+
binding = (
|
|
1475
|
+
database_path
|
|
1476
|
+
if isinstance(database_path, _PreviewDatabaseBinding)
|
|
1477
|
+
else None
|
|
1478
|
+
)
|
|
1479
|
+
database_instance = (
|
|
1480
|
+
binding.database_instance
|
|
1481
|
+
if binding is not None
|
|
1482
|
+
else _preview_database_instance(database_path)
|
|
1483
|
+
)
|
|
1484
|
+
if database_instance is None:
|
|
1485
|
+
raise ValueError("PreviewWorker requires a database path")
|
|
1486
|
+
self._database_binding = binding or _PreviewDatabaseBinding(database_instance)
|
|
1487
|
+
self._set_database_instance(database_instance)
|
|
1488
|
+
self.guid = guid
|
|
1489
|
+
self.metadata = metadata
|
|
1490
|
+
self.preview_size = preview_size
|
|
1491
|
+
self.deadline = deadline
|
|
1492
|
+
self._cancelled = threading.Event()
|
|
1493
|
+
self._connection_lock = threading.Lock()
|
|
1494
|
+
self._publication_lock = threading.RLock()
|
|
1495
|
+
self._connection = None
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
def cancel(self):
|
|
1499
|
+
with self._publication_lock:
|
|
1500
|
+
self._cancelled.set()
|
|
1501
|
+
with self._connection_lock:
|
|
1502
|
+
connection = self._connection
|
|
1503
|
+
if connection is not None:
|
|
1504
|
+
try:
|
|
1505
|
+
connection.interrupt()
|
|
1506
|
+
except Exception:
|
|
1507
|
+
# The worker may have closed the connection between the lock
|
|
1508
|
+
# release and this cross-thread cancellation request.
|
|
1509
|
+
pass
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
def is_cancelled(self):
|
|
1513
|
+
return self._cancelled.is_set()
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
def _set_connection(self, connection):
|
|
1517
|
+
with self._connection_lock:
|
|
1518
|
+
self._connection = connection
|
|
1519
|
+
if connection is not None and self._cancelled.is_set():
|
|
1520
|
+
try:
|
|
1521
|
+
connection.interrupt()
|
|
1522
|
+
except Exception:
|
|
1523
|
+
pass
|
|
1524
|
+
|
|
1525
|
+
|
|
1526
|
+
def _set_database_instance(self, database_instance):
|
|
1527
|
+
self.database_instance = database_instance
|
|
1528
|
+
self.database_path = database_instance.logical_path
|
|
1529
|
+
self.resolved_database_path = database_instance.resolved_path
|
|
1530
|
+
self.database_identity = database_instance.identity
|
|
1531
|
+
self.sidecar_identities = database_instance.sidecar_identities
|
|
1532
|
+
|
|
1533
|
+
|
|
1534
|
+
def require_current_database_instance(self):
|
|
1535
|
+
database_instance = self._database_binding.require_current()
|
|
1536
|
+
self._set_database_instance(database_instance)
|
|
1537
|
+
return database_instance
|
|
1538
|
+
|
|
1539
|
+
|
|
1540
|
+
def _emit_finished(self, previews, error):
|
|
1541
|
+
with self._publication_lock:
|
|
1542
|
+
if self._cancelled.is_set():
|
|
1543
|
+
previews = []
|
|
1544
|
+
error = None
|
|
1545
|
+
else:
|
|
1546
|
+
error = _bounded_preview_error(error)
|
|
1547
|
+
try:
|
|
1548
|
+
self.signals.finished.emit(
|
|
1549
|
+
self,
|
|
1550
|
+
self.generation,
|
|
1551
|
+
self.guid,
|
|
1552
|
+
previews,
|
|
1553
|
+
error,
|
|
1554
|
+
)
|
|
1555
|
+
except RuntimeError as err:
|
|
1556
|
+
message = str(err)
|
|
1557
|
+
if not (
|
|
1558
|
+
"wrapped C/C++ object" in message
|
|
1559
|
+
and "has been deleted" in message
|
|
1560
|
+
):
|
|
1561
|
+
raise
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
def run(self):
|
|
1565
|
+
try:
|
|
1566
|
+
if self._cancelled.is_set():
|
|
1567
|
+
previews = []
|
|
1568
|
+
else:
|
|
1569
|
+
preview_kwargs = {
|
|
1570
|
+
"size": self.preview_size,
|
|
1571
|
+
"is_cancelled": self._cancelled.is_set,
|
|
1572
|
+
"connection_callback": self._set_connection,
|
|
1573
|
+
}
|
|
1574
|
+
if self.deadline is not None:
|
|
1575
|
+
preview_kwargs["deadline"] = self.deadline
|
|
1576
|
+
previews = generate_run_previews(
|
|
1577
|
+
self._database_binding,
|
|
1578
|
+
self.metadata,
|
|
1579
|
+
**preview_kwargs,
|
|
1580
|
+
)
|
|
1581
|
+
self._set_database_instance(
|
|
1582
|
+
self._database_binding.database_instance,
|
|
1583
|
+
)
|
|
1584
|
+
if self._cancelled.is_set():
|
|
1585
|
+
previews = []
|
|
1586
|
+
self._emit_finished(previews, None)
|
|
1587
|
+
except InterruptedError:
|
|
1588
|
+
self._emit_finished([], None)
|
|
1589
|
+
except DatabaseInstanceChangedError as error:
|
|
1590
|
+
self._emit_finished([], error)
|
|
1591
|
+
except Exception as error:
|
|
1592
|
+
if self._cancelled.is_set():
|
|
1593
|
+
self._emit_finished([], None)
|
|
1594
|
+
return
|
|
1595
|
+
log_exception("Preview generation failed", error, __name__)
|
|
1596
|
+
self._emit_finished([], error)
|
|
1597
|
+
|
|
1598
|
+
|
|
1599
|
+
class PreviewSignals(QtCore.QObject):
|
|
1600
|
+
finished = QtCore.pyqtSignal(object, int, str, object, object)
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
def generate_run_previews(
|
|
1604
|
+
database_path,
|
|
1605
|
+
metadata,
|
|
1606
|
+
size=PREVIEW_SIZE,
|
|
1607
|
+
is_cancelled=None,
|
|
1608
|
+
connection_callback=None,
|
|
1609
|
+
deadline=None,
|
|
1610
|
+
):
|
|
1611
|
+
_raise_if_preview_aborted(is_cancelled, deadline)
|
|
1612
|
+
|
|
1613
|
+
table_name = metadata.get("result_table_name")
|
|
1614
|
+
if not database_path or not table_name:
|
|
1615
|
+
return []
|
|
1616
|
+
|
|
1617
|
+
binding = (
|
|
1618
|
+
database_path
|
|
1619
|
+
if isinstance(database_path, _PreviewDatabaseBinding)
|
|
1620
|
+
else None
|
|
1621
|
+
)
|
|
1622
|
+
database_instance = (
|
|
1623
|
+
binding.database_instance
|
|
1624
|
+
if binding is not None
|
|
1625
|
+
else _preview_database_instance(database_path)
|
|
1626
|
+
)
|
|
1627
|
+
if database_instance is None:
|
|
1628
|
+
return []
|
|
1629
|
+
binding = binding or _PreviewDatabaseBinding(database_instance)
|
|
1630
|
+
database_instance = binding.require_current()
|
|
1631
|
+
|
|
1632
|
+
dependencies = _dependencies_from_metadata(metadata)
|
|
1633
|
+
if not dependencies:
|
|
1634
|
+
return []
|
|
1635
|
+
|
|
1636
|
+
previews = []
|
|
1637
|
+
connection_kwargs = {
|
|
1638
|
+
"timeout": 10,
|
|
1639
|
+
"expected_database_identity": database_instance.identity,
|
|
1640
|
+
}
|
|
1641
|
+
if is_cancelled is not None:
|
|
1642
|
+
connection_kwargs["cancelled_callback"] = is_cancelled
|
|
1643
|
+
if deadline is not None:
|
|
1644
|
+
connection_kwargs["deadline"] = deadline
|
|
1645
|
+
conn = sqlite_read_only_connection(
|
|
1646
|
+
database_instance.logical_path,
|
|
1647
|
+
**connection_kwargs,
|
|
1648
|
+
)
|
|
1649
|
+
cursor = None
|
|
1650
|
+
try:
|
|
1651
|
+
if connection_callback is not None:
|
|
1652
|
+
connection_callback(conn)
|
|
1653
|
+
if is_cancelled is not None or deadline is not None:
|
|
1654
|
+
conn.set_progress_handler(
|
|
1655
|
+
lambda: int(_preview_abort_requested(is_cancelled, deadline)),
|
|
1656
|
+
PREVIEW_SQL_PROGRESS_OPCODES,
|
|
1657
|
+
)
|
|
1658
|
+
cursor = conn.cursor()
|
|
1659
|
+
available_columns = _table_columns(cursor, table_name)
|
|
1660
|
+
for parameter, axes in dependencies.items():
|
|
1661
|
+
_raise_if_preview_aborted(is_cancelled, deadline)
|
|
1662
|
+
if parameter not in available_columns:
|
|
1663
|
+
continue
|
|
1664
|
+
|
|
1665
|
+
declared_axes = [str(axis) for axis in axes]
|
|
1666
|
+
if len(declared_axes) > MAX_SUPPORTED_PLOT_DIMENSIONS:
|
|
1667
|
+
previews.append(_unsupported_preview(parameter, declared_axes))
|
|
1668
|
+
continue
|
|
1669
|
+
|
|
1670
|
+
axes = [axis for axis in declared_axes if axis in available_columns]
|
|
1671
|
+
if len(axes) == 1:
|
|
1672
|
+
preview = _preview_1d(cursor, table_name, metadata, parameter, axes[0], size)
|
|
1673
|
+
elif len(axes) >= 2:
|
|
1674
|
+
preview = _preview_2d(
|
|
1675
|
+
cursor,
|
|
1676
|
+
table_name,
|
|
1677
|
+
metadata,
|
|
1678
|
+
parameter,
|
|
1679
|
+
axes[:2],
|
|
1680
|
+
size,
|
|
1681
|
+
is_cancelled=lambda: _preview_abort_requested(
|
|
1682
|
+
is_cancelled,
|
|
1683
|
+
deadline,
|
|
1684
|
+
),
|
|
1685
|
+
)
|
|
1686
|
+
else:
|
|
1687
|
+
continue
|
|
1688
|
+
|
|
1689
|
+
if preview is not None:
|
|
1690
|
+
previews.append(preview)
|
|
1691
|
+
_raise_if_preview_aborted(is_cancelled, deadline)
|
|
1692
|
+
except sqlite3.OperationalError as error:
|
|
1693
|
+
if "interrupted" in str(error).lower():
|
|
1694
|
+
_raise_if_preview_aborted(is_cancelled, deadline)
|
|
1695
|
+
raise
|
|
1696
|
+
finally:
|
|
1697
|
+
try:
|
|
1698
|
+
if cursor is not None:
|
|
1699
|
+
cursor.close()
|
|
1700
|
+
finally:
|
|
1701
|
+
try:
|
|
1702
|
+
if connection_callback is not None:
|
|
1703
|
+
connection_callback(None)
|
|
1704
|
+
finally:
|
|
1705
|
+
conn.close()
|
|
1706
|
+
|
|
1707
|
+
binding.require_current()
|
|
1708
|
+
return previews
|
|
1709
|
+
|
|
1710
|
+
|
|
1711
|
+
def _preview_database_instance(database_source):
|
|
1712
|
+
"""Return the exact source instance carried by one preview request."""
|
|
1713
|
+
if isinstance(database_source, DatabaseInstance):
|
|
1714
|
+
return database_source
|
|
1715
|
+
if not database_source:
|
|
1716
|
+
return None
|
|
1717
|
+
return capture_database_instance(database_source)
|
|
1718
|
+
|
|
1719
|
+
|
|
1720
|
+
def _require_current_preview_database_instance(expected_instance):
|
|
1721
|
+
"""Reject a preview if its accepted logical source has been replaced."""
|
|
1722
|
+
current_instance = capture_database_instance(expected_instance.logical_path)
|
|
1723
|
+
if (
|
|
1724
|
+
not database_instances_differ(expected_instance, current_instance)
|
|
1725
|
+
and expected_instance.sidecar_identities.issubset(
|
|
1726
|
+
current_instance.sidecar_identities,
|
|
1727
|
+
)
|
|
1728
|
+
):
|
|
1729
|
+
if (
|
|
1730
|
+
expected_instance.sidecar_identities
|
|
1731
|
+
== current_instance.sidecar_identities
|
|
1732
|
+
):
|
|
1733
|
+
return expected_instance
|
|
1734
|
+
return current_instance
|
|
1735
|
+
quarantine_wal_for_replaced_database(expected_instance.logical_path)
|
|
1736
|
+
raise DatabaseInstanceChangedError(
|
|
1737
|
+
"The database was replaced while qPlot was generating a preview."
|
|
1738
|
+
)
|
|
1739
|
+
|
|
1740
|
+
|
|
1741
|
+
def _unsupported_preview(parameter, axes):
|
|
1742
|
+
axes = [str(axis) for axis in axes]
|
|
1743
|
+
return {
|
|
1744
|
+
"parameter": str(parameter),
|
|
1745
|
+
"axes": axes,
|
|
1746
|
+
"dimension_count": len(axes),
|
|
1747
|
+
"title": unsupported_plot_message(parameter, axes),
|
|
1748
|
+
"unsupported": True,
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
|
|
1752
|
+
def unsupported_preview_label(preview, size, object_name):
|
|
1753
|
+
dimensions = int(preview.get("dimension_count") or len(preview.get("axes") or []))
|
|
1754
|
+
text = f"{dimensions}D" if int(size) <= 32 else f"{dimensions}D\nunsupported"
|
|
1755
|
+
label = qtw.QLabel(text)
|
|
1756
|
+
label.setObjectName(object_name)
|
|
1757
|
+
label.setAccessibleName(f"{dimensions}D measurement unsupported")
|
|
1758
|
+
label.setFixedSize(int(size), int(size))
|
|
1759
|
+
label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
|
1760
|
+
label.setWordWrap(True)
|
|
1761
|
+
label.setToolTip(preview.get("title", "Unsupported measurement dimensionality"))
|
|
1762
|
+
label.setFrameShape(qtw.QFrame.Shape.Box)
|
|
1763
|
+
label.setFrameShadow(qtw.QFrame.Shadow.Plain)
|
|
1764
|
+
return label
|
|
1765
|
+
|
|
1766
|
+
|
|
1767
|
+
def _preview_1d(cursor, table_name, metadata, parameter, axis, size):
|
|
1768
|
+
x, y = _select_arrays(
|
|
1769
|
+
cursor,
|
|
1770
|
+
table_name,
|
|
1771
|
+
[axis, parameter],
|
|
1772
|
+
metadata,
|
|
1773
|
+
eligible_columns=[axis, parameter],
|
|
1774
|
+
)
|
|
1775
|
+
image = render_sparkline_preview(x, y, size=size)
|
|
1776
|
+
return {
|
|
1777
|
+
"parameter": parameter,
|
|
1778
|
+
"axes": [axis],
|
|
1779
|
+
"title": _preview_title(parameter, [axis]),
|
|
1780
|
+
"image": image,
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
|
|
1784
|
+
def _preview_2d(
|
|
1785
|
+
cursor,
|
|
1786
|
+
table_name,
|
|
1787
|
+
metadata,
|
|
1788
|
+
parameter,
|
|
1789
|
+
axes,
|
|
1790
|
+
size,
|
|
1791
|
+
*,
|
|
1792
|
+
is_cancelled=None,
|
|
1793
|
+
):
|
|
1794
|
+
grid_shape = _preview_grid_shape(metadata, parameter)
|
|
1795
|
+
grid = _aggregate_large_heatmap_preview(
|
|
1796
|
+
cursor,
|
|
1797
|
+
table_name,
|
|
1798
|
+
metadata,
|
|
1799
|
+
parameter,
|
|
1800
|
+
grid_shape,
|
|
1801
|
+
size,
|
|
1802
|
+
axes=axes,
|
|
1803
|
+
is_cancelled=is_cancelled,
|
|
1804
|
+
)
|
|
1805
|
+
if grid is not None:
|
|
1806
|
+
image = render_heatmap_grid_preview(grid, size=size)
|
|
1807
|
+
return {
|
|
1808
|
+
"parameter": parameter,
|
|
1809
|
+
"axes": list(axes),
|
|
1810
|
+
"title": _preview_title(parameter, axes),
|
|
1811
|
+
"image": image,
|
|
1812
|
+
"downsample_strategy": "spatial mean",
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
x, y, z = _select_arrays(
|
|
1816
|
+
cursor,
|
|
1817
|
+
table_name,
|
|
1818
|
+
[axes[1], axes[0], parameter],
|
|
1819
|
+
metadata,
|
|
1820
|
+
max_rows=_preview_2d_row_limit(grid_shape),
|
|
1821
|
+
sampling="stratified",
|
|
1822
|
+
)
|
|
1823
|
+
image = render_heatmap_preview(
|
|
1824
|
+
x,
|
|
1825
|
+
y,
|
|
1826
|
+
z,
|
|
1827
|
+
size=size,
|
|
1828
|
+
grid_shape=grid_shape,
|
|
1829
|
+
)
|
|
1830
|
+
return {
|
|
1831
|
+
"parameter": parameter,
|
|
1832
|
+
"axes": list(axes),
|
|
1833
|
+
"title": _preview_title(parameter, axes),
|
|
1834
|
+
"image": image,
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
|
|
1838
|
+
def _preview_title(parameter, axes):
|
|
1839
|
+
axes = [str(axis) for axis in axes if axis]
|
|
1840
|
+
if len(axes) == 0:
|
|
1841
|
+
return str(parameter)
|
|
1842
|
+
if len(axes) == 1:
|
|
1843
|
+
axis_text = axes[0]
|
|
1844
|
+
elif len(axes) == 2:
|
|
1845
|
+
axis_text = f"{axes[0]} and {axes[1]}"
|
|
1846
|
+
else:
|
|
1847
|
+
axis_text = f"{', '.join(axes[:-1])}, and {axes[-1]}"
|
|
1848
|
+
return f"{parameter} vs {axis_text}"
|
|
1849
|
+
|
|
1850
|
+
|
|
1851
|
+
def render_displayed_plot_preview(worker, parameter, size):
|
|
1852
|
+
"""Render one unmodified plot from its already loaded arrays."""
|
|
1853
|
+
|
|
1854
|
+
axes = list(getattr(parameter, "depends_on_", ()) or ())
|
|
1855
|
+
if len(axes) not in (1, 2):
|
|
1856
|
+
return None
|
|
1857
|
+
axis_data = getattr(worker, "axis_data", None)
|
|
1858
|
+
if not isinstance(axis_data, dict):
|
|
1859
|
+
return None
|
|
1860
|
+
size = int(size)
|
|
1861
|
+
if len(axes) == 1:
|
|
1862
|
+
x = np.asarray(axis_data.get("x", []), dtype=float).ravel()
|
|
1863
|
+
y = np.asarray(axis_data.get("y", []), dtype=float).ravel()
|
|
1864
|
+
count = min(x.size, y.size)
|
|
1865
|
+
if count == 0:
|
|
1866
|
+
return None
|
|
1867
|
+
x, y = x[:count], y[:count]
|
|
1868
|
+
valid = np.isfinite(x) & np.isfinite(y)
|
|
1869
|
+
x, y = x[valid], y[valid]
|
|
1870
|
+
if not x.size:
|
|
1871
|
+
return None
|
|
1872
|
+
# Keep GUI-thread painting bounded even for a very long line.
|
|
1873
|
+
limit = max(2, 4 * size)
|
|
1874
|
+
if x.size > limit:
|
|
1875
|
+
indices = np.linspace(0, x.size - 1, limit, dtype=np.intp)
|
|
1876
|
+
x, y = x[indices], y[indices]
|
|
1877
|
+
image = render_sparkline_preview(x, y, size=size)
|
|
1878
|
+
else:
|
|
1879
|
+
grid = np.asarray(getattr(worker, "dataGrid", []), dtype=float)
|
|
1880
|
+
if grid.ndim != 2 or not grid.size or not np.isfinite(grid).any():
|
|
1881
|
+
return None
|
|
1882
|
+
# The plot worker may have a high resolution grid; bound the reuse
|
|
1883
|
+
# work before the preview's spatial reduction runs on the GUI thread.
|
|
1884
|
+
max_axis = max(1, 4 * size)
|
|
1885
|
+
row_step = max(1, (grid.shape[0] + max_axis - 1) // max_axis)
|
|
1886
|
+
column_step = max(1, (grid.shape[1] + max_axis - 1) // max_axis)
|
|
1887
|
+
image = render_heatmap_grid_preview(
|
|
1888
|
+
grid[::row_step, ::column_step], size=size,
|
|
1889
|
+
)
|
|
1890
|
+
name = str(parameter.name)
|
|
1891
|
+
return {
|
|
1892
|
+
"parameter": name,
|
|
1893
|
+
"axes": axes,
|
|
1894
|
+
"title": _preview_title(name, axes),
|
|
1895
|
+
"image": image,
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
|
|
1899
|
+
def _preview_2d_row_limit(grid_shape):
|
|
1900
|
+
shape = _normalise_grid_shape(grid_shape)
|
|
1901
|
+
if shape is None:
|
|
1902
|
+
return MAX_PREVIEW_ROWS
|
|
1903
|
+
|
|
1904
|
+
rows, columns = shape
|
|
1905
|
+
grid_cells = rows * columns
|
|
1906
|
+
if grid_cells <= MAX_PREVIEW_GRID_CELLS:
|
|
1907
|
+
return max(MAX_PREVIEW_ROWS, grid_cells)
|
|
1908
|
+
|
|
1909
|
+
return MAX_PREVIEW_ROWS
|
|
1910
|
+
|
|
1911
|
+
|
|
1912
|
+
def render_sparkline_preview(x, y, size=PREVIEW_SIZE):
|
|
1913
|
+
image = QtGui.QImage(size, size, QtGui.QImage.Format.Format_RGB32)
|
|
1914
|
+
image.fill(QtGui.QColor(PREVIEW_BACKGROUND_COLOR))
|
|
1915
|
+
|
|
1916
|
+
x = np.asarray(x, dtype=float)
|
|
1917
|
+
y = np.asarray(y, dtype=float)
|
|
1918
|
+
valid = np.isfinite(x) & np.isfinite(y)
|
|
1919
|
+
x = x[valid]
|
|
1920
|
+
y = y[valid]
|
|
1921
|
+
if x.size == 0:
|
|
1922
|
+
return image
|
|
1923
|
+
|
|
1924
|
+
x_range = _finite_range(x)
|
|
1925
|
+
y_range = _finite_range(y)
|
|
1926
|
+
plot_margin = 10
|
|
1927
|
+
plot_size = size - 2 * plot_margin
|
|
1928
|
+
|
|
1929
|
+
def scale(values, data_range, invert=False):
|
|
1930
|
+
low, high = data_range
|
|
1931
|
+
if high == low:
|
|
1932
|
+
scaled = np.full(values.shape, 0.5)
|
|
1933
|
+
else:
|
|
1934
|
+
span = high - low
|
|
1935
|
+
if np.isfinite(span):
|
|
1936
|
+
scaled = (values - low) / span
|
|
1937
|
+
else:
|
|
1938
|
+
# Finite opposite-sign endpoints can have an infinite span.
|
|
1939
|
+
# Scale before subtraction only in that case, retaining the
|
|
1940
|
+
# direct arithmetic for tiny or closely spaced ranges.
|
|
1941
|
+
magnitude = max(abs(low), abs(high))
|
|
1942
|
+
scaled_low, scaled_high = low / magnitude, high / magnitude
|
|
1943
|
+
scaled = (values / magnitude - scaled_low) / (scaled_high - scaled_low)
|
|
1944
|
+
if invert:
|
|
1945
|
+
scaled = 1 - scaled
|
|
1946
|
+
return plot_margin + scaled * plot_size
|
|
1947
|
+
|
|
1948
|
+
xs = scale(x, x_range)
|
|
1949
|
+
ys = scale(y, y_range, invert=True)
|
|
1950
|
+
|
|
1951
|
+
painter = QtGui.QPainter(image)
|
|
1952
|
+
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
|
|
1953
|
+
painter.setPen(QtGui.QPen(QtGui.QColor(210, 0, 0), 3))
|
|
1954
|
+
|
|
1955
|
+
if x.size == 1:
|
|
1956
|
+
painter.drawEllipse(QtCore.QPointF(float(xs[0]), float(ys[0])), 3, 3)
|
|
1957
|
+
else:
|
|
1958
|
+
path = QtGui.QPainterPath(QtCore.QPointF(float(xs[0]), float(ys[0])))
|
|
1959
|
+
for x_value, y_value in zip(xs[1:], ys[1:], strict=False):
|
|
1960
|
+
path.lineTo(float(x_value), float(y_value))
|
|
1961
|
+
painter.drawPath(path)
|
|
1962
|
+
|
|
1963
|
+
painter.end()
|
|
1964
|
+
return image
|
|
1965
|
+
|
|
1966
|
+
|
|
1967
|
+
def render_heatmap_preview(x, y, z, size=PREVIEW_SIZE, grid_shape=None):
|
|
1968
|
+
image = QtGui.QImage(size, size, QtGui.QImage.Format.Format_RGB32)
|
|
1969
|
+
image.fill(QtGui.QColor(PREVIEW_BACKGROUND_COLOR))
|
|
1970
|
+
|
|
1971
|
+
x = np.asarray(x, dtype=float)
|
|
1972
|
+
y = np.asarray(y, dtype=float)
|
|
1973
|
+
z = np.asarray(z, dtype=float)
|
|
1974
|
+
valid = np.isfinite(x) & np.isfinite(y) & np.isfinite(z)
|
|
1975
|
+
if not np.any(valid):
|
|
1976
|
+
return image
|
|
1977
|
+
|
|
1978
|
+
x = x[valid]
|
|
1979
|
+
y = y[valid]
|
|
1980
|
+
z = z[valid]
|
|
1981
|
+
|
|
1982
|
+
grid = _fixed_heatmap_grid(
|
|
1983
|
+
x,
|
|
1984
|
+
y,
|
|
1985
|
+
z,
|
|
1986
|
+
grid_shape,
|
|
1987
|
+
max_cells=MAX_PREVIEW_GRID_CELLS,
|
|
1988
|
+
)
|
|
1989
|
+
if grid is None:
|
|
1990
|
+
grid = _unique_axis_heatmap_grid(
|
|
1991
|
+
x,
|
|
1992
|
+
y,
|
|
1993
|
+
z,
|
|
1994
|
+
max_cells=MAX_PREVIEW_GRID_CELLS,
|
|
1995
|
+
)
|
|
1996
|
+
if grid is None:
|
|
1997
|
+
grid = _binned_heatmap_grid(x, y, z, size=size, grid_shape=grid_shape)
|
|
1998
|
+
|
|
1999
|
+
return render_heatmap_grid_preview(grid, size=size)
|
|
2000
|
+
|
|
2001
|
+
|
|
2002
|
+
def render_heatmap_grid_preview(grid, size=PREVIEW_SIZE):
|
|
2003
|
+
image = QtGui.QImage(size, size, QtGui.QImage.Format.Format_RGB32)
|
|
2004
|
+
image.fill(QtGui.QColor(PREVIEW_BACKGROUND_COLOR))
|
|
2005
|
+
|
|
2006
|
+
grid = np.asarray(grid, dtype=float)
|
|
2007
|
+
if grid.size == 0 or np.all(np.isnan(grid)):
|
|
2008
|
+
return image
|
|
2009
|
+
|
|
2010
|
+
grid = _prepare_heatmap_display_grid(grid, size)
|
|
2011
|
+
rgb = _viridis_rgb(grid)
|
|
2012
|
+
rgb = np.flipud(rgb)
|
|
2013
|
+
rgb_bytes = rgb.tobytes()
|
|
2014
|
+
source = QtGui.QImage(
|
|
2015
|
+
rgb_bytes,
|
|
2016
|
+
rgb.shape[1],
|
|
2017
|
+
rgb.shape[0],
|
|
2018
|
+
rgb.shape[1] * 3,
|
|
2019
|
+
QtGui.QImage.Format.Format_RGB888,
|
|
2020
|
+
).copy()
|
|
2021
|
+
|
|
2022
|
+
return source.scaled(
|
|
2023
|
+
size,
|
|
2024
|
+
size,
|
|
2025
|
+
QtCore.Qt.AspectRatioMode.IgnoreAspectRatio,
|
|
2026
|
+
QtCore.Qt.TransformationMode.FastTransformation,
|
|
2027
|
+
).convertToFormat(QtGui.QImage.Format.Format_RGB32)
|
|
2028
|
+
|
|
2029
|
+
|
|
2030
|
+
def _aggregate_large_heatmap_preview(
|
|
2031
|
+
cursor,
|
|
2032
|
+
table_name,
|
|
2033
|
+
metadata,
|
|
2034
|
+
parameter,
|
|
2035
|
+
grid_shape,
|
|
2036
|
+
size,
|
|
2037
|
+
*,
|
|
2038
|
+
axes=None,
|
|
2039
|
+
is_cancelled=None,
|
|
2040
|
+
):
|
|
2041
|
+
shape = _normalise_grid_shape(grid_shape)
|
|
2042
|
+
if not _preview_requires_spatial_aggregation(
|
|
2043
|
+
cursor,
|
|
2044
|
+
table_name,
|
|
2045
|
+
metadata,
|
|
2046
|
+
shape,
|
|
2047
|
+
):
|
|
2048
|
+
return None
|
|
2049
|
+
|
|
2050
|
+
if axes is None:
|
|
2051
|
+
axes = _dependencies_from_metadata(metadata).get(parameter, [])
|
|
2052
|
+
if len(axes) < 2:
|
|
2053
|
+
return None
|
|
2054
|
+
|
|
2055
|
+
try:
|
|
2056
|
+
return _spatial_mean_preview_grid(
|
|
2057
|
+
cursor,
|
|
2058
|
+
table_name,
|
|
2059
|
+
parameter,
|
|
2060
|
+
x_axis=axes[1],
|
|
2061
|
+
y_axis=axes[0],
|
|
2062
|
+
grid_shape=shape,
|
|
2063
|
+
size=size,
|
|
2064
|
+
)
|
|
2065
|
+
except Exception as error:
|
|
2066
|
+
if is_cancelled is not None and is_cancelled():
|
|
2067
|
+
raise
|
|
2068
|
+
log_exception(
|
|
2069
|
+
"SQL preview aggregation failed; using streaming spatial means",
|
|
2070
|
+
error,
|
|
2071
|
+
__name__,
|
|
2072
|
+
)
|
|
2073
|
+
return _streaming_spatial_mean_preview_grid(
|
|
2074
|
+
cursor,
|
|
2075
|
+
table_name,
|
|
2076
|
+
parameter,
|
|
2077
|
+
x_axis=axes[1],
|
|
2078
|
+
y_axis=axes[0],
|
|
2079
|
+
grid_shape=shape,
|
|
2080
|
+
size=size,
|
|
2081
|
+
)
|
|
2082
|
+
|
|
2083
|
+
|
|
2084
|
+
def _preview_requires_spatial_aggregation(
|
|
2085
|
+
cursor,
|
|
2086
|
+
table_name,
|
|
2087
|
+
metadata,
|
|
2088
|
+
grid_shape,
|
|
2089
|
+
):
|
|
2090
|
+
shape = _normalise_grid_shape(grid_shape)
|
|
2091
|
+
if shape is not None and shape[0] * shape[1] > MAX_PREVIEW_GRID_CELLS:
|
|
2092
|
+
return True
|
|
2093
|
+
|
|
2094
|
+
row_limit = _preview_2d_row_limit(shape)
|
|
2095
|
+
if _metadata_result_count(metadata) > row_limit:
|
|
2096
|
+
return True
|
|
2097
|
+
|
|
2098
|
+
rowid_span = _rowid_span(cursor, table_name)
|
|
2099
|
+
if rowid_span is not None:
|
|
2100
|
+
first_rowid, last_rowid = rowid_span
|
|
2101
|
+
return last_rowid - first_rowid + 1 > row_limit
|
|
2102
|
+
|
|
2103
|
+
table = _sqlite_identifier(table_name)
|
|
2104
|
+
cursor.execute(f"SELECT COUNT(*) FROM {table}")
|
|
2105
|
+
row = cursor.fetchone()
|
|
2106
|
+
return bool(row and row[0] and int(row[0]) > row_limit)
|
|
2107
|
+
|
|
2108
|
+
|
|
2109
|
+
def _spatial_mean_preview_grid(
|
|
2110
|
+
cursor,
|
|
2111
|
+
table_name,
|
|
2112
|
+
parameter,
|
|
2113
|
+
*,
|
|
2114
|
+
x_axis,
|
|
2115
|
+
y_axis,
|
|
2116
|
+
grid_shape,
|
|
2117
|
+
size,
|
|
2118
|
+
):
|
|
2119
|
+
setup = _spatial_preview_aggregation_setup(
|
|
2120
|
+
cursor,
|
|
2121
|
+
table_name,
|
|
2122
|
+
parameter,
|
|
2123
|
+
x_axis=x_axis,
|
|
2124
|
+
y_axis=y_axis,
|
|
2125
|
+
grid_shape=grid_shape,
|
|
2126
|
+
size=size,
|
|
2127
|
+
)
|
|
2128
|
+
if setup is None:
|
|
2129
|
+
return None
|
|
2130
|
+
|
|
2131
|
+
(
|
|
2132
|
+
table,
|
|
2133
|
+
x_column,
|
|
2134
|
+
y_column,
|
|
2135
|
+
z_column,
|
|
2136
|
+
where_sql,
|
|
2137
|
+
full_shape,
|
|
2138
|
+
aggregate_shape,
|
|
2139
|
+
x_lower_edge,
|
|
2140
|
+
x_scale,
|
|
2141
|
+
y_lower_edge,
|
|
2142
|
+
y_scale,
|
|
2143
|
+
) = setup
|
|
2144
|
+
target_rows, target_columns = aggregate_shape
|
|
2145
|
+
x_bin_sql = f"MIN(CAST(({x_column} - ?) * ? AS INTEGER), ?)"
|
|
2146
|
+
y_bin_sql = f"MIN(CAST(({y_column} - ?) * ? AS INTEGER), ?)"
|
|
2147
|
+
cursor.execute(
|
|
2148
|
+
(
|
|
2149
|
+
"SELECT x_bin, y_bin, AVG(z_value) FROM ("
|
|
2150
|
+
f"SELECT {x_bin_sql} AS x_bin, {y_bin_sql} AS y_bin, "
|
|
2151
|
+
f"{z_column} AS z_value FROM {table} WHERE {where_sql}"
|
|
2152
|
+
") GROUP BY x_bin, y_bin ORDER BY y_bin, x_bin"
|
|
2153
|
+
),
|
|
2154
|
+
(
|
|
2155
|
+
x_lower_edge,
|
|
2156
|
+
x_scale,
|
|
2157
|
+
target_columns - 1,
|
|
2158
|
+
y_lower_edge,
|
|
2159
|
+
y_scale,
|
|
2160
|
+
target_rows - 1,
|
|
2161
|
+
),
|
|
2162
|
+
)
|
|
2163
|
+
|
|
2164
|
+
grid = np.full(aggregate_shape, np.nan, dtype=float)
|
|
2165
|
+
for x_index, y_index, mean_value in cursor:
|
|
2166
|
+
if mean_value is None:
|
|
2167
|
+
continue
|
|
2168
|
+
try:
|
|
2169
|
+
mean_value = float(mean_value)
|
|
2170
|
+
except (TypeError, ValueError):
|
|
2171
|
+
continue
|
|
2172
|
+
if not np.isfinite(mean_value):
|
|
2173
|
+
continue
|
|
2174
|
+
grid[int(y_index), int(x_index)] = mean_value
|
|
2175
|
+
|
|
2176
|
+
if not np.any(np.isfinite(grid)):
|
|
2177
|
+
return None
|
|
2178
|
+
|
|
2179
|
+
return _pad_spatial_preview_grid(grid, full_shape)
|
|
2180
|
+
|
|
2181
|
+
|
|
2182
|
+
def _streaming_spatial_mean_preview_grid(
|
|
2183
|
+
cursor,
|
|
2184
|
+
table_name,
|
|
2185
|
+
parameter,
|
|
2186
|
+
*,
|
|
2187
|
+
x_axis,
|
|
2188
|
+
y_axis,
|
|
2189
|
+
grid_shape,
|
|
2190
|
+
size,
|
|
2191
|
+
):
|
|
2192
|
+
setup = _spatial_preview_aggregation_setup(
|
|
2193
|
+
cursor,
|
|
2194
|
+
table_name,
|
|
2195
|
+
parameter,
|
|
2196
|
+
x_axis=x_axis,
|
|
2197
|
+
y_axis=y_axis,
|
|
2198
|
+
grid_shape=grid_shape,
|
|
2199
|
+
size=size,
|
|
2200
|
+
)
|
|
2201
|
+
if setup is None:
|
|
2202
|
+
return None
|
|
2203
|
+
|
|
2204
|
+
(
|
|
2205
|
+
table,
|
|
2206
|
+
x_column,
|
|
2207
|
+
y_column,
|
|
2208
|
+
z_column,
|
|
2209
|
+
where_sql,
|
|
2210
|
+
full_shape,
|
|
2211
|
+
aggregate_shape,
|
|
2212
|
+
x_lower_edge,
|
|
2213
|
+
x_scale,
|
|
2214
|
+
y_lower_edge,
|
|
2215
|
+
y_scale,
|
|
2216
|
+
) = setup
|
|
2217
|
+
target_rows, target_columns = aggregate_shape
|
|
2218
|
+
grid_sum = np.zeros(aggregate_shape, dtype=float)
|
|
2219
|
+
grid_count = np.zeros(aggregate_shape, dtype=np.int64)
|
|
2220
|
+
cursor.execute(
|
|
2221
|
+
f"SELECT {x_column}, {y_column}, {z_column} FROM {table} "
|
|
2222
|
+
f"WHERE {where_sql} ORDER BY {y_column}, {x_column}, {z_column}"
|
|
2223
|
+
)
|
|
2224
|
+
for x_value, y_value, z_value in cursor:
|
|
2225
|
+
try:
|
|
2226
|
+
x_value = float(x_value)
|
|
2227
|
+
y_value = float(y_value)
|
|
2228
|
+
z_value = float(z_value)
|
|
2229
|
+
except (TypeError, ValueError):
|
|
2230
|
+
continue
|
|
2231
|
+
if not (
|
|
2232
|
+
np.isfinite(x_value)
|
|
2233
|
+
and np.isfinite(y_value)
|
|
2234
|
+
and np.isfinite(z_value)
|
|
2235
|
+
):
|
|
2236
|
+
continue
|
|
2237
|
+
|
|
2238
|
+
x_index = int((x_value - x_lower_edge) * x_scale)
|
|
2239
|
+
y_index = int((y_value - y_lower_edge) * y_scale)
|
|
2240
|
+
x_index = min(max(x_index, 0), target_columns - 1)
|
|
2241
|
+
y_index = min(max(y_index, 0), target_rows - 1)
|
|
2242
|
+
grid_sum[y_index, x_index] += z_value
|
|
2243
|
+
grid_count[y_index, x_index] += 1
|
|
2244
|
+
|
|
2245
|
+
grid = np.full(aggregate_shape, np.nan, dtype=float)
|
|
2246
|
+
populated = grid_count > 0
|
|
2247
|
+
grid[populated] = grid_sum[populated] / grid_count[populated]
|
|
2248
|
+
if not np.any(populated):
|
|
2249
|
+
return None
|
|
2250
|
+
|
|
2251
|
+
return _pad_spatial_preview_grid(grid, full_shape)
|
|
2252
|
+
|
|
2253
|
+
|
|
2254
|
+
def _spatial_preview_aggregation_setup(
|
|
2255
|
+
cursor,
|
|
2256
|
+
table_name,
|
|
2257
|
+
parameter,
|
|
2258
|
+
*,
|
|
2259
|
+
x_axis,
|
|
2260
|
+
y_axis,
|
|
2261
|
+
grid_shape,
|
|
2262
|
+
size,
|
|
2263
|
+
):
|
|
2264
|
+
table = _sqlite_identifier(table_name)
|
|
2265
|
+
x_column = _sqlite_identifier(x_axis)
|
|
2266
|
+
y_column = _sqlite_identifier(y_axis)
|
|
2267
|
+
z_column = _sqlite_identifier(parameter)
|
|
2268
|
+
where_sql = (
|
|
2269
|
+
f"{x_column} IS NOT NULL AND {y_column} IS NOT NULL "
|
|
2270
|
+
f"AND {z_column} IS NOT NULL"
|
|
2271
|
+
)
|
|
2272
|
+
cursor.execute(
|
|
2273
|
+
f"SELECT COUNT(*), MIN({x_column}), MAX({x_column}), "
|
|
2274
|
+
f"COUNT(DISTINCT {x_column}), MIN({y_column}), "
|
|
2275
|
+
f"MAX({y_column}), COUNT(DISTINCT {y_column}) "
|
|
2276
|
+
f"FROM {table} WHERE {where_sql}"
|
|
2277
|
+
)
|
|
2278
|
+
summary = cursor.fetchone()
|
|
2279
|
+
if summary is None:
|
|
2280
|
+
return None
|
|
2281
|
+
|
|
2282
|
+
(
|
|
2283
|
+
source_rows,
|
|
2284
|
+
x_min,
|
|
2285
|
+
x_max,
|
|
2286
|
+
x_count,
|
|
2287
|
+
y_min,
|
|
2288
|
+
y_max,
|
|
2289
|
+
y_count,
|
|
2290
|
+
) = summary
|
|
2291
|
+
if (
|
|
2292
|
+
not source_rows
|
|
2293
|
+
or x_min is None
|
|
2294
|
+
or x_max is None
|
|
2295
|
+
or y_min is None
|
|
2296
|
+
or y_max is None
|
|
2297
|
+
or not x_count
|
|
2298
|
+
or not y_count
|
|
2299
|
+
):
|
|
2300
|
+
return None
|
|
2301
|
+
|
|
2302
|
+
full_shape, aggregate_shape = _spatial_preview_grid_shapes(
|
|
2303
|
+
grid_shape,
|
|
2304
|
+
observed_rows=int(y_count),
|
|
2305
|
+
observed_columns=int(x_count),
|
|
2306
|
+
size=size,
|
|
2307
|
+
)
|
|
2308
|
+
target_rows, target_columns = aggregate_shape
|
|
2309
|
+
x_lower_edge, x_scale = _spatial_preview_axis_bins(
|
|
2310
|
+
float(x_min),
|
|
2311
|
+
float(x_max),
|
|
2312
|
+
int(x_count),
|
|
2313
|
+
target_columns,
|
|
2314
|
+
)
|
|
2315
|
+
y_lower_edge, y_scale = _spatial_preview_axis_bins(
|
|
2316
|
+
float(y_min),
|
|
2317
|
+
float(y_max),
|
|
2318
|
+
int(y_count),
|
|
2319
|
+
target_rows,
|
|
2320
|
+
)
|
|
2321
|
+
return (
|
|
2322
|
+
table,
|
|
2323
|
+
x_column,
|
|
2324
|
+
y_column,
|
|
2325
|
+
z_column,
|
|
2326
|
+
where_sql,
|
|
2327
|
+
full_shape,
|
|
2328
|
+
aggregate_shape,
|
|
2329
|
+
x_lower_edge,
|
|
2330
|
+
x_scale,
|
|
2331
|
+
y_lower_edge,
|
|
2332
|
+
y_scale,
|
|
2333
|
+
)
|
|
2334
|
+
|
|
2335
|
+
|
|
2336
|
+
def _spatial_preview_grid_shapes(
|
|
2337
|
+
grid_shape,
|
|
2338
|
+
*,
|
|
2339
|
+
observed_rows,
|
|
2340
|
+
observed_columns,
|
|
2341
|
+
size,
|
|
2342
|
+
):
|
|
2343
|
+
max_cells = min(MAX_PREVIEW_ROWS, max(1, int(size) * int(size)))
|
|
2344
|
+
observed_shape = (int(observed_rows), int(observed_columns))
|
|
2345
|
+
shape = _normalise_grid_shape(grid_shape)
|
|
2346
|
+
if shape is None:
|
|
2347
|
+
target_shape = _preview_bin_shape(
|
|
2348
|
+
observed_shape,
|
|
2349
|
+
size,
|
|
2350
|
+
max_cells=max_cells,
|
|
2351
|
+
)
|
|
2352
|
+
return target_shape, target_shape
|
|
2353
|
+
|
|
2354
|
+
full_shape = _preview_bin_shape(shape, size, max_cells=max_cells)
|
|
2355
|
+
aggregate_shape = (
|
|
2356
|
+
_covered_preview_bin_count(observed_rows, shape[0], full_shape[0]),
|
|
2357
|
+
_covered_preview_bin_count(observed_columns, shape[1], full_shape[1]),
|
|
2358
|
+
)
|
|
2359
|
+
return full_shape, aggregate_shape
|
|
2360
|
+
|
|
2361
|
+
|
|
2362
|
+
def _covered_preview_bin_count(observed_count, planned_count, target_count):
|
|
2363
|
+
observed_count = max(1, int(observed_count))
|
|
2364
|
+
planned_count = max(1, int(planned_count))
|
|
2365
|
+
target_count = max(1, int(target_count))
|
|
2366
|
+
covered = (
|
|
2367
|
+
observed_count * target_count + planned_count - 1
|
|
2368
|
+
) // planned_count
|
|
2369
|
+
return min(target_count, max(1, covered))
|
|
2370
|
+
|
|
2371
|
+
|
|
2372
|
+
def _pad_spatial_preview_grid(grid, full_shape):
|
|
2373
|
+
if grid.shape == full_shape:
|
|
2374
|
+
return grid
|
|
2375
|
+
|
|
2376
|
+
padded = np.full(full_shape, np.nan, dtype=float)
|
|
2377
|
+
rows = min(grid.shape[0], full_shape[0])
|
|
2378
|
+
columns = min(grid.shape[1], full_shape[1])
|
|
2379
|
+
padded[:rows, :columns] = grid[:rows, :columns]
|
|
2380
|
+
return padded
|
|
2381
|
+
|
|
2382
|
+
|
|
2383
|
+
def _spatial_preview_axis_bins(lower, upper, source_count, bin_count):
|
|
2384
|
+
if not np.isfinite(lower) or not np.isfinite(upper):
|
|
2385
|
+
raise ValueError("Preview axis bounds must be finite")
|
|
2386
|
+
if source_count <= 1 or bin_count <= 1 or lower == upper:
|
|
2387
|
+
return lower, 1.0
|
|
2388
|
+
|
|
2389
|
+
source_step = (upper - lower) / (source_count - 1)
|
|
2390
|
+
lower_edge = lower - source_step / 2
|
|
2391
|
+
bin_width = (upper - lower + source_step) / bin_count
|
|
2392
|
+
return lower_edge, 1.0 / bin_width
|
|
2393
|
+
|
|
2394
|
+
|
|
2395
|
+
def _fixed_heatmap_grid(x, y, z, grid_shape, max_cells=None):
|
|
2396
|
+
shape = _normalise_grid_shape(grid_shape)
|
|
2397
|
+
if shape is None:
|
|
2398
|
+
return None
|
|
2399
|
+
|
|
2400
|
+
rows, columns = shape
|
|
2401
|
+
if max_cells is not None and rows * columns > max_cells:
|
|
2402
|
+
return None
|
|
2403
|
+
|
|
2404
|
+
grid = np.full((rows, columns), np.nan, dtype=float)
|
|
2405
|
+
x_index = _axis_value_indices(x, columns)
|
|
2406
|
+
y_index = _axis_value_indices(y, rows)
|
|
2407
|
+
placed = 0
|
|
2408
|
+
|
|
2409
|
+
if x_index is not None and y_index is not None:
|
|
2410
|
+
for x_value, y_value, z_value in zip(x, y, z, strict=False):
|
|
2411
|
+
column = x_index.get(float(x_value))
|
|
2412
|
+
row = y_index.get(float(y_value))
|
|
2413
|
+
if row is None or column is None:
|
|
2414
|
+
continue
|
|
2415
|
+
|
|
2416
|
+
grid[row, column] = z_value
|
|
2417
|
+
placed += 1
|
|
2418
|
+
|
|
2419
|
+
if placed:
|
|
2420
|
+
return grid
|
|
2421
|
+
|
|
2422
|
+
flat_grid = grid.ravel()
|
|
2423
|
+
point_count = min(flat_grid.size, z.size)
|
|
2424
|
+
flat_grid[:point_count] = z[:point_count]
|
|
2425
|
+
return grid
|
|
2426
|
+
|
|
2427
|
+
|
|
2428
|
+
def _unique_axis_heatmap_grid(x, y, z, max_cells):
|
|
2429
|
+
unique_x = np.unique(x[np.isfinite(x)])
|
|
2430
|
+
unique_y = np.unique(y[np.isfinite(y)])
|
|
2431
|
+
if unique_x.size == 0 or unique_y.size == 0:
|
|
2432
|
+
return None
|
|
2433
|
+
|
|
2434
|
+
if unique_x.size * unique_y.size > max_cells:
|
|
2435
|
+
return None
|
|
2436
|
+
|
|
2437
|
+
x_index = {
|
|
2438
|
+
float(value): index
|
|
2439
|
+
for index, value in enumerate(unique_x)
|
|
2440
|
+
}
|
|
2441
|
+
y_index = {
|
|
2442
|
+
float(value): index
|
|
2443
|
+
for index, value in enumerate(unique_y)
|
|
2444
|
+
}
|
|
2445
|
+
grid = np.full((unique_y.size, unique_x.size), np.nan, dtype=float)
|
|
2446
|
+
|
|
2447
|
+
for x_value, y_value, z_value in zip(x, y, z, strict=False):
|
|
2448
|
+
row = y_index.get(float(y_value))
|
|
2449
|
+
column = x_index.get(float(x_value))
|
|
2450
|
+
if row is None or column is None:
|
|
2451
|
+
continue
|
|
2452
|
+
grid[row, column] = z_value
|
|
2453
|
+
|
|
2454
|
+
return grid
|
|
2455
|
+
|
|
2456
|
+
|
|
2457
|
+
def _binned_heatmap_grid(x, y, z, size=PREVIEW_SIZE, grid_shape=None):
|
|
2458
|
+
rows, columns = _preview_bin_shape(
|
|
2459
|
+
grid_shape,
|
|
2460
|
+
size,
|
|
2461
|
+
max_cells=max(1, z.size // PREVIEW_SAMPLES_PER_CELL),
|
|
2462
|
+
)
|
|
2463
|
+
grid_sum = np.zeros((rows, columns), dtype=float)
|
|
2464
|
+
grid_count = np.zeros((rows, columns), dtype=float)
|
|
2465
|
+
|
|
2466
|
+
x_bins = _scaled_axis_indices(x, columns)
|
|
2467
|
+
y_bins = _scaled_axis_indices(y, rows)
|
|
2468
|
+
for row, column, value in zip(y_bins, x_bins, z, strict=False):
|
|
2469
|
+
grid_sum[row, column] += value
|
|
2470
|
+
grid_count[row, column] += 1
|
|
2471
|
+
|
|
2472
|
+
grid = np.full((rows, columns), np.nan, dtype=float)
|
|
2473
|
+
populated = grid_count > 0
|
|
2474
|
+
grid[populated] = grid_sum[populated] / grid_count[populated]
|
|
2475
|
+
return _fill_empty_heatmap_bins(grid)
|
|
2476
|
+
|
|
2477
|
+
|
|
2478
|
+
def _fill_empty_heatmap_bins(grid):
|
|
2479
|
+
if grid.size == 0 or np.all(np.isfinite(grid)):
|
|
2480
|
+
return grid
|
|
2481
|
+
if not np.any(np.isfinite(grid)):
|
|
2482
|
+
return grid
|
|
2483
|
+
|
|
2484
|
+
filled = np.array(grid, dtype=float, copy=True)
|
|
2485
|
+
row_positions = np.arange(filled.shape[0], dtype=float)
|
|
2486
|
+
column_positions = np.arange(filled.shape[1], dtype=float)
|
|
2487
|
+
|
|
2488
|
+
for column in range(filled.shape[1]):
|
|
2489
|
+
values = filled[:, column]
|
|
2490
|
+
finite = np.isfinite(values)
|
|
2491
|
+
if np.any(finite) and not np.all(finite):
|
|
2492
|
+
values[~finite] = np.interp(
|
|
2493
|
+
row_positions[~finite],
|
|
2494
|
+
row_positions[finite],
|
|
2495
|
+
values[finite],
|
|
2496
|
+
)
|
|
2497
|
+
|
|
2498
|
+
for row in range(filled.shape[0]):
|
|
2499
|
+
values = filled[row, :]
|
|
2500
|
+
finite = np.isfinite(values)
|
|
2501
|
+
if np.any(finite) and not np.all(finite):
|
|
2502
|
+
values[~finite] = np.interp(
|
|
2503
|
+
column_positions[~finite],
|
|
2504
|
+
column_positions[finite],
|
|
2505
|
+
values[finite],
|
|
2506
|
+
)
|
|
2507
|
+
|
|
2508
|
+
return filled
|
|
2509
|
+
|
|
2510
|
+
|
|
2511
|
+
def _prepare_heatmap_display_grid(grid, size):
|
|
2512
|
+
grid = np.asarray(grid, dtype=float)
|
|
2513
|
+
if grid.size == 0:
|
|
2514
|
+
return grid
|
|
2515
|
+
|
|
2516
|
+
if _finite_fraction(grid) >= PREVIEW_FILL_EMPTY_MIN_COVERAGE:
|
|
2517
|
+
grid = _fill_empty_heatmap_bins(grid)
|
|
2518
|
+
|
|
2519
|
+
rows, columns = grid.shape
|
|
2520
|
+
target_rows = min(max(1, int(size)), rows)
|
|
2521
|
+
target_columns = min(max(1, int(size)), columns)
|
|
2522
|
+
|
|
2523
|
+
if rows > target_rows:
|
|
2524
|
+
grid = _downsample_heatmap_axis(grid, target_rows, axis=0)
|
|
2525
|
+
if columns > target_columns:
|
|
2526
|
+
grid = _downsample_heatmap_axis(grid, target_columns, axis=1)
|
|
2527
|
+
|
|
2528
|
+
return grid
|
|
2529
|
+
|
|
2530
|
+
|
|
2531
|
+
def _downsample_heatmap_axis(grid, target_count, axis):
|
|
2532
|
+
grid = np.asarray(grid, dtype=float)
|
|
2533
|
+
axis_length = grid.shape[axis]
|
|
2534
|
+
target_count = max(1, min(int(target_count), axis_length))
|
|
2535
|
+
if target_count == axis_length:
|
|
2536
|
+
return grid
|
|
2537
|
+
|
|
2538
|
+
parts = np.array_split(np.arange(axis_length), target_count)
|
|
2539
|
+
if axis == 0:
|
|
2540
|
+
out = np.full((target_count, grid.shape[1]), np.nan, dtype=float)
|
|
2541
|
+
for index, part in enumerate(parts):
|
|
2542
|
+
out[index, :] = _nanmean_no_warning(grid[part, :], axis=0)
|
|
2543
|
+
else:
|
|
2544
|
+
out = np.full((grid.shape[0], target_count), np.nan, dtype=float)
|
|
2545
|
+
for index, part in enumerate(parts):
|
|
2546
|
+
out[:, index] = _nanmean_no_warning(grid[:, part], axis=1)
|
|
2547
|
+
|
|
2548
|
+
return out
|
|
2549
|
+
|
|
2550
|
+
|
|
2551
|
+
def _nanmean_no_warning(values, axis):
|
|
2552
|
+
values = np.asarray(values, dtype=float)
|
|
2553
|
+
finite = np.isfinite(values)
|
|
2554
|
+
count = np.sum(finite, axis=axis)
|
|
2555
|
+
total = np.nansum(values, axis=axis)
|
|
2556
|
+
out = np.full(total.shape, np.nan, dtype=float)
|
|
2557
|
+
np.divide(total, count, out=out, where=count > 0)
|
|
2558
|
+
return out
|
|
2559
|
+
|
|
2560
|
+
|
|
2561
|
+
def _finite_fraction(values):
|
|
2562
|
+
values = np.asarray(values)
|
|
2563
|
+
if values.size == 0:
|
|
2564
|
+
return 0.0
|
|
2565
|
+
return float(np.count_nonzero(np.isfinite(values))) / float(values.size)
|
|
2566
|
+
|
|
2567
|
+
|
|
2568
|
+
def _preview_bin_shape(grid_shape, size, max_cells):
|
|
2569
|
+
size = max(1, int(size))
|
|
2570
|
+
max_cells = max(1, min(int(max_cells), size * size))
|
|
2571
|
+
shape = _normalise_grid_shape(grid_shape)
|
|
2572
|
+
if shape is None:
|
|
2573
|
+
rows = size
|
|
2574
|
+
columns = size
|
|
2575
|
+
else:
|
|
2576
|
+
rows, columns = shape
|
|
2577
|
+
|
|
2578
|
+
rows = max(1, min(int(rows), size))
|
|
2579
|
+
columns = max(1, min(int(columns), size))
|
|
2580
|
+
if rows * columns > max_cells:
|
|
2581
|
+
scale = np.sqrt(max_cells / (rows * columns))
|
|
2582
|
+
rows = max(1, int(rows * scale))
|
|
2583
|
+
columns = max(1, int(columns * scale))
|
|
2584
|
+
|
|
2585
|
+
while rows * columns > max_cells:
|
|
2586
|
+
if rows >= columns and rows > 1:
|
|
2587
|
+
rows -= 1
|
|
2588
|
+
elif columns > 1:
|
|
2589
|
+
columns -= 1
|
|
2590
|
+
else:
|
|
2591
|
+
break
|
|
2592
|
+
|
|
2593
|
+
return rows, columns
|
|
2594
|
+
|
|
2595
|
+
|
|
2596
|
+
def _scaled_axis_indices(values, size):
|
|
2597
|
+
data_range = _finite_range(values)
|
|
2598
|
+
low, high = data_range
|
|
2599
|
+
if high == low:
|
|
2600
|
+
return np.zeros(values.shape, dtype=int)
|
|
2601
|
+
|
|
2602
|
+
scaled = (values - low) / (high - low)
|
|
2603
|
+
indices = np.floor(scaled * size).astype(int)
|
|
2604
|
+
return np.clip(indices, 0, size - 1)
|
|
2605
|
+
|
|
2606
|
+
|
|
2607
|
+
def _axis_value_indices(values, size):
|
|
2608
|
+
unique_values = np.unique(values[np.isfinite(values)])
|
|
2609
|
+
if unique_values.size == 0 or unique_values.size > size:
|
|
2610
|
+
return None
|
|
2611
|
+
|
|
2612
|
+
return {
|
|
2613
|
+
float(value): index
|
|
2614
|
+
for index, value in enumerate(unique_values)
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
|
|
2618
|
+
def _normalise_grid_shape(grid_shape):
|
|
2619
|
+
if grid_shape is None:
|
|
2620
|
+
return None
|
|
2621
|
+
|
|
2622
|
+
try:
|
|
2623
|
+
if len(grid_shape) < 2:
|
|
2624
|
+
return None
|
|
2625
|
+
except TypeError:
|
|
2626
|
+
return None
|
|
2627
|
+
|
|
2628
|
+
try:
|
|
2629
|
+
rows = int(grid_shape[0])
|
|
2630
|
+
columns = int(grid_shape[1])
|
|
2631
|
+
except (TypeError, ValueError):
|
|
2632
|
+
return None
|
|
2633
|
+
|
|
2634
|
+
if rows <= 0 or columns <= 0:
|
|
2635
|
+
return None
|
|
2636
|
+
return rows, columns
|
|
2637
|
+
|
|
2638
|
+
|
|
2639
|
+
def _viridis_rgb(values):
|
|
2640
|
+
low = float(np.nanmin(values))
|
|
2641
|
+
high = float(np.nanmax(values))
|
|
2642
|
+
if high == low:
|
|
2643
|
+
scaled = np.full(values.shape, 0.5, dtype=np.float64)
|
|
2644
|
+
else:
|
|
2645
|
+
span = high - low
|
|
2646
|
+
if np.isfinite(span):
|
|
2647
|
+
scaled = (values - low) / span
|
|
2648
|
+
else:
|
|
2649
|
+
# Preserve finite measured cells when opposite-sign endpoints
|
|
2650
|
+
# have a span too large for direct subtraction.
|
|
2651
|
+
magnitude = max(abs(low), abs(high))
|
|
2652
|
+
scaled_low, scaled_high = low / magnitude, high / magnitude
|
|
2653
|
+
scaled = (values / magnitude - scaled_low) / (scaled_high - scaled_low)
|
|
2654
|
+
|
|
2655
|
+
nan_values = ~np.isfinite(scaled)
|
|
2656
|
+
scaled = np.nan_to_num(scaled, nan=0.0)
|
|
2657
|
+
positions = scaled * (len(VIRIDIS_STOPS) - 1)
|
|
2658
|
+
lower = np.floor(positions).astype(int)
|
|
2659
|
+
upper = np.clip(lower + 1, 0, len(VIRIDIS_STOPS) - 1)
|
|
2660
|
+
fraction = (positions - lower)[..., None]
|
|
2661
|
+
|
|
2662
|
+
rgb = VIRIDIS_STOPS[lower] * (1 - fraction) + VIRIDIS_STOPS[upper] * fraction
|
|
2663
|
+
rgb = np.clip(rgb, 0, 255).astype(np.uint8)
|
|
2664
|
+
rgb[nan_values] = np.array([230, 230, 230], dtype=np.uint8)
|
|
2665
|
+
return rgb
|
|
2666
|
+
|
|
2667
|
+
|
|
2668
|
+
def _preview_grid_shape(metadata, parameter):
|
|
2669
|
+
run_description = _json_dict(metadata.get("run_description"))
|
|
2670
|
+
shapes = run_description.get("shapes")
|
|
2671
|
+
if isinstance(shapes, dict):
|
|
2672
|
+
shape = shapes.get(parameter)
|
|
2673
|
+
normalised = _normalise_grid_shape(shape)
|
|
2674
|
+
if normalised is not None:
|
|
2675
|
+
return normalised
|
|
2676
|
+
|
|
2677
|
+
for key in ("setpoint_shape", "point_shape"):
|
|
2678
|
+
normalised = _normalise_grid_shape(metadata.get(key))
|
|
2679
|
+
if normalised is not None:
|
|
2680
|
+
return normalised
|
|
2681
|
+
|
|
2682
|
+
return None
|
|
2683
|
+
|
|
2684
|
+
|
|
2685
|
+
def _select_arrays(
|
|
2686
|
+
cursor,
|
|
2687
|
+
table_name,
|
|
2688
|
+
columns,
|
|
2689
|
+
metadata,
|
|
2690
|
+
max_rows=None,
|
|
2691
|
+
sampling="stride",
|
|
2692
|
+
eligible_columns=None,
|
|
2693
|
+
):
|
|
2694
|
+
max_rows = int(max_rows or MAX_PREVIEW_ROWS)
|
|
2695
|
+
count = _metadata_result_count(metadata)
|
|
2696
|
+
|
|
2697
|
+
selected_columns = ", ".join(_sqlite_identifier(column) for column in columns)
|
|
2698
|
+
table = _sqlite_identifier(table_name)
|
|
2699
|
+
if eligible_columns:
|
|
2700
|
+
rows = _select_eligible_rows(
|
|
2701
|
+
cursor,
|
|
2702
|
+
table,
|
|
2703
|
+
selected_columns,
|
|
2704
|
+
eligible_columns,
|
|
2705
|
+
max_rows,
|
|
2706
|
+
)
|
|
2707
|
+
return _rows_to_float_arrays(rows, len(columns))
|
|
2708
|
+
|
|
2709
|
+
rowid_span = _rowid_span(cursor, table_name)
|
|
2710
|
+
if rowid_span is None:
|
|
2711
|
+
cursor.execute(
|
|
2712
|
+
f"SELECT {selected_columns} FROM {table} LIMIT ?",
|
|
2713
|
+
(max_rows, )
|
|
2714
|
+
)
|
|
2715
|
+
else:
|
|
2716
|
+
first_rowid, last_rowid = rowid_span
|
|
2717
|
+
span = max(0, last_rowid - first_rowid + 1)
|
|
2718
|
+
if count and count <= max_rows:
|
|
2719
|
+
cursor.execute(f"SELECT {selected_columns} FROM {table} ORDER BY rowid")
|
|
2720
|
+
elif span and span <= max_rows:
|
|
2721
|
+
cursor.execute(f"SELECT {selected_columns} FROM {table} ORDER BY rowid")
|
|
2722
|
+
elif sampling == "stratified":
|
|
2723
|
+
rows = _select_stratified_rows(
|
|
2724
|
+
cursor,
|
|
2725
|
+
table,
|
|
2726
|
+
selected_columns,
|
|
2727
|
+
first_rowid,
|
|
2728
|
+
last_rowid,
|
|
2729
|
+
max_rows,
|
|
2730
|
+
)
|
|
2731
|
+
return _rows_to_float_arrays(rows, len(columns))
|
|
2732
|
+
else:
|
|
2733
|
+
step_source = count if count else span
|
|
2734
|
+
step = max(1, (step_source + max_rows - 1) // max_rows)
|
|
2735
|
+
cursor.execute(
|
|
2736
|
+
f"""
|
|
2737
|
+
WITH RECURSIVE sample(rowid) AS (
|
|
2738
|
+
VALUES(?)
|
|
2739
|
+
UNION ALL
|
|
2740
|
+
SELECT rowid + ? FROM sample WHERE rowid + ? <= ?
|
|
2741
|
+
)
|
|
2742
|
+
SELECT {selected_columns}
|
|
2743
|
+
FROM {table}
|
|
2744
|
+
WHERE rowid IN (SELECT rowid FROM sample)
|
|
2745
|
+
ORDER BY rowid
|
|
2746
|
+
LIMIT ?
|
|
2747
|
+
""",
|
|
2748
|
+
(first_rowid, step, step, last_rowid, max_rows),
|
|
2749
|
+
)
|
|
2750
|
+
|
|
2751
|
+
rows = cursor.fetchall()
|
|
2752
|
+
if not rows:
|
|
2753
|
+
return [np.array([], dtype=float) for _ in columns]
|
|
2754
|
+
|
|
2755
|
+
return _rows_to_float_arrays(rows, len(columns))
|
|
2756
|
+
|
|
2757
|
+
|
|
2758
|
+
def _select_eligible_rows(
|
|
2759
|
+
cursor,
|
|
2760
|
+
table,
|
|
2761
|
+
selected_columns,
|
|
2762
|
+
eligible_columns,
|
|
2763
|
+
max_rows,
|
|
2764
|
+
):
|
|
2765
|
+
predicate = " AND ".join(
|
|
2766
|
+
f"{_sqlite_identifier(column)} IS NOT NULL"
|
|
2767
|
+
for column in dict.fromkeys(eligible_columns)
|
|
2768
|
+
)
|
|
2769
|
+
cursor.execute(f"SELECT COUNT(*) FROM {table} WHERE {predicate}")
|
|
2770
|
+
eligible_count = int(cursor.fetchone()[0] or 0)
|
|
2771
|
+
if eligible_count <= max_rows:
|
|
2772
|
+
cursor.execute(
|
|
2773
|
+
f"SELECT {selected_columns} FROM {table} "
|
|
2774
|
+
f"WHERE {predicate} ORDER BY rowid"
|
|
2775
|
+
)
|
|
2776
|
+
return cursor.fetchall()
|
|
2777
|
+
|
|
2778
|
+
step = max(1, (eligible_count + max_rows - 1) // max_rows)
|
|
2779
|
+
cursor.execute(
|
|
2780
|
+
f"""
|
|
2781
|
+
WITH eligible AS (
|
|
2782
|
+
SELECT {selected_columns},
|
|
2783
|
+
ROW_NUMBER() OVER (ORDER BY rowid) AS qplot_preview_row_number
|
|
2784
|
+
FROM {table}
|
|
2785
|
+
WHERE {predicate}
|
|
2786
|
+
)
|
|
2787
|
+
SELECT {selected_columns}
|
|
2788
|
+
FROM eligible
|
|
2789
|
+
WHERE (qplot_preview_row_number - 1) % ? = 0
|
|
2790
|
+
ORDER BY qplot_preview_row_number
|
|
2791
|
+
LIMIT ?
|
|
2792
|
+
""",
|
|
2793
|
+
(step, max_rows),
|
|
2794
|
+
)
|
|
2795
|
+
return cursor.fetchall()
|
|
2796
|
+
|
|
2797
|
+
|
|
2798
|
+
def _select_stratified_rows(
|
|
2799
|
+
cursor,
|
|
2800
|
+
table,
|
|
2801
|
+
selected_columns,
|
|
2802
|
+
first_rowid,
|
|
2803
|
+
last_rowid,
|
|
2804
|
+
max_rows,
|
|
2805
|
+
):
|
|
2806
|
+
rowids = _sample_rowids(first_rowid, last_rowid, max_rows)
|
|
2807
|
+
rows = []
|
|
2808
|
+
for start in range(0, len(rowids), PREVIEW_ROWID_CHUNK):
|
|
2809
|
+
chunk = [int(value) for value in rowids[start:start + PREVIEW_ROWID_CHUNK]]
|
|
2810
|
+
placeholders = ", ".join("?" for _ in chunk)
|
|
2811
|
+
cursor.execute(
|
|
2812
|
+
(
|
|
2813
|
+
f"SELECT {selected_columns} FROM {table} "
|
|
2814
|
+
f"WHERE rowid IN ({placeholders}) ORDER BY rowid"
|
|
2815
|
+
),
|
|
2816
|
+
chunk,
|
|
2817
|
+
)
|
|
2818
|
+
rows.extend(cursor.fetchall())
|
|
2819
|
+
|
|
2820
|
+
return rows
|
|
2821
|
+
|
|
2822
|
+
|
|
2823
|
+
def _sample_rowids(first_rowid, last_rowid, max_rows):
|
|
2824
|
+
span = max(0, last_rowid - first_rowid + 1)
|
|
2825
|
+
count = min(max_rows, span)
|
|
2826
|
+
if count <= 0:
|
|
2827
|
+
return np.array([], dtype=np.int64)
|
|
2828
|
+
|
|
2829
|
+
starts = (np.arange(count, dtype=np.int64) * span) // count
|
|
2830
|
+
ends = ((np.arange(1, count + 1, dtype=np.int64) * span) // count) - 1
|
|
2831
|
+
widths = np.maximum(ends - starts + 1, 1)
|
|
2832
|
+
jitter = (
|
|
2833
|
+
np.arange(count, dtype=np.int64) * 1_103_515_245 + 12_345
|
|
2834
|
+
) % widths
|
|
2835
|
+
rowids = first_rowid + starts + jitter
|
|
2836
|
+
rowids[0] = first_rowid
|
|
2837
|
+
rowids[-1] = last_rowid
|
|
2838
|
+
return np.unique(rowids)
|
|
2839
|
+
|
|
2840
|
+
|
|
2841
|
+
def _rowid_span(cursor, table_name):
|
|
2842
|
+
table = _sqlite_identifier(table_name)
|
|
2843
|
+
try:
|
|
2844
|
+
cursor.execute(f"SELECT MIN(rowid), MAX(rowid) FROM {table}")
|
|
2845
|
+
first_rowid, last_rowid = cursor.fetchone()
|
|
2846
|
+
except Exception:
|
|
2847
|
+
return None
|
|
2848
|
+
|
|
2849
|
+
if first_rowid is None or last_rowid is None:
|
|
2850
|
+
return None
|
|
2851
|
+
|
|
2852
|
+
try:
|
|
2853
|
+
return int(first_rowid), int(last_rowid)
|
|
2854
|
+
except (TypeError, ValueError):
|
|
2855
|
+
return None
|
|
2856
|
+
|
|
2857
|
+
|
|
2858
|
+
def _metadata_result_count(metadata):
|
|
2859
|
+
count = metadata.get("result_count")
|
|
2860
|
+
try:
|
|
2861
|
+
return int(count)
|
|
2862
|
+
except (TypeError, ValueError):
|
|
2863
|
+
return 0
|
|
2864
|
+
|
|
2865
|
+
|
|
2866
|
+
def _rows_to_float_arrays(rows, column_count):
|
|
2867
|
+
columns = []
|
|
2868
|
+
for index in range(column_count):
|
|
2869
|
+
values = []
|
|
2870
|
+
for row in rows:
|
|
2871
|
+
value = row[index]
|
|
2872
|
+
try:
|
|
2873
|
+
values.append(float(value))
|
|
2874
|
+
except (TypeError, ValueError):
|
|
2875
|
+
values.append(np.nan)
|
|
2876
|
+
columns.append(np.asarray(values, dtype=float))
|
|
2877
|
+
return columns
|
|
2878
|
+
|
|
2879
|
+
|
|
2880
|
+
def preview_placeholder_dimensions(metadata):
|
|
2881
|
+
"""Use known per-measurement dependencies, never guess from global axes."""
|
|
2882
|
+
dimensions = metadata.get("preview_dimensions")
|
|
2883
|
+
if isinstance(dimensions, (list, tuple)):
|
|
2884
|
+
return [value if type(value) is int and value > 0 else None for value in dimensions]
|
|
2885
|
+
if dimensions is None:
|
|
2886
|
+
dimensions = {}
|
|
2887
|
+
raw_description = metadata.get("run_description")
|
|
2888
|
+
description = (
|
|
2889
|
+
raw_description if isinstance(raw_description, dict)
|
|
2890
|
+
else _json_dict(raw_description)
|
|
2891
|
+
)
|
|
2892
|
+
interdependencies = description.get("interdependencies_", {})
|
|
2893
|
+
dependencies = (
|
|
2894
|
+
interdependencies.get("dependencies", {})
|
|
2895
|
+
if isinstance(interdependencies, dict) else {}
|
|
2896
|
+
)
|
|
2897
|
+
if not dependencies:
|
|
2898
|
+
dependencies = _legacy_dependencies(description)
|
|
2899
|
+
if isinstance(dependencies, dict):
|
|
2900
|
+
dimensions = {
|
|
2901
|
+
name: len(axes) for name, axes in dependencies.items()
|
|
2902
|
+
if isinstance(axes, (list, tuple))
|
|
2903
|
+
}
|
|
2904
|
+
return [dimensions.get(name) for name in metadata.get("measure_parameters") or []]
|
|
2905
|
+
|
|
2906
|
+
|
|
2907
|
+
def _dependencies_from_metadata(metadata):
|
|
2908
|
+
run_description = _json_dict(metadata.get("run_description"))
|
|
2909
|
+
dependencies = (
|
|
2910
|
+
run_description
|
|
2911
|
+
.get("interdependencies_", {})
|
|
2912
|
+
.get("dependencies", {})
|
|
2913
|
+
)
|
|
2914
|
+
if not dependencies:
|
|
2915
|
+
dependencies = _legacy_dependencies(run_description)
|
|
2916
|
+
|
|
2917
|
+
normalised = {}
|
|
2918
|
+
for parameter, axes in dependencies.items():
|
|
2919
|
+
parameter = _parameter_name(parameter)
|
|
2920
|
+
if axes is None:
|
|
2921
|
+
axes = []
|
|
2922
|
+
elif isinstance(axes, (str, dict)):
|
|
2923
|
+
axes = [axes]
|
|
2924
|
+
axes = [_parameter_name(axis) for axis in axes]
|
|
2925
|
+
axes = [axis for axis in axes if axis]
|
|
2926
|
+
if parameter and axes:
|
|
2927
|
+
normalised[parameter] = axes
|
|
2928
|
+
|
|
2929
|
+
if normalised:
|
|
2930
|
+
return normalised
|
|
2931
|
+
|
|
2932
|
+
measure_parameters = metadata.get("measure_parameters") or []
|
|
2933
|
+
sweep_parameters = metadata.get("sweep_parameters") or []
|
|
2934
|
+
return {
|
|
2935
|
+
parameter: list(sweep_parameters)
|
|
2936
|
+
for parameter in measure_parameters
|
|
2937
|
+
if sweep_parameters
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
|
|
2941
|
+
def _legacy_dependencies(run_description):
|
|
2942
|
+
out = {}
|
|
2943
|
+
paramspecs = run_description.get("interdependencies", {}).get("paramspecs", [])
|
|
2944
|
+
for paramspec in paramspecs:
|
|
2945
|
+
if not isinstance(paramspec, dict):
|
|
2946
|
+
continue
|
|
2947
|
+
name = paramspec.get("name")
|
|
2948
|
+
depends_on = paramspec.get("depends_on") or []
|
|
2949
|
+
if name and depends_on:
|
|
2950
|
+
out[name] = depends_on
|
|
2951
|
+
return out
|
|
2952
|
+
|
|
2953
|
+
|
|
2954
|
+
def _parameter_name(value):
|
|
2955
|
+
if isinstance(value, dict):
|
|
2956
|
+
return value.get("name", "")
|
|
2957
|
+
return str(value)
|
|
2958
|
+
|
|
2959
|
+
|
|
2960
|
+
def _json_dict(value):
|
|
2961
|
+
if not value:
|
|
2962
|
+
return {}
|
|
2963
|
+
|
|
2964
|
+
try:
|
|
2965
|
+
decoded = json.loads(value)
|
|
2966
|
+
except (TypeError, json.JSONDecodeError):
|
|
2967
|
+
return {}
|
|
2968
|
+
|
|
2969
|
+
return decoded if isinstance(decoded, dict) else {}
|
|
2970
|
+
|
|
2971
|
+
|
|
2972
|
+
def _signature_value(value):
|
|
2973
|
+
"""Freeze nested metadata so in-place mutations cannot hide changes."""
|
|
2974
|
+
|
|
2975
|
+
if isinstance(value, dict):
|
|
2976
|
+
return tuple(
|
|
2977
|
+
(str(key), _signature_value(item))
|
|
2978
|
+
for key, item in sorted(value.items(), key=lambda pair: str(pair[0]))
|
|
2979
|
+
)
|
|
2980
|
+
if isinstance(value, (list, tuple)):
|
|
2981
|
+
return tuple(_signature_value(item) for item in value)
|
|
2982
|
+
return value
|
|
2983
|
+
|
|
2984
|
+
|
|
2985
|
+
def _table_columns(cursor, table_name):
|
|
2986
|
+
cursor.execute(f"PRAGMA table_info({_sqlite_identifier(table_name)})")
|
|
2987
|
+
return {row[1] for row in cursor.fetchall()}
|
|
2988
|
+
|
|
2989
|
+
|
|
2990
|
+
def _sqlite_identifier(name):
|
|
2991
|
+
return f'"{str(name).replace(chr(34), chr(34) * 2)}"'
|
|
2992
|
+
|
|
2993
|
+
|
|
2994
|
+
def _finite_range(values):
|
|
2995
|
+
low = float(np.nanmin(values))
|
|
2996
|
+
high = float(np.nanmax(values))
|
|
2997
|
+
return low, high
|