This guide takes you from nothing to a working hardware test that records structured measurements, evaluates limits, and uploads results. It runs on your laptop first, with a fake instrument, so you can see the whole shape before any hardware is involved.
Prerequisites
- Python 3.10+
- The TofuPilot CLI
curl -fsSL https://www.tofupilot.app/install | shOn Windows:
$p = "$env:TEMP\tp-install.ps1"; irm https://www.tofupilot.app/install.ps1 -OutFile $p; powershell -ExecutionPolicy Bypass -File $p; ri $p -EA 0No account is needed to run a procedure locally.
Step 1: Create the Files
A procedure is a directory with a .yaml file, a phases/ folder for your test logic, and a plugs/ folder for your instrument drivers.
mkdir -p first-test/phases first-test/plugscd first-testStep 2: Write the Plug
A plug is a plain Python class that owns a connection to something: an instrument, a fixture, a DUT. Start with a fake one that returns a fixed value, so the test runs before the hardware exists.
class Multimeter: """Fake multimeter. Replace the body with real instrument calls.""" def read_voltage(self) -> float: return 3.31There is no base class to inherit and no decorator to apply. When you are ready for real hardware, the change is contained here:
import pyvisaclass Multimeter: def __init__(self): rm = pyvisa.ResourceManager("@py") self.instr = rm.open_resource("TCPIP::192.168.1.100::INSTR") self.instr.timeout = 5000 def read_voltage(self) -> float: return float(self.instr.query(":MEAS:VOLT:DC?")) def close(self): self.instr.close()Step 3: Write the Phase
A phase is a plain function. It receives measurements and any plug it names as a parameter, injected by name.
def power_rail(measurements, dmm): measurements.rail_3v3 = dmm.read_voltage()That is the whole test logic. No limit checking, because limits are not logic: they are data, and they live in the procedure.
Step 4: Declare the Procedure
procedure.yaml24 lines
name: My First Testversion: 1.0.0unit: serial_number: default_value: "PCBA000001" part_number: default_value: "PCBA-100"plugs: - name: dmm python: plugs.dmm:Multimetermain: - name: Power Rail python: phases.power_rail measurements: - name: Rail 3V3 unit: V validators: - operator: ">=" expected_value: 3.2 - operator: "<=" expected_value: 3.4Three things to notice:
- The measurement key comes from the name.
Rail 3V3becomesrail_3v3, which is what the phase assigns to. - The plug is matched by name.
dmmin theplugslist is thedmmparameter in the phase. - The limits are data. Two validators, greater-or-equal and less-or-equal, make a range.
Step 5: Run It
tofupilot run ./procedure.yamlThe first run creates a virtual environment for the procedure. You will be asked to confirm the serial and part number, then the phase runs and the result appears.
To see it fail, change the fake reading to something out of range:
def read_voltage(self) -> float: return 9.9Run it again and the phase fails with the reason, naming the validator that rejected the value. The measured value is still recorded; a failure is data, not an absence of data.
Step 6: Add a Second Measurement
Most real tests measure several things in one phase.
def power_rail(measurements, dmm): measurements.rail_3v3 = dmm.read_voltage() measurements.rail_5v0 = dmm.read_voltage()- name: Power Rail python: phases.power_rail measurements: - name: Rail 3V3 unit: V validators: - operator: ">=" expected_value: 3.2 - operator: "<=" expected_value: 3.4 - name: Rail 5V0 unit: V validators: - operator: ">=" expected_value: 4.8 - operator: "<=" expected_value: 5.2Measurements are not only numeric. A string check and a boolean check look like this:
- name: Firmware Version validators: - operator: "==" expected_value: "2.1.0" - name: Label Present validators: - operator: "==" expected_value: trueStep 7: Add a Second Phase
Phases are the unit of sequencing and of retry. Split the test when the steps are independent, or when you want them to fail separately.
def firmware(measurements, dmm): measurements.firmware_version = "2.1.0"main: - name: Power Rail python: phases.power_rail measurements: - name: Rail 3V3 unit: V validators: - operator: ">=" expected_value: 3.2 - operator: "<=" expected_value: 3.4 - name: Firmware python: phases.firmware measurements: - name: Firmware Version validators: - operator: "==" expected_value: "2.1.0"Independent phases run in parallel by default. If one must follow another, say so:
- name: Firmware python: phases.firmware depends_on: [power_rail]Step 8: Send Results to the Dashboard
Create a free account, then link the procedure and run it with --upload:
tofupilot logintofupilot linktofupilot run ./procedure.yaml --uploadEvery measurement arrives with its name, value, unit, limits and outcome, which is what makes first pass yield, Cpk and control charts work without any extra code.
What to Do Next
| Goal | Where to go |
|---|---|
| Talk to a real instrument | Getting started with PyVISA |
| Prompt the operator for input | How to add operator prompts to hardware tests |
| Test several boards at once | How to run parallel tests on multiple DUTs |
| Put it on a station | How to deploy Python test scripts to production stations |
| Set limits from real data | How to set measurement limits using production data |
If You Prefer Everything in Python
OpenHTF is the other purpose-built option, with the sequence and limits expressed as decorators instead of YAML. The same first test:
import openhtf as htffrom openhtf.util import unitsfrom tofupilot.openhtf import upload@htf.measures( htf.Measurement("rail_3v3") .in_range(3.2, 3.4) .with_units(units.VOLT),)def power_rail(test): test.measurements.rail_3v3 = 3.31test = htf.Test(power_rail, part_number="PCBA-100")test.add_output_callbacks(upload())test.execute(test_start=lambda: input("Scan serial: "))Install with pip install "tofupilot[openhtf]". Both frameworks run on the same CLI and upload to the same dashboard, so this is a style choice rather than a lock-in. See TofuPilot Framework vs OpenHTF.
Common First-Test Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
| Phase parameter does not match the plug name | Plug is not injected | The parameter name must equal the name in plugs |
| Assigning a key that is not declared | Measurement never appears | The key comes from the measurement name, in snake_case |
| Checking limits inside the phase | Limits are invisible to analytics | Declare validators in the procedure instead |
| One phase for the entire test | Everything fails together, no retry granularity | Split independent steps into phases |
| Sharing one instrument across parallel phases | Intermittent instrument errors | Use depends_on, or give each phase its own instrument |
