diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..487e667b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Default for all text files +* text=auto whitespace=trailing-space,tab-in-indent,tabwidth=2 +*.py text=auto whitespace=trailing-space,tab-in-indent,tabwidth=4 + +# Denote all files that are truly binary and should not be modified. +*.png binary +*.jpg binary diff --git a/.gitignore b/.gitignore index 5c11ba05..77b21b34 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ settings.json .gitignore .coverage +/nanovna-saver.exe.spec \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 841431ad..1ccc1761 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ Changelog ========= +v0.3.9 +------ + +- TX Power on V2 +- New analysis +- Magnitude Z Chart +- VSWR Chart improvements + v0.3.8 ------ diff --git a/NanoVNASaver/About.py b/NanoVNASaver/About.py index 0b195f83..ec594301 100644 --- a/NanoVNASaver/About.py +++ b/NanoVNASaver/About.py @@ -17,7 +17,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -VERSION = "0.3.8" +VERSION = "0.3.9" VERSION_URL = ( "https://raw.githubusercontent.com/" "NanoVNA-Saver/nanovna-saver/master/NanoVNASaver/About.py") diff --git a/NanoVNASaver/Analysis/Analysis.py b/NanoVNASaver/Analysis/Analysis.py index cf0fdd15..241f280f 100644 --- a/NanoVNASaver/Analysis/Analysis.py +++ b/NanoVNASaver/Analysis/Analysis.py @@ -18,8 +18,10 @@ # along with this program. If not, see . import logging import math - +import numpy as np +from scipy.signal import argrelextrema from PyQt5 import QtWidgets +from scipy import signal logger = logging.getLogger(__name__) @@ -27,6 +29,98 @@ class Analysis: _widget = None + @classmethod + def find_crossing_zero(cls, data, threshold=0): + ''' + + Find values crossing zero + return list of tuples (before, crossing, after) + indicating the index of data list + crossing is where data == 0 + or data nearest 0 + + at maximum 1 value == 0 + data must not start or end with 0 + + + :param cls: + :param data: list of values + :param threshold: unused, for future manage flipping around 0 + ''' + my_data = np.array(data) + zeroes = np.where(my_data == 0)[0] + + if 0 in zeroes: + raise ValueError("Data must non start with 0") + + if len(data) - 1 in zeroes: + raise ValueError("Data must non end with 0") + crossing = [(n - 1, n, n + 1) for n in zeroes] + + for n in np.where((my_data[:-1] * my_data[1:]) < 0)[0]: + if abs(data[n]) <= abs(data[n + 1]): + crossing.append((n, n, n + 1)) + else: + crossing.append((n, n + 1, n + 1)) + + return crossing + + @classmethod + def find_minimums(cls, data, threshold): + ''' + + Find values above threshold + return list of tuples (start, lowest, end) + indicating the index of data list + + + :param cls: + :param data: list of values + :param threshold: + ''' + + minimums = [] + min_start = -1 + min_idx = -1 + + min_val = threshold + for i, d in enumerate(data): + if d < threshold and i < len(data) - 1: + if d < min_val: + min_val = d + min_idx = i + if min_start == -1: + min_start = i + elif min_start != -1: + # We are above the threshold, and were in a section that was + # below + minimums.append((min_start, min_idx, i - 1)) + min_start = -1 + min_idx = -1 + min_val = threshold + return minimums + + @classmethod + def find_maximums(cls, data, threshold=None): + ''' + + Find peacs + + + :param cls: + :param data: list of values + :param threshold: + ''' + peaks, _ = signal.find_peaks( + data, width=2, distance=3, prominence=1) + +# my_data = np.array(data) +# maximums = argrelextrema(my_data, np.greater)[0] + if threshold is None: + return peaks + else: + return [k for k in peaks if data[k] > threshold] + def __init__(self, app: QtWidgets.QWidget): self.app = app @@ -50,8 +144,10 @@ def calculateRolloff(self, location1, location2): if frequency_factor < 1: frequency_factor = 1 / frequency_factor attenuation = abs(gain1 - gain2) - logger.debug("Measured points: %d Hz and %d Hz", frequency1, frequency2) + logger.debug("Measured points: %d Hz and %d Hz", + frequency1, frequency2) logger.debug("%f dB over %f factor", attenuation, frequency_factor) - octave_attenuation = attenuation / (math.log10(frequency_factor) / math.log10(2)) + octave_attenuation = attenuation / \ + (math.log10(frequency_factor) / math.log10(2)) decade_attenuation = attenuation / math.log10(frequency_factor) return octave_attenuation, decade_attenuation diff --git a/NanoVNASaver/Analysis/PeakSearchAnalysis.py b/NanoVNASaver/Analysis/PeakSearchAnalysis.py index c72465bc..0611f962 100644 --- a/NanoVNASaver/Analysis/PeakSearchAnalysis.py +++ b/NanoVNASaver/Analysis/PeakSearchAnalysis.py @@ -23,6 +23,10 @@ import numpy as np from NanoVNASaver.Analysis import Analysis +from NanoVNASaver.Formatting import format_vswr +from NanoVNASaver.Formatting import format_gain +from NanoVNASaver.Formatting import format_resistance +from NanoVNASaver.Formatting import format_frequency_short logger = logging.getLogger(__name__) @@ -38,8 +42,8 @@ def __init__(self, app): super().__init__(app) self._widget = QtWidgets.QWidget() - outer_layout = QtWidgets.QFormLayout() - self._widget.setLayout(outer_layout) + self.layout = QtWidgets.QFormLayout() + self._widget.setLayout(self.layout) self.rbtn_data_group = QtWidgets.QButtonGroup() self.rbtn_data_vswr = QtWidgets.QRadioButton("VSWR") @@ -70,40 +74,56 @@ def __init__(self, app): self.checkbox_move_markers = QtWidgets.QCheckBox() - outer_layout.addRow(QtWidgets.QLabel("Settings")) - outer_layout.addRow("Data source", self.rbtn_data_vswr) - outer_layout.addRow("", self.rbtn_data_resistance) - outer_layout.addRow("", self.rbtn_data_reactance) - outer_layout.addRow("", self.rbtn_data_s21_gain) - outer_layout.addRow(PeakSearchAnalysis.QHLine()) - outer_layout.addRow("Peak type", self.rbtn_peak_positive) - outer_layout.addRow("", self.rbtn_peak_negative) + self.layout.addRow(QtWidgets.QLabel("Settings")) + self.layout.addRow("Data source", self.rbtn_data_vswr) + self.layout.addRow("", self.rbtn_data_resistance) + self.layout.addRow("", self.rbtn_data_reactance) + self.layout.addRow("", self.rbtn_data_s21_gain) + self.layout.addRow(PeakSearchAnalysis.QHLine()) + self.layout.addRow("Peak type", self.rbtn_peak_positive) + self.layout.addRow("", self.rbtn_peak_negative) # outer_layout.addRow("", self.rbtn_peak_both) - outer_layout.addRow(PeakSearchAnalysis.QHLine()) - outer_layout.addRow("Max number of peaks", self.input_number_of_peaks) - outer_layout.addRow("Move markers", self.checkbox_move_markers) - outer_layout.addRow(PeakSearchAnalysis.QHLine()) - - outer_layout.addRow(QtWidgets.QLabel("Results")) + self.layout.addRow(PeakSearchAnalysis.QHLine()) + self.layout.addRow("Max number of peaks", self.input_number_of_peaks) + self.layout.addRow("Move markers", self.checkbox_move_markers) + self.layout.addRow(PeakSearchAnalysis.QHLine()) + self.layout.addRow(QtWidgets.QLabel("Results")) + self.results_header = self.layout.rowCount() def runAnalysis(self): + self.reset() + data = [] + sign = 1 count = self.input_number_of_peaks.value() if self.rbtn_data_vswr.isChecked(): - data = [] + fn = format_vswr for d in self.app.data11: - data11.append(d.vswr) + data.append(d.vswr) elif self.rbtn_data_s21_gain.isChecked(): - data = [] + fn = format_gain for d in self.app.data21: data.append(d.gain) + elif self.rbtn_data_resistance.isChecked(): + fn = format_resistance + for d in self.app.data11: + data.append(d.impedance().real) + elif self.rbtn_data_reactance.isChecked(): + fn = str + for d in self.app.data11: + data.append(d.impedance().imag) + else: logger.warning("Searching for peaks on unknown data") return if self.rbtn_peak_positive.isChecked(): - peaks, _ = signal.find_peaks(data, width=3, distance=3, prominence=1) + peaks, _ = signal.find_peaks( + data, width=3, distance=3, prominence=1) elif self.rbtn_peak_negative.isChecked(): - peaks, _ = signal.find_peaks(np.array(data)*-1, width=3, distance=3, prominence=1) + sign = -1 + data = [x * sign for x in data] + peaks, _ = signal.find_peaks( + data, width=3, distance=3, prominence=1) # elif self.rbtn_peak_both.isChecked(): # peaks_max, _ = signal.find_peaks(data, width=3, distance=3, prominence=1) # peaks_min, _ = signal.find_peaks(np.array(data)*-1, width=3, distance=3, prominence=1) @@ -117,8 +137,8 @@ def runAnalysis(self): # Having found the peaks, get the prominence data - for p in peaks: - logger.debug("Peak at %d", p) + for i, p in np.ndenumerate(peaks): + logger.debug("Peak %i at %d", i, p) prominences = signal.peak_prominences(data, peaks)[0] logger.debug("%d prominences", len(prominences)) @@ -131,9 +151,13 @@ def runAnalysis(self): logger.debug("Prominence %f", prominences[i]) logger.debug("Index in sweep %d", peaks[i]) logger.debug("Frequency %d", self.app.data11[peaks[i]].freq) - logger.debug("Value %f", data[peaks[i]]) + logger.debug("Value %f", sign * data[peaks[i]]) + self.layout.addRow( + f"Freq {format_frequency_short(self.app.data11[peaks[i]].freq)}", + QtWidgets.QLabel(f" value {fn(sign * data[peaks[i]])}" + )) - if self.checkbox_move_markers: + if self.checkbox_move_markers.isChecked(): if count > len(self.app.markers): logger.warning("More peaks found than there are markers") for i in range(min(count, len(self.app.markers))): @@ -152,4 +176,10 @@ def runAnalysis(self): logger.debug("Max peak at %d, value %f", max_idx, max_val) def reset(self): - pass + logger.debug("Reset analysis") + + logger.debug("Results start at %d, out of %d", + self.results_header, self.layout.rowCount()) + for i in range(self.results_header, self.layout.rowCount()): + logger.debug("deleting %s", self.layout.rowCount()) + self.layout.removeRow(self.layout.rowCount() - 1) diff --git a/NanoVNASaver/Analysis/VSWRAnalysis.py b/NanoVNASaver/Analysis/VSWRAnalysis.py index decfdeb9..200dda46 100644 --- a/NanoVNASaver/Analysis/VSWRAnalysis.py +++ b/NanoVNASaver/Analysis/VSWRAnalysis.py @@ -23,14 +23,32 @@ from NanoVNASaver.Analysis import Analysis, PeakSearchAnalysis from NanoVNASaver.Formatting import format_frequency +from NanoVNASaver.Formatting import format_complex_imp +from NanoVNASaver.RFTools import reflection_coefficient +import os +import csv +from NanoVNASaver.Marker.Values import Label +from NanoVNASaver.Marker.Widget import MarkerLabel +from NanoVNASaver.Marker.Widget import Marker +from collections import OrderedDict +from NanoVNASaver.Formatting import format_frequency_short +from NanoVNASaver.Formatting import format_resistance logger = logging.getLogger(__name__) +def round_2(x): + return round(x, 2) + + +def format_resistence_neg(x): + return format_resistance(x, allow_negative=True) + + class VSWRAnalysis(Analysis): max_dips_shown = 3 vswr_limit_value = 1.5 - + class QHLine(QtWidgets.QFrame): def __init__(self): super().__init__() @@ -61,6 +79,7 @@ def __init__(self, app): def runAnalysis(self): max_dips_shown = self.max_dips_shown data = [] + for d in self.app.data11: data.append(d.vswr) # min_idx = np.argmin(data) @@ -73,31 +92,17 @@ def runAnalysis(self): # self.app.markers[0].setFrequency(str(self.app.data11[min_idx].freq)) # self.app.markers[0].frequencyInput.setText(str(self.app.data11[min_idx].freq)) - minimums = [] - min_start = -1 - min_idx = -1 threshold = self.input_vswr_limit.value() - min_val = threshold - for i, d in enumerate(data): - if d < threshold and i < len(data)-1: - if d < min_val: - min_val = d - min_idx = i - if min_start == -1: - min_start = i - elif min_start != -1: - # We are above the threshold, and were in a section that was below - minimums.append((min_start, min_idx, i-1)) - min_start = -1 - min_idx = -1 - min_val = threshold - - logger.debug("Found %d sections under %f threshold", len(minimums), threshold) + minimums = self.find_minimums(data, threshold) + + logger.debug("Found %d sections under %f threshold", + len(minimums), threshold) results_header = self.layout.indexOf(self.results_label) - logger.debug("Results start at %d, out of %d", results_header, self.layout.rowCount()) + logger.debug("Results start at %d, out of %d", + results_header, self.layout.rowCount()) for i in range(results_header, self.layout.rowCount()): - self.layout.removeRow(self.layout.rowCount()-1) + self.layout.removeRow(self.layout.rowCount() - 1) if len(minimums) > max_dips_shown: self.layout.addRow(QtWidgets.QLabel("More than " + str(max_dips_shown) + @@ -141,7 +146,341 @@ def runAnalysis(self): format_frequency(self.app.data11[lowest].freq))) self.layout.addWidget(PeakSearchAnalysis.QHLine()) # Remove the final separator line - self.layout.removeRow(self.layout.rowCount()-1) + self.layout.removeRow(self.layout.rowCount() - 1) else: self.layout.addRow(QtWidgets.QLabel( "No areas found with VSWR below " + str(round(threshold, 2)) + ".")) + + +class ResonanceAnalysis(Analysis): + # max_dips_shown = 3 + + @classmethod + def vswr_transformed(cls, z, ratio=49) -> float: + refl = reflection_coefficient(z / ratio) + mag = abs(refl) + if mag == 1: + return 1 + return (1 + mag) / (1 - mag) + + class QHLine(QtWidgets.QFrame): + def __init__(self): + super().__init__() + self.setFrameShape(QtWidgets.QFrame.HLine) + + def __init__(self, app): + super().__init__(app) + + self._widget = QtWidgets.QWidget() + self.layout = QtWidgets.QFormLayout() + self._widget.setLayout(self.layout) + self.input_description = QtWidgets.QLineEdit("") + self.checkbox_move_marker = QtWidgets.QCheckBox() + self.layout.addRow(QtWidgets.QLabel("Settings")) + self.layout.addRow("Description", self.input_description) + self.layout.addRow(VSWRAnalysis.QHLine()) + + self.layout.addRow(VSWRAnalysis.QHLine()) + + self.results_label = QtWidgets.QLabel("Results") + self.layout.addRow(self.results_label) + + def _get_data(self, index): + my_data = {"freq": self.app.data11[index].freq, + "s11": self.app.data11[index].z, + "lambda": self.app.data11[index].wavelength, + "impedance": self.app.data11[index].impedance(), + "vswr": self.app.data11[index].vswr, + } + my_data["vswr_49"] = self.vswr_transformed( + my_data["impedance"], 49) + my_data["vswr_4"] = self.vswr_transformed( + my_data["impedance"], 4) + my_data["r"] = my_data["impedance"].real + my_data["x"] = my_data["impedance"].imag + + return my_data + + def _get_crossing(self): + + data = [] + for d in self.app.data11: + data.append(d.phase) + + crossing = sorted(self.find_crossing_zero(data)) + return crossing + + def runAnalysis(self): + self.reset() + # self.results_label = QtWidgets.QLabel("Results") + # max_dips_shown = self.max_dips_shown + description = self.input_description.text() + if description: + filename = os.path.join("/tmp/", "{}.csv".format(description)) + else: + filename = None + + crossing = self._get_crossing() + + logger.debug("Found %d sections ", + len(crossing)) + + results_header = self.layout.indexOf(self.results_label) + logger.debug("Results start at %d, out of %d", + results_header, self.layout.rowCount()) + for i in range(results_header, self.layout.rowCount()): + self.layout.removeRow(self.layout.rowCount() - 1) + +# if len(crossing) > max_dips_shown: +# self.layout.addRow(QtWidgets.QLabel("More than " + str(max_dips_shown) + +# " dips found. Lowest shown.")) + +# self.crossing = crossing[:max_dips_shown] + extended_data = [] + if len(crossing) > 0: + + for m in crossing: + start, lowest, end = m + my_data = self._get_data(lowest) + + extended_data.append(my_data) + if start != end: + logger.debug( + "Section from %d to %d, lowest at %d", start, end, lowest) + + self.layout.addRow( + "Resonance", + QtWidgets.QLabel( + f"{format_frequency(self.app.data11[lowest].freq)}" + f" ({format_complex_imp(self.app.data11[lowest].impedance())})")) + else: + self.layout.addRow("Resonance", QtWidgets.QLabel( + format_frequency(self.app.data11[lowest].freq))) + self.layout.addWidget(PeakSearchAnalysis.QHLine()) + # Remove the final separator line + self.layout.removeRow(self.layout.rowCount() - 1) + if filename and extended_data: + + with open(filename, 'w', newline='') as csvfile: + fieldnames = extended_data[0].keys() + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + + writer.writeheader() + for row in extended_data: + writer.writerow(row) + + else: + self.layout.addRow(QtWidgets.QLabel( + "No resonance found")) + + +class EFHWAnalysis(ResonanceAnalysis): + ''' + find only resonance when HI impedance + ''' + old_data = [] + + def reset(self): + logger.debug("reset") + + def runAnalysis(self): + self.reset() + # self.results_label = QtWidgets.QLabel("Results") + # max_dips_shown = self.max_dips_shown + description = self.input_description.text() + if description: + filename = os.path.join("/tmp/", "{}.csv".format(description)) + else: + filename = None + + crossing = self._get_crossing() + + data = [] + for d in self.app.data11: + data.append(d.impedance().real) + + maximums = sorted(self.find_maximums(data, threshold=500)) + + results_header = self.layout.indexOf(self.results_label) + logger.debug("Results start at %d, out of %d", + results_header, self.layout.rowCount()) + for i in range(results_header, self.layout.rowCount()): + self.layout.removeRow(self.layout.rowCount() - 1) + + extended_data = OrderedDict() + + #both = np.intersect1d([i[1] for i in crossing], maximums) + both = [] + + tolerance = 2 + for i in maximums: + for l, _, h in crossing: + if l - tolerance <= i <= h + tolerance: + both.append(i) + continue + if l > i: + continue + + if both: + logger.info("%i crossing HW", len(both)) + logger.info(crossing) + logger.info(maximums) + logger.info(both) + for m in both: + my_data = self._get_data(m) + if m in extended_data: + extended_data[m].update(my_data) + else: + extended_data[m] = my_data + for i in range(min(len(both), len(self.app.markers))): + + # self.app.markers[i].label = {} + # for l in TYPES: + # self.app.markers[i][l.label_id] = MarkerLabel(l.name) + # self.app.markers[i].label['actualfreq'].setMinimumWidth( + # 100) + # self.app.markers[i].label['returnloss'].setMinimumWidth(80) + + self.app.markers[i].setFrequency( + str(self.app.data11[both[i]].freq)) + self.app.markers[i].frequencyInput.setText( + str(self.app.data11[both[i]].freq)) + else: + logger.info("TO DO: find near data") + for m in crossing: + start, lowest, end = m + my_data = self._get_data(lowest) + + if lowest in extended_data: + extended_data[lowest].update(my_data) + else: + extended_data[lowest] = my_data + + logger.debug("maximumx %s of type %s", maximums, type(maximums)) + for m in maximums: + logger.debug("m %s of type %s", m, type(m)) + + my_data = self._get_data(m) + if m in extended_data: + extended_data[m].update(my_data) + else: + extended_data[m] = my_data + + # saving and comparing + + fields = [("freq", format_frequency_short), + ("r", format_resistence_neg), + ("lambda", round_2), + ] + if self.old_data: + diff = self.compare( + self.old_data[-1], extended_data, fields=fields) + else: + diff = self.compare({}, extended_data, fields=fields) + self.old_data.append(extended_data) + + for i, index in enumerate(sorted(extended_data.keys())): + + self.layout.addRow( + f"{format_frequency_short(self.app.data11[index].freq)}", + QtWidgets.QLabel(f" ({diff[i]['freq']})" + f" {format_complex_imp(self.app.data11[index].impedance())}" + f" ({diff[i]['r']})" + f" {diff[i]['lambda']} m")) + + # Remove the final separator line + # self.layout.removeRow(self.layout.rowCount() - 1) + if filename and extended_data: + + with open(filename, 'w', newline='') as csvfile: + fieldnames = extended_data[sorted( + extended_data.keys())[0]].keys() + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + + writer.writeheader() + for index in sorted(extended_data.keys()): + row = extended_data[index] + writer.writerow(row) + + def compare(self, old, new, fields=[("freq", str), ]): + ''' + Compare data to help changes + + NB + must be same sweep + ( same index must be same frequence ) + :param old: + :param new: + ''' + + def no_compare(): + + return {k: "-" for k, _ in fields} + + old_idx = sorted(old.keys()) + # 'odict_keys' object is not subscriptable + new_idx = sorted(new.keys()) + diff = {} + i_max = min(len(old_idx), len(new_idx)) + i_tot = max(len(old_idx), len(new_idx)) + + if i_max == i_tot: + logger.debug("may be the same antenna ... analyzing") + + else: + logger.warning("resonances changed from %s to %s", + len(old_idx), len(new_idx)) + + logger.debug("Trying to compare only first %s resonances", i_max) + + split = 0 + max_delta_f = 1000000 # 1M + for i, k in enumerate(new_idx): + my_diff = {} + + logger.info("Risonance %s at %s", i, + format_frequency(new[k]["freq"])) + + if len(old_idx) <= i + split: + diff[i] = no_compare() + continue + + delta_f = new[k]["freq"] - old[old_idx[i + split]]["freq"] + if abs(delta_f) < max_delta_f: + logger.debug("can compare") + + else: + logger.debug("can't compare, %s is too much ", + format_frequency(delta_f)) + if delta_f > 0: + + logger.debug("possible missing band, ") + if (len(old_idx) > (i + split + 1)): + if abs(new[k]["freq"] - old[old_idx[i + split + 1]]["freq"]) < max_delta_f: + logger.debug("new is missing band, compare next ") + split += 1 + # FIXME: manage 2 or more band missing ?!? + else: + logger.debug("new band, non compare ") + diff[i] = no_compare() + continue + else: + logger.debug("new band, non compare ") + diff[i] = no_compare() + + split -= 1 + continue + + for d, fn in fields: + my_diff[d] = fn(new[k][d] - old[old_idx[i + split]][d]) + logger.info("Delta %s = %s", d, + my_diff[d]) + + diff[i] = my_diff + + for i in range(i_max, i_tot): + # add missing in old ... if any + + diff[i] = no_compare() + + return diff diff --git a/NanoVNASaver/Charts/Frequency.py b/NanoVNASaver/Charts/Frequency.py index 7b99309e..66e9fd68 100644 --- a/NanoVNASaver/Charts/Frequency.py +++ b/NanoVNASaver/Charts/Frequency.py @@ -44,6 +44,7 @@ class FrequencyChart(Chart): fixedValues = False logarithmicX = False + logarithmicY = False leftMargin = 30 rightMargin = 20 @@ -132,6 +133,23 @@ def __init__(self, name): self.y_menu.addAction(self.action_set_fixed_maximum) self.y_menu.addAction(self.action_set_fixed_minimum) + if self.logarithmicYAllowed(): # This only works for some plot types + self.y_menu.addSeparator() + vertical_mode_group = QtWidgets.QActionGroup(self.y_menu) + self.action_set_linear_y = QtWidgets.QAction("Linear") + self.action_set_linear_y.setCheckable(True) + self.action_set_logarithmic_y = QtWidgets.QAction("Logarithmic") + self.action_set_logarithmic_y.setCheckable(True) + vertical_mode_group.addAction(self.action_set_linear_y) + vertical_mode_group.addAction(self.action_set_logarithmic_y) + self.action_set_linear_y.triggered.connect( + lambda: self.setLogarithmicY(False)) + self.action_set_logarithmic_y.triggered.connect( + lambda: self.setLogarithmicY(True)) + self.action_set_linear_y.setChecked(True) + self.y_menu.addAction(self.action_set_linear_y) + self.y_menu.addAction(self.action_set_logarithmic_y) + self.menu.addMenu(self.x_menu) self.menu.addMenu(self.y_menu) self.menu.addSeparator() @@ -178,12 +196,21 @@ def setFixedValues(self, fixed_values: bool): self.fixedValues = False self.y_action_automatic.setChecked(True) self.y_action_fixed_span.setChecked(False) + if fixed_values and self.minDisplayValue <= 0: + self.minDisplayValue = 0.01 self.update() def setLogarithmicX(self, logarithmic: bool): self.logarithmicX = logarithmic self.update() + def setLogarithmicY(self, logarithmic: bool): + self.logarithmicY = logarithmic and self.logarithmicYAllowed() + self.update() + + def logarithmicYAllowed(self) -> bool: + return False + def setMinimumFrequency(self): min_freq_str, selected = QtWidgets.QInputDialog.getText( self, "Start frequency", @@ -217,6 +244,8 @@ def setMinimumValue(self): return if not (self.fixedValues and min_val >= self.maxDisplayValue): self.minDisplayValue = min_val + if self.logarithmicY and min_val <= 0: + self.minDisplayValue = 0.01 if self.fixedValues: self.update() @@ -239,6 +268,9 @@ def resetDisplayLimits(self): self.action_automatic.setChecked(True) self.logarithmicX = False self.action_set_linear_x.setChecked(True) + self.logarithmicY = False + if self.logarithmicYAllowed(): + self.action_set_linear_y.setChecked(True) self.update() def getXPosition(self, d: Datapoint) -> int: @@ -585,6 +617,11 @@ def copy(self): new_chart.setLogarithmicX(self.logarithmicX) new_chart.action_set_logarithmic_x.setChecked(self.logarithmicX) new_chart.action_set_linear_x.setChecked(not self.logarithmicX) + + new_chart.setLogarithmicY(self.logarithmicY) + if self.logarithmicYAllowed(): + new_chart.action_set_logarithmic_y.setChecked(self.logarithmicY) + new_chart.action_set_linear_y.setChecked(not self.logarithmicY) return new_chart def keyPressEvent(self, a0: QtGui.QKeyEvent) -> None: diff --git a/NanoVNASaver/Charts/MagnitudeZ.py b/NanoVNASaver/Charts/MagnitudeZ.py index 6b13bd46..40390bb5 100644 --- a/NanoVNASaver/Charts/MagnitudeZ.py +++ b/NanoVNASaver/Charts/MagnitudeZ.py @@ -23,6 +23,7 @@ from PyQt5 import QtWidgets, QtGui from NanoVNASaver.RFTools import Datapoint +from NanoVNASaver.SITools import Format, Value from .Frequency import FrequencyChart from .LogMag import LogMagChart @@ -82,13 +83,18 @@ def drawValues(self, qp: QtGui.QPainter): maxValue = self.maxDisplayValue minValue = self.minDisplayValue self.maxValue = maxValue - self.minValue = minValue + if self.logarithmicY and minValue <= 0: + self.minValue = 0.01 + else: + self.minValue = minValue else: # Find scaling minValue = 100 maxValue = 0 for d in self.data: mag = self.magnitude(d) + if math.isinf(mag): # Avoid infinite scales + continue if mag > maxValue: maxValue = mag if mag < minValue: @@ -97,13 +103,18 @@ def drawValues(self, qp: QtGui.QPainter): if d.freq < self.fstart or d.freq > self.fstop: continue mag = self.magnitude(d) + if math.isinf(mag): # Avoid infinite scales + continue if mag > maxValue: maxValue = mag if mag < minValue: minValue = mag minValue = 10*math.floor(minValue/10) + if self.logarithmicY and minValue <= 0: + minValue = 0.01 self.minValue = minValue + maxValue = 10*math.ceil(maxValue/10) self.maxValue = maxValue @@ -112,28 +123,22 @@ def drawValues(self, qp: QtGui.QPainter): span = 0.01 self.span = span - target_ticks = math.floor(self.chartHeight / 60) - - for i in range(target_ticks): - val = minValue + (i / target_ticks) * span - y = self.topMargin + round((self.maxValue - val) / self.span * self.chartHeight) - qp.setPen(self.textColor) - if val != minValue: - digits = max(0, min(2, math.floor(3 - math.log10(abs(val))))) - if digits == 0: - vswrstr = str(round(val)) - else: - vswrstr = str(round(val, digits)) - qp.drawText(3, y + 3, vswrstr) + # We want one horizontal tick per 50 pixels, at most + horizontal_ticks = math.floor(self.chartHeight/50) + fmt = Format(max_nr_digits=4) + for i in range(horizontal_ticks): + y = self.topMargin + round(i * self.chartHeight / horizontal_ticks) qp.setPen(QtGui.QPen(self.foregroundColor)) - qp.drawLine(self.leftMargin - 5, y, self.leftMargin + self.chartWidth, y) - - qp.setPen(QtGui.QPen(self.foregroundColor)) - qp.drawLine(self.leftMargin - 5, self.topMargin, - self.leftMargin + self.chartWidth, self.topMargin) - qp.setPen(self.textColor) - qp.drawText(3, self.topMargin + 4, str(maxValue)) - qp.drawText(3, self.chartHeight+self.topMargin, str(minValue)) + qp.drawLine(self.leftMargin - 5, y, + self.leftMargin + self.chartWidth + 5, y) + qp.setPen(QtGui.QPen(self.textColor)) + val = Value(self.valueAtPosition(y)[0], fmt=fmt) + qp.drawText(3, y + 4, str(val)) + + qp.drawText(3, + self.chartHeight + self.topMargin, + str(Value(self.minValue, fmt=fmt))) + self.drawFrequencyTicks(qp) self.drawData(qp, self.data, self.sweepColor) @@ -142,17 +147,32 @@ def drawValues(self, qp: QtGui.QPainter): def getYPosition(self, d: Datapoint) -> int: mag = self.magnitude(d) - return self.topMargin + round((self.maxValue - mag) / self.span * self.chartHeight) + if self.logarithmicY and mag == 0: + return self.topMargin - self.chartHeight + if math.isfinite(mag): + if self.logarithmicY: + span = math.log(self.maxValue) - math.log(self.minValue) + return self.topMargin + round((math.log(self.maxValue) - math.log(mag)) / span * self.chartHeight) + return self.topMargin + round((self.maxValue - mag) / self.span * self.chartHeight) + else: + return self.topMargin def valueAtPosition(self, y) -> List[float]: absy = y - self.topMargin - val = -1 * ((absy / self.chartHeight * self.span) - self.maxValue) + if self.logarithmicY: + span = math.log(self.maxValue) - math.log(self.minValue) + val = math.exp(math.log(self.maxValue) - absy * span / self.chartHeight) + else: + val = self.maxValue - (absy / self.chartHeight * self.span) return [val] @staticmethod def magnitude(p: Datapoint) -> float: return abs(p.impedance()) + def logarithmicYAllowed(self) -> bool: + return True; + def copy(self): new_chart: LogMagChart = super().copy() new_chart.span = self.span diff --git a/NanoVNASaver/Charts/MagnitudeZSeries.py b/NanoVNASaver/Charts/MagnitudeZSeries.py new file mode 100644 index 00000000..1d276caf --- /dev/null +++ b/NanoVNASaver/Charts/MagnitudeZSeries.py @@ -0,0 +1,40 @@ + +# NanoVNASaver +# +# A python program to view and export Touchstone data from a NanoVNA +# Copyright (C) 2019, 2020 Rune B. Broberg +# Copyright (C) 2020 NanoVNA-Saver Authors +# +# 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 math +import logging +from typing import List + +from PyQt5 import QtWidgets, QtGui + +from NanoVNASaver.RFTools import Datapoint +from .MagnitudeZ import MagnitudeZChart + + +logger = logging.getLogger(__name__) + + +class MagnitudeZSeriesChart(MagnitudeZChart): + def __init__(self, name=""): + super().__init__(name) + + @staticmethod + def magnitude(p: Datapoint) -> float: + return abs(p.seriesImpedance()) + diff --git a/NanoVNASaver/Charts/MagnitudeZShunt.py b/NanoVNASaver/Charts/MagnitudeZShunt.py new file mode 100644 index 00000000..0bd4d057 --- /dev/null +++ b/NanoVNASaver/Charts/MagnitudeZShunt.py @@ -0,0 +1,40 @@ + +# NanoVNASaver +# +# A python program to view and export Touchstone data from a NanoVNA +# Copyright (C) 2019, 2020 Rune B. Broberg +# Copyright (C) 2020 NanoVNA-Saver Authors +# +# 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 math +import logging +from typing import List + +from PyQt5 import QtWidgets, QtGui + +from NanoVNASaver.RFTools import Datapoint +from .MagnitudeZ import MagnitudeZChart + + +logger = logging.getLogger(__name__) + + +class MagnitudeZShuntChart(MagnitudeZChart): + def __init__(self, name=""): + super().__init__(name) + + @staticmethod + def magnitude(p: Datapoint) -> float: + return abs(p.shuntImpedance()) + diff --git a/NanoVNASaver/Charts/Permeability.py b/NanoVNASaver/Charts/Permeability.py index 997019ba..76607f28 100644 --- a/NanoVNASaver/Charts/Permeability.py +++ b/NanoVNASaver/Charts/Permeability.py @@ -40,7 +40,6 @@ def __init__(self, name=""): self.fstop = 0 self.span = 0.01 self.max = 0 - self.logarithmicY = True self.maxDisplayValue = 100 self.minDisplayValue = -100 @@ -59,27 +58,11 @@ def __init__(self, name=""): self.setPalette(pal) self.setAutoFillBackground(True) - self.y_menu.addSeparator() - self.y_log_lin_group = QtWidgets.QActionGroup(self.y_menu) - self.y_action_linear = QtWidgets.QAction("Linear") - self.y_action_linear.setCheckable(True) - self.y_action_logarithmic = QtWidgets.QAction("Logarithmic") - self.y_action_logarithmic.setCheckable(True) - self.y_action_logarithmic.setChecked(True) - self.y_action_linear.triggered.connect(lambda: self.setLogarithmicY(False)) - self.y_action_logarithmic.triggered.connect(lambda: self.setLogarithmicY(True)) - self.y_log_lin_group.addAction(self.y_action_linear) - self.y_log_lin_group.addAction(self.y_action_logarithmic) - self.y_menu.addAction(self.y_action_linear) - self.y_menu.addAction(self.y_action_logarithmic) - - def setLogarithmicY(self, logarithmic: bool): - self.logarithmicY = logarithmic - self.update() + def logarithmicYAllowed(self) -> bool: + return True; def copy(self): new_chart: PermeabilityChart = super().copy() - new_chart.logarithmicY = self.logarithmicY return new_chart def drawChart(self, qp: QtGui.QPainter): diff --git a/NanoVNASaver/Charts/RI.py b/NanoVNASaver/Charts/RI.py index e27ad6ac..9d5d2d4b 100644 --- a/NanoVNASaver/Charts/RI.py +++ b/NanoVNASaver/Charts/RI.py @@ -24,6 +24,7 @@ from NanoVNASaver.Marker import Marker from NanoVNASaver.RFTools import Datapoint +from NanoVNASaver.SITools import Format, Value from .Chart import Chart from .Frequency import FrequencyChart @@ -178,8 +179,10 @@ def drawValues(self, qp: QtGui.QPainter): max_real = 0 max_imag = -1000 for d in self.data: - imp = d.impedance() + imp = self.impedance(d) re, im = imp.real, imp.imag + if math.isinf(re): # Avoid infinite scales + continue if re > max_real: max_real = re if re < min_real: @@ -191,8 +194,10 @@ def drawValues(self, qp: QtGui.QPainter): for d in self.reference: # Also check min/max for the reference sweep if d.freq < fstart or d.freq > fstop: continue - imp = d.impedance() + imp = self.impedance(d) re, im = imp.real, imp.imag + if math.isinf(re): # Avoid infinite scales + continue if re > max_real: max_real = re if re < min_real: @@ -250,6 +255,7 @@ def drawValues(self, qp: QtGui.QPainter): # We want one horizontal tick per 50 pixels, at most horizontal_ticks = math.floor(self.chartHeight/50) + fmt = Format(max_nr_digits=4) for i in range(horizontal_ticks): y = self.topMargin + round(i * self.chartHeight / horizontal_ticks) qp.setPen(QtGui.QPen(self.foregroundColor)) @@ -257,13 +263,13 @@ def drawValues(self, qp: QtGui.QPainter): qp.setPen(QtGui.QPen(self.textColor)) re = max_real - i * span_real / horizontal_ticks im = max_imag - i * span_imag / horizontal_ticks - qp.drawText(3, y + 4, str(round(re, 1))) - qp.drawText(self.leftMargin + self.chartWidth + 8, y + 4, str(round(im, 1))) + qp.drawText(3, y + 4, str(Value(re, fmt=fmt))) + qp.drawText(self.leftMargin + self.chartWidth + 8, y + 4, str(Value(im, fmt=fmt))) - qp.drawText(3, self.chartHeight + self.topMargin, str(round(min_real, 1))) + qp.drawText(3, self.chartHeight + self.topMargin, str(Value(min_real, fmt=fmt))) qp.drawText(self.leftMargin + self.chartWidth + 8, self.chartHeight + self.topMargin, - str(round(min_imag, 1))) + str(Value(min_imag, fmt=fmt))) self.drawFrequencyTicks(qp) @@ -397,12 +403,15 @@ def drawValues(self, qp: QtGui.QPainter): self.drawMarker(x, y_im, qp, m.color, self.markers.index(m)+1) def getImYPosition(self, d: Datapoint) -> int: - im = d.impedance().imag + im = self.impedance(d).imag return self.topMargin + round((self.max_imag - im) / self.span_imag * self.chartHeight) def getReYPosition(self, d: Datapoint) -> int: - re = d.impedance().real - return self.topMargin + round((self.max_real - re) / self.span_real * self.chartHeight) + re = self.impedance(d).real + if math.isfinite(re): + return self.topMargin + round((self.max_real - re) / self.span_real * self.chartHeight) + else: + return self.topMargin def valueAtPosition(self, y) -> List[float]: absy = y - self.topMargin @@ -520,3 +529,6 @@ def contextMenuEvent(self, event): self.action_set_fixed_maximum_imag.setText( f"Maximum jX ({self.maxDisplayImag})") self.menu.exec_(event.globalPos()) + + def impedance(self, p: Datapoint) -> complex: + return p.impedance() diff --git a/NanoVNASaver/Charts/RISeries.py b/NanoVNASaver/Charts/RISeries.py new file mode 100644 index 00000000..b2ca4eff --- /dev/null +++ b/NanoVNASaver/Charts/RISeries.py @@ -0,0 +1,35 @@ +# NanoVNASaver +# +# A python program to view and export Touchstone data from a NanoVNA +# Copyright (C) 2019, 2020 Rune B. Broberg +# Copyright (C) 2020 NanoVNA-Saver Authors +# +# 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 math +import logging +from typing import List + +from NanoVNASaver.RFTools import Datapoint + +from .RI import RealImaginaryChart + +logger = logging.getLogger(__name__) + + +class RealImaginarySeriesChart(RealImaginaryChart): + def __init__(self, name=""): + super().__init__(name) + + def impedance(self, p: Datapoint) -> complex: + return p.seriesImpedance() diff --git a/NanoVNASaver/Charts/RIShunt.py b/NanoVNASaver/Charts/RIShunt.py new file mode 100644 index 00000000..82e602f8 --- /dev/null +++ b/NanoVNASaver/Charts/RIShunt.py @@ -0,0 +1,35 @@ +# NanoVNASaver +# +# A python program to view and export Touchstone data from a NanoVNA +# Copyright (C) 2019, 2020 Rune B. Broberg +# Copyright (C) 2020 NanoVNA-Saver Authors +# +# 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 math +import logging +from typing import List + +from NanoVNASaver.RFTools import Datapoint + +from .RI import RealImaginaryChart + +logger = logging.getLogger(__name__) + + +class RealImaginaryShuntChart(RealImaginaryChart): + def __init__(self, name=""): + super().__init__(name) + + def impedance(self, p: Datapoint) -> complex: + return p.shuntImpedance() diff --git a/NanoVNASaver/Charts/VSWR.py b/NanoVNASaver/Charts/VSWR.py index 737d96a6..7ce8dd47 100644 --- a/NanoVNASaver/Charts/VSWR.py +++ b/NanoVNASaver/Charts/VSWR.py @@ -29,7 +29,6 @@ class VSWRChart(FrequencyChart): - logarithmicY = False maxVSWR = 3 span = 2 @@ -51,27 +50,12 @@ def __init__(self, name=""): pal.setColor(QtGui.QPalette.Background, self.backgroundColor) self.setPalette(pal) self.setAutoFillBackground(True) - self.y_menu.addSeparator() - self.y_log_lin_group = QtWidgets.QActionGroup(self.y_menu) - self.y_action_linear = QtWidgets.QAction("Linear") - self.y_action_linear.setCheckable(True) - self.y_action_linear.setChecked(True) - self.y_action_logarithmic = QtWidgets.QAction("Logarithmic") - self.y_action_logarithmic.setCheckable(True) - self.y_action_linear.triggered.connect(lambda: self.setLogarithmicY(False)) - self.y_action_logarithmic.triggered.connect(lambda: self.setLogarithmicY(True)) - self.y_log_lin_group.addAction(self.y_action_linear) - self.y_log_lin_group.addAction(self.y_action_logarithmic) - self.y_menu.addAction(self.y_action_linear) - self.y_menu.addAction(self.y_action_logarithmic) - - def setLogarithmicY(self, logarithmic: bool): - self.logarithmicY = logarithmic - self.update() + + def logarithmicYAllowed(self) -> bool: + return True def copy(self): new_chart: VSWRChart = super().copy() - new_chart.logarithmicY = self.logarithmicY return new_chart def drawValues(self, qp: QtGui.QPainter): @@ -211,5 +195,4 @@ def valueAtPosition(self, y) -> List[float]: def resetDisplayLimits(self): self.maxDisplayValue = 25 - self.logarithmicY = False super().resetDisplayLimits() diff --git a/NanoVNASaver/Charts/__init__.py b/NanoVNASaver/Charts/__init__.py index ce8c34f2..fb0022be 100644 --- a/NanoVNASaver/Charts/__init__.py +++ b/NanoVNASaver/Charts/__init__.py @@ -9,10 +9,14 @@ from .CLogMag import CombinedLogMagChart from .Magnitude import MagnitudeChart from .MagnitudeZ import MagnitudeZChart +from .MagnitudeZShunt import MagnitudeZShuntChart +from .MagnitudeZSeries import MagnitudeZSeriesChart from .Permeability import PermeabilityChart from .Phase import PhaseChart from .QFactor import QualityFactorChart from .RI import RealImaginaryChart +from .RIShunt import RealImaginaryShuntChart +from .RISeries import RealImaginarySeriesChart from .Smith import SmithChart from .SParam import SParameterChart from .TDR import TDRChart diff --git a/NanoVNASaver/Hardware/AVNA.py b/NanoVNASaver/Hardware/AVNA.py index 441bb9df..a26ed9a1 100644 --- a/NanoVNASaver/Hardware/AVNA.py +++ b/NanoVNASaver/Hardware/AVNA.py @@ -29,6 +29,7 @@ class AVNA(VNA): def __init__(self, iface: Interface): super().__init__(iface) + self.sweep_max_freq_Hz = 40e3 self.features.add("Customizable data points") def isValid(self): diff --git a/NanoVNASaver/Hardware/NanoVNA.py b/NanoVNASaver/Hardware/NanoVNA.py index e6ac3c07..d0cea41c 100644 --- a/NanoVNASaver/Hardware/NanoVNA.py +++ b/NanoVNASaver/Hardware/NanoVNA.py @@ -40,10 +40,26 @@ def __init__(self, iface: Interface): super().__init__(iface) self.sweep_method = "sweep" self.read_features() - self.start = 27000000 - self.stop = 30000000 + logger.debug("Setting initial start,stop") + self.start, self.stop = self._get_running_frequencies() + self.sweep_max_freq_Hz = 300e6 self._sweepdata = [] + def _get_running_frequencies(self): + + if self.name == "NanoVNA": + logger.debug("Reading values: frequencies") + try: + frequencies = super().readValues("frequencies") + return frequencies[0], frequencies[-1] + except Exception as e: + logger.warning("%s reading frequencies", e) + logger.info("falling back to generic") + else: + logger.debug("Name %s, fallback to generic", self.name) + + return VNA._get_running_frequencies(self) + def _capture_data(self) -> bytes: timeout = self.serial.timeout with self.serial.lock: diff --git a/NanoVNASaver/Hardware/NanoVNA_F.py b/NanoVNASaver/Hardware/NanoVNA_F.py index 66928232..4852fbda 100644 --- a/NanoVNASaver/Hardware/NanoVNA_F.py +++ b/NanoVNASaver/Hardware/NanoVNA_F.py @@ -23,6 +23,7 @@ from PyQt5 import QtGui from NanoVNASaver.Hardware.NanoVNA import NanoVNA +from NanoVNASaver.Hardware.Serial import Interface logger = logging.getLogger(__name__) @@ -31,3 +32,7 @@ class NanoVNA_F(NanoVNA): name = "NanoVNA-F" screenwidth = 800 screenheight = 480 + + def __init__(self, iface: Interface): + super().__init__(iface) + self.sweep_max_freq_Hz = 1500e6 diff --git a/NanoVNASaver/Hardware/NanoVNA_F_V2.py b/NanoVNASaver/Hardware/NanoVNA_F_V2.py index ae228044..c3fcd92a 100644 --- a/NanoVNASaver/Hardware/NanoVNA_F_V2.py +++ b/NanoVNASaver/Hardware/NanoVNA_F_V2.py @@ -1,34 +1,39 @@ -import logging -from NanoVNASaver.Hardware.Serial import drain_serial, Interface -import serial -import struct -import numpy as np -from PyQt5 import QtGui - -from NanoVNASaver.Hardware.NanoVNA import NanoVNA - -logger = logging.getLogger(__name__) - - -class NanoVNA_F_V2(NanoVNA): - name = "NanoVNA-F_V2" - screenwidth = 800 - screenheight = 480 - - def getScreenshot(self) -> QtGui.QPixmap: - logger.debug("Capturing screenshot...") - if not self.connected(): - return QtGui.QPixmap() - try: - rgba_array = self._capture_data() - image = QtGui.QImage( - rgba_array, - self.screenwidth, - self.screenheight, - QtGui.QImage.Format_RGB16) - logger.debug("Captured screenshot") - return QtGui.QPixmap(image) - except serial.SerialException as exc: - logger.exception( - "Exception while capturing screenshot: %s", exc) - return QtGui.QPixmap() +import logging +from NanoVNASaver.Hardware.Serial import drain_serial, Interface +import serial +import struct +import numpy as np +from PyQt5 import QtGui + +from NanoVNASaver.Hardware.NanoVNA import NanoVNA +from NanoVNASaver.Hardware.Serial import Interface + +logger = logging.getLogger(__name__) + + +class NanoVNA_F_V2(NanoVNA): + name = "NanoVNA-F_V2" + screenwidth = 800 + screenheight = 480 + + def __init__(self, iface: Interface): + super().__init__(iface) + self.sweep_max_freq_Hz = 3e9 + + def getScreenshot(self) -> QtGui.QPixmap: + logger.debug("Capturing screenshot...") + if not self.connected(): + return QtGui.QPixmap() + try: + rgba_array = self._capture_data() + image = QtGui.QImage( + rgba_array, + self.screenwidth, + self.screenheight, + QtGui.QImage.Format_RGB16) + logger.debug("Captured screenshot") + return QtGui.QPixmap(image) + except serial.SerialException as exc: + logger.exception( + "Exception while capturing screenshot: %s", exc) + return QtGui.QPixmap() diff --git a/NanoVNASaver/Hardware/NanoVNA_H.py b/NanoVNASaver/Hardware/NanoVNA_H.py index 0615094f..a8ae05ae 100644 --- a/NanoVNASaver/Hardware/NanoVNA_H.py +++ b/NanoVNASaver/Hardware/NanoVNA_H.py @@ -19,9 +19,14 @@ import logging from NanoVNASaver.Hardware.NanoVNA import NanoVNA +from NanoVNASaver.Hardware.Serial import Interface logger = logging.getLogger(__name__) class NanoVNA_H(NanoVNA): name = "NanoVNA-H" + + def __init__(self, iface: Interface): + super().__init__(iface) + self.sweep_max_freq_Hz = 1500e6 diff --git a/NanoVNASaver/Hardware/NanoVNA_H4.py b/NanoVNASaver/Hardware/NanoVNA_H4.py index c689de10..be0c14c8 100644 --- a/NanoVNASaver/Hardware/NanoVNA_H4.py +++ b/NanoVNASaver/Hardware/NanoVNA_H4.py @@ -27,10 +27,11 @@ class NanoVNA_H4(NanoVNA_H): name = "NanoVNA-H4" screenwidth = 480 screenheight = 320 - valid_datapoints = (101, 11, 51, 201) + valid_datapoints = (101, 11, 51, 201, 401) def __init__(self, iface: Interface): super().__init__(iface) + self.sweep_max_freq_Hz = 1500e6 self.sweep_method = "scan" if "Scan mask command" in self.features: self.sweep_method = "scan_mask" diff --git a/NanoVNASaver/Hardware/NanoVNA_V2.py b/NanoVNASaver/Hardware/NanoVNA_V2.py index c2c52619..d45a04cc 100644 --- a/NanoVNASaver/Hardware/NanoVNA_V2.py +++ b/NanoVNASaver/Hardware/NanoVNA_V2.py @@ -57,6 +57,15 @@ WRITE_SLEEP = 0.05 +_ADF4350_TXPOWER_DESC_MAP = { + 0: '9dB attenuation', + 1: '6dB attenuation', + 2: '3dB attenuation', + 3: 'Maximum', +} +_ADF4350_TXPOWER_DESC_REV_MAP = { + value: key for key, value in _ADF4350_TXPOWER_DESC_MAP.items()} + class NanoVNA_V2(VNA): name = "NanoVNA-V2" valid_datapoints = (101, 11, 51, 201, 301, 501, 1023) @@ -94,9 +103,21 @@ def read_features(self): self.features.add("Customizable data points") # TODO: more than one dp per freq self.features.add("Multi data points") + self.board_revision = self.read_board_revision() + if self.board_revision >= Version("2.0.4"): + self.sweep_max_freq_Hz = 4400e6 + else: + self.sweep_max_freq_Hz = 3000e6 if self.version <= Version("1.0.1"): logger.debug("Hack for s21 oddity in first sweeppoint") self.features.add("S21 hack") + if self.version >= Version("1.0.2"): + self.features.update({"Set TX power partial", "Set Average"}) + # Can only set ADF4350 power, i.e. for >= 140MHz + self.txPowerRanges = [ + ((140e6, self.sweep_max_freq_Hz), + [_ADF4350_TXPOWER_DESC_MAP[value] for value in (3, 2, 1, 0)]), + ] def readFirmware(self) -> str: result = f"HW: {self.read_board_revision()}\nFW: {self.version}" @@ -210,7 +231,9 @@ def read_board_revision(self) -> 'Version': if len(resp) != 2: logger.error("Timeout reading version registers") return None - return Version(f"{resp[0]}.0.{resp[1]}") + result = Version(f"{resp[0]}.0.{resp[1]}") + logger.debug("read_board_revision: %s", result) + return result def setSweep(self, start, stop): @@ -238,3 +261,21 @@ def _updateSweep(self): with self.serial.lock: self.serial.write(cmd) sleep(WRITE_SLEEP) + + def setTXPower(self, freq_range, power_desc): + if freq_range[0] != 140e6: + raise ValueError('Invalid TX power frequency range') + # 140MHz..max => ADF4350 + self._set_register(0x42, _ADF4350_TXPOWER_DESC_REV_MAP[power_desc], 1) + + def _set_register(self, addr, value, size): + if size == 1: + packet = pack(" List[int]: def resetSweep(self, start: int, stop: int): pass + def _get_running_frequencies(self): + ''' + If possible, read frequencies already runnung + if not return default values + Overwrite in specific HW + ''' + return 27000000, 30000000 + def connected(self) -> bool: return self.serial.is_open @@ -187,3 +199,6 @@ def readVersion(self) -> 'Version': def setSweep(self, start, stop): list(self.exec_command(f"sweep {start} {stop} {self.datapoints}")) + + def setTXPower(self, freq_range, power_desc): + raise NotImplementedError() diff --git a/NanoVNASaver/Marker/Values.py b/NanoVNASaver/Marker/Values.py index 0cf4e70c..e1b4193b 100644 --- a/NanoVNASaver/Marker/Values.py +++ b/NanoVNASaver/Marker/Values.py @@ -54,6 +54,10 @@ class Label(NamedTuple): Label("s21phase", "S21 Phase", "S21 Phase", True), Label("s21polar", "S21 Polar", "S21 Polar", False), Label("s21groupdelay", "S21 Group Delay", "S21 Group Delay", False), + Label("s21magshunt", "S21 |Z| shunt", "S21 Z Magnitude shunt", False), + Label("s21magseries", "S21 |Z| series", "S21 Z Magnitude series", False), + Label("s21realimagshunt", "S21 R+jX shunt", "S21 Z Real+Imag shunt", False), + Label("s21realimagseries", "S21 R+jX series", "S21 Z Real+Imag series", False), ) diff --git a/NanoVNASaver/Marker/Widget.py b/NanoVNASaver/Marker/Widget.py index 8cac4952..5708f426 100644 --- a/NanoVNASaver/Marker/Widget.py +++ b/NanoVNASaver/Marker/Widget.py @@ -351,3 +351,7 @@ def updateLabels(self, self.label['s21phase'].setText(format_phase(s21.phase)) self.label['s21polar'].setText( str(round(abs(s21.z), 2)) + "∠" + format_phase(s21.phase)) + self.label['s21magshunt'].setText(format_magnitude(abs(s21.shuntImpedance()))) + self.label['s21magseries'].setText(format_magnitude(abs(s21.seriesImpedance()))) + self.label['s21realimagshunt'].setText(format_complex_imp(s21.shuntImpedance(), allow_negative=True)) + self.label['s21realimagseries'].setText(format_complex_imp(s21.seriesImpedance(), allow_negative=True)) diff --git a/NanoVNASaver/NanoVNASaver.py b/NanoVNASaver/NanoVNASaver.py index b60e86a8..6a94c1b1 100644 --- a/NanoVNASaver/NanoVNASaver.py +++ b/NanoVNASaver/NanoVNASaver.py @@ -40,9 +40,9 @@ CapacitanceChart, CombinedLogMagChart, GroupDelayChart, InductanceChart, LogMagChart, PhaseChart, - MagnitudeChart, MagnitudeZChart, + MagnitudeChart, MagnitudeZChart, MagnitudeZShuntChart, MagnitudeZSeriesChart, QualityFactorChart, VSWRChart, PermeabilityChart, PolarChart, - RealImaginaryChart, + RealImaginaryChart, RealImaginaryShuntChart, RealImaginarySeriesChart, SmithChart, SParameterChart, TDRChart, ) from .Calibration import Calibration @@ -109,7 +109,6 @@ def __init__(self): self.calibration = Calibration() - logger.debug("Building user interface") self.baseTitle = f"NanoVNA Saver {NanoVNASaver.version}" @@ -155,6 +154,10 @@ def __init__(self): reflective=False)), ("log_mag", LogMagChart("S21 Gain")), ("magnitude", MagnitudeChart("|S21|")), + ("magnitude_z_shunt", MagnitudeZShuntChart("S21 |Z| shunt")), + ("magnitude_z_series", MagnitudeZSeriesChart("S21 |Z| series")), + ("real_imag_shunt", RealImaginaryShuntChart("S21 R+jX shunt")), + ("real_imag_series", RealImaginarySeriesChart("S21 R+jX series")), ("phase", PhaseChart("S21 Phase")), ("polar", PolarChart("S21 Polar Plot")), ("s_parameter", SParameterChart("S21 Real/Imaginary")), @@ -196,7 +199,8 @@ def __init__(self): left_column = QtWidgets.QVBoxLayout() right_column = QtWidgets.QVBoxLayout() right_column.addLayout(self.charts_layout) - self.marker_frame.setHidden(not self.settings.value("MarkersVisible", True, bool)) + self.marker_frame.setHidden( + not self.settings.value("MarkersVisible", True, bool)) chart_widget = QtWidgets.QWidget() chart_widget.setLayout(right_column) self.splitter = QtWidgets.QSplitter() @@ -317,9 +321,11 @@ def __init__(self): tdr_control_box.setMaximumWidth(250) self.tdr_result_label = QtWidgets.QLabel() - tdr_control_layout.addRow("Estimated cable length:", self.tdr_result_label) + tdr_control_layout.addRow( + "Estimated cable length:", self.tdr_result_label) - self.tdr_button = QtWidgets.QPushButton("Time Domain Reflectometry ...") + self.tdr_button = QtWidgets.QPushButton( + "Time Domain Reflectometry ...") self.tdr_button.clicked.connect(lambda: self.display_window("tdr")) tdr_control_layout.addRow(self.tdr_button) @@ -525,7 +531,7 @@ def connect_device(self): logger.info("Connection %s", self.interface) try: self.interface.open() - self.interface.timeout = 0.05 + except (IOError, AttributeError) as exc: logger.error("Tried to open %s and failed: %s", self.interface, exc) @@ -533,13 +539,15 @@ def connect_device(self): if not self.interface.isOpen(): logger.error("Unable to open port %s", self.interface) return + self.interface.timeout = 0.05 sleep(0.1) try: self.vna = get_VNA(self.interface) except IOError as exc: logger.error("Unable to connect to VNA: %s", exc) - self.vna.validateInput = self.settings.value("SerialInputValidation", True, bool) + self.vna.validateInput = self.settings.value( + "SerialInputValidation", True, bool) # connected self.btnSerialToggle.setText("Disconnect") @@ -563,6 +571,8 @@ def connect_device(self): self.sweep_control.update_center_span() self.sweep_control.update_step_size() + self.windows["sweep_settings"].vna_connected() + logger.debug("Starting initial sweep") self.sweep_start() @@ -661,7 +671,7 @@ def dataUpdated(self): if s21data: min_gain = min(s21data, key=lambda data: data.gain) - max_gain = min(s21data, key=lambda data: data.gain) + max_gain = max(s21data, key=lambda data: data.gain) self.s21_min_gain_label.setText( f"{format_gain(min_gain.gain)}" f" @ {format_frequency(min_gain.freq)}") @@ -832,3 +842,6 @@ def changeFont(self, font: QtGui.QFont) -> None: def update_sweep_title(self): for c in self.subscribing_charts: c.setSweepTitle(self.sweep.properties.name) + + def set_tx_power(self, freq_range, power_desc): + self.vna.setTXPower(freq_range, power_desc) diff --git a/NanoVNASaver/RFTools.py b/NanoVNASaver/RFTools.py index 3803bea0..d52429eb 100644 --- a/NanoVNASaver/RFTools.py +++ b/NanoVNASaver/RFTools.py @@ -39,6 +39,7 @@ class Datapoint(NamedTuple): @property def z(self) -> complex: """ return the datapoint impedance as complex number """ + # FIXME: not impedance, but s11 ? return complex(self.re, self.im) @property @@ -67,6 +68,18 @@ def wavelength(self) -> float: def impedance(self, ref_impedance: float = 50) -> complex: return gamma_to_impedance(self.z, ref_impedance) + def shuntImpedance(self, ref_impedance: float = 50) -> complex: + try: + return 0.5 * ref_impedance * self.z / (1 - self.z) + except ZeroDivisionError: + return math.inf + + def seriesImpedance(self, ref_impedance: float = 50) -> complex: + try: + return 2 * ref_impedance * (1 - self.z) / self.z + except ZeroDivisionError: + return math.inf + def qFactor(self, ref_impedance: float = 50) -> float: imp = self.impedance(ref_impedance) if imp.real == 0.0: @@ -158,7 +171,7 @@ def corr_att_data(data: List[Datapoint], att: float) -> List[Datapoint]: if att <= 0: return data else: - att = 10**(att/20) + att = 10**(att / 20) ndata = [] for dp in data: corrected = dp.z * att diff --git a/NanoVNASaver/Windows/AnalysisWindow.py b/NanoVNASaver/Windows/AnalysisWindow.py index bac9206e..04b69950 100644 --- a/NanoVNASaver/Windows/AnalysisWindow.py +++ b/NanoVNASaver/Windows/AnalysisWindow.py @@ -23,6 +23,9 @@ from NanoVNASaver.Analysis import Analysis, LowPassAnalysis, HighPassAnalysis, \ BandPassAnalysis, BandStopAnalysis, VSWRAnalysis, \ SimplePeakSearchAnalysis, MagLoopAnalysis +from NanoVNASaver.Analysis.VSWRAnalysis import ResonanceAnalysis +from NanoVNASaver.Analysis.VSWRAnalysis import EFHWAnalysis +from NanoVNASaver.Analysis import PeakSearchAnalysis logger = logging.getLogger(__name__) @@ -46,14 +49,25 @@ def __init__(self, app: QtWidgets.QWidget): select_analysis_box = QtWidgets.QGroupBox("Select analysis") select_analysis_layout = QtWidgets.QFormLayout(select_analysis_box) self.analysis_list = QtWidgets.QComboBox() - self.analysis_list.addItem("Low-pass filter", LowPassAnalysis(self.app)) - self.analysis_list.addItem("Band-pass filter", BandPassAnalysis(self.app)) - self.analysis_list.addItem("High-pass filter", HighPassAnalysis(self.app)) - self.analysis_list.addItem("Band-stop filter", BandStopAnalysis(self.app)) - # self.analysis_list.addItem("Peak search", PeakSearchAnalysis(self.app)) - self.analysis_list.addItem("Peak search", SimplePeakSearchAnalysis(self.app)) + self.analysis_list.addItem( + "Low-pass filter", LowPassAnalysis(self.app)) + self.analysis_list.addItem( + "Band-pass filter", BandPassAnalysis(self.app)) + self.analysis_list.addItem( + "High-pass filter", HighPassAnalysis(self.app)) + self.analysis_list.addItem( + "Band-stop filter", BandStopAnalysis(self.app)) + self.analysis_list.addItem( + "Simple Peak search", SimplePeakSearchAnalysis(self.app)) + self.analysis_list.addItem( + "Peak search", PeakSearchAnalysis(self.app)) self.analysis_list.addItem("VSWR analysis", VSWRAnalysis(self.app)) - self.analysis_list.addItem("MagLoop analysis", MagLoopAnalysis(self.app)) + self.analysis_list.addItem( + "Resonance analysis", ResonanceAnalysis(self.app)) + self.analysis_list.addItem( + "HWEF analysis", EFHWAnalysis(self.app)) + self.analysis_list.addItem( + "MagLoop analysis", MagLoopAnalysis(self.app)) select_analysis_layout.addRow("Analysis type", self.analysis_list) self.analysis_list.currentIndexChanged.connect(self.updateSelection) @@ -61,8 +75,10 @@ def __init__(self, app: QtWidgets.QWidget): btn_run_analysis.clicked.connect(self.runAnalysis) select_analysis_layout.addRow(btn_run_analysis) - self.checkbox_run_automatically = QtWidgets.QCheckBox("Run automatically") - self.checkbox_run_automatically.stateChanged.connect(self.toggleAutomaticRun) + self.checkbox_run_automatically = QtWidgets.QCheckBox( + "Run automatically") + self.checkbox_run_automatically.stateChanged.connect( + self.toggleAutomaticRun) select_analysis_layout.addRow(self.checkbox_run_automatically) analysis_box = QtWidgets.QGroupBox("Analysis") @@ -87,7 +103,8 @@ def updateSelection(self): old_item = self.analysis_layout.itemAt(0) if old_item is not None: old_widget = self.analysis_layout.itemAt(0).widget() - self.analysis_layout.replaceWidget(old_widget, self.analysis.widget()) + self.analysis_layout.replaceWidget( + old_widget, self.analysis.widget()) old_widget.hide() else: self.analysis_layout.addWidget(self.analysis.widget()) diff --git a/NanoVNASaver/Windows/SweepSettings.py b/NanoVNASaver/Windows/SweepSettings.py index 212b4f85..710d519c 100644 --- a/NanoVNASaver/Windows/SweepSettings.py +++ b/NanoVNASaver/Windows/SweepSettings.py @@ -44,6 +44,10 @@ def __init__(self, app: QtWidgets.QWidget): layout.addWidget(self.title_box()) layout.addWidget(self.settings_box()) + # We can only populate this box after the VNA has been connected. + self._power_box = QtWidgets.QGroupBox("Power") + self._power_layout = QtWidgets.QFormLayout(self._power_box) + layout.addWidget(self._power_box) layout.addWidget(self.sweep_box()) self.update_band() @@ -155,6 +159,17 @@ def sweep_box(self) -> 'QtWidgets.QWidget': layout.addRow(btn_set_band_sweep) return box + def vna_connected(self): + while self._power_layout.rowCount(): + self._power_layout.removeRow(0) + for freq_range, power_descs in self.app.vna.txPowerRanges: + power_sel = QtWidgets.QComboBox() + power_sel.addItems(power_descs) + power_sel.currentTextChanged.connect( + partial(self.update_tx_power, freq_range)) + self._power_layout.addRow("TX power {}..{}".format( + *map(format_frequency_short, freq_range)), power_sel) + def update_band(self, apply: bool = False): logger.debug("update_band(%s)", apply) index_start = self.band_list.model().index(self.band_list.currentIndex(), 1) @@ -233,3 +248,8 @@ def update_title(self, title: str = ""): with self.app.sweep.lock: self.app.sweep.properties.name = title self.app.update_sweep_title() + + def update_tx_power(self, freq_range, power_desc): + logger.debug("update_tx_power(%r)", power_desc) + with self.app.sweep.lock: + self.app.set_tx_power(freq_range, power_desc) diff --git a/README.md b/README.md index 3523c683..335f90a5 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ points, and generally display and analyze the resulting data. Latest Changes -------------- +### Changes in v0.3.9 + ### Changes in v0.3.8 - Allow editing of bands above 2.4GHz @@ -24,22 +26,6 @@ Latest Changes - Support for Nanovna-F V2 - Fixes a crash with S21 hack -### Changes in v0.3.7 - -- Added a delta marker -- Segments can now have exponential different step widths - (see logarithmic sweeping) -- More different data points selectable - (shorter are useful on logarithmic sweeping) -- Scrollable marker column -- Markers initialize on start, middle, end -- Frequency input is now more "lazy" - 10m, 50K and 1g are now valid for 10MHz, 50kHz and 1GHz -- Added a wavelength field to Markers -- 32 bit windows binaries build in actions -- Stability improvements due to better exception handling -- Workaround for wrong first S21mag value on V2 devices - Introduction ------------