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
141 changes: 101 additions & 40 deletions src/ars_wireworks/cards/yagi.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""NEC-2 card-deck generation for a Yagi-Uda beam — layer (b) (spec §10).

Element lengths and spacing are auto-designed from standard rules of thumb.
True optimization is out of scope for v1 (spec §16), and per-element control
is a later Advanced Mode feature.
By default element lengths and spacing are auto-designed from standard rules
of thumb. Advanced Mode (spec §8) lets the user override every element's
length and boom position via ``YagiModel.elements``; this builder resolves
either source to the same element list, so the rest of the deck — tags, feed,
loading — is identical. True optimization is out of scope for v1 (spec §16).
"""

from __future__ import annotations
Expand All @@ -17,7 +19,11 @@
wire_segment_count,
)
from ars_wireworks.cards.deck import Card, CardDeck
from ars_wireworks.model.antenna import YagiModel
from ars_wireworks.model.antenna import (
YagiElement,
YagiElementRole,
YagiModel,
)
from ars_wireworks.model.engine_choice import EngineChoice

#: The reflector runs a few percent longer than the driven element, the
Expand All @@ -37,6 +43,53 @@
_FIRST_DIRECTOR_TAG: int = 3


def auto_yagi_elements(model: YagiModel) -> tuple[YagiElement, ...]:
"""The auto-designed elements for ``model`` — the rules-of-thumb beam.

Exposed so the UI can seed a per-element editor with the defaults the
program would otherwise compute.
"""
spacing = ELEMENT_SPACING_WAVELENGTHS * model.wavelength_m
driven_length = model.length_m
elements = [
YagiElement(
role=YagiElementRole.REFLECTOR,
length_m=REFLECTOR_LENGTH_FACTOR * driven_length,
position_m=-spacing,
),
YagiElement(
role=YagiElementRole.DRIVEN,
length_m=driven_length,
position_m=0.0,
),
]
for index in range(model.element_count - 2):
elements.append(
YagiElement(
role=YagiElementRole.DIRECTOR,
length_m=DIRECTOR_LENGTH_FACTOR * driven_length,
position_m=(index + 1) * spacing,
)
)
return tuple(elements)


def resolve_yagi_elements(model: YagiModel) -> tuple[YagiElement, ...]:
"""The model's explicit elements if set, else the auto-designed ones."""
if model.elements is not None:
return model.elements
return auto_yagi_elements(model)


def _tag_for(element: YagiElement, directors: list[YagiElement]) -> int:
"""The NEC tag for ``element`` — preserves 1=driven, 2=reflector, 3+=dir."""
if element.role is YagiElementRole.DRIVEN:
return DRIVEN_TAG
if element.role is YagiElementRole.REFLECTOR:
return REFLECTOR_TAG
return _FIRST_DIRECTOR_TAG + directors.index(element)


