Skip to content

Instantly share code, notes, and snippets.

@Onefabis
Created November 18, 2025 17:42
Show Gist options
  • Select an option

  • Save Onefabis/8d7ea1bb1cfcac90828cba9662794cba to your computer and use it in GitHub Desktop.

Select an option

Save Onefabis/8d7ea1bb1cfcac90828cba9662794cba to your computer and use it in GitHub Desktop.
antenna plot for XY plane measurements with nanovna
import sys
import re
from pathlib import Path
from typing import List, Optional, Tuple
import numpy as np
from scipy.interpolate import PchipInterpolator
import matplotlib
matplotlib.use("QtAgg")
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (
QApplication, QWidget, QVBoxLayout, QLabel, QPushButton, QHBoxLayout,
QLineEdit, QScrollArea, QSizePolicy, QFileDialog, QSpacerItem
)
class PlotConfig:
SMOOTH_POINTS = 360
FIGURE_SIZE = (6, 6)
THETA_OFFSET = np.pi / 2
CURVE_LINEWIDTH = 2
POINT_COLOR = 'red'
RADIAL_LINE_COLOR = 'lightgray'
RADIAL_LINE_STYLE = ':'
RADIAL_LINE_WIDTH = 0.5
CONCENTRIC_COLOR = 'gray'
CONCENTRIC_WIDTH = 0.7
class PolarPlotter:
def __init__(self, config: PlotConfig = PlotConfig()):
self.config = config
@staticmethod
def db_to_radius(db: float) -> float:
db = np.clip(db, -100, 0)
return (db / 100 + 1) ** 5
def prepare_data(self, angles_deg: List[float], values_db: List[float]):
if len(angles_deg) < 2 or len(values_db) < 2:
# Not enough points for interpolation; return empty arrays
return np.array([]), np.array([]), np.array([]), np.array([])
angles_rad = np.deg2rad(np.array(angles_deg) % 360)
theta_smooth = np.linspace(0, 2 * np.pi, self.config.SMOOTH_POINTS)
sort_idx = np.argsort(angles_rad)
x_sorted = angles_rad[sort_idx]
y_sorted = np.array([self.db_to_radius(db) for db in values_db])[sort_idx]
# Extend for circular interpolation
x_ext = np.concatenate([x_sorted, x_sorted[0:1] + 2 * np.pi])
y_ext = np.concatenate([y_sorted, y_sorted[0:1]])
pchip = PchipInterpolator(x_ext, y_ext, extrapolate=True)
data_smooth = pchip(theta_smooth)
return x_sorted, theta_smooth, data_smooth, y_sorted
def plot_on_axis(self, ax, angles_deg: List[float], values_db: List[float]):
ax.clear()
# Prepare smooth data
theta_samples, theta_smooth, r_plot, original_r = self.prepare_data(angles_deg, values_db)
# Radial lines every 10° (draw first)
for deg in range(0, 360, 10):
ax.plot(
[np.deg2rad(deg), np.deg2rad(deg)],
[0, 1],
self.config.RADIAL_LINE_STYLE,
color=self.config.RADIAL_LINE_COLOR,
linewidth=self.config.RADIAL_LINE_WIDTH,
zorder=1
)
# Concentric circles (draw first)
db_levels = np.arange(0, -101, -10)
radii = [self.db_to_radius(db) for db in db_levels]
for r in radii:
ax.plot(
np.linspace(0, 2 * np.pi, 360),
np.ones(360) * r,
color=self.config.CONCENTRIC_COLOR,
linewidth=self.config.CONCENTRIC_WIDTH,
zorder=2
)
# Graph line cyan and points red — on top
ax.plot(theta_smooth, r_plot, linewidth=self.config.CURVE_LINEWIDTH, zorder=3)
ax.plot(theta_samples, original_r, 'o', color=self.config.POINT_COLOR, zorder=4)
# Offset 0 dB from edge
ax.set_ylim(0, 1)
ax.set_theta_direction(1)
ax.set_theta_offset(self.config.THETA_OFFSET)
ax.set_xticks(np.deg2rad([0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330]))
ax.set_xticklabels(['0°', '30°', '60°', '90°', '120°', '150°', '180°', '210°', '240°', '270°', '300°', '330°'])
yticks_labels_db = [0, -10, -20, -40]
yticks_radii = [self.db_to_radius(db) for db in yticks_labels_db]
ax.set_yticks(yticks_radii)
ax.set_yticklabels([str(db) for db in yticks_labels_db])
class AntennaPlot(QWidget):
DB_NUM_REGEX = re.compile(r'-?\d+(?:[.,]\d+)?') # extract numeric part
def __init__(self):
super().__init__()
self.plotter = PolarPlotter()
self.current_step: int = 30
self.db_lineedits: List[QLineEdit] = []
self.degree_labels: List[QLabel] = []
self.prev_db_texts: List[str] = []
self._setup_ui()
self._create_db_rows_for_step(self.current_step)
# Automatic plotting with empty or placeholder data
self._attempt_plot()
def _setup_ui(self):
self.setWindowTitle("Antenna Polar Plot (Step / dB inputs)")
self.resize(900, 620)
main_layout = QHBoxLayout()
self.setLayout(main_layout)
# ---------------- LEFT COLUMN ----------------
left_col = QVBoxLayout()
left_col.setAlignment(Qt.AlignmentFlag.AlignTop)
main_layout.addLayout(left_col, stretch=3)
top_btns = QHBoxLayout()
top_btns.setAlignment(Qt.AlignmentFlag.AlignLeft)
left_col.addLayout(top_btns)
self.save_btn = QPushButton("Save")
self.draw_btn = QPushButton("Draw")
self.mirror_btn = QPushButton("Mirror L to R")
top_btns.addWidget(self.save_btn)
top_btns.addItem(QSpacerItem(6, 0))
top_btns.addWidget(self.draw_btn)
top_btns.addItem(QSpacerItem(8, 0))
top_btns.addWidget(self.mirror_btn)
top_btns.addStretch(1)
self.canvas_fig = Figure(figsize=PlotConfig.FIGURE_SIZE)
self.canvas = FigureCanvas(self.canvas_fig)
self.canvas.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
left_col.addWidget(self.canvas)
self.ax = self.canvas.figure.add_subplot(
1, 1, 1, projection='polar', theta_offset=PlotConfig.THETA_OFFSET
)
# Error label with fixed height
self.error_label = QLabel()
self.error_label.setFixedHeight(40)
self.error_label.setWordWrap(True)
left_col.addWidget(self.error_label)
# ---------------- RIGHT COLUMN ----------------
right_col = QVBoxLayout()
right_col.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
main_layout.addLayout(right_col, stretch=1)
step_layout = QHBoxLayout()
step_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
right_col.addLayout(step_layout)
step_layout.addWidget(QLabel("Step:"), alignment=Qt.AlignmentFlag.AlignLeft)
self.step_input = QLineEdit()
self.step_input.setFixedWidth(60)
self.step_input.setText(str(self.current_step))
step_layout.addWidget(self.step_input)
step_layout.addWidget(QLabel("° (deg)"), alignment=Qt.AlignmentFlag.AlignLeft)
step_layout.addItem(QSpacerItem(6, 0))
self.set_btn = QPushButton("Set")
self.set_btn.clicked.connect(self._on_set_step)
step_layout.addWidget(self.set_btn)
# Make Save, Draw, Mirror same size as Set
hint = self.set_btn.sizeHint()
self.save_btn.setFixedSize(hint)
self.draw_btn.setFixedSize(hint)
self.mirror_btn.setFixedSize(hint)
self.draw_btn.clicked.connect(self._on_draw_clicked)
self.save_btn.clicked.connect(self._on_save)
self.mirror_btn.clicked.connect(self._on_mirror)
# Scroll area with dB inputs
self.scroll_area = QScrollArea()
self.scroll_area.setWidgetResizable(True)
right_col.addWidget(self.scroll_area)
self.rows_container = QWidget()
self.rows_layout = QVBoxLayout()
self.rows_layout.setSpacing(2) # reduced spacing
self.rows_layout.setContentsMargins(2, 2, 2, 2)
self.rows_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.rows_container.setLayout(self.rows_layout)
self.scroll_area.setWidget(self.rows_container)
def _clear_db_rows(self):
while self.rows_layout.count():
item = self.rows_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
# QSpacerItem has no .widget(), skip
self.db_lineedits = []
self.degree_labels = []
def _create_db_rows_for_step(self, step: int, restore_prev: bool = True):
prev_texts = [le.text() for le in self.db_lineedits] if self.db_lineedits else []
self.prev_db_texts = prev_texts
self._clear_db_rows()
n_rows = max(1, int(360 / float(step)))
deg_texts = [f"{i*step}°" for i in range(n_rows)]
fm = self.fontMetrics()
max_w = max(fm.horizontalAdvance(t) for t in deg_texts)
for i in range(n_rows):
angle = i * step
label_text = f"{angle}°"
row_widget = QWidget()
row_layout = QHBoxLayout()
row_layout.setContentsMargins(0, 0, 0, 0)
row_layout.setSpacing(6)
row_widget.setLayout(row_layout)
lbl = QLabel(label_text)
lbl.setFixedWidth(max_w)
lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
row_layout.addWidget(lbl)
le = QLineEdit()
le.setPlaceholderText("dB")
le.setToolTip("Enter value in dB (-100..0)")
le.setMinimumWidth(40)
row_layout.addWidget(le)
self.rows_layout.addWidget(row_widget)
self.degree_labels.append(lbl)
self.db_lineedits.append(le)
if restore_prev and self.prev_db_texts:
for idx, txt in enumerate(self.prev_db_texts):
if idx < len(self.db_lineedits):
self.db_lineedits[idx].setText(txt)
self.rows_layout.addStretch(1)
def _parse_db_field(self, text: str) -> Optional[float]:
if text is None or text.strip() == "":
return None
m = self.DB_NUM_REGEX.search(text)
if not m:
return None
try:
return float(m.group(0).replace(",", "."))
except:
return None
def _validate_and_collect(self) -> Tuple[Optional[List[float]], Optional[List[float]], Optional[str]]:
step_text = self.step_input.text().strip()
try:
step_val = float(step_text)
except:
return None, None, "Step value must be numeric."
if not (1 <= step_val <= 180):
return None, None, "Step is out of range (1-180)."
angles, values, errors = [], [], []
for idx, le in enumerate(self.db_lineedits, start=1):
txt = le.text().strip()
if txt == "":
continue
parsed = self._parse_db_field(txt)
if parsed is None:
errors.append(f"Non-numeric value at line {idx}.")
continue
if not (-100 <= parsed <= 0):
errors.append(f"dB out of range at line {idx}.")
continue
angles.append((idx-1)*step_val)
values.append(parsed)
if errors:
return None, None, " ".join(errors)
if len(values) < 2:
return None, None, "Not enough data points..."
return angles, values, None
def _attempt_plot(self):
self.error_label.clear()
self.ax.clear()
angles, values, err = self._validate_and_collect()
if err:
# If no data, just draw empty polar
self.plotter.plot_on_axis(self.ax, [], [])
self.canvas.draw()
if "Not enough data" not in err:
self.error_label.setText(err)
return
if angles is None or len(angles) < 2:
self.error_label.setText("Not enough points to plot.")
self.ax.clear()
self.canvas.draw()
return
try:
self.plotter.plot_on_axis(self.ax, angles, values)
self.canvas.draw()
self.error_label.clear()
except Exception as e:
self.error_label.setText(f"Error plotting data: {e}")
def _on_draw_clicked(self):
self._attempt_plot()
def _on_set_step(self):
prev_texts = [le.text() for le in self.db_lineedits] if self.db_lineedits else []
try:
step_val = float(self.step_input.text().strip())
except:
self.error_label.setText("Step value must be numeric.")
return
if not (1 <= step_val <= 180):
self.error_label.setText("Step is out of range (1-180).")
return
self.current_step = int(step_val)
self.prev_db_texts = prev_texts
self._create_db_rows_for_step(self.current_step, restore_prev=True)
self.error_label.clear()
def _on_mirror(self):
n = len(self.db_lineedits)
if n < 2:
self.error_label.setText("Not enough fields to mirror.")
return
texts = [le.text().strip() for le in self.db_lineedits]
for i in range(2, n + 1):
target = n - (i - 2)
if target <= i:
break
texts[target-1] = texts[i-1]
for idx, val in enumerate(texts):
self.db_lineedits[idx].setText(val)
self._attempt_plot()
def _on_save(self):
angles, values, err = self._validate_and_collect()
if err:
self.error_label.setText(err)
return
try:
matplotlib.use("Agg")
fig = Figure(figsize=PlotConfig.FIGURE_SIZE)
ax = fig.add_subplot(1,1,1,projection='polar', theta_offset=PlotConfig.THETA_OFFSET)
self.plotter.plot_on_axis(ax, angles, values)
filename, _ = QFileDialog.getSaveFileName(self, "Save Plot", "", "PNG (*.png);;JPG (*.jpg);;All Files (*)")
if filename:
fig.savefig(filename, dpi=150, bbox_inches='tight')
self.error_label.setText(f"Saved to {Path(filename).name}")
except Exception as e:
self.error_label.setText(f"Saving error: {e}")
finally:
matplotlib.use("QtAgg")
def main():
app = QApplication(sys.argv)
win = AntennaPlot()
win.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
@Onefabis
Copy link
Author

Вы можете сгенерировать .exe файл. Просто выполните команду pip install numpy scipy matplotlib PyQt6. Затем установите pyinstaller командой pip install pyinstaller и выполните команду pyinstaller --onefile --windowed antenna_plot_2.py

В поле "Step" (шаг поворота антенны) необходимо ввести значение от 1 до 180 градусов. Необходимо импортировать как минимум два значения в дБ, иначе график не будет построен.
Вы можете использовать кнопку "Mirror L to R" — она просто добавит ваши первые значения в дБ в обратном порядке в конец списка дБ и перерисует график.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment