Repository navigation
Field algebra, MRX fields and faster guiding-center tracing - #146
Closed
rogeriojorge wants to merge 10 commits into
Closed
rogeriojorge wants to merge 10 commits into
rogeriojorge wants to merge 10 commits into
Conversation
MagneticField defaults to Cartesian coordinates and adds f + g, f - g, a * f (CombinedField, flattened, static weights), B_xyz (B at a Cartesian point) and compare (two fields at the same physical points). ToroidalField is the base of fields in (s, theta, phi): it inverts to_xyz and measures the distance to the boundary from to_xyz alone. Vmec now inherits it and drops its copies of the base-class curl and curvature. The tracer recognizes any ToroidalField, or a sum led by one, so sums and scaled fields keep the axis chart and the boundary stop. MRXField evaluates an MRX 2-form (https://github.com/ToBlick/mrx) on its spline map, with covariant and contravariant components, sqrt(g) and the field-period rotation, and loads a relaxed checkpoint or computes the vacuum field of a VMEC or DESC domain (MRXField.from_mrx). DipoleField is a sum of point dipoles (permanent magnets) with the moments as dofs.
… points With linear interpolation in s, grad|B| jumps at every wout grid point. A guiding center whose radial drift changes sign at one chatters across it while the adaptive step shrinks to nothing, and under vmap the whole batch runs until that orbit reaches max_steps (about an hour at the default 1e6). On li383 at 1 T, one 5 keV proton of 128 did this at s = 14/15. The modes are now interpolated by cubic Hermite with centred-difference slopes, linear in the first interval so the axis extrapolation is unchanged. Grid values are kept and the s derivative is continuous. The stalled orbit now finishes in under 2000 steps, and 16 batches of 8 take 3-6 s each.
Each Tracing compiles a new solve because maxtime and the save times are constants of it: about 4 s on a CPU and 20 s on a GPU, every call. XLA's persistent cache, keyed by the compiled program, serves repeated traces from disk: on an A4000, repeated 64-particle guiding-center calls drop from 23-26 s to 6-7 s, and a new process starts at 18 s instead of 34 s. ESSOS_XLA_CACHE sets the directory (default ~/.cache/essos/xla); an empty value disables it, and a cache directory set by the user is kept.
A vmapped trace on one CPU device runs its small kernels on about one core. essos now asks JAX for os.cpu_count() CPU devices (ESSOS_CPU_DEVICES overrides; an existing XLA_FLAGS or jax_num_cpu_devices choice is kept), and Tracing pads the batch to a multiple of the device count instead of dropping to fewer devices. Each shard also stops at its own slowest orbit. Apple M4, VMEC li383 guiding centers: 64 particles over 20 us trace in about 1 s instead of 8.5 s, results equal to 7e-9; 512 particles 6.6x. B_on_surface sharded every leaf of the surface pytree and failed when one did not divide by the device count; it now shards only the points, when they divide evenly.
…e device The examples set XLA_FLAGS to a fixed device count (usually 1), which turned off the per-core sharding; they now use the ESSOS default. A differentiated trace stays on one device: compiling a gradient through a while loop sharded over several CPU devices aborts in XLA with JAX 0.6 (the Python 3.10 CI job).
PR #32 adds a permanent-magnet DipoleField (fixed orientations, scalar strengths through an interaction matrix). The point-dipole field with the moments as dofs takes a distinct name so the two can coexist.
The 12 x 32 seed search and 40 damped Newton steps made B_xyz 66x the cost of B. Newton now starts from the point's angle about the axis, measured from theta = 0 in the sense theta turns, and its distance from the axis relative to the boundary's in that direction. On 2000 points of VMEX QA, VMEC li383 and an MRX island state, from the axis to s = 1, the residual stays below 1.2e-15 m, at 19-92 us per point instead of 656.
Equinox partitions a pytree by putting non-array leaves in its slots; __init__ turned them into arrays, so a sum of coils and dipoles failed in every adaptive trace.
This was referenced Oct 7, 2026
Member
Author
|
Split into focused PRs, each reviewable on its own:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this adds
Field algebra on
MagneticField.to_xyzis the identity andsqrtg = 1), so a Cartesian field only definesB.f + g,f - g,a * fandsum([...])build aCombinedField. Nested sums are flattened, and the weights are static.B_xyz(xyz)evaluates any field at a Cartesian point.f.compare(g, points)returns|B_f - B_g| / |B_f|at the same physical points, even when the two fields use different coordinates.ToroidalField(MagneticField)is the base for fields in(s, theta, phi).flux_coordinates(the inversion ofto_xyz) moves up fromVmec.boundary_distancealso moves up and is now built onto_xyzalone.Vmeckeeps its analytic Fourier version of the boundary curve.Vmecsubclasses it and drops its copies of the base-classcurl_B,curl_b,kappaanddB_by_dX.is_toroidal(field)instead ofisinstance(field, Vmec). A scaled field or a sum led by a toroidal field therefore keeps the axis-regular chart, the boundary stop and the exterior-field handling.MRXField(ToroidalField)wraps a state of MRX (arXiv:2510.26986).Bhat = J DPhi^-1 Bon a spline mapPhiof the unit cube.(s, theta, phi) = (r^2, 2 pi theta_l, 2 pi zeta / nfp)and scale factorsc = (2r, 2pi, 2pi/nfp), the field is:B = DPhi Bhat / J,B^i = c_i Bhat^i / J,B_i = (DPhi^T DPhi Bhat)_i / (J c_i),sqrt(g) = J / (c_1 c_2 c_3).div B = 0holds exactly.MRXField.from_mrx(geometry, checkpoint=None)loads a relaxed checkpoint, or computes the vacuum (harmonic) field of a VMEC or DESC domain. MRX is imported lazily, so it is not a dependency.PointDipoleField(MagneticField)is a sum of point dipoles (permanent magnets), with the moments asdofs. It is named so it can coexist with the permanent-magnetDipoleFieldof #32, which keeps orientations fixed and optimizes scalar strengths.Examples.
examples/particle_tracing/trace_particles_mrx.py: MRX vs VMEC, field lines and alpha particles. It also takes an MRX checkpoint, for example an island state.examples/coil_optimization/optimize_coils_mrx.py: coils that reproduce an MRX field.essos/fields.pychanges by +315/-99 lines: three new classes, the algebra, and the generic inversion and boundary distance, net of the code removed fromVmec.Benchmarks
All on CPU (Apple M4), float64.
Consistency of
MRXFieldOn the MRX vacuum field of Landreman-Paul QA, at 200 random points:
B = e_i B^iB_i = e_i . Bsqrt(g) = det(e)B_xyz(to_xyz(p)) = B(p)div B(fromsqrt(g) B^i)Here
e = d to_xyz / d(s, theta, phi), computed by automatic differentiation.MRX vs VMEX on Landreman-Paul QA
Setup:
input.LandremanPaul2021_QA_lowreswas solved with VMEX tofsqr = 9.9e-14.||B||, not the flux.iotawas traced in ESSOS through both fields (FieldLineToroidal, 60 field periods).For reference, VMEC2000's wout of the same deck differs from VMEX by 3.5e-4 rms for s >= 0.1 and 1.0e-2 for s < 0.1. The difference near the axis is mostly VMEC's: its half-grid B is least accurate there. MRX's
iotasign is opposite because its poloidal angle is right-handed.Evaluation cost
QA mesh (12, 16, 16), p = 2.
B, batched (per point)gc_quantities, batched (per point)gc_quantities, single jitted callAdaptive guiding-center tracing in an MRX field takes more steps than in VMEC. At p = 2, grad|B| is only continuous, so the steps shrink at every knot. At p = 3 the same orbits trace 3.2x faster than at p = 2 and conserve energy better (2e-7 vs 4e-7).
from_mrxtherefore builds vacuum fields at p = 3 by default.Coils for an MRX field
optimize_coils_mrx.pyuses the reactor-scale QA domain, 5.7 T on axis, and 4 coils per half period (16 coils in total). It matches the full MRX vector field on the boundary: in vacuum that fixes the field inside.The coil field lines lie on the MRX flux surfaces in the Poincare section of the example.
DESC, VMEX and MRX on the same boundary (precise QA, vacuum)
Setup:
data/desc_LandremanPaul2021_QA.h5).VMECIO.write_vmec_input, solved tofsqr < 1e-13at ns = 33, 65, 129 and 257.B_xyz.iotais traced in ESSOS for 600 field periods.iotamoves with ns: -0.4210, -0.4195, -0.4186 and -0.4182 for ns = 33 to 257, against DESC's -0.4199. MRX's polar splines are smooth through the axis, and at (8, 12, 12) it already beats VMEX at ns = 257 there by 6x.Finite beta (2.5%, bootstrap current)
Setup:
input.LandremanPaul2021_QA_beta2p5_bootstrapat ns = 33, 65, 129 and 257, all tofsqr < 1e-13.scripts/relax.py, (12, 16, 16) p = 3, 20 Newton steps. The force residual ends at 4.5e-9,div Bat 1e-15, and the helicity drifts by 7e-6.I(s)of VMEX and MRX comes from Ampere's law around the poloidal loop at fixed s, evaluated through their ESSOS fields.J_phicomes fromcurl_B.With the bootstrap current, VMEX's iota near the axis needs ns = 257 to reach DESC's: ns = 33 is 20% off at rho = 0.1. Near the axis, MRX's field is 5x closer to DESC's than VMEX's at ns = 257. The toroidal current profiles of all three overlay, and so do the
J_phicontours on phi = 0.Field lines and guiding centers through MRX islands
The README now shows six 10 keV protons started across the (6,1) chain, in the MRX island state and in VMEX li383 (ns = 65). In MRX their crossings of phi = 0 jump around the island chain; in VMEX each stays on its own nested ring. The movie is
docs/readme_mrx_islands.mp4(330 kB), made bydocs/make_mrx_islands_movie.py.The states are from MRX's
scripts/tutorials/3_li383_newton.pyand4_li383_island_seed.py:For each field:
(1 / B_axis) * field), traced for 0.2 ms.Run on CPU (Apple M4, 10 devices).
|B|table differs fromsqrt(B_i B^i)built from its component tables by 0.55% rms and 3% at most. The guiding-center equations mix the two tables. Every MRX quantity comes from one B.Performance
Guiding centers stalled at VMEC grid points
_radial_interpwas linear in s, so grad|B| jumped at every wout grid point. A guiding center whose radial drift changes sign at such a point chatters across it while the adaptive step shrinks to nothing, and runs untilmax_steps(1e6). Undervmapthe whole batch waits for it. On li383 at 1 T, one 5 keV proton of 128 did this at s = 14/15. A 128-particle run then took more than 4 h on CPU and never finished on a GPU.The modes are now interpolated with cubic Hermite polynomials and centred-difference slopes. The first interval stays linear, so the axis extrapolation is unchanged. Grid values are kept and the s derivative is continuous. The stalled orbit now finishes in under 2000 steps. All batches of the same 128 particles take 3-6 s, and 8192 particles on an A4000 run in 8 even batches of 36-66 s.
Compilation
Every
Tracingcompiles a new solve, about 4 s on a CPU and 20 s on a GPU.dynamics.pynow enables XLA's persistent compilation cache:ESSOS_XLA_CACHE, default~/.cache/essos/xla. An empty value disables the cache, and a directory the user already set is kept.Inverting
to_xyzB_xyzandcompareinvertto_xyzby Newton iterations. The 12 x 32 seed search and 40 damped steps cost 66x one evaluation ofB. Newton now starts from the point's angle about the axis (measured from theta = 0, in the sense theta turns) and its distance from the axis relative to the boundary's. Twelve steps are enough.On 2000 points from the axis to s = 1:
CPU cores
On a single CPU device, a vmapped trace runs its small kernels on about one core.
essos/__init__.pynow asks JAX foros.cpu_count()CPU devices, andTracingalready shards particles over devices:ESSOS_CPU_DEVICESoverrides the count. AnXLA_FLAGSorjax_num_cpu_devicessetting already made by the user is kept.Tracingpads the batch to a multiple of the device count instead of falling back to fewer devices.On an Apple M4 (10 cores), VMEC li383 guiding centers:
B_on_surfacesharded every leaf of the surface pytree, and failed whenever one did not divide by the device count. It now shards only the points, and only when they divide evenly.The examples set
XLA_FLAGSto a fixed device count, usually 1, which turned the sharding off. They now use the default.A differentiated trace (
jax.gradthroughTracing) stays on one device. Compiling a gradient through a while loop sharded over several CPU devices aborts in XLA with JAX 0.6, the newest JAX for Python 3.10. That was the first CI failure of this PR. I reproduced it on Linux with Python 3.10 and JAX 0.6.2, and confirmed the fix there.Defaults on a GPU workstation
Two RTX A4000 and 36 CPU cores, with nothing configured: 1024 guiding centers in VMEC li383 over 100 us.
The results agree to 4e-7, within the default solver tolerance. Two GPUs are 3.5x faster than one, because each shard stops at its own slowest orbit.
Tests
tests/test_fields.pyadds tests for:compare,MRXFieldon an analytic torus map, which needs no MRX install: the identities above, field-period rotation, theis_toroidalsum, and gradients in the coefficients.Also tested: the radial interpolation keeps grid values and has a continuous s derivative (this test fails on the old linear interpolation). The suite has 323 passing tests. The two
test_survivor_compaction_preserves_outputs[adaptive8-...]failures also fail onmainon this machine; they are 1-ulp differences.Not in this PR
Near-axis fields stay in pyQSC_JAX. Its
near_axisgives B in Boozer coordinates, while its position map is in the axis's cylindrical angle, so making it aToroidalFieldneeds a root solve per point. Having it subclass an ESSOS class would also make pyQSC_JAX depend on ESSOS.The benchmark scripts are not part of the PR. Two examples cover the API:
trace_particles_mrx.pyandoptimize_coils_mrx.py.