Migrate from NI TestStand to OpenHTF
Map NI TestStand concepts to OpenHTF — sequences and step groups to tests and phase groups, numeric limit steps to measurements, preconditions to run_if and branches, station globals to configuration, report and database loggers to output callbacks — and plan a station-by-station transition.
Last updated · Verified with OpenHTF 1.6.1
TestStand sequences and OpenHTF tests describe the same thing: ordered steps with limits, run against one unit, producing a report. The vocabulary differs; the shapes line up.
Concept mapping
| TestStand | OpenHTF | Notes |
|---|---|---|
Sequence file (.seq) | Python module with htf.Test(...) | Text, diffable, in git |
| MainSequence | htf.Test(*phases) | |
| Setup / Main / Cleanup step groups | htf.PhaseGroup(setup=, main=, teardown=) | Cleanup semantics match: teardown runs if setup completed — Phase Groups |
| Step (Action) | Phase | A Python function |
| Numeric Limit Test step | @htf.measures(htf.Measurement(...).in_range(lo, hi).with_units(...)) | GELE → in_range; GT/LT only → one bound; EQ → .equals |
| Multiple Numeric Limit Test | Several Measurements on one phase, or a dimensioned measurement | |
| String Value Test | .equals("...") or .matches_regex(...) | |
| Pass/Fail Test | Boolean measurement .equals(True), or PhaseResult.FAIL_AND_CONTINUE | |
| Step result "Skipped" | PhaseResult.SKIP | Recorded as SKIP |
| Precondition expression | @htf.PhaseOptions(run_if=lambda: ...) for static, BranchSequence for result-dependent | run_if cannot see results; branches can — Checkpoints & Branching |
| Sequence Call | A phase list / PhaseSequence reused across tests; Subtest for an independent result | Subtests |
| Looping steps | force_repeat + repeat_limit, or a Python loop building phases | |
| Step failure causes sequence failure | Default OpenHTF behaviour (continue, record FAIL); stop_on_first_failure or checkpoint() to stop | Test Options |
| Station Globals / Sequence File Globals | Configuration: CONF.declare, YAML per station | |
| Locals / FileGlobals passed between steps | Measurements (test.get_measurement), plug state, test.test_record.metadata | |
| Process model (serial number prompt, report, loop) | test_start=prompt_for_test_start(), output callbacks, while True: around execute() | Device Under Test |
| Operator Interface | Built-in Operator UI or TofuPilot's | |
| Report (ATML / XML / HTML) | JSON test record via OutputToJSON | |
| Database Logger | An output callback — your SQL insert, or TofuPilot's upload() | |
| Code modules (LabVIEW VI, DLL, .NET) | Plug methods in Python; call DLLs via ctypes, LabVIEW via its Python connectivity or a rewrite | The main effort |
| Deployment Utility | Python packaging + a launcher — Production deployment | |
| Batch / parallel process models | Not built in; one process per socket, or TofuPilot procedures |
Worked example
A typical PCB sequence: Setup opens instruments; Main programs firmware, checks rails with numeric limits, runs an RF sub-sequence under a precondition; Cleanup powers down.
import openhtf as htf
from openhtf.output.callbacks import json_factory
from openhtf.plugs import user_input
from openhtf.util import checkpoints, configuration, units
from plugs import Dmm, Psu, Programmer, RfTester
CONF = configuration.CONF
RF_VARIANT = CONF.declare("rf_variant", default_value=True, description="Board has the radio fitted")
# --- Setup group: instruments open themselves in plug __init__; a phase can verify ---
@htf.plug(psu=Psu, dmm=Dmm)
def instruments_ready(test, psu, dmm):
test.logger.info("PSU %s, DMM %s", psu.idn, dmm.idn)
# --- Main group ---
@htf.plug(prog=Programmer)
@htf.measures(htf.Measurement("fw_crc").equals("0x9A3C")) # String Value Test
def program_firmware(test, prog):
test.measurements.fw_crc = prog.flash("fw_2.1.4.hex")
@htf.plug(psu=Psu, dmm=Dmm)
@htf.measures( # Multiple Numeric Limit Test
htf.Measurement("rail_3v3").in_range(3.2, 3.4).with_units(units.VOLT),
htf.Measurement("rail_1v8").in_range(1.75, 1.85).with_units(units.VOLT),
htf.Measurement("idle_current").in_range(maximum=0.120).with_units(units.AMPERE),
)
def check_rails(test, psu, dmm):
psu.set(12)
test.measurements.rail_3v3 = dmm.vdc("3V3")
test.measurements.rail_1v8 = dmm.vdc("1V8")
test.measurements.idle_current = psu.current()
@htf.PhaseOptions(run_if=lambda: CONF.rf_variant) # Precondition
@htf.plug(rf=RfTester)
@htf.measures(htf.Measurement("tx_power").in_range(17, 20).with_units(units.DECIBEL_MILLIWATTS))
def rf_tx_power(test, rf):
test.measurements.tx_power = rf.tx_power_dbm()
# --- Cleanup group ---
@htf.plug(psu=Psu)
def power_down(test, psu):
psu.off()
def main():
test = htf.Test(
htf.PhaseGroup(
setup=[instruments_ready],
main=[
program_firmware,
checkpoints.checkpoint("programmed"), # "step failure causes sequence failure" for the expensive part
check_rails,
htf.Subtest("rf", rf_tx_power), # Sequence Call with its own result
],
teardown=[power_down],
),
test_name="PCB01 EOL",
sequence_version="2.1.4", # any FileGlobal worth keeping → metadata
)
test.add_output_callbacks(
json_factory.OutputToJSON("./records/{dut_id}.{start_time_millis}.json", indent=2)
)
while True: # process model loop
test.execute(test_start=user_input.prompt_for_test_start())
if __name__ == "__main__":
main()Migrating the code modules
This is where the time goes. Options, roughly in order of preference:
- Rewrite in Python against the instrument's SCPI or SDK — PyVISA, pyserial, pymodbus. Usually shorter than expected: a LabVIEW driver VI often wraps a handful of SCPI strings.
- Call the existing DLL from a plug with
ctypesor a vendor Python binding. Keeps validated code; adds a Windows dependency. - Keep LabVIEW for one instrument and expose it over a local TCP socket or CLI that a plug calls. A bridge, not a destination.
Transition plan
- Pick one station with a simple sequence and a Python-comfortable owner. Run OpenHTF alongside TestStand on the same units for a week and compare records.
- Match limits exactly from the sequence file; keep the same measurement names so historical comparisons hold.
- Route both systems' results to one database so quality does not lose visibility during the switch — an output callback for OpenHTF, the existing logger for TestStand. TofuPilot accepts both OpenHTF records and imported files.
- Retire TestStand licences per station as each converts; the savings fund the plug work.
Related
From pytest
Convert a pytest suite that drives hardware into OpenHTF — test functions to phases, fixtures to plugs, assert ranges to measurements with limits, parametrize to with_args, markers to run_if, and conftest options to configuration — with a mapping table and worked example.
Production deployment
Take an OpenHTF test from a developer laptop to a factory station — project layout, pinned dependencies, per-station YAML configuration, launcher scripts and autostart on Windows and Linux, kiosk browser for the Operator UI, record storage and upload, updates and rollback.