From f9310276b25ec5ca84567003b077ce3bd2cb4084 Mon Sep 17 00:00:00 2001 From: Mike Kipps Date: Tue, 2 Jun 2026 20:01:36 -0400 Subject: [PATCH] Add per-element Yagi control (Advanced Mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Yagi was auto-designed only: element_count drove rules-of-thumb lengths and 0.20-wavelength spacing. This adds optional per-element geometry. * model/antenna.py — YagiElementRole + YagiElement (role, length, boom position), and an optional YagiModel.elements. When set it overrides the auto-design; validated for exactly one driven element, at most one reflector, >= 2 elements, and distinct positions. element_count is then ignored, so a custom beam is not capped at six. director_count and a new total_element_count cover both paths. * cards/yagi.py — refactored around resolve_yagi_elements(): auto-design or explicit, both producing the same element list. auto_yagi_elements() is exposed so the UI can seed the editor. The 1=driven / 2=reflector / 3+=director tag scheme is preserved, so per-element loading still targets the right wire and the feed stays on the driven element. * ui/yagi_elements_editor.py — an add/remove editor (role, length, boom position), shown in a checkable "Customise Yagi elements" Advanced box. Enabling it seeds from the auto-design so the user tweaks a working beam. * main_window.py — the box, build-model branch, capture/restore, and unit propagation. Existing Yagi and per-element-loading tests are unchanged (auto-design path is byte-for-byte the same). New tests cover the model rules, the auto/explicit resolver, the preserved tags, a custom NEC solve, and the UI seed/build/ round-trip. Suite 493 -> 504. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ars_wireworks/cards/yagi.py | 141 ++++++++++---- src/ars_wireworks/model/antenna.py | 75 +++++++- src/ars_wireworks/ui/main_window.py | 71 +++++++ src/ars_wireworks/ui/yagi_elements_editor.py | 177 ++++++++++++++++++ tests/test_yagi_per_element.py | 187 +++++++++++++++++++ 5 files changed, 603 insertions(+), 48 deletions(-) create mode 100644 src/ars_wireworks/ui/yagi_elements_editor.py create mode 100644 tests/test_yagi_per_element.py diff --git a/src/ars_wireworks/cards/yagi.py b/src/ars_wireworks/cards/yagi.py index 2ea2384..6d7eeea 100644 --- a/src/ars_wireworks/cards/yagi.py +++ b/src/ars_wireworks/cards/yagi.py @@ -1,8 +1,10 @@ """NEC-2 card-deck generation for a Yagi-Uda beam — layer (b) (spec §10). -Element lengths and spacing are auto-designed from standard rules of thumb. -True optimization is out of scope for v1 (spec §16), and per-element control -is a later Advanced Mode feature. +By default element lengths and spacing are auto-designed from standard rules +of thumb. Advanced Mode (spec §8) lets the user override every element's +length and boom position via ``YagiModel.elements``; this builder resolves +either source to the same element list, so the rest of the deck — tags, feed, +loading — is identical. True optimization is out of scope for v1 (spec §16). """ from __future__ import annotations @@ -17,7 +19,11 @@ wire_segment_count, ) from ars_wireworks.cards.deck import Card, CardDeck -from ars_wireworks.model.antenna import YagiModel +from ars_wireworks.model.antenna import ( + YagiElement, + YagiElementRole, + YagiModel, +) from ars_wireworks.model.engine_choice import EngineChoice #: The reflector runs a few percent longer than the driven element, the @@ -37,6 +43,53 @@ _FIRST_DIRECTOR_TAG: int = 3 +def auto_yagi_elements(model: YagiModel) -> tuple[YagiElement, ...]: + """The auto-designed elements for ``model`` — the rules-of-thumb beam. + + Exposed so the UI can seed a per-element editor with the defaults the + program would otherwise compute. + """ + spacing = ELEMENT_SPACING_WAVELENGTHS * model.wavelength_m + driven_length = model.length_m + elements = [ + YagiElement( + role=YagiElementRole.REFLECTOR, + length_m=REFLECTOR_LENGTH_FACTOR * driven_length, + position_m=-spacing, + ), + YagiElement( + role=YagiElementRole.DRIVEN, + length_m=driven_length, + position_m=0.0, + ), + ] + for index in range(model.element_count - 2): + elements.append( + YagiElement( + role=YagiElementRole.DIRECTOR, + length_m=DIRECTOR_LENGTH_FACTOR * driven_length, + position_m=(index + 1) * spacing, + ) + ) + return tuple(elements) + + +def resolve_yagi_elements(model: YagiModel) -> tuple[YagiElement, ...]: + """The model's explicit elements if set, else the auto-designed ones.""" + if model.elements is not None: + return model.elements + return auto_yagi_elements(model) + + +def _tag_for(element: YagiElement, directors: list[YagiElement]) -> int: + """The NEC tag for ``element`` — preserves 1=driven, 2=reflector, 3+=dir.""" + if element.role is YagiElementRole.DRIVEN: + return DRIVEN_TAG + if element.role is YagiElementRole.REFLECTOR: + return REFLECTOR_TAG + return _FIRST_DIRECTOR_TAG + directors.index(element) + + def build_yagi_deck( model: YagiModel, frequency_hz: float ) -> tuple[CardDeck, list[EngineChoice]]: @@ -50,51 +103,53 @@ def build_yagi_deck( radius = model.wire.radius_m boom_z = model.boom_height_m - spacing = ELEMENT_SPACING_WAVELENGTHS * model.wavelength_m - - driven_length = model.length_m - reflector_length = REFLECTOR_LENGTH_FACTOR * driven_length - director_length = DIRECTOR_LENGTH_FACTOR * driven_length + elements = resolve_yagi_elements(model) + # Directors get tags 3, 4, ... in the order they appear. + directors = [ + element + for element in elements + if element.role is YagiElementRole.DIRECTOR + ] - def element(tag: int, length: float, y: float) -> tuple[Card, int]: - """A horizontal element of ``length`` at boom position ``y``.""" + def element_card(element: YagiElement) -> tuple[Card, int]: + """The (GW card, segment count) for one element.""" + tag = _tag_for(element, directors) segments = wire_segment_count( - length, model.wavelength_m, - minimum=MIN_ELEMENT_SEGMENTS, force_odd=True, + element.length_m, + model.wavelength_m, + minimum=MIN_ELEMENT_SEGMENTS, + force_odd=True, density=model.segments_per_wavelength, ) - half = length / 2.0 + half = element.length_m / 2.0 card = Card( "GW", integers=(tag, segments), - reals=(-half, y, boom_z, half, y, boom_z, radius), + reals=( + -half, element.position_m, boom_z, + half, element.position_m, boom_z, + radius, + ), ) return card, segments - # Driven element at Y = 0; reflector behind it; directors in front. - driven_card, driven_segments = element(DRIVEN_TAG, driven_length, 0.0) - reflector_card, _ = element(REFLECTOR_TAG, reflector_length, -spacing) - cards: list[Card] = [ Card("CM", comment="ARS WireWorks - Yagi-Uda beam"), Card( "CM", comment=( f"Design {model.frequency_hz / 1e6:.4g} MHz, " - f"{model.element_count} elements, boom {boom_z:.3f} m" + f"{model.total_element_count} elements, boom {boom_z:.3f} m" ), ), Card("CE"), - driven_card, - reflector_card, ] - for index in range(model.director_count): - director_card, _ = element( - _FIRST_DIRECTOR_TAG + index, - director_length, - (index + 1) * spacing, - ) - cards.append(director_card) + driven_segments = 0 + for element in elements: + card, segments = element_card(element) + cards.append(card) + if element.role is YagiElementRole.DRIVEN: + driven_segments = segments cards.append(geometry_end_card()) ground, ground_choice = ground_card(model) @@ -104,18 +159,24 @@ def element(tag: int, length: float, y: float) -> tuple[Card, int]: cards.append(radiation_pattern_card(model)) cards.append(Card("EN")) + if model.elements is None: + element_explanation = ( + f"I auto-sized the {model.total_element_count} elements: a " + f"reflector {(REFLECTOR_LENGTH_FACTOR - 1) * 100:.0f}% longer than " + f"the driven element, directors " + f"{(1 - DIRECTOR_LENGTH_FACTOR) * 100:.0f}% shorter, spaced " + f"{ELEMENT_SPACING_WAVELENGTHS:g} wavelengths apart. These are " + f"starting-point rules of thumb, not an optimized design." + ) + else: + element_explanation = ( + f"I built the {model.total_element_count} elements from the " + f"lengths and boom positions you set, rather than the " + f"rules-of-thumb auto-design." + ) + choices: list[EngineChoice] = [ - EngineChoice( - topic="Element design", - explanation=( - f"I auto-sized the {model.element_count} elements: a reflector " - f"{(REFLECTOR_LENGTH_FACTOR - 1) * 100:.0f}% longer than the " - f"driven element, directors " - f"{(1 - DIRECTOR_LENGTH_FACTOR) * 100:.0f}% shorter, spaced " - f"{ELEMENT_SPACING_WAVELENGTHS:g} wavelengths apart. These are " - f"starting-point rules of thumb, not an optimized design." - ), - ), + EngineChoice(topic="Element design", explanation=element_explanation), EngineChoice( topic="Feedpoint", explanation=( diff --git a/src/ars_wireworks/model/antenna.py b/src/ars_wireworks/model/antenna.py index ddaeb14..65aeb1c 100644 --- a/src/ars_wireworks/model/antenna.py +++ b/src/ars_wireworks/model/antenna.py @@ -10,6 +10,7 @@ import math from dataclasses import dataclass +from enum import Enum from ars_wireworks.model.constants import ( DIPOLE_END_EFFECT_FACTOR, @@ -349,31 +350,89 @@ def radiator_length_m(self) -> float: return self.length_m / 2.0 +class YagiElementRole(Enum): + """The role of one Yagi element on the boom.""" + + REFLECTOR = "reflector" + DRIVEN = "driven" + DIRECTOR = "director" + + +@dataclass(frozen=True) +class YagiElement: + """One element of a Yagi, placed by the user (Advanced Mode, spec §8). + + ``position_m`` is the element's place along the boom (the Y axis); the + pattern depends only on the spacings between elements, so the driven + element conventionally sits at 0, the reflector behind it (negative), and + the directors in front (positive). + """ + + role: YagiElementRole + length_m: float + position_m: float + + def __post_init__(self) -> None: + if self.length_m <= 0.0: + raise ValueError("a Yagi element's length must be positive") + + @dataclass(kw_only=True) class YagiModel(AntennaModel): - """A horizontally-polarized Yagi-Uda beam of 2 to 6 elements. + """A horizontally-polarized Yagi-Uda beam. Elements run east-west (along X) and are spaced along the boom (the Y - axis) at a constant height. The set is one reflector, one driven element, - and ``element_count - 2`` directors. Element lengths and spacing are - auto-designed from rules of thumb by the card-deck builder; per-element - control is an Advanced Mode feature for a later session. + axis) at a constant height. By default the set is one reflector, one + driven element, and ``element_count - 2`` directors, auto-designed from + rules of thumb by the card-deck builder. + + Setting ``elements`` overrides the auto-design with the user's own + per-element lengths and boom positions (Advanced Mode, spec §8); exactly + one must be the driven element and at most one a reflector. ``element_count`` + is then ignored. """ boom_height_m: float element_count: int = 3 + #: Advanced Mode — explicit per-element geometry; ``None`` auto-designs. + elements: tuple[YagiElement, ...] | None = None def __post_init__(self) -> None: super().__post_init__() if self.boom_height_m <= 0.0: raise ValueError("boom_height_m must be positive") - if not 2 <= self.element_count <= 6: - raise ValueError("element_count must be between 2 and 6") + if self.elements is None: + if not 2 <= self.element_count <= 6: + raise ValueError("element_count must be between 2 and 6") + return + roles = [element.role for element in self.elements] + if len(self.elements) < 2: + raise ValueError("a Yagi needs at least two elements") + if roles.count(YagiElementRole.DRIVEN) != 1: + raise ValueError("a Yagi needs exactly one driven element") + if roles.count(YagiElementRole.REFLECTOR) > 1: + raise ValueError("a Yagi can have at most one reflector") + positions = [element.position_m for element in self.elements] + if len(set(positions)) != len(positions): + raise ValueError("two elements share the same boom position") @property def director_count(self) -> int: """Number of directors — elements beyond the reflector and driven.""" - return self.element_count - 2 + if self.elements is None: + return self.element_count - 2 + return sum( + 1 + for element in self.elements + if element.role is YagiElementRole.DIRECTOR + ) + + @property + def total_element_count(self) -> int: + """How many elements the beam has, auto-designed or explicit.""" + if self.elements is None: + return self.element_count + return len(self.elements) @dataclass(kw_only=True) diff --git a/src/ars_wireworks/ui/main_window.py b/src/ars_wireworks/ui/main_window.py index b34b992..9391ca9 100644 --- a/src/ars_wireworks/ui/main_window.py +++ b/src/ars_wireworks/ui/main_window.py @@ -59,6 +59,8 @@ RandomWireModel, RhombicModel, VerticalModel, + YagiElement, + YagiElementRole, YagiModel, ) from ars_wireworks.model.feedline import FeedLine @@ -103,6 +105,7 @@ from ars_wireworks.ui.path_view import PathView from ars_wireworks.ui.radial_editor import RadialGroupsEditor from ars_wireworks.ui.radial_wires_editor import RadialWiresEditor +from ars_wireworks.ui.yagi_elements_editor import YagiElementsEditor from ars_wireworks.ui.report_view import ReportView from ars_wireworks.ui.smith_view import SmithChartView from ars_wireworks.ui.sweep_view import SweepView @@ -531,6 +534,7 @@ def _promote_to_advanced(self) -> None: self._advanced_coils_box.setVisible(True) self._advanced_traps_box.setVisible(True) self._advanced_radials_box.setVisible(True) + self._advanced_yagi_box.setVisible(True) self._advanced_path_box.setVisible(True) centre = self._frequency.value() self._sweep_start.setValue(centre * 0.95) @@ -1333,6 +1337,21 @@ def _build_central_widget(self) -> QWidget: self._advanced_radials_box.setLayout(adv_radials_layout) self._advanced_radials_box.setVisible(False) + # Advanced Mode — per-element Yagi geometry (spec §8); hidden until + # promoted. Checking it overrides the rules-of-thumb auto-design with + # the user's own element lengths and boom positions. + self._yagi_elements_editor = YagiElementsEditor( + self._prefs.unit_system + ) + adv_yagi_layout = QVBoxLayout() + adv_yagi_layout.addWidget(self._yagi_elements_editor) + self._advanced_yagi_box = QGroupBox("Customise Yagi elements") + self._advanced_yagi_box.setCheckable(True) + self._advanced_yagi_box.setChecked(False) + self._advanced_yagi_box.setLayout(adv_yagi_layout) + self._advanced_yagi_box.setVisible(False) + self._advanced_yagi_box.toggled.connect(self._on_yagi_custom_toggled) + # Advanced Mode — custom path for single-wire antennas (spec §8). self._advanced_path = PathEditor( self._prefs.unit_system, show_feedpoint=False @@ -1359,6 +1378,7 @@ def _build_central_widget(self) -> QWidget: inputs_layout.addWidget(self._advanced_coils_box) inputs_layout.addWidget(self._advanced_traps_box) inputs_layout.addWidget(self._advanced_radials_box) + inputs_layout.addWidget(self._advanced_yagi_box) inputs_layout.addWidget(self._advanced_path_box) run_row = QHBoxLayout() run_row.addWidget(self._run) @@ -1498,6 +1518,7 @@ def _apply_preferences(self, prefs: Preferences) -> None: field.set_unit_system(prefs.unit_system) self._radial_editor.set_unit_system(prefs.unit_system) self._radial_wires_editor.set_unit_system(prefs.unit_system) + self._yagi_elements_editor.set_unit_system(prefs.unit_system) self._efhw_path.set_unit_system(prefs.unit_system) self._il_path.set_unit_system(prefs.unit_system) self._il_radial_editor.set_unit_system(prefs.unit_system) @@ -1602,6 +1623,15 @@ def _capture_state(self) -> dict: } for leg in self._fan_dipole_editor.legs() ], + "yagi_custom_on": self._advanced_yagi_box.isChecked(), + "yagi_elements": [ + { + "role": element.role.value, + "length_m": element.length_m, + "position_m": element.position_m, + } + for element in self._yagi_elements_editor.elements() + ], # Phase 7/10: loading coils and traps live in the Advanced # editors. Capture them as plain dicts so example projects can # ship a pre-loaded coil or trap (spec §14.7 lines 483-484). @@ -1671,6 +1701,19 @@ def _restore_state(self, state: dict) -> None: self._fan_dipole_editor.set_legs( [FanDipoleLeg(**leg) for leg in fan_legs] ) + yagi_elements = state.get("yagi_elements") + if yagi_elements: + self._yagi_elements_editor.set_elements( + [ + YagiElement( + role=YagiElementRole(element["role"]), + length_m=element["length_m"], + position_m=element["position_m"], + ) + for element in yagi_elements + ] + ) + self._advanced_yagi_box.setChecked(bool(state.get("yagi_custom_on"))) self._advanced_path.load_point_dicts(state.get("advanced_path", [])) self._advanced_path_box.setChecked( bool(state.get("advanced_path_on")) @@ -1699,6 +1742,7 @@ def _silently_promote_to_advanced(self) -> None: self._advanced_coils_box.setVisible(True) self._advanced_traps_box.setVisible(True) self._advanced_radials_box.setVisible(True) + self._advanced_yagi_box.setVisible(True) self._advanced_path_box.setVisible(True) def _reset_loaded_components( @@ -2031,6 +2075,27 @@ def _apply_antenna_defaults(self) -> None: if index >= 0: self._matching.setCurrentIndex(index) + def _on_yagi_custom_toggled(self, checked: bool) -> None: + """Seed the per-element editor from the auto-design when first enabled. + + Starting from the rules-of-thumb lengths and spacings the program + would otherwise compute lets the user tweak a working beam rather than + an empty table. + """ + if not checked or not self._yagi_elements_editor.is_empty(): + return + from ars_wireworks.cards.yagi import auto_yagi_elements + + model = YagiModel( + frequency_hz=self._frequency.value() * 1e6, + wire=self._wire.currentData(), + boom_height_m=self._yagi_boom_height.metres(), + element_count=self._yagi_element_count.value(), + ) + self._yagi_elements_editor.set_elements( + list(auto_yagi_elements(model)) + ) + def _build_model(self) -> AntennaModel: """Construct the antenna model for the current selections. @@ -2116,10 +2181,16 @@ def _build_model(self) -> AntennaModel: **loaded, ) if kind == "yagi": + custom_elements = ( + self._yagi_elements_editor.elements() + if self._advanced and self._advanced_yagi_box.isChecked() + else None + ) return YagiModel( **common, boom_height_m=self._yagi_boom_height.metres(), element_count=self._yagi_element_count.value(), + elements=custom_elements, **loaded, ) if kind == "horizontal_loop": diff --git a/src/ars_wireworks/ui/yagi_elements_editor.py b/src/ars_wireworks/ui/yagi_elements_editor.py new file mode 100644 index 0000000..9a30453 --- /dev/null +++ b/src/ars_wireworks/ui/yagi_elements_editor.py @@ -0,0 +1,177 @@ +"""Per-element Yagi editor — architecture layer (f) (spec §8, Advanced Mode). + +A Yagi's elements are entered as rows of (role, length, boom position). The +driven element is fed; a reflector sits behind it and directors in front, but +the user is free to set any lengths and spacings. This widget adds and removes +elements; the model checks the one-driven / at-most-one-reflector rules. +""" + +from __future__ import annotations + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QComboBox, + QHBoxLayout, + QLabel, + QPushButton, + QVBoxLayout, + QWidget, +) + +from ars_wireworks.model.antenna import YagiElement, YagiElementRole +from ars_wireworks.ui.units import LengthSpinBox +from ars_wireworks.units import UnitSystem + +#: A Yagi needs at least two elements to be a beam. +_MIN_ELEMENTS = 2 + +#: Width reserved for a row's remove button and the header's blank column. +_REMOVE_WIDTH = 30 + + +class YagiElementsEditor(QWidget): + """Add/remove editor for a Yagi's per-element geometry.""" + + def __init__(self, system: UnitSystem) -> None: + super().__init__() + self._system = system + self._rows: list[_ElementRow] = [] + + self._rows_layout = QVBoxLayout() + self._rows_layout.setContentsMargins(0, 0, 0, 0) + self._rows_layout.setSpacing(4) + + add_button = QPushButton("Add element") + add_button.clicked.connect(self._on_add_clicked) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addLayout(_header()) + layout.addLayout(self._rows_layout) + layout.addWidget(add_button) + + def elements(self) -> tuple[YagiElement, ...]: + """The elements currently entered.""" + return tuple(row.to_element() for row in self._rows) + + def is_empty(self) -> bool: + """Whether no elements have been added yet.""" + return not self._rows + + def set_unit_system(self, system: UnitSystem) -> None: + """Switch every length field to ``system``.""" + self._system = system + for row in self._rows: + row.set_unit_system(system) + + def add_element( + self, + *, + role: YagiElementRole = YagiElementRole.DIRECTOR, + length_m: float = 5.0, + position_m: float = 0.0, + ) -> None: + """Append an element row.""" + row = _ElementRow(self._system, role, length_m, position_m) + row.remove_requested.connect(lambda: self._remove_row(row)) + self._rows.append(row) + self._rows_layout.addWidget(row) + self._refresh_remove_buttons() + + def set_elements(self, elements: list[YagiElement]) -> None: + """Replace every element with ``elements``.""" + for row in list(self._rows): + self._rows.remove(row) + self._rows_layout.removeWidget(row) + row.deleteLater() + for element in elements: + self.add_element( + role=element.role, + length_m=element.length_m, + position_m=element.position_m, + ) + + def _on_add_clicked(self) -> None: + self.add_element() + + def _remove_row(self, row: _ElementRow) -> None: + if len(self._rows) <= _MIN_ELEMENTS: + return + self._rows.remove(row) + self._rows_layout.removeWidget(row) + row.deleteLater() + self._refresh_remove_buttons() + + def _refresh_remove_buttons(self) -> None: + removable = len(self._rows) > _MIN_ELEMENTS + for row in self._rows: + row.set_removable(removable) + + +class _ElementRow(QWidget): + """One element: a role, a length, a boom position, and a remove button.""" + + remove_requested = Signal() + + def __init__( + self, + system: UnitSystem, + role: YagiElementRole, + length_m: float, + position_m: float, + ) -> None: + super().__init__() + self._role = QComboBox() + for member in YagiElementRole: + self._role.addItem(member.value.capitalize(), member) + self._role.setCurrentIndex(self._role.findData(role)) + + self._length = LengthSpinBox( + min_m=0.1, max_m=300.0, value_m=length_m, system=system + ) + # Boom position runs both ways from the driven element (reflector + # behind is negative, directors in front positive). + self._position = LengthSpinBox( + min_m=-300.0, max_m=300.0, value_m=position_m, system=system + ) + + self._remove = QPushButton("✕") + self._remove.setObjectName("yagiElementRemoveButton") + self._remove.setFixedWidth(_REMOVE_WIDTH) + self._remove.setToolTip("Remove this element") + self._remove.clicked.connect(lambda: self.remove_requested.emit()) + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._role, stretch=1) + layout.addWidget(self._length, stretch=1) + layout.addWidget(self._position, stretch=1) + layout.addWidget(self._remove) + + def to_element(self) -> YagiElement: + """The :class:`YagiElement` for this row, in SI units.""" + return YagiElement( + role=self._role.currentData(), + length_m=self._length.metres(), + position_m=self._position.metres(), + ) + + def set_unit_system(self, system: UnitSystem) -> None: + """Switch the length and position fields to ``system``.""" + self._length.set_unit_system(system) + self._position.set_unit_system(system) + + def set_removable(self, removable: bool) -> None: + """Enable or disable the remove button.""" + self._remove.setEnabled(removable) + + +def _header() -> QHBoxLayout: + """The column-header row: Role, Length, Boom position.""" + header = QHBoxLayout() + for title in ("Role", "Length", "Boom position"): + header.addWidget(QLabel(title), stretch=1) + spacer = QWidget() + spacer.setFixedWidth(_REMOVE_WIDTH) + header.addWidget(spacer) + return header diff --git a/tests/test_yagi_per_element.py b/tests/test_yagi_per_element.py new file mode 100644 index 0000000..8d2acc4 --- /dev/null +++ b/tests/test_yagi_per_element.py @@ -0,0 +1,187 @@ +"""Tests for per-element Yagi control (model, card builder, and UI).""" + +from __future__ import annotations + +import pytest + +from ars_wireworks.cards.yagi import ( + DRIVEN_TAG, + REFLECTOR_TAG, + auto_yagi_elements, + build_yagi_deck, + resolve_yagi_elements, +) +from ars_wireworks.model.antenna import ( + YagiElement, + YagiElementRole, + YagiModel, +) +from ars_wireworks.solver.necpp import NecppSolver + +R = YagiElementRole + + +def _custom_elements(wavelength_m: float) -> tuple[YagiElement, ...]: + half = wavelength_m / 2.0 + return ( + YagiElement(role=R.REFLECTOR, length_m=1.05 * half, position_m=-0.15 * wavelength_m), + YagiElement(role=R.DRIVEN, length_m=0.97 * half, position_m=0.0), + YagiElement(role=R.DIRECTOR, length_m=0.90 * half, position_m=0.12 * wavelength_m), + ) + + +# --- model ------------------------------------------------------------------ + + +def test_element_rejects_non_positive_length() -> None: + with pytest.raises(ValueError): + YagiElement(role=R.DRIVEN, length_m=0.0, position_m=0.0) + + +def test_auto_design_still_validates_element_count() -> None: + with pytest.raises(ValueError, match="between 2 and 6"): + YagiModel(frequency_hz=14e6, boom_height_m=10.0, element_count=7) + # but a custom set is not capped at six + wl = 21.3 + many = tuple( + [YagiElement(role=R.REFLECTOR, length_m=10.0, position_m=-4.0), + YagiElement(role=R.DRIVEN, length_m=10.0, position_m=0.0)] + + [YagiElement(role=R.DIRECTOR, length_m=9.0, position_m=4.0 + i) + for i in range(6)] + ) + model = YagiModel(frequency_hz=14e6, boom_height_m=10.0, elements=many) + assert model.total_element_count == 8 + assert model.director_count == 6 + + +def test_custom_requires_exactly_one_driven() -> None: + with pytest.raises(ValueError, match="exactly one driven"): + YagiModel( + frequency_hz=14e6, + boom_height_m=10.0, + elements=( + YagiElement(role=R.REFLECTOR, length_m=10.0, position_m=-4.0), + YagiElement(role=R.DIRECTOR, length_m=9.0, position_m=4.0), + ), + ) + + +def test_custom_allows_at_most_one_reflector() -> None: + with pytest.raises(ValueError, match="at most one reflector"): + YagiModel( + frequency_hz=14e6, + boom_height_m=10.0, + elements=( + YagiElement(role=R.REFLECTOR, length_m=10.0, position_m=-4.0), + YagiElement(role=R.REFLECTOR, length_m=10.0, position_m=-8.0), + YagiElement(role=R.DRIVEN, length_m=10.0, position_m=0.0), + ), + ) + + +def test_custom_rejects_duplicate_positions() -> None: + with pytest.raises(ValueError, match="same boom position"): + YagiModel( + frequency_hz=14e6, + boom_height_m=10.0, + elements=( + YagiElement(role=R.DRIVEN, length_m=10.0, position_m=0.0), + YagiElement(role=R.DIRECTOR, length_m=9.0, position_m=0.0), + ), + ) + + +# --- card builder ----------------------------------------------------------- + + +def test_auto_elements_are_reflector_driven_directors() -> None: + model = YagiModel(frequency_hz=14.1e6, boom_height_m=12.0, element_count=4) + elements = auto_yagi_elements(model) + roles = [element.role for element in elements] + assert roles == [R.REFLECTOR, R.DRIVEN, R.DIRECTOR, R.DIRECTOR] + # the driven sits at the origin, the reflector behind, directors in front + assert elements[1].position_m == pytest.approx(0.0) + assert elements[0].position_m < 0.0 + assert all(e.position_m > 0.0 for e in elements[2:]) + + +def test_resolve_prefers_explicit_elements() -> None: + model = YagiModel(frequency_hz=14e6, boom_height_m=10.0, element_count=3) + assert resolve_yagi_elements(model) == auto_yagi_elements(model) + custom = _custom_elements(model.wavelength_m) + model2 = YagiModel(frequency_hz=14e6, boom_height_m=10.0, elements=custom) + assert resolve_yagi_elements(model2) == custom + + +def test_custom_deck_preserves_the_tag_scheme() -> None: + model = YagiModel( + frequency_hz=14e6, + boom_height_m=10.0, + elements=_custom_elements(21.3), + ) + deck, _ = build_yagi_deck(model, 14e6) + gw_tags = sorted(c.integers[0] for c in deck.cards if c.mnemonic == "GW") + assert gw_tags == [DRIVEN_TAG, REFLECTOR_TAG, 3] # 1, 2, 3 + feed = [c for c in deck.cards if c.mnemonic == "EX"][0] + assert feed.integers[1] == DRIVEN_TAG # fed on the driven element + + +def test_custom_yagi_solves() -> None: + model = YagiModel( + frequency_hz=14.1e6, + boom_height_m=12.0, + elements=_custom_elements(21.26), + ) + results = NecppSolver().solve(model, model.frequency_hz) + assert results.feedpoint_impedance.real > 0.0 + assert results.max_gain_dbi > 0.0 + + +# --- UI --------------------------------------------------------------------- + + +def test_main_window_custom_yagi_seeds_builds_and_round_trips(qapp) -> None: + from ars_wireworks.ui.main_window import MainWindow + + window = MainWindow() + window._silently_promote_to_advanced() + window._antenna_type.setCurrentIndex( + window._antenna_type.findData("yagi") + ) + window._frequency.setValue(14.1) + + # enabling the custom box seeds the editor from the auto-design + assert window._yagi_elements_editor.is_empty() + window._advanced_yagi_box.setChecked(True) + assert len(window._yagi_elements_editor.elements()) == 3 + + model = window._build_model() + assert model.elements is not None + assert model.total_element_count == 3 + + # without the box, the model auto-designs (elements is None) + window._advanced_yagi_box.setChecked(False) + assert window._build_model().elements is None + + # capture/restore round-trips the custom elements and the toggle + window._advanced_yagi_box.setChecked(True) + state = window._capture_state() + assert state["yagi_custom_on"] is True + assert len(state["yagi_elements"]) == 3 + window._restore_state(state) + assert window._advanced_yagi_box.isChecked() + assert len(window._yagi_elements_editor.elements()) == 3 + + +def test_yagi_editor_keeps_at_least_two_elements(qapp) -> None: + from ars_wireworks.ui.yagi_elements_editor import YagiElementsEditor + from ars_wireworks.units import UnitSystem + + editor = YagiElementsEditor(UnitSystem.METRIC) + editor.set_elements(list(_custom_elements(21.3))) + assert len(editor.elements()) == 3 + editor._remove_row(editor._rows[-1]) + assert len(editor.elements()) == 2 + # refuses to drop below two + editor._remove_row(editor._rows[-1]) + assert len(editor.elements()) == 2