diff --git a/src/ars_wireworks/results/buildsheet.py b/src/ars_wireworks/results/buildsheet.py index 8ba3c43..8a94e36 100644 --- a/src/ars_wireworks/results/buildsheet.py +++ b/src/ars_wireworks/results/buildsheet.py @@ -40,6 +40,8 @@ CoilBuildSpec, InsertedReactanceRow, TrapBuildSpec, + _coil_labels, + _trap_labels, coil_build_specs, inserted_reactance_rows, trap_build_specs, @@ -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), @@ -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, diff --git a/tests/test_buildsheet.py b/tests/test_buildsheet.py index 7a4c8a8..e800fec 100644 --- a/tests/test_buildsheet.py +++ b/tests/test_buildsheet.py @@ -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, @@ -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