Scanning a stage with the camera¶
In this tutorial you add a plan that uses two devices at once: it moves a stage, and takes a frame with the camera at each position. It continues from Acquiring images.
The plan lives in a presenter that holds nothing else, with no RunEngine,
no signal and no view. You write that one class and one line of the session,
and the window offers the scan.
Before you start¶
What you need
The project folder as you left it, and nothing else. The script doesn't need any new imports either.
1. Write the presenter¶
Open first_session.py, and add a presenter below ImageView:
class ScanPlans:
def __init__(self, name: str) -> None:
self.name = name
def scan(
self,
stage: HasPosition,
camera: Camera,
start: float = 0.0,
stop: float = 5.0,
points: int = 6,
) -> MsgGenerator[Any]:
return (yield from bp.scan([camera], stage.position, start, stop, points))
def plan_map(self) -> Mapping[str, PlanEntry]:
return {"scan": {"plan": self.scan}}
scan wraps a plan bluesky already has, also called
scan. It moves the stage through points positions
from start to stop, and reads the camera at each one.
The plan asks for a stage and a camera through the two protocols you wrote,
HasPosition and Camera, so it names no device. The presenter holds none
either.
2. Add it to the session¶
Add the highlighted line:
class FirstSession(QtSession):
config = "session.yaml"
stage: AsDevice[MyStage]
fast_stage: AsDevice[FastStage]
camera: AsDevice[SimBlobDetector]
stage_ctrl: AsPresenter[StagePresenter]
stage_plans: AsPresenter[StagePlans]
plan_ctrl: AsPresenter[PlanPresenter]
camera_ctrl: AsPresenter[CameraPresenter]
scan_plans: AsPresenter[ScanPlans]
stage_view: AsView[StageView]
plan_view: AsView[PlanView]
image_view: AsView[ImageView]
def wire(self) -> Iterator[Link]:
yield self.stage_view.sig_nudge, self.stage_ctrl.nudge
yield self.stage.position, self.stage_view.show_reading
yield self.fast_stage.position, self.stage_view.show_reading
yield self.plan_view.sig_run, self.plan_ctrl.run
yield self.plan_ctrl.sig_finished, self.plan_view.on_finished
yield self.plan_ctrl.sig_started, self.path_provider.set_plan
yield self.plan_ctrl.sig_finished, self.path_provider.reset_plan
yield self.plan_ctrl.sig_finished, self.camera_ctrl.show_last
yield self.camera_ctrl.sig_frame, self.image_view.show_frame
if __name__ == "__main__":
FirstSession().run()
wire doesn't change. Run the script:
The list of the plans has a third entry, scan.
3. Run the scan¶
Choose scan in the list of the plans. Its plan widget has two lists, one
for the stage and one for the camera, and an input each for start, stop
and points:

