portrait_mode: added a loop button to rerun the queue for debugging

This commit is contained in:
2026-06-24 15:30:06 +02:00
committed by appleb_m
parent 746bc147a2
commit c6ac739548
4 changed files with 487 additions and 44 deletions
+78 -30
View File
@@ -359,7 +359,6 @@ class MainWindow(QMainWindow):
self.compact_automation_panel.show_full_view_requested.connect(self._return_from_compact_automation_view)
self.compact_automation_panel.annotation_selected.connect(self._handle_compact_annotation)
self.tell_samples_dock = QDockWidget("Sample List", self)
self.tell_samples_dock.setObjectName("tell_samples_dock")
self.tell_samples_dock.setWidget(self.tell_samples)
@@ -374,7 +373,6 @@ class MainWindow(QMainWindow):
self.tabifyDockWidget(self.ref_tools_dock, self.tell_samples_dock)
if self.__decoded_token.staff:
self.ref_tools_dock.show()
self.ref_tools_dock.raise_()
else:
self.ref_tools_dock.hide()
@@ -419,7 +417,6 @@ class MainWindow(QMainWindow):
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.fluor_panel_dock)
self.fluor_panel_dock.hide()
# 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)
@@ -427,6 +424,8 @@ class MainWindow(QMainWindow):
self.log_dock.attach_logger("aareGUI")
self.log_dock.hide()
self.tabifyDockWidget(self.automation_progress_dock, self.log_dock)
self.tabifyDockWidget(self.manual_sample_dock, self.automation_progress_dock)
self.tabifyDockWidget(self.automation_progress_dock, self.log_dock)
@@ -933,13 +932,18 @@ class MainWindow(QMainWindow):
def _show_samcam_feed_banner(self, message: str) -> None:
self.__samcam_feed_banner_message = message
self.alert_banner_secondary.show_message(message, True)
self._show_runtime_notification(
title="Sample camera",
message=message,
level="warning",
sticky=True,
)
self.__samcam_feed_banner_active = True
def _clear_samcam_feed_banner(self) -> None:
if not self.__samcam_feed_banner_active:
return
self.alert_banner_secondary.clear_message()
self._clear_runtime_notification()
self.__samcam_feed_banner_active = False
def _stop_axis_camera_threads(self) -> None:
@@ -1183,19 +1187,31 @@ class MainWindow(QMainWindow):
@Slot(str, bool)
def _portrait_alert_primary(self, msg: str, is_error: bool) -> None:
"""Route primary alert banner — use in-panel toast in portrait mode."""
"""Route primary alerts into the runtime dock."""
if self.content_stack.currentWidget() is self.portrait_mode_page:
self.portrait_mode_panel.show_portrait_alert(msg, is_error)
else:
self.alert_banner.show_message(msg, is_error)
self._show_runtime_notification(
title="Runtime alert" if is_error else "Runtime update",
message=msg,
level="error" if is_error else "success",
sticky=is_error,
auto_clear_ms=None if is_error else 4000,
)
@Slot(str, bool)
def _portrait_alert_secondary(self, msg: str, is_error: bool) -> None:
"""Route secondary alert banner — use in-panel toast in portrait mode."""
"""Route secondary alerts into the runtime dock."""
if self.content_stack.currentWidget() is self.portrait_mode_page:
self.portrait_mode_panel.show_portrait_alert(msg, is_error)
else:
self.alert_banner_secondary.show_message(msg, is_error)
self._show_runtime_notification(
title="Device alert" if is_error else "Device update",
message=msg,
level="warning" if is_error else "info",
sticky=is_error,
auto_clear_ms=None if is_error else 4000,
)
@Slot()
def _refresh_portrait_queue_preview(self) -> None:
@@ -1546,16 +1562,46 @@ class MainWindow(QMainWindow):
self._dev_help_dialog.raise_()
self._dev_help_dialog.activateWindow()
def _show_runtime_notification(
self,
*,
title: str,
message: str,
level: str = "error",
sticky: bool = True,
auto_clear_ms: int | None = None,
) -> None:
self.log_dock.show_notification(
title=title,
message=message,
level=level,
sticky=sticky,
auto_clear_ms=auto_clear_ms,
)
def _show_runtime_waiting_notification(self, *, title: str, message: str) -> None:
self.log_dock.show_waiting_notification(title=title, message=message)
def _clear_runtime_notification(self) -> None:
self.log_dock.clear_notification()
def _clear_automation_critical_banner(self) -> None:
if not self._automation_critical_banner_active:
return
self.alert_banner.clear_message()
self._clear_runtime_notification()
self._automation_critical_banner_active = False
@Slot(bool)
def _on_automation_running_changed(self, running: bool) -> None:
if running:
self._clear_automation_critical_banner()
self._show_runtime_notification(
title="Automation running",
message="Automation resumed.",
level="success",
sticky=False,
auto_clear_ms=3500,
)
def _is_detector_critical_failure(self, message: str) -> bool:
text = (message or "").lower()
@@ -1589,14 +1635,12 @@ class MainWindow(QMainWindow):
def _on_manual_collection_critical_failure(self, message: str) -> None:
logger.critical(f"Manual collection critical detector failure: {message}")
try:
self.alert_banner_secondary.show_message(
self._detector_error_banner_text(automation=False, message=message),
True,
auto_clear_ms=0,
)
except Exception:
pass
self._show_runtime_notification(
title="Collection paused",
message=message,
level="error",
sticky=True,
)
try:
QMessageBox.critical(
@@ -1674,14 +1718,12 @@ class MainWindow(QMainWindow):
except Exception as e:
logger.error(f"Failed to update automation progress after critical failure: {e}")
# 3. Banner so the operator sees it immediately
try:
self.alert_banner.show_message(
banner_message, True, auto_clear_ms=0
)
self._automation_critical_banner_active = True
except Exception:
pass
self._show_runtime_notification(
title="Automation paused",
message=message,
level="error",
sticky=True,
)
# 4. Surface recovery UI
try:
@@ -1692,13 +1734,19 @@ class MainWindow(QMainWindow):
self._detector_error_dialog_title(message),
(
"Automation has been stopped because there is an error with the detector.\n\n"
"Please call your local contact.\n\n"
f"Details:\n{message}"
f"Details:\n{message}"
),
)
self.show_local_contact("Detector")
else:
self.show_local_contact("Recovery")
QMessageBox.critical(
self,
"Automation halted",
(
"A critical error occurred during automation and the "
"beamline could not recover automatically:\n\n"
f"{message}\n\n"
),
)
else:
if is_detector_failure:
QMessageBox.critical(
+216 -9
View File
@@ -1,32 +1,239 @@
# Python
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QDockWidget, QPlainTextEdit
from PySide6.QtCore import Qt, QTimer, Signal, Slot
from PySide6.QtWidgets import (
QDockWidget,
QPlainTextEdit,
QWidget,
QVBoxLayout,
QHBoxLayout,
QLabel,
QPushButton,
QToolButton,
QFrame,
)
from aare.common.logger_config import QtLogEmitter, QtLogHandler, find_existing_formatter, attach_to_logger
class RuntimeNotificationWidget(QFrame):
cleared = Signal()
show_log_requested = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("runtimeNotification")
self.setProperty("noticeLevel", "error")
self.setFrameShape(QFrame.Shape.StyledPanel)
self.setVisible(False)
self._auto_clear_timer = QTimer(self)
self._auto_clear_timer.setSingleShot(True)
self._auto_clear_timer.timeout.connect(self.clear_notification)
self._title = QLabel("", self)
self._title.setObjectName("runtimeNotificationTitle")
self._message = QLabel("", self)
self._message.setObjectName("runtimeNotificationMessage")
self._message.setWordWrap(True)
self._minimise_button = QToolButton(self)
self._minimise_button.setText("")
self._minimise_button.setToolTip("Minimise notification")
self._minimise_button.clicked.connect(self._toggle_minimised)
self._clear_button = QPushButton("Clear", self)
self._clear_button.clicked.connect(self.clear_notification)
self._show_log_button = QPushButton("Show Log", self)
self._show_log_button.clicked.connect(self.show_log_requested.emit)
header_layout = QHBoxLayout()
header_layout.setContentsMargins(0, 0, 0, 0)
header_layout.addWidget(self._title, 1)
header_layout.addWidget(self._minimise_button)
button_layout = QHBoxLayout()
button_layout.setContentsMargins(0, 0, 0, 0)
button_layout.addStretch(1)
button_layout.addWidget(self._show_log_button)
button_layout.addWidget(self._clear_button)
self._body = QWidget(self)
body_layout = QVBoxLayout(self._body)
body_layout.setContentsMargins(0, 0, 0, 0)
body_layout.setSpacing(8)
body_layout.addWidget(self._message)
body_layout.addLayout(button_layout)
root_layout = QVBoxLayout(self)
root_layout.setContentsMargins(12, 10, 12, 10)
root_layout.setSpacing(8)
root_layout.addLayout(header_layout)
root_layout.addWidget(self._body)
self._full_title = ""
self._full_message = ""
self._minimised = False
self._sticky = True
self.setStyleSheet(
"""
QFrame#runtimeNotification {
border: 1px solid #8a8a8a;
border-radius: 8px;
background-color: #fff4f4;
}
QFrame#runtimeNotification[noticeLevel="error"] {
background-color: #fff1f1;
border: 1px solid #d66;
}
QFrame#runtimeNotification[noticeLevel="warning"] {
background-color: #fff8e8;
border: 1px solid #d7aa42;
}
QFrame#runtimeNotification[noticeLevel="success"] {
background-color: #eefaf0;
border: 1px solid #6cb37a;
}
QFrame#runtimeNotification[noticeLevel="info"] {
background-color: #eef5ff;
border: 1px solid #6b9bd6;
}
QLabel#runtimeNotificationTitle {
font-weight: bold;
}
"""
)
def _set_level(self, level: str) -> None:
self.setProperty("noticeLevel", level)
self.style().unpolish(self)
self.style().polish(self)
self.update()
@Slot()
def _toggle_minimised(self) -> None:
self._minimised = not self._minimised
self._body.setVisible(not self._minimised)
self._minimise_button.setText("+" if self._minimised else "")
if self._minimised:
self._title.setText(self._full_title or "Notification")
else:
self._title.setText(self._full_title)
def show_notification(
self,
*,
title: str,
message: str,
level: str = "error",
sticky: bool = True,
auto_clear_ms: int | None = None,
) -> None:
self._auto_clear_timer.stop()
self._sticky = sticky
self._full_title = title.strip() or "Notification"
self._full_message = message.strip()
self._set_level(level)
self._title.setText(self._full_title)
self._message.setText(self._full_message)
self._clear_button.setVisible(not sticky)
self._body.setVisible(True)
self._minimised = False
self._minimise_button.setText("")
self.setVisible(True)
if not sticky:
timeout = 5000 if auto_clear_ms is None else int(auto_clear_ms)
self._auto_clear_timer.start(timeout)
def show_waiting(self, *, title: str, message: str, auto_clear_ms: int | None = None) -> None:
self.show_notification(
title=title,
message=message,
level="warning",
sticky=True,
auto_clear_ms=auto_clear_ms,
)
@Slot()
def clear_notification(self) -> None:
self._auto_clear_timer.stop()
self._full_title = ""
self._full_message = ""
self._title.clear()
self._message.clear()
self.setVisible(False)
self.cleared.emit()
class LogDock(QDockWidget):
def __init__(self, title="Log", parent=None):
super().__init__(title, parent)
self.setAllowedAreas(Qt.BottomDockWidgetArea | Qt.TopDockWidgetArea)
self.view = QPlainTextEdit(self)
self.setAllowedAreas(Qt.BottomDockWidgetArea | Qt.RightDockWidgetArea | Qt.LeftDockWidgetArea)
self.container = QWidget(self)
self.notification = RuntimeNotificationWidget(self.container)
self.notification.show_log_requested.connect(self._raise_and_focus_log)
self.view = QPlainTextEdit(self.container)
self.view.setReadOnly(True)
self.setWidget(self.view)
layout = QVBoxLayout(self.container)
layout.setContentsMargins(6, 6, 6, 6)
layout.setSpacing(6)
layout.addWidget(self.notification)
layout.addWidget(self.view, 1)
self.setWidget(self.container)
# connect emitter to append text safely from any thread
self.emitter = QtLogEmitter()
self.emitter.message.connect(self._append_line)
# logging handler
self.handler = QtLogHandler(self.emitter)
self.handler.setFormatter(find_existing_formatter())
def attach_logger(self, logger_name: str = "aareGUI"):
attach_to_logger(logger_name, self.handler)
@Slot(str)
def _append_line(self, text: str):
self.view.appendPlainText(text)
@Slot()
def _raise_and_focus_log(self) -> None:
self.setVisible(True)
self.raise_()
self.view.setFocus()
def show_notification(
self,
*,
title: str,
message: str,
level: str = "error",
sticky: bool = True,
auto_clear_ms: int | None = None,
) -> None:
self.setVisible(True)
self.raise_()
self.notification.show_notification(
title=title,
message=message,
level=level,
sticky=sticky,
auto_clear_ms=auto_clear_ms,
)
def show_waiting_notification(self, *, title: str, message: str) -> None:
self.setVisible(True)
self.raise_()
self.notification.show_waiting(title=title, message=message)
def clear_notification(self) -> None:
self.notification.clear_notification()
def clear(self):
self.view.clear()
+182 -5
View File
@@ -9,9 +9,13 @@ from PySide6.QtGui import (
from PySide6.QtWidgets import (
QFrame, QHBoxLayout, QLabel, QPushButton, QScrollArea,
QSizePolicy, QStackedWidget, QVBoxLayout, QWidget,
QSpinBox,
)
from aare.common.automation_models import AutomationProgress, StepStatus, WorkflowStateKind
from aare.common.logger_config import setup_logger
logger = setup_logger('aareGUI')
# ---------------------------------------------------------------------------
# Colour palette (kept identical to gui_designer.py)
@@ -249,6 +253,15 @@ class PortraitModePanel(QWidget):
self._job_list_panel = None
self._tell_samples = None
self._is_running = False
self._loop_enabled = False
self._loop_remaining = 0
self._loop_samples = []
self._loop_restart_timer = QTimer(self)
self._loop_restart_timer.setInterval(1000)
self._loop_restart_timer.timeout.connect(self._try_loop_restart)
self._loop_restart_deadline = None
# Pending alert messages received while in portrait mode
# Each entry: (msg, is_error)
@@ -349,13 +362,48 @@ class PortraitModePanel(QWidget):
ctrl_frame.setStyleSheet(
f"QFrame {{ background: {CARD_BG}; border-radius: 18px; }}"
)
ctrl_layout = QHBoxLayout(ctrl_frame)
ctrl_layout.setContentsMargins(16, 12, 16, 12)
ctrl_layout.addStretch(1)
ctrl_layout.setContentsMargins(12, 12, 12, 12)
ctrl_layout.setSpacing(12)
ctrl_layout.addStretch()
self._play_pause_btn = PlayPauseButton()
self._play_pause_btn.clicked.connect(self._on_play_pause_clicked)
ctrl_layout.addWidget(self._play_pause_btn, alignment=Qt.AlignCenter)
ctrl_layout.addStretch(1)
ctrl_layout.addWidget(self._play_pause_btn)
# Loop arrow button
self._loop_btn = QPushButton("")
self._loop_btn.setFixedSize(52, 52)
self._loop_btn.setCheckable(True)
self._loop_btn.setToolTip("Loop queue")
self._loop_btn.setStyleSheet(f"""
QPushButton {{
border-radius: 26px;
background: {BUTTON_BG};
color: {ACCENT};
font-size: 28px;
font-weight: bold;
}}
QPushButton:checked {{
background: {ACCENT};
color: {BG};
}}
""")
self._loop_btn.toggled.connect(self._toggle_loop)
ctrl_layout.addWidget(self._loop_btn)
self._loop_count = QSpinBox()
self._loop_count.setRange(1, 999)
self._loop_count.setValue(2)
self._loop_count.setPrefix("× ")
self._loop_count.setFixedHeight(40)
self._loop_count.setToolTip("Number of queue repeats")
ctrl_layout.addWidget(self._loop_count)
ctrl_layout.addStretch()
layout.addWidget(ctrl_frame)
# "UP NEXT" header
@@ -444,6 +492,13 @@ class PortraitModePanel(QWidget):
self._job_list_panel = job_list_panel
self._tell_samples = tell_samples
self._job_list_panel.loop_restart_requested = (
self._restart_loop_if_needed
)
self._populate_queue_from_tell_samples_if_empty()
self.refresh_queue_preview()
# ------------------------------------------------------------------
# Public update slots
# ------------------------------------------------------------------
@@ -665,4 +720,126 @@ class PortraitModePanel(QWidget):
last_msg, last_is_error = errors[-1]
primary_banner.show_message(last_msg, last_is_error)
self._pending_alerts.clear()
self._dismiss_portrait_alert()
self._dismiss_portrait_alert()
@Slot(bool)
def _toggle_loop(self, enabled: bool) -> None:
self._loop_enabled = enabled
if enabled and self._job_list_panel:
self._loop_samples = list(
self._job_list_panel.table_model.samples
)
self._loop_remaining = self._loop_count.value()
else:
self._loop_remaining = 0
def _try_loop_restart(self):
"""
Polls beamline state until safe to restart.
"""
if self._job_list_panel is None:
self._loop_restart_timer.stop()
return
# Timeout protection
if (
self._loop_restart_deadline is not None
and self._loop_restart_deadline.hasExpired()
):
self._loop_restart_timer.stop()
self._loop_enabled = False
self._loop_btn.setChecked(False)
self.show_portrait_alert(
"Loop stopped: beamline remained busy too long",
True,
)
return
# Still busy, wait
if getattr(
self._job_list_panel,
"_SampleQueuePanel__busy",
False,
):
return
# Safe to restart
self._loop_restart_timer.stop()
self._job_list_panel.run()
def _restart_loop_if_needed(self) -> bool:
"""
Restore queue and wait for beamline idle before restarting.
Has timeout protection.
"""
if not self._loop_enabled:
return False
if not self._loop_samples:
return False
if self._loop_remaining <= 0:
return False
self._job_list_panel.queue_samples(
list(self._loop_samples),
replace=True,
)
self._loop_remaining -= 1
# Start waiting for idle
self._loop_restart_deadline = (
QTimer().remainingTime()
)
# 30 second safety timeout
from PySide6.QtCore import QDeadlineTimer
self._loop_restart_deadline = QDeadlineTimer(30000)
self._loop_restart_timer.start()
return True
def _populate_queue_from_tell_samples_if_empty(self) -> None:
"""
Fill the automation queue from TELL samples if no queue exists.
Samples are ordered by physical sample position.
"""
if self._job_list_panel is None or self._tell_samples is None:
logger.info("Job list panel or tell_samples not ready")
return
# Do not overwrite an existing queue
if self._job_list_panel.table_model.samples:
logger.info("Queue already exists, not populating from tell_samples")
return
samples = list(
getattr(self._tell_samples.table_model, "samples", [])
)
ordered = sorted(
[
s for s in samples
if getattr(s, "location", None) is not None
],
key=lambda s: (
s.loc_str_sort()
if hasattr(s, "loc_str_sort")
else ""
),
)
if ordered:
self._job_list_panel.queue_samples(
ordered,
replace=True,
)
+11
View File
@@ -37,6 +37,7 @@ class SampleQueuePanel(QFrame):
self._step_through = False
self.ring_current = None
self._experiment_shutter_state = None
self.loop_restart_requested = None
self.__recovery_timer = QTimer(self)
self.__recovery_timer.setSingleShot(True)
@@ -231,6 +232,15 @@ class SampleQueuePanel(QFrame):
def _finish_empty_queue(self):
self.pause_automation()
if hasattr(self, "loop_restart_requested"):
logger.info("Loop restart requested")
try:
if self.loop_restart_requested():
return
except Exception as e:
logger.exception(e)
if self.park_and_dry_when_cleared.isChecked():
logger.info("Automation queue empty; parking and drying TELL.")
self.park_and_dry.emit()
@@ -255,6 +265,7 @@ class SampleQueuePanel(QFrame):
if self.__pause:
if self.__busy:
logger.error(f"Cannot run automation while beamline is busy. Busy flag = {self.__busy}")
self.show_error_dialog(
title="Beamline is busy",
msg="Cannot run automation while beamline is busy",