Capacitive Discharge

The examples in this directory are based on the benchmark cases from Turner et al. (Phys. Plasmas 20, 013507, 2013) [15].

The Monte-Carlo collision (MCC) model can be used to simulate electron and ion collisions with a neutral background gas. In particular this can be used to study capacitive discharges between parallel plates. The implementation has been tested against the benchmark results from Turner et al. [15].

Note

This example needs additional calibration data for cross sections. Download this data alongside your inputs file and update the paths in the inputs file:

git clone https://github.com/BLAST-WarpX/warpx-data.git

Run

The 1D PICMI input file can be used to reproduce the results from Turner et al. for a given case, N from 1 to 4, by executing python3 inputs_base_1d_picmi.py -n N, e.g.,

python3 inputs_base_1d_picmi.py -n 1

For MPI-parallel runs, prefix these lines with mpiexec -n 4 ... or srun -n 4 ..., depending on the system.

Listing 62 You can copy this file from Examples/Physics_applications/capacitive_discharge/inputs_base_1d_picmi.py.
#!/usr/bin/env python3
#
# --- Copyright 2021 Modern Electron (DSMC test added in 2023 by TAE Technologies)
# --- Monte-Carlo Collision script to reproduce the benchmark tests from
# --- Turner et al. (2013) - https://doi.org/10.1063/1.4775084

import argparse
import sys

import numpy as np
from scipy.sparse import csc_matrix
from scipy.sparse import linalg as sla

from pywarpx import callbacks, fields, libwarpx, particle_containers, picmi
from pywarpx.LoadThirdParty import load_cupy

constants = picmi.constants


class PoissonSolver1D(picmi.ElectrostaticSolver):
    """This solver is maintained as an example of the use of Python callbacks.
    However, it is not necessarily needed since the 1D code has the direct tridiagonal
    solver implemented."""

    def __init__(self, grid, **kwargs):
        """Direct solver for the Poisson equation using superLU. This solver is
        useful for 1D cases.

        Arguments:
            grid (picmi.Cartesian1DGrid): Instance of the grid on which the
            solver will be installed.
        """
        # Sanity check that this solver is appropriate to use
        if not isinstance(grid, picmi.Cartesian1DGrid):
            raise RuntimeError("Direct solver can only be used on a 1D grid.")

        super(PoissonSolver1D, self).__init__(
            grid=grid,
            method=kwargs.pop("method", "Multigrid"),
            required_precision=1,
            **kwargs,
        )

    def solver_initialize_inputs(self):
        """Grab geometrical quantities from the grid. The boundary potentials
        are also obtained from the grid using 'warpx_potential_zmin' for the
        left_voltage and 'warpx_potential_zmax' for the right_voltage.
        These can be given as floats or strings that can be parsed by the
        WarpX parser.
        """
        # grab the boundary potentials from the grid object
        self.right_voltage = self.grid.potential_zmax

        # set WarpX boundary potentials to None since we will handle it
        # ourselves in this solver
        self.grid.potential_xmin = None
        self.grid.potential_xmax = None
        self.grid.potential_ymin = None
        self.grid.potential_ymax = None
        self.grid.potential_zmin = None
        self.grid.potential_zmax = None

        super(PoissonSolver1D, self).solver_initialize_inputs()

        self.nz = self.grid.number_of_cells[0]
        self.dz = (self.grid.upper_bound[0] - self.grid.lower_bound[0]) / self.nz

        self.nxguardphi = 1
        self.nzguardphi = 1

        self.phi = np.zeros(self.nz + 1 + 2 * self.nzguardphi)

        self.decompose_matrix()

        callbacks.installpoissonsolver(self._run_solve)

    def decompose_matrix(self):
        """Function to build the superLU object used to solve the linear
        system."""
        self.nsolve = self.nz + 1

        # Set up the computation matrix in order to solve A*phi = rho
        A = np.zeros((self.nsolve, self.nsolve))
        idx = np.arange(self.nsolve)
        A[idx, idx] = -2.0
        A[idx[1:], idx[:-1]] = 1.0
        A[idx[:-1], idx[1:]] = 1.0

        A[0, 1] = 0.0
        A[-1, -2] = 0.0
        A[0, 0] = 1.0
        A[-1, -1] = 1.0

        A = csc_matrix(A, dtype=np.float64)
        self.lu = sla.splu(A)

    def _run_solve(self):
        """Function run on every step to perform the required steps to solve
        Poisson's equation."""
        # get rho from WarpX
        self.rho_data = fields.RhoFPWrapper(0, False)[...]
        # run superLU solver to get phi
        self.solve()
        # write phi to WarpX
        fields.PhiFPWrapper(0, True)[...] = self.phi[:]

    def solve(self):
        """The solution step. Includes getting the boundary potentials and
        calculating phi from rho."""

        left_voltage = 0.0
        right_voltage = eval(
            self.right_voltage,
            {"t": self.sim.extension.warpx.gett_new(0), "sin": np.sin, "pi": np.pi},
        )

        # Construct b vector
        rho = -self.rho_data / constants.ep0
        b = np.zeros(rho.shape[0], dtype=np.float64)
        b[:] = rho * self.dz**2

        b[0] = left_voltage
        b[-1] = right_voltage

        phi = self.lu.solve(b)

        self.phi[self.nzguardphi : -self.nzguardphi] = phi

        self.phi[: self.nzguardphi] = left_voltage
        self.phi[-self.nzguardphi :] = right_voltage


