feat(heatmap): scan history plotting via browser popup and settings scan selection

This commit is contained in:
2026-07-10 15:56:56 +02:00
committed by Jan Wyzula
parent d2b5c74204
commit 53b21725fd
5 changed files with 308 additions and 5 deletions
+107 -4
View File
@@ -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()
@@ -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()
@@ -161,6 +161,26 @@
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QLabel" name="label_scan_index">
<property name="text">
<string>Scan #</string>
</property>
</widget>
</item>
<item row="4" column="3" alignment="Qt::AlignmentFlag::AlignRight">
<widget class="ScanIndexComboBox" name="scan_index">
<property name="minimumSize">
<size>
<width>100</width>
<height>26</height>
</size>
</property>
<property name="toolTip">
<string>Plot the selected scan from history, or 'live' for live acquisition.</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
@@ -320,6 +340,11 @@
<extends>QWidget</extends>
<header>bec_color_map_widget</header>
</customwidget>
<customwidget>
<class>ScanIndexComboBox</class>
<extends>QComboBox</extends>
<header>scan_index_combo_box</header>
</customwidget>
</customwidgets>
<tabstops>
<tabstop>device_x</tabstop>
@@ -48,6 +48,24 @@
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_scan">
<item>
<widget class="QLabel" name="label_scan_index">
<property name="text">
<string>Scan #</string>
</property>
</widget>
</item>
<item>
<widget class="ScanIndexComboBox" name="scan_index">
<property name="toolTip">
<string>Plot the selected scan from history, or 'live' for live acquisition.</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QGroupBox" name="groupBox">
<property name="title">
@@ -262,6 +280,11 @@
<extends>QWidget</extends>
<header>bec_color_map_widget</header>
</customwidget>
<customwidget>
<class>ScanIndexComboBox</class>
<extends>QComboBox</extends>
<header>scan_index_combo_box</header>
</customwidget>
</customwidgets>
<tabstops>
<tabstop>device_x</tabstop>
+146
View File
@@ -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)