Skip to content

Write your first design

The authoring tour shows the same antenna expressed six different ways — but it assumes you can already write a design. This page is the on-ramp before it: we start from the smallest Builder that runs and grow it one change at a time. By the end you’ll have a tunable dipole and will understand every line.

If you haven’t yet, skim The model — the one idea is that an antenna is a function from knobs to a list of wires, returned by build_wires().

A dipole is just a straight wire fed in the middle. Here it is, hardcoded — one wire, no geometry parameters at all:

from antennaknobs import AntennaBuilder
class Builder(AntennaBuilder):
default_params = {"freq": 14.0} # MHz — the only knob
def build_wires(self):
y = 5.0 # half-length in metres → a 10 m wire, tip to tip
return [
((0.0, -y, 0.0), (0.0, y, 0.0), 21, 1 + 0j),
]

That’s a complete, loadable design. Drop it in your designs folder as my_dipole.py, refresh the workbench, and it appears under Your designs.

Reading the one wire tuple ((0,-y,0), (0,y,0), 21, 1+0j) against the build_wires() contract:

  • (0,-y,0)(0,y,0) — the two endpoints (metres): a straight wire along the y axis from −5 m to +5 m, lying on the ground plane (z = 0).
  • 21 — mesh this wire into 21 segments (more on that in step 4).
  • 1 + 0j — this wire is the driven element; the source sits on it. (A structural, undriven wire would carry None here.) Exactly one segment in the whole design carries the feed — here the wire’s own centre segment.

Load it and look at the SWR curve and the impedance readout. With freq at its default 14.0 MHz the antenna is capacitive — the reactance reads about X = −55 Ω (slightly too short for this frequency).

Now turn just the freq knob — no code change. As you raise it, watch the reactance climb toward zero:

freq (MHz)R (Ω)X (Ω)
14.065−55
14.469−22
14.671≈ 0
15.078+44

Around 14.6 MHz the reactance passes through zero: that’s resonance, and the resistance settles near 70 Ω — the signature of a half-wave dipole. (A 10 m wire is about a half wavelength at c / 2L ≈ 15 MHz; it resonates a touch lower because real wire has end effects.)

Right now the wire sits at z = 0, on the ground. Real antennas hang in the air, and height changes the pattern and the impedance. Promote the height to a knob — add base to the params and use it as the z coordinate:

class Builder(AntennaBuilder):
default_params = {
"freq": 14.0, # MHz — measurement frequency
"base": 10.0, # metres — height above ground
}
def build_wires(self):
y = 5.0
z = self.base # every param is read as self.<name>
return [
((0.0, -y, z), (0.0, y, z), 21, 1 + 0j),
]

Every key in default_params is now a knob in the panel, read inside the build as self.base. Slide it and watch the pattern lift off the ground. Make sure to enable ground modelling.

That 21 we’ve been carrying is the segment count — and it deserves an explanation, because it’s where antenna modelling meets antenna physics.

The solver is a Method of Moments engine: it chops each wire into nsegs short segments, puts an unknown current on each, and solves a linear system for them all at once. So nsegs is an accuracy/cost dial:

  • too few segments and the current distribution is too coarse to be trustworthy — the impedance and pattern are wrong;
  • too many and the solve is needlessly slow;
  • what matters is that each segment is short relative to a wavelength, and that segment length stays roughly constant across every wire so no part of the antenna is under-meshed.

Hardcoding 21 breaks that last rule the moment a wire’s length changes. The framework gives you the right tool — segs_for(length, ref):

def build_wires(self):
wavelength = 299.792458 / self.freq # metres; c = 299.792458 m·MHz
quarter = wavelength / 4.0 # the reference length
y = 5.0
z = self.base
n = self.segs_for(2 * y, quarter) # segments scaled to the wire's length
return [
((0.0, -y, z), (0.0, y, z), n, 1 + 0j),
]

segs_for scales the framework’s nominal_nsegs (default 21, the count for one reference length) by length / ref, so a wire twice as long gets twice the segments and segment length stays constant:

n = max(3, round(nominal_nsegs · length / ref))

Our 10 m wire is about two quarter-waves long, so it now meshes into 39 segments instead of 21 — finer, and it will track the length once we make it a parameter.

The last hardcoded number is y. Make it a knob and the antenna becomes adjustable — slide it longer to drop the resonant frequency, shorter to raise it:

from antennaknobs import AntennaBuilder
class Builder(AntennaBuilder):
default_params = {
"freq": 14.6, # MHz — measurement frequency
"base": 10.0, # metres — height above ground
"half_length": 5.0, # metres — half the wire, tip to centre
"ui_params": {"default_view": "xz"},
}
def build_wires(self):
wavelength = 299.792458 / self.freq
quarter = wavelength / 4.0
y = self.half_length
z = self.base
n = self.segs_for(2 * y, quarter)
return [
((0.0, -y, z), (0.0, y, z), n, 1 + 0j),
]

That ui_params block is optional polish — here it just opens the antenna in the side-on xz view. Now you have two complementary ways to reach resonance: move freq to meet the antenna, or move half_length to tune the antenna to the frequency.

half_length in raw metres works, but it freezes the antenna at one size: pick a different band and you’re back to hand-tuning the length. The fix is to tie every dimension to the wavelength of the band you’re cutting for. Add a design_freq knob and size the wire from it.

A half-wave dipole is two quarter-wave arms, so the half-length is one quarter wavelength — times a length_factor near 1.0 that trims a real wire to true resonance:

from antennaknobs import AntennaBuilder
class Builder(AntennaBuilder):
default_params = {
"design_freq": 14.6, # MHz — the band this antenna is cut for
"freq": 14.6, # MHz — measurement frequency (starts on band)
"length_factor": 0.97, # trims the half-wave to resonance (~97 %)
"base": 10.0, # metres — height above ground
"ui_params": {"default_view": "xz"},
}
def build_wires(self):
wavelength = 299.792458 / self.design_freq
quarter = wavelength / 4.0
y = quarter * self.length_factor # half-length ≈ a quarter wavelength
z = self.base
n = self.segs_for(2 * y, quarter)
return [
((0.0, -y, z), (0.0, y, z), n, 1 + 0j),
]

Two things changed, and they’re the whole point:

  • The geometry now scales with the band. Out of the box this reads R ≈ 71 Ω, X ≈ −5 Ω at 14.6 MHz — resonant on the band it’s cut for. Change design_freq to 21.2 MHz and the half-length rescales from 4.98 m to 3.43 m and it resonates on 15 m too; 28.4 MHz → 2.56 m on 10 m. One knob moves the whole antenna onto a band.
  • length_factor is the fine-tune. The bare half-wave estimate (length_factor = 1.0) lands a little long — real wire resonates around 97 % of it — so length_factor is the small dimensionless trim the optimiser trips, leaving design_freq to do the coarse band placement.

You’ve gone from one hardcoded wire to a band-tunable dipole. From here: