import copy
import logging
from struphy import BaseUnits
from struphy.io.options import LiteralOptions
from struphy.models.base import StruphyModel
from struphy.models.scalars import FunctionScalarFEEC, Scalars
from struphy.models.species import (
FieldSpecies,
ParticleSpecies,
)
from struphy.models.variables import FEECVariable, PICVariable
from struphy.propagators.drift_kinetic_maxwell_coupling import DriftKineticMaxwellCoupling
from struphy.propagators.push_guiding_center_bx_estar import PushGuidingCenterBxEstar
from struphy.propagators.push_guiding_center_parallel import PushGuidingCenterParallel
logger = logging.getLogger("struphy")
[docs]
class DriftKineticMaxwell(StruphyModel):
"""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.
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.
"""
@classmethod
def model_type(cls) -> LiteralOptions.ModelTypes:
return "Kinetic"
## species
class EMFields(FieldSpecies):
def __init__(self):
self.e_field = FEECVariable(space="Hcurl")
self.b_field = FEECVariable(space="Hdiv")
self.init_variables()
class KineticIons(ParticleSpecies):
def __init__(
self,
charge_number: int = 1,
mass_number: float = 1.0,
epsilon: float = None,
lam: float = None,
kappa: float = None,
):
self.var = PICVariable(space="Particles5D")
self.init_variables(
charge_number=charge_number,
mass_number=mass_number,
epsilon=epsilon,
lam=lam,
kappa=kappa,
)
## propagators
class Propagators:
def __init__(
self,
e_field: FEECVariable = None,
b_field: FEECVariable = None,
):
# the parallel electric force is applied by the coupling step only: push_para gets no phi and no e_field
self.coupling = DriftKineticMaxwellCoupling()
self.push_para = PushGuidingCenterParallel(b_tilde=b_field)
self.push_bxe = PushGuidingCenterBxEstar(b_tilde=b_field, e_field=e_field)
## 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.params = copy.deepcopy(locals())
# 1. instantiate all species
self.em_fields = self.EMFields()
self.kinetic_ions = self.KineticIons(charge_number, mass_number, epsilon, lam, kappa)
# 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(e_field=self.em_fields.e_field, b_field=self.em_fields.b_field)
# 4. assign variables to propagators
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
self.propagators.push_para.variables.ions = self.kinetic_ions.var
self.propagators.push_bxe.variables.ions = self.kinetic_ions.var
# 5. define scalars to be tracked during simulation: the parts of the energy conserved by the coupling step
# (with the coefficients of the propagator, known after its allocation)
coupling = self.propagators.coupling
electric_energy = FunctionScalarFEEC(lambda: coupling.energies()[0])
magnetic_energy = FunctionScalarFEEC(lambda: coupling.energies()[1])
particle_energy = FunctionScalarFEEC(lambda: coupling.energies()[2])
self.scalars = Scalars(
en_E=electric_energy,
en_B=magnetic_energy,
en_f=particle_energy,
en_tot=electric_energy + magnetic_energy + particle_energy,
)
@property
def bulk_species(self):
return self.kinetic_ions
@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_para = self.propagators.push_para
assert push_para.e_field is None and push_para._allocate_phi, (
"DriftKineticMaxwell: the parallel electric force b* . E is applied by the coupling step; "
"push_para 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``
- Total energy: ``en_tot`` (conserved by the coupling step up to the solver tolerance)"""
[docs]
@classmethod
def doc_discretization(cls):
"""Time integration is performed by the following propagators (in sequence):
1. :class:`~struphy.propagators.drift_kinetic_maxwell_coupling.DriftKineticMaxwellCoupling`
2. :class:`~struphy.propagators.push_guiding_center_parallel.PushGuidingCenterParallel`
3. :class:`~struphy.propagators.push_guiding_center_bx_estar.PushGuidingCenterBxEstar`
"""
doc = rf"""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_para``, 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_para``;
- :math:`\dot{{\mathbf X}} \ni v_\parallel \mathbf b^*` (streaming, curvature drift): ``push_para``; 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.
**1. DriftKineticMaxwellCoupling:**
{DriftKineticMaxwellCoupling.__doc__}
**2. PushGuidingCenterParallel:**
{PushGuidingCenterParallel.__doc__}
**3. PushGuidingCenterBxEstar:**
{PushGuidingCenterBxEstar.__doc__}
"""
return 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:
- polar splines (not supported yet by the coupling propagator)
- 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)"""