From f983df52edb6743c0dfdb692232f0bbcb9a3230c Mon Sep 17 00:00:00 2001 From: Mike Kipps Date: Tue, 2 Jun 2026 19:44:23 -0400 Subject: [PATCH] Add a fan-dipole wizard, completing the wizard suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fourth design wizard on the shared framework, now that FanDipoleModel exists. The user ticks the bands and sets the flat-top height; the wizard cuts one half-wave dipole leg per band and fills the fan-dipole height + leg editors. * results/fan_dipole_design.py — one half-wave leg per band; rejects fewer than two bands (a fan needs at least two legs). * ui/fan_dipole_wizard.py — BandPicker + height, defaulting to 40/20/10 m. * MainWindow.apply_fan_dipole_design — sets the type, height, operating frequency (lowest band), and legs. Tests cover the leg layout, the lowest-band/sorted-leg ordering, the two-band-minimum rejection, the apply hook building a real model, and the dialog/validation flow. Suite 485 -> 493. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../results/fan_dipole_design.py | 64 ++++++++++ src/ars_wireworks/ui/fan_dipole_wizard.py | 72 +++++++++++ src/ars_wireworks/ui/main_window.py | 27 ++++ tests/test_fan_dipole_wizard.py | 115 ++++++++++++++++++ 4 files changed, 278 insertions(+) create mode 100644 src/ars_wireworks/results/fan_dipole_design.py create mode 100644 src/ars_wireworks/ui/fan_dipole_wizard.py create mode 100644 tests/test_fan_dipole_wizard.py diff --git a/src/ars_wireworks/results/fan_dipole_design.py b/src/ars_wireworks/results/fan_dipole_design.py new file mode 100644 index 0000000..ea629cd --- /dev/null +++ b/src/ars_wireworks/results/fan_dipole_design.py @@ -0,0 +1,64 @@ +"""Fan-dipole design — architecture layer (d). + +A fan dipole is the simplest multiband design: one half-wave dipole leg per +band, all paralleled at the feed. Given the bands to cover, this cuts a leg +for each; the legs interact a little, so a builder trims them afterward (the +leg editor allows it). The wizard in :mod:`ui.fan_dipole_wizard` pours the +result into the fan-dipole height + leg editors. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from ars_wireworks.model.antenna import FanDipoleLeg, half_wave_length_m + +#: A sensible default height for a fan dipole's flat-top (metres). +DEFAULT_HEIGHT_M: float = 10.0 + + +@dataclass(frozen=True) +class FanDipoleDesign: + """A fan-dipole layout ready to drop into the editors. + + ``legs`` is one half-wave dipole per chosen band; ``height_m`` is the + flat-top height; ``design_frequency_hz`` is the lowest band, used to set + the operating frequency so the user lands on a covered band. + """ + + legs: tuple[FanDipoleLeg, ...] + height_m: float + design_frequency_hz: float + + +def design_fan_dipole( + *, + band_centres_mhz: list[float], + height_m: float = DEFAULT_HEIGHT_M, +) -> FanDipoleDesign: + """Lay out a fan dipole covering every band in ``band_centres_mhz``. + + One half-wave leg per band. Raises :class:`ValueError` for fewer than two + bands (a fan needs at least two legs), non-positive frequencies, or a + non-positive height. + """ + if len(band_centres_mhz) < 2: + raise ValueError("a fan dipole needs at least two bands") + if any(mhz <= 0.0 for mhz in band_centres_mhz): + raise ValueError("every band centre must be positive") + if height_m <= 0.0: + raise ValueError("the height must be positive") + + bands_hz = sorted(mhz * 1e6 for mhz in band_centres_mhz) + legs = tuple( + FanDipoleLeg( + resonant_frequency_hz=frequency_hz, + length_m=half_wave_length_m(frequency_hz), + ) + for frequency_hz in bands_hz + ) + return FanDipoleDesign( + legs=legs, + height_m=height_m, + design_frequency_hz=bands_hz[0], # lowest band + ) diff --git a/src/ars_wireworks/ui/fan_dipole_wizard.py b/src/ars_wireworks/ui/fan_dipole_wizard.py new file mode 100644 index 0000000..3d0e218 --- /dev/null +++ b/src/ars_wireworks/ui/fan_dipole_wizard.py @@ -0,0 +1,72 @@ +"""Fan-dipole wizard dialog — architecture layer (f). + +The user ticks the bands the antenna should cover and sets the flat-top +height; the wizard cuts a half-wave dipole leg for each band and fills the +main window's fan-dipole height + leg editors. The layout lives in +:mod:`results.fan_dipole_design`; this is the UI shell. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from PySide6.QtWidgets import QFormLayout, QVBoxLayout + +from ars_wireworks.results.fan_dipole_design import ( + DEFAULT_HEIGHT_M, + FanDipoleDesign, + design_fan_dipole, +) +from ars_wireworks.ui.units import LengthSpinBox +from ars_wireworks.ui.wizard_base import BandPicker, WizardDialog +from ars_wireworks.units import UnitSystem + +_INTRO = ( + "I will design a fan dipole: one half-wave dipole leg per band you tick, " + "all paralleled at one feedpoint and spread in a fan. On each band its " + "resonant leg carries the current. The legs interact a little, so trim " + "them once it is up — the leg list lets you." +) + + +class FanDipoleWizard(WizardDialog): + """Dialog that designs a fan dipole — a half-wave leg per band.""" + + def __init__( + self, + *, + on_apply: Callable[[FanDipoleDesign], None], + unit_system: UnitSystem, + default_bands: tuple[str, ...] = ("40 m", "20 m", "10 m"), + parent=None, + ) -> None: + self._unit_system = unit_system + self._default_bands = default_bands + super().__init__( + title="Fan-dipole wizard", + intro=_INTRO, + on_apply=on_apply, + parent=parent, + ) + + def build_body(self, layout: QVBoxLayout) -> None: + self._bands = BandPicker(default_bands=self._default_bands) + self._height = LengthSpinBox( + min_m=1.0, max_m=300.0, value_m=DEFAULT_HEIGHT_M, + system=self._unit_system, + ) + form = QFormLayout() + form.addRow("Flat-top height", self._height) + + layout.addWidget(self._bands) + layout.addLayout(form) + + def selected_band_centres_mhz(self) -> list[float]: + """Centre frequencies for every checked band (in MHz).""" + return self._bands.selected_band_centres_mhz() + + def compute_design(self) -> FanDipoleDesign: + return design_fan_dipole( + band_centres_mhz=self.selected_band_centres_mhz(), + height_m=self._height.metres(), + ) diff --git a/src/ars_wireworks/ui/main_window.py b/src/ars_wireworks/ui/main_window.py index 25c6c4d..b34b992 100644 --- a/src/ars_wireworks/ui/main_window.py +++ b/src/ars_wireworks/ui/main_window.py @@ -391,6 +391,11 @@ def _build_menu_bar(self) -> None: self._open_vertical_wizard ) wizards_menu.addAction(vertical_wizard_action) + fan_dipole_wizard_action = QAction("Fan dipole…", self) + fan_dipole_wizard_action.triggered.connect( + self._open_fan_dipole_wizard + ) + wizards_menu.addAction(fan_dipole_wizard_action) glossary_action = QAction("Glossary…", self) glossary_action.triggered.connect(self._open_glossary) @@ -658,6 +663,28 @@ def apply_trapped_vertical_design(self, design) -> None: wire_tag=0, ) + def _open_fan_dipole_wizard(self) -> None: + """Open the fan-dipole wizard and apply its design.""" + from ars_wireworks.ui.fan_dipole_wizard import FanDipoleWizard + + FanDipoleWizard( + on_apply=self.apply_fan_dipole_design, + unit_system=self._prefs.unit_system, + parent=self, + ).exec() + + def apply_fan_dipole_design(self, design) -> None: + """Drop the wizard's design into the fan-dipole editors. + + Switches the antenna type to fan dipole, sets the flat-top height and + the operating frequency to the lowest band, and lays in one half-wave + leg per band. + """ + _select_by_data(self._antenna_type, "fan_dipole") + self._frequency.setValue(design.design_frequency_hz / 1e6) + self._fan_height.set_metres(design.height_m) + self._fan_dipole_editor.set_legs(list(design.legs)) + def _start_lesson(self, lesson) -> None: """Begin running ``lesson`` in the lesson dock.""" self._lesson = LessonInterpreter(lesson, self) diff --git a/tests/test_fan_dipole_wizard.py b/tests/test_fan_dipole_wizard.py new file mode 100644 index 0000000..f83d9e2 --- /dev/null +++ b/tests/test_fan_dipole_wizard.py @@ -0,0 +1,115 @@ +"""Tests for the fan-dipole wizard (results.fan_dipole_design + UI).""" + +from __future__ import annotations + +import pytest + +from ars_wireworks.model.antenna import half_wave_length_m +from ars_wireworks.results.fan_dipole_design import ( + FanDipoleDesign, + design_fan_dipole, +) + + +# --- design algorithm ------------------------------------------------------- + + +def test_one_half_wave_leg_per_band() -> None: + design = design_fan_dipole(band_centres_mhz=[7.15, 14.175, 28.5]) + assert isinstance(design, FanDipoleDesign) + assert len(design.legs) == 3 + for leg in design.legs: + assert leg.length_m == pytest.approx( + half_wave_length_m(leg.resonant_frequency_hz) + ) + + +def test_design_frequency_is_the_lowest_band() -> None: + design = design_fan_dipole(band_centres_mhz=[14.175, 7.15, 21.225]) + assert design.design_frequency_hz == pytest.approx(7.15e6) + # legs come sorted low to high + freqs = [leg.resonant_frequency_hz for leg in design.legs] + assert freqs == sorted(freqs) + + +def test_height_is_carried_through() -> None: + design = design_fan_dipole( + band_centres_mhz=[7.15, 14.175], height_m=18.0 + ) + assert design.height_m == pytest.approx(18.0) + + +def test_fewer_than_two_bands_is_rejected() -> None: + with pytest.raises(ValueError, match="at least two bands"): + design_fan_dipole(band_centres_mhz=[14.175]) + with pytest.raises(ValueError, match="at least two bands"): + design_fan_dipole(band_centres_mhz=[]) + + +def test_bad_height_is_rejected() -> None: + with pytest.raises(ValueError): + design_fan_dipole(band_centres_mhz=[7.15, 14.175], height_m=0.0) + + +# --- MainWindow apply hook -------------------------------------------------- + + +def test_apply_fills_the_fan_dipole_editors(qapp) -> None: + from ars_wireworks.ui.main_window import MainWindow + + window = MainWindow() + design = design_fan_dipole( + band_centres_mhz=[7.15, 14.175, 28.5], height_m=15.0 + ) + window.apply_fan_dipole_design(design) + + assert window._antenna_type.currentData() == "fan_dipole" + assert window._frequency.value() == pytest.approx(7.15) + assert window._fan_height.metres() == pytest.approx(15.0, abs=1e-3) + assert len(window._fan_dipole_editor.legs()) == 3 + + # the populated editors build a real fan dipole + model = window._build_model() + assert len(model.legs) == 3 + + +# --- dialog + framework ----------------------------------------------------- + + +def test_wizard_apply_invokes_callback_with_a_design(qapp) -> None: + from ars_wireworks.ui.fan_dipole_wizard import FanDipoleWizard + from ars_wireworks.units import UnitSystem + + captured: list = [] + wizard = FanDipoleWizard( + on_apply=captured.append, unit_system=UnitSystem.METRIC + ) + wizard._on_apply_clicked() + assert len(captured) == 1 + assert isinstance(captured[0], FanDipoleDesign) + # default 40/20/10 -> three legs + assert len(captured[0].legs) == 3 + + +def test_wizard_warns_and_stays_open_on_one_band(qapp, monkeypatch) -> None: + from ars_wireworks.ui import wizard_base + from ars_wireworks.ui.fan_dipole_wizard import FanDipoleWizard + from ars_wireworks.units import UnitSystem + + warned: list = [] + monkeypatch.setattr( + wizard_base.QMessageBox, + "warning", + lambda *args, **kwargs: warned.append(args[2]), + ) + captured: list = [] + wizard = FanDipoleWizard( + on_apply=captured.append, unit_system=UnitSystem.METRIC + ) + # untick all but one band + for name, box in wizard._bands._boxes.items(): + box.setChecked(name == "40 m") + wizard._on_apply_clicked() + + assert not captured # nothing applied + assert warned and "at least two bands" in warned[0]