diff --git a/daq/src/aaredaq/config.py b/daq/src/aaredaq/config.py index b33ece9b..6b363943 100644 --- a/daq/src/aaredaq/config.py +++ b/daq/src/aaredaq/config.py @@ -324,6 +324,25 @@ class BeamlineConfig: pubsub.psubscribe(f"__keyspace@0__:{self.__bl}:sample_spreadsheet") return pubsub + @property + def reference_tools(self) -> SampleShortInfoList: + tmp = self.__client.get(f"{self.__bl}:reference-tools") + if tmp is None: + return SampleShortInfoList(s=[]) + + data_dict = json.loads(tmp) + return SampleShortInfoList(**data_dict) + + @reference_tools.setter + def reference_tools(self, data: SampleShortInfoList): + self.__client.set(f"{self.__bl}:reference-tools", data.model_dump_json()) + + def listen_changes_reference_tools(self) -> redis.client.PubSub: + self.__client.config_set("notify-keyspace-events", "KEA") + pubsub = self.__client.pubsub() + pubsub.psubscribe(f"__keyspace@0__:{self.__bl}:reference-tools") + return pubsub + @property def current_sample(self) -> SampleShortInfo | None: tmp = self.__client.get(f"{self.__bl}:current_sample") diff --git a/daq/src/aaredaq/server.py b/daq/src/aaredaq/server.py index 490965da..c8ce9bf1 100644 --- a/daq/src/aaredaq/server.py +++ b/daq/src/aaredaq/server.py @@ -26,6 +26,8 @@ from aaredaq.daq import AareDAQ from aaredaq.camera_stat_thread import start_image_stats_receiver, stop_image_stats_receiver +from aaregui.panels.reference_tools_panel import ReferenceToolsModel + app = FastAPI() # OAuth2 setup @@ -241,6 +243,9 @@ def get_spreadsheet(data: TokenData) -> SampleShortInfoList: else: return cfg.spreadsheet_pgroup(data.pgroups) +def get_reference_tools() -> SampleShortInfoList: + return cfg.reference_tools + async def spreadsheet_event_stream(data: TokenData) -> AsyncGenerator[str, None]: try: @@ -270,6 +275,11 @@ async def spreadsheet_sse(token: str = Depends(oauth2_scheme)): async def spreadsheet(token: str = Depends(oauth2_scheme)) -> SampleShortInfoList: return get_spreadsheet(auth.parse_token(token)) +@app.get("/sample/reference_tools") +async def reference_tools(token: str = Depends(oauth2_scheme)) -> SampleShortInfoList: + auth.check_jwt_ro(cfg, auth.parse_token(token)) + return get_reference_tools() + # State transitions @app.post("/state/dewar_exchange") diff --git a/gui/src/aaregui/main_window.py b/gui/src/aaregui/main_window.py index 146a98ae..eb261c3a 100644 --- a/gui/src/aaregui/main_window.py +++ b/gui/src/aaregui/main_window.py @@ -19,6 +19,7 @@ from aaredaqlib.sample_geometry import SampleGeometryModel from aaregui.panels.beamline_controls import BeamlineControls from aaregui.panels.data_collection_settings import DataCollectionSettings from aaregui.panels.manual_sample_panel import ManualSamplePanel +from aaregui.panels.reference_tools_panel import ReferenceToolsPanel from aaregui.panels.sample_queue_panel import SampleQueuePanel from aaregui.panels.tell_sample_panel import TellSamplePanel from aaregui.scan_logic.raster_grid_manager import RasterGridManager @@ -122,7 +123,14 @@ class MainWindow(QMainWindow): ) beamline_controls_scroll.setFixedWidth(self.beamline.set_width + 10) + self.ref_tools_panel = ReferenceToolsPanel() + # add dock or add to left column as you prefer; e.g. dock: + ref_dock = QDockWidget("Reference Tools", self) + ref_dock.setWidget(self.ref_tools_panel) + self.addDockWidget(Qt.LeftDockWidgetArea, ref_dock) + self.tell_samples = TellSamplePanel(samples=SampleShortInfoList(s = [])) + self.ref_tools = ReferenceToolsPanel(samples=SampleShortInfoList(s = [])) self.job_list_panel = SampleQueuePanel() self.tell_samples_dock = QDockWidget("Sample List", self) @@ -154,6 +162,7 @@ class MainWindow(QMainWindow): self.daq = DAQWorker(base_url=self.__base_url, token=self.__token) self.daq.spreadsheet.connect(self.tell_samples.new_sample_list) + self.daq.reference_tools.connect(self.ref_tools_panel.new_list) self.beamline.samcam.changed.connect(self.daq.samcam_settings) self.beamline.loopctr.background.clicked.connect(self.daq.alc_background) @@ -215,6 +224,8 @@ class MainWindow(QMainWindow): self.tell_samples.mount.connect(self.daq.mount) self.tell_samples.unmount.connect(self.daq.unmount) + self.ref_tools.mount.connect(self.daq.mount) + self.ref_tools.unmount.connect(self.daq.unmount) self.data_collection.raster.grid_size_updated.connect(self.raster.update_grid_size) self.data_collection.raster.exp_time_updated.connect(self.raster.update_exposure_time) @@ -319,7 +330,7 @@ class MainWindow(QMainWindow): QMessageBox.about( self, "About", - "Aare Macromolecular Crystallography GUI\nVersion: 1.0\nCopyright: Paul Scherrer Instiute 2024-2025", + "Aare Macromolecular Crystallography GUI\nVersion: 1.0\nCopyright: Paul Scherrer Institute 2024-2025", ) @Slot() diff --git a/gui/src/aaregui/panels/reference_tools_panel.py b/gui/src/aaregui/panels/reference_tools_panel.py new file mode 100644 index 00000000..6f832a32 --- /dev/null +++ b/gui/src/aaregui/panels/reference_tools_panel.py @@ -0,0 +1,136 @@ +# reference_tools_panel.py +from typing import Callable, List, Dict, Optional + +from PySide6.QtCore import Qt, QAbstractTableModel, QModelIndex, QTimer, Slot, Signal +from PySide6.QtWidgets import ( + QFrame, + QGridLayout, + QTableView, + QHeaderView, + QLabel, + QPushButton, + QMenu, + QAbstractItemView, +) +from aaredaqlib.models import SampleShortInfoList, SampleShortInfo, BeamlineStateEnum + + +class ReferenceToolsModel(QAbstractTableModel): + def __init__(self, rows: Optional[List[SampleShortInfo]] = None, parent=None): + super().__init__(parent) + self._rows: List[SampleShortInfo] = rows or [] + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + return len(self._rows) + + def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: + return 3 # position, sample_name, mount_count + + def data(self, index: QModelIndex, role: int = Qt.DisplayRole): + if not index.isValid() or role != Qt.DisplayRole: + return None + s: SampleShortInfo = self._rows[index.row()] + if index.column() == 0: + # use loc_str if present, fallback to "-" + return s.loc_str() if getattr(s, "loc_str", None) else "-" + if index.column() == 1: + return s.sample_name + if index.column() == 2: + return str(getattr(s, "mount_count", 0)) + return None + + def headerData(self, section: int, orientation, role: int = Qt.DisplayRole): + if role != Qt.DisplayRole: + return None + if orientation == Qt.Horizontal: + return ("position", "sample_name", "mount_count")[section] + return str(section + 1) + + def update_rows(self, rows: List[SampleShortInfo]): + self.beginResetModel() + self._rows = rows + self.endResetModel() + + def get_item(self, row: int) -> Optional[SampleShortInfo]: + try: + return self._rows[row] + except Exception: + return None + +class ReferenceToolsPanel(QFrame): + mount = Signal(SampleShortInfo) + unmount = Signal(SampleShortInfo) + + def __init__(self, samples: SampleShortInfoList | None = None, parent=None, refresh_interval_ms: int = 5000): + """ + :param samples: optional initial SampleShortInfoList to populate the table + :param parent: Qt parent + :param refresh_interval_ms: how often to call request_refresh (panel doesn't implement the request itself) + """ + super().__init__(parent) + + if samples is None: + samples = SampleShortInfoList(s=[]) + + self.setFrameShape(QFrame.Shape.StyledPanel) + self.setFrameShadow(QFrame.Shadow.Raised) + + layout = QGridLayout(self) + self.setLayout(layout) + + layout.addWidget(QLabel("Reference tools", parent=self), 0, 0, 1, 2) + + self.table_view = QTableView(parent=self) + # initialize model with provided samples + self.table_model = ReferenceToolsModel(samples.s) + self.table_view.setModel(self.table_model) + header = self.table_view.horizontalHeader() + header.setSectionResizeMode(QHeaderView.ResizeMode.Interactive) + header.setStretchLastSection(True) + self.table_view.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows) + self.table_view.setSelectionMode(QTableView.SelectionMode.SingleSelection) + self.table_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.table_view.customContextMenuRequested.connect(self._context_menu) + + layout.addWidget(self.table_view, 1, 0, 1, 2) + + self.unmount_button = QPushButton("Unmount", parent=self) + layout.addWidget(self.unmount_button, 2, 0) + self.unmount_button.clicked.connect(self._on_unmount_clicked) + + self._timer = QTimer(self) + self._timer.setInterval(refresh_interval_ms) + self._timer.timeout.connect(self.request_refresh) # implement to call DAQ/adapter + self._timer.start() + + def _selected_item(self) -> Optional[SampleShortInfo]: + idx = self.table_view.currentIndex() + if not idx.isValid(): + return None + return self.table_model.get_item(idx.row()) + + def _on_unmount_clicked(self): + item = self._selected_item() + if item is not None: + self.unmount.emit(item) + + def _context_menu(self, position): + idx = self.table_view.indexAt(position) + if not idx.isValid(): + return + item = self.table_model.get_item(idx.row()) + if item is None: + return + menu = QMenu() + act_mount = menu.addAction("Mount") + act_unmount = menu.addAction("Unmount") + action = menu.exec_(self.table_view.viewport().mapToGlobal(position)) + if action == act_mount: + self.mount.emit(item) + elif action == act_unmount: + self.unmount.emit(item) + + @Slot(SampleShortInfoList) + def new_list(self, lst: SampleShortInfoList): + # signal from DAQWorker will call this with the model + self.table_model.update_rows(lst.s) \ No newline at end of file diff --git a/gui/src/aaregui/threads/daq_worker.py b/gui/src/aaregui/threads/daq_worker.py index 91d5783b..07ad12aa 100644 --- a/gui/src/aaregui/threads/daq_worker.py +++ b/gui/src/aaregui/threads/daq_worker.py @@ -17,6 +17,7 @@ SPREADHSEET_FREQUENCY = 25 # Every 5 seconds class DAQWorker(QObject): update = Signal(DAQStatusModel) spreadsheet = Signal(SampleShortInfoList) + reference_tools = Signal(SampleShortInfoList) http_error = Signal(str) auth_error = Signal() automated_scan_done = Signal(int, bool) # sample ID, success @@ -82,6 +83,16 @@ class DAQWorker(QObject): print(e) self.http_error.emit(str(e)) + @Slot(QNetworkReply) + def handle_reference_tools_response(self, reply: QNetworkReply): + try: + response_data = self.handle_response(reply) + parsed_response = SampleShortInfoList.model_validate_json(response_data) + self.reference_tools.emit(parsed_response) + except Exception as e: + print(e) + self.http_error.emit(str(e)) + def handle_req_response(self, reply: QNetworkReply): if reply.error() != QNetworkReply.NetworkError.NoError: if reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) == 401: @@ -301,6 +312,16 @@ class DAQWorker(QObject): reply = self.__net_manager.get(request) reply.finished.connect(lambda: self.handle_spreadsheet_response(reply)) + @Slot() + def load_reference_tools(self): + if self.__base_url is None: + print(f"GET /sample/reference_tools") + return + request = QNetworkRequest(QUrl(f"{self.__base_url}/sample/reference_tools")) + request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8")) + reply = self.__net_manager.get(request) + reply.finished.connect(lambda: self.handle_reference_tools_response(reply)) + def handle_auto_scan_response(self, reply, sample_id: int): if reply.error() == QNetworkReply.NetworkError.NoError: resp = reply.readAll().data().decode("utf-8")