Skip to content

Station modelling

An antenna is never fed directly. Between the transmitter and the wire there is a feedline, often a transformer, sometimes a tuner — and every one of those pieces moves the impedance the rig sees and takes a cut of the power. antennaknobs models the whole station as one system: the antenna is solved as a multiport by the field solver, and everything else is a circuit stamped on top of it, solved simultaneously. Nothing is a correction factor; the SWR at the rig, the loss in the coax, and the current arriving at the feedpoint all come out of one solution.

This page introduces the vocabulary. The worked examples put it to use: two stations compared head-to-head, the end-fed question, and three ledgers of efficiency.

A design’s build_network() returns a Network — ports, branches, sources. Ports come in five kinds:

  • PortOnWire("feed") — a real port on a named wire of the geometry, at the wire’s middle unless told otherwise. This is the seam between the circuit world and the field world: the MoM solve produces the antenna’s multiport impedance at exactly these gaps. wire= and at= put the gap elsewhere; see A port anywhere along a wire.

  • PortVirtual("rig") — a pure circuit node with no geometry. The transmitter end of a feedline is the classic one: it exists only in the network, and driving it makes every readout — impedance, SWR, gain, the power budget — rig-referenced.

  • PortOnWireFloating("feed") — a gap port with both terminals exposed, addressed as feed.p and feed.n. An ordinary PortOnWire is stamped node-to-datum: its second terminal is bonded to the common return, so a branch can only reach one side of the gap. That is a stamping convention, not physics — the field solver only ever knows gap voltage and gap current. Use this when the two sides must go to different branches, which is what a balanced feed actually needs. It asks nothing of the solver, so it works on every engine — this is the construct to reach for when you need to hang a floating element off an antenna.

    A single floating gap is a one-port, so its stamp is purely differential and provides no common-mode path — give the network a CM return, or model the common-mode path as its own gap port and let the two-port coupling carry that physics.

  • PortAtEnd("riser", "p1") — a port at a wire’s endpoint rather than a mid-wire gap. It attaches to the shared junction node at that point without cutting a gap. momwire-only: NEC-2 has no junction-node port, so the PyNEC backend rejects designs that use it (a declared engine-parity break).

    Prefer PortOnWireFloating where the attachment can be authored as a mid-wire gap — it is portable and costs nothing. PortAtEnd earns its parity break only where the attachment is genuinely at a bare conductor end, because the obvious workarounds are measurably wrong there: a vanishing centre-tapped stub converges to an open (its far face is a dead end, not live conductor), and bridging across the two conductors gives the wrong conduction graph.

  • PortAtVertex("arm", "p0") — the series feed at a junction: a voltage inserted in the current path through the node, in series with the named wire’s connection to it. This is feeding an inverted vee at its exact apex, with no bridge wire — the model NEC-5 users build natively (EX at the shared knot), and momwire’s series node gap. Deliberately not PortAtEnd, which is the shunt junction port (its drive injects net current into the node — for a series-fed two-wire apex that current reads zero by symmetry). At a two-wire vertex the wire choice doesn’t change the answer; at a junction of three or more it names which arm the gap separates. Runs on the bspline, sinusoidal-galerkin, razor-2p, hmatrix and arrayblock families over every ground model, and on NEC-5 natively; sinusoidal, pulse and the NEC-2-shaped engines refuse it by name — the honest near-miss is the short-bridge idiom, which stays a different model you author explicitly (the stock dipoles.invvee is that spelling, and dipoles.invvee_apex is this one — about 2 Ω apart, nearly all reactance, at stock geometry).

The source (Driven(port="rig")) goes wherever your measurement plane is. Put it at the antenna feed and you’re modelling the antenna; put it at the far end of the feedline and you’re modelling the station.

The whole contract in one place — the simplest station in the catalog (dipoles.invvee_coax_station, a resonant inverted vee on a real coax run) returns exactly this:

from antennaknobs.network import (
Driven,
Network,
PortOnWire,
PortVirtual,
TL,
cable_from_catalog,
)
def build_network(self):
return Network(
ports={"feed": PortOnWire("feed"), "rig": PortVirtual("rig")},
branches=[
TL.from_cable(
cable_from_catalog(self.cable), "rig", "feed", self.line_len_m
),
],
sources=[Driven(port="rig", voltage=1 + 0j)],
)

