Extend a Simulation with Python
Overview
ImpactX’s Python interface lets you weave custom Python code into a running simulation. Through this interface, you can access and modify simulation data, e.g., particle phase-space coordinates, the reference particle, and space-charge fields, as the simulation is tracking the beam. This facilitates a wide range of workflows, including:
In-situ diagnostics that compute quantities of interest at each turn or element without writing files to disk.
Dynamic lattice manipulation, e.g., changing element strengths between turns, ramping voltages, or feedback systems.
Custom beam optics elements, e.g., a non-linear kick or a tabulated map, plugged in via the
impactx.elements.Programmableelement.AI/ML surrogate models built with PyTorch or TensorFlow to emulate detailed transport in a single element.
Because ImpactX exposes particles and fields without copies through pyAMReX,
per-step Python callbacks have very low overhead and stay GPU-resident when ImpactX runs on GPUs
(cupy, PyTorch, etc. can operate directly on the device data).
How to run a simulation with Python extensions
Install ImpactX with the Python interface: when compiling from source, this means
-DImpactX_PYTHON=ON(see building). Pre-built ImpactX packages also include this interface.Write a Python script that drives the simulation, registering callbacks or programmable elements before calling
track_particles(). A minimal template looks like:from impactx import ImpactX, distribution, elements sim = ImpactX() sim.space_charge = False sim.init_grids() # ... set up reference particle, distribution, and lattice ... # register tracking hooks or programmable elements here: def after_each_period(sim): turn = sim.tracking_period # e.g. compute a custom diagnostic from sim.beam sim.hook["after_period"] = after_each_period # advance the simulation sim.track_particles() sim.finalize()
Run the script like any Python script, optionally under MPI:
mpirun -np <n_ranks> python <script>.py
Callback Functions
ImpactX provides two complementary callback surfaces.
Tracking hooks
The hook mapping registers user-defined functions that are invoked at well-defined
points of the tracking loop. The supported locations are:
"before_period"— before each lattice period (e.g. turn or channel period)"after_period"— after each lattice period"before_element"— before each lattice element is entered"after_element"— after each lattice element is exited"before_slice"— before each element slice (and before that slice’s space-charge solve, when enabled; to read freshly-computed space-charge fields, prefer"after_element")
Each hook receives the ImpactX instance and may inspect or modify simulation state
through it (e.g., sim.beam, sim.lattice, sim.rho, sim.phi, …).
Use tracking_step, tracking_period, and
tracking_element to identify where in the loop the hook was called.
For instance, you can return if the current element does not match a type or name.
def after_each_turn(sim):
turn = sim.tracking_period
element = sim.tracking_element
df = sim.beam.to_df(local=True)
if df is not None:
print(f"turn {turn} at element={element.name}: "
f"{len(df)} local particles, "
f"<x>={df['position_x'].mean():.3e} m")
sim.hook["after_period"] = after_each_turn
Full example: Acceleration by RF Cavities.
Programmable lattice element
For workflows that replace a beamline element entirely (rather than observing the beam between elements),
use the impactx.elements.Programmable element. It exposes three property hooks:
push: push the whole particle container.ref_particle: push only the reference particle.beam_particles: called once per particle tile (high-performance); the reference particle is updated before this callback fires.
Typical usage (per-tile push) looks like this:
from impactx import elements
def my_drift(pge, pti, refpart):
"""Push the beam particles as a drift over one slice."""
soa = pti.soa().to_xp() # NumPy (CPU) or CuPy (GPU)
x = soa.real["position_x"]
y = soa.real["position_y"]
t = soa.real["position_t"]
# ...
slice_ds = pge.ds / pge.nslice
betgam2 = refpart.pt**2 - 1.0
x[:] += slice_ds * px[:]
y[:] += slice_ds * py[:]
t[:] += (slice_ds / betgam2) * pt[:]
def my_ref_drift(pge, refpart):
"""Push the reference particle over one slice."""
x = refpart.x
px = refpart.px
s = refpart.s
slice_ds = pge.ds / pge.nslice
# ...
refpart.x = x + 1.23
refpart.s = s + slice_ds
dr = elements.Programmable(name="my_drift")
dr.ds = 0.25
dr.nslice = 5
dr.beam_particles = lambda pti, refpart: my_drift(dr, pti, refpart)
dr.ref_particle = lambda refpart: my_ref_drift(dr, refpart)
sim.lattice.append(pge)
A complete script is provided in this FODO Cell example. For a more complex example, see our ML surrogate element example.
Accessing simulation data through Python
While the simulation is running, the Python code (i.e., the code inside callback functions) has read and write access to the beam particles and the space-charge fields. The two sections below describe the specific Python syntax for each.
See also Data Analysis for file and streaming-based analysis of the openPMD output written by BeamMonitor.