scpi-sim 0.3.0__py3-none-any.whl
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/__init__.py +15 -0
- scpi_sim/bench.py +534 -0
- scpi_sim/cli.py +226 -0
- scpi_sim/console.py +62 -0
- scpi_sim/control.py +132 -0
- scpi_sim/device.py +97 -0
- scpi_sim/errors.py +33 -0
- scpi_sim/faults.py +115 -0
- scpi_sim/instrument.py +273 -0
- scpi_sim/instruments/__init__.py +5 -0
- scpi_sim/instruments/dmm.py +114 -0
- scpi_sim/instruments/mux.py +67 -0
- scpi_sim/instruments/psu.py +153 -0
- scpi_sim/loads.py +73 -0
- scpi_sim/pytest_plugin.py +89 -0
- scpi_sim/scpi/__init__.py +0 -0
- scpi_sim/scpi/params.py +75 -0
- scpi_sim/scpi/parser.py +99 -0
- scpi_sim/scpi/pattern.py +87 -0
- scpi_sim/transport.py +161 -0
- scpi_sim-0.3.0.dist-info/METADATA +556 -0
- scpi_sim-0.3.0.dist-info/RECORD +26 -0
- scpi_sim-0.3.0.dist-info/WHEEL +5 -0
- scpi_sim-0.3.0.dist-info/entry_points.txt +5 -0
- scpi_sim-0.3.0.dist-info/licenses/LICENSE +21 -0
- scpi_sim-0.3.0.dist-info/top_level.txt +1 -0
scpi_sim/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Simulated SCPI lab instruments on a raw TCP socket."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.3.0"
|
|
4
|
+
|
|
5
|
+
from .instrument import Instrument, scpi # noqa: E402
|
|
6
|
+
from .instruments import Multimeter, Multiplexer, PowerSupply # noqa: E402
|
|
7
|
+
from .loads import Parallel, Resistor # noqa: E402
|
|
8
|
+
from .transport import InstrumentServer # noqa: E402
|
|
9
|
+
from .bench import Bench, BenchError # noqa: E402
|
|
10
|
+
from .device import Console, Device # noqa: E402
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Bench", "BenchError", "Console", "Device", "Instrument", "InstrumentServer", "Multimeter", "Multiplexer",
|
|
14
|
+
"Parallel", "PowerSupply", "Resistor", "scpi", "__version__",
|
|
15
|
+
]
|
scpi_sim/bench.py
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
"""A simulated test bench: instruments wired together, described in YAML.
|
|
2
|
+
|
|
3
|
+
Wiring lines join terminals (``psu.ch1``, ``dmm.input``, ``mux.101``,
|
|
4
|
+
``mux.common`` or a node name) into nets. A closed multiplexer relay joins its
|
|
5
|
+
channel terminal to the common terminal. Loads sit on nodes. Every reading is
|
|
6
|
+
evaluated against the nets as they are at that moment.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
import math
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
from collections.abc import Iterable
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
from .console import ConsoleServer
|
|
22
|
+
from .control import ControlServer
|
|
23
|
+
from .device import Console, Device
|
|
24
|
+
from .errors import ScpiError
|
|
25
|
+
from .instrument import Instrument
|
|
26
|
+
from .instruments import Multimeter, Multiplexer, PowerSupply
|
|
27
|
+
from .loads import Load, Parallel, Resistor
|
|
28
|
+
from .transport import InstrumentServer
|
|
29
|
+
|
|
30
|
+
def _whole(low: int, high: int | None = None):
|
|
31
|
+
def convert(value):
|
|
32
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < low or (high is not None and value > high):
|
|
33
|
+
raise ValueError(f"must be a whole number {low}-{high}" if high else f"must be a whole number >= {low}")
|
|
34
|
+
return value
|
|
35
|
+
return convert
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _real(low: float | None = None, above: float | None = None):
|
|
39
|
+
def convert(value):
|
|
40
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) \
|
|
41
|
+
or (low is not None and value < low) or (above is not None and value <= above):
|
|
42
|
+
bound = f" >= {low}" if low is not None else f" > {above}" if above is not None else ""
|
|
43
|
+
raise ValueError(f"must be a number{bound}")
|
|
44
|
+
return float(value)
|
|
45
|
+
return convert
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# type -> (class, {option: converter})
|
|
49
|
+
_TYPES = {
|
|
50
|
+
"psu": (PowerSupply, {"channels": _whole(1), "max_volts": _real(above=0), "max_amps": _real(above=0)}),
|
|
51
|
+
"dmm": (Multimeter, {"volts": _real(), "amps": _real(), "ohms": _real(low=0), "noise": _real(low=0),
|
|
52
|
+
"seed": _whole(0)}),
|
|
53
|
+
"mux": (Multiplexer, {"channels": _whole(1, 99)}),
|
|
54
|
+
}
|
|
55
|
+
# names an instrument cannot have: they are attributes of Bench
|
|
56
|
+
_RESERVED = {"instruments", "devices", "ports", "control_port", "host", "start", "stop", "port", "resource",
|
|
57
|
+
"console_port", "console_url"}
|
|
58
|
+
_NAME = re.compile(r"[a-z][a-z0-9_]*")
|
|
59
|
+
_NODE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
60
|
+
_WIRE = re.compile(r"\s*(\S+)\s*->\s*(\S+)\s*")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class BenchError(ValueError):
|
|
64
|
+
"""A problem in the bench description."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _Loader(yaml.SafeLoader):
|
|
68
|
+
"""SafeLoader that refuses duplicate keys instead of keeping the last one."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _unique_mapping(loader: _Loader, node: yaml.MappingNode, deep: bool = False) -> dict:
|
|
72
|
+
seen = set()
|
|
73
|
+
for key_node, _ in node.value:
|
|
74
|
+
key = loader.construct_object(key_node, deep=deep)
|
|
75
|
+
if key in seen:
|
|
76
|
+
raise BenchError(f"duplicate key {key!r} in line {key_node.start_mark.line + 1}")
|
|
77
|
+
seen.add(key)
|
|
78
|
+
return yaml.SafeLoader.construct_mapping(loader, node, deep)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_Loader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _unique_mapping)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _Nets:
|
|
85
|
+
"""Union-find over terminal names."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, parent: dict[str, str] | None = None):
|
|
88
|
+
self.parent = dict(parent or {})
|
|
89
|
+
|
|
90
|
+
def find(self, terminal: str) -> str:
|
|
91
|
+
self.parent.setdefault(terminal, terminal)
|
|
92
|
+
while self.parent[terminal] != terminal:
|
|
93
|
+
self.parent[terminal] = self.parent[self.parent[terminal]]
|
|
94
|
+
terminal = self.parent[terminal]
|
|
95
|
+
return terminal
|
|
96
|
+
|
|
97
|
+
def join(self, a: str, b: str) -> None:
|
|
98
|
+
self.parent[self.find(a)] = self.find(b)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class _NetLoad:
|
|
102
|
+
"""The load a supply output sees: everything on its net, resolved per call."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, bench: Bench, terminal: str):
|
|
105
|
+
self.bench, self.terminal = bench, terminal
|
|
106
|
+
|
|
107
|
+
def _parallel(self) -> Parallel:
|
|
108
|
+
return Parallel(tuple(self.bench._loads_on(self.terminal)))
|
|
109
|
+
|
|
110
|
+
def current(self, volts: float) -> float:
|
|
111
|
+
return self._parallel().current(volts)
|
|
112
|
+
|
|
113
|
+
def voltage(self, amps: float) -> float | None:
|
|
114
|
+
return self._parallel().voltage(amps)
|
|
115
|
+
|
|
116
|
+
def snapshot(self) -> Parallel:
|
|
117
|
+
"""The loads on the net right now, fixed for one operating-point calculation."""
|
|
118
|
+
return self._parallel()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class _DeviceInput:
|
|
122
|
+
"""A device input terminal seen as a load on its net."""
|
|
123
|
+
|
|
124
|
+
def __init__(self, device: Device, terminal: str):
|
|
125
|
+
self.device, self.terminal = device, terminal
|
|
126
|
+
|
|
127
|
+
def current(self, volts: float) -> float:
|
|
128
|
+
return self.device.current(self.terminal, volts)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class _DmmInput:
|
|
132
|
+
"""What the multimeter input is connected to."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, bench: Bench, terminal: str):
|
|
135
|
+
self.bench, self.terminal = bench, terminal
|
|
136
|
+
|
|
137
|
+
def volts(self) -> float:
|
|
138
|
+
return self.bench._net_voltage(self.terminal)
|
|
139
|
+
|
|
140
|
+
def ohms(self) -> float:
|
|
141
|
+
resistors = [load for load in self.bench._loads_on(self.terminal) if isinstance(load, Resistor)]
|
|
142
|
+
if not resistors:
|
|
143
|
+
return math.inf
|
|
144
|
+
if any(r.ohms == 0 for r in resistors):
|
|
145
|
+
return 0.0
|
|
146
|
+
return 1 / sum(1 / r.ohms for r in resistors)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Bench:
|
|
150
|
+
def __init__(self) -> None:
|
|
151
|
+
self.instruments: dict[str, Instrument] = {}
|
|
152
|
+
self.devices: dict[str, Device] = {}
|
|
153
|
+
self._console_ports: dict[str, int] = {} # device name -> configured console port
|
|
154
|
+
self.ports: dict[str, int] = {}
|
|
155
|
+
self.control_port: int | None = None
|
|
156
|
+
self.host = "127.0.0.1"
|
|
157
|
+
self._loads: dict[str, Load] = {}
|
|
158
|
+
self._static = _Nets()
|
|
159
|
+
# terminals that drive their net: PSU channels -> (psu, channel), device outputs -> (device, terminal)
|
|
160
|
+
self._sources: dict[str, tuple[PowerSupply | Device, int | str]] = {}
|
|
161
|
+
self._device_inputs: dict[str, _DeviceInput] = {}
|
|
162
|
+
self._update_failed: set[str] = set()
|
|
163
|
+
self._update_lock = threading.Lock() # one device update at a time, across instrument threads
|
|
164
|
+
self._servers: dict[str, InstrumentServer] = {}
|
|
165
|
+
self._control: ControlServer | None = None
|
|
166
|
+
self._consoles: dict[str, ConsoleServer] = {}
|
|
167
|
+
self._switch_lock = threading.Lock() # relay check + switch, across all multiplexers
|
|
168
|
+
|
|
169
|
+
# --- construction -------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
def from_file(cls, path: str | Path) -> Bench:
|
|
173
|
+
path = Path(path)
|
|
174
|
+
try:
|
|
175
|
+
data = yaml.load(path.read_text(encoding="utf-8"), Loader=_Loader)
|
|
176
|
+
except OSError as error:
|
|
177
|
+
raise BenchError(f"{path}: {error.strerror or error}") from None
|
|
178
|
+
except BenchError as error:
|
|
179
|
+
raise BenchError(f"{path}: {error}") from None
|
|
180
|
+
except yaml.YAMLError as error:
|
|
181
|
+
mark = getattr(error, "problem_mark", None)
|
|
182
|
+
where = f"line {mark.line + 1}, column {mark.column + 1}: " if mark else ""
|
|
183
|
+
problem = getattr(error, "problem", None) or str(error).splitlines()[0]
|
|
184
|
+
raise BenchError(f"{path}: {where}{problem}") from None
|
|
185
|
+
# python loads may live in modules next to the bench file
|
|
186
|
+
folder = str(path.resolve().parent)
|
|
187
|
+
sys.path.insert(0, folder)
|
|
188
|
+
try:
|
|
189
|
+
return cls.from_dict(data)
|
|
190
|
+
except BenchError as error:
|
|
191
|
+
raise BenchError(f"{path}: {error}") from None
|
|
192
|
+
finally:
|
|
193
|
+
sys.path.remove(folder)
|
|
194
|
+
|
|
195
|
+
@classmethod
|
|
196
|
+
def from_dict(cls, data: dict) -> Bench:
|
|
197
|
+
if not isinstance(data, dict):
|
|
198
|
+
raise BenchError("a bench is a mapping with an 'instruments' key")
|
|
199
|
+
unknown = set(data) - {"instruments", "devices", "loads", "wiring", "control"}
|
|
200
|
+
if unknown:
|
|
201
|
+
raise BenchError(f"unknown key {sorted(unknown)[0]!r} "
|
|
202
|
+
"(allowed: instruments, devices, loads, wiring, control)")
|
|
203
|
+
bench = cls()
|
|
204
|
+
bench._add_instruments(data.get("instruments"))
|
|
205
|
+
bench._add_devices(data.get("devices") or {})
|
|
206
|
+
bench._add_loads(data.get("loads") or {})
|
|
207
|
+
bench._add_wiring(data.get("wiring") or [])
|
|
208
|
+
bench._check()
|
|
209
|
+
bench.control_port = bench._control_setting(data.get("control"))
|
|
210
|
+
used = {**{f"instrument {n!r}": p for n, p in bench.ports.items()},
|
|
211
|
+
**{f"console of {n!r}": p for n, p in bench._console_ports.items()}}
|
|
212
|
+
seen: dict[int, str] = {}
|
|
213
|
+
for owner, port in used.items():
|
|
214
|
+
if port and port in seen:
|
|
215
|
+
raise BenchError(f"port {port} is used by {seen[port]} and {owner}")
|
|
216
|
+
seen[port] = owner
|
|
217
|
+
if bench.control_port and bench.control_port in seen:
|
|
218
|
+
raise BenchError(f"control port {bench.control_port} is already used by {seen[bench.control_port]}")
|
|
219
|
+
for instrument in bench.instruments.values():
|
|
220
|
+
instrument.listeners.append(bench._update_devices)
|
|
221
|
+
return bench
|
|
222
|
+
|
|
223
|
+
def _add_instruments(self, config) -> None:
|
|
224
|
+
if not isinstance(config, dict) or not config:
|
|
225
|
+
raise BenchError("'instruments' must map names to instrument settings")
|
|
226
|
+
used_ports: dict[int, str] = {}
|
|
227
|
+
for name, settings in config.items():
|
|
228
|
+
where = f"instrument {name!r}"
|
|
229
|
+
if not isinstance(name, str) or not _NAME.fullmatch(name):
|
|
230
|
+
raise BenchError(f"{where}: names use lowercase letters, digits and '_'")
|
|
231
|
+
if name in _RESERVED:
|
|
232
|
+
raise BenchError(f"{where}: reserved name, choose another one")
|
|
233
|
+
if not isinstance(settings, dict):
|
|
234
|
+
raise BenchError(f"{where}: settings must be a mapping")
|
|
235
|
+
settings = dict(settings)
|
|
236
|
+
kind = settings.pop("type", None)
|
|
237
|
+
if kind not in _TYPES:
|
|
238
|
+
raise BenchError(f"{where}: unknown type {kind!r} (allowed: {', '.join(_TYPES)})")
|
|
239
|
+
port = settings.pop("port", None)
|
|
240
|
+
if not isinstance(port, int) or isinstance(port, bool) or not 0 <= port <= 65535:
|
|
241
|
+
raise BenchError(f"{where}: 'port' must be a number 0-65535")
|
|
242
|
+
if port and port in used_ports:
|
|
243
|
+
raise BenchError(f"{where}: port {port} is already used by {used_ports[port]!r}")
|
|
244
|
+
used_ports[port] = name
|
|
245
|
+
factory, options = _TYPES[kind]
|
|
246
|
+
unknown = set(settings) - set(options)
|
|
247
|
+
if unknown:
|
|
248
|
+
raise BenchError(f"{where}: unknown option {sorted(unknown)[0]!r} "
|
|
249
|
+
f"(allowed for {kind}: {', '.join(sorted(options))})")
|
|
250
|
+
for option, value in settings.items():
|
|
251
|
+
try:
|
|
252
|
+
settings[option] = options[option](value)
|
|
253
|
+
except ValueError as error:
|
|
254
|
+
raise BenchError(f"{where}: option {option!r} {error}") from None
|
|
255
|
+
try:
|
|
256
|
+
instrument = factory(**settings)
|
|
257
|
+
except (TypeError, ValueError) as error:
|
|
258
|
+
raise BenchError(f"{where}: {error}") from None
|
|
259
|
+
self.instruments[name] = instrument
|
|
260
|
+
self.ports[name] = port
|
|
261
|
+
if isinstance(instrument, PowerSupply):
|
|
262
|
+
for channel in range(1, instrument.channel_count + 1):
|
|
263
|
+
terminal = f"{name}.ch{channel}"
|
|
264
|
+
self._sources[terminal] = (instrument, channel)
|
|
265
|
+
instrument.loads[channel - 1] = _NetLoad(self, terminal)
|
|
266
|
+
elif isinstance(instrument, Multimeter):
|
|
267
|
+
instrument.source = _DmmInput(self, f"{name}.input")
|
|
268
|
+
elif isinstance(instrument, Multiplexer):
|
|
269
|
+
instrument.before_close = lambda closed, mux=name: self._check_relays(mux, closed)
|
|
270
|
+
instrument.switch_lock = self._switch_lock
|
|
271
|
+
|
|
272
|
+
def _add_loads(self, config) -> None:
|
|
273
|
+
if not isinstance(config, dict):
|
|
274
|
+
raise BenchError("'loads' must map node names to loads")
|
|
275
|
+
for node, settings in config.items():
|
|
276
|
+
where = f"load {node!r}"
|
|
277
|
+
if not isinstance(node, str) or not _NODE.fullmatch(node) or node in self.instruments:
|
|
278
|
+
raise BenchError(f"{where}: a load sits on a node name (letters, digits, '_'), "
|
|
279
|
+
"not on an instrument")
|
|
280
|
+
if node in self.devices:
|
|
281
|
+
raise BenchError(f"{where}: {node!r} is a device; put the load on a node and wire "
|
|
282
|
+
"that node to a device terminal")
|
|
283
|
+
if not isinstance(settings, dict):
|
|
284
|
+
raise BenchError(f"{where}: settings must be a mapping")
|
|
285
|
+
kind = settings.get("type")
|
|
286
|
+
if kind == "resistor":
|
|
287
|
+
try:
|
|
288
|
+
self._loads[node] = Resistor(float(settings["ohms"]))
|
|
289
|
+
except KeyError:
|
|
290
|
+
raise BenchError(f"{where}: a resistor needs 'ohms'") from None
|
|
291
|
+
except (TypeError, ValueError) as error:
|
|
292
|
+
raise BenchError(f"{where}: {error}") from None
|
|
293
|
+
elif kind == "python":
|
|
294
|
+
self._loads[node] = self._python_load(where, settings)
|
|
295
|
+
else:
|
|
296
|
+
raise BenchError(f"{where}: unknown type {kind!r} (allowed: resistor, python)")
|
|
297
|
+
|
|
298
|
+
@staticmethod
|
|
299
|
+
def _import(where: str, target) -> type:
|
|
300
|
+
module_name, _, class_name = str(target).partition(":")
|
|
301
|
+
if not module_name or not class_name:
|
|
302
|
+
raise BenchError(f"{where}: 'class' must look like 'package.module:ClassName'")
|
|
303
|
+
try:
|
|
304
|
+
return getattr(importlib.import_module(module_name), class_name)
|
|
305
|
+
except Exception as error: # import errors, missing class
|
|
306
|
+
raise BenchError(f"{where}: cannot import {target}: {error}") from None
|
|
307
|
+
|
|
308
|
+
@classmethod
|
|
309
|
+
def _python_load(cls, where: str, settings: dict) -> Load:
|
|
310
|
+
target = settings.get("class", "")
|
|
311
|
+
factory = cls._import(where, target)
|
|
312
|
+
try:
|
|
313
|
+
load = factory(**(settings.get("args") or {}))
|
|
314
|
+
except Exception as error: # bad args, errors in the constructor
|
|
315
|
+
raise BenchError(f"{where}: cannot create {target}: {error}") from None
|
|
316
|
+
if not callable(getattr(load, "current", None)):
|
|
317
|
+
raise BenchError(f"{where}: {target} has no current(volts) method")
|
|
318
|
+
return load
|
|
319
|
+
|
|
320
|
+
def _add_devices(self, config) -> None:
|
|
321
|
+
if not isinstance(config, dict):
|
|
322
|
+
raise BenchError("'devices' must map names to device settings")
|
|
323
|
+
for name, settings in config.items():
|
|
324
|
+
where = f"device {name!r}"
|
|
325
|
+
if not isinstance(name, str) or not _NAME.fullmatch(name):
|
|
326
|
+
raise BenchError(f"{where}: names use lowercase letters, digits and '_'")
|
|
327
|
+
if name in _RESERVED:
|
|
328
|
+
raise BenchError(f"{where}: reserved name, choose another one")
|
|
329
|
+
if name in self.instruments:
|
|
330
|
+
raise BenchError(f"{where}: an instrument is already called {name!r}")
|
|
331
|
+
if not isinstance(settings, dict):
|
|
332
|
+
raise BenchError(f"{where}: settings must be a mapping")
|
|
333
|
+
unknown = set(settings) - {"class", "args", "console"}
|
|
334
|
+
if unknown:
|
|
335
|
+
raise BenchError(f"{where}: unknown key {sorted(unknown)[0]!r} (allowed: class, args, console)")
|
|
336
|
+
target = settings.get("class", "")
|
|
337
|
+
factory = self._import(where, target)
|
|
338
|
+
if not (isinstance(factory, type) and issubclass(factory, Device)):
|
|
339
|
+
raise BenchError(f"{where}: {target} is not a scpi_sim Device subclass")
|
|
340
|
+
args = settings.get("args") or {}
|
|
341
|
+
if not isinstance(args, dict):
|
|
342
|
+
raise BenchError(f"{where}: 'args' must be a mapping")
|
|
343
|
+
try:
|
|
344
|
+
device = factory(**args)
|
|
345
|
+
except Exception as error:
|
|
346
|
+
raise BenchError(f"{where}: cannot create {target}: {error}") from None
|
|
347
|
+
if not isinstance(getattr(device, "console", None), Console):
|
|
348
|
+
raise BenchError(f"{where}: {target} is not set up as a Device -- "
|
|
349
|
+
"does its __init__ call super().__init__()?")
|
|
350
|
+
if "console" in settings:
|
|
351
|
+
port = settings["console"]
|
|
352
|
+
if not isinstance(port, int) or isinstance(port, bool) or not 0 <= port <= 65535:
|
|
353
|
+
raise BenchError(f"{where}: 'console' must be a port number 0-65535")
|
|
354
|
+
self._console_ports[name] = port
|
|
355
|
+
self.devices[name] = device
|
|
356
|
+
for terminal, kind in device.terminals.items():
|
|
357
|
+
full = f"{name}.{terminal}"
|
|
358
|
+
if kind == "output":
|
|
359
|
+
self._sources[full] = (device, terminal)
|
|
360
|
+
else:
|
|
361
|
+
self._device_inputs[full] = _DeviceInput(device, terminal)
|
|
362
|
+
device.attach(lambda terminal, device_name=name: self._net_voltage(f"{device_name}.{terminal}"))
|
|
363
|
+
|
|
364
|
+
def _terminal(self, text: str, line: str) -> str:
|
|
365
|
+
where = f"wiring line {line!r}"
|
|
366
|
+
if "." not in text:
|
|
367
|
+
if not _NODE.fullmatch(text):
|
|
368
|
+
raise BenchError(f"{where}: invalid node name {text!r}")
|
|
369
|
+
if text in self.instruments:
|
|
370
|
+
raise BenchError(f"{where}: {text!r} is an instrument, name one of its terminals")
|
|
371
|
+
if text in self.devices:
|
|
372
|
+
raise BenchError(f"{where}: {text!r} is a device, name one of its terminals "
|
|
373
|
+
f"({', '.join(self.devices[text].terminals)})")
|
|
374
|
+
return text
|
|
375
|
+
name, _, pin = text.partition(".")
|
|
376
|
+
if name in self.devices:
|
|
377
|
+
if pin not in self.devices[name].terminals:
|
|
378
|
+
raise BenchError(f"{where}: unknown terminal {text!r} "
|
|
379
|
+
f"(device terminals: {', '.join(self.devices[name].terminals)})")
|
|
380
|
+
return text
|
|
381
|
+
instrument = self.instruments.get(name)
|
|
382
|
+
if instrument is None:
|
|
383
|
+
raise BenchError(f"{where}: unknown instrument or device {name!r}")
|
|
384
|
+
valid = (
|
|
385
|
+
(pin == "input") if isinstance(instrument, Multimeter) else
|
|
386
|
+
(text in self._sources) if isinstance(instrument, PowerSupply) else
|
|
387
|
+
(pin == "common" or (pin.isdigit() and int(pin) in instrument.valid_channels))
|
|
388
|
+
)
|
|
389
|
+
if not valid:
|
|
390
|
+
raise BenchError(f"{where}: unknown terminal {text!r}")
|
|
391
|
+
return text
|
|
392
|
+
|
|
393
|
+
def _add_wiring(self, lines) -> None:
|
|
394
|
+
if not isinstance(lines, list):
|
|
395
|
+
raise BenchError("'wiring' must be a list of 'A -> B' lines")
|
|
396
|
+
for line in lines:
|
|
397
|
+
m = _WIRE.fullmatch(str(line))
|
|
398
|
+
if not m:
|
|
399
|
+
raise BenchError(f"wiring line {line!r}: expected 'A -> B'")
|
|
400
|
+
self._static.join(self._terminal(m.group(1), line), self._terminal(m.group(2), line))
|
|
401
|
+
|
|
402
|
+
def _check(self) -> None:
|
|
403
|
+
for node in self._loads:
|
|
404
|
+
if node not in self._static.parent:
|
|
405
|
+
raise BenchError(f"load {node!r}: node {node!r} is not wired to anything")
|
|
406
|
+
self._check_sources(self._static, error=lambda msg: BenchError(msg))
|
|
407
|
+
|
|
408
|
+
@staticmethod
|
|
409
|
+
def _control_setting(value) -> int | None:
|
|
410
|
+
if value is None:
|
|
411
|
+
return None
|
|
412
|
+
if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= 65535:
|
|
413
|
+
raise BenchError("'control' must be a port number 0-65535")
|
|
414
|
+
return value
|
|
415
|
+
|
|
416
|
+
# --- evaluation -----------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
def _nets(self, closed_override: tuple[str, set[int]] | None = None) -> _Nets:
|
|
419
|
+
nets = _Nets(self._static.parent)
|
|
420
|
+
for name, instrument in self.instruments.items():
|
|
421
|
+
if isinstance(instrument, Multiplexer):
|
|
422
|
+
closed = instrument.closed
|
|
423
|
+
if closed_override is not None and closed_override[0] == name:
|
|
424
|
+
closed = closed_override[1]
|
|
425
|
+
for channel in tuple(closed):
|
|
426
|
+
nets.join(f"{name}.{channel}", f"{name}.common")
|
|
427
|
+
return nets
|
|
428
|
+
|
|
429
|
+
def _check_sources(self, nets: _Nets, error) -> None:
|
|
430
|
+
seen: dict[str, str] = {}
|
|
431
|
+
for terminal in self._sources:
|
|
432
|
+
net = nets.find(terminal)
|
|
433
|
+
if net in seen:
|
|
434
|
+
raise error(f"{terminal} and {seen[net]} would drive the same net")
|
|
435
|
+
seen[net] = terminal
|
|
436
|
+
|
|
437
|
+
def _check_relays(self, mux: str, closed: set[int]) -> None:
|
|
438
|
+
self._check_sources(self._nets((mux, closed)), error=lambda msg: ScpiError(-221))
|
|
439
|
+
|
|
440
|
+
def _loads_on(self, terminal: str) -> Iterable[Load]:
|
|
441
|
+
nets = self._nets()
|
|
442
|
+
net = nets.find(terminal)
|
|
443
|
+
loads = [load for node, load in self._loads.items() if nets.find(node) == net]
|
|
444
|
+
loads += [load for name, load in self._device_inputs.items() if nets.find(name) == net]
|
|
445
|
+
return loads
|
|
446
|
+
|
|
447
|
+
def _net_voltage(self, terminal: str) -> float:
|
|
448
|
+
"""Voltage of the net ``terminal`` is on: from the supply or device output driving it, else 0."""
|
|
449
|
+
nets = self._nets()
|
|
450
|
+
net = nets.find(terminal)
|
|
451
|
+
for source, (owner, pin) in self._sources.items():
|
|
452
|
+
if nets.find(source) == net:
|
|
453
|
+
if isinstance(owner, PowerSupply):
|
|
454
|
+
return owner.output(pin)[0]
|
|
455
|
+
volts = owner.voltage(pin)
|
|
456
|
+
return 0.0 if volts is None else float(volts)
|
|
457
|
+
return 0.0
|
|
458
|
+
|
|
459
|
+
def _update_devices(self) -> None:
|
|
460
|
+
with self._update_lock:
|
|
461
|
+
self._update_each()
|
|
462
|
+
|
|
463
|
+
def _update_each(self) -> None:
|
|
464
|
+
for name, device in self.devices.items():
|
|
465
|
+
try:
|
|
466
|
+
device.update()
|
|
467
|
+
except Exception as error:
|
|
468
|
+
if name not in self._update_failed:
|
|
469
|
+
self._update_failed.add(name)
|
|
470
|
+
print(f"scpi-sim: device {name}: update() failed: {type(error).__name__}: {error}",
|
|
471
|
+
file=sys.stderr)
|
|
472
|
+
|
|
473
|
+
# --- running ----------------------------------------------------------------------
|
|
474
|
+
|
|
475
|
+
def start(self, host: str = "127.0.0.1", free_ports: bool = False) -> None:
|
|
476
|
+
try:
|
|
477
|
+
for name, instrument in self.instruments.items():
|
|
478
|
+
server = InstrumentServer(instrument, host=host, port=0 if free_ports else self.ports[name])
|
|
479
|
+
server.start()
|
|
480
|
+
self._servers[name] = server
|
|
481
|
+
for name, port in self._console_ports.items():
|
|
482
|
+
console = ConsoleServer(self.devices[name], host=host, port=0 if free_ports else port, name=name)
|
|
483
|
+
console.start()
|
|
484
|
+
self._consoles[name] = console
|
|
485
|
+
if self.control_port is not None:
|
|
486
|
+
self._control = ControlServer(self, host=host, port=0 if free_ports else self.control_port)
|
|
487
|
+
self.control_port = self._control.start()
|
|
488
|
+
except BaseException:
|
|
489
|
+
self.stop()
|
|
490
|
+
raise
|
|
491
|
+
self.host = host
|
|
492
|
+
|
|
493
|
+
def stop(self) -> None:
|
|
494
|
+
"""Stop every server; if one fails, still stop the others, then re-raise."""
|
|
495
|
+
servers = [*([self._control] if self._control else []), *self._consoles.values(), *self._servers.values()]
|
|
496
|
+
self._control = None
|
|
497
|
+
self._consoles.clear()
|
|
498
|
+
self._servers.clear()
|
|
499
|
+
failure = None
|
|
500
|
+
for server in servers:
|
|
501
|
+
try:
|
|
502
|
+
server.stop()
|
|
503
|
+
except Exception as error:
|
|
504
|
+
failure = failure or error
|
|
505
|
+
if failure is not None:
|
|
506
|
+
raise failure
|
|
507
|
+
|
|
508
|
+
def port(self, name: str) -> int:
|
|
509
|
+
return self._servers[name].port
|
|
510
|
+
|
|
511
|
+
def resource(self, name: str) -> str:
|
|
512
|
+
return f"TCPIP0::{self.host}::{self.port(name)}::SOCKET"
|
|
513
|
+
|
|
514
|
+
def console_port(self, name: str) -> int:
|
|
515
|
+
"""Bound console port of device ``name``; KeyError if it has no console."""
|
|
516
|
+
return self._consoles[name].port
|
|
517
|
+
|
|
518
|
+
def console_url(self, name: str) -> str:
|
|
519
|
+
"""pyserial URL of a device console: ``socket://host:port``."""
|
|
520
|
+
return f"socket://{self.host}:{self.console_port(name)}"
|
|
521
|
+
|
|
522
|
+
def __enter__(self) -> Bench:
|
|
523
|
+
self.start()
|
|
524
|
+
return self
|
|
525
|
+
|
|
526
|
+
def __exit__(self, *exc) -> None:
|
|
527
|
+
self.stop()
|
|
528
|
+
|
|
529
|
+
def __getattr__(self, name: str) -> Instrument | Device:
|
|
530
|
+
for registry in ("instruments", "devices"):
|
|
531
|
+
items = self.__dict__.get(registry, {})
|
|
532
|
+
if name in items:
|
|
533
|
+
return items[name]
|
|
534
|
+
raise AttributeError(name)
|