Source code for skrf.vi.vna.nanovna_v2

from . import abcvna
import numpy as np
import skrf
from time import sleep


# Communication commands and register addresses are listed in the user manual at
# https://nanorfe.com/nanovna-v2-user-manual.html
# See also `python/nanovna.py` in the NanoVNA project repository at https://github.com/nanovna-v2/NanoVNA2-firmware
#
#
# COMMAND SUMMARY:
#
# No operation:
# cmd = [0x00]
#
# Indicate; 1 byte reply (always 0x32)
# cmd = [0x0d]
#
# Read 1 byte from address 0xAA; 1 byte reply
# cmd = [0x10, 0xAA]
#
# Read 2 bytes from address 0xAA; 2 byte reply
# cmd = [0x11, 0xAA]
#
# Read 4 bytes from address 0xAA; 4 byte reply
# cmd = [0x12, 0xAA]
#
# Read 0xNN values from FIFO at address 0xAA; 0xNN byte reply
# cmd = [0x18, 0xAA, 0xNN]
#
# Write 1 byte (0xBB) to address 0xAA; no reply
# cmd = [0x20, 0xAA, 0xBB]
#
# Write 2 bytes (0xB0 0xB1) to addresses 0xAA and following; no reply
# 0xB0 will be written to 0xAA; 0xB1 will be written to 0xAB
# cmd = [0x21, 0xAA, 0xB0, 0xB1]
#
# Write 4 bytes (0xB0 to 0xB3) to addresses 0xAA and following; no reply
# 0xB0 will be written to 0xAA; 0xB1 will be written to 0xAB; ...
# cmd = [0x22, 0xAA, 0xB0, 0xB1, 0xB2, 0xB3]
#
# Write 8 bytes (0xB0 to 0xB7) to addresses 0xAA and following; no reply
# 0xB0 will be written to 0xAA; 0xB1 will be written to 0xAB; ...
# cmd = [0x23, 0xAA, 0xB0, 0xB1, ..., 0xB7]
#
# Write 0xNN bytes to FIFO at address 0xAA and following; no reply
# cmd = [0x28, 0xAA, 0xNN, 0xB0, 0xB1, ..., 0xBNN]
#
#
# FIFO DATA FORMAT (encoding: little endian):
# 0x03 to 0x00: real part of channel 0 outgoing wave; fwd0re (4 bytes; int32)
# 0x07 to 0x04: imaginary part of channel 0 outgoing wave; fwd0im (4 bytes; int32)
# 0x0b to 0x08: real part of channel 0 incoming wave; rev0re (4 bytes; int32)
# 0x0f to 0x0c: imaginary part of channel 0 incoming wave; rev0im (4 bytes; int32)
# 0x13 to 0x10: real part of channel 1 incoming wave; rev1re (4 bytes; int32)
# 0x17 to 0x14: imaginary part of channel 1 incoming wave; rev1im (4 bytes; int32)
# 0x19 0x18: frequency index of the sample (0 to sweep_points - 1); 2 bytes; uint16
#
#
# REGISTER ADDRESSES (encoding: little endian):
# 0x07 to 0x00: sweep start frequency in Hz (8 bytes; uint64)
# 0x17 to 0x10: sweep step in Hz (8 bytes; uint64)
# 0x21 0x20: number of sweep frequency points (2 bytes; uint16)
# 0x23 0x22: number of data points to output for each frequency (2 bytes; uint16)

