🕹️ User’s Guide

This section aims to showcase wakiscapabilities together with useful recipes to use in the simulation scripts.

Since wakis has been developed for computing beam-coupling impedance for particle accelerator components, the example that will serve as a conductive thread for the explanation is a pillbox cavity with a passing proton beam.

The guide will go into detailed step-by-step on how to write the simulation script and visualize or access the computed data.

🪜 Simulation step-by-step

Import modules

The first part of a python script always includes importing external sources of code e.g., packages or modules. In wakis, we use:

  • numpy: Used for numerical operations, especially for matrix operations.

  • scipy.constants: to import physical constants easily like vacuum permittivity eps_0 or the speed of light c

  • matplotlib: Used for 1d and 2d plotting and visualization.

  • h5py: To store data in the memory-efficient format HDF5

  • tqdm: This package is used for displaying progress bars in loops.

  • pyvista: For handling and visualizing 3D CAD geometries and vtk-based 3D plotting.

Optionally, one can use os or sys packages to handle the PATH and directory creations. The first part of any simulaiton script could look similar to:

import numpy as np               # for handling arrays
import matplotlib.pyplot as plt  # for plotting
import os, sys                   # for path handling
import pyvista as pv             # for geometry modelling and 3D visualization
import h5py                      # for data save/import
from tqdm import tqdm            # for time loop prpgress bar
from scipy.constants import c as c_light    # speed of light constant

Next step is to import the wakis classes that will allow to run the electromagnetic simulations:

from wakis import GridFIT3D       # Grid generation
from wakis import SolverFIT3D     # EM field simulation
from wakis import WakeSolver      # Wake and Impedance calculation

Simulation domain, geometry and materials setup

wakis is a numerical electromagnetic solver that uses the Finite Integration Technique. The grid used is a structured grid composed by rectangular cells. The simulation domain is a rectangular box that will be broken into cells where the electromagnetic fields will be computed. Notice that all the input parameters for Wakis must be in SI Base Units.

Number of mesh cells

In every wakis simulation example, the script starts specifying the number of mesh cells per direction the user wants to use:

# ---------- Domain setup ---------
# Number of mesh cells
Nx = 50
Ny = 50
Nz = 100

Note

Note that the number of cells will heavily affect the simulation time (in particular, the timestep due to CFL condition), and also the memory requirements.

STL geometry importing

In beam-coupling impedance simulations one is usually interested in the geometric impedance, together with the impedance coming from material properties. In wakis, the geometry to simulate (sometimes referred as Embedded boundaries) can be imported from a .stl file containing a CAD model, and the units should be meters [m].

# stl geometry files (add path to them if necessary)
stl_cavity = 'cavity.stl'
stl_shell = 'shell.stl'
stl_solids = {'cavity': stl_cavity, 'shell': stl_shell}

# Optional: plot the geometry imported using PyVista
geometry = pv.read(stl_shell) + pv.read(stl_cavity)
geometry.plot() # add pyvista **kwargs to make a fancier plot

geometry plot example using a few pyvista **kwargs

Some example .stl files are available in the GitHub repository for the notebooks/ and examples folders. However, PyVista allows to generate importable geometry really easy through Constructive Solid Geometry (CSG). CSG is a modeling technique where complex geometries are built by combining simple primitives (like cubes, spheres, cylinders, cones) using Boolean operations: Union (+) combines two solids, Intersection (*) keeps only the overlapping volume or Difference (−): subtracts one solid from another. An example on how to generate a pillbox cavity using CSG is shown bellow:

import pyvista as pv

# create 2 cylinders oriented in the z-direction
pipe = pv.Cylinder(direction=(0,0,1.), radius=0.12, height=1)
cavity = pv.Cylinder(direction=(0,0,1.), radius=0.5, height=0.3)

# combine them and plot
surf = pipe + cavity
surf.plot()

# save
surf.save('geometry.stl', binary=False)

See also

Check PyVista’s documentation for more advanced 3d plotting

Imported .stl files can also be translated, rotated and scaled in x, y, z by providing a list. E.g., stl_scale['cavity'] = [1., 2, 1.] will duplicate the y dimension of the imported cavity.stl.

stl_scale = {'cavity': [1., 1., 2.], 'shell': [1., 1., 2.]} # scale factor
stl_rotate = {'cavity': [1., 1., 90.], 'shell': [1., 1., 90.]}  # rotate angle (degrees)
stl_translate = {'cavity': [1., 0, 0], 'shell': [1., 0, 0]} # displacement in [m]

Associating a material to each solid

Each stl solid can be associated with a material by indicating [eps, mu, conductivity]. In materials.py, the most used materials are available in the material library. The user can specify a value for the relative permittivity \(\varepsilon\), the relative permeability \(\mu\) and the value for the conductivity \(\sigma\) in [S/m] or use the material name from the library:

# Indicate relative permittivity, relative permeability and conductivity [S/m]
# [eps_r, mu_r, conductivity]
# or the material name from the library:
stl_materials = {'cavity': 'vacuum',       # equivalent to [1.0, 1.0, 0]
                'shell': [1e3, 1.0, 1e3]}  # equivalent to a 'lossy metal'

