diff --git a/NanoVNASaver/Analysis.py b/NanoVNASaver/Analysis.py new file mode 100644 index 0000000..50c7bb7 --- /dev/null +++ b/NanoVNASaver/Analysis.py @@ -0,0 +1,177 @@ +# NanoVNASaver - a python program to view and export Touchstone data from a NanoVNA +# Copyright (C) 2019. Rune B. Broberg +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +import logging +import math + +from PyQt5 import QtWidgets + + +logger = logging.getLogger(__name__) + + +class Analysis: + _widget = None + + def __init__(self, app): + from NanoVNASaver.NanoVNASaver import NanoVNASaver + self.app: NanoVNASaver = app + + def widget(self) -> QtWidgets.QWidget: + return self._widget + + def runAnalysis(self): + pass + + def reset(self): + pass + + +class LowPassAnalysis(Analysis): + def __init__(self, app): + super().__init__(app) + + self._widget = QtWidgets.QWidget() + + layout = QtWidgets.QFormLayout() + self._widget.setLayout(layout) + layout.addRow(QtWidgets.QLabel("Please place " + self.app.markers[0].name + " in the filter passband.")) + self.result_label = QtWidgets.QLabel() + self.cutoff_label = QtWidgets.QLabel() + self.six_db_label = QtWidgets.QLabel() + self.sixty_db_label = QtWidgets.QLabel() + self.db_per_octave_label = QtWidgets.QLabel() + self.db_per_decade_label = QtWidgets.QLabel() + layout.addRow("Result:", self.result_label) + layout.addRow("Cutoff frequency:", self.cutoff_label) + layout.addRow("-6 dB point:", self.six_db_label) + layout.addRow("-60 dB point:", self.sixty_db_label) + layout.addRow("Roll-off:", self.db_per_octave_label) + layout.addRow("Roll-off:", self.db_per_decade_label) + + def reset(self): + self.result_label.clear() + self.cutoff_label.clear() + self.six_db_label.clear() + self.sixty_db_label.clear() + self.db_per_octave_label.clear() + self.db_per_decade_label.clear() + + def runAnalysis(self): + from NanoVNASaver.NanoVNASaver import NanoVNASaver + self.reset() + pass_band_location = self.app.markers[0].location + logger.debug("Pass band location: %d", pass_band_location) + if pass_band_location < 0: + logger.debug("No location for %s", self.app.markers[0].name) + return + + if len(self.app.data21) == 0: + logger.debug("No data to analyse") + return + + pass_band_db = NanoVNASaver.gain(self.app.data21[pass_band_location]) + + logger.debug("Initial passband gain: %d", pass_band_db) + + initial_cutoff_location = -1 + for i in range(pass_band_location, len(self.app.data21)): + db = NanoVNASaver.gain(self.app.data21[i]) + if (pass_band_db - db) > 3: + # We found a cutoff location + initial_cutoff_location = i + break + + if initial_cutoff_location < 0: + self.result_label.setText("Cutoff location not found.") + return + + initial_cutoff_frequency = self.app.data21[initial_cutoff_location].freq + + logger.debug("Found initial cutoff frequency at %d", initial_cutoff_frequency) + + peak_location = -1 + peak_db = NanoVNASaver.gain(self.app.data21[initial_cutoff_location]) + for i in range(0, initial_cutoff_location): + db = NanoVNASaver.gain(self.app.data21[i]) + if db > peak_db: + peak_db = db + peak_location = i + + logger.debug("Found peak of %f at %d", peak_db, self.app.data[peak_location].freq) + + self.app.markers[0].setFrequency(str(self.app.data21[peak_location].freq)) + + cutoff_location = -1 + pass_band_db = peak_db + for i in range(peak_location, len(self.app.data21)): + db = NanoVNASaver.gain(self.app.data21[i]) + if (pass_band_db - db) > 3: + # We found the cutoff location + cutoff_location = i + break + + cutoff_frequency = self.app.data21[cutoff_location].freq + + logger.debug("Found true cutoff frequency at %d", cutoff_frequency) + + self.cutoff_label.setText(NanoVNASaver.formatFrequency(cutoff_frequency)) + self.app.markers[1].setFrequency(str(cutoff_frequency)) + + six_db_location = -1 + for i in range(cutoff_location, len(self.app.data21)): + db = NanoVNASaver.gain(self.app.data21[i]) + if (pass_band_db - db) > 6: + # We found 6dB location + six_db_location = i + break + + if six_db_location < 0: + self.result_label.setText("6 dB location not found.") + return + six_db_cutoff_frequency = self.app.data21[six_db_location].freq + self.six_db_label.setText(NanoVNASaver.formatFrequency(six_db_cutoff_frequency)) + + six_db_attenuation = NanoVNASaver.gain(self.app.data21[six_db_location]) + max_attenuation = NanoVNASaver.gain(self.app.data21[len(self.app.data21) - 1]) + frequency_factor = self.app.data21[len(self.app.data21) - 1].freq / six_db_cutoff_frequency + attenuation = (max_attenuation - six_db_attenuation) + logger.debug("Measured points: %d Hz and %d Hz", six_db_cutoff_frequency, self.app.data21[len(self.app.data21) - 1].freq) + logger.debug("%d dB over %f factor", attenuation, frequency_factor) + octave_attenuation = attenuation / (math.log10(frequency_factor) / math.log10(2)) + self.db_per_octave_label.setText(str(round(octave_attenuation, 3)) + " dB / octave") + decade_attenuation = attenuation / math.log10(frequency_factor) + self.db_per_decade_label.setText(str(round(decade_attenuation, 3)) + " dB / decade") + + sixty_db_location = -1 + for i in range(six_db_location, len(self.app.data21)): + db = NanoVNASaver.gain(self.app.data21[i]) + if (pass_band_db - db) > 60: + # We found 60dB location! Wow. + sixty_db_location = i + break + + if sixty_db_location < 0: + # # We derive 60 dB instead + # factor = 10 * (-54 / decade_attenuation) + # sixty_db_cutoff_frequency = round(six_db_cutoff_frequency + six_db_cutoff_frequency * factor) + # self.sixty_db_label.setText(NanoVNASaver.formatFrequency(sixty_db_cutoff_frequency) + " (derived)") + self.sixty_db_label.setText("Not calculated") + + else: + sixty_db_cutoff_frequency = self.app.data21[sixty_db_location].freq + self.sixty_db_label.setText(NanoVNASaver.formatFrequency(sixty_db_cutoff_frequency)) + + self.result_label.setText("Analysis complete (" + str(len(self.app.data)) + " points)") diff --git a/NanoVNASaver/Calibration.py b/NanoVNASaver/Calibration.py index 1807ca3..d8809d6 100644 --- a/NanoVNASaver/Calibration.py +++ b/NanoVNASaver/Calibration.py @@ -446,6 +446,7 @@ class CalibrationWindow(QtWidgets.QWidget): self.app.calibration.throughLength = float(self.through_length.text())/10**12 self.app.calibration.useIdealThrough = False + logger.debug("Attempting calibration calculation.") if self.app.calibration.calculateCorrections(): self.calibration_status_label.setText("Application calibration (" + str(len(self.app.calibration.s11short)) + " points)") @@ -722,6 +723,7 @@ class Calibration: def calculateCorrections(self): if not self.isValid1Port(): + logger.warning("Tried to calibrate from insufficient data.") return False self.frequencies = [int] * len(self.s11short) self.e00 = [np.complex] * len(self.s11short) @@ -729,6 +731,15 @@ class Calibration: self.deltaE = [np.complex] * len(self.s11short) self.e30 = [np.complex] * len(self.s11short) self.e10e32 = [np.complex] * len(self.s11short) + logger.debug("Calculating calibration for %d points.", len(self.s11short)) + if self.useIdealShort: + logger.debug("Using ideal values.") + else: + logger.debug("Using calibration set values.") + if self.isValid2Port(): + logger.debug("Calculating 2-port calibration.") + else: + logger.debug("Calculating 1-port calibration.") for i in range(len(self.s11short)): self.frequencies[i] = self.s11short[i].freq f = self.s11short[i].freq @@ -776,7 +787,7 @@ class Calibration: except ZeroDivisionError: self.isCalculated = False logger.error("Division error - did you use the same measurement for two of short, open and load?") - return + return self.isCalculated if self.isValid2Port(): self.e30[i] = np.complex(self.s21isolation[i].re, self.s21isolation[i].im) @@ -787,6 +798,7 @@ class Calibration: self.e10e32[i] = (s21m - self.e30[i]) * (1 - (self.e11[i]*self.e11[i])) self.isCalculated = True + logger.debug("Calibration correctly calculated.") return self.isCalculated def correct11(self, re, im, freq): diff --git a/NanoVNASaver/Marker.py b/NanoVNASaver/Marker.py index 2211049..beb99ff 100644 --- a/NanoVNASaver/Marker.py +++ b/NanoVNASaver/Marker.py @@ -139,12 +139,31 @@ class Marker(QtCore.QObject): # Set the frequency before loading any data return - stepsize = data[1].freq-data[0].freq + min_freq = data[0].freq + max_freq = data[len(data)-1].freq + stepsize = data[1].freq - data[0].freq + + if self.frequency + stepsize/2 < min_freq or self.frequency - stepsize/2 > max_freq: + return + for i in range(len(data)): - if abs(data[i].freq-self.frequency) <= (stepsize/2): + if abs(data[i].freq - self.frequency) <= (stepsize/2): self.location = i return + # No position found, but we are within the span + min_distance = max_freq + for i in range(len(data)): + if abs(data[i].freq - self.frequency) < min_distance: + min_distance = abs(data[i].freq - self.frequency) + else: + # We have now started moving away from the nearest point + self.location = i-1 + return + # If we still didn't find a best spot, it was the last value + self.location = len(data)-1 + return + def getGroupBox(self): return self.group_box diff --git a/NanoVNASaver/NanoVNASaver.py b/NanoVNASaver/NanoVNASaver.py index d420927..024c1eb 100644 --- a/NanoVNASaver/NanoVNASaver.py +++ b/NanoVNASaver/NanoVNASaver.py @@ -34,6 +34,7 @@ from .Calibration import CalibrationWindow, Calibration from .Marker import Marker from .SweepWorker import SweepWorker from .Touchstone import Touchstone +from .Analysis import Analysis, LowPassAnalysis from .about import version as ver Datapoint = collections.namedtuple('Datapoint', 'freq re im') @@ -312,6 +313,12 @@ class NanoVNASaver(QtWidgets.QWidget): marker_column.addSpacerItem(QtWidgets.QSpacerItem(1, 1, QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Expanding)) + self.analysis_window = AnalysisWindow(self) + + btn_show_analysis = QtWidgets.QPushButton("Analysis ...") + btn_show_analysis.clicked.connect(self.displayAnalysisWindow) + marker_column.addWidget(btn_show_analysis) + ################################################################################################################ # TDR ################################################################################################################ @@ -1050,6 +1057,10 @@ class NanoVNASaver(QtWidgets.QWidget): self.tdr_window.show() QtWidgets.QApplication.setActiveWindow(self.tdr_window) + def displayAnalysisWindow(self): + self.analysis_window.show() + QtWidgets.QApplication.setActiveWindow(self.analysis_window) + def showError(self, text): error_message = QtWidgets.QErrorMessage(self) error_message.showMessage(text) @@ -1641,6 +1652,7 @@ class SweepSettingsWindow(QtWidgets.QWidget): if self.band_pad_limits.isChecked(): span = stop - start start -= round(span / 10) + start = max(1, start) stop += round(span / 10) self.app.sweepStartInput.setText(str(start)) @@ -1823,3 +1835,48 @@ class BandsModel(QtCore.QAbstractTableModel): def setColor(self, color): self.color = color + + +class AnalysisWindow(QtWidgets.QWidget): + analyses = [] + analysis: Analysis = None + + def __init__(self, app): + super().__init__() + + self.app: NanoVNASaver = app + self.setWindowTitle("Sweep analysis") + self.setWindowIcon(self.app.icon) + + self.setMinimumSize(400, 300) + + shortcut = QtWidgets.QShortcut(QtCore.Qt.Key_Escape, self, self.hide) + + layout = QtWidgets.QVBoxLayout() + self.setLayout(layout) + + select_analysis_box = QtWidgets.QGroupBox("Select analysis") + select_analysis_layout = QtWidgets.QFormLayout(select_analysis_box) + analysis_list = QtWidgets.QComboBox() + analysis_list.addItem("Low-pass filter") + analysis_list.addItem("Band-pass filter") + analysis_list.addItem("High-pass filter") + select_analysis_layout.addRow("Analysis type", analysis_list) + + btn_run_analysis = QtWidgets.QPushButton("Run analysis") + btn_run_analysis.clicked.connect(self.runAnalysis) + select_analysis_layout.addRow(btn_run_analysis) + + analysis_box = QtWidgets.QGroupBox("Analysis") + analysis_layout = QtWidgets.QVBoxLayout(analysis_box) + + #### TEMPORARY #### + self.analysis = LowPassAnalysis(app) + analysis_layout.addWidget(self.analysis.widget()) + + layout.addWidget(select_analysis_box) + layout.addWidget(analysis_box) + + def runAnalysis(self): + if self.analysis is not None: + self.analysis.runAnalysis() diff --git a/NanoVNASaver/SweepWorker.py b/NanoVNASaver/SweepWorker.py index a05eae4..a3d1dc8 100644 --- a/NanoVNASaver/SweepWorker.py +++ b/NanoVNASaver/SweepWorker.py @@ -271,7 +271,9 @@ class SweepWorker(QtCore.QRunnable): def readSegment(self, start, stop): logger.debug("Setting sweep range to %d to %d", start, stop) self.app.setSweep(start, stop) - sleep(0.3) + sleep(1) # TODO This long delay seems to fix the weird data transitions we were seeing by getting partial + # sweeps. Clearly something needs to be done, maybe at firmware level, to address this fully. + # Let's check the frequencies first: frequencies = self.readFreq() # TODO: Set up checks for getting the right frequencies. Challenge: We don't set frequency to single-Hz