new_gui Phase 4: shortcuts, persistence, filename builder, face detection, stability

- keyboard shortcuts (mode/camera-tab/tools/help/quit) + window geometry+mode
  persistence via QSettings
- filename builder (prefix + auto-increment run) feeding scan file_prefix
- face-detection dialog (steps/step-size -> face_detection_result scatter)
- target-stability dialog (target<->beam distance + sigma)
- launch face/stability from camera tab strip
(prediction-metrics + smargon-trace deferred)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
appleb_m
2026-06-24 21:42:20 +02:00
co-authored by Claude Opus 4.8
parent e4dc3d505c
commit 7c22b24437
10 changed files with 373 additions and 3 deletions
+7
View File
@@ -35,6 +35,13 @@ Backend contract is documented in the `new-gui-backend-paths` memory.
## What is wired to the backend
- **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
fills scan `file_prefix` (`data/…` / `screening/…`); **face detection** dialog
(`face_detection``face_detection_result` scatter); **target-stability**
dialog (target↔beam distance + sigma). Launch face/stability from the camera
tab strip. (prediction-metrics + smargon-trace panels deferred.)
- **Live status** → status bar (flux/ring/λ/cryo/shutter/state), motors readouts
(omega/chi/phi), zoom + light sliders.
- **Baton** chip ← `baton_status_changed`.
+3 -2
View File
@@ -98,5 +98,6 @@ physics), J (pgroups + sessions), H (recovery + local-contact admin), developer
help. Deferred: full device-state status widget, BEC reinit-method picker,
tracebacks/error-log live tabs.
**Phase 4 — polish:** L (file builder), F (face detection), M (analysis panels),
N (shortcuts, layout persistence, tutorials, portrait/compact).
**Phase 4 — polish:** ✅ DONE. L (file builder), F (face detection), M (target-
stability dialog; prediction-metrics + smargon-trace deferred — niche), N
(keyboard shortcuts + layout/mode persistence; tutorials/portrait deferred).
+85 -1
View File
@@ -8,7 +8,8 @@ backend; the views speak in intent signals and request objects.
from __future__ import annotations
from PySide6.QtCore import QTimer, Slot
from PySide6.QtCore import QSettings, QTimer, Slot
from PySide6.QtGui import QKeySequence, QShortcut
from PySide6.QtWidgets import (
QMessageBox,
QStackedWidget,
@@ -110,6 +111,8 @@ class MainWindow(QWidget):
# backend
self._build_backend(pred_zmq_addr)
self._wire()
self._build_shortcuts()
self._restore_state()
# ------------------------------------------------------------- backend
def _build_backend(self, pred_zmq_addr) -> None:
@@ -196,6 +199,9 @@ class MainWindow(QWidget):
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)
if hasattr(self.daq, "run_number_incremented"):
self.daq.run_number_incremented.connect(
self.manual.pipeline.filename.increment_run)
# camera controls
cc = self.manual.camera_controls
@@ -205,6 +211,8 @@ class MainWindow(QWidget):
cc.autofocus_requested.connect(self.daq.autofocus)
if hasattr(self.daq, "send_screenshot_db"):
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)
if hasattr(self.daq, "raster_scan_completed"):
self.daq.raster_scan_completed.connect(self.manual.on_raster_completed)
@@ -350,6 +358,25 @@ class MainWindow(QWidget):
self._staff_tools.show()
self._staff_tools.raise_()
def _open_face_detection(self) -> None:
from aare.gui.new_gui.widgets.face_detection_dialog import FaceDetectionDialog
if getattr(self, "_face_dialog", None) is None:
self._face_dialog = FaceDetectionDialog(self.daq, self._palette, self)
self._face_dialog.show()
self._face_dialog.raise_()
def _open_stability(self) -> None:
from aare.gui.new_gui.widgets.stability_dialog import TargetStabilityDialog
if getattr(self, "_stability_dialog", None) is None:
self._stability_dialog = TargetStabilityDialog(self._palette, self)
if self.camera_thread is not None and hasattr(self.camera_thread, "target_point"):
self.camera_thread.target_point.connect(
self._stability_dialog.update_target_point)
self._stability_dialog.show()
self._stability_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
@@ -374,6 +401,11 @@ class MainWindow(QWidget):
self.top_bar.set_staff(staff)
self.manual.set_staff(staff)
self._update_control()
sd = getattr(self, "_stability_dialog", None)
geom = getattr(s, "geom", None)
beam = getattr(geom, "beam_location_pxl", None) if geom else None
if sd is not None and beam is not None:
sd.set_beam_center(beam.x, beam.y)
def _on_baton_status(self, status) -> None:
self._baton = status
@@ -640,7 +672,59 @@ class MainWindow(QWidget):
self.alert.show_message("Raster scan complete.", "success")
# --------------------------------------------------------------- close
# ----------------------------------------------------- shortcuts / state
def _build_shortcuts(self) -> None:
def sc(seq, slot):
QShortcut(QKeySequence(seq), self, activated=slot)
sc("Ctrl+1", lambda: self.state.set_mode("manual"))
sc("Ctrl+2", lambda: self.state.set_mode("automation"))
sc("Ctrl+T", self._open_staff_tools_if_staff)
sc("F1", self._show_help)
sc("Ctrl+Q", self.close)
# camera tabs (manual)
tabs = getattr(self.manual, "_tabs", {})
for seq, name in (("Alt+1", "Sample camera"), ("Alt+2", "Gonio"),
("Alt+3", "Beamline"), ("Alt+4", "XRF spectrum")):
if name in tabs:
sc(seq, lambda n=name: tabs[n].click())
def _open_staff_tools_if_staff(self) -> None:
if self._staff:
self._open_staff_tools()
def _show_help(self) -> None:
QMessageBox.information(
self, "Keyboard shortcuts",
"Ctrl+1 / Ctrl+2 — Manual / Automation\n"
"Alt+1..4 — camera tabs (Sample / Gonio / Beamline / XRF)\n"
"Ctrl+T — Staff tools (staff)\n"
"Ctrl+Q — Quit\n"
"F1 — This help",
)
def _settings(self) -> QSettings:
return QSettings("PSI", "AareGUI-new")
def _restore_state(self) -> None:
st = self._settings()
geo = st.value("geometry")
if geo is not None:
self.restoreGeometry(geo)
mode = st.value("mode")
if mode in ("manual", "automation"):
self.state.set_mode(mode)
self.top_bar.set_mode(mode)
def _save_state(self) -> None:
st = self._settings()
st.setValue("geometry", self.saveGeometry())
st.setValue("mode", self.state.mode)
def closeEvent(self, event): # noqa: N802
try:
self._save_state()
except Exception:
pass
try:
if hasattr(self.daq, "cleanup"):
self.daq.cleanup()
+3
View File
@@ -184,6 +184,7 @@ class ManualView(QWidget):
exp_time_s=float(self._defaults.get("raster_exp_time_s",
self._defaults.get("exp_time_s", 0.02))),
dtz=self._defaults.get("raster_dtz", self._defaults.get("dtz")),
file_prefix=self.pipeline.filename.file_prefix("data"),
)
self._state.run_now("raster")
self.raster_scan_requested.emit(req)
@@ -211,6 +212,7 @@ class ManualView(QWidget):
dtz=cs.collect_dtz(),
transmission=cs.collect_transmission(),
start_omega_deg=start_omega,
file_prefix=self.pipeline.filename.file_prefix("screening"),
)
self.rotation_scan_requested.emit(req)
return
@@ -226,6 +228,7 @@ class ManualView(QWidget):
exp_time_s=float(self._defaults.get("rotation_exp_time_s",
self._defaults.get("exp_time_s", 0.02))),
start_omega_deg=start_omega,
file_prefix=self.pipeline.filename.file_prefix("data"),
)
self.rotation_scan_requested.emit(req)
+2
View File
@@ -62,6 +62,7 @@ def build_raster_request(
exp_time_s: float,
dtz: float | None,
auto: bool = False,
file_prefix: str | None = None,
) -> RasterGridRequest:
"""grid_rect_img = (x, y, w, h) in image pixels (from the drawn grid).
@@ -91,6 +92,7 @@ def build_raster_request(
omega_deg=float(omega),
dtz=dtz,
transmission=transmission,
file_prefix=file_prefix,
visible=True,
)
@@ -24,6 +24,8 @@ class CameraControls(QWidget):
samcam_changed = Signal(object) # SampleCameraSettings
autofocus_requested = Signal(object) # AutofocusSettings
screenshot_requested = Signal(str, str) # filename, message
face_detect_clicked = Signal()
stability_clicked = Signal()
def __init__(self, palette: Palette, parent=None):
super().__init__(parent)
@@ -35,6 +37,8 @@ class CameraControls(QWidget):
("", "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),
):
b = QPushButton(text)
b.setCursor(Qt.PointingHandCursor)
@@ -0,0 +1,104 @@
"""Face-detection dialog: rotate the sample to find the best (face-on) angle.
Inputs steps + step-size → DAQWorker.face_detection(steps, step_size). Renders
the result (`face_detection_result`): height/area vs angle scatter + the fitted
best angles.
"""
from __future__ import annotations
from PySide6.QtCharts import QChart, QChartView, QScatterSeries, QValueAxis
from PySide6.QtCore import Qt, Slot
from PySide6.QtGui import QPainter
from PySide6.QtWidgets import (
QDialog,
QHBoxLayout,
QLabel,
QPushButton,
QSpinBox,
QVBoxLayout,
)
from aare.gui.new_gui.theme import build_qss
class FaceDetectionDialog(QDialog):
def __init__(self, daq, palette, parent=None):
super().__init__(parent)
self._daq = daq
self.setWindowTitle("Face detection")
self.setModal(False)
self.resize(560, 460)
self.setStyleSheet(build_qss(palette))
lay = QVBoxLayout(self)
ctl = QHBoxLayout()
ctl.addWidget(QLabel("Steps"))
self._steps = QSpinBox(); self._steps.setRange(1, 50); self._steps.setValue(14)
ctl.addWidget(self._steps)
ctl.addWidget(QLabel("Step size (°)"))
self._step = QSpinBox(); self._step.setRange(1, 50); self._step.setValue(15)
ctl.addWidget(self._step)
self._run = QPushButton("▶ Run")
self._run.setCursor(Qt.PointingHandCursor)
self._run.clicked.connect(self._on_run)
ctl.addWidget(self._run)
ctl.addStretch(1)
lay.addLayout(ctl)
self._status = QLabel("Idle.")
self._status.setStyleSheet(f"color:{palette.text_muted};")
lay.addWidget(self._status)
self._height = QScatterSeries(); self._height.setName("Height"); self._height.setMarkerSize(9)
self._area = QScatterSeries(); self._area.setName("Area"); self._area.setMarkerSize(9)
self._chart = QChart()
self._chart.addSeries(self._height)
self._chart.addSeries(self._area)
self._ax = QValueAxis(); self._ax.setTitleText("Angle [°]")
self._ay = QValueAxis(); self._ay.setTitleText("Height / Area")
self._chart.addAxis(self._ax, Qt.AlignBottom)
self._chart.addAxis(self._ay, Qt.AlignLeft)
for s in (self._height, self._area):
s.attachAxis(self._ax); s.attachAxis(self._ay)
view = QChartView(self._chart); view.setRenderHint(QPainter.Antialiasing)
lay.addWidget(view, 1)
sig = getattr(daq, "face_detection_result", None)
if sig is not None:
sig.connect(self.update_result)
def _on_run(self) -> None:
self._status.setText("Running…")
fn = getattr(self._daq, "face_detection", None)
if callable(fn):
fn(int(self._steps.value()), int(self._step.value()))
@Slot(dict)
def update_result(self, data: dict) -> None:
samples = data.get("samples", []) or []
self._height.clear(); self._area.clear()
xs, ys = [], []
for s in samples:
a = float(s.get("angle_deg", 0))
h = float(s.get("height", 0))
ar = float(s.get("area", 0))
self._height.append(a, h); self._area.append(a, ar)
xs.append(a); ys.extend([h, ar])
if xs:
self._ax.setRange(min(xs), max(xs))
self._ay.setRange(min(ys + [0]), max(ys + [1]))
bits = []
for key, label in (("height_fit", "height"), ("area_fit", "area")):
fit = data.get(key)
if isinstance(fit, dict) and fit.get("best_angle_deg") is not None:
bits.append(f"best {label} angle: {float(fit['best_angle_deg']):.1f}°")
status = data.get("status", "")
if data.get("running"):
ang = data.get("current_angle_deg")
status = f"Running… {ang:.1f}°" if ang is not None else "Running…"
elif not status:
status = "Done." if samples else "Idle."
self._status.setText(status + (" · " + " · ".join(bits) if bits else ""))
if not data.get("running"):
self._run.setEnabled(True)
@@ -0,0 +1,44 @@
"""Compact data filename builder: prefix + run number -> scan file_prefix.
Produces a relative ``file_prefix`` like ``data/<prefix>_001`` (or
``screening/<prefix>_001``), matching the existing GUI's add_data_to_path /
add_screening_to_path convention. Auto-increments the run number when the DAQ
reports a scan started (``run_number_incremented``).
"""
from __future__ import annotations
from PySide6.QtCore import Slot
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QSpinBox, QWidget
from aare.gui.new_gui.theme import Palette
class FilenameBuilder(QWidget):
def __init__(self, palette: Palette, parent=None):
super().__init__(parent)
lay = QHBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(6)
lbl = QLabel("Prefix")
lbl.setStyleSheet(f"font-size:11px; color:{palette.text_faint};")
lay.addWidget(lbl)
self._prefix = QLineEdit("sample")
self._prefix.setMaximumWidth(140)
lay.addWidget(self._prefix)
run = QLabel("Run")
run.setStyleSheet(f"font-size:11px; color:{palette.text_faint};")
lay.addWidget(run)
self._run = QSpinBox()
self._run.setRange(1, 999)
self._run.setValue(1)
lay.addWidget(self._run)
def file_prefix(self, kind: str = "data") -> str:
"""kind is 'data' or 'screening'."""
base = (self._prefix.text().strip() or "sample")
return f"{kind}/{base}_{self._run.value():03d}"
@Slot()
def increment_run(self) -> None:
self._run.setValue(min(999, self._run.value() + 1))
@@ -640,6 +640,9 @@ class PipelinePanel(QWidget):
head.addWidget(self._badge)
head.addWidget(self._meta)
head.addStretch(1)
from aare.gui.new_gui.widgets.filename_builder import FilenameBuilder
self.filename = FilenameBuilder(palette)
head.addWidget(self.filename)
self._hint = QLabel("")
self._hint.setStyleSheet(f"font-size:11px; color:{palette.text_faint};")
head.addWidget(self._hint)
@@ -0,0 +1,118 @@
"""Target-stability analysis: tracks the ML target point relative to the beam.
Plots the target↔beam distance and its rolling sigma over time, fed by the
PredictionSubscriber ``target_point`` stream and the live beam centre.
(prediction-metrics and smargon-trace panels are deferred — niche.)
"""
from __future__ import annotations
import math
import time
from collections import deque
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QValueAxis
from PySide6.QtCore import Qt, Slot
from PySide6.QtGui import QPainter
from PySide6.QtWidgets import (
QCheckBox,
QDialog,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
)
from aare.gui.new_gui.theme import build_qss
class TargetStabilityDialog(QDialog):
def __init__(self, palette, parent=None):
super().__init__(parent)
self.setWindowTitle("Target stability")
self.setModal(False)
self.resize(620, 440)
self.setStyleSheet(build_qss(palette))
self._beam = None
self._paused = False
self._samples: deque = deque(maxlen=400) # (t, dx, dy)
self._t0 = time.monotonic()
lay = QVBoxLayout(self)
top = QHBoxLayout()
self._stat = QLabel("Waiting for target…")
self._stat.setStyleSheet(f"color:{palette.text_muted};")
top.addWidget(self._stat, 1)
pause = QCheckBox("Pause")
pause.toggled.connect(self._set_paused)
top.addWidget(pause)
clear = QPushButton("Clear")
clear.clicked.connect(self._clear)
top.addWidget(clear)
lay.addLayout(top)
self._dist = QLineSeries(); self._dist.setName("Distance (px)")
self._sig = QLineSeries(); self._sig.setName("Sigma (px)")
self._chart = QChart()
self._chart.addSeries(self._dist); self._chart.addSeries(self._sig)
self._ax = QValueAxis(); self._ax.setTitleText("Time [s]")
self._ay = QValueAxis(); self._ay.setTitleText("px")
self._chart.addAxis(self._ax, Qt.AlignBottom)
self._chart.addAxis(self._ay, Qt.AlignLeft)
for s in (self._dist, self._sig):
s.attachAxis(self._ax); s.attachAxis(self._ay)
view = QChartView(self._chart); view.setRenderHint(QPainter.Antialiasing)
lay.addWidget(view, 1)
def _set_paused(self, on: bool) -> None:
self._paused = on
def _clear(self) -> None:
self._samples.clear()
self._dist.clear(); self._sig.clear()
def set_beam_center(self, x: float, y: float) -> None:
self._beam = (float(x), float(y))
@Slot(dict)
def update_target_point(self, payload: dict) -> None:
if self._paused or self._beam is None or not self.isVisible():
return
tp = payload.get("target_point") if isinstance(payload, dict) else None
if not tp:
return
if isinstance(tp, dict):
tx, ty = tp.get("x"), tp.get("y")
elif isinstance(tp, (list, tuple)) and len(tp) >= 2:
tx, ty = tp[0], tp[1]
else:
return
if tx is None or ty is None:
return
dx = float(tx) - self._beam[0]
dy = float(ty) - self._beam[1]
t = time.monotonic() - self._t0
self._samples.append((t, dx, dy))
self._redraw()
def _redraw(self) -> None:
if not self._samples:
return
win = list(self._samples)[-60:]
mean_dx = sum(s[1] for s in win) / len(win)
mean_dy = sum(s[2] for s in win) / len(win)
var = sum((s[1] - mean_dx) ** 2 + (s[2] - mean_dy) ** 2 for s in win) / len(win)
sigma = math.sqrt(var)
last = self._samples[-1]
dist = math.hypot(last[1], last[2])
self._stat.setText(f"distance {dist:.1f} px · sigma {sigma:.2f} px · "
f"{len(self._samples)} pts")
self._dist.append(last[0], dist)
self._sig.append(last[0], sigma)
# keep series bounded
if self._dist.count() > 400:
self._dist.remove(0); self._sig.remove(0)
ts = [s[0] for s in self._samples]
self._ax.setRange(min(ts), max(ts) + 0.01)
ys = [math.hypot(s[1], s[2]) for s in self._samples]
self._ay.setRange(0, max(ys + [1.0]) * 1.1)