Files
AareDAQ/src/aare/gui/panels/data_collection_settings.py
T
duan_jandClaude Fable 5 623f803ceb feat: auto-centering row with apply-after-mount, grid button raster-only
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>
2026-08-20 16:21:21 +02:00

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)