Skip to main content

scpi-sim

Simulated SCPI lab instruments — a multimeter, a power supply and a relay multiplexer — that listen on a raw TCP socket like real LAN instruments on port 5025. Develop and test instrument automation without the hardware.

Demo: a simulated power supply driven from the command line

$ scpi-sim serve psu --load 24 &
scpi-sim: PSU listening on 127.0.0.1:5025 (Ctrl+C to stop)
$ scpi-sim query "*IDN?" "VOLT 12;CURR 1;:OUTP ON;:MEAS:CURR?" "VOLT 40" "SYST:ERR?"
scpi-sim,PSU,SIM00002,0.2.0
+5.00000000E-01
-222,"Data out of range"

Why

Test software for lab instruments is usually written while the instruments are busy, on someone else's bench, or not bought yet — and it is rarely tested in CI at all. scpi-sim gives you instruments that:

  • are real network servers — anything that can open a TCP socket can talk to them: PyVISA (TCPIP0::127.0.0.1::5025::SOCKET), C#, LabVIEW, NI MAX, nc, your own code
  • keep state like the real thing — settings persist, *RST resets them, errors go to the error queue, status registers (*ESR?, *STB?) work
  • behave physically — the power supply goes from constant voltage into current limit when its load draws too much, the multimeter has ranges and reports overload (+9.90000000E+37) when a reading does not fit
  • are strict where real instruments are strict — short/long forms only, the SCPI path rule in compound commands, standard error codes — so bugs in your command strings show up before they meet the hardware
  • work together on a bench — wire a supply, a multiplexer and a multimeter in a YAML file, and the multimeter measures what the supply delivers into its load
  • break on purpose — inject timeouts, dropped connections, wrong identities or drifting readings at runtime, and see whether your test code copes

Compared with pyvisa-sim

pyvisa-sim is a PyVISA backend that simulates devices described in YAML, inside your Python process. It is a good fit for unit tests of PyVISA code. scpi-sim takes a different approach:

pyvisa-sim scpi-sim
Runs as PyVISA backend in your process TCP server, one port per instrument
Usable from Python with PyVISA any language or tool
Devices defined in YAML Python classes (generic instruments included)

On top of that, scpi-sim has physical behaviour (CV/CC into a load, ranges, overload), instruments wired together on a bench, fault injection at runtime and a pytest plugin.

Quick start

Requires Python 3.10 or newer; the only dependency is PyYAML.

git clone https://github.com/steitec/scpi-sim.git && cd scpi-sim
pip install -e .

scpi-sim serve dmm          # multimeter on 127.0.0.1:5025

From another terminal, with the built-in client (works on Linux, macOS and Windows):

$ scpi-sim query "*IDN?"
scpi-sim,DMM,SIM00001,0.2.0

scpi-sim query [--host HOST] [--port PORT] MESSAGE... sends each message and prints the answer to every message that contains a ?.

Any raw TCP client works as well, e.g. nc:

echo '*IDN?' | nc -q1 localhost 5025    # Linux (netcat-openbsd)
echo '*IDN?' | nc -N localhost 5025     # macOS

Every message must end with a newline (\n), as on real LAN instruments: printf '*IDN?' | nc … without one gets no answer.

With PyVISA and the pure-Python backend (pip install pyvisa pyvisa-py):

import pyvisa

rm = pyvisa.ResourceManager("@py")
dmm = rm.open_resource("TCPIP0::127.0.0.1::5025::SOCKET",
                       read_termination="\n", write_termination="\n")
print(dmm.query("MEAS:VOLT:DC?"))

Run several instruments side by side on different ports (scpi-sim query --port 5026 … talks to the second one):

scpi-sim serve psu --port 5025 --channels 2 --load 24
scpi-sim serve dmm --port 5026 --volts 3.3
scpi-sim serve mux --port 5027

The server listens on 127.0.0.1 by default; use --host 0.0.0.0 to make it reachable from other machines. A host name that resolves to several addresses (localhost → 127.0.0.1 and ::1) is served on all of them, on the same port.

Instruments

All instruments support the IEEE 488.2 common commands *IDN?, *RST, *CLS, *ESR?, *ESE, *ESE?, *SRE, *SRE?, *STB?, *OPC, *OPC?, *WAI, *TST? and SYSTem:ERRor[:NEXT]?, SYSTem:ERRor:COUNt?, SYSTem:VERSion?.

Multimeter — scpi-sim serve dmm

Command
MEASure:VOLTage[:DC]? [range] DC voltage
MEASure:CURRent[:DC]? [range] DC current
MEASure:RESistance? [range] 2-wire resistance
CONFigure:VOLTage[:DC] [range], CONFigure:CURRent[:DC] [range], CONFigure:RESistance [range] select function and range
CONFigure? e.g. "VOLT AUTO", "RES +1.00000000E+04"
READ? measure with the current configuration

Ranges: 0.1/1/10/100/1000 V, 0.01/0.1/1/3/10 A, 100 Ω … 100 MΩ in decades. range is AUTO, DEF, MIN, MAX or a value (the smallest range that holds its magnitude is selected); an optional second parameter (resolution) is accepted. A reading above 120 % of the range returns +9.90000000E+37 (-9.90000000E+37 for negative readings).

Options: --volts 5.0, --amps 0.1, --ohms 1000 (the simulated input), --noise 50e-6 (relative standard deviation, 0 for exact readings), --seed N (reproducible noise).

Power supply — scpi-sim serve psu

Command
[SOURce[n]:]VOLTage[:LEVel][:IMMediate][:AMPLitude] <v> / …? [MIN|MAX|DEF] voltage setpoint, 0–30 V
[SOURce[n]:]CURRent[:LEVel][:IMMediate][:AMPLitude] <a> / …? [MIN|MAX|DEF] current limit, 0–3 A
OUTPut[n][:STATe] ON|OFF / OUTPut[n][:STATe]? output on/off, n = channel
OUTPut[n]:MODE? OFF, CV or CC
MEASure:VOLTage[:DC]?, MEASure:CURRent[:DC]? at the output terminals
INSTrument:NSELect <n> / ?, INSTrument[:SELect] CH<n> / ? select channel

SOURce<n> and OUTPut<n> address channel n directly; without a number the channel selected with INSTrument is used. *RST state: 0 V, 0.1 A, output off, channel 1 selected. Values accept units (500 mV, 250 mA).

Options: --channels 1, --load OHMS (resistive load on every output; without it the outputs are open and deliver no current; --load 0 is a short circuit, which drives the output straight into the current limit).

Multiplexer — scpi-sim serve mux

Command
ROUTe:CLOSe (@101,103:105) close channels
ROUTe:OPEN (@…), ROUTe:OPEN:ALL open channels
ROUTe:CLOSe? (@…), ROUTe:OPEN? (@…) one 1/0 per channel
ROUTe:CLOSe:STATe? closed channels, e.g. (@101,105)

Channels are numbered 101, 102, … A list with an invalid channel is rejected as a whole. Options: --channels 16 (1–99).

Bench

A bench file starts several instruments at once and wires them together:

# examples/bench.yaml
instruments:
  psu: {type: psu, port: 5025, channels: 2}
  dmm: {type: dmm, port: 5026}
  mux: {type: mux, port: 5027, channels: 16}
loads:
  rail_12v: {type: resistor, ohms: 24}      # 0.5 A at 12 V
  rail_3v3: {type: resistor, ohms: 33}      # 0.1 A at 3.3 V
wiring:
  - psu.ch1 -> rail_12v
  - psu.ch2 -> rail_3v3
  - mux.101 -> rail_12v
  - mux.102 -> rail_3v3
  - mux.common -> dmm.input
