qplotter 1.6.0b3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. qplot/__init__.py +46 -0
  2. qplot/__main__.py +232 -0
  3. qplot/_auxiliary_paths.py +63 -0
  4. qplot/_metadata.py +12 -0
  5. qplot/_repair.py +14 -0
  6. qplot/_shutdown_supervisor.py +4049 -0
  7. qplot/_version.py +12 -0
  8. qplot/_windows_shutdown_job.py +1166 -0
  9. qplot/configuration/__init__.py +3 -0
  10. qplot/configuration/config.py +573 -0
  11. qplot/configuration/config_schema.json +319 -0
  12. qplot/configuration/scripts.py +255 -0
  13. qplot/configuration/themes/__init__.py +13 -0
  14. qplot/configuration/themes/_base.py +579 -0
  15. qplot/configuration/themes/blank.py +17 -0
  16. qplot/configuration/themes/dark.py +54 -0
  17. qplot/configuration/themes/light.py +54 -0
  18. qplot/datahandling/LoadFromDB.py +224 -0
  19. qplot/datahandling/__init__.py +47 -0
  20. qplot/datahandling/_trusted_live_helper.py +812 -0
  21. qplot/datahandling/_trusted_live_protocol.py +1745 -0
  22. qplot/datahandling/database.py +2330 -0
  23. qplot/datahandling/dimensions.py +43 -0
  24. qplot/datahandling/file_identity.py +461 -0
  25. qplot/datahandling/parameter_data.py +149 -0
  26. qplot/datahandling/plot_progress.py +17 -0
  27. qplot/datahandling/qcodes_cache.py +277 -0
  28. qplot/datahandling/qcodes_compat.py +14 -0
  29. qplot/datahandling/readSQL.py +2442 -0
  30. qplot/datahandling/readonly.py +2561 -0
  31. qplot/datahandling/source_activity.py +21 -0
  32. qplot/datahandling/trusted_array_preview.py +127 -0
  33. qplot/datahandling/trusted_derived_cache.py +1605 -0
  34. qplot/datahandling/trusted_derived_rendering.py +868 -0
  35. qplot/datahandling/trusted_heatmap.py +241 -0
  36. qplot/datahandling/trusted_live.py +2808 -0
  37. qplot/datahandling/trusted_live_queries.py +5907 -0
  38. qplot/datahandling/trusted_live_service.py +1894 -0
  39. qplot/datahandling/trusted_live_supervisor.py +2161 -0
  40. qplot/datahandling/trusted_plot.py +283 -0
  41. qplot/datahandling/trusted_presentation.py +1065 -0
  42. qplot/datahandling/trusted_snapshot.py +1132 -0
  43. qplot/datahandling/trusted_work_coordinator.py +2157 -0
  44. qplot/datahandling/trusted_work_scheduler.py +1204 -0
  45. qplot/diagnostics.py +241 -0
  46. qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
  47. qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
  48. qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
  49. qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
  50. qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
  51. qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
  52. qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
  53. qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
  54. qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
  55. qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
  56. qplot/testdata.py +1733 -0
  57. qplot/tools/__init__.py +13 -0
  58. qplot/tools/finite_means.py +114 -0
  59. qplot/tools/general.py +134 -0
  60. qplot/tools/heatmap_geometry.py +381 -0
  61. qplot/tools/operation_registry.py +179 -0
  62. qplot/tools/plot_tools.py +625 -0
  63. qplot/tools/sample_statistics.py +56 -0
  64. qplot/tools/worker.py +2787 -0
  65. qplot/windows/__init__.py +9 -0
  66. qplot/windows/_colorbar.py +422 -0
  67. qplot/windows/_commands.py +523 -0
  68. qplot/windows/_config_persistence.py +123 -0
  69. qplot/windows/_database_actions.py +4904 -0
  70. qplot/windows/_dataset_handle.py +221 -0
  71. qplot/windows/_dragdrop.py +71 -0
  72. qplot/windows/_export_paths.py +1167 -0
  73. qplot/windows/_help.py +169 -0
  74. qplot/windows/_native_averaging.py +173 -0
  75. qplot/windows/_native_transforms.py +389 -0
  76. qplot/windows/_plot1d_snap.py +608 -0
  77. qplot/windows/_plot1d_traces.py +2183 -0
  78. qplot/windows/_plot2d_colorbar.py +1126 -0
  79. qplot/windows/_plot2d_colorbar_dialog.py +786 -0
  80. qplot/windows/_plot2d_layers.py +2197 -0
  81. qplot/windows/_plot2d_sweeps.py +712 -0
  82. qplot/windows/_plotWin.py +1993 -0
  83. qplot/windows/_plot_actions.py +2695 -0
  84. qplot/windows/_plot_appearance.py +276 -0
  85. qplot/windows/_plot_axis_scaling.py +1485 -0
  86. qplot/windows/_plot_export.py +1445 -0
  87. qplot/windows/_plot_feedback.py +110 -0
  88. qplot/windows/_plot_marquee.py +656 -0
  89. qplot/windows/_plot_refresh.py +942 -0
  90. qplot/windows/_plot_state.py +239 -0
  91. qplot/windows/_plot_transform_labels.py +60 -0
  92. qplot/windows/_preferences.py +635 -0
  93. qplot/windows/_refresh_interval.py +70 -0
  94. qplot/windows/_run_controls.py +828 -0
  95. qplot/windows/_shortcuts.py +47 -0
  96. qplot/windows/_subplots/__init__.py +8 -0
  97. qplot/windows/_subplots/subplot1d.py +531 -0
  98. qplot/windows/_subplots/subplot2d.py +918 -0
  99. qplot/windows/_svg_export.py +209 -0
  100. qplot/windows/_trusted_derived_qt.py +1759 -0
  101. qplot/windows/_widgets/__init__.py +23 -0
  102. qplot/windows/_widgets/_run_formatting.py +387 -0
  103. qplot/windows/_widgets/details_tables.py +1119 -0
  104. qplot/windows/_widgets/dropbox.py +290 -0
  105. qplot/windows/_widgets/operations.py +460 -0
  106. qplot/windows/_widgets/preview.py +2997 -0
  107. qplot/windows/_widgets/run_list_items.py +488 -0
  108. qplot/windows/_widgets/toolbar.py +111 -0
  109. qplot/windows/_widgets/treeWidgets.py +2572 -0
  110. qplot/windows/_window_controls.py +413 -0
  111. qplot/windows/main.py +1635 -0
  112. qplot/windows/plot1d.py +311 -0
  113. qplot/windows/plot2d.py +1901 -0
  114. qplotter-1.6.0b3.dist-info/METADATA +318 -0
  115. qplotter-1.6.0b3.dist-info/RECORD +119 -0
  116. qplotter-1.6.0b3.dist-info/WHEEL +5 -0
  117. qplotter-1.6.0b3.dist-info/entry_points.txt +4 -0
  118. qplotter-1.6.0b3.dist-info/licenses/LICENSE +21 -0
  119. qplotter-1.6.0b3.dist-info/top_level.txt +1 -0
