nanovna-saver/src/NanoVNASaver/NanoVNASaver.py

674 wiersze
25 KiB
Python

# NanoVNASaver
#
# A python program to view and export Touchstone data from a NanoVNA
# Copyright (C) 2019, 2020 Rune B. Broberg
# Copyright (C) 2020,2021 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 <https://www.gnu.org/licenses/>.
import contextlib
import logging
import sys
import threading
from time import strftime, localtime
from PyQt5 import QtWidgets, QtCore, QtGui
from NanoVNASaver import Defaults
from .Windows import (
AboutWindow, AnalysisWindow, CalibrationWindow,
DeviceSettingsWindow, DisplaySettingsWindow, SweepSettingsWindow,
TDRWindow, FilesWindow
)
from .Controls.MarkerControl import MarkerControl
from .Controls.SweepControl import SweepControl
from .Controls.SerialControl import SerialControl
from .Formatting import format_frequency, format_vswr, format_gain
from .Hardware.Hardware import Interface
from .Hardware.VNA import VNA
from .RFTools import corr_att_data
from .Charts.Chart import Chart
from .Charts import (
CapacitanceChart,
CombinedLogMagChart, GroupDelayChart, InductanceChart,
LogMagChart, PhaseChart,
MagnitudeChart, MagnitudeZChart, MagnitudeZShuntChart,
MagnitudeZSeriesChart,
QualityFactorChart, VSWRChart, PermeabilityChart, PolarChart,
RealImaginaryMuChart,
RealImaginaryZChart, RealImaginaryZShuntChart, RealImaginaryZSeriesChart,
SmithChart, SParameterChart, TDRChart,
)
from .Calibration import Calibration
from .Marker.Widget import Marker
from .Marker.Delta import DeltaMarker
from .SweepWorker import SweepWorker
from .Settings.Bands import BandsModel
from .Settings.Sweep import Sweep
from .Touchstone import Touchstone
from .About import VERSION
logger = logging.getLogger(__name__)
class NanoVNASaver(QtWidgets.QWidget):
version = VERSION
dataAvailable = QtCore.pyqtSignal()
scaleFactor = 1
def __init__(self):
super().__init__()
self.s21att = 0.0
if getattr(sys, 'frozen', False):
logger.debug("Running from pyinstaller bundle")
self.icon = QtGui.QIcon(
f"{sys._MEIPASS}/icon_48x48.png") # pylint: disable=no-member
else:
self.icon = QtGui.QIcon("icon_48x48.png")
self.setWindowIcon(self.icon)
self.settings = Defaults.AppSettings(
QtCore.QSettings.IniFormat,
QtCore.QSettings.UserScope,
"NanoVNASaver",
"NanoVNASaver")
logger.info("Settings from: %s", self.settings.fileName())
Defaults.cfg = Defaults.restore(self.settings)
self.threadpool = QtCore.QThreadPool()
self.sweep = Sweep()
self.worker = SweepWorker(self)
self.worker.signals.updated.connect(self.dataUpdated)
self.worker.signals.finished.connect(self.sweepFinished)
self.worker.signals.sweepError.connect(self.showSweepError)
self.markers = []
self.marker_ref = False
self.marker_column = QtWidgets.QVBoxLayout()
self.marker_frame = QtWidgets.QFrame()
self.marker_column.setContentsMargins(0, 0, 0, 0)
self.marker_frame.setLayout(self.marker_column)
self.sweep_control = SweepControl(self)
self.marker_control = MarkerControl(self)
self.serial_control = SerialControl(self)
self.bands = BandsModel()
self.interface = Interface("serial", "None")
self.vna = VNA(self.interface)
self.dataLock = threading.Lock()
self.data = Touchstone()
self.ref_data = Touchstone()
self.sweepSource = ""
self.referenceSource = ""
self.calibration = Calibration()
logger.debug("Building user interface")
self.baseTitle = f"NanoVNA Saver {NanoVNASaver.version}"
self.updateTitle()
layout = QtWidgets.QBoxLayout(QtWidgets.QBoxLayout.LeftToRight)
scrollarea = QtWidgets.QScrollArea()
outer = QtWidgets.QVBoxLayout()
outer.addWidget(scrollarea)
self.setLayout(outer)
scrollarea.setWidgetResizable(True)
self.resize(Defaults.cfg.gui.window_width,
Defaults.cfg.gui.window_height)
scrollarea.setSizePolicy(
QtWidgets.QSizePolicy.MinimumExpanding,
QtWidgets.QSizePolicy.MinimumExpanding)
self.setSizePolicy(QtWidgets.QSizePolicy.MinimumExpanding,
QtWidgets.QSizePolicy.MinimumExpanding)
widget = QtWidgets.QWidget()
widget.setLayout(layout)
scrollarea.setWidget(widget)
self.charts = {
"s11": {
"capacitance": CapacitanceChart("S11 Serial C"),
"group_delay": GroupDelayChart("S11 Group Delay"),
"inductance": InductanceChart("S11 Serial L"),
"log_mag": LogMagChart("S11 Return Loss"),
"magnitude": MagnitudeChart("|S11|"),
"magnitude_z": MagnitudeZChart("S11 |Z|"),
"permeability": PermeabilityChart(
"S11 R/\N{GREEK SMALL LETTER OMEGA} &"
" X/\N{GREEK SMALL LETTER OMEGA}"),
"phase": PhaseChart("S11 Phase"),
"q_factor": QualityFactorChart("S11 Quality Factor"),
"real_imag": RealImaginaryZChart("S11 R+jX"),
"real_imag_mu": RealImaginaryMuChart("S11 \N{GREEK SMALL LETTER MU}"),
"smith": SmithChart("S11 Smith Chart"),
"s_parameter": SParameterChart("S11 Real/Imaginary"),
"vswr": VSWRChart("S11 VSWR"),
},
"s21": {
"group_delay": GroupDelayChart("S21 Group Delay",
reflective=False),
"log_mag": LogMagChart("S21 Gain"),
"magnitude": MagnitudeChart("|S21|"),
"magnitude_z_shunt": MagnitudeZShuntChart("S21 |Z| shunt"),
"magnitude_z_series": MagnitudeZSeriesChart("S21 |Z| series"),
"real_imag_shunt": RealImaginaryZShuntChart("S21 R+jX shunt"),
"real_imag_series": RealImaginaryZSeriesChart(
"S21 R+jX series"),
"phase": PhaseChart("S21 Phase"),
"polar": PolarChart("S21 Polar Plot"),
"s_parameter": SParameterChart("S21 Real/Imaginary"),
},
"combined": {
"log_mag": CombinedLogMagChart("S11 & S21 LogMag"),
},
}
self.tdr_chart = TDRChart("TDR")
self.tdr_mainwindow_chart = TDRChart("TDR")
# List of all the S11 charts, for selecting
self.s11charts = list(self.charts["s11"].values())
# List of all the S21 charts, for selecting
self.s21charts = list(self.charts["s21"].values())
# List of all charts that use both S11 and S21
self.combinedCharts = list(self.charts["combined"].values())
# List of all charts that can be selected for display
self.selectable_charts = (
self.s11charts + self.s21charts +
self.combinedCharts + [self.tdr_mainwindow_chart, ])
# List of all charts that subscribe to updates (including duplicates!)
self.subscribing_charts = []
self.subscribing_charts.extend(self.selectable_charts)
self.subscribing_charts.append(self.tdr_chart)
for c in self.subscribing_charts:
c.popoutRequested.connect(self.popoutChart)
self.charts_layout = QtWidgets.QGridLayout()
QtWidgets.QShortcut(QtGui.QKeySequence("Ctrl+Q"), self, self.close)
###############################################################
# Create main layout
###############################################################
left_column = QtWidgets.QVBoxLayout()
right_column = QtWidgets.QVBoxLayout()
right_column.addLayout(self.charts_layout)
self.marker_frame.setHidden(Defaults.cfg.gui.markers_hidden)
chart_widget = QtWidgets.QWidget()
chart_widget.setLayout(right_column)
self.splitter = QtWidgets.QSplitter()
self.splitter.addWidget(self.marker_frame)
self.splitter.addWidget(chart_widget)
self.splitter.restoreState(Defaults.cfg.gui.splitter_sizes)
layout.addLayout(left_column)
layout.addWidget(self.splitter, 2)
###############################################################
# Windows
###############################################################
self.windows = {
"about": AboutWindow(self),
# "analysis": AnalysisWindow(self),
"calibration": CalibrationWindow(self),
"device_settings": DeviceSettingsWindow(self),
"file": FilesWindow(self),
"sweep_settings": SweepSettingsWindow(self),
"setup": DisplaySettingsWindow(self),
"tdr": TDRWindow(self),
}
###############################################################
# Sweep control
###############################################################
left_column.addWidget(self.sweep_control)
# ###############################################################
# Marker control
###############################################################
left_column.addWidget(self.marker_control)
for c in self.subscribing_charts:
c.setMarkers(self.markers)
c.setBands(self.bands)
self.marker_data_layout = QtWidgets.QVBoxLayout()
self.marker_data_layout.setContentsMargins(0, 0, 0, 0)
for m in self.markers:
self.marker_data_layout.addWidget(m.get_data_layout())
scroll2 = QtWidgets.QScrollArea()
# scroll2.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn)
scroll2.setWidgetResizable(True)
scroll2.setVisible(True)
widget2 = QtWidgets.QWidget()
widget2.setLayout(self.marker_data_layout)
scroll2.setWidget(widget2)
self.marker_column.addWidget(scroll2)
# init delta marker (but assume only one marker exists)
self.delta_marker = DeltaMarker("Delta Marker 2 - Marker 1")
self.delta_marker_layout = self.delta_marker.get_data_layout()
self.delta_marker_layout.hide()
self.marker_column.addWidget(self.delta_marker_layout)
###############################################################
# Statistics/analysis
###############################################################
s11_control_box = QtWidgets.QGroupBox()
s11_control_box.setTitle("S11")
s11_control_layout = QtWidgets.QFormLayout()
s11_control_layout.setVerticalSpacing(0)
s11_control_box.setLayout(s11_control_layout)
self.s11_min_swr_label = QtWidgets.QLabel()
s11_control_layout.addRow("Min VSWR:", self.s11_min_swr_label)
self.s11_min_rl_label = QtWidgets.QLabel()
s11_control_layout.addRow("Return loss:", self.s11_min_rl_label)
self.marker_column.addWidget(s11_control_box)
s21_control_box = QtWidgets.QGroupBox()
s21_control_box.setTitle("S21")
s21_control_layout = QtWidgets.QFormLayout()
s21_control_layout.setVerticalSpacing(0)
s21_control_box.setLayout(s21_control_layout)
self.s21_min_gain_label = QtWidgets.QLabel()
s21_control_layout.addRow("Min gain:", self.s21_min_gain_label)
self.s21_max_gain_label = QtWidgets.QLabel()
s21_control_layout.addRow("Max gain:", self.s21_max_gain_label)
self.marker_column.addWidget(s21_control_box)
# self.marker_column.addStretch(1)
self.windows["analysis"] = AnalysisWindow(self)
btn_show_analysis = QtWidgets.QPushButton("Analysis ...")
btn_show_analysis.setMinimumHeight(20)
btn_show_analysis.clicked.connect(
lambda: self.display_window("analysis"))
self.marker_column.addWidget(btn_show_analysis)
###############################################################
# TDR
###############################################################
self.tdr_chart.tdrWindow = self.windows["tdr"]
self.tdr_mainwindow_chart.tdrWindow = self.windows["tdr"]
self.windows["tdr"].updated.connect(self.tdr_chart.update)
self.windows["tdr"].updated.connect(self.tdr_mainwindow_chart.update)
tdr_control_box = QtWidgets.QGroupBox()
tdr_control_box.setTitle("TDR")
tdr_control_layout = QtWidgets.QFormLayout()
tdr_control_box.setLayout(tdr_control_layout)
tdr_control_box.setMaximumWidth(240)
self.tdr_result_label = QtWidgets.QLabel()
self.tdr_result_label.setMinimumHeight(20)
tdr_control_layout.addRow(
"Estimated cable length:", self.tdr_result_label)
self.tdr_button = QtWidgets.QPushButton(
"Time Domain Reflectometry ...")
self.tdr_button.setMinimumHeight(20)
self.tdr_button.clicked.connect(lambda: self.display_window("tdr"))
tdr_control_layout.addRow(self.tdr_button)
left_column.addWidget(tdr_control_box)
###############################################################
# Spacer
###############################################################
left_column.addSpacerItem(
QtWidgets.QSpacerItem(1, 1, QtWidgets.QSizePolicy.Fixed,
QtWidgets.QSizePolicy.Expanding))
###############################################################
# Reference control
###############################################################
reference_control_box = QtWidgets.QGroupBox()
reference_control_box.setMaximumWidth(240)
reference_control_box.setTitle("Reference sweep")
reference_control_layout = QtWidgets.QFormLayout(reference_control_box)
btn_set_reference = QtWidgets.QPushButton("Set current as reference")
btn_set_reference.setMinimumHeight(20)
btn_set_reference.clicked.connect(self.setReference)
self.btnResetReference = QtWidgets.QPushButton("Reset reference")
self.btnResetReference.setMinimumHeight(20)
self.btnResetReference.clicked.connect(self.resetReference)
self.btnResetReference.setDisabled(True)
reference_control_layout.addRow(btn_set_reference)
reference_control_layout.addRow(self.btnResetReference)
left_column.addWidget(reference_control_box)
###############################################################
# Serial control
###############################################################
left_column.addWidget(self.serial_control)
###############################################################
# Calibration
###############################################################
btnOpenCalibrationWindow = QtWidgets.QPushButton("Calibration ...")
btnOpenCalibrationWindow.setMinimumHeight(20)
self.calibrationWindow = CalibrationWindow(self)
btnOpenCalibrationWindow.clicked.connect(
lambda: self.display_window("calibration"))
###############################################################
# Display setup
###############################################################
btn_display_setup = QtWidgets.QPushButton("Display setup ...")
btn_display_setup.setMinimumHeight(20)
btn_display_setup.setMaximumWidth(240)
btn_display_setup.clicked.connect(
lambda: self.display_window("setup"))
btn_about = QtWidgets.QPushButton("About ...")
btn_about.setMinimumHeight(20)
btn_about.setMaximumWidth(240)
btn_about.clicked.connect(
lambda: self.display_window("about"))
btn_open_file_window = QtWidgets.QPushButton("Files")
btn_open_file_window.setMinimumHeight(20)
btn_open_file_window.setMaximumWidth(240)
btn_open_file_window.clicked.connect(
lambda: self.display_window("file"))
button_grid = QtWidgets.QGridLayout()
button_grid.addWidget(btn_open_file_window, 0, 0)
button_grid.addWidget(btnOpenCalibrationWindow, 0, 1)
button_grid.addWidget(btn_display_setup, 1, 0)
button_grid.addWidget(btn_about, 1, 1)
left_column.addLayout(button_grid)
logger.debug("Finished building interface")
def _sweep_control(self, start: bool = True) -> None:
self.sweep_control.progress_bar.setValue(0 if start else 100)
self.sweep_control.btn_start.setDisabled(start)
self.sweep_control.btn_stop.setDisabled(not start)
self.sweep_control.toggle_settings(start)
def sweep_start(self):
# Run the device data update
if not self.vna.connected():
return
self.worker.stopped = False
self._sweep_control(start=True)
for m in self.markers:
m.resetLabels()
self.s11_min_rl_label.setText("")
self.s11_min_swr_label.setText("")
self.s21_min_gain_label.setText("")
self.s21_max_gain_label.setText("")
self.tdr_result_label.setText("")
self.settings.setValue("Segments", self.sweep_control.get_segments())
logger.debug("Starting worker thread")
self.threadpool.start(self.worker)
def sweep_stop(self):
self.worker.stopped = True
def saveData(self, data, data21, source=None):
with self.dataLock:
self.data.s11 = data
self.data.s21 = data21
if self.s21att > 0:
self.data.s21 = corr_att_data(self.data.s21, self.s21att)
if source is not None:
self.sweepSource = source
else:
self.sweepSource = (
f"{self.sweep.properties.name}"
f" {strftime('%Y-%m-%d %H:%M:%S', localtime())}"
).lstrip()
def markerUpdated(self, marker: Marker):
with self.dataLock:
marker.findLocation(self.data.s11)
marker.resetLabels()
marker.updateLabels(self.data.s11, self.data.s21)
for c in self.subscribing_charts:
c.update()
if not self.delta_marker_layout.isHidden():
m1 = self.markers[0]
m2 = None
if self.marker_ref:
if self.ref_data:
m2 = Marker("Reference")
m2.location = self.markers[0].location
m2.resetLabels()
m2.updateLabels(self.ref_data.s11,
self.ref_data.s21)
else:
logger.warning("No reference data for marker")
elif Marker.count() >= 2:
m2 = self.markers[1]
if m2 is None:
logger.error("No data for delta, missing marker or reference")
else:
self.delta_marker.set_markers(m1, m2)
self.delta_marker.resetLabels()
with contextlib.suppress(IndexError):
self.delta_marker.updateLabels()
def dataUpdated(self):
with self.dataLock:
s11 = self.data.s11[:]
s21 = self.data.s21[:]
for m in self.markers:
m.resetLabels()
m.updateLabels(s11, s21)
for c in self.s11charts:
c.setData(s11)
for c in self.s21charts:
c.setData(s21)
for c in self.combinedCharts:
c.setCombinedData(s11, s21)
self.sweep_control.progress_bar.setValue(int(self.worker.percentage))
self.windows["tdr"].updateTDR()
if s11:
min_vswr = min(s11, key=lambda data: data.vswr)
self.s11_min_swr_label.setText(
f"{format_vswr(min_vswr.vswr)} @"
f" {format_frequency(min_vswr.freq)}")
self.s11_min_rl_label.setText(format_gain(min_vswr.gain))
else:
self.s11_min_swr_label.setText("")
self.s11_min_rl_label.setText("")
if s21:
min_gain = min(s21, key=lambda data: data.gain)
max_gain = max(s21, key=lambda data: data.gain)
self.s21_min_gain_label.setText(
f"{format_gain(min_gain.gain)}"
f" @ {format_frequency(min_gain.freq)}")
self.s21_max_gain_label.setText(
f"{format_gain(max_gain.gain)}"
f" @ {format_frequency(max_gain.freq)}")
else:
self.s21_min_gain_label.setText("")
self.s21_max_gain_label.setText("")
self.updateTitle()
self.dataAvailable.emit()
def sweepFinished(self):
self._sweep_control(start=False)
for marker in self.markers:
marker.frequencyInput.textEdited.emit(
marker.frequencyInput.text())
def setReference(self, s11=None, s21=None, source=None):
if not s11:
with self.dataLock:
s11 = self.data.s11[:]
s21 = self.data.s21[:]
self.ref_data.s11 = s11
for c in self.s11charts:
c.setReference(s11)
self.ref_data.s21 = s21
for c in self.s21charts:
c.setReference(s21)
for c in self.combinedCharts:
c.setCombinedReference(s11, s21)
self.btnResetReference.setDisabled(False)
self.referenceSource = source or self.sweepSource
self.updateTitle()
def updateTitle(self):
insert = "("
if self.sweepSource != "":
insert += (
f"Sweep: {self.sweepSource} @ {len(self.data.s11)} points"
f"{', ' if self.referenceSource else ''}")
if self.referenceSource != "":
insert += (
f"Reference: {self.referenceSource} @"
f" {len(self.ref_data.s11)} points")
insert += ")"
title = f"{self.baseTitle} {insert or ''}"
self.setWindowTitle(title)
def resetReference(self):
self.ref_data = Touchstone()
self.referenceSource = ""
self.updateTitle()
for c in self.subscribing_charts:
c.resetReference()
self.btnResetReference.setDisabled(True)
def sizeHint(self) -> QtCore.QSize:
return QtCore.QSize(1100, 950)
def display_window(self, name):
self.windows[name].show()
QtWidgets.QApplication.setActiveWindow(self.windows[name])
def showError(self, text):
QtWidgets.QMessageBox.warning(self, "Error", text)
def showSweepError(self):
self.showError(self.worker.error_message)
with contextlib.suppress(IOError):
self.vna.flushSerialBuffers() # Remove any left-over data
self.vna.reconnect() # try reconnection
self.sweepFinished()
def popoutChart(self, chart: Chart):
logger.debug("Requested popout for chart: %s", chart.name)
new_chart = self.copyChart(chart)
new_chart.isPopout = True
new_chart.show()
new_chart.setWindowTitle(new_chart.name)
def copyChart(self, chart: Chart):
new_chart = chart.copy()
self.subscribing_charts.append(new_chart)
if chart in self.s11charts:
self.s11charts.append(new_chart)
if chart in self.s21charts:
self.s21charts.append(new_chart)
if chart in self.combinedCharts:
self.combinedCharts.append(new_chart)
new_chart.popoutRequested.connect(self.popoutChart)
return new_chart
def closeEvent(self, a0: QtGui.QCloseEvent) -> None:
self.worker.stopped = True
for marker in self.markers:
marker.update_settings()
self.settings.sync()
self.bands.saveSettings()
self.threadpool.waitForDone(2500)
Defaults.cfg.chart.marker_count = Marker.count()
Defaults.cfg.gui.window_width = self.width()
Defaults.cfg.gui.window_height = self.height()
Defaults.cfg.gui.splitter_sizes = self.splitter.saveState()
Defaults.store(self.settings, Defaults.cfg)
a0.accept()
sys.exit()
def changeFont(self, font: QtGui.QFont) -> None:
qf_new = QtGui.QFontMetricsF(font)
normal_font = QtGui.QFont(font)
normal_font.setPointSize(8)
qf_normal = QtGui.QFontMetricsF(normal_font)
# Characters we would normally display
standard_string = "0.123456789 0.123456789 MHz \N{OHM SIGN}"
new_width = qf_new.horizontalAdvance(standard_string)
old_width = qf_normal.horizontalAdvance(standard_string)
self.scaleFactor = new_width / old_width
logger.debug("New font width: %f, normal font: %f, factor: %f",
new_width, old_width, self.scaleFactor)
# TODO: Update all the fixed widths to account for the scaling
for m in self.markers:
m.get_data_layout().setFont(font)
m.setScale(self.scaleFactor)
def update_sweep_title(self):
for c in self.subscribing_charts:
c.setSweepTitle(self.sweep.properties.name)