[docs]class NanoVNAv2(abcvna.VNA): """ Python class for NanoVNA V2 network analyzers [#website]_. Parameters ---------- address : str SCPI identifier of the serial port for the NanoVNA. For example `'ASRL1::INSTR'` for `COM1` on Windows, or `'ASRL/dev/ttyACM0::INSTR'` for `/dev/ttyACM0` on Linux. Examples -------- Load and initialize NanoVNA on `COM1` (Windows OS, see Device Manager): >>> from skrf.vi import vna >>> nanovna = vna.NanoVNAv2('ASRL1::INSTR') Load and initialize NanoVNA on `/dev/ttyACM0` (Linux OS, see dmesg): >>> from skrf.vi import vna >>> nanovna = vna.NanoVNAv2('ASRL/dev/ttyACM0::INSTR') Configure frequency sweep (from 20 MHz to 4 GHz with 200 points, i.e. 20 MHz step): >>> nanovna.set_frequency_sweep(20e6, 4e9, 200) Get S11 and S21 as NumPy arrays: >>> s11, s21 = nanovna.get_s11_s21() Get list of available traces (will always return both channels, regardless of trace configuration): >>> traces_avail = nanovna.get_list_of_traces() Get 1-port networks of one or both of the traces listed in `get_list_of_traces()`: >>> nws_all = nanovna.get_traces(traces_avail) >>> nw_s11 = nws_all[0] >>> nw_s21 = nws_all[1] Get S11 as a 1-port skrf.Network: >>> nw_1 = nanovna.get_snp_network(ports=(0,)) Get S11 and S12 as s 2-port skrf.Network (incomplete with S21=S22=0): >>> nw_2 = nanovna.get_snp_network(ports=(0, 1)) Get S21 and S22 in a 2-port skrf.Network (incomplete with S11=S12=0): >>> nw_3 = nanovna.get_snp_network(ports=(1, 0)) References ---------- .. [#website] Website of NanoVNA V2: https://nanorfe.com/nanovna-v2.html """
[docs] def __init__(self, address: str = 'ASRL/dev/ttyACM0::INSTR'): super().__init__(address=address, visa_library='@py') self._protocol_reset() self._frequency = np.linspace(1e6, 10e6, 101) self.set_frequency_sweep(1e6, 10e6, 101)
[docs] def idn(self) -> str: """ Returns the identification string of the device. Returns ------- str Identification string, e.g. `NanoVNA_v2`. """ # send 1-byte READ (0x10) of address 0xf0 to retrieve device variant code self.resource.write_raw([0x10, 0xf0]) v_byte = self.resource.read_bytes(1) v = int.from_bytes(v_byte, byteorder='little') if v == 2: return 'NanoVNA_v2' else: return 'Unknown device, got deviceVariant={}'.format(v)
[docs] def reset(self): raise NotImplementedError
[docs] def wait_until_finished(self): raise NotImplementedError
def _protocol_reset(self): # send 8x NOP (0x00) to reset the communication protocol self.resource.write_raw([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
[docs] def get_s11_s21(self) -> (np.ndarray, np.ndarray): """ Returns individual NumPy arrays of the measured data of the sweep. Being a 1.5-port analyzer, the results only include :math:`S_{1,1}` and :math:`S_{2,1}`. Returns ------- tuple[np.ndarray, np.ndarray] List of NumPy arrays with :math:`S_{1,1}` and :math:`S_{2,1}`. Notes ----- Regardless of the calibration state of the NanoVNA, the results returned by this method are always raw, i.e. uncalibrated. The user needs to apply a manual calibration in postprocessing, if required. See Also -------- set_frequency_sweep get_traces :mod:`Calibration` """ # data is continuously being sampled and stored in the FIFO (address 0x30) # writing any value to 0x30 clears the FIFO, which enables on-demand readings f_points = len(self._frequency) # write any byte to register 0x30 to clear FIFO self.resource.write_raw([0x20, 0x30, 0x00]) data_raw = [] f_remaining = f_points while f_remaining > 0: # can only read 255 values in one take if f_remaining > 255: len_segment = 255 else: len_segment = f_remaining f_remaining = f_remaining - len_segment # read 'len_segment' values from FIFO (32 * len_segment bytes) self.resource.write_raw([0x18, 0x30, len_segment]) data_raw.extend(self.resource.read_bytes(32 * len_segment)) # parse FIFO data data_s11 = np.zeros(f_points, dtype=complex) data_s21 = np.zeros_like(data_s11) for i in range(f_points): i_start = i * 32 i_stop = (i + 1) * 32 data_chunk = data_raw[i_start:i_stop] fwd0re = int.from_bytes(data_chunk[0:4], 'little', signed=True) fwd0im = int.from_bytes(data_chunk[4:8], 'little', signed=True) rev0re = int.from_bytes(data_chunk[8:12], 'little', signed=True) rev0im = int.from_bytes(data_chunk[12:16], 'little', signed=True) rev1re = int.from_bytes(data_chunk[16:20], 'little', signed=True) rev1im = int.from_bytes(data_chunk[20:24], 'little', signed=True) freqIndex = int.from_bytes(data_chunk[24:26], 'little', signed=False) a1 = complex(fwd0re, fwd0im) b1 = complex(rev0re, rev0im) b2 = complex(rev1re, rev1im) data_s11[freqIndex] = b1 / a1 data_s21[freqIndex] = b2 / a1 return data_s11, data_s21
[docs] def set_frequency_sweep(self, start_freq: float, stop_freq: float, num_points: int = 201, **kwargs) -> None: """ Configures the frequency sweep. Only linear spacing is supported. Parameters ---------- start_freq : float Start frequency in Hertz stop_freq : float Stop frequency in Hertz num_points : int, optional Number of frequencies in the sweep. kwargs : dict, optional Returns ------- None """ f_step = 0.0 if num_points > 1: f_step = (stop_freq - start_freq) / (num_points - 1) self._frequency = np.linspace(start_freq, stop_freq, num_points) # set f_start by writing 8 bytes (cmd=0x23) to (0x00...0x07) cmd = b'\x23\x00' + int.to_bytes(int(start_freq), 8, byteorder='little', signed=False) self.resource.write_raw(cmd) # set f_step by writing 8 bytes (cmd=0x23) to (0x10...0x17) cmd = b'\x23\x10' + int.to_bytes(int(f_step), 8, byteorder='little', signed=False) self.resource.write_raw(cmd) # set f_points by writing 2 bytes (cmd=0x21) to (0x20 0x21) cmd = b'\x21\x20' + int.to_bytes(int(num_points), 2, byteorder='little', signed=False) self.resource.write_raw(cmd) # wait 1s for changes to be effective sleep(1)
[docs] def get_list_of_traces(self, **kwargs) -> list: """ Returns a list of dictionaries describing all available measurement traces. In case of the NanoVNA_v2, this is just a static list of the two measurement channels `[{'channel': 0, 'parameter': 'S11'}, {'channel': 1, 'parameter': 'S21'}]`. Parameters ---------- kwargs : dict, optional Returns ------- list """ return [{'channel': 0, 'parameter': 'S11'}, {'channel': 1, 'parameter': 'S21'}]
[docs] def get_traces(self, traces: list = None, **kwargs) -> list: """ Returns the data of the traces listed in `traces` as 1-port networks. Parameters ---------- traces: list of dict, optional Traces selected from :func:`get_list_of_traces`. kwargs: list Returns ------- list of skrf.Network List with trace data as individual 1-port networks. """ data_s11, data_s21 = self.get_s11_s21() frequency = skrf.Frequency.from_f(self._frequency, unit='hz') nw_s11 = skrf.Network(frequency=frequency, s=data_s11, name='Trace0') nw_s21 = skrf.Network(frequency=frequency, s=data_s21, name='Trace1') traces_valid = self.get_list_of_traces() networks = [] for trace in traces: if trace in traces_valid: if trace['channel'] == 0: networks.append(nw_s11) elif trace['channel'] == 1: networks.append(nw_s21) return networks
[docs] def get_snp_network(self, ports: tuple = (0, 1), **kwargs) -> skrf.Network: """ Returns a :math:`N`-port network containing the measured parameters at the positions specified in `ports`. The remaining responses will be 0. The rows and the column to be populated in the network are selected implicitly based on the position and the order of the entries in `ports`. See the parameter desciption for details. This function can be useful for sliced measurements of larger networks with an analyzer that does not have enough ports, for example when measuring a 3-port (e.g a balun) with the 1.5-port NanoVNA (example below). Parameters ---------- ports: tuple of int or None, optional Specifies the position and order of the measured responses in the returned `N`-port network. Valid entries are `0`, `1`, or `None`. The length of the tuple defines the size `N` of the network, the entries define the type (forward/reverse) and position (indices of the rows and the column to be populated). Number `0` refers to the source port (`s11` from the NanoVNA), `1` refers to the receiver port (`s21` from the NanoVNA), and `None` skips this position (required to increase `N`). For `N>1`, the colum index is determined by the position of the source port `0` in `ports`. See examples below. kwargs: list Additional parameters will be ignored. Returns ------- skrf.Network Examples -------- To get the measured S-matrix of a 3-port from six individual measurements, the slices (s11, s21), (s11, s31), (s12, s22), (s22, s32), (s13, s33), and (s23, s33) can be obtained directly as (incomplete) 3-port networks with the results stored at the correct positions, which helps combining them afterwards. >>> from skrf.vi import vna >>> nanovna = vna.NanoVNAv2() 1st slice: connect VNA_P1=P1 and VNA_P2=P2 to measure s11 and s21: >>> nw_s1 = nanovna.get_snp_network(ports=(0, 1, None)) This will return a 3-port network with [[s11_vna, 0, 0], [s21_vnas, 0, 0], [0, 0, 0]]. 2nd slice: connect VNA_P1=P1 and VNA_P2=P3 to measure s11 and s31: >>> nw_s2 = nanovna.get_snp_network(ports=(0, None, 1)) This will return a 3-port network with [[s11_vna, 0, 0], [0, 0, 0], [s21_vna, 0, 0]]. 3rd slice: connect VNA_P1=P2 and VNA_P2=P1 to measure s22 and s12: >>> nw_s3 = nanovna.get_snp_network(ports=(1, 0, None)) This will return a 3-port network with [[0, s21_vna, 0], [0, s11_vna, 0], [0, 0, 0]]. 4th slice: connect VNA_P1=P2 and VNA_P2=P3 to measure s22 and s32: >>> nw_s4 = nanovna.get_snp_network(ports=(None, 0, 1)) This will return a 3-port network with [[0, 0, 0], [0, s11_vna, 0], [0, s21_vna, 0]]. 5th slice: connect VNA_P1=P3 and VNA_P2=P1 to measure s13 and s33: >>> nw_s5 = nanovna.get_snp_network(ports=(1, None, 0)) This will return a 3-port network with [[0, 0, s21_vna], [0, 0, 0], [0, 0, s11_vna]]. 6th slice: connect VNA_P1=P3 and VNA_P2=P2 to measure s23 and s33: >>> nw_s6 = nanovna.get_snp_network(ports=(None, 1, 0)) This will return a 3-port network with [[0, 0, 0], [0, 0, s21_vna], [0, 0, s11_vna]]. Now, the six incomplete networks can simply be added to get to complete network of the 3-port: >>> nw = nw_s1 + nw_s2 + nw_s3 + nw_s4 + nw_s5 + nw_s6 The reflection coefficients s11, s22, s33 have been measured twice, so the sum still needs to be divided by 2 to get the correct result: >>> nw.s[:, 0, 0] = 0.5 * nw.s[:, 0, 0] >>> nw.s[:, 1, 1] = 0.5 * nw.s[:, 1, 1] >>> nw.s[:, 2, 2] = 0.5 * nw.s[:, 2, 2] This gives the average, but you could also replace it with just one of the measurements. This function can also be used for smaller networks: Get a 1-port network with `s11`, i.e. [s11_meas]: >>> nw = nanovna.get_snp_network(ports=(0, )) Get a 1-port network with `s21`, i.e. [s21_meas]: >>> nw = nanovna.get_snp_network(ports=(1, )) Get a 2-port network (incomplete) with `(s11, s21) = measurement, (s12, S22) = 0`, i.e. [[s11_meas, 0], [s21_meas, 0]]: >>> nw = nanovna.get_snp_network(ports=(0, 1)) Get a 2-port network (incomplete) with `(s12, s22) = measurement, (s11, S21) = 0`, i.e. [[0, s21_meas], [0, s11_meas]]: >>> nw = nanovna.get_snp_network(ports=(1, 0)) """ # load s11, s21 from NanoVNA data_s11, data_s21 = self.get_s11_s21() frequency = skrf.Frequency.from_f(self._frequency, unit='hz') # prepare empty S matrix to be populated s = np.zeros((len(frequency), len(ports), len(ports)), dtype=complex) # get trace indices from 'ports' (without None) rows = [] col = -1 for i_port, port in enumerate(ports): if port is not None: # get row indices directly from entries in `ports` rows.append(i_port) # try to get column index from from position of `0` entry (if present) if port == 0: col = i_port if col == -1: # `0` entry was not present to specify the column index if len(ports) == 1: # not a problem; it's a 1-port col = 0 else: # problem: column index is ambiguous raise ValueError('Source port index `0` is missing in `ports` with length > 1. Column index is ambiguous.') # populate N-port network with s11 and s21 k = 0 for _, port in enumerate(ports): if port is not None: if port == 0: s[:, rows[k], col] = data_s11 elif port == 1: s[:, rows[k], col] = data_s21 else: raise ValueError('Invalid port index `{}` in `ports`'.format(port)) k += 1 return skrf.Network(frequency=frequency, s=s)
[docs] def get_switch_terms(self, ports=(1, 2), **kwargs): raise NotImplementedError
@property def s11(self) -> skrf.Network: """ Measures :math:`S_{1,1}` and returns it as a 1-port Network. Returns ------- skrf.Network """ traces = self.get_list_of_traces() ntwk = self.get_traces([traces[0]])[0] ntwk.name = 'NanoVNA_S11' return ntwk @property def s21(self) -> skrf.Network: """ Measures :math:`S_{2,1}` and returns it as a 1-port Network. Returns ------- skrf.Network """ traces = self.get_list_of_traces() ntwk = self.get_traces([traces[1]])[0] ntwk.name = 'NanoVNA_S21' return ntwk