Skip to content
6 changes: 5 additions & 1 deletion instruments/abstract_instruments/instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,8 @@ def binblockread(self, data_width, fmt=None):
# CLASS METHODS #

URI_SCHEMES = ["serial", "tcpip", "gpib+usb",
"gpib+serial", "visa", "file", "usbtmc", "vxi11"]
"gpib+serial", "visa", "file", "usbtmc", "vxi11",
"test"]

@classmethod
def open_from_uri(cls, uri):
Expand All @@ -306,6 +307,7 @@ def open_from_uri(cls, uri):
gpib+serial:///dev/ttyACM0/15 # Currently non-functional.
visa://USB::0x0699::0x0401::C0000001::0::INSTR
usbtmc://USB::0x0699::0x0401::C0000001::0::INSTR
test://

For the ``serial`` URI scheme, baud rates may be explicitly specified
using the query parameter ``baud=``, as in the example
Expand Down Expand Up @@ -391,6 +393,8 @@ def open_from_uri(cls, uri):
# vxi11://192.168.1.104
# vxi11://TCPIP::192.168.1.105::gpib,5::INSTR
return cls.open_vxi11(parsed_uri.netloc, **kwargs)
elif parsed_uri.scheme == "test":
return cls.open_test(**kwargs)
else:
raise NotImplementedError("Invalid scheme or not yet "
"implemented.")
Expand Down
63 changes: 57 additions & 6 deletions instruments/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@
except ImportError:
yaml = None

import quantities as pq

from future.builtins import str

from instruments.util_fns import setattr_expression, split_unit_str

# FUNCTIONS ###################################################################


Expand All @@ -37,15 +43,29 @@ def walk_dict(d, path):
# Treat as a base case that the path is empty.
if not path:
return d
if isinstance(path, str):
if not isinstance(path, list):
path = path.split("/")

if not path[0]:
# If the first part of the path is empty, do nothing.
return walk_dict(d, path[1:])
else:
# Otherwise, resolve that segment and recurse.
return walk_dict(d[path[0]], path[1:])

def quantity_constructor(loader, node):
"""
Constructs a `pq.Quantity` instance from a PyYAML
node tagged as ``!Q``.
"""
# Follows the example of http://stackoverflow.com/a/43081967/267841.
value = loader.construct_scalar(node)
return pq.Quantity(*split_unit_str(value))

# We avoid having to register !Q every time by doing as soon as the
# relevant constructor is defined.
if yaml:
yaml.add_constructor(u'!Q', quantity_constructor)

def load_instruments(conf_file_name, conf_path="/"):
"""
Expand All @@ -63,6 +83,27 @@ def load_instruments(conf_file_name, conf_path="/"):
the form
``{'ddg': instruments.srs.SRSDG645.open_from_uri('gpib+usb://COM7/15')}``.

Optionally, each the value of each instrument key can also contain a dictionary

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

awkward sentence

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I'll improve that, then.

of attributes to set on the newly-created instrument. For instance, the following
dictionary creates a ThorLabs APT motor controller instrument with a single motor
model configured::

rot_stage:
class: !!python/name:instruments.thorabsapt.APTMotorController
uri: serial:///dev/ttyUSB0?baud=115200
attrs:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I LOVE IT

channel[0].motor_model: PRM1-Z8

Unitful attributes can be specified by using the ``!Q`` tag to quickly create
instances of `pq.Quantity`. In the example above, for instance, we can set a motion
timeout as a unitful quantity::

attrs:
motion_timeout: !Q 1 minute

When using the ``!Q`` tag, any text before a space is taken to be the magnitude
of the quantity, and text following is taken to be the unit specification.

By specifying a path within the configuration file, one can load only a part
of the given file. For instance, consider the configuration::

Expand All @@ -78,7 +119,7 @@ def load_instruments(conf_file_name, conf_path="/"):
all other keys in the YAML file.

:param str conf_file_name: Name of the configuration file to load
instruments from.
instruments from. Alternatively, a file-like object may be provided.
:param str conf_path: ``"/"`` separated path to the section in the
configuration file to load.

Expand All @@ -98,20 +139,30 @@ def load_instruments(conf_file_name, conf_path="/"):
raise ImportError("Could not import PyYAML, which is required "
"for this function.")

with open(conf_file_name, 'r') as f:
conf_dict = yaml.load(f)
if isinstance(conf_file_name, str):
with open(conf_file_name, 'r') as f:
conf_dict = yaml.load(f)
else:
conf_dict = yaml.load(conf_file_name)

conf_dict = walk_dict(conf_dict, conf_path)

inst_dict = {}
for name, value in conf_dict.iteritems():
for name, value in conf_dict.items():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch

try:
inst_dict[name] = value["class"].open_from_uri(value["uri"])

if 'attrs' in value:
# We have some attrs we can set on the newly created instrument.
for attr_name, attr_value in value['attrs'].items():
setattr_expression(inst_dict[name], attr_name, attr_value)

except IOError as ex:
# FIXME: need to subclass Warning so that repeated warnings
# aren't ignored.
warnings.warn("Exception occured loading device URI "
warnings.warn("Exception occured loading device with URI "
"{}:\n\t{}.".format(value["uri"], ex), RuntimeWarning)
inst_dict[name] = None


return inst_dict
75 changes: 75 additions & 0 deletions instruments/tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Module containing tests for util_fns.py
"""

