diff --git a/src/aare/gui/panels/raster_data_collection.py b/src/aare/gui/panels/raster_data_collection.py index 57f18ea9..bbbe74b4 100644 --- a/src/aare/gui/panels/raster_data_collection.py +++ b/src/aare/gui/panels/raster_data_collection.py @@ -1,6 +1,6 @@ from aarecommon.config.logger import setup_logger from aarecommon.math.diffraction_geometry import DiffractionGeometry -from aarecommon.models.models import BeamlineStateEnum, DAQStatusModel +from aarecommon.models.models import BeamlineStateEnum from PySide6.QtCore import Qt, Signal, Slot from PySide6.QtWidgets import QComboBox, QLabel, QMessageBox, QPushButton, QSlider @@ -8,7 +8,7 @@ from aare.gui.constants import LOGGER_NAME from aare.gui.panels.scan_settings_panel import ScanSettingsPanel from aare.gui.scan_logic.raster_grid_manager import RasterGridManager, RasterGridMetric from aare.gui.styles import ABORT_TEXT, GO_TEXT -from aare.gui.widgets.number_line_edit import DbOverrideLineEdit +from aare.gui.widgets.number_line_edit import NumberLineEdit from aare.gui.widgets.raster_grid_table import RasterGridTable logger = setup_logger(LOGGER_NAME) @@ -33,69 +33,45 @@ class RasterDataCollectionPanel(ScanSettingsPanel): parent=parent, ) - self._previous_sample_was_none_raster = True + self._default_exp_time = raster_mgr.active_grid.exp_time_s + # Image time typed in by the user; None means "follow the sample". + self._user_exp_time: float | None = None self._n_x = raster_mgr.active_grid.n_x self._n_y = raster_mgr.active_grid.n_y self._size_x = raster_mgr.active_grid.grid_size_mm.x * 1000.0 self._size_y = raster_mgr.active_grid.grid_size_mm.y * 1000.0 - self._total_time = raster_mgr.active_grid.exp_time_s * self._n_x * self._n_y + self._total_time = self._default_exp_time * self._n_x * self._n_y - self._layout.addWidget(QLabel("Grid element size", parent=self), 3, 0) - - self.width_enter = DbOverrideLineEdit(5, 100, default=self._size_x, decimals=0, parent=self) - self.width_enter.valueChanged.connect(self.grid_size) - self._register_override_field(self.width_enter) - self._layout.addWidget(self.width_enter, 3, 1) - - self._layout.addWidget(QLabel(" x ", parent=self), 3, 2) - - self.height_enter = DbOverrideLineEdit( - 5, 100, default=self._size_y, decimals=0, parent=self + # Grid element size: the drawn grid decides it, the user can ask for a + # different one. It has no spreadsheet counterpart, so it stays outside + # the Database/User toggle. + self.width_enter = NumberLineEdit( + 5, 100, self._size_x, decimals=0, parent=self, track_pending=True ) - self.height_enter.valueChanged.connect(self.grid_size) - self._register_override_field(self.height_enter) - - self._layout.addWidget(self.height_enter, 3, 3) - - self._layout.addWidget(QLabel("μm", parent=self), 3, 4) - - self._layout.addWidget(QLabel("Image time", parent=self), 4, 0) - self.image_time_enter = DbOverrideLineEdit( - 0.0005, 10.0, default=raster_mgr.active_grid.exp_time_s, decimals=4, parent=self + self.height_enter = NumberLineEdit( + 5, 100, self._size_y, decimals=0, parent=self, track_pending=True ) - self._layout.addWidget(self.image_time_enter, 4, 1, 1, 3) - self._layout.addWidget(QLabel("s", parent=self), 4, 4) - self.image_time_enter.valueChanged.connect(self.exp_time_s) - self._register_override_field(self.image_time_enter) + self._add_pair_row(3, "Grid element size", self.width_enter, self.height_enter, "μm") + for field in (self.width_enter, self.height_enter): + field.newValue.connect(self._on_element_size_committed) + self._add_field(field) - self._layout.addWidget(QLabel("Grid elements", parent=self), 5, 0) + self.image_time_enter = NumberLineEdit( + 0.0005, 10.0, self._default_exp_time, decimals=4, parent=self, track_pending=True + ) + self._add_row(4, "Image time", self.image_time_enter, "s") + self._add_database_field(self.image_time_enter, self._on_exp_time_committed) self.n_x_label = QLabel("-", parent=self) - self.n_x_label.setAlignment(Qt.AlignmentFlag.AlignRight) - self._layout.addWidget(self.n_x_label, 5, 1) - self._layout.addWidget(QLabel(" x ", parent=self), 5, 2) - self.n_y_label = QLabel("-", parent=self) - self.n_y_label.setAlignment(Qt.AlignmentFlag.AlignRight) - self._layout.addWidget(self.n_y_label, 5, 3) - - self._layout.addWidget(QLabel("Grid size", parent=self), 6, 0) + self._add_pair_row(5, "Grid elements", self.n_x_label, self.n_y_label) self.size_x_label = QLabel("-", parent=self) - self.size_x_label.setAlignment(Qt.AlignmentFlag.AlignRight) - self._layout.addWidget(self.size_x_label, 6, 1) - self._layout.addWidget(QLabel(" x ", parent=self), 6, 2) - self.size_y_label = QLabel("-", parent=self) - self.size_y_label.setAlignment(Qt.AlignmentFlag.AlignRight) - self._layout.addWidget(self.size_y_label, 6, 3) - - self._layout.addWidget(QLabel("μm", parent=self), 6, 4) + self._add_pair_row(6, "Grid size", self.size_x_label, self.size_y_label, "μm") self.metric_combo = QComboBox() - self._layout.addWidget(QLabel("Metric", parent=self), 7, 0) - self.metric_combo.addItem("Raster score", RasterGridMetric.RASTER_SCORE) self.metric_combo.addItem("Spot count (low res.)", RasterGridMetric.SPOTS_LOW_RES) self.metric_combo.addItem("Spot count", RasterGridMetric.SPOTS) @@ -110,25 +86,19 @@ class RasterDataCollectionPanel(ScanSettingsPanel): self.metric_combo.setCurrentIndex(self.metric_combo.findData(RasterGridMetric.RASTER_SCORE)) self.metric_combo.currentIndexChanged.connect(self.metric_changed) + self._add_row(7, "Metric", self.metric_combo) - self._layout.addWidget(self.metric_combo, 7, 1, 1, 3) - - self._layout.addWidget(QLabel("Transparency", parent=self), 8, 0) slider = QSlider(orientation=Qt.Orientation.Horizontal, parent=self) slider.setRange(0, 255) slider.setValue(127) slider.valueChanged.connect(lambda: self.raster_alpha_changed.emit(255 - slider.value())) - self._layout.addWidget(slider, 8, 1, 1, 3) + self._add_row(8, "Transparency", slider) self._table = RasterGridTable(raster_mgr) self._layout.addWidget(self._table, 9, 0, 1, 5) - self._layout.addWidget(QLabel("Measurement time", parent=self), 11, 0) self.total_time = QLabel(f"{self._total_time} min 0 s") - self.total_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - self._layout.addWidget(self.total_time, 11, 1, 1, 3) - - self.calculate_total_time() + self._add_row(11, "Measurement time", self.total_time) self.start_button = QPushButton("Evaluate grid") self.start_button.setStyleSheet(f"color: {GO_TEXT};") @@ -144,75 +114,68 @@ class RasterDataCollectionPanel(ScanSettingsPanel): self.abort_button = QPushButton("Abort measurement") self.abort_button.setStyleSheet(f"color: {ABORT_TEXT};") self._layout.addWidget(self.abort_button, 14, 0, 1, 5) - self._reset_to_defaults() + + # every field exists now: fill in the measurement time and the grid + self._values_changed() self.update_grid_scan_size() + # -- the panel's settings ---------------------------------------------- @Slot(float) - def grid_size(self, _: float): - self._size_x = self.width_enter.value - self._size_y = self.height_enter.value + def _on_exp_time_committed(self, value: float): + self._user_exp_time = value + self._user_took_over() + + def _write_active_values(self): + super()._write_active_values() + self.image_time_enter.set_committed_value( + self._setting(self._user_exp_time, self._db_params.exp_time_s, self._default_exp_time) + ) + + def _values_changed(self): + super()._values_changed() + self.exp_time_updated.emit(self.image_time_enter.committed_value) + self.calculate_total_time() + + # -- grid geometry ------------------------------------------------------ + @Slot(float) + def _on_element_size_committed(self, _value: float): + """The user asked for a different element size: the grid manager + recomputes how many elements fit and reports back to + grid_scan_size_change, which writes the size it settled on.""" + self._size_x = self.width_enter.committed_value + self._size_y = self.height_enter.committed_value self.grid_size_updated.emit(self._size_x / 1000.0, self._size_y / 1000.0) self.update_grid_scan_size() - @Slot(float) - def exp_time_s(self, inp: float): - self.exp_time_updated.emit(inp) - self.calculate_total_time() - @Slot(int, int, float, float) def grid_scan_size_change(self, n_x: int, n_y: int, size_x_mm: float, size_y_mm: float): + """The drawn grid changed (resized, or the beam size moved the default + element size).""" self._size_x = size_x_mm * 1000.0 self._size_y = size_y_mm * 1000.0 self._n_x = n_x self._n_y = n_y - self.calculate_total_time() self.update_grid_scan_size() - @Slot(DAQStatusModel) - def update_daq_status(self, s: DAQStatusModel): - super().update_daq_status(s) - can_edit = getattr(self, "_can_edit_params", False) - for w in (self.width_enter, self.height_enter, self.image_time_enter): - if hasattr(w, "set_busy"): - w.set_busy(not can_edit) - else: - w.setReadOnly(not can_edit) - - if s.sample is None and not self._previous_sample_was_none_raster: - # The override fields (grid size, image time) are reset by the base - # panel (_reset_to_defaults); nothing extra to reset here. - self._previous_sample_was_none_raster = True - elif s.sample is not None: - self._previous_sample_was_none_raster = False - self._beamline_state = s.state - def update_grid_scan_size(self): - # The live grid geometry is the "database"/system value for the element - # size; a user override persists and can be reverted via the toggle. - self.width_enter.set_db_value(self._size_x) - self.height_enter.set_db_value(self._size_y) + self.width_enter.set_committed_value(self._size_x) + self.height_enter.set_committed_value(self._size_y) self.n_x_label.setText(str(self._n_x)) self.size_x_label.setText(f"{self._size_x * self._n_x:.1f}") self.n_y_label.setText(str(self._n_y)) self.size_y_label.setText(f"{self._size_y * self._n_y:.1f}") - self.update_total_time_label() - try: - self.exp_time_s(self.image_time_enter.value) - except ValueError as e: - logger.warning(f"Invalid exposure time: {e} reseting to default") - self.exp_time_s(0.02) + self.calculate_total_time() def metric_changed(self, _: int): self.grid_metric_updated.emit(self.metric_combo.currentData()) - def get_parameter_mappings(self): - """Return raster-specific parameter mappings.""" - return [ - ("exposure", self.image_time_enter, None) - # Add other raster-specific parameters here as needed - ] - - def update_total_time_label(self): + def calculate_total_time(self): + exp_time_s = self.image_time_enter.committed_value + if self._n_x <= 0 or self._n_y <= 0 or exp_time_s < 0: + self._total_time = 0.0 + else: + # 30% buffer added + self._total_time = self._n_x * self._n_y * exp_time_s * 1.3 mins = int(self._total_time // 60) secs = round(self._total_time % 60) if secs == 60: @@ -220,29 +183,20 @@ class RasterDataCollectionPanel(ScanSettingsPanel): secs = 0 self.total_time.setText(f"{mins} min {secs} s") - def calculate_total_time(self): - if self._n_x <= 0 or self._n_y <= 0 or self.image_time_enter.value < 0: - self._total_time = 0.0 - else: - self._total_time = self._n_x * self._n_y * self.image_time_enter.value * 1.3 - # 30% buffer added - # Show minutes - self.update_total_time_label() - - @Slot() - def _on_evaluate_clicked(self): + # -- running ------------------------------------------------------------ + def _ready_to_run(self) -> bool: if self._beamline_state != BeamlineStateEnum.SampleAlignment: logger.error(f"Beamline state {self._beamline_state} is not Sample Alignment") QMessageBox.critical(None, "Error", "Beamline state is not Sample Alignment") - return - if self.check_before_run(scan_kind="raster"): + return False + return self.check_before_run(scan_kind="raster") + + @Slot() + def _on_evaluate_clicked(self): + if self._ready_to_run(): self.evaluate_grid.emit() @Slot() def _on_evaluate_auto_clicked(self): - if self._beamline_state != BeamlineStateEnum.SampleAlignment: - logger.error(f"Beamline state {self._beamline_state} is not Sample Alignment") - QMessageBox.critical(None, "Error", "Beamline state is not Sample Alignment") - return - if self.check_before_run(scan_kind="raster"): + if self._ready_to_run(): self.evaluate_grid_auto.emit() diff --git a/src/aare/gui/panels/rotation_data_collection.py b/src/aare/gui/panels/rotation_data_collection.py index 3899087a..8c7ca3b2 100644 --- a/src/aare/gui/panels/rotation_data_collection.py +++ b/src/aare/gui/panels/rotation_data_collection.py @@ -6,13 +6,13 @@ from aarecommon.math.diffraction_geometry import DiffractionGeometry from aarecommon.models.beamline import MXBeamline from aarecommon.models.models import BeamlineStateEnum, DAQStatusModel from aarecommon.models.rotation_scan import RotationScanRequest -from PySide6.QtCore import Qt, Signal, Slot +from PySide6.QtCore import Signal, Slot from PySide6.QtWidgets import QComboBox, QLabel, QMessageBox, QPushButton from aare.gui.constants import LOGGER_NAME from aare.gui.panels.scan_settings_panel import ScanSettingsPanel from aare.gui.styles import ABORT_TEXT, GO_TEXT -from aare.gui.widgets.number_line_edit import DbOverrideLineEdit, NumberLineEdit +from aare.gui.widgets.number_line_edit import NumberLineEdit logger = setup_logger(LOGGER_NAME) @@ -21,6 +21,15 @@ MAX_OMEGA_SPEED_DEG_S = 500.0 MIN_EXP_TIME_S = {MXBeamline.X06DA: 1 / 900, MXBeamline.X10SA: 1 / 120} +def omega_speed_deg_s(image_angle_deg: float, exp_time_s: float) -> float: + """How fast omega has to turn to cover one image in one exposure. The + goniometer caps this, and it is a limit on the pair - neither number is + wrong on its own - so no single field's validator can express it.""" + if exp_time_s <= 0: + return float("inf") + return image_angle_deg / exp_time_s + + def add_screening_to_path(path): p = Path(path) return "screening" / p @@ -54,26 +63,28 @@ class RotationDataCollectionPanel(ScanSettingsPanel): ), ) - self._beamline_state = None - self._curr_pgroup = "p11206" self._filename = "" - self._omega = 0 self._dose_mgy = 0 self._total_time = 0.0 self._min_exp_time_s = MIN_EXP_TIME_S.get(mx_beamline(), 0.0005) - self._layout.addWidget(QLabel("Start angle", parent=self), 3, 0) - self.start_angle = NumberLineEdit( - -720, 720.0, 0.0, decimals=3, parent=self, track_pending=True - ) - self._layout.addWidget(self.start_angle, 3, 1, 1, 2) - self._layout.addWidget(QLabel("°", parent=self), 3, 3) + # Settings the user typed in; None means "follow the mounted sample". + # The screening fields are not here: they have no spreadsheet + # counterpart, so they always keep whatever the user left in them. + self._user_total_angle: float | None = None + self._user_image_angle: float | None = None + self._user_exp_time: float | None = None self.omega_button = QPushButton("Ω") self.omega_button.setFixedWidth(30) self.omega_button.clicked.connect(self.update_omega_start) - self._layout.addWidget(self.omega_button, 3, 4) + + self.start_angle = NumberLineEdit( + -720, 720.0, 0.0, decimals=3, parent=self, track_pending=True + ) + self._add_row(3, "Start angle", self.start_angle, "°", trailing=self.omega_button) + self._add_field(self.start_angle) self._layout.addWidget( QLabel("
Screening
", parent=self), 4, 0, 1, 6 @@ -91,14 +102,12 @@ class RotationDataCollectionPanel(ScanSettingsPanel): self._layout.addWidget(self.screening_type, 5, 0, 1, 6) - self._layout.addWidget(QLabel("Image angle", parent=self), 6, 0) self.screening_image_angle = NumberLineEdit( 0, 90.0, 0.5, decimals=3, parent=self, track_pending=True ) - self._layout.addWidget(self.screening_image_angle, 6, 1, 1, 3) - self._layout.addWidget(QLabel("°", parent=self), 6, 4) + self._add_row(6, "Image angle", self.screening_image_angle, "°") + self._add_field(self.screening_image_angle) - self._layout.addWidget(QLabel("Image time", parent=self), 7, 0) default_screening_exp_time = cfg_get( "daq.data_collection_settings.default_screening_settings.exp_time_s", 0.1 ) @@ -110,15 +119,14 @@ class RotationDataCollectionPanel(ScanSettingsPanel): parent=self, track_pending=True, ) - self._layout.addWidget(self.screening_image_time_enter, 7, 1, 1, 3) - self._layout.addWidget(QLabel("s", parent=self), 7, 4) + self._add_row(7, "Image time", self.screening_image_time_enter, "s") + self._add_field(self.screening_image_time_enter) - self._layout.addWidget(QLabel("Screening transmission", parent=self), 8, 0) - self.screening_transmission_enter = DbOverrideLineEdit( - 0, 1.0, default=self._transmission, decimals=4, parent=self + self.screening_transmission_enter = NumberLineEdit( + 0, 1.0, self._default_transmission, decimals=4, parent=self, track_pending=True ) - self._layout.addWidget(self.screening_transmission_enter, 8, 1, 1, 3) - self.screening_transmission_enter.valueChanged.connect(self.set_screening_transmission) + self._add_row(8, "Screening transmission", self.screening_transmission_enter) + self._add_field(self.screening_transmission_enter) self.screening_button = QPushButton("Run screening") self.screening_button.setStyleSheet(f"color: {GO_TEXT};") @@ -129,55 +137,48 @@ class RotationDataCollectionPanel(ScanSettingsPanel): QLabel("
Rotation
", parent=self), 10, 0, 1, 6 ) - self._layout.addWidget(QLabel("Total angle", parent=self), 11, 0) default_steps = cfg_get( "daq.data_collection_settings.default_rotation_settings.steps", 1800 ) - default_increment_omega = cfg_get( + self._default_image_angle = cfg_get( "daq.data_collection_settings.default_rotation_settings.increment_omega_deg", 0.2 ) - default_total_angle = default_steps * default_increment_omega - self.total_angle = DbOverrideLineEdit( - 0, 9999.0, default=default_total_angle, decimals=3, parent=self - ) - self._layout.addWidget(self.total_angle, 11, 1, 1, 3) - self._layout.addWidget(QLabel("°", parent=self), 10, 4) - self._register_override_field(self.total_angle) - - self._layout.addWidget(QLabel("Image angle", parent=self), 12, 0) - self.image_angle = DbOverrideLineEdit( - 0, 10.0, default=default_increment_omega, decimals=3, parent=self - ) - self._layout.addWidget(self.image_angle, 12, 1, 1, 3) - self._layout.addWidget(QLabel("°", parent=self), 12, 4) - self._register_override_field(self.image_angle) - self._layout.addWidget(QLabel("Image time", parent=self), 13, 0) - default_image_exp_time = cfg_get( + self._default_total_angle = default_steps * self._default_image_angle + self._default_exp_time = cfg_get( "daq.data_collection_settings.default_rotation_settings.exp_time_s", 0.01 ) - self.image_time_enter = DbOverrideLineEdit( - self._min_exp_time_s, 10.0, default=default_image_exp_time, decimals=4, parent=self + + self.total_angle = NumberLineEdit( + 0, 9999.0, self._default_total_angle, decimals=3, parent=self, track_pending=True ) - self._layout.addWidget(self.image_time_enter, 13, 1, 1, 3) - self._layout.addWidget(QLabel("s", parent=self), 13, 4) - self._register_override_field(self.image_time_enter) + self._add_row(11, "Total angle", self.total_angle, "°") + self._add_database_field(self.total_angle, self._on_total_angle_committed) + + self.image_angle = NumberLineEdit( + 0, 10.0, self._default_image_angle, decimals=3, parent=self, track_pending=True + ) + self._add_row(12, "Image angle", self.image_angle, "°") + self._add_database_field(self.image_angle, self._on_image_angle_committed) + + self.image_time_enter = NumberLineEdit( + self._min_exp_time_s, + 10.0, + self._default_exp_time, + decimals=4, + parent=self, + track_pending=True, + ) + self._add_row(13, "Image time", self.image_time_enter, "s") + self._add_database_field(self.image_time_enter, self._on_exp_time_committed) + + self.omega_speed = QLabel("-") + self._add_row(14, "Rotation speed", self.omega_speed, "°/s") - self._layout.addWidget(QLabel("Total measurement time", parent=self), 15, 0) self.total_time = QLabel(f"{self._total_time} min 0 s") - self.total_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - self._layout.addWidget(self.total_time, 15, 1, 1, 3) + self._add_row(15, "Total measurement time", self.total_time) - self.total_angle.valueChanged.connect(self.calculate_measurement_time) - self.image_angle.valueChanged.connect(self.calculate_measurement_time) - self.image_time_enter.valueChanged.connect(self.calculate_measurement_time) - # Initial compute - self.calculate_measurement_time() - - self._layout.addWidget(QLabel("Dose", parent=self), 16, 0) self.dose = QLabel(f"{self._dose_mgy}") - self.dose.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - self._layout.addWidget(self.dose, 16, 1, 1, 3) - self._layout.addWidget(QLabel("MGy", parent=self), 16, 4) + self._add_row(16, "Dose", self.dose, "MGy") self.measurement_button = QPushButton("Run rotation") self.measurement_button.setStyleSheet(f"color: {GO_TEXT};") @@ -188,37 +189,92 @@ class RotationDataCollectionPanel(ScanSettingsPanel): self.abort_button = QPushButton("Abort measurement") self.abort_button.setStyleSheet(f"color: {ABORT_TEXT};") self._layout.addWidget(self.abort_button, 18, 0, 1, 6) - self._reset_to_defaults() - # Speed cap couples each angle/time pair: recompute limits whenever either commits. - self.screening_image_angle.newValue.connect(self._update_speed_limits) - self.screening_image_time_enter.newValue.connect(self._update_speed_limits) - self.image_angle.valueChanged.connect(self._update_speed_limits) - self.image_time_enter.valueChanged.connect(self._update_speed_limits) - self._update_speed_limits() + # every field exists now: fill in the speed and measurement time + self._values_changed() - @Slot() - def _update_speed_limits(self): - for angle, time, angle_max in ( - (self.screening_image_angle, self.screening_image_time_enter, 90.0), - (self.image_angle, self.image_time_enter, 10.0), - ): - angle.update_limits(0, min(angle_max, MAX_OMEGA_SPEED_DEG_S * time.value)) - time.update_limits(max(self._min_exp_time_s, angle.value / MAX_OMEGA_SPEED_DEG_S), 10.0) + # -- the panel's settings ---------------------------------------------- + @Slot(float) + def _on_total_angle_committed(self, value: float): + self._user_total_angle = value + self._user_took_over() - def _fields_out_of_range(self, *fields) -> bool: - """True (plus error box) when a field sits outside its current limits. - Needed because QDoubleValidator only paints the field red; ``.value`` - still reads the raw text, so without this gate the request goes out.""" - if all(getattr(f, "editor", f).hasAcceptableInput() for f in fields): - return False - msg = ( - "Image angle / Image time outside limits " - f"(max {MAX_OMEGA_SPEED_DEG_S:.0f} °/s, min image time {self._min_exp_time_s:.4f} s)" + @Slot(float) + def _on_image_angle_committed(self, value: float): + self._user_image_angle = value + self._user_took_over() + + @Slot(float) + def _on_exp_time_committed(self, value: float): + self._user_exp_time = value + self._user_took_over() + + def _write_active_values(self): + super()._write_active_values() + db = self._db_params + self.total_angle.set_committed_value( + self._setting(self._user_total_angle, db.total_angle_deg, self._default_total_angle) ) + self.image_angle.set_committed_value( + self._setting(self._user_image_angle, db.image_angle_deg, self._default_image_angle) + ) + self.image_time_enter.set_committed_value( + self._setting(self._user_exp_time, db.exp_time_s, self._default_exp_time) + ) + + def _values_changed(self): + super()._values_changed() + self._update_speed_readout() + self._update_measurement_time() + + # -- derived read-outs -------------------------------------------------- + def _update_speed_readout(self): + speed = omega_speed_deg_s( + self.image_angle.committed_value, self.image_time_enter.committed_value + ) + self.omega_speed.setText("-" if speed == float("inf") else f"{speed:.1f}") + too_fast = speed > MAX_OMEGA_SPEED_DEG_S + # A red read-out rather than a red field: the cap is on the pair. + self.omega_speed.setStyleSheet(f"color: {ABORT_TEXT};" if too_fast else "") + + def image_number(self) -> int: + total_angle = self.total_angle.committed_value + image_angle = self.image_angle.committed_value + if total_angle <= 0 or total_angle < image_angle or image_angle <= 0: + logger.warning(f"Cannot count images for {total_angle}° in steps of {image_angle}°") + return 0 + return round(total_angle / image_angle) + + def _update_measurement_time(self): + self._total_time = self.image_number() * self.image_time_enter.committed_value + mins = int(self._total_time // 60) + secs = round(self._total_time % 60) + if secs == 60: + mins += 1 + secs = 0 + self.total_time.setText(f"{mins} min {secs} s") + + # -- running ------------------------------------------------------------ + def _pair_is_runnable(self, angle: NumberLineEdit, time: NumberLineEdit) -> bool: + """Both halves of an image angle / image time pair have to be inside + their own limits, and together they have to stay under the omega speed + cap. QDoubleValidator only paints a field red, so a run has to ask.""" + speed = omega_speed_deg_s(angle.committed_value, time.committed_value) + if not (angle.hasAcceptableInput() and time.hasAcceptableInput()): + msg = ( + "Image angle / image time outside limits " + f"(min image time {self._min_exp_time_s:.4f} s)" + ) + elif speed > MAX_OMEGA_SPEED_DEG_S: + msg = ( + f"{angle.committed_value:.3f}° in {time.committed_value:.4f} s needs " + f"{speed:.0f} °/s, above the {MAX_OMEGA_SPEED_DEG_S:.0f} °/s limit" + ) + else: + return True logger.error(msg) QMessageBox.critical(self, "Error", msg) - return True + return False @Slot() def run_screening(self): @@ -226,7 +282,7 @@ class RotationDataCollectionPanel(ScanSettingsPanel): logger.error(f"Beamline state {self._beamline_state} is not Sample Alignment") QMessageBox.critical(None, "Error", "Beamline state is not Sample Alignment") return - if self._fields_out_of_range(self.screening_image_angle, self.screening_image_time_enter): + if not self._pair_is_runnable(self.screening_image_angle, self.screening_image_time_enter): return if not self.check_before_run(scan_kind="screening"): logger.error("Cannot run measurement because of check") @@ -240,26 +296,26 @@ class RotationDataCollectionPanel(ScanSettingsPanel): if is_single_image: r = RotationScanRequest( file_prefix=str(add_screening_to_path(self._filename)), - start_omega_deg=self.start_angle.value, - incr_omega_deg=self.screening_image_angle.value, + start_omega_deg=self.start_angle.committed_value, + incr_omega_deg=self.screening_image_angle.committed_value, wedge_omega_deg=screening_settings["omega_step_deg"], steps=1, - dtz=self._dtz, - transmission=self._screening_transmission, + dtz=self.dtz_enter.committed_value, + transmission=self.screening_transmission_enter.committed_value, screening=True, - exp_time_s=self.screening_image_time_enter.value, + exp_time_s=self.screening_image_time_enter.committed_value, ) else: r = RotationScanRequest( file_prefix=str(add_screening_to_path(self._filename)), - start_omega_deg=self.start_angle.value, - wedge_omega_deg=self.screening_image_angle.value, + start_omega_deg=self.start_angle.committed_value, + wedge_omega_deg=self.screening_image_angle.committed_value, incr_omega_deg=screening_settings["omega_step_deg"], steps=screening_settings["steps"], - dtz=self._dtz, - transmission=self._screening_transmission, + dtz=self.dtz_enter.committed_value, + transmission=self.screening_transmission_enter.committed_value, screening=True, - exp_time_s=self.screening_image_time_enter.value, + exp_time_s=self.screening_image_time_enter.committed_value, ) self.rotation_scan.emit(r) @@ -271,58 +327,25 @@ class RotationDataCollectionPanel(ScanSettingsPanel): logger.error(f"Beamline state {self._beamline_state} is not Sample Alignment") QMessageBox.critical(None, "Error", "Beamline state is not Sample Alignment") return - if self._fields_out_of_range(self.image_angle, self.image_time_enter): + if not self._pair_is_runnable(self.image_angle, self.image_time_enter): return if not self.check_before_run(scan_kind="rotation"): logger.error("Cannot run measurement because of check") return r = RotationScanRequest( file_prefix=str(add_data_to_path(self._filename)), - start_omega_deg=self.start_angle.value, + start_omega_deg=self.start_angle.committed_value, steps=self.image_number(), - incr_omega_deg=self.image_angle.value, - dtz=self._dtz, - transmission=self._transmission, + incr_omega_deg=self.image_angle.committed_value, + dtz=self.dtz_enter.committed_value, + transmission=self.transmission_enter.committed_value, screening=False, - exp_time_s=self.image_time_enter.value, + exp_time_s=self.image_time_enter.committed_value, ) self.rotation_scan.emit(r) self.viewer_track_online.emit() - def image_number(self): - total_angle = self.total_angle.value - image_angle = self.image_angle.value - if total_angle <= 0 or total_angle < image_angle: - print("Total angle is not valid.") - return 0 - elif image_angle <= 0: - print("Image angle is not valid.") - return 0 - - return round(total_angle / image_angle) - - def update_total_time_label(self): - mins = int(self._total_time // 60) - secs = round(self._total_time % 60) - if secs == 60: - mins += 1 - secs = 0 - self.total_time.setText(f"{mins} min {secs} s") - - def calculate_measurement_time(self): - if ( - self.image_angle.value <= 0 - or self.total_angle.value <= 0 - or self.image_time_enter.value < 0 - ): - self._total_time = 0.0 - self.update_total_time_label() - return - self._total_time = ( - self.total_angle.value / self.image_angle.value - ) * self.image_time_enter.value - self.update_total_time_label() - + # -- status ------------------------------------------------------------- @Slot(str) def update_filename(self, filename: str): self._filename = filename @@ -332,41 +355,14 @@ class RotationDataCollectionPanel(ScanSettingsPanel): super().update_daq_status(s) self._omega = s.geom.omega_deg - can_edit = getattr(self, "_can_edit_params", False) - for w in ( - self.start_angle, - self.screening_image_angle, - self.screening_image_time_enter, - self.total_angle, - self.image_angle, - self.image_time_enter, - ): - if hasattr(w, "set_busy"): - w.set_busy(not can_edit) - else: - w.setReadOnly(not can_edit) - - self.calculate_measurement_time() - if s.session.current_pgroup: - self._curr_pgroup = s.session.current_pgroup lambda_a = s.diffraction.wavelength_angstrom kdose = 2000 / (lambda_a * lambda_a) beam_area = s.geom.beam_size_mm.x * s.geom.beam_size_mm.y * 1e6 - time = self.image_number() * self.image_time_enter.value - - self._dose_mgy = (time * s.bl.flux_ph_s * self._transmission) / (beam_area * kdose) + transmission = self.transmission_enter.committed_value + self._dose_mgy = (self._total_time * s.bl.flux_ph_s * transmission) / (beam_area * kdose) self.dose.setText(f"{(self._dose_mgy / 1e6):.1f}") - self._beamline_state = s.state @Slot() def update_omega_start(self): if not self.start_angle.isReadOnly(): self.start_angle.force_update_value(self._omega) - - def get_parameter_mappings(self): - """Return rotation-specific parameter mappings.""" - return [ - ("totalrange", self.total_angle, lambda v: float(v)), - ("oscillation", self.image_angle, None), - ("exposure", self.image_time_enter, None), - ] diff --git a/src/aare/gui/panels/scan_settings_panel.py b/src/aare/gui/panels/scan_settings_panel.py index 65a5e6b1..04d67301 100644 --- a/src/aare/gui/panels/scan_settings_panel.py +++ b/src/aare/gui/panels/scan_settings_panel.py @@ -1,7 +1,36 @@ +"""Common part of the scan settings panels (Rotation, Raster). + +Where the numbers in the fields come from +----------------------------------------- +Every setting is worth one of three things, in this order: + +1. the value the user typed into it, while "User values" is selected, +2. the value the mounted sample asks for - its row in the aareDB spreadsheet, +3. the panel default, for a spreadsheet cell the user left empty. + +``ScanSettingsPanel._setting`` is that rule, and each panel spells out its +settings one line at a time in ``_write_active_values``. + +Committing a field - Enter, or leaving it - stores the number as the user's and +selects "User values". Only that one setting becomes the user's: everything +they did not touch keeps following the sample, so a new sample still brings its +own exposure time, angles and resolution. The toggle switches all of them back +and forth without forgetting anything. + +A number that was typed but never committed does not count: the field shows it +in the pending colour and the panel keeps using the value it had. What a scan +collects is read straight from the fields' committed values, so the panels keep +no second copy of them. +""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + from aarecommon.config.logger import setup_logger from aarecommon.math.diffraction_geometry import DiffractionGeometry -from aarecommon.models.models import DAQStatusModel, SessionsStateEnum -from PySide6.QtCore import Signal, Slot +from aarecommon.models.models import DAQStatusModel, SampleShortInfo, SessionsStateEnum +from PySide6.QtCore import Qt, Signal, Slot from PySide6.QtWidgets import ( QButtonGroup, QGridLayout, @@ -14,17 +43,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_`` 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( diff --git a/src/aare/gui/widgets/number_line_edit.py b/src/aare/gui/widgets/number_line_edit.py index 13584a64..e9ea2bed 100644 --- a/src/aare/gui/widgets/number_line_edit.py +++ b/src/aare/gui/widgets/number_line_edit.py @@ -105,6 +105,20 @@ class NumberLineEdit(QLineEdit): def value(self) -> float: return float(self.text()) + @property + def committed_value(self) -> float: + """The value that was applied last, by Enter/focus-out or by a + programmatic write. Unlike ``value`` it ignores text that is still + being typed, and it never raises on a half-written number.""" + return self._applied_value + + def set_committed_value(self, val: float): + """Show ``val`` as the committed value, unless the field already shows + it. Skipping the rewrite keeps a refresh that changes nothing from + stealing text the user is in the middle of typing.""" + if self.to_string(val) != self.to_string(self._applied_value): + self.force_update_value(val) + @Slot(float) def update_value(self, val: float): if abs(val - self.saved_value) > 0.001: @@ -267,148 +281,3 @@ class CheckedLineEdit(QWidget): def reset_to_default(self): # Force set to initial default self.force_update_value(self._internal_value) - - -class DbOverrideLineEdit(QWidget): - """A numeric field backed by two sources: a database value and the user's - own value, with a panel-level toggle choosing which is shown. - - Behaviour: - * Until the user edits the field, "mine" mirrors the database value, so a - fresh field just follows the database. - * Editing records the user's value, marks the field as user-edited, flips - the source to "mine", and emits ``edited`` so the owning panel can move - the whole panel to "My values". - * Switching back to "Database values" shows the database value but keeps - the user's value, so it can be recovered by switching to "My values". - * Database updates (new sample, status refresh) only overwrite the - database value; a user-edited value therefore persists across samples. - - The widget has no checkbox of its own - the panel drives ``set_source`` for - all of its fields together. - """ - - # active value changed (user edit, source toggle, or db update while showing db) - valueChanged = Signal(float) - # user finished editing -> the owning panel should switch to "My values" - edited = Signal(float) - - SOURCE_DB = "db" - SOURCE_MINE = "mine" - - def __init__( - self, - min_val: float, - max_val: float, - /, - default: float = 0.0, - decimals: int = 2, - parent=None, - ): - super().__init__(parent) - self._default = float(default) - self._db_value = float(default) - self._mine_value = float(default) - self._user_edited = False - self._source = self.SOURCE_DB - self._busy = False - - # pending color while typing: every DbOverride field lives in the - # Experiment configuration group, which opted into the feedback - self.editor = NumberLineEdit(min_val, max_val, default, decimals, self, track_pending=True) - self.editor.newValue.connect(self._on_editor_value) - - layout = QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self.editor) - self._apply_editable() - - # -- internal helpers -------------------------------------------------- - def _active_value(self) -> float: - return self._mine_value if self._source == self.SOURCE_MINE else self._db_value - - def _refresh_display(self): - self.editor.force_update_value(self._active_value()) - - def _apply_editable(self): - # Editable whenever the panel is not busy; editing implies "mine". - self.editor.setReadOnly(self._busy) - - def _on_editor_value(self, value: float): - self._user_edited = True - self._mine_value = value - self._source = self.SOURCE_MINE - self.valueChanged.emit(value) - self.edited.emit(value) - - # -- panel-facing API -------------------------------------------------- - def set_source(self, source: str, emit: bool = True): - """Show the database value or the user's value. The panel switches all - of its fields with emit=False, then calls emit_value_changed() once each - so downstream consumers resync after every field is in the new source.""" - if source not in (self.SOURCE_DB, self.SOURCE_MINE): - return - self._source = source - self._refresh_display() - if emit: - self.valueChanged.emit(self._active_value()) - - def emit_value_changed(self): - self.valueChanged.emit(self._active_value()) - - def source(self) -> str: - return self._source - - def set_db_value(self, value: float): - """Update the database value. Mirrors into the user's value while the - field has not been user-edited, so untouched fields follow the database; - a user-edited value is preserved (only the db side changes), which is - what lets a user's value persist across samples.""" - value = float(value) - changed = value != self._db_value - self._db_value = value - if not self._user_edited: - self._mine_value = value - # Only refresh/emit when the shown value actually changes, so repeated - # database pushes (e.g. live grid geometry) cannot feed back into a loop. - if self._source == self.SOURCE_DB and changed: - self._refresh_display() - self.valueChanged.emit(value) - - def set_mine_silently(self, value: float): - """Set the user's value from a linked field (e.g. the dtz<->resolution - pair) without emitting. Marks the field user-edited and refreshes the - display if the user's value is currently shown. No signal is emitted, so - the linked field (which drives the physics downstream) does not feed back - into a loop.""" - value = float(value) - self._mine_value = value - self._user_edited = True - if self._source == self.SOURCE_MINE: - self._refresh_display() - - def has_user_value(self) -> bool: - return self._user_edited - - def set_busy(self, busy: bool): - self._busy = busy - self._apply_editable() - - def update_limits(self, min_val: float, max_val: float): - self.editor.update_limits(min_val, max_val) - - def reset(self): - """Forget the user's value and return to the constructor default, - following the database again (used when the sample is cleared).""" - self._user_edited = False - self._db_value = self._default - self._mine_value = self._default - self._source = self.SOURCE_DB - self._refresh_display() - - @property - def value(self) -> float: - return self.editor.value - - def isReadOnly(self) -> bool: - return self.editor.isReadOnly() diff --git a/tests/unit/gui/test_data_collection_settings.py b/tests/unit/gui/test_data_collection_settings.py index 30d093c4..a1a45d2c 100644 --- a/tests/unit/gui/test_data_collection_settings.py +++ b/tests/unit/gui/test_data_collection_settings.py @@ -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): diff --git a/tests/unit/gui/test_rotation_speed_limits.py b/tests/unit/gui/test_rotation_speed_limits.py index a0f652de..5299fd69 100644 --- a/tests/unit/gui/test_rotation_speed_limits.py +++ b/tests/unit/gui/test_rotation_speed_limits.py @@ -1,7 +1,9 @@ """Omega speed cap (500 deg/s) and per-beamline min image time on the rotation panel. -Why: QDoubleValidator only paints a field red, so the interlock lives in the -linked limits plus the Run-button gate. These fail if either half breaks. +Why: the image angle and the image time are each fine on their own - it is the +pair that can ask the goniometer to turn too fast. So the cap is a read-out +plus a gate on the Run button, and only the per-beamline minimum image time is +a limit of the field itself. """ import pytest @@ -29,19 +31,20 @@ def _panel(monkeypatch, beamline): return RotationDataCollectionPanel(diffraction=diffraction) -def _editor(field): - # NumberLineEdit is the editor itself; DbOverrideLineEdit wraps one in .editor - return getattr(field, "editor", field) +def _runnable(panel, monkeypatch): + panel._beamline_state = BeamlineStateEnum.SampleAlignment + monkeypatch.setattr(panel, "check_before_run", lambda scan_kind: True) + return panel def _commit(field, text): - _editor(field).setText(text) - _editor(field).on_editing_finished() + field.setText(text) + field.on_editing_finished() def _accepts(field, text) -> bool: - _editor(field).setText(text) - return _editor(field).hasAcceptableInput() + field.setText(text) + return field.hasAcceptableInput() @pytest.mark.parametrize( @@ -54,16 +57,35 @@ def test_min_image_time_per_beamline(qapp, monkeypatch, beamline, too_short, ok) assert _accepts(field, ok) -def test_speed_cap_couples_angle_and_time(qapp, monkeypatch): +def test_speed_readout_follows_the_pair(qapp, monkeypatch): panel = _panel(monkeypatch, "X06DA") - _commit(panel.image_angle, "1.000") # -> time floor 1/500 = 0.002 s - assert not _accepts(panel.image_time_enter, "0.0010") - assert _accepts(panel.image_time_enter, "0.0020") + _commit(panel.image_angle, "1.000") + _commit(panel.image_time_enter, "0.1000") + assert panel.omega_speed.text() == "10.0" + assert not panel.omega_speed.styleSheet() # within the cap - _commit(panel.image_time_enter, "0.0100") # -> angle max 5 deg - assert not _accepts(panel.image_angle, "6.000") - assert _accepts(panel.image_angle, "4.000") + _commit(panel.image_time_enter, "0.0012") # 833 deg/s + assert panel.omega_speed.text() == "833.3" + assert panel.omega_speed.styleSheet() # marked red + + +def test_run_blocked_above_the_speed_cap(qapp, monkeypatch): + panel = _runnable(_panel(monkeypatch, "X06DA"), monkeypatch) + boxes = [] + monkeypatch.setattr(QMessageBox, "critical", lambda *a, **k: boxes.append(a)) + emitted = [] + panel.rotation_scan.connect(emitted.append) + + _commit(panel.image_angle, "1.000") + _commit(panel.image_time_enter, "0.0012") # 833 deg/s, over the cap + panel.run_measurement() + assert emitted == [] + assert len(boxes) == 1 + + _commit(panel.image_time_enter, "0.0100") # 100 deg/s + panel.run_measurement() + assert len(emitted) == 1 def test_run_blocked_while_field_red(qapp, monkeypatch): @@ -74,7 +96,7 @@ def test_run_blocked_while_field_red(qapp, monkeypatch): panel.rotation_scan.connect(emitted.append) panel._beamline_state = BeamlineStateEnum.SampleAlignment - _editor(panel.image_time_enter).setText("0.0010") # below 1/900 s, stays red + panel.image_time_enter.setText("0.0010") # below 1/900 s, stays red panel.run_measurement() assert emitted == []