Repository: microsoft/Qcodes

Issue:
Creating parameters with get/set_cmd in a virtual instrument causes AttributeError
When creating a virtual instrument (i.e. a wrapper around a device) which has parameters with a `get_cmd` or a `set_cmd` method, the creation will fail as it assumes that all instruments have a `write` or an `ask` method, which is not true for virtual instruments which subclass `InstrumentBase`.

For example, the following code snippet will cause an `AttributeError`:

```py3
from qcodes.instrument.base import InstrumentBase

class VirtualInstrument(InstrumentBase):
    def __init__(self, name):
        super().__init__(self, name)

        add_parameter("Bla", get_cmd=self.get_bla)

    def get_bla(self):
        return 1
```

Though this example is contrived, it came up in https://github.com/QNLSydney/qcodes_measurements/blob/master/qcodes_measurements/device/channel_wrapper.py which labels gates/keeps track of gate connection state.

## Potential Solutions
 - Change the initialization of Parameter to use:
```python3
exec_str_ask = getattr(instrument, "ask", None) if instrument else None
```
```python3
exec_str_write = getattr(instrument, "write", None) if instrument else None
```
 - Subclass from `Instrument` instead, although this potentially causes some issues?
    - A nonsensical value for IDN unless overwritten, which also leads to a nonsensical connect_message.
    - Linting errors on `ask_raw` and `write_raw` which must be overwritten.


Discussion:

