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>
400 lines
17 KiB
Python
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
|