Tutorial — Test a Resistor with OpenHTF

Step-by-step OpenHTF tutorial. Build a resistor test with a power supply and multimeter plug (PyVISA, with a simulation mode), add a measurement with limits, read the console summary, and upload the record.

Last updated · Verified with OpenHTF 1.6.1

Build a complete test in six steps: apply a voltage with a programmable supply, read the current with a multimeter, compute the resistance with Ohm's law and check it against a tolerance. This mirrors the resistor tutorial in the upstream repository, rewritten so every step runs without hardware.

You will use phases, measurements with validators and units, plugs with a simulation flag, and configuration to switch between the bench and the simulator.

An empty test

Create main_test.py with one phase that does nothing yet:

main_test.py
import openhtf as htf

def resistor_test(test):
    """Placeholder phase; filled in below."""

def main():
    test = htf.Test(resistor_test)
    test.execute(lambda: "R-0001")

if __name__ == "__main__":
    main()
Terminal
======================= test: openhtf_test  outcome: PASS ======================

A phase that returns nothing is a CONTINUE, so the test passes.

Declare the measurement

Tell OpenHTF the phase will record resistor_val:

main_test.py
@htf.measures(
    htf.Measurement("resistor_val")
    .doc("Computed resistor value")
)
def resistor_test(test):
    """Placeholder phase; filled in below."""
Terminal
======================= test: openhtf_test  outcome: FAIL ======================

It fails: a declared measurement that is never set is a failure (see allow_unset_measurements in Configuration). To see why it failed, add a console summary callback:

main_test.py
from openhtf.output.callbacks import console_summary

def main():
    test = htf.Test(resistor_test)
    test.add_output_callbacks(console_summary.ConsoleSummary())
    test.execute(lambda: "R-0001")
Terminal
openhtf_test:FAIL
failed phase: resistor_test [ran for 0.00 sec]
  failed_item: resistor_val (Outcome.UNSET)
    measured_value: UNSET
    validators:

======================= test: openhtf_test  outcome: FAIL ======================

Set a placeholder value and the test passes again:

main_test.py
def resistor_test(test):
    test.measurements["resistor_val"] = 10

Write the plugs

Instruments live in plugs. These two wrap a multimeter and a power supply over PyVISA and take a simulate flag so the tutorial runs on any laptop. Copy them into resistor_plugs.py:

resistor_plugs.py
import random
import time

from openhtf.core import base_plugs


class MultimeterPlug(base_plugs.BasePlug):
    """Digital multimeter over SCPI. `simulate=True` returns random values."""

    def __init__(self, simulate: bool = False) -> None:
        self.simulate = simulate
        self.rm = None
        self.dmm = None

    def connect(self) -> None:
        if self.simulate:
            return
        import pyvisa  # only needed on the bench
        self.rm = pyvisa.ResourceManager()
        self.dmm = self.rm.open_resource("USB0::6833::8458::DM8A265201811::0::INSTR")
        self.logger.info("Connected to %s", self.dmm.query("*IDN?").strip())

    def read_current(self) -> float:
        if self.simulate:
            current = random.uniform(0.0006, 0.0008)      # ~4 V across ~5.6 kΩ
        else:
            current = float(self.dmm.query("MEASure:CURRent:DC? AUTO,1E-3"))
        self.logger.info("Current: %.6f A", current)
        return current

    def tearDown(self) -> None:
        if self.rm:
            self.rm.close()


class PowerSupplyPlug(base_plugs.BasePlug):
    """Programmable power supply over SCPI."""

    def __init__(self, simulate: bool = False) -> None:
        self.simulate = simulate
        self.rm = None
        self.supply = None

    def connect(self) -> None:
        if self.simulate:
            return
        import pyvisa
        self.rm = pyvisa.ResourceManager()
        self.supply = self.rm.open_resource("USB0::6833::42152::DP9D264501253::0::INSTR")
        self.supply.write(":OUTP ALL, OFF")

    def set_voltage(self, voltage: float, channel: str = "CH1") -> None:
        if not self.simulate:
            self.supply.write(f":APPL {channel}, {voltage}")
            self.supply.write(f":OUTP {channel}, ON")
        self.logger.info("Set %s to %.2f V", channel, voltage)

    def tearDown(self) -> None:
        if self.supply:
            self.supply.write(":OUTP ALL, OFF")
        if self.rm:
            self.rm.close()

