Source code for struphy.models.gyrokinetic_darwin

import logging

from struphy import BaseUnits
from struphy.io.options import LiteralOptions
from struphy.models.gyrokinetic_maxwell import GyrokineticMaxwell, _LazyBackground
from struphy.models.scalars import FunctionScalarFEEC
from struphy.models.variables import FEECVariable, PICVariable
from struphy.propagators.base import Propagator
from struphy.propagators.gyrokinetic_darwin import DarwinFieldCoupling
from struphy.propagators.gyrokinetic_maxwell import (
    BackgroundWeights,
    GyroAverage,
    GyroParallelPush,
    PushGuidingCenterParallelFullB,
)

logger = logging.getLogger("struphy")


[docs] class GyrokineticDarwin(GyrokineticMaxwell): """Quasi-neutral gyrokinetic Darwin model of Remmerswaal, Hatzky and Sonnendrücker (2025), Sec. 6: the gauge-invariant gyrokinetic Maxwell model in the limit of quasi-neutrality and without the transversal displacement current, which removes the light wave, the Langmuir wave and the compressional Alfvén wave. Parameters ---------- base_units: BaseUnits Base units for normalization (default: BaseUnits()) species: tuple[tuple[str, int, float]] The gyrocentre species as ``(name, charge_number, mass_number)``; the species are the attributes ``kinetic_<name>`` of the model (default: ions and electrons) with_drifts: bool Whether to include the perpendicular (E x B and grad-B) drifts and their currents, default: True kinetic_backgrounds: bool Whether the background moments of the field equations are taken from the kinetic backgrounds of the species (default), or the polarization from the fluid equilibrium density background_current: bool Whether the equilibrium current :math:`\\nabla \\times \\mathbf B_0` enters Ampère's law (default: False) n_gyro: int Number of points of the gyro-average; 0 is the zero-Larmor-radius limit (default) n_disc: int Number of radial Gauss-Legendre points of the disc average (default: 2) solve_initial_poisson: bool Whether to solve the quasi-neutrality equation for the initial electric field (default: True); the part of the potential that is constant in the perpendicular directions (the kernel of the perpendicular Laplacian, which the polarization cannot balance) is removed """ @classmethod def model_type(cls) -> LiteralOptions.ModelTypes: return "Kinetic" ## propagators class Propagators: def __init__( self, species: dict[str, PICVariable], b_field: FEECVariable = None, background: BackgroundWeights = None, polarization=0.0, background_current: bool = False, gyro: GyroAverage = None, with_drifts: bool = True, ): variables = tuple(species.values()) self.fields = DarwinFieldCoupling( species=variables, background=background, polarization=polarization, background_current=background_current, gyro=gyro, with_drifts=with_drifts, ) for name in species: if gyro is not None and gyro.flr: setattr(self, f"push_para_{name}", GyroParallelPush(b_tilde=b_field, gyro=gyro)) else: setattr(self, f"push_para_{name}", PushGuidingCenterParallelFullB(b_tilde=b_field)) def __init__( self, base_units: BaseUnits = BaseUnits(), species: tuple = GyrokineticMaxwell.DEFAULT_SPECIES, with_drifts: bool = True, kinetic_backgrounds: bool = True, background_current: bool = False, n_gyro: int = 0, n_disc: int = 2, solve_initial_poisson: bool = True, ): super().__init__( base_units=base_units, species=species, with_drifts=with_drifts, kinetic_backgrounds=kinetic_backgrounds, background_current=background_current, n_gyro=n_gyro, n_disc=n_disc, solve_initial_poisson=solve_initial_poisson, measure_gauss_law=False, ) self.params.pop("measure_gauss_law", None) def _make_propagators(self, variables: dict[str, PICVariable]): self.propagators = self.Propagators( species=variables, b_field=self.em_fields.b_field, background=_LazyBackground(self), polarization=self._polarization, background_current=self.background_current, gyro=self.gyro, with_drifts=self.with_drifts, ) def _assign_variables(self, variables: dict[str, PICVariable]): self.propagators.fields.variables.e = self.em_fields.e_field self.propagators.fields.variables.b = self.em_fields.b_field for name, var in variables.items(): getattr(self.propagators, f"push_para_{name}").variables.ions = var def _field_energies(self) -> dict: return { "en_E": FunctionScalarFEEC(self._compute_en_E), "en_B": FunctionScalarFEEC(self._compute_en_B), } def _setup_gauss_law(self): """The quasi-neutrality equation (with the polarization matrix) for the initial electric field.""" PL = self.propagators.fields.longitudinal_operator self._PL = PL self._e_tmp = self.em_fields.e_field.spline.vector.space.zeros() if not self.solve_initial_poisson: return logger.info("\nINITIAL QUASI-NEUTRALITY SOLVE (with the polarization matrix):") grad = Propagator.derham.grad self._charge_accums = {} self._charge_coeffs = {} super()._setup_gauss_law_accumulators() rhs = self._charge_density(self._gauss_rhs) phi = PL.solve_laplacian(rhs) logger.info(f"... {PL.inverse_laplacian._info}") # the kernel of the perpendicular Laplacian (the charge density averaged over the perpendicular # directions, k_perp = 0) cannot be balanced by the perpendicular polarization: that part of the # potential is only set by the regularization and is removed (it is marker noise in a quasi-neutral plasma) removed = PL.remove_average(phi) logger.info(f"... removed the perpendicular average of the potential (norm {removed:.3e})") e = self.em_fields.e_field.spline.vector grad.dot(-phi, out=e) e.update_ghost_regions() def _compute_en_E(self): r"""Longitudinal polarization energy :math:`\frac12 \mathbf e^\top \mathbb P_L \mathbf e` (:math:`\frac12 \mathbf D^{\textnormal{Dar}} \cdot \mathbf E` of Eq. (6.18)).""" e = self.em_fields.e_field.spline.vector self._PL.dot(e, out=self._e_tmp) return 0.5 * self._e_tmp.inner(e) def calculate_gauss_error(self): raise NotImplementedError("The Darwin model enforces the quasi-neutrality equation implicitly.")
[docs] @classmethod def doc_pde(cls): r"""**PDEs solved by model:** The gyrocentre equations of motion of :class:`~struphy.models.gyrokinetic_maxwell.GyrokineticMaxwell`, coupled to the quasi-neutral Darwin field equations (Sec. 6 of the paper) in the gauge-invariant form .. math:: \frac{\partial \mathbf D^{\textnormal{Dar}}}{\partial t} &= \nabla \times \left(\mathbf B - \frac{p_{0,\parallel} - p_{0,\perp}}{B_0^2} \mathbf B_\perp + \sum_s \alpha_s^2 \int f_s\, \mu\, \mathbf b_0 \,\textrm d v_\parallel \textrm d \mu\right) - \sum_s \frac{\alpha_s^2}{\varepsilon_s} \int f_s\, \dot{\mathbf R} \,\textrm d v_\parallel \textrm d \mu\,, \\ \frac{\partial \mathbf B}{\partial t} &= - \nabla \times \mathbf E\,, \qquad \mathbf D^{\textnormal{Dar}} = \Pi_{L,\perp}\left(\sum_s \alpha_s^2 \frac{n_{0,s}}{B_0^2} \mathbf E_\perp\right)\,, where :math:`\Pi_{L,\perp}` is the gyrokinetic longitudinal projection (Eq. (6.6)): compared with the gyrokinetic Maxwell model, the vacuum displacement current is dropped (quasi-neutrality, :math:`\epsilon_0 \to 0`) and only the longitudinal part of the polarization current is kept (Darwin approximation of the second-order Hamiltonian, Eq. (6.5)); for the modes constant in the perpendicular directions (:math:`k_\perp = 0`), where the projection is degenerate, the full polarization is retained (:class:`~struphy.propagators.gyrokinetic_darwin.DarwinLongitudinalOperator`). The divergence of Ampère's law is the quasi-neutrality equation (6.15), its transversal part the constraint of the differential-algebraic system (Eqs. (6.16)); the light, Langmuir and compressional Alfvén waves are removed, the shear Alfvén wave is retained with :math:`\omega = k_\parallel / \sqrt{\sum_s \alpha_s^2 n_{0,s}/B_0^2}` (light units). Reference: R. Remmerswaal, R. Hatzky, E. Sonnendrücker, *Gauge-invariant variational formulations of electromagnetic gyrokinetic theory*, J. Plasma Phys. 91, E127 (2025), `doi:10.1017/S0022377825100688 <https://doi.org/10.1017/S0022377825100688>`_. """
[docs] @classmethod def doc_normalization(cls): r"""As :class:`~struphy.models.gyrokinetic_maxwell.GyrokineticMaxwell` (light units); the vacuum permittivity is dropped from the field equations (quasi-neutrality)."""
[docs] @classmethod def doc_scalar_quantities(cls): r"""**The following scalars are tracked during simulation:** - Longitudinal polarization energy :math:`\frac12 \mathbf e^\top \mathbb P_L \mathbf e`: ``en_E`` - Magnetic field energy (with the magnetizing matrix): ``en_B`` - Species energies :math:`\alpha_s^2 \sum_p w_p (v_\parallel^2/2 + \mu B_\parallel)`: ``en_<name>`` - Total energy: ``en_tot`` - Lost markers: ``n_lost_<name>``"""
[docs] @classmethod def doc_discretization(cls): """Time integration is performed by the following propagators (in sequence): 1. :class:`~struphy.propagators.gyrokinetic_darwin.DarwinFieldCoupling` (the fields, the parallel velocities and the drifts of all species) 2. :class:`~struphy.propagators.gyrokinetic_maxwell.PushGuidingCenterParallelFullB` (one per species, ZLR) or :class:`~struphy.propagators.gyrokinetic_maxwell.GyroParallelPush` (finite Larmor radius) """ doc = rf"""**1. DarwinFieldCoupling:** {DarwinFieldCoupling.__doc__} **2. PushGuidingCenterParallelFullB / GyroParallelPush (one per species):** {GyroParallelPush.__doc__} The field equations are a differential-algebraic system: all couplings are solved in one implicit mid-point step, which conserves the Darwin energy exactly for the parallel and E x B couplings. The system is preconditioned by the weighted mass matrix of the polarization plus the continuum limit of the skin term (:func:`~struphy.propagators.gyrokinetic_maxwell.skin_preconditioner_matrix`); the solver tolerances are relative to the right-hand side. """ return doc
[docs] @classmethod def doc_long_description(cls): r"""GyrokineticDarwin is the quasi-neutral gyrokinetic Darwin approximation of the gauge-invariant gyrokinetic Maxwell model (Remmerswaal, Hatzky, Sonnendrücker, J. Plasma Phys. 91, E127, 2025, Sec. 6). The fast light, Langmuir and compressional Alfvén waves are removed, the model is still gauge invariant and compatible, and the shear Alfvén wave is retained. Since the model is gauge invariant it is solved in the fields :math:`\mathbf E`, :math:`\mathbf B` (no potentials and no gauge multiplier): the longitudinal polarization operator replaces the dielectric matrix of the Maxwell model and the field equations become a differential-algebraic system, solved implicitly together with the particle couplings. Same species, background and finite-Larmor-radius options as the Maxwell model."""
[docs] @classmethod def doc_examples(cls): r"""Create and initialize the model: .. code-block:: python from struphy.models import GyrokineticDarwin model = GyrokineticDarwin(species=(("ions", 1, 1.0), ("electrons", -1, 1 / 100))) model.em_fields.e_field model.em_fields.b_field model.kinetic_ions.var ``b_field`` is the perturbation of the equilibrium field; the initial electric field follows from the quasi-neutrality equation (``solve_initial_poisson``). """
[docs] @classmethod def doc_use_cases(cls): r"""This model is appropriate for: - low-frequency electromagnetic gyrokinetic simulations without the fast waves (large time steps) - comparison of the Darwin approximation with the gyrokinetic Maxwell model - backgrounds with parallel flow and pressure anisotropy, finite Larmor radius effects"""
[docs] @classmethod def doc_cannot_be_used_for(cls): r"""This model is not suitable for: - the compressional Alfvén, light or Langmuir waves (removed by the approximation) - delta-f (control variate) simulations and polar-spline domains - backgrounds with parallel flow (the flow terms of Eq. (2.3) are not yet included in the Darwin solve)"""