This page was generated from docs/examples/writing_drivers/Creating-Simulated-PyVISA-Instruments.ipynb. Interactive online version: Binder badge.

Creating Simulated PyVISA Instruments

When developing stuff in a large codebase like QCoDeS, it is often uncanningly easy to submit a change that breaks stuff. Therefore, continuous integration is performed in the form of automated tests that run before new code is allowed into the codebase. The many tests of QCoDeS can be found in the tests folder.

But how about drivers? They constitute the majority of the codebase, but how can we test them? Wouldn’t that require a physical copy each instrument to be present on the California server where we run our tests? It used to be so, but not anymore! For drivers utilising PyVISA (i.e. VisaInstrument drivers), we may create simulated instruments to which the drivers may connect.

What?

This way, we may instantiate drivers and run simple tests on them. Tests like:

  • Can the driver even instantiate? This is very relevant when underlying APIs change.

  • Is the drivers (e.g.) “voltage-to-bytecode” converter working properly?

Not!

It is not feasible to simulate any but the most trivial features of the instrument. Simulated instruments can not and should not perform tests like:

  • Do we wait sufficiently long for this oscilloscope’s trace to be acquired?

  • Does our driver handle overlapping commands of this AWG correctly?

How?

The basic scheme goes as follows:

Below is an example.

Example: Weinschel8320

The Weinschel 8320 is a very simple driver.

[1]:
from typing import TYPE_CHECKING

import numpy as np

import qcodes.validators as vals
from qcodes.instrument.visa import VisaInstrument, VisaInstrumentKWArgs

if TYPE_CHECKING:
    from typing_extensions import Unpack


class Weinschel8320(VisaInstrument):
    """
    QCoDeS driver for the stepped attenuator
    Weinschel is formerly known as Aeroflex/Weinschel
    """

    default_terminator = "\r"

    def __init__(
        self, name: str, address: str, **kwargs: "Unpack[VisaInstrumentKWArgs]"
    ):
        super().__init__(name, address, **kwargs)

        self.attenuation = self.add_parameter(
            "attenuation",
            unit="dB",
            set_cmd="ATTN ALL {:02.0f}",
            get_cmd="ATTN? 1",
            vals=vals.Enum(*np.arange(0, 60.1, 2).tolist()),
            get_parser=float,
        )
        """Parameter attenuation"""

        self.connect_message()
Logging hadn't been started.
Activating auto-logging. Current session state plus future input saved.
Filename       : C:\Users\jenielse\.qcodes\logs\command_history.log
Mode           : append
Output logging : True
Raw input log  : False
Timestamping   : True
State          : active
Qcodes Logfile : C:\Users\jenielse\.qcodes\logs\221108-14924-qcodes.log

The .yaml file

The simplest .yaml file that is still useful, reads, in all its glory:

spec: "1.0"
devices:
  device 1:
    eom:
      GPIB INSTR:
        q: "\r"  # MAKE SURE! that this matches the terminator of the driver!
        r: "\r"
    error: ERROR
    dialogues:
      - q: "*IDN?"
        r: "QCoDeS, Weinschel 8320 (Simulated), 1337, 0.0.01"


resources:
  GPIB::1::INSTR:
    device: device 1

Note that since no physical connection is made, it doesn’t matter what interface we pretend to use (GPIB, USB, ethernet, serial, …). As a convention, we always write GPIB in the .yaml files. This simulates an instrument with no settable parameter; only an *IDN? response. This is enough to instantiate the instrument.

We save the above file as qcodes/instrument/sims/Weinschel_8320.yaml. Note that in this example we have cheated a bit and already written the file in that location.

Then we may connect to the simulated instrument.

There are two different ways we may tell pyvisa-sim which yaml file to make use of. We can either follow the official instructions and pass visalib as a string of the form “pathtoyamlfile@sim” where sim indicates that we make use of the simulated backend. This is however not very convenient when the yaml file is part of a package since we would need to know the absolute path. It is therefor also possible to use the argument pyvisa_sim_file. This can be given in two forms either just the name of the file such as Weinschel_8320.yaml in which case we search for the file in the qcodes.instrument.sims folder. Or alternatively in the format mymodule.mysubmodule:name_of_file e.g. qcodes.instrument.sims:Weinschel_8320.yaml in which case we search for the file in the supplied module.

