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()