diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 82b473f6..422a6e63 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -44,6 +44,7 @@ from aare.gui.panels.smargon_trace_panel import SmargonTracePanel from aare.gui.panels.axis_video_panel import AxisVideoPanel from aare.gui.panels.fluorescence_panel import FluorescencePanel from aare.gui.panels.automation_panel import AutomationProgressWidget +from aare.gui.panels.compact_automation_panel import CompactAutomationPanel #Scan Logic from aare.gui.scan_logic.raster_grid_manager import RasterGridManager @@ -271,10 +272,20 @@ class MainWindow(QMainWindow): if cfg_get("gui.cameras.secondary_beamline_camera_url", None): self.secondary_beamline_view = VideoGraphicsView() - self.secondary_beamline_view_panel = AxisVideoPanel("Secondary view", self.secondary_beamline_view, parent=top_widget) + self.secondary_beamline_view_panel = AxisVideoPanel("Secondary view", self.secondary_beamline_view, + parent=top_widget) self.secondary_beamline_view_panel.refresh_requested.connect(self.refresh_axis_cameras) self.video_tab.addTab(self.secondary_beamline_view_panel, "Secondary view") + self.compact_sample_camera = SampleCameraImageLabel( + geom=geom, + raster=self.raster, + parent=top_widget, + default_image=default_image, + ) + self.compact_automation_panel = CompactAutomationPanel(self.compact_sample_camera, parent=top_widget) + self.video_tab.addTab(self.compact_automation_panel, "Automation") + top_widget_layout.addWidget(self.video_tab) self._start_axis_camera_threads() @@ -362,6 +373,11 @@ class MainWindow(QMainWindow): self.tabifyDockWidget(self.manual_sample_dock, self.automation_progress_dock) self.tabifyDockWidget(self.automation_progress_dock, self.log_dock) + self.compact_automation_panel.play_pause_clicked.connect(self.job_list_panel.run) + self.compact_automation_panel.skip_clicked.connect(self.job_list_panel.skip_current_sample) + self.compact_automation_panel.step_through_toggled.connect(self.job_list_panel.set_step_through) + self.compact_automation_panel.show_full_view_requested.connect(self.restore_default_view) + self.job_list_panel.samples_in_queue_changed.connect( self.automation_progress_panel.set_samples_in_queue ) @@ -369,7 +385,13 @@ class MainWindow(QMainWindow): self.automation_progress_panel.set_running ) self.job_list_panel.automation_running_changed.connect( - self._on_automation_running_changed + self.compact_automation_panel.set_running + ) + self.job_list_panel.step_through_changed.connect( + self.compact_automation_panel.set_step_through + ) + self.job_list_panel.samples_in_queue_changed.connect( + self._refresh_compact_queue_preview ) self.automation_progress_panel.set_samples_in_queue( len(self.job_list_panel.table_model.samples) @@ -521,19 +543,24 @@ class MainWindow(QMainWindow): logger.debug(f"Starting prediction subscriber thread {sample_feed_addr}") self.prediction_thread = PredictionSubscriber(pred_zmq_url=sample_feed_addr, topic=b"") self.prediction_thread.image.connect(self.sample_camera.update_pixmap) + self.prediction_thread.image.connect(self.compact_sample_camera.update_pixmap) self.prediction_thread.prediction.connect(self.sample_camera.update_detections) + self.prediction_thread.prediction.connect(self.compact_sample_camera.update_detections) self.prediction_thread.prediction.connect(self.prediction_metrics_panel.update_from_prediction) self.prediction_thread.target_point.connect(self.sample_camera.update_target_point) + self.prediction_thread.target_point.connect(self.compact_sample_camera.update_target_point) self.prediction_thread.target_point.connect(self.target_stability_panel.update_target_point) self.prediction_thread.focus_measure.connect(self.status_bar.update_sharpness) self.prediction_thread.fps_measure.connect(self.status_bar.update_samcam_fps) self.prediction_thread.camera_availability_changed.connect(self.sample_camera.set_camera_available) + self.prediction_thread.camera_availability_changed.connect(self.compact_sample_camera.set_camera_available) self.prediction_thread.camera_availability_changed.connect(self._on_sample_camera_availability_changed) self.prediction_thread.camera_error.connect(self._on_sample_camera_error) self.prediction_thread.start() else: self.prediction_thread = None self.sample_camera.set_camera_available(False) + self.compact_sample_camera.set_camera_available(False) self._show_samcam_feed_banner("Sample camera feed unavailable: no stream configured") # @@ -627,6 +654,7 @@ class MainWindow(QMainWindow): self.daq.update.connect(self.beamline.illumination_panel.update_daq_status) self.daq.update.connect(self.raster.update_daq_status) self.daq.update.connect(self.sample_camera.update_daq_status) + self.daq.update.connect(self.compact_sample_camera.update_daq_status) self.daq.update.connect(self.tell_samples.update_daq_status) self.daq.update.connect(self.ref_tools_panel.update_daq_status) if self.prediction_thread is not None: @@ -844,6 +872,30 @@ class MainWindow(QMainWindow): self.gonio_camera_thread.frame_ready.connect(self.beamline_view_1_combined.update_frame) self.gonio_camera_thread.start() + def _all_tell_samples_in_default_order(self) -> list: + samples = list(self.tell_samples.table_model.samples) + return sorted( + [sample for sample in samples if sample.location is not None], + key=lambda sample: sample.loc_str_sort(), + ) + + @Slot() + def enter_compact_automation_view(self) -> None: + self.job_list_panel.ensure_default_queue_from_samples( + self._all_tell_samples_in_default_order() + ) + self._refresh_compact_queue_preview() + self.video_tab.setCurrentWidget(self.compact_automation_panel) + + @Slot() + def _refresh_compact_queue_preview(self) -> None: + current_sample, next_sample, next_next_sample = self.job_list_panel.queue_preview() + self.compact_automation_panel.set_samples( + current_sample, + next_sample, + next_next_sample, + ) + @Slot() def refresh_axis_cameras(self) -> None: logger.info("Refreshing Axis camera threads") @@ -861,7 +913,6 @@ class MainWindow(QMainWindow): # Main menu bar menu_bar = self.menuBar() - # File menu file_menu = menu_bar.addMenu("File") quit_action = QAction("&Quit", self) quit_action.setShortcut(QKeySequence.StandardKey.Quit) @@ -981,6 +1032,11 @@ class MainWindow(QMainWindow): beamline_combined_tab_action.triggered.connect(lambda: self.video_tab.setCurrentIndex(3)) view_menu.addAction(beamline_combined_tab_action) + compact_automation_action = QAction("Compact Automation View", self) + compact_automation_action.setShortcut(QKeySequence("Ctrl+5")) + compact_automation_action.triggered.connect(self.enter_compact_automation_view) + view_menu.addAction(compact_automation_action) + view_menu.addSeparator() restore_default_view_action = QAction("Restore Default View", self) @@ -1296,11 +1352,11 @@ class MainWindow(QMainWindow): #TODO tidy up mount and sampel view fucntions @Slot() def mount_view(self): - self.video_tab.setCurrentIndex(3) + self.video_tab.setCurrentWidget(self.beamline_combined_panel) @Slot() def sample_view(self): - self.video_tab.setCurrentIndex(0) + self.video_tab.setCurrentWidget(self.sample_camera) @Slot(DAQStatusModel) def update_daq_status(self, s: DAQStatusModel): @@ -1349,6 +1405,8 @@ class MainWindow(QMainWindow): if self._remote_close_deadline_ts is not None: self._clear_remote_close_request() + self._refresh_compact_queue_preview() + if not self.__mounting and s.state == BeamlineStateEnum.RobotSampleExchange: self.__mounting = True self.video_tab.setCurrentIndex(3) diff --git a/src/aare/gui/models/sample_queue_model.py b/src/aare/gui/models/sample_queue_model.py index 481a7a61..3c03968f 100644 --- a/src/aare/gui/models/sample_queue_model.py +++ b/src/aare/gui/models/sample_queue_model.py @@ -43,28 +43,28 @@ class SampleQueueSpreadsheet(QAbstractTableModel): def data(self, index, role=None): if role == Qt.ItemDataRole.DisplayRole: return get_entry(self.samples[index.row()], index.column()) - elif role == Qt.ItemDataRole.TextAlignmentRole: # Align text to center + elif role == Qt.ItemDataRole.TextAlignmentRole: return Qt.AlignmentFlag.AlignCenter elif role == Qt.ItemDataRole.BackgroundRole: if index.row() == 0: if self.__running: - return QBrush(QColor(255, 102, 0)) # Fluorescent orange + return QBrush(QColor(255, 102, 0)) else: - return QBrush(QColor(114, 159, 207)) # Dark blue - return QBrush(QColor(255, 255, 255)) # White - return None # For other roles, return None + return QBrush(QColor(114, 159, 207)) + return QBrush(QColor(255, 255, 255)) + return None def headerData(self, section, orientation, role=None): if role == Qt.ItemDataRole.DisplayRole: - if orientation == Qt.Orientation.Horizontal: # Column header + if orientation == Qt.Orientation.Horizontal: return self.header[section] if self.header else f"Column {section + 1}" - if orientation == Qt.Orientation.Vertical: # Row header - return str(section + 1) # Row numbers start from 1 + if orientation == Qt.Orientation.Vertical: + return str(section + 1) return None def updateData(self, samples: list[SampleShortInfo],): self.beginResetModel() - self.samples = samples + self.samples = list(samples) self.endResetModel() def mimeTypes(self): @@ -107,6 +107,7 @@ class SampleQueueSpreadsheet(QAbstractTableModel): def set_running(self, running: bool): self.__running = running + self.layoutChanged.emit() def remove_sample(self, db_id: int): self.beginResetModel() diff --git a/src/aare/gui/panels/compact_automation_panel.py b/src/aare/gui/panels/compact_automation_panel.py new file mode 100644 index 00000000..09b1c8a5 --- /dev/null +++ b/src/aare/gui/panels/compact_automation_panel.py @@ -0,0 +1,141 @@ +from PySide6.QtCore import Qt, Signal, Slot +from PySide6.QtWidgets import ( + QFrame, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPushButton, + QToolButton, + QMenu, + QWidget, +) + +from aare.common.models import SampleShortInfo + + +class CompactAutomationPanel(QFrame): + play_pause_clicked = Signal() + skip_clicked = Signal() + step_through_toggled = Signal(bool) + annotation_selected = Signal(str) + show_full_view_requested = Signal() + ensure_default_queue_requested = Signal() + + def __init__(self, camera_widget: QWidget, parent=None): + super().__init__(parent) + + self._running = False + self._step_through = False + self._current_sample: SampleShortInfo | None = None + self._next_sample: SampleShortInfo | None = None + self._next_next_sample: SampleShortInfo | None = None + + self.setFrameShape(QFrame.Shape.StyledPanel) + self.setObjectName("compactAutomationPanel") + + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(12, 12, 12, 12) + main_layout.setSpacing(10) + + top_bar = QHBoxLayout() + top_bar.setSpacing(8) + + self.menu_button = QToolButton(self) + self.menu_button.setText("☰") + self.menu_button.clicked.connect(self.show_full_view_requested.emit) + + self.title_label = QLabel("Automation mode", self) + self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + top_bar.addWidget(self.menu_button) + top_bar.addWidget(self.title_label, 1) + + main_layout.addLayout(top_bar) + main_layout.addWidget(camera_widget, 1) + + info_panel = QFrame(self) + info_layout = QVBoxLayout(info_panel) + info_layout.setContentsMargins(10, 10, 10, 10) + info_layout.setSpacing(6) + + self.current_label = QLabel("Current: —", self) + self.next_label = QLabel("Next: —", self) + self.next_next_label = QLabel("Next next: —", self) + + for label in (self.current_label, self.next_label, self.next_next_label): + label.setWordWrap(True) + + info_layout.addWidget(self.current_label) + info_layout.addWidget(self.next_label) + info_layout.addWidget(self.next_next_label) + + main_layout.addWidget(info_panel) + + button_row = QHBoxLayout() + button_row.setSpacing(10) + + self.play_pause_button = QPushButton("▶ Play", self) + self.play_pause_button.clicked.connect(self.play_pause_clicked.emit) + + self.skip_button = QPushButton("⏭ Skip", self) + self.skip_button.clicked.connect(self.skip_clicked.emit) + + self.step_button = QPushButton("Step Through", self) + self.step_button.setCheckable(True) + self.step_button.toggled.connect(self._on_step_toggled) + + self.annotation_button = QToolButton(self) + self.annotation_button.setText("+") + self.annotation_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + + annotation_menu = QMenu(self.annotation_button) + for label in ["Heart", "Thumbs Up", "Thumbs Down", "Eyes", "Scan Again"]: + action = annotation_menu.addAction(label) + action.triggered.connect( + lambda checked=False, value=label: self.annotation_selected.emit(value) + ) + self.annotation_button.setMenu(annotation_menu) + + button_row.addWidget(self.play_pause_button, 1) + button_row.addWidget(self.skip_button, 1) + button_row.addWidget(self.step_button, 1) + button_row.addWidget(self.annotation_button) + + main_layout.addLayout(button_row) + + @Slot(bool) + def set_running(self, running: bool) -> None: + self._running = bool(running) + self.play_pause_button.setText("⏸ Pause" if self._running else "▶ Play") + + @Slot(bool) + def set_step_through(self, enabled: bool) -> None: + self._step_through = bool(enabled) + self.step_button.blockSignals(True) + self.step_button.setChecked(self._step_through) + self.step_button.blockSignals(False) + + @Slot(object, object, object) + def set_samples( + self, + current_sample: SampleShortInfo | None, + next_sample: SampleShortInfo | None, + next_next_sample: SampleShortInfo | None, + ) -> None: + self._current_sample = current_sample + self._next_sample = next_sample + self._next_next_sample = next_next_sample + + self.current_label.setText(f"Current: {self._format_sample(current_sample)}") + self.next_label.setText(f"Next: {self._format_sample(next_sample)}") + self.next_next_label.setText(f"Next next: {self._format_sample(next_next_sample)}") + + def _on_step_toggled(self, checked: bool) -> None: + self._step_through = checked + self.step_through_toggled.emit(checked) + + @staticmethod + def _format_sample(sample: SampleShortInfo | None) -> str: + if sample is None: + return "—" + return f"{sample.sample_name} ({sample.loc_str()})" diff --git a/src/aare/gui/panels/sample_queue_panel.py b/src/aare/gui/panels/sample_queue_panel.py index 5f65ec8a..97327779 100644 --- a/src/aare/gui/panels/sample_queue_panel.py +++ b/src/aare/gui/panels/sample_queue_panel.py @@ -23,6 +23,7 @@ class SampleQueuePanel(QFrame): viewer_track_online = Signal() samples_in_queue_changed = Signal(int) automation_running_changed = Signal(bool) + step_through_changed = Signal(bool) def __init__(self, parent=None, samples: SampleShortInfoList | None = None): super().__init__(parent) @@ -32,6 +33,7 @@ class SampleQueuePanel(QFrame): self.__pause = True self.__set_to_pause = False self._current_db_id: int | None = None + self._step_through = False self.ring_current = None self._experiment_shutter_state = None @@ -100,6 +102,7 @@ class SampleQueuePanel(QFrame): layout.addLayout(button_layout) self._emit_samples_in_queue_changed() self.automation_running_changed.emit(False) + self.step_through_changed.emit(False) def _samples_left_in_queue(self) -> int: total = len(self.table_model.samples) @@ -110,6 +113,63 @@ class SampleQueuePanel(QFrame): def _emit_samples_in_queue_changed(self) -> None: self.samples_in_queue_changed.emit(self._samples_left_in_queue()) + def set_step_through(self, enabled: bool) -> None: + self._step_through = bool(enabled) + self.step_through_changed.emit(self._step_through) + + def is_step_through(self) -> bool: + return self._step_through + + def queue_preview(self) -> tuple[SampleShortInfo | None, SampleShortInfo | None, SampleShortInfo | None]: + samples = self.table_model.samples + current = samples[0] if len(samples) > 0 else None + next_sample = samples[1] if len(samples) > 1 else None + next_next_sample = samples[2] if len(samples) > 2 else None + return current, next_sample, next_next_sample + + def queue_samples(self, samples: list[SampleShortInfo], replace: bool = True) -> None: + if replace: + self.table_model.updateData(list(samples)) + else: + merged = list(self.table_model.samples) + existing_ids = {sample.db_id for sample in merged} + for sample in samples: + if sample.db_id not in existing_ids: + merged.append(sample) + self.table_model.updateData(merged) + self._emit_samples_in_queue_changed() + + def ensure_default_queue_from_samples(self, samples: list[SampleShortInfo]) -> None: + if len(self.table_model.samples) > 0: + return + + ordered = sorted( + [sample for sample in samples if sample.location is not None], + key=lambda sample: sample.loc_str_sort(), + ) + self.queue_samples(ordered, replace=True) + + def skip_current_sample(self) -> None: + if not self.table_model.samples: + return + + current = self.table_model.samples[0] + self.table_model.remove_sample(current.db_id) + self._current_db_id = None + + if not self.__pause and len(self.table_model.samples) > 0: + next_item = self.table_model.samples[0] + if self._step_through: + self.pause_automation(set_id_to_None=False) + else: + self._current_db_id = next_item.db_id + self._emit_samples_in_queue_changed() + self.auto_scan.emit(next_item) + else: + self._finish_empty_queue() + + self._emit_samples_in_queue_changed() + def remove_selected_samples(self): """Remove selected samples from the queue.""" selected_indexes = self.table_view.selectionModel().selectedRows() @@ -328,9 +388,12 @@ class SampleQueuePanel(QFrame): if not self.__pause and len(self.table_model.samples) > 0: next_item = self.table_model.samples[0] - self._current_db_id = next_item.db_id - self._emit_samples_in_queue_changed() - self.auto_scan.emit(next_item) + if self._step_through: + self.pause_automation(set_id_to_None=False) + else: + self._current_db_id = next_item.db_id + self._emit_samples_in_queue_changed() + self.auto_scan.emit(next_item) else: self._finish_empty_queue() @@ -365,9 +428,12 @@ class SampleQueuePanel(QFrame): # Missing or other errors -> continue to next sample if not self.__pause and len(self.table_model.samples) > 0: next_item = self.table_model.samples[0] - self._current_db_id = next_item.db_id - self._emit_samples_in_queue_changed() - self.auto_scan.emit(next_item) + if self._step_through: + self.pause_automation(set_id_to_None=False) + else: + self._current_db_id = next_item.db_id + self._emit_samples_in_queue_changed() + self.auto_scan.emit(next_item) else: self._finish_empty_queue()