Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 75 additions & 87 deletions src/ars_wireworks/cards/fan_dipole.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -26,145 +26,133 @@
#: 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"),
# The feed bridge across the centre gap (fed at its only segment).
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
19 changes: 18 additions & 1 deletion src/ars_wireworks/cards/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -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(
Expand Down
34 changes: 13 additions & 21 deletions src/ars_wireworks/model/antenna.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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(
Expand Down
Loading
Loading