mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-07-24 20:23:01 +02:00
feat(heatmap): scan history plotting via browser popup and settings scan selection
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user