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