diff --git a/src/nqrduck_autotm/controller.py b/src/nqrduck_autotm/controller.py index 396dac0..dba1055 100644 --- a/src/nqrduck_autotm/controller.py +++ b/src/nqrduck_autotm/controller.py @@ -1,4 +1,5 @@ import logging +import numpy as np from serial.tools.list_ports import comports from PyQt6 import QtSerialPort from PyQt5.QtCore import QThread, pyqtSignal, pyqtSlot @@ -34,9 +35,16 @@ class AutoTMController(ModuleController): def start_frequency_sweep(self, start_frequency : float, stop_frequency : float) -> None: """ This starts a frequency sweep on the device in the specified range.""" logger.debug("Starting frequency sweep from %s to %s", start_frequency, stop_frequency) + # We create the frequency sweep spinner dialog + self.module.model.clear_data_points() + self.module.view.create_frequency_sweep_spinner_dialog() # Print the command 'f ' to the serial connection - command = "f %s %s" % (start_frequency, stop_frequency) - self.module.model.serial.write(command.encode('utf-8')) + try: + command = "f %s %s" % (start_frequency, stop_frequency) + self.module.model.serial.write(command.encode('utf-8')) + except AttributeError: + logger.error("Could not start frequency sweep. No device connected.") + def on_ready_read(self) -> None: """This method is called when data is received from the serial connection. """ @@ -44,11 +52,108 @@ class AutoTMController(ModuleController): while serial.canReadLine(): text = serial.readLine().data().decode() text = text.rstrip('\r\n') - logger.debug("Received data: %s", text) - if text.startswith("f"): + # logger.debug("Received data: %s", text) + # If the text starts with 'f' and the frequency sweep spinner is visible we know that the data is a data point + if text.startswith("f") and self.module.view.frequency_sweep_spinner.isVisible(): text = text[1:].split("r") frequency = float(text[0]) - return_loss = float(text[1]) - self.module.model.add_data_point(frequency, return_loss) + return_loss, phase = map(float, text[1].split("p")) + self.module.model.add_data_point(frequency, return_loss, phase) + elif text.startswith("r") and self.module.model.active_calibration == None: + logger.debug("Measurement finished") + self.module.view.plot_data() + self.module.view.frequency_sweep_spinner.hide() + elif text.startswith("r") and self.module.model.active_calibration == "short": + logger.debug("Short calibration finished") + self.module.model.short_calibration = self.module.model.data_points.copy() + self.module.model.active_calibration = None + self.module.view.frequency_sweep_spinner.hide() + elif text.startswith("r") and self.module.model.active_calibration == "open": + logger.debug("Open calibration finished") + self.module.model.open_calibration = self.module.model.data_points.copy() + self.module.model.active_calibration = None + self.module.view.frequency_sweep_spinner.hide() + elif text.startswith("r") and self.module.model.active_calibration == "load": + logger.debug("Load calibration finished") + self.module.model.load_calibration = self.module.model.data_points.copy() + self.module.model.active_calibration = None + self.module.view.frequency_sweep_spinner.hide() else: self.module.view.add_info_text(text) + + def on_short_calibration(self, start_frequency : float, stop_frequency : float) -> None: + """This method is called when the short calibration button is pressed. + It starts a frequency sweep in the specified range and then starts a short calibration. + """ + logger.debug("Starting short calibration") + self.module.model.init_short_calibration() + self.start_frequency_sweep(start_frequency, stop_frequency) + + def on_open_calibration(self, start_frequency : float, stop_frequency : float) -> None: + """This method is called when the open calibration button is pressed. + It starts a frequency sweep in the specified range and then starts an open calibration. + """ + logger.debug("Starting open calibration") + self.module.model.init_open_calibration() + self.start_frequency_sweep(start_frequency, stop_frequency) + + def on_load_calibration(self, start_frequency : float, stop_frequency : float) -> None: + """This method is called when the load calibration button is pressed. + It starts a frequency sweep in the specified range and then loads a calibration. + """ + logger.debug("Starting load calibration") + self.module.model.init_load_calibration() + self.start_frequency_sweep(start_frequency, stop_frequency) + + def calculate_calibration(self) -> None: + """This method is called when the calculate calibration button is pressed. + It calculates the calibration from the short, open and calibration data points. + """ + logger.debug("Calculating calibration") + # First we check if the short and open calibration data points are available + if self.module.model.short_calibration == None: + logger.error("No short calibration data points available") + return + + if self.module.model.open_calibration == None: + logger.error("No open calibration data points available") + return + + if self.module.model.load_calibration == None: + logger.error("No load calibration data points available") + return + + # Then we check if the short, open and load calibration data points have the same length + if len(self.module.model.short_calibration) != len(self.module.model.open_calibration) or len(self.module.model.short_calibration) != len(self.module.model.load_calibration): + logger.error("The short, open and load calibration data points do not have the same length") + return + + # Then we calculate the calibration + ideal_gamma_short = -1 + ideal_gamma_open = 1 + ideal_gamma_load = 0 + + short_calibration = [10 **(-returnloss_s[1] /6 / 24 / 20) for returnloss_s in self.module.model.short_calibration] + open_calibration = [10 **(-returnloss_o[1] / 6 / 24 / 20) for returnloss_o in self.module.model.open_calibration] + load_calibration = [10 **(-returnloss_l[1] / 6 / 24 / 20) for returnloss_l in self.module.model.load_calibration] + + e_00s = [] + e11s = [] + delta_es = [] + for gamma_s, gamma_o, gamma_l in zip(short_calibration, open_calibration, load_calibration): + A = np.array([ + [1, ideal_gamma_short * gamma_s, -ideal_gamma_short], + [1, ideal_gamma_open * gamma_o, -ideal_gamma_open], + [1, ideal_gamma_load * gamma_l, -ideal_gamma_load] + ]) + + B = np.array([gamma_s, gamma_o, gamma_l]) + + # Solve the system + e_00, e11, delta_e = np.linalg.lstsq(A, B, rcond=None)[0] + + e_00s.append(e_00) + e11s.append(e11) + delta_es.append(delta_e) + + self.module.model.calibration = (e_00s, e11s, delta_es) \ No newline at end of file diff --git a/src/nqrduck_autotm/model.py b/src/nqrduck_autotm/model.py index e57aad2..98a4d4a 100644 --- a/src/nqrduck_autotm/model.py +++ b/src/nqrduck_autotm/model.py @@ -1,22 +1,29 @@ import serial +import logging from PyQt6.QtCore import pyqtSignal from PyQt6.QtSerialPort import QSerialPort from nqrduck.module.module_model import ModuleModel +logger = logging.getLogger(__name__) + class AutoTMModel(ModuleModel): available_devices_changed = pyqtSignal(list) serial_changed = pyqtSignal(QSerialPort) data_points_changed = pyqtSignal(list) - + + short_calibration_finished = pyqtSignal(list) + open_calibration_finished = pyqtSignal(list) + load_calibration_finished = pyqtSignal(list) def __init__(self, module) -> None: super().__init__(module) self.data_points = [] + self.active_calibration = None @property def available_devices(self): return self._available_devices - + @available_devices.setter def available_devices(self, value): self._available_devices = value @@ -25,18 +32,80 @@ class AutoTMModel(ModuleModel): @property def serial(self): 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) -> None: - """Add a data point to the model. """ - self.data_points.append((frequency, return_loss)) + def add_data_point(self, frequency: float, return_loss: float, phase : float) -> None: + """Add a data point to the model.""" + 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. """ + """Clear all data points from the model.""" self.data_points.clear() self.data_points_changed.emit(self.data_points) + + @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 diff --git a/src/nqrduck_autotm/resources/autotm_widget.ui b/src/nqrduck_autotm/resources/autotm_widget.ui index 6fadc68..1890934 100644 --- a/src/nqrduck_autotm/resources/autotm_widget.ui +++ b/src/nqrduck_autotm/resources/autotm_widget.ui @@ -163,7 +163,7 @@ - + Calibrate diff --git a/src/nqrduck_autotm/resources/duck_kick.gif b/src/nqrduck_autotm/resources/duck_kick.gif new file mode 100644 index 0000000..0b7b08e Binary files /dev/null and b/src/nqrduck_autotm/resources/duck_kick.gif differ diff --git a/src/nqrduck_autotm/view.py b/src/nqrduck_autotm/view.py index db44efc..637d32b 100644 --- a/src/nqrduck_autotm/view.py +++ b/src/nqrduck_autotm/view.py @@ -1,9 +1,12 @@ import logging from datetime import datetime +from pathlib import Path +from PyQt6.QtGui import QMovie from PyQt6.QtSerialPort import QSerialPort -from PyQt6.QtWidgets import QWidget, QLabel, QVBoxLayout, QApplication +from PyQt6.QtWidgets import QWidget, QLabel, QVBoxLayout, QApplication, QHBoxLayout, QLineEdit, QPushButton, QDialog from PyQt6.QtCore import pyqtSlot, Qt from nqrduck.module.module_view import ModuleView +from nqrduck.contrib.mplwidget import MplWidget from .widget import Ui_Form logger = logging.getLogger(__name__) @@ -39,8 +42,8 @@ class AutoTMView(ModuleView): float(self._ui_form.stopEdit.text()) )) - # Connect the data points changed signal to the on_data_points_changed slot - self.module.model.data_points_changed.connect(self.on_data_points_changed) + # On clicking of the calibration button call the on_calibration_button_clicked method + self._ui_form.calibrationButton.clicked.connect(self.on_calibration_button_clicked) # Add a vertical layout to the info box self._ui_form.scrollAreaWidgetContents.setLayout(QVBoxLayout()) @@ -68,6 +71,14 @@ class AutoTMView(ModuleView): ax.set_ylim(-100, 0) self._ui_form.S11Plot.canvas.draw() + def on_calibration_button_clicked(self) -> None: + """This method is called when the calibration button is clicked. + It opens the calibration window. + """ + logger.debug("Calibration button clicked") + self.calibration_window = self.CalibrationWindow(self.module) + self.calibration_window.show() + @pyqtSlot(list) def on_available_devices_changed(self, available_devices : list) -> None: """Update the available devices list in the view. """ @@ -104,15 +115,32 @@ class AutoTMView(ModuleView): self._ui_form.connectionLabel.setText("Disconnected") logger.debug("Updated serial connection label") - @pyqtSlot(list) - def on_data_points_changed(self, data_points : list) -> None: + def plot_data(self) -> None: """Update the S11 plot with the current data points. Args: data_points (list): List of data points to plot. """ - x = [data_point[0] for data_point in data_points] - y = [data_point[1] for data_point in data_points] + x = [data_point[0] for data_point in self.module.model.data_points] + y = [(data_point[1] - 900) / 30 for data_point in self.module.model.data_points] + phase = [(data_point[2] - 900) / 10 for data_point in self.module.model.data_points] + + # Calibration test: + #calibration = self.module.model.calibration + #e_00 = calibration[0] + #e11 = calibration[1] + #delta_e = calibration[2] + + #y_corr = [(data_point - e_00[i]) / (data_point * e11[i] - delta_e[i]) for i, data_point in enumerate(y)] + #import numpy as np + #y = [data_point[1] for data_point in self.module.model.data_points] + #open_calibration = [data_point[1] for data_point in self.module.model.open_calibration] + #load_calibration = [data_point[1] for data_point in self.module.model.load_calibration] + #short_calibration = [data_point[1] for data_point in self.module.model.short_calibration] + + #y_corr = np.array(y) - np.array(load_calibration) + #y_corr = y_corr - np.mean(y_corr) + ax = self._ui_form.S11Plot.canvas.ax ax.clear() ax.set_xlabel("Frequency (MHz)") @@ -120,6 +148,7 @@ class AutoTMView(ModuleView): ax.set_title("S11") ax.grid(True) ax.plot(x, y) + ax.plot(x, phase) # make the y axis go down instead of up ax.invert_yaxis() self._ui_form.S11Plot.canvas.draw() @@ -139,4 +168,169 @@ class AutoTMView(ModuleView): text_label = QLabel(text) text_label.setStyleSheet("font-size: 25px;") self._ui_form.scrollAreaWidgetContents.layout().addWidget(text_label) - self._ui_form.scrollArea.verticalScrollBar().setValue(self._ui_form.scrollArea.verticalScrollBar().maximum()) \ No newline at end of file + self._ui_form.scrollArea.verticalScrollBar().setValue(self._ui_form.scrollArea.verticalScrollBar().maximum()) + + def create_frequency_sweep_spinner_dialog(self) -> None: + """Creates a frequency sweep spinner dialog. """ + self.frequency_sweep_spinner = self.FrequencySweepSpinner() + self.frequency_sweep_spinner.show() + + class FrequencySweepSpinner(QDialog): + """This class implements a spinner dialog that is shown during a frequency sweep.""" + + def __init__(self): + super().__init__() + self.setWindowTitle("Frequency sweep") + self.setModal(True) + self.setWindowFlag(Qt.WindowType.FramelessWindowHint) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + path = Path(__file__).parent + self.spinner_movie = QMovie(str(path / "resources/duck_kick.gif")) + self.spinner_label = QLabel(self) + self.spinner_label.setMovie(self.spinner_movie) + + self.layout = QVBoxLayout(self) + self.layout.addWidget(self.spinner_label) + + self.spinner_movie.start() + + + class CalibrationWindow(QWidget): + + def __init__(self, module, parent=None): + super().__init__() + self.module = module + self.setParent(parent) + self.setWindowTitle("Calibration") + + # Add vertical main layout + main_layout = QVBoxLayout() + + # Add horizontal layout for the frequency range + frequency_layout = QHBoxLayout() + main_layout.addLayout(frequency_layout) + frequency_label = QLabel("Frequency range") + frequency_layout.addWidget(frequency_label) + start_edit = QLineEdit() + start_edit.setPlaceholderText("Start") + frequency_layout.addWidget(start_edit) + stop_edit = QLineEdit() + stop_edit.setPlaceholderText("Stop") + frequency_layout.addWidget(stop_edit) + unit_label = QLabel("MHz") + frequency_layout.addWidget(unit_label) + + # Add horizontal layout for the calibration type + type_layout = QHBoxLayout() + main_layout.addLayout(type_layout) + + # Add vertical layout for short calibration + short_layout = QVBoxLayout() + short_button = QPushButton("Short") + short_button.clicked.connect(lambda: self.module.controller.on_short_calibration( + float(start_edit.text()), + float(stop_edit.text()) + )) + # Short plot widget + self.short_plot = MplWidget() + short_layout.addWidget(self.short_plot) + short_layout.addWidget(short_button) + type_layout.addLayout(short_layout) + + # Add vertical layout for open calibration + open_layout = QVBoxLayout() + open_button = QPushButton("Open") + open_button.clicked.connect(lambda: self.module.controller.on_open_calibration( + float(start_edit.text()), + float(stop_edit.text()) + )) + # Open plot widget + self.open_plot = MplWidget() + open_layout.addWidget(self.open_plot) + open_layout.addWidget(open_button) + type_layout.addLayout(open_layout) + + # Add vertical layout for load calibration + load_layout = QVBoxLayout() + load_button = QPushButton("Load") + load_button.clicked.connect(lambda: self.module.controller.on_load_calibration( + float(start_edit.text()), + float(stop_edit.text()) + )) + # Load plot widget + self.load_plot = MplWidget() + load_layout.addWidget(self.load_plot) + load_layout.addWidget(load_button) + type_layout.addLayout(load_layout) + + # Add vertical layout for save calibration + data_layout = QVBoxLayout() + # Export button + export_button = QPushButton("Export") + export_button.clicked.connect(self.on_export_button_clicked) + data_layout.addWidget(export_button) + # Import button + import_button = QPushButton("Import") + import_button.clicked.connect(self.on_import_button_clicked) + data_layout.addWidget(import_button) + # Apply button + apply_button = QPushButton("Apply calibration") + apply_button.clicked.connect(self.on_apply_button_clicked) + data_layout.addWidget(apply_button) + + main_layout.addLayout(data_layout) + + self.setLayout(main_layout) + + # Connect the calibration finished signals to the on_calibration_finished slot + self.module.model.short_calibration_finished.connect(self.on_short_calibration_finished) + self.module.model.open_calibration_finished.connect(self.on_open_calibration_finished) + self.module.model.load_calibration_finished.connect(self.on_load_calibration_finished) + + def on_short_calibration_finished(self, short_calibration : list) -> None: + self.on_calibration_finished("short", self.short_plot, short_calibration) + + def on_open_calibration_finished(self, open_calibration : list) -> None: + self.on_calibration_finished("open", self.open_plot, open_calibration) + + def on_load_calibration_finished(self, load_calibration : list) -> None: + self.on_calibration_finished("load", self.load_plot, load_calibration) + + def on_calibration_finished(self, type : str, widget: MplWidget, data :list) -> None: + """This method is called when a calibration has finished. + It plots the calibration data on the given widget. + """ + x = [data_point[0] for data_point in data] + magnitude = [data_point[1] for data_point in data] + phase = [data_point[2] for data_point in data] + ax = widget.canvas.ax + ax.clear() + ax.set_xlabel("Frequency (MHz)") + ax.set_ylabel("S11 (dB)") + ax.set_title("S11") + ax.grid(True) + ax.plot(x, magnitude, label="Magnitude") + ax.plot(x, phase, label="Phase") + ax.legend() + # make the y axis go down instead of up + ax.invert_yaxis() + widget.canvas.draw() + widget.canvas.flush_events() + + def on_export_button_clicked(self) -> None: + """This method is called when the export button is clicked. """ + pass + + def on_import_button_clicked(self) -> None: + """This method is called when the import button is clicked. """ + pass + + def on_apply_button_clicked(self) -> None: + """This method is called when the apply button is clicked. """ + self.module.controller.calculate_calibration() + # Close the calibration window + self.close() + + + \ No newline at end of file diff --git a/src/nqrduck_autotm/widget.py b/src/nqrduck_autotm/widget.py index 010ea79..0a2c0d2 100644 --- a/src/nqrduck_autotm/widget.py +++ b/src/nqrduck_autotm/widget.py @@ -94,9 +94,9 @@ class Ui_Form(object): self.startButton = QtWidgets.QPushButton(parent=Form) self.startButton.setObjectName("startButton") self.verticalLayout_2.addWidget(self.startButton) - self.pushButton = QtWidgets.QPushButton(parent=Form) - self.pushButton.setObjectName("pushButton") - self.verticalLayout_2.addWidget(self.pushButton) + self.calibrationButton = QtWidgets.QPushButton(parent=Form) + self.calibrationButton.setObjectName("calibrationButton") + self.verticalLayout_2.addWidget(self.calibrationButton) self.pushButton_3 = QtWidgets.QPushButton(parent=Form) self.pushButton_3.setObjectName("pushButton_3") self.verticalLayout_2.addWidget(self.pushButton_3) @@ -149,7 +149,7 @@ class Ui_Form(object): self.label_7.setText(_translate("Form", "Stop Frequency:")) self.label_5.setText(_translate("Form", "Start Frequency:")) self.startButton.setText(_translate("Form", "Start Sweep")) - self.pushButton.setText(_translate("Form", "Calibrate")) + self.calibrationButton.setText(_translate("Form", "Calibrate")) self.pushButton_3.setText(_translate("Form", "T&M Settings")) self.titleinfoLabel.setText(_translate("Form", "Info Box:")) from nqrduck.contrib.mplwidget import MplWidget