Three fields, always: ports (every name a branch or source may reference), branches, sources. The one geometry-side obligation: a PortOnWire must land on a named wire in build_wires(). By default that is the wire carrying the port’s own name, such as a short wire tagged "feed", and the gap sits at its middle.

PortOnWire(name, wire=..., at=...) puts the gap somewhere other than the middle of a wire of its own name:

  • wire names the geometry wire when it differs from the port’s name. This is how one wire carries several ports: an off-centre feed and a trap on the same leg, or a NEC deck’s source and its loads.
  • at is a position along that wire, as a fraction of its length measured from the wire’s first endpoint, strictly between 0 and 1. None is the middle. Because it is a fraction, it survives a length knob and a remesh.
ports = {
"feed": PortOnWire("feed", wire="top", at=1 / 3), # an off-centre feed
"trap": PortOnWire("trap", wire="top", at=0.85), # a load on the same wire
}

Every engine feeds that point on the wire’s own mesh: the segment count of a wire carrying a positioned port is never changed to reach it. What happens next depends on where the engine can feed:

  • PyNEC and NEC-2 feed a delta gap at a segment centre. When every positioned port on the wire is already a segment centre, the wire stays whole. Otherwise the wire is split: each port gets its own short wire centred on it, reaching a quarter of the way to its neighbours or a third of the way to a wire end, with plain wire in between. A port within a segment of an end, with nothing tighter nearby, runs its short wire to the end.
  • NEC-5 feeds a knot. When every positioned port on the wire is already a knot, the wire stays whole. Otherwise the wire is cut at every port, and each port is fed at the knot the pieces on either side share.
  • Every momwire solver feeds positioned ports exactly, split like its reference engine family. The B-spline (degree 2), sinusoidal, sinusoidal-Galerkin and pulse solvers split the wire as PyNEC and NEC-2 do. Razor and B-spline degree 1 cut it at every port as NEC-5 does, and feed each port as a series gap at the knot the pieces share.

A split wire’s solve carries a FeedPlacement advisory naming each port and its position. No engine places a port on a nearest site, so a port never moves.

A wire whose only port is a PortAtVertex keeps its authored segment count on every engine, because the vertex source sits at the wire’s end.

Two refusals keep the model honest. A distributed port spans its whole wire, so it cannot share one and takes no at. Two ports at the same point would be one port.

This is also how an imported NEC deck carries its off-centre sources and loads: the importer keeps the deck’s wires whole and positions each attachment on them (see NEC deck import).

Between ports run branches, each a physical element with a minimal, honest model:

branchwhat it is
TL / TL.from_cabletransmission line — ideal, or a real cable resolved out of the CABLES catalog with cable_from_catalog (RG-58, RG-8X, window line…) with frequency-dependent matched loss; SWR-multiplied loss emerges from the circuit solution rather than a formula
BalancedLinebalanced / differential two-conductor line — the pair sibling of TL, carrying ±I with the return riding the partner wire (open-wire feeder, the Sterba curtain’s offset-pair risers). Set by a single differential impedance zdiff; add an optional common-mode path zcomm for a pair that also carries end-to-end conductor continuity. Narrower scope than the rest of this table — see what BalancedLine is good for
Loadseries R/L/C in a wire’s current path — a trap, a terminating resistor
TwoPortseries R/L/C between two ports — a tuner’s series capacitor
ShuntR/L/C from a port to the common return — a tuner’s shunt coil
Transformerideal ratio + magnetizing branch with core-loss Q — the balun/unun model. Give it a core (ferrite.FerriteCore) instead and the branch follows the mix’s complex permeability, so inductance and loss move with frequency
Autotransformertapped single winding — two mutually coupled sections (M = k·√(L₁L₂)) sharing a node, so the common section carries the difference of the input and output currents. The ratio falls out of the L/M matrix instead of being asserted, which is what an ideal-ratio model would get wrong