Defining the grid object

After this, the user needs to indicate the domain bounds to simulate, in [m]. They can be calculated from the imported geometry or just hardcoded with float values:

# Domain bounds
geometry = pv.read(stl_shell) + pv.read(stl_cavity)
xmin, xmax, ymin, ymax, zmin, zmax = geometry.bounds

Finally, we can build our simulation grid using GridFIT3D class including all the information above:

# initialize grid object
grid = GridFIT3D(xmin, xmax, ymin, ymax, zmin, zmax,
                Nx, Ny, Nz,
                stl_solids=stl_solids,
                stl_materials=stl_materials,
                stl_translate=stl_translate,
                stl_scale=stl_scale,
                stl_rotate=stl_rotate,
                )

Optionally, the grid of the simulation can be inspected interactively in 3D (thanks to PyVista):

grid.inspect(add_stl=['cavity', 'shell'] #default is all stl solids
             stl_opacity=0.5             #default is 0.5
             stl_colors=['blue', 'red']) #default is `white`

Gif showing how the grid.inspect() method works

wakis API is fully exposed, so all the grid class parameters relevant for the simulation can be accessed as class attributes and modified once the class has been instantiated. E.g., grid.dx gives the mesh step size in x direction, grid.N gives the total number of cells. Attributes can be checked by typing grid. and then pressing TAB when running on ipython.

Tip

Thanks to a fully exposed python API, all (relevant) class attributes can be checked by typing the class instance (e.g., grid or solver) followed a dot . and the name of the attribute.

When running on ipython all attributes and functions() can be viewed by typing the name of the class instance + . and then pressing TAB. Similarly, when writing code on a IDE (e.g., Visual Studio), a list with all the attributes and functions will be shown when typing e.g., solver..

Setting up the electromagnetic solver

Once the simulation domain has been defined through the geometry and the grid, the electromagnetic (EM) solver can be created. The solver implemented in wakis uses the Finite Integration Technique (FIT) [^1]. The recipe on how to instantiate the SolverFIT3D class is given below:

solver = SolverFIT3D(grid=grid,     # pass grid object
                     dt=dt,         # (OPTIONAL) define timestep
                     cfl=0.5,       # Default if no dt is defined
                     bc_low=bc_low,
                     bc_high=bc_high,
                     use_stl=True,     # Enables or disables geometry import
                     bg='vacuum',      # Background material
                     )

Simulation timestep

The simulation timestep is calculated following the CFL condition, that mainly depends on the cell size. A default value of 0.5 ensures simulation stability:

# Extract of SolverFIT3D __init__
self.dt = cfln / (c_light * np.sqrt(1 / self.grid.dx ** 2 + 1 / self.grid.dy ** 2 +
                                            1 / self.grid.dz ** 2))

Boundary conditions

The required parameters bc_low and bc_high allow to choose the 6 boundary conditions for our simulation box. For the lower-end boundaries bc_low = [x-, y-, z-] and for the high-end boundaries bc_high = [x+, y+, z+] is used. The supported values to give for -/+ boundaries are:

  • pec stands for Perfect Electric Conductor: it is a Dirichlet boundary conditions that forces the tangent electric \(E\) field at the boundary to be 0.

  • pmc stands for Perfect Magnetic Conductor: similarly to pec, it is a Dirichlet boundary conditions that forces the tangent magnetic \(H\) field at the boundary to be 0.

  • periodic: The field values of the high-end boundary are passed to the lower-end boundary, simulating a periodic structure where the fields re-enter the simulation domain.

  • abc First order extrapolation (FOEXTRAP) absorbing boundary condition (ABC)[^2]: a type of Dirichlet absorbing boundary condition that allows to absorb longitudinally propagating fields when they reach the boundary. Only works under specific circumstances.

  • pml Perfect Matching Layer: A more advanced type of ABC, capable of absorbing electromagnetic waves propagating in a wider range of propagation angles [^3]. This boundary conditions are needed e.g., for simulating accelerator beampipe transitions and RF cavities above beampipe cutoff. Currently under development

An example of the boundary conditions that are typically used for the pillbox cavity example are:

# boundary conditions
bc_low=['pec', 'pec', 'pml']  #or z=`pec` if below cutoff
bc_high=['pec', 'pec', 'pml'] #or z=`pec` if below cutoff

Accessing solver’s fields, matrices and simulation parameters

Similarly to the grid object from GridFIT3D, wakis’s SolverFIT3D class is fully exposed. This means that once the solver object is instantiated, all the parameters of the EM solver can be accessed as class attributes solver.attr and modified. E.g., one can modify the simulation timestep after the instantiation by doing:

solver.dt = 1e-9 #[s]

Electromagnetic fields E, J, H

