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