Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions src/ars_wireworks/results/yagi_design.py
Original file line number Diff line number Diff line change
@@ -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),
)
28 changes: 28 additions & 0 deletions src/ars_wireworks/ui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
90 changes: 90 additions & 0 deletions src/ars_wireworks/ui/yagi_wizard.py
Original file line number Diff line number Diff line change
@@ -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(),
)
114 changes: 114 additions & 0 deletions tests/test_yagi_wizard.py
Original file line number Diff line number Diff line change
@@ -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
Loading