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 math
20import logging
21
22import numpy as np
23from PyQt5 import QtWidgets, QtGui, QtCore
24
25from .Chart import Chart
26
27logger = logging.getLogger(__name__)
28
29
30class TDRChart(Chart):
31    maxDisplayLength = 50
32    minDisplayLength = 0
33    fixedSpan = False
34
35    minImpedance = 0
36    maxImpedance = 1000
37    fixedValues = False
38
39    markerLocation = -1
40
41    def __init__(self, name):
42        super().__init__(name)
43        self.tdrWindow = None
44        self.leftMargin = 30
45        self.rightMargin = 20
46        self.bottomMargin = 25
47        self.topMargin = 20
48        self.setMinimumSize(300, 300)
49        self.setSizePolicy(
50            QtWidgets.QSizePolicy(
51                QtWidgets.QSizePolicy.MinimumExpanding,
52                QtWidgets.QSizePolicy.MinimumExpanding))
53        pal = QtGui.QPalette()
54        pal.setColor(QtGui.QPalette.Background, self.backgroundColor)
55        self.setPalette(pal)
56        self.setAutoFillBackground(True)
57
58        self.setContextMenuPolicy(QtCore.Qt.DefaultContextMenu)
59        self.menu = QtWidgets.QMenu()
60
61        self.reset = QtWidgets.QAction("Reset")
62        self.reset.triggered.connect(self.resetDisplayLimits)
63        self.menu.addAction(self.reset)
64
65        self.x_menu = QtWidgets.QMenu("Length axis")
66        self.mode_group = QtWidgets.QActionGroup(self.x_menu)
67        self.action_automatic = QtWidgets.QAction("Automatic")
68        self.action_automatic.setCheckable(True)
69        self.action_automatic.setChecked(True)
70        self.action_automatic.changed.connect(
71            lambda: self.setFixedSpan(self.action_fixed_span.isChecked()))
72        self.action_fixed_span = QtWidgets.QAction("Fixed span")
73        self.action_fixed_span.setCheckable(True)
74        self.action_fixed_span.changed.connect(
75            lambda: self.setFixedSpan(self.action_fixed_span.isChecked()))
76        self.mode_group.addAction(self.action_automatic)
77        self.mode_group.addAction(self.action_fixed_span)
78        self.x_menu.addAction(self.action_automatic)
79        self.x_menu.addAction(self.action_fixed_span)
80        self.x_menu.addSeparator()
81
82        self.action_set_fixed_start = QtWidgets.QAction(
83            f"Start ({self.minDisplayLength})")
84        self.action_set_fixed_start.triggered.connect(self.setMinimumLength)
85
86        self.action_set_fixed_stop = QtWidgets.QAction(
87            f"Stop ({self.maxDisplayLength})")
88        self.action_set_fixed_stop.triggered.connect(self.setMaximumLength)
89
90        self.x_menu.addAction(self.action_set_fixed_start)
91        self.x_menu.addAction(self.action_set_fixed_stop)
92
93        self.y_menu = QtWidgets.QMenu("Impedance axis")
94        self.y_mode_group = QtWidgets.QActionGroup(self.y_menu)
95        self.y_action_automatic = QtWidgets.QAction("Automatic")
96        self.y_action_automatic.setCheckable(True)
97        self.y_action_automatic.setChecked(True)
98        self.y_action_automatic.changed.connect(
99            lambda: self.setFixedValues(self.y_action_fixed.isChecked()))
100        self.y_action_fixed = QtWidgets.QAction("Fixed")
101        self.y_action_fixed.setCheckable(True)
102        self.y_action_fixed.changed.connect(
103            lambda: self.setFixedValues(self.y_action_fixed.isChecked()))
104        self.y_mode_group.addAction(self.y_action_automatic)
105        self.y_mode_group.addAction(self.y_action_fixed)
106        self.y_menu.addAction(self.y_action_automatic)
107        self.y_menu.addAction(self.y_action_fixed)
108        self.y_menu.addSeparator()
109
110        self.y_action_set_fixed_maximum = QtWidgets.QAction(
111            f"Maximum ({self.maxImpedance})")
112        self.y_action_set_fixed_maximum.triggered.connect(self.setMaximumImpedance)
113
114        self.y_action_set_fixed_minimum = QtWidgets.QAction(
115            f"Minimum ({self.minImpedance})")
116        self.y_action_set_fixed_minimum.triggered.connect(self.setMinimumImpedance)
117
118        self.y_menu.addAction(self.y_action_set_fixed_maximum)
119        self.y_menu.addAction(self.y_action_set_fixed_minimum)
120
121        self.menu.addMenu(self.x_menu)
122        self.menu.addMenu(self.y_menu)
123        self.menu.addSeparator()
124        self.menu.addAction(self.action_save_screenshot)
125        self.action_popout = QtWidgets.QAction("Popout chart")
126        self.action_popout.triggered.connect(
127            lambda: self.popoutRequested.emit(self))
128        self.menu.addAction(self.action_popout)
129
130        self.chartWidth = self.width() - self.leftMargin - self.rightMargin
131        self.chartHeight = self.height() - self.bottomMargin - self.topMargin
132
133    def contextMenuEvent(self, event):
134        self.action_set_fixed_start.setText(
135            f"Start ({self.minDisplayLength})")
136        self.action_set_fixed_stop.setText(
137            f"Stop ({self.maxDisplayLength})")
138        self.y_action_set_fixed_minimum.setText(
139            f"Minimum ({self.minImpedance})")
140        self.y_action_set_fixed_maximum.setText(
141            f"Maximum ({self.maxImpedance})")
142        self.menu.exec_(event.globalPos())
143
144    def isPlotable(self, x, y):
145        return self.leftMargin <= x <= self.width() - self.rightMargin and \
146               self.topMargin <= y <= self.height() - self.bottomMargin
147
148    def resetDisplayLimits(self):
149        self.fixedSpan = False
150        self.minDisplayLength = 0
151        self.maxDisplayLength = 100
152        self.fixedValues = False
153        self.minImpedance = 0
154        self.maxImpedance = 1000
155        self.update()
156
157    def setFixedSpan(self, fixed_span):
158        self.fixedSpan = fixed_span
159        self.update()
160
161    def setMinimumLength(self):
162        min_val, selected = QtWidgets.QInputDialog.getDouble(
163            self, "Start length (m)",
164            "Set start length (m)", value=self.minDisplayLength,
165            min=0, decimals=1)
166        if not selected:
167            return
168        if not (self.fixedSpan and min_val >= self.maxDisplayLength):
169            self.minDisplayLength = min_val
170        if self.fixedSpan:
171            self.update()
172
173    def setMaximumLength(self):
174        max_val, selected = QtWidgets.QInputDialog.getDouble(
175            self, "Stop length (m)",
176            "Set stop length (m)", value=self.minDisplayLength,
177            min=0.1, decimals=1)
178        if not selected:
179            return
180        if not (self.fixedSpan and max_val <= self.minDisplayLength):
181            self.maxDisplayLength = max_val
182        if self.fixedSpan:
183            self.update()
184
185    def setFixedValues(self, fixed_values):
186        self.fixedValues = fixed_values
187        self.update()
188
189    def setMinimumImpedance(self):
190        min_val, selected = QtWidgets.QInputDialog.getDouble(
191            self, "Minimum impedance (\N{OHM SIGN})",
192            "Set minimum impedance (\N{OHM SIGN})",
193            value=self.minDisplayLength,
194            min=0, decimals=1)
195        if not selected:
196            return
197        if not (self.fixedValues and min_val >= self.maxImpedance):
198            self.minImpedance = min_val
199        if self.fixedValues:
200            self.update()
201
202    def setMaximumImpedance(self):
203        max_val, selected = QtWidgets.QInputDialog.getDouble(
204            self, "Maximum impedance (\N{OHM SIGN})",
205            "Set maximum impedance (\N{OHM SIGN})",
206            value=self.minDisplayLength,
207            min=0.1, decimals=1)
208        if not selected:
209            return
210        if not (self.fixedValues and max_val <= self.minImpedance):
211            self.maxImpedance = max_val
212        if self.fixedValues:
213            self.update()
214
215    def copy(self):
216        new_chart: TDRChart = super().copy()
217        new_chart.tdrWindow = self.tdrWindow
218        new_chart.minDisplayLength = self.minDisplayLength
219        new_chart.maxDisplayLength = self.maxDisplayLength
220        new_chart.fixedSpan = self.fixedSpan
221        new_chart.minImpedance = self.minImpedance
222        new_chart.maxImpedance = self.maxImpedance
223        new_chart.fixedValues = self.fixedValues
224        self.tdrWindow.updated.connect(new_chart.update)
225        return new_chart
226
227    def mouseMoveEvent(self, a0: QtGui.QMouseEvent) -> None:
228        if a0.buttons() == QtCore.Qt.RightButton:
229            a0.ignore()
230            return
231        if a0.buttons() == QtCore.Qt.MiddleButton:
232            # Drag the display
233            a0.accept()
234            if self.moveStartX != -1 and self.moveStartY != -1:
235                dx = self.moveStartX - a0.x()
236                dy = self.moveStartY - a0.y()
237                self.zoomTo(self.leftMargin + dx, self.topMargin + dy,
238                            self.leftMargin + self.chartWidth + dx,
239                            self.topMargin + self.chartHeight + dy)
240            self.moveStartX = a0.x()
241            self.moveStartY = a0.y()
242            return
243        if a0.modifiers() == QtCore.Qt.ControlModifier:
244            # Dragging a box
245            if not self.draggedBox:
246                self.draggedBoxStart = (a0.x(), a0.y())
247            self.draggedBoxCurrent = (a0.x(), a0.y())
248            self.update()
249            a0.accept()
250            return
251
252        x = a0.x()
253        absx = x - self.leftMargin
254        if absx < 0 or absx > self.width() - self.rightMargin:
255            a0.ignore()
256            return
257        a0.accept()
258        width = self.width() - self.leftMargin - self.rightMargin
259        if len(self.tdrWindow.td) > 0:
260            if self.fixedSpan:
261                max_index = np.searchsorted(self.tdrWindow.distance_axis, self.maxDisplayLength * 2)
262                min_index = np.searchsorted(self.tdrWindow.distance_axis, self.minDisplayLength * 2)
263                x_step = (max_index - min_index) / width
264            else:
265                max_index = math.ceil(len(self.tdrWindow.distance_axis) / 2)
266                x_step = max_index / width
267
268            self.markerLocation = int(round(absx * x_step))
269            self.update()
270        return
271
272    def paintEvent(self, a0: QtGui.QPaintEvent) -> None:
273        qp = QtGui.QPainter(self)
274        qp.setPen(QtGui.QPen(self.textColor))
275        qp.drawText(3, 15, self.name)
276
277        width = self.width() - self.leftMargin - self.rightMargin
278        height = self.height() - self.bottomMargin - self.topMargin
279
280        qp.setPen(QtGui.QPen(self.foregroundColor))
281        qp.drawLine(self.leftMargin - 5,
282                    self.height() - self.bottomMargin,
283                    self.width() - self.rightMargin,
284                    self.height() - self.bottomMargin)
285        qp.drawLine(self.leftMargin,
286                    self.topMargin - 5,
287                    self.leftMargin,
288                    self.height() - self.bottomMargin + 5)
289        # Number of ticks does not include the origin
290        ticks = math.floor((self.width() - self.leftMargin) / 100)
291        self.drawTitle(qp)
292
293        if len(self.tdrWindow.td) > 0:
294            if self.fixedSpan:
295                max_length = max(0.1, self.maxDisplayLength)
296                max_index = np.searchsorted(self.tdrWindow.distance_axis, max_length * 2)
297                min_index = np.searchsorted(self.tdrWindow.distance_axis, self.minDisplayLength * 2)
298                if max_index == min_index:
299                    if max_index < len(self.tdrWindow.distance_axis) - 1:
300                        max_index += 1
301                    else:
302                        min_index -= 1
303                x_step = (max_index - min_index) / width
304            else:
305                min_index = 0
306                max_index = math.ceil(len(self.tdrWindow.distance_axis) / 2)
307                x_step = max_index / width
308
309            if self.fixedValues:
310                min_impedance = max(0, self.minImpedance)
311                max_impedance = max(0.1, self.maxImpedance)
312            else:
313                # TODO: Limit the search to the selected span?
314                min_impedance = max(
315                    0,
316                    np.min(self.tdrWindow.step_response_Z) / 1.05)
317                max_impedance = min(
318                    1000,
319                    np.max(self.tdrWindow.step_response_Z) * 1.05)
320
321            y_step = np.max(self.tdrWindow.td) * 1.1 / height
322            y_impedance_step = (max_impedance - min_impedance) / height
323
324            for i in range(ticks):
325                x = self.leftMargin + round((i + 1) * width / ticks)
326                qp.setPen(QtGui.QPen(self.foregroundColor))
327                qp.drawLine(x, self.topMargin, x, self.topMargin + height)
328                qp.setPen(QtGui.QPen(self.textColor))
329                qp.drawText(
330                    x - 15,
331                    self.topMargin + height + 15,
332                    str(round(
333                        self.tdrWindow.distance_axis[
334                            min_index +
335                            int((x - self.leftMargin) * x_step) - 1] / 2,
336                        1)) + "m")
337
338            qp.setPen(QtGui.QPen(self.textColor))
339            qp.drawText(
340                self.leftMargin - 10,
341                self.topMargin + height + 15,
342                str(round(self.tdrWindow.distance_axis[min_index] / 2,
343                          1)) + "m")
344
345            y_ticks = math.floor(height / 60)
346            y_tick_step = height/y_ticks
347
348            for i in range(y_ticks):
349                y = self.bottomMargin + int(i * y_tick_step)
350                qp.setPen(self.foregroundColor)
351                qp.drawLine(self.leftMargin, y, self.leftMargin + width, y)
352                y_val = max_impedance - y_impedance_step * i * y_tick_step
353                qp.setPen(self.textColor)
354                qp.drawText(3, y + 3, str(round(y_val, 1)))
355
356            qp.drawText(3, self.topMargin + height + 3, str(round(min_impedance, 1)))
357
358            pen = QtGui.QPen(self.sweepColor)
359            pen.setWidth(self.pointSize)
360            qp.setPen(pen)
361            for i in range(min_index, max_index):
362                if i < min_index or i > max_index:
363                    continue
364
365                x = self.leftMargin + int((i - min_index) / x_step)
366                y = (self.topMargin + height) - int(self.tdrWindow.td[i] / y_step)
367                if self.isPlotable(x, y):
368                    pen.setColor(self.sweepColor)
369                    qp.setPen(pen)
370                    qp.drawPoint(x, y)
371
372                x = self.leftMargin + int((i - min_index) / x_step)
373                y = (self.topMargin + height) -\
374                    int((self.tdrWindow.step_response_Z[i]-min_impedance) / y_impedance_step)
375                if self.isPlotable(x, y):
376                    pen.setColor(self.secondarySweepColor)
377                    qp.setPen(pen)
378                    qp.drawPoint(x, y)
379
380            id_max = np.argmax(self.tdrWindow.td)
381            max_point = QtCore.QPoint(
382                self.leftMargin + int((id_max - min_index) / x_step),
383                (self.topMargin + height) - int(self.tdrWindow.td[id_max] / y_step))
384            qp.setPen(self.markers[0].color)
385            qp.drawEllipse(max_point, 2, 2)
386            qp.setPen(self.textColor)
387            qp.drawText(max_point.x() - 10, max_point.y() - 5,
388                        str(round(self.tdrWindow.distance_axis[id_max] / 2,
389                                  2)) + "m")
390
391            if self.markerLocation != -1:
392                marker_point = QtCore.QPoint(
393                    self.leftMargin +
394                    int((self.markerLocation - min_index) / x_step),
395                    (self.topMargin + height) -
396                    int(self.tdrWindow.td[self.markerLocation] / y_step))
397                qp.setPen(self.textColor)
398                qp.drawEllipse(marker_point, 2, 2)
399                qp.drawText(
400                    marker_point.x() - 10,
401                    marker_point.y() - 5,
402                    str(round(self.tdrWindow.distance_axis[self.markerLocation] / 2,
403                              2)) + "m")
404
405        if self.draggedBox and self.draggedBoxCurrent[0] != -1:
406            dashed_pen = QtGui.QPen(self.foregroundColor, 1, QtCore.Qt.DashLine)
407            qp.setPen(dashed_pen)
408            top_left = QtCore.QPoint(self.draggedBoxStart[0], self.draggedBoxStart[1])
409            bottom_right = QtCore.QPoint(self.draggedBoxCurrent[0], self.draggedBoxCurrent[1])
410            rect = QtCore.QRect(top_left, bottom_right)
411            qp.drawRect(rect)
412
413        qp.end()
414
415    def valueAtPosition(self, y):
416        if len(self.tdrWindow.td) > 0:
417            height = self.height() - self.topMargin - self.bottomMargin
418            absy = (self.height() - y) - self.bottomMargin
419            if self.fixedValues:
420                min_impedance = self.minImpedance
421                max_impedance = self.maxImpedance
422            else:
423                min_impedance = max(
424                    0,
425                    np.min(self.tdrWindow.step_response_Z) / 1.05)
426                max_impedance = min(
427                    1000,
428                    np.max(self.tdrWindow.step_response_Z) * 1.05)
429            y_step = (max_impedance - min_impedance) / height
430            return y_step * absy + min_impedance
431        return 0
432
433    def lengthAtPosition(self, x, limit=True):
434        if len(self.tdrWindow.td) > 0:
435            width = self.width() - self.leftMargin - self.rightMargin
436            absx = x - self.leftMargin
437            if self.fixedSpan:
438                max_length = self.maxDisplayLength
439                min_length = self.minDisplayLength
440                x_step = (max_length - min_length) / width
441            else:
442                min_length = 0
443                max_length = self.tdrWindow.distance_axis[
444                    math.ceil(len(self.tdrWindow.distance_axis) / 2)] / 2
445                x_step = max_length / width
446            if limit and absx < 0:
447                return min_length
448            if limit and absx > width:
449                return max_length
450            return absx * x_step + min_length
451        return 0
452
453    def zoomTo(self, x1, y1, x2, y2):
454        logger.debug("Zoom to (x,y) by (x,y): (%d, %d) by (%d, %d)", x1, y1, x2, y2)
455        val1 = self.valueAtPosition(y1)
456        val2 = self.valueAtPosition(y2)
457
458        if val1 != val2:
459            self.minImpedance = round(min(val1, val2), 3)
460            self.maxImpedance = round(max(val1, val2), 3)
461            self.setFixedValues(True)
462
463        len1 = max(0, self.lengthAtPosition(x1, limit=False))
464        len2 = max(0, self.lengthAtPosition(x2, limit=False))
465
466        if len1 >= 0 and len2 >= 0 and len1 != len2:
467            self.minDisplayLength = min(len1, len2)
468            self.maxDisplayLength = max(len1, len2)
469            self.setFixedSpan(True)
470
471        self.update()
472
473    def wheelEvent(self, a0: QtGui.QWheelEvent) -> None:
474        if len(self.tdrWindow.td) == 0:
475            a0.ignore()
476            return
477        chart_height = self.chartHeight
478        chart_width = self.chartWidth
479        do_zoom_x = do_zoom_y = True
480        if a0.modifiers() == QtCore.Qt.ShiftModifier:
481            do_zoom_x = False
482        if a0.modifiers() == QtCore.Qt.ControlModifier:
483            do_zoom_y = False
484        if a0.angleDelta().y() > 0:
485            # Zoom in
486            a0.accept()
487            # Center of zoom = a0.x(), a0.y()
488            # We zoom in by 1/10 of the width/height.
489            rate = a0.angleDelta().y() / 120
490            if do_zoom_x:
491                zoomx = rate * chart_width / 10
492            else:
493                zoomx = 0
494            if do_zoom_y:
495                zoomy = rate * chart_height / 10
496            else:
497                zoomy = 0
498            absx = max(0, a0.x() - self.leftMargin)
499            absy = max(0, a0.y() - self.topMargin)
500            ratiox = absx/chart_width
501            ratioy = absy/chart_height
502            # TODO: Change zoom to center on the mouse if possible,
503            #       or extend box to the side that has room if not.
504            p1x = int(self.leftMargin + ratiox * zoomx)
505            p1y = int(self.topMargin + ratioy * zoomy)
506            p2x = int(self.leftMargin + chart_width - (1 - ratiox) * zoomx)
507            p2y = int(self.topMargin + chart_height - (1 - ratioy) * zoomy)
508            self.zoomTo(p1x, p1y, p2x, p2y)
509        elif a0.angleDelta().y() < 0:
510            # Zoom out
511            a0.accept()
512            # Center of zoom = a0.x(), a0.y()
513            # We zoom out by 1/9 of the width/height, to match zoom in.
514            rate = -a0.angleDelta().y() / 120
515            if do_zoom_x:
516                zoomx = rate * chart_width / 9
517            else:
518                zoomx = 0
519            if do_zoom_y:
520                zoomy = rate * chart_height / 9
521            else:
522                zoomy = 0
523            absx = max(0, a0.x() - self.leftMargin)
524            absy = max(0, a0.y() - self.topMargin)
525            ratiox = absx/chart_width
526            ratioy = absy/chart_height
527            p1x = int(self.leftMargin - ratiox * zoomx)
528            p1y = int(self.topMargin - ratioy * zoomy)
529            p2x = int(self.leftMargin + chart_width + (1 - ratiox) * zoomx)
530            p2y = int(self.topMargin + chart_height + (1 - ratioy) * zoomy)
531            self.zoomTo(p1x, p1y, p2x, p2y)
532        else:
533            a0.ignore()
534
535    def resizeEvent(self, a0: QtGui.QResizeEvent) -> None:
536        super().resizeEvent(a0)
537        self.chartWidth = self.width() - self.leftMargin - self.rightMargin
538        self.chartHeight = self.height() - self.bottomMargin - self.topMargin
539