Migrating from Legacy Systems

Migrate from NI VeriStand to Python

Split a VeriStand system into what stays on the real-time engine and what moves to Python plugs, phases and measurements, with the licensing outcome.

JJulien Buteau
advanced14 min readSeptember 22, 2026
Validate one test before a wider rollout.
Existing test
Connect results
Compare one run
Roll out

NI VeriStand is two products sold as one. The first is a real-time execution engine: it runs plant models and maps I/O on a PXI or CompactRIO target at fixed loop rates. The second is everything around that engine: stimulus profiles, the workspace, TDMS logging, alarms, the .NET automation API, and the result files. Python replaces the second part well. It replaces the first part only in specific cases, and a migration plan that does not separate the two ends up either rebuilding a real-time platform by accident or keeping licenses it no longer needs.

This guide sorts a VeriStand system into layers, says which ones move to Python, which stay on the engine, and which are deleted rather than ported.

What a VeriStand System Contains

LayerVeriStand artifactOutcome
Real-time engineSystem definition (.nivssdf), hardware I/O mapping, compiled models, custom devicesStays, or is removed because it was never needed
Deterministic test logicReal-time sequences (.nivsseq), stimulus profiles (.nivsstimprof)Ported: niveristand for deterministic parts, plain phases for the rest
Reactive configurationAlarms, procedures, calculated channelsSplit: protection stays, pass/fail criteria become validators
Operator interactionWorkspace screensPorted: unit identification and operator UI phases
Data loggingTDMS files from the Data Logging tool or profile loggingPorted: multi-dimensional measurements, raw file attached
AutomationTestStand calls, macros, .NET API scriptsPorted: a VeriStand plug and phases
ResultsTDMS on a share, DIAdem reports, custom SQLNot ported: the engine uploads runs

The rest of the guide goes through these layers in the order a migration usually touches them. The first decision, the engine, is the one everything else depends on.

Decide First: Do You Need the Engine?

There is no open-source equivalent of the VeriStand real-time engine. A Linux machine with the PREEMPT_RT patch, FMPy for model execution and your own I/O code can replace it, but that is a platform build, not a migration, and it takes a team a few quarters. The realistic question is which of three situations you are in.

Genuine HIL. A plant model runs at 1 to 10 kHz, the loop is closed through hardware I/O, the unit under test is an ECU or a controller, and fault injection happens on sensor lines. The engine stays. What moves to Python is the test logic, the logging, the automation and the results. This is the common case and most of this guide addresses it.

Bench work that landed on VeriStand because it was installed. A power supply, a DAQ card and a multimeter, no plant model, no loop to close. The engine does nothing here that nidaqmx and PyVISA do not, and it costs a deployment license per bench. Python talks to the instruments directly. See Removing the Engine below.

Model-in-the-loop on Windows. A VeriStand PC license running an FMU against a test sequence, with no real-time target. FMPy loads the same FMU on the host, and timing accuracy on Windows is enough for this class of test because there was no deterministic target to begin with.

The licensing consequence matters because it is the budget line the migration is usually justified on. A VeriStand Full development seat is a subscription. A rig that only runs a pre-configured system definition needs an Operator license, which is a one-time purchase. After migration, development happens in Python on any machine, and each rig that keeps its engine needs one Operator license and nothing else. NI does not publish a complete price list and prices vary by region and agreement, so the figures below are distributor list prices from 2023 to 2025 and only useful as orders of magnitude.

LicenseTypeOrder of magnitude
VeriStand FullDevelopment, per seat, yearly subscription3,400 to 4,000 USD or EUR per year
VeriStand PCDevelopment without real-time target, yearly2,400 per year
VeriStand OperatorDeployment, per rig, perpetual3,000 one-time

A team with three Full seats and four rigs typically ends with four Operator licenses and one Full seat kept to open the archive.

The VeriStand Plug

If the engine stays, the first piece of Python to write is a plug that owns the connection to it. The niveristand package installs with pip and wraps the .NET client API that ships with VeriStand, so this runs on the Windows host that already talks to the target.

