From c34d50c2fb862d4c772ff6c31dd98843cf2f6152 Mon Sep 17 00:00:00 2001 From: Mike Kipps Date: Wed, 3 Jun 2026 10:42:59 -0400 Subject: [PATCH 1/2] Make the fan-dipole summary and tuning chart per-band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fan dipole is multiband by leg, but the summary and build sheet treated it as a single-band antenna at the operating frequency. Summary: the "Other bands" line ran the harmonic-match scan, which the coupled fan legs miss, so a multiband antenna read as "not a close match on other bands". A fan dipole now gets a per-leg report instead — fan_dipole_legs_report solves at each leg's own design frequency, and the summary lists every band the antenna covers with its SWR ("Bands covered — one dipole leg per band"). Build sheet: the tuning chart was one whole-antenna table from the summed leg length, meaningless for a fan. A fan dipole now gets a per-band tuning table (BandTuningRow) — each leg's length and the trim that shifts its band ~1% — and the overall chart is suppressed. Templates render whichever applies. Tests cover the per-leg report, the summary's "Bands covered" section, the per-band tuning table replacing the overall chart, and that non-fan antennas keep the overall chart. Suite 521 -> 526. Note: the per-leg SWRs surface that the modelled legs interact strongly at their default half-wave lengths (some bands read high until trimmed) — a fan-dipole geometry-fidelity matter, separate from this presentation fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../resources/templates/buildsheet.md.j2 | 13 +++++ .../resources/templates/buildsheet_html.j2 | 12 +++++ src/ars_wireworks/results/buildsheet.py | 45 +++++++++++++++- src/ars_wireworks/solver/multiband.py | 33 +++++++++++- src/ars_wireworks/ui/main_window.py | 54 ++++++++++++++++--- tests/test_buildsheet.py | 38 +++++++++++++ tests/test_fan_dipole.py | 42 +++++++++++++++ 7 files changed, 227 insertions(+), 10 deletions(-) diff --git a/src/ars_wireworks/resources/templates/buildsheet.md.j2 b/src/ars_wireworks/resources/templates/buildsheet.md.j2 index 4864e62..83a04e9 100644 --- a/src/ars_wireworks/resources/templates/buildsheet.md.j2 +++ b/src/ars_wireworks/resources/templates/buildsheet.md.j2 @@ -33,6 +33,7 @@ _Quantities and specifications only — no prices._ - {{ note }} {% endfor %} +{% if sheet.tuning_chart %} ## Tuning chart If the antenna resonates off target, adjust the overall wire length. On a centre-fed antenna, split the change equally between the two legs. @@ -44,6 +45,18 @@ If the antenna resonates off target, adjust the overall wire length. On a centre {% endfor %} Real-world results vary — adjust in small steps and re-measure. Beams, verticals with radials, and tuned loops behave differently from a plain wire. +{% endif %} +{% if sheet.band_tuning %} +## Per-band tuning + +Each leg tunes its own band independently — trim the leg for the band you are adjusting, shorter to raise its resonance, longer to lower it. Trim a little at a time and re-measure; the legs interact slightly. + +| Band | Leg length | Trim to raise ~1% | +|------|-----------:|------------------:| +{% for row in sheet.band_tuning %} +| {{ row.band }} | {{ row.leg_length_m | length(sheet.unit_system, 2) }} | {{ row.trim_per_percent_m | small_length(sheet.unit_system) }} | +{% endfor %} +{% endif %} {% if sheet.coil_specs %} ## Loading coils diff --git a/src/ars_wireworks/resources/templates/buildsheet_html.j2 b/src/ars_wireworks/resources/templates/buildsheet_html.j2 index 506d298..6d5d599 100644 --- a/src/ars_wireworks/resources/templates/buildsheet_html.j2 +++ b/src/ars_wireworks/resources/templates/buildsheet_html.j2 @@ -54,6 +54,7 @@ {% endfor %} +{% if sheet.tuning_chart %}

Tuning chart

