scpi-sim 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scpi_sim-0.3.0/CHANGELOG.md +75 -0
- scpi_sim-0.3.0/LICENSE +21 -0
- scpi_sim-0.3.0/MANIFEST.in +6 -0
- scpi_sim-0.3.0/PKG-INFO +556 -0
- scpi_sim-0.3.0/README.md +520 -0
- scpi_sim-0.3.0/docs/demo.tape +28 -0
- scpi_sim-0.3.0/examples/bench.yaml +18 -0
- scpi_sim-0.3.0/examples/devices/bench.yaml +18 -0
- scpi_sim-0.3.0/examples/devices/sensor_board.py +79 -0
- scpi_sim-0.3.0/examples/devices/test_sensor_board.py +62 -0
- scpi_sim-0.3.0/examples/test_example.py +53 -0
- scpi_sim-0.3.0/pyproject.toml +52 -0
- scpi_sim-0.3.0/setup.cfg +4 -0
- scpi_sim-0.3.0/src/scpi_sim/__init__.py +15 -0
- scpi_sim-0.3.0/src/scpi_sim/bench.py +534 -0
- scpi_sim-0.3.0/src/scpi_sim/cli.py +226 -0
- scpi_sim-0.3.0/src/scpi_sim/console.py +62 -0
- scpi_sim-0.3.0/src/scpi_sim/control.py +132 -0
- scpi_sim-0.3.0/src/scpi_sim/device.py +97 -0
- scpi_sim-0.3.0/src/scpi_sim/errors.py +33 -0
- scpi_sim-0.3.0/src/scpi_sim/faults.py +115 -0
- scpi_sim-0.3.0/src/scpi_sim/instrument.py +273 -0
- scpi_sim-0.3.0/src/scpi_sim/instruments/__init__.py +5 -0
- scpi_sim-0.3.0/src/scpi_sim/instruments/dmm.py +114 -0
- scpi_sim-0.3.0/src/scpi_sim/instruments/mux.py +67 -0
- scpi_sim-0.3.0/src/scpi_sim/instruments/psu.py +153 -0
- scpi_sim-0.3.0/src/scpi_sim/loads.py +73 -0
- scpi_sim-0.3.0/src/scpi_sim/pytest_plugin.py +89 -0
- scpi_sim-0.3.0/src/scpi_sim/scpi/__init__.py +0 -0
- scpi_sim-0.3.0/src/scpi_sim/scpi/params.py +75 -0
- scpi_sim-0.3.0/src/scpi_sim/scpi/parser.py +99 -0
- scpi_sim-0.3.0/src/scpi_sim/scpi/pattern.py +87 -0
- scpi_sim-0.3.0/src/scpi_sim/transport.py +161 -0
- scpi_sim-0.3.0/src/scpi_sim.egg-info/PKG-INFO +556 -0
- scpi_sim-0.3.0/src/scpi_sim.egg-info/SOURCES.txt +60 -0
- scpi_sim-0.3.0/src/scpi_sim.egg-info/dependency_links.txt +1 -0
- scpi_sim-0.3.0/src/scpi_sim.egg-info/entry_points.txt +5 -0
- scpi_sim-0.3.0/src/scpi_sim.egg-info/requires.txt +8 -0
- scpi_sim-0.3.0/src/scpi_sim.egg-info/top_level.txt +1 -0
- scpi_sim-0.3.0/tests/conftest.py +16 -0
- scpi_sim-0.3.0/tests/helpers.py +157 -0
- scpi_sim-0.3.0/tests/test_bench.py +210 -0
- scpi_sim-0.3.0/tests/test_bench_devices.py +167 -0
- scpi_sim-0.3.0/tests/test_bench_threads.py +78 -0
- scpi_sim-0.3.0/tests/test_cli.py +228 -0
- scpi_sim-0.3.0/tests/test_console.py +125 -0
- scpi_sim-0.3.0/tests/test_control.py +99 -0
- scpi_sim-0.3.0/tests/test_device.py +85 -0
- scpi_sim-0.3.0/tests/test_dmm.py +102 -0
- scpi_sim-0.3.0/tests/test_errors.py +18 -0
- scpi_sim-0.3.0/tests/test_faults.py +168 -0
- scpi_sim-0.3.0/tests/test_instrument.py +124 -0
- scpi_sim-0.3.0/tests/test_loads.py +50 -0
- scpi_sim-0.3.0/tests/test_mux.py +53 -0
- scpi_sim-0.3.0/tests/test_packaging.py +109 -0
- scpi_sim-0.3.0/tests/test_params.py +56 -0
- scpi_sim-0.3.0/tests/test_parser.py +53 -0
- scpi_sim-0.3.0/tests/test_pattern.py +36 -0
- scpi_sim-0.3.0/tests/test_plugin.py +118 -0
- scpi_sim-0.3.0/tests/test_psu.py +120 -0
- scpi_sim-0.3.0/tests/test_pyvisa.py +16 -0
- scpi_sim-0.3.0/tests/test_transport.py +116 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## 0.3.0 - 2026-10-26
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Devices**: your own device under test as a Python class
|
|
12
|
+
(`scpi_sim.Device`) with input terminals that draw current and output
|
|
13
|
+
terminals that drive their net; `update()` after every instrument message
|
|
14
|
+
lets it react to power-on (inrush, boot timelines). Configured under
|
|
15
|
+
`devices:` in the bench file.
|
|
16
|
+
- **Device console**: a raw TCP port per device (`console:` in the bench
|
|
17
|
+
file), broadcast to every client, typed lines go to `console_line()`;
|
|
18
|
+
pyserial reaches it as `socket://host:port` (`Bench.console_url()`).
|
|
19
|
+
- Example device in `examples/devices` (sensor board with boot log and
|
|
20
|
+
console) and the README section "Writing your own device".
|
|
21
|
+
- **Docker image**: `Dockerfile` (non-root, example bench by default); CI
|
|
22
|
+
builds and smoke-tests it, releases publish `ghcr.io/steitec/scpi-sim`.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- `InstrumentServer` is now built on a small `TcpServer` base class shared
|
|
27
|
+
with the console server.
|
|
28
|
+
- `Instrument.listeners`: callables run after every message, outside the
|
|
29
|
+
instrument lock (the bench uses it to update devices). A `no_response`
|
|
30
|
+
fault now also runs them.
|
|
31
|
+
- `Bench.stop()` stops every server even if one of them fails.
|
|
32
|
+
|
|
33
|
+
## 0.2.0 - 2026-10-26
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **Bench**: `scpi-sim serve --bench FILE` starts several instruments from a
|
|
38
|
+
YAML file and wires them together — the multimeter measures, through closed
|
|
39
|
+
multiplexer relays, what the power supply delivers into its loads.
|
|
40
|
+
- Loads: resistors (`ohms: 0` is a short), parallel loads and custom Python
|
|
41
|
+
classes with a `current(volts)` curve; the supply finds the CV/CC
|
|
42
|
+
operating point for any of them.
|
|
43
|
+
- A relay that would connect two supply outputs is refused with
|
|
44
|
+
`-221,"Settings conflict"`.
|
|
45
|
+
- Bench files are checked strictly: duplicate keys, wrong option types and
|
|
46
|
+
unknown terminals are reported with the offending key or line.
|
|
47
|
+
- Python loads are imported from the bench file's folder as well.
|
|
48
|
+
- The control port is refused if it clashes with an instrument port, closes
|
|
49
|
+
its connections when the bench stops and works on IPv6 (`--host ::1`).
|
|
50
|
+
- The source distribution contains the tests, examples and changelog and can
|
|
51
|
+
run its own test suite.
|
|
52
|
+
- **Fault injection**: `no_response`, `delay`, `disconnect`, `error`, `idn`,
|
|
53
|
+
`offset`, `noise` — from Python (`instrument.inject(...)`), from pytest, or
|
|
54
|
+
into a running bench with `scpi-sim fault` through a JSON control port.
|
|
55
|
+
- **pytest plugin**: `scpi_bench` and `scpi_bench_factory` fixtures,
|
|
56
|
+
configured with `--scpi-bench FILE` or the `scpi_bench` ini key.
|
|
57
|
+
- Examples in `examples/` (bench file and a pytest suite using PyVISA).
|
|
58
|
+
- Release workflow for PyPI (Trusted Publishing).
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- PyYAML is now a runtime dependency.
|
|
63
|
+
- `PowerSupply` takes any `load=` object; `load_ohms=` still works.
|
|
64
|
+
|
|
65
|
+
## 0.1.0 - 2026-10-26
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
|
|
69
|
+
- Generic multimeter, power supply and multiplexer on raw TCP sockets
|
|
70
|
+
(`scpi-sim serve dmm|psu|mux`).
|
|
71
|
+
- SCPI parser with short/long forms, optional nodes, numeric suffixes and the
|
|
72
|
+
SCPI path rule; IEEE 488.2 common commands, error queue and status registers.
|
|
73
|
+
- `scpi-sim query`, a small cross-platform client.
|
|
74
|
+
- Python API (`InstrumentServer`, `Instrument` with the `@scpi` decorator).
|
|
75
|
+
|
scpi_sim-0.3.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ralf Steinert
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
scpi_sim-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scpi-sim
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Simulated SCPI lab instruments (DMM, power supply, multiplexer) on a raw TCP socket
|
|
5
|
+
Author: Ralf Steinert
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/steitec/scpi-sim
|
|
8
|
+
Project-URL: Repository, https://github.com/steitec/scpi-sim
|
|
9
|
+
Project-URL: Changelog, https://github.com/steitec/scpi-sim/blob/main/CHANGELOG.md
|
|
10
|
+
Project-URL: Issues, https://github.com/steitec/scpi-sim/issues
|
|
11
|
+
Keywords: scpi,simulator,test automation,pyvisa,instrument,lab
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Manufacturing
|
|
15
|
+
Classifier: Framework :: Pytest
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Operating System :: OS Independent
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering
|
|
23
|
+
Classifier: Topic :: Software Development :: Testing
|
|
24
|
+
Classifier: Topic :: System :: Hardware
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: pyyaml>=6
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
31
|
+
Requires-Dist: pyvisa>=1.13; extra == "dev"
|
|
32
|
+
Requires-Dist: pyvisa-py>=0.7; extra == "dev"
|
|
33
|
+
Requires-Dist: pyserial>=3.5; extra == "dev"
|
|
34
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# scpi-sim
|
|
38
|
+
|
|
39
|
+
Simulated SCPI lab instruments — a multimeter, a power supply and a relay
|
|
40
|
+
multiplexer — that listen on a raw TCP socket like real LAN instruments on
|
|
41
|
+
port 5025. Develop and test instrument automation without the hardware.
|
|
42
|
+
|
|
43
|
+

