Compare commits
25 Commits
505b374e8f
...
main
Author | SHA1 | Date | |
---|---|---|---|
2a5f1657f4 | |||
7644cbe0ad | |||
92c5876b23 | |||
3b12c21e20 | |||
1170da8b04 | |||
c5dc320989 | |||
452dddc19c | |||
3c02a4b388 | |||
339dbe255e | |||
81143a72c4 | |||
f021780971 | |||
6f947a28fa | |||
581131f1e0 | |||
adf6e40752 | |||
411f96dd87 | |||
b4e4b689ea | |||
2012c37ccb | |||
5184c05bb5 | |||
8d7f87c9e6 | |||
776c9cc491 | |||
994080e574 | |||
b6cb1ecde7 | |||
383fe3ceea | |||
9912e318a8 | |||
d8b1a56c99 |
16
README.md
16
README.md
@@ -1,5 +1,9 @@
|
||||
# Charon VNA
|
||||
|
||||
<!--  -->
|
||||
<!--  -->
|
||||
<!--  -->
|
||||
|
||||
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
|
||||

|
||||
|
||||
### 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)
|
@@ -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)
|
481
examples/spectral_purity.ipynb
Normal file
481
examples/spectral_purity.ipynb
Normal file
File diff suppressed because one or more lines are too long
BIN
img/calibration_standard.jpg
Normal file
BIN
img/calibration_standard.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 766 KiB |
@@ -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
|
||||
|
@@ -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(),
|
||||
]
|
||||
)
|
@@ -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
94
src/charon_vna/io_.py
Normal 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
|
@@ -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,47 +311,147 @@ 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,
|
||||
),
|
||||
)
|
||||
for ff, freq in enumerate(s.frequency.data):
|
||||
if callback is not None:
|
||||
callback(ff, len(s.frequency))
|
||||
self.set_output(frequency=freq, power=-5)
|
||||
self.sdr.rx_destroy_buffer()
|
||||
self.sdr.rx_lo = int(freq)
|
||||
self.sdr.rx_enabled_channels = [0, 1]
|
||||
self.sdr.gain_control_mode_chan0 = "manual"
|
||||
self.sdr.gain_control_mode_chan1 = "manual"
|
||||
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])
|
||||
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:
|
||||
# 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)
|
||||
self.sdr.rx_enabled_channels = [0, 1]
|
||||
self.sdr.gain_control_mode_chan0 = "manual"
|
||||
self.sdr.gain_control_mode_chan1 = "manual"
|
||||
self.sdr.rx_hardwaregain_chan0 = 40
|
||||
self.sdr.rx_hardwaregain_chan1 = 40
|
||||
rx = self.sdr.rx()
|
||||
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
81
src/charon_vna/vna_dev.py
Normal 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()
|
||||
|
||||
# %%
|
Reference in New Issue
Block a user