Skip to navigation

Drivers

Drivers are the translators between Stanza’s high-level device operations and the specific commands your hardware understands. Stanza includes drivers for common instruments (QDAC-II, OPX) and makes it straightforward to add support for custom hardware.

Built-in Drivers

QDAC-II: Voltage Control and DC Measurement

The QDAC-II is a 24-channel DAC with built-in current measurement. It’s perfect for DC gate control and low-noise current measurements.

Basic setup for voltage control:

instruments:
- name: qdac-control
type: CONTROL
driver: qdac2
ip_addr: 192.168.1.100
slew_rate: 0.5 # V/s - safe ramp rate

Adding current measurement:

instruments:
- name: qdac-control
type: CONTROL
driver: qdac2
ip_addr: 192.168.1.100
slew_rate: 0.5
- name: qdac-measure
type: MEASUREMENT
driver: qdac2
ip_addr: 192.168.1.100
measurement_duration: 10e-3 # 10 ms integration
sample_time: 100e-6 # 100 μs per sample
current_range: "LOW" # For pA-nA currents

Or use one instrument for both:

instruments:
- name: qdac
type: GENERAL # Both control and measurement
driver: qdac2
ip_addr: 192.168.1.100
slew_rate: 0.5
measurement_duration: 10e-3
sample_time: 100e-6

Key parameters:

  • slew_rate: How fast voltages ramp (V/s). Lower is safer but slower.
  • measurement_duration: Total time to measure (seconds). Longer = less noise.
  • sample_time: Integration time per sample. Smaller = more samples.
  • current_range: "LOW" (±1 μA) or "HIGH" (±100 μA)

OPX: Fast DC Measurements

The OPX driver provides DC current measurement with pause-based synchronization. It’s useful when you need faster measurements than QDAC can provide:

instruments:
# QDAC for voltage control
- name: qdac-control
type: CONTROL
driver: qdac2
ip_addr: 192.168.1.100
slew_rate: 0.5
# OPX for DC current measurement
- name: opx
type: MEASUREMENT
driver: opx
ip_addr: 192.168.1.200
measurement_duration: 1e-3
sample_time: 100e-6
measurement_channels: [1, 2]
machine_type: "OPX1000"

The OPX driver automatically generates QUA programs for pause-based measurements. During sweeps, it pauses between voltage steps, measures current, and streams data back.

Example sweep with OPX:

@routine
def fast_charge_diagram(ctx, gate_x, gate_y, contact, session=None):
"""Fast charge diagram with OPX current measurement."""
device = ctx.resources.device
vx = np.linspace(-2.0, -0.5, 200)
vy = np.linspace(-2.0, -0.5, 200)
# OPX measures DC current at the specified contact
v_data, i_data = device.sweep_2d(
gate_x, vx.tolist(),
gate_y, vy.tolist(),
contact, # Must be a contact configured with OPX measure_channel
session=session
)
return {"voltages": v_data, "currents": i_data}

QSwitch: Digital Breakout Box

QSwitch is a programmable relay matrix for routing signals between instruments and device pads. Each QSwitch channel has 10 relays: relay 0 is ground, relays 1-9 connect to different instruments or buses. This lets you reconfigure measurement setups without manual cable swapping.

Basic configuration:

gates:
G1: {type: BARRIER, control_channel: 1, breakout_channel: 1, v_lower_bound: -3.0, v_upper_bound: 3.0}
G2: {type: PLUNGER, control_channel: 2, breakout_channel: 2, v_lower_bound: -3.0, v_upper_bound: 3.0}
contacts:
DRAIN: {type: DRAIN, control_channel: 3, measure_channel: 1, breakout_channel: 3, v_lower_bound: -3.0, v_upper_bound: 3.0}
instruments:
- name: qdac2
type: GENERAL
driver: qdac2
ip_addr: 192.168.1.100
port: 5025
slew_rate: 100.0
breakout_line: 9 # This instrument connects through relay 9
- name: qswitch
type: BREAKOUT_BOX
driver: qswitch
ip_addr: 192.168.1.102
port: 5025

