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
185 changes: 175 additions & 10 deletions src/ars_wireworks/results/buildsheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
CoilBuildSpec,
InsertedReactanceRow,
TrapBuildSpec,
_coil_labels,
_trap_labels,
coil_build_specs,
inserted_reactance_rows,
trap_build_specs,
Expand Down Expand Up @@ -171,17 +173,16 @@ def build_sheet(
raise ValueError("trim_margin and waste_factor must not be negative")

deck, _ = build_deck(model, results.frequency_hz)
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)
cut_list = _compose_cut_list(model, deck, trim_margin)
# The cut list is the authoritative wire bill for the model-driven antennas
# (fan dipole, vertical, trapped wire); fall back to the deck total only for
# the generic by-length path, where the two agree.
if _is_model_driven_cut_list(model):
total_length = sum(
item.quantity * item.modeled_length_m for item in cut_list
)
else:
lengths = _wire_lengths(deck)
cut_list = _cut_list(lengths, trim_margin)
total_length = sum(lengths)
total_length = sum(_wire_lengths(deck))

return BuildSheet(
antenna_name=antenna_name(model),
Expand Down Expand Up @@ -269,6 +270,170 @@ def _fan_dipole_cut_list(
)


def _is_model_driven_cut_list(model: AntennaModel) -> bool:
"""Whether ``model`` gets a cut list built from the model, not the deck."""
return isinstance(model, (FanDipoleModel, VerticalModel)) or (
_dipole_family_with_loads(model)
)


def _compose_cut_list(
model: AntennaModel, deck: CardDeck, trim_margin: float
) -> tuple[CutListItem, ...]:
"""The cut list for ``model``, model-driven where the deck is misleading.

A trapped antenna's deck is one continuous radiator wire (the traps are
loads, not cuts) and a vertical's deck mixes the radiator in with the
radial wires, so for those the cut list is built from the model: the
radiator split into the sections between its traps/coils, and the radials
listed and labelled separately. Everything else groups the deck's wires
by length as before.
"""
if isinstance(model, FanDipoleModel):
return _fan_dipole_cut_list(model, trim_margin)
if isinstance(model, VerticalModel):
return _vertical_cut_list(model, trim_margin)
if _dipole_family_with_loads(model):
return _loaded_dipole_cut_list(model, trim_margin)
return _cut_list(_wire_lengths(deck), trim_margin)


def _dipole_family_with_loads(model: AntennaModel) -> bool:
"""A centre-fed dipole/inverted-V carrying traps or loading coils."""
return isinstance(model, (DipoleModel, InvertedVeeModel)) and bool(
model.traps or model.loading_coils
)


def _section_item(
description: str, length_m: float, quantity: int, trim_margin: float
) -> CutListItem:
"""A cut-list item for a labelled wire of ``length_m``."""
return CutListItem(
description=description,
quantity=quantity,
modeled_length_m=length_m,
cut_length_m=length_m * (1.0 + trim_margin),
)


def _pair_label(label: str) -> str:
"""Drop a symmetric-pair side suffix: ``T1L`` -> ``T1``, ``L2`` -> ``L2``."""
if len(label) >= 2 and label[-1] in ("L", "R") and label[-2].isdigit():
return label[:-1]
return label


def _radiator_loads(model: AntennaModel) -> list[tuple[float, str]]:
"""(position fraction, label) for every trap/coil on the radiator wire."""
# A load with no explicit wire_tag sits on the radiator (the default); an
# explicit tag points it at another wire (a Yagi element), which doesn't
# section the radiator.
loads: list[tuple[float, str]] = []
if model.traps:
for label, trap in zip(_trap_labels(model.traps), model.traps):
if getattr(trap, "wire_tag", None) is None:
loads.append((trap.position_fraction, _pair_label(label)))
if model.loading_coils:
for label, coil in zip(_coil_labels(model.loading_coils), model.loading_coils):
if getattr(coil, "wire_tag", None) is None:
loads.append((coil.position_fraction, _pair_label(label)))
return loads


def _arm_items(
*,
feed_fraction: float,
end_fraction: float,
loads: Sequence[tuple[float, str]],
length_m: float,
trim_margin: float,
quantity: int,
) -> list[CutListItem]:
"""Sections of one arm: feedpoint -> each load -> the tip, in order."""
boundaries = (
[("Feedpoint", feed_fraction)]
+ [(label, position) for position, label in loads]
+ [("tip", end_fraction)]
)
items: list[CutListItem] = []
for (name_a, frac_a), (name_b, frac_b) in zip(boundaries, boundaries[1:]):
length = abs(frac_b - frac_a) * length_m
items.append(
_section_item(f"{name_a} → {name_b}", length, quantity, trim_margin)
)
return items


def _radiator_sections(
model: AntennaModel,
length_m: float,
feed_fraction: float,
trim_margin: float,
) -> list[CutListItem]:
"""The radiator split into the wire sections between its traps/coils.

Empty when the radiator carries no loads. A base- or end-fed radiator is
one arm (quantity 1); a centre-fed one is a symmetric pair (quantity 2),
so the loads on the far side describe both legs.
"""
loads = _radiator_loads(model)
if not loads:
return []
end_fed = abs(feed_fraction) < 1e-6 or abs(feed_fraction - 1.0) < 1e-6
if end_fed:
end = 1.0 if feed_fraction < 0.5 else 0.0
ordered = sorted(loads, key=lambda load: abs(load[0] - feed_fraction))
return _arm_items(
feed_fraction=feed_fraction, end_fraction=end, loads=ordered,
length_m=length_m, trim_margin=trim_margin, quantity=1,
)
far = sorted((load for load in loads if load[0] > feed_fraction))
return _arm_items(
feed_fraction=feed_fraction, end_fraction=1.0, loads=far,
length_m=length_m, trim_margin=trim_margin, quantity=2,
)


def _loaded_dipole_cut_list(
model: AntennaModel, trim_margin: float
) -> tuple[CutListItem, ...]:
"""A centre-fed dipole/inverted-V cut into the sections between its traps."""
return tuple(
_radiator_sections(model, model.length_m, 0.5, trim_margin)
)


def _vertical_cut_list(
model: VerticalModel, trim_margin: float
) -> tuple[CutListItem, ...]:
"""Radiator (sectioned if loaded) plus the radials, each labelled.

Fixes the generic cut list conflating the radiator with same-length
radials, and gives a trapped vertical its inter-trap section lengths.
"""
items: list[CutListItem] = []
sections = _radiator_sections(
model, model.radiator_length_m, 0.0, trim_margin
)
if sections:
items.extend(sections)
else:
items.append(
_section_item("Radiator", model.radiator_length_m, 1, trim_margin)
)
for group in model.radials:
label = (
"Ground radials" if group.is_ground_mounted else "Elevated radials"
)
items.append(
_section_item(label, group.length_m, group.count, trim_margin)
)
for radial in model.individual_radials:
items.append(_section_item("Radial", radial.length_m, 1, trim_margin))
return tuple(items)


def _bill_of_materials(
model: AntennaModel,
deck: CardDeck,
Expand Down
63 changes: 63 additions & 0 deletions tests/test_buildsheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@
DipoleModel,
EfhwModel,
HorizontalLoopModel,
RadialGroup,
RhombicModel,
VerticalModel,
)
from ars_wireworks.model.trap import Trap
from ars_wireworks.results.buildsheet import (
build_sheet,
render_html,
Expand Down Expand Up @@ -176,3 +179,63 @@ def test_build_sheet_honours_the_imperial_unit_system():
assert " cm |" not in imperial
assert "ft total" in imperial # bill of materials
assert "ft above ground" in imperial # installation notes


# --- multiband / trapped / vertical cut lists -------------------------------


def _two_trap_dipole() -> DipoleModel:
"""A 3-band trapped dipole: two symmetric trap pairs about the centre."""
traps = (
Trap(resonant_frequency_hz=21.225e6, inductance_h=5e-6, position_fraction=0.5 + 0.17),
Trap(resonant_frequency_hz=21.225e6, inductance_h=5e-6, position_fraction=0.5 - 0.17),
Trap(resonant_frequency_hz=14.175e6, inductance_h=5e-6, position_fraction=0.5 + 0.25),
Trap(resonant_frequency_hz=14.175e6, inductance_h=5e-6, position_fraction=0.5 - 0.25),
)
return DipoleModel(frequency_hz=FREQ_40M_HZ, height_m=12.0, traps=traps)


def test_trapped_dipole_cut_list_is_sectioned_between_traps() -> None:
sheet = build_sheet(_two_trap_dipole(), _results())
descriptions = [item.description for item in sheet.cut_list]
# the radiator is cut into feed -> T1 -> T2 -> tip sections, not one wire
assert descriptions == ["Feedpoint → T1", "T1 → T2", "T2 → tip"]
# a centre-fed dipole is a symmetric pair: two of each section
assert all(item.quantity == 2 for item in sheet.cut_list)
assert "Antenna wire" not in descriptions


def test_trapped_vertical_separates_radiator_sections_from_radials() -> None:
traps = (
Trap(resonant_frequency_hz=21.225e6, inductance_h=5e-6, position_fraction=0.34),
Trap(resonant_frequency_hz=14.175e6, inductance_h=5e-6, position_fraction=0.5),
)
model = VerticalModel(
frequency_hz=FREQ_40M_HZ,
base_height_m=0.3,
radials=(RadialGroup(count=32, length_m=10.2, height_m=0.0),),
traps=traps,
)
sheet = build_sheet(model, _results())
descriptions = [item.description for item in sheet.cut_list]
# radiator sectioned (one arm), radials a distinct labelled line — not the
# old single "qty 33" entry that conflated radiator with radials
assert "Feedpoint → T1" in descriptions
assert "T2 → tip" in descriptions
radials = [it for it in sheet.cut_list if it.description == "Ground radials"]
assert len(radials) == 1
assert radials[0].quantity == 32


def test_plain_vertical_labels_radiator_and_radials() -> None:
model = VerticalModel(
frequency_hz=FREQ_40M_HZ,
base_height_m=0.3,
radials=(RadialGroup(count=4, length_m=10.1, height_m=0.0),),
)
sheet = build_sheet(model, _results())
by_desc = {item.description: item for item in sheet.cut_list}
assert "Radiator" in by_desc and by_desc["Radiator"].quantity == 1
assert "Ground radials" in by_desc and by_desc["Ground radials"].quantity == 4
# the radiator and radials are never merged into one line
assert len(sheet.cut_list) == 2
Loading