Compare commits

...

19 Commits
v0.2.0 ... main

Author SHA1 Message Date
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
505b374e8f Update README.md
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 08:35:30 -06:00
c73533b156 Update README.md
All checks were successful
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Successful in -41s
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been skipped
2025-06-26 08:13:33 -06:00
594c72f2bf Update README.md
All checks were successful
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Successful in -1m1s
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been skipped
2025-06-04 14:14:59 -06:00
43d9486edf Update README.md
All checks were successful
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Successful in -1m1s
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been skipped
2025-06-04 14:10:29 -06:00
27f7277c45 Update README.md
All checks were successful
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Successful in -32s
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been skipped
2025-06-04 13:55:14 -06:00
1a76c4e7ab IO control on pluto appears to be working
All checks were successful
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Successful in 1m27s
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been skipped
2025-03-09 01:54:05 -07:00
67ddbb0f90 use tabs for plots 2025-03-08 23:11:01 -07:00
97497e640e disable some plots that are annoying me 2025-03-08 23:05:35 -07:00
e78cf5595a fix import issue when running gui entry point 2025-03-08 23:02:02 -07:00
086f348084 add pypi link
All checks were successful
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Successful in 16s
Publish Python 🐍 distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been skipped
2025-01-17 00:37:30 -07:00
6 changed files with 273 additions and 61 deletions

View File

@ -1,5 +1,9 @@
# Charon VNA # 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. 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 ## Installation
@ -7,7 +11,8 @@ Named after [Pluto's moon](https://en.wikipedia.org/wiki/Charon_(moon)), Charon
1. Install LibIIO. This is a dependency of [PyADI-IIO](https://wiki.analog.com/resources/tools-software/linux-software/pyadi-iio). 1. Install LibIIO. This is a dependency of [PyADI-IIO](https://wiki.analog.com/resources/tools-software/linux-software/pyadi-iio).
On Ubuntu 22.04 just run `sudo apt-get install -y libiio-dev` On Ubuntu 22.04 just run `sudo apt-get install -y libiio-dev`
2. `pip install charon-vna` 2. Charon releases are published on [PyPi](https://pypi.org/project/charon-vna/). Install using pip:
`pip install charon-vna`
## Hardware Setup ## Hardware Setup
@ -17,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 - 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) - 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) - 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) - 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 - 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 - SMA cables
- Calibration standard - Calibration standard
- Ideally something with s-parameters measured on a better VNA - 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 - I have used a basic SMA load and two modified SMA jacks with decent results
![calibration standard](img/calibration_standard.jpg)
### Pluto Configuration ### Pluto Configuration
@ -48,6 +54,7 @@ It will also be accessible over a socket to enable test automation with external
TBD TBD
### Power Calibration ### 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. 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. 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.
@ -59,6 +66,13 @@ Note that unlike the main calibration, power calibration frequencies do not need
## References ## References
#### Pluto Default Connection Settings #### Pluto Default Connection Settings
user: `root`
password: `analog` - user: `root`
ip: `192.168.2.1` - 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

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

View File

