qplotter 1.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. qplot/__init__.py +46 -0
  2. qplot/__main__.py +232 -0
  3. qplot/_auxiliary_paths.py +67 -0
  4. qplot/_build_info.json +1 -0
  5. qplot/_metadata.py +75 -0
  6. qplot/_repair.py +14 -0
  7. qplot/_shutdown_supervisor.py +4049 -0
  8. qplot/_version.py +12 -0
  9. qplot/_windows_shutdown_job.py +1166 -0
  10. qplot/configuration/__init__.py +3 -0
  11. qplot/configuration/config.py +578 -0
  12. qplot/configuration/config_schema.json +357 -0
  13. qplot/configuration/fonts.py +3 -0
  14. qplot/configuration/scripts.py +255 -0
  15. qplot/configuration/themes/__init__.py +13 -0
  16. qplot/configuration/themes/_base.py +592 -0
  17. qplot/configuration/themes/blank.py +17 -0
  18. qplot/configuration/themes/dark.py +54 -0
  19. qplot/configuration/themes/light.py +54 -0
  20. qplot/datahandling/LoadFromDB.py +224 -0
  21. qplot/datahandling/__init__.py +47 -0
  22. qplot/datahandling/_trusted_live_helper.py +812 -0
  23. qplot/datahandling/_trusted_live_protocol.py +1745 -0
  24. qplot/datahandling/database.py +2340 -0
  25. qplot/datahandling/dimensions.py +43 -0
  26. qplot/datahandling/file_identity.py +461 -0
  27. qplot/datahandling/parameter_data.py +149 -0
  28. qplot/datahandling/plot_progress.py +17 -0
  29. qplot/datahandling/qcodes_cache.py +277 -0
  30. qplot/datahandling/qcodes_compat.py +14 -0
  31. qplot/datahandling/readSQL.py +2442 -0
  32. qplot/datahandling/readonly.py +2566 -0
  33. qplot/datahandling/source_activity.py +21 -0
  34. qplot/datahandling/trusted_array_preview.py +148 -0
  35. qplot/datahandling/trusted_derived_cache.py +1605 -0
  36. qplot/datahandling/trusted_derived_rendering.py +876 -0
  37. qplot/datahandling/trusted_heatmap.py +246 -0
  38. qplot/datahandling/trusted_live.py +2808 -0
  39. qplot/datahandling/trusted_live_queries.py +5907 -0
  40. qplot/datahandling/trusted_live_service.py +1919 -0
  41. qplot/datahandling/trusted_live_supervisor.py +2161 -0
  42. qplot/datahandling/trusted_plot.py +283 -0
  43. qplot/datahandling/trusted_presentation.py +1065 -0
  44. qplot/datahandling/trusted_snapshot.py +1132 -0
  45. qplot/datahandling/trusted_work_coordinator.py +2157 -0
  46. qplot/datahandling/trusted_work_scheduler.py +1204 -0
  47. qplot/diagnostics.py +241 -0
  48. qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
  49. qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
  50. qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
  51. qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
  52. qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
  53. qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
  54. qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
  55. qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
  56. qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
  57. qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
  58. qplot/testdata.py +1804 -0
  59. qplot/tools/__init__.py +13 -0
  60. qplot/tools/finite_means.py +123 -0
  61. qplot/tools/general.py +139 -0
  62. qplot/tools/heatmap_geometry.py +391 -0
  63. qplot/tools/operation_registry.py +179 -0
  64. qplot/tools/plot_tools.py +650 -0
  65. qplot/tools/sample_statistics.py +111 -0
  66. qplot/tools/worker.py +2838 -0
  67. qplot/windows/__init__.py +9 -0
  68. qplot/windows/_colorbar.py +422 -0
  69. qplot/windows/_commands.py +523 -0
  70. qplot/windows/_config_persistence.py +123 -0
  71. qplot/windows/_database_actions.py +4933 -0
  72. qplot/windows/_dataset_handle.py +221 -0
  73. qplot/windows/_display_precision.py +48 -0
  74. qplot/windows/_dragdrop.py +70 -0
  75. qplot/windows/_export_paths.py +1167 -0
  76. qplot/windows/_help.py +210 -0
  77. qplot/windows/_mixed_appearance.py +303 -0
  78. qplot/windows/_mixed_plots.py +461 -0
  79. qplot/windows/_native_averaging.py +323 -0
  80. qplot/windows/_native_transforms.py +551 -0
  81. qplot/windows/_plot1d_snap.py +612 -0
  82. qplot/windows/_plot1d_traces.py +2236 -0
  83. qplot/windows/_plot2d_colorbar.py +1127 -0
  84. qplot/windows/_plot2d_colorbar_dialog.py +786 -0
  85. qplot/windows/_plot2d_layers.py +2291 -0
  86. qplot/windows/_plot2d_sweeps.py +712 -0
  87. qplot/windows/_plotWin.py +2082 -0
  88. qplot/windows/_plot_actions.py +2703 -0
  89. qplot/windows/_plot_annotation.py +186 -0
  90. qplot/windows/_plot_appearance.py +283 -0
  91. qplot/windows/_plot_axis_scaling.py +1521 -0
  92. qplot/windows/_plot_data_viewbox.py +94 -0
  93. qplot/windows/_plot_drop_axes.py +224 -0
  94. qplot/windows/_plot_export.py +1445 -0
  95. qplot/windows/_plot_feedback.py +112 -0
  96. qplot/windows/_plot_marquee.py +659 -0
  97. qplot/windows/_plot_refresh.py +950 -0
  98. qplot/windows/_plot_source_promotion.py +178 -0
  99. qplot/windows/_plot_state.py +237 -0
  100. qplot/windows/_plot_status.py +115 -0
  101. qplot/windows/_plot_tiling.py +35 -0
  102. qplot/windows/_plot_transform_labels.py +60 -0
  103. qplot/windows/_preferences.py +724 -0
  104. qplot/windows/_refresh_interval.py +172 -0
  105. qplot/windows/_run_controls.py +789 -0
  106. qplot/windows/_shortcuts.py +47 -0
  107. qplot/windows/_subplots/__init__.py +8 -0
  108. qplot/windows/_subplots/subplot1d.py +532 -0
  109. qplot/windows/_subplots/subplot2d.py +919 -0
  110. qplot/windows/_svg_export.py +209 -0
  111. qplot/windows/_trusted_derived_qt.py +1759 -0
  112. qplot/windows/_widgets/__init__.py +23 -0
  113. qplot/windows/_widgets/_run_formatting.py +387 -0
  114. qplot/windows/_widgets/details_tables.py +1119 -0
  115. qplot/windows/_widgets/dropbox.py +290 -0
  116. qplot/windows/_widgets/operations.py +460 -0
  117. qplot/windows/_widgets/preview.py +2997 -0
  118. qplot/windows/_widgets/run_list_items.py +488 -0
  119. qplot/windows/_widgets/toolbar.py +111 -0
  120. qplot/windows/_widgets/treeWidgets.py +2572 -0
  121. qplot/windows/_window_controls.py +414 -0
  122. qplot/windows/main.py +1632 -0
  123. qplot/windows/plot1d.py +313 -0
  124. qplot/windows/plot2d.py +1935 -0
  125. qplotter-1.6.0.dist-info/METADATA +322 -0
  126. qplotter-1.6.0.dist-info/RECORD +130 -0
  127. qplotter-1.6.0.dist-info/WHEEL +5 -0
  128. qplotter-1.6.0.dist-info/entry_points.txt +4 -0
  129. qplotter-1.6.0.dist-info/licenses/LICENSE +21 -0
  130. qplotter-1.6.0.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