qplotter 1.6.0__py3-none-any.whl

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