data collection settings: panel-level Database/User-values toggle

Add DbOverrideLineEdit: a numeric field holding both a database value and
the user's value, driven by a per-tab Database/User-values toggle. The
user's value persists across samples, can be reverted to the database
value, and recovered again - replacing the clunky per-field checkboxes.

Fix the dtz<->resolution coupling so editing one updates the other in both
Database and User modes (previously it broke whenever a value was
overridden). Wire resolution, detector distance, transmission, exposure,
rotation total/image angle and the raster grid element size through the
new widget; other panels are untouched.

Add tests covering persist/revert/recover, the toggle's downstream emit,
the dtz/resolution coupling in both modes, and cross-sample persistence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
appleb_m
2026-06-24 15:30:08 +02:00
co-authored by Claude Opus 4.8
parent 23290f9e49
commit 476aa69d70
5 changed files with 551 additions and 67 deletions
+17 -14
View File
@@ -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))
+15 -11
View File
@@ -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("<center><B><u>Rotation</u></B></center>", 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:
+165 -42
View File
@@ -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
return True
+136
View File
@@ -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()
@@ -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