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
from antennaknobs.network import Wire
class Builder(AntennaBuilder):
default_params = {
"freq": 14.0, # MHz — measurement frequency
"design_freq": 14.0, # MHz — the band it's designed for (anchors the mesh)
}
def build_wires(self):
y = 5.0 # half-length in metres → a 10 m wire, tip to tip
return [
Wire((0.0, -y, 0.0), (0.0, y, 0.0), ex=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((0,-y,0), (0,y,0), ex=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).
  • no segment count — you didn’t pass one, so the framework meshes the wire for you, at a density anchored to design_freq (step 4 explains the rule — you will never pick a count by hand).
  • ex=1 + 0j — this wire is the driven element; the source sits on it. (A structural, undriven wire is just Wire(a, b).) Exactly one segment in the whole design carries the feed — here the wire’s own centre segment.

Load it and look at the Smith chart 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
"design_freq": 14.0, # MHz — mesh anchor
"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 [
Wire((0.0, -y, z), (0.0, y, z), ex=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 None we’ve been carrying is the segment count — and the reason it’s None 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 short segments, puts an unknown current on each, and solves a linear system for them all at once. So the mesh 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- or over-meshed.

You never manage any of that. A None count means the framework meshes the wire at the design density:

n = max(1, round(N · length / (λ/4))) # λ from design_freq

where N is the workbench’s segment slider (nominal_nsegs). N is a physical unit — N=21 means a segment length of λ/84 on every wire of every design — and because every None wire gets the same segment length, no part of the antenna can end up meshed out of step with its neighbours. That uniformity is not a nicety: hand-picked counts that broke it are the single most common historical bug in our own design catalog (the full story, with the same design written both ways, is in Segmentation you never think about).

The mesh is anchored to design_freq — the band the antenna is designed for — never to the measurement freq, so sweeping the measurement frequency can never re-mesh the geometry under you.

Our 10 m wire is about two quarter-waves long at 14 MHz, so at the default N=21 it meshes into 39 segments — and the count 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
from antennaknobs.network import Wire
class Builder(AntennaBuilder):
default_params = {
"freq": 14.6, # MHz — measurement frequency
"design_freq": 14.6, # MHz — mesh anchor
"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):
y = self.half_length
z = self.base
return [
Wire((0.0, -y, z), (0.0, y, z), ex=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 — and you’ve been carrying exactly the right knob since step 1: design_freq, so far only anchoring the mesh, now sizes the geometry too.

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
from antennaknobs.network import Wire
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 = self.design_wavelength # free-space λ at design_freq, in metres
quarter = wavelength / 4.0
y = quarter * self.length_factor # half-length ≈ a quarter wavelength
z = self.base
return [
Wire((0.0, -y, z), (0.0, y, z), ex=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: