Skip to content

How a session works

A session is one running application. It knows which components to make, makes them in the right order, connects them, and takes them apart again when it ends.

You write a session as a class:

from collections.abc import Iterator
from typing import Annotated

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


class MyApp(QtSession):
    config = "session.yaml"

    stage: AsDevice[MyStage]
    motor_ctrl: AsPresenter[MotorPresenter]
    motor_widget: Annotated[AsView[MotorView], Declare(step_size=5.0)]

    def wire(self) -> Iterator[Link]:
        yield self.motor_ctrl.sig_moved, self.motor_widget.refresh


MyApp().run()

Each annotated line is a declaration: the name on the left becomes the component's name, and AsDevice, AsPresenter or AsView says which layer it belongs to and which class to make. Once the session is built, self.motor_ctrl holds the MotorPresenter it made, and your editor and mypy see it as one.

The layers of a session

A session has three layers of components, the DVP pattern, and the services below them reach the hardware. Point at a layer to read what it holds:

The layers of a session
session processservicestheir own programs, reached by prefixover Channel Access or PVAccessA service talks to the hardware and offers it to the devices under a prefix. The session starts the ones it launches before anything else and stops them after every component. A device that needs no hardware, such as a simulated stage, needs no service.hardwareviewswhat the user sees and touchesA view holds the widgets. It shows what devices and presenters report, and turns what the user does into a signal a presenter acts on. It may hold presenters and devices.presentersdecide what happens and whenA presenter runs plans, computes results from the documents a run produces, moves devices when asked, and keeps the state the application needs. It may hold devices, never a view.devicesthe signals of your setupEach device is an ophyd-async device, a set of signals such as a position or an exposure time. It knows what can be controlled, not when or why.A service talks to the hardware and offers it to the devices under a prefix. The session starts the ones it launches before anything else and stops them after every component. A device that needs no hardware, such as a simulated stage, needs no service. A view holds the widgets. It shows what devices and presenters report, and turns what the user does into a signal a presenter acts on. It may hold presenters and devices. A presenter runs plans, computes results from the documents a run produces, moves devices when asked, and keeps the state the application needs. It may hold devices, never a view. Each device is an ophyd-async device, a set of signals such as a position or an exposure time. It knows what can be controlled, not when or why.
session processservicestheir own programs, reached by prefixover Channel Access or PVAccessA service talks to the hardware and offers it to the devices under a prefix. The session starts the ones it launches before anything else and stops them after every component. A device that needs no hardware, such as a simulated stage, needs no service.hardwareviewswhat the user sees and touchesA view holds the widgets. It shows what devices and presenters report, and turns what the user does into a signal a presenter acts on. It may hold presenters and devices.presentersdecide what happens and whenA presenter runs plans, computes results from the documents a run produces, moves devices when asked, and keeps the state the application needs. It may hold devices, never a view.devicesthe signals of your setupEach device is an ophyd-async device, a set of signals such as a position or an exposure time. It knows what can be controlled, not when or why.A service talks to the hardware and offers it to the devices under a prefix. The session starts the ones it launches before anything else and stops them after every component. A device that needs no hardware, such as a simulated stage, needs no service. A view holds the widgets. It shows what devices and presenters report, and turns what the user does into a signal a presenter acts on. It may hold presenters and devices. A presenter runs plans, computes results from the documents a run produces, moves devices when asked, and keeps the state the application needs. It may hold devices, never a view. Each device is an ophyd-async device, a set of signals such as a position or an exposure time. It knows what can be controlled, not when or why.

The session builds the layers in that order, and a component's constructor or setup can take only what its own layer or an earlier one owns. So a view may take presenters and devices, while a presenter never holds a view, which is why a presenter runs without a screen, in a test for example. Signals and slots are not bound by the order: they connect components across layers in either direction. Components explains what each layer may contain.

Services are separate programs, not components the session builds. The session starts the ones it launches before anything else and stops them after every component.

What a build does

build reads the configuration, gets the frontend ready, then runs the build steps in a fixed order:

The build steps

Thirteen steps, always in this order. Step through them to see what each one does.

read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreport
read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreport

