feat(bec_widgets): add command-job builder to the tomo queue GUI
First iteration of Step 3 (TOMO_QUEUE_COMMAND_JOBS_PLAN.md section 6) on top of the existing TomoParamsWidget/TomoQueueDialog baseline: - CommandJobBuilderDialog: a structured step-by-step form (action dropdown, per-action fields) built entirely from the live tomo_queue_actions global var, so it tracks flomni.py's action registry and device allow-list with no hardcoded mirror. Produces a job dict identical to what tomo_queue_add_command() writes. - TomoQueueDialog's queue table gained a "Details" column and renders command jobs (CMD + step summary + idempotency marker) the same way flomni.tomo_queue_show() does. - "Add current params to queue" now stamps kind="tomo" and a fresh id on the job, matching the CLI's own self-describing format. - Delete/clear now refuse when a "running" job is involved instead of silently allowing it (plan section 6.3). Mid-run drag-reorder of queue jobs (plan section 6.4), multi-device single move steps, and live device-limit validation are deliberately not in this iteration - see TOMO_QUEUE_GUI_TESTING.md.
This commit is contained in:
@@ -41,6 +41,7 @@ explicit hint for executing it from the CLI.
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
||||
from bec_lib import bec_logger
|
||||
@@ -51,6 +52,7 @@ from qtpy.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
@@ -60,6 +62,7 @@ from qtpy.QtWidgets import (
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
@@ -997,9 +1000,19 @@ class TomoQueueDialog(QDialog):
|
||||
vbox = QVBoxLayout(self)
|
||||
vbox.setSpacing(6)
|
||||
|
||||
self._table = QTableWidget(0, 8)
|
||||
self._table = QTableWidget(0, 9)
|
||||
self._table.setHorizontalHeaderLabels(
|
||||
["#", "Label", "Status", "Type", "Projections", "Exp (s)", "Step (µm)", "Added at"]
|
||||
[
|
||||
"#",
|
||||
"Label",
|
||||
"Status",
|
||||
"Type",
|
||||
"Projections",
|
||||
"Exp (s)",
|
||||
"Step (µm)",
|
||||
"Details",
|
||||
"Added at",
|
||||
]
|
||||
)
|
||||
self._table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
||||
self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||
@@ -1008,14 +1021,17 @@ class TomoQueueDialog(QDialog):
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
btn_add = QPushButton("Add current params to queue")
|
||||
btn_add_cmd = QPushButton("Add command…")
|
||||
btn_del = QPushButton("Delete selected")
|
||||
btn_clr = QPushButton("Clear all")
|
||||
btn_exe = QPushButton("Execute queue…")
|
||||
btn_add.clicked.connect(self.add_to_queue)
|
||||
btn_add_cmd.clicked.connect(self.add_command_to_queue)
|
||||
btn_del.clicked.connect(self._delete_selected)
|
||||
btn_clr.clicked.connect(self._clear_queue)
|
||||
btn_exe.clicked.connect(self._show_execute_hint)
|
||||
btn_row.addWidget(btn_add)
|
||||
btn_row.addWidget(btn_add_cmd)
|
||||
btn_row.addWidget(btn_del)
|
||||
btn_row.addWidget(btn_clr)
|
||||
btn_row.addStretch()
|
||||
@@ -1028,19 +1044,33 @@ class TomoQueueDialog(QDialog):
|
||||
jobs = self._load_queue()
|
||||
self._table.setRowCount(len(jobs))
|
||||
for row, job in enumerate(jobs):
|
||||
params = job.get("params", {})
|
||||
status = job.get("status", "pending")
|
||||
color = STATUS_COLORS.get(status, "#888888")
|
||||
cells = [
|
||||
str(row),
|
||||
job.get("label", f"job_{row}"),
|
||||
status,
|
||||
str(params.get("tomo_type", "?")),
|
||||
_format_projections(params),
|
||||
_fmt_num(params.get("tomo_countingtime")),
|
||||
_fmt_num(params.get("tomo_shellstep")),
|
||||
job.get("added_at", ""),
|
||||
]
|
||||
if _job_kind(job) == "command":
|
||||
cells = [
|
||||
str(row),
|
||||
job.get("label", f"job_{row}"),
|
||||
status,
|
||||
"CMD",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
_format_command_summary(job),
|
||||
job.get("added_at", ""),
|
||||
]
|
||||
else:
|
||||
params = job.get("params", {})
|
||||
cells = [
|
||||
str(row),
|
||||
job.get("label", f"job_{row}"),
|
||||
status,
|
||||
str(params.get("tomo_type", "?")),
|
||||
_format_projections(params),
|
||||
_fmt_num(params.get("tomo_countingtime")),
|
||||
_fmt_num(params.get("tomo_shellstep")),
|
||||
"",
|
||||
job.get("added_at", ""),
|
||||
]
|
||||
for col, text in enumerate(cells):
|
||||
item = QTableWidgetItem(text)
|
||||
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
@@ -1060,6 +1090,8 @@ class TomoQueueDialog(QDialog):
|
||||
label = f"job_{len(jobs)}"
|
||||
jobs.append(
|
||||
{
|
||||
"kind": "tomo",
|
||||
"id": uuid.uuid4().hex,
|
||||
"label": label.strip(),
|
||||
"params": params,
|
||||
"status": "pending",
|
||||
@@ -1069,10 +1101,32 @@ class TomoQueueDialog(QDialog):
|
||||
self._save_queue(jobs)
|
||||
self._refresh()
|
||||
|
||||
def add_command_to_queue(self) -> None:
|
||||
"""Open the structured command-job builder and, on confirm, append
|
||||
the resulting job -- in exactly the format the CLI writes, so CLI
|
||||
and GUI stay interchangeable (see TOMO_QUEUE_COMMAND_JOBS_PLAN.md
|
||||
section 6.5).
|
||||
"""
|
||||
dlg = CommandJobBuilderDialog(self._client, parent=self)
|
||||
if dlg.exec() == QDialog.DialogCode.Accepted and dlg.job is not None:
|
||||
jobs = self._load_queue()
|
||||
jobs.append(dlg.job)
|
||||
self._save_queue(jobs)
|
||||
self._refresh()
|
||||
|
||||
def _delete_selected(self) -> None:
|
||||
rows = sorted({idx.row() for idx in self._table.selectedIndexes()}, reverse=True)
|
||||
if not rows:
|
||||
return
|
||||
jobs = self._load_queue()
|
||||
if any(row < len(jobs) and jobs[row].get("status") == "running" for row in rows):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Cannot delete",
|
||||
"A running job can't be deleted from here. Stop the current "
|
||||
"measurement first (it becomes 'incomplete'), then delete it.",
|
||||
)
|
||||
return
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Delete jobs",
|
||||
@@ -1081,7 +1135,6 @@ class TomoQueueDialog(QDialog):
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
jobs = self._load_queue()
|
||||
for row in rows:
|
||||
if row < len(jobs):
|
||||
jobs.pop(row)
|
||||
@@ -1089,6 +1142,15 @@ class TomoQueueDialog(QDialog):
|
||||
self._refresh()
|
||||
|
||||
def _clear_queue(self) -> None:
|
||||
jobs = self._load_queue()
|
||||
if any(job.get("status") == "running" for job in jobs):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Cannot clear",
|
||||
"A job is currently running. Stop it first (it becomes "
|
||||
"'incomplete'), then clear the queue.",
|
||||
)
|
||||
return
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Clear queue",
|
||||
@@ -1124,6 +1186,249 @@ class TomoQueueDialog(QDialog):
|
||||
super().showEvent(event)
|
||||
|
||||
|
||||
# ── CommandJobBuilderDialog ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CommandJobBuilderDialog(QDialog):
|
||||
"""Structured builder for a tomo-queue "command" job: an ordered list of
|
||||
named actions, each with only the fields its own params schema declares
|
||||
-- there is nothing free-text to type wrong (TOMO_QUEUE_COMMAND_JOBS_PLAN.md
|
||||
section 0's rejected alternative was a code box precisely because
|
||||
syntax-valid is a false safety signal; this builder is the safer,
|
||||
friendlier alternative it settled on).
|
||||
|
||||
Built entirely from the live ``tomo_queue_actions`` global var (published
|
||||
by ``Flomni.__init__``, see ``flomni.py``'s ``_describe_tomo_queue_actions()``)
|
||||
-- no hardcoded mirror of the action registry or device allow-list to
|
||||
drift out of sync with flomni.py, the same problem QUEUE_PARAM_NAMES
|
||||
already has.
|
||||
|
||||
On accept, ``self.job`` holds a job dict in exactly the format the CLI's
|
||||
``tomo_queue_add_command()`` writes (``kind="command"``, a fresh id, the
|
||||
assembled ``steps`` list) so CLI and GUI stay interchangeable. The caller
|
||||
is responsible for appending it to the queue -- this dialog only builds
|
||||
the dict, it never touches the queue global var itself.
|
||||
"""
|
||||
|
||||
def __init__(self, client, parent=None):
|
||||
super().__init__(parent)
|
||||
self._client = client
|
||||
self.setWindowTitle("Add command job")
|
||||
self.setMinimumSize(520, 480)
|
||||
self.job: Optional[dict] = None
|
||||
|
||||
registry = self._client.get_global_var("tomo_queue_actions") or {}
|
||||
self._actions: dict = registry.get("actions", {})
|
||||
self._move_devices: dict = registry.get("move_devices", {})
|
||||
|
||||
self._steps: list[dict] = []
|
||||
self._step_field_widgets: dict[str, tuple[QWidget, dict]] = {}
|
||||
self._idem_user_overridden = False
|
||||
|
||||
self._build_ui()
|
||||
|
||||
if not self._actions:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"No actions available",
|
||||
"The tomo_queue_actions registry is empty or unavailable -- "
|
||||
"is a Flomni CLI session running? Command jobs can't be "
|
||||
"built without it.",
|
||||
)
|
||||
|
||||
def _build_ui(self):
|
||||
vbox = QVBoxLayout(self)
|
||||
|
||||
vbox.addWidget(QLabel("Steps (run in order):"))
|
||||
self._step_list = QListWidget()
|
||||
vbox.addWidget(self._step_list)
|
||||
|
||||
step_btn_row = QHBoxLayout()
|
||||
btn_remove = QPushButton("Remove step")
|
||||
btn_up = QPushButton("Move up")
|
||||
btn_down = QPushButton("Move down")
|
||||
btn_remove.clicked.connect(self._on_remove_step)
|
||||
btn_up.clicked.connect(lambda: self._on_move_step(-1))
|
||||
btn_down.clicked.connect(lambda: self._on_move_step(1))
|
||||
step_btn_row.addWidget(btn_remove)
|
||||
step_btn_row.addWidget(btn_up)
|
||||
step_btn_row.addWidget(btn_down)
|
||||
step_btn_row.addStretch()
|
||||
vbox.addLayout(step_btn_row)
|
||||
vbox.addWidget(_hline())
|
||||
|
||||
add_box = QGroupBox("Add a step")
|
||||
add_vbox = QVBoxLayout(add_box)
|
||||
action_row = QHBoxLayout()
|
||||
action_row.addWidget(QLabel("Action:"))
|
||||
self._action_combo = QComboBox()
|
||||
for name, spec in self._actions.items():
|
||||
self._action_combo.addItem(name, name)
|
||||
idx = self._action_combo.count() - 1
|
||||
self._action_combo.setItemData(idx, spec.get("help", ""), Qt.ItemDataRole.ToolTipRole)
|
||||
self._action_combo.currentIndexChanged.connect(self._on_action_changed)
|
||||
action_row.addWidget(self._action_combo, stretch=1)
|
||||
add_vbox.addLayout(action_row)
|
||||
|
||||
self._field_form = QFormLayout()
|
||||
add_vbox.addLayout(self._field_form)
|
||||
|
||||
btn_add_step = QPushButton("Add step")
|
||||
btn_add_step.clicked.connect(self._on_add_step)
|
||||
add_vbox.addWidget(btn_add_step)
|
||||
vbox.addWidget(add_box)
|
||||
|
||||
meta_form = QFormLayout()
|
||||
self._label_edit = QLineEdit()
|
||||
meta_form.addRow("Label:", self._label_edit)
|
||||
self._idem_check = QCheckBox("Idempotent (safe to silently re-run after a crash)")
|
||||
self._idem_check.stateChanged.connect(self._on_idem_toggled_by_user)
|
||||
meta_form.addRow("", self._idem_check)
|
||||
vbox.addLayout(meta_form)
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
buttons.accepted.connect(self._on_accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
vbox.addWidget(buttons)
|
||||
|
||||
if self._actions:
|
||||
self._on_action_changed(0)
|
||||
|
||||
# ── dynamic per-action fields ─────────────────────────────────────────
|
||||
|
||||
def _on_action_changed(self, _index: int) -> None:
|
||||
while self._field_form.rowCount():
|
||||
self._field_form.removeRow(0)
|
||||
self._step_field_widgets = {}
|
||||
|
||||
action_name = self._action_combo.currentData()
|
||||
spec = self._actions.get(action_name, {})
|
||||
for param_name, field in spec.get("params", {}).items():
|
||||
widget = self._build_field_widget(field)
|
||||
self._step_field_widgets[param_name] = (widget, field)
|
||||
label = field.get("label", param_name)
|
||||
unit = field.get("unit")
|
||||
if unit:
|
||||
label = f"{label} ({unit})"
|
||||
self._field_form.addRow(f"{label}:", widget)
|
||||
|
||||
def _build_field_widget(self, field: dict) -> QWidget:
|
||||
field_type = field.get("type")
|
||||
if field_type == "float":
|
||||
w = QDoubleSpinBox()
|
||||
w.setDecimals(4)
|
||||
w.setRange(field.get("min", -1e9), field.get("max", 1e9))
|
||||
w.setValue(field.get("default", 0.0))
|
||||
return w
|
||||
if field_type == "device_positions":
|
||||
row = QWidget()
|
||||
layout = QHBoxLayout(row)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
device_combo = QComboBox()
|
||||
for name, meta in self._move_devices.items():
|
||||
text = f"{name} - {meta.get('label', name)}"
|
||||
if meta.get("unit"):
|
||||
text += f" ({meta['unit']})"
|
||||
device_combo.addItem(text, name)
|
||||
target_spin = QDoubleSpinBox()
|
||||
target_spin.setDecimals(4)
|
||||
target_spin.setRange(-1e6, 1e6)
|
||||
layout.addWidget(device_combo)
|
||||
layout.addWidget(target_spin)
|
||||
row._device_combo = device_combo # stashed for _read_field_value
|
||||
row._target_spin = target_spin
|
||||
return row
|
||||
# Unknown param type -- degrade gracefully rather than crash. The
|
||||
# registry lets a new *action* appear with zero widget changes (see
|
||||
# the class docstring), but a genuinely new *field* type still needs
|
||||
# one; this is that seam.
|
||||
return QLabel(f"(unsupported param type: {field_type!r})")
|
||||
|
||||
def _read_field_value(self, widget: QWidget, field: dict) -> Any:
|
||||
field_type = field.get("type")
|
||||
if field_type == "float":
|
||||
return widget.value()
|
||||
if field_type == "device_positions":
|
||||
device = widget._device_combo.currentData()
|
||||
target = widget._target_spin.value()
|
||||
return {device: target} if device else {}
|
||||
return None
|
||||
|
||||
# ── step list management ──────────────────────────────────────────────
|
||||
|
||||
def _on_add_step(self) -> None:
|
||||
action_name = self._action_combo.currentData()
|
||||
if action_name is None:
|
||||
return
|
||||
kwargs = {}
|
||||
for param_name, (widget, field) in self._step_field_widgets.items():
|
||||
value = self._read_field_value(widget, field)
|
||||
if field.get("type") == "device_positions" and not value:
|
||||
QMessageBox.warning(self, "Missing device", "Select a device to move.")
|
||||
return
|
||||
kwargs[param_name] = value
|
||||
|
||||
self._steps.append({"action": action_name, "kwargs": kwargs})
|
||||
summary = f"{action_name}{kwargs}" if kwargs else action_name
|
||||
self._step_list.addItem(summary)
|
||||
self._recompute_idempotent_default()
|
||||
|
||||
def _on_remove_step(self) -> None:
|
||||
row = self._step_list.currentRow()
|
||||
if row < 0:
|
||||
return
|
||||
self._step_list.takeItem(row)
|
||||
del self._steps[row]
|
||||
self._recompute_idempotent_default()
|
||||
|
||||
def _on_move_step(self, direction: int) -> None:
|
||||
row = self._step_list.currentRow()
|
||||
new_row = row + direction
|
||||
if row < 0 or not (0 <= new_row < len(self._steps)):
|
||||
return
|
||||
self._steps[row], self._steps[new_row] = self._steps[new_row], self._steps[row]
|
||||
item = self._step_list.takeItem(row)
|
||||
self._step_list.insertItem(new_row, item)
|
||||
self._step_list.setCurrentRow(new_row)
|
||||
|
||||
def _recompute_idempotent_default(self) -> None:
|
||||
"""Idempotent checkbox defaults to "every step idempotent" (ANDed,
|
||||
mirroring tomo_queue_add_command()'s own default), until the
|
||||
operator explicitly overrides it -- then their choice sticks.
|
||||
"""
|
||||
if self._idem_user_overridden or not self._steps:
|
||||
return
|
||||
all_idem = all(
|
||||
self._actions.get(step["action"], {}).get("idempotent", False) for step in self._steps
|
||||
)
|
||||
self._idem_check.blockSignals(True)
|
||||
self._idem_check.setChecked(all_idem)
|
||||
self._idem_check.blockSignals(False)
|
||||
|
||||
def _on_idem_toggled_by_user(self, _state: int) -> None:
|
||||
self._idem_user_overridden = True
|
||||
|
||||
# ── accept ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _on_accept(self) -> None:
|
||||
if not self._steps:
|
||||
QMessageBox.warning(self, "No steps", "Add at least one step before confirming.")
|
||||
return
|
||||
label = self._label_edit.text().strip() or f"cmd_{uuid.uuid4().hex[:6]}"
|
||||
self.job = {
|
||||
"kind": "command",
|
||||
"id": uuid.uuid4().hex,
|
||||
"label": label,
|
||||
"steps": list(self._steps),
|
||||
"idempotent": self._idem_check.isChecked(),
|
||||
"status": "pending",
|
||||
"added_at": datetime.datetime.now().isoformat(timespec="seconds"),
|
||||
}
|
||||
self.accept()
|
||||
|
||||
|
||||
# ── module-level helpers ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1185,6 +1490,24 @@ def _format_projections(params: dict) -> str:
|
||||
return "?"
|
||||
|
||||
|
||||
def _job_kind(job: dict) -> str:
|
||||
"""A job with no "kind" key predates command jobs and is a tomogram --
|
||||
same back-compat default as flomni.py's tomo_queue_execute()/show()."""
|
||||
return job.get("kind", "tomo")
|
||||
|
||||
|
||||
def _format_command_summary(job: dict) -> str:
|
||||
"""Mirrors flomni.py's tomo_queue_show() command-job rendering exactly,
|
||||
so the CLI and this table read as the same list."""
|
||||
steps = job.get("steps", [])
|
||||
parts = [
|
||||
f"{step['action']}{step['kwargs']}" if step.get("kwargs") else step["action"]
|
||||
for step in steps
|
||||
]
|
||||
idem = "idem" if job.get("idempotent") else "NOT idem"
|
||||
return " > ".join(parts) + f" [{idem}]"
|
||||
|
||||
|
||||
def _fmt_num(val) -> str:
|
||||
"""Compact numeric formatting for table cells; blank for None."""
|
||||
if val is None:
|
||||
|
||||
Reference in New Issue
Block a user