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