Skip to content

Loading NEC decks

antennaknobs can export any design as a NEC2 card deck; read_nec is the reverse direction. (SimNEC .ssn circuits have their own matching pair — see SimNEC round-trip.) It parses a .nec file — the format xnec2c, 4nec2, EZNEC, and fifty years of antenna handbooks all speak — into wire geometry a design can return from build_wires, so you can solve, sweep, and view a deck someone published without retyping its coordinates.

Set expectations first, though: a NEC deck is frozen geometry, not a parameterized design. The knobs are where antennaknobs earns its name — a native builder expresses its dimensions as parameters and wavelength fractions, so element lengths, spacings, and angles are all draggable and optimizable, and design_freq moves the whole antenna to another band. A deck has none of that structure to expose; coordinates are just numbers. What an imported deck supports is the measurement frequency, a height lift, and a whole-geometry scale — useful, but blunt. Treat the import as a viewer for published decks, and as a source of dimensions when you decide a design is worth porting to a real AntennaBuilder.

You don’t need any Python to run a deck. Drop the .nec file in ~/.antennaknobs/designs/ (on Windows, %USERPROFILE%\.antennaknobs\designs\) and it appears in the workbench’s design list as user.<filename>, with the deck’s own frequency, per-wire radii, feed and network; the reload button next to the design picker re-reads it after an edit. A deck is data, so unlike a Python design it never asks to be allowed. For one-off CLI work the same loader takes the file straight from a path: every subcommand accepts an @file.nec builder spec — python -m antennaknobs draw --builder @my_yagi.nec — and decks mix with named designs in --builders lists (see Naming a design). In PowerShell quote the whole spec, --builder "@C:\decks\my_yagi.nec": an unquoted @" opens a here-string there, and the path never reaches the program.

Write a stub when you want knobs on the deck — a height lift, a scale stretch — or a label of your own. Put it next to the deck in the same folder; a stub of the same name as its deck is the design, and the bare deck is not listed twice:

# my_yagi.py — my_yagi.nec sits next to it
from types import MappingProxyType
from antennaknobs import AntennaBuilder, read_nec
WIRES_FILE = "my_yagi.nec"
class Builder(AntennaBuilder):
label = "My Yagi (NEC deck)"
default_params = MappingProxyType(
{
"freq": 14.1,
"scale": 1.0, # stretches the whole deck; drag to resonate
"height": 10.0, # lifts a z=0 deck above the ground plane
}
)
def build_wires(self):
deck = read_nec(self, WIRES_FILE, network=True)
s, h = self.scale, self.height
lift = lambda p: (p[0] * s, p[1] * s, p[2] * s + h)
# specs=True: `Wire` entries, each carrying ITS OWN radius from the
# deck's GW card (and LD 5 conductivity) — a fat driven element
# with thin radials solves faithfully, wire by wire. The named
# entries carry the deck's feed and network attachment points.
return [
w._replace(p0=lift(w.p0), p1=lift(w.p1))
for w in deck.wire_tuples(specs=True)
]
def build_network(self):
# The deck's EX drive plus its translated LD/TL/NT cards (see below).
return read_nec(self, WIRES_FILE, network=True).network()

read_nec(self, name) has the same folder confinement as read_json: it can only read files next to the design — no absolute paths, no .. that climbs out, no symlinks pointing elsewhere — so a deck-based design stays safe to share: the data can only ever become antenna geometry.

The two knobs above are essentially all a deck can offer, and both are worth having:

  • height — most published decks are drawn at z = 0 in free space, which would put the wires in the workbench’s ground plane.
  • scale — a deck is fixed metres; a uniform stretch of the whole geometry is the only way to move its resonance without editing the deck. It scales everything together — element lengths and spacings and the boom — so it is nothing like a native design’s per-dimension knobs, but it will pull a slightly-off deck onto frequency.

There is no design_freq band scaling, no per-element tuning, and nothing meaningful for the optimizer to hold onto — those need dimensions expressed as parameters, which is exactly what porting the deck to a native AntennaBuilder gives you.

