Source code for struphy.models.drift_kinetic_maxwell

from struphy import BaseUnits
from struphy.models.guiding_center import GuidingCenter
from struphy.models.scalars import FunctionScalarFEEC
from struphy.models.species import FieldSpecies
from struphy.models.variables import FEECVariable
from struphy.propagators.drift_kinetic_maxwell_coupling import DriftKineticMaxwellCoupling


[docs] class DriftKineticMaxwell(GuidingCenter): """Electromagnetic drift-kinetic equation for one guiding-center species coupled to Maxwell's equations, in the thermal normalization, with an implicit, energy conserving field-particle coupling that also holds in the quasi-neutral limit. The electric field enters the guiding-center equations through the gauge-invariant :math:`\\mathbf E^* = \\mathbf E - \\varepsilon \\mu \\nabla |B|`. Ampère's law contains the guiding-center current and the (linearized) polarization current; the displacement current has the coefficient :math:`\\lambda^2 \\varepsilon^2 = \\hat v_\\textrm{th}^2/c^2`, option ``lam`` of the coupling propagator :class:`~struphy.propagators.drift_kinetic_maxwell_coupling.DriftKineticMaxwellCoupling`; ``lam=0.0`` is the quasi-neutral limit. Extends :class:`~struphy.models.GuidingCenter` by the electromagnetic fields. Parameters ---------- base_units: BaseUnits Base units for normalization (default: ``BaseUnits(kBT=1.0)``; the thermal energy defines the velocity unit). 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) epsilon: float, optional Normalized cyclotron period: 1 / (cyclotron frequency × time unit). If None, computed from units and charge/mass numbers. lam: float, optional Cyclotron frequency × length unit / speed of light. If None, computed from units and charge/mass numbers. kappa: float, optional Plasma frequency × length unit / speed of light. If None, computed from units and charge/mass numbers. """ ## additional species class EMFields(FieldSpecies): def __init__(self): self.e_field = FEECVariable(space="Hcurl") self.b_field = FEECVariable(space="Hdiv") self.init_variables() ## additional propagators class Propagators(GuidingCenter.Propagators): """Propagators of :class:`~struphy.models.GuidingCenter` plus ``coupling`` (Maxwell's equations, currents and the parallel electric force in one implicit step). Parameters ---------- **kwargs Passed to :class:`GuidingCenter.Propagators <struphy.models.GuidingCenter.Propagators>`. """ splitting_order = ("coupling", "push_parallel", "push_bxe") def __init__(self, **kwargs): super().__init__(**kwargs) self.coupling = DriftKineticMaxwellCoupling() # propagators renamed to the names of GuidingCenter (old name -> new name), for the options in old run metadata _renamed_propagators = {"push_para": "push_parallel"} # attribute types for IDEs and type checkers: the classes of this model, not those of the parent em_fields: EMFields propagators: Propagators ## abstract methods def __init__( self, base_units: BaseUnits = BaseUnits(kBT=1.0), charge_number: int = 1, mass_number: float = 1.0, epsilon: float = None, lam: float = None, kappa: float = None, ): # 0. store input parameters self._store_params(locals()) # 1.-5. see GuidingCenter super().__init__(base_units, charge_number, mass_number, epsilon) def _create_species(self): """:meta private:""" self.em_fields = self.EMFields() super()._create_species() def _propagator_args(self): """:meta private:""" # the pushers of GuidingCenter with the total magnetic field B0 + b_field and e_field in the E* x b drift; # the parallel electric force is applied by the coupling step only (push_parallel gets no phi, no e_field) return {**super()._propagator_args(), "b_tilde": self.em_fields.b_field, "e_field": self.em_fields.e_field} def _link_propagator_variables(self): """:meta private:""" super()._link_propagator_variables() self.propagators.coupling.variables.e = self.em_fields.e_field self.propagators.coupling.variables.b = self.em_fields.b_field self.propagators.coupling.variables.ions = self.kinetic_ions.var def _create_scalars(self): """The parts of the energy conserved by the coupling step (with the coefficients of the propagator, known after its allocation). :meta private: """ scalars = super()._create_scalars() # the particle energy of the coupling step contains the magnetic-moment energy: it replaces en_f and en_mu scalars.pop("en_mu") scalars.pop("en_tot") coupling = self.propagators.coupling en_E = FunctionScalarFEEC(lambda: coupling.energies()[0]) en_B = FunctionScalarFEEC(lambda: coupling.energies()[1]) scalars["en_f"] = FunctionScalarFEEC(lambda: coupling.energies()[2]) return {"en_E": en_E, "en_B": en_B, **scalars, "en_tot": en_E + en_B + scalars["en_f"]} @property def velocity_scale(self): return "thermal" def post_allocate(self): """Check that the parallel electric force is applied only once (by the coupling step), then no initial field solve: the electric field is initialized from the parameter file. :meta private: """ push_parallel = self.propagators.push_parallel assert push_parallel.e_field is None and push_parallel._allocate_phi, ( "DriftKineticMaxwell: the parallel electric force b* . E is applied by the coupling step; " "push_parallel must not get an electric field or a potential (it would be applied twice)." ) ## default parameters def generate_default_parameter_file(self, path=None, prompt=True): """The thermal velocity scale needs the thermal energy unit: ``BaseUnits(kBT=1.0)``. :meta private: """ params_path = super().generate_default_parameter_file(path=path, prompt=prompt) with open(params_path, "r") as f: lines = f.readlines() with open(params_path, "w") as f: for line in lines: f.write("base_units = BaseUnits(kBT=1.0)\n" if "BaseUnits(" in line else line) return params_path
[docs] @classmethod def doc_pde(cls): r"""**PDEs solved by model:** Drift-kinetic equation for :math:`f(\mathbf X, v_\parallel, \mu, t)`: .. math:: \frac{\partial f}{\partial t} + \frac{v_\parallel \mathbf B^* + \mathbf E^* \times \mathbf b_0}{B^*_\parallel} \cdot \nabla f + \frac{1}{\varepsilon} \frac{\mathbf B^* \cdot \mathbf E^*}{B^*_\parallel} \frac{\partial f}{\partial v_\parallel} = 0\,, with .. math:: \mathbf E^* = \mathbf E - \varepsilon \mu \nabla |\mathbf B|\,,\qquad \mathbf B^* = \mathbf B_0 + \tilde{\mathbf B} + \varepsilon v_\parallel \nabla \times \mathbf b_0\,,\qquad B^*_\parallel = \mathbf b_0 \cdot \mathbf B^*\,, where :math:`|\mathbf B| = \mathbf b_0 \cdot (\mathbf B_0 + \tilde{\mathbf B})`: the total magnetic field in the first term of :math:`\mathbf B^*` (parallel streaming along the perturbed field lines), the curvature term with the equilibrium unit vector :math:`\mathbf b_0`. Ampère's law, with the displacement current and the (linearized) polarization current of the guiding centers: .. math:: \varepsilon^2 \lambda^2 \frac{\partial \mathbf E}{\partial t} + \varepsilon^2 \kappa^2 \frac{n_0}{|\mathbf B_0|^2} \frac{\partial \mathbf E_\perp}{\partial t} = \nabla \times \tilde{\mathbf B} - \varepsilon \kappa^2 \iint f B^*_\parallel \left( v_\parallel \frac{\mathbf B^*}{B^*_\parallel} + \frac{\mathbf E \times \mathbf b_0}{B^*_\parallel} \right) \textrm d v_\parallel \textrm d \mu\,, Faraday's law: .. math:: \frac{\partial \tilde{\mathbf B}}{\partial t} + \nabla \times \mathbf E = 0\,. :math:`\mathbf B_0` is a static equilibrium field, :math:`n_0` the equilibrium density, and :math:`\mathbf E_\perp = \mathbf E - (\mathbf b_0 \cdot \mathbf E) \mathbf b_0`. The current in Ampère's law is the guiding-center current without the magnetic (grad-:math:`B`) drift, whose work on :math:`\mathbf E` is therefore missing where :math:`\nabla |B| \neq 0`. The coefficients :math:`\varepsilon\lambda` (option ``lam``, 0 is the quasi-neutral limit), :math:`(\varepsilon\kappa)^2` (option ``chi``) and :math:`\varepsilon\kappa^2` (option ``c2``) can be changed in :class:`~struphy.propagators.drift_kinetic_maxwell_coupling.DriftKineticMaxwellCoupling`. Without the polarization current the quasi-neutral limit would be singular for flute modes (:math:`k_\parallel = 0`). """
[docs] @classmethod def doc_normalization(cls): r"""The model uses the thermal velocity of the species as reference velocity: .. math:: \hat v = \hat v_\textrm{th} = \sqrt{\frac{k_\textrm{B} \hat T}{m}}\,,\qquad \hat t = \frac{\hat x}{\hat v}\,, \qquad \hat E = \hat v \hat B\,,\qquad \hat \mu = \frac{m \hat v^2}{\hat B}\,. The species parameters (see :ref:`equation_params`) are .. math:: \varepsilon = \frac{1}{\hat\Omega_c \hat t} = \frac{\hat v}{\hat\Omega_c \hat x}\,,\qquad \lambda = \frac{\hat\Omega_c \hat x}{c}\,,\qquad \kappa = \frac{\hat\Omega_p \hat x}{c}\,. The drift-kinetic equation and Faraday's law then have only the coefficient :math:`\varepsilon` (:math:`\frac{m \hat v}{q \hat B \hat x} = \varepsilon` in :math:`\mathbf B^*` and :math:`\mathbf E^*`, :math:`\frac{q \hat B \hat t}{m} = \frac{1}{\varepsilon}` in the parallel force). Ampère's law :math:`\frac{1}{c^2} \partial_t \mathbf E + \mu_0 \frac{n m}{|B|^2} \partial_t \mathbf E_\perp = \nabla \times \mathbf B - \mu_0 q \mathbf J`, multiplied by :math:`\hat x / \hat B`, has the coefficients .. math:: \frac{\hat v^2}{c^2} = \varepsilon^2 \lambda^2\,,\qquad \frac{\mu_0 \hat n m \hat v^2}{\hat B^2} = \frac{\hat v^2}{\hat v_A^2} = \varepsilon^2 \kappa^2\,,\qquad \frac{\mu_0 q \hat n \hat v \hat x}{\hat B} = \varepsilon \kappa^2\,. Their ratio :math:`\kappa^2/\lambda^2 = \hat\Omega_p^2/\hat\Omega_c^2 = c^2/\hat v_A^2` compares the polarization with the vacuum dielectric, and :math:`1/\kappa` is the inertial length :math:`c/\hat\Omega_p` in units of :math:`\hat x`. For example, deuterium with :math:`\hat x = 1` m, :math:`\hat B = 5` T, :math:`\hat n = 10^{20}` m\ :sup:`-3`, :math:`k_\textrm{B} \hat T = 10` keV gives :math:`\varepsilon = 2.9 \cdot 10^{-3}`, :math:`\lambda = 0.80`, :math:`\kappa = 31`, i.e. :math:`\varepsilon^2 \lambda^2 = 5.3 \cdot 10^{-6}`, :math:`\varepsilon^2 \kappa^2 = 8.0 \cdot 10^{-3}` and :math:`\varepsilon \kappa^2 = 2.8`."""
[docs] @classmethod def doc_scalar_quantities(cls): r"""**The following scalars are tracked during simulation** (the parts of the energy conserved by the coupling step, with the coefficients of :class:`~struphy.propagators.drift_kinetic_maxwell_coupling.DriftKineticMaxwellCoupling`, here the defaults): - Electric field energy (displacement and polarization) :math:`\frac{\varepsilon^2 \lambda^2}{2} \| \mathbf E \|^2 + \frac{\varepsilon^2 \kappa^2}{2} \| \mathbf E_\perp \|^2_{n_0/|B_0|^2}`: ``en_E`` - Magnetic field energy :math:`\frac{1}{2} \| \tilde{\mathbf B} \|^2`: ``en_B`` - Particle energy :math:`\varepsilon^2 \kappa^2 \sum_p w_p (v_{\parallel,p}^2/2 + \mu_p |B_0|)`: ``en_f``; unlike in :class:`~struphy.models.GuidingCenter` it contains the magnetic-moment energy (there is no ``en_mu``) and the coefficients of the coupling step - Total energy: ``en_tot`` (conserved by the coupling step up to the solver tolerance) - Lost markers: ``n_lost_particles``"""
[docs] @classmethod def doc_discretization(cls): r"""Time integration is performed by the following propagators (in sequence). Each term of the drift-kinetic equation is applied by exactly one propagator; the parallel electric force only by the coupling step (the model passes no potential and no electric field to ``push_parallel``, and asserts this): - :math:`\dot v_\parallel \ni \frac{1}{\varepsilon} \mathbf b^* \cdot \mathbf E`: coupling step (``push_vpar_with_efield``); - :math:`\dot v_\parallel \ni -\mu\, \mathbf b^* \cdot \nabla |B|` (mirror force): ``push_parallel``; - :math:`\dot{\mathbf X} \ni v_\parallel \mathbf b^*` (streaming, curvature drift): ``push_parallel``; its current enters Ampère's law in the coupling step; - :math:`\dot{\mathbf X} \ni \mathbf E \times \mathbf b_0 / B^*_\parallel`: ``push_bxe``; its current enters Ampère's law in the coupling step (option ``exb_current``); - :math:`\dot{\mathbf X} \ni -\varepsilon \mu \nabla |B| \times \mathbf b_0 / B^*_\parallel` (grad-:math:`B` drift): ``push_bxe``; its current is not included.""" return cls.propagators_doc()
[docs] @classmethod def doc_long_description(cls): r"""DriftKineticMaxwell couples one drift-kinetic (guiding-center) species to Maxwell's equations in the gauge-invariant :math:`v_\parallel` formulation (the electric field, not a potential, enters :math:`\mathbf E^*`), normalized with the thermal velocity of the species. Maxwell's equations and the parallel acceleration are solved together in one implicit, energy conserving step, which allows time steps above the plasma period and has no cancellation problem. Since the drift-kinetic ordering removes the polarization drift, its (linearized) polarization current is added to Ampère's law; with it, the quasi-neutral limit without displacement current is well-posed and gives the inertial Alfvén wave. The guiding centers then stream along the field and drift with :math:`\mathbf E^* \times \mathbf b_0`. The total energy is conserved exactly by the splitting if :math:`\nabla |B_0| = 0` and either :math:`\mu = 0` or :math:`\mathbf b_0 \cdot \tilde{\mathbf B} = 0`."""
[docs] @classmethod def doc_examples(cls): r"""Create a drift-kinetic Maxwell model in the quasi-neutral limit: .. code-block:: python from struphy.models import DriftKineticMaxwell model = DriftKineticMaxwell() prop = model.propagators.coupling prop.options = prop.Options(lam=0.0) """
[docs] @classmethod def doc_use_cases(cls): r"""This model is appropriate for: - electromagnetic drift-kinetic simulations with time steps above the plasma period - shear (inertial) Alfvén waves with kinetic parallel dynamics - the quasi-neutral limit (``lam=0.0``) with full-f weights"""
[docs] @classmethod def doc_cannot_be_used_for(cls): r"""This model is not suitable for: - the quasi-neutral limit with control-variate (delta-f) weights or with vacuum regions - finite Larmor radius effects beyond the linearized long-wavelength polarization - exact energy conservation in inhomogeneous :math:`|B_0|` (the grad-:math:`B` drift current is missing)"""