diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py index 422046f..a1e2717 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py @@ -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) diff --git a/csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py b/csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py index 7e2c661..3dc3658 100644 --- a/csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py +++ b/csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py @@ -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'{msg}') + 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.005–2.0 mm).""" diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py index 5b46715..1a5c070 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -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: diff --git a/csaxs_bec/device_configs/ptycho_flomni.yaml b/csaxs_bec/device_configs/ptycho_flomni.yaml index c7d8d7b..9809928 100644 --- a/csaxs_bec/device_configs/ptycho_flomni.yaml +++ b/csaxs_bec/device_configs/ptycho_flomni.yaml @@ -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 ######################