@ -3,6 +3,7 @@ import json
import pickle import pickle
import re import re
import sys import sys
import webbrowser
from pathlib import Path from pathlib import Path
from typing import List from typing import List
@ -14,23 +15,23 @@ from numpy import typing as npt
from PySide6.QtGui import QAction, QKeySequence from PySide6.QtGui import QAction, QKeySequence
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QApplication,
QDialogButtonBox,
QFileDialog, QFileDialog,
QInputDialog, QInputDialog,
QLineEdit, QLineEdit,
QMainWindow, QMainWindow,
QMenu, QMenu,
QProgressBar, QProgressBar,
QTabWidget,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from vna import Charon
from charon_vna.plots import PlotWidget from charon_vna.plots import PlotWidget
from charon_vna.util import net2s, s2net 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" CONFIG_SUFFIX = ".json"
@ -43,7 +44,7 @@ class MainWindow(QMainWindow):
def __init__(self, ip: str | None = None): def __init__(self, ip: str | None = None):
super().__init__() super().__init__()
self.config_path = DEFAULT_CONFIG self.config_path = PATH_CONFIG_DEFAULT
with open(self.config_path, "r") as f: with open(self.config_path, "r") as f:
config = json.load(f) config = json.load(f)
self._frequency = config["frequency"] self._frequency = config["frequency"]
@ -96,6 +97,19 @@ class MainWindow(QMainWindow):
action_cal_solt.triggered.connect(self.calibrate_solt) action_cal_solt.triggered.connect(self.calibrate_solt)
menu_calibration.addAction(action_cal_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 # Content
window_layout = QVBoxLayout() window_layout = QVBoxLayout()
@ -104,19 +118,20 @@ class MainWindow(QMainWindow):
prog_sweep.setMaximum(100) prog_sweep.setMaximum(100)
prog_sweep.setFormat("%v / %m") prog_sweep.setFormat("%v / %m")
# prog_sweep.setTextVisible(False) # prog_sweep.setTextVisible(False)
prog_sweep.setValue(50) prog_sweep.setValue(0)
window_layout.addWidget(prog_sweep) window_layout.addWidget(prog_sweep)
self.prog_sweep = prog_sweep self.prog_sweep = prog_sweep
# window_widget.se plot_widget = QTabWidget()
plot_layout = QVBoxLayout()
# TODO: handle plots properly
self.plots = [] self.plots = []
for type_ in ["logmag", "phase", "vswr", "smith"]: for type_ in [
"logmag",
"phase",
"vswr",
"smith",
]:
self.plots.append(PlotWidget(type_=type_)) self.plots.append(PlotWidget(type_=type_))
plot_layout.addWidget(self.plots[-1]) plot_widget.addTab(self.plots[-1], type_)
plot_widget = QWidget()
plot_widget.setLayout(plot_layout)
window_layout.addWidget(plot_widget) window_layout.addWidget(plot_widget)
# Set the central widget of the Window. # Set the central widget of the Window.
@ -127,6 +142,7 @@ class MainWindow(QMainWindow):
def saveas_config(self) -> None: def saveas_config(self) -> None:
print("Prompting for save path...") print("Prompting for save path...")
dialog = QFileDialog(self) dialog = QFileDialog(self)
dialog.setNameFilter(f"*{CONFIG_SUFFIX}")
dialog.setDefaultSuffix(CONFIG_SUFFIX) dialog.setDefaultSuffix(CONFIG_SUFFIX)
dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptSave) dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptSave)
if dialog.exec(): if dialog.exec():
@ -135,8 +151,8 @@ class MainWindow(QMainWindow):
raise ValueError( raise ValueError(
f"{config_path.name} is not a valid configuration file. Must have extension {CONFIG_SUFFIX}" f"{config_path.name} is not a valid configuration file. Must have extension {CONFIG_SUFFIX}"
) )
if config_path == DEFAULT_CONFIG: if config_path == PATH_CONFIG_DEFAULT:
raise ValueError(f"Cannot overwrite default configuration file at {DEFAULT_CONFIG}") raise ValueError(f"Cannot overwrite default configuration file at {PATH_CONFIG_DEFAULT}")
self.config_path = config_path self.config_path = config_path
print(f"Config path is now {self.config_path.resolve()}") print(f"Config path is now {self.config_path.resolve()}")
@ -160,7 +176,7 @@ class MainWindow(QMainWindow):
self.load_config(self.config_path) self.load_config(self.config_path)
def save_config(self) -> None: def save_config(self) -> None:
if self.config_path == DEFAULT_CONFIG: if self.config_path == PATH_CONFIG_DEFAULT:
self.saveas_config() self.saveas_config()
else: else:
print(f"Saving config to {self.config_path.resolve()}") print(f"Saving config to {self.config_path.resolve()}")

View File