Resolving patch:
diff --git a/qcodes/instrument/parameter.py b/qcodes/instrument/parameter.py
--- a/qcodes/instrument/parameter.py
+++ b/qcodes/instrument/parameter.py
@@ -520,9 +520,8 @@ def get_ramp_values(self, value: Union[float, int, Sized],
                 # isn't, even though it's valid.
                 # probably MultiType with a mix of numeric and non-numeric types
                 # just set the endpoint and move on
-                logging.warning(
-                    'cannot sweep {} from {} to {} - jumping.'.format(
-                        self.name, start_value, value))
+                logging.warning('cannot sweep %s from %r to %r - jumping.',
+                                self.name, start_value, value)
                 return []
 
             # drop the initial value, we're already there
@@ -851,7 +850,7 @@ def __init__(self, name: str,
                                       'when max_val_age is set')
                 self.get_raw = lambda: self._latest['raw_value']
             else:
-                exec_str_ask = instrument.ask if instrument else None
+                exec_str_ask = getattr(instrument, "ask", None) if instrument else None
                 self.get_raw = Command(arg_count=0, cmd=get_cmd, exec_str=exec_str_ask)
             self.get = self._wrap_get(self.get_raw)
 
@@ -859,7 +858,7 @@ def __init__(self, name: str,
             if set_cmd is None:
                 self.set_raw = partial(self._save_val, validate=False)# type: Callable
             else:
-                exec_str_write = instrument.write if instrument else None
+                exec_str_write = getattr(instrument, "write", None) if instrument else None
                 self.set_raw = Command(arg_count=1, cmd=set_cmd, exec_str=exec_str_write)# type: Callable
             self.set = self._wrap_set(self.set_raw)
 
@@ -1593,10 +1592,10 @@ def __init__(self, name, instrument=None,
                  delay=0, max_delay=None, step=None, max_val_age=3600,
                  vals=None, val_mapping=None, **kwargs):
         super().__init__(name, instrument=instrument,
-                 get_cmd=get_cmd, get_parser=get_parser,
-                 set_cmd=set_cmd, set_parser=set_parser,
-                 post_delay=delay, step=step, max_val_age=max_val_age,
-                 vals=vals, val_mapping=val_mapping, **kwargs)
+                         get_cmd=get_cmd, get_parser=get_parser,
+                         set_cmd=set_cmd, set_parser=set_parser,
+                         post_delay=delay, step=step, max_val_age=max_val_age,
+                         vals=vals, val_mapping=val_mapping, **kwargs)
         warnings.warn('`StandardParameter` is deprecated, '
                         'use `Parameter` instead. {}'.format(self))
 


Test patch:
diff --git a/qcodes/tests/instrument_mocks.py b/qcodes/tests/instrument_mocks.py
--- a/qcodes/tests/instrument_mocks.py
+++ b/qcodes/tests/instrument_mocks.py
@@ -4,7 +4,7 @@
 
 import numpy as np
 
-from qcodes.instrument.base import Instrument
+from qcodes.instrument.base import Instrument, InstrumentBase
 from qcodes.utils.validators import Numbers
 from qcodes.instrument.parameter import MultiParameter, Parameter, ArrayParameter
 from qcodes.instrument.channel import InstrumentChannel, ChannelList
@@ -56,25 +56,31 @@ def _measure_skewed_parabola(self):
         Adds an -x term to add a corelation between the parameters.
         '''
         return ((self.x.get()**2 + self.y.get()**2 +
-                self.z.get()**2)*(1 + abs(self.y.get()-self.x.get())) +
-                self.noise.get()*np.random.rand(1))
+                 self.z.get()**2)*(1 + abs(self.y.get()-self.x.get())) +
+                 self.noise.get()*np.random.rand(1))
 
 
-class MockMetaParabola(Instrument):
+class MockMetaParabola(InstrumentBase):
     '''
     Test for a meta instrument, has a tunable gain knob
+
+    Unlike a MockParabola, a MockMetaParabola does not have a connection, or
+    access to ask_raw/write_raw, i.e. it would not be connected to a real instrument.
+
+    It is also not tracked in the global _all_instruments list, but is still
+    snapshottable in a station.
     '''
 
     def __init__(self, name, mock_parabola_inst, **kw):
+        """
+        Create a new MockMetaParabola, connected to an existing MockParabola instance.
+        """
         super().__init__(name, **kw)
         self.mock_parabola_inst = mock_parabola_inst
 
         # Instrument parameters
         for parname in ['x', 'y', 'z']:
-            self.add_parameter(parname, unit='a.u.',
-                               parameter_class=Parameter,
-                               vals=Numbers(), initial_value=0,
-                               get_cmd=None, set_cmd=None)
+            self.parameters[parname] = getattr(mock_parabola_inst, parname)
         self.add_parameter('gain', parameter_class=Parameter,
                            initial_value=1,
                            get_cmd=None, set_cmd=None)
@@ -95,7 +101,7 @@ def _get_skew_parabola(self):
 
 class DummyInstrument(Instrument):
 
-    def __init__(self, name='dummy', gates=['dac1', 'dac2', 'dac3'], **kwargs):
+    def __init__(self, name='dummy', gates=('dac1', 'dac2', 'dac3'), **kwargs):
 
         """
         Create a dummy instrument that can be used for testing
diff --git a/qcodes/tests/test_instrument.py b/qcodes/tests/test_instrument.py
--- a/qcodes/tests/test_instrument.py
+++ b/qcodes/tests/test_instrument.py
@@ -1,12 +1,17 @@
 """
 Test suite for  instument.*
 """
+
+import gc
 import weakref
+import io
+import contextlib
+
 from unittest import TestCase
-from qcodes.instrument.base import Instrument, InstrumentBase, find_or_create_instrument
-from .instrument_mocks import DummyInstrument, MockParabola
-from qcodes.instrument.parameter import Parameter
-import gc
+from ..instrument.base import Instrument, InstrumentBase, find_or_create_instrument
+from ..instrument.parameter import Parameter
+from .instrument_mocks import DummyInstrument, MockParabola, MockMetaParabola
+
 
 
 class TestInstrument(TestCase):
@@ -65,10 +70,10 @@ def test_repr(self):
 
     def test_add_remove_f_p(self):
         with self.assertRaises(KeyError):
-                self.instrument.add_parameter('dac1', get_cmd='foo')
+            self.instrument.add_parameter('dac1', get_cmd='foo')
         self.instrument.add_function('function', call_cmd='foo')
         with self.assertRaises(KeyError):
-                self.instrument.add_function('function', call_cmd='foo')
+            self.instrument.add_function('function', call_cmd='foo')
 
         self.instrument.add_function('dac1', call_cmd='foo')
         # test custom __get_attr__
@@ -125,6 +130,37 @@ def test_snapshot_value(self):
                          snapshot['parameters']['has_snapshot_value']['value'])
         self.assertNotIn('value', snapshot['parameters']['no_snapshot_value'])
 
+    def test_meta_instrument(self):
+        mock_instrument = MockMetaParabola("mock_parabola", self.instrument2)
+
+        # Check that the mock instrument can return values
+        self.assertEqual(mock_instrument.parabola(), self.instrument2.parabola())
+        mock_instrument.x(1)
+        mock_instrument.y(2)
+        self.assertEqual(mock_instrument.parabola(), self.instrument2.parabola())
+        self.assertNotEqual(mock_instrument.parabola(), 0)
+
+        # Add a scaling factor
+        mock_instrument.gain(2)
+        self.assertEqual(mock_instrument.parabola(), self.instrument2.parabola()*2)
+
+        # Check snapshots
+        snap = mock_instrument.snapshot(update=True)
+        self.assertIn("parameters", snap)
+        self.assertIn("gain", snap["parameters"])
+        self.assertEqual(snap["parameters"]["gain"]["value"], 2)
+
+        # Check printable snapshot
+        f = io.StringIO()
+        with contextlib.redirect_stdout(f):
+            mock_instrument.print_readable_snapshot()
+        readable_snap = f.getvalue()
+
+        # Line length satisfied
+        self.assertTrue(all(len(line) <= 80 for line in readable_snap.splitlines()))
+        # Gain is included in output with correct value
+        self.assertRegex(readable_snap, r"gain[ \t]+:[ \t]+2")
+
 
 class TestFindOrCreateInstrument(TestCase):
     """Tests for find_or_create_instrument function"""
diff --git a/qcodes/tests/test_parameter.py b/qcodes/tests/test_parameter.py
--- a/qcodes/tests/test_parameter.py
+++ b/qcodes/tests/test_parameter.py
@@ -259,6 +259,12 @@ def test_step_ramp(self):
         p(44.5)
         self.assertListEqual(p.set_values, [42, 43, 44, 44.5])
 
+        # Test error conditions
+        with self.assertLogs(level='WARN'):
+            self.assertEqual(p.get_ramp_values("A", 1), [])
+        with self.assertRaises(RuntimeError):
+            p.get_ramp_values((1, 2, 3), 1)
+
     def test_scale_raw_value(self):
         p = Parameter(name='test_scale_raw_value', set_cmd=None)
         p(42)