Replace TestStand Callbacks in Python
Callbacks are usually the hardest part of moving a test system off a traditional test executive. The measurement code ports easily: a voltage read is a voltage read. The callbacks are harder, because they encode behavior that lives outside any single test sequence, and because much of what they do disappears rather than migrates.
This guide maps each common callback to its Python equivalent, and identifies which ones you should not port at all.
What Callbacks Do
In NI TestStand, a process model defines the lifecycle around your test sequence, and callbacks are the named points where a sequence can override the default. PreUUT prompts for a serial number. PostUUT handles the result. TestReport formats the report. Override one in a client sequence and your version runs instead of the model's.
The pattern solves a real problem: customizing the lifecycle for one product without copying the whole model. Its cost is indirection, since understanding one run can mean opening the model, the client sequence, and every override.
The Mapping
| Callback | Purpose | Python equivalent |
|---|---|---|
PreUUTLoop | Once before testing begins | Station-scoped plug initialization |
PreUUT | Identify the next unit | unit: block, prompt or auto_identify |
MainSequence | The test itself | main: phases |
PostUUT | Act on one unit's result | teardown: phases |
PostUUTLoop | Once after the last unit | Station-scoped plug teardown |
TestReport | Format the report | Not ported: built in |
LogToDatabase | Store results | Not ported: built in |
ProcessModelPostStep | After every step | Not ported: engine records phases |
SequenceFilePreStep | Before each step in a file | setup: phases or depends_on |
Four of nine are not ported. That is the main finding of most migrations: a large share of callback code exists to move results into storage, and that job no longer belongs to your test code.
PreUUT: Identifying the Unit
PreUUT typically prompts for a serial number and validates it, deciding whether to continue or skip.
In the TofuPilot Framework, identification is declared rather than coded:
unit: serial_number: default_value: "SN000001" part_number: default_value: "PCB-MAIN-V2"The operator is prompted before the test starts. For a station that derives serials instead of asking, auto_identify removes the prompt:
unit: auto_identify: true serial_number: default_value: "BURNIN-{slot}" part_number: default_value: "PCB-MAIN-V2"When identification needs real logic — reading a serial from EEPROM, checking an MES for a work order, refusing a unit already tested — that becomes the first setup phase:
def identify(phase, unit, scanner, mes): serial = scanner.read() if not mes.is_released(serial): phase.fail(f"{serial} has no released work order") return unit.serial_number = serialA failing setup phase stops the run before any main phase executes, which is the behavior PreUUT returning False was giving you.
PostUUT: Acting on the Result
PostUUT runs after the test with the result available, typically to drive a pass/fail indicator, print a label, or route the unit.
This becomes a teardown phase. Teardown always runs, whether the test passed, failed, errored, or timed out:
teardown: - name: Signal Result python: phases.signal_resultdef signal_result(unit, indicator, printer): indicator.ready() printer.print_label(unit.serial_number)Two differences from PostUUT are worth knowing.
Teardown runs per slot. On a multi-socket station a slot's teardown can run while other slots are still testing, so anything touching shared hardware needs scope: execution to run once, after every slot is done.
Result-dependent branching is usually the wrong shape here. A PostUUT that formatted a report or wrote a database row has no equivalent, because the engine uploads the run itself. What remains is physical: light a lamp, print a label, release a fixture. When an action genuinely must differ on pass and fail, drive it from the phase that made the determination rather than reconstructing the verdict in teardown.
PreUUTLoop and PostUUTLoop: Station Lifetime
These run once around the whole testing session, usually to open instrument connections and close them at the end.
There is no phase equivalent, because this is not test logic. It is resource lifetime, and it belongs to the plug:
plugs: - name: power_supply python: plugs.psu:PowerSupply scope: station config: address: "192.168.1.100"A station-scoped plug is created on first use and held across executions, so back-to-back units do not pay the reconnection cost. Connection setup goes in __init__, cleanup in __del__:
import pyvisaclass PowerSupply: def __init__(self, address: str): self._rm = pyvisa.ResourceManager() self._inst = self._rm.open_resource(f"TCPIP::{address}::INSTR") def read_voltage(self) -> float: return float(self._inst.query("MEAS:VOLT?")) def __del__(self): self._inst.close()The engine terminates the plug subprocess on deletion rather than relying on garbage collection, so cleanup is deterministic. It also health-checks a held plug process before each reuse and respawns it if it died — one of the failure modes a hand-written PreUUTLoop usually did not cover.
Note the scope boundary: a station plug is released when a new deployment is applied, so plug code changes always take effect on the first run after a deploy. In a one-shot tofupilot run, station behaves as execution.
TestReport and LogToDatabase: Do Not Port
These are the two callbacks teams invest the most in, and the two that should not survive the migration.
In a classic executive, results are your problem. You write a report callback to produce XML, a database callback to insert rows, and a schema to hold them. It is normal for this to be thousands of lines, and it is normal for it to be the least-loved code in the repository.
In the TofuPilot Framework the engine uploads the run: procedure, unit, phases, measurements, logs, attachments, outcome. Stations queue offline and drain on reconnect with the original timestamp.
The instinct to port a report callback anyway is worth resisting. If the goal was an XML file for a customer, that is a report generated from stored data, not a step inside the test. Keeping it in the test means every station needs the formatting code, every schema change is a redeploy, and a formatting bug fails a passing unit.
The honest tradeoff: you no longer control the storage format. If you have a genuine requirement to write a specific schema into a system of record, that is an export from the API, running once, off the station.
ProcessModelPostStep: Do Not Port
ProcessModelPostStep runs after every step, typically to log step results or accumulate statistics.
The engine already records every phase: name, outcome, duration, measurements with their validators, retry count. It arrives in the dashboard without a callback, and phase-level analysis like a pareto of failures is a view over that data.
Port this only if it did something genuinely unusual, such as toggling a watchdog between steps. That becomes ordinary code in the phases that need it, not a global hook.
Per-Step Hooks: depends_on and Setup
SequenceFilePreStep and similar per-step hooks are often used to enforce ordering or preconditions.
Ordering is declared directly:
main: - name: Power On python: phases.power_on key: power_on - name: Measure Rails python: phases.measure_rails depends_on: [power_on]Phases without dependencies run in parallel on a worker pool; depends_on serializes what must be ordered. This replaces a hook that existed to check "did the previous step pass" with a statement of the actual dependency.
Preconditions that apply to everything go in setup, which must pass before any main phase runs. Preconditions for one phase go in the phase, using phase.skip():
def advanced_calibration(phase, device): if not device.supports_advanced_mode(): phase.skip() return device.calibrate()Failure Behavior
Much callback code exists to control what happens after a failure. That is configuration:
execution: on_first_failure: continueThe default stop cancels phases that have not started. continue runs them anyway, which is what you want when a full failure profile is more valuable than cycle time.
For intermittent failures, retry is per phase and explicit:
main: - name: Network Connect python: phases.network retry: limit: 5 delay: 1sRetries do not happen automatically on failure. The phase must return a retry action, or then: must map an outcome to a retry. Every attempt is preserved and uploaded with a retry_count, so a unit that passed on the fourth attempt is visible as such rather than looking clean.
A Migration Order That Works
- Inventory the callbacks and mark each one. Most fall into: reporting or database (delete), lifecycle (becomes plug scope), or real logic (becomes a phase).
- Port the plugs first. Instrument connections are the foundation, and getting them right makes everything else testable.
- Port
MainSequencetomainphases, one measurement at a time, with validators in YAML rather than in the code. - Move
PreUUTinto theunit:block, adding a setup phase only if identification has real logic. - Move
PostUUTinto teardown, checking scope for anything touching shared hardware. - Delete the reporting and database callbacks. Run both systems in parallel for a batch and compare stored results before removing the old path.
- Add slots last, once a single unit passes reliably.
Step 6 is the one that stalls. It is worth agreeing early with whoever owns the quality records what "the results are in the new system" means, because that conversation, not the code, is usually the critical path.
Key Points
- Callbacks split into three groups: lifecycle, real logic, and result plumbing.
- Result plumbing — reports, database writes, per-step logging — is not ported; the engine records and uploads runs.
- Lifecycle callbacks become plug scope:
stationfor connections held across runs,executionfor shared resources,slotper unit. - Real logic becomes phases: setup for preconditions, teardown for post-test actions that always run.
- Failure handling and ordering become configuration:
on_first_failure,retry, anddepends_on.