From d02f8853202e5041b037de2a488b8529cbb65014 Mon Sep 17 00:00:00 2001 From: Mike Kipps Date: Wed, 3 Jun 2026 12:33:05 -0400 Subject: [PATCH] Fix fan-dipole coupling: parallel legs, and silence the segment false alarm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fan dipole fanned its legs out from one tiny feed gap, so the legs converged to near-zero separation exactly at the high-current feedpoint — maximising the coupling that detunes them. A leg that is a harmonic on another's band (the 80 m leg is a full-wave on 40 m, the 40 m on 20 m) then swamped the resonant leg: the 40 m leg in an 80/40 fan read SWR ~405. Rebuild the geometry as a true parallel manifold: the legs run parallel a fixed end_spacing apart, a left and right bus carry each leg's halves back to a short central feed bridge, so the spacing is constant the whole length — including at the feed. The end spacing now genuinely controls coupling. On an 80/40/15/10 fan the 40 m leg drops from ~405 to ~14, and a 40/20/15/10 fan's 20 m leg from ~38 to ~10 (octave legs still need per-leg trimming — that is physics, and the per-band tuning chart is for it). Because the legs are parallel, an end offset is no longer bounded by leg length, so the old "too large for the shortest leg" limit is gone. Also fix the validation false alarm the user hit on Run: the segment-length check judged the short legs and feed bridge against the long design wavelength. A fan dipole's legs are auto-segmented for their own bands and its feed bridge/buses are intentional short structural wires, so the check is skipped for a fan dipole. Tests updated for the parallel geometry; added guards that the octave-leg SWR stays workable and that no short-segment alarm fires. Suite 530 -> 532. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ars_wireworks/cards/fan_dipole.py | 162 ++++++++++++-------------- src/ars_wireworks/cards/validation.py | 19 ++- src/ars_wireworks/model/antenna.py | 34 +++--- tests/test_fan_dipole.py | 84 +++++++++---- 4 files changed, 165 insertions(+), 134 deletions(-) diff --git a/src/ars_wireworks/cards/fan_dipole.py b/src/ars_wireworks/cards/fan_dipole.py index be7a1d2..644bc15 100644 --- a/src/ars_wireworks/cards/fan_dipole.py +++ b/src/ars_wireworks/cards/fan_dipole.py @@ -1,16 +1,16 @@ """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. +The legs run parallel, a fixed ``end_spacing_m`` apart, so their separation is +constant along the whole length — including the high-current feed region, +where a converging fan would cram them together and couple hardest. A short +feed bridge at the centre joins a left and a right bus; each leg's two halves +tie to the buses at the leg's own offset, so all the legs hang in parallel +across one feedpoint with the spacing the builder set. """ from __future__ import annotations -import math - from ars_wireworks.cards.common import ( frequency_card, geometry_end_card, @@ -26,54 +26,39 @@ #: 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. +#: The feed bridge across the centre gap is a single segment. FEED_TAG: int = 1 +#: Half-width of the centre feed gap (metres) — the small gap between a leg's +#: two halves, bridged through the feed. +_FEED_HALF_GAP_M: float = 0.05 + 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. + Geometry comes from the legs' lengths and the end spacing; ``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) + gap = _FEED_HALF_GAP_M + offsets = model.leg_end_offsets_m 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" + f"Design {model.frequency_hz / 1e6:.4g} MHz, {len(model.legs)} " + f"legs, longest {model.longest_leg_m:.3f} m, ends " + f"{model.end_spacing_m:.3f} m apart, height {height:.3f} m" ), ), Card("CE"), @@ -81,90 +66,93 @@ def build_fan_dipole_deck( Card( "GW", integers=(FEED_TAG, 1), - reals=(*left_node, *right_node, radius), + reals=(-gap, 0.0, height, gap, 0.0, height, radius), ), ] - # 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) - ): - dy = offsets[index] - dx = math.sqrt(max(half * half - dy * dy, 0.0)) - right_tag = 2 * index + 2 - left_tag = 2 * index + 3 + tag = FEED_TAG + 1 + + # Left and right buses run along Y at x = -/+gap, tying every leg's inner + # ends back to the feed bridge at y = 0. One short segment per gap between + # consecutive tap points (the legs' offsets and the feed at 0). + bus_ys = sorted(set(offsets) | {0.0}) + for x in (-gap, gap): + for y_a, y_b in zip(bus_ys, bus_ys[1:]): + cards.append( + Card( + "GW", + integers=(tag, 1), + reals=(x, y_a, height, x, y_b, height, radius), + ) + ) + tag += 1 + + # Each leg: two halves running out along X at the leg's own Y offset, so + # the legs stay parallel and a constant end_spacing apart. + for leg, offset in zip(model.legs, offsets): + half = leg.length_m / 2.0 + segments = wire_segment_count( + half, model.wavelength_m, minimum=MIN_LEG_SEGMENTS, + force_odd=False, density=model.segments_per_wavelength, + ) cards.append( Card( "GW", - integers=(right_tag, segments), - reals=( - *right_node, - right_node[0] + dx, - right_node[1] + dy, - height, - radius, - ), + integers=(tag, segments), + reals=(gap, offset, height, half, offset, height, radius), ) ) + tag += 1 cards.append( Card( "GW", - integers=(left_tag, segments), - reals=( - *left_node, - left_node[0] - dx, - left_node[1] - dy, - height, - radius, - ), + integers=(tag, segments), + reals=(-gap, offset, height, -half, offset, height, radius), ) ) + tag += 1 cards.extend( [ geometry_end_card(), - ground, - voltage_source_card(FEED_TAG, 1), + *_ground_and_feed(model), 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." - ), - ), + return CardDeck(tuple(cards)), _choices(model) + + +def _ground_and_feed(model: FanDipoleModel) -> list[Card]: + """The GN card and the voltage source on the feed bridge.""" + ground, _ = ground_card(model) + return [ground, voltage_source_card(FEED_TAG, 1)] + + +def _choices(model: FanDipoleModel) -> list[EngineChoice]: + """The "Why did the model choose this?" notes for a fan dipole.""" + _, ground_choice = ground_card(model) + return [ 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." + "I fed a short bridge across the centre gap, with a left and " + "right bus carrying each leg's two halves back to it — 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", + topic="Leg layout", explanation=( - 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." + f"I ran the legs parallel, {model.end_spacing_m:.2f} m apart, " + f"so their spacing is constant the whole way — including at " + f"the feed, where a fan would crowd them together. Wider " + f"spacing reduces how much the legs detune one another." ), ), ground_choice, ] - return CardDeck(tuple(cards)), choices diff --git a/src/ars_wireworks/cards/validation.py b/src/ars_wireworks/cards/validation.py index 9f0007c..091b659 100644 --- a/src/ars_wireworks/cards/validation.py +++ b/src/ars_wireworks/cards/validation.py @@ -47,6 +47,19 @@ class ValidationIssue: blocks_run: bool +def _skip_segment_length_check(model: AntennaModel) -> bool: + """Whether to skip the shortest-segment check for ``model``. + + A fan dipole's legs are each auto-segmented for their own band, and its + feed bridge and buses are intentional short structural wires (a standard + NEC technique). Judged against the long design wavelength they look "too + short", but it is a false alarm — skip the check for a fan dipole. + """ + from ars_wireworks.model.antenna import FanDipoleModel + + return isinstance(model, FanDipoleModel) + + def validate(model: AntennaModel) -> list[ValidationIssue]: """Check ``model`` for common mistakes before solving (spec §13).""" issues: list[ValidationIssue] = [] @@ -87,7 +100,11 @@ def validate(model: AntennaModel) -> list[ValidationIssue]: ) ) - if shortest_segment is not None and wavelength > 0.0: + if ( + shortest_segment is not None + and wavelength > 0.0 + and not _skip_segment_length_check(model) + ): if shortest_segment < wavelength / 200.0: issues.append( ValidationIssue( diff --git a/src/ars_wireworks/model/antenna.py b/src/ars_wireworks/model/antenna.py index 20d349e..5314ba8 100644 --- a/src/ars_wireworks/model/antenna.py +++ b/src/ars_wireworks/model/antenna.py @@ -159,16 +159,16 @@ def __post_init__(self) -> None: 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``. - - ``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. + Each leg is a half-wave dipole cut for its own band, all fed across a + common centre gap, giving multiband coverage without traps. On any given + band the resonant leg presents a low impedance and dominates, while the + others sit off-resonance. The legs run parallel in a horizontal plane at + ``height_m``, stacked along the Y axis. + + ``end_spacing_m`` is the distance between adjacent legs — what a spreader + sets. Because the legs are parallel, that spacing holds the whole way, + including at the feed; the wider it is, the less the legs couple (and + detune one another), at the cost of needing more support. """ height_m: float @@ -184,14 +184,6 @@ def __post_init__(self) -> None: 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: @@ -200,10 +192,10 @@ def longest_leg_m(self) -> float: @property def leg_end_offsets_m(self) -> tuple[float, ...]: - """Each leg's end offset across the fan, centred on zero. + """Each leg's Y offset, 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. + Adjacent legs are ``end_spacing_m`` apart; the card builder runs each + leg parallel to the others at this offset. """ count = len(self.legs) return tuple( diff --git a/tests/test_fan_dipole.py b/tests/test_fan_dipole.py index 19eda6e..748ce10 100644 --- a/tests/test_fan_dipole.py +++ b/tests/test_fan_dipole.py @@ -64,11 +64,12 @@ def test_longest_leg_is_the_lowest_band() -> None: # --- card builder ----------------------------------------------------------- -def test_deck_has_a_feed_bridge_and_two_wires_per_leg() -> None: +def test_deck_has_a_feed_bridge_and_two_halves_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) + # feed bridge + left/right buses + two halves per leg + assert sum(1 for c in gw if c.integers[0] == FEED_TAG) == 1 + 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 @@ -77,18 +78,19 @@ def test_deck_has_a_feed_bridge_and_two_wires_per_leg() -> None: assert deck.cards[-1].mnemonic == "EN" -def test_all_leg_halves_meet_the_feed_nodes() -> None: +def test_legs_run_parallel_at_the_feed_height() -> 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 - ) + height = _model().height_m + # the legs (the long wires) run flat along X at the common height + long_wires = [ + c for c in deck.cards if c.mnemonic == "GW" + and abs(c.reals[3] - c.reals[0]) > 1.0 # spans a metre or more in X + ] + assert long_wires # the leg halves + for card in long_wires: + assert card.reals[2] == pytest.approx(height) # z1 + assert card.reals[5] == pytest.approx(height) # z2 + assert card.reals[1] == pytest.approx(card.reals[4]) # constant Y def test_dispatch_routes_fan_dipole_to_its_builder() -> None: @@ -290,21 +292,25 @@ def test_deck_ends_are_spaced_by_the_end_spacing() -> None: 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 + # the parallel legs (long wires) sit at Y offsets stepping by the spacing + leg_ys = sorted( + { + round(c.reals[1], 4) + for c in deck.cards_of("GW") + if abs(c.reals[3] - c.reals[0]) > 1.0 # a leg half, not a bus + } ) - gaps = [round(ends[i + 1] - ends[i], 4) for i in range(len(ends) - 1)] + gaps = [round(leg_ys[i + 1] - leg_ys[i], 4) for i in range(len(leg_ys) - 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, - ) +def test_end_spacing_must_be_positive() -> None: + # parallel legs have no length limit on their offset, so any positive + # spacing is allowed (wide spacing just needs more support) + 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(), @@ -327,3 +333,31 @@ def test_main_window_round_trips_the_end_spacing(qapp) -> None: 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) + + +def test_fan_dipole_has_no_short_segment_validation_alarm() -> None: + # the short legs + feed bridge are judged per-band, not against the long + # design wavelength, so no false "segment too short" issue is raised + from ars_wireworks.cards.validation import validate + + model = FanDipoleModel( + frequency_hz=7.15e6, height_m=12.0, legs=_seven_band_legs() + ) + messages = [issue.message for issue in validate(model)] + assert not any("shorter than" in m for m in messages) + assert not any(issue.blocks_run for issue in validate(model)) + + +def test_parallel_geometry_tames_the_octave_leg_coupling() -> None: + # an 80/40 fan: the 80 m leg is a full-wave on 40 m. The converging fan + # gave the 40 m leg SWR in the hundreds; the parallel layout keeps the + # legs apart, so it stays workable (well under that). + from ars_wireworks.solver.necpp import NecppSolver + + legs = tuple( + FanDipoleLeg(resonant_frequency_hz=f, length_m=half_wave_length_m(f)) + for f in (3.75e6, 7.15e6, 21.225e6, 28.5e6) + ) + model = FanDipoleModel(frequency_hz=7.15e6, height_m=12.0, legs=legs) + swr_40m = NecppSolver().solve(model, 7.15e6).swr + assert swr_40m < 30.0 # was ~405 with the converging fan