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