Dynamics in the HTU Beamline
This example involves tracking a 25 pC electron bunch with 100 MeV total energy through the BELLA Hundred-Terawatt Undulator (HTU) beamline at LBNL [1], [2].
Fig. 17 Survey plot of the BELLA Hundred-Terawatt Undulator (HTU) beamline at LBNL.
This plot can be generated with sim.lattice.plot_survey(ref=ref) (see impactx.elements.KnownElementsList.plot_survey()).
The bunch is generated in practice from a laser-plasma accelerator stage, resulting in a small intial rms beam size (3.9, 3.9, 1.0) microns, 2 mrad transverse divergence and 2.5% energy spread. Due to the large energy spread, chromatic focusing effects are important.
In this test, the initial and final values of \(\sigma_x\), \(\sigma_y\), \(\sigma_t\), \(\epsilon_x\), \(\epsilon_y\), and \(\epsilon_t\) must agree with nominal values.
Run
This example can be run as:
Python script:
python3 run_impactx.pyor
For MPI-parallel runs, prefix these lines with mpiexec -n 4 ... or srun -n 4 ..., depending on the system.
#!/usr/bin/env python3
#
# Copyright 2022-2023 ImpactX contributors
# Authors: Axel Huebl, Chad Mitchell
# License: BSD-3-Clause-LBNL
#
# HTU References:
# - https://doi.org/10.1103/vh62-gz1p
# - https://doi.org/10.1117/12.3056776
#
# -*- coding: utf-8 -*-
import numpy as np
from htu_lattice import get_lattice
from scipy.constants import c, e, m_e
from impactx import ImpactX, distribution, twiss
sim = ImpactX()
# set numerical parameters and IO control
sim.space_charge = False
sim.slice_step_diagnostics = True
# silent running
silent = False
if silent:
sim.verbose = 0
sim.tiny_profiler = False
# note: lattice beam monitors will still write files
sim.diagnostics = False
# domain decomposition & space charge mesh
sim.init_grids()
# basic beam parameters
total_energy_MeV = 100.0 # reference energy (total)
mass_MeV = 0.510998950 # particle mass
kin_energy_MeV = total_energy_MeV - mass_MeV
bunch_charge_C = 25.0e-12 # used with space charge
npart = 10000 # number of macro particles
# set reference particle
ref = sim.particle_container().ref_particle()
ref.set_charge_qe(-1.0).set_mass_MeV(mass_MeV).set_kin_energy_MeV(kin_energy_MeV)
# factors converting the beam distribution to ImpactX input
gamma = total_energy_MeV / mass_MeV
bg = np.sqrt(gamma**2 - 1.0)
sigma_tau = 1e-6 # in m
sigma_p = 2.5e-2 # dimensionless
rigidity = m_e * c * bg / e
# particle bunch
distr = distribution.Gaussian(
**twiss(
beta_x=0.002,
beta_y=0.002,
beta_t=sigma_tau / sigma_p,
emitt_x=1.5e-6 / bg,
emitt_y=1.5e-6 / bg,
emitt_t=sigma_tau * sigma_p,
alpha_x=0.0,
alpha_y=0.0,
alpha_t=0.0,
)
)
sim.add_particles(bunch_charge_C, distr, npart)
# set the lattice
sim.lattice.extend(get_lattice("impactx"))
# run simulation
sim.track_particles()
# in situ calculate the reduced beam characteristics
# note: rbc us a dataframe, sim can be finalized once rbc was created
# beam = sim.particle_container()
# rbc = beam.reduced_beam_characteristics()
# clean shutdown
sim.finalize()
Analyze
We run the following script to analyze correctness:
Script analysis_htu_beamline.py
#!/usr/bin/env python3
#
# Copyright 2022-2023 ImpactX contributors
# Authors: Axel Huebl, Chad Mitchell
# License: BSD-3-Clause-LBNL
#
import numpy as np
import openpmd_api as io
from scipy.stats import moment
def get_moments(beam):
"""Calculate standard deviations of beam position & momenta
and emittance values
Returns
-------
sigx, sigy, sigt, emittance_x, emittance_y, emittance_t
"""
sigx = moment(beam["position_x"], moment=2) ** 0.5 # variance -> std dev.
sigpx = moment(beam["momentum_x"], moment=2) ** 0.5
sigy = moment(beam["position_y"], moment=2) ** 0.5
sigpy = moment(beam["momentum_y"], moment=2) ** 0.5
sigt = moment(beam["position_t"], moment=2) ** 0.5
sigpt = moment(beam["momentum_t"], moment=2) ** 0.5
epstrms = beam.cov(ddof=0)
emittance_x = (sigx**2 * sigpx**2 - epstrms["position_x"]["momentum_x"] ** 2) ** 0.5
emittance_y = (sigy**2 * sigpy**2 - epstrms["position_y"]["momentum_y"] ** 2) ** 0.5
emittance_t = (sigt**2 * sigpt**2 - epstrms["position_t"]["momentum_t"] ** 2) ** 0.5
return (sigx, sigy, sigt, emittance_x, emittance_y, emittance_t)
# initial/final beam
series1 = io.Series("diags/openPMD/TCPhosphor.h5", io.Access.read_only)
last_step = list(series1.iterations)[-1]
initial = series1.iterations[last_step].particles["beam"].to_df()
series2 = io.Series("diags/openPMD/UC_VisaEBeam8.h5", io.Access.read_only)
last_step = list(series2.iterations)[-1]
beam_final = series2.iterations[last_step].particles["beam"]
final = beam_final.to_df()
# compare number of particles
num_particles = 10000
assert num_particles == len(initial)
assert num_particles == len(final)
print("Initial Beam (at TCPhosphor screen):")
sigx, sigy, sigt, emittance_x, emittance_y, emittance_t = get_moments(initial)
print(f" sigx={sigx:e} sigy={sigy:e} sigt={sigt:e}")
print(
f" emittance_x={emittance_x:e} emittance_y={emittance_y:e} emittance_t={emittance_t:e}"
)
atol = 0.0 # ignored
rtol = 10.0 * num_particles**-0.5 # from random sampling of a smooth distribution
print(f" rtol={rtol} (ignored: atol~={atol})")
assert np.allclose(
[sigx, sigy, sigt, emittance_x, emittance_y, emittance_t],
[
3.536004e-04,
2.152397e-04,
1.530077e-06,
3.734497e-08,
1.506135e-08,
3.729656e-08,
],
rtol=rtol,
atol=atol,
)
print("")
print("Final Beam (at VisaEbeam8 screen):")
sigx, sigy, sigt, emittance_x, emittance_y, emittance_t = get_moments(final)
s_ref = beam_final.get_attribute("s_ref")
gamma_ref = beam_final.get_attribute("gamma_ref")
print(f" sigx={sigx:e} sigy={sigy:e} sigt={sigt:e}")
print(
f" emittance_x={emittance_x:e} emittance_y={emittance_y:e} emittance_t={emittance_t:e}\n"
f" s_ref={s_ref:e} gamma_ref={gamma_ref:e}"
)
atol = 0.0 # ignored
rtol = 10.0 * num_particles**-0.5 # from random sampling of a smooth distribution
print(f" rtol={rtol} (ignored: atol~={atol})")
assert np.allclose(
[sigx, sigy, sigt, emittance_x, emittance_y, emittance_t, s_ref, gamma_ref],
[
1.759036e-03,
2.337416e-04,
1.175086e-04,
2.813148e-06,
6.167234e-07,
2.858550e-06,
9.459980,
1.956951e02,
],
rtol=rtol,
atol=atol,
)
Off-energy transport in the HTU Beamline
This is a modification of the example htu-beamline, as follows. The beam total energy is increased from 100 MeV to 150 MeV, and the chicane \(R_{56}\) is decreased from 200 microns to 0.
The purpose of this example is to compare two distinct methods for transporting highly off-energy beams:
keep the 100 MeV reference energy, but displace the mean energy of the initial beam distribution by setting the parameter mean_pt
modify the reference energy to 150 MeV, and rescale the input parameters of the initial beam distribution to yield a distribution with the same second moments as 1)
The bunch is generated in practice from a laser-plasma accelerator stage, resulting in a small intial rms beam size (3.9, 3.9, 1.0) microns, 2 mrad transverse divergence and 2.5% energy spread. Due to the large energy spread, chromatic focusing effects are important.
In this test, the initial and final values of \(\sigma_x\), \(\sigma_y\), \(\sigma_t\), \(\epsilon_x\), \(\epsilon_y\), and \(\epsilon_t\) must agree with nominal values.
The two methods described in 1) and 2) are physically equivalent and the test verifies that the two methods produce physically equivalent output.
Run
This example can be run as:
Python script:
python3 run_impactx_offenergy1.pyorpython3 run_impactx_offenergy2.py
For MPI-parallel runs, prefix these lines with mpiexec -n 4 ... or srun -n 4 ..., depending on the system.
#!/usr/bin/env python3
#
# Copyright 2022-2023 ImpactX contributors
# Authors: Axel Huebl, Chad Mitchell
# License: BSD-3-Clause-LBNL
#
# HTU References:
# - https://doi.org/10.1103/vh62-gz1p
# - https://doi.org/10.1117/12.3056776
#
# -*- coding: utf-8 -*-
import numpy as np
from htu_lattice import get_lattice
from impactx import ImpactX, distribution, twiss
sim = ImpactX()
# set numerical parameters and IO control
sim.space_charge = False
sim.slice_step_diagnostics = True
# silent running
silent = False
if silent:
sim.verbose = 0
sim.tiny_profiler = False
# note: lattice beam monitors will still write files
sim.diagnostics = False
# domain decomposition & space charge mesh
sim.init_grids()
# basic beam parameters
total_energy_MeV_nominal = 100.0 # reference energy (total)
total_energy_MeV_target = 150.0 # desired total energy
mass_MeV = 0.510998950 # particle mass
kin_energy_MeV = total_energy_MeV_nominal - mass_MeV
bunch_charge_C = 25.0e-12 # used with space charge
npart = 10000 # number of macro particles
# set reference particle
ref = sim.particle_container().ref_particle()
ref.set_charge_qe(-1.0).set_mass_MeV(mass_MeV).set_kin_energy_MeV(kin_energy_MeV)
# factors converting the beam distribution to ImpactX input
gamma = total_energy_MeV_nominal / mass_MeV
bg = np.sqrt(gamma**2 - 1.0)
sigma_tau = 1e-6 # in m
sigma_p = 2.5e-2 # dimensionless
# energy shift
delta_gamma = (total_energy_MeV_target - total_energy_MeV_nominal) / mass_MeV
mu_p = delta_gamma / bg
# particle bunch
distr = distribution.Gaussian(
**twiss(
beta_x=0.002,
beta_y=0.002,
beta_t=sigma_tau / sigma_p,
emitt_x=1.5e-6 / bg,
emitt_y=1.5e-6 / bg,
emitt_t=sigma_tau * sigma_p,
alpha_x=0.0,
alpha_y=0.0,
alpha_t=0.0,
mean_pt=-mu_p,
),
)
sim.add_particles(bunch_charge_C, distr, npart)
# set the lattice
sim.lattice.extend(get_lattice("impactx", chicane_r56=0.0))
# run simulation
sim.track_particles()
# in situ calculate the reduced beam characteristics
# note: rbc us a dataframe, sim can be finalized once rbc was created
# beam = sim.particle_container()
# rbc = beam.reduced_beam_characteristics()
# clean shutdown
sim.finalize()
#!/usr/bin/env python3
#
# Copyright 2022-2023 ImpactX contributors
# Authors: Axel Huebl, Chad Mitchell
# License: BSD-3-Clause-LBNL
#
# HTU References:
# - https://doi.org/10.1103/vh62-gz1p
# - https://doi.org/10.1117/12.3056776
#
# -*- coding: utf-8 -*-
import numpy as np
from htu_lattice import get_lattice
from impactx import ImpactX, distribution, twiss
sim = ImpactX()
# set numerical parameters and IO control
sim.space_charge = False
sim.slice_step_diagnostics = True
# silent running
silent = False
if silent:
sim.verbose = 0
sim.tiny_profiler = False
# note: lattice beam monitors will still write files
sim.diagnostics = False
# domain decomposition & space charge mesh
sim.init_grids()
# basic beam parameters
total_energy_MeV_nominal = 100.0 # reference energy (total)
total_energy_MeV_target = 150.0 # desired total energy
mass_MeV = 0.510998950 # particle mass
kin_energy_MeV = total_energy_MeV_target - mass_MeV
bunch_charge_C = 25.0e-12 # used with space charge
npart = 10000 # number of macro particles
# set reference particle
ref = sim.particle_container().ref_particle()
ref.set_charge_qe(-1.0).set_mass_MeV(mass_MeV).set_kin_energy_MeV(kin_energy_MeV)
# factors converting the beam distribution to ImpactX input
gamma_nominal = total_energy_MeV_nominal / mass_MeV
gamma_target = total_energy_MeV_target / mass_MeV
bg_nominal = np.sqrt(gamma_nominal**2 - 1.0)
bg_target = np.sqrt(gamma_target**2 - 1.0)
ratio = bg_target / bg_nominal
sigma_tau = 1e-6 # in m
sigma_p = 2.5e-2 / ratio # dimensionless
mu_p = 0.0
# particle bunch
distr = distribution.Gaussian(
**twiss(
beta_x=0.002 * ratio,
beta_y=0.002 * ratio,
beta_t=sigma_tau / sigma_p,
emitt_x=1.5e-6 / bg_target,
emitt_y=1.5e-6 / bg_target,
emitt_t=sigma_tau * sigma_p,
alpha_x=0.0,
alpha_y=0.0,
alpha_t=0.0,
mean_pt=-mu_p,
),
)
sim.add_particles(bunch_charge_C, distr, npart)
# set the lattice
sim.lattice.extend(get_lattice("impactx", chicane_r56=0.0))
# run simulation
sim.track_particles()
# in situ calculate the reduced beam characteristics
# note: rbc us a dataframe, sim can be finalized once rbc was created
# beam = sim.particle_container()
# rbc = beam.reduced_beam_characteristics()
# clean shutdown
sim.finalize()
The two scripts are physically equivalent, corresponding to methods 1) and 2) above, and should produce physically equivalent output.
Analyze
We run the following script to analyze correctness:
Script analysis_htu_beamline_offenergy.py
#!/usr/bin/env python3
#
# Copyright 2022-2023 ImpactX contributors
# Authors: Axel Huebl, Chad Mitchell
# License: BSD-3-Clause-LBNL
#
import numpy as np
import openpmd_api as io
from scipy.stats import moment
def get_moments(beam):
"""Calculate standard deviations of beam position & momenta
and emittance values
Returns
-------
sigx, sigy, sigt, emittance_x, emittance_y, emittance_t
"""
sigx = moment(beam["position_x"], moment=2) ** 0.5 # variance -> std dev.
sigpx = moment(beam["momentum_x"], moment=2) ** 0.5
sigy = moment(beam["position_y"], moment=2) ** 0.5
sigpy = moment(beam["momentum_y"], moment=2) ** 0.5
sigt = moment(beam["position_t"], moment=2) ** 0.5
sigpt = moment(beam["momentum_t"], moment=2) ** 0.5
epstrms = beam.cov(ddof=0)
emittance_x = (sigx**2 * sigpx**2 - epstrms["position_x"]["momentum_x"] ** 2) ** 0.5
emittance_y = (sigy**2 * sigpy**2 - epstrms["position_y"]["momentum_y"] ** 2) ** 0.5
emittance_t = (sigt**2 * sigpt**2 - epstrms["position_t"]["momentum_t"] ** 2) ** 0.5
return (sigx, sigy, sigt, emittance_x, emittance_y, emittance_t)
# initial/final beam
series1 = io.Series("diags/openPMD/TCPhosphor.h5", io.Access.read_only)
last_step = list(series1.iterations)[-1]
beam_initial = series1.iterations[last_step].particles["beam"]
initial = beam_initial.to_df()
series2 = io.Series("diags/openPMD/UC_VisaEBeam8.h5", io.Access.read_only)
last_step = list(series2.iterations)[-1]
beam_final = series2.iterations[last_step].particles["beam"]
final = beam_final.to_df()
# compare number of particles
num_particles = 10000
assert num_particles == len(initial)
assert num_particles == len(final)
gamma_ref = beam_initial.get_attribute("gamma_ref")
bg = np.sqrt(gamma_ref**2 - 1.0)
print("Initial Beam (at TCPhosphor screen):")
sigx, sigy, sigt, emittance_x, emittance_y, emittance_t = get_moments(initial)
emittance_xn = emittance_x * bg
emittance_yn = emittance_y * bg
emittance_tn = emittance_t * bg
print(f" sigx={sigx:e} sigy={sigy:e} sigt={sigt:e}")
print(
f" emittance_xn={emittance_xn:e} emittance_yn={emittance_yn:e} emittance_tn={emittance_tn:e}"
)
atol = 0.0 # ignored
rtol = 20.0 * num_particles**-0.5 # from random sampling of a smooth distribution
print(f" rtol={rtol} (ignored: atol~={atol})")
assert np.allclose(
[sigx, sigy, sigt, emittance_xn, emittance_yn, emittance_tn],
[
4.786098e-04,
2.983777e-04,
1.147205e-06,
3.791183e-06,
2.164368e-06,
5.589590e-06,
],
rtol=rtol,
atol=atol,
)
gamma_ref = beam_final.get_attribute("gamma_ref")
bg = np.sqrt(gamma_ref**2 - 1.0)
print("")
print("Final Beam (at VisaEbeam8 screen):")
sigx, sigy, sigt, emittance_x, emittance_y, emittance_t = get_moments(final)
emittance_xn = emittance_x * bg
emittance_yn = emittance_y * bg
emittance_tn = emittance_t * bg
print(f" sigx={sigx:e} sigy={sigy:e} sigt={sigt:e}")
print(
f" emittance_xn={emittance_xn:e} emittance_yn={emittance_yn:e} emittance_tn={emittance_tn:e}\n"
)
atol = 0.0 # ignored
rtol = 20.0 * num_particles**-0.5 # from random sampling of a smooth distribution
print(f" rtol={rtol} (ignored: atol~={atol})")
assert np.allclose(
[sigx, sigy, sigt, emittance_xn, emittance_yn, emittance_tn],
[
7.554152e-03,
2.151019e-03,
9.054048e-04,
6.090367e-03,
6.238249e-04,
4.425367e-03,
],
rtol=rtol,
atol=atol,
)