Instrument Control

How to Control a Keysight DMM in Python

Control a Keysight 34461A or 34465A from Python: SCPI setup, NPLC and range selection, four-wire resistance, and recording readings as measurements.

JJulien Buteau
beginner12 min readMarch 14, 2026

The Keysight 34461A and 34465A are the workhorse bench DMMs on most test benches. They speak SCPI over USB or LAN, so Python talks to them without vendor software. This guide covers connection, the settings that actually affect your numbers, and turning readings into recorded measurements.

Prerequisites

  • Python 3.10+
  • A Keysight 34460A/34461A/34465A/34470A on USB or Ethernet
  • pip install pyvisa pyvisa-py

Connecting

keysight/connect.py
import pyvisarm = pyvisa.ResourceManager("@py")print(rm.list_resources())dmm = rm.open_resource("TCPIP::192.168.1.100::INSTR")dmm.timeout = 10000print(dmm.query("*IDN?").strip())# Keysight Technologies,34461A,MY59001234,A.03.01-02.40-03.01-00.52-03-01

On USB the resource looks like USB0::0x2A8D::0x1301::MY59001234::INSTR. The 2A8D vendor ID is Keysight; 1301 is the 34461A.

Reading a Voltage

The shortest correct sequence:

keysight/measure.py
dmm.write("*RST")dmm.write("*CLS")voltage = float(dmm.query(":MEAS:VOLT:DC? 10,0.0001"))print(f"{voltage:.6f} V")

The two arguments to :MEAS:VOLT:DC? are range and resolution. Passing them explicitly matters more than it looks, and that is the next section.

The Three Settings That Change Your Numbers

Range. Autorange is convenient and slow, because the meter hunts on every reading. In a production test where the expected value is known, fix the range:

keysight/range.py
dmm.write(":CONF:VOLT:DC 10")     # 10 V range, fixeddmm.write(":VOLT:DC:RANG:AUTO OFF")

Fixing the range typically takes a reading from tens of milliseconds to a few milliseconds, which is significant when a test takes a hundred readings.

NPLC (integration time). This is the single biggest lever on noise versus speed. NPLC is measured in power line cycles:

NPLCReading time (50 Hz)Noise rejectionUse for
0.02~0.4 msPoorFast screening
0.2~4 msModerateGeneral production
1~20 msGood, rejects line noiseDefault for accuracy
10~200 msVery goodLow-level, calibration
100~2 sBestMetrology
keysight/nplc.py
dmm.write(":VOLT:DC:NPLC 1")

NPLC of 1 or above rejects mains hum, because the integration window is a whole number of line cycles. Below 1, line noise starts appearing in your readings and people blame the board.

Autozero. The meter periodically measures its own offset. Accurate, but it roughly halves throughput. For a fast production test where you have characterised the drift:

keysight/autozero.py
dmm.write(":VOLT:DC:ZERO:AUTO ONCE")  # Take one zero, then hold

Four-Wire Resistance

Two-wire resistance includes your lead resistance, which is fine at kilohms and useless at milliohms. For anything below about 100 Ω, use four-wire:

keysight/four_wire.py
dmm.write(":CONF:FRES 100")        # Four-wire, 100 Ω rangedmm.write(":FRES:NPLC 10")         # Slow and quiet for low valuesdmm.write(":FRES:OCOM ON")         # Offset compensation, for thermal EMFresistance = float(dmm.query(":READ?"))print(f"{resistance:.6f} Ω")

Offset compensation takes two readings with opposite current polarity and cancels thermoelectric voltages at the junctions. On a weld or busbar measurement it is the difference between a repeatable number and a drifting one.

A Reusable Plug

plugs/dmm.py
45 lines
import pyvisaclass KeysightDmm:    """Keysight 34461A/34465A over SCPI."""    RESOURCE = "TCPIP::192.168.1.100::INSTR"    def __init__(self):        rm = pyvisa.ResourceManager("@py")        self.instr = rm.open_resource(self.RESOURCE)        self.instr.timeout = 10000        self.instr.write("*RST")        self.instr.write("*CLS")    def dc_voltage(self, range_v: float = 10, nplc: float = 1) -> float:        self.instr.write(f":CONF:VOLT:DC {range_v}")        self.instr.write(f":VOLT:DC:NPLC {nplc}")        return float(self.instr.query(":READ?"))    def dc_current(self, range_a: float = 1, nplc: float = 1) -> float:        self.instr.write(f":CONF:CURR:DC {range_a}")        self.instr.write(f":CURR:DC:NPLC {nplc}")        return float(self.instr.query(":READ?"))    def four_wire_resistance(self, range_ohm: float = 100, nplc: float = 10) -> float:        self.instr.write(f":CONF:FRES {range_ohm}")        self.instr.write(f":FRES:NPLC {nplc}")        self.instr.write(":FRES:OCOM ON")        return float(self.instr.query(":READ?"))    def identify(self) -> str:        return self.instr.query("*IDN?").strip()    def errors(self) -> list[str]:        out = []        while True:            err = self.instr.query(":SYST:ERR?").strip()            if err.startswith("+0,") or err.startswith("0,"):                break            out.append(err)        return out    def close(self):        self.instr.close()

