diff --git a/csaxs_bec/bec_widgets/widgets/client.py b/csaxs_bec/bec_widgets/widgets/client.py
index 7a2a3e4..c3c4192 100644
--- a/csaxs_bec/bec_widgets/widgets/client.py
+++ b/csaxs_bec/bec_widgets/widgets/client.py
@@ -4,7 +4,6 @@
from __future__ import annotations
from bec_lib.logger import bec_logger
-
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
logger = bec_logger.logger
@@ -14,6 +13,7 @@ logger = bec_logger.logger
_Widgets = {
"SampleStorageWidget": "SampleStorageWidget",
+ "SAXSWidget": "SAXSWidget",
"SlitControlWidget": "SlitControlWidget",
"TomoParamsWidget": "TomoParamsWidget",
"XRayEye": "XRayEye",
@@ -32,6 +32,82 @@ class SampleStorageWidget(RPCBase):
"""
+class SAXSWidget(RPCBase):
+ """Widget for preparing SAXS measurement tables from an image ROI."""
+
+ _IMPORT_MODULE = "csaxs_bec.bec_widgets.widgets.saxs_widget.saxs_widget"
+
+ @rpc_call
+ def add_scanning_row(
+ self,
+ sample_name: "str | None" = None,
+ exposure_time: "float | None" = None,
+ fast_axis: "str | None" = None,
+ stepper_axis: "str | None" = None,
+ fast_step: "float | None" = None,
+ stepper_step: "float | None" = None,
+ comment: "str" = "",
+ ) -> "SAXSRow | None":
+ """
+ None
+ """
+
+ @rpc_call
+ def add_tensor_rows(
+ self,
+ sample_name: "str | None" = None,
+ exposure_time: "float | None" = None,
+ fast_axis: "str | None" = None,
+ stepper_axis: "str | None" = None,
+ fast_step: "float | None" = None,
+ stepper_step: "float | None" = None,
+ comment: "str" = "",
+ roty: "float | None" = None,
+ rotx_values: "list[float] | None" = None,
+ roty_values: "list[float] | None" = None,
+ angle_pairs: "list[tuple[float, float]] | None" = None,
+ ) -> "list[SAXSRow]":
+ """
+ None
+ """
+
+ @rpc_call
+ def clear_rows(self) -> "None":
+ """
+ None
+ """
+
+ @rpc_call
+ def delete_selected_rows(self) -> "None":
+ """
+ None
+ """
+
+ @rpc_call
+ def export_csv(self, file_name: "str | None" = None, selected: "bool" = False) -> "str | None":
+ """
+ None
+ """
+
+ @rpc_call
+ def rows_as_dicts(self) -> "list[dict]":
+ """
+ None
+ """
+
+ @rpc_call
+ def set_positioners(self, x_positioner: "str", y_positioner: "str") -> "None":
+ """
+ None
+ """
+
+ @rpc_call
+ def mark_scan_result(self, sample_id_or_row, scan_number, success: "bool") -> "None":
+ """
+ None
+ """
+
+
class SlitControlWidget(RPCBase):
"""Interactive GUI for cSAXS slit center and size control."""
diff --git a/csaxs_bec/bec_widgets/widgets/designer_plugins.py b/csaxs_bec/bec_widgets/widgets/designer_plugins.py
index 7716621..ca0ff0c 100644
--- a/csaxs_bec/bec_widgets/widgets/designer_plugins.py
+++ b/csaxs_bec/bec_widgets/widgets/designer_plugins.py
@@ -9,6 +9,7 @@ designer_plugins = {
"csaxs_bec.bec_widgets.widgets.sample_storage.sample_storage",
"SampleStorageWidget",
),
+ "SAXSWidget": ("csaxs_bec.bec_widgets.widgets.saxs_widget.saxs_widget", "SAXSWidget"),
"SlitControlWidget": (
"csaxs_bec.bec_widgets.widgets.slit_control.slit_control",
"SlitControlWidget",
@@ -22,6 +23,7 @@ designer_plugins = {
widget_icons = {
"SampleStorageWidget": "widgets",
+ "SAXSWidget": "table_chart",
"SlitControlWidget": "widgets",
"TomoParamsWidget": "widgets",
"XRayEye": "widgets",
diff --git a/csaxs_bec/bec_widgets/widgets/saxs_widget/README.md b/csaxs_bec/bec_widgets/widgets/saxs_widget/README.md
new file mode 100644
index 0000000..8024810
--- /dev/null
+++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/README.md
@@ -0,0 +1,184 @@
+# SAXS Widget
+
+The SAXS widget is a planning widget for preparing scanning SAXS and SAXS tensor tomography
+measurements from an image ROI. It combines:
+
+- a live image view with one persistent rectangular ROI,
+- X/Y positioner selection and compact positioner controls,
+- ROI-to-positioner mapping controls with camera scale per pixel, camera-to-x-ray offset and direction,
+- row creation dialogs for scanning SAXS and tensor tomography,
+- a measurement table that can be saved, exported as CSV, or submitted to downstream code.
+
+The widget does not run the scan itself. Its main job is to turn the current ROI, mapping
+corrections, selected motors and user-entered scan parameters into a flat table payload that a
+macro or script can consume.
+
+## Session Payload
+
+Saved sessions are published as `VariableMessage` entries through
+`MessageEndpointSAXSWidget.measurement_table(session_name)`, which resolves to:
+
+```text
+info/saxs_widget/sessions/{session_name}
+```
+
+The saved message has this shape:
+
+```python
+VariableMessage(
+ value=[row_0, row_1, ...],
+ metadata={
+ "session_name": session_name,
+ "timestamp": "2026-07-10T12:34:56Z",
+ },
+)
+```
+
+`metadata["timestamp"]` is an ISO-8601 UTC string, not a POSIX timestamp.
+
+The `value` field is a full table snapshot: a list of flat dictionaries, where each dictionary is
+one planned scan row. Do not treat one stream message as one scan row; treat it as the saved table
+version for that session.
+
+For a session's current plan, select the newest snapshot from its stream using
+`metadata["timestamp"]`. Older snapshots are retained as session history (max 50 entries) and should only be used
+when a macro intentionally needs to replay an earlier plan.
+
+CSV export uses the same row fields, but additionally prefixes each row with `session_name`.
+
+## Row Fields For Scan Macros
+
+Core scan fields:
+
+| Field | Meaning |
+| --- | --- |
+| `template` | `scanning` or `tensor`. Tensor rows include rotation angle values. |
+| `sample_name` | User-provided sample label. |
+| `sample_id` | Stable generated ID for this row. Use this when reporting scan results back. |
+| `exposure_time` | Exposure time in seconds. |
+| `comment` | Optional free-text note from the user. |
+| `fast_axis` | Resolved motor name used as the fast axis. |
+| `fast_start` / `fast_stop` | Corrected start and stop positions for the fast axis. |
+| `fast_step` | Step size along the fast axis. |
+| `stepper_axis` | Resolved motor name used as the stepper axis. |
+| `stepper_start` / `stepper_stop` | Corrected start and stop positions for the stepper axis. |
+| `stepper_step` | Step size along the stepper axis. |
+| `images_per_line` | Number of image points along one fast-axis line. |
+| `num_lines` | Number of stepper-axis lines. |
+| `total_frames` | Total planned frames, equal to `images_per_line * num_lines`. |
+
+Coordinate and motor context:
+
+| Field | Meaning |
+| --- | --- |
+| `fast_axis_coordinate` | Coordinate selected as the fast axis: `x` or `y`. |
+| `stepper_axis_coordinate` | Coordinate selected as the stepper axis: `x` or `y`. |
+| `x_positioner` / `y_positioner` | Motors selected for the ROI X/Y coordinates. |
+| `rotx_axis` / `roty_axis` | Rotation motors selected for tensor tomography rows. |
+| `rotx` / `roty` | Rotation angles for this row. They are `None` for non-tensor rows. |
+| `camera_center_x`, `camera_center_y` | X/Y motor positions that corresponded to the image reference point when the row was created. |
+
+ROI and mapping provenance:
+
+| Field | Meaning |
+| --- | --- |
+| `raw_roi_x`, `raw_roi_y` | Raw ROI origin in image coordinates, retained for traceability and restoring the camera view. |
+| `raw_roi_width`, `raw_roi_height` | Raw ROI size in image coordinates, retained for traceability and restoring the camera view. |
+| `correction_camera_scale_pixel_x`, `correction_camera_scale_pixel_y` | Camera pixel-to-motor scale factors used to convert ROI coordinates to positions. |
+| `correction_offset_cam_xray_x`, `correction_offset_cam_xray_y` | Calibrated offsets from the camera reference point to the x-ray beam, in motor units. |
+| `correction_direction_x`, `correction_direction_y` | Direction signs, usually `1` or `-1`. |
+| `correction_reference_x`, `correction_reference_y` | Image coordinates used as the camera reference point. New rows use the image center when image data exists, otherwise the ROI center. |
+
+The mapping reference is reset to the image center only when a first image becomes available
+or the image shape changes. While no image data is available, it follows the ROI center.
+
+Coordinate conversion follows this workflow:
+
+```text
+camera-space motor position = camera_center + direction * camera_scale_pixel * (raw_roi - reference)
+x-ray beam scan position = camera-space motor position + offset_cam_xray
+```
+
+`Move to selected` restores the saved raw ROI and moves back to `camera_center_x` /
+`camera_center_y`, i.e. the motor positions saved when the row was created. The exported table scan
+coordinates include both the camera-scaled ROI displacement and the calibrated camera-to-x-ray offset.
+
+The ROI, camera-center, and correction fields document how the plan was created. A scan macro
+should execute the already-corrected `fast_*` and `stepper_*` fields rather than recalculating
+positions from the image coordinates.
+
+Bookkeeping and convenience fields:
+
+| Field | Meaning |
+| --- | --- |
+| `scan_results` | JSON string containing previous scan result records, if any. Not needed to start a new scan. |
+| `step_size_x` / `step_size_y` | Coordinate-specific step size aliases. |
+
+## Macro Usage Notes
+
+The fields required to execute a row are `template`, `fast_axis`, `fast_start`, `fast_stop`,
+`fast_step`, `stepper_axis`, `stepper_start`, `stepper_stop`, `stepper_step`,
+`exposure_time`, and the detector-count fields required by the beamline. Tensor rows additionally
+require `rotx_axis`, `roty_axis`, `rotx`, and `roty`.
+
+A scan macro should usually consume the row fields in this order:
+
+1. Validate that the message value is a list of rows and that each row has the required fields and usable motor names.
+2. Configure detector/exposure from `exposure_time`, `images_per_line`, `num_lines`,
+ and `total_frames`.
+3. Run the grid scan with `fast_axis`, `fast_start`, `fast_stop`, `fast_step`,
+ `stepper_axis`, `stepper_start`, `stepper_stop` and `stepper_step`.
+4. For `template == "tensor"`, move or include `rotx_axis`/`roty_axis` with `rotx`/`roty`
+ before running the grid scan.
+5. Preserve `sample_name`, `sample_id`, `comment` and ROI/correction fields in metadata so the
+ scan can be traced back to the planned row.
+
+Minimal pseudo-code:
+
+```python
+def run_saxs_table(variable_message):
+ session_name = variable_message.metadata.get("session_name", "")
+ rows = variable_message.value
+
+ for row in rows:
+ if not row["fast_axis"] or not row["stepper_axis"]:
+ raise ValueError("SAXS row does not provide usable fast and stepper motors")
+
+ metadata = {
+ "session_name": session_name,
+ "sample_name": row["sample_name"],
+ "sample_id": row["sample_id"],
+ "comment": row["comment"],
+ "raw_roi": {
+ "x": row["raw_roi_x"],
+ "y": row["raw_roi_y"],
+ "width": row["raw_roi_width"],
+ "height": row["raw_roi_height"],
+ },
+ "camera_center": {
+ "x": row["camera_center_x"],
+ "y": row["camera_center_y"],
+ },
+ }
+
+ if row["template"] == "tensor":
+ move(row["rotx_axis"], row["rotx"])
+ move(row["roty_axis"], row["roty"])
+
+ run_grid_scan(
+ fast_axis=row["fast_axis"],
+ fast_start=row["fast_start"],
+ fast_stop=row["fast_stop"],
+ fast_step=row["fast_step"],
+ stepper_axis=row["stepper_axis"],
+ stepper_start=row["stepper_start"],
+ stepper_stop=row["stepper_stop"],
+ stepper_step=row["stepper_step"],
+ exposure_time=row["exposure_time"],
+ images_per_line=row["images_per_line"],
+ metadata=metadata,
+ )
+```
+
+The function names above are placeholders. Use the beamline's actual motion and scan APIs, but keep
+the field mapping explicit so payload changes are easy to review.
diff --git a/csaxs_bec/bec_widgets/widgets/saxs_widget/__init__.py b/csaxs_bec/bec_widgets/widgets/saxs_widget/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/__init__.py
@@ -0,0 +1 @@
+
diff --git a/csaxs_bec/bec_widgets/widgets/saxs_widget/controls.py b/csaxs_bec/bec_widgets/widgets/saxs_widget/controls.py
new file mode 100644
index 0000000..9e7d7d8
--- /dev/null
+++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/controls.py
@@ -0,0 +1,447 @@
+from __future__ import annotations
+
+from bec_qthemes import material_icon
+from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
+ BECDeviceFilter,
+ DeviceComboBox,
+)
+from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
+from qtpy.QtCore import Property, QSize, Qt, Signal
+from qtpy.QtWidgets import (
+ QCheckBox,
+ QComboBox,
+ QDoubleSpinBox,
+ QFrame,
+ QGridLayout,
+ QGroupBox,
+ QLabel,
+ QLineEdit,
+ QPushButton,
+)
+
+from csaxs_bec.bec_widgets.widgets.saxs_widget.models import Correction
+
+TOOLTIP_MAPPING_LOCK = "Lock ROI mapping values to prevent accidental edits during setup."
+TOOLTIP_CAMERA_SCALE_PIXEL = (
+ "Pixel-to-motor scale. This converts an ROI pixel delta from the camera reference "
+ "point into motor units."
+)
+TOOLTIP_OFFSET_CAM_XRAY = (
+ "Calibrated offset from the camera reference point to the x-ray beam, in motor units."
+)
+TOOLTIP_DIRECTION = (
+ "Orientation sign. Use + when increasing image pixels should increase the motor "
+ "coordinate, or - when the camera/motor axis is inverted."
+)
+TOOLTIP_REFERENCE = (
+ "Current image reference point in pixels. This is normally the pinned crosshair at "
+ "the camera center and is read-only here."
+)
+TOOLTIP_ROI_SIZE = "Target ROI width and height in image pixels."
+TOOLTIP_APPLY_ROI_SIZE = (
+ "Resize the current ROI to the entered width and height while keeping its center."
+)
+TOOLTIP_APPLY_CORRECTIONS = (
+ "Apply the current mapping values to selected or all existing table rows."
+)
+
+
+def separator(parent=None) -> QFrame:
+ line = QFrame(parent=parent)
+ line.setObjectName("saxs_toolbar_separator")
+ line.setFrameShape(QFrame.Shape.HLine)
+ line.setFrameShadow(QFrame.Shadow.Sunken)
+ return line
+
+
+def field_label(text: str, parent=None, *, tooltip: str = "") -> QLabel:
+ label = QLabel(text, parent)
+ label.setObjectName("saxs_field_label")
+ if tooltip:
+ label.setToolTip(tooltip)
+ return label
+
+
+def spinbox(
+ *,
+ value: float,
+ parent=None,
+ minimum: float = -1_000_000.0,
+ maximum: float = 1_000_000.0,
+ single_step: float = 0.1,
+ decimals: int = 4,
+) -> QDoubleSpinBox:
+ spin = QDoubleSpinBox(parent=parent)
+ spin.setDecimals(decimals)
+ spin.setRange(minimum, maximum)
+ spin.setSingleStep(single_step)
+ spin.setValue(value)
+ spin.setStepType(QDoubleSpinBox.StepType.AdaptiveDecimalStepType)
+ return spin
+
+
+def positive_spinbox(value: float, *, single_step: float = 0.01, parent=None) -> QDoubleSpinBox:
+ return spinbox(
+ value=value,
+ minimum=1e-12,
+ maximum=1_000_000.0,
+ single_step=single_step,
+ decimals=6,
+ parent=parent,
+ )
+
+
+def device_combo(parent=None) -> DeviceComboBox:
+ combo = DeviceComboBox(
+ parent=parent, device_filter=BECDeviceFilter.POSITIONER, autocomplete=True
+ )
+ combo.set_first_element_as_empty = True
+ return combo
+
+
+def direction_combo(parent=None) -> QComboBox:
+ combo = QComboBox(parent)
+ combo.addItem("+", 1)
+ combo.addItem("-", -1)
+ combo.setItemData(
+ 0,
+ "Positive image-pixel movement maps to positive motor movement on this axis.",
+ Qt.ItemDataRole.ToolTipRole,
+ )
+ combo.setItemData(
+ 1,
+ "Positive image-pixel movement maps to negative motor movement on this axis.",
+ Qt.ItemDataRole.ToolTipRole,
+ )
+ return combo
+
+
+class DeviceSelectionBox(QGroupBox):
+ """Device inputs for translation positioners and optional SASTT axes."""
+
+ x_positioner_changed = Signal(str)
+ y_positioner_changed = Signal(str)
+ rotx_axis_changed = Signal(str)
+ roty_axis_changed = Signal(str)
+ lock_changed = Signal(bool)
+ sastt_enabled_changed = Signal(bool)
+ device_selection_enabled_changed = Signal(bool)
+
+ def __init__(self, parent=None):
+ super().__init__("Devices", parent)
+ self.setObjectName("saxs_device_selection_box")
+ layout = QGridLayout(self)
+ layout.setContentsMargins(10, 14, 10, 10)
+ layout.setHorizontalSpacing(8)
+ layout.setVerticalSpacing(7)
+ layout.setColumnStretch(1, 1)
+ layout.setColumnStretch(3, 1)
+
+ self.x_positioner_combo = device_combo(self)
+ self.y_positioner_combo = device_combo(self)
+ self.rotx_axis_combo = device_combo(self)
+ self.roty_axis_combo = device_combo(self)
+ self.device_lock_toggle = ToggleSwitch(self, checked=False)
+ self.sastt_checkbox = QCheckBox("Enable SASTT", self)
+ self.sastt_checkbox.setChecked(True)
+ self.sastt_checkbox.setToolTip(
+ "Enable tensor tomography mode to use X/Y rotation axes when adding rows."
+ )
+
+ layout.addWidget(field_label("Edit lock", self), 0, 0)
+ layout.addWidget(self.device_lock_toggle, 0, 1, alignment=Qt.AlignmentFlag.AlignLeft)
+ layout.addWidget(self.sastt_checkbox, 0, 2, 1, 2)
+
+ layout.addWidget(field_label("X axis", self), 1, 0)
+ layout.addWidget(field_label("Y axis", self), 1, 2)
+ layout.addWidget(self.x_positioner_combo, 2, 0, 1, 2)
+ layout.addWidget(self.y_positioner_combo, 2, 2, 1, 2)
+
+ layout.addWidget(field_label("X rotation", self), 3, 0)
+ layout.addWidget(field_label("Y rotation", self), 3, 2)
+ layout.addWidget(self.rotx_axis_combo, 4, 0, 1, 2)
+ layout.addWidget(self.roty_axis_combo, 4, 2, 1, 2)
+
+ self.x_positioner_combo.currentTextChanged.connect(self.x_positioner_changed.emit)
+ self.y_positioner_combo.currentTextChanged.connect(self.y_positioner_changed.emit)
+ self.rotx_axis_combo.currentTextChanged.connect(self.rotx_axis_changed.emit)
+ self.roty_axis_combo.currentTextChanged.connect(self.roty_axis_changed.emit)
+ self.device_lock_toggle.enabled.connect(self._on_lock_changed)
+ self.sastt_checkbox.stateChanged.connect(self._on_sastt_changed)
+ self._update_enabled_state()
+
+ def _get_x_positioner(self) -> str:
+ return self.x_positioner_combo.currentText()
+
+ def _set_x_positioner(self, value: str) -> None:
+ self.x_positioner_combo.setCurrentText(value or "")
+
+ def _get_y_positioner(self) -> str:
+ return self.y_positioner_combo.currentText()
+
+ def _set_y_positioner(self, value: str) -> None:
+ self.y_positioner_combo.setCurrentText(value or "")
+
+ def _get_rotx_axis(self) -> str:
+ return self.rotx_axis_combo.currentText()
+
+ def _set_rotx_axis(self, value: str) -> None:
+ self.rotx_axis_combo.setCurrentText(value or "")
+
+ def _get_roty_axis(self) -> str:
+ return self.roty_axis_combo.currentText()
+
+ def _set_roty_axis(self, value: str) -> None:
+ self.roty_axis_combo.setCurrentText(value or "")
+
+ def _get_sastt_enabled(self) -> bool:
+ return self.sastt_checkbox.isChecked()
+
+ def _set_sastt_enabled(self, enabled: bool) -> None:
+ self.sastt_checkbox.setChecked(bool(enabled))
+
+ def _get_device_selection_enabled(self) -> bool:
+ return bool(self.device_lock_toggle.checked)
+
+ def _set_device_selection_enabled(self, enabled: bool) -> None:
+ self.device_lock_toggle.checked = bool(enabled)
+ self._update_enabled_state()
+
+ x_positioner = Property(str, _get_x_positioner, _set_x_positioner, notify=x_positioner_changed)
+ y_positioner = Property(str, _get_y_positioner, _set_y_positioner, notify=y_positioner_changed)
+ rotx_axis = Property(str, _get_rotx_axis, _set_rotx_axis, notify=rotx_axis_changed)
+ roty_axis = Property(str, _get_roty_axis, _set_roty_axis, notify=roty_axis_changed)
+ sastt_enabled = Property(
+ bool, _get_sastt_enabled, _set_sastt_enabled, notify=sastt_enabled_changed
+ )
+ device_selection_enabled = Property(
+ bool,
+ _get_device_selection_enabled,
+ _set_device_selection_enabled,
+ notify=device_selection_enabled_changed,
+ )
+
+ def is_locked(self) -> bool:
+ return not self.device_selection_enabled
+
+ def is_sastt_enabled(self) -> bool:
+ return self.sastt_enabled
+
+ def set_positioners(self, x_positioner: str, y_positioner: str) -> None:
+ if x_positioner:
+ self.x_positioner = x_positioner
+ if y_positioner:
+ self.y_positioner = y_positioner
+
+ def _on_lock_changed(self, enabled: bool) -> None:
+ self._update_enabled_state()
+ self.device_selection_enabled_changed.emit(bool(enabled))
+ self.lock_changed.emit(not bool(enabled))
+
+ def _on_sastt_changed(self, state: int) -> None:
+ self._update_enabled_state()
+ self.sastt_enabled_changed.emit(bool(state))
+
+ def _update_enabled_state(self) -> None:
+ is_locked = self.is_locked()
+ sastt_enabled = self.sastt_enabled
+ self.x_positioner_combo.setEnabled(is_locked)
+ self.y_positioner_combo.setEnabled(is_locked)
+ self.rotx_axis_combo.setEnabled(is_locked and sastt_enabled)
+ self.roty_axis_combo.setEnabled(is_locked and sastt_enabled)
+ # Keep SASTT toggle usable even when axis selectors are disabled.
+ self.sastt_checkbox.setEnabled(True)
+
+
+class ROIMappingBox(QGroupBox):
+ """Controls for converting image ROI coordinates into scan coordinates."""
+
+ apply_requested = Signal()
+ roi_size_requested = Signal(float, float)
+
+ def __init__(self, parent=None):
+ super().__init__("ROI mapping", parent)
+ self.setObjectName("saxs_roi_mapping_box")
+ layout = QGridLayout(self)
+ layout.setContentsMargins(10, 14, 10, 10)
+ layout.setHorizontalSpacing(8)
+ layout.setVerticalSpacing(7)
+ layout.setColumnStretch(1, 1)
+ layout.setColumnStretch(2, 1)
+
+ # An unknown calibration must not silently become a one-motor-unit-per-pixel scan.
+ self.camera_scale_pixel_x = spinbox(value=0.0, single_step=0.1, parent=self)
+ self.offset_cam_xray_x = spinbox(value=0.0, single_step=0.1, parent=self)
+ self.direction_x = direction_combo(self)
+ self.camera_scale_pixel_y = spinbox(value=0.0, single_step=0.1, parent=self)
+ self.offset_cam_xray_y = spinbox(value=0.0, single_step=0.1, parent=self)
+ self.direction_y = direction_combo(self)
+ self.mapping_lock_toggle = ToggleSwitch(self, checked=False)
+ self.reference_x = QLineEdit("0", self)
+ self.reference_y = QLineEdit("0", self)
+ self.roi_width = positive_spinbox(50.0, single_step=1.0, parent=self)
+ self.roi_height = positive_spinbox(50.0, single_step=1.0, parent=self)
+ self._set_field_tooltips()
+ for reference in [self.reference_x, self.reference_y]:
+ reference.setObjectName("saxs_reference_field")
+ reference.setReadOnly(True)
+ reference.setAlignment(Qt.AlignmentFlag.AlignRight)
+
+ self.edit_lock_label = field_label("Edit lock", self, tooltip=TOOLTIP_MAPPING_LOCK)
+ self.axis_label = field_label("Axis", self)
+ self.camera_scale_pixel_label = field_label(
+ "Camera scale / px", self, tooltip=TOOLTIP_CAMERA_SCALE_PIXEL
+ )
+ self.offset_cam_xray_label = field_label(
+ "Cam-xray offset", self, tooltip=TOOLTIP_OFFSET_CAM_XRAY
+ )
+ self.direction_label = field_label("Direction", self, tooltip=TOOLTIP_DIRECTION)
+ self.reference_label = field_label("Reference", self, tooltip=TOOLTIP_REFERENCE)
+ self.reference_axis_label = field_label("x / y", self, tooltip=TOOLTIP_REFERENCE)
+ self.roi_size_label = field_label("ROI size", self, tooltip=TOOLTIP_ROI_SIZE)
+
+ layout.addWidget(self.edit_lock_label, 0, 2, alignment=Qt.AlignmentFlag.AlignRight)
+ layout.addWidget(self.mapping_lock_toggle, 0, 3, alignment=Qt.AlignmentFlag.AlignLeft)
+ layout.addWidget(self.axis_label, 1, 0)
+ layout.addWidget(self.camera_scale_pixel_label, 1, 1)
+ layout.addWidget(self.offset_cam_xray_label, 1, 2)
+ layout.addWidget(self.direction_label, 1, 3)
+ layout.addWidget(field_label("X", self), 2, 0)
+ layout.addWidget(self.camera_scale_pixel_x, 2, 1)
+ layout.addWidget(self.offset_cam_xray_x, 2, 2)
+ layout.addWidget(self.direction_x, 2, 3)
+ layout.addWidget(field_label("Y", self), 3, 0)
+ layout.addWidget(self.camera_scale_pixel_y, 3, 1)
+ layout.addWidget(self.offset_cam_xray_y, 3, 2)
+ layout.addWidget(self.direction_y, 3, 3)
+
+ layout.addWidget(self.reference_label, 4, 0)
+ layout.addWidget(self.reference_x, 4, 1)
+ layout.addWidget(self.reference_y, 4, 2)
+ layout.addWidget(self.reference_axis_label, 4, 3)
+
+ self.apply_roi_size_button = QPushButton("Apply ROI size", self)
+ self.apply_roi_size_button.setIcon(material_icon("aspect_ratio", size=(16, 16)))
+ self.apply_roi_size_button.setIconSize(QSize(18, 18))
+ self.apply_roi_size_button.setMinimumHeight(32)
+ self.apply_roi_size_button.setCursor(Qt.CursorShape.PointingHandCursor)
+ self.apply_roi_size_button.setFlat(True)
+ self.apply_roi_size_button.setProperty("saxsButtonRole", "toolbar")
+ self.apply_roi_size_button.setToolTip(TOOLTIP_APPLY_ROI_SIZE)
+ layout.addWidget(self.roi_size_label, 5, 0)
+ layout.addWidget(self.roi_width, 5, 1)
+ layout.addWidget(self.roi_height, 5, 2)
+ layout.addWidget(self.apply_roi_size_button, 5, 3)
+
+ self.apply_corrections_button = QPushButton("Apply corrections", self)
+ self.apply_corrections_button.setIcon(
+ material_icon("published_with_changes", size=(16, 16))
+ )
+ self.apply_corrections_button.setIconSize(QSize(18, 18))
+ self.apply_corrections_button.setMinimumHeight(32)
+ self.apply_corrections_button.setCursor(Qt.CursorShape.PointingHandCursor)
+ self.apply_corrections_button.setFlat(True)
+ self.apply_corrections_button.setProperty("saxsButtonRole", "toolbar")
+ self.apply_corrections_button.setToolTip(TOOLTIP_APPLY_CORRECTIONS)
+ layout.addWidget(self.apply_corrections_button, 6, 0, 1, 4)
+
+ self.apply_roi_size_button.clicked.connect(self._emit_roi_size_requested)
+ self.apply_corrections_button.clicked.connect(self.apply_requested.emit)
+ self.mapping_lock_toggle.stateChanged.connect(self.set_locked)
+ self.set_locked(self.mapping_lock_toggle.isChecked())
+
+ def _set_field_tooltips(self) -> None:
+ self.mapping_lock_toggle.setToolTip(TOOLTIP_MAPPING_LOCK)
+ self.camera_scale_pixel_x.setToolTip(TOOLTIP_CAMERA_SCALE_PIXEL)
+ self.camera_scale_pixel_y.setToolTip(TOOLTIP_CAMERA_SCALE_PIXEL)
+ self.offset_cam_xray_x.setToolTip(TOOLTIP_OFFSET_CAM_XRAY)
+ self.offset_cam_xray_y.setToolTip(TOOLTIP_OFFSET_CAM_XRAY)
+ self.direction_x.setToolTip(TOOLTIP_DIRECTION)
+ self.direction_y.setToolTip(TOOLTIP_DIRECTION)
+ self.reference_x.setToolTip(TOOLTIP_REFERENCE)
+ self.reference_y.setToolTip(TOOLTIP_REFERENCE)
+ self.roi_width.setToolTip(TOOLTIP_ROI_SIZE)
+ self.roi_height.setToolTip(TOOLTIP_ROI_SIZE)
+
+ def correction(self) -> Correction:
+ return Correction(
+ camera_scale_pixel_x=self.camera_scale_pixel_x.value(),
+ offset_cam_xray_x=self.offset_cam_xray_x.value(),
+ direction_x=int(self.direction_x.currentData() or 1),
+ camera_scale_pixel_y=self.camera_scale_pixel_y.value(),
+ offset_cam_xray_y=self.offset_cam_xray_y.value(),
+ direction_y=int(self.direction_y.currentData() or 1),
+ reference_x=float(self.reference_x.text() or 0.0),
+ reference_y=float(self.reference_y.text() or 0.0),
+ )
+
+ def set_correction(self, correction: Correction) -> None:
+ for widget in [
+ self.camera_scale_pixel_x,
+ self.offset_cam_xray_x,
+ self.direction_x,
+ self.camera_scale_pixel_y,
+ self.offset_cam_xray_y,
+ self.direction_y,
+ ]:
+ widget.blockSignals(True)
+ try:
+ self.camera_scale_pixel_x.setValue(correction.camera_scale_pixel_x)
+ self.offset_cam_xray_x.setValue(correction.offset_cam_xray_x)
+ self._set_combo_current_data(self.direction_x, correction.direction_x)
+ self.camera_scale_pixel_y.setValue(correction.camera_scale_pixel_y)
+ self.offset_cam_xray_y.setValue(correction.offset_cam_xray_y)
+ self._set_combo_current_data(self.direction_y, correction.direction_y)
+ finally:
+ for widget in [
+ self.camera_scale_pixel_x,
+ self.offset_cam_xray_x,
+ self.direction_x,
+ self.camera_scale_pixel_y,
+ self.offset_cam_xray_y,
+ self.direction_y,
+ ]:
+ widget.blockSignals(False)
+ self.set_reference_position(correction.reference_x, correction.reference_y)
+
+ def set_reference_position(self, x: float, y: float) -> None:
+ self.reference_x.setText(f"{x:.6g}")
+ self.reference_y.setText(f"{y:.6g}")
+
+ def set_roi_size(self, width: float, height: float) -> None:
+ self.roi_width.blockSignals(True)
+ self.roi_height.blockSignals(True)
+ try:
+ self.roi_width.setValue(width)
+ self.roi_height.setValue(height)
+ finally:
+ self.roi_width.blockSignals(False)
+ self.roi_height.blockSignals(False)
+
+ @staticmethod
+ def _set_combo_current_data(combo: QComboBox, value: int) -> None:
+ for index in range(combo.count()):
+ if combo.itemData(index) == value:
+ combo.setCurrentIndex(index)
+ return
+
+ def _emit_roi_size_requested(self) -> None:
+ self.roi_size_requested.emit(self.roi_width.value(), self.roi_height.value())
+
+ def is_locked(self) -> bool:
+ return self.mapping_lock_toggle.isChecked()
+
+ def set_locked(self, locked: bool) -> None:
+ for widget in [
+ self.camera_scale_pixel_x,
+ self.offset_cam_xray_x,
+ self.direction_x,
+ self.camera_scale_pixel_y,
+ self.offset_cam_xray_y,
+ self.direction_y,
+ self.roi_width,
+ self.roi_height,
+ self.apply_roi_size_button,
+ self.apply_corrections_button,
+ ]:
+ widget.setEnabled(not locked)
diff --git a/csaxs_bec/bec_widgets/widgets/saxs_widget/coordinates.py b/csaxs_bec/bec_widgets/widgets/saxs_widget/coordinates.py
new file mode 100644
index 0000000..e4144b6
--- /dev/null
+++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/coordinates.py
@@ -0,0 +1,108 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from csaxs_bec.bec_widgets.widgets.saxs_widget.models import Correction, RawROI
+
+
+def scaled_delta(
+ raw_value: float, reference: float, camera_scale_pixel: float, direction: int
+) -> float:
+ """Convert a raw image coordinate into a motor-space delta."""
+ return int(direction) * float(camera_scale_pixel) * (float(raw_value) - float(reference))
+
+
+def mapped_coordinate(
+ raw_value: float,
+ *,
+ reference: float,
+ camera_scale_pixel: float,
+ direction: int,
+ camera_center: float = 0.0,
+ offset_cam_xray: float = 0.0,
+) -> float:
+ """Map one image coordinate to motor coordinates."""
+ return (
+ float(camera_center)
+ + float(offset_cam_xray)
+ + scaled_delta(raw_value, reference, camera_scale_pixel, direction)
+ )
+
+
+def axis_limits(
+ raw_start: float,
+ raw_size: float,
+ *,
+ reference: float,
+ camera_scale_pixel: float,
+ direction: int,
+ camera_center: float = 0.0,
+ offset_cam_xray: float = 0.0,
+) -> tuple[float, float]:
+ """Return start/stop motor coordinates for one raw ROI axis."""
+ start = mapped_coordinate(
+ raw_start,
+ reference=reference,
+ camera_scale_pixel=camera_scale_pixel,
+ direction=direction,
+ camera_center=camera_center,
+ offset_cam_xray=offset_cam_xray,
+ )
+ stop = mapped_coordinate(
+ float(raw_start) + float(raw_size),
+ reference=reference,
+ camera_scale_pixel=camera_scale_pixel,
+ direction=direction,
+ camera_center=camera_center,
+ offset_cam_xray=offset_cam_xray,
+ )
+ return start, stop
+
+
+def point_position(
+ image_x: float,
+ image_y: float,
+ correction: Correction,
+ *,
+ camera_center: tuple[float, float] = (0.0, 0.0),
+ include_offset_cam_xray: bool = True,
+) -> tuple[float, float]:
+ """Map one image point into X/Y motor coordinates."""
+ offset_cam_xray_x = correction.offset_cam_xray_x if include_offset_cam_xray else 0.0
+ offset_cam_xray_y = correction.offset_cam_xray_y if include_offset_cam_xray else 0.0
+ return (
+ mapped_coordinate(
+ image_x,
+ reference=correction.reference_x,
+ camera_scale_pixel=correction.camera_scale_pixel_x,
+ direction=correction.direction_x,
+ camera_center=camera_center[0],
+ offset_cam_xray=offset_cam_xray_x,
+ ),
+ mapped_coordinate(
+ image_y,
+ reference=correction.reference_y,
+ camera_scale_pixel=correction.camera_scale_pixel_y,
+ direction=correction.direction_y,
+ camera_center=camera_center[1],
+ offset_cam_xray=offset_cam_xray_y,
+ ),
+ )
+
+
+def roi_origin_position(
+ raw_roi: RawROI,
+ correction: Correction,
+ *,
+ camera_center: tuple[float, float] = (0.0, 0.0),
+ include_offset_cam_xray: bool = True,
+) -> tuple[float, float]:
+ """Map the raw ROI origin into X/Y motor coordinates."""
+ return point_position(
+ raw_roi.x,
+ raw_roi.y,
+ correction,
+ camera_center=camera_center,
+ include_offset_cam_xray=include_offset_cam_xray,
+ )
diff --git a/csaxs_bec/bec_widgets/widgets/saxs_widget/dialogs.py b/csaxs_bec/bec_widgets/widgets/saxs_widget/dialogs.py
new file mode 100644
index 0000000..69edf96
--- /dev/null
+++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/dialogs.py
@@ -0,0 +1,336 @@
+from __future__ import annotations
+
+from typing import Literal
+
+from qtpy.QtWidgets import (
+ QCheckBox,
+ QComboBox,
+ QDialog,
+ QDialogButtonBox,
+ QFileDialog,
+ QFormLayout,
+ QGroupBox,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QMessageBox,
+ QPushButton,
+ QSpinBox,
+ QStackedWidget,
+ QTextEdit,
+ QVBoxLayout,
+ QWidget,
+)
+
+from csaxs_bec.bec_widgets.widgets.saxs_widget.controls import positive_spinbox, spinbox
+from csaxs_bec.bec_widgets.widgets.saxs_widget.models import (
+ SAXSRow,
+ linspace_values,
+ load_angle_file,
+ pair_angle_lists,
+ parse_angle_text,
+)
+
+
+class AxisSelectorWidget(QWidget):
+ """Simple checkbox-based axis selector."""
+
+ def __init__(self, label: str, *, axis_is_x: bool):
+ super().__init__()
+ self.axis_checkbox = QCheckBox(f"{label} axis is X", self)
+ self.axis_checkbox.setChecked(axis_is_x)
+
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(self.axis_checkbox)
+
+ def is_x(self) -> bool:
+ return self.axis_checkbox.isChecked()
+
+ def set_is_x(self, axis_is_x: bool) -> None:
+ if self.axis_checkbox.isChecked() == axis_is_x:
+ return
+ self.axis_checkbox.blockSignals(True)
+ self.axis_checkbox.setChecked(axis_is_x)
+ self.axis_checkbox.blockSignals(False)
+
+
+class ScanStepWidget(QWidget):
+ """Linked-by-default fast and stepper step size controls."""
+
+ def __init__(self, fast_step: float, stepper_step: float | None = None, *, linked: bool = True):
+ super().__init__()
+ self.fast_step = positive_spinbox(fast_step, single_step=0.005, parent=self)
+ self.stepper_step = positive_spinbox(
+ fast_step if stepper_step is None else stepper_step, single_step=0.005, parent=self
+ )
+ self.link_step_sizes = QCheckBox("Link step sizes", self)
+ self.link_step_sizes.setChecked(linked)
+
+ layout = QFormLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addRow("Fast step", self.fast_step)
+ layout.addRow("Stepper step", self.stepper_step)
+ layout.addRow(self.link_step_sizes)
+
+ self.fast_step.valueChanged.connect(self._sync_step_sizes)
+ self.link_step_sizes.toggled.connect(self._update_link_state)
+ self._update_link_state(self.link_step_sizes.isChecked())
+
+ def _sync_step_sizes(self, value: float) -> None:
+ if self.link_step_sizes.isChecked():
+ self.stepper_step.blockSignals(True)
+ self.stepper_step.setValue(value)
+ self.stepper_step.blockSignals(False)
+
+ def _update_link_state(self, linked: bool) -> None:
+ self.stepper_step.setEnabled(not linked)
+ if linked:
+ self.stepper_step.blockSignals(True)
+ self.stepper_step.setValue(self.fast_step.value())
+ self.stepper_step.blockSignals(False)
+
+ def values(self) -> dict:
+ return {
+ "fast_step": self.fast_step.value(),
+ "stepper_step": self.stepper_step.value(),
+ "step_sizes_linked": self.link_step_sizes.isChecked(),
+ }
+
+
+class ScanningSAXSDialog(QDialog):
+ """Dialog for parameters that belong to a SAXS measurement row."""
+
+ def __init__(
+ self,
+ parent: QWidget | None = None,
+ *,
+ exposure_time: float,
+ fast_axis: Literal["x", "y"] = "x",
+ stepper_axis: Literal["x", "y"] = "y",
+ fast_step: float,
+ stepper_step: float | None = None,
+ linked: bool = True,
+ sample_name: str = "",
+ comment: str = "",
+ title: str = "Add scanning SAXS",
+ ):
+ super().__init__(parent)
+ self.setWindowTitle(title)
+ self.sample_name = QLineEdit(sample_name, self)
+ self.exposure_time = positive_spinbox(exposure_time, single_step=0.01, parent=self)
+ self.fast_axis = AxisSelectorWidget("Fast", axis_is_x=fast_axis == "x")
+ self.stepper_axis = AxisSelectorWidget("Stepper", axis_is_x=stepper_axis == "x")
+ self.fast_axis.axis_checkbox.toggled.connect(self._sync_stepper_axis)
+ self.stepper_axis.axis_checkbox.toggled.connect(self._sync_fast_axis)
+ self.step_sizes = ScanStepWidget(fast_step, stepper_step, linked=linked)
+ self.comment = QTextEdit(comment, self)
+ self.comment.setFixedHeight(70)
+
+ form = QFormLayout()
+ form.addRow("Sample name", self.sample_name)
+ form.addRow("Exposure time", self.exposure_time)
+ form.addRow("Fast axis", self.fast_axis)
+ form.addRow("Stepper axis", self.stepper_axis)
+ form.addRow("Step sizes", self.step_sizes)
+ form.addRow("Comment", self.comment)
+
+ buttons = QDialogButtonBox(
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self
+ )
+ buttons.accepted.connect(self.accept)
+ buttons.rejected.connect(self.reject)
+
+ layout = QVBoxLayout(self)
+ layout.addLayout(form)
+ layout.addWidget(buttons)
+
+ self._sync_stepper_axis(self.fast_axis.is_x())
+
+ def values(self) -> dict:
+ values = {
+ "sample_name": self.sample_name.text(),
+ "exposure_time": self.exposure_time.value(),
+ "comment": self.comment.toPlainText(),
+ "fast_axis": "x" if self.fast_axis.is_x() else "y",
+ "stepper_axis": "x" if self.stepper_axis.is_x() else "y",
+ }
+ values.update(self.step_sizes.values())
+ return values
+
+ def _sync_stepper_axis(self, fast_axis_is_x: bool) -> None:
+ self.stepper_axis.set_is_x(not fast_axis_is_x)
+
+ def _sync_fast_axis(self, stepper_axis_is_x: bool) -> None:
+ self.fast_axis.set_is_x(not stepper_axis_is_x)
+
+
+class TensorSAXSDialog(ScanningSAXSDialog):
+ """Dialog for SAXS tensor tomography row generation."""
+
+ def __init__(
+ self,
+ parent: QWidget | None = None,
+ *,
+ exposure_time: float,
+ fast_axis: Literal["x", "y"] = "x",
+ stepper_axis: Literal["x", "y"] = "y",
+ fast_step: float,
+ stepper_step: float | None = None,
+ ):
+ super().__init__(
+ parent,
+ exposure_time=exposure_time,
+ fast_axis=fast_axis,
+ stepper_axis=stepper_axis,
+ fast_step=fast_step,
+ stepper_step=stepper_step,
+ title="Add SAXS tensor tomography",
+ )
+
+ self.generated_roty = spinbox(value=0.0, single_step=1.0, parent=self)
+ self.angle_source_combo = QComboBox(self)
+ self.angle_source_combo.addItems(["Generate range", "Manual lists", "Load file"])
+
+ self.rotx_start = spinbox(value=0.0, single_step=1.0, parent=self)
+ self.rotx_stop = spinbox(value=180.0, single_step=1.0, parent=self)
+ self.rotx_count = QSpinBox(self)
+ self.rotx_count.setRange(1, 10_000)
+ self.rotx_count.setValue(19)
+
+ range_widget = QWidget(self)
+ range_form = QFormLayout(range_widget)
+ range_form.setContentsMargins(0, 0, 0, 0)
+ range_form.addRow("RotX start", self.rotx_start)
+ range_form.addRow("RotX stop", self.rotx_stop)
+ range_form.addRow("N angles", self.rotx_count)
+ range_form.addRow("RotY", self.generated_roty)
+
+ self.manual_rotx_values = QLineEdit(self)
+ self.manual_rotx_values.setPlaceholderText("0, 10, 20, 30")
+ self.manual_roty_values = QLineEdit(self)
+ self.manual_roty_values.setPlaceholderText("0 or 0, 1, 2, 3")
+ manual_widget = QWidget(self)
+ manual_form = QFormLayout(manual_widget)
+ manual_form.setContentsMargins(0, 0, 0, 0)
+ manual_form.addRow("RotX values", self.manual_rotx_values)
+ manual_form.addRow("RotY values", self.manual_roty_values)
+
+ file_widget = QWidget(self)
+ file_layout = QVBoxLayout(file_widget)
+ file_layout.setContentsMargins(0, 0, 0, 0)
+ file_row = QHBoxLayout()
+ self.angle_file_path = QLineEdit(self)
+ self.angle_file_path.setPlaceholderText("CSV, JSON, YAML")
+ self.angle_file_button = QPushButton("Browse", self)
+ file_row.addWidget(self.angle_file_path)
+ file_row.addWidget(self.angle_file_button)
+ file_layout.addLayout(file_row)
+ self.angle_file_preview = QLabel("No angle file selected.", self)
+ self.angle_file_preview.setWordWrap(True)
+ file_layout.addWidget(self.angle_file_preview)
+
+ self.rotx_stack = QStackedWidget(self)
+ self.rotx_stack.addWidget(range_widget)
+ self.rotx_stack.addWidget(manual_widget)
+ self.rotx_stack.addWidget(file_widget)
+ self.angle_source_combo.currentIndexChanged.connect(self.rotx_stack.setCurrentIndex)
+ self.angle_file_button.clicked.connect(self._browse_angle_file)
+ self.angle_file_path.textChanged.connect(self._update_angle_file_preview)
+
+ extra = QGroupBox("Tensor tomography", self)
+ extra_form = QFormLayout(extra)
+ extra_form.addRow("Angle source", self.angle_source_combo)
+ extra_form.addRow(self.rotx_stack)
+
+ self.layout().insertWidget(1, extra)
+
+ def rotx_values(self) -> list[float]:
+ return [rotx for rotx, _ in self.angle_pairs()]
+
+ def angle_pairs(self) -> list[tuple[float, float]]:
+ if self.angle_source_combo.currentIndex() == 0:
+ return pair_angle_lists(
+ linspace_values(
+ self.rotx_start.value(), self.rotx_stop.value(), self.rotx_count.value()
+ ),
+ [self.generated_roty.value()],
+ )
+
+ if self.angle_source_combo.currentIndex() == 1:
+ rotx_values = parse_angle_text(self.manual_rotx_values.text())
+ roty_values = parse_angle_text(self.manual_roty_values.text())
+ return pair_angle_lists(rotx_values, roty_values or [0.0])
+
+ if not self.angle_file_path.text().strip():
+ raise ValueError("Choose an angle file.")
+ return load_angle_file(self.angle_file_path.text().strip())
+
+ def _browse_angle_file(self) -> None:
+ file_name, _ = QFileDialog.getOpenFileName(
+ self, "Load tensor angles", "", "Angle files (*.csv *.json *.yaml *.yml);;All files (*)"
+ )
+ if file_name:
+ self.angle_file_path.setText(file_name)
+
+ def _update_angle_file_preview(self) -> None:
+ file_name = self.angle_file_path.text().strip()
+ if not file_name:
+ self.angle_file_preview.setText("No angle file selected.")
+ return
+ try:
+ pairs = load_angle_file(file_name)
+ except Exception as exc: # noqa: BLE001 - user-facing validation preview
+ self.angle_file_preview.setText(f"Could not load angles: {exc}")
+ return
+ first = ", ".join(f"({x:g}, {y:g})" for x, y in pairs[:3])
+ suffix = "" if len(pairs) <= 3 else " ..."
+ self.angle_file_preview.setText(f"{len(pairs)} angle pairs: {first}{suffix}")
+
+ def values(self) -> dict:
+ values = super().values()
+ values["angle_pairs"] = self.angle_pairs()
+ return values
+
+ def accept(self) -> None:
+ try:
+ self.angle_pairs()
+ # File and parser libraries expose several unrelated exception hierarchies.
+ except Exception as exc: # noqa: BLE001
+ QMessageBox.warning(self, "Invalid angle values", str(exc))
+ return
+ super().accept()
+
+
+class RowEditDialog(ScanningSAXSDialog):
+ """Explicit edit dialog for an existing row."""
+
+ def __init__(self, parent: QWidget | None, row: SAXSRow):
+ super().__init__(
+ parent,
+ exposure_time=row.exposure_time,
+ fast_axis=row.fast_axis,
+ stepper_axis=row.stepper_axis,
+ fast_step=row.fast_step,
+ stepper_step=row.stepper_step,
+ linked=row.fast_step == row.stepper_step,
+ sample_name=row.sample_name,
+ comment=row.comment,
+ title="Edit SAXS row",
+ )
+ self.rotx = spinbox(value=row.rotx or 0.0, single_step=1.0, parent=self)
+ self.roty = spinbox(value=row.roty or 0.0, single_step=1.0, parent=self)
+ self.rotx.setEnabled(row.template == "tensor")
+ self.roty.setEnabled(row.template == "tensor")
+
+ extra = QGroupBox("Tensor angles", self)
+ extra_form = QFormLayout(extra)
+ extra_form.addRow("RotX", self.rotx)
+ extra_form.addRow("RotY", self.roty)
+ self.layout().insertWidget(1, extra)
+
+ def values(self) -> dict:
+ values = super().values()
+ values["rotx"] = self.rotx.value()
+ values["roty"] = self.roty.value()
+ return values
diff --git a/csaxs_bec/bec_widgets/widgets/saxs_widget/models.py b/csaxs_bec/bec_widgets/widgets/saxs_widget/models.py
new file mode 100644
index 0000000..8ea1139
--- /dev/null
+++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/models.py
@@ -0,0 +1,538 @@
+from __future__ import annotations
+
+import csv
+import json
+import math
+import re
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Literal
+
+from bec_lib import messages
+from bec_lib.endpoints import EndpointInfo, EndpointType, MessageOp
+
+from csaxs_bec.bec_widgets.widgets.saxs_widget.coordinates import axis_limits
+
+try:
+ import yaml
+except ImportError: # pragma: no cover - depends on the local environment
+ yaml = None
+
+
+DEFAULT_EXPOSURE_TIME = 0.05
+DEFAULT_STEP_SIZE = 0.025
+
+
+class MessageEndpointSAXSWidget:
+ """Custom endpoint definition for the SAXS widget."""
+
+ @staticmethod
+ def measurement_table(session_name: str) -> EndpointInfo:
+ """
+ Endpoint for SAXS widget measurement table updates. This endpoint is used by the device server to publish
+ the measurement table using a messages.VariableMessage message.
+
+ Args:
+ session_name (str): Session name, e.g. "session_1".
+ """
+ endpoint = f"{EndpointType.INFO.value}/saxs_widget/sessions/{session_name}"
+ return EndpointInfo(
+ endpoint=endpoint, message_type=messages.VariableMessage, message_op=MessageOp.STREAM
+ )
+
+
+def linspace_values(start: float, stop: float, count: int) -> list[float]:
+ """Return endpoint-inclusive values without depending on numpy in the widget layer."""
+ if count <= 0:
+ raise ValueError("count must be positive")
+ if count == 1:
+ return [float(start)]
+ step = (stop - start) / (count - 1)
+ return [float(start + step * index) for index in range(count)]
+
+
+def parse_angle_text(text: str) -> list[float]:
+ """Parse a comma, semicolon or whitespace separated list of angles."""
+ tokens = [token for token in re.split(r"[\s,;]+", text.strip()) if token]
+ if not tokens:
+ return []
+ try:
+ return [float(token) for token in tokens]
+ except ValueError as exc:
+ raise ValueError("Angle values must be numeric.") from exc
+
+
+def pair_angle_lists(rotx_values: list[float], roty_values: list[float] | None = None):
+ """Return paired RotX/RotY angles, broadcasting a singleton axis when needed."""
+ roty_values = roty_values or [0.0]
+ if not rotx_values:
+ raise ValueError("At least one RotX angle is required.")
+ try:
+ if len(rotx_values) == len(roty_values):
+ return [(float(x), float(y)) for x, y in zip(rotx_values, roty_values)]
+ if len(roty_values) == 1:
+ return [(float(x), float(roty_values[0])) for x in rotx_values]
+ if len(rotx_values) == 1:
+ return [(float(rotx_values[0]), float(y)) for y in roty_values]
+ except (TypeError, ValueError) as exc:
+ raise ValueError("RotX and RotY angle values must be numeric.") from exc
+ raise ValueError(
+ "RotX and RotY angle lists must have the same length, or one list must have one value."
+ )
+
+
+def normalize_angle_pairs(angle_pairs) -> list[tuple[float, float]]:
+ """Validate explicit RotX/RotY angle pairs from the public API."""
+ if not angle_pairs:
+ raise ValueError("At least one angle pair is required.")
+ try:
+ pairs = [(float(rotx), float(roty)) for rotx, roty in angle_pairs]
+ except (TypeError, ValueError) as exc:
+ raise ValueError("Angle pairs must be a list of two-value RotX/RotY entries.") from exc
+ if not pairs:
+ raise ValueError("At least one angle pair is required.")
+ return pairs
+
+
+def _coerce_angle_sequence(value) -> list[float]:
+ if value is None:
+ return []
+ if isinstance(value, str):
+ return parse_angle_text(value)
+ if isinstance(value, (int, float)):
+ return [float(value)]
+ try:
+ return [float(item) for item in value]
+ except (TypeError, ValueError) as exc:
+ raise ValueError("Angle values must be numeric.") from exc
+
+
+def _mapping_value(mapping: dict, *names: str):
+ lowered = {str(key).lower().replace("-", "_"): value for key, value in mapping.items()}
+ for name in names:
+ if name in lowered:
+ return lowered[name]
+ return None
+
+
+def _angle_pairs_from_records(records: list) -> list[tuple[float, float]]:
+ if not records:
+ raise ValueError("No angles found.")
+ if all(isinstance(record, dict) for record in records):
+ pairs = []
+ for record in records:
+ rotx = _mapping_value(record, "rotx", "rot_x", "x", "x_rotation")
+ roty = _mapping_value(record, "roty", "rot_y", "y", "y_rotation")
+ if rotx is None:
+ raise ValueError("Angle records must contain a RotX column/value.")
+ pairs.append((float(rotx), 0.0 if roty is None else float(roty)))
+ return pairs
+ if all(isinstance(record, (int, float, str)) for record in records):
+ return pair_angle_lists(_coerce_angle_sequence(records))
+
+ pairs = []
+ for record in records:
+ if len(record) == 0:
+ continue
+ rotx = float(record[0])
+ roty = float(record[1]) if len(record) > 1 and record[1] != "" else 0.0
+ pairs.append((rotx, roty))
+ if not pairs:
+ raise ValueError("No angles found.")
+ return pairs
+
+
+def _angle_pairs_from_mapping(payload: dict) -> list[tuple[float, float]]:
+ rotx = _mapping_value(payload, "rotx", "rot_x", "x", "x_rotation")
+ roty = _mapping_value(payload, "roty", "rot_y", "y", "y_rotation")
+ if rotx is None and "angles" in payload:
+ return _angle_pairs_from_records(payload["angles"])
+ if rotx is None:
+ raise ValueError("Angle file must contain RotX values.")
+ return pair_angle_lists(_coerce_angle_sequence(rotx), _coerce_angle_sequence(roty))
+
+
+def _load_csv_angle_file(path: Path) -> list[tuple[float, float]]:
+ with path.open(newline="", encoding="utf-8") as handle:
+ sample = handle.read(2048)
+ handle.seek(0)
+ has_header = csv.Sniffer().has_header(sample) if sample.strip() else False
+ if has_header:
+ return _angle_pairs_from_records(list(csv.DictReader(handle)))
+ return _angle_pairs_from_records([row for row in csv.reader(handle) if row])
+
+
+def load_angle_file(file_name: str | Path) -> list[tuple[float, float]]:
+ """Load RotX/RotY angle pairs from CSV, JSON or YAML."""
+ path = Path(file_name)
+ suffix = path.suffix.lower()
+ try:
+ if suffix == ".csv":
+ return _load_csv_angle_file(path)
+ if suffix == ".json":
+ with path.open(encoding="utf-8") as handle:
+ payload = json.load(handle)
+ elif suffix in {".yaml", ".yml"}:
+ if yaml is None:
+ raise ValueError("YAML angle files require PyYAML to be installed.")
+ with path.open(encoding="utf-8") as handle:
+ payload = yaml.safe_load(handle)
+ else:
+ raise ValueError("Angle files must be CSV, JSON, YAML or YML.")
+ except (OSError, csv.Error) as exc:
+ raise ValueError(f"Could not load angle file '{path}': {exc}") from exc
+
+ if isinstance(payload, dict):
+ return _angle_pairs_from_mapping(payload)
+ if isinstance(payload, list):
+ return _angle_pairs_from_records(payload)
+ raise ValueError("Unsupported angle file format.")
+
+
+def _normalize_axis(axis: str) -> Literal["x", "y"]:
+ value = str(axis).strip().lower()
+ if value not in {"x", "y"}:
+ raise ValueError("Axis must be 'x' or 'y'.")
+ return value # type: ignore[return-value]
+
+
+def cont_grid_point_counts(
+ fast_start: float,
+ fast_end: float,
+ stepper_start: float,
+ stepper_stop: float,
+ fast_step: float,
+ stepper_step: float,
+) -> tuple[int, int, int]:
+ """Match ``ContGrid.prepare_scan`` point count calculation."""
+ if fast_step <= 0 or stepper_step <= 0:
+ raise ValueError("Step sizes must be positive.")
+ if math.isclose(fast_start, fast_end, abs_tol=fast_step):
+ raise ValueError("Fast start and stop are too close for the selected step size.")
+ if math.isclose(stepper_start, stepper_stop, abs_tol=stepper_step):
+ raise ValueError("Stepper start and stop are too close for the selected step size.")
+
+ images_per_line = int(math.ceil(abs(fast_end - fast_start) / fast_step)) + 1
+ num_lines = int(math.ceil(abs(stepper_stop - stepper_start) / stepper_step)) + 1
+ return images_per_line, num_lines, images_per_line * num_lines
+
+
+@dataclass(frozen=True)
+class RawROI:
+ """Raw ROI coordinates in image pixels, as reported by the ROI controller."""
+
+ x: float
+ y: float
+ width: float
+ height: float
+
+
+@dataclass(frozen=True)
+class CameraCenter:
+ """Motor coordinates that correspond to the camera reference point."""
+
+ x: float = 0.0
+ y: float = 0.0
+
+
+@dataclass(frozen=True)
+class Correction:
+ """Corrections to convert raw ROI coordinates to beam positions."""
+
+ camera_scale_pixel_x: float
+ offset_cam_xray_x: float
+ direction_x: int
+ camera_scale_pixel_y: float
+ offset_cam_xray_y: float
+ direction_y: int
+ reference_x: float = 0.0
+ reference_y: float = 0.0
+
+
+@dataclass(frozen=True)
+class ScanGeometry:
+ """Scan geometry in physical units, after applying corrections to the raw ROI."""
+
+ fast_axis: Literal["x", "y"]
+ fast_start: float
+ fast_stop: float
+ fast_step: float
+ stepper_axis: Literal["x", "y"]
+ stepper_start: float
+ stepper_stop: float
+ stepper_step: float
+ images_per_line: int
+ num_lines: int
+ total_frames: int
+
+ @property
+ def grid_label(self) -> str:
+ return f"{self.images_per_line} x {self.num_lines} = {self.total_frames}"
+
+ @property
+ def x_start(self) -> float:
+ return self.fast_start if self.fast_axis == "x" else self.stepper_start
+
+ @property
+ def x_stop(self) -> float:
+ return self.fast_stop if self.fast_axis == "x" else self.stepper_stop
+
+ @property
+ def y_start(self) -> float:
+ return self.fast_start if self.fast_axis == "y" else self.stepper_start
+
+ @property
+ def y_stop(self) -> float:
+ return self.fast_stop if self.fast_axis == "y" else self.stepper_stop
+
+
+@dataclass(frozen=True)
+class ScanResult:
+ """Result of a scan, including its number and success status."""
+
+ scan_number: str
+ success: bool
+
+
+@dataclass
+class SAXSRow:
+ """A row in the SAXS scan table, representing a single scan."""
+
+ sample_name: str
+ sample_id: str
+ raw_roi: RawROI
+ correction: Correction
+ geometry: ScanGeometry
+ exposure_time: float
+ camera_center: CameraCenter
+ comment: str = ""
+ template: Literal["scanning", "tensor"] = "scanning"
+ rotx: float | None = None
+ roty: float | None = None
+ x_positioner: str = ""
+ y_positioner: str = ""
+ rotx_axis: str = ""
+ roty_axis: str = ""
+ scan_results: list[ScanResult] = field(default_factory=list)
+
+ @property
+ def fast_axis(self) -> Literal["x", "y"]:
+ return self.geometry.fast_axis
+
+ @property
+ def stepper_axis(self) -> Literal["x", "y"]:
+ return self.geometry.stepper_axis
+
+ @property
+ def fast_start(self) -> float:
+ return self.geometry.fast_start
+
+ @property
+ def fast_stop(self) -> float:
+ return self.geometry.fast_stop
+
+ @property
+ def fast_step(self) -> float:
+ return self.geometry.fast_step
+
+ @property
+ def stepper_start(self) -> float:
+ return self.geometry.stepper_start
+
+ @property
+ def stepper_stop(self) -> float:
+ return self.geometry.stepper_stop
+
+ @property
+ def stepper_step(self) -> float:
+ return self.geometry.stepper_step
+
+ @property
+ def step_size_x(self) -> float:
+ return self.fast_step if self.fast_axis == "x" else self.stepper_step
+
+ @property
+ def step_size_y(self) -> float:
+ return self.fast_step if self.fast_axis == "y" else self.stepper_step
+
+ @property
+ def fast_positioner(self) -> str:
+ return self.x_positioner if self.fast_axis == "x" else self.y_positioner
+
+ @property
+ def stepper_positioner(self) -> str:
+ return self.y_positioner if self.fast_axis == "x" else self.x_positioner
+
+ def _flat_payload(self) -> dict:
+ """Return one canonical flat payload for CSV and broker transport."""
+ geometry = self.geometry
+ scan_results_json = json.dumps(
+ [
+ {"scan_number": result.scan_number, "success": result.success}
+ for result in self.scan_results
+ ],
+ separators=(",", ":"),
+ )
+ return {
+ "template": self.template,
+ "sample_name": self.sample_name,
+ "sample_id": self.sample_id,
+ "exposure_time": self.exposure_time,
+ "comment": self.comment,
+ "fast_axis": self.fast_positioner or geometry.fast_axis,
+ "fast_start": geometry.fast_start,
+ "fast_stop": geometry.fast_stop,
+ "fast_step": geometry.fast_step,
+ "stepper_axis": self.stepper_positioner or geometry.stepper_axis,
+ "stepper_start": geometry.stepper_start,
+ "stepper_stop": geometry.stepper_stop,
+ "stepper_step": geometry.stepper_step,
+ "images_per_line": geometry.images_per_line,
+ "num_lines": geometry.num_lines,
+ "total_frames": geometry.total_frames,
+ "fast_axis_coordinate": geometry.fast_axis,
+ "stepper_axis_coordinate": geometry.stepper_axis,
+ "x_positioner": self.x_positioner,
+ "y_positioner": self.y_positioner,
+ "rotx_axis": self.rotx_axis,
+ "roty_axis": self.roty_axis,
+ "rotx": self.rotx,
+ "roty": self.roty,
+ "camera_center_x": self.camera_center.x,
+ "camera_center_y": self.camera_center.y,
+ "raw_roi_x": self.raw_roi.x,
+ "raw_roi_y": self.raw_roi.y,
+ "raw_roi_width": self.raw_roi.width,
+ "raw_roi_height": self.raw_roi.height,
+ "correction_camera_scale_pixel_x": self.correction.camera_scale_pixel_x,
+ "correction_offset_cam_xray_x": self.correction.offset_cam_xray_x,
+ "correction_direction_x": self.correction.direction_x,
+ "correction_camera_scale_pixel_y": self.correction.camera_scale_pixel_y,
+ "correction_offset_cam_xray_y": self.correction.offset_cam_xray_y,
+ "correction_direction_y": self.correction.direction_y,
+ "correction_reference_x": self.correction.reference_x,
+ "correction_reference_y": self.correction.reference_y,
+ "scan_results": scan_results_json,
+ # Convenience aliases for scan macro consumers.
+ "step_size_x": self.step_size_x,
+ "step_size_y": self.step_size_y,
+ }
+
+ def to_message_dict(self) -> dict:
+ """Return a transport-safe flat payload for broker transport."""
+ return self._flat_payload()
+
+ def to_dict(self) -> dict:
+ return self.to_message_dict()
+
+ def to_csv_dict(self) -> dict:
+ """Return a flat dict suitable for CSV export."""
+ return self._flat_payload()
+
+ @staticmethod
+ def help() -> dict[str, str]:
+ """Return human-readable documentation for all exported payload fields."""
+ return {
+ "template": "Row template type, for example scanning or tensor.",
+ "sample_name": "User-provided sample label.",
+ "sample_id": "Generated short unique ID for this row.",
+ "exposure_time": "Exposure time in seconds.",
+ "comment": "Optional free-text comment.",
+ "fast_axis": "Resolved motor name used as fast axis, for example samx or samy.",
+ "fast_start": "Physical start position of the fast axis.",
+ "fast_stop": "Physical stop position of the fast axis.",
+ "fast_step": "Step size used along the fast axis.",
+ "stepper_axis": "Resolved motor name used as stepper axis.",
+ "stepper_start": "Physical start position of the stepper axis.",
+ "stepper_stop": "Physical stop position of the stepper axis.",
+ "stepper_step": "Step size used along the stepper axis.",
+ "images_per_line": "Number of images acquired per fast-axis line.",
+ "num_lines": "Number of stepper-axis lines.",
+ "total_frames": "Total number of images, images_per_line multiplied by num_lines.",
+ "fast_axis_coordinate": "Coordinate axis letter selected as fast axis, x or y.",
+ "stepper_axis_coordinate": "Coordinate axis letter selected as stepper axis, x or y.",
+ "x_positioner": "Configured motor name representing the X coordinate.",
+ "y_positioner": "Configured motor name representing the Y coordinate.",
+ "rotx_axis": "Configured rotation motor name for RotX.",
+ "roty_axis": "Configured rotation motor name for RotY.",
+ "rotx": "RotX angle value for this row, null for non-tensor rows.",
+ "roty": "RotY angle value for this row, null for non-tensor rows.",
+ "camera_center_x": "X motor position that corresponded to the image reference point when the row was created.",
+ "camera_center_y": "Y motor position that corresponded to the image reference point when the row was created.",
+ "raw_roi_x": "Raw ROI X origin in image coordinates.",
+ "raw_roi_y": "Raw ROI Y origin in image coordinates.",
+ "raw_roi_width": "Raw ROI width in image coordinates.",
+ "raw_roi_height": "Raw ROI height in image coordinates.",
+ "correction_camera_scale_pixel_x": "Camera pixel-to-motor scale applied from ROI X to physical X.",
+ "correction_offset_cam_xray_x": "Calibrated X offset from the camera reference point to the x-ray beam.",
+ "correction_direction_x": "Direction sign for X mapping, typically +1 or -1.",
+ "correction_camera_scale_pixel_y": "Camera pixel-to-motor scale applied from ROI Y to physical Y.",
+ "correction_offset_cam_xray_y": "Calibrated Y offset from the camera reference point to the x-ray beam.",
+ "correction_direction_y": "Direction sign for Y mapping, typically +1 or -1.",
+ "correction_reference_x": "Image coordinate used as the camera reference point for X.",
+ "correction_reference_y": "Image coordinate used as the camera reference point for Y.",
+ "scan_results": "JSON string list of scan result objects with scan_number and success.",
+ "step_size_x": "Convenience alias of step size in X coordinates.",
+ "step_size_y": "Convenience alias of step size in Y coordinates.",
+ }
+
+
+def corrected_geometry(
+ raw_roi: RawROI,
+ correction: Correction,
+ fast_axis: Literal["x", "y"] = "x",
+ fast_step: float = DEFAULT_STEP_SIZE,
+ stepper_axis: Literal["x", "y"] = "y",
+ stepper_step: float = DEFAULT_STEP_SIZE,
+ *,
+ camera_center: CameraCenter | tuple[float, float],
+) -> ScanGeometry:
+ fast_axis = _normalize_axis(fast_axis)
+ stepper_axis = _normalize_axis(stepper_axis)
+ if fast_axis == stepper_axis:
+ raise ValueError("Fast axis and stepper axis must be different.")
+
+ if isinstance(camera_center, CameraCenter):
+ center_x, center_y = camera_center.x, camera_center.y
+ else:
+ center_x, center_y = camera_center
+
+ def roi_axis_limits(axis: Literal["x", "y"]) -> tuple[float, float]:
+ if axis == "x":
+ return axis_limits(
+ raw_roi.x,
+ raw_roi.width,
+ reference=correction.reference_x,
+ camera_scale_pixel=correction.camera_scale_pixel_x,
+ direction=correction.direction_x,
+ camera_center=center_x,
+ offset_cam_xray=correction.offset_cam_xray_x,
+ )
+ return axis_limits(
+ raw_roi.y,
+ raw_roi.height,
+ reference=correction.reference_y,
+ camera_scale_pixel=correction.camera_scale_pixel_y,
+ direction=correction.direction_y,
+ camera_center=center_y,
+ offset_cam_xray=correction.offset_cam_xray_y,
+ )
+
+ fast_start, fast_stop = roi_axis_limits(fast_axis)
+ stepper_start, stepper_stop = roi_axis_limits(stepper_axis)
+ images_per_line, num_lines, total_frames = cont_grid_point_counts(
+ fast_start, fast_stop, stepper_start, stepper_stop, fast_step, stepper_step
+ )
+ return ScanGeometry(
+ fast_axis=fast_axis,
+ fast_start=fast_start,
+ fast_stop=fast_stop,
+ fast_step=fast_step,
+ stepper_axis=stepper_axis,
+ stepper_start=stepper_start,
+ stepper_stop=stepper_stop,
+ stepper_step=stepper_step,
+ images_per_line=images_per_line,
+ num_lines=num_lines,
+ total_frames=total_frames,
+ )
diff --git a/csaxs_bec/bec_widgets/widgets/saxs_widget/register_saxs_widget.py b/csaxs_bec/bec_widgets/widgets/saxs_widget/register_saxs_widget.py
new file mode 100644
index 0000000..2baf060
--- /dev/null
+++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/register_saxs_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.saxs_widget.saxs_widget_plugin import SAXSWidgetPlugin
+
+ QPyDesignerCustomWidgetCollection.addCustomWidget(SAXSWidgetPlugin())
+
+
+if __name__ == "__main__": # pragma: no cover
+ main()
diff --git a/csaxs_bec/bec_widgets/widgets/saxs_widget/saxs_widget.py b/csaxs_bec/bec_widgets/widgets/saxs_widget/saxs_widget.py
new file mode 100644
index 0000000..64bd4dd
--- /dev/null
+++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/saxs_widget.py
@@ -0,0 +1,1946 @@
+"""Custom Widget to setup grid scan regions for scanning SAXS and SAXS tensor tomography measurement"""
+
+from __future__ import annotations
+
+import csv
+import json
+import keyword
+import re
+from dataclasses import replace
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Literal
+from uuid import uuid4
+
+from bec_lib import bec_logger
+from bec_lib.messages import VariableMessage
+from bec_qthemes import material_icon
+from bec_widgets.utils.bec_widget import BECWidget
+from bec_widgets.utils.colors import Colors, get_accent_colors, get_theme_name, rgba, theme_color
+from bec_widgets.utils.error_popups import SafeSlot
+from bec_widgets.utils.toolbars.actions import MaterialIconAction
+from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox2D
+from bec_widgets.widgets.plots.image.image import Image
+from bec_widgets.widgets.plots.roi.image_roi import RectangularROI
+from qtpy.QtCore import Qt, QTimer, Signal
+from qtpy.QtGui import QPalette
+from qtpy.QtWidgets import (
+ QApplication,
+ QDialog,
+ QFileDialog,
+ QFrame,
+ QGroupBox,
+ QHBoxLayout,
+ QInputDialog,
+ QLabel,
+ QMessageBox,
+ QPushButton,
+ QSizePolicy,
+ QSplitter,
+ QVBoxLayout,
+ QWidget,
+)
+
+from csaxs_bec.bec_widgets.widgets.saxs_widget.controls import (
+ DeviceSelectionBox,
+ ROIMappingBox,
+ separator,
+)
+from csaxs_bec.bec_widgets.widgets.saxs_widget.dialogs import (
+ RowEditDialog,
+ ScanningSAXSDialog,
+ TensorSAXSDialog,
+)
+from csaxs_bec.bec_widgets.widgets.saxs_widget.models import (
+ DEFAULT_EXPOSURE_TIME,
+ DEFAULT_STEP_SIZE,
+ CameraCenter,
+ Correction,
+ MessageEndpointSAXSWidget,
+ RawROI,
+ SAXSRow,
+ ScanResult,
+ corrected_geometry,
+ normalize_angle_pairs,
+ pair_angle_lists,
+)
+from csaxs_bec.bec_widgets.widgets.saxs_widget.table import SAXSTableWidget
+
+logger = bec_logger.logger
+
+
+class SAXSWidget(BECWidget, QWidget):
+ """Widget for preparing SAXS measurement tables from an image ROI."""
+
+ PLUGIN = True
+ ICON_NAME = "table_chart"
+ USER_ACCESS = [
+ "add_scanning_row",
+ "add_tensor_rows",
+ "clear_rows",
+ "delete_selected_rows",
+ "export_csv",
+ "rows_as_dicts",
+ "set_positioners",
+ "mark_scan_result",
+ "move_to_selected_row",
+ ]
+
+ ROI_NAME = "SAXS ROI"
+
+ def __init__(self, parent=None, **kwargs):
+ kwargs.setdefault("theme_update", True)
+ super().__init__(parent=parent, **kwargs)
+ self.get_bec_shortcuts()
+ self._rows: list[SAXSRow] = []
+ self._saxs_roi: RectangularROI | None = None
+ self._last_exposure_time = DEFAULT_EXPOSURE_TIME
+ self._last_fast_axis = "x"
+ self._last_stepper_axis = "y"
+ self._last_fast_step = DEFAULT_STEP_SIZE
+ self._last_stepper_step = DEFAULT_STEP_SIZE
+ self._active_session: str | None = None
+ self._session_dirty = False
+ self._crosshair_pin_enabled = False
+ self._mapping_reference: tuple[float, float] | None = None
+ self._mapping_reference_image_shape: tuple[int, int] | None = None
+ self._crosshair_pin_position: tuple[float, float] | None = None
+
+ self._accent_colors = get_accent_colors()
+ self._roi_color = self._accent_colors.highlight or "#00bcd4"
+ self._build_ui()
+ self.apply_theme()
+ self._connect_signals()
+ self._ensure_saxs_roi()
+ self._sync_mapping_reference_to_display()
+ self._refresh_crosshair_pin_if_enabled()
+
+ # ------------------------------------------------------------------
+ # UI construction
+ # ------------------------------------------------------------------
+
+ def _build_ui(self) -> None:
+ root = QVBoxLayout(self)
+ root.setContentsMargins(8, 8, 8, 8)
+ root.setSpacing(8)
+
+ self.main_splitter = QSplitter(Qt.Orientation.Vertical, self)
+ self.main_splitter.setObjectName("saxs_main_splitter")
+ self.main_splitter.setChildrenCollapsible(False)
+ root.addWidget(self.main_splitter, 1)
+
+ self._build_top_panel()
+ self._build_table_panel()
+ self.main_splitter.setStretchFactor(0, 3)
+ self.main_splitter.setStretchFactor(1, 2)
+ self._update_session_ui_state()
+
+ def _build_top_panel(self) -> None:
+ top = QWidget(self.main_splitter)
+ top.setObjectName("saxs_top_panel")
+ top_layout = QHBoxLayout(top)
+ top_layout.setContentsMargins(0, 0, 0, 0)
+ top_layout.setSpacing(8)
+
+ setup_group, setup_layout = self._create_content_group(
+ "Setup", top, object_name="saxs_setup_group"
+ )
+ setup_group.setMinimumWidth(640)
+ setup_group.setMaximumWidth(860)
+
+ camera_group, camera_layout = self._create_content_group(
+ "Camera View", top, object_name="saxs_camera_group"
+ )
+ camera_layout.addWidget(self._create_image_widget(camera_group), 1)
+
+ setup_layout.addWidget(self._build_device_mapping_panel(setup_group))
+ setup_layout.addWidget(separator(setup_group))
+ setup_layout.addWidget(self._create_positioner_box(setup_group))
+ setup_layout.addStretch()
+
+ top_layout.addWidget(camera_group, 1)
+ top_layout.addWidget(setup_group)
+ self.main_splitter.addWidget(top)
+
+ def _build_table_panel(self) -> None:
+ bottom = QWidget(self.main_splitter)
+ bottom.setObjectName("saxs_bottom_panel")
+ bottom_layout = QHBoxLayout(bottom)
+ bottom_layout.setContentsMargins(0, 0, 0, 0)
+ bottom_layout.setSpacing(8)
+
+ self.table_group, table_group_layout = self._create_content_group(
+ "Measurement planning", bottom, object_name="saxs_table_group"
+ )
+
+ table_group_layout.addWidget(self._build_table_session_bar())
+ table_group_layout.addWidget(self._create_table_widget(self.table_group), 1)
+
+ bottom_layout.addWidget(self.table_group, 1)
+ self.main_splitter.addWidget(bottom)
+
+ @staticmethod
+ def _create_content_group(
+ title: str, parent: QWidget, *, object_name: str
+ ) -> tuple[QGroupBox, QVBoxLayout]:
+ """Create a consistently spaced content group used by the main layout."""
+ group = QGroupBox(title, parent)
+ group.setObjectName(object_name)
+ layout = QVBoxLayout(group)
+ layout.setContentsMargins(10, 12, 10, 10)
+ layout.setSpacing(8)
+ return group, layout
+
+ def _create_image_widget(self, parent: QWidget) -> Image:
+ self.image = Image(parent=parent)
+ self._customize_image_widget(self.image)
+ return self.image
+
+ def _build_device_mapping_panel(self, parent: QWidget) -> QWidget:
+ panel = QWidget(parent)
+ layout = QHBoxLayout(panel)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(8)
+
+ self.device_box = DeviceSelectionBox(panel)
+ self.mapping_box = ROIMappingBox(panel)
+ layout.addWidget(self.device_box, 1)
+ layout.addWidget(self.mapping_box, 1)
+ return panel
+
+ def _create_positioner_box(self, parent: QWidget) -> PositionerBox2D:
+ self.positioner_box = PositionerBox2D(parent=parent)
+ self.positioner_box.hide_device_selection = True
+ self._customize_positioner_box(self.positioner_box)
+ return self.positioner_box
+
+ @staticmethod
+ def _customize_positioner_box(positioner_box: PositionerBox2D) -> None:
+ """Keep only the compact two-axis controls needed in this widget."""
+ for direction in ["horizontal", "vertical"]:
+ ui = positioner_box._device_ui_components_hv(direction)
+ for name, widget in ui.items():
+ if name == "step_size":
+ widget.setValue(1)
+ continue
+ if name not in {
+ "tweak_increase",
+ "tweak_decrease",
+ "step_size",
+ "readback",
+ "device_box",
+ }:
+ widget.hide()
+
+ def _create_table_widget(self, parent: QWidget) -> SAXSTableWidget:
+ self.table_widget = SAXSTableWidget(parent)
+ self.table = self.table_widget.table
+ self.add_scanning_button = self.table_widget.add_scanning_button
+ self.add_tensor_button = self.table_widget.add_tensor_button
+ self.status_label = self.table_widget.status_label
+ return self.table_widget
+
+ # ------------------------------------------------------------------
+ # Image component and crosshair reference
+ # ------------------------------------------------------------------
+
+ def _customize_image_widget(self, image: Image) -> None:
+ image.enable_full_colorbar = True
+ image.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
+ # Enable crosshair toggle in the image toolbar.
+ toggle_cross_action = MaterialIconAction(
+ icon_name="add",
+ checkable=True,
+ label_text="Show/Hide Crosshair",
+ text_position="under",
+ parent=image,
+ tooltip="Toggle crosshair visibility in the image",
+ )
+ toggle_cross_action.action.toggled.connect(self._toggle_crosshair_visibility)
+ toggle_cross_action.action.setChecked(True)
+ image.toolbar.components.add_safe("toggle_cross_action", toggle_cross_action)
+ image.toolbar.get_bundle("device_selection").add_action("toggle_cross_action")
+ # Enable Live mode for camera
+ toggle_cam = MaterialIconAction(
+ icon_name="videocam",
+ checkable=True,
+ label_text="Live Camera Mode",
+ text_position="under",
+ parent=image,
+ tooltip="Toggle camera live mode",
+ )
+ toggle_cam.action.toggled.connect(self._toggle_camera_live_mode)
+ toggle_cam.action.setChecked(True)
+ image.toolbar.components.add_safe("toggle_cam", toggle_cam)
+ image.toolbar.get_bundle("device_selection").add_action("toggle_cam")
+ image.toolbar.show_bundles(["device_selection"])
+
+ def _toggle_crosshair_visibility(self, enabled: bool) -> None:
+ self._crosshair_pin_enabled = bool(enabled)
+ if enabled:
+ self._refresh_crosshair_pin_if_enabled(force=True)
+ return
+ self._clear_reference_crosshair_pin()
+ # self.image.toggle_crosshair(False)
+
+ def _toggle_camera_live_mode(self, enabled: bool) -> None:
+ # If enabled, set the camera to live mode, if disabled, stop the live mode.
+ device_name = getattr(self.image, "device", "")
+ if device_name and device_name in self.dev:
+ device = self.dev[device_name]
+ if hasattr(device, "start_live_mode") and enabled:
+ try:
+ device.start_live_mode()
+ except Exception as exc:
+ logger.warning(f"Failed to toggle live mode for {device_name}: {exc}")
+ elif hasattr(device, "stop_live_mode") and not enabled:
+ try:
+ device.stop_live_mode()
+ except Exception as exc:
+ logger.warning(f"Failed to toggle live mode for {device_name}: {exc}")
+ else:
+ logger.warning(
+ f"Device {device_name} does not have start_live_mode or stop_live_mode methods."
+ )
+
+ def _refresh_crosshair_pin_if_enabled(self, force: bool = False) -> None:
+ if self._crosshair_pin_enabled:
+ pin_position = self._mapping_reference_position()
+ if (
+ not force
+ and self._positions_match(self._crosshair_pin_position, pin_position)
+ and self._has_reference_crosshair_pin()
+ ):
+ return
+ self._place_reference_crosshair_pin(pin_position)
+
+ def _sync_mapping_reference_to_display(self) -> None:
+ shape = self._display_image_shape()
+ if shape is None:
+ if self._mapping_reference is None or self._mapping_reference_image_shape is not None:
+ self._set_mapping_reference_position(*self._roi_center_position(), image_shape=None)
+ return
+ self._update_mapping_reference_fields()
+ return
+
+ if self._mapping_reference is None or self._mapping_reference_image_shape != shape:
+ self._set_mapping_reference_position(
+ *self._image_center_position(shape), image_shape=shape
+ )
+ return
+ self._update_mapping_reference_fields()
+
+ def _on_image_updated(self, *_args) -> None:
+ self._sync_mapping_reference_to_display()
+ self._refresh_crosshair_pin_if_enabled()
+
+ def _on_roi_reference_changed(self, *_args) -> None:
+ self._sync_roi_size_fields()
+ shape = self._display_image_shape()
+ if shape is None:
+ self._set_mapping_reference_position(*self._roi_center_position(), image_shape=None)
+ elif self._mapping_reference is None:
+ self._set_mapping_reference_position(
+ *self._image_center_position(shape), image_shape=shape
+ )
+ self._refresh_crosshair_pin_if_enabled()
+
+ def _place_reference_crosshair_pin(
+ self, pin_position: tuple[float, float] | None = None
+ ) -> None:
+ if getattr(self, "image", None) is None:
+ return
+ if self.image.crosshair is None:
+ self.image.toggle_crosshair(True)
+ crosshair = self.image.crosshair
+ if crosshair is None:
+ return
+
+ pin_x, pin_y = pin_position or self._mapping_reference_position()
+ image_item = self.image.main_image
+ item_name = image_item.objectName() or str(id(image_item))
+ snap_x = {item_name: int(pin_x)}
+ snap_y = {item_name: int(pin_y)}
+ crosshair.set_pin(pin_x, pin_y, snap_x, snap_y)
+ if crosshair.pinned_pos is None:
+ # ``set_pin`` needs at least one visible data item. During early widget
+ # construction there may be no image data yet, so draw the same pin
+ # position directly as a fallback.
+ crosshair._draw_pin(pin_x, pin_y)
+ crosshair.pinned_pos = (pin_x, pin_y)
+ crosshair.mouse_moved(manual_pos=(pin_x, pin_y))
+ self._crosshair_pin_position = (pin_x, pin_y)
+ self._update_reference_crosshair_label(pin_x, pin_y)
+ self.image.toggle_crosshair(False)
+
+ @staticmethod
+ def _positions_match(
+ first: tuple[float, float] | None, second: tuple[float, float] | None
+ ) -> bool:
+ if first is None or second is None:
+ return False
+ return abs(first[0] - second[0]) <= 1e-9 and abs(first[1] - second[1]) <= 1e-9
+
+ def _has_reference_crosshair_pin(self) -> bool:
+ crosshair = getattr(self.image, "crosshair", None)
+ if crosshair is not None and crosshair.pinned_pos is not None:
+ return True
+ detached_pin = getattr(self.image, "_detached_pin", None)
+ return detached_pin is not None and detached_pin.get("pos") is not None
+
+ def _clear_reference_crosshair_pin(self) -> None:
+ crosshair = getattr(self.image, "crosshair", None)
+ if crosshair is not None:
+ crosshair.clear_pin()
+ if hasattr(self.image, "_discard_detached_pin"):
+ self.image._discard_detached_pin()
+ self._crosshair_pin_position = None
+
+ def _update_reference_crosshair_label(self, image_x: float, image_y: float) -> None:
+ crosshair = getattr(self.image, "crosshair", None)
+ pinned_label = crosshair.pinned_label if crosshair is not None else None
+ if pinned_label is None:
+ detached_pin = getattr(self.image, "_detached_pin", None)
+ if detached_pin is not None:
+ pinned_label = detached_pin.get("label")
+ if pinned_label is None:
+ return
+ pinned_label.setText(f"pin\nx: {image_x:.6g} px\ny: {image_y:.6g} px")
+
+ def _display_image_shape(self) -> tuple[int, int] | None:
+ try:
+ data = self.image.main_image.image
+ except (AttributeError, KeyError):
+ return None
+ shape = getattr(data, "shape", None)
+ if data is None or shape is None or len(shape) < 2:
+ return None
+ if shape[0] <= 0 or shape[1] <= 0:
+ return None
+ return int(shape[0]), int(shape[1])
+
+ @staticmethod
+ def _image_center_position(shape: tuple[int, int]) -> tuple[float, float]:
+ return float(shape[0]) / 2.0, float(shape[1]) / 2.0
+
+ def _roi_center_position(self) -> tuple[float, float]:
+ roi = self._ensure_saxs_roi()
+ coords = roi.get_coordinates(typed=True)
+ return float(coords["center_x"]), float(coords["center_y"])
+
+ def _set_mapping_reference_position(
+ self, x: float, y: float, *, image_shape: tuple[int, int] | None
+ ) -> None:
+ self._mapping_reference = (float(x), float(y))
+ self._mapping_reference_image_shape = image_shape
+ self._update_mapping_reference_fields()
+
+ def _update_mapping_reference_fields(self) -> None:
+ if self._mapping_reference is None or not hasattr(self, "mapping_box"):
+ return
+ self.mapping_box.set_reference_position(*self._mapping_reference)
+
+ def _mapping_reference_position(self) -> tuple[float, float]:
+ self._sync_mapping_reference_to_display()
+ return self._mapping_reference or (0.0, 0.0)
+
+ # ------------------------------------------------------------------
+ # Table session controls
+ # ------------------------------------------------------------------
+
+ def _build_table_session_bar(self) -> QWidget:
+ bar = QFrame(self.table_group)
+ bar.setObjectName("saxs_session_bar")
+ bar.setFrameShape(QFrame.Shape.NoFrame)
+ bar.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
+ layout = QHBoxLayout(bar)
+ layout.setContentsMargins(12, 8, 12, 8)
+ layout.setSpacing(8)
+
+ self.session_icon_label = QLabel(bar)
+ self.session_icon_label.setObjectName("saxs_session_icon")
+ self.session_icon_label.setFixedSize(20, 20)
+ self.session_icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ session_title = QLabel("Session", bar)
+ session_title.setObjectName("saxs_session_title")
+ self.session_name_label = QLabel("none", bar)
+ self.session_name_label.setObjectName("saxs_session_name")
+ self.session_state_label = QLabel("No active session", bar)
+ self.session_state_label.setObjectName("saxs_session_status")
+ self.session_state_label.setProperty("sessionState", "none")
+
+ self.new_session_button = QPushButton("New session", bar)
+ self.load_session_button = QPushButton("Load session", bar)
+ self.delete_session_button = QPushButton("Delete session", bar)
+
+ self.new_session_button.setIcon(material_icon("add", size=(16, 16)))
+ self.load_session_button.setIcon(material_icon("folder_open", size=(16, 16)))
+ self.delete_session_button.setIcon(material_icon("delete", size=(16, 16)))
+ for button in [
+ self.new_session_button,
+ self.load_session_button,
+ self.delete_session_button,
+ ]:
+ button.setProperty("saxsButtonRole", "session")
+ button.setMinimumHeight(32)
+ button.setCursor(Qt.CursorShape.PointingHandCursor)
+ button.setFlat(True)
+ self.delete_session_button.setProperty("saxsButtonRole", "danger")
+
+ layout.addWidget(self.session_icon_label)
+ layout.addWidget(session_title)
+ layout.addWidget(self.session_name_label)
+ layout.addWidget(self.session_state_label)
+ layout.addStretch()
+ layout.addWidget(self.new_session_button)
+ layout.addWidget(self.load_session_button)
+ layout.addWidget(self.delete_session_button)
+ return bar
+
+ # ------------------------------------------------------------------
+ # Signal wiring and theme
+ # ------------------------------------------------------------------
+
+ def _connect_signals(self) -> None:
+ self.device_box.x_positioner_changed.connect(self.positioner_box.set_positioner_hor)
+ self.device_box.y_positioner_changed.connect(self.positioner_box.set_positioner_ver)
+ self.device_box.sastt_enabled_changed.connect(self._update_sastt_enabled)
+ self.mapping_box.apply_requested.connect(self._prompt_apply_corrections)
+ self.mapping_box.roi_size_requested.connect(self._apply_roi_size)
+ self.image.image_updated.connect(self._on_image_updated)
+
+ self.table_widget.edit_requested.connect(self._edit_row_by_index)
+ self.table_widget.move_to_selected_requested.connect(self.move_to_selected_row)
+ self.table_widget.rows_reordered.connect(self.reorder_rows)
+ self.table_widget.clear_selected_requested.connect(self.delete_selected_rows)
+ self.table_widget.clear_all_requested.connect(self.clear_rows)
+ self.table_widget.save_requested.connect(self.save_session)
+ self.table_widget.store_csv_requested.connect(self.store_rows_as_csv)
+
+ self.new_session_button.clicked.connect(self._new_session)
+ self.load_session_button.clicked.connect(self._load_session)
+ self.delete_session_button.clicked.connect(self._delete_session)
+
+ self.table_widget.add_scanning_requested.connect(self.add_scanning_row)
+ self.table_widget.add_tensor_requested.connect(self.add_tensor_rows)
+ self.image.roi_controller.roiRemoved.connect(self._on_roi_removed)
+
+ self._update_sastt_enabled(self.device_box.is_sastt_enabled())
+
+ @SafeSlot(str)
+ @SafeSlot()
+ def apply_theme(self, _theme: str | None = None) -> None:
+ app = QApplication.instance()
+ palette = app.palette() if app is not None else QPalette()
+ theme = getattr(app, "theme", None) if app is not None else None
+ accents = get_accent_colors()
+ light_theme = get_theme_name() == "light"
+
+ card_bg = theme_color(theme, "CARD_BG", palette.base().color())
+ border = theme_color(theme, "BORDER", palette.mid().color())
+ foreground = theme_color(theme, "FG", palette.text().color())
+ accent = accents.default
+ success = accents.success
+ warning = accents.warning
+ emergency = accents.emergency
+
+ panel_bg = Colors._blend(card_bg, accent, 0.03 if light_theme else 0.08)
+ recessed_bg = Colors._blend(card_bg, foreground, 0.03 if light_theme else 0.10)
+ muted = Colors._blend(card_bg, foreground, 0.62)
+ accent_border = Colors._blend(border, accent, 0.38)
+ subtle_border = Colors._blend(border, accent, 0.18)
+ danger_bg = rgba(emergency, 28 if light_theme else 44)
+ hover_bg = rgba(accent, 26 if light_theme else 42)
+
+ if hasattr(self, "session_icon_label"):
+ self.session_icon_label.setPixmap(
+ material_icon("workspaces", size=(18, 18), color=accent.name(), filled=True)
+ )
+
+ self.setStyleSheet(
+ "SAXSWidget {"
+ "border: none;"
+ "}"
+ "QGroupBox#saxs_setup_group, QGroupBox#saxs_camera_group, "
+ "QGroupBox#saxs_table_group {"
+ f"background-color: {card_bg.name()};"
+ f"border: 1px solid {border.name()};"
+ "border-radius: 8px;"
+ "margin-top: 10px;"
+ "}"
+ "QGroupBox#saxs_setup_group::title, QGroupBox#saxs_camera_group::title, "
+ "QGroupBox#saxs_table_group::title {"
+ "subcontrol-origin: margin;"
+ "left: 10px;"
+ "padding: 0 4px;"
+ f"color: {foreground.name()};"
+ "font-weight: 700;"
+ "}"
+ "QGroupBox#saxs_device_selection_box, QGroupBox#saxs_roi_mapping_box {"
+ f"border: 1px solid {subtle_border.name()};"
+ "border-radius: 6px;"
+ "margin-top: 10px;"
+ "font-weight: 600;"
+ "}"
+ "QGroupBox#saxs_device_selection_box::title, QGroupBox#saxs_roi_mapping_box::title {"
+ "subcontrol-origin: margin;"
+ "left: 8px;"
+ "padding: 0 4px;"
+ f"color: {foreground.name()};"
+ "}"
+ "QLabel#saxs_field_label, QLabel#saxs_toolbar_label {"
+ f"color: {muted.name()};"
+ "font-weight: 700;"
+ "font-size: 11px;"
+ "}"
+ "QLineEdit#saxs_reference_field {"
+ f"background-color: {recessed_bg.name()};"
+ f"border: 1px solid {subtle_border.name()};"
+ "border-radius: 4px;"
+ f"color: {muted.name()};"
+ "padding: 3px 6px;"
+ "}"
+ "QFrame#saxs_session_bar, QFrame#saxs_table_controls {"
+ f"background-color: {panel_bg.name()};"
+ f"border: 1px solid {accent_border.name()};"
+ "border-radius: 6px;"
+ "}"
+ "QLabel#saxs_session_title {"
+ f"color: {foreground.name()};"
+ "font-weight: 700;"
+ "}"
+ "QLabel#saxs_session_name {"
+ f"background-color: {recessed_bg.name()};"
+ f"border: 1px solid {subtle_border.name()};"
+ "border-radius: 4px;"
+ f"color: {foreground.name()};"
+ "font-weight: 600;"
+ "padding: 3px 8px;"
+ "}"
+ "QLabel#saxs_session_status {"
+ "border-radius: 10px;"
+ "font-weight: 700;"
+ "padding: 3px 9px;"
+ "}"
+ "QLabel#saxs_session_status[sessionState='none'] {"
+ f"background-color: {rgba(muted, 28 if light_theme else 44)};"
+ f"color: {muted.name()};"
+ "}"
+ "QLabel#saxs_session_status[sessionState='dirty'] {"
+ f"background-color: {rgba(warning, 34 if light_theme else 56)};"
+ f"color: {warning.name()};"
+ "}"
+ "QLabel#saxs_session_status[sessionState='saved'] {"
+ f"background-color: {rgba(success, 34 if light_theme else 56)};"
+ f"color: {success.name()};"
+ "}"
+ "QLabel#saxs_table_status, QLabel#saxs_row_summary {"
+ f"color: {muted.name()};"
+ "}"
+ "QFrame#saxs_toolbar_separator {"
+ f"color: {subtle_border.name()};"
+ "}"
+ "QPushButton[saxsButtonRole='session']:flat,"
+ "QPushButton[saxsButtonRole='toolbar']:flat,"
+ "QPushButton[saxsButtonRole='danger']:flat {"
+ "border: 1px solid transparent;"
+ "border-radius: 4px;"
+ "padding: 4px 9px;"
+ "}"
+ "QPushButton[saxsButtonRole='session']:flat:hover,"
+ "QPushButton[saxsButtonRole='toolbar']:flat:hover {"
+ f"background-color: {hover_bg};"
+ f"border-color: {accent_border.name()};"
+ "}"
+ "QPushButton[saxsButtonRole='danger']:flat:hover {"
+ f"background-color: {danger_bg};"
+ f"border-color: {emergency.name()};"
+ "}"
+ "QTableWidget#saxs_measurement_table {"
+ f"background-color: {card_bg.name()};"
+ f"border: 1px solid {subtle_border.name()};"
+ "border-radius: 6px;"
+ "gridline-color: transparent;"
+ "}"
+ "QTableWidget#saxs_measurement_table::item {"
+ "padding: 3px 6px;"
+ "}"
+ "QTableWidget#saxs_measurement_table::item:selected {"
+ f"background-color: {rgba(accent, 44 if light_theme else 74)};"
+ "}"
+ "QHeaderView::section {"
+ f"background-color: {recessed_bg.name()};"
+ f"border: 1px solid {subtle_border.name()};"
+ "font-weight: 700;"
+ "padding: 5px 6px;"
+ "}"
+ )
+
+ # ------------------------------------------------------------------
+ # ROI and positioner state
+ # ------------------------------------------------------------------
+
+ def _ensure_saxs_roi(self) -> RectangularROI:
+ if self._saxs_roi is not None and self._saxs_roi in self.image.rois:
+ return self._saxs_roi
+
+ roi = self.image.add_roi(
+ kind="rect", name=self.ROI_NAME, pos=(10, 10), size=(50, 50), movable=True, line_width=2
+ )
+ roi.line_color = self._roi_color
+ roi.removable = False
+ roi.sigRegionChangeFinished.connect(self._on_roi_reference_changed)
+ self._saxs_roi = roi
+ self._sync_roi_size_fields()
+ return roi
+
+ def _on_roi_removed(self, roi) -> None:
+ if roi is self._saxs_roi:
+ self._saxs_roi = None
+ QTimer.singleShot(0, self._ensure_saxs_roi) # Schedule re-adding ROI
+
+ def _current_raw_roi(self) -> RawROI:
+ roi = self._ensure_saxs_roi()
+ coords = roi.get_coordinates(typed=True)
+ x, y = coords["bottom_left"]
+ return RawROI(
+ x=float(x), y=float(y), width=float(coords["width"]), height=float(coords["height"])
+ )
+
+ def _sync_roi_size_fields(self) -> None:
+ if not hasattr(self, "mapping_box") or self._saxs_roi is None:
+ return
+ coords = self._saxs_roi.get_coordinates(typed=True)
+ self.mapping_box.set_roi_size(float(coords["width"]), float(coords["height"]))
+
+ def _set_roi_from_raw(self, raw_roi: RawROI) -> None:
+ roi = self._ensure_saxs_roi()
+ roi.setSize((raw_roi.width, raw_roi.height))
+ roi.setPos(raw_roi.x, raw_roi.y)
+ self._sync_roi_size_fields()
+
+ def _apply_roi_size(self, width: float, height: float) -> None:
+ roi = self._ensure_saxs_roi()
+ coords = roi.get_coordinates(typed=True)
+ center_x = float(coords["center_x"])
+ center_y = float(coords["center_y"])
+ roi.setSize((float(width), float(height)))
+ roi.setPos(center_x - float(width) / 2.0, center_y - float(height) / 2.0)
+ self._sync_roi_size_fields()
+ self._refresh_crosshair_pin_if_enabled()
+
+ def _current_correction(self) -> Correction:
+ reference_x, reference_y = self._mapping_reference_position()
+ if not hasattr(self, "mapping_box"):
+ return Correction(
+ camera_scale_pixel_x=1.0,
+ offset_cam_xray_x=0.0,
+ direction_x=1,
+ camera_scale_pixel_y=1.0,
+ offset_cam_xray_y=0.0,
+ direction_y=1,
+ reference_x=reference_x,
+ reference_y=reference_y,
+ )
+ correction = self.mapping_box.correction()
+ return Correction(
+ camera_scale_pixel_x=correction.camera_scale_pixel_x,
+ offset_cam_xray_x=correction.offset_cam_xray_x,
+ direction_x=correction.direction_x,
+ camera_scale_pixel_y=correction.camera_scale_pixel_y,
+ offset_cam_xray_y=correction.offset_cam_xray_y,
+ direction_y=correction.direction_y,
+ reference_x=reference_x,
+ reference_y=reference_y,
+ )
+
+ def _current_camera_center(self) -> CameraCenter:
+ current_x, current_y = self._current_positioner_positions()
+ return CameraCenter(x=current_x, y=current_y)
+
+ def _current_positioner_positions(self) -> tuple[float, float]:
+ x_positioner = self.device_box.x_positioner
+ y_positioner = self.device_box.y_positioner
+ if not x_positioner or not y_positioner:
+ raise ValueError("Select X and Y positioners before computing scan coordinates.")
+
+ missing = [name for name in [x_positioner, y_positioner] if name not in self.dev]
+ if missing:
+ raise ValueError(f"Device(s) not available: {', '.join(missing)}.")
+
+ return (
+ self._read_positioner_position(x_positioner),
+ self._read_positioner_position(y_positioner),
+ )
+
+ def _read_positioner_position(self, device_name: str) -> float:
+ device = self.dev[device_name]
+ errors: list[str] = []
+ for source in (device, getattr(device, "obj", None)):
+ if source is None:
+ continue
+ try:
+ value = getattr(source, "position", None)
+ if value is not None:
+ return float(value)
+ except (TypeError, ValueError) as exc:
+ errors.append(str(exc))
+
+ readback = getattr(source, "readback", None)
+ if readback is not None:
+ if hasattr(readback, "get"):
+ try:
+ return float(readback.get())
+ except Exception as exc: # noqa: BLE001 - try the next supported read path
+ errors.append(str(exc))
+ if hasattr(readback, "read"):
+ try:
+ value = self._value_from_reading(readback.read(), device_name)
+ if value is not None:
+ return value
+ except Exception as exc: # noqa: BLE001 - try the next supported read path
+ errors.append(str(exc))
+
+ read = getattr(source, "read", None)
+ if callable(read):
+ try:
+ value = self._value_from_reading(read(), device_name)
+ if value is not None:
+ return value
+ except Exception as exc: # noqa: BLE001 - collect context for the user-facing error
+ errors.append(str(exc))
+
+ details = f" ({'; '.join(errors)})" if errors else ""
+ raise ValueError(f"Could not read current position for {device_name}{details}.")
+
+ @staticmethod
+ def _value_from_reading(reading, device_name: str) -> float | None:
+ if isinstance(reading, (int, float)):
+ return float(reading)
+ if not isinstance(reading, dict):
+ return None
+
+ candidates = [device_name]
+ candidates.extend(
+ key
+ for key in reading
+ if str(key).startswith(f"{device_name}_") or str(key).endswith("_readback")
+ )
+ candidates.extend(key for key in reading if key not in candidates)
+
+ for key in candidates:
+ entry = reading.get(key)
+ if isinstance(entry, dict) and "value" in entry:
+ return float(entry["value"])
+ if isinstance(entry, (int, float)):
+ return float(entry)
+ return None
+
+ # ------------------------------------------------------------------
+ # Measurement row construction
+ # ------------------------------------------------------------------
+
+ def _make_sample_id(self) -> str:
+ return uuid4().hex
+
+ def _build_row(
+ self,
+ *,
+ sample_name: str,
+ exposure_time: float,
+ fast_axis: str,
+ stepper_axis: str,
+ fast_step: float,
+ stepper_step: float,
+ comment: str,
+ template: Literal["scanning", "tensor"] = "scanning",
+ rotx: float | None = None,
+ roty: float | None = None,
+ raw_roi: RawROI | None = None,
+ correction: Correction | None = None,
+ camera_center: CameraCenter | None = None,
+ sample_id: str | None = None,
+ x_positioner: str | None = None,
+ y_positioner: str | None = None,
+ rotx_axis: str | None = None,
+ roty_axis: str | None = None,
+ ) -> SAXSRow:
+ raw = raw_roi or self._current_raw_roi()
+ corr = correction or self._current_correction()
+ center = camera_center or self._current_camera_center()
+ geometry = corrected_geometry(
+ raw, corr, fast_axis, fast_step, stepper_axis, stepper_step, camera_center=center
+ )
+ return SAXSRow(
+ sample_name=sample_name,
+ sample_id=sample_id or self._make_sample_id(),
+ raw_roi=raw,
+ correction=corr,
+ geometry=geometry,
+ exposure_time=exposure_time,
+ camera_center=center,
+ comment=comment,
+ template=template,
+ rotx=rotx,
+ roty=roty,
+ x_positioner=(self.device_box.x_positioner if x_positioner is None else x_positioner),
+ y_positioner=(self.device_box.y_positioner if y_positioner is None else y_positioner),
+ rotx_axis=self.device_box.rotx_axis if rotx_axis is None else rotx_axis,
+ roty_axis=self.device_box.roty_axis if roty_axis is None else roty_axis,
+ )
+
+ def _validate_selected_devices(self, *, tensor: bool) -> None:
+ selected = {
+ "X positioner": self.device_box.x_positioner,
+ "Y positioner": self.device_box.y_positioner,
+ }
+ if tensor:
+ selected.update(
+ {"RotX axis": self.device_box.rotx_axis, "RotY axis": self.device_box.roty_axis}
+ )
+
+ missing_selection = [label for label, name in selected.items() if not name]
+ if missing_selection:
+ raise ValueError(f"Select {', '.join(missing_selection)} before adding rows.")
+
+ names = list(selected.values())
+ duplicates = sorted({name for name in names if names.count(name) > 1})
+ if duplicates:
+ raise ValueError(
+ "Each scan and rotation axis must use a distinct device; duplicated: "
+ + ", ".join(duplicates)
+ + "."
+ )
+
+ unavailable = [name for name in names if name not in self.dev]
+ if unavailable:
+ raise ValueError(f"Device(s) not available: {', '.join(unavailable)}.")
+
+ def _device_limits(self, device_name: str) -> tuple[float, float] | None:
+ try:
+ limits = self.dev[device_name].limits
+ if callable(limits):
+ limits = limits()
+ if not isinstance(limits, (list, tuple)) or len(limits) != 2:
+ return None
+ low, high = float(limits[0]), float(limits[1])
+ except Exception as exc: # noqa: BLE001 - optional preflight must tolerate backend failures
+ logger.debug("Could not read limits for {}: {}", device_name, exc)
+ return None
+ return (low, high) if low < high else None
+
+ def _device_signal_value(self, device_name: str, signal_name: str) -> float | None:
+ try:
+ signal = getattr(self.dev[device_name], signal_name)
+ getter = getattr(signal, "get", None)
+ if callable(getter):
+ try:
+ value = getter(cached=True)
+ except TypeError:
+ value = getter()
+ else:
+ value = signal
+ return float(value)
+ except Exception: # noqa: BLE001 - optional cached preflight value
+ return None
+
+ def _validate_rows_motion(self, rows: list[SAXSRow]) -> None:
+ if not rows:
+ return
+
+ targets: dict[str, list[float]] = {}
+ velocity_checks: set[tuple[str, float, float]] = set()
+ for row in rows:
+ targets.setdefault(row.x_positioner, []).extend(
+ [row.geometry.x_start, row.geometry.x_stop]
+ )
+ targets.setdefault(row.y_positioner, []).extend(
+ [row.geometry.y_start, row.geometry.y_stop]
+ )
+ if row.rotx_axis and row.rotx is not None:
+ targets.setdefault(row.rotx_axis, []).append(row.rotx)
+ if row.roty_axis and row.roty is not None:
+ targets.setdefault(row.roty_axis, []).append(row.roty)
+ velocity_checks.add((row.fast_positioner, row.fast_step, row.exposure_time))
+
+ for device_name, values in targets.items():
+ limits = self._device_limits(device_name)
+ if limits is None:
+ continue
+ low, high = limits
+ outside = [value for value in values if not low <= value <= high]
+ if outside:
+ raise ValueError(
+ f"Planned position {outside[0]:.6g} for {device_name} is outside "
+ f"its limits [{low:.6g}, {high:.6g}]."
+ )
+
+ for device_name, fast_step, exposure_time in velocity_checks:
+ requested_velocity = abs(fast_step / exposure_time)
+ maximum_velocity = self._device_signal_value(device_name, "velocity")
+ base_velocity = self._device_signal_value(device_name, "base_velocity")
+ if maximum_velocity is not None and requested_velocity > maximum_velocity:
+ raise ValueError(
+ f"Requested fast-axis velocity {requested_velocity:.6g} exceeds "
+ f"{device_name} velocity {maximum_velocity:.6g}."
+ )
+ if base_velocity is not None and requested_velocity < base_velocity:
+ raise ValueError(
+ f"Requested fast-axis velocity {requested_velocity:.6g} is below "
+ f"{device_name} base velocity {base_velocity:.6g}."
+ )
+
+ @SafeSlot()
+ def add_scanning_row(
+ self,
+ sample_name: str | None = None,
+ exposure_time: float | None = None,
+ fast_axis: str | None = None,
+ stepper_axis: str | None = None,
+ fast_step: float | None = None,
+ stepper_step: float | None = None,
+ comment: str = "",
+ ) -> SAXSRow | None:
+ if not self._active_session:
+ self._warn("Select or create a session before adding rows.")
+ return None
+ fast_axis = fast_axis or self._last_fast_axis
+ stepper_axis = stepper_axis or self._last_stepper_axis
+ fast_step = fast_step if fast_step is not None else self._last_fast_step
+ stepper_step = stepper_step if stepper_step is not None else self._last_stepper_step
+ try:
+ self._validate_selected_devices(tensor=False)
+ except ValueError as exc:
+ self._warn(str(exc))
+ return None
+ if sample_name is None or exposure_time is None:
+ dialog = ScanningSAXSDialog(
+ self,
+ exposure_time=self._last_exposure_time,
+ fast_axis=self._last_fast_axis,
+ stepper_axis=self._last_stepper_axis,
+ fast_step=self._last_fast_step,
+ stepper_step=self._last_stepper_step,
+ )
+ if dialog.exec() != QDialog.DialogCode.Accepted:
+ return None
+ values = dialog.values()
+ else:
+ values = {
+ "sample_name": sample_name,
+ "exposure_time": exposure_time,
+ "fast_axis": fast_axis,
+ "stepper_axis": stepper_axis,
+ "fast_step": fast_step,
+ "stepper_step": stepper_step,
+ "comment": comment,
+ }
+
+ try:
+ row = self._build_row(
+ sample_name=values["sample_name"],
+ exposure_time=values["exposure_time"],
+ fast_axis=values["fast_axis"],
+ stepper_axis=values["stepper_axis"],
+ fast_step=values["fast_step"],
+ stepper_step=values["stepper_step"],
+ comment=values["comment"],
+ )
+ self._validate_rows_motion([row])
+ except ValueError as exc:
+ self._warn(str(exc))
+ return None
+
+ self._last_exposure_time = row.exposure_time
+ self._last_fast_axis = row.fast_axis
+ self._last_stepper_axis = row.stepper_axis
+ self._last_fast_step = row.fast_step
+ self._last_stepper_step = row.stepper_step
+ self._append_rows([row])
+ return row
+
+ @SafeSlot()
+ def add_tensor_rows(
+ self,
+ sample_name: str | None = None,
+ exposure_time: float | None = None,
+ fast_axis: str | None = None,
+ stepper_axis: str | None = None,
+ fast_step: float | None = None,
+ stepper_step: float | None = None,
+ comment: str = "",
+ roty: float | None = None,
+ rotx_values: list[float] | None = None,
+ roty_values: list[float] | None = None,
+ angle_pairs: list[tuple[float, float]] | None = None,
+ ) -> list[SAXSRow]:
+ if not self._active_session:
+ self._warn("Select or create a session before adding rows.")
+ return []
+ fast_axis = fast_axis or self._last_fast_axis
+ stepper_axis = stepper_axis or self._last_stepper_axis
+ fast_step = fast_step if fast_step is not None else self._last_fast_step
+ stepper_step = stepper_step if stepper_step is not None else self._last_stepper_step
+ try:
+ self._validate_selected_devices(tensor=True)
+ except ValueError as exc:
+ self._warn(str(exc))
+ return []
+ if not self.device_box.is_sastt_enabled():
+ self._warn("Enable SASTT to add tensor tomography rows.")
+ return []
+
+ if (
+ sample_name is None
+ or exposure_time is None
+ or (angle_pairs is None and rotx_values is None)
+ ):
+ dialog = TensorSAXSDialog(
+ self,
+ exposure_time=self._last_exposure_time,
+ fast_axis=self._last_fast_axis,
+ stepper_axis=self._last_stepper_axis,
+ fast_step=self._last_fast_step,
+ stepper_step=self._last_stepper_step,
+ )
+ if dialog.exec() != QDialog.DialogCode.Accepted:
+ return []
+ values = dialog.values()
+ else:
+ try:
+ if angle_pairs is not None:
+ resolved_angle_pairs = normalize_angle_pairs(angle_pairs)
+ else:
+ resolved_angle_pairs = pair_angle_lists(
+ rotx_values or [], roty_values or ([roty] if roty is not None else [0.0])
+ )
+ except ValueError as exc:
+ self._warn(str(exc))
+ return []
+ values = {
+ "sample_name": sample_name,
+ "exposure_time": exposure_time,
+ "fast_axis": fast_axis,
+ "stepper_axis": stepper_axis,
+ "fast_step": fast_step,
+ "stepper_step": stepper_step,
+ "comment": comment,
+ "angle_pairs": resolved_angle_pairs,
+ }
+
+ rows: list[SAXSRow] = []
+ try:
+ raw_roi = self._current_raw_roi()
+ correction = self._current_correction()
+ camera_center = self._current_camera_center()
+ for rotx, roty_value in normalize_angle_pairs(values["angle_pairs"]):
+ rows.append(
+ self._build_row(
+ sample_name=values["sample_name"],
+ exposure_time=values["exposure_time"],
+ fast_axis=values["fast_axis"],
+ stepper_axis=values["stepper_axis"],
+ fast_step=values["fast_step"],
+ stepper_step=values["stepper_step"],
+ comment=values["comment"],
+ template="tensor",
+ rotx=float(rotx),
+ roty=float(roty_value),
+ raw_roi=raw_roi,
+ correction=correction,
+ camera_center=camera_center,
+ )
+ )
+ self._validate_rows_motion(rows)
+ except ValueError as exc:
+ self._warn(str(exc))
+ return []
+
+ self._last_exposure_time = values["exposure_time"]
+ self._last_fast_axis = values["fast_axis"]
+ self._last_stepper_axis = values["stepper_axis"]
+ self._last_fast_step = values["fast_step"]
+ self._last_stepper_step = values["stepper_step"]
+ self._append_rows(rows)
+ return rows
+
+ # ------------------------------------------------------------------
+ # Measurement table actions
+ # ------------------------------------------------------------------
+
+ def _append_rows(self, rows: list[SAXSRow]) -> None:
+ self._rows.extend(rows)
+ self._session_dirty = True
+ self._refresh_table()
+
+ @SafeSlot()
+ def edit_selected_row(self) -> None:
+ selected = self.table_widget.selected_rows()
+ if len(selected) != 1:
+ self._warn("Select exactly one row to edit.")
+ return
+ self._edit_row_by_index(selected[0])
+
+ @SafeSlot(int)
+ def move_to_selected_row(self, index: int) -> None:
+ if index < 0 or index >= len(self._rows):
+ self._warn("Select exactly one row to move to.")
+ return
+
+ row = self._rows[index]
+ x_positioner = row.x_positioner
+ y_positioner = row.y_positioner
+ if not x_positioner or not y_positioner:
+ self._warn("The selected row does not contain X/Y positioners.")
+ return
+
+ missing = [name for name in [x_positioner, y_positioner] if name not in self.dev]
+ if missing:
+ self._warn(f"Device(s) not available: {', '.join(missing)}.")
+ return
+
+ target_x, target_y = self._row_camera_center_position(row)
+ if not self._confirm_move_to_selected(row, x_positioner, y_positioner, target_x, target_y):
+ return
+
+ self._restore_row_view(row)
+ try:
+ self.dev[x_positioner].move(target_x, relative=False)
+ self.dev[y_positioner].move(target_y, relative=False)
+ except Exception as exc: # noqa: BLE001 - device move errors are user-facing here
+ self._warn(f"Could not move to selected row: {exc}")
+ return
+
+ self.status_label.setText(
+ f"Moving {x_positioner}={target_x:.6g}, {y_positioner}={target_y:.6g}"
+ )
+
+ def _confirm_move_to_selected(
+ self, row: SAXSRow, x_positioner: str, y_positioner: str, target_x: float, target_y: float
+ ) -> bool:
+ box = QMessageBox(self)
+ box.setIcon(QMessageBox.Icon.Question)
+ box.setWindowTitle("Move to selected")
+ box.setText(f"Move to selected row '{row.sample_name}'?")
+ box.setInformativeText(
+ "This restores the submitted ROI and moves the row's X/Y positioners to "
+ "the saved motor positions from when the row was created:\n"
+ f"{x_positioner}: {target_x:.6g}\n"
+ f"{y_positioner}: {target_y:.6g}"
+ )
+ box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel)
+ box.setDefaultButton(QMessageBox.StandardButton.Cancel)
+ return box.exec() == QMessageBox.StandardButton.Yes
+
+ def _restore_row_view(self, row: SAXSRow) -> None:
+ self.set_positioners(row.x_positioner, row.y_positioner)
+ self.mapping_box.set_correction(row.correction)
+ self._set_roi_from_raw(row.raw_roi)
+ self._set_mapping_reference_position(
+ row.correction.reference_x,
+ row.correction.reference_y,
+ image_shape=self._display_image_shape(),
+ )
+ self._refresh_crosshair_pin_if_enabled(force=True)
+
+ @staticmethod
+ def _row_camera_center_position(row: SAXSRow) -> tuple[float, float]:
+ return row.camera_center.x, row.camera_center.y
+
+ def _edit_row_by_index(self, index: int) -> None:
+ if index < 0 or index >= len(self._rows):
+ return
+ row = self._rows[index]
+ dialog = RowEditDialog(self, row)
+ if dialog.exec() != QDialog.DialogCode.Accepted:
+ return
+ values = dialog.values()
+ try:
+ updated = self._build_row(
+ sample_name=values["sample_name"],
+ exposure_time=values["exposure_time"],
+ fast_axis=values["fast_axis"],
+ stepper_axis=values["stepper_axis"],
+ fast_step=values["fast_step"],
+ stepper_step=values["stepper_step"],
+ comment=values["comment"],
+ template=row.template,
+ rotx=values["rotx"] if row.template == "tensor" else None,
+ roty=values["roty"] if row.template == "tensor" else None,
+ raw_roi=row.raw_roi,
+ correction=row.correction,
+ camera_center=row.camera_center,
+ sample_id=row.sample_id,
+ x_positioner=row.x_positioner,
+ y_positioner=row.y_positioner,
+ rotx_axis=row.rotx_axis,
+ roty_axis=row.roty_axis,
+ )
+ self._validate_rows_motion([updated])
+ except ValueError as exc:
+ self._warn(str(exc))
+ return
+ updated.scan_results = list(row.scan_results)
+ self._rows[index] = updated
+ self._last_exposure_time = updated.exposure_time
+ self._last_fast_axis = updated.fast_axis
+ self._last_stepper_axis = updated.stepper_axis
+ self._last_fast_step = updated.fast_step
+ self._last_stepper_step = updated.stepper_step
+ self._session_dirty = True
+ self._refresh_table()
+
+ def reorder_rows(self, order: list[int]) -> None:
+ try:
+ normalized_order = [int(index) for index in order]
+ except (TypeError, ValueError):
+ self._warn("Could not reorder rows: invalid row order.")
+ return
+ expected_order = list(range(len(self._rows)))
+ if sorted(normalized_order) != expected_order:
+ self._warn("Could not reorder rows: invalid row order.")
+ return
+ if normalized_order == expected_order:
+ return
+
+ self._rows = [self._rows[index] for index in normalized_order]
+ self._session_dirty = True
+ self._refresh_table()
+
+ @SafeSlot()
+ def delete_selected_rows(self) -> None:
+ for index in sorted(self.table_widget.selected_rows(), reverse=True):
+ del self._rows[index]
+ self._session_dirty = True
+ self._refresh_table()
+
+ @SafeSlot()
+ def clear_rows(self) -> None:
+ self._rows.clear()
+ self._session_dirty = True
+ self._refresh_table()
+
+ # ------------------------------------------------------------------
+ # Existing-row correction and export actions
+ # ------------------------------------------------------------------
+
+ def apply_corrections(self, rows: list[int] | None = None) -> None:
+ if self.mapping_box.is_locked():
+ return
+ indices = list(rows) if rows is not None else list(range(len(self._rows)))
+ if any(not isinstance(index, int) or not 0 <= index < len(self._rows) for index in indices):
+ self._warn("Could not apply corrections: invalid row selection.")
+ return
+ try:
+ correction = self._current_correction()
+ except ValueError as exc:
+ self._warn(str(exc))
+ return
+ candidates: list[tuple[int, SAXSRow]] = []
+ try:
+ for index in indices:
+ row = self._rows[index]
+ geometry = corrected_geometry(
+ row.raw_roi,
+ correction,
+ row.fast_axis,
+ row.fast_step,
+ row.stepper_axis,
+ row.stepper_step,
+ camera_center=row.camera_center,
+ )
+ candidates.append((index, replace(row, correction=correction, geometry=geometry)))
+ self._validate_rows_motion([candidate for _, candidate in candidates])
+ except ValueError as exc:
+ self._warn(f"Could not apply corrections: {exc}")
+ return
+
+ for index, candidate in candidates:
+ self._rows[index] = candidate
+ self._session_dirty = True
+ self._refresh_table()
+
+ def _prompt_apply_corrections(self) -> None:
+ if self.mapping_box.is_locked() or not self._rows:
+ return
+
+ selected = self.table_widget.selected_rows()
+ box = QMessageBox(self)
+ box.setWindowTitle("Apply corrections")
+ box.setText(
+ "Apply current camera scale, camera-to-x-ray offset and direction to existing rows?"
+ )
+ selected_button = None
+ if selected:
+ selected_button = box.addButton("Selected rows", QMessageBox.ButtonRole.AcceptRole)
+ all_button = box.addButton("All rows", QMessageBox.ButtonRole.AcceptRole)
+ box.addButton(QMessageBox.StandardButton.Cancel)
+ box.exec()
+ clicked = box.clickedButton()
+ if selected_button is not None and clicked is selected_button:
+ self.apply_corrections(selected)
+ elif clicked is all_button:
+ self.apply_corrections()
+
+ @SafeSlot()
+ def store_rows_as_csv(self) -> None:
+ if not self._rows:
+ self._warn("There are no rows to export.")
+ return
+
+ selected = self.table_widget.selected_rows()
+
+ box = QMessageBox(self)
+ box.setWindowTitle("Save and store table as CSV")
+ box.setText("Store selected rows or all rows?" if selected else "Store all rows?")
+ selected_button = None
+ if selected:
+ selected_button = box.addButton("Selected rows", QMessageBox.ButtonRole.AcceptRole)
+ all_button = box.addButton("All rows", QMessageBox.ButtonRole.AcceptRole)
+ box.addButton(QMessageBox.StandardButton.Cancel)
+ box.exec()
+ clicked = box.clickedButton()
+ path = None
+ if selected_button is not None and clicked is selected_button:
+ path = self.export_csv(selected=True)
+ elif clicked is all_button:
+ path = self.export_csv(selected=False)
+ if path: # Only store if exported
+ self.save_session(show_feedback=True)
+
+ @SafeSlot(str)
+ def export_csv(self, file_name: str | None = None, selected: bool = False) -> str | None:
+ rows = self.table_widget.selected_model_rows() if selected else self._rows
+ if not rows:
+ self._warn("There are no rows to export.")
+ return None
+ if file_name is None:
+ file_name, _ = QFileDialog.getSaveFileName(
+ self,
+ "Export SAXS table",
+ "saxs_measurements.csv",
+ "CSV files (*.csv);;All files (*)",
+ )
+ if not file_name:
+ return None
+
+ path = Path(file_name)
+ session_name = self._active_session or ""
+ dictionaries = [{"session_name": session_name, **row.to_csv_dict()} for row in rows]
+ fieldnames = list(dictionaries[0])
+ with path.open("w", newline="", encoding="utf-8") as handle:
+ writer = csv.DictWriter(handle, fieldnames=fieldnames)
+ writer.writeheader()
+ writer.writerows(dictionaries)
+ self.status_label.setText(f"Exported {len(rows)} rows")
+ return str(path)
+
+ @SafeSlot(result=list)
+ def rows_as_dicts(self) -> list[dict]:
+ session_name = self._active_session or ""
+ return [{"session_name": session_name, **row.to_dict()} for row in self._rows]
+
+ @SafeSlot(str, str)
+ def set_positioners(self, x_positioner: str, y_positioner: str) -> None:
+ self.device_box.set_positioners(x_positioner, y_positioner)
+ if x_positioner:
+ ui = self.positioner_box._device_ui_components_hv("horizontal")
+ step_size = ui["step_size"].value()
+ self.positioner_box.set_positioner_hor(x_positioner)
+ ui = self.positioner_box._device_ui_components_hv("horizontal")
+ ui["step_size"].setValue(step_size)
+ if y_positioner:
+ ui = self.positioner_box._device_ui_components_hv("vertical")
+ step_size = ui["step_size"].value()
+ self.positioner_box.set_positioner_ver(y_positioner)
+ ui = self.positioner_box._device_ui_components_hv("vertical")
+ ui["step_size"].setValue(step_size)
+
+ @SafeSlot(object, object, bool)
+ def mark_scan_result(self, sample_id_or_row, scan_number, success: bool) -> None:
+ index = self._resolve_row_index(sample_id_or_row)
+ if index is None:
+ self._warn(f"Could not find row {sample_id_or_row!r}.")
+ return
+ self._rows[index].scan_results.append(ScanResult(str(scan_number), bool(success)))
+ self._session_dirty = True
+ self._refresh_table()
+
+ # ------------------------------------------------------------------
+ # Session actions and persistence
+ # ------------------------------------------------------------------
+
+ @SafeSlot()
+ def save_session(self, show_feedback: bool = True) -> None:
+ if not self._active_session:
+ if show_feedback:
+ self._warn("Select or create a session before saving.")
+ return
+ self.publish_table_for_session_name(self._active_session, self._session_payload())
+ self._session_dirty = False
+ if show_feedback:
+ self.status_label.setText(f"Saved {len(self._rows)} rows")
+ self._update_session_ui_state()
+
+ def _refresh_table(self) -> None:
+ self.table_widget.set_rows(self._rows)
+ self.status_label.setText("Ready")
+ self._update_session_ui_state()
+
+ def _set_active_session(self, session_name: str | None) -> None:
+ if session_name:
+ self._active_session = self._validate_session_name(session_name)
+ else:
+ self._active_session = None
+ self._update_session_ui_state()
+
+ def _validate_session_name(self, value: str) -> str:
+ name = value.strip()
+ if not name:
+ raise ValueError("Session name cannot be empty.")
+ if name.startswith("_"):
+ raise ValueError(f"{name!r} must not start with '_'.")
+ if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", name):
+ raise ValueError(
+ f"{name!r} is not a valid ASCII identifier; use letters, numbers, and underscores only (no spaces)."
+ )
+ if keyword.iskeyword(name):
+ raise ValueError(f"{name!r} is not a valid Python identifier.")
+ return name
+
+ def _prompt_valid_session_name(self, title: str) -> str | None:
+ previous_input = ""
+ while True:
+ text, ok = QInputDialog.getText(self, title, "Session name:", text=previous_input)
+ if not ok:
+ return None
+ previous_input = text
+ try:
+ return self._validate_session_name(text)
+ except ValueError as exc:
+ self._warn(f"{exc} Session names cannot contain spaces.")
+
+ def _update_session_ui_state(self) -> None:
+ has_session = bool(self._active_session)
+ self.table_widget.setEnabled(has_session)
+ self.add_scanning_button.setEnabled(has_session)
+ self.add_tensor_button.setEnabled(has_session and self.device_box.is_sastt_enabled())
+ self.table_widget.save_button.setEnabled(has_session and self._session_dirty)
+ self.new_session_button.setEnabled(True)
+ self.load_session_button.setEnabled(True)
+ self.delete_session_button.setEnabled(True)
+ self.session_name_label.setText(self._active_session or "none")
+ if not has_session:
+ state = "No active session"
+ state_key = "none"
+ elif self._session_dirty:
+ state = "Unsaved changes"
+ state_key = "dirty"
+ else:
+ state = "Saved"
+ state_key = "saved"
+ self.session_state_label.setText(state)
+ self.session_state_label.setProperty("sessionState", state_key)
+ self.session_state_label.style().unpolish(self.session_state_label)
+ self.session_state_label.style().polish(self.session_state_label)
+
+ def _new_session(self) -> None:
+ name = self._prompt_valid_session_name("New session")
+ if not name:
+ return
+ sessions_data = self.fetch_available_sessions()
+ if name in sessions_data:
+ action = self._prompt_existing_session_action(name)
+ if action == "load":
+ self._load_latest_session(name, sessions_data)
+ elif action == "replace":
+ self._replace_existing_session_with_empty(name)
+ return
+
+ proceed = self._confirm_session_change(name)
+ if not proceed:
+ return
+ self._set_active_session(name)
+ self._session_dirty = bool(self._rows)
+ self._update_session_ui_state()
+
+ def _load_session(self) -> None:
+ name, sessions_data = self._prompt_available_session_name(
+ title="Load session",
+ label="Available sessions:",
+ empty_warning="No saved sessions are available to load.",
+ )
+ if not name:
+ return
+ versions = sessions_data.get(name, [])
+ if not versions:
+ self._warn(f"No stored versions found for session '{name}'.")
+ return
+
+ labels = [version["label"] for version in versions]
+ selected_label, selected_ok = QInputDialog.getItem(
+ self, "Select version timestamp", "Saved versions (latest first):", labels, 0, False
+ )
+ if not selected_ok:
+ return
+ selected_index = labels.index(str(selected_label))
+ selected_payload = versions[selected_index]["payload"]
+
+ self._load_session_payload(name, selected_payload)
+
+ def _prompt_available_session_name(
+ self, *, title: str, label: str, empty_warning: str
+ ) -> tuple[str | None, dict[str, list[dict[str, Any]]]]:
+ sessions_data = self.fetch_available_sessions()
+ session_names = sorted(sessions_data)
+ if not session_names:
+ self._warn(empty_warning)
+ return None, sessions_data
+
+ name, ok = QInputDialog.getItem(self, title, label, session_names, 0, False)
+ if not ok:
+ return None, sessions_data
+ name = str(name)
+ return (name or None), sessions_data
+
+ def _load_latest_session(
+ self, session_name: str, sessions_data: dict[str, list[dict[str, Any]]]
+ ) -> bool:
+ versions = sessions_data.get(session_name, [])
+ if not versions:
+ self._warn(f"No stored versions found for session '{session_name}'.")
+ return False
+ return self._load_session_payload(session_name, versions[0]["payload"])
+
+ def _load_session_payload(self, session_name: str, payload: list[dict]) -> bool:
+ proceed = self._confirm_session_change(session_name)
+ if not proceed:
+ return False
+ self._set_active_session(session_name)
+ self._rows = self._rows_from_payload(payload)
+ self._session_dirty = False
+ self._refresh_table()
+ return True
+
+ def _prompt_existing_session_action(
+ self, session_name: str
+ ) -> Literal["load", "replace", "cancel"]:
+ box = QMessageBox(self)
+ box.setIcon(QMessageBox.Icon.Warning)
+ box.setWindowTitle("Session already exists")
+ box.setText(f"Session '{session_name}' already exists.")
+ box.setInformativeText(
+ "Load the saved session, delete its saved history and create an empty "
+ "session with this name, or cancel."
+ )
+ load_button = box.addButton("Load existing", QMessageBox.ButtonRole.AcceptRole)
+ replace_button = box.addButton(
+ "Delete history and create empty", QMessageBox.ButtonRole.DestructiveRole
+ )
+ cancel_button = box.addButton(QMessageBox.StandardButton.Cancel)
+ box.exec()
+
+ clicked = box.clickedButton()
+ if clicked is load_button:
+ return "load"
+ if clicked is replace_button:
+ return "replace"
+ if clicked is cancel_button:
+ return "cancel"
+ return "cancel"
+
+ def _replace_existing_session_with_empty(self, session_name: str) -> None:
+ self.delete_data_for_session_name(session_name)
+ self._rows.clear()
+ self._session_dirty = False
+ self._set_active_session(session_name)
+ self._refresh_table()
+ self.status_label.setText(f"Created empty session '{session_name}'")
+
+ def _confirm_session_change(self, new_session_name: str) -> bool:
+ if not self._rows or self._active_session == new_session_name:
+ return True
+
+ current = self._active_session or "none"
+ box = QMessageBox(self)
+ box.setIcon(QMessageBox.Icon.Question)
+ box.setWindowTitle("Switch session")
+ box.setText(
+ f"Switch from session '{current}' to '{new_session_name}' with {len(self._rows)} existing row(s)?"
+ )
+ box.setInformativeText("Delete current rows, keep them, or cancel the switch.")
+ delete_button = box.addButton("Delete rows", QMessageBox.ButtonRole.AcceptRole)
+ keep_button = box.addButton("Keep rows", QMessageBox.ButtonRole.DestructiveRole)
+ cancel_button = box.addButton(QMessageBox.StandardButton.Cancel)
+ box.exec()
+
+ clicked = box.clickedButton()
+ if clicked is cancel_button:
+ return False
+ if clicked is delete_button:
+ self.clear_rows()
+ if clicked is keep_button:
+ return True
+ return clicked is delete_button
+
+ def _delete_session(self) -> None:
+ name, _sessions_data = self._prompt_available_session_name(
+ title="Delete session",
+ label="Available sessions:",
+ empty_warning="No saved sessions are available to delete.",
+ )
+ if not name:
+ return
+ if not self._confirm_delete_session(name):
+ return
+ self._delete_named_session(name)
+
+ def _confirm_delete_session(self, session_name: str) -> bool:
+ box = QMessageBox(self)
+ box.setIcon(QMessageBox.Icon.Warning)
+ box.setWindowTitle("Delete session")
+ box.setText(f"Delete session '{session_name}'?")
+ if session_name == self._active_session:
+ box.setInformativeText(
+ "This deletes all saved versions and clears the current table in the widget."
+ )
+ else:
+ box.setInformativeText("This deletes all saved versions for the selected session.")
+ box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel)
+ return box.exec() == QMessageBox.StandardButton.Yes
+
+ def _delete_named_session(self, session_name: str) -> None:
+ self.delete_data_for_session_name(session_name)
+ if session_name == self._active_session:
+ self._rows.clear()
+ self._session_dirty = False
+ self._set_active_session(None)
+ self._refresh_table()
+ self.status_label.setText(f"Deleted session '{session_name}'")
+
+ # ------------------------------------------------------------------
+ # Shared table and UI state helpers
+ # ------------------------------------------------------------------
+
+ def _resolve_row_index(self, sample_id_or_row) -> int | None:
+ if isinstance(sample_id_or_row, int):
+ return sample_id_or_row if 0 <= sample_id_or_row < len(self._rows) else None
+ sample_id = str(sample_id_or_row)
+ for index, row in enumerate(self._rows):
+ if row.sample_id == sample_id:
+ return index
+ return None
+
+ def _update_sastt_enabled(self, enabled: bool) -> None:
+ self.add_tensor_button.setEnabled(bool(enabled) and bool(self._active_session))
+ tooltip = "" if enabled else "Enable SASTT to add tensor tomography rows."
+ self.add_tensor_button.setToolTip(tooltip)
+
+ def _warn(self, message: str) -> None:
+ logger.warning(message)
+ QMessageBox.warning(self, "SAXS Widget", message)
+
+ # ------------------------------------------------------------------
+ # Session payload helpers and backend access
+ # ------------------------------------------------------------------
+
+ def _session_payload(self) -> list[dict]:
+ return [row.to_dict() for row in self._rows]
+
+ def _rows_from_payload(self, payload: list[dict]) -> list[SAXSRow]:
+ restored_rows: list[SAXSRow] = []
+ for entry in payload:
+ if not isinstance(entry, dict):
+ continue
+ try:
+ raw_roi = RawROI(
+ x=float(entry["raw_roi_x"]),
+ y=float(entry["raw_roi_y"]),
+ width=float(entry["raw_roi_width"]),
+ height=float(entry["raw_roi_height"]),
+ )
+ correction = Correction(
+ camera_scale_pixel_x=float(entry["correction_camera_scale_pixel_x"]),
+ offset_cam_xray_x=float(entry["correction_offset_cam_xray_x"]),
+ direction_x=int(entry["correction_direction_x"]),
+ camera_scale_pixel_y=float(entry["correction_camera_scale_pixel_y"]),
+ offset_cam_xray_y=float(entry["correction_offset_cam_xray_y"]),
+ direction_y=int(entry["correction_direction_y"]),
+ reference_x=float(entry["correction_reference_x"]),
+ reference_y=float(entry["correction_reference_y"]),
+ )
+ camera_center = CameraCenter(
+ x=float(entry["camera_center_x"]), y=float(entry["camera_center_y"])
+ )
+ geometry = corrected_geometry(
+ raw_roi,
+ correction,
+ fast_axis=str(entry.get("fast_axis_coordinate", "x")),
+ fast_step=float(entry.get("fast_step", DEFAULT_STEP_SIZE)),
+ stepper_axis=str(entry.get("stepper_axis_coordinate", "y")),
+ stepper_step=float(entry.get("stepper_step", DEFAULT_STEP_SIZE)),
+ camera_center=camera_center,
+ )
+ row = SAXSRow(
+ sample_name=str(entry.get("sample_name", "")),
+ sample_id=str(entry.get("sample_id", self._make_sample_id())),
+ raw_roi=raw_roi,
+ correction=correction,
+ geometry=geometry,
+ exposure_time=float(entry.get("exposure_time", DEFAULT_EXPOSURE_TIME)),
+ camera_center=camera_center,
+ comment=str(entry.get("comment", "")),
+ template=(
+ "tensor"
+ if str(entry.get("template", "scanning")) == "tensor"
+ else "scanning"
+ ),
+ rotx=(None if entry.get("rotx") is None else float(entry.get("rotx"))),
+ roty=(None if entry.get("roty") is None else float(entry.get("roty"))),
+ x_positioner=str(entry.get("x_positioner", "")),
+ y_positioner=str(entry.get("y_positioner", "")),
+ rotx_axis=str(entry.get("rotx_axis", "")),
+ roty_axis=str(entry.get("roty_axis", "")),
+ )
+ scan_results_raw = entry.get("scan_results", "[]")
+ if isinstance(scan_results_raw, str):
+ try:
+ scan_results_data = json.loads(scan_results_raw)
+ except json.JSONDecodeError:
+ scan_results_data = []
+ elif isinstance(scan_results_raw, list):
+ scan_results_data = scan_results_raw
+ else:
+ scan_results_data = []
+ row.scan_results = [
+ ScanResult(str(item.get("scan_number", "")), bool(item.get("success", False)))
+ for item in scan_results_data
+ if isinstance(item, dict)
+ ]
+ restored_rows.append(row)
+ except (KeyError, TypeError, ValueError) as exc:
+ logger.warning("Skipping invalid stored SAXS row payload: {}", exc)
+ return restored_rows
+
+ def _extract_variable_message(self, entry: dict[str, Any]) -> VariableMessage | None:
+ value_field = entry.get("value")
+ if isinstance(value_field, VariableMessage):
+ return value_field
+ if isinstance(value_field, dict):
+ nested = value_field.get("value")
+ if isinstance(nested, VariableMessage):
+ return nested
+ return None
+
+ @staticmethod
+ def _utc_timestamp() -> str:
+ """Return the current UTC time in the session metadata representation."""
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+ def _extract_entry_timestamp(self, _entry: dict[str, Any], msg: VariableMessage) -> str:
+ metadata = msg.metadata or {}
+ timestamp = metadata.get("timestamp")
+ if isinstance(timestamp, str) and timestamp:
+ return timestamp
+ return self._utc_timestamp()
+
+ @staticmethod
+ def _format_timestamp_human(timestamp: str) -> str:
+ try:
+ return datetime.fromisoformat(timestamp).astimezone().strftime("%Y-%m-%d %H:%M:%S")
+ except ValueError:
+ return timestamp
+
+ def fetch_session_versions(self, session_name: str) -> list[dict[str, Any]]:
+ stream_data = self.client.connector.xread(
+ MessageEndpointSAXSWidget.measurement_table(session_name), from_start=True
+ )
+ if not stream_data:
+ return []
+
+ versions: list[dict[str, Any]] = []
+ for entry in stream_data:
+ if not isinstance(entry, dict):
+ continue
+ msg = self._extract_variable_message(entry)
+ if msg is None:
+ continue
+ payload = msg.value if isinstance(msg.value, list) else []
+ timestamp = self._extract_entry_timestamp(entry, msg)
+ row_count = len(payload)
+ versions.append(
+ {
+ "timestamp": timestamp,
+ "label": f"{self._format_timestamp_human(timestamp)} ({row_count} rows)",
+ "payload": payload,
+ }
+ )
+
+ versions.sort(key=lambda item: item["timestamp"], reverse=True)
+ return versions
+
+ def fetch_table_for_session_name(self, session_name: str, version_index: int = 0) -> list[dict]:
+ """Fetch the table for a given session name.
+
+ This method is intended to be overridden by external code that provides
+ the actual data fetching logic, e.g., from a Redis database or other storage.
+ """
+ versions = self.fetch_session_versions(session_name)
+ if not versions:
+ return []
+ if version_index < 0 or version_index >= len(versions):
+ version_index = 0
+ return versions[version_index]["payload"]
+
+ def publish_table_for_session_name(self, session_name: str, payload: list[dict]) -> None:
+ """Publish the table for a given session name.
+
+ This method is intended to be overridden by external code that provides
+ the actual data publishing logic, e.g., to a Redis database or other storage.
+ """
+ msg = VariableMessage(
+ value=payload,
+ metadata={"session_name": session_name, "timestamp": self._utc_timestamp()},
+ )
+ self.client.connector.xadd(
+ MessageEndpointSAXSWidget.measurement_table(session_name),
+ {"value": msg},
+ max_size=50,
+ expire=1209600,
+ ) # 14 days in seconds
+
+ def delete_data_for_session_name(self, session_name: str) -> None:
+ """Delete the data for a given session name.
+
+ This method is intended to be overridden by external code that provides
+ the actual data deletion logic, e.g., from a Redis database or other storage.
+ """
+ self.client.connector.delete(MessageEndpointSAXSWidget.measurement_table(session_name))
+
+ def fetch_available_sessions(self) -> dict[str, list[dict[str, Any]]]:
+ """Fetch all available sessions and their saved stream versions.
+
+ This method is intended to be overridden by external code that provides
+ the actual data fetching logic, e.g., from a Redis database or other storage.
+ """
+ keys = self.client.connector.keys(MessageEndpointSAXSWidget.measurement_table("*"))
+ sessions: dict[str, list[dict[str, Any]]] = {}
+ for key in keys:
+ key_str = key.decode() if isinstance(key, bytes) else str(key)
+ session_name = key_str.split("/")[-1]
+ sessions[session_name] = self.fetch_session_versions(session_name)
+ return sessions
+
+
+if __name__ == "__main__": # pragma: no cover
+ import sys
+
+ from bec_qthemes import apply_theme
+ from qtpy.QtWidgets import QApplication
+
+ app = QApplication(sys.argv)
+ apply_theme("light")
+ widget = SAXSWidget()
+ widget.resize(1200, 800)
+ widget.show()
+ sys.exit(app.exec())
diff --git a/csaxs_bec/bec_widgets/widgets/saxs_widget/saxs_widget.pyproject b/csaxs_bec/bec_widgets/widgets/saxs_widget/saxs_widget.pyproject
new file mode 100644
index 0000000..729afc5
--- /dev/null
+++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/saxs_widget.pyproject
@@ -0,0 +1 @@
+{'files': ['saxs_widget.py']}
\ No newline at end of file
diff --git a/csaxs_bec/bec_widgets/widgets/saxs_widget/saxs_widget_plugin.py b/csaxs_bec/bec_widgets/widgets/saxs_widget/saxs_widget_plugin.py
new file mode 100644
index 0000000..f26554f
--- /dev/null
+++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/saxs_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 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.saxs_widget.saxs_widget import SAXSWidget
+
+DOM_XML = """
+
+
+
+
+"""
+
+
+class SAXSWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
+ def __init__(self):
+ super().__init__()
+ self._form_editor = None
+
+ def createWidget(self, parent):
+ if parent is None:
+ return QWidget()
+ t = SAXSWidget(parent)
+ return t
+
+ def domXml(self):
+ return DOM_XML
+
+ def group(self):
+ return ""
+
+ def icon(self):
+ return designer_material_icon(SAXSWidget.ICON_NAME)
+
+ def includeFile(self):
+ return "saxs_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 "SAXSWidget"
+
+ def toolTip(self):
+ return "Widget for preparing SAXS measurement tables from an image ROI."
+
+ def whatsThis(self):
+ return self.toolTip()
diff --git a/csaxs_bec/bec_widgets/widgets/saxs_widget/table.py b/csaxs_bec/bec_widgets/widgets/saxs_widget/table.py
new file mode 100644
index 0000000..26640c4
--- /dev/null
+++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/table.py
@@ -0,0 +1,393 @@
+from __future__ import annotations
+
+import html
+
+from bec_qthemes import material_icon
+from qtpy.QtCore import QSize, Qt, Signal
+from qtpy.QtGui import QDrag, QKeySequence, QShortcut
+from qtpy.QtWidgets import (
+ QAbstractItemView,
+ QFrame,
+ QHBoxLayout,
+ QHeaderView,
+ QLabel,
+ QPushButton,
+ QTableWidget,
+ QTableWidgetItem,
+ QVBoxLayout,
+ QWidget,
+)
+
+from csaxs_bec.bec_widgets.widgets.saxs_widget.models import SAXSRow, ScanResult
+
+
+class SAXSMeasurementTable(QTableWidget):
+ """QTableWidget variant that reports internal row drag/drop as a row order."""
+
+ rows_reordered = Signal(list)
+
+ def __init__(self, rows: int, columns: int, parent=None):
+ super().__init__(rows, columns, parent)
+ self.setDragEnabled(True)
+ self.setAcceptDrops(True)
+ self.viewport().setAcceptDrops(True)
+ self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
+ self.setDefaultDropAction(Qt.DropAction.MoveAction)
+ self.setDragDropOverwriteMode(False)
+ self.setDropIndicatorShown(True)
+
+ def dropEvent(self, event) -> None:
+ order = self.row_order_after_drop(
+ self.rowCount(), self._selected_row_numbers(), self._drop_row(event)
+ )
+ if order is None or order == list(range(self.rowCount())):
+ event.ignore()
+ return
+
+ self.rows_reordered.emit(order)
+ event.acceptProposedAction()
+
+ def startDrag(self, _supported_actions) -> None:
+ indexes = self.selectedIndexes()
+ if not indexes:
+ return
+ mime_data = self.model().mimeData(indexes)
+ if mime_data is None:
+ return
+
+ drag = QDrag(self)
+ drag.setMimeData(mime_data)
+ if hasattr(drag, "exec"):
+ drag.exec(Qt.DropAction.MoveAction)
+ else: # pragma: no cover - Qt5 compatibility
+ drag.exec_(Qt.DropAction.MoveAction)
+
+ def _selected_row_numbers(self) -> list[int]:
+ return sorted({index.row() for index in self.selectionModel().selectedRows()})
+
+ def _drop_row(self, event) -> int:
+ point = self._drop_position(event)
+ row = self.indexAt(point).row()
+ if row < 0:
+ return self.rowCount()
+ if self.dropIndicatorPosition() == QAbstractItemView.DropIndicatorPosition.BelowItem:
+ return row + 1
+ if self.dropIndicatorPosition() == QAbstractItemView.DropIndicatorPosition.OnViewport:
+ return self.rowCount()
+ return row
+
+ @staticmethod
+ def _drop_position(event):
+ if hasattr(event, "position"):
+ position = event.position()
+ if hasattr(position, "toPoint"):
+ return position.toPoint()
+ return position
+ return event.pos()
+
+ @staticmethod
+ def row_order_after_drop(
+ row_count: int, source_rows: list[int], drop_row: int
+ ) -> list[int] | None:
+ source_rows = sorted({row for row in source_rows if 0 <= row < row_count})
+ if not source_rows:
+ return None
+
+ drop_row = max(0, min(drop_row, row_count))
+ source_row_set = set(source_rows)
+ remaining = [row for row in range(row_count) if row not in source_row_set]
+ insert_at = drop_row - sum(row < drop_row for row in source_rows)
+ insert_at = max(0, min(insert_at, len(remaining)))
+ return remaining[:insert_at] + source_rows + remaining[insert_at:]
+
+
+class SAXSTableWidget(QWidget):
+ """Table and row-level actions for prepared SAXS measurements."""
+
+ HEADERS = [
+ "Sample",
+ "ID",
+ "Fast start",
+ "Fast stop",
+ "Stepper start",
+ "Stepper stop",
+ "Exp",
+ "Fast step",
+ "Stepper step",
+ "Images / line",
+ "RotX",
+ "RotY",
+ "Comment",
+ "Scan results",
+ ]
+
+ edit_requested = Signal(int)
+ clear_selected_requested = Signal()
+ clear_all_requested = Signal()
+ save_requested = Signal()
+ upload_requested = Signal()
+ store_csv_requested = Signal()
+ add_scanning_requested = Signal()
+ add_tensor_requested = Signal()
+ move_to_selected_requested = Signal(int)
+ rows_reordered = Signal(list)
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._rows: list[SAXSRow] = []
+
+ self.table = SAXSMeasurementTable(0, len(self.HEADERS), self)
+ self.table.setObjectName("saxs_measurement_table")
+ self.table.setHorizontalHeaderLabels(self.HEADERS)
+ self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
+ self.table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
+ self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
+ self.table.setCornerButtonEnabled(False)
+ self.table.setShowGrid(False)
+ self.table.setTextElideMode(Qt.TextElideMode.ElideRight)
+ self.table.setWordWrap(False)
+ self.table.verticalHeader().setVisible(False)
+ self.table.verticalHeader().setDefaultSectionSize(28)
+ self.table.verticalHeader().setMinimumSectionSize(24)
+ header = self.table.horizontalHeader()
+ header.setHighlightSections(False)
+ header.setMinimumSectionSize(70)
+ header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
+ header.setSectionResizeMode(13, QHeaderView.ResizeMode.Stretch)
+ self.table.setAlternatingRowColors(True)
+
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(8)
+ layout.addWidget(self.table, 1)
+ layout.addWidget(self._build_controls())
+
+ self.table.selectionModel().selectionChanged.connect(self._update_action_state)
+ self.table.cellDoubleClicked.connect(self._edit_row_from_double_click)
+ self.table.rows_reordered.connect(self.rows_reordered.emit)
+ self.delete_shortcut = QShortcut(QKeySequence.StandardKey.Delete, self.table)
+ self.delete_shortcut.activated.connect(self.clear_selected_requested.emit)
+ self.delete_shortcut2 = QShortcut(QKeySequence.StandardKey.Backspace, self.table)
+ self.delete_shortcut2.activated.connect(self.clear_selected_requested.emit)
+ self._update_action_state()
+
+ def set_rows(self, rows: list[SAXSRow]) -> None:
+ self._rows = rows
+ self.table.setRowCount(len(rows))
+ for row_index, row in enumerate(rows):
+ values = [
+ row.sample_name,
+ row.sample_id[:6],
+ self._format_float(row.fast_start),
+ self._format_float(row.fast_stop),
+ self._format_float(row.stepper_start),
+ self._format_float(row.stepper_stop),
+ self._format_float(row.exposure_time),
+ self._format_float(row.fast_step),
+ self._format_float(row.stepper_step),
+ row.geometry.grid_label,
+ "" if row.rotx is None else self._format_float(row.rotx),
+ "" if row.roty is None else self._format_float(row.roty),
+ row.comment,
+ ]
+ for col, value in enumerate(values):
+ self.table.setItem(row_index, col, self._table_item(value))
+ self.table.setCellWidget(row_index, 13, self._scan_results_widget(row.scan_results))
+ self._update_action_state()
+
+ def selected_rows(self) -> list[int]:
+ return sorted({index.row() for index in self.table.selectionModel().selectedRows()})
+
+ def selected_model_rows(self) -> list[SAXSRow]:
+ return [self._rows[index] for index in self.selected_rows()]
+
+ def _build_controls(self) -> QWidget:
+ bar = QFrame(self)
+ bar.setObjectName("saxs_table_controls")
+ bar.setFrameShape(QFrame.Shape.NoFrame)
+ bar.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
+ layout = QHBoxLayout(bar)
+ layout.setContentsMargins(10, 7, 10, 7)
+ layout.setSpacing(8)
+
+ add_label = QLabel("Add", bar)
+ add_label.setObjectName("saxs_toolbar_label")
+ self.add_scanning_button = QPushButton("Scanning SAXS", bar)
+ self.add_tensor_button = QPushButton("Tensor tomography", bar)
+
+ table_label = QLabel("Table", bar)
+ table_label.setObjectName("saxs_toolbar_label")
+ self.row_summary_label = QLabel("0 rows", bar)
+ self.row_summary_label.setObjectName("saxs_row_summary")
+
+ rows_label = QLabel("Rows", bar)
+ rows_label.setObjectName("saxs_toolbar_label")
+ output_label = QLabel("Output", bar)
+ output_label.setObjectName("saxs_toolbar_label")
+
+ self.edit_button = QPushButton("Edit", bar)
+ self.move_to_selected_button = QPushButton("Move to selected", bar)
+ self.clear_selected_button = QPushButton("Remove", bar)
+ self.clear_all_button = QPushButton("Clear all", bar)
+ self.save_button = QPushButton("Save", bar)
+ self.store_csv_button = QPushButton("Store CSV", bar)
+ self.status_label = QLabel("Ready", bar)
+ self.status_label.setObjectName("saxs_table_status")
+ self.status_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
+ self.status_label.setMinimumWidth(120)
+
+ self.add_scanning_button.setToolTip("Create one scanning SAXS row from the current ROI.")
+ self.add_tensor_button.setToolTip(
+ "Create tensor tomography rows from the current ROI and angle definition."
+ )
+
+ self.edit_button.setToolTip(
+ "Edit the selected row.\nHint: double-click a row to open the editor directly."
+ )
+ self.move_to_selected_button.setToolTip(
+ "Move the X/Y positioners to the saved motor positions from when the row "
+ "was created and restore its saved ROI."
+ )
+ self.clear_selected_button.setToolTip(
+ "Remove all selected rows from the table.\nHint: use Delete or Backspace."
+ )
+ self.clear_all_button.setToolTip("Remove every row currently listed in the table.")
+ self.save_button.setToolTip("Save the current table state for the active session.")
+ self.store_csv_button.setToolTip(
+ "Export selected rows or the full table into CSV and save the active session state."
+ )
+
+ self.add_scanning_button.setIcon(material_icon("add", size=(16, 16)))
+ self.add_tensor_button.setIcon(material_icon("add", size=(16, 16)))
+ self.edit_button.setIcon(material_icon("edit", size=(16, 16)))
+ self.move_to_selected_button.setIcon(material_icon("my_location", size=(16, 16)))
+ self.clear_selected_button.setIcon(material_icon("delete", size=(16, 16)))
+ self.clear_all_button.setIcon(material_icon("clear_all", size=(16, 16)))
+ self.save_button.setIcon(material_icon("save", size=(16, 16)))
+ self.store_csv_button.setIcon(material_icon("download", size=(16, 16)))
+
+ self.add_scanning_button.clicked.connect(self.add_scanning_requested.emit)
+ self.add_tensor_button.clicked.connect(self.add_tensor_requested.emit)
+ self.edit_button.clicked.connect(self._edit_selected)
+ self.move_to_selected_button.clicked.connect(self._move_to_selected)
+ self.clear_selected_button.clicked.connect(self.clear_selected_requested.emit)
+ self.clear_all_button.clicked.connect(self.clear_all_requested.emit)
+ self.save_button.clicked.connect(self.save_requested.emit)
+ self.store_csv_button.clicked.connect(self.store_csv_requested.emit)
+
+ for button in [
+ self.add_scanning_button,
+ self.add_tensor_button,
+ self.edit_button,
+ self.move_to_selected_button,
+ self.clear_selected_button,
+ self.clear_all_button,
+ self.save_button,
+ self.store_csv_button,
+ ]:
+ button.setProperty("saxsButtonRole", "toolbar")
+ button.setMinimumHeight(34)
+ button.setIconSize(QSize(18, 18))
+ button.setCursor(Qt.CursorShape.PointingHandCursor)
+ button.setLayoutDirection(Qt.LayoutDirection.LeftToRight)
+ button.setFlat(True)
+ self.clear_selected_button.setProperty("saxsButtonRole", "danger")
+ self.clear_all_button.setProperty("saxsButtonRole", "danger")
+ self.add_scanning_button.setMinimumWidth(128)
+ self.add_tensor_button.setMinimumWidth(150)
+ for button in [
+ self.edit_button,
+ self.move_to_selected_button,
+ self.clear_selected_button,
+ self.clear_all_button,
+ self.save_button,
+ self.store_csv_button,
+ ]:
+ button.setMinimumWidth(84)
+ self.move_to_selected_button.setMinimumWidth(128)
+
+ layout.addWidget(add_label)
+ layout.addWidget(self.add_scanning_button)
+ layout.addWidget(self.add_tensor_button)
+ layout.addWidget(self._vertical_separator(bar))
+ layout.addWidget(table_label)
+ layout.addWidget(self.row_summary_label)
+ layout.addStretch()
+ layout.addWidget(self._vertical_separator(bar))
+ layout.addWidget(rows_label)
+ layout.addWidget(self.edit_button)
+ layout.addWidget(self.move_to_selected_button)
+ layout.addWidget(self.clear_selected_button)
+ layout.addWidget(self.clear_all_button)
+ layout.addWidget(self._vertical_separator(bar))
+ layout.addWidget(output_label)
+ layout.addWidget(self.save_button)
+ layout.addWidget(self.store_csv_button)
+ layout.addWidget(self.status_label)
+ return bar
+
+ @staticmethod
+ def _vertical_separator(parent: QWidget) -> QFrame:
+ separator = QFrame(parent)
+ separator.setObjectName("saxs_toolbar_separator")
+ separator.setFrameShape(QFrame.Shape.VLine)
+ separator.setFrameShadow(QFrame.Shadow.Sunken)
+ return separator
+
+ def _edit_selected(self) -> None:
+ selected = self.selected_rows()
+ if len(selected) == 1:
+ self.edit_requested.emit(selected[0])
+
+ def _move_to_selected(self) -> None:
+ selected = self.selected_rows()
+ if len(selected) == 1:
+ self.move_to_selected_requested.emit(selected[0])
+
+ def _edit_row_from_double_click(self, row: int, _column: int) -> None:
+ self.table.selectRow(row)
+ self.edit_requested.emit(row)
+
+ def _update_action_state(self, *_args) -> None:
+ selected_count = len(self.selected_rows())
+ row_count = len(self._rows)
+ if selected_count:
+ self.row_summary_label.setText(f"{selected_count} selected / {row_count} rows")
+ else:
+ self.row_summary_label.setText(f"{row_count} rows")
+ self.edit_button.setEnabled(selected_count == 1)
+ self.move_to_selected_button.setEnabled(selected_count == 1)
+ self.clear_selected_button.setEnabled(selected_count > 0)
+ self.clear_all_button.setEnabled(row_count > 0)
+ self.save_button.setEnabled(row_count > 0)
+ self.store_csv_button.setEnabled(row_count > 0)
+
+ @staticmethod
+ def _table_item(value: str) -> QTableWidgetItem:
+ item = QTableWidgetItem(str(value))
+ item.setFlags(
+ Qt.ItemFlag.ItemIsEnabled
+ | Qt.ItemFlag.ItemIsSelectable
+ | Qt.ItemFlag.ItemIsDragEnabled
+ | Qt.ItemFlag.ItemIsDropEnabled
+ )
+ item.setToolTip(str(value))
+ return item
+
+ @staticmethod
+ def _scan_results_widget(results: list[ScanResult]) -> QLabel:
+ label = QLabel()
+ label.setContentsMargins(4, 0, 4, 0)
+ label.setTextFormat(Qt.TextFormat.RichText)
+ label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
+ parts = []
+ for result in results:
+ color = "#2e7d32" if result.success else "#c62828"
+ text = html.escape(str(result.scan_number))
+ parts.append(f'{text}')
+ label.setText(", ".join(parts))
+ return label
+
+ @staticmethod
+ def _format_float(value: float) -> str:
+ return f"{value:.6g}"
diff --git a/csaxs_bec/macros/get_saxs_widget_table_data.py b/csaxs_bec/macros/get_saxs_widget_table_data.py
new file mode 100644
index 0000000..fc4d2e2
--- /dev/null
+++ b/csaxs_bec/macros/get_saxs_widget_table_data.py
@@ -0,0 +1,30 @@
+"""
+This macro is used to fetch saved data for a dataset/session created by the SAXS widget.
+It returns a Varible message containing 'value' and 'metadata' fields. The 'metadata'
+contains some context information, while 'value' contains a list of dictionaries, each
+representing a row in the table. The order is kept as exported from the table.
+Scan information can be fetched from this dictionary to setup the cont grid scan.
+"""
+
+from __future__ import annotations
+
+from csaxs_bec.bec_widgets.widgets.saxs_widget.models import MessageEndpointSAXSWidget
+
+
+def get_saxs_widget_table_data(session_name: str, count: int = -1):
+ if not isinstance(count, int):
+ raise TypeError("count must be an integer stream index.")
+ all_msgs = []
+ try:
+ all_msgs = bec.connector.xread(
+ MessageEndpointSAXSWidget.measurement_table(session_name), from_start=True
+ )
+ msg = all_msgs[count]["value"]
+ except (IndexError, KeyError, TypeError) as exc:
+ raise ValueError(
+ f"No data found for session '{session_name}' and index {count}. Total length of all_msgs: {len(all_msgs)}"
+ ) from exc
+ print(
+ f"Retrieved data for session '{msg.metadata.get('session_name')}' with {len(msg.value)} rows; Created at {msg.metadata.get('timestamp')}"
+ )
+ return msg
diff --git a/csaxs_bec/macros/run_cont_grid_scan_for_table_row.py b/csaxs_bec/macros/run_cont_grid_scan_for_table_row.py
new file mode 100644
index 0000000..cdfbd53
--- /dev/null
+++ b/csaxs_bec/macros/run_cont_grid_scan_for_table_row.py
@@ -0,0 +1,133 @@
+"""
+This macro is used to run a cont_grid scan for a single row of the data provided by the SAXS widget.
+These are fetched through the get_saxs_widget_table_data macro, which returns a Variable message containing
+'value' and 'metadata' fields. 'value' contains a list of dictionaries, each representing a row in the table.
+This dictionary is the input for this macro.
+"""
+
+from __future__ import annotations
+
+import numpy as np
+from bec_lib.scan_repeat import scan_repeat
+
+
+# TODO Default True here means that ANY error will be retried up to 3 times. This is dangerous in the sense that
+# if the error is due to a misconfiguration of the scan parameters, it will just keep retrying and failing.
+# Please contact the BEC team if you want to establish a dedicated error handling for this macro.
+@scan_repeat(max_repeats=3, default=True)
+def run_cont_grid_scan_for_table_row(
+ session_name: str, row: dict, phone_numbers: list[str] | None = None
+) -> None:
+ """
+ Run a continuous grid scan for a specific row in the SAXS widget table.
+
+ Args:
+ row (dict): A dictionary representing a row in the SAXS widget table. This should contain all necessary
+ parameters for the continuous grid scan.
+ phone_numbers (list[str] | None): A list of phone numbers to notify in case of errors. Defaults to None.
+ """
+ metadata = {
+ "session_name": session_name,
+ "sample_name": row["sample_name"],
+ "sample_id": row["sample_id"],
+ "comment": row["comment"],
+ "raw_roi": {
+ "x": row["raw_roi_x"],
+ "y": row["raw_roi_y"],
+ "width": row["raw_roi_width"],
+ "height": row["raw_roi_height"],
+ },
+ "camera_center": {"x": row["camera_center_x"], "y": row["camera_center_y"]},
+ "correction_camera_scale_pixel_x": row["correction_camera_scale_pixel_x"],
+ "correction_offset_cam_xray_x": row["correction_offset_cam_xray_x"],
+ "grid_scan_params": {
+ "fast_axis": row["fast_axis"],
+ "fast_start": row["fast_start"],
+ "fast_stop": row["fast_stop"],
+ "fast_step": row["fast_step"],
+ "stepper_axis": row["stepper_axis"],
+ "stepper_start": row["stepper_start"],
+ "stepper_stop": row["stepper_stop"],
+ "stepper_step": row["stepper_step"],
+ "exposure_time": row["exposure_time"],
+ "images_per_line": row["images_per_line"],
+ "num_lines": row["num_lines"],
+ },
+ }
+ if row["template"] == "tensor":
+ metadata.update(
+ {
+ "roty_axis": row["roty_axis"],
+ "roty": row["roty"],
+ "rotx_axis": row["rotx_axis"],
+ "rotx": row["rotx"],
+ }
+ )
+
+ # Move rotation motors if tensor tomography scan template is send.
+ if row["template"] == "tensor":
+ try:
+ # Move motors to the starting position of the scan
+ move_command = []
+ roty_motor = dev.devices[row["roty_axis"]]
+ setpoint_roty = roty_motor.setpoint.get(cached=True)
+ if not np.isclose(setpoint_roty, row["roty"]):
+ move_command.extend([row["roty_axis"], row["roty"]])
+ setpoint_rotx = dev.devices[row["rotx_axis"]].setpoint.get(cached=True)
+ if not np.isclose(setpoint_rotx, row["rotx"]):
+ move_command.extend([row["rotx_axis"], row["rotx"]])
+ # Wait for all motor movements to complete
+ if move_command:
+ scans.umv(*move_command, relative=False)
+ except Exception as e:
+ info_msg = (
+ "Error while moving motors to starting position for sample "
+ f"'{row['sample_name']}' (ID: {row['sample_id']})."
+ )
+ print(info_msg)
+ # Send message to scilog with error tag and text
+ msg = bec.messaging.scilog.new()
+ msg.add_tags("error")
+ msg.add_text(info_msg)
+ msg.add_text(f"Error details: {e}")
+ msg.send()
+ # Notify the user via SMS if phone numbers are provided
+ if phone_numbers:
+ msg = bec.messaging.signal.new()
+ msg.add_text(info_msg)
+ msg.send(scope=phone_numbers)
+ raise
+
+ # Scan command
+ try:
+ scans.cont_grid(
+ fast_axis=row["fast_axis"],
+ fast_start=row["fast_start"],
+ fast_end=row["fast_stop"],
+ fast_step_size=row["fast_step"],
+ stepper_axis=row["stepper_axis"],
+ stepper_start=row["stepper_start"],
+ stepper_stop=row["stepper_stop"],
+ stepper_step_size=row["stepper_step"],
+ exp_time=row["exposure_time"],
+ relative=False,
+ metadata=metadata,
+ )
+ except Exception as e:
+ info_msg = (
+ f"Error while executing cont_grid scan for sample '{row['sample_name']}' "
+ f"with ID '{row['sample_id']}'."
+ )
+ print(info_msg)
+ # Send message to scilog with error tag and text
+ msg = bec.messaging.scilog.new()
+ msg.add_tags("error")
+ msg.add_text(info_msg)
+ msg.add_text(f"Error details: {e}")
+ msg.send()
+ # Notify the user via SMS if phone numbers are provided
+ if phone_numbers:
+ msg = bec.messaging.signal.new()
+ msg.add_text(info_msg)
+ msg.send(scope=phone_numbers)
+ raise
diff --git a/csaxs_bec/scripts/saxs_scan_script.py b/csaxs_bec/scripts/saxs_scan_script.py
new file mode 100644
index 0000000..dccb034
--- /dev/null
+++ b/csaxs_bec/scripts/saxs_scan_script.py
@@ -0,0 +1,38 @@
+# List of phone numbers to send signal notifications to, example: ["+1234567890", "+0987654321"].
+# If not Swiss numbers, please provide + prefix. If empty, no signal notifications will be sent.
+
+#############
+### Example script, copy from here and edit to your needs
+#############
+TELEFON_NUMBERS = []
+SESSION_NAME = "test_session"
+
+
+# count = -1 means latest saved table from the SAXS widget, 0 means first saved table, 1 means second saved table, etc. -1 is DEFAULT
+# Provide it as a kwargs if you need to fetch older table data.
+msg = get_saxs_widget_table_data(SESSION_NAME)
+
+print(
+ f"Fetched {len(msg.value)} rows from SAXS widget table for session '{msg.metadata.get('session_name')}'. Table data created at {msg.metadata.get('timestamp')}"
+)
+
+total_rows = len(msg.value)
+for ii, row in enumerate(msg.value):
+ print(f"Running continuous grid scan for row {ii + 1}/{len(msg.value)}: {row}")
+ run_cont_grid_scan_for_table_row(
+ session_name=SESSION_NAME, row=row, phone_numbers=TELEFON_NUMBERS
+ )
+ if ii % 10 == 0:
+ # Send update to scilog every 10 scans to avoid spamming the log
+ msg = bec.messaging.scilog.new()
+ msg.add_text(
+ f"Completed {ii + 1}/{total_rows} continuous grid scans for session '{SESSION_NAME}'"
+ )
+ msg.send()
+ # Send update to signal if phone numbers are provided
+ if TELEFON_NUMBERS:
+ msg = bec.messaging.signal.new()
+ msg.add_text(
+ f"Completed {ii + 1}/{total_rows} continuous grid scans for session '{SESSION_NAME}'"
+ )
+ msg.send(scope=TELEFON_NUMBERS)