Skip to content

How to write a component

This page shows how to write each kind of component and add it to a session. Components explains the rules behind it.

A device

Write an ophyd-async device. Its constructor must accept name as a keyword, as every ophyd-async base class does:

from ophyd_async.core import StandardReadable, soft_signal_rw


class MyStage(StandardReadable):
    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)

Every argument after name can come from the session file.

A presenter

A presenter is any class that takes name as a keyword and keeps it:

from psygnal import Signal

from redsun import DeviceMapping, slot


class StagePresenter:
    sig_moved = Signal(float)

    def __init__(self, name: str, *, devices: DeviceMapping, step: float = 1.0) -> None:
        self.name = name
        self.stage = devices["stage"]
        self.step = step

    @slot
    async def nudge(self) -> None:
        position = await self.stage.position.get_value()
        await self.stage.position.set(position + self.step)
        self.sig_moved.emit(position + self.step)
  • devices is filled by type: DeviceMapping is every device of the session, by name.
  • step comes from the session file if it is there, and is 1.0 if not.
  • nudge is a slot, so the session can connect a signal to it. It may be async.

A view

A Qt view is a QWidget with a placement, and a constructor starting with (name: str, parent: QWidget):

from psygnal import Signal
from qtpy.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget

from redsun import Placement, slot
from redsun.qt import Dock


class StageView(QWidget):
    placement: Placement = Dock("left")
    sig_nudge = Signal()

    def __init__(self, name: str, parent: QWidget) -> None:
        super().__init__(parent)
        self.name = name
        button = QPushButton("Nudge")
        button.clicked.connect(self.sig_nudge.emit)
        self.label = QLabel("position: 0.0")
        layout = QVBoxLayout(self)
        layout.addWidget(button)
        layout.addWidget(self.label)

    @slot
    def show_position(self, position: float) -> None:
        self.label.setText(f"position: {position}")

Add them to a session

Declare each component on a session class, and connect the view's button to the presenter, and the presenter back to the view, in wire:

from collections.abc import Iterator

from redsun import AsDevice, AsPresenter, AsView, Link
from redsun.qt import QtSession


class MyApp(QtSession):
    stage: AsDevice[MyStage]
    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_ctrl.sig_moved, self.stage_view.show_position

To give a component arguments in Python rather than in the file, use Declare:

from typing import Annotated

from redsun import Declare


class MyApp(QtSession):
    stage_ctrl: Annotated[AsPresenter[StagePresenter], Declare(step=0.5)]

If the file sets the same argument, Declare wins.

A component reads its arguments from the file under its own name. To read them under another key, use FromConfig:

from redsun import FromConfig


class MyApp(QtSession):
    stage: Annotated[AsDevice[MyStage], FromConfig("xy_stage")]
devices:
  xy_stage:
    units: um

The device is still called stage. Only the key its arguments are read from changes, which helps when that key isn't a valid Python name.

Alias gives the component another name:

from redsun import Alias


class MyApp(QtSession):
    stage_ctrl: Annotated[AsPresenter[StagePresenter], Alias("ctrl")]

The alias is the name the component receives, the name the session lists it under and the name a session file wires it by. The attribute you declared it under still holds it, and its arguments are still read from the entry named after that attribute, here stage_ctrl.

Use another component

A constructor runs before the other components exist, so you ask for another component, or a value it shares, in setup:

class MotorReadings:
    """The last position read from each motor."""

    def __init__(self) -> None:
        self.positions: dict[str, float] = {}


class RoiPresenter:
    def __init__(self, name: str) -> None:
        self.name = name

    def setup(self, readings: MotorReadings) -> None:
        self.readings = readings

MotorReadings stands for any class of yours that another component shares.

To share a value, see Share a value. To ask for "every component that can do X", see Questions.

Clean up

Define shutdown, plain or async, and the session calls it when it shuts down:

class StagePresenter:
    def shutdown(self) -> None:
        self._task.cancel()

Test it

A component takes plain values, so a test can make it directly:

from redsun.aio import run_coro


def test_nudge_moves_by_one_step() -> None:
    stage = MyStage(name="stage")
    run_coro(stage.connect(mock=True))
    ctrl = StagePresenter("ctrl", devices={"stage": stage}, step=2.0)

    run_coro(ctrl.nudge())

    assert run_coro(stage.position.get_value()) == 2.0

To test it inside a session without a window, declare the same components on a plain Session, which makes every component and shows nothing. Give it mock: true, and its devices connect as connect(mock=True) does, with no service launched:

from redsun import AsDevice, AsPresenter, Session


class MyHeadlessApp(Session):
    stage: AsDevice[MyStage]
    stage_ctrl: AsPresenter[StagePresenter]


def test_the_session_builds_without_hardware() -> None:
    app = MyHeadlessApp({"mock": True}).build()

    assert set(app.devices) == {"stage"}
    app.shutdown()