diff --git a/pyproject.toml b/pyproject.toml
index 0da2787e..428bf705 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,9 @@ requires-python = ">=3.11"
dependencies = [
"uv",
"gunicorn",
- "aarecommon>=0.5.1",
+ # >=0.7: DataCollectionParameters.transmission is a 0-1 fraction, which
+ # the scan panels rely on (older releases held an int percentage).
+ "aarecommon>=0.7.3",
"pydantic>=2.11",
"numpy",
"jfjoch_client>=1.0.0rc165",
diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py
index 5a3187e8..1094658f 100644
--- a/src/aare/gui/main_window.py
+++ b/src/aare/gui/main_window.py
@@ -597,21 +597,27 @@ class MainWindow(QMainWindow):
self.quick_unmount_button = QPushButton("⏏ Unmount", dewar_tab)
self.quick_unmount_button.clicked.connect(lambda: self._on_manual_unmount_requested())
- # main_window.py, next to quick_unmount_button (~L597)
+ # Was parented to dewar_tab but never added to a layout, so Qt
+ # painted it at (0, 0) — on top of the TELL banner. It lives in the
+ # automation row, left of Unmount, like the pop-out clone below.
self.mount_next_button = QPushButton("⏭ Mount next", dewar_tab)
self.mount_next_button.clicked.connect(self._mount_next_from_queue)
- # add to automation_row loop + self._queue_action_buttons as kind "next"
automation_row = QHBoxLayout()
for w in (
self.job_list_panel.play_button,
self.job_list_panel.remove_button,
self.job_list_panel.clear_button,
+ self.mount_next_button,
self.quick_unmount_button,
self.job_list_panel.park_and_dry_when_cleared,
self.job_list_panel.pause_on_conditions_cb,
):
automation_row.addWidget(w)
+ # Trailing stretch: without it every widget shares the spare width
+ # and the buttons balloon; five buttons plus two long checkboxes need
+ # them at their natural size.
+ automation_row.addStretch(1)
dewar_layout.addLayout(automation_row)
# "Remove selected" now unqueues the dewar-table selection — the
@@ -630,6 +636,7 @@ class MainWindow(QMainWindow):
(self.job_list_panel.play_button, "run"),
(self.job_list_panel.remove_button, "queue"),
(self.job_list_panel.clear_button, "queue"),
+ (self.mount_next_button, "queue"),
]
self.tell_samples.status_chips.buttonClicked.connect(
lambda _chip: self._update_queue_buttons_enabled()
@@ -1195,6 +1202,8 @@ class MainWindow(QMainWindow):
self.daq.update.connect(self.sample_camera.update_daq_status)
self.daq.update.connect(self.compact_sample_camera.update_daq_status)
self.daq.update.connect(self.portrait_sample_camera.update_daq_status)
+ for camera in (self.sample_camera, self.compact_sample_camera, self.portrait_sample_camera):
+ self.daq.auto_centering.connect(camera.set_auto_centering)
self.daq.update.connect(self.tell_samples.update_daq_status)
self.daq.update.connect(self.ref_tools_panel.update_daq_status)
if self.prediction_thread is not None:
@@ -1462,19 +1471,22 @@ class MainWindow(QMainWindow):
remove_button.clicked.connect(lambda: self._unqueue_panel_selection(dewar_panel))
clear_button = QPushButton("✖ Clear list")
clear_button.clicked.connect(jl.clear)
+ mount_next_button = QPushButton("⏭ Mount next")
+ mount_next_button.clicked.connect(self._mount_next_from_queue)
unmount_button = QPushButton("⏏ Unmount")
unmount_button.clicked.connect(lambda: self._on_manual_unmount_requested())
- # Same Queued-view gating as the docked trio.
+ # Same Queued-view gating as the docked buttons.
self._queue_action_buttons += [
(run_button, "run"),
(remove_button, "queue"),
(clear_button, "queue"),
+ (mount_next_button, "queue"),
]
self._update_queue_buttons_enabled()
row = QHBoxLayout()
- for button in (run_button, remove_button, clear_button, unmount_button):
+ for button in (run_button, remove_button, clear_button, mount_next_button, unmount_button):
row.addWidget(button)
for source in (jl.park_and_dry_when_cleared, jl.pause_on_conditions_cb):
clone = QCheckBox(source.text())
@@ -1485,6 +1497,7 @@ class MainWindow(QMainWindow):
clone.toggled.connect(source.setChecked)
source.toggled.connect(clone.setChecked)
row.addWidget(clone)
+ row.addStretch(1) # natural button widths, see the docked row
return row
def _show_admin_tip(self, message: str) -> None:
diff --git a/src/aare/gui/panels/raster_data_collection.py b/src/aare/gui/panels/raster_data_collection.py
index 57f18ea9..bbbe74b4 100644
--- a/src/aare/gui/panels/raster_data_collection.py
+++ b/src/aare/gui/panels/raster_data_collection.py
@@ -1,6 +1,6 @@
from aarecommon.config.logger import setup_logger
from aarecommon.math.diffraction_geometry import DiffractionGeometry
-from aarecommon.models.models import BeamlineStateEnum, DAQStatusModel
+from aarecommon.models.models import BeamlineStateEnum
from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtWidgets import QComboBox, QLabel, QMessageBox, QPushButton, QSlider
@@ -8,7 +8,7 @@ from aare.gui.constants import LOGGER_NAME
from aare.gui.panels.scan_settings_panel import ScanSettingsPanel
from aare.gui.scan_logic.raster_grid_manager import RasterGridManager, RasterGridMetric
from aare.gui.styles import ABORT_TEXT, GO_TEXT
-from aare.gui.widgets.number_line_edit import DbOverrideLineEdit
+from aare.gui.widgets.number_line_edit import NumberLineEdit
from aare.gui.widgets.raster_grid_table import RasterGridTable
logger = setup_logger(LOGGER_NAME)
@@ -33,69 +33,45 @@ class RasterDataCollectionPanel(ScanSettingsPanel):
parent=parent,
)
- self._previous_sample_was_none_raster = True
+ self._default_exp_time = raster_mgr.active_grid.exp_time_s
+ # Image time typed in by the user; None means "follow the sample".
+ self._user_exp_time: float | None = None
self._n_x = raster_mgr.active_grid.n_x
self._n_y = raster_mgr.active_grid.n_y
self._size_x = raster_mgr.active_grid.grid_size_mm.x * 1000.0
self._size_y = raster_mgr.active_grid.grid_size_mm.y * 1000.0
- self._total_time = raster_mgr.active_grid.exp_time_s * self._n_x * self._n_y
+ self._total_time = self._default_exp_time * self._n_x * self._n_y
- self._layout.addWidget(QLabel("Grid element size", parent=self), 3, 0)
-
- self.width_enter = DbOverrideLineEdit(5, 100, default=self._size_x, decimals=0, parent=self)
- self.width_enter.valueChanged.connect(self.grid_size)
- self._register_override_field(self.width_enter)
- self._layout.addWidget(self.width_enter, 3, 1)
-
- self._layout.addWidget(QLabel(" x ", parent=self), 3, 2)
-
- self.height_enter = DbOverrideLineEdit(
- 5, 100, default=self._size_y, decimals=0, parent=self
+ # Grid element size: the drawn grid decides it, the user can ask for a
+ # different one. It has no spreadsheet counterpart, so it stays outside
+ # the Database/User toggle.
+ self.width_enter = NumberLineEdit(
+ 5, 100, self._size_x, decimals=0, parent=self, track_pending=True
)
- self.height_enter.valueChanged.connect(self.grid_size)
- self._register_override_field(self.height_enter)
-
- self._layout.addWidget(self.height_enter, 3, 3)
-
- self._layout.addWidget(QLabel("μm", parent=self), 3, 4)
-
- self._layout.addWidget(QLabel("Image time", parent=self), 4, 0)
- self.image_time_enter = DbOverrideLineEdit(
- 0.0005, 10.0, default=raster_mgr.active_grid.exp_time_s, decimals=4, parent=self
+ self.height_enter = NumberLineEdit(
+ 5, 100, self._size_y, decimals=0, parent=self, track_pending=True
)
- self._layout.addWidget(self.image_time_enter, 4, 1, 1, 3)
- self._layout.addWidget(QLabel("s", parent=self), 4, 4)
- self.image_time_enter.valueChanged.connect(self.exp_time_s)
- self._register_override_field(self.image_time_enter)
+ self._add_pair_row(3, "Grid element size", self.width_enter, self.height_enter, "μm")
+ for field in (self.width_enter, self.height_enter):
+ field.newValue.connect(self._on_element_size_committed)
+ self._add_field(field)
- self._layout.addWidget(QLabel("Grid elements", parent=self), 5, 0)
+ self.image_time_enter = NumberLineEdit(
+ 0.0005, 10.0, self._default_exp_time, decimals=4, parent=self, track_pending=True
+ )
+ self._add_row(4, "Image time", self.image_time_enter, "s")
+ self._add_database_field(self.image_time_enter, self._on_exp_time_committed)
self.n_x_label = QLabel("-", parent=self)
- self.n_x_label.setAlignment(Qt.AlignmentFlag.AlignRight)
- self._layout.addWidget(self.n_x_label, 5, 1)
- self._layout.addWidget(QLabel(" x ", parent=self), 5, 2)
-
self.n_y_label = QLabel("-", parent=self)
- self.n_y_label.setAlignment(Qt.AlignmentFlag.AlignRight)
- self._layout.addWidget(self.n_y_label, 5, 3)
-
- self._layout.addWidget(QLabel("Grid size", parent=self), 6, 0)
+ self._add_pair_row(5, "Grid elements", self.n_x_label, self.n_y_label)
self.size_x_label = QLabel("-", parent=self)
- self.size_x_label.setAlignment(Qt.AlignmentFlag.AlignRight)
- self._layout.addWidget(self.size_x_label, 6, 1)
- self._layout.addWidget(QLabel(" x ", parent=self), 6, 2)
-
self.size_y_label = QLabel("-", parent=self)
- self.size_y_label.setAlignment(Qt.AlignmentFlag.AlignRight)
- self._layout.addWidget(self.size_y_label, 6, 3)
-
- self._layout.addWidget(QLabel("μm", parent=self), 6, 4)
+ self._add_pair_row(6, "Grid size", self.size_x_label, self.size_y_label, "μm")
self.metric_combo = QComboBox()
- self._layout.addWidget(QLabel("Metric", parent=self), 7, 0)
-
self.metric_combo.addItem("Raster score", RasterGridMetric.RASTER_SCORE)
self.metric_combo.addItem("Spot count (low res.)", RasterGridMetric.SPOTS_LOW_RES)
self.metric_combo.addItem("Spot count", RasterGridMetric.SPOTS)
@@ -110,25 +86,19 @@ class RasterDataCollectionPanel(ScanSettingsPanel):
self.metric_combo.setCurrentIndex(self.metric_combo.findData(RasterGridMetric.RASTER_SCORE))
self.metric_combo.currentIndexChanged.connect(self.metric_changed)
+ self._add_row(7, "Metric", self.metric_combo)
- self._layout.addWidget(self.metric_combo, 7, 1, 1, 3)
-
- self._layout.addWidget(QLabel("Transparency", parent=self), 8, 0)
slider = QSlider(orientation=Qt.Orientation.Horizontal, parent=self)
slider.setRange(0, 255)
slider.setValue(127)
slider.valueChanged.connect(lambda: self.raster_alpha_changed.emit(255 - slider.value()))
- self._layout.addWidget(slider, 8, 1, 1, 3)
+ self._add_row(8, "Transparency", slider)
self._table = RasterGridTable(raster_mgr)
self._layout.addWidget(self._table, 9, 0, 1, 5)
- self._layout.addWidget(QLabel("Measurement time", parent=self), 11, 0)
self.total_time = QLabel(f"{self._total_time} min 0 s")
- self.total_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
- self._layout.addWidget(self.total_time, 11, 1, 1, 3)
-
- self.calculate_total_time()
+ self._add_row(11, "Measurement time", self.total_time)
self.start_button = QPushButton("Evaluate grid")
self.start_button.setStyleSheet(f"color: {GO_TEXT};")
@@ -144,75 +114,68 @@ class RasterDataCollectionPanel(ScanSettingsPanel):
self.abort_button = QPushButton("Abort measurement")
self.abort_button.setStyleSheet(f"color: {ABORT_TEXT};")
self._layout.addWidget(self.abort_button, 14, 0, 1, 5)
- self._reset_to_defaults()
+
+ # every field exists now: fill in the measurement time and the grid
+ self._values_changed()
self.update_grid_scan_size()
+ # -- the panel's settings ----------------------------------------------
@Slot(float)
- def grid_size(self, _: float):
- self._size_x = self.width_enter.value
- self._size_y = self.height_enter.value
+ def _on_exp_time_committed(self, value: float):
+ self._user_exp_time = value
+ self._user_took_over()
+
+ def _write_active_values(self):
+ super()._write_active_values()
+ self.image_time_enter.set_committed_value(
+ self._setting(self._user_exp_time, self._db_params.exp_time_s, self._default_exp_time)
+ )
+
+ def _values_changed(self):
+ super()._values_changed()
+ self.exp_time_updated.emit(self.image_time_enter.committed_value)
+ self.calculate_total_time()
+
+ # -- grid geometry ------------------------------------------------------
+ @Slot(float)
+ def _on_element_size_committed(self, _value: float):
+ """The user asked for a different element size: the grid manager
+ recomputes how many elements fit and reports back to
+ grid_scan_size_change, which writes the size it settled on."""
+ self._size_x = self.width_enter.committed_value
+ self._size_y = self.height_enter.committed_value
self.grid_size_updated.emit(self._size_x / 1000.0, self._size_y / 1000.0)
self.update_grid_scan_size()
- @Slot(float)
- def exp_time_s(self, inp: float):
- self.exp_time_updated.emit(inp)
- self.calculate_total_time()
-
@Slot(int, int, float, float)
def grid_scan_size_change(self, n_x: int, n_y: int, size_x_mm: float, size_y_mm: float):
+ """The drawn grid changed (resized, or the beam size moved the default
+ element size)."""
self._size_x = size_x_mm * 1000.0
self._size_y = size_y_mm * 1000.0
self._n_x = n_x
self._n_y = n_y
- self.calculate_total_time()
self.update_grid_scan_size()
- @Slot(DAQStatusModel)
- def update_daq_status(self, s: DAQStatusModel):
- super().update_daq_status(s)
- can_edit = getattr(self, "_can_edit_params", False)
- for w in (self.width_enter, self.height_enter, self.image_time_enter):
- if hasattr(w, "set_busy"):
- w.set_busy(not can_edit)
- else:
- w.setReadOnly(not can_edit)
-
- if s.sample is None and not self._previous_sample_was_none_raster:
- # The override fields (grid size, image time) are reset by the base
- # panel (_reset_to_defaults); nothing extra to reset here.
- self._previous_sample_was_none_raster = True
- elif s.sample is not None:
- self._previous_sample_was_none_raster = False
- self._beamline_state = s.state
-
def update_grid_scan_size(self):
- # The live grid geometry is the "database"/system value for the element
- # size; a user override persists and can be reverted via the toggle.
- self.width_enter.set_db_value(self._size_x)
- self.height_enter.set_db_value(self._size_y)
+ self.width_enter.set_committed_value(self._size_x)
+ self.height_enter.set_committed_value(self._size_y)
self.n_x_label.setText(str(self._n_x))
self.size_x_label.setText(f"{self._size_x * self._n_x:.1f}")
self.n_y_label.setText(str(self._n_y))
self.size_y_label.setText(f"{self._size_y * self._n_y:.1f}")
- self.update_total_time_label()
- try:
- self.exp_time_s(self.image_time_enter.value)
- except ValueError as e:
- logger.warning(f"Invalid exposure time: {e} reseting to default")
- self.exp_time_s(0.02)
+ self.calculate_total_time()
def metric_changed(self, _: int):
self.grid_metric_updated.emit(self.metric_combo.currentData())
- def get_parameter_mappings(self):
- """Return raster-specific parameter mappings."""
- return [
- ("exposure", self.image_time_enter, None)
- # Add other raster-specific parameters here as needed
- ]
-
- def update_total_time_label(self):
+ def calculate_total_time(self):
+ exp_time_s = self.image_time_enter.committed_value
+ if self._n_x <= 0 or self._n_y <= 0 or exp_time_s < 0:
+ self._total_time = 0.0
+ else:
+ # 30% buffer added
+ self._total_time = self._n_x * self._n_y * exp_time_s * 1.3
mins = int(self._total_time // 60)
secs = round(self._total_time % 60)
if secs == 60:
@@ -220,29 +183,20 @@ class RasterDataCollectionPanel(ScanSettingsPanel):
secs = 0
self.total_time.setText(f"{mins} min {secs} s")
- def calculate_total_time(self):
- if self._n_x <= 0 or self._n_y <= 0 or self.image_time_enter.value < 0:
- self._total_time = 0.0
- else:
- self._total_time = self._n_x * self._n_y * self.image_time_enter.value * 1.3
- # 30% buffer added
- # Show minutes
- self.update_total_time_label()
-
- @Slot()
- def _on_evaluate_clicked(self):
+ # -- running ------------------------------------------------------------
+ def _ready_to_run(self) -> bool:
if self._beamline_state != BeamlineStateEnum.SampleAlignment:
logger.error(f"Beamline state {self._beamline_state} is not Sample Alignment")
QMessageBox.critical(None, "Error", "Beamline state is not Sample Alignment")
- return
- if self.check_before_run(scan_kind="raster"):
+ return False
+ return self.check_before_run(scan_kind="raster")
+
+ @Slot()
+ def _on_evaluate_clicked(self):
+ if self._ready_to_run():
self.evaluate_grid.emit()
@Slot()
def _on_evaluate_auto_clicked(self):
- if self._beamline_state != BeamlineStateEnum.SampleAlignment:
- logger.error(f"Beamline state {self._beamline_state} is not Sample Alignment")
- QMessageBox.critical(None, "Error", "Beamline state is not Sample Alignment")
- return
- if self.check_before_run(scan_kind="raster"):
+ if self._ready_to_run():
self.evaluate_grid_auto.emit()
diff --git a/src/aare/gui/panels/rotation_data_collection.py b/src/aare/gui/panels/rotation_data_collection.py
index 3899087a..8c7ca3b2 100644
--- a/src/aare/gui/panels/rotation_data_collection.py
+++ b/src/aare/gui/panels/rotation_data_collection.py
@@ -6,13 +6,13 @@ from aarecommon.math.diffraction_geometry import DiffractionGeometry
from aarecommon.models.beamline import MXBeamline
from aarecommon.models.models import BeamlineStateEnum, DAQStatusModel
from aarecommon.models.rotation_scan import RotationScanRequest
-from PySide6.QtCore import Qt, Signal, Slot
+from PySide6.QtCore import Signal, Slot
from PySide6.QtWidgets import QComboBox, QLabel, QMessageBox, QPushButton
from aare.gui.constants import LOGGER_NAME
from aare.gui.panels.scan_settings_panel import ScanSettingsPanel
from aare.gui.styles import ABORT_TEXT, GO_TEXT
-from aare.gui.widgets.number_line_edit import DbOverrideLineEdit, NumberLineEdit
+from aare.gui.widgets.number_line_edit import NumberLineEdit
logger = setup_logger(LOGGER_NAME)
@@ -21,6 +21,15 @@ MAX_OMEGA_SPEED_DEG_S = 500.0
MIN_EXP_TIME_S = {MXBeamline.X06DA: 1 / 900, MXBeamline.X10SA: 1 / 120}
+def omega_speed_deg_s(image_angle_deg: float, exp_time_s: float) -> float:
+ """How fast omega has to turn to cover one image in one exposure. The
+ goniometer caps this, and it is a limit on the pair - neither number is
+ wrong on its own - so no single field's validator can express it."""
+ if exp_time_s <= 0:
+ return float("inf")
+ return image_angle_deg / exp_time_s
+
+
def add_screening_to_path(path):
p = Path(path)
return "screening" / p
@@ -54,26 +63,28 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
),
)
- self._beamline_state = None
- self._curr_pgroup = "p11206"
self._filename = ""
-
self._omega = 0
self._dose_mgy = 0
self._total_time = 0.0
self._min_exp_time_s = MIN_EXP_TIME_S.get(mx_beamline(), 0.0005)
- self._layout.addWidget(QLabel("Start angle", parent=self), 3, 0)
- self.start_angle = NumberLineEdit(
- -720, 720.0, 0.0, decimals=3, parent=self, track_pending=True
- )
- self._layout.addWidget(self.start_angle, 3, 1, 1, 2)
- self._layout.addWidget(QLabel("°", parent=self), 3, 3)
+ # Settings the user typed in; None means "follow the mounted sample".
+ # The screening fields are not here: they have no spreadsheet
+ # counterpart, so they always keep whatever the user left in them.
+ self._user_total_angle: float | None = None
+ self._user_image_angle: float | None = None
+ self._user_exp_time: float | None = None
self.omega_button = QPushButton("Ω")
self.omega_button.setFixedWidth(30)
self.omega_button.clicked.connect(self.update_omega_start)
- self._layout.addWidget(self.omega_button, 3, 4)
+
+ self.start_angle = NumberLineEdit(
+ -720, 720.0, 0.0, decimals=3, parent=self, track_pending=True
+ )
+ self._add_row(3, "Start angle", self.start_angle, "°", trailing=self.omega_button)
+ self._add_field(self.start_angle)
self._layout.addWidget(
QLabel("
Screening", parent=self), 4, 0, 1, 6
@@ -91,14 +102,12 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
self._layout.addWidget(self.screening_type, 5, 0, 1, 6)
- self._layout.addWidget(QLabel("Image angle", parent=self), 6, 0)
self.screening_image_angle = NumberLineEdit(
0, 90.0, 0.5, decimals=3, parent=self, track_pending=True
)
- self._layout.addWidget(self.screening_image_angle, 6, 1, 1, 3)
- self._layout.addWidget(QLabel("°", parent=self), 6, 4)
+ self._add_row(6, "Image angle", self.screening_image_angle, "°")
+ self._add_field(self.screening_image_angle)
- self._layout.addWidget(QLabel("Image time", parent=self), 7, 0)
default_screening_exp_time = cfg_get(
"daq.data_collection_settings.default_screening_settings.exp_time_s", 0.1
)
@@ -110,15 +119,14 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
parent=self,
track_pending=True,
)
- self._layout.addWidget(self.screening_image_time_enter, 7, 1, 1, 3)
- self._layout.addWidget(QLabel("s", parent=self), 7, 4)
+ self._add_row(7, "Image time", self.screening_image_time_enter, "s")
+ self._add_field(self.screening_image_time_enter)
- self._layout.addWidget(QLabel("Screening transmission", parent=self), 8, 0)
- self.screening_transmission_enter = DbOverrideLineEdit(
- 0, 1.0, default=self._transmission, decimals=4, parent=self
+ self.screening_transmission_enter = NumberLineEdit(
+ 0, 1.0, self._default_transmission, decimals=4, parent=self, track_pending=True
)
- self._layout.addWidget(self.screening_transmission_enter, 8, 1, 1, 3)
- self.screening_transmission_enter.valueChanged.connect(self.set_screening_transmission)
+ self._add_row(8, "Screening transmission", self.screening_transmission_enter)
+ self._add_field(self.screening_transmission_enter)
self.screening_button = QPushButton("Run screening")
self.screening_button.setStyleSheet(f"color: {GO_TEXT};")
@@ -129,55 +137,48 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
QLabel("Rotation", parent=self), 10, 0, 1, 6
)
- self._layout.addWidget(QLabel("Total angle", parent=self), 11, 0)
default_steps = cfg_get(
"daq.data_collection_settings.default_rotation_settings.steps", 1800
)
- default_increment_omega = cfg_get(
+ self._default_image_angle = cfg_get(
"daq.data_collection_settings.default_rotation_settings.increment_omega_deg", 0.2
)
- default_total_angle = default_steps * default_increment_omega
- self.total_angle = DbOverrideLineEdit(
- 0, 9999.0, default=default_total_angle, decimals=3, parent=self
- )
- self._layout.addWidget(self.total_angle, 11, 1, 1, 3)
- self._layout.addWidget(QLabel("°", parent=self), 10, 4)
- self._register_override_field(self.total_angle)
-
- self._layout.addWidget(QLabel("Image angle", parent=self), 12, 0)
- self.image_angle = DbOverrideLineEdit(
- 0, 10.0, default=default_increment_omega, decimals=3, parent=self
- )
- self._layout.addWidget(self.image_angle, 12, 1, 1, 3)
- self._layout.addWidget(QLabel("°", parent=self), 12, 4)
- self._register_override_field(self.image_angle)
- self._layout.addWidget(QLabel("Image time", parent=self), 13, 0)
- default_image_exp_time = cfg_get(
+ self._default_total_angle = default_steps * self._default_image_angle
+ self._default_exp_time = cfg_get(
"daq.data_collection_settings.default_rotation_settings.exp_time_s", 0.01
)
- self.image_time_enter = DbOverrideLineEdit(
- self._min_exp_time_s, 10.0, default=default_image_exp_time, decimals=4, parent=self
+
+ self.total_angle = NumberLineEdit(
+ 0, 9999.0, self._default_total_angle, decimals=3, parent=self, track_pending=True
)
- self._layout.addWidget(self.image_time_enter, 13, 1, 1, 3)
- self._layout.addWidget(QLabel("s", parent=self), 13, 4)
- self._register_override_field(self.image_time_enter)
+ self._add_row(11, "Total angle", self.total_angle, "°")
+ self._add_database_field(self.total_angle, self._on_total_angle_committed)
+
+ self.image_angle = NumberLineEdit(
+ 0, 10.0, self._default_image_angle, decimals=3, parent=self, track_pending=True
+ )
+ self._add_row(12, "Image angle", self.image_angle, "°")
+ self._add_database_field(self.image_angle, self._on_image_angle_committed)
+
+ self.image_time_enter = NumberLineEdit(
+ self._min_exp_time_s,
+ 10.0,
+ self._default_exp_time,
+ decimals=4,
+ parent=self,
+ track_pending=True,
+ )
+ self._add_row(13, "Image time", self.image_time_enter, "s")
+ self._add_database_field(self.image_time_enter, self._on_exp_time_committed)
+
+ self.omega_speed = QLabel("-")
+ self._add_row(14, "Rotation speed", self.omega_speed, "°/s")
- self._layout.addWidget(QLabel("Total measurement time", parent=self), 15, 0)
self.total_time = QLabel(f"{self._total_time} min 0 s")
- self.total_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
- self._layout.addWidget(self.total_time, 15, 1, 1, 3)
+ self._add_row(15, "Total measurement time", self.total_time)
- self.total_angle.valueChanged.connect(self.calculate_measurement_time)
- self.image_angle.valueChanged.connect(self.calculate_measurement_time)
- self.image_time_enter.valueChanged.connect(self.calculate_measurement_time)
- # Initial compute
- self.calculate_measurement_time()
-
- self._layout.addWidget(QLabel("Dose", parent=self), 16, 0)
self.dose = QLabel(f"{self._dose_mgy}")
- self.dose.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
- self._layout.addWidget(self.dose, 16, 1, 1, 3)
- self._layout.addWidget(QLabel("MGy", parent=self), 16, 4)
+ self._add_row(16, "Dose", self.dose, "MGy")
self.measurement_button = QPushButton("Run rotation")
self.measurement_button.setStyleSheet(f"color: {GO_TEXT};")
@@ -188,37 +189,92 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
self.abort_button = QPushButton("Abort measurement")
self.abort_button.setStyleSheet(f"color: {ABORT_TEXT};")
self._layout.addWidget(self.abort_button, 18, 0, 1, 6)
- self._reset_to_defaults()
- # Speed cap couples each angle/time pair: recompute limits whenever either commits.
- self.screening_image_angle.newValue.connect(self._update_speed_limits)
- self.screening_image_time_enter.newValue.connect(self._update_speed_limits)
- self.image_angle.valueChanged.connect(self._update_speed_limits)
- self.image_time_enter.valueChanged.connect(self._update_speed_limits)
- self._update_speed_limits()
+ # every field exists now: fill in the speed and measurement time
+ self._values_changed()
- @Slot()
- def _update_speed_limits(self):
- for angle, time, angle_max in (
- (self.screening_image_angle, self.screening_image_time_enter, 90.0),
- (self.image_angle, self.image_time_enter, 10.0),
- ):
- angle.update_limits(0, min(angle_max, MAX_OMEGA_SPEED_DEG_S * time.value))
- time.update_limits(max(self._min_exp_time_s, angle.value / MAX_OMEGA_SPEED_DEG_S), 10.0)
+ # -- the panel's settings ----------------------------------------------
+ @Slot(float)
+ def _on_total_angle_committed(self, value: float):
+ self._user_total_angle = value
+ self._user_took_over()
- def _fields_out_of_range(self, *fields) -> bool:
- """True (plus error box) when a field sits outside its current limits.
- Needed because QDoubleValidator only paints the field red; ``.value``
- still reads the raw text, so without this gate the request goes out."""
- if all(getattr(f, "editor", f).hasAcceptableInput() for f in fields):
- return False
- msg = (
- "Image angle / Image time outside limits "
- f"(max {MAX_OMEGA_SPEED_DEG_S:.0f} °/s, min image time {self._min_exp_time_s:.4f} s)"
+ @Slot(float)
+ def _on_image_angle_committed(self, value: float):
+ self._user_image_angle = value
+ self._user_took_over()
+
+ @Slot(float)
+ def _on_exp_time_committed(self, value: float):
+ self._user_exp_time = value
+ self._user_took_over()
+
+ def _write_active_values(self):
+ super()._write_active_values()
+ db = self._db_params
+ self.total_angle.set_committed_value(
+ self._setting(self._user_total_angle, db.total_angle_deg, self._default_total_angle)
)
+ self.image_angle.set_committed_value(
+ self._setting(self._user_image_angle, db.image_angle_deg, self._default_image_angle)
+ )
+ self.image_time_enter.set_committed_value(
+ self._setting(self._user_exp_time, db.exp_time_s, self._default_exp_time)
+ )
+
+ def _values_changed(self):
+ super()._values_changed()
+ self._update_speed_readout()
+ self._update_measurement_time()
+
+ # -- derived read-outs --------------------------------------------------
+ def _update_speed_readout(self):
+ speed = omega_speed_deg_s(
+ self.image_angle.committed_value, self.image_time_enter.committed_value
+ )
+ self.omega_speed.setText("-" if speed == float("inf") else f"{speed:.1f}")
+ too_fast = speed > MAX_OMEGA_SPEED_DEG_S
+ # A red read-out rather than a red field: the cap is on the pair.
+ self.omega_speed.setStyleSheet(f"color: {ABORT_TEXT};" if too_fast else "")
+
+ def image_number(self) -> int:
+ total_angle = self.total_angle.committed_value
+ image_angle = self.image_angle.committed_value
+ if total_angle <= 0 or total_angle < image_angle or image_angle <= 0:
+ logger.warning(f"Cannot count images for {total_angle}° in steps of {image_angle}°")
+ return 0
+ return round(total_angle / image_angle)
+
+ def _update_measurement_time(self):
+ self._total_time = self.image_number() * self.image_time_enter.committed_value
+ mins = int(self._total_time // 60)
+ secs = round(self._total_time % 60)
+ if secs == 60:
+ mins += 1
+ secs = 0
+ self.total_time.setText(f"{mins} min {secs} s")
+
+ # -- running ------------------------------------------------------------
+ def _pair_is_runnable(self, angle: NumberLineEdit, time: NumberLineEdit) -> bool:
+ """Both halves of an image angle / image time pair have to be inside
+ their own limits, and together they have to stay under the omega speed
+ cap. QDoubleValidator only paints a field red, so a run has to ask."""
+ speed = omega_speed_deg_s(angle.committed_value, time.committed_value)
+ if not (angle.hasAcceptableInput() and time.hasAcceptableInput()):
+ msg = (
+ "Image angle / image time outside limits "
+ f"(min image time {self._min_exp_time_s:.4f} s)"
+ )
+ elif speed > MAX_OMEGA_SPEED_DEG_S:
+ msg = (
+ f"{angle.committed_value:.3f}° in {time.committed_value:.4f} s needs "
+ f"{speed:.0f} °/s, above the {MAX_OMEGA_SPEED_DEG_S:.0f} °/s limit"
+ )
+ else:
+ return True
logger.error(msg)
QMessageBox.critical(self, "Error", msg)
- return True
+ return False
@Slot()
def run_screening(self):
@@ -226,7 +282,7 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
logger.error(f"Beamline state {self._beamline_state} is not Sample Alignment")
QMessageBox.critical(None, "Error", "Beamline state is not Sample Alignment")
return
- if self._fields_out_of_range(self.screening_image_angle, self.screening_image_time_enter):
+ if not self._pair_is_runnable(self.screening_image_angle, self.screening_image_time_enter):
return
if not self.check_before_run(scan_kind="screening"):
logger.error("Cannot run measurement because of check")
@@ -240,26 +296,26 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
if is_single_image:
r = RotationScanRequest(
file_prefix=str(add_screening_to_path(self._filename)),
- start_omega_deg=self.start_angle.value,
- incr_omega_deg=self.screening_image_angle.value,
+ start_omega_deg=self.start_angle.committed_value,
+ incr_omega_deg=self.screening_image_angle.committed_value,
wedge_omega_deg=screening_settings["omega_step_deg"],
steps=1,
- dtz=self._dtz,
- transmission=self._screening_transmission,
+ dtz=self.dtz_enter.committed_value,
+ transmission=self.screening_transmission_enter.committed_value,
screening=True,
- exp_time_s=self.screening_image_time_enter.value,
+ exp_time_s=self.screening_image_time_enter.committed_value,
)
else:
r = RotationScanRequest(
file_prefix=str(add_screening_to_path(self._filename)),
- start_omega_deg=self.start_angle.value,
- wedge_omega_deg=self.screening_image_angle.value,
+ start_omega_deg=self.start_angle.committed_value,
+ wedge_omega_deg=self.screening_image_angle.committed_value,
incr_omega_deg=screening_settings["omega_step_deg"],
steps=screening_settings["steps"],
- dtz=self._dtz,
- transmission=self._screening_transmission,
+ dtz=self.dtz_enter.committed_value,
+ transmission=self.screening_transmission_enter.committed_value,
screening=True,
- exp_time_s=self.screening_image_time_enter.value,
+ exp_time_s=self.screening_image_time_enter.committed_value,
)
self.rotation_scan.emit(r)
@@ -271,58 +327,25 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
logger.error(f"Beamline state {self._beamline_state} is not Sample Alignment")
QMessageBox.critical(None, "Error", "Beamline state is not Sample Alignment")
return
- if self._fields_out_of_range(self.image_angle, self.image_time_enter):
+ if not self._pair_is_runnable(self.image_angle, self.image_time_enter):
return
if not self.check_before_run(scan_kind="rotation"):
logger.error("Cannot run measurement because of check")
return
r = RotationScanRequest(
file_prefix=str(add_data_to_path(self._filename)),
- start_omega_deg=self.start_angle.value,
+ start_omega_deg=self.start_angle.committed_value,
steps=self.image_number(),
- incr_omega_deg=self.image_angle.value,
- dtz=self._dtz,
- transmission=self._transmission,
+ incr_omega_deg=self.image_angle.committed_value,
+ dtz=self.dtz_enter.committed_value,
+ transmission=self.transmission_enter.committed_value,
screening=False,
- exp_time_s=self.image_time_enter.value,
+ exp_time_s=self.image_time_enter.committed_value,
)
self.rotation_scan.emit(r)
self.viewer_track_online.emit()
- def image_number(self):
- total_angle = self.total_angle.value
- image_angle = self.image_angle.value
- if total_angle <= 0 or total_angle < image_angle:
- print("Total angle is not valid.")
- return 0
- elif image_angle <= 0:
- print("Image angle is not valid.")
- return 0
-
- return round(total_angle / image_angle)
-
- def update_total_time_label(self):
- mins = int(self._total_time // 60)
- secs = round(self._total_time % 60)
- if secs == 60:
- mins += 1
- secs = 0
- self.total_time.setText(f"{mins} min {secs} s")
-
- def calculate_measurement_time(self):
- if (
- self.image_angle.value <= 0
- or self.total_angle.value <= 0
- or self.image_time_enter.value < 0
- ):
- self._total_time = 0.0
- self.update_total_time_label()
- return
- self._total_time = (
- self.total_angle.value / self.image_angle.value
- ) * self.image_time_enter.value
- self.update_total_time_label()
-
+ # -- status -------------------------------------------------------------
@Slot(str)
def update_filename(self, filename: str):
self._filename = filename
@@ -332,41 +355,14 @@ class RotationDataCollectionPanel(ScanSettingsPanel):
super().update_daq_status(s)
self._omega = s.geom.omega_deg
- can_edit = getattr(self, "_can_edit_params", False)
- for w in (
- self.start_angle,
- self.screening_image_angle,
- self.screening_image_time_enter,
- self.total_angle,
- self.image_angle,
- self.image_time_enter,
- ):
- if hasattr(w, "set_busy"):
- w.set_busy(not can_edit)
- else:
- w.setReadOnly(not can_edit)
-
- self.calculate_measurement_time()
- if s.session.current_pgroup:
- self._curr_pgroup = s.session.current_pgroup
lambda_a = s.diffraction.wavelength_angstrom
kdose = 2000 / (lambda_a * lambda_a)
beam_area = s.geom.beam_size_mm.x * s.geom.beam_size_mm.y * 1e6
- time = self.image_number() * self.image_time_enter.value
-
- self._dose_mgy = (time * s.bl.flux_ph_s * self._transmission) / (beam_area * kdose)
+ transmission = self.transmission_enter.committed_value
+ self._dose_mgy = (self._total_time * s.bl.flux_ph_s * transmission) / (beam_area * kdose)
self.dose.setText(f"{(self._dose_mgy / 1e6):.1f}")
- self._beamline_state = s.state
@Slot()
def update_omega_start(self):
if not self.start_angle.isReadOnly():
self.start_angle.force_update_value(self._omega)
-
- def get_parameter_mappings(self):
- """Return rotation-specific parameter mappings."""
- return [
- ("totalrange", self.total_angle, lambda v: float(v)),
- ("oscillation", self.image_angle, None),
- ("exposure", self.image_time_enter, None),
- ]
diff --git a/src/aare/gui/panels/scan_settings_panel.py b/src/aare/gui/panels/scan_settings_panel.py
index 65a5e6b1..17d369c0 100644
--- a/src/aare/gui/panels/scan_settings_panel.py
+++ b/src/aare/gui/panels/scan_settings_panel.py
@@ -1,7 +1,36 @@
+"""Common part of the scan settings panels (Rotation, Raster).
+
+Where the numbers in the fields come from
+-----------------------------------------
+Every setting is worth one of three things, in this order:
+
+1. the value the user typed into it, while "User values" is selected,
+2. the value the mounted sample asks for - its row in the aareDB spreadsheet,
+3. the panel default, for a spreadsheet cell the user left empty.
+
+``ScanSettingsPanel._setting`` is that rule, and each panel spells out its
+settings one line at a time in ``_write_active_values``.
+
+Committing a field - Enter, or leaving it - stores the number as the user's and
+selects "User values". Only that one setting becomes the user's: everything
+they did not touch keeps following the sample, so a new sample still brings its
+own exposure time, angles and resolution. The toggle switches all of them back
+and forth without forgetting anything.
+
+A number that was typed but never committed does not count: the field shows it
+in the pending colour and the panel keeps using the value it had. What a scan
+collects is read straight from the fields' committed values, so the panels keep
+no second copy of them.
+"""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Any
+
from aarecommon.config.logger import setup_logger
from aarecommon.math.diffraction_geometry import DiffractionGeometry
-from aarecommon.models.models import DAQStatusModel, SessionsStateEnum
-from PySide6.QtCore import Signal, Slot
+from aarecommon.models.models import DAQStatusModel, SampleShortInfo, SessionsStateEnum
+from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtWidgets import (
QButtonGroup,
QGridLayout,
@@ -14,17 +43,82 @@ from PySide6.QtWidgets import (
from aare.gui.constants import LOGGER_NAME
from aare.gui.widgets.message_box import precondition_check
-from aare.gui.widgets.number_line_edit import DbOverrideLineEdit
+from aare.gui.widgets.number_line_edit import NumberLineEdit
logger = setup_logger(LOGGER_NAME)
+# Columns already reported as unusable, so the status loop says it once.
+_BAD_COLUMNS: set[str] = set()
+
+
+def _spreadsheet_float(params: Any, column: str) -> float | None:
+ """One cell of the sample's spreadsheet row, as a float. An empty cell, a
+ cell that does not hold a number, and a column aareDB does not have all
+ read as None: aareDB owns these names, and a renamed column must not kill
+ the status loop. Each bad column is reported once - pydantic's own message
+ names the column it expected instead."""
+ try:
+ value = getattr(params, column)
+ return None if value is None else float(value)
+ except (AttributeError, TypeError, ValueError) as e:
+ if column not in _BAD_COLUMNS:
+ _BAD_COLUMNS.add(column)
+ logger.error(f"Ignoring the aareDB parameter {column!r}: {e}")
+ return None
+
+
+@dataclass
+class SampleParameters:
+ """The mounted sample's data collection parameters, in the panels' own
+ vocabulary. ``from_sample`` is the only place that knows the aareDB
+ spreadsheet column names. Every field is optional - the cell may be empty.
+ """
+
+ resolution_a: float | None = None
+ transmission: float | None = None
+ total_angle_deg: float | None = None
+ image_angle_deg: float | None = None
+ exp_time_s: float | None = None
+
+ @classmethod
+ def from_sample(cls, sample: SampleShortInfo | None) -> "SampleParameters":
+ params = None if sample is None else sample.aaredb_params
+ if params is None:
+ return cls()
+ # DataCollectionParameters.transmission is the 0-to-1 fraction the
+ # scan requests use (aarecommon >= 0.7 rejects anything above 1.0 by
+ # design; the percent spreadsheets humans write are converted inside
+ # AareDB). No scaling here: dividing again would turn 20% into 0.2%.
+ return cls(
+ resolution_a=_spreadsheet_float(params, "targetresolution"),
+ transmission=_spreadsheet_float(params, "transmission"),
+ total_angle_deg=_spreadsheet_float(params, "totalangle"),
+ image_angle_deg=_spreadsheet_float(params, "oscillation"),
+ exp_time_s=_spreadsheet_float(params, "exposure"),
+ )
+
+
class ScanSettingsPanel(QWidget):
+ """Detector distance / resolution / transmission plus the Database-vs-User
+ toggle. See the module docstring for where the numbers come from.
+
+ A panel deriving from this one:
+
+ * creates its own fields and registers them with :meth:`_add_field` (panel
+ only) or :meth:`_add_database_field` (also filled from the spreadsheet),
+ * keeps one ``_user_`` attribute per database-backed field of its
+ own, set by that field's commit slot,
+ * extends :meth:`_write_active_values` with a line per setting,
+ * ends its ``__init__`` with ``self._values_changed()``, so its read-outs
+ start out right once every field exists.
+ """
+
dtz_updated = Signal(float)
- dtz_move = Signal(float)
+ transmission_updated = Signal(float)
+
# TODO min and max dtz is set by beamline add max
MIN_DTZ = 108.0 # this is beamline dependent
- transmission_updated = Signal(float)
def __init__(
self,
@@ -35,35 +129,38 @@ class ScanSettingsPanel(QWidget):
):
super().__init__(parent)
self._diffraction = diffraction
+ self._default_dtz = default_dtz
+ self._default_transmission = default_transmission
- self._transmission = default_transmission
- self._screening_transmission = default_transmission
- self._dtz = default_dtz
- self._high_res = diffraction.resolution_angstrom(self._dtz)
-
- self._sample = None
- self._params = None
- self._last_sample_id = None
- self._previous_sample_was_none = True
- self._sample_space_group = None
- self._sample_cell_parameters = None
- self._sample_pdb_id = None
- self._target_dose = None
+ self._beamline_state = None
self._ring_current = None
self._experiment_shutter_state = None
self._door_prohibited = None
+ self._can_edit_params = False
- # Fields whose value can come from the database or be overridden by the
- # user; the panel-level toggle drives them all together.
- self._override_fields: list[DbOverrideLineEdit] = []
- self._source = DbOverrideLineEdit.SOURCE_DB
+ # Parameters of the mounted sample; all-empty while nothing is mounted.
+ self._db_params = SampleParameters()
- # Outer layout: a Database/My-values toggle above the settings grid.
- # Subclasses keep adding their widgets to self._layout (the grid) as
- # before, so they are unaffected by the wrapping.
+ # The settings the user typed in; None means "follow the sample".
+ self._user_resolution: float | None = None
+ self._user_dtz: float | None = None
+ self._user_transmission: float | None = None
+
+ # Every numeric field of the panel, locked while the beamline is busy.
+ self._fields: list[NumberLineEdit] = []
+ self._show_user_values = False
+
+ # Outer layout: the Database/User toggle above the settings grid.
+ # Subclasses keep adding their widgets to self._layout (the grid), so
+ # they are unaffected by the wrapping.
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
+ # Created here, not in _build_source_toggle: basedpyright only counts
+ # instance variables assigned in __init__ as initialized.
+ self._database_radio = QRadioButton("Database values", self)
+ self._user_radio = QRadioButton("User values", self)
+ self._source_group = QButtonGroup(self)
outer.addWidget(self._build_source_toggle())
grid_host = QWidget(self)
@@ -74,264 +171,198 @@ class ScanSettingsPanel(QWidget):
self._layout.setContentsMargins(m.left(), 6, m.right(), 3)
outer.addWidget(grid_host)
- self._layout.addWidget(QLabel("High resolution", parent=self), 0, 0)
- self.high_res_enter = DbOverrideLineEdit(
- 1.0, 10, default=self._high_res, decimals=2, parent=self
+ # Resolution and detector distance are two views of one setting:
+ # committing either one rewrites the other.
+ default_resolution = self._dtz_to_res(default_dtz)
+ self.high_res_enter = NumberLineEdit(
+ 1.0, 10, default=default_resolution, decimals=2, parent=self, track_pending=True
)
- self._layout.addWidget(self.high_res_enter, 0, 1, 1, 3)
- self._layout.addWidget(QLabel("Å", parent=self), 0, 4)
+ self._add_row(0, "High resolution", self.high_res_enter, "Å")
+ self._add_database_field(self.high_res_enter, self._on_resolution_committed)
- self._layout.addWidget(QLabel("Detector distance", parent=self), 1, 0)
- self.dtz_enter = DbOverrideLineEdit(
- self.MIN_DTZ, 1000, default=self._dtz, decimals=2, parent=self
+ self.dtz_enter = NumberLineEdit(
+ self.MIN_DTZ, 1000, default=default_dtz, decimals=2, parent=self, track_pending=True
)
- self._layout.addWidget(self.dtz_enter, 1, 1, 1, 3)
- self._layout.addWidget(QLabel("mm", parent=self), 1, 4)
+ self._add_row(1, "Detector distance", self.dtz_enter, "mm")
+ self._add_database_field(self.dtz_enter, self._on_dtz_committed)
- self._layout.addWidget(QLabel("Rotation transmission", parent=self), 2, 0)
- self.transmission_enter = DbOverrideLineEdit(
- 0, 1.0, default=self._transmission, decimals=4, parent=self
+ self.transmission_enter = NumberLineEdit(
+ 0, 1.0, default=default_transmission, decimals=4, parent=self, track_pending=True
)
- self._layout.addWidget(self.transmission_enter, 2, 1, 1, 3)
- self.transmission_enter.valueChanged.connect(self.set_transmission)
- self._register_override_field(self.transmission_enter)
+ self._add_row(2, "Rotation transmission", self.transmission_enter)
+ self._add_database_field(self.transmission_enter, self._on_transmission_committed)
- # dtz and resolution are two views of one quantity. They share the
- # source toggle; ``valueChanged`` keeps internal state/downstream in
- # sync (toggle, db updates), while ``edited`` (a real user edit) derives
- # the partner and switches the panel to "My values".
- self.dtz_enter.valueChanged.connect(self._on_dtz_value_changed)
- self.high_res_enter.valueChanged.connect(self._on_high_res_value_changed)
- self.dtz_enter.edited.connect(self._on_dtz_edited)
- self.high_res_enter.edited.connect(self._on_high_res_edited)
- self._override_fields.append(self.dtz_enter)
- self._override_fields.append(self.high_res_enter)
+ # -- grid rows ----------------------------------------------------------
+ def _add_row(
+ self, row: int, label: str, field: QWidget, unit: str = "", trailing: QWidget | None = None
+ ) -> None:
+ """One row of the settings grid: label, the field, its unit. A read-out
+ (a QLabel) is right-aligned like the input boxes. ``trailing`` takes the
+ last column and narrows the field by one to make room for it."""
+ self._layout.addWidget(QLabel(label, parent=self), row, 0)
+ self._align_readout(field)
+ span = 2 if trailing is not None else 3
+ self._layout.addWidget(field, row, 1, 1, span)
+ if unit:
+ self._layout.addWidget(QLabel(unit, parent=self), row, 1 + span)
+ if trailing is not None:
+ self._layout.addWidget(trailing, row, 4)
- self._reset_to_defaults()
+ def _add_pair_row(
+ self, row: int, label: str, first: QWidget, second: QWidget, unit: str = ""
+ ) -> None:
+ """A row holding two values side by side: label, first x second, unit."""
+ self._layout.addWidget(QLabel(label, parent=self), row, 0)
+ self._layout.addWidget(QLabel(" x ", parent=self), row, 2)
+ for widget, column in ((first, 1), (second, 3)):
+ self._align_readout(widget)
+ self._layout.addWidget(widget, row, column)
+ if unit:
+ self._layout.addWidget(QLabel(unit, parent=self), row, 4)
- # -- source toggle -----------------------------------------------------
+ def _align_readout(self, widget: QWidget) -> None:
+ # NumberLineEdit aligns itself; a QLabel showing a value has to be told
+ if isinstance(widget, QLabel):
+ widget.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
+
+ # -- field registration -------------------------------------------------
+ def _add_field(self, field: NumberLineEdit) -> None:
+ """A numeric input with no spreadsheet counterpart: it is locked while
+ the beamline is busy, and the Database/User toggle leaves it alone."""
+ self._fields.append(field)
+
+ def _add_database_field(
+ self, field: NumberLineEdit, on_commit: Callable[[float], None]
+ ) -> None:
+ """A numeric input that also has a spreadsheet counterpart. ``on_commit``
+ stores the committed number as the user's value for that setting and
+ calls :meth:`_user_took_over`."""
+ self._add_field(field)
+ field.newValue.connect(on_commit)
+
+ # -- Database / User toggle ---------------------------------------------
def _build_source_toggle(self) -> QWidget:
container = QWidget(self)
row = QHBoxLayout(container)
row.setContentsMargins(0, 0, 0, 0)
- self._db_radio = QRadioButton("Database values", container)
- self._mine_radio = QRadioButton("User values", container)
- self._db_radio.setChecked(True)
- self._source_group = QButtonGroup(container)
- self._source_group.addButton(self._db_radio)
- self._source_group.addButton(self._mine_radio)
- self._db_radio.toggled.connect(self._on_source_radio_toggled)
- row.addWidget(self._db_radio)
- row.addWidget(self._mine_radio)
+ self._database_radio.setChecked(True)
+ self._source_group.addButton(self._database_radio)
+ self._source_group.addButton(self._user_radio)
+ # One connection is enough: toggled fires on both directions.
+ self._user_radio.toggled.connect(self._on_user_values_toggled)
+ row.addWidget(self._database_radio)
+ row.addWidget(self._user_radio)
row.addStretch()
return container
- def _register_override_field(self, field: DbOverrideLineEdit):
- self._override_fields.append(field)
- field.edited.connect(self._on_override_field_edited)
-
@Slot(bool)
- def _on_source_radio_toggled(self, _checked: bool):
- source = (
- DbOverrideLineEdit.SOURCE_DB
- if self._db_radio.isChecked()
- else DbOverrideLineEdit.SOURCE_MINE
- )
- if source != self._source:
- self.set_source(source)
+ def _on_user_values_toggled(self, checked: bool):
+ self._show_user_values = checked
+ self._refresh_fields()
+
+ def _user_took_over(self):
+ """Select "User values" and let the derived read-outs catch up. Called
+ by every commit slot, after it stored the setting the user changed."""
+ self._show_user_values = True
+ # blocked: _on_user_values_toggled would rewrite the fields from here,
+ # in the middle of the commit that got us here
+ self._user_radio.blockSignals(True)
+ self._user_radio.setChecked(True)
+ self._user_radio.blockSignals(False)
+ self._values_changed()
@Slot(float)
- def _on_override_field_edited(self, _value: float):
- # Editing any field means the user is now driving the panel.
- if self._source != DbOverrideLineEdit.SOURCE_MINE:
- self.set_source(DbOverrideLineEdit.SOURCE_MINE)
+ def _on_transmission_committed(self, value: float):
+ self._user_transmission = value
+ self._user_took_over()
- def set_source(self, source: str):
- self._source = source
- # Reflect in the toggle without re-triggering the handler.
- self._db_radio.blockSignals(True)
- self._mine_radio.blockSignals(True)
- self._db_radio.setChecked(source == DbOverrideLineEdit.SOURCE_DB)
- self._mine_radio.setChecked(source == DbOverrideLineEdit.SOURCE_MINE)
- self._db_radio.blockSignals(False)
- self._mine_radio.blockSignals(False)
- # Two phases: switch every field first, then resync downstream once all
- # fields agree (so the dtz<->resolution coupling sees consistent state).
- for field in self._override_fields:
- field.set_source(source, emit=False)
- for field in self._override_fields:
- field.emit_value_changed()
+ @Slot(float)
+ def _on_resolution_committed(self, value: float):
+ self._user_resolution = value
+ self._user_dtz = self._res_to_dtz(value)
+ self.dtz_enter.force_update_value(self._user_dtz)
+ self._user_took_over()
- @Slot(DAQStatusModel)
- def update_daq_status(self, s: DAQStatusModel):
- self.dtz_enter.update_limits(s.bl.dtz_min, s.bl.dtz_max)
- self.high_res_enter.update_limits(
- self._diffraction.resolution_angstrom(s.bl.dtz_min),
- self._diffraction.resolution_angstrom(s.bl.dtz_max),
+ @Slot(float)
+ def _on_dtz_committed(self, value: float):
+ self._user_dtz = value
+ self._user_resolution = self._dtz_to_res(value)
+ self.high_res_enter.force_update_value(self._user_resolution)
+ self._user_took_over()
+
+ def _refresh_fields(self):
+ """Show the values of the active source. A field that already shows its
+ value is left alone, so a refresh that changes nothing cannot steal
+ text the user is in the middle of typing."""
+ self._write_active_values()
+ self._values_changed()
+
+ def _setting(self, user: float | None, database: float | None, default: float) -> float:
+ """What a setting is worth: the value the user typed while "User
+ values" is selected, else the one the mounted sample asks for, else the
+ panel default."""
+ if self._show_user_values and user is not None:
+ return user
+ if database is not None:
+ return database
+ return default
+
+ def _write_active_values(self):
+ """Show every setting that follows the toggle. Panels extend this with
+ a line per setting of their own."""
+ db = self._db_params
+ resolution = self._setting(
+ self._user_resolution, db.resolution_a, self._dtz_to_res(self._default_dtz)
)
- self._diffraction = s.diffraction
- self._ring_current = s.bl.ring_current_mA
- self._experiment_shutter_state = s.bl.exp_shutter_open
- self._door_prohibited = getattr(s.bl, "pss_prohibited", None)
- can_edit = (not s.busy) and (
- s.session.session in (SessionsStateEnum.OwnedByYou, SessionsStateEnum.PendingElseToYou)
+ self.high_res_enter.set_committed_value(resolution)
+ # The distance follows the resolution unless the user set it directly.
+ self.dtz_enter.set_committed_value(
+ self._setting(self._user_dtz, None, self._res_to_dtz(resolution))
+ )
+ self.transmission_enter.set_committed_value(
+ self._setting(self._user_transmission, db.transmission, self._default_transmission)
)
- self._can_edit_params = can_edit
- # Lock/unlock the override fields
- for w in (self.dtz_enter, self.high_res_enter, self.transmission_enter):
- w.set_busy(not can_edit)
- # Update sample and parameters
- if s.sample is not None:
- self._sample = s.sample
- self._params = s.sample.aaredb_params if hasattr(s.sample, "aaredb_params") else None
- # Enable reload button only if sample has parameters
- self._previous_sample_was_none = False
-
- # Check if this is a new sample - if so, update parameters
- if self._last_sample_id != s.sample.db_id:
- self.update_data_collection_parameters()
- else:
- if not self._previous_sample_was_none:
- logger.info("Sample cleared, resetting parameters to defaults")
- # self._reset_to_defaults()
- self._previous_sample_was_none = True # Mark that sample is now None
-
- self._sample = None
- self._params = None
- self._last_sample_id = None
-
- def _reset_to_defaults(self):
- # Forget user overrides and follow the database/defaults again.
- for w in self._override_fields:
- w.reset()
- self.set_source(DbOverrideLineEdit.SOURCE_DB)
- self._dtz = float(self.dtz_enter.value)
- self._transmission = float(self.transmission_enter.value)
- self._high_res = float(self.high_res_enter.value)
+ def _values_changed(self) -> None:
+ """Runs whenever the effective values change: a commit, the toggle, a
+ new sample. Panels extend it to refresh their read-outs; here it pushes
+ the two settings the raster grid manager keeps a copy of."""
+ self.dtz_updated.emit(self.dtz_enter.committed_value)
+ self.transmission_updated.emit(self.transmission_enter.committed_value)
+ # -- resolution <-> detector distance -----------------------------------
def _dtz_to_res(self, dtz: float) -> float:
return self._diffraction.resolution_angstrom(dtz)
def _res_to_dtz(self, res: float) -> float:
- dtz = self._diffraction.calc_dtz_mm(res)
- return max(dtz, self.MIN_DTZ)
+ return max(self._diffraction.calc_dtz_mm(res), self.MIN_DTZ)
- @Slot(float)
- def _on_dtz_value_changed(self, v: float):
- # dtz display changed (user edit, toggle, or db update); keep internal
- # state and push the effective dtz downstream.
- self._dtz = v
- self._high_res = self._dtz_to_res(v)
- self.dtz_updated.emit(self._dtz)
+ # -- status -------------------------------------------------------------
+ @Slot(DAQStatusModel)
+ def update_daq_status(self, s: DAQStatusModel):
+ self._diffraction = s.diffraction
+ self._beamline_state = s.state
+ self._ring_current = s.bl.ring_current_mA
+ self._experiment_shutter_state = s.bl.exp_shutter_open
+ self._door_prohibited = getattr(s.bl, "pss_prohibited", None)
- @Slot(float)
- def _on_high_res_value_changed(self, v: float):
- # Resolution display changed; dtz_updated is emitted by the dtz field
- # itself on toggle/db updates, so just track internal state here.
- self._high_res = v
+ self.dtz_enter.update_limits(s.bl.dtz_min, s.bl.dtz_max)
+ self.high_res_enter.update_limits(
+ self._dtz_to_res(s.bl.dtz_min), self._dtz_to_res(s.bl.dtz_max)
+ )
- @Slot(float)
- def _on_dtz_edited(self, v: float):
- # A real user edit of dtz: derive resolution, write both "mine" stores,
- # then switch the whole panel to "My values".
- self._dtz = v
- self._high_res = self._dtz_to_res(v)
- self.high_res_enter.set_mine_silently(self._high_res)
- if self._source != DbOverrideLineEdit.SOURCE_MINE:
- self.set_source(DbOverrideLineEdit.SOURCE_MINE)
- self.dtz_updated.emit(self._dtz)
+ self._can_edit_params = (not s.busy) and (
+ s.session.session in (SessionsStateEnum.OwnedByYou, SessionsStateEnum.PendingElseToYou)
+ )
+ for field in self._fields:
+ field.setReadOnly(not self._can_edit_params)
- @Slot(float)
- def _on_high_res_edited(self, v: float):
- # A real user edit of resolution: derive dtz, write both "mine" stores,
- # then switch the whole panel to "My values".
- self._high_res = v
- self._dtz = self._res_to_dtz(v)
- self.dtz_enter.set_mine_silently(self._dtz)
- if self._source != DbOverrideLineEdit.SOURCE_MINE:
- self.set_source(DbOverrideLineEdit.SOURCE_MINE)
- self.dtz_updated.emit(self._dtz)
-
- @Slot(float)
- def set_screening_transmission(self, v: float):
- self._screening_transmission = v
-
- @Slot(float)
- def set_transmission(self, v: float):
- self._transmission = v
- self.transmission_updated.emit(self._transmission)
-
- @Slot()
- def reload_parameters(self):
- logger.info("Reloading parameters from spreadsheet")
- temp_id = self._last_sample_id
- self._last_sample_id = None
- self.update_data_collection_parameters()
- self._last_sample_id = temp_id
- # Show the freshly-loaded database values.
- self.set_source(DbOverrideLineEdit.SOURCE_DB)
-
- def update_data_collection_parameters(self):
-
- if self._sample is None:
- return
-
- # Update the last sample ID to prevent repeated updates
- self._last_sample_id = self._sample.db_id
-
- # If no parameters exist, we keep the current (default) values
- if self._params is None:
- return
-
- # Get parameter mappings from child class
- param_mappings = self.get_parameter_mappings()
-
- # Update the database side of each parameter (a user override persists).
- for param_name, widget, converter in param_mappings:
- value = getattr(self._params, param_name, None)
- if value is not None:
- converted = converter(value) if converter else value
- if hasattr(widget, "set_db_value"):
- widget.set_db_value(converted)
- elif hasattr(widget, "force_update_value"):
- widget.force_update_value(converted)
- else:
- widget.update_value(converted)
-
- # Handle transmission with conversion (common to all panels)
- if (transmission := getattr(self._params, "transmission", None)) is not None:
- transmission_value = transmission / 100.0 if transmission > 1.0 else transmission
- self._transmission = transmission_value
- self.transmission_enter.set_db_value(transmission_value)
-
- # Handle target resolution (common to all panels). dtz is derived from
- # the resolution so its database value is kept consistent here.
- if (target_res := getattr(self._params, "targetresolution", None)) is not None:
- self._apply_db_resolution(float(target_res))
-
- # Store metadata (common to all panels)
- self._sample_space_group = getattr(self._params, "spacegroupnumber", None)
- self._sample_cell_parameters = getattr(self._params, "cellparameters", None)
- self._sample_pdb_id = getattr(self._params, "pdbid", None)
- self._target_dose = getattr(self._params, "dose", None)
-
- def _apply_db_resolution(self, target_res: float):
- """Set the database resolution and the matching database dtz so the
- linked pair stays consistent regardless of the active source."""
- self._high_res = target_res
- self._dtz = self._res_to_dtz(target_res)
- self.high_res_enter.set_db_value(target_res)
- self.dtz_enter.set_db_value(self._dtz)
-
- def get_parameter_mappings(self):
- """Return a list of (param_name, widget, converter) tuples.
- Child classes should override this to specify their specific parameters.
-
- Returns:
- List of tuples: (parameter_name, widget, converter_function or None)
- """
- return []
+ # A different sample, or an edited spreadsheet row for the same one.
+ db_params = SampleParameters.from_sample(s.sample)
+ if db_params != self._db_params:
+ logger.info(f"Sample parameters changed to {db_params}")
+ self._db_params = db_params
+ self._refresh_fields()
def check_before_run(self, scan_kind: str):
if not precondition_check(
diff --git a/src/aare/gui/threads/daq_worker.py b/src/aare/gui/threads/daq_worker.py
index 9f60f9af..81579a2b 100644
--- a/src/aare/gui/threads/daq_worker.py
+++ b/src/aare/gui/threads/daq_worker.py
@@ -84,6 +84,9 @@ class DAQWorker(QObject):
http_error = Signal(str)
status_message = Signal(str, bool)
automation_progress = Signal(object)
+ # True while a loop centering runs — /status alone cannot tell it from
+ # a manual alignment move (see build_busy_overlay_style).
+ auto_centering = Signal(bool)
gui_sessions_loaded = Signal(list)
gui_close_requested = Signal(int, int, str)
recovery_action_completed = Signal(str)
@@ -154,6 +157,10 @@ class DAQWorker(QObject):
self._timer.start()
self._counter = 0
self._automation_progress_buffer = ""
+ # Two centering sources OR-ed into auto_centering: our own
+ # /alc/center_loop POST in flight, and the automation Center step.
+ self._manual_centering = False
+ self._automation_centering = False
self._cleanup_done = False
self._last_auth_error_log_ts = 0.0
@@ -747,7 +754,7 @@ class DAQWorker(QObject):
"""
if self._base_url is None:
logger.info(f"POST /{url}: {body}")
- return
+ return None
request = QNetworkRequest(QUrl(f"{self._base_url}/{url}"))
request.setRawHeader(b"Authorization", f"Bearer {self._token}".encode())
@@ -755,6 +762,7 @@ class DAQWorker(QObject):
request.setRawHeader(b"Content-Type", b"application/json")
reply = self._net_manager.post(request, QByteArray(body.encode("utf-8")))
reply.finished.connect(lambda: self.handle_req_response(reply))
+ return reply
def generic_put(self, url: str, body: str = ""):
"""
@@ -844,7 +852,20 @@ class DAQWorker(QObject):
@Slot()
def center_loop(self):
- self.generic_post("alc/center_loop")
+ # The endpoint blocks server-side until centering ends, so the reply
+ # doubles as the "done" edge for the camera overlay.
+ reply = self.generic_post("alc/center_loop")
+ if reply is None:
+ return
+ self._set_centering(manual=True)
+ reply.finished.connect(lambda: self._set_centering(manual=False))
+
+ def _set_centering(self, *, manual: bool | None = None, automation: bool | None = None):
+ if manual is not None:
+ self._manual_centering = manual
+ if automation is not None:
+ self._automation_centering = automation
+ self.auto_centering.emit(self._manual_centering or self._automation_centering)
@Slot()
def force_session(self):
@@ -1968,6 +1989,13 @@ class DAQWorker(QObject):
progress = self._parse_automation_progress(progress_payload)
self.automation_progress.emit(progress)
self._emit_automation_progress_events(progress)
+ self._set_centering(
+ automation=not progress.finished
+ and any(
+ step.step == WorkflowStateKind.LOOP_CENTRE and step.status == StepStatus.RUNNING
+ for step in progress.steps
+ )
+ )
current = progress.current_step or "Idle"
if progress.finished:
diff --git a/src/aare/gui/widgets/busy_overlay.py b/src/aare/gui/widgets/busy_overlay.py
index 3a0d11e7..cbf356b7 100644
--- a/src/aare/gui/widgets/busy_overlay.py
+++ b/src/aare/gui/widgets/busy_overlay.py
@@ -17,6 +17,9 @@ from aare.gui.styles import (
BUSY_PSI_RED,
BUSY_PSI_RED_BORDER,
BUSY_PSI_RED_DOT,
+ BUSY_PURPLE,
+ BUSY_PURPLE_BORDER,
+ BUSY_PURPLE_DOT,
BUSY_RED_BADGE,
BUSY_RED_BORDER,
BUSY_RED_DOT,
@@ -174,6 +177,7 @@ def build_busy_overlay_style(
is_busy: bool,
tell_state: TellStateModel | None,
session_state: SessionsStateEnum | None = None,
+ auto_centering: bool = False,
) -> BusyOverlayStyle | None:
if session_state in {
SessionsStateEnum.OwnedByElse,
@@ -190,6 +194,24 @@ def build_busy_overlay_style(
accent_dot=BUSY_YELLOW_DOT,
subtext="Grab the baton if you need to interact with GUI",
)
+
+ # Auto loop centering is busy + SampleAlignment in /status — the exact
+ # combo the callers hide (manual omega moves look identical), so it
+ # gets its own flag: the GUI knows it POSTed /alc/center_loop, or the
+ # automation progress says the Center step is running. Checked before
+ # is_busy because the callers pass is_busy=False during alignment.
+ if auto_centering:
+ return BusyOverlayStyle(
+ text="AUTO CENTERING",
+ badge_bg=BUSY_PURPLE,
+ badge_fg=WHITE,
+ overlay_fill=qcolor(BUSY_PURPLE, 190),
+ overlay_border=qcolor(BUSY_PURPLE_BORDER, 235),
+ overlay_text=qcolor(WHITE),
+ accent_dot=BUSY_PURPLE_DOT,
+ animate=True,
+ )
+
if not is_busy:
return None
diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py
index c57482ca..d4093c74 100644
--- a/src/aare/gui/widgets/camera_image.py
+++ b/src/aare/gui/widgets/camera_image.py
@@ -130,6 +130,7 @@ class SampleCameraImageLabel(QGraphicsView):
self._last_grid_update_ts = 0.0
self._grid_update_min_interval_s = 1.0 / 25.0
self._tell_state = None
+ self._auto_centering = False
self._busy_overlay_style: BusyOverlayStyle | None = None
self._geom = geom
@@ -295,6 +296,12 @@ class SampleCameraImageLabel(QGraphicsView):
self._smoothed_target_point = None
self.update()
+ @Slot(bool)
+ def set_auto_centering(self, active: bool) -> None:
+ # ponytail: only stored — the overlay style is rebuilt on the next
+ # /status tick (500 ms), well within the seconds a centering takes.
+ self._auto_centering = active
+
def _busy_overlay_text(self) -> str:
tell_state = self._tell_state
if tell_state is None:
@@ -843,6 +850,7 @@ class SampleCameraImageLabel(QGraphicsView):
is_busy=bool(s.busy) and s.state != BeamlineStateEnum.SampleAlignment,
tell_state=s.tell_state,
session_state=self._session_state,
+ auto_centering=self._auto_centering,
)
if new_busy_style != self._busy_overlay_style:
self._busy_overlay_style = new_busy_style
diff --git a/src/aare/gui/widgets/number_line_edit.py b/src/aare/gui/widgets/number_line_edit.py
index 13584a64..e9ea2bed 100644
--- a/src/aare/gui/widgets/number_line_edit.py
+++ b/src/aare/gui/widgets/number_line_edit.py
@@ -105,6 +105,20 @@ class NumberLineEdit(QLineEdit):
def value(self) -> float:
return float(self.text())
+ @property
+ def committed_value(self) -> float:
+ """The value that was applied last, by Enter/focus-out or by a
+ programmatic write. Unlike ``value`` it ignores text that is still
+ being typed, and it never raises on a half-written number."""
+ return self._applied_value
+
+ def set_committed_value(self, val: float):
+ """Show ``val`` as the committed value, unless the field already shows
+ it. Skipping the rewrite keeps a refresh that changes nothing from
+ stealing text the user is in the middle of typing."""
+ if self.to_string(val) != self.to_string(self._applied_value):
+ self.force_update_value(val)
+
@Slot(float)
def update_value(self, val: float):
if abs(val - self.saved_value) > 0.001:
@@ -267,148 +281,3 @@ class CheckedLineEdit(QWidget):
def reset_to_default(self):
# Force set to initial default
self.force_update_value(self._internal_value)
-
-
-class DbOverrideLineEdit(QWidget):
- """A numeric field backed by two sources: a database value and the user's
- own value, with a panel-level toggle choosing which is shown.
-
- Behaviour:
- * Until the user edits the field, "mine" mirrors the database value, so a
- fresh field just follows the database.
- * Editing records the user's value, marks the field as user-edited, flips
- the source to "mine", and emits ``edited`` so the owning panel can move
- the whole panel to "My values".
- * Switching back to "Database values" shows the database value but keeps
- the user's value, so it can be recovered by switching to "My values".
- * Database updates (new sample, status refresh) only overwrite the
- database value; a user-edited value therefore persists across samples.
-
- The widget has no checkbox of its own - the panel drives ``set_source`` for
- all of its fields together.
- """
-
- # active value changed (user edit, source toggle, or db update while showing db)
- valueChanged = Signal(float)
- # user finished editing -> the owning panel should switch to "My values"
- edited = Signal(float)
-
- SOURCE_DB = "db"
- SOURCE_MINE = "mine"
-
- def __init__(
- self,
- min_val: float,
- max_val: float,
- /,
- default: float = 0.0,
- decimals: int = 2,
- parent=None,
- ):
- super().__init__(parent)
- self._default = float(default)
- self._db_value = float(default)
- self._mine_value = float(default)
- self._user_edited = False
- self._source = self.SOURCE_DB
- self._busy = False
-
- # pending color while typing: every DbOverride field lives in the
- # Experiment configuration group, which opted into the feedback
- self.editor = NumberLineEdit(min_val, max_val, default, decimals, self, track_pending=True)
- self.editor.newValue.connect(self._on_editor_value)
-
- layout = QHBoxLayout(self)
- layout.setContentsMargins(0, 0, 0, 0)
- layout.addWidget(self.editor)
- self._apply_editable()
-
- # -- internal helpers --------------------------------------------------
- def _active_value(self) -> float:
- return self._mine_value if self._source == self.SOURCE_MINE else self._db_value
-
- def _refresh_display(self):
- self.editor.force_update_value(self._active_value())
-
- def _apply_editable(self):
- # Editable whenever the panel is not busy; editing implies "mine".
- self.editor.setReadOnly(self._busy)
-
- def _on_editor_value(self, value: float):
- self._user_edited = True
- self._mine_value = value
- self._source = self.SOURCE_MINE
- self.valueChanged.emit(value)
- self.edited.emit(value)
-
- # -- panel-facing API --------------------------------------------------
- def set_source(self, source: str, emit: bool = True):
- """Show the database value or the user's value. The panel switches all
- of its fields with emit=False, then calls emit_value_changed() once each
- so downstream consumers resync after every field is in the new source."""
- if source not in (self.SOURCE_DB, self.SOURCE_MINE):
- return
- self._source = source
- self._refresh_display()
- if emit:
- self.valueChanged.emit(self._active_value())
-
- def emit_value_changed(self):
- self.valueChanged.emit(self._active_value())
-
- def source(self) -> str:
- return self._source
-
- def set_db_value(self, value: float):
- """Update the database value. Mirrors into the user's value while the
- field has not been user-edited, so untouched fields follow the database;
- a user-edited value is preserved (only the db side changes), which is
- what lets a user's value persist across samples."""
- value = float(value)
- changed = value != self._db_value
- self._db_value = value
- if not self._user_edited:
- self._mine_value = value
- # Only refresh/emit when the shown value actually changes, so repeated
- # database pushes (e.g. live grid geometry) cannot feed back into a loop.
- if self._source == self.SOURCE_DB and changed:
- self._refresh_display()
- self.valueChanged.emit(value)
-
- def set_mine_silently(self, value: float):
- """Set the user's value from a linked field (e.g. the dtz<->resolution
- pair) without emitting. Marks the field user-edited and refreshes the
- display if the user's value is currently shown. No signal is emitted, so
- the linked field (which drives the physics downstream) does not feed back
- into a loop."""
- value = float(value)
- self._mine_value = value
- self._user_edited = True
- if self._source == self.SOURCE_MINE:
- self._refresh_display()
-
- def has_user_value(self) -> bool:
- return self._user_edited
-
- def set_busy(self, busy: bool):
- self._busy = busy
- self._apply_editable()
-
- def update_limits(self, min_val: float, max_val: float):
- self.editor.update_limits(min_val, max_val)
-
- def reset(self):
- """Forget the user's value and return to the constructor default,
- following the database again (used when the sample is cleared)."""
- self._user_edited = False
- self._db_value = self._default
- self._mine_value = self._default
- self._source = self.SOURCE_DB
- self._refresh_display()
-
- @property
- def value(self) -> float:
- return self.editor.value
-
- def isReadOnly(self) -> bool:
- return self.editor.isReadOnly()
diff --git a/tests/unit/gui/test_busy_overlay.py b/tests/unit/gui/test_busy_overlay.py
index 2b83d71c..6a71154b 100644
--- a/tests/unit/gui/test_busy_overlay.py
+++ b/tests/unit/gui/test_busy_overlay.py
@@ -2,6 +2,7 @@
the badge pill (which reads as a button). These checks fail if the text
renderer stops painting or the video view stops routing through it."""
+from aarecommon.models.models import SessionsStateEnum
from PySide6.QtCore import Qt
from PySide6.QtGui import QPainter, QPixmap
@@ -34,3 +35,18 @@ def test_video_view_paints_busy_text(qtbot):
assert view.grab().toImage() != idle, "busy style must change the rendered view"
view.set_busy_overlay_style(None)
assert view.grab().toImage() == idle, "clearing the style must restore the view"
+
+
+def test_auto_centering_flag_overrides_alignment_gate():
+ # Callers pass is_busy=False during SampleAlignment; the flag must still
+ # produce the overlay, and the viewing-mode badge must still win.
+ style = build_busy_overlay_style(is_busy=False, tell_state=None, auto_centering=True)
+ assert style is not None and style.text == "AUTO CENTERING"
+ assert build_busy_overlay_style(is_busy=False, tell_state=None) is None
+ viewing = build_busy_overlay_style(
+ is_busy=False,
+ tell_state=None,
+ session_state=SessionsStateEnum.OwnedByElse,
+ auto_centering=True,
+ )
+ assert viewing is not None and viewing.text.startswith("Viewing mode")
diff --git a/tests/unit/gui/test_data_collection_settings.py b/tests/unit/gui/test_data_collection_settings.py
index 30d093c4..d821e942 100644
--- a/tests/unit/gui/test_data_collection_settings.py
+++ b/tests/unit/gui/test_data_collection_settings.py
@@ -1,86 +1,93 @@
-"""Tests for the data-collection settings override model.
+"""Tests for the scan settings panels.
-Covers DbOverrideLineEdit (database value vs user's value, persistence across
-samples, go-back-to-DB, recover-my-value) and the panel-level Database/My-values
-toggle with the dtz<->resolution coupling that must hold in both modes.
+Covers where the numbers in the fields come from (the mounted sample's
+spreadsheet row, the user's own values, the panel defaults), that a user value
+survives a new sample all the way into the scan request while the settings the
+user did not touch keep following the sample, and that a number typed but never
+committed does not take effect.
"""
import types
+from typing import Any, cast
import pytest
from aarecommon.math.coordinate import Coordinate, SmargonCoordinate
from aarecommon.math.diffraction_geometry import DiffractionGeometry
-from aarecommon.models.models import SampleGeometryModel
+from aarecommon.models.models import (
+ BeamlineStateEnum,
+ DataCollectionParameters,
+ SampleGeometryModel,
+ SampleShortInfo,
+)
from PySide6.QtCore import Qt
from aare.gui.panels.data_collection_settings import DataCollectionSettings
from aare.gui.panels.raster_data_collection import RasterDataCollectionPanel
from aare.gui.panels.rotation_data_collection import RotationDataCollectionPanel
+from aare.gui.panels.scan_settings_panel import SampleParameters
from aare.gui.scan_logic.raster_grid_manager import RasterGridManager
-from aare.gui.widgets.number_line_edit import DbOverrideLineEdit, NumberLineEdit
+from aare.gui.widgets.number_line_edit import NumberLineEdit
-def _edit(field: DbOverrideLineEdit, text: str):
- """Simulate a user typing into and committing a field."""
- field.editor.setText(text)
- field.editor.on_editing_finished()
+def _commit(field: NumberLineEdit, text: str):
+ """Type into a field and commit it, as Enter or leaving the field does."""
+ field.setText(text)
+ field.on_editing_finished()
+
+
+def _mount(panel, **params):
+ """Mount a sample whose spreadsheet row holds `params`; same two steps
+ update_daq_status takes when the parameters of the mounted sample change."""
+ panel._db_params = SampleParameters(**params)
+ panel._refresh_fields()
# ---------------------------------------------------------------------------
-# Widget: DbOverrideLineEdit
+# The spreadsheet row -> SampleParameters
# ---------------------------------------------------------------------------
-def test_db_override_follows_database_until_edited(qapp):
- w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2)
- assert w.source() == DbOverrideLineEdit.SOURCE_DB
- assert not w.has_user_value()
-
- w.set_db_value(150.0)
- assert w.value == 150.0 # untouched field tracks the database
+def test_sample_parameters_translate_the_spreadsheet_row():
+ # The real model, so the column names are checked against aareDB and not
+ # against a stand-in that agrees with whatever this file happens to say.
+ sample = SampleShortInfo(
+ db_id=1,
+ puck_name="puck1",
+ dewar_name="dewar1",
+ sample_name="sample1",
+ run_number=1,
+ pin=1,
+ aaredb_params=DataCollectionParameters(
+ targetresolution=1.5,
+ transmission=0.2, # 0-to-1 fraction; aareDB converts the percent sheets
+ totalangle=180,
+ oscillation=0.1,
+ exposure=0.02,
+ ),
+ )
+ assert SampleParameters.from_sample(sample) == SampleParameters(
+ resolution_a=1.5,
+ transmission=0.2,
+ total_angle_deg=180.0,
+ image_angle_deg=0.1,
+ exp_time_s=0.02,
+ )
-def test_db_override_user_value_persists_and_recovers(qapp):
- w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2)
-
- _edit(w, "300.00")
- assert w.has_user_value()
- assert w.source() == DbOverrideLineEdit.SOURCE_MINE
- assert w.value == 300.0
-
- # A new database value (e.g. next sample) must not clobber the user's value.
- w.set_db_value(120.0)
- assert w.value == 300.0
-
- # Go back to the database value, then recover the user's value.
- w.set_source(DbOverrideLineEdit.SOURCE_DB, emit=False)
- assert w.value == 120.0
- w.set_source(DbOverrideLineEdit.SOURCE_MINE, emit=False)
- assert w.value == 300.0
+def test_sample_parameters_are_empty_without_a_sample():
+ assert SampleParameters.from_sample(None) == SampleParameters()
-def test_db_override_reset_forgets_user_value(qapp):
- w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2)
- _edit(w, "300.00")
- w.reset()
- assert w.value == 200.0
- assert w.source() == DbOverrideLineEdit.SOURCE_DB
- assert not w.has_user_value()
-
-
-def test_db_override_emits_value_changed_on_toggle(qapp):
- w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2)
- _edit(w, "300.00") # mine = 300, db = 200
- seen = []
- w.valueChanged.connect(lambda v: seen.append(v))
- w.set_source(DbOverrideLineEdit.SOURCE_DB)
- assert seen[-1] == 200.0 # toggling pushes the now-active value downstream
- w.set_source(DbOverrideLineEdit.SOURCE_MINE)
- assert seen[-1] == 300.0
+def test_a_column_aaredb_does_not_have_is_ignored():
+ # aareDB owns these names: a renamed column must leave the panel on its
+ # defaults, not break the status loop it is read from.
+ params = types.SimpleNamespace(targetresolution=1.5)
+ sample = cast(Any, types.SimpleNamespace(aaredb_params=params))
+ assert SampleParameters.from_sample(sample) == SampleParameters(resolution_a=1.5)
# ---------------------------------------------------------------------------
-# Panel: toggle + dtz/resolution coupling
+# Rotation panel
# ---------------------------------------------------------------------------
@@ -104,71 +111,142 @@ def panel(qapp, diffraction):
return RotationDataCollectionPanel(diffraction=diffraction)
-def test_editing_dtz_updates_resolution_and_switches_to_mine(panel, diffraction):
- _edit(panel.dtz_enter, "250.00")
- assert panel._source == DbOverrideLineEdit.SOURCE_MINE
- assert abs(panel.high_res_enter.value - diffraction.resolution_angstrom(250.0)) < 0.01
+@pytest.fixture
+def runnable_panel(panel, monkeypatch):
+ """A rotation panel whose Run button reaches the request: beamline ready
+ and the file path/precondition dialogs answered."""
+ panel._beamline_state = BeamlineStateEnum.SampleAlignment
+ monkeypatch.setattr(panel, "check_before_run", lambda scan_kind: True)
+ return panel
-def test_editing_resolution_updates_dtz(panel, diffraction):
- _edit(panel.high_res_enter, "2.00")
- expected = max(panel.MIN_DTZ, diffraction.calc_dtz_mm(2.0))
- assert abs(panel.dtz_enter.value - expected) < 0.01
+def test_fields_follow_the_mounted_sample(panel):
+ _mount(panel, resolution_a=2.5, transmission=0.5, total_angle_deg=180.0, exp_time_s=0.02)
+
+ assert panel.high_res_enter.committed_value == 2.5
+ assert panel.dtz_enter.committed_value == pytest.approx(panel._res_to_dtz(2.5), abs=0.01)
+ assert panel.transmission_enter.committed_value == 0.5
+ assert panel.total_angle.committed_value == 180.0
+ assert panel.image_time_enter.committed_value == 0.02
+ # An empty spreadsheet cell leaves the panel default in place.
+ assert panel.image_angle.committed_value == panel._default_image_angle
-def test_coupling_holds_in_database_mode(panel, diffraction):
- # Set a database resolution; dtz database value must stay consistent.
- panel._apply_db_resolution(2.5)
- assert panel._source == DbOverrideLineEdit.SOURCE_DB
- expected_dtz = max(panel.MIN_DTZ, diffraction.calc_dtz_mm(2.5))
- assert abs(panel.dtz_enter.value - expected_dtz) < 0.01
- assert abs(panel.high_res_enter.value - 2.5) < 0.01
+def test_editing_resolution_updates_dtz_and_selects_user_values(panel):
+ _commit(panel.high_res_enter, "2.00")
+
+ assert panel._show_user_values
+ assert panel._user_radio.isChecked()
+ assert panel.dtz_enter.committed_value == pytest.approx(panel._res_to_dtz(2.0), abs=0.01)
-def test_toggle_pushes_active_value_downstream(panel):
- emitted = []
- panel.dtz_updated.connect(lambda v: emitted.append(round(v, 2)))
+def test_editing_dtz_updates_resolution(panel, diffraction):
+ _commit(panel.dtz_enter, "250.00")
- _edit(panel.dtz_enter, "250.00") # mine dtz = 250
- mine_dtz = panel.dtz_enter.value
-
- panel.set_source(DbOverrideLineEdit.SOURCE_DB)
- db_dtz = panel.dtz_enter.value
- assert emitted[-1] == round(db_dtz, 2) # downstream got the db value
-
- panel.set_source(DbOverrideLineEdit.SOURCE_MINE)
- assert panel.dtz_enter.value == mine_dtz # user value recovered
- assert emitted[-1] == round(mine_dtz, 2) # downstream got the user value
-
-
-def test_user_override_persists_across_samples(panel, diffraction):
- # Sample 1 loads a database resolution.
- panel._sample = types.SimpleNamespace(db_id=1)
- panel._params = types.SimpleNamespace(
- targetresolution=2.5, transmission=0.5, totalrange=180.0, oscillation=0.1, exposure=0.02
+ assert panel.high_res_enter.committed_value == pytest.approx(
+ diffraction.resolution_angstrom(250.0), abs=0.01
)
- panel.update_data_collection_parameters()
- # User overrides the exposure time.
- _edit(panel.image_time_enter, "0.0500")
- assert panel.image_time_enter.value == 0.05
- # Sample 2 arrives with a different database exposure.
- panel._sample = types.SimpleNamespace(db_id=2)
- panel._params = types.SimpleNamespace(
- targetresolution=1.8, transmission=1.0, totalrange=360.0, oscillation=0.2, exposure=0.01
- )
- panel.update_data_collection_parameters()
+def test_user_value_survives_the_next_sample(runnable_panel):
+ panel = runnable_panel
+ _mount(panel, resolution_a=2.5, transmission=0.5, total_angle_deg=180.0, exp_time_s=0.02)
- # In My-values mode the user's exposure persists across the sample change.
- assert panel.image_time_enter.value == 0.05
- # Going back to Database shows the new sample's database value.
- panel.set_source(DbOverrideLineEdit.SOURCE_DB)
- assert panel.image_time_enter.value == 0.01
+ # The user overrides the resolution the spreadsheet asks for.
+ _commit(panel.high_res_enter, "3.00")
+ user_dtz = panel.dtz_enter.committed_value
+
+ # Next sample, same spreadsheet values: the user's resolution stays put...
+ _mount(panel, resolution_a=2.5, transmission=0.5, total_angle_deg=180.0, exp_time_s=0.02)
+ assert panel.high_res_enter.committed_value == 3.00
+ assert panel.dtz_enter.committed_value == user_dtz
+
+ # ...and it is what the scan actually collects at.
+ requests = []
+ panel.rotation_scan.connect(requests.append)
+ panel.run_measurement()
+ assert requests[-1].dtz == user_dtz
+
+
+def test_untouched_settings_keep_following_the_sample(panel):
+ _mount(panel, total_angle_deg=180.0, exp_time_s=0.02)
+ _commit(panel.image_time_enter, "0.0500") # only the image time is the user's
+
+ _mount(panel, total_angle_deg=360.0, exp_time_s=0.02)
+ assert panel.image_time_enter.committed_value == 0.05
+ assert panel.total_angle.committed_value == 360.0
+
+
+def test_toggle_swaps_between_the_two_sets(panel):
+ _mount(panel, resolution_a=2.5, exp_time_s=0.02)
+ _commit(panel.image_time_enter, "0.0500")
+
+ panel._database_radio.setChecked(True) # back to Database values
+ assert panel.image_time_enter.committed_value == 0.02
+ assert panel.high_res_enter.committed_value == 2.5
+
+ panel._user_radio.setChecked(True) # the user's set is remembered
+ assert panel.image_time_enter.committed_value == 0.05
+
+
+def test_a_new_sample_is_shown_while_on_database_values(panel):
+ _mount(panel, exp_time_s=0.02)
+ assert panel.image_time_enter.committed_value == 0.02
+ _mount(panel, exp_time_s=0.03)
+ assert panel.image_time_enter.committed_value == 0.03
+
+
+def test_text_that_was_never_entered_does_not_take_effect(runnable_panel):
+ panel = runnable_panel
+ _mount(panel, resolution_a=2.5, transmission=0.5, total_angle_deg=180.0, exp_time_s=0.02)
+ database_dtz = panel.dtz_enter.committed_value
+
+ requests = []
+ panel.rotation_scan.connect(requests.append)
+
+ panel.high_res_enter.setText("3.00") # typed, never entered
+ panel.run_measurement()
+ assert not panel._show_user_values
+ assert requests[-1].dtz == database_dtz
+
+ # Committing it is what makes it the user's.
+ panel.high_res_enter.on_editing_finished()
+ panel.run_measurement()
+ assert panel._show_user_values
+ assert requests[-1].dtz == pytest.approx(panel._res_to_dtz(3.00), abs=0.01)
+
+
+def test_screening_transmission_is_the_panels_own(runnable_panel):
+ panel = runnable_panel
+ _mount(panel, transmission=0.5)
+ _commit(panel.screening_transmission_enter, "0.1000")
+
+ requests = []
+ panel.rotation_scan.connect(requests.append)
+ panel.run_screening()
+
+ assert requests[-1].transmission == 0.1 # not the 0.5 the rotation uses
+
+
+def test_downstream_gets_the_active_values(panel):
+ dtz_seen = []
+ transmission_seen = []
+ panel.dtz_updated.connect(dtz_seen.append)
+ panel.transmission_updated.connect(transmission_seen.append)
+
+ _commit(panel.dtz_enter, "250.00")
+ assert dtz_seen[-1] == 250.0
+
+ # The transmission was never touched, so it keeps following the sample.
+ _mount(panel, transmission=0.4)
+ assert transmission_seen[-1] == 0.4
+
+ panel._database_radio.setChecked(True)
+ assert dtz_seen[-1] == pytest.approx(panel.dtz_enter.committed_value)
# ---------------------------------------------------------------------------
-# Raster grid element size follows the same override model
+# Raster panel
# ---------------------------------------------------------------------------
@@ -191,33 +269,37 @@ def raster_panel(qapp, diffraction, geom):
return RasterDataCollectionPanel(raster_mgr=mgr, diffraction=diffraction)
-def test_grid_element_size_user_value_persists_over_geometry(raster_panel):
+def test_grid_element_size_is_reported_and_echoed_back(raster_panel):
emitted = []
raster_panel.grid_size_updated.connect(lambda x, y: emitted.append((x, y)))
- _edit(raster_panel.width_enter, "20")
- assert raster_panel._source == DbOverrideLineEdit.SOURCE_MINE
- assert raster_panel.width_enter.value == 20.0
- assert emitted # editing pushed the new element size downstream
+ _commit(raster_panel.width_enter, "20")
+ assert emitted[-1][0] == pytest.approx(0.020)
- # The live grid geometry keeps reporting a different element size; the
- # user's value must persist (only the database side changes).
- raster_panel.width_enter.set_db_value(35.0)
- assert raster_panel.width_enter.value == 20.0
-
- # Revert to the geometry value, then recover the user's value.
- raster_panel.set_source(DbOverrideLineEdit.SOURCE_DB)
- assert raster_panel.width_enter.value == 35.0
- raster_panel.set_source(DbOverrideLineEdit.SOURCE_MINE)
- assert raster_panel.width_enter.value == 20.0
+ # The grid manager answers with the geometry it settled on; the field
+ # follows it and does not emit again.
+ before = len(emitted)
+ raster_panel.grid_scan_size_change(4, 5, 0.035, 0.020)
+ assert raster_panel.width_enter.committed_value == 35.0
+ assert len(emitted) == before
-def test_grid_size_field_shares_panel_toggle(raster_panel):
- # Editing the resolution flips the whole panel to "My values", which the
- # grid-size field shares.
- _edit(raster_panel.high_res_enter, "2.50")
- assert raster_panel._source == DbOverrideLineEdit.SOURCE_MINE
- assert raster_panel.width_enter.source() == DbOverrideLineEdit.SOURCE_MINE
+def test_raster_exposure_follows_the_toggle(raster_panel):
+ _mount(raster_panel, exp_time_s=0.04)
+ assert raster_panel.image_time_enter.committed_value == 0.04
+
+ _commit(raster_panel.image_time_enter, "0.0800")
+ _mount(raster_panel, exp_time_s=0.04)
+ assert raster_panel.image_time_enter.committed_value == 0.08
+
+ raster_panel._database_radio.setChecked(True)
+ assert raster_panel.image_time_enter.committed_value == 0.04
+
+
+def test_grid_element_size_stays_out_of_the_toggle(raster_panel):
+ _commit(raster_panel.width_enter, "20")
+ # A panel-only field must not take the panel to user values.
+ assert not raster_panel._show_user_values
# ---------------------------------------------------------------------------
@@ -293,8 +375,10 @@ def test_typing_marks_pending_and_enter_commits(tracked_box, qtbot):
_type(qtbot, tracked_box, "300")
assert tracked_box.property("movestate") == "pending"
assert not seen # nothing is applied until Enter/focus-out
+ assert tracked_box.committed_value == 100.0
qtbot.keyClick(tracked_box, Qt.Key.Key_Return)
assert seen == [300.0]
+ assert tracked_box.committed_value == 300.0
assert tracked_box.property("movestate") == ""
@@ -328,26 +412,13 @@ def test_programmatic_update_clears_pending(tracked_box, qtbot):
assert tracked_box.property("movestate") == ""
-def test_db_override_typing_pending_until_commit(qapp, qtbot):
- w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2)
- qtbot.addWidget(w)
- _type(qtbot, w.editor, "300")
- assert w.editor.property("movestate") == "pending"
- qtbot.keyClick(w.editor, Qt.Key.Key_Return)
- assert w.editor.property("movestate") == ""
- assert w.value == 300.0
- assert w.source() == DbOverrideLineEdit.SOURCE_MINE
-
-
-def test_db_override_source_toggle_clears_pending(qapp, qtbot):
- w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2)
- qtbot.addWidget(w)
- _type(qtbot, w.editor, "300") # typing, no commit
- assert w.editor.property("movestate") == "pending"
- # a panel refresh (radio toggle, DB push) discards the uncommitted text
- w.set_source(DbOverrideLineEdit.SOURCE_DB, emit=False)
- assert w.editor.property("movestate") == ""
- assert w.value == 200.0
+def test_set_committed_value_keeps_a_value_that_did_not_change(tracked_box, qtbot):
+ _type(qtbot, tracked_box, "300") # typing, no commit
+ tracked_box.set_committed_value(100.0) # what the field already holds
+ assert tracked_box.text() == "300" # the typing survives
+ tracked_box.set_committed_value(120.0) # a real change wins
+ assert tracked_box.committed_value == 120.0
+ assert tracked_box.property("movestate") == ""
def test_energy_spin_motor_move_semantics(settings_panel, daq_status_factory):
diff --git a/tests/unit/gui/test_rotation_speed_limits.py b/tests/unit/gui/test_rotation_speed_limits.py
index a0f652de..5299fd69 100644
--- a/tests/unit/gui/test_rotation_speed_limits.py
+++ b/tests/unit/gui/test_rotation_speed_limits.py
@@ -1,7 +1,9 @@
"""Omega speed cap (500 deg/s) and per-beamline min image time on the rotation panel.
-Why: QDoubleValidator only paints a field red, so the interlock lives in the
-linked limits plus the Run-button gate. These fail if either half breaks.
+Why: the image angle and the image time are each fine on their own - it is the
+pair that can ask the goniometer to turn too fast. So the cap is a read-out
+plus a gate on the Run button, and only the per-beamline minimum image time is
+a limit of the field itself.
"""
import pytest
@@ -29,19 +31,20 @@ def _panel(monkeypatch, beamline):
return RotationDataCollectionPanel(diffraction=diffraction)
-def _editor(field):
- # NumberLineEdit is the editor itself; DbOverrideLineEdit wraps one in .editor
- return getattr(field, "editor", field)
+def _runnable(panel, monkeypatch):
+ panel._beamline_state = BeamlineStateEnum.SampleAlignment
+ monkeypatch.setattr(panel, "check_before_run", lambda scan_kind: True)
+ return panel
def _commit(field, text):
- _editor(field).setText(text)
- _editor(field).on_editing_finished()
+ field.setText(text)
+ field.on_editing_finished()
def _accepts(field, text) -> bool:
- _editor(field).setText(text)
- return _editor(field).hasAcceptableInput()
+ field.setText(text)
+ return field.hasAcceptableInput()
@pytest.mark.parametrize(
@@ -54,16 +57,35 @@ def test_min_image_time_per_beamline(qapp, monkeypatch, beamline, too_short, ok)
assert _accepts(field, ok)
-def test_speed_cap_couples_angle_and_time(qapp, monkeypatch):
+def test_speed_readout_follows_the_pair(qapp, monkeypatch):
panel = _panel(monkeypatch, "X06DA")
- _commit(panel.image_angle, "1.000") # -> time floor 1/500 = 0.002 s
- assert not _accepts(panel.image_time_enter, "0.0010")
- assert _accepts(panel.image_time_enter, "0.0020")
+ _commit(panel.image_angle, "1.000")
+ _commit(panel.image_time_enter, "0.1000")
+ assert panel.omega_speed.text() == "10.0"
+ assert not panel.omega_speed.styleSheet() # within the cap
- _commit(panel.image_time_enter, "0.0100") # -> angle max 5 deg
- assert not _accepts(panel.image_angle, "6.000")
- assert _accepts(panel.image_angle, "4.000")
+ _commit(panel.image_time_enter, "0.0012") # 833 deg/s
+ assert panel.omega_speed.text() == "833.3"
+ assert panel.omega_speed.styleSheet() # marked red
+
+
+def test_run_blocked_above_the_speed_cap(qapp, monkeypatch):
+ panel = _runnable(_panel(monkeypatch, "X06DA"), monkeypatch)
+ boxes = []
+ monkeypatch.setattr(QMessageBox, "critical", lambda *a, **k: boxes.append(a))
+ emitted = []
+ panel.rotation_scan.connect(emitted.append)
+
+ _commit(panel.image_angle, "1.000")
+ _commit(panel.image_time_enter, "0.0012") # 833 deg/s, over the cap
+ panel.run_measurement()
+ assert emitted == []
+ assert len(boxes) == 1
+
+ _commit(panel.image_time_enter, "0.0100") # 100 deg/s
+ panel.run_measurement()
+ assert len(emitted) == 1
def test_run_blocked_while_field_red(qapp, monkeypatch):
@@ -74,7 +96,7 @@ def test_run_blocked_while_field_red(qapp, monkeypatch):
panel.rotation_scan.connect(emitted.append)
panel._beamline_state = BeamlineStateEnum.SampleAlignment
- _editor(panel.image_time_enter).setText("0.0010") # below 1/900 s, stays red
+ panel.image_time_enter.setText("0.0010") # below 1/900 s, stays red
panel.run_measurement()
assert emitted == []
diff --git a/uv.lock b/uv.lock
index a3bbd4b1..5e34f601 100644
--- a/uv.lock
+++ b/uv.lock
@@ -11,7 +11,7 @@ overrides = [{ name = "opencv-python", marker = "sys_platform == 'nonexistent'"
[[package]]
name = "aarecommon"
-version = "0.5.4"
+version = "0.7.3"
source = { registry = "https://gitea.psi.ch/api/packages/mx/pypi/simple" }
dependencies = [
{ name = "jfjoch-client" },
@@ -24,14 +24,14 @@ dependencies = [
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
]
-sdist = { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aarecommon/0.5.4/aarecommon-0.5.4.tar.gz", hash = "sha256:3e4e2e5ee1dbe2690e6b6ee26598f2fd845f5f458bbe8b05282e24cf9fa27aca" }
+sdist = { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aarecommon/0.7.3/aarecommon-0.7.3.tar.gz", hash = "sha256:e85ddf9d3b740ca7536597b26cf839b159e96efc68845a6efb667a7dca5c4e0f" }
wheels = [
- { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aarecommon/0.5.4/aarecommon-0.5.4-py3-none-any.whl", hash = "sha256:ad0efe9d593532331dd898edf0498a5ccb992d11cfb3b7db037385c399258600" },
+ { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aarecommon/0.7.3/aarecommon-0.7.3-py3-none-any.whl", hash = "sha256:22e8c87e2800cbe9223341b43b448f4d0a4f2c199e59c27d17eed2a4b2e11531" },
]
[[package]]
name = "aaredaq"
-version = "0.21.3"
+version = "0.22.1"
source = { editable = "." }
dependencies = [
{ name = "aarecommon" },
@@ -82,8 +82,8 @@ test = [
[package.metadata]
requires-dist = [
- { name = "aarecommon", specifier = ">=0.5.1" },
- { name = "aaredb", specifier = ">=0.2", index = "https://gitea.psi.ch/api/packages/mx/pypi/simple" },
+ { name = "aarecommon", specifier = ">=0.7.3" },
+ { name = "aaredb", specifier = ">=0.83.1", index = "https://gitea.psi.ch/api/packages/mx/pypi/simple" },
{ name = "aarelcinfer-client", specifier = "==0.1.1a8" },
{ name = "aarescan-client", specifier = "==1.0.0rc5" },
{ name = "basedpyright", marker = "extra == 'test'" },
@@ -124,7 +124,7 @@ provides-extras = ["test", "docs"]
[[package]]
name = "aaredb"
-version = "0.6.0"
+version = "0.83.1"
source = { registry = "https://gitea.psi.ch/api/packages/mx/pypi/simple" }
dependencies = [
{ name = "pydantic" },
@@ -132,9 +132,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "urllib3" },
]
-sdist = { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aaredb/0.6.0/aaredb-0.6.0.tar.gz", hash = "sha256:c6e9a4d5c23b1efd0e558e30811685343b9ace20cb6dd433b3e43ae26b9af049" }
+sdist = { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aaredb/0.83.1/aaredb-0.83.1.tar.gz", hash = "sha256:9bbcf7cb9f165ae1217cb0392e48c820748976cadd3daf194b71a99c21de836e" }
wheels = [
- { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aaredb/0.6.0/aaredb-0.6.0-py3-none-any.whl", hash = "sha256:78cd04e09a9ae16843ded6a60322baf35f43d0d926c787544402b7a85d2924e3" },
+ { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aaredb/0.83.1/aaredb-0.83.1-py3-none-any.whl", hash = "sha256:cf7a2cd8171f7911b572b6367e5a8aabf9b10d731bddb724495273e8e17e2eb7" },
]
[[package]]