Accessing Field Data

ImpactX exposes the space-charge fields computed on the AMR mesh:

  • the charge density \(\rho\)

  • the scalar potential \(\phi\), and

  • the resulting force kick vector components,

directly to Python as pyAMReX MultiFab objects.

Selecting a field

Use the following accessors on the ImpactX instance, each of which returns a MultiFab on the requested mesh-refinement level:

rho = sim.rho(lev=0)                              # charge density
phi = sim.phi(lev=0)                              # scalar potential
E_x = sim.space_charge_field(lev=0, comp="x")     # x-component of the space-charge force
E_y = sim.space_charge_field(lev=0, comp="y")
E_z = sim.space_charge_field(lev=0, comp="z")

Available mesh levels range from 0 to sim.finest_level.

Lifetime / Validity

Important

The space-charge fields are computed during the per-slice space-charge solve. Outside of that window the MultiFab data may be uninitialized, stale, or empty. You should therefore read them either:

  • From inside a sim.hook "after_element" callback, which fires after the element’s last per-slice solve and sees the freshly computed fields of that last slice. Note: "before_slice" fires before the slice’s solve, so on the very first slice phi/rho are uninitialized.

  • Or right after track_particles() returns, but before the simulation is finalized.

See Callback Functions for how to install such a hook. Space-charge must of course be enabled (see sim.space_charge) for the fields to be populated at all.

Mesh metadata

To interpret indices and array shapes, query the AMR geometry on the same level:

geom = sim.Geom(lev=0)
prob_lo = geom.ProbLo()        # physical lower corner of the domain
prob_hi = geom.ProbHi()        # physical upper corner
dx      = geom.CellSize()      # cell sizes (dx, dy, dz)

ba = sim.boxArray(lev=0)        # box decomposition
dm = sim.DistributionMap(lev=0) # which MPI rank owns which box

Accessing field arrays

There are several ways to read or modify a MultiFab, with different trade-offs. The underlying API is provided by pyAMReX, see the pyAMReX compute guide for the full reference.

Many reductions and bulk operations are exposed directly as MultiFab methods:

rho_max = rho.max(comp=0)        # maximum value (per component)
phi_min = phi.min(comp=0)        # minimum value
rho.mult(2.0, 0, 1)              # scale in place

These have low overhead and run on CPU or GPU as appropriate.

Single elements and slices can be addressed with global (i, j, k) indices. This is convenient for inspection and debugging.

# read a single line of cells (level 0, component 0)
line = rho[:, ny // 2, nz // 2]

# write a single value
rho[i, j, k] = 0.0

Negative-imaginary indices address ghost cells; see the pyAMReX compute guide linked above.

In MPI-parallel simulations, this indexing interface will trigger MPI communication and can become costly.

For high-performance, GPU-portable access: modifying a field over many cells, or coupling to cupy/numpy arrays in-place. Enables to iterate over the boxes owned by the current MPI rank:

for mfi in rho:
    bx  = mfi.tilebox()
    arr = rho.array(mfi).to_xp()   # NumPy (CPU) or CuPy (GPU)
    arr[...] *= 0.5                # in-place modification

:ref:`to_xp() <https://pyamrex.readthedocs.io/en/latest/usage/zerocopy.html>`__ (or the explicit to_numpy()/to_cupy()) returns a zero-copy view into the box’s data with the layout matching the local box, including ghost cells.

Full example: plotting the space-charge potential

The following script runs a short constant-focusing channel with 3D space charge and installs an "after_element" hook that saves a PNG of the scalar potential \(\phi\) at the transverse mid-plane after the element’s last per-slice Poisson solve. The same pattern works for sim.rho and sim.space_charge_field.

This and other examples are available under tests/python/.

Full example: sim.hook["after_element"] reads sim.phi
import matplotlib.pyplot as plt

from impactx import ImpactX, amr, distribution, elements

sim = ImpactX()

# numerical parameters
sim.n_cell = [48, 48, 40]
sim.tiny_profiler = False
sim.particle_shape = 2
sim.space_charge = "3D"
sim.poisson_solver = "fft"
sim.prob_relative = [1.1]
sim.slice_step_diagnostics = True

sim.init_grids()

# 2 GeV proton beam, ~1 um normalized transverse rms emittance
kin_energy_MeV = 2.0e3
bunch_charge_C = 1.0e-8
npart = 10000

ref = sim.beam.ref
ref.set_species("proton").set_kin_energy_MeV(kin_energy_MeV)

distr = distribution.Waterbag(
    lambdaX=1.2154443728379865788e-3,
    lambdaY=1.2154443728379865788e-3,
    lambdaT=4.0956844276541331005e-4,
    lambdaPx=8.2274435782286157175e-4,
    lambdaPy=8.2274435782286157175e-4,
    lambdaPt=2.4415943602685364584e-3,
)
sim.add_particles(bunch_charge_C, distr, npart)

# short CF cell so the test stays quick
sim.lattice.extend(
    [
        elements.ConstF(name="cf1", ds=2.0, kx=1.0, ky=1.0, kt=1.0, nslice=5),
    ]
)

def plot_phi(sim):
    """Plot phi at the transverse mid-plane after an element finishes.

    ``sim.hook["after_element"]`` fires after the element's last per-slice
    Poisson solve, so ``sim.phi(lev=0)`` holds the freshly computed
    potential of the last slice when we read it.
    (``"before_slice"`` would fire *before* the slice's solve, when phi
    is from a previous slice's solve — or uninitialized on the first slice.)

    GPU/MPI-portable: we use global indexing and pyAMReX garthers to rank zero.
    """
    phi = sim.phi(lev=0)
    geom = sim.Geom(lev=0)
    half_z = sim.n_cell[2] // 2
    mm = 1e3

    # this triggers an MPI-collective gather
    plane = phi[:, :, half_z, 0]

    # other MPI ranks can return now
    if not amr.ParallelDescriptor.IOProcessor():
        return

    # now plot on the IOProcessor
    fig, ax = plt.subplots()
    im = ax.imshow(
        plane.T * 1.0,
        origin="lower",
        aspect="auto",
        extent=[
            geom.ProbLo(0) * mm,
            geom.ProbHi(0) * mm,
            geom.ProbLo(1) * mm,
            geom.ProbHi(0) * mm,
        ],
    )
    fig.colorbar(im, label=r"$\phi$  [V]")
    ax.set_xlabel(r"$x$  [mm]")
    ax.set_ylabel(r"$y$  [mm]")
    if save_png:
        fig.savefig(f"phi_step{sim.tracking_step:04d}.png")
    else:
        plt.show()
    plt.close(fig)

sim.hook["after_element"] = plot_phi

sim.track_particles()
sim.finalize()

See also