From 1bf47ce7a93d7258a2165a97bed9fd9d8ad0ab4c Mon Sep 17 00:00:00 2001 From: Chris Granade Date: Wed, 12 Apr 2017 12:17:29 +1000 Subject: [PATCH 01/12] Added test:// URI schema. --- instruments/abstract_instruments/instrument.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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.") From 4eef89dc5a52d255bbe1b1f77f155cda041ee20f Mon Sep 17 00:00:00 2001 From: Chris Granade Date: Wed, 12 Apr 2017 12:17:53 +1000 Subject: [PATCH 02/12] Config: Py3 fix and support for setting attrs. --- instruments/config.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/instruments/config.py b/instruments/config.py index 74d09499b..f04f417cb 100644 --- a/instruments/config.py +++ b/instruments/config.py @@ -78,7 +78,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 +98,36 @@ 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, unicode)): + 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(): + # Allow "." in attribute names so that we can set attributes + # recursively. + target = inst_dict[name] + while '.' in attr_name: + head, attr_name = attr_name.split('.', 1) + target = getattr(target, head) + setattr(target, 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 From 6507fb3d63815460e6b6db5e0a90b7f807e0ef06 Mon Sep 17 00:00:00 2001 From: Chris Granade Date: Wed, 12 Apr 2017 12:50:42 +1000 Subject: [PATCH 03/12] Added sub/indexing to attrs config. --- instruments/config.py | 10 +++------- instruments/util_fns.py | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/instruments/config.py b/instruments/config.py index f04f417cb..733595def 100644 --- a/instruments/config.py +++ b/instruments/config.py @@ -16,6 +16,8 @@ except ImportError: yaml = None +from instruments.util_fns import setattr_expression + # FUNCTIONS ################################################################### @@ -114,13 +116,7 @@ def load_instruments(conf_file_name, conf_path="/"): 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(): - # Allow "." in attribute names so that we can set attributes - # recursively. - target = inst_dict[name] - while '.' in attr_name: - head, attr_name = attr_name.split('.', 1) - target = getattr(target, head) - setattr(target, attr_name, attr_value) + setattr_expression(inst_dict[name], attr_name, attr_value) except IOError as ex: # FIXME: need to subclass Warning so that repeated warnings 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): """ From e878a213b05d580ac7a76c3d4e9b5da35547e547 Mon Sep 17 00:00:00 2001 From: Chris Granade Date: Wed, 12 Apr 2017 12:57:19 +1000 Subject: [PATCH 04/12] ThorLabsAPT: Example of new config support. --- instruments/thorlabs/thorlabsapt.py | 34 ++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/instruments/thorlabs/thorlabsapt.py b/instruments/thorlabs/thorlabsapt.py index d79ab01ad..27995976e 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,35 @@ def set_scale(self, motor_model): logger.warning("Scale factors for controller %s and motor %s are " "unknown", self._apt.model_number, motor_model) + 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 From 8669ae762f42f6f36fa5095649755498975d540c Mon Sep 17 00:00:00 2001 From: Chris Granade Date: Wed, 12 Apr 2017 13:00:12 +1000 Subject: [PATCH 05/12] Added to docstring. --- instruments/config.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/instruments/config.py b/instruments/config.py index 733595def..e26601cab 100644 --- a/instruments/config.py +++ b/instruments/config.py @@ -65,6 +65,17 @@ 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 + By specifying a path within the configuration file, one can load only a part of the given file. For instance, consider the configuration:: From 1ce032082489661c10e8d8e1a467f787de010392 Mon Sep 17 00:00:00 2001 From: Chris Granade Date: Wed, 12 Apr 2017 13:15:56 +1000 Subject: [PATCH 06/12] Added support for physical quantities to config. --- instruments/config.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/instruments/config.py b/instruments/config.py index e26601cab..8f625e65d 100644 --- a/instruments/config.py +++ b/instruments/config.py @@ -16,6 +16,8 @@ except ImportError: yaml = None +import quantities as pq + from instruments.util_fns import setattr_expression # FUNCTIONS ################################################################### @@ -48,6 +50,11 @@ def walk_dict(d, path): # Otherwise, resolve that segment and recurse. return walk_dict(d[path[0]], path[1:]) +def quantity_constructor(loader, node): + # Follows the example of http://stackoverflow.com/a/43081967/267841. + value = loader.construct_scalar(node) + magnitude, units = value.split(" ", 1) + return pq.Quantity(magnitude, units) def load_instruments(conf_file_name, conf_path="/"): """ @@ -76,6 +83,16 @@ def load_instruments(conf_file_name, conf_path="/"): 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:: @@ -110,6 +127,8 @@ def load_instruments(conf_file_name, conf_path="/"): if yaml is None: raise ImportError("Could not import PyYAML, which is required " "for this function.") + # Add support for the physical quantity tag. + yaml.add_constructor(u'!Q', quantity_constructor) if isinstance(conf_file_name, (str, unicode)): with open(conf_file_name, 'r') as f: From b0c6809473b718f0ec0d2a9cc91ca81b0c297d68 Mon Sep 17 00:00:00 2001 From: Chris Granade Date: Wed, 12 Apr 2017 17:09:11 +1000 Subject: [PATCH 07/12] pylint fixes --- instruments/config.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/instruments/config.py b/instruments/config.py index 8f625e65d..f4de35cd9 100644 --- a/instruments/config.py +++ b/instruments/config.py @@ -18,6 +18,8 @@ import quantities as pq +from future.builtins import str + from instruments.util_fns import setattr_expression # FUNCTIONS ################################################################### @@ -51,6 +53,10 @@ def walk_dict(d, path): 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) magnitude, units = value.split(" ", 1) @@ -130,7 +136,7 @@ def load_instruments(conf_file_name, conf_path="/"): # Add support for the physical quantity tag. yaml.add_constructor(u'!Q', quantity_constructor) - if isinstance(conf_file_name, (str, unicode)): + if isinstance(conf_file_name, str): with open(conf_file_name, 'r') as f: conf_dict = yaml.load(f) else: From eaec515d350f0e01ff33103c860459331dae7775 Mon Sep 17 00:00:00 2001 From: Chris Granade Date: Wed, 12 Apr 2017 17:15:48 +1000 Subject: [PATCH 08/12] More pylint fixes --- instruments/thorlabs/thorlabsapt.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/instruments/thorlabs/thorlabsapt.py b/instruments/thorlabs/thorlabsapt.py index 27995976e..e9ec0459e 100644 --- a/instruments/thorlabs/thorlabsapt.py +++ b/instruments/thorlabs/thorlabsapt.py @@ -520,6 +520,9 @@ 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 " @@ -542,12 +545,12 @@ def motor_model(self): :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 # From c92cd6f0ceb1bcd060a9feeb4c2ec5323bcb5579 Mon Sep 17 00:00:00 2001 From: Chris Granade Date: Wed, 12 Apr 2017 17:23:40 +1000 Subject: [PATCH 09/12] Tests for new setattr_expression --- instruments/tests/test_util_fns.py | 46 +++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/instruments/tests/test_util_fns.py b/instruments/tests/test_util_fns.py index 914372404..7144f1bc7 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,46 @@ 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' + From fc853c294880d94d09a626032c43b222451f91e0 Mon Sep 17 00:00:00 2001 From: Chris Granade Date: Wed, 12 Apr 2017 17:41:36 +1000 Subject: [PATCH 10/12] Added tests for new config, YAML. --- instruments/config.py | 15 ++++--- instruments/tests/test_config.py | 75 ++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 instruments/tests/test_config.py diff --git a/instruments/config.py b/instruments/config.py index f4de35cd9..57afa9be3 100644 --- a/instruments/config.py +++ b/instruments/config.py @@ -20,7 +20,7 @@ from future.builtins import str -from instruments.util_fns import setattr_expression +from instruments.util_fns import setattr_expression, split_unit_str # FUNCTIONS ################################################################### @@ -43,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:]) @@ -59,8 +60,12 @@ def quantity_constructor(loader, node): """ # Follows the example of http://stackoverflow.com/a/43081967/267841. value = loader.construct_scalar(node) - magnitude, units = value.split(" ", 1) - return pq.Quantity(magnitude, units) + 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="/"): """ @@ -133,8 +138,6 @@ def load_instruments(conf_file_name, conf_path="/"): if yaml is None: raise ImportError("Could not import PyYAML, which is required " "for this function.") - # Add support for the physical quantity tag. - yaml.add_constructor(u'!Q', quantity_constructor) if isinstance(conf_file_name, str): with open(conf_file_name, 'r') as f: diff --git a/instruments/tests/test_config.py b/instruments/tests/test_config.py new file mode 100644 index 000000000..8c7c5ae64 --- /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 + +import quantities as pq + +from unittest import skipIf + +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') From b0f74833e7257b22596d2b1ba568754d818e8d3b Mon Sep 17 00:00:00 2001 From: Chris Granade Date: Wed, 12 Apr 2017 17:45:12 +1000 Subject: [PATCH 11/12] pylint fix for new tests --- instruments/tests/test_config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/instruments/tests/test_config.py b/instruments/tests/test_config.py index 8c7c5ae64..eaec4d6ab 100644 --- a/instruments/tests/test_config.py +++ b/instruments/tests/test_config.py @@ -10,10 +10,10 @@ from io import StringIO -import quantities as pq - from unittest import skipIf +import quantities as pq + try: import yaml except ImportError: From 10915e69048e1abe2ddb8543579555604f7e46c7 Mon Sep 17 00:00:00 2001 From: Chris Granade Date: Wed, 12 Apr 2017 18:33:02 +1000 Subject: [PATCH 12/12] Very minor pylint fix --- instruments/tests/test_util_fns.py | 1 - 1 file changed, 1 deletion(-) diff --git a/instruments/tests/test_util_fns.py b/instruments/tests/test_util_fns.py index 7144f1bc7..ecf8e6ec5 100644 --- a/instruments/tests/test_util_fns.py +++ b/instruments/tests/test_util_fns.py @@ -212,4 +212,3 @@ def __init__(self): a = A() setattr_expression(a, 'b[0].x', 'foo') assert a.b[0].x == 'foo' -