class CapacitiveDischargeExample(object):
    """The following runs a simulation of a parallel plate capacitor seeded
    with a plasma in the spacing between the plates. A time varying voltage is
    applied across the capacitor. The groups of 4 values below correspond to
    the 4 cases simulated by Turner et al. (2013) in their benchmarks of
    PIC-MCC codes.
    """

    gap = 0.067  # m

    freq = 13.56e6  # Hz
    voltage = [450.0, 200.0, 150.0, 120.0]  # V

    gas_density = [9.64e20, 32.1e20, 96.4e20, 321e20]  # m^-3
    gas_temp = 300.0  # K
    m_ion = 6.67e-27  # kg

    plasma_density = [2.56e14, 5.12e14, 5.12e14, 3.84e14]  # m^-3
    elec_temp = 30000.0  # K

    seed_nppc = 16 * np.array([32, 16, 8, 4])

    nz = [128, 256, 512, 512]

    dt = 1.0 / (np.array([400, 800, 1600, 3200]) * freq)

    # Total simulation time in seconds
    total_time = np.array([1280, 5120, 5120, 15360]) / freq
    # Time (in seconds) between diagnostic evaluations
    diag_interval = 32 / freq

    def __init__(self, n=0, test=False, pythonsolver=False, dsmc=False):
        """Get input parameters for the specific case (n) desired."""
        self.n = n
        self.test = test
        self.pythonsolver = pythonsolver
        self.dsmc = dsmc

        # Case specific input parameters
        self.voltage = f"{self.voltage[n]}*sin(2*pi*{self.freq:.5e}*t)"

        self.gas_density = self.gas_density[n]
        self.plasma_density = self.plasma_density[n]
        self.seed_nppc = self.seed_nppc[n]

        self.nz = self.nz[n]

        self.dt = self.dt[n]
        self.max_steps = int(self.total_time[n] / self.dt)
        self.diag_steps = int(self.diag_interval / self.dt)

        if self.test:
            self.max_steps = 50
            self.diag_steps = 5
            self.mcc_subcycling_steps = 2
            self.rng = np.random.default_rng(23094290)
        else:
            self.mcc_subcycling_steps = None
            self.rng = np.random.default_rng()

        self.ion_density_array = np.zeros(self.nz + 1)

        self.setup_run()

    def setup_run(self):
        """Setup simulation components."""

        #######################################################################
        # Set geometry and boundary conditions                                #
        #######################################################################

        self.grid = picmi.Cartesian1DGrid(
            number_of_cells=[self.nz],
            warpx_max_grid_size=128,
            lower_bound=[0],
            upper_bound=[self.gap],
            lower_boundary_conditions=["dirichlet"],
            upper_boundary_conditions=["dirichlet"],
            lower_boundary_conditions_particles=["absorbing"],
            upper_boundary_conditions_particles=["absorbing"],
            warpx_potential_hi_z=self.voltage,
        )

        #######################################################################
        # Field solver                                                        #
        #######################################################################

        if self.pythonsolver:
            self.solver = PoissonSolver1D(grid=self.grid)
        else:
            # This will use the tridiagonal solver
            self.solver = picmi.ElectrostaticSolver(grid=self.grid)

        #######################################################################
        # Particle types setup                                                #
        #######################################################################

        self.electrons = picmi.Species(
            particle_type="electron",
            name="electrons",
            initial_distribution=picmi.UniformDistribution(
                density=self.plasma_density,
                rms_velocity=[np.sqrt(constants.kb * self.elec_temp / constants.m_e)]
                * 3,
            ),
        )
        self.ions = picmi.Species(
            particle_type="He",
            name="he_ions",
            charge="q_e",
            mass=self.m_ion,
            initial_distribution=picmi.UniformDistribution(
                density=self.plasma_density,
                rms_velocity=[np.sqrt(constants.kb * self.gas_temp / self.m_ion)] * 3,
            ),
        )
        if self.dsmc:
            self.neutrals = picmi.Species(
                particle_type="He",
                name="neutrals",
                charge=0,
                mass=self.m_ion,
                warpx_reflection_model_zlo=1.0,
                warpx_reflection_model_zhi=1.0,
                warpx_do_resampling=True,
                warpx_resampling_trigger_max_avg_ppc=int(self.seed_nppc * 1.5),
                initial_distribution=picmi.UniformDistribution(
                    density=self.gas_density,
                    rms_velocity=[np.sqrt(constants.kb * self.gas_temp / self.m_ion)]
                    * 3,
                ),
            )

        #######################################################################
        # Collision initialization                                            #
        #######################################################################

        cross_sec_direc = "../../../../warpx-data/MCC_cross_sections/He/"

        electron_scattering_processes = {
            "elastic": {"cross_section": cross_sec_direc + "electron_scattering.dat"},
            "excitation1": {
                "cross_section": cross_sec_direc + "excitation_1.dat",
                "energy": 19.82,
            },
            "excitation2": {
                "cross_section": cross_sec_direc + "excitation_2.dat",
                "energy": 20.61,
            },
            "ionization": {
                "cross_section": cross_sec_direc + "ionization.dat",
                "energy": 24.55,
                "species": self.ions,
            },
        }
        if self.dsmc:
            ionization = {"ionization": electron_scattering_processes.pop("ionization")}
            ionization["ionization"]["target_species"] = self.neutrals
            ionization["ionization"].pop("species")
            electron_colls_dsmc = picmi.DSMCCollisions(
                name="coll_elec_dsmc",
                species=[self.electrons, self.neutrals],
                product_species=[self.ions, self.electrons],
                ndt=4,
                scattering_processes=ionization,
            )
            electron_colls_mcc = picmi.MCCCollisions(
                name="coll_elec",
                species=self.electrons,
                background_density=self.gas_density,
                background_temperature=self.gas_temp,
                background_mass=self.ions.mass,
                ndt=self.mcc_subcycling_steps,
                scattering_processes=electron_scattering_processes,
            )
            electron_colls = [electron_colls_mcc, electron_colls_dsmc]
        else:
            electron_colls_mcc = picmi.MCCCollisions(
                name="coll_elec",
                species=self.electrons,
                background_density=self.gas_density,
                background_temperature=self.gas_temp,
                background_mass=self.ions.mass,
                ndt=self.mcc_subcycling_steps,
                scattering_processes=electron_scattering_processes,
            )
            electron_colls = [electron_colls_mcc]

        ion_scattering_processes = {
            "elastic": {"cross_section": cross_sec_direc + "ion_scattering.dat"},
            "back": {"cross_section": cross_sec_direc + "ion_back_scatter.dat"},
            # 'charge_exchange': {'cross_section': cross_sec_direc+'charge_exchange.dat'}
        }
        if self.dsmc:
            ion_colls = picmi.DSMCCollisions(
                name="coll_ion",
                species=[self.ions, self.neutrals],
                ndt=5,
                scattering_processes=ion_scattering_processes,
            )
        else:
            ion_colls = picmi.MCCCollisions(
                name="coll_ion",
                species=self.ions,
                background_density=self.gas_density,
                background_temperature=self.gas_temp,
                ndt=self.mcc_subcycling_steps,
                scattering_processes=ion_scattering_processes,
            )
        ion_colls = [ion_colls]

        #######################################################################
        # Initialize simulation                                               #
        #######################################################################

        self.sim = picmi.Simulation(
            solver=self.solver,
            time_step_size=self.dt,
            max_steps=self.max_steps,
            warpx_collisions=electron_colls + ion_colls,
            verbose=self.test,
        )
        self.solver.sim = self.sim

        self.sim.add_species(
            self.electrons,
            layout=picmi.GriddedLayout(
                n_macroparticle_per_cell=[self.seed_nppc], grid=self.grid
            ),
        )
        self.sim.add_species(
            self.ions,
            layout=picmi.GriddedLayout(
                n_macroparticle_per_cell=[self.seed_nppc], grid=self.grid
            ),
        )
        if self.dsmc:
            self.sim.add_species(
                self.neutrals,
                layout=picmi.GriddedLayout(
                    n_macroparticle_per_cell=[self.seed_nppc // 2], grid=self.grid
                ),
            )
        self.solver.sim_ext = self.sim.extension

        if self.dsmc:
            # Periodically reset neutral density to starting temperature
            callbacks.installbeforecollisions(self.rethermalize_neutrals)

        #######################################################################
        # Add diagnostics for the CI test to be happy                         #
        #######################################################################

        species = [self.electrons, self.ions]
        if self.dsmc:
            species.append(self.neutrals)
        particle_diag = picmi.ParticleDiagnostic(
            species=species,
            name="diag1",
            period=0,
        )
        field_diag = picmi.FieldDiagnostic(
            name="diag1",
            grid=self.grid,
            period=0,
            data_list=["rho_electrons", "rho_he_ions"],
        )
        self.sim.add_diagnostic(particle_diag)
        self.sim.add_diagnostic(field_diag)

    def rethermalize_neutrals(self):
        # When using DSMC the neutral temperature will change due to collisions
        # with the ions. This is not captured in the original MCC test.
        # Re-thermalize the neutrals every 1000 steps
        step = self.sim.extension.warpx.getistep(lev=0)
        if step % 1000 != 10:
            return

        if not hasattr(self, "neutral_cont"):
            self.neutral_cont = particle_containers.ParticleContainerWrapper(
                self.neutrals.name
            )

        ux_arrays = self.neutral_cont.uxp
        uy_arrays = self.neutral_cont.uyp
        uz_arrays = self.neutral_cont.uzp

        xp, _ = load_cupy()

        vel_std = np.sqrt(constants.kb * self.gas_temp / self.m_ion)
        for ii in range(len(ux_arrays)):
            nps = len(ux_arrays[ii])
            ux_arrays[ii][:] = xp.array(vel_std * self.rng.normal(size=nps))
            uy_arrays[ii][:] = xp.array(vel_std * self.rng.normal(size=nps))
            uz_arrays[ii][:] = xp.array(vel_std * self.rng.normal(size=nps))

    def _get_rho_ions(self):
        # deposit the ion density in rho_fp
        he_ions_wrapper = particle_containers.ParticleContainerWrapper("he_ions")
        he_ions_wrapper.deposit_charge_density(level=0)

        rho_data = self.rho_wrapper[...]
        self.ion_density_array += rho_data / constants.q_e / self.diag_steps

    def run_sim(self):
        self.sim.step(self.max_steps - self.diag_steps)

        self.rho_wrapper = fields.RhoFPWrapper(0, False)
        callbacks.installafterstep(self._get_rho_ions)

        self.sim.step(self.diag_steps)

        if self.pythonsolver:
            # confirm that the external solver was run
            assert hasattr(self.solver, "phi")

        if libwarpx.amr.ParallelDescriptor.MyProc() == 0:
            np.save(f"ion_density_case_{self.n + 1}.npy", self.ion_density_array)

        # query the particle z-coordinates if this is run during CI testing
        # to cover that functionality
        if self.test:
            he_ions_wrapper = particle_containers.ParticleContainerWrapper("he_ions")
            nparts = he_ions_wrapper.get_particle_count(local=True)
            z_coords = np.concatenate(he_ions_wrapper.zp)
            assert len(z_coords) == nparts
            assert np.all(z_coords >= 0.0) and np.all(z_coords <= self.gap)


##########################
# parse input parameters
##########################

parser = argparse.ArgumentParser()
parser.add_argument(
    "-t",
    "--test",
    help="toggle whether this script is run as a short CI test",
    action="store_true",
)
parser.add_argument(
    "-n", help="Test number to run (1 to 4)", required=False, type=int, default=1
)
parser.add_argument(
    "--pythonsolver",
    help="toggle whether to use the Python level solver",
    action="store_true",
)
parser.add_argument(
    "--dsmc",
    help="toggle whether to use DSMC for ions in place of MCC",
    action="store_true",
)
args, left = parser.parse_known_args()
sys.argv = sys.argv[:1] + left

if args.n < 1 or args.n > 4:
    raise AttributeError("Test number must be an integer from 1 to 4.")

run = CapacitiveDischargeExample(
    n=args.n - 1, test=args.test, pythonsolver=args.pythonsolver, dsmc=args.dsmc
)
run.run_sim()

Analyze

Once the simulation completes an output file avg_ion_density.npy will be created which can be compared to the literature results as in the plot below. Running case 1 on four CPU processors takes roughly 20 minutes to complete.

Visualize

The figure below shows a comparison of the ion density as calculated in WarpX (in June 2022 with PR #3118) compared to the literature results (which can be found in the supplementary materials of Turner et al.).

MCC benchmark against :cite:t:`ex-Turner2013`.

Fig. 11 MCC benchmark against Turner et al. [15].