diff --git a/csaxs_bec/bec_widgets/widgets/client.py b/csaxs_bec/bec_widgets/widgets/client.py index 92d4240..1ec7a87 100644 --- a/csaxs_bec/bec_widgets/widgets/client.py +++ b/csaxs_bec/bec_widgets/widgets/client.py @@ -13,10 +13,91 @@ logger = bec_logger.logger _Widgets = { + "SlitControlWidget": "SlitControlWidget", + "TomoParamsWidget": "TomoParamsWidget", "XRayEye": "XRayEye", } +class SlitControlWidget(RPCBase): + """Interactive GUI for cSAXS slit center and size control.""" + + _IMPORT_MODULE = "csaxs_bec.bec_widgets.widgets.slit_control.slit_control" + + @rpc_timeout(20) + @rpc_call + def set_slit(self, slit: "int"): + """ + Switch the widget to control a different slit (1–6). + """ + + @rpc_call + def move_center(self, direction: "str"): + """ + Move the slit center by one step. direction: up / down / left / right. + """ + + @rpc_call + def move_size(self, direction: "str"): + """ + 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). + """ + + @rpc_call + def double_step(self): + """ + Double the current step size. + """ + + @rpc_call + def halve_step(self): + """ + Halve the current step size. + """ + + +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 3a99fd0..fefec5a 100644 --- a/csaxs_bec/bec_widgets/widgets/designer_plugins.py +++ b/csaxs_bec/bec_widgets/widgets/designer_plugins.py @@ -5,9 +5,19 @@ from __future__ import annotations # pylint: skip-file designer_plugins = { + "SlitControlWidget": ( + "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/__init__.py b/csaxs_bec/bec_widgets/widgets/slit_control/__init__.py new file mode 100644 index 0000000..a83c037 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/slit_control/__init__.py @@ -0,0 +1,3 @@ +from csaxs_bec.bec_widgets.widgets.slit_control.slit_control import SlitControlWidget + +__all__ = ["SlitControlWidget"] diff --git a/csaxs_bec/bec_widgets/widgets/slit_control/register_slit_control.py b/csaxs_bec/bec_widgets/widgets/slit_control/register_slit_control.py new file mode 100644 index 0000000..a119ea0 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/slit_control/register_slit_control.py @@ -0,0 +1,15 @@ +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.slit_control.slit_control_plugin import SlitControlPlugin + + QPyDesignerCustomWidgetCollection.addCustomWidget(SlitControlPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/csaxs_bec/bec_widgets/widgets/slit_control/register_slit_control_widget.py b/csaxs_bec/bec_widgets/widgets/slit_control/register_slit_control_widget.py new file mode 100644 index 0000000..372ad0a --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/slit_control/register_slit_control_widget.py @@ -0,0 +1,15 @@ +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.slit_control.slit_control_widget_plugin import SlitControlWidgetPlugin + + QPyDesignerCustomWidgetCollection.addCustomWidget(SlitControlWidgetPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py b/csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py new file mode 100644 index 0000000..7e2c661 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py @@ -0,0 +1,501 @@ +from __future__ import annotations + +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.QtGui import QKeySequence +from qtpy.QtWidgets import ( + QButtonGroup, + QDoubleSpinBox, + QFrame, + QGridLayout, + QGroupBox, + QHBoxLayout, + QHeaderView, + QLabel, + QPushButton, + QRadioButton, + QShortcut, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +logger = bec_logger.logger + +# ── arrow glyphs ───────────────────────────────────────────────────────────── +_UP = "\u2191" # ↑ +_DOWN = "\u2193" # ↓ +_LEFT = "\u2190" # ← +_RIGHT = "\u2192" # → +_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: + sep = QFrame() + sep.setFrameShape(QFrame.Shape.HLine) + sep.setFrameShadow(QFrame.Shadow.Sunken) + 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 (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). + + 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 + + USER_ACCESS = [ + "set_slit", + "move_center", + "move_size", + "set_step", + "double_step", + "halve_step", + ] + + _SLIT_LABELS: dict[int, str] = { + 1: "Slit 1 (frontend)", + 2: "Slit 2 (optics 1)", + 3: "Slit 3 (optics 2)", + 4: "Slit 4 (EB1 entrance)", + 5: "Slit 5 (EB1 exit)", + 6: "Slit 6 (EB2)", + } + + _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._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._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 ─────────────────────────────────────────────────────── + + def _build_ui(self): + root = QVBoxLayout(self) + root.setSpacing(8) + + # 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_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()) + + # 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 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) + + name_item = QTableWidgetItem(label) + name_item.setFlags(Qt.ItemFlag.ItemIsEnabled) + tbl.setItem(row, 1, name_item) + + 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) + + lbl = QLabel("S") + lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + + 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.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")) + btn_shrink_x.clicked.connect(lambda: self.move_size("left")) + btn_grow_x.clicked.connect(lambda: self.move_size("right")) + + grid.addWidget(btn_grow_y, 0, 1) + grid.addWidget(btn_shrink_x, 1, 0) + grid.addWidget(lbl, 1, 1) + grid.addWidget(btn_grow_x, 1, 2) + grid.addWidget(btn_shrink_y, 2, 1) + + 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_changed) + btn_half = QPushButton("\u00f7 2") + + 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) + return box + + 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) + + 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 + + # ── keyboard shortcuts ──────────────────────────────────────────────────── + + def _build_shortcuts(self): + """Create all shortcuts once (disabled); toggle enables/disables them.""" + ctx = Qt.ShortcutContext.WidgetWithChildrenShortcut + + def _sc(key: str, fn) -> QShortcut: + sc = QShortcut(QKeySequence(key), self) + sc.setContext(ctx) + sc.activated.connect(fn) + sc.setEnabled(False) + return sc + + # slit selection 1-6 + for num in range(1, 7): + self._shortcuts.append(_sc(str(num), lambda n=num: self.set_slit(n))) + + # 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"))) + + # 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 + + # step + self._shortcuts.append(_sc(",", self.halve_step)) + self._shortcuts.append(_sc(".", self.double_step)) + + # 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"]) + item.setText(f"{value:.4f}") + except Exception: + item.setText("---") + + 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}") + self.slit = slit + btn = self._slit_group.button(slit) + if btn is not None: + btn.setChecked(True) + self._tweaking_lbl.setText(f"Tweaking: {self._SLIT_LABELS[slit]}") + self._update_row_bold() + + def move_center(self, direction: str): + """Move the slit center. direction: up / down / left / right.""" + axis_sign = { + "up": ("yc", 1), + "down": ("yc", -1), + "right": ("xc", 1), + "left": ("xc", -1), + } + if direction not in axis_sign: + raise ValueError(f"Unknown direction: {direction!r}") + suffix, sign = axis_sign[direction] + self._move_relative(suffix, sign * self.step) + + def move_size(self, direction: str): + """Adjust slit size. up/right = grow, down/left = shrink.""" + axis_sign = { + "up": ("ys", 1), + "down": ("ys", -1), + "right": ("xs", 1), + "left": ("xs", -1), + } + if direction not in axis_sign: + raise ValueError(f"Unknown direction: {direction!r}") + suffix, sign = axis_sign[direction] + self._move_relative(suffix, sign * self.step) + + def _move_relative(self, axis_suffix: str, delta: float): + dev_name = self._dev_name(axis_suffix) + try: + device = getattr(self.dev, dev_name) + reading = device.read() + current = float(reading[dev_name]["value"]) + 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 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) + self._step_spin.setValue(self.step) + self._step_spin.blockSignals(False) + + def double_step(self): + self.set_step(self.step * 2) + + def halve_step(self): + self.set_step(self.step / 2) + + # ── internal slots ──────────────────────────────────────────────────────── + + def _on_step_changed(self, value: float): + self.step = max(self._STEP_MIN, min(self._STEP_MAX, value)) + + # ── cleanup ─────────────────────────────────────────────────────────────── + + def cleanup(self): + 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/slit_control/slit_control_plugin.py b/csaxs_bec/bec_widgets/widgets/slit_control/slit_control_plugin.py new file mode 100644 index 0000000..c849900 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/slit_control/slit_control_plugin.py @@ -0,0 +1,56 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +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.slit_control.slit_control import SlitControlWidget + +DOM_XML = """ + + + + +""" + + +class SlitControlPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + if parent is None: + return QWidget() + return SlitControlWidget(parent) + + def domXml(self): + return DOM_XML + + def group(self): + return "" + + def icon(self): + return designer_material_icon(SlitControlWidget.ICON_NAME) + + def includeFile(self): + return "slit_control" + + 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 "SlitControlWidget" + + def toolTip(self): + return "SlitControlWidget" + + def whatsThis(self): + return self.toolTip() diff --git a/csaxs_bec/bec_widgets/widgets/slit_control/slit_control_widget.pyproject b/csaxs_bec/bec_widgets/widgets/slit_control/slit_control_widget.pyproject new file mode 100644 index 0000000..4486e4b --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/slit_control/slit_control_widget.pyproject @@ -0,0 +1 @@ +{'files': ['slit_control.py']} \ No newline at end of file diff --git a/csaxs_bec/bec_widgets/widgets/slit_control/slit_control_widget_plugin.py b/csaxs_bec/bec_widgets/widgets/slit_control/slit_control_widget_plugin.py new file mode 100644 index 0000000..8e28c5d --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/slit_control/slit_control_widget_plugin.py @@ -0,0 +1,57 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from qtpy.QtDesigner import QDesignerCustomWidgetInterface +from qtpy.QtWidgets import QWidget + +from bec_widgets.utils.bec_designer import designer_material_icon +from csaxs_bec.bec_widgets.widgets.slit_control.slit_control import SlitControlWidget + +DOM_XML = """ + + + + +""" + + +class SlitControlWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + if parent is None: + return QWidget() + t = SlitControlWidget(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "" + + def icon(self): + return designer_material_icon(SlitControlWidget.ICON_NAME) + + def includeFile(self): + return "slit_control_widget" + + 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 "SlitControlWidget" + + def toolTip(self): + return "" + + def whatsThis(self): + return self.toolTip() diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/__init__.py b/csaxs_bec/bec_widgets/widgets/tomo_params/__init__.py new file mode 100644 index 0000000..6807c87 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/__init__.py @@ -0,0 +1,3 @@ +from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import TomoParamsWidget + +__all__ = ["TomoParamsWidget"] diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/register_tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/register_tomo_params.py new file mode 100644 index 0000000..dfd1190 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/register_tomo_params.py @@ -0,0 +1,15 @@ +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_params/register_tomo_params_widget.py b/csaxs_bec/bec_widgets/widgets/tomo_params/register_tomo_params_widget.py new file mode 100644 index 0000000..3e7497e --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/register_tomo_params_widget.py @@ -0,0 +1,15 @@ +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_widget_plugin import TomoParamsWidgetPlugin + + QPyDesignerCustomWidgetCollection.addCustomWidget(TomoParamsWidgetPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py new file mode 100644 index 0000000..5b46715 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -0,0 +1,942 @@ +""" +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, + QDialog, + QDoubleSpinBox, + QFormLayout, + QFrame, + QGroupBox, + QHBoxLayout, + QHeaderView, + QInputDialog, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QScrollArea, + QSpinBox, + 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, +} + + +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) + + # ── UI construction ─────────────────────────────────────────────────────── + + def _build_ui(self): + root = QVBoxLayout(self) + root.setSpacing(6) + + # scrollable params panel + 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) + root.addWidget(params_scroll) + + self._btn_queue = QPushButton("☰ Queue control…") + self._btn_queue.setToolTip("Open the tomo queue manager") + self._btn_queue.clicked.connect(self._show_queue_dialog) + root.addWidget(self._btn_queue) + self._queue_dlg: Optional["TomoQueueDialog"] = None + + 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 + + # ── 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: params (guarded against edit mode) + sample name.""" + self._refresh_params() + self._refresh_sample_name() + + # ── public refresh API ──────────────────────────────────────────────────── + + def refresh(self) -> None: + """Full refresh: params + sample name.""" + self._refresh_params() + self._refresh_sample_name() + + def _refresh_sample_name(self) -> None: + # Mirror the exact path used by Flomni.sample_get_name(0): + # getattr(dev.flomni_samples.sample_names, "sample0").get() + try: + name_signal = self.dev.flomni_samples.sample_names.sample0 + name = name_signal.get() + self._lbl_sample.setText(str(name) if name else "---") + except Exception: + self._lbl_sample.setText("---") + + 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) + + # ── 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 dialog ────────────────────────────────────────────────────────── + + def add_to_queue(self) -> None: + """Open queue dialog and trigger Add (also accessible via RPC).""" + dlg = self._get_queue_dialog() + dlg.show() + dlg.raise_() + dlg.add_to_queue() + + def _show_queue_dialog(self) -> None: + dlg = self._get_queue_dialog() + dlg.show() + dlg.raise_() + dlg.activateWindow() + + def _get_queue_dialog(self) -> "TomoQueueDialog": + if self._queue_dlg is None: + self._queue_dlg = TomoQueueDialog(self.client, parent=self) + return self._queue_dlg + + # ── cleanup ─────────────────────────────────────────────────────────────── + + def cleanup(self) -> None: + self._poll_timer.stop() + super().cleanup() + + +# ── TomoQueueDialog ─────────────────────────────────────────────────────────── + + +class TomoQueueDialog(QDialog): + """ + Non-modal popup window for managing the FlOMNI tomo scan queue. + + Opened from the main TomoParamsWidget via the "Queue control…" button. + Shares the BEC client with its parent for global-var access. + Polls the queue every 2 s to stay in sync with CLI-driven changes. + """ + + _POLL_MS = 2000 + + def __init__(self, client, parent=None): + super().__init__(parent) + self._client = client + self.setWindowTitle("FlOMNI – Tomo Queue Control") + self.setMinimumSize(720, 360) + self._build_ui() + self._poll_timer = QTimer(self) + self._poll_timer.setInterval(self._POLL_MS) + self._poll_timer.timeout.connect(self._refresh) + self._poll_timer.start() + self._refresh() + + # ── global-var helpers ──────────────────────────────────────────────────── + + def _gv_get(self, key: str): + try: + return self._client.get_global_var(key) + except Exception as exc: + logger.warning(f"TomoQueueDialog: get_global_var({key!r}) failed: {exc}") + return None + + def _gv_set(self, key: str, value) -> bool: + try: + self._client.set_global_var(key, value) + return True + except Exception as exc: + logger.warning(f"TomoQueueDialog: set_global_var({key!r}) failed: {exc}") + return False + + def _load_queue(self) -> list[dict]: + val = self._gv_get("tomo_queue") + return val if isinstance(val, list) else [] + + def _save_queue(self, jobs: list[dict]) -> None: + self._gv_set("tomo_queue", jobs) + + def _load_params(self) -> dict: + 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 + + # ── UI ──────────────────────────────────────────────────────────────────── + + def _build_ui(self): + vbox = QVBoxLayout(self) + vbox.setSpacing(6) + + self._table = QTableWidget(0, 6) + self._table.setHorizontalHeaderLabels( + ["#", "Label", "Status", "Type", "FOV x×y (µm)", "Added at"] + ) + self._table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + vbox.addWidget(self._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) + 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) + + # ── refresh ─────────────────────────────────────────────────────────────── + + def _refresh(self) -> None: + jobs = self._load_queue() + self._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) + if col == 2: + item.setForeground(_color_from_hex(color)) + self._table.setItem(row, col, item) + + # ── queue actions ───────────────────────────────────────────────────────── + + def add_to_queue(self) -> None: + 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() + if not label.strip(): + label = f"job_{len(jobs)}" + jobs.append( + { + "label": label.strip(), + "params": params, + "status": "pending", + "added_at": datetime.datetime.now().isoformat(timespec="seconds"), + } + ) + self._save_queue(jobs) + self._refresh() + + def _delete_selected(self) -> None: + rows = sorted({idx.row() for idx in self._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() + + 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() + + 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"Queue: {len(jobs)} job(s), {pending} pending/resumable.\n\n" + "Execution is a blocking CLI operation — run from the BEC IPython session:\n\n" + " flomni.tomo_queue_execute()\n\n" + "Queue status updates here automatically once running.", + ) + + # ── cleanup ─────────────────────────────────────────────────────────────── + + def closeEvent(self, event): + self._poll_timer.stop() + super().closeEvent(event) + + def showEvent(self, event): + self._poll_timer.start() + self._refresh() + super().showEvent(event) + + +# ── 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) \ No newline at end of file diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params_plugin.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params_plugin.py new file mode 100644 index 0000000..e3a6774 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params_plugin.py @@ -0,0 +1,53 @@ +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() diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params_widget.pyproject b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params_widget.pyproject new file mode 100644 index 0000000..aae051e --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params_widget.pyproject @@ -0,0 +1 @@ +{'files': ['tomo_params.py']} \ No newline at end of file diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params_widget_plugin.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params_widget_plugin.py new file mode 100644 index 0000000..ad26d47 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params_widget_plugin.py @@ -0,0 +1,57 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from qtpy.QtDesigner import QDesignerCustomWidgetInterface +from qtpy.QtWidgets import QWidget + +from bec_widgets.utils.bec_designer import designer_material_icon +from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import TomoParamsWidget + +DOM_XML = """ + + + + +""" + + +class TomoParamsWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + if parent is None: + return QWidget() + t = TomoParamsWidget(parent) + return t + + 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_widget" + + 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 "" + + def whatsThis(self): + return self.toolTip()