33. Device trees: one index of every device¶
Date: 2026-10-24
Status¶
Accepted
Context¶
ophyd-async describes a setup as a tree. One root device holds the stages,
cameras and lights as its children, and their signals sit below them. Before
this record, most of redsun saw only the devices a session declared, so a
session declaring one MyMicroscope got no stage in its
positioner, no light in its light view and no
camera to offer its plans. DeviceMapping and
DevicesOf held the declared devices and nothing below
them. So the docs asked you to declare each piece a plan or a view needs as a
device of its own.
Reading the trees meant deciding which devices answer a question about devices, what names they go by, how a view shows a device that sits deep in a tree, and what a plan holds when it locks a device.
Decision¶
- The session keeps one index of every device. In the registry
build step, the
session lists each declared device and every device below it with
walk_devicesfromophyd-async, and keeps them all except signals and commands. A device signal is a device too, and so is a command, an action the device runs.DeviceMappingis that index, andDevicesOf[P]is the index filtered byP. Signals stay out because a writable signal hasset,locateandsubscribe, so with them in, a motor's velocity would answer a question about axes. Commands stay out for the same reason. The index is built after connecting, because some transports create children only while they connect. - Devices keep the names
ophyd-asyncgives them. The stagexyofmicroscopeismicroscope-xy, the name the run's documents also use. No code finds a device or its owner by splitting a name at-, since a name may hold a dash of its own and the separator can be changed. Two devices with one name, or a device with no name, stop the build with aBuildError. - Each device answers for itself.
DevicesOf[P]checks every device againstP, and a device's parent or children play no part in its answer. So a root and its children can all match. ADeviceMapis theophyd-asynccontainer that holds devices by key, and it matches only if it satisfiesPitself. A map of lights is not a light, soDevicesOf[Light]holds the lights and not the map. - The positioner groups axes by the device holding them. An axis is a row of the box of its direct parent, under the name the parent gives it. An axis with no parent is a box of its own, and an axis below another axis is left out, as before.
- A lock reaches down, never up. A plan that locks a device holds it and
every device below it, and no device above it. A plan moving the stage's
xdisables thexrow and leaves theyrow free. Presenters check a device withheld_by, and views apply the same rule to the paths the presenters send them. - A view shows a device by its root and path.
device_pathreturns aDevicePath, which reads asmicroscope / xy.x. The descriptions presenters send carry it in new fields that have defaults:AxisInfo.path,LightInfo.path,Configuration.pathsandParamDescription.labels. A view titles a box with the path and gives it the device's name as its tooltip. Session.deviceskeeps meaning the declared devices that built, since the build summary counts them.
Before¶
The same setup, declared as one root device:
class MyMicroscope(StandardReadable):
def __init__(self, name: str = "") -> None:
self.xy = MyStage()
self.camera = MyCamera()
super().__init__(name=name)
class MyApp(QtSession):
microscope: AsDevice[MyMicroscope]
A presenter asking for DeviceMapping received microscope alone, and the
positioner showed one box, microscope, holding every axis of the setup. To
see the stage and the camera as devices, you declared them one by one:
After¶
The session declaring microscope holds every device of the tree:
set(devices)
# {"microscope", "microscope-xy", "microscope-xy-x", "microscope-xy-y",
# "microscope-camera"}, and the camera's own devices
The positioner shows the box microscope / xy with the rows x and y,
and a plan whose parameter takes a camera is offered microscope / camera.
The session that declares stage and camera one by one keeps working, and
now also holds stage-x and stage-y.
Other options were rejected:
- Letting each component walk the trees itself. Every built-in and every plugin would repeat the walk and the name checks, and they could disagree on what a session holds.
- One positioner box per declared device. A root holding the whole setup would be one box with every axis in it.
- Locks reaching up as well as down. Moving one axis would lock the whole microscope in the window.
- Changing
Session.devicesto the index. It is public, and it means what the session declared. - New types for
Configuration.ownersandParamDescription.choicesin place of new fields. Presenters and views written for 0.15 would break.
Consequences¶
- A session declaring devices one by one sees more devices too: their
children are now in
DeviceMappingand among the choices of a plan parameter they match. No name disappears. - A device whose axes sit deeper than its own children moves them to another
box: a stage holding its axes in
stage.motorsshows them in the boxstage / motors, so positions saved understageno longer match. The migration page says to save them again; no converter is written. find_axesandwalk_axesare removed. They gathered every axis below one device for one box, which the positioner no longer does.- Children declared with annotations, in the
ophyd-asyncstyle, are created when anEpicsDeviceis made, or while it connects when it asks the IOC for its structure (with_pvi=True).redsunreads the tree after connecting, so it sees both. A device that adds children without naming them stops the build. - A root device takes the prefix of one service. ADR 34 lets it take one prefix per piece, from several services.
- A custom view written for 0.15 keeps working, and shows names where the new fields would give paths.