SCPI-Instrument-Control 2.0.0__py3-none-any.whl → 3.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 +1 -1
- scpi_control/channel.py +67 -18
- scpi_control/connection/mock/__init__.py +5 -0
- scpi_control/connection/{mock.py → mock/base.py} +46 -225
- scpi_control/connection/mock/helpers.py +25 -0
- scpi_control/connection/mock/lecroy.py +96 -0
- scpi_control/connection/mock/siglent.py +235 -0
- scpi_control/connection/mock/tektronix.py +226 -0
- scpi_control/exceptions.py +6 -0
- scpi_control/gui/widgets/daq_ai_panel.py +2 -8
- scpi_control/math_channel.py +5 -14
- scpi_control/measurement.py +66 -15
- scpi_control/measurement_badges.py +123 -0
- scpi_control/models.py +313 -21
- scpi_control/oscilloscope.py +39 -9
- scpi_control/report_generator/__init__.py +1 -1
- scpi_control/report_generator/analysis/__init__.py +1 -0
- scpi_control/report_generator/analysis/computed_analyzer.py +137 -0
- scpi_control/report_generator/app.py +74 -3
- scpi_control/report_generator/generators/markdown_generator.py +12 -8
- scpi_control/report_generator/generators/pdf_generator.py +147 -139
- scpi_control/report_generator/llm/analyzer.py +20 -8
- scpi_control/report_generator/llm/client.py +134 -91
- scpi_control/report_generator/llm/context_builder.py +13 -16
- scpi_control/report_generator/llm/daq_analyzer.py +21 -16
- scpi_control/report_generator/llm/prompts.py +17 -1
- scpi_control/report_generator/llm/tools.py +274 -0
- scpi_control/report_generator/main_window.py +37 -40
- scpi_control/report_generator/models/report_data.py +48 -21
- scpi_control/report_generator/models/template.py +26 -0
- scpi_control/report_generator/utils/waveform_analyzer.py +96 -25
- scpi_control/report_generator/utils/waveform_loader.py +271 -201
- scpi_control/report_generator/widgets/ai_analysis_panel.py +22 -3
- scpi_control/report_generator/widgets/metadata_panel.py +87 -1
- scpi_control/report_generator/widgets/pdf_preview_dialog.py +5 -2
- scpi_control/report_generator/widgets/template_manager_dialog.py +29 -0
- scpi_control/scpi_commands.py +632 -96
- scpi_control/trigger.py +75 -32
- scpi_control/waveform.py +61 -208
- scpi_control/waveform_schema.py +44 -0
- scpi_control/waveform_transfer.py +370 -0
- {scpi_instrument_control-2.0.0.dist-info → scpi_instrument_control-3.0.0.dist-info}/METADATA +29 -4
- {scpi_instrument_control-2.0.0.dist-info → scpi_instrument_control-3.0.0.dist-info}/RECORD +47 -36
- {scpi_instrument_control-2.0.0.dist-info → scpi_instrument_control-3.0.0.dist-info}/WHEEL +0 -0
- {scpi_instrument_control-2.0.0.dist-info → scpi_instrument_control-3.0.0.dist-info}/entry_points.txt +0 -0
- {scpi_instrument_control-2.0.0.dist-info → scpi_instrument_control-3.0.0.dist-info}/licenses/LICENSE +0 -0
- {scpi_instrument_control-2.0.0.dist-info → scpi_instrument_control-3.0.0.dist-info}/top_level.txt +0 -0
scpi_control/__init__.py
CHANGED
|
@@ -25,7 +25,7 @@ For automated test report generation:
|
|
|
25
25
|
from scpi_control.report_generator import ReportGenerator
|
|
26
26
|
"""
|
|
27
27
|
|
|
28
|
-
__version__ = "
|
|
28
|
+
__version__ = "3.0.0"
|
|
29
29
|
|
|
30
30
|
from scpi_control.exceptions import CommandError, SiglentConnectionError, SiglentError, SiglentTimeoutError
|
|
31
31
|
from scpi_control.oscilloscope import Oscilloscope
|
scpi_control/channel.py
CHANGED
|
@@ -4,7 +4,8 @@ import logging
|
|
|
4
4
|
from typing import TYPE_CHECKING, Literal, Optional
|
|
5
5
|
|
|
6
6
|
from scpi_control import exceptions
|
|
7
|
-
from scpi_control.
|
|
7
|
+
from scpi_control.models import validate_channel
|
|
8
|
+
from scpi_control.scpi_commands import coupling_from_wire, coupling_to_wire, probe_from_wire, probe_to_wire
|
|
8
9
|
|
|
9
10
|
if TYPE_CHECKING:
|
|
10
11
|
from scpi_control.oscilloscope import Oscilloscope
|
|
@@ -31,10 +32,8 @@ class Channel:
|
|
|
31
32
|
"""
|
|
32
33
|
self._scope = oscilloscope
|
|
33
34
|
self._channel = channel_number
|
|
34
|
-
self._prefix = f"C{channel_number}"
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
raise exceptions.InvalidParameterError(f"Invalid channel number: {channel_number}. Must be 1-4.")
|
|
36
|
+
validate_channel(oscilloscope, channel_number)
|
|
38
37
|
|
|
39
38
|
@property
|
|
40
39
|
def _dialect(self) -> str:
|
|
@@ -51,9 +50,10 @@ class Channel:
|
|
|
51
50
|
True if channel is displayed, False otherwise
|
|
52
51
|
"""
|
|
53
52
|
response = self._scope.query(self._cmd("get_channel_display", ch=self._channel))
|
|
54
|
-
# Response format: "C1:TRA ON"
|
|
55
|
-
#
|
|
56
|
-
|
|
53
|
+
# Response format: "C1:TRA ON"/"C1:TRA OFF" (legacy/modern) or a bare
|
|
54
|
+
# "1"/"0" numeric select (tektronix SELect:CH<x>? -- TBS p.144)
|
|
55
|
+
token = response.strip().split()[-1].upper() if response.strip() else ""
|
|
56
|
+
return token in ("ON", "1")
|
|
57
57
|
|
|
58
58
|
@enabled.setter
|
|
59
59
|
def enabled(self, value: bool) -> None:
|
|
@@ -171,13 +171,18 @@ class Channel:
|
|
|
171
171
|
Returns:
|
|
172
172
|
Probe ratio (e.g., 1.0 for 1X, 10.0 for 10X)
|
|
173
173
|
"""
|
|
174
|
+
# Probe commands are family-split on Tek (tek_tbs/tek_mso) and absent
|
|
175
|
+
# from the plain base table; gate before querying so a forced-dialect
|
|
176
|
+
# variant fallback raises cleanly instead of a raw KeyError.
|
|
177
|
+
if not self._scope._has_command("get_probe_ratio"):
|
|
178
|
+
raise exceptions.FeatureNotSupportedError(f"probe ratio is not supported on the {self._dialect} dialect")
|
|
174
179
|
response = self._scope.query(self._cmd("get_probe_ratio", ch=self._channel))
|
|
175
180
|
# Response may include echo like "C1:ATTN 10"
|
|
176
181
|
if ":" in response:
|
|
177
182
|
response = response.split(":", 1)[1]
|
|
178
183
|
if " " in response:
|
|
179
184
|
response = response.split(" ", 1)[1]
|
|
180
|
-
return
|
|
185
|
+
return probe_from_wire(self._dialect, response.strip())
|
|
181
186
|
|
|
182
187
|
@probe_ratio.setter
|
|
183
188
|
def probe_ratio(self, ratio: float) -> None:
|
|
@@ -190,7 +195,14 @@ class Channel:
|
|
|
190
195
|
"""
|
|
191
196
|
if ratio <= 0:
|
|
192
197
|
raise exceptions.InvalidParameterError(f"Probe ratio must be positive: {ratio}")
|
|
193
|
-
self._scope.
|
|
198
|
+
if not self._scope._has_command("set_probe_ratio"):
|
|
199
|
+
raise exceptions.FeatureNotSupportedError(f"probe ratio is not supported on the {self._dialect} dialect")
|
|
200
|
+
if self._dialect == "tektronix":
|
|
201
|
+
# Tek speaks probe attenuation as a gain factor (1/ratio); the
|
|
202
|
+
# tek_tbs/tek_mso family templates use the {gain} placeholder
|
|
203
|
+
self._scope.write(self._cmd("set_probe_ratio", ch=self._channel, gain=probe_to_wire(self._dialect, ratio)))
|
|
204
|
+
else:
|
|
205
|
+
self._scope.write(self._cmd("set_probe_ratio", ch=self._channel, ratio=probe_to_wire(self._dialect, ratio)))
|
|
194
206
|
logger.info(f"Channel {self._channel} probe ratio set to {ratio}X")
|
|
195
207
|
|
|
196
208
|
@property
|
|
@@ -200,9 +212,21 @@ class Channel:
|
|
|
200
212
|
Returns:
|
|
201
213
|
Bandwidth limit: 'ON', 'OFF', or frequency limit
|
|
202
214
|
"""
|
|
215
|
+
if self._dialect == "lecroy":
|
|
216
|
+
# BWL? is global: "C1,OFF,C2,20MHZ,..." pairs (MAUI p.7-18). The
|
|
217
|
+
# real LeCroy <mode> vocabulary is {OFF,20MHZ,200MHZ,...} -- there
|
|
218
|
+
# is no "ON" token, so any non-OFF wire token maps to the public
|
|
219
|
+
# "ON" (mirrors the modern/tektronix ON/OFF normalization below).
|
|
220
|
+
tokens = [t.strip().upper() for t in self._scope.query(self._cmd("get_bandwidth_limit")).split(",")]
|
|
221
|
+
try:
|
|
222
|
+
wire = tokens[tokens.index(f"C{self._channel}") + 1]
|
|
223
|
+
except (ValueError, IndexError):
|
|
224
|
+
return "OFF"
|
|
225
|
+
return "OFF" if wire == "OFF" else "ON"
|
|
203
226
|
response = self._scope.query(self._cmd("get_bandwidth_limit", ch=self._channel)).strip().upper()
|
|
204
|
-
if self._dialect
|
|
205
|
-
# Modern wire tokens are FULL/20M/200M;
|
|
227
|
+
if self._dialect in ("modern", "tektronix"):
|
|
228
|
+
# Modern wire tokens are FULL/20M/200M; Tek's are FULl/TWENty (or
|
|
229
|
+
# a hertz value on MSO2) -- both speak ON/OFF at the public layer
|
|
206
230
|
return "OFF" if response == "FULL" else "ON"
|
|
207
231
|
return response
|
|
208
232
|
|
|
@@ -218,6 +242,21 @@ class Channel:
|
|
|
218
242
|
raise exceptions.InvalidParameterError(f"Invalid bandwidth limit: {limit}. Must be ON, OFF, or FULL.")
|
|
219
243
|
if self._dialect == "modern":
|
|
220
244
|
wire = "FULL" if limit in ("OFF", "FULL") else "20M"
|
|
245
|
+
elif self._dialect == "tektronix":
|
|
246
|
+
if limit in ("OFF", "FULL"):
|
|
247
|
+
wire = "FULL"
|
|
248
|
+
elif getattr(getattr(self._scope, "model_capability", None), "scpi_variant", None) == "tek_mso":
|
|
249
|
+
# MSO 2-Series bandwidth vocabulary is {<NR3>|FULl} -- it has no
|
|
250
|
+
# TWENty keyword, so send 20 MHz as an explicit hertz value
|
|
251
|
+
# (2 Series MSO PM 077-1776-07 p.2-183).
|
|
252
|
+
wire = "20E6"
|
|
253
|
+
else:
|
|
254
|
+
# TBS1000C accepts the TWEnty keyword (TBS PM 077-1691-01 p.53).
|
|
255
|
+
wire = "TWENty"
|
|
256
|
+
elif self._dialect == "lecroy":
|
|
257
|
+
# LeCroy BWL <mode> vocabulary has no "ON" token -- {OFF,20MHZ,
|
|
258
|
+
# 200MHZ,...} (MAUI p.7-18). Map public ON to the 20MHz limit.
|
|
259
|
+
wire = "OFF" if limit in ("OFF", "FULL") else "20MHZ"
|
|
221
260
|
else:
|
|
222
261
|
wire = "OFF" if limit == "FULL" else limit
|
|
223
262
|
self._scope.write(self._cmd("set_bandwidth_limit", ch=self._channel, limit=wire))
|
|
@@ -230,8 +269,9 @@ class Channel:
|
|
|
230
269
|
Returns:
|
|
231
270
|
Unit string (typically 'V' for volts)
|
|
232
271
|
"""
|
|
233
|
-
|
|
234
|
-
|
|
272
|
+
if not self._scope._has_command("get_channel_unit"):
|
|
273
|
+
raise exceptions.FeatureNotSupportedError(f"channel unit is not supported on the {self._dialect} dialect")
|
|
274
|
+
response = self._scope.query(self._cmd("get_channel_unit", ch=self._channel))
|
|
235
275
|
return response.strip()
|
|
236
276
|
|
|
237
277
|
@unit.setter
|
|
@@ -241,7 +281,9 @@ class Channel:
|
|
|
241
281
|
Args:
|
|
242
282
|
unit: Unit string ('V' for volts, 'A' for amps)
|
|
243
283
|
"""
|
|
244
|
-
self._scope.
|
|
284
|
+
if not self._scope._has_command("set_channel_unit"):
|
|
285
|
+
raise exceptions.FeatureNotSupportedError(f"channel unit is not supported on the {self._dialect} dialect")
|
|
286
|
+
self._scope.write(self._cmd("set_channel_unit", ch=self._channel, unit=unit))
|
|
245
287
|
logger.info(f"Channel {self._channel} unit set to {unit}")
|
|
246
288
|
|
|
247
289
|
def auto_scale(self) -> None:
|
|
@@ -261,16 +303,23 @@ class Channel:
|
|
|
261
303
|
Returns:
|
|
262
304
|
Dictionary with all channel settings
|
|
263
305
|
"""
|
|
264
|
-
|
|
306
|
+
config = {
|
|
265
307
|
"channel": self._channel,
|
|
266
308
|
"enabled": self.enabled,
|
|
267
309
|
"coupling": self.coupling,
|
|
268
310
|
"voltage_scale": self.voltage_scale,
|
|
269
311
|
"voltage_offset": self.voltage_offset,
|
|
270
|
-
"probe_ratio": self.probe_ratio,
|
|
271
|
-
"bandwidth_limit": self.bandwidth_limit,
|
|
272
|
-
"unit": self.unit,
|
|
273
312
|
}
|
|
313
|
+
try:
|
|
314
|
+
config["probe_ratio"] = self.probe_ratio
|
|
315
|
+
except exceptions.FeatureNotSupportedError:
|
|
316
|
+
config["probe_ratio"] = None
|
|
317
|
+
config["bandwidth_limit"] = self.bandwidth_limit
|
|
318
|
+
try:
|
|
319
|
+
config["unit"] = self.unit
|
|
320
|
+
except exceptions.FeatureNotSupportedError:
|
|
321
|
+
config["unit"] = None
|
|
322
|
+
return config
|
|
274
323
|
|
|
275
324
|
def __repr__(self) -> str:
|
|
276
325
|
"""String representation."""
|
|
@@ -1,55 +1,21 @@
|
|
|
1
|
-
"""Mock connection
|
|
1
|
+
"""Mock connection shell: constructor/state, connect/disconnect, PSU/AWG/DAQ handling,
|
|
2
|
+
and personality dispatch to the vendor-specific scope write/query/waveform modules."""
|
|
2
3
|
|
|
3
4
|
from __future__ import annotations
|
|
4
5
|
|
|
5
6
|
import re
|
|
6
|
-
from typing import Dict, Iterable, List, Optional,
|
|
7
|
+
from typing import Dict, Iterable, List, Optional, Union
|
|
7
8
|
|
|
8
9
|
from scpi_control import exceptions
|
|
9
10
|
from scpi_control.connection.base import BaseConnection
|
|
11
|
+
from scpi_control.connection.mock.helpers import MOCK_SCREENSHOT_BMP, _build_ieee_block
|
|
12
|
+
from scpi_control.connection.mock import lecroy, siglent, tektronix
|
|
10
13
|
from scpi_control.models import detect_model_from_idn
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
""
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def _format_nr3(value: float) -> str:
|
|
19
|
-
"""Format a bare NR3 numeric value (no unit) as used by modern-dialect queries."""
|
|
20
|
-
return f"{value:.2E}"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def _build_ieee_block(payload: bytes) -> bytes:
|
|
24
|
-
"""Wrap payload in an IEEE-488.2 definite-length block: #<ndigits><length><payload>."""
|
|
25
|
-
length_str = str(len(payload)).encode()
|
|
26
|
-
return b"#" + str(len(length_str)).encode() + length_str + payload
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
# Minimal valid 1x1 24bpp BMP, so a mock SCDP? screenshot decodes as a real image.
|
|
30
|
-
MOCK_SCREENSHOT_BMP = bytes.fromhex("424d3a000000000000003600000028000000010000000100000001001800000000000400000000000000000000000000000000000000ffffff00")
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
# Canonical PAVA? measurement values for the legacy dialect (mirrors real
|
|
34
|
-
# hardware where PAVA? is legacy-only; the modern dialect has no equivalent).
|
|
35
|
-
_MOCK_PAVA_VALUES: Dict[str, Tuple[str, str]] = {
|
|
36
|
-
"PKPK": ("2.000E+00", "V"),
|
|
37
|
-
"MAX": ("1.000E+00", "V"),
|
|
38
|
-
"MIN": ("-1.000E+00", "V"),
|
|
39
|
-
"AMPL": ("2.000E+00", "V"),
|
|
40
|
-
"TOP": ("1.000E+00", "V"),
|
|
41
|
-
"BASE": ("-1.000E+00", "V"),
|
|
42
|
-
"CMEAN": ("0.000E+00", "V"),
|
|
43
|
-
"MEAN": ("0.000E+00", "V"),
|
|
44
|
-
"RMS": ("7.070E-01", "V"),
|
|
45
|
-
"CRMS": ("7.070E-01", "V"),
|
|
46
|
-
"FREQ": ("1.000E+03", "HZ"),
|
|
47
|
-
"PER": ("1.000E-03", "S"),
|
|
48
|
-
"RISE": ("3.500E-05", "S"),
|
|
49
|
-
"FALL": ("3.500E-05", "S"),
|
|
50
|
-
"WID": ("5.000E-04", "S"),
|
|
51
|
-
"NWID": ("5.000E-04", "S"),
|
|
52
|
-
"DUTY": ("5.000E+01", "%"),
|
|
15
|
+
_PERSONALITIES = {
|
|
16
|
+
"siglent": siglent,
|
|
17
|
+
"tektronix": tektronix,
|
|
18
|
+
"lecroy": lecroy,
|
|
53
19
|
}
|
|
54
20
|
|
|
55
21
|
|
|
@@ -88,13 +54,16 @@ class MockConnection(BaseConnection):
|
|
|
88
54
|
daq_mode: bool = False,
|
|
89
55
|
daq_idn: str = "Keysight Technologies,34970A,MY12345678,A.01.02",
|
|
90
56
|
daq_readings: str = "1.234,2.345,3.456",
|
|
57
|
+
tek_badges: Optional[Dict[int, Dict[str, str]]] = None,
|
|
91
58
|
):
|
|
92
59
|
super().__init__(host, port, timeout)
|
|
93
60
|
channels = channel_states.keys() if channel_states else range(1, 3)
|
|
94
61
|
|
|
95
62
|
self.idn = idn
|
|
96
63
|
# Personality: answer only the wire dialect this model actually speaks
|
|
97
|
-
|
|
64
|
+
capability = detect_model_from_idn(idn)
|
|
65
|
+
self.scope_dialect = capability.dialect
|
|
66
|
+
self.scope_vendor = capability.vendor
|
|
98
67
|
self._channel_enabled: Dict[int, bool] = {ch: channel_states.get(ch, True) if channel_states else True for ch in channels}
|
|
99
68
|
self._voltage_scales: Dict[int, float] = {ch: voltage_scales.get(ch, 1.0) if voltage_scales else 1.0 for ch in channels}
|
|
100
69
|
self._voltage_offsets: Dict[int, float] = {ch: voltage_offsets.get(ch, 0.0) if voltage_offsets else 0.0 for ch in channels}
|
|
@@ -116,6 +85,25 @@ class MockConnection(BaseConnection):
|
|
|
116
85
|
self.trigger_level: Dict[int, float] = {ch: 0.0 for ch in channels}
|
|
117
86
|
self.trigger_status: List[str] = trigger_status[:] if trigger_status else ["Stop"]
|
|
118
87
|
|
|
88
|
+
# Tektronix wire-vocabulary state (shared across tek_tbs/tek_mso variants)
|
|
89
|
+
self.tek_stop_after = "RUNSTOP"
|
|
90
|
+
self.data_source: int = 1
|
|
91
|
+
self.probe_gains: Dict[int, float] = {ch: 0.1 for ch in channels}
|
|
92
|
+
self.holdoff_time = 0.0
|
|
93
|
+
# Measurement badges: slot -> {"type": ..., "source": ...}. Seed via
|
|
94
|
+
# tek_badges to model badges a user created on the instrument.
|
|
95
|
+
self.badges: Dict[int, Dict[str, str]] = {n: dict(cfg) for n, cfg in (tek_badges or {}).items()}
|
|
96
|
+
if self.scope_vendor == "tektronix":
|
|
97
|
+
# Tek vocabulary differs from both Siglent dialects (guide TEKTRONIX_COMMANDS table)
|
|
98
|
+
self.trigger_mode = "AUTO"
|
|
99
|
+
self.trigger_slope = "RISE"
|
|
100
|
+
self.trigger_source = "CH1"
|
|
101
|
+
self._channel_coupling = {ch: "DC" for ch in channels}
|
|
102
|
+
if not trigger_status:
|
|
103
|
+
# "SAVE" is the Tek TRIGger:STATE? token for stopped; the shared
|
|
104
|
+
# default ["Stop"] is Siglent vocabulary and not a valid Tek state.
|
|
105
|
+
self.trigger_status = ["SAVE"]
|
|
106
|
+
|
|
119
107
|
self.custom_responses = custom_responses or {}
|
|
120
108
|
self.writes: List[str] = []
|
|
121
109
|
self.queries: List[str] = []
|
|
@@ -360,102 +348,18 @@ class MockConnection(BaseConnection):
|
|
|
360
348
|
self.awg_channels[ch]["enabled"] = enabled
|
|
361
349
|
return
|
|
362
350
|
|
|
363
|
-
# Oscilloscope commands
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if match := re.match(r":CHANnel(\d+):SCALe\s+(.+)", command, re.IGNORECASE):
|
|
369
|
-
ch = int(match.group(1))
|
|
370
|
-
self._voltage_scales[ch] = float(match.group(2))
|
|
371
|
-
self.scale_updates.setdefault(ch, []).append(float(match.group(2)))
|
|
372
|
-
return
|
|
373
|
-
if match := re.match(r":CHANnel(\d+):OFFSet\s+(.+)", command, re.IGNORECASE):
|
|
374
|
-
self._voltage_offsets[int(match.group(1))] = float(match.group(2))
|
|
375
|
-
return
|
|
376
|
-
if match := re.match(r":CHANnel(\d+):COUPling\s+(\w+)", command, re.IGNORECASE):
|
|
377
|
-
self._channel_coupling[int(match.group(1))] = match.group(2).upper()
|
|
378
|
-
return
|
|
379
|
-
if match := re.match(r":TIMebase:SCALe\s+(.+)", command, re.IGNORECASE):
|
|
380
|
-
self.timebase = float(match.group(1))
|
|
381
|
-
self.timebase_updates.append(self.timebase)
|
|
382
|
-
return
|
|
383
|
-
if match := re.match(r":TRIGger:MODE\s+(\w+)", command, re.IGNORECASE):
|
|
384
|
-
self.trigger_mode = match.group(1) # stored as wire token, e.g. "NORMal" (guide p.482)
|
|
385
|
-
if match.group(1).upper() == "SINGLE" and len(self.trigger_status) <= 1:
|
|
386
|
-
# Status vocabulary matches real hardware: Ready while armed, Stop when done (same rule as the legacy ARM handler)
|
|
387
|
-
self.trigger_status = ["Ready", "Stop"]
|
|
388
|
-
return
|
|
389
|
-
if re.match(r":TRIGger:RUN$", command, re.IGNORECASE):
|
|
390
|
-
self.trigger_mode = "AUTO"
|
|
391
|
-
return
|
|
392
|
-
if re.match(r":TRIGger:STOP$", command, re.IGNORECASE):
|
|
393
|
-
if len(self.trigger_status) <= 1:
|
|
394
|
-
self.trigger_status = ["Stop"]
|
|
395
|
-
return
|
|
396
|
-
if match := re.match(r":TRIGger:TYPE\s+(\w+)", command, re.IGNORECASE):
|
|
397
|
-
self.trigger_type = match.group(1).upper()
|
|
398
|
-
return
|
|
399
|
-
if match := re.match(r":TRIGger:EDGE:SOURce\s+(\w+)", command, re.IGNORECASE):
|
|
400
|
-
self.trigger_source = match.group(1).upper()
|
|
401
|
-
return
|
|
402
|
-
if match := re.match(r":TRIGger:EDGE:LEVel\s+(.+)", command, re.IGNORECASE):
|
|
403
|
-
self.trigger_level[1] = float(match.group(1))
|
|
404
|
-
return
|
|
405
|
-
if match := re.match(r":TRIGger:EDGE:SLOPe\s+(\w+)", command, re.IGNORECASE):
|
|
406
|
-
self.trigger_slope = match.group(1) # wire token, e.g. "RISing" (guide p.494)
|
|
407
|
-
return
|
|
408
|
-
if match := re.match(r":TRIGger:EDGE:COUPling\s+(\w+)", command, re.IGNORECASE):
|
|
409
|
-
self.trigger_coupling = match.group(1)
|
|
410
|
-
return
|
|
411
|
-
# Unknown modern writes fall through and are merely recorded,
|
|
412
|
-
# mirroring real scopes which silently drop unknown commands
|
|
413
|
-
|
|
414
|
-
if command.upper().startswith("TDIV "):
|
|
415
|
-
value = command.split(" ", 1)[1]
|
|
416
|
-
try:
|
|
417
|
-
self.timebase = float(value)
|
|
418
|
-
except ValueError:
|
|
419
|
-
self.timebase = self.timebase
|
|
420
|
-
self.timebase_updates.append(self.timebase)
|
|
421
|
-
elif match := re.match(r"C(\d+):VDIV\s+(.+)", command, re.IGNORECASE):
|
|
422
|
-
channel = int(match.group(1))
|
|
423
|
-
value = float(match.group(2))
|
|
424
|
-
self._voltage_scales[channel] = value
|
|
425
|
-
self.scale_updates.setdefault(channel, []).append(value)
|
|
426
|
-
elif match := re.match(r"C(\d+):OFST\s+(.+)", command, re.IGNORECASE):
|
|
427
|
-
channel = int(match.group(1))
|
|
428
|
-
value = float(match.group(2))
|
|
429
|
-
self._voltage_offsets[channel] = value
|
|
430
|
-
elif match := re.match(r"C(\d+):TRA\s+(ON|OFF)", command, re.IGNORECASE):
|
|
431
|
-
channel = int(match.group(1))
|
|
432
|
-
self._channel_enabled[channel] = match.group(2).upper() == "ON"
|
|
433
|
-
elif match := re.match(r"C(\d+):CPL\s+(\w+)", command, re.IGNORECASE):
|
|
434
|
-
self._channel_coupling[int(match.group(1))] = match.group(2).upper()
|
|
435
|
-
elif command.upper().startswith("TRIG_MODE "):
|
|
436
|
-
self.trigger_mode = command.split(" ", 1)[1].upper()
|
|
437
|
-
elif command.upper().startswith("TRIG_SELECT "):
|
|
438
|
-
_, params = command.split(" ", 1)
|
|
439
|
-
trig_type, _, source = params.split(",")
|
|
440
|
-
self.trigger_type = trig_type.strip().upper()
|
|
441
|
-
self.trigger_source = source.strip().upper()
|
|
442
|
-
elif match := re.match(r"C(\d+):TRSL\s+(\w+)", command, re.IGNORECASE):
|
|
443
|
-
self.trigger_slope = match.group(2).upper()
|
|
444
|
-
elif match := re.match(r"C(\d+):TRCP\s+(\w+)", command, re.IGNORECASE):
|
|
445
|
-
self.trigger_coupling = match.group(2).upper()
|
|
446
|
-
elif command.upper() == "ARM":
|
|
447
|
-
# Simulate an acquisition that will eventually stop when no custom sequence is provided.
|
|
448
|
-
# Status vocabulary matches real hardware: Ready while armed, Stop when done.
|
|
449
|
-
if len(self.trigger_status) <= 1:
|
|
450
|
-
self.trigger_status = ["Ready", "Stop"]
|
|
451
|
-
elif match := re.match(r"C(\d+):TRLV\s+(.+)", command, re.IGNORECASE):
|
|
452
|
-
channel = int(match.group(1))
|
|
453
|
-
self.trigger_level[channel] = float(match.group(2))
|
|
454
|
-
elif match := re.match(r"C(\d+):WF\?", command, re.IGNORECASE):
|
|
351
|
+
# Oscilloscope commands: C{n}:WF? channel recording is shared across every
|
|
352
|
+
# scope dialect/vendor (Tek's CURVe? uses data_source instead - Task 9), so
|
|
353
|
+
# it is recorded here before personality dispatch rather than inside a
|
|
354
|
+
# single vendor module's write handler.
|
|
355
|
+
if match := re.match(r"C(\d+):WF\?", command, re.IGNORECASE):
|
|
455
356
|
channel = int(match.group(1))
|
|
456
357
|
self._last_waveform_channel = channel
|
|
457
358
|
self.waveform_requests.append(channel)
|
|
458
359
|
|
|
360
|
+
personality = _PERSONALITIES.get(self.scope_vendor, siglent)
|
|
361
|
+
personality.handle_write(self, command)
|
|
362
|
+
|
|
459
363
|
def read(self) -> str:
|
|
460
364
|
"""Return an empty response for completeness."""
|
|
461
365
|
if not self._connected:
|
|
@@ -693,88 +597,10 @@ class MockConnection(BaseConnection):
|
|
|
693
597
|
return "CV"
|
|
694
598
|
return "CV"
|
|
695
599
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
return self.trigger_status[0]
|
|
701
|
-
if upper == ":TRIGGER:MODE?": # mixed-case wire token, e.g. "NORMal", p.482
|
|
702
|
-
return self.trigger_mode
|
|
703
|
-
if upper == ":TRIGGER:TYPE?":
|
|
704
|
-
return self.trigger_type
|
|
705
|
-
if upper == ":TRIGGER:EDGE:SOURCE?": # bare token, p.495
|
|
706
|
-
return self.trigger_source
|
|
707
|
-
if upper == ":TRIGGER:EDGE:SLOPE?": # wire token e.g. "RISing", p.494
|
|
708
|
-
return self.trigger_slope
|
|
709
|
-
if upper == ":TRIGGER:EDGE:COUPLING?":
|
|
710
|
-
return self.trigger_coupling
|
|
711
|
-
if upper == ":TRIGGER:EDGE:LEVEL?": # bare NR3, p.492
|
|
712
|
-
return _format_nr3(self.trigger_level.get(1, 0.0))
|
|
713
|
-
if match := re.match(r":CHANNEL(\d+):SWITCH\?", upper):
|
|
714
|
-
return "ON" if self._channel_enabled.get(int(match.group(1)), True) else "OFF"
|
|
715
|
-
if match := re.match(r":CHANNEL(\d+):SCALE\?", upper): # bare NR3, p.58
|
|
716
|
-
return _format_nr3(self._voltage_scales.get(int(match.group(1)), 1.0))
|
|
717
|
-
if match := re.match(r":CHANNEL(\d+):OFFSET\?", upper): # bare NR3, p.56
|
|
718
|
-
return _format_nr3(self._voltage_offsets.get(int(match.group(1)), 0.0))
|
|
719
|
-
if match := re.match(r":CHANNEL(\d+):COUPLING\?", upper): # DC|AC|GND, p.51
|
|
720
|
-
return self._channel_coupling.get(int(match.group(1)), "DC")
|
|
721
|
-
if upper == ":TIMEBASE:SCALE?": # bare NR3, p.476
|
|
722
|
-
return _format_nr3(self.timebase)
|
|
723
|
-
if upper == ":ACQUIRE:SRATE?": # bare NR3, p.46
|
|
724
|
-
return _format_nr3(self.sample_rate)
|
|
725
|
-
|
|
726
|
-
if self.scope_dialect == "legacy":
|
|
727
|
-
if match := re.match(r"C(\d+):VDIV\?", command, re.IGNORECASE):
|
|
728
|
-
channel = int(match.group(1))
|
|
729
|
-
value = self._voltage_scales.get(channel, 1.0)
|
|
730
|
-
return _format_scientific(value, "V")
|
|
731
|
-
|
|
732
|
-
if match := re.match(r"C(\d+):OFST\?", command, re.IGNORECASE):
|
|
733
|
-
channel = int(match.group(1))
|
|
734
|
-
value = self._voltage_offsets.get(channel, 0.0)
|
|
735
|
-
return _format_scientific(value, "V")
|
|
736
|
-
|
|
737
|
-
if match := re.match(r"C(\d+):TRA\?", command, re.IGNORECASE):
|
|
738
|
-
channel = int(match.group(1))
|
|
739
|
-
return "ON" if self._channel_enabled.get(channel, True) else "OFF"
|
|
740
|
-
|
|
741
|
-
if match := re.match(r"C(\d+):CPL\?", command, re.IGNORECASE):
|
|
742
|
-
return self._channel_coupling.get(int(match.group(1)), "D1M")
|
|
743
|
-
|
|
744
|
-
if match := re.match(r"C(\d+):TRLV\?", command, re.IGNORECASE):
|
|
745
|
-
channel = int(match.group(1))
|
|
746
|
-
return _format_scientific(self.trigger_level.get(channel, 0.0), "V")
|
|
747
|
-
|
|
748
|
-
if re.match(r"C(\d+):TRSL\?", command, re.IGNORECASE):
|
|
749
|
-
return self.trigger_slope
|
|
750
|
-
|
|
751
|
-
if re.match(r"C(\d+):TRCP\?", command, re.IGNORECASE):
|
|
752
|
-
return self.trigger_coupling
|
|
753
|
-
|
|
754
|
-
if upper == "TDIV?":
|
|
755
|
-
return _format_scientific(self.timebase, "S")
|
|
756
|
-
|
|
757
|
-
if upper == "SARA?":
|
|
758
|
-
return _format_scientific(self.sample_rate, "Sa/s")
|
|
759
|
-
|
|
760
|
-
if upper == "SAST?":
|
|
761
|
-
if len(self.trigger_status) > 1:
|
|
762
|
-
return self.trigger_status.pop(0)
|
|
763
|
-
return self.trigger_status[0]
|
|
764
|
-
|
|
765
|
-
if upper == "TRIG_MODE?":
|
|
766
|
-
return self.trigger_mode
|
|
767
|
-
|
|
768
|
-
if upper == "TRIG_SELECT?":
|
|
769
|
-
return f"{self.trigger_type},SR,{self.trigger_source}"
|
|
770
|
-
|
|
771
|
-
if match := re.match(r"PAVA\?\s*(\w+)\s*,\s*C?(\d+)", command, re.IGNORECASE):
|
|
772
|
-
mtype = match.group(1).upper()
|
|
773
|
-
channel = match.group(2)
|
|
774
|
-
entry = _MOCK_PAVA_VALUES.get(mtype)
|
|
775
|
-
if entry is not None:
|
|
776
|
-
value, unit = entry
|
|
777
|
-
return f"PAVA {mtype},C{channel},{value}{unit}"
|
|
600
|
+
personality = _PERSONALITIES.get(self.scope_vendor, siglent)
|
|
601
|
+
response = personality.handle_query(self, command)
|
|
602
|
+
if response is not None:
|
|
603
|
+
return response
|
|
778
604
|
|
|
779
605
|
if self.psu_mode or self.awg_mode or self.daq_mode:
|
|
780
606
|
return ""
|
|
@@ -787,10 +613,6 @@ class MockConnection(BaseConnection):
|
|
|
787
613
|
"""Convenience helper to query multiple commands sequentially."""
|
|
788
614
|
return [self.query(cmd) for cmd in commands]
|
|
789
615
|
|
|
790
|
-
def _build_waveform_block(self, payload: bytes) -> bytes:
|
|
791
|
-
"""Construct a minimal Siglent-style block response."""
|
|
792
|
-
return b"DESC," + _build_ieee_block(payload)
|
|
793
|
-
|
|
794
616
|
def read_raw(self, size: Optional[int] = None) -> bytes:
|
|
795
617
|
"""Return deterministic raw waveform data (or a mock screenshot BMP after SCDP?)."""
|
|
796
618
|
if not self._connected:
|
|
@@ -801,6 +623,5 @@ class MockConnection(BaseConnection):
|
|
|
801
623
|
# scope's SCDP? reply is parsed in screen_capture.py.
|
|
802
624
|
return _build_ieee_block(MOCK_SCREENSHOT_BMP)
|
|
803
625
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
return self._build_waveform_block(payload)
|
|
626
|
+
personality = _PERSONALITIES.get(self.scope_vendor, siglent)
|
|
627
|
+
return personality.build_waveform_response(self)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Vendor-neutral formatting helpers shared by the mock connection shell and every
|
|
2
|
+
personality module. Lives separately so personality modules never need to import
|
|
3
|
+
from `base` (which imports these helpers), avoiding an import cycle."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _format_scientific(value: float, unit: str) -> str:
|
|
9
|
+
"""Format a numeric value with a unit using Siglent-style scientific notation."""
|
|
10
|
+
return f"{value:.2E}{unit}"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _format_nr3(value: float) -> str:
|
|
14
|
+
"""Format a bare NR3 numeric value (no unit) as used by modern-dialect queries."""
|
|
15
|
+
return f"{value:.2E}"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _build_ieee_block(payload: bytes) -> bytes:
|
|
19
|
+
"""Wrap payload in an IEEE-488.2 definite-length block: #<ndigits><length><payload>."""
|
|
20
|
+
length_str = str(len(payload)).encode()
|
|
21
|
+
return b"#" + str(len(length_str)).encode() + length_str + payload
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Minimal valid 1x1 24bpp BMP, so a mock SCDP? screenshot decodes as a real image.
|
|
25
|
+
MOCK_SCREENSHOT_BMP = bytes.fromhex("424d3a000000000000003600000028000000010000000100000001001800000000000400000000000000000000000000000000000000ffffff00")
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""LeCroy MAUI mock personality.
|
|
2
|
+
|
|
3
|
+
Writes reuse the Siglent legacy handler (Siglent copied LeCroy's command set);
|
|
4
|
+
queries answer in CHDR OFF format: bare values, no unit suffixes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
import struct
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from scpi_control.connection.mock.helpers import _build_ieee_block, _format_nr3
|
|
12
|
+
from scpi_control.connection.mock.siglent import _MOCK_PAVA_VALUES, handle_write as _legacy_write
|
|
13
|
+
|
|
14
|
+
_INR_MAP = {"TRIG'D": "1"} # INR bit 0: new signal acquired
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def handle_write(conn, command: str) -> bool:
|
|
18
|
+
upper = command.strip().upper()
|
|
19
|
+
if upper.startswith("CFMT ") or upper.startswith("CORD ") or upper == "CHDR OFF":
|
|
20
|
+
return True
|
|
21
|
+
if upper == "STOP":
|
|
22
|
+
conn.trigger_mode = "STOP" # TRMD? reports STOP after a STOP command
|
|
23
|
+
return True
|
|
24
|
+
if upper.startswith("BWL "):
|
|
25
|
+
# Legacy chain has no BWL handler; record as a no-op write explicitly
|
|
26
|
+
# so the fidelity contract (write returns True => consumed) holds.
|
|
27
|
+
return True
|
|
28
|
+
# The legacy chain understands TDIV/VDIV/TRA/CPL/TRIG_* — LeCroy originals.
|
|
29
|
+
# Its modern branch gates on scope_dialect == "modern", which is False here.
|
|
30
|
+
return _legacy_write(conn, command)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def handle_query(conn, command: str) -> Optional[str]:
|
|
34
|
+
upper = command.strip().upper()
|
|
35
|
+
|
|
36
|
+
if match := re.match(r"C(\d+):VDIV\?", upper):
|
|
37
|
+
return _format_nr3(conn._voltage_scales.get(int(match.group(1)), 1.0))
|
|
38
|
+
if match := re.match(r"C(\d+):OFST\?", upper):
|
|
39
|
+
return _format_nr3(conn._voltage_offsets.get(int(match.group(1)), 0.0))
|
|
40
|
+
if match := re.match(r"C(\d+):TRA\?", upper):
|
|
41
|
+
return "ON" if conn._channel_enabled.get(int(match.group(1)), True) else "OFF"
|
|
42
|
+
if match := re.match(r"C(\d+):CPL\?", upper):
|
|
43
|
+
return conn._channel_coupling.get(int(match.group(1)), "D1M")
|
|
44
|
+
if match := re.match(r"C(\d+):TRLV\?", upper):
|
|
45
|
+
return _format_nr3(conn.trigger_level.get(int(match.group(1)), 0.0))
|
|
46
|
+
if re.match(r"C(\d+):TRSL\?", upper):
|
|
47
|
+
return conn.trigger_slope
|
|
48
|
+
if re.match(r"C(\d+):TRCP\?", upper):
|
|
49
|
+
return conn.trigger_coupling
|
|
50
|
+
if match := re.match(r"C(\d+):ATTN\?", upper):
|
|
51
|
+
return "10"
|
|
52
|
+
if upper == "TDIV?":
|
|
53
|
+
return _format_nr3(conn.timebase)
|
|
54
|
+
if upper == "TRIG_MODE?" or upper == "TRMD?":
|
|
55
|
+
return conn.trigger_mode
|
|
56
|
+
if upper == "TRIG_SELECT?" or upper == "TRSE?":
|
|
57
|
+
return f"{conn.trigger_type},SR,{conn.trigger_source}"
|
|
58
|
+
if upper == "INR?":
|
|
59
|
+
if len(conn.trigger_status) > 1:
|
|
60
|
+
return _INR_MAP.get(conn.trigger_status.pop(0).upper(), "0")
|
|
61
|
+
return _INR_MAP.get(conn.trigger_status[0].upper(), "0")
|
|
62
|
+
if upper == "BWL?":
|
|
63
|
+
return ",".join(f"C{ch},OFF" for ch in sorted(conn._channel_enabled))
|
|
64
|
+
if upper.startswith("VBS? 'RETURN=APP.ACQUISITION.HORIZONTAL.SAMPLINGRATE'"):
|
|
65
|
+
return _format_nr3(conn.sample_rate)
|
|
66
|
+
if match := re.match(r"C(\d+):PAVA\?\s*(\w+)", upper):
|
|
67
|
+
mtype = match.group(2)
|
|
68
|
+
entry = _MOCK_PAVA_VALUES.get(mtype)
|
|
69
|
+
if entry is not None:
|
|
70
|
+
value, _unit = entry
|
|
71
|
+
# CHDR OFF suppresses units on LeCroy; native shape is <param>,<value>,<state>
|
|
72
|
+
return f"{mtype},{value},OK"
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_waveform_response(conn) -> bytes:
|
|
77
|
+
"""Construct a LeCroy WAVEDESC + sample-array block (WF? ALL, CORD LO)."""
|
|
78
|
+
channel = conn._last_waveform_channel or next(iter(conn._waveform_payloads))
|
|
79
|
+
codes = conn._waveform_payloads.get(channel, bytes())
|
|
80
|
+
gain = conn._voltage_scales.get(channel, 1.0) / 25.0 # mirror Siglent scaling for comparable volts
|
|
81
|
+
desc = bytearray(346)
|
|
82
|
+
desc[0:8] = b"WAVEDESC"
|
|
83
|
+
struct.pack_into("<h", desc, 32, 0)
|
|
84
|
+
struct.pack_into("<i", desc, 36, 346)
|
|
85
|
+
struct.pack_into("<i", desc, 40, 0)
|
|
86
|
+
# TRIGTIME_ARRAY (offset 48) and RIS_TIME_ARRAY (offset 52) lengths: 0 for
|
|
87
|
+
# this single-shot mock (the bytearray is already zero-filled; packed
|
|
88
|
+
# explicitly to document the layout parse_wavedesc now skips).
|
|
89
|
+
struct.pack_into("<i", desc, 48, 0)
|
|
90
|
+
struct.pack_into("<i", desc, 52, 0)
|
|
91
|
+
struct.pack_into("<i", desc, 116, len(codes))
|
|
92
|
+
struct.pack_into("<f", desc, 156, gain)
|
|
93
|
+
struct.pack_into("<f", desc, 160, conn._voltage_offsets.get(channel, 0.0))
|
|
94
|
+
struct.pack_into("<f", desc, 176, 1.0 / conn.sample_rate)
|
|
95
|
+
struct.pack_into("<d", desc, 180, 0.0)
|
|
96
|
+
return _build_ieee_block(bytes(desc) + codes)
|