Skip to content
steitecPublic

About

Simulated SCPI lab instruments (DMM, power supply, multiplexer) on a raw TCP socket — test instrument automation without hardware

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

steitec

scpi-sim

CI PyPI Python License Demo

Simulated SCPI lab instruments — a multimeter, a power supply, a relay multiplexer and an oscilloscope — that listen on a raw TCP socket like real LAN instruments on port 5025, and on request over VXI-11 and HiSLIP (TCPIP::host::INSTR, hislip0). Develop and test instrument automation without the hardware. Profiles make them answer like fifteen common real models from Rohde & Schwarz, Keysight, Keithley, Siglent, Rigol and Tektronix, so code written for those instruments runs unchanged.

Try it in your browser: https://scpi-sim.steitec.net, a simulated power supply wired to a DAQ and an oscilloscope, running the real simulator in the page. Walkthroughs, CI recipes and troubleshooting are in the wiki; this README is the reference.

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 — and over VXI-11 and HiSLIP, so scripts that open TCPIP::host::INSTR or TCPIP::host::hislip0::INSTR run unchanged
  • 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
  • capture like a scope — waveforms come as real IEEE 488.2 binary blocks, through an 8-bit converter and a working edge trigger, so a wrong V/div clips and a level the signal never crosses leaves your *OPC? hanging
  • 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: raw socket per instrument, VXI-11, HiSLIP
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).

Oscilloscope — scpi-sim serve scope

$ scpi-sim serve scope --signal 1:square,freq=1k,vpp=3.3,offset=1.65,duty=0.25 &
$ scpi-sim query ":CHAN1:OFFS 1.65;:TRIG:LEV 1.65;:TIM:SCAL 5e-4" ":MEAS:FREQ?" ":MEAS:DUTY?" ":WAV:DATA?"
+1.00000000E+03
+2.50000000E+01
#800001000<1000 bytes of binary data>
Command
:RUN, :STOP, :SINGle, :DIGitize [<source>,...], :AUToscale, :TRIGger:FORCe acquisition
:TER?, :OPERegister:CONDition? a new trigger event; bit 3 = running
:CHANnel<n>:SCALe, :RANGe, :OFFSet, :DISPlay, :COUPling, :PROBe vertical (DC, AC, GND)
:TIMebase:SCALe, :RANGe, :POSition horizontal
:TRIGger[:EDGE]:LEVel, :SOURce, :SLOPe, :TRIGger:SWEep edge trigger, Auto or Normal
:ACQuire:SRATe?, :ACQuire:POINts? what the settings give
:WAVeform:SOURce, :FORMat (BYTE, WORD, ASCii), :BYTeorder, :UNSigned, :POINts, :POINts:MODE transfer settings
:WAVeform:DATA?, :PREamble?, :XINCrement?, :XORigin?, :XREFerence?, :YINCrement?, :YORigin?, :YREFerence? the data and how to scale it
:MEASure:VPP?, VMAX, VMIN, VAVerage, VRMS, VAMPlitude, VTOP, VBASe, FREQuency, PERiod, RISetime, FALLtime, PWIDth, NWIDth, DUTYcycle, NDUTy, OVERshoot, PREShoot [<source>]; +9.9E+37 if there is no result

It behaves like the instrument, including the ways test code goes wrong:

  • A stopped scope keeps its capture, with the scale it was taken with; a running one takes a new capture for every query.
  • The trigger looks at the signal. Without an edge through the level, Auto sweep runs unsynchronised, Normal keeps the old capture, and Single waits: *OPC? gets no answer, so your client times out, as on the real instrument.
  • Samples pass an 8-bit converter. Too small a V/div clips, too slow a timebase aliases, and without a complete period on the screen there is no frequency. Measurements are computed from the samples, so they go wrong the same way.
  • Sample rate and memory depth depend on the timebase and on how many channels are on. Raw memory can only be read from a stopped scope.

A capture costs nothing until it is read, and then about 1.7 Mpoints per second (pure Python): reading 20 Mpoints takes some 12 s, so a client needs a long enough timeout, or smaller reads.

Options: --signal CH:SHAPE[,KEY=VALUE...] (repeat per channel; shapes dc, sine, square, triangle, sawtooth; keys freq, vpp, offset, duty, phase, rise), --channels 2|4, --noise 0.002 (volts rms), --seed N. Default inputs: CH1 a 1 kHz sine of 2 Vpp, CH2 a 1 kHz square from 0 to 3.3 V, CH3 and CH4 0 V; only CH1 is on after *RST.

Profiles

A profile makes a simulated instrument behave like a specific real model: the model's *IDN?, its command dialect, its limits and ranges and its answer formats. Test code written for the real instrument runs against the simulator without changes — in CI, on a laptop, without hardware.

