From fad35d1c89e9f4a7b0dbb76872e24ee762057fb1 Mon Sep 17 00:00:00 2001 From: Steven Casagrande Date: Thu, 7 Feb 2019 17:31:39 -0500 Subject: [PATCH 1/8] Drop support for Python 3.4 (#200) --- .travis.yml | 1 - README.rst | 4 ++-- setup.py | 1 - tox.ini | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index b46392035..1d34c5b7e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,7 +3,6 @@ sudo: false language: python python: - "2.7" - - "3.4" - "3.5" - "3.6" - "3.7" diff --git a/README.rst b/README.rst index bd4e5d44c..d37d1674a 100644 --- a/README.rst +++ b/README.rst @@ -111,7 +111,7 @@ send, one can use the following functions to do so: Python Version Compatibility ---------------------------- -At this time, Python 2.7, 3.4, 3.5, 3.6, and 3.7 are supported. Should you encounter +At this time, Python 2.7, 3.5, 3.6, and 3.7 are supported. Should you encounter any problems with this library that occur in one version or another, please do not hesitate to let us know. @@ -134,7 +134,7 @@ To run the tests against all supported version of Python, you will need to have the binary for each installed, as well as any requirements needed to install ``numpy`` under each Python version. On Debian/Ubuntu systems this means you will need to install the ``python-dev`` package for each version of Python -supported (``python2.7-dev``, ``python3.4-dev``, etc). +supported (``python2.7-dev``, ``python3.7-dev``, etc). With the required system packages installed, all tests can be run with ``tox``: diff --git a/setup.py b/setup.py index 7e5e6b1c4..f7f6f75c0 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,6 @@ "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", diff --git a/tox.ini b/tox.ini index 508210fb1..d4a105448 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27,py34,py35,py36,py37 +envlist = py27,py35,py36,py37 [testenv] deps = -rdev-requirements.txt commands = pytest From c081174c1d227ff723ac047c8fcdb2a17e84b29f Mon Sep 17 00:00:00 2001 From: Steven Casagrande Date: Fri, 8 Feb 2019 16:20:41 -0500 Subject: [PATCH 2/8] Fix issue #197 (#201) Where checking if value is in enum will raise value error in Py38 --- instruments/hp/hp6632b.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/instruments/hp/hp6632b.py b/instruments/hp/hp6632b.py index b07407293..06e352558 100644 --- a/instruments/hp/hp6632b.py +++ b/instruments/hp/hp6632b.py @@ -468,6 +468,9 @@ def check_error_queue(self): done = True else: result.append( - self.ErrorCodes(err) if err in self.ErrorCodes else err) + self.ErrorCodes(err) + if any(err == item.value for item in self.ErrorCodes) + else err + ) return result From ca5fb15eac3e761c3829cab2f1945d67b8f15c1d Mon Sep 17 00:00:00 2001 From: Chris Granade Date: Sat, 9 Feb 2019 16:10:24 -0800 Subject: [PATCH 3/8] Improvements to APT (#167) * Added driver/motor tables for T/KDC001 APT devs. * Moved TODO comment to avoid pylint error. * Misc Py3k changes for ThorLabs APT * motion_timeout for APT motor cmds, fix scale factor * ThorLabsAPT: Example of new config support. * More pylint fixes * Fix for line continuation convention. * Rearranged imports into standard order. * Added an APT test. Not working yet. * Fix linting issues * New handling in loopback for empty terminator. * struct.Struct for contents of hw_info packets * Support for specifying expected apt pkt sizes * Fixes to APT and APT tests * Missed a conflict marker. * Fixed bug due to `if size` falling through on size == 0. * Removed trailing whitespace. * Locked requirements.txt; see #174. * Remove numpy version pinning in requirements.txt * Add tests to cover additional loopback comm behaviour * Make pylint happy * Revert changes to size=0 behaviour in loopback comm --- .../comm/loopback_communicator.py | 22 +++-- .../comm/serial_communicator.py | 16 +++- instruments/tests/test_comm/test_loopback.py | 19 ++++ .../tests/test_thorlabs/test_thorlabs_apt.py | 82 +++++++++++++++++ instruments/thorlabs/_abstract.py | 43 ++++++++- instruments/thorlabs/_packets.py | 14 ++- instruments/thorlabs/thorlabsapt.py | 92 ++++++++++++++++--- 7 files changed, 256 insertions(+), 32 deletions(-) create mode 100644 instruments/tests/test_thorlabs/test_thorlabs_apt.py diff --git a/instruments/abstract_instruments/comm/loopback_communicator.py b/instruments/abstract_instruments/comm/loopback_communicator.py index 85020ec3c..029c9d34f 100644 --- a/instruments/abstract_instruments/comm/loopback_communicator.py +++ b/instruments/abstract_instruments/comm/loopback_communicator.py @@ -108,17 +108,21 @@ def read_raw(self, size=-1): :rtype: `bytes` """ if self._stdin is not None: - if size >= 0: + if size == -1 or size is None: + result = bytes() + if self._terminator: + while result.endswith(self._terminator.encode("utf-8")) is False: + c = self._stdin.read(1) + if c == b'': + break + result += c + return result[:-len(self._terminator)] + return self._stdin.read(-1) + + elif size >= 0: input_var = self._stdin.read(size) return bytes(input_var) - elif size == -1: - result = bytes() - while result.endswith(self._terminator.encode("utf-8")) is False: - c = self._stdin.read(1) - if c == b'': - break - result += c - return result[:-len(self._terminator)] + else: raise ValueError("Must read a positive value of characters.") else: diff --git a/instruments/abstract_instruments/comm/serial_communicator.py b/instruments/abstract_instruments/comm/serial_communicator.py index 02b88f23c..40bc5b30d 100644 --- a/instruments/abstract_instruments/comm/serial_communicator.py +++ b/instruments/abstract_instruments/comm/serial_communicator.py @@ -118,13 +118,23 @@ def read_raw(self, size=-1): return resp elif size == -1: result = bytes() - while result.endswith(self._terminator.encode("utf-8")) is False: + # If the terminator is empty, we can't use endswith, but must + # read as many bytes as are available. + # On the other hand, if terminator is nonempty, we can check + # that the tail end of the buffer matches it. + c = None + term = self._terminator.encode('utf-8') if self._terminator else None + while not ( + result.endswith(term) + if term is not None else + c == b'' + ): c = self._conn.read(1) - if c == b'': + if c == b'' and term is not None: raise IOError("Serial connection timed out before reading " "a termination character.") result += c - return result[:-len(self._terminator)] + return result[:-len(term)] if term is not None else result else: raise ValueError("Must read a positive value of characters.") diff --git a/instruments/tests/test_comm/test_loopback.py b/instruments/tests/test_comm/test_loopback.py index c02c36024..943fd6a0e 100644 --- a/instruments/tests/test_comm/test_loopback.py +++ b/instruments/tests/test_comm/test_loopback.py @@ -99,6 +99,25 @@ def test_loopbackcomm_read_raw_2char_terminator(): assert mock_stdin.read.call_count == 5 +def test_loopbackcomm_read_raw_terminator_is_empty_string(): + mock_stdin = mock.MagicMock() + mock_stdin.read.side_effect = [b"abc"] + comm = LoopbackCommunicator(stdin=mock_stdin) + comm._terminator = "" + + assert comm.read_raw() == b"abc" + mock_stdin.read.assert_has_calls([mock.call(-1)]) + assert mock_stdin.read.call_count == 1 + + +def test_loopbackcomm_read_raw_size_invalid(): + with pytest.raises(ValueError): + mock_stdin = mock.MagicMock() + mock_stdin.read.side_effect = [b"abc"] + comm = LoopbackCommunicator(stdin=mock_stdin) + comm.read_raw(size=-2) + + def test_loopbackcomm_write_raw(): mock_stdout = mock.MagicMock() comm = LoopbackCommunicator(stdout=mock_stdout) diff --git a/instruments/tests/test_thorlabs/test_thorlabs_apt.py b/instruments/tests/test_thorlabs/test_thorlabs_apt.py new file mode 100644 index 000000000..286d585f0 --- /dev/null +++ b/instruments/tests/test_thorlabs/test_thorlabs_apt.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Module containing tests for the Thorlabs TC200 +""" + +# IMPORTS #################################################################### + +# pylint: disable=unused-import + +from __future__ import absolute_import + +import struct + +import pytest +import quantities as pq + +import instruments as ik +from instruments.thorlabs._packets import ThorLabsPacket, hw_info_data +from instruments.thorlabs._cmds import ThorLabsCommands +from instruments.tests import expected_protocol + +# TESTS ###################################################################### + +# pylint: disable=protected-access,unused-argument + + +def test_apt_hw_info(): + with expected_protocol( + ik.thorlabs.ThorLabsAPT, + [ + ThorLabsPacket( + message_id=ThorLabsCommands.HW_REQ_INFO, + param1=0x00, param2=0x00, + dest=0x50, + source=0x01, + data=None + ).pack() + ], + [ + ThorLabsPacket( + message_id=ThorLabsCommands.HW_GET_INFO, + dest=0x01, + source=0x50, + data=hw_info_data.pack( + # Serial number + b'\x01\x02\x03\x04', + # Model number + "ABC-123".encode('ascii'), + # HW type + 3, + # FW version, + 0xa1, 0xa2, 0xa3, + # Notes + "abcdefg".encode('ascii'), + # HW version + 42, + # Mod state + 43, + # Number of channels + 2 + ) + ).pack() + ], + sep="" + ) as apt: + # Check internal representations. + # NB: we shouldn't do this in some sense, but these fields + # act as an API to the APT subclasses. + assert apt._hw_type == "Unknown type: 3" + assert apt._fw_version == "a1.a2.a3" + assert apt._notes == "abcdefg" + assert apt._hw_version == 42 + assert apt._mod_state == 43 + + # Check external API. + assert apt.serial_number == '01020304' + assert apt.model_number == 'ABC-123' + assert apt.name == ( + "ThorLabs APT Instrument model ABC-123, " + "serial 01020304 (HW version 42, FW version a1.a2.a3)" + ) diff --git a/instruments/thorlabs/_abstract.py b/instruments/thorlabs/_abstract.py index 820950dac..423e1d465 100644 --- a/instruments/thorlabs/_abstract.py +++ b/instruments/thorlabs/_abstract.py @@ -9,8 +9,13 @@ from __future__ import absolute_import from __future__ import division +import time + from instruments.thorlabs import _packets from instruments.abstract_instruments.instrument import Instrument +from instruments.util_fns import assume_units + +from quantities import second # CLASSES ##################################################################### @@ -35,10 +40,10 @@ def sendpacket(self, packet): :param packet: The thorlabs data packet that will be queried :type packet: `ThorLabsPacket` """ - self.sendcmd(packet.pack()) + self._file.write_raw(packet.pack()) # pylint: disable=protected-access - def querypacket(self, packet, expect=None): + def querypacket(self, packet, expect=None, timeout=None, expect_data_len=None): """ Sends a packet to the connected APT instrument, and waits for a packet in response. Optionally, checks whether the received packet type is @@ -52,11 +57,40 @@ def querypacket(self, packet, expect=None): with the default value of `None` then no checking occurs. :type expect: `str` or `None` + :param timeout: Sets a timeout to wait before returning `None`, indicating + no packet was received. If the timeout is set to `None`, then the + timeout is inherited from the underlying communicator and no additional + timeout is added. If timeout is set to `False`, then this method waits + indefinitely. If timeout is set to a unitful quantity, then it is interpreted + as a time and used as the timeout value. Finally, if the timeout is a unitless + number (e.g. `float` or `int`), then seconds are assumed. + + :param int expect_data_len: Number of bytes to expect as the + data for the returned packet. + :return: Returns the response back from the instrument wrapped up in - a thorlabs packet + a ThorLabs APT packet, or None if no packet was received. :rtype: `ThorLabsPacket` """ - resp = self.query(packet.pack()) + t_start = time.time() + + if timeout: + timeout = assume_units(timeout, second).rescale('second').magnitude + + while True: + self._file.write_raw(packet.pack()) + resp = self._file.read_raw( + expect_data_len + 6 # the header is six bytes. + if expect_data_len else + 6 + ) + if resp or timeout is None: + break + else: + tic = time.time() + if tic - t_start > timeout: + break + if not resp: if expect is None: return None @@ -71,4 +105,5 @@ def querypacket(self, packet, expect=None): raise IOError("APT returned message ID {}, expected {}".format( pkt._message_id, expect )) + return pkt diff --git a/instruments/thorlabs/_packets.py b/instruments/thorlabs/_packets.py index 7a9ef8ded..5c4171970 100644 --- a/instruments/thorlabs/_packets.py +++ b/instruments/thorlabs/_packets.py @@ -15,6 +15,18 @@ message_header_nopacket = struct.Struct(' Date: Mon, 11 Feb 2019 16:08:24 -0500 Subject: [PATCH 4/8] Fix bug with SCPIFunctionGenerator.function, add tests (#202) * Fix bug with SCPIFunctionGenerator.function, add tests * Fix linting --- .../generic_scpi/scpi_function_generator.py | 4 +- .../test_scpi_function_generator.py | 80 +++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 instruments/tests/test_generic_scpi/test_scpi_function_generator.py diff --git a/instruments/generic_scpi/scpi_function_generator.py b/instruments/generic_scpi/scpi_function_generator.py index 4877168ae..122d29894 100644 --- a/instruments/generic_scpi/scpi_function_generator.py +++ b/instruments/generic_scpi/scpi_function_generator.py @@ -87,7 +87,7 @@ def _set_amplitude_(self, magnitude, units): function = enum_property( command="FUNC", - enum=lambda: Function, # pylint: disable=undefined-variable + enum=FunctionGenerator.Function, doc=""" Gets/sets the output function of the function generator @@ -103,7 +103,7 @@ def _set_amplitude_(self, magnitude, units): Set value should be within correct bounds of instrument. - :units: As specified (if a `~quntities.quantity.Quantity`) or assumed + :units: As specified (if a `~quantities.quantity.Quantity`) or assumed to be of units volts. :type: `~quantities.quantity.Quantity` with units volts. """ diff --git a/instruments/tests/test_generic_scpi/test_scpi_function_generator.py b/instruments/tests/test_generic_scpi/test_scpi_function_generator.py new file mode 100644 index 000000000..6920a3f4b --- /dev/null +++ b/instruments/tests/test_generic_scpi/test_scpi_function_generator.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Module containing tests for generic SCPI function generator instruments +""" + +# IMPORTS #################################################################### + +from __future__ import absolute_import + +import quantities as pq + +import instruments as ik +from instruments.tests import expected_protocol, make_name_test + +# TESTS ###################################################################### + +test_scpi_func_gen_name = make_name_test(ik.generic_scpi.SCPIFunctionGenerator) + + +def test_scpi_func_gen_amplitude(): + with expected_protocol( + ik.generic_scpi.SCPIFunctionGenerator, + [ + "VOLT:UNIT?", + "VOLT?", + "VOLT:UNIT VPP", + "VOLT 2.0", + "VOLT:UNIT DBM", + "VOLT 1.5" + ], [ + "VPP", + "+1.000000E+00" + ] + ) as fg: + assert fg.amplitude == (1 * pq.V, fg.VoltageMode.peak_to_peak) + fg.amplitude = 2 * pq.V + fg.amplitude = (1.5 * pq.V, fg.VoltageMode.dBm) + + +def test_scpi_func_gen_frequency(): + with expected_protocol( + ik.generic_scpi.SCPIFunctionGenerator, + [ + "FREQ?", + "FREQ 1.005000e+02" + ], [ + "+1.234000E+03" + ] + ) as fg: + assert fg.frequency == 1234 * pq.Hz + fg.frequency = 100.5 * pq.Hz + + +def test_scpi_func_gen_function(): + with expected_protocol( + ik.generic_scpi.SCPIFunctionGenerator, + [ + "FUNC?", + "FUNC SQU" + ], [ + "SIN" + ] + ) as fg: + assert fg.function == fg.Function.sinusoid + fg.function = fg.Function.square + + +def test_scpi_func_gen_offset(): + with expected_protocol( + ik.generic_scpi.SCPIFunctionGenerator, + [ + "VOLT:OFFS?", + "VOLT:OFFS 4.321000e-01" + ], [ + "+1.234000E+01", + ] + ) as fg: + assert fg.offset == 12.34 * pq.V + fg.offset = 0.4321 * pq.V From 13845c9d3d23cacdf7bc7c479431b67077254de6 Mon Sep 17 00:00:00 2001 From: Steven Casagrande Date: Mon, 11 Feb 2019 18:50:31 -0500 Subject: [PATCH 5/8] Fix Agilent 33220a, add tests (#203) --- instruments/agilent/agilent33220a.py | 14 +- .../tests/test_agilent/test_agilent_33220a.py | 169 ++++++++++++++++++ 2 files changed, 171 insertions(+), 12 deletions(-) create mode 100644 instruments/tests/test_agilent/test_agilent_33220a.py diff --git a/instruments/agilent/agilent33220a.py b/instruments/agilent/agilent33220a.py index 8afb868cf..b5282414d 100644 --- a/instruments/agilent/agilent33220a.py +++ b/instruments/agilent/agilent33220a.py @@ -78,14 +78,6 @@ class OutputPolarity(Enum): # PROPERTIES # - @property - def frequency(self): - return super(Agilent33220a, self).frequency - - @frequency.setter - def frequency(self, newval): - super(Agilent33220a, self).frequency = newval - function = enum_property( command="FUNC", enum=Function, @@ -182,13 +174,11 @@ def load_resistance(self): def load_resistance(self, newval): if isinstance(newval, self.LoadResistance): newval = newval.value - elif isinstance(newval, int): + else: + newval = assume_units(newval, pq.ohm).rescale(pq.ohm).magnitude if (newval < 0) or (newval > 10000): raise ValueError( "Load resistance must be between 0 and 10,000") - newval = assume_units(newval, pq.ohm).rescale(pq.ohm).magnitude - else: - raise TypeError("Not a valid load resistance type.") self.sendcmd("OUTP:LOAD {}".format(newval)) @property diff --git a/instruments/tests/test_agilent/test_agilent_33220a.py b/instruments/tests/test_agilent/test_agilent_33220a.py new file mode 100644 index 000000000..425e03a19 --- /dev/null +++ b/instruments/tests/test_agilent/test_agilent_33220a.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Module containing tests for generic SCPI function generator instruments +""" + +# IMPORTS #################################################################### + +from __future__ import absolute_import + +import quantities as pq + +import instruments as ik +from instruments.tests import expected_protocol, make_name_test + +# TESTS ###################################################################### + +test_scpi_func_gen_name = make_name_test(ik.agilent.Agilent33220a) + + +def test_agilent33220a_amplitude(): + with expected_protocol( + ik.agilent.Agilent33220a, + [ + "VOLT:UNIT?", + "VOLT?", + "VOLT:UNIT VPP", + "VOLT 2.0", + "VOLT:UNIT DBM", + "VOLT 1.5" + ], [ + "VPP", + "+1.000000E+00" + ] + ) as fg: + assert fg.amplitude == (1 * pq.V, fg.VoltageMode.peak_to_peak) + fg.amplitude = 2 * pq.V + fg.amplitude = (1.5 * pq.V, fg.VoltageMode.dBm) + + +def test_agilent33220a_frequency(): + with expected_protocol( + ik.agilent.Agilent33220a, + [ + "FREQ?", + "FREQ 1.005000e+02" + ], [ + "+1.234000E+03" + ] + ) as fg: + assert fg.frequency == 1234 * pq.Hz + fg.frequency = 100.5 * pq.Hz + + +def test_agilent33220a_function(): + with expected_protocol( + ik.agilent.Agilent33220a, + [ + "FUNC?", + "FUNC:SQU" + ], [ + "SIN" + ] + ) as fg: + assert fg.function == fg.Function.sinusoid + fg.function = fg.Function.square + + +def test_agilent33220a_offset(): + with expected_protocol( + ik.agilent.Agilent33220a, + [ + "VOLT:OFFS?", + "VOLT:OFFS 4.321000e-01" + ], [ + "+1.234000E+01", + ] + ) as fg: + assert fg.offset == 12.34 * pq.V + fg.offset = 0.4321 * pq.V + + +def test_agilent33220a_duty_cycle(): + with expected_protocol( + ik.agilent.Agilent33220a, + [ + "FUNC:SQU:DCYC?", + "FUNC:SQU:DCYC 75" + ], [ + "53", + ] + ) as fg: + assert fg.duty_cycle == 53 + fg.duty_cycle = 75 + + +def test_agilent33220a_ramp_symmetry(): + with expected_protocol( + ik.agilent.Agilent33220a, + [ + "FUNC:RAMP:SYMM?", + "FUNC:RAMP:SYMM 75" + ], [ + "53", + ] + ) as fg: + assert fg.ramp_symmetry == 53 + fg.ramp_symmetry = 75 + + +def test_agilent33220a_output(): + with expected_protocol( + ik.agilent.Agilent33220a, + [ + "OUTP?", + "OUTP OFF" + ], [ + "ON", + ] + ) as fg: + assert fg.output is True + fg.output = False + + +def test_agilent33220a_output_sync(): + with expected_protocol( + ik.agilent.Agilent33220a, + [ + "OUTP:SYNC?", + "OUTP:SYNC OFF" + ], [ + "ON", + ] + ) as fg: + assert fg.output_sync is True + fg.output_sync = False + + +def test_agilent33220a_output_polarity(): + with expected_protocol( + ik.agilent.Agilent33220a, + [ + "OUTP:POL?", + "OUTP:POL NORM" + ], [ + "INV", + ] + ) as fg: + assert fg.output_polarity == fg.OutputPolarity.inverted + fg.output_polarity = fg.OutputPolarity.normal + + +def test_agilent33220a_load_resistance(): + with expected_protocol( + ik.agilent.Agilent33220a, + [ + "OUTP:LOAD?", + "OUTP:LOAD?", + "OUTP:LOAD 100.0", + "OUTP:LOAD MAX" + ], [ + "50", + "INF" + ] + ) as fg: + assert fg.load_resistance == 50 * pq.Ohm + assert fg.load_resistance == fg.LoadResistance.high_impedance + fg.load_resistance = 100 * pq.Ohm + fg.load_resistance = fg.LoadResistance.maximum From 7ccdc131091a0f19bacb9cc2ee52c2f13c0443ff Mon Sep 17 00:00:00 2001 From: Steven Casagrande Date: Thu, 14 Feb 2019 15:24:54 -0500 Subject: [PATCH 6/8] Function Generator single/multi-channel consistency (#206) * Function Generator single/multi-channel consistency * Fix Py27 linting issue * Fix linting issue * Add tests for FunctionGenerator abstract instrument --- dev-requirements.txt | 1 + .../function_generator.py | 291 +++++++++++++----- instruments/tests/__init__.py | 17 +- .../test_function_generator.py | 171 ++++++++++ .../test_scpi_function_generator.py | 25 +- 5 files changed, 425 insertions(+), 80 deletions(-) create mode 100644 instruments/tests/test_abstract_inst/test_function_generator.py diff --git a/dev-requirements.txt b/dev-requirements.txt index 4fe922cad..b4082436b 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,5 +1,6 @@ mock pytest +pytest-mock hypothesis pylint==1.7.1 astroid==1.5.3 diff --git a/instruments/abstract_instruments/function_generator.py b/instruments/abstract_instruments/function_generator.py index e9baa64be..107d6591f 100644 --- a/instruments/abstract_instruments/function_generator.py +++ b/instruments/abstract_instruments/function_generator.py @@ -12,13 +12,13 @@ import abc from enum import Enum +from builtins import range from future.utils import with_metaclass import quantities as pq - from instruments.abstract_instruments import Instrument import instruments.units as u -from instruments.util_fns import assume_units +from instruments.util_fns import assume_units, ProxyList # CLASSES ##################################################################### @@ -32,6 +32,175 @@ class FunctionGenerator(with_metaclass(abc.ABCMeta, Instrument)): provide a consistent interface to the user. """ + def __init__(self, filelike): + super(FunctionGenerator, self).__init__(filelike) + self._channel_count = 1 + + # pylint:disable=protected-access + class Channel(with_metaclass(abc.ABCMeta, object)): + """ + Abstract base class for physical channels on a function generator. + + All applicable concrete instruments should inherit from this ABC to + provide a consistent interface to the user. + + Function generators that only have a single channel do not need to + define their own concrete implementation of this class. Ones with + multiple channels need their own definition of this class, where + this class contains the concrete implementations of the below + abstract methods. Instruments with 1 channel have their concrete + implementations at the parent instrument level. + """ + def __init__(self, parent, name): + self._parent = parent + self._name = name + + # ABSTRACT PROPERTIES # + + @property + def frequency(self): + """ + Gets/sets the the output frequency of the function generator. This is + an abstract property. + + :type: `~quantities.Quantity` + """ + if self._parent._channel_count == 1: + return self._parent.frequency + else: + raise NotImplementedError() + + @frequency.setter + def frequency(self, newval): + if self._parent._channel_count == 1: + self._parent.frequency = newval + else: + raise NotImplementedError() + + @property + def function(self): + """ + Gets/sets the output function mode of the function generator. This is + an abstract property. + + :type: `~enum.Enum` + """ + if self._parent._channel_count == 1: + return self._parent.function + else: + raise NotImplementedError() + + @function.setter + def function(self, newval): + if self._parent._channel_count == 1: + self._parent.function = newval + else: + raise NotImplementedError() + + @property + def offset(self): + """ + Gets/sets the output offset voltage of the function generator. This is + an abstract property. + + :type: `~quantities.Quantity` + """ + if self._parent._channel_count == 1: + return self._parent.offset + else: + raise NotImplementedError() + + @offset.setter + def offset(self, newval): + if self._parent._channel_count == 1: + self._parent.offset = newval + else: + raise NotImplementedError() + + @property + def phase(self): + """ + Gets/sets the output phase of the function generator. This is an + abstract property. + + :type: `~quantities.Quantity` + """ + if self._parent._channel_count == 1: + return self._parent.phase + else: + raise NotImplementedError() + + @phase.setter + def phase(self, newval): + if self._parent._channel_count == 1: + self._parent.phase = newval + else: + raise NotImplementedError() + + def _get_amplitude_(self): + if self._parent._channel_count == 1: + return self._parent._get_amplitude_() + else: + raise NotImplementedError() + + def _set_amplitude_(self, magnitude, units): + if self._parent._channel_count == 1: + self._parent._set_amplitude_(magnitude=magnitude, units=units) + else: + raise NotImplementedError() + + @property + def amplitude(self): + """ + Gets/sets the output amplitude of the function generator. + + If set with units of :math:`\\text{dBm}`, then no voltage mode can + be passed. + + If set with units of :math:`\\text{V}` as a `~quantities.Quantity` or a + `float` without a voltage mode, then the voltage mode is assumed to be + peak-to-peak. + + :units: As specified, or assumed to be :math:`\\text{V}` if not + specified. + :type: Either a `tuple` of a `~quantities.Quantity` and a + `FunctionGenerator.VoltageMode`, or a `~quantities.Quantity` + if no voltage mode applies. + """ + mag, units = self._get_amplitude_() + + if units == self._parent.VoltageMode.dBm: + return pq.Quantity(mag, u.dBm) + + return pq.Quantity(mag, pq.V), units + + @amplitude.setter + def amplitude(self, newval): + # Try and rescale to dBm... if it succeeds, set the magnitude + # and units accordingly, otherwise handle as a voltage. + try: + newval_dbm = newval.rescale(u.dBm) + mag = float(newval_dbm.magnitude) + units = self._parent.VoltageMode.dBm + except (AttributeError, ValueError): + # OK, we have volts. Now, do we have a tuple? If not, assume Vpp. + if not isinstance(newval, tuple): + mag = newval + units = self._parent.VoltageMode.peak_to_peak + else: + mag, units = newval + + # Finally, convert the magnitude out to a float. + mag = float(assume_units(mag, pq.V).rescale(pq.V).magnitude) + + self._set_amplitude_(mag, units) + + def sendcmd(self, cmd): + self._parent.sendcmd(cmd) + + def query(self, cmd, size=-1): + return self._parent.query(cmd, size) + # ENUMS # class VoltageMode(Enum): @@ -53,20 +222,27 @@ class Function(Enum): noise = 'NOIS' arbitrary = 'ARB' - # ABSTRACT METHODS # + @property + def channel(self): + return ProxyList(self, self.Channel, range(self._channel_count)) + + # PASSTHROUGH PROPERTIES # + + @property + def amplitude(self): + return self.channel[0].amplitude + + @amplitude.setter + def amplitude(self, newval): + self.channel[0].amplitude = newval - @abc.abstractmethod def _get_amplitude_(self): - pass + raise NotImplementedError() - @abc.abstractmethod def _set_amplitude_(self, magnitude, units): - pass - - # ABSTRACT PROPERTIES # + raise NotImplementedError() @property - @abc.abstractmethod def frequency(self): """ Gets/sets the the output frequency of the function generator. This is @@ -74,15 +250,19 @@ def frequency(self): :type: `~quantities.Quantity` """ - pass + if self._channel_count > 1: + return self.channel[0].frequency + else: + raise NotImplementedError() @frequency.setter - @abc.abstractmethod def frequency(self, newval): - pass + if self._channel_count > 1: + self.channel[0].frequency = newval + else: + raise NotImplementedError() @property - @abc.abstractmethod def function(self): """ Gets/sets the output function mode of the function generator. This is @@ -90,15 +270,19 @@ def function(self): :type: `~enum.Enum` """ - pass + if self._channel_count > 1: + return self.channel[0].function + else: + raise NotImplementedError() @function.setter - @abc.abstractmethod def function(self, newval): - pass + if self._channel_count > 1: + self.channel[0].function = newval + else: + raise NotImplementedError() @property - @abc.abstractmethod def offset(self): """ Gets/sets the output offset voltage of the function generator. This is @@ -106,15 +290,19 @@ def offset(self): :type: `~quantities.Quantity` """ - pass + if self._channel_count > 1: + return self.channel[0].offset + else: + raise NotImplementedError() @offset.setter - @abc.abstractmethod def offset(self, newval): - pass + if self._channel_count > 1: + self.channel[0].offset = newval + else: + raise NotImplementedError() @property - @abc.abstractmethod def phase(self): """ Gets/sets the output phase of the function generator. This is an @@ -122,57 +310,14 @@ def phase(self): :type: `~quantities.Quantity` """ - pass + if self._channel_count > 1: + return self.channel[0].phase + else: + raise NotImplementedError() @phase.setter - @abc.abstractmethod def phase(self, newval): - pass - - # CONCRETE PROPERTIES # - - @property - def amplitude(self): - """ - Gets/sets the output amplitude of the function generator. - - If set with units of :math:`\\text{dBm}`, then no voltage mode can - be passed. - - If set with units of :math:`\\text{V}` as a `~quantities.Quantity` or a - `float` without a voltage mode, then the voltage mode is assumed to be - peak-to-peak. - - :units: As specified, or assumed to be :math:`\\text{V}` if not - specified. - :type: Either a `tuple` of a `~quantities.Quantity` and a - `FunctionGenerator.VoltageMode`, or a `~quantities.Quantity` - if no voltage mode applies. - """ - mag, units = self._get_amplitude_() - - if units == self.VoltageMode.dBm: - return pq.Quantity(mag, u.dBm) - - return pq.Quantity(mag, pq.V), units - - @amplitude.setter - def amplitude(self, newval): - # Try and rescale to dBm... if it succeeds, set the magnitude - # and units accordingly, otherwise handle as a voltage. - try: - newval_dbm = newval.rescale(u.dBm) - mag = float(newval_dbm.magnitude) - units = self.VoltageMode.dBm - except (AttributeError, ValueError): - # OK, we have volts. Now, do we have a tuple? If not, assume Vpp. - if not isinstance(newval, tuple): - mag = newval - units = self.VoltageMode.peak_to_peak - else: - mag, units = newval - - # Finally, convert the magnitude out to a float. - mag = float(assume_units(mag, pq.V).rescale(pq.V).magnitude) - - self._set_amplitude_(mag, units) + if self._channel_count > 1: + self.channel[0].phase = newval + else: + raise NotImplementedError() diff --git a/instruments/tests/__init__.py b/instruments/tests/__init__.py index ea901d886..b54a355ad 100644 --- a/instruments/tests/__init__.py +++ b/instruments/tests/__init__.py @@ -26,7 +26,7 @@ @contextlib.contextmanager -def expected_protocol(ins_class, host_to_ins, ins_to_host, sep="\n"): +def expected_protocol(ins_class, host_to_ins, ins_to_host, sep="\n", repeat=1): """ Given an instrument class, expected output from the host and expected input from the instrument, asserts that the protocol in a context block proceeds @@ -35,7 +35,8 @@ def expected_protocol(ins_class, host_to_ins, ins_to_host, sep="\n"): For an example of how to write tests using this context manager, see the ``make_name_test`` function below. - :param type ins_class: Instrument class to use for the protocol assertion. + :param ins_class: Instrument class to use for the protocol assertion. + :type ins_class: `~instruments.Instrument` :param host_to_ins: Data to be sent by the host to the instrument; this is checked against the actual data sent by the instrument class during the execution of this context manager. @@ -46,9 +47,17 @@ def expected_protocol(ins_class, host_to_ins, ins_to_host, sep="\n"): be used to assert correct behaviour within the context. :type ins_to_host: ``str`` or ``list``; if ``list``, each line is concatenated with the separator given by ``sep``. + :param str sep: Character to be inserted after each string in both + host_to_ins and ins_to_host parameters. This is typically the + termination character you would like to have inserted. + :param int repeat: The number of times the host_to_ins and + ins_to_host data sets should be duplicated. Typically the default + value of 1 is sufficient, but increasing this is useful when + testing multiple calls in the same test that should have the same + command transactions. """ if isinstance(sep, bytes): - sep = sep.encode("utf-8") + sep = sep.decode("utf-8") # Normalize assertion and playback strings. if isinstance(ins_to_host, list): @@ -60,6 +69,7 @@ def expected_protocol(ins_class, host_to_ins, ins_to_host, sep="\n"): (sep.encode("utf-8") if ins_to_host else b"") elif isinstance(ins_to_host, str): ins_to_host = ins_to_host.encode("utf-8") + ins_to_host *= repeat if isinstance(host_to_ins, list): host_to_ins = [ @@ -70,6 +80,7 @@ def expected_protocol(ins_class, host_to_ins, ins_to_host, sep="\n"): (sep.encode("utf-8") if host_to_ins else b"") elif isinstance(host_to_ins, str): host_to_ins = host_to_ins.encode("utf-8") + host_to_ins *= repeat stdin = BytesIO(ins_to_host) stdout = BytesIO() diff --git a/instruments/tests/test_abstract_inst/test_function_generator.py b/instruments/tests/test_abstract_inst/test_function_generator.py new file mode 100644 index 000000000..00198669b --- /dev/null +++ b/instruments/tests/test_abstract_inst/test_function_generator.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Module containing tests for the abstract function generator class +""" + +# IMPORTS #################################################################### + +from __future__ import absolute_import + +import pytest +import quantities as pq + +import instruments as ik + + +# TESTS ###################################################################### + +@pytest.fixture +def fg(): + return ik.abstract_instruments.FunctionGenerator.open_test() + + +def test_func_gen_default_channel_count(fg): + assert fg._channel_count == 1 + + +def test_func_gen_raises_not_implemented_error_one_channel_getting(fg): + fg._channel_count = 1 + with pytest.raises(NotImplementedError): + _ = fg.amplitude + with pytest.raises(NotImplementedError): + _ = fg.frequency + with pytest.raises(NotImplementedError): + _ = fg.function + with pytest.raises(NotImplementedError): + _ = fg.offset + with pytest.raises(NotImplementedError): + _ = fg.phase + + +def test_func_gen_raises_not_implemented_error_one_channel_setting(fg): + fg._channel_count = 1 + with pytest.raises(NotImplementedError): + fg.amplitude = 1 + with pytest.raises(NotImplementedError): + fg.frequency = 1 + with pytest.raises(NotImplementedError): + fg.function = 1 + with pytest.raises(NotImplementedError): + fg.offset = 1 + with pytest.raises(NotImplementedError): + fg.phase = 1 + + +def test_func_gen_raises_not_implemented_error_two_channel_getting(fg): + fg._channel_count = 2 + with pytest.raises(NotImplementedError): + _ = fg.channel[0].amplitude + with pytest.raises(NotImplementedError): + _ = fg.channel[0].frequency + with pytest.raises(NotImplementedError): + _ = fg.channel[0].function + with pytest.raises(NotImplementedError): + _ = fg.channel[0].offset + with pytest.raises(NotImplementedError): + _ = fg.channel[0].phase + + +def test_func_gen_raises_not_implemented_error_two_channel_setting(fg): + fg._channel_count = 2 + with pytest.raises(NotImplementedError): + fg.channel[0].amplitude = 1 + with pytest.raises(NotImplementedError): + fg.channel[0].frequency = 1 + with pytest.raises(NotImplementedError): + fg.channel[0].function = 1 + with pytest.raises(NotImplementedError): + fg.channel[0].offset = 1 + with pytest.raises(NotImplementedError): + fg.channel[0].phase = 1 + + +def test_func_gen_two_channel_passes_thru_call_getter(fg, mocker): + mock_channel = mocker.MagicMock() + mock_properties = [mocker.PropertyMock(return_value=1) for _ in range(5)] + + mocker.patch("instruments.abstract_instruments.FunctionGenerator.Channel", new=mock_channel) + type(mock_channel()).amplitude = mock_properties[0] + type(mock_channel()).frequency = mock_properties[1] + type(mock_channel()).function = mock_properties[2] + type(mock_channel()).offset = mock_properties[3] + type(mock_channel()).phase = mock_properties[4] + + fg._channel_count = 2 + _ = fg.amplitude + _ = fg.frequency + _ = fg.function + _ = fg.offset + _ = fg.phase + + for mock_property in mock_properties: + mock_property.assert_called_once_with() + + +def test_func_gen_one_channel_passes_thru_call_getter(fg, mocker): + mock_properties = [mocker.PropertyMock(return_value=1) for _ in range(4)] + mock_method = mocker.MagicMock(return_value=(1, pq.V)) + + mocker.patch("instruments.abstract_instruments.FunctionGenerator.frequency", new=mock_properties[0]) + mocker.patch("instruments.abstract_instruments.FunctionGenerator.function", new=mock_properties[1]) + mocker.patch("instruments.abstract_instruments.FunctionGenerator.offset", new=mock_properties[2]) + mocker.patch("instruments.abstract_instruments.FunctionGenerator.phase", new=mock_properties[3]) + mocker.patch("instruments.abstract_instruments.FunctionGenerator._get_amplitude_", new=mock_method) + + fg._channel_count = 1 + _ = fg.channel[0].amplitude + _ = fg.channel[0].frequency + _ = fg.channel[0].function + _ = fg.channel[0].offset + _ = fg.channel[0].phase + + for mock_property in mock_properties: + mock_property.assert_called_once_with() + + mock_method.assert_called_once_with() + + +def test_func_gen_two_channel_passes_thru_call_setter(fg, mocker): + mock_channel = mocker.MagicMock() + mock_properties = [mocker.PropertyMock() for _ in range(5)] + + mocker.patch("instruments.abstract_instruments.FunctionGenerator.Channel", new=mock_channel) + type(mock_channel()).amplitude = mock_properties[0] + type(mock_channel()).frequency = mock_properties[1] + type(mock_channel()).function = mock_properties[2] + type(mock_channel()).offset = mock_properties[3] + type(mock_channel()).phase = mock_properties[4] + + fg._channel_count = 2 + fg.amplitude = 1 + fg.frequency = 1 + fg.function = 1 + fg.offset = 1 + fg.phase = 1 + + for mock_property in mock_properties: + mock_property.assert_called_once_with(1) + + +def test_func_gen_one_channel_passes_thru_call_setter(fg, mocker): + mock_properties = [mocker.PropertyMock() for _ in range(4)] + mock_method = mocker.MagicMock() + + mocker.patch("instruments.abstract_instruments.FunctionGenerator.frequency", new=mock_properties[0]) + mocker.patch("instruments.abstract_instruments.FunctionGenerator.function", new=mock_properties[1]) + mocker.patch("instruments.abstract_instruments.FunctionGenerator.offset", new=mock_properties[2]) + mocker.patch("instruments.abstract_instruments.FunctionGenerator.phase", new=mock_properties[3]) + mocker.patch("instruments.abstract_instruments.FunctionGenerator._set_amplitude_", new=mock_method) + + fg._channel_count = 1 + fg.channel[0].amplitude = 1 + fg.channel[0].frequency = 1 + fg.channel[0].function = 1 + fg.channel[0].offset = 1 + fg.channel[0].phase = 1 + + for mock_property in mock_properties: + mock_property.assert_called_once_with(1) + + mock_method.assert_called_once_with(magnitude=1, units=fg.VoltageMode.peak_to_peak) diff --git a/instruments/tests/test_generic_scpi/test_scpi_function_generator.py b/instruments/tests/test_generic_scpi/test_scpi_function_generator.py index 6920a3f4b..d32456728 100644 --- a/instruments/tests/test_generic_scpi/test_scpi_function_generator.py +++ b/instruments/tests/test_generic_scpi/test_scpi_function_generator.py @@ -31,12 +31,17 @@ def test_scpi_func_gen_amplitude(): ], [ "VPP", "+1.000000E+00" - ] + ], + repeat=2 ) as fg: assert fg.amplitude == (1 * pq.V, fg.VoltageMode.peak_to_peak) fg.amplitude = 2 * pq.V fg.amplitude = (1.5 * pq.V, fg.VoltageMode.dBm) + assert fg.channel[0].amplitude == (1 * pq.V, fg.VoltageMode.peak_to_peak) + fg.channel[0].amplitude = 2 * pq.V + fg.channel[0].amplitude = (1.5 * pq.V, fg.VoltageMode.dBm) + def test_scpi_func_gen_frequency(): with expected_protocol( @@ -46,11 +51,15 @@ def test_scpi_func_gen_frequency(): "FREQ 1.005000e+02" ], [ "+1.234000E+03" - ] + ], + repeat=2 ) as fg: assert fg.frequency == 1234 * pq.Hz fg.frequency = 100.5 * pq.Hz + assert fg.channel[0].frequency == 1234 * pq.Hz + fg.channel[0].frequency = 100.5 * pq.Hz + def test_scpi_func_gen_function(): with expected_protocol( @@ -60,11 +69,15 @@ def test_scpi_func_gen_function(): "FUNC SQU" ], [ "SIN" - ] + ], + repeat=2 ) as fg: assert fg.function == fg.Function.sinusoid fg.function = fg.Function.square + assert fg.channel[0].function == fg.Function.sinusoid + fg.channel[0].function = fg.Function.square + def test_scpi_func_gen_offset(): with expected_protocol( @@ -74,7 +87,11 @@ def test_scpi_func_gen_offset(): "VOLT:OFFS 4.321000e-01" ], [ "+1.234000E+01", - ] + ], + repeat=2 ) as fg: assert fg.offset == 12.34 * pq.V fg.offset = 0.4321 * pq.V + + assert fg.channel[0].offset == 12.34 * pq.V + fg.channel[0].offset = 0.4321 * pq.V From e05dad5149408362df9bac0ba47b2ab5d644e948 Mon Sep 17 00:00:00 2001 From: Catherine Holloway Date: Thu, 14 Feb 2019 18:58:03 -0500 Subject: [PATCH 7/8] Adding support for the minghe mhs5200 (#150) * added mhs5200 * added minghe function generator * added absolute_import * fixed scaling on frequency * switched to abstract instrument class * fixed a few docstrings * after testing with device * Minghe MHS5200 - Add instrument to docs * isolating changes from cc1 test station: * Revert "isolating changes from cc1 test station:" This reverts commit 87b8dec40d927460bb9cb0edf6d671c3535f27c2. * reverting changes and fixing duty cycle * Update for new FunctionGenerator multichannel consistency update --- doc/examples/minghe/ex_minghe_mhs5200.py | 28 +++ doc/source/apiref/minghe.rst | 15 ++ instruments/__init__.py | 1 + instruments/minghe/__init__.py | 7 + instruments/minghe/mhs5200a.py | 235 ++++++++++++++++++ .../tests/test_minghe/test_minghe_mhs5200a.py | 210 ++++++++++++++++ 6 files changed, 496 insertions(+) create mode 100644 doc/examples/minghe/ex_minghe_mhs5200.py create mode 100644 doc/source/apiref/minghe.rst create mode 100644 instruments/minghe/__init__.py create mode 100644 instruments/minghe/mhs5200a.py create mode 100644 instruments/tests/test_minghe/test_minghe_mhs5200a.py diff --git a/doc/examples/minghe/ex_minghe_mhs5200.py b/doc/examples/minghe/ex_minghe_mhs5200.py new file mode 100644 index 000000000..60b69bedb --- /dev/null +++ b/doc/examples/minghe/ex_minghe_mhs5200.py @@ -0,0 +1,28 @@ +#!/usr/bin/python +from instruments.minghe import MHS5200 +import quantities as pq + +mhs = MHS5200.open_serial(vid=6790, pid=29987, baud=57600) +print(mhs.serial_number) +mhs.channel[0].frequency = 3000000*pq.Hz +print(mhs.channel[0].frequency) +mhs.channel[0].function = MHS5200.Function.sawtooth_down +print(mhs.channel[0].function) +mhs.channel[0].amplitude = 9.0*pq.V +print(mhs.channel[0].amplitude) +mhs.channel[0].offset = -0.5 +print(mhs.channel[0].offset) +mhs.channel[0].phase = 90 +print(mhs.channel[0].phase) + +mhs.channel[1].frequency = 2000000*pq.Hz +print(mhs.channel[1].frequency) +mhs.channel[1].function = MHS5200.Function.square +print(mhs.channel[1].function) +mhs.channel[1].amplitude = 2.0*pq.V +print(mhs.channel[1].amplitude) +mhs.channel[1].offset = 0.0 +print(mhs.channel[1].offset) +mhs.channel[1].phase = 15 +print(mhs.channel[1].phase) + diff --git a/doc/source/apiref/minghe.rst b/doc/source/apiref/minghe.rst new file mode 100644 index 000000000..6a1763039 --- /dev/null +++ b/doc/source/apiref/minghe.rst @@ -0,0 +1,15 @@ +.. + TODO: put documentation license header here. + +.. currentmodule:: instruments.minghe + +====== +Minghe +====== + +:class:`MHS5200` Function Generator +=================================== + +.. autoclass:: MHS5200 + :members: + :undoc-members: diff --git a/instruments/__init__.py b/instruments/__init__.py index ca26f525b..a6b20794d 100644 --- a/instruments/__init__.py +++ b/instruments/__init__.py @@ -17,6 +17,7 @@ from . import hp from . import keithley from . import lakeshore +from . import minghe from . import newport from . import oxford from . import phasematrix diff --git a/instruments/minghe/__init__.py b/instruments/minghe/__init__.py new file mode 100644 index 000000000..997950f6c --- /dev/null +++ b/instruments/minghe/__init__.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Module containing MingHe instruments +""" +from __future__ import absolute_import +from .mhs5200a import MHS5200 diff --git a/instruments/minghe/mhs5200a.py b/instruments/minghe/mhs5200a.py new file mode 100644 index 000000000..97e661f3f --- /dev/null +++ b/instruments/minghe/mhs5200a.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Provides the support for the MingHe low-cost function generator. + +Class originally contributed by Catherine Holloway. +""" + +# IMPORTS ##################################################################### + +from __future__ import absolute_import +from __future__ import division + +from builtins import range +from enum import Enum + +import quantities as pq +from instruments.abstract_instruments import FunctionGenerator +from instruments.util_fns import ProxyList, assume_units + +# CLASSES ##################################################################### + + +class MHS5200(FunctionGenerator): + """ + The MHS5200 is a low-cost, 2 channel function generator. + + There is no user manual, but Al Williams has reverse-engineered the + communications protocol: + https://github.com/wd5gnr/mhs5200a/blob/master/MHS5200AProtocol.pdf + """ + def __init__(self, filelike): + super(MHS5200, self).__init__(filelike) + self._channel_count = 2 + self.terminator = "\r\n" + + def _ack_expected(self, msg=""): + if msg.find(":r") == 0: + return None + # most commands res + return "ok" + + # INNER CLASSES # + + class Channel(FunctionGenerator.Channel): + """ + Class representing a channel on the MHS52000. + """ + # pylint: disable=protected-access + + __CHANNEL_NAMES = { + 1: '1', + 2: '2' + } + + def __init__(self, mhs, idx): + self._mhs = mhs + super(MHS5200.Channel, self).__init__(parent=mhs, name=idx) + # Use zero-based indexing for the external API, but one-based + # for talking to the instrument. + self._idx = idx + 1 + self._chan = self.__CHANNEL_NAMES[self._idx] + self._count = 0 + + def _get_amplitude_(self): + query = ":r{0}a".format(self._chan) + response = self._mhs.query(query) + return float(response.replace(query, ""))/100.0, self._mhs.VoltageMode.rms + + def _set_amplitude_(self, magnitude, units): + if units == self._mhs.VoltageMode.peak_to_peak or \ + units == self._mhs.VoltageMode.rms: + magnitude = assume_units(magnitude, "V").rescale(pq.V).magnitude + elif units == self._mhs.VoltageMode.dBm: + raise NotImplementedError("Decibel units are not supported.") + magnitude *= 100 + query = ":s{0}a{1}".format(self._chan, int(magnitude)) + self._mhs.sendcmd(query) + + @property + def duty_cycle(self): + """ + Gets/Sets the duty cycle of this channel. + + :units: A fraction + :type: `~quantities.Quantity` + """ + query = ":r{0}d".format(self._chan) + response = self._mhs.query(query) + duty = float(response.replace(query, ""))/10.0 + return duty + + @duty_cycle.setter + def duty_cycle(self, new_val): + query = ":s{0}d{1}".format(self._chan, int(100.0*new_val)) + self._mhs.sendcmd(query) + + @property + def enable(self): + """ + Gets/Sets the enable state of this channel. + + :param newval: the enable state + :type: `bool` + """ + query = ":r{0}b".format(self._chan) + return int(self._mhs.query(query).replace(query, ""). + replace("\r", "")) + + @enable.setter + def enable(self, newval): + query = ":s{0}b{1}".format(self._chan, int(newval)) + self._mhs.sendcmd(query) + + @property + def frequency(self): + """ + Gets/Sets the frequency of this channel. + + :units: As specified (if a `~quantities.Quantity`) or assumed to be + of units hertz. + :type: `~quantities.Quantity` + """ + query = ":r{0}f".format(self._chan) + response = self._mhs.query(query) + freq = float(response.replace(query, ""))*pq.Hz + return freq/100.0 + + @frequency.setter + def frequency(self, new_val): + new_val = assume_units(new_val, pq.Hz).rescale(pq.Hz).\ + magnitude*100.0 + query = ":s{0}f{1}".format(self._chan, int(new_val)) + self._mhs.sendcmd(query) + + @property + def offset(self): + """ + Gets/Sets the offset of this channel. + + :param new_val: The fraction of the duty cycle to offset the + function by. + :type: `float` + """ + # need to convert + query = ":r{0}o".format(self._chan) + response = self._mhs.query(query) + return int(response.replace(query, ""))/100.0-1.20 + + @offset.setter + def offset(self, new_val): + new_val = int(new_val*100)+120 + query = ":s{0}o{1}".format(self._chan, new_val) + self._mhs.sendcmd(query) + + @property + def phase(self): + """ + Gets/Sets the phase of this channel. + + :units: As specified (if a `~quantities.Quantity`) or assumed to be + of degrees. + :type: `~quantities.Quantity` + """ + # need to convert + query = ":r{0}p".format(self._chan) + response = self._mhs.query(query) + return int(response.replace(query, ""))*pq.deg + + @phase.setter + def phase(self, new_val): + new_val = assume_units(new_val, pq.deg).rescale("deg").magnitude + query = ":s{0}p{1}".format(self._chan, int(new_val)) + self._mhs.sendcmd(query) + + @property + def function(self): + """ + Gets/Sets the wave type of this channel. + + :type: `MHS5200.Function` + """ + query = ":r{0}w".format(self._chan) + response = self._mhs.query(query).replace(query, "") + return self._mhs.Function(int(response)) + + @function.setter + def function(self, new_val): + query = ":s{0}w{1}".format(self._chan, + self._mhs.Function(new_val).value) + self._mhs.sendcmd(query) + + class Function(Enum): + """ + Enum containing valid wave modes for + """ + sine = 0 + square = 1 + triangular = 2 + sawtooth_up = 3 + sawtooth_down = 4 + + @property + def channel(self): + """ + Gets a specific channel object. The desired channel is specified like + one would access a list. + + For instance, this would print the counts of the first channel:: + + >>> mhs = ik.minghe.MHS5200.open_serial(vid=1027, pid=24577, + baud=19200, timeout=1) + >>> print(mhs.channel[0].frequency) + + :rtype: `list`[`MHS5200.Channel`] + """ + return ProxyList(self, MHS5200.Channel, range(self._channel_count)) + + @property + def serial_number(self): + """ + Get the serial number, as an int + + :rtype: int + """ + query = ":r0c" + response = self.query(query) + response = response.replace(query, "").replace("\r", "") + return response + + def _get_amplitude_(self): + raise NotImplementedError() + + def _set_amplitude_(self, magnitude, units): + raise NotImplementedError() diff --git a/instruments/tests/test_minghe/test_minghe_mhs5200a.py b/instruments/tests/test_minghe/test_minghe_mhs5200a.py new file mode 100644 index 000000000..1f70afdc8 --- /dev/null +++ b/instruments/tests/test_minghe/test_minghe_mhs5200a.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Module containing tests for the MingHe MHS52000a +""" + +# IMPORTS #################################################################### + +from __future__ import absolute_import + +import pytest +import quantities as pq + +import instruments as ik +from instruments.tests import expected_protocol + + +# TESTS ###################################################################### + + +def test_mhs_amplitude(): + with expected_protocol( + ik.minghe.MHS5200, + [ + ":r1a", + ":r2a", + ":s1a660", + ":s2a800" + ], + [ + ":r1a330", + ":r2a500", + "ok", + "ok" + ], + sep="\r\n" + ) as mhs: + assert mhs.channel[0].amplitude[0] == 3.3*pq.V + assert mhs.channel[1].amplitude[0] == 5.0*pq.V + mhs.channel[0].amplitude = 6.6*pq.V + mhs.channel[1].amplitude = 8.0*pq.V + + +def test_mhs_amplitude_dbm_notimplemented(): + with expected_protocol( + ik.minghe.MHS5200, + [], + [], + sep="\r\n" + ) as mhs: + with pytest.raises(NotImplementedError): + mhs.channel[0].amplitude = 6.6*ik.units.dBm + + +def test_mhs_duty_cycle(): + with expected_protocol( + ik.minghe.MHS5200, + [ + ":r1d", + ":r2d", + ":s1d6", + ":s2d80" + + ], + [ + ":r1d010", + ":r2d100", + "ok", + "ok" + ], + sep="\r\n" + ) as mhs: + assert mhs.channel[0].duty_cycle == 1.0 + assert mhs.channel[1].duty_cycle == 10.0 + mhs.channel[0].duty_cycle = 0.06 + mhs.channel[1].duty_cycle = 0.8 + + +def test_mhs_enable(): + with expected_protocol( + ik.minghe.MHS5200, + [ + ":r1b", + ":r2b", + ":s1b0", + ":s2b1" + ], + [ + ":r1b1", + ":r2b0", + "ok", + "ok" + ], + sep="\r\n" + ) as mhs: + assert mhs.channel[0].enable + assert not mhs.channel[1].enable + mhs.channel[0].enable = False + mhs.channel[1].enable = True + + +def test_mhs_frequency(): + with expected_protocol( + ik.minghe.MHS5200, + [ + ":r1f", + ":r2f", + ":s1f600000", + ":s2f800000" + + ], + [ + ":r1f3300000", + ":r2f50000000", + "ok", + "ok" + ], + sep="\r\n" + ) as mhs: + assert mhs.channel[0].frequency == 33.0*pq.kHz + assert mhs.channel[1].frequency == 500.0*pq.kHz + mhs.channel[0].frequency = 6*pq.kHz + mhs.channel[1].frequency = 8*pq.kHz + + +def test_mhs_offset(): + with expected_protocol( + ik.minghe.MHS5200, + [ + ":r1o", + ":r2o", + ":s1o60", + ":s2o180" + + ], + [ + ":r1o120", + ":r2o0", + "ok", + "ok" + ], + sep="\r\n" + ) as mhs: + assert mhs.channel[0].offset == 0 + assert mhs.channel[1].offset == -1.2 + mhs.channel[0].offset = -0.6 + mhs.channel[1].offset = 0.6 + + +def test_mhs_phase(): + with expected_protocol( + ik.minghe.MHS5200, + [ + ":r1p", + ":r2p", + ":s1p60", + ":s2p180" + + ], + [ + ":r1p120", + ":r2p0", + "ok", + "ok" + ], + sep="\r\n" + ) as mhs: + assert mhs.channel[0].phase == 120 + assert mhs.channel[1].phase == 0 + mhs.channel[0].phase = 60 + mhs.channel[1].phase = 180 + + +def test_mhs_wave_type(): + with expected_protocol( + ik.minghe.MHS5200, + [ + ":r1w", + ":r2w", + ":s1w2", + ":s2w3" + + ], + [ + ":r1w0", + ":r2w1", + "ok", + "ok" + ], + sep="\r\n" + ) as mhs: + assert mhs.channel[0].function == mhs.Function.sine + assert mhs.channel[1].function == mhs.Function.square + mhs.channel[0].function = mhs.Function.triangular + mhs.channel[1].function = mhs.Function.sawtooth_up + + +def test_mhs_serial_number(): + with expected_protocol( + ik.minghe.MHS5200, + [ + ":r0c" + + ], + [ + ":r0c5225A1", + ], + sep="\r\n" + ) as mhs: + assert mhs.serial_number == "5225A1" From b59a45aa755e8d9dbb8163ea872cb8a709dc9d16 Mon Sep 17 00:00:00 2001 From: Steven Casagrande Date: Thu, 14 Feb 2019 23:34:50 -0500 Subject: [PATCH 8/8] Docstring modifications (#207) --- instruments/minghe/mhs5200a.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/instruments/minghe/mhs5200a.py b/instruments/minghe/mhs5200a.py index 97e661f3f..cfe04b2ab 100644 --- a/instruments/minghe/mhs5200a.py +++ b/instruments/minghe/mhs5200a.py @@ -83,7 +83,7 @@ def duty_cycle(self): Gets/Sets the duty cycle of this channel. :units: A fraction - :type: `~quantities.Quantity` + :type: `float` """ query = ":r{0}d".format(self._chan) response = self._mhs.query(query) @@ -100,7 +100,6 @@ def enable(self): """ Gets/Sets the enable state of this channel. - :param newval: the enable state :type: `bool` """ query = ":r{0}b".format(self._chan) @@ -138,8 +137,8 @@ def offset(self): """ Gets/Sets the offset of this channel. - :param new_val: The fraction of the duty cycle to offset the - function by. + The fraction of the duty cycle to offset the function by. + :type: `float` """ # need to convert @@ -208,6 +207,7 @@ def channel(self): For instance, this would print the counts of the first channel:: + >>> import instruments as ik >>> mhs = ik.minghe.MHS5200.open_serial(vid=1027, pid=24577, baud=19200, timeout=1) >>> print(mhs.channel[0].frequency)