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