The session reads its files and mappings in order, merges them, and checks the result once, listing every problem it finds.

read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreport# common.yaml presenters:   motor_ctrl:     step: 2.0# common.yaml presenters:   motor_ctrl:     step: 2.0# simulation.yaml session: my-lab# simulation.yaml session: my-labmerged and checked then
read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreport# common.yaml presenters:   motor_ctrl:     step: 2.0# common.yaml presenters:   motor_ctrl:     step: 2.0# simulation.yaml session: my-lab# simulation.yaml session: my-labmerged and checked then

Before any component exists, the session sets the backend that runs coroutine slots. A QtSession also makes the QApplication here.

read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreportbackend forcoroutine slotsQApplicationQtSession only
read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreportbackend forcoroutine slotsQApplicationQtSession only

The session runs the module of every launched service at once, as its own process, and waits up to 15 seconds for the ready text the declaration names. A session whose storage names a catalog also starts its tiled server.

read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreportcamera_ioc: Annotated[     AsService,     Launch("mylab.iocs.camera", ready="Server startup complete."), ]camera_ioc: Annotated[     AsService,     Launch("mylab.iocs.camera", ready="Server startup complete."), ]$ python -m mylab.iocs.camera ... Server startup complete.$ python -m mylab.iocs.camera ... Server startup complete. starts it, then waits for the ready line
read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreportcamera_ioc: Annotated[     AsService,     Launch("mylab.iocs.camera", ready="Server startup complete."), ]camera_ioc: Annotated[     AsService,     Launch("mylab.iocs.camera", ready="Server startup complete."), ]$ python -m mylab.iocs.camera ... Server startup complete.$ python -m mylab.iocs.camera ... Server startup complete. starts it, then waits for the ready line

Each device is made with its name and its settings, plus its service's prefix and the path provider when its constructor takes them. One that raises is left out.

read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreportstage = MyStage(name="stage", prefix="ST:") camera = MyCamera(name="camera", path_provider=paths)stage = MyStage(name="stage", prefix="ST:") camera = MyCamera(name="camera", path_provider=paths)
read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreportstage = MyStage(name="stage", prefix="ST:") camera = MyCamera(name="camera", path_provider=paths)stage = MyStage(name="stage", prefix="ST:") camera = MyCamera(name="camera", path_provider=paths)

The devices connect all at once. A device that hasn't answered after 10 seconds is left out, like one that failed to build.

read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreportstageno answer in 10 s:left outcameracamera_ioc connect()connect()
read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreportstageno answer in 10 s:left outcameracamera_ioc connect()connect()

The registry collects what a constructor can ask for by type, before the first presenter is made.

read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreportSessionConfigSettingsDeviceMappingDevicesOf[P]path providercatalog addresswhat theproviders share
read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreportSessionConfigSettingsDeviceMappingDevicesOf[P]path providercatalog addresswhat theproviders share

Each presenter gets its constructor arguments by type and from the files. What it shares with provides is registered as soon as it is made.

read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreportclass MotorPresenter:     def __init__(self, name: str, *, devices: DeviceMapping, step: float = 1.0): ...     @provides     def readings(self) -> MotorReadings: ...class MotorPresenter:     def __init__(self, name: str, *, devices: DeviceMapping, step: float = 1.0): ...     @provides     def readings(self) -> MotorReadings: ...
read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreportclass MotorPresenter:     def __init__(self, name: str, *, devices: DeviceMapping, step: float = 1.0): ...     @provides     def readings(self) -> MotorReadings: ...class MotorPresenter:     def __init__(self, name: str, *, devices: DeviceMapping, step: float = 1.0): ...     @provides     def readings(self) -> MotorReadings: ...

Each view is made the same way. In a QtSession it also gets the main window as its parent.

read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreportclass MotorView(QWidget):     def __init__(self, name: str, parent: QWidget): ...class MotorView(QWidget):     def __init__(self, name: str, parent: QWidget): ...
read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreportclass MotorView(QWidget):     def __init__(self, name: str, parent: QWidget): ...class MotorView(QWidget):     def __init__(self, name: str, parent: QWidget): ...

Every setup runs now that all presenters and views exist, in declaration order, and gets what it asks for by type.

