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
64 changes: 64 additions & 0 deletions src/ars_wireworks/results/fan_dipole_design.py
Original file line number Diff line number Diff line change
@@ -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
)
72 changes: 72 additions & 0 deletions src/ars_wireworks/ui/fan_dipole_wizard.py
Original file line number Diff line number Diff line change
@@ -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(),
)
27 changes: 27 additions & 0 deletions src/ars_wireworks/ui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
115 changes: 115 additions & 0 deletions tests/test_fan_dipole_wizard.py
Original file line number Diff line number Diff line change
@@ -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]
Loading