25 Commits

Author SHA1 Message Date
2a5f1657f4 add some analysis of generated signal
All checks were successful
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Successful in -49s
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been skipped
2025-07-26 19:14:38 -06:00
7644cbe0ad fix path to version file
All checks were successful
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Successful in -58s
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been skipped
2025-07-21 21:40:21 -06:00
92c5876b23 switch to src-layout to avoid issues with multiple top level packages
Some checks failed
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Failing after -52s
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been skipped
2025-07-21 21:39:31 -06:00
3b12c21e20 typing + flake8
Some checks failed
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Failing after -53s
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been skipped
2025-07-16 22:00:07 -06:00
1170da8b04 plots 2025-07-16 21:56:30 -06:00
c5dc320989 2-port SOLT cal
Some checks failed
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Failing after -56s
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been skipped
2025-07-08 00:02:03 -06:00
452dddc19c functional 1 port calibration 2025-07-07 23:22:36 -06:00
3c02a4b388 allow downselecting measurements 2025-07-07 22:49:49 -06:00
339dbe255e smash some functions together
Some checks failed
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Failing after -1m4s
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been skipped
2025-07-07 22:28:21 -06:00
81143a72c4 bettah port handling
Some checks failed
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Failing after -58s
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been skipped
2025-07-07 22:15:48 -06:00
f021780971 working 2-port capture 2025-07-07 20:13:59 -06:00
6f947a28fa add really basic usage file for developing vna class outside of gui 2025-07-07 20:04:54 -06:00
581131f1e0 document and rearrange some stuff 2025-07-07 20:04:16 -06:00
adf6e40752 change default IP to what I have configured on my pluto (revert later) 2025-07-07 19:43:15 -06:00
411f96dd87 only define default ip once 2025-07-07 19:39:16 -06:00
b4e4b689ea Add basic switch control for white-wired pluto io shield
Some checks failed
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Failing after -53s
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been skipped
2025-07-07 19:36:59 -06:00
2012c37ccb add image of my janky cal standard
Some checks failed
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Failing after -1m6s
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been skipped
2025-06-26 22:00:08 -06:00
5184c05bb5 README formatting
All checks were successful
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Successful in -1m0s
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been skipped
2025-06-26 21:37:41 -06:00
8d7f87c9e6 add links to sister projects
All checks were successful
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Successful in -1m2s
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been skipped
2025-06-26 21:08:56 -06:00
776c9cc491 typing and defaults
All checks were successful
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Successful in -48s
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been skipped
2025-06-26 21:06:15 -06:00
994080e574 start a pickle-less cal read/write 2025-06-26 21:06:15 -06:00
b6cb1ecde7 DAC updates 2025-06-26 21:06:15 -06:00
383fe3ceea slight power cal improvement 2025-06-26 21:06:15 -06:00
9912e318a8 add help links 2025-06-26 21:06:15 -06:00
d8b1a56c99 rename config path 2025-06-26 21:06:15 -06:00
17 changed files with 917 additions and 93 deletions

View File

