2023-08-09 09:57:34 +00:00
import cmath
import numpy as np
2023-08-08 15:09:28 +00:00
import logging
2023-08-23 08:06:49 +00:00
from scipy . signal import find_peaks
2023-07-31 11:20:14 +00:00
from PyQt6 . QtCore import pyqtSignal
2023-08-07 12:34:41 +00:00
from PyQt6 . QtSerialPort import QSerialPort
2023-03-23 15:08:59 +00:00
from nqrduck . module . module_model import ModuleModel
2023-08-08 15:09:28 +00:00
logger = logging . getLogger ( __name__ )
2023-08-09 09:57:34 +00:00
2023-08-16 14:23:23 +00:00
class S11Data :
2023-08-09 09:57:34 +00:00
# Conversion factors - the data is generally sent and received in mV
# These values are used to convert the data to dB and degrees
2023-08-16 14:23:23 +00:00
CENTER_POINT_MAGNITUDE = 900 # mV
2023-08-23 06:54:57 +00:00
CENTER_POINT_PHASE = 0 # mV
2023-08-16 14:23:23 +00:00
MAGNITUDE_SLOPE = 30 # dB/mV
PHASE_SLOPE = 10 # deg/mV
2023-08-09 09:57:34 +00:00
2023-08-16 14:23:23 +00:00
def __init__ ( self , data_points : list ) - > None :
2023-08-09 09:57:34 +00:00
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
2023-08-16 14:23:23 +00:00
2023-08-09 09:57:34 +00:00
@property
def return_loss_db ( self ) :
2023-08-16 14:23:23 +00:00
return (
self . return_loss_mv - self . CENTER_POINT_MAGNITUDE
) / self . MAGNITUDE_SLOPE
2023-08-09 09:57:34 +00:00
@property
2023-08-23 08:06:49 +00:00
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
2023-08-16 14:23:23 +00:00
2023-08-09 09:57:34 +00:00
@property
def phase_rad ( self ) :
return self . phase_deg * cmath . pi / 180
2023-08-16 14:23:23 +00:00
2023-08-09 09:57:34 +00:00
@property
def gamma ( self ) :
""" Complex reflection coefficient """
2023-08-10 07:07:51 +00:00
if len ( self . return_loss_db ) != len ( self . phase_rad ) :
raise ValueError ( " return_loss_db and phase_rad must be the same length " )
2023-08-16 14:23:23 +00:00
return [
cmath . rect ( 10 * * ( - loss_db / 20 ) , phase_rad )
for loss_db , phase_rad in zip ( self . return_loss_db , self . phase_rad )
]
2023-08-10 07:07:51 +00:00
2023-08-23 08:06:49 +00:00
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 ]
)
2023-12-07 12:09:07 +00:00
# Murks: The last point is always wrong so just set it to the previous value
phase_data_corrected [ - 1 ] = phase_data_corrected [ - 2 ]
2023-08-23 08:06:49 +00:00
return phase_data_corrected
2023-08-09 14:32:13 +00:00
def to_json ( self ) :
return {
" frequency " : self . frequency . tolist ( ) ,
" return_loss_mv " : self . return_loss_mv . tolist ( ) ,
2023-08-16 14:23:23 +00:00
" phase_mv " : self . phase_mv . tolist ( ) ,
2023-08-09 14:32:13 +00:00
}
2023-08-16 14:23:23 +00:00
2023-08-09 14:32:13 +00:00
@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 )
2023-08-09 09:57:34 +00:00
2023-08-16 14:23:23 +00:00
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 ) :
2023-07-31 11:20:14 +00:00
available_devices_changed = pyqtSignal ( list )
2023-08-07 12:34:41 +00:00
serial_changed = pyqtSignal ( QSerialPort )
data_points_changed = pyqtSignal ( list )
2023-08-08 15:09:28 +00:00
2023-08-09 09:57:34 +00:00
short_calibration_finished = pyqtSignal ( S11Data )
open_calibration_finished = pyqtSignal ( S11Data )
load_calibration_finished = pyqtSignal ( S11Data )
measurement_finished = pyqtSignal ( S11Data )
2023-08-07 12:34:41 +00:00
def __init__ ( self , module ) - > None :
super ( ) . __init__ ( module )
self . data_points = [ ]
2023-08-08 15:09:28 +00:00
self . active_calibration = None
2023-08-10 07:07:51 +00:00
self . calibration = None
2023-08-21 07:37:00 +00:00
self . serial = None
2023-07-31 11:20:14 +00:00
@property
def available_devices ( self ) :
return self . _available_devices
2023-08-08 15:09:28 +00:00
2023-07-31 11:20:14 +00:00
@available_devices.setter
def available_devices ( self , value ) :
self . _available_devices = value
self . available_devices_changed . emit ( value )
2023-07-31 13:24:46 +00:00
@property
def serial ( self ) :
2023-08-16 14:23:23 +00:00
""" The serial property is used to store the current serial connection. """
2023-07-31 13:24:46 +00:00
return self . _serial
2023-08-08 15:09:28 +00:00
2023-07-31 13:24:46 +00:00
@serial.setter
def serial ( self , value ) :
self . _serial = value
self . serial_changed . emit ( value )
2023-08-07 12:34:41 +00:00
2023-08-16 14:23:23 +00:00
def add_data_point (
self , frequency : float , return_loss : float , phase : float
) - > None :
2023-08-09 09:57:34 +00:00
""" 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 .
"""
2023-08-08 15:09:28 +00:00
self . data_points . append ( ( frequency , return_loss , phase ) )
2023-08-07 12:34:41 +00:00
self . data_points_changed . emit ( self . data_points )
def clear_data_points ( self ) - > None :
2023-08-08 15:09:28 +00:00
""" Clear all data points from the model. """
2023-08-07 12:34:41 +00:00
self . data_points . clear ( )
self . data_points_changed . emit ( self . data_points )
2023-08-08 15:09:28 +00:00
2023-08-09 09:57:34 +00:00
@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
2023-08-16 14:23:23 +00:00
2023-08-09 09:57:34 +00:00
@measurement.setter
def measurement ( self , value ) :
""" The measurement value is a tuple of three lists: frequency, return loss and phase. """
2023-08-09 14:32:13 +00:00
self . _measurement = value
self . measurement_finished . emit ( value )
2023-08-09 09:57:34 +00:00
# Calibration properties
2023-08-08 15:09:28 +00:00
@property
def active_calibration ( self ) :
return self . _active_calibration
2023-08-16 14:23:23 +00:00
2023-08-08 15:09:28 +00:00
@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 " )
2023-08-09 14:32:13 +00:00
self . _short_calibration = value
self . short_calibration_finished . emit ( value )
2023-08-08 15:09:28 +00:00
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 " )
2023-08-09 14:32:13 +00:00
self . _open_calibration = value
self . open_calibration_finished . emit ( value )
2023-08-08 15:09:28 +00:00
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 " )
2023-08-09 14:32:13 +00:00
self . _load_calibration = value
self . load_calibration_finished . emit ( value )
2023-08-08 15:09:28 +00:00
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
2023-08-16 14:23:23 +00:00
2023-08-08 15:09:28 +00:00
@calibration.setter
def calibration ( self , value ) :
logger . debug ( " Setting calibration " )
self . _calibration = value
2023-08-16 14:23:23 +00:00
@property
def LUT ( self ) :
return self . _LUT
@LUT.setter
def LUT ( self , value ) :
self . _LUT = value
2023-08-21 08:52:08 +00:00
@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