Adding a Driver

This page explains the recommended workflow for creating a new instrument driver for piec. The majority of the implementation work is handled by an AI coding assistant — your job is to gather the right materials, provide them clearly, and then review and validate the output.

The Code Rules Reference at the bottom of this page defines every architectural rule the driver must follow. Read it before starting.


Step 1: Gather your materials

Before opening an AI session, collect the following:

  1. The instrument programming manual — the PDF (or equivalent) that documents all remote-control commands for the instrument.

  2. The parent class file — identify which instrument category your instrument belongs to (e.g. AWG, oscilloscope, sourcemeter, lock-in amplifier) and locate its interface file in src/piec/drivers/. Examples:

    Instrument type

    Parent file

    Arbitrary Waveform Generator

    drivers/awg/awg.py

    Oscilloscope

    drivers/oscilloscope/oscilloscope.py

    Source Meter

    drivers/sourcemeter/sourcemeter.py

    Lock-in Amplifier

    drivers/lockin/lockin.py

    Digital Multimeter

    drivers/dmm/dmm.py

  3. A convenience base class (optional) — piec provides convenience classes that handle common low-level communication boilerplate. Using one is optional — a driver that inherits only from the instrument-type parent is perfectly valid.

    Class

    File

    Use when

    Scpi

    drivers/scpi.py

    The instrument communicates over VISA (GPIB, USB-TMC, Ethernet) and follows the SCPI-99 standard. Gives you idn(), reset(), clear(), error(), wait(), etc. for free.

    Digilent

    drivers/digilent.py

    The instrument is an MCC/Digilent DAQ device using the Universal Library (mcculw). Replaces VISA entirely with UL board-number addressing.

    # With Scpi convenience class
    from .awg import Awg
    from ..scpi import Scpi
    
    class RigolDg1000(Awg, Scpi):
        ...
    
    # With Digilent convenience class
    from .daq import Daq
    from ..digilent import Digilent
    
    class Usb231(Daq, Digilent):
        ...
    
    # No convenience class — implement everything directly
    from .awg import Awg
    
    class MyCustomAwg(Awg):
        ...
    

    If you are unsure which applies, describe the instrument’s communication interface in the AI prompt and let it determine the right inheritance.

  4. DRIVER_DEVELOPMENT_GUIDE.md — this file (included at the bottom of this page) is the ruleset the AI must follow. Attach it to every session.


Step 2: Create the file skeleton

Before running the AI session, create an empty driver file in the correct location:

src/piec/drivers/<category>/<model_name>.py

For example, a Rigol DG1000 AWG would go in src/piec/drivers/awg/rigol_dg1000.py.

Use src/piec/drivers/example/specific_example.py as a reference for the expected structure — class definition, AUTODETECT_ID, class attributes, and method stubs.


Step 3: Run the AI coding session

Open your preferred AI coding assistant (e.g. Google Gemini, GitHub Copilot, Claude) and attach the following files:

File

Why

DRIVER_DEVELOPMENT_GUIDE.md

The rules the driver must follow

drivers/<category>/<category>.py

The parent interface to implement

drivers/scpi.py or drivers/digilent.py

Optional — include the relevant convenience class file only if your instrument uses it

Instrument programming manual

The primary source of all command strings and device capabilities

Instrument user manual

Optional — attach alongside the programming manual if you need to extract hardware specs or features not covered there (e.g. channel count, voltage ranges). The programming manual usually covers this.

Then provide the following prompt, filling in the blanks:


Primary prompt (implementation)

You are tasked with creating a new instrument driver for the piec Python package.

**Instrument:** <Manufacturer> <Model Number> (e.g. Rigol DG1000)
**Instrument type:** <Type> (e.g. Arbitrary Waveform Generator)
**Parent class:** <ClassName> from the attached parent file (e.g. Awg)
**Uses SCPI protocol:** <Yes / No>

**Attached files:**
- DRIVER_DEVELOPMENT_GUIDE.md — read this first. It defines every rule the driver must follow.
- <category>.py — the parent class. Implement every method defined in it.
- scpi.py — include only if the instrument uses SCPI. If it does not, omit this file and implement communication directly.
- <manual filename> — use this as the sole source for all command strings.

**Your task:**
1. Implement all methods from the parent class using commands from the attached manual.
   Only use commands you can find in the manual — do not invent or guess command strings.
2. Fill out all class attributes (capabilities and limits) as described in the guide.
3. Set AUTODETECT_ID to the unique model substring returned by the instrument's *IDN? response.
   If the manual does not show an example *IDN? response, set it to None.
4. Do not write any # inline comments — use docstrings only.
5. Follow the method naming, signature, and default-parameter rules in the guide exactly.