And the radius is not optional. The solvers default to an idealized 0.5 mm wire; a real deck’s 5 mm Yagi elements have very different reactance. On the xnec2c 2 m Yagi example deck, the imported geometry matches the independent nec2c solver to 0.011 Ω with the deck’s radius, and misses by 35 Ω without it. wire_tuples(specs=True) (above) handles this per wire: every emitted Wire carries a WireSpec with that GW card’s own radius, so even a mixed-radius deck keeps its reactance. (The pre-#388 recipe — plain wire_tuples() plus a build_wire_material() returning WireSpec(radius=deck.dominant_radius(), conductivity=deck.conductivity) — still works, approximating mixed radii with the length-dominant one.)

Two notes on per-wire radii: both engines honor them fully — PyNEC natively (NEC takes a radius per wire), and every momwire solver since momwire 0.13.0, the compressed H-matrix family included. And the scale knob stretches geometry only — specs describe physical wire stock and are never scaled.

A deck’s FR card is exposed as deck.freq_mhz = (lo, hi). Use it at import time to seed freq and the measurement window, so a 40 m or 2 m deck is tunable on its own band instead of parked in the default window. A stub can do this to itself at the bottom of the file — copy this block verbatim:

def _seed_defaults_from_deck(cls):
"""Follow the deck's FR card: default `freq` to the sweep centre and set
the measurement window to the sweep, so the design tunes on its own band.
Also surface the run-config cards the import recorded but didn't apply
(deck.skipped_note()) as the UI's informational design note.
Errors are swallowed here so they surface in build_wires instead of
killing the import."""
try:
deck = read_nec(cls(), WIRES_FILE, network=True)
except Exception:
return
params = dict(cls.default_params)
ui = dict(params.get("ui_params", ()))
if deck.freq_mhz:
lo, hi = deck.freq_mhz
mid = 0.5 * (lo + hi)
if lo >= hi:
lo, hi = 0.9 * mid, 1.1 * mid
params["freq"] = round(mid, 3)
ui["meas_freq_range"] = (lo, hi)
note = deck.skipped_note()
if note:
ui["notes"] = note
params["ui_params"] = MappingProxyType(ui)
cls.default_params = MappingProxyType(params)
_seed_defaults_from_deck(Builder)
CardsMeaning
GWStraight wires (tag, segments, endpoints, radius)
GA, GHArcs and helices, generated as per-segment chords exactly as NEC does internally
GMMove / replicate — repetitions compound, each copy transforming the previous one
GXReflect in the Z, Y, X planes, tag increment doubling per plane
GRRotate about Z into a cylindrical array
GSScale — including xnec2c’s tag-range extension (scale only tags I1..I2)
EX types 0/5Voltage-source feeds, resolved through NEC’s (tag, segment) addressing
EX types 4/6Current-source feeds (NEC-5’s type 4, 4nec2’s type 6), in amps — network=True only, see below

Card semantics are transcribed from the nec2c 1.3.1 sources, quirks included (tag 0 never increments under any transform), and validated against it: 73 of the 75 xnec2c example decks parse — the other two are rejected with a deliberate “cannot model” error — and impedance round-trips through the nec2c CLI agree to a fraction of an ohm.

Decks are read the way real ones are written: free-format fields separated by spaces and/or commas, missing trailing fields as 0, old Fortran 1.0D+03 exponents, CM/CE comment headers, parsing stops at EN.

A feed on a segment that isn’t its wire’s middle keeps its wire whole. With network=True it becomes a port positioned along the wire at that segment’s centre, and each engine meshes the wire so the gap lands exactly there; PyNEC and NEC-2 keep the deck’s own segment count. Without network=True, a wire tuple can only feed its middle segment, so the wire is still split on the deck’s own segment boundaries and the feed gets a 1-segment wire of its own: same geometry, same feed point.

Wires are also split wherever another wire’s segment endpoint touches them mid-wire. NEC connects segments whose ends coincide — the grouping into GW cards is irrelevant to it — so a deck may run one wire straight through another and rely on the crossing carrying current (the W8IO whip benchmark’s matching straps cross the whip axis exactly this way). antennaknobs’ engines junction wires at wire ends, so the import shatters such wires at the shared boundary: same segments, same boundaries, and the crossing becomes the real junction NEC’s connection rule implies.

Loading, feedlines, and networks (network=True)

Section titled “Loading, feedlines, and networks (network=True)”

read_nec(self, name, network=True) additionally translates the deck’s LD/TL/NT cards into the workbench’s own port-network branches (antennaknobs.network: Load, TL, TwoPort, Shunt — the same system the trap dipole and station designs use), wherever it can express them exactly:

CardTranslation
LD type 0/1 (lumped series/parallel RLC)A Load per segment in the card’s range (expanded up to 8 segments), each a port positioned at its segment’s centre on the uncut host wire. On a NEC-5 deck the card names one knot instead (see the NEC-5 dialect)
LD type 4 (fixed impedance)Load(r=…) when X = 0; a fixed complex-Z Load when reactive
LD type 5 over the whole structure (wire conductivity)deck.conductivity, baked into every wire_tuples(specs=True) spec (or feed it to WireSpec in build_wire_material)
LD type 5 on a tag/range covering whole wiresPer-wire conductivity (deck.wire_conductivity), baked into those wires’ specs=True specs — a ranged card wins over the whole-structure one
LD type 6 (4nec2 LC-trap)The parallel RLC 4nec2 itself converts it to: R_p = Q·ωL at the deck’s first FR frequency (F1 is the coil’s unloaded Q, 0 → 100)
LD type 7 (4nec2 wire insulation)Per-wire dielectric jackets (deck.wire_insulationWireSpec.insulation_radius/eps_r) for wires the card covers in full — the solvers model the insulated-wire velocity factor
EX type 6 (4nec2 current source)A DrivenCurrent source — the forced complex current drives the network exactly as 4nec2 would
EX type 4 (NEC-5 current source)The same DrivenCurrent, at the segment end NEC-5’s rule names (I4, else the sign of I3) — the form EZNEC’s NEC-5 export writes. NEC-2’s type 4 (an elementary current source at a point in space, no segment addressed) is refused by name
TLA TL branch: negative z0 (NEC’s crossed line) becomes transposed=True, zero length resolves to the port separation, conductance-only end admittances become Shunt(r=1/G), reactive ones a fixed 1-port Admittance
NT with an all-real, rank-1 Y matrixAn ideal Transformer (turns ratio plus a series winding resistance) — EZNEC’s own transformer card, read as the transformer it is rather than the pi network that Y would also describe
NT with an all-real Y matrix, not rank 1Its exact resistive pi: a series TwoPort between the ports plus a Shunt at each
NT with susceptanceThe full 2×2 complex Y as an Admittance branch

deck.wire_tuples() then emits the deck’s wires, cut only where another wire touches them and named where something attaches (no legacy ex markers), and deck.network() returns the matching Network — the deck’s EX cards become its Driven sources — ready to return from build_wires / build_network as in the quick start above. A deck with no network cards still works identically: network() is then just the drive.

What cannot be translated exactly stays out, with a per-card reason in deck.ignored_detail (rendered by skipped_note()): distributed per-metre RLC (LD 2/3), an LD 5/7 range covering only part of a wire’s segments (per-wire specs cover whole wires only), and an LD landing on a segment that also has a TL/NT connection (NEC composes those in series inside the segment, which the port model doesn’t express).

An NT with susceptance anywhere holds at one frequency only. EZNEC writes such a card — a line, an L network, or a load — by evaluating it once at the deck’s FR frequency and freezing the result; an all-real NT (the pi network or the transformer above) is a real circuit and holds at every frequency. A deck with a frequency-dependent NT carries an import note naming the card and the frequency it was written for, and solving or sweeping away from that frequency raises the same advisory in the solve response: the network is applied unchanged, so the result does not model it off that frequency.

A deck also carries run configuration, which the workbench manages itself. Those cards are recorded in deck.ignored rather than translated:

  • GN/GD ground and the GE ground flag — the workbench applies its own ground model, but since v0.75.1 that model starts where the deck is: a deck in the designs folder opens with the ground switch set from its cards (GE 0 off; GE 1 or GN 1 perfect; GN 2 finite + Sommerfeld and GN 0 finite + reflection coefficients, each with the card’s own εr and σ in the soil fields), the ground panel says “from the file”, and the CLI’s @file.nec route applies the same unless --ground is given. A NEC-5 deck is the exception for GN 0: NEC-5 has no reflection-coefficient ground, so its GN 0 is Sommerfeld, and the panel says “Sommerfeld (NEC-5 GN 0)”. A deck reads as NEC-5 when its GN card ends in NEC-5’s NOFILE, when a source sits at a segment end, or when EZNEC’s stamp says it wrote NEC-5. GD means different things in the two dialects. In a NEC-5 deck a bare GD after GE 1 is the ground: EZNEC’s “Real, MININEC type”, perfect for the currents and the card’s εr and σ for the pattern. It opens as the MININEC-type ground, and GD -1 cancels it to free space. In a NEC-2 deck GD is a second medium beyond a cliff, read only by a cliff-mode RP. With the cliff at radius 0 and height 0 after GN 1, it is the same MININEC idiom, and so is 4nec2’s GN 3. Both open as the MININEC-type ground. Any other GD (a real cliff, or a GD over a finite GN) is a card not applied. deck.ground_spec carries it in the CLI’s --ground shape and deck.ground still says whether the deck wanted a ground at all. The solver-slot label reads N = deck’s own for such a design: its GW segment counts are what the solvers honour, whatever the slot’s N says.
  • LD loading, TL/NT feedlines and networks — unless imported with network=True as above
  • FR sweeps (harvested into deck.freq_mhz), RP/NE/NH/XQ output requests

Expect readouts to differ from a deck’s published numbers when those numbers relied on its ground or on cards the translation could not express.

deck.skipped_note() turns that record into one human-readable sentence (“Deck cards not applied: LD (loading), RP (radiation-pattern request) — the app’s own settings are used instead.”), or None when the deck carries nothing the workbench overrides. The ground is not listed, because the workbench seeds its ground from the deck as described above. Deck-backed design stubs put it under ui_params["notes"] and the workbench shows it beneath the antenna selector, so the mismatch is explained right where the deck is viewed.

Decks the wire-model genuinely cannot represent are rejected with a clear error rather than silently approximated: surface patches (SP/SM), tapered wires (GC), Green’s-function files (GF), and plane-wave excitation.

Real-world decks are mostly written for 4nec2, and the importer reads that dialect natively: SY symbolic variables with full expressions (BASIC-style — trig in degrees, ^ power, unit suffixes like 1.5*mm or 36.6pF), '-comments, fused mnemonics (GW1,8,…), and #14-style AWG wire-gauge radii. A deck’s EK card (extended thin-wire kernel) is honoured on both engines — momwire builds the solver with its own O(a²) tube expansion, PyNEC with NEC’s — so fat-wire decks solve kernel-for-kernel with NEC without a flag (EK -1, like an absent card, stays reduced; see the extended thin-wire kernel). A remote 1-segment wire parked hundreds of wavelengths away purely to terminate a TL card is recognised and replaced by a virtual circuit node (the deck solves in seconds instead of meshing an electrically irrelevant wire; the substitution is named in skipped_note()). EZNEC’s virtual wire — the short, distant wire its transformer and lossy-line idiom puts a source or a network end on — is recognised the same way: each referenced segment becomes a virtual circuit node, the source drives the node, and the idiom’s open-circuit pins are kept as the ideal open they stand for, so the deck solves without meshing the wire and skipped_note() says what was translated. A source EZNEC placed on that virtual wire is the rig — the generator end of the deck’s feed system, not the antenna — so it takes the station convention’s port name ("rig"), and "feed" keeps meaning the antenna’s own terminals, the same naming the SimNEC importer uses. When what a phantom segment drives is an NT gyrator (zero diagonal, Y12 = Y21 = jB), the only way NEC-2 can spell a current source, it imports as the current source it is, whether the phantom wire has one segment or several, and the driving point reads as the antenna rather than its reciprocal. This dialect support was validated against a 3,146-deck corpus of published models — ARRL course material, 4nec2’s own library, and the wider web.

4nec2 (5.7.0 and later) also lets EX, LD, TL and NT give a segment as a percentage of the wire’s length, measured from its first end: EX 0 2 50% 0 1 0 feeds the middle of wire 2. With network=True the percentage is the exact position: a port positioned along the wire at that point, fed there on every engine, and the wire’s own segment count is never changed to reach it. When every such point on the wire is already a segment centre (PyNEC, NEC-2) or a knot (NEC-5) of its mesh, the wire stays whole and each port is fed there. Otherwise the wire is split so every port on it is fed exactly, however many share it, and a FeedPlacement advisory says so. On PyNEC and NEC-2 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 cuts the wire at every port and feeds each one at the knot the pieces on either side share.

Another program can feed a percentage that falls exactly on a segment boundary differently. antennaknobs feeds the stated point.

Without network=True, the percentage names the segment whose centre is nearest. A percentage exactly on the boundary between two segments is equally near both, so it is refused, and the message names network=True. 0% and 100% use the end segment’s centre. A percentage needs a tag that names one wire, and an LD range given as two percentages expands over the segments between them.

With network=True a feed at a wire end is fed at the end itself, not at the centre of the segment it stands in. That distinction is the whole difference between the two drive models: a segment gap is E = V/Δ spread over the mesh cell, where the contact and the cell’s centre are one drive, while the default point model is E = V·δ(s − s_f), where where in the cell the point sits IS the answer. On a base-fed vertical over ground the two placements differ by 29 % in X.

NEC-5 changed one thing the importer must not guess about: sources can sit at a segment end (a knot) rather than a segment center — EX grows an end-selector field, and a negative segment number selects an end too. NEC-2 uses that same field position for print-control flags, so the two dialects genuinely collide. The importer resolves it conservatively: the spellings that can only be NEC-5 (a negative segment, or the end-selector value NEC-2 never defines) are refused with the dialect named — never silently read as a NEC-2 center feed half a segment away — while the one ambiguous value keeps its legal NEC-2 meaning. The exception is NEC-5’s current source, EX type 4: NEC-2’s type 4 addresses no segment, so a segment-addressed type 4 can only be NEC-5, and the importer reads its end field by NEC-5’s full rule (I4 names the end; when zero, a positive segment number means end 2). With network=True, a voltage source on an interior knot keeps its wire whole. On the middle knot it is an ordinary middle-of-wire port, the way antennaknobs feeds every centre-fed design; on any other interior knot it is a port positioned at that knot. A source at a wire end, an EX 4 current source, or a source on a knot where another wire joins imports as a PortAtVertex, voltage or current alike. Either way it solves on momwire, and the deck also solves natively on the NEC-5 engine, which speaks the form as a first-class citizen. NEC-5’s GN card also names a ground file, and the NOFILE that says there is none is accepted.

A deck that shows neither NOFILE nor an explicit end field reads as NEC-2, because the EX card alone cannot say which program it was written for. Such a deck declares itself NEC-5 with a comment card whose whole text is NEC-5 (CM NEC-5), or with EZNEC’s own stamp line — CM ! Written by EZNEC/Pro+ v. 7.0 in NEC-5 format. — whose format token names the dialect: NEC-5 declares it, NEC-2 and NEC-4.2 keep the NEC-2 reading (EZNEC’s NEC-4.2 slot writes the same deck with NEC-4’s EX 6 segment current source in place of EX 4), and any other token is refused by name. An EX with I4 = 0 then reads NEC-5’s way: end 2 of a positive segment, end 1 of a negative one, and the same rule reaches the ends NT and TL cards name, so TL 3,2,2,-1 and EX 4,2,-1 resolve to one port. A comment that only mentions NEC-5 declares nothing.

On a deck read as NEC-5, a discrete LD card (types 0, 1, 4 and 6) addresses a segment end the same way: I3 is the segment and I4 its end, not the last segment of a range. Each such card is one load at that knot, placed like a source there, and a load on the fed knot shares the source’s port.

Outside a design, parse_nec(text, name=...) takes raw deck text and returns the same NecDeck; name labels errors, which always carry the offending line number (my_yagi.nec, line 7: GW card: segment count must be >= 1, got 0).

from antennaknobs.nec_import import parse_nec
deck = parse_nec(open("some.nec").read(), name="some.nec")
NecDeck fieldMeaning
wirestuple[NecWire, ...] — every straight wire after all transforms (tag, n_seg, p1, p2, radius)
feedstuple[NecFeed, ...] — each EX source resolved onto a wire (wire index, 1-based seg, complex voltage; current=True marks a forced current in amps, 4nec2’s EX 6 or NEC-5’s EX 4; edge 1/2 marks a NEC-5 end source)
freq_mhzThe FR card’s sweep range as (lo, hi) MHz, or None
groundTrue if the deck requested a ground plane (GE flag or a GN card)
ground_spec, ground_methodThe ground the deck models, in the CLI’s --ground shape — None (free space), "pec", ("finite", eps_r, sigma) for GN 2, ("finite-fast", eps_r, sigma) for a NEC-2 deck’s GN 0 (a NEC-5 deck’s GN 0 is "finite"), ("mininec", eps_r, sigma) for the MININEC-type ground — and the finite model’s name ("sommerfeld" / "fast" / "mininec")
ground_card, nec5_dialectThe card the ground came from ("GN 0" / "GN 2" / "GD" / "GN 1 + GD" / "GN 3", or None), and whether the deck shows NEC-5’s dialect: NOFILE on its GN card, a source at a segment end, or EZNEC’s NEC-5 stamp
commentsThe CM header text, line by line
ignoredMnemonics of run-configuration cards seen but not applied
loads, tls, ntsThe translated LD/TL/NT records (network=True only)
conductivityWhole-structure LD 5 wire conductivity in S/m, or None
ignored_detail(mnemonic, reason) per card network=True still could not translate

plus four methods: wire_tuples() (the deck as build_wires() tuples; raises if no voltage source drives the antenna), network() (the translated cards + EX drives as a Network, network=True only), dominant_radius(), and skipped_note() (the not-applied record as one informational sentence, reasons included).