From abed4f544ce201599f0f3cacd69148b0b86d1055 Mon Sep 17 00:00:00 2001 From: appel_c Date: Thu, 9 Jul 2026 19:02:26 +0200 Subject: [PATCH] apply fixes --- .../bec_widgets/widgets/saxs_widget/README.md | 24 +- .../widgets/saxs_widget/controls.py | 70 ++++- .../widgets/saxs_widget/coordinates.py | 106 +++++++ .../bec_widgets/widgets/saxs_widget/models.py | 72 +++-- .../widgets/saxs_widget/saxs_widget.py | 268 ++++++++++++++---- .../bec_widgets/widgets/saxs_widget/table.py | 28 +- 6 files changed, 482 insertions(+), 86 deletions(-) create mode 100644 csaxs_bec/bec_widgets/widgets/saxs_widget/coordinates.py diff --git a/csaxs_bec/bec_widgets/widgets/saxs_widget/README.md b/csaxs_bec/bec_widgets/widgets/saxs_widget/README.md index 4ef5f34..18581be 100644 --- a/csaxs_bec/bec_widgets/widgets/saxs_widget/README.md +++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/README.md @@ -42,7 +42,7 @@ CSV export uses the same row fields, but additionally prefixes each row with `se ## Row Fields For Scan Macros -Each row currently uses `schema_version = 2`. +Each row currently uses `schema_version = 3`. Core scan fields: @@ -73,6 +73,7 @@ Coordinate and motor context: | `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: @@ -81,13 +82,24 @@ ROI and mapping provenance: | `raw_roi_x`, `raw_roi_y` | Raw ROI origin in image coordinates. | | `raw_roi_width`, `raw_roi_height` | Raw ROI size in image coordinates. | | `correction_scale_x`, `correction_scale_y` | Scale factors used to convert ROI coordinates to positions. | -| `correction_offset_x`, `correction_offset_y` | Physical positions at the mapping reference point. | +| `correction_offset_x`, `correction_offset_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 zero/reference point for the correction. New rows use the image center when image data exists, otherwise the ROI center. | +| `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 * scale * (raw_roi - reference) +x-ray beam scan position = camera-space motor position + correction_offset +``` + +`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 scaled ROI displacement and the calibrated x-ray offset. + Bookkeeping and compatibility fields: | Field | Meaning | @@ -121,7 +133,7 @@ def run_saxs_table(variable_message): rows = variable_message.value for row in rows: - if row.get("schema_version") != 2: + if row.get("schema_version") != 3: raise ValueError(f"Unsupported SAXS row schema: {row.get('schema_version')}") metadata = { @@ -135,6 +147,10 @@ def run_saxs_table(variable_message): "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": diff --git a/csaxs_bec/bec_widgets/widgets/saxs_widget/controls.py b/csaxs_bec/bec_widgets/widgets/saxs_widget/controls.py index 83758f9..25d1f53 100644 --- a/csaxs_bec/bec_widgets/widgets/saxs_widget/controls.py +++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/controls.py @@ -221,6 +221,7 @@ 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) @@ -241,6 +242,8 @@ class ROIMappingBox(QGroupBox): 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) for reference in [self.reference_x, self.reference_y]: reference.setObjectName("saxs_reference_field") reference.setReadOnly(True) @@ -269,6 +272,18 @@ class ROIMappingBox(QGroupBox): layout.addWidget(self.reference_y, 4, 2) layout.addWidget(field_label("x / y", self), 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") + layout.addWidget(field_label("ROI size", self), 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)) @@ -278,8 +293,9 @@ class ROIMappingBox(QGroupBox): self.apply_corrections_button.setCursor(Qt.CursorShape.PointingHandCursor) self.apply_corrections_button.setFlat(True) self.apply_corrections_button.setProperty("saxsButtonRole", "toolbar") - layout.addWidget(self.apply_corrections_button, 5, 0, 1, 4) + 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()) @@ -296,10 +312,59 @@ class ROIMappingBox(QGroupBox): reference_y=float(self.reference_y.text() or 0.0), ) + def set_correction(self, correction: Correction) -> None: + for widget in [ + self.scale_x, + self.offset_x, + self.direction_x, + self.scale_y, + self.offset_y, + self.direction_y, + ]: + widget.blockSignals(True) + try: + self.scale_x.setValue(correction.scale_x) + self.offset_x.setValue(correction.offset_x) + self._set_combo_current_data(self.direction_x, correction.direction_x) + self.scale_y.setValue(correction.scale_y) + self.offset_y.setValue(correction.offset_y) + self._set_combo_current_data(self.direction_y, correction.direction_y) + finally: + for widget in [ + self.scale_x, + self.offset_x, + self.direction_x, + self.scale_y, + self.offset_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() @@ -311,6 +376,9 @@ class ROIMappingBox(QGroupBox): self.scale_y, self.offset_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..cb09b08 --- /dev/null +++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/coordinates.py @@ -0,0 +1,106 @@ +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, scale: float, direction: int) -> float: + """Convert a raw image coordinate into a motor-space delta.""" + return int(direction) * float(scale) * (float(raw_value) - float(reference)) + + +def mapped_coordinate( + raw_value: float, + *, + reference: float, + scale: float, + direction: int, + camera_center: float = 0.0, + beam_offset: float = 0.0, +) -> float: + """Map one image coordinate to motor coordinates.""" + return ( + float(camera_center) + + float(beam_offset) + + scaled_delta(raw_value, reference, scale, direction) + ) + + +def axis_limits( + raw_start: float, + raw_size: float, + *, + reference: float, + scale: float, + direction: int, + camera_center: float = 0.0, + beam_offset: float = 0.0, +) -> tuple[float, float]: + """Return start/stop motor coordinates for one raw ROI axis.""" + start = mapped_coordinate( + raw_start, + reference=reference, + scale=scale, + direction=direction, + camera_center=camera_center, + beam_offset=beam_offset, + ) + stop = mapped_coordinate( + float(raw_start) + float(raw_size), + reference=reference, + scale=scale, + direction=direction, + camera_center=camera_center, + beam_offset=beam_offset, + ) + return start, stop + + +def point_position( + image_x: float, + image_y: float, + correction: Correction, + *, + camera_center: tuple[float, float] = (0.0, 0.0), + include_beam_offset: bool = True, +) -> tuple[float, float]: + """Map one image point into X/Y motor coordinates.""" + offset_x = correction.offset_x if include_beam_offset else 0.0 + offset_y = correction.offset_y if include_beam_offset else 0.0 + return ( + mapped_coordinate( + image_x, + reference=correction.reference_x, + scale=correction.scale_x, + direction=correction.direction_x, + camera_center=camera_center[0], + beam_offset=offset_x, + ), + mapped_coordinate( + image_y, + reference=correction.reference_y, + scale=correction.scale_y, + direction=correction.direction_y, + camera_center=camera_center[1], + beam_offset=offset_y, + ), + ) + + +def roi_origin_position( + raw_roi: RawROI, + correction: Correction, + *, + camera_center: tuple[float, float] = (0.0, 0.0), + include_beam_offset: 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_beam_offset=include_beam_offset, + ) diff --git a/csaxs_bec/bec_widgets/widgets/saxs_widget/models.py b/csaxs_bec/bec_widgets/widgets/saxs_widget/models.py index 937327e..3e30ae9 100644 --- a/csaxs_bec/bec_widgets/widgets/saxs_widget/models.py +++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/models.py @@ -11,6 +11,8 @@ 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 @@ -222,9 +224,17 @@ class RawROI: 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 physical positions.""" + """Corrections to convert raw ROI coordinates to beam positions.""" scale_x: float offset_x: float @@ -295,6 +305,7 @@ class SAXSRow: correction: Correction geometry: ScanGeometry exposure_time: float + camera_center: CameraCenter comment: str = "" template: Literal["scanning", "tensor"] = "scanning" rotx: float | None = None @@ -364,7 +375,7 @@ class SAXSRow: separators=(",", ":"), ) return { - "schema_version": 2, + "schema_version": 3, "template": self.template, "sample_name": self.sample_name, "sample_id": self.sample_id, @@ -389,6 +400,8 @@ class SAXSRow: "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, @@ -427,7 +440,7 @@ class SAXSRow: def help() -> dict[str, str]: """Return human-readable documentation for all exported payload fields.""" return { - "schema_version": "Payload schema version for consumers. Current value is 2.", + "schema_version": "Payload schema version for consumers. Current value is 3.", "template": "Row template type, for example scanning or tensor.", "sample_name": "User-provided sample label.", "sample_id": "Generated short unique ID for this row.", @@ -452,15 +465,17 @@ class SAXSRow: "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_scale_x": "Scale factor applied from ROI X to physical X.", - "correction_offset_x": "Physical X position at correction_reference_x.", + "correction_offset_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_scale_y": "Scale factor applied from ROI Y to physical Y.", - "correction_offset_y": "Physical Y position at correction_reference_y.", + "correction_offset_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 that maps to correction_offset_x.", "correction_reference_y": "Image coordinate that maps to correction_offset_y.", @@ -482,31 +497,42 @@ def corrected_geometry( 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.") - def axis_limits(axis: Literal["x", "y"]) -> tuple[float, float]: - if axis == "x": - start = correction.offset_x + correction.direction_x * correction.scale_x * ( - raw_roi.x - correction.reference_x - ) - stop = correction.offset_x + correction.direction_x * correction.scale_x * ( - raw_roi.x + raw_roi.width - correction.reference_x - ) - return start, stop - start = correction.offset_y + correction.direction_y * correction.scale_y * ( - raw_roi.y - correction.reference_y - ) - stop = correction.offset_y + correction.direction_y * correction.scale_y * ( - raw_roi.y + raw_roi.height - correction.reference_y - ) - return start, stop + if isinstance(camera_center, CameraCenter): + center_x, center_y = camera_center.x, camera_center.y + else: + center_x, center_y = camera_center - fast_start, fast_stop = axis_limits(fast_axis) - stepper_start, stepper_stop = axis_limits(stepper_axis) + 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, + scale=correction.scale_x, + direction=correction.direction_x, + camera_center=center_x, + beam_offset=correction.offset_x, + ) + return axis_limits( + raw_roi.y, + raw_roi.height, + reference=correction.reference_y, + scale=correction.scale_y, + direction=correction.direction_y, + camera_center=center_y, + beam_offset=correction.offset_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 ) diff --git a/csaxs_bec/bec_widgets/widgets/saxs_widget/saxs_widget.py b/csaxs_bec/bec_widgets/widgets/saxs_widget/saxs_widget.py index f7e5ff7..e57a768 100644 --- a/csaxs_bec/bec_widgets/widgets/saxs_widget/saxs_widget.py +++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/saxs_widget.py @@ -55,6 +55,7 @@ from csaxs_bec.bec_widgets.widgets.saxs_widget.dialogs import ( from csaxs_bec.bec_widgets.widgets.saxs_widget.models import ( DEFAULT_EXPOSURE_TIME, DEFAULT_STEP_SIZE, + CameraCenter, Correction, MessageEndpointSAXSWidget, RawROI, @@ -83,6 +84,7 @@ class SAXSWidget(BECWidget, QWidget): "rows_as_dicts", "set_positioners", "mark_scan_result", + "move_to_selected_row", ] ROI_NAME = "SAXS ROI" @@ -130,10 +132,6 @@ class SAXSWidget(BECWidget, QWidget): top_layout.setContentsMargins(0, 0, 0, 0) top_layout.setSpacing(8) - self.image = Image(parent=top) - self._customize_image_widget(self.image) - top_layout.addWidget(self.image, 1) - side_panel = QFrame(top) side_panel.setObjectName("saxs_side_panel") side_panel.setFrameShape(QFrame.Shape.NoFrame) @@ -157,6 +155,10 @@ class SAXSWidget(BECWidget, QWidget): device_mapping_layout.addWidget(self.device_box, 1) device_mapping_layout.addWidget(self.mapping_box, 1) + self.image = Image(parent=top) + self._customize_image_widget(self.image) + top_layout.addWidget(self.image, 1) + side_layout.addWidget(side_title) side_layout.addWidget(device_mapping_panel) side_layout.addWidget(separator(side_panel)) @@ -167,6 +169,9 @@ class SAXSWidget(BECWidget, QWidget): for direction in ["horizontal", "vertical"]: ui = self.positioner_box._device_ui_components_hv(direction) for k, w in ui.items(): + if k == "step_size": + w.setValue(1) + continue if k not in [ "tweak_increase", "tweak_decrease", @@ -260,7 +265,7 @@ class SAXSWidget(BECWidget, QWidget): # If enabled, set the camera to live mode, if disabled, stop the live mode. print(f"Toggling camera live mode to {'enabled' if enabled else 'disabled'}") - def _refresh_crosshair_pin_if_enabled(self, *_args, force: bool = False) -> None: + def _refresh_crosshair_pin_if_enabled(self, force: bool = False) -> None: if self._crosshair_pin_enabled: pin_position = self._mapping_reference_position() if ( @@ -268,7 +273,6 @@ class SAXSWidget(BECWidget, QWidget): and self._positions_match(self._crosshair_pin_position, pin_position) and self._has_reference_crosshair_pin() ): - self._update_reference_crosshair_label(*pin_position) return self._place_reference_crosshair_pin(pin_position) @@ -293,6 +297,7 @@ class SAXSWidget(BECWidget, QWidget): 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) @@ -362,16 +367,7 @@ class SAXSWidget(BECWidget, QWidget): pinned_label = detached_pin.get("label") if pinned_label is None: return - samx, samy = self._map_image_point_to_position(image_x, image_y) - x_name = ( - getattr(self, "device_box", None).x_positioner if hasattr(self, "device_box") else "" - ) - y_name = ( - getattr(self, "device_box", None).y_positioner if hasattr(self, "device_box") else "" - ) - x_name = x_name or "samx" - y_name = y_name or "samy" - pinned_label.setText(f"pin\n{x_name}: {samx:.6g}\n{y_name}: {samy:.6g}") + 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: @@ -410,16 +406,6 @@ class SAXSWidget(BECWidget, QWidget): self._sync_mapping_reference_to_display() return self._mapping_reference or (0.0, 0.0) - def _map_image_point_to_position(self, image_x: float, image_y: float) -> tuple[float, float]: - correction = self._current_correction() - samx = correction.offset_x + correction.direction_x * correction.scale_x * ( - image_x - correction.reference_x - ) - samy = correction.offset_y + correction.direction_y * correction.scale_y * ( - image_y - correction.reference_y - ) - return samx, samy - def _build_table_session_bar(self) -> QWidget: bar = QFrame(self.table_group) bar.setObjectName("saxs_session_bar") @@ -474,10 +460,11 @@ class SAXSWidget(BECWidget, QWidget): 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._connect_mapping_reference_signals() 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.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) @@ -493,21 +480,6 @@ class SAXSWidget(BECWidget, QWidget): self._update_sastt_enabled(self.device_box.is_sastt_enabled()) - def _connect_mapping_reference_signals(self) -> None: - for spin_box in [ - self.mapping_box.scale_x, - self.mapping_box.offset_x, - self.mapping_box.scale_y, - self.mapping_box.offset_y, - ]: - spin_box.valueChanged.connect(self._refresh_crosshair_pin_if_enabled) - self.mapping_box.direction_x.currentIndexChanged.connect( - self._refresh_crosshair_pin_if_enabled - ) - self.mapping_box.direction_y.currentIndexChanged.connect( - self._refresh_crosshair_pin_if_enabled - ) - @SafeSlot(str) @SafeSlot() def apply_theme(self, _theme: str | None = None) -> None: @@ -671,8 +643,9 @@ class SAXSWidget(BECWidget, QWidget): ) roi.line_color = self._roi_color roi.removable = False - roi.edgesChanged.connect(self._on_roi_reference_changed) + 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: @@ -688,6 +661,28 @@ class SAXSWidget(BECWidget, QWidget): 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"): @@ -713,6 +708,88 @@ class SAXSWidget(BECWidget, QWidget): 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 + def _make_sample_id(self) -> str: return uuid4().hex @@ -731,11 +808,15 @@ class SAXSWidget(BECWidget, QWidget): 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() - geometry = corrected_geometry(raw, corr, fast_axis, fast_step, stepper_axis, stepper_step) + 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(), @@ -743,6 +824,7 @@ class SAXSWidget(BECWidget, QWidget): correction=corr, geometry=geometry, exposure_time=exposure_time, + camera_center=center, comment=comment, template=template, rotx=rotx, @@ -893,6 +975,7 @@ class SAXSWidget(BECWidget, QWidget): 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( @@ -908,6 +991,7 @@ class SAXSWidget(BECWidget, QWidget): roty=float(roty_value), raw_roi=raw_roi, correction=correction, + camera_center=camera_center, ) ) except ValueError as exc: @@ -935,6 +1019,72 @@ class SAXSWidget(BECWidget, QWidget): 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 @@ -957,6 +1107,7 @@ class SAXSWidget(BECWidget, QWidget): 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: @@ -989,7 +1140,11 @@ class SAXSWidget(BECWidget, QWidget): if self.mapping_box.is_locked(): return indices = rows if rows is not None else list(range(len(self._rows))) - correction = self._current_correction() + try: + correction = self._current_correction() + except ValueError as exc: + self._warn(str(exc)) + return for index in indices: row = self._rows[index] try: @@ -1001,6 +1156,7 @@ class SAXSWidget(BECWidget, QWidget): 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}") @@ -1090,9 +1246,17 @@ class SAXSWidget(BECWidget, QWidget): 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: @@ -1297,6 +1461,9 @@ class SAXSWidget(BECWidget, QWidget): if not isinstance(entry, dict): continue try: + schema_version = int(entry["schema_version"]) + if schema_version != 3: + raise ValueError(f"Unsupported SAXS row schema: {schema_version}") raw_roi = RawROI( x=float(entry["raw_roi_x"]), y=float(entry["raw_roi_y"]), @@ -1310,8 +1477,11 @@ class SAXSWidget(BECWidget, QWidget): scale_y=float(entry["correction_scale_y"]), offset_y=float(entry["correction_offset_y"]), direction_y=int(entry["correction_direction_y"]), - reference_x=float(entry.get("correction_reference_x", 0.0)), - reference_y=float(entry.get("correction_reference_y", 0.0)), + 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, @@ -1320,6 +1490,7 @@ class SAXSWidget(BECWidget, QWidget): 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", "")), @@ -1328,6 +1499,7 @@ class SAXSWidget(BECWidget, QWidget): 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" @@ -1358,7 +1530,7 @@ class SAXSWidget(BECWidget, QWidget): ] restored_rows.append(row) except (KeyError, TypeError, ValueError) as exc: - logger.warning("Skipping invalid stored SAXS row payload: %s", exc) + logger.warning("Skipping invalid stored SAXS row payload: {}", exc) return restored_rows def _extract_variable_message(self, entry: dict[str, Any]) -> VariableMessage | None: diff --git a/csaxs_bec/bec_widgets/widgets/saxs_widget/table.py b/csaxs_bec/bec_widgets/widgets/saxs_widget/table.py index f2243cb..4cd158b 100644 --- a/csaxs_bec/bec_widgets/widgets/saxs_widget/table.py +++ b/csaxs_bec/bec_widgets/widgets/saxs_widget/table.py @@ -49,6 +49,7 @@ class SAXSTableWidget(QWidget): store_csv_requested = Signal() add_scanning_requested = Signal() add_tensor_requested = Signal() + move_to_selected_requested = Signal(int) def __init__(self, parent=None): super().__init__(parent) @@ -143,10 +144,10 @@ class SAXSTableWidget(QWidget): 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.upload_button = QPushButton("Upload", bar) self.store_csv_button = QPushButton("Store CSV", bar) self.status_label = QLabel("Ready", bar) self.status_label.setObjectName("saxs_table_status") @@ -161,14 +162,15 @@ class SAXSTableWidget(QWidget): 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.upload_button.setToolTip( - "Upload selected rows, or the full table if none are selected." - ) self.store_csv_button.setToolTip( "Export selected rows or the full table into CSV and save the active session state." ) @@ -176,29 +178,29 @@ class SAXSTableWidget(QWidget): 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.upload_button.setIcon(material_icon("upload", 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.upload_button.clicked.connect(self.upload_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.upload_button, self.store_csv_button, ]: button.setProperty("saxsButtonRole", "toolbar") @@ -213,13 +215,14 @@ class SAXSTableWidget(QWidget): 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.upload_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) @@ -231,12 +234,12 @@ class SAXSTableWidget(QWidget): 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.upload_button) layout.addWidget(self.store_csv_button) layout.addWidget(self.status_label) return bar @@ -254,6 +257,11 @@ class SAXSTableWidget(QWidget): 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) @@ -266,10 +274,10 @@ class SAXSTableWidget(QWidget): 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.upload_button.setEnabled(row_count > 0) self.store_csv_button.setEnabled(row_count > 0) @staticmethod