From 6e52a4e2e495f3fb305213fd676249e153393a32 Mon Sep 17 00:00:00 2001 From: Mike Kipps Date: Tue, 2 Jun 2026 19:17:55 -0400 Subject: [PATCH] Add the fan (parallel) dipole as a first-class antenna type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fan dipole is several half-wave dipoles, one per band, sharing a single feedpoint — multiband coverage without traps. This adds it end to end. Topology: the legs are fed across a common centre gap. A one-segment bridge wire carries the source; every leg's right half ties to the bridge's right node and its left half to the left node, so all the dipoles hang in parallel across the feed (the same shared-node feeding the vertical uses for its radials). The legs spread over a 30 deg azimuth fan so the wires stay clear. On each band the resonant leg presents the low impedance and dominates. Touch points: * model/antenna.py — FanDipoleLeg + FanDipoleModel (>= 2 legs), and a half_wave_length_m() helper shared with the leg editor. * cards/fan_dipole.py — build_fan_dipole_deck; registered in cards/build.py. * ui/fan_dipole_editor.py — an add/remove leg list (band + length; editing the band re-fills the half-wave length, which can then be trimmed). * main_window.py — picker entry + geometry page (appended last so existing stacked-widget indices are untouched), _build_model branch, and capture/restore of the legs. Note: folded dipole already owns _fd_*, so the fan height field is _fan_height. * validation.py — fan dipole is centre-fed. * buildsheet.py — names it "Fan dipole" (report reuses that name). Tests cover the model, the deck topology (one feed bridge, two wires per leg, all halves meeting the feed nodes), multiband NEC solves, the leg editor, the build-sheet name, and MainWindow build + capture/restore. Suite: 473 -> 485. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ars_wireworks/cards/build.py | 3 + src/ars_wireworks/cards/fan_dipole.py | 176 +++++++++++++++++++++ src/ars_wireworks/cards/validation.py | 12 +- src/ars_wireworks/model/antenna.py | 58 +++++++ src/ars_wireworks/results/buildsheet.py | 2 + src/ars_wireworks/ui/fan_dipole_editor.py | 182 ++++++++++++++++++++++ src/ars_wireworks/ui/main_window.py | 36 +++++ tests/test_fan_dipole.py | 172 ++++++++++++++++++++ 8 files changed, 639 insertions(+), 2 deletions(-) create mode 100644 src/ars_wireworks/cards/fan_dipole.py create mode 100644 src/ars_wireworks/ui/fan_dipole_editor.py create mode 100644 tests/test_fan_dipole.py diff --git a/src/ars_wireworks/cards/build.py b/src/ars_wireworks/cards/build.py index 9ee536a..cd39035 100644 --- a/src/ars_wireworks/cards/build.py +++ b/src/ars_wireworks/cards/build.py @@ -12,6 +12,7 @@ from ars_wireworks.cards.deck import Card, CardDeck from ars_wireworks.cards.dipole import build_dipole_deck from ars_wireworks.cards.efhw import build_efhw_deck +from ars_wireworks.cards.fan_dipole import build_fan_dipole_deck from ars_wireworks.cards.folded_dipole import build_folded_dipole_deck from ars_wireworks.cards.inverted_v import build_inverted_v_deck from ars_wireworks.cards.long_wire import build_long_wire_deck @@ -27,6 +28,7 @@ DeltaLoopModel, DipoleModel, EfhwModel, + FanDipoleModel, FoldedDipoleModel, HorizontalLoopModel, InvertedVeeModel, @@ -47,6 +49,7 @@ #: Card-deck builder for each supported antenna model type. _BUILDERS: dict[type[AntennaModel], _DeckBuilder] = { DipoleModel: build_dipole_deck, + FanDipoleModel: build_fan_dipole_deck, FoldedDipoleModel: build_folded_dipole_deck, InvertedVeeModel: build_inverted_v_deck, OcfdModel: build_ocfd_deck, diff --git a/src/ars_wireworks/cards/fan_dipole.py b/src/ars_wireworks/cards/fan_dipole.py new file mode 100644 index 0000000..d37c563 --- /dev/null +++ b/src/ars_wireworks/cards/fan_dipole.py @@ -0,0 +1,176 @@ +"""NEC-2 card-deck generation for a fan (parallel) dipole — layer (b). + +Several half-wave dipoles, each cut for its own band, share one feedpoint. +The legs are fed across a common centre gap: a short bridge wire carries the +source, every leg's right half ties to the bridge's right node and its left +half to the left node, so all the dipoles hang in parallel across the feed. +The legs spread in azimuth in a horizontal fan so they do not overlap. +""" + +from __future__ import annotations + +import math + +from ars_wireworks.cards.common import ( + frequency_card, + geometry_end_card, + ground_card, + radiation_pattern_card, + voltage_source_card, + wire_segment_count, +) +from ars_wireworks.cards.deck import Card, CardDeck +from ars_wireworks.model.antenna import FanDipoleModel +from ars_wireworks.model.engine_choice import EngineChoice + +#: Each leg half gets at least this many segments. +MIN_LEG_SEGMENTS: int = 5 + +#: The bridge wire that carries the feed is a single segment. +FEED_TAG: int = 1 + +#: Total azimuth spread of the fan, in degrees — modest, so each leg behaves +#: much like a stand-alone dipole while the wires stay clear of one another. +_FAN_SPREAD_DEG: float = 30.0 + + +def build_fan_dipole_deck( + model: FanDipoleModel, frequency_hz: float +) -> tuple[CardDeck, list[EngineChoice]]: + """Build the NEC-2 card deck for ``model``, excited at ``frequency_hz``. + + Geometry comes from the legs' lengths; ``frequency_hz`` sets only the + excitation, so a sweep across the legs' bands reuses one geometry. + """ + if frequency_hz <= 0.0: + raise ValueError("frequency_hz must be positive") + + radius = model.wire.radius_m + height = model.height_m + ground, ground_choice = ground_card(model) + + legs = model.legs + count = len(legs) + half_lengths = [leg.length_m / 2.0 for leg in legs] + leg_segments = [ + wire_segment_count( + half, + model.wavelength_m, + minimum=MIN_LEG_SEGMENTS, + force_odd=False, + density=model.segments_per_wavelength, + ) + for half in half_lengths + ] + # Size the feed gap to about one of the shortest leg's segments, so the + # single-segment bridge is not wildly shorter than its neighbours. + shortest_seg = min( + half / seg for half, seg in zip(half_lengths, leg_segments) + ) + gap = max(shortest_seg, 0.02) + left_node = (-gap / 2.0, 0.0, height) + right_node = (gap / 2.0, 0.0, height) + + cards: list[Card] = [ + Card("CM", comment="ARS WireWorks - fan dipole"), + Card( + "CM", + comment=( + f"Design {model.frequency_hz / 1e6:.4g} MHz, {count} legs, " + f"longest {model.longest_leg_m:.3f} m, height {height:.3f} m" + ), + ), + Card("CE"), + # The feed bridge across the centre gap (fed at its only segment). + Card( + "GW", + integers=(FEED_TAG, 1), + reals=(*left_node, *right_node, radius), + ), + ] + + # Spread the legs symmetrically about the X axis. + for index, (leg, half, segments) in enumerate( + zip(legs, half_lengths, leg_segments) + ): + if count == 1: + azimuth = 0.0 + else: + azimuth = math.radians( + -_FAN_SPREAD_DEG / 2.0 + + index * _FAN_SPREAD_DEG / (count - 1) + ) + dx = half * math.cos(azimuth) + dy = half * math.sin(azimuth) + right_tag = 2 * index + 2 + left_tag = 2 * index + 3 + cards.append( + Card( + "GW", + integers=(right_tag, segments), + reals=( + *right_node, + right_node[0] + dx, + right_node[1] + dy, + height, + radius, + ), + ) + ) + cards.append( + Card( + "GW", + integers=(left_tag, segments), + reals=( + *left_node, + left_node[0] - dx, + left_node[1] - dy, + height, + radius, + ), + ) + ) + + cards.extend( + [ + geometry_end_card(), + ground, + voltage_source_card(FEED_TAG, 1), + frequency_card(frequency_hz), + radiation_pattern_card(model), + Card("EN"), + ] + ) + + total_segments = sum(leg_segments) * 2 + 1 + choices = [ + EngineChoice( + topic="Segmentation", + explanation=( + f"I modelled the fan as {count} dipole legs, each split into " + f"two halves ({total_segments} segments in all) sharing a " + f"one-segment feed bridge — about {model.segments_per_wavelength} " + f"segments per wavelength." + ), + ), + EngineChoice( + topic="Feedpoint", + explanation=( + "I fed a short bridge wire across the centre gap, with every " + "leg's two halves tied to its ends — so all the legs hang in " + "parallel across one feedpoint, the way a fan dipole is built. " + "On each band its resonant leg presents the low impedance and " + "carries the current." + ), + ), + EngineChoice( + topic="Fan layout", + explanation=( + f"I spread the legs over a {_FAN_SPREAD_DEG:.0f}° fan in " + f"azimuth so the wires stay clear of one another while each " + f"still works much like a stand-alone dipole." + ), + ), + ground_choice, + ] + return CardDeck(tuple(cards)), choices diff --git a/src/ars_wireworks/cards/validation.py b/src/ars_wireworks/cards/validation.py index ef4606f..9f0007c 100644 --- a/src/ars_wireworks/cards/validation.py +++ b/src/ars_wireworks/cards/validation.py @@ -255,16 +255,24 @@ def _coil_self_resonant_frequency_hz(coil: LoadingCoil) -> float | None: def _feed_is_at_centre(model: AntennaModel) -> bool: - """True for centre-fed antennas (dipole, folded dipole, inverted-V, OCFD).""" + """True for centre-fed antennas (dipole, fan, folded, inverted-V, OCFD).""" from ars_wireworks.model.antenna import ( DipoleModel, + FanDipoleModel, FoldedDipoleModel, InvertedVeeModel, OcfdModel, ) return isinstance( - model, (DipoleModel, FoldedDipoleModel, InvertedVeeModel, OcfdModel) + model, + ( + DipoleModel, + FanDipoleModel, + FoldedDipoleModel, + InvertedVeeModel, + OcfdModel, + ), ) diff --git a/src/ars_wireworks/model/antenna.py b/src/ars_wireworks/model/antenna.py index 5a36300..ddaeb14 100644 --- a/src/ars_wireworks/model/antenna.py +++ b/src/ars_wireworks/model/antenna.py @@ -123,6 +123,64 @@ def __post_init__(self) -> None: raise ValueError("height_m must not be negative (wire below ground)") +def half_wave_length_m(frequency_hz: float) -> float: + """The practical half-wave dipole length to cut for ``frequency_hz``. + + A half wavelength shortened by the end-effect factor — the same formula + :attr:`AntennaModel.length_m` uses, exposed for callers that only have a + frequency (a fan-dipole leg, the fan-dipole leg editor's auto-fill). + """ + if frequency_hz <= 0.0: + raise ValueError("frequency_hz must be positive") + return DIPOLE_END_EFFECT_FACTOR * SPEED_OF_LIGHT_M_PER_S / (2.0 * frequency_hz) + + +@dataclass(frozen=True) +class FanDipoleLeg: + """One leg of a fan dipole — a half-wave dipole resonant on its own band. + + ``resonant_frequency_hz`` is the band the leg is cut for (carried for the + build sheet and labels); ``length_m`` is its actual tip-to-tip span, which + defaults to the half-wave for that frequency but can be trimmed. + """ + + resonant_frequency_hz: float + length_m: float + + def __post_init__(self) -> None: + if self.resonant_frequency_hz <= 0.0: + raise ValueError("a fan-dipole leg's frequency must be positive") + if self.length_m <= 0.0: + raise ValueError("a fan-dipole leg's length must be positive") + + +@dataclass(kw_only=True) +class FanDipoleModel(AntennaModel): + """A fan (parallel) dipole — several dipoles sharing one feedpoint. + + Each leg is a half-wave dipole cut for its own band; the legs are all fed + across a common centre gap and spread in azimuth in a fan, giving multiband + coverage without traps. On any given band the resonant leg presents a low + impedance and dominates, while the others sit off-resonance. All legs lie + in a horizontal plane at ``height_m``. + """ + + height_m: float + legs: tuple[FanDipoleLeg, ...] + + def __post_init__(self) -> None: + super().__post_init__() + if self.height_m < 0.0: + raise ValueError("height_m must not be negative (wire below ground)") + if len(self.legs) < 2: + raise ValueError("a fan dipole needs at least two legs") + + @property + def longest_leg_m(self) -> float: + """The span of the longest (lowest-band) leg — sizes the build.""" + return max(leg.length_m for leg in self.legs) + + @dataclass(kw_only=True) class FoldedDipoleModel(AntennaModel): """A centre-fed half-wave folded dipole. diff --git a/src/ars_wireworks/results/buildsheet.py b/src/ars_wireworks/results/buildsheet.py index 9b290a9..14d8c45 100644 --- a/src/ars_wireworks/results/buildsheet.py +++ b/src/ars_wireworks/results/buildsheet.py @@ -23,6 +23,7 @@ DeltaLoopModel, DipoleModel, EfhwModel, + FanDipoleModel, FoldedDipoleModel, HorizontalLoopModel, InvertedVeeModel, @@ -60,6 +61,7 @@ #: Human-readable name for each antenna model type. _ANTENNA_NAMES: dict[type[AntennaModel], str] = { DipoleModel: "Half-wave dipole", + FanDipoleModel: "Fan dipole", FoldedDipoleModel: "Folded dipole", InvertedVeeModel: "Inverted-V", OcfdModel: "Off-center-fed dipole", diff --git a/src/ars_wireworks/ui/fan_dipole_editor.py b/src/ars_wireworks/ui/fan_dipole_editor.py new file mode 100644 index 0000000..abdff94 --- /dev/null +++ b/src/ars_wireworks/ui/fan_dipole_editor.py @@ -0,0 +1,182 @@ +"""Fan-dipole leg editor — architecture layer (f). + +A fan dipole is entered as a list of legs, each a band (resonant frequency) +and a tip-to-tip length. Editing a leg's frequency auto-fills its length with +the half-wave for that band; the length can then be trimmed. This widget adds +and removes legs; a fan dipole always keeps at least two. +""" + +from __future__ import annotations + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QDoubleSpinBox, + QHBoxLayout, + QLabel, + QPushButton, + QVBoxLayout, + QWidget, +) + +from ars_wireworks.model.antenna import FanDipoleLeg, half_wave_length_m +from ars_wireworks.ui.units import LengthSpinBox +from ars_wireworks.units import UnitSystem + +#: A fan dipole needs at least two legs to be a fan. +_MIN_LEGS = 2 + +#: Width reserved for a row's remove button and the header's blank column. +_REMOVE_WIDTH = 30 + +#: Default legs offered for a fresh fan dipole — 40 m and 20 m. +_DEFAULT_LEGS: tuple[tuple[float, float], ...] = ( + (7.15e6, half_wave_length_m(7.15e6)), + (14.175e6, half_wave_length_m(14.175e6)), +) + + +class FanDipoleLegEditor(QWidget): + """Add/remove editor for a fan dipole's legs.""" + + def __init__(self, system: UnitSystem) -> None: + super().__init__() + self._system = system + self._rows: list[_LegRow] = [] + + self._rows_layout = QVBoxLayout() + self._rows_layout.setContentsMargins(0, 0, 0, 0) + self._rows_layout.setSpacing(4) + + add_button = QPushButton("Add leg") + 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) + + for frequency_hz, length_m in _DEFAULT_LEGS: + self.add_leg(frequency_hz=frequency_hz, length_m=length_m) + + def legs(self) -> tuple[FanDipoleLeg, ...]: + """The legs currently entered.""" + return tuple(row.to_leg() for row in 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_leg( + self, *, frequency_hz: float = 14.175e6, length_m: float | None = None + ) -> None: + """Append a leg row, defaulting the length to the half-wave.""" + if length_m is None: + length_m = half_wave_length_m(frequency_hz) + row = _LegRow(self._system, frequency_hz, length_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_legs(self, legs: list[FanDipoleLeg]) -> None: + """Replace every leg with ``legs`` — keeps at least the default two.""" + for row in list(self._rows): + self._rows.remove(row) + self._rows_layout.removeWidget(row) + row.deleteLater() + chosen = legs or [ + FanDipoleLeg(resonant_frequency_hz=f, length_m=length) + for f, length in _DEFAULT_LEGS + ] + for leg in chosen: + self.add_leg( + frequency_hz=leg.resonant_frequency_hz, length_m=leg.length_m + ) + + def _on_add_clicked(self) -> None: + self.add_leg() + + def _remove_row(self, row: _LegRow) -> None: + if len(self._rows) <= _MIN_LEGS: + return + self._rows.remove(row) + self._rows_layout.removeWidget(row) + row.deleteLater() + self._refresh_remove_buttons() + + def _refresh_remove_buttons(self) -> None: + """A leg can be removed only while more than the minimum remain.""" + removable = len(self._rows) > _MIN_LEGS + for row in self._rows: + row.set_removable(removable) + + +class _LegRow(QWidget): + """One leg: a band (MHz) and a length, with a remove button. + + Editing the frequency re-fills the length with the half-wave for that + band; the length can then be trimmed for the fan's mutual coupling. + """ + + remove_requested = Signal() + + def __init__( + self, system: UnitSystem, frequency_hz: float, length_m: float + ) -> None: + super().__init__() + self._frequency = QDoubleSpinBox() + self._frequency.setDecimals(3) + self._frequency.setRange(1.0, 1300.0) + self._frequency.setSuffix(" MHz") + self._frequency.setValue(frequency_hz / 1e6) + + self._length = LengthSpinBox( + min_m=0.1, max_m=300.0, value_m=length_m, system=system + ) + self._frequency.valueChanged.connect(self._on_frequency_changed) + + self._remove = QPushButton("✕") + self._remove.setObjectName("legRemoveButton") + self._remove.setFixedWidth(_REMOVE_WIDTH) + self._remove.setToolTip("Remove this leg") + self._remove.clicked.connect(lambda: self.remove_requested.emit()) + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._frequency, stretch=1) + layout.addWidget(self._length, stretch=1) + layout.addWidget(self._remove) + + def _on_frequency_changed(self, mhz: float) -> None: + """Re-cut the leg to a half-wave when the band changes.""" + if mhz > 0.0: + self._length.set_metres(half_wave_length_m(mhz * 1e6)) + + def to_leg(self) -> FanDipoleLeg: + """The :class:`FanDipoleLeg` for this row, in SI units.""" + return FanDipoleLeg( + resonant_frequency_hz=self._frequency.value() * 1e6, + length_m=self._length.metres(), + ) + + def set_unit_system(self, system: UnitSystem) -> None: + """Switch the length field to ``system``.""" + self._length.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: Band, Length.""" + header = QHBoxLayout() + for title in ("Band", "Length"): + header.addWidget(QLabel(title), stretch=1) + spacer = QWidget() + spacer.setFixedWidth(_REMOVE_WIDTH) + header.addWidget(spacer) + return header diff --git a/src/ars_wireworks/ui/main_window.py b/src/ars_wireworks/ui/main_window.py index 17794e2..25c6c4d 100644 --- a/src/ars_wireworks/ui/main_window.py +++ b/src/ars_wireworks/ui/main_window.py @@ -47,6 +47,8 @@ DeltaLoopModel, DipoleModel, EfhwModel, + FanDipoleLeg, + FanDipoleModel, FoldedDipoleModel, HorizontalLoopModel, InvertedVeeModel, @@ -85,6 +87,7 @@ from ars_wireworks.ui.info_button import InfoButton, show_explanation from ars_wireworks.ui.lesson_dialog import LessonPickerDialog from ars_wireworks.ui.lesson_panel import LessonPanel +from ars_wireworks.ui.fan_dipole_editor import FanDipoleLegEditor from ars_wireworks.ui.loads_editor import LoadsEditor from ars_wireworks.ui.loading_coils_editor import LoadingCoilsEditor from ars_wireworks.ui.traps_editor import TrapsEditor @@ -918,6 +921,7 @@ def _build_central_widget(self) -> QWidget: self._antenna_type.addItem("Rhombic", "rhombic") self._antenna_type.addItem("Terminated rhombic", "terminated_rhombic") self._antenna_type.addItem("Inverted-L", "inverted_l") + self._antenna_type.addItem("Fan dipole", "fan_dipole") # Dipole. self._dipole_height = self._length(min_m=0.0, max_m=300.0, value_m=10.0) @@ -1024,6 +1028,17 @@ def _build_central_widget(self) -> QWidget: il_outer.setContentsMargins(0, 0, 0, 0) il_outer.addWidget(self._il_radial_editor) il_outer.addWidget(self._il_path_box) + # Fan dipole: a height plus a list of legs (band + tip-to-tip length). + self._fan_height = self._length(min_m=0.0, max_m=300.0, value_m=10.0) + self._fan_dipole_editor = FanDipoleLegEditor(self._prefs.unit_system) + fan_dipole_page = QWidget() + fan_outer = QVBoxLayout(fan_dipole_page) + fan_outer.setContentsMargins(0, 0, 0, 0) + fan_height_form = QFormLayout() + fan_height_form.setContentsMargins(0, 0, 0, 0) + fan_height_form.addRow("Height", self._fan_height) + fan_outer.addLayout(fan_height_form) + fan_outer.addWidget(self._fan_dipole_editor) # Rhombic. self._rhombic_leg_wl = _real_spinbox( minimum=0.5, maximum=10.0, value=2.0, decimals=2, suffix=" λ", @@ -1113,6 +1128,7 @@ def _build_central_widget(self) -> QWidget: ) ) self._geometry.addWidget(inverted_l_page) + self._geometry.addWidget(fan_dipole_page) self._antenna_type.currentIndexChanged.connect( self._geometry.setCurrentIndex ) @@ -1458,6 +1474,7 @@ def _apply_preferences(self, prefs: Preferences) -> None: 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) + self._fan_dipole_editor.set_unit_system(prefs.unit_system) self._lw_path.set_unit_system(prefs.unit_system) self._advanced_path.set_unit_system(prefs.unit_system) app = QApplication.instance() @@ -1551,6 +1568,13 @@ def _capture_state(self) -> dict: } for group in self._il_radial_editor.radial_groups() ], + "fan_dipole_legs": [ + { + "resonant_frequency_hz": leg.resonant_frequency_hz, + "length_m": leg.length_m, + } + for leg in self._fan_dipole_editor.legs() + ], # 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). @@ -1615,6 +1639,11 @@ def _restore_state(self, state: dict) -> None: self._il_radial_editor.set_groups( [RadialGroup(**group) for group in il_radials] ) + fan_legs = state.get("fan_dipole_legs") + if fan_legs: + self._fan_dipole_editor.set_legs( + [FanDipoleLeg(**leg) for leg in fan_legs] + ) self._advanced_path.load_point_dicts(state.get("advanced_path", [])) self._advanced_path_box.setChecked( bool(state.get("advanced_path_on")) @@ -2023,6 +2052,13 @@ def _build_model(self) -> AntennaModel: separation_m=self._fd_separation.metres(), **loaded, ) + if kind == "fan_dipole": + return FanDipoleModel( + **common, + height_m=self._fan_height.metres(), + legs=self._fan_dipole_editor.legs(), + **loaded, + ) if kind == "inverted_v": return InvertedVeeModel( **common, diff --git a/tests/test_fan_dipole.py b/tests/test_fan_dipole.py new file mode 100644 index 0000000..2123115 --- /dev/null +++ b/tests/test_fan_dipole.py @@ -0,0 +1,172 @@ +"""Tests for the fan (parallel) dipole — model, card builder, and UI.""" + +from __future__ import annotations + +import pytest + +from ars_wireworks.cards.build import build_deck +from ars_wireworks.cards.fan_dipole import FEED_TAG, build_fan_dipole_deck +from ars_wireworks.model.antenna import ( + FanDipoleLeg, + FanDipoleModel, + half_wave_length_m, +) +from ars_wireworks.solver.necpp import NecppSolver + + +def _legs() -> tuple[FanDipoleLeg, ...]: + return ( + FanDipoleLeg( + resonant_frequency_hz=7.15e6, length_m=half_wave_length_m(7.15e6) + ), + FanDipoleLeg( + resonant_frequency_hz=14.175e6, + length_m=half_wave_length_m(14.175e6), + ), + ) + + +def _model(**overrides) -> FanDipoleModel: + params = {"frequency_hz": 14.175e6, "height_m": 12.0, "legs": _legs()} + params.update(overrides) + return FanDipoleModel(**params) + + +# --- model ------------------------------------------------------------------ + + +def test_half_wave_length_matches_the_dipole_formula() -> None: + # a 20 m half-wave is ~10 m + assert half_wave_length_m(14.175e6) == pytest.approx(10.05, abs=0.1) + with pytest.raises(ValueError): + half_wave_length_m(0.0) + + +def test_leg_rejects_non_positive_values() -> None: + with pytest.raises(ValueError): + FanDipoleLeg(resonant_frequency_hz=0.0, length_m=10.0) + with pytest.raises(ValueError): + FanDipoleLeg(resonant_frequency_hz=7e6, length_m=0.0) + + +def test_model_needs_at_least_two_legs() -> None: + with pytest.raises(ValueError, match="at least two legs"): + FanDipoleModel( + frequency_hz=14e6, height_m=10.0, legs=(_legs()[0],) + ) + + +def test_longest_leg_is_the_lowest_band() -> None: + model = _model() + assert model.longest_leg_m == pytest.approx(half_wave_length_m(7.15e6)) + + +# --- card builder ----------------------------------------------------------- + + +def test_deck_has_a_feed_bridge_and_two_wires_per_leg() -> None: + deck, choices = build_fan_dipole_deck(_model(), 14.175e6) + gw = [c for c in deck.cards if c.mnemonic == "GW"] + # one feed bridge + two halves per leg + assert len(gw) == 1 + 2 * len(_model().legs) + # exactly one excitation, on the feed bridge + ex = [c for c in deck.cards if c.mnemonic == "EX"] + assert len(ex) == 1 + assert ex[0].integers[1] == FEED_TAG + # the deck is closed + assert deck.cards[-1].mnemonic == "EN" + + +def test_all_leg_halves_meet_the_feed_nodes() -> None: + deck, _ = build_fan_dipole_deck(_model(), 14.175e6) + gw = [c for c in deck.cards if c.mnemonic == "GW"] + bridge = gw[0] + left_node = bridge.reals[0:3] + right_node = bridge.reals[3:6] + # every leg-half starts at one of the two feed nodes + for card in gw[1:]: + start = card.reals[0:3] + assert start == pytest.approx(left_node) or start == pytest.approx( + right_node + ) + + +def test_dispatch_routes_fan_dipole_to_its_builder() -> None: + deck, _ = build_deck(_model(), 14.175e6) + assert any(c.mnemonic == "GW" for c in deck.cards) + assert sum(c.mnemonic == "EX" for c in deck.cards) == 1 + + +def test_build_sheet_names_it_a_fan_dipole() -> None: + from ars_wireworks.results.buildsheet import antenna_name + + assert antenna_name(_model()) == "Fan dipole" + + +# --- solve ------------------------------------------------------------------ + + +def test_fan_dipole_solves_on_each_legs_band() -> None: + solver = NecppSolver() + for f_mhz in (7.15, 14.175): + model = _model(frequency_hz=f_mhz * 1e6) + results = solver.solve(model, model.frequency_hz) + # a physical solve: positive feedpoint resistance and real gain + assert results.feedpoint_impedance.real > 0.0 + assert results.max_gain_dbi > 0.0 + + +# --- editor ----------------------------------------------------------------- + + +def test_editor_keeps_at_least_two_legs(qapp) -> None: + from ars_wireworks.ui.fan_dipole_editor import FanDipoleLegEditor + from ars_wireworks.units import UnitSystem + + editor = FanDipoleLegEditor(UnitSystem.METRIC) + assert len(editor.legs()) == 2 + # removing below the minimum is refused + editor._remove_row(editor._rows[0]) + assert len(editor.legs()) == 2 + # adding then removing works + editor.add_leg(frequency_hz=21.225e6) + assert len(editor.legs()) == 3 + editor._remove_row(editor._rows[-1]) + assert len(editor.legs()) == 2 + + +def test_editor_frequency_change_refills_length(qapp) -> None: + from ars_wireworks.ui.fan_dipole_editor import FanDipoleLegEditor + from ars_wireworks.units import UnitSystem + + editor = FanDipoleLegEditor(UnitSystem.METRIC) + row = editor._rows[0] + row._frequency.setValue(21.225) # move this leg to 15 m + leg = row.to_leg() + assert leg.resonant_frequency_hz == pytest.approx(21.225e6) + assert leg.length_m == pytest.approx(half_wave_length_m(21.225e6), abs=0.05) + + +# --- MainWindow integration ------------------------------------------------- + + +def test_main_window_builds_and_round_trips_a_fan_dipole(qapp) -> None: + from ars_wireworks.ui.main_window import MainWindow + + window = MainWindow() + index = window._antenna_type.findData("fan_dipole") + window._antenna_type.setCurrentIndex(index) + # the geometry stack tracks the picker index exactly + assert window._geometry.currentIndex() == index + + window._frequency.setValue(14.175) + model = window._build_model() + assert isinstance(model, FanDipoleModel) + assert len(model.legs) == 2 + + # capture/restore preserves the legs + state = window._capture_state() + assert len(state["fan_dipole_legs"]) == 2 + window._fan_dipole_editor.add_leg(frequency_hz=28.5e6) + window._restore_state(state) + assert len(window._fan_dipole_editor.legs()) == 2