diff --git a/src/ars_wireworks/results/buildsheet.py b/src/ars_wireworks/results/buildsheet.py index 14d8c45..8ba3c43 100644 --- a/src/ars_wireworks/results/buildsheet.py +++ b/src/ars_wireworks/results/buildsheet.py @@ -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 ), @@ -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, diff --git a/src/ars_wireworks/results/sketch.py b/src/ars_wireworks/results/sketch.py index 99a0c10..5fa5506 100644 --- a/src/ars_wireworks/results/sketch.py +++ b/src/ars_wireworks/results/sketch.py @@ -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] @@ -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: @@ -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] = [] diff --git a/tests/test_fan_dipole.py b/tests/test_fan_dipole.py index 2123115..5242cf1 100644 --- a/tests/test_fan_dipole.py +++ b/tests/test_fan_dipole.py @@ -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 ------------------------------------------------------------------