Metadata-Version: 2.4
Name: rf-bench
Version: 0.3.0
Summary: Meta-package: installs rf-bench-drivers-siglent, -icom, -yaesu, -utils
Author-email: Jeff Francis <gjfrancis@protonmail.com>
License: GPL-3.0-or-later
Project-URL: Homepage, https://github.com/jfrancis42/rf-bench
Project-URL: Repository, https://github.com/jfrancis42/rf-bench
Project-URL: Bug Tracker, https://github.com/jfrancis42/rf-bench/issues
Keywords: siglent,scpi,test-equipment,spectrum-analyzer,function-generator,oscilloscope,ic7300,ft891,hamlib,rf,bench-automation,amateur-radio
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Science/Research
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering
Classifier: Topic :: System :: Hardware :: Hardware Drivers
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: rf-bench-drivers-utils>=0.1.0
Requires-Dist: rf-bench-drivers-siglent>=0.1.0
Requires-Dist: rf-bench-drivers-icom>=0.1.0
Requires-Dist: rf-bench-drivers-yaesu>=0.1.0
Provides-Extra: dev
Requires-Dist: matplotlib; extra == "dev"
Requires-Dist: scipy; extra == "dev"
Requires-Dist: build; extra == "dev"
Requires-Dist: twine; extra == "dev"
Dynamic: license-file

# rf-bench