Once the solver has been instantiated, the electromagnetic fields electric \(E\), magnetic \(H\) and current \(J\) can be accessed and modified to e.g., add initial conditions to the simulation. The fields are 3D vectorial matrices of sizes [Nx, Ny, Nz]x3. The times 3 comes from their vectorial nature, since there are values for each simulation cell in \(x\), \(y\), and \(z\) direction. Below an example on how to access the electric field component \(E_z\) to add an initial condition to the field:

# modify the lower left bottom corner (cell [0,0,0]) of the simulation domain.
solver.E[0,0,0,'z'] = c_light

# modify the x component of H for a XY plane at a particular z
iz = Nz//3
solver.H[:, :, iz, 'x'] = np.ones((Nx, Ny))

# modify the y component of the J on the z axis at a particular x, y
ix, iy = Nx//2, Ny//2
solver.J[ix, iy, :,'y']

# get the absolute value of the electric field
E_abs = solver.E.get_abs() #size [Nx, Ny, Nz]

The routines inspect() for 2D and inspect3d() allow for quick visualization of the field values:

solver.E.inspect(plane='YZ',            # 2d plane, cut at the domain center
                 cmap='bwr',            # colormap
                 dpi=100,               # plot DPI value (pixel resolution)
                 # also, the plane can be specified as a 2D slice in x,y,z
                 # e.g., for the YZ plane at x = Nx//2
                 x=Nx//2,
                 y=slice(0,Ny),
                 z=slice(0,Nz)
                )

Material tensors ieps, imu, sigma

The same applies for the material tensors for permittivity \(\varepsilon\), permeability \(\mu\), and conductivity \(\sigma\). For computational cost reasons, the values saved in memory correspond to \(\varepsilon^{-1}\) and \(\mu^{-1}\). To access a specific value or a slice of values (and modify them if desired):

# permittivity(^-1) tensor in x direction
solver.ieps[:, :, :, 'x']

# permeability(^-1) tensor in y direction
solver.imu[:, :, :, 'y']

# Modify first 10 cells in x of the conductivity tensor:
for d in ['x', 'y', 'z']:
    solver.sigma[:10, :, :, d] = np.ones(10)*1e3 #S/m

Since wakis supports anisotropy, the tensors are 3D matrices of sizes [Nx, Ny, Nz]x3 since there are values for each simulation cell in \(x\), \(y\), and \(z\) direction. Similarly, one can inspect the values given to the tensors by using e.g., solver.sigma.inspect()

Tip

