Metadata-Version: 2.4
Name: scpi-sim
Version: 0.4.2
Summary: Simulated SCPI lab instruments (DMM, power supply, multiplexer) on a raw TCP socket
Author: Ralf Steinert
License-Expression: MIT
Project-URL: Homepage, https://github.com/steitec/scpi-sim
Project-URL: Repository, https://github.com/steitec/scpi-sim
Project-URL: Changelog, https://github.com/steitec/scpi-sim/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/steitec/scpi-sim/issues
Keywords: scpi,simulator,test automation,pyvisa,instrument,lab
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Manufacturing
Classifier: Framework :: Pytest
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Operating System :: OS Independent
Classifier: Topic :: Scientific/Engineering
Classifier: Topic :: Software Development :: Testing
Classifier: Topic :: System :: Hardware
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pyyaml>=6
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pyvisa>=1.13; extra == "dev"
Requires-Dist: pyvisa-py>=0.7; extra == "dev"
Requires-Dist: pyserial>=3.5; extra == "dev"
Requires-Dist: build>=1.2; extra == "dev"
Dynamic: license-file

# 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.
[Profiles](#profiles) make them answer like ten common real models from
Rohde & Schwarz, Keysight, Keithley and Siglent, 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, running the real simulator in the page.

![Demo: a simulated power supply driven from the command line](https://raw.githubusercontent.com/steitec/scpi-sim/main/docs/demo.gif)

```console
$ 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](https://github.com/pyvisa/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.

```bash
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):

```console
$ 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`:

```bash
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`):

```python
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):

```bash
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).

## 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.

```console
$ 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?` |

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:

```yaml
# examples/profiles/bench.yaml
instruments:
  psu: {profile: rs-hmp4040, port: 5025}
  daq: {profile: keysight-daq970a, port: 5026}
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
```

```python
# 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`. 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,
  VXI-11/HiSLIP/USB, exact timing or accuracy.
- 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](https://github.com/steitec/scpi-sim/issues).

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

## Bench

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

```yaml
# 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
```

```console
$ 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)
```

```console
$ 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](#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.

```python
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> ")
```

```yaml
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 (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()` 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.

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`](https://github.com/steitec/scpi-sim/tree/main/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:

```console
$ 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:

```python
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`](https://github.com/steitec/scpi-sim/blob/main/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):

```bash
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):

```bash
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:

```python
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:

```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:

```python
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), VXI-11, a simple oscilloscope

## License

MIT
