SCPI-Instrument-Control 1.0.1__py3-none-any.whl → 2.0.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_control/__init__.py +10 -1
- scpi_control/automation.py +28 -12
- scpi_control/channel.py +30 -18
- scpi_control/connection/base.py +4 -0
- scpi_control/connection/mock.py +239 -46
- scpi_control/connection/socket.py +172 -81
- scpi_control/connection/visa_connection.py +7 -5
- scpi_control/daq_models.py +316 -0
- scpi_control/daq_scpi_commands.py +227 -0
- scpi_control/data_logger.py +762 -0
- scpi_control/function_generator.py +4 -4
- scpi_control/gui/app.py +4 -4
- scpi_control/gui/connection_manager.py +7 -7
- scpi_control/gui/daq_worker.py +205 -0
- scpi_control/gui/main_window.py +131 -17
- scpi_control/gui/widgets/__init__.py +14 -1
- scpi_control/gui/widgets/daq_ai_panel.py +390 -0
- scpi_control/gui/widgets/daq_channel_config.py +317 -0
- scpi_control/gui/widgets/daq_data_view.py +371 -0
- scpi_control/gui/widgets/daq_scan_config.py +275 -0
- scpi_control/gui/widgets/data_logger_control.py +364 -0
- scpi_control/gui/widgets/math_panel.py +2 -4
- scpi_control/gui/widgets/psu_control.py +2 -4
- scpi_control/gui/widgets/terminal_widget.py +10 -20
- scpi_control/gui/widgets/timebase_control.py +4 -8
- scpi_control/gui/widgets/vector_graphics_panel.py +2 -2
- scpi_control/models.py +21 -0
- scpi_control/oscilloscope.py +51 -21
- scpi_control/power_supply.py +5 -5
- scpi_control/report_generator/llm/__init__.py +14 -1
- scpi_control/report_generator/llm/daq_analyzer.py +316 -0
- scpi_control/report_generator/llm/daq_context_builder.py +340 -0
- scpi_control/report_generator/llm/daq_prompts.py +114 -0
- scpi_control/scpi_commands.py +203 -70
- scpi_control/server/__init__.py +0 -0
- scpi_control/server/__main__.py +19 -0
- scpi_control/server/api/__init__.py +0 -0
- scpi_control/server/api/discovery.py +35 -0
- scpi_control/server/api/scope.py +467 -0
- scpi_control/server/api/sessions.py +50 -0
- scpi_control/server/api/stream.py +103 -0
- scpi_control/server/app.py +91 -0
- scpi_control/server/compute.py +109 -0
- scpi_control/server/discovery.py +126 -0
- scpi_control/server/recorder.py +66 -0
- scpi_control/server/schemas.py +113 -0
- scpi_control/server/sessions.py +392 -0
- scpi_control/server/static/assets/index-0Vpguwg6.js +9 -0
- scpi_control/server/static/assets/index-CyUPLIHk.css +1 -0
- scpi_control/server/static/index.html +13 -0
- scpi_control/trigger.py +88 -44
- scpi_control/vector_graphics.py +3 -2
- scpi_control/waveform.py +27 -9
- {scpi_instrument_control-1.0.1.dist-info → scpi_instrument_control-2.0.0.dist-info}/METADATA +59 -18
- {scpi_instrument_control-1.0.1.dist-info → scpi_instrument_control-2.0.0.dist-info}/RECORD +59 -32
- {scpi_instrument_control-1.0.1.dist-info → scpi_instrument_control-2.0.0.dist-info}/WHEEL +1 -1
- {scpi_instrument_control-1.0.1.dist-info → scpi_instrument_control-2.0.0.dist-info}/entry_points.txt +1 -0
- {scpi_instrument_control-1.0.1.dist-info → scpi_instrument_control-2.0.0.dist-info}/top_level.txt +0 -1
- siglent/__init__.py +0 -35
- {scpi_instrument_control-1.0.1.dist-info → scpi_instrument_control-2.0.0.dist-info}/licenses/LICENSE +0 -0
scpi_control/__init__.py
CHANGED
|
@@ -7,6 +7,7 @@ Supported equipment:
|
|
|
7
7
|
- Oscilloscopes (Siglent SDS series and SCPI-compatible models)
|
|
8
8
|
- Function Generators / AWGs (Siglent SDG series and SCPI-compatible models)
|
|
9
9
|
- Power Supplies (Siglent SPD series and SCPI-compatible models)
|
|
10
|
+
- Data Acquisition / Data Loggers (Keysight 34970A/DAQ970A and SCPI-compatible models)
|
|
10
11
|
|
|
11
12
|
For high-level automation and data collection, see the automation module:
|
|
12
13
|
from scpi_control.automation import DataCollector, TriggerWaitCollector
|
|
@@ -17,11 +18,14 @@ For power supply control:
|
|
|
17
18
|
For function generator control:
|
|
18
19
|
from scpi_control import FunctionGenerator
|
|
19
20
|
|
|
21
|
+
For data acquisition / data logging:
|
|
22
|
+
from scpi_control import DataLogger
|
|
23
|
+
|
|
20
24
|
For automated test report generation:
|
|
21
25
|
from scpi_control.report_generator import ReportGenerator
|
|
22
26
|
"""
|
|
23
27
|
|
|
24
|
-
__version__ = "
|
|
28
|
+
__version__ = "2.0.0"
|
|
25
29
|
|
|
26
30
|
from scpi_control.exceptions import CommandError, SiglentConnectionError, SiglentError, SiglentTimeoutError
|
|
27
31
|
from scpi_control.oscilloscope import Oscilloscope
|
|
@@ -33,6 +37,9 @@ from scpi_control.psu_data_logger import PSUDataLogger, TimedPSULogger
|
|
|
33
37
|
# Function generator support (new in v0.6.0, stable in v1.0.0)
|
|
34
38
|
from scpi_control.function_generator import FunctionGenerator
|
|
35
39
|
|
|
40
|
+
# Data acquisition / Data logger support (new in v1.1.0)
|
|
41
|
+
from scpi_control.data_logger import DataLogger
|
|
42
|
+
|
|
36
43
|
__all__ = [
|
|
37
44
|
# Core features
|
|
38
45
|
"Oscilloscope",
|
|
@@ -46,4 +53,6 @@ __all__ = [
|
|
|
46
53
|
"TimedPSULogger",
|
|
47
54
|
# Function generator features
|
|
48
55
|
"FunctionGenerator",
|
|
56
|
+
# Data acquisition features
|
|
57
|
+
"DataLogger",
|
|
49
58
|
]
|
scpi_control/automation.py
CHANGED
|
@@ -63,19 +63,21 @@ class DataCollector:
|
|
|
63
63
|
def __init__(
|
|
64
64
|
self,
|
|
65
65
|
host: str,
|
|
66
|
-
port: int =
|
|
66
|
+
port: int = 5025,
|
|
67
67
|
timeout: float = 5.0,
|
|
68
68
|
connection: Optional[BaseConnection] = None,
|
|
69
|
+
dialect: Optional[str] = None,
|
|
69
70
|
):
|
|
70
71
|
"""Initialize data collector.
|
|
71
72
|
|
|
72
73
|
Args:
|
|
73
74
|
host: IP address or hostname of the oscilloscope
|
|
74
|
-
port: TCP port for SCPI communication (default: 5024)
|
|
75
|
+
port: TCP port for SCPI communication (default: 5025, the Siglent raw SCPI socket; 5024 is the telnet-style port with prompts and is not recommended)
|
|
75
76
|
timeout: Command timeout in seconds (default: 5.0)
|
|
76
77
|
connection: Optional connection implementation (e.g., MockConnection for offline tests)
|
|
78
|
+
dialect: Optional SCPI dialect override passed to Oscilloscope ("legacy" or "modern"; None auto-detects)
|
|
77
79
|
"""
|
|
78
|
-
self.scope = Oscilloscope(host, port, timeout, connection=connection)
|
|
80
|
+
self.scope = Oscilloscope(host, port, timeout, connection=connection, dialect=dialect)
|
|
79
81
|
self._connected = False
|
|
80
82
|
|
|
81
83
|
def connect(self) -> None:
|
|
@@ -472,19 +474,21 @@ class TriggerWaitCollector:
|
|
|
472
474
|
def __init__(
|
|
473
475
|
self,
|
|
474
476
|
host: str,
|
|
475
|
-
port: int =
|
|
477
|
+
port: int = 5025,
|
|
476
478
|
timeout: float = 5.0,
|
|
477
479
|
connection: Optional[BaseConnection] = None,
|
|
480
|
+
dialect: Optional[str] = None,
|
|
478
481
|
):
|
|
479
482
|
"""Initialize trigger wait collector.
|
|
480
483
|
|
|
481
484
|
Args:
|
|
482
485
|
host: IP address or hostname of the oscilloscope
|
|
483
|
-
port: TCP port for SCPI communication
|
|
486
|
+
port: TCP port for SCPI communication (default: 5025, the Siglent raw SCPI socket; 5024 is the telnet-style port with prompts and is not recommended)
|
|
484
487
|
timeout: Command timeout in seconds
|
|
485
488
|
connection: Optional connection implementation (e.g., MockConnection for offline tests)
|
|
489
|
+
dialect: Optional SCPI dialect override passed to Oscilloscope ("legacy" or "modern"; None auto-detects)
|
|
486
490
|
"""
|
|
487
|
-
self.collector = DataCollector(host, port, timeout, connection=connection)
|
|
491
|
+
self.collector = DataCollector(host, port, timeout, connection=connection, dialect=dialect)
|
|
488
492
|
|
|
489
493
|
def __enter__(self):
|
|
490
494
|
"""Context manager entry."""
|
|
@@ -505,6 +509,11 @@ class TriggerWaitCollector:
|
|
|
505
509
|
) -> Optional[Dict[int, WaveformData]]:
|
|
506
510
|
"""Wait for a trigger event and capture waveform.
|
|
507
511
|
|
|
512
|
+
If the trigger mode has been set to NORMAL beforehand (e.g. via
|
|
513
|
+
``trigger.set_mode('NORMAL')``), that mode is preserved and the wait
|
|
514
|
+
completes on the first trigger event. In any other mode, the scope is
|
|
515
|
+
switched to SINGLE and armed for a one-shot acquisition.
|
|
516
|
+
|
|
508
517
|
Args:
|
|
509
518
|
channels: List of channel numbers to capture
|
|
510
519
|
max_wait: Maximum time to wait for trigger in seconds
|
|
@@ -526,16 +535,23 @@ class TriggerWaitCollector:
|
|
|
526
535
|
... if data:
|
|
527
536
|
... print("Trigger captured!")
|
|
528
537
|
"""
|
|
529
|
-
#
|
|
530
|
-
|
|
531
|
-
self.collector.scope.
|
|
538
|
+
# Honor a user-configured NORMAL trigger mode; otherwise arm a
|
|
539
|
+
# one-shot SINGLE acquisition. trigger.mode is dialect-normalized.
|
|
540
|
+
current_mode = self.collector.scope.trigger.mode
|
|
541
|
+
|
|
542
|
+
if current_mode == "NORM":
|
|
543
|
+
# NORMAL mode re-arms after every trigger and never reports
|
|
544
|
+
# STOP, so watch for the trigger event itself.
|
|
545
|
+
done_states = {"TRIGD", "STOP"}
|
|
546
|
+
else:
|
|
547
|
+
self.collector.scope.trigger_single()
|
|
548
|
+
done_states = {"STOP"}
|
|
532
549
|
|
|
533
550
|
start_time = time.time()
|
|
534
551
|
while (time.time() - start_time) < max_wait:
|
|
535
|
-
|
|
536
|
-
status = self.collector.scope.query(":TRIG:STAT?").strip()
|
|
552
|
+
status = self.collector.scope.acquisition_status()
|
|
537
553
|
|
|
538
|
-
if status
|
|
554
|
+
if status in done_states:
|
|
539
555
|
# Trigger occurred, capture waveform
|
|
540
556
|
logger.info("Trigger detected!")
|
|
541
557
|
waveforms = {}
|
scpi_control/channel.py
CHANGED
|
@@ -4,6 +4,7 @@ import logging
|
|
|
4
4
|
from typing import TYPE_CHECKING, Literal, Optional
|
|
5
5
|
|
|
6
6
|
from scpi_control import exceptions
|
|
7
|
+
from scpi_control.scpi_commands import coupling_from_wire, coupling_to_wire
|
|
7
8
|
|
|
8
9
|
if TYPE_CHECKING:
|
|
9
10
|
from scpi_control.oscilloscope import Oscilloscope
|
|
@@ -35,6 +36,13 @@ class Channel:
|
|
|
35
36
|
if not 1 <= channel_number <= 4:
|
|
36
37
|
raise exceptions.InvalidParameterError(f"Invalid channel number: {channel_number}. Must be 1-4.")
|
|
37
38
|
|
|
39
|
+
@property
|
|
40
|
+
def _dialect(self) -> str:
|
|
41
|
+
return getattr(self._scope, "dialect", None) or "legacy"
|
|
42
|
+
|
|
43
|
+
def _cmd(self, name: str, **kwargs) -> str:
|
|
44
|
+
return self._scope._get_command(name, **kwargs)
|
|
45
|
+
|
|
38
46
|
@property
|
|
39
47
|
def enabled(self) -> bool:
|
|
40
48
|
"""Get channel display state.
|
|
@@ -42,7 +50,7 @@ class Channel:
|
|
|
42
50
|
Returns:
|
|
43
51
|
True if channel is displayed, False otherwise
|
|
44
52
|
"""
|
|
45
|
-
response = self._scope.query(
|
|
53
|
+
response = self._scope.query(self._cmd("get_channel_display", ch=self._channel))
|
|
46
54
|
# Response format: "C1:TRA ON" or "C1:TRA OFF"
|
|
47
55
|
# Extract the last word
|
|
48
56
|
return "ON" in response.upper()
|
|
@@ -55,7 +63,7 @@ class Channel:
|
|
|
55
63
|
value: True to display channel, False to hide
|
|
56
64
|
"""
|
|
57
65
|
state = "ON" if value else "OFF"
|
|
58
|
-
self._scope.write(
|
|
66
|
+
self._scope.write(self._cmd("set_channel_display", ch=self._channel, state=state))
|
|
59
67
|
logger.info(f"Channel {self._channel} {'enabled' if value else 'disabled'}")
|
|
60
68
|
|
|
61
69
|
def enable(self) -> None:
|
|
@@ -73,8 +81,7 @@ class Channel:
|
|
|
73
81
|
Returns:
|
|
74
82
|
Coupling mode: 'DC', 'AC', or 'GND'
|
|
75
83
|
"""
|
|
76
|
-
|
|
77
|
-
return response.upper().strip()
|
|
84
|
+
return coupling_from_wire(self._dialect, self._scope.query(self._cmd("get_coupling", ch=self._channel)))
|
|
78
85
|
|
|
79
86
|
@coupling.setter
|
|
80
87
|
def coupling(self, mode: CouplingType) -> None:
|
|
@@ -86,7 +93,7 @@ class Channel:
|
|
|
86
93
|
mode = mode.upper()
|
|
87
94
|
if mode not in ["DC", "AC", "GND"]:
|
|
88
95
|
raise exceptions.InvalidParameterError(f"Invalid coupling mode: {mode}. Must be DC, AC, or GND.")
|
|
89
|
-
self._scope.write(
|
|
96
|
+
self._scope.write(self._cmd("set_coupling", ch=self._channel, coupling=coupling_to_wire(self._dialect, mode)))
|
|
90
97
|
logger.info(f"Channel {self._channel} coupling set to {mode}")
|
|
91
98
|
|
|
92
99
|
@property
|
|
@@ -96,7 +103,7 @@ class Channel:
|
|
|
96
103
|
Returns:
|
|
97
104
|
Voltage scale in volts/division
|
|
98
105
|
"""
|
|
99
|
-
response = self._scope.query(
|
|
106
|
+
response = self._scope.query(self._cmd("get_voltage_div", ch=self._channel))
|
|
100
107
|
# Response may include echo like "C1:VDIV 1.0E+00V" or just "1.0E+00V"
|
|
101
108
|
# Remove the echo prefix if present
|
|
102
109
|
if ":" in response:
|
|
@@ -120,7 +127,7 @@ class Channel:
|
|
|
120
127
|
"""
|
|
121
128
|
if volts_per_div <= 0:
|
|
122
129
|
raise exceptions.InvalidParameterError(f"Voltage scale must be positive: {volts_per_div}")
|
|
123
|
-
self._scope.write(
|
|
130
|
+
self._scope.write(self._cmd("set_voltage_div", ch=self._channel, vdiv=volts_per_div))
|
|
124
131
|
logger.info(f"Channel {self._channel} scale set to {volts_per_div} V/div")
|
|
125
132
|
|
|
126
133
|
def set_scale(self, volts_per_div: float) -> None:
|
|
@@ -138,7 +145,7 @@ class Channel:
|
|
|
138
145
|
Returns:
|
|
139
146
|
Offset voltage in volts
|
|
140
147
|
"""
|
|
141
|
-
response = self._scope.query(
|
|
148
|
+
response = self._scope.query(self._cmd("get_voltage_offset", ch=self._channel))
|
|
142
149
|
# Response may include echo like "C1:OFST 1.0E+00V"
|
|
143
150
|
if ":" in response:
|
|
144
151
|
response = response.split(":", 1)[1]
|
|
@@ -154,7 +161,7 @@ class Channel:
|
|
|
154
161
|
Args:
|
|
155
162
|
offset: Offset voltage in volts
|
|
156
163
|
"""
|
|
157
|
-
self._scope.write(
|
|
164
|
+
self._scope.write(self._cmd("set_voltage_offset", ch=self._channel, offset=offset))
|
|
158
165
|
logger.info(f"Channel {self._channel} offset set to {offset} V")
|
|
159
166
|
|
|
160
167
|
@property
|
|
@@ -164,7 +171,7 @@ class Channel:
|
|
|
164
171
|
Returns:
|
|
165
172
|
Probe ratio (e.g., 1.0 for 1X, 10.0 for 10X)
|
|
166
173
|
"""
|
|
167
|
-
response = self._scope.query(
|
|
174
|
+
response = self._scope.query(self._cmd("get_probe_ratio", ch=self._channel))
|
|
168
175
|
# Response may include echo like "C1:ATTN 10"
|
|
169
176
|
if ":" in response:
|
|
170
177
|
response = response.split(":", 1)[1]
|
|
@@ -183,7 +190,7 @@ class Channel:
|
|
|
183
190
|
"""
|
|
184
191
|
if ratio <= 0:
|
|
185
192
|
raise exceptions.InvalidParameterError(f"Probe ratio must be positive: {ratio}")
|
|
186
|
-
self._scope.write(
|
|
193
|
+
self._scope.write(self._cmd("set_probe_ratio", ch=self._channel, ratio=ratio))
|
|
187
194
|
logger.info(f"Channel {self._channel} probe ratio set to {ratio}X")
|
|
188
195
|
|
|
189
196
|
@property
|
|
@@ -193,8 +200,11 @@ class Channel:
|
|
|
193
200
|
Returns:
|
|
194
201
|
Bandwidth limit: 'ON', 'OFF', or frequency limit
|
|
195
202
|
"""
|
|
196
|
-
response = self._scope.query(
|
|
197
|
-
|
|
203
|
+
response = self._scope.query(self._cmd("get_bandwidth_limit", ch=self._channel)).strip().upper()
|
|
204
|
+
if self._dialect == "modern":
|
|
205
|
+
# Modern wire tokens are FULL/20M/200M; the API speaks ON/OFF
|
|
206
|
+
return "OFF" if response == "FULL" else "ON"
|
|
207
|
+
return response
|
|
198
208
|
|
|
199
209
|
@bandwidth_limit.setter
|
|
200
210
|
def bandwidth_limit(self, limit: BandwidthLimitType) -> None:
|
|
@@ -206,10 +216,11 @@ class Channel:
|
|
|
206
216
|
limit = limit.upper()
|
|
207
217
|
if limit not in ["ON", "OFF", "FULL"]:
|
|
208
218
|
raise exceptions.InvalidParameterError(f"Invalid bandwidth limit: {limit}. Must be ON, OFF, or FULL.")
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
219
|
+
if self._dialect == "modern":
|
|
220
|
+
wire = "FULL" if limit in ("OFF", "FULL") else "20M"
|
|
221
|
+
else:
|
|
222
|
+
wire = "OFF" if limit == "FULL" else limit
|
|
223
|
+
self._scope.write(self._cmd("set_bandwidth_limit", ch=self._channel, limit=wire))
|
|
213
224
|
logger.info(f"Channel {self._channel} bandwidth limit set to {limit}")
|
|
214
225
|
|
|
215
226
|
@property
|
|
@@ -219,6 +230,7 @@ class Channel:
|
|
|
219
230
|
Returns:
|
|
220
231
|
Unit string (typically 'V' for volts)
|
|
221
232
|
"""
|
|
233
|
+
# legacy-only command; not routed
|
|
222
234
|
response = self._scope.query(f"{self._prefix}:UNIT?")
|
|
223
235
|
return response.strip()
|
|
224
236
|
|
|
@@ -241,7 +253,7 @@ class Channel:
|
|
|
241
253
|
# For per-channel auto-scale, we might need to use different commands
|
|
242
254
|
# This is a basic implementation that may need adjustment for SD824x
|
|
243
255
|
logger.info(f"Auto-scaling channel {self._channel}")
|
|
244
|
-
self._scope.write("
|
|
256
|
+
self._scope.write(self._cmd("auto_setup"))
|
|
245
257
|
|
|
246
258
|
def get_configuration(self) -> dict:
|
|
247
259
|
"""Get all channel configuration parameters.
|
scpi_control/connection/base.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Abstract base class for oscilloscope connections."""
|
|
2
2
|
|
|
3
|
+
import threading
|
|
3
4
|
from abc import ABC, abstractmethod
|
|
4
5
|
from typing import Optional, Union
|
|
5
6
|
|
|
@@ -19,6 +20,9 @@ class BaseConnection(ABC):
|
|
|
19
20
|
self.port = port
|
|
20
21
|
self.timeout = timeout
|
|
21
22
|
self._connected = False
|
|
23
|
+
# Reentrant so query() can hold it across its own write()/read() calls.
|
|
24
|
+
# Callers doing compound exchanges (write + read_raw) must hold it too.
|
|
25
|
+
self.lock = threading.RLock()
|
|
22
26
|
|
|
23
27
|
@abstractmethod
|
|
24
28
|
def connect(self) -> None:
|