apply fixes
CI for csaxs_bec / test (pull_request) Failing after 1m26s
CI for csaxs_bec / test (push) Failing after 1m54s

This commit is contained in:
2026-07-09 19:02:26 +02:00
parent dff7e67ead
commit abed4f544c
6 changed files with 482 additions and 86 deletions
@@ -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":
@@ -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)
@@ -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,
)
@@ -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
)
@@ -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:
@@ -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