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