Repository: microsoft/Qcodes

Issue:
Keithley 2600: support time trace measurements via buffer reading
< description coming soon >

Main use case: to record a time series with __fixed__ time intervals for use for a noise power spectrum. To get the timings right, delegate the sweep to the instrument.

-------

This PR probably generalized the "time trace" part of this PR https://github.com/QCoDeS/Qcodes/pull/1617 by @ThorvaldLarsen .

Original work by @StefanD986.


Discussion:

Resolving patch:
diff --git a/qcodes/instrument_drivers/tektronix/Keithley_2600_channels.py b/qcodes/instrument_drivers/tektronix/Keithley_2600_channels.py
--- a/qcodes/instrument_drivers/tektronix/Keithley_2600_channels.py
+++ b/qcodes/instrument_drivers/tektronix/Keithley_2600_channels.py
@@ -1,14 +1,16 @@
 import logging
 import struct
 import numpy as np
+import warnings
 from typing import List, Dict, Optional
 
 import qcodes as qc
 from qcodes import VisaInstrument, DataSet
 from qcodes.instrument.channel import InstrumentChannel
-from qcodes.instrument.base import Instrument
-from qcodes.instrument.parameter import ArrayParameter
+from qcodes.instrument.base import Instrument, Parameter
+from qcodes.instrument.parameter import ArrayParameter, ParameterWithSetpoints
 import qcodes.utils.validators as vals
+from qcodes.utils.helpers import create_on_off_val_mapping
 
 
 log = logging.getLogger(__name__)
@@ -74,11 +76,108 @@ def get_raw(self) -> np.ndarray:
                                                 self.steps,
                                                 self.mode)
         else:
-            raise RuntimeError("No instrument attached to Parameter")
+            raise RuntimeError("No instrument attached to Parameter.")
 
         return data
 
 
