Compare commits

..

4 Commits

Author SHA1 Message Date
18a1b2faa3 better tx tone
All checks were successful
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Successful in 40s
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been skipped
2025-01-15 23:01:10 -07:00
c034cdcb95 document firmware versions 2025-01-15 22:59:29 -07:00
0c152c337e make vna.py importable 2025-01-15 22:23:54 -07:00
22a33c2b84 some config stuff 2025-01-15 22:14:27 -07:00
3 changed files with 211 additions and 161 deletions

View File

@ -39,7 +39,9 @@ Without this, you'll be limited to S11 and uncalibrated S21 measurements (with r
There's nothing special about this particular board, if you want more than 4 ports you can make your own pretty easily. You just need 3 SPxT switches. Note that these switches will see tons of cycles so avoid mechanical switches.
- SMA cables
### Pluto Modification
### Pluto Configuration
Most of my testing is with Pluto firmware [v0.39](https://github.com/analogdevicesinc/plutosdr-fw/releases/tag/v0.39) though this may work with other firmware versions. Instructions for upgrading firmware are on the [Analog Devices wiki](https://wiki.analog.com/university/tools/pluto/users/firmware).
We need two receive channels on the SDR. If you have a Pluto+ that should already be configured and you can skip this step.

View File

@ -14,6 +14,8 @@ from numpy import typing as npt
from PySide6.QtGui import QAction, QKeySequence
from PySide6.QtWidgets import (
QApplication,
QFileDialog,
QInputDialog,
QMainWindow,
QMenu,
QProgressBar,
@ -21,18 +23,19 @@ from PySide6.QtWidgets import (
QWidget,
)
from skrf import plotting as rf_plt
from vna import Charon
from charon_vna.gui_helpers import FlowLayout
from charon_vna.util import db20, s2vswr
# from vna import Charon
# %%
DEFAULT_CONFIG = dict(
frequency=np.arange(1e9, 2e9, 11), # Hz
power=-5, # dB
)
CONFIG_SUFFIX = ".json"
class PlotWidget(QWidget):
traces: List[Tuple[int | str]]
@ -157,6 +160,7 @@ class MainWindow(QMainWindow):
super().__init__()
self.config_path = None
self._frequency = np.linspace(1e9, 2e9, 101) # TODO: read frequency from config
# self.device = Charon("ip:192.168.3.1", frequency=DEFAULT_CONFIG["frequency"])
@ -169,10 +173,10 @@ class MainWindow(QMainWindow):
menu_file = QMenu("&File")
menubar.addMenu(menu_file)
action_load_config = QAction("&Open Configuration", self)
menu_file.addAction(action_load_config)
action_load_config.triggered.connect(self.load_config)
action_load_config.setShortcut(QKeySequence("Ctrl+O"))
action_open_config = QAction("&Open Configuration", self)
menu_file.addAction(action_open_config)
action_open_config.triggered.connect(self.open_config)
action_open_config.setShortcut(QKeySequence("Ctrl+O"))
action_save_config = QAction("&Save Configuration", self)
menu_file.addAction(action_save_config)
action_save_config.triggered.connect(self.save_config)
@ -186,7 +190,7 @@ class MainWindow(QMainWindow):
menubar.addMenu(menu_stimulus)
action_set_frequency = QAction("&Frequency", self)
menu_stimulus.addAction(action_set_frequency)
# action_set_frequency.triggered.connect(self.set_frequency)
action_set_frequency.triggered.connect(self.set_frequency)
action_set_power = QAction("&Power", self)
menu_stimulus.addAction(action_set_power)
# action_set_power.triggered.connect(self.set_power)
@ -230,24 +234,51 @@ class MainWindow(QMainWindow):
def saveas_config(self) -> None:
print("Prompting for save path...")
# TODO: prompt for config path
self.config_path = Path(__file__).parent / "config.json"
print(f"Config path is now {self.config_path.resolve()}")
self.save_config()
dialog = QFileDialog(self)
dialog.setDefaultSuffix(CONFIG_SUFFIX)
dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptSave)
if dialog.exec():
config_path = Path(dialog.selectedFiles()[0])
print(config_path)
if config_path.suffix != CONFIG_SUFFIX:
raise ValueError(
f"{config_path.name} is not a valid configuration file. Must have extension {CONFIG_SUFFIX}"
)
self.config_path = config_path
print(f"Config path is now {self.config_path.resolve()}")
self.save_config()
def open_config(self) -> None:
print("Prompting for load path...")
dialog = QFileDialog(self)
dialog.setNameFilter(f"*{CONFIG_SUFFIX}")
dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptOpen)
if dialog.exec():
config_path = Path(dialog.selectedFiles()[0])
print(config_path)
if config_path.suffix != CONFIG_SUFFIX:
raise ValueError(
f"{config_path.name} is not a valid configuration file. Must have extension {CONFIG_SUFFIX}"
)
self.config_path = config_path
print(f"Config path is now {self.config_path.resolve()}")
self.load_config(self.config_path)
def save_config(self) -> None:
if self.config_path is None:
self.saveas_config()
else:
print(f"saving config to {self.config_path.resolve()}")
print(f"Saving config to {self.config_path.resolve()}")
# TODO: save config
def load_config(self) -> None:
print("loading config")
def load_config(self, path: Path) -> None:
print(f"Loading config from {path}...")
# TODO: load config
def generate_sim_data(self) -> None:
coords = {"frequency": np.linspace(1e9, 2e9, 101), "m": [1], "n": [1]}
coords = {"frequency": self._frequency, "m": [1], "n": [1]}
shape = tuple(len(v) for v in coords.values())
data = xr.DataArray(
((-1 + 2 * np.random.rand(*shape)) + 1j * (-1 + 2 * np.random.rand(*shape))) / np.sqrt(2),
@ -258,6 +289,20 @@ class MainWindow(QMainWindow):
for plot in self.plots:
plot.update_plot(data)
def set_frequency(self, *, frequency: npt.ArrayLike | None = None):
print(frequency)
if frequency is None:
start, ok = QInputDialog.getDouble(
self, "Start Frequency", "Start Frequency", minValue=30e6, maxValue=6e9, value=1e9
)
stop, ok = QInputDialog.getDouble(
self, "Stop Frequency", "Stop Frequency", minValue=30e6, maxValue=6e9, value=2e9
)
points, ok = QInputDialog.getInt(self, "Points", "Points", minValue=2, value=101)
frequency = np.linspace(start, stop, points)
# Currently does not support zero span
self._frequency = frequency
def main() -> None:
app = QApplication(sys.argv)

View File

@ -4,7 +4,8 @@ from pathlib import Path
from typing import Any, Dict, Tuple
import adi
import iio
# import iio
import numpy as np
import skrf as rf
import xarray as xr
@ -140,8 +141,9 @@ class Charon:
self.set_output_power(power)
self.sdr.tx_lo = int(frequency - self.FREQUENCY_OFFSET)
self.sdr.tx_cyclic_buffer = True
# self.sdr.tx(generate_tone(f=self.FREQUENCY_OFFSET, fs=self.sdr.sample_rate))
self.sdr.dds_single_tone(self.FREQUENCY_OFFSET, scale=0.9, channel=0)
# For some reason the pluto's DDS has truly horrendous phase noise to the point where it looks modulated
self.sdr.tx(generate_tone(f=self.FREQUENCY_OFFSET, fs=self.sdr.sample_rate))
# self.sdr.dds_single_tone(self.FREQUENCY_OFFSET, scale=0.9, channel=0)
def _rx(self, count: int = 1, fc: float | None = None) -> npt.ArrayLike:
if count < 1:
@ -229,158 +231,159 @@ class Charon:
# %%
sdr = Charon("ip:192.168.3.1", frequency=np.linspace(1e9, 1.1e9, 11))
if __name__ == "__main__":
pass
# %% initialization
config = sdr.get_config()
# print(sdr.ctrl.debug_attrs["adi,rx-rf-port-input-select"].value)
# print(sdr.ctrl.debug_attrs["adi,tx-rf-port-input-select"].value)
config
# %%
sdr = Charon("ip:192.168.3.1", frequency=np.linspace(1e9, 1.1e9, 11))
# %% generate tone
fc = 1e9
sdr.set_output(frequency=fc + sdr.FREQUENCY_OFFSET, power=-5)
# %% initialization
config = sdr.get_config()
# print(sdr.ctrl.debug_attrs["adi,rx-rf-port-input-select"].value)
# print(sdr.ctrl.debug_attrs["adi,tx-rf-port-input-select"].value)
config
# %% capture data
data = sdr._rx(1, fc=fc)
# %% generate tone
fc = 1e9
sdr.set_output(frequency=fc + sdr.FREQUENCY_OFFSET, power=-5)
# %% Plot in time
fig, axs = plt.subplots(2, 1, sharex=True, tight_layout=True)
axs[0].plot(np.real(data).T)
axs[1].plot(np.imag(data).T)
axs[0].set_ylabel("Real")
axs[1].set_ylabel("Imag")
axs[0].grid(True)
axs[1].grid(True)
axs[-1].set_xlabel("Sample")
axs[-1].set_xlim(0, data.shape[-1])
fig.show()
# %% capture data
data = sdr._rx(1, fc=fc)
# %%
fig, ax = plt.subplots(1, 1, tight_layout=True)
ax.plot(np.real(data).T, np.imag(data).T)
ax.grid(True)
ax.set_aspect("equal")
ax.set_xlabel("Real")
ax.set_ylabel("Imag")
ax.set_xlim(np.array([-1, 1]) * (2 ** (12 - 1) - 1))
ax.set_ylim(ax.get_xlim())
fig.show()
# %% Plot in time
fig, axs = plt.subplots(2, 1, sharex=True, tight_layout=True)
axs[0].plot(np.real(data).T)
axs[1].plot(np.imag(data).T)
axs[0].set_ylabel("Real")
axs[1].set_ylabel("Imag")
axs[0].grid(True)
axs[1].grid(True)
axs[-1].set_xlabel("Sample")
axs[-1].set_xlim(0, data.shape[-1])
fig.show()
# %% Plot in frequency
f = np.fft.fftfreq(data.shape[-1], 1 / sdr.sdr.sample_rate)
RX_BITS = 12 # for each of i, q (including sign bit)
fft_data = np.fft.fft(data, axis=-1, norm="forward") / (2 ** (RX_BITS - 1))
plt.figure()
for cc, chan in enumerate(sdr.sdr.rx_enabled_channels):
plt.plot(
np.fft.fftshift(f),
db20(np.fft.fftshift(fft_data[cc])),
label=f"Channel {chan}",
# %%
fig, ax = plt.subplots(1, 1, tight_layout=True)
ax.plot(np.real(data).T, np.imag(data).T)
ax.grid(True)
ax.set_aspect("equal")
ax.set_xlabel("Real")
ax.set_ylabel("Imag")
ax.set_xlim(np.array([-1, 1]) * (2 ** (12 - 1) - 1))
ax.set_ylim(ax.get_xlim())
fig.show()
# %% Plot in frequency
f = np.fft.fftfreq(data.shape[-1], 1 / sdr.sdr.sample_rate)
RX_BITS = 12 # for each of i, q (including sign bit)
fft_data = np.fft.fft(data, axis=-1, norm="forward") / (2 ** (RX_BITS - 1))
plt.figure()
for cc, chan in enumerate(sdr.sdr.rx_enabled_channels):
plt.plot(
np.fft.fftshift(f),
db20(np.fft.fftshift(fft_data[cc])),
label=f"Channel {chan}",
)
plt.legend()
plt.ylim(-100, 0)
plt.xlabel("Frequency [Hz]")
plt.ylabel("Power [dBfs]")
plt.title(f"Fc = {sdr.sdr.rx_lo / 1e9} GHz")
plt.gca().xaxis.set_major_formatter(EngFormatter())
plt.grid(True)
plt.show()
# %%
s = sdr.vna_capture(frequency=np.linspace(70e6, 200e6, 101))
# %% Plot Logmag
fig, axs = plt.subplots(2, 1, sharex=True, tight_layout=True)
axs[0].plot(s.frequency, db20(s), label="Measured")
axs[1].plot(s.frequency, np.rad2deg(np.angle((s))), label="Measured")
axs[0].grid(True)
axs[1].grid(True)
axs[0].set_ylim(-80, 0)
axs[1].set_ylim(-200, 200)
axs[1].set_xlim(np.min(s.frequency), np.max(s.frequency))
axs[1].xaxis.set_major_formatter(EngFormatter(places=1))
axs[1].set_xlabel("Frequency")
axs[0].set_ylabel("|S11| [dB]")
axs[1].set_ylabel("∠S11 [deg]")
reference_sparams = None
reference_sparams = dir_ / "RBP-135+_Plus25degC.s2p"
if reference_sparams is not None:
ref = rf.Network(reference_sparams)
rbp135 = net2s(ref)
axs[0].plot(rbp135.frequency, db20(rbp135.sel(m=1, n=1)), label="Datasheet")
axs[1].plot(rbp135.frequency, np.rad2deg(np.angle(rbp135.sel(m=2, n=1))), label="Datasheet")
axs[0].legend()
axs[1].legend()
plt.show()
# %% SOL calibration
cal_frequency = np.linspace(70e6, 600e6, 101)
ideal_cal_frequency = rf.Frequency(np.min(cal_frequency), np.max(cal_frequency), len(cal_frequency))
input("Connect SHORT and press ENTER...")
short = sdr.vna_capture(frequency=cal_frequency)
input("Connect OPEN and press ENTER...")
open = sdr.vna_capture(frequency=cal_frequency)
input("Connect LOAD and press ENTER...")
load = sdr.vna_capture(frequency=cal_frequency)
short_net = s2net(short)
open_net = s2net(open)
load_net = s2net(load)
cal_ideal = rf.media.DefinedGammaZ0(frequency=ideal_cal_frequency)
calibration = rf.calibration.OnePort(
[short_net, open_net, load_net],
[cal_ideal.short(), cal_ideal.open(), cal_ideal.load(0)],
)
plt.legend()
plt.ylim(-100, 0)
plt.xlabel("Frequency [Hz]")
plt.ylabel("Power [dBfs]")
plt.title(f"Fc = {sdr.sdr.rx_lo / 1e9} GHz")
plt.gca().xaxis.set_major_formatter(EngFormatter())
plt.grid(True)
plt.show()
# %%
s = sdr.vna_capture(frequency=cal_frequency)
# %%
s = sdr.vna_capture(frequency=np.linspace(70e6, 200e6, 101))
# %%
s_calibrated = calibration.apply_cal(s2net(s))
# %% Plot Logmag
fig, axs = plt.subplots(2, 1, sharex=True, tight_layout=True)
plt.figure()
s_calibrated.plot_s_smith()
# ref.plot_s_smith(m=1, n=1)
plt.show()
axs[0].plot(s.frequency, db20(s), label="Measured")
axs[1].plot(s.frequency, np.rad2deg(np.angle((s))), label="Measured")
plt.figure()
for start, stop in HAM_BANDS:
plt.axvspan(start, stop, alpha=0.1, color="k")
s_calibrated.plot_s_db()
# ref.plot_s_db(m=1, n=1)
plt.gca().xaxis.set_major_formatter(EngFormatter())
plt.grid(True)
plt.xlim(s_calibrated.f[0], s_calibrated.f[-1])
plt.show()
axs[0].grid(True)
axs[1].grid(True)
plt.figure()
for start, stop in HAM_BANDS:
plt.axvspan(start, stop, alpha=0.1, color="k")
# s_calibrated.plot_s_vswr()
# drop invalid points
vswr = copy.deepcopy(s_calibrated.s_vswr[:, 0, 0])
vswr[vswr < 1] = np.nan
plt.plot(s_calibrated.f, vswr)
plt.axhline(1, color="k", linestyle="--")
plt.ylabel("VSWR")
plt.xlabel("Frequency [Hz]")
# ref.plot_s_vswr(m=1, n=1)
plt.gca().xaxis.set_major_formatter(EngFormatter())
plt.grid(True)
plt.ylim(0, 10)
plt.xlim(s_calibrated.f[0], s_calibrated.f[-1])
plt.show()
axs[0].set_ylim(-80, 0)
axs[1].set_ylim(-200, 200)
axs[1].set_xlim(np.min(s.frequency), np.max(s.frequency))
axs[1].xaxis.set_major_formatter(EngFormatter(places=1))
axs[1].set_xlabel("Frequency")
axs[0].set_ylabel("|S11| [dB]")
axs[1].set_ylabel("∠S11 [deg]")
reference_sparams = None
reference_sparams = dir_ / "RBP-135+_Plus25degC.s2p"
if reference_sparams is not None:
ref = rf.Network(reference_sparams)
rbp135 = net2s(ref)
axs[0].plot(rbp135.frequency, db20(rbp135.sel(m=1, n=1)), label="Datasheet")
axs[1].plot(rbp135.frequency, np.rad2deg(np.angle(rbp135.sel(m=2, n=1))), label="Datasheet")
axs[0].legend()
axs[1].legend()
plt.show()
# %% SOL calibration
cal_frequency = np.linspace(70e6, 600e6, 101)
ideal_cal_frequency = rf.Frequency(np.min(cal_frequency), np.max(cal_frequency), len(cal_frequency))
input("Connect SHORT and press ENTER...")
short = sdr.vna_capture(frequency=cal_frequency)
input("Connect OPEN and press ENTER...")
open = sdr.vna_capture(frequency=cal_frequency)
input("Connect LOAD and press ENTER...")
load = sdr.vna_capture(frequency=cal_frequency)
short_net = s2net(short)
open_net = s2net(open)
load_net = s2net(load)
cal_ideal = rf.media.DefinedGammaZ0(frequency=ideal_cal_frequency)
calibration = rf.calibration.OnePort(
[short_net, open_net, load_net],
[cal_ideal.short(), cal_ideal.open(), cal_ideal.load(0)],
)
# %%
s = sdr.vna_capture(frequency=cal_frequency)
# %%
s_calibrated = calibration.apply_cal(s2net(s))
plt.figure()
s_calibrated.plot_s_smith()
# ref.plot_s_smith(m=1, n=1)
plt.show()
plt.figure()
for start, stop in HAM_BANDS:
plt.axvspan(start, stop, alpha=0.1, color="k")
s_calibrated.plot_s_db()
# ref.plot_s_db(m=1, n=1)
plt.gca().xaxis.set_major_formatter(EngFormatter())
plt.grid(True)
plt.xlim(s_calibrated.f[0], s_calibrated.f[-1])
plt.show()
plt.figure()
for start, stop in HAM_BANDS:
plt.axvspan(start, stop, alpha=0.1, color="k")
# s_calibrated.plot_s_vswr()
# drop invalid points
vswr = copy.deepcopy(s_calibrated.s_vswr[:, 0, 0])
vswr[vswr < 1] = np.nan
plt.plot(s_calibrated.f, vswr)
plt.axhline(1, color="k", linestyle="--")
plt.ylabel("VSWR")
plt.xlabel("Frequency [Hz]")
# ref.plot_s_vswr(m=1, n=1)
plt.gca().xaxis.set_major_formatter(EngFormatter())
plt.grid(True)
plt.ylim(0, 10)
plt.xlim(s_calibrated.f[0], s_calibrated.f[-1])
plt.show()
# %%
# %%