diff --git a/instruments/abstract_instruments/instrument.py b/instruments/abstract_instruments/instrument.py index 2f19cc7bf..c97f4ca7f 100644 --- a/instruments/abstract_instruments/instrument.py +++ b/instruments/abstract_instruments/instrument.py @@ -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): @@ -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 @@ -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.") diff --git a/instruments/config.py b/instruments/config.py index 74d09499b..57afa9be3 100644 --- a/instruments/config.py +++ b/instruments/config.py @@ -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 ################################################################### @@ -37,8 +43,9 @@ 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:]) @@ -46,6 +53,19 @@ def walk_dict(d, path): # 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="/"): """ @@ -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 + 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: + 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:: @@ -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. @@ -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(): 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 diff --git a/instruments/tests/test_config.py b/instruments/tests/test_config.py new file mode 100644 index 000000000..eaec4d6ab --- /dev/null +++ b/instruments/tests/test_config.py @@ -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.") +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') diff --git a/instruments/tests/test_util_fns.py b/instruments/tests/test_util_fns.py index 914372404..ecf8e6ec5 100644 --- a/instruments/tests/test_util_fns.py +++ b/instruments/tests/test_util_fns.py @@ -16,7 +16,8 @@ from instruments.util_fns import ( ProxyList, - assume_units, convert_temperature + assume_units, convert_temperature, + setattr_expression ) # TEST CASES ################################################################# @@ -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' diff --git a/instruments/thorlabs/thorlabsapt.py b/instruments/thorlabs/thorlabsapt.py index d79ab01ad..e9ec0459e 100644 --- a/instruments/thorlabs/thorlabsapt.py +++ b/instruments/thorlabs/thorlabsapt.py @@ -12,6 +12,7 @@ import re import struct import logging +import warnings from builtins import range import quantities as pq @@ -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 @@ -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 @@ -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 diff --git a/instruments/util_fns.py b/instruments/util_fns.py index 316343fc9..977bfdcf3 100644 --- a/instruments/util_fns.py +++ b/instruments/util_fns.py @@ -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 @@ -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): """