diff --git a/bec_widgets/widgets/plots/heatmap/heatmap.py b/bec_widgets/widgets/plots/heatmap/heatmap.py
index a6be1174..8fe6e146 100644
--- a/bec_widgets/widgets/plots/heatmap/heatmap.py
+++ b/bec_widgets/widgets/plots/heatmap/heatmap.py
@@ -9,13 +9,15 @@ import pyqtgraph as pg
from bec_lib import bec_logger, messages
from bec_lib.endpoints import MessageEndpoints
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
+from bec_qthemes import material_icon
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import QObject, Qt, QThread, QTimer, Signal
from qtpy.QtGui import QTransform
+from qtpy.QtWidgets import QDialog, QPushButton, QVBoxLayout
from toolz import partition
from bec_widgets.utils.bec_connector import ConnectionConfig
-from bec_widgets.utils.colors import Colors
+from bec_widgets.utils.colors import Colors, get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.toolbars.actions import MaterialIconAction
@@ -23,6 +25,7 @@ from bec_widgets.widgets.plots.heatmap.settings.heatmap_setting import HeatmapSe
from bec_widgets.widgets.plots.image.image_base import ImageBase
from bec_widgets.widgets.plots.image.image_item import ImageItem
from bec_widgets.widgets.plots.plot_base import PlotBase
+from bec_widgets.widgets.services.scan_history_browser.components import ScanHistoryView
logger = bec_logger.logger
@@ -268,6 +271,11 @@ class Heatmap(ImageBase):
self._interpolation_worker: _StepInterpolationWorker | None = None
self._pending_interpolation_request: _InterpolationRequest | None = None
self.heatmap_dialog = None
+ self.scan_history_dialog = None
+ self.scan_history_widget = None
+ # Scan ID the widget is pinned to when plotting from history; None means live mode.
+ self._history_scan_id: str | None = None
+ self._selected_history_scan_id: str | None = None
bg_color = pg.mkColor((240, 240, 240, 150))
self.config_label = pg.LegendItem(
labelTextColor=(0, 0, 0), offset=(-30, 1), brush=pg.mkBrush(bg_color), horSpacing=0
@@ -425,8 +433,10 @@ class Heatmap(ImageBase):
self.update_with_scan_history(scan_id=scan_id)
return
+ self._history_scan_id = None
self._fetch_running_scan()
- self.sync_signal_update.emit()
+ # Also notifies settings widgets and triggers a plot update via sync_signal_update
+ self.heatmap_property_changed.emit()
def _fetch_running_scan(self):
scan = self.client.queue.scan_storage.current_scan
@@ -480,7 +490,12 @@ class Heatmap(ImageBase):
else:
self.scan_id = self.scan_item.scan_id
- self.sync_signal_update.emit()
+ # Pin the widget to the history scan; live scan updates are ignored until
+ # plot() is called again without a scan_id.
+ self._history_scan_id = self.scan_id
+
+ # Also notifies settings widgets and triggers a plot update via sync_signal_update
+ self.heatmap_property_changed.emit()
def update_labels(self):
"""
@@ -529,6 +544,20 @@ class Heatmap(ImageBase):
self.show_heatmap_settings
)
+ self.toolbar.components.add_safe(
+ "scan_history",
+ MaterialIconAction(
+ icon_name="manage_search",
+ tooltip="Open Scan History browser",
+ checkable=True,
+ parent=self,
+ ),
+ )
+ self.toolbar.get_bundle("axis_popup").add_action("scan_history")
+ self.toolbar.components.get_action("scan_history").action.triggered.connect(
+ self.show_scan_history_popup
+ )
+
# disable all processing actions except for the fft and log
bundle = self.toolbar.get_bundle("image_processing")
for name, action in bundle.bundle_actions.items():
@@ -569,6 +598,71 @@ class Heatmap(ImageBase):
self.heatmap_dialog.activateWindow()
heatmap_settings_action.setChecked(True) # keep it toggled
+ ################################################################################
+ # Scan History browser popup
+
+ def show_scan_history_popup(self):
+ """
+ Show the scan history popup with the scan history browser and a plotting button.
+ Requesting a plot pins the heatmap to the selected scan instead of the live acquisition.
+ """
+ scan_history_action = self.toolbar.components.get_action("scan_history").action
+ if self.scan_history_dialog is None or not self.scan_history_dialog.isVisible():
+ self.scan_history_widget = ScanHistoryView(parent=self)
+ self.scan_history_widget.scan_selected.connect(self._on_history_scan_selected)
+ self.scan_history_widget.no_scan_selected.connect(self._on_history_scan_deselected)
+ request_plotting_button = QPushButton(
+ material_icon("play_arrow", size=(24, 24), color=get_accent_colors().success),
+ "Request Plotting",
+ )
+ request_plotting_button.clicked.connect(self._request_history_plot)
+ self.scan_history_dialog = QDialog(modal=False)
+ self.scan_history_dialog.setWindowTitle(f"{self.object_name} - Scan History Browser")
+ self.scan_history_dialog.layout = QVBoxLayout(self.scan_history_dialog)
+ self.scan_history_dialog.layout.addWidget(self.scan_history_widget)
+ self.scan_history_dialog.layout.addWidget(request_plotting_button)
+ self.scan_history_dialog.finished.connect(self._scan_history_closed)
+ self.scan_history_dialog.show()
+ self.scan_history_dialog.resize(380, 320)
+ scan_history_action.setChecked(True)
+ else:
+ # If already open, bring it to the front
+ self.scan_history_dialog.raise_()
+ self.scan_history_dialog.activateWindow()
+ scan_history_action.setChecked(True) # keep it toggle
+
+ @SafeSlot(dict, dict)
+ def _on_history_scan_selected(self, content: dict, metadata: dict):
+ """Track the scan selected in the scan history browser."""
+ self._selected_history_scan_id = content["scan_id"]
+
+ @SafeSlot()
+ def _on_history_scan_deselected(self):
+ """Clear the tracked scan when no scan is selected in the scan history browser."""
+ self._selected_history_scan_id = None
+
+ @SafeSlot()
+ def _request_history_plot(self):
+ """Plot the currently configured devices for the scan selected in the history browser."""
+ if self._selected_history_scan_id is None:
+ logger.info("No scan selected in the scan history browser; skipping plot request.")
+ return
+ self.update_with_scan_history(scan_id=self._selected_history_scan_id)
+
+ def _scan_history_closed(self):
+ """
+ Slot for when the scan history dialog is closed.
+ """
+ if self.scan_history_dialog is None:
+ return
+ self.scan_history_widget.close()
+ self.scan_history_widget.deleteLater()
+ self.scan_history_widget = None
+ self.scan_history_dialog.deleteLater()
+ self.scan_history_dialog = None
+ self._selected_history_scan_id = None
+ self.toolbar.components.get_action("scan_history").action.setChecked(False)
+
def toggle_interpolation_info(self):
"""
Toggle the visibility of the interpolation info label.
@@ -595,6 +689,8 @@ class Heatmap(ImageBase):
msg(dict): The message content.
meta(dict): The message metadata.
"""
+ if self._history_scan_id is not None:
+ return
current_scan_id = msg.get("scan_id", None)
if current_scan_id is None:
return
@@ -612,6 +708,8 @@ class Heatmap(ImageBase):
@SafeSlot(dict, dict)
def on_scan_progress(self, msg: dict, meta: dict):
+ if self._history_scan_id is not None:
+ return
self.sync_signal_update.emit()
status = msg.get("done")
if status:
@@ -877,7 +975,9 @@ class Heatmap(ImageBase):
self.config_label.setOffset((-30, 1))
self.config_label.setVisible(True)
self.config_label.clear()
- self.config_label.addItem(self.plot_item, f"Scan: {scan_msg.scan_number}")
+ # Indicate whether the widget follows the live acquisition or is pinned to a history scan
+ mode = "history" if self._history_scan_id is not None else "live"
+ self.config_label.addItem(self.plot_item, f"Scan: {scan_msg.scan_number} ({mode})")
self.config_label.addItem(self.plot_item, f"Scan Name: {scan_msg.scan_name}")
if scan_msg.scan_name != "grid_scan" or self._image_config.enforce_interpolation:
self.config_label.addItem(
@@ -1581,6 +1681,9 @@ class Heatmap(ImageBase):
def cleanup(self):
self._finish_interpolation_thread()
+ if self.scan_history_dialog is not None:
+ self.scan_history_dialog.reject()
+ self.scan_history_dialog = None
super().cleanup()
diff --git a/bec_widgets/widgets/plots/heatmap/settings/heatmap_setting.py b/bec_widgets/widgets/plots/heatmap/settings/heatmap_setting.py
index dd60a38f..b448fc64 100644
--- a/bec_widgets/widgets/plots/heatmap/settings/heatmap_setting.py
+++ b/bec_widgets/widgets/plots/heatmap/settings/heatmap_setting.py
@@ -113,8 +113,10 @@ class HeatmapSettings(SettingWidget):
self.ui.enforce_interpolation.setChecked(
getattr(self.target_widget._image_config, "enforce_interpolation", False)
)
+ self.ui.scan_index.refresh_scan_indices()
+ self.ui.scan_index.set_scan_id(self.target_widget._history_scan_id)
- @SafeSlot()
+ @SafeSlot(popup_error=True)
def accept_changes(self):
"""
Apply all properties from the settings widget to the target widget.
@@ -130,6 +132,7 @@ class HeatmapSettings(SettingWidget):
interpolation = self.ui.interpolation.currentText()
oversampling_factor = self.ui.oversampling_factor.value()
enforce_interpolation = self.ui.enforce_interpolation.isChecked()
+ scan_id = self.ui.scan_index.scan_id
self.target_widget.plot(
device_x=device_x,
@@ -143,6 +146,7 @@ class HeatmapSettings(SettingWidget):
interpolation=interpolation,
oversampling_factor=oversampling_factor,
enforce_interpolation=enforce_interpolation,
+ scan_id=scan_id,
reload=True,
)
@@ -159,5 +163,7 @@ class HeatmapSettings(SettingWidget):
self.ui.device_z.deleteLater()
self.ui.signal_z.close()
self.ui.signal_z.deleteLater()
+ self.ui.scan_index.close()
+ self.ui.scan_index.deleteLater()
self.ui.interpolation.close()
self.ui.interpolation.deleteLater()
diff --git a/bec_widgets/widgets/plots/heatmap/settings/heatmap_settings_horizontal.ui b/bec_widgets/widgets/plots/heatmap/settings/heatmap_settings_horizontal.ui
index 801f1136..d688e589 100644
--- a/bec_widgets/widgets/plots/heatmap/settings/heatmap_settings_horizontal.ui
+++ b/bec_widgets/widgets/plots/heatmap/settings/heatmap_settings_horizontal.ui
@@ -161,6 +161,26 @@
+ -
+
+
+ Scan #
+
+
+
+ -
+
+
+
+ 100
+ 26
+
+
+
+ Plot the selected scan from history, or 'live' for live acquisition.
+
+
+
@@ -320,6 +340,11 @@
QWidget
+
+ ScanIndexComboBox
+ QComboBox
+
+
device_x
diff --git a/bec_widgets/widgets/plots/heatmap/settings/heatmap_settings_vertical.ui b/bec_widgets/widgets/plots/heatmap/settings/heatmap_settings_vertical.ui
index dce274f2..4bf5d12e 100644
--- a/bec_widgets/widgets/plots/heatmap/settings/heatmap_settings_vertical.ui
+++ b/bec_widgets/widgets/plots/heatmap/settings/heatmap_settings_vertical.ui
@@ -48,6 +48,24 @@
+ -
+
+
-
+
+
+ Scan #
+
+
+
+ -
+
+
+ Plot the selected scan from history, or 'live' for live acquisition.
+
+
+
+
+
-
@@ -262,6 +280,11 @@
QWidget
+
+ ScanIndexComboBox
+ QComboBox
+
+
device_x
diff --git a/tests/unit_tests/test_heatmap_widget.py b/tests/unit_tests/test_heatmap_widget.py
index 730037e4..571f1077 100644
--- a/tests/unit_tests/test_heatmap_widget.py
+++ b/tests/unit_tests/test_heatmap_widget.py
@@ -948,3 +948,149 @@ def test_device_properties_with_none_values(heatmap_widget):
# Entry None should not change anything
heatmap_widget.signal_y = None
assert heatmap_widget.signal_y # Should still have validated entry
+
+
+def test_heatmap_history_mode_ignores_live_scan_updates(heatmap_widget):
+ """
+ Once pinned to a history scan, the heatmap ignores live scan status/progress updates
+ until plot() is called again without a scan_id.
+ """
+ history_scan = mock.MagicMock()
+ history_scan.scan_id = "scan-456"
+
+ with mock.patch.object(heatmap_widget, "get_history_scan_item", return_value=history_scan):
+ heatmap_widget.update_with_scan_history(scan_id="scan-456")
+
+ assert heatmap_widget._history_scan_id == "scan-456"
+
+ scan_msg = messages.ScanStatusMessage(scan_id="live-scan", status="open", metadata={}, info={})
+ with mock.patch.object(heatmap_widget, "reset") as reset_mock:
+ heatmap_widget.on_scan_status(scan_msg.content, scan_msg.metadata)
+ reset_mock.assert_not_called()
+ assert heatmap_widget.scan_id == "scan-456"
+
+ with mock.patch.object(heatmap_widget, "sync_signal_update") as sync_mock:
+ heatmap_widget.on_scan_progress({"done": False}, {})
+ sync_mock.emit.assert_not_called()
+
+ # Plotting without a scan_id returns to live mode
+ heatmap_widget.plot(device_x="samx", device_y="samy", device_z="bpm4i")
+ assert heatmap_widget._history_scan_id is None
+ with mock.patch.object(heatmap_widget, "reset") as reset_mock:
+ heatmap_widget.on_scan_status(scan_msg.content, scan_msg.metadata)
+ reset_mock.assert_called_once()
+
+
+def test_heatmap_scan_history_popup(heatmap_widget, qtbot):
+ """
+ Test that the scan history popup opens with the browser view and a plot request button,
+ and that requesting a plot pins the heatmap to the selected scan.
+ """
+ scan_action = heatmap_widget.toolbar.components.get_action("scan_history").action
+ assert not scan_action.isChecked()
+ assert heatmap_widget.scan_history_dialog is None
+
+ heatmap_widget.show_scan_history_popup()
+ qtbot.waitUntil(lambda: heatmap_widget.scan_history_dialog is not None)
+ assert heatmap_widget.scan_history_dialog.isVisible()
+ assert scan_action.isChecked()
+ assert heatmap_widget.scan_history_widget is not None
+
+ # Without a selection, requesting a plot does nothing
+ with mock.patch.object(heatmap_widget, "update_with_scan_history") as update_mock:
+ heatmap_widget._request_history_plot()
+ update_mock.assert_not_called()
+
+ # With a selection, requesting a plot pins to the selected scan
+ heatmap_widget._on_history_scan_selected({"scan_id": "scan-789"}, {})
+ with mock.patch.object(heatmap_widget, "update_with_scan_history") as update_mock:
+ heatmap_widget._request_history_plot()
+ update_mock.assert_called_once_with(scan_id="scan-789")
+
+ heatmap_widget._on_history_scan_deselected()
+ assert heatmap_widget._selected_history_scan_id is None
+
+ heatmap_widget.scan_history_dialog.close()
+ qtbot.waitUntil(lambda: heatmap_widget.scan_history_dialog is None)
+ assert not scan_action.isChecked()
+
+
+def test_heatmap_settings_scan_index(heatmap_widget, qtbot):
+ """
+ Test that the settings dialog exposes the scan index combobox and that accepting
+ the dialog with a history scan selected plots that scan.
+ """
+ heatmap_widget.plot(device_x="samx", device_y="samy", device_z="bpm4i")
+ heatmap_widget.show_heatmap_settings()
+ qtbot.waitUntil(lambda: heatmap_widget.heatmap_dialog is not None)
+
+ dialog = heatmap_widget.heatmap_dialog
+ assert hasattr(dialog.widget.ui, "scan_index")
+ assert dialog.widget.ui.scan_index.currentText() == "live"
+
+ dialog.widget.ui.scan_index.addItem("42", "scan-hist-42")
+ dialog.widget.ui.scan_index.setCurrentIndex(dialog.widget.ui.scan_index.count() - 1)
+
+ with mock.patch.object(heatmap_widget, "update_with_scan_history") as update_mock:
+ dialog.accept()
+ qtbot.waitUntil(lambda: heatmap_widget.heatmap_dialog is None)
+ update_mock.assert_called_once_with(scan_id="scan-hist-42")
+
+
+def test_heatmap_config_label_shows_live_or_history(heatmap_widget):
+ """The config label marks the scan number with (live) or (history) depending on the mode."""
+ scan_msg = mock.MagicMock()
+ scan_msg.scan_number = 5
+ scan_msg.scan_name = "line_scan"
+ heatmap_widget.status_message = scan_msg
+ heatmap_widget._image_config.show_config_label = True
+
+ heatmap_widget.redraw_config_label()
+ labels = [label.text for _, label in heatmap_widget.config_label.items]
+ assert "Scan: 5 (live)" in labels
+
+ heatmap_widget._history_scan_id = "scan-1"
+ heatmap_widget.redraw_config_label()
+ labels = [label.text for _, label in heatmap_widget.config_label.items]
+ assert "Scan: 5 (history)" in labels
+
+
+def test_heatmap_settings_scan_index_syncs_with_widget(heatmap_widget, qtbot, scan_history_factory):
+ """
+ The scan index combobox in the settings dialog mirrors the heatmap state:
+ it shows the pinned history scan number, or 'live' in live mode, with scans
+ ordered live -> latest -> oldest.
+ """
+ from bec_lib.scan_history import ScanHistory
+
+ msgs = [
+ scan_history_factory(scan_id="hid1", scan_number=1),
+ scan_history_factory(scan_id="hid2", scan_number=2),
+ ]
+ heatmap_widget.client.history = ScanHistory(heatmap_widget.client, False)
+ for msg in msgs:
+ heatmap_widget.client.history._scan_data[msg.scan_id] = msg
+ heatmap_widget.client.history._scan_ids.append(msg.scan_id)
+ heatmap_widget.client.history._scan_numbers.append(msg.scan_number)
+
+ heatmap_widget.plot(device_x="samx", device_y="samy", device_z="bpm4i")
+ heatmap_widget.show_heatmap_settings()
+ qtbot.waitUntil(lambda: heatmap_widget.heatmap_dialog is not None)
+ combo = heatmap_widget.heatmap_dialog.widget.ui.scan_index
+
+ assert [combo.itemText(i) for i in range(combo.count())] == ["live", "2", "1"]
+ assert combo.currentText() == "live"
+
+ # Pinning the widget to a history scan updates the open settings dialog
+ history_scan = mock.MagicMock()
+ history_scan.scan_id = "hid2"
+ with mock.patch.object(heatmap_widget, "get_history_scan_item", return_value=history_scan):
+ heatmap_widget.update_with_scan_history(scan_id="hid2")
+ assert combo.currentText() == "2"
+
+ # Returning to live mode updates the dialog as well
+ heatmap_widget.plot(device_x="samx", device_y="samy", device_z="bpm4i")
+ assert combo.currentText() == "live"
+
+ heatmap_widget.heatmap_dialog.reject()
+ qtbot.waitUntil(lambda: heatmap_widget.heatmap_dialog is None)