$ scpi-sim profiles
$ scpi-sim serve --profile rs-hmp4040 --load 24 &
scpi-sim: rs-hmp4040 (HMP4040) listening on 127.0.0.1:5025 (Ctrl+C to stop)
$ scpi-sim query "*IDN?" "INST OUT1" "APPL 12,1" "OUTP ON" "MEAS:CURR?"
Rohde&Schwarz,HMP4040,3629.6776.04/100001,2.70
0.500
Profile Instrument Kind Dialect highlights
rs-hmp4040 Rohde & Schwarz HMP4040 4 × 32 V / 10 A supply INST OUT1, APPL, OUTP:SEL + OUTP:GEN, 10.000
rs-nge103b Rohde & Schwarz NGE103B 3 × 32 V / 3 A supply HMP dialect, OUTP:SEL?, MEAS:POW?
keysight-e36312a Keysight E36312A 6 V / 5 A + 2 × 25 V / 1 A supply channel lists: VOLT 5,(@1), MEAS:VOLT? (@1:3), APPL P6V,5,1
siglent-spd3303x Siglent SPD3303X 2 × 32 V / 3.2 A + fixed 5 V CH1:VOLT 5, OUTP CH1,ON, SYST:STAT?
rs-hmc8012 Rohde & Schwarz HMC8012 multimeter MEAS? 40, FUNC VOLT, RANG:UPP
keysight-34461a Keysight Truevolt 34461A multimeter MEAS, CONF, CONF? with resolution, INIT/FETC?
keithley-dmm6500 Keithley DMM6500 (SCPI) multimeter :SENS:FUNC "VOLT", :READ?, :TRAC:DATA?
siglent-sdm3065x Siglent SDM3065X multimeter 34401A style, CONF? without resolution
keysight-daq970a Keysight DAQ970A + DAQM901A multiplexer with built-in DMM MEAS:VOLT:DC? (@101,102), ROUT:SCAN, INIT/FETC?
keithley-daq6510 Keithley DAQ6510 + 7700 multiplexer with built-in DMM ROUT:CLOS (@101) + READ?, ROUT:SCAN:CRE, TRAC:DATA?
keysight-dsox2024a Keysight InfiniiVision DSO-X 2024A 4-channel oscilloscope :WAV:PRE?, :WAV:DATA?, :DIG, :MEAS:VPP?; 0x01/0xFF = clipped
rigol-ds1054z Rigol DS1054Z 4-channel oscilloscope :WAV:MODE RAW, :WAV:STAR/:STOP, #9 blocks, :MEAS:ITEM? VPP,CHAN1, :TRIG:STAT?
tektronix-tbs2104b Tektronix TBS2104B 4-channel oscilloscope DAT:SOU CH1, WFMO?, CURV?, HEADER ON, MEASU:IMM:VAL?, ALLEV?
rs-rtb2004 Rohde & Schwarz RTB2004 4-channel oscilloscope CHAN1:DATA?, CHAN1:DATA:HEAD?, FORM UINT,8, MEAS1:RES?, RUNS
siglent-sds1104x-e Siglent SDS1104X-E 4-channel oscilloscope C1:VDIV 500mV, C1:WF? DAT2, WFSU, C1:PAVA? PKPK, SAST?, INR?

The five scopes share nothing on the wire: a Tektronix does not understand :WAV:DATA?, a Siglent answers C1:VDIV 5.00E-01V, and each one's data converts to volts with the formula from its own manual:

$ scpi-sim serve --signal 1:sine,freq=1k,vpp=2 --profile rigol-ds1054z &
$ scpi-sim query "*IDN?" ":TIM:SCAL 0.0002" ":WAV:PRE?" ":WAV:DATA?" ":MEAS:ITEM? FREQ,CHAN1"
RIGOL TECHNOLOGIES,DS1054Z,DS1ZA000000001,00.04.03.SP2
0,0,1200,1,2.000000e-06,-1.200000e-03,0,4.000000e-02,0,127
#9000001200<1200 bytes of binary data>
1.000040e+03

examples/scope/capture_rigol.py is that procedure with PyVISA, and rigol-ds1054z also works with the unmodified ds1054z client library over VXI-11.

In a bench file an instrument gets profile: instead of (or with a matching) type:. The profile fixes channels and limits; serial: gives one instrument its own serial number; without it, instruments of the same profile share the profile's default serial. A data acquisition unit's built-in multimeter reads the common of the card it measures (daq.common1, daq.common2, …; daq.common is card 1), so wiring the channels is enough:

# examples/profiles/bench.yaml
instruments:
  psu: {profile: rs-hmp4040, port: 5025}
  daq: {profile: keysight-daq970a, port: 5026}
  scope:
    profile: rigol-ds1054z
    port: 5027
    signals:
      1: {shape: sine, frequency: 1000, vpp: 2}
