Coming to Bluesky from EPICS#
This page is for users who know EPICS Channel Access – caget,
caput, camonitor, MEDM, and possibly PyEpics or epics_get(FULL_PV) from SPEC – but
have not used Bluesky’s device abstraction (ophyd) before.
SPEC & PyEpics: PVs are addressed by string#
In SPEC, you read a PV by typing its full name:
SPEC> epics_get("3idxps1:m5.RBV")
You write with:
SPEC> epics_put("3idxps1:m5.VAL", 30)
PVs are accessed by string. Nothing wraps the PV; the string is the handle. If you mistype the name, SPEC complains at runtime.
PyEpics equivalents (import epics):
epics.caget("3idxps1:m5.RBV")epics.caput("3idxps1:m5.VAL", 30)
Bluesky: PVs are wrapped in ophyd Devices#
In Bluesky, you do not address PVs directly. Instead, a Python object called a Device wraps a set of related PVs, and you interact with the device:
sample_stage.omega.user_readback.get() # equivalent to caget("3idxps1:m5.RBV")
sample_stage.omega.user_setpoint.put(30) # equivalent to caput("3idxps1:m5.VAL", 30)
A EpicsMotor device, for instance, wraps not just .VAL and .RBV
but also .DMOV, .MOVN, .STOP, .HLM, .LLM, .EGU, and others
– about a dozen PVs. You access them as attributes:
sample_stage.omega.motor_done_move # the .DMOV PV
sample_stage.omega.motor_is_moving # the .MOVN PV
sample_stage.omega.user_offset # the .OFF PV
This is the trade. You give up the freedom of “any PV, any time” in exchange for:
A self-documenting object you can
tab-completein IPython.A connection object that knows how to wait for connection, fail cleanly on disconnect, and subscribe to monitors.
A
read()method that returns a structured dict of all “normal” signals at once (useful as a basic snapshot).Plug-and-play integration with the Bluesky RunEngine and the document stream.
The connection model#
caget opens a CA channel, reads once, closes the channel. Cheap
per-call, but the channel does not persist.
ophyd Devices open a CA channel per Signal at instantiation time and keep them open. This means:
A device is “connected” or “not connected”; check with
device.connected(bool) ordevice.wait_for_connection(timeout=5).Reads via
signal.get()are local cache reads (cheap), not CA round-trips, after the first monitor establishes the cache.Writes via
signal.put(value)go straight to CA.Subscriptions are first-class:
signal.subscribe(callback)returns a subscription id;signal.unsubscribe(cid)removes it.
Off-network reality: on a workstation that cannot reach the IOC’s
PVs, device.wait_for_connection() will time out; signal.get()
will raise DisconnectedError. This is the expected failure mode
when testing without EPICS.
signal.get() versus device.read()#
These are subtly different and both useful:
signal.get()returns the current value of one signal. It is what you reach for when you want one number.device.read()returns a dict of all signals on the device whosekindishintedornormal. Used internally by the RunEngine to record an event document.
Example on an EpicsMotor:
sample_stage.omega.user_readback.get()
# 30.0
sample_stage.omega.read()
# {'sample_stage_omega': {'value': 30.0, 'timestamp': ...},
# 'sample_stage_omega_user_setpoint': {'value': 30.0, 'timestamp': ...}}
read() is the right call when you want a snapshot suitable for
saving; get() is the right call when you want a number.
motor.position versus signal.get()#
Instrument control rests on two fundamentals: move positioners and read detectors. Everything else builds on those. This section is about the first half: how ophyd models a positioner, and why “where is this thing?” has two valid answers in Python.
Both motor.position and motor.user_readback.get() return a
single number representing “where this is right now,” and in normal
use they agree. The distinction is what kind of ophyd object each
is defined on:
motor.positionis a property defined on positioners – any device that inherits fromophyd.positioner.PositionerBase(EpicsMotor,SoftPositioner,PVPositioner,PseudoPositioner, and ourInterlockedEpicsMotorviaEpicsMotor). It returns Python’s best understanding of the positioner’s current position.signal.get()is the method on Signals – anything that inherits fromophyd.signal.Signal(EpicsSignal,EpicsSignalRO,Signal,AttributeSignal,DerivedSignal). It returns the current value of that one signal.
A motor is a Device containing Signals (and possibly other
Devices). Devices nest arbitrarily – sample_stage is a Device
of motor devices; laser_optics adds config Signals
(in_position, out_position, tolerance) and derived state on
top. The recursive structure is what makes paths like
sample_stage.xprime.user_readback meaningful: each . walks one level
down the tree.
A note on freshness for EPICS-backed objects#
EpicsSignal and EpicsSignalRO keep their cached value
up-to-date in the background via CA monitors. signal.get()
therefore returns the most recent monitor-event value without a CA
round-trip. Same for any positioner property whose update is wired
to those signals – EpicsMotor.position reflects the latest
readback monitor it has received.
A SoftPositioner might not have CA in the loop – it could be a
pure-Python simulator or a wrapper with greater complexity. Its
.position reflects whatever its local logic has set, whether that
comes from a simulation, a derived calculation, or values pulled
from elsewhere.
To force a fresh CA read (skip the cache), use
signal.get(use_monitor=False).
What positioners provide beyond signals#
Member |
What it is |
|---|---|
|
property – current position (positioner’s best understanding) |
|
property – bool “is it moving” indication |
|
start a move; returns a |
|
RE-friendly |
|
abort an in-progress move |
|
callback fires on each position update |
Positioner is an interface, not a hardware kind#
Every positioner – whether it wraps an EPICS motor record, a
temperature controller, a virtual axis, or a pure-Python simulator
– implements the same small interface. The methods are .move(),
.set(), .stop(). The Bluesky-relevant attributes are:
.position– current readback (the positioner’s best understanding).setpoint(or, forEpicsMotor,.user_setpoint) – the most recently commanded target.readback(or.user_readbackforEpicsMotor) – the signal whose value drives.position.moving– bool “is it moving” indication
Motion completion is reported through a MoveStatus object
returned by .set() / .move(wait=False).
What changes between positioner implementations is how the interface is implemented, not how a plan uses the positioner. Common base classes:
Class |
Underlying hardware |
Typical use |
|---|---|---|
|
An EPICS motor record ( |
Stepper / servo motors driven by motor records. The default. |
|
A setpoint PV + a readback PV + a “done” PV |
Things like a temperature controller: real EPICS, but no motor record. |
|
Same, but “done” is computed in Python via a tolerance |
Temperature controllers without a hardware “stable” PV. |
|
Local Python logic, optionally with EPICS in the wrapper |
Simulators, derived axes, custom controllers without a clean PV mapping. Our |
|
One or more real positioners |
Computed axes derived from real ones – e.g. an |
Because all of these expose the same positioner interface, a plan
written for EpicsMotor works without change on a PVPositioner.
Concrete and runnable today against the simulator:
RE(bp.scan([sim_det], sim_motor, -1, 1, 11))
The same plan against a hypothetical temperature controller (no code change, just a different object):
# When a temperature controller is added to this instrument, e.g. as
# a PVPositioner subclass named 'temperature' in devices.yml:
RE(bp.scan([scaler], temperature, 300, 400, 11))
This is a real temperature scan – recorded in the catalog the same way a motor scan would be. The plan doesn’t know or care which kind of positioner it’s driving; it just uses the positioner interface.
What Signals provide#
Member |
What it is |
|---|---|
|
scalar value (cached for EPICS-backed signals) |
|
direct CA put (no RE involved) |
|
RE-friendly put; returns a |
|
one-entry dict |
|
callback on value updates |
|
identity, classification, connection state |
.read() is universal#
Every Device and every Signal has a .read() method:
signal.read()– one-entry dict.device.read()– merged dict of every contained Signal’s.read()whosekindishintedornormal, recursively walking nested Devices. This is what the RunEngine calls during a scan to populate the event document.
Addendum: the Bluesky plan-stub equivalent#
When a plan needs to record a value into the run’s event stream:
Plan stub |
Equivalent direct call |
What happens |
|---|---|---|
|
|
The value is added to the run’s event document under the signal’s storage-form name. |
|
|
Move; nothing returned at the plan level. |
|
|
Returns the |
The point: at the IPython prompt you call ophyd methods directly
to interact with hardware. Inside a plan you yield from the
corresponding stub so the RunEngine can orchestrate document
publication, pauses, suspenders, and cleanup.
Concise rule-of-thumb table#
You want… |
Use… |
|---|---|
“Where is this motor?” (interactive) |
|
One specific Signal’s value (interactive) |
|
A force-fresh CA read |
|
A snapshot dict for storage |
|
The same operations from inside a plan |
|
Dotted vs. underscored names: controls vs. storage#
You may have noticed something in the read() output earlier in
this page: the keys are spelled with underscores
(sample_stage_omega_user_setpoint), while in Python you address
the same signal with dots
(sample_stage.omega.user_setpoint). Both name the same
underlying signal. They belong to two different use cases:
Dotted form – for controls in Python.
sample_stageis a Python object;.omegais an attribute on it;.user_setpointis a Signal you can.get()/.put()/.subscribe(). Dots are how Python walks an object tree.Underscored form – for storage: keys in event documents, columns in xarray Datasets, dataset paths in HDF5, search terms in the Tiled catalog. None of those support nested attribute access; each recorded signal needs a single flat string that uniquely names it across the whole instrument.
The translation is mechanical: replace . with _, starting from
the device’s name attribute.
# Controls side -- you type dotted, you get a Signal object
sample_stage.omega.user_readback
# EpicsSignalRO(...)
# Storage side -- the same signal carries a flat string name
sample_stage.omega.user_readback.name
# 'sample_stage_omega_user_readback'
# That string is what shows up as a key in read() and as a column
# in catalog data
ds = cat[uid].primary.read()
ds["sample_stage_omega_user_readback"]
Why the two forms?#
The split was forced by what the storage layer could accept.
Historically Bluesky run documents were stored as JSON in MongoDB,
and MongoDB does not allow . in document field names (it
reserves the dot for sub-document path syntax). The convention of
flattening Python attribute paths to underscored strings dates from
that constraint. The underlying tools have evolved – the Tiled
server we use today does not have the MongoDB restriction – but
the underscored convention persists, and changing it now would
break every existing analysis script that reads Bluesky data.
Practical rules#
You are… |
Use… |
Example |
|---|---|---|
commanding the hardware |
dotted |
|
reading a value in code |
dotted |
|
reading data back from a run |
underscored |
|
writing a plan that mentions a Signal |
dotted |
|
referencing a Signal by name in a callback |
underscored |
|
If unsure, ask the signal itself:
sample_stage.omega.user_readback.name
# 'sample_stage_omega_user_readback' <-- the storage name
The .name is usually derived mechanically from the Python path,
but it can be overridden when a Device is constructed
(Signal(..., name="custom")). When in doubt, trust .name.
The kind attribute#
Every ophyd Signal and Device has a kind:
hinted– included inread(), plotted by default.normal– included inread(), not plotted.config– included inread_configuration(), recorded once per scan as “this is how the instrument was set up.”omitted– not included anywhere by default; you have to ask for it specifically.
You will see all four in this repo. The interlock-related signals on
LaserOptics, for instance, use kind="config" for the tunable
positions and kind="omitted" for the derived status signals.
Status objects#
A Bluesky plan often does not block; it asks a Device to “set this
value” and gets back a Status object that completes when the
hardware finishes. This is what makes Bluesky’s pause/resume work:
the RunEngine can await a Status between message steps.
You will rarely create a Status yourself, but you will see them in
the output of direct ophyd calls:
status = sample_stage.xprime.set(5)
status # <MoveStatus name=... done=False>
status.wait() # block until the move finishes (or fails)
status.success # True if it finished cleanly
For interactive use, device.move(5) is the convenience wrapper
that calls set and waits.
How does this relate to RE(...) and yield from?#
RE (the RunEngine) does not care about the PVs. It cares about
the messages a plan yields: “set this signal,” “wait for this
status,” “read these devices,” “trigger this detector.” Each
message references a Device by Python object, not a PV name.
The PV-level conveniences (get, put, read) are still available
when you need them outside a plan. Most of the time, inside a plan,
you write yield from bps.mv(sample_stage.xprime, 5) – the stub knows
how to translate the device + value into the right sequence of
messages.
See The RunEngine for the full picture, and Plans and stubs for the message-vocabulary distinction.
Cheat table#
EPICS / CA |
ophyd |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
See also#
SPEC user perspective – if you came from SPEC, that page may be more directly useful.
How to add a device – the practical steps to bring a new EPICS device into a session.
ophyddocumentation – the authoritative reference for the device library.