Material tensors \(\varepsilon\), \(\mu\), and \(\sigma\), and electromagnetic fields \(E\), \(H\) and \(J\) are created as Field objects, the class in field.py. This class allows for optimized access to matrix values, conversion to array format using the lexico-grapihc index and inspection methods ìnspect()` via 2D plots to confirm that the tensor are built correctly before running the simulation.

Running a simulation

Once the domain and geometry are defined in grid and the fields and internal operators have been instantiated with solver, an electromagnetic time-domain simulation can be run provided some initial conditions. The simplest way to run the code, step-by-step, is by calling the routine solver.one_setp():

# Run one step (advance from t=0 to t=dt)
solver.one_step()

# Run a number of timesteps while modifying the fields
hf = h5py.File('results/Ex.h5', 'w')
Nt = 100
for n in tqdm(range(Nt)):

    # [OPTIONAL] Modify field
    #source_fun(t) can be any function, see next section
    solver.E[:, :, :, 'x'] = source_fun(n*dt)

    # Advance
    solver.one_step()

    # [OPTIONAL] Plot 2D on-the-fly. -->See dedicated section.
    solver.plot2D(field='E', component='x', plane='ZY', pos=0.5, #
                  cmap='rainbow', title='img/Ex', off_screen=True,
                  n=n, interpolation='spline36')

    # [OPTIONAL] Save in hdf5 format
    hf['#'+str(n).zfill(5)]=solver.E[Nx//2, :, :, 'x']

A more optimized solution for running a EM simulation is the routine solver.emsolve(). This removes the need of the loop, while still allows to use built-in plotting routines and saving the fields at any timestep though the input arguments:

# emsolve function call example
Nt = 10000              # Number of timesteps to run
source = sources.Beam() # [OPTIONAL] ->See next section for details

solver.emsolve(Nt, source=source, # [OPT] field or current time-dependent source
            save=False, fields=['E'], components=['Abs'], # [OPT] field components to save
            every=1, subdomain=None, # [OPT] frequency of save and subdomain slice [x,y,z]
            plot=False, plot_every=1,  # [OPT] on-the-fly plot enable and frequency
            plot3d=False, # [OPT] use 3D plot instead of 2D
            **kwargs) # [OPT] plot arguments. ->See built-in plotting section for details

Tip

All the functions inside wakis are documented with docstrings explaining each input parameter. To access it (after instantiating the class) simply type a question mark at the end, e.g.: solver.emsolve? or solver.plot3D?

Adding other time-dependent sources

In most time-domain simulations, when the field is not excited by initial conditions, a time-dependent source is used that to update the field values at a particular region in the simulation domain. In wakis, several time-dependent sources are available inside sources.py. They can be passed to the solver object in order for them to be added to the time-stepping routine.

Sources in wakis can be:

  • Point sources: only modifying the field or field component at one specific location \((x_s, y_s, z_s)\)

  • Line sources: modifying the field over a line \((x_s, y_s, \forall z)\)

  • Plane or port sources: modifying the field on a 2d plane e.g., \(z=z_s \forall x,y\)

  • Volume sources: modify the field in a 3d subdomain: e.g., \(z=slice(0, Nz-30) \forall x,y\).

The sources can modify any component of the \(E\), \(H\) fields or the current \(J\), and be introduced in the simulation as a callback

To add a time-dependent source, one can simply setup a time-loop and run the routine solver.one_step() after the source has been applied (see Running a simulation section). However, a more optimized way is to pass a source to the EM solver. The source objects available inside sources.py are:

  • Beam: a line source for \(J_z\) that adds a gaussian-shaped current traversing the domain from z- to z+. Beam’s longitudinal size \(sigma_z\) and peak current \(q\), as well as transverse position, can be defined as class attributes during instantiation.

  • PlaneWave: a port source that excites a sinusoidal plane wave n +z direction by modifying \(E_x\) and \(H_y\) in the XY plane. Plane wave’s frequency \(f\), longitudinal position \(z_s\), and plane extent \((\bold{x_s}, \bold{y_s})\) an be defined as class attributes.

  • WavePacket: a port source that excites a gaussian wave packet that travels in z+ direction, by modifying \(H_y\) and \(E_x\) field components. The frequency \(f\) or wavelength \(\lambda\), longitudinal size \(sigma_z\), transverse size \(sigma_{xy}\) and propagation speed (relativistic \(\beta\)), can be defined as class attributes.

  • Dipole: Updates the user-defined field and component every timestep to introduce a dipole-like sinusoidal excitation

  • Pulse: Injects an electromagnetic pulse at the given source point (xs, ys, zs), with the selected shape {“Harris”, “Gaussian”, “Rectangular”}, length, and amplitude

The user can easily add a CustomSource by following this pseudocode recipe:

class CustomSource:
    def __init__(self, attr1, attr2):
        '''
        Docstring
        '''

        # Class initialization of attributes

        self.attr1 = attr1
        self.attr2 = attr2


    def update(self, solver, t):
        # solver will be a `SolverFIT3D` class object
        # Here goes the code that should be run every timestep

        # e.g., point source at the 10th cell of the domain in x,y,z on Ex
        solver.E[10,10,10,'x'] = self.attr1*t

        # e.g., line source for all z at domain center in xy, on Jz
        solver.J[solver.Nx//2,solver.Ny//2,:,'z'] = self.attr1*solver.z*t

        # e.g., port source on XY plane at first cell 0 in z, on Hy
        X, Y = np.meshgrid(solver.x, solver.y)
        solver.H[:,:,0,'y'] = np.exp(-(X**2+Y**2)/self.attr1)*t

Combining wakis sources, geometry capabilities and material tensors, many different physical phenomena can be simulated with wakis: laser pulses, interaction with plasma, waveguides… For particle accelerators, the Beam class was created but the profile and trajectory can be easily modified to simulate advanced impedance effects.

🕹️ Using wakis as a Wakefield solver for beam-coupling impedance

A few words about beam-coupling impedance and wakefields

The determination of electromagnetic wakefields and their impact on accelerator performance is a significant issue in current accelerator components. These wakefields, which are generated within the accelerator vacuum chamber as a result of the interaction between the structure and a passing beam, can have significant effects on the machine.

These effects can be characterized through the beam coupling impedance in the frequency domain, and wake potential in the time domain. Accurate evaluation of these properties is essential for predicting dissipated power and maintaining beam stability. wakis project was conceived at CERN, in the ABP-CEI group, to provide the accelerator community with a python based, open-source tool able to compute the beam-coupling impedance for present and future accelerator components.

Setting up the wake solver

wakis can compute wake potential and impedance for both longitudinal and transverse planes for general 3D structures. The wake computation is performed right after the electromagnetic simulation, and the dedicated routines are encapsulated in the class WakeSolver, inside wakeSolver.py. The recipe on how to instantiate the WakeSolverclass is:

wake = WakeSolver(q=q, # beam charge in Coulombs [C]
                 sigmaz=sigmaz, # beam longitudinal sigma in [m]
                 beta=beta,     # beam relativistic beta
                 xsource=xs, ysource=ys, # beam transverse source position (DIPOLAR)
                 xtest=xt, ytest=yt,     # beam transverse integration path (QUADRUPOLAR)
                 add_space=add_space,    # remove no. cells in z- and z+ from the wake integration
                 save=True, logfile=True # save results in txt format and enable logfile
                 results_folder='results/',   # Name of the results folder
                 Ez_file='Ez.h5',        # Name of the HDF5 file to store E field
                 )

As can be deduced from the instantiation, the wake object contains the information of the beam source. When wake is passed to the solver object, a Beam source will be automatically added using wake attributes.

Tip

The attribute add_space is a very useful addition to improve the wake potential calculation. it removes the specified no. of cells from the integration path e.g., add_space=10 removes the last 10 cells in z- and in z+. This allows to remove some perturbations caused by the beam injection or some unwanted reflections from the domain boundaries.

Running a wakefield simulation

A wakefield simulation can be run using the solver.wakesolve routine. Similarly to emsolve it will run the electromagnetic simulation until a desired wakelength is reached. Then, the wake potential and impedance in longitudinal and transverse planes will be computed from the field saved in Ez.h5 file:

solver.wakesolve(wakelength, # Simulation wakelength in [m]
                wake=wake,   # wake object of WakeSolver class
                add_space=add_space,
                save_J=True,   # [OPT] Save source current Jz in HDF5 format
                plot=False, plot_every=30, # [OPT] Enable 2Dplot and plot frequency
                **plotkw, # [OPT] plot arguments. ->See built-in plotting section for details
                )

Recomputing some magnitudes

Once the simulation is finished and the data is safely stored in the HDF5 file, any result of wake potential and/or impedance can be re-computed and optimized. For instance, the longitudinal or transverse impedance can be recomputed using a higher number of samples or a different maximum frequency:

# re-calculate longitudinal impedance from the wake.WP result
wake.calc_long_Z(samples=10001, fmax=2e9)
plt.plot(wake.f, np.real(wake.Z)) #plot real part
plt.plot(wake.f, np.imag(wake.Z)) #plot imaginary part
plt.plot(wake.f, np.abs(wake.Z))  #plot absolute

# re-calculate transverse impedance from the wake.WPx and WPy result
wake.calc_trans_Z(samples=10001, fmax=2e9)
plt.plot(wake.f, np.abs(wake.Zx)) #plot absolute of Zx
plt.plot(wake.f, np.abs(wake.Zy)) #plot absolute of Zy

📈 Extrapolate a partially decayed wake with IDDEFIX

IDDEFIX is a physics-informed evolutionary optimization framework that fits a resonator-based model (parameterized by R, f, Q) to wakefield simulation data. It leverages Differential Evolution to optimize these parameters, enabling efficient classification and extrapolation of electromagnetic wakefield behavior. This allows for reduced simulation time while maintaining long-term accuracy, akin to time-series forecasting in machine learning. Developed by Sebastien Joly and Malthe Raschke, it is part of the Wakis ecosystem of python packages developed at CERN, and available in the ImpedanCEI organization.

See also

Specific documentation for IDDEFIX is available at http://iddefix.readthedocs.io/

Import simulation results

First, we import iddefix and load previous Wakis wakefield results:

import wakis
import iddefix

# Load partially decayed wake results
wake30 = wakis.WakeSolver()
wake30.load_results('results_wl30/')
wake_length = 30 # [m]

# Plot imported results
fig, ax = plt.subplots()
ax.plot(wake30.s, wake30.WP, c='tab:red', label='Wakelength = 10 m')
ax.set_xlabel('s [m]')

One can recompute impedance from the partially decayed wakes using iddefix.compute_fft() and compute_deconvolution() routines. Note that Wakis simulations compute the Wake potential, since the excitation is not a delta but a distributed current source (gaussian bunch). Therefore, to get the impedance, a deconvolution is needed:

fig, ax = plt.subplots()

# Plot wakis impedance
ax.plot(wake30.f, np.abs(wake30.Z), c='k', lw=2, label='Impedance from Wakis')

# Plot iddefix FFT and deconvolution results
f, WP_fft = iddefix.compute_fft(wake30.s/c, wake30.WP*1e12/c, fmax=1.5e9)
ax.plot(f, np.abs(WP_fft), c='tab:red', alpha=0.7, label='Impedance from FFT')
f, Z = iddefix.compute_deconvolution(wake30.s/c, wake30.WP*1e12/c, fmax=1.5e9, sigma=10e-2/c)
ax.plot(f, np.abs(Z), c='tab:green', alpha=0.7, label='Impedance from deconvolution')
ax.set_xlabel('frequency [Hz]')
ax.legend()

Parameter bounds determination

To estimate the parameter bounds of the resonators in the impedance data, one can use the SmartBoundDetermination class:

# Compute impedance through deconvolution
# To improve algorithm speed and convergence, it is advised to keep the data to about 1000 samples
f,Z = iddefix.compute_deconvolution(wake30.s/c, wake30.WP*1e12/c, samples=1000, fmax=1.2e9, sigma=10e-2/c)
Z *= -1.0 # longitudinal impedance normalization

# Control the heigths to be passed to the peak finder routine
heights = np.zeros_like(Z)
heights[:] = 450
heights[np.logical_and(f>0.70e9,f<0.8e9)] = 3000

bounds = iddefix.SmartBoundDetermination(f, np.real(Z),
                                        Rs_bounds=[0.8, 10],    # bound multipliers for peak Rs
                                        Q_bounds=[0.5, 5],      # bound multipliers for estimated Q
                                        fres_bounds=[-0.01e9, +0.01e9]) # bound margins for estimated fres

bounds.find(minimum_peak_height=heights, distance=10 )
bounds.inspect()        # plot the found peaks and estimated Q
bounds.to_table()       # print as a table the estimated bounds

Run differential evolution (DE) and minimization

Now it is time to pass the data to the EvolutionaryAlgorithm class. The available fit founctions use the Broadband Resonator Formalism, and the evolutionary algorithm find the list of parameters (Rs, Q, fr) that better describe the impedance. To run the Differential Evolution algorithm, follow:

%%time
DE_model = iddefix.EvolutionaryAlgorithm(f,
                                         Z.real,
                                         N_resonators=bounds.N_resonators,
                                         parameterBounds=bounds.parameterBounds,
                                         plane='longitudinal',
                                         fitFunction='impedance',
                                         wake_length=wake_length, # in [m]
                                         objectiveFunction=iddefix.ObjectiveFunctions.sumOfSquaredErrorReal
                                         )

# Run the differential evolution
DE_model.run_differential_evolution(maxiter=30000,
                                    popsize=150,
                                    tol=0.001,
                                    mutation=(0.3, 0.8),
                                    crossover_rate=0.5)
print(DE_model.warning)

Additionally, we can ran a second-step optimization using the Nelder-Mead minimization for local refinement:

DE_model.run_minimization_algorithm()

Asses the DE fitting

To asses the fitting, one can compare the partially decayed with the analytically fitted one:

#%matplotlib ipympl

# Retrieve partially decayed wake portential (30 m)
WP_pd = DE_model.get_wake_potential(wake30.s/c)

# Retreieve partially decayed  fittted impedance
f_pd = np.linspace(0, 1.2e9, 10000)
Z_pd = DE_model.get_impedance_from_fitFunction(f_pd, use_minimization=False)  # use only evolutionary parameters
Z_pd_min = DE_model.get_impedance_from_fitFunction(f_pd, use_minimization=True) # use minimization parameters

# Plot comparison
fig1, ax = plt.subplots(1,2, figsize=[12,4], dpi=150)
ax[0].plot(wake30.s, wake30.WP, c='k', alpha=0.8,label='Wakis wl=30 m')
ax[0].plot(wake30.s, -WP_pd*1e-12, c='tab:red', lw=1.5, label='iddefix')
ax[0].set_xlabel('s [cm]')
ax[0].set_ylabel('Longitudinal wake potential [V/pC]', color='tab:red')
ax[0].legend()

#ax[1].plot(wake30.f*1e-9, np.abs(wake30.Z), c='k', label='Wakis wl=30 m')
ax[1].plot(wake30.f*1e-9, np.real(wake30.Z), ls='-', c='k', lw=1.5, label=' Wakis wl=30 m Real')
#ax[1].plot(wake30.f*1e-9, np.imag(wake30.Z), ls=':', c='k', lw=1.5, label='Imag')

ax[1].plot(f_pd*1e-9, np.real(Z_pd), ls='-', c='tab:blue', alpha=0.8, lw=1.5, label='DE wl=30 m Real')
#ax[1].plot(f_pd*1e-9, np.imag(Z_pd), ls=':', c='tab:blue', alpha=0.6, lw=1.5, label='DE Imag')

ax[1].plot(f_pd*1e-9, np.real(Z_pd_min), ls='-', c='tab:red', alpha=0.6, lw=1.5, label='DE+min wl=30 m Real')
#ax[1].plot(f_pd*1e-9, np.imag(Z_pd_min), ls=':', c='tab:red', alpha=0.6, lw=1.5, label='DE+min Imag')

ax[1].set_xlabel('f [GHz]')
ax[1].set_ylabel('Longitudinal impedance [Abs][$\Omega$]', color='tab:blue')
ax[1].legend()

fig1.tight_layout()

Fully decayed impedance

Once the fitting is satisfactory, the fully decayed impedance can be computed via de Broadband resonator formalism using the (Rs, Q, fr) parameters fitted with the differential evolution:

# Fully decayed wake, wakelength 1000 m
t_fd = np.linspace(wake30.s[0]/c, 1000/c, 10000)
WP_fd = DE_model.get_wake_potential(t_fd, sigma=1e-2/c)

# Fully decayed wake, wakelength inf m
f_fd = np.linspace(0, 1.5e9, 10000)
Z_fd = DE_model.get_impedance(f_fd)

# Plot
fig1, ax = plt.subplots(1,2, figsize=[12,4], dpi=150)
ax[0].plot(t_fd*c, WP_fd*1e-12*c, c='tab:red', lw=1.5, label='iddefix')
ax[0].set_xlabel('s [cm]')
ax[0].set_ylabel('Longitudinal wake potential [V/pC]', color='tab:red')
ax[0].legend()

ax[1].plot(f_fd*1e-9, np.abs(Z_fd), c='tab:blue', alpha=0.8, lw=2, label='Fully decayed, Abs')
ax[1].plot(f_fd*1e-9, np.real(Z_fd), ls='--', c='tab:blue', lw=1.5, label='Fully decayed, Real')
ax[1].plot(f_fd*1e-9, np.imag(Z_fd), ls=':', c='tab:blue', lw=1.5, label='Fully decayed, Imag')
ax[1].set_xlabel('f [GHz]')
ax[1].set_ylabel('Longitudinal impedance [Abs][$\Omega$]', color='tab:blue')
ax[1].legend()

fig1.tight_layout()

🧮 Compute non-equidistant Fourier Transforms with neffint

Neffint is an acronym for Non-equidistant Filon Fourier integration. This is a python package for computing Fourier integrals using a method based on Filon’s rule with non-equidistant grid spacing. Developed by Eskil Vik and Nicolas Mounet, it is part of the Wakis ecosystem of python packages developed at CERN, and available in the ImpedanCEI organization.

neffint has been integrated in IDDEFIX as an alternative method to compute Fourier Transforms:

time, wake_function = iddefix.compute_ineffint(frequency_data, impedance_data,
                                 times=np.linspace(1e-11, 50e-9, 1000), #avoid starting at zero
                                 plane='transverse', #or longitudinal, changes normalization
                                 adaptative=True, # refines the sampling, but can be slow/unstable
                                 )

frequency, impedance = iddefix.compute_neffint(time_data, wake_data,
                                 frequencies=np.linspace(0, 5e9, 1000),
                                 adaptative=True, # refines the sampling, but can be slow/unstable
                                 )

See also

Check out IDDEFIX’s test_001 for a comparison of all the different FFT methods available

🌡️ Estimate the beam induced heating of the simulated impedance with BIHC

Beam Induced Heating Computation (BIHC) tool is a package that allows the estimation of the dissipated power due to the passage of a particle beam inside an accelerator component. The dissipated power value depends on the characteristics of the particle beam (beam spectrum and intensity) and on the characteristics of the consdiered accelerator component (beam-coupling impedance). BIHC helps generating different beam filling schemes, bunch profiles, bunch intensities, and load machine parameters for the different accelerators at CERN.

See also

Specific documentation for BIHC is available at https://bihc.readthedocs.io/

Generate beam parameters

To generate the beam spectrum, we need the filling scheme used in the machine, containing the number and spacing between bunches. An example of a CERN LHC filling scheme is given bellow:

def fillingSchemeLHC(ninj, ntrain=5, nbunches=36):
    '''
    Returns the filling scheme for the LHC
    using the standard pattern

    Parameters
    ----------
    ninj: number of injections (batches)
    '''

    # Define filling scheme: parameters
    #ninj = 11 # Defining number of injections
    nslots = 3564 # Defining total number of slots for LHC
    #ntrain = 5 # Defining the number of trains
    #nbunches = 36 # Defining a number of bunchs e.g. 18, 36, 72..
    batchS = 7 # Batch spacing in 25 ns slots
    injspacing = 37 # Injection spacing in 25 ns slots

    # Defining the trains as lists of True/Falses
    bt = [True]*nbunches
    st = [False]*batchS
    stt = [False]*injspacing
    sc = [False]*(nslots-(ntrain*nbunches*ninj+((ntrain-1)*(batchS)*ninj)+((1)*injspacing*(ninj))))
    an1 = bt+ st +bt+ st+ bt+ st+ bt+ stt
    an = an1 * ninj + sc # This is the final true false sequence that is the beam distribution

    return an

With this information, we can use BIHC to fill the Beam class:

# Create beam object
fillingScheme = fillingSchemeLHC(ninj=9, ntrain=4, nbunches=72)
bl = 1.2e-9                 # bunch length [s]
Np = 2.3e11                 # bunch intensity [protons/bunch]
bunchShape = 'q-GAUSSIAN'   # bunch profile shape in time
qvalue = 3/5                # value of q parameter in the q-gaussian distribution
fillMode = 'FLATTOP'        # Energy
fmax = 2e9                  # Maximum frequency of the beam spectrum [Hz]

beam = bihc.Beam(Np=Np, bunchLength=bl, fillingScheme=fillingScheme,
                bunchShape=bunchShape, qvalue=qvalue,
                machine='LHC', fillMode=fillMode, spectrum='numeric', fmax=fmax)

print(f'* Number of bunches used: {np.sum(fillingScheme)}')
print(f'* Total intensity: {np.sum(fillingScheme)*Np:.2e} protons')

And plot the beam longitudinal profile (time-domain) and beam spectrum (frequency domain):

fig, ax = plt.subplots(1,2, figsize=[14,6])

t, prof = beam.longitudinalProfile
ax[0].plot(t*1e6, prof*beam.Np,)
ax[0].set_xlabel('Time [ms]')
ax[0].set_ylabel('Profile Intensity [protons]')

f, spectrum = beam.spectrum
ax[1].plot(f*1e-9, spectrum*beam.Np*np.sum(fillingScheme), c='r')
ax[1].set_xlabel('Frquency [GHz]')
ax[1].set_ylabel('Spectrum Intensity [protons]')
ax[1].set_xlim((0, 2.0))

The impedance object

To compute the power loss, we need to fill the Impedance class with the impedance data of the accelerator device under study:

Z = bihc.Impedance(f=frequency, Z=impedance) # directly from array (Wakis, IDDEFIX)
Z.getImpedancefromCST('impedance.txt') # from CST or other output txt file
Z.getResonatorImpedance(R, Q, fres)    # 1 resonator impedance
for i in range(len(fr)): # n resonator impedance
    Zmode = bihc.Impedance(frequency)
    Zmode.getResonatorImpedance(Rs=Rs[i], Qr= Qr[i], fr=fr[i])
    Z = Z + Zmode
Z.getRWImpedance(L ,b, sigma)    # single-layer resistive wall impedance

Power loss calculation, 1 beam case

With BIHC we can simply calculate the power loss by beam.getPloss(Z). However, due to inacuracies in the wakefield simulation or the CAD model, or to account for changes in the revolution frequency during operation, BIHC also performs a statistical analysis by rigidly shifting the impedance curve in piece-wise shifts beam.getShiftedPloss(Z, shift=shift) to account for different overlaps with the beam spectral lines. A basic power loss calculation can be done by:

print('Calculate beam-induced power loss')
print('---------------------------------')
# Get unshifted ploss
ploss, ploss_density = beam.getPloss(Z)
print(f'Dissipated power (no-shift): {ploss:.3} W')

# Get min/max power loss with rigid shift
shift = 20e6  # distance between shift steps [Hz]
shifts, power = beam.getShiftedPloss(Z, shift=shift)

print(f'Minimum dissipated power: P_min = {np.min(power):.3} W, at step {shifts[np.argmin(power)]}')
print(f'Maximum dissipated power: P_max = {np.max(power):.3} W, at step {shifts[np.argmax(power)]}')
print(f'Average dissipated power: P_mean = {np.mean(power):.3} W')

# Retrieve impedance that gave the maximum Ploss
Z_max = beam.Zmax

One can also plot the power loss density across the frequencies of interest:

# Unshifted impedance
ploss, ploss_density = beam.getPloss(Z)

# Shifted impedance
ploss_max, ploss_density_max = beam.getPloss(Z_max)

fig, ax = plt.subplots(figsize=[10,7])
l1, = ax.plot(np.linspace(0, Z_max.f.max()/1e9, len(ploss_density_max )), ploss_density_max , color='r', marker='v', lw=3, alpha=0.8)
l0, = ax.plot(np.linspace(0, Z.f.max()/1e9, len(ploss_density )), ploss_density , color='k', marker='v', lw=3, alpha=0.8)

ax.set_ylabel('Power by frequency [W]', color='k')
ax.set_yscale('log')
ax.set_xlabel('Frequency [GHz]')
ax.set_xlim((0, 1.5))
ax.set_ylim(ymin=1e-1, ymax=1e4)
ax.grid(which='minor', axis='y', alpha=0.8, ls=':')
ax.legend([l0, l1, l2], [f'Ploss', 'Ploss Max.'], loc=1)

Power loss calculation, 2 counter-rotating beams

If the accelerator component of interested was placed in a collider’s common-beam chamber, it would see the effect of 2 beam power loss. The beam-induced heating in this case is a function of the distance with the interaction point (IP) and can be greater than a factor 2 of the 1-beam case. We can compute this with BIHC too:

#       2 beam case
# ----------------------
# Defining the phase shift array for LHC
c = 299792458 # Speed of light in vacuum [m/s]
ring_circumference =  26658.883   #[m]

start = -3.5 #m
stop = 3.5 #m
resolution = 0.001 #m power2b

s = np.arange(start, stop, resolution)
tau_s = 2*s/c # Phase shift array [s]

power2b = beam.get2BeamPloss(Z, tau_s=tau_s)
power2b_max = beam.get2BeamPloss(Z_max, tau_s=tau_s)

# Plot power los vs distance from IP
fig, ax = plt.subplots(figsize=[12,5])

ax.plot(s, power2b_max, label="2-b Ploss Max.", c='b', ls='-', alpha=0.7)
ax.plot(s, power2b, label="2-b Ploss ", c='deepskyblue', ls='-', alpha=0.7)

ax.set_ylabel('Dissipated power [W]')
ax.set_xlabel('s Distance from IP [m]')

ax.axhline(np.max(power), c='b', ls='--', alpha=0.5, label='Max. 1-b power')
ax.axhline(np.mean(power), c='deepskyblue', ls='--', alpha=0.5, label='1-b power')

ax.set_ylim(ymin=0)
ax.set_title(f'2 beam power loss vs s')
fig.legend(bbox_to_anchor=(0.55, 0.0), fontsize=14, loc='lower center', ncol=2)
fig.tight_layout(rect=[0, 0.2, 1, 1])