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/main_window.py b/src/aare/gui/main_window.py index 5a3187e8..1094658f 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() @@ -1195,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: @@ -1462,19 +1471,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 +1497,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: 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..17d369c0 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,82 @@ 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) +# 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 +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() + # 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=_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"), + ) + + 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,35 +129,38 @@ 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) + # 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) @@ -74,264 +171,198 @@ 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._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._database_radio.setChecked(True) + 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/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/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_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") diff --git a/tests/unit/gui/test_data_collection_settings.py b/tests/unit/gui/test_data_collection_settings.py index 30d093c4..d821e942 100644 --- a/tests/unit/gui/test_data_collection_settings.py +++ b/tests/unit/gui/test_data_collection_settings.py @@ -1,86 +1,93 @@ -"""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, + DataCollectionParameters, + SampleGeometryModel, + SampleShortInfo, +) 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(): + # 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=0.2, # 0-to-1 fraction; aareDB converts the percent sheets + totalangle=180, + oscillation=0.1, + exposure=0.02, + ), + ) + 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_sample_parameters_are_empty_without_a_sample(): + assert SampleParameters.from_sample(None) == SampleParameters() -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_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) # --------------------------------------------------------------------------- -# Panel: toggle + dtz/resolution coupling +# Rotation panel # --------------------------------------------------------------------------- @@ -104,71 +111,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 +269,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 +375,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 +412,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 == [] 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]]