From 24a4ca0ffa804a1b2b5d651ffd2b20ffc925fe4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Holger=20M=C3=BCller?= Date: Mon, 19 Sep 2022 19:21:22 +0200 Subject: [PATCH] refactored BandStopAnalysis --- NanoVNASaver/Analysis/BandPassAnalysis.py | 74 +++--- NanoVNASaver/Analysis/BandStopAnalysis.py | 266 ++-------------------- NanoVNASaver/AnalyticTools.py | 19 +- NanoVNASaver/Charts/RI.py | 181 ++++++++------- NanoVNASaver/SITools.py | 5 +- test/test_analytics.py | 63 +++++ test/test_rftools.py | 10 +- test/test_sitools.py | 2 +- 8 files changed, 246 insertions(+), 374 deletions(-) create mode 100644 test/test_analytics.py diff --git a/NanoVNASaver/Analysis/BandPassAnalysis.py b/NanoVNASaver/Analysis/BandPassAnalysis.py index 449fa06..75bda74 100644 --- a/NanoVNASaver/Analysis/BandPassAnalysis.py +++ b/NanoVNASaver/Analysis/BandPassAnalysis.py @@ -18,7 +18,7 @@ # along with this program. If not, see . import logging import math -from typing import Dict +from typing import Dict, List, Tuple from PyQt5 import QtWidgets @@ -37,22 +37,22 @@ class BandPassAnalysis(Analysis): self._widget = QtWidgets.QWidget() - layout = QtWidgets.QFormLayout() - self._widget.setLayout(layout) - layout.addRow(QtWidgets.QLabel("Band pass filter analysis")) - layout.addRow( - QtWidgets.QLabel( - f"Please place {self.app.markers[0].name}" - f" in the filter passband.")) self.label = { label: QtWidgets.QLabel() for label in - ('result', 'octave_l', 'octave_r', 'decade_l', 'decade_r', + ('titel', 'result', 'octave_l', 'octave_r', 'decade_l', 'decade_r', 'freq_center', 'span_3.0dB', 'span_6.0dB', 'q_factor') } for attn in CUTOFF_VALS: self.label[f"{attn:.1f}dB_l"] = QtWidgets.QLabel() self.label[f"{attn:.1f}dB_r"] = QtWidgets.QLabel() + layout = QtWidgets.QFormLayout() + self._widget.setLayout(layout) + layout.addRow(self.label['titel']) + layout.addRow( + QtWidgets.QLabel( + f"Please place {self.app.markers[0].name}" + f" in the filter passband.")) layout.addRow("Result:", self.label['result']) layout.addRow(QtWidgets.QLabel("")) @@ -77,6 +77,11 @@ class BandPassAnalysis(Analysis): layout.addRow("Roll-off:", self.label['octave_r']) layout.addRow("Roll-off:", self.label['decade_r']) + self.set_titel("Band pass filter analysis") + + def set_titel(self, name): + self.label['titel'].setText(name) + def reset(self): for label in self.label.values(): label.clear() @@ -91,28 +96,13 @@ class BandPassAnalysis(Analysis): s21 = self.app.data.s21 gains = [d.gain for d in s21] - marker = self.app.markers[0] - if marker.location <= 0 or marker.location >= len(s21) - 1: - logger.debug("No valid location for %s (%s)", - marker.name, marker.location) - self.label['result'].setText( - f"Please place {marker.name} in the passband.") - return - - # find center of passband based on marker pos - if (peak := at.center_from_idx(gains, marker.location)) < 0: - self.label['result'].setText("Bandpass center not found") + if (peak := self.find_center(gains)) < 0: return peak_db = gains[peak] - logger.debug("Bandpass center pos: %d(%fdB)", peak, peak_db) + logger.debug("Filter center pos: %d(%fdB)", peak, peak_db) # find passband bounderies - cutoff_pos = {} - for attn in CUTOFF_VALS: - cutoff_pos[f"{attn:.1f}dB_l"] = at.cut_off_left( - gains, peak, peak_db, attn) - cutoff_pos[f"{attn:.1f}dB_r"] = at.cut_off_right( - gains, peak, peak_db, attn) + cutoff_pos = self.find_bounderies(gains, peak, peak_db) cutoff_freq = { att: s21[val].freq if val >= 0 else math.nan for att, val in cutoff_pos.items() @@ -129,8 +119,8 @@ class BandPassAnalysis(Analysis): result = { 'span_3.0dB': cutoff_freq['3.0dB_r'] - cutoff_freq['3.0dB_l'], 'span_6.0dB': cutoff_freq['6.0dB_r'] - cutoff_freq['6.0dB_l'], - 'freq_center': int( - math.sqrt(cutoff_freq['3.0dB_l'] * cutoff_freq['3.0dB_r'])), + 'freq_center': + math.sqrt(cutoff_freq['3.0dB_l'] * cutoff_freq['3.0dB_r']), } result['q_factor'] = result['freq_center'] / result['span_3.0dB'] @@ -190,3 +180,29 @@ class BandPassAnalysis(Analysis): 10 ** (5 * (math.log10(cutoff_pos['20.0dB_r']) - math.log10(cutoff_pos['10.0dB_r']) ))) + + def find_center(self, gains: List[float]) -> int: + marker = self.app.markers[0] + if marker.location <= 0 or marker.location >= len(gains) - 1: + logger.debug("No valid location for %s (%s)", + marker.name, marker.location) + self.label['result'].setText( + f"Please place {marker.name} in the passband.") + return -1 + + # find center of passband based on marker pos + if (peak := at.center_from_idx(gains, marker.location)) < 0: + self.label['result'].setText("Bandpass center not found") + return -1 + return peak + + def find_bounderies(self, + gains: List[float], + peak: int, peak_db: float) -> Dict[str, int]: + cutoff_pos = {} + for attn in CUTOFF_VALS: + cutoff_pos[f"{attn:.1f}dB_l"] = at.cut_off_left( + gains, peak, peak_db, attn) + cutoff_pos[f"{attn:.1f}dB_r"] = at.cut_off_right( + gains, peak, peak_db, attn) + return cutoff_pos diff --git a/NanoVNASaver/Analysis/BandStopAnalysis.py b/NanoVNASaver/Analysis/BandStopAnalysis.py index db11b44..d463df6 100644 --- a/NanoVNASaver/Analysis/BandStopAnalysis.py +++ b/NanoVNASaver/Analysis/BandStopAnalysis.py @@ -17,12 +17,11 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . import logging -import math +from typing import Dict, List -from PyQt5 import QtWidgets - -from NanoVNASaver.Analysis.BandPassAnalysis import BandPassAnalysis -from NanoVNASaver.Formatting import format_frequency +import NanoVNASaver.AnalyticTools as at +from NanoVNASaver.Analysis.BandPassAnalysis import ( + BandPassAnalysis, CUTOFF_VALS) logger = logging.getLogger(__name__) @@ -30,251 +29,16 @@ logger = logging.getLogger(__name__) class BandStopAnalysis(BandPassAnalysis): def __init__(self, app): super().__init__(app) + self.set_titel("Band stop filter analysis") - self._widget = QtWidgets.QWidget() + def find_center(self, gains: List[float]) -> int: + return max(enumerate(gains), key=lambda i: i[1])[0] - layout = QtWidgets.QFormLayout() - self._widget.setLayout(layout) - layout.addRow(QtWidgets.QLabel("Band stop filter analysis")) - self.result_label = QtWidgets.QLabel() - self.lower_cutoff_label = QtWidgets.QLabel() - self.lower_six_db_label = QtWidgets.QLabel() - self.lower_sixty_db_label = QtWidgets.QLabel() - self.lower_db_per_octave_label = QtWidgets.QLabel() - self.lower_db_per_decade_label = QtWidgets.QLabel() - - self.upper_cutoff_label = QtWidgets.QLabel() - self.upper_six_db_label = QtWidgets.QLabel() - self.upper_sixty_db_label = QtWidgets.QLabel() - self.upper_db_per_octave_label = QtWidgets.QLabel() - self.upper_db_per_decade_label = QtWidgets.QLabel() - layout.addRow("Result:", self.result_label) - - layout.addRow(QtWidgets.QLabel("")) - - self.center_frequency_label = QtWidgets.QLabel() - self.span_label = QtWidgets.QLabel() - self.six_db_span_label = QtWidgets.QLabel() - self.quality_label = QtWidgets.QLabel() - - layout.addRow("Center frequency:", self.center_frequency_label) - layout.addRow("Bandwidth (-3 dB):", self.span_label) - layout.addRow("Quality factor:", self.quality_label) - layout.addRow("Bandwidth (-6 dB):", self.six_db_span_label) - - layout.addRow(QtWidgets.QLabel("")) - - layout.addRow(QtWidgets.QLabel("Lower side:")) - layout.addRow("Cutoff frequency:", self.lower_cutoff_label) - layout.addRow("-6 dB point:", self.lower_six_db_label) - layout.addRow("-60 dB point:", self.lower_sixty_db_label) - layout.addRow("Roll-off:", self.lower_db_per_octave_label) - layout.addRow("Roll-off:", self.lower_db_per_decade_label) - - layout.addRow(QtWidgets.QLabel("")) - - layout.addRow(QtWidgets.QLabel("Upper side:")) - layout.addRow("Cutoff frequency:", self.upper_cutoff_label) - layout.addRow("-6 dB point:", self.upper_six_db_label) - layout.addRow("-60 dB point:", self.upper_sixty_db_label) - layout.addRow("Roll-off:", self.upper_db_per_octave_label) - layout.addRow("Roll-off:", self.upper_db_per_decade_label) - - def reset(self): - self.result_label.clear() - self.span_label.clear() - self.quality_label.clear() - self.six_db_span_label.clear() - - self.upper_cutoff_label.clear() - self.upper_six_db_label.clear() - self.upper_sixty_db_label.clear() - self.upper_db_per_octave_label.clear() - self.upper_db_per_decade_label.clear() - - self.lower_cutoff_label.clear() - self.lower_six_db_label.clear() - self.lower_sixty_db_label.clear() - self.lower_db_per_octave_label.clear() - self.lower_db_per_decade_label.clear() - - def runAnalysis(self): - if not self.app.data.s21: - logger.debug("No data to analyse") - self.result_label.setText("No data to analyse.") - return - - self.reset() - s21 = self.app.data.s21 - gains = [d.gain for d in s21] - - peak_location, pass_band_db = max(enumerate(gains), key=lambda i: i[1]) - logger.debug("Found peak of %f at %d", - pass_band_db, s21[peak_location].freq) - - lower_cutoff_location = next( - (i for i in range(len(s21)) if (pass_band_db - s21[i].gain) > 3), -1) - - lower_cutoff_frequency = s21[lower_cutoff_location].freq - lower_cutoff_gain = ( - s21[lower_cutoff_location].gain - pass_band_db) - - if lower_cutoff_gain < -4: - logger.debug("Lower cutoff frequency found at %f dB" - " - insufficient data points for true -3 dB point.", - lower_cutoff_gain) - - logger.debug("Found true lower cutoff frequency at %d", - lower_cutoff_frequency) - - self.lower_cutoff_label.setText( - f"{format_frequency(lower_cutoff_frequency)}" - f" ({round(lower_cutoff_gain, 1)} dB)") - - self.app.markers[1].setFrequency(str(lower_cutoff_frequency)) - self.app.markers[1].frequencyInput.setText(str(lower_cutoff_frequency)) - - upper_cutoff_location = next((i for i in range( - len(s21) - 1, -1, -1) if (pass_band_db - s21[i].gain) > 3), -1) - - upper_cutoff_frequency = ( - s21[upper_cutoff_location].freq) - upper_cutoff_gain = ( - s21[upper_cutoff_location].gain - pass_band_db) - if upper_cutoff_gain < -4: - logger.debug("Upper cutoff frequency found at %f dB" - " - insufficient data points for true -3 dB point.", - upper_cutoff_gain) - - logger.debug("Found true upper cutoff frequency at %d", - upper_cutoff_frequency) - - self.upper_cutoff_label.setText( - f"{format_frequency(upper_cutoff_frequency)}" - f" ({round(upper_cutoff_gain, 1)} dB)") - self.app.markers[2].setFrequency(str(upper_cutoff_frequency)) - self.app.markers[2].frequencyInput.setText(str(upper_cutoff_frequency)) - - span = upper_cutoff_frequency - lower_cutoff_frequency - center_frequency = math.sqrt( - lower_cutoff_frequency * upper_cutoff_frequency) - q = center_frequency / span - - self.span_label.setText(format_frequency(span)) - self.center_frequency_label.setText( - format_frequency(center_frequency)) - self.quality_label.setText(str(round(q, 2))) - - self.app.markers[0].setFrequency(str(round(center_frequency))) - self.app.markers[0].frequencyInput.setText( - str(round(center_frequency))) - - lower_six_db_location = next((i for i in range( - lower_cutoff_location, len(s21)) if (pass_band_db - s21[i].gain) > 6), -1) - - if lower_six_db_location < 0: - self.result_label.setText("Lower 6 dB location not found.") - return - lower_six_db_cutoff_frequency = ( - s21[lower_six_db_location].freq) - self.lower_six_db_label.setText( - format_frequency(lower_six_db_cutoff_frequency)) - - ten_db_location = next((i for i in range(lower_cutoff_location, len( - s21)) if (pass_band_db - s21[i].gain) > 10), -1) - - twenty_db_location = next((i for i in range( - lower_cutoff_location, len(s21)) if (pass_band_db - s21[i].gain) > 20), -1) - - sixty_db_location = next((i for i in range( - lower_six_db_location, len(s21)) if (pass_band_db - s21[i].gain) > 60), -1) - - if sixty_db_location > 0: - sixty_db_cutoff_frequency = ( - s21[sixty_db_location].freq) - self.lower_sixty_db_label.setText( - format_frequency(sixty_db_cutoff_frequency)) - elif ten_db_location != -1 and twenty_db_location != -1: - ten = s21[ten_db_location].freq - twenty = s21[twenty_db_location].freq - sixty_db_frequency = ten * \ - 10 ** (5 * (math.log10(twenty) - math.log10(ten))) - self.lower_sixty_db_label.setText( - f"{format_frequency(sixty_db_frequency)} (derived)") - else: - self.lower_sixty_db_label.setText("Not calculated") - - if (ten_db_location > 0 and - twenty_db_location > 0 and - ten_db_location != twenty_db_location): - octave_attenuation, decade_attenuation = self.calculateRolloff( - ten_db_location, twenty_db_location) - self.lower_db_per_octave_label.setText( - f"{round(octave_attenuation, 3)} dB / octave") - self.lower_db_per_decade_label.setText( - f"{round(decade_attenuation, 3)} dB / decade") - else: - self.lower_db_per_octave_label.setText("Not calculated") - self.lower_db_per_decade_label.setText("Not calculated") - - upper_six_db_location = next((i for i in range( - upper_cutoff_location, -1, -1) if (pass_band_db - s21[i].gain) > 6), -1) - - if upper_six_db_location < 0: - self.result_label.setText("Upper 6 dB location not found.") - return - upper_six_db_cutoff_frequency = ( - s21[upper_six_db_location].freq) - self.upper_six_db_label.setText( - format_frequency(upper_six_db_cutoff_frequency)) - - six_db_span = ( - upper_six_db_cutoff_frequency - lower_six_db_cutoff_frequency) - - self.six_db_span_label.setText( - format_frequency(six_db_span)) - - ten_db_location = next((i for i in range( - upper_cutoff_location, -1, -1) if (pass_band_db - s21[i].gain) > 10), -1) - - twenty_db_location = next((i for i in range( - upper_cutoff_location, -1, -1) if (pass_band_db - s21[i].gain) > 20), -1) - - sixty_db_location = next((i for i in range( - upper_six_db_location, -1, -1) if (pass_band_db - s21[i].gain) > 60), -1) - - if sixty_db_location > 0: - sixty_db_cutoff_frequency = ( - s21[sixty_db_location].freq) - self.upper_sixty_db_label.setText( - format_frequency(sixty_db_cutoff_frequency)) - elif ten_db_location != -1 and twenty_db_location != -1: - ten = s21[ten_db_location].freq - twenty = s21[twenty_db_location].freq - sixty_db_frequency = ten * 10 ** ( - 5 * (math.log10(twenty) - math.log10(ten))) - self.upper_sixty_db_label.setText( - f"{format_frequency(sixty_db_frequency)} (derived)") - else: - self.upper_sixty_db_label.setText("Not calculated") - - if (ten_db_location > 0 and - twenty_db_location > 0 and - ten_db_location != twenty_db_location): - octave_attenuation, decade_attenuation = self.calculateRolloff( - ten_db_location, twenty_db_location) - self.upper_db_per_octave_label.setText( - f"{round(octave_attenuation, 3)} dB / octave") - self.upper_db_per_decade_label.setText( - f"{round(decade_attenuation, 3)} dB / decade") - else: - self.upper_db_per_octave_label.setText("Not calculated") - self.upper_db_per_decade_label.setText("Not calculated") - - if upper_cutoff_gain < -4 or lower_cutoff_gain < -4: - self.result_label.setText( - f"Analysis complete ({len(self.app.data.s11)} points)\n" - f"Insufficient data for analysis. Increase segment count.") - return - self.result_label.setText( - f"Analysis complete ({len(self.app.data.s11)} points)") + def find_bounderies(self, + gains: List[float], + _: int, peak_db: float) -> Dict[str, int]: + cutoff_pos = {} + for attn in CUTOFF_VALS: + cutoff_pos[f"{attn:.1f}dB_l"], cutoff_pos[f"{attn:.1f}dB_r"] = ( + at.dip_cut_offs(gains, peak_db, attn)) + return cutoff_pos diff --git a/NanoVNASaver/AnalyticTools.py b/NanoVNASaver/AnalyticTools.py index 8fb2f95..9312cd4 100644 --- a/NanoVNASaver/AnalyticTools.py +++ b/NanoVNASaver/AnalyticTools.py @@ -16,13 +16,14 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from typing import Callable, List, Tuple import itertools as it +import math +from typing import Callable, List, Tuple import numpy as np import scipy -from NanoVNASaver.RFTools import Datapoint +import NanoVNASaver.AnalyticTools as at def zero_crossings(data: List[float]) -> List[int]: @@ -59,8 +60,8 @@ def maxima(data: List[float], threshold: float = 0.0) -> List[int]: Returns: List[int]: indices of maxima """ - peaks, _ = scipy.signal.find_peaks( - data, width=2, distance=3, prominence=1) + peaks = scipy.signal.find_peaks( + data, width=2, distance=3, prominence=1)[0].tolist() return [ i for i in peaks if data[i] > threshold ] if threshold else peaks @@ -75,8 +76,8 @@ def minima(data: List[float], threshold: float = 0.0) -> List[int]: Returns: List[int]: indices of minima """ - bottoms, _ = scipy.signal.find_peaks( - -np.array(data), width=2, distance=3, prominence=1) + bottoms = scipy.signal.find_peaks( + -np.array(data), width=2, distance=3, prominence=1)[0].tolist() return [ i for i in bottoms if data[i] < threshold ] if threshold else bottoms @@ -139,3 +140,9 @@ def cut_off_right(gains: List[float], idx: int, (i for i in range(idx, len(gains)) if (peak_gain - gains[i]) > attn), -1) + + +def dip_cut_offs(gains: List[float], peak_gain: float, + attn: float = 3.0) -> Tuple[int, int]: + rng = np.where(np.array(gains) < (peak_gain - attn))[0].tolist() + return (rng[0], rng[-1]) if rng else (math.nan, math.nan) diff --git a/NanoVNASaver/Charts/RI.py b/NanoVNASaver/Charts/RI.py index fa19d9d..0b58d2d 100644 --- a/NanoVNASaver/Charts/RI.py +++ b/NanoVNASaver/Charts/RI.py @@ -127,8 +127,9 @@ class RealImaginaryChart(FrequencyChart): self.drawTitle(qp) def drawValues(self, qp: QtGui.QPainter): - if len(self.data) == 0 and len(self.reference) == 0: + if not self.data and not self.reference: return + pen = QtGui.QPen(Chart.color.sweep) pen.setWidth(self.dim.point) line_pen = QtGui.QPen(Chart.color.sweep) @@ -142,71 +143,7 @@ class RealImaginaryChart(FrequencyChart): if self.bands.enabled: self.drawBands(qp, self.fstart, self.fstop) - # Find scaling - if self.fixedValues: - min_real = self.minDisplayReal - max_real = self.maxDisplayReal - min_imag = self.minDisplayImag - max_imag = self.maxDisplayImag - else: - min_real = 1000 - min_imag = 1000 - max_real = 0 - max_imag = -1000 - for d in self.data: - imp = self.impedance(d) - re, im = imp.real, imp.imag - if math.isinf(re): # Avoid infinite scales - continue - max_real = max(max_real, re) - min_real = min(min_real, re) - max_imag = max(max_imag, im) - min_imag = min(min_imag, im) - # Also check min/max for the reference sweep - for d in self.reference: - if d.freq < self.fstart or d.freq > self.fstop: - continue - imp = self.impedance(d) - re, im = imp.real, imp.imag - if math.isinf(re): # Avoid infinite scales - continue - max_real = max(max_real, re) - min_real = min(min_real, re) - max_imag = max(max_imag, im) - min_imag = min(min_imag, im) - # Always have at least 8 numbered horizontal lines - max_real = math.ceil(max_real) - min_real = math.floor(min_real) - max_imag = math.ceil(max_imag) - min_imag = math.floor(min_imag) - - if max_imag - min_imag < 8: - missing = 8 - (max_imag - min_imag) - max_imag += math.ceil(missing / 2) - min_imag -= math.floor(missing / 2) - - if 0 > max_imag > -2: - max_imag = 0 - if 0 < min_imag < 2: - min_imag = 0 - - if (max_imag - min_imag) > 8 and min_imag < 0 < max_imag: - # We should show a "0" line for the reactive part - span = max_imag - min_imag - step_size = span / 8 - if max_imag < step_size: - # The 0 line is the first step after the top. - # Scale accordingly. - max_imag = -min_imag / 7 - elif -min_imag < step_size: - # The 0 line is the last step before the bottom. - # Scale accordingly. - min_imag = -max_imag / 7 - else: - # Scale max_imag to be a whole factor of min_imag - num_min = math.floor(min_imag / step_size * -1) - num_max = 8 - num_min - max_imag = num_max * (min_imag / num_min) * -1 + min_real, max_real, min_imag, max_imag = self.find_scaling() self.max_real = max_real self.max_imag = max_imag @@ -214,24 +151,9 @@ class RealImaginaryChart(FrequencyChart): self.span_real = (max_real - min_real) or 0.01 self.span_imag = (max_imag - min_imag) or 0.01 - # We want one horizontal tick per 50 pixels, at most - horizontal_ticks = self.dim.height // 50 + self.drawHorizontalTicks(qp) fmt = Format(max_nr_digits=3) - for i in range(horizontal_ticks): - y = self.topMargin + i * self.dim.height // horizontal_ticks - qp.setPen(QtGui.QPen(Chart.color.foreground)) - qp.drawLine(self.leftMargin - 5, y, - self.leftMargin + self.dim.width + 5, y) - qp.setPen(QtGui.QPen(Chart.color.text)) - re = max_real - i * self.span_real / horizontal_ticks - im = max_imag - i * self.span_imag / horizontal_ticks - qp.drawText(3, y + 4, f"{Value(re, fmt=fmt)}") - qp.drawText( - self.leftMargin + self.dim.width + 8, - y + 4, - f"{Value(im, fmt=fmt)}") - qp.drawText(3, self.dim.height + self.topMargin, str(Value(min_real, fmt=fmt))) qp.drawText(self.leftMargin + self.dim.width + 8, @@ -242,7 +164,7 @@ class RealImaginaryChart(FrequencyChart): primary_pen = pen secondary_pen = QtGui.QPen(Chart.color.sweep_secondary) - if len(self.data) > 0: + if self.data: c = QtGui.QColor(Chart.color.sweep) c.setAlpha(255) pen = QtGui.QPen(c) @@ -307,7 +229,7 @@ class RealImaginaryChart(FrequencyChart): line_pen.setColor(Chart.color.reference) secondary_pen.setColor(Chart.color.reference_secondary) qp.setPen(primary_pen) - if len(self.reference) > 0: + if self.reference: c = QtGui.QColor(Chart.color.reference) c.setAlpha(255) pen = QtGui.QPen(c) @@ -379,6 +301,97 @@ class RealImaginaryChart(FrequencyChart): self.drawMarker(x, y_im, qp, m.color, self.markers.index(m) + 1) + def drawHorizontalTicks(self, qp): + # We want one horizontal tick per 50 pixels, at most + fmt = Format(max_nr_digits=3) + horizontal_ticks = self.dim.height // 50 + for i in range(horizontal_ticks): + y = self.topMargin + i * self.dim.height // horizontal_ticks + qp.setPen(QtGui.QPen(Chart.color.foreground)) + qp.drawLine(self.leftMargin - 5, y, + self.leftMargin + self.dim.width + 5, y) + qp.setPen(QtGui.QPen(Chart.color.text)) + re = self.max_real - i * self.span_real / horizontal_ticks + im = self.max_imag - i * self.span_imag / horizontal_ticks + qp.drawText(3, y + 4, f"{Value(re, fmt=fmt)}") + qp.drawText( + self.leftMargin + self.dim.width + 8, + y + 4, + f"{Value(im, fmt=fmt)}") + + def find_scaling(self): + # Find scaling + if self.fixedValues: + min_real = self.minDisplayReal + max_real = self.maxDisplayReal + min_imag = self.minDisplayImag + max_imag = self.maxDisplayImag + return min_real, max_real, min_imag, max_imag + + min_real = 1000 + min_imag = 1000 + max_real = 0 + max_imag = -1000 + for d in self.data: + imp = self.impedance(d) + re, im = imp.real, imp.imag + if math.isinf(re): # Avoid infinite scales + continue + max_real = max(max_real, re) + min_real = min(min_real, re) + max_imag = max(max_imag, im) + min_imag = min(min_imag, im) + # Also check min/max for the reference sweep + for d in self.reference: + if d.freq < self.fstart or d.freq > self.fstop: + continue + imp = self.impedance(d) + re, im = imp.real, imp.imag + if math.isinf(re): # Avoid infinite scales + continue + max_real = max(max_real, re) + min_real = min(min_real, re) + max_imag = max(max_imag, im) + min_imag = min(min_imag, im) + # Always have at least 8 numbered horizontal lines + max_real = math.ceil(max_real) + min_real = math.floor(min_real) + max_imag = math.ceil(max_imag) + min_imag = math.floor(min_imag) + + min_imag, max_imag = self.imag_scaling_constraints(min_imag, max_imag) + return min_real, max_real, min_imag, max_imag + + def imag_scaling_constraints(self, min_imag, max_imag): + if max_imag - min_imag < 8: + missing = 8 - (max_imag - min_imag) + max_imag += math.ceil(missing / 2) + min_imag -= math.floor(missing / 2) + + if 0 > max_imag > -2: + max_imag = 0 + if 0 < min_imag < 2: + min_imag = 0 + + if (max_imag - min_imag) > 8 and min_imag < 0 < max_imag: + # We should show a "0" line for the reactive part + span = max_imag - min_imag + step_size = span / 8 + if max_imag < step_size: + # The 0 line is the first step after the top. + # Scale accordingly. + max_imag = -min_imag / 7 + elif -min_imag < step_size: + # The 0 line is the last step before the bottom. + # Scale accordingly. + min_imag = -max_imag / 7 + else: + # Scale max_imag to be a whole factor of min_imag + num_min = math.floor(min_imag / step_size * -1) + num_max = 8 - num_min + max_imag = num_max * (min_imag / num_min) * -1 + return min_imag, max_imag + def getImYPosition(self, d: Datapoint) -> int: im = self.impedance(d).imag return int(self.topMargin + (self.max_imag - im) / self.span_imag diff --git a/NanoVNASaver/SITools.py b/NanoVNASaver/SITools.py index 4214f7e..686a0b4 100644 --- a/NanoVNASaver/SITools.py +++ b/NanoVNASaver/SITools.py @@ -72,7 +72,8 @@ class Value: self.fmt = fmt if isinstance(value, str): self._value = Decimal(math.nan) - self.parse(value) + if value.lower() != 'nan': + self.parse(value) else: self._value = Decimal(value, context=Value.CTX) @@ -83,7 +84,7 @@ class Value: def __str__(self) -> str: fmt = self.fmt if math.isnan(self._value): - return (f"NaN{fmt.space_str}{self._unit}") + return (f"-{fmt.space_str}{self._unit}") if (fmt.assume_infinity and abs(self._value) >= 10 ** ((fmt.max_offset + 1) * 3)): return (("-" if self._value < 0 else "") + diff --git a/test/test_analytics.py b/test/test_analytics.py new file mode 100644 index 0000000..401735f --- /dev/null +++ b/test/test_analytics.py @@ -0,0 +1,63 @@ +# NanoVNASaver +# +# A python program to view and export Touchstone data from a NanoVNA +# Copyright (C) 2019, 2020 Rune B. Broberg +# Copyright (C) 2020ff 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 unittest + +import numpy as np +# Import targets to be tested +import NanoVNASaver.AnalyticTools as at + +SINEWAVE = [math.sin(x/45 * math.pi) for x in range(360)] + + +class AnalyticsTools(unittest.TestCase): + + def test_zero_crossings(self): + self.assertEqual(at.zero_crossings(SINEWAVE), + [45, 90, 135, 180, 225, 270, 315]) + self.assertEqual(at.zero_crossings([]), []) + + def test_maxima(self): + self.assertEqual(at.maxima(SINEWAVE), [112, 202, 292]) + self.assertEqual(at.maxima(SINEWAVE, 0.9999), []) + self.assertEqual(at.maxima(-np.array(SINEWAVE)), [67, 157, 247]) + + def test_minima(self): + self.assertEqual(at.minima(SINEWAVE), [67, 157, 247]) + self.assertEqual(at.minima(SINEWAVE, -0.9999), []) + self.assertEqual(at.minima(-np.array(SINEWAVE)), [112, 202, 292]) + + def test_take_from_idx(self): + self.assertEqual( + at.take_from_idx(SINEWAVE, 109, lambda i: i[1] > 0.9), + [107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118]) + + def test_center_from_idx(self): + self.assertEqual(at.center_from_idx(SINEWAVE, 200), 22) + self.assertEqual(at.center_from_idx(SINEWAVE, 200, .5), 202) + + def test_cut_off_left(self): + self.assertEqual(at.cut_off_left(SINEWAVE, 210, 1, 0.4), 189) + + def test_cut_off_right(self): + self.assertEqual(at.cut_off_right(SINEWAVE, 210, 1, 0.4), 216) + + def test_dip_cut_offs(self): + self.assertEqual(at.dip_cut_offs(SINEWAVE, .8, .9), (47, 358)) + self.assertEqual(at.dip_cut_offs(SINEWAVE[:90], .8, .9), (47, 88)) diff --git a/test/test_rftools.py b/test/test_rftools.py index 677d68f..e146f66 100644 --- a/test/test_rftools.py +++ b/test/test_rftools.py @@ -143,7 +143,8 @@ class TestRFToolsDatapoint(unittest.TestCase): self.dp50 = Datapoint(100000, 1, 0) self.dp75 = Datapoint(100000, 0.2, 0) self.dp_im50 = Datapoint(100000, 0, 1) - self.dp_ill = Datapoint(100000, 1.1, 0) + self.dp_ill = Datapoint(100000, 1.1, 0) + self.dp_div0 = Datapoint(100000, 0.0, 1.0) def test_properties(self): self.assertEqual(self.dp.z, complex(0.1091, 0.3118)) @@ -166,3 +167,10 @@ class TestRFToolsDatapoint(unittest.TestCase): self.assertAlmostEqual(self.dp.qFactor(), 0.6999837) self.assertAlmostEqual(self.dp.capacitiveEquivalent(), -4.54761539e-08) self.assertAlmostEqual(self.dp.inductiveEquivalent(), 5.57001e-05) + self.assertAlmostEqual(self.dp.shuntImpedance(), + complex(-6.18740998e-04, 8.749362528)) + self.assertAlmostEqual(self.dp.seriesImpedance(), + complex(-2.02067318e-2, -285.7351012)) + self.assertAlmostEqual(self.dp0.shuntImpedance(), 0) + self.assertAlmostEqual(self.dp0.seriesImpedance(), math.inf) + self.assertAlmostEqual(self.dp50.shuntImpedance(), math.inf) diff --git a/test/test_sitools.py b/test/test_sitools.py index dec3e45..6b718ff 100644 --- a/test/test_sitools.py +++ b/test/test_sitools.py @@ -81,7 +81,7 @@ class TestTSIToolsValue(unittest.TestCase): self.assertEqual(str(Value(1e24)), "1.00000Y") self.assertEqual(str(Value(1e27)), "\N{INFINITY}") self.assertEqual(str(Value(-1e27)), "-\N{INFINITY}") - self.assertEqual(str(Value(nan)), "NaN") + self.assertEqual(str(Value(nan)), "-") self.assertEqual(float(Value(1e27)), 1e27) self.assertEqual( str(Value(11, fmt=Format(printable_max=10))), '')