loads:
  rail_12v: {type: resistor, ohms: 24}
  rail_5v: {type: resistor, ohms: 10}
wiring:
  - psu.ch1 -> rail_12v
  - psu.ch2 -> rail_5v
  - daq.101 -> rail_12v
  - daq.102 -> rail_5v
  - scope.ch2 -> rail_12v
# code for the real instruments, unchanged (examples/profiles/test_profiles.py)
psu.write("INST OUT1"); psu.write("APPL 12,1"); psu.write("OUTP:SEL ON")
psu.write("INST OUT2"); psu.write("APPL 5,1"); psu.write("OUTP:SEL ON")
psu.write("OUTP:GEN ON")
volts = [float(v) for v in daq.query("MEAS:VOLT:DC? (@101,102)").split(",")]

Options a profile accepts besides port and serial: multimeters volts, amps, ohms, noise, seed; data acquisition units cards (DAQ970A 1–3, DAQ6510 1–2), noise, seed; oscilloscopes signals, noise, seed. Fault injection works on profiles as on any instrument (on a DAQ, offset and noise act on its built-in multimeter).

What profiles cover, and what not:

  • The commands typical test code uses — selecting channels, setting and reading back levels, outputs, measuring, ranges, relays and scans, errors, reset. Each profile's module docstring names the manual it follows and lists what is left out; a command the profile does not know gives -113.
  • Not: AC and the other exotic functions, trigger models and sample counts, list/sequence modes, protection, persona/emulation modes, Keithley TSP, USB, exact timing or accuracy. On oscilloscopes not: bandwidth limits, math and FFT, digital channels, serial decoding, trigger types other than edge, averaging and peak detect, delay and phase measurements.
  • The profiles follow the manufacturers' public programming manuals and are not verified on real hardware. If you own one of these instruments and something answers differently, please open an issue.

