Metadata-Version: 2.4
Name: momwire
Version: 0.79.1
Summary: A pure-Python method-of-moments antenna simulator with optional C++ accelerators
Author-email: "Steven M. Burns" <smburns47@gmail.com>
Project-URL: Homepage, https://github.com/stevenmburns/momwire
Project-URL: Issues, https://github.com/stevenmburns/momwire/issues
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: MacOS
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.21
Requires-Dist: scipy>=1.7
Requires-Dist: threadpoolctl>=3
Provides-Extra: test
Requires-Dist: pytest; extra == "test"
Requires-Dist: pytest-xdist; extra == "test"
Requires-Dist: matplotlib; extra == "test"
Requires-Dist: scikit-rf; extra == "test"
Requires-Dist: pyyaml; extra == "test"
Requires-Dist: setuptools>=61.0; extra == "test"
Requires-Dist: wheel; extra == "test"
Requires-Dist: pybind11; extra == "test"
Provides-Extra: validation
Requires-Dist: pymininec>=1.2; extra == "validation"
Dynamic: license-file

# momwire

A pure-Python method-of-moments antenna simulator with optional C++ accelerators (pybind11).

Extracted from [antenna_designer](https://github.com/stevenmburns/antenna_designer).

## Solvers

`BSplineSolver` (degree-d Galerkin, default d=1/2) is the default solver;
`HMatrixSolver` and `ArrayBlockSolver` are structural accelerators built on
top of it. `SinusoidalSolver` and `SinusoidalGalerkinSolver` reproduce NEC2's
three-term basis (collocation and Galerkin testing respectively) as
in-codebase NEC comparators. `RazorSolver` is the NEC-5 formulation twin: a
tent basis with razor-blade (RWG mixed-potential path) testing, transcribed
from the NEC-5 Users Manual rather than NEC2's, over every ground, buried
and crossing decks included. Because its
testing rule — not its basis, which it shares with `BSplineSolver(degree=1)`
— is NEC-5's own, it reproduces NEC-5's characteristic slow O(1/N) impedance
walk without needing the (licensed) NEC-5 binary; see
[docs/razor-solver.md](docs/razor-solver.md) (momwire#309).

## Ports

Every solver family exposes the same port surface, and it is the surface to
build on: `compute_port_solution()` runs **one** fill and **one**
factorisation over every port at once, so a multi-port structure costs what a
single-port one costs.

Three kinds of port, declared at construction, and they can be mixed:

| kwarg | what it is | entry |
| --- | --- | --- |
| `feeds=` | a delta gap at a point along a wire — NEC's `EX 0` | `(wire_index, arclength, voltage)` |
| `junction_ports=` | a shunt port on a junction NODE's KCL row: the node drives net inflow (the node's row leaves the constraint set) | `(junction_index, voltage)`, or a bare index for 0 V |
| `node_gaps=` | a SERIES EMF at a junction node, in series with one named wire end — the apex feed | `(wire_index, "start"\|"end", voltage)` |

Ports are numbered `[feeds…, junction_ports…, node_gaps…]`, and that is the
order every port readout is in.

**A junction port has one terminal.** Its voltage is the node's potential and
its current is the current the port puts into the wires at the node, on every
family that serves it. Nothing in the model carries that current back, so a
junction port on its own is not a one-port in the circuit sense: its
`Re(Y)` is not a radiation conductance and can be negative. An L of two
wires driven at its corner reads −26.9 − 342.3j Ω on both `BSplineSolver` and
`SinusoidalGalerkinSolver` (momwire#1165), and that is not a sign error.
Junction ports are for a network that closes the current: an element between
nodes A and B sees `Z_AA + Z_BB − Z_AB − Z_BA` of `Z = inv(sol.y)`. That
impedance is passive, its input power is the radiated power, and across two
facing wire ends it tends to a delta gap's as the gap closes.

```python
sol = solver.compute_port_solution()  # one fill, one factorisation
sol.y  # (n_ports, n_ports) short-circuit Y
sol.coeffs  # (n_dof, n_ports) — column j is the
#   solution for 1 V at port j
sol.port_currents  # the same matrix as `y`, asserted
```

`compute_y_matrix()` is `compute_port_solution().y`, so the two cannot drift.
Any other excitation is `coeffs @ V` with no second fill — that is the point
of the class. To turn a column into currents on the structure, use the
solver's `currents_at_knots(coeffs[:, j])`, or `element_currents(coeffs[:, j],
subdiv=…)` for the `(mid, moment, nodes, delta)` source terms a field
evaluator wants. `PortSolution.basis` is an opaque per-solve handle — do not
introspect it.

## Decks

`momwire.deck` reads a NEC-2 deck and puts it on a solver. The dialect —
which cards run, which are refused and in exactly what words — is specified
at
**[momwire.dev/reference/deck-grammar-nec2/](https://momwire.dev/reference/deck-grammar-nec2/)**;
that page is normative, and the code is tested against its anchors.

```python
from momwire.deck import build_solver, parse

deck = """CM 20 m dipole, 10 m up over average ground
CE
GW 1 21 -5.05 0. 10. 5.05 0. 10. 1.E-3
GE 1
GN 2 0 0 0 13. 0.005
EX 0 1 11 0 1. 0.
FR 0 1 0 0 14.1
XQ
EN
"""
built = build_solver(parse(deck), basis="bspline")
y = built.solver.compute_port_solution().y
port = built.ports.feed_ports[0]  # the solver row this EX card drives
print(f"Z = {1.0 / y[port, port]:.1f} ohm")  # Z = 67.0-41.1j ohm
```

`parse()` returns a dialect-neutral `DeckModel`; `build_solver()` maps it onto
one of the seven `BASES` names (five solver families — `"bspline"` is the
default, the degree-2 B-spline) and returns the solver together with a
`PortPlan`. The plan is what makes the solver's ports readable: which row is
which `EX` or `LD` card, each load's `LoadSpec`, and one drive vector per
execute group over a port set that never changes. Stamping a load impedance
is port algebra and stays with the consumer — `momwire.deck` puts the gap in
the matrix and hands over the spec.

A deck's **execute groups** are what the model says about running it: one per
execute card, each carrying its own frequency list, kernel flag and
`Environment` (the ground, its plane and a cliff's second medium). A `GN` card
arms, so a deck may run once in free space and once over ground and each group
says which; `build_solver(model, group=k)` builds over group `k`'s, and
`frequency_mhz=`, `extended_kernel=` and `environment=` override it.

Only the operating point moves between those calls, never the geometry, so a
swept caller translates once:

```python
from momwire.deck import prepare_mesh

mesh = prepare_mesh(model)  # the polylines, the port plan
solvers = [build_solver(model, mesh=mesh, frequency_mhz=f) for f in sweep]
```

Every solver built from one handle is given the same coordinate arrays, so a
prepared solve is bit-equal to an unprepared one.

## SimNEC portal

Installing momwire puts **`momwire-nec2c`** on your path — a resident NEC
engine speaking the protocol [SimNEC](https://ae6ty.com/smith_charts/) uses
to drive `nec2c`, with momwire's solver behind it. Point SimNEC's NEC portal
dialog at that command and its Smith chart, tuner and sweeps run on momwire.
`python -m momwire.portal` is the long spelling, and `--selftest` is the
deployment smoke.

```bash
momwire-nec2c -version                            # NEC2momwire.<major>.<minor>
momwire-nec2c --selftest                          # PASS / FAIL, no checkout needed
momwire-nec2c --basis sinusoidal < dipole.nec     # or run a deck by hand
```

SimNEC's **NEC-5** slot takes the `momwire-nec5[-<basis>]` commands instead
(`momwire-nec5-razor-2p` is NEC-5's own formulation): SimNEC picks the deck
syntax from `nec2c` or `nec5` in the engine's path.

Setup, the two filename rules SimNEC enforces, `--basis`, the caching flags
and what refusals look like:
**[momwire.dev/reference/portal-usage/](https://momwire.dev/reference/portal-usage/)**.

`momwire.portal` may use the solver API and `momwire.deck`; nothing else in
momwire may import from it. The SimNEC protocol is the portal's business
alone, and a test enforces that.

## Public names

Everything importable from `momwire` itself. `__all__` is the source of
truth — `tests/test_public_surface_954.py` fails if this list and `__all__`
disagree in either direction, so a name promoted without a line here (or a
line here for a name that was never exported) is a red test, not a stale doc.

Solvers — see [Solvers](#solvers) above for what distinguishes them:

- `BSplineSolver` — degree-d Galerkin, the default.
- `HMatrixSolver`, `ArrayBlockSolver` — structural accelerators over it.
- `SinusoidalSolver`, `SinusoidalGalerkinSolver` — NEC2's three-term basis.
- `RazorSolver` — the NEC-5 formulation twin, over every ground, buried and
  crossing decks included.
- `PulseSolver`, `HarringtonSolver` — the textbook pair.

Results and control:

- `PortSolution` — a solved port network's currents, voltages and Z.
- `DistributedRLC` — a wire's per-metre series or parallel RLC (NEC's `LD 2`
  / `LD 3`), the `distributed_rlc` kwarg every loading family takes beside
  `wire_conductivity` and the insulation pair.
- `FeedPlacement` — where a solver put a feed (or a Razor lumped load): the
  arclength asked for, the one used, and the offset between them. Returned
  by every solver's `feed_placements()` (momwire#1059).
- `Capabilities` — what a solver class declares it spans.
- `CancelToken`, `SolveAborted` — cooperative cancellation for a long solve.
- `LatticeFFTUnavailable` — raised when a lattice deck cannot take the FFT path.
- `accelerated` — True iff the C++ accelerator loaded. Assert it rather than
  discovering a silent fall-back to pure Python by its runtime.
- `accelerator_variant` — which build of it loaded: `"avx2"`, `"sse2"`,
  `"legacy"` (the single unsuffixed extension macOS and non-x86 still ship),
  or `None` when none did. On x86 the wheel carries the same kernels compiled
  twice and picks by CPU feature *before* importing either, because an
  AVX2 binary on an older CPU does not raise ImportError — it kills the
  interpreter with an illegal instruction (momwire#1032).

Wire material, for a consumer mirroring the loading into another tool:

- `wire_internal_impedance`, `insulation_inductance` — the per-metre quantities.
- `equivalent_radius` — the coated-wire pair's effective radius.

Answers a consumer must give IDENTICALLY to momwire, exported so it cannot
answer them differently:

- `ground_touch_tol` — is this wire end on the plane (a per-wire *relative*
  tolerance; an invented absolute one disagrees at the margin).
- `grounded_crossing_exemption` — does this in-plane junction earn the
  crossing exemption.
- `below_reach_refusal` — how far a buried structure may span before the
  below/below remainder stops being tabulated.
- `wire_to_element` — the geometry conversion the array-block path uses.
- `SURFACE_HEIGHT_CLASS` — the low-stand-off class's measured conditioning,
  including the validity floor a consumer refuses against.

Capability axes:

- `axes_for` — every axis of one capability row, declared union derived. The
  single derivation point: a consumer re-deriving `ground_model` from
  `grounds`, or `wire_position` from `buried`/`contact`, is the drift this
  exists to prevent.
- `AXIS_VALUES` — the declared vocabulary.
- `DERIVED_AXES` — which axes are computed rather than declared.

## Install

```bash
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install -e .
```

**macOS:** the C++ accelerator uses Homebrew's OpenMP runtime, so `brew install
libomp` is required — both to compile from source and to run the prebuilt
Apple-Silicon wheel. The wheel deliberately does **not** bundle `libomp` (it links
Homebrew's by absolute path) so that it shares a single OpenMP runtime with
pynec-accel; two private copies in one process abort with `OMP: Error #15` (or
deadlock). Without `libomp` installed, the accelerator can't load and momwire
warns and falls back to the slower pure-Python path. On Linux the system
`libgomp` covers this, so no extra step is needed.

