Command line
antennaknobs has a command-line interface for batch work. The subcommands:
python -m antennaknobs {draw,sweep,optimize,pattern,compare_patterns,params,export,list,screen,allow,disallow}| Command | What it does |
|---|---|
list | List available designs (built-in and user) |
draw | Draw the antenna geometry |
sweep | Sweep a parameter or frequency |
pattern | Plot the far-field pattern |
compare_patterns | Overlay the patterns of several antennas / engines |
optimize | Optimize an antenna’s parameters |
params | Print a design’s knob values as paste-ready Python |
export | Export the design to a NEC-2 .nec card deck |
screen | Show what a design file does that’s unusual, without running it |
allow | Allow a user design to run (it runs code on your machine) |
disallow | Stop allowing a user design to run |
Naming a design
Section titled “Naming a design”Designs are addressed as family.name (the same names list prints):
python -m antennaknobs list # arrays.bowtiearray, beams.yagi, loops.delta_loop, ...Three spec forms work anywhere a --builder / --builders argument does:
-
family.name— a catalog or user design. -
family.name:variant— a stored knob-set overlay (see Variants are overlays). -
@path/to/file.necor@path/to/file.ssn— a NEC card deck (viaread_nec) or a SimNEC circuit (via the SimNEC importer), loaded on the fly as a frozen-geometry design. No user-design stub to write:draw,sweep,pattern,schematic, andexportall take it directly, and files mix freely with named designs in--builderslists —Terminal window python -m antennaknobs compare_patterns --builders dipoles.invvee @measured/invvee.necA station
.ssn’s tuner chain rides along as the design’s feed network, and its Generator sets the frequency. The@sigil keeps the grammar unambiguous (a barefoo.necwould parse as familyfoo, designnec), and an@spec never splits off a:variantsuffix, so colons in paths (Windows drive letters) pass through.
Patterns
Section titled “Patterns”# Far-field pattern of a Yagi, solved with momwire's default (B-spline) basispython -m antennaknobs pattern --builder beams.yagi --engine momwireUseful pattern flags: --fn out.png (write to a file instead of the screen),
--ground free|pec|finite|finite:<eps_r>,<sigma>, --wireframe, and
--elevation_angle.
Sweeps
Section titled “Sweeps”sweep plots impedance against measurement frequency by default; --param <knob> sweeps any named knob instead. Add --swr to plot the curve as SWR
(against a 50 Ω reference by default, --z0 to change it):
# SWR across the bandpython -m antennaknobs sweep --builder dipoles.invvee --swr# how SWR responds to the droop angle, at a fixed frequencypython -m antennaknobs sweep --builder dipoles.invvee --swr --param angle_degFrequency sweeps use the vectorized impedance sweep (one geometry, many
frequencies), so they are much faster than scripting one solve per point.
Note that knob sweeps in free space can be perfectly flat by design —
translation-invariant knobs like a height base only matter over a ground
(--ground finite).
Drawing the feed network
Section titled “Drawing the feed network”schematic renders a design’s build_network() — feedline, tuner, balun, and
the port the source sits on — as an SVG:
python -m antennaknobs schematic --builder wire.doublet_ladder_tuner --out tuner.svg# annotate each box with the watts it burnspython -m antennaknobs schematic --builder verticals.stub_matched_vertical --power --out m.svgNeeds the optional extra: pip install 'antennaknobs[schematic]' (schemdraw —
MIT, and with no dependencies of its own).
The circuit half of a design is otherwise visible only as
power-budget rows, which means an element that
burns nothing — an ideal TL, a bypass() — appears nowhere. This draws
every branch whether it dissipates or not, groups them under the box they came
from, and marks where the source sits, which is the design’s reference plane.
Boxes may carry their own drawing. station.t_network_tuner declares one, so
it renders as the tee it is, with the coil between the capacitors — an ordering
the branch list cannot express, because “the coil goes in the middle” lives in
the head of whoever wrote the factory. A box without a fragment still draws,
from per-branch default symbols; it is simply more anonymous. Fragments are
written with schematic.series / retn / shunt, which are plain data, so
declaring one costs station.py no drawing-library import:
Composite( ports=("rig", "out"), branches=(...), schematic=( series("capacitor", "81 pF"), shunt("inductor", "4.2 µH"), series("capacitor", "500 pF"), ),)Balanced sections are drawn as two conductors. Past a FloatingBalun’s
secondary, or along a BalancedLine, the return current rides the partner wire
rather than the common datum — so there is a second rail, the isolation barrier
is drawn through the balun, and no ground symbol appears beyond it. A roller
inductance split half into each leg of a balanced tuner is two coils facing
each other; a differential capacitor across the output is a rung between the
rails. A Shunt keeps its ground wherever it sits, because that is what a
Shunt is — a 100 MΩ common-mode pin draws as the connection to common it
actually makes.
Not every network is a chain, and nothing is invented for the ones that are not. A trap in a dipole leg, or a Sterba curtain’s risers bridging points on the structure, are drawn beneath the antenna and labelled with the nodes they bridge. A second antenna fed in parallel from the same point is noted rather than drawn in line, which would say the two are in series. Designs with no feed circuit are refused with a message rather than an empty picture, and a multi-feed antenna (16 driven ports, no chain) says so.
Capturing from a VNA
Section titled “Capturing from a VNA”capture sweeps a USB-attached NanoVNA and writes the .s1p the overlay and
fit read:
# list what's attachedpython -m antennaknobs capture --list# sweep 27-30 MHz and save itpython -m antennaknobs capture --out bench_10m.s1p --start 27 --stop 30 --points 101Needs the optional extra: pip install 'antennaknobs[vna]' (pyserial). Pass
--port /dev/ttyACM0 when more than one analyzer is attached; --driver selects
the protocol (nanovna today — the driver registry is the extension point for
others). Both NanoVNA console dialects are handled: the scan command on
current firmware, falling back to sweep + data 0 on the original. Whatever
the device measures is what you get — a firmware that caps the sweep at 101
points reports 101 points rather than being padded out.
Capture is CLI-only and local by design. The web workbench never opens a serial port: its backend often runs on another machine, where the serial ports aren’t yours (and on the hosted instance aren’t anyone’s business). The workflow across a remote backend is capture locally, then upload the file in the workbench.
Overlaying a VNA measurement
Section titled “Overlaying a VNA measurement”--measured <file.s1p> draws a measured sweep alongside the modeled one —
the “did my model match reality?” chart. A NanoVNA (or any VNA) exports the
one-port Touchstone .s1p this reads; files written as R+jX instead of S11
work too.
# the antenna on the bench, against the model of itpython -m antennaknobs sweep --builder dipoles.invvee --swr \ --range 28.0 29.0 --npoints 21 --measured bench_10m.s1p --fn compare.png# same comparison on the Smith chart, or as R and Xpython -m antennaknobs sweep --builder dipoles.invvee --use_smithchart \ --measured bench_10m.s1pThe overlay works on all three impedance chart forms (SWR, Smith, R/X); the
measured trace is dashed with × markers against the modeled solid line. Some
details worth knowing:
- Reference impedance. The file declares its own (a NanoVNA writes 50 Ω);
the trace is renormalized through its impedance to whatever
--z0the chart uses, so a 75 Ω calibration overlays correctly on a 50 Ω chart. - Bands. The measurement is interpolated onto the sweep grid and drawn only where the two bands overlap — a single-band measurement against a wide sweep renders over its own band, and nothing is extrapolated. Disjoint bands are an error, not an empty chart.
- Frequency only. Measured data is indexed by frequency, so
--measuredneeds--param freq(the default). - Measurement plane. The comparison happens at whatever plane the chart
already plots — normally the antenna feedpoint, so calibrate the VNA at the
feedpoint. A design whose
build_network()includes a station chain plots the station plane instead, which is what a shack-end measurement sees.
Expect some irreducible disagreement: common-mode current on a real feedline perturbs a measurement in ways a differential model does not reproduce. A structured residual — the two curves offset the same way across the band — is usually pointing at something physical (line length, ground, a connector), which is the diagnostic value of drawing them together.
Fitting a model to a measurement
Section titled “Fitting a model to a measurement”fit goes the other way: instead of drawing the measurement next to the model,
it solves for the model parameters that reproduce it — site ground constants,
as-built length, feedline electrical length, stray feedpoint reactance.
python -m antennaknobs fit --builder dipoles.invvee --measured bench_10m.s1p \ --params length_factor angle_deg --npoints 15 --fractions 0.15 --fn fit.pngIt prints the fitted values with their shifts, the RMS |ΔΓ| before and after, a
paste-ready variant block, and warnings when the fit is under-determined or a
parameter ended pinned at a bound. --plane station --line RG-213:30.5 moves
the comparison to the far end of a known feedline for a shack-end sweep.
The full treatment — how to read the residual, why identifiability is the hard part, and what a fit does and doesn’t prove — is in Calibrating a model against your VNA.
Choosing an engine
Section titled “Choosing an engine”The --engine flag selects the solver:
--engine momwire # momwire (default), default (B-spline) basis--engine momwire:sinusoidal # NEC-2's own formulation (basis, testing and feed)--engine momwire:sinusoidal-galerkin # same basis, Galerkin testing, converged feed--engine momwire:bspline # B-spline Galerkin basis--engine momwire:bspline-d1 # …at degree 1 (bs1) — the cheapest d1-vs-d2 convergence check--engine momwire:hmatrix # B-spline + hierarchical-matrix (ACA) acceleration--engine momwire:arrayblock # element-aware block solver for arrays--engine momwire:razor-2p # NEC-5 formulation twin, NEC-5's identified quadrature — the interactive lane--engine pynec # the NEC-2 reference backend (needs pynec-accel)--engine nec5 # a licensed LOCAL NEC-5 binary (joins the roster only when $NEC5_EXE points at one)nec5 is the real engine, not to be confused with momwire:razor-2p:
the latter is momwire’s independently written formulation twin (same
basis and testing rule, transcribed from the manual), while --engine nec5
drives an actual user-licensed NEC-5 binary through its card deck and
printout. It never appears in the roster unless $NEC5_EXE points at the
binary — see NEC-5 as a third engine for setup,
capabilities, and the license terms that keep it strictly local.
There is no -converged suffix any more (momwire#654). It bound a
zero-width gap in place of the sinusoidal-Galerkin solver’s NEC-style
segment-wide one, and that zero-width gap is now the solver’s own default —
so the plain sinusoidal-galerkin name means what the suffixed one used to,
and a command line carrying the old spelling should simply drop it. The
impedance converges to the B-spline answer instead of reproducing NEC’s
mesh-dependent reactance drift, which is worth two to three orders of
magnitude of apparent cross-basis disagreement on near-open high-Q feeds
(wire.lazy_h, wire.vbeam class).
If you specifically want NEC’s segment-wide gap back — cross-checking against
a NEC or EZNEC result, where reproducing the mesh walk is the point — it
survives as a solver option rather than a roster name: pick it from the web
panel’s feed-model control, or pass feed_model="segment" when constructing
the solver yourself. See Solvers & accuracy.
razor-2p is RazorSolver’s only --basis roster name — a tent basis
tested by NEC-5’s own razor-blade (mixed-potential path) rule, transcribed
from the NEC-5 manual rather than chosen for convenience, so its
convergence behaviour is checkable without the licensed binary. It binds
the two-point testing-path rule (momwire#316), reproduces NEC-5’s testing
formulation without the licensed binary, and at a working mesh it does
track the licensed engine closely (0.003–0.007 Ω, momwire#603). Its limit
is bspline-d2’s, reached more slowly — it does not converge somewhere
NEC-5-specific. razor-nec5 remains as a deprecated alias, so an existing
command line keeps working.
The class’s other quadrature — the default, converged Gauss-Legendre lane
— is not a --basis name: it left the roster in momwire#753 (2026-09-02,
“a roster entry is a menu item that must be worth ordering”, momwire#654),
and is reached only by constructing RazorSolver(...) directly. It came
first, built as the twin before momwire#316’s residue study identified the
two-point quadrature rule that made the match near-exact; keeping
full-order Gauss-Legendre on the testing path is now useful for exactly
one question — whether a coarse-mesh disagreement is the testing rule’s
own error or NEC-5’s quadrature shortcut — and it costs about 20× the
time to ask it versus razor-2p
(free space N=1600, one box, momwire 0.44.0: 20.2 s against 0.97 s; over
a finite ground at N=800, 11.1 s against 0.64 s). Memory is no longer the
differentiator it once was: since momwire#742 gave both lanes the same
C++ fill — the razor family had no accelerated path at all before that —
they sit within a tenth of each other (520 MB against 479 MB, free
N=1600). And at a fine mesh the answers converge anyway: 0.001 Ω apart at
N=1600. Neither serves
the extended kernel, junction/node-gap ports, or ground contact over a
finite ground — see Solvers & accuracy
for the full guidance and refusal boundary.
momwire is the default so a plain install works without the optional
pynec-accel package. See The solver & accuracy for which
engine to reach for — including when the accelerated hmatrix / arrayblock
solvers pay off.
The extended kernel
Section titled “The extended kernel”--extended-kernel applies NEC’s extended thin-wire kernel (the EK card) on
the momwire engine, wherever --engine is accepted:
python -m antennaknobs sweep --builder wire.dipole --extended-kernelIt matters for fat wires — segments not much longer than the wire radius —
and is a fraction of a percent on ordinary thin wire; see
the extended thin-wire kernel.
Every momwire basis but razor-2p (and RazorSolver’s unrostered
Gauss-Legendre quadrature) serves it — sinusoidal-galerkin included since
momwire 0.27.0 (momwire#246/#287/#299). On the B-spline/sinusoidal families
the one refusal left is the combination with use_singular_enrichment
(momwire#271), which exits with a named message rather than a
reduced-kernel answer under an extended-kernel request; razor refuses it
outright (it is a reduced-kernel-only formulation twin, out of scope by
design — see Razor).
The flag applies only to momwire: passing it with --engine pynec is an
error.
An imported deck brings its own: a @file.nec design whose deck carries an
EK card is solved with the kernel on without the flag, and either source
turns it on (EK -1, like an absent card, leaves it off).
Comparing engines
Section titled “Comparing engines”Solve the same design two ways and overlay the patterns — the built-in cross-validation:
python -m antennaknobs compare_patterns \ --builders beams.moxon beams.moxon \ --engines pynec momwire:bspline --fn check.pngWith a licensed NEC-5 binary on the machine (export NEC5_EXE=...), nec5
joins the roster and the comparison becomes a three-way triangle of
independently written solvers — see NEC-5 as a third
engine for setup, capabilities, and the license terms
that keep it strictly local:
python -m antennaknobs compare_patterns \ --builders beams.moxon beams.moxon beams.moxon \ --engines momwire pynec nec5 --fn triangle.pngAlongside the overlaid plot, compare_patterns prints a metrics table — peak
gain (dBi), takeoff angle, front-to-back, and −3 dB azimuth/elevation
beamwidths — one row per antenna, so the comparison comes with numbers, not just
shapes:
design peak dBi takeoff° F/B dB az bw° el bw°----------------------------------------------------------------dipoles.invvee 1.93 1 0.0 85 89beams.yagi 8.89 1 8.2 60 42Copying params back to code
Section titled “Copying params back to code”After tuning — in the workbench or with optimize — turn the knob values back
into source you can paste into a design file. params prints a design’s current
values as a default_params = {...} block:
python -m antennaknobs params --builder beams.yagipython -m antennaknobs params --builder specialty.hentenna:z100 --wrap mappingproxyFor a name:variant it prints a <variant>_params block instead — and that
block carries only the keys that differ from default_params, because a
variant is stored as an overlay on the defaults (just the deltas; the resolver
fills the rest in — see Variants are overlays). So the
second command above emits a minimal z100_params = {...} you can paste straight
back as the variant. A bare design (or :default) prints the full
default_params, since that is the baseline everything overlays.
Useful flags: --name <var> (name the emitted block), --no-ui (knob values
only, drop the ui_params block), and --wrap mappingproxy (match the
catalog’s frozen-params style). An optimize run ends by printing the same
paste-ready block for its result, so the tuned values go straight into code.
Variants are overlays
Section titled “Variants are overlays”A design can ship named variants — alternate knob-sets selected with
name:variant (beams.moxon:original, specialty.hentenna:z100). A variant is
declared as a <variant>_params mapping on the Builder class, and it is an
overlay on default_params: it lists only the keys it changes, and every
other key is inherited from default_params.
class Builder(AntennaBuilder): default_params = {"freq": 28.5, "halfdriver": 2.46, "tipspacer_factor": 0.077} original_params = {"halfdriver": 2.4336} # just the delta — the rest inheritThat is exactly the form params name:variant emits, so the round-trip is
lossless: copy a tuned variant, paste it back as its <variant>_params, and it
means the same thing. (A variant written out in full still works — overlaying a
complete dict reproduces that dict — but the minimal delta form is the idiom.)
Exporting to NEC
Section titled “Exporting to NEC”python -m antennaknobs export --builder beams.yagi --fn yagi.necThe deck is validated against nec2c, so designs round-trip into other NEC
tools. The reverse direction — loading an existing .nec deck as a design —
is parse_nec / read_nec.
Allowing user designs to run
Section titled “Allowing user designs to run”A design file in ~/.antennaknobs/designs/ is a full Python program that runs
with your user privileges, so it does not run until you allow it — like
VS Code’s workspace-trust prompt. The decision is remembered per file, by its
contents: a new file always asks first, and an allowed file that later changes
asks again. Decisions live in .trust.json inside the design folder and are
keyed relative to it, so they travel with the folder — mount it into the
Docker container
or move it to a new machine and your allowed designs stay allowed.
# A design someone sent you: review it first, then allow that exact versionpython -m antennaknobs screen ~/Downloads/their_design.pypython -m antennaknobs allow their_design
# A design you author: allow your future edits too, so saves never re-promptpython -m antennaknobs allow my_dipole --edits
# Stop allowing onepython -m antennaknobs disallow their_designscreen prints what the file does that’s unusual (imports outside the
antenna-modelling stack, file access, network use) without running it. The
report is advisory — it informs your decision, it isn’t a verdict. See
Authoring designs with Claude for the full
workflow, including the equivalent “needs your OK to run” panel in the web app.