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
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-01On 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:
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:
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:
| NPLC | Reading time (50 Hz) | Noise rejection | Use for |
|---|---|---|---|
| 0.02 | ~0.4 ms | Poor | Fast screening |
| 0.2 | ~4 ms | Moderate | General production |
| 1 | ~20 ms | Good, rejects line noise | Default for accuracy |
| 10 | ~200 ms | Very good | Low-level, calibration |
| 100 | ~2 s | Best | Metrology |
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:
dmm.write(":VOLT:DC:ZERO:AUTO ONCE") # Take one zero, then holdFour-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:
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.py45 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
def power_rails(measurements, dmm): measurements.rail_3v3 = dmm.dc_voltage(range_v=10) measurements.rail_1v8 = dmm.dc_voltage(range_v=10)def shunt_resistance(measurements, dmm): measurements.shunt_resistance = dmm.four_wire_resistance(range_ohm=1)def instrument_health(measurements, dmm): measurements.dmm_errors = len(dmm.errors())procedure.yaml53 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: 0Run 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
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:
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.py34 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
| Problem | Cause | Fix |
|---|---|---|
| Readings noisy at the last digits | NPLC too low | Raise to 1 or 10 |
| Readings drift with room temperature | Autozero disabled | Re-enable, or ZERO:AUTO ONCE more often |
| Low-value resistance reads high and unstable | Two-wire, lead resistance included | Use :CONF:FRES with four leads |
| Milliohm reading wanders | Thermal EMF at the junctions | :FRES:OCOM ON |
| Each reading takes ~100 ms | Autoranging on every read | Fix the range, turn autorange off |
-420, "Query UNTERMINATED" | Reading a response that was never produced | Check the command actually returns data |
| Timeouts under parallel phases | One session used concurrently | Serialise with depends_on |