# IMPORTS ####################################################################

from __future__ import absolute_import, unicode_literals

from io import StringIO

from unittest import skipIf

import quantities as pq

try:
import yaml
except ImportError:
yaml = None

from instruments import Instrument
from instruments.config import (
load_instruments
)

# TEST CASES #################################################################

# pylint: disable=protected-access,missing-docstring

@skipIf(yaml is None, "PyYAML is not installed.")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should be skipping tests if pyyaml is missing. I can see the situation where it fails to install in CI (for whatever reason) and simultaneously there is a bug that these tests would reveal. Build would be green, even though some tests technically fail.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point, I'll make sure YAML (whichever once we go with) is appropriately a dependency for the test environment, then. I think now that the conda command depends on rumel.yaml, it's not as heavy of a dependency as it used to be.

def test_load_test_instrument():
config_data = StringIO(u"""
test:
class: !!python/name:instruments.Instrument
uri: test://
""")
insts = load_instruments(config_data)
assert isinstance(insts['test'], Instrument)

@skipIf(yaml is None, "PyYAML is not installed.")
def test_load_test_instrument_subtree():
config_data = StringIO(u"""
instruments:
test:
class: !!python/name:instruments.Instrument
uri: test://
""")
insts = load_instruments(config_data, conf_path="/instruments")
assert isinstance(insts['test'], Instrument)

@skipIf(yaml is None, "PyYAML is not installed.")
def test_yaml_quantity_tag():
yaml_data = StringIO(u"""
a:
b: !Q 37 tesla
c: !Q 41.2 inches
d: !Q 98
""")
data = yaml.load(yaml_data)
assert data['a']['b'] == pq.Quantity(37, 'tesla')
assert data['a']['c'] == pq.Quantity(41.2, 'inches')
assert data['a']['d'] == 98

@skipIf(yaml is None, "PyYAML is not installed.")
def test_load_test_instrument_setattr():
config_data = StringIO(u"""
test:
class: !!python/name:instruments.Instrument
uri: test://
attrs:
foo: !Q 111 GHz
""")
insts = load_instruments(config_data)
assert insts['test'].foo == pq.Quantity(111, 'GHz')
45 changes: 44 additions & 1 deletion instruments/tests/test_util_fns.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@

from instruments.util_fns import (
ProxyList,
assume_units, convert_temperature
assume_units, convert_temperature,
setattr_expression
)

# TEST CASES #################################################################
Expand Down Expand Up @@ -169,3 +170,45 @@ def test_temperater_conversion_failure():
@raises(ValueError)
def test_assume_units_failures():
assume_units(1, 'm').rescale('s')

def test_setattr_expression_simple():
class A(object):
x = 'x'
y = 'y'
z = 'z'

a = A()
setattr_expression(a, 'x', 'foo')
assert a.x == 'foo'

