Test Station Setup

Test Sequencing and Orchestration

Learn how to build repeatable hardware test sequences using TofuPilot with OpenHTF and Python for structured, automated test orchestration.

JJulien Buteau
intermediate11 min readMarch 14, 2026

A hardware test isn't one check. It's a sequence: power up, wait for boot, measure voltages, run calibration, check communication, stress test, power down. Capturing each step with its measurements and timing gives you a structured record of the full test flow.

Why Test Sequencing Matters

Running tests manually from a bench instrument works for prototypes. It doesn't work for production. Production testing needs:

  • Repeatability: Every unit goes through the exact same steps in the exact same order
  • Speed: No waiting for an operator to click "next"
  • Data capture: Every measurement recorded automatically with limits and pass/fail status
  • Traceability: A record of exactly what was tested, in what order, with what results

Test orchestration means defining that sequence once and running it the same way every time.

Test Sequence Architecture

A well-structured test sequence follows a pattern:

┌─────────────┐ │ Setup │ Power on, initialize instruments, identify DUT ├─────────────┤ │ Step 1 │ Measurement or action with pass/fail criteria ├─────────────┤ │ Step 2 │ Next measurement or action ├─────────────┤ │ ... │ Additional steps as needed ├─────────────┤ │ Teardown │ Power off, release instruments, upload results └─────────────┘

Each step produces measurements. Each measurement has limits. The sequence stops on critical failures or continues through all steps depending on your strategy.

Building a Test Sequence with OpenHTF

OpenHTF is a Python framework designed for hardware test sequencing. TofuPilot integrates as an output callback.

Plugs subclass BasePlug with setUp and tearDown (note the capitalisation, OpenHTF will not call setup/teardown), and are injected into a phase with @htf.plug(name=PlugClass).

production_test_sequence.py
97 lines
import timeimport openhtf as htffrom openhtf.plugs import BasePlugfrom tofupilot.openhtf import uploadclass PowerSupplyPlug(BasePlug):    """Controls the bench power supply."""    def setUp(self):        self.psu = connect_power_supply()    def set_voltage(self, voltage):        self.psu.write(f"VOLT {voltage}")    def enable_output(self):        self.psu.write("OUTP ON")    def disable_output(self):        self.psu.write("OUTP OFF")    def tearDown(self):        self.disable_output()class DMMPlug(BasePlug):    """Reads from the digital multimeter."""    def setUp(self):        self.dmm = connect_dmm()    def measure_voltage(self):        return float(self.dmm.query("MEAS:VOLT:DC?"))    def measure_current(self):        return float(self.dmm.query("MEAS:CURR:DC?"))# Step 1: Power rail verification@htf.PhaseOptions(name="Power Rail Verification")@htf.plug(psu=PowerSupplyPlug, dmm=DMMPlug)@htf.measures(    htf.Measurement("vcc_3v3").in_range(3.25, 3.35).with_units("V"),    htf.Measurement("vcc_1v8").in_range(1.75, 1.85).with_units("V"),    htf.Measurement("vcc_5v0").in_range(4.90, 5.10).with_units("V"),)def power_rail_check(test, psu, dmm):    psu.set_voltage(12.0)    psu.enable_output()    time.sleep(0.5)  # Wait for rails to stabilize    test.measurements.vcc_3v3 = dmm.measure_voltage()    # Switch DMM channel and measure other rails    test.measurements.vcc_1v8 = dmm.measure_voltage()    test.measurements.vcc_5v0 = dmm.measure_voltage()# Step 2: Current consumption@htf.PhaseOptions(name="Current Consumption")@htf.plug(psu=PowerSupplyPlug, dmm=DMMPlug)@htf.measures(    htf.Measurement("idle_current_ma").in_range(30, 60).with_units("mA"),    htf.Measurement("active_current_ma").in_range(80, 150).with_units("mA"),)def current_check(test, psu, dmm):    test.measurements.idle_current_ma = dmm.measure_current() * 1000    trigger_active_mode()    time.sleep(0.2)    test.measurements.active_current_ma = dmm.measure_current() * 1000# Step 3: Communication check@htf.PhaseOptions(name="Communication Interfaces")@htf.measures(    htf.Measurement("uart_loopback").equals(True),    htf.Measurement("spi_whoami").equals(0x68),)def comm_check(test):    test.measurements.uart_loopback = verify_uart_loopback()    test.measurements.spi_whoami = read_spi_register(0x75)def main():    test = htf.Test(        power_rail_check,        current_check,        comm_check,        procedure_id="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",  # procedure UUID from the dashboard        part_number="PCBA-100",    )    test.add_output_callbacks(upload())    test.execute(lambda: input("Scan DUT serial: "))if __name__ == "__main__":    main()

