fix: keep user values in the scan panels across sample mounts
CI / lint (push) Skipped
CI / test (3.12) (push) Skipped
CI / test (3.13) (push) Skipped
CI / test-with-beamline-plugins (pxi_bec) (push) Skipped
CI / test-with-beamline-plugins (pxii_bec) (push) Skipped
CI / test-with-beamline-plugins (pxiii_bec) (push) Skipped

The panels kept the effective detector distance, resolution and
transmission in their own attributes, and a sample change overwrote those
from the spreadsheet whatever the Database/User toggle said. The fields
went on showing the user's numbers while the scan request carried the
database ones, so a value had to be re-entered after every mount.

The fields are now the only place a value lives. Each setting is worth,
in order: what the user typed while "User values" is selected, what the
mounted sample asks for, the panel default - one rule, ScanSettingsPanel
._setting, that each panel spells out a line at a time. Overrides are per
setting, so a setting the user did not touch keeps following the sample.

Along the way:

* DbOverrideLineEdit is gone; the panels use NumberLineEdit directly, and
  the per-widget database/user bookkeeping is replaced by SampleParameters,
  the one place that knows the aareDB spreadsheet column names.
* The (name, widget, converter) mapping loops with getattr/hasattr are
  replaced by explicit per-field commit slots.
* The omega speed cap no longer drives one field's validator from another
  field's value: it is a read-out plus a check on the pair at Run time.
* _add_row/_add_pair_row build the label/field/unit rows, which also puts
  the Total angle degree sign on its own row instead of the header above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHoUkj66jxByS2ypY5h9Mn
This commit is contained in:
2026-09-07 16:29:14 +02:00
co-authored by Claude Opus 5
parent abd061dd43
commit fd4b9b3122
6 changed files with 750 additions and 846 deletions
+75 -121
View File
@@ -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()
+161 -165
View File
@@ -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("<center><B><u>Screening</u></B></center>", 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("<center><B><u>Rotation</u></B></center>", 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),
]
+264 -250
View File
@@ -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,67 @@ 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)
def _as_float(value: Any) -> float | None:
"""Spreadsheet values arrive as numbers or as strings; an empty cell is None."""
return None if value is None else float(value)
@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()
transmission = _as_float(params.transmission)
if transmission is not None and transmission > 1.0:
# aareDB holds transmission either as a fraction or as a percentage
transmission = transmission / 100.0
return cls(
resolution_a=_as_float(params.targetresolution),
transmission=transmission,
total_angle_deg=_as_float(params.totalrange),
image_angle_deg=_as_float(params.oscillation),
exp_time_s=_as_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_<setting>`` 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,32 +114,30 @@ 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)
@@ -74,264 +151,201 @@ 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._database_radio = QRadioButton("Database values", container)
self._user_radio = QRadioButton("User values", container)
self._database_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._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(
+14 -145
View File
@@ -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()
+197 -148
View File
@@ -1,86 +1,71 @@
"""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, SampleGeometryModel
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():
params = types.SimpleNamespace(
targetresolution=1.5,
transmission=20.0, # percent
totalrange="180", # spreadsheet cells can arrive as text
oscillation=0.1,
exposure=0.02,
)
sample = cast(Any, types.SimpleNamespace(aaredb_params=params))
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_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_sample_parameters_are_empty_without_a_sample():
assert SampleParameters.from_sample(None) == SampleParameters()
# ---------------------------------------------------------------------------
# Panel: toggle + dtz/resolution coupling
# Rotation panel
# ---------------------------------------------------------------------------
@@ -104,71 +89,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 +247,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 +353,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 +390,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):
+39 -17
View File
@@ -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 == []