GUI: added beamline_state_panel.py with station widgets to GUI

This commit is contained in:
2026-04-23 10:59:17 +02:00
parent 01f1819551
commit 2a31544aba
2 changed files with 555 additions and 14 deletions
+24 -3
View File
@@ -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)
+531 -11
View File
@@ -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("<H3>Beamline state</H3>")
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()