How to show a plan's progress¶
A plan can report how far it has got, so that the user sees it in the plan's plan widget. The plan opens a progress scope for each thing it counts, and the plan widget shows a bar for every scope while the plan runs.
Prerequisites¶
You need a session running plans from plan widgets, as in Write a plan that runs until stopped. The blocks below are parts of that page's script, and the whole script is at the end.
Report progress from a plan¶
Open a scope with declare_progress, move it with update_progress, and
finish it with done=True:
def series(
self, camera: Camera, frames: int = 10, repeats: int = 2
) -> MsgGenerator[None]:
yield from bps.open_run()
yield from rps.declare_progress("repeats")
for repeat in range(repeats):
status = yield from bps.abs_set(
camera.shutter, True, wait=False, group="shutter"
)
yield from rps.monitor_progress("shutter", status, parent="repeats")
yield from bps.wait(group="shutter")
yield from rps.declare_progress("series", parent="repeats")
for frame in range(frames):
yield from bps.trigger_and_read([camera])
yield from rps.update_progress(
"series", current=frame + 1, initial=0, target=frames, unit="frames"
)
yield from rps.update_progress("series", done=True)
yield from rps.update_progress(
"repeats", current=repeat + 1, initial=0, target=repeats, unit="repeats"
)
yield from rps.update_progress("repeats", done=True)
yield from bps.close_run()
A scope declared with parent is nested under that scope, so here each series
of frames sits under the count of repeats. current, initial and target
are in unit. A plan that knows only how far it has got as a proportion passes
fraction, from 0 to 1, instead. A scope with no target has no known end.
The engine finishes every scope still open when the plan ends, however it ends, so a plan that fails or is stopped leaves no bar behind. A plan that reports nothing shows no bar.
Show it in the plan widget¶
The engine announces every open scope on RunEngine.sig_progress. The
presenter that owns the engine passes the signal on through a signal of its
own:
connected in its __init__, once it has made the engine:
The view hands what it receives to the plan widget of the plan that is running:
@slot
def on_progress(self, scopes: tuple[ProgressState, ...]) -> None:
self.widgets[self.chooser.currentText()].show_progress(scopes)
and the session links the two, with
yield self.plan_ctrl.sig_progress, self.plan_view.on_progress in wire.
Follow a device's progress¶
A step the plan starts without waiting, such as a move or a detector
completing, returns a status. monitor_progress opens a scope that follows the
status, and finishes the scope when the status is done:
status = yield from bps.abs_set(
camera.shutter, True, wait=False, group="shutter"
)
yield from rps.monitor_progress("shutter", status, parent="repeats")
yield from bps.wait(group="shutter")
A status that reports its progress, such as a detector's complete or a
motor's set in ophyd-async, fills the bar. A status that reports nothing
gets a bar with no end, which moves back and forth until the status is done.
The example's shutter is a soft signal and opens at once, so its bar is gone
almost as soon as it appears. A real shutter or motor keeps the bar in the plan
widget for as long as it moves.
The plan still waits on the status itself. A status that fails closes its
scope, and the failure reaches the plan through its wait. The scope belongs
to the status, so the plan doesn't update or finish it with update_progress.
What a bar shows¶
Each scope is a row of the plan widget's "Progress" group, indented under its parent. The plan widget hides the group while no scope is open.
| the scope has | the bar | the text |
|---|---|---|
a target, or a fraction |
fills up to the end | 37 / 100 frames, or 42 % for a fraction |
no end, but a current |
moves back and forth | 412 frames |
| nothing reported yet | moves back and forth | no text |
When the plan passes time_remaining, the text ends with , 12 s left.
Pausing¶
A paused plan keeps its bars as they were, and its next update_progress
after it resumes moves them on.
The example in full¶
The whole script
"""The session of the guide "How to write a plan that runs until stopped"."""
from __future__ import annotations
from collections.abc import Iterator, Mapping # noqa: TC003
from concurrent.futures import Future, wait
from typing import Any, Protocol, runtime_checkable
import bluesky.plan_stubs as bps
from bluesky.protocols import Readable, Triggerable
from bluesky.utils import MsgGenerator # noqa: TC002
from ophyd_async.core import AsyncStatus, SignalRW, StandardReadable, soft_signal_rw
from psygnal import Signal
from qtpy.QtWidgets import QComboBox, QStackedWidget, QVBoxLayout, QWidget
import redsun.engine.plan_stubs as rps
from redsun import (
AsDevice,
AsPresenter,
AsView,
DeviceMapping,
HasPlans,
Link,
Placement,
PlanEntry,
slot,
)
from redsun.engine import ProgressState, RunEngine
from redsun.engine.actions import ActionManager, ActionState, PlanAction, continuous
from redsun.log import Loggable
from redsun.presenter.plan_spec import (
PlanSpec,
UnresolvableAnnotationError,
collect_arguments,
create_plan_spec,
resolve_arguments,
)
from redsun.qt import Dock, QtSession
from redsun.view.qt.utils import ActionButton, PlanWidget, create_plan_widget
class MyCamera(StandardReadable):
def __init__(self, name: str = "") -> None:
with self.add_children_as_readables():
self.frames = soft_signal_rw(int)
self.shutter = soft_signal_rw(bool)
super().__init__(name=name)
@AsyncStatus.wrap
async def trigger(self) -> None:
await self.frames.set(await self.frames.get_value() + 1)
@runtime_checkable
class Camera(Readable[Any], Triggerable, Protocol):
shutter: SignalRW[bool]
SNAP = PlanAction(name="snap", description="Take one frame")
SHUTTER = PlanAction(name="shutter", toggle_states=("Open", "Close"))
class MyController:
def __init__(self, name: str) -> None:
self.name = name
self.actions = ActionManager()
@continuous(pausable=True)
def live(self, camera: Camera) -> MsgGenerator[None]:
yield from bps.open_run()
while True:
yield from bps.checkpoint()
yield from bps.trigger_and_read([camera])
@continuous
def snapshots(
self, camera: Camera, snap: PlanAction = SNAP, shutter: PlanAction = SHUTTER
) -> MsgGenerator[None]:
yield from bps.open_run()
while True:
name = yield from self.actions.wait(snap, shutter)
try:
if name == snap.name:
yield from bps.trigger_and_read([camera])
else:
yield from bps.mv(camera.shutter, True)
yield from self.actions.wait_released(shutter)
finally:
if name == shutter.name:
yield from bps.mv(camera.shutter, False)
self.actions.done(name)
def series(
self, camera: Camera, frames: int = 10, repeats: int = 2
) -> MsgGenerator[None]:
yield from bps.open_run()
yield from rps.declare_progress("repeats")
for repeat in range(repeats):
status = yield from bps.abs_set(
camera.shutter, True, wait=False, group="shutter"
)
yield from rps.monitor_progress("shutter", status, parent="repeats")
yield from bps.wait(group="shutter")
yield from rps.declare_progress("series", parent="repeats")
for frame in range(frames):
yield from bps.trigger_and_read([camera])
yield from rps.update_progress(
"series", current=frame + 1, initial=0, target=frames, unit="frames"
)
yield from rps.update_progress("series", done=True)
yield from rps.update_progress(
"repeats", current=repeat + 1, initial=0, target=repeats, unit="repeats"
)
yield from rps.update_progress("repeats", done=True)
yield from bps.close_run()
def plan_map(self) -> Mapping[str, PlanEntry]:
return {
"live": {"plan": self.live},
"snapshots": {"plan": self.snapshots},
"series": {"plan": self.series},
}
class PlanPresenter(Loggable):
sig_finished = Signal()
sig_progress = Signal(tuple)
def __init__(self, name: str, *, devices: DeviceMapping) -> None:
self.name = name
self.devices = devices
self.engine = RunEngine()
self.plans: dict[str, PlanEntry] = {}
self.specs: dict[str, PlanSpec] = {}
self.futures: set[Future[Any]] = set()
self.engine.sig_progress.connect(self.sig_progress.emit)
def setup(self, plan_sources: Mapping[str, HasPlans]) -> None:
for component in plan_sources.values():
for plan, entry in component.plan_map().items():
try:
self.specs[plan] = create_plan_spec(entry["plan"], self.devices)
except (UnresolvableAnnotationError, ValueError) as error:
self.logger.warning(error)
continue
self.plans[plan] = entry
@slot
def run(self, plan: str, values: dict[str, Any]) -> None:
if self.futures:
self.logger.warning(f"A plan is running; {plan!r} not started")
return
resolved = resolve_arguments(self.specs[plan], values, self.devices)
args, kwargs = collect_arguments(self.specs[plan], resolved)
self.watch(self.engine(self.plans[plan]["plan"](*args, **kwargs)))
@slot
def toggle(self, plan: str, on: bool, values: dict[str, Any]) -> None:
if on:
self.run(plan, values)
elif self.engine.state != "idle":
self.watch(self.engine.stop())
@slot
def pause(self, paused: bool) -> None:
if paused:
self.engine.request_pause(defer=True)
else:
self.watch(self.engine.resume())
def watch(self, future: Future[Any]) -> None:
self.futures.add(future)
future.add_done_callback(self.finished)
def finished(self, future: Future[Any]) -> None:
self.futures.discard(future)
if not self.futures and self.engine.state != "paused":
self.sig_finished.emit()
def shutdown(self) -> None:
if self.futures or self.engine.state == "paused":
wait([self.engine.stop()], timeout=10)
class PlanView(QWidget):
placement: Placement = Dock("right")
sig_run = Signal(str, dict)
sig_toggle = Signal(str, bool, dict)
sig_pause = Signal(bool)
sig_action_request = Signal(str, bool)
def __init__(self, name: str, parent: QWidget) -> None:
super().__init__(parent)
self.name = name
self.chooser = QComboBox()
self.pages = QStackedWidget()
self.chooser.currentIndexChanged.connect(self.pages.setCurrentIndex)
layout = QVBoxLayout(self)
layout.addWidget(self.chooser)
layout.addWidget(self.pages)
self.widgets: dict[str, PlanWidget] = {}
def setup(
self, plan_sources: Mapping[str, HasPlans], devices: DeviceMapping
) -> None:
for component in plan_sources.values():
for entry in component.plan_map().values():
try:
self.add_plan(create_plan_spec(entry["plan"], devices))
except (UnresolvableAnnotationError, ValueError):
continue
def add_plan(self, spec: PlanSpec) -> None:
widget = create_plan_widget(
spec,
run_callback=lambda: self.ask_to_run(spec.name),
toggle_callback=lambda on: self.ask_to_toggle(spec.name, on),
pause_callback=lambda paused: self.ask_to_pause(spec.name, paused),
action_clicked_callback=self.ask,
action_toggled_callback=self.ask_or_release,
)
self.widgets[spec.name] = widget
self.chooser.addItem(spec.name)
self.pages.addWidget(widget.group_box)
def ask_to_run(self, plan: str) -> None:
self.setEnabled(False)
self.sig_run.emit(plan, self.widgets[plan].parameters)
def ask_to_toggle(self, plan: str, on: bool) -> None:
self.chooser.setEnabled(not on)
self.widgets[plan].toggle(on)
self.sig_toggle.emit(plan, on, self.widgets[plan].parameters)
def ask_to_pause(self, plan: str, paused: bool) -> None:
self.widgets[plan].pause(paused)
self.sig_pause.emit(paused)
@slot
def on_finished(self) -> None:
self.setEnabled(True)
self.chooser.setEnabled(True)
self.widgets[self.chooser.currentText()].toggle(False)
def ask(self, name: str) -> None:
self.sig_action_request.emit(name, True)
def ask_or_release(self, checked: bool, name: str) -> None:
self.sig_action_request.emit(name, checked)
@slot
def on_action_changed(self, name: str, state: str) -> None:
for widget in self.widgets.values():
if name in widget.action_buttons:
self.set_action_button(widget.action_buttons[name], state)
def set_action_button(self, button: ActionButton, state: str) -> None:
match state:
case ActionState.IDLE:
button.setEnabled(False)
button.release()
case ActionState.OFFERED:
button.setEnabled(True)
case ActionState.RUNNING:
button.setEnabled(button.isCheckable())
@slot
def on_progress(self, scopes: tuple[ProgressState, ...]) -> None:
self.widgets[self.chooser.currentText()].show_progress(scopes)
class MyApp(QtSession):
camera: AsDevice[MyCamera]
ctrl: AsPresenter[MyController]
plan_ctrl: AsPresenter[PlanPresenter]
plan_view: AsView[PlanView]
def wire(self) -> Iterator[Link]:
yield self.plan_view.sig_run, self.plan_ctrl.run
yield self.plan_view.sig_toggle, self.plan_ctrl.toggle
yield self.plan_view.sig_pause, self.plan_ctrl.pause
yield self.plan_ctrl.sig_finished, self.plan_view.on_finished
yield self.plan_view.sig_action_request, self.ctrl.actions.request
yield self.ctrl.actions.sig_changed, self.plan_view.on_action_changed
yield self.plan_ctrl.sig_progress, self.plan_view.on_progress
if __name__ == "__main__":
MyApp().run()