control: 5099
$ scpi-sim serve --bench examples/bench.yaml
scpi-sim: psu (PSU) listening on 127.0.0.1:5025
scpi-sim: dmm (DMM) listening on 127.0.0.1:5026
scpi-sim: mux (MUX) listening on 127.0.0.1:5027
scpi-sim: control on 127.0.0.1:5099
scpi-sim: bench running (Ctrl+C to stop)
$ scpi-sim query "SOUR1:VOLT 12;:SOUR1:CURR 1;:OUTP1 ON;*OPC?"
1
$ scpi-sim query --port 5027 "ROUT:CLOS (@101);*OPC?"
1
$ scpi-sim query --port 5026 "MEAS:VOLT?"
+1.20000310E+01
$ scpi-sim query "INST:NSEL 1;:MEAS:CURR?"
+5.00000000E-01
  • Instruments: type is psu, dmm or mux, port is the TCP port (0 = any free port); the other keys are the options of that instrument (psu: channels, max_volts, max_amps; dmm: volts, amps, ohms, noise, seed; mux: channels).
  • Wiring: each line A -> B connects two terminals: <psu>.ch<n>, <dmm>.input, <mux>.<channel>, <mux>.common or a node name of your choice. A closed multiplexer relay connects its channel to common.
  • Loads sit on nodes: {type: resistor, ohms: 24} (ohms: 0 is a short), or your own Python class with a current(volts) method, e.g. a diode or a device that draws constant current: {type: python, class: "mypackage.loads:Lamp", args: {amps: 0.3}}. The supply finds the operating point (constant voltage, or current limit) for any such load.
  • The multimeter measures the voltage of the net its input is on (0 V if nothing drives it) and the resistance of the resistors on it.
  • Two supply outputs never end up on the same net: in the file that is an error, and a ROUT:CLOS that would connect them is refused with -221,"Settings conflict".
  • Devices: your own device under test, written in Python, with its own terminals and an optional console port — see Writing your own device.
  • Mistakes in the file are reported with the offending line and exit code 2.

Every instrument has its own connection, so a command sent to one instrument can still be in flight when you query another one. Finish commands with *OPC? before measuring elsewhere, as you would on a real bench (ROUT:CLOS (@101);*OPC?).

Writing your own device

A device under test is a Python class with terminals: an input draws current from the net it is wired to, an output drives its net like a supply. The bench calls update() after every instrument message, so the device notices when its supply switches on, and it can talk over a console port like a serial line behind a terminal server.

import threading, time
from scpi_sim import Device

class SensorBoard(Device):
    terminals = {"vin": "input", "3V3": "output"}

    def __init__(self, serial="SB-00-0001"):
        super().__init__()                     # required: sets up console and bench hooks
        self.serial, self.powered_at, self.generation = serial, None, 0

    def update(self):                          # after every instrument message
        powered = self.input_voltage("vin") >= 4.5
        if powered and self.powered_at is None:
            self.powered_at = time.monotonic()
            self.generation += 1
            threading.Thread(target=self.boot, args=(self.generation,), daemon=True).start()
        elif not powered and self.powered_at is not None:
            self.powered_at = None
            self.generation += 1               # a boot still running is now stale

    def boot(self, generation):                # own thread: may sleep
        time.sleep(1.0)
        if generation == self.generation:      # not switched off meanwhile
            self.console.write("ready\nsensor> ")

    def current(self, terminal, volts):        # amps drawn on an input
        powered_at = self.powered_at           # read once, update() runs in another thread
        if powered_at is None:
            return 0.0
        return 0.4 if time.monotonic() - powered_at < 0.2 else 0.08

    def voltage(self, terminal):               # volts driven on an output
        return 3.3 if self.input_voltage("vin") >= 4.5 else 0.0

    def console_line(self, line):              # a line typed on the console
        if line == "id?":
            self.console.write(self.serial + "\nsensor> ")
devices:
  board:
    class: "sensor_board:SensorBoard"   # module next to the bench file, or installed
    args: {serial: "SB-24-0042"}
    console: 5028                       # optional: console on this TCP port
wiring:
  - psu.ch1 -> board.vin
  - mux.101 -> board.3V3
  - mux.common -> dmm.input
Member Called by What it does
terminals — {"name": "input" | "output"}; wired as <device>.<name>
current(terminal, volts) the supply amps drawn on an input at that voltage
voltage(terminal) the bench volts driven on an output; None reads as 0 V (the output still counts as a driver of its net)
update() after every instrument message look at input_voltage(), start timelines
input_voltage(terminal) your code voltage of the net an input is on
console.write(text) your code send text to everyone connected to the console
console_line(line) the console port a line someone typed