tearDown() runs after the test whether it passed or not, so the supply is always switched off. The VISA resource strings are the Rigol DM858 and DP932E used upstream; replace them with your own (pyvisa.ResourceManager().list_resources() lists what is connected — see the PyVISA plug guide).

Use the plugs in the phase

Inject both plugs and compute the resistance:

main_test.py
import time

import openhtf as htf
from openhtf.output.callbacks import console_summary
from openhtf.util import units

import resistor_plugs


@htf.measures(
    htf.Measurement("resistor_val")
    .doc("Computed resistor value")
    .with_units(units.OHM)
)
@htf.plug(dmm=resistor_plugs.MultimeterPlug)
@htf.plug(supply=resistor_plugs.PowerSupplyPlug)
def resistor_test(test, dmm, supply):
    supply.connect()
    dmm.connect()

    input_voltage = 4.0                     # V
    supply.set_voltage(input_voltage)
    time.sleep(0.5)                         # let the current settle
    current = dmm.read_current()

    measured_r = round(input_voltage / current, 1)
    test.measurements["resistor_val"] = measured_r
    test.logger.info("R = %.1f Ω", measured_r)


def main():
    test = htf.Test(resistor_test)
    test.add_output_callbacks(console_summary.ConsoleSummary())
    test.execute(lambda: "R-0001")


if __name__ == "__main__":
    main()

Right now the plugs are constructed with simulate=False, so this step needs the bench. The next step fixes that.

Switch simulation on with configuration

The plugs take simulate in __init__, but @htf.plug(dmm=MultimeterPlug) constructs them with no arguments. bind_init_args binds constructor arguments to configuration values, so the same script runs on the bench (simulate: false) and on a laptop (simulate: true):

main_test.py
from openhtf.util import configuration

CONF = configuration.CONF
SIMULATE = CONF.declare("simulate", default_value=False, description="Bypass the instruments")

MultimeterPlug = configuration.bind_init_args(resistor_plugs.MultimeterPlug, SIMULATE)
PowerSupplyPlug = configuration.bind_init_args(resistor_plugs.PowerSupplyPlug, SIMULATE)

@htf.measures(htf.Measurement("resistor_val").doc("Computed resistor value").with_units(units.OHM))
@htf.plug(dmm=MultimeterPlug)
@htf.plug(supply=PowerSupplyPlug)
def resistor_test(test, dmm, supply):
    ...

Run it simulated without touching the code:

Terminal
python main_test.py --config-value simulate=true -v
Terminal
I 10:42:07 <plug: PowerSupplyPlug> - Set CH1 to 4.00 V
I 10:42:08 <plug: MultimeterPlug> - Current: 0.000712 A
I 10:42:08 <phase: resistor_test> - R = 5617.9 Ω
openhtf_test:PASS

======================= test: openhtf_test  outcome: PASS ======================

Add limits

A 5.6 kΩ ±5 % resistor must read between 5320 Ω and 5880 Ω. Add the validator:

main_test.py
@htf.measures(
    htf.Measurement("resistor_val")
    .doc("Computed resistor value")
    .in_range(5320, 5880)
    .with_units(units.OHM)
)

With a 220 Ω part on the bench (or by forcing current = 0.018 in the simulator) the summary now explains the failure:

Terminal
openhtf_test:FAIL
failed phase: resistor_test [ran for 0.52 sec]
  failed_item: resistor_val (Outcome.FAIL)
    measured_value: 222.2
    validators:
      validator: 5320 <= x <= 5880

======================= test: openhtf_test  outcome: FAIL ======================

You have a deployable resistor test. From here:

  • Save every run: test.add_output_callbacks(json_factory.OutputToJSON("./records/{dut_id}.{start_time_millis}.json", indent=2))Output Callbacks.
  • Ask the operator for the serial number instead of hard-coding it — Device Under Test.
  • Give the operator a browser UI — Operator UI.
  • Track yield across thousands of resistors: test.add_output_callbacks(upload())Manufacturing Test Analytics.

On this page

First-pass yield
0%4.1
Track with TofuPilot