Step 4: Register the driver

Once the driver file is in place:

  1. Add the import to the __init__.py in the category folder so piec can discover it.

  2. Verify autodetection — the AUTODETECT_ID string must be a unique substring of the instrument’s identification response. You can test this by connecting to the instrument and calling .idn() on it.


Step 5: Validate with the test notebook

Each driver category has a Jupyter test notebook in its folder (e.g. drivers/awg/awg_test.ipynb). Use this to verify your driver against the virtual instrument first, then against real hardware.

The notebook follows this structure:

  • Section 0 — connection and .idn() check

  • Sections 1–2 — base Instrument communication tests

  • Sections 3+ — driver-specific method tests (one cell per method)

  • Final section — cleanup and disconnect

When adding a new driver, copy src/piec/drivers/example/example_test.ipynb as a starting template. Add a cell for each method your driver implements. Run each cell from top to bottom sequentially and confirm expected behavior before opening a pull request.


Before contributing

If you are planning to submit this driver as a pull request, we strongly recommend the following before forking the repository and opening a PR:

  • Test against real hardware. Run the test notebook (Step 5) end-to-end with a physical instrument connected. Every cell should pass without errors or unexpected responses.

  • Cross-check all command strings. For each method, verify the command sent to the instrument against the manual one more time — AI-generated command strings can be subtly wrong.

  • Check the autodetect ID. Connect to the instrument, call .idn(), and confirm that AUTODETECT_ID is a substring of the actual response string.

  • Run the automated test suite. From the repository root: pytest tests/ -v. All tests must pass before submitting.

A driver that has only been verified against the virtual instrument is not ready for a pull request. Physical hardware verification is the minimum bar for contribution.


Code Rules Reference

The following is the complete DRIVER_DEVELOPMENT_GUIDE.md. Every rule here is enforced during code review.

Instrument Driver Development Guide

This guide outlines the strict requirements and conventions for creating new instrument drivers within the piec library. Adhering to these rules ensures a globally consistent, interface-compliant, and minimal codebase across all supported instruments.

1. The 3-Level Architecture

PIEC drivers follow a strict 3-level hierarchy to ensure consistency and modularity.

Level 1: The Foundation (Instrument)

All instruments in the library MUST inherit from the base Instrument class. This class handles the core VISA communication and standard PIEC behavior.

Convenience Classes (e.g., Scpi)

Scpi is a convenience class, not a structural level. It provides vetted implementations of standard IEEE 488.2 / SCPI-99 functions (like idn, reset, clear, error, wait, self_test, operation_complete, initialize) that most SCPI-compliant instruments share.

How it works with Level 2 base classes:

Every Level 2 base class (e.g., Oscilloscope, Awg) already defines skeleton versions of these SCPI commands — empty methods with full docstrings but no implementation. This means:

  • A driver that inherits only from the Level 2 class has the correct interface and can override each skeleton with its own native protocol commands.

  • A driver that also inherits from Scpi gets the real SCPI implementations for free via MRO — no extra work needed for standard *IDN?, *RST, *CLS, etc.

[!IMPORTANT] Verification: Always cross-check the instrument manual. If your instrument is SCPI-compliant but does not support a standard Scpi method (e.g., *RST doesn’t reset properly), or uses a different command string, you MUST override the method in your Level 3 driver.

Level 2: Instrument-Type Interface (example.py, oscilloscope.py)

These files define the Template/Interface for an entire category of instruments.

  • They list all requirements (methods and attributes) for that type.

  • They include skeleton versions of the standard SCPI commands (idn, reset, clear, etc.) so that the interface is complete even without Scpi inheritance.

  • They contain no specific SCPI command strings — only the “vocabulary” of the instrument type.

Level 3: Specific Instrument Model (agilent_33220a.py)

This is the Actual Implementation of the driver.

  • Inherits from the Level 2 Category (e.g., Awg) and optionally from the Scpi convenience class.

  • Implements the Level 2 interface using specific hardware commands.

Path A — SCPI-compliant instrument (most common):

from .awg import Awg
from ..scpi import Scpi

# Inherits real SCPI implementations (idn, reset, clear, etc.) from Scpi
class Agilent33220a(Awg, Scpi):
    AUTODETECT_ID = "33220A"
    # Only implement the instrument-specific methods...

Path B — Non-SCPI instrument (proprietary protocol):

from .oscilloscope import Oscilloscope

# No Scpi mixin — override the skeletons with native protocol commands
class MyProprietaryScope(Oscilloscope):
    AUTODETECT_ID = "PROPSCOPE"

    def idn(self):
        return self.instrument.query("ID?")

    def reset(self):
        self.instrument.write("FACTORY_RESET")
        self.set_trigger_sweep("AUTO")  # ensure AUTO mode per the contract
        self._initialize_state()
    # ... override remaining skeletons ...

