From 105db62b4176ebb134f0c60426d2b35fb70423d0 Mon Sep 17 00:00:00 2001 From: Mike Kipps Date: Wed, 3 Jun 2026 07:06:08 -0400 Subject: [PATCH] Add a Yagi designer wizard, completing the five-wizard set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fifth design wizard, now that per-element Yagi control exists. The user picks a band, the number of elements, and the boom height; the wizard lays out a tapered beam — reflector + driven + directors that shorten progressively and sit at a widening spacing — and turns on per-element control with those elements. * results/yagi_design.py — the tapered layout (a stronger starting point than the uniform rules-of-thumb auto-design, though still not optimized). * ui/yagi_wizard.py — band + element count (2-10) + boom height. * MainWindow.apply_yagi_design — sets the Yagi, promotes to Advanced, fills the per-element editor, and checks the "Customise Yagi elements" box so the build uses the designed elements. Tests cover the layout and taper, the two-element case, the rejections, an end-to-end NEC solve with forward gain, the apply hook, and the dialog flow. Suite 504 -> 512. The design-wizard suite (trapped dipole, inverted-L, multiband vertical, fan dipole, Yagi) is now complete. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ars_wireworks/results/yagi_design.py | 110 ++++++++++++++++++++++ src/ars_wireworks/ui/main_window.py | 28 ++++++ src/ars_wireworks/ui/yagi_wizard.py | 90 ++++++++++++++++++ tests/test_yagi_wizard.py | 114 +++++++++++++++++++++++ 4 files changed, 342 insertions(+) create mode 100644 src/ars_wireworks/results/yagi_design.py create mode 100644 src/ars_wireworks/ui/yagi_wizard.py create mode 100644 tests/test_yagi_wizard.py diff --git a/src/ars_wireworks/results/yagi_design.py b/src/ars_wireworks/results/yagi_design.py new file mode 100644 index 0000000..1474110 --- /dev/null +++ b/src/ars_wireworks/results/yagi_design.py @@ -0,0 +1,110 @@ +"""Yagi-Uda design — architecture layer (d) (spec §8). + +Lays out a tapered Yagi: a reflector behind the driven element, then a run of +directors that shorten progressively and sit at a widening spacing along the +boom. That taper is what real multi-element Yagis use; it beats the uniform +rules-of-thumb auto-design as a starting point, though it is still a starting +design, not a numerically optimized one (optimization is out of scope, §16). + +The wizard in :mod:`ui.yagi_wizard` pours the result into the per-element Yagi +editor. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from ars_wireworks.model.antenna import ( + YagiElement, + YagiElementRole, + half_wave_length_m, +) +from ars_wireworks.model.constants import SPEED_OF_LIGHT_M_PER_S + +#: Reflector a touch longer than the driven element, behind it. +_REFLECTOR_LENGTH_FACTOR: float = 1.05 +_REFLECTOR_SPACING_WL: float = 0.15 + +#: Directors: the first sits close to the driven element, the rest at a wider +#: step; each is shorter than the last, down to a floor. +_FIRST_DIRECTOR_SPACING_WL: float = 0.10 +_DIRECTOR_SPACING_WL: float = 0.20 +_DIRECTOR_TAPER_PER_STEP: float = 0.01 +_DIRECTOR_MIN_FACTOR: float = 0.88 + +#: A sensible default boom height (metres). +DEFAULT_BOOM_HEIGHT_M: float = 12.0 + + +@dataclass(frozen=True) +class YagiDesign: + """A Yagi layout ready to drop into the per-element editor.""" + + design_frequency_hz: float + boom_height_m: float + elements: tuple[YagiElement, ...] + + @property + def boom_length_m(self) -> float: + """Front-to-back boom span across all elements.""" + positions = [element.position_m for element in self.elements] + return max(positions) - min(positions) + + +def design_yagi( + *, + band_centre_mhz: float, + element_count: int, + boom_height_m: float = DEFAULT_BOOM_HEIGHT_M, +) -> YagiDesign: + """Lay out a tapered ``element_count``-element Yagi for ``band_centre_mhz``. + + Reflector + driven + (``element_count`` − 2) directors. Raises + :class:`ValueError` for fewer than two elements or non-positive inputs. + """ + if element_count < 2: + raise ValueError("a Yagi needs at least two elements") + if band_centre_mhz <= 0.0: + raise ValueError("the band centre must be positive") + if boom_height_m <= 0.0: + raise ValueError("the boom height must be positive") + + frequency_hz = band_centre_mhz * 1e6 + wavelength_m = SPEED_OF_LIGHT_M_PER_S / frequency_hz + driven_length = half_wave_length_m(frequency_hz) + + elements = [ + YagiElement( + role=YagiElementRole.REFLECTOR, + length_m=_REFLECTOR_LENGTH_FACTOR * driven_length, + position_m=-_REFLECTOR_SPACING_WL * wavelength_m, + ), + YagiElement( + role=YagiElementRole.DRIVEN, + length_m=driven_length, + position_m=0.0, + ), + ] + + position = 0.0 + for index in range(element_count - 2): + if index == 0: + position = _FIRST_DIRECTOR_SPACING_WL * wavelength_m + else: + position += _DIRECTOR_SPACING_WL * wavelength_m + factor = max( + _DIRECTOR_MIN_FACTOR, 0.95 - _DIRECTOR_TAPER_PER_STEP * index + ) + elements.append( + YagiElement( + role=YagiElementRole.DIRECTOR, + length_m=factor * driven_length, + position_m=position, + ) + ) + + return YagiDesign( + design_frequency_hz=frequency_hz, + boom_height_m=boom_height_m, + elements=tuple(elements), + ) diff --git a/src/ars_wireworks/ui/main_window.py b/src/ars_wireworks/ui/main_window.py index 9391ca9..efb5de4 100644 --- a/src/ars_wireworks/ui/main_window.py +++ b/src/ars_wireworks/ui/main_window.py @@ -399,6 +399,9 @@ def _build_menu_bar(self) -> None: self._open_fan_dipole_wizard ) wizards_menu.addAction(fan_dipole_wizard_action) + yagi_wizard_action = QAction("Yagi designer…", self) + yagi_wizard_action.triggered.connect(self._open_yagi_wizard) + wizards_menu.addAction(yagi_wizard_action) glossary_action = QAction("Glossary…", self) glossary_action.triggered.connect(self._open_glossary) @@ -689,6 +692,31 @@ def apply_fan_dipole_design(self, design) -> None: self._fan_height.set_metres(design.height_m) self._fan_dipole_editor.set_legs(list(design.legs)) + def _open_yagi_wizard(self) -> None: + """Open the Yagi designer and apply its design (spec §8).""" + from ars_wireworks.ui.yagi_wizard import YagiWizard + + YagiWizard( + on_apply=self.apply_yagi_design, + unit_system=self._prefs.unit_system, + parent=self, + ).exec() + + def apply_yagi_design(self, design) -> None: + """Drop the wizard's design into the per-element Yagi editor. + + Switches to a Yagi, sets the design frequency and boom height, promotes + to Advanced Mode, and turns on per-element control with the designed + elements (so the build uses them rather than the auto-design). + """ + _select_by_data(self._antenna_type, "yagi") + self._frequency.setValue(design.design_frequency_hz / 1e6) + self._yagi_boom_height.set_metres(design.boom_height_m) + if not self._advanced: + self._silently_promote_to_advanced() + self._yagi_elements_editor.set_elements(list(design.elements)) + self._advanced_yagi_box.setChecked(True) + def _start_lesson(self, lesson) -> None: """Begin running ``lesson`` in the lesson dock.""" self._lesson = LessonInterpreter(lesson, self) diff --git a/src/ars_wireworks/ui/yagi_wizard.py b/src/ars_wireworks/ui/yagi_wizard.py new file mode 100644 index 0000000..088329a --- /dev/null +++ b/src/ars_wireworks/ui/yagi_wizard.py @@ -0,0 +1,90 @@ +"""Yagi designer wizard dialog — architecture layer (f) (spec §8). + +The user picks a band, the number of elements, and the boom height; the wizard +lays out a tapered Yagi (reflector + driven + directors that shorten and widen +along the boom) and fills the per-element Yagi editor. The layout lives in +:mod:`results.yagi_design`; this is the UI shell. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from PySide6.QtWidgets import QComboBox, QFormLayout, QSpinBox, QVBoxLayout + +from ars_wireworks.results.trap_design import STANDARD_BAND_CENTRES_MHZ +from ars_wireworks.results.yagi_design import ( + DEFAULT_BOOM_HEIGHT_M, + YagiDesign, + design_yagi, +) +from ars_wireworks.ui.units import LengthSpinBox +from ars_wireworks.ui.wizard_base import WizardDialog +from ars_wireworks.units import UnitSystem + +_INTRO = ( + "I will design a tapered Yagi: a reflector, the driven element, and a run " + "of directors that shorten and spread out along the boom. It is a strong " + "starting point you can then tune element by element — not a numerically " + "optimized design." +) + +_DEFAULT_BAND = "20 m" +_MIN_ELEMENTS = 2 +_MAX_ELEMENTS = 10 + + +class YagiWizard(WizardDialog): + """Dialog that designs a tapered Yagi (spec §8).""" + + def __init__( + self, + *, + on_apply: Callable[[YagiDesign], None], + unit_system: UnitSystem, + parent=None, + ) -> None: + self._unit_system = unit_system + super().__init__( + title="Yagi designer", + intro=_INTRO, + on_apply=on_apply, + parent=parent, + ) + + def build_body(self, layout: QVBoxLayout) -> None: + self._band = QComboBox() + for name, mhz in STANDARD_BAND_CENTRES_MHZ: + self._band.addItem(f"{name} ({mhz:.3f} MHz)", mhz) + default_index = next( + ( + i + for i, (name, _) in enumerate(STANDARD_BAND_CENTRES_MHZ) + if name == _DEFAULT_BAND + ), + 0, + ) + self._band.setCurrentIndex(default_index) + + self._elements = QSpinBox() + self._elements.setRange(_MIN_ELEMENTS, _MAX_ELEMENTS) + self._elements.setValue(3) + self._elements.setSuffix(" elements") + + self._boom_height = LengthSpinBox( + min_m=1.0, max_m=300.0, value_m=DEFAULT_BOOM_HEIGHT_M, + system=self._unit_system, + ) + + form = QFormLayout() + form.addRow("Band", self._band) + form.addRow("Elements", self._elements) + form.addRow("Boom height", self._boom_height) + layout.addLayout(form) + + def compute_design(self) -> YagiDesign: + return design_yagi( + band_centre_mhz=float(self._band.currentData()), + element_count=self._elements.value(), + boom_height_m=self._boom_height.metres(), + ) diff --git a/tests/test_yagi_wizard.py b/tests/test_yagi_wizard.py new file mode 100644 index 0000000..0ab6048 --- /dev/null +++ b/tests/test_yagi_wizard.py @@ -0,0 +1,114 @@ +"""Tests for the Yagi designer wizard (results.yagi_design + UI).""" + +from __future__ import annotations + +import pytest + +from ars_wireworks.model.antenna import YagiElementRole +from ars_wireworks.results.yagi_design import YagiDesign, design_yagi + +R = YagiElementRole + + +# --- design algorithm ------------------------------------------------------- + + +def test_layout_is_reflector_driven_then_directors() -> None: + design = design_yagi(band_centre_mhz=14.175, element_count=4) + assert isinstance(design, YagiDesign) + roles = [element.role for element in design.elements] + assert roles == [R.REFLECTOR, R.DRIVEN, R.DIRECTOR, R.DIRECTOR] + assert design.design_frequency_hz == pytest.approx(14.175e6) + + +def test_directors_taper_shorter_and_spread_along_the_boom() -> None: + design = design_yagi(band_centre_mhz=14.175, element_count=5) + directors = [e for e in design.elements if e.role is R.DIRECTOR] + lengths = [d.length_m for d in directors] + positions = [d.position_m for d in directors] + # each director is shorter than the last and further out + assert lengths == sorted(lengths, reverse=True) + assert positions == sorted(positions) + assert len(set(lengths)) == len(lengths) # genuinely tapered + + +def test_reflector_is_behind_and_longer_than_the_driven() -> None: + design = design_yagi(band_centre_mhz=14.175, element_count=3) + reflector = design.elements[0] + driven = design.elements[1] + assert reflector.position_m < 0.0 < design.elements[2].position_m + assert reflector.length_m > driven.length_m + + +def test_two_element_beam_has_no_directors() -> None: + design = design_yagi(band_centre_mhz=14.175, element_count=2) + roles = [e.role for e in design.elements] + assert roles == [R.REFLECTOR, R.DRIVEN] + assert design.boom_length_m > 0.0 + + +def test_invalid_inputs_are_rejected() -> None: + with pytest.raises(ValueError, match="at least two elements"): + design_yagi(band_centre_mhz=14.175, element_count=1) + with pytest.raises(ValueError): + design_yagi(band_centre_mhz=0.0, element_count=3) + with pytest.raises(ValueError): + design_yagi(band_centre_mhz=14.175, element_count=3, boom_height_m=0.0) + + +# --- the design builds and solves ------------------------------------------ + + +def test_designed_yagi_solves_with_forward_gain() -> None: + from ars_wireworks.model.antenna import YagiModel + from ars_wireworks.solver.necpp import NecppSolver + + design = design_yagi(band_centre_mhz=14.175, element_count=4) + model = YagiModel( + frequency_hz=design.design_frequency_hz, + boom_height_m=design.boom_height_m, + elements=design.elements, + ) + results = NecppSolver().solve(model, model.frequency_hz) + assert results.max_gain_dbi > 6.0 # a real beam, more than a dipole + assert results.front_to_back_db is not None + + +# --- MainWindow apply hook -------------------------------------------------- + + +def test_apply_turns_on_custom_yagi_with_the_design(qapp) -> None: + from ars_wireworks.ui.main_window import MainWindow + + window = MainWindow() + design = design_yagi(band_centre_mhz=14.175, element_count=5) + window.apply_yagi_design(design) + + assert window._antenna_type.currentData() == "yagi" + assert window._frequency.value() == pytest.approx(14.175) + assert window._advanced is True + assert window._advanced_yagi_box.isChecked() + assert len(window._yagi_elements_editor.elements()) == 5 + + # the build uses the designed elements, not the auto-design + model = window._build_model() + assert model.elements is not None + assert model.total_element_count == 5 + + +# --- dialog ----------------------------------------------------------------- + + +def test_wizard_apply_invokes_callback_with_a_design(qapp) -> None: + from ars_wireworks.ui.yagi_wizard import YagiWizard + from ars_wireworks.units import UnitSystem + + captured: list = [] + wizard = YagiWizard( + on_apply=captured.append, unit_system=UnitSystem.METRIC + ) + wizard._on_apply_clicked() + assert len(captured) == 1 + assert isinstance(captured[0], YagiDesign) + # default is a 3-element beam + assert len(captured[0].elements) == 3