Press Run. The position of stage goes from 0.0 to 5.0, one
millimetre at a time, and the last frame appears below.
4. Find the file¶
Open the camera's folder, where the last tutorial left its files. There's a
new file there, scan_00000.h5, named after the plan. It holds six frames,
one for each position.
To count them, run this command, replacing the last word with the path of the file:
The file holds the frames but not the positions. The positions are in the documents of the run, and nothing in this session keeps them; How to keep a catalog of runs shows how.
5. Scan the other stage¶
Choose fast_stage in the list of stages, set points to 3, and press
Run. fast_stage goes to 0.0, then to 2.5 and to 5.0, and
scan_00001.h5 holds three frames.
The whole script
"""The session built in the "Scanning a stage with the camera" tutorial."""
from __future__ import annotations
from collections.abc import Iterator, Mapping # noqa: TC003
from functools import cached_property
from typing import Any, Protocol, runtime_checkable
from urllib.parse import urlsplit
from urllib.request import url2pathname
import bluesky.plan_stubs as bps
import bluesky.plans as bp
import h5py
import numpy as np
from bluesky.protocols import Readable, Reading, Triggerable
from bluesky.utils import MsgGenerator # noqa: TC002
from event_model import DocumentRouter, StreamResource
from ophyd_async.core import (
MovableLogic,
SignalRW,
StandardMovable,
StandardReadable,
soft_signal_rw,
)
from ophyd_async.sim import SimBlobDetector # noqa: TC002
from psygnal import Signal
from qtpy.QtGui import QImage, QPixmap
from qtpy.QtWidgets import (
QComboBox,
QFormLayout,
QLabel,
QPushButton,
QStackedWidget,
QVBoxLayout,
QWidget,
)
from redsun import (
AsDevice,
AsPresenter,
AsView,
CallbackType,
DeviceMapping,
DevicesOf,
HasPlans,
Link,
Placement,
PlanEntry,
slot,
)
from redsun.engine import RunEngine
from redsun.presenter.plan_spec import (
PlanSpec,
collect_arguments,
create_plan_spec,
resolve_arguments,
)
from redsun.qt import Dock, QtSession
from redsun.view.qt.utils import PlanWidget, create_plan_widget
class MyStage(StandardReadable, StandardMovable[float]):
def __init__(self, name: str = "", *, units: str = "mm") -> None:
with self.add_children_as_readables():
self.position = soft_signal_rw(float, units=units)
super().__init__(name=name)
@cached_property
def movable_logic(self) -> MovableLogic[float]:
return MovableLogic(setpoint=self.position, readback=self.position)
class FastStage(StandardReadable, StandardMovable[float]):
def __init__(self, name: str = "", *, units: str = "mm") -> None:
with self.add_children_as_readables():
self.position = soft_signal_rw(float, initial_value=5.0, units=units)
self.speed = soft_signal_rw(float, initial_value=10.0)
super().__init__(name=name)
@cached_property
def movable_logic(self) -> MovableLogic[float]:
return MovableLogic(setpoint=self.position, readback=self.position)
@runtime_checkable
class HasPosition(Protocol):
position: SignalRW[float]
@runtime_checkable
class Camera(Readable[Any], Triggerable, Protocol): ...
class StagePresenter:
def __init__(
self, name: str, *, stages: DevicesOf[HasPosition], step: float = 1.0
) -> None:
self.name = name
self.stages = stages
self.step = step
@slot
async def nudge(self, stage: str) -> None:
position = await self.stages[stage].position.get_value()
await self.stages[stage].position.set(position + self.step)
class StageView(QWidget):
placement: Placement = Dock("left")
sig_nudge = Signal(str)
def __init__(self, name: str, parent: QWidget) -> None:
super().__init__(parent)
self.name = name
self.rows = QFormLayout(self)
self.labels: dict[str, QLabel] = {}
def add_row(self, stage: str) -> None:
button = QPushButton(f"Nudge {stage}")
button.clicked.connect(lambda: self.sig_nudge.emit(stage))
self.labels[stage] = QLabel()
self.rows.addRow(button, self.labels[stage])
@slot
def show_reading(self, reading: dict[str, Reading[float]]) -> None:
for stage, entry in reading.items():
if stage not in self.labels:
self.add_row(stage)
self.labels[stage].setText(f"position: {entry['value']}")
class StagePlans:
def __init__(self, name: str) -> None:
self.name = name
def walk(
self, stage: HasPosition, steps: int = 5, size: float = 1.0
) -> MsgGenerator[None]:
for _ in range(steps):
position = yield from bps.rd(stage.position)
yield from bps.mv(stage.position, position + size)
def plan_map(self) -> Mapping[str, PlanEntry]:
return {"walk": {"plan": self.walk}}
class PlanPresenter:
sig_started = Signal(str)
sig_finished = Signal()
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] = {}
def setup(
self,
plan_sources: Mapping[str, HasPlans],
callbacks: Mapping[str, CallbackType],
) -> None:
for component in plan_sources.values():
self.plans.update(component.plan_map())
for plan, entry in self.plans.items():
self.specs[plan] = create_plan_spec(entry["plan"], self.devices)
for callback in callbacks.values():
self.engine.subscribe(callback)
@slot
def run(self, plan: str, values: dict[str, Any]) -> None:
resolved = resolve_arguments(self.specs[plan], values, self.devices)
args, kwargs = collect_arguments(self.specs[plan], resolved)
self.sig_started.emit(plan)
future = self.engine(self.plans[plan]["plan"](*args, **kwargs))
future.add_done_callback(lambda _: self.sig_finished.emit())
class PlanView(QWidget):
placement: Placement = Dock("right")
sig_run = Signal(str, dict)
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():
self.add_plan(create_plan_spec(entry["plan"], devices))
def add_plan(self, spec: PlanSpec) -> None:
widget = create_plan_widget(
spec, run_callback=lambda: self.ask_to_run(spec.name)
)
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)
@slot
def on_finished(self) -> None:
self.setEnabled(True)
class CameraPresenter(DocumentRouter):
sig_frame = Signal(object)
def __init__(self, name: str, *, cameras: DevicesOf[Camera]) -> None:
super().__init__()
self.name = name
self.cameras = cameras
self.written: tuple[str, str] | None = None
def snap(self, camera: Camera, frames: int = 3) -> MsgGenerator[Any]:
return (yield from bp.count([camera], num=frames))
def plan_map(self) -> Mapping[str, PlanEntry]:
return {"snap": {"plan": self.snap}}
def stream_resource(self, doc: StreamResource) -> StreamResource:
if doc["data_key"] in self.cameras:
self.written = (doc["uri"], doc["parameters"]["dataset"])
return doc
@slot
def show_last(self) -> None:
if self.written is not None:
uri, dataset = self.written
with h5py.File(url2pathname(urlsplit(uri).path), "r") as file:
self.sig_frame.emit(file[dataset][-1])
self.written = None
class ImageView(QWidget):
placement: Placement = Dock("right")
def __init__(self, name: str, parent: QWidget) -> None:
super().__init__(parent)
self.name = name
self.image = QLabel("No image yet")
QVBoxLayout(self).addWidget(self.image)
@slot
def show_frame(self, frame: object) -> None:
values = np.asarray(frame, dtype=float)
low, high = values.min(), values.max()
grey = (255 * (values - low) / max(high - low, 1.0)).astype(np.uint8)
height, width = grey.shape
image = QImage(
grey.tobytes(), width, height, width, QImage.Format.Format_Grayscale8
)
self.image.setPixmap(QPixmap.fromImage(image.copy()))
class ScanPlans:
def __init__(self, name: str) -> None:
self.name = name
def scan(
self,
stage: HasPosition,
camera: Camera,
start: float = 0.0,
stop: float = 5.0,
points: int = 6,
) -> MsgGenerator[Any]:
return (yield from bp.scan([camera], stage.position, start, stop, points))
def plan_map(self) -> Mapping[str, PlanEntry]:
return {"scan": {"plan": self.scan}}
class FirstSession(QtSession):
config = "session.yaml"
stage: AsDevice[MyStage]
fast_stage: AsDevice[FastStage]
camera: AsDevice[SimBlobDetector]
stage_ctrl: AsPresenter[StagePresenter]
stage_plans: AsPresenter[StagePlans]
plan_ctrl: AsPresenter[PlanPresenter]
camera_ctrl: AsPresenter[CameraPresenter]
scan_plans: AsPresenter[ScanPlans]
stage_view: AsView[StageView]
plan_view: AsView[PlanView]
image_view: AsView[ImageView]
def wire(self) -> Iterator[Link]:
yield self.stage_view.sig_nudge, self.stage_ctrl.nudge
yield self.stage.position, self.stage_view.show_reading
yield self.fast_stage.position, self.stage_view.show_reading
yield self.plan_view.sig_run, self.plan_ctrl.run
yield self.plan_ctrl.sig_finished, self.plan_view.on_finished
yield self.plan_ctrl.sig_started, self.path_provider.set_plan
yield self.plan_ctrl.sig_finished, self.path_provider.reset_plan
yield self.plan_ctrl.sig_finished, self.camera_ctrl.show_last
yield self.camera_ctrl.sig_frame, self.image_view.show_frame
if __name__ == "__main__":
FirstSession().run()
What you built¶
You built a scan that moves the stage you choose and takes a frame at each position, and the camera writes one file for each run.
You put it together from pieces that don't name each other. The stage and the camera reach the plan through two protocols. The plan reaches the presenter that runs it, and the view that shows its plan widget, because its presenter offers plans. The frames reach the presenter that shows them through the documents of the run. So you can replace any piece, or take it to another session, without editing the others.
Next steps¶
- Arranging the window is the next tutorial, where you give the image the middle of the window.
- How presenters run plans covers plans that run until they are stopped, and actions the user takes while they run.
- Questions explains how a component asks the session what it holds.