Install it with pip install "tofupilot[openhtf]". Every phase runs in order, every measurement is captured, and the full sequence uploads when the test completes.

One thing the example glosses over: the three rails are read from the same DMM call without switching channels. On a real fixture you would either switch the mux between reads or use separate channels, otherwise all three measurements record the same rail.

Building a Test Sequence with the Python SDK

If you're not using OpenHTF, create the run directly. Each step becomes a phase with its own measurements and outcome.

sequence_with_client.py
68 lines
import osimport timefrom datetime import datetime, timezonefrom tofupilot.v2 import TofuPilotserial = input("Scan DUT serial: ")phases = []# Step 1: Power railsstarted = datetime.now(timezone.utc)psu.enable(12.0)time.sleep(0.5)vcc_3v3 = dmm.measure_voltage(channel=1)vcc_1v8 = dmm.measure_voltage(channel=2)rails_ok = 3.25 <= vcc_3v3 <= 3.35 and 1.75 <= vcc_1v8 <= 1.85phases.append({    "name": "Power Rail Verification",    "outcome": "PASS" if rails_ok else "FAIL",    "started_at": started,    "ended_at": datetime.now(timezone.utc),    "measurements": [        {            "name": "VCC 3V3",            "measured_value": vcc_3v3,            "units": "V",            "validators": [                {"operator": ">=", "expected_value": 3.25},                {"operator": "<=", "expected_value": 3.35},            ],        },        {            "name": "VCC 1V8",            "measured_value": vcc_1v8,            "units": "V",            "validators": [                {"operator": ">=", "expected_value": 1.75},                {"operator": "<=", "expected_value": 1.85},            ],        },    ],})# Step 2: Functional checkstarted = datetime.now(timezone.utc)boot_ok = wait_for_boot(timeout=5)phases.append({    "name": "Boot Sequence",    "outcome": "PASS" if boot_ok else "FAIL",    "started_at": started,    "ended_at": datetime.now(timezone.utc),    "measurements": [        {"name": "Boot Success", "measured_value": boot_ok},    ],})# Upload the complete sequencerun_outcome = "PASS" if all(p["outcome"] == "PASS" for p in phases) else "FAIL"with TofuPilot(api_key=os.getenv("TOFUPILOT_API_KEY")) as client:    client.runs.create(        procedure_id="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",        serial_number=serial,        part_number="PCBA-100",        outcome=run_outcome,        phases=phases,    )

Note that the limits travel with the measurement as validators rather than living only in the if statement above. That is what keeps the result interpretable later, when the limits have changed and you are looking at old data.

Sequence Design Best Practices

PracticeWhy
Test cheap things firstIf a power rail is shorted, don't waste time running communication checks
Group related measurementsKeep all voltage checks in one step, all current checks in another
Use consistent step names"Power Rail Verification" across all procedures, not "Voltage Check" in one and "Rail Test" in another
Include setup/teardownAlways power down the DUT at the end, even if the test fails
Set limits on every measurementA measurement without limits can't be trended or analyzed

The teardown point deserves emphasis. A phase that raises leaves the supply enabled unless the plug's tearDown cuts it, which is exactly why the power-down belongs in the plug rather than at the end of a phase body.

Handling Sequence Failures

Two strategies for what happens when a step fails:

Fail-fast: Stop the sequence immediately on the first failure. Use this for safety-critical tests or when a failure in step 1 makes later steps meaningless (a shorted rail means there is no point testing communication).

Run-all: Continue through all steps even if one fails. Use this when you want complete diagnostic data, since knowing which 3 of 20 measurements failed helps root cause analysis.

In OpenHTF a phase returns htf.PhaseResult.STOP to halt the sequence, or CONTINUE to carry on. Most production sequences are run-all with a fail-fast gate on the first few safety-relevant phases.

What Gets Stored for Each Sequence

Every test run captures:

DataPurpose
Procedure IDWhich test sequence was run
Serial numberWhich unit was tested
Overall outcomeDid the sequence pass?
Phases with measurementsEvery step, every measurement, every limit
TimestampsWhen the test started and ended
Station IDWhich station ran the test
DurationHow long the sequence took

This structured data is what enables trending, comparison, and analytics across your entire production history.

More Guides

Put this guide into practice