### The NEC-5 engines on Linux, without Python

Each release also carries **`momwire-nec5-linux-x86_64.tar.gz`**: two of
momwire's NEC-5 engines as native programs, with no Python in them. They are
razor-2p (`momwire-nec5-razor-2p`) and bs2, the default B-spline formulation
(`momwire-nec5-bspline`). You do not need Python, pip or momwire installed.
Their printouts are byte for byte the Python commands'.

```bash
tar -xzf momwire-nec5-linux-x86_64.tar.gz
cd momwire-nec5-linux-x86_64
./momwire-nec5-razor2p-linux -version
./momwire-nec5-razor2p-linux deck.nec deck.out   # EZNEC's and SimNEC's form
./momwire-nec5-razor2p-linux - - < chain.nec     # SimNEC's streaming form
./momwire-nec5-bs2-linux deck.nec deck.out       # the same forms, bs2
```

For SimNEC, point the NEC-5 engine setting at `momwire-nec5-razor2p-linux`
or `momwire-nec5-bs2-linux` in that folder, and keep the folder together:
each command runs its `-avx2` build on a processor with AVX2 and FMA, and its
`-sse2` build on any other 64-bit x86 processor. They need 64-bit x86 Linux
with glibc 2.28 or newer (RHEL/Alma 8, Debian 10, Ubuntu 18.10 and later);
everything else is built in. A deck an engine does not serve yet is refused
in the printout by name ("not served by this build: ..."), and the Python
command serves it. The other formulations stay Python. The existing Windows
`momwire-nec5-razor-2p.exe` in the EZNEC drop-in zip is unchanged.

