KV equilibrium distribution and envelope equations
The motion of intense charged particle beams is, in general, very complicated because of the Coloumb forces between particles. The distribution function \(f(\mathbf{x}, \dot{\mathbf{x}}, t)\) evolves according to the Vlasov-Poisson equations
where \(q\) is the particle charge, \(m\) is the particle mass, \(\mathbf{E}\) is the electric field, and \(\mathbf{B}\) is the magnetic field. \(\mathbf{E}\) and \(\mathbf{B}\) account for both external/applied and internal/self-generated fields. Assuming that self-generated magnetic fields are negligible and that applied fields are entirely magnetic, \(\mathbf{E}(\mathbf{x}, t)\) is determined by the Poisson equation:
When the phase space is four-dimensional (4D), so that \(\mathbf{x} = (x, \dot{x}, y, \dot{y})\), and when the focusing forces are time-dependent linear functions of \(x\) and \(y\), there is a special equilibrium solution to the Vlasov-Poisson equations known as the Kapchinskij-Vladimiskij (KV) distribution. The KV distribution is constructed from single-particle invariants \(J_x(\mathbf{x}, \dot{\mathbf{x}})\) and \(J_y(\mathbf{x}, \dot{\mathbf{x}})\), also called the Courant-Snyder invariants, in the time-dependent linear system as follows:
The KV distribution is a uniformly populated ellipsoid in the 4D phase space, with the constants \(\tilde{J}_{x,y}\) determining the ellipsoid dimensions. To prove that the distribution is in equilibrium, we have to show that it generates a linear electric field (linear in the positions \(x\) and \(y\)), since a linear electric field preserves the single-particle invariants from which the distribution is constructed. Proving this is actually fairly involved. The first step is to show that a uniform-density 4D ellipsoid projects to a uniform charge density within an elliptical envelope in the \(x\)-\(y\) plane; i.e., for a beam of line density \(\lambda\) and ellipse radii \(r_{x, y}\), the density is
The second step is to solve the Poisson equation for this charge distribution. The end result is a simple expression for the field \(E(\mathbf{x}) = (E_x, E_y)\)inside the ellipse:
The field outside the ellipse is nonlinear in \(x\) and \(y\); however, since there are no particles outside the ellipse, all particles see a linear net force from the beam and external fields.
Let’s plug these fields into the single-particle equations of motion. If \(\kappa(s)\) is the applied focusing strength at location \(s\), then
Here \(' = d/ds\) and \(Q\) is the perveance, which is the charge density scaled by a factor which depends on the beam energy (higher energy = weaker space charge forces). With some additional work, we can derive differential equations for \(r_x\) and \(r_y\):
Here \(\varepsilon_x = 4\sqrt{\langle xx \rangle \langle x'x' \rangle - \langle xx' \rangle \langle xx' \rangle}\) and \(\varepsilon_y = 4\sqrt{\langle yy \rangle \langle y'y' \rangle - \langle yy' \rangle \langle yy' \rangle}\) are the invariant areas of the \(x\)-\(x'\) and \(y\)-\(y'\) phase space ellipses; we call them the emittances. The first term in Equation 5 is the effect of linear focusing, the second term accounts the incompressibility of the phase space volume, and the third term accounts for linear space charge forces. This set of differential equations can be solved numerically to obtain the evolution of the beam envelope, i.e., the beam sizes \(r_{x, y}\), as a function of time.
Particle-based envelope solver
Jeff Holmes had a neat idea to solve the KV envelope equations equations using the PyORBIT tracking code. He noted that the first term Equation 7 resembles a single-particle equation of motion, where \(r_x\) and \(r_y\) are treated as the particle coordinates, and that the next two terms act as nonlinear driving forces on this fictitous particle. It’s straightforward to implement integrate these equations in PyORBIT, which represents the accelerator as a set of nodes that sequentially update the phase space coordinates of each particle in the bunch. We can simply let the first bunch particle store the envelope radii \(r_{x, y}\) as its \(x\) and \(y\) coordinates, and the first derivatives \(r_{x,y}'\) as its \(x'\) and \(y'\) coordinates. The first term in Equation 7 will be handled by the existing nodes in the lattice. To apply the nonlinear driving terms, we can define a new node which applies a momentum kick based on these coordinates and constants \(\varepsilon_{x}\), \(\varepsilon_y\), and \(Q\). All additional particles in the bunch can receive regular space charge kicks, assuming a uniform-density elliptical beam with dimensions given by the envelope parameters. These additional particles are considered “test” particles because they do not affect the space charge field of the beam in a self-consistent way.
PyORBIT tracking algorithms are written in C++ and accessible from the Python level using “wrapper” functions. So to implement the new node type, we start by writing a C++ class. The fundamental object in PyORBIT is a Bunch, which contains an array of phase space coordinates for each particle. The method below tracks a bunch instance over a distance length.
After compilation, the C++ class is accessible from the orbit.ext.kv_envelope module in Python scripts. To use this solver in simulations, we need a Python object that subclasses orbit.lattice.AccNodeBunchTracker, a base class for all PyORBIT node classes. This class just calls the KVEnvelopeSolver instance when it’s time to track the bunch.
I’ll now create a function to add a set of these nodes to an existing AccLattice objects. This function will split up the existing nodes as necessary to approach evenly-spaced KV envelope solver nodes. The function also sets the length of the space charge kick to match the subsequent node length.
Finally, we need a way to easily run simulations. I’ll first create a class called KVEnvelope that lets me set the distribution parameters, intensity, etc.
Finally, I’ll create a KVEnvelopeTracker class that modifies an existing lattice by adding envelope solver nodes and tracks the envelope.
class KVEnvelopeTracker:"""KV envelope tracker."""def__init__(self, lattice: AccLattice, path_length_max: float=None) ->None:self.lattice = latticeself.solver_nodes = add_kv_envelope_solver_nodes( lattice=self.lattice, path_length_min=1.00e-06, path_length_max=path_length_max, perveance=0.0, # will update based on envelope eps_x=1.0, # will update based on envelope eps_y=1.0, # will update based on envelope )# Upper/lower bounds on envelope parametersself.ub = np.full(4, +np.inf)self.lb = np.full(4, -np.inf)self.lb[0] =+1.00e-12self.lb[2] =+1.00e-12def toggle_solver_nodes(self, setting: bool) ->None:"""Turn solver nodes on/off."""for node inself.solver_nodes: node.active = settingdef update_solver_node_parameters(self, envelope: KVEnvelope) ->None:"""Update solver node parameters based on beam."""for solver_node inself.solver_nodes: solver_node.set_perveance(envelope.perveance) solver_node.set_emittances(envelope.eps_x *4.0, envelope.eps_y *4.0)def track(self, envelope: KVEnvelope, periods: int=1) ->dict[str, np.ndarray]:"""Track the envelope + distribution through the lattice."""self.update_solver_node_parameters(envelope) monitor = KVEnvelopeMonitor() action_container = AccActionsContainer() action_container.addAction(monitor, AccActionsContainer.ENTRANCE) action_container.addAction(monitor, AccActionsContainer.EXIT) bunch = envelope.to_bunch()for period inrange(periods):self.lattice.trackBunch(bunch, actionContainer=action_container) envelope.from_bunch(bunch) history = monitor.package()return historydef match_zero_sc(self, envelope: KVEnvelope) ->None:"""Match envelope to lattice without space charge."""self.toggle_solver_nodes(False) bunch = envelope.to_bunch(size=0, env=False) matrix_lattice = TEAPOT_MATRIX_Lattice(self.lattice, bunch) lattice_params = matrix_lattice.getRingParametersDict()self.toggle_solver_nodes(True) alpha_x = lattice_params["alpha x"] alpha_y = lattice_params["alpha y"] beta_x = lattice_params["beta x [m]"] beta_y = lattice_params["beta y [m]"] envelope.set_twiss(alpha_x, beta_x, alpha_y, beta_y)def match(self, envelope: DanilovEnvelope20, periods: int=1, **kwargs) ->None:"""Match envelope to lattice with space charge."""if envelope.perveance ==0.0:returnself.match_zero_sc(envelope)def loss_function(params: np.ndarray) -> np.ndarray: envelope.set_params(params) loss =0.0for period inrange(periods):self.track(envelope) residuals = envelope.params - params residuals =1000.0* residuals loss += np.mean(np.abs(residuals))return loss /float(periods) kwargs.setdefault("xtol", 1.00e-13) kwargs.setdefault("ftol", 1.00e-13) kwargs.setdefault("gtol", 1.00e-13) kwargs.setdefault("verbose", 2) result = scipy.optimize.least_squares( loss_function, envelope.params.copy(), bounds=(self.lb, self.ub), **kwargs )
I’ve also added capabilities to find the periodic envelope, i.e., an envelope which returns to its original state after one lattice period.
Benchmark against PIC solver
In the rest of this post, I’ll benchmark the KV envelope solver against direct particle-in-cell (PIC) calculations. Some utility functions are defined below to generate a FODO lattice for the benchmark.
Code
import numpy as npimport scipy.optimizefrom orbit.core.bunch import Bunchfrom orbit.core.bunch import BunchTwissAnalysisfrom orbit.lattice import AccLatticefrom orbit.lattice import AccNodefrom orbit.teapot import TEAPOT_Latticefrom orbit.teapot import TEAPOT_MATRIX_Latticefrom orbit.teapot import DriftTEAPOTfrom orbit.teapot import QuadTEAPOTdef split_node(node: AccNode, max_part_length: float=None) -> AccNode:if max_part_length:if node.getLength() > max_part_length: node.setnParts(1+int(node.getLength() / max_part_length))return nodedef compute_phase_advances(lattice: AccLattice, mass: float, kin_energy: float) -> np.ndarray: bunch = Bunch() bunch.mass(mass) bunch.getSyncParticle().kinEnergy(kin_energy) matrix_lattice = TEAPOT_MATRIX_Lattice(lattice, bunch) lattice_params = matrix_lattice.getRingParametersDict() phase_adv = [ lattice_params["fractional tune x"], lattice_params["fractional tune y"], ] phase_adv = np.array(phase_adv) phase_adv = phase_adv *2.0* np.pireturn phase_advdef make_fodo_lattice( phase_adv_x: float, phase_adv_y: float, length: float, mass: float, kin_energy: float, fill_factor: float=0.5, start: str="drift", fringe: bool=False, max_part_length: float=0.1, verbose: bool=False,) -> AccLattice:"""Create FODO lattice. Parameters ---------- phase_adv_x{y}: float The x{y} lattice phase advance [rad]. length : float The length of the lattice [m]. mass, kin_energy : float Mass [GeV/c^2] and kinetic energy [GeV] of synchronous particle. fill_fac : float The fraction of the lattice occupied by quadrupoles. fringe : bool Whether to include nonlinear fringe fields in the lattice. start : str If 'drift', the lattice will be O-F-O-O-D-O. If 'quad' the lattice will be (F/2)-O-O-D-O-O-(F/2). reverse : bool If True, reverse the lattice elements. Returns ------- TEAPOT_Lattice """def _make_lattice(k1: float, k2: float) -> AccLattice:"""Create FODO lattice with specified focusing strengths. k1 and k2 are the focusing strengths of the focusing (1st) and defocusing (2nd) quads, respectively. """# Instantiate elements lattice = TEAPOT_Lattice() drift1 = DriftTEAPOT("drift1") drift2 = DriftTEAPOT("drift2") drift_half1 = DriftTEAPOT("drift_half1") drift_half2 = DriftTEAPOT("drift_half2") qf = QuadTEAPOT("qf") qd = QuadTEAPOT("qd") qf_half1 = QuadTEAPOT("qf_half1") qf_half2 = QuadTEAPOT("qf_half2") qd_half1 = QuadTEAPOT("qd_half1") qd_half2 = QuadTEAPOT("qd_half2")# Set lengths half_nodes = (drift_half1, drift_half2, qf_half1, qf_half2, qd_half1, qd_half2) full_nodes = (drift1, drift2, qf, qd)for node in half_nodes: node.setLength(length * fill_factor /4.0)for node in full_nodes: node.setLength(length * fill_factor /2.0)# Set quad focusing strengthsfor node in (qf, qf_half1, qf_half2): node.addParam("kq", +k1)for node in (qd, qd_half1, qd_half2): node.addParam("kq", -k2)# Create latticeif start =="drift": lattice.addNode(drift_half1) lattice.addNode(qf) lattice.addNode(drift2) lattice.addNode(qd) lattice.addNode(drift_half2)elif start =="quad": lattice.addNode(qf_half1) lattice.addNode(drift1) lattice.addNode(qd) lattice.addNode(drift2) lattice.addNode(qf_half2)# Toggle fringe fieldsfor node in lattice.getNodes(): node.setUsageFringeFieldIN(fringe) node.setUsageFringeFieldOUT(fringe) lattice.initialize()return latticedef loss_function(k: np.ndarray) ->float: lattice = _make_lattice(k[0], k[1]) phase_adv_calc = compute_phase_advances(lattice, mass, kin_energy) phase_adv_targ = np.array([phase_adv_x, phase_adv_y])return np.abs(phase_adv_calc - phase_adv_targ) x0 = np.array([0.5, 0.5]) # ~ 80 deg phase advance result = scipy.optimize.least_squares(loss_function, x0, verbose=verbose) lattice = _make_lattice(*result.x)for node in lattice.getNodes(): node = split_node(node, max_part_length)if verbose: phase_adv_calc = compute_phase_advances(lattice, mass, kin_energy) phase_adv_targ = np.array([phase_adv_x, phase_adv_y]) phase_adv_calc *=180.0/ np.pi phase_adv_targ *=180.0/ np.piprint(f"phase_adv_x = {phase_adv_calc[0]} (target={phase_adv_targ[0]})")print(f"phase_adv_y = {phase_adv_calc[1]} (target={phase_adv_targ[1]})")return latticedef get_bunch_coords(bunch: Bunch) -> np.ndarray:"""Extract phase space coordinate array from bunch.""" X = np.zeros((bunch.getSize(), 6))for i inrange(bunch.getSize()): X[i, 0] = bunch.x(i) X[i, 1] = bunch.xp(i) X[i, 2] = bunch.y(i) X[i, 3] = bunch.yp(i) X[i, 4] = bunch.z(i) X[i, 5] = bunch.dE(i)return Xdef get_bunch_cov(bunch: Bunch) -> np.ndarray:"""Compute 6 x 6 covariance matrix from bunch.""" calc = BunchTwissAnalysis() calc.computeBunchMoments(bunch, 2, 0, 0) cov_matrix = np.zeros((6, 6))for i inrange(6):for j inrange(i +1): cov_matrix[i, j] = calc.getCorrelation(j, i) cov_matrix[j, i] = cov_matrix[i, j]return cov_matrixclass BunchMonitor:"""Monitors bunch during transport."""def__init__(self, verbose: int=1) ->None:self.distance =0.0self._pos_old =0.0self._pos_new =0.0self.verbose = verboseself.history = {}for key in ["s", "xrms", "yrms"]:self.history[key] = []def package_history(self) ->None: history = copy.deepcopy(self.history)for key in history: history[key] = np.array(history[key]) history["s"] -= history["s"][0]return historydef__call__(self, params_dict: dict) ->None: bunch = params_dict["bunch"] node = params_dict["node"]# Update tracking distanceself._pos_new = params_dict["path_length"]ifself._pos_old >self._pos_new:self._pos_old =0.0self.distance +=self._pos_new -self._pos_oldself._pos_old =self._pos_new# Store parameters cov_matrix = get_bunch_cov(bunch)self.history["s"].append(self.distance)self.history["xrms"].append(np.sqrt(cov_matrix[0, 0]))self.history["yrms"].append(np.sqrt(cov_matrix[2, 2]))
Okay, we’re ready to run the benchmark. We’ll consider a long bunch of protons at 1 GeV kinetic energy traveling through a FODO lattice of length 5 meters. The phase advance is 85 degrees in both planes. The covariance matrix of the transverse distribution is set to “match” the lattice without space charge; this means that, without space charge, the beam distribution returns to its initial state after one lattice period. With space charge, the beam will be mismatched. The longitudinal distribution is uniform in \(z\) with zero spread in particle energies, meaning that space charge forces only affect the transverse distribution.
We’ll track the bunch through five lattice periods and record the rms beam size as a function of distance, comparing the envelope solver predictions to PIC predictions. Since the space charge forces are completely transverse, we’ll use a 2D space charge solver.
Notice the space-charge-driven coupling between the beam size in either plane.
Conclusion
This envelope tracker implementation is a little hacky. For operational use, PyORBIT will eventually develop a more robust envelope tracker that integrates with the existing code base and can handle arbitrary coupling in the lattice and beam. For now, though, this tracker will be a very nice utility to benchmark PIC codes against envelope tracker assumptions. I also plan to use this tracker to reproduce particle-core resonance studies from older papers, with the hope of furhter developing these models and applying them to data from the SNS.
The envelope solver code is currently on this branch of my PyORBIT fork.