mirror of
https://github.com/nqrduck/nqrduck-autotm.git
synced 2024-09-19 10:50:35 +00:00
377 lines
13 KiB
Python
377 lines
13 KiB
Python
import cmath
|
|
import numpy as np
|
|
import logging
|
|
from scipy.signal import find_peaks
|
|
from PyQt6.QtCore import pyqtSignal
|
|
from PyQt6.QtSerialPort import QSerialPort
|
|
from nqrduck.module.module_model import ModuleModel
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class S11Data:
|
|
# Conversion factors - the data is generally sent and received in mV
|
|
# These values are used to convert the data to dB and degrees
|
|
CENTER_POINT_MAGNITUDE = 900 # mV
|
|
CENTER_POINT_PHASE = 0 # mV
|
|
MAGNITUDE_SLOPE = 30 # dB/mV
|
|
PHASE_SLOPE = 10 # deg/mV
|
|
|
|
def __init__(self, data_points: list) -> None:
|
|
self.frequency = np.array([data_point[0] for data_point in data_points])
|
|
self.return_loss_mv = np.array([data_point[1] for data_point in data_points])
|
|
self.phase_mv = np.array([data_point[2] for data_point in data_points])
|
|
|
|
@property
|
|
def millivolts(self):
|
|
return self.frequency, self.return_loss_mv, self.phase_mv
|
|
|
|
@property
|
|
def return_loss_db(self):
|
|
return (
|
|
self.return_loss_mv - self.CENTER_POINT_MAGNITUDE
|
|
) / self.MAGNITUDE_SLOPE
|
|
|
|
@property
|
|
def phase_deg(self, phase_correction=True):
|
|
"""Returns the absolute value of the phase in degrees
|
|
|
|
Keyword Arguments:
|
|
phase_correction {bool} -- If True, the phase correction is applied. (default: {False})
|
|
"""
|
|
|
|
phase_deg = (self.phase_mv - self.CENTER_POINT_PHASE) / self.PHASE_SLOPE
|
|
if phase_correction:
|
|
phase_deg = self.phase_correction(self.frequency, phase_deg)
|
|
|
|
return phase_deg
|
|
|
|
@property
|
|
def phase_rad(self):
|
|
return self.phase_deg * cmath.pi / 180
|
|
|
|
@property
|
|
def gamma(self):
|
|
"""Complex reflection coefficient"""
|
|
if len(self.return_loss_db) != len(self.phase_rad):
|
|
raise ValueError("return_loss_db and phase_rad must be the same length")
|
|
|
|
return [
|
|
cmath.rect(10 ** (-loss_db / 20), phase_rad)
|
|
for loss_db, phase_rad in zip(self.return_loss_db, self.phase_rad)
|
|
]
|
|
|
|
def phase_correction(
|
|
self, frequency_data: np.array, phase_data: np.array
|
|
) -> np.array:
|
|
"""This method fixes the phase sign of the phase data.
|
|
The AD8302 can only measure the absolute value of the phase.
|
|
Therefore we need to correct the phase sign. This can be done via the slope of the phase.
|
|
If the slope is negative, the phase is positive and vice versa.
|
|
|
|
Args:
|
|
frequency_data (np.array): The frequency data.
|
|
phase_data (np.array): The phase data.
|
|
|
|
Returns:
|
|
np.array: The corrected phase data.
|
|
"""
|
|
# First we apply a moving average filter to the phase data
|
|
WINDOW_SIZE = 5
|
|
phase_data_filtered = (
|
|
np.convolve(phase_data, np.ones(WINDOW_SIZE), "same") / WINDOW_SIZE
|
|
)
|
|
|
|
# Fix transient response
|
|
phase_data_filtered[: WINDOW_SIZE // 2] = phase_data[: WINDOW_SIZE // 2]
|
|
phase_data_filtered[-WINDOW_SIZE // 2 :] = phase_data[-WINDOW_SIZE // 2 :]
|
|
|
|
# Now we find the peaks and valleys of the data
|
|
HEIGHT = 100
|
|
distance = len(phase_data_filtered) / 10
|
|
|
|
peaks, _ = find_peaks(phase_data_filtered, distance=distance, height=HEIGHT)
|
|
|
|
valleys, _ = find_peaks(
|
|
180 - phase_data_filtered, distance=distance, height=HEIGHT
|
|
)
|
|
|
|
# Determine if the first point is a peak or a valley
|
|
if phase_data_filtered[0] > phase_data_filtered[1]:
|
|
peaks = np.insert(peaks, 0, 0)
|
|
else:
|
|
valleys = np.insert(valleys, 0, 0)
|
|
|
|
# Determine if the last point is a peak or a valley
|
|
if phase_data_filtered[-1] > phase_data_filtered[-2]:
|
|
peaks = np.append(peaks, len(phase_data_filtered) - 1)
|
|
else:
|
|
valleys = np.append(valleys, len(phase_data_filtered) - 1)
|
|
|
|
frequency_peaks = frequency_data[peaks]
|
|
frequency_valleys = frequency_data[valleys]
|
|
|
|
# Combine the peaks and valleys
|
|
frequency_peaks_valleys = np.sort(
|
|
np.concatenate((frequency_peaks, frequency_valleys))
|
|
)
|
|
peaks_valleys = np.sort(np.concatenate((peaks, valleys)))
|
|
|
|
# Now we can determine the slope of the phase
|
|
# For this we compare the phase of our peaks_valleys array to the next point
|
|
# If the phase is increasing, the slope is positive, if it is decreasing, the slope is negative
|
|
phase_slope = np.zeros(len(peaks_valleys) - 1)
|
|
for i in range(len(peaks_valleys) - 1):
|
|
phase_slope[i] = (
|
|
phase_data_filtered[peaks_valleys[i + 1]]
|
|
- phase_data_filtered[peaks_valleys[i]]
|
|
)
|
|
|
|
# Now we can determine the sign of the phase
|
|
# If the slope is negative, the phase is positive and vice versa
|
|
phase_sign = np.sign(phase_slope) * -1
|
|
|
|
# Now we can correct the phase for the different sections
|
|
phase_data_corrected = np.zeros(len(phase_data))
|
|
for i in range(len(peaks_valleys) - 1):
|
|
phase_data_corrected[peaks_valleys[i] : peaks_valleys[i + 1]] = (
|
|
phase_data_filtered[peaks_valleys[i] : peaks_valleys[i + 1]]
|
|
* phase_sign[i]
|
|
)
|
|
|
|
# Murks: The last point is always wrong so just set it to the previous value
|
|
phase_data_corrected[-1] = phase_data_corrected[-2]
|
|
|
|
return phase_data_corrected
|
|
|
|
def to_json(self):
|
|
return {
|
|
"frequency": self.frequency.tolist(),
|
|
"return_loss_mv": self.return_loss_mv.tolist(),
|
|
"phase_mv": self.phase_mv.tolist(),
|
|
}
|
|
|
|
@classmethod
|
|
def from_json(cls, json):
|
|
f = json["frequency"]
|
|
rl = json["return_loss_mv"]
|
|
p = json["phase_mv"]
|
|
data = [(f[i], rl[i], p[i]) for i in range(len(f))]
|
|
return cls(data)
|
|
|
|
|
|
class LookupTable:
|
|
"""This class is used to store a lookup table for tuning and matching of electrical probeheads."""
|
|
|
|
data = dict()
|
|
|
|
def __init__(
|
|
self,
|
|
start_frequency: float,
|
|
stop_frequency: float,
|
|
frequency_step: float,
|
|
) -> None:
|
|
self.start_frequency = start_frequency
|
|
self.stop_frequency = stop_frequency
|
|
self.frequency_step = frequency_step
|
|
|
|
# This is the frequency at which the tuning and matching process was started
|
|
self.started_frequency = None
|
|
|
|
self.init_voltages()
|
|
|
|
def init_voltages(self) -> None:
|
|
"""Initialize the lookup table with default values."""
|
|
for frequency in np.arange(
|
|
self.start_frequency, self.stop_frequency, self.frequency_step
|
|
):
|
|
self.started_frequency = frequency
|
|
self.add_voltages(None, None)
|
|
|
|
def is_incomplete(self) -> bool:
|
|
"""This method returns True if the lookup table is incomplete,
|
|
i.e. if there are frequencies for which no the tuning or matching voltage is none.
|
|
|
|
Returns:
|
|
bool: True if the lookup table is incomplete, False otherwise.
|
|
"""
|
|
return any(
|
|
[
|
|
tuning_voltage is None or matching_voltage is None
|
|
for tuning_voltage, matching_voltage in self.data.values()
|
|
]
|
|
)
|
|
|
|
def get_next_frequency(self) -> float:
|
|
"""This method returns the next frequency for which the tuning and matching voltage is not yet set.
|
|
|
|
Returns:
|
|
float: The next frequency for which the tuning and matching voltage is not yet set.
|
|
"""
|
|
|
|
for frequency, (tuning_voltage, matching_voltage) in self.data.items():
|
|
if tuning_voltage is None or matching_voltage is None:
|
|
return frequency
|
|
|
|
return None
|
|
|
|
def add_voltages(self, tuning_voltage: float, matching_voltage: float) -> None:
|
|
"""Add a tuning and matching voltage for the last started frequency to the lookup table.
|
|
|
|
Args:
|
|
tuning_voltage (float): The tuning voltage for the given frequency.
|
|
matching_voltage (float): The matching voltage for the given frequency."""
|
|
self.data[self.started_frequency] = (tuning_voltage, matching_voltage)
|
|
|
|
|
|
class AutoTMModel(ModuleModel):
|
|
available_devices_changed = pyqtSignal(list)
|
|
serial_changed = pyqtSignal(QSerialPort)
|
|
data_points_changed = pyqtSignal(list)
|
|
|
|
short_calibration_finished = pyqtSignal(S11Data)
|
|
open_calibration_finished = pyqtSignal(S11Data)
|
|
load_calibration_finished = pyqtSignal(S11Data)
|
|
measurement_finished = pyqtSignal(S11Data)
|
|
|
|
def __init__(self, module) -> None:
|
|
super().__init__(module)
|
|
self.data_points = []
|
|
self.active_calibration = None
|
|
self.calibration = None
|
|
self.serial = None
|
|
|
|
@property
|
|
def available_devices(self):
|
|
return self._available_devices
|
|
|
|
@available_devices.setter
|
|
def available_devices(self, value):
|
|
self._available_devices = value
|
|
self.available_devices_changed.emit(value)
|
|
|
|
@property
|
|
def serial(self):
|
|
"""The serial property is used to store the current serial connection."""
|
|
return self._serial
|
|
|
|
@serial.setter
|
|
def serial(self, value):
|
|
self._serial = value
|
|
self.serial_changed.emit(value)
|
|
|
|
def add_data_point(
|
|
self, frequency: float, return_loss: float, phase: float
|
|
) -> None:
|
|
"""Add a data point to the model. These data points are our intermediate data points read in via the serial connection.
|
|
They will be saved in the according properties later on.
|
|
"""
|
|
self.data_points.append((frequency, return_loss, phase))
|
|
self.data_points_changed.emit(self.data_points)
|
|
|
|
def clear_data_points(self) -> None:
|
|
"""Clear all data points from the model."""
|
|
self.data_points.clear()
|
|
self.data_points_changed.emit(self.data_points)
|
|
|
|
@property
|
|
def measurement(self):
|
|
"""The measurement property is used to store the current measurement.
|
|
This is the measurement that is shown in the main S11 plot"""
|
|
return self._measurement
|
|
|
|
@measurement.setter
|
|
def measurement(self, value):
|
|
"""The measurement value is a tuple of three lists: frequency, return loss and phase."""
|
|
self._measurement = value
|
|
self.measurement_finished.emit(value)
|
|
|
|
# Calibration properties
|
|
|
|
@property
|
|
def active_calibration(self):
|
|
return self._active_calibration
|
|
|
|
@active_calibration.setter
|
|
def active_calibration(self, value):
|
|
self._active_calibration = value
|
|
|
|
@property
|
|
def short_calibration(self):
|
|
return self._short_calibration
|
|
|
|
@short_calibration.setter
|
|
def short_calibration(self, value):
|
|
logger.debug("Setting short calibration")
|
|
self._short_calibration = value
|
|
self.short_calibration_finished.emit(value)
|
|
|
|
def init_short_calibration(self):
|
|
"""This method is called when a frequency sweep has been started for a short calibration in this way the module knows that the next data points are for a short calibration."""
|
|
self.active_calibration = "short"
|
|
self.clear_data_points()
|
|
|
|
@property
|
|
def open_calibration(self):
|
|
return self._open_calibration
|
|
|
|
@open_calibration.setter
|
|
def open_calibration(self, value):
|
|
logger.debug("Setting open calibration")
|
|
self._open_calibration = value
|
|
self.open_calibration_finished.emit(value)
|
|
|
|
def init_open_calibration(self):
|
|
"""This method is called when a frequency sweep has been started for an open calibration in this way the module knows that the next data points are for an open calibration."""
|
|
self.active_calibration = "open"
|
|
self.clear_data_points()
|
|
|
|
@property
|
|
def load_calibration(self):
|
|
return self._load_calibration
|
|
|
|
@load_calibration.setter
|
|
def load_calibration(self, value):
|
|
logger.debug("Setting load calibration")
|
|
self._load_calibration = value
|
|
self.load_calibration_finished.emit(value)
|
|
|
|
def init_load_calibration(self):
|
|
"""This method is called when a frequency sweep has been started for a load calibration in this way the module knows that the next data points are for a load calibration."""
|
|
self.active_calibration = "load"
|
|
self.clear_data_points()
|
|
|
|
@property
|
|
def calibration(self):
|
|
return self._calibration
|
|
|
|
@calibration.setter
|
|
def calibration(self, value):
|
|
logger.debug("Setting calibration")
|
|
self._calibration = value
|
|
|
|
@property
|
|
def LUT(self):
|
|
return self._LUT
|
|
|
|
@LUT.setter
|
|
def LUT(self, value):
|
|
self._LUT = value
|
|
|
|
@property
|
|
def frequency_sweep_start(self):
|
|
"""The timestamp for when the frequency sweep has been started. This is used for timing of the frequency sweep."""
|
|
return self._frequency_sweep_start
|
|
|
|
@frequency_sweep_start.setter
|
|
def frequency_sweep_start(self, value):
|
|
self._frequency_sweep_start = value
|
|
|
|
@property
|
|
def frequency_sweep_end(self):
|
|
"""The timestamp for when the frequency sweep has been ended. This is used for timing of the frequency sweep."""
|
|
return self._frequency_sweep_end
|
|
|
|
@frequency_sweep_end.setter
|
|
def frequency_sweep_end(self, value):
|
|
self._frequency_sweep_end = value
|