If the antenna resonates off target, adjust the overall wire length. On a centre-fed antenna, split the change equally between the two legs.

@@ -63,6 +64,17 @@ {% endfor %}

Real-world results vary — adjust in small steps and re-measure. Beams, verticals with radials, and tuned loops behave differently from a plain wire.

+{% endif %} +{% if sheet.band_tuning %} +

Per-band tuning

+

Each leg tunes its own band independently — trim the leg for the band you are adjusting, shorter to raise its resonance, longer to lower it. Trim a little at a time and re-measure; the legs interact slightly.

+ + +{% for row in sheet.band_tuning %} + +{% endfor %} +
BandLeg lengthTrim to raise ~1%
{{ row.band }}{{ row.leg_length_m | length(sheet.unit_system, 2) }}{{ row.trim_per_percent_m | small_length(sheet.unit_system) }}
+{% endif %} {% if sheet.coil_specs %}

Loading coils

diff --git a/src/ars_wireworks/results/buildsheet.py b/src/ars_wireworks/results/buildsheet.py index 337d0ef..a763339 100644 --- a/src/ars_wireworks/results/buildsheet.py +++ b/src/ars_wireworks/results/buildsheet.py @@ -115,6 +115,19 @@ def magnitude_cm(self) -> float: return abs(self.length_change_m) * 100.0 +@dataclass(frozen=True) +class BandTuningRow: + """Per-band tuning guidance for a multiband-by-leg antenna (a fan dipole). + + Each leg tunes its own band independently, so the chart gives each leg's + length and how much to trim it to move that band's resonance ~1% higher. + """ + + band: str + leg_length_m: float + trim_per_percent_m: float + + @dataclass(frozen=True) class BuildSheet: """Everything that goes on the printable build sheet (spec §9).""" @@ -135,6 +148,8 @@ class BuildSheet: trap_specs: tuple[TrapBuildSpec, ...] = () #: Per-component inserted-reactance table (spec §9, line 246). inserted_reactance: tuple[InsertedReactanceRow, ...] = () + #: Per-band tuning for a fan dipole — one row per leg (spec §9). + band_tuning: tuple[BandTuningRow, ...] = () def tuning_chart(reference_length_m: float) -> tuple[TuningRow, ...]: @@ -184,6 +199,15 @@ def build_sheet( else: total_length = sum(_wire_lengths(deck)) + # A fan dipole tunes per leg, so the single whole-antenna tuning chart is + # meaningless — give a per-band chart instead. + if isinstance(model, FanDipoleModel): + overall_tuning: tuple[TuningRow, ...] = () + band_tuning = _fan_dipole_band_tuning(model) + else: + overall_tuning = tuning_chart(total_length) + band_tuning = () + return BuildSheet( antenna_name=antenna_name(model), frequency_mhz=results.frequency_hz / 1e6, @@ -192,7 +216,7 @@ def build_sheet( model, deck, total_length, unit_system, waste_factor ), installation_notes=_installation_notes(deck, unit_system), - tuning_chart=tuning_chart(total_length), + tuning_chart=overall_tuning, why_this_design=plain_language_summary(results), trim_margin_percent=trim_margin * 100.0, geometry_svg=geometry_sketch(model, unit_system=unit_system), @@ -200,6 +224,7 @@ def build_sheet( coil_specs=coil_build_specs(model, results.frequency_hz), trap_specs=trap_build_specs(model), inserted_reactance=inserted_reactance_rows(model, results.frequency_hz), + band_tuning=band_tuning, ) @@ -246,6 +271,24 @@ def _cut_list( ) +def _fan_dipole_band_tuning(model: FanDipoleModel) -> tuple[BandTuningRow, ...]: + """Per-leg tuning rows for a fan dipole — each leg tunes its own band. + + A leg's resonance moves inversely with its length, so trimming ~1% of the + leg shifts that band ~1% higher; the legs are independent. + """ + return tuple( + BandTuningRow( + band=f"{leg.resonant_frequency_hz / 1e6:.3f} MHz", + leg_length_m=leg.length_m, + trim_per_percent_m=leg.length_m * 0.01, + ) + for leg in sorted( + model.legs, key=lambda leg: leg.resonant_frequency_hz + ) + ) + + def _fan_dipole_cut_list( model: FanDipoleModel, trim_margin: float ) -> tuple[CutListItem, ...]: diff --git a/src/ars_wireworks/solver/multiband.py b/src/ars_wireworks/solver/multiband.py index 7d7f254..0434e76 100644 --- a/src/ars_wireworks/solver/multiband.py +++ b/src/ars_wireworks/solver/multiband.py @@ -9,7 +9,7 @@ from dataclasses import dataclass -from ars_wireworks.model.antenna import AntennaModel +from ars_wireworks.model.antenna import AntennaModel, FanDipoleModel from ars_wireworks.solver.base import Solver, SolverConvergenceError from ars_wireworks.solver.results import Results @@ -105,3 +105,34 @@ def _characterise( return resonant_mhz, best_swr, 0.0 width_mhz = discriminant**0.5 / curvature * spacing return resonant_mhz, best_swr, width_mhz * 1000.0 + + +@dataclass(frozen=True) +class LegBand: + """One fan-dipole leg's band coverage — its design frequency and SWR.""" + + frequency_mhz: float + swr: float + + +def fan_dipole_legs_report( + model: FanDipoleModel, solver: Solver +) -> list[LegBand]: + """The SWR on each fan-dipole leg's band. + + A fan dipole is multiband by construction — one dipole leg per band — so + rather than the harmonic-match scan :func:`band_matches` runs, this solves + the antenna at each leg's own design frequency and reports the match there, + low band to high. The legs interact, so a leg may read off 2:1 until it is + trimmed; this still shows every band the antenna is built to cover. + """ + report: list[LegBand] = [] + for leg in sorted(model.legs, key=lambda leg: leg.resonant_frequency_hz): + try: + results = solver.solve(model, leg.resonant_frequency_hz) + except SolverConvergenceError: + continue + report.append( + LegBand(leg.resonant_frequency_hz / 1e6, _operator_swr(results)) + ) + return report diff --git a/src/ars_wireworks/ui/main_window.py b/src/ars_wireworks/ui/main_window.py index 8faba7a..a618d29 100644 --- a/src/ars_wireworks/ui/main_window.py +++ b/src/ars_wireworks/ui/main_window.py @@ -71,6 +71,7 @@ from ars_wireworks.results.buildsheet import build_sheet from ars_wireworks.results.report import render_report from ars_wireworks.results.summary import ( + format_swr, numeric_summary, plain_language_summary, summary_rows, @@ -258,11 +259,13 @@ def _path_geometry_page( return page, box -def _summary_html(results, band_matches=None) -> str: +def _summary_html(results, band_matches=None, fan_legs=None) -> str: """The numeric summary as HTML — each result is a clickable anchor (§7). ``band_matches`` is the multiband scan (spec §9): a list of matches, an - empty list when none were found, or None when no scan was run. + empty list when none were found, or None when no scan was run. ``fan_legs`` + is a fan dipole's per-leg band report; when present it replaces the + harmonic-match scan, because a fan dipole covers a band per leg. """ rows = summary_rows(results) width = max(len(label) for label, _, _ in rows) @@ -275,7 +278,27 @@ def _summary_html(results, band_matches=None) -> str: lines.append(f'{prefix}{html.escape(value)}') block = "
" + "\n".join(lines) + "
" sentence = "