[2]:
wein_sim = Weinschel8320(
    "wein_sim",
    address="GPIB::1::INSTR",  # This matches the address in the .yaml file
    pyvisa_sim_file="Weinschel_8320.yaml",
)
Connected to: QCoDeS Weinschel 8320 (Simulated) (serial:1337, firmware:0.0.01) in 0.07s

The test

Now we can write a useful test!

[3]:
import pytest

from qcodes.instrument_drivers.weinschel import Weinschel8320


# The following decorator makes the driver
# available to all the functions in this module
@pytest.fixture(scope="function", name="weinschel_driver_1")
def _weinschel_driver_1():
    wein_sim = Weinschel8320(
        "wein_sim",
        address="GPIB::1::65535::INSTR",
        pyvisa_sim_file="Weinschel_8320.yaml",
    )
    yield wein_sim

    wein_sim.close()


def test_init_v1(weinschel_driver_1):
    """
    Test that simple initialisation works
    """

    # There is not that much to do, really.
    # We can check that the IDN string reads back correctly

    idn_dict = weinschel_driver_1.IDN()

    assert idn_dict["vendor"] == "QCoDeS"

Save the test as tests/drivers/test_weinschel_8320.py.

Open a command line/console/terminal, navigate to the tests/drivers/ folder and run

>> pytest test_weinschel_8320.py

This should give you an output similar to

========================================= 1 passed in 0.73 seconds ==========================================

Congratulations! That was it.

Bonus example: including parameters in the simulated instrument

It is also possible to add queriable parameters to the .yaml file, but testing that you can read those back is of limited value. You should only add them if your driver needs them to instantiate, e.g. if it checks that some range or impedance is configured correctly on startup, or - more generally - if a part of your driver code that you’d like to test needs it to run.

For the sake of this example, let us add a test that the driver’s parameter’s validator will reject an attenuation of less than 0 dBm. Note that this concrete test is redundant, since we have separate tests for validators. It is, however, an excellent example to learn from.

First we update the .yaml file to contain a property matching the parameter.

spec: "1.0"
devices:
  device 1:
    eom:
      GPIB INSTR:
        q: "\r"  # MAKE SURE! that this matches the terminator of the driver!
        r: "\r"
    error: ERROR
    dialogues:
      - q: "*IDN?"
        r: "QCoDeS, Weinschel 8320 (Simulated), 1337, 0.0.01"

    properties:

      attenuation:
        default: 0
        getter:
          q: "ATTN? 1"  # the set/get commands have to simply be copied over from the driver
          r: "{:02.0f}"
        setter:
          q: "ATTN ALL {:02.0f}"

resources:
  GPIB::1::INSTR:
    device: device 1

Notice that we don’t include the the r: OK as the response of setting a property. This is in contrast to what https://pyvisa-sim.readthedocs.io/en/latest/definitions.html#properties does. The response of a successful setting of a parameter will not return ‘OK’.

Again we have cheated and already added that file to the above location.

Next we update the test script.

[4]:
import pytest

from qcodes.instrument_drivers.weinschel import Weinschel8320


# The following decorator makes the driver
# available to all the functions in this module
@pytest.fixture(scope="function", name="weinschel_driver_2")
def _weinschel_driver():
    wein_sim = Weinschel8320(
        "wein_sim", address="GPIB::1::INSTR", pyvisa_sim_file="Weinschel_8320.yaml"
    )
    yield wein_sim

    wein_sim.close()


def test_init_v2(driver):
    """
    Test that simple initialisation works
    """

    # There is not that much to do, really.
    # We can check that the IDN string reads back correctly

    idn_dict = driver.IDN()

    assert idn_dict["vendor"] == "QCoDeS"


def test_attenuation_validation(weinschel_driver_2):
    """
    Test that incorrect values are rejected
    """

    bad_values = [-1, 1, 1.5]

    for bv in bad_values:
        with pytest.raises(ValueError):
            weinschel_driver_2.attenuation(bv)

Open a command line/console/terminal, navigate to the tests/drivers/ folder and run

>> pytest test_weinschel_8320.py

This should give you an output similar to

========================================= 2 passed in 0.73 seconds ==========================================

That’s it!