Fix/scan panel user values #200
+3
-1
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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("<center><B><u>Screening</u></B></center>", 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("<center><B><u>Rotation</u></B></center>", 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),
|
||||
]
|
||||
|
||||
@@ -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_<setting>`` 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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user