From 7c22b244377cb3779e37ded4402efeb4f8731eec Mon Sep 17 00:00:00 2001 From: appleb_m Date: Wed, 24 Jun 2026 21:42:20 +0200 Subject: [PATCH] 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) --- src/aare/gui/new_gui/README.md | 7 ++ src/aare/gui/new_gui/ROADMAP.md | 5 +- src/aare/gui/new_gui/main_window.py | 86 ++++++++++++- src/aare/gui/new_gui/manual_view.py | 3 + src/aare/gui/new_gui/requests.py | 2 + .../gui/new_gui/widgets/camera_controls.py | 4 + .../new_gui/widgets/face_detection_dialog.py | 104 +++++++++++++++ .../gui/new_gui/widgets/filename_builder.py | 44 +++++++ .../gui/new_gui/widgets/pipeline_panel.py | 3 + .../gui/new_gui/widgets/stability_dialog.py | 118 ++++++++++++++++++ 10 files changed, 373 insertions(+), 3 deletions(-) create mode 100644 src/aare/gui/new_gui/widgets/face_detection_dialog.py create mode 100644 src/aare/gui/new_gui/widgets/filename_builder.py create mode 100644 src/aare/gui/new_gui/widgets/stability_dialog.py diff --git a/src/aare/gui/new_gui/README.md b/src/aare/gui/new_gui/README.md index 3c2d45c7..beadc0c4 100644 --- a/src/aare/gui/new_gui/README.md +++ b/src/aare/gui/new_gui/README.md @@ -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`. diff --git a/src/aare/gui/new_gui/ROADMAP.md b/src/aare/gui/new_gui/ROADMAP.md index 4db10477..f938c401 100644 --- a/src/aare/gui/new_gui/ROADMAP.md +++ b/src/aare/gui/new_gui/ROADMAP.md @@ -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). diff --git a/src/aare/gui/new_gui/main_window.py b/src/aare/gui/new_gui/main_window.py index e69f1b91..1629ba08 100644 --- a/src/aare/gui/new_gui/main_window.py +++ b/src/aare/gui/new_gui/main_window.py @@ -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() diff --git a/src/aare/gui/new_gui/manual_view.py b/src/aare/gui/new_gui/manual_view.py index 42dd09ae..f2f2eee0 100644 --- a/src/aare/gui/new_gui/manual_view.py +++ b/src/aare/gui/new_gui/manual_view.py @@ -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) diff --git a/src/aare/gui/new_gui/requests.py b/src/aare/gui/new_gui/requests.py index b8931f12..aa25c6c6 100644 --- a/src/aare/gui/new_gui/requests.py +++ b/src/aare/gui/new_gui/requests.py @@ -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, ) diff --git a/src/aare/gui/new_gui/widgets/camera_controls.py b/src/aare/gui/new_gui/widgets/camera_controls.py index d037db14..95adbaa9 100644 --- a/src/aare/gui/new_gui/widgets/camera_controls.py +++ b/src/aare/gui/new_gui/widgets/camera_controls.py @@ -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) diff --git a/src/aare/gui/new_gui/widgets/face_detection_dialog.py b/src/aare/gui/new_gui/widgets/face_detection_dialog.py new file mode 100644 index 00000000..d6b5e009 --- /dev/null +++ b/src/aare/gui/new_gui/widgets/face_detection_dialog.py @@ -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) diff --git a/src/aare/gui/new_gui/widgets/filename_builder.py b/src/aare/gui/new_gui/widgets/filename_builder.py new file mode 100644 index 00000000..692a83ca --- /dev/null +++ b/src/aare/gui/new_gui/widgets/filename_builder.py @@ -0,0 +1,44 @@ +"""Compact data filename builder: prefix + run number -> scan file_prefix. + +Produces a relative ``file_prefix`` like ``data/_001`` (or +``screening/_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)) diff --git a/src/aare/gui/new_gui/widgets/pipeline_panel.py b/src/aare/gui/new_gui/widgets/pipeline_panel.py index d1c8557e..8b0b95b5 100644 --- a/src/aare/gui/new_gui/widgets/pipeline_panel.py +++ b/src/aare/gui/new_gui/widgets/pipeline_panel.py @@ -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) diff --git a/src/aare/gui/new_gui/widgets/stability_dialog.py b/src/aare/gui/new_gui/widgets/stability_dialog.py new file mode 100644 index 00000000..bf21a71b --- /dev/null +++ b/src/aare/gui/new_gui/widgets/stability_dialog.py @@ -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)