The breakout_channel on each pad maps to a QSwitch channel. The breakout_line on each instrument indicates which relay connects to that instrument.

High-level device operations:

@routine
def initialize_breakout(ctx):
device = ctx.resources.device
# Ground all channels for safety
device.ground_breakout_lines()
# Unground channels to enable control
device.unground_breakout_lines()
# Connect all pads to their configured instruments
device.connect_breakout_lines()
# Disconnect when done
device.disconnect_breakout_lines()

Direct instrument access for fine-grained control:

from stanza.utils import device_from_yaml
# Load device and get QSwitch instance
device = device_from_yaml("device.yaml")
qswitch = device.breakout_box_instrument
# Single channel operations
qswitch.set_grounded("G1") # Connect G1 to ground (relay 0)
qswitch.set_ungrounded("G1") # Disconnect G1 from ground
qswitch.set_connected("G1", 9) # Connect G1 to relay 9 (QDAC)
qswitch.set_disconnected("G1", 9) # Disconnect G1 from relay 9
# Check state
is_grounded = qswitch.get_grounded("G1")
is_connected = qswitch.get_connected("G1", 9)
# Multi-channel operations (faster than looping)
qswitch.set_grounded(["G1", "G2", "DRAIN"])
qswitch.set_connected(["G1", "G2"], 9)
# Query multiple channels - Get the connection status of G1 and G2 to line 9
states = qswitch.get_connected(["G1", "G2"], 9) # Returns [True, False]

Safety pattern:

@routine
def safe_measurement(ctx, gate, voltages, contact):
device = ctx.resources.device
qswitch = device.breakout_box_instrument
try:
# Connect measurement path
qswitch.set_ungrounded([gate, contact])
device.connect_breakout_lines()
# Run measurement
v_data, i_data = device.sweep_1d(gate, voltages, contact)
return {"voltages": v_data, "currents": i_data}
finally:
# Set all pads to zero volts
device.zero()
# Always ground channels when done
device.disconnect_breakout_lines()
device.ground_breakout_lines()

Key methods:

  • set_grounded(channel) / set_ungrounded(channel): Control relay 0 (ground)
  • set_connected(channel, line) / set_disconnected(channel, line): Control specific relay
  • get_grounded(channel) / get_connected(channel, line): Query relay state
  • All methods accept single channel strings or lists for batch operations

The QSwitch uses SCPI commands over TCP/IP. Each channel-relay pair is addressed as channel!relay in SCPI notation (e.g., 1!9 for channel 1, relay 9).

Testing Without Hardware

Enable simulation mode to develop code before hardware access:

instruments:
- name: qdac-sim
type: GENERAL
driver: qdac2
ip_addr: 127.0.0.1
is_simulation: true
sim_file: "./sim_config.yaml"

Create sim_config.yaml to define simulated responses:

# Simulation configuration
gates:
LEFT_BARRIER:
pinchoff_voltage: -0.85
leakage_current: 1e-13
RIGHT_BARRIER:
pinchoff_voltage: -0.92
leakage_current: 2e-13
contacts:
DRAIN:
noise_level: 1e-12

Now your code runs without hardware, using these simulated characteristics.

Writing a Custom Driver

Need to support custom hardware? Here’s how to write a driver.

Step 1: Understand What You Need

Every driver implements one or both protocols:

  • ControlInstrument: For setting/reading voltages
  • MeasurementInstrument: For measuring currents

Step 2: Create Your Driver File

Create stanza/drivers/mycustom.py:

