From fd4b9b3122a67c5c470c9bad2c231fd8cbb8a82a Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Mon, 7 Sep 2026 16:29:14 +0200 Subject: [PATCH 1/7] fix: keep user values in the scan panels across sample mounts The panels kept the effective detector distance, resolution and transmission in their own attributes, and a sample change overwrote those from the spreadsheet whatever the Database/User toggle said. The fields went on showing the user's numbers while the scan request carried the database ones, so a value had to be re-entered after every mount. The fields are now the only place a value lives. Each setting is worth, in order: what the user typed while "User values" is selected, what the mounted sample asks for, the panel default - one rule, ScanSettingsPanel ._setting, that each panel spells out a line at a time. Overrides are per setting, so a setting the user did not touch keeps following the sample. Along the way: * DbOverrideLineEdit is gone; the panels use NumberLineEdit directly, and the per-widget database/user bookkeeping is replaced by SampleParameters, the one place that knows the aareDB spreadsheet column names. * The (name, widget, converter) mapping loops with getattr/hasattr are replaced by explicit per-field commit slots. * The omega speed cap no longer drives one field's validator from another field's value: it is a read-out plus a check on the pair at Run time. * _add_row/_add_pair_row build the label/field/unit rows, which also puts the Total angle degree sign on its own row instead of the header above. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JHoUkj66jxByS2ypY5h9Mn --- src/aare/gui/panels/raster_data_collection.py | 196 +++---- .../gui/panels/rotation_data_collection.py | 326 ++++++----- src/aare/gui/panels/scan_settings_panel.py | 514 +++++++++--------- src/aare/gui/widgets/number_line_edit.py | 159 +----- .../unit/gui/test_data_collection_settings.py | 345 +++++++----- tests/unit/gui/test_rotation_speed_limits.py | 56 +- 6 files changed, 750 insertions(+), 846 deletions(-) diff --git a/src/aare/gui/panels/raster_data_collection.py b/src/aare/gui/panels/raster_data_collection.py index 57f18ea9..bbbe74b4 100644 --- a/src/aare/gui/panels/raster_data_collection.py +++ b/src/aare/gui/panels/raster_data_collection.py @@ -1,6 +1,6 @@ from aarecommon.config.logger import setup_logger from aarecommon.math.diffraction_geometry import DiffractionGeometry -from aarecommon.models.models import BeamlineStateEnum, DAQStatusModel +from aarecommon.models.models import BeamlineStateEnum from PySide6.QtCore import Qt, Signal, Slot from PySide6.QtWidgets import QComboBox, QLabel, QMessageBox, QPushButton, QSlider @@ -8,7 +8,7 @@ from aare.gui.constants import LOGGER_NAME from aare.gui.panels.scan_settings_panel import ScanSettingsPanel from aare.gui.scan_logic.raster_grid_manager import RasterGridManager, RasterGridMetric from aare.gui.styles import ABORT_TEXT, GO_TEXT -from aare.gui.widgets.number_line_edit import DbOverrideLineEdit +from aare.gui.widgets.number_line_edit import NumberLineEdit from aare.gui.widgets.raster_grid_table import RasterGridTable logger = setup_logger(LOGGER_NAME) @@ -33,69 +33,45 @@ class RasterDataCollectionPanel(ScanSettingsPanel): parent=parent, ) - self._previous_sample_was_none_raster = True + self._default_exp_time = raster_mgr.active_grid.exp_time_s + # Image time typed in by the user; None means "follow the sample". + self._user_exp_time: float | None = None self._n_x = raster_mgr.active_grid.n_x self._n_y = raster_mgr.active_grid.n_y self._size_x = raster_mgr.active_grid.grid_size_mm.x * 1000.0 self._size_y = raster_mgr.active_grid.grid_size_mm.y * 1000.0 - self._total_time = raster_mgr.active_grid.exp_time_s * self._n_x * self._n_y + self._total_time = self._default_exp_time * self._n_x * self._n_y - self._layout.addWidget(QLabel("Grid element size", parent=self), 3, 0) - - self.width_enter = DbOverrideLineEdit(5, 100, default=self._size_x, decimals=0, parent=self) - self.width_enter.valueChanged.connect(self.grid_size) - self._register_override_field(self.width_enter) - self._layout.addWidget(self.width_enter, 3, 1) - - self._layout.addWidget(QLabel(" x ", parent=self), 3, 2) - - self.height_enter = DbOverrideLineEdit( - 5, 100, default=self._size_y, decimals=0, parent=self + # Grid element size: the drawn grid decides it, the user can ask for a + # different one. It has no spreadsheet counterpart, so it stays outside + # the Database/User toggle. + self.width_enter = NumberLineEdit( + 5, 100, self._size_x, decimals=0, parent=self, track_pending=True ) - self.height_enter.valueChanged.connect(self.grid_size) - self._register_override_field(self.height_enter) - - self._layout.addWidget(self.height_enter, 3, 3) - - self._layout.addWidget(QLabel("μm", parent=self), 3, 4) - - self._layout.addWidget(QLabel("Image time", parent=self), 4, 0) - self.image_time_enter = DbOverrideLineEdit( - 0.0005, 10.0, default=raster_mgr.active_grid.exp_time_s, decimals=4, parent=self + self.height_enter = NumberLineEdit( + 5, 100, self._size_y, decimals=0, parent=self, track_pending=True ) - self._layout.addWidget(self.image_time_enter, 4, 1, 1, 3) - self._layout.addWidget(QLabel("s", parent=self), 4, 4) - self.image_time_enter.valueChanged.connect(self.exp_time_s) - self._register_override_field(self.image_time_enter) + self._add_pair_row(3, "Grid element size", self.width_enter, self.height_enter, "μm") + for field in (self.width_enter, self.height_enter): + field.newValue.connect(self._on_element_size_committed) + self._add_field(field) - self._layout.addWidget(QLabel("Grid elements", parent=self), 5, 0) + self.image_time_enter = NumberLineEdit( + 0.0005, 10.0, self._default_exp_time, decimals=4, parent=self, track_pending=True + ) + self._add_row(4, "Image time", self.image_time_enter, "s") + self._add_database_field(self.image_time_enter, self._on_exp_time_committed) self.n_x_label = QLabel("-", parent=self) - self.n_x_label.setAlignment(Qt.AlignmentFlag.AlignRight) - self._layout.addWidget(self.n_x_label, 5, 1) - self._layout.addWidget(QLabel(" x ", parent=self), 5, 2) - self.n_y_label = QLabel("-", parent=self) - self.n_y_label.setAlignment(Qt.AlignmentFlag.AlignRight) - self._layout.addWidget(self.n_y_label, 5, 3) - - self._layout.addWidget(QLabel("Grid size", parent=self), 6, 0) + self._add_pair_row(5, "Grid elements", self.n_x_label, self.n_y_label) self.size_x_label = QLabel("-", parent=self) - self.size_x_label.setAlignment(Qt.AlignmentFlag.AlignRight) - self._layout.addWidget(self.size_x_label, 6, 1) - self._layout.addWidget(QLabel(" x ", parent=self), 6, 2) - self.size_y_label = QLabel("-", parent=self) - self.size_y_label.setAlignment(Qt.AlignmentFlag.AlignRight) - self._layout.addWidget(self.size_y_label, 6, 3) - - self._layout.addWidget(QLabel("μm", parent=self), 6, 4) + self._add_pair_row(6, "Grid size", self.size_x_label, self.size_y_label, "μm") self.metric_combo = QComboBox() - self._layout.addWidget(QLabel("Metric", parent=self), 7, 0) - self.metric_combo.addItem("Raster score", RasterGridMetric.RASTER_SCORE) self.metric_combo.addItem("Spot count (low res.)", RasterGridMetric.SPOTS_LOW_RES) self.metric_combo.addItem("Spot count", RasterGridMetric.SPOTS) @@ -110,25 +86,19 @@ class RasterDataCollectionPanel(ScanSettingsPanel): self.metric_combo.setCurrentIndex(self.metric_combo.findData(RasterGridMetric.RASTER_SCORE)) self.metric_combo.currentIndexChanged.connect(self.metric_changed) + self._add_row(7, "Metric", self.metric_combo) - self._layout.addWidget(self.metric_combo, 7, 1, 1, 3) - - self._layout.addWidget(QLabel("Transparency", parent=self), 8, 0) slider = QSlider(orientation=Qt.Orientation.Horizontal, parent=self) slider.setRange(0, 255) slider.setValue(127) slider.valueChanged.connect(lambda: self.raster_alpha_changed.emit(255 - slider.value())) - self._layout.addWidget(slider, 8, 1, 1, 3) + self._add_row(8, "Transparency", slider) self._table = RasterGridTable(raster_mgr) self._layout.addWidget(self._table, 9, 0, 1, 5) - self._layout.addWidget(QLabel("Measurement time", parent=self), 11, 0) self.total_time = QLabel(f"{self._total_time} min 0 s") - self.total_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - self._layout.addWidget(self.total_time, 11, 1, 1, 3) - - self.calculate_total_time() + self._add_row(11, "Measurement time", self.total_time) self.start_button = QPushButton("Evaluate grid") self.start_button.setStyleSheet(f"color: {GO_TEXT};") @@ -144,75 +114,68 @@ class RasterDataCollectionPanel(ScanSettingsPanel): self.abort_button = QPushButton("Abort measurement") self.abort_button.setStyleSheet(f"color: {ABORT_TEXT};") self._layout.addWidget(self.abort_button, 14, 0, 1, 5) - self._reset_to_defaults() + + # every field exists now: fill in the measurement time and the grid + self._values_changed() self.update_grid_scan_size() + # -- the panel's settings ---------------------------------------------- @Slot(float) - def grid_size(self, _: float): - self._size_x = self.width_enter.value - self._size_y = self.height_enter.value + def _on_exp_time_committed(self, value: float): + self._user_exp_time = value + self._user_took_over() + + def _write_active_values(self): + super()._write_active_values() + self.image_time_enter.set_committed_value( + self._setting(self._user_exp_time, self._db_params.exp_time_s, self._default_exp_time) + ) + + def _values_changed(self): + super()._values_changed() + self.exp_time_updated.emit(self.image_time_enter.committed_value) + self.calculate_total_time() + + # -- grid geometry ------------------------------------------------------ + @Slot(float) + def _on_element_size_committed(self, _value: float): + """The user asked for a different element size: the grid manager + recomputes how many elements fit and reports back to + grid_scan_size_change, which writes the size it settled on.""" + self._size_x = self.width_enter.committed_value + self._size_y = self.height_enter.committed_value self.grid_size_updated.emit(self._size_x / 1000.0, self._size_y / 1000.0) self.update_grid_scan_size() - @Slot(float) - def exp_time_s(self, inp: float): - self.exp_time_updated.emit(inp) - self.calculate_total_time() - @Slot(int, int, float, float) def grid_scan_size_change(self, n_x: int, n_y: int, size_x_mm: float, size_y_mm: float): + """The drawn grid changed (resized, or the beam size moved the default + element size).""" self._size_x = size_x_mm * 1000.0 self._size_y = size_y_mm * 1000.0 self._n_x = n_x self._n_y = n_y - self.calculate_total_time() self.update_grid_scan_size() - @Slot(DAQStatusModel) - def update_daq_status(self, s: DAQStatusModel): - super().update_daq_status(s) - can_edit = getattr(self, "_can_edit_params", False) - for w in (self.width_enter, self.height_enter, self.image_time_enter): - if hasattr(w, "set_busy"): - w.set_busy(not can_edit) - else: - w.setReadOnly(not can_edit) - - if s.sample is None and not self._previous_sample_was_none_raster: - # The override fields (grid size, image time) are reset by the base - # panel (_reset_to_defaults); nothing extra to reset here. - self._previous_sample_was_none_raster = True - elif s.sample is not None: - self._previous_sample_was_none_raster = False - self._beamline_state = s.state - def update_grid_scan_size(self): - # The live grid geometry is the "database"/system value for the element - # size; a user override persists and can be reverted via the toggle. - self.width_enter.set_db_value(self._size_x) - self.height_enter.set_db_value(self._size_y) + self.width_enter.set_committed_value(self._size_x) + self.height_enter.set_committed_value(self._size_y) self.n_x_label.setText(str(self._n_x)) self.size_x_label.setText(f"{self._size_x * self._n_x:.1f}") self.n_y_label.setText(str(self._n_y)) self.size_y_label.setText(f"{self._size_y * self._n_y:.1f}") - self.update_total_time_label() - try: - self.exp_time_s(self.image_time_enter.value) - except ValueError as e: - logger.warning(f"Invalid exposure time: {e} reseting to default") - self.exp_time_s(0.02) + self.calculate_total_time() def metric_changed(self, _: int): self.grid_metric_updated.emit(self.metric_combo.currentData()) - def get_parameter_mappings(self): - """Return raster-specific parameter mappings.""" - return [ - ("exposure", self.image_time_enter, None) - # Add other raster-specific parameters here as needed - ] - - def update_total_time_label(self): + def calculate_total_time(self): + exp_time_s = self.image_time_enter.committed_value + if self._n_x <= 0 or self._n_y <= 0 or exp_time_s < 0: + self._total_time = 0.0 + else: + # 30% buffer added + self._total_time = self._n_x * self._n_y * exp_time_s * 1.3 mins = int(self._total_time // 60) secs = round(self._total_time % 60) if secs == 60: @@ -220,29 +183,20 @@ class RasterDataCollectionPanel(ScanSettingsPanel): secs = 0 self.total_time.setText(f"{mins} min {secs} s") - def calculate_total_time(self): - if self._n_x <= 0 or self._n_y <= 0 or self.image_time_enter.value < 0: - self._total_time = 0.0 - else: - self._total_time = self._n_x * self._n_y * self.image_time_enter.value * 1.3 - # 30% buffer added - # Show minutes - self.update_total_time_label() - - @Slot() - def _on_evaluate_clicked(self): + # -- running ------------------------------------------------------------ + def _ready_to_run(self) -> bool: if self._beamline_state != BeamlineStateEnum.SampleAlignment: logger.error(f"Beamline state {self._beamline_state} is not Sample Alignment") QMessageBox.critical(None, "Error", "Beamline state is not Sample Alignment") - return - if self.check_before_run(scan_kind="raster"): + return False + return self.check_before_run(scan_kind="raster") + + @Slot() + def _on_evaluate_clicked(self): + if self._ready_to_run(): self.evaluate_grid.emit() @Slot() def _on_evaluate_auto_clicked(self): - if self._beamline_state != BeamlineStateEnum.SampleAlignment: - logger.error(f"Beamline state {self._beamline_state} is not Sample Alignment") - QMessageBox.critical(None, "Error", "Beamline state is not Sample Alignment") - return - if self.check_before_run(scan_kind="raster"): + if self._ready_to_run(): self.evaluate_grid_auto.emit() diff --git a/src/aare/gui/panels/rotation_data_collection.py b/src/aare/gui/panels/rotation_data_collection.py index 3899087a..8c7ca3b2 100644 --- a/src/aare/gui/panels/rotation_data_collection.py +++ b/src/aare/gui/panels/rotation_data_collection.py @@ -6,13 +6,13 @@ from aarecommon.math.diffraction_geometry import DiffractionGeometry from aarecommon.models.beamline import MXBeamline from aarecommon.models.models import BeamlineStateEnum, DAQStatusModel from aarecommon.models.rotation_scan import RotationScanRequest -from PySide6.QtCore import Qt, Signal, Slot +from PySide6.QtCore import Signal, Slot from PySide6.QtWidgets import QComboBox, QLabel, QMessageBox, QPushButton from aare.gui.constants import LOGGER_NAME from aare.gui.panels.scan_settings_panel import ScanSettingsPanel from aare.gui.styles import ABORT_TEXT, GO_TEXT -from aare.gui.widgets.number_line_edit import DbOverrideLineEdit, NumberLineEdit +from aare.gui.widgets.number_line_edit import NumberLineEdit logger = setup_logger(LOGGER_NAME) @@ -21,6 +21,15 @@ MAX_OMEGA_SPEED_DEG_S = 500.0 MIN_EXP_TIME_S = {MXBeamline.X06DA: 1 / 900, MXBeamline.X10SA: 1 / 120} +def omega_speed_deg_s(image_angle_deg: float, exp_time_s: float) -> float: + """How fast omega has to turn to cover one image in one exposure. The + goniometer caps this, and it is a limit on the pair - neither number is + wrong on its own - so no single field's validator can express it.""" + if exp_time_s <= 0: + return float("inf") + return image_angle_deg / exp_time_s + + def add_screening_to_path(path): p = Path(path) return "screening" / p @@ -54,26 +63,28 @@ class RotationDataCollectionPanel(ScanSettingsPanel): ), ) - self._beamline_state = None - self._curr_pgroup = "p11206" self._filename = "" - self._omega = 0 self._dose_mgy = 0 self._total_time = 0.0 self._min_exp_time_s = MIN_EXP_TIME_S.get(mx_beamline(), 0.0005) - self._layout.addWidget(QLabel("Start angle", parent=self), 3, 0) - self.start_angle = NumberLineEdit( - -720, 720.0, 0.0, decimals=3, parent=self, track_pending=True - ) - self._layout.addWidget(self.start_angle, 3, 1, 1, 2) - self._layout.addWidget(QLabel("°", parent=self), 3, 3) + # Settings the user typed in; None means "follow the mounted sample". + # The screening fields are not here: they have no spreadsheet + # counterpart, so they always keep whatever the user left in them. + self._user_total_angle: float | None = None + self._user_image_angle: float | None = None + self._user_exp_time: float | None = None self.omega_button = QPushButton("Ω") self.omega_button.setFixedWidth(30) self.omega_button.clicked.connect(self.update_omega_start) - self._layout.addWidget(self.omega_button, 3, 4) + + self.start_angle = NumberLineEdit( + -720, 720.0, 0.0, decimals=3, parent=self, track_pending=True + ) + self._add_row(3, "Start angle", self.start_angle, "°", trailing=self.omega_button) + self._add_field(self.start_angle) self._layout.addWidget( QLabel("
Screening
", parent=self), 4, 0, 1, 6 @@ -91,14 +102,12 @@ class RotationDataCollectionPanel(ScanSettingsPanel): self._layout.addWidget(self.screening_type, 5, 0, 1, 6) - self._layout.addWidget(QLabel("Image angle", parent=self), 6, 0) self.screening_image_angle = NumberLineEdit( 0, 90.0, 0.5, decimals=3, parent=self, track_pending=True ) - self._layout.addWidget(self.screening_image_angle, 6, 1, 1, 3) - self._layout.addWidget(QLabel("°", parent=self), 6, 4) + self._add_row(6, "Image angle", self.screening_image_angle, "°") + self._add_field(self.screening_image_angle) - self._layout.addWidget(QLabel("Image time", parent=self), 7, 0) default_screening_exp_time = cfg_get( "daq.data_collection_settings.default_screening_settings.exp_time_s", 0.1 ) @@ -110,15 +119,14 @@ class RotationDataCollectionPanel(ScanSettingsPanel): parent=self, track_pending=True, ) - self._layout.addWidget(self.screening_image_time_enter, 7, 1, 1, 3) - self._layout.addWidget(QLabel("s", parent=self), 7, 4) + self._add_row(7, "Image time", self.screening_image_time_enter, "s") + self._add_field(self.screening_image_time_enter) - self._layout.addWidget(QLabel("Screening transmission", parent=self), 8, 0) - self.screening_transmission_enter = DbOverrideLineEdit( - 0, 1.0, default=self._transmission, decimals=4, parent=self + self.screening_transmission_enter = NumberLineEdit( + 0, 1.0, self._default_transmission, decimals=4, parent=self, track_pending=True ) - self._layout.addWidget(self.screening_transmission_enter, 8, 1, 1, 3) - self.screening_transmission_enter.valueChanged.connect(self.set_screening_transmission) + self._add_row(8, "Screening transmission", self.screening_transmission_enter) + self._add_field(self.screening_transmission_enter) self.screening_button = QPushButton("Run screening") self.screening_button.setStyleSheet(f"color: {GO_TEXT};") @@ -129,55 +137,48 @@ class RotationDataCollectionPanel(ScanSettingsPanel): QLabel("
Rotation
", parent=self), 10, 0, 1, 6 ) - self._layout.addWidget(QLabel("Total angle", parent=self), 11, 0) default_steps = cfg_get( "daq.data_collection_settings.default_rotation_settings.steps", 1800 ) - default_increment_omega = cfg_get( + self._default_image_angle = cfg_get( "daq.data_collection_settings.default_rotation_settings.increment_omega_deg", 0.2 ) - default_total_angle = default_steps * default_increment_omega - self.total_angle = DbOverrideLineEdit( - 0, 9999.0, default=default_total_angle, decimals=3, parent=self - ) - self._layout.addWidget(self.total_angle, 11, 1, 1, 3) - self._layout.addWidget(QLabel("°", parent=self), 10, 4) - self._register_override_field(self.total_angle) - - self._layout.addWidget(QLabel("Image angle", parent=self), 12, 0) - self.image_angle = DbOverrideLineEdit( - 0, 10.0, default=default_increment_omega, decimals=3, parent=self - ) - self._layout.addWidget(self.image_angle, 12, 1, 1, 3) - self._layout.addWidget(QLabel("°", parent=self), 12, 4) - self._register_override_field(self.image_angle) - self._layout.addWidget(QLabel("Image time", parent=self), 13, 0) - default_image_exp_time = cfg_get( + self._default_total_angle = default_steps * self._default_image_angle + self._default_exp_time = cfg_get( "daq.data_collection_settings.default_rotation_settings.exp_time_s", 0.01 ) - self.image_time_enter = DbOverrideLineEdit( - self._min_exp_time_s, 10.0, default=default_image_exp_time, decimals=4, parent=self + + self.total_angle = NumberLineEdit( + 0, 9999.0, self._default_total_angle, decimals=3, parent=self, track_pending=True ) - self._layout.addWidget(self.image_time_enter, 13, 1, 1, 3) - self._layout.addWidget(QLabel("s", parent=self), 13, 4) - self._register_override_field(self.image_time_enter) + self._add_row(11, "Total angle", self.total_angle, "°") + self._add_database_field(self.total_angle, self._on_total_angle_committed) + + self.image_angle = NumberLineEdit( + 0, 10.0, self._default_image_angle, decimals=3, parent=self, track_pending=True + ) + self._add_row(12, "Image angle", self.image_angle, "°") + self._add_database_field(self.image_angle, self._on_image_angle_committed) + + self.image_time_enter = NumberLineEdit( + self._min_exp_time_s, + 10.0, + self._default_exp_time, + decimals=4, + parent=self, + track_pending=True, + ) + self._add_row(13, "Image time", self.image_time_enter, "s") + self._add_database_field(self.image_time_enter, self._on_exp_time_committed) + + self.omega_speed = QLabel("-") + self._add_row(14, "Rotation speed", self.omega_speed, "°/s") - self._layout.addWidget(QLabel("Total measurement time", parent=self), 15, 0) self.total_time = QLabel(f"{self._total_time} min 0 s") - self.total_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - self._layout.addWidget(self.total_time, 15, 1, 1, 3) + self._add_row(15, "Total measurement time", self.total_time) - self.total_angle.valueChanged.connect(self.calculate_measurement_time) - self.image_angle.valueChanged.connect(self.calculate_measurement_time) - self.image_time_enter.valueChanged.connect(self.calculate_measurement_time) - # Initial compute - self.calculate_measurement_time() - - self._layout.addWidget(QLabel("Dose", parent=self), 16, 0) self.dose = QLabel(f"{self._dose_mgy}") - self.dose.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - self._layout.addWidget(self.dose, 16, 1, 1, 3) - self._layout.addWidget(QLabel("MGy", parent=self), 16, 4) + self._add_row(16, "Dose", self.dose, "MGy") self.measurement_button = QPushButton("Run rotation") self.measurement_button.setStyleSheet(f"color: {GO_TEXT};") @@ -188,37 +189,92 @@ class RotationDataCollectionPanel(ScanSettingsPanel): self.abort_button = QPushButton("Abort measurement") self.abort_button.setStyleSheet(f"color: {ABORT_TEXT};") self._layout.addWidget(self.abort_button, 18, 0, 1, 6) - self._reset_to_defaults() - # Speed cap couples each angle/time pair: recompute limits whenever either commits. - self.screening_image_angle.newValue.connect(self._update_speed_limits) - self.screening_image_time_enter.newValue.connect(self._update_speed_limits) - self.image_angle.valueChanged.connect(self._update_speed_limits) - self.image_time_enter.valueChanged.connect(self._update_speed_limits) - self._update_speed_limits() + # every field exists now: fill in the speed and measurement time + self._values_changed() - @Slot() - def _update_speed_limits(self): - for angle, time, angle_max in ( - (self.screening_image_angle, self.screening_image_time_enter, 90.0), - (self.image_angle, self.image_time_enter, 10.0), - ): - angle.update_limits(0, min(angle_max, MAX_OMEGA_SPEED_DEG_S * time.value)) - time.update_limits(max(self._min_exp_time_s, angle.value / MAX_OMEGA_SPEED_DEG_S), 10.0) + # -- the panel's settings ---------------------------------------------- + @Slot(float) + def _on_total_angle_committed(self, value: float): + self._user_total_angle = value + self._user_took_over() - def _fields_out_of_range(self, *fields) -> bool: - """True (plus error box) when a field sits outside its current limits. - Needed because QDoubleValidator only paints the field red; ``.value`` - still reads the raw text, so without this gate the request goes out.""" - if all(getattr(f, "editor", f).hasAcceptableInput() for f in fields): - return False - msg = ( - "Image angle / Image time outside limits " - f"(max {MAX_OMEGA_SPEED_DEG_S:.0f} °/s, min image time {self._min_exp_time_s:.4f} s)" + @Slot(float) + def _on_image_angle_committed(self, value: float): + self._user_image_angle = value + self._user_took_over() + + @Slot(float) + def _on_exp_time_committed(self, value: float): + self._user_exp_time = value + self._user_took_over() + + def _write_active_values(self): + super()._write_active_values() + db = self._db_params + self.total_angle.set_committed_value( + self._setting(self._user_total_angle, db.total_angle_deg, self._default_total_angle) ) + self.image_angle.set_committed_value( + self._setting(self._user_image_angle, db.image_angle_deg, self._default_image_angle) + ) + self.image_time_enter.set_committed_value( + self._setting(self._user_exp_time, db.exp_time_s, self._default_exp_time) + ) + + def _values_changed(self): + super()._values_changed() + self._update_speed_readout() + self._update_measurement_time() + + # -- derived read-outs -------------------------------------------------- + def _update_speed_readout(self): + speed = omega_speed_deg_s( + self.image_angle.committed_value, self.image_time_enter.committed_value + ) + self.omega_speed.setText("-" if speed == float("inf") else f"{speed:.1f}") + too_fast = speed > MAX_OMEGA_SPEED_DEG_S + # A red read-out rather than a red field: the cap is on the pair. + self.omega_speed.setStyleSheet(f"color: {ABORT_TEXT};" if too_fast else "") + + def image_number(self) -> int: + total_angle = self.total_angle.committed_value + image_angle = self.image_angle.committed_value + if total_angle <= 0 or total_angle < image_angle or image_angle <= 0: + logger.warning(f"Cannot count images for {total_angle}° in steps of {image_angle}°") + return 0 + return round(total_angle / image_angle) + + def _update_measurement_time(self): + self._total_time = self.image_number() * self.image_time_enter.committed_value + mins = int(self._total_time // 60) + secs = round(self._total_time % 60) + if secs == 60: + mins += 1 + secs = 0 + self.total_time.setText(f"{mins} min {secs} s") + + # -- running ------------------------------------------------------------ + def _pair_is_runnable(self, angle: NumberLineEdit, time: NumberLineEdit) -> bool: + """Both halves of an image angle / image time pair have to be inside + their own limits, and together they have to stay under the omega speed + cap. QDoubleValidator only paints a field red, so a run has to ask.""" + speed = omega_speed_deg_s(angle.committed_value, time.committed_value) + if not (angle.hasAcceptableInput() and time.hasAcceptableInput()): + msg = ( + "Image angle / image time outside limits " + f"(min image time {self._min_exp_time_s:.4f} s)" + ) + elif speed > MAX_OMEGA_SPEED_DEG_S: + msg = ( + f"{angle.committed_value:.3f}° in {time.committed_value:.4f} s needs " + f"{speed:.0f} °/s, above the {MAX_OMEGA_SPEED_DEG_S:.0f} °/s limit" + ) + else: + return True logger.error(msg) QMessageBox.critical(self, "Error", msg) - return True + return False @Slot() def run_screening(self): @@ -226,7 +282,7 @@ class RotationDataCollectionPanel(ScanSettingsPanel): logger.error(f"Beamline state {self._beamline_state} is not Sample Alignment") QMessageBox.critical(None, "Error", "Beamline state is not Sample Alignment") return - if self._fields_out_of_range(self.screening_image_angle, self.screening_image_time_enter): + if not self._pair_is_runnable(self.screening_image_angle, self.screening_image_time_enter): return if not self.check_before_run(scan_kind="screening"): logger.error("Cannot run measurement because of check") @@ -240,26 +296,26 @@ class RotationDataCollectionPanel(ScanSettingsPanel): if is_single_image: r = RotationScanRequest( file_prefix=str(add_screening_to_path(self._filename)), - start_omega_deg=self.start_angle.value, - incr_omega_deg=self.screening_image_angle.value, + start_omega_deg=self.start_angle.committed_value, + incr_omega_deg=self.screening_image_angle.committed_value, wedge_omega_deg=screening_settings["omega_step_deg"], steps=1, - dtz=self._dtz, - transmission=self._screening_transmission, + dtz=self.dtz_enter.committed_value, + transmission=self.screening_transmission_enter.committed_value, screening=True, - exp_time_s=self.screening_image_time_enter.value, + exp_time_s=self.screening_image_time_enter.committed_value, ) else: r = RotationScanRequest( file_prefix=str(add_screening_to_path(self._filename)), - start_omega_deg=self.start_angle.value, - wedge_omega_deg=self.screening_image_angle.value, + start_omega_deg=self.start_angle.committed_value, + wedge_omega_deg=self.screening_image_angle.committed_value, incr_omega_deg=screening_settings["omega_step_deg"], steps=screening_settings["steps"], - dtz=self._dtz, - transmission=self._screening_transmission, + dtz=self.dtz_enter.committed_value, + transmission=self.screening_transmission_enter.committed_value, screening=True, - exp_time_s=self.screening_image_time_enter.value, + exp_time_s=self.screening_image_time_enter.committed_value, ) self.rotation_scan.emit(r) @@ -271,58 +327,25 @@ class RotationDataCollectionPanel(ScanSettingsPanel): logger.error(f"Beamline state {self._beamline_state} is not Sample Alignment") QMessageBox.critical(None, "Error", "Beamline state is not Sample Alignment") return - if self._fields_out_of_range(self.image_angle, self.image_time_enter): + if not self._pair_is_runnable(self.image_angle, self.image_time_enter): return if not self.check_before_run(scan_kind="rotation"): logger.error("Cannot run measurement because of check") return r = RotationScanRequest( file_prefix=str(add_data_to_path(self._filename)), - start_omega_deg=self.start_angle.value, + start_omega_deg=self.start_angle.committed_value, steps=self.image_number(), - incr_omega_deg=self.image_angle.value, - dtz=self._dtz, - transmission=self._transmission, + incr_omega_deg=self.image_angle.committed_value, + dtz=self.dtz_enter.committed_value, + transmission=self.transmission_enter.committed_value, screening=False, - exp_time_s=self.image_time_enter.value, + exp_time_s=self.image_time_enter.committed_value, ) self.rotation_scan.emit(r) self.viewer_track_online.emit() - def image_number(self): - total_angle = self.total_angle.value - image_angle = self.image_angle.value - if total_angle <= 0 or total_angle < image_angle: - print("Total angle is not valid.") - return 0 - elif image_angle <= 0: - print("Image angle is not valid.") - return 0 - - return round(total_angle / image_angle) - - def update_total_time_label(self): - mins = int(self._total_time // 60) - secs = round(self._total_time % 60) - if secs == 60: - mins += 1 - secs = 0 - self.total_time.setText(f"{mins} min {secs} s") - - def calculate_measurement_time(self): - if ( - self.image_angle.value <= 0 - or self.total_angle.value <= 0 - or self.image_time_enter.value < 0 - ): - self._total_time = 0.0 - self.update_total_time_label() - return - self._total_time = ( - self.total_angle.value / self.image_angle.value - ) * self.image_time_enter.value - self.update_total_time_label() - + # -- status ------------------------------------------------------------- @Slot(str) def update_filename(self, filename: str): self._filename = filename @@ -332,41 +355,14 @@ class RotationDataCollectionPanel(ScanSettingsPanel): super().update_daq_status(s) self._omega = s.geom.omega_deg - can_edit = getattr(self, "_can_edit_params", False) - for w in ( - self.start_angle, - self.screening_image_angle, - self.screening_image_time_enter, - self.total_angle, - self.image_angle, - self.image_time_enter, - ): - if hasattr(w, "set_busy"): - w.set_busy(not can_edit) - else: - w.setReadOnly(not can_edit) - - self.calculate_measurement_time() - if s.session.current_pgroup: - self._curr_pgroup = s.session.current_pgroup lambda_a = s.diffraction.wavelength_angstrom kdose = 2000 / (lambda_a * lambda_a) beam_area = s.geom.beam_size_mm.x * s.geom.beam_size_mm.y * 1e6 - time = self.image_number() * self.image_time_enter.value - - self._dose_mgy = (time * s.bl.flux_ph_s * self._transmission) / (beam_area * kdose) + transmission = self.transmission_enter.committed_value + self._dose_mgy = (self._total_time * s.bl.flux_ph_s * transmission) / (beam_area * kdose) self.dose.setText(f"{(self._dose_mgy / 1e6):.1f}") - self._beamline_state = s.state @Slot() def update_omega_start(self): if not self.start_angle.isReadOnly(): self.start_angle.force_update_value(self._omega) - - def get_parameter_mappings(self): - """Return rotation-specific parameter mappings.""" - return [ - ("totalrange", self.total_angle, lambda v: float(v)), - ("oscillation", self.image_angle, None), - ("exposure", self.image_time_enter, None), - ] diff --git a/src/aare/gui/panels/scan_settings_panel.py b/src/aare/gui/panels/scan_settings_panel.py index 65a5e6b1..04d67301 100644 --- a/src/aare/gui/panels/scan_settings_panel.py +++ b/src/aare/gui/panels/scan_settings_panel.py @@ -1,7 +1,36 @@ +"""Common part of the scan settings panels (Rotation, Raster). + +Where the numbers in the fields come from +----------------------------------------- +Every setting is worth one of three things, in this order: + +1. the value the user typed into it, while "User values" is selected, +2. the value the mounted sample asks for - its row in the aareDB spreadsheet, +3. the panel default, for a spreadsheet cell the user left empty. + +``ScanSettingsPanel._setting`` is that rule, and each panel spells out its +settings one line at a time in ``_write_active_values``. + +Committing a field - Enter, or leaving it - stores the number as the user's and +selects "User values". Only that one setting becomes the user's: everything +they did not touch keeps following the sample, so a new sample still brings its +own exposure time, angles and resolution. The toggle switches all of them back +and forth without forgetting anything. + +A number that was typed but never committed does not count: the field shows it +in the pending colour and the panel keeps using the value it had. What a scan +collects is read straight from the fields' committed values, so the panels keep +no second copy of them. +""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + from aarecommon.config.logger import setup_logger from aarecommon.math.diffraction_geometry import DiffractionGeometry -from aarecommon.models.models import DAQStatusModel, SessionsStateEnum -from PySide6.QtCore import Signal, Slot +from aarecommon.models.models import DAQStatusModel, SampleShortInfo, SessionsStateEnum +from PySide6.QtCore import Qt, Signal, Slot from PySide6.QtWidgets import ( QButtonGroup, QGridLayout, @@ -14,17 +43,67 @@ from PySide6.QtWidgets import ( from aare.gui.constants import LOGGER_NAME from aare.gui.widgets.message_box import precondition_check -from aare.gui.widgets.number_line_edit import DbOverrideLineEdit +from aare.gui.widgets.number_line_edit import NumberLineEdit logger = setup_logger(LOGGER_NAME) +def _as_float(value: Any) -> float | None: + """Spreadsheet values arrive as numbers or as strings; an empty cell is None.""" + return None if value is None else float(value) + + +@dataclass +class SampleParameters: + """The mounted sample's data collection parameters, in the panels' own + vocabulary. ``from_sample`` is the only place that knows the aareDB + spreadsheet column names. Every field is optional - the cell may be empty. + """ + + resolution_a: float | None = None + transmission: float | None = None + total_angle_deg: float | None = None + image_angle_deg: float | None = None + exp_time_s: float | None = None + + @classmethod + def from_sample(cls, sample: SampleShortInfo | None) -> "SampleParameters": + params = None if sample is None else sample.aaredb_params + if params is None: + return cls() + transmission = _as_float(params.transmission) + if transmission is not None and transmission > 1.0: + # aareDB holds transmission either as a fraction or as a percentage + transmission = transmission / 100.0 + return cls( + resolution_a=_as_float(params.targetresolution), + transmission=transmission, + total_angle_deg=_as_float(params.totalrange), + image_angle_deg=_as_float(params.oscillation), + exp_time_s=_as_float(params.exposure), + ) + + class ScanSettingsPanel(QWidget): + """Detector distance / resolution / transmission plus the Database-vs-User + toggle. See the module docstring for where the numbers come from. + + A panel deriving from this one: + + * creates its own fields and registers them with :meth:`_add_field` (panel + only) or :meth:`_add_database_field` (also filled from the spreadsheet), + * keeps one ``_user_`` attribute per database-backed field of its + own, set by that field's commit slot, + * extends :meth:`_write_active_values` with a line per setting, + * ends its ``__init__`` with ``self._values_changed()``, so its read-outs + start out right once every field exists. + """ + dtz_updated = Signal(float) - dtz_move = Signal(float) + transmission_updated = Signal(float) + # TODO min and max dtz is set by beamline add max MIN_DTZ = 108.0 # this is beamline dependent - transmission_updated = Signal(float) def __init__( self, @@ -35,32 +114,30 @@ class ScanSettingsPanel(QWidget): ): super().__init__(parent) self._diffraction = diffraction + self._default_dtz = default_dtz + self._default_transmission = default_transmission - self._transmission = default_transmission - self._screening_transmission = default_transmission - self._dtz = default_dtz - self._high_res = diffraction.resolution_angstrom(self._dtz) - - self._sample = None - self._params = None - self._last_sample_id = None - self._previous_sample_was_none = True - self._sample_space_group = None - self._sample_cell_parameters = None - self._sample_pdb_id = None - self._target_dose = None + self._beamline_state = None self._ring_current = None self._experiment_shutter_state = None self._door_prohibited = None + self._can_edit_params = False - # Fields whose value can come from the database or be overridden by the - # user; the panel-level toggle drives them all together. - self._override_fields: list[DbOverrideLineEdit] = [] - self._source = DbOverrideLineEdit.SOURCE_DB + # Parameters of the mounted sample; all-empty while nothing is mounted. + self._db_params = SampleParameters() - # Outer layout: a Database/My-values toggle above the settings grid. - # Subclasses keep adding their widgets to self._layout (the grid) as - # before, so they are unaffected by the wrapping. + # The settings the user typed in; None means "follow the sample". + self._user_resolution: float | None = None + self._user_dtz: float | None = None + self._user_transmission: float | None = None + + # Every numeric field of the panel, locked while the beamline is busy. + self._fields: list[NumberLineEdit] = [] + self._show_user_values = False + + # Outer layout: the Database/User toggle above the settings grid. + # Subclasses keep adding their widgets to self._layout (the grid), so + # they are unaffected by the wrapping. outer = QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) outer.setSpacing(0) @@ -74,264 +151,201 @@ class ScanSettingsPanel(QWidget): self._layout.setContentsMargins(m.left(), 6, m.right(), 3) outer.addWidget(grid_host) - self._layout.addWidget(QLabel("High resolution", parent=self), 0, 0) - self.high_res_enter = DbOverrideLineEdit( - 1.0, 10, default=self._high_res, decimals=2, parent=self + # Resolution and detector distance are two views of one setting: + # committing either one rewrites the other. + default_resolution = self._dtz_to_res(default_dtz) + self.high_res_enter = NumberLineEdit( + 1.0, 10, default=default_resolution, decimals=2, parent=self, track_pending=True ) - self._layout.addWidget(self.high_res_enter, 0, 1, 1, 3) - self._layout.addWidget(QLabel("Å", parent=self), 0, 4) + self._add_row(0, "High resolution", self.high_res_enter, "Å") + self._add_database_field(self.high_res_enter, self._on_resolution_committed) - self._layout.addWidget(QLabel("Detector distance", parent=self), 1, 0) - self.dtz_enter = DbOverrideLineEdit( - self.MIN_DTZ, 1000, default=self._dtz, decimals=2, parent=self + self.dtz_enter = NumberLineEdit( + self.MIN_DTZ, 1000, default=default_dtz, decimals=2, parent=self, track_pending=True ) - self._layout.addWidget(self.dtz_enter, 1, 1, 1, 3) - self._layout.addWidget(QLabel("mm", parent=self), 1, 4) + self._add_row(1, "Detector distance", self.dtz_enter, "mm") + self._add_database_field(self.dtz_enter, self._on_dtz_committed) - self._layout.addWidget(QLabel("Rotation transmission", parent=self), 2, 0) - self.transmission_enter = DbOverrideLineEdit( - 0, 1.0, default=self._transmission, decimals=4, parent=self + self.transmission_enter = NumberLineEdit( + 0, 1.0, default=default_transmission, decimals=4, parent=self, track_pending=True ) - self._layout.addWidget(self.transmission_enter, 2, 1, 1, 3) - self.transmission_enter.valueChanged.connect(self.set_transmission) - self._register_override_field(self.transmission_enter) + self._add_row(2, "Rotation transmission", self.transmission_enter) + self._add_database_field(self.transmission_enter, self._on_transmission_committed) - # dtz and resolution are two views of one quantity. They share the - # source toggle; ``valueChanged`` keeps internal state/downstream in - # sync (toggle, db updates), while ``edited`` (a real user edit) derives - # the partner and switches the panel to "My values". - self.dtz_enter.valueChanged.connect(self._on_dtz_value_changed) - self.high_res_enter.valueChanged.connect(self._on_high_res_value_changed) - self.dtz_enter.edited.connect(self._on_dtz_edited) - self.high_res_enter.edited.connect(self._on_high_res_edited) - self._override_fields.append(self.dtz_enter) - self._override_fields.append(self.high_res_enter) + # -- grid rows ---------------------------------------------------------- + def _add_row( + self, row: int, label: str, field: QWidget, unit: str = "", trailing: QWidget | None = None + ) -> None: + """One row of the settings grid: label, the field, its unit. A read-out + (a QLabel) is right-aligned like the input boxes. ``trailing`` takes the + last column and narrows the field by one to make room for it.""" + self._layout.addWidget(QLabel(label, parent=self), row, 0) + self._align_readout(field) + span = 2 if trailing is not None else 3 + self._layout.addWidget(field, row, 1, 1, span) + if unit: + self._layout.addWidget(QLabel(unit, parent=self), row, 1 + span) + if trailing is not None: + self._layout.addWidget(trailing, row, 4) - self._reset_to_defaults() + def _add_pair_row( + self, row: int, label: str, first: QWidget, second: QWidget, unit: str = "" + ) -> None: + """A row holding two values side by side: label, first x second, unit.""" + self._layout.addWidget(QLabel(label, parent=self), row, 0) + self._layout.addWidget(QLabel(" x ", parent=self), row, 2) + for widget, column in ((first, 1), (second, 3)): + self._align_readout(widget) + self._layout.addWidget(widget, row, column) + if unit: + self._layout.addWidget(QLabel(unit, parent=self), row, 4) - # -- source toggle ----------------------------------------------------- + def _align_readout(self, widget: QWidget) -> None: + # NumberLineEdit aligns itself; a QLabel showing a value has to be told + if isinstance(widget, QLabel): + widget.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + + # -- field registration ------------------------------------------------- + def _add_field(self, field: NumberLineEdit) -> None: + """A numeric input with no spreadsheet counterpart: it is locked while + the beamline is busy, and the Database/User toggle leaves it alone.""" + self._fields.append(field) + + def _add_database_field( + self, field: NumberLineEdit, on_commit: Callable[[float], None] + ) -> None: + """A numeric input that also has a spreadsheet counterpart. ``on_commit`` + stores the committed number as the user's value for that setting and + calls :meth:`_user_took_over`.""" + self._add_field(field) + field.newValue.connect(on_commit) + + # -- Database / User toggle --------------------------------------------- def _build_source_toggle(self) -> QWidget: container = QWidget(self) row = QHBoxLayout(container) row.setContentsMargins(0, 0, 0, 0) - self._db_radio = QRadioButton("Database values", container) - self._mine_radio = QRadioButton("User values", container) - self._db_radio.setChecked(True) + self._database_radio = QRadioButton("Database values", container) + self._user_radio = QRadioButton("User values", container) + self._database_radio.setChecked(True) self._source_group = QButtonGroup(container) - self._source_group.addButton(self._db_radio) - self._source_group.addButton(self._mine_radio) - self._db_radio.toggled.connect(self._on_source_radio_toggled) - row.addWidget(self._db_radio) - row.addWidget(self._mine_radio) + self._source_group.addButton(self._database_radio) + self._source_group.addButton(self._user_radio) + # One connection is enough: toggled fires on both directions. + self._user_radio.toggled.connect(self._on_user_values_toggled) + row.addWidget(self._database_radio) + row.addWidget(self._user_radio) row.addStretch() return container - def _register_override_field(self, field: DbOverrideLineEdit): - self._override_fields.append(field) - field.edited.connect(self._on_override_field_edited) - @Slot(bool) - def _on_source_radio_toggled(self, _checked: bool): - source = ( - DbOverrideLineEdit.SOURCE_DB - if self._db_radio.isChecked() - else DbOverrideLineEdit.SOURCE_MINE - ) - if source != self._source: - self.set_source(source) + def _on_user_values_toggled(self, checked: bool): + self._show_user_values = checked + self._refresh_fields() + + def _user_took_over(self): + """Select "User values" and let the derived read-outs catch up. Called + by every commit slot, after it stored the setting the user changed.""" + self._show_user_values = True + # blocked: _on_user_values_toggled would rewrite the fields from here, + # in the middle of the commit that got us here + self._user_radio.blockSignals(True) + self._user_radio.setChecked(True) + self._user_radio.blockSignals(False) + self._values_changed() @Slot(float) - def _on_override_field_edited(self, _value: float): - # Editing any field means the user is now driving the panel. - if self._source != DbOverrideLineEdit.SOURCE_MINE: - self.set_source(DbOverrideLineEdit.SOURCE_MINE) + def _on_transmission_committed(self, value: float): + self._user_transmission = value + self._user_took_over() - def set_source(self, source: str): - self._source = source - # Reflect in the toggle without re-triggering the handler. - self._db_radio.blockSignals(True) - self._mine_radio.blockSignals(True) - self._db_radio.setChecked(source == DbOverrideLineEdit.SOURCE_DB) - self._mine_radio.setChecked(source == DbOverrideLineEdit.SOURCE_MINE) - self._db_radio.blockSignals(False) - self._mine_radio.blockSignals(False) - # Two phases: switch every field first, then resync downstream once all - # fields agree (so the dtz<->resolution coupling sees consistent state). - for field in self._override_fields: - field.set_source(source, emit=False) - for field in self._override_fields: - field.emit_value_changed() + @Slot(float) + def _on_resolution_committed(self, value: float): + self._user_resolution = value + self._user_dtz = self._res_to_dtz(value) + self.dtz_enter.force_update_value(self._user_dtz) + self._user_took_over() - @Slot(DAQStatusModel) - def update_daq_status(self, s: DAQStatusModel): - self.dtz_enter.update_limits(s.bl.dtz_min, s.bl.dtz_max) - self.high_res_enter.update_limits( - self._diffraction.resolution_angstrom(s.bl.dtz_min), - self._diffraction.resolution_angstrom(s.bl.dtz_max), + @Slot(float) + def _on_dtz_committed(self, value: float): + self._user_dtz = value + self._user_resolution = self._dtz_to_res(value) + self.high_res_enter.force_update_value(self._user_resolution) + self._user_took_over() + + def _refresh_fields(self): + """Show the values of the active source. A field that already shows its + value is left alone, so a refresh that changes nothing cannot steal + text the user is in the middle of typing.""" + self._write_active_values() + self._values_changed() + + def _setting(self, user: float | None, database: float | None, default: float) -> float: + """What a setting is worth: the value the user typed while "User + values" is selected, else the one the mounted sample asks for, else the + panel default.""" + if self._show_user_values and user is not None: + return user + if database is not None: + return database + return default + + def _write_active_values(self): + """Show every setting that follows the toggle. Panels extend this with + a line per setting of their own.""" + db = self._db_params + resolution = self._setting( + self._user_resolution, db.resolution_a, self._dtz_to_res(self._default_dtz) ) - self._diffraction = s.diffraction - self._ring_current = s.bl.ring_current_mA - self._experiment_shutter_state = s.bl.exp_shutter_open - self._door_prohibited = getattr(s.bl, "pss_prohibited", None) - can_edit = (not s.busy) and ( - s.session.session in (SessionsStateEnum.OwnedByYou, SessionsStateEnum.PendingElseToYou) + self.high_res_enter.set_committed_value(resolution) + # The distance follows the resolution unless the user set it directly. + self.dtz_enter.set_committed_value( + self._setting(self._user_dtz, None, self._res_to_dtz(resolution)) + ) + self.transmission_enter.set_committed_value( + self._setting(self._user_transmission, db.transmission, self._default_transmission) ) - self._can_edit_params = can_edit - # Lock/unlock the override fields - for w in (self.dtz_enter, self.high_res_enter, self.transmission_enter): - w.set_busy(not can_edit) - # Update sample and parameters - if s.sample is not None: - self._sample = s.sample - self._params = s.sample.aaredb_params if hasattr(s.sample, "aaredb_params") else None - # Enable reload button only if sample has parameters - self._previous_sample_was_none = False - - # Check if this is a new sample - if so, update parameters - if self._last_sample_id != s.sample.db_id: - self.update_data_collection_parameters() - else: - if not self._previous_sample_was_none: - logger.info("Sample cleared, resetting parameters to defaults") - # self._reset_to_defaults() - self._previous_sample_was_none = True # Mark that sample is now None - - self._sample = None - self._params = None - self._last_sample_id = None - - def _reset_to_defaults(self): - # Forget user overrides and follow the database/defaults again. - for w in self._override_fields: - w.reset() - self.set_source(DbOverrideLineEdit.SOURCE_DB) - self._dtz = float(self.dtz_enter.value) - self._transmission = float(self.transmission_enter.value) - self._high_res = float(self.high_res_enter.value) + def _values_changed(self) -> None: + """Runs whenever the effective values change: a commit, the toggle, a + new sample. Panels extend it to refresh their read-outs; here it pushes + the two settings the raster grid manager keeps a copy of.""" + self.dtz_updated.emit(self.dtz_enter.committed_value) + self.transmission_updated.emit(self.transmission_enter.committed_value) + # -- resolution <-> detector distance ----------------------------------- def _dtz_to_res(self, dtz: float) -> float: return self._diffraction.resolution_angstrom(dtz) def _res_to_dtz(self, res: float) -> float: - dtz = self._diffraction.calc_dtz_mm(res) - return max(dtz, self.MIN_DTZ) + return max(self._diffraction.calc_dtz_mm(res), self.MIN_DTZ) - @Slot(float) - def _on_dtz_value_changed(self, v: float): - # dtz display changed (user edit, toggle, or db update); keep internal - # state and push the effective dtz downstream. - self._dtz = v - self._high_res = self._dtz_to_res(v) - self.dtz_updated.emit(self._dtz) + # -- status ------------------------------------------------------------- + @Slot(DAQStatusModel) + def update_daq_status(self, s: DAQStatusModel): + self._diffraction = s.diffraction + self._beamline_state = s.state + self._ring_current = s.bl.ring_current_mA + self._experiment_shutter_state = s.bl.exp_shutter_open + self._door_prohibited = getattr(s.bl, "pss_prohibited", None) - @Slot(float) - def _on_high_res_value_changed(self, v: float): - # Resolution display changed; dtz_updated is emitted by the dtz field - # itself on toggle/db updates, so just track internal state here. - self._high_res = v + self.dtz_enter.update_limits(s.bl.dtz_min, s.bl.dtz_max) + self.high_res_enter.update_limits( + self._dtz_to_res(s.bl.dtz_min), self._dtz_to_res(s.bl.dtz_max) + ) - @Slot(float) - def _on_dtz_edited(self, v: float): - # A real user edit of dtz: derive resolution, write both "mine" stores, - # then switch the whole panel to "My values". - self._dtz = v - self._high_res = self._dtz_to_res(v) - self.high_res_enter.set_mine_silently(self._high_res) - if self._source != DbOverrideLineEdit.SOURCE_MINE: - self.set_source(DbOverrideLineEdit.SOURCE_MINE) - self.dtz_updated.emit(self._dtz) + self._can_edit_params = (not s.busy) and ( + s.session.session in (SessionsStateEnum.OwnedByYou, SessionsStateEnum.PendingElseToYou) + ) + for field in self._fields: + field.setReadOnly(not self._can_edit_params) - @Slot(float) - def _on_high_res_edited(self, v: float): - # A real user edit of resolution: derive dtz, write both "mine" stores, - # then switch the whole panel to "My values". - self._high_res = v - self._dtz = self._res_to_dtz(v) - self.dtz_enter.set_mine_silently(self._dtz) - if self._source != DbOverrideLineEdit.SOURCE_MINE: - self.set_source(DbOverrideLineEdit.SOURCE_MINE) - self.dtz_updated.emit(self._dtz) - - @Slot(float) - def set_screening_transmission(self, v: float): - self._screening_transmission = v - - @Slot(float) - def set_transmission(self, v: float): - self._transmission = v - self.transmission_updated.emit(self._transmission) - - @Slot() - def reload_parameters(self): - logger.info("Reloading parameters from spreadsheet") - temp_id = self._last_sample_id - self._last_sample_id = None - self.update_data_collection_parameters() - self._last_sample_id = temp_id - # Show the freshly-loaded database values. - self.set_source(DbOverrideLineEdit.SOURCE_DB) - - def update_data_collection_parameters(self): - - if self._sample is None: - return - - # Update the last sample ID to prevent repeated updates - self._last_sample_id = self._sample.db_id - - # If no parameters exist, we keep the current (default) values - if self._params is None: - return - - # Get parameter mappings from child class - param_mappings = self.get_parameter_mappings() - - # Update the database side of each parameter (a user override persists). - for param_name, widget, converter in param_mappings: - value = getattr(self._params, param_name, None) - if value is not None: - converted = converter(value) if converter else value - if hasattr(widget, "set_db_value"): - widget.set_db_value(converted) - elif hasattr(widget, "force_update_value"): - widget.force_update_value(converted) - else: - widget.update_value(converted) - - # Handle transmission with conversion (common to all panels) - if (transmission := getattr(self._params, "transmission", None)) is not None: - transmission_value = transmission / 100.0 if transmission > 1.0 else transmission - self._transmission = transmission_value - self.transmission_enter.set_db_value(transmission_value) - - # Handle target resolution (common to all panels). dtz is derived from - # the resolution so its database value is kept consistent here. - if (target_res := getattr(self._params, "targetresolution", None)) is not None: - self._apply_db_resolution(float(target_res)) - - # Store metadata (common to all panels) - self._sample_space_group = getattr(self._params, "spacegroupnumber", None) - self._sample_cell_parameters = getattr(self._params, "cellparameters", None) - self._sample_pdb_id = getattr(self._params, "pdbid", None) - self._target_dose = getattr(self._params, "dose", None) - - def _apply_db_resolution(self, target_res: float): - """Set the database resolution and the matching database dtz so the - linked pair stays consistent regardless of the active source.""" - self._high_res = target_res - self._dtz = self._res_to_dtz(target_res) - self.high_res_enter.set_db_value(target_res) - self.dtz_enter.set_db_value(self._dtz) - - def get_parameter_mappings(self): - """Return a list of (param_name, widget, converter) tuples. - Child classes should override this to specify their specific parameters. - - Returns: - List of tuples: (parameter_name, widget, converter_function or None) - """ - return [] + # A different sample, or an edited spreadsheet row for the same one. + db_params = SampleParameters.from_sample(s.sample) + if db_params != self._db_params: + logger.info(f"Sample parameters changed to {db_params}") + self._db_params = db_params + self._refresh_fields() def check_before_run(self, scan_kind: str): if not precondition_check( diff --git a/src/aare/gui/widgets/number_line_edit.py b/src/aare/gui/widgets/number_line_edit.py index 13584a64..e9ea2bed 100644 --- a/src/aare/gui/widgets/number_line_edit.py +++ b/src/aare/gui/widgets/number_line_edit.py @@ -105,6 +105,20 @@ class NumberLineEdit(QLineEdit): def value(self) -> float: return float(self.text()) + @property + def committed_value(self) -> float: + """The value that was applied last, by Enter/focus-out or by a + programmatic write. Unlike ``value`` it ignores text that is still + being typed, and it never raises on a half-written number.""" + return self._applied_value + + def set_committed_value(self, val: float): + """Show ``val`` as the committed value, unless the field already shows + it. Skipping the rewrite keeps a refresh that changes nothing from + stealing text the user is in the middle of typing.""" + if self.to_string(val) != self.to_string(self._applied_value): + self.force_update_value(val) + @Slot(float) def update_value(self, val: float): if abs(val - self.saved_value) > 0.001: @@ -267,148 +281,3 @@ class CheckedLineEdit(QWidget): def reset_to_default(self): # Force set to initial default self.force_update_value(self._internal_value) - - -class DbOverrideLineEdit(QWidget): - """A numeric field backed by two sources: a database value and the user's - own value, with a panel-level toggle choosing which is shown. - - Behaviour: - * Until the user edits the field, "mine" mirrors the database value, so a - fresh field just follows the database. - * Editing records the user's value, marks the field as user-edited, flips - the source to "mine", and emits ``edited`` so the owning panel can move - the whole panel to "My values". - * Switching back to "Database values" shows the database value but keeps - the user's value, so it can be recovered by switching to "My values". - * Database updates (new sample, status refresh) only overwrite the - database value; a user-edited value therefore persists across samples. - - The widget has no checkbox of its own - the panel drives ``set_source`` for - all of its fields together. - """ - - # active value changed (user edit, source toggle, or db update while showing db) - valueChanged = Signal(float) - # user finished editing -> the owning panel should switch to "My values" - edited = Signal(float) - - SOURCE_DB = "db" - SOURCE_MINE = "mine" - - def __init__( - self, - min_val: float, - max_val: float, - /, - default: float = 0.0, - decimals: int = 2, - parent=None, - ): - super().__init__(parent) - self._default = float(default) - self._db_value = float(default) - self._mine_value = float(default) - self._user_edited = False - self._source = self.SOURCE_DB - self._busy = False - - # pending color while typing: every DbOverride field lives in the - # Experiment configuration group, which opted into the feedback - self.editor = NumberLineEdit(min_val, max_val, default, decimals, self, track_pending=True) - self.editor.newValue.connect(self._on_editor_value) - - layout = QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self.editor) - self._apply_editable() - - # -- internal helpers -------------------------------------------------- - def _active_value(self) -> float: - return self._mine_value if self._source == self.SOURCE_MINE else self._db_value - - def _refresh_display(self): - self.editor.force_update_value(self._active_value()) - - def _apply_editable(self): - # Editable whenever the panel is not busy; editing implies "mine". - self.editor.setReadOnly(self._busy) - - def _on_editor_value(self, value: float): - self._user_edited = True - self._mine_value = value - self._source = self.SOURCE_MINE - self.valueChanged.emit(value) - self.edited.emit(value) - - # -- panel-facing API -------------------------------------------------- - def set_source(self, source: str, emit: bool = True): - """Show the database value or the user's value. The panel switches all - of its fields with emit=False, then calls emit_value_changed() once each - so downstream consumers resync after every field is in the new source.""" - if source not in (self.SOURCE_DB, self.SOURCE_MINE): - return - self._source = source - self._refresh_display() - if emit: - self.valueChanged.emit(self._active_value()) - - def emit_value_changed(self): - self.valueChanged.emit(self._active_value()) - - def source(self) -> str: - return self._source - - def set_db_value(self, value: float): - """Update the database value. Mirrors into the user's value while the - field has not been user-edited, so untouched fields follow the database; - a user-edited value is preserved (only the db side changes), which is - what lets a user's value persist across samples.""" - value = float(value) - changed = value != self._db_value - self._db_value = value - if not self._user_edited: - self._mine_value = value - # Only refresh/emit when the shown value actually changes, so repeated - # database pushes (e.g. live grid geometry) cannot feed back into a loop. - if self._source == self.SOURCE_DB and changed: - self._refresh_display() - self.valueChanged.emit(value) - - def set_mine_silently(self, value: float): - """Set the user's value from a linked field (e.g. the dtz<->resolution - pair) without emitting. Marks the field user-edited and refreshes the - display if the user's value is currently shown. No signal is emitted, so - the linked field (which drives the physics downstream) does not feed back - into a loop.""" - value = float(value) - self._mine_value = value - self._user_edited = True - if self._source == self.SOURCE_MINE: - self._refresh_display() - - def has_user_value(self) -> bool: - return self._user_edited - - def set_busy(self, busy: bool): - self._busy = busy - self._apply_editable() - - def update_limits(self, min_val: float, max_val: float): - self.editor.update_limits(min_val, max_val) - - def reset(self): - """Forget the user's value and return to the constructor default, - following the database again (used when the sample is cleared).""" - self._user_edited = False - self._db_value = self._default - self._mine_value = self._default - self._source = self.SOURCE_DB - self._refresh_display() - - @property - def value(self) -> float: - return self.editor.value - - def isReadOnly(self) -> bool: - return self.editor.isReadOnly() diff --git a/tests/unit/gui/test_data_collection_settings.py b/tests/unit/gui/test_data_collection_settings.py index 30d093c4..a1a45d2c 100644 --- a/tests/unit/gui/test_data_collection_settings.py +++ b/tests/unit/gui/test_data_collection_settings.py @@ -1,86 +1,71 @@ -"""Tests for the data-collection settings override model. +"""Tests for the scan settings panels. -Covers DbOverrideLineEdit (database value vs user's value, persistence across -samples, go-back-to-DB, recover-my-value) and the panel-level Database/My-values -toggle with the dtz<->resolution coupling that must hold in both modes. +Covers where the numbers in the fields come from (the mounted sample's +spreadsheet row, the user's own values, the panel defaults), that a user value +survives a new sample all the way into the scan request while the settings the +user did not touch keep following the sample, and that a number typed but never +committed does not take effect. """ import types +from typing import Any, cast import pytest from aarecommon.math.coordinate import Coordinate, SmargonCoordinate from aarecommon.math.diffraction_geometry import DiffractionGeometry -from aarecommon.models.models import SampleGeometryModel +from aarecommon.models.models import BeamlineStateEnum, SampleGeometryModel from PySide6.QtCore import Qt from aare.gui.panels.data_collection_settings import DataCollectionSettings from aare.gui.panels.raster_data_collection import RasterDataCollectionPanel from aare.gui.panels.rotation_data_collection import RotationDataCollectionPanel +from aare.gui.panels.scan_settings_panel import SampleParameters from aare.gui.scan_logic.raster_grid_manager import RasterGridManager -from aare.gui.widgets.number_line_edit import DbOverrideLineEdit, NumberLineEdit +from aare.gui.widgets.number_line_edit import NumberLineEdit -def _edit(field: DbOverrideLineEdit, text: str): - """Simulate a user typing into and committing a field.""" - field.editor.setText(text) - field.editor.on_editing_finished() +def _commit(field: NumberLineEdit, text: str): + """Type into a field and commit it, as Enter or leaving the field does.""" + field.setText(text) + field.on_editing_finished() + + +def _mount(panel, **params): + """Mount a sample whose spreadsheet row holds `params`; same two steps + update_daq_status takes when the parameters of the mounted sample change.""" + panel._db_params = SampleParameters(**params) + panel._refresh_fields() # --------------------------------------------------------------------------- -# Widget: DbOverrideLineEdit +# The spreadsheet row -> SampleParameters # --------------------------------------------------------------------------- -def test_db_override_follows_database_until_edited(qapp): - w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2) - assert w.source() == DbOverrideLineEdit.SOURCE_DB - assert not w.has_user_value() - - w.set_db_value(150.0) - assert w.value == 150.0 # untouched field tracks the database +def test_sample_parameters_translate_the_spreadsheet_row(): + params = types.SimpleNamespace( + targetresolution=1.5, + transmission=20.0, # percent + totalrange="180", # spreadsheet cells can arrive as text + oscillation=0.1, + exposure=0.02, + ) + sample = cast(Any, types.SimpleNamespace(aaredb_params=params)) + assert SampleParameters.from_sample(sample) == SampleParameters( + resolution_a=1.5, + transmission=0.2, + total_angle_deg=180.0, + image_angle_deg=0.1, + exp_time_s=0.02, + ) -def test_db_override_user_value_persists_and_recovers(qapp): - w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2) - - _edit(w, "300.00") - assert w.has_user_value() - assert w.source() == DbOverrideLineEdit.SOURCE_MINE - assert w.value == 300.0 - - # A new database value (e.g. next sample) must not clobber the user's value. - w.set_db_value(120.0) - assert w.value == 300.0 - - # Go back to the database value, then recover the user's value. - w.set_source(DbOverrideLineEdit.SOURCE_DB, emit=False) - assert w.value == 120.0 - w.set_source(DbOverrideLineEdit.SOURCE_MINE, emit=False) - assert w.value == 300.0 - - -def test_db_override_reset_forgets_user_value(qapp): - w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2) - _edit(w, "300.00") - w.reset() - assert w.value == 200.0 - assert w.source() == DbOverrideLineEdit.SOURCE_DB - assert not w.has_user_value() - - -def test_db_override_emits_value_changed_on_toggle(qapp): - w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2) - _edit(w, "300.00") # mine = 300, db = 200 - seen = [] - w.valueChanged.connect(lambda v: seen.append(v)) - w.set_source(DbOverrideLineEdit.SOURCE_DB) - assert seen[-1] == 200.0 # toggling pushes the now-active value downstream - w.set_source(DbOverrideLineEdit.SOURCE_MINE) - assert seen[-1] == 300.0 +def test_sample_parameters_are_empty_without_a_sample(): + assert SampleParameters.from_sample(None) == SampleParameters() # --------------------------------------------------------------------------- -# Panel: toggle + dtz/resolution coupling +# Rotation panel # --------------------------------------------------------------------------- @@ -104,71 +89,142 @@ def panel(qapp, diffraction): return RotationDataCollectionPanel(diffraction=diffraction) -def test_editing_dtz_updates_resolution_and_switches_to_mine(panel, diffraction): - _edit(panel.dtz_enter, "250.00") - assert panel._source == DbOverrideLineEdit.SOURCE_MINE - assert abs(panel.high_res_enter.value - diffraction.resolution_angstrom(250.0)) < 0.01 +@pytest.fixture +def runnable_panel(panel, monkeypatch): + """A rotation panel whose Run button reaches the request: beamline ready + and the file path/precondition dialogs answered.""" + panel._beamline_state = BeamlineStateEnum.SampleAlignment + monkeypatch.setattr(panel, "check_before_run", lambda scan_kind: True) + return panel -def test_editing_resolution_updates_dtz(panel, diffraction): - _edit(panel.high_res_enter, "2.00") - expected = max(panel.MIN_DTZ, diffraction.calc_dtz_mm(2.0)) - assert abs(panel.dtz_enter.value - expected) < 0.01 +def test_fields_follow_the_mounted_sample(panel): + _mount(panel, resolution_a=2.5, transmission=0.5, total_angle_deg=180.0, exp_time_s=0.02) + + assert panel.high_res_enter.committed_value == 2.5 + assert panel.dtz_enter.committed_value == pytest.approx(panel._res_to_dtz(2.5), abs=0.01) + assert panel.transmission_enter.committed_value == 0.5 + assert panel.total_angle.committed_value == 180.0 + assert panel.image_time_enter.committed_value == 0.02 + # An empty spreadsheet cell leaves the panel default in place. + assert panel.image_angle.committed_value == panel._default_image_angle -def test_coupling_holds_in_database_mode(panel, diffraction): - # Set a database resolution; dtz database value must stay consistent. - panel._apply_db_resolution(2.5) - assert panel._source == DbOverrideLineEdit.SOURCE_DB - expected_dtz = max(panel.MIN_DTZ, diffraction.calc_dtz_mm(2.5)) - assert abs(panel.dtz_enter.value - expected_dtz) < 0.01 - assert abs(panel.high_res_enter.value - 2.5) < 0.01 +def test_editing_resolution_updates_dtz_and_selects_user_values(panel): + _commit(panel.high_res_enter, "2.00") + + assert panel._show_user_values + assert panel._user_radio.isChecked() + assert panel.dtz_enter.committed_value == pytest.approx(panel._res_to_dtz(2.0), abs=0.01) -def test_toggle_pushes_active_value_downstream(panel): - emitted = [] - panel.dtz_updated.connect(lambda v: emitted.append(round(v, 2))) +def test_editing_dtz_updates_resolution(panel, diffraction): + _commit(panel.dtz_enter, "250.00") - _edit(panel.dtz_enter, "250.00") # mine dtz = 250 - mine_dtz = panel.dtz_enter.value - - panel.set_source(DbOverrideLineEdit.SOURCE_DB) - db_dtz = panel.dtz_enter.value - assert emitted[-1] == round(db_dtz, 2) # downstream got the db value - - panel.set_source(DbOverrideLineEdit.SOURCE_MINE) - assert panel.dtz_enter.value == mine_dtz # user value recovered - assert emitted[-1] == round(mine_dtz, 2) # downstream got the user value - - -def test_user_override_persists_across_samples(panel, diffraction): - # Sample 1 loads a database resolution. - panel._sample = types.SimpleNamespace(db_id=1) - panel._params = types.SimpleNamespace( - targetresolution=2.5, transmission=0.5, totalrange=180.0, oscillation=0.1, exposure=0.02 + assert panel.high_res_enter.committed_value == pytest.approx( + diffraction.resolution_angstrom(250.0), abs=0.01 ) - panel.update_data_collection_parameters() - # User overrides the exposure time. - _edit(panel.image_time_enter, "0.0500") - assert panel.image_time_enter.value == 0.05 - # Sample 2 arrives with a different database exposure. - panel._sample = types.SimpleNamespace(db_id=2) - panel._params = types.SimpleNamespace( - targetresolution=1.8, transmission=1.0, totalrange=360.0, oscillation=0.2, exposure=0.01 - ) - panel.update_data_collection_parameters() +def test_user_value_survives_the_next_sample(runnable_panel): + panel = runnable_panel + _mount(panel, resolution_a=2.5, transmission=0.5, total_angle_deg=180.0, exp_time_s=0.02) - # In My-values mode the user's exposure persists across the sample change. - assert panel.image_time_enter.value == 0.05 - # Going back to Database shows the new sample's database value. - panel.set_source(DbOverrideLineEdit.SOURCE_DB) - assert panel.image_time_enter.value == 0.01 + # The user overrides the resolution the spreadsheet asks for. + _commit(panel.high_res_enter, "3.00") + user_dtz = panel.dtz_enter.committed_value + + # Next sample, same spreadsheet values: the user's resolution stays put... + _mount(panel, resolution_a=2.5, transmission=0.5, total_angle_deg=180.0, exp_time_s=0.02) + assert panel.high_res_enter.committed_value == 3.00 + assert panel.dtz_enter.committed_value == user_dtz + + # ...and it is what the scan actually collects at. + requests = [] + panel.rotation_scan.connect(requests.append) + panel.run_measurement() + assert requests[-1].dtz == user_dtz + + +def test_untouched_settings_keep_following_the_sample(panel): + _mount(panel, total_angle_deg=180.0, exp_time_s=0.02) + _commit(panel.image_time_enter, "0.0500") # only the image time is the user's + + _mount(panel, total_angle_deg=360.0, exp_time_s=0.02) + assert panel.image_time_enter.committed_value == 0.05 + assert panel.total_angle.committed_value == 360.0 + + +def test_toggle_swaps_between_the_two_sets(panel): + _mount(panel, resolution_a=2.5, exp_time_s=0.02) + _commit(panel.image_time_enter, "0.0500") + + panel._database_radio.setChecked(True) # back to Database values + assert panel.image_time_enter.committed_value == 0.02 + assert panel.high_res_enter.committed_value == 2.5 + + panel._user_radio.setChecked(True) # the user's set is remembered + assert panel.image_time_enter.committed_value == 0.05 + + +def test_a_new_sample_is_shown_while_on_database_values(panel): + _mount(panel, exp_time_s=0.02) + assert panel.image_time_enter.committed_value == 0.02 + _mount(panel, exp_time_s=0.03) + assert panel.image_time_enter.committed_value == 0.03 + + +def test_text_that_was_never_entered_does_not_take_effect(runnable_panel): + panel = runnable_panel + _mount(panel, resolution_a=2.5, transmission=0.5, total_angle_deg=180.0, exp_time_s=0.02) + database_dtz = panel.dtz_enter.committed_value + + requests = [] + panel.rotation_scan.connect(requests.append) + + panel.high_res_enter.setText("3.00") # typed, never entered + panel.run_measurement() + assert not panel._show_user_values + assert requests[-1].dtz == database_dtz + + # Committing it is what makes it the user's. + panel.high_res_enter.on_editing_finished() + panel.run_measurement() + assert panel._show_user_values + assert requests[-1].dtz == pytest.approx(panel._res_to_dtz(3.00), abs=0.01) + + +def test_screening_transmission_is_the_panels_own(runnable_panel): + panel = runnable_panel + _mount(panel, transmission=0.5) + _commit(panel.screening_transmission_enter, "0.1000") + + requests = [] + panel.rotation_scan.connect(requests.append) + panel.run_screening() + + assert requests[-1].transmission == 0.1 # not the 0.5 the rotation uses + + +def test_downstream_gets_the_active_values(panel): + dtz_seen = [] + transmission_seen = [] + panel.dtz_updated.connect(dtz_seen.append) + panel.transmission_updated.connect(transmission_seen.append) + + _commit(panel.dtz_enter, "250.00") + assert dtz_seen[-1] == 250.0 + + # The transmission was never touched, so it keeps following the sample. + _mount(panel, transmission=0.4) + assert transmission_seen[-1] == 0.4 + + panel._database_radio.setChecked(True) + assert dtz_seen[-1] == pytest.approx(panel.dtz_enter.committed_value) # --------------------------------------------------------------------------- -# Raster grid element size follows the same override model +# Raster panel # --------------------------------------------------------------------------- @@ -191,33 +247,37 @@ def raster_panel(qapp, diffraction, geom): return RasterDataCollectionPanel(raster_mgr=mgr, diffraction=diffraction) -def test_grid_element_size_user_value_persists_over_geometry(raster_panel): +def test_grid_element_size_is_reported_and_echoed_back(raster_panel): emitted = [] raster_panel.grid_size_updated.connect(lambda x, y: emitted.append((x, y))) - _edit(raster_panel.width_enter, "20") - assert raster_panel._source == DbOverrideLineEdit.SOURCE_MINE - assert raster_panel.width_enter.value == 20.0 - assert emitted # editing pushed the new element size downstream + _commit(raster_panel.width_enter, "20") + assert emitted[-1][0] == pytest.approx(0.020) - # The live grid geometry keeps reporting a different element size; the - # user's value must persist (only the database side changes). - raster_panel.width_enter.set_db_value(35.0) - assert raster_panel.width_enter.value == 20.0 - - # Revert to the geometry value, then recover the user's value. - raster_panel.set_source(DbOverrideLineEdit.SOURCE_DB) - assert raster_panel.width_enter.value == 35.0 - raster_panel.set_source(DbOverrideLineEdit.SOURCE_MINE) - assert raster_panel.width_enter.value == 20.0 + # The grid manager answers with the geometry it settled on; the field + # follows it and does not emit again. + before = len(emitted) + raster_panel.grid_scan_size_change(4, 5, 0.035, 0.020) + assert raster_panel.width_enter.committed_value == 35.0 + assert len(emitted) == before -def test_grid_size_field_shares_panel_toggle(raster_panel): - # Editing the resolution flips the whole panel to "My values", which the - # grid-size field shares. - _edit(raster_panel.high_res_enter, "2.50") - assert raster_panel._source == DbOverrideLineEdit.SOURCE_MINE - assert raster_panel.width_enter.source() == DbOverrideLineEdit.SOURCE_MINE +def test_raster_exposure_follows_the_toggle(raster_panel): + _mount(raster_panel, exp_time_s=0.04) + assert raster_panel.image_time_enter.committed_value == 0.04 + + _commit(raster_panel.image_time_enter, "0.0800") + _mount(raster_panel, exp_time_s=0.04) + assert raster_panel.image_time_enter.committed_value == 0.08 + + raster_panel._database_radio.setChecked(True) + assert raster_panel.image_time_enter.committed_value == 0.04 + + +def test_grid_element_size_stays_out_of_the_toggle(raster_panel): + _commit(raster_panel.width_enter, "20") + # A panel-only field must not take the panel to user values. + assert not raster_panel._show_user_values # --------------------------------------------------------------------------- @@ -293,8 +353,10 @@ def test_typing_marks_pending_and_enter_commits(tracked_box, qtbot): _type(qtbot, tracked_box, "300") assert tracked_box.property("movestate") == "pending" assert not seen # nothing is applied until Enter/focus-out + assert tracked_box.committed_value == 100.0 qtbot.keyClick(tracked_box, Qt.Key.Key_Return) assert seen == [300.0] + assert tracked_box.committed_value == 300.0 assert tracked_box.property("movestate") == "" @@ -328,26 +390,13 @@ def test_programmatic_update_clears_pending(tracked_box, qtbot): assert tracked_box.property("movestate") == "" -def test_db_override_typing_pending_until_commit(qapp, qtbot): - w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2) - qtbot.addWidget(w) - _type(qtbot, w.editor, "300") - assert w.editor.property("movestate") == "pending" - qtbot.keyClick(w.editor, Qt.Key.Key_Return) - assert w.editor.property("movestate") == "" - assert w.value == 300.0 - assert w.source() == DbOverrideLineEdit.SOURCE_MINE - - -def test_db_override_source_toggle_clears_pending(qapp, qtbot): - w = DbOverrideLineEdit(0, 1000, default=200.0, decimals=2) - qtbot.addWidget(w) - _type(qtbot, w.editor, "300") # typing, no commit - assert w.editor.property("movestate") == "pending" - # a panel refresh (radio toggle, DB push) discards the uncommitted text - w.set_source(DbOverrideLineEdit.SOURCE_DB, emit=False) - assert w.editor.property("movestate") == "" - assert w.value == 200.0 +def test_set_committed_value_keeps_a_value_that_did_not_change(tracked_box, qtbot): + _type(qtbot, tracked_box, "300") # typing, no commit + tracked_box.set_committed_value(100.0) # what the field already holds + assert tracked_box.text() == "300" # the typing survives + tracked_box.set_committed_value(120.0) # a real change wins + assert tracked_box.committed_value == 120.0 + assert tracked_box.property("movestate") == "" def test_energy_spin_motor_move_semantics(settings_panel, daq_status_factory): diff --git a/tests/unit/gui/test_rotation_speed_limits.py b/tests/unit/gui/test_rotation_speed_limits.py index a0f652de..5299fd69 100644 --- a/tests/unit/gui/test_rotation_speed_limits.py +++ b/tests/unit/gui/test_rotation_speed_limits.py @@ -1,7 +1,9 @@ """Omega speed cap (500 deg/s) and per-beamline min image time on the rotation panel. -Why: QDoubleValidator only paints a field red, so the interlock lives in the -linked limits plus the Run-button gate. These fail if either half breaks. +Why: the image angle and the image time are each fine on their own - it is the +pair that can ask the goniometer to turn too fast. So the cap is a read-out +plus a gate on the Run button, and only the per-beamline minimum image time is +a limit of the field itself. """ import pytest @@ -29,19 +31,20 @@ def _panel(monkeypatch, beamline): return RotationDataCollectionPanel(diffraction=diffraction) -def _editor(field): - # NumberLineEdit is the editor itself; DbOverrideLineEdit wraps one in .editor - return getattr(field, "editor", field) +def _runnable(panel, monkeypatch): + panel._beamline_state = BeamlineStateEnum.SampleAlignment + monkeypatch.setattr(panel, "check_before_run", lambda scan_kind: True) + return panel def _commit(field, text): - _editor(field).setText(text) - _editor(field).on_editing_finished() + field.setText(text) + field.on_editing_finished() def _accepts(field, text) -> bool: - _editor(field).setText(text) - return _editor(field).hasAcceptableInput() + field.setText(text) + return field.hasAcceptableInput() @pytest.mark.parametrize( @@ -54,16 +57,35 @@ def test_min_image_time_per_beamline(qapp, monkeypatch, beamline, too_short, ok) assert _accepts(field, ok) -def test_speed_cap_couples_angle_and_time(qapp, monkeypatch): +def test_speed_readout_follows_the_pair(qapp, monkeypatch): panel = _panel(monkeypatch, "X06DA") - _commit(panel.image_angle, "1.000") # -> time floor 1/500 = 0.002 s - assert not _accepts(panel.image_time_enter, "0.0010") - assert _accepts(panel.image_time_enter, "0.0020") + _commit(panel.image_angle, "1.000") + _commit(panel.image_time_enter, "0.1000") + assert panel.omega_speed.text() == "10.0" + assert not panel.omega_speed.styleSheet() # within the cap - _commit(panel.image_time_enter, "0.0100") # -> angle max 5 deg - assert not _accepts(panel.image_angle, "6.000") - assert _accepts(panel.image_angle, "4.000") + _commit(panel.image_time_enter, "0.0012") # 833 deg/s + assert panel.omega_speed.text() == "833.3" + assert panel.omega_speed.styleSheet() # marked red + + +def test_run_blocked_above_the_speed_cap(qapp, monkeypatch): + panel = _runnable(_panel(monkeypatch, "X06DA"), monkeypatch) + boxes = [] + monkeypatch.setattr(QMessageBox, "critical", lambda *a, **k: boxes.append(a)) + emitted = [] + panel.rotation_scan.connect(emitted.append) + + _commit(panel.image_angle, "1.000") + _commit(panel.image_time_enter, "0.0012") # 833 deg/s, over the cap + panel.run_measurement() + assert emitted == [] + assert len(boxes) == 1 + + _commit(panel.image_time_enter, "0.0100") # 100 deg/s + panel.run_measurement() + assert len(emitted) == 1 def test_run_blocked_while_field_red(qapp, monkeypatch): @@ -74,7 +96,7 @@ def test_run_blocked_while_field_red(qapp, monkeypatch): panel.rotation_scan.connect(emitted.append) panel._beamline_state = BeamlineStateEnum.SampleAlignment - _editor(panel.image_time_enter).setText("0.0010") # below 1/900 s, stays red + panel.image_time_enter.setText("0.0010") # below 1/900 s, stays red panel.run_measurement() assert emitted == [] -- 2.54.0 From 85e0609aa45bb23a233ce1ff41e72cee311f4a41 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Mon, 7 Sep 2026 16:54:16 +0200 Subject: [PATCH 2/7] fix: read the aareDB total angle from the column that exists The column is totalangle, not totalrange, so reading it raised AttributeError four times a second in the status loop. Reading a spreadsheet cell now goes through _spreadsheet_float, which treats a missing column, an empty cell and a cell that does not hold a number all as "not set" and logs the column once. aareDB owns these names; the GUI should fall back to its defaults when one moves, not die on the status loop it is read from. Note that the same misspelling is still in daq.py (spreadsheet_params and get_auto_raster_params), where getattr's default hides it: automation has never picked up the total angle from the spreadsheet either. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JHoUkj66jxByS2ypY5h9Mn --- src/aare/gui/panels/scan_settings_panel.py | 31 ++++++++++++++----- .../unit/gui/test_data_collection_settings.py | 10 +++++- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/aare/gui/panels/scan_settings_panel.py b/src/aare/gui/panels/scan_settings_panel.py index 04d67301..f6085ade 100644 --- a/src/aare/gui/panels/scan_settings_panel.py +++ b/src/aare/gui/panels/scan_settings_panel.py @@ -48,9 +48,24 @@ from aare.gui.widgets.number_line_edit import NumberLineEdit logger = setup_logger(LOGGER_NAME) -def _as_float(value: Any) -> float | None: - """Spreadsheet values arrive as numbers or as strings; an empty cell is None.""" - return None if value is None else float(value) +# Columns already reported as unusable, so the status loop says it once. +_BAD_COLUMNS: set[str] = set() + + +def _spreadsheet_float(params: Any, column: str) -> float | None: + """One cell of the sample's spreadsheet row, as a float. An empty cell, a + cell that does not hold a number, and a column aareDB does not have all + read as None: aareDB owns these names, and a renamed column must not kill + the status loop. Each bad column is reported once - pydantic's own message + names the column it expected instead.""" + try: + value = getattr(params, column) + return None if value is None else float(value) + except (AttributeError, TypeError, ValueError) as e: + if column not in _BAD_COLUMNS: + _BAD_COLUMNS.add(column) + logger.error(f"Ignoring the aareDB parameter {column!r}: {e}") + return None @dataclass @@ -71,16 +86,16 @@ class SampleParameters: params = None if sample is None else sample.aaredb_params if params is None: return cls() - transmission = _as_float(params.transmission) + transmission = _spreadsheet_float(params, "transmission") if transmission is not None and transmission > 1.0: # aareDB holds transmission either as a fraction or as a percentage transmission = transmission / 100.0 return cls( - resolution_a=_as_float(params.targetresolution), + resolution_a=_spreadsheet_float(params, "targetresolution"), transmission=transmission, - total_angle_deg=_as_float(params.totalrange), - image_angle_deg=_as_float(params.oscillation), - exp_time_s=_as_float(params.exposure), + total_angle_deg=_spreadsheet_float(params, "totalangle"), + image_angle_deg=_spreadsheet_float(params, "oscillation"), + exp_time_s=_spreadsheet_float(params, "exposure"), ) diff --git a/tests/unit/gui/test_data_collection_settings.py b/tests/unit/gui/test_data_collection_settings.py index a1a45d2c..70d296a8 100644 --- a/tests/unit/gui/test_data_collection_settings.py +++ b/tests/unit/gui/test_data_collection_settings.py @@ -46,7 +46,7 @@ def test_sample_parameters_translate_the_spreadsheet_row(): params = types.SimpleNamespace( targetresolution=1.5, transmission=20.0, # percent - totalrange="180", # spreadsheet cells can arrive as text + totalangle="180", # spreadsheet cells can arrive as text oscillation=0.1, exposure=0.02, ) @@ -64,6 +64,14 @@ def test_sample_parameters_are_empty_without_a_sample(): assert SampleParameters.from_sample(None) == SampleParameters() +def test_a_column_aaredb_does_not_have_is_ignored(): + # aareDB owns these names: a renamed column must leave the panel on its + # defaults, not break the status loop it is read from. + params = types.SimpleNamespace(targetresolution=1.5) + sample = cast(Any, types.SimpleNamespace(aaredb_params=params)) + assert SampleParameters.from_sample(sample) == SampleParameters(resolution_a=1.5) + + # --------------------------------------------------------------------------- # Rotation panel # --------------------------------------------------------------------------- -- 2.54.0 From daabf830d71a5b3d1e83398ffc7519a811c3d5ec Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Mon, 7 Sep 2026 16:59:53 +0200 Subject: [PATCH 3/7] fix: read the aareDB transmission as the percentage it is DataCollectionParameters.transmission is a StrictInt documented as "positive, between 0 and 100" (aareDB's own API description of the spreadsheet payload), while every model that consumes it wants a 0-to-1 fraction. The old "divide by 100 only when above 1.0" guess therefore turned a spreadsheet asking for 1% into a scan at 100% transmission - a hundredfold dose on the sample. A fraction cannot be stored in that column at all: the model rejects 0.2. The mapping test now builds a real DataCollectionParameters instead of a stand-in namespace, so a renamed column fails the test rather than the beamline. That is what let 'totalrange' through. daq.py's spreadsheet_params and get_auto_raster_params still carry both the old column name and the same transmission guess. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JHoUkj66jxByS2ypY5h9Mn --- src/aare/gui/panels/scan_settings_panel.py | 6 +- src/aare/gui/threads/prediction_subscriber.py | 71 ++++++++++++++++--- .../unit/gui/test_data_collection_settings.py | 30 +++++--- 3 files changed, 89 insertions(+), 18 deletions(-) diff --git a/src/aare/gui/panels/scan_settings_panel.py b/src/aare/gui/panels/scan_settings_panel.py index f6085ade..5ef1750a 100644 --- a/src/aare/gui/panels/scan_settings_panel.py +++ b/src/aare/gui/panels/scan_settings_panel.py @@ -86,9 +86,11 @@ class SampleParameters: params = None if sample is None else sample.aaredb_params if params is None: return cls() + # aareDB holds transmission as a percentage (DataCollectionParameters + # .transmission is an int, 0 to 100); everything here works in the + # 0-to-1 fraction the scan requests are built from. transmission = _spreadsheet_float(params, "transmission") - if transmission is not None and transmission > 1.0: - # aareDB holds transmission either as a fraction or as a percentage + if transmission is not None: transmission = transmission / 100.0 return cls( resolution_a=_spreadsheet_float(params, "targetresolution"), diff --git a/src/aare/gui/threads/prediction_subscriber.py b/src/aare/gui/threads/prediction_subscriber.py index 85b5a24f..5eb6f823 100644 --- a/src/aare/gui/threads/prediction_subscriber.py +++ b/src/aare/gui/threads/prediction_subscriber.py @@ -1,5 +1,6 @@ import json import time +from threading import Lock import cv2 import numpy as np @@ -14,6 +15,17 @@ from aare.gui.constants import LOGGER_NAME logger = setup_logger(LOGGER_NAME) +# Frames handed to the GUI thread but not painted yet. The image signal is a +# queued connection, so without a cap the subscriber hands over frames faster +# than they can be painted and the Qt event queue grows without bound - lag +# that no amount of draining on the socket can fix. +MAX_FRAMES_IN_FLIGHT = 1 + +# The socket's own queue. Only a memory bound: _recv_latest() drops whatever +# piled up behind the newest frame anyway. Has to stay above the number of +# parts in a frame, or the pipe cannot assemble a multipart message. +RCV_QUEUE_MESSAGES = 10 + class PredictionSubscriber(QThread): prediction = Signal(dict) @@ -30,6 +42,14 @@ class PredictionSubscriber(QThread): self._sock = self._ctx.socket(zmq.SUB) self._sock.setsockopt(zmq.RCVTIMEO, 500) self._sock.setsockopt(zmq.LINGER, 0) + self._sock.setsockopt(zmq.RCVHWM, RCV_QUEUE_MESSAGES) + + # Delivery of an image frees the slot for the next one. Queued back to + # the thread this object lives in (the GUI thread), like the painting + # slots, so it runs once the GUI has worked through the frame. + self._frames_in_flight = 0 + self._frames_in_flight_lock = Lock() + self.image.connect(self._on_image_delivered) self._emit_images = True self.running = True @@ -62,6 +82,37 @@ class PredictionSubscriber(QThread): def set_emit_images(self, enabled: bool) -> None: self._emit_images = enabled + def _recv_latest(self) -> tuple[list[bytes], int]: + """The newest frame on the socket, plus how many older ones were + dropped to get to it. This is what CONFLATE would do, except that + CONFLATE keeps a single message *part* and every frame here is + multipart (header, image, detections), so it cannot be used.""" + sock = self._sock + if sock is None: # run() has already torn the socket down + raise zmq.Again + parts = sock.recv_multipart() + dropped = 0 + while True: + try: + parts = sock.recv_multipart(zmq.NOBLOCK) + except zmq.Again: + return parts, dropped + dropped += 1 + + @Slot(QPixmap) + def _on_image_delivered(self, _pixmap: QPixmap) -> None: + with self._frames_in_flight_lock: + self._frames_in_flight = max(0, self._frames_in_flight - 1) + + def _gui_ready_for_frame(self) -> bool: + with self._frames_in_flight_lock: + return self._frames_in_flight < MAX_FRAMES_IN_FLIGHT + + def _emit_image(self, pixmap: QPixmap) -> None: + with self._frames_in_flight_lock: + self._frames_in_flight += 1 + self.image.emit(pixmap) + def _set_camera_available(self, available: bool, error: str | None = None) -> None: if available != self._camera_available: self._camera_available = available @@ -152,12 +203,10 @@ class PredictionSubscriber(QThread): self.focus_measure.emit(sharpness) def run(self): - self._debug_last_log_ts = time.perf_counter() - self._debug_msg_count = 0 try: while self.running: try: - parts = self._sock.recv_multipart() + parts, dropped = self._recv_latest() except zmq.Again: now = time.perf_counter() elapsed = now - self._fps_window_start @@ -179,7 +228,8 @@ class PredictionSubscriber(QThread): now = time.perf_counter() self._last_frame_time = now - self._fps_frame_count += 1 + # dropped frames count too: this is the camera's rate, not ours + self._fps_frame_count += 1 + dropped elapsed = now - self._fps_window_start if elapsed >= self._fps_emit_period_s: @@ -211,7 +261,12 @@ class PredictionSubscriber(QThread): target = next((d for d in json_dicts if "target_point" in d), None) image_bytes = max(non_json_parts, key=len) if non_json_parts else None - if self._emit_images and header and image_bytes: + # The decode is skipped along with the emit: a frame the GUI + # is too busy to paint is not worth decoding. The sharpness + # read-out is the exception, it works on the decoded image. + emit_image = self._emit_images and self._gui_ready_for_frame() + + if header and image_bytes and (emit_image or self._measure_focus): rgb = self._decode_rgb_image(header, image_bytes) if rgb is None: self._set_camera_available( @@ -220,9 +275,9 @@ class PredictionSubscriber(QThread): else: self._set_camera_available(True) self._emit_focus_measure_if_enabled(rgb) - if self.running: - self.image.emit(self._rgb_to_pixmap(rgb)) - elif self._emit_images: + if emit_image and self.running: + self._emit_image(self._rgb_to_pixmap(rgb)) + elif self._emit_images and not (header and image_bytes): self._set_camera_available( False, "Sample camera feed unavailable: no frame header in zmq stream" ) diff --git a/tests/unit/gui/test_data_collection_settings.py b/tests/unit/gui/test_data_collection_settings.py index 70d296a8..07333279 100644 --- a/tests/unit/gui/test_data_collection_settings.py +++ b/tests/unit/gui/test_data_collection_settings.py @@ -13,7 +13,12 @@ from typing import Any, cast import pytest from aarecommon.math.coordinate import Coordinate, SmargonCoordinate from aarecommon.math.diffraction_geometry import DiffractionGeometry -from aarecommon.models.models import BeamlineStateEnum, SampleGeometryModel +from aarecommon.models.models import ( + BeamlineStateEnum, + DataCollectionParameters, + SampleGeometryModel, + SampleShortInfo, +) from PySide6.QtCore import Qt from aare.gui.panels.data_collection_settings import DataCollectionSettings @@ -43,14 +48,23 @@ def _mount(panel, **params): def test_sample_parameters_translate_the_spreadsheet_row(): - params = types.SimpleNamespace( - targetresolution=1.5, - transmission=20.0, # percent - totalangle="180", # spreadsheet cells can arrive as text - oscillation=0.1, - exposure=0.02, + # The real model, so the column names are checked against aareDB and not + # against a stand-in that agrees with whatever this file happens to say. + sample = SampleShortInfo( + db_id=1, + puck_name="puck1", + dewar_name="dewar1", + sample_name="sample1", + run_number=1, + pin=1, + aaredb_params=DataCollectionParameters( + targetresolution=1.5, + transmission=20, # aareDB stores a percentage + totalangle=180, + oscillation=0.1, + exposure=0.02, + ), ) - sample = cast(Any, types.SimpleNamespace(aaredb_params=params)) assert SampleParameters.from_sample(sample) == SampleParameters( resolution_a=1.5, transmission=0.2, -- 2.54.0 From 179d4346cef09d6ff7bca15f5e040a8a7e1a1f4e Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Mon, 7 Sep 2026 17:00:21 +0200 Subject: [PATCH 4/7] revert: drop the ZMQ video change from this branch Committed by mistake in daabf83 - the video stream work was explicitly put on hold and does not belong in this branch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JHoUkj66jxByS2ypY5h9Mn --- src/aare/gui/threads/prediction_subscriber.py | 71 +++---------------- 1 file changed, 8 insertions(+), 63 deletions(-) diff --git a/src/aare/gui/threads/prediction_subscriber.py b/src/aare/gui/threads/prediction_subscriber.py index 5eb6f823..85b5a24f 100644 --- a/src/aare/gui/threads/prediction_subscriber.py +++ b/src/aare/gui/threads/prediction_subscriber.py @@ -1,6 +1,5 @@ import json import time -from threading import Lock import cv2 import numpy as np @@ -15,17 +14,6 @@ from aare.gui.constants import LOGGER_NAME logger = setup_logger(LOGGER_NAME) -# Frames handed to the GUI thread but not painted yet. The image signal is a -# queued connection, so without a cap the subscriber hands over frames faster -# than they can be painted and the Qt event queue grows without bound - lag -# that no amount of draining on the socket can fix. -MAX_FRAMES_IN_FLIGHT = 1 - -# The socket's own queue. Only a memory bound: _recv_latest() drops whatever -# piled up behind the newest frame anyway. Has to stay above the number of -# parts in a frame, or the pipe cannot assemble a multipart message. -RCV_QUEUE_MESSAGES = 10 - class PredictionSubscriber(QThread): prediction = Signal(dict) @@ -42,14 +30,6 @@ class PredictionSubscriber(QThread): self._sock = self._ctx.socket(zmq.SUB) self._sock.setsockopt(zmq.RCVTIMEO, 500) self._sock.setsockopt(zmq.LINGER, 0) - self._sock.setsockopt(zmq.RCVHWM, RCV_QUEUE_MESSAGES) - - # Delivery of an image frees the slot for the next one. Queued back to - # the thread this object lives in (the GUI thread), like the painting - # slots, so it runs once the GUI has worked through the frame. - self._frames_in_flight = 0 - self._frames_in_flight_lock = Lock() - self.image.connect(self._on_image_delivered) self._emit_images = True self.running = True @@ -82,37 +62,6 @@ class PredictionSubscriber(QThread): def set_emit_images(self, enabled: bool) -> None: self._emit_images = enabled - def _recv_latest(self) -> tuple[list[bytes], int]: - """The newest frame on the socket, plus how many older ones were - dropped to get to it. This is what CONFLATE would do, except that - CONFLATE keeps a single message *part* and every frame here is - multipart (header, image, detections), so it cannot be used.""" - sock = self._sock - if sock is None: # run() has already torn the socket down - raise zmq.Again - parts = sock.recv_multipart() - dropped = 0 - while True: - try: - parts = sock.recv_multipart(zmq.NOBLOCK) - except zmq.Again: - return parts, dropped - dropped += 1 - - @Slot(QPixmap) - def _on_image_delivered(self, _pixmap: QPixmap) -> None: - with self._frames_in_flight_lock: - self._frames_in_flight = max(0, self._frames_in_flight - 1) - - def _gui_ready_for_frame(self) -> bool: - with self._frames_in_flight_lock: - return self._frames_in_flight < MAX_FRAMES_IN_FLIGHT - - def _emit_image(self, pixmap: QPixmap) -> None: - with self._frames_in_flight_lock: - self._frames_in_flight += 1 - self.image.emit(pixmap) - def _set_camera_available(self, available: bool, error: str | None = None) -> None: if available != self._camera_available: self._camera_available = available @@ -203,10 +152,12 @@ class PredictionSubscriber(QThread): self.focus_measure.emit(sharpness) def run(self): + self._debug_last_log_ts = time.perf_counter() + self._debug_msg_count = 0 try: while self.running: try: - parts, dropped = self._recv_latest() + parts = self._sock.recv_multipart() except zmq.Again: now = time.perf_counter() elapsed = now - self._fps_window_start @@ -228,8 +179,7 @@ class PredictionSubscriber(QThread): now = time.perf_counter() self._last_frame_time = now - # dropped frames count too: this is the camera's rate, not ours - self._fps_frame_count += 1 + dropped + self._fps_frame_count += 1 elapsed = now - self._fps_window_start if elapsed >= self._fps_emit_period_s: @@ -261,12 +211,7 @@ class PredictionSubscriber(QThread): target = next((d for d in json_dicts if "target_point" in d), None) image_bytes = max(non_json_parts, key=len) if non_json_parts else None - # The decode is skipped along with the emit: a frame the GUI - # is too busy to paint is not worth decoding. The sharpness - # read-out is the exception, it works on the decoded image. - emit_image = self._emit_images and self._gui_ready_for_frame() - - if header and image_bytes and (emit_image or self._measure_focus): + if self._emit_images and header and image_bytes: rgb = self._decode_rgb_image(header, image_bytes) if rgb is None: self._set_camera_available( @@ -275,9 +220,9 @@ class PredictionSubscriber(QThread): else: self._set_camera_available(True) self._emit_focus_measure_if_enabled(rgb) - if emit_image and self.running: - self._emit_image(self._rgb_to_pixmap(rgb)) - elif self._emit_images and not (header and image_bytes): + if self.running: + self.image.emit(self._rgb_to_pixmap(rgb)) + elif self._emit_images: self._set_camera_available( False, "Sample camera feed unavailable: no frame header in zmq stream" ) -- 2.54.0 From 2fdb1952b67a5d857f67c50ef07daec2cd61d252 Mon Sep 17 00:00:00 2001 From: Dawn Date: Tue, 8 Sep 2026 11:21:31 +0200 Subject: [PATCH 5/7] fix: put Mount next in the automation row, gated to the Queued view The button was parented to the dewar tab but never added to a layout, so Qt painted it at (0, 0) over the TELL sample changer banner. It now sits left of Unmount in the automation row (docked and pop-out), greyed out outside the Queued view like Remove/Clear. A trailing stretch keeps the row's buttons at their natural width instead of sharing the spare space. Co-Authored-By: Claude Fable 5.1 --- src/aare/gui/main_window.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 5a3187e8..a8cb82ff 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -597,21 +597,27 @@ class MainWindow(QMainWindow): self.quick_unmount_button = QPushButton("⏏ Unmount", dewar_tab) self.quick_unmount_button.clicked.connect(lambda: self._on_manual_unmount_requested()) - # main_window.py, next to quick_unmount_button (~L597) + # Was parented to dewar_tab but never added to a layout, so Qt + # painted it at (0, 0) — on top of the TELL banner. It lives in the + # automation row, left of Unmount, like the pop-out clone below. self.mount_next_button = QPushButton("⏭ Mount next", dewar_tab) self.mount_next_button.clicked.connect(self._mount_next_from_queue) - # add to automation_row loop + self._queue_action_buttons as kind "next" automation_row = QHBoxLayout() for w in ( self.job_list_panel.play_button, self.job_list_panel.remove_button, self.job_list_panel.clear_button, + self.mount_next_button, self.quick_unmount_button, self.job_list_panel.park_and_dry_when_cleared, self.job_list_panel.pause_on_conditions_cb, ): automation_row.addWidget(w) + # Trailing stretch: without it every widget shares the spare width + # and the buttons balloon; five buttons plus two long checkboxes need + # them at their natural size. + automation_row.addStretch(1) dewar_layout.addLayout(automation_row) # "Remove selected" now unqueues the dewar-table selection — the @@ -630,6 +636,7 @@ class MainWindow(QMainWindow): (self.job_list_panel.play_button, "run"), (self.job_list_panel.remove_button, "queue"), (self.job_list_panel.clear_button, "queue"), + (self.mount_next_button, "queue"), ] self.tell_samples.status_chips.buttonClicked.connect( lambda _chip: self._update_queue_buttons_enabled() @@ -1462,19 +1469,22 @@ class MainWindow(QMainWindow): remove_button.clicked.connect(lambda: self._unqueue_panel_selection(dewar_panel)) clear_button = QPushButton("✖ Clear list") clear_button.clicked.connect(jl.clear) + mount_next_button = QPushButton("⏭ Mount next") + mount_next_button.clicked.connect(self._mount_next_from_queue) unmount_button = QPushButton("⏏ Unmount") unmount_button.clicked.connect(lambda: self._on_manual_unmount_requested()) - # Same Queued-view gating as the docked trio. + # Same Queued-view gating as the docked buttons. self._queue_action_buttons += [ (run_button, "run"), (remove_button, "queue"), (clear_button, "queue"), + (mount_next_button, "queue"), ] self._update_queue_buttons_enabled() row = QHBoxLayout() - for button in (run_button, remove_button, clear_button, unmount_button): + for button in (run_button, remove_button, clear_button, mount_next_button, unmount_button): row.addWidget(button) for source in (jl.park_and_dry_when_cleared, jl.pause_on_conditions_cb): clone = QCheckBox(source.text()) @@ -1485,6 +1495,7 @@ class MainWindow(QMainWindow): clone.toggled.connect(source.setChecked) source.toggled.connect(clone.setChecked) row.addWidget(clone) + row.addStretch(1) # natural button widths, see the docked row return row def _show_admin_tip(self, message: str) -> None: -- 2.54.0 From 2df433b33cabd0aadb60e6aeee173c47236672e5 Mon Sep 17 00:00:00 2001 From: Dawn Date: Tue, 8 Sep 2026 11:35:26 +0200 Subject: [PATCH 6/7] feat: show AUTO CENTERING in the sample camera view while a loop centering runs /status cannot tell an auto centering from a manual alignment move (both are busy + SampleAlignment, the combo the overlay deliberately hides), so the DAQ worker emits its own auto_centering flag: True while its own /alc/center_loop POST is in flight (the endpoint blocks until done) or while the automation progress reports the Center step running. The sample camera views pass it to build_busy_overlay_style, which paints a purple AUTO CENTERING wave text like the ROBOT MOUNTING/COOLING ones. Co-Authored-By: Claude Fable 5.1 --- src/aare/gui/main_window.py | 2 ++ src/aare/gui/threads/daq_worker.py | 32 ++++++++++++++++++++++++++-- src/aare/gui/widgets/busy_overlay.py | 22 +++++++++++++++++++ src/aare/gui/widgets/camera_image.py | 8 +++++++ tests/unit/gui/test_busy_overlay.py | 16 ++++++++++++++ 5 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index a8cb82ff..1094658f 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -1202,6 +1202,8 @@ class MainWindow(QMainWindow): self.daq.update.connect(self.sample_camera.update_daq_status) self.daq.update.connect(self.compact_sample_camera.update_daq_status) self.daq.update.connect(self.portrait_sample_camera.update_daq_status) + for camera in (self.sample_camera, self.compact_sample_camera, self.portrait_sample_camera): + self.daq.auto_centering.connect(camera.set_auto_centering) self.daq.update.connect(self.tell_samples.update_daq_status) self.daq.update.connect(self.ref_tools_panel.update_daq_status) if self.prediction_thread is not None: diff --git a/src/aare/gui/threads/daq_worker.py b/src/aare/gui/threads/daq_worker.py index 9f60f9af..81579a2b 100644 --- a/src/aare/gui/threads/daq_worker.py +++ b/src/aare/gui/threads/daq_worker.py @@ -84,6 +84,9 @@ class DAQWorker(QObject): http_error = Signal(str) status_message = Signal(str, bool) automation_progress = Signal(object) + # True while a loop centering runs — /status alone cannot tell it from + # a manual alignment move (see build_busy_overlay_style). + auto_centering = Signal(bool) gui_sessions_loaded = Signal(list) gui_close_requested = Signal(int, int, str) recovery_action_completed = Signal(str) @@ -154,6 +157,10 @@ class DAQWorker(QObject): self._timer.start() self._counter = 0 self._automation_progress_buffer = "" + # Two centering sources OR-ed into auto_centering: our own + # /alc/center_loop POST in flight, and the automation Center step. + self._manual_centering = False + self._automation_centering = False self._cleanup_done = False self._last_auth_error_log_ts = 0.0 @@ -747,7 +754,7 @@ class DAQWorker(QObject): """ if self._base_url is None: logger.info(f"POST /{url}: {body}") - return + return None request = QNetworkRequest(QUrl(f"{self._base_url}/{url}")) request.setRawHeader(b"Authorization", f"Bearer {self._token}".encode()) @@ -755,6 +762,7 @@ class DAQWorker(QObject): request.setRawHeader(b"Content-Type", b"application/json") reply = self._net_manager.post(request, QByteArray(body.encode("utf-8"))) reply.finished.connect(lambda: self.handle_req_response(reply)) + return reply def generic_put(self, url: str, body: str = ""): """ @@ -844,7 +852,20 @@ class DAQWorker(QObject): @Slot() def center_loop(self): - self.generic_post("alc/center_loop") + # The endpoint blocks server-side until centering ends, so the reply + # doubles as the "done" edge for the camera overlay. + reply = self.generic_post("alc/center_loop") + if reply is None: + return + self._set_centering(manual=True) + reply.finished.connect(lambda: self._set_centering(manual=False)) + + def _set_centering(self, *, manual: bool | None = None, automation: bool | None = None): + if manual is not None: + self._manual_centering = manual + if automation is not None: + self._automation_centering = automation + self.auto_centering.emit(self._manual_centering or self._automation_centering) @Slot() def force_session(self): @@ -1968,6 +1989,13 @@ class DAQWorker(QObject): progress = self._parse_automation_progress(progress_payload) self.automation_progress.emit(progress) self._emit_automation_progress_events(progress) + self._set_centering( + automation=not progress.finished + and any( + step.step == WorkflowStateKind.LOOP_CENTRE and step.status == StepStatus.RUNNING + for step in progress.steps + ) + ) current = progress.current_step or "Idle" if progress.finished: diff --git a/src/aare/gui/widgets/busy_overlay.py b/src/aare/gui/widgets/busy_overlay.py index 3a0d11e7..cbf356b7 100644 --- a/src/aare/gui/widgets/busy_overlay.py +++ b/src/aare/gui/widgets/busy_overlay.py @@ -17,6 +17,9 @@ from aare.gui.styles import ( BUSY_PSI_RED, BUSY_PSI_RED_BORDER, BUSY_PSI_RED_DOT, + BUSY_PURPLE, + BUSY_PURPLE_BORDER, + BUSY_PURPLE_DOT, BUSY_RED_BADGE, BUSY_RED_BORDER, BUSY_RED_DOT, @@ -174,6 +177,7 @@ def build_busy_overlay_style( is_busy: bool, tell_state: TellStateModel | None, session_state: SessionsStateEnum | None = None, + auto_centering: bool = False, ) -> BusyOverlayStyle | None: if session_state in { SessionsStateEnum.OwnedByElse, @@ -190,6 +194,24 @@ def build_busy_overlay_style( accent_dot=BUSY_YELLOW_DOT, subtext="Grab the baton if you need to interact with GUI", ) + + # Auto loop centering is busy + SampleAlignment in /status — the exact + # combo the callers hide (manual omega moves look identical), so it + # gets its own flag: the GUI knows it POSTed /alc/center_loop, or the + # automation progress says the Center step is running. Checked before + # is_busy because the callers pass is_busy=False during alignment. + if auto_centering: + return BusyOverlayStyle( + text="AUTO CENTERING", + badge_bg=BUSY_PURPLE, + badge_fg=WHITE, + overlay_fill=qcolor(BUSY_PURPLE, 190), + overlay_border=qcolor(BUSY_PURPLE_BORDER, 235), + overlay_text=qcolor(WHITE), + accent_dot=BUSY_PURPLE_DOT, + animate=True, + ) + if not is_busy: return None diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index c57482ca..d4093c74 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -130,6 +130,7 @@ class SampleCameraImageLabel(QGraphicsView): self._last_grid_update_ts = 0.0 self._grid_update_min_interval_s = 1.0 / 25.0 self._tell_state = None + self._auto_centering = False self._busy_overlay_style: BusyOverlayStyle | None = None self._geom = geom @@ -295,6 +296,12 @@ class SampleCameraImageLabel(QGraphicsView): self._smoothed_target_point = None self.update() + @Slot(bool) + def set_auto_centering(self, active: bool) -> None: + # ponytail: only stored — the overlay style is rebuilt on the next + # /status tick (500 ms), well within the seconds a centering takes. + self._auto_centering = active + def _busy_overlay_text(self) -> str: tell_state = self._tell_state if tell_state is None: @@ -843,6 +850,7 @@ class SampleCameraImageLabel(QGraphicsView): is_busy=bool(s.busy) and s.state != BeamlineStateEnum.SampleAlignment, tell_state=s.tell_state, session_state=self._session_state, + auto_centering=self._auto_centering, ) if new_busy_style != self._busy_overlay_style: self._busy_overlay_style = new_busy_style diff --git a/tests/unit/gui/test_busy_overlay.py b/tests/unit/gui/test_busy_overlay.py index 2b83d71c..6a71154b 100644 --- a/tests/unit/gui/test_busy_overlay.py +++ b/tests/unit/gui/test_busy_overlay.py @@ -2,6 +2,7 @@ the badge pill (which reads as a button). These checks fail if the text renderer stops painting or the video view stops routing through it.""" +from aarecommon.models.models import SessionsStateEnum from PySide6.QtCore import Qt from PySide6.QtGui import QPainter, QPixmap @@ -34,3 +35,18 @@ def test_video_view_paints_busy_text(qtbot): assert view.grab().toImage() != idle, "busy style must change the rendered view" view.set_busy_overlay_style(None) assert view.grab().toImage() == idle, "clearing the style must restore the view" + + +def test_auto_centering_flag_overrides_alignment_gate(): + # Callers pass is_busy=False during SampleAlignment; the flag must still + # produce the overlay, and the viewing-mode badge must still win. + style = build_busy_overlay_style(is_busy=False, tell_state=None, auto_centering=True) + assert style is not None and style.text == "AUTO CENTERING" + assert build_busy_overlay_style(is_busy=False, tell_state=None) is None + viewing = build_busy_overlay_style( + is_busy=False, + tell_state=None, + session_state=SessionsStateEnum.OwnedByElse, + auto_centering=True, + ) + assert viewing is not None and viewing.text.startswith("Viewing mode") -- 2.54.0 From 2d0235071cb72f41ff9d9895980c59a3d6a61669 Mon Sep 17 00:00:00 2001 From: Dawn Date: Tue, 8 Sep 2026 11:53:26 +0200 Subject: [PATCH 7/7] fix: take the aareDB transmission as the fraction aarecommon >= 0.7 declares CI resolves aarecommon 0.7.3, where DataCollectionParameters.transmission is a 0-to-1 fraction that rejects anything above 1.0 (the percent sheets are converted inside AareDB >= 0.83, which main already requires). The divide-by-100 from the earlier 'percentage' fix therefore failed the model test on CI and would have turned 20% into 0.2% at the beamline. Pass the fraction through and pin aarecommon>=0.7.3 so the older int percentage model can no longer be installed; relock. Also satisfy the diff typecheck gate: basedpyright only counts instance variables assigned in __init__, so the Database/User toggle widgets are created there and _build_source_toggle only lays them out. Co-Authored-By: Claude Fable 5.1 --- pyproject.toml | 4 +++- src/aare/gui/panels/scan_settings_panel.py | 20 +++++++++---------- .../unit/gui/test_data_collection_settings.py | 2 +- uv.lock | 18 ++++++++--------- 4 files changed, 23 insertions(+), 21 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0da2787e..428bf705 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,9 @@ requires-python = ">=3.11" dependencies = [ "uv", "gunicorn", - "aarecommon>=0.5.1", + # >=0.7: DataCollectionParameters.transmission is a 0-1 fraction, which + # the scan panels rely on (older releases held an int percentage). + "aarecommon>=0.7.3", "pydantic>=2.11", "numpy", "jfjoch_client>=1.0.0rc165", diff --git a/src/aare/gui/panels/scan_settings_panel.py b/src/aare/gui/panels/scan_settings_panel.py index 5ef1750a..17d369c0 100644 --- a/src/aare/gui/panels/scan_settings_panel.py +++ b/src/aare/gui/panels/scan_settings_panel.py @@ -86,15 +86,13 @@ class SampleParameters: params = None if sample is None else sample.aaredb_params if params is None: return cls() - # aareDB holds transmission as a percentage (DataCollectionParameters - # .transmission is an int, 0 to 100); everything here works in the - # 0-to-1 fraction the scan requests are built from. - transmission = _spreadsheet_float(params, "transmission") - if transmission is not None: - transmission = transmission / 100.0 + # DataCollectionParameters.transmission is the 0-to-1 fraction the + # scan requests use (aarecommon >= 0.7 rejects anything above 1.0 by + # design; the percent spreadsheets humans write are converted inside + # AareDB). No scaling here: dividing again would turn 20% into 0.2%. return cls( resolution_a=_spreadsheet_float(params, "targetresolution"), - transmission=transmission, + transmission=_spreadsheet_float(params, "transmission"), total_angle_deg=_spreadsheet_float(params, "totalangle"), image_angle_deg=_spreadsheet_float(params, "oscillation"), exp_time_s=_spreadsheet_float(params, "exposure"), @@ -158,6 +156,11 @@ class ScanSettingsPanel(QWidget): outer = QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) outer.setSpacing(0) + # Created here, not in _build_source_toggle: basedpyright only counts + # instance variables assigned in __init__ as initialized. + self._database_radio = QRadioButton("Database values", self) + self._user_radio = QRadioButton("User values", self) + self._source_group = QButtonGroup(self) outer.addWidget(self._build_source_toggle()) grid_host = QWidget(self) @@ -242,10 +245,7 @@ class ScanSettingsPanel(QWidget): container = QWidget(self) row = QHBoxLayout(container) row.setContentsMargins(0, 0, 0, 0) - self._database_radio = QRadioButton("Database values", container) - self._user_radio = QRadioButton("User values", container) self._database_radio.setChecked(True) - self._source_group = QButtonGroup(container) self._source_group.addButton(self._database_radio) self._source_group.addButton(self._user_radio) # One connection is enough: toggled fires on both directions. diff --git a/tests/unit/gui/test_data_collection_settings.py b/tests/unit/gui/test_data_collection_settings.py index 07333279..d821e942 100644 --- a/tests/unit/gui/test_data_collection_settings.py +++ b/tests/unit/gui/test_data_collection_settings.py @@ -59,7 +59,7 @@ def test_sample_parameters_translate_the_spreadsheet_row(): pin=1, aaredb_params=DataCollectionParameters( targetresolution=1.5, - transmission=20, # aareDB stores a percentage + transmission=0.2, # 0-to-1 fraction; aareDB converts the percent sheets totalangle=180, oscillation=0.1, exposure=0.02, diff --git a/uv.lock b/uv.lock index a3bbd4b1..5e34f601 100644 --- a/uv.lock +++ b/uv.lock @@ -11,7 +11,7 @@ overrides = [{ name = "opencv-python", marker = "sys_platform == 'nonexistent'" [[package]] name = "aarecommon" -version = "0.5.4" +version = "0.7.3" source = { registry = "https://gitea.psi.ch/api/packages/mx/pypi/simple" } dependencies = [ { name = "jfjoch-client" }, @@ -24,14 +24,14 @@ dependencies = [ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aarecommon/0.5.4/aarecommon-0.5.4.tar.gz", hash = "sha256:3e4e2e5ee1dbe2690e6b6ee26598f2fd845f5f458bbe8b05282e24cf9fa27aca" } +sdist = { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aarecommon/0.7.3/aarecommon-0.7.3.tar.gz", hash = "sha256:e85ddf9d3b740ca7536597b26cf839b159e96efc68845a6efb667a7dca5c4e0f" } wheels = [ - { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aarecommon/0.5.4/aarecommon-0.5.4-py3-none-any.whl", hash = "sha256:ad0efe9d593532331dd898edf0498a5ccb992d11cfb3b7db037385c399258600" }, + { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aarecommon/0.7.3/aarecommon-0.7.3-py3-none-any.whl", hash = "sha256:22e8c87e2800cbe9223341b43b448f4d0a4f2c199e59c27d17eed2a4b2e11531" }, ] [[package]] name = "aaredaq" -version = "0.21.3" +version = "0.22.1" source = { editable = "." } dependencies = [ { name = "aarecommon" }, @@ -82,8 +82,8 @@ test = [ [package.metadata] requires-dist = [ - { name = "aarecommon", specifier = ">=0.5.1" }, - { name = "aaredb", specifier = ">=0.2", index = "https://gitea.psi.ch/api/packages/mx/pypi/simple" }, + { name = "aarecommon", specifier = ">=0.7.3" }, + { name = "aaredb", specifier = ">=0.83.1", index = "https://gitea.psi.ch/api/packages/mx/pypi/simple" }, { name = "aarelcinfer-client", specifier = "==0.1.1a8" }, { name = "aarescan-client", specifier = "==1.0.0rc5" }, { name = "basedpyright", marker = "extra == 'test'" }, @@ -124,7 +124,7 @@ provides-extras = ["test", "docs"] [[package]] name = "aaredb" -version = "0.6.0" +version = "0.83.1" source = { registry = "https://gitea.psi.ch/api/packages/mx/pypi/simple" } dependencies = [ { name = "pydantic" }, @@ -132,9 +132,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "urllib3" }, ] -sdist = { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aaredb/0.6.0/aaredb-0.6.0.tar.gz", hash = "sha256:c6e9a4d5c23b1efd0e558e30811685343b9ace20cb6dd433b3e43ae26b9af049" } +sdist = { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aaredb/0.83.1/aaredb-0.83.1.tar.gz", hash = "sha256:9bbcf7cb9f165ae1217cb0392e48c820748976cadd3daf194b71a99c21de836e" } wheels = [ - { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aaredb/0.6.0/aaredb-0.6.0-py3-none-any.whl", hash = "sha256:78cd04e09a9ae16843ded6a60322baf35f43d0d926c787544402b7a85d2924e3" }, + { url = "https://gitea.psi.ch/api/packages/mx/pypi/files/aaredb/0.83.1/aaredb-0.83.1-py3-none-any.whl", hash = "sha256:cf7a2cd8171f7911b572b6367e5a8aabf9b10d731bddb724495273e8e17e2eb7" }, ] [[package]] -- 2.54.0