Source code for struphy.models.linear_vlasov_ampere_one_species

import copy
import logging

import cunumpy as xp
from feectools.ddm.mpi import mpi as MPI

from struphy import BaseUnits
from struphy.io.options import LiteralOptions
from struphy.kinetic_background.maxwellians import Maxwellian3D
from struphy.models.base import StruphyModel
from struphy.models.scalars import BilinearEnergyFEEC, FunctionScalarPIC, Scalars
from struphy.models.species import (
    FieldSpecies,
    ParticleSpecies,
)
from struphy.models.variables import FEECVariable, PICVariable
from struphy.pic.accumulation import accum_kernels
from struphy.pic.accumulation.particles_to_grid import ParticlesToGrid
from struphy.propagators.base import Propagator
from struphy.propagators.efield_weights_coupling import EfieldWeightsCoupling
from struphy.propagators.poisson_solve import PoissonSolve
from struphy.propagators.push_eta import PushEta
from struphy.propagators.push_vin_efield import PushVinEfield
from struphy.propagators.push_vxb import PushVxB
from struphy.utils.pyccel import Pyccelkernel

logger = logging.getLogger("struphy")
rank = MPI.COMM_WORLD.Get_rank()


[docs] class LinearVlasovAmpereOneSpecies(StruphyModel): """Linearized Vlasov-Ampère equations for one kinetic species around a Maxwellian background. Parameters ---------- base_units: BaseUnits Base units for normalization (default: BaseUnits()) charge_number: int Charge number (in units of the positive elementary charge) of the species (default: 1) mass_number: float Mass number (in units of Proton mass) of the species (default: 1.0) alpha: float, optional Dimensionless parameter: plasma frequency / cyclotron frequency. If None, computed from units and charge/mass numbers. epsilon: float, optional Normalized cyclotron period: 1 / (cyclotron frequency × time unit). If None, computed from units and charge/mass numbers. with_B0: bool Whether to include the effect of a background magnetic field B0 (default: True) with_E0: bool Whether to include the effect of a background electric field E0 (default: True) """ @classmethod def model_type(cls) -> LiteralOptions.ModelTypes: return "Kinetic" ## species class EMFields(FieldSpecies): def __init__(self): self.e_field = FEECVariable(space="Hcurl") self.phi = FEECVariable(space="H1") self.init_variables() class KineticIons(ParticleSpecies): def __init__( self, charge_number: int = 1, mass_number: float = 1.0, alpha: float = None, epsilon: float = None, ): self.var = PICVariable(space="DeltaFParticles6D") self.init_variables( charge_number=charge_number, mass_number=mass_number, alpha=alpha, epsilon=epsilon, ) ## propagators class Propagators: def __init__( self, with_B0: bool = True, with_E0: bool = True, ): self.push_eta = PushEta() if with_E0: self.push_vinE = PushVinEfield() self.coupling_Eweights = EfieldWeightsCoupling() if with_B0: self.push_vxb = PushVxB() ## abstract methods def __init__( self, base_units: BaseUnits = BaseUnits(), charge_number: int = 1, mass_number: float = 1.0, alpha: float = None, epsilon: float = None, with_B0: bool = True, with_E0: bool = True, ): # 0. store input parameters self.params = copy.deepcopy(locals()) # 1. instantiate all species self.em_fields = self.EMFields() self.kinetic_ions = self.KineticIons( charge_number, mass_number, alpha, epsilon, ) # 2. derive units (must be done after instantiating species to access charge and mass numbers) self.setup_equation_params(base_units=base_units) # 3. instantiate all propagators self.propagators = self.Propagators(with_B0=with_B0, with_E0=with_E0) # 4. assign variables to propagators self.propagators.push_eta.variables.var = self.kinetic_ions.var if with_E0: self.propagators.push_vinE.variables.var = self.kinetic_ions.var self.propagators.coupling_Eweights.variables.e = self.em_fields.e_field self.propagators.coupling_Eweights.variables.ions = self.kinetic_ions.var if with_B0: self.propagators.push_vxb.variables.ions = self.kinetic_ions.var # 5. define scalars to be tracked during simulation electric_energy = BilinearEnergyFEEC(self.em_fields.e_field) particle_energy = FunctionScalarPIC( self._compute_en_w, self.kinetic_ions.var, ) self.scalars = Scalars( en_E=electric_energy, en_w=particle_energy, en_tot=electric_energy + particle_energy, ) # initial Poisson (not a propagator used in time stepping) alpha = self.kinetic_ions.equation_params.alpha epsilon = self.kinetic_ions.equation_params.epsilon particles_to_grid = ParticlesToGrid( self.kinetic_ions.var, "H1", Pyccelkernel(accum_kernels.charge_density_0form), ) self.initial_poisson = PoissonSolve( rho=particles_to_grid, rho_coeffs=alpha**2 / epsilon, ) self.initial_poisson.variables.phi = self.em_fields.phi @property def bulk_species(self): return self.kinetic_ions @property def velocity_scale(self): return "light" def allocate_helpers(self): """Solve initial Poisson equation. :meta private: """ self._tmp = xp.empty(1, dtype=float) logger.info("\nINITIAL POISSON SOLVE:") # use control variate method particles = self.kinetic_ions.var.particles particles.update_weights() self.initial_poisson.allocate() # Solve with dt=1. and compute electric field logger.info("\nSolving initial Poisson problem...") self.initial_poisson(1.0) phi = self.initial_poisson.variables.phi.spline.vector Propagator.derham.grad.dot(-phi, out=self.em_fields.e_field.spline.vector) logger.info("... Done.") def _compute_en_w(self): particles = self.kinetic_ions.var.particles # evaluate f0 if not hasattr(self, "_f0"): backgrounds = self.kinetic_ions.var.backgrounds if isinstance(backgrounds, list): self._f0 = backgrounds[0] else: self._f0 = backgrounds self._f0_values = xp.zeros( self.kinetic_ions.var.particles.markers.shape[0], dtype=float, ) assert isinstance(self._f0, Maxwellian3D) self._f0_values[particles.valid_mks] = self._f0(*particles.phasespace_coords.T) # alpha^2 * v_th^2 / (2*N) * sum_p s_0 * w_p^2 / f_{0,p} alpha = self.kinetic_ions.equation_params.alpha vth = self._f0.params["vth1"][0] self._tmp[0] = ( alpha**2 * vth**2 / (2 * particles.Np) * xp.dot( particles.weights**2, # w_p^2 particles.sampling_density / self._f0_values[particles.valid_mks], # s_{0,p} / f_{0,p} ) ) return self._tmp[0] ## default parameters def generate_default_parameter_file(self, path=None, prompt=True): params_path = super().generate_default_parameter_file(path=path, prompt=prompt) new_file = [] with open(params_path, "r") as f: for line in f: if "maxwellian_1 + maxwellian_2" in line: new_file += ["background = maxwellian_1\n"] elif "maxwellian_1pt =" in line: new_file += ["maxwellian_1pt = maxwellians.Maxwellian3D(n=(0.0, perturbation))\n"] elif "saving_params = " in line: new_file += ["\nbinplot = BinningPlot(slice='e1', n_bins=128, ranges=(0.0, 1.0))\n"] new_file += ["saving_params = SavingParameters(binning_plots=(binplot,))\n\n"] else: new_file += [line] with open(params_path, "w") as f: for line in new_file: f.write(line)
[docs] @classmethod def doc_pde(cls): r"""**PDEs solved by model:** Linearized Ampère's law: .. math:: \frac{\partial \tilde{\mathbf{E}}}{\partial t} = -\frac{\alpha^2}{\varepsilon} \int_{\mathbb{R}^3} \mathbf{v} \tilde{f} \, \textrm{d}^3 \mathbf{v} Linearized Vlasov equation: .. math:: \frac{\partial \tilde{f}}{\partial t} + \mathbf{v} \cdot \nabla \tilde{f} + \frac{1}{\varepsilon} \left( \mathbf{E}_0 + \mathbf{v} \times \mathbf{B}_0 \right) \cdot \frac{\partial \tilde{f}}{\partial \mathbf{v}} = \frac{1}{v_{\text{th}}^2 \varepsilon} \tilde{\mathbf{E}} \cdot \mathbf{v} f_0 where :math:`Z=-1` and :math:`A=1/1836` for electrons. The background distribution function :math:`f_0` is a uniform Maxwellian .. math:: f_0 = \frac{n_0(\mathbf{x})}{\left( \sqrt{2 \pi} v_{\text{th}} \right)^3} \exp \left( - \frac{|\mathbf{v}|^2}{2 v_{\text{th}}^2} \right) and the background electric field has to verify the following compatibility condition between with background density .. math:: \nabla_{\mathbf{x}} \ln (n_0(\mathbf{x})) = \frac{1}{v_{\text{th}}^2 \varepsilon} \mathbf{E}_0 At initial time the weak Poisson equation is solved once to weakly satisfy Gauss' law, .. math:: \int_\Omega \nabla \psi^\top \cdot \nabla \phi \, \textrm{d} \mathbf{x} &= \frac{\alpha^2}{\varepsilon} \int_\Omega \int_{\mathbb{R}^3} \psi \, \tilde{f} \, \text{d}^3 \mathbf{v} \, \textrm{d} \mathbf{x} \qquad \forall \ \psi \in H^1 \\[2mm] \tilde{\mathbf{E}}(t=0) &= -\nabla \phi(t=0) Moreover, it is assumed that .. math:: \int_{\mathbb{R}^3} \mathbf{v} f_0 \, \text{d}^3 \mathbf{v} = 0 """
[docs] @classmethod def doc_normalization(cls): r"""The light speed defines the velocity scale: .. math:: \hat v = c,\qquad \hat E = \hat B \hat v,\qquad \hat\phi = \hat E \hat x. The species parameters are :math:`\alpha=\hat\Omega_p/\hat\Omega_c` and :math:`\varepsilon=1/(\hat\Omega_c\hat t)`."""
[docs] @classmethod def doc_scalar_quantities(cls): r"""**The following scalars are tracked during simulation:** - Electric field energy: ``en_E`` - Perturbation particle energy: ``en_w`` - Total energy: ``en_tot``"""
[docs] @classmethod def doc_discretization(cls): """Time integration is performed by the following propagators (in sequence): 1. :class:`~struphy.propagators.push_eta.PushEta` 2. :class:`~struphy.propagators.push_vin_efield.PushVinEfield` (if :attr:`with_E0` is True) 3. :class:`~struphy.propagators.efield_weights_coupling.EfieldWeightsCoupling` 4. :class:`~struphy.propagators.push_vxb.PushVxB` (if :attr:`with_B0` is True) """ doc = rf"""**1. push_eta.PushEta:** {PushEta.__doc__} **2. push_vin_efield.PushVinEfield:** {PushVinEfield.__doc__} **3. efield_weights_coupling.EfieldWeightsCoupling:** {EfieldWeightsCoupling.__doc__} **4. push_vxb.PushVxB:** {PushVxB.__doc__} """ return doc
[docs] @classmethod def doc_long_description(cls): r"""LinearVlasovAmpereOneSpecies is the linearized delta-f counterpart of VlasovAmpereOneSpecies. It is intended for weakly perturbed kinetic problems around a prescribed Maxwellian equilibrium and is especially useful for linear instability or damping studies."""
[docs] @classmethod def doc_examples(cls): r"""Create and initialize the linear Vlasov-Ampère model: .. code-block:: python from struphy.models import LinearVlasovAmpereOneSpecies model = LinearVlasovAmpereOneSpecies() model.em_fields.e_field model.em_fields.phi model.kinetic_ions.var """
[docs] @classmethod def doc_use_cases(cls): r"""This model is appropriate for: - linear delta-f kinetic instabilities - weakly perturbed Landau-like damping studies with electrostatic fields - verification of linearized field-particle coupling"""
[docs] @classmethod def doc_cannot_be_used_for(cls): r"""This model is not suitable for: - strongly nonlinear departures from the chosen equilibrium - multi-species kinetic coupling - fully electromagnetic magnetic-field evolution - equilibria that are not compatible with the built-in Maxwellian assumptions"""