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
28 changes: 11 additions & 17 deletions src/ars_wireworks/cards/fan_dipole.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,6 @@
#: The bridge wire that carries the feed is a single segment.
FEED_TAG: int = 1

#: Total azimuth spread of the fan, in degrees — modest, so each leg behaves
#: much like a stand-alone dipole while the wires stay clear of one another.
_FAN_SPREAD_DEG: float = 30.0


def build_fan_dipole_deck(
model: FanDipoleModel, frequency_hz: float
Expand Down Expand Up @@ -89,19 +85,16 @@ def build_fan_dipole_deck(
),
]

# Spread the legs symmetrically about the X axis.
# Fan each leg so its end sits at a controlled perpendicular offset from
# the feed — adjacent ends step by ``end_spacing_m`` (the spreader spacing).
# The leg runs straight from the feed to that end, so it still spans its
# full half-length; a longer leg therefore needs a shallower angle.
offsets = model.leg_end_offsets_m
for index, (leg, half, segments) in enumerate(
zip(legs, half_lengths, leg_segments)
):
if count == 1:
azimuth = 0.0
else:
azimuth = math.radians(
-_FAN_SPREAD_DEG / 2.0
+ index * _FAN_SPREAD_DEG / (count - 1)
)
dx = half * math.cos(azimuth)
dy = half * math.sin(azimuth)
dy = offsets[index]
dx = math.sqrt(max(half * half - dy * dy, 0.0))
right_tag = 2 * index + 2
left_tag = 2 * index + 3
cards.append(
Expand Down Expand Up @@ -166,9 +159,10 @@ def build_fan_dipole_deck(
EngineChoice(
topic="Fan layout",
explanation=(
f"I spread the legs over a {_FAN_SPREAD_DEG:.0f}° fan in "
f"azimuth so the wires stay clear of one another while each "
f"still works much like a stand-alone dipole."
f"I fanned the legs so adjacent ends sit "
f"{model.end_spacing_m:.2f} m apart — the spreader spacing you "
f"set. Wider spacing reduces how much the legs detune one "
f"another, at the cost of more end support."
),
),
ground_choice,
Expand Down
30 changes: 30 additions & 0 deletions src/ars_wireworks/model/antenna.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,23 +164,53 @@ class FanDipoleModel(AntennaModel):
coverage without traps. On any given band the resonant leg presents a low
impedance and dominates, while the others sit off-resonance. All legs lie
in a horizontal plane at ``height_m``.

``end_spacing_m`` is the distance between the ends of adjacent legs — what
a spreader at the leg ends sets. The legs are fanned so their ends step by
this much across the fan; the wider the spacing, the less the legs couple
(and detune one another), at the cost of needing more end support.
"""

height_m: float
legs: tuple[FanDipoleLeg, ...]
#: Spacing between the ends of adjacent legs (the spreader spacing).
end_spacing_m: float = 0.3

def __post_init__(self) -> None:
super().__post_init__()
if self.height_m < 0.0:
raise ValueError("height_m must not be negative (wire below ground)")
if len(self.legs) < 2:
raise ValueError("a fan dipole needs at least two legs")
if self.end_spacing_m <= 0.0:
raise ValueError("end_spacing_m must be positive")
for leg, offset in zip(self.legs, self.leg_end_offsets_m):
# The end of a leg can sit at most its half-length out from the
# feed, so an end offset must stay well inside that.
if abs(offset) > 0.8 * leg.length_m / 2.0:
raise ValueError(
"end_spacing_m is too large for the shortest leg — "
"reduce the spacing or the number of legs"
)

@property
def longest_leg_m(self) -> float:
"""The span of the longest (lowest-band) leg — sizes the build."""
return max(leg.length_m for leg in self.legs)

@property
def leg_end_offsets_m(self) -> tuple[float, ...]:
"""Each leg's end offset across the fan, centred on zero.

Adjacent legs' ends step by ``end_spacing_m``; the card builder fans
each leg to put its end at this perpendicular offset from the feed.
"""
count = len(self.legs)
return tuple(
(index - (count - 1) / 2.0) * self.end_spacing_m
for index in range(count)
)


@dataclass(kw_only=True)
class FoldedDipoleModel(AntennaModel):
Expand Down
13 changes: 13 additions & 0 deletions src/ars_wireworks/resources/templates/buildsheet.md.j2
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ _Quantities and specifications only — no prices._
- {{ note }}
{% endfor %}

{% if sheet.tuning_chart %}
## Tuning chart

If the antenna resonates off target, adjust the overall wire length. On a centre-fed antenna, split the change equally between the two legs.
Expand All @@ -44,6 +45,18 @@ If the antenna resonates off target, adjust the overall wire length. On a centre
{% endfor %}

Real-world results vary — adjust in small steps and re-measure. Beams, verticals with radials, and tuned loops behave differently from a plain wire.
{% endif %}
{% if sheet.band_tuning %}
## Per-band tuning

Each leg tunes its own band independently — trim the leg for the band you are adjusting, shorter to raise its resonance, longer to lower it. Trim a little at a time and re-measure; the legs interact slightly.

| Band | Leg length | Trim to raise ~1% |
|------|-----------:|------------------:|
{% for row in sheet.band_tuning %}
| {{ row.band }} | {{ row.leg_length_m | length(sheet.unit_system, 2) }} | {{ row.trim_per_percent_m | small_length(sheet.unit_system) }} |
{% endfor %}
{% endif %}

{% if sheet.coil_specs %}
## Loading coils
Expand Down
12 changes: 12 additions & 0 deletions src/ars_wireworks/resources/templates/buildsheet_html.j2
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
{% endfor %}
</ul>

{% if sheet.tuning_chart %}
<h2>Tuning chart</h2>
<p>If the antenna resonates off target, adjust the overall wire length. On a centre-fed antenna, split the change equally between the two legs.</p>
<table>
Expand All @@ -63,6 +64,17 @@
{% endfor %}
</table>
<p class="note">Real-world results vary — adjust in small steps and re-measure. Beams, verticals with radials, and tuned loops behave differently from a plain wire.</p>
{% endif %}
{% if sheet.band_tuning %}
<h2>Per-band tuning</h2>
<p>Each leg tunes its own band independently — trim the leg for the band you are adjusting, shorter to raise its resonance, longer to lower it. Trim a little at a time and re-measure; the legs interact slightly.</p>
<table>
<tr><th>Band</th><th>Leg length</th><th>Trim to raise ~1%</th></tr>
{% for row in sheet.band_tuning %}
<tr><td>{{ row.band }}</td><td>{{ row.leg_length_m | length(sheet.unit_system, 2) }}</td><td>{{ row.trim_per_percent_m | small_length(sheet.unit_system) }}</td></tr>
{% endfor %}
</table>
{% endif %}

{% if sheet.coil_specs %}
<h2>Loading coils</h2>
Expand Down
45 changes: 44 additions & 1 deletion src/ars_wireworks/results/buildsheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,19 @@ def magnitude_cm(self) -> float:
return abs(self.length_change_m) * 100.0


@dataclass(frozen=True)
class BandTuningRow:
"""Per-band tuning guidance for a multiband-by-leg antenna (a fan dipole).

Each leg tunes its own band independently, so the chart gives each leg's
length and how much to trim it to move that band's resonance ~1% higher.
"""

band: str
leg_length_m: float
trim_per_percent_m: float


@dataclass(frozen=True)
class BuildSheet:
"""Everything that goes on the printable build sheet (spec §9)."""
Expand All @@ -135,6 +148,8 @@ class BuildSheet:
trap_specs: tuple[TrapBuildSpec, ...] = ()
#: Per-component inserted-reactance table (spec §9, line 246).
inserted_reactance: tuple[InsertedReactanceRow, ...] = ()
#: Per-band tuning for a fan dipole — one row per leg (spec §9).
band_tuning: tuple[BandTuningRow, ...] = ()


def tuning_chart(reference_length_m: float) -> tuple[TuningRow, ...]:
Expand Down Expand Up @@ -184,6 +199,15 @@ def build_sheet(
else:
total_length = sum(_wire_lengths(deck))

# A fan dipole tunes per leg, so the single whole-antenna tuning chart is
# meaningless — give a per-band chart instead.
if isinstance(model, FanDipoleModel):
overall_tuning: tuple[TuningRow, ...] = ()
band_tuning = _fan_dipole_band_tuning(model)
else:
overall_tuning = tuning_chart(total_length)
band_tuning = ()

return BuildSheet(
antenna_name=antenna_name(model),
frequency_mhz=results.frequency_hz / 1e6,
Expand All @@ -192,14 +216,15 @@ def build_sheet(
model, deck, total_length, unit_system, waste_factor
),
installation_notes=_installation_notes(deck, unit_system),
tuning_chart=tuning_chart(total_length),
tuning_chart=overall_tuning,
why_this_design=plain_language_summary(results),
trim_margin_percent=trim_margin * 100.0,
geometry_svg=geometry_sketch(model, unit_system=unit_system),
unit_system=unit_system,
coil_specs=coil_build_specs(model, results.frequency_hz),
trap_specs=trap_build_specs(model),
inserted_reactance=inserted_reactance_rows(model, results.frequency_hz),
band_tuning=band_tuning,
)


Expand Down Expand Up @@ -246,6 +271,24 @@ def _cut_list(
)


def _fan_dipole_band_tuning(model: FanDipoleModel) -> tuple[BandTuningRow, ...]:
"""Per-leg tuning rows for a fan dipole — each leg tunes its own band.

A leg's resonance moves inversely with its length, so trimming ~1% of the
leg shifts that band ~1% higher; the legs are independent.
"""
return tuple(
BandTuningRow(
band=f"{leg.resonant_frequency_hz / 1e6:.3f} MHz",
leg_length_m=leg.length_m,
trim_per_percent_m=leg.length_m * 0.01,
)
for leg in sorted(
model.legs, key=lambda leg: leg.resonant_frequency_hz
)
)


def _fan_dipole_cut_list(
model: FanDipoleModel, trim_margin: float
) -> tuple[CutListItem, ...]:
Expand Down
33 changes: 32 additions & 1 deletion src/ars_wireworks/solver/multiband.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from dataclasses import dataclass

from ars_wireworks.model.antenna import AntennaModel
from ars_wireworks.model.antenna import AntennaModel, FanDipoleModel
from ars_wireworks.solver.base import Solver, SolverConvergenceError
from ars_wireworks.solver.results import Results

Expand Down Expand Up @@ -105,3 +105,34 @@ def _characterise(
return resonant_mhz, best_swr, 0.0
width_mhz = discriminant**0.5 / curvature * spacing
return resonant_mhz, best_swr, width_mhz * 1000.0


@dataclass(frozen=True)
class LegBand:
"""One fan-dipole leg's band coverage — its design frequency and SWR."""

frequency_mhz: float
swr: float


def fan_dipole_legs_report(
model: FanDipoleModel, solver: Solver
) -> list[LegBand]:
"""The SWR on each fan-dipole leg's band.

A fan dipole is multiband by construction — one dipole leg per band — so
rather than the harmonic-match scan :func:`band_matches` runs, this solves
the antenna at each leg's own design frequency and reports the match there,
low band to high. The legs interact, so a leg may read off 2:1 until it is
trimmed; this still shows every band the antenna is built to cover.
"""
report: list[LegBand] = []
for leg in sorted(model.legs, key=lambda leg: leg.resonant_frequency_hz):
try:
results = solver.solve(model, leg.resonant_frequency_hz)
except SolverConvergenceError:
continue
report.append(
LegBand(leg.resonant_frequency_hz / 1e6, _operator_swr(results))
)
return report
Loading
Loading