TestStand is a capable test executive. The reasons teams leave it are usually commercial and operational rather than technical: about $4,310 per seat per year, Windows-only, and binary .seq files that resist code review. This guide is the concrete migration: what maps to what, what the work actually costs, and how to validate the port before anything is decommissioned.
For the shorter survey of options, see TestStand alternatives.
Before You Start: Inventory What You Use
The size of the job is set almost entirely by your step modules.
| What your steps call | Migration effort |
|---|---|
| Python modules | Minimal; they port as-is |
| C/C++ DLLs | Small; call them with ctypes |
| .NET assemblies | Small to medium; pythonnet, or reimplement |
| LabVIEW VIs doing instrument work | Large; this is the real cost |
| ActiveX / COM | Medium; often easier to reimplement |
Also inventory the parts of TestStand you actually rely on. Most teams use sequences, limit steps and a stock operator interface. Fewer use callbacks in anger, and those are where the surprises live.
The Concept Map
| TestStand | TofuPilot Framework |
|---|---|
Sequence file (.seq) | procedure.yaml |
| MainSequence | main: list |
| Step | Entry under main: |
| Step module (code) | Phase function in phases/ |
| Numeric Limit Test | measurements with >= / <= validators |
| Multiple Numeric Limit Test | Several measurements in one phase |
| String Value Test | validators with operator: "==" |
| Pass/Fail Test | Boolean measurement, == true |
| Action step | Phase with no measurements |
| Precondition | enable_if on the phase |
| Run mode: Skip | enable_if: false |
| Loop / retry on failure | retry on the phase |
| Sequential model | Default execution |
| Parallel / Batch model | execution.slots |
| UUT serial number | unit.serial_number |
| PreUUT / PostUUT | setup / teardown phases |
| Station globals | Plug state or run metadata |
| StationInfo, step results | Run metadata and measurements |
| Database logging step | Automatic upload |
| Report generation | Dashboard and exports |
Porting a Sequence
A typical TestStand MainSequence: power up, three Numeric Limit Tests, a String Value Test, power down.
procedure.yaml55 lines
name: PCBA Functional Testversion: 1.0.0description: Ported from FCT_PCBA100.seq rev Dunit: serial_number: default_value: "PCBA000001" part_number: default_value: "PCBA-100"plugs: - name: psu python: plugs.psu:PowerSupply - name: dmm python: plugs.dmm:Multimeter - name: dut python: plugs.dut:Dutmain: - name: Power Up python: phases.power_up - name: Rail Measurements python: phases.rail_measurements depends_on: [power_up] measurements: - name: Rail 3V3 unit: V description: FCT_PCBA100.seq step 3 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.2 - name: Quiescent Current unit: mA validators: - operator: "<=" expected_value: 45.0 - name: Firmware Version python: phases.firmware_version depends_on: [rail_measurements] measurements: - name: Firmware Version validators: - operator: "==" expected_value: "2.1.0"def power_up(psu): """Action step: no measurements, just side effects.""" psu.configure(channel=1, volts=5.0, current_limit=0.5) psu.output(1, True)def rail_measurements(measurements, dmm): """Three Numeric Limit Tests become three measurements in one phase.""" measurements.rail_3v3 = dmm.dc_voltage() measurements.rail_5v0 = dmm.dc_voltage() measurements.quiescent_current = dmm.dc_current_ma()def firmware_version(measurements, dut): measurements.firmware_version = dut.query_firmware()Keeping the original step reference in description is worth the small effort. During validation you will be comparing against the TestStand report line by line, and it makes that mechanical.
Power-down belongs in the plug's close(), not a final phase, so it happens even when a phase raises:
class PowerSupply: def configure(self, channel: int, volts: float, current_limit: float): ... def output(self, channel: int, on: bool): ... def close(self): """Runs at teardown whatever happened during the run.""" for ch in (1, 2, 3): self.output(ch, False)Porting the Process Models
Sequential is the default. Nothing to do.
Parallel and Batch become fixture slots:
execution: slots: - name: Slot 1 - name: Slot 2 - name: Slot 3 - name: Slot 4Each slot runs independently with its own unit, measurements and outcome, and a failure in one slot leaves the others running. For a fixture with many identical positions, a count works instead of a list:
execution: slots: 16Porting the Callbacks
PreUUT and PostUUT become setup and teardown phases. The distinction that matters, and that TestStand also forces you to think about, is scope:
- Slot-scoped teardown touches that slot only and may run while neighbouring slots are still testing.
- Execution-scoped teardown runs once after every slot is finished, and is where a shared rack supply, a fixture lock or a chamber belongs.
The classic multi-slot bug is a slot-scoped teardown switching off a shared supply while another slot is mid-measurement. See replace TestStand callbacks in Python.
Calling Existing Code
You rarely have to rewrite working instrument code immediately.
C/C++ DLL:
import ctypesclass LegacyInstrument: def __init__(self): self._lib = ctypes.CDLL("./vendor/instrument.dll") self._lib.read_voltage.restype = ctypes.c_double def read_voltage(self) -> float: return float(self._lib.read_voltage())LabVIEW VI, via a built executable or the LabVIEW Python API, wrapped the same way. This is the step worth measuring honestly: if most of your instrument logic is in VIs, that is the migration, and the sequencing part is the easy half.
Validate Before You Cut Over
Non-negotiable, and cheap:
- Pick 20-30 units including known-bad ones.
- Run TestStand and the new procedure on each.
- Compare measurement by measurement, not just pass/fail.
- Investigate every disagreement before decommissioning anything.
A limit transcribed with the wrong sign or a factor of ten is the failure mode that ships bad boards, and it is invisible if you only compare verdicts.
Keep measurement names stable from day one of the port. They are the identity of the data across every run you will ever take.
What It Costs
| Work item | Effort |
|---|---|
| Port step modules | Small if C/Python, large if LabVIEW |
| Re-express sequences as procedures | Small, mostly mechanical |
| Rebuild the operator interface | Small; declared in the procedure |
| Replace database logging | Small; upload replaces the schema |
| Parallel validation | Medium, and mandatory |
| Retraining | Small; Python is a smaller step than TestStand was |
When Not to Migrate
- Deep NI hardware integration. PXI chassis, switching, FPGA targets and NI-specific drivers you would have to rewrite.
- Large working sequence library, no new stations. If it works and is not growing, the cost may not be justified.
- Heavy LabVIEW step modules with no budget to port them.
If You Prefer Decorators
OpenHTF is the other purpose-built Python option, with sequencing and limits expressed in Python decorators rather than YAML:
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 test_rail(test): test.measurements.rail_3v3 = 3.31test = 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]". Both run on the same CLI and upload to the same dashboard. See TofuPilot Framework vs OpenHTF.
Common Migration Mistakes
| Mistake | Consequence | Fix |
|---|---|---|
| Porting sequences before step modules | Nothing runs end to end | Port and test modules first |
| Comparing pass/fail only during validation | A wrong limit survives the port | Compare measurement values |
| Recreating TestStand structure exactly | You inherit workarounds you no longer need | Port the intent |
| Power-down as a final phase | Board stays energised when a phase raises | Put it in the plug's close() |
| Slot-scoped teardown on shared equipment | Intermittent multi-slot failures | Execution scope for shared resources |
| Big-bang cutover | One risky day | One station, one product, then expand |
