qplotter 1.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qplot/__init__.py +46 -0
- qplot/__main__.py +232 -0
- qplot/_auxiliary_paths.py +67 -0
- qplot/_build_info.json +1 -0
- qplot/_metadata.py +75 -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 +578 -0
- qplot/configuration/config_schema.json +357 -0
- qplot/configuration/fonts.py +3 -0
- qplot/configuration/scripts.py +255 -0
- qplot/configuration/themes/__init__.py +13 -0
- qplot/configuration/themes/_base.py +592 -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 +2340 -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 +2566 -0
- qplot/datahandling/source_activity.py +21 -0
- qplot/datahandling/trusted_array_preview.py +148 -0
- qplot/datahandling/trusted_derived_cache.py +1605 -0
- qplot/datahandling/trusted_derived_rendering.py +876 -0
- qplot/datahandling/trusted_heatmap.py +246 -0
- qplot/datahandling/trusted_live.py +2808 -0
- qplot/datahandling/trusted_live_queries.py +5907 -0
- qplot/datahandling/trusted_live_service.py +1919 -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 +1804 -0
- qplot/tools/__init__.py +13 -0
- qplot/tools/finite_means.py +123 -0
- qplot/tools/general.py +139 -0
- qplot/tools/heatmap_geometry.py +391 -0
- qplot/tools/operation_registry.py +179 -0
- qplot/tools/plot_tools.py +650 -0
- qplot/tools/sample_statistics.py +111 -0
- qplot/tools/worker.py +2838 -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 +4933 -0
- qplot/windows/_dataset_handle.py +221 -0
- qplot/windows/_display_precision.py +48 -0
- qplot/windows/_dragdrop.py +70 -0
- qplot/windows/_export_paths.py +1167 -0
- qplot/windows/_help.py +210 -0
- qplot/windows/_mixed_appearance.py +303 -0
- qplot/windows/_mixed_plots.py +461 -0
- qplot/windows/_native_averaging.py +323 -0
- qplot/windows/_native_transforms.py +551 -0
- qplot/windows/_plot1d_snap.py +612 -0
- qplot/windows/_plot1d_traces.py +2236 -0
- qplot/windows/_plot2d_colorbar.py +1127 -0
- qplot/windows/_plot2d_colorbar_dialog.py +786 -0
- qplot/windows/_plot2d_layers.py +2291 -0
- qplot/windows/_plot2d_sweeps.py +712 -0
- qplot/windows/_plotWin.py +2082 -0
- qplot/windows/_plot_actions.py +2703 -0
- qplot/windows/_plot_annotation.py +186 -0
- qplot/windows/_plot_appearance.py +283 -0
- qplot/windows/_plot_axis_scaling.py +1521 -0
- qplot/windows/_plot_data_viewbox.py +94 -0
- qplot/windows/_plot_drop_axes.py +224 -0
- qplot/windows/_plot_export.py +1445 -0
- qplot/windows/_plot_feedback.py +112 -0
- qplot/windows/_plot_marquee.py +659 -0
- qplot/windows/_plot_refresh.py +950 -0
- qplot/windows/_plot_source_promotion.py +178 -0
- qplot/windows/_plot_state.py +237 -0
- qplot/windows/_plot_status.py +115 -0
- qplot/windows/_plot_tiling.py +35 -0
- qplot/windows/_plot_transform_labels.py +60 -0
- qplot/windows/_preferences.py +724 -0
- qplot/windows/_refresh_interval.py +172 -0
- qplot/windows/_run_controls.py +789 -0
- qplot/windows/_shortcuts.py +47 -0
- qplot/windows/_subplots/__init__.py +8 -0
- qplot/windows/_subplots/subplot1d.py +532 -0
- qplot/windows/_subplots/subplot2d.py +919 -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 +414 -0
- qplot/windows/main.py +1632 -0
- qplot/windows/plot1d.py +313 -0
- qplot/windows/plot2d.py +1935 -0
- qplotter-1.6.0.dist-info/METADATA +322 -0
- qplotter-1.6.0.dist-info/RECORD +130 -0
- qplotter-1.6.0.dist-info/WHEEL +5 -0
- qplotter-1.6.0.dist-info/entry_points.txt +4 -0
- qplotter-1.6.0.dist-info/licenses/LICENSE +21 -0
- qplotter-1.6.0.dist-info/top_level.txt +1 -0
qplot/windows/plot2d.py
ADDED
|
@@ -0,0 +1,1935 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import numpy.typing as npt
|
|
5
|
+
import pyqtgraph as pg
|
|
6
|
+
from PyQt6 import QtCore, QtGui
|
|
7
|
+
from PyQt6 import QtWidgets as qtw
|
|
8
|
+
|
|
9
|
+
from qplot.datahandling.parameter_data import numeric_isfinite
|
|
10
|
+
from qplot.tools.heatmap_geometry import (
|
|
11
|
+
AxisGeometry,
|
|
12
|
+
HeatmapGeometry,
|
|
13
|
+
canonicalize_heatmap_data,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from . import _colorbar
|
|
17
|
+
from ._commands import command_spec, create_action
|
|
18
|
+
from ._display_precision import (
|
|
19
|
+
DISPLAY_PRECISION_MESSAGE,
|
|
20
|
+
HEATMAP_DISPLAY_RANGE_MESSAGE,
|
|
21
|
+
float_display_loses_distinctions,
|
|
22
|
+
heatmap_display_range_is_finite,
|
|
23
|
+
)
|
|
24
|
+
from ._mixed_plots import MixedPlotMixin
|
|
25
|
+
from ._plot2d_sweeps import Plot2DSweepMixin
|
|
26
|
+
from ._plot_axis_scaling import _install_flush_axis_draw_specs
|
|
27
|
+
from ._plotWin import plotWidget
|
|
28
|
+
|
|
29
|
+
_COLORBAR_COLORMAPS = _colorbar._COLORBAR_COLORMAPS
|
|
30
|
+
_HEATMAP_VIEW_RELOAD_DEBOUNCE_MS = 450
|
|
31
|
+
_HEATMAP_VIEW_RELOAD_MIN_FRACTION = 0.95
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class plot2d(
|
|
35
|
+
Plot2DSweepMixin,
|
|
36
|
+
MixedPlotMixin,
|
|
37
|
+
plotWidget,
|
|
38
|
+
):
|
|
39
|
+
"""
|
|
40
|
+
Plot window for 2d and higher plots, aka Heatmaps.
|
|
41
|
+
Inherits and wraps several functions from qplot.windows._plotWin.plotWidget.
|
|
42
|
+
PlotWidget handles majority of set up, recommend to view first.
|
|
43
|
+
|
|
44
|
+
Key functions to see in plot2d:
|
|
45
|
+
initFrame
|
|
46
|
+
refreshPlot
|
|
47
|
+
|
|
48
|
+
"""
|
|
49
|
+
operation_kind = "plot2d"
|
|
50
|
+
get_mergables = QtCore.pyqtSignal()
|
|
51
|
+
open_subplot = QtCore.pyqtSignal([object, object, tuple])
|
|
52
|
+
sweep_moved = QtCore.pyqtSignal([int, float])
|
|
53
|
+
close_sweeps_requested = QtCore.pyqtSignal([object, object])
|
|
54
|
+
remove_dataset = QtCore.pyqtSignal([object])
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
*args: Any,
|
|
59
|
+
**kargs: Any,
|
|
60
|
+
) -> None:
|
|
61
|
+
super().__init__(*args, **kargs)
|
|
62
|
+
self.sweep_lines: dict[int, Any] = {}
|
|
63
|
+
self.active_sweep_line_id = None
|
|
64
|
+
self.__dict__["rotate"] = None # FOR SUBPLOT CURSOR
|
|
65
|
+
self.__dict__["_colorbar_manual_levels"] = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def initAxes(self) -> None:
|
|
69
|
+
"""Keep axis-selection state; expose swaps through Appearance."""
|
|
70
|
+
super().initAxes()
|
|
71
|
+
self.axes_dock.toggleViewAction().setVisible(False)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def initFrame(self) -> None:
|
|
75
|
+
"""
|
|
76
|
+
Sets up the initial plot and starting data.
|
|
77
|
+
|
|
78
|
+
"""
|
|
79
|
+
self.vb.set_shift_pan_axis_constraint(True)
|
|
80
|
+
self._large_heatmap_sql_mode = False
|
|
81
|
+
self._heatmap_full_axis_ranges: dict[str, tuple[float, float]] | None = None
|
|
82
|
+
self._heatmap_full_view_ranges: dict[str, tuple[float, float]] | None = None
|
|
83
|
+
self._heatmap_last_view_ranges: dict[str, tuple[float, float]] | None = None
|
|
84
|
+
self._heatmap_worker_downsample_info: dict[str, Any] | None = None
|
|
85
|
+
self._heatmap_downsample_info: dict[str, Any] | None = None
|
|
86
|
+
self._heatmap_view_reload_timer = QtCore.QTimer(self)
|
|
87
|
+
self._heatmap_view_reload_timer.setSingleShot(True)
|
|
88
|
+
self._heatmap_view_reload_timer.timeout.connect(
|
|
89
|
+
self._reload_visible_heatmap_data
|
|
90
|
+
)
|
|
91
|
+
self._connect_heatmap_range_controls()
|
|
92
|
+
self._install_plot_control_view_range_preserver()
|
|
93
|
+
|
|
94
|
+
for side in ("left", "right", "top", "bottom"):
|
|
95
|
+
_install_flush_axis_draw_specs(self.plot.getAxis(side))
|
|
96
|
+
|
|
97
|
+
self.image = pg.ImageItem(axisOrder='row-major')
|
|
98
|
+
self.image.setZValue(0) # Like *Send to back*
|
|
99
|
+
self.heatmap_mesh = pg.PColorMeshItem()
|
|
100
|
+
self.heatmap_mesh.setZValue(0)
|
|
101
|
+
self.heatmap_mesh.hide()
|
|
102
|
+
|
|
103
|
+
self.plot.addItem(self.image)
|
|
104
|
+
self.plot.addItem(self.heatmap_mesh)
|
|
105
|
+
self.hover_pixel_outline = qtw.QGraphicsRectItem()
|
|
106
|
+
self.hover_pixel_outline.setPen(
|
|
107
|
+
pg.mkPen((255, 255, 255, 190), width=1.5, cosmetic=True)
|
|
108
|
+
)
|
|
109
|
+
self.hover_pixel_outline.setBrush(QtGui.QBrush(QtCore.Qt.BrushStyle.NoBrush))
|
|
110
|
+
self.hover_pixel_outline.setZValue(10)
|
|
111
|
+
self.hover_pixel_outline.hide()
|
|
112
|
+
self._add_plot_overlay(self.hover_pixel_outline)
|
|
113
|
+
self._init_heatmap_layers()
|
|
114
|
+
|
|
115
|
+
# Wait for loader to finish to enure needed data is collected.
|
|
116
|
+
self.load_data()
|
|
117
|
+
self.show_status("Heatmap ready; loading data...", 5000)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _connect_heatmap_range_controls(self) -> None:
|
|
121
|
+
self.plot.sigRangeChangedManually.connect(
|
|
122
|
+
self._schedule_visible_heatmap_reload
|
|
123
|
+
)
|
|
124
|
+
self.vb.autoRange_triggered.connect(self._zoom_large_heatmap_to_all)
|
|
125
|
+
self.plot.autoBtn.clicked.connect(
|
|
126
|
+
lambda _button: self._zoom_large_heatmap_to_all()
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _plot_layout_controls(self) -> tuple[Any, ...]:
|
|
131
|
+
"""Return controls whose visibility changes resize the plot canvas."""
|
|
132
|
+
|
|
133
|
+
return tuple(
|
|
134
|
+
control
|
|
135
|
+
for name in (
|
|
136
|
+
"axes_dock",
|
|
137
|
+
"oper_dock",
|
|
138
|
+
"toolbarRef",
|
|
139
|
+
"toolbarCo_ord",
|
|
140
|
+
)
|
|
141
|
+
if (control := self.__dict__.get(name)) is not None
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _install_plot_control_view_range_preserver(self) -> None:
|
|
146
|
+
"""Watch every dock and toolbar that can resize the heatmap canvas."""
|
|
147
|
+
|
|
148
|
+
installed = self.__dict__.setdefault(
|
|
149
|
+
"_plot_control_view_range_filters",
|
|
150
|
+
set(),
|
|
151
|
+
)
|
|
152
|
+
for control in self._plot_layout_controls():
|
|
153
|
+
control_id = id(control)
|
|
154
|
+
if control_id in installed:
|
|
155
|
+
continue
|
|
156
|
+
control.installEventFilter(self)
|
|
157
|
+
installed.add(control_id)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _preserve_heatmap_view_range_after_control_layout(self) -> None:
|
|
161
|
+
"""Restore a loaded heatmap's viewport after controls are relaid out."""
|
|
162
|
+
|
|
163
|
+
worker = self.__dict__.get("worker")
|
|
164
|
+
if worker is None or getattr(worker, "running", False):
|
|
165
|
+
# Never restore a pre-refresh range over a newly published heatmap.
|
|
166
|
+
return
|
|
167
|
+
try:
|
|
168
|
+
if not self._has_plottable_heatmap_data():
|
|
169
|
+
return
|
|
170
|
+
except (AttributeError, TypeError, ValueError):
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
viewbox = self._primary_heatmap_viewbox()
|
|
175
|
+
publication_generation = self.__dict__.get(
|
|
176
|
+
"_qplot_publication_generation",
|
|
177
|
+
0,
|
|
178
|
+
)
|
|
179
|
+
pending = self.__dict__.get("_plot_control_layout_restore")
|
|
180
|
+
if (
|
|
181
|
+
isinstance(pending, dict)
|
|
182
|
+
and pending.get("viewbox") is viewbox
|
|
183
|
+
and pending.get("worker") is worker
|
|
184
|
+
and pending.get("publication_generation")
|
|
185
|
+
== publication_generation
|
|
186
|
+
):
|
|
187
|
+
saved_range = pending["view_range"]
|
|
188
|
+
else:
|
|
189
|
+
# A completed render can still have its initial auto-range
|
|
190
|
+
# queued for the next paint. Do not preserve the empty viewport
|
|
191
|
+
# over that newly loaded data when a control becomes visible.
|
|
192
|
+
if getattr(viewbox, "_autoRangeNeedsUpdate", False):
|
|
193
|
+
viewbox.updateAutoRange()
|
|
194
|
+
view_range = viewbox.viewRange()
|
|
195
|
+
saved_range = (
|
|
196
|
+
tuple(float(value) for value in view_range[0]),
|
|
197
|
+
tuple(float(value) for value in view_range[1]),
|
|
198
|
+
)
|
|
199
|
+
except (AttributeError, IndexError, TypeError, ValueError):
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
token = self.__dict__.get("_plot_control_layout_token", 0) + 1
|
|
203
|
+
self.__dict__["_plot_control_layout_token"] = token
|
|
204
|
+
self.__dict__["_plot_control_layout_restore"] = {
|
|
205
|
+
"publication_generation": publication_generation,
|
|
206
|
+
"view_range": saved_range,
|
|
207
|
+
"viewbox": viewbox,
|
|
208
|
+
"worker": worker,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
def restore(final_pass: bool = False) -> None:
|
|
212
|
+
if self.__dict__.get("_plot_control_layout_token") != token:
|
|
213
|
+
return
|
|
214
|
+
if (
|
|
215
|
+
self.__dict__.get("worker") is not worker
|
|
216
|
+
or getattr(worker, "running", False)
|
|
217
|
+
or self.__dict__.get("_qplot_publication_generation", 0)
|
|
218
|
+
!= publication_generation
|
|
219
|
+
):
|
|
220
|
+
self.__dict__.pop("_plot_control_layout_restore", None)
|
|
221
|
+
return
|
|
222
|
+
try:
|
|
223
|
+
if getattr(viewbox, "_autoRangeNeedsUpdate", False):
|
|
224
|
+
# Consume the auto-range queued by ViewBox.resizeEvent so
|
|
225
|
+
# it cannot run later and overwrite the saved viewport.
|
|
226
|
+
viewbox.updateAutoRange()
|
|
227
|
+
current_range = viewbox.viewRange()
|
|
228
|
+
except (AttributeError, TypeError):
|
|
229
|
+
self.__dict__.pop("_plot_control_layout_restore", None)
|
|
230
|
+
return
|
|
231
|
+
if not np.allclose(
|
|
232
|
+
current_range,
|
|
233
|
+
saved_range,
|
|
234
|
+
rtol=1e-12,
|
|
235
|
+
atol=1e-12,
|
|
236
|
+
):
|
|
237
|
+
viewbox.setRange(
|
|
238
|
+
xRange=saved_range[0],
|
|
239
|
+
yRange=saved_range[1],
|
|
240
|
+
padding=0,
|
|
241
|
+
disableAutoRange=False,
|
|
242
|
+
)
|
|
243
|
+
self._sync_secondary_heatmap_view_ranges()
|
|
244
|
+
|
|
245
|
+
if final_pass:
|
|
246
|
+
self.__dict__.pop("_plot_control_layout_restore", None)
|
|
247
|
+
else:
|
|
248
|
+
# A dock and its canvas resize can arrive in adjacent event-loop
|
|
249
|
+
# turns. Recheck once after both layout passes have settled.
|
|
250
|
+
QtCore.QTimer.singleShot(0, lambda: restore(True))
|
|
251
|
+
|
|
252
|
+
QtCore.QTimer.singleShot(0, restore)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _view_range_changed_programmatically(self) -> None:
|
|
256
|
+
self._schedule_visible_heatmap_reload()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _emit_heatmap_trace_updated(self) -> None:
|
|
260
|
+
"""Publish a completed heatmap render when Qt is fully initialised."""
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
self.trace_updated.emit()
|
|
264
|
+
except RuntimeError:
|
|
265
|
+
# A few focused geometry tests construct plot2d with ``__new__``
|
|
266
|
+
# and deliberately omit QMainWindow initialisation.
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@QtCore.pyqtSlot(bool)
|
|
271
|
+
def closeEvent(self, event: object) -> None:
|
|
272
|
+
self.close_secondary_heatmaps()
|
|
273
|
+
super().closeEvent(event)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def initRefresh(self, refresh: Any) -> None:
|
|
277
|
+
super().initRefresh(refresh)
|
|
278
|
+
|
|
279
|
+
self.relevel_refresh = qtw.QCheckBox("Autoscale colors on refresh")
|
|
280
|
+
self.relevel_refresh.setToolTip("Autoscale the heatmap colour range on each refresh")
|
|
281
|
+
self.relevel_refresh.toggled.connect(self._colorbar_auto_refresh_changed)
|
|
282
|
+
self.toolbarRef.insertWidget(self.plotStatusAction, self.relevel_refresh)
|
|
283
|
+
self.toolbarRef.insertSeparator(self.plotStatusAction)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def initContextMenu(self) -> None:
|
|
287
|
+
super().initContextMenu()
|
|
288
|
+
|
|
289
|
+
autoColor = create_action("heatmap.autoscale_color", self)
|
|
290
|
+
self.register_shortcut(autoColor, command_spec("heatmap.autoscale_color"))
|
|
291
|
+
autoColor.triggered.connect(self.scaleColorbar)
|
|
292
|
+
self.vbMenu.insertAction(self.autoscaleSep, autoColor)
|
|
293
|
+
|
|
294
|
+
actions = self.vbMenu.actions()
|
|
295
|
+
|
|
296
|
+
sep = self.vbMenu.insertSeparator(actions[3])
|
|
297
|
+
|
|
298
|
+
### Sweep control
|
|
299
|
+
h_sweep = create_action("heatmap.horizontal_cut", self)
|
|
300
|
+
self.register_shortcut(h_sweep, command_spec("heatmap.horizontal_cut"))
|
|
301
|
+
h_sweep.triggered.connect(lambda _: self.openSweep("h"))
|
|
302
|
+
self.vbMenu.insertAction(sep, h_sweep)
|
|
303
|
+
|
|
304
|
+
v_sweep = create_action("heatmap.vertical_cut", self)
|
|
305
|
+
self.register_shortcut(v_sweep, command_spec("heatmap.vertical_cut"))
|
|
306
|
+
v_sweep.triggered.connect(lambda _: self.openSweep("v"))
|
|
307
|
+
self.vbMenu.insertAction(sep, v_sweep)
|
|
308
|
+
|
|
309
|
+
# Link finish update with check for rotation of sweep cursor
|
|
310
|
+
self.end_wait.connect(self.rotate_sweeps)
|
|
311
|
+
self.vbMenu.insertSeparator(h_sweep)
|
|
312
|
+
|
|
313
|
+
self._init_colorbar_scale_controls()
|
|
314
|
+
|
|
315
|
+
for key, text in (
|
|
316
|
+
(QtCore.Qt.Key.Key_Left, "Move selected cut left"),
|
|
317
|
+
(QtCore.Qt.Key.Key_Right, "Move selected cut right"),
|
|
318
|
+
(QtCore.Qt.Key.Key_Up, "Move selected cut up"),
|
|
319
|
+
(QtCore.Qt.Key.Key_Down, "Move selected cut down"),
|
|
320
|
+
):
|
|
321
|
+
action = QtGui.QAction(text, self)
|
|
322
|
+
action.setShortcut(QtGui.QKeySequence(key))
|
|
323
|
+
action.setShortcutContext(QtCore.Qt.ShortcutContext.WindowShortcut)
|
|
324
|
+
action.triggered.connect(
|
|
325
|
+
lambda _, key=key: self.move_sweep_with_arrow_key(key)
|
|
326
|
+
)
|
|
327
|
+
self.addAction(action)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def initLabels(self) -> None:
|
|
331
|
+
super().initLabels()
|
|
332
|
+
self._install_plot_control_view_range_preserver()
|
|
333
|
+
self.__dict__["z_index"] = None
|
|
334
|
+
|
|
335
|
+
posLabelx = qtw.QLabel("z= ")
|
|
336
|
+
self.toolbarCo_ord.addWidget(posLabelx)
|
|
337
|
+
self.pos_labels["z"] = posLabelx
|
|
338
|
+
self.apply_cursor_label_widths()
|
|
339
|
+
self._init_heatmap_downsample_warning_button()
|
|
340
|
+
|
|
341
|
+
###############################################################################
|
|
342
|
+
|
|
343
|
+
def refreshPlot(self, finished: bool = True, worker: Any = None) -> None:
|
|
344
|
+
"""
|
|
345
|
+
Updates plot based on data produced by the thread worker. Data is
|
|
346
|
+
assigned in plotWidget.refreshPlot, then all plot items are produced
|
|
347
|
+
here.
|
|
348
|
+
|
|
349
|
+
Parameters
|
|
350
|
+
----------
|
|
351
|
+
finished : bool
|
|
352
|
+
In the event the worker had to abort, finished is False and refresh
|
|
353
|
+
is not ran.
|
|
354
|
+
"""
|
|
355
|
+
plot_worker = worker if worker is not None else self.worker
|
|
356
|
+
if not super().refreshPlot(finished, worker=worker):
|
|
357
|
+
plot_worker.running = False
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
full_sql_refresh = bool(
|
|
361
|
+
getattr(plot_worker, "loaded_from_sql_heatmap", False)
|
|
362
|
+
and getattr(plot_worker, "heatmap_axis_ranges", None) is None
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
try:
|
|
366
|
+
def clear_display():
|
|
367
|
+
self._invalidate_heatmap_geometry()
|
|
368
|
+
bar = self.__dict__.get("bar")
|
|
369
|
+
hide = getattr(bar, "hide", None)
|
|
370
|
+
if callable(hide):
|
|
371
|
+
hide()
|
|
372
|
+
|
|
373
|
+
if not self._refresh_publication_source_is_current(
|
|
374
|
+
plot_worker,
|
|
375
|
+
clear_display=clear_display,
|
|
376
|
+
):
|
|
377
|
+
return
|
|
378
|
+
self._update_large_heatmap_state(plot_worker)
|
|
379
|
+
self.refresh_secondary_heatmaps()
|
|
380
|
+
self.refresh_secondary_lines()
|
|
381
|
+
if not self._refresh_publication_source_is_current(
|
|
382
|
+
plot_worker,
|
|
383
|
+
clear_display=clear_display,
|
|
384
|
+
):
|
|
385
|
+
return
|
|
386
|
+
if not self._has_plottable_heatmap_data():
|
|
387
|
+
self._invalidate_heatmap_geometry()
|
|
388
|
+
if not self._refresh_publication_source_is_current(
|
|
389
|
+
plot_worker,
|
|
390
|
+
clear_display=clear_display,
|
|
391
|
+
):
|
|
392
|
+
return
|
|
393
|
+
self._emit_heatmap_trace_updated()
|
|
394
|
+
if not self._refresh_publication_source_is_current(
|
|
395
|
+
plot_worker,
|
|
396
|
+
clear_display=clear_display,
|
|
397
|
+
):
|
|
398
|
+
return
|
|
399
|
+
self.show_status(
|
|
400
|
+
f"Waiting for plottable data for {self.param.name}...",
|
|
401
|
+
5000,
|
|
402
|
+
)
|
|
403
|
+
self.show_plot_state(
|
|
404
|
+
"Waiting for plottable data",
|
|
405
|
+
f"{self.param.name} has no finite heatmap data yet.",
|
|
406
|
+
kind="empty",
|
|
407
|
+
)
|
|
408
|
+
display_synchronized = self._mark_display_synchronized(
|
|
409
|
+
plot_worker,
|
|
410
|
+
)
|
|
411
|
+
if (
|
|
412
|
+
getattr(plot_worker, "dataset_completed", None) is True
|
|
413
|
+
and not display_synchronized
|
|
414
|
+
):
|
|
415
|
+
return
|
|
416
|
+
if getattr(plot_worker, "_qplot_source_rejected", False):
|
|
417
|
+
clear_display()
|
|
418
|
+
return
|
|
419
|
+
self._commit_refresh_publication(plot_worker)
|
|
420
|
+
return
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
geometry = self._update_heatmap_geometry()
|
|
424
|
+
except (TypeError, ValueError) as error:
|
|
425
|
+
self._invalidate_heatmap_geometry()
|
|
426
|
+
self._emit_heatmap_trace_updated()
|
|
427
|
+
if not self._refresh_publication_source_is_current(
|
|
428
|
+
plot_worker,
|
|
429
|
+
clear_display=clear_display,
|
|
430
|
+
):
|
|
431
|
+
return
|
|
432
|
+
self.show_status(f"Cannot display heatmap: {error}", 10_000)
|
|
433
|
+
self.show_plot_state(
|
|
434
|
+
"Invalid heatmap geometry",
|
|
435
|
+
str(error),
|
|
436
|
+
kind="error",
|
|
437
|
+
)
|
|
438
|
+
return
|
|
439
|
+
|
|
440
|
+
viewbox = self._primary_heatmap_viewbox()
|
|
441
|
+
if not heatmap_display_range_is_finite(geometry, (viewbox.width(), viewbox.height())):
|
|
442
|
+
self._heatmap_view_reload_timer.stop()
|
|
443
|
+
self._reject_heatmap_color_range(
|
|
444
|
+
message=HEATMAP_DISPLAY_RANGE_MESSAGE,
|
|
445
|
+
title="Unsupported heatmap coordinate range",
|
|
446
|
+
)
|
|
447
|
+
self._emit_heatmap_trace_updated()
|
|
448
|
+
if self._refresh_publication_source_is_current(
|
|
449
|
+
plot_worker, clear_display=clear_display):
|
|
450
|
+
self._mark_display_synchronized(plot_worker)
|
|
451
|
+
return
|
|
452
|
+
|
|
453
|
+
if float_display_loses_distinctions(self.dataGrid):
|
|
454
|
+
self._reject_heatmap_color_range(
|
|
455
|
+
message=DISPLAY_PRECISION_MESSAGE,
|
|
456
|
+
title="Unsupported heatmap display precision",
|
|
457
|
+
)
|
|
458
|
+
self._emit_heatmap_trace_updated()
|
|
459
|
+
if self._refresh_publication_source_is_current(
|
|
460
|
+
plot_worker, clear_display=clear_display):
|
|
461
|
+
self._mark_display_synchronized(plot_worker)
|
|
462
|
+
return
|
|
463
|
+
|
|
464
|
+
autoLevels = self.relevel_refresh.isChecked()
|
|
465
|
+
operation_apply_generation = int(
|
|
466
|
+
getattr(plot_worker, "_qplot_operation_apply_generation", 0)
|
|
467
|
+
)
|
|
468
|
+
operation_auto_levels = bool(
|
|
469
|
+
operation_apply_generation
|
|
470
|
+
> self.__dict__.get("_colorbar_operation_apply_generation", 0)
|
|
471
|
+
and self._colorbar_manual_levels is None
|
|
472
|
+
)
|
|
473
|
+
color_levels = self._colorbar_manual_levels
|
|
474
|
+
if color_levels is None:
|
|
475
|
+
if not hasattr(self, "bar") or autoLevels or operation_auto_levels:
|
|
476
|
+
color_levels = self._data_colorbar_levels()
|
|
477
|
+
else:
|
|
478
|
+
color_levels = self.bar.levels()
|
|
479
|
+
if color_levels is not None and not np.isfinite(float(color_levels[1]) - float(color_levels[0])):
|
|
480
|
+
self._reject_heatmap_color_range()
|
|
481
|
+
self._emit_heatmap_trace_updated()
|
|
482
|
+
if not self._refresh_publication_source_is_current(
|
|
483
|
+
plot_worker,
|
|
484
|
+
clear_display=clear_display,
|
|
485
|
+
):
|
|
486
|
+
return
|
|
487
|
+
# A color rejection is a concrete display of the loaded data.
|
|
488
|
+
# A completed source must not keep replacing that error with
|
|
489
|
+
# automatic reloads; live sources and explicit refreshes remain
|
|
490
|
+
# retryable through the usual guarded publication state.
|
|
491
|
+
self._mark_display_synchronized(plot_worker)
|
|
492
|
+
return
|
|
493
|
+
self._heatmap_color_range_rejected = False
|
|
494
|
+
self._heatmap_publication_color_levels = color_levels
|
|
495
|
+
try:
|
|
496
|
+
self._render_heatmap()
|
|
497
|
+
finally:
|
|
498
|
+
self.__dict__.pop("_heatmap_publication_color_levels", None)
|
|
499
|
+
if not self._refresh_publication_source_is_current(
|
|
500
|
+
plot_worker,
|
|
501
|
+
clear_display=clear_display,
|
|
502
|
+
):
|
|
503
|
+
return
|
|
504
|
+
|
|
505
|
+
# Produce color bar on first run
|
|
506
|
+
if not hasattr(self, "bar"):
|
|
507
|
+
colorbar_items = self._heatmap_colorbar_items()
|
|
508
|
+
self.bar = self.plot.addColorBar(
|
|
509
|
+
colorbar_items,
|
|
510
|
+
colorMap=self._colorbar_colormap(),
|
|
511
|
+
rounding=self._data_colorbar_rounding(),
|
|
512
|
+
width=self._configured_colorbar_width(),
|
|
513
|
+
colorMapMenu=False,
|
|
514
|
+
)
|
|
515
|
+
self._heatmap_colorbar_item_ids = tuple(
|
|
516
|
+
id(item) for item in colorbar_items
|
|
517
|
+
)
|
|
518
|
+
self._set_colorbar_tick_formatter()
|
|
519
|
+
# The colour bar is created after the initial plot theme has
|
|
520
|
+
# been applied, so style its axes immediately as well.
|
|
521
|
+
self.config.theme.style_plotItem(self)
|
|
522
|
+
self.apply_axis_major_tick_count_preference()
|
|
523
|
+
if self._colorbar_manual_levels is None:
|
|
524
|
+
self.scaleColorbar()
|
|
525
|
+
else:
|
|
526
|
+
self._set_colorbar_levels(*self._colorbar_manual_levels)
|
|
527
|
+
|
|
528
|
+
self.bar.show()
|
|
529
|
+
|
|
530
|
+
# The dependent-variable label may have changed when an operation
|
|
531
|
+
# such as differentiation was added or removed.
|
|
532
|
+
self._sync_colorbar_axis_scaling()
|
|
533
|
+
if autoLevels or operation_auto_levels:
|
|
534
|
+
self.__dict__["_colorbar_manual_levels"] = None
|
|
535
|
+
self.scaleColorbar()
|
|
536
|
+
elif self._colorbar_manual_levels is not None:
|
|
537
|
+
self._set_colorbar_levels(*self._colorbar_manual_levels)
|
|
538
|
+
|
|
539
|
+
self._restore_heatmap_interactions()
|
|
540
|
+
if not self._refresh_publication_source_is_current(
|
|
541
|
+
plot_worker,
|
|
542
|
+
clear_display=clear_display,
|
|
543
|
+
):
|
|
544
|
+
return
|
|
545
|
+
self._emit_heatmap_trace_updated()
|
|
546
|
+
if not self._refresh_publication_source_is_current(
|
|
547
|
+
plot_worker,
|
|
548
|
+
clear_display=clear_display,
|
|
549
|
+
):
|
|
550
|
+
return
|
|
551
|
+
self.__dict__["_colorbar_operation_apply_generation"] = max(
|
|
552
|
+
operation_apply_generation,
|
|
553
|
+
self.__dict__.get("_colorbar_operation_apply_generation", 0),
|
|
554
|
+
)
|
|
555
|
+
self._mark_display_synchronized(plot_worker)
|
|
556
|
+
if getattr(plot_worker, "_qplot_source_rejected", False):
|
|
557
|
+
clear_display()
|
|
558
|
+
return
|
|
559
|
+
self._commit_refresh_publication(plot_worker, preview_ready=True)
|
|
560
|
+
finally:
|
|
561
|
+
overlay = self.__dict__.get("plot_state_overlay")
|
|
562
|
+
if overlay is not None:
|
|
563
|
+
overlay.finish(plot_worker)
|
|
564
|
+
if isinstance(
|
|
565
|
+
getattr(plot_worker, "_qplot_publication_snapshot", None),
|
|
566
|
+
dict,
|
|
567
|
+
):
|
|
568
|
+
if self._refresh_publication_source_is_current(
|
|
569
|
+
plot_worker,
|
|
570
|
+
clear_display=clear_display,
|
|
571
|
+
):
|
|
572
|
+
self._commit_refresh_publication(plot_worker)
|
|
573
|
+
# Allow new workers after empty live loads or display errors.
|
|
574
|
+
plot_worker.running = False
|
|
575
|
+
self._ensure_refresh_monitor()
|
|
576
|
+
if full_sql_refresh and not self.__dict__.get("_heatmap_color_range_rejected", False):
|
|
577
|
+
self._schedule_visible_heatmap_reload()
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _update_large_heatmap_state(self, worker: Any) -> None:
|
|
581
|
+
self._update_heatmap_downsample_state(worker)
|
|
582
|
+
if not getattr(worker, "loaded_from_sql_heatmap", False):
|
|
583
|
+
self._large_heatmap_sql_mode = False
|
|
584
|
+
self._heatmap_full_axis_ranges = None
|
|
585
|
+
self._heatmap_full_view_ranges = None
|
|
586
|
+
self._heatmap_last_view_ranges = None
|
|
587
|
+
timer = self.__dict__.get("_heatmap_view_reload_timer")
|
|
588
|
+
if timer is not None:
|
|
589
|
+
timer.stop()
|
|
590
|
+
return
|
|
591
|
+
|
|
592
|
+
self._large_heatmap_sql_mode = True
|
|
593
|
+
worker_ranges = getattr(worker, "heatmap_axis_ranges", None)
|
|
594
|
+
if worker_ranges is None or self._heatmap_full_axis_ranges is None:
|
|
595
|
+
source_ranges = self._normalise_axis_ranges(
|
|
596
|
+
getattr(worker, "heatmap_source_axis_ranges", None)
|
|
597
|
+
)
|
|
598
|
+
self._heatmap_full_axis_ranges = (
|
|
599
|
+
source_ranges or self._axis_ranges_from_data()
|
|
600
|
+
)
|
|
601
|
+
self._heatmap_full_view_ranges = self._axis_view_ranges_from_data()
|
|
602
|
+
|
|
603
|
+
self._heatmap_last_view_ranges = self._normalise_axis_ranges(worker_ranges)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _init_heatmap_downsample_warning_button(self) -> None:
|
|
607
|
+
resolution_label = qtw.QLabel("Resolution: pending")
|
|
608
|
+
resolution_label.setObjectName("heatmapResolutionStatusLabel")
|
|
609
|
+
resolution_label.setToolTip("Heatmap source and plotted grid resolution.")
|
|
610
|
+
resolution_label.setSizePolicy(qtw.QSizePolicy.Policy.Expanding, qtw.QSizePolicy.Policy.Preferred)
|
|
611
|
+
resolution_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignVCenter)
|
|
612
|
+
self.heatmap_resolution_label = resolution_label
|
|
613
|
+
self.toolbarCo_ord.addWidget(resolution_label)
|
|
614
|
+
|
|
615
|
+
parent = self._heatmap_downsample_button_parent()
|
|
616
|
+
self.__dict__["_heatmap_downsample_button_parent"] = parent
|
|
617
|
+
parent.installEventFilter(self)
|
|
618
|
+
self.heatmap_downsample_button = self._new_heatmap_downsample_button(parent)
|
|
619
|
+
|
|
620
|
+
self._update_heatmap_downsample_button()
|
|
621
|
+
self._update_heatmap_resolution_label()
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _heatmap_downsample_button_parent(self) -> qtw.QWidget:
|
|
625
|
+
viewport = self.widget.viewport() if hasattr(self.widget, "viewport") else None
|
|
626
|
+
if isinstance(viewport, qtw.QWidget):
|
|
627
|
+
return viewport
|
|
628
|
+
return self.widget
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _new_heatmap_downsample_button(
|
|
632
|
+
self,
|
|
633
|
+
parent: qtw.QWidget,
|
|
634
|
+
) -> qtw.QToolButton:
|
|
635
|
+
button = qtw.QToolButton(parent)
|
|
636
|
+
button.setObjectName("heatmapDownsampleWarningButton")
|
|
637
|
+
style = self.style()
|
|
638
|
+
if style is not None:
|
|
639
|
+
button.setIcon(
|
|
640
|
+
style.standardIcon(qtw.QStyle.StandardPixmap.SP_MessageBoxWarning)
|
|
641
|
+
)
|
|
642
|
+
button.setIconSize(QtCore.QSize(18, 18))
|
|
643
|
+
button.setAutoRaise(False)
|
|
644
|
+
button.setFixedSize(28, 28)
|
|
645
|
+
button.setText("")
|
|
646
|
+
button.setToolButtonStyle(
|
|
647
|
+
QtCore.Qt.ToolButtonStyle.ToolButtonIconOnly
|
|
648
|
+
)
|
|
649
|
+
button.setToolTip("This heatmap is downsampled. Click for details.")
|
|
650
|
+
button.clicked.connect(
|
|
651
|
+
lambda _checked=False: self.show_heatmap_downsample_dialog()
|
|
652
|
+
)
|
|
653
|
+
return button
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _position_heatmap_downsample_button(self) -> None:
|
|
657
|
+
button = self.__dict__.get("heatmap_downsample_button")
|
|
658
|
+
parent = self.__dict__.get("_heatmap_downsample_button_parent")
|
|
659
|
+
if button is None or parent is None:
|
|
660
|
+
return
|
|
661
|
+
|
|
662
|
+
margin = 8
|
|
663
|
+
button.move(
|
|
664
|
+
max(margin, parent.width() - button.width() - margin),
|
|
665
|
+
max(margin, parent.height() - button.height() - margin),
|
|
666
|
+
)
|
|
667
|
+
button.raise_()
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def eventFilter(self, source, event):
|
|
671
|
+
if (
|
|
672
|
+
any(source is control for control in self._plot_layout_controls())
|
|
673
|
+
and event.type() in {
|
|
674
|
+
QtCore.QEvent.Type.Show,
|
|
675
|
+
QtCore.QEvent.Type.Hide,
|
|
676
|
+
}
|
|
677
|
+
):
|
|
678
|
+
self._preserve_heatmap_view_range_after_control_layout()
|
|
679
|
+
|
|
680
|
+
if (
|
|
681
|
+
event.type() == QtCore.QEvent.Type.Resize
|
|
682
|
+
and source is self.__dict__.get("_heatmap_downsample_button_parent")
|
|
683
|
+
):
|
|
684
|
+
self._position_heatmap_downsample_button()
|
|
685
|
+
|
|
686
|
+
return super().eventFilter(source, event)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _update_heatmap_downsample_state(self, worker: Any) -> None:
|
|
690
|
+
self._heatmap_full_resolution = getattr(worker, "heatmap_full_resolution", False) is True
|
|
691
|
+
self._heatmap_worker_downsample_info = self._heatmap_downsample_info_from_worker(
|
|
692
|
+
worker
|
|
693
|
+
)
|
|
694
|
+
self._refresh_heatmap_downsample_info()
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _refresh_heatmap_downsample_info(self) -> None:
|
|
698
|
+
merged: dict[str, Any] = {}
|
|
699
|
+
worker_info = self.__dict__.get("_heatmap_worker_downsample_info")
|
|
700
|
+
if isinstance(worker_info, dict):
|
|
701
|
+
merged.update(worker_info)
|
|
702
|
+
|
|
703
|
+
self._heatmap_downsample_info = merged or None
|
|
704
|
+
self._update_heatmap_downsample_button()
|
|
705
|
+
self._update_heatmap_resolution_label()
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _heatmap_downsample_info_from_worker(
|
|
709
|
+
self,
|
|
710
|
+
worker: Any,
|
|
711
|
+
) -> dict[str, Any] | None:
|
|
712
|
+
info = getattr(worker, "heatmap_downsample_info", None)
|
|
713
|
+
if isinstance(info, dict) and (
|
|
714
|
+
info.get("source_sampled")
|
|
715
|
+
or info.get("source_aggregated")
|
|
716
|
+
or info.get("grid_binned")
|
|
717
|
+
or info.get("empty_bins_filled")
|
|
718
|
+
):
|
|
719
|
+
return dict(info)
|
|
720
|
+
|
|
721
|
+
if getattr(worker, "heatmap_full_resolution", False) is True:
|
|
722
|
+
return None
|
|
723
|
+
|
|
724
|
+
return self._fallback_heatmap_downsample_info(worker)
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _fallback_heatmap_downsample_info(
|
|
728
|
+
self,
|
|
729
|
+
worker: Any,
|
|
730
|
+
) -> dict[str, Any] | None:
|
|
731
|
+
data_grid = getattr(worker, "dataGrid", self.__dict__.get("dataGrid", None))
|
|
732
|
+
if data_grid is None:
|
|
733
|
+
return None
|
|
734
|
+
try:
|
|
735
|
+
grid_rows, grid_columns = data_grid.shape
|
|
736
|
+
except (AttributeError, ValueError):
|
|
737
|
+
return None
|
|
738
|
+
|
|
739
|
+
if grid_rows <= 0 or grid_columns <= 0:
|
|
740
|
+
return None
|
|
741
|
+
|
|
742
|
+
source_rows, source_columns = self._source_heatmap_grid_shape(worker)
|
|
743
|
+
source_cell_count = self._source_heatmap_cell_count(
|
|
744
|
+
source_rows,
|
|
745
|
+
source_columns,
|
|
746
|
+
worker,
|
|
747
|
+
)
|
|
748
|
+
if source_cell_count is None:
|
|
749
|
+
return None
|
|
750
|
+
|
|
751
|
+
limit = self._full_resolution_heatmap_limit(worker)
|
|
752
|
+
grid_cell_count = int(grid_rows * grid_columns)
|
|
753
|
+
grid_reduced = grid_cell_count < int(source_cell_count)
|
|
754
|
+
if (
|
|
755
|
+
source_rows is not None
|
|
756
|
+
and source_columns is not None
|
|
757
|
+
and (
|
|
758
|
+
int(grid_rows) < int(source_rows)
|
|
759
|
+
or int(grid_columns) < int(source_columns)
|
|
760
|
+
)
|
|
761
|
+
):
|
|
762
|
+
grid_reduced = True
|
|
763
|
+
|
|
764
|
+
if int(source_cell_count) <= limit or not grid_reduced:
|
|
765
|
+
return None
|
|
766
|
+
|
|
767
|
+
loaded_point_count = getattr(worker, "loaded_point_count", None)
|
|
768
|
+
source_aggregated = bool(
|
|
769
|
+
getattr(worker, "aggregated_heatmap_source", False)
|
|
770
|
+
)
|
|
771
|
+
source_sampled = bool(getattr(worker, "sampled_heatmap_source", False))
|
|
772
|
+
if loaded_point_count is not None and not source_aggregated:
|
|
773
|
+
try:
|
|
774
|
+
source_sampled = source_sampled or int(loaded_point_count) < int(
|
|
775
|
+
source_cell_count
|
|
776
|
+
)
|
|
777
|
+
except (TypeError, ValueError):
|
|
778
|
+
pass
|
|
779
|
+
|
|
780
|
+
return {
|
|
781
|
+
"source_row_count": getattr(
|
|
782
|
+
worker,
|
|
783
|
+
"total_point_count_estimate",
|
|
784
|
+
source_cell_count,
|
|
785
|
+
),
|
|
786
|
+
"estimated_range_rows": getattr(
|
|
787
|
+
worker,
|
|
788
|
+
"_heatmap_estimated_range_rows",
|
|
789
|
+
None,
|
|
790
|
+
),
|
|
791
|
+
"loaded_point_count": loaded_point_count,
|
|
792
|
+
"source_sampled": source_sampled,
|
|
793
|
+
"source_aggregated": source_aggregated,
|
|
794
|
+
"aggregated_source_row_count": getattr(
|
|
795
|
+
worker,
|
|
796
|
+
"_heatmap_aggregated_source_rows",
|
|
797
|
+
None,
|
|
798
|
+
),
|
|
799
|
+
"source_sample_limit": (
|
|
800
|
+
loaded_point_count if source_sampled else None
|
|
801
|
+
),
|
|
802
|
+
"source_sample_stride": getattr(
|
|
803
|
+
worker,
|
|
804
|
+
"_heatmap_source_sample_stride",
|
|
805
|
+
None,
|
|
806
|
+
),
|
|
807
|
+
"source_sample_strategy": None,
|
|
808
|
+
"source_aggregation_strategy": (
|
|
809
|
+
"spatial mean" if source_aggregated else None
|
|
810
|
+
),
|
|
811
|
+
"axis_ranges": getattr(worker, "heatmap_axis_ranges", None),
|
|
812
|
+
"unique_x_count": source_columns,
|
|
813
|
+
"unique_y_count": source_rows,
|
|
814
|
+
"exact_cell_count": source_cell_count,
|
|
815
|
+
"source_grid_columns": source_columns,
|
|
816
|
+
"source_grid_rows": source_rows,
|
|
817
|
+
"source_grid_cell_count": source_cell_count,
|
|
818
|
+
"grid_columns": int(grid_columns),
|
|
819
|
+
"grid_rows": int(grid_rows),
|
|
820
|
+
"grid_cell_count": grid_cell_count,
|
|
821
|
+
"grid_binned": True,
|
|
822
|
+
"grid_cell_limit": limit,
|
|
823
|
+
"full_resolution_point_limit": limit,
|
|
824
|
+
"empty_bins_filled": bool(source_sampled),
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _source_heatmap_grid_shape(
|
|
829
|
+
self,
|
|
830
|
+
worker: Any,
|
|
831
|
+
) -> tuple[int | None, int | None]:
|
|
832
|
+
shape = getattr(worker, "heatmap_source_grid_shape", None)
|
|
833
|
+
if shape is None and hasattr(worker, "_heatmap_source_grid_shape_from_metadata"):
|
|
834
|
+
try:
|
|
835
|
+
shape = worker._heatmap_source_grid_shape_from_metadata()
|
|
836
|
+
except (AttributeError, TypeError, ValueError):
|
|
837
|
+
shape = None
|
|
838
|
+
if shape is None:
|
|
839
|
+
shape = self._source_heatmap_grid_shape_from_metadata()
|
|
840
|
+
if shape is None:
|
|
841
|
+
return None, None
|
|
842
|
+
|
|
843
|
+
try:
|
|
844
|
+
source_rows, source_columns = shape
|
|
845
|
+
source_rows = int(source_rows)
|
|
846
|
+
source_columns = int(source_columns)
|
|
847
|
+
except (TypeError, ValueError):
|
|
848
|
+
return None, None
|
|
849
|
+
|
|
850
|
+
if source_rows <= 0 or source_columns <= 0:
|
|
851
|
+
return None, None
|
|
852
|
+
|
|
853
|
+
return source_rows, source_columns
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _source_heatmap_grid_shape_from_metadata(self) -> tuple[int, int] | None:
|
|
857
|
+
try:
|
|
858
|
+
dataset = self.ds
|
|
859
|
+
except (AttributeError, KeyError):
|
|
860
|
+
return None
|
|
861
|
+
|
|
862
|
+
cache = getattr(dataset, "cache", None)
|
|
863
|
+
shapes = getattr(getattr(cache, "rundescriber", None), "shapes", None)
|
|
864
|
+
if not isinstance(shapes, dict):
|
|
865
|
+
return None
|
|
866
|
+
|
|
867
|
+
shape = shapes.get(self.param.name)
|
|
868
|
+
if shape is None:
|
|
869
|
+
return None
|
|
870
|
+
|
|
871
|
+
try:
|
|
872
|
+
dimensions = [int(dimension) for dimension in shape]
|
|
873
|
+
except (TypeError, ValueError):
|
|
874
|
+
return None
|
|
875
|
+
|
|
876
|
+
depends_on = list(getattr(self.param, "depends_on_", ()))
|
|
877
|
+
if len(dimensions) != len(depends_on):
|
|
878
|
+
return None
|
|
879
|
+
|
|
880
|
+
axes = self.axis_options
|
|
881
|
+
try:
|
|
882
|
+
x_dimension = depends_on.index(axes["x"])
|
|
883
|
+
y_dimension = depends_on.index(axes["y"])
|
|
884
|
+
except (KeyError, ValueError):
|
|
885
|
+
return None
|
|
886
|
+
|
|
887
|
+
if (
|
|
888
|
+
x_dimension >= len(dimensions)
|
|
889
|
+
or y_dimension >= len(dimensions)
|
|
890
|
+
or dimensions[x_dimension] <= 0
|
|
891
|
+
or dimensions[y_dimension] <= 0
|
|
892
|
+
):
|
|
893
|
+
return None
|
|
894
|
+
|
|
895
|
+
return dimensions[y_dimension], dimensions[x_dimension]
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def _source_heatmap_cell_count(
|
|
899
|
+
self,
|
|
900
|
+
source_rows: int | None,
|
|
901
|
+
source_columns: int | None,
|
|
902
|
+
worker: Any,
|
|
903
|
+
) -> int | None:
|
|
904
|
+
if source_rows is not None and source_columns is not None:
|
|
905
|
+
return int(source_rows * source_columns)
|
|
906
|
+
|
|
907
|
+
try:
|
|
908
|
+
dataset_count = getattr(self.ds, "number_of_results", None)
|
|
909
|
+
except (AttributeError, KeyError):
|
|
910
|
+
dataset_count = None
|
|
911
|
+
|
|
912
|
+
for value in (
|
|
913
|
+
getattr(worker, "total_point_count_estimate", None),
|
|
914
|
+
dataset_count,
|
|
915
|
+
):
|
|
916
|
+
if value is None:
|
|
917
|
+
continue
|
|
918
|
+
try:
|
|
919
|
+
count = int(value)
|
|
920
|
+
except (TypeError, ValueError):
|
|
921
|
+
continue
|
|
922
|
+
if count > 0:
|
|
923
|
+
return count
|
|
924
|
+
|
|
925
|
+
return None
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _full_resolution_heatmap_limit(self, worker: Any) -> int:
|
|
929
|
+
try:
|
|
930
|
+
configured_limit = worker.max_full_heatmap_points
|
|
931
|
+
except AttributeError:
|
|
932
|
+
configured_limit = self.config.get(
|
|
933
|
+
"runtime_settings.max_full_heatmap_points"
|
|
934
|
+
)
|
|
935
|
+
return max(1, int(configured_limit))
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _update_heatmap_downsample_button(self) -> None:
|
|
939
|
+
visible = self.__dict__.get("_heatmap_downsample_info") is not None
|
|
940
|
+
button = self.__dict__.get("heatmap_downsample_button")
|
|
941
|
+
if button is None:
|
|
942
|
+
return
|
|
943
|
+
|
|
944
|
+
button.setVisible(visible)
|
|
945
|
+
if visible:
|
|
946
|
+
self._position_heatmap_downsample_button()
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _update_heatmap_resolution_label(self) -> None:
|
|
950
|
+
label = self.__dict__.get("heatmap_resolution_label")
|
|
951
|
+
if label is None:
|
|
952
|
+
return
|
|
953
|
+
|
|
954
|
+
text = self._heatmap_resolution_label_text()
|
|
955
|
+
label.setText(text)
|
|
956
|
+
if self._heatmap_downsample_info is not None:
|
|
957
|
+
label.setToolTip(self._heatmap_downsample_dialog_text())
|
|
958
|
+
else:
|
|
959
|
+
label.setToolTip(text)
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _heatmap_resolution_label_text(self) -> str:
|
|
963
|
+
info = self.__dict__.get("_heatmap_downsample_info")
|
|
964
|
+
if isinstance(info, dict):
|
|
965
|
+
grid_columns = self._format_heatmap_count(info.get("grid_columns"))
|
|
966
|
+
grid_rows = self._format_heatmap_count(info.get("grid_rows"))
|
|
967
|
+
info_source_columns = self._format_heatmap_count(
|
|
968
|
+
info.get("source_grid_columns")
|
|
969
|
+
)
|
|
970
|
+
info_source_rows = self._format_heatmap_count(
|
|
971
|
+
info.get("source_grid_rows")
|
|
972
|
+
)
|
|
973
|
+
if (
|
|
974
|
+
info.get("source_sampled")
|
|
975
|
+
or info.get("source_aggregated")
|
|
976
|
+
or info.get("grid_binned")
|
|
977
|
+
):
|
|
978
|
+
if info.get("source_grid_columns") is None or info.get("source_grid_rows") is None:
|
|
979
|
+
reason = ("exact source dimensions not retained" if info.get("source_dimensions_limited")
|
|
980
|
+
else "source dimensions unavailable")
|
|
981
|
+
return f"Resolution: downsampled {grid_columns} x {grid_rows}; {reason}"
|
|
982
|
+
return (
|
|
983
|
+
"Resolution: downsampled "
|
|
984
|
+
f"{grid_columns} x {grid_rows} of "
|
|
985
|
+
f"{info_source_columns} x {info_source_rows}"
|
|
986
|
+
)
|
|
987
|
+
return f"Resolution: full {grid_columns} x {grid_rows}"
|
|
988
|
+
|
|
989
|
+
data_grid = self.__dict__.get("dataGrid")
|
|
990
|
+
if data_grid is None:
|
|
991
|
+
return "Resolution: pending"
|
|
992
|
+
try:
|
|
993
|
+
plotted_grid_rows, plotted_grid_columns = data_grid.shape
|
|
994
|
+
except (AttributeError, ValueError):
|
|
995
|
+
return "Resolution: pending"
|
|
996
|
+
|
|
997
|
+
source_shape = self._source_heatmap_grid_shape_from_metadata()
|
|
998
|
+
if source_shape is None:
|
|
999
|
+
if self.__dict__.get("_heatmap_full_resolution", False):
|
|
1000
|
+
return (
|
|
1001
|
+
"Resolution: full "
|
|
1002
|
+
f"{self._format_heatmap_count(plotted_grid_columns)} x "
|
|
1003
|
+
f"{self._format_heatmap_count(plotted_grid_rows)}"
|
|
1004
|
+
)
|
|
1005
|
+
return (
|
|
1006
|
+
"Resolution: plotted "
|
|
1007
|
+
f"{self._format_heatmap_count(plotted_grid_columns)} x "
|
|
1008
|
+
f"{self._format_heatmap_count(plotted_grid_rows)}; source unknown"
|
|
1009
|
+
)
|
|
1010
|
+
|
|
1011
|
+
source_rows, source_columns = source_shape
|
|
1012
|
+
if (
|
|
1013
|
+
int(source_rows) == int(plotted_grid_rows)
|
|
1014
|
+
and int(source_columns) == int(plotted_grid_columns)
|
|
1015
|
+
):
|
|
1016
|
+
return (
|
|
1017
|
+
"Resolution: full "
|
|
1018
|
+
f"{self._format_heatmap_count(plotted_grid_columns)} x "
|
|
1019
|
+
f"{self._format_heatmap_count(plotted_grid_rows)}"
|
|
1020
|
+
)
|
|
1021
|
+
|
|
1022
|
+
return (
|
|
1023
|
+
"Resolution: plotted "
|
|
1024
|
+
f"{self._format_heatmap_count(plotted_grid_columns)} x "
|
|
1025
|
+
f"{self._format_heatmap_count(plotted_grid_rows)} of "
|
|
1026
|
+
f"{self._format_heatmap_count(source_columns)} x "
|
|
1027
|
+
f"{self._format_heatmap_count(source_rows)}"
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def show_heatmap_downsample_dialog(self) -> None:
|
|
1032
|
+
if self._heatmap_downsample_info is None:
|
|
1033
|
+
return
|
|
1034
|
+
|
|
1035
|
+
dialog = self._new_heatmap_downsample_dialog()
|
|
1036
|
+
try:
|
|
1037
|
+
dialog.exec()
|
|
1038
|
+
finally:
|
|
1039
|
+
dialog.deleteLater()
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def _new_heatmap_downsample_dialog(self) -> qtw.QDialog:
|
|
1043
|
+
"""Create a titled warning dialog for a downsampled heatmap."""
|
|
1044
|
+
dialog = qtw.QDialog(self)
|
|
1045
|
+
dialog.setWindowTitle("Warning: Heatmap downsampled")
|
|
1046
|
+
dialog.setModal(True)
|
|
1047
|
+
|
|
1048
|
+
layout = qtw.QVBoxLayout(dialog)
|
|
1049
|
+
content = qtw.QHBoxLayout()
|
|
1050
|
+
icon_label = qtw.QLabel(dialog)
|
|
1051
|
+
style = dialog.style()
|
|
1052
|
+
assert style is not None
|
|
1053
|
+
icon = style.standardIcon(
|
|
1054
|
+
qtw.QStyle.StandardPixmap.SP_MessageBoxWarning
|
|
1055
|
+
)
|
|
1056
|
+
icon_label.setPixmap(icon.pixmap(64, 64))
|
|
1057
|
+
icon_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
|
|
1058
|
+
content.addWidget(icon_label)
|
|
1059
|
+
|
|
1060
|
+
message_label = qtw.QLabel(self._heatmap_downsample_dialog_message(), dialog)
|
|
1061
|
+
message_label.setTextFormat(QtCore.Qt.TextFormat.PlainText)
|
|
1062
|
+
message_label.setWordWrap(True)
|
|
1063
|
+
message_label.setMinimumWidth(520)
|
|
1064
|
+
content.addWidget(message_label, 1)
|
|
1065
|
+
layout.addLayout(content)
|
|
1066
|
+
|
|
1067
|
+
button_box = qtw.QDialogButtonBox(
|
|
1068
|
+
qtw.QDialogButtonBox.StandardButton.Ok,
|
|
1069
|
+
dialog,
|
|
1070
|
+
)
|
|
1071
|
+
preferences_button = qtw.QPushButton("Open Preferences…", dialog)
|
|
1072
|
+
button_box.addButton(
|
|
1073
|
+
preferences_button, qtw.QDialogButtonBox.ButtonRole.ActionRole
|
|
1074
|
+
)
|
|
1075
|
+
preferences_button.setObjectName("openHeatmapLimitPreferencesButton")
|
|
1076
|
+
preferences_button.clicked.connect(
|
|
1077
|
+
lambda: self._open_heatmap_limit_preferences(dialog)
|
|
1078
|
+
)
|
|
1079
|
+
button_box.accepted.connect(dialog.accept)
|
|
1080
|
+
layout.addWidget(button_box)
|
|
1081
|
+
return dialog
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def _open_heatmap_limit_preferences(self, dialog: qtw.QDialog) -> None:
|
|
1085
|
+
"""Close the alert, then open the preference that controls this limit."""
|
|
1086
|
+
dialog.accept()
|
|
1087
|
+
QtCore.QTimer.singleShot(
|
|
1088
|
+
0,
|
|
1089
|
+
lambda: self.show_preferences_dialog(initial_tab="Runtime"),
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def _heatmap_source_grid_description(self, info: dict[str, Any]) -> str:
|
|
1094
|
+
if info.get("source_dimensions_limited"):
|
|
1095
|
+
return (
|
|
1096
|
+
"Exact source dimensions were not retained during downsampling "
|
|
1097
|
+
"to keep memory use bounded. This does not omit source measurements "
|
|
1098
|
+
"from the plotted averages."
|
|
1099
|
+
)
|
|
1100
|
+
columns, rows = info.get("source_grid_columns"), info.get("source_grid_rows")
|
|
1101
|
+
if columns is None or rows is None:
|
|
1102
|
+
return "Exact source grid dimensions are unavailable in the run metadata."
|
|
1103
|
+
return (
|
|
1104
|
+
f"The source heatmap grid is {self._format_heatmap_count(columns)} x "
|
|
1105
|
+
f"{self._format_heatmap_count(rows)} = "
|
|
1106
|
+
f"{self._format_heatmap_count(info.get('source_grid_cell_count'))} cells."
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
def _heatmap_downsample_dialog_message(self) -> str:
|
|
1110
|
+
"""Return the plain-text details shown below the dialog title."""
|
|
1111
|
+
info = self._heatmap_downsample_info or {}
|
|
1112
|
+
lines = ["This heatmap is displayed from downsampled data.", ""]
|
|
1113
|
+
|
|
1114
|
+
if info.get("grid_binned"):
|
|
1115
|
+
full_limit = self._format_heatmap_count(
|
|
1116
|
+
info.get("full_resolution_point_limit")
|
|
1117
|
+
)
|
|
1118
|
+
grid_columns = self._format_heatmap_count(info.get("grid_columns"))
|
|
1119
|
+
grid_rows = self._format_heatmap_count(info.get("grid_rows"))
|
|
1120
|
+
grid_cells = self._format_heatmap_count(info.get("grid_cell_count"))
|
|
1121
|
+
lines.extend([
|
|
1122
|
+
self._heatmap_source_grid_description(info),
|
|
1123
|
+
"The full-resolution heatmap limit is "
|
|
1124
|
+
f"{full_limit} points.",
|
|
1125
|
+
f"The plotted grid is {grid_columns} x {grid_rows} = "
|
|
1126
|
+
f"{grid_cells} cells.",
|
|
1127
|
+
"Values falling into the same plotted grid cell were averaged.",
|
|
1128
|
+
"",
|
|
1129
|
+
])
|
|
1130
|
+
|
|
1131
|
+
lines.extend([
|
|
1132
|
+
"The full-resolution heatmap limit can be changed using the "
|
|
1133
|
+
"Preferences dialog.",
|
|
1134
|
+
"",
|
|
1135
|
+
"The full-resolution limit controls when qPlot switches to an "
|
|
1136
|
+
"averaged display grid.",
|
|
1137
|
+
])
|
|
1138
|
+
return "\n".join(lines)
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def _heatmap_downsample_dialog_text(self) -> str:
|
|
1142
|
+
info = self._heatmap_downsample_info or {}
|
|
1143
|
+
lines = ["This heatmap is displayed from downsampled data."]
|
|
1144
|
+
|
|
1145
|
+
if (
|
|
1146
|
+
not info.get("source_sampled")
|
|
1147
|
+
and not info.get("source_aggregated")
|
|
1148
|
+
and not info.get("grid_binned")
|
|
1149
|
+
):
|
|
1150
|
+
return "\n".join(lines)
|
|
1151
|
+
|
|
1152
|
+
lines.append("")
|
|
1153
|
+
|
|
1154
|
+
axis_ranges = info.get("axis_ranges")
|
|
1155
|
+
if isinstance(axis_ranges, dict):
|
|
1156
|
+
x_range = self._format_heatmap_axis_range(axis_ranges.get("x"))
|
|
1157
|
+
y_range = self._format_heatmap_axis_range(axis_ranges.get("y"))
|
|
1158
|
+
if x_range and y_range:
|
|
1159
|
+
lines.extend([
|
|
1160
|
+
"Visible range loaded:",
|
|
1161
|
+
f"X: {x_range}",
|
|
1162
|
+
f"Y: {y_range}",
|
|
1163
|
+
"",
|
|
1164
|
+
])
|
|
1165
|
+
|
|
1166
|
+
grid_columns = self._format_heatmap_count(info.get("grid_columns"))
|
|
1167
|
+
grid_rows = self._format_heatmap_count(info.get("grid_rows"))
|
|
1168
|
+
grid_cells = self._format_heatmap_count(info.get("grid_cell_count"))
|
|
1169
|
+
full_limit = self._format_heatmap_count(
|
|
1170
|
+
info.get("full_resolution_point_limit")
|
|
1171
|
+
)
|
|
1172
|
+
if info.get("grid_binned"):
|
|
1173
|
+
lines.extend([
|
|
1174
|
+
"Full-resolution grid:",
|
|
1175
|
+
self._heatmap_source_grid_description(info),
|
|
1176
|
+
"The full-resolution heatmap limit is "
|
|
1177
|
+
f"{full_limit} points.",
|
|
1178
|
+
f"The plotted grid is {grid_columns} x {grid_rows} = "
|
|
1179
|
+
f"{grid_cells} cells.",
|
|
1180
|
+
"Values falling into the same plotted grid cell were averaged.",
|
|
1181
|
+
"",
|
|
1182
|
+
])
|
|
1183
|
+
|
|
1184
|
+
lines.append("Source rows:")
|
|
1185
|
+
loaded_count = info.get("loaded_point_count")
|
|
1186
|
+
source_count = info.get("source_row_count")
|
|
1187
|
+
aggregated_count = info.get("aggregated_source_row_count")
|
|
1188
|
+
if info.get("source_aggregated") and aggregated_count is not None:
|
|
1189
|
+
lines.append(
|
|
1190
|
+
"All "
|
|
1191
|
+
f"{self._format_heatmap_count(aggregated_count)} matching "
|
|
1192
|
+
"source rows contributed to "
|
|
1193
|
+
f"{self._format_heatmap_count(loaded_count)} spatial mean cells."
|
|
1194
|
+
)
|
|
1195
|
+
elif loaded_count is not None and source_count is not None:
|
|
1196
|
+
lines.append(
|
|
1197
|
+
"Loaded "
|
|
1198
|
+
f"{self._format_heatmap_count(loaded_count)} finite "
|
|
1199
|
+
"source rows from "
|
|
1200
|
+
f"{self._format_heatmap_count(source_count)} database rows."
|
|
1201
|
+
)
|
|
1202
|
+
elif loaded_count is not None:
|
|
1203
|
+
lines.append(
|
|
1204
|
+
"Loaded "
|
|
1205
|
+
f"{self._format_heatmap_count(loaded_count)} finite source rows."
|
|
1206
|
+
)
|
|
1207
|
+
else:
|
|
1208
|
+
lines.append("The number of loaded source rows is unknown.")
|
|
1209
|
+
estimated_rows = info.get("estimated_range_rows")
|
|
1210
|
+
if estimated_rows not in (None, info.get("source_row_count")):
|
|
1211
|
+
lines.append(
|
|
1212
|
+
"Estimated rows in the visible range: "
|
|
1213
|
+
f"{self._format_heatmap_count(estimated_rows)}."
|
|
1214
|
+
)
|
|
1215
|
+
if info.get("source_sampled"):
|
|
1216
|
+
sample_limit = self._format_heatmap_count(
|
|
1217
|
+
info.get("source_sample_limit")
|
|
1218
|
+
)
|
|
1219
|
+
stride = info.get("source_sample_stride")
|
|
1220
|
+
if isinstance(stride, (int, float)) and stride > 1:
|
|
1221
|
+
lines.append(
|
|
1222
|
+
"Database rows were stride-sampled before plotting "
|
|
1223
|
+
"(about every "
|
|
1224
|
+
f"{self._format_heatmap_ordinal(stride)} matching row), "
|
|
1225
|
+
"capped at "
|
|
1226
|
+
f"{sample_limit} rows."
|
|
1227
|
+
)
|
|
1228
|
+
else:
|
|
1229
|
+
lines.append(
|
|
1230
|
+
"Database rows were uniformly sampled before plotting, capped at "
|
|
1231
|
+
f"{sample_limit} rows."
|
|
1232
|
+
)
|
|
1233
|
+
elif not info.get("source_aggregated"):
|
|
1234
|
+
lines.append("All matching source rows were read before display binning.")
|
|
1235
|
+
|
|
1236
|
+
lines.extend(["", "Display grid:"])
|
|
1237
|
+
unique_x = self._format_heatmap_count(info.get("unique_x_count"))
|
|
1238
|
+
unique_y = self._format_heatmap_count(info.get("unique_y_count"))
|
|
1239
|
+
if info.get("grid_binned"):
|
|
1240
|
+
lines.append(
|
|
1241
|
+
"Loaded values were averaged into a "
|
|
1242
|
+
f"{grid_columns} x {grid_rows} display grid."
|
|
1243
|
+
)
|
|
1244
|
+
if info.get("unique_x_count") is None or info.get("unique_y_count") is None:
|
|
1245
|
+
lines.append(
|
|
1246
|
+
"Exact source-axis counts were not retained during downsampling."
|
|
1247
|
+
if info.get("source_dimensions_limited") else
|
|
1248
|
+
"Exact source-axis counts are unavailable."
|
|
1249
|
+
)
|
|
1250
|
+
else:
|
|
1251
|
+
lines.append(
|
|
1252
|
+
"The source x/y positions would form up to "
|
|
1253
|
+
f"{unique_x} x {unique_y} cells."
|
|
1254
|
+
)
|
|
1255
|
+
else:
|
|
1256
|
+
lines.append(
|
|
1257
|
+
"Loaded values were displayed on an exact "
|
|
1258
|
+
f"{grid_columns} x {grid_rows} grid."
|
|
1259
|
+
)
|
|
1260
|
+
|
|
1261
|
+
if info.get("empty_bins_filled"):
|
|
1262
|
+
if info.get("source_sampled"):
|
|
1263
|
+
lines.append(
|
|
1264
|
+
"Empty sampled display bins were filled by interpolation."
|
|
1265
|
+
)
|
|
1266
|
+
else:
|
|
1267
|
+
lines.append(
|
|
1268
|
+
"Empty display bins were filled by interpolation."
|
|
1269
|
+
)
|
|
1270
|
+
|
|
1271
|
+
return "\n".join(lines)
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
@staticmethod
|
|
1275
|
+
def _format_heatmap_count(value: Any) -> str:
|
|
1276
|
+
try:
|
|
1277
|
+
return f"{int(value):,}"
|
|
1278
|
+
except (TypeError, ValueError):
|
|
1279
|
+
return "unknown"
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
@staticmethod
|
|
1283
|
+
def _format_heatmap_ordinal(value: Any) -> str:
|
|
1284
|
+
try:
|
|
1285
|
+
number = int(value)
|
|
1286
|
+
except (TypeError, ValueError):
|
|
1287
|
+
return "unknown"
|
|
1288
|
+
|
|
1289
|
+
suffix = "th"
|
|
1290
|
+
if number % 100 not in (11, 12, 13):
|
|
1291
|
+
suffix = {1: "st", 2: "nd", 3: "rd"}.get(number % 10, "th")
|
|
1292
|
+
|
|
1293
|
+
return f"{number:,}{suffix}"
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
@staticmethod
|
|
1297
|
+
def _format_heatmap_axis_range(value: Any) -> str | None:
|
|
1298
|
+
try:
|
|
1299
|
+
low, high = value
|
|
1300
|
+
low = float(low)
|
|
1301
|
+
high = float(high)
|
|
1302
|
+
except (TypeError, ValueError):
|
|
1303
|
+
return None
|
|
1304
|
+
|
|
1305
|
+
if not np.isfinite(low) or not np.isfinite(high):
|
|
1306
|
+
return None
|
|
1307
|
+
|
|
1308
|
+
return f"{low:.6g} to {high:.6g}"
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def _axis_ranges_from_data(self) -> dict[str, tuple[float, float]] | None:
|
|
1312
|
+
ranges = {}
|
|
1313
|
+
for axis in ("x", "y"):
|
|
1314
|
+
values = np.asarray(self.axis_data.get(axis, []), dtype=float)
|
|
1315
|
+
finite = values[np.isfinite(values)]
|
|
1316
|
+
if finite.size == 0:
|
|
1317
|
+
return None
|
|
1318
|
+
|
|
1319
|
+
low = float(np.nanmin(finite))
|
|
1320
|
+
high = float(np.nanmax(finite))
|
|
1321
|
+
if low == high:
|
|
1322
|
+
return None
|
|
1323
|
+
|
|
1324
|
+
ranges[axis] = (low, high)
|
|
1325
|
+
|
|
1326
|
+
return ranges
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def _axis_view_ranges_from_data(
|
|
1330
|
+
self,
|
|
1331
|
+
) -> dict[str, tuple[float, float]] | None:
|
|
1332
|
+
try:
|
|
1333
|
+
geometry = HeatmapGeometry.from_centres(
|
|
1334
|
+
self.axis_data.get("x", []),
|
|
1335
|
+
self.axis_data.get("y", []),
|
|
1336
|
+
)
|
|
1337
|
+
except (TypeError, ValueError):
|
|
1338
|
+
return None
|
|
1339
|
+
|
|
1340
|
+
return {
|
|
1341
|
+
"x": (float(geometry.x.edges[0]), float(geometry.x.edges[-1])),
|
|
1342
|
+
"y": (float(geometry.y.edges[0]), float(geometry.y.edges[-1])),
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
def _zoom_large_heatmap_to_all(self) -> None:
|
|
1347
|
+
if not self._large_heatmap_sql_mode:
|
|
1348
|
+
self._sync_secondary_heatmap_view_ranges()
|
|
1349
|
+
return
|
|
1350
|
+
|
|
1351
|
+
full_axis_ranges = self._heatmap_full_axis_ranges
|
|
1352
|
+
if full_axis_ranges is None:
|
|
1353
|
+
self._sync_secondary_heatmap_view_ranges()
|
|
1354
|
+
return
|
|
1355
|
+
|
|
1356
|
+
self._heatmap_view_reload_timer.stop()
|
|
1357
|
+
view_ranges = self._heatmap_full_view_ranges or full_axis_ranges
|
|
1358
|
+
self._primary_heatmap_viewbox().setRange(
|
|
1359
|
+
xRange=view_ranges["x"],
|
|
1360
|
+
yRange=view_ranges["y"],
|
|
1361
|
+
padding=0,
|
|
1362
|
+
)
|
|
1363
|
+
|
|
1364
|
+
self._reload_full_heatmap_data()
|
|
1365
|
+
self._sync_secondary_heatmap_view_ranges()
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def _reload_full_heatmap_data(self) -> bool:
|
|
1369
|
+
full_axis_ranges = self._heatmap_full_axis_ranges
|
|
1370
|
+
if full_axis_ranges is None or self._heatmap_last_view_ranges is None:
|
|
1371
|
+
return False
|
|
1372
|
+
if getattr(getattr(self, "worker", None), "running", False):
|
|
1373
|
+
self._heatmap_view_reload_timer.start(
|
|
1374
|
+
_HEATMAP_VIEW_RELOAD_DEBOUNCE_MS
|
|
1375
|
+
)
|
|
1376
|
+
return False
|
|
1377
|
+
|
|
1378
|
+
return self.load_data(
|
|
1379
|
+
force_sql_heatmap=True,
|
|
1380
|
+
heatmap_axis_ranges=None,
|
|
1381
|
+
heatmap_full_axis_ranges=full_axis_ranges,
|
|
1382
|
+
status_message=f"Loading full heatmap for {self.param.name}...",
|
|
1383
|
+
)
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def _schedule_visible_heatmap_reload(self, *_args: Any) -> None:
|
|
1387
|
+
if not self.__dict__.get("_heatmap_layer_view_sync_active", False):
|
|
1388
|
+
self._sync_secondary_heatmap_view_ranges()
|
|
1389
|
+
|
|
1390
|
+
if not self._large_heatmap_sql_mode:
|
|
1391
|
+
return
|
|
1392
|
+
|
|
1393
|
+
if not self._has_plottable_heatmap_data():
|
|
1394
|
+
return
|
|
1395
|
+
|
|
1396
|
+
self._heatmap_view_reload_timer.start(_HEATMAP_VIEW_RELOAD_DEBOUNCE_MS)
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
def _reload_visible_heatmap_data(self) -> None:
|
|
1400
|
+
if not self._large_heatmap_sql_mode:
|
|
1401
|
+
return
|
|
1402
|
+
|
|
1403
|
+
if getattr(getattr(self, "worker", None), "running", False):
|
|
1404
|
+
self._heatmap_view_reload_timer.start(_HEATMAP_VIEW_RELOAD_DEBOUNCE_MS)
|
|
1405
|
+
return
|
|
1406
|
+
|
|
1407
|
+
ranges = self._visible_heatmap_axis_ranges()
|
|
1408
|
+
if ranges is None:
|
|
1409
|
+
self._reload_full_heatmap_data()
|
|
1410
|
+
return
|
|
1411
|
+
if self._axis_ranges_match(ranges, self._heatmap_last_view_ranges):
|
|
1412
|
+
return
|
|
1413
|
+
|
|
1414
|
+
self.load_data(
|
|
1415
|
+
force_sql_heatmap=True,
|
|
1416
|
+
heatmap_axis_ranges=ranges,
|
|
1417
|
+
heatmap_full_axis_ranges=self._heatmap_full_axis_ranges,
|
|
1418
|
+
status_message=f"Loading visible data for {self.param.name}...",
|
|
1419
|
+
)
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
def _visible_heatmap_axis_ranges(self) -> dict[str, tuple[float, float]] | None:
|
|
1423
|
+
full_ranges = self._heatmap_full_axis_ranges
|
|
1424
|
+
if full_ranges is None:
|
|
1425
|
+
return None
|
|
1426
|
+
|
|
1427
|
+
try:
|
|
1428
|
+
view_x, view_y = self._primary_heatmap_viewbox().viewRange()
|
|
1429
|
+
except Exception:
|
|
1430
|
+
return None
|
|
1431
|
+
|
|
1432
|
+
ranges = self._normalise_axis_ranges({
|
|
1433
|
+
"x": tuple(view_x),
|
|
1434
|
+
"y": tuple(view_y),
|
|
1435
|
+
})
|
|
1436
|
+
if ranges is None:
|
|
1437
|
+
return None
|
|
1438
|
+
|
|
1439
|
+
clamped = {}
|
|
1440
|
+
is_zoomed = False
|
|
1441
|
+
for axis in ("x", "y"):
|
|
1442
|
+
full_low, full_high = full_ranges[axis]
|
|
1443
|
+
low, high = ranges[axis]
|
|
1444
|
+
full_width = full_high - full_low
|
|
1445
|
+
if full_width <= 0:
|
|
1446
|
+
return None
|
|
1447
|
+
|
|
1448
|
+
low = max(full_low, min(full_high, low))
|
|
1449
|
+
high = max(full_low, min(full_high, high))
|
|
1450
|
+
if high <= low:
|
|
1451
|
+
return None
|
|
1452
|
+
|
|
1453
|
+
if (high - low) < full_width * _HEATMAP_VIEW_RELOAD_MIN_FRACTION:
|
|
1454
|
+
is_zoomed = True
|
|
1455
|
+
|
|
1456
|
+
clamped[axis] = (low, high)
|
|
1457
|
+
|
|
1458
|
+
return clamped if is_zoomed else None
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
def _normalise_axis_ranges(
|
|
1462
|
+
self,
|
|
1463
|
+
ranges: dict[str, tuple[float, float]] | None,
|
|
1464
|
+
) -> dict[str, tuple[float, float]] | None:
|
|
1465
|
+
if ranges is None:
|
|
1466
|
+
return None
|
|
1467
|
+
|
|
1468
|
+
normalised = {}
|
|
1469
|
+
for axis in ("x", "y"):
|
|
1470
|
+
axis_range = ranges.get(axis)
|
|
1471
|
+
if axis_range is None:
|
|
1472
|
+
return None
|
|
1473
|
+
|
|
1474
|
+
try:
|
|
1475
|
+
low, high = sorted(float(value) for value in axis_range)
|
|
1476
|
+
except (TypeError, ValueError):
|
|
1477
|
+
return None
|
|
1478
|
+
|
|
1479
|
+
if not (np.isfinite(low) and np.isfinite(high)) or low == high:
|
|
1480
|
+
return None
|
|
1481
|
+
|
|
1482
|
+
normalised[axis] = (low, high)
|
|
1483
|
+
|
|
1484
|
+
return normalised
|
|
1485
|
+
|
|
1486
|
+
|
|
1487
|
+
def _axis_ranges_match(
|
|
1488
|
+
self,
|
|
1489
|
+
left: dict[str, tuple[float, float]],
|
|
1490
|
+
right: dict[str, tuple[float, float]] | None,
|
|
1491
|
+
) -> bool:
|
|
1492
|
+
if right is None:
|
|
1493
|
+
return False
|
|
1494
|
+
|
|
1495
|
+
for axis in ("x", "y"):
|
|
1496
|
+
left_low, left_high = left[axis]
|
|
1497
|
+
right_low, right_high = right[axis]
|
|
1498
|
+
width = max(abs(left_high - left_low), abs(right_high - right_low))
|
|
1499
|
+
coordinate_scale = max(
|
|
1500
|
+
abs(left_low),
|
|
1501
|
+
abs(left_high),
|
|
1502
|
+
abs(right_low),
|
|
1503
|
+
abs(right_high),
|
|
1504
|
+
1.0,
|
|
1505
|
+
)
|
|
1506
|
+
tolerance = max(
|
|
1507
|
+
width * 0.01,
|
|
1508
|
+
np.finfo(float).eps * coordinate_scale * 16,
|
|
1509
|
+
)
|
|
1510
|
+
if (
|
|
1511
|
+
abs(left_low - right_low) > tolerance
|
|
1512
|
+
or abs(left_high - right_high) > tolerance
|
|
1513
|
+
):
|
|
1514
|
+
return False
|
|
1515
|
+
|
|
1516
|
+
return True
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
def _has_plottable_heatmap_data(self) -> bool:
|
|
1520
|
+
x_data = np.asarray(self.axis_data.get("x", []), dtype=float)
|
|
1521
|
+
y_data = np.asarray(self.axis_data.get("y", []), dtype=float)
|
|
1522
|
+
z_data = np.asarray(self.dataGrid, dtype=float)
|
|
1523
|
+
|
|
1524
|
+
return bool(
|
|
1525
|
+
x_data.size > 0
|
|
1526
|
+
and y_data.size > 0
|
|
1527
|
+
and z_data.size > 0
|
|
1528
|
+
and np.any(np.isfinite(z_data))
|
|
1529
|
+
)
|
|
1530
|
+
|
|
1531
|
+
|
|
1532
|
+
def _update_heatmap_geometry(self) -> HeatmapGeometry:
|
|
1533
|
+
"""Build and install geometry from the current setpoint centres."""
|
|
1534
|
+
|
|
1535
|
+
self.__dict__.pop("heatmap_geometry", None)
|
|
1536
|
+
self.__dict__.pop("rect", None)
|
|
1537
|
+
self._reset_heatmap_hover()
|
|
1538
|
+
x_centres, y_centres, data_grid = canonicalize_heatmap_data(
|
|
1539
|
+
self.axis_data["x"],
|
|
1540
|
+
self.axis_data["y"],
|
|
1541
|
+
self.dataGrid,
|
|
1542
|
+
)
|
|
1543
|
+
geometry = HeatmapGeometry.from_centres(x_centres, y_centres)
|
|
1544
|
+
|
|
1545
|
+
axis_data = dict(self.axis_data)
|
|
1546
|
+
axis_data["x"] = x_centres
|
|
1547
|
+
axis_data["y"] = y_centres
|
|
1548
|
+
self.__dict__["axis_data"] = axis_data
|
|
1549
|
+
self.__dict__["dataGrid"] = data_grid
|
|
1550
|
+
self.__dict__["heatmap_geometry"] = geometry
|
|
1551
|
+
if heatmap_display_range_is_finite(geometry):
|
|
1552
|
+
self.__dict__["rect"] = QtCore.QRectF(*geometry.rect)
|
|
1553
|
+
return geometry
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
def _heatmap_geometry(self) -> HeatmapGeometry | None:
|
|
1557
|
+
geometry = self.__dict__.get("heatmap_geometry")
|
|
1558
|
+
if isinstance(geometry, HeatmapGeometry):
|
|
1559
|
+
return geometry
|
|
1560
|
+
return None
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
def _required_heatmap_geometry(self) -> HeatmapGeometry:
|
|
1564
|
+
geometry = self._heatmap_geometry()
|
|
1565
|
+
if geometry is None:
|
|
1566
|
+
raise RuntimeError("Heatmap geometry is not available.")
|
|
1567
|
+
return geometry
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
def _render_heatmap(self) -> None:
|
|
1571
|
+
"""Render uniform grids as images and rectilinear grids as meshes."""
|
|
1572
|
+
|
|
1573
|
+
if (
|
|
1574
|
+
self.__dict__.get("_heatmap_color_range_rejected", False)
|
|
1575
|
+
or not self.__dict__.get("_primary_heatmap_visible", True)
|
|
1576
|
+
):
|
|
1577
|
+
self._hide_heatmap_renderers()
|
|
1578
|
+
return
|
|
1579
|
+
|
|
1580
|
+
geometry = self._required_heatmap_geometry()
|
|
1581
|
+
# Render a float view; operations, raw cuts and CSV use the exact grid.
|
|
1582
|
+
data_grid = np.asarray(self.dataGrid, dtype=float)
|
|
1583
|
+
color_levels = self.__dict__.get("_heatmap_publication_color_levels")
|
|
1584
|
+
if color_levels is None:
|
|
1585
|
+
color_levels = self._current_colorbar_levels()
|
|
1586
|
+
if geometry.is_uniform:
|
|
1587
|
+
image_options: dict[str, Any] = {"autoLevels": False}
|
|
1588
|
+
if color_levels is not None:
|
|
1589
|
+
image_options["levels"] = color_levels
|
|
1590
|
+
self.image.setImage(data_grid, **image_options)
|
|
1591
|
+
self.image.setRect(QtCore.QRectF(*geometry.rect))
|
|
1592
|
+
self.heatmap_mesh.hide()
|
|
1593
|
+
self.image.show()
|
|
1594
|
+
self._schedule_heatmap_view_validation()
|
|
1595
|
+
return
|
|
1596
|
+
|
|
1597
|
+
mesh_data = np.asarray(data_grid, dtype=float).copy()
|
|
1598
|
+
mesh_data[~np.isfinite(mesh_data)] = np.nan
|
|
1599
|
+
x_vertices, y_vertices = np.meshgrid(
|
|
1600
|
+
geometry.x.edges,
|
|
1601
|
+
geometry.y.edges,
|
|
1602
|
+
indexing="xy",
|
|
1603
|
+
)
|
|
1604
|
+
if color_levels is not None:
|
|
1605
|
+
self.heatmap_mesh.setLevels(color_levels, update=False)
|
|
1606
|
+
self.heatmap_mesh.setData(
|
|
1607
|
+
x_vertices,
|
|
1608
|
+
y_vertices,
|
|
1609
|
+
mesh_data,
|
|
1610
|
+
autoLevels=False,
|
|
1611
|
+
)
|
|
1612
|
+
self.image.hide()
|
|
1613
|
+
self.heatmap_mesh.show()
|
|
1614
|
+
self._schedule_heatmap_view_validation()
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
def _hide_heatmap_renderers(self) -> None:
|
|
1618
|
+
for item_name in ("image", "heatmap_mesh"):
|
|
1619
|
+
item = self.__dict__.get(item_name)
|
|
1620
|
+
if item is not None:
|
|
1621
|
+
item.hide()
|
|
1622
|
+
|
|
1623
|
+
|
|
1624
|
+
def _reset_heatmap_hover(self) -> None:
|
|
1625
|
+
self.hide_hover_pixel_outline()
|
|
1626
|
+
labels = self.__dict__.get("pos_labels")
|
|
1627
|
+
if not isinstance(labels, dict):
|
|
1628
|
+
return
|
|
1629
|
+
index_label = labels.get("index")
|
|
1630
|
+
if index_label is not None:
|
|
1631
|
+
index_label.setText("")
|
|
1632
|
+
z_label = labels.get("z")
|
|
1633
|
+
if z_label is not None:
|
|
1634
|
+
z_label.setText("z =")
|
|
1635
|
+
|
|
1636
|
+
|
|
1637
|
+
def _invalidate_heatmap_geometry(self) -> None:
|
|
1638
|
+
self.__dict__.pop("heatmap_geometry", None)
|
|
1639
|
+
self.__dict__.pop("rect", None)
|
|
1640
|
+
self._hide_heatmap_renderers()
|
|
1641
|
+
self._reset_heatmap_hover()
|
|
1642
|
+
if self.__dict__.get("marquee") is not None:
|
|
1643
|
+
self.clear_marquee()
|
|
1644
|
+
self._set_sweep_lines_visible(False)
|
|
1645
|
+
|
|
1646
|
+
|
|
1647
|
+
def _restore_heatmap_interactions(self) -> None:
|
|
1648
|
+
self._set_sweep_lines_visible(True)
|
|
1649
|
+
marquee = self.__dict__.get("marquee")
|
|
1650
|
+
if isinstance(marquee, QtCore.QRectF):
|
|
1651
|
+
if self._marquee_intersects_heatmap(marquee):
|
|
1652
|
+
self.set_marquee_rect(marquee)
|
|
1653
|
+
else:
|
|
1654
|
+
self.clear_marquee()
|
|
1655
|
+
self._snap_sweep_lines_to_pixel_centres()
|
|
1656
|
+
|
|
1657
|
+
|
|
1658
|
+
def _set_sweep_lines_visible(self, visible: bool) -> None:
|
|
1659
|
+
for line in self.__dict__.get("sweep_lines", {}).values():
|
|
1660
|
+
set_visible = getattr(line, "setVisible", None)
|
|
1661
|
+
if callable(set_visible):
|
|
1662
|
+
set_visible(visible)
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
def _marquee_intersects_heatmap(self, rect: QtCore.QRectF) -> bool:
|
|
1666
|
+
geometry = self._heatmap_geometry()
|
|
1667
|
+
if geometry is None:
|
|
1668
|
+
return False
|
|
1669
|
+
normalised = rect.normalized()
|
|
1670
|
+
left, bottom, right, top = geometry.bounds
|
|
1671
|
+
return bool(
|
|
1672
|
+
normalised.right() > left
|
|
1673
|
+
and normalised.left() < right
|
|
1674
|
+
and normalised.bottom() > bottom
|
|
1675
|
+
and normalised.top() < top
|
|
1676
|
+
)
|
|
1677
|
+
|
|
1678
|
+
|
|
1679
|
+
def heatmap_sample_at(
|
|
1680
|
+
self,
|
|
1681
|
+
x_value: float,
|
|
1682
|
+
y_value: float,
|
|
1683
|
+
) -> tuple[int, int, float, float, float] | None:
|
|
1684
|
+
"""Return index, recorded coordinates, and value under a point."""
|
|
1685
|
+
|
|
1686
|
+
geometry = self._heatmap_geometry()
|
|
1687
|
+
if geometry is None:
|
|
1688
|
+
return None
|
|
1689
|
+
index = geometry.index_at(x_value, y_value)
|
|
1690
|
+
if index is None:
|
|
1691
|
+
return None
|
|
1692
|
+
|
|
1693
|
+
x_index, y_index = index
|
|
1694
|
+
return (
|
|
1695
|
+
x_index,
|
|
1696
|
+
y_index,
|
|
1697
|
+
geometry.x.centre(x_index),
|
|
1698
|
+
geometry.y.centre(y_index),
|
|
1699
|
+
float(self.dataGrid[y_index, x_index]),
|
|
1700
|
+
)
|
|
1701
|
+
|
|
1702
|
+
|
|
1703
|
+
def show_hover_pixel_outline(self, i: int, j: int) -> None:
|
|
1704
|
+
"""
|
|
1705
|
+
Move the hover outline to the heatmap pixel at the given data indices.
|
|
1706
|
+
|
|
1707
|
+
Parameters
|
|
1708
|
+
----------
|
|
1709
|
+
i : int
|
|
1710
|
+
Column index within the heatmap data grid.
|
|
1711
|
+
j : int
|
|
1712
|
+
Row index within the heatmap data grid.
|
|
1713
|
+
"""
|
|
1714
|
+
self.__dict__["z_index"] = [i, j]
|
|
1715
|
+
self._update_hover_pixel_outline_from_index()
|
|
1716
|
+
|
|
1717
|
+
|
|
1718
|
+
def hide_hover_pixel_outline(self) -> None:
|
|
1719
|
+
"""
|
|
1720
|
+
Hide the heatmap hover outline and clear the saved hover index.
|
|
1721
|
+
|
|
1722
|
+
"""
|
|
1723
|
+
self.__dict__["z_index"] = None
|
|
1724
|
+
outline = self.__dict__.get("hover_pixel_outline")
|
|
1725
|
+
if outline is not None:
|
|
1726
|
+
outline.hide()
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
def _update_hover_pixel_outline_from_index(self) -> None:
|
|
1730
|
+
geometry = self._heatmap_geometry()
|
|
1731
|
+
z_index = self.__dict__.get("z_index")
|
|
1732
|
+
if (
|
|
1733
|
+
not hasattr(self, "hover_pixel_outline")
|
|
1734
|
+
or geometry is None
|
|
1735
|
+
or not isinstance(z_index, list)
|
|
1736
|
+
or len(z_index) != 2
|
|
1737
|
+
):
|
|
1738
|
+
if hasattr(self, "hover_pixel_outline"):
|
|
1739
|
+
self.hover_pixel_outline.hide()
|
|
1740
|
+
return
|
|
1741
|
+
|
|
1742
|
+
i, j = z_index
|
|
1743
|
+
try:
|
|
1744
|
+
cell_rect = geometry.cell_rect(i, j)
|
|
1745
|
+
except (IndexError, TypeError):
|
|
1746
|
+
self.__dict__["z_index"] = None
|
|
1747
|
+
self.hover_pixel_outline.hide()
|
|
1748
|
+
return
|
|
1749
|
+
|
|
1750
|
+
self.hover_pixel_outline.setRect(QtCore.QRectF(*cell_rect))
|
|
1751
|
+
self.hover_pixel_outline.show()
|
|
1752
|
+
|
|
1753
|
+
|
|
1754
|
+
def _snap_marquee_rect(self, rect: QtCore.QRectF) -> QtCore.QRectF:
|
|
1755
|
+
"""
|
|
1756
|
+
Snap marquee edges to heatmap pixel boundaries.
|
|
1757
|
+
|
|
1758
|
+
"""
|
|
1759
|
+
geometry = self._heatmap_geometry()
|
|
1760
|
+
if geometry is None:
|
|
1761
|
+
return rect
|
|
1762
|
+
|
|
1763
|
+
try:
|
|
1764
|
+
left, right = geometry.x.snap_interval(rect.left(), rect.right())
|
|
1765
|
+
bottom, top = geometry.y.snap_interval(rect.top(), rect.bottom())
|
|
1766
|
+
except ValueError:
|
|
1767
|
+
return rect
|
|
1768
|
+
|
|
1769
|
+
return QtCore.QRectF(left, bottom, right - left, top - bottom)
|
|
1770
|
+
|
|
1771
|
+
|
|
1772
|
+
def _snap_translated_marquee_rect(
|
|
1773
|
+
self,
|
|
1774
|
+
rect: QtCore.QRectF,
|
|
1775
|
+
original: QtCore.QRectF,
|
|
1776
|
+
handle: Any,
|
|
1777
|
+
) -> None:
|
|
1778
|
+
"""Preserve selected cell counts during Shift-drag translation."""
|
|
1779
|
+
|
|
1780
|
+
geometry = self._heatmap_geometry()
|
|
1781
|
+
if geometry is None:
|
|
1782
|
+
super()._snap_translated_marquee_rect(rect, original, handle)
|
|
1783
|
+
return
|
|
1784
|
+
|
|
1785
|
+
original = original.normalized()
|
|
1786
|
+
snapped = self._snap_marquee_rect(QtCore.QRectF(rect).normalized())
|
|
1787
|
+
adjusted = QtCore.QRectF(snapped)
|
|
1788
|
+
try:
|
|
1789
|
+
if "w" in handle or "e" in handle:
|
|
1790
|
+
left, right = self._translated_marquee_axis_bounds(
|
|
1791
|
+
geometry.x,
|
|
1792
|
+
original.left(),
|
|
1793
|
+
original.right(),
|
|
1794
|
+
snapped.left(),
|
|
1795
|
+
snapped.right(),
|
|
1796
|
+
anchor_low="w" in handle,
|
|
1797
|
+
)
|
|
1798
|
+
adjusted.setLeft(left)
|
|
1799
|
+
adjusted.setRight(right)
|
|
1800
|
+
if "n" in handle or "s" in handle:
|
|
1801
|
+
bottom, top = self._translated_marquee_axis_bounds(
|
|
1802
|
+
geometry.y,
|
|
1803
|
+
original.top(),
|
|
1804
|
+
original.bottom(),
|
|
1805
|
+
snapped.top(),
|
|
1806
|
+
snapped.bottom(),
|
|
1807
|
+
anchor_low="s" in handle,
|
|
1808
|
+
)
|
|
1809
|
+
adjusted.setTop(bottom)
|
|
1810
|
+
adjusted.setBottom(top)
|
|
1811
|
+
except ValueError:
|
|
1812
|
+
super()._snap_translated_marquee_rect(rect, original, handle)
|
|
1813
|
+
return
|
|
1814
|
+
|
|
1815
|
+
rect.setRect(
|
|
1816
|
+
adjusted.left(),
|
|
1817
|
+
adjusted.top(),
|
|
1818
|
+
adjusted.width(),
|
|
1819
|
+
adjusted.height(),
|
|
1820
|
+
)
|
|
1821
|
+
|
|
1822
|
+
|
|
1823
|
+
@staticmethod
|
|
1824
|
+
def _translated_marquee_axis_bounds(
|
|
1825
|
+
axis: AxisGeometry,
|
|
1826
|
+
original_low: float,
|
|
1827
|
+
original_high: float,
|
|
1828
|
+
snapped_low: float,
|
|
1829
|
+
snapped_high: float,
|
|
1830
|
+
*,
|
|
1831
|
+
anchor_low: bool,
|
|
1832
|
+
) -> tuple[float, float]:
|
|
1833
|
+
original_cells = axis.slice_for_interval(original_low, original_high)
|
|
1834
|
+
target_cells = axis.slice_for_interval(snapped_low, snapped_high)
|
|
1835
|
+
cell_count = original_cells.stop - original_cells.start
|
|
1836
|
+
|
|
1837
|
+
if anchor_low:
|
|
1838
|
+
start = target_cells.start
|
|
1839
|
+
stop = min(start + cell_count, axis.count)
|
|
1840
|
+
start = max(0, stop - cell_count)
|
|
1841
|
+
else:
|
|
1842
|
+
stop = target_cells.stop
|
|
1843
|
+
start = max(0, stop - cell_count)
|
|
1844
|
+
stop = min(axis.count, start + cell_count)
|
|
1845
|
+
|
|
1846
|
+
return axis.edges[start], axis.edges[stop]
|
|
1847
|
+
|
|
1848
|
+
|
|
1849
|
+
def _add_marquee_color_context_action(self, menu: qtw.QMenu) -> QtGui.QAction:
|
|
1850
|
+
action = self._add_marquee_context_action(
|
|
1851
|
+
menu,
|
|
1852
|
+
"Zoom color",
|
|
1853
|
+
self.zoom_marquee_color,
|
|
1854
|
+
)
|
|
1855
|
+
if self._marquee_color_levels() is None:
|
|
1856
|
+
action.setEnabled(False)
|
|
1857
|
+
action.setToolTip("No finite data range inside the marquee.")
|
|
1858
|
+
return action
|
|
1859
|
+
|
|
1860
|
+
|
|
1861
|
+
def zoom_marquee_color(self) -> bool:
|
|
1862
|
+
levels = self._marquee_color_levels()
|
|
1863
|
+
if levels is None:
|
|
1864
|
+
return False
|
|
1865
|
+
|
|
1866
|
+
return self.setColorbarManualRange(*levels)
|
|
1867
|
+
|
|
1868
|
+
|
|
1869
|
+
def _marquee_stats_text(self) -> str | None:
|
|
1870
|
+
selected = self._marquee_selected_data()
|
|
1871
|
+
if selected is None:
|
|
1872
|
+
return None
|
|
1873
|
+
|
|
1874
|
+
values = selected[numeric_isfinite(selected)]
|
|
1875
|
+
if values.size == 0:
|
|
1876
|
+
return None
|
|
1877
|
+
|
|
1878
|
+
if self.marquee is None:
|
|
1879
|
+
return None
|
|
1880
|
+
|
|
1881
|
+
rows, cols = selected.shape
|
|
1882
|
+
rect = self._snap_marquee_rect(self.marquee.normalized())
|
|
1883
|
+
return self._format_marquee_stats_text(f"{cols}×{rows} points", values, rect)
|
|
1884
|
+
|
|
1885
|
+
|
|
1886
|
+
def _marquee_color_levels(self) -> tuple[float, float] | None:
|
|
1887
|
+
selected = self._marquee_selected_data()
|
|
1888
|
+
if selected is None:
|
|
1889
|
+
return None
|
|
1890
|
+
|
|
1891
|
+
values = selected[numeric_isfinite(selected)]
|
|
1892
|
+
if values.size == 0:
|
|
1893
|
+
return None
|
|
1894
|
+
|
|
1895
|
+
vmin = float(values.min())
|
|
1896
|
+
vmax = float(values.max())
|
|
1897
|
+
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin >= vmax:
|
|
1898
|
+
return None
|
|
1899
|
+
|
|
1900
|
+
return vmin, vmax
|
|
1901
|
+
|
|
1902
|
+
|
|
1903
|
+
def _marquee_selected_data(self) -> npt.NDArray[np.float64] | None:
|
|
1904
|
+
if (
|
|
1905
|
+
self.__dict__.get("marquee") is None
|
|
1906
|
+
or self._heatmap_geometry() is None
|
|
1907
|
+
or "dataGrid" not in self.__dict__
|
|
1908
|
+
):
|
|
1909
|
+
return None
|
|
1910
|
+
|
|
1911
|
+
slices = self._marquee_cell_slices()
|
|
1912
|
+
if slices is None:
|
|
1913
|
+
return None
|
|
1914
|
+
|
|
1915
|
+
row_slice, col_slice = slices
|
|
1916
|
+
selected = np.asarray(self.dataGrid[row_slice, col_slice])
|
|
1917
|
+
if selected.size == 0:
|
|
1918
|
+
return None
|
|
1919
|
+
|
|
1920
|
+
return selected
|
|
1921
|
+
|
|
1922
|
+
|
|
1923
|
+
def _marquee_cell_slices(self) -> tuple[slice, slice] | None:
|
|
1924
|
+
geometry = self._heatmap_geometry()
|
|
1925
|
+
if geometry is None or self.marquee is None:
|
|
1926
|
+
return None
|
|
1927
|
+
|
|
1928
|
+
rect = self._snap_marquee_rect(self.marquee.normalized())
|
|
1929
|
+
try:
|
|
1930
|
+
col_slice = geometry.x.slice_for_interval(rect.left(), rect.right())
|
|
1931
|
+
row_slice = geometry.y.slice_for_interval(rect.top(), rect.bottom())
|
|
1932
|
+
except ValueError:
|
|
1933
|
+
return None
|
|
1934
|
+
|
|
1935
|
+
return row_slice, col_slice
|