new_gui: staff view + motor/camera UX edits + prediction-metrics

- Staff alignment view (3rd mode, staff only): beam location / beamstop /
  align CoR / flux measurement procedures; camera+spectrum + alignment motors
- Motors: compact SMARGON with editable Chi/Phi + Focus(Z) jog; staff-only ABR
  (GMX/GMY/GMZ) moved under SMARGON; energy removed (now staff-tools only)
- Inline camera Exp/Gain by the sample camera
- Prediction-metrics dialog from the prediction stream
- shared _wire_motors; guest overlay also covers the staff camera

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
appleb_m
2026-06-24 22:09:38 +02:00
co-authored by Claude Opus 4.8
parent 7c22b24437
commit 49dfb40eff
9 changed files with 581 additions and 176 deletions
+11
View File
@@ -35,6 +35,17 @@ Backend contract is documented in the `new-gui-backend-paths` memory.
## What is wired to the backend
- **Staff view** (3rd mode toggle, staff only) — alignment cockpit with procedures
Beam location / Beamstop alignment / Align centre of rotation / Flux measurement
(switch the beamline state where one exists; CoR uses Sample alignment + omega +
smargon + ABR). Centre = camera (beam-centre marker) / XRF spectrum; right =
full motors (omega/smargon/ABR/zoom/light).
- **Motors panel**: compact SMARGON with **editable Chi/Phi** + **Focus (Z)** jog
(new — smargon-z move), staff-only **ABR** (GMX/GMY/GMZ + save/goto) under
SMARGON. Energy lives in the staff Tools dialog (not the motors panel).
- **Camera exp/gain** are inline in the camera tab strip (Exp/Gain → `samcam_settings`).
- **Prediction metrics** dialog (per-class counts, confidence, FPS) from the
prediction stream (launched from the camera strip). (smargon-trace still deferred.)
- **Polish (Phase 4)**: keyboard shortcuts (Ctrl+1/2 mode, Alt+1-4 camera tabs,
Ctrl+T tools, F1 help, Ctrl+Q quit); window-geometry + mode persistence
(QSettings); a **filename builder** (prefix + auto-incrementing run number) that
+84 -23
View File
@@ -97,8 +97,11 @@ class MainWindow(QWidget):
self.body = QStackedWidget()
self.manual = ManualView(self.state, self._palette, self._defaults)
self.automation = AutomationView(self.state, self._palette)
from aare.gui.new_gui.staff_view import StaffView
self.staff = StaffView(self._palette)
self.body.addWidget(self.manual)
self.body.addWidget(self.automation)
self.body.addWidget(self.staff)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
@@ -213,6 +216,7 @@ class MainWindow(QWidget):
cc.screenshot_requested.connect(self.daq.send_screenshot_db)
cc.face_detect_clicked.connect(self._open_face_detection)
cc.stability_clicked.connect(self._open_stability)
cc.metrics_clicked.connect(self._open_metrics)
if hasattr(self.daq, "raster_scan_completed"):
self.daq.raster_scan_completed.connect(self.manual.on_raster_completed)
@@ -231,12 +235,24 @@ class MainWindow(QWidget):
s.unmount_requested.connect(self.daq.unmount)
# motors -> DAQ
self.manual.motors.omega_rel.connect(self.daq.set_omega_rel)
self.manual.motors.zoom_changed.connect(self.daq.zoom)
self.manual.motors.front_light_changed.connect(self.daq.front_light)
self.manual.motors.back_light_changed.connect(self.daq.back_light)
self.manual.motors.smargon_jog.connect(self._on_smargon_jog)
self.manual.motors.energy_changed.connect(self.daq.change_energy)
self._wire_motors(self.manual.motors)
# staff alignment view
self.daq.update.connect(self.staff.update_daq_status)
self.staff.state_requested.connect(self._on_state_change)
if hasattr(self.daq, "beam_center"):
self.staff.beam_center_changed.connect(self.daq.beam_center)
if hasattr(self.daq, "beam_mark_clear"):
self.staff.beam_mark_clear.connect(self.daq.beam_mark_clear)
self._wire_motors(self.staff.motors)
sp2 = self.staff.spectrum
sp2.start_clicked.connect(self.daq.fluorimeter_start)
sp2.stop_clicked.connect(self.daq.fluorimeter_stop)
sp2.snapshot_clicked.connect(self.daq.fluorimeter_request_snapshot)
if hasattr(self.daq, "fluorimeter_spectrum_update"):
self.daq.fluorimeter_spectrum_update.connect(sp2.update_spectrum)
if hasattr(self.daq, "fluorimeter_update"):
self.daq.fluorimeter_update.connect(sp2.update_live)
# automation
self.automation.start_requested.connect(self._start_automation)
@@ -294,13 +310,34 @@ class MainWindow(QWidget):
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)
# staff-view camera mirrors the same stream
scam = self.staff.camera
self.camera_thread.image.connect(scam.update_pixmap)
if hasattr(self.camera_thread, "fps_measure"):
self.camera_thread.fps_measure.connect(scam.update_fps)
if hasattr(self.camera_thread, "target_point"):
self.camera_thread.target_point.connect(scam.update_target_point)
# ------------------------------------------------------------ handlers
def _wire_motors(self, m) -> None:
m.omega_rel.connect(self.daq.set_omega_rel)
m.zoom_changed.connect(self.daq.zoom)
m.front_light_changed.connect(self.daq.front_light)
m.back_light_changed.connect(self.daq.back_light)
m.smargon_jog.connect(self._on_smargon_jog)
m.smargon_angles.connect(self._on_smargon_angles)
if hasattr(self.daq, "abr_tweak"):
m.abr_tweak.connect(self.daq.abr_tweak)
if hasattr(self.daq, "abr_save"):
m.abr_save.connect(self.daq.abr_save)
if hasattr(self.daq, "abr_goto_meas"):
m.abr_goto.connect(self.daq.abr_goto_meas)
@Slot(str)
def _on_mode_changed(self, mode: str) -> None:
self.body.setCurrentWidget(
self.automation if mode == "automation" else self.manual
)
page = {"automation": self.automation, "staff": self.staff}.get(
mode, self.manual)
self.body.setCurrentWidget(page)
self.top_bar.set_mode(mode)
@Slot(object)
@@ -377,6 +414,18 @@ class MainWindow(QWidget):
self._stability_dialog.show()
self._stability_dialog.raise_()
def _open_metrics(self) -> None:
from aare.gui.new_gui.widgets.prediction_metrics_dialog import (
PredictionMetricsDialog,
)
if getattr(self, "_metrics_dialog", None) is None:
self._metrics_dialog = PredictionMetricsDialog(self._palette, self)
if self.camera_thread is not None and hasattr(self.camera_thread, "prediction"):
self.camera_thread.prediction.connect(
self._metrics_dialog.update_from_prediction)
self._metrics_dialog.show()
self._metrics_dialog.raise_()
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
@@ -417,18 +466,20 @@ class MainWindow(QWidget):
timeout = getattr(req, "timeout_seconds", 30) if req else 30
self._show_incoming_dialog(who, timeout)
def _set_control(self, in_control: bool, title: str = "GUEST MODE",
sub: str = "") -> None:
self.manual.set_control_state(in_control, title, sub)
self.staff.camera.set_control_state(in_control, title, sub)
def _update_control(self) -> None:
"""Grey the camera + show Guest/Vacant overlay unless we hold the baton."""
b = self._baton
if b is None:
self.manual.set_control_state(True)
return
if getattr(b, "you_are_holder", False):
self.manual.set_control_state(True)
if b is None or getattr(b, "you_are_holder", False):
self._set_control(True)
return
holder = getattr(b, "holder", None)
if holder is None:
self.manual.set_control_state(
self._set_control(
False, "VACANT", "Grab the baton to take control of the beamline.")
return
username = getattr(holder, "username", None)
@@ -439,7 +490,7 @@ class MainWindow(QWidget):
sub = "Staff has control of the beamline."
else:
sub = "Another user has control of the beamline."
self.manual.set_control_state(False, "GUEST MODE", sub)
self._set_control(False, "GUEST MODE", sub)
# ---- baton request / response flow ----
@Slot(dict)
@@ -542,14 +593,14 @@ class MainWindow(QWidget):
else:
self._note(f"No DAQ method for state '{key}'.", error=True)
def _on_smargon_jog(self, dx_mm: float, dy_mm: float) -> None:
"""Relative sample-holder jog in beamline (screen) space.
def _on_smargon_jog(self, dx_mm: float, dy_mm: float, dz_mm: float = 0.0) -> None:
"""Relative sample-holder jog in beamline (screen) space (x/y) + focus (z).
The delta is a beamline-frame offset; ``beamline_to_smargon`` rotates it
through the current omega/phi/chi into an absolute smargon sh_mm target,
so the jog stays screen-intuitive at any goniometer angle. Arrow signs:
←/→ = -x/+x, ↑/↓ = -y/+y (camera y points down). Flip in MotorsPanel if
the beamline convention differs.
through the current omega/phi/chi into an absolute smargon sh_mm target.
z is the optical/focus axis. Arrow signs: ←/→ = -x/+x, ↑/↓ = -y/+y
(camera y points down); focus +/- = +z/-z. Flip in MotorsPanel if the
beamline convention differs.
"""
from aare.common.coordinate import Coordinate, SmargonCoordinate
@@ -558,12 +609,22 @@ class MainWindow(QWidget):
self._note("No geometry yet; cannot jog.", error=True)
return
try:
sh = geom.beamline_to_smargon(Coordinate(x=dx_mm, y=dy_mm, z=0.0))
sh = geom.beamline_to_smargon(Coordinate(x=dx_mm, y=dy_mm, z=dz_mm))
except Exception as exc:
self._note(f"Could not compute jog target: {exc}", error=True)
return
self.daq.move_smargon(SmargonCoordinate(sh_mm=sh))
def _on_smargon_angles(self, chi_deg: float, phi_deg: float) -> None:
"""Move to absolute chi/phi, preserving the current sample-holder position."""
from aare.common.coordinate import SmargonCoordinate
geom = getattr(self.manual.last_status, "geom", None)
smg = getattr(geom, "smargon", None) if geom else None
sh = getattr(smg, "sh_mm", None) if smg else None
self.daq.move_smargon(
SmargonCoordinate(sh_mm=sh, phi_deg=phi_deg, chi_deg=chi_deg))
def _on_spectrum(self, model) -> None:
self.manual.spectrum.update_spectrum(model)
self.manual.show_spectrum()
+1
View File
@@ -345,6 +345,7 @@ class ManualView(QWidget):
def update_daq_status(self, s) -> None:
self._last_status = s
self.motors.update_daq_status(s)
self.camera_controls.update_daq_status(s)
bl = getattr(s, "bl", None)
if bl is not None and getattr(bl, "zoom", None) is not None:
self.camera.set_zoom(bl.zoom)
+188
View File
@@ -0,0 +1,188 @@
"""Staff alignment view — a Manual-style cockpit for beamline alignment.
Procedures: Beam location · Beamstop alignment · Align centre of rotation · Flux
measurement. Each switches the relevant beamline state (where one exists) and the
contextual controls. Centre-of-rotation has no backend state — it parks the
beamline in Sample alignment and relies on omega + smargon + ABR (right panel).
"""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QDoubleSpinBox,
QHBoxLayout,
QLabel,
QPushButton,
QStackedWidget,
QVBoxLayout,
QWidget,
)
from aare.gui.new_gui.theme import CHANGER_W, Palette
from aare.gui.new_gui.widgets.camera import CameraViewport
from aare.gui.new_gui.widgets.common import micro_label, section_label
from aare.gui.new_gui.widgets.motors_panel import MotorsPanel
from aare.gui.new_gui.widgets.spectrum_view import SpectrumView
# (key, title, description, DAQWorker state method, centre page index)
PROCEDURES = (
("beam", "Beam location", "Mark the beam on the sample camera / detector.",
"beam_location", 0),
("beamstop", "Beamstop alignment", "Centre the beamstop (use Beamline camera).",
"beamstop_alignment", 0),
("cor", "Align centre of rotation",
"Rotate ω and re-centre with smargon / ABR.", "sample_alignment", 0),
("flux", "Flux measurement", "Measure flux / fluorescence spectrum.",
"flux_measurement", 1),
)
class StaffView(QWidget):
state_requested = Signal(str) # DAQWorker state method name
beam_center_changed = Signal(float, float)
beam_mark_clear = Signal()
def __init__(self, palette: Palette, parent=None):
super().__init__(parent)
self._p = palette
self._last_status = None
root = QHBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
# --- left: procedure selector ---
left = QWidget()
left.setObjectName("SidePanel")
left.setFixedWidth(CHANGER_W)
ll = QVBoxLayout(left)
ll.setContentsMargins(16, 16, 16, 16)
ll.setSpacing(8)
ll.addWidget(section_label("STAFF · ALIGNMENT"))
ll.addSpacing(4)
self._proc_btns: dict[str, QPushButton] = {}
for key, title, desc, _method, _idx in PROCEDURES:
btn = QPushButton(f"{title}\n{desc}")
btn.setCheckable(True)
btn.setCursor(Qt.PointingHandCursor)
btn.setStyleSheet(self._proc_qss(False))
btn.clicked.connect(lambda _=False, k=key: self._select(k))
btn.toggled.connect(lambda on, b=btn: b.setStyleSheet(self._proc_qss(on)))
self._proc_btns[key] = btn
ll.addWidget(btn)
ll.addStretch(1)
root.addWidget(left)
# --- centre: camera / spectrum + contextual controls ---
center = QWidget()
cl = QVBoxLayout(center)
cl.setContentsMargins(0, 0, 0, 0)
cl.setSpacing(0)
self.camera = CameraViewport(palette)
self.camera.set_mode("plain")
self.spectrum = SpectrumView(palette)
self.center_stack = QStackedWidget()
self.center_stack.addWidget(self.camera) # 0
self.center_stack.addWidget(self.spectrum) # 1
cl.addWidget(self.center_stack, 1)
cl.addWidget(self._build_contextual())
root.addWidget(center, 1)
# --- right: alignment motors (omega / smargon / ABR / zoom / light) ---
self.motors = MotorsPanel(palette)
self.motors.set_staff(True) # ABR always available in staff view
root.addWidget(self.motors)
self._select("beam")
# ----- contextual bottom panel -----
def _build_contextual(self) -> QWidget:
host = QWidget()
host.setObjectName("BottomPanel")
hl = QVBoxLayout(host)
hl.setContentsMargins(22, 12, 22, 12)
self.ctx = QStackedWidget()
hl.addWidget(self.ctx)
# beam location
beam = QWidget()
bl = QHBoxLayout(beam)
bl.setContentsMargins(0, 0, 0, 0)
bl.setSpacing(8)
bl.addWidget(micro_label("BEAM CENTRE (px)"))
self._bc_x = QDoubleSpinBox(); self._bc_x.setRange(-4000, 4000)
self._bc_y = QDoubleSpinBox(); self._bc_y.setRange(-4000, 4000)
bl.addWidget(self._bc_x); bl.addWidget(self._bc_y)
setb = QPushButton("Set beam centre"); setb.setCursor(Qt.PointingHandCursor)
setb.clicked.connect(
lambda: self.beam_center_changed.emit(self._bc_x.value(), self._bc_y.value()))
clr = QPushButton("Clear beam marks"); clr.setCursor(Qt.PointingHandCursor)
clr.clicked.connect(self.beam_mark_clear)
bl.addWidget(setb); bl.addWidget(clr); bl.addStretch(1)
self.ctx.addWidget(beam) # 0
self.ctx.addWidget(self._note_panel( # 1
"Switch to the Beamline camera and adjust the beamstop hardware until "
"centred on the beam."))
self.ctx.addWidget(self._note_panel( # 2
"Rotate ω (right panel) and re-centre the sample with the smargon jog "
"and ABR focus so it stays on the rotation axis at all angles."))
# flux — spectrum controls live in the SpectrumView itself
self.ctx.addWidget(self._note_panel( # 3
"Flux / fluorescence spectrum shown above. Use Start / Stop / Snapshot."))
return host
def _note_panel(self, text: str) -> QWidget:
w = QWidget()
lay = QHBoxLayout(w)
lay.setContentsMargins(0, 0, 0, 0)
lbl = QLabel(text)
lbl.setWordWrap(True)
lbl.setStyleSheet(f"font-size:12px; color:{self._p.text_muted};")
lay.addWidget(lbl)
return w
def _proc_qss(self, on: bool) -> str:
p = self._p
if on:
return (f"QPushButton {{ text-align:left; background:{p.accent_tint_bg};"
f" border:1px solid {p.accent_tint_border}; border-radius:9px;"
f" padding:10px 12px; font-size:12.5px; color:{p.text_primary}; }}")
return (f"QPushButton {{ text-align:left; background:{p.surface};"
f" border:1px solid {p.border_control}; border-radius:9px;"
f" padding:10px 12px; font-size:12.5px; color:{p.text_secondary}; }}")
def _select(self, key: str) -> None:
ctx_index = {"beam": 0, "beamstop": 1, "cor": 2, "flux": 3}[key]
for k, btn in self._proc_btns.items():
btn.blockSignals(True)
btn.setChecked(k == key)
btn.setStyleSheet(self._proc_qss(k == key))
btn.blockSignals(False)
for _key, _title, _desc, method, center_idx in PROCEDURES:
if _key == key:
self.center_stack.setCurrentIndex(center_idx)
self.ctx.setCurrentIndex(ctx_index)
self.state_requested.emit(method)
break
# ----- live status -----
def update_daq_status(self, s) -> None:
self._last_status = s
self.motors.update_daq_status(s)
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)
bl = getattr(s, "bl", None)
if bl is not None:
self.camera.set_beam_state(getattr(bl, "shutter_open", False),
getattr(s, "busy", False))
diff = getattr(s, "diffraction", None)
bc = getattr(diff, "beam_center_pxl", None) if diff else None
if bc and len(bc) >= 2 and not self._bc_x.hasFocus() and not self._bc_y.hasFocus():
self._bc_x.blockSignals(True); self._bc_y.blockSignals(True)
self._bc_x.setValue(float(bc[0])); self._bc_y.setValue(float(bc[1]))
self._bc_x.blockSignals(False); self._bc_y.blockSignals(False)
+1 -1
View File
@@ -99,7 +99,7 @@ class AppState(QObject):
return self._mode
def set_mode(self, mode: str) -> None:
if mode not in ("manual", "automation") or mode == self._mode:
if mode not in ("manual", "automation", "staff") or mode == self._mode:
return
self._mode = mode
self.mode_changed.emit(mode)
+7 -1
View File
@@ -28,7 +28,8 @@ class ModeToggle(QWidget):
self._group = QButtonGroup(self)
self._group.setExclusive(True)
self._buttons: dict[str, QPushButton] = {}
for key, label in (("manual", "Manual"), ("automation", "Automation")):
for key, label in (("manual", "Manual"), ("automation", "Automation"),
("staff", "Staff")):
btn = QPushButton(label)
btn.setObjectName("ModeSeg")
btn.setCheckable(True)
@@ -38,11 +39,15 @@ class ModeToggle(QWidget):
lay.addWidget(btn)
self._buttons[key] = btn
self._buttons["manual"].setChecked(True)
self._buttons["staff"].setVisible(False) # staff only
def set_mode(self, mode: str) -> None:
if mode in self._buttons:
self._buttons[mode].setChecked(True)
def set_staff(self, is_staff: bool) -> None:
self._buttons["staff"].setVisible(is_staff)
class TopBar(QWidget):
"""Top chrome strip. ``mode_changed`` is re-exposed from the toggle."""
@@ -143,6 +148,7 @@ class TopBar(QWidget):
def set_staff(self, is_staff: bool) -> None:
self._tools_btn.setVisible(is_staff)
self.toggle.set_staff(is_staff)
if is_staff != self._is_staff:
self._is_staff = is_staff
if self._last_baton is not None:
+53 -23
View File
@@ -26,6 +26,7 @@ class CameraControls(QWidget):
screenshot_requested = Signal(str, str) # filename, message
face_detect_clicked = Signal()
stability_clicked = Signal()
metrics_clicked = Signal()
def __init__(self, palette: Palette, parent=None):
super().__init__(parent)
@@ -33,12 +34,34 @@ class CameraControls(QWidget):
lay = QHBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(6)
# Inline exposure + gain (always visible, by the camera).
lay.addWidget(self._mini_label("Exp"))
self._exposure = QDoubleSpinBox()
self._exposure.setRange(0.0, 1.0)
self._exposure.setDecimals(4)
self._exposure.setSingleStep(0.001)
self._exposure.setValue(0.02)
self._exposure.setSuffix(" s")
self._exposure.setFixedWidth(82)
self._style_spin(self._exposure)
self._exposure.editingFinished.connect(self._emit_samcam)
lay.addWidget(self._exposure)
lay.addWidget(self._mini_label("Gain"))
self._gain = QDoubleSpinBox()
self._gain.setRange(0, 1000)
self._gain.setValue(1.0)
self._gain.setFixedWidth(64)
self._style_spin(self._gain)
self._gain.editingFinished.connect(self._emit_samcam)
lay.addWidget(self._gain)
for text, tip, slot in (
("", "Camera settings (gain / exposure)", self._open_settings),
("AF", "Autofocus", self._on_autofocus),
("", "Screenshot to database", self._open_screenshot),
("⊙ Face", "Face detection", self.face_detect_clicked.emit),
("∿ Stability", "Target stability", self.stability_clicked.emit),
("◧ Metrics", "Prediction metrics", self.metrics_clicked.emit),
):
b = QPushButton(text)
b.setCursor(Qt.PointingHandCursor)
@@ -52,28 +75,35 @@ class CameraControls(QWidget):
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 _mini_label(self, text: str):
from PySide6.QtWidgets import QLabel
lbl = QLabel(text)
lbl.setStyleSheet(f"font-size:11px; color:{self._p.text_faint};")
return lbl
def _style_spin(self, spin: QDoubleSpinBox) -> None:
spin.setStyleSheet(
f"QDoubleSpinBox {{ background:{self._p.surface};"
f" border:1px solid {self._p.border_control}; border-radius:6px;"
f" padding:2px 4px; font-size:12px; }}"
)
def _emit_samcam(self) -> None:
self.samcam_changed.emit(
SampleCameraSettings(gain=self._gain.value(),
exposure=self._exposure.value()))
def update_daq_status(self, s) -> None:
bl = getattr(s, "bl", None)
cam = getattr(bl, "sample_camera", None) if bl else None
if cam is None:
return
for spin, attr in ((self._exposure, "exposure"), (self._gain, "gain")):
v = getattr(cam, attr, None)
if v is not None and not spin.hasFocus():
spin.blockSignals(True)
spin.setValue(float(v))
spin.blockSignals(False)
def _on_autofocus(self) -> None:
self.autofocus_requested.emit(
+157 -128
View File
@@ -1,7 +1,8 @@
"""Motors panel (Manual mode, right column): OMEGA, SMARGON, ZOOM, LIGHT.
"""Motors panel (right column): OMEGA, SMARGON (+ABR for staff), ZOOM, LIGHT.
Readouts update from DAQStatusModel; controls emit intent signals the main
window wires to DAQWorker (set_omega_rel, move_smargon, zoom, front/back light).
window wires to DAQWorker (set_omega_rel, move_smargon, zoom, front/back light,
abr_tweak/save/goto). Energy lives in the staff Tools dialog, not here.
"""
from __future__ import annotations
@@ -14,11 +15,13 @@ from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QPushButton,
QScrollArea,
QSlider,
QVBoxLayout,
QWidget,
)
from aare.common.coordinate import AerotechCoordinate, Coordinate
from aare.gui.new_gui.theme import FONT_MONO_FALLBACK, MOTORS_W, Palette
from aare.gui.new_gui.widgets.common import hline, section_label
@@ -28,12 +31,15 @@ JOG_STEPS_UM = (10, 50, 100, 500)
class MotorsPanel(QWidget):
omega_rel = Signal(float) # degrees, relative
smargon_jog = Signal(float, float) # dx_mm, dy_mm
omega_rel = Signal(float) # degrees, relative
smargon_jog = Signal(float, float, float) # dx_mm, dy_mm, dz_mm (beamline frame)
smargon_angles = Signal(float, float) # absolute chi_deg, phi_deg
zoom_changed = Signal(float)
front_light_changed = Signal(int)
back_light_changed = Signal(int)
energy_changed = Signal(float) # keV (staff only)
abr_tweak = Signal(object) # AerotechCoordinate (staff)
abr_save = Signal()
abr_goto = Signal()
def __init__(self, palette: Palette, parent=None):
super().__init__(parent)
@@ -42,7 +48,6 @@ class MotorsPanel(QWidget):
self._p = palette
self._jog_idx = 2 # default 100 µm
from PySide6.QtWidgets import QScrollArea
scroll = QScrollArea(self)
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
@@ -58,19 +63,18 @@ class MotorsPanel(QWidget):
# ---- OMEGA ----
lay.addWidget(section_label("OMEGA"))
lay.addSpacing(10)
lay.addSpacing(8)
row = QHBoxLayout()
self._omega_val = QLabel("0.00°")
self._omega_val.setStyleSheet(
f"font-family:{FONT_MONO_FALLBACK}; font-size:22px; font-weight:600;"
)
f"font-family:{FONT_MONO_FALLBACK}; font-size:22px; font-weight:600;")
row.addWidget(self._omega_val)
row.addStretch(1)
wsym = QLabel("ω")
wsym.setStyleSheet(f"font-size:11px; color:{palette.text_faint};")
row.addWidget(wsym)
lay.addLayout(row)
lay.addSpacing(10)
lay.addSpacing(8)
grid = QGridLayout()
grid.setSpacing(6)
for i, step in enumerate(OMEGA_STEPS):
@@ -80,58 +84,109 @@ class MotorsPanel(QWidget):
grid.addWidget(btn, i // 3, i % 3)
lay.addLayout(grid)
lay.addSpacing(16)
lay.addSpacing(14)
lay.addWidget(hline(palette))
lay.addSpacing(16)
lay.addSpacing(14)
# ---- SMARGON ----
# ---- SMARGON (compact) ----
lay.addWidget(section_label("SMARGON"))
lay.addSpacing(10)
readouts = QHBoxLayout()
self._chi_val = self._readout(readouts, "Chi", palette)
self._phi_val = self._readout(readouts, "Phi", palette)
lay.addLayout(readouts)
lay.addSpacing(10)
lay.addSpacing(8)
# editable chi / phi + Set
ang = QHBoxLayout()
ang.setSpacing(6)
ang.addWidget(self._small_label("Chi"))
self._chi = self._angle_spin(-90, 90)
ang.addWidget(self._chi)
ang.addWidget(self._small_label("Phi"))
self._phi = self._angle_spin(-180, 180)
ang.addWidget(self._phi)
set_ang = QPushButton("Set")
set_ang.setCursor(Qt.PointingHandCursor)
set_ang.clicked.connect(
lambda: self.smargon_angles.emit(self._chi.value(), self._phi.value()))
ang.addWidget(set_ang)
lay.addLayout(ang)
lay.addSpacing(8)
# XY jog pad + focus (Z)
padrow = QHBoxLayout()
pad = QGridLayout()
pad.setSpacing(6)
up = self._jog_btn("")
down = self._jog_btn("")
left = self._jog_btn("")
right = self._jog_btn("")
self._center = QPushButton("100µm")
self._center.setCursor(Qt.PointingHandCursor)
self._center.setStyleSheet(
f"QPushButton {{ background:{palette.surface};"
f" border:1px solid {palette.border_control}; border-radius:6px;"
f" font-size:10px; color:{palette.text_faint}; padding:7px 0; }}"
)
pad.setSpacing(5)
up, down = self._jog_btn(""), self._jog_btn("")
left, right = self._jog_btn(""), self._jog_btn("")
self._center = self._jog_btn("100µm")
self._center.clicked.connect(self._cycle_step)
up.clicked.connect(lambda: self._jog(0, 1))
down.clicked.connect(lambda: self._jog(0, -1))
left.clicked.connect(lambda: self._jog(-1, 0))
right.clicked.connect(lambda: self._jog(1, 0))
up.clicked.connect(lambda: self._jog(0, 1, 0))
down.clicked.connect(lambda: self._jog(0, -1, 0))
left.clicked.connect(lambda: self._jog(-1, 0, 0))
right.clicked.connect(lambda: self._jog(1, 0, 0))
pad.addWidget(up, 0, 1)
pad.addWidget(left, 1, 0)
pad.addWidget(self._center, 1, 1)
pad.addWidget(right, 1, 2)
pad.addWidget(down, 2, 1)
padw = QWidget()
padw.setLayout(pad)
padw.setFixedWidth(150)
center_pad = QHBoxLayout()
center_pad.addStretch(1)
center_pad.addWidget(padw)
center_pad.addStretch(1)
lay.addLayout(center_pad)
padw = QWidget(); padw.setLayout(pad); padw.setFixedWidth(132)
padrow.addWidget(padw)
# focus (Z) column
foc = QVBoxLayout()
foc.setSpacing(5)
foc.addWidget(self._small_label("Focus (Z)"))
fplus = self._jog_btn("")
fminus = self._jog_btn("")
fplus.clicked.connect(lambda: self._jog(0, 0, 1))
fminus.clicked.connect(lambda: self._jog(0, 0, -1))
foc.addWidget(fplus)
foc.addWidget(fminus)
foc.addStretch(1)
padrow.addLayout(foc)
lay.addLayout(padrow)
lay.addSpacing(16)
# ---- ABR (staff only) ----
self._abr_section = QWidget()
ab = QVBoxLayout(self._abr_section)
ab.setContentsMargins(0, 0, 0, 0)
ab.setSpacing(0)
ab.addSpacing(14)
ab.addWidget(hline(palette))
ab.addSpacing(14)
ab.addWidget(section_label("ABR · meas. pos"))
ab.addSpacing(6)
steprow = QHBoxLayout()
steprow.addWidget(self._small_label("Step"))
self._abr_step = QDoubleSpinBox(); self._abr_step.setRange(1, 1000)
self._abr_step.setValue(10); self._abr_step.setSuffix(" µm")
steprow.addWidget(self._abr_step); steprow.addStretch(1)
ab.addLayout(steprow)
abr_grid = QGridLayout(); abr_grid.setSpacing(5)
self._abr_labels = {}
for r, axis in enumerate(("x", "y", "z")):
abr_grid.addWidget(self._small_label(f"GM{axis.upper()}"), r, 0)
m = self._jog_btn(""); p = self._jog_btn("+")
m.clicked.connect(lambda _=False, a=axis: self._abr(a, -1))
p.clicked.connect(lambda _=False, a=axis: self._abr(a, +1))
val = QLabel("")
val.setStyleSheet(f"font-family:{FONT_MONO_FALLBACK}; font-size:11px;")
self._abr_labels[axis] = val
abr_grid.addWidget(m, r, 1); abr_grid.addWidget(p, r, 2); abr_grid.addWidget(val, r, 3)
ab.addLayout(abr_grid)
abr_btns = QHBoxLayout()
save = QPushButton("Save"); save.clicked.connect(self.abr_save)
goto = QPushButton("Goto"); goto.clicked.connect(self.abr_goto)
for b in (save, goto):
b.setCursor(Qt.PointingHandCursor)
abr_btns.addWidget(save); abr_btns.addWidget(goto)
ab.addLayout(abr_btns)
self._abr_section.setVisible(False)
lay.addWidget(self._abr_section)
lay.addSpacing(14)
lay.addWidget(hline(palette))
lay.addSpacing(16)
lay.addSpacing(14)
# ---- ZOOM ----
lay.addWidget(section_label("ZOOM"))
lay.addSpacing(10)
lay.addSpacing(8)
zoom_row = QHBoxLayout()
zoom_row.setSpacing(6)
self._zoom_group = QButtonGroup(self)
@@ -150,88 +205,55 @@ class MotorsPanel(QWidget):
zoom_row.addStretch(1)
lay.addLayout(zoom_row)
lay.addSpacing(16)
lay.addSpacing(14)
lay.addWidget(hline(palette))
lay.addSpacing(16)
lay.addSpacing(14)
# ---- LIGHT ----
lay.addWidget(section_label("LIGHT"))
lay.addSpacing(8)
self._front = self._light_slider(lay, "Front", palette, self.front_light_changed)
lay.addSpacing(10)
self._front = self._light_slider(lay, "Front", palette,
self.front_light_changed)
lay.addSpacing(12)
self._back = self._light_slider(lay, "Back", palette,
self.back_light_changed)
# ---- ENERGY (staff only) ----
self._energy_section = QWidget()
es = QVBoxLayout(self._energy_section)
es.setContentsMargins(0, 0, 0, 0)
es.setSpacing(0)
es.addSpacing(16)
es.addWidget(hline(palette))
es.addSpacing(16)
es.addWidget(section_label("ENERGY"))
es.addSpacing(10)
erow = QHBoxLayout()
erow.setSpacing(6)
self._energy_spin = QDoubleSpinBox()
self._energy_spin.setDecimals(3)
self._energy_spin.setRange(4.0, 25.0)
self._energy_spin.setSingleStep(0.01)
self._energy_spin.setSuffix(" keV")
self._energy_spin.setValue(12.4)
self._energy_spin.setStyleSheet(
f"QDoubleSpinBox {{ font-family:{FONT_MONO_FALLBACK}; font-size:14px;"
f" background:{palette.surface}; border:1px solid {palette.border_control};"
f" border-radius:6px; padding:5px 8px; color:{palette.text_primary}; }}"
)
set_btn = QPushButton("Set")
set_btn.setCursor(Qt.PointingHandCursor)
set_btn.setStyleSheet(
f"QPushButton {{ background:{palette.accent}; color:#fff; border:none;"
f" border-radius:6px; padding:6px 14px; font-weight:600; }}"
)
set_btn.clicked.connect(
lambda: self.energy_changed.emit(self._energy_spin.value())
)
erow.addWidget(self._energy_spin, 1)
erow.addWidget(set_btn)
es.addLayout(erow)
self._energy_section.setVisible(False)
lay.addWidget(self._energy_section)
self._back = self._light_slider(lay, "Back", palette, self.back_light_changed)
lay.addStretch(1)
def set_staff(self, is_staff: bool) -> None:
self._energy_section.setVisible(is_staff)
self._abr_section.setVisible(is_staff)
# -------- helpers --------
def _readout(self, lay: QHBoxLayout, name: str, p: Palette) -> QLabel:
box = QVBoxLayout()
box.setSpacing(0)
lab = QLabel(name)
lab.setStyleSheet(f"font-size:10.5px; color:{p.text_faint};")
val = QLabel("0.0°")
val.setStyleSheet(f"font-family:{FONT_MONO_FALLBACK}; font-size:15px;")
box.addWidget(lab)
box.addWidget(val)
wrap = QWidget()
wrap.setLayout(box)
lay.addWidget(wrap, 1)
return val
def _small_label(self, text: str) -> QLabel:
lbl = QLabel(text)
lbl.setStyleSheet(f"font-size:10.5px; color:{self._p.text_faint};")
return lbl
def _angle_spin(self, lo: float, hi: float) -> QDoubleSpinBox:
s = QDoubleSpinBox()
s.setRange(lo, hi)
s.setDecimals(1)
s.setSuffix("°")
s.setFixedWidth(64)
s.setStyleSheet(
f"QDoubleSpinBox {{ font-family:{FONT_MONO_FALLBACK}; font-size:12px;"
f" background:{self._p.surface}; border:1px solid {self._p.border_control};"
f" border-radius:6px; padding:3px 4px; }}"
)
return s
def _jog_btn(self, glyph: str) -> QPushButton:
b = QPushButton(glyph)
b.setCursor(Qt.PointingHandCursor)
b.setStyleSheet(
f"QPushButton {{ background:{self._p.surface};"
f" border:1px solid {self._p.border_control}; border-radius:6px;"
f" font-size:11px; padding:6px 0; color:{self._p.text_secondary}; }}"
)
return b
def _light_slider(self, lay: QVBoxLayout, name: str, p: Palette,
signal) -> QSlider:
def _light_slider(self, lay: QVBoxLayout, name: str, p: Palette, signal) -> QSlider:
lbl = QLabel(name)
lbl.setStyleSheet(f"font-size:10.5px; color:{p.text_faint};")
lay.addWidget(lbl)
lay.addSpacing(5)
lay.addSpacing(4)
s = QSlider(Qt.Horizontal)
s.setRange(0, 100)
s.setStyleSheet(self._slider_qss(p))
@@ -255,22 +277,26 @@ class MotorsPanel(QWidget):
if b.isChecked():
b.setStyleSheet(
f"QPushButton {{ background:{p.accent}; color:#fff; border:none;"
f" border-radius:6px; font-size:11px; font-weight:600; padding:5px 10px; }}"
)
f" border-radius:6px; font-size:11px; font-weight:600; padding:5px 10px; }}")
else:
b.setStyleSheet(
f"QPushButton {{ background:{p.surface};"
f" border:1px solid {p.border_control}; border-radius:6px;"
f" font-size:11px; padding:5px 10px; }}"
)
f" font-size:11px; padding:5px 10px; }}")
def _cycle_step(self) -> None:
self._jog_idx = (self._jog_idx + 1) % len(JOG_STEPS_UM)
self._center.setText(f"{JOG_STEPS_UM[self._jog_idx]}µm")
def _jog(self, sx: int, sy: int) -> None:
def _jog(self, sx: int, sy: int, sz: int) -> None:
step_mm = JOG_STEPS_UM[self._jog_idx] / 1000.0
self.smargon_jog.emit(sx * step_mm, sy * step_mm)
self.smargon_jog.emit(sx * step_mm, sy * step_mm, sz * step_mm)
def _abr(self, axis: str, sign: int) -> None:
step_mm = self._abr_step.value() / 1000.0 * sign
kw = {"x": 0.0, "y": 0.0, "z": 0.0}
kw[axis] = step_mm
self.abr_tweak.emit(AerotechCoordinate(at_mm=Coordinate(**kw), omega_deg=None))
# -------- live updates --------
def update_daq_status(self, s) -> None:
@@ -281,12 +307,14 @@ class MotorsPanel(QWidget):
self._omega_val.setText(f"{omega:.2f}°")
smg = getattr(geom, "smargon", None)
if smg is not None:
chi = getattr(smg, "chi_deg", None)
phi = getattr(smg, "phi_deg", None)
if chi is not None:
self._chi_val.setText(f"{chi:.1f}°")
if phi is not None:
self._phi_val.setText(f"{phi:.1f}°")
self._sync_spin(self._chi, getattr(smg, "chi_deg", None))
self._sync_spin(self._phi, getattr(smg, "phi_deg", None))
meas = getattr(geom, "aerotech_meas", None)
if meas is not None:
for axis in ("x", "y", "z"):
v = getattr(meas, axis, None)
if v is not None:
self._abr_labels[axis].setText(f"{v:.4f}")
bl = getattr(s, "bl", None)
if bl is not None:
zoom = getattr(bl, "zoom", None)
@@ -294,15 +322,16 @@ class MotorsPanel(QWidget):
self._sync_zoom(zoom)
self._sync_slider(self._front, getattr(bl, "front_light", None))
self._sync_slider(self._back, getattr(bl, "back_light", None))
diff = getattr(s, "diffraction", None)
energy = getattr(diff, "energy_keV", None) if diff else None
if energy is not None and not self._energy_spin.hasFocus():
self._energy_spin.blockSignals(True)
self._energy_spin.setValue(float(energy))
self._energy_spin.blockSignals(False)
@staticmethod
def _sync_spin(spin: QDoubleSpinBox, value) -> None:
if value is None or spin.hasFocus():
return
spin.blockSignals(True)
spin.setValue(float(value))
spin.blockSignals(False)
def _sync_zoom(self, zoom: float) -> None:
# check the nearest level without re-emitting
nearest = min(ZOOM_LEVELS, key=lambda z: abs(z - zoom))
btn = self._zoom_btns.get(nearest)
if btn and not btn.isChecked():
@@ -0,0 +1,79 @@
"""Compact ML prediction-metrics dialog: per-class counts, confidence, FPS.
Fed by the PredictionSubscriber ``prediction`` stream (throttled). A lightweight
version of the old prediction-metrics panel (no rolling chart).
"""
from __future__ import annotations
import time
from PySide6.QtCore import Slot
from PySide6.QtWidgets import (
QDialog,
QHeaderView,
QLabel,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
)
from aare.gui.new_gui.theme import build_qss
class PredictionMetricsDialog(QDialog):
def __init__(self, palette, parent=None):
super().__init__(parent)
self.setWindowTitle("Prediction metrics")
self.setModal(False)
self.resize(360, 360)
self.setStyleSheet(build_qss(palette))
self._last = 0.0
self._frame_ts: list[float] = []
lay = QVBoxLayout(self)
self._summary = QLabel("Waiting for predictions…")
self._summary.setStyleSheet(f"color:{palette.text_muted};")
lay.addWidget(self._summary)
self._table = QTableWidget(0, 2)
self._table.setHorizontalHeaderLabels(["Class", "Count"])
self._table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self._table.verticalHeader().setVisible(False)
lay.addWidget(self._table)
@Slot(dict)
def update_from_prediction(self, payload: dict) -> None:
if not self.isVisible() or not isinstance(payload, dict):
return
now = time.monotonic()
if now - self._last < 0.1: # throttle to ~10 Hz
return
self._last = now
self._frame_ts.append(now)
self._frame_ts = [t for t in self._frame_ts if now - t <= 2.0]
fps = len(self._frame_ts) / 2.0
boxes = payload.get("boxes", []) or []
if isinstance(boxes, dict):
boxes = list(boxes.values())
counts: dict[str, int] = {}
confs: list[float] = []
for b in boxes:
if not isinstance(b, dict):
continue
label = str(b.get("label", b.get("cls", "?")))
counts[label] = counts.get(label, 0) + 1
c = b.get("conf", b.get("confidence"))
if isinstance(c, (int, float)):
confs.append(float(c))
mean_c = sum(confs) / len(confs) if confs else 0.0
max_c = max(confs) if confs else 0.0
self._summary.setText(
f"{len(boxes)} detections · mean conf {mean_c:.2f} · "
f"max {max_c:.2f} · {fps:.0f} fps")
self._table.setRowCount(0)
for name in sorted(counts):
r = self._table.rowCount()
self._table.insertRow(r)
self._table.setItem(r, 0, QTableWidgetItem(name))
self._table.setItem(r, 1, QTableWidgetItem(str(counts[name])))