import copy
import logging
import cunumpy as xp
from feectools.linalg.solvers import inverse
from maybempi import MPI
from struphy import BaseUnits
from struphy.feec import preconditioner
from struphy.io.options import LiteralOptions
from struphy.models.base import StruphyModel
from struphy.models.scalars import (
BilinearEnergyFEEC,
FunctionScalarFEEC,
FunctionScalarPIC,
KineticEnergyPIC,
LostMarkersPIC,
Scalars,
)
from struphy.models.species import (
FieldSpecies,
ParticleSpecies,
)
from struphy.models.variables import FEECVariable, PICVariable
from struphy.pic.accumulation.kernels.gc_density_0form import gc_density_0form
from struphy.pic.accumulation.kernels.gk_density_gyro import gk_density_gyro
from struphy.pic.accumulation.particles_to_grid import AccumulatorVector
from struphy.pic.diagnostics.kernels.gk_energy_gyro import gk_energy_gyro
from struphy.propagators.base import Propagator
from struphy.propagators.gyrokinetic_maxwell import (
BackgroundWeights,
GyroAverage,
GyroDriftCoupling,
GyroEparCoupling,
GyroParallelPush,
MaxwellGyroMagnetisation,
PushGuidingCenterParallelFullB,
SpeciesBackground,
dielectric_matrix,
magnetizing_matrix,
parallel_flow_from_equilibrium,
)
logger = logging.getLogger("struphy")
[docs]
class GyrokineticMaxwell(StruphyModel):
"""Gauge-invariant electromagnetic gyrokinetic Maxwell model of Remmerswaal, Hatzky and Sonnendrücker (2025):
gyrocentre species (5D) coupled to Maxwell's equations with the polarization, magnetisation and the
gyro-averaged currents of the gyrocentres.
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
(:class:`~struphy.propagators.gyrokinetic_maxwell.GyroDriftCoupling`), default: True
kinetic_backgrounds: bool
Whether the background moments of the field equations (polarization, parallel flow, pressure anisotropy)
are taken from the kinetic backgrounds of the species (default), or the polarization from the fluid
equilibrium density for every species (no flow, isotropic)
background_current: bool
Whether the equilibrium current :math:`\\nabla \\times \\mathbf B_0` enters Ampère's law, to be balanced by the
gyrocentre currents of the backgrounds (see :meth:`parallel_flow`), default: False
n_gyro: int
Number of points of the gyro-average (ring) of the fields at the gyrocentres; 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 Gauss law with the dielectric matrix for the initial electric field (default: True)
measure_gauss_law: bool
Whether to track the Gauss-law error as a scalar quantity (default: False)
"""
@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 Gyrocentres(ParticleSpecies):
def __init__(self, charge_number: int = 1, mass_number: float = 1.0):
self.var = PICVariable(space="Particles5D")
self.init_variables(charge_number=charge_number, mass_number=mass_number)
DEFAULT_SPECIES = (("ions", 1, 1.0), ("electrons", -1, 1.0 / 1836))
## 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.maxwell = MaxwellGyroMagnetisation(
species=variables,
background=background,
polarization=None if background is not None else polarization,
background_current=background_current,
gyro=gyro,
)
for name in species:
setattr(self, f"epar_{name}", GyroEparCoupling(b_tilde=b_field, polarization=polarization, gyro=gyro))
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))
if with_drifts:
self.drifts = GyroDriftCoupling(
species=variables, b_tilde=b_field, polarization=polarization, gyro=gyro
)
## abstract methods
def __init__(
self,
base_units: BaseUnits = BaseUnits(),
species: tuple = 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,
measure_gauss_law: bool = False,
):
# 0. store input parameters
self.params = copy.deepcopy(locals())
self.params["species"] = tuple(tuple(s) for s in species)
# 1. instantiate all species
self.em_fields = self.EMFields()
self._species_names = []
for name, charge_number, mass_number in species:
setattr(self, f"kinetic_{name}", self.Gyrocentres(int(charge_number), float(mass_number)))
self._species_names += [name]
assert len(self._species_names) > 0, "At least one gyrocentre species is needed."
# 2. derive units (must be done after instantiating species to access charge and mass numbers)
self.setup_equation_params(base_units=base_units)
self._alpha2 = {
name: self.gyrocentres(name).var.species.equation_params.alpha ** 2 for name in self._species_names
}
self._polarization_constant = float(sum(self._alpha2.values()))
self.kinetic_backgrounds = kinetic_backgrounds
self.background_current = background_current
self.gyro = GyroAverage(n_gyro=n_gyro, n_disc=n_disc)
self.solve_initial_poisson = solve_initial_poisson
self.measure_gauss_law = measure_gauss_law
# the background weights are built in post_allocate (they need the kinetic backgrounds and the equilibrium);
# until then the propagators hold a placeholder that is resolved at allocation
self._background = None
self._polarization = _LazyPolarization(self)
# 3. instantiate all propagators
variables = {name: self.gyrocentres(name).var for name in self._species_names}
self.with_drifts = with_drifts
self._make_propagators(variables)
# 4. assign variables to propagators
self._assign_variables(variables)
# 5. define scalars to be tracked during simulation
scalars_dict = dict(self._field_energies())
total = None
for scalar in scalars_dict.values():
total = scalar if total is None else total + scalar
for name, var in variables.items():
energy = KineticEnergyPIC(var, normalization=self._alpha2[name]) + FunctionScalarPIC(
lambda name=name: self._compute_en_muB(name), var
)
scalars_dict[f"en_{name}"] = energy
total = energy if total is None else total + energy
scalars_dict["en_tot"] = total
for name, var in variables.items():
scalars_dict[f"n_lost_{name}"] = LostMarkersPIC(var)
if measure_gauss_law:
scalars_dict["gauss_error"] = FunctionScalarFEEC(self.calculate_gauss_error)
self.scalars = Scalars(**scalars_dict)
def _make_propagators(self, variables: dict[str, PICVariable]):
"""Instantiate the propagators (the field operators' backgrounds are resolved lazily at allocation)."""
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]):
"""Assign the model variables to the propagators."""
self.propagators.maxwell.variables.e = self.em_fields.e_field
self.propagators.maxwell.variables.b = self.em_fields.b_field
for name, var in variables.items():
epar = getattr(self.propagators, f"epar_{name}")
epar.variables.e = self.em_fields.e_field
epar.variables.var = var
getattr(self.propagators, f"push_para_{name}").variables.ions = var
if self.with_drifts:
self.propagators.drifts.variables.e = self.em_fields.e_field
def _field_energies(self) -> dict:
"""The field energy scalars (name -> Scalar)."""
return {
"en_E": FunctionScalarFEEC(self._compute_en_E),
"en_B": FunctionScalarFEEC(self._compute_en_B),
}
@property
def species_names(self) -> list[str]:
"""The names of the gyrocentre species (attributes ``kinetic_<name>``)."""
return self._species_names
def gyrocentres(self, name: str) -> ParticleSpecies:
"""The gyrocentre species ``kinetic_<name>``."""
return getattr(self, f"kinetic_{name}")
@property
def bulk_species(self):
return self.gyrocentres(self._species_names[0])
@property
def velocity_scale(self):
return "light"
@property
def background(self) -> BackgroundWeights:
"""The background weights of the field operators (available after :meth:`post_allocate`)."""
return self._background
def species_background(self, name: str) -> SpeciesBackground:
"""The background moments of the species ``kinetic_<name>``."""
return SpeciesBackground(self.gyrocentres(name).var, kinetic=self.kinetic_backgrounds)
def parallel_flow(self, equil, name: str, fraction: float = 1.0):
r"""The parallel flow of the species ``kinetic_<name>`` that carries the ``fraction`` of the equilibrium current
:math:`\mathbf b_0 \cdot \nabla \times \mathbf B_0` (Eq. (5.62) of the paper, see
:func:`~struphy.propagators.gyrokinetic_maxwell.parallel_flow_from_equilibrium`), to be used as
``u_para=(..., None)`` of the species' ``GyroMaxwellian2D`` with ``background_current=True``.
The density of the flow is taken from the kinetic background added so far (or the fluid equilibrium if none)."""
var = self.gyrocentres(name).var
params = var.species.equation_params
background = SpeciesBackground(var, kinetic=var.backgrounds is not None)
def density(e1, e2, e3):
return background.n(e1, e2, e3, equil=equil)
return parallel_flow_from_equilibrium(equil, params.alpha, params.epsilon, density, fraction=fraction)
def _setup_backgrounds(self):
"""Resolve the background weights of the field operators from the kinetic backgrounds and the equilibrium."""
backgrounds = tuple(self.species_background(name) for name in self._species_names)
self._background = BackgroundWeights(backgrounds, Propagator.projected_equil.equil)
def post_allocate(self):
for name in self._species_names:
species = self.gyrocentres(name)
if species.var.species.weights_params.control_variate:
raise NotImplementedError(
f"{self.__class__.__name__} is a full-f model: the control variate would drop the "
"field-dependent drifts of f_0 (e.g. the grad-B current of the perturbation)."
)
# the field operators' backgrounds are resolved before the propagators are allocated (sim.allocate calls
# post_allocate after the propagators' allocate; the lazy placeholders resolved them already)
if self._background is None:
self._setup_backgrounds()
self._PB = getattr(Propagator.basis_ops, "PB")
self._PBb = self._PB.codomain.zeros()
self._E0T = Propagator.derham.extraction_ops["0"].transpose()
self._B_tilde_par = Propagator.derham.V0.zeros()
self._mass_ops = Propagator.mass_ops
self._A = dielectric_matrix(self._mass_ops, self._background.polarization)
aniso = self._background.anisotropy if self._background.has_anisotropy else None
self._M2H = magnetizing_matrix(self._mass_ops, aniso)
self._tmp = xp.empty(1, dtype=float)
self._setup_gauss_law()
def _setup_gauss_law_accumulators(self):
"""Charge accumulators of all species (gyro-averaged with finite Larmor radius)."""
self._charge_accums = {}
self._charge_coeffs = {}
for name in self._species_names:
var = self.gyrocentres(name).var
params = var.species.equation_params
self._charge_coeffs[name] = params.alpha**2 / params.epsilon
if self.gyro.flr:
kernel = gk_density_gyro
pe = Propagator.projected_equil
args = (
params.epsilon,
self.gyro.n_gyro,
self.gyro.ring_s,
self.gyro.ring_w,
pe.unit_b1[0]._data,
pe.unit_b1[1]._data,
pe.unit_b1[2]._data,
pe.absB0._data,
)
else:
kernel, args = gc_density_0form, ()
accum = AccumulatorVector(var.particles, "H1", kernel, self._mass_ops, Propagator.domain.args_domain)
self._charge_accums[name] = (accum, args)
grad = Propagator.derham.grad
self._rho = grad.domain.zeros()
self._gauss_rhs = grad.domain.zeros()
def _setup_gauss_law(self):
"""Charge accumulators of all species, the weak Gauss operator and the initial Poisson solve."""
grad = Propagator.derham.grad
self._setup_gauss_law_accumulators()
self._gauss_op = grad.T @ self._A
self._residual = grad.domain.zeros()
if self.solve_initial_poisson:
self._initial_poisson()
def _charge_density(self, out):
"""The free charge density sum_s alpha_s^2/eps_s rho_s (weak, in V1 ... as a V0 vector), into ``out``."""
out *= 0.0
for name, (accum, args) in self._charge_accums.items():
accum(*args)
self._rho *= 0.0
self._rho += accum.vectors[0]
self._rho *= self._charge_coeffs[name]
out += self._rho
return out
def _initial_poisson(self):
r"""Solve the weak Gauss law :math:`\mathbb G^\top \mathbb A \mathbb G \phi = \sum_s \frac{\alpha_s^2}{\varepsilon_s} \boldsymbol \rho_s`
with the dielectric matrix and set :math:`\mathbf e = -\mathbb G \phi` (Eq. (5.44a) of the paper)."""
logger.info("\nINITIAL POISSON SOLVE (Gauss's law with the dielectric matrix):")
grad = Propagator.derham.grad
rhs = self._charge_density(self._gauss_rhs)
# the periodic problem needs a zero-mean right-hand side (the net charge is in the kernel of the Laplacian)
ones = rhs.space.zeros()
ones[:] = 1.0
ones.update_ghost_regions()
M0 = self._mass_ops.M0
mean = rhs.inner(ones) / M0.dot_inner(ones, ones)
rhs -= mean * M0.dot(ones)
operator = grad.T @ self._A @ grad
pc = preconditioner.StiffnessPreconditioner(self._mass_ops, "grad", mid=self._A)
solver = inverse(operator, "pcg", pc=pc, tol=1e-12, maxiter=3000)
phi = solver.solve(rhs)
logger.info(f"... {solver._info}")
e = self.em_fields.e_field.spline.vector
grad.dot(-phi, out=e)
e.update_ghost_regions()
def calculate_gauss_error(self):
r"""Maximum norm of the weak Gauss-law residual
.. math::
\mathbb G^\top \mathbb A \mathbf e + \sum_s \frac{\alpha_s^2}{\varepsilon_s} \boldsymbol \rho_s\,,
where :math:`\boldsymbol \rho_s = \sum_p w_p \langle \vec \Lambda^0 \rangle_p` is the (gyro-averaged) gyrocentre
density of species :math:`s`."""
rhs = self._charge_density(self._gauss_rhs)
e = self.em_fields.e_field.spline.vector
residual = self._gauss_op.dot(e, out=self._residual)
residual += rhs
self._tmp[0] = xp.max(xp.abs(residual.toarray()))
if Propagator.derham.comm is not None:
Propagator.derham.comm.Allreduce(MPI.IN_PLACE, self._tmp, op=MPI.MAX)
return self._tmp[0]
def _compute_en_E(self):
r"""Electric energy :math:`\frac12 \mathbf e^\top \mathbb A \mathbf e` with the dielectric matrix."""
e = self.em_fields.e_field.spline.vector
return 0.5 * self._A.dot_inner(e, e)
def _compute_en_B(self):
r"""Magnetic energy :math:`\frac12 \mathbf b^\top \mathbb M^2_H \mathbf b` with the magnetizing matrix."""
b = self.em_fields.b_field.spline.vector
return 0.5 * self._M2H.dot_inner(b, b)
def _compute_en_muB(self, name: str):
r"""Magnetic-moment energy :math:`\alpha_s^2 \sum_p w_p \mu_p B_\parallel(\boldsymbol \eta_p)` of one species,
with :math:`B_\parallel = |B_0| + \langle\langle \Pi^0(\mathbf b_0 \cdot \tilde{\mathbf B}) \rangle\rangle`."""
var = self.gyrocentres(name).var
particles = var.particles
PBb = self._PB.dot(self.em_fields.b_field.spline.vector, out=self._PBb)
if self.gyro.flr:
B_tilde_par = self._E0T.dot(PBb, out=self._B_tilde_par)
B_tilde_par.update_ghost_regions()
pe = Propagator.projected_equil
gk_energy_gyro(
particles.markers,
Propagator.derham.args_derham,
Propagator.domain.args_domain,
particles.first_diagnostics_idx,
particles.mu_idx,
var.species.equation_params.epsilon,
self.gyro.n_gyro,
self.gyro.disc_s,
self.gyro.disc_w,
pe.unit_b1[0]._data,
pe.unit_b1[1]._data,
pe.unit_b1[2]._data,
pe.absB0._data,
B_tilde_par._data,
)
else:
particles.save_magnetic_energy(PBb)
valid = particles.valid_mks
# The weights (column 5) already carry the 1/N_p of the Monte-Carlo estimate, see KineticEnergyPIC.
energy = particles.markers[valid, 5].dot(particles.markers[valid, particles.first_diagnostics_idx])
return self._alpha2[name] * energy
## 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:
# b_field is the perturbation of the equilibrium field: no background
if "model.em_fields.b_field.add_background(" in line:
continue
new_file += [line]
with open(params_path, "w") as f:
for line in new_file:
f.write(line)
return params_path
[docs]
@classmethod
def doc_pde(cls):
r"""**PDEs solved by model:**
Gyrocentre equations of motion (per species :math:`s`, transformation parameters :math:`(\xi_R, \xi_\Theta) = (1, 0)`):
.. math::
\dot{\mathbf R} = v_\parallel \frac{\mathbf B^*}{B^*_\parallel} + \frac{\mathbf E^* \times \mathbf b_0}{B^*_\parallel}\,,
\qquad \dot v_\parallel = \frac{1}{\varepsilon_s} \frac{\mathbf B^* \cdot \mathbf E^*}{B^*_\parallel}\,,
\qquad \dot \mu = 0\,,
where
.. math::
\mathbf E^* = \langle \mathbf E \rangle - \varepsilon_s \mu \nabla \left(B_0 + \langle\langle \tilde B_\parallel \rangle\rangle\right)\,, \qquad
\mathbf B^* = \mathbf B_0 + \langle \tilde{\mathbf B} \rangle + \varepsilon_s v_\parallel \nabla \times \mathbf b_0\,, \qquad
B^*_\parallel = \mathbf b_0 \cdot \mathbf B^*\,, \qquad
\tilde B_\parallel = \Pi^0(\mathbf b_0 \cdot \tilde{\mathbf B})\,,
with the gyro-average :math:`\langle \cdot \rangle` and the disc average :math:`\langle\langle \cdot \rangle\rangle`
(point evaluations in the zero-Larmor-radius limit ``n_gyro = 0``).
Field equations (centred background with parallel flow :math:`u_{0,\parallel,s}` and pressures
:math:`p_{0,\parallel}, p_{0,\perp}`, :math:`\mathbf B = \mathbf B_0 + \tilde{\mathbf B}`):
.. math::
\left(\mathbf I + \sum_s \alpha_s^2 \frac{n_{0,s}}{B_0^2} \Pi_\perp\right) \frac{\partial \mathbf E}{\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 \frac{n_{0,s} u_{0,\parallel,s}}{B_0^2} \mathbf b_0 \times \mathbf E
+ \sum_s \alpha_s^2 \int f_s\, \mu\, \mathbf b_0 \,\textrm d v_\parallel \textrm d \mu\right)
\\
&\quad + \sum_s \alpha_s^2 \frac{n_{0,s} u_{0,\parallel,s}}{B_0^2} \mathbf b_0 \times (\nabla \times \mathbf E)
- \sum_s \frac{\alpha_s^2}{\varepsilon_s} \int f_s\, \dot{\mathbf R} \,\textrm d v_\parallel \textrm d \mu\,,
\\[2mm]
\frac{\partial \mathbf B}{\partial t} &= - \nabla \times \mathbf E\,,
with the gyro-average adjoints in the currents. The left-hand side operator is the vacuum permittivity
plus the polarization, the curl terms on the right are the curl of the magnetisation. Gauss's law
:math:`\nabla \cdot \mathbf D = \rho_f` is solved for the initial electric field (``solve_initial_poisson``)
and monitored (``measure_gauss_law``); :math:`\nabla \cdot \mathbf B = 0` holds exactly in the discrete de Rham complex.
Since the model is gauge invariant, it is formulated in :math:`\mathbf E \in H(\textnormal{curl})` and
:math:`\mathbf B \in H(\textnormal{div})` rather than in the potentials.
Conserved energy:
.. math::
\mathcal H = \frac12 \mathbf e^\top \mathbb A \mathbf e + \frac12 \mathbf b^\top \mathbb M^2_H \mathbf b
+ \sum_s \alpha_s^2 \sum_p w_p \left(\frac{v_{\parallel,p}^2}{2} + \mu_p \left(B_0 + \langle\langle \tilde B_\parallel \rangle\rangle\right)(\boldsymbol \eta_p)\right)\,.
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"""The model uses the light speed as reference velocity:
.. math::
\hat v = c,\qquad \hat E = \hat B \hat v,\qquad \hat t = \hat x / c\,.
The species parameters :math:`\alpha_s=\hat\Omega_{p,s}/\hat\Omega_{c,s}` and
:math:`\varepsilon_s=1/(\hat\Omega_{c,s}\hat t)` are signed (negative for negative charges).
The background moments :math:`n_{0,s}`, :math:`u_{0,\parallel,s}` and the pressures
:math:`p_{\parallel,s} = \alpha_s^2 n_{0,s} v_{\textnormal{th},\parallel,s}^2`,
:math:`p_{\perp,s} = \alpha_s^2 n_{0,s} v_{\textnormal{th},\perp,s}^2` are those of the kinetic backgrounds
(``kinetic_backgrounds=True``), else :math:`n_{0,s} = n_0` of the fluid equilibrium."""
[docs]
@classmethod
def doc_scalar_quantities(cls):
r"""**The following scalars are tracked during simulation:**
- Electric field energy (with the dielectric matrix): ``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>``
- Optional Gauss-law diagnostic: ``gauss_error``"""
[docs]
@classmethod
def doc_discretization(cls):
"""Time integration is performed by the following propagators (in sequence):
1. :class:`~struphy.propagators.gyrokinetic_maxwell.MaxwellGyroMagnetisation`
2. :class:`~struphy.propagators.gyrokinetic_maxwell.GyroEparCoupling` (one per species)
3. :class:`~struphy.propagators.gyrokinetic_maxwell.PushGuidingCenterParallelFullB` (one per species, ZLR) or
:class:`~struphy.propagators.gyrokinetic_maxwell.GyroParallelPush` (finite Larmor radius)
4. :class:`~struphy.propagators.gyrokinetic_maxwell.GyroDriftCoupling` (all species, if ``with_drifts``)
"""
doc = rf"""**1. MaxwellGyroMagnetisation:**
{MaxwellGyroMagnetisation.__doc__}
**2. GyroEparCoupling (one per species):**
{GyroEparCoupling.__doc__}
**3. PushGuidingCenterParallelFullB (one per species, zero Larmor radius):**
{PushGuidingCenterParallelFullB.__doc__}
**3'. GyroParallelPush (one per species, finite Larmor radius):**
{GyroParallelPush.__doc__}
**4. GyroDriftCoupling (all species):**
{GyroDriftCoupling.__doc__}
All field solves are implicit, so the light wave, the plasma oscillation and the compressional Alfvén wave
do not limit the time step. The common dielectric matrix is assembled once by
:func:`~struphy.propagators.gyrokinetic_maxwell.dielectric_matrix`; with finite Larmor radius the particle
couplings are applied matrix-free (:class:`~struphy.propagators.gyrokinetic_maxwell.GyroParticleOperator`).
"""
return doc
[docs]
@classmethod
def doc_long_description(cls):
r"""GyrokineticMaxwell is the gauge-invariant electromagnetic gyrokinetic model of
Remmerswaal, Hatzky and Sonnendrücker (J. Plasma Phys. 91, E127, 2025), Sec. 5 ("gyrokinetic Maxwell model"),
with the transformation parameters :math:`(\xi_R, \xi_\Theta) = (1, 0)`.
Any number of gyrocentre species is coupled to the electric and magnetic fields, with the polarization
entering as a dielectric matrix, the pressure anisotropy and the parallel flow of the backgrounds as
magnetizing and flow operators, and the gyrocentre magnetisation and the gyro-averaged currents as sources of
Ampère's law. The equilibrium current can be carried by the species backgrounds (``background_current``),
Gauss's law is solved initially and can be monitored, and finite Larmor radius effects enter through the
gyro- and disc averages (``n_gyro``, ``n_disc``) with matrix-free particle couplings.
It is full-f (no control variate) and uses tensor-product coefficients (no polar splines)."""
[docs]
@classmethod
def doc_examples(cls):
r"""Create and initialize the model:
.. code-block:: python
from struphy.models import GyrokineticMaxwell
model = GyrokineticMaxwell(species=(("ions", 1, 1.0), ("electrons", -1, 1 / 100)), n_gyro=4)
model.em_fields.e_field
model.em_fields.b_field
model.kinetic_ions.var
model.kinetic_electrons.var
``b_field`` is the perturbation of the equilibrium field (do not add a ``FieldsBackground`` to it).
The loading parameters should match the thermal speeds in light units, e.g.
``LoadingParameters(moments=(0, 0, vth, vth), B0=...)``, and the kinetic backgrounds
(``GyroMaxwellian2D``) provide the background density, flow and pressures of the field equations.
"""
[docs]
@classmethod
def doc_use_cases(cls):
r"""This model is appropriate for:
- electromagnetic gyrokinetic simulations with finite Larmor radius (shear, kinetic and compressional Alfvén waves)
- energy-conserving coupling of several gyrocentre species to Maxwell's equations
- backgrounds with parallel flow (equilibrium current) and pressure anisotropy
- prototyping of the gauge-invariant gyrokinetic formulation in Struphy"""
[docs]
@classmethod
def doc_cannot_be_used_for(cls):
r"""This model is not suitable for:
- delta-f (control variate) simulations
- polar-spline (pole) domains
- the quasi-neutral limit without the fast waves (see the Darwin variant,
:class:`~struphy.models.gyrokinetic_darwin.GyrokineticDarwin`)
- gyro-averages beyond the leading order in the background gradient length (the gyro-points are placed with
the inverse Jacobian at the gyrocentre)"""
class _LazyPolarization:
"""Resolves to the polarization weight of the model's backgrounds when the propagators are allocated."""
def __init__(self, model: GyrokineticMaxwell):
self._model = model
def _resolve(self):
if self._model._background is None:
self._model._setup_backgrounds()
return self._model._background.polarization
def __call__(self, e1, e2, e3):
return self._resolve()(e1, e2, e3)
class _LazyBackground:
"""Resolves to the model's :class:`BackgroundWeights` when the propagators are allocated."""
def __init__(self, model: GyrokineticMaxwell):
self._model = model
def _resolve(self) -> BackgroundWeights:
if self._model._background is None:
self._model._setup_backgrounds()
return self._model._background
def __getattr__(self, name):
return getattr(self._resolve(), name)