Skip to content

Describing a device with a protocol

In this tutorial you add a second stage to the session, of a different class, and the presenter and view you already wrote control it too. To get there, you change how the presenter asks for its devices, and nothing in the view. It continues from Writing your first session.

You start by checking the types of the script. Then you write a protocol, which describes the attributes and methods a device must have, ask the session for the devices that satisfy it, and add the stage.

Before you start

What you need

A type checker. This tutorial uses mypy, which doesn't come with redsun, so add it to the project as a development tool:

uv add --dev mypy

Open first_session.py as you left it at the end of Writing your first session. You keep working in this file until the last tutorial. Each tutorial adds to what's there, and when a line you wrote has to change, the page shows it highlighted.

1. Check the types

uv run mypy first_session.py

Even though the script runs, mypy finds two errors in it:

first_session.py:29: error: "Device" has no attribute "position"  [attr-defined]
first_session.py:30: error: "Device" has no attribute "position"  [attr-defined]
Found 2 errors in 1 file (checked 1 source file)

The line numbers in your file may differ, but the two lines are the ones in nudge that read and set the position.

devices is a DeviceMapping, which holds every device of the session, of any kind. So all the type checker knows about one of them is that it's a device, and not every device has a position.

2. Say what the presenter needs

The presenter needs only one thing from a stage, a position it can read and set. Write that down as a protocol, above StagePresenter:

@runtime_checkable
class HasPosition(Protocol):
    position: SignalRW[float]

Add the imports it needs below the ones the file has. The line from __future__ import annotations stays first in the file:

from typing import Protocol, runtime_checkable

from ophyd_async.core import SignalRW

MyStage doesn't inherit from HasPosition, and you don't change it. It satisfies the protocol because it has a position of that type, which is called structural subtyping. runtime_checkable lets Python make the same check while the program runs, which the next tutorial relies on.

3. Ask for the devices that fit

Change the constructor of StagePresenter as highlighted. Where it asked for a DeviceMapping under the name devices, it now asks for DevicesOf[HasPosition] under the name stages. The rest of the class stays as it is:

class StagePresenter:
    def __init__(
        self, name: str, *, stages: DevicesOf[HasPosition], step: float = 1.0
    ) -> None:
        self.name = name
        self.stages = stages
        self.step = step

Add one more import, below the others:

from redsun import DevicesOf

The session now passes only the devices that satisfy HasPosition, by name, and leaves the others out. It reads what to pass from the type of the parameter, so the name of the parameter is yours to choose.

Imports on more than one line

The file now imports from redsun on two lines, which Python allows. The whole script at the end of each page gathers such lines into one, as a formatter would. It also drops the import of DeviceMapping, which nothing uses for now. Leave it in your file, because a later tutorial needs it again.

4. Check the types again

uv run mypy first_session.py
Success: no issues found in 1 source file

Run the script. The window hasn't changed, and the button moves the stage as before:

uv run first_session.py

5. Add a second stage

Add a second device below MyStage. It starts at 5.0, and it has a speed, which the first one lacks:

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)

FastStage shares no code with MyStage and never mentions HasPosition, but it satisfies the protocol all the same.

6. Add it to the session

Add the highlighted lines to the session. They declare the stage, and add the link that sends its position to the view:

class FirstSession(QtSession):
    config = "session.yaml"
    stage: AsDevice[MyStage]
    fast_stage: AsDevice[FastStage]
    stage_ctrl: AsPresenter[StagePresenter]
    stage_view: AsView[StageView]

    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


if __name__ == "__main__":
    FirstSession().run()

Check the types, then run the script:

uv run mypy first_session.py
uv run first_session.py
Session built: 2/2 devices, 1/1 presenters, 1/1 views

The window of the session, with a row for each stage: a Nudge button and a
position

The view has a second row, for fast_stage, which starts at 5.0. Press its button: fast_stage moves to 5.5, and stage stays where it is.

One presenter and one view now control two stages of different classes, even though you wrote neither of them for FastStage.

The whole script
"""The session built in the "Describing a device with a protocol" tutorial."""

from __future__ import annotations

from collections.abc import Iterator  # noqa: TC003
from functools import cached_property
from typing import Protocol, runtime_checkable

from bluesky.protocols import Reading  # noqa: TC002
from ophyd_async.core import (
    MovableLogic,
    SignalRW,
    StandardMovable,
    StandardReadable,
    soft_signal_rw,
)
from psygnal import Signal
from qtpy.QtWidgets import (
    QFormLayout,
    QLabel,
    QPushButton,
    QWidget,
)

from redsun import (
    AsDevice,
    AsPresenter,
    AsView,
    DevicesOf,
    Link,
    Placement,
    slot,
)
from redsun.qt import Dock, QtSession


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]




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 FirstSession(QtSession):
    config = "session.yaml"
    stage: AsDevice[MyStage]
    fast_stage: AsDevice[FastStage]
    stage_ctrl: AsPresenter[StagePresenter]
    stage_view: AsView[StageView]

    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


if __name__ == "__main__":
    FirstSession().run()

What you built

You built a session with two stages of different classes, which the presenter and view you already had both show and move. The script also passes the type checker. From now on, each stage you add takes a line in the session and a link, and no new presenter or view.

Next steps

  • Building controls for a plan is the next tutorial, where you write a plan and the window gains the controls to run it.
  • Questions explains how a component matches a protocol, and how presenters and views are asked for in the same way.
  • Write a component has more detail on each kind of component.