wip
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user