" + html.escape(plain_language_summary(results)) + "

" - return block + sentence + _other_bands_html(band_matches) + bands = ( + _fan_legs_html(fan_legs) + if fan_legs is not None + else _other_bands_html(band_matches) + ) + return block + sentence + bands + + +def _fan_legs_html(fan_legs) -> str: + """A fan dipole's per-leg band coverage — one dipole leg per band (§9).""" + if not fan_legs: + return "" + lines = [f"{'Band':<14}SWR"] + for leg in fan_legs: + lines.append(f"{leg.frequency_mhz:.3f} MHz {format_swr(leg.swr)}") + body = html.escape("\n".join(lines)) + return ( + "

Bands covered — one dipole leg per band; trim each leg to " + "tune its own band:

" + f"
{body}
" + ) def _other_bands_html(band_matches) -> str: @@ -1884,7 +1907,10 @@ def _on_run(self) -> None: ) return - from ars_wireworks.solver.multiband import band_matches + from ars_wireworks.solver.multiband import ( + band_matches, + fan_dipole_legs_report, + ) try: solver = NecppSolver() @@ -1898,17 +1924,29 @@ def _on_run(self) -> None: except Exception: # noqa: BLE001 - the band scan is best-effort matches = [] - self._display_solution(model, results, matches) + # A fan dipole is multiband by leg, so report the SWR on each leg's own + # band rather than the harmonic-match scan, which the coupled legs miss. + fan_legs = None + if isinstance(model, FanDipoleModel): + try: + fan_legs = fan_dipole_legs_report(model, solver) + except Exception: # noqa: BLE001 - best-effort, like the band scan + fan_legs = [] + + self._display_solution(model, results, matches, fan_legs=fan_legs) def _display_solution( - self, model, results, matches, *, notice_html: str = "" + self, model, results, matches, *, notice_html: str = "", fan_legs=None ) -> None: """Populate every result view from a solved model. Shared by the Run and Auto-resonate actions. ``notice_html`` is an - optional banner prepended to the summary (the auto-resonate readout). + optional banner prepended to the summary (the auto-resonate readout); + ``fan_legs`` is a fan dipole's per-leg band report, if any. """ - self._output.setHtml(notice_html + _summary_html(results, matches)) + self._output.setHtml( + notice_html + _summary_html(results, matches, fan_legs) + ) self._why_panel.show_choices(results.engine_choices) self._why_panel.set_expanded(self._learn_mode) self._pattern_view.show_results(results) diff --git a/tests/test_buildsheet.py b/tests/test_buildsheet.py index cb73753..9d4c054 100644 --- a/tests/test_buildsheet.py +++ b/tests/test_buildsheet.py @@ -256,3 +256,41 @@ def test_ocfd_cut_list_gives_the_short_and_long_legs() -> None: # one-third / two-thirds split of the half-wave, so the feed offset shows assert short == pytest.approx(model.length_m / 3.0, rel=1e-3) assert long == pytest.approx(short * 2.0, rel=1e-3) + + +def _fan_dipole(): + from ars_wireworks.model.antenna import FanDipoleLeg, FanDipoleModel, half_wave_length_m + + bands = [7.15e6, 14.175e6, 28.5e6] + legs = tuple( + FanDipoleLeg(resonant_frequency_hz=f, length_m=half_wave_length_m(f)) + for f in bands + ) + return FanDipoleModel(frequency_hz=7.15e6, height_m=10.0, legs=legs) + + +def test_fan_dipole_has_per_band_tuning_not_one_overall_chart() -> None: + sheet = build_sheet(_fan_dipole(), _results()) + # the single whole-antenna tuning chart is meaningless for a fan dipole + assert sheet.tuning_chart == () + # one per-band tuning row per leg, labelled by frequency + assert len(sheet.band_tuning) == 3 + bands = [row.band for row in sheet.band_tuning] + assert bands == ["7.150 MHz", "14.175 MHz", "28.500 MHz"] + # trimming ~1% of a leg shifts its band ~1% + row = sheet.band_tuning[0] + assert row.trim_per_percent_m == pytest.approx(row.leg_length_m * 0.01) + + +def test_fan_dipole_build_sheet_renders_per_band_tuning() -> None: + md = render_markdown(build_sheet(_fan_dipole(), _results())) + assert "## Per-band tuning" in md + assert "## Tuning chart" not in md # the overall chart is suppressed + for band in ("7.150 MHz", "14.175 MHz", "28.500 MHz"): + assert band in md + + +def test_non_fan_keeps_the_overall_tuning_chart() -> None: + sheet = build_sheet(DipoleModel(frequency_hz=FREQ_40M_HZ, height_m=10.0), _results()) + assert len(sheet.tuning_chart) == 6 + assert sheet.band_tuning == () diff --git a/tests/test_fan_dipole.py b/tests/test_fan_dipole.py index 5242cf1..fd8123b 100644 --- a/tests/test_fan_dipole.py +++ b/tests/test_fan_dipole.py @@ -215,3 +215,45 @@ def test_main_window_builds_and_round_trips_a_fan_dipole(qapp) -> None: window._fan_dipole_editor.add_leg(frequency_hz=28.5e6) window._restore_state(state) assert len(window._fan_dipole_editor.legs()) == 2 + + +def test_fan_dipole_legs_report_covers_every_leg_band() -> None: + from ars_wireworks.solver.multiband import LegBand, fan_dipole_legs_report + + model = FanDipoleModel( + frequency_hz=7.15e6, + height_m=12.0, + legs=( + 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)), + ), + ) + report = fan_dipole_legs_report(model, NecppSolver()) + assert [type(item) for item in report] == [LegBand, LegBand] + # one entry per leg, low band first, each with a real SWR + freqs = [round(item.frequency_mhz, 3) for item in report] + assert freqs == [7.150, 14.175] + assert all(item.swr >= 1.0 for item in report) + + +def test_summary_html_lists_every_leg_band_for_a_fan_dipole(qapp) -> None: + from ars_wireworks.solver.multiband import fan_dipole_legs_report + from ars_wireworks.ui.main_window import _summary_html + + model = FanDipoleModel( + frequency_hz=7.15e6, + height_m=12.0, + legs=( + 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)), + ), + ) + solver = NecppSolver() + results = solver.solve(model, model.frequency_hz) + report = fan_dipole_legs_report(model, solver) + html_text = _summary_html(results, [], report) + # the summary reports every leg band, not just the operating frequency + assert "Bands covered" in html_text + assert "7.150 MHz" in html_text + assert "14.175 MHz" in html_text + assert "not a close match" not in html_text # the misleading line is gone From ba5ca87493e69ec6cc8e9750c61c466f989016ad Mon Sep 17 00:00:00 2001 From: Mike Kipps Date: Wed, 3 Jun 2026 12:03:08 -0400 Subject: [PATCH 2/2] Make the fan-dipole leg-end spacing a user-set variable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fan geometry used a fixed 30° azimuth fan, so the spacing between adjacent leg ends was an uncontrolled by-product of angle × leg length — and came out backwards: the legs closest in frequency (nearly the same length) ended up closest together, maximising the coupling that detunes them. Replace it with a controlled end spacing — the distance between adjacent leg ends, which is what a spreader actually sets. FanDipoleModel gains end_spacing_m (default 0.3 m); leg_end_offsets_m centres the legs and steps their ends by that much, and the card builder fans each leg to put its end at that perpendicular offset (a longer leg takes a shallower angle). The model rejects a spacing too large for the shortest leg. A "Leg-end spacing" field on the fan-dipole input drives it (auto-saved and unit-aware like the other length inputs). Widening it measurably reduces the inter-leg coupling on most bands; perfect multiband still needs per-leg trimming (the per-band tuning chart), since a leg can also be a harmonic on another leg's band. Tests: the end offsets step by the spacing, the deck's leg ends match, an over-large spacing is rejected, and the field round-trips through the form. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ars_wireworks/cards/fan_dipole.py | 28 +++++------ src/ars_wireworks/model/antenna.py | 30 ++++++++++++ src/ars_wireworks/ui/main_window.py | 8 ++- tests/test_fan_dipole.py | 70 +++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 18 deletions(-) diff --git a/src/ars_wireworks/cards/fan_dipole.py b/src/ars_wireworks/cards/fan_dipole.py index d37c563..be7a1d2 100644 --- a/src/ars_wireworks/cards/fan_dipole.py +++ b/src/ars_wireworks/cards/fan_dipole.py @@ -29,10 +29,6 @@ #: 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 @@ -89,19 +85,16 @@ def build_fan_dipole_deck( ), ] - # Spread the legs symmetrically about the X axis. + # Fan each leg so its end sits at a controlled perpendicular offset from + # the feed — adjacent ends step by ``end_spacing_m`` (the spreader spacing). + # The leg runs straight from the feed to that end, so it still spans its + # full half-length; a longer leg therefore needs a shallower angle. + offsets = model.leg_end_offsets_m 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) + dy = offsets[index] + dx = math.sqrt(max(half * half - dy * dy, 0.0)) right_tag = 2 * index + 2 left_tag = 2 * index + 3 cards.append( @@ -166,9 +159,10 @@ def build_fan_dipole_deck( 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." + f"I fanned the legs so adjacent ends sit " + f"{model.end_spacing_m:.2f} m apart — the spreader spacing you " + f"set. Wider spacing reduces how much the legs detune one " + f"another, at the cost of more end support." ), ), ground_choice, diff --git a/src/ars_wireworks/model/antenna.py b/src/ars_wireworks/model/antenna.py index 65aeb1c..20d349e 100644 --- a/src/ars_wireworks/model/antenna.py +++ b/src/ars_wireworks/model/antenna.py @@ -164,10 +164,17 @@ class FanDipoleModel(AntennaModel): 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``. + + ``end_spacing_m`` is the distance between the ends of adjacent legs — what + a spreader at the leg ends sets. The legs are fanned so their ends step by + this much across the fan; the wider the spacing, the less the legs couple + (and detune one another), at the cost of needing more end support. """ height_m: float legs: tuple[FanDipoleLeg, ...] + #: Spacing between the ends of adjacent legs (the spreader spacing). + end_spacing_m: float = 0.3 def __post_init__(self) -> None: super().__post_init__() @@ -175,12 +182,35 @@ def __post_init__(self) -> None: 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") + if self.end_spacing_m <= 0.0: + raise ValueError("end_spacing_m must be positive") + for leg, offset in zip(self.legs, self.leg_end_offsets_m): + # The end of a leg can sit at most its half-length out from the + # feed, so an end offset must stay well inside that. + if abs(offset) > 0.8 * leg.length_m / 2.0: + raise ValueError( + "end_spacing_m is too large for the shortest leg — " + "reduce the spacing or the number of 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) + @property + def leg_end_offsets_m(self) -> tuple[float, ...]: + """Each leg's end offset across the fan, centred on zero. + + Adjacent legs' ends step by ``end_spacing_m``; the card builder fans + each leg to put its end at this perpendicular offset from the feed. + """ + count = len(self.legs) + return tuple( + (index - (count - 1) / 2.0) * self.end_spacing_m + for index in range(count) + ) + @dataclass(kw_only=True) class FoldedDipoleModel(AntennaModel): diff --git a/src/ars_wireworks/ui/main_window.py b/src/ars_wireworks/ui/main_window.py index a618d29..ba99631 100644 --- a/src/ars_wireworks/ui/main_window.py +++ b/src/ars_wireworks/ui/main_window.py @@ -1119,8 +1119,12 @@ 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). + # Fan dipole: a height, the spreader spacing between leg ends, and 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_end_spacing = self._length( + min_m=0.05, max_m=10.0, value_m=0.3 + ) self._fan_dipole_editor = FanDipoleLegEditor(self._prefs.unit_system) fan_dipole_page = QWidget() fan_outer = QVBoxLayout(fan_dipole_page) @@ -1128,6 +1132,7 @@ def _build_central_widget(self) -> QWidget: fan_height_form = QFormLayout() fan_height_form.setContentsMargins(0, 0, 0, 0) fan_height_form.addRow("Height", self._fan_height) + fan_height_form.addRow("Leg-end spacing", self._fan_end_spacing) fan_outer.addLayout(fan_height_form) fan_outer.addWidget(self._fan_dipole_editor) # Rhombic. @@ -2230,6 +2235,7 @@ def _build_model(self) -> AntennaModel: **common, height_m=self._fan_height.metres(), legs=self._fan_dipole_editor.legs(), + end_spacing_m=self._fan_end_spacing.metres(), **loaded, ) if kind == "inverted_v": diff --git a/tests/test_fan_dipole.py b/tests/test_fan_dipole.py index fd8123b..19eda6e 100644 --- a/tests/test_fan_dipole.py +++ b/tests/test_fan_dipole.py @@ -257,3 +257,73 @@ def test_summary_html_lists_every_leg_band_for_a_fan_dipole(qapp) -> None: assert "7.150 MHz" in html_text assert "14.175 MHz" in html_text assert "not a close match" not in html_text # the misleading line is gone + + +# --- leg-end spacing -------------------------------------------------------- + + +def _seven_band_legs() -> tuple: + bands = [7.15e6, 10.125e6, 14.175e6, 18.118e6, 21.225e6, 24.94e6, 28.5e6] + return tuple( + FanDipoleLeg(resonant_frequency_hz=f, length_m=half_wave_length_m(f)) + for f in bands + ) + + +def test_leg_end_offsets_step_by_the_end_spacing() -> None: + model = FanDipoleModel( + frequency_hz=7.15e6, height_m=10.0, legs=_seven_band_legs(), + end_spacing_m=0.4, + ) + offsets = model.leg_end_offsets_m + # centred on zero, adjacent ends a constant end_spacing apart + assert offsets[len(offsets) // 2] == pytest.approx(0.0) + gaps = [offsets[i + 1] - offsets[i] for i in range(len(offsets) - 1)] + assert all(gap == pytest.approx(0.4) for gap in gaps) + + +def test_deck_ends_are_spaced_by_the_end_spacing() -> None: + from ars_wireworks.cards.fan_dipole import build_fan_dipole_deck + + model = FanDipoleModel( + frequency_hz=7.15e6, height_m=10.0, legs=_seven_band_legs(), + end_spacing_m=0.4, + ) + deck, _ = build_fan_dipole_deck(model, 7.15e6) + # right-half far-end Y coordinates (even tags) step by exactly the spacing + ends = sorted( + c.reals[4] for c in deck.cards_of("GW") + if c.integers[0] >= 2 and c.integers[0] % 2 == 0 + ) + gaps = [round(ends[i + 1] - ends[i], 4) for i in range(len(ends) - 1)] + assert all(gap == pytest.approx(0.4) for gap in gaps) + + +def test_end_spacing_too_large_for_the_shortest_leg_is_rejected() -> None: + with pytest.raises(ValueError, match="too large"): + FanDipoleModel( + frequency_hz=7.15e6, height_m=10.0, legs=_seven_band_legs(), + end_spacing_m=2.0, + ) + with pytest.raises(ValueError): + FanDipoleModel( + frequency_hz=7.15e6, height_m=10.0, legs=_seven_band_legs(), + end_spacing_m=0.0, + ) + + +def test_main_window_round_trips_the_end_spacing(qapp) -> None: + from ars_wireworks.ui.main_window import MainWindow + + window = MainWindow() + window._antenna_type.setCurrentIndex( + window._antenna_type.findData("fan_dipole") + ) + window._fan_end_spacing.set_metres(0.5) + model = window._build_model() + assert model.end_spacing_m == pytest.approx(0.5, abs=1e-3) + # the spinbox is captured and restored with the rest of the form + state = window._capture_state() + window._fan_end_spacing.set_metres(0.2) + window._restore_state(state) + assert window._fan_end_spacing.metres() == pytest.approx(0.5, abs=1e-3)