From 7676faf3ffe0e1a8ba80a6295585262ea4e390b4 Mon Sep 17 00:00:00 2001 From: x12sa Date: Wed, 1 Jul 2026 11:56:30 +0200 Subject: [PATCH 1/9] added predict gap to csaxs --- .../bec_ipython_client/plugins/cSAXS/cSAXS.py | 102 +++++++++++++++--- .../cSAXS/intensity_map_predict_gap.py | 26 +++++ 2 files changed, 114 insertions(+), 14 deletions(-) create mode 100755 csaxs_bec/bec_ipython_client/plugins/cSAXS/intensity_map_predict_gap.py diff --git a/csaxs_bec/bec_ipython_client/plugins/cSAXS/cSAXS.py b/csaxs_bec/bec_ipython_client/plugins/cSAXS/cSAXS.py index f24d80d..42325de 100644 --- a/csaxs_bec/bec_ipython_client/plugins/cSAXS/cSAXS.py +++ b/csaxs_bec/bec_ipython_client/plugins/cSAXS/cSAXS.py @@ -1,23 +1,21 @@ -# import builtins -# import datetime -# import os -# import subprocess -# import time -# from pathlib import Path +import inspect -# import numpy as np from bec_lib import bec_logger -# from bec_lib.alarm_handler import AlarmBase -# from bec_lib.pdf_writer import PDFWriter from typeguard import typechecked - +from csaxs_bec.bec_ipython_client.plugins.cSAXS.diagnostics import cSAXSDiagnostics +from csaxs_bec.bec_ipython_client.plugins.cSAXS.filter_transmission import cSAXSFilterTransmission +from csaxs_bec.bec_ipython_client.plugins.cSAXS.intensity_map_predict_gap import ( + predict_gap as _predict_gap, +) +from csaxs_bec.bec_ipython_client.plugins.cSAXS.slits import cSAXSSlits from csaxs_bec.bec_ipython_client.plugins.cSAXS.smaract import cSAXSInitSmaractStages from csaxs_bec.bec_ipython_client.plugins.cSAXS.smaract import cSAXSSmaract from csaxs_bec.bec_ipython_client.plugins.omny.omny_general_tools import OMNYTools -from csaxs_bec.bec_ipython_client.plugins.cSAXS.filter_transmission import cSAXSFilterTransmission -from csaxs_bec.bec_ipython_client.plugins.cSAXS.diagnostics import cSAXSDiagnostics -from csaxs_bec.bec_ipython_client.plugins.cSAXS.slits import cSAXSSlits + +logger = bec_logger.logger + + class cSAXSError(Exception): pass @@ -36,6 +34,80 @@ class cSAXS( self.diagnostics = cSAXSDiagnostics() super().__init__(client=client) + # ------------------------------------------------------------------ + # Undulator + # ------------------------------------------------------------------ + + def predict_gap(self, energy: float, n: int = 3) -> None: + """Print the predicted undulator gap for *energy* [keV] on harmonic *n*. + + Examples + -------- + csaxs.predict_gap(6.2) # h=3 (default) + csaxs.predict_gap(10.0, n=5) # explicit harmonic + """ + import math + + gap = float(_predict_gap(energy, n=n)) + if math.isnan(gap): + print(f"Energy {energy:.3f} keV is unreachable on harmonic {n}.") + else: + print(f"Predicted gap for {energy:.3f} keV (h={n}): {gap:.4f} mm") + + # ------------------------------------------------------------------ + # Help / discovery + # ------------------------------------------------------------------ + + def commands(self) -> None: + """Print a table of all available cSAXS commands and sub-namespaces.""" + from rich import box + from rich.console import Console + from rich.table import Table + + console = Console() + + entries: list[tuple[str, str]] = [] + seen: set[str] = set() + + for cls in type(self).__mro__: + if cls is object: + continue + module = getattr(cls, "__module__", "") or "" + if "csaxs_bec" not in module: + continue + for name, func in inspect.getmembers(cls, predicate=inspect.isfunction): + if name.startswith("_") or name in seen: + continue + seen.add(name) + doc = (inspect.getdoc(func) or "").split("\n")[0].strip() + entries.append((name, doc)) + + entries.sort(key=lambda x: x[0]) + + tbl = Table(title="cSAXS Commands", box=box.SQUARE, show_lines=False) + tbl.add_column("Command", style="cyan bold", no_wrap=True, min_width=46) + tbl.add_column("Description") + for name, doc in entries: + tbl.add_row(f"csaxs.{name}()", doc) + console.print(tbl) + console.print("") + + ns = Table(title="Sub-namespaces", box=box.SQUARE, show_lines=False) + ns.add_column("Access", style="cyan bold", no_wrap=True, min_width=46) + ns.add_column("Description") + for access, desc in [ + ("csaxs.diagnostics.show_all()", "All diagnostic device readbacks"), + ( + "csaxs.diagnostics.bpm_xbox1 / .bpm_xbox2", + "BPM diagnostics — .show_all(), .gain(val)", + ), + ("csaxs.diagnostics.bim", "BIM diagnostics — .show_all(), .gain(val)"), + ("csaxs.diagnostics.beamstop", "Beamstop diode — .show_all(), .gain(val)"), + ("csaxs.diagnostics.polarization", "Polarization diodes — .show_all(), .gain(val)"), + ("csaxs.OMNYTools.*", "OMNY instrument tools"), + ]: + ns.add_row(access, desc) + console.print(ns) # this is the csaxs master file that imports all routines from csaxs @@ -45,4 +117,6 @@ class cSAXS( # csaxs = cSAXS(bec) # # then all commands can be accessed by for example -# csaxs._cSAXS_smaract_stages_..... \ No newline at end of file +# csaxs.commands() +# csaxs.predict_gap(6.2) +# csaxs._cSAXS_smaract_stages_... \ No newline at end of file diff --git a/csaxs_bec/bec_ipython_client/plugins/cSAXS/intensity_map_predict_gap.py b/csaxs_bec/bec_ipython_client/plugins/cSAXS/intensity_map_predict_gap.py new file mode 100755 index 0000000..e991ba7 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/cSAXS/intensity_map_predict_gap.py @@ -0,0 +1,26 @@ +"""Undulator gap predictor emitted by plot_intensity_map.py. +Edit the fitted constants in the signature to retune.""" + +import numpy as np + + +def predict_gap(energy, n=3, gap_min=5.0, + E_inf=3.83167, c0=3.1133, c1=-0.644678, c2=0.0210398): + """Undulator gap [mm] to place `energy` [keV] on harmonic `n`. + Fitted constants are the defaults below; edit them to retune. + Returns NaN where the energy is unreachable on that harmonic.""" + energy = np.asarray(energy, float) + arg = E_inf * n / energy - 1.0 # required K^2/2; must be > 0 + with np.errstate(invalid="ignore", divide="ignore"): + y = np.log(arg) + if abs(c2) < 1e-12: + g = (y - c0) / c1 + else: + disc = c1 * c1 - 4.0 * c2 * (c0 - y) + sq = np.sqrt(np.where(disc >= 0, disc, np.nan)) + r1 = (-c1 + sq) / (2.0 * c2) + r2 = (-c1 - sq) / (2.0 * c2) + g = np.where(c1 + 2.0 * c2 * r1 < 0, r1, r2) + g = np.where(arg > 0, g, np.nan) # above harmonic cutoff + g = np.where(g >= gap_min, g, np.nan) # below mechanical minimum + return g -- 2.54.0 From 23a81cd67e8e62dc3455222fd4ee336f2a5af7f8 Mon Sep 17 00:00:00 2001 From: x12sa Date: Wed, 1 Jul 2026 12:03:52 +0200 Subject: [PATCH 2/9] first version --- csaxs_bec/bec_widgets/widgets/client.py | 45 +++ .../bec_widgets/widgets/designer_plugins.py | 5 + .../widgets/slit_control/__init__.py | 3 + .../slit_control/register_slit_control.py | 15 + .../register_slit_control_widget.py | 15 + .../widgets/slit_control/slit_control.py | 373 ++++++++++++++++++ .../slit_control/slit_control_plugin.py | 56 +++ .../slit_control_widget.pyproject | 1 + .../slit_control_widget_plugin.py | 57 +++ 9 files changed, 570 insertions(+) create mode 100644 csaxs_bec/bec_widgets/widgets/slit_control/__init__.py create mode 100644 csaxs_bec/bec_widgets/widgets/slit_control/register_slit_control.py create mode 100644 csaxs_bec/bec_widgets/widgets/slit_control/register_slit_control_widget.py create mode 100644 csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py create mode 100644 csaxs_bec/bec_widgets/widgets/slit_control/slit_control_plugin.py create mode 100644 csaxs_bec/bec_widgets/widgets/slit_control/slit_control_widget.pyproject create mode 100644 csaxs_bec/bec_widgets/widgets/slit_control/slit_control_widget_plugin.py diff --git a/csaxs_bec/bec_widgets/widgets/client.py b/csaxs_bec/bec_widgets/widgets/client.py index 92d4240..c0827dd 100644 --- a/csaxs_bec/bec_widgets/widgets/client.py +++ b/csaxs_bec/bec_widgets/widgets/client.py @@ -13,10 +13,55 @@ logger = bec_logger.logger _Widgets = { + "SlitControlWidget": "SlitControlWidget", "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 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..c57998d 100644 --- a/csaxs_bec/bec_widgets/widgets/designer_plugins.py +++ b/csaxs_bec/bec_widgets/widgets/designer_plugins.py @@ -5,9 +5,14 @@ from __future__ import annotations # pylint: skip-file designer_plugins = { + "SlitControlWidget": ( + "csaxs_bec.bec_widgets.widgets.slit_control.slit_control", + "SlitControlWidget", + ), "XRayEye": ("csaxs_bec.bec_widgets.widgets.xray_eye.x_ray_eye", "XRayEye"), } widget_icons = { + "SlitControlWidget": "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..75a444b --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py @@ -0,0 +1,373 @@ +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.utils.rpc_decorator import rpc_timeout +from qtpy.QtCore import Qt +from qtpy.QtGui import QDoubleValidator +from qtpy.QtWidgets import ( + QButtonGroup, + QDoubleSpinBox, + QFrame, + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QPushButton, + QRadioButton, + QVBoxLayout, + QWidget, +) + +logger = bec_logger.logger + +# ── arrow glyphs ──────────────────────────────────────────────────────────── +_UP = "\u2191" # ↑ +_DOWN = "\u2193" # ↓ +_LEFT = "\u2190" # ← +_RIGHT = "\u2192" # → +_SHRINK_X = "\u2192\u2190" # →← decrease x size +_GROW_X = "\u2190\u2192" # ←→ increase x size +_SHRINK_Y = "\u2191\u2193" # ↑↓ decrease y size +_GROW_Y = "\u2193\u2191" # ↓↑ increase y size + + +def _separator() -> QFrame: + sep = QFrame() + sep.setFrameShape(QFrame.Shape.HLine) + sep.setFrameShadow(QFrame.Shadow.Sunken) + return sep + + +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 + + Movement is dispatched through ``scans.umvr``, exactly as in the CLI + macros, keeping the same queue / callback path used everywhere else at + cSAXS. + """ + + 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) + + 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._build_ui() + self._subscribe_readbacks() + self._refresh_labels() + + # ── UI construction ────────────────────────────────────────────────────── + + def _build_ui(self): + root = QVBoxLayout(self) + root.setSpacing(8) + + root.addWidget(self._build_slit_selector()) + root.addWidget(_separator()) + + clusters = QHBoxLayout() + clusters.addWidget(self._build_center_cluster()) + clusters.addWidget(self._build_size_cluster()) + root.addLayout(clusters) + + root.addWidget(_separator()) + 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) + 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) + + 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.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_size_cluster(self) -> QGroupBox: + box = QGroupBox("Size (S)") + grid = QGridLayout(box) + grid.setSpacing(4) + + 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.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_step_control(self) -> QGroupBox: + box = QGroupBox("Step size (mm)") + layout = QHBoxLayout(box) + + 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) + + btn_half = QPushButton("\u00f7 2") + btn_double = QPushButton("\u00d7 2") + btn_half.clicked.connect(self.halve_step) + btn_double.clicked.connect(self.double_step) + + 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)") + grid = QGridLayout(box) + + 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) + + return box + + # ── device naming ──────────────────────────────────────────────────────── + + def _dev_name(self, suffix: str) -> str: + return f"sl{self.slit}{suffix}" + + # ── Redis subscriptions ────────────────────────────────────────────────── + + 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) + + 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() + + @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) + + 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}") + + 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) + try: + device = self.device_manager.devices[dev_name] + reading = device.read() + value = reading[dev_name]["value"] + label.setText(f"{value:.4f}") + except Exception: + label.setText("---") + + # ── RPC-exposed 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() + + def move_center(self, direction: str): + """Move the slit center by one step. 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 the slit size by one step. + 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) + device = self.device_manager.devices[dev_name] + # Mirror the umvr call path used in the CLI macros. + self.scans.umvr(device, delta) + + def set_step(self, value: float): + """Set the shared 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): + """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 ─────────────────────────────────────────────────────── + + def _on_slit_selected(self, slit_id: int): + self.set_slit(slit_id) + + def _on_step_spinbox_changed(self, value: float): + clamped = max(self._STEP_MIN, min(self._STEP_MAX, value)) + self.step = clamped + + # ── cleanup ────────────────────────────────────────────────────────────── + + def cleanup(self): + self._unsubscribe_readbacks() + super().cleanup() 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() -- 2.54.0 From 74c9a718190ae2c0d1a8b87f5373f0ef174a4f68 Mon Sep 17 00:00:00 2001 From: x12sa Date: Wed, 1 Jul 2026 12:25:44 +0200 Subject: [PATCH 3/9] now showing motor positions --- .../widgets/slit_control/slit_control.py | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) 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 75a444b..9849ffb 100644 --- a/csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py +++ b/csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py @@ -5,7 +5,6 @@ from bec_lib.endpoints import MessageEndpoints from bec_widgets import BECWidget, SafeSlot from bec_widgets.utils.rpc_decorator import rpc_timeout from qtpy.QtCore import Qt -from qtpy.QtGui import QDoubleValidator from qtpy.QtWidgets import ( QButtonGroup, QDoubleSpinBox, @@ -27,10 +26,10 @@ _UP = "\u2191" # ↑ _DOWN = "\u2193" # ↓ _LEFT = "\u2190" # ← _RIGHT = "\u2192" # → -_SHRINK_X = "\u2192\u2190" # →← decrease x size -_GROW_X = "\u2190\u2192" # ←→ increase x size -_SHRINK_Y = "\u2191\u2193" # ↑↓ decrease y size -_GROW_Y = "\u2193\u2191" # ↓↑ increase y size +_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 →←) def _separator() -> QFrame: @@ -53,9 +52,8 @@ class SlitControlWidget(BECWidget, QWidget): - Readback table: horizontal / vertical rows × center / size columns, updated live via Redis subscription - Movement is dispatched through ``scans.umvr``, exactly as in the CLI - macros, keeping the same queue / callback path used everywhere else at - cSAXS. + Movement reads the current position via ``device.read()`` then dispatches + an absolute ``scans.umv`` — the same path used by the CLI macros. """ PLUGIN = True @@ -282,9 +280,9 @@ class SlitControlWidget(BECWidget, QWidget): ): dev_name = self._dev_name(suffix) try: - device = self.device_manager.devices[dev_name] + device = getattr(self.dev, dev_name) reading = device.read() - value = reading[dev_name]["value"] + value = float(reading[dev_name]["value"]) label.setText(f"{value:.4f}") except Exception: label.setText("---") @@ -337,9 +335,14 @@ class SlitControlWidget(BECWidget, QWidget): def _move_relative(self, axis_suffix: str, delta: float): dev_name = self._dev_name(axis_suffix) - device = self.device_manager.devices[dev_name] - # Mirror the umvr call path used in the CLI macros. - self.scans.umvr(device, delta) + try: + device = getattr(self.dev, dev_name) + reading = device.read() + current = float(reading[dev_name]["value"]) + target = current + delta + self.scans.umv(device, target) + 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).""" @@ -370,4 +373,4 @@ class SlitControlWidget(BECWidget, QWidget): def cleanup(self): self._unsubscribe_readbacks() - super().cleanup() + super().cleanup() \ No newline at end of file -- 2.54.0 From 3948a8cbb26ee61f83551915c41ae589c968daaf Mon Sep 17 00:00:00 2001 From: holler Date: Wed, 1 Jul 2026 15:04:54 +0200 Subject: [PATCH 4/9] fix movement of devices --- csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 9849ffb..47c200e 100644 --- a/csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py +++ b/csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py @@ -53,7 +53,8 @@ class SlitControlWidget(BECWidget, QWidget): updated live via Redis subscription Movement reads the current position via ``device.read()`` then dispatches - an absolute ``scans.umv`` — the same path used by the CLI macros. + Movement calls ``device.set(target)`` on the device proxy, which routes + through to the device server — the same pattern used by XRayEye2DControl. """ PLUGIN = True @@ -334,13 +335,14 @@ 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 - self.scans.umv(device, target) + device.set(target) except Exception as exc: logger.warning(f"SlitControlWidget: failed to move {dev_name}: {exc}") -- 2.54.0 From 3a0eda4fc22a08d9965dfb355e89bd3640b14406 Mon Sep 17 00:00:00 2001 From: holler Date: Wed, 1 Jul 2026 15:14:26 +0200 Subject: [PATCH 5/9] fix move command again --- csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 47c200e..dbbc8ed 100644 --- a/csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py +++ b/csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py @@ -53,8 +53,8 @@ class SlitControlWidget(BECWidget, QWidget): updated live via Redis subscription Movement reads the current position via ``device.read()`` then dispatches - Movement calls ``device.set(target)`` on the device proxy, which routes - through to the device server — the same pattern used by XRayEye2DControl. + Movement calls ``device.move(target)`` — the documented BECWidget API for + moving a positioner (``self.dev['motor'].move(position)``). """ PLUGIN = True @@ -342,7 +342,7 @@ class SlitControlWidget(BECWidget, QWidget): reading = device.read() current = float(reading[dev_name]["value"]) target = current + delta - device.set(target) + device.move(target) except Exception as exc: logger.warning(f"SlitControlWidget: failed to move {dev_name}: {exc}") -- 2.54.0 From 738756c39d0c2ec1877e390ce9da777b143dddef Mon Sep 17 00:00:00 2001 From: holler Date: Wed, 1 Jul 2026 15:47:19 +0200 Subject: [PATCH 6/9] tomo parameters gui initial version --- .../widgets/tomo_parameters/__init__.py | 3 + .../tomo_parameters/register_tomo_params.py | 15 + .../widgets/tomo_parameters/tomo_params.py | 901 ++++++++++++++++++ .../tomo_parameters/tomo_params_plugin.py | 53 ++ 4 files changed, 972 insertions(+) create mode 100644 csaxs_bec/bec_widgets/widgets/tomo_parameters/__init__.py create mode 100644 csaxs_bec/bec_widgets/widgets/tomo_parameters/register_tomo_params.py create mode 100644 csaxs_bec/bec_widgets/widgets/tomo_parameters/tomo_params.py create mode 100644 csaxs_bec/bec_widgets/widgets/tomo_parameters/tomo_params_plugin.py diff --git a/csaxs_bec/bec_widgets/widgets/tomo_parameters/__init__.py b/csaxs_bec/bec_widgets/widgets/tomo_parameters/__init__.py new file mode 100644 index 0000000..6807c87 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/tomo_parameters/__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_parameters/register_tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_parameters/register_tomo_params.py new file mode 100644 index 0000000..dfd1190 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/tomo_parameters/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_parameters/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_parameters/tomo_params.py new file mode 100644 index 0000000..5a2f789 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/tomo_parameters/tomo_params.py @@ -0,0 +1,901 @@ +""" +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 new file mode 100644 index 0000000..e3a6774 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/tomo_parameters/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() -- 2.54.0 From 827f994dd4b2816b5026af7fe8c635a21f424490 Mon Sep 17 00:00:00 2001 From: menzel Date: Wed, 1 Jul 2026 17:57:36 +0200 Subject: [PATCH 7/9] Update predict_gap constants to corrected 3-parameter fit Replace the 4-parameter (quadratic) constants with the 3-parameter pure-exponential fit from plot_intensity_map.py (operating-locus calibration, gap-residual RMS ~14 um). c2 was insignificant (0.2 sigma) and left E_inf degenerate (+/-11 keV). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plugins/cSAXS/intensity_map_predict_gap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/cSAXS/intensity_map_predict_gap.py b/csaxs_bec/bec_ipython_client/plugins/cSAXS/intensity_map_predict_gap.py index e991ba7..dbdc992 100755 --- a/csaxs_bec/bec_ipython_client/plugins/cSAXS/intensity_map_predict_gap.py +++ b/csaxs_bec/bec_ipython_client/plugins/cSAXS/intensity_map_predict_gap.py @@ -5,7 +5,7 @@ import numpy as np def predict_gap(energy, n=3, gap_min=5.0, - E_inf=3.83167, c0=3.1133, c1=-0.644678, c2=0.0210398): + E_inf=3.2878, c0=2.46086, c1=-0.468091, c2=0.0): """Undulator gap [mm] to place `energy` [keV] on harmonic `n`. Fitted constants are the defaults below; edit them to retune. Returns NaN where the energy is unreachable on that harmonic.""" -- 2.54.0 From 5d26b737717bb24085258b6406f96cacdaca58c8 Mon Sep 17 00:00:00 2001 From: x12sa Date: Thu, 2 Jul 2026 08:42:25 +0200 Subject: [PATCH 8/9] tomo parameter widget --- .../widgets/tomo_params/__init__.py | 3 + .../tomo_params/register_tomo_params.py | 15 + .../register_tomo_params_widget.py | 15 + .../widgets/tomo_params/tomo_params.py | 942 ++++++++++++++++++ .../widgets/tomo_params/tomo_params_plugin.py | 53 + .../tomo_params/tomo_params_widget.pyproject | 1 + .../tomo_params/tomo_params_widget_plugin.py | 57 ++ 7 files changed, 1086 insertions(+) create mode 100644 csaxs_bec/bec_widgets/widgets/tomo_params/__init__.py create mode 100644 csaxs_bec/bec_widgets/widgets/tomo_params/register_tomo_params.py create mode 100644 csaxs_bec/bec_widgets/widgets/tomo_params/register_tomo_params_widget.py create mode 100644 csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py create mode 100644 csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params_plugin.py create mode 100644 csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params_widget.pyproject create mode 100644 csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params_widget_plugin.py 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() -- 2.54.0 From d68f15aa41e2d21495349d99393fab1fb9c8db10 Mon Sep 17 00:00:00 2001 From: x12sa Date: Thu, 2 Jul 2026 08:43:26 +0200 Subject: [PATCH 9/9] slit control tested and improved --- csaxs_bec/bec_widgets/widgets/client.py | 42 +- .../bec_widgets/widgets/designer_plugins.py | 5 + .../widgets/slit_control/slit_control.py | 449 +++++---- .../widgets/tomo_parameters/__init__.py | 3 - .../tomo_parameters/register_tomo_params.py | 15 - .../widgets/tomo_parameters/tomo_params.py | 901 ------------------ .../tomo_parameters/tomo_params_plugin.py | 53 -- 7 files changed, 330 insertions(+), 1138 deletions(-) delete mode 100644 csaxs_bec/bec_widgets/widgets/tomo_parameters/__init__.py delete mode 100644 csaxs_bec/bec_widgets/widgets/tomo_parameters/register_tomo_params.py delete mode 100644 csaxs_bec/bec_widgets/widgets/tomo_parameters/tomo_params.py delete mode 100644 csaxs_bec/bec_widgets/widgets/tomo_parameters/tomo_params_plugin.py 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() -- 2.54.0