qplot/tools/worker.py ADDED
@@ -0,0 +1,2787 @@
1
+ import math
2
+ import threading
3
+ from contextlib import ExitStack, closing
4
+ from copy import copy
5
+ from fractions import Fraction
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import numpy as np
9
+ from PyQt6 import QtCore
10
+
11
+ from qplot.datahandling import load_param_data_from_db, load_param_data_from_db_prep
12
+ from qplot.datahandling.dimensions import ensure_supported_plot_dimensions
13
+ from qplot.datahandling.file_identity import (
14
+ canonical_database_path,
15
+ database_file_identity,
16
+ database_sidecar_identities,
17
+ )
18
+ from qplot.datahandling.parameter_data import (
19
+ concatenate_record_samples,
20
+ integer_preserving_dtype,
21
+ numeric_isfinite,
22
+ numeric_isnan,
23
+ )
24
+ from qplot.datahandling.plot_progress import PlotProgress
25
+ from qplot.datahandling.qcodes_cache import (
26
+ cache_database_path,
27
+ cache_dataset_completed,
28
+ cache_is_live,
29
+ cache_rundescriber,
30
+ cache_table_name,
31
+ snapshot_cache_parameter_state,
32
+ )
33
+ from qplot.datahandling.readonly import (
34
+ DatabaseInstanceChangedError,
35
+ qcodes_read_only_connection,
36
+ sqlite_read_only_connection,
37
+ )
38
+ from qplot.datahandling.trusted_live import TrustedLiveSourceChangedError
39
+ from qplot.diagnostics import log_exception
40
+ from qplot.tools.operation_registry import OperationCall, OperationExecutionError
41
+
42
+ from . import data2matrix
43
+ from .finite_means import FiniteBinMeans
44
+ from .heatmap_geometry import (
45
+ bounded_grid_shape,
46
+ canonicalize_heatmap_data,
47
+ spatial_axis_bins,
48
+ )
49
+
50
+ if TYPE_CHECKING:
51
+ import qcodes
52
+
53
+ MAX_FULL_HEATMAP_POINTS = 2_000_000
54
+ MAX_SQL_HEATMAP_SOURCE_ROWS = 250_000
55
+ MAX_SQL_HEATMAP_GRID_SIDE = 800
56
+ MAX_SQL_HEATMAP_GRID_CELLS = 250_000
57
+ SQL_HEATMAP_SAMPLES_PER_CELL = 4
58
+ CANCELLATION_CHUNK_SIZE = 65_536
59
+ MAX_TRUSTED_FULL_PLOT_DECODE_BYTES = 1024 * 1024 * 1024
60
+
61
+
62
+ class PlotWorkCancelled(InterruptedError):
63
+ """Internal control flow used to unwind cancelled plot work safely."""
64
+
65
+
66
+ def _sqlite_identifier(name):
67
+ return '"' + str(name).replace('"', '""') + '"'
68
+
69
+
70
+ class _HeatmapArrayReader:
71
+ """Read numeric NPY slices from a physically read-only SQLite BLOB."""
72
+
73
+ def __init__(self, blob):
74
+ self.blob = blob
75
+ version = np.lib.format.read_magic(blob)
76
+ # QCoDeS writes NPY v3: its length field matches v2 and its numeric
77
+ # dtype headers are ASCII (v3 only changes the text encoding).
78
+ if version == (1, 0):
79
+ read_header = np.lib.format.read_array_header_1_0
80
+ elif version in ((2, 0), (3, 0)):
81
+ read_header = np.lib.format.read_array_header_2_0
82
+ else:
83
+ raise ValueError("Unsupported heatmap array format.")
84
+ self.shape, self.fortran_order, self.dtype = read_header(blob)
85
+ self.offset = blob.tell()
86
+ self.size = math.prod(self.shape)
87
+ if self.dtype.kind not in "biufc":
88
+ raise ValueError("Heatmap arrays must contain numeric values.")
89
+ if self.offset + self.size * self.dtype.itemsize != len(blob):
90
+ raise ValueError("Heatmap array payload does not match its shape.")
91
+
92
+ def read(self, start, stop, check_cancelled):
93
+ check_cancelled()
94
+ if not self.fortran_order or len(self.shape) <= 1:
95
+ self.blob.seek(self.offset + start * self.dtype.itemsize)
96
+ return np.frombuffer(
97
+ self.blob.read((stop - start) * self.dtype.itemsize),
98
+ dtype=self.dtype,
99
+ )
100
+
101
+ # Flatten every column in the same logical (C) order, even when
102
+ # signal and setpoints were saved with different memory layouts.
103
+ positions = np.ravel_multi_index(
104
+ np.unravel_index(np.arange(start, stop), self.shape),
105
+ self.shape, order="F",
106
+ )
107
+ order = np.argsort(positions)
108
+ ordered_positions = positions[order]
109
+ boundaries = np.r_[0, np.flatnonzero(np.diff(ordered_positions) != 1) + 1,
110
+ stop - start]
111
+ values = np.empty(stop - start, dtype=self.dtype)
112
+ for first, last in zip(boundaries[:-1], boundaries[1:], strict=True):
113
+ check_cancelled()
114
+ self.blob.seek(self.offset + int(ordered_positions[first]) * self.dtype.itemsize)
115
+ values[order[first:last]] = np.frombuffer(
116
+ self.blob.read(int(last - first) * self.dtype.itemsize),
117
+ dtype=self.dtype,
118
+ )
119
+ return values
120
+
121
+
122
+ class loader(QtCore.QRunnable):
123
+ """
124
+ A Worker to be placed inside a QThreadPool.
125
+ It handles fetched data from the dataset cache and performs necessary work
126
+ before rerending data
127
+
128
+ """
129
+ _trusted_wait_timer: QtCore.QTimer | None
130
+ _spatial_heatmap_axes: tuple[np.ndarray, np.ndarray]
131
+ _spatial_heatmap_indices: tuple[np.ndarray, np.ndarray]
132
+ _spatial_heatmap_source_unique_counts: tuple[int, int]
133
+ _heatmap_aggregated_source_rows: int
134
+
135
+ def __init__(self,
136
+ cache : "qcodes.dataset.data_set_cache.DataSetCacheWithDBBackend",
137
+ param : "qcodes.dataset.descriptions.param_spec.ParamSpec",
138
+ param_dict : dict,
139
+ axes : dict,
140
+ read_data : bool = True,
141
+ operations: list | None = None,
142
+ force_sql_heatmap: bool = False,
143
+ max_full_heatmap_points: int = MAX_FULL_HEATMAP_POINTS,
144
+ max_heatmap_grid_cells: int = MAX_SQL_HEATMAP_GRID_CELLS,
145
+ max_heatmap_grid_side: int = MAX_SQL_HEATMAP_GRID_SIDE,
146
+ heatmap_axis_ranges: dict | None = None,
147
+ heatmap_full_axis_ranges: dict | None = None,
148
+ database_identity=None,
149
+ deadline=None,
150
+ ):
151
+ """
152
+ Sets up worker with required data for run()
153
+
154
+ Please note that self.__init__ is run in main thread, self.run() is ran
155
+ in the worker thread.
156
+
157
+ Parameters
158
+ ----------
159
+ cache : qcodes.dataset.data_set_cache.DataSetCacheWithDBBackend
160
+ The cache for the dataset that is being refreshed.
161
+ param : qcodes.dataset.descriptions.param_spec.ParamSpec
162
+ The parameter being updated.
163
+ param_dict : dict{str: ParamSpec}
164
+ List of all parameter data inside the dataset.
165
+ axes : dict{str: str}
166
+ The selected parameter for the axes.
167
+ read_data : bool
168
+ Whether to read the database for new data or use current data.
169
+ The default is True.
170
+ operations: list
171
+ A list containing functions to perform on the refreshed data
172
+ before returning
173
+
174
+ """
175
+ super().__init__()
176
+ self.running = True
177
+ self.emitter = _emitter() # For signals
178
+ self._cancelled = threading.Event()
179
+ self._sql_connection_lock = threading.Lock()
180
+ self._publication_lock = threading.RLock()
181
+ self._sql_connection = None
182
+
183
+ # Required working data
184
+ self.cache = cache
185
+ self.table_name = cache_table_name(cache)
186
+ self.sidecar_identities = database_sidecar_identities(
187
+ cache_database_path(cache)
188
+ )
189
+ self.param = param
190
+ self.dataset_completed: bool | None = None
191
+ self.display_param = copy(param)
192
+ self.param_dict = param_dict
193
+
194
+ self.axes_dict = axes
195
+ self.read_data = read_data
196
+ self.operations = [] if operations is None else operations
197
+ self.force_sql_heatmap = force_sql_heatmap
198
+ self.max_full_heatmap_points = max(1, int(max_full_heatmap_points))
199
+ self.max_heatmap_grid_cells = max(1, int(max_heatmap_grid_cells))
200
+ self.max_heatmap_grid_side = max(1, int(max_heatmap_grid_side))
201
+ self.heatmap_axis_ranges = heatmap_axis_ranges
202
+ self.heatmap_full_axis_ranges = heatmap_full_axis_ranges
203
+ self.database_identity = database_identity
204
+ self.deadline = deadline
205
+ self.database_replaced = False
206
+ self.sampled_heatmap_source = False
207
+ self.aggregated_heatmap_source = False
208
+ self.loaded_from_sql_heatmap = False
209
+ self.loaded_point_count: int | None = None
210
+ self.heatmap_downsample_info: dict[str, Any] | None = None
211
+ self.heatmap_full_resolution = False
212
+ self.heatmap_source_grid_shape: tuple[int, int] | None = None
213
+ self.heatmap_source_axis_ranges: (
214
+ dict[str, tuple[float, float]] | None
215
+ ) = None
216
+
217
+
218
+ def _ensure_cancel_state(self) -> None:
219
+ """Initialise cancellation state for legacy/tests using ``__new__``."""
220
+
221
+ if not hasattr(self, "_cancelled"):
222
+ self._cancelled = threading.Event()
223
+ if not hasattr(self, "_sql_connection_lock"):
224
+ self._sql_connection_lock = threading.Lock()
225
+ self._sql_connection = None
226
+ if not hasattr(self, "_publication_lock"):
227
+ self._publication_lock = threading.RLock()
228
+
229
+
230
+ def cancel(self) -> None:
231
+ """Request cooperative cancellation and interrupt an active SQL read."""
232
+
233
+ self._ensure_cancel_state()
234
+ with self._publication_lock:
235
+ self._cancelled.set()
236
+ request = getattr(self, "_trusted_request", None)
237
+ if request is not None:
238
+ request.cancel()
239
+ with self._sql_connection_lock:
240
+ connection = self._sql_connection
241
+ if connection is not None:
242
+ try:
243
+ connection.interrupt()
244
+ except Exception:
245
+ # The worker may be closing the connection at the same time.
246
+ pass
247
+
248
+
249
+ def is_cancelled(self) -> bool:
250
+ self._ensure_cancel_state()
251
+ return self._cancelled.is_set()
252
+
253
+
254
+ def _check_cancelled(self) -> None:
255
+ if self.is_cancelled():
256
+ raise PlotWorkCancelled("Plot load cancelled.")
257
+
258
+
259
+ def _read_only_open_kwargs(self) -> dict[str, Any]:
260
+ """Return identity and abort controls for snapshot preparation."""
261
+ self._require_expected_source_current()
262
+ kwargs: dict[str, Any] = {
263
+ "cancelled_callback": self.is_cancelled,
264
+ }
265
+ database_identity = getattr(self, "database_identity", None)
266
+ if database_identity is not None:
267
+ kwargs["expected_database_identity"] = database_identity
268
+ deadline = getattr(self, "deadline", None)
269
+ if deadline is not None:
270
+ kwargs["deadline"] = deadline
271
+ return kwargs
272
+
273
+
274
+ def _require_expected_source_current(self) -> None:
275
+ """Validate the exact retained-plot main and sidecar identities."""
276
+
277
+ expected_sidecars = getattr(self, "expected_sidecar_identities", None)
278
+ if expected_sidecars is None:
279
+ return
280
+ database_path = getattr(self, "expected_database_path", None)
281
+ if database_path is None:
282
+ database_path = cache_database_path(self.cache)
283
+ expected_resolved_path = getattr(
284
+ self,
285
+ "expected_resolved_database_path",
286
+ None,
287
+ )
288
+ if (
289
+ expected_resolved_path is not None
290
+ and canonical_database_path(database_path)
291
+ != expected_resolved_path
292
+ ):
293
+ raise DatabaseInstanceChangedError(
294
+ "The retained plot database path resolved to another source."
295
+ )
296
+ expected_identity = getattr(self, "database_identity", None)
297
+ if (
298
+ expected_identity is not None
299
+ and database_file_identity(database_path) != expected_identity
300
+ ):
301
+ raise DatabaseInstanceChangedError(
302
+ "The retained plot database was replaced."
303
+ )
304
+ if database_sidecar_identities(
305
+ database_path,
306
+ expected_resolved_path,
307
+ ) != frozenset(expected_sidecars):
308
+ raise DatabaseInstanceChangedError(
309
+ "A retained plot SQLite sidecar was replaced."
310
+ )
311
+
312
+
313
+ def _set_sql_connection(self, connection) -> None:
314
+ self._ensure_cancel_state()
315
+ with self._sql_connection_lock:
316
+ self._sql_connection = connection
317
+ if connection is not None and self._cancelled.is_set():
318
+ try:
319
+ connection.interrupt()
320
+ except Exception:
321
+ pass
322
+
323
+
324
+ def _close_sql_connection(self, connection) -> None:
325
+ try:
326
+ connection.close()
327
+ finally:
328
+ self._set_sql_connection(None)
329
+ self._require_expected_source_current()
330
+
331
+
332
+ def _emit_finished(self, finished: bool) -> None:
333
+ """Emit completion unless Qt already deleted the receiver at shutdown."""
334
+
335
+ self._ensure_cancel_state()
336
+ with self._publication_lock:
337
+ if finished and self._cancelled.is_set():
338
+ finished = False
339
+ self.running = False
340
+ try:
341
+ self.emitter.finished.emit(finished)
342
+ except RuntimeError as err:
343
+ message = str(err)
344
+ if not (
345
+ "wrapped C/C++ object" in message
346
+ and "has been deleted" in message
347
+ ):
348
+ raise
349
+
350
+
351
+ def _finish_cancelled(self) -> None:
352
+ self.running = False
353
+ self._emit_finished(False)
354
+
355
+
356
+ def start(self, pool):
357
+ """Wait for broker data without occupying a plot-processing thread.
358
+
359
+ Called on the GUI thread. Submission and done checks are nonblocking;
360
+ all capture, decoding and transform work remains off the GUI thread.
361
+ """
362
+ from qplot.datahandling.trusted_plot import TrustedPlotDataset
363
+ dataset = getattr(self.cache, "_dataset", None)
364
+ if not isinstance(dataset, TrustedPlotDataset) or not self.read_data:
365
+ pool.start(self)
366
+ return
367
+ try:
368
+ self._trusted_request = dataset.service.submit_plot_prefix(
369
+ dataset, deadline=self.deadline, heatmap_plan=self._trusted_heatmap_plan(),
370
+ )
371
+ except Exception as error:
372
+ self._trusted_submission_error = error
373
+ pool.start(self)
374
+ return
375
+ timer = QtCore.QTimer(self.emitter)
376
+ self._trusted_wait_timer = timer
377
+
378
+ def ready():
379
+ if not self.is_cancelled() and not self._trusted_request.done:
380
+ return
381
+ timer.stop()
382
+ timer.deleteLater()
383
+ self._trusted_wait_timer = None
384
+ if self.is_cancelled():
385
+ self._trusted_request.cancel()
386
+ self._trusted_request = None
387
+ self._finish_cancelled()
388
+ else:
389
+ pool.start(self)
390
+
391
+ timer.timeout.connect(ready)
392
+ timer.start(10)
393
+
394
+ @property
395
+ def progress(self):
396
+ request = getattr(self, "_trusted_request", None)
397
+ if request is not None and not request.done:
398
+ return request.progress or PlotProgress("Waiting to read plot data")
399
+ if request is not None:
400
+ return PlotProgress("Waiting to process plot data", stage=2)
401
+ return getattr(self, "_progress", PlotProgress("Waiting to load plot data"))
402
+
403
+ def _prepare_trusted_prefix(self):
404
+ from qplot.datahandling.trusted_plot import TrustedPlotDataset
405
+ dataset = getattr(self.cache, "_dataset", None)
406
+ if not isinstance(dataset, TrustedPlotDataset) or not self.read_data:
407
+ return
408
+ if (error := getattr(self, "_trusted_submission_error", None)) is not None:
409
+ raise error
410
+ request = getattr(self, "_trusted_request", None)
411
+ if request is None:
412
+ # Direct worker users may run synchronously outside the GUI.
413
+ request = dataset.service.submit_plot_prefix(
414
+ dataset, deadline=self.deadline, heatmap_plan=self._trusted_heatmap_plan())
415
+ self._trusted_request = request
416
+ try:
417
+ if self.is_cancelled():
418
+ request.cancel()
419
+ self._trusted_prefix = request.wait()
420
+ self._progress = PlotProgress("Processing plot data", stage=2)
421
+ self.dataset_length_at_start = self._trusted_prefix.row_count
422
+ self._check_cancelled()
423
+ finally:
424
+ self._trusted_request = None
425
+
426
+ def _trusted_heatmap_plan(self):
427
+ from qplot.datahandling.trusted_heatmap import NumericHeatmapPlan
428
+ if len(self.param.depends_on_) != 2:
429
+ return None
430
+ names = (self.axes_dict['x'], self.axes_dict['y'], self.param.name)
431
+ if any(self.param_dict[name].type != 'numeric' for name in names):
432
+ return None
433
+ ranges = {}
434
+ # SQL range filtering accepts either axis independently.
435
+ for axis, values in (self.heatmap_axis_ranges or {}).items():
436
+ try:
437
+ low, high = sorted(float(value) for value in values)
438
+ except (TypeError, ValueError):
439
+ continue
440
+ if np.isfinite(low) and np.isfinite(high) and low != high:
441
+ ranges[axis] = (low, high)
442
+ return NumericHeatmapPlan(
443
+ *names, self.max_full_heatmap_points, self.max_heatmap_grid_cells,
444
+ self.max_heatmap_grid_side,
445
+ tuple(tuple(ranges[axis]) if axis in ranges else None for axis in ('x', 'y')),
446
+ bool(self.operations),
447
+ )
448
+
449
+ def _load_trusted_numeric_heatmap(self, result):
450
+ self._check_cancelled()
451
+ self.dataset_completed = result.completed
452
+ self.total_point_count_estimate = result.selected_count
453
+ self.axis_data = dict(zip(('x', 'y'), result.axes, strict=True))
454
+ self.axis_param = {axis: self.param_dict[self.axes_dict[axis]] for axis in ('x', 'y')}
455
+ self.heatmap_source_grid_shape = self._heatmap_source_grid_shape_from_metadata()
456
+ self._spatial_heatmap_axes = result.axes
457
+ indices = np.nonzero(np.isfinite(result.grid))
458
+ self._spatial_heatmap_indices = (indices[1], indices[0])
459
+ self._spatial_heatmap_source_unique_counts = result.unique_counts
460
+ self._array_heatmap_cardinality_exact = result.cardinality_exact
461
+ self._heatmap_aggregated_source_rows = result.matching_count
462
+ self.aggregated_heatmap_source = True
463
+ self.sampled_heatmap_source = False
464
+ self._heatmap_source_info = {
465
+ 'row_count': result.selected_count, 'estimated_range_rows': result.matching_count,
466
+ 'sampled': False, 'aggregated': True, 'sample_limit': None, 'sample_stride': None,
467
+ 'strategy': 'spatial mean',
468
+ 'axis_ranges': self._normalised_heatmap_axis_ranges(self.heatmap_axis_ranges),
469
+ }
470
+ source_shape = self.heatmap_source_grid_shape or result.unique_counts[::-1]
471
+ _, _, self.dataGrid = self._spatial_aggregation_grid(result.grid[indices], *source_shape)
472
+ full_ranges = self._normalised_heatmap_axis_ranges(self.heatmap_full_axis_ranges)
473
+ if full_ranges is not None:
474
+ self.heatmap_source_axis_ranges = full_ranges
475
+ elif self.heatmap_axis_ranges is None and result.matching_count:
476
+ self.heatmap_source_axis_ranges = dict(zip(('x', 'y'), result.bounds, strict=True))
477
+ self.loaded_from_sql_heatmap = True
478
+ self.loaded_point_count = len(indices[0])
479
+ self.heatmap_downsample_info = self._heatmap_downsample_info()
480
+ self.read_data = False
481
+
482
+ def _plot_connection(self, *, decode_arrays=False):
483
+ prefix = getattr(self, "_trusted_prefix", None)
484
+ if prefix is not None:
485
+ self._require_expected_source_current()
486
+ return prefix.connect(decode_arrays=decode_arrays, cancelled=self.is_cancelled)
487
+ from qplot.datahandling.trusted_plot import TrustedPlotDataset
488
+ if isinstance(getattr(self.cache, "_dataset", None), TrustedPlotDataset):
489
+ raise RuntimeError("The trusted plot prefix is unavailable; reopen the plot.")
490
+ factory = qcodes_read_only_connection if decode_arrays else sqlite_read_only_connection
491
+ return factory(cache_database_path(self.cache), **self._read_only_open_kwargs())
492
+
493
+ def run(self):
494
+ try:
495
+ self._run()
496
+ finally:
497
+ request = getattr(self, "_trusted_request", None)
498
+ if request is not None:
499
+ # Validation or source fencing may fail before consuming the
500
+ # asynchronously submitted capture.
501
+ request.cancel()
502
+ self._trusted_request = None
503
+ self._trusted_prefix = None
504
+
505
+ def _run(self):
506
+ try:
507
+ self._check_cancelled()
508
+ self._require_expected_source_current()
509
+ ensure_supported_plot_dimensions(
510
+ getattr(self.param, "name", "Measurement"),
511
+ getattr(self.param, "depends_on_", ()),
512
+ )
513
+ if len(getattr(self.param, "depends_on_", ())) > 1:
514
+ self._validate_heatmap_parameter_types()
515
+ elif len(self.param.depends_on_) == 1:
516
+ for name in (self.param.name, *self.param.depends_on_):
517
+ if getattr(self.param_dict[name], "type", None) == "complex":
518
+ self._reject_complex_line(name)
519
+ self._prepare_trusted_prefix()
520
+ self._progress = PlotProgress("Processing plot data", stage=2)
521
+ cache = self.cache
522
+ cache_live = cache_is_live(cache)
523
+ from qplot.datahandling.trusted_heatmap import NumericHeatmap
524
+ trusted_heatmap = isinstance(getattr(self, '_trusted_prefix', None), NumericHeatmap)
525
+ if trusted_heatmap:
526
+ self._load_trusted_numeric_heatmap(self._trusted_prefix)
527
+
528
+ # Completion checks and cache preparation can query SQLite, so do
529
+ # them here rather than on the GUI thread. In-memory live caches
530
+ # are already authoritative and should never be read via SQLite.
531
+ if self.read_data:
532
+ if cache_live:
533
+ self.dataset_completed = cache_dataset_completed(cache)
534
+ self.read_data = False
535
+ else:
536
+ completion_conn = self._plot_connection(decode_arrays=True)
537
+ self._set_sql_connection(completion_conn)
538
+ try:
539
+ self._check_cancelled()
540
+ (
541
+ parameter_complete,
542
+ self.dataset_completed,
543
+ ) = load_param_data_from_db_prep(
544
+ cache,
545
+ self.param,
546
+ connection=completion_conn,
547
+ )
548
+ finally:
549
+ self._close_sql_connection(completion_conn)
550
+ self._check_cancelled()
551
+ self.read_data = not parameter_complete
552
+
553
+ self._check_cancelled()
554
+ use_sql_heatmap = (
555
+ self.read_data
556
+ and not cache_live
557
+ and self._should_use_sql_heatmap()
558
+ )
559
+ if use_sql_heatmap:
560
+ self._load_large_heatmap_from_sql()
561
+
562
+ elif not trusted_heatmap:
563
+ if self.read_data:
564
+ prefix = getattr(self, "_trusted_prefix", None)
565
+ if prefix is not None:
566
+ shape = self._parameter_shape()
567
+ planned_bytes = (math.prod(shape) * len(self.param_dict) * 128
568
+ if shape else 0)
569
+ if max(prefix.decode_bytes, planned_bytes) > MAX_TRUSTED_FULL_PLOT_DECODE_BYTES:
570
+ raise ValueError("Full-resolution plot data exceeds the 1 GiB decoding budget.")
571
+ write_status, read_status, existing_data = (
572
+ snapshot_cache_parameter_state(cache, self.param.name)
573
+ )
574
+ conn = self._plot_connection(decode_arrays=True)
575
+ self._set_sql_connection(conn)
576
+ try:
577
+ self._check_cancelled()
578
+ (
579
+ self.updated_read_status,
580
+ self.updated_write_status,
581
+ self.cache_data
582
+ ) = load_param_data_from_db(
583
+ conn,
584
+ self.table_name,
585
+ cache_rundescriber(cache),
586
+ self.param.name,
587
+ write_status,
588
+ read_status,
589
+ existing_data,
590
+ check_cancelled=self._check_cancelled,
591
+ )
592
+ finally:
593
+ self._close_sql_connection(conn)
594
+
595
+ self._check_cancelled()
596
+ data = self.cache_data[self.param.name]
597
+ write_status = self.updated_write_status
598
+
599
+ else:
600
+ # Capture the acquired extent with its arrays, including
601
+ # cache-only processing after a run's final publication.
602
+ write_status, _, cached_data = snapshot_cache_parameter_state(
603
+ cache, self.param.name,
604
+ )
605
+ data = cached_data[self.param.name]
606
+
607
+ self._check_cancelled()
608
+ data = self._normalise_array_records(data)
609
+ depvarData = data[self.param.name]
610
+ acquired = self._acquired_sample_mask(depvarData, write_status)
611
+
612
+ # A QCoDeS array record adds a storage dimension, not an
613
+ # independent setpoint. Select the plot by declared axes.
614
+ if len(self.param.depends_on_) == 1:
615
+ axis_data, axis_param = self.for_1d(data, acquired & ~numeric_isnan(depvarData))
616
+
617
+ # for shaped 2d plots
618
+ elif len(depvarData.shape) == 2:
619
+ (
620
+ axis_data,
621
+ axis_param,
622
+ dataGrid
623
+ ) = self.for_shaped_2d(
624
+ data,
625
+ depvarData,
626
+ acquired=acquired,
627
+ )
628
+
629
+ else:
630
+ #Remove nan values
631
+ valid_rows = acquired & ~numeric_isnan(depvarData)
632
+
633
+ # for unshaped 2d plots
634
+ (
635
+ axis_data,
636
+ axis_param,
637
+ dataGrid
638
+ ) = self.for_unshaped_2d(
639
+ data,
640
+ valid_rows,
641
+ depvarData
642
+ )
643
+
644
+ # Allow main to fetch data
645
+ self.axis_data = axis_data
646
+ self.axis_param = axis_param
647
+ if len(self.param.depends_on_) != 1:
648
+ self.dataGrid = dataGrid
649
+
650
+ except PlotWorkCancelled:
651
+ self._finish_cancelled()
652
+ return
653
+ except (DatabaseInstanceChangedError, TrustedLiveSourceChangedError) as err:
654
+ self.database_replaced = True
655
+ if self.is_cancelled():
656
+ self._finish_cancelled()
657
+ return
658
+ self.emitter.errorOccurred.emit(err)
659
+ self._emit_finished(False)
660
+ return
661
+ except Exception as err: # Raise error in main thread
662
+ if self.is_cancelled():
663
+ self._finish_cancelled()
664
+ return
665
+ log_exception("Plot worker failed", err, __name__)
666
+ self.emitter.errorOccurred.emit(err)
667
+ self._emit_finished(False) # False: Failed
668
+ return
669
+
670
+ try:
671
+ self._check_cancelled()
672
+ # Operations are an ordered, atomic pipeline. Large heatmaps are
673
+ # reduced for display only after this pipeline has completed.
674
+ # Normalize heatmap geometry first so coordinate-dependent
675
+ # operations see spatial order rather than acquisition order.
676
+ if self.operations:
677
+ self._progress = PlotProgress("Applying transforms", stage=2)
678
+ self._canonicalize_heatmap()
679
+ self._check_cancelled()
680
+ results = self.do_operations()
681
+ if results is not None:
682
+ (
683
+ self.axis_data["x"],
684
+ self.axis_data["y"]
685
+ ) = results[:2]
686
+ if hasattr(self, "dataGrid"):
687
+ self.dataGrid = results[2]
688
+ self._apply_operation_metadata()
689
+
690
+ self._check_cancelled()
691
+ if len(self.param.depends_on_) == 1:
692
+ # Operations can replace either axis. Validate their output
693
+ # before finished(True) permits any GUI/cache publication.
694
+ for axis in ("x", "y"):
695
+ self._real_line_values(
696
+ self.axis_data[axis], self.axis_param[axis].name,
697
+ )
698
+ self._check_cancelled()
699
+ self._aggregate_operated_heatmap_if_needed()
700
+ self._check_cancelled()
701
+ # Operations may return replacement coordinates or grids, so keep
702
+ # the final geometry validation and normalization as well.
703
+ self._canonicalize_heatmap()
704
+ self._check_cancelled()
705
+ except PlotWorkCancelled:
706
+ self._finish_cancelled()
707
+ return
708
+ except DatabaseInstanceChangedError as err:
709
+ self.database_replaced = True
710
+ if self.is_cancelled():
711
+ self._finish_cancelled()
712
+ return
713
+ self.emitter.errorOccurred.emit(err)
714
+ self._emit_finished(False)
715
+ return
716
+ except Exception as err:
717
+ if self.is_cancelled():
718
+ self._finish_cancelled()
719
+ return
720
+ log_exception("Plot processing failed", err, __name__)
721
+ self.emitter.errorOccurred.emit(err)
722
+ self._emit_finished(False)
723
+ return
724
+
725
+ try:
726
+ self._require_expected_source_current()
727
+ except DatabaseInstanceChangedError as err:
728
+ self.database_replaced = True
729
+ if self.is_cancelled():
730
+ self._finish_cancelled()
731
+ return
732
+ self.emitter.errorOccurred.emit(err)
733
+ self._emit_finished(False)
734
+ return
735
+
736
+ # Callback
737
+ self.heatmap_full_resolution = bool(
738
+ len(self.param.depends_on_) == 2
739
+ and self.heatmap_downsample_info is None
740
+ and not self.sampled_heatmap_source
741
+ and not self.aggregated_heatmap_source
742
+ )
743
+ self._progress = PlotProgress("Preparing display", stage=2)
744
+ self._emit_finished(True)
745
+
746
+
747
+ def _acquired_sample_mask(self, values, write_status):
748
+ """Separate acquired samples from dtype-dependent planned padding."""
749
+ self._check_cancelled()
750
+ if self._parameter_shape() is None:
751
+ # Unshaped caches have no planned padding; their write status
752
+ # does not count all acquired samples.
753
+ acquired = np.ones(values.shape, dtype=bool)
754
+ else:
755
+ acquired = np.zeros(values.shape, dtype=bool)
756
+ count = write_status.get(self.param.name)
757
+ if count is not None:
758
+ acquired.ravel()[:count] = True
759
+ self._check_cancelled()
760
+ return acquired
761
+
762
+
763
+ def _normalise_array_records(self, data):
764
+ """Flatten ragged QCoDeS records together, without touching the cache."""
765
+
766
+ heatmap = len(self.param.depends_on_) > 1
767
+ setpoints = (
768
+ [self.axes_dict[axis] for axis in ("x", "y")]
769
+ if heatmap else self.param.depends_on_
770
+ )
771
+ names = list(dict.fromkeys((self.param.name, *setpoints)))
772
+ columns = [np.asarray(data[name]) for name in names]
773
+ if not any(
774
+ column.dtype.kind == "O" and any(np.ndim(value) for value in column.flat)
775
+ for column in columns
776
+ ):
777
+ return data
778
+
779
+ # Nested object cells represent acquisition records. Dense numeric
780
+ # object cells instead preserve mixed signed/unsigned sample values.
781
+ # Other columns may still hold one scalar or a dense array per record.
782
+ record_count = len(columns[0])
783
+ if any(column.ndim == 0 or len(column) != record_count for column in columns):
784
+ raise ValueError("Setpoint and measurement record counts do not match.")
785
+
786
+ def records():
787
+ for index in range(record_count):
788
+ self._check_cancelled()
789
+ values = [np.asarray(column[index]) for column in columns]
790
+ size = values[0].size
791
+ for name, value in zip(names, values, strict=True):
792
+ if heatmap:
793
+ self._real_heatmap_values(
794
+ value, name, coordinate=name != self.param.name,
795
+ )
796
+ else:
797
+ self._real_line_values(value, name)
798
+ if value.dtype.kind not in "biufcO":
799
+ raise ValueError("Array records must contain numeric values.")
800
+ if value.size not in (1, size):
801
+ raise ValueError("Setpoint and measurement shapes do not match.")
802
+ yield values, size
803
+
804
+ # Count actual samples first: padding to the longest record can grow
805
+ # quadratically. Reject complex records above before allocating or
806
+ # converting any numeric output; the raw cache remains untouched.
807
+ total = 0
808
+ dtypes: list[np.dtype | None] = [None for _ in names]
809
+ for values, size in records():
810
+ total += size
811
+ dtypes = [value.dtype if dtype is None else integer_preserving_dtype((dtype, value.dtype))
812
+ for dtype, value in zip(dtypes, values, strict=True)]
813
+ self._check_cancelled()
814
+ if heatmap:
815
+ self._requires_bounded_heatmap(total)
816
+ normalised = {name: np.empty(total, dtype=dtype)
817
+ for name, dtype in zip(names, dtypes, strict=True)}
818
+ offset = 0
819
+ chunk_size = max(1, CANCELLATION_CHUNK_SIZE)
820
+ for values, size in records():
821
+ for start in range(0, size, chunk_size):
822
+ stop = min(size, start + chunk_size)
823
+ for name, value in zip(names, values, strict=True):
824
+ self._check_cancelled()
825
+ # flat slices use logical C order and copy at most one
826
+ # chunk, even when a record has Fortran storage order.
827
+ normalised[name][offset + start:offset + stop] = (
828
+ value.item() if value.size == 1 else value.flat[start:stop]
829
+ )
830
+ offset += size
831
+ self._check_cancelled()
832
+ return normalised
833
+
834
+
835
+ def _canonicalize_heatmap(self) -> None:
836
+ """Keep worker indices consistent with increasing heatmap axes."""
837
+
838
+ self._check_cancelled()
839
+ if not hasattr(self, "dataGrid"):
840
+ return
841
+ x_data = np.asarray(self.axis_data.get("x", []))
842
+ self._check_cancelled()
843
+ y_data = np.asarray(self.axis_data.get("y", []))
844
+ self._check_cancelled()
845
+ data_grid = np.asarray(self.dataGrid)
846
+ if x_data.size == 0 or y_data.size == 0 or data_grid.size == 0:
847
+ return
848
+
849
+ x_data, y_data, data_grid = canonicalize_heatmap_data(
850
+ x_data,
851
+ y_data,
852
+ data_grid,
853
+ )
854
+ self._check_cancelled()
855
+ self.axis_data["x"] = x_data
856
+ self.axis_data["y"] = y_data
857
+ self.dataGrid = data_grid
858
+
859
+
860
+ def _should_use_sql_heatmap(self):
861
+ if len(getattr(self.param, "depends_on_", ())) <= 1:
862
+ return False
863
+
864
+ if self.force_sql_heatmap:
865
+ if getattr(self, "operations", None):
866
+ raise OperationExecutionError(
867
+ "Operations cannot be applied to a heatmap detail reload."
868
+ )
869
+ return True
870
+
871
+ setpoint_count = self._large_heatmap_point_count()
872
+ if setpoint_count is None:
873
+ return False
874
+
875
+ requires_bounded_load = self._requires_bounded_heatmap(setpoint_count)
876
+ return requires_bounded_load or getattr(
877
+ self, "_variable_multidimensional_array_records", False,
878
+ )
879
+
880
+
881
+ def _requires_bounded_heatmap(self, point_count):
882
+ """Reject full-resolution operations before allocating a large grid."""
883
+
884
+ self._check_cancelled()
885
+ limit = max(1, int(getattr(
886
+ self,
887
+ "max_full_heatmap_points",
888
+ MAX_FULL_HEATMAP_POINTS,
889
+ )))
890
+ requires_bounded_load = point_count > limit
891
+ if requires_bounded_load and getattr(self, "operations", None):
892
+ raise OperationExecutionError(
893
+ f"This heatmap requires {point_count:,} full-resolution points, "
894
+ f"which exceeds the full-resolution operation limit of {limit:,}. "
895
+ "Disable operations or increase max_full_heatmap_points."
896
+ )
897
+ return requires_bounded_load
898
+
899
+
900
+ def _large_heatmap_point_count(self):
901
+ self._variable_multidimensional_array_records = False
902
+ source_grid_shape = self._heatmap_source_grid_shape_from_metadata()
903
+ planned_count = 0
904
+ if source_grid_shape is not None:
905
+ source_grid_rows, source_grid_columns = source_grid_shape
906
+ planned_count = int(source_grid_rows * source_grid_columns)
907
+ self.heatmap_source_grid_shape = source_grid_shape
908
+ # Small planned shapes still need array-header inspection: the
909
+ # stored record shapes need not be identical to the scan shape.
910
+ if planned_count > self.max_full_heatmap_points or not self._has_array_heatmap_columns():
911
+ self.total_point_count_estimate = planned_count
912
+ return planned_count
913
+
914
+ conn = self._plot_connection()
915
+ self._set_sql_connection(conn)
916
+ try:
917
+ self._check_cancelled()
918
+ if self._has_array_heatmap_columns():
919
+ # A storage row can contain millions of samples. Inspect
920
+ # bounded NPY headers, stopping as soon as the limit is crossed.
921
+ setpoint_count = 0
922
+ first_shapes = None
923
+ with closing(self._array_heatmap_records(conn)) as records:
924
+ for values, size in records:
925
+ shapes = tuple(value.shape if isinstance(value, _HeatmapArrayReader)
926
+ else () for value in values)
927
+ if first_shapes is None:
928
+ first_shapes = shapes
929
+ elif any(shape != first and max(len(shape), len(first)) > 1
930
+ for first, shape in zip(first_shapes, shapes, strict=True)):
931
+ # QCoDeS' np.array(records, dtype=object) retry can
932
+ # also broadcast/fail for ragged multidimensional
933
+ # records, before our normaliser ever receives data.
934
+ self._variable_multidimensional_array_records = True
935
+ setpoint_count += size
936
+ if setpoint_count > self.max_full_heatmap_points:
937
+ break
938
+ setpoint_count = max(planned_count, setpoint_count)
939
+ else:
940
+ setpoint_count = self._selected_parameter_row_count(conn)
941
+ finally:
942
+ self._close_sql_connection(conn)
943
+
944
+ if setpoint_count is not None:
945
+ self.total_point_count_estimate = setpoint_count
946
+ return setpoint_count
947
+
948
+
949
+ def _parameter_shape(self):
950
+ shapes = getattr(cache_rundescriber(self.cache), "shapes", None)
951
+ if not isinstance(shapes, dict):
952
+ return None
953
+
954
+ return shapes.get(self.param.name)
955
+
956
+
957
+ def _shape_size(self, shape):
958
+ if shape is None:
959
+ return None
960
+
961
+ try:
962
+ dimensions = [int(dimension) for dimension in shape]
963
+ except (TypeError, ValueError):
964
+ return None
965
+
966
+ if not dimensions or any(dimension <= 0 for dimension in dimensions):
967
+ return None
968
+
969
+ return math.prod(dimensions)
970
+
971
+
972
+ def _load_large_heatmap_from_sql(self):
973
+ # SQLite aggregates treat QCoDeS complex BLOBs as numbers, so reject
974
+ # every declared complex column before the bounded SQL path can
975
+ # compute a misleading grid.
976
+ self._validate_heatmap_parameter_types()
977
+ conn = self._plot_connection()
978
+ self._set_sql_connection(conn)
979
+ full_arrays = None
980
+ try:
981
+ self._check_cancelled()
982
+ rowid_min, rowid_max = self._rowid_span(conn)
983
+ self._check_cancelled()
984
+ self.heatmap_source_grid_shape = (
985
+ self._heatmap_source_grid_shape_from_metadata()
986
+ )
987
+ if (
988
+ getattr(self, "_variable_multidimensional_array_records", False)
989
+ and self.total_point_count_estimate <= self.max_full_heatmap_points
990
+ ):
991
+ full_arrays = self._read_full_array_heatmap_arrays(conn)
992
+ x_data, y_data, z_data = (
993
+ full_arrays if full_arrays is not None
994
+ else self._read_heatmap_arrays(conn, rowid_min, rowid_max)
995
+ )
996
+ finally:
997
+ self._close_sql_connection(conn)
998
+
999
+ self._check_cancelled()
1000
+ if full_arrays is not None:
1001
+ # Keep normal full-resolution pivot/operation semantics for the
1002
+ # compatibility decoder, including sparse-grid size checks.
1003
+ data = dict(zip(
1004
+ (self.axes_dict["x"], self.axes_dict["y"], self.param.name),
1005
+ full_arrays, strict=True,
1006
+ ))
1007
+ axes, _params, data_grid = self.for_unshaped_2d(
1008
+ data, np.ones(z_data.size, dtype=bool), z_data,
1009
+ )
1010
+ x_axis, y_axis = axes["x"], axes["y"]
1011
+ else:
1012
+ x_axis, y_axis, data_grid = self._heatmap_grid_from_arrays(
1013
+ x_data, y_data, z_data,
1014
+ )
1015
+ self.axis_data = {
1016
+ "x": x_axis,
1017
+ "y": y_axis,
1018
+ }
1019
+ self.axis_param = {
1020
+ "x": self.param_dict[self.axes_dict["x"]],
1021
+ "y": self.param_dict[self.axes_dict["y"]],
1022
+ }
1023
+ self.dataGrid = data_grid
1024
+ self.loaded_from_sql_heatmap = True
1025
+ self.loaded_point_count = int(z_data.size)
1026
+ self.heatmap_downsample_info = self._heatmap_downsample_info()
1027
+
1028
+ # The direct SQL path deliberately does not populate QCoDeS' full
1029
+ # in-memory cache. Keep future refreshes on the database path.
1030
+ self.read_data = False
1031
+
1032
+
1033
+ def _read_full_array_heatmap_arrays(self, conn):
1034
+ """Decode compatible small records in order, with bounded BLOB reads."""
1035
+ buffered = []
1036
+ source_count = 0
1037
+ with closing(self._array_heatmap_chunks(conn)) as chunks:
1038
+ for size, arrays in chunks:
1039
+ self._check_cancelled()
1040
+ source_count += size
1041
+ # A live acquisition may grow after the header preflight.
1042
+ # Switch to bounded rendering rather than grow the buffer;
1043
+ # operations instead raise their usual resolution-limit error.
1044
+ if self._requires_bounded_heatmap(source_count):
1045
+ return None
1046
+ buffered.append(arrays)
1047
+ self._check_cancelled()
1048
+ self.total_point_count_estimate = source_count
1049
+ return tuple(
1050
+ concatenate_record_samples([arrays[axis] for arrays in buffered])
1051
+ if buffered else np.array([], dtype=float)
1052
+ for axis in range(3)
1053
+ )
1054
+
1055
+
1056
+ def _rowid_span(self, conn):
1057
+ self._check_cancelled()
1058
+ table = _sqlite_identifier(self.table_name)
1059
+ row = conn.execute(f"SELECT MIN(rowid), MAX(rowid) FROM {table}").fetchone()
1060
+ if row is None or row[0] is None or row[1] is None:
1061
+ return None, None
1062
+
1063
+ return int(row[0]), int(row[1])
1064
+
1065
+
1066
+ def _read_heatmap_arrays(self, conn, rowid_min, rowid_max):
1067
+ self._check_cancelled()
1068
+ if rowid_min is None or rowid_max is None:
1069
+ self._heatmap_source_info = {
1070
+ "row_count": 0,
1071
+ "estimated_range_rows": None,
1072
+ "sampled": False,
1073
+ "aggregated": False,
1074
+ "sample_limit": MAX_SQL_HEATMAP_SOURCE_ROWS,
1075
+ "sample_stride": None,
1076
+ "strategy": "empty",
1077
+ "axis_ranges": self._normalised_heatmap_axis_ranges(
1078
+ self.heatmap_axis_ranges
1079
+ ),
1080
+ }
1081
+ return (
1082
+ np.array([], dtype=float),
1083
+ np.array([], dtype=float),
1084
+ np.array([], dtype=float),
1085
+ )
1086
+
1087
+ if self._has_array_heatmap_columns():
1088
+ return self._read_array_heatmap_arrays(conn)
1089
+
1090
+ table = _sqlite_identifier(self.table_name)
1091
+ x_column = _sqlite_identifier(self.axes_dict["x"])
1092
+ y_column = _sqlite_identifier(self.axes_dict["y"])
1093
+ z_column = _sqlite_identifier(self.param.name)
1094
+ # Match _arrays_from_values: an observation contributes only when
1095
+ # both coordinates and its signal are finite. Apply this before
1096
+ # MIN/MAX, cardinalities and AVG so invalid samples cannot distort
1097
+ # bounds or poison a bin containing valid samples. BETWEEN also
1098
+ # excludes missing values, including SQLite NULLs.
1099
+ max_finite = float(np.finfo(float).max)
1100
+ finite_where_sql = " AND ".join(
1101
+ f"{column} BETWEEN {-max_finite} AND {max_finite}"
1102
+ for column in (x_column, y_column, z_column)
1103
+ )
1104
+ selected_count = self._selected_parameter_row_count(conn)
1105
+ # Retain the acquired row count as a conservative loading bound;
1106
+ # summaries and returned arrays count only valid observations.
1107
+ row_count = selected_count
1108
+ if row_count is None:
1109
+ row_count = rowid_max - rowid_min + 1
1110
+ columns = f"{x_column}, {y_column}, {z_column}"
1111
+ axis_ranges = self._normalised_heatmap_axis_ranges(self.heatmap_axis_ranges)
1112
+ self.total_point_count_estimate = row_count
1113
+ self._heatmap_source_info = {
1114
+ "row_count": int(row_count),
1115
+ "estimated_range_rows": int(row_count),
1116
+ "sampled": False,
1117
+ "aggregated": False,
1118
+ "sample_limit": MAX_SQL_HEATMAP_SOURCE_ROWS,
1119
+ "sample_stride": None,
1120
+ "strategy": "all",
1121
+ "axis_ranges": axis_ranges,
1122
+ }
1123
+
1124
+ axis_where_sql, parameters = self._heatmap_where_clause()
1125
+ if axis_where_sql:
1126
+ where_sql = f"{finite_where_sql} AND {axis_where_sql}"
1127
+ range_summary = self._heatmap_spatial_summary(
1128
+ conn,
1129
+ where_sql,
1130
+ parameters,
1131
+ )
1132
+ range_row_count = range_summary[0]
1133
+ self._heatmap_estimated_range_rows = range_row_count
1134
+ self._heatmap_source_info.update({
1135
+ "estimated_range_rows": range_row_count,
1136
+ "strategy": "visible range",
1137
+ })
1138
+ if range_row_count > MAX_SQL_HEATMAP_SOURCE_ROWS:
1139
+ return self._spatially_aggregated_heatmap_arrays(
1140
+ conn,
1141
+ where_sql,
1142
+ parameters,
1143
+ range_summary,
1144
+ )
1145
+
1146
+ self.sampled_heatmap_source = False
1147
+ self.aggregated_heatmap_source = False
1148
+ cursor = conn.execute(
1149
+ (
1150
+ f"SELECT {columns} FROM {table} "
1151
+ f"WHERE {where_sql} ORDER BY rowid"
1152
+ ),
1153
+ parameters,
1154
+ )
1155
+ return self._arrays_from_cursor(cursor)
1156
+
1157
+ if row_count <= MAX_SQL_HEATMAP_SOURCE_ROWS:
1158
+ self.sampled_heatmap_source = False
1159
+ self.aggregated_heatmap_source = False
1160
+ cursor = conn.execute(
1161
+ (
1162
+ f"SELECT {columns} FROM {table} "
1163
+ f"WHERE {finite_where_sql} ORDER BY rowid"
1164
+ ),
1165
+ )
1166
+ arrays = self._arrays_from_cursor(cursor)
1167
+ self._heatmap_source_info["estimated_range_rows"] = int(arrays[2].size)
1168
+ return arrays
1169
+
1170
+ summary = self._heatmap_spatial_summary(conn, finite_where_sql, ())
1171
+ return self._spatially_aggregated_heatmap_arrays(
1172
+ conn,
1173
+ finite_where_sql,
1174
+ (),
1175
+ summary,
1176
+ )
1177
+
1178
+
1179
+ def _has_array_heatmap_columns(self):
1180
+ return any(
1181
+ getattr(param, "type", None) == "array"
1182
+ for param in (
1183
+ self.param,
1184
+ self.param_dict[self.axes_dict["x"]],
1185
+ self.param_dict[self.axes_dict["y"]],
1186
+ )
1187
+ )
1188
+
1189
+
1190
+ def _array_heatmap_records(self, conn):
1191
+ """Yield one aligned record, without materialising any array BLOB."""
1192
+ names = (self.axes_dict["x"], self.axes_dict["y"], self.param.name)
1193
+ array_columns = [
1194
+ getattr(self.param_dict[name], "type", None) == "array"
1195
+ for name in names
1196
+ ]
1197
+ columns = ", ".join(
1198
+ "NULL" if is_array else _sqlite_identifier(name)
1199
+ for name, is_array in zip(names, array_columns, strict=True)
1200
+ )
1201
+ table = _sqlite_identifier(self.table_name)
1202
+ selected = f"{_sqlite_identifier(self.param.name)} IS NOT NULL"
1203
+ last_rowid = 0
1204
+ while True:
1205
+ self._check_cancelled()
1206
+ # Finish each bounded row query before decoding. Array payloads
1207
+ # are opened separately and always with readonly=True.
1208
+ rows = conn.execute(
1209
+ f"SELECT rowid, {columns} FROM {table} "
1210
+ f"WHERE rowid > ? AND {selected} ORDER BY rowid LIMIT 128",
1211
+ (last_rowid,),
1212
+ ).fetchall()
1213
+ if not rows:
1214
+ return
1215
+ for rowid, *values in rows:
1216
+ self._check_cancelled()
1217
+ with ExitStack() as stack:
1218
+ for index, (name, is_array) in enumerate(zip(names, array_columns, strict=True)):
1219
+ self._check_cancelled()
1220
+ if is_array:
1221
+ blob = stack.enter_context(conn.blobopen(
1222
+ self.table_name, name, rowid, readonly=True,
1223
+ ))
1224
+ values[index] = _HeatmapArrayReader(blob)
1225
+ if values[index].dtype.kind == "c":
1226
+ self._reject_complex_heatmap(
1227
+ name,
1228
+ coordinate=name != self.param.name,
1229
+ )
1230
+ sizes = [value.size if isinstance(value, _HeatmapArrayReader) else 1
1231
+ for value in values]
1232
+ size = sizes[2]
1233
+ if any(value_size not in (1, size) for value_size in sizes):
1234
+ raise ValueError("Heatmap setpoint and measurement shapes do not match.")
1235
+ yield values, size
1236
+ last_rowid = rows[-1][0]
1237
+
1238
+
1239
+ def _array_heatmap_chunks(self, conn):
1240
+ chunk_size = max(1, min(CANCELLATION_CHUNK_SIZE, MAX_SQL_HEATMAP_SOURCE_ROWS))
1241
+ with closing(self._array_heatmap_records(conn)) as records:
1242
+ for values, size in records:
1243
+ for start in range(0, size, chunk_size):
1244
+ self._check_cancelled()
1245
+ stop = min(size, start + chunk_size)
1246
+ decoded = [
1247
+ value.read(0 if value.size == 1 else start,
1248
+ 1 if value.size == 1 else stop,
1249
+ self._check_cancelled)
1250
+ if isinstance(value, _HeatmapArrayReader) else value
1251
+ for value in values
1252
+ ]
1253
+ yield stop - start, self._arrays_from_values(*decoded)
1254
+
1255
+
1256
+ def _array_heatmap_visible_values(self, arrays):
1257
+ x_data, y_data, z_data = arrays
1258
+ valid = np.ones(z_data.size, dtype=bool)
1259
+ for axis, values in (("x", x_data), ("y", y_data)):
1260
+ bounds = self._grid_axis_bounds(axis)
1261
+ if bounds is not None:
1262
+ valid &= (values >= bounds[0]) & (values <= bounds[1])
1263
+ return x_data[valid], y_data[valid], z_data[valid]
1264
+
1265
+
1266
+ def _read_array_heatmap_arrays(self, conn):
1267
+ """Keep a bounded sample buffer, or stream two passes into a mean grid."""
1268
+ buffered: list[tuple[np.ndarray, np.ndarray, np.ndarray]] | None = []
1269
+ source_count = matching_count = 0
1270
+ # Keep exact coordinates only while an axis could fit on screen.
1271
+ # Once saturated, retain its extrema and bin it on the second pass.
1272
+ axis_limit = max(1, self.max_heatmap_grid_side)
1273
+ unique: list[set[float] | None] = [set(), set()]
1274
+ precision_sensitive = [False, False]
1275
+ lower = [np.inf, np.inf]
1276
+ upper = [-np.inf, -np.inf]
1277
+ with closing(self._array_heatmap_chunks(conn)) as chunks:
1278
+ for size, arrays in chunks:
1279
+ self._check_cancelled()
1280
+ source_count += size
1281
+ arrays = self._array_heatmap_visible_values(arrays)
1282
+ matching_count += arrays[2].size
1283
+ if buffered is not None:
1284
+ if matching_count <= MAX_SQL_HEATMAP_SOURCE_ROWS:
1285
+ if arrays[2].size:
1286
+ buffered.append(arrays)
1287
+ else:
1288
+ buffered = None
1289
+ for axis, values in enumerate(arrays[:2]):
1290
+ if not values.size:
1291
+ continue
1292
+ lower[axis] = min(lower[axis], float(np.min(values)))
1293
+ upper[axis] = max(upper[axis], float(np.max(values)))
1294
+ axis_unique = unique[axis]
1295
+ precision_sensitive[axis] |= self._coordinate_conversion_may_round(values)
1296
+ if axis_unique is not None:
1297
+ axis_unique.update(np.unique(values).tolist())
1298
+ # Check accumulated exact coordinates before the set
1299
+ # is bounded: a collision may span acquisition records.
1300
+ if precision_sensitive[axis]:
1301
+ self._float_heatmap_coordinates(
1302
+ np.array(list(axis_unique), dtype=object),
1303
+ self.axes_dict[("x", "y")[axis]],
1304
+ )
1305
+ if len(axis_unique) > axis_limit:
1306
+ unique[axis] = None
1307
+ elif precision_sensitive[axis]:
1308
+ self._float_heatmap_coordinates(
1309
+ values, self.axes_dict[("x", "y")[axis]],
1310
+ )
1311
+
1312
+ self.total_point_count_estimate = source_count
1313
+ self.sampled_heatmap_source = False
1314
+ self.aggregated_heatmap_source = buffered is None
1315
+ self._heatmap_source_info = {
1316
+ "row_count": source_count,
1317
+ "estimated_range_rows": matching_count,
1318
+ "sampled": False,
1319
+ "aggregated": buffered is None,
1320
+ "sample_limit": MAX_SQL_HEATMAP_SOURCE_ROWS,
1321
+ "sample_stride": None,
1322
+ "strategy": "spatial mean" if buffered is None else "all",
1323
+ "axis_ranges": self._normalised_heatmap_axis_ranges(self.heatmap_axis_ranges),
1324
+ }
1325
+ if buffered is not None:
1326
+ self._check_cancelled()
1327
+ return tuple(
1328
+ concatenate_record_samples([arrays[axis] for arrays in buffered])
1329
+ if buffered else np.array([], dtype=float)
1330
+ for axis in range(3)
1331
+ )
1332
+
1333
+ counts = tuple(len(values) if values is not None else axis_limit + 1
1334
+ for values in unique)
1335
+ bins = self._bounded_grid_shape(*counts)
1336
+ exact = [values is not None and len(values) <= count
1337
+ for values, count in zip(unique, bins, strict=True)]
1338
+ axes = [
1339
+ np.array(sorted(values), dtype=float) if exact[axis] and values is not None
1340
+ else self._scaled_axis_indices(
1341
+ np.array([lower[axis], upper[axis]]), bins[axis],
1342
+ (lower[axis], upper[axis]),
1343
+ )[0]
1344
+ for axis, values in enumerate(unique)
1345
+ ]
1346
+ means = FiniteBinMeans((axes[1].size, axes[0].size), self._check_cancelled)
1347
+
1348
+ def accumulate(*, replay=False):
1349
+ # Both passes use the same private snapshot, frozen bin geometry,
1350
+ # and visible-value filtering. Close each iterator (including its
1351
+ # read-only BLOBs) before starting the optional exact replay.
1352
+ with closing(self._array_heatmap_chunks(conn)) as chunks:
1353
+ for _size, arrays in chunks:
1354
+ self._check_cancelled()
1355
+ x_data, y_data, z_data = self._array_heatmap_visible_values(arrays)
1356
+ indices = [
1357
+ np.searchsorted(axes[axis], values) if exact[axis]
1358
+ else self._scaled_axis_indices(
1359
+ values, bins[axis], (lower[axis], upper[axis]),
1360
+ )[1]
1361
+ for axis, values in enumerate((x_data, y_data))
1362
+ ]
1363
+ if replay:
1364
+ means.replay((indices[1], indices[0]), z_data)
1365
+ else:
1366
+ means.add((indices[1], indices[0]), z_data)
1367
+
1368
+ accumulate()
1369
+ if means.begin_exact_replay():
1370
+ accumulate(replay=True)
1371
+
1372
+ self._check_cancelled()
1373
+ y_indices, x_indices = np.nonzero(means.counts)
1374
+ z_data = means.means()[y_indices, x_indices]
1375
+ self._spatial_heatmap_axes = (axes[0], axes[1])
1376
+ self._spatial_heatmap_indices = (x_indices, y_indices)
1377
+ self._spatial_heatmap_source_unique_counts = (counts[0], counts[1])
1378
+ self._array_heatmap_cardinality_exact = tuple(values is not None for values in unique)
1379
+ self._heatmap_aggregated_source_rows = matching_count
1380
+ full_ranges = self._normalised_heatmap_axis_ranges(self.heatmap_full_axis_ranges)
1381
+ if full_ranges is not None:
1382
+ self.heatmap_source_axis_ranges = full_ranges
1383
+ elif self.heatmap_axis_ranges is None:
1384
+ self.heatmap_source_axis_ranges = {
1385
+ axis: (lower[index], upper[index]) for index, axis in enumerate(("x", "y"))
1386
+ }
1387
+ return axes[0][x_indices], axes[1][y_indices], z_data
1388
+
1389
+
1390
+ def _heatmap_spatial_summary(self, conn, where_sql, parameters):
1391
+ self._check_cancelled()
1392
+ table = _sqlite_identifier(self.table_name)
1393
+ x_column = _sqlite_identifier(self.axes_dict["x"])
1394
+ y_column = _sqlite_identifier(self.axes_dict["y"])
1395
+ row = conn.execute(
1396
+ (
1397
+ f"SELECT COUNT(*), MIN({x_column}), MAX({x_column}), "
1398
+ f"COUNT(DISTINCT {x_column}), MIN({y_column}), "
1399
+ f"MAX({y_column}), COUNT(DISTINCT {y_column}) "
1400
+ f"FROM {table} WHERE {where_sql}"
1401
+ ),
1402
+ parameters,
1403
+ ).fetchone()
1404
+ if row is None or row[0] is None:
1405
+ return (0, None, None, 0, None, None, 0)
1406
+
1407
+ return (
1408
+ int(row[0]),
1409
+ row[1],
1410
+ row[2],
1411
+ int(row[3] or 0),
1412
+ row[4],
1413
+ row[5],
1414
+ int(row[6] or 0),
1415
+ )
1416
+
1417
+
1418
+ def _spatially_aggregated_heatmap_arrays(
1419
+ self,
1420
+ conn,
1421
+ where_sql,
1422
+ parameters,
1423
+ summary,
1424
+ ):
1425
+ (
1426
+ matching_rows,
1427
+ x_min,
1428
+ x_max,
1429
+ x_count,
1430
+ y_min,
1431
+ y_max,
1432
+ y_count,
1433
+ ) = summary
1434
+ if (
1435
+ matching_rows <= 0
1436
+ or x_min is None
1437
+ or x_max is None
1438
+ or y_min is None
1439
+ or y_max is None
1440
+ or x_count <= 0
1441
+ or y_count <= 0
1442
+ ):
1443
+ self.sampled_heatmap_source = False
1444
+ self.aggregated_heatmap_source = False
1445
+ return self._arrays_from_values([], [], [])
1446
+
1447
+ x_bins, y_bins = self._bounded_grid_shape(x_count, y_count)
1448
+ table = _sqlite_identifier(self.table_name)
1449
+ x_column = _sqlite_identifier(self.axes_dict["x"])
1450
+ y_column = _sqlite_identifier(self.axes_dict["y"])
1451
+ z_column = _sqlite_identifier(self.param.name)
1452
+
1453
+ x_exact = x_bins >= x_count
1454
+ y_exact = y_bins >= y_count
1455
+ query_parameters: list[float | int] = []
1456
+ if x_exact:
1457
+ x_centres = np.array([], dtype=float)
1458
+ x_group_sql = x_column
1459
+ else:
1460
+ x_centres, x_lower_edge, x_scale = self._spatial_axis_bins(
1461
+ float(x_min),
1462
+ float(x_max),
1463
+ x_count,
1464
+ x_bins,
1465
+ )
1466
+ x_group_sql = f"MIN(CAST(({x_column} - ?) * ? AS INTEGER), ?)"
1467
+ query_parameters.extend((x_lower_edge, x_scale, x_bins - 1))
1468
+
1469
+ if y_exact:
1470
+ y_centres = np.array([], dtype=float)
1471
+ y_group_sql = y_column
1472
+ else:
1473
+ y_centres, y_lower_edge, y_scale = self._spatial_axis_bins(
1474
+ float(y_min),
1475
+ float(y_max),
1476
+ y_count,
1477
+ y_bins,
1478
+ )
1479
+ y_group_sql = f"MIN(CAST(({y_column} - ?) * ? AS INTEGER), ?)"
1480
+ query_parameters.extend((y_lower_edge, y_scale, y_bins - 1))
1481
+
1482
+ if (
1483
+ (not x_exact and x_centres.size == 0)
1484
+ or (not y_exact and y_centres.size == 0)
1485
+ ):
1486
+ self.sampled_heatmap_source = False
1487
+ self.aggregated_heatmap_source = False
1488
+ return self._arrays_from_values([], [], [])
1489
+
1490
+ grouped_source_sql = (
1491
+ f"SELECT {x_group_sql} AS x_group, {y_group_sql} AS y_group, "
1492
+ f"{z_column} AS z_value FROM {table} WHERE {where_sql}"
1493
+ )
1494
+ cursor = conn.execute(
1495
+ (
1496
+ "SELECT x_group, y_group, AVG(z_value), COUNT(*), "
1497
+ "MIN(z_value), MAX(z_value) FROM ("
1498
+ + grouped_source_sql +
1499
+ ") GROUP BY x_group, y_group ORDER BY y_group, x_group"
1500
+ ),
1501
+ (*query_parameters, *parameters),
1502
+ )
1503
+ x_groups = []
1504
+ y_groups = []
1505
+ z_values: list[float | None] = []
1506
+ failed_means = {}
1507
+ aggregated_source_rows = 0
1508
+ for row_number, (x_group, y_group, z_value, bin_rows, z_min, z_max) in enumerate(cursor):
1509
+ if row_number % 1024 == 0:
1510
+ self._check_cancelled()
1511
+ largest = max(abs(float(z_min)), abs(float(z_max)))
1512
+ safe_sample = (np.finfo(float).max / 2) / int(bin_rows)
1513
+ if (z_value is None or not math.isfinite(z_value) or largest > safe_sample
1514
+ or z_min < 0 < z_max):
1515
+ failed_means[(x_group, y_group)] = (len(z_values), int(bin_rows))
1516
+ x_groups.append(x_group)
1517
+ y_groups.append(y_group)
1518
+ z_values.append(z_value)
1519
+ aggregated_source_rows += int(bin_rows)
1520
+
1521
+ if failed_means:
1522
+ # SQLite AVG can lose a cancellation residual before a risky sum
1523
+ # overflows. Replay all risky groups from the original values in
1524
+ # one bounded pass, even when AVG happened to return finite data.
1525
+ totals = dict.fromkeys(failed_means, Fraction())
1526
+ cursor = conn.execute(grouped_source_sql, (*query_parameters, *parameters))
1527
+ for row_number, (x_group, y_group, value) in enumerate(cursor):
1528
+ if row_number % 1024 == 0:
1529
+ self._check_cancelled()
1530
+ key = (x_group, y_group)
1531
+ if key in totals:
1532
+ totals[key] += Fraction(value)
1533
+ for index, (key, total) in enumerate(totals.items()):
1534
+ if index % 1024 == 0:
1535
+ self._check_cancelled()
1536
+ position, count = failed_means[key]
1537
+ z_values[position] = float(total / count)
1538
+
1539
+ x_group_data = np.asarray(x_groups, dtype=float)
1540
+ y_group_data = np.asarray(y_groups, dtype=float)
1541
+ z_data = np.asarray(z_values, dtype=float)
1542
+ finite = (
1543
+ np.isfinite(x_group_data)
1544
+ & np.isfinite(y_group_data)
1545
+ & np.isfinite(z_data)
1546
+ )
1547
+
1548
+ if x_exact:
1549
+ x_centres = np.unique(x_group_data[np.isfinite(x_group_data)])
1550
+ x_index_data = np.searchsorted(x_centres, x_group_data[finite])
1551
+ else:
1552
+ x_index_data = x_group_data[finite].astype(np.int64)
1553
+ if y_exact:
1554
+ y_centres = np.unique(y_group_data[np.isfinite(y_group_data)])
1555
+ y_index_data = np.searchsorted(y_centres, y_group_data[finite])
1556
+ else:
1557
+ y_index_data = y_group_data[finite].astype(np.int64)
1558
+
1559
+ z_data = z_data[finite]
1560
+ self._spatial_heatmap_axes = (x_centres, y_centres)
1561
+ self._spatial_heatmap_indices = (x_index_data, y_index_data)
1562
+ self._spatial_heatmap_source_unique_counts = (x_count, y_count)
1563
+ self._heatmap_aggregated_source_rows = aggregated_source_rows
1564
+ full_axis_ranges = self._normalised_heatmap_axis_ranges(
1565
+ self.heatmap_full_axis_ranges
1566
+ )
1567
+ if full_axis_ranges is not None:
1568
+ self.heatmap_source_axis_ranges = full_axis_ranges
1569
+ elif self.heatmap_axis_ranges is None:
1570
+ self.heatmap_source_axis_ranges = {
1571
+ "x": (float(x_min), float(x_max)),
1572
+ "y": (float(y_min), float(y_max)),
1573
+ }
1574
+ self.sampled_heatmap_source = False
1575
+ self.aggregated_heatmap_source = True
1576
+ self._heatmap_source_info.update({
1577
+ "estimated_range_rows": matching_rows,
1578
+ "sampled": False,
1579
+ "aggregated": True,
1580
+ "sample_limit": None,
1581
+ "sample_stride": None,
1582
+ "strategy": "spatial mean",
1583
+ })
1584
+ return x_centres[x_index_data], y_centres[y_index_data], z_data
1585
+
1586
+
1587
+ @staticmethod
1588
+ def _spatial_axis_bins(lower, upper, source_count, bin_count):
1589
+ return spatial_axis_bins(lower, upper, source_count, bin_count)
1590
+
1591
+
1592
+ def _selected_parameter_row_count(self, conn):
1593
+ self._check_cancelled()
1594
+ table = _sqlite_identifier(self.table_name)
1595
+ z_column = _sqlite_identifier(self.param.name)
1596
+ row = conn.execute(
1597
+ f"SELECT COUNT(*) FROM {table} WHERE {z_column} IS NOT NULL"
1598
+ ).fetchone()
1599
+ if row is None or row[0] is None:
1600
+ return None
1601
+
1602
+ return int(row[0])
1603
+
1604
+
1605
+ def _heatmap_where_clause(self):
1606
+ ranges = self.heatmap_axis_ranges or {}
1607
+ clauses: list[str] = []
1608
+ parameters: list[float] = []
1609
+
1610
+ for axis in ("x", "y"):
1611
+ axis_range = ranges.get(axis)
1612
+ if axis_range is None:
1613
+ continue
1614
+
1615
+ try:
1616
+ low, high = sorted(float(value) for value in axis_range)
1617
+ except (TypeError, ValueError):
1618
+ continue
1619
+
1620
+ if not (np.isfinite(low) and np.isfinite(high)) or low == high:
1621
+ continue
1622
+
1623
+ column = _sqlite_identifier(self.axes_dict[axis])
1624
+ clauses.append(f"{column} BETWEEN ? AND ?")
1625
+ parameters.extend((low, high))
1626
+
1627
+ return " AND ".join(clauses), parameters
1628
+
1629
+
1630
+ def _normalised_heatmap_axis_ranges(self, ranges):
1631
+ if not ranges:
1632
+ return None
1633
+
1634
+ normalised = {}
1635
+ for axis in ("x", "y"):
1636
+ axis_range = ranges.get(axis)
1637
+ if axis_range is None:
1638
+ return None
1639
+
1640
+ try:
1641
+ low, high = sorted(float(value) for value in axis_range)
1642
+ except (TypeError, ValueError):
1643
+ return None
1644
+
1645
+ if not (np.isfinite(low) and np.isfinite(high)) or low == high:
1646
+ return None
1647
+
1648
+ normalised[axis] = (low, high)
1649
+
1650
+ return normalised
1651
+
1652
+
1653
+ def _arrays_from_cursor(self, cursor):
1654
+ x_values = []
1655
+ y_values = []
1656
+ z_values = []
1657
+
1658
+ for row_number, (x_value, y_value, z_value) in enumerate(cursor):
1659
+ if row_number % 1024 == 0:
1660
+ self._check_cancelled()
1661
+ x_values.append(x_value)
1662
+ y_values.append(y_value)
1663
+ z_values.append(z_value)
1664
+
1665
+ return self._arrays_from_values(x_values, y_values, z_values)
1666
+
1667
+
1668
+ def _reject_complex_heatmap(self, parameter_name=None, *, coordinate=False):
1669
+ if parameter_name is None:
1670
+ parameter_name = getattr(self.param, "name", "measurement")
1671
+ detail = "a coordinate" if coordinate else "measurement data"
1672
+ raise ValueError(
1673
+ "Complex-valued heatmaps are not supported: "
1674
+ f"parameter '{parameter_name}' contains {detail}."
1675
+ )
1676
+
1677
+
1678
+ def _validate_heatmap_parameter_types(self):
1679
+ """Reject declared complex columns before any SQLite arithmetic."""
1680
+
1681
+ for axis in ("x", "y"):
1682
+ name = self.axes_dict[axis]
1683
+ if getattr(self.param_dict[name], "type", None) == "complex":
1684
+ self._reject_complex_heatmap(name, coordinate=True)
1685
+ if getattr(self.param, "type", None) == "complex":
1686
+ self._reject_complex_heatmap()
1687
+
1688
+
1689
+ def _real_heatmap_values(self, values, parameter_name, *, coordinate=False):
1690
+ """Return an array only when conversion to float cannot lose phase."""
1691
+
1692
+ result = np.asarray(values)
1693
+ if np.iscomplexobj(result) or (
1694
+ result.dtype.kind == "O" and any(np.iscomplexobj(value) for value in result.flat)
1695
+ ):
1696
+ self._reject_complex_heatmap(
1697
+ parameter_name,
1698
+ coordinate=coordinate,
1699
+ )
1700
+ if coordinate and result.dtype.kind == "O":
1701
+ # Mixed record dtypes need Python's exact int/float comparisons;
1702
+ # NumPy scalar comparisons may promote a large integer to float.
1703
+ result = np.frompyfunc(
1704
+ lambda value: value.item() if isinstance(value, np.generic) else value,
1705
+ 1, 1,
1706
+ )(result)
1707
+ return result
1708
+
1709
+
1710
+ def _arrays_from_values(self, x_values, y_values, z_values):
1711
+ self._check_cancelled()
1712
+ x_values = self._real_heatmap_values(
1713
+ x_values,
1714
+ self.axes_dict["x"],
1715
+ coordinate=True,
1716
+ )
1717
+ self._check_cancelled()
1718
+ y_values = self._real_heatmap_values(
1719
+ y_values,
1720
+ self.axes_dict["y"],
1721
+ coordinate=True,
1722
+ )
1723
+ self._check_cancelled()
1724
+ z_values = self._real_heatmap_values(
1725
+ z_values,
1726
+ getattr(self.param, "name", "measurement"),
1727
+ )
1728
+ # Broadcast within a decoded record/chunk, before flattening or
1729
+ # masking, so a scalar slow setpoint accompanies every array sample.
1730
+ x_values, y_values, z_values = np.broadcast_arrays(x_values, y_values, z_values)
1731
+ # Keep source coordinates exact until complete records are assembled
1732
+ # and their float representation can be checked for merged positions.
1733
+ x_data = np.asarray(x_values).reshape(-1)
1734
+ self._check_cancelled()
1735
+ y_data = np.asarray(y_values).reshape(-1)
1736
+ self._check_cancelled()
1737
+ z_data = np.asarray(z_values).reshape(-1)
1738
+ self._check_cancelled()
1739
+ finite = numeric_isfinite(x_data) & numeric_isfinite(y_data) & numeric_isfinite(z_data)
1740
+ self._check_cancelled()
1741
+
1742
+ return x_data[finite], y_data[finite], z_data[finite]
1743
+
1744
+
1745
+ @staticmethod
1746
+ def _coordinate_conversion_may_round(values):
1747
+ if values.dtype.kind == "O" or values.dtype.itemsize > 8:
1748
+ return True
1749
+ if values.dtype.kind in "iu" and values.dtype.itemsize == 8:
1750
+ return bool(np.any(values > 2**53) or (
1751
+ values.dtype.kind == "i" and np.any(values < -(2**53))
1752
+ ))
1753
+ return False
1754
+
1755
+
1756
+ def _float_heatmap_coordinates(self, values, parameter_name):
1757
+ """Reject loss of recorded coordinates before grouping or operations."""
1758
+ self._check_cancelled()
1759
+ values = np.asarray(values)
1760
+ if values.dtype.kind == "O":
1761
+ values = np.frompyfunc(
1762
+ lambda value: value.item() if isinstance(value, np.generic) else value,
1763
+ 1, 1,
1764
+ )(values)
1765
+ converted = np.asarray(values, dtype=float)
1766
+ if self._coordinate_conversion_may_round(values):
1767
+ unique = np.unique(values[numeric_isfinite(values)])
1768
+ self._check_cancelled()
1769
+ displayed = np.asarray(unique, dtype=float)
1770
+ if displayed.size > 1 and np.any(displayed[1:] == displayed[:-1]):
1771
+ raise ValueError(
1772
+ f"Heatmap coordinate precision is insufficient: distinct values "
1773
+ f"of parameter '{parameter_name}' collapse at floating-point precision."
1774
+ )
1775
+ # Distinct cells can remain distinct while every centre rounds.
1776
+ # That would silently change operations, cursor values and CSV.
1777
+ # Require lossless conversion even before streaming sets saturate.
1778
+ for index, (original, display) in enumerate(zip(unique, displayed, strict=True)):
1779
+ if index % 1024 == 0:
1780
+ self._check_cancelled()
1781
+ original = original.item() if isinstance(original, np.generic) else original
1782
+ if original != float(display):
1783
+ raise ValueError(
1784
+ f"Heatmap coordinate precision is insufficient: parameter "
1785
+ f"'{parameter_name}' cannot be represented exactly as "
1786
+ "heatmap coordinates."
1787
+ )
1788
+ self._check_cancelled()
1789
+ return converted
1790
+
1791
+
1792
+ def _heatmap_grid_from_arrays(
1793
+ self,
1794
+ x_data,
1795
+ y_data,
1796
+ z_data,
1797
+ *,
1798
+ max_cells=None,
1799
+ ):
1800
+ self._check_cancelled()
1801
+ axes = getattr(self, "axes_dict", {})
1802
+ x_data = self._float_heatmap_coordinates(x_data, axes.get("x", "x"))
1803
+ y_data = self._float_heatmap_coordinates(y_data, axes.get("y", "y"))
1804
+ # Keep recorded values through accumulation and any exact replay.
1805
+ # The bounded display means become floats only after division.
1806
+ z_data = np.asarray(z_data)
1807
+ display_limit = max(1, int(getattr(
1808
+ self, "max_heatmap_grid_cells", MAX_SQL_HEATMAP_GRID_CELLS,
1809
+ )))
1810
+ max_cells = (
1811
+ display_limit if max_cells is None
1812
+ else max(1, min(int(max_cells), display_limit))
1813
+ )
1814
+ if z_data.size == 0:
1815
+ self._heatmap_grid_info = {
1816
+ "unique_x_count": 0,
1817
+ "unique_y_count": 0,
1818
+ "exact_cell_count": 0,
1819
+ "grid_columns": 0,
1820
+ "grid_rows": 0,
1821
+ "grid_cell_count": 0,
1822
+ "grid_binned": False,
1823
+ "grid_cell_limit": max_cells,
1824
+ "empty_bins_filled": False,
1825
+ }
1826
+ return (
1827
+ np.array([], dtype=float),
1828
+ np.array([], dtype=float),
1829
+ np.empty((0, 0), dtype=float),
1830
+ )
1831
+
1832
+ unique_x = np.unique(x_data)
1833
+ self._check_cancelled()
1834
+ unique_y = np.unique(y_data)
1835
+ self._check_cancelled()
1836
+ exact_cells = unique_x.size * unique_y.size
1837
+ source_grid_shape = getattr(self, "heatmap_source_grid_shape", None)
1838
+ if source_grid_shape is None:
1839
+ if getattr(self, "aggregated_heatmap_source", False):
1840
+ source_x_count, source_y_count = (
1841
+ self._spatial_heatmap_source_unique_counts
1842
+ )
1843
+ source_grid_rows = int(source_y_count)
1844
+ source_grid_columns = int(source_x_count)
1845
+ else:
1846
+ source_grid_rows = int(unique_y.size)
1847
+ source_grid_columns = int(unique_x.size)
1848
+ else:
1849
+ source_grid_rows, source_grid_columns = source_grid_shape
1850
+ source_grid_cell_count = int(source_grid_rows * source_grid_columns)
1851
+ if getattr(self, "aggregated_heatmap_source", False):
1852
+ return self._spatial_aggregation_grid(
1853
+ z_data,
1854
+ source_grid_rows,
1855
+ source_grid_columns,
1856
+ )
1857
+
1858
+ if (
1859
+ not getattr(self, "sampled_heatmap_source", False)
1860
+ and self._bounded_grid_shape(
1861
+ unique_x.size, unique_y.size, max_cells=max_cells,
1862
+ ) == (unique_x.size, unique_y.size)
1863
+ ):
1864
+ x_axis, y_axis, data_grid = self._unique_heatmap_grid(
1865
+ x_data,
1866
+ y_data,
1867
+ z_data,
1868
+ unique_x,
1869
+ unique_y,
1870
+ )
1871
+ self._heatmap_grid_info = {
1872
+ "unique_x_count": int(unique_x.size),
1873
+ "unique_y_count": int(unique_y.size),
1874
+ "exact_cell_count": int(exact_cells),
1875
+ "source_grid_columns": int(source_grid_columns),
1876
+ "source_grid_rows": int(source_grid_rows),
1877
+ "source_grid_cell_count": int(source_grid_cell_count),
1878
+ "grid_columns": int(unique_x.size),
1879
+ "grid_rows": int(unique_y.size),
1880
+ "grid_cell_count": int(exact_cells),
1881
+ "grid_binned": False,
1882
+ "grid_cell_limit": max_cells,
1883
+ "empty_bins_filled": False,
1884
+ }
1885
+ return x_axis, y_axis, data_grid
1886
+
1887
+ if getattr(self, "sampled_heatmap_source", False):
1888
+ max_cells = min(
1889
+ max_cells,
1890
+ max(1, int(z_data.size) // SQL_HEATMAP_SAMPLES_PER_CELL),
1891
+ )
1892
+ (
1893
+ x_axis,
1894
+ y_axis,
1895
+ data_grid,
1896
+ empty_bins_filled,
1897
+ ) = self._sampled_overview_grid(
1898
+ x_data,
1899
+ y_data,
1900
+ z_data,
1901
+ unique_x,
1902
+ unique_y,
1903
+ max_cells=max_cells,
1904
+ )
1905
+ else:
1906
+ x_axis, y_axis, data_grid = self._binned_heatmap_grid(
1907
+ x_data,
1908
+ y_data,
1909
+ z_data,
1910
+ unique_x,
1911
+ unique_y,
1912
+ max_cells=max_cells,
1913
+ )
1914
+ empty_bins_filled = False
1915
+ self._heatmap_grid_info = {
1916
+ "unique_x_count": int(unique_x.size),
1917
+ "unique_y_count": int(unique_y.size),
1918
+ "exact_cell_count": int(exact_cells),
1919
+ "source_grid_columns": int(source_grid_columns),
1920
+ "source_grid_rows": int(source_grid_rows),
1921
+ "source_grid_cell_count": int(source_grid_cell_count),
1922
+ "grid_columns": int(x_axis.size),
1923
+ "grid_rows": int(y_axis.size),
1924
+ "grid_cell_count": int(data_grid.size),
1925
+ "grid_binned": True,
1926
+ "grid_cell_limit": int(max_cells),
1927
+ "empty_bins_filled": empty_bins_filled,
1928
+ }
1929
+ return x_axis, y_axis, data_grid
1930
+
1931
+
1932
+ def _spatial_aggregation_grid(
1933
+ self,
1934
+ z_data,
1935
+ source_grid_rows,
1936
+ source_grid_columns,
1937
+ ):
1938
+ self._check_cancelled()
1939
+ x_axis, y_axis = self._spatial_heatmap_axes
1940
+ x_indices, y_indices = self._spatial_heatmap_indices
1941
+ data_grid = np.full(
1942
+ (y_axis.size, x_axis.size),
1943
+ np.nan,
1944
+ dtype=float,
1945
+ )
1946
+ data_grid[y_indices, x_indices] = z_data
1947
+ self._check_cancelled()
1948
+
1949
+ source_x_count, source_y_count = self._spatial_heatmap_source_unique_counts
1950
+ source_grid_cell_count = int(source_grid_rows * source_grid_columns)
1951
+ self._heatmap_grid_info = {
1952
+ "unique_x_count": int(source_x_count),
1953
+ "unique_y_count": int(source_y_count),
1954
+ "exact_cell_count": int(source_x_count * source_y_count),
1955
+ "source_grid_columns": int(source_grid_columns),
1956
+ "source_grid_rows": int(source_grid_rows),
1957
+ "source_grid_cell_count": source_grid_cell_count,
1958
+ "grid_columns": int(x_axis.size),
1959
+ "grid_rows": int(y_axis.size),
1960
+ "grid_cell_count": int(data_grid.size),
1961
+ "grid_binned": (
1962
+ x_axis.size < source_x_count or y_axis.size < source_y_count
1963
+ ),
1964
+ "grid_cell_limit": getattr(
1965
+ self,
1966
+ "max_heatmap_grid_cells",
1967
+ MAX_SQL_HEATMAP_GRID_CELLS,
1968
+ ),
1969
+ # Spatial aggregation averages only observations assigned to each
1970
+ # cell. Empty cells represent coordinate pairs that were never
1971
+ # measured and must remain missing.
1972
+ "empty_bins_filled": False,
1973
+ }
1974
+ # Saturated array axes carry only a lower cardinality bound for
1975
+ # choosing bins. Do not present that bound as an exact source size.
1976
+ cardinality_exact = getattr(self, "_array_heatmap_cardinality_exact", (True, True))
1977
+ for axis, dimension, is_exact in zip(
1978
+ ("x", "y"), ("columns", "rows"), cardinality_exact, strict=True):
1979
+ if not is_exact:
1980
+ self._heatmap_grid_info[f"unique_{axis}_count"] = None
1981
+ self._heatmap_grid_info["exact_cell_count"] = None
1982
+ if self.heatmap_source_grid_shape is None:
1983
+ self._heatmap_grid_info[f"source_grid_{dimension}"] = None
1984
+ self._heatmap_grid_info["source_grid_cell_count"] = None
1985
+ return x_axis, y_axis, data_grid
1986
+
1987
+
1988
+ def _heatmap_downsample_info(self):
1989
+ source_info = getattr(self, "_heatmap_source_info", None) or {}
1990
+ grid_info = getattr(self, "_heatmap_grid_info", None) or {}
1991
+ source_sampled = bool(source_info.get("sampled", False))
1992
+ source_aggregated = bool(source_info.get("aggregated", False))
1993
+ grid_reduced = bool(grid_info.get("grid_binned", False))
1994
+ source_grid_columns = grid_info.get("source_grid_columns")
1995
+ source_grid_rows = grid_info.get("source_grid_rows")
1996
+ grid_columns = grid_info.get("grid_columns")
1997
+ grid_rows = grid_info.get("grid_rows")
1998
+ if (
1999
+ source_grid_columns is not None
2000
+ and source_grid_rows is not None
2001
+ and grid_columns is not None
2002
+ and grid_rows is not None
2003
+ ):
2004
+ grid_reduced = grid_reduced or (
2005
+ int(grid_columns) < int(source_grid_columns)
2006
+ or int(grid_rows) < int(source_grid_rows)
2007
+ )
2008
+ if not source_sampled and not source_aggregated and not grid_reduced:
2009
+ return None
2010
+
2011
+ return {
2012
+ "source_row_count": source_info.get("row_count"),
2013
+ "estimated_range_rows": source_info.get("estimated_range_rows"),
2014
+ "loaded_point_count": getattr(self, "loaded_point_count", None),
2015
+ "source_sampled": source_sampled,
2016
+ "source_aggregated": source_aggregated,
2017
+ "aggregated_source_row_count": getattr(
2018
+ self,
2019
+ "_heatmap_aggregated_source_rows",
2020
+ None,
2021
+ ),
2022
+ "source_sample_limit": source_info.get("sample_limit"),
2023
+ "source_sample_stride": source_info.get("sample_stride"),
2024
+ "source_sample_strategy": (
2025
+ source_info.get("strategy") if source_sampled else None
2026
+ ),
2027
+ "source_aggregation_strategy": (
2028
+ source_info.get("strategy") if source_aggregated else None
2029
+ ),
2030
+ "axis_ranges": source_info.get("axis_ranges"),
2031
+ "unique_x_count": grid_info.get("unique_x_count"),
2032
+ "unique_y_count": grid_info.get("unique_y_count"),
2033
+ "exact_cell_count": grid_info.get("exact_cell_count"),
2034
+ "source_grid_columns": source_grid_columns,
2035
+ "source_grid_rows": source_grid_rows,
2036
+ "source_grid_cell_count": grid_info.get("source_grid_cell_count"),
2037
+ "source_dimensions_limited": (
2038
+ not all(getattr(self, "_array_heatmap_cardinality_exact", (True, True)))
2039
+ and (source_grid_columns is None or source_grid_rows is None)
2040
+ ),
2041
+ "grid_columns": grid_info.get("grid_columns"),
2042
+ "grid_rows": grid_info.get("grid_rows"),
2043
+ "grid_cell_count": grid_info.get("grid_cell_count"),
2044
+ "grid_binned": grid_reduced,
2045
+ "grid_cell_limit": grid_info.get("grid_cell_limit"),
2046
+ "full_resolution_point_limit": getattr(
2047
+ self,
2048
+ "max_full_heatmap_points",
2049
+ MAX_FULL_HEATMAP_POINTS,
2050
+ ),
2051
+ "empty_bins_filled": bool(grid_info.get("empty_bins_filled", False)),
2052
+ }
2053
+
2054
+
2055
+ def _heatmap_source_grid_shape_from_metadata(self):
2056
+ try:
2057
+ shape = self._parameter_shape()
2058
+ except (AttributeError, TypeError, ValueError):
2059
+ return None
2060
+
2061
+ if shape is None:
2062
+ return None
2063
+
2064
+ try:
2065
+ dimensions = [int(dimension) for dimension in shape]
2066
+ except (TypeError, ValueError):
2067
+ return None
2068
+
2069
+ depends_on = list(getattr(self.param, "depends_on_", ()))
2070
+ if len(dimensions) != len(depends_on):
2071
+ return None
2072
+
2073
+ try:
2074
+ x_dimension = depends_on.index(self.axes_dict["x"])
2075
+ y_dimension = depends_on.index(self.axes_dict["y"])
2076
+ except (KeyError, ValueError):
2077
+ return None
2078
+
2079
+ if (
2080
+ x_dimension >= len(dimensions)
2081
+ or y_dimension >= len(dimensions)
2082
+ or dimensions[x_dimension] <= 0
2083
+ or dimensions[y_dimension] <= 0
2084
+ ):
2085
+ return None
2086
+
2087
+ return dimensions[y_dimension], dimensions[x_dimension]
2088
+
2089
+
2090
+ def _unique_heatmap_grid(self, x_data, y_data, z_data, unique_x, unique_y):
2091
+ self._check_cancelled()
2092
+ x_index = np.searchsorted(unique_x, x_data)
2093
+ self._check_cancelled()
2094
+ y_index = np.searchsorted(unique_y, y_data)
2095
+ means = FiniteBinMeans((unique_y.size, unique_x.size), self._check_cancelled)
2096
+ for start in range(0, z_data.size, CANCELLATION_CHUNK_SIZE):
2097
+ self._check_cancelled()
2098
+ stop = start + CANCELLATION_CHUNK_SIZE
2099
+ indices = (y_index[start:stop], x_index[start:stop])
2100
+ means.add(indices, z_data[start:stop])
2101
+ if means.begin_exact_replay():
2102
+ for start in range(0, z_data.size, CANCELLATION_CHUNK_SIZE):
2103
+ self._check_cancelled()
2104
+ stop = start + CANCELLATION_CHUNK_SIZE
2105
+ means.replay((y_index[start:stop], x_index[start:stop]), z_data[start:stop])
2106
+ data_grid = means.means()
2107
+
2108
+ return unique_x, unique_y, data_grid
2109
+
2110
+
2111
+ def _binned_heatmap_grid(
2112
+ self,
2113
+ x_data,
2114
+ y_data,
2115
+ z_data,
2116
+ unique_x,
2117
+ unique_y,
2118
+ max_cells=None,
2119
+ ):
2120
+ self._check_cancelled()
2121
+ x_bins, y_bins = self._bounded_grid_shape(
2122
+ unique_x.size,
2123
+ unique_y.size,
2124
+ max_cells=max_cells,
2125
+ )
2126
+ x_centres, x_index = self._heatmap_axis_indices(
2127
+ x_data,
2128
+ unique_x,
2129
+ x_bins,
2130
+ self._grid_axis_bounds("x"),
2131
+ )
2132
+ y_centres, y_index = self._heatmap_axis_indices(
2133
+ y_data,
2134
+ unique_y,
2135
+ y_bins,
2136
+ self._grid_axis_bounds("y"),
2137
+ )
2138
+ self._check_cancelled()
2139
+
2140
+ means = FiniteBinMeans((y_centres.size, x_centres.size), self._check_cancelled)
2141
+ for start in range(0, z_data.size, CANCELLATION_CHUNK_SIZE):
2142
+ self._check_cancelled()
2143
+ stop = start + CANCELLATION_CHUNK_SIZE
2144
+ indices = (y_index[start:stop], x_index[start:stop])
2145
+ means.add(indices, z_data[start:stop])
2146
+
2147
+ if means.begin_exact_replay():
2148
+ for start in range(0, z_data.size, CANCELLATION_CHUNK_SIZE):
2149
+ self._check_cancelled()
2150
+ stop = start + CANCELLATION_CHUNK_SIZE
2151
+ means.replay((y_index[start:stop], x_index[start:stop]), z_data[start:stop])
2152
+
2153
+ self._check_cancelled()
2154
+ data_grid = means.means()
2155
+
2156
+ return x_centres, y_centres, data_grid
2157
+
2158
+
2159
+ def _heatmap_axis_indices(self, values, unique_values, bin_count, bounds=None):
2160
+ """Preserve an axis exactly unless its own cardinality needs binning."""
2161
+
2162
+ self._check_cancelled()
2163
+ if unique_values.size <= bin_count:
2164
+ indices = np.searchsorted(unique_values, values)
2165
+ self._check_cancelled()
2166
+ return unique_values, indices
2167
+
2168
+ return self._scaled_axis_indices(values, bin_count, bounds)
2169
+
2170
+
2171
+ def _sampled_overview_grid(
2172
+ self,
2173
+ x_data,
2174
+ y_data,
2175
+ z_data,
2176
+ unique_x,
2177
+ unique_y,
2178
+ *,
2179
+ max_cells,
2180
+ ):
2181
+ """Build a sampled overview whose display-only gaps are interpolated."""
2182
+
2183
+ x_axis, y_axis, data_grid = self._binned_heatmap_grid(
2184
+ x_data,
2185
+ y_data,
2186
+ z_data,
2187
+ unique_x,
2188
+ unique_y,
2189
+ max_cells=max_cells,
2190
+ )
2191
+ empty_bins_filled = bool(
2192
+ np.any(~np.isfinite(data_grid))
2193
+ and np.any(np.isfinite(data_grid))
2194
+ )
2195
+ if empty_bins_filled:
2196
+ data_grid = self._fill_empty_heatmap_bins(data_grid)
2197
+
2198
+ return x_axis, y_axis, data_grid, empty_bins_filled
2199
+
2200
+
2201
+ def _fill_empty_heatmap_bins(self, data_grid):
2202
+ self._check_cancelled()
2203
+ if data_grid.size == 0 or np.all(np.isfinite(data_grid)):
2204
+ return data_grid
2205
+ if not np.any(np.isfinite(data_grid)):
2206
+ return data_grid
2207
+
2208
+ filled = np.array(data_grid, dtype=float, copy=True)
2209
+ row_positions = np.arange(filled.shape[0], dtype=float)
2210
+ column_positions = np.arange(filled.shape[1], dtype=float)
2211
+
2212
+ for column in range(filled.shape[1]):
2213
+ self._check_cancelled()
2214
+ values = filled[:, column]
2215
+ finite = np.isfinite(values)
2216
+ if np.any(finite) and not np.all(finite):
2217
+ values[~finite] = np.interp(
2218
+ row_positions[~finite],
2219
+ row_positions[finite],
2220
+ values[finite],
2221
+ )
2222
+
2223
+ for row in range(filled.shape[0]):
2224
+ self._check_cancelled()
2225
+ values = filled[row, :]
2226
+ finite = np.isfinite(values)
2227
+ if np.any(finite) and not np.all(finite):
2228
+ values[~finite] = np.interp(
2229
+ column_positions[~finite],
2230
+ column_positions[finite],
2231
+ values[finite],
2232
+ )
2233
+
2234
+ return filled
2235
+
2236
+
2237
+ def _grid_axis_bounds(self, axis):
2238
+ ranges = getattr(self, "heatmap_axis_ranges", None) or {}
2239
+ axis_range = ranges.get(axis)
2240
+ if axis_range is None:
2241
+ return None
2242
+
2243
+ try:
2244
+ low, high = sorted(float(value) for value in axis_range)
2245
+ except (TypeError, ValueError):
2246
+ return None
2247
+
2248
+ if not (np.isfinite(low) and np.isfinite(high)) or low == high:
2249
+ return None
2250
+
2251
+ return low, high
2252
+
2253
+
2254
+ def _bounded_grid_shape(self, x_count, y_count, max_cells=None):
2255
+ max_cells = (
2256
+ getattr(self, "max_heatmap_grid_cells", MAX_SQL_HEATMAP_GRID_CELLS)
2257
+ if max_cells is None
2258
+ else int(max_cells)
2259
+ )
2260
+ max_side = getattr(
2261
+ self,
2262
+ "max_heatmap_grid_side",
2263
+ MAX_SQL_HEATMAP_GRID_SIDE,
2264
+ )
2265
+ return bounded_grid_shape(x_count, y_count, max_cells=max_cells, max_side=max_side)
2266
+
2267
+
2268
+ def _scaled_axis_indices(self, values, bin_count, bounds=None):
2269
+ self._check_cancelled()
2270
+ if bounds is None:
2271
+ lower = float(np.nanmin(values))
2272
+ upper = float(np.nanmax(values))
2273
+ else:
2274
+ lower, upper = bounds
2275
+
2276
+ if not np.isfinite(lower) or not np.isfinite(upper):
2277
+ return np.array([], dtype=float), np.array([], dtype=np.int64)
2278
+
2279
+ if lower == upper or bin_count <= 1:
2280
+ return (
2281
+ np.array([lower], dtype=float),
2282
+ np.zeros(values.size, dtype=np.int64),
2283
+ )
2284
+
2285
+ scaled = (values - lower) / (upper - lower)
2286
+ self._check_cancelled()
2287
+ indices = np.floor(scaled * bin_count).astype(np.int64)
2288
+ self._check_cancelled()
2289
+ indices = np.clip(indices, 0, bin_count - 1)
2290
+ step = (upper - lower) / bin_count
2291
+ centres = lower + (np.arange(bin_count, dtype=float) + 0.5) * step
2292
+
2293
+ return centres, indices
2294
+
2295
+
2296
+ def _reject_complex_line(self, parameter_name):
2297
+ detail = (
2298
+ "measurement data" if parameter_name == self.param.name
2299
+ else "a coordinate"
2300
+ )
2301
+ raise ValueError(
2302
+ "Complex-valued 1D plots are not supported: "
2303
+ f"parameter '{parameter_name}' contains {detail}."
2304
+ )
2305
+
2306
+
2307
+ def _real_line_values(self, values, parameter_name):
2308
+ """Reject complex samples without converting or changing raw data."""
2309
+
2310
+ result = np.asarray(values)
2311
+ if np.iscomplexobj(result) or (
2312
+ result.dtype.kind == "O" and any(np.iscomplexobj(value) for value in result.flat)
2313
+ ):
2314
+ self._reject_complex_line(parameter_name)
2315
+ return result
2316
+
2317
+
2318
+ def for_1d(self, data, valid_rows):
2319
+ self._check_cancelled()
2320
+ axis_data = {}
2321
+ axis_param = {}
2322
+ x_name = self.axes_dict["x"]
2323
+ y_name = (
2324
+ self.param.name
2325
+ if x_name != self.param.name
2326
+ else self.param.depends_on_[0]
2327
+ )
2328
+ # Flatten both arrays with the same mask in QCoDeS record order.
2329
+ # Sorting or uniquing either axis would break sample alignment.
2330
+ x_values = self._real_line_values(data[x_name], x_name)
2331
+ y_values = self._real_line_values(data[y_name], y_name)
2332
+ if x_values.shape != y_values.shape:
2333
+ raise ValueError("1D setpoint and measurement shapes do not match")
2334
+ valid_rows = (
2335
+ np.asarray(valid_rows)
2336
+ & ~numeric_isnan(x_values)
2337
+ & ~numeric_isnan(y_values)
2338
+ )
2339
+ axis_data["x"] = x_values[valid_rows].reshape(-1)
2340
+ self._check_cancelled()
2341
+ axis_param["x"] = self.param_dict[x_name]
2342
+ axis_data["y"] = y_values[valid_rows].reshape(-1)
2343
+ self._check_cancelled()
2344
+ axis_param["y"] = self.param_dict[y_name]
2345
+
2346
+ return axis_data, axis_param
2347
+
2348
+
2349
+ def for_shaped_2d(self, data, depvarData, *, acquired=None):
2350
+ self._check_cancelled()
2351
+ axis_data = {}
2352
+ axis_param = {}
2353
+ axis_dimension = {}
2354
+ valid = {}
2355
+ shaped_axes_are_rectilinear = True
2356
+ depvarData = self._real_heatmap_values(
2357
+ depvarData,
2358
+ getattr(self.param, "name", "measurement"),
2359
+ )
2360
+ if acquired is not None and not np.all(acquired):
2361
+ # Plotting-only missing values preserve the planned shape and
2362
+ # source dtypes. Boolean values fit exactly in float cells, while
2363
+ # integers need object cells to preserve their precision with NaN.
2364
+ # Never infer acquisition from zeros or completion.
2365
+ missing_dtype = depvarData.dtype
2366
+ if missing_dtype.kind in "iuO":
2367
+ missing_dtype = object
2368
+ elif missing_dtype.kind == "b":
2369
+ missing_dtype = float
2370
+ depvarData = depvarData.astype(
2371
+ missing_dtype,
2372
+ copy=True,
2373
+ )
2374
+ depvarData[~acquired] = np.nan
2375
+ self._check_cancelled()
2376
+
2377
+ # Find correct data for each axis
2378
+ for axis in ["x", "y"]:
2379
+ self._check_cancelled()
2380
+ name = self.axes_dict[axis]
2381
+ param = self.param_dict[name]
2382
+
2383
+ param_data = self._real_heatmap_values(
2384
+ data[name],
2385
+ name,
2386
+ coordinate=True,
2387
+ )
2388
+ if acquired is not None and not np.all(acquired):
2389
+ # Unwritten coordinates must not choose axis representatives
2390
+ # or make a rectilinear acquired prefix appear serpentine.
2391
+ if param_data.dtype.kind in "iuO":
2392
+ param_data = param_data.astype(object)
2393
+ param_data = np.where(acquired, param_data, np.nan)
2394
+ param_data = self._float_heatmap_coordinates(param_data, name)
2395
+ dimension = self._shaped_axis_dimension(name, param_data, depvarData)
2396
+ shaped_axes_are_rectilinear &= self._shaped_axis_is_rectilinear(
2397
+ param_data,
2398
+ dimension,
2399
+ )
2400
+ param_data = self._shaped_axis_values(param_data, dimension)
2401
+
2402
+ valid[axis] = np.isfinite(param_data)
2403
+ axis_data[axis] = param_data[valid[axis]]
2404
+ axis_param[axis] = param
2405
+ axis_dimension[axis] = dimension
2406
+
2407
+ # Snake scans and repeated setpoints need coordinate-based assembly.
2408
+ # In particular, repeated cells must retain the unshaped path's mean.
2409
+ if (
2410
+ not shaped_axes_are_rectilinear
2411
+ or axis_dimension["x"] == axis_dimension["y"]
2412
+ or self._requires_bounded_heatmap(
2413
+ int(axis_data["x"].size) * int(axis_data["y"].size)
2414
+ )
2415
+ or any(
2416
+ np.unique(axis_data[axis]).size != axis_data[axis].size
2417
+ for axis in ("x", "y")
2418
+ )
2419
+ ):
2420
+ valid_rows = numeric_isfinite(depvarData)
2421
+ for axis in ("x", "y"):
2422
+ name = self.axes_dict[axis]
2423
+ valid_rows &= np.isfinite(np.asarray(data[name], dtype=float))
2424
+ return self.for_unshaped_2d(data, valid_rows, depvarData)
2425
+
2426
+ dataGrid = self._shaped_data_grid(
2427
+ data,
2428
+ depvarData,
2429
+ axis_dimension,
2430
+ valid,
2431
+ )
2432
+
2433
+ return axis_data, axis_param, dataGrid
2434
+
2435
+
2436
+ def _shaped_axis_dimension(self, name, param_data, depvarData):
2437
+ depends_on = list(getattr(self.param, "depends_on_", ()))
2438
+ if (
2439
+ param_data.shape == depvarData.shape
2440
+ and len(depends_on) == depvarData.ndim
2441
+ and name in depends_on
2442
+ ):
2443
+ return depends_on.index(name)
2444
+
2445
+ residuals = [
2446
+ self._shaped_axis_residual(param_data, dimension)
2447
+ for dimension in range(depvarData.ndim)
2448
+ ]
2449
+ return int(np.nanargmin(residuals))
2450
+
2451
+
2452
+ def _shaped_axis_values(self, param_data, dimension):
2453
+ """Choose axis representatives without implying validity of samples."""
2454
+ self._check_cancelled()
2455
+ moved = np.moveaxis(param_data, dimension, 0)
2456
+ rows = moved.reshape(moved.shape[0], -1)
2457
+ values = np.full(rows.shape[0], np.nan, dtype=float)
2458
+
2459
+ for index, row in enumerate(rows):
2460
+ if index % 1024 == 0:
2461
+ self._check_cancelled()
2462
+ finite = np.flatnonzero(np.isfinite(row))
2463
+ if finite.size:
2464
+ values[index] = row[finite[0]]
2465
+
2466
+ return values
2467
+
2468
+
2469
+ def _shaped_axis_residual(self, param_data, dimension):
2470
+ self._check_cancelled()
2471
+ values = self._shaped_axis_values(param_data, dimension)
2472
+ shape = [1] * param_data.ndim
2473
+ shape[dimension] = values.size
2474
+ expected = np.broadcast_to(values.reshape(shape), param_data.shape)
2475
+ valid = np.isfinite(param_data) & np.isfinite(expected)
2476
+ if not np.any(valid):
2477
+ return np.inf
2478
+
2479
+ return float(np.nanmax(np.abs(param_data[valid] - expected[valid])))
2480
+
2481
+
2482
+ def _shaped_axis_is_rectilinear(self, param_data, dimension):
2483
+ """Check finite coordinates; missing samples can still be rectilinear."""
2484
+ self._check_cancelled()
2485
+ values = self._shaped_axis_values(param_data, dimension)
2486
+ shape = [1] * param_data.ndim
2487
+ shape[dimension] = values.size
2488
+ expected = np.broadcast_to(values.reshape(shape), param_data.shape)
2489
+ valid = np.isfinite(param_data) & np.isfinite(expected)
2490
+ if not np.any(valid):
2491
+ return True
2492
+
2493
+ # Compare positions relative to the sweep itself. Using a relative
2494
+ # tolerance on the raw coordinates makes the tolerance grow with an
2495
+ # arbitrary offset (for example, a GHz carrier), and can therefore
2496
+ # hide a genuine sub-Hz serpentine reversal.
2497
+ finite_values = values[np.isfinite(values)]
2498
+ origin = finite_values[0]
2499
+ centred_values = finite_values - origin
2500
+ span = float(np.max(np.abs(centred_values)))
2501
+ if not np.isfinite(span) or span == 0:
2502
+ span = 1.0
2503
+
2504
+ return bool(np.all(np.isclose(
2505
+ (param_data[valid] - origin) / span,
2506
+ (expected[valid] - origin) / span,
2507
+ rtol=1e-10,
2508
+ atol=1e-12,
2509
+ )))
2510
+
2511
+
2512
+ def _shaped_data_grid(self, data, depvarData, axis_dimension, valid):
2513
+ self._check_cancelled()
2514
+ x_dimension = axis_dimension["x"]
2515
+ y_dimension = axis_dimension["y"]
2516
+
2517
+ # Axis representatives may come from another row or column. Retain
2518
+ # each sample's recorded coordinate validity before using those axes
2519
+ # to publish the grid or pass it to operations.
2520
+ valid_rows = numeric_isfinite(depvarData)
2521
+ for axis in ["x", "y"]:
2522
+ self._check_cancelled()
2523
+ name = self.axes_dict[axis]
2524
+ valid_rows &= np.isfinite(np.asarray(data[name], dtype=float))
2525
+ self._check_cancelled()
2526
+
2527
+ if {x_dimension, y_dimension} == {0, 1}:
2528
+ selection = (
2529
+ np.ix_(valid["y"], valid["x"])
2530
+ if x_dimension == 1
2531
+ else np.ix_(valid["x"], valid["y"])
2532
+ )
2533
+ # Advanced indexing creates a private grid, leaving the QCoDeS
2534
+ # cache intact for later loads and different axis selections.
2535
+ data_grid = depvarData[selection]
2536
+ self._check_cancelled()
2537
+ invalid = ~valid_rows[selection]
2538
+ if np.any(invalid):
2539
+ if data_grid.dtype.kind in "iu":
2540
+ data_grid = data_grid.astype(object)
2541
+ elif data_grid.dtype.kind == "b":
2542
+ data_grid = data_grid.astype(float)
2543
+ data_grid[invalid] = np.nan
2544
+ self._check_cancelled()
2545
+ return data_grid if x_dimension == 1 else data_grid.transpose()
2546
+
2547
+ return self.for_unshaped_2d(data, valid_rows, depvarData)[2]
2548
+
2549
+
2550
+ def for_unshaped_2d(self, data, valid_rows, depvarData):
2551
+ self._check_cancelled()
2552
+ axis_data = {}
2553
+ axis_param = {}
2554
+ for axis in ["x", "y"]:
2555
+ self._check_cancelled()
2556
+ # Get specific parameter
2557
+ name = self.axes_dict[axis]
2558
+ param = self.param_dict[name]
2559
+
2560
+ # Update data
2561
+ axis_data[axis] = data[name][valid_rows]
2562
+ axis_param[axis] = param
2563
+
2564
+ x_data, y_data, z_data = self._arrays_from_values(
2565
+ axis_data["x"], axis_data["y"], depvarData[valid_rows],
2566
+ )
2567
+ x_data = self._float_heatmap_coordinates(x_data, self.axes_dict["x"])
2568
+ y_data = self._float_heatmap_coordinates(y_data, self.axes_dict["y"])
2569
+ # Source rows and planned shapes do not bound a pivot: even a short
2570
+ # diagonal scan creates unique-X * unique-Y cells. Count only paired,
2571
+ # valid samples, including when a shaped scan falls back to this path.
2572
+ x_count = int(np.unique(x_data).size)
2573
+ self._check_cancelled()
2574
+ y_count = int(np.unique(y_data).size)
2575
+ self._check_cancelled()
2576
+ if self._requires_bounded_heatmap(x_count * y_count):
2577
+ self.heatmap_source_grid_shape = (y_count, x_count)
2578
+ self.heatmap_source_axis_ranges = {
2579
+ "x": (float(np.min(x_data)), float(np.max(x_data))),
2580
+ "y": (float(np.min(y_data)), float(np.max(y_data))),
2581
+ }
2582
+ self.sampled_heatmap_source = False
2583
+ self.aggregated_heatmap_source = False
2584
+ x_axis, y_axis, data_grid = self._heatmap_grid_from_arrays(
2585
+ x_data, y_data, z_data,
2586
+ max_cells=getattr(
2587
+ self, "max_full_heatmap_points", MAX_FULL_HEATMAP_POINTS,
2588
+ ),
2589
+ )
2590
+ self.loaded_point_count = int(z_data.size)
2591
+ self._heatmap_aggregated_source_rows = int(z_data.size)
2592
+ self._heatmap_source_info = {
2593
+ "row_count": int(z_data.size),
2594
+ "estimated_range_rows": int(z_data.size),
2595
+ "sampled": False,
2596
+ "aggregated": True,
2597
+ "strategy": "spatial mean",
2598
+ "axis_ranges": None,
2599
+ }
2600
+ self.heatmap_downsample_info = self._heatmap_downsample_info()
2601
+ return {"x": x_axis, "y": y_axis}, axis_param, data_grid
2602
+
2603
+ dataGrid = data2matrix(
2604
+ y_data,
2605
+ x_data,
2606
+ z_data,
2607
+ check_cancelled=self._check_cancelled,
2608
+ )
2609
+ self._check_cancelled()
2610
+
2611
+ # remove duplicates
2612
+ axis_data["y"] = dataGrid.index.to_numpy(float)
2613
+ axis_data["x"] = dataGrid.columns.to_numpy(float)
2614
+
2615
+ dataGrid = dataGrid.to_numpy()
2616
+
2617
+ return axis_data, axis_param, dataGrid
2618
+
2619
+
2620
+ def do_operations(self):
2621
+ """
2622
+ Runs through all functions in self.operations and performs those on the
2623
+ data.
2624
+ Work is performed on copies so a failure cannot return partial output.
2625
+
2626
+ Returns
2627
+ -------
2628
+ data_dict["x"], data_dict["y"], data_dict["z"] : np.ndarray
2629
+ The updated data after all operations have been performed
2630
+ None : NoneType
2631
+ No operations to perform.
2632
+
2633
+ """
2634
+ operations = self.operations
2635
+ if len(operations) == 0:
2636
+ return None
2637
+
2638
+ self._check_cancelled()
2639
+ data_dict = {
2640
+ "x" : self.axis_data["x"].copy(),
2641
+ "y" : None,
2642
+ "z" : None,
2643
+ }
2644
+ self._check_cancelled()
2645
+ data_dict["y"] = self.axis_data["y"].copy()
2646
+ self._check_cancelled()
2647
+ if hasattr(self, "dataGrid"):
2648
+ data_dict["z"] = self.dataGrid.copy()
2649
+ self._check_cancelled()
2650
+
2651
+ for operation in operations:
2652
+ self._check_cancelled()
2653
+ try:
2654
+ if isinstance(operation, OperationCall):
2655
+ results = operation.execute(data_dict, self.is_cancelled)
2656
+ else:
2657
+ # Backwards compatibility: arbitrary operations continue
2658
+ # to receive exactly one data dictionary argument. Such a
2659
+ # call can only be cancelled after it returns.
2660
+ results = operation(data_dict)
2661
+ self._check_cancelled()
2662
+ for key in results.keys():
2663
+ data_dict[key] = results[key]
2664
+ except Exception as err:
2665
+ if self.is_cancelled():
2666
+ raise PlotWorkCancelled("Plot load cancelled.") from err
2667
+ name = getattr(operation, "name", None)
2668
+ description = f' "{name}"' if name else ""
2669
+ raise OperationExecutionError(
2670
+ f"Operation{description} failed: {err}"
2671
+ ) from err
2672
+
2673
+ return data_dict["x"], data_dict["y"], data_dict["z"]
2674
+
2675
+
2676
+ def _apply_operation_metadata(self):
2677
+ """Update the dependent-variable label and unit after derivatives."""
2678
+
2679
+ is_line_plot = len(getattr(self.param, "depends_on_", ())) == 1
2680
+ if is_line_plot:
2681
+ self.display_param = copy(self.axis_param["y"])
2682
+
2683
+ for operation in self.operations:
2684
+ axis_name = getattr(operation, "derivative_axis", None)
2685
+ if axis_name not in ("x", "y"):
2686
+ continue
2687
+
2688
+ axis_param = self.axis_param[axis_name]
2689
+ value_label = (
2690
+ getattr(self.display_param, "label", "")
2691
+ or getattr(self.display_param, "name", "")
2692
+ )
2693
+ axis_label = (
2694
+ getattr(axis_param, "label", "")
2695
+ or getattr(axis_param, "name", axis_name)
2696
+ )
2697
+ self.display_param.label = f"d({value_label})/d({axis_label})"
2698
+
2699
+ value_unit = getattr(self.display_param, "unit", "")
2700
+ axis_unit = getattr(axis_param, "unit", "")
2701
+ if value_unit and axis_unit:
2702
+ self.display_param.unit = f"{value_unit}/{axis_unit}"
2703
+ elif axis_unit:
2704
+ self.display_param.unit = f"1/{axis_unit}"
2705
+
2706
+ if is_line_plot:
2707
+ self.axis_param["y"] = self.display_param
2708
+
2709
+
2710
+ def _aggregate_operated_heatmap_if_needed(self):
2711
+ """Reduce an operated raw heatmap to the configured display limit."""
2712
+
2713
+ self._check_cancelled()
2714
+ if not self.operations or not hasattr(self, "dataGrid"):
2715
+ return
2716
+
2717
+ data_grid = np.asarray(self.dataGrid, dtype=float)
2718
+ self._check_cancelled()
2719
+ if data_grid.size <= self.max_full_heatmap_points:
2720
+ return
2721
+
2722
+ x_axis = np.asarray(self.axis_data["x"], dtype=float)
2723
+ self._check_cancelled()
2724
+ y_axis = np.asarray(self.axis_data["y"], dtype=float)
2725
+ if data_grid.shape != (y_axis.size, x_axis.size):
2726
+ raise ValueError(
2727
+ "Operated heatmap dimensions do not match its coordinate axes."
2728
+ )
2729
+
2730
+ source_rows, source_columns = data_grid.shape
2731
+ self.heatmap_source_grid_shape = (source_rows, source_columns)
2732
+ self.heatmap_source_axis_ranges = {
2733
+ "x": (float(np.nanmin(x_axis)), float(np.nanmax(x_axis))),
2734
+ "y": (float(np.nanmin(y_axis)), float(np.nanmax(y_axis))),
2735
+ }
2736
+ x_data = np.tile(x_axis, y_axis.size)
2737
+ self._check_cancelled()
2738
+ y_data = np.repeat(y_axis, x_axis.size)
2739
+ self._check_cancelled()
2740
+ z_data = data_grid.reshape(-1)
2741
+ finite = np.isfinite(x_data) & np.isfinite(y_data) & np.isfinite(z_data)
2742
+ self._check_cancelled()
2743
+ x_data = x_data[finite]
2744
+ y_data = y_data[finite]
2745
+ z_data = z_data[finite]
2746
+
2747
+ max_cells = min(
2748
+ self.max_full_heatmap_points,
2749
+ getattr(
2750
+ self,
2751
+ "max_heatmap_grid_cells",
2752
+ MAX_SQL_HEATMAP_GRID_CELLS,
2753
+ ),
2754
+ )
2755
+ x_axis, y_axis, data_grid = self._heatmap_grid_from_arrays(
2756
+ x_data,
2757
+ y_data,
2758
+ z_data,
2759
+ max_cells=max_cells,
2760
+ )
2761
+ source_count = int(source_rows * source_columns)
2762
+ self._heatmap_aggregated_source_rows = int(z_data.size)
2763
+ self._heatmap_source_info = {
2764
+ "row_count": source_count,
2765
+ "estimated_range_rows": source_count,
2766
+ "sampled": False,
2767
+ "aggregated": True,
2768
+ "sample_limit": None,
2769
+ "sample_stride": None,
2770
+ "strategy": "operations, then spatial mean",
2771
+ "axis_ranges": self.heatmap_source_axis_ranges,
2772
+ }
2773
+ self.axis_data["x"] = x_axis
2774
+ self.axis_data["y"] = y_axis
2775
+ self.dataGrid = data_grid
2776
+ self.loaded_point_count = int(data_grid.size)
2777
+ self.heatmap_downsample_info = self._heatmap_downsample_info()
2778
+
2779
+
2780
+ class _emitter(QtCore.QObject):
2781
+ """
2782
+ QRunnable cannot emit signals, use of QObject can
2783
+ """
2784
+ printer = QtCore.pyqtSignal([str]) # FOR USE IN PLACE OF PRINT()
2785
+ finished = QtCore.pyqtSignal([bool]) # Callback to main to say fetch data
2786
+ errorOccurred = QtCore.pyqtSignal([Exception]) # Errors do not display in threads
2787
+