diff --git a/src/aare/gui/new_gui/README.md b/src/aare/gui/new_gui/README.md index 5bfa22b6..dbc285b0 100644 --- a/src/aare/gui/new_gui/README.md +++ b/src/aare/gui/new_gui/README.md @@ -40,6 +40,14 @@ Backend contract is documented in the `new-gui-backend-paths` memory. - **Baton** chip ← `baton_status_changed`. - **Sample list** ← `spreadsheet` → Sample Changer + Library. - **Camera** ← `PredictionSubscriber` (live SAMCAM image, fps, target point). + ML detection overlays (boxes + convex-hull polygons, per-class colours), a + state-coloured beam-centre marker (`geom.beam_location_pxl`), and a focus + readout (`focus_measure`). Camera controls (⚙ gain/exposure → `samcam_settings`, + AF → `autofocus`, ◉ → `send_screenshot_db`) live in the tab strip. +- **Gonio / Beamline tabs** ← axis IP video via `VideoThread` (`frame_ready`). +- **Preconditions**: data-collection actions are gated by a ring/shutter/hutch + warning (1-hour snooze); mounting is hard-blocked by the hutch PSS; + `sample_missing` shows a modal (suppressed during automation). - **Mount / Unmount** → `mount(SampleShortInfo)` / `unmount()`. - **Motors** → `set_omega_rel`, `move_smargon`, `zoom`, `front_light`, `back_light`, and a staff-only **Energy** control → `change_energy` (keV). diff --git a/src/aare/gui/new_gui/ROADMAP.md b/src/aare/gui/new_gui/ROADMAP.md index 3141f9e8..76fff602 100644 --- a/src/aare/gui/new_gui/ROADMAP.md +++ b/src/aare/gui/new_gui/ROADMAP.md @@ -83,14 +83,15 @@ Start→`automated_scan`). ## Proposed phased plan -**Phase 1 — core operator parity (HIGH value):** D (completed-scan feedback + -screening), C (raster results heatmap+table), E (XRF spectrum), K (live -automation progress) + the pausable protocol queue. Makes day-to-day collection -fully usable. +**Phase 1 — core operator parity (HIGH value):** ✅ DONE. D (completed-scan +feedback + screening), C (raster results heatmap+table), E (XRF spectrum), K +(live automation progress) + the pausable protocol queue. (Deferred: dose/ +temperature readouts in screening.) -**Phase 2 — camera & feedback:** A (ML overlays, beam centre, screenshot, +**Phase 2 — camera & feedback:** ✅ DONE. A (ML overlays, beam centre, screenshot, focus), B (gonio/beamline video), I (alert banner + precondition dialogs + -sample-missing). +sample-missing). Still deferred from A: smargon bookmarks (Ctrl-click), helical +markers. **Phase 3 — staff tooling:** G (beam/detector physics), J (pgroups + sessions), H (recovery + local-contact admin), developer help. diff --git a/src/aare/gui/new_gui/app.py b/src/aare/gui/new_gui/app.py index a59d949d..3acf95d8 100644 --- a/src/aare/gui/new_gui/app.py +++ b/src/aare/gui/new_gui/app.py @@ -57,6 +57,29 @@ def _beamline_defaults(): ) +def _camera_defaults(): + """Resolve (gonio_addr, beamline_addr, gonio_id) for the Gonio/Beamline tabs.""" + match mx_beamline(): + case MXBeamline.X06DA: + return ( + cfg_get("gui.cameras.gonio_camera_url", "axis-accc8ed2972e.psi.ch"), + cfg_get("gui.cameras.beamline_camera_url", "x06da-axis-1.psi.ch"), + int(cfg_get("gui.cameras.gonio_camera_id", 3)), + ) + case MXBeamline.X10SA: + return ( + cfg_get("gui.cameras.gonio_camera_url", "axis-accc8ea5e463.psi.ch"), + cfg_get("gui.cameras.beamline_camera_url", "axis-accc8eb02488.psi.ch"), + int(cfg_get("gui.cameras.gonio_camera_id", 1)), + ) + case _: + return ( + cfg_get("gui.cameras.gonio_camera_url", ""), + cfg_get("gui.cameras.beamline_camera_url", ""), + int(cfg_get("gui.cameras.gonio_camera_id", 1)), + ) + + def _clean(value: str | None) -> str | None: if value is None: return None @@ -114,7 +137,12 @@ def main() -> None: ) sys.exit(1) - win = MainWindow(base_url=base_url, token=token, pred_zmq_addr=pred_zmq) + gonio_addr, beamline_addr, gonio_id = _camera_defaults() + win = MainWindow( + base_url=base_url, token=token, pred_zmq_addr=pred_zmq, + gonio_cam_addr=_clean(gonio_addr), beamline_cam_addr=_clean(beamline_addr), + gonio_cam_id=gonio_id, + ) win.show() sys.exit(app.exec()) diff --git a/src/aare/gui/new_gui/main_window.py b/src/aare/gui/new_gui/main_window.py index 472008f7..f92e84ab 100644 --- a/src/aare/gui/new_gui/main_window.py +++ b/src/aare/gui/new_gui/main_window.py @@ -10,6 +10,7 @@ from __future__ import annotations from PySide6.QtCore import QTimer, Slot from PySide6.QtWidgets import ( + QMessageBox, QStackedWidget, QVBoxLayout, QWidget, @@ -24,6 +25,7 @@ from aare.gui.new_gui.status_bar import StatusBar from aare.gui.new_gui.theme import LIGHT, build_qss from aare.gui.new_gui.top_bar import TopBar from aare.gui.new_gui.widgets.alert_banner import AlertBanner +from aare.gui.new_gui.widgets import preconditions logger = setup_logger("aareGUI.new") @@ -49,10 +51,15 @@ def _collect_defaults() -> dict: class MainWindow(QWidget): - def __init__(self, base_url, token, pred_zmq_addr=None, parent=None): + def __init__(self, base_url, token, pred_zmq_addr=None, + gonio_cam_addr=None, beamline_cam_addr=None, gonio_cam_id=1, + parent=None): super().__init__(parent) self._base_url = base_url self._token = token + self._gonio_cam_addr = gonio_cam_addr + self._beamline_cam_addr = beamline_cam_addr + self._gonio_cam_id = gonio_cam_id self._palette = LIGHT self._defaults = _collect_defaults() @@ -123,6 +130,28 @@ class MainWindow(QWidget): logger.warning("Could not start camera stream: %s", exc) self.camera_thread = None + # axis IP video for Gonio / Beamline tabs + self.gonio_thread = self._start_video( + self._gonio_cam_addr, self.manual.gonio_view, camera=self._gonio_cam_id) + self.beamline_thread = self._start_video( + self._beamline_cam_addr, self.manual.beamline_view, camera=1) + + def _start_video(self, addr, view, camera=1): + if not addr: + return None + try: + from aare.gui.threads.axis_video_thread import VideoThread + + thread = VideoThread(ip=addr, camera=camera) + thread.frame_ready.connect(view.update_frame) + if hasattr(thread, "error_occurred"): + thread.error_occurred.connect(view.set_error) + thread.start() + return thread + except Exception as exc: # pragma: no cover - hardware/runtime path + logger.warning("Could not start video (%s): %s", addr, exc) + return None + # -------------------------------------------------------------- wiring def _wire(self) -> None: s = self.state @@ -159,13 +188,22 @@ class MainWindow(QWidget): # manual intents -> DAQ self.manual.center_loop_requested.connect(self.daq.center_loop) self.manual.center_at_point_requested.connect(self._on_center_at_point) - # Non-auto raster: smargon_top_left is computed from the drawn grid. - self.manual.raster_scan_requested.connect(self.daq.raster_scan) - self.manual.rotation_scan_requested.connect(self.daq.standard_scan) + # Scans are precondition-gated (ring/shutter/hutch) at the boundary. + self.manual.raster_scan_requested.connect(self._do_raster) + self.manual.rotation_scan_requested.connect(self._do_rotation) self.manual.smart_params_requested.connect(self._on_smart_params) - self.manual.xrf_scan_requested.connect(self.daq.fluorimeter_spectrum) + self.manual.xrf_scan_requested.connect(self._do_xrf) self.manual.raster_goto_requested.connect(self.daq.move_smargon) self.manual.status_message.connect(self._note) + + # camera controls + cc = self.manual.camera_controls + if hasattr(self.daq, "samcam_settings"): + cc.samcam_changed.connect(self.daq.samcam_settings) + if hasattr(self.daq, "autofocus"): + cc.autofocus_requested.connect(self.daq.autofocus) + if hasattr(self.daq, "send_screenshot_db"): + cc.screenshot_requested.connect(self.daq.send_screenshot_db) if hasattr(self.daq, "raster_scan_completed"): self.daq.raster_scan_completed.connect(self.manual.on_raster_completed) @@ -210,7 +248,7 @@ class MainWindow(QWidget): if hasattr(self.daq, "auth_error"): self.daq.auth_error.connect(self._on_auth_error) if hasattr(self.daq, "sample_missing"): - self.daq.sample_missing.connect(lambda msg: self._note(msg, error=True)) + self.daq.sample_missing.connect(self._on_sample_missing) if hasattr(self.daq, "polled_devices_status"): self.daq.polled_devices_status.connect(self._on_device_status) if hasattr(self.daq, "detector_error"): @@ -239,6 +277,12 @@ class MainWindow(QWidget): self.camera_thread.fps_measure.connect(cam.update_fps) if hasattr(self.camera_thread, "target_point"): self.camera_thread.target_point.connect(cam.update_target_point) + if hasattr(self.camera_thread, "prediction"): + self.camera_thread.prediction.connect(cam.update_predictions) + if hasattr(self.camera_thread, "focus_measure"): + self.camera_thread.focus_measure.connect(cam.update_focus) + if hasattr(self.camera_thread, "enable_focus_measurement"): + self.camera_thread.enable_focus_measurement(True) if hasattr(self.camera_thread, "update_daq_status"): self.daq.update.connect(self.camera_thread.update_daq_status) @@ -260,10 +304,47 @@ class MainWindow(QWidget): self.manual.changer.set_samples(samples) self.automation.set_samples(samples) + # ---- precondition-gated actions ---- + def _precondition_ok(self) -> bool: + bl = getattr(self.manual.last_status, "bl", None) + if bl is None: + return True + shutter = getattr(bl, "exp_shutter_open", None) + if shutter is None: + shutter = getattr(bl, "shutter_open", None) + return preconditions.precondition_check( + self, + ring_current=getattr(bl, "ring_current_mA", None), + shutter_open=shutter, + door_prohibited=getattr(bl, "pss_prohibited", None), + ) + + def _do_raster(self, req) -> None: + if self._precondition_ok(): + self.daq.raster_scan(req) + + def _do_rotation(self, req) -> None: + if self._precondition_ok(): + self.daq.standard_scan(req) + + def _do_xrf(self, req) -> None: + if self._precondition_ok(): + self.daq.fluorimeter_spectrum(req) + @Slot(object, bool) def _on_mount_requested(self, sample, reference: bool = False) -> None: + reason = preconditions.hutch_blocks_mount(self.manual.last_status) + if reason is not None: + QMessageBox.critical(self, "Mounting blocked", reason) + self.alert.show_message(reason, "error") + return self.daq.mount(sample, reference) + def _on_sample_missing(self, msg: str) -> None: + self.alert.show_message(msg, "error") + if not self._auto_running: # don't interrupt automation with a modal + QMessageBox.warning(self, "No sample", msg) + @Slot(object) def _on_reference_tools(self, payload) -> None: tools = getattr(payload, "s", payload) @@ -449,6 +530,8 @@ class MainWindow(QWidget): """Automate-parameters (simple strategy): forward seeds, then collect.""" from aare.gui.new_gui import requests + if not self._precondition_ok(): + return if hasattr(self.daq, "smart_params"): self.daq.smart_params(sp) self.daq.standard_scan(requests.rotation_from_simple(sp)) @@ -553,9 +636,12 @@ class MainWindow(QWidget): self.daq.cleanup() except Exception: pass - try: - if self.camera_thread is not None and hasattr(self.camera_thread, "stop"): - self.camera_thread.stop() - except Exception: - pass + for thread in (getattr(self, "camera_thread", None), + getattr(self, "gonio_thread", None), + getattr(self, "beamline_thread", None)): + try: + if thread is not None and hasattr(thread, "stop"): + thread.stop() + except Exception: + pass super().closeEvent(event) diff --git a/src/aare/gui/new_gui/manual_view.py b/src/aare/gui/new_gui/manual_view.py index e863ee79..42dd09ae 100644 --- a/src/aare/gui/new_gui/manual_view.py +++ b/src/aare/gui/new_gui/manual_view.py @@ -16,12 +16,14 @@ from aare.gui.new_gui import requests from aare.gui.new_gui.state import AppState from aare.gui.new_gui.theme import CAMERA_TABS_H, Palette from aare.gui.new_gui.widgets.camera import CameraViewport +from aare.gui.new_gui.widgets.camera_controls import CameraControls from aare.gui.new_gui.widgets.motors_panel import MotorsPanel from aare.gui.new_gui.widgets.pipeline_panel import PipelinePanel from aare.gui.new_gui.widgets.heatmap import build_heatmap_qimage, metric_value from aare.gui.new_gui.widgets.raster_grids_dialog import RasterGridsDialog from aare.gui.new_gui.widgets.sample_changer import SampleChangerPanel, sample_id from aare.gui.new_gui.widgets.spectrum_view import SpectrumView +from aare.gui.new_gui.widgets.video_view import VideoView class ManualView(QWidget): @@ -62,9 +64,13 @@ class ManualView(QWidget): cl.setSpacing(0) self.camera = CameraViewport(palette) self.spectrum = SpectrumView(palette) + self.gonio_view = VideoView(palette, "Gonio") + self.beamline_view = VideoView(palette, "Beamline") self.center_stack = QStackedWidget() - self.center_stack.addWidget(self.camera) # index 0 - self.center_stack.addWidget(self.spectrum) # index 1 + self.center_stack.addWidget(self.camera) # index 0 + self.center_stack.addWidget(self.spectrum) # index 1 + self.center_stack.addWidget(self.gonio_view) # index 2 + self.center_stack.addWidget(self.beamline_view) # index 3 cl.addWidget(self._build_camera_tabs()) cl.addWidget(self.center_stack, 1) @@ -95,7 +101,7 @@ class ManualView(QWidget): self._tabs: dict[str, QPushButton] = {} # (label, center-stack index) — Gonio/Beamline still show the camera (Phase 2) for i, (name, idx) in enumerate( - (("Sample camera", 0), ("Gonio", 0), ("Beamline", 0), ("XRF spectrum", 1)) + (("Sample camera", 0), ("Gonio", 2), ("Beamline", 3), ("XRF spectrum", 1)) ): tab = QPushButton(name) tab.setObjectName("CamTab") @@ -108,6 +114,8 @@ class ManualView(QWidget): self._tabs[name] = tab lay.addWidget(tab) lay.addStretch(1) + self.camera_controls = CameraControls(self._p) + lay.addWidget(self.camera_controls) return bar def show_spectrum(self) -> None: @@ -337,3 +345,10 @@ class ManualView(QWidget): bl = getattr(s, "bl", None) if bl is not None and getattr(bl, "zoom", None) is not None: self.camera.set_zoom(bl.zoom) + if bl is not None: + self.camera.set_beam_state( + getattr(bl, "shutter_open", False), getattr(s, "busy", False)) + geom = getattr(s, "geom", None) + beam = getattr(geom, "beam_location_pxl", None) if geom else None + if beam is not None: + self.camera.set_beam_center(beam.x, beam.y) diff --git a/src/aare/gui/new_gui/widgets/camera.py b/src/aare/gui/new_gui/widgets/camera.py index 13fc5aed..d9147ecd 100644 --- a/src/aare/gui/new_gui/widgets/camera.py +++ b/src/aare/gui/new_gui/widgets/camera.py @@ -28,6 +28,12 @@ from PySide6.QtWidgets import ( from aare.gui.new_gui.theme import FONT_MONO_FALLBACK, Palette +# Per-class detection colours (match the existing GUI). +_DET_COLORS = { + "pin": "#ff5555", "loop_all": "#3fb950", "loop_face": "#d29922", + "crystal": "#4d8bff", "needle": "#d24bff", "ice": "#33d6e0", +} + class CameraViewport(QWidget): """Camera image + overlays. Modes: 'empty' | 'picking' | 'mounted'.""" @@ -49,6 +55,13 @@ class CameraViewport(QWidget): self._zoom: float = 2.0 self._mode = "empty" self._target_point: QPoint | None = None # image-pixel coords + self._beam_px: QPoint | None = None # beam centre, image-pixel coords + self._beam_shutter = False + self._beam_busy = False + self._focus: float | None = None + self._detections: list = [] # raw boxes from prediction + self._det_shape = None # [h, w] of the inference image + self._show_detections = True # Raster grid-draw state self._grid_draw_enabled = False @@ -233,6 +246,30 @@ class CameraViewport(QWidget): self._target_point = None self.update() + def set_beam_center(self, x: float, y: float) -> None: + self._beam_px = QPoint(int(x), int(y)) + self.update() + + def set_beam_state(self, shutter_open: bool, busy: bool) -> None: + self._beam_shutter = bool(shutter_open) + self._beam_busy = bool(busy) + self.update() + + def update_focus(self, value: float) -> None: + self._focus = value + self.update() + + def update_predictions(self, payload: dict) -> None: + if not isinstance(payload, dict): + return + self._detections = payload.get("boxes", []) or [] + self._det_shape = payload.get("shape") + self.update() + + def set_detections_visible(self, visible: bool) -> None: + self._show_detections = visible + self.update() + # -------------------------------------------------- coordinate mapping def _widget_to_image(self, pt: QPoint) -> QPoint: if self._draw_rect.width() <= 0 or self._draw_rect.height() <= 0: @@ -349,15 +386,82 @@ class CameraViewport(QWidget): if self._mode == "mounted": self._paint_mounted_overlays(painter, r) + if self._show_detections and self._mode == "mounted": + self._paint_detections(painter) + self._paint_grid(painter) + def _paint_detections(self, painter: QPainter) -> None: + if not self._detections or self._pixmap is None or self._pixmap.isNull(): + return + # Map detection-image coords -> our image coords (handles differing inference res). + det_w = det_h = None + if self._det_shape and len(self._det_shape) >= 2: + det_h, det_w = float(self._det_shape[0]), float(self._det_shape[1]) + sx = self._img_w / det_w if det_w else 1.0 + sy = self._img_h / det_h if det_h else 1.0 + f = QFont() + f.setPixelSize(10) + painter.setFont(f) + for d in self._detections: + try: + x1, y1 = float(d["x1"]) * sx, float(d["y1"]) * sy + x2, y2 = float(d["x2"]) * sx, float(d["y2"]) * sy + except (KeyError, TypeError, ValueError): + continue + label = str(d.get("label", "")) + color = QColor(_DET_COLORS.get(label, "#cccccc")) + tl = self._image_to_widget(QPoint(int(x1), int(y1))) + br = self._image_to_widget(QPoint(int(x2), int(y2))) + rect = QRect(tl, br).normalized() + painter.setBrush(Qt.NoBrush) + painter.setPen(QPen(color, 2)) + painter.drawRect(rect) + # polygon (points are relative to the box origin in detection coords) + poly = d.get("poly") + if poly: + from PySide6.QtGui import QPolygon + pts = [] + for px, py in poly: + ix = int((float(px) + float(d["x1"])) * sx) + iy = int((float(py) + float(d["y1"])) * sy) + pts.append(self._image_to_widget(QPoint(ix, iy))) + if len(pts) >= 3: + faint = QColor(color) + faint.setAlpha(150) + painter.setPen(QPen(faint, 1)) + painter.drawPolygon(QPolygon(pts)) + conf = d.get("conf") + tag = f"{label} {conf:.2f}" if isinstance(conf, (int, float)) else label + painter.setPen(color) + painter.drawText(rect.left(), rect.top() - 3, tag) + def _paint_crosshair(self, painter: QPainter, r: QRect) -> None: - pen = QPen(QColor(255, 255, 255, 46)) - pen.setWidth(1) - painter.setPen(pen) - cx, cy = r.width() // 2, r.height() // 2 - painter.drawLine(cx, cy - 21, cx, cy + 21) - painter.drawLine(cx - 21, cy, cx + 21, cy) + # Beam-centre crosshair at the real beam pixel when known, else viewport centre. + if self._beam_px is not None and self._pixmap is not None \ + and not self._pixmap.isNull(): + c = self._image_to_widget(self._beam_px) + # colour by state: shutter open = green, busy = red, idle = orange + if self._beam_busy: + col = QColor(255, 0, 0) + elif self._beam_shutter: + col = QColor(0, 200, 0) + else: + col = QColor(245, 121, 0) + pen = QPen(col) + pen.setWidth(2) + painter.setPen(pen) + painter.drawLine(c.x(), c.y() - 14, c.x(), c.y() + 14) + painter.drawLine(c.x() - 14, c.y(), c.x() + 14, c.y()) + painter.setBrush(Qt.NoBrush) + painter.drawEllipse(c, 7, 7) + else: + pen = QPen(QColor(255, 255, 255, 46)) + pen.setWidth(1) + painter.setPen(pen) + cx, cy = r.width() // 2, r.height() // 2 + painter.drawLine(cx, cy - 21, cx, cy + 21) + painter.drawLine(cx - 21, cy, cx + 21, cy) def _paint_overlay_text(self, painter: QPainter, r: QRect) -> None: painter.setPen(QColor("#867f96")) @@ -366,6 +470,8 @@ class CameraViewport(QWidget): painter.setFont(f) fps = "-" if self._fps != self._fps else f"{self._fps:.0f}" # NaN-safe text = f"SAMCAM · {fps} fps · {self._zoom:.1f}×" + if self._focus is not None and self._focus == self._focus: # NaN-safe + text += f" · focus {self._focus:.0f}" painter.drawText(QRect(0, 8, r.width() - 14, 16), Qt.AlignRight | Qt.AlignTop, text) diff --git a/src/aare/gui/new_gui/widgets/camera_controls.py b/src/aare/gui/new_gui/widgets/camera_controls.py new file mode 100644 index 00000000..d037db14 --- /dev/null +++ b/src/aare/gui/new_gui/widgets/camera_controls.py @@ -0,0 +1,95 @@ +"""Compact sample-camera controls: settings (gain/exposure), autofocus, screenshot.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QDialog, + QDialogButtonBox, + QDoubleSpinBox, + QFormLayout, + QHBoxLayout, + QLineEdit, + QPushButton, + QWidget, +) + +from aare.common.models import AutofocusSettings, SampleCameraSettings +from aare.gui.new_gui.theme import Palette + + +class CameraControls(QWidget): + """Lives in the camera tab strip. Emits backend-ready models.""" + + samcam_changed = Signal(object) # SampleCameraSettings + autofocus_requested = Signal(object) # AutofocusSettings + screenshot_requested = Signal(str, str) # filename, message + + def __init__(self, palette: Palette, parent=None): + super().__init__(parent) + self._p = palette + lay = QHBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(6) + for text, tip, slot in ( + ("⚙", "Camera settings (gain / exposure)", self._open_settings), + ("AF", "Autofocus", self._on_autofocus), + ("◉", "Screenshot to database", self._open_screenshot), + ): + b = QPushButton(text) + b.setCursor(Qt.PointingHandCursor) + b.setToolTip(tip) + b.setFixedHeight(26) + b.setStyleSheet( + f"QPushButton {{ background:{palette.surface};" + f" border:1px solid {palette.border_control}; border-radius:6px;" + f" padding:3px 10px; font-size:12px; color:{palette.text_secondary}; }}" + ) + b.clicked.connect(slot) + lay.addWidget(b) + + def _open_settings(self) -> None: + dlg = QDialog(self) + dlg.setWindowTitle("Camera settings") + form = QFormLayout(dlg) + gain = QDoubleSpinBox() + gain.setRange(0, 1000) + gain.setValue(1.0) + exposure = QDoubleSpinBox() + exposure.setRange(0.0, 1.0) + exposure.setDecimals(4) + exposure.setSingleStep(0.001) + exposure.setValue(0.02) + form.addRow("Gain", gain) + form.addRow("Exposure (s)", exposure) + bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + form.addRow(bb) + bb.accepted.connect(dlg.accept) + bb.rejected.connect(dlg.reject) + if dlg.exec() == QDialog.Accepted: + self.samcam_changed.emit( + SampleCameraSettings(gain=gain.value(), exposure=exposure.value()) + ) + + def _on_autofocus(self) -> None: + self.autofocus_requested.emit( + AutofocusSettings(center_x_pxl=None, center_y_pxl=None, + radius_pxl=30, z_range_um=2000, z_steps=10) + ) + + def _open_screenshot(self) -> None: + dlg = QDialog(self) + dlg.setWindowTitle("Screenshot to database") + form = QFormLayout(dlg) + fn = QLineEdit() + fn.setPlaceholderText("optional") + msg = QLineEdit() + msg.setPlaceholderText("optional") + form.addRow("Filename", fn) + form.addRow("Message", msg) + bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + form.addRow(bb) + bb.accepted.connect(dlg.accept) + bb.rejected.connect(dlg.reject) + if dlg.exec() == QDialog.Accepted: + self.screenshot_requested.emit(fn.text(), msg.text()) diff --git a/src/aare/gui/new_gui/widgets/preconditions.py b/src/aare/gui/new_gui/widgets/preconditions.py new file mode 100644 index 00000000..76213217 --- /dev/null +++ b/src/aare/gui/new_gui/widgets/preconditions.py @@ -0,0 +1,73 @@ +"""Pre-action safety checks: beamline preconditions + hutch-PSS mount block. + +Mirrors the existing GUI's message_box: before a data-collection action we warn +if the ring current is low, the experiment shutter is closed, or the hutch isn't +in the prohibited (searched/closed) state — with a 1-hour snooze. Mounting is +hard-blocked by the hutch PSS. Only *explicitly* bad values raise a warning, so +missing/unknown fields (e.g. SIMULATED) don't nag. +""" + +from __future__ import annotations + +import time + +from PySide6.QtWidgets import QCheckBox, QMessageBox + +LOW_CURRENT_THRESHOLD = 100.0 # mA +SNOOZE_SECONDS = 3600.0 + +_snooze_until = 0.0 + + +def _snoozed() -> bool: + return time.time() < _snooze_until + + +def _snooze() -> None: + global _snooze_until + _snooze_until = time.time() + SNOOZE_SECONDS + + +def precondition_problems(ring_current, shutter_open, door_prohibited) -> list[str]: + problems: list[str] = [] + if ring_current is not None and ring_current < LOW_CURRENT_THRESHOLD: + problems.append(f"Ring current is low: {round(ring_current, 2)} mA.") + if shutter_open is False: + problems.append("Experiment safety shutter is closed.") + if door_prohibited is False: + problems.append("Hutch is not searched/closed (door safety not active).") + return problems + + +def precondition_check(parent, *, ring_current, shutter_open, door_prohibited) -> bool: + """Return True to proceed. Shows a Yes/No warning if there are problems.""" + if _snoozed(): + return True + problems = precondition_problems(ring_current, shutter_open, door_prohibited) + if not problems: + return True + box = QMessageBox(parent) + box.setIcon(QMessageBox.Icon.Warning) + box.setWindowTitle("Beamline not ready") + box.setText("\n".join(problems) + "\n\nDo you wish to continue?") + box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + box.setDefaultButton(QMessageBox.StandardButton.No) + snooze_cb = QCheckBox("Don't ask again for 1 hour") + box.setCheckBox(snooze_cb) + proceed = box.exec() == QMessageBox.StandardButton.Yes + if proceed and snooze_cb.isChecked(): + _snooze() + return proceed + + +def hutch_blocks_mount(status) -> str | None: + """Reason the hutch PSS blocks a mount, or None if OK.""" + bl = getattr(status, "bl", None) + if bl is None: + return None + if getattr(bl, "pss_prohibited", None) is False: + return ("Door safety is not active: close the hutch doors and complete " + "the safety search before mounting.") + if getattr(bl, "pss_alarm", False): + return "The hutch safety alarm is active. Mounting is blocked until it clears." + return None diff --git a/src/aare/gui/new_gui/widgets/video_view.py b/src/aare/gui/new_gui/widgets/video_view.py new file mode 100644 index 00000000..0a8d946a --- /dev/null +++ b/src/aare/gui/new_gui/widgets/video_view.py @@ -0,0 +1,47 @@ +"""A simple axis-camera video view: paints the latest QImage, aspect-fit.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt, Slot +from PySide6.QtGui import QColor, QImage, QPainter, QPixmap +from PySide6.QtWidgets import QWidget + +from aare.gui.new_gui.theme import Palette + + +class VideoView(QWidget): + """Displays frames from a VideoThread (frame_ready: QImage).""" + + def __init__(self, palette: Palette, label: str = "Camera", parent=None): + super().__init__(parent) + self._p = palette + self._label = label + self._pixmap: QPixmap | None = None + self._error: str | None = None + self.setMinimumHeight(220) + + @Slot(QImage) + def update_frame(self, image: QImage) -> None: + if image is not None and not image.isNull(): + self._pixmap = QPixmap.fromImage(image) + self._error = None + self.update() + + @Slot(str) + def set_error(self, message: str) -> None: + self._error = message + self.update() + + def paintEvent(self, event): # noqa: N802 + painter = QPainter(self) + r = self.rect() + painter.fillRect(r, QColor(self._p.cam_stop2)) + if self._pixmap is not None and not self._pixmap.isNull(): + scaled = self._pixmap.scaled( + r.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation) + painter.drawPixmap((r.width() - scaled.width()) // 2, + (r.height() - scaled.height()) // 2, scaled) + else: + painter.setPen(QColor("#867f96")) + painter.drawText(r, Qt.AlignCenter, + self._error or f"{self._label} — no feed")