The NESTOR vacuum solve¶
Free-boundary VMEX couples the plasma iteration to Merkel’s Green’s-function
vacuum solve (NESTOR, J. Comp. Phys. 66, 83 (1986)), ported from VMEC2000’s
vacuum.f pipeline with the same activation cadence. This page explains
the exterior Neumann problem, the full-vs-incremental update split, and
which parts of the free-boundary problem are differentiated; the run recipe
is Run a free-boundary equilibrium.
The exterior Neumann problem¶
For LFREEB = T decks, vmex.core.vacuum implements Merkel’s
Green’s-function method. In the vacuum region the field is curl-free, so it
is written as
with \(\mathbf{B}_{\mathrm{ext}}\) the field of the external coils (mgrid or Biot–Savart) plus the net-toroidal-current filament, and the plasma boundary acting as a flux surface:
Green’s second identity turns this exterior Neumann problem into a boundary integral equation for the surface potential,
which, after expanding \(\Phi\) in Fourier harmonics
\(\sin(mu - nv)/\cos(mu - nv)\) on the boundary, becomes a dense
mnpd2 x mnpd2 linear system for the potential coefficients potvac.
The \(|\mathbf{x}-\mathbf{x}'| \to 0\) singularity of \(G\) is split
off and integrated analytically (analyt.f, the cmns coefficient
tables); the regular remainder is tabulated on the angular grid (greenf /
fourp). Implementation: geometry-independent tables in
vacuum_basis(), the jitted full/incremental
solves in make_vacuum_solver(), and the surface
field \(B_u = \mathrm{bexu} + \partial_u\Phi\) (etc.) with
\(\mathrm{bsqvac} = |B_{\mathrm{vac}}|^2/2\) in
vacuum_channels().
Coupling cadence (funct3d.f)¶
vmex.core.freeboundary.solve_free_boundary() drives the coupling
with the VMEC2000 cadence:
the vacuum solve activates once \(\mathrm{fsqr}+\mathrm{fsqz} \le 10^{-3}\);
a full NESTOR solve runs when
mod(iter2 - iter1, nvacskip) == 0, factoring the dense potential matrix once; cheaper incremental updates reuse that LU factor (VMEC2000’sDGETRF/DGETRSsplit) while only rebuilding the analytic right-hand side, and the cadence adapts as\[\mathrm{nvacskip} \leftarrow \max\!\left(\mathrm{nvskip}_0,\; \frac{1}{\max(0.1,\; 10^{11}\,(\mathrm{fsqr}+\mathrm{fsqz}))}\right);\]the vacuum pressure enters the edge force through
rbsq = (bsqvac + presf_ns) * R(edge) / hsatjs = ns, and the constraint reference surfacesrcon0, zcon0ramp by 0.9 per iteration.
The multigrid form of this coupling — carried vacuum state, per-stage NESTOR rebuilds, one activation across the ladder — is described in The multigrid ladder.
External fields¶
The forward NESTOR solver consumes a MgridField.
It may be loaded from an mgrid file (trilinear interpolation weighted by
EXTCUR) or built once with
from_cartesian_field(), which tabulates an
ESSOS/SIMSOPT Biot–Savart object or any xyz -> B callable. The resulting
table and its current scale remain JAX-differentiable; tabulation itself does
not retain coil-geometry derivatives. For a coupled solve,
from_parameterized_cartesian_field() instead
tabulates field(coil_parameters, xyz) entirely in JAX, retaining exact
shape/current derivatives through interpolation and NESTOR. Direct,
interpolation-free ESSOS derivatives use the virtual-casing residual below.
VMEX carries no coil code.
On a GPU free-boundary run, the plasma iteration, mgrid interpolation, cached vacuum arrays, and final state remain on the accelerator. The dense NESTOR assembly/factor/solve is explicitly placed on CPU and its small boundary inputs/outputs are bridged inside the jitted cadence loop. This follows the VMEC++ accelerator decomposition and avoids the alternate LASYM branch seen with accelerator dense linear algebra. An explicitly requested GPU LASYM multigrid ladder therefore seeds only its coarsest rung on CPU, then transfers the converged branch to all finer GPU rungs.
What is (and is not) differentiated¶
The NESTOR iteration above is a host-driven fixed point and is not
differentiated. For coil/current optimization,
vmex.core.virtual_casing instead expresses the interface conditions as
smooth objectives on a prescribed boundary. At the plasma-vacuum
interface the total exterior field
\(\mathbf{B}_{\mathrm{out}} = \mathbf{B}_{\mathrm{coil}} +
\mathbf{B}_{\mathrm{plasma}}\) must be tangent, and pressure balance holds:
The plasma’s own exterior field comes from the virtual-casing principle.
In the BIEST convention, its layer densities on \(\partial\Omega\) are
\(\sigma=\mathbf{B}\cdot\mathbf{n}\) and
\(\mathbf{J}=\mathbf{B}\times\mathbf{n}\). The exterior field of the
enclosed plasma currents is the internal branch
\(-\nabla G[\sigma]-\mathrm{BiotSavart}[\mathbf{J}]\), evaluated with an
accurate singular quadrature (reused from the optional
virtual_casing_jax package,
required as virtual-casing-jax >= 0.0.5 from the canonical
uwplasma/virtual_casing_jax repository;
surface_field_data_from_wout()
adapts a converged boundary + field, and
plasma_field_on_boundary() evaluates
the integral). The key structural fact: for a fixed trial boundary,
\(\mathbf{B}_{\mathrm{plasma}}\) on that boundary does not depend on the
coil degrees of freedom, so it is precomputed once and frozen. The residual
assembled by
PlasmaVacuumInterface is then a
smooth JAX function of the external-field dofs alone (coil Fourier
coefficients/currents of a callable ESSOS coil field via
external_B_cartesian(), or
extcur), so jax.value_and_grad returns gradients validated against
finite differences — no NESTOR adjoint is required.
The finite-beta single-stage example uses a pressure profile that vanishes at the LCFS. It therefore needs no prescribed physical sheet current in the jump condition; nonzero edge pressure or an imposed sheet current requires an additional interface model.
Despite using the interface equations, that example is a fixed-boundary
optimization: every trial boundary is prescribed to VMEX and reconverged, and
both boundary and coil coefficients are decision variables. Virtual casing
separates the converged total VMEX field into plasma-current and external-coil
parts; it does not run NESTOR or a free-boundary equilibrium. The preview
single_stage_free_boundary_optimization*.py examples instead hold the
plasma boundary implicit and vary only coil parameters through the coupled
NESTOR derivative below. They need ESSOS with
uwplasma/ESSOS#58 (commit 1b3210ca, not on PyPI).
The reported normalized total-pressure jump is
It is dimensionless and vanishes when the ideal-MHD pressure-balance
condition holds. It is not an error in the prescribed volume pressure
profile. Even when \(p_{\rm edge}=0\), it supplies the tangential-field
magnitude condition that B.n/B alone does not constrain.
Field-query API¶
MagneticField provides stored Cartesian points,
B, absB, and spatial derivatives through gradgradgradB. A field
constructed from exterior_field() also
provides B_vjp and the three spatial-derivative VJPs in the problem’s
boundary/current DOFs. The virtual-casing path applies outside the LCFS;
VmecInteriorField evaluates the live VMEC
spectral field inside. Direct off-surface quadrature must stay away from the
source surface and all targets must stay away from external coil filaments.
For near-LCFS field-line tracing,
with_near_surface_continuation()
prepares the singular on-surface plasma field and gradient once, then uses the
first-order continuation
\(\mathbf B(\mathbf x_\Gamma+\delta\mathbf x)=\mathbf B_\Gamma+
\nabla\mathbf B_\Gamma\delta\mathbf x+O(|\delta\mathbf x|^2)\). This removes
the otherwise prohibitive source-grid refinement from every ODE step; direct
quadrature remains the validation path farther from the LCFS.
Virtual casing reconstructs the field produced by currents inside the plasma
surface. It does not determine the external coil field: supply an ESSOS coil
field or MGRID field and VmecExtender adds the two.
This distinction matters for finite-beta exterior tracing and coil design.
vmex_get_B_gradB.py demonstrates the stable interior API. The exterior
field and tracing previews need ESSOS with uwplasma/ESSOS#58 (commit 1b3210ca). The
single-stage previews write initial and optimized surface/coil VTK files;
setting
MAKE_MOVIE=True adds a compact animation of accepted iterates. Set the
examples’ MOVIE_SURFACE_COLOR to None, "absB", "B.n/B", or
a scalar-field callable to control boundary coloring without storing VTK data
for every iteration.
Accuracy outside the surface¶
The direct path (B() without a
continuation plan) evaluates the virtual-casing integrals with the periodic
trapezoid rule on a fixed schedule of source grids. At a target a distance
\(d\) from the surface its error behaves as \(e^{-2\pi d/h}\), up to a
weak algebraic factor, where \(h\) is the largest source spacing of the
finest schedule level. Schedule levels count points over the full torus:
the default levels of from_wout, from_state and exterior_field
is ((nphi, ntheta), (2 nphi, 2 ntheta)), so the finest level has
2 nphi toroidal points on the whole torus and a toroidal spacing
\(h = 2\pi R/(2\,\mathrm{nphi})\) whatever nfp is (for nfp = 2
this equals \(2\pi R/(\mathrm{nfp}\cdot\mathrm{nphi})\)). Keep
\(d \gtrsim 2h\): one spacing gives about three digits, two spacings about
four. The default nphi = ntheta = 32 on a QA configuration with
\(R \approx 1\) m has \(h \approx 0.1\) m, about 0.6 minor radii.
The requested digits does not bound the returned error. The schedule
stops, target by target, at the first level whose double-layer self-test
passes, and that test can pass for a target whose field is still wrong. On the
vacuum deck input.LandremanPaul2021_QA_lowres (ctor of order
\(10^{-11}\) A, so the exact plasma field outside is zero) the returned
field has these median | maximum errors relative to volavgB, for 40
targets along the outward normal, digits = 4, versus distance in minor
radii \(a\) and per-period source grid nphi = ntheta = N:
N |
d = a |
0.5 a |
0.2 a |
0.1 a |
0.05 a |
0.02 a |
|---|---|---|---|---|---|---|
32 (default) |
3e-5 | 3e-3 |
6e-3 | 1.3e-2 |
0.12 | 0.18 |
0.31 | 0.56 |
0.45 | 1.3 |
0.55 | 2.7 |
64 |
2e-5 | 3e-4 |
4e-5 | 3e-4 |
1.8e-2 | 3e-2 |
0.13 | 0.17 |
0.30 | 0.62 |
0.49 | 1.9 |
Each doubling of the grid moves a given error level about twice as close to the
surface, so no affordable grid reaches \(10^{-4}\) within 0.1 a. The
known-answer tests in tests/test_virtual_casing_physics.py check the
identities on a circular torus carrying an outside z-axis current and an
inside axis filament: the internal branch returns the filament field outside
and on the surface and minus the z-axis field inside, to \(10^{-4}\) at
three finest-level spacings.
Eager B() calls therefore check an
estimate of the returned error,
B_error_estimate()
(offsurface_error_estimate()). It reproduces
the schedule’s choice of level and reports, per point, the difference between
the returned value and the finest level when the schedule stopped early, and
otherwise the larger of the finest level’s double-layer error and the square
of the relative change between the last two levels (halving the spacing
squares the trapezoid error factor). Errors are relative to the RMS of
\(|B|\) on the surface. When any point exceeds \(10^{-\mathrm{digits}}\),
B emits ExteriorFieldAccuracyWarning, or raises
ExteriorFieldAccuracyError with
accuracy_check="raise"; accuracy_check="off" skips the estimate. The
returned field is identical in every mode. Traced calls (jit, grad,
field-line integration) never check and can call the estimate directly. It
costs 1.2 to 1.5 times the plasma-field evaluation it checks.
On 816 targets at digits = 4 (the vacuum deck above at N = 32 and 64 over
six distances, and the torus oracle on two schedules from 0.25 to 4 finest
spacings) no target that passed the estimate had an error above
\(1.5\times10^{-4}\), no target with an error below \(3\times10^{-5}\)
was flagged, and the error was within 1.7 times the estimate for nine targets
in ten and within 21 times for 99 in 100. The schedule’s own self-test passed
11 of the same targets with errors above \(3\times10^{-4}\), one of them
at 0.30. The estimate does not see truncation of the source data on the finest
grid itself: at d = a with N = 64 the error reached 26 times the estimate for
one target in ten, while staying below \(3\times10^{-4}\).
Use the direct path above about 0.5 a with N >= 64. Below about 0.2 a use
with_near_surface_continuation(): on
the 2.5 % beta QA deck with a 32 x 32 grid it was within 0.1–0.2 % of
\(|B|\) at 0.1–0.2 a against a 256 x 256 direct reference, where the
direct default was 12–31 % off, but its first-order continuation is worse
than the direct path at 0.5 a (0.7–2 %) and the plan took about one to one
and a half minutes to build on one laptop CPU. Between the two, check the
estimate. These timings and errors are from a single review measurement on
2026-09-13, not a committed benchmark record.
Coupled free-boundary adjoint¶
Let \(F(z,c)=0\) be the converged VMEC force residual after NESTOR has computed the vacuum pressure on the moving edge, with equilibrium state \(z\) and coil parameters \(c\). For a scalar objective \(J\), VMEX solves
solve_free_boundary_implicit() keeps
the host-driven forward iterations off the AD tape, re-evaluates the complete
VMEC–NESTOR residual at the converged state, and solves this transpose system
with one matrix-free GCROT adjoint. A direct ESSOS BiotSavart.b_cyl field
retains coil shape/current derivatives without writing an mgrid file.
The public construction is explicit: create
make_free_boundary_config(), map the
coil vector to a field with field_from_parameters, call the implicit solve,
stack physics rows with vmex.core.optimize.residuals_from_tuples(), and
apply jax.value_and_grad. take_free_boundary_gradients.py checks one
direction against independent re-solves. The free-boundary single-stage
previews pass the same scalar pair to SciPy. These examples need the unreleased
ESSOS commit 1b3210ca (uwplasma/ESSOS#58).
This path is currently limited to reverse mode. Its low-memory host Krylov
lane peaks near 3–5 GB on the bundled coarse examples, but the first coupled
transpose still takes about one to two minutes to compile on the reference
CPU and is not yet a practical GPU path. Its device="auto" policy therefore
uses the CPU on an accelerator host unless the process already pins JAX
placement, while retaining an explicit per-call GPU override.
adjoint_solver="boundary_schur" enables the boundary-Schur transpose. It
differentiates one three-surface force row at a time, retains every terminal
radial stencil coupling in the bulk, isolates the one evolved edge row that
contains NESTOR’s response, and eliminates the radial bulk with a
two-sided-equilibrated, globally pivoted
sparse LU. Reverse-mode row differentiation is used because each local
Jacobian has three times more inputs than outputs. The reduced transpose is
solved and back-substituted, then checked against the original coupled
residual; a failed certificate continues with coupled Krylov from the Schur
answer. No dense full-state Jacobian is formed.
The reduced lane is not yet the default. Direct local-row assembly removes the full radial basis sweep, the pivoted band solve removes the inaccurate no-pivot elimination, and the exact one-row interface avoids redundant NESTOR pullbacks. Local-force and vacuum-response compilation remain the cold-cost targets. The next measured step is to cache accepted-state local executables and batch the NESTOR edge pullbacks on GPU. Promotion requires lower cold time and memory on the bundled 3-D case while retaining the re-solve finite-difference, CPU/GPU, and fixed/free field certificates. Timings belong in the resource harness, not committed JSON files.