diff --git a/.gitignore b/.gitignore index 998ca08..cf49507 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /venv/ /env/ .idea/ +.tox/ .vscode/ /build/ /dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e2b36f9..3177b9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ Changelog ========= +0.5.4 +----- + 0.5.3 ----- diff --git a/NanoVNASaver/Analysis/BandPassAnalysis.py b/NanoVNASaver/Analysis/BandPassAnalysis.py index 7558516..449fa06 100644 --- a/NanoVNASaver/Analysis/BandPassAnalysis.py +++ b/NanoVNASaver/Analysis/BandPassAnalysis.py @@ -18,15 +18,18 @@ # along with this program. If not, see . import logging import math +from typing import Dict from PyQt5 import QtWidgets +import NanoVNASaver.AnalyticTools as at +from NanoVNASaver.Analysis.Base import Analysis from NanoVNASaver.Formatting import format_frequency -from NanoVNASaver.Analysis import Analysis - logger = logging.getLogger(__name__) +CUTOFF_VALS = (3.0, 6.0, 10.0, 20.0, 60.0) + class BandPassAnalysis(Analysis): def __init__(self, app): @@ -41,344 +44,149 @@ class BandPassAnalysis(Analysis): QtWidgets.QLabel( f"Please place {self.app.markers[0].name}" f" in the filter passband.")) - 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) + self.label = { + label: QtWidgets.QLabel() for label in + ('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.addRow("Result:", self.label['result']) 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("Center frequency:", self.label['freq_center']) + layout.addRow("Bandwidth (-3 dB):", self.label['span_3.0dB']) + layout.addRow("Quality factor:", self.label['q_factor']) + layout.addRow("Bandwidth (-6 dB):", self.label['span_6.0dB']) 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("Cutoff frequency:", self.label['3.0dB_l']) + layout.addRow("-6 dB point:", self.label['6.0dB_l']) + layout.addRow("-60 dB point:", self.label['60.0dB_l']) + layout.addRow("Roll-off:", self.label['octave_l']) + layout.addRow("Roll-off:", self.label['decade_l']) 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) + layout.addRow("Cutoff frequency:", self.label['3.0dB_r']) + layout.addRow("-6 dB point:", self.label['6.0dB_r']) + layout.addRow("-60 dB point:", self.label['60.0dB_r']) + layout.addRow("Roll-off:", self.label['octave_r']) + layout.addRow("Roll-off:", self.label['decade_r']) def reset(self): - self.result_label.clear() - self.center_frequency_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() + for label in self.label.values(): + label.clear() def runAnalysis(self): - self.reset() - pass_band_location = self.app.markers[0].location - logger.debug("Pass band location: %d", pass_band_location) - - if len(self.app.data.s21) == 0: + if not self.app.data.s21: logger.debug("No data to analyse") - self.result_label.setText("No data to analyse.") + self.label['result'].setText("No data to analyse.") return - if pass_band_location < 0: - logger.debug("No location for %s", self.app.markers[0].name) - self.result_label.setText( - f"Please place {self.app.markers[0].name} in the passband.") + self.reset() + 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 - pass_band_db = self.app.data.s21[pass_band_location].gain - - logger.debug("Initial passband gain: %d", pass_band_db) - - initial_lower_cutoff_location = -1 - for i in range(pass_band_location, -1, -1): - if (pass_band_db - self.app.data.s21[i].gain) > 3: - # We found a cutoff location - initial_lower_cutoff_location = i - break - - if initial_lower_cutoff_location < 0: - self.result_label.setText("Lower cutoff location not found.") + # 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 + peak_db = gains[peak] + logger.debug("Bandpass center pos: %d(%fdB)", peak, peak_db) - initial_lower_cutoff_frequency = ( - self.app.data.s21[initial_lower_cutoff_location].freq) + # 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_freq = { + att: s21[val].freq if val >= 0 else math.nan + for att, val in cutoff_pos.items() + } + cutoff_gain = { + att: gains[val] if val >= 0 else math.nan + for att, val in cutoff_pos.items() + } + logger.debug("Cuttoff frequencies: %s", cutoff_freq) + logger.debug("Cuttoff gains: %s", cutoff_gain) - logger.debug("Found initial lower cutoff frequency at %d", - initial_lower_cutoff_frequency) + self.derive_60dB(cutoff_pos, cutoff_freq) - initial_upper_cutoff_location = -1 - for i in range(pass_band_location, len(self.app.data.s21), 1): - if (pass_band_db - self.app.data.s21[i].gain) > 3: - # We found a cutoff location - initial_upper_cutoff_location = i - break + 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'])), + } + result['q_factor'] = result['freq_center'] / result['span_3.0dB'] - if initial_upper_cutoff_location < 0: - self.result_label.setText("Upper cutoff location not found.") - return + result['octave_l'], result['decade_l'] = self.calculateRolloff( + cutoff_pos["10.0dB_l"], cutoff_pos["20.0dB_l"]) + result['octave_r'], result['decade_r'] = self.calculateRolloff( + cutoff_pos["10.0dB_r"], cutoff_pos["20.0dB_r"]) - initial_upper_cutoff_frequency = ( - self.app.data.s21[initial_upper_cutoff_location].freq) + for label, val in cutoff_freq.items(): + self.label[label].setText( + f"{format_frequency(val)}" + f" ({cutoff_gain[label]:.1f} dB)") + for label in ('freq_center', 'span_3.0dB', 'span_6.0dB'): + self.label[label].setText(format_frequency(result[label])) + self.label['q_factor'].setText(f"{result['q_factor']:.2f}") - logger.debug("Found initial upper cutoff frequency at %d", - initial_upper_cutoff_frequency) + for label in ('octave_l', 'decade_l', 'octave_r', 'decade_r'): + self.label[label].setText(f"{result[label]:.3f}dB/{label[:-2]}") - peak_location = -1 - peak_db = self.app.data.s21[initial_lower_cutoff_location].gain - for i in range(initial_lower_cutoff_location, - initial_upper_cutoff_location, 1): - db = self.app.data.s21[i].gain - if db > peak_db: - peak_db = db - peak_location = i + self.app.markers[0].setFrequency(f"{result['freq_center']}") + self.app.markers[0].frequencyInput.setText(f"{result['freq_center']}") + self.app.markers[1].setFrequency(f"{cutoff_freq['3.0dB_l']}") + self.app.markers[1].frequencyInput.setText(f"{cutoff_freq['3.0dB_l']}") + self.app.markers[2].setFrequency(f"{cutoff_freq['3.0dB_r']}") + self.app.markers[2].frequencyInput.setText(f"{cutoff_freq['3.0dB_r']}") - logger.debug("Found peak of %f at %d", peak_db, - self.app.data.s11[peak_location].freq) - - lower_cutoff_location = -1 - pass_band_db = peak_db - for i in range(peak_location, -1, -1): - if (pass_band_db - self.app.data.s21[i].gain) > 3: - # We found the cutoff location - lower_cutoff_location = i - break - - lower_cutoff_frequency = ( - self.app.data.s21[lower_cutoff_location].freq) - lower_cutoff_gain = ( - self.app.data.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 = -1 - pass_band_db = peak_db - for i in range(peak_location, len(self.app.data.s21), 1): - if (pass_band_db - self.app.data.s21[i].gain) > 3: - # We found the cutoff location - upper_cutoff_location = i - break - - upper_cutoff_frequency = self.app.data.s21[upper_cutoff_location].freq - upper_cutoff_gain = ( - self.app.data.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 roll-off - - lower_six_db_location = -1 - for i in range(lower_cutoff_location, -1, -1): - if (pass_band_db - self.app.data.s21[i].gain) > 6: - # We found 6dB location - lower_six_db_location = i - break - - if lower_six_db_location < 0: - self.result_label.setText("Lower 6 dB location not found.") - return - lower_six_db_cutoff_frequency = ( - self.app.data.s21[lower_six_db_location].freq) - self.lower_six_db_label.setText( - format_frequency(lower_six_db_cutoff_frequency)) - - ten_db_location = -1 - for i in range(lower_cutoff_location, -1, -1): - if (pass_band_db - self.app.data.s21[i].gain) > 10: - # We found 6dB location - ten_db_location = i - break - - twenty_db_location = -1 - for i in range(lower_cutoff_location, -1, -1): - if (pass_band_db - self.app.data.s21[i].gain) > 20: - # We found 6dB location - twenty_db_location = i - break - - sixty_db_location = -1 - for i in range(lower_six_db_location, -1, -1): - if (pass_band_db - self.app.data.s21[i].gain) > 60: - # We found 60dB location! Wow. - sixty_db_location = i - break - - if sixty_db_location > 0: - if sixty_db_location > 0: - sixty_db_cutoff_frequency = ( - self.app.data.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 = self.app.data.s21[ten_db_location].freq - twenty = self.app.data.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( - str(round(octave_attenuation, 3)) + " dB / octave") - self.lower_db_per_decade_label.setText( - str(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 roll-off - - upper_six_db_location = -1 - for i in range(upper_cutoff_location, len(self.app.data.s21), 1): - if (pass_band_db - self.app.data.s21[i].gain) > 6: - # We found 6dB location - upper_six_db_location = i - break - - if upper_six_db_location < 0: - self.result_label.setText("Upper 6 dB location not found.") - return - upper_six_db_cutoff_frequency = ( - self.app.data.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 = -1 - for i in range(upper_cutoff_location, len(self.app.data.s21), 1): - if (pass_band_db - self.app.data.s21[i].gain) > 10: - # We found 6dB location - ten_db_location = i - break - - twenty_db_location = -1 - for i in range(upper_cutoff_location, len(self.app.data.s21), 1): - if (pass_band_db - self.app.data.s21[i].gain) > 20: - # We found 6dB location - twenty_db_location = i - break - - sixty_db_location = -1 - for i in range(upper_six_db_location, len(self.app.data.s21), 1): - if (pass_band_db - self.app.data.s21[i].gain) > 60: - # We found 60dB location! Wow. - sixty_db_location = i - break - - if sixty_db_location > 0: - sixty_db_cutoff_frequency = ( - self.app.data.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 = self.app.data.s21[ten_db_location].freq - twenty = self.app.data.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" + if cutoff_gain['3.0dB_l'] < -4 or cutoff_gain['3.0dB_r'] < -4: + logger.warning( + "Data points insufficient for true -3 dB points." + "Cutoff gains: %fdB, %fdB", cutoff_gain['3.0dB_l'], cutoff_gain['3.0dB_r']) + self.label['result'].setText( + f"Analysis complete ({len(s21)} points)\n" f"Insufficient data for analysis. Increase segment count.") - else: - self.result_label.setText( - f"Analysis complete ({len(self.app.data.s11)} points)") + return + self.label['result'].setText( + f"Analysis complete ({len(s21)} points)") + + def derive_60dB(self, + cutoff_pos: Dict[str, int], + cutoff_freq: Dict[str, float]): + """derive 60dB cutoff if needed an possible + + Args: + cutoff_pos (Dict[str, int]) + cutoff_freq (Dict[str, float]) + """ + if (math.isnan(cutoff_freq['60.0dB_l']) and + cutoff_pos['20.0dB_l'] != -1 and cutoff_pos['10.0dB_l'] != -1): + cutoff_freq['60.0dB_l'] = ( + cutoff_freq["10.0dB_l"] * + 10 ** (5 * (math.log10(cutoff_pos['20.0dB_l']) - + math.log10(cutoff_pos['10.0dB_l'])))) + if (math.isnan(cutoff_freq['60.0dB_r']) and + cutoff_pos['20.0dB_r'] != -1 and cutoff_pos['10.0dB_r'] != -1): + cutoff_freq['60.0dB_r'] = ( + cutoff_freq["10.0dB_r"] * + 10 ** (5 * (math.log10(cutoff_pos['20.0dB_r']) - + math.log10(cutoff_pos['10.0dB_r']) + ))) diff --git a/NanoVNASaver/Analysis/BandStopAnalysis.py b/NanoVNASaver/Analysis/BandStopAnalysis.py index f58a74e..f5e4b70 100644 --- a/NanoVNASaver/Analysis/BandStopAnalysis.py +++ b/NanoVNASaver/Analysis/BandStopAnalysis.py @@ -21,7 +21,7 @@ import math from PyQt5 import QtWidgets -from NanoVNASaver.Analysis import Analysis +from NanoVNASaver.Analysis.Base import Analysis from NanoVNASaver.Formatting import format_frequency logger = logging.getLogger(__name__) @@ -100,34 +100,28 @@ class BandStopAnalysis(Analysis): def runAnalysis(self): self.reset() - - if len(self.app.data.s21) == 0: + if not self.app.data.s21: logger.debug("No data to analyse") self.result_label.setText("No data to analyse.") return - peak_location = -1 - peak_db = self.app.data.s21[0].gain - for i in range(len(self.app.data.s21)): - db = self.app.data.s21[i].gain - if db > peak_db: - peak_db = db - peak_location = i + s21 = self.app.data.s21 + peak_location, peak = max(enumerate(s21), key=lambda i: i[1].gain) logger.debug("Found peak of %f at %d", - peak_db, self.app.data.s11[peak_location].freq) + peak.gain, peak.freq) + pass_band_db = peak.gain lower_cutoff_location = -1 - pass_band_db = peak_db - for i in range(len(self.app.data.s21)): - if (pass_band_db - self.app.data.s21[i].gain) > 3: + for i in range(len(s21)): + if (pass_band_db - s21[i].gain) > 3: # We found the cutoff location lower_cutoff_location = i break - lower_cutoff_frequency = self.app.data.s21[lower_cutoff_location].freq + lower_cutoff_frequency = s21[lower_cutoff_location].freq lower_cutoff_gain = ( - self.app.data.s21[lower_cutoff_location].gain - pass_band_db) + s21[lower_cutoff_location].gain - pass_band_db) if lower_cutoff_gain < -4: logger.debug("Lower cutoff frequency found at %f dB" @@ -145,16 +139,16 @@ class BandStopAnalysis(Analysis): self.app.markers[1].frequencyInput.setText(str(lower_cutoff_frequency)) upper_cutoff_location = -1 - for i in range(len(self.app.data.s21)-1, -1, -1): - if (pass_band_db - self.app.data.s21[i].gain) > 3: + for i in range(len(s21)-1, -1, -1): + if (pass_band_db - s21[i].gain) > 3: # We found the cutoff location upper_cutoff_location = i break upper_cutoff_frequency = ( - self.app.data.s21[upper_cutoff_location].freq) + s21[upper_cutoff_location].freq) upper_cutoff_gain = ( - self.app.data.s21[upper_cutoff_location].gain - pass_band_db) + 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.", @@ -186,8 +180,8 @@ class BandStopAnalysis(Analysis): # Lower roll-off lower_six_db_location = -1 - for i in range(lower_cutoff_location, len(self.app.data.s21)): - if (pass_band_db - self.app.data.s21[i].gain) > 6: + for i in range(lower_cutoff_location, len(s21)): + if (pass_band_db - s21[i].gain) > 6: # We found 6dB location lower_six_db_location = i break @@ -196,39 +190,39 @@ class BandStopAnalysis(Analysis): self.result_label.setText("Lower 6 dB location not found.") return lower_six_db_cutoff_frequency = ( - self.app.data.s21[lower_six_db_location].freq) + s21[lower_six_db_location].freq) self.lower_six_db_label.setText( format_frequency(lower_six_db_cutoff_frequency)) ten_db_location = -1 - for i in range(lower_cutoff_location, len(self.app.data.s21)): - if (pass_band_db - self.app.data.s21[i].gain) > 10: + for i in range(lower_cutoff_location, len(s21)): + if (pass_band_db - s21[i].gain) > 10: # We found 6dB location ten_db_location = i break twenty_db_location = -1 - for i in range(lower_cutoff_location, len(self.app.data.s21)): - if (pass_band_db - self.app.data.s21[i].gain) > 20: + for i in range(lower_cutoff_location, len(s21)): + if (pass_band_db - s21[i].gain) > 20: # We found 6dB location twenty_db_location = i break sixty_db_location = -1 - for i in range(lower_six_db_location, len(self.app.data.s21)): - if (pass_band_db - self.app.data.s21[i].gain) > 60: + for i in range(lower_six_db_location, len(s21)): + if (pass_band_db - s21[i].gain) > 60: # We found 60dB location! Wow. sixty_db_location = i break if sixty_db_location > 0: sixty_db_cutoff_frequency = ( - self.app.data.s21[sixty_db_location].freq) + 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 = self.app.data.s21[ten_db_location].freq - twenty = self.app.data.s21[twenty_db_location].freq + 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( @@ -253,7 +247,7 @@ class BandStopAnalysis(Analysis): upper_six_db_location = -1 for i in range(upper_cutoff_location, -1, -1): - if (pass_band_db - self.app.data.s21[i].gain) > 6: + if (pass_band_db - s21[i].gain) > 6: # We found 6dB location upper_six_db_location = i break @@ -262,7 +256,7 @@ class BandStopAnalysis(Analysis): self.result_label.setText("Upper 6 dB location not found.") return upper_six_db_cutoff_frequency = ( - self.app.data.s21[upper_six_db_location].freq) + s21[upper_six_db_location].freq) self.upper_six_db_label.setText( format_frequency(upper_six_db_cutoff_frequency)) @@ -274,33 +268,33 @@ class BandStopAnalysis(Analysis): ten_db_location = -1 for i in range(upper_cutoff_location, -1, -1): - if (pass_band_db - self.app.data.s21[i].gain) > 10: + if (pass_band_db - s21[i].gain) > 10: # We found 6dB location ten_db_location = i break twenty_db_location = -1 for i in range(upper_cutoff_location, -1, -1): - if (pass_band_db - self.app.data.s21[i].gain) > 20: + if (pass_band_db - s21[i].gain) > 20: # We found 6dB location twenty_db_location = i break sixty_db_location = -1 for i in range(upper_six_db_location, -1, -1): - if (pass_band_db - self.app.data.s21[i].gain) > 60: + if (pass_band_db - s21[i].gain) > 60: # We found 60dB location! Wow. sixty_db_location = i break if sixty_db_location > 0: sixty_db_cutoff_frequency = ( - self.app.data.s21[sixty_db_location].freq) + 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 = self.app.data.s21[ten_db_location].freq - twenty = self.app.data.s21[twenty_db_location].freq + 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( diff --git a/NanoVNASaver/Analysis/Analysis.py b/NanoVNASaver/Analysis/Base.py similarity index 79% rename from NanoVNASaver/Analysis/Analysis.py rename to NanoVNASaver/Analysis/Base.py index fa0d782..eb05df9 100644 --- a/NanoVNASaver/Analysis/Analysis.py +++ b/NanoVNASaver/Analysis/Base.py @@ -18,13 +18,22 @@ # along with this program. If not, see . import logging import math +from typing import Tuple + import numpy as np +import scipy from PyQt5 import QtWidgets -from scipy import signal + logger = logging.getLogger(__name__) +class QHLine(QtWidgets.QFrame): + def __init__(self): + super().__init__() + self.setFrameShape(QtWidgets.QFrame.HLine) + + class Analysis: _widget = None @@ -109,7 +118,7 @@ class Analysis: :param data: list of values :param threshold: """ - peaks, _ = signal.find_peaks( + peaks, _ = scipy.signal.find_peaks( data, width=2, distance=3, prominence=1) # my_data = np.array(data) @@ -130,21 +139,18 @@ class Analysis: def reset(self): pass - def calculateRolloff(self, location1, location2): - if location1 == location2: - return 0, 0 - frequency1 = self.app.data.s21[location1].freq - frequency2 = self.app.data.s21[location2].freq - gain1 = self.app.data.s21[location1].gain - gain2 = self.app.data.s21[location2].gain - frequency_factor = frequency2 / frequency1 - 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("%f dB over %f factor", attenuation, frequency_factor) - octave_attenuation = attenuation / \ - (math.log10(frequency_factor) / math.log10(2)) - decade_attenuation = attenuation / math.log10(frequency_factor) - return octave_attenuation, decade_attenuation + def calculateRolloff(self, idx_1: int, idx_2: int) -> Tuple[float, float]: + if idx_1 == idx_2: + return (math.nan, math.nan) + s21 = self.app.data.s21 + freq_1 = s21[idx_1].freq + freq_2 = s21[idx_2].freq + gain1 = s21[idx_1].gain + gain2 = s21[idx_2].gain + factor = freq_1 / freq_2 if freq_1 > freq_2 else freq_2 / freq_1 + attn = abs(gain1 - gain2) + logger.debug("Measured points: %d Hz and %d Hz\n%fdB over %f factor", + freq_1, freq_2, attn, factor) + octave_attn = attn / (math.log10(factor) / math.log10(2)) + decade_attn = attn / math.log10(factor) + return (octave_attn, decade_attn) diff --git a/NanoVNASaver/Analysis/HighPassAnalysis.py b/NanoVNASaver/Analysis/HighPassAnalysis.py index 4060890..8893a3f 100644 --- a/NanoVNASaver/Analysis/HighPassAnalysis.py +++ b/NanoVNASaver/Analysis/HighPassAnalysis.py @@ -21,7 +21,7 @@ import math from PyQt5 import QtWidgets -from NanoVNASaver.Analysis import Analysis +from NanoVNASaver.Analysis.Base import Analysis from NanoVNASaver.Formatting import format_frequency logger = logging.getLogger(__name__) diff --git a/NanoVNASaver/Analysis/LowPassAnalysis.py b/NanoVNASaver/Analysis/LowPassAnalysis.py index 996089c..9342fc4 100644 --- a/NanoVNASaver/Analysis/LowPassAnalysis.py +++ b/NanoVNASaver/Analysis/LowPassAnalysis.py @@ -21,7 +21,7 @@ import math from PyQt5 import QtWidgets -from NanoVNASaver.Analysis import Analysis +from NanoVNASaver.Analysis.Base import Analysis from NanoVNASaver.Formatting import format_frequency logger = logging.getLogger(__name__) diff --git a/NanoVNASaver/Analysis/PeakSearchAnalysis.py b/NanoVNASaver/Analysis/PeakSearchAnalysis.py index 9f95b05..d867347 100644 --- a/NanoVNASaver/Analysis/PeakSearchAnalysis.py +++ b/NanoVNASaver/Analysis/PeakSearchAnalysis.py @@ -19,10 +19,10 @@ import logging from PyQt5 import QtWidgets -from scipy import signal +import scipy import numpy as np -from NanoVNASaver.Analysis import Analysis +from NanoVNASaver.Analysis.Base import Analysis from NanoVNASaver.Formatting import format_vswr from NanoVNASaver.Formatting import format_gain from NanoVNASaver.Formatting import format_resistance @@ -112,17 +112,17 @@ class PeakSearchAnalysis(Analysis): return if self.rbtn_peak_positive.isChecked(): - peaks, _ = signal.find_peaks( + peaks, _ = scipy.signal.find_peaks( data, width=3, distance=3, prominence=1) elif self.rbtn_peak_negative.isChecked(): sign = -1 data = [x * sign for x in data] - peaks, _ = signal.find_peaks( + peaks, _ = scipy.signal.find_peaks( data, width=3, distance=3, prominence=1) # elif self.rbtn_peak_both.isChecked(): - # peaks_max, _ = signal.find_peaks( + # peaks_max, _ = scipy.signal.find_peaks( # data, width=3, distance=3, prominence=1) - # peaks_min, _ = signal.find_peaks( + # peaks_min, _ = scipy.signal.find_peaks( # np.array(data)*-1, width=3, distance=3, prominence=1) # peaks = np.concatenate((peaks_max, peaks_min)) else: @@ -136,7 +136,7 @@ class PeakSearchAnalysis(Analysis): for i, p in np.ndenumerate(peaks): logger.debug("Peak %i at %d", i, p) - prominences = signal.peak_prominences(data, peaks)[0] + prominences = scipy.signal.peak_prominences(data, peaks)[0] logger.debug("%d prominences", len(prominences)) # Find the peaks with the most extreme values diff --git a/NanoVNASaver/Analysis/ResonanceAnalysis.py b/NanoVNASaver/Analysis/ResonanceAnalysis.py new file mode 100644 index 0000000..a32ef73 --- /dev/null +++ b/NanoVNASaver/Analysis/ResonanceAnalysis.py @@ -0,0 +1,316 @@ +# 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 os +import csv +import itertools +import logging + +from PyQt5 import QtWidgets + +from NanoVNASaver.Analysis.Base import Analysis, QHLine +from NanoVNASaver.Formatting import ( + format_frequency, format_complex_imp, + format_frequency_short, format_resistance) +from NanoVNASaver.RFTools import reflection_coefficient + +logger = logging.getLogger(__name__) + + +def format_resistence_neg(x): + return format_resistance(x, allow_negative=True) + + +def vswr_transformed(z, ratio=49) -> float: + refl = reflection_coefficient(z / ratio) + mag = abs(refl) + return 1 if mag == 1 else (1 + mag) / (1 - mag) + + +class ResonanceAnalysis(Analysis): + + 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(QHLine()) + + self.layout.addRow(QHLine()) + + self.results_label = QtWidgets.QLabel("Results") + self.layout.addRow(self.results_label) + + def _get_data(self, index): + my_data = {"freq": self.app.data.s11[index].freq, + "s11": self.app.data.s11[index].z, + "lambda": self.app.data.s11[index].wavelength, + "impedance": self.app.data.s11[index].impedance(), + "vswr": self.app.data.s11[index].vswr, + } + my_data["vswr_49"] = vswr_transformed( + my_data["impedance"], 49) + my_data["vswr_4"] = 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 = [d.phase for d in self.app.data.s11] + return sorted(self.find_crossing_zero(data)) + + def runAnalysis(self): + self.reset() + filename = ( + os.path.join("/tmp/", f"{self.input_description.text()}.csv") + if self.input_description.text() + else 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 _ in range(results_header, self.layout.rowCount()): + self.layout.removeRow(self.layout.rowCount() - 1) + + if crossing: + extended_data = [] + for m in crossing: + start, lowest, end = m + my_data = self._get_data(lowest) + s11_low = self.app.data.s11[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(s11_low.freq)}" + f" ({format_complex_imp(s11_low.impedance())})")) + else: + self.layout.addRow("Resonance", QtWidgets.QLabel( + format_frequency(self.app.data.s11[lowest].freq))) + self.layout.addWidget(QHLine()) + # Remove the final separator line + self.layout.removeRow(self.layout.rowCount() - 1) + if filename and extended_data: + with open(filename, 'w', encoding='utf-8', 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() + if description := self.input_description.text(): + filename = os.path.join("/tmp/", f"{description}.csv") + else: + filename = None + crossing = self._get_crossing() + data = [d.impedance().real for d in self.app.data.s11] + 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 _ in range(results_header, self.layout.rowCount()): + self.layout.removeRow(self.layout.rowCount() - 1) + extended_data = {} + both = [] + tolerance = 2 + for i, (low, _, high) in itertools.product(maximums, crossing): + if low - tolerance <= i <= high + tolerance: + both.append(i) + continue + if low > 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].setFrequency( + str(self.app.data.s11[both[i]].freq)) + self.app.markers[i].frequencyInput.setText( + str(self.app.data.s11[both[i]].freq)) + + else: + logger.info("TO DO: find near data") + for _, lowest, _ in crossing: + 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 + fields = [("freq", format_frequency_short), + ("r", format_resistence_neg), ("lambda", lambda x: round(x, 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())): + s11_idx = self.app.data.s11[index] + self.layout.addRow( + f"{format_frequency_short(s11_idx.freq)}", + QtWidgets.QLabel( + f" ({diff[i]['freq']})" + f" {format_complex_imp(s11_idx.impedance())}" + f" ({diff[i]['r']}) {diff[i]['lambda']} m")) + + 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=None): + """ + Compare data to help changes + + NB + must be same sweep + ( same index must be same frequence ) + :param old: + :param new: + """ + fields = fields or [("freq", str), ] + + 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/Analysis/SimplePeakSearchAnalysis.py b/NanoVNASaver/Analysis/SimplePeakSearchAnalysis.py index 1f3f1d1..ae75cec 100644 --- a/NanoVNASaver/Analysis/SimplePeakSearchAnalysis.py +++ b/NanoVNASaver/Analysis/SimplePeakSearchAnalysis.py @@ -21,7 +21,7 @@ import logging from PyQt5 import QtWidgets import numpy as np -from NanoVNASaver.Analysis import Analysis, PeakSearchAnalysis +from NanoVNASaver.Analysis.Base import Analysis, QHLine from NanoVNASaver.Formatting import format_frequency @@ -62,12 +62,12 @@ class SimplePeakSearchAnalysis(Analysis): 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(QHLine()) outer_layout.addRow("Peak type", self.rbtn_peak_positive) outer_layout.addRow("", self.rbtn_peak_negative) - outer_layout.addRow(PeakSearchAnalysis.QHLine()) + outer_layout.addRow(QHLine()) outer_layout.addRow("Move marker to peak", self.checkbox_move_marker) - outer_layout.addRow(PeakSearchAnalysis.QHLine()) + outer_layout.addRow(QHLine()) outer_layout.addRow(QtWidgets.QLabel("Results")) @@ -78,26 +78,23 @@ class SimplePeakSearchAnalysis(Analysis): outer_layout.addRow("Peak value:", self.peak_value) def runAnalysis(self): + if not self.app.data.s11: + return + s11 = self.app.data.s11 + s21 = self.app.data.s21 + if self.rbtn_data_vswr.isChecked(): suffix = "" - data = [] - for d in self.app.data.s11: - data.append(d.vswr) + data = [d.vswr for d in s11] elif self.rbtn_data_resistance.isChecked(): suffix = " \N{OHM SIGN}" - data = [] - for d in self.app.data.s11: - data.append(d.impedance().real) + data = [d.impedance().real for d in s11] elif self.rbtn_data_reactance.isChecked(): suffix = " \N{OHM SIGN}" - data = [] - for d in self.app.data.s11: - data.append(d.impedance().imag) + data = [d.impedance().imag for d in s11] elif self.rbtn_data_s21_gain.isChecked(): suffix = " dB" - data = [] - for d in self.app.data.s21: - data.append(d.gain) + data = [d.gain for d in s21] else: logger.warning("Searching for peaks on unknown data") return diff --git a/NanoVNASaver/Analysis/VSWRAnalysis.py b/NanoVNASaver/Analysis/VSWRAnalysis.py index 4e802f7..9939fce 100644 --- a/NanoVNASaver/Analysis/VSWRAnalysis.py +++ b/NanoVNASaver/Analysis/VSWRAnalysis.py @@ -16,40 +16,22 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import os -import csv -import itertools import logging +from typing import List -import numpy as np from PyQt5 import QtWidgets -from NanoVNASaver.Analysis import Analysis, PeakSearchAnalysis -from NanoVNASaver.Formatting import ( - format_frequency, format_complex_imp, - format_frequency_short, format_resistance) -from NanoVNASaver.RFTools import reflection_coefficient +import NanoVNASaver.AnalyticTools as at +from NanoVNASaver.Analysis.Base import Analysis, QHLine +from NanoVNASaver.Formatting import format_frequency, format_vswr 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__() - self.setFrameShape(QtWidgets.QFrame.HLine) - def __init__(self, app): super().__init__(app) @@ -58,7 +40,7 @@ class VSWRAnalysis(Analysis): self._widget.setLayout(self.layout) self.input_vswr_limit = QtWidgets.QDoubleSpinBox() - self.input_vswr_limit.setValue(self.vswr_limit_value) + self.input_vswr_limit.setValue(VSWRAnalysis.vswr_limit_value) self.input_vswr_limit.setSingleStep(0.1) self.input_vswr_limit.setMinimum(1) self.input_vswr_limit.setMaximum(25) @@ -67,140 +49,24 @@ class VSWRAnalysis(Analysis): self.checkbox_move_marker = QtWidgets.QCheckBox() self.layout.addRow(QtWidgets.QLabel("Settings")) self.layout.addRow("VSWR limit", self.input_vswr_limit) - self.layout.addRow(VSWRAnalysis.QHLine()) + self.layout.addRow(QHLine()) self.results_label = QtWidgets.QLabel("Results") self.layout.addRow(self.results_label) + self.minimums: List[int] = [] + def runAnalysis(self): - max_dips_shown = self.max_dips_shown - data = [d.vswr for d in self.app.data.s11] + if not self.app.data.s11: + return + s11 = self.app.data.s11 + + data = [d.vswr for d in s11] threshold = self.input_vswr_limit.value() - 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()) - for _ in range(results_header, self.layout.rowCount()): - self.layout.removeRow(self.layout.rowCount() - 1) - if len(minimums) > max_dips_shown: - self.layout.addRow( - QtWidgets.QLabel( - f"More than {str(max_dips_shown)} dips found." - " Lowest shown.")) - - dips = [] - for m in minimums: - start, lowest, end = m - dips.append(data[lowest]) - best_dips = [] - for _ in range(max_dips_shown): - min_idx = np.argmin(dips) - best_dips.append(minimums[min_idx]) - dips.remove(dips[min_idx]) - minimums.remove(minimums[min_idx]) - minimums = best_dips - self.minimums = minimums - if len(minimums) > 0: - for m in minimums: - start, lowest, end = m - if start != end: - logger.debug( - "Section from %d to %d, lowest at %d", - start, end, lowest) - self.layout.addRow("Start", QtWidgets.QLabel( - format_frequency(self.app.data.s11[start].freq))) - - self.layout.addRow("Minimum", QtWidgets.QLabel( - f"{format_frequency(self.app.data.s11[lowest].freq)}" - f" ({round(data[lowest], 2)})")) - - self.layout.addRow("End", QtWidgets.QLabel( - format_frequency(self.app.data.s11[end].freq))) - - self.layout.addRow( - "Span", QtWidgets.QLabel(format_frequency( - (self.app.data.s11[end].freq - - self.app.data.s11[start].freq)))) - - else: - self.layout.addRow("Low spot", QtWidgets.QLabel( - format_frequency(self.app.data.s11[lowest].freq))) - - self.layout.addWidget(PeakSearchAnalysis.QHLine()) - self.layout.removeRow(self.layout.rowCount() - 1) - else: - self.layout.addRow( - QtWidgets.QLabel( - f"No areas found with VSWR below {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) - return 1 if mag == 1 else (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.data.s11[index].freq, - "s11": self.app.data.s11[index].z, - "lambda": self.app.data.s11[index].wavelength, - "impedance": self.app.data.s11[index].impedance(), - "vswr": self.app.data.s11[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 = [d.phase for d in self.app.data.s11] - return sorted(self.find_crossing_zero(data)) - - def runAnalysis(self): - self.reset() - # self.results_label = QtWidgets.QLabel("Results") - # max_dips_shown = self.max_dips_shown - filename = ( - os.path.join("/tmp/", f"{self.input_description.text()}.csv") - if self.input_description.text() - else None) - - crossing = self._get_crossing() - - logger.debug("Found %d sections ", - len(crossing)) + minima = sorted(at.minima(data, threshold), + key=lambda i: data[i])[:VSWRAnalysis.max_dips_shown] + self.minimums = minima results_header = self.layout.indexOf(self.results_label) logger.debug("Results start at %d, out of %d", @@ -208,223 +74,24 @@ class ResonanceAnalysis(Analysis): for _ 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] - if crossing: - extended_data = [] - for m in crossing: - start, lowest, end = m - my_data = self._get_data(lowest) - s11_low = self.app.data.s11[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(s11_low.freq)}" - f" ({format_complex_imp(s11_low.impedance())})")) - else: - self.layout.addRow("Resonance", QtWidgets.QLabel( - format_frequency(self.app.data.s11[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: + if not minima: self.layout.addRow(QtWidgets.QLabel( - "No resonance found")) + f"No areas found with VSWR below {format_vswr(threshold)}.")) + return - -class EFHWAnalysis(ResonanceAnalysis): - """ - find only resonance when HI impedance - """ - old_data = [] - - def reset(self): - logger.debug("reset") - - def runAnalysis(self): - self.reset() - if description := self.input_description.text(): - filename = os.path.join("/tmp/", f"{description}.csv") - else: - filename = None - crossing = self._get_crossing() - data = [d.impedance().real for d in self.app.data.s11] - 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 _ in range(results_header, self.layout.rowCount()): - self.layout.removeRow(self.layout.rowCount() - 1) - extended_data = {} - both = [] - tolerance = 2 - for i, (low, _, high) in itertools.product(maximums, crossing): - if low - tolerance <= i <= high + tolerance: - both.append(i) - continue - if low > 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].setFrequency( - str(self.app.data.s11[both[i]].freq)) - self.app.markers[i].frequencyInput.setText( - str(self.app.data.s11[both[i]].freq)) - - else: - logger.info("TO DO: find near data") - for _, lowest, _ in crossing: - 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 - 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())): - s11_idx = self.app.data.s11[index] + for idx in minima: + rng = at.take_from_idx(data, idx, lambda i: i[1] < threshold) + begin, end = rng[0], rng[-1] + self.layout.addRow("Start", QtWidgets.QLabel( + format_frequency(s11[begin].freq))) + self.layout.addRow("Minimum", QtWidgets.QLabel( + f"{format_frequency(s11[idx].freq)}" + f" ({round(s11[idx].vswr, 2)})")) + self.layout.addRow("End", QtWidgets.QLabel( + format_frequency(s11[end].freq))) self.layout.addRow( - f"{format_frequency_short(s11_idx.freq)}", - QtWidgets.QLabel( - f" ({diff[i]['freq']})" - f" {format_complex_imp(s11_idx.impedance())}" - f" ({diff[i]['r']}) {diff[i]['lambda']} m")) + "Span", QtWidgets.QLabel(format_frequency( + (s11[end].freq - s11[begin].freq)))) + self.layout.addWidget(QHLine()) - 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=None): - """ - Compare data to help changes - - NB - must be same sweep - ( same index must be same frequence ) - :param old: - :param new: - """ - fields = fields or [("freq", str), ] - - 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 + self.layout.removeRow(self.layout.rowCount() - 1) diff --git a/NanoVNASaver/Analysis/__init__.py b/NanoVNASaver/Analysis/__init__.py index c463400..e69de29 100644 --- a/NanoVNASaver/Analysis/__init__.py +++ b/NanoVNASaver/Analysis/__init__.py @@ -1,9 +0,0 @@ -from .Analysis import Analysis -from .BandPassAnalysis import BandPassAnalysis -from .BandStopAnalysis import BandStopAnalysis -from .HighPassAnalysis import HighPassAnalysis -from .LowPassAnalysis import LowPassAnalysis -from .PeakSearchAnalysis import PeakSearchAnalysis -from .SimplePeakSearchAnalysis import SimplePeakSearchAnalysis -from .VSWRAnalysis import VSWRAnalysis -from .AntennaAnalysis import MagLoopAnalysis diff --git a/NanoVNASaver/AnalyticTools.py b/NanoVNASaver/AnalyticTools.py new file mode 100644 index 0000000..8fb2f95 --- /dev/null +++ b/NanoVNASaver/AnalyticTools.py @@ -0,0 +1,141 @@ +# 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 . +from typing import Callable, List, Tuple +import itertools as it + +import numpy as np +import scipy + +from NanoVNASaver.RFTools import Datapoint + + +def zero_crossings(data: List[float]) -> List[int]: + """find zero crossings + + Args: + data (List[float]): data list execute + + Returns: + List[int]: sorted indices of zero crossing points + """ + if not data: + return [] + + np_data = np.array(data) + + # start with real zeros (ignore first and last element) + real_zeros = [n for n in np.where(np_data == 0.0)[0] if + n not in {0, np_data.size - 1}] + # now multipy elements to find change in signess + crossings = [ + n if abs(np_data[n]) < abs(np_data[n + 1]) else n + 1 + for n in np.where((np_data[:-1] * np_data[1:]) < 0.0)[0] + ] + return sorted(real_zeros + crossings) + + +def maxima(data: List[float], threshold: float = 0.0) -> List[int]: + """maxima + + Args: + data (List[float]): data list to execute + + Returns: + List[int]: indices of maxima + """ + peaks, _ = scipy.signal.find_peaks( + data, width=2, distance=3, prominence=1) + return [ + i for i in peaks if data[i] > threshold + ] if threshold else peaks + + +def minima(data: List[float], threshold: float = 0.0) -> List[int]: + """minima + + Args: + data (List[float]): data list to execute + + Returns: + List[int]: indices of minima + """ + bottoms, _ = scipy.signal.find_peaks( + -np.array(data), width=2, distance=3, prominence=1) + return [ + i for i in bottoms if data[i] < threshold + ] if threshold else bottoms + + +def take_from_idx(data: List[float], + idx: int, + predicate: Callable) -> List[int]: + """take_from_center + + Args: + data (List[float]): data list to execute + idx (int): index of a start position + predicate (Callable): predicate on which elements to take + from center. (e.g. lambda i: i[1] < threshold) + + Returns: + List[int]: indices of element matching predicate left + and right from index + """ + lower = list(reversed( + [i for i, _ in + it.takewhile(predicate, + reversed(list(enumerate(data[:idx]))))])) + upper = [i for i, _ in + it.takewhile(predicate, + enumerate(data[idx:], idx))] + return lower + upper + + +def center_from_idx(gains: List[float], + idx: int, delta: float = 3.0) -> int: + """find maximum from index postion of gains in a attn dB gain span + + Args: + gains (List[float]): gain values + idx (int): start position to search from + delta (float=3.0): max gain delta from start + + Returns: + int: position of highest gain from start in range (-1 if no data) + """ + peak_db = gains[idx] + rng = take_from_idx(gains, idx, + lambda i: abs(peak_db - i[1]) < delta) + return max(rng, key=lambda i: gains[i]) if rng else -1 + + +def cut_off_left(gains: List[float], idx: int, + peak_gain: float, attn: float = 3.0) -> int: + return next( + (i for i in range(idx, -1, -1) if + (peak_gain - gains[i]) > attn), + -1) + + +def cut_off_right(gains: List[float], idx: int, + peak_gain: float, attn: float = 3.0) -> int: + return next( + (i for i in range(idx, len(gains)) if + (peak_gain - gains[i]) > attn), + -1) diff --git a/NanoVNASaver/SITools.py b/NanoVNASaver/SITools.py index 28d81c1..4214f7e 100644 --- a/NanoVNASaver/SITools.py +++ b/NanoVNASaver/SITools.py @@ -82,6 +82,8 @@ class Value: def __str__(self) -> str: fmt = self.fmt + if math.isnan(self._value): + return (f"NaN{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/NanoVNASaver/Windows/AnalysisWindow.py b/NanoVNASaver/Windows/AnalysisWindow.py index fd31bc8..a88ec1d 100644 --- a/NanoVNASaver/Windows/AnalysisWindow.py +++ b/NanoVNASaver/Windows/AnalysisWindow.py @@ -20,13 +20,16 @@ import logging from PyQt5 import QtWidgets, QtCore -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 +from NanoVNASaver.Analysis.Base import Analysis +from NanoVNASaver.Analysis.AntennaAnalysis import MagLoopAnalysis +from NanoVNASaver.Analysis.ResonanceAnalysis import EFHWAnalysis, ResonanceAnalysis +from NanoVNASaver.Analysis.LowPassAnalysis import LowPassAnalysis +from NanoVNASaver.Analysis.HighPassAnalysis import HighPassAnalysis +from NanoVNASaver.Analysis.BandPassAnalysis import BandPassAnalysis +from NanoVNASaver.Analysis.BandStopAnalysis import BandStopAnalysis +from NanoVNASaver.Analysis.SimplePeakSearchAnalysis import SimplePeakSearchAnalysis +from NanoVNASaver.Analysis.VSWRAnalysis import VSWRAnalysis +from NanoVNASaver.Analysis.PeakSearchAnalysis import PeakSearchAnalysis logger = logging.getLogger(__name__) diff --git a/README.md b/README.md index a5328fd..62f48ce 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ points, and generally display and analyze the resulting data. Latest Changes -------------- +### Changes in 0.5.4-pre + ### Changes in 0.5.3 - Python 3.10 compatability fixes