diff --git a/src/ars_wireworks/results/buildsheet.py b/src/ars_wireworks/results/buildsheet.py index 8a94e36..337d0ef 100644 --- a/src/ars_wireworks/results/buildsheet.py +++ b/src/ars_wireworks/results/buildsheet.py @@ -272,9 +272,9 @@ 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) - ) + return isinstance( + model, (FanDipoleModel, VerticalModel, OcfdModel) + ) or _dipole_family_with_loads(model) def _compose_cut_list( @@ -293,6 +293,8 @@ def _compose_cut_list( return _fan_dipole_cut_list(model, trim_margin) if isinstance(model, VerticalModel): return _vertical_cut_list(model, trim_margin) + if isinstance(model, OcfdModel): + return _ocfd_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) @@ -404,6 +406,24 @@ def _loaded_dipole_cut_list( ) +def _ocfd_cut_list( + model: OcfdModel, trim_margin: float +) -> tuple[CutListItem, ...]: + """The two unequal legs of an off-center-fed dipole. + + An OCFD/Windom is built as a short leg and a long leg off a centre balun; + the off-centre feed is the whole point of the design, so the cut list gives + each leg's length rather than one wire that hides where the feed goes. + """ + total = model.path.total_length_m if model.path else model.length_m + one_side = model.feed_fraction * total + legs = sorted((one_side, total - one_side)) + return ( + _section_item("Short leg (feedpoint → end)", legs[0], 1, trim_margin), + _section_item("Long leg (feedpoint → end)", legs[1], 1, trim_margin), + ) + + def _vertical_cut_list( model: VerticalModel, trim_margin: float ) -> tuple[CutListItem, ...]: diff --git a/src/ars_wireworks/results/sketch.py b/src/ars_wireworks/results/sketch.py index 5fa5506..af78abf 100644 --- a/src/ars_wireworks/results/sketch.py +++ b/src/ars_wireworks/results/sketch.py @@ -12,7 +12,11 @@ from ars_wireworks.cards.build import build_deck from ars_wireworks.cards.deck import CardDeck -from ars_wireworks.model.antenna import AntennaModel, FanDipoleModel +from ars_wireworks.model.antenna import ( + AntennaModel, + FanDipoleModel, + VerticalModel, +) from ars_wireworks.units import UnitSystem, format_length _Point3 = tuple[float, float, float] @@ -43,7 +47,13 @@ def geometry_sketch( segments = _wire_segments(deck) if not segments: return _svg([], 'No geometry to draw.') - projection = _choose_projection(segments) + # A vertical or inverted-L is a fundamentally vertical antenna: its radials + # spread widest in plan view, which would collapse the radiator (and an + # inverted-L's riser + bent top) to a point. Force a side elevation so the + # vertical structure shows. + projection = _choose_projection( + segments, elevation_only=isinstance(model, VerticalModel) + ) feed = _feedpoint(deck, projection) return _render(segments, projection, feed, unit_system) @@ -113,17 +123,23 @@ def _wire_segments(deck: CardDeck) -> list[_Segment]: return segments -def _choose_projection(segments: Sequence[_Segment]) -> str: +def _choose_projection( + segments: Sequence[_Segment], *, elevation_only: bool = False +) -> str: """The coordinate plane that shows the most structure. ``max`` keeps the first entry on a tie, so an elevation view (which shows - height above ground) is preferred over a flat plan view. + height above ground) is preferred over a flat plan view. With + ``elevation_only`` the flat plan view is excluded entirely — for an antenna + whose point is its height (a vertical, an inverted-L). """ points = [point for segment in segments for point in segment] x = _extent(p[0] for p in points) y = _extent(p[1] for p in points) z = _extent(p[2] for p in points) - candidates = (("XZ", x + z), ("YZ", y + z), ("XY", x + y)) + candidates = [("XZ", x + z), ("YZ", y + z)] + if not elevation_only: + candidates.append(("XY", x + y)) return max(candidates, key=lambda item: item[1])[0] diff --git a/tests/test_buildsheet.py b/tests/test_buildsheet.py index e800fec..cb73753 100644 --- a/tests/test_buildsheet.py +++ b/tests/test_buildsheet.py @@ -239,3 +239,20 @@ def test_plain_vertical_labels_radiator_and_radials() -> None: 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 + + +def test_ocfd_cut_list_gives_the_short_and_long_legs() -> None: + from ars_wireworks.model.antenna import OcfdModel + + model = OcfdModel(frequency_hz=FREQ_40M_HZ, height_m=12.0, feed_fraction=1 / 3) + sheet = build_sheet(model, _results()) + by_desc = {item.description: item for item in sheet.cut_list} + assert set(by_desc) == { + "Short leg (feedpoint → end)", + "Long leg (feedpoint → end)", + } + short = by_desc["Short leg (feedpoint → end)"].modeled_length_m + long = by_desc["Long leg (feedpoint → end)"].modeled_length_m + # one-third / two-thirds split of the half-wave, so the feed offset shows + assert short == pytest.approx(model.length_m / 3.0, rel=1e-3) + assert long == pytest.approx(short * 2.0, rel=1e-3) diff --git a/tests/test_sketch.py b/tests/test_sketch.py index 9a08229..d32062f 100644 --- a/tests/test_sketch.py +++ b/tests/test_sketch.py @@ -63,3 +63,33 @@ def test_geometry_sketch_works_for_every_kind_of_geometry(): svg = geometry_sketch(model) assert svg.startswith("" in svg + + +def test_vertical_and_inverted_l_use_a_side_elevation() -> None: + from ars_wireworks.model.path import SupportPoint, WirePath + + # A plain vertical: radials spread widest in plan, but a plan view would + # collapse the vertical radiator — so it must draw a side elevation. + vertical = VerticalModel( + frequency_hz=FREQ_40M_HZ, + base_height_m=0.3, + radials=(RadialGroup(count=8, length_m=10.1, height_m=0.0),), + ) + assert "Side elevation" in geometry_sketch(vertical) + assert "Plan view" not in geometry_sketch(vertical) + + # An inverted-L's riser + bent top only show in elevation. + path = WirePath( + ( + SupportPoint(0.0, 0.0, 0.5), + SupportPoint(0.0, 0.0, 12.0), + SupportPoint(20.0, 0.0, 12.0), + ) + ) + inverted_l = VerticalModel( + frequency_hz=3.75e6, + base_height_m=0.5, + radials=(RadialGroup(count=16, length_m=20.0, height_m=0.0),), + path=path, + ) + assert "Side elevation" in geometry_sketch(inverted_l)