read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreportclass RoiPresenter:     def setup(self, readings: MotorReadings) -> None:         self.readings = readingsclass RoiPresenter:     def setup(self, readings: MotorReadings) -> None:         self.readings = readings
read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreportclass RoiPresenter:     def setup(self, readings: MotorReadings) -> None:         self.readings = readingsclass RoiPresenter:     def setup(self, readings: MotorReadings) -> None:         self.readings = readings

The session records what was built, notes the settings it would save, and closes itself to further building.

read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreportrecord whatwas builtnote the settingsit would saveclose to morebuilding
read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreportrecord whatwas builtnote the settingsit would saveclose to morebuilding

Signals are connected to slots: the links wire yields, then the wiring section of the files, then the pairs.

read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreportdef wire(self) -> Iterator[Link]:     yield self.motor_ctrl.sig_moved, self.motor_widget.refreshdef wire(self) -> Iterator[Link]:     yield self.motor_ctrl.sig_moved, self.motor_widget.refresh
read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreportdef wire(self) -> Iterator[Link]:     yield self.motor_ctrl.sig_moved, self.motor_widget.refreshdef wire(self) -> Iterator[Link]:     yield self.motor_ctrl.sig_moved, self.motor_widget.refresh

A QtSession puts every view in its main window, where its placement says. A plain Session shows nothing.

read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreportmain windowDock("left")motor_widgetCentral()
read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreportmain windowDock("left")motor_widgetCentral()

The session logs a summary of what it built and what it left out.

read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreportSession built: 2/2 devices, 1/1 presenters, 1/1 viewsSession built: 2/2 devices, 1/1 presenters, 1/1 views
read theconfigurationget thefrontend readyservicesdevicesconnectsealsetupviewspresentersregistrywiringpresentationreportSession built: 2/2 devices, 1/1 presenters, 1/1 viewsSession built: 2/2 devices, 1/1 presenters, 1/1 views

The shared values registry collects come from the session's providers, classes made only to share values. The settings seal notes let the session tell later whether you changed any.

The order never changes. A frontend can change what happens inside a step, never which steps run: presentation does nothing in a plain Session, while QtSession shows the main window and its views there.

Components that fail to build

A component that fails to build is left out, and so is anything built from it; the session carries on with the rest:

A device that fails to build

The stage and its presenter build. The camera's view takes the camera's presenter, which takes the camera.

stagecamerastage_ctrlcamera_ctrlcamera_view takestakestakes
stagecamerastage_ctrlcamera_ctrlcamera_view takestakestakes

The camera's constructor raises, so the session logs the error and leaves the camera out.

stagecameraconstructor raisedstage_ctrlcamera_ctrlcamera_view takestakestakes
stagecameraconstructor raisedstage_ctrlcamera_ctrlcamera_view takestakestakes

camera_ctrl takes the camera, so it is left out too.

stagecameraconstructor raisedstage_ctrlcamera_ctrlleft outcamera_view takestakestakes
stagecameraconstructor raisedstage_ctrlcamera_ctrlleft outcamera_view takestakestakes

So is camera_view, which takes camera_ctrl. The stage and its presenter run as usual.

stagecameraconstructor raisedstage_ctrlcamera_ctrlleft outcamera_viewleft out takestakestakes
stagecameraconstructor raisedstage_ctrlcamera_ctrlleft outcamera_viewleft out takestakestakes

Here the camera's constructor can't find the serial port it talks through:

class MyCamera(StandardReadable):
    def __init__(self, name: str = "", *, port: str = "COM3") -> None:
        raise OSError(f"serial port {port} not found")


class MyApp(Session):
    stage: AsDevice[MyStage]
    camera: AsDevice[MyCamera]
    stage_ctrl: AsPresenter[StagePresenter]

The session logs the error, and the summary at the end names what is missing:

$ uv run python my_session.py
[07-10-26|22:15:21][ERROR]: Failed to build device 'camera': serial port COM3 not found (_base.py:1581)
[07-10-26|22:15:21][WARNING]: Session built: 1/2 devices, 1/1 presenters, 0/0 views
Not built: camera (device) (_base.py:878)

