Stellarator coil optimization and particle tracing in JAX, on CPU or GPU. Coil objectives and adaptive tracing support automatic differentiation.
pip install essos- Automatic differentiation.
jax.gradworks through coil geometry, Biot-Savart fields and adaptive tracing. - Coil optimization. Fit coils to a plasma boundary under length, curvature,
separation, coil-surface-distance and force constraints, with
least_squares, an augmented Lagrangian, multi-objective (Pareto) search, or stochastic optimization over Gaussian coil perturbations. Coils can also target a near-axis field, particle confinement, or a finite-beta boundary (VMEX, or the plasma field from virtual_casing_jax inoptimize_coils_virtual_casing.py). - Particle tracing. Guiding-centre and full-orbit (Boris) models, with Monte Carlo collisions on background species with density and temperature profiles (NRL Coulomb logarithm per species pair), electric fields and alpha-loss diagnostics.
- Boozer-coordinate tracing. Guiding-centre diagnostics from the Boozer
|B|spectrum and flux functionsiota,GandI, with fixed-step (RK4, Dopri5, Dopri8) or adaptive Dormand-Prince stepping and optional collisions. - Field-line tracing. Adaptive, arclength and toroidal-angle models, with Poincare sections.
- Fields. Biot-Savart from coils, VMEC equilibria (stellarator-symmetric or
not, analytic derivatives, optional
mode_tolerancetruncation), near-axis expansions, andCombinedFieldto trace a sum of fields as one. - Interpolated fields.
InterpolatedField(source, R=..., Z=..., nr, nz, nphi, nfp)tabulates any Cartesian field (coils, dipoles, MGRID, combined) on a cylindrical grid and evaluatesB,dB/dXand guiding-centre terms from a tricubic spline; it saves to.npzor MGRID and works underjitandgrad. Seetrace_particles_interpolated_dipoles.py. - VMEC MGRID. Export coil fields and load MGRID files as JAX-compatible three-dimensional magnetic fields.
- Surfaces. Fourier-represented toroidal surfaces, from a VMEC
woutor built directly. - Parallel by default. ESSOS asks JAX for one device per CPU core and
shards particles over every CPU core or GPU (
ESSOS_CPU_DEVICESoverrides the count;devices=picks). Compiled traces are cached on disk (ESSOS_XLA_CACHE, default~/.cache/essos/xla). - Checked against SIMSOPT.
examples/comparisons_simsoptcompares coils, surfaces, VMEC import, field lines, guiding-centre and full-orbit tracing, and losses with SIMSOPT on the same inputs. Alpha losses are also compared with SIMSOPT, FIRM3D and SIMPLE (below).
Fit coils to a VMEC boundary, trading normal-field error against coil length and
curvature. Losses compose with +, and L.grad is the exact gradient.
from essos.coils import Coils, CreateEquallySpacedCurves
from essos.fields import BiotSavart
from essos.losses import custom_loss
from essos.surfaces import BdotN_over_B, SurfaceRZFourier
from scipy.optimize import least_squares
surface = SurfaceRZFourier.from_wout_file("wout.nc", s=1, ntheta=30, nphi=30,
range_torus="half period")
coils = Coils(curves=CreateEquallySpacedCurves(3, 3, 10.0, 5.6, n_segments=45,
nfp=2, stellsym=True),
currents=[1.0] * 3)
L = (custom_loss(lambda field, surface: abs(BdotN_over_B(surface, field)).sum(),
"field", surface=surface)
+ custom_loss(lambda field: (field.coils.length - 32.0).clip(0).mean(), "field")
+ custom_loss(lambda field: (field.coils.curvature - 0.1).clip(0).mean(), "field"))
L.dependencies = {"field": BiotSavart(coils)}
result = least_squares(L, L.starting_dofs, L.grad, max_nfev=400)
optimized = L.dofs_to_pytree(result.x)["field"].coilsMore in examples/coil_optimization.
import jax.numpy as jnp
from essos.coils import Coils
from essos.dynamics import Tracing
from essos.fields import BiotSavart
field = BiotSavart(Coils.from_json("coils.json"))
R0 = jnp.linspace(1.21, 1.40, 8)
seeds = jnp.array([R0, jnp.zeros_like(R0), jnp.zeros_like(R0)]).T
tracing = Tracing(field=field, model="FieldLineAdaptative", initial_conditions=seeds,
maxtime=8000, times_to_trace=40000, atol=1e-8, rtol=1e-8)
tracing.poincare_plot(shifts=[0.0])More in examples/fieldline_tracing.
Run examples/simple_examples/mgrid_from_coils.py
to export the included Landreman-Paul QA coils, load the file as a magnetic
field, and compare its interpolated field with direct Biot-Savart. It prints
the differences and saves a plot; the maximum difference at its four sample
points is about 0.2%. Accuracy depends on grid resolution and distance from
the coils, so compare in your region of interest before using a grid.
The cylindrical grid covers one field period in phi. The field repeats across periods, and R and Z queries are clamped to the grid bounds, so set the bounds to cover the region you intend to evaluate.
from essos.constants import ALPHA_PARTICLE_CHARGE, ALPHA_PARTICLE_MASS, ONE_EV
from essos.dynamics import Particles, Tracing
particles = Particles(initial_xyz=seeds, mass=ALPHA_PARTICLE_MASS,
charge=ALPHA_PARTICLE_CHARGE, energy=4000 * ONE_EV)
tracing = Tracing(field=field, model="GuidingCenterAdaptative", particles=particles,
maxtime=1e-4, times_to_trace=800, atol=1e-7, rtol=1e-7)
tracing.plot()
print(tracing.loss_fractions)More in examples/particle_tracing, including
full-orbit, collisional and electric-field variants.
Inside the last closed flux surface (LCFS), guiding centres move in VMEC
coordinates. An orbit that crosses the LCFS continues in Cartesian coordinates
in a field you supply for the outside (coils, an mgrid, or a VMEX
VmecExtender) until it strikes the wall or comes back in. Below, 3 keV
protons in a VMEX free-boundary equilibrium of the Landreman-Paul QA coils,
with a wall 3 cm outside the LCFS.
vmec = vmex.essos_vmec_field(wout) # or Vmec("wout_*.nc")
outside = vmex.VmecExtender.from_wout(wout, external_field=coil_grid)
tracing = Tracing(field=vmec, model="GuidingCenterAdaptative", particles=particles,
maxtime=4e-5, exterior_field=outside, wall=0.03) # gap [m] or a surface
print(tracing.wall_hits.mean(), tracing.wall_positions[tracing.wall_hits])The full script is
trace_particles_vmec_to_wall.py;
the figure comes from python docs/make_readme_figures.py wall.
Transform a VMEC equilibrium with booz_xform_jax,
then trace guiding centres in an axis-regular chart with optional Monte Carlo
collisions. method="rk4" (default), "dopri5" or "dopri8" take fixed
steps; "adaptive" (Dopri5) and "adaptive8" (Dopri8) control each
particle's step to tolerance. This tracer provides forward diagnostics.
import numpy as np
from netCDF4 import Dataset
from booz_xform_jax import Booz_xform
from essos.boozer import BoozerField, trace_boozer
from essos.constants import ALPHA_PARTICLE_CHARGE, ALPHA_PARTICLE_MASS, ONE_EV
booz = Booz_xform(verbose=0, mboz=32, nboz=32)
booz.read_wout("wout.nc", flux=False)
booz.run()
with Dataset("wout.nc") as wout:
phi_edge = float(wout.variables["phi"][-1]) # boundary toroidal flux
field = BoozerField.from_booz_xform(booz, psi0=-phi_edge / (2 * np.pi))
n = 1000
speed = np.sqrt(2 * 3.5e6 * ONE_EV / ALPHA_PARTICLE_MASS)
result = trace_boozer(field, s=np.full(n, 0.25), theta=np.random.uniform(0, 2*np.pi, n),
zeta=np.random.uniform(0, 2*np.pi, n), pitch=np.random.uniform(-1, 1, n),
speed=speed, mass=ALPHA_PARTICLE_MASS, charge=ALPHA_PARTICLE_CHARGE,
tmax=1e-2) # species=BackgroundSpecies(...) adds collisions
print(result.lost.mean(), result.loss_fractions())psi0 is minus VMEC's boundary toroidal flux over 2 pi. A particle
is lost at s = 1; result.loss_times and result.states hold when and where.
Boozer tracing evaluates |B| = sum [bc_mn(s) cos(m theta - n zeta) + bs_mn(s) sin(m theta - n zeta)]
and its derivatives, together with the flux functions G, I and iota.
256 collisionless 3.5 MeV alphas per equilibrium, started at s = 0.5 with
uniform Boozer angles and pitch, traced for 10 ms and lost at s = 1, in the
Landreman-Paul QA reactor-scale wout, the ARIES-CS n3are wout, and the
W7-X standard configuration scaled to the same minor radius (1.70 m) and
volume-averaged |B| (5.86 T). Apple M4 (load about 3 from other processes), 8 CPU
cores per code; compilation and field set-up excluded. DESC traces the first 32
alphas only, on VMECIO.load(wout, L=24, M=8, N=8) with the half-mesh lambda fix of
PlasmaControl/DESC#2352, and its times were taken with other DESC runs sharing the
machine. Run with
examples/cross_code_benchmark/benchmark.py.
| Equilibrium | Code | Method | Run time | Lost | Same fate as SIMPLE and FIRM3D | max |E/E0 - 1| |
|---|---|---|---|---|---|---|
| QA | ESSOS trace_boozer |
Dopri8, dt = 2.1e-07 s (default) |
19 s | 7.0 ± 1.6% | 100.0% | 1e-05 |
| SIMPLE | symplectic, defaults | 17 s | 7.0 ± 1.6% | - | not reported | |
| FIRM3D | RK45, tol 1e-9, gc_noK |
48 s | 7.4 ± 1.6% | - | 4e-05 | |
| SIMSOPT | RK45, tol 1e-9, gc_noK |
17 s | 8.2 ± 1.7% | 85.4% | 5e-05 | |
| DESC | Tsit5, tol 1e-7, L = 24, M = N = 8 |
3859 s | 6.2 ± 4.3% (32) | 100.0% | 2e-05 | |
| ARIES-CS | ESSOS trace_boozer |
Dopri8, dt = 1.1e-07 s (default) |
36 s | 22.7 ± 2.6% | 98.3% | 1e-05 |
| SIMPLE | symplectic, defaults | 24 s | 23.4 ± 2.6% | - | not reported | |
| FIRM3D | RK45, tol 1e-9, gc_noK |
146 s | 21.5 ± 2.6% | - | 5e-03 | |
| SIMSOPT | RK45, tol 1e-9, gc_noK |
64 s | 23.8 ± 2.7% | 93.7% | 1e-04 | |
| DESC | Tsit5, tol 1e-7, L = 24, M = N = 8 |
23150 s | 34.4 ± 8.4% (32) | 90.3% | 7e-05 | |
| W7-X | ESSOS trace_boozer |
Dopri8, dt = 1.4e-07 s (default) |
26 s | 25.8 ± 2.7% | 97.1% | 7e-04 |
| SIMPLE | symplectic, defaults | 191 s | 27.3 ± 2.8% | - | not reported | |
| FIRM3D | RK45, tol 1e-9, gc_noK |
191 s | 27.0 ± 2.8% | - | 3e-03 | |
| SIMSOPT | RK45, tol 1e-9, gc_noK |
111 s | 28.9 ± 2.8% | 99.6% | 1e-03 | |
| DESC | Tsit5, tol 1e-7, L = 24, M = N = 8 |
21807 s | 40.6 ± 8.7% (32) | 89.7% | 2e-04 |
The fate agreement is the share of particles lost or confined as in SIMPLE and
FIRM3D, over the particles on which those two agree. The bar plot scales DESC's
time from 32 to 256 particles. ESSOS, SIMSOPT and FIRM3D use the same 32x32
booz_xform spectrum (ESSOS keeps modes above its default 1e-6 of B00 and,
above 48 modes, evaluates them from a table in the Boozer angles); SIMPLE reads
the wout directly and DESC fits it. ± is the binomial standard error.
- VMEC magnetic axis. VMEC guiding centres are integrated in
sqrt(s) (cos theta, sin theta), which is regular on the axis, so orbits cross it; trajectories are returned in(s, theta, phi, ...)withthetain[0, 2 pi). - LCFS and wall. A VMEC guiding-centre trace stops at the exact LCFS
crossing (
tracing.lcfs_times,lcfs_positions,lcfs_energies), found by root finding. Passexterior_field=(ESSOS coils, a VMEXVmecExtenderorMgridField, or any batchedxyz -> B) andwall=(aSurfaceClassifieror a signed-distance callable, positive inside) to follow the orbit outside in Cartesian coordinates until it strikes the wall (wall_times,wall_positions,wall_energies) or comes back inside, where it returns to flux coordinates (returns).tracing.statusgives each outcome (essos.dynamics.VMEC_STATUS);trajectories_xyzandregionhold the whole orbit. A non-finite step ends that orbit asfailed, with its time. Loss fractions use the exact wall, or LCFS, times. Collisions are switched off outside the LCFS. Supplyingconditionreplaces these events; it is evaluated on(s, theta, phi, ...). Seeexamples/particle_tracing/trace_particles_vmec_to_wall.py. - Stopping coil-field traces. Pass
stopping_criteria=LevelsetStoppingCriterion(...)to end Cartesian traces once they leave a prescribed distance from a surface, and read the per-line mask fromtracing.boundary_hits. - Step budget.
max_steps(default1_000_000) bounds every Diffrax solve, so a trace that cannot finish returns instead of running unbounded. - Progress bars are off by default; pass
progress=Truewhen interactive. Withparticle_batch_size=nparticles are tracednat a time and the bar counts completed particles (batches are bypassed underjax.jit/grad). - Model choice.
FieldLineArclengthtraces a fixed physical length so rescalingBdoes not change the run;FieldLineToroidalsets coverage directly in toroidal angle for flux-coordinate fields.
Near-axis expansions need pyQSC_JAX, which is not on PyPI and therefore cannot be a declared dependency:
pip install git+https://github.com/uwplasma/pyQSC_JAX.gitEverything else works without it; the near-axis entry points raise with this command if it is missing.
| Folder | Contents |
|---|---|
coil_optimization |
VMEC and near-axis targets, augmented Lagrangian, stochastic, multi-objective, particle confinement, finite beta |
fieldline_tracing |
Coil and VMEC field lines, Poincare sections, connection length |
particle_tracing |
Guiding-centre and full-orbit tracing, classifiers, electric fields, Boozer vs VMEC |
particle_tracing_collisions |
Collisional tracing and velocity-distribution statistics |
comparisons_simsopt |
Side-by-side runs with SIMSOPT |
cross_code_benchmark |
Alpha losses against SIMSOPT, FIRM3D and SIMPLE |
simple_examples |
Coils from near-axis or BOOZ_XFORM, perturbed coils, combined fields, MGRID |
paper |
Integrator, gradient and Poincare figures |
pytestMIT, see LICENSE.
Developed by the UWPlasma group at the University of Wisconsin-Madison, with support from Simons Foundation grant 560651.





