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
3 changes: 3 additions & 0 deletions src/ars_wireworks/cards/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from ars_wireworks.cards.deck import Card, CardDeck
from ars_wireworks.cards.dipole import build_dipole_deck
from ars_wireworks.cards.efhw import build_efhw_deck
from ars_wireworks.cards.fan_dipole import build_fan_dipole_deck
from ars_wireworks.cards.folded_dipole import build_folded_dipole_deck
from ars_wireworks.cards.inverted_v import build_inverted_v_deck
from ars_wireworks.cards.long_wire import build_long_wire_deck
Expand All @@ -27,6 +28,7 @@
DeltaLoopModel,
DipoleModel,
EfhwModel,
FanDipoleModel,
FoldedDipoleModel,
HorizontalLoopModel,
InvertedVeeModel,
Expand All @@ -47,6 +49,7 @@
#: Card-deck builder for each supported antenna model type.
_BUILDERS: dict[type[AntennaModel], _DeckBuilder] = {
DipoleModel: build_dipole_deck,
FanDipoleModel: build_fan_dipole_deck,
FoldedDipoleModel: build_folded_dipole_deck,
InvertedVeeModel: build_inverted_v_deck,
OcfdModel: build_ocfd_deck,
Expand Down
176 changes: 176 additions & 0 deletions src/ars_wireworks/cards/fan_dipole.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"""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.
"""

from __future__ import annotations

import math

from ars_wireworks.cards.common import (
frequency_card,
geometry_end_card,
ground_card,
radiation_pattern_card,
voltage_source_card,
wire_segment_count,
)
from ars_wireworks.cards.deck import Card, CardDeck
from ars_wireworks.model.antenna import FanDipoleModel
from ars_wireworks.model.engine_choice import EngineChoice

#: 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.
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
) -> 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.
"""
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)

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"
),
),
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),
),
]

# Spread the legs symmetrically about the X axis.
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)
right_tag = 2 * index + 2
left_tag = 2 * index + 3
cards.append(
Card(
"GW",
integers=(right_tag, segments),
reals=(
*right_node,
right_node[0] + dx,
right_node[1] + dy,
height,
radius,
),
)
)
cards.append(
Card(
"GW",
integers=(left_tag, segments),
reals=(
*left_node,
left_node[0] - dx,
left_node[1] - dy,
height,
radius,
),
)
)

cards.extend(
[
geometry_end_card(),
ground,
voltage_source_card(FEED_TAG, 1),
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."
),
),
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."
),
),
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."
),
),
ground_choice,
]
return CardDeck(tuple(cards)), choices
12 changes: 10 additions & 2 deletions src/ars_wireworks/cards/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,16 +255,24 @@ def _coil_self_resonant_frequency_hz(coil: LoadingCoil) -> float | None:


def _feed_is_at_centre(model: AntennaModel) -> bool:
"""True for centre-fed antennas (dipole, folded dipole, inverted-V, OCFD)."""
"""True for centre-fed antennas (dipole, fan, folded, inverted-V, OCFD)."""
from ars_wireworks.model.antenna import (
DipoleModel,
FanDipoleModel,
FoldedDipoleModel,
InvertedVeeModel,
OcfdModel,
)

return isinstance(
model, (DipoleModel, FoldedDipoleModel, InvertedVeeModel, OcfdModel)
model,
(
DipoleModel,
FanDipoleModel,
FoldedDipoleModel,
InvertedVeeModel,
OcfdModel,
),
)


Expand Down
58 changes: 58 additions & 0 deletions src/ars_wireworks/model/antenna.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,64 @@ def __post_init__(self) -> None:
raise ValueError("height_m must not be negative (wire below ground)")


def half_wave_length_m(frequency_hz: float) -> float:
"""The practical half-wave dipole length to cut for ``frequency_hz``.

A half wavelength shortened by the end-effect factor — the same formula
:attr:`AntennaModel.length_m` uses, exposed for callers that only have a
frequency (a fan-dipole leg, the fan-dipole leg editor's auto-fill).
"""
if frequency_hz <= 0.0:
raise ValueError("frequency_hz must be positive")
return DIPOLE_END_EFFECT_FACTOR * SPEED_OF_LIGHT_M_PER_S / (2.0 * frequency_hz)


@dataclass(frozen=True)
class FanDipoleLeg:
"""One leg of a fan dipole — a half-wave dipole resonant on its own band.

``resonant_frequency_hz`` is the band the leg is cut for (carried for the
build sheet and labels); ``length_m`` is its actual tip-to-tip span, which
defaults to the half-wave for that frequency but can be trimmed.
"""

resonant_frequency_hz: float
length_m: float

def __post_init__(self) -> None:
if self.resonant_frequency_hz <= 0.0:
raise ValueError("a fan-dipole leg's frequency must be positive")
if self.length_m <= 0.0:
raise ValueError("a fan-dipole leg's length must be positive")


@dataclass(kw_only=True)
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``.
"""

height_m: float
legs: tuple[FanDipoleLeg, ...]

def __post_init__(self) -> None:
super().__post_init__()
if self.height_m < 0.0:
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")

@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)


@dataclass(kw_only=True)
class FoldedDipoleModel(AntennaModel):
"""A centre-fed half-wave folded dipole.
Expand Down
2 changes: 2 additions & 0 deletions src/ars_wireworks/results/buildsheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
DeltaLoopModel,
DipoleModel,
EfhwModel,
FanDipoleModel,
FoldedDipoleModel,
HorizontalLoopModel,
InvertedVeeModel,
Expand Down Expand Up @@ -60,6 +61,7 @@
#: Human-readable name for each antenna model type.
_ANTENNA_NAMES: dict[type[AntennaModel], str] = {
DipoleModel: "Half-wave dipole",
FanDipoleModel: "Fan dipole",
FoldedDipoleModel: "Folded dipole",
InvertedVeeModel: "Inverted-V",
OcfdModel: "Off-center-fed dipole",
Expand Down
Loading
Loading