From 0cb3b4ce57b989328f8552984df00e741c50d6a3 Mon Sep 17 00:00:00 2001 From: hitz_s Date: Wed, 9 Sep 2026 15:14:18 +0200 Subject: [PATCH] wip --- .../digital_twin_core/motion_worker.py | 3 +- .../widgets/scheduler/item_dialog.py | 68 ++++-------------- .../widgets/scheduler/schedule_item.py | 1 + .../widgets/scheduler/scheduler.py | 71 +++++++++++++++---- debye_bec/devices/absorber.py | 10 ++- debye_bec/devices/eh_shutter.py | 10 ++- debye_bec/devices/op_shutter.py | 10 ++- 7 files changed, 95 insertions(+), 78 deletions(-) diff --git a/debye_bec/bec_ipython_client/plugins/digital_twin_core/motion_worker.py b/debye_bec/bec_ipython_client/plugins/digital_twin_core/motion_worker.py index dfbadf5..7b1ac6e 100644 --- a/debye_bec/bec_ipython_client/plugins/digital_twin_core/motion_worker.py +++ b/debye_bec/bec_ipython_client/plugins/digital_twin_core/motion_worker.py @@ -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 diff --git a/debye_bec/bec_widgets/widgets/scheduler/item_dialog.py b/debye_bec/bec_widgets/widgets/scheduler/item_dialog.py index aaae858..2a6ceff 100644 --- a/debye_bec/bec_widgets/widgets/scheduler/item_dialog.py +++ b/debye_bec/bec_widgets/widgets/scheduler/item_dialog.py @@ -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`.""" diff --git a/debye_bec/bec_widgets/widgets/scheduler/schedule_item.py b/debye_bec/bec_widgets/widgets/scheduler/schedule_item.py index b675094..f58fb07 100644 --- a/debye_bec/bec_widgets/widgets/scheduler/schedule_item.py +++ b/debye_bec/bec_widgets/widgets/scheduler/schedule_item.py @@ -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 diff --git a/debye_bec/bec_widgets/widgets/scheduler/scheduler.py b/debye_bec/bec_widgets/widgets/scheduler/scheduler.py index 97e25a2..9ebaaf1 100644 --- a/debye_bec/bec_widgets/widgets/scheduler/scheduler.py +++ b/debye_bec/bec_widgets/widgets/scheduler/scheduler.py @@ -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: diff --git a/debye_bec/devices/absorber.py b/debye_bec/devices/absorber.py index 29c3c9f..f069962 100644 --- a/debye_bec/devices/absorber.py +++ b/debye_bec/devices/absorber.py @@ -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 diff --git a/debye_bec/devices/eh_shutter.py b/debye_bec/devices/eh_shutter.py index 27b141e..ad9d30b 100644 --- a/debye_bec/devices/eh_shutter.py +++ b/debye_bec/devices/eh_shutter.py @@ -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 diff --git a/debye_bec/devices/op_shutter.py b/debye_bec/devices/op_shutter.py index 4f963ad..68b0a8d 100644 --- a/debye_bec/devices/op_shutter.py +++ b/debye_bec/devices/op_shutter.py @@ -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