Rules that keep a bench responsive:

  • current(), voltage() and update() run in the instruments' threads — never block in them. Start a thread for anything that takes time (boot sequence) and compute time-dependent values from time.monotonic(). console_line() runs on the console's event loop: don't block there either.
  • The bench runs one update() at a time, but current() and voltage() can run while update() or your own threads change the device's state: read shared attributes once into a local, and use a lock for state that changes in several steps.
  • Call super().__init__() in your __init__ and write str to the console.
  • current() must not call input_voltage(): the input voltage is what the supply computes from current().
  • A device output and a supply (or two outputs) on one net is an error, like two supplies; a relay that would connect them is refused with -221.
  • An exception in current()/voltage() becomes -300 in the error queue of the instrument that asked; one in update() or console_line() is printed once and ignored.

Reach the console with any raw TCP client (nc localhost 5028) or with pyserial, as serial.serial_for_url(bench.console_url("board")). The complete example is in examples/devices: pytest examples/devices --scpi-bench examples/devices/bench.yaml.

Fault injection

Faults are injected at runtime — from Python, from pytest, or into a running bench through its control port:

$ scpi-sim fault dmm noise sigma=0.05
ok
$ scpi-sim fault psu no_response count=2
ok
$ scpi-sim fault --list
psu: no_response count=2
dmm: noise sigma=0.05
mux: -
$ scpi-sim fault dmm --clear
ok
Fault Parameters Effect
no_response count=1 the next count answers are not sent (the commands still execute)
delay seconds, count (default: until cleared) answers are sent seconds later
disconnect — the next message closes the connection
error code, message one entry is pushed into the error queue
idn value *IDN? answers value
offset value multimeter: added to every reading
noise sigma multimeter: replaces the noise of the readings

Faults stay active until cleared or used up (no_response, disconnect and delay with a count); *RST and *CLS do not clear them. They belong to the instrument, not to a connection. From Python: bench.dmm.inject("offset", value=0.2), bench.dmm.clear_faults().

The control port speaks one JSON object per line: {"instrument": "dmm", "inject": "noise", "params": {"sigma": 0.05}}, {"instrument": "dmm", "clear": true}, {"list": true}. It has no authentication: with --host 0.0.0.0 anyone who can reach the port can inject faults, so expose a bench only on a network you trust.

pytest plugin

Installing scpi-sim registers a pytest plugin. The scpi_bench fixture starts a fresh bench for every test, on free ports, and stops it afterwards:

import pyvisa
import pytest


def test_dead_multimeter_raises_timeout(scpi_bench):
    rm = pyvisa.ResourceManager("@py")
    dmm = rm.open_resource(scpi_bench.resource("dmm"), read_termination="\n",
                           write_termination="\n", timeout=500)
    scpi_bench.dmm.inject("no_response")
    with pytest.raises(pyvisa.errors.VisaIOError):
        dmm.query("MEAS:VOLT?")
    dmm.close()
    rm.close()
  • The bench comes from --scpi-bench FILE, or from the ini key scpi_bench = FILE (relative to the rootdir); without either it is a generic psu, dmm and mux, not wired.
  • scpi_bench.resource(name) gives the VISA resource string, scpi_bench.port(name) the port, scpi_bench.<name> the instrument object.
  • scpi_bench_factory(path_or_dict) starts further benches; a relative path is relative to the rootdir, like the ini key.

examples/test_example.py is a complete example: pytest examples/test_example.py --scpi-bench examples/bench.yaml.

SCPI behaviour

  • Keywords match in short or long form, case-insensitive (MEAS, measure) — but nothing in between (MEASU is an error), like on real instruments. Optional nodes in [ ] may be left out.
  • Compound commands follow the SCPI path rule: after ; a command continues at the level of the previous one, so SOUR:VOLT 5;CURR 1 sets SOUR:CURR. Start with : to go back to the root: MEAS:VOLT?;:MEAS:CURR? works, MEAS:VOLT?;MEAS:CURR? gives -113,"Undefined header" — exactly as a real instrument would.
  • Several queries in one message are answered in one line, separated by ;.
  • *RST resets the instrument settings; *CLS clears the error queue and the event status register. The error queue holds 20 entries; on overflow the last one becomes -350,"Queue overflow".

Error codes:

