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
bec_color_map_widget
+ + ScanIndexComboBox + QComboBox +
scan_index_combo_box
+
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
bec_color_map_widget
+ + ScanIndexComboBox + QComboBox +
scan_index_combo_box
+
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)