from stanza.base.instruments import BaseInstrument
from stanza.base.channels import ControlChannel, MeasurementChannel, ChannelConfig
import socket
class MyCustomDriver(BaseInstrument):
"""Driver for my custom instrument."""
def __init__(self, name: str, ip_addr: str, port: int = 5025,
slew_rate: float = 1.0):
super().__init__(name=name)
self.ip_addr = ip_addr
self.port = port
self.default_slew_rate = slew_rate
# Connect to instrument
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.connect((ip_addr, port))
def set_voltage(self, pad: str, voltage: float) -> None:
"""Set voltage on a pad."""
channel = self.get_channel(pad)
# Validate bounds
v_min, v_max = channel.config.voltage_range
if not (v_min <= voltage <= v_max):
raise ValueError(f"Voltage {voltage} out of bounds [{v_min}, {v_max}]")
# Send to hardware
cmd = f"SOUR{channel.control_channel}:VOLT {voltage}\n"
self.sock.sendall(cmd.encode())
# Update parameter
channel.set_parameter("voltage", voltage)
def get_voltage(self, pad: str) -> float:
"""Get current voltage on a pad."""
channel = self.get_channel(pad)
# Query hardware
cmd = f"SOUR{channel.control_channel}:VOLT?\n"
self.sock.sendall(cmd.encode())
response = self.sock.recv(1024).decode().strip()
voltage = float(response)
channel.set_parameter("voltage", voltage)
return voltage
def get_slew_rate(self, pad: str) -> float:
"""Get slew rate for a pad."""
channel = self.get_channel(pad)
return channel.get_parameter_value("slew_rate")
def measure(self, pad: str) -> float:
"""Measure current at a pad."""
channel = self.get_channel(pad)
# Trigger measurement
cmd = f"MEAS{channel.measure_channel}:CURR\n"
self.sock.sendall(cmd.encode())
response = self.sock.recv(1024).decode().strip()
return float(response)
def close(self) -> None:
"""Close connection."""
self.sock.close()

Step 3: Use Your Driver

Add it to your device configuration:

instruments:
- name: my-instrument
type: GENERAL
driver: mycustom # Matches filename (mycustom.py)
ip_addr: 192.168.1.200
port: 5025
slew_rate: 0.5

Stanza automatically discovers and instantiates your driver.

Example: Simple Serial Instrument

Here’s a complete driver for a serial DAC:

from stanza.base.instruments import BaseControlInstrument
import serial
class SerialDAC(BaseControlInstrument):
"""Driver for a simple serial DAC."""
def __init__(self, name: str, port: str, baudrate: int = 9600,
slew_rate: float = 1.0):
super().__init__(name=name)
self.default_slew_rate = slew_rate
# Open serial port
self.serial = serial.Serial(
port=port,
baudrate=baudrate,
timeout=1.0
)
def set_voltage(self, pad: str, voltage: float) -> None:
"""Set voltage on a channel."""
channel = self.get_channel(pad)
# Validate
v_min, v_max = channel.config.voltage_range
if not (v_min <= voltage <= v_max):
raise ValueError(f"Voltage out of bounds")
# Send command (example protocol)
cmd = f"SET {channel.control_channel} {voltage}\n"
self.serial.write(cmd.encode())
# Wait for acknowledgment
response = self.serial.readline().decode().strip()
if response != "OK":
raise RuntimeError(f"DAC error: {response}")
channel.set_parameter("voltage", voltage)
def get_voltage(self, pad: str) -> float:
"""Read voltage from a channel."""
channel = self.get_channel(pad)
cmd = f"GET {channel.control_channel}\n"
self.serial.write(cmd.encode())
response = self.serial.readline().decode().strip()
voltage = float(response)
channel.set_parameter("voltage", voltage)
return voltage
def get_slew_rate(self, pad: str) -> float:
"""Get slew rate."""
return self.default_slew_rate
def close(self) -> None:
"""Close serial port."""
self.serial.close()

Use it:

instruments:
- name: serial-dac
type: CONTROL
driver: serial_dac # Filename: serial_dac.py
port: "/dev/ttyUSB0"
baudrate: 115200
slew_rate: 0.5

How Sweeps Work

When you call device.sweep_1d(), here’s what happens:

  1. Stanza generates voltage points from your start/stop/n_points
  2. For each voltage:
    • Calls control_instrument.set_voltage(gate, voltage)
    • Waits for voltage to settle (based on slew rate)
    • Calls measurement_instrument.measure(contact)
  3. Returns arrays of voltages and measurements
  4. If session provided, logs the data automatically

