diff --git a/csaxs_bec/bec_widgets/widgets/client.py b/csaxs_bec/bec_widgets/widgets/client.py index c0827dd..1ec7a87 100644 --- a/csaxs_bec/bec_widgets/widgets/client.py +++ b/csaxs_bec/bec_widgets/widgets/client.py @@ -14,6 +14,7 @@ logger = bec_logger.logger _Widgets = { "SlitControlWidget": "SlitControlWidget", + "TomoParamsWidget": "TomoParamsWidget", "XRayEye": "XRayEye", } @@ -39,14 +40,13 @@ class SlitControlWidget(RPCBase): @rpc_call def move_size(self, direction: "str"): """ - Adjust the slit size by one step. - up / right = grow, down / left = shrink. + Adjust the slit size by one step. up/right = grow, down/left = shrink. """ @rpc_call def set_step(self, value: "float"): """ - Set the shared step size in mm (clamped to 0.005 – 2.0 mm). + Set the shared step size in mm (clamped to 0.005–2.0 mm). """ @rpc_call @@ -62,6 +62,42 @@ class SlitControlWidget(RPCBase): """ +class TomoParamsWidget(RPCBase): + """Interactive GUI for FlOMNI tomo scan parameter editing and queue management.""" + + _IMPORT_MODULE = "csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params" + + @rpc_call + def refresh(self) -> "None": + """ + Full refresh: params, queue, progress, sample name. + """ + + @rpc_call + def enter_edit_mode(self) -> "None": + """ + Unlock all parameter fields for editing. + """ + + @rpc_call + def cancel_edit(self) -> "None": + """ + Discard changes and re-lock fields. + """ + + @rpc_call + def submit_params(self) -> "None": + """ + Validate, write to global vars, and re-lock fields. + """ + + @rpc_call + def add_to_queue(self) -> "None": + """ + Snapshot the current live global-var parameters and append a new job. + """ + + class XRayEye(RPCBase): _IMPORT_MODULE = "csaxs_bec.bec_widgets.widgets.xray_eye.x_ray_eye" diff --git a/csaxs_bec/bec_widgets/widgets/designer_plugins.py b/csaxs_bec/bec_widgets/widgets/designer_plugins.py index c57998d..fefec5a 100644 --- a/csaxs_bec/bec_widgets/widgets/designer_plugins.py +++ b/csaxs_bec/bec_widgets/widgets/designer_plugins.py @@ -9,10 +9,15 @@ designer_plugins = { "csaxs_bec.bec_widgets.widgets.slit_control.slit_control", "SlitControlWidget", ), + "TomoParamsWidget": ( + "csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params", + "TomoParamsWidget", + ), "XRayEye": ("csaxs_bec.bec_widgets.widgets.xray_eye.x_ray_eye", "XRayEye"), } widget_icons = { "SlitControlWidget": "widgets", + "TomoParamsWidget": "widgets", "XRayEye": "widgets", } 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 dbbc8ed..7e2c661 100644 --- a/csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py +++ b/csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py @@ -1,10 +1,10 @@ from __future__ import annotations from bec_lib import bec_logger -from bec_lib.endpoints import MessageEndpoints -from bec_widgets import BECWidget, SafeSlot +from bec_widgets import BECWidget from bec_widgets.utils.rpc_decorator import rpc_timeout -from qtpy.QtCore import Qt +from qtpy.QtCore import Qt, QTimer +from qtpy.QtGui import QKeySequence from qtpy.QtWidgets import ( QButtonGroup, QDoubleSpinBox, @@ -12,24 +12,31 @@ from qtpy.QtWidgets import ( QGridLayout, QGroupBox, QHBoxLayout, + QHeaderView, QLabel, QPushButton, QRadioButton, + QShortcut, + QTableWidget, + QTableWidgetItem, QVBoxLayout, QWidget, ) logger = bec_logger.logger -# ── arrow glyphs ──────────────────────────────────────────────────────────── +# ── arrow glyphs ───────────────────────────────────────────────────────────── _UP = "\u2191" # ↑ _DOWN = "\u2193" # ↓ _LEFT = "\u2190" # ← _RIGHT = "\u2192" # → -_SHRINK_X = "\u2192\u2190" # →← decrease x size (inward) -_GROW_X = "\u2190\u2192" # ←→ increase x size (outward) -_GROW_Y = "\u2191\u2193" # ↑↓ outward (90° rotation of ←→) -_SHRINK_Y = "\u2193\u2191" # ↓↑ inward (90° rotation of →←) +_SHRINK_X = "\u2192\u2190" # →← inward horizontal +_GROW_X = "\u2190\u2192" # ←→ outward horizontal +# Vertical size: ⬇/⬆ (bold filled arrows), no separator line +_SHRINK_Y = "\u2b07\n\u2b06" # ⬇⬆ inward vertical +_GROW_Y = "\u2b06\n\u2b07" # ⬆⬇ outward vertical + +_Y_BTN_MIN_H = 55 # px – enough for two bold lines def _separator() -> QFrame: @@ -39,22 +46,48 @@ def _separator() -> QFrame: return sep +# ── keyboard shortcut map ───────────────────────────────────────────────────── +# (Qt key string, action label for legend) +_KB_CENTER = [ + ("Up", "↑", "center up"), + ("Down", "↓", "center down"), + ("Left", "←", "center left"), + ("Right", "→", "center right"), +] +_KB_SIZE = [ + ("S", "S", "x smaller"), + ("F", "F", "x wider"), + ("X", "X", "y smaller"), + ("E", "E", "y wider"), +] +_KB_STEP = [ + (",", ",", "step ÷ 2"), + (".", ".", "step × 2"), +] + + class SlitControlWidget(BECWidget, QWidget): """ Interactive GUI for cSAXS slit center and size control. - Layout - ------ - - Radio buttons to select slit (sl1–sl6) - - "C" cluster: four outward arrows to move x/y center - - "S" cluster: four double-arrow buttons to grow/shrink x/y size - - Shared step size field (mm) with ×2 / ÷2 buttons and manual entry - - Readback table: horizontal / vertical rows × center / size columns, - updated live via Redis subscription + Layout (top → bottom) + --------------------- + 1. Overview table – all six slits with live readbacks and radio-button + selection (polled every ``_POLL_MS`` ms via ``device.read()``). + 2. "Tweaking: Slit N" label. + 3. S cluster (size, LEFT) | C cluster (center, RIGHT) ← mirrors keyboard. + 4. Step control: [× 2] [step spinbox] [÷ 2]. + 5. ⌨ Keyboard-tweaking toggle + legend (hidden when inactive). - Movement reads the current position via ``device.read()`` then dispatches - Movement calls ``device.move(target)`` — the documented BECWidget API for - moving a positioner (``self.dev['motor'].move(position)``). + Keyboard mode + ------------- + Toggle with the ⌨ button. When active: + * 1–6 select slit + * ← → ↑ ↓ move center + * S / F shrink / grow x size + * X / E shrink / grow y size + * , / . halve / double step + * Esc exit keyboard mode """ PLUGIN = True @@ -80,77 +113,113 @@ class SlitControlWidget(BECWidget, QWidget): _STEP_MIN = 0.005 # mm (5 µm) _STEP_MAX = 2.0 # mm _STEP_DEFAULT = 0.05 # mm (50 µm) + _POLL_MS = 2000 def __init__(self, parent=None, slit: int = 4, **kwargs): super().__init__(parent=parent, **kwargs) self.get_bec_shortcuts() self.slit = slit self.step = self._STEP_DEFAULT - self._subscribed_devices: list[str] = [] + 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._build_ui() - self._subscribe_readbacks() - self._refresh_labels() + self._build_shortcuts() # created once, enabled/disabled by toggle + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + # polling timer + self._poll_timer = QTimer(self) + self._poll_timer.setInterval(self._POLL_MS) + self._poll_timer.timeout.connect(self._refresh_overview) + self._poll_timer.start() + self._refresh_overview() - # ── UI construction ────────────────────────────────────────────────────── + # ── UI construction ─────────────────────────────────────────────────────── def _build_ui(self): root = QVBoxLayout(self) root.setSpacing(8) - root.addWidget(self._build_slit_selector()) + # 1. overview table + root.addWidget(self._build_overview()) root.addWidget(_separator()) + # 2. active-slit label + self._tweaking_lbl = QLabel(f"Tweaking: {self._SLIT_LABELS[self.slit]}") + self._tweaking_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + root.addWidget(self._tweaking_lbl) + + # 3. S cluster (LEFT) | C cluster (RIGHT) ← mirrors keyboard layout clusters = QHBoxLayout() - clusters.addWidget(self._build_center_cluster()) clusters.addWidget(self._build_size_cluster()) + clusters.addWidget(self._build_center_cluster()) root.addLayout(clusters) root.addWidget(_separator()) + + # 4. step [× 2] [spinbox] [÷ 2] root.addWidget(self._build_step_control()) + root.addWidget(_separator()) - root.addWidget(self._build_readback_table()) - def _build_slit_selector(self) -> QGroupBox: - box = QGroupBox("Slit") - layout = QHBoxLayout(box) + # 5. keyboard toggle + legend + self._btn_kb = QPushButton("\u2328 Keyboard tweaking") + self._btn_kb.setCheckable(True) + self._btn_kb.toggled.connect(self._on_kb_toggled) + root.addWidget(self._btn_kb) + + self._kb_legend = self._build_kb_legend() + self._kb_legend.setVisible(False) + root.addWidget(self._kb_legend) + + 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"]) + # 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) + tbl.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) + for col in range(2, 6): + tbl.horizontalHeader().setSectionResizeMode(col, QHeaderView.ResizeMode.Stretch) + tbl.verticalHeader().setVisible(False) + tbl.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + tbl.setSelectionMode(QTableWidget.SelectionMode.NoSelection) + self._ov_table = tbl + self._slit_group = QButtonGroup(self) - for num, label in self._SLIT_LABELS.items(): - btn = QRadioButton(str(num)) - btn.setToolTip(label) - btn.setChecked(num == self.slit) - self._slit_group.addButton(btn, num) - layout.addWidget(btn) - layout.addStretch() - self._slit_group.idClicked.connect(self._on_slit_selected) - return box - def _build_center_cluster(self) -> QGroupBox: - box = QGroupBox("Center (C)") - grid = QGridLayout(box) - grid.setSpacing(4) + for row, (slit_num, label) in enumerate(self._SLIT_LABELS.items()): + container = QWidget() + cl = QHBoxLayout(container) + cl.setContentsMargins(2, 0, 2, 0) + cl.setAlignment(Qt.AlignmentFlag.AlignCenter) + radio = QRadioButton() + radio.setChecked(slit_num == self.slit) + self._slit_group.addButton(radio, slit_num) + cl.addWidget(radio) + tbl.setCellWidget(row, 0, container) - lbl = QLabel("C") - lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + name_item = QTableWidgetItem(label) + name_item.setFlags(Qt.ItemFlag.ItemIsEnabled) + tbl.setItem(row, 1, name_item) - btn_up = QPushButton(_UP) - btn_down = QPushButton(_DOWN) - btn_left = QPushButton(_LEFT) - btn_right = QPushButton(_RIGHT) - - btn_up.clicked.connect(lambda: self.move_center("up")) - 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")) - - grid.addWidget(btn_up, 0, 1) - grid.addWidget(btn_left, 1, 0) - grid.addWidget(lbl, 1, 1) - grid.addWidget(btn_right, 1, 2) - grid.addWidget(btn_down, 2, 1) + for col_off in range(4): + item = QTableWidgetItem("---") + item.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + item.setFlags(Qt.ItemFlag.ItemIsEnabled) + tbl.setItem(row, col_off + 2, item) + self._ov_items[(row, col_off + 2)] = item + self._slit_group.idClicked.connect(self.set_slit) + self._update_row_bold() + vbox.addWidget(tbl) return box def _build_size_cluster(self) -> QGroupBox: + """Size cluster on the LEFT (s/f/e/x keys are on the left of a keyboard).""" box = QGroupBox("Size (S)") grid = QGridLayout(box) grid.setSpacing(4) @@ -158,16 +227,17 @@ class SlitControlWidget(BECWidget, QWidget): lbl = QLabel("S") lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) - # top = grow y, bottom = shrink y, left = shrink x, right = grow x btn_grow_y = QPushButton(_GROW_Y) btn_shrink_y = QPushButton(_SHRINK_Y) btn_shrink_x = QPushButton(_SHRINK_X) btn_grow_x = QPushButton(_GROW_X) - btn_grow_y.setToolTip("Increase y size") - btn_shrink_y.setToolTip("Decrease y size") - btn_shrink_x.setToolTip("Decrease x size") - btn_grow_x.setToolTip("Increase x size") + btn_grow_y.setMinimumHeight(_Y_BTN_MIN_H) + btn_shrink_y.setMinimumHeight(_Y_BTN_MIN_H) + btn_grow_y.setToolTip("Increase y size [E]") + btn_shrink_y.setToolTip("Decrease y size [X]") + btn_shrink_x.setToolTip("Decrease x size [S]") + btn_grow_x.setToolTip("Increase x size [F]") btn_grow_y.clicked.connect(lambda: self.move_size("up")) btn_shrink_y.clicked.connect(lambda: self.move_size("down")) @@ -182,131 +252,193 @@ class SlitControlWidget(BECWidget, QWidget): return box + def _build_center_cluster(self) -> QGroupBox: + """Center cluster on the RIGHT (arrow keys are on the right of a keyboard).""" + box = QGroupBox("Center (C)") + grid = QGridLayout(box) + grid.setSpacing(4) + + lbl = QLabel("C") + lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + + btn_up = QPushButton(_UP) + btn_down = QPushButton(_DOWN) + btn_left = QPushButton(_LEFT) + btn_right = QPushButton(_RIGHT) + + btn_up.setToolTip("Center up [↑]") + btn_down.setToolTip("Center down [↓]") + btn_left.setToolTip("Center left [←]") + btn_right.setToolTip("Center right [→]") + + btn_up.clicked.connect(lambda: self.move_center("up")) + 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")) + + grid.addWidget(btn_up, 0, 1) + grid.addWidget(btn_left, 1, 0) + grid.addWidget(lbl, 1, 1) + grid.addWidget(btn_right, 1, 2) + grid.addWidget(btn_down, 2, 1) + + return box + def _build_step_control(self) -> QGroupBox: box = QGroupBox("Step size (mm)") layout = QHBoxLayout(box) + btn_double = QPushButton("\u00d7 2") self._step_spin = QDoubleSpinBox() self._step_spin.setDecimals(4) self._step_spin.setRange(self._STEP_MIN, self._STEP_MAX) self._step_spin.setSingleStep(0.005) self._step_spin.setValue(self.step) - self._step_spin.valueChanged.connect(self._on_step_spinbox_changed) - + self._step_spin.valueChanged.connect(self._on_step_changed) btn_half = QPushButton("\u00f7 2") - btn_double = QPushButton("\u00d7 2") - btn_half.clicked.connect(self.halve_step) - btn_double.clicked.connect(self.double_step) + btn_double.setToolTip("Double step [.]") + btn_half.setToolTip("Halve step [,]") + btn_double.clicked.connect(self.double_step) + btn_half.clicked.connect(self.halve_step) + + layout.addWidget(btn_double) layout.addWidget(self._step_spin) layout.addWidget(btn_half) - layout.addWidget(btn_double) return box - def _build_readback_table(self) -> QGroupBox: - box = QGroupBox("Current slit dimensions (mm)") + def _build_kb_legend(self) -> QGroupBox: + box = QGroupBox("\u2328 Keyboard tweaking active — press Esc to exit") grid = QGridLayout(box) + grid.setSpacing(2) + grid.setColumnMinimumWidth(1, 20) - for col, text in enumerate(("", "Center", "Size"), start=0): - lbl = QLabel(f"{text}") - lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) - grid.addWidget(lbl, 0, col) - - grid.addWidget(QLabel("Horizontal"), 1, 0) - self._lbl_xc = QLabel("---") - self._lbl_xs = QLabel("---") - self._lbl_xc.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._lbl_xs.setAlignment(Qt.AlignmentFlag.AlignCenter) - grid.addWidget(self._lbl_xc, 1, 1) - grid.addWidget(self._lbl_xs, 1, 2) - - grid.addWidget(QLabel("Vertical"), 2, 0) - self._lbl_yc = QLabel("---") - self._lbl_ys = QLabel("---") - self._lbl_yc.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._lbl_ys.setAlignment(Qt.AlignmentFlag.AlignCenter) - grid.addWidget(self._lbl_yc, 2, 1) - grid.addWidget(self._lbl_ys, 2, 2) + rows = [ + ("Slit:", "1 2 3 4 5 6"), + ("Center:", "\u2190 \u2192 \u2191 \u2193 (arrow keys)"), + ("Size x:", "S smaller F wider"), + ("Size y:", "X smaller E wider"), + ("Step:", ", halve . double"), + ] + for r, (label, desc) in enumerate(rows): + lbl = QLabel(f"{label}") + desc_lbl = QLabel(desc) + grid.addWidget(lbl, r, 0, Qt.AlignmentFlag.AlignRight) + grid.addWidget(desc_lbl, r, 2) return box - # ── device naming ──────────────────────────────────────────────────────── + # ── keyboard shortcuts ──────────────────────────────────────────────────── - def _dev_name(self, suffix: str) -> str: - return f"sl{self.slit}{suffix}" + def _build_shortcuts(self): + """Create all shortcuts once (disabled); toggle enables/disables them.""" + ctx = Qt.ShortcutContext.WidgetWithChildrenShortcut - # ── Redis subscriptions ────────────────────────────────────────────────── + def _sc(key: str, fn) -> QShortcut: + sc = QShortcut(QKeySequence(key), self) + sc.setContext(ctx) + sc.activated.connect(fn) + sc.setEnabled(False) + return sc - def _subscribe_readbacks(self): - for suffix in ("xc", "xs", "yc", "ys"): - name = self._dev_name(suffix) - self.bec_dispatcher.connect_slot( - self.on_readback, MessageEndpoints.device_readback(name) - ) - self._subscribed_devices.append(name) + # slit selection 1-6 + for num in range(1, 7): + self._shortcuts.append(_sc(str(num), lambda n=num: self.set_slit(n))) - def _unsubscribe_readbacks(self): - for name in self._subscribed_devices: - self.bec_dispatcher.disconnect_slot( - self.on_readback, MessageEndpoints.device_readback(name) - ) - self._subscribed_devices.clear() + # center movement (arrow keys) + self._shortcuts.append(_sc("Up", lambda: self.move_center("up"))) + self._shortcuts.append(_sc("Down", lambda: self.move_center("down"))) + self._shortcuts.append(_sc("Left", lambda: self.move_center("left"))) + self._shortcuts.append(_sc("Right", lambda: self.move_center("right"))) - @SafeSlot(dict, dict) - def on_readback(self, data: dict, meta: dict): - signals = data.get("signals", {}) - for dev_name, sig in signals.items(): - value = sig.get("value") - if value is not None: - self._update_label(dev_name, value) + # size keys + self._shortcuts.append(_sc("S", lambda: self.move_size("left"))) # x smaller + self._shortcuts.append(_sc("F", lambda: self.move_size("right"))) # x wider + self._shortcuts.append(_sc("X", lambda: self.move_size("down"))) # y smaller + self._shortcuts.append(_sc("E", lambda: self.move_size("up"))) # y wider - def _update_label(self, dev_name: str, value: float): - mapping = { - self._dev_name("xc"): self._lbl_xc, - self._dev_name("xs"): self._lbl_xs, - self._dev_name("yc"): self._lbl_yc, - self._dev_name("ys"): self._lbl_ys, - } - label = mapping.get(dev_name) - if label is not None: - label.setText(f"{value:.4f}") + # step + self._shortcuts.append(_sc(",", self.halve_step)) + self._shortcuts.append(_sc(".", self.double_step)) - def _refresh_labels(self): - """One-shot device.read() so the table is populated immediately after a slit switch.""" - for suffix, label in ( - ("xc", self._lbl_xc), - ("xs", self._lbl_xs), - ("yc", self._lbl_yc), - ("ys", self._lbl_ys), - ): - dev_name = self._dev_name(suffix) + # escape to exit keyboard mode + self._shortcuts.append(_sc("Escape", lambda: self._btn_kb.setChecked(False))) + + def _on_kb_toggled(self, checked: bool): + for sc in self._shortcuts: + sc.setEnabled(checked) + self._kb_legend.setVisible(checked) + if checked: + self._btn_kb.setText("\u2328 Keyboard tweaking ON") + self.setFocus() # grab focus so shortcuts fire immediately + else: + self._btn_kb.setText("\u2328 Keyboard tweaking") + + # ── 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"]) - label.setText(f"{value:.4f}") + item.setText(f"{value:.4f}") except Exception: - label.setText("---") + item.setText("---") - # ── RPC-exposed public API ──────────────────────────────────────────────── + 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) + + # advance through the other slits one per tick + 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._slow_poll_idx += 1 + + def _update_row_bold(self): + for row, (slit_num, _) in enumerate(self._SLIT_LABELS.items()): + item = self._ov_table.item(row, 1) + if item is None: + continue + font = item.font() + font.setBold(slit_num == self.slit) + item.setFont(font) + + # ── device naming ───────────────────────────────────────────────────────── + + def _dev_name(self, suffix: str) -> str: + return f"sl{self.slit}{suffix}" + + # ── public API ──────────────────────────────────────────────────────────── @rpc_timeout(20) def set_slit(self, slit: int): """Switch the widget to control a different slit (1–6).""" if slit not in self._SLIT_LABELS: raise ValueError(f"Unknown slit number: {slit}") - if slit == self.slit: - return - self._unsubscribe_readbacks() self.slit = slit btn = self._slit_group.button(slit) if btn is not None: btn.setChecked(True) - self._subscribe_readbacks() - self._refresh_labels() + self._tweaking_lbl.setText(f"Tweaking: {self._SLIT_LABELS[slit]}") + self._update_row_bold() def move_center(self, direction: str): - """Move the slit center by one step. direction: up / down / left / right.""" + """Move the slit center. direction: up / down / left / right.""" axis_sign = { "up": ("yc", 1), "down": ("yc", -1), @@ -319,10 +451,7 @@ class SlitControlWidget(BECWidget, QWidget): self._move_relative(suffix, sign * self.step) def move_size(self, direction: str): - """ - Adjust the slit size by one step. - up / right = grow, down / left = shrink. - """ + """Adjust slit size. up/right = grow, down/left = shrink.""" axis_sign = { "up": ("ys", 1), "down": ("ys", -1), @@ -335,19 +464,17 @@ class SlitControlWidget(BECWidget, QWidget): self._move_relative(suffix, sign * self.step) def _move_relative(self, axis_suffix: str, delta: float): - """Read current position, compute absolute target, set via device proxy.""" dev_name = self._dev_name(axis_suffix) try: device = getattr(self.dev, dev_name) reading = device.read() current = float(reading[dev_name]["value"]) - target = current + delta - device.move(target) + device.move(current + delta) except Exception as exc: logger.warning(f"SlitControlWidget: failed to move {dev_name}: {exc}") def set_step(self, value: float): - """Set the shared step size in mm (clamped to 0.005 – 2.0 mm).""" + """Set step size in mm (clamped to 0.005–2.0 mm).""" value = max(self._STEP_MIN, min(self._STEP_MAX, float(value))) self.step = value self._step_spin.blockSignals(True) @@ -355,24 +482,20 @@ class SlitControlWidget(BECWidget, QWidget): self._step_spin.blockSignals(False) def double_step(self): - """Double the current step size.""" self.set_step(self.step * 2) def halve_step(self): - """Halve the current step size.""" self.set_step(self.step / 2) - # ── internal slots ─────────────────────────────────────────────────────── + # ── internal slots ──────────────────────────────────────────────────────── - def _on_slit_selected(self, slit_id: int): - self.set_slit(slit_id) + def _on_step_changed(self, value: float): + self.step = max(self._STEP_MIN, min(self._STEP_MAX, value)) - def _on_step_spinbox_changed(self, value: float): - clamped = max(self._STEP_MIN, min(self._STEP_MAX, value)) - self.step = clamped - - # ── cleanup ────────────────────────────────────────────────────────────── + # ── cleanup ─────────────────────────────────────────────────────────────── def cleanup(self): - self._unsubscribe_readbacks() + self._poll_timer.stop() + for sc in self._shortcuts: + sc.setEnabled(False) super().cleanup() \ No newline at end of file diff --git a/csaxs_bec/bec_widgets/widgets/tomo_parameters/__init__.py b/csaxs_bec/bec_widgets/widgets/tomo_parameters/__init__.py deleted file mode 100644 index 6807c87..0000000 --- a/csaxs_bec/bec_widgets/widgets/tomo_parameters/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import TomoParamsWidget - -__all__ = ["TomoParamsWidget"] diff --git a/csaxs_bec/bec_widgets/widgets/tomo_parameters/register_tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_parameters/register_tomo_params.py deleted file mode 100644 index dfd1190..0000000 --- a/csaxs_bec/bec_widgets/widgets/tomo_parameters/register_tomo_params.py +++ /dev/null @@ -1,15 +0,0 @@ -def main(): # pragma: no cover - from qtpy import PYSIDE6 - - if not PYSIDE6: - print("PYSIDE6 is not available in the environment. Cannot patch designer.") - return - from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection - - from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params_plugin import TomoParamsPlugin - - QPyDesignerCustomWidgetCollection.addCustomWidget(TomoParamsPlugin()) - - -if __name__ == "__main__": # pragma: no cover - main() diff --git a/csaxs_bec/bec_widgets/widgets/tomo_parameters/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_parameters/tomo_params.py deleted file mode 100644 index 5a2f789..0000000 --- a/csaxs_bec/bec_widgets/widgets/tomo_parameters/tomo_params.py +++ /dev/null @@ -1,901 +0,0 @@ -""" -TomoParamsWidget – BEC widget for editing FlOMNI tomo scan parameters and -managing the tomo scan queue. - -Parameters are stored as BEC global vars; this widget reads and writes them -via ``self.client.get_global_var`` / ``self.client.set_global_var``, which -is the same mechanism used by the ``Flomni`` CLI object. - -Polling -------- -BEC does not publish push notifications for global-var changes, so a -``QTimer`` polls ``tomo_queue`` and ``tomo_progress`` every 2 s to stay in -sync with changes made in a parallel CLI session. The parameter panel is -refreshed on demand (when the user opens it or submits changes) rather than -on every poll, to avoid clobbering in-progress edits. - -Queue execution ---------------- -``tomo_queue_execute()`` is a blocking Flomni CLI method that cannot be -called directly from the GUI server process. This widget manages the queue -via direct global-var manipulation (add / delete / clear) and shows an -explicit hint for executing it from the CLI. -""" - -from __future__ import annotations - -import datetime -from typing import Any, Optional - -from bec_lib import bec_logger -from bec_widgets import BECWidget, SafeSlot -from qtpy.QtCore import Qt, QTimer -from qtpy.QtWidgets import ( - QCheckBox, - QComboBox, - QDoubleSpinBox, - QFormLayout, - QFrame, - QGroupBox, - QHBoxLayout, - QHeaderView, - QInputDialog, - QLabel, - QLineEdit, - QMessageBox, - QPushButton, - QScrollArea, - QSpinBox, - QSplitter, - QTableWidget, - QTableWidgetItem, - QVBoxLayout, - QWidget, -) - -logger = bec_logger.logger - -# ── constants ──────────────────────────────────────────────────────────────── - -TOMO_TYPES = { - 1: "8 equally spaced sub-tomograms", - 2: "Golden ratio (sorted in bunches)", - 3: "Equally spaced, golden ratio start", -} - -STATUS_COLORS = { - "pending": "#888888", - "running": "#2196F3", - "incomplete": "#FF9800", - "done": "#4CAF50", -} - -# Exact tuple from Flomni._TOMO_QUEUE_PARAM_NAMES (source of truth in flomni.py) -QUEUE_PARAM_NAMES = ( - "tomo_countingtime", - "tomo_shellstep", - "fovx", - "fovy", - "stitch_x", - "stitch_y", - "tomo_stitch_overlap", - "ptycho_reconstruct_foldername", - "manual_shift_y", - "frames_per_trigger", - "single_point_instead_of_fermat_scan", - "tomo_type", - "tomo_angle_range", - "tomo_angle_stepsize", - "golden_ratio_bunch_size", - "golden_max_number_of_projections", - "golden_projections_at_0_deg_for_damage_estimation", - "zero_deg_reference_at_each_subtomo", - "corridor_size", -) - -DEFAULTS: dict[str, Any] = { - "tomo_countingtime": 0.1, - "tomo_shellstep": 1.0, - "fovx": 20.0, - "fovy": 20.0, - "stitch_x": 0, - "stitch_y": 0, - "tomo_stitch_overlap": 0.2, - "ptycho_reconstruct_foldername": "ptycho_reconstruct", - "manual_shift_y": 0.0, - "frames_per_trigger": 1, - "single_point_instead_of_fermat_scan": False, - "tomo_type": 1, - "corridor_size": -1.0, - "tomo_angle_range": 180, - "tomo_angle_stepsize": 10.0, - "zero_deg_reference_at_each_subtomo": False, - "golden_ratio_bunch_size": 20, - "golden_max_number_of_projections": 1000.0, - "golden_projections_at_0_deg_for_damage_estimation": 0, -} - -PROGRESS_DEFAULTS: dict[str, Any] = { - "subtomo": 0, - "subtomo_projection": 0, - "subtomo_total_projections": 1, - "projection": 0, - "total_projections": 1, - "angle": 0.0, - "tomo_type": 0, - "tomo_start_time": None, - "estimated_remaining_time": None, - "estimated_finish_time": None, - "heartbeat": None, - "accumulated_idle_time": 0.0, -} - - -def _hline() -> QFrame: - line = QFrame() - line.setFrameShape(QFrame.Shape.HLine) - line.setFrameShadow(QFrame.Shadow.Sunken) - return line - - -# ── main widget ────────────────────────────────────────────────────────────── - - -class TomoParamsWidget(BECWidget, QWidget): - """ - Interactive GUI for FlOMNI tomo scan parameter editing and queue management. - - Layout - ------ - Top half (scrollable): - - Read-only sample-name header - - Tomo-type dropdown (always visible) - - Common parameters (always visible) - - Type-1 section: angle range, projected total, 0° reference - - Type-2/3 section: golden ratio parameters - - Edit / Submit / Cancel buttons - - Bottom half (splitter): - - Queue table: all jobs with status, type, FOV, added_at - - Queue action buttons: Add, Delete selected, Clear, Execute hint - - Progress panel: live tomo_progress fields, polled every 2 s - """ - - PLUGIN = True - - USER_ACCESS = [ - "refresh", - "enter_edit_mode", - "cancel_edit", - "submit_params", - "add_to_queue", - ] - - _POLL_INTERVAL_MS = 2000 - - def __init__(self, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) - self.get_bec_shortcuts() - self._edit_mode = False - # widget refs populated by _build_params_panel - self._pw: dict[str, QWidget] = {} # key -> input widget - self._build_ui() - # polling timer – does NOT refresh params (avoid overwriting edits) - self._poll_timer = QTimer(self) - self._poll_timer.setInterval(self._POLL_INTERVAL_MS) - self._poll_timer.timeout.connect(self._on_poll) - self._poll_timer.start() - # initial load - self.refresh() - - # ── global-var I/O ─────────────────────────────────────────────────────── - - def _gv_get(self, key: str) -> Any: - try: - return self.client.get_global_var(key) - except Exception as exc: - logger.warning(f"TomoParamsWidget: get_global_var({key!r}) failed: {exc}") - return None - - def _gv_set(self, key: str, value: Any) -> bool: - try: - self.client.set_global_var(key, value) - return True - except Exception as exc: - logger.warning(f"TomoParamsWidget: set_global_var({key!r}) failed: {exc}") - return False - - def _load_params(self) -> dict[str, Any]: - params = {} - for key in QUEUE_PARAM_NAMES: - val = self._gv_get(key) - params[key] = val if val is not None else DEFAULTS.get(key) - return params - - def _load_queue(self) -> list[dict]: - val = self._gv_get("tomo_queue") - if not isinstance(val, list): - return [] - return val - - def _save_queue(self, jobs: list[dict]) -> None: - self._gv_set("tomo_queue", jobs) - - def _load_progress(self) -> dict[str, Any]: - val = self._gv_get("tomo_progress") - if not isinstance(val, dict): - return dict(PROGRESS_DEFAULTS) - return {**PROGRESS_DEFAULTS, **val} - - # ── UI construction ─────────────────────────────────────────────────────── - - def _build_ui(self): - root = QVBoxLayout(self) - root.setSpacing(6) - - splitter = QSplitter(Qt.Orientation.Vertical) - - # ── top: params panel (scrollable) ─────────────────────────────────── - params_scroll = QScrollArea() - params_scroll.setWidgetResizable(True) - params_inner = QWidget() - params_vbox = QVBoxLayout(params_inner) - params_vbox.setSpacing(6) - - params_vbox.addWidget(self._build_header()) - params_vbox.addWidget(_hline()) - params_vbox.addWidget(self._build_params_panel()) - params_vbox.addLayout(self._build_edit_buttons()) - params_vbox.addStretch() - - params_scroll.setWidget(params_inner) - splitter.addWidget(params_scroll) - - # ── bottom: queue + progress ────────────────────────────────────────── - bottom = QWidget() - bottom_vbox = QVBoxLayout(bottom) - bottom_vbox.setSpacing(6) - bottom_vbox.addWidget(self._build_queue_panel()) - bottom_vbox.addWidget(self._build_progress_panel()) - splitter.addWidget(bottom) - - splitter.setSizes([480, 300]) - root.addWidget(splitter) - - def _build_header(self) -> QGroupBox: - box = QGroupBox("Sample") - layout = QHBoxLayout(box) - self._lbl_sample = QLabel("---") - layout.addWidget(QLabel("Name:")) - layout.addWidget(self._lbl_sample) - layout.addStretch() - return box - - def _build_params_panel(self) -> QGroupBox: - box = QGroupBox("Tomo parameters") - vbox = QVBoxLayout(box) - - # tomo_type selector (always visible, drives section visibility) - type_row = QHBoxLayout() - type_row.addWidget(QLabel("Tomo type:")) - self._pw["tomo_type"] = QComboBox() - for tid, label in TOMO_TYPES.items(): - self._pw["tomo_type"].addItem(f"{tid} – {label}", tid) - self._pw["tomo_type"].currentIndexChanged.connect(self._on_type_changed) - type_row.addWidget(self._pw["tomo_type"], stretch=1) - vbox.addLayout(type_row) - vbox.addWidget(_hline()) - - # common parameters - common_form = QFormLayout() - common_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight) - self._add_double( - common_form, - "tomo_countingtime", - "Counting time (s)", - min_=0.001, - max_=100.0, - decimals=3, - ) - self._add_double( - common_form, "tomo_shellstep", "Shell step (µm)", min_=0.001, max_=1000.0, decimals=3 - ) - self._add_double(common_form, "fovx", "FOV x (µm)", min_=0.1, max_=200.0, decimals=2) - self._add_double(common_form, "fovy", "FOV y (µm)", min_=0.1, max_=100.0, decimals=2) - self._add_int(common_form, "stitch_x", "Stitch x", min_=0, max_=50) - self._add_int(common_form, "stitch_y", "Stitch y", min_=0, max_=50) - self._add_double( - common_form, - "tomo_stitch_overlap", - "Stitch overlap (µm)", - min_=0.0, - max_=50.0, - decimals=3, - ) - self._add_text(common_form, "ptycho_reconstruct_foldername", "Reconstruct folder") - self._add_double( - common_form, - "manual_shift_y", - "Manual shift y (µm)", - min_=-1000.0, - max_=1000.0, - 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) - - # type-1 section - self._sec_type1 = self._build_type1_section() - vbox.addWidget(self._sec_type1) - - # type-2/3 section - self._sec_type23 = self._build_type23_section() - vbox.addWidget(self._sec_type23) - - return box - - def _build_type1_section(self) -> QGroupBox: - box = QGroupBox("Type 1 – sub-tomogram settings") - form = QFormLayout(box) - form.setLabelAlignment(Qt.AlignmentFlag.AlignRight) - - # angle range combo - self._pw["tomo_angle_range"] = QComboBox() - for v in (180, 360): - self._pw["tomo_angle_range"].addItem(f"{v}°", v) - self._pw["tomo_angle_range"].currentIndexChanged.connect(self._update_projection_preview) - form.addRow("Angle range:", self._pw["tomo_angle_range"]) - - # requested total projections (user entry in edit mode) - self._pw["_requested_total"] = QSpinBox() - self._pw["_requested_total"].setRange(8, 10000) - self._pw["_requested_total"].setSingleStep(8) - self._pw["_requested_total"].setValue(144) - self._pw["_requested_total"].valueChanged.connect(self._update_projection_preview) - form.addRow("Requested total projections:", self._pw["_requested_total"]) - - # read-only preview labels - self._lbl_actual_total = QLabel("---") - self._lbl_achievable_step = QLabel("---") - form.addRow("Actual achievable total:", self._lbl_actual_total) - form.addRow("Angular step (°):", self._lbl_achievable_step) - - self._add_bool(form, "zero_deg_reference_at_each_subtomo", "0° reference each sub-tomo") - return box - - def _build_type23_section(self) -> QGroupBox: - box = QGroupBox("Type 2 / 3 – golden ratio settings") - form = QFormLayout(box) - form.setLabelAlignment(Qt.AlignmentFlag.AlignRight) - - self._add_int( - form, "golden_ratio_bunch_size", "Bunch size (type 2 only)", min_=1, max_=10000 - ) - self._add_double( - form, - "golden_max_number_of_projections", - "Max projections (0 = ∞)", - min_=0.0, - max_=1e6, - decimals=0, - ) - self._add_bool( - form, - "golden_projections_at_0_deg_for_damage_estimation", - "0° projections for damage estimation", - ) - return box - - def _build_edit_buttons(self) -> QHBoxLayout: - layout = QHBoxLayout() - self._btn_edit = QPushButton("Edit") - self._btn_submit = QPushButton("Submit") - self._btn_cancel = QPushButton("Cancel") - self._btn_submit.setVisible(False) - self._btn_cancel.setVisible(False) - self._btn_edit.clicked.connect(self.enter_edit_mode) - self._btn_submit.clicked.connect(self.submit_params) - self._btn_cancel.clicked.connect(self.cancel_edit) - layout.addStretch() - layout.addWidget(self._btn_edit) - layout.addWidget(self._btn_submit) - layout.addWidget(self._btn_cancel) - return layout - - def _build_queue_panel(self) -> QGroupBox: - box = QGroupBox("Tomo queue") - vbox = QVBoxLayout(box) - - self._queue_table = QTableWidget(0, 6) - self._queue_table.setHorizontalHeaderLabels( - ["#", "Label", "Status", "Type", "FOV x×y (µm)", "Added at"] - ) - self._queue_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) - self._queue_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) - self._queue_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) - vbox.addWidget(self._queue_table) - - btn_row = QHBoxLayout() - btn_add = QPushButton("Add current params to queue") - btn_del = QPushButton("Delete selected") - btn_clr = QPushButton("Clear all") - btn_exe = QPushButton("Execute queue…") - btn_add.clicked.connect(self.add_to_queue) - btn_del.clicked.connect(self._delete_selected_jobs) - btn_clr.clicked.connect(self._clear_queue) - btn_exe.clicked.connect(self._show_execute_hint) - btn_row.addWidget(btn_add) - btn_row.addWidget(btn_del) - btn_row.addWidget(btn_clr) - btn_row.addStretch() - btn_row.addWidget(btn_exe) - vbox.addLayout(btn_row) - return box - - def _build_progress_panel(self) -> QGroupBox: - box = QGroupBox("Scan progress") - form = QFormLayout(box) - form.setLabelAlignment(Qt.AlignmentFlag.AlignRight) - self._prog_labels: dict[str, QLabel] = {} - fields = [ - ("tomo_type", "Tomo type"), - ("projection", "Projection"), - ("total_projections", "Total projections"), - ("subtomo", "Sub-tomo"), - ("subtomo_projection", "Sub-tomo projection"), - ("angle", "Angle (°)"), - ("estimated_remaining_time", "ETA remaining"), - ("estimated_finish_time", "ETA finish"), - ("accumulated_idle_time", "Idle time (s)"), - ] - for key, label in fields: - lbl = QLabel("---") - self._prog_labels[key] = lbl - form.addRow(f"{label}:", lbl) - return box - - # ── widget factory helpers ──────────────────────────────────────────────── - - def _add_double( - self, form: QFormLayout, key: str, label: str, min_: float, max_: float, decimals: int - ) -> QDoubleSpinBox: - w = QDoubleSpinBox() - w.setDecimals(decimals) - w.setRange(min_, max_) - w.setEnabled(False) - self._pw[key] = w - form.addRow(f"{label}:", w) - return w - - def _add_int(self, form: QFormLayout, key: str, label: str, min_: int, max_: int) -> QSpinBox: - w = QSpinBox() - w.setRange(min_, max_) - w.setEnabled(False) - self._pw[key] = w - form.addRow(f"{label}:", w) - return w - - def _add_text(self, form: QFormLayout, key: str, label: str) -> QLineEdit: - w = QLineEdit() - w.setEnabled(False) - self._pw[key] = w - form.addRow(f"{label}:", w) - return w - - def _add_bool(self, form: QFormLayout, key: str, label: str) -> QCheckBox: - w = QCheckBox() - w.setEnabled(False) - self._pw[key] = w - form.addRow(f"{label}:", w) - return w - - # ── populate / read field values ───────────────────────────────────────── - - def _populate_fields(self, params: dict[str, Any]) -> None: - """Fill all input widgets from a params dict (does not enable them).""" - type_val = int(params.get("tomo_type", 1)) - idx = self._pw["tomo_type"].findData(type_val) - if idx >= 0: - self._pw["tomo_type"].blockSignals(True) - self._pw["tomo_type"].setCurrentIndex(idx) - self._pw["tomo_type"].blockSignals(False) - - for key, widget in self._pw.items(): - if key in ("tomo_type", "_requested_total"): - continue - val = params.get(key) - if val is None: - continue - if isinstance(widget, QDoubleSpinBox): - widget.setValue(float(val)) - elif isinstance(widget, QSpinBox): - widget.setValue(int(val)) - elif isinstance(widget, QLineEdit): - widget.setText(str(val)) - elif isinstance(widget, QCheckBox): - widget.setChecked(bool(val)) - elif isinstance(widget, QComboBox): - idx2 = widget.findData(int(val)) - if idx2 >= 0: - widget.setCurrentIndex(idx2) - - # derive requested_total from stored tomo_angle_stepsize - angle_range = int(params.get("tomo_angle_range", 180)) - stepsize = float(params.get("tomo_angle_stepsize", 10.0)) - actual_total, _, _ = _compute_type1(angle_range, stepsize) - self._pw["_requested_total"].blockSignals(True) - self._pw["_requested_total"].setValue(actual_total) - self._pw["_requested_total"].blockSignals(False) - - self._update_projection_preview() - self._update_type_visibility(type_val) - - def _read_fields(self) -> dict[str, Any]: - """Read all input widgets into a params dict.""" - params: dict[str, Any] = {} - - params["tomo_type"] = self._pw["tomo_type"].currentData() - - for key, widget in self._pw.items(): - if key in ("tomo_type", "_requested_total"): - continue - if isinstance(widget, QDoubleSpinBox): - params[key] = widget.value() - elif isinstance(widget, QSpinBox): - params[key] = widget.value() - elif isinstance(widget, QLineEdit): - params[key] = widget.text().strip() - elif isinstance(widget, QCheckBox): - params[key] = widget.isChecked() - elif isinstance(widget, QComboBox): - params[key] = widget.currentData() - - # derive tomo_angle_stepsize from requested_total + angle_range - angle_range = int(params.get("tomo_angle_range", 180)) - requested = self._pw["_requested_total"].value() - stepsize = _requested_to_stepsize(angle_range, requested) - params["tomo_angle_stepsize"] = stepsize - - # single_point forces stitch to 0 - if params.get("single_point_instead_of_fermat_scan"): - params["stitch_x"] = 0 - params["stitch_y"] = 0 - - return params - - # ── validation ──────────────────────────────────────────────────────────── - - def _validate(self, params: dict[str, Any]) -> Optional[str]: - if params.get("fovx", 0) > 200: - return "fovx must be ≤ 200 µm" - if params.get("fovy", 0) > 100: - return "fovy must be ≤ 100 µm" - if not isinstance(params.get("stitch_x"), int): - return "stitch_x must be an integer" - if not isinstance(params.get("stitch_y"), int): - return "stitch_y must be an integer" - fpt = params.get("frames_per_trigger", 1) - if not isinstance(fpt, int) or fpt < 1 or isinstance(fpt, bool): - return "frames_per_trigger must be a positive integer" - if params.get("tomo_type") not in (1, 2, 3): - return "tomo_type must be 1, 2, or 3" - if params.get("tomo_angle_range") not in (180, 360): - return "tomo_angle_range must be 180 or 360" - return None - - # ── type visibility ─────────────────────────────────────────────────────── - - def _update_type_visibility(self, tomo_type: int) -> None: - self._sec_type1.setVisible(tomo_type == 1) - self._sec_type23.setVisible(tomo_type in (2, 3)) - # bunch_size is type-2 only; find its row in the form layout - bunch_widget = self._pw.get("golden_ratio_bunch_size") - if bunch_widget is not None: - bunch_widget.setVisible(tomo_type == 2) - # also hide the label in the form layout - form = self._sec_type23.layout() - if isinstance(form, QFormLayout): - idx = form.indexOf(bunch_widget) - if idx >= 0: - row, role = form.getItemPosition(idx) - label_item = form.itemAt(row, QFormLayout.ItemRole.LabelRole) - if label_item and label_item.widget(): - label_item.widget().setVisible(tomo_type == 2) - - # ── type-1 projection preview ───────────────────────────────────────────── - - def _update_projection_preview(self) -> None: - angle_range_widget = self._pw.get("tomo_angle_range") - if angle_range_widget is None: - return - angle_range = int(angle_range_widget.currentData() or 180) - requested = self._pw["_requested_total"].value() - stepsize = _requested_to_stepsize(angle_range, requested) - actual_total, achievable_step, _ = _compute_type1(angle_range, stepsize) - - self._lbl_actual_total.setText(str(actual_total)) - self._lbl_achievable_step.setText(f"{achievable_step:.4f}") - - if actual_total != requested: - self._lbl_actual_total.setStyleSheet("color: orange;") - self._lbl_actual_total.setToolTip( - f"Requested {requested} → actual {actual_total} " - f"(must be divisible into 8 equal sub-tomograms)" - ) - else: - self._lbl_actual_total.setStyleSheet("") - self._lbl_actual_total.setToolTip("") - - # ── slots ───────────────────────────────────────────────────────────────── - - def _on_type_changed(self, _index: int) -> None: - tomo_type = self._pw["tomo_type"].currentData() - self._update_type_visibility(tomo_type) - - def _on_single_point_changed(self, state: int) -> None: - checked = bool(state) - if checked: - self._pw["stitch_x"].setValue(0) - self._pw["stitch_y"].setValue(0) - self._pw["stitch_x"].setEnabled(self._edit_mode and not checked) - self._pw["stitch_y"].setEnabled(self._edit_mode and not checked) - - def _on_poll(self) -> None: - """Periodic refresh: queue + progress, but NOT params (avoid edit clobber).""" - self._refresh_queue() - self._refresh_progress() - # refresh sample name (cheap) - self._refresh_sample_name() - - # ── public refresh API ──────────────────────────────────────────────────── - - def refresh(self) -> None: - """Full refresh: params, queue, progress, sample name.""" - self._refresh_params() - self._refresh_queue() - self._refresh_progress() - self._refresh_sample_name() - - def _refresh_sample_name(self) -> None: - # sample_name is stored as a device readback, not a global var; - # best-effort read from dev if available, else leave as-is. - try: - device = getattr(self.dev, "flomni_samples", None) - if device is not None: - reading = device.read() - # pick up whatever field is the active sample name - val = next(iter(reading.values()), {}).get("value", "---") - self._lbl_sample.setText(str(val)) - except Exception: - pass - - def _refresh_params(self) -> None: - if self._edit_mode: - return - params = self._load_params() - self._populate_fields(params) - - def _refresh_queue(self) -> None: - jobs = self._load_queue() - table = self._queue_table - table.setRowCount(len(jobs)) - for row, job in enumerate(jobs): - params = job.get("params", {}) - status = job.get("status", "pending") - color = STATUS_COLORS.get(status, "#888888") - - cells = [ - str(row), - job.get("label", f"job_{row}"), - status, - str(params.get("tomo_type", "?")), - f"{params.get('fovx', '?')} × {params.get('fovy', '?')}", - job.get("added_at", ""), - ] - for col, text in enumerate(cells): - item = QTableWidgetItem(text) - item.setForeground(table.palette().text() if col != 2 else _color_from_hex(color)) - table.setItem(row, col, item) - - def _refresh_progress(self) -> None: - prog = self._load_progress() - for key, lbl in self._prog_labels.items(): - val = prog.get(key) - if val is None: - lbl.setText("---") - elif isinstance(val, float): - lbl.setText(f"{val:.2f}") - else: - lbl.setText(str(val)) - - # ── edit-mode workflow ──────────────────────────────────────────────────── - - def enter_edit_mode(self) -> None: - """Unlock all parameter fields for editing.""" - # Refresh first so fields reflect latest backend state - params = self._load_params() - self._populate_fields(params) - self._set_fields_enabled(True) - self._edit_mode = True - self._btn_edit.setVisible(False) - self._btn_submit.setVisible(True) - self._btn_cancel.setVisible(True) - - def cancel_edit(self) -> None: - """Discard changes and re-lock fields.""" - self._edit_mode = False - self._set_fields_enabled(False) - self._btn_edit.setVisible(True) - self._btn_submit.setVisible(False) - self._btn_cancel.setVisible(False) - # restore from backend - params = self._load_params() - self._populate_fields(params) - - def submit_params(self) -> None: - """Validate, write to global vars, and re-lock fields.""" - params = self._read_fields() - error = self._validate(params) - if error: - QMessageBox.warning(self, "Validation error", error) - return - - # Confirm if actual_total differs from requested (type-1) - if params.get("tomo_type") == 1: - angle_range = int(params.get("tomo_angle_range", 180)) - requested = self._pw["_requested_total"].value() - stepsize = params["tomo_angle_stepsize"] - actual_total, _, _ = _compute_type1(angle_range, stepsize) - if actual_total != requested: - reply = QMessageBox.question( - self, - "Projection count adjusted", - f"Requested {requested} projections → achievable: {actual_total}.\n" - f"(Must be divisible into 8 equal sub-tomograms.)\n\n" - f"Submit with {actual_total} projections?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, - ) - if reply != QMessageBox.StandardButton.Yes: - return - - ok = True - for key, val in params.items(): - if key in QUEUE_PARAM_NAMES: - if not self._gv_set(key, val): - ok = False - - if ok: - self._edit_mode = False - self._set_fields_enabled(False) - self._btn_edit.setVisible(True) - self._btn_submit.setVisible(False) - self._btn_cancel.setVisible(False) - else: - QMessageBox.critical(self, "Error", "One or more parameters could not be written.") - - def _set_fields_enabled(self, enabled: bool) -> None: - skip = {"_requested_total"} if not enabled else set() - for key, widget in self._pw.items(): - if key in skip: - continue - widget.setEnabled(enabled) - # stitch locked when single_point is active - if enabled and self._pw["single_point_instead_of_fermat_scan"].isChecked(): - self._pw["stitch_x"].setEnabled(False) - self._pw["stitch_y"].setEnabled(False) - # projection requested total only meaningful in edit mode - self._pw["_requested_total"].setEnabled(enabled) - - # ── queue management ────────────────────────────────────────────────────── - - def add_to_queue(self) -> None: - """Snapshot the current live global-var parameters and append a new job.""" - label, ok = QInputDialog.getText(self, "Add to queue", "Job label (leave blank for auto):") - if not ok: - return - params = self._load_params() - jobs = self._load_queue() - idx = len(jobs) - if not label.strip(): - label = f"job_{idx}" - job = { - "label": label.strip(), - "params": params, - "status": "pending", - "added_at": datetime.datetime.now().isoformat(timespec="seconds"), - } - jobs.append(job) - self._save_queue(jobs) - self._refresh_queue() - - def _delete_selected_jobs(self) -> None: - rows = sorted({idx.row() for idx in self._queue_table.selectedIndexes()}, reverse=True) - if not rows: - return - reply = QMessageBox.question( - self, - "Delete jobs", - f"Delete {len(rows)} selected job(s)?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, - ) - if reply != QMessageBox.StandardButton.Yes: - return - jobs = self._load_queue() - for row in rows: - if row < len(jobs): - jobs.pop(row) - self._save_queue(jobs) - self._refresh_queue() - - def _clear_queue(self) -> None: - reply = QMessageBox.question( - self, - "Clear queue", - "Remove all jobs from the queue?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, - ) - if reply != QMessageBox.StandardButton.Yes: - return - self._save_queue([]) - self._refresh_queue() - - def _show_execute_hint(self) -> None: - jobs = self._load_queue() - pending = sum(1 for j in jobs if j.get("status") in ("pending", "incomplete", "running")) - QMessageBox.information( - self, - "Execute queue", - f"The queue has {len(jobs)} job(s), {pending} pending/resumable.\n\n" - "Queue execution is a blocking CLI operation and must be started from " - "the BEC IPython session:\n\n" - " flomni.tomo_queue_execute()\n\n" - "The queue status will update here automatically once running.", - ) - - # ── cleanup ─────────────────────────────────────────────────────────────── - - def cleanup(self) -> None: - self._poll_timer.stop() - super().cleanup() - - -# ── module-level helpers ───────────────────────────────────────────────────── - - -def _requested_to_stepsize(angle_range: int, requested_total: int) -> float: - """Convert a desired total projection count to tomo_angle_stepsize.""" - if requested_total <= 0: - return float(angle_range) - return (angle_range / requested_total) * 8 - - -def _compute_type1(angle_range: int, stepsize: float) -> tuple[int, float, float]: - """ - Given stored tomo_angle_stepsize, return: - (actual_total, achievable_step, stepsize) - Mirrors the CLI's internal computation exactly. - """ - if stepsize <= 0: - return 0, 0.0, 0.0 - N = int(angle_range / stepsize) - if N <= 0: - return 0, 0.0, 0.0 - achievable_step = angle_range / N - actual_total = N * 8 - return actual_total, achievable_step, stepsize - - -def _color_from_hex(hex_color: str): - from qtpy.QtGui import QColor - - return QColor(hex_color) diff --git a/csaxs_bec/bec_widgets/widgets/tomo_parameters/tomo_params_plugin.py b/csaxs_bec/bec_widgets/widgets/tomo_parameters/tomo_params_plugin.py deleted file mode 100644 index e3a6774..0000000 --- a/csaxs_bec/bec_widgets/widgets/tomo_parameters/tomo_params_plugin.py +++ /dev/null @@ -1,53 +0,0 @@ -from bec_widgets.utils.bec_designer import designer_material_icon -from qtpy.QtDesigner import QDesignerCustomWidgetInterface -from qtpy.QtWidgets import QWidget - -from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import TomoParamsWidget - -DOM_XML = """ - - - - -""" - - -class TomoParamsPlugin(QDesignerCustomWidgetInterface): # pragma: no cover - def __init__(self): - super().__init__() - self._form_editor = None - - def createWidget(self, parent): - if parent is None: - return QWidget() - return TomoParamsWidget(parent) - - def domXml(self): - return DOM_XML - - def group(self): - return "" - - def icon(self): - return designer_material_icon(TomoParamsWidget.ICON_NAME) - - def includeFile(self): - return "tomo_params" - - def initialize(self, form_editor): - self._form_editor = form_editor - - def isContainer(self): - return False - - def isInitialized(self): - return self._form_editor is not None - - def name(self): - return "TomoParamsWidget" - - def toolTip(self): - return "TomoParamsWidget" - - def whatsThis(self): - return self.toolTip()