+class TimeTrace(ParameterWithSetpoints):
+    """
+    A parameter class that holds the data corresponding to the time dependence of
+    current and voltage.
+    """
+    def _check_time_trace(self) -> None:
+        """
+        A helper function that compares the integration time with measurement
+        interval for accurate results.
+
+        Raises:
+            RuntimeError: If no instrument attached to Parameter.
+        """
+        if self.instrument is None:
+            raise RuntimeError("No instrument attached to Parameter.")
+
+        dt = self.instrument.timetrace_dt()
+        nplc = self.instrument.nplc()
+        linefreq = self.instrument.linefreq()
+        plc = 1/linefreq
+        if nplc * plc > dt:
+            warnings.warn(f'Integration time of {nplc*plc*1000:.1f} ' +
+                          f'ms is longer than {dt*1000:.1f} ms set ' +
+                          'as measurement interval. Consider lowering ' +
+                          'NPLC or increasing interval.', UserWarning, 2)
+
+    def _set_mode(self, mode: str) -> None:
+        """
+        A helper function to set correct units and labels.
+
+        Args:
+            mode: User defined mode for the timetrace. It can be either
+            "current" or "voltage".
+        """
+        if mode == 'current':
+            self.unit='A'
+            self.label='Current'
+        if mode == 'voltage':
+            self.unit='V'
+            self.label='Voltage'
+
+    def _time_trace(self) -> np.ndarray:
+        """
+        The function that prepares a Lua script for timetrace data acquisition.
+
+        Raises:
+            RuntimeError: If no instrument attached to Parameter.
+        """
+
+        if self.instrument is None:
+            raise RuntimeError("No instrument attached to Parameter.")
+
+        channel = self.instrument.channel
+        npts = self.instrument.timetrace_npts()
+        dt = self.instrument.timetrace_dt()
+        mode = self.instrument.timetrace_mode()
+
+        mode_map = {"current": "i", "voltage": "v"}
+
+        script = ['{}.measure.count={}'.format(channel, npts),
+                  'oldint={}.measure.interval'.format(channel),
+                  '{}.measure.interval={}'.format(channel, dt),
+                  '{}.nvbuffer1.clear()'.format(channel),
+                  '{}.measure.{}({}.nvbuffer1)'.format(channel, mode_map[mode], channel),
+                  '{}.measure.interval=oldint'.format(channel),
+                  '{}.measure.count=1'.format(channel),
+                  'format.data = format.REAL32',
+                  'format.byteorder = format.LITTLEENDIAN',
+                  'printbuffer(1, {}, {}.nvbuffer1.readings)'.format(npts, channel)]
+
+        return self.instrument._execute_lua(script, npts)
+
+    def get_raw(self) -> np.ndarray:  # pylint: disable=E0202
+
+        if self.instrument is None:
+            raise RuntimeError("No instrument attached to Parameter.")
+
+        self._check_time_trace()
+        data = self._time_trace()
+        return data
+
+
+class TimeAxis(Parameter):
+    """
+    A simple :class:`.Parameter` that holds all the times (relative to the
+    measurement start) at which the points of the time trace were acquired.
+    """
+    def get_raw(self) -> np.ndarray:  # pylint: disable=E0202
+
+        if self.instrument is None:
+            raise RuntimeError("No instrument attached to Parameter.")
+
+        npts = self.instrument.timetrace_npts()
+        dt = self.instrument.timetrace_dt()
+        return np.linspace(0, dt*npts, npts, endpoint=False)
+
+
 class KeithleyChannel(InstrumentChannel):
     """
     Class to hold the two Keithley channels, i.e.
@@ -100,6 +199,9 @@ def __init__(self, parent: Instrument, name: str, channel: str) -> None:
 
         super().__init__(parent, name)
         self.model = self._parent.model
+        self._extra_visa_timeout = 5000
+        self._measurement_duration_factor = 2 # Ensures that we are always above
+                                              # the expected time.
         vranges = self._parent._vranges
         iranges = self._parent._iranges
         vlimit_minmax = self.parent._vlimit_minmax
@@ -138,7 +240,15 @@ def __init__(self, parent: Instrument, name: str, channel: str) -> None:
                            get_cmd=f'{channel}.source.output',
                            get_parser=float,
                            set_cmd=f'{channel}.source.output={{:d}}',
-                           val_mapping={'on':  1, 'off': 0})
+                           val_mapping=create_on_off_val_mapping(on_val=1,
+                                                                 off_val=0))
+
+        self.add_parameter('linefreq',
+                           label='Line frequency',
+                           get_cmd='localnode.linefreq',
+                           get_parser=float,
+                           set_cmd=False,
+                           unit='Hz')
 
         self.add_parameter('nplc',
                            label='Number of power line cycles',
@@ -154,16 +264,27 @@ def __init__(self, parent: Instrument, name: str, channel: str) -> None:
                            label='voltage source range',
                            get_cmd=f'{channel}.source.rangev',
                            get_parser=float,
-                           set_cmd=f'{channel}.source.rangev={{}}',
+                           set_cmd=self._set_sourcerange_v,
                            unit='V',
                            docstring='The range used when sourcing voltage '
                                      'This affects the range and the precision '
                                      'of the source.',
                            vals=vals.Enum(*vranges[self.model]))
+
+        self.add_parameter('source_autorange_v_enabled',
+                           label='voltage source autorange',
+                           get_cmd=f'{channel}.source.autorangev',
+                           get_parser=float,
+                           set_cmd=f'{channel}.source.autorangev={{}}',
+                           docstring='Set autorange on/off for source voltage.',
+                           val_mapping=create_on_off_val_mapping(on_val=1,
+                                                                 off_val=0))
+
         self.add_parameter('measurerange_v',
                            label='voltage measure range',
                            get_cmd=f'{channel}.measure.rangev',
-                           set_cmd=f'{channel}.measure.rangev={{}}',
+                           get_parser=float,
+                           set_cmd=self._set_measurerange_v,
                            unit='V',
                            docstring='The range to perform voltage '
                                      'measurements in. This affects the range '
@@ -172,24 +293,42 @@ def __init__(self, parent: Instrument, name: str, channel: str) -> None:
                                      'source current this will have no effect, '
                                      'set `sourcerange_v` instead',
                            vals=vals.Enum(*vranges[self.model]))
+
+        self.add_parameter('measure_autorange_v_enabled',
+                           label='voltage measure autorange',
+                           get_cmd=f'{channel}.measure.autorangev',
+                           get_parser=float,
+                           set_cmd=f'{channel}.measure.autorangev={{}}',
+                           docstring='Set autorange on/off for measure voltage.',
+                           val_mapping=create_on_off_val_mapping(on_val=1,
+                                                                 off_val=0))
         # current range
         # needs get after set
         self.add_parameter('sourcerange_i',
                            label='current source range',
                            get_cmd=f'{channel}.source.rangei',
                            get_parser=float,
-                           set_cmd=f'{channel}.source.rangei={{}}',
+                           set_cmd=self._set_sourcerange_i,
                            unit='A',
                            docstring='The range used when sourcing current '
                                      'This affects the range and the '
                                      'precision of the source.',
                            vals=vals.Enum(*iranges[self.model]))
 
+        self.add_parameter('source_autorange_i_enabled',
+                            label='current source autorange',
+                            get_cmd=f'{channel}.source.autorangei',
+                            get_parser=float,
+                            set_cmd=f'{channel}.source.autorangei={{}}',
+                            docstring='Set autorange on/off for source current.',
+                            val_mapping=create_on_off_val_mapping(on_val=1,
+                                                                 off_val=0))
+
         self.add_parameter('measurerange_i',
                            label='current measure range',
                            get_cmd=f'{channel}.measure.rangei',
                            get_parser=float,
-                           set_cmd=f'{channel}.measure.rangei={{}}',
+                           set_cmd=self._set_measurerange_i,
                            unit='A',
                            docstring='The range to perform current '
                                      'measurements in. This affects the range '
@@ -198,6 +337,15 @@ def __init__(self, parent: Instrument, name: str, channel: str) -> None:
                                      'current this will have no effect, set '
                                      '`sourcerange_i` instead',
                            vals=vals.Enum(*iranges[self.model]))
+
+        self.add_parameter('measure_autorange_i_enabled',
+                           label='current autorange',
+                           get_cmd=f'{channel}.measure.autorangei',
+                           get_parser=float,
+                           set_cmd=f'{channel}.measure.autorangei={{}}',
+                           docstring='Set autorange on/off for measure current.',
+                           val_mapping=create_on_off_val_mapping(on_val=1,
+                                                                 off_val=0))
         # Compliance limit
         self.add_parameter('limitv',
                            get_cmd=f'{channel}.source.limitv',
@@ -224,6 +372,37 @@ def __init__(self, parent: Instrument, name: str, channel: str) -> None:
         self.add_parameter('fastsweep',
                            parameter_class=LuaSweepParameter)
 
+        self.add_parameter('timetrace_npts',
+                           initial_value=500,
+                           label='Number of points',
+                           get_cmd=None,
+                           set_cmd=None)
+
+        self.add_parameter('timetrace_dt',
+                           initial_value=1e-3,
+                           label='Time resolution',
+                           unit='s',
+                           get_cmd=None,
+                           set_cmd=None)
+
+        self.add_parameter(name='time_axis',
+                           label='Time',
+                           unit='s',
+                           snapshot_value=False,
+                           vals=vals.Arrays(shape=(self.timetrace_npts,)),
+                           parameter_class=TimeAxis)
+
+        self.add_parameter('timetrace',
+                           vals=vals.Arrays(shape=(self.timetrace_npts,)),
+                           setpoints=(self.time_axis,),
+                           parameter_class=TimeTrace)
+
+        self.add_parameter('timetrace_mode',
+                           initial_value='current',
+                           get_cmd=None,
+                           set_cmd=self.timetrace._set_mode,
+                           vals=vals.Enum('current', 'voltage'))
+
         self.channel = channel
 
     def reset(self) -> None:
@@ -311,10 +490,26 @@ def _fast_sweep(self, start: float, stop: float, steps: int,
                   'printbuffer(1, {}, {}.nvbuffer1.readings)'.format(steps,
                                                                      channel)]
 
-        self.write(self._parent._scriptwrapper(program=script, debug=True))
-        # we must wait for the script to execute
-        oldtimeout = self._parent.visa_handle.timeout
-        self._parent.visa_handle.timeout = 2*1000*steps*nplc/50 + 5000
+        return self._execute_lua(script, steps)
+
+    def _execute_lua(self, _script: List[str], steps: int) -> np.ndarray:
+        """
+        This is the function that sends the Lua script to be executed and
+        returns the corresponding data from the buffer.
+
+        Args:
+            _script: The Lua script to be executed.
+            steps: Number of points.
+        """
+        nplc = self.nplc()
+        linefreq = self.linefreq()
+        _time_trace_extra_visa_timeout = self._extra_visa_timeout
+        _factor = self._measurement_duration_factor
+        estimated_measurement_duration = _factor*1000*steps*nplc/linefreq
+        new_visa_timeout = (estimated_measurement_duration
+                          + _time_trace_extra_visa_timeout)
+
+        self.write(self.root_instrument._scriptwrapper(program=_script, debug=True))
 
         # now poll all the data
         # The problem is that a '\n' character might by chance be present in
@@ -322,21 +517,39 @@ def _fast_sweep(self, start: float, stop: float, steps: int,
         fullsize = 4*steps + 3
         received = 0
         data = b''
-        while received < fullsize:
-            data_temp = self._parent.visa_handle.read_raw()
-            received += len(data_temp)
-            data += data_temp
+        # we must wait for the script to execute
+        with self.root_instrument.timeout.set_to(new_visa_timeout):
+            while received < fullsize:
+                data_temp = self.root_instrument.visa_handle.read_raw()
+                received += len(data_temp)
+                data += data_temp
 
         # From the manual p. 7-94, we know that a b'#0' is prepended
         # to the data and a b'\n' is appended
         data = data[2:-1]
-
         outdata = np.array(list(struct.iter_unpack('<f', data)))
         outdata = np.reshape(outdata, len(outdata))
+        return outdata
 
-        self._parent.visa_handle.timeout = oldtimeout
+    def _set_sourcerange_v(self, val: float) -> None:
+        channel = self.channel
+        self.source_autorange_v_enabled(False)
+        self.write(f'{channel}.source.rangev={val}')
 
-        return outdata
+    def _set_measurerange_v(self, val: float) -> None:
+        channel = self.channel
+        self.measure_autorange_v_enabled(False)
+        self.write(f'{channel}.measure.rangev={val}')
+
+    def _set_sourcerange_i(self, val: float) -> None:
+        channel = self.channel
+        self.source_autorange_i_enabled(False)
+        self.write(f'{channel}.source.rangei={val}')
+
+    def _set_measurerange_i(self, val: float) -> None:
+        channel = self.channel
+        self.measure_autorange_i_enabled(False)
+        self.write(f'{channel}.measure.rangei={val}')
 
 
 class Keithley_2600(VisaInstrument):


Test patch:
diff --git a/qcodes/tests/drivers/test_keithley_26xx.py b/qcodes/tests/drivers/test_keithley_26xx.py
--- a/qcodes/tests/drivers/test_keithley_26xx.py
+++ b/qcodes/tests/drivers/test_keithley_26xx.py
@@ -1,4 +1,6 @@
 import pytest
+import numpy as np
+from collections import Counter
 
 from qcodes.instrument_drivers.tektronix.Keithley_2600_channels import \
     Keithley_2600
@@ -7,7 +9,7 @@
 visalib = sims.__file__.replace('__init__.py', 'Keithley_2600.yaml@sim')
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope='function')
 def driver():
     driver = Keithley_2600('Keithley_2600',
                            address='GPIB::1::INSTR',
@@ -17,6 +19,15 @@ def driver():
     driver.close()
 
 
+@pytest.fixture(scope='function')
+def smus(driver):
+    smu_names = {'smua', 'smub'}
+    assert smu_names == set(list(driver.submodules.keys()))
+
+    yield tuple(getattr(driver, smu_name)
+                for smu_name in smu_names)
+
+
 def test_idn(driver):
     assert {'firmware': '3.0.0',
             'model': '2601B',
@@ -40,27 +51,121 @@ def test_smu_channels_and_their_parameters(driver):
 
         assert 'current' == smu.mode()
         smu.mode('voltage')
+        assert smu.mode() == 'voltage'
 
-        assert 'off' == smu.output()
-        smu.output('on')
+        assert smu.output() is False
+        smu.output(True)
+        assert smu.output() is True
 
         assert 0.0 == smu.nplc()
         smu.nplc(2.3)
+        assert smu.nplc() == 2.3
 
         assert 0.0 == smu.sourcerange_v()
         some_valid_sourcerange_v = driver._vranges[smu.model][2]
         smu.sourcerange_v(some_valid_sourcerange_v)
+        assert smu.sourcerange_v() == some_valid_sourcerange_v
+
+        assert smu.source_autorange_v_enabled() is False
+        smu.source_autorange_v_enabled(True)
+        assert smu.source_autorange_v_enabled() is True
+
+        assert 0.0 == smu.measurerange_v()
+        some_valid_measurerange_v = driver._vranges[smu.model][2]
+        smu.measurerange_v(some_valid_measurerange_v)
+        assert smu.measurerange_v() == some_valid_measurerange_v
+
+        assert smu.measure_autorange_v_enabled() is False
+        smu.measure_autorange_v_enabled(True)
+        assert smu.measure_autorange_v_enabled() is True
 
         assert 0.0 == smu.sourcerange_i()
         some_valid_sourcerange_i = driver._iranges[smu.model][2]
         smu.sourcerange_i(some_valid_sourcerange_i)
+        assert smu.sourcerange_i() == some_valid_sourcerange_i
+
+        assert smu.source_autorange_i_enabled() is False
+        smu.source_autorange_i_enabled(True)
+        assert smu.source_autorange_i_enabled() is True
 
         assert 0.0 == smu.measurerange_i()
         some_valid_measurerange_i = driver._iranges[smu.model][2]
         smu.measurerange_i(some_valid_measurerange_i)
+        assert smu.measurerange_i() == some_valid_measurerange_i
+
+        assert smu.measure_autorange_i_enabled() is False
+        smu.measure_autorange_i_enabled(True)
+        assert smu.measure_autorange_i_enabled() is True
 
         assert 0.0 == smu.limitv()
         smu.limitv(2.3)
+        assert smu.limitv() == 2.3
 
         assert 0.0 == smu.limiti()
         smu.limiti(2.3)
+        assert smu.limiti() == 2.3
+
+        assert 'current' == smu.timetrace_mode()
+        smu.timetrace_mode('voltage')
+        assert smu.timetrace_mode() == 'voltage'
+
+        assert 500 == smu.timetrace_npts()
+        smu.timetrace_npts(600)
+        assert smu.timetrace_npts() == 600
+
+        assert 0.001 == smu.timetrace_dt()
+        smu.timetrace_dt(0.002)
+        assert smu.timetrace_dt() == 0.002
+
+        dt = smu.timetrace_dt()
+        npts = smu.timetrace_npts()
+        expected_time_axis = np.linspace(0, dt*npts, npts, endpoint=False)
+        assert len(expected_time_axis) == len(smu.time_axis())
+        assert Counter(expected_time_axis) == Counter(smu.time_axis())
+        assert set(expected_time_axis) == set(smu.time_axis())
+
+        smu.timetrace_mode('current')
+        assert 'A' == smu.timetrace.unit
+        assert 'Current' == smu.timetrace.label
+        assert smu.time_axis == smu.timetrace.setpoints[0]
+
+        smu.timetrace_mode('voltage')
+        assert 'V' == smu.timetrace.unit
+        assert 'Voltage' == smu.timetrace.label
+        assert smu.time_axis == smu.timetrace.setpoints[0]
+
+
+def test_setting_source_voltage_range_disables_autorange(smus):
+    for smu in smus:
+        smu.source_autorange_v_enabled(True)
+        assert smu.source_autorange_v_enabled() is True
+        some_valid_sourcerange_v = smu.root_instrument._vranges[smu.model][2]
+        smu.sourcerange_v(some_valid_sourcerange_v)
+        assert smu.source_autorange_v_enabled() is False
+
+
+def test_setting_measure_voltage_range_disables_autorange(smus):
+    for smu in smus:
+        smu.measure_autorange_v_enabled(True)
+        assert smu.measure_autorange_v_enabled() is True
+        some_valid_measurerange_v = smu.root_instrument._vranges[smu.model][2]
+        smu.measurerange_v(some_valid_measurerange_v)
+        assert smu.measure_autorange_v_enabled() is False
+
+
+def test_setting_source_current_range_disables_autorange(smus):
+    for smu in smus:
+        smu.source_autorange_i_enabled(True)
+        assert smu.source_autorange_i_enabled() is True
+        some_valid_sourcerange_i = smu.root_instrument._iranges[smu.model][2]
+        smu.sourcerange_i(some_valid_sourcerange_i)
+        assert smu.source_autorange_i_enabled() is False
+
+
+def test_setting_measure_current_range_disables_autorange(smus):
+    for smu in smus:
+        smu.measure_autorange_i_enabled(True)
+        assert smu.measure_autorange_i_enabled() is True
+        some_valid_measurerange_i = smu.root_instrument._iranges[smu.model][2]
+        smu.measurerange_i(some_valid_measurerange_i)
+        assert smu.measure_autorange_i_enabled() is False