1#  NanoVNASaver
2#
3#  A python program to view and export Touchstone data from a NanoVNA
4#  Copyright (C) 2019, 2020  Rune B. Broberg
5#  Copyright (C) 2020 NanoVNA-Saver Authors
6#
7#  This program is free software: you can redistribute it and/or modify
8#  it under the terms of the GNU General Public License as published by
9#  the Free Software Foundation, either version 3 of the License, or
10#  (at your option) any later version.
11#
12#  This program is distributed in the hope that it will be useful,
13#  but WITHOUT ANY WARRANTY; without even the implied warranty of
14#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15#  GNU General Public License for more details.
16#
17#  You should have received a copy of the GNU General Public License
18#  along with this program.  If not, see <https://www.gnu.org/licenses/>.
19import logging
20import math
21
22from PyQt5 import QtWidgets
23
24from NanoVNASaver.Formatting import format_frequency
25
26from NanoVNASaver.Analysis import Analysis
27
28logger = logging.getLogger(__name__)
29
30
31class BandPassAnalysis(Analysis):
32    def __init__(self, app):
33        super().__init__(app)
34
35        self._widget = QtWidgets.QWidget()
36
37        layout = QtWidgets.QFormLayout()
38        self._widget.setLayout(layout)
39        layout.addRow(QtWidgets.QLabel("Band pass filter analysis"))
40        layout.addRow(
41            QtWidgets.QLabel(
42                f"Please place {self.app.markers[0].name} in the filter passband."))
43        self.result_label = QtWidgets.QLabel()
44        self.lower_cutoff_label = QtWidgets.QLabel()
45        self.lower_six_db_label = QtWidgets.QLabel()
46        self.lower_sixty_db_label = QtWidgets.QLabel()
47        self.lower_db_per_octave_label = QtWidgets.QLabel()
48        self.lower_db_per_decade_label = QtWidgets.QLabel()
49
50        self.upper_cutoff_label = QtWidgets.QLabel()
51        self.upper_six_db_label = QtWidgets.QLabel()
52        self.upper_sixty_db_label = QtWidgets.QLabel()
53        self.upper_db_per_octave_label = QtWidgets.QLabel()
54        self.upper_db_per_decade_label = QtWidgets.QLabel()
55        layout.addRow("Result:", self.result_label)
56
57        layout.addRow(QtWidgets.QLabel(""))
58
59        self.center_frequency_label = QtWidgets.QLabel()
60        self.span_label = QtWidgets.QLabel()
61        self.six_db_span_label = QtWidgets.QLabel()
62        self.quality_label = QtWidgets.QLabel()
63
64        layout.addRow("Center frequency:", self.center_frequency_label)
65        layout.addRow("Bandwidth (-3 dB):", self.span_label)
66        layout.addRow("Quality factor:", self.quality_label)
67        layout.addRow("Bandwidth (-6 dB):", self.six_db_span_label)
68
69        layout.addRow(QtWidgets.QLabel(""))
70
71        layout.addRow(QtWidgets.QLabel("Lower side:"))
72        layout.addRow("Cutoff frequency:", self.lower_cutoff_label)
73        layout.addRow("-6 dB point:", self.lower_six_db_label)
74        layout.addRow("-60 dB point:", self.lower_sixty_db_label)
75        layout.addRow("Roll-off:", self.lower_db_per_octave_label)
76        layout.addRow("Roll-off:", self.lower_db_per_decade_label)
77
78        layout.addRow(QtWidgets.QLabel(""))
79
80        layout.addRow(QtWidgets.QLabel("Upper side:"))
81        layout.addRow("Cutoff frequency:", self.upper_cutoff_label)
82        layout.addRow("-6 dB point:", self.upper_six_db_label)
83        layout.addRow("-60 dB point:", self.upper_sixty_db_label)
84        layout.addRow("Roll-off:", self.upper_db_per_octave_label)
85        layout.addRow("Roll-off:", self.upper_db_per_decade_label)
86
87    def reset(self):
88        self.result_label.clear()
89        self.center_frequency_label.clear()
90        self.span_label.clear()
91        self.quality_label.clear()
92        self.six_db_span_label.clear()
93
94        self.upper_cutoff_label.clear()
95        self.upper_six_db_label.clear()
96        self.upper_sixty_db_label.clear()
97        self.upper_db_per_octave_label.clear()
98        self.upper_db_per_decade_label.clear()
99
100        self.lower_cutoff_label.clear()
101        self.lower_six_db_label.clear()
102        self.lower_sixty_db_label.clear()
103        self.lower_db_per_octave_label.clear()
104        self.lower_db_per_decade_label.clear()
105
106    def runAnalysis(self):
107        self.reset()
108        pass_band_location = self.app.markers[0].location
109        logger.debug("Pass band location: %d", pass_band_location)
110
111        if len(self.app.data21) == 0:
112            logger.debug("No data to analyse")
113            self.result_label.setText("No data to analyse.")
114            return
115
116        if pass_band_location < 0:
117            logger.debug("No location for %s", self.app.markers[0].name)
118            self.result_label.setText(
119                f"Please place {self.app.markers[0].name} in the passband.")
120            return
121
122        pass_band_db = self.app.data21[pass_band_location].gain
123
124        logger.debug("Initial passband gain: %d", pass_band_db)
125
126        initial_lower_cutoff_location = -1
127        for i in range(pass_band_location, -1, -1):
128            if (pass_band_db - self.app.data21[i].gain) > 3:
129                # We found a cutoff location
130                initial_lower_cutoff_location = i
131                break
132
133        if initial_lower_cutoff_location < 0:
134            self.result_label.setText("Lower cutoff location not found.")
135            return
136
137        initial_lower_cutoff_frequency = self.app.data21[initial_lower_cutoff_location].freq
138
139        logger.debug("Found initial lower cutoff frequency at %d", initial_lower_cutoff_frequency)
140
141        initial_upper_cutoff_location = -1
142        for i in range(pass_band_location, len(self.app.data21), 1):
143            if (pass_band_db - self.app.data21[i].gain) > 3:
144                # We found a cutoff location
145                initial_upper_cutoff_location = i
146                break
147
148        if initial_upper_cutoff_location < 0:
149            self.result_label.setText("Upper cutoff location not found.")
150            return
151
152        initial_upper_cutoff_frequency = self.app.data21[initial_upper_cutoff_location].freq
153
154        logger.debug("Found initial upper cutoff frequency at %d", initial_upper_cutoff_frequency)
155
156        peak_location = -1
157        peak_db = self.app.data21[initial_lower_cutoff_location].gain
158        for i in range(initial_lower_cutoff_location, initial_upper_cutoff_location, 1):
159            db = self.app.data21[i].gain
160            if db > peak_db:
161                peak_db = db
162                peak_location = i
163
164        logger.debug("Found peak of %f at %d", peak_db, self.app.data11[peak_location].freq)
165
166        lower_cutoff_location = -1
167        pass_band_db = peak_db
168        for i in range(peak_location, -1, -1):
169            if (pass_band_db - self.app.data21[i].gain) > 3:
170                # We found the cutoff location
171                lower_cutoff_location = i
172                break
173
174        lower_cutoff_frequency = self.app.data21[lower_cutoff_location].freq
175        lower_cutoff_gain = self.app.data21[lower_cutoff_location].gain - pass_band_db
176
177        if lower_cutoff_gain < -4:
178            logger.debug("Lower cutoff frequency found at %f dB"
179                         " - insufficient data points for true -3 dB point.",
180                         lower_cutoff_gain)
181        logger.debug("Found true lower cutoff frequency at %d", lower_cutoff_frequency)
182
183        self.lower_cutoff_label.setText(
184            f"{format_frequency(lower_cutoff_frequency)}"
185            f" ({round(lower_cutoff_gain, 1)} dB)")
186
187        self.app.markers[1].setFrequency(str(lower_cutoff_frequency))
188        self.app.markers[1].frequencyInput.setText(str(lower_cutoff_frequency))
189
190        upper_cutoff_location = -1
191        pass_band_db = peak_db
192        for i in range(peak_location, len(self.app.data21), 1):
193            if (pass_band_db - self.app.data21[i].gain) > 3:
194                # We found the cutoff location
195                upper_cutoff_location = i
196                break
197
198        upper_cutoff_frequency = self.app.data21[upper_cutoff_location].freq
199        upper_cutoff_gain = self.app.data21[upper_cutoff_location].gain - pass_band_db
200        if upper_cutoff_gain < -4:
201            logger.debug("Upper cutoff frequency found at %f dB"
202                         " - insufficient data points for true -3 dB point.",
203                         upper_cutoff_gain)
204
205        logger.debug("Found true upper cutoff frequency at %d", upper_cutoff_frequency)
206
207        self.upper_cutoff_label.setText(
208            f"{format_frequency(upper_cutoff_frequency)}"
209            f" ({round(upper_cutoff_gain, 1)} dB)")
210        self.app.markers[2].setFrequency(str(upper_cutoff_frequency))
211        self.app.markers[2].frequencyInput.setText(str(upper_cutoff_frequency))
212
213        span = upper_cutoff_frequency - lower_cutoff_frequency
214        center_frequency = math.sqrt(
215            lower_cutoff_frequency * upper_cutoff_frequency)
216        q = center_frequency / span
217
218        self.span_label.setText(format_frequency(span))
219        self.center_frequency_label.setText(
220            format_frequency(center_frequency))
221        self.quality_label.setText(str(round(q, 2)))
222
223        self.app.markers[0].setFrequency(str(round(center_frequency)))
224        self.app.markers[0].frequencyInput.setText(str(round(center_frequency)))
225
226        # Lower roll-off
227
228        lower_six_db_location = -1
229        for i in range(lower_cutoff_location, -1, -1):
230            if (pass_band_db - self.app.data21[i].gain) > 6:
231                # We found 6dB location
232                lower_six_db_location = i
233                break
234
235        if lower_six_db_location < 0:
236            self.result_label.setText("Lower 6 dB location not found.")
237            return
238        lower_six_db_cutoff_frequency = self.app.data21[lower_six_db_location].freq
239        self.lower_six_db_label.setText(
240            format_frequency(lower_six_db_cutoff_frequency))
241
242        ten_db_location = -1
243        for i in range(lower_cutoff_location, -1, -1):
244            if (pass_band_db - self.app.data21[i].gain) > 10:
245                # We found 6dB location
246                ten_db_location = i
247                break
248
249        twenty_db_location = -1
250        for i in range(lower_cutoff_location, -1, -1):
251            if (pass_band_db - self.app.data21[i].gain) > 20:
252                # We found 6dB location
253                twenty_db_location = i
254                break
255
256        sixty_db_location = -1
257        for i in range(lower_six_db_location, -1, -1):
258            if (pass_band_db - self.app.data21[i].gain) > 60:
259                # We found 60dB location! Wow.
260                sixty_db_location = i
261                break
262
263        if sixty_db_location > 0:
264            if sixty_db_location > 0:
265                sixty_db_cutoff_frequency = self.app.data21[sixty_db_location].freq
266                self.lower_sixty_db_label.setText(
267                    format_frequency(sixty_db_cutoff_frequency))
268            elif ten_db_location != -1 and twenty_db_location != -1:
269                ten = self.app.data21[ten_db_location].freq
270                twenty = self.app.data21[twenty_db_location].freq
271                sixty_db_frequency = ten * \
272                    10 ** (5 * (math.log10(twenty) - math.log10(ten)))
273                self.lower_sixty_db_label.setText(
274                    f"{format_frequency(sixty_db_frequency)} (derived)")
275            else:
276                self.lower_sixty_db_label.setText("Not calculated")
277
278        if ten_db_location > 0 and twenty_db_location > 0 and ten_db_location != twenty_db_location:
279            octave_attenuation, decade_attenuation = self.calculateRolloff(
280                ten_db_location, twenty_db_location)
281            self.lower_db_per_octave_label.setText(
282                str(round(octave_attenuation, 3)) + " dB / octave")
283            self.lower_db_per_decade_label.setText(
284                str(round(decade_attenuation, 3)) + " dB / decade")
285        else:
286            self.lower_db_per_octave_label.setText("Not calculated")
287            self.lower_db_per_decade_label.setText("Not calculated")
288
289        # Upper roll-off
290
291        upper_six_db_location = -1
292        for i in range(upper_cutoff_location, len(self.app.data21), 1):
293            if (pass_band_db - self.app.data21[i].gain) > 6:
294                # We found 6dB location
295                upper_six_db_location = i
296                break
297
298        if upper_six_db_location < 0:
299            self.result_label.setText("Upper 6 dB location not found.")
300            return
301        upper_six_db_cutoff_frequency = self.app.data21[upper_six_db_location].freq
302        self.upper_six_db_label.setText(
303            format_frequency(upper_six_db_cutoff_frequency))
304
305        six_db_span = upper_six_db_cutoff_frequency - lower_six_db_cutoff_frequency
306
307        self.six_db_span_label.setText(
308            format_frequency(six_db_span))
309
310        ten_db_location = -1
311        for i in range(upper_cutoff_location, len(self.app.data21), 1):
312            if (pass_band_db - self.app.data21[i].gain) > 10:
313                # We found 6dB location
314                ten_db_location = i
315                break
316
317        twenty_db_location = -1
318        for i in range(upper_cutoff_location, len(self.app.data21), 1):
319            if (pass_band_db - self.app.data21[i].gain) > 20:
320                # We found 6dB location
321                twenty_db_location = i
322                break
323
324        sixty_db_location = -1
325        for i in range(upper_six_db_location, len(self.app.data21), 1):
326            if (pass_band_db - self.app.data21[i].gain) > 60:
327                # We found 60dB location! Wow.
328                sixty_db_location = i
329                break
330
331        if sixty_db_location > 0:
332            sixty_db_cutoff_frequency = self.app.data21[sixty_db_location].freq
333            self.upper_sixty_db_label.setText(
334                format_frequency(sixty_db_cutoff_frequency))
335        elif ten_db_location != -1 and twenty_db_location != -1:
336            ten = self.app.data21[ten_db_location].freq
337            twenty = self.app.data21[twenty_db_location].freq
338            sixty_db_frequency = ten * \
339                10 ** (5 * (math.log10(twenty) - math.log10(ten)))
340            self.upper_sixty_db_label.setText(
341                f"{format_frequency(sixty_db_frequency)} (derived)")
342        else:
343            self.upper_sixty_db_label.setText("Not calculated")
344
345        if ten_db_location > 0 and twenty_db_location > 0 and ten_db_location != twenty_db_location:
346            octave_attenuation, decade_attenuation = self.calculateRolloff(
347                ten_db_location, twenty_db_location)
348            self.upper_db_per_octave_label.setText(
349                f"{round(octave_attenuation, 3)} dB / octave")
350            self.upper_db_per_decade_label.setText(
351                f"{round(decade_attenuation, 3)} dB / decade")
352        else:
353            self.upper_db_per_octave_label.setText("Not calculated")
354            self.upper_db_per_decade_label.setText("Not calculated")
355
356        if upper_cutoff_gain < -4 or lower_cutoff_gain < -4:
357            self.result_label.setText(
358                f"Analysis complete ({len(self.app.data11)} points)\n"
359                f"Insufficient data for analysis. Increase segment count.")
360        else:
361            self.result_label.setText(
362                f"Analysis complete ({len(self.app.data11)} points)")
363