diff --git a/src/aare/gui/new_gui/README.md b/src/aare/gui/new_gui/README.md index beadc0c4..7b0dc11d 100644 --- a/src/aare/gui/new_gui/README.md +++ b/src/aare/gui/new_gui/README.md @@ -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 diff --git a/src/aare/gui/new_gui/main_window.py b/src/aare/gui/new_gui/main_window.py index 1629ba08..98a79ce7 100644 --- a/src/aare/gui/new_gui/main_window.py +++ b/src/aare/gui/new_gui/main_window.py @@ -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() diff --git a/src/aare/gui/new_gui/manual_view.py b/src/aare/gui/new_gui/manual_view.py index f2f2eee0..1c8a1cfa 100644 --- a/src/aare/gui/new_gui/manual_view.py +++ b/src/aare/gui/new_gui/manual_view.py @@ -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) diff --git a/src/aare/gui/new_gui/staff_view.py b/src/aare/gui/new_gui/staff_view.py new file mode 100644 index 00000000..9499a143 --- /dev/null +++ b/src/aare/gui/new_gui/staff_view.py @@ -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) diff --git a/src/aare/gui/new_gui/state.py b/src/aare/gui/new_gui/state.py index 22193d4f..2867a57e 100644 --- a/src/aare/gui/new_gui/state.py +++ b/src/aare/gui/new_gui/state.py @@ -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) diff --git a/src/aare/gui/new_gui/top_bar.py b/src/aare/gui/new_gui/top_bar.py index 737ebf1b..85d72271 100644 --- a/src/aare/gui/new_gui/top_bar.py +++ b/src/aare/gui/new_gui/top_bar.py @@ -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: diff --git a/src/aare/gui/new_gui/widgets/camera_controls.py b/src/aare/gui/new_gui/widgets/camera_controls.py index 95adbaa9..55ae0a2f 100644 --- a/src/aare/gui/new_gui/widgets/camera_controls.py +++ b/src/aare/gui/new_gui/widgets/camera_controls.py @@ -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( diff --git a/src/aare/gui/new_gui/widgets/motors_panel.py b/src/aare/gui/new_gui/widgets/motors_panel.py index a462d7d5..f5c6609e 100644 --- a/src/aare/gui/new_gui/widgets/motors_panel.py +++ b/src/aare/gui/new_gui/widgets/motors_panel.py @@ -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(): diff --git a/src/aare/gui/new_gui/widgets/prediction_metrics_dialog.py b/src/aare/gui/new_gui/widgets/prediction_metrics_dialog.py new file mode 100644 index 00000000..c52edbe1 --- /dev/null +++ b/src/aare/gui/new_gui/widgets/prediction_metrics_dialog.py @@ -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])))