2. Constructor (__init__)

  • DO NOT write a custom __init__ method if its only purpose is to call super().__init__(resource_name, **kwargs). The base Instrument class handles standard initialization.

  • IF you must write a custom constructor for hardware configuration queries, it must take *args, **kwargs and pass them exactly to super():

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # Custom queries here...

3. Autodetection (AUTODETECT_ID)

Every driver MUST (if possible) define a class-level string attribute named AUTODETECT_ID. This is a unique substring expected to be returned by the instrument when queried with an .idn() command.

    AUTODETECT_ID = "MODEL_1234"

4. Class Attributes (Capabilities & Limits)

Class attributes define the valid parameters an instrument can accept. The parent base classes (e.g., Oscilloscope, Awg) define a strict vocabulary of these attribute names.

  • Drivers MUST explicitly assign their supported capabilities using these exact class attribute names (e.g., frequency, voltage, waveform).

  • NEVER introduce new vocabulary terms (like waveform = ['WEIRD_WAVE']) in the child class that do not exist in the parent class’s definitions.

Attribute Formatting Rules: The class attributes must follow a specific syntax based on what kind of parameter they restrict:

  1. Lists (Discrete Sets): If the argument takes a limited number of defined values, use a list of the appropriate type. Examples:

    channel = [1, 2]
    waveform = ['SIN', 'SQU', 'RAMP']
    
  2. Tuples (Ranges): If the argument accepts any continuous float/int value within a range, use a geometric tuple (min, max). Examples:

    amplitude = (0.01, 10.0) # Vpp
    offset = (-5.0, 5.0) 
    
  3. Dictionaries (Dependent Arguments): If the valid range or options of an argument depend on the state of another argument (e.g., the maximum frequency is restricted depending on the waveform selected), write this as a dictionary. The primary key is the name of the argument it depends on:

    frequency = {
        'waveform': {
            'SIN': (1e-6, 30e6),
            'SQU': (1e-6, 10e6),
            'DC': None
        }
    }
    

    (If a parameter has no known class attribute boundaries, set it to None.)

5. Method Conventions: set_, configure_, and run_

Function naming strictly determines scope:

  • set_<property> Methods: Must perform a SINGLE action. For instance, set_frequency only changes the frequency. They correspond directly to SCPI writes assigning one explicit hardware parameter.

  • get_<property> Methods: Must RETURN a single, unformatted value (e.g., a status bit, a scalar measurement).

  • read_<property> Methods: Must RETURN formatted or complex data (e.g., an array of waveform points, a multi-value response, or a post-processed string).

    • Formatting: The specific data structure (typically a pandas.DataFrame) must adhere to the return specification detailed in the parent class’s docstring.

  • configure_<module> Methods: Must perform MULTIPLE actions by wrapping and calling several individual set_ functions. For instance, configure_waveform might call set_waveform, set_frequency, and set_amplitude.

    • For EVERY configure_ command, initialize all non-essential arguments to None in the signature, and only invoke the corresponding set_ method if the parameter is not None.

  • quick_read Method: A specialized convenience function (common in Oscilloscopes) used to return whatever data is currently ready or displayed on the hardware (e.g., a cursor value or mean measurement). It is used for fast, unformatted polling.

  • run_<routine> Methods: Used for hardware-executed routines where the instrument performs a complete operation internally (at hardware speed) and then returns the results. The key distinction is that a run_ method triggers autonomous instrument behavior — unlike set_ (which only writes a parameter) or configure_ (which just calls multiple set_ methods). Examples:

    • run_voltage_sweep(...) — the sourcemeter executes the full I-V sweep internally and returns all data points at once.

    • run_current_sweep(...) — same for current sweep.

    • This is fundamentally different from manually looping set_source_voltage + quick_read in Python.

6. Method Signatures and Default Parameters

  • Method signatures must perfectly mirror the parent interface.

  • DO NOT provide arbitrary magnitude or state defaults in your set_ functions. Parameters like voltage=0.0, waveform="SIN", or frequency=1000 must be set to None in the signature.

  • Drivers must enforce explicit parameter assignments, looking like:

    def set_voltage(self, channel=1, voltage=None):
        if voltage is None:
            raise ValueError("voltage must be provided")
        self.instrument.write(f"SOUR{channel}:VOLT {voltage}")
    
  • EXCEPTIONS:

    • Structural/targeting defaults like channel=1.

    • Boolean flag toggles like on=True, ac=False, or four_wire=False.

    • Convenience configure_ Methods: These are allowed to retain sensible default values if those defaults are established in the parent interface.

