diff --git a/src/aare/gui/gui.py b/src/aare/gui/gui.py index f3532841..284ec734 100644 --- a/src/aare/gui/gui.py +++ b/src/aare/gui/gui.py @@ -37,8 +37,8 @@ if __name__ == "__main__": default_gonio_camera_id = 3 case MXBeamline.X10SA: default_url = "http://127.0.0.1:5210" - default_zmq_addr = "tcp://x10sa-spark-01:9091" #"tcp://x10sa-pserv-01:9089" - default_pred_zmq_addr = "tcp://x10sa-spark-01:9091" + default_zmq_addr = "tcp://x10sa-spark-01:9091" #"tcp://x10sa-pserv-01:9089" # + default_pred_zmq_addr = "tcp://x10sa-spark-01:9091" #"tcp://sls-gpu-003:9089"#"" default_beamline_cam_addr = "axis-accc8eb02488.psi.ch" default_gonio_cam_addr = "axis-accc8ea5e463.psi.ch" default_gonio_camera_id = 1 @@ -103,6 +103,11 @@ if __name__ == "__main__": try: token = auth(base_url) + if not token or token.count(".") != 2: + raise RuntimeError( + "Authentication did not return a valid token. " + "Please check the server is running (it may still be initialising)." + ) logger.info("Authentication successful") except Exception as e: logger.error(f"Cannot connect to AareDAQ server. Exiting. {e}") @@ -133,8 +138,9 @@ if __name__ == "__main__": QMessageBox.critical( None, "Fatal Error", - f"An error occurred during startup:\n\n{str(e)}\n\nSee console for details." + f"An error occurred during startup. See console for details." f"\nPlease check the server is running and your network connection." + f"\n\n{str(e)}\n\n" ) except: pass diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index a6b0204a..95b2dbfe 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -1,6 +1,8 @@ +import time + import jwt -from PySide6.QtCore import Qt, Slot, Signal -from PySide6.QtGui import QAction +from PySide6.QtCore import Qt, Slot, Signal, QTimer, QSettings +from PySide6.QtGui import QAction, QPixmap from PySide6.QtWidgets import ( QMainWindow, QWidget, @@ -20,16 +22,19 @@ from aare.gui.panels.LogPanel import LogDock from aare.gui.panels.beamline_controls import BeamlineControls 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 from aare.gui.panels.manual_sample_panel import ManualSamplePanel from aare.gui.panels.reference_tools_panel import ReferenceToolsPanel from aare.gui.panels.sample_queue_panel import SampleQueuePanel from aare.gui.panels.tell_sample_panel import TellSamplePanel from aare.gui.panels.face_detection_panel import FaceDetectionPanel +from aare.gui.panels.smargon_trace_panel import SmargonTracePanel from aare.gui.scan_logic.raster_grid_manager import RasterGridManager from aare.gui.scan_logic.rotation_scan_manager import RotationScanManager from aare.gui.scan_logic.sample_mount_logic import SampleMountLogic from aare.gui.threads.axis_video_thread import VideoThread from aare.gui.tutorials.tutorial_manager import TutorialManager, TutorialStep +from aare.gui.tutorials.controls_help_dialog import ControlsHelpDialog from aare.gui.threads.camera_thread import SampleCameraThread from aare.gui.threads.prediction_subscriber import PredictionSubscriber @@ -61,15 +66,35 @@ class MainWindow(QMainWindow): self.__token = token self.__mounting = False self._dev_help_dialog = None + self._beamline_recovery_dialog = None + self._controls_help_dialog = None + self._cleanup_done = False # Tutorial manager (define tutorials after widgets exist) self.tutorial_manager = TutorialManager(self) self.viewer = JFJochDBusClient() - # Decode the JWT without signature verification - self.__decoded_token = TokenData(**jwt.decode(token, options={"verify_signature": False})) - logger.debug(self.__decoded_token) + try: + token_str = (token or "").strip() + if token_str.count(".") != 2: + raise ValueError( + "Invalid authentication token received (not a JWT). " + "This usually happens when the server is not running or still starting." + ) + + payload = jwt.decode(token_str, options={"verify_signature": False}) + self.__decoded_token = TokenData(**payload) + except Exception as e: + logger.error(f"Failed to decode authentication token: {e}", exc_info=True) + QMessageBox.critical( + None, + "Authentication Error", + "Could not start the GUI because authentication data was invalid.\n\n" + "Most commonly the server is not running yet (or is still initialising).\n" + "Please start/restart the server and try again." + ) + raise self.setStyleSheet("background-color: rgb(216, 228, 253);") @@ -176,6 +201,7 @@ class MainWindow(QMainWindow): self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.tell_samples_dock) self.ref_tools_dock = QDockWidget("Reference Tools", self) + self.ref_tools_dock.setObjectName("ref_tools_dock") self.ref_tools_dock.setWidget(self.ref_tools_panel) self.ref_tools_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea) self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.ref_tools_dock) @@ -184,6 +210,7 @@ class MainWindow(QMainWindow): self.sample_logic = SampleMountLogic() self.job_list_dock = QDockWidget("Automation list", self) + self.job_list_dock.setObjectName("job_list_dock") self.job_list_dock.setWidget(self.job_list_panel) self.job_list_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea) self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.job_list_dock) @@ -191,12 +218,14 @@ class MainWindow(QMainWindow): self.manual_sample_panel = ManualSamplePanel() self.manual_sample_dock = QDockWidget("Manual sample", self) + self.manual_sample_dock.setObjectName("manual_sample_dock") self.manual_sample_dock.setWidget(self.manual_sample_panel) self.manual_sample_dock.setAllowedAreas(Qt.DockWidgetArea.BottomDockWidgetArea) self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.manual_sample_dock) self.face_panel = FaceDetectionPanel() self.face_panel_dock = QDockWidget("Face detection", self) + self.face_panel_dock.setObjectName("face_panel_dock") self.face_panel_dock.setWidget(self.face_panel) self.face_panel_dock.setAllowedAreas(Qt.DockWidgetArea.RightDockWidgetArea | Qt.DockWidgetArea.LeftDockWidgetArea) self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.face_panel_dock) @@ -204,6 +233,7 @@ class MainWindow(QMainWindow): self.fluor_panel = FluorescencePanel() self.fluor_panel_dock = QDockWidget("Fluorescence", self) + self.fluor_panel_dock.setObjectName("fluor_panel_dock") self.fluor_panel_dock.setWidget(self.fluor_panel) self.fluor_panel_dock.setAllowedAreas(Qt.DockWidgetArea.TopDockWidgetArea | Qt.DockWidgetArea.BottomDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea) self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.fluor_panel_dock) @@ -211,16 +241,30 @@ class MainWindow(QMainWindow): # Create and add the dock to your main window self.log_dock = LogDock("Console Log", self) + self.log_dock.setObjectName("log_dock") self.addDockWidget(Qt.BottomDockWidgetArea, self.log_dock) self.log_dock.attach_logger("") self.log_dock.attach_logger("aareDAQ") self.log_dock.attach_logger("aareGUI") self.log_dock.hide() + self.smargon_trace_panel = SmargonTracePanel() + self.smargon_trace_dock = QDockWidget("Smargon trace", self) + self.smargon_trace_dock.setObjectName("smargon_trace_dock") + self.smargon_trace_dock.setWidget(self.smargon_trace_panel) + self.smargon_trace_dock.setAllowedAreas( + Qt.DockWidgetArea.RightDockWidgetArea + | Qt.DockWidgetArea.LeftDockWidgetArea + | Qt.DockWidgetArea.BottomDockWidgetArea + ) + self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.smargon_trace_dock) + self.smargon_trace_dock.hide() + self.setCentralWidget(top_widget) self.setWindowTitle("AareGUI") self.create_menu_bar() + self._restore_window_state() # Define tutorials now that the UI exists @@ -233,6 +277,7 @@ class MainWindow(QMainWindow): self.daq.reference_tools.connect(self.ref_tools_panel.new_list) self.beamline.samcam.changed.connect(self.daq.samcam_settings) + self.beamline.samcam.screenshot_requested.connect(self.daq.send_screenshot_db) self.beamline.loopctr.background.clicked.connect(self.daq.alc_background) self.beamline.loopctr.find_tip.clicked.connect(self.daq.center_loop) self.beamline.loopctr.bounding_box.clicked.connect(self.daq.ml_bounding_box) @@ -267,7 +312,6 @@ class MainWindow(QMainWindow): if zmq_addr is not None: self.camera_thread = SampleCameraThread(zmq_url=zmq_addr) - self.camera_thread.camera_image.connect(self.sample_camera.update_pixmap) self.camera_thread.start() self.camera_thread.focus_measure.connect(self.status_bar.update_sharpness) self.camera_thread.fps_measure.connect(self.status_bar.update_samcam_fps) @@ -278,14 +322,25 @@ class MainWindow(QMainWindow): if pred_zmq_addr is not None: logger.debug(f"Starting prediction subscriber thread {pred_zmq_addr}") self.prediction_thread = PredictionSubscriber(pred_zmq_url=pred_zmq_addr, topic="detections") - self.prediction_thread.image.connect(self.sample_camera.update_pixmap) self.prediction_thread.prediction.connect(self.sample_camera.update_detections) self.prediction_thread.start() else: self.prediction_thread = None + self._last_pred_image_ts: float | None = None + self._pred_preferred_timeout_s: float = 0.7 # tune: how long we "trust" prediction images + self._pred_is_preferred: bool = False - QApplication.instance().aboutToQuit.connect(self.cleanup) + if self.camera_thread is not None: + self.camera_thread.camera_image.connect(self._on_samcam_camera_pixmap) + + if self.prediction_thread is not None: + self.prediction_thread.image.connect(self._on_samcam_prediction_pixmap) + + self._samcam_source_timer = QTimer(self) + self._samcam_source_timer.setInterval(200) # ms + self._samcam_source_timer.timeout.connect(self._update_samcam_source_preference) + self._samcam_source_timer.start() # # self.data_collection.helical.helical_scan.connect(self.worker.helical_scan) @@ -395,6 +450,31 @@ class MainWindow(QMainWindow): register_tutorials(self, self.tutorial_manager) + @Slot(QPixmap) + def _on_samcam_prediction_pixmap(self, pix: QPixmap) -> None: + self._last_pred_image_ts = time.monotonic() + self._pred_is_preferred = True + self.sample_camera.update_pixmap(pix) + + @Slot(QPixmap) + def _on_samcam_camera_pixmap(self, pix: QPixmap) -> None: + # Only show camera frames when prediction is not currently "healthy" + if not self._pred_is_preferred: + self.sample_camera.update_pixmap(pix) + + @Slot() + def _update_samcam_source_preference(self) -> None: + if self.prediction_thread is None: + self._pred_is_preferred = False + return + + if self._last_pred_image_ts is None: + self._pred_is_preferred = False + return + + age_s = time.monotonic() - self._last_pred_image_ts + self._pred_is_preferred = age_s <= self._pred_preferred_timeout_s + def start_text_tutorial(self) -> None: self.tutorial_manager.start("intro_text") @@ -453,6 +533,15 @@ class MainWindow(QMainWindow): self.fluor_panel_dock.visibilityChanged.connect(show_fluor_panel_action.setChecked) view_menu.addAction(show_fluor_panel_action) + show_smargon_trace_action = QAction("Show Smargon trace", self) + show_smargon_trace_action.setCheckable(True) + show_smargon_trace_action.setChecked(False) + show_smargon_trace_action.triggered.connect(lambda checked: self.smargon_trace_dock.setVisible(checked)) + self.smargon_trace_dock.visibilityChanged.connect( + lambda visible: self.smargon_trace_panel.refresh_plot(force=True) if visible else None + ) + view_menu.addAction(show_smargon_trace_action) + show_log_action = QAction("Show Log", self) show_log_action.setCheckable(True) show_log_action.setChecked(False) @@ -465,10 +554,19 @@ class MainWindow(QMainWindow): about_action.triggered.connect(self.show_about_dialog) help_menu.addAction(about_action) + controls_help_action = QAction("Mouse / Keyboard Controls", self) + controls_help_action.triggered.connect(self.show_controls_help) + help_menu.addAction(controls_help_action) + dev_help_action = QAction("Developer / Help", self) dev_help_action.triggered.connect(self.show_developer_help) help_menu.addAction(dev_help_action) + if self.__decoded_token.staff: + beamline_recovery_action = QAction("Beamline Recovery", self) + beamline_recovery_action.triggered.connect(self.show_beamline_recovery) + help_menu.addAction(beamline_recovery_action) + help_menu.addSeparator() start_text_tutorial_action = QAction("Start Tutorial (Text)", self) @@ -485,6 +583,14 @@ class MainWindow(QMainWindow): "About", "Aare Macromolecular Crystallography GUI\nVersion: 1.0\nCopyright: Paul Scherrer Institute 2024-2025", ) + + def show_controls_help(self) -> None: + if self._controls_help_dialog is None: + self._controls_help_dialog = ControlsHelpDialog(parent=self) + self._controls_help_dialog.show() + self._controls_help_dialog.raise_() + self._controls_help_dialog.activateWindow() + def show_developer_help(self) -> None: if self._dev_help_dialog is None: self._dev_help_dialog = DeveloperHelpDialog( @@ -497,6 +603,18 @@ class MainWindow(QMainWindow): self._dev_help_dialog.raise_() self._dev_help_dialog.activateWindow() + def show_beamline_recovery(self) -> None: + if not bool(getattr(self.__decoded_token, "staff", False)): + return + if self._beamline_recovery_dialog is None: + self._beamline_recovery_dialog = BeamlineRecoveryDialog( + daq=self.daq, + parent=self, + ) + self._beamline_recovery_dialog.show() + self._beamline_recovery_dialog.raise_() + self._beamline_recovery_dialog.activateWindow() + @Slot(str) def show_sample_missing_dialog(self, msg: str): if self.job_list_panel.is_running(): @@ -514,8 +632,12 @@ class MainWindow(QMainWindow): @Slot(DAQStatusModel) def update_daq_status(self, s: DAQStatusModel): - self.beamline_camera_thread.set_busy(s.busy) - self.gonio_camera_thread.set_busy(s.busy) + if hasattr(self, "beamline_camera_thread") and self.beamline_camera_thread is not None: + self.beamline_camera_thread.set_busy(s.busy) + + if hasattr(self, "gonio_camera_thread") and self.gonio_camera_thread is not None: + self.gonio_camera_thread.set_busy(s.busy) + if not self.__mounting and s.state == BeamlineStateEnum.RobotSampleExchange: self.__mounting = True self.video_tab.setCurrentIndex(3) @@ -532,12 +654,57 @@ class MainWindow(QMainWindow): self.status_bar.setStyleSheet("color: green;") self.status_bar.showMessage(f' {msg} ', 10000) + def _restore_window_state(self) -> None: + settings = QSettings() + geometry = settings.value("main_window/geometry") + state = settings.value("main_window/state") + + if geometry is not None: + self.restoreGeometry(geometry) + if state is not None: + self.restoreState(state) + + def closeEvent(self, event) -> None: + try: + settings = QSettings() + settings.setValue("main_window/geometry", self.saveGeometry()) + settings.setValue("main_window/state", self.saveState()) + except Exception as e: + logger.warning(f"Failed to save main window state: {e}") + + try: + self.cleanup() + except Exception as e: + logger.warning(f"Cleanup during closeEvent failed: {e}") + + super().closeEvent(event) + def cleanup(self): - if self.camera_thread is not None: - self.camera_thread.stop() - if self.prediction_thread is not None: - self.prediction_thread.stop() - if self.beamline_camera_thread is not None: - self.beamline_camera_thread.stop() - if self.gonio_camera_thread is not None: - self.gonio_camera_thread.stop() + if getattr(self, "_cleanup_done", False): + return + self._cleanup_done = True + + try: + if hasattr(self, "_samcam_source_timer") and self._samcam_source_timer is not None: + self._samcam_source_timer.stop() + except Exception as e: + logger.warning(f"Failed to stop _samcam_source_timer: {e}") + + for attr_name in ( + "camera_thread", + "prediction_thread", + "beamline_camera_thread", + "gonio_camera_thread", + ): + thread = getattr(self, attr_name, None) + if thread is None: + continue + + logger.debug(f"Stopping {attr_name}") + + try: + thread.stop() + except Exception as e: + logger.warning(f"Failed to stop {attr_name}: {e}") + + setattr(self, attr_name, None) \ No newline at end of file diff --git a/src/aare/gui/panels/beamline_recovery_panel.py b/src/aare/gui/panels/beamline_recovery_panel.py new file mode 100644 index 00000000..6f9afa13 --- /dev/null +++ b/src/aare/gui/panels/beamline_recovery_panel.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +from PySide6.QtCore import Slot +from PySide6.QtWidgets import ( + QDialog, + QVBoxLayout, + QLabel, + QPushButton, + QWidget, + QDialogButtonBox, + QInputDialog, + QLineEdit, + QMessageBox, +) + +from aare.common.models import DAQStatusModel +from aare.gui.threads.daq_worker import DAQWorker + + +class RecoveryPanel(QWidget): + def __init__(self, *, daq: DAQWorker, parent=None): + super().__init__(parent) + self._daq = daq + self._last_status: DAQStatusModel | None = None + + layout = QVBoxLayout(self) + layout.setSpacing(10) + + self._warning_primary = QLabel( + "⚠ Recovery actions are staff-only and intentionally dangerous.", + self, + ) + self._warning_primary.setWordWrap(True) + self._warning_primary.setStyleSheet( + "QLabel {" + " background: #fff3cd;" + " color: #7a4b00;" + " border: 1px solid #f0c36d;" + " border-radius: 6px;" + " padding: 8px;" + " font-weight: 600;" + "}" + ) + layout.addWidget(self._warning_primary) + + self._warning_secondary = QLabel( + "Only use these commands when beamline is stuck and certain beamline is unrecoverable through normal operation.", + self, + ) + self._warning_secondary.setWordWrap(True) + self._warning_secondary.setStyleSheet( + "QLabel {" + " background: #fdeaea;" + " color: #8b1e1e;" + " border: 1px solid #e6a8a8;" + " border-radius: 6px;" + " padding: 8px;" + " font-weight: 600;" + "}" + ) + layout.addWidget(self._warning_secondary) + + self._status = QLabel("Current status: waiting for DAQ status update…", self) + self._status.setWordWrap(True) + self._status.setStyleSheet( + "QLabel {" + " background: #fafafa;" + " border: 1px solid #d0d0d0;" + " border-radius: 6px;" + " padding: 8px;" + "}" + ) + layout.addWidget(self._status) + + self._take_over_btn = QPushButton("Take over beamline", self) + self._take_over_btn.setStyleSheet( + "QPushButton {" + " background: #fff3cd;" + " border: 1px solid #f0c36d;" + " border-radius: 6px;" + " padding: 8px;" + " font-weight: 600;" + "}" + ) + self._take_over_btn.clicked.connect(self._take_over_beamline) + layout.addWidget(self._take_over_btn) + + self._free_beamline_btn = QPushButton("Free beamline", self) + self._free_beamline_btn.setStyleSheet( + "QPushButton {" + " background: #fff3cd;" + " border: 1px solid #f0c36d;" + " border-radius: 6px;" + " padding: 8px;" + " font-weight: 600;" + "}" + ) + self._free_beamline_btn.clicked.connect(self._free_beamline) + layout.addWidget(self._free_beamline_btn) + + self._recover_beamline_btn = QPushButton("Recover beamline", self) + self._recover_beamline_btn.setStyleSheet( + "QPushButton {" + " background: #fdeaea;" + " color: #8b1e1e;" + " border: 1px solid #e6a8a8;" + " border-radius: 6px;" + " padding: 8px;" + " font-weight: 700;" + "}" + ) + self._recover_beamline_btn.clicked.connect(self._recover_beamline) + layout.addWidget(self._recover_beamline_btn) + + self._recovery_unmount_btn = QPushButton("Unmount sample (recovery)", self) + self._recovery_unmount_btn.setStyleSheet( + "QPushButton {" + " background: #fdeaea;" + " color: #8b1e1e;" + " border: 1px solid #e6a8a8;" + " border-radius: 6px;" + " padding: 8px;" + " font-weight: 700;" + "}" + ) + self._recovery_unmount_btn.clicked.connect(self._recovery_unmount_sample) + layout.addWidget(self._recovery_unmount_btn) + + layout.addStretch(1) + + self._daq.update.connect(self._set_daq_status) + self._refresh_buttons() + + def _prompt_recovery_code(self, action_name: str) -> str | None: + code, ok = QInputDialog.getText( + self, + action_name, + "Enter recovery confirmation code:", + QLineEdit.EchoMode.Password, + ) + if not ok: + return None + code = code.strip() + return code or None + + def _sample_appears_mounted(self) -> bool: + try: + return self._last_status is not None and self._last_status.sample is not None + except Exception: + return False + + def _beamline_appears_busy(self) -> bool: + try: + return self._last_status is not None and bool(self._last_status.busy) + except Exception: + return False + + def _status_text(self) -> str: + if self._last_status is None: + return "Current status: waiting for DAQ status update…" + + state_name = getattr(self._last_status.state, "name", str(self._last_status.state)) + busy = bool(getattr(self._last_status, "busy", False)) + sample_mounted = self._sample_appears_mounted() + tell_connected = bool(getattr(self._last_status, "tell_connected", False)) + + return ( + f"State: {state_name}\n" + f"Busy: {busy}\n" + f"Sample mounted: {sample_mounted}\n" + f"TELL connected: {tell_connected}" + ) + + def _refresh_buttons(self) -> None: + sample_mounted = self._sample_appears_mounted() + beamline_busy = self._beamline_appears_busy() + + self._recovery_unmount_btn.setEnabled(sample_mounted) + self._recovery_unmount_btn.setToolTip( + "" if sample_mounted else "Disabled because no mounted sample is visible in current status." + ) + + self._free_beamline_btn.setEnabled(beamline_busy) + self._free_beamline_btn.setToolTip( + "" if beamline_busy else "Disabled because beamline does not currently appear busy." + ) + + def _confirm(self, title: str, msg: str) -> bool: + reply = QMessageBox.warning( + self, + title, + msg, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + return reply == QMessageBox.StandardButton.Yes + + @Slot(DAQStatusModel) + def _set_daq_status(self, s: DAQStatusModel) -> None: + self._last_status = s + self._status.setText(self._status_text()) + self._refresh_buttons() + + @Slot() + def _take_over_beamline(self) -> None: + if not self._confirm( + "Take over beamline", + "This will forcefully grab the active beamline session.\n\nDo you want to continue?", + ): + return + code = self._prompt_recovery_code("Take over beamline") + if not code: + return + self._daq.take_over_beamline(code) + + @Slot() + def _free_beamline(self) -> None: + if not self._confirm( + "Free beamline", + "This will clear the beamline busy flag.\n\nDo you want to continue?", + ): + return + code = self._prompt_recovery_code("Free beamline") + if not code: + return + self._daq.free_beamline(code) + + @Slot() + def _recover_beamline(self) -> None: + if self._sample_appears_mounted(): + if not self._confirm( + "Recover beamline", + "A sample appears to be mounted.\n\n" + "Recovering the beamline may damage the sample or leave hardware in an unsafe state.\n\n" + "Only continue if you are sure this is the correct recovery action.", + ): + return + else: + if not self._confirm( + "Recover beamline", + "This will take over the beamline, clear the busy flag, and set the state to Maintenance.\n\n" + "Do you want to continue?", + ): + return + + code = self._prompt_recovery_code("Recover beamline") + if not code: + return + self._daq.recover_beamline(code) + + @Slot() + def _recovery_unmount_sample(self) -> None: + if not self._confirm( + "Unmount sample (recovery)", + "This will force-take the session and attempt a controlled recovery unmount.\n\n" + "Use this only if normal unmount is not possible.", + ): + return + + code = self._prompt_recovery_code("Unmount sample (recovery)") + if not code: + return + self._daq.recovery_unmount_sample(code) + + +class BeamlineRecoveryDialog(QDialog): + def __init__(self, *, daq: DAQWorker, parent=None): + super().__init__(parent) + self.setWindowTitle("Beamline Recovery") + self.setMinimumSize(560, 420) + + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(8) + + self._panel = RecoveryPanel(daq=daq, parent=self) + layout.addWidget(self._panel, 1) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, parent=self) + buttons.rejected.connect(self.reject) + buttons.accepted.connect(self.accept) + layout.addWidget(buttons) diff --git a/src/aare/gui/panels/developer_help_dialog.py b/src/aare/gui/panels/developer_help_dialog.py index dcc7bfd1..5979f2b2 100644 --- a/src/aare/gui/panels/developer_help_dialog.py +++ b/src/aare/gui/panels/developer_help_dialog.py @@ -29,6 +29,7 @@ from aare.common.logger_config import QtLogEmitter, QtLogHandler, find_existing_ from aare.gui.threads.daq_worker import DAQWorker + class DeveloperHelpDialog(QDialog): def __init__(self, *, daq: DAQWorker, is_staff: bool, parent=None): super().__init__(parent) diff --git a/src/aare/gui/panels/face_detection_panel.py b/src/aare/gui/panels/face_detection_panel.py index 8ee8f1b5..d6d3f929 100644 --- a/src/aare/gui/panels/face_detection_panel.py +++ b/src/aare/gui/panels/face_detection_panel.py @@ -1,4 +1,3 @@ -# Python from PySide6.QtCore import Signal from PySide6.QtWidgets import QWidget, QGridLayout, QLabel, QPushButton, QVBoxLayout from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas @@ -12,6 +11,7 @@ from aare.common.logger_config import setup_logger logger = setup_logger("aareGUI") + class FaceDetectionPanel(QWidget): face_detection = Signal(int, int) @@ -20,6 +20,7 @@ class FaceDetectionPanel(QWidget): super().__init__(parent) self.steps = 14 self.step_size = 15 + self._manual_run_requested = False def _set_steps(val: float): self.steps = int(val) @@ -29,27 +30,22 @@ class FaceDetectionPanel(QWidget): self.fig = Figure(figsize=(5, 4), tight_layout=True) self.canvas = FigureCanvas(self.fig) - self.ax1 = self.fig.add_subplot(2, 1, 1) # Height vs angle - self.ax2 = self.fig.add_subplot(2, 1, 2) # Area vs angle + self.ax1 = self.fig.add_subplot(2, 1, 1) + self.ax2 = self.fig.add_subplot(2, 1, 2) self._top_layout = QGridLayout() self._top_layout.addWidget(TitleLabel("TELL sample changer", self), 0, 0, 1, 3) - self.status_lbl = QLabel("") + self.status_lbl = QLabel("Idle") self._top_layout.addWidget(self.status_lbl, 0, 2) self._top_layout.addWidget(QLabel("step size", parent=self), 1, 0) - self.step_size_enter = NumberLineEdit( - 0, 50, 15, decimals=4, parent=self - ) - + self.step_size_enter = NumberLineEdit(0, 50, 15, decimals=4, parent=self) self._top_layout.addWidget(self.step_size_enter, 1, 1, 1, 3) self.step_size_enter.newValue.connect(_set_step_size) self._top_layout.addWidget(QLabel("°", parent=self), 1, 4) self._top_layout.addWidget(QLabel("number of steps", parent=self), 2, 0) - self.steps_enter = NumberLineEdit( - 0, 50, 14, decimals=4, parent=self - ) + self.steps_enter = NumberLineEdit(0, 50, 14, decimals=4, parent=self) self._top_layout.addWidget(self.steps_enter, 2, 1, 1, 3) self._top_layout.addWidget(QLabel("", parent=self), 2, 4) self.steps_enter.newValue.connect(_set_steps) @@ -69,49 +65,68 @@ class FaceDetectionPanel(QWidget): def run_and_refresh(self): try: + self._manual_run_requested = True + self.status_lbl.setText("Starting...") + self.face_detection_button.setEnabled(False) self.face_detection.emit(int(self.steps), int(self.step_size)) except Exception as e: + self._manual_run_requested = False self.status_lbl.setText(f"Error: {e}") + self.face_detection_button.setEnabled(True) def update_plot(self, data): - samples = data.get("samples", []) - print(samples) + samples = data.get("samples", []) or [] + running = bool(data.get("running", False)) + angle = data.get("current_angle_deg") + status = data.get("status", "") + + if running: + if self._manual_run_requested: + self.status_lbl.setText(f"Running... angle {angle}" if angle is not None else "Running...") + else: + self.status_lbl.setText(f"Automation running... angle {angle}" if angle is not None else "Automation running...") + else: + if self._manual_run_requested: + self.status_lbl.setText("Done") + self.face_detection_button.setEnabled(True) + self._manual_run_requested = False + elif samples: + self.status_lbl.setText("Showing latest result") + else: + self.status_lbl.setText("Idle") + + self.ax1.clear() + self.ax2.clear() + if not samples: - self.ax1.clear() - self.ax2.clear() self.ax1.text(0.5, 0.5, "No data", ha="center", va="center") self.ax2.text(0.5, 0.5, "No data", ha="center", va="center") self.canvas.draw_idle() - logger.info("No data") return angles = np.array([s["angle_deg"] for s in samples], dtype=float) heights = np.array([s["height"] for s in samples], dtype=float) areas = np.array([s["area"] for s in samples], dtype=float) - self.ax1.clear() - self.ax2.clear() self.ax1.scatter(angles, heights, s=16, c="tab:blue", label="Height") self.ax2.scatter(angles, areas, s=16, c="tab:green", label="Area") - ang_grid = np.linspace(angles.min(),angles.max(), 400) - #ang_grid_wrapped = ((ang_grid + 180) % 360) - 180 - hf = data.get("height_fit", {}) + ang_grid = np.linspace(angles.min(), angles.max(), 400) + + hf = data.get("height_fit", {}) or {} if {"A", "B", "phi_rad", "C"} <= hf.keys(): A, B, phi, C = hf["A"], hf["B"], hf["phi_rad"], hf["C"] - height_fit = A + B * np.cos(C*np.deg2rad(ang_grid) - phi) + height_fit = A + B * np.cos(C * np.deg2rad(ang_grid) - phi) self.ax1.plot(ang_grid, height_fit, color="tab:orange", label="Height fit") if "best_angle_deg" in hf: - logger.info(f"best angle: {hf['best_angle_deg']}") self.ax1.axvline(hf["best_angle_deg"], color="tab:orange", ls="--", alpha=0.6) - af = data.get("area_fit", {}) - if {"A", "B", "phi_rad", "C"} <= hf.keys(): + af = data.get("area_fit", {}) or {} + if {"A", "B", "phi_rad", "C"} <= af.keys(): A2, B2, phi2, C2 = af["A"], af["B"], af["phi_rad"], af["C"] area_fit = A2 + B2 * np.cos(C2 * np.deg2rad(ang_grid) - phi2) self.ax2.plot(ang_grid, area_fit, color="tab:red", label="Area fit") if "best_angle_deg" in af: - logger.info(f"best angle: {af['best_angle_deg']}") self.ax2.axvline(af["best_angle_deg"], color="tab:red", ls="--", alpha=0.6) self.ax1.set_xlabel("Angle (deg)") diff --git a/src/aare/gui/panels/file_path_panel.py b/src/aare/gui/panels/file_path_panel.py index 8af17c42..db141a7f 100644 --- a/src/aare/gui/panels/file_path_panel.py +++ b/src/aare/gui/panels/file_path_panel.py @@ -182,6 +182,7 @@ class FilePathPanel(QWidget): self.__puck_pos = 99 self.directory_edit.setText(f"{self.__formatted_date}/test") else: + self.__sample_id = sample.db_id self.__sample_name = sample.sample_name self.__dewar_pos = sample.loc_str() self.__puck_name = sample.puck_name diff --git a/src/aare/gui/panels/smargon_panel.py b/src/aare/gui/panels/smargon_panel.py index 89940545..c9b1f5f6 100644 --- a/src/aare/gui/panels/smargon_panel.py +++ b/src/aare/gui/panels/smargon_panel.py @@ -85,7 +85,8 @@ class SmargonPanel(QWidget): @Slot() def home(self): - self.smargon.emit(Smargon.SMARGON_HOME) + #TODO move SMARGON_HOME to REDIS, allow GUI to read this value + self.smargon.emit(SmargonCoordinate(sh_mm=Coordinate(x=0, y=0, z=18), phi_deg=0, chi_deg=0)) @Slot(float) def phi(self, f: float): diff --git a/src/aare/gui/panels/smargon_trace_panel.py b/src/aare/gui/panels/smargon_trace_panel.py new file mode 100644 index 00000000..fe958702 --- /dev/null +++ b/src/aare/gui/panels/smargon_trace_panel.py @@ -0,0 +1,639 @@ +from __future__ import annotations + +import csv +from math import sqrt +from pathlib import Path + +import numpy as np +from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure +from PySide6.QtCore import QSettings, QTimer, Qt +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QFileDialog, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + + +class SmargonTracePanel(QWidget): + HOME_X_MM = 0.0 + HOME_Y_MM = 0.0 + HOME_Z_MM = 18.0 + TABLE_MAX_ROWS = 20 + + COLOR_X = "tab:red" + COLOR_Y = "tab:green" + COLOR_Z = "tab:blue" + COLOR_DISTANCE = "tab:purple" + + SETTINGS_GROUP = "smargon_trace_panel" + + def __init__(self, csv_path: str | Path = "logs/smargon_trace.csv", parent=None): + super().__init__(parent) + + self._csv_path = Path(csv_path) + self._active_csv_path: Path | None = None + self._last_mtime_ns: int | None = None + self._last_rows: list[dict[str, object]] = [] + self._last_distances: list[float] = [] + self._last_lengths: list[float] = [] + + self._status = QLabel("Waiting for smargon trace data...") + self._status.setWordWrap(True) + + self._summary = QLabel( + f"Home position: " + f"X={self.HOME_X_MM:.3f} mm, " + f"Y={self.HOME_Y_MM:.3f} mm, " + f"Z={self.HOME_Z_MM:.3f} mm" + ) + self._summary.setWordWrap(True) + + self._metrics = QLabel("Latest metrics: distance=n/a | length=n/a") + self._metrics.setWordWrap(True) + + self._show_summary_cb = QCheckBox("Summary") + self._show_delta_cb = QCheckBox("Delta") + self._show_absolute_cb = QCheckBox("Absolute") + self._show_distance_cb = QCheckBox("Total distance") + self._show_table_cb = QCheckBox("Table") + + self._show_summary_cb.setChecked(True) + self._show_delta_cb.setChecked(True) + self._show_absolute_cb.setChecked(False) + self._show_distance_cb.setChecked(False) + self._show_table_cb.setChecked(False) + + self._units_combo = QComboBox() + self._units_combo.addItems(["mm", "µm"]) + self._units_combo.setCurrentText("mm") + self._units_combo.setMaximumWidth(90) + + self._export_btn = QPushButton("Export table CSV") + self._export_btn.clicked.connect(self._export_table_csv) + + display_group = QGroupBox("Display") + display_layout = QHBoxLayout(display_group) + display_layout.setContentsMargins(8, 6, 8, 6) + display_layout.setSpacing(10) + display_layout.addWidget(self._show_summary_cb) + display_layout.addWidget(self._show_delta_cb) + display_layout.addWidget(self._show_absolute_cb) + display_layout.addWidget(self._show_distance_cb) + display_layout.addWidget(self._show_table_cb) + display_layout.addStretch() + display_layout.addWidget(QLabel("Units:")) + display_layout.addWidget(self._units_combo) + display_layout.addWidget(self._export_btn) + + self._summary_group = QGroupBox("Summary") + summary_layout = QFormLayout(self._summary_group) + summary_layout.setContentsMargins(8, 6, 8, 6) + summary_layout.setSpacing(6) + summary_layout.addRow("Status:", self._status) + summary_layout.addRow("Position:", self._summary) + summary_layout.addRow("Metrics:", self._metrics) + + self._figure = Figure(figsize=(7, 7)) + self._canvas = FigureCanvas(self._figure) + + self._table = QTableWidget(self) + self._table.setColumnCount(8) + self._table.setHorizontalHeaderLabels( + ["Point", "Event", "Sample", "X", "Y", "Z", "Distance", "Length"] + ) + self._table.verticalHeader().setVisible(False) + self._table.setAlternatingRowColors(True) + self._table.setVisible(False) + + layout = QVBoxLayout(self) + layout.setContentsMargins(6, 6, 6, 6) + layout.setSpacing(8) + layout.addWidget(display_group) + layout.addWidget(self._summary_group) + layout.addWidget(self._canvas) + layout.addWidget(self._table) + + self._show_summary_cb.toggled.connect(self._on_controls_changed) + self._show_delta_cb.toggled.connect(self._on_controls_changed) + self._show_absolute_cb.toggled.connect(self._on_controls_changed) + self._show_distance_cb.toggled.connect(self._on_controls_changed) + self._show_table_cb.toggled.connect(self._on_controls_changed) + self._units_combo.currentTextChanged.connect(lambda _text: self._on_controls_changed()) + + self._load_settings() + + self._timer = QTimer(self) + self._timer.setInterval(1000) + self._timer.timeout.connect(self.refresh_plot) + self._timer.start() + + self._apply_visibility_settings() + self.refresh_plot(force=True) + + def refresh_plot(self, force: bool = False) -> None: + csv_path = self._resolve_csv_path() + + if csv_path is None or not csv_path.exists(): + self._active_csv_path = None + self._last_mtime_ns = None + self._last_rows = [] + self._last_distances = [] + self._last_lengths = [] + self._status.setText( + "No trace file found. Tried: " + + ", ".join(str(p) for p in self._candidate_paths()) + ) + self._summary.setText( + f"Home position: " + f"X={self.HOME_X_MM:.3f} mm, " + f"Y={self.HOME_Y_MM:.3f} mm, " + f"Z={self.HOME_Z_MM:.3f} mm" + ) + self._metrics.setText("Latest metrics: distance=n/a | length=n/a") + self._draw_empty("No smargon trace file found yet") + self._clear_table() + return + + try: + stat = csv_path.stat() + if ( + not force + and self._active_csv_path == csv_path + and self._last_mtime_ns == stat.st_mtime_ns + ): + return + + self._active_csv_path = csv_path + self._last_mtime_ns = stat.st_mtime_ns + + rows = self._read_rows(csv_path) + if not rows: + self._last_rows = [] + self._last_distances = [] + self._last_lengths = [] + self._status.setText(f"Trace file is empty: {csv_path}") + self._summary.setText( + f"Home position: " + f"X={self.HOME_X_MM:.3f} mm, " + f"Y={self.HOME_Y_MM:.3f} mm, " + f"Z={self.HOME_Z_MM:.3f} mm" + ) + self._metrics.setText("Latest metrics: distance=n/a | length=n/a") + self._draw_empty("Smargon trace file is empty") + self._clear_table() + return + + unit_name, unit_scale = self._unit_settings() + + x = list(range(1, len(rows) + 1)) + + shx_mm = [row["shx_mm"] for row in rows] + shy_mm = [row["shy_mm"] for row in rows] + shz_mm = [row["shz_mm"] for row in rows] + + dx_mm = [value - self.HOME_X_MM for value in shx_mm] + dy_mm = [value - self.HOME_Y_MM for value in shy_mm] + dz_mm = [value - self.HOME_Z_MM for value in shz_mm] + + shx = [value * unit_scale for value in shx_mm] + shy = [value * unit_scale for value in shy_mm] + shz = [value * unit_scale for value in shz_mm] + + dx = [value * unit_scale for value in dx_mm] + dy = [value * unit_scale for value in dy_mm] + dz = [value * unit_scale for value in dz_mm] + + distances_mm = [ + sqrt(dx0**2 + dy0**2 + dz0**2) + for dx0, dy0, dz0 in zip(dx_mm, dy_mm, dz_mm) + ] + distances = [value * unit_scale for value in distances_mm] + + lengths_mm = [self._projected_length_mm(row) for row in rows] + lengths = [value * unit_scale for value in lengths_mm] + + self._last_rows = rows + self._last_distances = distances + self._last_lengths = lengths + + self._redraw_plots( + x=x, + shx=shx, + shy=shy, + shz=shz, + dx=dx, + dy=dy, + dz=dz, + distances=distances, + unit_name=unit_name, + ) + + last = rows[-1] + last_dx_mm = dx_mm[-1] + last_dy_mm = dy_mm[-1] + last_dz_mm = dz_mm[-1] + + distance_value = distances[-1] + length_value = lengths[-1] + + self._status.setText( + f"Loaded {len(rows)} points from {csv_path} | " + f"last event={last['event']} | " + f"sample_id={last['sample_id']}" + ) + self._summary.setText( + f"Home: X={self.HOME_X_MM * unit_scale:.3f}, " + f"Y={self.HOME_Y_MM * unit_scale:.3f}, " + f"Z={self.HOME_Z_MM * unit_scale:.3f} {unit_name} | " + f"Latest: X={last['shx_mm'] * unit_scale:.5f}, " + f"Y={last['shy_mm'] * unit_scale:.5f}, " + f"Z={last['shz_mm'] * unit_scale:.5f} {unit_name} | " + f"Δ: X={last_dx_mm * unit_scale:+.5f}, " + f"Y={last_dy_mm * unit_scale:+.5f}, " + f"Z={last_dz_mm * unit_scale:+.5f} {unit_name}" + ) + self._metrics.setText( + f"distance={distance_value:.5f} {unit_name} | " + f"length={length_value:.5f} {unit_name} (beamline-plane projected)" + ) + + self._populate_table( + rows=rows, + distances=distances, + lengths=lengths, + unit_name=unit_name, + unit_scale=unit_scale, + ) + + except Exception as e: + self._last_rows = [] + self._last_distances = [] + self._last_lengths = [] + self._status.setText(f"Failed to load trace from {csv_path}: {e}") + self._summary.setText( + f"Home position: " + f"X={self.HOME_X_MM:.3f} mm, " + f"Y={self.HOME_Y_MM:.3f} mm, " + f"Z={self.HOME_Z_MM:.3f} mm" + ) + self._metrics.setText("Latest metrics: distance=n/a | length=n/a") + self._draw_empty("Failed to parse smargon trace") + self._clear_table() + + def showEvent(self, event) -> None: + super().showEvent(event) + self.refresh_plot(force=True) + + def _on_controls_changed(self) -> None: + self._save_settings() + self._apply_visibility_settings() + + def _load_settings(self) -> None: + settings = QSettings() + settings.beginGroup(self.SETTINGS_GROUP) + self._show_summary_cb.setChecked(settings.value("show_summary", True, type=bool)) + self._show_delta_cb.setChecked(settings.value("show_delta", True, type=bool)) + self._show_absolute_cb.setChecked(settings.value("show_absolute", False, type=bool)) + self._show_distance_cb.setChecked(settings.value("show_distance", False, type=bool)) + self._show_table_cb.setChecked(settings.value("show_table", False, type=bool)) + self._units_combo.setCurrentText(settings.value("units", "mm", type=str)) + settings.endGroup() + + def _save_settings(self) -> None: + settings = QSettings() + settings.beginGroup(self.SETTINGS_GROUP) + settings.setValue("show_summary", self._show_summary_cb.isChecked()) + settings.setValue("show_delta", self._show_delta_cb.isChecked()) + settings.setValue("show_absolute", self._show_absolute_cb.isChecked()) + settings.setValue("show_distance", self._show_distance_cb.isChecked()) + settings.setValue("show_table", self._show_table_cb.isChecked()) + settings.setValue("units", self._units_combo.currentText()) + settings.endGroup() + + def _apply_visibility_settings(self) -> None: + if not ( + self._show_delta_cb.isChecked() + or self._show_absolute_cb.isChecked() + or self._show_distance_cb.isChecked() + ): + self._show_delta_cb.blockSignals(True) + self._show_delta_cb.setChecked(True) + self._show_delta_cb.blockSignals(False) + + self._summary_group.setVisible(self._show_summary_cb.isChecked()) + self._table.setVisible(self._show_table_cb.isChecked()) + self.refresh_plot(force=True) + + def _export_table_csv(self) -> None: + if not self._last_rows: + return + + unit_name, unit_scale = self._unit_settings() + file_path, _ = QFileDialog.getSaveFileName( + self, + "Export Smargon Trace Table", + "smargon_trace_export.csv", + "CSV Files (*.csv)", + ) + if not file_path: + return + + with open(file_path, "w", encoding="utf-8", newline="") as f: + writer = csv.writer(f) + writer.writerow([ + "point", + "timestamp", + "event", + "sample_id", + f"shx_{unit_name}", + f"shy_{unit_name}", + f"shz_{unit_name}", + f"distance_{unit_name}", + f"length_{unit_name}", + "omega_deg", + "phi_deg", + "chi_deg", + ]) + + for idx, (row, distance_value, length_value) in enumerate( + zip(self._last_rows, self._last_distances, self._last_lengths), + start=1, + ): + writer.writerow([ + idx, + row["timestamp"], + row["event"], + row["sample_id"], + float(row["shx_mm"]) * unit_scale, + float(row["shy_mm"]) * unit_scale, + float(row["shz_mm"]) * unit_scale, + distance_value, + length_value, + row["omega_deg"], + row["phi_deg"], + row["chi_deg"], + ]) + + def _redraw_plots( + self, + *, + x: list[int], + shx: list[float], + shy: list[float], + shz: list[float], + dx: list[float], + dy: list[float], + dz: list[float], + distances: list[float], + unit_name: str, + ) -> None: + self._figure.clear() + + enabled = [] + if self._show_delta_cb.isChecked(): + enabled.append("delta") + if self._show_absolute_cb.isChecked(): + enabled.append("absolute") + if self._show_distance_cb.isChecked(): + enabled.append("distance") + + axes = self._figure.subplots(len(enabled), 1, squeeze=False) + axes_list = [row[0] for row in axes] + + for ax, plot_name in zip(axes_list, enabled): + if plot_name == "delta": + ax.plot(x, dx, marker="o", color=self.COLOR_X, label=f"ΔSHX [{unit_name}]") + ax.plot(x, dy, marker="o", color=self.COLOR_Y, label=f"ΔSHY [{unit_name}]") + ax.plot(x, dz, marker="o", color=self.COLOR_Z, label=f"ΔSHZ [{unit_name}]") + ax.axhline(0.0, color="black", linewidth=1.0, alpha=0.5) + ax.set_title("Smargon displacement from home") + ax.set_ylabel(f"Δ position [{unit_name}]") + ax.grid(True, alpha=0.3) + ax.legend(loc="best") + + elif plot_name == "absolute": + ax.plot(x, shx, marker="o", color=self.COLOR_X, label=f"SHX [{unit_name}]") + ax.plot(x, shy, marker="o", color=self.COLOR_Y, label=f"SHY [{unit_name}]") + ax.plot(x, shz, marker="o", color=self.COLOR_Z, label=f"SHZ [{unit_name}]") + ax.set_title("Smargon absolute position") + ax.set_ylabel(f"Position [{unit_name}]") + ax.grid(True, alpha=0.3) + ax.legend(loc="best") + + elif plot_name == "distance": + ax.plot(x, distances, marker="o", color=self.COLOR_DISTANCE, label=f"Total distance [{unit_name}]") + ax.set_title("Total distance from home") + ax.set_ylabel(f"Distance [{unit_name}]") + ax.grid(True, alpha=0.3) + ax.legend(loc="best") + + ax.set_xlabel("Trace point") + + self._figure.tight_layout() + self._canvas.draw_idle() + + def _populate_table( + self, + *, + rows: list[dict[str, object]], + distances: list[float], + lengths: list[float], + unit_name: str, + unit_scale: float, + ) -> None: + recent_rows = rows[-self.TABLE_MAX_ROWS:] + recent_distances = distances[-self.TABLE_MAX_ROWS:] + recent_lengths = lengths[-self.TABLE_MAX_ROWS:] + + self._table.setRowCount(len(recent_rows)) + self._table.setHorizontalHeaderLabels( + [ + "Point", + "Event", + "Sample", + f"X [{unit_name}]", + f"Y [{unit_name}]", + f"Z [{unit_name}]", + f"Distance [{unit_name}]", + f"Length [{unit_name}]", + ] + ) + + start_idx = len(rows) - len(recent_rows) + 1 + for row_idx, (row, distance_value, length_value) in enumerate( + zip(recent_rows, recent_distances, recent_lengths) + ): + values = [ + str(start_idx + row_idx), + str(row["event"]), + str(row["sample_id"]), + f"{float(row['shx_mm']) * unit_scale:.5f}", + f"{float(row['shy_mm']) * unit_scale:.5f}", + f"{float(row['shz_mm']) * unit_scale:.5f}", + f"{distance_value:.5f}", + f"{length_value:.5f}", + ] + for col_idx, value in enumerate(values): + item = QTableWidgetItem(value) + item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + self._table.setItem(row_idx, col_idx, item) + + self._table.resizeColumnsToContents() + + def _clear_table(self) -> None: + self._table.setRowCount(0) + + def _unit_settings(self) -> tuple[str, float]: + if self._units_combo.currentText() == "µm": + return "µm", 1000.0 + return "mm", 1.0 + + def _candidate_paths(self) -> list[Path]: + here = Path(__file__).resolve() + project_root = here.parents[4] + + candidates = [ + Path.cwd() / self._csv_path, + project_root / self._csv_path, + project_root / "src" / "aare" / "daq" / "logs" / "smargon_trace.csv", + project_root / "src" / "aare" / "gui" / "logs" / "smargon_trace.csv", + ] + + out: list[Path] = [] + seen: set[str] = set() + for path in candidates: + key = str(path.resolve()) if path.exists() else str(path) + if key not in seen: + out.append(path) + seen.add(key) + return out + + def _resolve_csv_path(self) -> Path | None: + existing = [p for p in self._candidate_paths() if p.exists()] + if not existing: + return None + return max(existing, key=lambda p: p.stat().st_mtime_ns) + + def _read_rows(self, csv_path: Path) -> list[dict[str, object]]: + rows: list[dict[str, object]] = [] + + with csv_path.open("r", encoding="utf-8", newline="") as f: + reader = csv.DictReader(f) + for row in reader: + try: + rows.append( + { + "timestamp": row.get("timestamp", row.get("ts", "")), + "event": row.get("event", ""), + "sample_id": row.get("sample_id", ""), + "omega_deg": float(row.get("omega_deg", "nan")), + "zoom": float(row.get("zoom", "nan")), + "shx_mm": float(row.get("shx_mm", "nan")), + "shy_mm": float(row.get("shy_mm", "nan")), + "shz_mm": float(row.get("shz_mm", "nan")), + "phi_deg": float(row.get("phi_deg", "nan")), + "chi_deg": float(row.get("chi_deg", "nan")), + } + ) + except (TypeError, ValueError): + continue + + return rows + + def _projected_length_mm(self, row: dict[str, object]) -> float: + rel = np.array( + [ + float(row["shx_mm"]) - self.HOME_X_MM, + float(row["shy_mm"]) - self.HOME_Y_MM, + float(row["shz_mm"]) - self.HOME_Z_MM, + ], + dtype=float, + ) + + vec_x = self._smargon_nudge_basis( + axis="x", + omega_deg=float(row["omega_deg"]), + phi_deg=float(row["phi_deg"]), + chi_deg=float(row["chi_deg"]), + ) + vec_y = self._smargon_nudge_basis( + axis="y", + omega_deg=float(row["omega_deg"]), + phi_deg=float(row["phi_deg"]), + chi_deg=float(row["chi_deg"]), + ) + + beam_x = float(np.dot(rel, vec_x)) + beam_y = float(np.dot(rel, vec_y)) + + return sqrt(beam_x**2 + beam_y**2) + + def _smargon_nudge_basis( + self, + *, + axis: str, + omega_deg: float, + phi_deg: float, + chi_deg: float, + ) -> np.ndarray: + phi = np.radians(np.around(phi_deg, decimals=1)) + chi = np.radians(np.around(chi_deg, decimals=1)) + omega = np.radians(np.around(omega_deg, decimals=1)) + + if axis == "x": + coord_x, coord_y, coord_z = 1.0, 0.0, 0.0 + elif axis == "y": + coord_x, coord_y, coord_z = 0.0, 1.0, 0.0 + else: + raise ValueError(f"Unsupported axis: {axis}") + + co = np.cos(omega) + so = np.sin(omega) + cp = np.cos(phi) + sp = np.sin(phi) + cc = np.cos(chi) + sc = np.sin(chi) + + offset_x = ( + -coord_z * co * sp + - coord_y * so * sp + + coord_x * cp * sc + - coord_y * co * cc * cp + + coord_z * so * cc * cp + ) + offset_y = ( + -coord_z * co * cp + - coord_y * so * cp + - coord_x * sc * sp + + coord_y * co * cc * sp + - coord_z * so * cc * sp + ) + offset_z = -coord_x * cc - coord_y * co * sc + coord_z * so * sc + + vec = np.array([offset_x, offset_y, offset_z], dtype=float) + norm = np.linalg.norm(vec) + + if norm == 0.0: + return np.array([0.0, 0.0, 0.0], dtype=float) + + return vec / norm + + def _draw_empty(self, message: str) -> None: + self._figure.clear() + ax = self._figure.add_subplot(111) + ax.set_title("Smargon trace") + ax.text(0.5, 0.5, message, ha="center", va="center", transform=ax.transAxes) + ax.set_xticks([]) + ax.set_yticks([]) + self._figure.tight_layout() + self._canvas.draw_idle() \ No newline at end of file diff --git a/src/aare/gui/threads/camera_thread.py b/src/aare/gui/threads/camera_thread.py index b5d16b5a..aba490d3 100644 --- a/src/aare/gui/threads/camera_thread.py +++ b/src/aare/gui/threads/camera_thread.py @@ -67,7 +67,7 @@ class SampleCameraThread(QThread): self.__fps_window_start = now self.__fps_frame_count = 0 - if len(r) != 2: + if len(r) < 2: continue data = r[-1] @@ -82,31 +82,25 @@ class SampleCameraThread(QThread): except: continue - #meta, data = r - #header = json.loads(meta) - #header_shape = header["shape"] - if header and header.get("type") == "uint8":# and len(header_shape) == 2: - header_shape = header["shape"] - bayer_image = np.frombuffer(data, dtype=np.uint8) - bayer_image = bayer_image.reshape(header_shape) - rgb_image = cv2.cvtColor(bayer_image, cv2.COLOR_BAYER_GB2RGB) - #cv2.COLOR_BAYER_GB2RGB for ethernet connection - rgb_image = rgb_image[:, ::-1, :].copy() + if header and header.get("type") == "uint8": + h, w = header["shape"][:2] + raw = np.frombuffer(data, np.uint8).reshape((h, w)) + + rgb = cv2.cvtColor(raw, cv2.COLOR_BAYER_GB2RGB) # choose correct Bayer order + #rgb = cv2.flip(rgb, 0) + if self.__measure_focus: - gray = cv2.cvtColor(rgb_image, cv2.COLOR_RGB2GRAY) + gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY) if self.__focus_mask is None or self.__focus_mask.shape != gray.shape: height, width = gray.shape y, x = np.ogrid[:height, :width] - self.__focus_mask = (x - self.__beam_x) ** 2 + (y - self.__beam_y) ** 2 <= self.__radius ** 2 + self.__focus_mask = (x - self.__beam_x) ** 2 + ( + y - self.__beam_y) ** 2 <= self.__radius ** 2 sharpness = focus_measure_edges(gray, self.__focus_mask) - # print(f"GUI: mask pixels: {self.__focus_mask.sum()}, " - # f"center=({self.__beam_x:.1f}, {self.__beam_y:.1f}), " - # f"radius={self.__radius}, " - # f"focus={sharpness:.2f}") self.focus_measure.emit(sharpness) - qimage = QImage(rgb_image.data, header_shape[1], header_shape[0], QImage.Format.Format_RGB888) + qimage = QImage(rgb.data, rgb.shape[1], rgb.shape[0], QImage.Format.Format_RGB888).copy() self.camera_image.emit(QPixmap.fromImage(qimage)) else: print("Sample camera image has wrong dimensions") diff --git a/src/aare/gui/threads/prediction_subscriber.py b/src/aare/gui/threads/prediction_subscriber.py index 1142435d..2c1de0a7 100644 --- a/src/aare/gui/threads/prediction_subscriber.py +++ b/src/aare/gui/threads/prediction_subscriber.py @@ -8,6 +8,9 @@ from PySide6.QtGui import QImage, QPixmap # If you need Bayer conversion like your SampleCameraThread did: import cv2 +from aare.common.logger_config import setup_logger + +logger = setup_logger("aareGUI") class PredictionSubscriber(QThread): # emits parsed JSON payload (dict with keys: time, frame_id, shape, boxes) @@ -20,12 +23,16 @@ class PredictionSubscriber(QThread): super().__init__(parent) self._ctx = zmq.Context() self._sock = self._ctx.socket(zmq.SUB) + self._sock.setsockopt(zmq.RCVTIMEO, 500) + self._sock.setsockopt(zmq.LINGER, 0) + if isinstance(topic, str): self._sock.setsockopt_string(zmq.SUBSCRIBE, topic) elif isinstance(topic, bytes): self._sock.setsockopt(zmq.SUBSCRIBE, topic) else: self._sock.setsockopt(zmq.SUBSCRIBE, b"") + self._sock.connect(pred_zmq_url) self.running = True @@ -65,23 +72,23 @@ class PredictionSubscriber(QThread): else: return None - # If you still need the horizontal flip you had before: - rgb = rgb[:, ::-1, :].copy() - - qimage = QImage(rgb.data, rgb.shape[1], rgb.shape[0], QImage.Format.Format_RGB888) + qimage = QImage(rgb.data, rgb.shape[1], rgb.shape[0], QImage.Format.Format_RGB888).copy() return QPixmap.fromImage(qimage) def run(self): - while self.running: - try: - parts = self._sock.recv_multipart() + try: + while self.running: + try: + parts = self._sock.recv_multipart() + except zmq.Again: + continue + if not parts: continue json_dicts: list[dict] = [] non_json_parts: list[bytes] = [] - # Parse all parts; keep non-JSON as candidates for image bytes for p in parts: d = self._try_parse_json(p) if d is not None: @@ -89,26 +96,51 @@ class PredictionSubscriber(QThread): else: non_json_parts.append(p) - header = next((d for d in json_dicts if "shape" in d and d.get("type") == "uint8"), None) + header = next( + (d for d in json_dicts if "shape" in d and d.get("type") == "uint8"), + None, + ) detections = next((d for d in json_dicts if "boxes" in d), None) - # Heuristic: image payload is usually the largest non-JSON part image_bytes = max(non_json_parts, key=len) if non_json_parts else None if header and image_bytes: pix = self._decode_image(header, image_bytes) - if pix is not None: + if pix is not None and self.running: self.image.emit(pix) - if detections: + if detections and self.running: self.prediction.emit(detections) - except Exception as e: - logger.error("PredictionSubscriber error:", e) - break + except Exception as e: + if self.running: + logger.exception(f"PredictionSubscriber error: {e}") + finally: + try: + if self._sock is not None: + self._sock.close(0) + except Exception: + pass + finally: + self._sock = None + + try: + if self._ctx is not None: + self._ctx.term() + except Exception: + pass + finally: + self._ctx = None def stop(self): self.running = False - self._sock.close() - self.quit() - self.wait() \ No newline at end of file + self.requestInterruption() + + try: + if self._sock is not None: + self._sock.close(0) + except Exception: + pass + + if not self.wait(1500): + logger.warning("PredictionSubscriber did not stop within timeout") \ No newline at end of file diff --git a/src/aare/gui/tutorials/controls_help_dialog.py b/src/aare/gui/tutorials/controls_help_dialog.py new file mode 100644 index 00000000..88e8c731 --- /dev/null +++ b/src/aare/gui/tutorials/controls_help_dialog.py @@ -0,0 +1,156 @@ +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QDialog, + QVBoxLayout, + QTextEdit, + QDialogButtonBox, + QTabWidget, + QWidget, +) + + +class ControlsHelpDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + + self.setWindowTitle("Mouse / Keyboard Controls") + self.setMinimumSize(760, 560) + + layout = QVBoxLayout(self) + + self._tabs = QTabWidget(self) + self._tabs.addTab( + self._create_tab( + """ +
Applies to gonio camera, beamline view, and combined beamline views.
+ +