"Maxwellian (Gaussian) distributions in velocity space."
import copy
import logging
import warnings
from inspect import signature
from typing import Callable, Literal
import cunumpy as xp
from struphy.fields_background.base import AxisymmMHDequilibrium, FluidEquilibriumWithB
from struphy.geometry.base import Domain
from struphy.initial.base import Perturbation
from struphy.io.options import LiteralOptions
from struphy.kinetic_background.base import Maxwellian, check_B0_option, eval_B0_option
logger = logging.getLogger("struphy")
[docs]
class Maxwellian3D(Maxwellian):
r"""A :class:`~struphy.kinetic_background.base.Maxwellian` depending :math:`(\eta_1, \eta_2, \eta_3)`
and on three (:math:`n=3`) Cartesian velocities.
Parameters
----------
n, ui, vthi : tuple
Moments of the Maxwellian as tuples. The first entry defines the background
(float for constant background or callable), the second entry defines a Perturbation (can be None).
uniform_on_disc : bool
Whether the density n is uniform on the disc.
"""
def __init__(
self,
n: tuple[float | Callable, Perturbation] = (1.0, None),
u1: tuple[float | Callable, Perturbation] = (0.0, None),
u2: tuple[float | Callable, Perturbation] = (0.0, None),
u3: tuple[float | Callable, Perturbation] = (0.0, None),
vth1: tuple[float | Callable, Perturbation] = (1.0, None),
vth2: tuple[float | Callable, Perturbation] = (1.0, None),
vth3: tuple[float | Callable, Perturbation] = (1.0, None),
uniform_on_disc: bool = False,
):
# use setter to store input parameters
self.params = copy.deepcopy(locals())
self.check_maxw_params()
# factors multiplied onto the defined moments n, u and vth (can be set via setter)
self._moment_factors = {
"n": 1.0,
"u": [1.0, 1.0, 1.0],
"vth": [1.0, 1.0, 1.0],
}
@property
def vdim(self):
"""Dimension of the velocity space."""
return 3
@property
def velocity_coords(self) -> LiteralOptions.VelocityCoordinates:
"""Velocity coordinates of the background."""
return "cartesian"
[docs]
def velocity_jacobian_det(self, eta1, eta2, eta3, vx, vy, vz):
"""Jacobian determinant is 1 (Cartesian velocity coordinates).
Input parameters should be slice of 2d numpy marker array. (i.e. *self.phasespace_coords)
Parameters
----------
eta1, eta2, eta3 : array_like
Logical evaluation points.
vx, vy, vz : array_like
Velocity evaluation points.
Returns
-------
out : array-like
The Jacobian determinant evaluated at given logical coordinates.
-------
"""
assert eta1.ndim == eta2.ndim == eta3.ndim == 1
assert vx.ndim == vy.ndim == vz.ndim == 1
return 1.0 + 0 * eta1
@property
def volume_form(self):
"""Boolean. True if the background is represented as a volume form (thus including the velocity Jacobian)."""
return False
@property
def moment_factors(self):
"""Collection of factors multiplied onto the defined moments n, u, and vth."""
return self._moment_factors
@moment_factors.setter
def moment_factors(self, new_factors: dict):
for kw, arg in new_factors.items():
assert kw in self._moment_factors, f"Unknown moment factor {kw}."
if kw in {"u", "vth"}:
assert len(arg) == 3
self._moment_factors[kw] = arg
[docs]
def n(self, eta1, eta2, eta3):
"""Zero-th moment (density)."""
out = self._evaluate_moment(eta1, eta2, eta3, name="n")
return out * self.moment_factors["n"]
[docs]
def u(self, eta1, eta2, eta3):
"""Mean velocities."""
out = []
out += [self._evaluate_moment(eta1, eta2, eta3, name="u1")]
out += [self._evaluate_moment(eta1, eta2, eta3, name="u2")]
out += [self._evaluate_moment(eta1, eta2, eta3, name="u3")]
return [ou * mom_fac for ou, mom_fac in zip(out, self.moment_factors["u"])]
[docs]
def vth(self, eta1, eta2, eta3):
"""Thermal velocities."""
out = []
out += [self._evaluate_moment(eta1, eta2, eta3, name="vth1")]
out += [self._evaluate_moment(eta1, eta2, eta3, name="vth2")]
out += [self._evaluate_moment(eta1, eta2, eta3, name="vth3")]
return [ou * mom_fac for ou, mom_fac in zip(out, self.moment_factors["vth"])]
[docs]
class GyroMaxwellian2D(Maxwellian):
r"""A gyrotropic :class:`~struphy.kinetic_background.base.Maxwellian` depending on
two velocities :math:`(v_\parallel, \mu)`, :math:`n=2`,
where :math:`v_\parallel = \mathbf v \cdot \mathbf b_0` and
:math:`\mu = v_\perp^2/(2B_0)` is the magnetic moment, with :math:`B_0` the background magnetic field strength.
Parameters
----------
n, u_para, u_perp, vth_para, vth_perp : tuple
Moments of the Maxwellian as tuples. The first entry defines the background
(float for constant background or callable), the second entry defines a Perturbation (can be None).
volume_form : bool
Whether to represent the Maxwellian as a volume form;
if True it is multiplied by the Jacobian determinant |v_perp|
of the polar coordinate transofrmation (default = False).
B0: float | Callable | "equil"
Background magnetic field strength :math:`B_0` in :math:`\mu = v_\perp^2/(2B_0)`, see :meth:`eval_B0`:
a constant, a callable of the stacked logical positions ``etas`` of shape ``(3, N)`` returning shape ``(N,)``,
or ``"equil"`` (default) for the local field strength :math:`|\mathbf B_0|(\boldsymbol \eta)` of the
equilibrium attached with :meth:`attach_equil` (done automatically for the background and initial
condition of a :class:`~struphy.pic.particles.Particles5D` species).
uniform_on_disc : bool
Whether the density n is uniform on the disc.
"""
def __init__(
self,
n: tuple[float | Callable, Perturbation] = (1.0, None),
u_para: tuple[float | Callable, Perturbation] = (0.0, None),
u_perp: tuple[float | Callable, Perturbation] = (0.0, None),
vth_para: tuple[float | Callable, Perturbation] = (1.0, None),
vth_perp: tuple[float | Callable, Perturbation] = (1.0, None),
volume_form: bool = True,
B0: float | Callable | Literal["equil"] = "equil",
uniform_on_disc: bool = False,
):
# use setter to store input parameters
self.params = copy.deepcopy(locals())
self.check_maxw_params()
check_B0_option(B0)
# equilibrium providing |B_0| for B0="equil" (see attach_equil)
self._B0_equil = None
# last (B0 option, equilibrium, positions, values) of eval_B0: f0 and its velocity Jacobian are evaluated
# at the same markers
self._B0_cache = None
# volume form represenation
self._volume_form = volume_form
# factors multiplied onto the defined moments n, u and vth (can be set via setter)
self._moment_factors = {
"n": 1.0,
"u": [1.0, 1.0],
"vth": [1.0, 1.0],
}
@property
def vdim(self):
"""Dimension of the velocity space."""
return 2
@property
def velocity_coords(self) -> LiteralOptions.VelocityCoordinates:
"""Velocity coordinates of the background."""
return "vpara_mu"
[docs]
def velocity_jacobian_det(self, eta1, eta2, eta3, v_para, mu):
r"""Jacobian determinant of the velocity coordinate transformation to :math:`(v_\parallel, mu)`, is :math:`B_0`.
Input parameters should be slice of 2d numpy marker array. (i.e. *self.phasespace_coords)
Parameters
----------
eta1, eta2, eta3 : array_like
Logical evaluation points.
v_para, mu : array_like
Parallel velocity and magnetic moment evaluation points.
Returns
-------
out : array-like
The Jacobian determinant evaluated at given logical coordinates.
-------
"""
assert eta1.ndim == eta2.ndim == eta3.ndim == 1
assert v_para.ndim == mu.ndim == 1
return self.eval_B0(xp.stack((eta1, eta2, eta3), axis=0))
[docs]
def attach_equil(self, equil: FluidEquilibriumWithB):
"""Attach the equilibrium whose field strength :math:`|\\mathbf B_0|` is used for ``B0="equil"``
(replacing an equilibrium attached before). Does nothing for a constant or callable ``B0``.
The background object itself is changed: if it is shared by particles with different equilibria (e.g.
two simulations set up in one script before running), the last equilibrium attached is used by all of
them, and a warning is logged."""
if isinstance(self.params["B0"], str):
if equil is None:
return
assert isinstance(equil, FluidEquilibriumWithB), (
f'GyroMaxwellian2D with B0="equil" needs an equilibrium with magnetic field, got {type(equil).__name__}.'
)
if self._B0_equil is not None and self._B0_equil is not equil:
logger.warning(
'GyroMaxwellian2D with B0="equil" gets a different equilibrium attached: the background object '
"is shared, and all particles using it take |B_0| from the new equilibrium from now on. This is "
"fine if the simulation set up with the previous equilibrium is not used any more (e.g. a scan); "
"otherwise use a separate background object for each simulation."
)
self._B0_equil = equil
self._B0_cache = None
[docs]
def eval_B0(self, etas):
"""Evaluate the magnetic field strength :math:`B_0` in :math:`\\mu = v_\\perp^2/(2B_0)` at flat positions.
Parameters
----------
etas : array_like
Stacked logical positions of shape ``(3, N)``.
Returns
-------
out : array-like
:math:`B_0` at the positions, shape ``(N,)``.
"""
B0 = self.params["B0"]
if not (isinstance(B0, str) or callable(B0)):
return eval_B0_option(B0, etas)
# a position-dependent B0 is often evaluated twice at the same markers (f0 and velocity_jacobian_det in
# the control variate); the comparison is much cheaper than an evaluation of |B_0|
cache = self._B0_cache
if (
cache is not None
and cache[0] is B0
and cache[1] is self._B0_equil
and cache[2].shape == etas.shape
and xp.array_equal(cache[2], etas)
):
return cache[3]
out = eval_B0_option(B0, etas, self._B0_equil, owner="GyroMaxwellian2D")
self._B0_cache = (B0, self._B0_equil, xp.array(etas, copy=True), out)
return out
[docs]
def eval_B0_on_args(self, etas):
""":meth:`eval_B0` at the positions ``eta1, eta2, eta3`` of a Maxwellian evaluation, flat (1d arrays)
or on a phase-space meshgrid (``3 + vdim`` dimensions, positions on the first 3 axes): there it is
evaluated on the spatial points only and broadcast to the velocity axes. A constant ``B0`` is returned
as float."""
B0 = self.params["B0"]
if not (isinstance(B0, str) or callable(B0)):
return float(B0)
if xp.ndim(etas[0]) == 1:
return self.eval_B0(xp.stack(etas, axis=0))
# spatial part of the meshgrid (velocity axes at their first point)
spatial = [a[(slice(None),) * 3 + (0,) * (xp.ndim(a) - 3)] for a in etas]
out = self.eval_B0(xp.stack([xp.ravel(a) for a in spatial], axis=0))
out = xp.reshape(out, xp.shape(spatial[0]) + (1,) * (xp.ndim(etas[0]) - 3))
return xp.broadcast_to(out, xp.shape(etas[0]))
@property
def volume_form(self) -> bool:
"""Boolean. True if the background is represented as a volume form (thus including the velocity Jacobian |v_perp|)."""
return self._volume_form
@property
def moment_factors(self):
"""Collection of factors multiplied onto the defined moments n, u, and vth."""
return self._moment_factors
@moment_factors.setter
def moment_factors(self, new_factors: dict):
for kw, arg in new_factors.items():
assert kw in self._moment_factors, f"Unknown moment factor {kw}."
if kw in {"u", "vth"}:
assert len(arg) == 2
self._moment_factors[kw] = arg
[docs]
def n(self, eta1, eta2, eta3):
"""Zero-th moment (density)."""
out = self._evaluate_moment(eta1, eta2, eta3, name="n")
return out * self.moment_factors["n"]
[docs]
def u(self, eta1, eta2, eta3):
"""Mean velocities."""
out = []
out += [self._evaluate_moment(eta1, eta2, eta3, name="u_para")]
out += [self._evaluate_moment(eta1, eta2, eta3, name="u_perp")]
return [ou * mom_fac for ou, mom_fac in zip(out, self.moment_factors["u"])]
[docs]
def vth(self, eta1, eta2, eta3):
"""Thermal velocities."""
out = []
out += [self._evaluate_moment(eta1, eta2, eta3, name="vth_para")]
out += [self._evaluate_moment(eta1, eta2, eta3, name="vth_perp")]
return [ou * mom_fac for ou, mom_fac in zip(out, self.moment_factors["vth"])]
[docs]
class GyroMaxwellian2Dvperp(Maxwellian):
r"""A gyrotropic :class:`~struphy.kinetic_background.base.Maxwellian` depending on
two velocities :math:`(v_\parallel, v_\perp)`, :math:`n=2`,
where :math:`v_\parallel = \mathbf v \cdot \mathbf b_0` and :math:`v_\perp`
is the radial component of a polar coordinate system perpendicular
to the magentic direction :math:`\mathbf b_0`.
Parameters
----------
n, u_para, u_perp, vth_para, vth_perp : tuple
Moments of the Maxwellian as tuples. The first entry defines the background
(float for constant background or callable), the second entry defines a Perturbation (can be None).
equil : FluidEquilibriumWithB
Fluid background.
volume_form : bool
Whether to represent the Maxwellian as a volume form;
if True it is multiplied by the Jacobian determinant |v_perp|
of the polar coordinate transofrmation (default = False).
uniform_on_disc : bool
Whether the density n is uniform on the disc (default = False).
"""
def __init__(
self,
n: tuple[float | Callable, Perturbation] = (1.0, None),
u_para: tuple[float | Callable, Perturbation] = (0.0, None),
u_perp: tuple[float | Callable, Perturbation] = (0.0, None),
vth_para: tuple[float | Callable, Perturbation] = (1.0, None),
vth_perp: tuple[float | Callable, Perturbation] = (1.0, None),
equil: FluidEquilibriumWithB = None,
volume_form: bool = True,
uniform_on_disc: bool = False,
):
# use setter to store input parameters
self.params = copy.deepcopy(locals())
self.check_maxw_params()
# volume form represenation
self._volume_form = volume_form
self._equil = equil
# factors multiplied onto the defined moments n, u and vth (can be set via setter)
self._moment_factors = {
"n": 1.0,
"u": [1.0, 1.0],
"vth": [1.0, 1.0],
}
@property
def vdim(self):
"""Dimension of the velocity space."""
return 2
@property
def velocity_coords(self) -> LiteralOptions.VelocityCoordinates:
"""Velocity coordinates of the background."""
return "vpara_vperp"
[docs]
def velocity_jacobian_det(self, eta1, eta2, eta3, v_para, v_perp):
r"""Jacobian determinant of the velocity coordinate transformation to :math:`(v_\parallel, v_\perp)`, is :math:`v_\perp`.
Input parameters should be slice of 2d numpy marker array. (i.e. *self.phasespace_coords)
Parameters
----------
eta1, eta2, eta3 : array_like
Logical evaluation points.
v_para, v_perp : array_like
Parallel and perpendicular velocity evaluation points.
Returns
-------
out : array-like
The Jacobian determinant evaluated at given logical coordinates.
-------
"""
assert eta1.ndim == eta2.ndim == eta3.ndim == 1
assert v_para.ndim == v_perp.ndim == 1
return v_perp
@property
def volume_form(self) -> bool:
"""Boolean. True if the background is represented as a volume form (thus including the velocity Jacobian |v_perp|)."""
return self._volume_form
@property
def equil(self) -> FluidEquilibriumWithB:
"""Fluid background with B-field."""
return self._equil
@property
def moment_factors(self):
"""Collection of factors multiplied onto the defined moments n, u, and vth."""
return self._moment_factors
@moment_factors.setter
def moment_factors(self, new_factors: dict):
for kw, arg in new_factors.items():
assert kw in self._moment_factors, f"Unknown moment factor {kw}."
if kw in {"u", "vth"}:
assert len(arg) == 2
self._moment_factors[kw] = arg
[docs]
def n(self, eta1, eta2, eta3):
"""Zero-th moment (density)."""
out = self._evaluate_moment(eta1, eta2, eta3, name="n")
return out * self.moment_factors["n"]
[docs]
def u(self, eta1, eta2, eta3):
"""Mean velocities."""
out = []
out += [self._evaluate_moment(eta1, eta2, eta3, name="u_para")]
out += [self._evaluate_moment(eta1, eta2, eta3, name="u_perp")]
return [ou * mom_fac for ou, mom_fac in zip(out, self.moment_factors["u"])]
[docs]
def vth(self, eta1, eta2, eta3):
"""Thermal velocities."""
out = []
out += [self._evaluate_moment(eta1, eta2, eta3, name="vth_para")]
out += [self._evaluate_moment(eta1, eta2, eta3, name="vth_perp")]
return [ou * mom_fac for ou, mom_fac in zip(out, self.moment_factors["vth"])]
[docs]
class CanonicalMaxwellian2D(GyroMaxwellian2D):
r"""Canonical Maxwellian distribution function in
:math:`(\eta_1, \eta_2, \eta_3, v_\parallel, \mu)` coordinates.
Uses caching for evaluation of the canonical toroidal momentum in these coordinates.
The distribution is parameterized by the density and thermal speed as functions of the
(shifted) canonical toroidal momentum :math:`\psi_c`:
.. math::
\psi_c = P_\phi - \text{sign}(v_\parallel)\sqrt{2(\epsilon - \mu B_\text{ref})}\frac{m_sF}{q_sB_\text{ref}}
\mathcal{H}(\epsilon - \mu B_\text{ref})\,,\qquad P_\phi = \psi + \frac{m_s F}{q_s B}v_\parallel\,,
where :math:`P_\phi` is the canonical toroidal momentum and the reference field :math:`B_\text{ref}` is set by
``shift`` (see below). The shift (Angelino et al., Phys. Plasmas 13, 052304 (2006)) makes :math:`\psi_c = \psi`
where :math:`|\mathbf B| = B_\text{ref}` on the orbit of a passing particle, such that the profiles in
:math:`\psi_c` are close to the profiles in the flux label of the position.
- Energy
.. math::
\epsilon = \frac{1}{2}m_sv_\parallel² + \mu B,
- Magnetic moment
.. math::
\mu = \frac{m_s v_\perp²}{2B},
where :math:`\psi` is the poloidal magnetic flux function, :math:`F=F(\psi)` is the poloidal current function and :math:`\mathcal{H}` is the Heaviside function.
With the three constants of motion, a canonical Maxwellian distribution function is defined as
.. math::
F(\psi_c, \epsilon, \mu) = \frac{n(\psi_c)}{(2\pi)^{3/2}v_\text{th}³(\psi_c)} \text{exp}\left[ - \frac{\epsilon}{v_\text{th}²(\psi_c)}\right].
Parameters
----------
n, vth : tuple
Moments of the canonical Maxwellian as tuples. The first entry defines the background
(float for constant background or callable), the second entry defines a Perturbation (can be None).
maxw_params : dict
Parameters for the kinetic background.
vth : tuple[float | Callable, Perturbation]
Thermal-speed background and optional perturbation.
equil : AxisymmMHDequilibrium, optional
Fluid equilibrium used to evaluate background profiles in the magnetic geometry.
volume_form : bool, default=True
If ``True``, represent the distribution as a volume form and include the appropriate
velocity-space Jacobian when evaluating it.
cache_size : int, optional
Number of rows in the cache buffer for :math:`\psi_c` evaluation. If ``None``, no caching is used.
Must be able to accomodate all markers on the current process.
shift : str | None, default="Bmax"
Reference field :math:`B_\text{ref}` of the shift of the canonical toroidal momentum:
* ``"Bmax"``: the maximum of :math:`|\mathbf B|` on the flux surface :math:`\psi = P_\phi`, see
:meth:`~struphy.fields_background.base.AxisymmMHDequilibrium.absB_max`. A particle can only bounce
(:math:`v_\parallel = 0`) on this surface, hence the shift vanishes for all particles that can bounce,
:math:`\psi_c` is a function of the constants of motion :math:`P_\phi, \epsilon, \mu` and the
distribution is an exact equilibrium of the guiding-center motion (with the constant poloidal current
function :math:`F = -B_0 R_0` assumed in :math:`P_\phi`, as in
:class:`~struphy.fields_background.equils.AdhocTorus`). It has a net parallel flow.
* ``"B0"``: the on-axis field (Angelino et al.). The shift jumps at the bounce points of the trapped
particles with :math:`\epsilon > \mu B_0` (the sign of :math:`v_\parallel` changes there), so the
distribution is not an exact equilibrium (default up to Struphy 3.6).
* ``None``: no shift, :math:`\psi_c = P_\phi` (exact equilibrium, but the profiles are shifted by the
drift-orbit width with respect to the position).
shifted : bool, optional
Deprecated, use ``shift``: ``True`` is ``shift="B0"``, ``False`` is ``shift=None``.
"""
shift_modes = {None: 0, "B0": 1, "Bmax": 2}
"""Integer codes of the values of ``shift``, as passed to the compiled kernels."""
def __init__(
self,
n: tuple[float | Callable, Perturbation] = (1.0, None),
vth: tuple[float | Callable, Perturbation] = (1.0, None),
volume_form: bool = True,
uniform_on_disc: bool = False,
equil: AxisymmMHDequilibrium = None,
epsilon: float = 1.0,
cache_size: int | None = None,
shift: str | None = "Bmax",
shifted: bool | None = None,
):
assert isinstance(equil, AxisymmMHDequilibrium)
if shifted is not None:
warnings.warn(
'CanonicalMaxwellian2D(shifted=...) is deprecated, use shift="B0" (shifted=True) or shift=None '
"(shifted=False).",
DeprecationWarning,
stacklevel=2,
)
shift = "B0" if shifted else None
assert shift in self.shift_modes, f"shift must be one of {list(self.shift_modes)}, but is {shift!r}."
super().__init__(
n=n,
u_para=(0.0, None),
u_perp=(0.0, None),
vth_para=vth,
vth_perp=vth,
volume_form=volume_form,
B0=equil.absB0,
uniform_on_disc=uniform_on_disc,
)
# store additional parameters
self._equil = equil
self._epsilon = epsilon
self._shift = shift
self.params["vth"] = vth
self.params["equil"] = equil
self.params["epsilon"] = epsilon
self.params["cache_size"] = cache_size
self.params["shift"] = shift
# factors multiplied onto the defined moments n and vth (can be set via setter)
self._moment_factors = {
"n": 1.0,
"vth": 1.0,
}
# create cache for psi_c evaluation
if cache_size is not None:
self.cbufs = {}
self.cbufs["absB0"] = xp.empty(cache_size, dtype=float)
self.cbufs["x"] = xp.empty(cache_size, dtype=float)
self.cbufs["y"] = xp.empty(cache_size, dtype=float)
self.cbufs["z"] = xp.empty(cache_size, dtype=float)
self.cbufs["R"] = xp.empty(cache_size, dtype=float)
self.cbufs["P"] = xp.empty(cache_size, dtype=float)
self.cbufs["Z"] = xp.empty(cache_size, dtype=float)
self.cbufs["psi"] = xp.empty(cache_size, dtype=float)
self.cbufs["energy"] = xp.empty(cache_size, dtype=float)
self.cbufs["psic"] = xp.empty(cache_size, dtype=float)
self.cbufs["positive_mask"] = xp.empty(cache_size, dtype=bool)
self.cbufs["correction"] = xp.empty(cache_size, dtype=float)
self.cbufs["Bref"] = xp.empty(cache_size, dtype=float)
logger.debug(f"Created {len(self.cbufs)} cache buffers for psi_c evaluation, each with size {cache_size}.")
else:
self.cbufs = None
# psi_c of the current __call__, shared by the moments n and vth of that call (None outside of __call__)
self._psi_c_call = None
def __call__(self, *phase_space_coords):
r"""Evaluates the canonical Maxwellian, see :meth:`~struphy.kinetic_background.base.Maxwellian.__call__`.
:math:`\psi_c` is evaluated once from the given coordinates and shared by the moments :math:`n` and
:math:`v_\text{th}` of this call. It is not kept between calls: markers move and change slots (MPI sorting,
refilling) between calls, so a stored :math:`\psi_c` can belong to other phase-space points."""
if any(callable(self.params[name][0]) for name in ("n", "vth")):
self._psi_c_call = self.eval_psic(*self._psic_coords(*phase_space_coords))
try:
return super().__call__(*phase_space_coords)
finally:
self._psi_c_call = None
@staticmethod
def _psic_coords(eta1, eta2, eta3, v_parallel, mu):
"""Arrange phase-space points as the input of :meth:`eval_psic`: one (5, n_markers) array for flat
(marker) evaluation, or the five 5d meshgrid arrays."""
if eta1.ndim == 1:
return [xp.stack((eta1, eta2, eta3, v_parallel, mu), axis=0)]
elif eta1.ndim == 5:
return (eta1, eta2, eta3, v_parallel, mu)
else:
raise ValueError(f"Input arrays must be 1d or 5d (from meshgrid), but got {eta1.ndim}d.")
@property
def equil(self) -> AxisymmMHDequilibrium:
"""One of :mod:`~struphy.fields_background.equils`
in case that moments are to be set in that way, None otherwise.
"""
return self._equil
@property
def epsilon(self) -> float:
"""Epsilon parameter in the canonical toroidal momentum."""
return self._epsilon
@property
def shift(self) -> str | None:
"""Reference field of the shift of the canonical toroidal momentum (``"Bmax"``, ``"B0"`` or ``None``)."""
return self._shift
@GyroMaxwellian2D.moment_factors.setter
def moment_factors(self, new_factors: dict):
# n and vth are scalar factors here (no u)
for kw, arg in new_factors.items():
assert kw in self._moment_factors, f"Unknown moment factor {kw}."
self._moment_factors[kw] = arg
def _evaluate_moment(
self,
eta1,
eta2,
eta3,
v_parallel,
mu,
*,
name: str = "n",
add_perturbation: bool = None,
):
"""Scalar moment evaluation as background + perturbation.
Incontrast to standard Maxwellians, here the moments are evaluated
at the phase space coordinates.
Parameters
----------
eta1, eta2, eta3, v_parallel, mu : numpy.arrays
Phase space evaluation points. All arrays must be of same shape (can be 1d for flat evaluation).
name : str
Which moment to evaluate (see varaible "dct" below).
add_perturbation : bool | None
Whether to add the perturbation defined in params. If None, is taken from self.add_perturbation.
Returns
-------
A float (background value) or a numpy.array of the evaluated scalar moment.
"""
# collect arguments
assert isinstance(eta1, xp.ndarray)
assert isinstance(eta2, xp.ndarray)
assert isinstance(eta3, xp.ndarray)
assert isinstance(v_parallel, xp.ndarray)
assert isinstance(mu, xp.ndarray)
assert eta1.shape == eta2.shape == eta3.shape == v_parallel.shape == mu.shape
params = self.params[name]
assert isinstance(params, tuple)
assert len(params) == 2
if eta1.ndim not in (1, 5):
raise ValueError(f"Input arrays must be 1d or 5d (from meshgrid), but got {eta1.ndim}d.")
# initialize output
out = 0.0 * eta1
# evaluate background
background = params[0]
if isinstance(background, (float, int)):
out += background
else:
assert callable(background)
sig = signature(background)
assert len(sig.parameters) == 1, (
f"Background function {background} must take one argument (psi_c), but takes {len(sig.parameters)}."
)
# inside __call__, psi_c has been evaluated at the same points; otherwise (direct call of n or vth)
# evaluate it here
psi_c = self._psi_c_call
if psi_c is None:
psi_c = self.eval_psic(*self._psic_coords(eta1, eta2, eta3, v_parallel, mu))
out += background(psi_c)
# add perturbation
if add_perturbation is None:
add_perturbation = self.add_perturbation
perturbation = params[1]
if perturbation is not None and add_perturbation:
assert isinstance(perturbation, Perturbation)
if eta1.ndim == 1:
out += perturbation(eta1, eta2, eta3)
else:
raise NotImplementedError("Perturbation evaluation for meshgrid input not implemented yet.")
# uniform density on disc (n=2 eta_1)
if name == "n" and self.params.get("uniform_on_disc", False):
out *= 2.0 * eta1
return out
[docs]
def eval_psic(self, *coords, use_cbufs: bool = True):
r"""Shifted canonical toroidal momentum evaluated at given particle positions and velocities.
If ``use_cbufs`` is ``False``, the cache buffers are not used (and not overwritten) for marker evaluation.
Otherwise the result for markers is a view of the buffer ``cbufs["psic"]``, which the next evaluation
overwrites: copy it to keep it."""
cbufs = self.cbufs if use_cbufs else None
B0 = self.equil.params["B0"]
R0 = self.equil.params["R0"]
if len(coords) == 1:
if use_cbufs and self.cbufs is None:
logger.warning(
f"Initialize {self.__class__.__name__} with `cache_size` for faster psi_c evaluation for markers!"
)
etas = coords[0][:3] # these are views (no mem allocation)
vparallel = coords[0][3]
mu = coords[0][4]
n_markers = etas.shape[1]
if cbufs is None:
absB0 = self.equil.absB0(etas)
x, y, z = self.equil.domain(etas)
else:
absB0 = cbufs["absB0"][:n_markers]
x = cbufs["x"][:n_markers]
y = cbufs["y"][:n_markers]
z = cbufs["z"][:n_markers]
absB0[:] = self.equil.absB0(etas)
x[:], y[:], z[:] = self.equil.domain(etas)
else:
assert len(coords) == 5
assert coords[0].ndim == coords[1].ndim == coords[2].ndim == coords[3].ndim == coords[4].ndim == 5
eta1 = coords[0][:, :, :, 0, 0]
eta2 = coords[1][:, :, :, 0, 0]
eta3 = coords[2][:, :, :, 0, 0]
etas = (eta1, eta2, eta3)
absB0 = self.equil.absB0(*etas)[:, :, :, None, None]
x, y, z = self.equil.domain(*etas)
vparallel = coords[3]
mu = coords[4]
if cbufs is None or len(coords) != 1:
R, P, Z = self.equil.inverse_map(x, y, z)
psi = self.equil.psi(R, Z)
if len(coords) != 1:
psi = psi[:, :, :, None, None]
energy = 1 / 2 * vparallel**2 + mu * absB0
psi_c = psi - self._epsilon * B0 * R0 / absB0 * vparallel
if self._shift is not None:
self._add_shift(
psi_c,
energy,
vparallel,
mu,
positive_mask=xp.empty(psi_c.shape, dtype=bool),
correction=xp.empty(psi_c.shape, dtype=float),
Bref=xp.empty(psi_c.shape, dtype=float),
)
else:
R = cbufs["R"][:n_markers]
P = cbufs["P"][:n_markers]
Z = cbufs["Z"][:n_markers]
psi = cbufs["psi"][:n_markers]
energy = cbufs["energy"][:n_markers]
psi_c = cbufs["psic"][:n_markers]
positive_mask = cbufs["positive_mask"][:n_markers]
correction = cbufs["correction"][:n_markers]
Bref = cbufs["Bref"][:n_markers]
R[:], P[:], Z[:] = self.equil.inverse_map(x, y, z)
psi[:] = self.equil.psi(R, Z)
energy[:] = 1 / 2 * vparallel**2 + mu * absB0
psi_c[:] = psi - self._epsilon * B0 * R0 / absB0 * vparallel
if self._shift is not None:
self._add_shift(
psi_c, energy, vparallel, mu, positive_mask=positive_mask, correction=correction, Bref=Bref
)
return psi_c
def _add_shift(self, psi_c, energy, vparallel, mu, *, positive_mask, correction, Bref):
r"""Add the shift :math:`-\text{sign}(v_\parallel)\sqrt{2(\epsilon - \mu B_\text{ref})}
\frac{m_sF}{q_sB_\text{ref}}\mathcal{H}(\epsilon - \mu B_\text{ref})` in place to the canonical toroidal
momentum ``psi_c`` (:math:`P_\phi` on input). ``positive_mask``, ``correction`` and ``Bref`` are work arrays
of the shape of ``psi_c``."""
B0 = self.equil.params["B0"]
R0 = self.equil.params["R0"]
if self._shift == "B0":
positive_mask[...] = (energy - mu * B0) > 0
correction[...] = 0.0
correction[positive_mask] = (
self._epsilon
* xp.sign(vparallel[positive_mask])
* xp.sqrt(2 * (energy[positive_mask] - mu[positive_mask] * B0))
* R0
)
elif self._shift == "Bmax":
# maximum of |B| on the flux surface psi = P_phi, the only surface where the particle can bounce
Bref[...] = self.equil.absB_max(psi_c)
positive_mask[...] = (energy - mu * Bref) > 0
correction[...] = 0.0
correction[positive_mask] = (
self._epsilon
* xp.sign(vparallel[positive_mask])
* xp.sqrt(2 * (energy[positive_mask] - mu[positive_mask] * Bref[positive_mask]))
* R0
* (B0 / Bref[positive_mask])
)
else:
raise ValueError(f"Unknown shift {self._shift!r}.")
psi_c[...] += correction
[docs]
def shift_kernel_args(self):
"""Arguments ``(shift_mode, psi_axis, psi_edge, bmax_table)`` of the compiled kernels that evaluate the
shifted canonical toroidal momentum of markers, see :attr:`shift_modes` and
:attr:`~struphy.fields_background.base.AxisymmMHDequilibrium.absB_max_table`."""
return self.shift_kernel_args_for(self.equil, self.shift)
[docs]
@classmethod
def shift_kernel_args_for(cls, equil: AxisymmMHDequilibrium, shift: str | None):
"""Kernel arguments of :meth:`shift_kernel_args` for a given equilibrium and ``shift``."""
assert shift in cls.shift_modes, f"shift must be one of {list(cls.shift_modes)}, but is {shift!r}."
psi_axis, psi_edge = equil.psi_range
if shift == "Bmax":
bmax_table = xp.ascontiguousarray(equil.absB_max_table, dtype=float)
else:
bmax_table = xp.zeros(2, dtype=float)
return cls.shift_modes[shift], float(psi_axis), float(psi_edge), bmax_table
[docs]
def eval_rc(self, eta1, eta2, eta3, vparallel, mu):
r""" Square root of radially normalized canonical toroidal momentum.
.. math::
\begin{aligned}
r_c^2 &= \frac{\psi_c - \psi_\text{axis}}{\psi_\text{edge} - \psi_\text{axis}} \,,
\\[3mm]
r_c &= \begin{cases}
\sqrt{\frac{\psi_c - \psi_\text{axis}}{\psi_\text{edge} - \psi_\text{axis}}} & \text{if} \quad \frac{\psi_c - \psi_\text{axis}}{\psi_\text{edge} - \psi_\text{axis}} \geq 0 \,, \\
-\sqrt{\frac{\psi_c - \psi_\text{axis}}{\psi_\text{edge} - \psi_\text{axis}}} & \text{if} \quad \frac{\psi_c - \psi_\text{axis}}{\psi_\text{edge} - \psi_\text{axis}} < 0 \,,
\end{cases}
\end{aligned}
where :math:`\psi_\text{axis}` and :math:`\psi_\text{edge}` are poloidal magnetic flux function at the center and edge of poloidal plane respectively.
"""
# calculate psic
psic = self.eval_psic(eta1, eta2, eta3, vparallel, mu)
# calculate rc²
rc_squared = (psic - self.equil.psi_range[0]) / (self.equil.psi_range[1] - self.equil.psi_range[0])
# sorting out indices of negative rc²
neg_index = xp.logical_not(rc_squared >= 0)
# make them positive
rc_squared[neg_index] *= -1
# calculate rc
rc = xp.sqrt(rc_squared)
rc[neg_index] *= -1
return rc
[docs]
def n(self, eta1, eta2, eta3, vparallel, mu):
"""Zero-th moment (density)."""
out = self._evaluate_moment(eta1, eta2, eta3, vparallel, mu, name="n")
return out * self.moment_factors["n"]
[docs]
def u(self, eta1, eta2, eta3, vparallel, mu):
"""Mean velocities (zero for the canonical Maxwellian)."""
return [0.0 * eta1, 0.0 * eta1]
[docs]
def vth(self, eta1, eta2, eta3, vparallel, mu):
"""Thermal velocities."""
out = self._evaluate_moment(eta1, eta2, eta3, vparallel, mu, name="vth")
out = out * self.moment_factors["vth"]
return [out, out]
[docs]
class ColdPlasma(Maxwellian):
r"""Base class for a distribution as a Dirac-delta in velocity (vth = 0).
The __call__ method returns the density evaluation."""
[docs]
@classmethod
def default_maxw_params(cls):
"""Default parameters dictionary defining the constant value of the constant background."""
return {
"n": 5.0,
"u1": 0.0,
"u2": 0.0,
"u3": 0.0,
"vth1": 0.0,
"vth2": 0.0,
"vth3": 0.0,
}
def __init__(
self,
n: tuple[float | Callable, Perturbation] = (1.0, None),
u1: tuple[float | Callable, Perturbation] = (0.0, None),
u2: tuple[float | Callable, Perturbation] = (0.0, None),
u3: tuple[float | Callable, Perturbation] = (0.0, None),
equil: FluidEquilibriumWithB = None,
uniform_on_disc: bool = False,
):
# use setter to store input parameters
self.params = copy.deepcopy(locals())
self._params["vth1"] = (0.0, None)
self._params["vth2"] = (0.0, None)
self._params["vth3"] = (0.0, None)
self.check_maxw_params()
self._equil = equil
@property
def vdim(self):
"""Dimension of the velocity space (vdim = 0)."""
return 0
@property
def velocity_coords(self) -> LiteralOptions.VelocityCoordinates:
"""Velocity coordinates of the background."""
return None
@property
def volume_form(self):
"""Boolean. True if the background is represented as a volume form (thus including the velocity Jacobian)."""
return False
@property
def equil(self) -> FluidEquilibriumWithB:
"""Fluid background with B-field."""
return self._equil
[docs]
def velocity_jacobian_det(self, eta1, eta2, eta3, *v):
"""Jacobian determinant of the velocity coordinate transformation."""
return 1.0 + 0.0 * eta1
[docs]
def n(self, eta1, eta2, eta3):
"""Zero-th moment (density)."""
out = self._evaluate_moment(eta1, eta2, eta3, name="n")
return out
[docs]
def u(self, eta1, eta2, eta3):
"""Mean velocities."""
out = []
out += [self._evaluate_moment(eta1, eta2, eta3, name="u1")]
out += [self._evaluate_moment(eta1, eta2, eta3, name="u2")]
out += [self._evaluate_moment(eta1, eta2, eta3, name="u3")]
return out
[docs]
def vth(self, eta1, eta2, eta3):
"""Thermal velocities (are zero here, see __init__)."""
out = []
out += [self._evaluate_moment(eta1, eta2, eta3, name="vth1")]
out += [self._evaluate_moment(eta1, eta2, eta3, name="vth2")]
out += [self._evaluate_moment(eta1, eta2, eta3, name="vth3")]
return out
def __call__(self, eta1, eta2, eta3):
return self.n(eta1, eta2, eta3)