Files
AareDAQ/src/aare/gui/panels/scan_settings_panel.py
T
duan_jandClaude Fable 5.1 bd2b25e156 fix(gui): reach the file-exists guard from every tab, test exact names
The tabs are handed parent=DataCollectionSettings but
QStackedWidget.addWidget() reparents them to the stack, so the guard's
parent() lookup never found file_path_panel and every Run (rotation,
screening, raster, X-ray centering, Simple) skipped the check. Lookup
now walks up the widget tree; regression test drives all five runs
through the real DataCollectionSettings.

The "taken" test is exact again: <run>_master.h5 plus the DAQ's
derived <run>_raster2d/_raster1d_master.h5. The directory test and the
_*_master.h5 glob are gone: neither is a file a run writes, and they
painted run numbers red that nothing would ever produce.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 14:27:19 +02:00

400 lines
17 KiB
Python

"""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, SampleShortInfo, SessionsStateEnum
from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtWidgets import (
QButtonGroup,
QGridLayout,
QHBoxLayout,
QLabel,
QRadioButton,
QVBoxLayout,
QWidget,
)
from aare.gui.constants import LOGGER_NAME
from aare.gui.panels.file_path_panel import find_file_path_panel
from aare.gui.widgets.message_box import precondition_check
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)
transmission_updated = Signal(float)
# TODO min and max dtz is set by beamline add max
MIN_DTZ = 108.0 # this is beamline dependent
def __init__(
self,
diffraction: DiffractionGeometry,
default_dtz: float = 200.0,
default_transmission: float = 1.0,
transmission_row: int = 2,
transmission_label: str = "Transmission",
parent=None,
):
super().__init__(parent)
self._diffraction = diffraction
self._default_dtz = default_dtz
self._default_transmission = default_transmission
self._beamline_state = None
self._ring_current = None
self._experiment_shutter_state = None
self._door_prohibited = None
self._can_edit_params = False
# Parameters of the mounted sample; all-empty while nothing is mounted.
self._db_params = SampleParameters()
# 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)
self._layout = QGridLayout(grid_host)
# Toggle-to-grid gap = one grid row gap (top). Bottom 3 + the column's
# 3px spacing = one row gap between the last button and Abort too.
m = self._layout.contentsMargins()
self._layout.setContentsMargins(m.left(), 6, m.right(), 3)
outer.addWidget(grid_host)
# 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._add_row(0, "High resolution", self.high_res_enter, "Å")
self._add_database_field(self.high_res_enter, self._on_resolution_committed)
self.dtz_enter = NumberLineEdit(
self.MIN_DTZ, 1000, default=default_dtz, decimals=2, parent=self, track_pending=True
)
self._add_row(1, "Detector distance", self.dtz_enter, "mm")
self._add_database_field(self.dtz_enter, self._on_dtz_committed)
# Placed where the subclass says: transmission is a per-mode setting
# (user request), so each tab puts its own row next to its section
# instead of a shared top-level "Rotation transmission" for all tabs.
self.transmission_enter = NumberLineEdit(
0, 1.0, default=default_transmission, decimals=4, parent=self, track_pending=True
)
self._add_row(transmission_row, transmission_label, self.transmission_enter)
self._add_database_field(self.transmission_enter, self._on_transmission_committed)
# -- 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)
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)
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._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
@Slot(bool)
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_transmission_committed(self, value: float):
self._user_transmission = value
self._user_took_over()
@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(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.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)
)
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:
return max(self._diffraction.calc_dtz_mm(res), self.MIN_DTZ)
# -- status -------------------------------------------------------------
@Slot(DAQStatusModel)
def update_daq_status(self, s: DAQStatusModel):
previous_energy = self._diffraction.energy_keV
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)
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)
)
if self._diffraction.energy_keV != previous_energy:
dtz = self.dtz_enter.committed_value
resolution = self._dtz_to_res(dtz)
self.high_res_enter.set_committed_value(resolution)
if self._show_user_values and self._user_dtz is not None:
self._user_resolution = resolution
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)
# 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(
self,
ring_current=self._ring_current,
shutter_open=self._experiment_shutter_state,
door_prohibited=self._door_prohibited,
):
logger.warning("Beamline not ready; user chose not to continue scan")
return False
file_path_panel = find_file_path_panel(self)
if file_path_panel is not None:
reply = file_path_panel.file_path_error_box(scan_kind=scan_kind)
logger.debug(f"reply from file path panel: {reply}")
if not reply:
logger.warning("Error with file path.")
return False
return True