From 2a31544aba67a7edb171e5f12e62ce4c5144ec23 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Thu, 23 Apr 2026 10:59:17 +0200 Subject: [PATCH] GUI: added beamline_state_panel.py with station widgets to GUI --- src/aare/gui/main_window.py | 27 +- src/aare/gui/panels/beamline_state_panel.py | 542 +++++++++++++++++++- 2 files changed, 555 insertions(+), 14 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index bb37544a..88739868 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -23,6 +23,7 @@ from aare.gui.models.gui_state_manager import UIStateManager from aare.gui.panels.LogPanel import LogDock from aare.gui.panels.beamline_controls import BeamlineControls +from aare.gui.panels.beamline_state_panel import BeamlineStatePanel from aare.gui.panels.data_collection_settings import DataCollectionSettings from aare.gui.panels.developer_help_dialog import DeveloperHelpDialog from aare.gui.panels.beamline_recovery_panel import BeamlineRecoveryDialog @@ -166,17 +167,31 @@ class MainWindow(QMainWindow): collection_controls_scroll = NoWheelScrollArea(top_widget) + self.left_column = QWidget(parent=top_widget) + self.left_column_layout = QVBoxLayout(self.left_column) + self.left_column_layout.setContentsMargins(0, 0, 0, 0) + self.left_column_layout.setSpacing(8) + self.data_collection = DataCollectionSettings(s=geom, - parent=top_widget, + parent=self.left_column, raster_mgr=self.raster, diffraction=diffraction) + self.beamline_state_panel = BeamlineStatePanel(parent=self.left_column) + + self.left_column_layout.addWidget(self.data_collection) + self.left_column_layout.addWidget(self.beamline_state_panel) + self.left_column_layout.addStretch() + top_widget_layout.addWidget(collection_controls_scroll) - collection_controls_scroll.setWidget(self.data_collection) + collection_controls_scroll.setWidget(self.left_column) collection_controls_scroll.setHorizontalScrollBarPolicy( Qt.ScrollBarPolicy.ScrollBarAlwaysOff ) - collection_controls_scroll.setFixedWidth(self.data_collection.set_width + 10) + collection_controls_scroll.setWidgetResizable(True) + collection_controls_scroll.setFixedWidth( + max(self.data_collection.set_width, self.beamline_state_panel.set_width) + 10 + ) self.video_tab = QTabWidget(parent=top_widget) @@ -527,6 +542,11 @@ class MainWindow(QMainWindow): self.status_bar.close_shutter.connect(self.daq.close_shutter) self.status_bar.open_shutter.connect(self.daq.open_shutter) + self.beamline_state_panel.dewar_exchange.connect(self.daq.dewar_exchange) + self.beamline_state_panel.sample_exchange.connect(self.daq.sample_exchange) + self.beamline_state_panel.sample_alignment.connect(self.daq.sample_alignment) + self.beamline_state_panel.beam_location.connect(self.daq.beam_location) + self.rotation.file_ready.connect(self.viewer.load_image) self.raster.image_selected.connect(self.viewer.load_image) @@ -562,6 +582,7 @@ class MainWindow(QMainWindow): self.daq.update.connect(self.sample_logic.update_daq_status) self.daq.update.connect(self.manual_sample_panel.update_daq_status) self.daq.update.connect(self.fluor_panel.update_daq_status) + self.daq.update.connect(self.beamline_state_panel.update_daq_status) self.daq.update.connect(self.update_daq_status) self.daq.sample_missing.connect(self.show_sample_missing_dialog) diff --git a/src/aare/gui/panels/beamline_state_panel.py b/src/aare/gui/panels/beamline_state_panel.py index 1928f630..7da5e1ef 100644 --- a/src/aare/gui/panels/beamline_state_panel.py +++ b/src/aare/gui/panels/beamline_state_panel.py @@ -1,20 +1,540 @@ -from PySide6.QtWidgets import QWidget, QGridLayout, QPushButton +from collections import deque +from dataclasses import dataclass -from aare.gui.widgets.title_label import TitleLabel +from PySide6.QtCore import QPoint, QRect, Qt, Signal, Slot, QSettings +from PySide6.QtGui import QColor, QPainter, QPen +from PySide6.QtWidgets import QFrame, QLabel, QPushButton + +from aare.common.models import BeamlineStateEnum, DAQStatusModel -class BeamlineStatePanel(QWidget): +@dataclass(frozen=True) +class StationSpec: + state: BeamlineStateEnum + label: str + x: int + y: int + clickable: bool = False + tooltip: str = "" + + +class HoverableLabel(QLabel): + hovered = Signal(object) + unhovered = Signal() + + def enterEvent(self, event) -> None: + self.hovered.emit(getattr(self, "_beamline_state", None)) + super().enterEvent(event) + + def leaveEvent(self, event) -> None: + self.unhovered.emit() + super().leaveEvent(event) + + +class HoverableButton(QPushButton): + hovered = Signal(object) + unhovered = Signal() + + def enterEvent(self, event) -> None: + self.hovered.emit(getattr(self, "_beamline_state", None)) + super().enterEvent(event) + + def leaveEvent(self, event) -> None: + self.unhovered.emit() + super().leaveEvent(event) + + +class BeamlineStatePanel(QFrame): + sample_exchange = Signal() + sample_alignment = Signal() + dewar_exchange = Signal() + beam_location = Signal() + data_collection = Signal() + xtal_snapshot = Signal() + xray_fluorescence = Signal() + + set_width = 400 + map_height = 560 + station_radius = 8 + def __init__(self, parent=None): super().__init__(parent) - grid_layout = QGridLayout(self) + self.setFrameShape(QFrame.Shape.StyledPanel) + self.setFrameShadow(QFrame.Shadow.Raised) + self.setFixedWidth(self.set_width) + self.setMinimumHeight(self.map_height) - grid_layout.addWidget(TitleLabel("Beamline state", self), 0, 0) + self._settings = QSettings("PSI", "AareGUI") - self.sample_exchange = QPushButton("Sample exchange", parent=self) - grid_layout.addWidget(self.sample_exchange, 1, 0) + self._current_state: BeamlineStateEnum | None = None + self._hovered_state: BeamlineStateEnum | None = None + self._pending_target_state: BeamlineStateEnum | None = None + self._last_stable_state: BeamlineStateEnum | None = None + self._view_mode: str = self._settings.value("beamline_state_panel/view_mode", "metro", type=str) - self.sample_alignment = QPushButton("Sample alignment", parent=self) - grid_layout.addWidget(self.sample_alignment, 2, 0) + self._line_color = QColor(111, 129, 160) + self._line_current = QColor(0, 126, 229) + self._line_hover = QColor(244, 196, 48) - self.dewar_exchange = QPushButton("Load/unload samples", parent=self) - grid_layout.addWidget(self.dewar_exchange, 3, 0) + self._station_current = QColor(0, 126, 229) + self._station_current_ring = QColor(120, 195, 255) + self._station_hover = QColor(244, 196, 48) + self._label_current_bg = "rgba(0, 126, 229, 0.12)" + self._label_hover_bg = "rgba(244, 196, 48, 0.22)" + + self._group_colors: dict[BeamlineStateEnum, QColor] = { + BeamlineStateEnum.DewarTransfer: QColor(128, 90, 213), # Purple + BeamlineStateEnum.SampleExchange: QColor(237, 137, 54), # Orange + BeamlineStateEnum.RobotSampleExchange: QColor(237, 137, 54), # Orange + BeamlineStateEnum.SampleAlignment: QColor(72, 187, 120), # Green + BeamlineStateEnum.BeamLocation: QColor(72, 187, 120), # Green + BeamlineStateEnum.DataCollection: QColor(236, 72, 153), # Pink + BeamlineStateEnum.XtalSnapshot: QColor(236, 72, 153), # Pink + BeamlineStateEnum.XrayFluorescence: QColor(236, 72, 153), # Pink + } + + self._group_label_colors: dict[BeamlineStateEnum, str] = { + state: color.name() for state, color in self._group_colors.items() + } + + self._group_label_backgrounds: dict[BeamlineStateEnum, str] = { + BeamlineStateEnum.DewarTransfer: "rgba(128, 90, 213, 0.14)", + BeamlineStateEnum.SampleExchange: "rgba(237, 137, 54, 0.16)", + BeamlineStateEnum.RobotSampleExchange: "rgba(237, 137, 54, 0.16)", + BeamlineStateEnum.SampleAlignment: "rgba(72, 187, 120, 0.16)", + BeamlineStateEnum.BeamLocation: "rgba(72, 187, 120, 0.16)", + BeamlineStateEnum.DataCollection: "rgba(236, 72, 153, 0.14)", + BeamlineStateEnum.XtalSnapshot: "rgba(236, 72, 153, 0.14)", + BeamlineStateEnum.XrayFluorescence: "rgba(236, 72, 153, 0.14)", + } + + self._metro_stations = [ + StationSpec(BeamlineStateEnum.DewarTransfer, "Dewar transfer", 96, 126, True, "Dewar transfer mode"), + StationSpec(BeamlineStateEnum.SampleExchange, "Manual sample exchange", 96, 186, True, "Manual sample exchange mode"), + StationSpec(BeamlineStateEnum.RobotSampleExchange, "Robot sample exchange", 96, 246, False, "Robot-assisted sample exchange"), + StationSpec(BeamlineStateEnum.SampleAlignment, "Sample alignment", 96, 316, True, "Sample centring and alignment mode"), + StationSpec(BeamlineStateEnum.BeamLocation, "Beam location", 250, 316, True, "Beam location mode"), + StationSpec(BeamlineStateEnum.DataCollection, "Data collection", 96, 396, True, "Measurement / collection mode"), + StationSpec(BeamlineStateEnum.XtalSnapshot, "Crystal snapshot", 250, 396, True, "Crystal snapshot mode"), + StationSpec(BeamlineStateEnum.XrayFluorescence, "XRF", 96, 476, True, "X-ray fluorescence mode"), + ] + + self._line_stations = [ + StationSpec(BeamlineStateEnum.DewarTransfer, "Dewar transfer", 54, 170, True, "Dewar transfer mode"), + StationSpec(BeamlineStateEnum.SampleExchange, "Manual sample exchange", 54, 220, True, "Manual sample exchange mode"), + StationSpec(BeamlineStateEnum.RobotSampleExchange, "Robot sample exchange", 54, 270, False, "Robot-assisted sample exchange"), + StationSpec(BeamlineStateEnum.SampleAlignment, "Sample alignment", 54, 320, True, "Sample centring and alignment mode"), + StationSpec(BeamlineStateEnum.BeamLocation, "Beam location", 54, 370, True, "Beam location mode"), + StationSpec(BeamlineStateEnum.DataCollection, "Data collection", 54, 420, True, "Measurement / collection mode"), + StationSpec(BeamlineStateEnum.XtalSnapshot, "Crystal snapshot", 54, 470, True, "Crystal snapshot mode"), + StationSpec(BeamlineStateEnum.XrayFluorescence, "XRF", 54, 520, True, "X-ray fluorescence mode"), + ] + + self._segments = [ + (BeamlineStateEnum.DewarTransfer, BeamlineStateEnum.SampleExchange), + (BeamlineStateEnum.SampleExchange, BeamlineStateEnum.RobotSampleExchange), + (BeamlineStateEnum.RobotSampleExchange, BeamlineStateEnum.SampleAlignment), + (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.BeamLocation), + (BeamlineStateEnum.SampleAlignment, BeamlineStateEnum.DataCollection), + (BeamlineStateEnum.DataCollection, BeamlineStateEnum.XtalSnapshot), + (BeamlineStateEnum.DataCollection, BeamlineStateEnum.XrayFluorescence), + ] + + self._graph = self._build_graph(self._segments) + self._station_widgets: dict[BeamlineStateEnum, QLabel | QPushButton] = {} + + self.title = QLabel(self) + self.title.setText("

Beamline state

") + self.title.setStyleSheet("background-color: #4B0082; color: #ffffff;") + self.title.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.title.setFixedHeight(50) + self.title.setGeometry(0, 0, self.set_width, 50) + + self.current_label = QLabel("Current: —", self) + self.current_label.setStyleSheet(""" + QLabel { + color: rgb(30, 41, 59); + font-size: 18px; + font-weight: 700; + padding-left: 4px; + background: transparent; + } + """) + self.current_label.move(14, 58) + self.current_label.adjustSize() + + self.view_toggle = QPushButton(self) + self.view_toggle.setCheckable(False) + self.view_toggle.setStyleSheet(""" + QPushButton { + background-color: rgb(240, 244, 252); + color: rgb(55, 67, 87); + border: 1px solid rgb(160, 175, 200); + border-radius: 8px; + padding: 4px 10px; + font-size: 12px; + font-weight: 600; + } + QPushButton:hover { + background-color: rgb(228, 236, 248); + } + """) + self.view_toggle.clicked.connect(self._toggle_view_mode) + self._update_toggle_text() + + self._build_station_widgets() + self._position_station_widgets() + + @staticmethod + def _canon_segment(a: BeamlineStateEnum, b: BeamlineStateEnum) -> tuple[BeamlineStateEnum, BeamlineStateEnum]: + return tuple(sorted((a, b), key=lambda state: state.value)) + + def _build_graph( + self, + segments: list[tuple[BeamlineStateEnum, BeamlineStateEnum]], + ) -> dict[BeamlineStateEnum, set[BeamlineStateEnum]]: + graph: dict[BeamlineStateEnum, set[BeamlineStateEnum]] = {} + for a, b in segments: + graph.setdefault(a, set()).add(b) + graph.setdefault(b, set()).add(a) + return graph + + def _stations(self) -> list[StationSpec]: + return self._metro_stations if self._view_mode == "metro" else self._line_stations + + def _station_map(self) -> dict[BeamlineStateEnum, StationSpec]: + return {station.state: station for station in self._stations()} + + def _path_segments_between( + self, + start: BeamlineStateEnum | None, + end: BeamlineStateEnum | None, + ) -> set[tuple[BeamlineStateEnum, BeamlineStateEnum]]: + if start is None or end is None: + return set() + + if start == BeamlineStateEnum.Moving or end == BeamlineStateEnum.Moving: + return set() + + if start == end: + return set() + + queue = deque([start]) + previous: dict[BeamlineStateEnum, BeamlineStateEnum | None] = {start: None} + + while queue: + node = queue.popleft() + if node == end: + break + + for neighbour in self._graph.get(node, set()): + if neighbour in previous: + continue + previous[neighbour] = node + queue.append(neighbour) + + if end not in previous: + return set() + + path_segments: set[tuple[BeamlineStateEnum, BeamlineStateEnum]] = set() + cursor = end + while previous[cursor] is not None: + parent = previous[cursor] + path_segments.add(self._canon_segment(cursor, parent)) + cursor = parent + + return path_segments + + def _active_hover_route(self) -> set[tuple[BeamlineStateEnum, BeamlineStateEnum]]: + if self._hovered_state is not None: + route_source = self._last_stable_state if self._current_state == BeamlineStateEnum.Moving else self._current_state + return self._path_segments_between(route_source, self._hovered_state) + + if self._current_state == BeamlineStateEnum.Moving and self._pending_target_state is not None: + return self._path_segments_between(self._last_stable_state, self._pending_target_state) + + return set() + + def _build_station_widgets(self) -> None: + for station in self._metro_stations: + if station.clickable: + widget: QLabel | QPushButton = HoverableButton(station.label, self) + widget.setFlat(True) + widget.setCursor(Qt.CursorShape.PointingHandCursor) + widget.clicked.connect(lambda _checked=False, state=station.state: self._emit_for_state(state)) + else: + widget = HoverableLabel(station.label, self) + + widget._beamline_state = station.state + widget.hovered.connect(self._set_hovered_state) + widget.unhovered.connect(self._clear_hovered_state) + widget.setToolTip(station.tooltip or station.label) + self._station_widgets[station.state] = widget + + self._apply_station_highlight() + + def _position_station_widgets(self) -> None: + station_map = self._station_map() + + for state, widget in self._station_widgets.items(): + station = station_map[state] + widget.setText(station.label) + widget.adjustSize() + + label_x = station.x + 18 + label_y = station.y - 12 + + if self._view_mode == "metro": + if state == BeamlineStateEnum.SampleAlignment: + label_x = station.x + 24 + label_y = station.y + 8 + elif state == BeamlineStateEnum.DataCollection: + label_x = station.x + 24 + label_y = station.y + 8 + elif state == BeamlineStateEnum.BeamLocation: + label_y = station.y - 18 + elif state == BeamlineStateEnum.XtalSnapshot: + label_y = station.y - 14 + elif state == BeamlineStateEnum.XrayFluorescence: + label_y = station.y - 14 + else: + label_x = station.x + 20 + label_y = station.y - 12 + + widget.move(label_x, label_y) + widget.show() + + self._apply_station_highlight() + + def _update_toggle_text(self) -> None: + self.view_toggle.setText("View: Metro" if self._view_mode == "metro" else "View: Line") + self.view_toggle.adjustSize() + self.view_toggle.move(self.set_width - 118, 58) + + @Slot() + def _toggle_view_mode(self) -> None: + self._view_mode = "line" if self._view_mode == "metro" else "metro" + self._settings.setValue("beamline_state_panel/view_mode", self._view_mode) + self._update_toggle_text() + self._position_station_widgets() + self.update() + + def _emit_for_state(self, state: BeamlineStateEnum) -> None: + self._pending_target_state = state + self._hovered_state = None + self.update() + + if state == BeamlineStateEnum.SampleExchange: + self.sample_exchange.emit() + elif state == BeamlineStateEnum.SampleAlignment: + self.sample_alignment.emit() + elif state == BeamlineStateEnum.DewarTransfer: + self.dewar_exchange.emit() + elif state == BeamlineStateEnum.BeamLocation: + self.beam_location.emit() + elif state == BeamlineStateEnum.DataCollection: + self.data_collection.emit() + elif state == BeamlineStateEnum.XtalSnapshot: + self.xtal_snapshot.emit() + elif state == BeamlineStateEnum.XrayFluorescence: + self.xray_fluorescence.emit() + + @Slot(object) + def _set_hovered_state(self, state: BeamlineStateEnum | None) -> None: + self._hovered_state = state + self._apply_station_highlight() + + @Slot() + def _clear_hovered_state(self) -> None: + self._hovered_state = None + self._apply_station_highlight() + + def _apply_station_highlight(self) -> None: + for station in self._stations(): + widget = self._station_widgets[station.state] + is_current = station.state == self._current_state + is_hovered = station.state == self._hovered_state + is_pending = station.state == self._pending_target_state and self._current_state == BeamlineStateEnum.Moving + + label_color = self._group_label_colors.get(station.state, "rgb(55, 67, 87)") + label_bg = self._group_label_backgrounds.get(station.state, "transparent") + + if isinstance(widget, QPushButton): + if is_current: + widget.setStyleSheet(f""" + QPushButton {{ + border: none; + border-radius: 10px; + background: {self._label_current_bg}; + color: rgb(0, 92, 170); + font-size: 13px; + font-weight: 700; + text-align: left; + padding: 2px 6px 2px 8px; + }} + QPushButton:hover {{ + color: rgb(0, 92, 170); + }} + """) + elif is_hovered or is_pending: + widget.setStyleSheet(f""" + QPushButton {{ + border: none; + border-radius: 10px; + background: {self._label_hover_bg}; + color: rgb(115, 88, 0); + font-size: 13px; + font-weight: 700; + text-align: left; + padding: 2px 6px 2px 8px; + }} + QPushButton:hover {{ + color: rgb(115, 88, 0); + }} + """) + else: + widget.setStyleSheet(f""" + QPushButton {{ + border: none; + border-radius: 10px; + background: {label_bg}; + color: {label_color}; + font-size: 13px; + font-weight: 600; + text-align: left; + padding: 2px 6px 2px 8px; + }} + QPushButton:hover {{ + color: {label_color}; + }} + """) + else: + if is_current: + widget.setStyleSheet(f""" + QLabel {{ + border-radius: 10px; + background: {self._label_current_bg}; + color: rgb(0, 92, 170); + font-size: 13px; + font-weight: 700; + padding: 2px 6px 2px 8px; + }} + """) + elif is_hovered or is_pending: + widget.setStyleSheet(f""" + QLabel {{ + border-radius: 10px; + background: {self._label_hover_bg}; + color: rgb(115, 88, 0); + font-size: 13px; + font-weight: 700; + padding: 2px 6px 2px 8px; + }} + """) + else: + widget.setStyleSheet(f""" + QLabel {{ + border-radius: 10px; + background: {label_bg}; + color: {label_color}; + font-size: 13px; + font-weight: 600; + padding: 2px 6px 2px 8px; + }} + """) + + widget.adjustSize() + + self.update() + + def _station_center(self, state: BeamlineStateEnum) -> QPoint: + station = self._station_map()[state] + return QPoint(station.x, station.y) + + def _segment_color(self, a: BeamlineStateEnum, b: BeamlineStateEnum) -> QColor: + segment = self._canon_segment(a, b) + + active_hover_route = self._active_hover_route() + if segment in active_hover_route: + return self._line_hover + + current_path = self._path_segments_between(BeamlineStateEnum.DewarTransfer, self._last_stable_state or self._current_state) + if segment in current_path: + return self._line_current + + return self._line_color + + def _draw_segment(self, painter: QPainter, start: QPoint, end: QPoint, color: QColor) -> None: + pen = QPen(color, 4) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + painter.drawLine(start, end) + + def _station_base_color(self, state: BeamlineStateEnum) -> QColor: + return self._group_colors.get(state, QColor(180, 190, 210)) + + def _draw_station(self, painter: QPainter, station: StationSpec) -> None: + center = self._station_center(station.state) + rect = QRect( + center.x() - self.station_radius, + center.y() - self.station_radius, + self.station_radius * 2, + self.station_radius * 2, + ) + + if station.state == self._hovered_state or ( + self._current_state == BeamlineStateEnum.Moving and station.state == self._pending_target_state + ): + painter.setPen(QPen(self._station_hover, 3)) + painter.setBrush(self._station_hover) + elif station.state == self._current_state: + painter.setPen(QPen(self._station_current_ring, 4)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawEllipse( + QRect( + center.x() - self.station_radius - 4, + center.y() - self.station_radius - 4, + (self.station_radius + 4) * 2, + (self.station_radius + 4) * 2, + ) + ) + painter.setPen(QPen(self._station_current, 2)) + painter.setBrush(self._station_current) + else: + base_color = self._station_base_color(station.state) + painter.setPen(QPen(base_color.darker(125), 2)) + painter.setBrush(base_color) + + painter.drawEllipse(rect) + + def paintEvent(self, event) -> None: + super().paintEvent(event) + + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + + for start_state, end_state in self._segments: + start = self._station_center(start_state) + end = self._station_center(end_state) + color = self._segment_color(start_state, end_state) + self._draw_segment(painter, start, end, color) + + for station in self._stations(): + self._draw_station(painter, station) + + @Slot(DAQStatusModel) + def update_daq_status(self, status: DAQStatusModel) -> None: + self.set_current_state(status.state) + + def set_current_state(self, state: BeamlineStateEnum | None) -> None: + self._current_state = state + + if state is not None and state != BeamlineStateEnum.Moving: + self._last_stable_state = state + if self._pending_target_state == state: + self._pending_target_state = None + + label = state.display_name() if state is not None else "—" + self.current_label.setText(f"Current: {label}") + self.current_label.adjustSize() + self._apply_station_highlight() \ No newline at end of file