From ee701f5d790264726d829b2a191e0480da086dba Mon Sep 17 00:00:00 2001 From: appel_c Date: Fri, 3 Jul 2026 18:16:11 +0200 Subject: [PATCH] feat(saxs-widget): Add SAXS Widget for scanning SAXS and Tensor Tomography measurement setup --- csaxs_bec/bec_widgets/widgets/client.py | 91 +- .../bec_widgets/widgets/designer_plugins.py | 2 + .../bec_widgets/widgets/saxs_widget/README.md | 189 ++ .../widgets/saxs_widget/__init__.py | 1 + .../widgets/saxs_widget/controls.py | 446 ++++ .../widgets/saxs_widget/coordinates.py | 108 + .../widgets/saxs_widget/dialogs.py | 335 +++ .../bec_widgets/widgets/saxs_widget/models.py | 549 +++++ .../saxs_widget/register_saxs_widget.py | 15 + .../widgets/saxs_widget/saxs_widget.py | 1826 +++++++++++++++++ .../widgets/saxs_widget/saxs_widget.pyproject | 1 + .../widgets/saxs_widget/saxs_widget_plugin.py | 57 + .../bec_widgets/widgets/saxs_widget/table.py | 393 ++++ .../macros/get_saxs_widget_table_data.py | 32 + .../run_cont_grid_scan_for_table_row.py | 130 ++ csaxs_bec/scripts/saxs_scan_script.py | 38 + tests/tests_bec_widgets/test_saxs_controls.py | 33 + tests/tests_bec_widgets/test_saxs_sessions.py | 136 ++ .../test_saxs_table_widget.py | 157 ++ 19 files changed, 4532 insertions(+), 7 deletions(-) create mode 100644 csaxs_bec/bec_widgets/widgets/saxs_widget/README.md create mode 100644 csaxs_bec/bec_widgets/widgets/saxs_widget/__init__.py create mode 100644 csaxs_bec/bec_widgets/widgets/saxs_widget/controls.py create mode 100644 csaxs_bec/bec_widgets/widgets/saxs_widget/coordinates.py create mode 100644 csaxs_bec/bec_widgets/widgets/saxs_widget/dialogs.py create mode 100644 csaxs_bec/bec_widgets/widgets/saxs_widget/models.py create mode 100644 csaxs_bec/bec_widgets/widgets/saxs_widget/register_saxs_widget.py create mode 100644 csaxs_bec/bec_widgets/widgets/saxs_widget/saxs_widget.py create mode 100644 csaxs_bec/bec_widgets/widgets/saxs_widget/saxs_widget.pyproject create mode 100644 csaxs_bec/bec_widgets/widgets/saxs_widget/saxs_widget_plugin.py create mode 100644 csaxs_bec/bec_widgets/widgets/saxs_widget/table.py create mode 100644 csaxs_bec/macros/get_saxs_widget_table_data.py create mode 100644 csaxs_bec/macros/run_cont_grid_scan_for_table_row.py create mode 100644 csaxs_bec/scripts/saxs_scan_script.py create mode 100644 tests/tests_bec_widgets/test_saxs_controls.py create mode 100644 tests/tests_bec_widgets/test_saxs_sessions.py create mode 100644 tests/tests_bec_widgets/test_saxs_table_widget.py diff --git a/csaxs_bec/bec_widgets/widgets/client.py b/csaxs_bec/bec_widgets/widgets/client.py index 1ec7a87..242d8cc 100644 --- a/csaxs_bec/bec_widgets/widgets/client.py +++ b/csaxs_bec/bec_widgets/widgets/client.py @@ -13,12 +13,89 @@ logger = bec_logger.logger _Widgets = { + "SAXSWidget": "SAXSWidget", "SlitControlWidget": "SlitControlWidget", "TomoParamsWidget": "TomoParamsWidget", "XRayEye": "XRayEye", } +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.""" @@ -34,31 +111,31 @@ class SlitControlWidget(RPCBase): @rpc_call def move_center(self, direction: "str"): """ - Move the slit center by one step. direction: up / down / left / right. + Move the slit center. direction: up / down / left / right. """ @rpc_call def move_size(self, direction: "str"): """ - Adjust the slit size by one step. up/right = grow, down/left = shrink. + Adjust slit size. up/right = grow, down/left = shrink. """ @rpc_call def set_step(self, value: "float"): """ - Set the shared step size in mm (clamped to 0.005–2.0 mm). + Set step size in mm (clamped to 0.005–2.0 mm). """ @rpc_call def double_step(self): """ - Double the current step size. + None """ @rpc_call def halve_step(self): """ - Halve the current step size. + None """ @@ -70,7 +147,7 @@ class TomoParamsWidget(RPCBase): @rpc_call def refresh(self) -> "None": """ - Full refresh: params, queue, progress, sample name. + Full refresh: params + sample name. """ @rpc_call @@ -94,7 +171,7 @@ class TomoParamsWidget(RPCBase): @rpc_call def add_to_queue(self) -> "None": """ - Snapshot the current live global-var parameters and append a new job. + Open queue dialog and trigger Add (also accessible via RPC). """ diff --git a/csaxs_bec/bec_widgets/widgets/designer_plugins.py b/csaxs_bec/bec_widgets/widgets/designer_plugins.py index fefec5a..e137f0a 100644 --- a/csaxs_bec/bec_widgets/widgets/designer_plugins.py +++ b/csaxs_bec/bec_widgets/widgets/designer_plugins.py @@ -5,6 +5,7 @@ from __future__ import annotations # pylint: skip-file designer_plugins = { + "SAXSWidget": ("csaxs_bec.bec_widgets.widgets.saxs_widget.saxs_widget", "SAXSWidget"), "SlitControlWidget": ( "csaxs_bec.bec_widgets.widgets.slit_control.slit_control", "SlitControlWidget", @@ -17,6 +18,7 @@ designer_plugins = { } widget_icons = { + "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..d1557b4 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/README.md @@ -0,0 +1,189 @@ +# 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`. | +| `frames_per_trigger` | Convenience alias of `images_per_line`; often useful for detector setup. | + +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` | Convenience alias of `fast_step`. | +| `step_size_x` / `step_size_y` | Coordinate-specific step size aliases. | +| `fast_end` | Convenience alias of `fast_stop`. | +| `fast_step_size` | Convenience alias of `fast_step`. | +| `stepper_step_size` | Convenience alias of `stepper_step`. | + +## 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`, + `total_frames` and/or `frames_per_trigger`. +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"], + frames_per_trigger=row["frames_per_trigger"], + 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..0b7f372 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/controls.py @@ -0,0 +1,446 @@ +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) + + self.camera_scale_pixel_x = spinbox(value=1.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=1.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..9d4ee52 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/dialogs.py @@ -0,0 +1,335 @@ +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() + except ValueError as exc: + 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..f8bf548 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/models.py @@ -0,0 +1,549 @@ +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() + 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.") + + 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 frames_per_trigger(self) -> int: + return self.images_per_line + + @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": geometry.fast_step, + "step_size_x": self.step_size_x, + "step_size_y": self.step_size_y, + "fast_end": geometry.fast_stop, + "fast_step_size": geometry.fast_step, + "stepper_step_size": geometry.stepper_step, + "frames_per_trigger": geometry.images_per_line, + } + + 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": "Convenience alias of fast_step.", + "step_size_x": "Convenience alias of step size in X coordinates.", + "step_size_y": "Convenience alias of step size in Y coordinates.", + "fast_end": "Convenience alias of fast_stop.", + "fast_step_size": "Convenience alias of fast_step.", + "stepper_step_size": "Convenience alias of stepper_step.", + "frames_per_trigger": "Convenience alias of images_per_line.", + } + + +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..6f3072c --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/saxs_widget.py @@ -0,0 +1,1826 @@ +"""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 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: pin_x - 0.5} + snap_y = {item_name: pin_y - 0.5} + 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, + ) -> 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, + y_positioner=self.device_box.y_positioner, + rotx_axis=self.device_box.rotx_axis, + roty_axis=self.device_box.roty_axis, + ) + + @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 + if not self.device_box.x_positioner or not self.device_box.y_positioner: + self._warn("Select X and Y positioners before adding a scanning SAXS row.") + 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"], + ) + 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 + if not self.device_box.x_positioner or not self.device_box.y_positioner: + self._warn("Select X and Y positioners before adding tensor tomography rows.") + return [] + if not self.device_box.rotx_axis or not self.device_box.roty_axis: + self._warn("Select X and Y rotation axes before adding tensor tomography rows.") + 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, + ) + ) + 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, + ) + 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 = rows if rows is not None else list(range(len(self._rows))) + try: + correction = self._current_correction() + except ValueError as exc: + self._warn(str(exc)) + return + for index in indices: + row = self._rows[index] + try: + row.correction = correction + row.geometry = corrected_geometry( + row.raw_roi, + correction, + row.fast_axis, + row.fast_step, + row.stepper_axis, + row.stepper_step, + camera_center=row.camera_center, + ) + except ValueError as exc: + self._warn(f"Could not update row {index + 1}: {exc}") + 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() + if not selected: + self._warn("There are no rows to export.") + return None + + box = QMessageBox(self) + box.setWindowTitle("Save and store table as CSV") + box.setText("Store selected rows or all rows?") + 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 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._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 + if not self._rows: + if show_feedback: + self._warn("Nothing to save: the table is empty.") + 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.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..e5d390b --- /dev/null +++ b/csaxs_bec/macros/get_saxs_widget_table_data.py @@ -0,0 +1,32 @@ +""" +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 typing import TYPE_CHECKING + +from csaxs_bec.bec_widgets.widgets.saxs_widget.models import MessageEndpointSAXSWidget + +if TYPE_CHECKING: + from bec_lib.messages import VariableMessage + + +def get_saxs_widget_table_data(session_name: str, count: int = -1) -> VariableMessage: + try: + all_msgs = bec.connector.xread( + MessageEndpointSAXSWidget.measurement_table(session_name), from_start=True + ) + msg = all_msgs[-1]["value"] + except IndexError 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..277cb77 --- /dev/null +++ b/csaxs_bec/macros/run_cont_grid_scan_for_table_row.py @@ -0,0 +1,130 @@ +""" +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.logger import bec_logger +from bec_lib.scan_repeat import scan_repeat + +logger = bec_logger.logger + + +# TODO: Careful, this means that currently this repeats the macro 3 times no matter what error is raised. +# This can be improved significantly by providing a dedicated error handler. To implement this, please +# consider potential errors and reach out to the BEC team helping with the implementation. +@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_end": row["fast_end"], + "fast_step": row["fast_step"], + "slow_axis": row["slow_axis"], + "slow_start": row["slow_start"], + "slow_end": row["slow_end"], + "slow_step": row["slow_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=True) + except Exception as e: + info_msg = f"Error while moving motors to starting position for sample '{row['sample_name']}' (ID: {row['sample_id']}). Running a maximum of 3 repeat attempts." + logger.error(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 e + + # Scan command + try: + scans.cont_grid( + 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"], + 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']}' with ID '{row['sample_id']}'. Running a maximum of 3 repeat attempts." + logger.error(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 e 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) diff --git a/tests/tests_bec_widgets/test_saxs_controls.py b/tests/tests_bec_widgets/test_saxs_controls.py new file mode 100644 index 0000000..05cf775 --- /dev/null +++ b/tests/tests_bec_widgets/test_saxs_controls.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QApplication + +from csaxs_bec.bec_widgets.widgets.saxs_widget.controls import ROIMappingBox + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +def test_roi_mapping_fields_have_descriptive_tooltips(app): + box = ROIMappingBox() + + assert "Pixel-to-motor scale" in box.camera_scale_pixel_x.toolTip() + assert "x-ray beam" in box.offset_cam_xray_x.toolTip() + assert "Orientation sign" in box.direction_x.toolTip() + assert "camera center" in box.reference_x.toolTip() + assert "width and height" in box.roi_width.toolTip() + assert box.camera_scale_pixel_label.toolTip() == box.camera_scale_pixel_x.toolTip() + assert box.offset_cam_xray_label.toolTip() == box.offset_cam_xray_x.toolTip() + assert box.direction_label.toolTip() == box.direction_x.toolTip() + assert box.reference_label.toolTip() == box.reference_x.toolTip() + assert box.roi_size_label.toolTip() == box.roi_width.toolTip() + assert box.direction_x.itemData(0, Qt.ItemDataRole.ToolTipRole) + assert box.direction_x.itemData(1, Qt.ItemDataRole.ToolTipRole) diff --git a/tests/tests_bec_widgets/test_saxs_sessions.py b/tests/tests_bec_widgets/test_saxs_sessions.py new file mode 100644 index 0000000..b9e01c7 --- /dev/null +++ b/tests/tests_bec_widgets/test_saxs_sessions.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from csaxs_bec.bec_widgets.widgets.saxs_widget import saxs_widget as saxs_module +from csaxs_bec.bec_widgets.widgets.saxs_widget.saxs_widget import SAXSWidget + + +class _StatusLabel: + def __init__(self): + self.text = "" + + def setText(self, text: str) -> None: + self.text = text + + +class _TableWidget: + def __init__(self): + self.rows = None + + def set_rows(self, rows) -> None: + self.rows = list(rows) + + +class _Connector: + def __init__(self): + self.entries = [] + + def xadd(self, endpoint, entry, **kwargs) -> None: + self.entries.append((endpoint, entry, kwargs)) + + +def _widget_shell(*, active_session: str | None = None, rows=None) -> SAXSWidget: + widget = SAXSWidget.__new__(SAXSWidget) + widget._active_session = active_session + widget._rows = list(rows or []) + widget._session_dirty = False + widget.status_label = _StatusLabel() + widget.table_widget = _TableWidget() + widget._update_session_ui_state = lambda: None + return widget + + +def test_delete_session_prompts_available_sessions_and_deletes_selection(monkeypatch): + widget = _widget_shell(active_session="alpha", rows=[object()]) + deleted = [] + confirmed = [] + prompt_args = {} + widget.fetch_available_sessions = lambda: {"alpha": [], "beta": []} + widget.delete_data_for_session_name = deleted.append + widget._confirm_delete_session = lambda name: confirmed.append(name) or True + + def fake_get_item(parent, title, label, items, current, editable): + prompt_args.update(title=title, label=label, items=list(items), editable=editable) + return "beta", True + + monkeypatch.setattr(saxs_module.QInputDialog, "getItem", fake_get_item) + + SAXSWidget._delete_session(widget) + + assert prompt_args == { + "title": "Delete session", + "label": "Available sessions:", + "items": ["alpha", "beta"], + "editable": False, + } + assert confirmed == ["beta"] + assert deleted == ["beta"] + assert widget._active_session == "alpha" + assert len(widget._rows) == 1 + assert widget.status_label.text == "Deleted session 'beta'" + + +def test_deleting_active_session_clears_active_table(): + widget = _widget_shell(active_session="alpha", rows=[object()]) + widget._session_dirty = True + deleted = [] + widget.delete_data_for_session_name = deleted.append + + SAXSWidget._delete_named_session(widget, "alpha") + + assert deleted == ["alpha"] + assert widget._active_session is None + assert widget._rows == [] + assert widget._session_dirty is False + assert widget.table_widget.rows == [] + assert widget.status_label.text == "Deleted session 'alpha'" + + +def test_new_session_with_existing_name_can_load_saved_session(): + widget = _widget_shell(active_session="other", rows=[object()]) + sessions = {"alpha": [{"payload": [{"sample_name": "stored"}]}]} + loaded = [] + widget._prompt_valid_session_name = lambda title: "alpha" + widget.fetch_available_sessions = lambda: sessions + widget._prompt_existing_session_action = lambda name: "load" + widget._load_latest_session = lambda name, data: loaded.append((name, data)) or True + + SAXSWidget._new_session(widget) + + assert loaded == [("alpha", sessions)] + assert widget._active_session == "other" + assert len(widget._rows) == 1 + + +def test_new_session_with_existing_name_can_replace_history_with_empty_session(): + widget = _widget_shell(active_session="other", rows=[object()]) + deleted = [] + widget._session_dirty = True + widget._prompt_valid_session_name = lambda title: "alpha" + widget.fetch_available_sessions = lambda: {"alpha": [{"payload": [{"sample_name": "stored"}]}]} + widget._prompt_existing_session_action = lambda name: "replace" + widget.delete_data_for_session_name = deleted.append + + SAXSWidget._new_session(widget) + + assert deleted == ["alpha"] + assert widget._active_session == "alpha" + assert widget._rows == [] + assert widget._session_dirty is False + assert widget.table_widget.rows == [] + assert widget.status_label.text == "Created empty session 'alpha'" + + +def test_published_session_metadata_uses_an_iso_utc_timestamp(): + widget = _widget_shell() + connector = _Connector() + widget.client = type("Client", (), {"connector": connector})() + + SAXSWidget.publish_table_for_session_name(widget, "alpha", []) + + message = connector.entries[0][1]["value"] + timestamp = message.metadata["timestamp"] + assert isinstance(timestamp, str) + assert timestamp.endswith("Z") + assert datetime.fromisoformat(timestamp).tzinfo == timezone.utc diff --git a/tests/tests_bec_widgets/test_saxs_table_widget.py b/tests/tests_bec_widgets/test_saxs_table_widget.py new file mode 100644 index 0000000..7e21de6 --- /dev/null +++ b/tests/tests_bec_widgets/test_saxs_table_widget.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from qtpy.QtCore import QPoint, QPointF, Qt +from qtpy.QtWidgets import QApplication + +from csaxs_bec.bec_widgets.widgets.saxs_widget.models import ( + CameraCenter, + Correction, + RawROI, + SAXSRow, + corrected_geometry, +) +from csaxs_bec.bec_widgets.widgets.saxs_widget.saxs_widget import SAXSWidget +from csaxs_bec.bec_widgets.widgets.saxs_widget import table as table_module +from csaxs_bec.bec_widgets.widgets.saxs_widget.table import SAXSTableWidget + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +def _row(name: str, index: int) -> SAXSRow: + raw_roi = RawROI(x=10 + index, y=20 + index, width=30, height=40) + correction = Correction( + camera_scale_pixel_x=0.1, + offset_cam_xray_x=1.0, + direction_x=1, + camera_scale_pixel_y=0.2, + offset_cam_xray_y=-1.0, + direction_y=1, + ) + camera_center = CameraCenter(x=100 + index, y=200 + index) + geometry = corrected_geometry(raw_roi, correction, camera_center=camera_center) + return SAXSRow( + sample_name=name, + sample_id=f"sample-{index}", + raw_roi=raw_roi, + correction=correction, + geometry=geometry, + exposure_time=0.05, + camera_center=camera_center, + x_positioner="samx", + y_positioner="samy", + ) + + +def test_saxs_row_payload_has_no_schema_version(): + row = _row("alpha", 0) + + assert "schema_version" not in row.to_dict() + assert "schema_version" not in SAXSRow.help() + + +class _DropEvent: + def __init__(self, point: QPoint): + self._point = point + self.accepted = False + self.ignored = False + + def position(self) -> QPointF: + return QPointF(self._point) + + def pos(self) -> QPoint: + return self._point + + def acceptProposedAction(self) -> None: + self.accepted = True + + def ignore(self) -> None: + self.ignored = True + + +class _Drag: + instances = [] + + def __init__(self, source): + self.source = source + self.mime_data = None + self.executed_with = None + self.instances.append(self) + + def setMimeData(self, mime_data) -> None: + self.mime_data = mime_data + + def exec(self, supported_actions): + self.executed_with = supported_actions + return Qt.DropAction.MoveAction + + +def test_table_displays_rows_and_updates_selection_actions(app): + widget = SAXSTableWidget() + rows = [_row("alpha", 0), _row("beta", 1)] + + widget.set_rows(rows) + widget.table.selectRow(1) + app.processEvents() + + assert widget.table.rowCount() == 2 + assert widget.table.item(0, 0).text() == "alpha" + assert widget.table.item(0, 0).flags() & Qt.ItemFlag.ItemIsDragEnabled + assert widget.selected_rows() == [1] + assert widget.selected_model_rows() == [rows[1]] + assert widget.move_to_selected_button.isEnabled() + + +def test_table_start_drag_keeps_source_items_visible(app, monkeypatch): + widget = SAXSTableWidget() + rows = [_row("alpha", 0), _row("beta", 1), _row("gamma", 2)] + widget.set_rows(rows) + widget.table.selectRow(1) + app.processEvents() + _Drag.instances.clear() + monkeypatch.setattr(table_module, "QDrag", _Drag) + + widget.table.startDrag(Qt.DropAction.MoveAction) + + assert len(_Drag.instances) == 1 + assert _Drag.instances[0].source is widget.table + assert _Drag.instances[0].mime_data is not None + assert _Drag.instances[0].executed_with == Qt.DropAction.MoveAction + assert [widget.table.item(row, 0).text() for row in range(3)] == ["alpha", "beta", "gamma"] + + +def test_drag_drop_reorders_rows_and_persists_in_saxs_widget(app): + widget = SAXSTableWidget() + rows = [_row("alpha", 0), _row("beta", 1), _row("gamma", 2)] + widget.set_rows(rows) + widget.resize(900, 240) + widget.show() + app.processEvents() + + saxs_widget = SAXSWidget.__new__(SAXSWidget) + saxs_widget._rows = list(rows) + saxs_widget._session_dirty = False + saxs_widget.table_widget = widget + saxs_widget.status_label = widget.status_label + saxs_widget._update_session_ui_state = lambda: None + widget.rows_reordered.connect(lambda order: SAXSWidget.reorder_rows(saxs_widget, order)) + + widget.table.selectRow(0) + app.processEvents() + last_row = widget.table.rowCount() - 1 + drop_y = widget.table.rowViewportPosition(last_row) + widget.table.rowHeight(last_row) + 4 + event = _DropEvent(QPoint(4, drop_y)) + widget.table.dropEvent(event) + + assert event.accepted + assert not event.ignored + assert [row.sample_name for row in saxs_widget._rows] == ["beta", "gamma", "alpha"] + assert [widget.table.item(row, 0).text() for row in range(3)] == ["beta", "gamma", "alpha"] + assert saxs_widget._session_dirty