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.py97 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.py68 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
| Practice | Why |
|---|---|
| Test cheap things first | If a power rail is shorted, don't waste time running communication checks |
| Group related measurements | Keep 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/teardown | Always power down the DUT at the end, even if the test fails |
| Set limits on every measurement | A 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:
| Data | Purpose |
|---|---|
| Procedure ID | Which test sequence was run |
| Serial number | Which unit was tested |
| Overall outcome | Did the sequence pass? |
| Phases with measurements | Every step, every measurement, every limit |
| Timestamps | When the test started and ended |
| Station ID | Which station ran the test |
| Duration | How long the sequence took |
This structured data is what enables trending, comparison, and analytics across your entire production history.