|
|
44
|
+
|
|
45
|
+
```console
|
|
46
|
+
$ scpi-sim serve psu --load 24 &
|
|
47
|
+
scpi-sim: PSU listening on 127.0.0.1:5025 (Ctrl+C to stop)
|
|
48
|
+
$ scpi-sim query "*IDN?" "VOLT 12;CURR 1;:OUTP ON;:MEAS:CURR?" "VOLT 40" "SYST:ERR?"
|
|
49
|
+
scpi-sim,PSU,SIM00002,0.2.0
|
|
50
|
+
+5.00000000E-01
|
|
51
|
+
-222,"Data out of range"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Why
|
|
55
|
+
|
|
56
|
+
Test software for lab instruments is usually written while the instruments
|
|
57
|
+
are busy, on someone else's bench, or not bought yet — and it is rarely tested
|
|
58
|
+
in CI at all. scpi-sim gives you instruments that:
|
|
59
|
+
|
|
60
|
+
- **are real network servers** — anything that can open a TCP socket can talk
|
|
61
|
+
to them: PyVISA (`TCPIP0::127.0.0.1::5025::SOCKET`), C#, LabVIEW, NI MAX,
|
|
62
|
+
`nc`, your own code
|
|
63
|
+
- **keep state** like the real thing — settings persist, `*RST` resets them,
|
|
64
|
+
errors go to the error queue, status registers (`*ESR?`, `*STB?`) work
|
|
65
|
+
- **behave physically** — the power supply goes from constant voltage into
|
|
66
|
+
current limit when its load draws too much, the multimeter has ranges and
|
|
67
|
+
reports overload (`+9.90000000E+37`) when a reading does not fit
|
|
68
|
+
- **are strict where real instruments are strict** — short/long forms only,
|
|
69
|
+
the SCPI path rule in compound commands, standard error codes — so bugs in
|
|
70
|
+
your command strings show up before they meet the hardware
|
|
71
|
+
- **work together on a bench** — wire a supply, a multiplexer and a
|
|
72
|
+
multimeter in a YAML file, and the multimeter measures what the supply
|
|
73
|
+
delivers into its load
|
|
74
|
+
- **break on purpose** — inject timeouts, dropped connections, wrong
|
|
75
|
+
identities or drifting readings at runtime, and see whether your test code
|
|
76
|
+
copes
|
|
77
|
+
|
|
78
|
+
### Compared with pyvisa-sim
|
|
79
|
+
|
|
80
|
+
[pyvisa-sim](https://github.com/pyvisa/pyvisa-sim) is a PyVISA backend that
|
|
81
|
+
simulates devices described in YAML, inside your Python process. It is a good
|
|
82
|
+
fit for unit tests of PyVISA code. scpi-sim takes a different approach:
|
|
83
|
+
|
|
84
|
+
| | pyvisa-sim | scpi-sim |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| Runs as | PyVISA backend in your process | TCP server, one port per instrument |
|
|
87
|
+
| Usable from | Python with PyVISA | any language or tool |
|
|
88
|
+
| Devices defined in | YAML | Python classes (generic instruments included) |
|
|
89
|
+
|
|
90
|
+
On top of that, scpi-sim has physical behaviour (CV/CC into a load, ranges,
|
|
91
|
+
overload), instruments wired together on a bench, fault injection at runtime
|
|
92
|
+
and a pytest plugin.
|
|
93
|
+
|
|
94
|
+
## Quick start
|
|
95
|
+
|
|
96
|
+
Requires Python 3.10 or newer; the only dependency is PyYAML.
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
git clone https://github.com/steitec/scpi-sim.git && cd scpi-sim
|
|
100
|
+
pip install -e .
|
|
101
|
+
|
|
102
|
+
scpi-sim serve dmm # multimeter on 127.0.0.1:5025
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
From another terminal, with the built-in client (works on Linux, macOS and
|
|
106
|
+
Windows):
|
|
107
|
+
|
|
108
|
+
```console
|
|
109
|
+
$ scpi-sim query "*IDN?"
|
|
110
|
+
scpi-sim,DMM,SIM00001,0.2.0
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`scpi-sim query [--host HOST] [--port PORT] MESSAGE...` sends each message
|
|
114
|
+
and prints the answer to every message that contains a `?`.
|
|
115
|
+
|
|
116
|
+
Any raw TCP client works as well, e.g. `nc`:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
echo '*IDN?' | nc -q1 localhost 5025 # Linux (netcat-openbsd)
|
|
120
|
+
echo '*IDN?' | nc -N localhost 5025 # macOS
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Every message must end with a newline (`\n`), as on real LAN instruments:
|
|
124
|
+
`printf '*IDN?' | nc …` without one gets no answer.
|
|
125
|
+
|
|
126
|
+
With PyVISA and the pure-Python backend (`pip install pyvisa pyvisa-py`):
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
import pyvisa
|
|
130
|
+
|
|
131
|
+
rm = pyvisa.ResourceManager("@py")
|
|
132
|
+
dmm = rm.open_resource("TCPIP0::127.0.0.1::5025::SOCKET",
|
|
133
|
+
read_termination="\n", write_termination="\n")
|
|
134
|
+
print(dmm.query("MEAS:VOLT:DC?"))
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Run several instruments side by side on different ports (`scpi-sim query
|
|
138
|
+
--port 5026 …` talks to the second one):
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
scpi-sim serve psu --port 5025 --channels 2 --load 24
|
|
142
|
+
scpi-sim serve dmm --port 5026 --volts 3.3
|
|
143
|
+
scpi-sim serve mux --port 5027
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The server listens on `127.0.0.1` by default; use `--host 0.0.0.0` to make it
|
|
147
|
+
reachable from other machines. A host name that resolves to several addresses
|
|
148
|
+
(`localhost` → `127.0.0.1` and `::1`) is served on all of them, on the same
|
|
149
|
+
port.
|
|
150
|
+
|
|
151
|
+
## Instruments
|
|
152
|
+
|
|
153
|
+
All instruments support the IEEE 488.2 common commands `*IDN?`, `*RST`,
|
|
154
|
+
`*CLS`, `*ESR?`, `*ESE`, `*ESE?`, `*SRE`, `*SRE?`, `*STB?`, `*OPC`, `*OPC?`,
|
|
155
|
+
`*WAI`, `*TST?` and `SYSTem:ERRor[:NEXT]?`, `SYSTem:ERRor:COUNt?`,
|
|
156
|
+
`SYSTem:VERSion?`.
|
|
157
|
+
|
|
158
|
+
### Multimeter — `scpi-sim serve dmm`
|
|
159
|
+
|
|
160
|
+
| Command | |
|
|
161
|
+
|---|---|
|
|
162
|
+
| `MEASure:VOLTage[:DC]? [range]` | DC voltage |
|
|
163
|
+
| `MEASure:CURRent[:DC]? [range]` | DC current |
|
|
164
|
+
| `MEASure:RESistance? [range]` | 2-wire resistance |
|
|
165
|
+
| `CONFigure:VOLTage[:DC] [range]`, `CONFigure:CURRent[:DC] [range]`, `CONFigure:RESistance [range]` | select function and range |
|
|
166
|
+
| `CONFigure?` | e.g. `"VOLT AUTO"`, `"RES +1.00000000E+04"` |
|
|
167
|
+
| `READ?` | measure with the current configuration |
|
|
168
|
+
|
|
169
|
+
Ranges: 0.1/1/10/100/1000 V, 0.01/0.1/1/3/10 A, 100 Ω … 100 MΩ in decades.
|
|
170
|
+
`range` is `AUTO`, `DEF`, `MIN`, `MAX` or a value (the smallest range that
|
|
171
|
+
holds its magnitude is selected); an optional second parameter (resolution)
|
|
172
|
+
is accepted. A reading above 120 % of the range returns `+9.90000000E+37`
|
|
173
|
+
(`-9.90000000E+37` for negative readings).
|
|
174
|
+
|
|
175
|
+
Options: `--volts 5.0`, `--amps 0.1`, `--ohms 1000` (the simulated input),
|
|
176
|
+
`--noise 50e-6` (relative standard deviation, `0` for exact readings),
|
|
177
|
+
`--seed N` (reproducible noise).
|
|
178
|
+
|
|
179
|
+
### Power supply — `scpi-sim serve psu`
|
|
180
|
+
|
|
181
|
+
| Command | |
|
|
182
|
+
|---|---|
|
|
183
|
+
| `[SOURce[n]:]VOLTage[:LEVel][:IMMediate][:AMPLitude] <v>` / `…? [MIN\|MAX\|DEF]` | voltage setpoint, 0–30 V |
|
|
184
|
+
| `[SOURce[n]:]CURRent[:LEVel][:IMMediate][:AMPLitude] <a>` / `…? [MIN\|MAX\|DEF]` | current limit, 0–3 A |
|
|
185
|
+
| `OUTPut[n][:STATe] ON\|OFF` / `OUTPut[n][:STATe]?` | output on/off, `n` = channel |
|
|
186
|
+
| `OUTPut[n]:MODE?` | `OFF`, `CV` or `CC` |
|
|
187
|
+
| `MEASure:VOLTage[:DC]?`, `MEASure:CURRent[:DC]?` | at the output terminals |
|
|
188
|
+
| `INSTrument:NSELect <n>` / `?`, `INSTrument[:SELect] CH<n>` / `?` | select channel |
|
|
189
|
+
|
|
190
|
+
`SOURce<n>` and `OUTPut<n>` address channel `n` directly; without a number
|
|
191
|
+
the channel selected with `INSTrument` is used. `*RST` state: 0 V, 0.1 A,
|
|
192
|
+
output off, channel 1 selected. Values accept units
|
|
193
|
+
(`500 mV`, `250 mA`).
|
|
194
|
+
|
|
195
|
+
Options: `--channels 1`, `--load OHMS` (resistive load on every output;
|
|
196
|
+
without it the outputs are open and deliver no current; `--load 0` is a short
|
|
197
|
+
circuit, which drives the output straight into the current limit).
|
|
198
|
+
|
|
199
|
+
### Multiplexer — `scpi-sim serve mux`
|
|
200
|
+
|
|
201
|
+
| Command | |
|
|
202
|
+
|---|---|
|
|
203
|
+
| `ROUTe:CLOSe (@101,103:105)` | close channels |
|
|
204
|
+
| `ROUTe:OPEN (@…)`, `ROUTe:OPEN:ALL` | open channels |
|
|
205
|
+
| `ROUTe:CLOSe? (@…)`, `ROUTe:OPEN? (@…)` | one `1`/`0` per channel |
|
|
206
|
+
| `ROUTe:CLOSe:STATe?` | closed channels, e.g. `(@101,105)` |
|
|
207
|
+
|
|
208
|
+
Channels are numbered 101, 102, … A list with an invalid channel is rejected
|
|
209
|
+
as a whole. Options: `--channels 16` (1–99).
|
|
210
|
+
|
|
211
|
+
## Bench
|
|
212
|
+
|
|
213
|
+
A bench file starts several instruments at once and wires them together:
|
|
214
|
+
|
|
215
|
+
```yaml
|
|
216
|
+
# examples/bench.yaml
|
|
217
|
+
instruments:
|
|
218
|
+
psu: {type: psu, port: 5025, channels: 2}
|
|
219
|
+
dmm: {type: dmm, port: 5026}
|
|
220
|
+
mux: {type: mux, port: 5027, channels: 16}
|
|
221
|
+
loads:
|
|
222
|
+
rail_12v: {type: resistor, ohms: 24} # 0.5 A at 12 V
|
|
223
|
+
rail_3v3: {type: resistor, ohms: 33} # 0.1 A at 3.3 V
|
|
224
|
+
wiring:
|
|
225
|
+
- psu.ch1 -> rail_12v
|
|
226
|
+
- psu.ch2 -> rail_3v3
|
|
227
|
+
- mux.101 -> rail_12v
|
|
228
|
+
- mux.102 -> rail_3v3
|
|
229
|
+
- mux.common -> dmm.input
|
|
230
|
+
control: 5099
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
```console
|
|
234
|
+
$ scpi-sim serve --bench examples/bench.yaml
|
|
235
|
+
scpi-sim: psu (PSU) listening on 127.0.0.1:5025
|
|
236
|
+
scpi-sim: dmm (DMM) listening on 127.0.0.1:5026
|
|
237
|
+
scpi-sim: mux (MUX) listening on 127.0.0.1:5027
|
|
238
|
+
scpi-sim: control on 127.0.0.1:5099
|
|
239
|
+
scpi-sim: bench running (Ctrl+C to stop)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
```console
|
|
243
|
+
$ scpi-sim query "SOUR1:VOLT 12;:SOUR1:CURR 1;:OUTP1 ON;*OPC?"
|
|
244
|
+
1
|
|
245
|
+
$ scpi-sim query --port 5027 "ROUT:CLOS (@101);*OPC?"
|
|
246
|
+
1
|
|
247
|
+
$ scpi-sim query --port 5026 "MEAS:VOLT?"
|
|
248
|
+
+1.20000310E+01
|
|
249
|
+
$ scpi-sim query "INST:NSEL 1;:MEAS:CURR?"
|
|
250
|
+
+5.00000000E-01
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
- **Instruments**: `type` is `psu`, `dmm` or `mux`, `port` is the TCP port
|
|
254
|
+
(`0` = any free port); the other keys are the options of that instrument
|
|
255
|
+
(`psu`: `channels`, `max_volts`, `max_amps`; `dmm`: `volts`, `amps`, `ohms`,
|
|
256
|
+
`noise`, `seed`; `mux`: `channels`).
|
|
257
|
+
- **Wiring**: each line `A -> B` connects two terminals: `<psu>.ch<n>`,
|
|
258
|
+
`<dmm>.input`, `<mux>.<channel>`, `<mux>.common` or a node name of your
|
|
259
|
+
choice. A closed multiplexer relay connects its channel to `common`.
|
|
260
|
+
- **Loads** sit on nodes: `{type: resistor, ohms: 24}` (`ohms: 0` is a short),
|
|
261
|
+
or your own Python class with a `current(volts)` method, e.g. a diode or a
|
|
262
|
+
device that draws constant current:
|
|
263
|
+
`{type: python, class: "mypackage.loads:Lamp", args: {amps: 0.3}}`. The
|
|
264
|
+
supply finds the operating point (constant voltage, or current limit) for
|
|
265
|
+
any such load.
|
|
266
|
+
- The multimeter measures the voltage of the net its input is on (0 V if
|
|
267
|
+
nothing drives it) and the resistance of the resistors on it.
|
|
268
|
+
- Two supply outputs never end up on the same net: in the file that is an
|
|
269
|
+
error, and a `ROUT:CLOS` that would connect them is refused with
|
|
270
|
+
`-221,"Settings conflict"`.
|
|
271
|
+
- **Devices**: your own device under test, written in Python, with its own
|
|
272
|
+
terminals and an optional console port — see
|
|
273
|
+
[Writing your own device](#writing-your-own-device).
|
|
274
|
+
- Mistakes in the file are reported with the offending line and exit code 2.
|
|
275
|
+
|
|
276
|
+
Every instrument has its own connection, so a command sent to one
|
|
277
|
+
instrument can still be in flight when you query another one. Finish
|
|
278
|
+
commands with `*OPC?` before measuring elsewhere, as you would on a real
|
|
279
|
+
bench (`ROUT:CLOS (@101);*OPC?`).
|
|
280
|
+
|
|
281
|
+
## Writing your own device
|
|
282
|
+
|
|
283
|
+
A device under test is a Python class with terminals: an **input** draws
|
|
284
|
+
current from the net it is wired to, an **output** drives its net like a
|
|
285
|
+
supply. The bench calls `update()` after every instrument message, so the
|
|
286
|
+
device notices when its supply switches on, and it can talk over a console
|
|
287
|
+
port like a serial line behind a terminal server.
|
|
288
|
+
|
|
289
|
+
```python
|
|
290
|
+
import threading, time
|
|
291
|
+
from scpi_sim import Device
|
|
292
|
+
|
|
293
|
+
class SensorBoard(Device):
|
|
294
|
+
terminals = {"vin": "input", "3V3": "output"}
|
|
295
|
+
|
|
296
|
+
def __init__(self, serial="SB-00-0001"):
|
|
297
|
+
super().__init__() # required: sets up console and bench hooks
|
|
298
|
+
self.serial, self.powered_at, self.generation = serial, None, 0
|
|
299
|
+
|
|
300
|
+
def update(self): # after every instrument message
|
|
301
|
+
powered = self.input_voltage("vin") >= 4.5
|
|
302
|
+
if powered and self.powered_at is None:
|
|
303
|
+
self.powered_at = time.monotonic()
|
|
304
|
+
self.generation += 1
|
|
305
|
+
threading.Thread(target=self.boot, args=(self.generation,), daemon=True).start()
|
|
306
|
+
elif not powered and self.powered_at is not None:
|
|
307
|
+
self.powered_at = None
|
|
308
|
+
self.generation += 1 # a boot still running is now stale
|
|
309
|
+
|
|
310
|
+
def boot(self, generation): # own thread: may sleep
|
|
311
|
+
time.sleep(1.0)
|
|
312
|
+
if generation == self.generation: # not switched off meanwhile
|
|
313
|
+
self.console.write("ready\nsensor> ")
|
|
314
|
+
|
|
315
|
+
def current(self, terminal, volts): # amps drawn on an input
|
|
316
|
+
powered_at = self.powered_at # read once, update() runs in another thread
|
|
317
|
+
if powered_at is None:
|
|
318
|
+
return 0.0
|
|
319
|
+
return 0.4 if time.monotonic() - powered_at < 0.2 else 0.08
|
|
320
|
+
|
|
321
|
+
def voltage(self, terminal): # volts driven on an output
|
|
322
|
+
return 3.3 if self.input_voltage("vin") >= 4.5 else 0.0
|
|
323
|
+
|
|
324
|
+
def console_line(self, line): # a line typed on the console
|
|
325
|
+
if line == "id?":
|
|
326
|
+
self.console.write(self.serial + "\nsensor> ")
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
```yaml
|
|
330
|
+
devices:
|
|
331
|
+
board:
|
|
332
|
+
class: "sensor_board:SensorBoard" # module next to the bench file, or installed
|
|
333
|
+
args: {serial: "SB-24-0042"}
|
|
334
|
+
console: 5028 # optional: console on this TCP port
|
|
335
|
+
wiring:
|
|
336
|
+
- psu.ch1 -> board.vin
|
|
337
|
+
- mux.101 -> board.3V3
|
|
338
|
+
- mux.common -> dmm.input
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
| Member | Called by | What it does |
|
|
342
|
+
|---|---|---|
|
|
343
|
+
| `terminals` | — | `{"name": "input" \| "output"}`; wired as `<device>.<name>` |
|
|
344
|
+
| `current(terminal, volts)` | the supply | amps drawn on an input at that voltage |
|
|
345
|
+
| `voltage(terminal)` | the bench | volts driven on an output; `None` reads as 0 V (the output still counts as a driver of its net) |
|
|
346
|
+
| `update()` | after every instrument message | look at `input_voltage()`, start timelines |
|
|
347
|
+
| `input_voltage(terminal)` | your code | voltage of the net an input is on |
|
|
348
|
+
| `console.write(text)` | your code | send text to everyone connected to the console |
|
|
349
|
+
| `console_line(line)` | the console port | a line someone typed |
|
|
350
|
+
|
|
351
|
+
Rules that keep a bench responsive:
|
|
352
|
+
|
|
353
|
+
- `current()`, `voltage()` and `update()` run in the instruments' threads —
|
|
354
|
+
never block in them. Start a thread for anything that takes time (boot
|
|
355
|
+
sequence) and compute time-dependent values from `time.monotonic()`.
|
|
356
|
+
`console_line()` runs on the console's event loop: don't block there either.
|
|
357
|
+
- The bench runs one `update()` at a time, but `current()` and `voltage()` can
|
|
358
|
+
run while `update()` or your own threads change the device's state: read
|
|
359
|
+
shared attributes once into a local, and use a lock for state that changes
|
|
360
|
+
in several steps.
|
|
361
|
+
- Call `super().__init__()` in your `__init__` and write `str` to the console.
|
|
362
|
+
- `current()` must not call `input_voltage()`: the input voltage is what the
|
|
363
|
+
supply computes from `current()`.
|
|
364
|
+
- A device output and a supply (or two outputs) on one net is an error, like
|
|
365
|
+
two supplies; a relay that would connect them is refused with `-221`.
|
|
366
|
+
- An exception in `current()`/`voltage()` becomes `-300` in the error queue of
|
|
367
|
+
the instrument that asked; one in `update()` or `console_line()` is printed
|
|
368
|
+
once and ignored.
|
|
369
|
+
|
|
370
|
+
Reach the console with any raw TCP client (`nc localhost 5028`) or with
|
|
371
|
+
pyserial, as `serial.serial_for_url(bench.console_url("board"))`. The complete
|
|
372
|
+
example is in
|
|
373
|
+
[`examples/devices`](https://github.com/steitec/scpi-sim/tree/main/examples/devices):
|
|
374
|
+
`pytest examples/devices --scpi-bench examples/devices/bench.yaml`.
|
|
375
|
+
|
|
376
|
+
## Fault injection
|
|
377
|
+
|
|
378
|
+
Faults are injected at runtime — from Python, from pytest, or into a running
|
|
379
|
+
bench through its control port:
|
|
380
|
+
|
|
381
|
+
```console
|
|
382
|
+
$ scpi-sim fault dmm noise sigma=0.05
|
|
383
|
+
ok
|
|
384
|
+
$ scpi-sim fault psu no_response count=2
|
|
385
|
+
ok
|
|
386
|
+
$ scpi-sim fault --list
|
|
387
|
+
psu: no_response count=2
|
|
388
|
+
dmm: noise sigma=0.05
|
|
389
|
+
mux: -
|
|
390
|
+
$ scpi-sim fault dmm --clear
|
|
391
|
+
ok
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
| Fault | Parameters | Effect |
|
|
395
|
+
|---|---|---|
|
|
396
|
+
| `no_response` | `count=1` | the next `count` answers are not sent (the commands still execute) |
|
|
397
|
+
| `delay` | `seconds`, `count` (default: until cleared) | answers are sent `seconds` later |
|
|
398
|
+
| `disconnect` | — | the next message closes the connection |
|
|
399
|
+
| `error` | `code`, `message` | one entry is pushed into the error queue |
|
|
400
|
+
| `idn` | `value` | `*IDN?` answers `value` |
|
|
401
|
+
| `offset` | `value` | multimeter: added to every reading |
|
|
402
|
+
| `noise` | `sigma` | multimeter: replaces the noise of the readings |
|
|
403
|
+
|
|
404
|
+
Faults stay active until cleared or used up (`no_response`, `disconnect` and
|
|
405
|
+
`delay` with a `count`); `*RST` and `*CLS` do not clear them. They belong to
|
|
406
|
+
the instrument, not to a connection. From Python:
|
|
407
|
+
`bench.dmm.inject("offset", value=0.2)`, `bench.dmm.clear_faults()`.
|
|
408
|
+
|
|
409
|
+
The control port speaks one JSON object per line:
|
|
410
|
+
`{"instrument": "dmm", "inject": "noise", "params": {"sigma": 0.05}}`,
|
|
411
|
+
`{"instrument": "dmm", "clear": true}`, `{"list": true}`. It has no
|
|
412
|
+
authentication: with `--host 0.0.0.0` anyone who can reach the port can
|
|
413
|
+
inject faults, so expose a bench only on a network you trust.
|
|
414
|
+
|
|
415
|
+
## pytest plugin
|
|
416
|
+
|
|
417
|
+
Installing scpi-sim registers a pytest plugin. The `scpi_bench` fixture
|
|
418
|
+
starts a fresh bench for every test, on free ports, and stops it afterwards:
|
|
419
|
+
|
|
420
|
+
```python
|
|
421
|
+
import pyvisa
|
|
422
|
+
import pytest
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def test_dead_multimeter_raises_timeout(scpi_bench):
|
|
426
|
+
rm = pyvisa.ResourceManager("@py")
|
|
427
|
+
dmm = rm.open_resource(scpi_bench.resource("dmm"), read_termination="\n",
|
|
428
|
+
write_termination="\n", timeout=500)
|
|
429
|
+
scpi_bench.dmm.inject("no_response")
|
|
430
|
+
with pytest.raises(pyvisa.errors.VisaIOError):
|
|
431
|
+
dmm.query("MEAS:VOLT?")
|
|
432
|
+
dmm.close()
|
|
433
|
+
rm.close()
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
- The bench comes from `--scpi-bench FILE`, or from the ini key
|
|
437
|
+
`scpi_bench = FILE` (relative to the rootdir); without either it is a
|
|
438
|
+
generic `psu`, `dmm` and `mux`, not wired.
|
|
439
|
+
- `scpi_bench.resource(name)` gives the VISA resource string,
|
|
440
|
+
`scpi_bench.port(name)` the port, `scpi_bench.<name>` the instrument object.
|
|
441
|
+
- `scpi_bench_factory(path_or_dict)` starts further benches; a relative path
|
|
442
|
+
is relative to the rootdir, like the ini key.
|
|
443
|
+
|
|
444
|
+
[`examples/test_example.py`](https://github.com/steitec/scpi-sim/blob/main/examples/test_example.py) is a complete example:
|
|
445
|
+
`pytest examples/test_example.py --scpi-bench examples/bench.yaml`.
|
|
446
|
+
|
|
447
|
+
## SCPI behaviour
|
|
448
|
+
|
|
449
|
+
- Keywords match in short or long form, case-insensitive (`MEAS`,
|
|
450
|
+
`measure`) — but nothing in between (`MEASU` is an error), like on real
|
|
451
|
+
instruments. Optional nodes in `[ ]` may be left out.
|
|
452
|
+
- Compound commands follow the SCPI path rule: after `;` a command continues
|
|
453
|
+
at the level of the previous one, so `SOUR:VOLT 5;CURR 1` sets
|
|
454
|
+
`SOUR:CURR`. Start with `:` to go back to the root:
|
|
455
|
+
`MEAS:VOLT?;:MEAS:CURR?` works, `MEAS:VOLT?;MEAS:CURR?` gives
|
|
456
|
+
`-113,"Undefined header"` — exactly as a real instrument would.
|
|
457
|
+
- Several queries in one message are answered in one line, separated by `;`.
|
|
458
|
+
- `*RST` resets the instrument settings; `*CLS` clears the error queue and
|
|
459
|
+
the event status register. The error queue holds 20 entries; on overflow
|
|
460
|
+
the last one becomes `-350,"Queue overflow"`.
|
|
461
|
+
|
|
462
|
+
Error codes:
|
|
463
|
+
|
|
464
|
+
| Code | Message |
|
|
465
|
+
|---|---|
|
|
466
|
+
| -102 | Syntax error |
|
|
467
|
+
| -104 | Data type error |
|
|
468
|
+
| -108 | Parameter not allowed (more parameters than the command takes) |
|
|
469
|
+
| -109 | Missing parameter |
|
|
470
|
+
| -113 | Undefined header |
|
|
471
|
+
| -114 | Header suffix out of range |
|
|
472
|
+
| -131 | Invalid suffix |
|
|
473
|
+
| -222 | Data out of range |
|
|
474
|
+
| -223 | Too much data (line longer than 64 KiB) |
|
|
475
|
+
| -224 | Illegal parameter value |
|
|
476
|
+
| -300 | Device-specific error |
|
|
477
|
+
| -350 | Queue overflow |
|
|
478
|
+
|
|
479
|
+
## Docker
|
|
480
|
+
|
|
481
|
+
The image runs the example bench (supply on 5025, multimeter on 5026,
|
|
482
|
+
multiplexer on 5027, control port on 5099):
|
|
483
|
+
|
|
484
|
+
```bash
|
|
485
|
+
docker run --rm -p 5025-5027:5025-5027 -p 127.0.0.1:5099:5099 ghcr.io/steitec/scpi-sim
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
Your own bench file (devices in Python modules next to it are found too):
|
|
489
|
+
|
|
490
|
+
```bash
|
|
491
|
+
docker run --rm -p 5025-5028:5025-5028 -v "$PWD/bench":/bench ghcr.io/steitec/scpi-sim
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
The control port has no authentication, hence `127.0.0.1:` in front of it.
|
|
495
|
+
Any other command works as well, e.g.
|
|
496
|
+
`docker run --rm -p 5025:5025 ghcr.io/steitec/scpi-sim serve dmm --host 0.0.0.0`.
|
|
497
|
+
In CI, start it as a service container and point your tests at
|
|
498
|
+
`TCPIP0::localhost::5025::SOCKET`. The image is published with each release;
|
|
499
|
+
until then build it yourself with `docker build -t scpi-sim .`.
|
|
500
|
+
|
|
501
|
+
## Python API
|
|
502
|
+
|
|
503
|
+
Start instruments from your own tests, on a free port:
|
|
504
|
+
|
|
505
|
+
```python
|
|
506
|
+
import pyvisa
|
|
507
|
+
|
|
508
|
+
from scpi_sim import InstrumentServer, PowerSupply
|
|
509
|
+
|
|
510
|
+
with InstrumentServer(PowerSupply(load_ohms=24), port=0) as server:
|
|
511
|
+
rm = pyvisa.ResourceManager("@py")
|
|
512
|
+
psu = rm.open_resource(f"TCPIP0::127.0.0.1::{server.port}::SOCKET",
|
|
513
|
+
read_termination="\n", write_termination="\n")
|
|
514
|
+
psu.write("VOLT 12;CURR 1;:OUTP ON")
|
|
515
|
+
print(psu.query("MEAS:CURR?")) # +5.00000000E-01
|
|
516
|
+
psu.write("CURR 0.25")
|
|
517
|
+
print(psu.query("MEAS:VOLT?;:OUTP:MODE?")) # +6.00000000E+00;CC
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
The simulated multimeter input can be changed from Python while a test runs:
|
|
521
|
+
`dmm = Multimeter(noise=0)`, then `dmm.volts = 1.8`.
|
|
522
|
+
|
|
523
|
+
A bench from Python:
|
|
524
|
+
|
|
525
|
+
```python
|
|
526
|
+
from scpi_sim import Bench
|
|
527
|
+
|
|
528
|
+
with Bench.from_file("examples/bench.yaml") as bench: # starts all servers
|
|
529
|
+
print(bench.resource("dmm")) # TCPIP0::127.0.0.1::5026::SOCKET
|
|
530
|
+
bench.psu.process("SOUR1:VOLT 12;:SOUR1:CURR 1;:OUTP1 ON")
|
|
531
|
+
bench.mux.process("ROUT:CLOS (@101)")
|
|
532
|
+
print(bench.dmm.process("MEAS:VOLT?")) # about +1.2000E+01
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
Your own instrument is a subclass of `Instrument` with one method per
|
|
536
|
+
command:
|
|
537
|
+
|
|
538
|
+
```python
|
|
539
|
+
from scpi_sim import Instrument, scpi
|
|
540
|
+
|
|
541
|
+
class Thermometer(Instrument):
|
|
542
|
+
model, serial = "THERMO", "SIM00042"
|
|
543
|
+
|
|
544
|
+
@scpi("MEASure:TEMPerature?")
|
|
545
|
+
def measure(self, call):
|
|
546
|
+
return 23.5
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
## Roadmap
|
|
550
|
+
|
|
551
|
+
- **later** — profiles that behave similar to common bench instruments,
|
|
552
|
+
VXI-11, a simple oscilloscope
|
|
553
|
+
|
|
554
|
+
## License
|
|
555
|
+
|
|
556
|
+
MIT
|