Code Message
-102 Syntax error
-104 Data type error
-108 Parameter not allowed (more parameters than the command takes)
-109 Missing parameter
-113 Undefined header
-114 Header suffix out of range
-131 Invalid suffix
-222 Data out of range
-223 Too much data (line longer than 64 KiB)
-224 Illegal parameter value
-300 Device-specific error
-350 Queue overflow

Docker

The image runs the example bench (supply on 5025, multimeter on 5026, multiplexer on 5027, control port on 5099):

docker run --rm -p 5025-5027:5025-5027 -p 127.0.0.1:5099:5099 ghcr.io/steitec/scpi-sim

Your own bench file (devices in Python modules next to it are found too):

docker run --rm -p 5025-5028:5025-5028 -v "$PWD/bench":/bench ghcr.io/steitec/scpi-sim

The control port has no authentication, hence 127.0.0.1: in front of it. Any other command works as well, e.g. docker run --rm -p 5025:5025 ghcr.io/steitec/scpi-sim serve dmm --host 0.0.0.0. In CI, start it as a service container and point your tests at TCPIP0::localhost::5025::SOCKET. The image is published with each release; until then build it yourself with docker build -t scpi-sim ..

Python API

Start instruments from your own tests, on a free port:

import pyvisa

from scpi_sim import InstrumentServer, PowerSupply

with InstrumentServer(PowerSupply(load_ohms=24), port=0) as server:
    rm = pyvisa.ResourceManager("@py")
    psu = rm.open_resource(f"TCPIP0::127.0.0.1::{server.port}::SOCKET",
                           read_termination="\n", write_termination="\n")
    psu.write("VOLT 12;CURR 1;:OUTP ON")
    print(psu.query("MEAS:CURR?"))              # +5.00000000E-01
    psu.write("CURR 0.25")
    print(psu.query("MEAS:VOLT?;:OUTP:MODE?"))  # +6.00000000E+00;CC

The simulated multimeter input can be changed from Python while a test runs: dmm = Multimeter(noise=0), then dmm.volts = 1.8.

A bench from Python:

from scpi_sim import Bench

with Bench.from_file("examples/bench.yaml") as bench:   # starts all servers
    print(bench.resource("dmm"))                         # TCPIP0::127.0.0.1::5026::SOCKET
    bench.psu.process("SOUR1:VOLT 12;:SOUR1:CURR 1;:OUTP1 ON")
    bench.mux.process("ROUT:CLOS (@101)")
    print(bench.dmm.process("MEAS:VOLT?"))               # about +1.2000E+01

Your own instrument is a subclass of Instrument with one method per command:

from scpi_sim import Instrument, scpi

class Thermometer(Instrument):
    model, serial = "THERMO", "SIM00042"

    @scpi("MEASure:TEMPerature?")
    def measure(self, call):
        return 23.5

Roadmap

  • later — profiles that behave similar to common bench instruments, VXI-11, a simple oscilloscope

License

MIT

Metadata

Release files for scpi-sim 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for scpi-sim 0.3.0
File Size Uploaded Provenance
scpi_sim-0.3.0.tar.gz 67.8 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for scpi-sim 0.3.0
File Interpreter ABI Platform
scpi_sim-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 111.4 kB

Release files / scpi_sim-0.3.0.tar.gz

Download URL scpi_sim-0.3.0.tar.gz
Size 67.8 kB
Tags Source
SHA-256 checksum
How to use checksums
ce6386d2c97cc579fa65968362d86eece51f709c68a8d1ac7f95918d6c867f67
BLAKE2b-256 checksum
How to use checksums
92d96e833921041a2970b323336d66490250f1ff733dae5c7aad43da36813dbc
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

Release files / scpi_sim-0.3.0-py3-none-any.whl

Download URL scpi_sim-0.3.0-py3-none-any.whl
Size 43.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
543a8bc8ed24f8ebbaab4437aa793561ab11dbc2c22f5e945063764e6d99b683
BLAKE2b-256 checksum
How to use checksums
2ce1688d9ea67dbe623288356d218be9c947c087797b2148dc5b5d64e3c4e007
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

Security for scpi-sim 0.3.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for scpi_sim-0.3.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page