Using It in a Procedure

phases/power_rails.py
def power_rails(measurements, dmm):    measurements.rail_3v3 = dmm.dc_voltage(range_v=10)    measurements.rail_1v8 = dmm.dc_voltage(range_v=10)
phases/shunt_resistance.py
def shunt_resistance(measurements, dmm):    measurements.shunt_resistance = dmm.four_wire_resistance(range_ohm=1)
phases/instrument_health.py
def instrument_health(measurements, dmm):    measurements.dmm_errors = len(dmm.errors())
procedure.yaml
53 lines
name: Board Electrical Testversion: 1.0.0unit:  serial_number:    default_value: "PCBA000001"  part_number:    default_value: "PCBA-100"plugs:  - name: dmm    python: plugs.dmm:KeysightDmmmain:  - name: Power Rails    python: phases.power_rails    measurements:      - name: Rail 3V3        unit: V        validators:          - operator: ">="            expected_value: 3.2          - operator: "<="            expected_value: 3.4      - name: Rail 1V8        unit: V        validators:          - operator: ">="            expected_value: 1.75          - operator: "<="            expected_value: 1.85  - name: Shunt Resistance    python: phases.shunt_resistance    depends_on: [power_rails]    measurements:      - name: Shunt Resistance        unit: "Ω"        description: Four-wire with offset compensation        validators:          - operator: ">="            expected_value: 0.0095          - operator: "<="            expected_value: 0.0105  - name: Instrument Health    python: phases.instrument_health    depends_on: [shunt_resistance]    measurements:      - name: DMM Errors        validators:          - operator: "=="            expected_value: 0

Run it with tofupilot run ./procedure.yaml.

The phases are chained because they share one VISA session. Independent phases run concurrently, and a single meter cannot serve two at once.

Recording the Instrument

phases/record_equipment.py
def record_equipment(run, dmm):    run.metadata["dmm_id"] = dmm.identify()

When two benches disagree, the first question is which meter took the reading. *IDN? includes the serial number, so this answers it without anyone typing an asset tag.

Speed

For a production test taking many readings of the same quantity, configure once and read repeatedly rather than reconfiguring each time:

keysight/fast_loop.py
dmm.write(":CONF:VOLT:DC 10")dmm.write(":VOLT:DC:NPLC 0.2")dmm.write(":VOLT:DC:RANG:AUTO OFF")dmm.write(":TRIG:SOUR IMM")dmm.write(":SAMP:COUN 100")readings = [float(x) for x in dmm.query(":READ?").split(",")]print(f"{len(readings)} readings, mean {sum(readings)/len(readings):.6f} V")

:SAMP:COUN takes the whole burst inside the meter and returns them in one transfer, which is far faster than a hundred round trips.

If You Already Run OpenHTF

keysight/openhtf_test.py
34 lines
import pyvisaimport openhtf as htffrom openhtf.plugs import BasePlugfrom openhtf.util import unitsfrom tofupilot.openhtf import uploadclass KeysightPlug(BasePlug):    def setUp(self):        rm = pyvisa.ResourceManager("@py")        self.instr = rm.open_resource("TCPIP::192.168.1.100::INSTR")        self.instr.timeout = 10000        self.instr.write("*RST")    def dc_voltage(self) -> float:        self.instr.write(":CONF:VOLT:DC 10")        self.instr.write(":VOLT:DC:NPLC 1")        return float(self.instr.query(":READ?"))    def tearDown(self):        self.instr.close()@htf.measures(    htf.Measurement("rail_3v3").in_range(3.2, 3.4).with_units(units.VOLT),)@htf.plug(dmm=KeysightPlug)def test_rail(test, dmm):    test.measurements.rail_3v3 = dmm.dc_voltage()test = htf.Test(test_rail, part_number="PCBA-100")test.add_output_callbacks(upload())test.execute(test_start=lambda: input("Scan serial: "))

Install with pip install "tofupilot[openhtf]", or skip the callback and run it with tofupilot run.

Troubleshooting

ProblemCauseFix
Readings noisy at the last digitsNPLC too lowRaise to 1 or 10
Readings drift with room temperatureAutozero disabledRe-enable, or ZERO:AUTO ONCE more often
Low-value resistance reads high and unstableTwo-wire, lead resistance includedUse :CONF:FRES with four leads
Milliohm reading wandersThermal EMF at the junctions:FRES:OCOM ON
Each reading takes ~100 msAutoranging on every readFix the range, turn autorange off
-420, "Query UNTERMINATED"Reading a response that was never producedCheck the command actually returns data
Timeouts under parallel phasesOne session used concurrentlySerialise with depends_on

More Guides

Put this guide into practice