### The NEC-5 engines on Windows, without Python (preview)

**Preview:** each release from 0.79.0 also carries
**`momwire-nec5-windows-x64.zip`**, the same two engines as native Windows
programs, signed with momwire's certificate. The zip holds
`momwire-nec5-razor2p-avx2.exe` and `momwire-nec5-bs2-avx2.exe` for a
processor with AVX2, the `-sse2` pair for any other 64-bit x86 processor, and
a `win7` folder with the sse2 pair built to run on Windows 7 SP1. The zip's
README.txt says which to use. The DLLs they load ship beside them, so nothing
needs installing; keep each folder together. A deck an engine does not serve
yet is refused in the printout by name, as on Linux. The EZNEC drop-in,
`momwire-eznec-windows.zip`, is unchanged and is still the supported way to
run momwire under EZNEC on Windows.

### The NEC-5 engines on macOS, without Python (preview)

**Preview:** each release from 0.79.0 also carries the same two engines for
Apple silicon (macOS 14 or later, arm64), as
**`momwire-nec5-macos-arm64.tar.gz`**. The
math library is the system's Accelerate, the one numpy and SciPy use on a
Mac, and the OpenMP runtime is built in, so nothing ships beside the two
programs. Their printouts match the Python commands' on the same Mac.

They are ad-hoc signed only: no Developer ID and no notarisation. A tarball
fetched with `curl` and unpacked with `tar` carries no quarantine flag and
runs as it is:

```bash
curl -LO <URL of momwire-nec5-macos-arm64.tar.gz>
tar -xzf momwire-nec5-macos-arm64.tar.gz
cd momwire-nec5-macos-arm64
./momwire-nec5-razor2p-macos -version
./momwire-nec5-bs2-macos deck.nec deck.out
```

A copy downloaded through a web browser is quarantined, and macOS refuses to
open it until the flag is cleared from the whole folder:

```bash
xattr -dr com.apple.quarantine momwire-nec5-macos-arm64
```

### The same answer with or without AVX-512

numpy and OpenBLAS switch to AVX-512 code on a processor that has it, and
that code rounds differently in the last bits. momwire's own entry points
(the `momwire-nec5`/`momwire-nec42`/`momwire-nec2c-shared` commands and the
servers they start, the Windows drop-in, the Linux razor-2p program and the
test suite) turn it off on such a processor, by setting
`NPY_DISABLE_CPU_FEATURES` and `OPENBLAS_CORETYPE=Haswell` before numpy
loads, so the printout doesn't change with the processor. A value you set
yourself is left alone, and on a processor without AVX-512 nothing is set.
This doesn't cover everything. `momwire-nec2c`, `python -m momwire...` run by
hand, a resident server one of those started, and a program that imports
numpy before momwire have all loaded numpy before the pin. For those, call
`momwire_serve_client.pin_cpu_env()` before importing numpy, or set the two
variables in the environment. The Windows razor-2p hybrid's own OpenBLAS DLL
picks its kernels before any code can pin it. Even pinned, a value that
cancels to almost nothing can still print differently in its last digits.

## Test

```bash
pip install -e ".[test]"   # core + test deps, and what `make build` needs (pybind11, setuptools, wheel)
pytest tests/
```

The cross-validation against NEC2 (`tests/test_pynec_backend.py`) additionally
needs PyNEC — a test-only dependency installed separately from a wheel (see
below). Those tests skip cleanly when it isn't present; everything else runs
without it.

## Optional: PyNEC backend (test-only)

momwire can be cross-validated against NEC2 via [PyNEC](https://github.com/tmolteno/python-necpp) (the `tests/test_pynec_backend.py` suite); NEC2 also delivers ~5–10× faster single-frequency solves. PyNEC is a **test-flow-only** dependency — momwire's own solver never imports it.

### Install the PyNEC wheel

Install PyNEC from the [python-necpp fork](https://github.com/stevenmburns/python-necpp)'s release. The distribution is named `pynec-accel` (the import name stays `PyNEC`); the wheels are self-contained — OpenBLAS is vendored (via scipy-openblas32), so no system BLAS, SWIG, or build toolchain is needed — and cover Linux, Windows, and macOS (arm64) on CPython 3.10–3.14:

```bash
pip install pynec-accel --no-index \
    --find-links https://github.com/stevenmburns/python-necpp/releases/expanded_assets/v1.7.6
```

`--no-index` ensures pip takes the fork's wheel rather than upstream PyNEC on PyPI (which is broken on current Python and lacks the OpenBLAS/OpenMP work). On macOS the wheel shares Homebrew's libomp (`brew install libomp`) rather than vendoring its own, so it can coexist with momwire's accelerator in one process. After install, `from PyNEC import nec_context` works and the cross-validation tests run; without it they're skipped (momwire itself needs no PyNEC).

### Runtime thread pinning

The wheel links OpenBLAS and parallelises the NEC2 matrix fill with OpenMP. Pick thread counts up front:

```bash
export OMP_NUM_THREADS=$(nproc --all)   # PyNEC matrix fill
export OPENBLAS_NUM_THREADS=1           # muzzle numpy/scipy's idle pool
```

Pinning `OPENBLAS_NUM_THREADS=1` stops numpy/scipy from spinning up their own OpenBLAS thread pool that contends with PyNEC's threads on the same cores. On a 100-director Yagi (2142 segs) this is worth ~8% wall time at NP=4.
