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