new_gui Phase 2: camera ML overlays, controls/screenshot, gonio/beamline video, preconditions

- camera: ML detection boxes+polygons (per-class colours), state-coloured beam
  centre marker, focus readout
- camera controls in tab strip: gain/exposure (samcam_settings), autofocus,
  screenshot-to-DB
- Gonio/Beamline tabs wired to axis IP video via VideoThread
- precondition gate (ring/shutter/hutch + 1h snooze) on scans; hutch-PSS mount
  block; sample-missing modal

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
appleb_m
2026-06-24 16:51:54 +02:00
co-authored by Claude Opus 4.8
parent b288c23d1f
commit 5b833bf9eb
9 changed files with 486 additions and 27 deletions
+8
View File
@@ -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).
+7 -6
View File
@@ -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.
+29 -1
View File
@@ -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())
+97 -11
View File
@@ -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)
+18 -3
View File
@@ -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)
+112 -6
View File
@@ -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)
@@ -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())
@@ -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
@@ -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")