Important

You can run this notebook in a live session Binder or view it on nbviewer or GitHub.

Anatomy of a Device

In this notebook you will:

  • Understand the various methods of an ophyd Signal

  • Learn how to group Signals into Devices.

  • Learn how to specific “pseudopositions” that expose real axes (corresponding to physical hardware) and pseudoaxes, that move real axes via some mathematical transformation.

Recommended Prerequisites:

Configuration

Below, we will connect to EPICS IOC(s) controlling simulated hardware in lieu of actual motors, detectors. The IOCs should already be running in the background. Run this command to verify that they are running: it should produce output with RUNNING on each line. In the event of a problem, edit this command to replace status with restart all and run again.

[1]:
!supervisorctl -c supervisor/supervisord.conf status
decay                            RUNNING   pid 4977, uptime 0:00:18
mini_beamline                    RUNNING   pid 4978, uptime 0:00:18
random_walk                      RUNNING   pid 4979, uptime 0:00:18
random_walk_horiz                RUNNING   pid 4980, uptime 0:00:18
random_walk_vert                 RUNNING   pid 4981, uptime 0:00:18
simple                           RUNNING   pid 4982, uptime 0:00:18
thermo_sim                       RUNNING   pid 4983, uptime 0:00:18
trigger_with_pc                  RUNNING   pid 4984, uptime 0:00:18
[2]:
%run scripts/beamline_configuration.py
/home/travis/virtualenv/python3.7.1/lib/python3.7/site-packages/pims/image_reader.py:26: RuntimeWarning: PIMS image_reader.py could not find scikit-image. Falling back to matplotlib's imread(), which uses floats instead of integers. This may break your scripts.
(To ignore this warning, include the line "warnings.simplefilter("ignore", RuntimeWarning)" in your script.)
  warnings.warn(RuntimeWarning(ski_preferred))
[3]:
import time
from ophyd import Device, Signal, Component as Cpt, DeviceStatus
from ophyd.sim import SynSignal, SynPeriodicSignal

Interface to Signal

[4]:
sig = Signal(name='sig', value=3)
sig
[4]:
Signal(name='sig', value=3, timestamp=1594844725.5787332)

Methods that require no communication with the IOC

[5]:
sig.name
[5]:
'sig'
[6]:
sig.parent is None
[6]:
True

Methods that ask the IOC to tell us something it already ‘knows’

[7]:
sig.connected
[7]:
True
[8]:
sig.limits
[8]:
(0, 0)
[9]:
sig.read()
[9]:
{'sig': {'value': 3, 'timestamp': 1594844725.5787332}}
[10]:
sig.describe()
[10]:
{'sig': {'source': 'SIM:sig', 'dtype': 'integer', 'shape': []}}

Monitoring (subscribing for updates asynchronously)

[11]:
def cb(value, old_value, **a_whole_bunch_of_junk):
    print(f'changed from {old_value} to {value}')

sig.subscribe(cb)
# The act of subscribing always generates one reading immediately...
[11]:
0

If this were an EpicsSignal instead of a Signal, cb would be called from a thread every time pyepics receives a new update about the value of sig. In this case, we have to update it manually.

[12]:
sig.put(5)
changed from 3 to 5
[13]:
sig.put(10)
changed from 5 to 10

Or we can connect to the random_walk IOC which publishes a new updates at a regular interval.

[14]:
from ophyd import EpicsSignal

rand = EpicsSignal('random_walk:x', name='rand')
token = rand.subscribe(cb)
[15]:
rand.unsubscribe(token)

Methods that ask the IOC to take a (potentially lengthy) action

[16]:
def cb():
    print("finished at t =", time.time())