A mistake in the session itself, such as a malformed wiring section, still stops the build, and a strict session stops whenever something is missing. How to find out why a component is missing shows how to read the summary and make a session strict. ADR 11 records why the session carries on.

Two cases only log a warning: a component that shares nothing, asks for nothing and isn't wired to anything, and a shared value no component asks for. Neither is a mistake while you're still putting a session together, or when a plugin ships more than your session uses.

Shutting down

Each build step registers how to undo what it did, at the moment it does it. These releases run in reverse order when you call shutdown:

What shutdown undoes, in order

Shutdown runs the releases the build registered, newest first.

connectionscomponentsnewest firstdevicesserviceslog files
connectionscomponentsnewest firstdevicesserviceslog files

The connections go first, so no signal reaches a slot of a component that is shutting down.

connectionscomponentsnewest firstdevicesserviceslog files
connectionscomponentsnewest firstdevicesserviceslog files

Then each presenter and view, newest first, through its shutdown method.

connectionscomponentsnewest firstdevicesserviceslog files
connectionscomponentsnewest firstdevicesserviceslog files

Then the devices, which the presenters and views may still have used in their own shutdown.

connectionscomponentsnewest firstdevicesserviceslog files
connectionscomponentsnewest firstdevicesserviceslog files

Then the launched services stop, now that no device needs them.

connectionscomponentsnewest firstdevicesserviceslog files
connectionscomponentsnewest firstdevicesserviceslog files

The log files close last, so they record how everything ended.

connectionscomponentsnewest firstdevicesserviceslog files
connectionscomponentsnewest firstdevicesserviceslog files

The connections go first, so no signal calls a slot of a component that is shutting down. A build that fails halfway runs the same releases, so it never leaves a service running. You can call shutdown twice, and build the session again afterwards.

The configuration

You can describe a session in one of two ways: declare its components in a Python class, or list them all in a session file. Either way, the settings come from the files and mappings the session reads.

The components and their classes live in your code, where your editor and mypy can check them. Files are optional. When the class lists some in config, they supply the session name and the arguments of the components the class declares, each under the component's name:

class MyApp(QtSession):
    config = "session.yaml"

    motor_ctrl: AsPresenter[MotorPresenter]
session: my-lab

presenters:
  motor_ctrl:
    step: 2.0

A file can also add a component the class doesn't declare, by naming the plugin that provides it, as Components from a file and from a class shows.

Every component comes from a plugin, and you need no class of your own:

from redsun import Session

app = Session.from_config("session.yaml").build()
session: my-lab
frontend: qt

devices:
  stage:
    plugin_name: mylab
    plugin_id: stage

The frontend key picks the class the session is built on, so a file naming qt comes up as a QtSession. With no class name to fall back on, the files must set session. Run a session without a GUI shows a file that names no frontend.

Merging the sources

A session reads its sources in order and merges them: a later source wins, and nested sections merge key by key, except for a component's entry, which a later source replaces whole:

Two files merged

common.yaml, the first source, gives motor_ctrl a step and a speed.

# common.yaml session: my-lab presenters:   motor_ctrl:     step: 2.0     speed: 1.0# common.yaml session: my-lab presenters:   motor_ctrl:     step: 2.0     speed: 1.0
# common.yaml session: my-lab presenters:   motor_ctrl:     step: 2.0     speed: 1.0# common.yaml session: my-lab presenters:   motor_ctrl:     step: 2.0     speed: 1.0

simulation.yaml is read after it and names motor_ctrl again.

# common.yaml session: my-lab presenters:   motor_ctrl:     step: 2.0     speed: 1.0# common.yaml session: my-lab presenters:   motor_ctrl:     step: 2.0     speed: 1.0# simulation.yaml presenters:   motor_ctrl:     step: 0.5# simulation.yaml presenters:   motor_ctrl:     step: 0.5 then
# common.yaml session: my-lab presenters:   motor_ctrl:     step: 2.0     speed: 1.0# common.yaml session: my-lab presenters:   motor_ctrl:     step: 2.0     speed: 1.0# simulation.yaml presenters:   motor_ctrl:     step: 0.5# simulation.yaml presenters:   motor_ctrl:     step: 0.5 then

