Created
November 18, 2025 17:42
-
-
Save Onefabis/8d7ea1bb1cfcac90828cba9662794cba to your computer and use it in GitHub Desktop.
antenna plot for XY plane measurements with nanovna
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() |
Author
Author
Compiled .exe file is here - https://drive.google.com/file/d/1mIRidDbjz8svZOUpGXvjTezPPmLdWCGh/view?usp=sharing
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
You can generate .exe file. Just pip -install numpy, scipy, matplotlib and PyQt6. Then install with pip -install pyinstaller and run the command: pyinstaller --onefile --windowed antenna_plot_2.py
You need to enter 1-180 degree value in the "Step" field. You need to import at least two dB values, otherwise it will not draw the plot.
You can use "Mirror L to R" button, it will simply enter your first dB values in reversed order at the end of dB list and re-draw the plot.