Auto Centering moves out of the experiment tabs to its own row between Manual sample and Exp. Config., with an apply-after-mount checkbox that re-runs it on every detected sample mount (guarded against firing on GUI startup). Draw a Grid now only shows on the Raster scan tab. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
254 lines
11 KiB
Python
254 lines
11 KiB
Python
from aarecommon.math.diffraction_geometry import DiffractionGeometry
|
|
from aarecommon.math.sample_geometry import SampleGeometryModel
|
|
from aarecommon.models.models import DAQStatusModel
|
|
from PySide6.QtCore import Signal, Slot
|
|
from PySide6.QtWidgets import (
|
|
QCheckBox,
|
|
QDoubleSpinBox,
|
|
QFrame,
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QPushButton,
|
|
QSizePolicy,
|
|
QStackedWidget,
|
|
QTabBar,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
from aare.gui.panels.file_path_panel import FilePathPanel
|
|
from aare.gui.panels.fluorescence_data_collection import FluorescenceDataCollectionPanel
|
|
from aare.gui.panels.manual_sample_panel import ManualSamplePanel
|
|
from aare.gui.panels.raster_data_collection import RasterDataCollectionPanel
|
|
from aare.gui.panels.rotation_data_collection import RotationDataCollectionPanel
|
|
from aare.gui.panels.smart_rotation_panel import SimpleRotationSettingsPanel
|
|
from aare.gui.scan_logic.raster_grid_manager import RasterGridManager
|
|
from aare.gui.styles import BANNER_TAB_GAP
|
|
from aare.gui.widgets.title_label import TitleLabel, tighten_column
|
|
|
|
|
|
class DataCollectionSettings(QFrame):
|
|
cancel = Signal()
|
|
# Same convention as the Beamline setup panel: keV in the GUI, eV on emit.
|
|
change_energy = Signal(float)
|
|
|
|
set_width = 400
|
|
|
|
def __init__(
|
|
self,
|
|
s: SampleGeometryModel,
|
|
raster_mgr: RasterGridManager,
|
|
diffraction: DiffractionGeometry,
|
|
parent=None,
|
|
):
|
|
super().__init__(parent)
|
|
self.setObjectName("dataCollectionSettings")
|
|
self.setFixedWidth(self.set_width)
|
|
self.setFrameShape(QFrame.Shape.StyledPanel)
|
|
self.setFrameShadow(QFrame.Shadow.Raised)
|
|
|
|
v_layout = QVBoxLayout(self)
|
|
self.file_path_panel = FilePathPanel(self)
|
|
v_layout.addWidget(self.file_path_panel)
|
|
|
|
# Between Dataset path and Exp. Config., collapsible like both;
|
|
# main_window aliases this instead of the former bottom dock.
|
|
self.manual_sample_panel = ManualSamplePanel(self)
|
|
v_layout.addWidget(self.manual_sample_panel)
|
|
|
|
# Auto Centering applies to every scan type, so it lives outside the
|
|
# experiment tabs, between Manual sample and Exp. Config. The checkbox
|
|
# arms an automatic run after each sample mount (see update_daq_status).
|
|
self.find_tip = QPushButton("Auto Centering", parent=self)
|
|
self.auto_center_after_mount = QCheckBox("apply after mount", parent=self)
|
|
self.auto_center_after_mount.setToolTip(
|
|
"Run Auto Centering automatically after each sample mount"
|
|
)
|
|
auto_center_row = QWidget(self)
|
|
auto_center_layout = QHBoxLayout(auto_center_row)
|
|
auto_center_layout.setContentsMargins(0, 0, 0, 0)
|
|
auto_center_layout.addWidget(self.find_tip, 1)
|
|
auto_center_layout.addWidget(self.auto_center_after_mount)
|
|
v_layout.addWidget(auto_center_row)
|
|
|
|
# QTabBar + QStackedWidget instead of QTabWidget: the grid button
|
|
# must sit BETWEEN the tab bar and the pages, which a QTabWidget
|
|
# cannot host.
|
|
self._tab_bar = QTabBar(self)
|
|
self._stack = QStackedWidget(self)
|
|
|
|
self.raster = RasterDataCollectionPanel(
|
|
parent=self, raster_mgr=raster_mgr, diffraction=diffraction
|
|
)
|
|
self.screening = RotationDataCollectionPanel(parent=self, diffraction=diffraction)
|
|
self.simple = SimpleRotationSettingsPanel(parent=self)
|
|
self.fluo = FluorescenceDataCollectionPanel(parent=self)
|
|
for panel, label in (
|
|
(self.raster, "Raster scan"),
|
|
(self.screening, "Rotation"),
|
|
(self.simple, "Simple"),
|
|
(self.fluo, "XRF"),
|
|
):
|
|
self._stack.addWidget(panel)
|
|
self._tab_bar.addTab(label)
|
|
|
|
# Only meaningful for raster scans; hidden on the other tabs
|
|
# (_on_tab_changed).
|
|
self.bounding_box = QPushButton("Draw a Grid", parent=self)
|
|
|
|
# Live readout mirrored from the Beamline setup panel, same reason:
|
|
# "what is" and "what to set" must not share one ambiguous row.
|
|
# Fed per DAQ tick in update_daq_status.
|
|
self.current_energy_label = QLabel("—", parent=self)
|
|
current_energy_row = QWidget(self)
|
|
current_energy_layout = QHBoxLayout(current_energy_row)
|
|
current_energy_layout.setContentsMargins(0, 0, 0, 0)
|
|
current_energy_layout.addWidget(QLabel("Current energy / λ", parent=current_energy_row))
|
|
current_energy_layout.addWidget(self.current_energy_label)
|
|
current_energy_layout.addStretch()
|
|
|
|
# Energy row copied from the Beamline setup panel so users can change
|
|
# energy without leaving the experiment configuration.
|
|
self.energy_spin = QDoubleSpinBox(parent=self)
|
|
self.energy_spin.setDecimals(3)
|
|
self.energy_spin.setRange(1.0, 30.0)
|
|
self.energy_spin.setSingleStep(0.1)
|
|
self.energy_spin.setValue(12.0)
|
|
self.change_energy_button = QPushButton("Change Energy", parent=self)
|
|
self.change_energy_button.clicked.connect(self._emit_change_energy)
|
|
energy_row = QWidget(self)
|
|
energy_layout = QHBoxLayout(energy_row)
|
|
energy_layout.setContentsMargins(0, 0, 0, 0)
|
|
energy_layout.addWidget(QLabel("Set Energy (keV)", parent=energy_row))
|
|
energy_layout.addWidget(self.energy_spin)
|
|
energy_layout.addWidget(self.change_energy_button)
|
|
|
|
# Pane frame carries the border QTabWidget::pane used to draw
|
|
# (#expConfigPane rule in styles.py).
|
|
pane = QFrame(self)
|
|
pane.setObjectName("expConfigPane")
|
|
pane_layout = QVBoxLayout(pane)
|
|
# No bottom padding: the pages' own bottom margins breathe inside the
|
|
# border, and the Abort button should hug the pane.
|
|
pane_layout.setContentsMargins(6, 6, 6, 0)
|
|
pane_layout.addWidget(self.bounding_box)
|
|
pane_layout.addWidget(current_energy_row)
|
|
pane_layout.addWidget(energy_row)
|
|
pane_layout.addWidget(self._stack)
|
|
|
|
# Own container: TitleLabel collapse hides its siblings, so without it
|
|
# "Exp. Config." would also swallow the dataset path and abort button.
|
|
exp_config = QWidget(self)
|
|
exp_config_layout = QVBoxLayout(exp_config)
|
|
exp_config_layout.setContentsMargins(0, 0, 0, 0)
|
|
exp_config_layout.setSpacing(0) # tab bar flush on the pane, like QTabWidget
|
|
exp_config_layout.addWidget(
|
|
TitleLabel(
|
|
"Experiment configuration", exp_config, collapsible=True, default_collapsed=False
|
|
)
|
|
)
|
|
# Explicit spacer, not layout spacing: the tab bar must keep sitting
|
|
# flush on the pane below, only the banner gets breathing room.
|
|
exp_config_layout.addSpacing(BANNER_TAB_GAP)
|
|
exp_config_layout.addWidget(self._tab_bar)
|
|
exp_config_layout.addWidget(pane)
|
|
v_layout.addWidget(exp_config)
|
|
|
|
# Abort lives inside each tab now, under that tab's action buttons;
|
|
# all four feed the same cancel signal.
|
|
for panel in (self.raster, self.screening, self.simple, self.fluo):
|
|
panel.abort_button.clicked.connect(self.cancel_button_clicked)
|
|
v_layout.addStretch()
|
|
tighten_column(v_layout)
|
|
# Abort hugs the pane: undo the uniform bottom margin tighten_column
|
|
# just gave the Exp. Config. group.
|
|
m = exp_config_layout.contentsMargins()
|
|
exp_config_layout.setContentsMargins(m.left(), m.top(), m.right(), 0)
|
|
|
|
raster_mgr.update_filename(self.file_path_panel.filename)
|
|
self.screening.update_filename(self.file_path_panel.filename)
|
|
self.simple.update_filename(self.file_path_panel.filename)
|
|
|
|
self.file_path_panel.set_scan_kind("raster")
|
|
self.file_path_panel.path_updated.connect(raster_mgr.update_filename)
|
|
self.file_path_panel.path_updated.connect(self.screening.update_filename)
|
|
self.file_path_panel.path_updated.connect(self.simple.update_filename)
|
|
self._sample_id = None
|
|
# False until the first DAQ status: a sample already mounted at GUI
|
|
# startup must not trigger an auto-centering (hardware would move
|
|
# uninvited on every restart).
|
|
self._status_seen = False
|
|
|
|
self._tab_bar.currentChanged.connect(self._stack.setCurrentIndex)
|
|
self._tab_bar.currentChanged.connect(self._on_tab_changed)
|
|
self._tab_bar.currentChanged.connect(self._sync_stack_height)
|
|
self._sync_stack_height(self._tab_bar.currentIndex())
|
|
|
|
@Slot(int)
|
|
def _sync_stack_height(self, idx: int):
|
|
# QStackedWidget's sizeHint is its TALLEST page, which left a dead gap
|
|
# above the Abort button on shorter tabs. Ignored vertical policy on
|
|
# hidden pages makes the stack track only the current page's height.
|
|
for i in range(self._stack.count()):
|
|
page = self._stack.widget(i)
|
|
vertical = QSizePolicy.Policy.Preferred if i == idx else QSizePolicy.Policy.Ignored
|
|
page.setSizePolicy(QSizePolicy.Policy.Preferred, vertical)
|
|
self._stack.adjustSize()
|
|
|
|
@Slot()
|
|
def _emit_change_energy(self):
|
|
self.change_energy.emit(float(self.energy_spin.value()) * 1000.0)
|
|
|
|
@Slot()
|
|
def switch_to_raster(self):
|
|
self._tab_bar.setCurrentIndex(0)
|
|
|
|
@Slot()
|
|
def cancel_button_clicked(self):
|
|
self.cancel.emit()
|
|
|
|
@Slot(DAQStatusModel)
|
|
def update_daq_status(self, s: DAQStatusModel):
|
|
energy = s.diffraction.energy_keV
|
|
# 0.0 is the server's detector-unavailable fallback, and the
|
|
# wavelength property divides by it — guard before touching it.
|
|
if not energy:
|
|
text = "— / —"
|
|
else:
|
|
text = f"{energy:.3f} keV / {s.diffraction.wavelength_angstrom:.4f} Å"
|
|
# Guarded: runs per DAQ tick (2 Hz), skip the repaint when unchanged.
|
|
if self.current_energy_label.text() != text:
|
|
self.current_energy_label.setText(text)
|
|
|
|
self.raster.update_daq_status(s)
|
|
self.screening.update_daq_status(s)
|
|
self.simple.update_daq_status(s)
|
|
self._track_sample(None if s.sample is None else s.sample.db_id)
|
|
|
|
def _track_sample(self, db_id: int | None):
|
|
# Separate from update_daq_status so tests can drive mount detection
|
|
# without building a full DAQStatusModel.
|
|
if db_id is None:
|
|
# Forget on unmount so remounting the same sample counts as a
|
|
# fresh mount below.
|
|
self._sample_id = None
|
|
elif db_id != self._sample_id:
|
|
self._sample_id = db_id
|
|
self._tab_bar.setCurrentIndex(0)
|
|
if self._status_seen and self.auto_center_after_mount.isChecked():
|
|
# Reuse the button wiring (clicked -> daq.center_loop in
|
|
# main_window) instead of a second signal path.
|
|
self.find_tip.click()
|
|
self._status_seen = True
|
|
|
|
@Slot(int)
|
|
def _on_tab_changed(self, idx: int):
|
|
self.bounding_box.setVisible(idx == 0)
|
|
# 0: Raster, 1: Rotation (incl. screening), 2: Simple (rotation wrapper), 3: XRF (ignore)
|
|
kind = "rotation"
|
|
if idx == 0:
|
|
kind = "raster"
|
|
elif idx == 1 or idx == 2:
|
|
kind = "rotation"
|
|
self.file_path_panel.set_scan_kind(kind)
|