wip
CI for debye_bec / test (push) Successful in 57s
CI for debye_bec / test (pull_request) Successful in 56s

This commit is contained in:
2026-09-09 15:14:18 +02:00
parent 7082b9ff7b
commit 0cb3b4ce57
7 changed files with 95 additions and 78 deletions
@@ -223,8 +223,7 @@ class MotionWorker:
self.motor = alias
if abs_closed:
if self.dev.abs.status.get() == ABS_STATUS.OPEN:
status = self.dev.abs.close()
status.wait(timeout=5)
status = self.dev.abs.close(wait=True)
if surveyed_axes is not None:
for surv_ax in surveyed_axes:
surv_ax["name"] = surv_ax["device"].dotted_name
@@ -1,60 +1,5 @@
"""
The dialog behind the schedule widget's "Add..."/"Edit..." buttons.
Rather than asking the operator to remember and type
`scans.xas_simple_scan(12000, 14000, 2, 10)`-style commands, this presents
a dropdown of item types; picking one swaps in that item's own form below
the dropdown (a `QStackedWidget` page per item, kept in the same order as
the dropdown so a combo index also works as a stack index - see
`_add_page`). The available items are:
- "ScanControl": BEC's own `bec_widgets` `ScanControl` widget, embedded
as-is - scan selection, its live-generated per-scan argument form,
docs tooltips, metadata, "recall last scan parameters", all of it.
Reusing it instead of a plugin-owned reimplementation means this stays
in sync with BEC's scan capabilities for free, and looks/behaves exactly
like the scan controls an operator already knows from elsewhere in the
GUI. `ScanControl.button_run_scan` ("Start") is hidden here: this dialog
only ever wants the configured scan name/args/kwargs, never an
immediate submission - see `_collect_scan_result`.
- "Move": pick a device and a target value/relative flag.
- "Digital Twin": the beamline-alignment `DigitalTwin` widget, embedded
the same way as ScanControl. Instead of submitting anything itself, OK
captures a *snapshot* of `DigitalTwin.get_assistant_config()` and stores
it - execution later calls `move_all_axes(...)` with that frozen
config, so editing the (possibly separately open) Digital Twin widget
afterwards never affects an already-added schedule item, exactly like a
Scan item's captured args/kwargs aren't affected by reopening
ScanControl elsewhere. See `_collect_digital_twin_result`.
- "Absorber", "Ionization Chamber", "Reference foil changer", "Auto-Gain":
beamline-specific quick-fill forms. Each has its own `_collect_*_result`
that either builds a command straight from the form fields or raises
`ValueError` with a message explaining what's missing or invalid -
`_on_accept` catches that and shows it in a message box, rather than
silently doing nothing the way the old "Generate command" buttons used
to. Each also has a `_prefill_*_form` so "Edit..." can reselect the
exact same field values, not just show the generated command as text.
- "Other": a free-text field for anything else (including RPC calls to
other widgets); also the fallback shown, pre-filled with the raw
command, for any saved item this dialog can't reconstruct into a form
(see `_apply_initial`).
Whichever item is selected, the dialog's only output is the same kind of
plain command string the executor already knows how to run - this dialog
adds a friendlier way to *build* that string, it doesn't change what
happens with it afterwards. `kind`/`form_state` are carried along purely
so "Edit..." can reopen the dialog pre-filled instead of asking the user
to start over - except for the Digital Twin item, where "Edit..."
currently falls back to showing the generated command as read/write text
on the "Other" page rather than reloading the captured config back into
DigitalTwin's input fields; see the note on `_apply_initial`.
The Digital Twin item reuses `kind="move"` (not a new kind): it submits
through `scans.mv(...)`, exactly like the plain Move item, so it should be
treated the same way everywhere else in the plugin that branches on kind -
not guard-protected, and counted under the "Movements" notification
toggle. A `form_state["source"] = "digital_twin"` marker is only used
locally, by this dialog, to tell the two apart when reopening for Edit.
"""
from __future__ import annotations
@@ -153,6 +98,13 @@ class ScheduleItemDialog(QDialog):
self.item_combo.currentIndexChanged.connect(self.stack.setCurrentIndex)
# Shared across all items (not per-page), since it's a property
# of the schedule item, not of any one form.
self.pause_on_failure_check = QCheckBox("Pause schedule if item execution fails")
layout.addWidget(self.pause_on_failure_check)
self.item_combo.currentIndexChanged.connect(self.stack.setCurrentIndex)
# Dispatch table keyed by the dropdown item's data (see
# _add_page) - one place to register a new item type instead of
# scattering per-item checks through _collect_result().
@@ -659,6 +611,8 @@ class ScheduleItemDialog(QDialog):
# pre-fill (edit mode) / result extraction
# ------------------------------------------------------------------ #
def _apply_initial(self, initial: dict):
self.pause_on_failure_check.setChecked(bool(initial.get("pause_on_failure", False)))
kind = initial.get("kind")
state = initial.get("form_state") or {}
source = state.get("source")
@@ -705,7 +659,9 @@ class ScheduleItemDialog(QDialog):
handler = self._collectors.get(key)
if handler is None:
raise ValueError(f"Unknown item type: {key!r}")
return handler()
result = handler()
result["pause_on_failure"] = self.pause_on_failure_check.isChecked()
return result
def result(self) -> dict:
"""Valid after `exec_()` returns `QDialog.Accepted`."""
@@ -40,6 +40,7 @@ class ScheduleItem(BaseModel):
# time - `kind`/`form_state` only drive the UI.
kind: ScheduleItemKind = "custom"
form_state: dict | None = None
pause_on_failure: bool
# Bookkeeping used to reconnect to a submission that is still (or was)
# in flight on the BEC scan/device server, after this widget has been
@@ -64,7 +64,9 @@ from .schedule_logic import index_of, pick_next_runnable, protected_prefix_lengt
logger = bec_logger.logger
_ACTIVE_QUEUE_STATES = ("PENDING", "RUNNING")
_ITEM_ID_ROLE = Qt.UserRole + 1 # QListWidgetItem data role used to map a row back to an item_id
_ITEM_ID_ROLE = (
Qt.ItemDataRole.UserRole + 1
) # QListWidgetItem data role used to map a row back to an item_id
ICON_SIZE = 20
_ICON_MAP = {
@@ -152,12 +154,15 @@ class Scheduler(BECWidget, QWidget):
self.beamline = self.get_beamline()
self.connector = self.client.connector
if self.beamline in ['x01da', 'x10da']:
if self.beamline in ["x01da", "x10da"]:
logger.info(
'Scheduler running at X01DA or X10DA, import and load digital twin and auto-gain'
"Scheduler running at X01DA or X10DA, import and load digital twin and auto-gain"
)
from ....bec_ipython_client.plugins.digital_twin_core.digital_twin_core import DigitalTwinCore
from ....bec_ipython_client.plugins.auto_gain import AutoGain
from ....bec_ipython_client.plugins.digital_twin_core.digital_twin_core import (
DigitalTwinCore,
)
self.digital_twin = DigitalTwinCore()
self.auto_gain = AutoGain()
else:
@@ -470,7 +475,18 @@ class Scheduler(BECWidget, QWidget):
self.list_widget.clear()
selected_row = None
for row, item in enumerate(items):
text = f"{item.command}"
text = ""
if item.form_state is not None:
if item.command.startswith("scans.xas_simple_scan"):
text = (
f"XAS simple scan from {item.form_state['kwargs']['start']} eV to "
+ f"{item.form_state['kwargs']['stop']} eV, {item.form_state['kwargs']['scan_time']}"
+ f" s per spectrum, measure {item.form_state['kwargs']['scan_duration']}s. "
+ f"Comment: {item.form_state['kwargs']['metadata']['comment']}, "
+ f"Sample Name: {item.form_state['kwargs']['metadata']['sample_name']}"
)
if text == "":
text = f"{item.command}"
if item.error:
text += f" ({item.error.strip().splitlines()[-1]})"
list_item = QListWidgetItem(text, self.list_widget)
@@ -700,7 +716,7 @@ class Scheduler(BECWidget, QWidget):
@SafeSlot()
def _on_notifications_clicked(self):
dialog = NotificationSettingsDialog(self.schedule.notifications, self.client, parent=self)
if dialog.exec_() != QDialog.Accepted:
if dialog.exec_() != QDialog.DialogCode.Accepted:
return
with self._lock:
self.schedule.notifications = dialog.result_settings()
@@ -736,7 +752,7 @@ class Scheduler(BECWidget, QWidget):
def _on_guard_clicked(self):
device_names = sorted(self.dev.keys())
dialog = GuardSettingsDialog(self.schedule.guard, device_names, parent=self)
if dialog.exec_() != QDialog.Accepted:
if dialog.exec_() != QDialog.DialogCode.Accepted:
return
with self._lock:
self.schedule.guard = dialog.result_settings()
@@ -775,15 +791,21 @@ class Scheduler(BECWidget, QWidget):
# ---- UI-triggered edit actions ---- #
@SafeSlot()
def _on_add_clicked(self):
dialog = ScheduleItemDialog(self.scans, self.dev, parent=self, client=self.client, beamline=self.beamline)
if dialog.exec_() != QDialog.Accepted:
dialog = ScheduleItemDialog(
self.scans, self.dev, parent=self, client=self.client, beamline=self.beamline
)
if dialog.exec_() != QDialog.DialogCode.Accepted:
return
result = dialog.result()
with self._lock:
idx = self._index_of_locked(self._selected_item_id)
insert_at = None if idx is None else idx + 1
new_id = self.add_item(
result["command"], index=insert_at, kind=result["kind"], form_state=result["form_state"]
result["command"],
index=insert_at,
kind=result["kind"],
form_state=result["form_state"],
pause_on_failure=result["pause_on_failure"],
)
self._selected_item_id = new_id
self._refresh_ui()
@@ -797,7 +819,12 @@ class Scheduler(BECWidget, QWidget):
idx = self._index_of_locked(self._selected_item_id)
item = self.schedule.items[idx] if idx is not None else None
initial = (
{"kind": item.kind, "command": item.command, "form_state": item.form_state}
{
"kind": item.kind,
"command": item.command,
"form_state": item.form_state,
"pause_on_failure": item.pause_on_failure,
}
if item is not None
else None
)
@@ -809,7 +836,7 @@ class Scheduler(BECWidget, QWidget):
dialog = ScheduleItemDialog(
self.scans, self.dev, parent=self, initial=initial, client=self.client
)
if dialog.exec_() != QDialog.Accepted:
if dialog.exec_() != QDialog.DialogCode.Accepted:
return
result = dialog.result()
try:
@@ -818,6 +845,7 @@ class Scheduler(BECWidget, QWidget):
result["command"],
kind=result["kind"],
form_state=result["form_state"],
pause_on_failure=result["pause_on_failure"],
)
except RuntimeError as exc:
QMessageBox.warning(self, "Cannot edit item", str(exc))
@@ -876,6 +904,7 @@ class Scheduler(BECWidget, QWidget):
"command": item.command,
"kind": item.kind,
"form_state": dict(item.form_state) if item.form_state else None,
"pause_on_failure": item.pause_on_failure,
}
logger.info(f"Copied schedule item: {self._clipboard_item['command']}")
self._update_buttons()
@@ -900,6 +929,7 @@ class Scheduler(BECWidget, QWidget):
index=insert_at,
kind=self._clipboard_item["kind"],
form_state=self._clipboard_item["form_state"],
pause_on_failure=self._clipboard_item["pause_on_failure"],
)
self._selected_item_id = new_id
self._refresh_ui()
@@ -983,6 +1013,7 @@ class Scheduler(BECWidget, QWidget):
index: int | None = None,
kind: str = "custom",
form_state: dict | None = None,
pause_on_failure: bool = False,
) -> str:
"""
RPC-exposed: insert a new, PENDING command into the schedule.
@@ -1009,7 +1040,11 @@ class Scheduler(BECWidget, QWidget):
with self._lock:
protected = self._protected_prefix_length_locked()
item = ScheduleItem(
item_id=str(uuid.uuid4()), command=command, kind=kind, form_state=form_state
item_id=str(uuid.uuid4()),
command=command,
kind=kind,
form_state=form_state,
pause_on_failure=pause_on_failure,
)
if index is None or index >= len(self.schedule.items):
self.schedule.items.append(item)
@@ -1020,7 +1055,12 @@ class Scheduler(BECWidget, QWidget):
return item.item_id
def edit_item(
self, item_id: str, command: str, kind: str = "custom", form_state: dict | None = None
self,
item_id: str,
command: str,
kind: str = "custom",
form_state: dict | None = None,
pause_on_failure: bool = True,
):
"""
RPC-exposed: change the command of an item that has not started
@@ -1041,6 +1081,7 @@ class Scheduler(BECWidget, QWidget):
item.command = command
item.kind = kind
item.form_state = form_state
item.pause_on_failure = pause_on_failure
self._persist_locked()
self.schedule_changed.emit()
@@ -1255,6 +1296,8 @@ class Scheduler(BECWidget, QWidget):
item.status = ScheduleItemStatus.FAILED
item.error = traceback.format_exc()
logger.error(f"Schedule item failed: {item.command}\n{item.error}")
if item.pause_on_failure:
self._abort_requested = True
finally:
if not self._closing.is_set():
with self._lock:
+8 -2
View File
@@ -75,7 +75,7 @@ class Absorber(PSIDeviceBase):
# Wait for connection on all components, ensure IOC is connected
self.wait_for_connection(all_signals=True, timeout=5)
def open(self, force: bool = False) -> DeviceStatus | None:
def open(self, force: bool = False, wait: bool = True) -> CompareStatus | None:
"""Open the Absorber
Args:
@@ -94,15 +94,21 @@ class Absorber(PSIDeviceBase):
status.wait(timeout=TIMEOUT_FOR_PV)
self.request.put(1)
status = CompareStatus(self.status, STATUS.OPEN, timeout=self.timeout_for_move)
if wait:
status.wait(timeout=self.timeout_for_move)
return None
return status
else:
return None
def close(self) -> DeviceStatus | None:
def close(self, wait: bool = True) -> CompareStatus | None:
"""Close the Absorber"""
if self.status.get() == STATUS.OPEN:
self.request.put(1)
status = CompareStatus(self.status, STATUS.CLOSED, timeout=self.timeout_for_move)
if wait:
status.wait(timeout=self.timeout_for_move)
return None
return status
else:
return None
+8 -2
View File
@@ -66,20 +66,26 @@ class EHPhotonShutter(PSIDeviceBase):
# Wait for connection on all components, ensure IOC is connected
self.wait_for_connection(all_signals=True, timeout=5)
def open(self) -> DeviceStatus | None:
def open(self, wait: bool = True) -> CompareStatus | None:
"""Open the Shutter"""
if self.status.get() == STATUS.CLOSED:
self.request_open.put(1)
status = CompareStatus(self.status, STATUS.NOT_CLOSED, timeout=self.timeout_for_move)
if wait:
status.wait(timeout=self.timeout_for_move)
return None
return status
else:
return None
def close(self) -> DeviceStatus | None:
def close(self, wait: bool = True) -> CompareStatus | None:
"""Close the Shutter"""
if self.status.get() == STATUS.NOT_CLOSED:
self.request_close.put(1)
status = CompareStatus(self.status, STATUS.CLOSED, timeout=self.timeout_for_move)
if wait:
status.wait(timeout=self.timeout_for_move)
return None
return status
else:
return None
+8 -2
View File
@@ -71,7 +71,7 @@ class OPPhotonShutter(PSIDeviceBase):
# Wait for connection on all components, ensure IOC is connected
self.wait_for_connection(all_signals=True, timeout=5)
def open(self, force: bool = False) -> DeviceStatus | None:
def open(self, force: bool = False, wait: bool = True) -> CompareStatus | None:
"""Open the Shutter
Args:
@@ -92,15 +92,21 @@ class OPPhotonShutter(PSIDeviceBase):
status.wait(timeout=TIMEOUT_FOR_PV)
self.request_open.put(1)
status = CompareStatus(self.status, STATUS.NOT_CLOSED, timeout=self.timeout_for_move)
if wait:
status.wait(timeout=self.timeout_for_move)
return None
return status
else:
return None
def close(self) -> DeviceStatus | None:
def close(self, wait: bool = True) -> CompareStatus | None:
"""Close the Shutter"""
if self.status.get() == STATUS.NOT_CLOSED:
self.request_close.put(1)
status = CompareStatus(self.status, STATUS.CLOSED, timeout=self.timeout_for_move)
if wait:
status.wait(timeout=self.timeout_for_move)
return None
return status
else:
return None