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
39 changes: 36 additions & 3 deletions src/ars_wireworks/results/buildsheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,13 +171,22 @@ def build_sheet(
raise ValueError("trim_margin and waste_factor must not be negative")

deck, _ = build_deck(model, results.frequency_hz)
lengths = _wire_lengths(deck)
total_length = sum(lengths)
if isinstance(model, FanDipoleModel):
# A fan dipole's deck splits each leg into two feed-gap halves plus a
# tiny bridge wire, so the generic by-length cut list lists unlabelled
# half-wires and a phantom bridge. Build it from the legs instead: one
# full-length wire per band, so every leg gets its own cut entry.
cut_list = _fan_dipole_cut_list(model, trim_margin)
total_length = sum(leg.length_m for leg in model.legs)
else:
lengths = _wire_lengths(deck)
cut_list = _cut_list(lengths, trim_margin)
total_length = sum(lengths)

return BuildSheet(
antenna_name=antenna_name(model),
frequency_mhz=results.frequency_hz / 1e6,
cut_list=_cut_list(lengths, trim_margin),
cut_list=cut_list,
bill_of_materials=_bill_of_materials(
model, deck, total_length, unit_system, waste_factor
),
Expand Down Expand Up @@ -236,6 +245,30 @@ def _cut_list(
)


def _fan_dipole_cut_list(
model: FanDipoleModel, trim_margin: float
) -> tuple[CutListItem, ...]:
"""One full-length wire per leg, labelled by band — from the model.

The builder cuts one dipole per band (its tip-to-tip half-wave), so each
leg is its own entry; the feed-gap halves and bridge wire the NEC deck
uses are modelling artefacts, not things to cut.
"""
return tuple(
CutListItem(
description=(
f"Dipole leg for {leg.resonant_frequency_hz / 1e6:.3f} MHz"
),
quantity=1,
modeled_length_m=leg.length_m,
cut_length_m=leg.length_m * (1.0 + trim_margin),
)
for leg in sorted(
model.legs, key=lambda leg: leg.resonant_frequency_hz
)
)


def _bill_of_materials(
model: AntennaModel,
deck: CardDeck,
Expand Down
63 changes: 62 additions & 1 deletion src/ars_wireworks/results/sketch.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from ars_wireworks.cards.build import build_deck
from ars_wireworks.cards.deck import CardDeck
from ars_wireworks.model.antenna import AntennaModel
from ars_wireworks.model.antenna import AntennaModel, FanDipoleModel
from ars_wireworks.units import UnitSystem, format_length

_Point3 = tuple[float, float, float]
Expand All @@ -34,6 +34,11 @@ def geometry_sketch(
model: AntennaModel, *, unit_system: UnitSystem = UnitSystem.METRIC
) -> str:
"""A dimensioned SVG line drawing of ``model``'s geometry."""
if isinstance(model, FanDipoleModel):
# The deck fans the legs in azimuth and splits each across a feed gap,
# so a generic projection draws an overlapping star. Draw the builder's
# view instead: the legs as parallel dipoles sharing one feedpoint.
return _fan_dipole_sketch(model, unit_system)
deck, _ = build_deck(model, model.frequency_hz)
segments = _wire_segments(deck)
if not segments:
Expand All @@ -43,6 +48,62 @@ def geometry_sketch(
return _render(segments, projection, feed, unit_system)


def _fan_dipole_sketch(model: FanDipoleModel, unit_system: UnitSystem) -> str:
"""A schematic of a fan dipole: parallel legs sharing a centre feedpoint.

Each leg is drawn to scale as a horizontal dipole, longest at the top,
labelled with its band; a feed line ties their centres together. This is
how a builder pictures a fan dipole, not the azimuth-fanned NEC geometry.
"""
legs = sorted(model.legs, key=lambda leg: leg.resonant_frequency_hz)
longest_m = max(leg.length_m for leg in legs)
scale = (_CANVAS_W - 2 * _MARGIN) / longest_m
centre_x = _CANVAS_W / 2.0
top_y, bottom_y = float(_MARGIN), float(_CANVAS_H - _MARGIN)
count = len(legs)

def y_for(index: int) -> float:
if count == 1:
return (top_y + bottom_y) / 2.0
return top_y + index * (bottom_y - top_y) / (count - 1)

elements: list[str] = [
_line(centre_x, y_for(0), centre_x, y_for(count - 1), _FEED_COLOR, 1.4)
]
for index, leg in enumerate(legs):
y = y_for(index)
half = leg.length_m / 2.0 * scale
elements.append(
_line(centre_x - half, y, centre_x + half, y, _WIRE_COLOR, 2.6)
)
elements.append(
_text(
centre_x + half / 2.0, y - 7,
f"{leg.resonant_frequency_hz / 1e6:.4g} MHz",
11, _WIRE_COLOR, "middle",
)
)

feed_y = (y_for(0) + y_for(count - 1)) / 2.0
elements.append(_circle(centre_x, feed_y, 5.0, _FEED_COLOR))
elements.append(
_text(centre_x + 10, feed_y + 4, "feed", 11, _FEED_COLOR, "start")
)

longest_half = longest_m / 2.0 * scale
elements += _width_dimension(
centre_x - longest_half, centre_x + longest_half,
bottom_y + 24, longest_m, unit_system,
)
elements.append(
_text(
_CANVAS_W / 2.0, _CANVAS_H - 14,
"Fan dipole — legs share one feedpoint", 12, _DIM_COLOR, "middle",
)
)
return _svg(elements)


def _wire_segments(deck: CardDeck) -> list[_Segment]:
"""Every wire in the deck as a pair of 3-D endpoints."""
segments: list[_Segment] = []
Expand Down
45 changes: 45 additions & 0 deletions tests/test_fan_dipole.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,51 @@ def test_build_sheet_names_it_a_fan_dipole() -> None:
assert antenna_name(_model()) == "Fan dipole"


def _three_band_model() -> FanDipoleModel:
bands = [7.15e6, 14.175e6, 28.5e6]
legs = tuple(
FanDipoleLeg(resonant_frequency_hz=f, length_m=half_wave_length_m(f))
for f in bands
)
return FanDipoleModel(frequency_hz=7.15e6, height_m=12.0, legs=legs)


def test_cut_list_has_one_labelled_entry_per_leg() -> None:
from ars_wireworks.results.buildsheet import build_sheet
from ars_wireworks.solver.necpp import NecppSolver
from ars_wireworks.units import UnitSystem

model = _three_band_model()
results = NecppSolver().solve(model, model.frequency_hz)
sheet = build_sheet(model, results, unit_system=UnitSystem.METRIC)

# one entry per band (not unlabelled half-wires), and no phantom feed wire
assert len(sheet.cut_list) == 3
for item in sheet.cut_list:
assert item.quantity == 1
assert "MHz" in item.description
assert item.modeled_length_m > 1.0 # full leg, not a 0.5 m bridge

# each entry is the full tip-to-tip half-wave for its band
lengths = {round(item.modeled_length_m, 2) for item in sheet.cut_list}
assert lengths == {
round(half_wave_length_m(f), 2) for f in (7.15e6, 14.175e6, 28.5e6)
}
# the lowest band's leg is the longest
assert "7.150 MHz" in sheet.cut_list[0].description


def test_sketch_draws_one_labelled_wire_per_leg() -> None:
from ars_wireworks.results.sketch import geometry_sketch

svg = geometry_sketch(_three_band_model())
# one drawn wire per leg, each band labelled, shared-feed schematic
assert svg.count('stroke-width="2.6"') == 3
for mhz in ("7.15 MHz", "14.18 MHz", "28.5 MHz"):
assert mhz in svg
assert "share one feedpoint" in svg


# --- solve ------------------------------------------------------------------


Expand Down
Loading