Reactive elements accept a finite Q (ql, qc, qlmag), and that is where real matchboxes and transformers burn power. Degenerate values are physics, not errors: a 0 H series arm is an ideal short, a 0 F shunt is an open — sliders can sweep straight through them.

BalancedLine is newer and more narrowly validated than the other branches, so it is worth being explicit about where it earns its keep.

It models a tightly coupled pair. The differential stamp assumes the two conductors are close enough to behave as one TEM line. Measured against physical MoM wires, a pair at 0.004 λ spacing (the Sterba offset-pair scale) matches the analytic (η₀/π)·acosh(D/2a) within 3 %; by 0.06 λ the TEM model is visibly wrong and degrades monotonically in between. A widely-spaced pair is not a transmission line, and modelling it as one will quietly mislead you.

It is differential-only by construction. The stamp carries ±I and cannot represent common-mode radiation. That is a deliberate contract, and it is why the element suits structures whose pairs measurably are balanced — wire.sterba’s risers run a common-mode residual of 0.05–0.15 of the differential current. It is not the tool for asking “how much does my feedline radiate”; for feedline common-mode see FloatingBalun.

Whether zcomm matters depends on the topology it sits in. The optional common-mode path exists because a real pair also provides end-to-end conductor continuity, which a purely differential stamp drops. Two regimes, and it is worth knowing which one you are in:

  • Closed loop, λ/2 pairs — the Sterba curtain. Continuity is load-bearing: omit it and the three-bay array loses 5 dB with its beam swung 35° off broadside. But the value carries no information: sweeping zcomm from 25 Ω to 3200 Ω moves the gain by 0.05 dB, because at λ/2 the common-mode line is a repeater. Turn it on; don’t try to tune it.
  • Open feed treearrays.bowtie1x2_bl’s corporate feed. There is no common-mode return, so a CM path is a genuine extra shunt admittance and its value matters a great deal: zcomm = 100 Ω drags the driving-point resistance from 50 Ω to 5 Ω. The correct model is CM-open (leave zcomm unset), which is what that design defaults to; large finite values converge back to it.

The rule of thumb: set zcomm when the pair’s two conductors are part of a closed conduction path that the differential stamp would otherwise break. Leave it open when the pair simply ends.

