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
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:
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
Reference in New Issue
Block a user