def test_setattr_expression_index():
class A(object):
x = ['x', 'y', 'z']

a = A()
setattr_expression(a, 'x[1]', 'foo')
assert a.x[1] == 'foo'

def test_setattr_expression_nested():
class B(object):
x = 'x'
class A(object):
b = None
def __init__(self):
self.b = B()

a = A()
setattr_expression(a, 'b.x', 'foo')
assert a.b.x == 'foo'

def test_setattr_expression_both():
class B(object):
x = 'x'
class A(object):
b = None
def __init__(self):
self.b = [B()]

a = A()
setattr_expression(a, 'b[0].x', 'foo')
assert a.b[0].x == 'foo'
37 changes: 36 additions & 1 deletion instruments/thorlabs/thorlabsapt.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import re
import struct
import logging
import warnings

from builtins import range
import quantities as pq
Expand Down Expand Up @@ -443,6 +444,8 @@ class MotorChannel(ThorLabsAPT.APTChannel):

# INSTANCE VARIABLES #

_motor_model = None

#: Sets the scale between the encoder counts and physical units
#: for the position, velocity and acceleration parameters of this
#: channel. By default, set to dimensionless, indicating that the proper
Expand Down Expand Up @@ -496,7 +499,7 @@ class MotorChannel(ThorLabsAPT.APTChannel):

# UNIT CONVERSION METHODS #

def set_scale(self, motor_model):
def _set_scale(self, motor_model):
"""
Sets the scale factors for this motor channel, based on the model
of the attached motor and the specifications of the driver of which
Expand All @@ -517,6 +520,38 @@ def set_scale(self, motor_model):
logger.warning("Scale factors for controller %s and motor %s are "
"unknown", self._apt.model_number, motor_model)

# We copy the docstring below, so it's OK for this method
# to not have a docstring of its own.
# pylint: disable=missing-docstring
def set_scale(self, motor_model):
warnings.warn(
"The set_scale method has been deprecated in favor "
"of the motor_model property.",
DeprecationWarning
)
return self._set_scale(motor_model)

set_scale.__doc__ = _set_scale.__doc__

@property
def motor_model(self):
"""
Gets or sets the model name of the attached motor.
Note that the scale factors for this motor channel are based on the model
of the attached motor and the specifications of the driver of which
this is a channel, such that setting a new motor model will update
the scale factors accordingly.

:type: `str` or `None`
"""
return self._motor_model

@motor_model.setter
def motor_model(self, newval):
self._set_scale(newval)
self._motor_model = newval


# MOTOR COMMANDS #

@property
Expand Down
34 changes: 32 additions & 2 deletions instruments/util_fns.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@
from enum import Enum, IntEnum
import quantities as pq

# FUNCTIONS ###################################################################
# CONSTANTS ###################################################################

# pylint: disable=too-many-arguments
_IDX_REGEX = re.compile(r'([a-zA-Z_][a-zA-Z0-9_]*)\[(-?[0-9]*)\]')

# FUNCTIONS ###################################################################

# pylint: disable=too-many-arguments
def assume_units(value, units):
"""
If units are not provided for ``value`` (that is, if it is a raw
Expand All @@ -37,6 +39,34 @@ def assume_units(value, units):
value = pq.Quantity(value, units)
return value

def setattr_expression(target, name_expr, value):
"""
Recursively calls getattr/setattr for attribute
names that are miniature expressions with subscripting.
For instance, of the form ``a[0].b``.
"""
# Allow "." in attribute names so that we can set attributes
# recursively.
if '.' in name_expr:
# Recursion: We have to strip off a level of getattr.
head, name_expr = name_expr.split('.', 1)
match = _IDX_REGEX.match(head)
if match:
head_name, head_idx = match.groups()
target = getattr(target, head_name)[int(head_idx)]
else:
target = getattr(target, head)

setattr_expression(target, name_expr, value)
else:
# Base case: We're in the last part of a dot-expression.
match = _IDX_REGEX.match(name_expr)
if match:
name, idx = match.groups()
getattr(target, name)[int(idx)] = value
else:
setattr(target, name_expr, value)


def convert_temperature(temperature, base):
"""
Expand Down