scpi-sim is not affiliated with or endorsed by Rohde & Schwarz, Keysight Technologies, Tektronix/Keithley, Siglent or Rigol; product names and trademarks belong to their owners.

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, mux or scope, 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; scope: channels, noise, seed, signals: {1: {shape: sine, frequency: 1000, vpp: 2}}).
  • Wiring: each line A -> B connects two terminals: <psu>.ch<n>, <dmm>.input, <mux>.<channel>, <mux>.common, <scope>.ch<n> (shows the DC voltage of its net, or a device's waveform) 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)
waveform(terminal) an oscilloscope a Signal or Transient for that output; None shows voltage() as a flat line
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 (a client that doesn't read loses output, like on a serial line)
console_line(line) the console port a line someone typed

Rules that keep a bench responsive:

  • current(), voltage(), waveform() 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() (doing it anyway raises RuntimeError, so -300).
  • 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.

Waveforms on the oscilloscope

A scope channel wired to a device output shows voltage() as a flat line. For more, give the device a waveform(terminal): a Signal repeats forever, a Transient happens once, for instance at power-on.

import time
from scpi_sim import Device, Signal, Transient

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

    def update(self):
        if self.input_voltage("vin") >= 10 and self.rail is None:
            # (seconds after the event, volts); ripple starts when the shape has settled
            self.rail = Transient(time.monotonic(), ((0, 0), (2e-3, 0), (5e-3, 3.3)),
                                  ripple=Signal("sine", frequency=300e3, vpp=0.03))

    def waveform(self, terminal):
        return self.rail

    def voltage(self, terminal):
        rail = self.rail                     # what a multimeter reads
        return 0.0 if rail is None else rail.at(time.monotonic() - rail.start)

Then capture it the way you would on the bench: arm, switch on, read.

scope.query(":TRIG:SOUR CHAN1;:TRIG:LEV 1.65;:SING;:OPER:COND?")  # a query: wait until it is armed
psu.write("VOLT 12;CURR 3;:OUTP ON")
while int(scope.query(":OPER:COND?")) & 8:                        # bit 3: still running
    pass
codes = scope.query_binary_values(":WAV:DATA?", datatype="B")

The trigger finds the edge in the shape, so a Single armed after the event keeps waiting, as on a real scope. To see a current, add an output that gives volts per amp, like a current probe or a shunt. examples/power_on checks inrush, a load step, the rail ramp and its ripple: pytest examples/power_on --scpi-bench examples/power_on/bench.yaml.

Limits: the part of a capture after the trigger is computed when the trigger fires, so switching off right after it does not cut it short. A channel wired directly to a supply output shows its DC voltage only.

Reaching the console

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, oscilloscope: added to every reading
noise sigma multimeter (relative), oscilloscope (volts rms): replaces the noise
no_trigger — oscilloscope: the trigger never fires
signal channel, shape, frequency, vpp, offset, duty, phase oscilloscope: replaces what is at a channel's input, until the faults are cleared

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.

VXI-11 and HiSLIP

Many PyVISA scripts do not open a raw socket but the instrument's VISA LAN protocol: TCPIP::192.168.1.20::INSTR (VXI-11) or TCPIP::192.168.1.20::hislip0::INSTR (HiSLIP). scpi-sim serves both next to the raw sockets, so such a script runs against the simulator once the address points to it:

$ scpi-sim serve --hislip --vxi11 --no-portmapper --profile keysight-34461a
scpi-sim: keysight-34461a (34461A) listening on 127.0.0.1:5025 (Ctrl+C to stop)
scpi-sim: HiSLIP on 127.0.0.1:4880
scpi-sim: VXI-11 on 127.0.0.1:40651, no port mapper
scpi-sim:   keysight-34461a (34461A): TCPIP0::127.0.0.1::hislip0::INSTR
scpi-sim:   keysight-34461a (34461A): TCPIP0::127.0.0.1,40651::inst0::INSTR
Option Meaning
--hislip serve HiSLIP, port 4880 (--hislip-port)
--vxi11 serve VXI-11; the core channel gets any free port (--vxi11-port)
--portmapper-port the port mapper VXI-11 clients ask for the core port, default 111
--no-portmapper VXI-11 without port mapper

The options go before the instrument (serve --hislip psu) and work with --profile and --bench too.

Port 111. A VXI-11 client first asks the port mapper on port 111 where the instrument listens. Binding port 111 takes privileges on Linux and macOS, and rpcbind may already use it. Choose one:

  • --no-portmapper and put the core port into the resource string: TCPIP0::127.0.0.1,40651::inst0::INSTR (PyVISA-py understands this form; NI-VISA and Keysight VISA do not).
  • the Docker image, which serves the port mapper on 111 (see Docker).
  • on Linux: sudo setcap cap_net_bind_service=+ep "$(readlink -f "$(which python3)")" — this allows every program run by that Python to use low ports.

Device names. One simulator may serve several instruments, so the LAN device name picks one: for a single instrument inst0 and hislip0; on a bench inst<N> / hislip<N> by position in the bench file, or the instrument name: TCPIP0::host::psu::INSTR (VXI-11), TCPIP0::host::hislip_psu::INSTR (HiSLIP — PyVISA-py recognises HiSLIP by the hislip prefix). bench.resource("psu", "hislip") and bench.resource("psu", "vxi11") build these strings.

What behaves like a real instrument:

  • Answers wait in an output buffer until the client reads them; reading when nothing is waiting runs into the client's timeout, like on a real instrument after a command without a query.
  • A new message before the answer was read discards the answer and gives -410,"Query INTERRUPTED" (IEEE 488.2).
  • The status byte (read_stb(), VXI-11 device_readstb, HiSLIP AsyncStatusQuery) has MAV (16) while an answer waits.
  • Device clear (clear()) drops pending input and answers; the error queue and the settings stay.
  • Locks (lock_excl(); VXI-11 device_lock, HiSLIP AsyncLock) are exclusive and shared across both protocols.
  • Faults work as on the raw socket: no_response, delay (also cancelled by a device clear) and disconnect (closes the link or HiSLIP session).

Not supported: service requests (SRQ, interrupt channel), device_docmd, HiSLIP overlapped mode and HiSLIP 2.0 (TLS, authentication); shared lock requests are granted as exclusive locks. Tested with PyVISA-py; not yet verified against NI-VISA or Keysight VISA — reports welcome.

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.
  • The bench serves HiSLIP and VXI-11 too (free ports, no port mapper): scpi_bench.resource("dmm", "hislip"), scpi_bench.resource("dmm", "vxi11").
  • 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
-410 Query INTERRUPTED (VXI-11 and HiSLIP only, see above)

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.

The image also serves HiSLIP (4880) and VXI-11 (port mapper on 111, core channel on 5098), so TCPIP0::localhost::psu::INSTR and TCPIP0::localhost::hislip_psu::INSTR work from the host:

docker run --rm -p 5025-5027:5025-5027 -p 111:111 -p 111:111/udp -p 5098:5098 -p 4880:4880 \
  ghcr.io/steitec/scpi-sim

The port mapper tells clients the core port inside the container, so map 5098 to the same port outside. On Linux, publishing port 111 fails if rpcbind runs on the host; leave the port mapper out then and use TCPIP0::localhost,5098::psu::INSTR with PyVISA-py. 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 — more profiles (reports from owners of the real instruments welcome), service requests over VXI-11/HiSLIP, a simple oscilloscope

License

MIT

About

Simulated SCPI lab instruments (DMM, power supply, multiplexer) on a raw TCP socket — test instrument automation without hardware

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages