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/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 %}
If the antenna resonates off target, adjust the overall wire length. On a centre-fed antenna, split the change equally between the two legs.
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 %} +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% |
|---|---|---|
| {{ row.band }} | {{ row.leg_length_m | length(sheet.unit_system, 2) }} | {{ row.trim_per_percent_m | small_length(sheet.unit_system) }} |
" + "\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:
@@ -1096,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)
@@ -1105,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.
@@ -1884,7 +1912,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 +1929,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)
@@ -2192,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_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..19eda6e 100644
--- a/tests/test_fan_dipole.py
+++ b/tests/test_fan_dipole.py
@@ -215,3 +215,115 @@ 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
+
+
+# --- 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)