qplotter 1.6.0b3__py3-none-any.whl

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