Your driver just needs to implement set_voltage() and measure(). Stanza handles the rest.

Driver Best Practices

Validate inputs: Check voltage bounds before sending to hardware.

def set_voltage(self, pad: str, voltage: float) -> None:
channel = self.get_channel(pad)
v_min, v_max = channel.config.voltage_range
if not (v_min <= voltage <= v_max):
raise ValueError(f"Voltage {voltage} out of range [{v_min}, {v_max}]")
# Now safe to send to hardware
...

Handle errors gracefully: Catch and re-raise with context.

from stanza.exceptions import InstrumentError
def measure(self, pad: str) -> float:
try:
# Talk to hardware
...
except socket.timeout:
raise InstrumentError(f"Timeout measuring {pad}")
except Exception as e:
raise InstrumentError(f"Error measuring {pad}: {e}")

Implement close(): Clean up resources.

def close(self) -> None:
"""Clean up connections."""
if hasattr(self, 'sock') and self.sock:
self.sock.close()
if hasattr(self, 'serial') and self.serial:
self.serial.close()

Support simulation: Add a simulation flag for testing.

def __init__(self, name: str, ip_addr: str, is_simulation: bool = False):
super().__init__(name=name)
self.is_simulation = is_simulation
if not is_simulation:
# Connect to real hardware
self.sock = socket.socket(...)
else:
# Simulation mode
self.simulated_voltages = {}
def set_voltage(self, pad: str, voltage: float) -> None:
if self.is_simulation:
self.simulated_voltages[pad] = voltage
else:
# Real hardware command
...

Add logging for debugging: Use Python’s logging module.

import logging
logger = logging.getLogger(__name__)
def set_voltage(self, pad: str, voltage: float) -> None:
logger.debug(f"Setting {pad} to {voltage:.3f} V")
...
logger.debug(f"Set complete")

Testing Your Driver

Write tests to verify your driver works:

import pytest
from stanza.drivers.mycustom import MyCustomDriver
from stanza.base.channels import ControlChannel, ChannelConfig
def test_set_get_voltage():
"""Test voltage setting and reading."""
driver = MyCustomDriver(
name="test",
ip_addr="127.0.0.1",
is_simulation=True
)
# Add a channel
config = ChannelConfig(
name="G1",
voltage_range=(-3.0, 3.0),
control_channel=1
)
channel = ControlChannel(name="G1", config=config, control_channel=1)
driver.add_channel("G1", channel)
# Test
driver.set_voltage("G1", -1.5)
voltage = driver.get_voltage("G1")
assert voltage == pytest.approx(-1.5)
driver.close()

Test with actual device configuration:

from stanza.utils import device_from_yaml
def test_driver_in_device():
"""Test driver with full device configuration."""
device = device_from_yaml("test_device.yaml")
# Test jump
device.jump({"G1": -1.5, "G2": -0.8})
# Test check
v = device.check("G1")
assert v == pytest.approx(-1.5)
# Test measure
i = device.measure("DRAIN")
assert isinstance(i, float)
# Test sweep
voltages = [-2.0, -1.0, 0.0]
v_data, i_data = device.sweep_1d("G1", voltages, "DRAIN")
assert len(v_data) == 3
assert len(i_data) == 3

Troubleshooting

“Cannot connect to instrument”: Check IP address and network connection.

import socket
# Test if you can reach the instrument
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(2.0)
try:
sock.connect(("192.168.1.100", 5025))
print("Connection successful")
except socket.timeout:
print("Connection timeout - check IP address")
except socket.error as e:
print(f"Connection failed: {e}")
finally:
sock.close()

“Voltage out of bounds”: Check your device configuration voltage limits match your hardware.

“No response from instrument”: Verify your command syntax matches the instrument’s protocol. Check the manual or use the instrument’s native software to test commands.

“Channel not found”: Make sure channel numbers in your YAML match the driver’s expectations.

Next Steps