status = sig.set(5)
status.add_callback(cb)
changed from 10 to 5
finished at t = 1594844725.6764612
/home/travis/virtualenv/python3.7.1/lib/python3.7/site-packages/ipykernel_launcher.py:5: DeprecationWarning: The signature of a Status callback is now expected to be cb(status). The signature cb() is supported, but support will be removed in a future release of ophyd.
  """
[17]:
status
[17]:
Status(obj=Signal(name='sig', value=5, timestamp=1594844725.6752853), done=True, success=True)
[18]:
status.done
[18]:
True
[19]:
status = sig.trigger()
status.add_callback(cb)
finished at t = 1594844725.701149
/home/travis/virtualenv/python3.7.1/lib/python3.7/site-packages/ipykernel_launcher.py:2: DeprecationWarning: The signature of a Status callback is now expected to be cb(status). The signature cb() is supported, but support will be removed in a future release of ophyd.

Interface of a Status object

[20]:
status = DeviceStatus(sig)
[21]:
status.done
[21]:
False
[22]:
status.success
[22]:
False
[23]:
def cb():
    print("BOOM")

status.add_callback(cb)
/home/travis/virtualenv/python3.7.1/lib/python3.7/site-packages/ipykernel_launcher.py:4: DeprecationWarning: The signature of a Status callback is now expected to be cb(status). The signature cb() is supported, but support will be removed in a future release of ophyd.
  after removing the cwd from sys.path.
[24]:
status.callbacks
[24]:
deque([<function ophyd.utils.adapt_old_callback_signature.<locals>.callback(status)>])
[25]:
status.device  # the Device or Signal that the Status pertains to
[25]:
Signal(name='sig', value=5, timestamp=1594844725.6752853)
[26]:
status._finished()
BOOM
[27]:
status.done
[27]:
True
[28]:
status.success
[28]:
True
[29]:
# Failure looks like this:
status = DeviceStatus(sig)
status.add_callback(cb)
status._finished(success=False)
status.success
/home/travis/virtualenv/python3.7.1/lib/python3.7/site-packages/ipykernel_launcher.py:3: DeprecationWarning: The signature of a Status callback is now expected to be cb(status). The signature cb() is supported, but support will be removed in a future release of ophyd.
  This is separate from the ipykernel package so we can avoid doing imports until
[29]:
False
DeviceStatus(device=sig, done=True, success=False) encountered an error during _handle_failure()
Traceback (most recent call last):
  File "/home/travis/virtualenv/python3.7.1/lib/python3.7/site-packages/ophyd/status.py", line 253, in _run_callbacks
    self._handle_failure()
  File "/home/travis/virtualenv/python3.7.1/lib/python3.7/site-packages/ophyd/status.py", line 608, in _handle_failure
    self.device.stop()
AttributeError: 'Signal' object has no attribute 'stop'
BOOM

We’ll see later how to actually use this in practice.

Interface to Device

[30]:
# This encodes the _structure_ of a kind of Device.
# Real examples include EpicsMotor, EpicsScaler or user-defined
# combinations of these, such as a platform that can move in X and Y.

class Platform(Device):
    x = Cpt(Signal, value=3)
    y = Cpt(Signal, value=4)

p1 = Platform(name='p1')
p2 = Platform(name='p2')

Names and relationships

[31]:
p1
[31]:
Platform(prefix='', name='p1', read_attrs=['x', 'y'], configuration_attrs=[])
[32]:
p1.component_names
[32]:
('x', 'y')
[33]:
p1.x
[33]:
Signal(name='p1_x', parent='p1', value=3, timestamp=1594844725.7910647)
[34]:
p1.y
[34]:
Signal(name='p1_y', parent='p1', value=4, timestamp=1594844725.7911282)
[35]:
p1.name
[35]:
'p1'
[36]:
p1.x.name
[36]:
'p1_x'
[37]:
p1.x.parent is p1
[37]:
True

Reading the parent combines the readings of its children

[38]:
p1.read()
[38]:
OrderedDict([('p1_x', {'value': 3, 'timestamp': 1594844725.7910647}),
             ('p1_y', {'value': 4, 'timestamp': 1594844725.7911282})])
[39]:
p1.x.read()
[39]:
{'p1_x': {'value': 3, 'timestamp': 1594844725.7910647}}

and describe works exactly the same way:

[40]:
p1.describe()
[40]:
OrderedDict([('p1_x', {'source': 'SIM:p1_x', 'dtype': 'integer', 'shape': []}),
             ('p1_y',
              {'source': 'SIM:p1_y', 'dtype': 'integer', 'shape': []})])
[41]:
p1.x.describe()
[41]:
{'p1_x': {'source': 'SIM:p1_x', 'dtype': 'integer', 'shape': []}}

Components are sorted into categories:

  • OMITTED – not read (exposed for debugging only)

  • NORMAL / read_attrs – things to read once per Event (i.e. row in the table)

  • CONFIG / configuration_attrs – things to read once per Event Descriptor (which usually means one per run)

  • things ommitted from data collection entirely, but available for debugging etc.

  • HINTED – subset of NORMAL flagged as interesting

[42]:
p1.read_attrs
[42]:
['x', 'y']
[43]:
p1.configuration_attrs
[43]:
[]
[44]:
# dumb example...

class Platform(Device):
    x = Cpt(Signal, value=3)
    y = Cpt(Signal, value=4)
    motion_compensation = Cpt(Signal, value=1, kind='CONFIG')  # a boolean

p1 = Platform(name='p1')
p2 = Platform(name='p2')
[45]:
p1.read_attrs
[45]:
['x', 'y']
[46]:
p1.configuration_attrs
[46]:
['motion_compensation']
[47]:
p1.read_configuration()
[47]:
OrderedDict([('p1_motion_compensation',
              {'value': 1, 'timestamp': 1594844725.8988354})])
[48]:
p1.describe_configuration()
[48]:
OrderedDict([('p1_motion_compensation',
              {'source': 'SIM:p1_motion_compensation',
               'dtype': 'integer',
               'shape': []})])

The data from configuration_attrs isn’t displayed by the built-in callbacks…

[49]:
RE(count([p1]))


Transient Scan ID: 1     Time: 2020-07-15 20:25:25
Persistent Unique Scan ID: '8274dcbc-395d-4537-aac9-2e0d819dd6ed'
New stream: 'primary'
+-----------+------------+
|   seq_num |       time |
+-----------+------------+
|         1 | 20:25:25.9 |
+-----------+------------+
generator count ['8274dcbc'] (scan num: 1)



[49]:
('8274dcbc-395d-4537-aac9-2e0d819dd6ed',)

… but the data is saved, and it can accessed conveniently like so:

[50]:
h = db[-1]
h.config_data('p1')
[50]:
{'primary': [{'p1_motion_compensation': 1}]}
[51]:
p1.summary()
data keys (* hints)
-------------------
 p1_x
 p1_y

read attrs
----------
x                    Signal              ('p1_x')
y                    Signal              ('p1_y')

config keys
-----------
p1_motion_compensation

configuration attrs
-------------------
motion_compensation  Signal              ('p1_motion_compensation')

unused attrs
------------

Hints are meant to help downstream consumers of the data correctly infer user intent and automatically construct useful views on the data. They are only a suggestion. They do not affect what is saved.

[52]:
# dumb example...

class Platform(Device):
    x = Cpt(Signal, value=3, kind='hinted')
    y = Cpt(Signal, value=4, kind='hinted')
    motion_compensation = Cpt(Signal, value=1, kind='config')  # a boolean

p1 = Platform(name='p1')
p1.hints
[52]:
{'fields': ['p1_x', 'p1_y']}
[53]:
p1.summary()
data keys (* hints)
-------------------
*p1_x
*p1_y

read attrs
----------
x                    Signal              ('p1_x')
y                    Signal              ('p1_y')

config keys
-----------
p1_motion_compensation

configuration attrs
-------------------
motion_compensation  Signal              ('p1_motion_compensation')

unused attrs
------------

‘Staging’ – a hook for putting a device into a controlled state for data collection (and then putting it back)

[54]:
class Platform(Device):
    _default_configuration_attrs = ('motion_compensation',)
    _default_read_attrs = ('x', 'y')
    x = Cpt(Signal, value=3)
    y = Cpt(Signal, value=4)
    motion_compensation = Cpt(Signal, value=1)  # a boolean

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.stage_sigs['motion_compensation'] = 1


p1 = Platform(name='p1')
[55]:
p1.motion_compensation.get()
[55]:
1
[56]:
p1.motion_compensation.put(0)

Device.stage() stashes the current state of the signals in stage_sigs and then puts the device into the desired state.

[57]:
p1.stage()
[57]:
[Platform(prefix='', name='p1', read_attrs=['x', 'y'], configuration_attrs=['motion_compensation'])]
[58]:
p1.motion_compensation.get()
[58]:
1

Device.unstage() uses that stashed stage to put everything back.

[59]:
p1.unstage()
[59]:
[Platform(prefix='', name='p1', read_attrs=['x', 'y'], configuration_attrs=['motion_compensation'])]
[60]:
p1.motion_compensation.get()
[60]:
0

Staging twice is illegal:

[61]:
p1.stage()
[61]:
[Platform(prefix='', name='p1', read_attrs=['x', 'y'], configuration_attrs=['motion_compensation'])]
[62]:
# THIS IS EXPECTED TO CREATE AN ERROR.

p1.stage()
---------------------------------------------------------------------------
RedundantStaging                          Traceback (most recent call last)
<ipython-input-62-acdfc37e2a79> in <module>
      1 # THIS IS EXPECTED TO CREATE AN ERROR.
      2
----> 3 p1.stage()

~/virtualenv/python3.7.1/lib/python3.7/site-packages/ophyd/device.py in stage(self)
    516         elif self._staged == Staged.yes:
    517             raise RedundantStaging("Device {!r} is already staged. "
--> 518                                    "Unstage it first.".format(self))
    519         elif self._staged == Staged.partially:
    520             raise RedundantStaging("Device {!r} has been partially staged. "

RedundantStaging: Device Platform(prefix='', name='p1', read_attrs=['x', 'y'], configuration_attrs=['motion_compensation']) is already staged. Unstage it first.

But unstaging is indempotent:

[63]:
p1.unstage()
p1.unstage()
p1.unstage()
[63]:
[Platform(prefix='', name='p1', read_attrs=['x', 'y'], configuration_attrs=['motion_compensation'])]

Pseudopositioners

[64]:
from ophyd import (PseudoPositioner, PseudoSingle)
from ophyd.pseudopos import (pseudo_position_argument,
                             real_position_argument)
from ophyd import SoftPositioner
C = Cpt

class SPseudo3x3(PseudoPositioner):
    pseudo1 = C(PseudoSingle, limits=(-10, 10), egu='a')
    pseudo2 = C(PseudoSingle, limits=(-10, 10), egu='b')
    pseudo3 = C(PseudoSingle, limits=None, egu='c')

    real1 = C(SoftPositioner, init_pos=0.)
    real2 = C(SoftPositioner, init_pos=0.)
    real3 = C(SoftPositioner, init_pos=0.)

    sig = C(Signal, value=0)

    @pseudo_position_argument
    def forward(self, pseudo_pos):
        # logger.debug('forward %s', pseudo_pos)
        return self.RealPosition(real1=-pseudo_pos.pseudo1,
                                    real2=-pseudo_pos.pseudo2,
                                    real3=-pseudo_pos.pseudo3)

    @real_position_argument
    def inverse(self, real_pos):
        # logger.debug('inverse %s', real_pos)
        return self.PseudoPosition(pseudo1=-real_pos.real1,
                                    pseudo2=-real_pos.real2,
                                    pseudo3=-real_pos.real3)


p3 = SPseudo3x3(name='p3')
[65]:
from ophyd.sim import det

RE(scan([det, p3], p3.pseudo2, -1, 1, 5))


Transient Scan ID: 2     Time: 2020-07-15 20:25:26
Persistent Unique Scan ID: '10e05f76-7ed5-4b26-84d3-466ae8af69e1'
New stream: 'primary'
+-----------+------------+------------+------------+------------+------------+
|   seq_num |       time | p3_pseudo2 | p3_pseudo1 | p3_pseudo3 |        det |
+-----------+------------+------------+------------+------------+------------+
|         1 | 20:25:26.8 |     -1.000 |     -0.000 |     -0.000 |      1.000 |
|         2 | 20:25:26.8 |     -0.500 |     -0.000 |     -0.000 |      1.000 |
|         3 | 20:25:26.9 |      0.000 |     -0.000 |     -0.000 |      1.000 |
|         4 | 20:25:27.0 |      0.500 |     -0.000 |     -0.000 |      1.000 |
|         5 | 20:25:27.0 |      1.000 |     -0.000 |     -0.000 |      1.000 |
+-----------+------------+------------+------------+------------+------------+
generator scan ['10e05f76'] (scan num: 2)



/home/travis/virtualenv/python3.7.1/lib/python3.7/site-packages/bluesky/callbacks/fitting.py:166: RuntimeWarning: invalid value encountered in double_scalars
  for dir in range(input.ndim)]
[65]:
('10e05f76-7ed5-4b26-84d3-466ae8af69e1',)
[ ]: