"""
This module contains the class defining a particle bunch.
"""
# TODO: clean methods to set and get bunch matrix
from __future__ import annotations
from copy import deepcopy
from typing import Optional
import numpy as np
import scipy.constants as ct
from aptools.plotting.quick_diagnostics import full_phase_space
from .push.runge_kutta_4 import apply_rk4_pusher
from .push.boris_pusher import apply_boris_pusher
[docs]
class ParticleBunch:
"""Defines a particle bunch.
Parameters
----------
w : ndarray
Weight of the macroparticles, i.e., the number of real particles
represented by each macroparticle. In practice, :math:`w = q_m / q`,
where :math:`q_m` and :math:`q` are, respectively the charge of the
macroparticle and of the real particle (e.g., an electron).
x, y, xi : ndarray
Position of the macropparticles in the x, y, and xi directions in
units of m.
px, py, pz : ndarray
Momentum of the macropparticles in the x, y, and z directions in
non-dimensional units (beta*gamma).
bunch_matrix : ndarray
6 x N matrix, where N is the number of particles, containing the
phase-space information of the bunch. If provided, the arguments x
to pz are not considered. The matrix contains (x, px, y, py, z, pz)
if matrix_type='standard' or (x, x', y, y', xi, dp) if
matrix_type='alternative'.
matrix_type : string
Indicates the type of bunch_matrix. Possible values are 'standard'
or 'alternative' (see above).
gamma_ref : float
Reference energy with respect to which the particle momentum dp is
calculated. Only needed if bunch_matrix is used and
matrix_type='alternative'.
tags : ndarray
Individual tags assigned to each particle.
prop_distance : float
Propagation distance of the bunch along the beamline.
t_flight : float
Time of flight of the bunch along the beamline.
z_injection: float
Particles have a ballistic motion for z<z_injection (in meters).
name : str
Name of the particle bunch. Used for species identification
in openPMD diagnostics.
q_species, m_species : float
Charge and mass of a single particle of the species represented
by the macroparticles. For an electron bunch (default),
``q_species=-e`` and ``m_species=m_e``
"""
_n_unnamed = 0 # Number of unnamed ParticleBunch instances
def __init__(
self,
w: np.ndarray,
x: Optional[np.ndarray] = None,
y: Optional[np.ndarray] = None,
xi: Optional[np.ndarray] = None,
px: Optional[np.ndarray] = None,
py: Optional[np.ndarray] = None,
pz: Optional[np.ndarray] = None,
bunch_matrix: Optional[np.ndarray] = None,
matrix_type: Optional[str] = "standard",
gamma_ref: Optional[float] = None,
tags: Optional[np.ndarray] = None,
prop_distance: Optional[float] = 0,
t_flight: Optional[float] = 0,
z_injection: Optional[float] = None,
name: Optional[str] = None,
q_species: Optional[float] = -ct.e,
m_species: Optional[float] = ct.m_e,
) -> None:
if bunch_matrix is not None:
if matrix_type == "standard":
self.set_phase_space_from_matrix(bunch_matrix)
elif matrix_type == "alternative":
self.set_phase_space_from_alternative_matrix(bunch_matrix, gamma_ref)
else:
self.x = x
self.y = y
self.xi = xi
self.px = px
self.py = py
self.pz = pz
self.w = w
# self.mu = 0
self.tags = tags
self.prop_distance = prop_distance
self.t_flight = t_flight
self.z_injection = z_injection
self.x_ref = 0
self.theta_ref = 0
self.set_name(name)
self.q_species = q_species
self.m_species = m_species
self.__field_arrays_allocated = False
self.__rk4_arrays_allocated = False
@property
def q(self) -> np.ndarray:
"""Get an array with the charge of each macroparticle.
This property is implemented for convenience and for backward
compatibility.
"""
return self.w * self.q_species
@q.setter
def q(self, q_new):
"""Set the total charge of each macroparticle.
This property is implemented for convenience and for backward
compatibility.
"""
self.w = np.abs(q_new / self.q_species)
[docs]
def set_name(self, name):
"""Set the particle bunch name"""
if name is None:
name = "elec_bunch_{}".format(ParticleBunch._n_unnamed)
ParticleBunch._n_unnamed += 1
self.name = name
[docs]
def set_phase_space(self, x, y, xi, px, py, pz):
"""Sets the phase space coordinates"""
self.x = x
self.y = y
self.xi = xi
self.px = px
self.py = py
self.pz = pz
[docs]
def set_phase_space_from_matrix(self, beam_matrix):
"""
Sets the phase space coordinates from a matrix with the values of
(x, px, y, py, xi, pz).
"""
self.x = beam_matrix[0]
self.y = beam_matrix[2]
self.xi = beam_matrix[4]
self.px = beam_matrix[1]
self.py = beam_matrix[3]
self.pz = beam_matrix[5]
[docs]
def set_phase_space_from_alternative_matrix(self, beam_matrix, gamma_ref):
"""
Sets the phase space coordinates from a matrix with the values of
(x, x', y, y', xi, dp).
Parameters
----------
bunch_matrix : array
6 x N matrix, where N is the number of particles, containing the
phase-space information of the bunch as (x, x', y, y', xi, dp) in
units of (m, rad, m, rad, m, -). dp is defined as
dp = (g-g_ref)/g_ref, while x' = px/p_kin and y' = py/p_kin, where
p_kin is the kinetic momentum of each particle.
gamma_ref : float
Reference energy with respect to which the particle momentum dp is
calculated.
"""
dp = beam_matrix[5]
gamma = (dp + 1) * gamma_ref
p_kin = np.sqrt(gamma**2 - 1)
self.x = beam_matrix[0]
self.px = beam_matrix[1] * p_kin
self.y = beam_matrix[2]
self.py = beam_matrix[3] * p_kin
self.xi = beam_matrix[4]
self.pz = np.sqrt(gamma**2 - self.px**2 - self.py**2 - 1)
[docs]
def set_bunch_matrix(self, beam_matrix):
"""Sets the 6D phase space and charge of the bunch"""
self.x = beam_matrix[0]
self.y = beam_matrix[1]
self.xi = beam_matrix[2]
self.px = beam_matrix[3]
self.py = beam_matrix[4]
self.pz = beam_matrix[5]
self.w = beam_matrix[6] / self.q_species
[docs]
def get_bunch_matrix(self):
"""Returns a matrix with the 6D phase space and charge of the bunch"""
return np.array(
[
self.x,
self.y,
self.xi,
self.px,
self.py,
self.pz,
self.w * self.q_species,
]
)
[docs]
def get_6D_matrix(self):
"""
Returns the 6D phase space matrix of the bunch containing
(x, px, y, py, xi, pz)
"""
return np.array([self.x, self.px, self.y, self.py, self.xi, self.pz])
[docs]
def get_6D_matrix_with_charge(self):
"""
Returns the 6D phase space matrix of the bunch containing
(x, px, y, py, xi, pz)
"""
return np.array(
[
self.x,
self.px,
self.y,
self.py,
self.xi,
self.pz,
self.w * self.q_species,
]
)
[docs]
def get_alternative_6D_matrix(self):
"""
Returns the 6D matrix of the bunch containing
(x, x', y, y', xi, dp)
"""
g = np.sqrt(1 + self.px**2 + self.py**2 + self.pz**2)
g_avg = np.average(g, weights=self.w)
b_avg = np.sqrt(1 - g_avg ** (-2))
dp = (g - g_avg) / (g_avg * b_avg)
p_kin = np.sqrt(g**2 - 1)
return np.array(
[self.x, self.px / p_kin, self.y, self.py / p_kin, self.xi, dp]
), g_avg
[docs]
def increase_prop_distance(self, dist):
"""Increases the propagation distance"""
self.prop_distance += dist
[docs]
def reposition_xi(self, xi_c):
"""Recenter bunch along xi around the specified xi_c"""
current_xi_c = np.average(self.xi, weights=self.w)
dxi = xi_c - current_xi_c
self.xi += dxi
[docs]
def get_openpmd_diagnostics_data(self, global_time):
"""
Returns a dictionary with the necessary data to write the openPMD
diagnostics of the particle bunch.
"""
diag_dict = {
"x": self.x,
"y": self.y,
"z": self.xi,
"px": self.px * self.m_species * ct.c,
"py": self.py * self.m_species * ct.c,
"pz": self.pz * self.m_species * ct.c,
"w": self.w,
"q": self.q_species,
"m": self.m_species,
"name": self.name,
"z_off": global_time * ct.c,
"geometry": "3d_cartesian",
}
if self.tags is not None:
diag_dict["id"] = self.tags
return diag_dict
[docs]
def show(self, **kwargs):
"""Show the phase space of the bunch in all dimensions."""
full_phase_space(
self.x,
self.y,
self.xi,
self.px,
self.py,
self.pz,
self.w * self.q_species,
show=True,
**kwargs,
)
[docs]
def evolve(self, fields, t, dt, pusher="rk4"):
"""Evolve particle bunch to the next time step.
Parameters
----------
fields : list
List of fields in which to evolve the particle bunch.
t : float
The current time.
dt : float
Time step by which to evolve the bunch.
pusher : str, optional
The particle pusher to use. Either 'rk4' or 'boris'. By
default 'rk4'.
"""
if self.z_injection is not None:
if (np.amax(self.xi) + self.prop_distance) < self.z_injection:
fields = []
if pusher == "rk4":
apply_rk4_pusher(self, fields, t, dt)
elif pusher == "boris":
apply_boris_pusher(self, fields, t, dt)
else:
raise ValueError(
f"Bunch pusher '{pusher}' not recognized. "
"Possible values are 'boris' and 'rk4'"
)
self.prop_distance += dt * ct.c
[docs]
def copy(self) -> ParticleBunch:
"""Return a copy of the bunch.
To improve performance, this copy won't contain copies of auxiliary
arrays, only of the particle coordinates and properties.
"""
bunch_copy = ParticleBunch(
w=deepcopy(self.w),
x=deepcopy(self.x),
y=deepcopy(self.y),
xi=deepcopy(self.xi),
px=deepcopy(self.px),
py=deepcopy(self.py),
pz=deepcopy(self.pz),
prop_distance=deepcopy(self.prop_distance),
name=deepcopy(self.name),
q_species=deepcopy(self.q_species),
m_species=deepcopy(self.m_species),
tags=deepcopy(self.tags),
)
bunch_copy.x_ref = self.x_ref
bunch_copy.theta_ref = self.theta_ref
return bunch_copy
[docs]
def get_field_arrays(self):
"""Get the arrays where the gathered fields will be stored."""
if not self.__field_arrays_allocated:
self.__preallocate_field_arrays()
return (self.__e_x, self.__e_y, self.__e_z, self.__b_x, self.__b_y, self.__b_z)
[docs]
def get_rk4_arrays(self):
"""Get the arrays needed by the RK4 pusher."""
if not self.__rk4_arrays_allocated:
self.__preallocate_rk4_arrays()
return (
self.__x_rk4,
self.__y_rk4,
self.__xi_rk4,
self.__px_rk4,
self.__py_rk4,
self.__pz_rk4,
self.__dx_rk4,
self.__dy_rk4,
self.__dxi_rk4,
self.__dpx_rk4,
self.__dpy_rk4,
self.__dpz_rk4,
self.__k_x,
self.__k_y,
self.__k_xi,
self.__k_px,
self.__k_py,
self.__k_pz,
)
def __preallocate_field_arrays(self):
"""Preallocate the arrays where the gathered fields will be stored."""
n_part = len(self.x)
self.__e_x = np.zeros(n_part)
self.__e_y = np.zeros(n_part)
self.__e_z = np.zeros(n_part)
self.__b_x = np.zeros(n_part)
self.__b_y = np.zeros(n_part)
self.__b_z = np.zeros(n_part)
self.__field_arrays_allocated = True
def __preallocate_rk4_arrays(self):
"""Preallocate the arrays needed by the RK4 pusher."""
n_part = len(self.x)
self.__k_x = np.zeros(n_part)
self.__k_y = np.zeros(n_part)
self.__k_xi = np.zeros(n_part)
self.__k_px = np.zeros(n_part)
self.__k_py = np.zeros(n_part)
self.__k_pz = np.zeros(n_part)
self.__x_rk4 = np.zeros(n_part)
self.__y_rk4 = np.zeros(n_part)
self.__xi_rk4 = np.zeros(n_part)
self.__px_rk4 = np.zeros(n_part)
self.__py_rk4 = np.zeros(n_part)
self.__pz_rk4 = np.zeros(n_part)
self.__dx_rk4 = np.zeros(n_part)
self.__dy_rk4 = np.zeros(n_part)
self.__dxi_rk4 = np.zeros(n_part)
self.__dpx_rk4 = np.zeros(n_part)
self.__dpy_rk4 = np.zeros(n_part)
self.__dpz_rk4 = np.zeros(n_part)
self.__rk4_arrays_allocated = True