A component's entry is replaced whole, so motor_ctrl keeps the later step and loses speed.

# common.yaml session: my-lab presenters:   motor_ctrl:     step: 2.0     speed: 1.0# common.yaml session: my-lab presenters:   motor_ctrl:     step: 2.0     speed: 1.0# simulation.yaml presenters:   motor_ctrl:     step: 0.5# simulation.yaml presenters:   motor_ctrl:     step: 0.5# merged session: my-lab presenters:   motor_ctrl:     step: 0.5# merged session: my-lab presenters:   motor_ctrl:     step: 0.5 thenmotor_ctrl replacedwhole: speed is gone
# common.yaml session: my-lab presenters:   motor_ctrl:     step: 2.0     speed: 1.0# common.yaml session: my-lab presenters:   motor_ctrl:     step: 2.0     speed: 1.0# simulation.yaml presenters:   motor_ctrl:     step: 0.5# simulation.yaml presenters:   motor_ctrl:     step: 0.5# merged session: my-lab presenters:   motor_ctrl:     step: 0.5# merged session: my-lab presenters:   motor_ctrl:     step: 0.5 thenmotor_ctrl replacedwhole: speed is gone

The merged result is checked once, before anything is built.

# common.yaml session: my-lab presenters:   motor_ctrl:     step: 2.0     speed: 1.0# common.yaml session: my-lab presenters:   motor_ctrl:     step: 2.0     speed: 1.0# simulation.yaml presenters:   motor_ctrl:     step: 0.5# simulation.yaml presenters:   motor_ctrl:     step: 0.5checked beforeanything is built# merged session: my-lab presenters:   motor_ctrl:     step: 0.5# merged session: my-lab presenters:   motor_ctrl:     step: 0.5 thenmotor_ctrl replacedwhole: speed is gone
# common.yaml session: my-lab presenters:   motor_ctrl:     step: 2.0     speed: 1.0# common.yaml session: my-lab presenters:   motor_ctrl:     step: 2.0     speed: 1.0# simulation.yaml presenters:   motor_ctrl:     step: 0.5# simulation.yaml presenters:   motor_ctrl:     step: 0.5checked beforeanything is built# merged session: my-lab presenters:   motor_ctrl:     step: 0.5# merged session: my-lab presenters:   motor_ctrl:     step: 0.5 thenmotor_ctrl replacedwhole: speed is gone

schema_version, frontend and services.transport say what kind of session this is, so every source must agree on them; a different value is an error. A subclass adds its sources after its base class's, so you write shared settings once:

class Instrument(QtSession):
    config = "common.yaml"


class Simulation(Instrument):
    config = "simulation.yaml"  # read after common.yaml

Every mistake in the configuration at once

The check runs on the merged result. A misspelled key or a value of the wrong type raises ConfigurationError, which lists every problem as section.key: what. Session file lists every key.

Session name

You set the name with the session key; without it, the session is named after its class. The name reaches three places:

Where the session name is used
session namefolder for data,catalog and logsapplication thatmenus and commandsare registered onfile with one user'ssaved settings
session namefolder for data,catalog and logsapplication thatmenus and commandsare registered onfile with one user'ssaved settings

Log files

Each run writes its log to a file under logs/<session> in the root folder the session's data goes under, and each launched service writes to a file of its own beside it. The files follow the root if it moves while the session runs, and close last at shutdown. Configure logging shows the layout.

Session protocols

A session is written against two protocols, and the classes redsun ships fill them in:

Session protocols and classes
BuildableSession+one method per build stepDesktopSession+window+run()build, show the window, start the event loopSession+start_runtime()set the backend of coroutine slots+present()does nothingQtSession+start_runtime()also make the QApplication+present()show the main window and views implementsextendsextendsimplements
BuildableSession+one method per build stepDesktopSession+window+run()build, show the window, start the event loopSession+start_runtime()set the backend of coroutine slots+present()does nothingQtSession+start_runtime()also make the QApplication+present()show the main window and views implementsextendsextendsimplements

A session with no frontend shows nothing, which is what a test wants: build() alone gives you every component without opening a window.