7. Communication & Protocol Convenience

  • Read variables using self.instrument.query("SCPI?").

  • Write variables using self.instrument.write("SCPI").

  • The Role of Scpi: Inheritance from Scpi is a convenience to avoid rewriting the same basic *IDN?, *RST, *CLS, *ESR?, *WAI, *TST?, *OPC? commands. However, the driver developer is responsible for verifying that the inherited reset(), clear(), etc., map correctly to the instrument’s manual.

  • When to skip Scpi: If your instrument does not speak SCPI at all (e.g., it uses a proprietary serial/binary protocol), simply inherit from the Level 2 category class alone. The skeleton methods defined there give you the correct interface — just override each one with your native protocol commands.

8. Automatic State Tracking

The piec framework automatically tracks the “last set” value of any parameter that has a corresponding class attribute.

  • Whenever a set_<property>(value=...) method finishes successfully, the decorator updates self._current_<property> with that value.

  • These attributes are useful for dependent parameter checks (handled by the framework) and for driver-side conditional logic.

  • Example: If you need to know the current mode to set the correct voltage range, you can access self._current_mode.

8a. Automatic String Lowercasing

The auto_check_params decorator automatically converts all string arguments to lowercase before they are passed into your driver method. This means:

  • Driver implementations should always expect lowercase strings (e.g. 'sin', not 'SIN').

  • You do not need to call .lower() inside your methods — the framework handles it.

  • Validation is case-insensitive regardless of how class attribute values are written — the validation function lowercases both sides before comparing, so 'sin', 'SIN', and 'Sin' all pass against ['SIN', 'SQU', 'RAMP'] or ['sin', 'squ', 'ramp'] equally.

  • If your instrument requires an uppercase string in its command (e.g. the instrument rejects FUNC sin), call .upper() on the argument inside your method before writing it to the instrument.

[!CAUTION]
Initial State is None: Upon first connection, all tracked attributes are initialized to None. This means the first few set_ calls (where one parameter depends on another) might skip validation or cause errors if your logic expects a value. Always perform a hardware query in __init__ (see Rule 2) to synchronize these states immediately.

9. Optional Methods

PIEC has two mechanisms for optional methods, ensuring measurement code never needs to change regardless of which specific driver is connected.

9a. @optional Decorator (Parent Base Classes)

Some standard instrument features are not universally supported across all models. Use @optional in the category base class to mark these:

from ..instrument import Instrument, optional

class Oscilloscope(Instrument):
    @optional
    def set_channel_impedance(self, channel, channel_impedance):
        """Sets the channel impedance, e.g. 1MOhm, 50Ohm"""
  • Only use @optional in base classes (e.g., Oscilloscope, Awg), never in specific drivers.

  • If a specific driver supports the feature, override the method as usual.

  • If it doesn’t, do nothing — calls will print [OPTIONAL SKIP] and return None.

9b. Automatic Optional (Child-Specific Methods)

Any public method that a specific driver defines beyond what the parent class provides is automatically treated as optional. If measurement code calls that method on a different driver that doesn’t have it, it gracefully skips.

# In a Keysight-specific driver:
class KeysightDSOX3024a(Oscilloscope, Scpi):
    def read_statistics(self):  # Not in parent Oscilloscope — auto-optional
        return self.instrument.query(":MEAS:STAT?")

This works because all standard methods exist on every driver via the parent class. Only truly missing child-specific methods trigger the skip mechanism.

10. Argument Mapping

In cases where the Level 2 interface uses a generic argument (e.g., channel=1, mode='CONSTANT') but the hardware expects a different value (e.g., channel='A', mode='FIXED'), the Level 3 driver is responsible for its own internal mapping:

    def set_mode(self, channel, mode):
        # Map generic PIEC mode to specific hardware command
        mode_map = {'CONSTANT': 'FIXED', 'SWEEP': 'SWE'}
        hw_mode = mode_map.get(mode)
        if hw_mode is None:
             raise ValueError(f"Mode {mode} not supported by this instrument")
        self.instrument.write(f"SOUR{channel}:FUNC:{hw_mode}")

This ensures the user’s measurement code can remain model-agnostic.

11. Repository Folder Structure

To keep the drivers directory organized, follow this nesting pattern:

  1. Category Folder: (e.g., drivers/oscilloscope/)

  2. Interface File: Named after the category (e.g., oscilloscope.py).

  3. Model Drivers: Put directly in the category folder, named after the model (e.g., dsox3024a.py).

piec/
  drivers/
    oscilloscope/
      oscilloscope.py       (Level 2 Interface)
      dsox3024a.py          (Level 3 Driver - Keysight)
      tds6604.py            (Level 3 Driver - Tektronix)