def build_yagi_deck(
model: YagiModel, frequency_hz: float
) -> tuple[CardDeck, list[EngineChoice]]:
Expand All @@ -50,51 +103,53 @@ def build_yagi_deck(

radius = model.wire.radius_m
boom_z = model.boom_height_m
spacing = ELEMENT_SPACING_WAVELENGTHS * model.wavelength_m

driven_length = model.length_m
reflector_length = REFLECTOR_LENGTH_FACTOR * driven_length
director_length = DIRECTOR_LENGTH_FACTOR * driven_length
elements = resolve_yagi_elements(model)
# Directors get tags 3, 4, ... in the order they appear.
directors = [
element
for element in elements
if element.role is YagiElementRole.DIRECTOR
]

def element(tag: int, length: float, y: float) -> tuple[Card, int]:
"""A horizontal element of ``length`` at boom position ``y``."""
def element_card(element: YagiElement) -> tuple[Card, int]:
"""The (GW card, segment count) for one element."""
tag = _tag_for(element, directors)
segments = wire_segment_count(
length, model.wavelength_m,
minimum=MIN_ELEMENT_SEGMENTS, force_odd=True,
element.length_m,
model.wavelength_m,
minimum=MIN_ELEMENT_SEGMENTS,
force_odd=True,
density=model.segments_per_wavelength,
)
half = length / 2.0
half = element.length_m / 2.0
card = Card(
"GW",
integers=(tag, segments),
reals=(-half, y, boom_z, half, y, boom_z, radius),
reals=(
-half, element.position_m, boom_z,
half, element.position_m, boom_z,
radius,
),
)
return card, segments

# Driven element at Y = 0; reflector behind it; directors in front.
driven_card, driven_segments = element(DRIVEN_TAG, driven_length, 0.0)
reflector_card, _ = element(REFLECTOR_TAG, reflector_length, -spacing)

cards: list[Card] = [
Card("CM", comment="ARS WireWorks - Yagi-Uda beam"),
Card(
"CM",
comment=(
f"Design {model.frequency_hz / 1e6:.4g} MHz, "
f"{model.element_count} elements, boom {boom_z:.3f} m"
f"{model.total_element_count} elements, boom {boom_z:.3f} m"
),
),
Card("CE"),
driven_card,
reflector_card,
]
for index in range(model.director_count):
director_card, _ = element(
_FIRST_DIRECTOR_TAG + index,
director_length,
(index + 1) * spacing,
)
cards.append(director_card)
driven_segments = 0
for element in elements:
card, segments = element_card(element)
cards.append(card)
if element.role is YagiElementRole.DRIVEN:
driven_segments = segments

cards.append(geometry_end_card())
ground, ground_choice = ground_card(model)
Expand All @@ -104,18 +159,24 @@ def element(tag: int, length: float, y: float) -> tuple[Card, int]:
cards.append(radiation_pattern_card(model))
cards.append(Card("EN"))

if model.elements is None:
element_explanation = (
f"I auto-sized the {model.total_element_count} elements: a "
f"reflector {(REFLECTOR_LENGTH_FACTOR - 1) * 100:.0f}% longer than "
f"the driven element, directors "
f"{(1 - DIRECTOR_LENGTH_FACTOR) * 100:.0f}% shorter, spaced "
f"{ELEMENT_SPACING_WAVELENGTHS:g} wavelengths apart. These are "
f"starting-point rules of thumb, not an optimized design."
)
else:
element_explanation = (
f"I built the {model.total_element_count} elements from the "
f"lengths and boom positions you set, rather than the "
f"rules-of-thumb auto-design."
)

choices: list[EngineChoice] = [
EngineChoice(
topic="Element design",
explanation=(
f"I auto-sized the {model.element_count} elements: a reflector "
f"{(REFLECTOR_LENGTH_FACTOR - 1) * 100:.0f}% longer than the "
f"driven element, directors "
f"{(1 - DIRECTOR_LENGTH_FACTOR) * 100:.0f}% shorter, spaced "
f"{ELEMENT_SPACING_WAVELENGTHS:g} wavelengths apart. These are "
f"starting-point rules of thumb, not an optimized design."
),
),
EngineChoice(topic="Element design", explanation=element_explanation),
EngineChoice(
topic="Feedpoint",
explanation=(
Expand Down
75 changes: 67 additions & 8 deletions src/ars_wireworks/model/antenna.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import math
from dataclasses import dataclass
from enum import Enum

from ars_wireworks.model.constants import (
DIPOLE_END_EFFECT_FACTOR,
Expand Down Expand Up @@ -349,31 +350,89 @@ def radiator_length_m(self) -> float:
return self.length_m / 2.0


class YagiElementRole(Enum):
"""The role of one Yagi element on the boom."""

REFLECTOR = "reflector"
DRIVEN = "driven"
DIRECTOR = "director"


@dataclass(frozen=True)
class YagiElement:
"""One element of a Yagi, placed by the user (Advanced Mode, spec §8).

``position_m`` is the element's place along the boom (the Y axis); the
pattern depends only on the spacings between elements, so the driven
element conventionally sits at 0, the reflector behind it (negative), and
the directors in front (positive).
"""

role: YagiElementRole
length_m: float
position_m: float

def __post_init__(self) -> None:
if self.length_m <= 0.0:
raise ValueError("a Yagi element's length must be positive")


@dataclass(kw_only=True)
class YagiModel(AntennaModel):
"""A horizontally-polarized Yagi-Uda beam of 2 to 6 elements.
"""A horizontally-polarized Yagi-Uda beam.

Elements run east-west (along X) and are spaced along the boom (the Y
axis) at a constant height. The set is one reflector, one driven element,
and ``element_count - 2`` directors. Element lengths and spacing are
auto-designed from rules of thumb by the card-deck builder; per-element
control is an Advanced Mode feature for a later session.
axis) at a constant height. By default the set is one reflector, one
driven element, and ``element_count - 2`` directors, auto-designed from
rules of thumb by the card-deck builder.

Setting ``elements`` overrides the auto-design with the user's own
per-element lengths and boom positions (Advanced Mode, spec §8); exactly
one must be the driven element and at most one a reflector. ``element_count``
is then ignored.
"""

boom_height_m: float
element_count: int = 3
#: Advanced Mode — explicit per-element geometry; ``None`` auto-designs.
elements: tuple[YagiElement, ...] | None = None

def __post_init__(self) -> None:
super().__post_init__()
if self.boom_height_m <= 0.0:
raise ValueError("boom_height_m must be positive")
if not 2 <= self.element_count <= 6:
raise ValueError("element_count must be between 2 and 6")
if self.elements is None:
if not 2 <= self.element_count <= 6:
raise ValueError("element_count must be between 2 and 6")
return
roles = [element.role for element in self.elements]
if len(self.elements) < 2:
raise ValueError("a Yagi needs at least two elements")
if roles.count(YagiElementRole.DRIVEN) != 1:
raise ValueError("a Yagi needs exactly one driven element")
if roles.count(YagiElementRole.REFLECTOR) > 1:
raise ValueError("a Yagi can have at most one reflector")
positions = [element.position_m for element in self.elements]
if len(set(positions)) != len(positions):
raise ValueError("two elements share the same boom position")

@property
def director_count(self) -> int:
"""Number of directors — elements beyond the reflector and driven."""
return self.element_count - 2
if self.elements is None:
return self.element_count - 2
return sum(
1
for element in self.elements
if element.role is YagiElementRole.DIRECTOR
)

@property
def total_element_count(self) -> int:
"""How many elements the beam has, auto-designed or explicit."""
if self.elements is None:
return self.element_count
return len(self.elements)


@dataclass(kw_only=True)
Expand Down
Loading
Loading