Engine support follows the port, not the element. BalancedLine itself is a pure circuit stamp and runs anywhere. What can pin a design to momwire is how it attaches: a design that hangs the line off PortAtEnd is momwire-only, because NEC-2 has no junction-node port and the PyNEC backend rejects it (see Ports). Those designs lose cross-engine validation, but no longer cross-basis validation: the sinusoidal-Galerkin solver implements junction ports as well (momwire#182), in free space and over a PEC ground (momwire#191; finite grounds are still refused). So a PortAtEnd design can be checked against a different basis and a different testing scheme, not merely a second B-spline degree. A design that attaches through PortOnWireFloating keeps both engines.

Naming a line by its geometry instead of its spool

Section titled “Naming a line by its geometry instead of its spool”

zdiff is the number off the spool — 300 / 450 / 600 Ω. When you know the line instead (conductor size, spacing, jacket), balanced_line_from_geometry computes zdiff and vf for you:

# #12 bare wire on six-inch spreaders — the classic 600 Ω open-wire line
balanced_line_from_geometry(
"t1",
"t2",
"a1",
"a2",
spacing=0.1524,
length=20.0,
conductor=0.001024,
)
# a jacketed catalog wire brings its own insulation along
balanced_line_from_geometry(
"t1",
"t2",
"a1",
"a2",
spacing=0.0254,
length=20.0,
conductor="18-awg-pvc",
)

conductor is a radius in metres, a WireSpec, or a WIRES catalog key; pass conductor2 for an unequal pair. two_wire_params() is the same calculation standalone, if you want the numbers without the element.

How much to trust it depends on the construction:

  • Bare conductors are exact — the general unequal-radius Z = (η₀/2π)·acosh((D² − a₁² − a₂²)/2a₁a₂), which collapses to the textbook (η₀/π)·acosh(D/d) for a matched pair. #12 at six inches comes out at 599.9 Ω.
  • Jacketed round conductors use a coaxial-shell model: the potential splits into a dielectric part near each wire and an air part across the gap. Right for insulated open-wire and for window line, and refused outright when the jackets touch — there is no air path then, and the model would be quietly wrong rather than loudly absent.
  • Solid-web twinlead has no air path at all, so it takes the mixing rule ε_eff = 1 + fill·(εᵣ − 1) with an explicit fill (≈0.5 for a solid web, ≈0.15–0.25 for windowed). That is a fitted fraction, not a derived one.

A manufacturer’s “450 Ω” is a round number covering a range of real constructions, so expect geometry to land within a few percent of a nameplate rather than on it — and when you know the nameplate vf, that is better data than either estimator here.

Three catalog designs use BalancedLine today: wire.doublet_balanced_tuner (floating centre port — runs on every engine), plus wire.sterba_bl and arrays.bowtie1x2_bl (both PortAtEnd, so momwire-only).

You could assemble every tuner from raw branches — but the common boxes ship pre-built in antennaknobs.station, and designs instantiate them by name:

from antennaknobs.network import Instance, TL
from antennaknobs.station import t_network_tuner
branches = [
Instance(
"tuner",
t_network_tuner(c1_pF=81.2, c2_pF=500, l_uH=4.218, ql=200),
rig="rig", # formal → actual port map
out="li",
),
TL.from_cable(cable_from_catalog("openwire-600"), "li", "feed", 30.48),
]

A box (Composite) has a formal port interface and a private inside: the tuner’s tee midpoint exists as tuner.m, invisible to the rest of the design. The stdlib today: t_network_tuner, l_network_tuner, unun (with the compensation capacitor real 49:1 builds carry), balun, autotransformer, link_coupling, balanced_l_tuner — all parameterized in radio units (picofarads, microhenries) — plus two special members:

  • bypass() — a box-shaped nothing: it wires its input straight to its output. Swap any tuner or balun for bypass() and you get the same station without that box, in a one-line change — the honest way to answer “what is this component actually buying me?”

  • stubsshunt_shorted_stub, shunt_open_stub, and their series_* twins are matching elements made of nothing but cable: a length of line terminated in a short or an open, presenting +jZ₀·tan(βl) or −jZ₀·cot(βl) at its port. Lengths are given in wavelengths on the line at a design frequency (the velocity factor is applied for you; the composite bakes metres, so it detunes across a sweep like the real thing), and cable="RG-213" cuts it from a catalog reel, loss included. single_stub_tuner and double_stub_tuner place them at the classic positions — a match with no lumped parts, just cable lengths. verticals.stub_matched_vertical is the worked example: a 22 Ω quarter-wave vertical brought to 50 Ω by two lengths of RG-213, with both lengths as live knobs so the match is something you find.

  • a tuner that tunes itselfl_network_tuner(tune_to=50) leaves out the two values, and the engine solving the design picks them. At tune_at_mhz (else the design frequency) it chooses the parts that present tune_to ohms for whatever hangs on the tuner’s output, then holds them, so a sweep shows the tuned network’s bandwidth rather than a match that follows you around. mode picks the parts: "low" (series L, shunt C, the default), "high" (series C, shunt L), "ll" or "cc". shunt_at puts the shunt part across "out" (the antenna side, the default, as on the fixed-value tuner) or "rig" (the transmitter side); "auto" takes the side the load calls for, the way an L autotuner’s relay moves its capacitor: across out when the load’s resistance must come down, across rig when it must go up. A fixed side can only step a load one way, which is what makes it worth modelling: the tool tells you when your box cannot match. The readout reports what it tuned to. SimNEC’s automatic LC match element imports as this component, and a low- or high-pass tuner exports back as that element, so each program tunes it against its own antenna. What the element cannot say (a T, "ll" / "cc", component ranges, a fixed side that found no match) is refused by name; --freeze-tuners writes the tuned parts as fixed components instead.

    t_network_tuner(tune_to=50, c_max_pF=250) is the T that tunes itself. A T has three parts for two conditions (R and X at the rig), so one is held: give any one of c1_pF, l_uH or c2_pF and the other two are tuned, or give none and a capacitor sits at c_max_pF, the way a T autotuner parks one capacitor and searches the other two. pin says which: "c1", "c2", or "auto" (the default), which tries both and keeps the less lossy. Which capacitor you park barely changes the loss (at most 0.14 dB apart, across 625 loads and four bands), but it changes which loads the tuner can reach, and each capacitor reaches loads the other cannot.

    Both tuners take component ranges — c_min_pF, c_max_pF, l_min_uH, l_max_uH — because a real tuner’s parts are finite: the algebra will happily ask for a 900 pF capacitor nobody owns.

    When the target is out of reach a tuner does what an operator does: it tunes the parts it can move for the lowest SWR they give, leaves them there, and says so. The readout adds an SWR reached row and marks the tuning best effort, and an advisory names the part that ran out of range. This covers a tuning that needs a part outside its range, and a T with two parts given, where one part cannot set both R and X. The tuner is bypassed instead, with the reason, when no network of its kind could match the load at all (a shunt across the load can only bring its resistance down), or when its best is no better than the load’s own SWR, the way a real tuner’s bypass relay wins. on_no_match="bypass" asks for the bypass whenever the exact answer is out of reach.

    Knobs, or a tuner that tunes itself? Reach for knobs — fixed part values marked for the optimizer, as loops.skyloop_lmatch marks series_L_uH and shunt_C_pF — when the parts are the subject: you want the sliders and to see what values the box takes. Reach for tune_to when the box is not the subject and its parts are an outcome. A self-tuning tuner retunes on every solve, because every solve builds its own engine, so during an optimizer run or a knob drag it follows the antenna the way a real autotuner does. That is why the optimizer and the drag-hold refuse a match objective measured at the tuner’s tune frequency: there the port reads tune_to whatever the knobs do, so there is nothing to optimise. One band edge away the tuner holds its parts and the match responds again, so “make my tuner setting also match at the band edge” works as asked.

qlmag is a single, frequency-independent Q on the magnetizing branch. Real ferrite has a strongly frequency-dependent complex permeability μ = μ′ − jμ″, and that dependence is the whole story of a choke — a 43-mix balun burns its watts in one part of the spectrum and is nearly lossless elsewhere. A flat Q cannot say “loses most here, little there”, which is usually the question being asked.

Give a Transformer, FloatingBalun, unun or balun a core instead:

from antennaknobs.ferrite import core_from_catalog
from antennaknobs.station import unun
core = core_from_catalog("FT-240", "43", turns=11, c_stray_pF=3.0)
unun(7.0, core=core) # 49:1, wound on that core

core supersedes lmag/qlmag wholesale — it is the core, the same way TL.from_cable’s cable is the cable. The magnetizing branch then follows the material: μ′ sets the inductance, μ″ sets the loss, and the effective Q is just μ′/μ″. A material with flat μ′ and μ″ = μ′/Q reproduces the scalar model exactly, which is how the two connect.

Three honesty notes, because this is a place where a model can look more authoritative than it is:

  • The material data is the vendor’s, fetched not bundled. core_from_catalog downloads Fair-Rite’s published complex-permeability CSV for the mix on first use and caches it under ~/.antennaknobs/ferrite, so the package ships no vendor data and everything after the first call is offline. FerriteMaterial.from_table() takes your own measurements instead — better data still, since the vendor never wound your toroid.
  • Parametric mix models were tried and dropped. An earlier version shipped one-pole fits; measured against the published curves their relaxation frequencies were out by up to 8× (mix 43 peaks at 4.4 MHz, not 35), and even a best-fit two-pole model misses by 22–60% over 0.1–100 MHz for every mix but 31. These NiZn ferrites have a distribution of relaxation times, so no small parametric catalog can be honest about their loss.
  • A choke’s impedance peak comes from the winding, not just the material. A single relaxation climbs and saturates; the few pF of winding self-capacitance (c_stray_pF) parallel-resonate with it, and that is what puts the peak in every published choke plot — and why |Z| falls again above it.

unun / balun model the isolated case: two windings, coupled only through the core, so the secondary current is a ratio-scaled copy of the primary’s. autotransformer is the other one — a single coil with a tap, so the two sections are galvanically connected and the common section carries the difference of the input and output currents.

That difference is constitutive, not cosmetic, which is why the element is two mutually coupled inductors rather than an ideal ratio:

from antennaknobs.station import autotransformer, autotransformer_ratio
# 4 µH from ground to the tap, 1.05 µH from the tap to the top
Instance("xf", autotransformer(4.0, 1.05, k=0.99), tap="feed", top="rig")
autotransformer_ratio(4.0, 1.05) # 1.51 — impedance ratio n² ≈ 2.3

Turns go as √L on one core, so the ideal ratio is n = 1 + √(upper/lower) and the tap sees the top’s load over . The model reproduces that in the k → 1 limit rather than assuming it — at realistic coupling (0.95–0.99) you get a little less, plus the series leakage reactance a real tapped coil has, which is the whole reason not to use an ideal ratio here.

k must lie in [0, 1]. Above 1 the inductance matrix stops being positive semi-definite (M² > L₁L₂) and the pair would deliver more energy than it stores; SimSmith permits that and calls it non-physical, we refuse it, because the power budget claims to balance. ql gives both sections a finite Q, and only the resistive part is itemised — never the reactive power the two sections trade back and forth.

Boxes are ordinary values made by ordinary functions, so a design can also define its own — a measured, calibrated component wrapped once and reused across variants.

An ideal line has frequencies where the circuit genuinely has no finite solution. A lossless half-wave line has no finite admittance at all; a lossless quarter-wave open stub is a dead short across the port it hangs on. These are not numerical accidents — they are what the idealisation says, and the fix is physical: give the line the loss it really has (k1/k2, or cable="..."). Any real attenuation moves the pole off the real axis.

Two places this shows up:

  • At construction, when the length and design frequency make it decidable — shunt_open_stub at exactly λ/4 refuses to be built.

  • Mid-sweep, when a length that was innocuous at its design frequency becomes λ/4 or λ/2 somewhere else in the swept band. Nothing is decidable in advance there, and no individual element is singular — each stamp is finite and the assembled system is not. The reducer detects it, names the branch and its electrical length, and repeats the remedy:

    SingularNetworkError: the network has no finite solution at this frequency
    (reciprocal condition 7.6e-16 after equilibration). Every branch stamped fine
    on its own — the singularity is in the assembled system.
    Suspect:
    stub: open-ended line feed→stub.far is 0.2500 λ at 35.0000 MHz — an odd
    multiple of λ/4, where an open stub's Z_in = 0 shorts the port it hangs on

A sweep loses only that sample: the frequency in question comes back as NaN (the workbench renders it as an open) with the reason logged, and every other frequency answers normally. A single-point solve raises, because someone who asked about one frequency should get the sentence, not a silent NaN.

The near-miss is the sneaky case and gets the same treatment: a sample a few kHz off the pole is not singular, merely dominated by it, and produces enormous numbers whose leading digits are noise. That logs a warning rather than refusing — the answer exists, it just should not be believed.

Because every branch current is an explicit unknown in the circuit solve, dissipation is read off the solution, branch by branch. The workbench shows it as the power budget: one row per lossy element, grouped by box (tuner: Shunt m), an antenna (accepted) row for what survives to the wires, and — with a finite ground selected — the honest bottom line, radiated (incl. ground).

Those are the three ledgers of efficiency in one display: the network’s cut, the structure’s cut, and the dirt’s cut. A station that is “matched at 1.1:1” can still be delivering half its power to the feedline and the ionosphere none the wiser — the budget is what makes that visible while you turn the knobs.

The circuit layer is minimal on purpose. The transformer is an ideal ratio plus one magnetizing branch — enough to reproduce a published insertion-loss curve’s shape, calibrated to a measurement, not a full core characterization. Line loss is the cable-table matched-loss model. Component Q is constant with frequency. Each of these is the simplest model that makes the power budget honest; when a measurement disagrees, the knobs (qlmag, ql, cable choice) are where you reconcile it.

Open wire.doublet_ladder_tuner — an 88 ft doublet, 100 ft of open-wire line, and a lossy T-network, referenced to the rig. Watch the tuner’s rows in the power budget as you drag the capacitor knobs: the SWR meter and the watts tell different stories, and this page is the vocabulary for reading both.