Algorithms¶
This page documents the numerical algorithms of the vmex solver and
how they map onto their VMEC2000 counterparts. The module-by-module map is in
Architecture; the underlying equations are derived in
Equations and derivations.
Discretization summary¶
Radial grid¶
A uniform grid in \(s \in [0,1]\) with ns points:
Following VMEC2000, quantities live on a mix of the full mesh (\(s_j\))
and the half mesh (\(s_{j-1/2}\)): geometry derivatives, the Jacobian,
and |B|-type quantities are half-mesh; R/Z coefficients and iotaf are
full-mesh. Odd-m coefficients are stored internally with the axis-regular
\(\sqrt{s}\) factor removed (scalxc), and R/Z and \(\lambda\)
evolution starts from the m-dependent jmin2/jlam radial indices
(vmec_params.f). These conventions are implemented in
vmex.core.geometry and vmex.core.setup.
Angular grids and Fourier transforms¶
Uniform tensor-product grids in \(\theta\) and \(\zeta\) (one field
period) with VMEC’s symmetry-reduced theta extent. VMEC does not use a
plain FFT: the analysis transform (tomnsps) is a two-stage weighted DFT
with endpoint half-weights and mscale/nscale normalization baked into
precomputed trig tables (fixaray.f):
and analogously for the sine tables. Derivative terms use cosnvn/sinnvn,
which already include the field-period scaling \(n\,\mathrm{NFP}\). The
tables are built in vmex.core.fourier and the transforms
(totzsps/totzspa synthesis, tomnsps/tomnspa analysis) are batched
dot_general matmuls in vmex.core.transforms — GEMM-friendly
and XLA-fusable while matching VMEC2000 normalization exactly.
Geometry pipeline¶
Per iteration (vmex.core.geometry, VMEC2000 jacobian.f):
synthesize \((R,Z,\lambda)\) and their angular derivatives on the
(s,\theta,\zeta)grid from the spectral state;form radial derivatives on the half mesh;
compute the half-mesh Jacobian \(\sqrt{g}\) and the metric elements
guu, guv, gvv;evaluate the Jacobian sign proxy \(\tau\); a sign change away from the axis flags a bad Jacobian (
irst = 2).
The iteration loop¶
VMEC solves the fixed-boundary equilibrium by evolving the stacked Fourier coefficients \(\mathbf{x}\) of \((R,Z,\lambda)\) with a preconditioned, damped second-order Richardson iteration:
where \(\mathbf{r}\) is the spectral force residual and the damping
is averaged over the last ndamp = 10 steps (evolve.f). This is
implemented in vmex.core.step:
damping_coefficients() advances the ndamp
window and returns the (b1, fac) pair,
momentum_update() applies the velocity/position
update, and the traced controller scalars (delt, damping history,
best-residual trackers, iter1, ijacob) live in
StepControl.
Convergence is declared when the physical residuals satisfy
fsqr, fsqz, fsql <= ftolv simultaneously; the residual norms and the m=1
constraint rotation follow residue.f90 (vmex.core.residuals).
Restart control (restart.f)¶
The loop keeps a checkpoint of the best state and applies VMEC2000’s exact
back-off rules (restart_decision() classifies the
step as STEP_OK/RESTART_JACOBIAN/RESTART_GROWTH;
apply_restart() restores the checkpoint, zeroes
the velocity and rescales delt):
bad Jacobian (
irst = 2): restore the checkpoint, zero the velocity,delt *= 0.90; on the first bad Jacobian the axis guess is recomputed (guess_axis), anddeltis reset atijacob = 25, 50with a hard stop at 75 (jac75_flag);residual blow-up (
irst = 3): if after more than 10 steps the residual exceeds \(10^4\times\) the checkpoint value, restore anddelt /= 1.03.
1D radial preconditioner (precondn.f, scalfor.f)¶
The 1D preconditioner approximates the diagonal (in (m,n)) of the
linearized radial force operator: for each spectral column the R/Z force is
replaced by the solution of a radial tridiagonal system
whose coefficients are flux-surface integrals over the half mesh
(precondn.f, precondn()) of
\(p_\tau = -4\,r_{12}^2\,\mathrm{bsq}\,w/\sqrt{g}\)-type quantities: the
poloidal-derivative couplings give \(a_x\), the radial-derivative
couplings \(b_x\), and
\(c_x = \langle \tfrac14 p_{\mathrm{factor}} (B^v)^2 \sqrt{g}\rangle\)
the toroidal couplings, each with even-m and odd-m columns (the odd column
carries the internal \(\sqrt{s}\) scalings). Assembly of the per-mode
system with the \(m^2\) and \((n\,\mathrm{NFP})^2\) weights, the
edge_pedestal = 0.05 and ZC(0,0)(ns) fac = 0.25 stabilizations of
scalfor.f, and the jmin axis-row rules is
scalfor_matrices(); the application is
scalfor(). The solve is a Thomas
algorithm vectorized over all spectral columns simultaneously
(vmex.core.preconditioner.tridiagonal_solve(), a thin arg-order
adapter over solvax.tridiagonal_solve — the shared SOLVAX linear-solver
package). \(\lambda\) uses the diagonal faclam factors from
lamcal.f90 (lamcal()):
with \(b_\lambda = \langle g_{uu}/\sqrt{g}\rangle\), \(c_\lambda = \langle g_{vv}/\sqrt{g}\rangle\), \(d_\lambda = \langle g_{uv}/\sqrt{g}\rangle\) (the \(\sqrt{s}\) damping only bites for \(m > 16\)).
Preconditioner matrices, force norms, and the constraint multiplier tcon
are recomputed every ns4 = 25 iterations and reused in between — this
cadence is parity-critical and is mirrored exactly.
2D block preconditioner (precon2d.f)¶
For stiff cases (high beta, high aspect ratio, high mode number) VMEC2000
optionally switches to its 2D preconditioner: a Newton step on the
1D-preconditioned force. Let \(g(\mathbf{x})\) be the 1D-preconditioned
spectral force map (with the ns4 cache frozen, so the 1D operator is a
fixed linear map during the Newton solve). The update direction solves
Because the invertible 1D operator \(M_{\mathrm{1D}}^{-1}\) is baked into both \(J\) and the right-hand side, it cancels exactly: \(\delta\) is the same full Newton step \(-(\partial F/\partial\mathbf{x})^{-1} F\) on the raw force — the 1D preconditioner only conditions the linear solve (near equilibrium \(M_{\mathrm{1D}}\) approximates \(\partial F/\partial \mathbf{x}\), so \(J \approx I\)).
VMEC2000 (Sources/Hessian/precon2d.f) builds \(J\) explicitly by
finite-difference “jogs” of every spectral column and LU-factors it with
BCYCLIC. In vmex.core.preconditioner_2d the force map is
traceable, so \(J v\) is an exact Hessian-vector product from one
jax.jvp (flat_operator()) — no
jogs, no assembled blocks — and the system is solved with matrix-free
restarted GMRES from SOLVAX (solvax.gmres) in
newton_direction(). A loose GMRES
tolerance yields an inexact Newton step; peak memory stays at one force
graph. Activation mirrors evolve.f
(Prec2DConfig): finest grid only,
iter2 >= 10, and fsqr + fsqz + fsql < prec2d_threshold; the wiring in
vmex.core.solver swaps the Newton direction for the 1D force
direction under a lax.cond, leaving the default 1D-only path untouched.
Spectral condensation (alias.f)¶
The constraint force that drives spectral condensation is assembled from
spectrally filtered to \(m \in [1, \mathrm{mpol}-2]\) with the
faccon(m) weights, and scaled per surface by
(bcovar.f). Implemented in vmex.core.forces (constraint force)
and vmex.core.fields (tcon).
Two execution lanes, one physics¶
vmex.core.solver exposes the same jitted iteration through two
lanes (selected by vmex --mode cli|jit):
CLI lane (default): a Python
whileloop around a jitted N-iteration block kernel, with residuals checked on the host between blocks. This gives exact-ftolearly exit, live VMEC2000-format printing everyNSTEPiterations, and buffer donation, with zero autodiff bookkeeping.JIT lane: a single
lax.while_loopover the same physics — fully traceable, used as the forward solver inside the differentiable API.
A regression test asserts per-block state agreement between the lanes to
machine precision. Which device (CPU or GPU) a lane runs on is decided by
the measured placement policy of vmex.core.device — see
Device policy (CPU/GPU).
Multigrid and hot restart (runvmec.f, interp.f)¶
vmex.core.multigrid.solve_multigrid() runs the NS_ARRAY
ladder: each stage solves at its ns with its own
FTOL_ARRAY/NITER_ARRAY entry, and the converged coefficients are
interpolated in \(\sqrt{s}\)-internal form to the next finer grid
(interp.f): scale by scalxc, extrapolate odd-m modes to the axis on
the scaled array, interpolate linearly in \(s\), unscale, and zero the
odd-m axis row (interpolate_coefficients() /
interpolate_state(); the equations are in
Equations and derivations). Mode and radial arrays are padded to the maximum
resolution so all stages share one compiled executable — the ladder pays a
single JIT compile. The same interpolation seam provides hot restart
(vmex.core.solver.hot_restart_state()): a previous solution (e.g.
the previous point of a parameter scan) can seed the solve directly, at the
same or a different radial resolution.
Free boundary (NESTOR)¶
For LFREEB = T decks, vmex.core.vacuum implements Merkel’s
Green’s-function method (NESTOR, J. Comp. Phys. 66, 83 (1986)). 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().
vmex.core.freeboundary.solve_free_boundary() drives the coupling
with the VMEC2000 cadence (funct3d.f):
the vacuum solve activates once \(\mathrm{fsqr}+\mathrm{fsqz} \le 10^{-3}\);
a full NESTOR solve runs when
mod(iter2 - iter1, nvacskip) == 0, with cheaper incremental updates in between, 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)atjs = ns, and the constraint reference surfacesrcon0, zcon0ramp by 0.9 per iteration.
The external field comes either from an mgrid file
(vmex.core.mgrid, trilinear interpolation weighted by EXTCUR)
or from any xyz -> B callable — e.g. an ESSOS essos.coils.Coils
Biot-Savart field (lambda pts: coils.B(pts)), interpolation-free and
differentiable through the coil parameters. vmex itself carries no coil
code; coils live in ESSOS.
Differentiable free boundary (virtual casing)¶
The NESTOR iteration above is a host-driven fixed point and is not
differentiated. For coil/current optimization,
vmex.core.freeboundary_diff instead expresses the free-boundary
condition as a smooth objective on a given 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:
the field produced outside \(\partial\Omega\) by the plasma currents
equals that of the surface current
\(\mathbf{K} = \mathbf{n}\times\mathbf{B}/\mu_0\) on
\(\partial\Omega\), evaluated with an accurate on-surface singular
quadrature (reused from virtual_casing_jax;
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
FreeBoundaryDiffProblem 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), and its value_and_grad_bnormal helper returns gradients
validated against finite differences — no NESTOR adjoint is required.
Implicit differentiation¶
Gradients of equilibrium properties are computed by implicit differentiation
of the converged fixed point (vmex.core.implicit). The equilibrium
is the root of the force residual \(F(x, p) = 0\) with \(x\) the
spectral state and \(p\) the parameters
(ImplicitParams: boundary coefficients,
profiles, phiedge, pres_scale, curtor). By the implicit function
theorem, if \(\partial F/\partial x\) is invertible at the root, the
solution map \(p \mapsto x^\star(p)\) is differentiable with
and for a scalar objective \(\mathcal{J}(x^\star(p))\) with cotangent \(\bar{g} = \partial\mathcal{J}/\partial x\), the reverse-mode (adjoint) form needs one linear solve regardless of the number of parameters:
solve_implicit() wraps this in
jax.custom_vjp:
forward: runs the fast CLI-lane host solver (
jax.pure_callback— multigrid staging, restarts, and adaptive time-step control stay invisible to autodiff; only the fixed point defines the derivative) and returns the converged \(x^\star\);backward: solves the adjoint system matrix-free with restarted SOLVAX GMRES (
adjoint_matvec()): onejax.vjplinearization of the residual function (residual_fn()) is reused as the transposed operator, and one more VJP contracts \(\lambda\) against \(\partial F/\partial p\).
The residual is chosen as the self-consistently 1D-preconditioned force
gc of a single fresh evaluate_forces() pass:
\(F = M(x,p)\,f(x,p)\) with \(f\) the raw spectral force and
\(M\) the invertible 1D-preconditioner map. At the root
\(dF = M\,df + dM\,f = M\,df\) up to \(O(\mathrm{ftol})\), so the
implicit gradients equal those of the raw force to solver accuracy — while
the adjoint GMRES inherits VMEC’s own preconditioning for free (this is the
“preconditioned adjoint”: near equilibrium \(\partial F/\partial x\)
is close to the identity, so GMRES converges in a handful of Krylov
iterations).
Why the gradient is cheap: reverse-mode through an unrolled iteration would store every iterate (memory linear in the iteration count) and backpropagate through thousands of steps. The implicit adjoint touches only the converged state: its cost is a fixed handful of residual evaluations (one linearization plus the GMRES matvecs) and its memory is O(1) in the iteration count — independent of how many Richardson steps, restarts, or multigrid stages the forward solve needed. Multigrid stages act purely as an initializer and are stop-gradient by construction. See Optimization and differentiability for usage and the references (Skene & Burns 2026; jaxopt; DESC) in References.
Gradient checking: solver-sensitive metrics and the frozen path¶
The adjoint returns the derivative of the fixed point of the frozen
residual \(F\): the preconditioner, the tcon constraint strength, the
converged m=1 Z-force branch, and the dof mask are all captured once at the
base parameters and held fixed, not re-derived as \(p\) moves. This is
exactly the object the implicit function theorem differentiates, and it has a
subtle consequence for how the gradient must be validated.
Equilibrium outputs fall into two classes:
Smooth bulk integrals — the magnetic energy
wb, the aspect ratio, the volume: volume averages of the converged geometry. For these a naive central finite difference through the full host solver (re-converging independently at \(p\pm h\)) already matchesjax.gradto \(\mathrm{rtol}\le 10^{-6}\). The solver’s internal path — how many Richardson steps, which restarts fired — averages out of a bulk integral.Solver-sensitive metrics — the rotational transform
iota(built from the current-constrainedchipsatncurr = 1), the mirror ratio, the magnetic well, the Boozer/QI residual: these read the converged state directly and locally. A naive re-solve at \(p\pm h\) lets the convergence logic (restart timing, the m=1 rotation, the last accepted step) re-form slightly differently on each side. That is an \(O(1)\) perturbation of the discrete path, not of the fixed point, and it can swamp — even sign-flip — the finite difference. Onli383_low_res, \(d(\iota_{\mathrm{edge}})/d(\mathrm{RBC}(-1,1))\) is \(-0.773\) from the adjoint but \(+0.045\) from a naive central FD: the two do not even agree in sign.
The naive FD is therefore not a valid reference for solver-sensitive
metrics — the disagreement is a property of the finite-difference probe, not
an error in the adjoint. The correct check reuses the same frozen residual
the adjoint differentiates:
frozen_path_directional_fd() takes a directional
step \(p\pm h\), Newton-solves the frozen \(F\) to its perturbed root
(rather than re-running the full adaptive solver from scratch), and finite-
differences that. It reproduces the adjoint to solver accuracy for iota,
mirror, well, and QI alike, and it is the reference used in
tests/test_implicit_grad.py. Bulk integrals are validated against both the
naive and the frozen-path FD; solver-sensitive metrics against the frozen-path
FD only. In short: the adjoint linearizes the fixed point, so a correct
numerical check must also hold the path fixed.
Forward-mode Jacobians for least squares (block-tridiagonal)¶
The adjoint above is the right tool for one scalar objective and many parameters. A least-squares optimizer needs the opposite object — the full residual Jacobian over all boundary dofs — which is computed in forward mode: per dof tangent \(t_j\), the state response is
one linear solve per dof. Rather than running an independent GMRES per
column, the default path (jac_solver="block") exploits a structural
fact: in the raw force formulation the radial coupling of
\(\partial F/\partial z\) is exactly nearest-neighbor (the
finite-difference stencil in \(s\)), so the operator is exactly
block-tridiagonal — ns dense \((3\,mn \times 3\,mn)\) blocks.
(The preconditioned formulation used by the adjoint is dense in radius,
because the 1D preconditioner’s inverse is.) The blocks are assembled with
3-colored jax.jvp probes — a cost independent of the dof count —
factored once with SOLVAX’s block-Thomas elimination (the BCYCLIC
analogue), and back-substituted for every dof right-hand side; a short
warm-started GMRES pass against the preconditioned system certifies each
column to the same tolerance as the per-column path. Measured: 33x on the
Jacobian phase of the benchmark optimization step (see
Optimization and differentiability). The same per-dof responses \(dz_j\) double as a
first-order perturbation warm start for the optimizer’s next trial solves —
the DESC-style eq.perturb pattern — making the linearization pay twice.