diff --git a/src/aare/gui/panels/raster_data_collection.py b/src/aare/gui/panels/raster_data_collection.py index f83b1b4f..ad77223f 100644 --- a/src/aare/gui/panels/raster_data_collection.py +++ b/src/aare/gui/panels/raster_data_collection.py @@ -5,7 +5,7 @@ from aare.common.diffraction_geometry import DiffractionGeometry from aare.common.models import DAQStatusModel, BeamlineStateEnum from aare.gui.panels.scan_settings_panel import ScanSettingsPanel from aare.gui.scan_logic.raster_grid_manager import RasterGridManager, RasterGridMetric -from aare.gui.widgets.number_line_edit import CheckedLineEdit +from aare.gui.widgets.number_line_edit import DbOverrideLineEdit from aare.gui.widgets.raster_grid_table import RasterGridTable from aare.common.logger_config import setup_logger @@ -35,27 +35,29 @@ class RasterDataCollectionPanel(ScanSettingsPanel): self._layout.addWidget(QLabel("Grid element size", parent=self), 3, 0) - self.width_enter = CheckedLineEdit(5, 100, decimals=0, default=self.__size_x, parent=self) - self.width_enter.newValue.connect(self.grid_size) + 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 = CheckedLineEdit(5, 100, decimals=0, default=self.__size_y, parent=self) - self.height_enter.newValue.connect(self.grid_size) - + self.height_enter = DbOverrideLineEdit(5, 100, default=self.__size_y, decimals=0, parent=self) + 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 = CheckedLineEdit( - 0.0005, 10.0, raster_mgr.active_grid.exp_time_s, decimals=4, check_box_text='', parent=self + self.image_time_enter = DbOverrideLineEdit( + 0.0005, 10.0, default=raster_mgr.active_grid.exp_time_s, decimals=4, parent=self ) self._layout.addWidget(self.image_time_enter, 4, 1, 1, 3) self._layout.addWidget(QLabel("s", parent=self), 4, 4) - self.image_time_enter.newValue.connect(self.exp_time_s) + self.image_time_enter.valueChanged.connect(self.exp_time_s) + self._register_override_field(self.image_time_enter) self._layout.addWidget(QLabel("Grid elements", parent=self), 5, 0) @@ -119,7 +121,6 @@ class RasterDataCollectionPanel(ScanSettingsPanel): self.total_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) self._layout.addWidget(self.total_time, 11, 1, 1, 3) - self.image_time_enter.newValue.connect(self.exp_time_s) self.calculate_total_time() self.start_button = QPushButton("Evaluate grid") @@ -167,16 +168,18 @@ class RasterDataCollectionPanel(ScanSettingsPanel): w.setReadOnly(not can_edit) if s.sample is None and not self._previous_sample_was_none_raster: - for w in (self.width_enter, self.height_enter, self.image_time_enter): - w.reset_to_default() + # 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): - self.width_enter.update_value(self.__size_x) - self.height_enter.update_value(self.__size_y) + # 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.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)) diff --git a/src/aare/gui/panels/rotation_data_collection.py b/src/aare/gui/panels/rotation_data_collection.py index 8ee971da..b26b477e 100644 --- a/src/aare/gui/panels/rotation_data_collection.py +++ b/src/aare/gui/panels/rotation_data_collection.py @@ -8,7 +8,7 @@ from aare.common.logger_config import setup_logger from aare.common.models import DAQStatusModel, BeamlineStateEnum from aare.common.rotation_scan import RotationScanRequest from aare.gui.panels.scan_settings_panel import ScanSettingsPanel -from aare.gui.widgets.number_line_edit import NumberLineEdit, CheckedLineEdit +from aare.gui.widgets.number_line_edit import NumberLineEdit, CheckedLineEdit, DbOverrideLineEdit logger = setup_logger("aareGUI") @@ -84,30 +84,33 @@ class RotationDataCollectionPanel(ScanSettingsPanel): self._layout.addWidget(QLabel("
Rotation
", parent=self), 9, 0, 1, 6) self._layout.addWidget(QLabel("Total angle", parent=self), 10, 0) - self.total_angle = CheckedLineEdit(0, 9999.0, 360.0, decimals=3, parent=self) + self.total_angle = DbOverrideLineEdit(0, 9999.0, default=360.0, decimals=3, parent=self) self._layout.addWidget(self.total_angle, 10, 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), 11, 0) - self.image_angle = CheckedLineEdit(0, 10.0, 0.2, decimals=3, parent=self) + self.image_angle = DbOverrideLineEdit(0, 10.0, default=0.2, decimals=3, parent=self) self._layout.addWidget(self.image_angle, 11, 1, 1, 3) self._layout.addWidget(QLabel("°", parent=self), 11, 4) + self._register_override_field(self.image_angle) #TODO add protection on X10SA to prevent too short exposure time/ too high detector rep rate self._layout.addWidget(QLabel("Image time", parent=self), 12, 0) - self.image_time_enter = CheckedLineEdit( - 0.0005, 10.0, 0.01, decimals=4, parent=self + self.image_time_enter = DbOverrideLineEdit( + 0.0005, 10.0, default=0.01, decimals=4, parent=self ) self._layout.addWidget(self.image_time_enter, 12, 1, 1, 3) self._layout.addWidget(QLabel("s", parent=self), 12, 4) + self._register_override_field(self.image_time_enter) self._layout.addWidget(QLabel("Total measurement time", parent=self), 13, 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, 13, 1, 1, 3) - self.total_angle.newValue.connect(self.calculate_measurement_time) - self.image_angle.newValue.connect(self.calculate_measurement_time) - self.image_time_enter.newValue.connect(self.calculate_measurement_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() @@ -238,9 +241,10 @@ class RotationDataCollectionPanel(ScanSettingsPanel): w.setReadOnly(not can_edit) if s.sample is None and not self._previous_sample_was_none_rotation: - # Reset rotation-specific widgets to their defaults - 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): + # The DB-sourced fields (total_angle, image_angle, image_time_enter) + # are reset by the base panel (_reset_to_defaults); only reset the + # screening/start widgets that are not part of the source toggle. + for w in (self.start_angle, self.screening_image_angle, self.screening_image_time_enter): w.reset_to_default() self._previous_sample_was_none_rotation = True elif s.sample is not None: diff --git a/src/aare/gui/panels/scan_settings_panel.py b/src/aare/gui/panels/scan_settings_panel.py index 78bc30f9..1af3e2b7 100644 --- a/src/aare/gui/panels/scan_settings_panel.py +++ b/src/aare/gui/panels/scan_settings_panel.py @@ -1,11 +1,20 @@ from PySide6.QtCore import Slot, Signal -from PySide6.QtWidgets import QWidget, QGridLayout, QLabel, QPushButton +from PySide6.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QGridLayout, + QLabel, + QPushButton, + QRadioButton, + QButtonGroup, +) from aare.common.diffraction_geometry import DiffractionGeometry from aare.common.logger_config import setup_logger from aare.common.models import DAQStatusModel, SessionsStateEnum from aare.gui.widgets.message_box import ring_current_low_check, experiment_hutch_shutter_check -from aare.gui.widgets.number_line_edit import CheckedLineEdit +from aare.gui.widgets.number_line_edit import DbOverrideLineEdit logger = setup_logger("aareGUI") @@ -38,38 +47,114 @@ class ScanSettingsPanel(QWidget): self._ring_current = None self._experiment_shutter_state = None - self._layout = QGridLayout(self) + # 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 + + # 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. + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.addWidget(self._build_source_toggle()) + + grid_host = QWidget(self) + self._layout = QGridLayout(grid_host) + outer.addWidget(grid_host) self._layout.addWidget(QLabel("High resolution", parent=self), 0, 0) - self.high_res_enter = CheckedLineEdit( - 1.0, 10, decimals=2, default=self._high_res, parent=self + self.high_res_enter = DbOverrideLineEdit( + 1.0, 10, default=self._high_res, decimals=2, parent=self ) self._layout.addWidget(self.high_res_enter, 0, 1, 1, 3) self._layout.addWidget(QLabel("Å", parent=self), 0, 4) - self.high_res_enter.newValue.connect(self.set_high_res) - self._layout.addWidget(QLabel("Detector distance", parent=self), 1, 0) - self.dtz_enter = CheckedLineEdit(self.MIN_DTZ, 1000, decimals=2, default=self._dtz, check_box_text="", parent=self) + self.dtz_enter = DbOverrideLineEdit( + self.MIN_DTZ, 1000, default=self._dtz, decimals=2, parent=self + ) self._layout.addWidget(self.dtz_enter, 1, 1, 1, 3) self._layout.addWidget(QLabel("mm", parent=self), 1, 4) - self.dtz_enter.newValue.connect(self.set_dtz) - self.dtz_enter.readOnlyChanged.connect(self.high_res_enter.external_read_only) - self.high_res_enter.readOnlyChanged.connect(self.dtz_enter.external_read_only) - self._layout.addWidget(QLabel("Beam transmission", parent=self), 2, 0) - self.transmission_enter = CheckedLineEdit( - 0, 1.0, decimals=4, default=self._transmission, check_box_text="", parent=self + self.transmission_enter = DbOverrideLineEdit( + 0, 1.0, default=self._transmission, decimals=4, parent=self ) self._layout.addWidget(self.transmission_enter, 2, 1, 1, 3) - self.transmission_enter.newValue.connect(self.set_transmission) + self.transmission_enter.valueChanged.connect(self.set_transmission) + + # 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) + + self._register_override_field(self.transmission_enter) self.reload_params_button = QPushButton("Reload DB params") self.reload_params_button.setToolTip("Reload data collection parameters from database") self.reload_params_button.clicked.connect(self.reload_parameters) self.reload_params_button.setVisible(False) # Child classes should make it visible + # -- source toggle ----------------------------------------------------- + def _build_source_toggle(self) -> QWidget: + container = QWidget(self) + row = QHBoxLayout(container) + row.setContentsMargins(0, 0, 0, 0) + self._db_radio = QRadioButton("Database values", container) + self._mine_radio = QRadioButton("User values", container) + self._db_radio.setChecked(True) + self._source_group = QButtonGroup(container) + self._source_group.addButton(self._db_radio) + self._source_group.addButton(self._mine_radio) + self._db_radio.toggled.connect(self._on_source_radio_toggled) + row.addWidget(self._db_radio) + row.addWidget(self._mine_radio) + 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) + + @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 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(DAQStatusModel) def update_daq_status(self, s: DAQStatusModel): self.dtz_enter.update_limits(s.bl.dtz_min, s.bl.dtz_max) @@ -80,8 +165,7 @@ class ScanSettingsPanel(QWidget): self._experiment_shutter_state = s.bl.exp_shutter_open can_edit = (not s.busy) and (s.session.session in (SessionsStateEnum.OwnedByYou, SessionsStateEnum.PendingElseToYou)) self._can_edit_params = can_edit - # Lock/unlock base NumberLineEdits - + # Lock/unlock the override fields for w in (self.dtz_enter, self.high_res_enter, self.transmission_enter): w.set_busy(not can_edit) @@ -108,33 +192,60 @@ class ScanSettingsPanel(QWidget): self.reload_params_button.setEnabled(False) def _reset_to_defaults(self): - self._dtz = float(self.dtz_enter.get_default()) - self._transmission = float(self.transmission_enter.get_default()) - self._high_res = float(self.high_res_enter.get_default()) + # 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 _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 self.MIN_DTZ if dtz < self.MIN_DTZ else dtz @Slot(float) - def set_dtz(self, v: float): - #dtz_enter will only be edited in GUI when manual is checked, otherwise it is updated when high_res changes + 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.__diffraction.resolution_angstrom(self._dtz) - self.high_res_enter.force_update_value(self._high_res) + self._high_res = self._dtz_to_res(v) self.dtz_updated.emit(self._dtz) @Slot(float) - def set_high_res(self, v: float): - #if updated from spreadsheet should change dtz external - #if manually updated from GUI should not change dtz internal + 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 = self.__diffraction.calc_dtz_mm(v) - if self._dtz < self.MIN_DTZ: - self._dtz = self.MIN_DTZ - self.dtz_enter.force_update_value(self._dtz) + + @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) + + @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_transmission(self, v: float): self._transmission = v - self.transmission_enter.update_value(self._transmission) self.transmission_updated.emit(self._transmission) @Slot() @@ -144,6 +255,8 @@ class ScanSettingsPanel(QWidget): 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): @@ -160,26 +273,28 @@ class ScanSettingsPanel(QWidget): # Get parameter mappings from child class param_mappings = self.get_parameter_mappings() - # Update parameters based on 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: - if hasattr(widget, "force_update_value"): - widget.force_update_value(converter(value) if converter else value) + 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(converter(value) if converter else value) + 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.force_update_value(transmission_value) + self.transmission_enter.set_db_value(transmission_value) - # Handle target resolution (common to all panels) + # 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._high_res = target_res - self.high_res_enter.update_value(self._high_res) - self.set_high_res(self._high_res) + self._apply_db_resolution(float(target_res)) # Store metadata (common to all panels) self._sample_space_group = getattr(self._params, 'spacegroupnumber', None) @@ -187,6 +302,14 @@ class ScanSettingsPanel(QWidget): 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. @@ -210,4 +333,4 @@ class ScanSettingsPanel(QWidget): if not reply: logger.warning("Error with file path.") return False - return True \ No newline at end of file + return True diff --git a/src/aare/gui/widgets/number_line_edit.py b/src/aare/gui/widgets/number_line_edit.py index 35883624..ae9f7076 100644 --- a/src/aare/gui/widgets/number_line_edit.py +++ b/src/aare/gui/widgets/number_line_edit.py @@ -225,3 +225,139 @@ 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 + + self.editor = NumberLineEdit(min_val, max_val, default, decimals, self) + 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 new file mode 100644 index 00000000..4f3b17b1 --- /dev/null +++ b/tests/unit/gui/test_data_collection_settings.py @@ -0,0 +1,218 @@ +"""Tests for the data-collection settings override model. + +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. +""" + +import types + +import pytest + +from aare.common.coordinate import Coordinate, SmargonCoordinate +from aare.common.diffraction_geometry import DiffractionGeometry +from aare.common.models import SampleGeometryModel +from aare.gui.panels.raster_data_collection import RasterDataCollectionPanel +from aare.gui.panels.rotation_data_collection import RotationDataCollectionPanel +from aare.gui.scan_logic.raster_grid_manager import RasterGridManager +from aare.gui.widgets.number_line_edit import DbOverrideLineEdit + + +def _edit(field: DbOverrideLineEdit, text: str): + """Simulate a user typing into and committing a field.""" + field.editor.setText(text) + field.editor.on_editing_finished() + + +# --------------------------------------------------------------------------- +# Widget: DbOverrideLineEdit +# --------------------------------------------------------------------------- + + +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_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 + + +# --------------------------------------------------------------------------- +# Panel: toggle + dtz/resolution coupling +# --------------------------------------------------------------------------- + + +@pytest.fixture +def diffraction(): + return DiffractionGeometry( + energy_keV=12.0, + dtz_mm=150.0, + pixel_size_mm=0.075, + beam_center_pxl=(1000.0, 1000.0), + detector_size_pxl=(2000, 2000), + detector_description="Eiger 16M", + detector_serial_number="123", + poni_rot1_rad=0.0, + poni_rot2_rad=0.0, + ) + + +@pytest.fixture +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 + + +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_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_toggle_pushes_active_value_downstream(panel): + emitted = [] + panel.dtz_updated.connect(lambda v: emitted.append(round(v, 2))) + + _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, + ) + 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() + + # 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 + + +# --------------------------------------------------------------------------- +# Raster grid element size follows the same override model +# --------------------------------------------------------------------------- + + +@pytest.fixture +def raster_panel(qapp, diffraction): + geom = SampleGeometryModel( + beam_location_pxl=Coordinate(x=500, y=500), + pixel_in_mm=0.001, + aerotech=Coordinate(x=0, y=0, z=0), + aerotech_meas=Coordinate(x=0, y=0, z=0), + smargon=SmargonCoordinate( + sh_mm=Coordinate(x=0, y=0, z=0), phi_deg=0.0, chi_deg=0.0 + ), + omega_deg=0.0, + beam_size_mm=Coordinate(x=0.01, y=0.01), + ) + mgr = RasterGridManager(geom) + return RasterDataCollectionPanel(raster_mgr=mgr, diffraction=diffraction) + + +def test_grid_element_size_user_value_persists_over_geometry(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 + + # 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 + + +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