improvements in gui

This commit is contained in:
x12sa
2026-07-07 10:41:40 +02:00
committed by holler
co-authored by holler
parent 0aa7f0d75a
commit a718772e96
4 changed files with 272 additions and 48 deletions
@@ -278,12 +278,7 @@ class XrayEyeAlign:
self.gui.show_crosshair()
self.send_message(
<<<<<<< Updated upstream
"Submit height. Use arrows if far off."
=======
"Adjust sample height with the arrows if needed, then mark "
"the sample and submit - height will be centered automatically"
>>>>>>> Stashed changes
)
self.gui.enable_submit_button(True)
self.movement_buttons_enabled(True, True)
@@ -1,13 +1,18 @@
from __future__ import annotations
import threading
from typing import Optional
from bec_lib import bec_logger
from bec_widgets import BECWidget
from bec_widgets.utils.rpc_decorator import rpc_timeout
from qtpy.QtCore import Qt, QTimer
from qtpy.QtCore import Signal
from qtpy.QtGui import QKeySequence
from qtpy.QtWidgets import (
QButtonGroup,
QDoubleSpinBox,
QFormLayout,
QFrame,
QGridLayout,
QGroupBox,
@@ -92,6 +97,9 @@ class SlitControlWidget(BECWidget, QWidget):
PLUGIN = True
# carries fetched slit data from the background thread to the main thread
_slit_data_ready = Signal(object)
USER_ACCESS = [
"set_slit",
"move_center",
@@ -122,8 +130,14 @@ class SlitControlWidget(BECWidget, QWidget):
self.step = self._STEP_DEFAULT
self._ov_items: dict[tuple[int, int], QTableWidgetItem] = {}
self._shortcuts: list[QShortcut] = []
# rotating index through non-selected slits for slow background polling
self._slow_poll_idx: int = 0
self._fetch_thread: Optional[threading.Thread] = None
self._slit_data_ready.connect(self._apply_slit_data)
self._locked: bool = False
self._move_btns: list[QPushButton] = [] # populated by _build_* methods
self._lockout_timer = QTimer(self)
self._lockout_timer.setSingleShot(True)
self._lockout_timer.timeout.connect(self._release_lockout)
self._build_ui()
self._build_shortcuts() # created once, enabled/disabled by toggle
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
@@ -162,7 +176,28 @@ class SlitControlWidget(BECWidget, QWidget):
root.addWidget(_separator())
# 5. keyboard toggle + legend
# 5. absolute target entry + stop button
target_row = QHBoxLayout()
target_row.addWidget(self._build_target_panel(), stretch=1)
self._btn_stop = QPushButton("■ Stop")
self._btn_stop.setToolTip("Stop all axes of the selected slit")
self._btn_stop.clicked.connect(self._stop_slit)
self._btn_stop.setMinimumWidth(70)
target_row.addWidget(self._btn_stop, stretch=0)
root.addLayout(target_row)
root.addWidget(_separator())
# 6. status label (move errors, limit hits)
self._status_lbl = QLabel("")
self._status_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._status_lbl.setMinimumHeight(18)
root.addWidget(self._status_lbl)
self._status_timer = QTimer(self)
self._status_timer.setSingleShot(True)
self._status_timer.timeout.connect(lambda: self._status_lbl.setText(""))
# 7. keyboard toggle + legend
self._btn_kb = QPushButton("\u2328 Keyboard tweaking")
self._btn_kb.setCheckable(True)
self._btn_kb.toggled.connect(self._on_kb_toggled)
@@ -172,12 +207,58 @@ class SlitControlWidget(BECWidget, QWidget):
self._kb_legend.setVisible(False)
root.addWidget(self._kb_legend)
def _build_target_panel(self) -> QGroupBox:
"""Four spinboxes to move the selected slit to an absolute position."""
box = QGroupBox("Move to absolute position (mm)")
form = QFormLayout(box)
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
self._target_spins: dict[str, QDoubleSpinBox] = {}
labels = {"xc": "x center", "xs": "x size", "yc": "y center", "ys": "y size"}
for suffix, label in labels.items():
spin = QDoubleSpinBox()
spin.setDecimals(4)
spin.setRange(-500.0, 500.0)
spin.setSingleStep(0.001)
self._target_spins[suffix] = spin
go_btn = QPushButton("")
go_btn.setFixedWidth(28)
go_btn.setToolTip(f"Move {label} to entered value")
# capture suffix in closure
go_btn.clicked.connect(lambda checked=False, s=suffix: self._move_absolute(s))
spin.returnPressed = None # QDoubleSpinBox has no returnPressed
row_widget = QWidget()
row_layout = QHBoxLayout(row_widget)
row_layout.setContentsMargins(0, 0, 0, 0)
row_layout.addWidget(spin)
row_layout.addWidget(go_btn)
form.addRow(f"{label}:", row_widget)
return box
def _build_overview(self) -> QGroupBox:
box = QGroupBox("All slits (mm) select to tweak")
vbox = QVBoxLayout(box)
tbl = QTableWidget(len(self._SLIT_LABELS), 6)
tbl.setHorizontalHeaderLabels(["", "Slit", "x center", "x size", "y center", "y size"])
_R = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
_L = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
for col, (text, align) in enumerate(
[
("", _L),
("Slit", _L),
("x center", _R),
("x size", _R),
("y center", _R),
("y size", _R),
]
):
hdr_item = QTableWidgetItem(text)
hdr_item.setTextAlignment(align)
tbl.setHorizontalHeaderItem(col, hdr_item)
# col 0 (radio) and col 1 (name) sized to content;
# value cols 2-5 share remaining width equally → evenly distributed
tbl.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
@@ -243,6 +324,7 @@ class SlitControlWidget(BECWidget, QWidget):
btn_shrink_y.clicked.connect(lambda: self.move_size("down"))
btn_shrink_x.clicked.connect(lambda: self.move_size("left"))
btn_grow_x.clicked.connect(lambda: self.move_size("right"))
self._move_btns += [btn_grow_y, btn_shrink_y, btn_shrink_x, btn_grow_x]
grid.addWidget(btn_grow_y, 0, 1)
grid.addWidget(btn_shrink_x, 1, 0)
@@ -275,6 +357,7 @@ class SlitControlWidget(BECWidget, QWidget):
btn_down.clicked.connect(lambda: self.move_center("down"))
btn_left.clicked.connect(lambda: self.move_center("left"))
btn_right.clicked.connect(lambda: self.move_center("right"))
self._move_btns += [btn_up, btn_down, btn_left, btn_right]
grid.addWidget(btn_up, 0, 1)
grid.addWidget(btn_left, 1, 0)
@@ -374,40 +457,131 @@ class SlitControlWidget(BECWidget, QWidget):
else:
self._btn_kb.setText("\u2328 Keyboard tweaking")
def _engage_lockout(self, ms: int = 2000) -> None:
"""Disable move buttons and keyboard move shortcuts for ``ms`` ms."""
self._locked = True
for btn in self._move_btns:
btn.setEnabled(False)
# disable only the movement shortcuts (indices 6+); keep slit-select (0-5)
for sc in self._shortcuts[6:]:
sc.setEnabled(False)
self._lockout_timer.start(ms)
def _release_lockout(self) -> None:
"""Re-enable move buttons and shortcuts (called by the lockout timer)."""
self._locked = False
for btn in self._move_btns:
btn.setEnabled(True)
# only re-enable movement shortcuts if keyboard mode is active
if self._btn_kb.isChecked():
for sc in self._shortcuts[6:]:
sc.setEnabled(True)
def _populate_target_spins(self) -> None:
"""Pre-fill target spinboxes with current device values."""
for suffix, spin in self._target_spins.items():
try:
device = getattr(self.dev, self._dev_name(suffix))
reading = device.read()
value = float(reading[self._dev_name(suffix)]["value"])
spin.setValue(value)
except Exception:
pass
def _move_absolute(self, axis_suffix: str) -> None:
"""Move one axis to the value currently entered in its target spinbox."""
if self._locked:
return
self._engage_lockout(3000) # absolute moves may cover more distance
dev_name = self._dev_name(axis_suffix)
target = self._target_spins[axis_suffix].value()
try:
device = getattr(self.dev, dev_name)
device.move(target)
except Exception as exc:
msg = str(exc) or type(exc).__name__
logger.warning(f"SlitControlWidget: absolute move {dev_name} failed: {msg}")
self._show_status(f"{dev_name}: {msg}")
def _stop_slit(self) -> None:
"""Stop all four axes of the currently selected slit."""
for suffix in ("xc", "xs", "yc", "ys"):
try:
device = getattr(self.dev, self._dev_name(suffix))
device.stop()
except Exception as exc:
msg = str(exc) or type(exc).__name__
logger.warning(f"SlitControlWidget: stop {self._dev_name(suffix)} failed: {msg}")
self._show_status(f"Stop failed: {msg}")
def _show_status(self, msg: str, error: bool = True) -> None:
"""Show a brief status message below the controls (auto-clears after 4 s)."""
color = "#e53935" if error else "#43a047"
self._status_lbl.setText(f'<span style="color:{color}">{msg}</span>')
self._status_timer.start(4000)
# ── overview polling ──────────────────────────────────────────────────────
def _refresh_slit_row(self, slit_num: int) -> None:
"""Read all four axis values for one slit and update its table row."""
row = list(self._SLIT_LABELS).index(slit_num)
for col_off, suffix in enumerate(("xc", "xs", "yc", "ys")):
dev_name = f"sl{slit_num}{suffix}"
item = self._ov_items.get((row, col_off + 2))
if item is None:
continue
try:
device = getattr(self.dev, dev_name)
reading = device.read()
value = float(reading[dev_name]["value"])
item.setText(f"{value:.4f}")
except Exception:
item.setText("---")
# ── background polling ────────────────────────────────────────────────────
def _refresh_overview(self) -> None:
"""
Per-tick (2 s) refresh strategy:
- Selected slit: every tick → updated every 2 s.
- Other slits: one per tick in rotation → each updated every ~10 s
(5 other slits × 2 s). At most 8 device.read() calls per tick.
"""
# always refresh the selected slit
self._refresh_slit_row(self.slit)
Kick off a background thread to fetch slit values without blocking the
Qt main thread. device.read() on virtual slit devices triggers RPC
calls to the device server; doing those in the main thread stalls the UI.
The thread emits _slit_data_ready when done; _apply_slit_data updates
the table and target spinboxes safely from the main thread.
# advance through the other slits one per tick
Per-tick strategy (2 s timer):
- Selected slit: every tick.
- Other slits: one per tick in rotation (~10 s per non-selected slit).
"""
if self._fetch_thread is not None and self._fetch_thread.is_alive():
return # previous fetch still running skip this tick
self._fetch_thread = threading.Thread(target=self._fetch_slit_data, daemon=True)
self._fetch_thread.start()
def _fetch_slit_data(self) -> None:
"""Runs in background thread NO Qt widget access here."""
data: dict[tuple[int, str], Optional[float]] = {}
self._fetch_one_slit(self.slit, data)
others = [n for n in self._SLIT_LABELS if n != self.slit]
if others:
self._slow_poll_idx = self._slow_poll_idx % len(others)
self._refresh_slit_row(others[self._slow_poll_idx])
self._fetch_one_slit(others[self._slow_poll_idx], data)
self._slow_poll_idx += 1
self._slit_data_ready.emit(data) # thread-safe: Qt queues it to main thread
def _fetch_one_slit(self, slit_num: int, data: dict[tuple[int, str], Optional[float]]) -> None:
"""Fetch four axis values for one slit into data (background thread)."""
for suffix in ("xc", "xs", "yc", "ys"):
dev_name = f"sl{slit_num}{suffix}"
try:
device = getattr(self.dev, dev_name)
reading = device.read()
data[(slit_num, suffix)] = float(reading[dev_name]["value"])
except Exception:
data[(slit_num, suffix)] = None
def _apply_slit_data(self, data: dict) -> None:
"""Update table and target spinboxes in the main thread (slot for signal)."""
suffix_order = ("xc", "xs", "yc", "ys")
for (slit_num, suffix), value in data.items():
row = list(self._SLIT_LABELS).index(slit_num)
col_off = suffix_order.index(suffix)
item = self._ov_items.get((row, col_off + 2))
if item is None:
continue
if value is not None:
item.setText(f"{value:.3f}")
if slit_num == self.slit and hasattr(self, "_target_spins"):
spin = self._target_spins.get(suffix)
if spin is not None and not spin.hasFocus():
spin.blockSignals(True)
spin.setValue(value)
spin.blockSignals(False)
else:
item.setText("---")
def _update_row_bold(self):
for row, (slit_num, _) in enumerate(self._SLIT_LABELS.items()):
@@ -436,6 +610,7 @@ class SlitControlWidget(BECWidget, QWidget):
btn.setChecked(True)
self._tweaking_lbl.setText(f"Tweaking: {self._SLIT_LABELS[slit]}")
self._update_row_bold()
self._populate_target_spins()
def move_center(self, direction: str):
"""Move the slit center. direction: up / down / left / right."""
@@ -464,6 +639,9 @@ class SlitControlWidget(BECWidget, QWidget):
self._move_relative(suffix, sign * self.step)
def _move_relative(self, axis_suffix: str, delta: float):
if self._locked:
return
self._engage_lockout(2000)
dev_name = self._dev_name(axis_suffix)
try:
device = getattr(self.dev, dev_name)
@@ -471,7 +649,9 @@ class SlitControlWidget(BECWidget, QWidget):
current = float(reading[dev_name]["value"])
device.move(current + delta)
except Exception as exc:
logger.warning(f"SlitControlWidget: failed to move {dev_name}: {exc}")
msg = str(exc) or type(exc).__name__
logger.warning(f"SlitControlWidget: failed to move {dev_name}: {msg}")
self._show_status(f"{dev_name}: {msg}")
def set_step(self, value: float):
"""Set step size in mm (clamped to 0.0052.0 mm)."""
@@ -292,14 +292,6 @@ class TomoParamsWidget(BECWidget, QWidget):
decimals=3,
)
self._add_int(common_form, "frames_per_trigger", "Frames / trigger", min_=1, max_=100)
self._add_double(
common_form,
"corridor_size",
"Corridor size (µm, 1=off)",
min_=-1.0,
max_=200.0,
decimals=2,
)
sp = self._add_bool(common_form, "single_point_instead_of_fermat_scan", "Single-point scan")
sp.stateChanged.connect(self._on_single_point_changed)
vbox.addLayout(common_form)
@@ -642,8 +634,49 @@ class TomoParamsWidget(BECWidget, QWidget):
params = self._load_params()
self._populate_fields(params)
def _is_tomo_running(self) -> bool:
"""
Return True if a tomo scan is likely active.
Uses the ``tomo_progress["heartbeat"]`` timestamp written by
``_tomo_scan_at_angle()`` at the start of every projection — this is
updated whether the scan was started via the queue or directly via
``flomni.tomo_scan()``, so it's more reliable than queue job status.
A heartbeat fresher than 2 minutes indicates a scan in progress;
older than that it's either done, or stuck long enough that editing
parameters is intentional.
"""
import datetime
try:
prog = self._gv_get("tomo_progress")
if not isinstance(prog, dict):
return False
heartbeat_str = prog.get("heartbeat")
if heartbeat_str is None:
return False
heartbeat = datetime.datetime.fromisoformat(heartbeat_str)
age_s = (datetime.datetime.now() - heartbeat).total_seconds()
return age_s < 120 # 2-minute window
except Exception:
return False
def submit_params(self) -> None:
"""Validate, write to global vars, and re-lock fields."""
if self._is_tomo_running():
reply = QMessageBox.warning(
self,
"Scan in progress",
"A tomo scan appears to be running.\n\n"
"Parameters read via properties (e.g. counting time) take effect "
"immediately on the next projection. Parameters used to build the "
"scan trajectory (FOV, angle step) are already fixed for this run "
"but will affect any subsequent queue job.\n\n"
"Apply changes now?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
)
if reply != QMessageBox.StandardButton.Yes:
return
params = self._read_fields()
error = self._validate(params)
if error:
+23 -7
View File
@@ -58,6 +58,7 @@ fheater:
readOnly: false
readoutPriority: baseline
connectionTimeout: 20
foptx:
description: Optics X
deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor
@@ -78,8 +79,12 @@ foptx:
#170 micron, 60 nm
#in: -13.831
#250 micron, 30 nm, Abe structures
in: -13.8809375
# in: -13.8809375
# out: -14.1809
#250 micron, 30 nm, Tomas structures
in: -14.5490625
out: -14.1809
fopty:
description: Optics Y
deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor
@@ -101,8 +106,12 @@ fopty:
#in: 0.42
#out: 0.57
#250 micron, 30 nm, Abe structures
in: 2.8299
out: 2.8299
# in: 2.8299
# out: 2.8299
#250 micron, 30 nm, Tomas structures
in: 2.8419921875
out: 2.8419921875
foptz:
description: Optics Z
deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor
@@ -121,6 +130,7 @@ foptz:
connectionTimeout: 20
userParameter:
in: 23
fsamroy:
description: Sample rotation
deviceClass: csaxs_bec.devices.omny.galil.fupr_ophyd.FuprGalilMotor
@@ -154,7 +164,7 @@ fsamx:
readoutPriority: baseline
connectionTimeout: 20
userParameter:
in: -1.1
in: -1.14
fsamy:
description: Sample coarse Y
deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor
@@ -314,8 +324,12 @@ fosax:
# in: 8.731922
# out: 5.1
#250 micron, 30 nm, Abe structures
in: 8.755141
# in: 8.755141
# out: 5.1
#250 micron, 30 nm, Tomas structures
in: 9.420798
out: 5.1
fosay:
description: OSA Y
deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor
@@ -338,7 +352,9 @@ fosay:
#170 micron, 60 nm, 7.6 kev
#in: -0.0422
#250 micron, 30 nm, Abe structures
in: -2.357436
# in: -2.357436
#250 micron, 30 nm, Tomas structures
in: -2.383993
fosaz:
description: OSA Z
deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor
@@ -364,7 +380,7 @@ fosaz:
# out: 6
# micron, 30 nm, 7.9 kev, foptz 32 //abe's fzp's
in: -2
out: -5
out: -5
############################################################
#################### flOMNI RT motors ######################