rf-bench 0.2.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.
- rf_bench/__init__.py +138 -0
- rf_bench/icom/__init__.py +5 -0
- rf_bench/icom/ic7300.py +213 -0
- rf_bench/siglent/__init__.py +23 -0
- rf_bench/siglent/sdg1000x.py +220 -0
- rf_bench/siglent/sdm3000x.py +400 -0
- rf_bench/siglent/sds2000x.py +295 -0
- rf_bench/siglent/spd3303x.py +386 -0
- rf_bench/siglent/ssa3000x.py +231 -0
- rf_bench/utils/__init__.py +121 -0
- rf_bench/utils/rf_utils.py +958 -0
- rf_bench/yaesu/__init__.py +5 -0
- rf_bench/yaesu/ft891.py +305 -0
- rf_bench-0.2.0.dist-info/METADATA +379 -0
- rf_bench-0.2.0.dist-info/RECORD +18 -0
- rf_bench-0.2.0.dist-info/WHEEL +5 -0
- rf_bench-0.2.0.dist-info/licenses/LICENSE +674 -0
- rf_bench-0.2.0.dist-info/top_level.txt +1 -0
rf_bench/__init__.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
rf_bench — Python drivers and RF utilities for bench instrument automation
|
|
3
|
+
|
|
4
|
+
Drivers connect via raw TCP/SCPI (Siglent instruments, port 5025) or Hamlib
|
|
5
|
+
rigctld (radios, port 4532). No NI-VISA or pyvisa required.
|
|
6
|
+
|
|
7
|
+
Subpackages::
|
|
8
|
+
|
|
9
|
+
rf_bench.siglent — SSA3000X, SDG1000X, SDS2000X, SDM3000X, SPD3303X
|
|
10
|
+
rf_bench.icom — IC7300
|
|
11
|
+
rf_bench.yaesu — FT891
|
|
12
|
+
rf_bench.utils — RF math: conversions, noise, impedance, propagation,
|
|
13
|
+
passive components, attenuators, IM products, S-meter
|
|
14
|
+
|
|
15
|
+
All public symbols are re-exported here for convenience::
|
|
16
|
+
|
|
17
|
+
from rf_bench import SDG1000X, SPD3303X, IC7300, FT891, dbm_to_vpp, format_freq
|
|
18
|
+
|
|
19
|
+
Or import from subpackages directly::
|
|
20
|
+
|
|
21
|
+
from rf_bench.siglent import SSA3000X, SDG1000X, SDM3000X, SPD3303X
|
|
22
|
+
from rf_bench.icom import IC7300
|
|
23
|
+
from rf_bench.utils import thermal_noise_floor, cascaded_noise_figure, pi_attenuator
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
# Instrument classes
|
|
27
|
+
from .siglent import (
|
|
28
|
+
SSA3000X, SDG1000X, SDS2000X, DBM_MIN, DBM_MAX,
|
|
29
|
+
SDM3000X, SDM_RANGE_AUTO,
|
|
30
|
+
SPD3303X, TRACKING_INDEPENDENT, TRACKING_SERIES, TRACKING_PARALLEL,
|
|
31
|
+
)
|
|
32
|
+
from .icom import IC7300
|
|
33
|
+
from .yaesu import FT891, PREAMP_OFF, PREAMP_AMP1
|
|
34
|
+
|
|
35
|
+
# RF utilities — re-exported flat for convenience
|
|
36
|
+
from .utils import (
|
|
37
|
+
# Constants
|
|
38
|
+
SPEED_OF_LIGHT, S9_HF_DBM, S9_VHF_DBM,
|
|
39
|
+
# Power / voltage
|
|
40
|
+
dbm_to_vpp, vpp_to_dbm,
|
|
41
|
+
dbm_to_vrms, vrms_to_dbm,
|
|
42
|
+
dbm_to_watts, watts_to_dbm,
|
|
43
|
+
dbm_to_uv, uv_to_dbm,
|
|
44
|
+
# Power ratio / extended dB
|
|
45
|
+
db_to_linear, linear_to_db,
|
|
46
|
+
db_to_voltage_ratio, voltage_ratio_to_db,
|
|
47
|
+
dbm_to_dbw, dbw_to_dbm,
|
|
48
|
+
dbm_to_dbuv, dbuv_to_dbm,
|
|
49
|
+
# Impedance / reflection
|
|
50
|
+
rl_to_vswr, vswr_to_rl,
|
|
51
|
+
gamma_to_vswr, vswr_to_gamma,
|
|
52
|
+
rl_to_gamma, gamma_to_rl,
|
|
53
|
+
rl_to_vswr_v, vswr_to_rl_v, gamma_to_vswr_v,
|
|
54
|
+
# Noise and dynamic range
|
|
55
|
+
thermal_noise_floor,
|
|
56
|
+
noise_figure_from_mds, mds_from_noise_figure,
|
|
57
|
+
ip3_from_imd, ip3_to_dynamic_range,
|
|
58
|
+
cascaded_noise_figure,
|
|
59
|
+
noise_temp_to_nf, nf_to_noise_temp,
|
|
60
|
+
# Propagation / antenna
|
|
61
|
+
wavelength, quarter_wave, half_wave,
|
|
62
|
+
freespace_path_loss,
|
|
63
|
+
# Passive components
|
|
64
|
+
capacitive_reactance, inductive_reactance,
|
|
65
|
+
lc_resonant_freq, l_from_resonant, c_from_resonant,
|
|
66
|
+
q_factor, bw_from_q,
|
|
67
|
+
parallel_resistance, voltage_divider,
|
|
68
|
+
skin_depth,
|
|
69
|
+
# Attenuator design
|
|
70
|
+
pi_attenuator, t_attenuator,
|
|
71
|
+
# IM products
|
|
72
|
+
intermod_products,
|
|
73
|
+
# S-meter
|
|
74
|
+
s_unit_to_dbm, dbm_to_s_unit,
|
|
75
|
+
# Formatting
|
|
76
|
+
format_freq, format_freq_short,
|
|
77
|
+
# Standard value series
|
|
78
|
+
nearest_rbw, nearest_value,
|
|
79
|
+
SIGLENT_RBW_SERIES, E12_SERIES, E24_SERIES, E48_SERIES, E96_SERIES,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
__version__ = "0.2.0"
|
|
83
|
+
|
|
84
|
+
__all__ = [
|
|
85
|
+
# Siglent instruments
|
|
86
|
+
"SSA3000X",
|
|
87
|
+
"SDG1000X", "DBM_MIN", "DBM_MAX",
|
|
88
|
+
"SDS2000X",
|
|
89
|
+
"SDM3000X", "SDM_RANGE_AUTO",
|
|
90
|
+
"SPD3303X", "TRACKING_INDEPENDENT", "TRACKING_SERIES", "TRACKING_PARALLEL",
|
|
91
|
+
# Icom
|
|
92
|
+
"IC7300",
|
|
93
|
+
# Yaesu
|
|
94
|
+
"FT891", "PREAMP_OFF", "PREAMP_AMP1",
|
|
95
|
+
# Constants
|
|
96
|
+
"SPEED_OF_LIGHT", "S9_HF_DBM", "S9_VHF_DBM",
|
|
97
|
+
# Power / voltage
|
|
98
|
+
"dbm_to_vpp", "vpp_to_dbm",
|
|
99
|
+
"dbm_to_vrms", "vrms_to_dbm",
|
|
100
|
+
"dbm_to_watts", "watts_to_dbm",
|
|
101
|
+
"dbm_to_uv", "uv_to_dbm",
|
|
102
|
+
# Power ratio / extended dB
|
|
103
|
+
"db_to_linear", "linear_to_db",
|
|
104
|
+
"db_to_voltage_ratio", "voltage_ratio_to_db",
|
|
105
|
+
"dbm_to_dbw", "dbw_to_dbm",
|
|
106
|
+
"dbm_to_dbuv", "dbuv_to_dbm",
|
|
107
|
+
# Impedance / reflection
|
|
108
|
+
"rl_to_vswr", "vswr_to_rl",
|
|
109
|
+
"gamma_to_vswr", "vswr_to_gamma",
|
|
110
|
+
"rl_to_gamma", "gamma_to_rl",
|
|
111
|
+
"rl_to_vswr_v", "vswr_to_rl_v", "gamma_to_vswr_v",
|
|
112
|
+
# Noise and dynamic range
|
|
113
|
+
"thermal_noise_floor",
|
|
114
|
+
"noise_figure_from_mds", "mds_from_noise_figure",
|
|
115
|
+
"ip3_from_imd", "ip3_to_dynamic_range",
|
|
116
|
+
"cascaded_noise_figure",
|
|
117
|
+
"noise_temp_to_nf", "nf_to_noise_temp",
|
|
118
|
+
# Propagation / antenna
|
|
119
|
+
"wavelength", "quarter_wave", "half_wave",
|
|
120
|
+
"freespace_path_loss",
|
|
121
|
+
# Passive components
|
|
122
|
+
"capacitive_reactance", "inductive_reactance",
|
|
123
|
+
"lc_resonant_freq", "l_from_resonant", "c_from_resonant",
|
|
124
|
+
"q_factor", "bw_from_q",
|
|
125
|
+
"parallel_resistance", "voltage_divider",
|
|
126
|
+
"skin_depth",
|
|
127
|
+
# Attenuator design
|
|
128
|
+
"pi_attenuator", "t_attenuator",
|
|
129
|
+
# IM products
|
|
130
|
+
"intermod_products",
|
|
131
|
+
# S-meter
|
|
132
|
+
"s_unit_to_dbm", "dbm_to_s_unit",
|
|
133
|
+
# Formatting
|
|
134
|
+
"format_freq", "format_freq_short",
|
|
135
|
+
# Standard value series
|
|
136
|
+
"nearest_rbw", "nearest_value",
|
|
137
|
+
"SIGLENT_RBW_SERIES", "E12_SERIES", "E24_SERIES", "E48_SERIES", "E96_SERIES",
|
|
138
|
+
]
|
rf_bench/icom/ic7300.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ic7300.py — Icom IC-7300 CAT control via Hamlib rigctld
|
|
3
|
+
|
|
4
|
+
Communicates with rigctld over a plain TCP socket on port 4532.
|
|
5
|
+
rigctld must be running before any test is started:
|
|
6
|
+
|
|
7
|
+
rigctld -m 3073 -r /dev/ttyUSB0 -s 115200 &
|
|
8
|
+
|
|
9
|
+
Hamlib model numbers: 3073 (Hamlib 4.x), 373 (Hamlib 3.x).
|
|
10
|
+
|
|
11
|
+
The IC-7300 CI-V baud rate must match (Menu → Set → Connectors → CI-V Baud Rate).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import socket
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Constants
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
DEFAULT_HOST = "localhost"
|
|
23
|
+
DEFAULT_PORT = 4532
|
|
24
|
+
CONNECT_TIMEOUT = 5 # seconds
|
|
25
|
+
RECV_TIMEOUT = 3 # seconds
|
|
26
|
+
RECV_BUFSIZE = 4096
|
|
27
|
+
|
|
28
|
+
# AGC mode values as sent to rigctld set_level AGC
|
|
29
|
+
AGC_OFF = 0
|
|
30
|
+
AGC_FAST = 1
|
|
31
|
+
AGC_MID = 2 # "medium" on IC-7300 front panel
|
|
32
|
+
AGC_SLOW = 3
|
|
33
|
+
|
|
34
|
+
# Mode strings understood by Hamlib
|
|
35
|
+
MODES = {"usb": "USB", "lsb": "LSB", "cw": "CW", "cwr": "CWR",
|
|
36
|
+
"am": "AM", "fm": "FM", "rtty": "RTTY"}
|
|
37
|
+
|
|
38
|
+
# Standard passband widths (Hz) by mode — used as default if not specified
|
|
39
|
+
DEFAULT_PASSBAND = {"USB": 2400, "LSB": 2400, "CW": 500, "CWR": 500,
|
|
40
|
+
"AM": 6000, "FM": 15000, "RTTY": 500}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Driver class
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
class IC7300:
|
|
48
|
+
"""
|
|
49
|
+
IC-7300 CAT driver via Hamlib rigctld.
|
|
50
|
+
|
|
51
|
+
All frequency values are in Hz. get_strength() returns a numeric value
|
|
52
|
+
whose scale depends on the Hamlib version and calibration; use the
|
|
53
|
+
smeter-cal test to map it to dBm.
|
|
54
|
+
|
|
55
|
+
Usage:
|
|
56
|
+
rig = IC7300()
|
|
57
|
+
rig.set_mode("usb")
|
|
58
|
+
rig.set_frequency(14_200_000)
|
|
59
|
+
rig.set_agc("off")
|
|
60
|
+
strength = rig.get_strength()
|
|
61
|
+
rig.close()
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT):
|
|
65
|
+
self._host = host
|
|
66
|
+
self._port = port
|
|
67
|
+
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
68
|
+
self._sock.settimeout(CONNECT_TIMEOUT)
|
|
69
|
+
self._sock.connect((host, port))
|
|
70
|
+
self._sock.settimeout(RECV_TIMEOUT)
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
# Public API
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def get_frequency(self) -> float:
|
|
77
|
+
"""Return current VFO-A frequency in Hz."""
|
|
78
|
+
resp = self._cmd("\\get_freq")
|
|
79
|
+
try:
|
|
80
|
+
# rigctld responds with just the number, or "Frequency: <n>"
|
|
81
|
+
return float(resp.split()[-1])
|
|
82
|
+
except (ValueError, IndexError):
|
|
83
|
+
return 0.0
|
|
84
|
+
|
|
85
|
+
def set_frequency(self, freq_hz: float) -> None:
|
|
86
|
+
"""Set VFO-A frequency in Hz."""
|
|
87
|
+
self._cmd(f"\\set_freq {int(freq_hz)}")
|
|
88
|
+
time.sleep(0.1) # give the IC-7300 time to tune
|
|
89
|
+
|
|
90
|
+
def get_mode(self) -> tuple[str, int]:
|
|
91
|
+
"""
|
|
92
|
+
Return current (mode_string, passband_hz).
|
|
93
|
+
Mode string is a Hamlib mode name e.g. "USB", "CW".
|
|
94
|
+
"""
|
|
95
|
+
resp = self._cmd("\\get_mode")
|
|
96
|
+
lines = resp.strip().splitlines()
|
|
97
|
+
mode = lines[0].strip() if lines else "?"
|
|
98
|
+
try:
|
|
99
|
+
passband = int(lines[1].strip()) if len(lines) > 1 else 0
|
|
100
|
+
except ValueError:
|
|
101
|
+
passband = 0
|
|
102
|
+
return mode, passband
|
|
103
|
+
|
|
104
|
+
def set_mode(self, mode: str, passband_hz: int = 0) -> None:
|
|
105
|
+
"""
|
|
106
|
+
Set receiver mode.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
mode: One of 'usb', 'lsb', 'cw', 'cwr', 'am', 'fm', 'rtty'
|
|
110
|
+
passband_hz: IF filter width in Hz. 0 = use rig default.
|
|
111
|
+
"""
|
|
112
|
+
ham_mode = MODES.get(mode.lower(), mode.upper())
|
|
113
|
+
if passband_hz == 0:
|
|
114
|
+
passband_hz = DEFAULT_PASSBAND.get(ham_mode, 0)
|
|
115
|
+
self._cmd(f"\\set_mode {ham_mode} {passband_hz}")
|
|
116
|
+
time.sleep(0.15)
|
|
117
|
+
|
|
118
|
+
def get_strength(self) -> float:
|
|
119
|
+
"""
|
|
120
|
+
Return the S-meter / signal strength level.
|
|
121
|
+
|
|
122
|
+
Hamlib returns STRENGTH as a float. For the IC-7300 via Hamlib 4.x
|
|
123
|
+
this is typically in dB relative to S9 (range ≈ −54 to +60 dB).
|
|
124
|
+
The exact mapping depends on the Hamlib version; use smeter-cal to
|
|
125
|
+
characterize the specific rig/version combination.
|
|
126
|
+
|
|
127
|
+
Returns NaN if the read fails.
|
|
128
|
+
"""
|
|
129
|
+
resp = self._cmd("\\get_level STRENGTH")
|
|
130
|
+
try:
|
|
131
|
+
return float(resp.split()[-1])
|
|
132
|
+
except (ValueError, IndexError):
|
|
133
|
+
return float("nan")
|
|
134
|
+
|
|
135
|
+
def get_strength_settled(self, settle_s: float = 0.5, samples: int = 3) -> float:
|
|
136
|
+
"""
|
|
137
|
+
Wait settle_s seconds then take the average of `samples` strength readings.
|
|
138
|
+
|
|
139
|
+
Use after changing SDG level to let the IC-7300's AGC settle before reading.
|
|
140
|
+
"""
|
|
141
|
+
time.sleep(settle_s)
|
|
142
|
+
readings = []
|
|
143
|
+
for _ in range(samples):
|
|
144
|
+
v = self.get_strength()
|
|
145
|
+
if not float("nan") == v: # isnan check
|
|
146
|
+
readings.append(v)
|
|
147
|
+
time.sleep(0.1)
|
|
148
|
+
if not readings:
|
|
149
|
+
return float("nan")
|
|
150
|
+
return sum(readings) / len(readings)
|
|
151
|
+
|
|
152
|
+
def set_agc(self, mode: str) -> None:
|
|
153
|
+
"""
|
|
154
|
+
Set AGC mode.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
mode: 'off', 'fast', 'mid', or 'slow'
|
|
158
|
+
"""
|
|
159
|
+
agc_map = {"off": AGC_OFF, "fast": AGC_FAST, "mid": AGC_MID, "slow": AGC_SLOW}
|
|
160
|
+
val = agc_map.get(mode.lower())
|
|
161
|
+
if val is None:
|
|
162
|
+
raise ValueError(f"AGC mode must be one of {list(agc_map)}, got {mode!r}")
|
|
163
|
+
self._cmd(f"\\set_level AGC {val}")
|
|
164
|
+
time.sleep(0.1)
|
|
165
|
+
|
|
166
|
+
def get_agc(self) -> int:
|
|
167
|
+
"""Return current AGC value (0=off, 1=fast, 2=mid, 3=slow)."""
|
|
168
|
+
resp = self._cmd("\\get_level AGC")
|
|
169
|
+
try:
|
|
170
|
+
return int(float(resp.split()[-1]))
|
|
171
|
+
except (ValueError, IndexError):
|
|
172
|
+
return -1
|
|
173
|
+
|
|
174
|
+
def set_rf_gain(self, gain: float) -> None:
|
|
175
|
+
"""
|
|
176
|
+
Set RF gain (0.0 – 1.0, where 1.0 = maximum gain).
|
|
177
|
+
With AGC off, this directly controls receiver sensitivity.
|
|
178
|
+
"""
|
|
179
|
+
self._cmd(f"\\set_level RFGAIN {gain:.3f}")
|
|
180
|
+
|
|
181
|
+
def close(self) -> None:
|
|
182
|
+
"""Close the rigctld connection."""
|
|
183
|
+
self._sock.close()
|
|
184
|
+
|
|
185
|
+
# ------------------------------------------------------------------
|
|
186
|
+
# Context manager
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def __enter__(self):
|
|
190
|
+
return self
|
|
191
|
+
|
|
192
|
+
def __exit__(self, *_):
|
|
193
|
+
self.close()
|
|
194
|
+
|
|
195
|
+
# ------------------------------------------------------------------
|
|
196
|
+
# Internal helpers
|
|
197
|
+
# ------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
def _cmd(self, cmd: str) -> str:
|
|
200
|
+
"""Send a command to rigctld and return the response."""
|
|
201
|
+
self._sock.sendall((cmd + "\n").encode())
|
|
202
|
+
time.sleep(0.05)
|
|
203
|
+
try:
|
|
204
|
+
resp = b""
|
|
205
|
+
while True:
|
|
206
|
+
chunk = self._sock.recv(RECV_BUFSIZE)
|
|
207
|
+
resp += chunk
|
|
208
|
+
# rigctld responses end with a newline (or blank line for multi-line)
|
|
209
|
+
if resp.endswith(b"\n"):
|
|
210
|
+
break
|
|
211
|
+
return resp.decode(errors="replace").strip()
|
|
212
|
+
except socket.timeout:
|
|
213
|
+
return ""
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""rf_bench.siglent — Siglent instrument drivers (SCPI over raw TCP, port 5025)"""
|
|
2
|
+
|
|
3
|
+
from .ssa3000x import SSA3000X
|
|
4
|
+
from .sdg1000x import SDG1000X, DBM_MIN, DBM_MAX
|
|
5
|
+
from .sds2000x import SDS2000X
|
|
6
|
+
from .sdm3000x import SDM3000X, RANGE_AUTO as SDM_RANGE_AUTO
|
|
7
|
+
from .spd3303x import (
|
|
8
|
+
SPD3303X,
|
|
9
|
+
TRACKING_INDEPENDENT, TRACKING_SERIES, TRACKING_PARALLEL,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
# Spectrum analyzer
|
|
14
|
+
"SSA3000X",
|
|
15
|
+
# Function generator
|
|
16
|
+
"SDG1000X", "DBM_MIN", "DBM_MAX",
|
|
17
|
+
# Oscilloscope
|
|
18
|
+
"SDS2000X",
|
|
19
|
+
# Bench multimeter
|
|
20
|
+
"SDM3000X", "SDM_RANGE_AUTO",
|
|
21
|
+
# Triple-output power supply
|
|
22
|
+
"SPD3303X", "TRACKING_INDEPENDENT", "TRACKING_SERIES", "TRACKING_PARALLEL",
|
|
23
|
+
]
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sdg1000x.py — Siglent SDG1000X function generator driver
|
|
3
|
+
|
|
4
|
+
Connects via raw TCP/SCPI to port 5025. Uses Siglent's EasyWave protocol
|
|
5
|
+
(C1:BSWV / C2:BSWV commands), not standard IEEE SCPI. Both channels are
|
|
6
|
+
independent; each can be set to different frequencies and levels simultaneously.
|
|
7
|
+
|
|
8
|
+
Model: Siglent SDG1000X series
|
|
9
|
+
Default address: 10.1.1.61:5025
|
|
10
|
+
|
|
11
|
+
Amplitude notes:
|
|
12
|
+
All public methods accept and return dBm (into 50 Ω).
|
|
13
|
+
Internally the SDG uses Vpp across its configured load impedance.
|
|
14
|
+
With LOAD,50 the displayed/set amplitude IS the voltage across the 50 Ω load.
|
|
15
|
+
Conversion is handled by rf_utils.dbm_to_vpp / vpp_to_dbm.
|
|
16
|
+
|
|
17
|
+
Known firmware behavior:
|
|
18
|
+
Some SDG firmware versions ignore the LOAD parameter inside BSWV and require
|
|
19
|
+
a separate `C1:OUTP ON,LOAD,50` to set the termination. Always enable output
|
|
20
|
+
with the LOAD parameter explicit (output_on() handles this).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import socket
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
from ..utils.rf_utils import dbm_to_vpp, vpp_to_dbm
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Constants
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
DEFAULT_HOST = "10.1.1.61"
|
|
34
|
+
DEFAULT_PORT = 5025
|
|
35
|
+
CONNECT_TIMEOUT = 10 # seconds
|
|
36
|
+
RECV_TIMEOUT = 5 # seconds
|
|
37
|
+
RECV_BUFSIZE = 65536
|
|
38
|
+
|
|
39
|
+
# SDG1000X amplitude limits (Vpp into 50 Ω)
|
|
40
|
+
VPP_MIN_50 = 0.002 # 2 mVpp ≈ −46 dBm; waveform quality degrades below ~10 mVpp
|
|
41
|
+
VPP_MAX_50 = 10.0 # 10 Vpp ≈ +24 dBm
|
|
42
|
+
|
|
43
|
+
DBM_MIN = vpp_to_dbm(VPP_MIN_50) # ≈ −46 dBm
|
|
44
|
+
DBM_MAX = vpp_to_dbm(VPP_MAX_50) # ≈ +24 dBm
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Driver class
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
class SDG1000X:
|
|
52
|
+
"""
|
|
53
|
+
Driver for the Siglent SDG1000X dual-channel function generator.
|
|
54
|
+
|
|
55
|
+
Usage:
|
|
56
|
+
sdg = SDG1000X("10.1.1.61")
|
|
57
|
+
sdg.set_sine(1, freq_hz=14_001_000, level_dbm=-20)
|
|
58
|
+
sdg.set_sine(2, freq_hz=14_001_500, level_dbm=-20)
|
|
59
|
+
sdg.output_on(1)
|
|
60
|
+
sdg.output_on(2)
|
|
61
|
+
...
|
|
62
|
+
sdg.output_off_all()
|
|
63
|
+
sdg.close()
|
|
64
|
+
|
|
65
|
+
Context manager:
|
|
66
|
+
with SDG1000X("10.1.1.61") as sdg:
|
|
67
|
+
sdg.set_sine(1, 14e6, -20)
|
|
68
|
+
sdg.output_on(1)
|
|
69
|
+
...
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT):
|
|
73
|
+
self._host = host
|
|
74
|
+
self._port = port
|
|
75
|
+
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
76
|
+
self._sock.settimeout(CONNECT_TIMEOUT)
|
|
77
|
+
self._sock.connect((host, port))
|
|
78
|
+
self._sock.settimeout(RECV_TIMEOUT)
|
|
79
|
+
# Drain any startup banner
|
|
80
|
+
time.sleep(0.1)
|
|
81
|
+
try:
|
|
82
|
+
self._sock.recv(RECV_BUFSIZE)
|
|
83
|
+
except socket.timeout:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
# Public API
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def identify(self) -> str:
|
|
91
|
+
"""Return IDN string."""
|
|
92
|
+
return self._query("*IDN?")
|
|
93
|
+
|
|
94
|
+
def set_sine(self, channel: int, freq_hz: float, level_dbm: float,
|
|
95
|
+
phase_deg: float = 0.0) -> None:
|
|
96
|
+
"""
|
|
97
|
+
Configure a channel for sine wave output.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
channel: 1 or 2
|
|
101
|
+
freq_hz: Frequency in Hz (1 μHz to 60 MHz)
|
|
102
|
+
level_dbm: Output level in dBm into 50 Ω
|
|
103
|
+
phase_deg: Phase offset in degrees [default 0]
|
|
104
|
+
|
|
105
|
+
The channel output is NOT enabled by this call; follow with output_on().
|
|
106
|
+
"""
|
|
107
|
+
self._check_channel(channel)
|
|
108
|
+
self._check_level(level_dbm)
|
|
109
|
+
|
|
110
|
+
vpp = dbm_to_vpp(level_dbm)
|
|
111
|
+
ch = f"C{channel}"
|
|
112
|
+
self._cmd(
|
|
113
|
+
f"{ch}:BSWV WVTP,SINE,"
|
|
114
|
+
f"FRQ,{freq_hz:.6f},"
|
|
115
|
+
f"AMP,{vpp:.6f},"
|
|
116
|
+
f"OFST,0,"
|
|
117
|
+
f"PHSE,{phase_deg:.3f}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def output_on(self, channel: int) -> None:
|
|
121
|
+
"""Enable output on the specified channel with 50 Ω termination."""
|
|
122
|
+
self._check_channel(channel)
|
|
123
|
+
self._cmd(f"C{channel}:OUTP ON,LOAD,50")
|
|
124
|
+
|
|
125
|
+
def output_off(self, channel: int) -> None:
|
|
126
|
+
"""Disable output on the specified channel."""
|
|
127
|
+
self._check_channel(channel)
|
|
128
|
+
self._cmd(f"C{channel}:OUTP OFF")
|
|
129
|
+
|
|
130
|
+
def output_off_all(self) -> None:
|
|
131
|
+
"""Disable both channel outputs."""
|
|
132
|
+
self.output_off(1)
|
|
133
|
+
self.output_off(2)
|
|
134
|
+
|
|
135
|
+
def set_level(self, channel: int, level_dbm: float) -> None:
|
|
136
|
+
"""
|
|
137
|
+
Change only the output level of a configured channel (preserves frequency).
|
|
138
|
+
"""
|
|
139
|
+
self._check_channel(channel)
|
|
140
|
+
self._check_level(level_dbm)
|
|
141
|
+
vpp = dbm_to_vpp(level_dbm)
|
|
142
|
+
self._cmd(f"C{channel}:BSWV AMP,{vpp:.6f}")
|
|
143
|
+
|
|
144
|
+
def query_channel(self, channel: int) -> dict:
|
|
145
|
+
"""
|
|
146
|
+
Query current channel settings.
|
|
147
|
+
|
|
148
|
+
Returns a dict with keys: wvtp, freq_hz, amp_vpp, amp_dbm, ofst, phase_deg.
|
|
149
|
+
"""
|
|
150
|
+
self._check_channel(channel)
|
|
151
|
+
resp = self._query(f"C{channel}:BSWV?")
|
|
152
|
+
# Response: "C1:BSWV WVTP,SINE,FRQ,14001000.0,AMP,0.063,..."
|
|
153
|
+
params = {}
|
|
154
|
+
if "BSWV" in resp:
|
|
155
|
+
resp = resp.split("BSWV", 1)[1].strip()
|
|
156
|
+
parts = resp.split(",")
|
|
157
|
+
for i in range(0, len(parts) - 1, 2):
|
|
158
|
+
key = parts[i].strip().upper()
|
|
159
|
+
val = parts[i + 1].strip()
|
|
160
|
+
params[key] = val
|
|
161
|
+
|
|
162
|
+
result = {"wvtp": params.get("WVTP", "?")}
|
|
163
|
+
try:
|
|
164
|
+
result["freq_hz"] = float(params.get("FRQ", 0))
|
|
165
|
+
result["amp_vpp"] = float(params.get("AMP", 0))
|
|
166
|
+
result["amp_dbm"] = vpp_to_dbm(result["amp_vpp"])
|
|
167
|
+
result["ofst"] = float(params.get("OFST", 0))
|
|
168
|
+
result["phase_deg"] = float(params.get("PHSE", 0))
|
|
169
|
+
except (ValueError, ZeroDivisionError):
|
|
170
|
+
pass
|
|
171
|
+
return result
|
|
172
|
+
|
|
173
|
+
def close(self) -> None:
|
|
174
|
+
"""Disable outputs and close the TCP connection."""
|
|
175
|
+
try:
|
|
176
|
+
self.output_off_all()
|
|
177
|
+
except Exception:
|
|
178
|
+
pass
|
|
179
|
+
self._sock.close()
|
|
180
|
+
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
# Context manager
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
def __enter__(self):
|
|
186
|
+
return self
|
|
187
|
+
|
|
188
|
+
def __exit__(self, *_):
|
|
189
|
+
self.close()
|
|
190
|
+
|
|
191
|
+
# ------------------------------------------------------------------
|
|
192
|
+
# Internal helpers
|
|
193
|
+
# ------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def _check_channel(channel: int) -> None:
|
|
197
|
+
if channel not in (1, 2):
|
|
198
|
+
raise ValueError(f"Channel must be 1 or 2, got {channel}")
|
|
199
|
+
|
|
200
|
+
def _check_level(self, level_dbm: float) -> None:
|
|
201
|
+
if not (DBM_MIN <= level_dbm <= DBM_MAX):
|
|
202
|
+
raise ValueError(
|
|
203
|
+
f"Level {level_dbm:.1f} dBm out of range "
|
|
204
|
+
f"[{DBM_MIN:.1f}, {DBM_MAX:.1f}] dBm"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def _cmd(self, cmd: str) -> None:
|
|
208
|
+
"""Send a command and wait briefly for the instrument to process it."""
|
|
209
|
+
self._sock.sendall((cmd + "\n").encode())
|
|
210
|
+
time.sleep(0.05)
|
|
211
|
+
|
|
212
|
+
def _query(self, cmd: str) -> str:
|
|
213
|
+
"""Send a query and return the response string."""
|
|
214
|
+
self._sock.sendall((cmd + "\n").encode())
|
|
215
|
+
time.sleep(0.05)
|
|
216
|
+
try:
|
|
217
|
+
data = self._sock.recv(RECV_BUFSIZE)
|
|
218
|
+
return data.decode(errors="replace").strip()
|
|
219
|
+
except socket.timeout:
|
|
220
|
+
return ""
|