> **v0.3.0 — package has been split into focused sub-packages.**
>
> `rf-bench` is now a meta-package. Installing it pulls in the four
> independent driver packages listed below. Each can also be installed
> individually. All import paths (`rf_bench.siglent`, `rf_bench.icom`,
> `rf_bench.yaesu`, `rf_bench.utils`) are unchanged.
>
> | Package | PyPI | Provides |
> |---------|------|---------|
> | `rf-bench-drivers-siglent` | [![PyPI](https://img.shields.io/pypi/v/rf-bench-drivers-siglent)](https://pypi.org/project/rf-bench-drivers-siglent/) | SSA3000X, SDG1000X, SDS2000X, SDM3000X, SPD3303X |
> | `rf-bench-drivers-icom` | [![PyPI](https://img.shields.io/pypi/v/rf-bench-drivers-icom)](https://pypi.org/project/rf-bench-drivers-icom/) | IC7300 |
> | `rf-bench-drivers-yaesu` | [![PyPI](https://img.shields.io/pypi/v/rf-bench-drivers-yaesu)](https://pypi.org/project/rf-bench-drivers-yaesu/) | FT891 |
> | `rf-bench-drivers-utils` | [![PyPI](https://img.shields.io/pypi/v/rf-bench-drivers-utils)](https://pypi.org/project/rf-bench-drivers-utils/) | RF math utilities |

---

Python drivers and RF utilities for bench instrument automation. Connects
to Siglent test equipment via raw TCP/SCPI (no pyvisa required) and to HF
transceivers via [Hamlib](https://hamlib.github.io/) rigctld.

## Instruments supported

### Siglent (`rf_bench.siglent`)

| Class | Instrument family | Tested with | Protocol |
|-------|------------------|-------------|---------|
| `SSA3000X` | SSA3000X Plus series spectrum analyzers | SSA3032X Plus (9 kHz–3.2 GHz) | SCPI / TCP port 5025 |
| `SDG1000X` | SDG1000X series function generators | SDG1062X (2-ch, 60 MHz) | EasyWave / TCP port 5025 |
| `SDS2000X` | SDS2000X Plus series oscilloscopes | SDS2504X Plus (500 MHz) | SCPI / TCP port 5025 |
| `SDM3000X` | SDM3000 series bench multimeters | SDM3045X (4.5-digit) | SCPI / TCP port 5025 |
| `SPD3303X` | SPD3303X series triple-output PSUs | SPD3303X-E (2×32 V/3.2 A + fixed) | SCPI / TCP port 5025 |

### Icom (`rf_bench.icom`)

| Class | Instrument | Protocol |
|-------|-----------|---------|
| `IC7300` | IC-7300 HF/6m transceiver | Hamlib rigctld / TCP port 4532 |

### Yaesu (`rf_bench.yaesu`)

| Class | Instrument | Protocol |
|-------|-----------|---------|
| `FT891` | FT-891 HF/6m transceiver | Hamlib rigctld / TCP port 4532 |

### Utilities (`rf_bench.utils`)

`rf_utils` — pure-Python RF math library. Power conversions, impedance and
reflection math, noise figure, IP3, frequency formatting. No instruments,
no side effects; safe to import anywhere.

## Installation

Install everything at once (recommended):

```bash
pip install rf-bench
```

Or install only the sub-packages you need:

```bash
pip install rf-bench-drivers-siglent   # Siglent instruments
pip install rf-bench-drivers-icom      # Icom IC-7300
pip install rf-bench-drivers-yaesu     # Yaesu FT-891
pip install rf-bench-drivers-utils     # RF math utilities (no instruments)
```

**Dependency:** [NumPy](https://numpy.org/) (for `rf_bench.utils` and the
`SDS2000X` waveform decoder).

**For radio control:** [Hamlib](https://hamlib.github.io/) must be installed
and `rigctld` must be running before using `IC7300` or `FT891`.

```bash
# IC-7300  (CI-V baud set to 115200 in radio menu)
rigctld -m 3073 -r /dev/ttyUSB0 -s 115200 &

# FT-891  (CAT baud set to 38400 in Menu 031)
rigctld -m 1036 -r /dev/ttyUSB0 -s 38400 &
```

## Quick start

```python
from rf_bench import SDG1000X, IC7300, dbm_to_vpp, format_freq

# Function generator — two-tone test signal
with SDG1000X("10.1.1.61") as sdg:
    sdg.set_sine(1, freq_hz=14_001_000, level_dbm=-30)
    sdg.set_sine(2, freq_hz=14_001_500, level_dbm=-30)
    sdg.output_on(1)
    sdg.output_on(2)
    # ... run test ...

# IC-7300 S-meter reading
with IC7300() as rig:
    rig.set_frequency(14_200_000)
    rig.set_mode("usb")
    rig.set_agc("off")
    strength = rig.get_strength_settled(settle_s=0.5)
    print(f"Signal: {strength:.1f} STRENGTH units")

# RF math
dbm_to_vpp(-20)          # → 0.0632 Vpp  (P = Vpp²/8R, 50 Ω)
format_freq(14_200_000)  # → '14.2000 MHz'
```

Or import from subpackages:

```python
from rf_bench.siglent import SSA3000X, SDG1000X
from rf_bench.icom   import IC7300
from rf_bench.yaesu  import FT891, PREAMP_OFF, PREAMP_AMP1
from rf_bench.utils  import thermal_noise_floor, ip3_from_imd, rl_to_vswr
```

## Siglent drivers

### SSA3000X

*Tested with: Siglent SSA3032X Plus*

```python
from rf_bench.siglent import SSA3000X

with SSA3000X("10.1.1.60") as ssa:
    ssa.enable_tracking_generator(dbm=0)
    rbw = ssa.setup_band(14_000_000, 14_350_000, points=1001)
    ssa.single_sweep()           # blocks until sweep completes
    trace = ssa.get_trace()      # → np.ndarray of dBm values (length = points)
```

### SDG1000X

*Tested with: Siglent SDG1062X*

```python
from rf_bench.siglent import SDG1000X

with SDG1000X("10.1.1.61") as sdg:
    sdg.set_sine(1, freq_hz=14_001_000, level_dbm=-30)
    sdg.output_on(1)
    sdg.set_level(1, level_dbm=-40)   # change level only, preserve frequency
    info = sdg.query_channel(1)       # → {freq_hz, amp_vpp, amp_dbm, ...}
```

Amplitude range: ≈ −50 dBm (2 mVpp) to +24 dBm (10 Vpp) into 50 Ω.

### SDS2000X

*Tested with: Siglent SDS2504X Plus (firmware 5.4.x)*

```python
from rf_bench.siglent import SDS2000X

with SDS2000X("10.1.1.58") as scope:
    # Waveform capture (deep memory, auto V/div)
    voltages, sample_rate = scope.capture_audio(channel=1, duration_s=2.0)
    rms  = scope.measure_rms(channel=1)
    vpp  = scope.measure_vpp(channel=1)
    freq = scope.measure_freq(channel=1)
    vdiv = scope.autoscale_vdiv(channel=1)
```

#### Built-in AWG [Option — requires AWG license]

The SDS2000X Plus has a licensed 25 MHz single-channel AWG output on the
dedicated "Gen Out" BNC.

*Tested with: SDS2504X Plus (firmware 5.4.x) — AWG license confirmed present.*

```python
# Waveforms — each call configures and enables the output
scope.set_awg_sine(freq_hz=1000, amplitude_vpp=1.0)
scope.set_awg_sine(freq_hz=1000, amplitude_vpp=1.0, offset_v=0.5, phase_deg=90)

scope.set_awg_square(freq_hz=1000, amplitude_vpp=2.0)
scope.set_awg_square(freq_hz=1000, amplitude_vpp=2.0, duty_pct=30.0)

scope.set_awg_ramp(freq_hz=500, amplitude_vpp=1.5)             # sawtooth (100% symmetry)
scope.set_awg_ramp(freq_hz=500, amplitude_vpp=1.5, symmetry_pct=50.0)  # triangle

scope.set_awg_dc(offset_v=2.5)    # constant DC voltage

# Output control
scope.awg_output_on()
scope.awg_output_off()

# Query current state
state = scope.get_awg_state()
# → {'function': 'SINE', 'freq_hz': 1000.0, 'amplitude_vpp': 1.0,
#    'offset_v': 0.0, 'output_on': True}
```

Frequency range: 1 mHz – 25 MHz.  Amplitude: 2 mVpp – 6 Vpp into high
impedance (1 mVpp – 3 Vpp into 50 Ω).  Output impedance: 50 Ω.

#### MSO / Digital channels [Option — hardware not tested]

The SDS2000X Plus supports 16 digital channels (D0–D15) via an optional
MSO hardware probe pod.  Channels D0–D7 form pod 1; D8–D15 form pod 2;
thresholds are set per pod.

> **Note:** This code is implemented from the official Siglent EN11F
> programming guide but has **not been tested with physical MSO
> hardware** — the author does not currently have the digital probe pod.
> Feedback on correctness, especially the bit-packing format and custom
> threshold voltage syntax, is welcome.

```python
# Enable digital display and configure channels
scope.digital_enable()
scope.digital_channel_enable(0)              # turn on D0
scope.digital_channel_enable(5)              # turn on D5
scope.set_digital_threshold(1, "TTL")        # D0–D7: TTL (1.4 V)
scope.set_digital_threshold(2, "LVCMOS33")   # D8–D15: 3.3 V LVCMOS
scope.set_digital_threshold(1, 1.8)          # D0–D7: 1.8 V custom

scope.set_digital_label(0, "CLK")            # label D0 as "CLK"

# Capture digital waveform from current (frozen) acquisition
scope.stop()
samples, sr = scope.capture_digital(0)       # D0 → (bool array, sample_rate Hz)

sr_hz  = scope.get_digital_sample_rate()     # → e.g. 1.25e9
n_pts  = scope.get_digital_point_count()     # → e.g. 2500
print(scope.get_digital_threshold(1))        # → 'TTL'
print(scope.is_digital_channel_enabled(0))   # → True

scope.digital_disable()
```

Digital data returned by `capture_digital()` is a numpy `bool` array with one
element per sample (True = logic HIGH).  Internally the scope returns packed
bits (1 bit per sample, LSB of each byte = earliest sample); the driver
unpacks them automatically.

### SDM3000X

*Tested with: Siglent SDM3045X (4.5-digit)*
*Compatible with: SDM3045X, SDM3055 (5.5-digit), SDM3065X (6.5-digit)*

All measurement functions return SI units (V, A, Ω, Hz, F, °C).  MEAS commands
are one-shot; use `configure_*()` + `read_multiple()` for repeated measurements.

```python
from rf_bench.siglent import SDM3000X

with SDM3000X("10.1.1.63") as dmm:
    v = dmm.measure_vdc()                    # DC voltage, auto-range → V
    v = dmm.measure_vdc(range_v=20)          # DC voltage, 20 V range → V
    i = dmm.measure_idc()                    # DC current → A
    r = dmm.measure_resistance()             # 2-wire resistance → Ω
    r = dmm.measure_resistance(four_wire=True)   # 4-wire (Kelvin) → Ω
    f = dmm.measure_frequency()              # frequency → Hz
    dmm.measure_continuity()                 # resistance; beeps if < ~30 Ω
    dmm.measure_diode()                      # forward voltage → V

    # SDM3055 / SDM3065X only:
    c = dmm.measure_capacitance()            # → F
    t = dmm.measure_temperature()            # → °C (FRTD probe default)

    # Multi-sample: configure once, read many
    dmm.configure_vdc(range_v=5)
    samples = dmm.read_multiple(20)          # → [float, ...] 20 samples
```

### SPD3303X

*Tested with: Siglent SPD3303X-E (2× 0–32 V / 0–3.2 A + fixed CH3)*
*Compatible with: SPD3303C, SPD3303X, SPD3303X-E*

CH1 and CH2 are fully programmable CC/CV channels.  CH3 is a fixed-voltage output
(2.5 V, 3.3 V, or 5 V selected by front-panel switch); its voltage cannot be set
via SCPI but its output can be enabled/disabled and measured.

```python
from rf_bench.siglent import SPD3303X, TRACKING_INDEPENDENT, TRACKING_SERIES

with SPD3303X("10.1.1.64") as psu:
    # Basic CH1 setup
    psu.set_voltage(1, 5.0)          # 5 V setpoint
    psu.set_current(1, 0.5)          # 500 mA current limit
    psu.enable(1)

    v    = psu.measure_voltage(1)    # actual output voltage → V
    i    = psu.measure_current(1)    # actual output current → A
    p    = psu.measure_power(1)      # actual output power → W
    mode = psu.get_mode(1)           # 'CV' or 'CC'

    state = psu.measure_all(1)       # {'voltage_v', 'current_a', 'power_w'}

    # CH3 (fixed voltage — 2.5/3.3/5 V set by front-panel switch)
    psu.enable(3)
    psu.measure_voltage(3)           # reads actual CH3 output voltage

    psu.disable_all()

    # Series tracking: CH1+CH2 in series for up to 64 V
    psu.set_tracking(TRACKING_SERIES)
    psu.set_voltage(1, 24.0)         # CH2 mirrors CH1 automatically
    psu.enable(1)
    psu.enable(2)
    psu.get_status()   # → {'ch1_mode': 'CV', 'ch2_mode': 'CV', 'track_mode': 'SER'}
```

## Radio drivers

`IC7300` and `FT891` share an identical core interface and are drop-in
substitutable.

```python
from rf_bench.icom  import IC7300
from rf_bench.yaesu import FT891, PREAMP_OFF, PREAMP_AMP1

# Shared interface
for RigClass in (IC7300, FT891):
    with RigClass() as rig:
        rig.set_frequency(14_200_000)
        rig.set_mode("usb", passband_hz=2400)
        rig.set_agc("slow")
        rig.set_rf_gain(1.0)
        strength = rig.get_strength_settled()

# FT-891 additions: preamp / attenuator
with FT891() as rig:
    rig.set_preamp(PREAMP_OFF)   # IPO — bypass preamp for large-signal tests
    rig.set_preamp(PREAMP_AMP1)  # AMP1 — ~10 dB gain for sensitivity tests
    rig.set_att(6)               # 0, 6, or 12 dB front-end attenuation
```

**AGC note:** `set_agc("off")` is a true hardware bypass on the IC-7300.
On the FT-891 it maps to the slowest AGC constant — not a true bypass.

If both radios are in use simultaneously, run each rigctld on a separate port:

```bash
rigctld -m 3073 -r /dev/ttyUSB0 -s 115200 -T localhost -t 4532 &
rigctld -m 1036 -r /dev/ttyUSB1 -s 38400  -T localhost -t 4533 &
```

```python
ic  = IC7300("localhost", 4532)
ft  = FT891("localhost", 4533)
```

## RF utilities

`rf_bench.utils` is a pure-Python RF math library. No instruments, no side effects;
safe to import anywhere.

```python
from rf_bench.utils import (
    # Constants
    SPEED_OF_LIGHT,                  # 299 792 458 m/s (exact)
    S9_HF_DBM, S9_VHF_DBM,          # −73 / −93 dBm (ITU S-meter references)

    # Power / voltage (50 Ω default; pass impedance= to override)
    dbm_to_vpp, vpp_to_dbm,         # dBm ↔ Vpp  (sine: P = Vpp²/8R; 0 dBm → 0.6325 Vpp)
    dbm_to_vrms, vrms_to_dbm,       # dBm ↔ Vrms
    dbm_to_watts, watts_to_dbm,     # dBm ↔ Watts
    dbm_to_uv, uv_to_dbm,           # dBm ↔ µVrms

    # Power ratio / extended dB units
    db_to_linear, linear_to_db,     # power ratio ↔ dB
    db_to_voltage_ratio,            # voltage ratio from dB (10^(dB/20))
    voltage_ratio_to_db,            # dB from voltage ratio (20·log10)
    dbm_to_dbw, dbw_to_dbm,         # dBm ↔ dBW
    dbm_to_dbuv, dbuv_to_dbm,       # dBm ↔ dBµV (0 dBm at 50 Ω = 106.99 dBµV)

    # Impedance / reflection
    rl_to_vswr, vswr_to_rl,         # return loss ↔ VSWR
    gamma_to_vswr, vswr_to_gamma,   # reflection coeff ↔ VSWR
    rl_to_gamma, gamma_to_rl,
    rl_to_vswr_v, vswr_to_rl_v,     # vectorized (numpy array) versions
    gamma_to_vswr_v,

    # Noise and dynamic range
    thermal_noise_floor,             # kTB in dBm (exact Boltzmann constant)
    noise_figure_from_mds,           # NF from measured MDS and bandwidth
    mds_from_noise_figure,           # MDS from NF and bandwidth
    ip3_from_imd,                    # OIP3 or IIP3 from two-tone IMD levels
    ip3_to_dynamic_range,            # SFDR = (2/3)(IP3 − noise floor)
    cascaded_noise_figure,           # Friis formula for cascade of (gain_db, nf_db) stages
    noise_temp_to_nf,                # noise temperature (K) → NF (dB)
    nf_to_noise_temp,                # NF (dB) → noise temperature (K)

    # Propagation / antenna
    wavelength, quarter_wave,        # λ, λ/4 in metres; optional velocity_factor
    half_wave,                       # λ/2 in metres
    freespace_path_loss,             # FSPL = 20·log10(4πdf/c) in dB

    # Passive components
    capacitive_reactance,            # Xc = 1/(2πfC) Ω
    inductive_reactance,             # Xl = 2πfL Ω
    lc_resonant_freq,                # f = 1/(2π√(LC)) Hz
    l_from_resonant, c_from_resonant,# compute L or C from resonant frequency
    q_factor, bw_from_q,             # Q = f0/BW ↔ BW = f0/Q
    parallel_resistance,             # 1/Σ(1/Rᵢ) — 2 or more values
    voltage_divider,                 # Vout = Vin · R2 / (R1+R2)
    skin_depth,                      # δ in metres (copper default: 5.8×10⁷ S/m)

    # Attenuator design
    pi_attenuator,                   # π-pad: {'r_shunt': Ω, 'r_series': Ω}
    t_attenuator,                    # T-pad:  {'r_series': Ω, 'r_shunt': Ω}

    # IM products
    intermod_products,               # two-tone near-carrier IM products, odd orders

    # S-meter
    s_unit_to_dbm, dbm_to_s_unit,   # ITU S-unit ↔ dBm (HF default; vhf=True for VHF)

    # Formatting
    format_freq,                     # 14200000 → '14.2000 MHz'; also GHz and Hz
    format_freq_short,               # 14200000 → '14.2 MHz' (trailing zeros trimmed)
    nearest_rbw,                     # nearest Siglent RBW step
    nearest_value,                   # nearest value in any list (E-series, RBW, etc.)

    # Standard value series
    SIGLENT_RBW_SERIES,
    E12_SERIES, E24_SERIES, E48_SERIES, E96_SERIES,
)
```

## Default instrument addresses

| Driver class | Tested instrument | Default IP / address | Port | Notes |
|-------------|------------------|---------------------|------|-------|
| `SSA3000X` | SSA3032X Plus | 10.1.1.60 | 5025 | LAN/SCPI |
| `SDG1000X` | SDG1062X | 10.1.1.55 | 5025 | LAN/SCPI |
| `SDS2000X` | SDS2504X Plus | 10.1.1.58 | 5025 | LAN/SCPI |
| `SDM3000X` | SDM3045X | 10.1.1.63 | 5025 | LAN/SCPI |
| `SPD3303X` | SPD3303X-E | 10.1.1.56 | 5025 | LAN/SCPI |
| `IC7300` | IC-7300 | localhost | 4532 | `rigctld -m 3073 -r /dev/ttyUSB0 -s 115200` |
| `FT891` | FT-891 | localhost | 4532 | `rigctld -m 1036 -r /dev/ttyUSB0 -s 38400` |

All drivers accept `host` and `port` constructor arguments to override defaults.

## Firmware bugs and quirks

Known instrument firmware issues discovered during live testing. Workarounds are
implemented in the drivers; this section explains the underlying behaviour for
reference when working on driver code or upgrading firmware.

### SDS2000X Plus — firmware 5.4.0.1.6.2R5

**Bug 1: `:WAVeform:DATA?` intermittently returns display-buffer data instead of deep memory**

`:WAVeform:POINt MAX` followed by `:WAVeform:DATA?` occasionally returns 1 000
samples at 2 GSps (the 1 000-point display buffer) instead of full acquisition
memory (10 M samples at 20 MHz for a 1 s capture).  The bug is
**state-dependent** — it is not tied to any specific VDIV or TDIV value.
It appears more frequently when the scope's ADC configuration has been recently
changed (e.g., by an autorange probe at a different VDIV).

*Workaround:* `capture_audio()` detects the display-buffer condition
(`len ≤ 1000` and `sample_rate > 500 MHz`) and retries once; the second
attempt almost always succeeds because re-arming the trigger clears the state.

**Bug 2: ADC not reconfigured after VDIV change — INVESTIGATED, NOT REPRODUCED**

Initially suspected: after `C1:VDIV` changes the vertical scale, WAVEDESC would
report the new (correct) vgain while the ADC data remained scaled for the old
VDIV — a "phantom vgain."  Live debug output disproved this: on attempt 1 of a
deep-memory capture, WAVEDESC-reported vgain matched the ADC data correctly, and
the 99th-percentile amplitude matched the expected signal level.  The inflated
amplitude seen in earlier testing was caused by `numpy.max()` picking up isolated
noise spikes, not a firmware miscalibration.

*Status:* No driver workaround is needed or present.  Retained here to document
the investigation in case the symptom reappears on a different firmware version.

**Bug 3: Stale bytes in socket after binary block read cause PAVA pipeline shift**

After `_read_binary_block()` reads a waveform or preamble block, the scope
appends a trailing `\n` that arrives slightly after the binary payload.  Without
an explicit drain, this byte sits in the TCP receive buffer.  The next
`_query()` call reads the stale byte and returns empty; all subsequent PAVA
queries are shifted by one response (each call returns the previous call's
data).  Symptom: `measure_rms()` returns NaN and subsequent calls return each
other's values.

*Workaround:* `_read_binary_block()` drains all trailing bytes with a 50 ms
timeout after each binary read.  `measure_rms/vpp/freq()` additionally call
`_drain()` before each PAVA query.

**Bug 4: PAVA returns a silent incorrect value when V/div is poorly matched to the signal**

At large V/div (e.g., 2 V/div for a signal only a few mV peak), the signal
occupies only ±1–3 ADC counts.  PAVA returns a near-zero Vpp rather than an
error, with no indication that the measurement is unreliable.  If autorange
naively trusts this small reading it selects a far-too-fine V/div, the ADC
clips the actual signal, and the error compounds.

*Workaround:* `_autorange_vdiv()` captures a short waveform at 0.1 V/div and
computes the 99th-percentile peak directly from the ADC counts, bypassing PAVA
entirely for autorange decisions.

**Behavior note: `:WAVeform:POINt MAX` must be sent before every data read**

Without it, the scope returns the 1 000-point display-decimated trace regardless
of acquisition memory depth.  This is consistent with general SCPI oscilloscope
behavior (default is display resolution, not deep memory) but can surprise users
who expect a long-timebase capture to return deep-memory data automatically.

*Practice:* always issued in `capture_audio()`.

**Bug 6: TDIV/VDIV changes must be made while scope is stopped**

Changing TDIV or VDIV while the scope is running produces a corrupt acquisition
(may return 5 bytes, all +127).

*Workaround:* `capture_audio()` always issues `:STOP` before reconfiguring.

**Bug 7: PAVA requires a fresh, complete sweep before it returns valid data**

After `:STOP`, calling `:RUN` and querying PAVA immediately returns NaN or
wrong values.  PAVA needs at least one full sweep to complete.

*Workaround:* `measure_rms/vpp/freq()` set a short TDIV (2 ms/div = 20 ms
window) before running, so the sweep finishes well within the 0.5 s sleep.

**Bug 8: Changing TDIV while stopped silently corrupts the VDIV register**

When `measure_rms/vpp/freq()` are called after `capture_audio()`, the
`_pava_setup()` helper issues `:STOP`, changes `TDIV` to 2 ms, then runs.
Even though the scope is stopped at the time of the TDIV change (workaround
for Bug 6), the firmware silently resets the VDIV register to an undefined
value.  The acquisition runs at the new TDIV but with a wrong VDIV, causing
PAVA FREQ to return wildly incorrect values (e.g. 45200 Hz, 99500 Hz) while
PAVA RMS and PKPK appear plausible but are off by the VDIV ratio.

*Workaround:* `_pava_setup()` now explicitly re-applies `C{n}:VDIV` after
every TDIV change, using the VDIV that was last set by `capture_audio()` for
that channel (tracked in `self._last_capture_vdiv`), or 0.5 V/div if no
prior capture has been done.

### SSA3000X Plus — firmware 3.2.2.6.3R2

**Quirk: `:SENS:SWE:POIN` command silently ignored — trace length fixed at 751**

The `setup_band()` method sends `:SENS:SWE:POIN {n}` to request a specific
number of sweep points, but firmware 3.2.x on the SSA3032X Plus ignores the
command.  The instrument always returns traces with exactly 751 points,
regardless of what was requested.  This makes the RBW calculation in
`setup_band()` incorrect if it uses the requested point count instead of the
actual count.

*Workaround:* `setup_band()` calls `get_sweep_points()` to read the actual
point count before computing the RBW, ensuring the RBW matches what the
firmware will use.  The `:SENS:SWE:POIN` command is still sent (it may work
on other firmware versions) but the actual count is read back and used.

### SDG1062X — firmware 1.01.01.33R3

**Bug: BSWV response appends unit suffixes to all numeric values**

Querying `C1:BSWV?` returns fields like `FRQ,1000HZ`, `AMP,0.2V`,
`AMPDBM,-10.0dBm`, `OFST,0.0V`.  The `float()` built-in raises `ValueError`
on these strings, silently returning 0 for all channel parameters.

*Workaround:* `_strip_unit()` in `SDG1000X` strips the unit suffix via regex
before parsing.

### SPD3303X-E — firmware (all versions tested)

**Quirk: `OUTP?` does not respond reliably**

`:OUTP? CH1` sometimes returns an empty response.

*Workaround:* `is_enabled()` uses the `SYST:STAT?` status register bitmask
(bits 4–6 = CH1/CH2/CH3 output enable) instead.

## License

GPL-3.0-or-later — see [LICENSE](LICENSE).