@ -1,11 +1,94 @@
import json
import shutil
import zipfile
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory
from typing import List
import skrf as rf import skrf as rf
from util import net2s
# scikit-rf has no way to save files aside from touchstone and pickle # scikit-rf has no way to save Calibration objects aside from pickle
def cal2zarr(cal: rf.calibration.Calibration, outpath: Path): def cal2zip(cal: rf.calibration.Calibration, path: Path | str) -> None:
ideals = [net2s(net) for net in cal.ideals] path = Path(path)
measured = [net2s(net) for net in cal.measured] cal_type = cal.__class__
# s.to_zarr(outpath) 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,11 +1,11 @@
# %% imports # %% imports
import copy import copy
from enum import IntEnum, unique
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, Tuple from typing import Any, Callable, Dict, Literal, Tuple
import adi import adi
import iio
# import iio
import numpy as np import numpy as np
import skrf as rf import skrf as rf
import xarray as xr import xarray as xr
@ -34,14 +34,61 @@ def generate_tone(f: float, fs: float, N: int = 1024, scale: int = 2**14):
return iq return iq
@unique
class AD9361Register(IntEnum):
AUXDAC1_WORD = 0x018
AUXDAC2_WORD = 0x019
AUXDAC1_CONFIG = 0x01A
AUXDAC2_CONFIG = 0x01B
AUXADC_CLOCK_DIVIDER = 0x01C
AUXADC_CONFIG = 0x01D
AUXADC_WORD_MSB = 0x01E
AUXADC_WORD_LSB = 0x01F
AUTO_GPIO = 0x020
AGC_GAIN_LOCK_DELAY = 0x021
AGC_ATTACK_DELAY = 0x022
AUXDAC_ENABLE_CONTROL = 0x023
RX_LOAD_SYNTH_DELAY = 0x024
TX_LOAD_SYNTH_DELAY = 0x025
EXTERNAL_LNA_CONTROL = 0x026
GPO_FORCE_AND_INIT = 0x027
GPO0_RX_DELAY = 0x028
GPO1_RX_DELAY = 0x029
GPO2_RX_DELAY = 0x02A
GPO3_RX_DELAY = 0x02B
GPO0_TX_DELAY = 0x02C
GPO1_TX_DELAY = 0x02D
GPO2_TX_DELAY = 0x02E
GPO3_TX_DELAY = 0x02F
AUXDAC1_RX_DELAY = 0x030
AUXDAC1_TX_DELAY = 0x031
AUXDAC2_RX_DELAY = 0x032
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: class Charon:
FREQUENCY_OFFSET = 1e6 FREQUENCY_OFFSET = 1e6
DEFAULT_IP = "192.168.2.1"
calibration: rf.calibration.Calibration | None = None calibration: rf.calibration.Calibration | None = None
def __init__( def __init__(
self, self,
ip: str = "192.168.2.1", ip: str = DEFAULT_IP,
frequency: npt.ArrayLike = np.linspace(1e9, 2e9, 3), frequency: npt.ArrayLike = np.linspace(1e9, 2e9, 3),
ports: Tuple[int] = (1,), ports: Tuple[int] = (1,),
): ):
@ -49,7 +96,8 @@ class Charon:
self.frequency = frequency self.frequency = frequency
# everything RF # everything RF
self.sdr = adi.ad9361(uri=f"ip:{ip}") uri = f"ip:{ip}"
self.sdr = adi.ad9361(uri=uri)
for attr, expected in [ for attr, expected in [
("adi,2rx-2tx-mode-enable", True), ("adi,2rx-2tx-mode-enable", True),
# ("adi,gpo-manual-mode-enable", True), # ("adi,gpo-manual-mode-enable", True),
@ -81,14 +129,16 @@ class Charon:
self.sdr.tx_hardwaregain_chan0 = -10 self.sdr.tx_hardwaregain_chan0 = -10
# # switch control # # switch control
# ctx = iio.Context(uri) ctx = iio.Context(uri)
# self.ctrl = ctx.find_device("ad9361-phy") self.ctrl = ctx.find_device("ad9361-phy")
# # raw ad9361 register accesss: # raw ad9361 register accesss:
# # https://ez.analog.com/linux-software-drivers/f/q-a/120853/control-fmcomms3-s-gpo-with-python # 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 # noqa: E501
# self.ctrl.reg_write(0x26, 0x90) # bit 7: AuxDAC Manual, bit 4: GPO Manual self.ctrl.reg_write(AD9361Register.EXTERNAL_LNA_CONTROL, 0x90) # bit 7: AuxDAC Manual, bit 4: GPO Manual
# self._set_gpo(self.ports[0] - 1) self.ctrl.reg_write(AD9361Register.AUXDAC_ENABLE_CONTROL, 0x3F)
# # TODO: init AuxDAC self._set_gpo(0b0000)
self._set_dac_code(value=0, channel=1)
self._set_dac_code(value=0, channel=2)
def get_config(self) -> Dict[str, Any]: def get_config(self) -> Dict[str, Any]:
config = dict() config = dict()
@ -113,28 +163,74 @@ class Charon:
return config return config
def _get_gpo(self) -> int: def _get_gpo(self) -> int:
return (self.ctrl.reg_read(0x27) >> 4) & 0x0F return (self.ctrl.reg_read(AD9361Register.GPO_FORCE_AND_INIT) >> 4) & 0x0F
def _set_gpo(self, value: int) -> None: def _set_gpo(self, value: int) -> None:
self.ctrl.reg_write(0x27, (value & 0x0F) << 4) # bits 7-4: GPO3-0 self.ctrl.reg_write(AD9361Register.GPO_FORCE_AND_INIT, (value & 0x0F) << 4) # bits 7-4: GPO3-0
def _set_dac_voltage(self, voltage: float, channel: Literal[1, 2]):
raise NotImplementedError()
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)")
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 ~= (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
self.ctrl.reg_write(
AD9361Register.__getitem__(f"AUXDAC{channel}_WORD"),
(value >> 2) & 0xFF,
)
self.ctrl.reg_write(
AD9361Register.__getitem__(f"AUXDAC{channel}_CONFIG"),
(value & 0x3) | (vref.value << 2) | (step_factor << 4),
)
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 _get_dac_voltage(self) -> float:
raise NotImplementedError()
def set_output_power(self, power: float): def set_output_power(self, power: float):
# FIXME: this is a hack because I don't want to go through re-calibration pout = xr.DataArray(
if power == 5: [-15, -10, -5, 0, 5],
tx_gain = -1 dims=["tx_gain"],
elif power == 0: coords=dict(
tx_gain = -7 # TODO: correct over frequency
elif power == -5: frequency=1e9, # FIXME: I'm not sure at what frequency I generated this table
tx_gain = -12 tx_channel=0,
elif power == -10: tx_gain=[-22, -17, -12, -7, -1],
tx_gain = -17 ),
elif power == -15: )
tx_gain = -22
else: tx_gain_idx = np.abs(pout - power).argmin(dim="tx_gain")
raise NotImplementedError() tx_gain = pout.coords["tx_gain"][tx_gain_idx]
# # 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]
self.sdr.tx_hardwaregain_chan0 = float(tx_gain) self.sdr.tx_hardwaregain_chan0 = float(tx_gain)
def set_output(self, frequency: float, power: float): def set_output(self, frequency: float, power: float):
@ -220,6 +316,7 @@ class Charon:
) )
for ff, freq in enumerate(s.frequency.data): for ff, freq in enumerate(s.frequency.data):
if callback is not None: if callback is not None:
# report progress during sweep
callback(ff, len(s.frequency)) callback(ff, len(s.frequency))
self.set_output(frequency=freq, power=-5) self.set_output(frequency=freq, power=-5)
self.sdr.rx_destroy_buffer() self.sdr.rx_destroy_buffer()
@ -231,7 +328,9 @@ class Charon:
self.sdr.rx_hardwaregain_chan1 = 40 self.sdr.rx_hardwaregain_chan1 = 40
rx = self.sdr.rx() rx = self.sdr.rx()
s.loc[dict(frequency=freq)] = np.mean(rx[1] / rx[0]) s.loc[dict(frequency=freq)] = np.mean(rx[1] / rx[0])
if callback is not None: if callback is not None:
# mark capture as complete
callback(len(s.frequency), len(s.frequency)) callback(len(s.frequency), len(s.frequency))
return s return s

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 KiB