@@ -1,5 +1,9 @@
# Charon VNA
<!-- ![PyPi Downloads](https://img.shields.io/pypi/dm/charon-vna) -->
<!-- ![Last Commit](https://img.shields.io/gitea/last-commit/brendanhaines/charon-vna?gitea_url=https%3A%2F%2Fgit.brendanhaines.com) -->
<!-- ![Workflow Status](https://git.brendanhaines.com/brendanhaines/charon-vna/actions/workflows/python_publish.yml/badge.svg) -->
Named after [Pluto's moon](https://en.wikipedia.org/wiki/Charon_(moon)), Charon uses the [ADI Pluto SDR](https://www.analog.com/en/resources/evaluation-hardware-and-software/evaluation-boards-kits/adalm-pluto.html) as a vector network analyzer. The basic usage is as a 1 port VNA but this can be extended to arbitrarily many ports with the addition of a couple RF switches.
## Installation
@@ -18,13 +22,14 @@ You need a few things:
- Note that you _must_ have two receive ports which means revision C or later of the basic Pluto
- Directional couplers (1 per port up to 4 ports)
- I have been using [AAMCS-UDC-0.5G-18G-10dB-Sf](http://www.aa-mcs.com/wp-content/uploads/documents/AAMCS-UDC-0.5G-18G-10dB-Sf.pdf)
- Charon switch board - coming soon.
- [Cerberus RF switch](https://git.brendanhaines.com/brendanhaines/cerberus_sp4t) + [Pluto IO Shield](https://git.brendanhaines.com/brendanhaines/pluto_io_shield)
- Optional. Without this you'll be limited to S11 and uncalibrated S21 measurements (with required re-cabling)
- 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
- Calibration standard
- Ideally something with s-parameters measured on a better VNA
- I have used a basic SMA load and two modified SMA jacks with decent results
![calibration standard](img/calibration_standard.jpg)
### Pluto Configuration
@@ -49,6 +54,7 @@ It will also be accessible over a socket to enable test automation with external
TBD
### Power Calibration
I include a default output power lookup table. This is derived from two TX channels of two Pluto SDRs and does not include any of the loss of a coupler or Charon switch board.
Absolute output power is generally not well calibrated for VNAs anyway and has negligible impact on most measurements so this is probably sufficient for most users. If you're trying to run a power sweep this may be insufficient.
@@ -60,11 +66,13 @@ Note that unlike the main calibration, power calibration frequencies do not need
## References
#### Pluto Default Connection Settings
user: `root`
password: `analog`
ip: `192.168.2.1`
- user: `root`
- password: `analog`
- ip: `192.168.2.1`
## Alternatives
- [NanoVNA](https://nanovna.com/). 2-ports. 50 kHz - 2.7 GHz. Degraded performance above 1.5 GHz. S11 and S21 only.
- [pluto-network-analyzer](https://github.com/fromconcepttocircuit/pluto-network-analyzer). 2-ports. 100 MHz - 3 GHz. S11 and S21 only. Uses a [wideband RF bridge](https://www.60dbm.com/product/rf-bridge-1-3000-mhz/) instead of a coupler
- [LibreVNA](https://github.com/jankae/LibreVNA). 2-ports. 100 KHz - 6 GHz. I've never used this but it is almost certainly faster than Charon. Not sure how the performance compares. $700 on [AliExpress](https://www.aliexpress.us/item/3256802242049773.html?gatewayAdapt=glo2usa4itemAdapt)

View File

@@ -1,11 +0,0 @@
from pathlib import Path
import skrf as rf
from util import net2s
# scikit-rf has no way to save files aside from touchstone and pickle
def cal2zarr(cal: rf.calibration.Calibration, outpath: Path):
ideals = [net2s(net) for net in cal.ideals]
measured = [net2s(net) for net in cal.measured]
# s.to_zarr(outpath)

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 KiB

View File

@@ -9,7 +9,7 @@ description = "RF Network Analyzer based on the Pluto SDR"
readme = "README.md"
requires-python = ">=3"
# keywords = ["one", "two"]
license = { text = "MIT License" }
license = "MIT"
classifiers = ["Programming Language :: Python :: 3"]
dependencies = [
"numpy",
@@ -34,7 +34,7 @@ charon-cli = "charon_vna.cli:main"
charon-gui = "charon_vna.gui:main"
[tool.setuptools_scm]
version_file = "charon_vna/_version.py"
version_file = "src/charon_vna/_version.py"
[tool.black]
line-length = 120

View File

@@ -3,14 +3,14 @@ import subprocess
import numpy as np
from charon_vna.gui import DEFAULT_CONFIG
from charon_vna.gui import PATH_CONFIG_DEFAULT
config = dict(
frequency=np.linspace(80e6, 500e6, 500).tolist(),
power=-5,
)
with open(DEFAULT_CONFIG, "w") as f:
with open(PATH_CONFIG_DEFAULT, "w") as f:
json.dump(config, f)
# autoformat
@@ -19,7 +19,7 @@ subprocess.run(
"python",
"-m",
"json.tool",
DEFAULT_CONFIG.resolve().as_posix(),
DEFAULT_CONFIG.resolve().as_posix(),
PATH_CONFIG_DEFAULT.resolve().as_posix(),
PATH_CONFIG_DEFAULT.resolve().as_posix(),
]
)

View File

@@ -3,6 +3,7 @@ import json
import pickle
import re
import sys
import webbrowser
from pathlib import Path
from typing import List
@@ -14,7 +15,6 @@ from numpy import typing as npt
from PySide6.QtGui import QAction, QKeySequence
from PySide6.QtWidgets import (
QApplication,
QDialogButtonBox,
QFileDialog,
QInputDialog,
QLineEdit,
@@ -31,7 +31,7 @@ from charon_vna.util import net2s, s2net
from charon_vna.vna import Charon
# %%
DEFAULT_CONFIG = Path(__file__).parent / "config_default.json"
PATH_CONFIG_DEFAULT = Path(__file__).parent / "config_default.json"
CONFIG_SUFFIX = ".json"
@@ -44,7 +44,7 @@ class MainWindow(QMainWindow):
def __init__(self, ip: str | None = None):
super().__init__()
self.config_path = DEFAULT_CONFIG
self.config_path = PATH_CONFIG_DEFAULT
with open(self.config_path, "r") as f:
config = json.load(f)
self._frequency = config["frequency"]
@@ -56,6 +56,9 @@ class MainWindow(QMainWindow):
vna_kwargs["ip"] = ip
self.vna = Charon(**vna_kwargs)
self.active_port = 0
self.vna.set_switches(a=self.active_port, b=self.active_port)
mpl.use("QtAgg")
self.setWindowTitle("Charon VNA")
@@ -90,6 +93,9 @@ class MainWindow(QMainWindow):
action_trigger.triggered.connect(self.capture)
action_trigger.setShortcut("Ctrl+T")
menu_stimulus.addAction(action_trigger)
action_p0 = QAction("Switch &Port", self)
action_p0.triggered.connect(self.toggle_port)
menu_stimulus.addAction(action_p0)
menu_calibration = QMenu("&Calibration")
menubar.addMenu(menu_calibration)
@@ -97,6 +103,19 @@ class MainWindow(QMainWindow):
action_cal_solt.triggered.connect(self.calibrate_solt)
menu_calibration.addAction(action_cal_solt)
menu_help = QMenu("&Help")
menubar.addMenu(menu_help)
action_open_homepage = QAction("&Documentation", self)
action_open_homepage.triggered.connect(
lambda: webbrowser.open("https://git.brendanhaines.com/brendanhaines/charon_vna")
)
menu_help.addAction(action_open_homepage)
action_report_issue = QAction("&Report an Issue", self)
action_report_issue.triggered.connect(
lambda: webbrowser.open("https://git.brendanhaines.com/brendanhaines/charon_vna/issues")
)
menu_help.addAction(action_report_issue)
# Content
window_layout = QVBoxLayout()
@@ -126,9 +145,15 @@ class MainWindow(QMainWindow):
widget.setLayout(window_layout)
self.setCentralWidget(widget)
def toggle_port(self):
self.active_port = int(not self.active_port)
print(f"Activating port {self.active_port}")
self.vna.set_switches(a=self.active_port, b=self.active_port)
def saveas_config(self) -> None:
print("Prompting for save path...")
dialog = QFileDialog(self)
dialog.setNameFilter(f"*{CONFIG_SUFFIX}")
dialog.setDefaultSuffix(CONFIG_SUFFIX)
dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptSave)
if dialog.exec():
@@ -137,8 +162,8 @@ class MainWindow(QMainWindow):
raise ValueError(
f"{config_path.name} is not a valid configuration file. Must have extension {CONFIG_SUFFIX}"
)
if config_path == DEFAULT_CONFIG:
raise ValueError(f"Cannot overwrite default configuration file at {DEFAULT_CONFIG}")
if config_path == PATH_CONFIG_DEFAULT:
raise ValueError(f"Cannot overwrite default configuration file at {PATH_CONFIG_DEFAULT}")
self.config_path = config_path
print(f"Config path is now {self.config_path.resolve()}")
@@ -162,7 +187,7 @@ class MainWindow(QMainWindow):
self.load_config(self.config_path)
def save_config(self) -> None:
if self.config_path == DEFAULT_CONFIG:
if self.config_path == PATH_CONFIG_DEFAULT:
self.saveas_config()
else:
print(f"Saving config to {self.config_path.resolve()}")
@@ -249,7 +274,7 @@ def main() -> None:
"Pluto IP Address",
"Enter Pluto IP Address",
QLineEdit.Normal,
"192.168.2.1",
Charon.DEFAULT_IP,
)
match = re.match(r"(\d{1,3}\.){3}\d{1,3}", text)
if not match:

94
src/charon_vna/io_.py Normal file
View File

@@ -0,0 +1,94 @@
import json
import shutil
import zipfile
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import List
import skrf as rf
# scikit-rf has no way to save Calibration objects aside from pickle
def cal2zip(cal: rf.calibration.Calibration, path: Path | str) -> None:
path = Path(path)
cal_type = cal.__class__
measured: List[rf.network.Network] = cal.measured
ideals: List[rf.network.Network] = cal.ideals
if cal_type not in [rf.calibration.OnePort]:
raise NotImplementedError(f"Calibration {cal_type.__name__} serialization not implemented")
assert len(ideals) == len(measured) # this should have already been asserted when cal was instantiated
with TemporaryDirectory() as temp:
dir_temp = Path(temp)
with zipfile.ZipFile(dir_temp / "archive.zip", "w") as archive:
# create a configuration file
filename_config = dir_temp / "config.json"
with open(filename_config, "w") as f:
json.dump(
dict(
cal_type=cal_type.__name__,
num_standards=len(measured),
),
f,
)
archive.write(filename_config, str(filename_config.relative_to(dir_temp)))
# add standard data
dir_ideals = dir_temp / "ideals"
dir_ideals.mkdir()
for ii, ideal in enumerate(ideals):
filename = dir_ideals / f"{ii}.s2p"
ideal.write_touchstone(filename)
archive.write(filename, str(filename.relative_to(dir_temp)))
# add test data
dir_measured = dir_temp / "measured"
dir_measured.mkdir()
for ii, meas in enumerate(measured):
filename = dir_measured / f"{ii}.s2p"
meas.write_touchstone(filename)
archive.write(filename, str(filename.relative_to(dir_temp)))
print("Wrote calibration to file")
archive.printdir()
shutil.move(dir_temp / "archive.zip", path)
def zip2cal(path: Path | str):
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"Calibration file {path} does not exist")
with zipfile.ZipFile(path) as archive:
archive.printdir()
config = json.loads(archive.read("config.json"))
print(config)
ideals = list()
measured = list()
with TemporaryDirectory() as temp:
dir_temp = Path(temp)
for ii in range(config["num_standards"]):
with open(dir_temp / f"{ii}.s2p", "wb") as f:
f.write(archive.read(f"ideals/{ii}.s2p"))
ideals.append(rf.network.Network(dir_temp / f"{ii}.s2p"))
with open(dir_temp / f"{ii}.s2p", "wb") as f:
f.write(archive.read(f"measured/{ii}.s2p"))
measured.append(rf.network.Network(dir_temp / f"{ii}.s2p"))
cal_type = config["cal_type"]
CalClass = getattr(rf.calibration, cal_type)
if not issubclass(CalClass, rf.calibration.Calibration):
raise ValueError()
calibration = CalClass(measured=measured, ideals=ideals)
return calibration

View File

@@ -1,8 +1,9 @@
# %% imports
import copy
import pickle
from enum import IntEnum, unique
from pathlib import Path
from typing import Any, Callable, Dict, Literal, Tuple
from typing import Any, Callable, Dict, List, Literal, Tuple
import adi
import iio
@@ -66,17 +67,35 @@ class AD9361Register(IntEnum):
AUXDAC2_TX_DELAY = 0x033
@unique
class AD9361DacVref(IntEnum):
VREF_1V0 = 0b00
VREF_1V5 = 0b01
VREF_2V0 = 0b10
VREF_2V5 = 0b11
@unique
class AD9361DacStepFactor(IntEnum):
FACTOR_2 = 0b0
FACTOR_1 = 0b1
class Charon:
FREQUENCY_OFFSET = 1e6
DEFAULT_IP = "192.168.3.1"
calibration: rf.calibration.Calibration | None = None
def __init__(
self,
ip: str = "192.168.2.1",
ip: str = DEFAULT_IP,
frequency: npt.ArrayLike = np.linspace(1e9, 2e9, 3),
ports: Tuple[int] = (1,),
ports: Tuple[int] | int = 1,
):
if isinstance(ports, int):
ports = (np.arange(ports) + 1).tolist()
ports = tuple(ports)
self.ports = ports
self.frequency = frequency
@@ -113,17 +132,22 @@ class Charon:
self.sdr.rx_hardwaregain_chan1 = 10
self.sdr.tx_hardwaregain_chan0 = -10
# # switch control
# switch control
ctx = iio.Context(uri)
self.ctrl = ctx.find_device("ad9361-phy")
# raw ad9361 register accesss:
# https://ez.analog.com/linux-software-drivers/f/q-a/120853/control-fmcomms3-s-gpo-with-python
# https://www.analog.com/media/cn/technical-documentation/user-guides/ad9364_register_map_reference_manual_ug-672.pdf # noqa: E501
# https://www.analog.com/media/cn/technical-documentation/user-guides/ad9364_register_map_reference_manual_ug-672.pdf
self.ctrl.reg_write(AD9361Register.EXTERNAL_LNA_CONTROL, 0x90) # bit 7: AuxDAC Manual, bit 4: GPO Manual
self.ctrl.reg_write(AD9361Register.AUXDAC_ENABLE_CONTROL, 0x3F)
# initialize switch control outputs
self._set_gpo(0b0000)
self._set_dac(value=0, channel=1)
self._set_dac(value=0, channel=2)
self._set_dac_code(value=0, channel=1)
self._set_dac_code(value=0, channel=2)
# set default switch state
self.set_switches(a=self.ports[0] - 1, b=self.ports[0] - 1)
def get_config(self) -> Dict[str, Any]:
config = dict()
@@ -153,28 +177,40 @@ class Charon:
def _set_gpo(self, value: int) -> None:
self.ctrl.reg_write(AD9361Register.GPO_FORCE_AND_INIT, (value & 0x0F) << 4) # bits 7-4: GPO3-0
def _set_dac(self, value: int, channel: Literal[1, 2]):
def _get_dac_code(self, channel: Literal[1, 2]) -> Tuple[float, AD9361DacVref, AD9361DacStepFactor]:
word = self.ctrl.reg_read(AD9361Register.__getitem__(f"AUXDAC{channel}_WORD"))
config = self.ctrl.reg_read(AD9361Register.__getitem__(f"AUXDAC{channel}_CONFIG"))
value = (word << 2) + (config & 0x3)
vref = AD9361DacVref((config >> 2) & 0x3)
step_factor = AD9361DacStepFactor((config >> 4) & 0x1)
return (value, vref, step_factor)
def _set_dac_code(
self,
value: int,
channel: Literal[1, 2],
vref: AD9361DacVref = AD9361DacVref.VREF_1V0,
step_factor: AD9361DacStepFactor = AD9361DacStepFactor.FACTOR_2,
) -> None:
if channel not in [1, 2]:
raise ValueError(f"Invalid channel {channel}. Must be 1 or 2")
if value > 0x3FF or value < 0:
raise ValueError("Invalid value for 10 bit DAC. Must be between 0 and 0x3FF (inclusive)")
@unique
class Vref(IntEnum):
VREF_1V0 = 0b00
VREF_1V5 = 0b01
VREF_2V0 = 0b10
VREF_2V5 = 0b11
@unique
class StepFactor(IntEnum):
FACTOR_2 = 0b0
FACTOR_1 = 0b1
vref = AD9361DacVref(vref)
step_factor = AD9361DacStepFactor(step_factor)
# https://www.analog.com/media/cn/technical-documentation/user-guides/ad9364_register_map_reference_manual_ug-672.pdf
# page 13
# vout = 0.97 * vref + (0.000738 + 9e-6 * (vref * 1.6 - 2)) * auxdac_word[9:0] * step_factor - 0.3572 * step_factor + 0.05
# vout = (
# 0.97 * vref
# + (0.000738 + 9e-6 * (vref * 1.6 - 2)) * auxdac_word[9:0] * step_factor
# - 0.3572 * step_factor
# + 0.05
# )
# vout ~= (vref - 0.3572 * step_factor) + 0.000738 * auxdac_word[9:0] * step_factor
# which gives a 1.5V swing with step_factor == 2 and 0.75V swing with step_factor == 1
# vref basically just changes the minimum voltage with negligible impact on output scaling
@@ -185,26 +221,36 @@ class Charon:
)
self.ctrl.reg_write(
AD9361Register.__getitem__(f"AUXDAC{channel}_CONFIG"),
(value & 0x3) | (Vref.VREF_1V0.value << 2) | (StepFactor.FACTOR_2 << 4),
(value & 0x3) | (vref.value << 2) | (step_factor << 4),
)
def set_switches(self, b: int, a: int, excitation: int | None = None):
if excitation is None:
excitation = a
val = 0
val |= int(bool(excitation)) << 0 # exc = GPO0
val |= int(bool(a)) << 2 # a = GPO2
val |= int(bool(b)) << 1 # b = GPO1
self._set_gpo(val)
def set_output_power(self, power: float):
# FIXME: this is a hack because I don't want to go through re-calibration
if power == 5:
tx_gain = -1
elif power == 0:
tx_gain = -7
elif power == -5:
tx_gain = -12
elif power == -10:
tx_gain = -17
elif power == -15:
tx_gain = -22
else:
raise NotImplementedError()
# # TODO: correct over frequency
# tx_gain_idx = np.abs(pout.sel(tx_channel=0) - power).argmin(dim="tx_gain")
# tx_gain = pout.coords["tx_gain"][tx_gain_idx]
pout = xr.DataArray(
[-15, -10, -5, 0, 5],
dims=["tx_gain"],
coords=dict(
# TODO: correct over frequency
frequency=1e9, # FIXME: I'm not sure at what frequency I generated this table
tx_channel=0,
tx_gain=[-22, -17, -12, -7, -1],
),
)
tx_gain_idx = np.abs(pout - power).argmin(dim="tx_gain")
tx_gain = pout.coords["tx_gain"][tx_gain_idx]
self.sdr.tx_hardwaregain_chan0 = float(tx_gain)
def set_output(self, frequency: float, power: float):
@@ -265,32 +311,42 @@ class Charon:
return np.mean(data[1] / data[0])
def sweep_b_over_a(self):
def capture(
self,
callback: Callable[[int, int], None] | None = None,
*,
measurements: List[Tuple[int, int]] = None,
):
if measurements is None:
measurements = [(m, n) for n in self.ports for m in self.ports]
measurements = list(measurements)
s = xr.DataArray(
np.zeros(
len(self.frequency),
[len(self.frequency), len(self.ports), len(self.ports)],
dtype=np.complex128,
),
dims=["frequency"],
dims=["frequency", "m", "n"],
coords=dict(
frequency=self.frequency,
m=list(self.ports),
n=list(self.ports),
),
)
for frequency in self.frequency:
s.loc[dict(frequency=frequency)] = self.get_b_over_a(frequency=frequency)
return s
def vna_capture(self, frequency: npt.ArrayLike, callback: Callable[int, int] | None):
s = xr.DataArray(
np.empty(len(frequency), dtype=np.complex128),
dims=["frequency"],
coords=dict(
frequency=frequency,
),
)
total_count = len(measurements) * len(s.frequency)
count = 0
for m in s.m.data:
for n in s.n.data:
if (m, n) in measurements:
self.set_switches(b=m - 1, a=n - 1)
for ff, freq in enumerate(s.frequency.data):
if callback is not None:
callback(ff, len(s.frequency))
# report progress during sweep
callback(count, total_count)
self.set_output(frequency=freq, power=-5)
self.sdr.rx_destroy_buffer()
self.sdr.rx_lo = int(freq)
@@ -300,12 +356,102 @@ class Charon:
self.sdr.rx_hardwaregain_chan0 = 40
self.sdr.rx_hardwaregain_chan1 = 40
rx = self.sdr.rx()
s.loc[dict(frequency=freq)] = np.mean(rx[1] / rx[0])
s.loc[dict(frequency=freq, m=m, n=n)] = np.mean(rx[1] / rx[0])
count += 1
if callback is not None:
callback(len(s.frequency), len(s.frequency))
# mark capture as complete
callback(total_count, total_count)
return s
def calibrate_sol(self, prompt: Callable[[str], None] | None = None, **kwargs) -> None:
if len(self.ports) != 1:
raise ValueError(
f"SOL calibration needs only one port but {len(self.ports)} ports are enabled. "
"Did you mean to use SOLT?"
)
if prompt is None:
prompt = lambda s: input(f"{s}\nENTER to continue...")
ideal = rf.media.DefinedGammaZ0(frequency=rf.media.Frequency.from_f(self.frequency, unit="Hz"))
ideals = [ideal.short(), ideal.open(), ideal.load(0)]
names = ["short", "open", "load"]
measured = list()
for name in names:
prompt(f"Connect standard {name} to port {self.ports[0]}")
measured.append(self.capture(**kwargs))
cal = rf.OnePort(measured=[s2net(m) for m in measured], ideals=ideals)
self.calibration = cal
def calibrate_solt(self, prompt: Callable[[str], None] | None = None, **kwargs) -> None:
if len(self.ports) < 2:
raise ValueError(
f"SOLT calibration needs at least two ports but {len(self.ports)} ports are enabled. "
"Did you mean to use SOL?"
)
if len(self.ports) > 2:
raise NotImplementedError("SOLT calibration with more than two ports not yet supported")
if prompt is None:
prompt = lambda s: input(f"{s}\nENTER to continue...")
ideal = rf.media.DefinedGammaZ0(frequency=rf.media.Frequency.from_f(self.frequency, unit="Hz"))
ideals = [ideal.short(), ideal.open(), ideal.load(0)]
ideals = [rf.two_port_reflect(id, id) for id in ideals]
thru = np.zeros((len(self.frequency), 2, 2), dtype=np.complex128)
thru[:, 0, 1] = 1
thru[:, 1, 0] = 1
thru = rf.Network(frequency=self.frequency, f_unit="Hz", s=thru)
ideals.append(thru)
names_1p = ["short", "open", "load"]
names_2p = ["thru"]
measured = list()
for name in names_1p:
measured_param = list()
for port in self.ports:
prompt(f"Connect standard {name} to port {port}")
measured_param.append(self.capture(measurements=[(port, port)], **kwargs).sel(m=port, n=port))
measured.append(rf.two_port_reflect(*[s2net(m) for m in measured_param]))
for name in names_2p:
prompt(f"Connect standard {name} between ports {self.ports[0]} and {self.ports[1]}")
measured.append(s2net(self.capture(**kwargs)))
cal = rf.SOLT(measured=measured, ideals=ideals)
self.calibration = cal
def save_calibration(self, path: Path | str):
path = Path(path)
if path.suffix.lower() == ".pkl":
with open(str(path), "wb") as f:
pickle.dump(self.calibration, f)
else:
raise NotImplementedError(f"Unknown calibration file extension: {path.suffix}")
def load_calibration(self, path: Path | str):
path = Path(path)
if path.suffix.lower() == ".pkl":
with open(str(path), "rb") as f:
cal = pickle.load(f)
if not isinstance(cal, rf.calibration.Calibration):
raise ValueError(f"Expected {rf.calibration.Calibration}, got {type(cal)}")
self.calibration = cal
else:
raise NotImplementedError(f"Unknown calibration file extension: {path.suffix}")
# %%
if __name__ == "__main__":

81
src/charon_vna/vna_dev.py Normal file
View File

@@ -0,0 +1,81 @@
# %% imports
import numpy as np
from matplotlib import pyplot as plt
from matplotlib.ticker import EngFormatter
from charon_vna.util import db20, net2s, s2net
from charon_vna.vna import Charon
# %%
frequency = np.linspace(80e6, 280e6, 301)
# %%
vna = Charon(frequency=frequency, ports=2)
# %%
s = vna.capture()
# %%
for m in s.m.data:
for n in s.n.data:
plt.plot(s.frequency, db20(s.sel(m=m, n=n)), label="$S_{" + str(m) + str(n) + "}$")
plt.grid(True)
plt.legend()
plt.show()
# %%
vna.calibrate_sol()
# %%
vna.calibrate_solt()
# %%
vna.save_calibration("./calibration.pkl")
# %%
vna.load_calibration("./calibration.pkl")
# %%
s2 = net2s(vna.calibration.apply_cal(s2net(s)))
# s2.coords["m"] = s.m
# s2.coords["n"] = s.n
for m in s.m.data:
for n in s.n.data:
plt.plot(s.frequency, db20(s.sel(m=m, n=n)), label="$S_{" + str(m) + str(n) + "}$ (uncalibrated)")
plt.plot(s2.frequency, db20(s2.sel(m=m, n=n)), label="$S_{" + str(m) + str(n) + "}$ (calibrated)")
plt.grid(True)
plt.legend()
plt.xlabel("Frequency [Hz]")
plt.ylabel("Magnitude [dB]")
# plt.ylim(-30, 5)
plt.ylim(-25, 5)
plt.xlim(s.frequency[0], s.frequency[-1])
plt.gca().xaxis.set_major_formatter(EngFormatter())
plt.tight_layout()
plt.show()
for m in s.m.data:
for n in s.n.data:
if m != n:
plt.plot(
s.frequency,
np.angle(s.sel(m=m, n=n), deg=True),
label="$S_{" + str(m) + str(n) + "}$ (uncalibrated)",
)
plt.plot(
s2.frequency,
np.angle(s2.sel(m=m, n=n), deg=True),
label="$S_{" + str(m) + str(n) + "}$ (calibrated)",
)
plt.grid(True)
plt.legend()
plt.ylabel("Phase [deg]")
plt.xlabel("Frequency [Hz]")
plt.xlim(s.frequency[0], s.frequency[-1])
plt.gca().xaxis.set_major_formatter(EngFormatter())
plt.tight_layout()
plt.show()
# %%