Files
AareDAQ/src/aare/gui/panels/data_collection_settings.py
T
duan_jandClaude Fable 5 adc8514174 feat: pending color while typing in Experiment configuration values
Values inside the Experiment configuration banner now show the same
pending color as the motor move boxes while the typed text differs from
the applied value; Enter (or focus-out) commits as before and clears it.
Opt-in via NumberLineEdit(track_pending=True) because MotorMoveGroup
owns the movestate property on its registered boxes; reuses the existing
movestate QSS, so no new styling. The Set Energy spinbox marks pending
until Change Energy is clicked, since its apply gate is the button.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:40:03 +02:00

275 lines
12 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 mounting", 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)
# Pending color (same movestate QSS as the value fields) from edit
# until Change Energy is clicked — this row's apply gate is the
# button, not Enter, so the color marks "not sent yet".
self._energy_sent = self.energy_spin.value()
self.energy_spin.valueChanged.connect(self._on_energy_spin_changed)
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()
def _set_energy_pending(self, pending: bool):
# movestate lives on the spinbox's internal QLineEdit so the existing
# QLineEdit[movestate="pending"] theme rules match without new QSS.
box = self.energy_spin.lineEdit()
state = "pending" if pending else ""
if box.property("movestate") != state:
box.setProperty("movestate", state)
box.style().unpolish(box)
box.style().polish(box)
@Slot(float)
def _on_energy_spin_changed(self, value: float):
self._set_energy_pending(value != self._energy_sent)
@Slot()
def _emit_change_energy(self):
self._energy_sent = self.energy_spin.value()
self._set_energy_pending(False)
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)