qplotter 1.6.0__py3-none-any.whl

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