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.
$ 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"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 openTCPIP::host::INSTRorTCPIP::host::hislip0::INSTRrun unchanged - keep state like the real thing — settings persist,
*RSTresets 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
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.
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:5025From another terminal, with the built-in client (works on Linux, macOS and Windows):
$ scpi-sim query "*IDN?"
scpi-sim,DMM,SIM00001,0.2.0scpi-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 # macOSEvery 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 5027The 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.
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?.
| 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).
| 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).
| 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).
$ 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.
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+03examples/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.
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:
typeispsu,dmm,muxorscope,portis 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 -> Bconnects 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 tocommon. - Loads sit on nodes:
{type: resistor, ohms: 24}(ohms: 0is a short), or your own Python class with acurrent(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:CLOSthat 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?).
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()andupdate()run in the instruments' threads — never block in them. Start a thread for anything that takes time (boot sequence) and compute time-dependent values fromtime.monotonic().console_line()runs on the console's event loop: don't block there either.- The bench runs one
update()at a time, butcurrent()andvoltage()can run whileupdate()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 writestrto the console. current()must not callinput_voltage(): the input voltage is what the supply computes fromcurrent()(doing it anyway raisesRuntimeError, 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-300in the error queue of the instrument that asked; one inupdate()orconsole_line()is printed once and ignored.
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.
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.
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.
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-portmapperand 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-11device_readstb, HiSLIPAsyncStatusQuery) 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-11device_lock, HiSLIPAsyncLock) are exclusive and shared across both protocols. - Faults work as on the raw socket:
no_response,delay(also cancelled by a device clear) anddisconnect(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.
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 keyscpi_bench = FILE(relative to the rootdir); without either it is a genericpsu,dmmandmux, 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.
- Keywords match in short or long form, case-insensitive (
MEAS,measure) — but nothing in between (MEASUis 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, soSOUR:VOLT 5;CURR 1setsSOUR: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
;. *RSTresets the instrument settings;*CLSclears 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) |
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-simYour 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-simThe 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-simThe 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 ..
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;CCThe 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+01Your 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- later — more profiles (reports from owners of the real instruments welcome), service requests over VXI-11/HiSLIP, a simple oscilloscope
MIT