procedure.yaml
plugs:  - name: HIL Rig    key: rig    python: plugs.veristand:VeriStandRig    scope: station    config:      sysdef: "C:\\HIL\\Powertrain\\Powertrain.nivssdf"      gateway: "localhost"      log_dir: "C:\\HIL\\Logs"
plugs/veristand.py
20 lines
from niveristand.legacy import NIVeriStandclass VeriStandRig:    def __init__(self, sysdef: str, log_dir: str, gateway: str = "localhost", deploy_timeout_ms: int = 120000):        self.log_dir = log_dir        NIVeriStand.LaunchNIVeriStand()        NIVeriStand.WaitForNIVeriStandReady()        self._ws = NIVeriStand.Workspace2(gateway)        # deploy=True pushes the system definition to the target; takes a minute on PXI        self._ws.ConnectToSystem(sysdef, True, deploy_timeout_ms)    def get(self, channel: str) -> float:        return self._ws.GetSingleChannelValue(channel)    def set(self, channel: str, value: float) -> None:        self._ws.SetSingleChannelValue(channel, value)    def __del__(self):        self._ws.DisconnectFromSystem("", True)

Two details of the plug carry most of the value.

Scope is station. Deploying a system definition to a PXI target takes on the order of a minute, and a station plug does it once, when the first run needs it, then keeps the connection across runs. Every unit tested after the first starts immediately. If a rig has to be redeployed between units, execution scope does that, at the cost of the deploy time per run.

Channel paths are the interface. A VeriStand channel is addressed by its path in the system definition, such as Targets/Controller/Simulation Models/Models/Engine/Outports/RPM, or by an alias like Aliases/DesiredRPM. Aliases are the better contract, because a model or hardware change that moves a channel breaks a path but not an alias. Define aliases for everything the test touches and keep the .nivsalias file next to the procedure.

Stimulus Profiles and Real-Time Sequences

A stimulus profile is the closest thing VeriStand has to a test sequence: it sets channels, waits, checks conditions and logs. A real-time sequence is the deterministic part, compiled and run on the engine. They port along two different paths, and choosing wrong is the most common way a migrated HIL test gets slower or less trustworthy.

Deterministic on the engine. niveristand converts a decorated Python function into a real-time sequence and runs it on the target with run_py_as_rtseq. The function keeps VeriStand's timing guarantees, at the cost of VeriStand's restrictions: values are typed wrappers accessed through .value, comparisons take two operands only, and the body can only use the niveristand library, because it is compiled, not interpreted.

sequences/rpm_step.py
from niveristand import NivsParam, nivs_rt_sequencefrom niveristand.clientapi import ChannelReference, DoubleValuefrom niveristand.library import wait_until_settled@NivsParam("setpoint", DoubleValue(0), NivsParam.BY_VALUE)@nivs_rt_sequencedef rpm_step(setpoint):    desired = ChannelReference("Aliases/DesiredRPM")    actual = ChannelReference("Aliases/ActualRPM")    settled = DoubleValue(0)    desired.value = setpoint.value    # upper, lower, tolerance window, timeout: all evaluated on the engine tick    settled.value = wait_until_settled(actual, 9999999, setpoint.value - 50, 25, 10)    return settled.value
phases/rpm_response.py
from niveristand import run_py_as_rtseqfrom sequences.rpm_step import rpm_stepdef rpm_response(phase, measurements, rig):    result = run_py_as_rtseq(rpm_step, rtseq_params={"setpoint": 2500})    if result != 0:        phase.fail("RPM did not settle within 10 s")        return    measurements.actual_rpm = rig.get("Aliases/ActualRPM")

Host-side in a phase. Setting a setpoint, waiting a few seconds and reading a steady-state value does not need the engine's timing. The gateway round trip is in the tens of milliseconds, and for a test whose tolerances are in seconds that is invisible. This path has no restrictions: any library, any data structure, breakpoints in a normal debugger.

The rule that separates the two: if the sequence reacts to a signal within a step, or its timing requirement is under about 100 ms, it stays deterministic. Everything else moves to a phase, which is where it becomes easy to read and test.

A stimulus profile that mixed both, which most do, becomes one phase per test step, with the deterministic parts as sequence functions the phase calls. depends_on replaces the profile's step ordering, and timeout on each phase replaces the profile's step timeout.

Alarms, Procedures and Calculated Channels

These three features are configured in the system definition and executed by the engine, and they serve two different purposes that VeriStand does not distinguish.

Protection. An alarm on battery current that triggers a procedure cutting the supply protects hardware within one engine tick. That stays in the system definition, because Python on the host cannot react in time, and because a protection that depends on a test running is not a protection.

Pass/fail criteria. An alarm on coolant temperature that exists so the operator sees a red indicator when the test should fail is not protection, it is a limit. That becomes a validator on the logged channel, where it is versioned with the test, applied identically on every rig and recorded with the result:

procedure.yaml
23 lines
main:  - name: Thermal Soak    python: phases.thermal_soak    timeout: 25m    measurements:      - name: Coolant Temperature        title: Coolant Temperature        x_axis:          legend: Time          unit: s        y_axis:          - legend: Temperature            key: temperature            unit: °C            aggregations:              - type: max                validators:                  - operator: "<="                    expected_value: 105              - type: mean                validators:                  - operator: "<="                    expected_value: 92

Calculated channels follow the same split. One that feeds a model or an alarm stays. One that existed only to log or display a derived value, such as power from voltage and current, is a line of NumPy in the phase that reads the log.

Data Logging: TDMS to Measurements

VeriStand logs to TDMS, either continuously through the Data Logging tool or per step from a stimulus profile. Those files are the raw record and keeping them is right. What changes is that the test now extracts what it needs from the file, validates it, and attaches the original.

phases/thermal_soak.py
31 lines
import timefrom pathlib import Pathimport numpy as npfrom nptdms import TdmsFiledef thermal_soak(measurements, attach, log, rig):    # The Data Logging tool in the system definition starts a 1 kHz TDMS log    # while Aliases/LogTrigger is high, into the folder given in the plug config    rig.set("Aliases/HeaterEnable", 1)    rig.set("Aliases/LogTrigger", 1)    time.sleep(20 * 60)    rig.set("Aliases/LogTrigger", 0)    rig.set("Aliases/HeaterEnable", 0)    log_path = max(Path(rig.log_dir).glob("*.tdms"), key=lambda p: p.stat().st_mtime)    tdms = TdmsFile.read(log_path)    channel = tdms.groups()[0]["CoolantTemp"]  # names follow the log configuration    temperature = channel[:]    time_s = channel.time_track()    # 1 kHz over 20 min is 1.2 M points; the chart needs about a thousand    step = max(1, len(temperature) // 1000)    measurements.coolant_temperature.x_axis = time_s[::step].tolist()    measurements.coolant_temperature.y_axis.temperature = temperature[::step].tolist()    measurements.coolant_temperature.y_axis.temperature.aggregations.max = float(np.max(temperature))    measurements.coolant_temperature.y_axis.temperature.aggregations.mean = float(np.mean(temperature))    attach.file(log_path, "thermal_soak.tdms")    log.info(f"Logged {len(temperature)} samples, max {np.max(temperature):.1f} °C")

The aggregations are computed on the full-rate data, and the chart is decimated. That distinction is what makes the result trustworthy: a maximum computed after decimation can miss a spike, and a chart of a million points is unreadable in any tool. The raw TDMS stays attached to the run for the cases where someone needs the spike itself.

npTDMS reads TDMS files without any NI software installed, so the same phase runs on a Linux analysis machine against archived logs, which is also how historical data gets validated against the new limits before cutover.

Workspace: Operator UI and Monitoring

A VeriStand workspace screen does two jobs. It shows live channels while the rig runs, and it collects operator input: which unit is on the bench, whether the fixture is closed, whether to proceed after a warning.

The second job moves. Unit identification is declared in the unit: block and prompted before the test starts, and confirmations become operator UI phases in the procedure, so they are versioned and recorded like everything else. A workspace button that started a profile becomes the start of the run.

The first job does not need to move on day one. A rig that keeps its engine can keep its workspace open for live monitoring, and the operator UI runs beside it. Once the run streams to the dashboard, the live view there covers most of what the workspace was used for, at which point the workspace becomes a debugging tool rather than the operator's screen.

Results: Do Not Port

The result path in a VeriStand installation is usually a TDMS folder on a share, a DIAdem script that turns it into a PDF, and sometimes a SQL insert written by hand. It is the part teams spent the most on and the part that should not survive.

The engine uploads the run: procedure, unit, phases, measurements with their validators, attachments, outcome. Runs queue offline when the rig loses the network and drain on reconnect with the original timestamp. A limit change is a change to procedure.yaml, deployed to every rig at once, rather than a DIAdem script edited in three places.

When a specific report format is a contractual deliverable, generate it from the stored data through the API, once, off the rig. Putting the report generator inside the test means every rig needs DIAdem and a formatting bug fails a passing unit. For years of existing TDMS logs, Import Historical Test Data via the API covers the backfill, and the npTDMS phase above is the extraction step of it.

Removing the Engine

This section applies to the second and third situations: bench work that never needed determinism, and model-in-the-loop on Windows. The plug for the rig is replaced by plugs for the instruments, and the mapping is mostly one library per device.

VeriStand itemPython equivalent
NI-DAQmx device in the system definitionnidaqmx
NI-XNET CAN or LIN portpython-can with the nixnet interface, cantools for the DBC
PXI DMM, PXI switchnidmm, niswitch
SCPI power supply or loadPyVISA
Compiled Simulink modelRe-export as FMU, run with FMPy
FMUFMPy
Custom device written in LabVIEWRewrite as a plug, or keep the engine

The last row is the honest limit. A custom device is LabVIEW code running inside the engine, often for a protocol or a signal generator NI does not ship. If it does real-time work, it is the reason to stay in the first situation. If it wraps an instrument, it is a plug.

The other limit is timing. Python on Windows sleeps with millisecond-scale jitter, and a software loop above about 100 Hz is not reliable from a phase. For a bench test that reads a steady state every second this is irrelevant. For a control loop it is the reason the engine existed, and no amount of Python replaces it.

Model-in-the-loop moves cleanly because FMPy runs the same FMU the VeriStand PC license ran, in-process, and the phase drives the co-simulation step by step:

plugs/plant.py
31 lines
from fmpy import extract, read_model_descriptionfrom fmpy.fmi2 import FMU2Slaveclass PlantModel:    def __init__(self, fmu_path: str, step_s: float = 0.001):        self._desc = read_model_description(fmu_path)        self._refs = {v.name: v.valueReference for v in self._desc.modelVariables}        self._fmu = FMU2Slave(            guid=self._desc.guid,            unzipDirectory=extract(fmu_path),            modelIdentifier=self._desc.coSimulation.modelIdentifier,        )        self._fmu.instantiate()        self._fmu.setupExperiment(startTime=0.0)        self._fmu.enterInitializationMode()        self._fmu.exitInitializationMode()        self._t = 0.0        self._dt = step_s    def step(self, inputs: dict[str, float]) -> None:        self._fmu.setReal([self._refs[k] for k in inputs], list(inputs.values()))        self._fmu.doStep(currentCommunicationPoint=self._t, communicationStepSize=self._dt)        self._t += self._dt    def get(self, name: str) -> float:        return self._fmu.getReal([self._refs[name]])[0]    def __del__(self):        self._fmu.terminate()        self._fmu.freeInstance()

An FMU that misbehaves takes the Python process with it, because it is the vendor's C code running in-process. The engine runs each plug in its own process, so a crashing model fails the phase and is respawned rather than taking the station down.

A Migration Order That Works

  1. Sort the system into the layers table, one line per artifact. The engine decision falls out of the first column: if nothing needs a deterministic loop, plan for the engine to go.
  2. Write the VeriStand plug and run one existing stimulus profile from a phase, with the result uploaded. This is a day of work and it proves the rig, the gateway and the upload path before anything is ported.
  3. Port stimulus profiles one at a time. Deterministic parts become niveristand sequence functions, the rest becomes phase code. Run the old profile and the new phase on the same unit and compare the TDMS logs before retiring the profile.
  4. Move pass/fail out of alarms and DIAdem scripts into validators. Keep the protective alarms in the system definition and mark them as such.
  5. Replace workspace interaction with the unit: block and operator UI phases. Leave the workspace open for monitoring until the dashboard covers it.
  6. Delete the result path. Agree with whoever owns the quality records what "the results are in the new system" means before this step, because that conversation is the critical path, not the code.
  7. Re-license. One Operator license per rig that keeps its engine, one Full seat to open the archive, and nothing on rigs that lost the engine.

Step 3 is where the time goes, and the comparison in it is not optional. A host-side phase that replaced a deterministic sequence will produce plausible numbers with slightly different timing, and the TDMS comparison is the only thing that catches it.

Key Points

  • VeriStand is a real-time engine plus everything around it. Python replaces the second; the first stays for genuine HIL and goes for bench work that never needed it.
  • The engine is reached through one plug with station scope, so the system definition deploys once and every unit after the first starts immediately.
  • Sequences with timing requirements under about 100 ms stay deterministic through niveristand. Everything else becomes a phase.
  • Protective alarms stay in the system definition. Alarms that were pass/fail criteria become validators.
  • TDMS stays the raw record. Aggregations are computed at full rate, charts are decimated, and the file is attached to the run.
  • Result plumbing, DIAdem reports and SQL inserts are not ported. Re-licensing to Operator seats per rig is the budget outcome.

More Guides

Put this guide into practice