Skip to content

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:

uv run first_session.py
Session built: 3/3 devices, 5/5 presenters, 3/3 views

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:

The window of the session: the rows of the stages on the left, and on the
right the plan widget of scan, with a list of stages, a list of cameras and
three inputs, above an image of concentric
rings

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:

uv run python -c "import h5py, sys; print(h5py.File(sys.argv[1])['entry/data/data'].shape)" PATH
(6, 240, 320)

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.