"Base classes for kinetic backgrounds."
import copy
import logging
from abc import ABCMeta, abstractmethod
from typing import Callable, get_args
import cunumpy as xp
import matplotlib.pyplot as plt
import numpy as np
from matplotlib import cm
from matplotlib.colors import Normalize
from struphy.fields_background.base import FluidEquilibrium, FluidEquilibriumWithB
from struphy.geometry.base import Domain
from struphy.initial.base import Perturbation
from struphy.io.options import LiteralOptions
from struphy.utils.utils import __class_with_params_repr_no_defaults__
logger = logging.getLogger("struphy")
[docs]
class KineticBackground(metaclass=ABCMeta):
r"""Base class for kinetic background distributions.
Kinetic backgrounds are mainly used for particle weight computation:
* they appear as initial conditions in the numerator of particle weights
* they are evaluated at particle coordinates in the control-variate method for noise reduction.
Kinetic backgrounds can be defined in arbitrary phase space coordinates.
A determinant of the velocity Jacobian must be provided in the subclasses.
"""
@property
@abstractmethod
def vdim(self):
"""Dimension of the velocity space (vdim = n)."""
pass
@property
@abstractmethod
def velocity_coords(self) -> LiteralOptions.VelocityCoordinates:
"""Velocity coordinates of the background."""
pass
[docs]
@abstractmethod
def velocity_jacobian_det(self, eta1, eta2, eta3, *v):
"""Jacobian determinant of the velocity coordinate transformation (starting from Cartesian velocity coordinates)."""
pass
@property
@abstractmethod
def volume_form(self) -> bool:
"""True if the background is represented as a volume form (thus including the velocity Jacobian)."""
pass
[docs]
@abstractmethod
def n(self, *coords):
"""Number density (0-form).
Parameters
----------
coords : numpy.arrays
Evaluation points. All arrays must be of same shape (can be 1d for flat evaluation).
Returns
-------
A numpy.array with the density evaluated at evaluation points (same shape as coords).
"""
pass
[docs]
@abstractmethod
def u(self, *coords):
"""Mean velocities (Cartesian components).
Parameters
----------
coords : numpy.arrays
Evaluation points. All arrays must be of same shape (can be 1d for flat evaluation).
Returns
-------
A list[float] (background values) or a list[numpy.array] of the evaluated velocities.
"""
pass
@abstractmethod
def __call__(self, *phase_space_coords):
"""Evaluates the background distribution function f0 at the given phase space coordinates.
There are two use-cases for this function in the code:
1. Evaluating for particles ("flat evaluation", inputs are all 1D of length N_p)
2. Evaluating the function on a meshgrid (in phase space).
Hence all arguments must always have
1. the same shape
2. either ndim = 1 or ndim = 3 + vdim.
Parameters
----------
*phase_space_coords : array_like
Position-velocity arguments.
Returns
-------
f0 : xp.ndarray
The evaluated background.
"""
pass
@property
def params(self) -> dict:
"""Parameters passed to __init__(), as dictionary."""
if not hasattr(self, "_params"):
self._params = {}
return self._params
@params.setter
def params(self, new):
assert isinstance(new, dict)
if "self" in new:
new.pop("self")
if "__class__" in new:
new.pop("__class__")
self._params = new
@property
def axes_transform(self) -> dict:
"""Mapping from logical dimension key to its LaTeX-formatted axis label, used for plot labeling.
Keys are "e1", "e2", "e3" (always :math:`\\eta_1, \\eta_2, \\eta_3`) and "v1", "v2", "v3", whose labels
depend on :attr:`velocity_coords`: "cartesian" (:math:`v_x, v_y, v_z`), "vpara_mu"
(:math:`v_\\parallel, \\mu`), "vpara_vperp" (:math:`v_\\parallel, v_\\perp`), or "vpara_energy"
(:math:`v_\\parallel, E`). The "v3" entry is None unless velocity_coords is "cartesian".
"""
dct = {}
dct["e1"] = "$\\eta_1$"
dct["e2"] = "$\\eta_2$"
dct["e3"] = "$\\eta_3$"
if self.velocity_coords == "cartesian":
dct["v1"] = "$v_x$"
dct["v2"] = "$v_y$"
dct["v3"] = "$v_z$"
elif self.velocity_coords == "vpara_mu":
dct["v1"] = "$v_\\parallel$"
dct["v2"] = "$\\mu$"
dct["v3"] = None
elif self.velocity_coords == "vpara_vperp":
dct["v1"] = "$v_\\parallel$"
dct["v2"] = "$v_\\perp$"
dct["v3"] = None
elif self.velocity_coords == "vpara_energy":
dct["v1"] = "$v_\\parallel$"
dct["v2"] = "$E$"
dct["v3"] = None
return dct
def __repr__(self):
out = f"{self.__class__.__name__}(\n"
for k, v in self.params.items():
out += " " * 4
out += f"{k}={v},\n"
out += ")"
return out
def __repr_no_defaults__(self):
return __class_with_params_repr_no_defaults__(self)
[docs]
def reduced_eval(
self,
dim_1: LiteralOptions.KineticDimensionsToPlot | int = "e1",
dim_2: LiteralOptions.KineticDimensionsToPlot | int | None = None,
v_lim: float | tuple[float] = 5.0,
resol: int | tuple[int] | np.ndarray | tuple[np.ndarray] = 100,
integrate_resol: tuple[int | float] | None = None,
max_points: int = 1e8,
domain: Domain | None = None,
):
"""Evaluate a "reduced" version of the background, where all but 1 or 2 dimensions have been integrated out.
See :ref:`binning`.
Integration is performed via a simple midpoint rule, with the number of integration points specified by ``integrate_resol``.
One can set a maximum for the total evaluation points in order to avoid memory issues (default is 1e8, corresponding to ~1 GB for double precision).
Parameters
----------
dim_1, dim_2 : LiteralOptions.KineticDimensionsToPlot | int
The axis (or axes) along which the reduced distribution is evaluated (i.e. the axes that are not integrated out).
They refere to logical phase space axes.
If dim_2 is not defined the reduced distribution is 1D, otherwise it is 2D.
v_lim : float | tuple[float]
Limit values for the velocity axes (default: 5.0), given as ``(v_lim_1, v_lim_2)`` for ``dim_1`` and
``dim_2`` respectively (a single float is broadcast to both entries). ``v_lim[0]`` also sets the
integration bounds of any velocity axis that is integrated out (i.e. neither ``dim_1`` nor ``dim_2``);
``v_lim[1]`` is only used when ``dim_2`` is itself a velocity axis.
For a Cartesian velocity coordinate (and v_parallel), the limits are [-v_lim, v_lim]. For a positive
velocity coordinate (such as mu or v_perp), the limits are [0, v_lim].
resol : int | tuple[int] | np.ndarray | tuple[np.ndarray]
Resolution of the evaluation grid along the plotted axis (axes). If a single integer is provided,
it is used for both dim_1 and dim_2. If a numpy array is provided, it is used as the evaluation points along the corresponding axis (or axes).
integrate_resol : tuple[int | float] | None
Number of quadrature points for integration along each phase space axis.
If None, is determined as :math:`max\\_points^{1/N}` where :math:`N` is the number of axes to integrate out.
If tuple, length must be the dimension of the phase space (3 + vdim), where the plotted axes (dim_1, dim_2)
must hold the value None. A float value means evaluation at that point rather than intgration.
Example: integrate_resol=(None, 0.5, 20, None, 15, 15) for a 3D integral in (eta_3, vy, vz),
evaluated at eta_2=0.5, and dim_1="e1", dim_2="v1" for 2D evaluation.
High number of quadrature points can lead to memory issues.
max_points : int = 1e8
Maximum number of points to evaluate the background on (default is 1e8, corresponding to ~1 GB for double precision).
domain : Domain | None = None
Mapping to physical space. If given, dim_1 and dim_2 must both be space axes (["e1","e2","e3"]) and the
returned ``physical_coords`` holds the corresponding "x", "y", "z" arrays; otherwise ``physical_coords``
is None.
Returns
-------
reduced_density : xp.ndarray
The background integrated over all axes except dim_1 (and dim_2, if given); 1D if dim_2 is None, else 2D.
plot_pts1, plot_pts2 : xp.ndarray | None
Evaluation points along dim_1 and dim_2. ``plot_pts2`` is None if dim_2 is not given.
physical_coords : dict | None
Dictionary with keys "x", "y", "z" holding the domain-mapped position arrays (broadcast to the shape
of ``reduced_density``), or None if no domain was given.
"""
if isinstance(dim_1, int):
dim_1 = get_args(LiteralOptions.KineticDimensionsToPlot)[dim_1]
if isinstance(dim_2, int):
dim_2 = get_args(LiteralOptions.KineticDimensionsToPlot)[dim_2]
if domain is not None:
assert dim_1 in ["e1", "e2", "e3"] and dim_2 in ["e1", "e2", "e3"], (
'To perform a plot in physical space you must use two space axes (dim_1, dim_2 in ["e1","e2","e3"]).'
)
n_axes_plot = 1 + (dim_2 is not None)
n_v_to_plot = ("v" in dim_1) + ("v" in dim_2 if dim_2 is not None else 0)
if isinstance(v_lim, (int, float)):
v_lim = float(v_lim)
v_lim = (v_lim, v_lim)
else:
assert isinstance(v_lim, tuple)
if len(v_lim) == 1:
v_lim = (v_lim[0], v_lim[0])
if isinstance(resol, int):
resol = (resol,) * n_axes_plot
n_eval_pts = resol
elif isinstance(resol, np.ndarray):
resol = (resol,)
n_eval_pts = (resol[0].size,)
elif isinstance(resol, tuple):
assert len(resol) == n_axes_plot, (
f"resol must have length {n_axes_plot} for this evaluation (dim_1, dim_2)."
)
n_eval_pts = tuple(r.size if isinstance(r, np.ndarray) else r for r in resol)
n_axes_integration = 3 + self.vdim - n_axes_plot
max_quad_points = max_points
for r in n_eval_pts:
max_quad_points //= r
# phase space grid, first add plotting points for the axes that are plotted
tabs = [None] * (3 + self.vdim)
axe_to_plot1, plot_pts1 = self._get_plot_pts(dim_1, v_lim[0], resol[0])
tabs[axe_to_plot1] = plot_pts1
if dim_2 is not None:
axe_to_plot2, plot_pts2 = self._get_plot_pts(dim_2, v_lim[1], resol[1])
assert axe_to_plot2 != axe_to_plot1, "You must specify different dimensions for dim_1 and dim_2"
tabs[axe_to_plot2] = plot_pts2
else:
axe_to_plot2 = None
plot_pts2 = None
# add integration points for the axes that are not plotted
if integrate_resol is None:
n_int = int(max_quad_points ** (1 / n_axes_integration))
integrate_resol = (n_int,) * (3 + self.vdim)
else:
assert len(integrate_resol) == 3 + self.vdim, (
f"integrate_resol must have length {3 + self.vdim} for this background"
)
assert integrate_resol[axe_to_plot1] is None, (
f"integrate_resol must be None for the axis {dim_1} that is plotted"
)
if axe_to_plot2 is not None:
assert integrate_resol[axe_to_plot2] is None, (
f"integrate_resol must be None for the axis {dim_2} that is plotted"
)
cp = 1
for r in integrate_resol:
if r is not None:
cp *= r
assert cp <= max_quad_points, (
f"Too many quadrature points for integration, reduce integrate_resol or increase max_points (current: {cp} > {max_quad_points})"
)
logger.info(f"Reduced evaluation with {integrate_resol = }")
velocity_space_volume = 1.0
for i, tab in enumerate(tabs):
if tab is None:
if i < 3:
if isinstance(integrate_resol[i], float):
tabs[i] = xp.array([integrate_resol[i]])
else:
raise NotImplementedError("Integration over spatial axes is not implemented yet.")
tabs[i] = xp.linspace(0.0, 1.0, integrate_resol[i])
else:
if i == 3: # Cartesian and v_parallel
tabs[i] = self._midpoint_pts(-v_lim[0], v_lim[0], integrate_resol[i])
velocity_space_volume *= 2 * v_lim[0]
else:
if self.velocity_coords == "cartesian": # Cartesian
tabs[i] = self._midpoint_pts(-v_lim[0], v_lim[0], integrate_resol[i])
velocity_space_volume *= 2 * v_lim[0]
else: # v_perp, mu and energy
assert i == 4
tabs[i] = self._midpoint_pts(0.0, v_lim[0], integrate_resol[i])
velocity_space_volume *= v_lim[0]
# push to physical position space if needed
if domain is not None:
tmp = domain(*tabs[:3])
physical_coords = {}
# keep the plotted spatial axes (e1, e2, e3) as full slices, fix the rest at index 0
idx = [0, 0, 0]
for axe_to_plot in (axe_to_plot1, axe_to_plot2):
if axe_to_plot is not None and axe_to_plot < 3:
idx[axe_to_plot] = slice(None)
idx = tuple(idx)
physical_coords["x"] = tmp[(0,) + idx]
physical_coords["y"] = tmp[(1,) + idx]
physical_coords["z"] = tmp[(2,) + idx]
else:
physical_coords = None
# memory intensive evaluation of the background on the phase space grid
phase_space_mesh = xp.meshgrid(*tabs, indexing="ij")
total_density = self(*phase_space_mesh)
axes_to_integrate = [i for i in range(3 + self.vdim)]
axes_to_integrate.remove(axe_to_plot1)
if dim_2 is not None:
axes_to_integrate.remove(axe_to_plot2)
reduced_density = xp.mean(total_density, tuple(axes_to_integrate))
reduced_density *= velocity_space_volume
return reduced_density, plot_pts1, plot_pts2, physical_coords
@staticmethod
def _midpoint_pts(left: float, right: float, n: int):
"""``n`` midpoints of the ``n`` equal-width sub-intervals of ``[left, right]``, for the midpoint quadrature
rule used in :meth:`reduced_eval`.
Using ``xp.linspace(left, right, n)`` instead (endpoint-inclusive) would place the samples on ``n - 1``
intervals and bias the ``mean(f) * (right - left)`` quadrature low by a factor ``(n - 1) / n``.
"""
dx = (right - left) / n
return left + dx * (xp.arange(n) + 0.5)
def _get_plot_pts(
self,
dim: LiteralOptions.KineticDimensionsToPlot,
v_lim: float,
resol: int | np.ndarray,
):
"""Resolve a single dimension key to its phase-space axis index and its array of evaluation points.
Parameters
----------
dim : LiteralOptions.KineticDimensionsToPlot
The axis to resolve, one of "e1", "e2", "e3", "v1", "v2", "v3".
v_lim : float
Limit value for the axis if it is a velocity axis (ignored for logical space axes, which always
span [0, 1]). For a Cartesian velocity coordinate (and v_parallel) the range is [-v_lim, v_lim];
for a positive velocity coordinate (such as mu or v_perp) the range is [0, v_lim].
resol : int | np.ndarray
Resolution of the evaluation grid along the axis. If an integer is provided, it is used to generate a linearly spaced array of that many points spanning the axis range.
If a numpy array is provided, it is used directly as the evaluation points along the axis.
Returns
-------
axe_to_plot : int
Index of ``dim`` in the phase space (0, 1, 2 for e1, e2, e3 and 3, 4, 5 for v1, v2, v3).
plot_pts : xp.ndarray
Array of ``resol`` evaluation points spanning the axis range.
"""
if dim == "e1":
axe_to_plot = 0
elif dim == "e2":
axe_to_plot = 1
elif dim == "e3":
axe_to_plot = 2
elif dim == "v1":
axe_to_plot = 3
elif dim == "v2":
axe_to_plot = 4
elif dim == "v3":
axe_to_plot = 5
else:
raise AssertionError("dim argument must match an existing dimension")
if axe_to_plot - 3 >= self.vdim:
raise AssertionError("Coordinate " + dim + " does not exist with this background")
if axe_to_plot == 3: # Cartesian and v_parallel
v_left = -v_lim
v_right = v_lim
else:
if self.velocity_coords == "cartesian": # Cartesian
v_left = -v_lim
v_right = v_lim
else: # v_perp, mu and energy
v_left = 0.0
v_right = v_lim
if isinstance(resol, int):
if axe_to_plot < 3:
plot_pts = xp.linspace(0.0, 1.0, resol)
else:
plot_pts = xp.linspace(v_left, v_right, resol)
elif isinstance(resol, np.ndarray):
plot_pts = resol
else:
raise AssertionError("resol argument must be an int or a numpy array")
return axe_to_plot, plot_pts
[docs]
def plot(
self,
dim_1: LiteralOptions.KineticDimensionsToPlot = "e1",
dim_2: LiteralOptions.KineticDimensionsToPlot | None = None,
v_lim: float = 5.0,
resol: int | tuple[int] = 100,
integrate_resol: tuple[int | float] | None = None,
max_points: int = 1e7,
domain: Domain | None = None,
proj_axis: tuple[str,] = ("x", "y"),
plot_3D: bool = False,
):
"""
Plots the density profile (slice) of the phase space background distribution.
The slice can be 1D or 2D, in logical coordinates. If a domain is given, the 2D slice is also plotted
in physical (Cartesian) coordinates, and optionally as a 3D surface.
See :meth:`reduced_eval` for how the profile is computed.
Parameters
----------
dim_1, dim_2 : LiteralOptions.KineticDimensionsToPlot = ["e1","e2","e3","v1","v2","v3"]
The axes used in the projection, they refere to logical phase space axes.
If dim_2 is not defined the projection is 1D, it is 2D if dim_2 is attributed.
v_lim : float = 5.0
Limit value of the velocity axes (broadcast to dim_1 and dim_2, see :meth:`reduced_eval`).
resol : int | tuple[int] = 100
Resolution of the plot along each plotted axis. If a single integer is provided, it is used
for both dim_1 and dim_2.
integrate_resol : tuple[int | float] | None = None
Number of quadrature points for integration along each phase space axis that is not plotted.
See :meth:`reduced_eval` for details; if None it is chosen automatically from max_points.
A float value means evaluation at that point rather than intgration.
max_points : int = 1e7
Maximum number of points to evaluate the background on, to avoid memory issues.
domain : Domain | None = None
Domain used to map the plot to physical (Cartesian) space, producing an additional 2D plot (and,
if plot_3D=True, a 3D surface plot) alongside the logical-space plot. If given, dim_1 and dim_2
must both be space axes (["e1", "e2", "e3"]).
proj_axis : tuple[str] = ("x", "y")
The two Cartesian axes ("x", "y", "z") used for the 2D physical-space plot (only used if domain
is given). If you do not see the density profile in 2D, you may change these axes.
plot_3D : bool = False
Also plot the density as a colored surface in 3D physical space. Requires domain to be given.
"""
if plot_3D:
assert domain is not None, "To perform a 3D plot you must provide a domain."
assert all([pa in ["x", "y", "z"] for pa in proj_axis]), (
f"proj_axis must be a tuple of two axes among ['x', 'y', 'z'], but is {proj_axis}"
)
reduced_density, plot_pts1, plot_pts2, physical_coords = self.reduced_eval(
dim_1=dim_1,
dim_2=dim_2,
v_lim=v_lim,
resol=resol,
integrate_resol=integrate_resol,
max_points=max_points,
domain=domain,
)
fig, ax = plt.subplots(1, 1)
if dim_2 is None:
ax.plot(plot_pts1, reduced_density)
ax.set_xlabel(self.axes_transform[dim_1])
ax.set_ylabel("density")
ax.set_title("Kinetic background profile")
else:
X, Y = xp.meshgrid(plot_pts1, plot_pts2, indexing="ij")
for_color = ax.pcolor(X, Y, reduced_density)
ax.set_xlabel(self.axes_transform[dim_1])
ax.set_ylabel(self.axes_transform[dim_2])
fig.colorbar(for_color)
ax.set_title("Kinetic background profile (logical space)")
if physical_coords is not None:
fig, ax = plt.subplots(1, 1)
for_color_phys = ax.pcolor(
physical_coords[proj_axis[0]], physical_coords[proj_axis[1]], reduced_density
)
ax.set_xlabel(proj_axis[0])
ax.set_ylabel(proj_axis[1])
fig.colorbar(for_color_phys)
ax.set_title("Kinetic background profile (physical space)")
if plot_3D:
fig, ax = plt.subplots(subplot_kw={"projection": "3d"})
norm = Normalize(reduced_density.min(), reduced_density.max() + 0.01)
colors = cm.viridis(norm(reduced_density))
ax.plot_surface(
X=physical_coords["x"],
Y=physical_coords["y"],
Z=physical_coords["z"],
facecolors=colors,
)
ax.set_xlabel("x")
ax.set_ylabel("y")
ax.set_zlabel("z")
ax.set_title("Kinetic background profile (physical space)")
plt.show(block=True)
def __add__(self, other_f0):
return SumKineticBackground(self, other_f0)
def __mul__(self, a):
return ScalarMultiplyKineticBackground(self, a)
def __rmul__(self, a):
return ScalarMultiplyKineticBackground(self, a)
def __truediv__(self, a):
assert isinstance(a, float) or isinstance(a, int) or isinstance(a, xp.int64)
assert a != 0, "Cannot divide by zero!"
return ScalarMultiplyKineticBackground(self, 1 / a)
def __sub__(self, other_f0):
return SumKineticBackground(self, ScalarMultiplyKineticBackground(other_f0, -1.0))
[docs]
class SumKineticBackground(KineticBackground):
def __init__(self, f1, f2):
# use setter to store input parameters
self.params = copy.deepcopy(locals())
assert isinstance(f1, KineticBackground)
assert isinstance(f2, KineticBackground)
assert f1.vdim == f2.vdim
assert f1.velocity_coords == f2.velocity_coords
assert f1.volume_form == f2.volume_form
self._f1 = f1
self._f2 = f2
if hasattr(f1, "_equil"):
assert f1.equil is f2.equil
self._equil = f1.equil
@property
def vdim(self):
"""Dimension of the velocity space (vdim = n)."""
return self._f1.vdim
@property
def velocity_coords(self):
"""Velocity coordinates of the background."""
return self._f1.velocity_coords
@property
def volume_form(self):
"""Boolean. True if the background is represented as a volume form (thus including the velocity Jacobian)."""
return self._f1.volume_form
@property
def equil(self) -> FluidEquilibriumWithB:
"""Fluid background with B-field."""
if not hasattr(self, "_equil"):
self._equil = None
return self._equil
[docs]
def velocity_jacobian_det(self, eta1, eta2, eta3, *v):
"""Jacobian determinant of the velocity coordinate transformation."""
return self._f1.velocity_jacobian_det(eta1, eta2, eta3, *v)
[docs]
def n(self, *coords):
return self._f1.n(*coords) + self._f2.n(*coords)
[docs]
def u(self, *coords):
n1 = self._f1.n(*coords)
n2 = self._f2.n(*coords)
u1s = self._f1.u(*coords)
u2s = self._f2.u(*coords)
return [(n1 * u1 + n2 * u2) / (n1 + n2) for u1, u2 in zip(u1s, u2s)]
def __call__(self, *phase_space_coords):
return self._f1(*phase_space_coords) + self._f2(*phase_space_coords)
[docs]
class ScalarMultiplyKineticBackground(KineticBackground):
def __init__(self, f0, a):
# use setter to store input parameters
self.params = copy.deepcopy(locals())
assert isinstance(f0, KineticBackground)
assert isinstance(a, float) or isinstance(a, int) or isinstance(a, xp.int64)
self._f = f0
self._a = a
@property
def vdim(self):
"""Dimension of the velocity space (vdim = n)."""
return self._f.vdim
@property
def velocity_coords(self):
"""Velocity coordinates of the background."""
return self._f.velocity_coords
@property
def volume_form(self):
"""Boolean. True if the background is represented as a volume form (thus including the velocity Jacobian)."""
return self._f.volume_form
[docs]
def velocity_jacobian_det(self, eta1, eta2, eta3, *v):
"""Jacobian determinant of the velocity coordinate transformation."""
return self._f.velocity_jacobian_det(eta1, eta2, eta3, *v)
[docs]
def n(self, *coords):
return self._a * self._f.n(*coords)
[docs]
def u(self, *coords):
return self._f.u(*coords)
def __call__(self, *phase_space_coords):
return self._a * self._f(*phase_space_coords)
[docs]
class Maxwellian(KineticBackground):
r"""Base class for a Maxwellian distribution function.
It is defined on :math:`[0, 1]^3 \times \mathbb R^n, n \geq 1,`
with logical position coordinates :math:`\boldsymbol{\eta} \in [0, 1]^3`:
.. math::
f(\boldsymbol{\eta}, v_1,\ldots,v_n) = n(\boldsymbol{\eta}) \prod_{i=1}^n \frac{1}{\sqrt{2\pi}\,v_{\mathrm{th},i}(\boldsymbol{\eta})}
\exp\left[-\frac{(v_i-u_i(\boldsymbol{\eta}))^2}{2\,v_{\mathrm{th},i}(\boldsymbol{\eta})^2}\right],
defined by its velocity moments: the density :math:`n(\boldsymbol{\eta})`,
the mean-velocities :math:`u_i(\boldsymbol{\eta})`,
and the thermal velocities :math:`v_{\mathrm{th},i}(\boldsymbol{\eta})`.
"""
[docs]
@abstractmethod
def vth(self, *coords):
"""Thermal velocities (0-forms).
Parameters
----------
coords : numpy.arrays
Evaluation points. All arrays must be of same shape (can be 1d for flat evaluation).
Returns
-------
A list[float] (background values) or a list[numpy.array] of the evaluated thermal velocities.
"""
pass
[docs]
def check_maxw_params(self):
for k, v in self.params.items():
assert isinstance(k, str)
if isinstance(v, tuple):
assert len(v) == 2
assert isinstance(v[0], (float, int, Callable))
assert isinstance(v[1], Perturbation) or v[1] is None
# check for uniform drawing on disc
if self.params.get("uniform_on_disc", False):
assert self.params.get("n") == (1.0, None), "Uniform drawing on disc requires n=1.0 without perturbation."
# def __repr__(self):
# out = f" {self.__class__.__name__}:"
# out += "\n maxw_params: (background, perturbation)"
# for k, v in self.maxw_params.items():
# out += f"\n {k}: {v}"
# return out
@property
def gauss_types(self) -> tuple[LiteralOptions.OptsGaussianCoordinate]:
"""Velocity coordinate types of the Maxwellian (one per velocity dimension)."""
if self.velocity_coords == "cartesian":
self._gauss_types = ("cartesian",) * self.vdim
elif self.velocity_coords == "vpara_vperp":
self._gauss_types = ("cartesian", "polar")
elif self.velocity_coords == "vpara_mu":
self._gauss_types = ("cartesian", "mu")
elif self.velocity_coords == "vpara_energy":
self._gauss_types = ("cartesian", "energy")
else:
raise ValueError(
f"Unknown velocity coordinates {self.velocity_coords}, must be one of ['cartesian', 'vpara_vperp', 'vpara_mu', 'vpara_energy']"
)
return self._gauss_types
[docs]
@classmethod
def gaussian(
self, v, u=0.0, vth=1.0, B0=2.0, type: LiteralOptions.OptsGaussianCoordinate = "cartesian", volume_form=False
):
r"""1-dim. normal distribution, to which array-valued mean- and thermal velocities can be passed.
The ``type`` selects the velocity coordinate of the Maxwellian:
- ``"cartesian"``: standard Gaussian,
.. math::
G(v) = \frac{1}{\sqrt{2\pi}\,v_{\mathrm{th}}}\exp\left[-\frac{(v-u)^2}{2\,v_{\mathrm{th}}^2}\right]\,.
- ``"polar"``: :math:`v \geq 0` is the radial coordinate of a polar representation
:math:`(v, \theta)` of a 2d isotropic Gaussian velocity space (e.g. :math:`v_\perp`
in gyro-/drift-kinetic Maxwellians, gyro-angle already integrated out), requires :math:`u=0`,
.. math::
G_{\mathrm{polar}}(v) = \frac{1}{v_{\mathrm{th}}^2}\exp\left[-\frac{v^2}{2\,v_{\mathrm{th}}^2}\right]\,.
- ``"energy"``: :math:`v \geq 0` is an energy-like coordinate such as :math:`\mu|\mathbf B| = m v_\perp^2/2`,
requires :math:`u=0`, ``volume_form`` must be ``False`` (its Jacobian depends on :math:`B^*`),
.. math::
G_{\mathrm{energy}}(v) = \frac{1}{v_{\mathrm{th}}^2}\exp\left[-\frac{v}{v_{\mathrm{th}}^2}\right]\,.
For ``"polar"``, ``volume_form=True`` multiplies by the polar velocity Jacobian
:math:`|v|` (needed to integrate to 1 over :math:`v \in [0,\infty)`; for :math:`u=0`
this reduces to the Rayleigh distribution); ``volume_form=False`` leaves the Jacobian
out, corresponding to the 0-form (density) representation used elsewhere in the
discretization.
Parameters
----------
v : float | array-like
Velocity coordinate; must be non-negative if ``type`` is ``"polar"`` or ``"energy"``.
u : float | array-like
Mean velocity evaluated at position array, same shape as v.
Must be 0 unless ``type == "cartesian"``.
vth : float | array-like
Thermal velocity evaluated at position array, same shape as v.
B0: float | array-like
Background magnetic field evaluated at position array, same shape as v.
Only used for ``type == "mu"``.
type : str
Velocity coordinate type, one of ``"cartesian"``, ``"polar"``, ``"mu"``, ``"energy"``.
volume_form : bool
If True, multiply by the polar velocity Jacobian |v|. Only valid for ``type == "polar"``.
Returns
-------
An array of size(v).
"""
if isinstance(v, xp.ndarray):
if isinstance(u, xp.ndarray):
assert v.shape == u.shape, f"{v.shape =} but {u.shape =}"
if isinstance(vth, xp.ndarray):
assert v.shape == vth.shape, f"{v.shape =} but {vth.shape =}"
if isinstance(B0, xp.ndarray):
assert v.shape == B0.shape, f"{v.shape =} but {B0.shape =}"
if type == "cartesian":
out = 1.0 / vth * 1.0 / xp.sqrt(2.0 * xp.pi) * xp.exp(-((v - u) ** 2) / (2.0 * vth**2))
elif type == "polar":
assert xp.all(v >= 0.0)
assert xp.all(u == 0.0)
out = 1.0 / vth**2 * xp.exp(-(v**2) / (2.0 * vth**2))
if volume_form:
out *= v
elif type == "mu":
assert xp.all(v >= 0.0)
assert xp.all(u == 0.0)
out = 1.0 / vth**2 * xp.exp(-v * B0 / vth**2)
if volume_form:
out *= B0
elif type == "energy":
assert xp.all(v >= 0.0)
assert xp.all(u == 0.0)
out = 1.0 / vth**2 * xp.exp(-v / vth**2)
else:
raise ValueError(
f"Unknown Gaussian coordinate type {type}. Must be one of ['cartesian', 'polar', 'mu', 'energy']."
)
return out
def __call__(self, *phase_space_coords):
"""Evaluates the Maxwellian distribution function.
There are two use-cases for this function in the code:
1. Evaluating for particles ("flat evaluation", inputs are all 1D of length N_p)
2. Evaluating the function on a meshgrid (in phase space).
Hence all arguments must always have
1. the same shape
2. either ndim = 1 or ndim = 3 + vdim.
Parameters
----------
*phase_space_coords : array_like
Phase space coordinates (position and velocity).
Returns
-------
f : xp.ndarray
The evaluated Maxwellian.
"""
from struphy.kinetic_background.maxwellians import CanonicalMaxwellian2D
args = phase_space_coords
# Check that all args have the same shape
shape0 = xp.shape(args[0])
for i, arg in enumerate(args):
assert xp.shape(arg) == shape0, f"Argument {i} has {xp.shape(arg) =}, but must be {shape0 =}."
assert xp.ndim(arg) == 1 or xp.ndim(arg) == 3 + self.vdim, (
f"{xp.ndim(arg) =} not allowed for Maxwellian evaluation."
) # flat or meshgrid evaluation
# Get result evaluated at eta's
if isinstance(self, CanonicalMaxwellian2D):
res = self.n(*args)
us = self.u(*args)
vths = self.vth(*args)
else:
res = self.n(*args[: -self.vdim])
us = self.u(*args[: -self.vdim])
vths = self.vth(*args[: -self.vdim])
# take care of correct broadcasting, assuming args come from phase space meshgrid
if xp.ndim(args[0]) > 3 and not isinstance(self, CanonicalMaxwellian2D):
# move eta axes to the back
arg_t = xp.moveaxis(args[0], 0, -1)
arg_t = xp.moveaxis(arg_t, 0, -1)
arg_t = xp.moveaxis(arg_t, 0, -1)
# broadcast
res_broad = res + 0.0 * arg_t
# move eta axes to the front
res = xp.moveaxis(res_broad, -1, 0)
res = xp.moveaxis(res, -1, 0)
res = xp.moveaxis(res, -1, 0)
# Multiply result with gaussian in v's
for i, v in enumerate(args[-self.vdim :]):
# correct broadcasting
if xp.ndim(args[0]) > 3 and not isinstance(self, CanonicalMaxwellian2D):
u_broad = us[i] + 0.0 * arg_t
u = xp.moveaxis(u_broad, -1, 0)
u = xp.moveaxis(u, -1, 0)
u = xp.moveaxis(u, -1, 0)
vth_broad = vths[i] + 0.0 * arg_t
vth = xp.moveaxis(vth_broad, -1, 0)
vth = xp.moveaxis(vth, -1, 0)
vth = xp.moveaxis(vth, -1, 0)
else:
u = us[i]
vth = vths[i]
# local field strength, needed by the "mu" velocity-Gaussian type
B0 = 2.0
if self.gauss_types[i] == "mu":
B0_param = self.params.get("B0", 2.0)
if callable(B0_param):
# callable expects flat (3, N) positions (one column per point); reshape back for meshgrid input
etas = xp.stack([xp.ravel(a) for a in args[:3]], axis=0)
B0 = xp.reshape(B0_param(etas), shape0)
else:
B0 = B0_param
res *= self.gaussian(v, u=u, vth=vth, B0=B0, type=self.gauss_types[i], volume_form=self.volume_form)
return res
def _evaluate_moment(self, eta1, eta2, eta3, *, name: str = "n", add_perturbation: bool = None):
"""Scalar moment evaluation as background + perturbation.
This method is overridden in CanonicalMaxwellian2D to feature
the canonical toroidal momentum in the evaluation.
Parameters
----------
eta1, eta2, eta3 : numpy.arrays
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 eta1.shape == eta2.shape == eta3.shape
params = self.params[name]
assert isinstance(params, tuple)
assert len(params) == 2
# flat evaluation for markers (one column per marker)
if eta1.ndim == 1:
etas = [xp.stack((eta1, eta2, eta3), axis=0)]
# assuming that input comes from meshgrid.
elif eta1.ndim == 4:
etas = (
eta1[:, :, :, 0],
eta2[:, :, :, 0],
eta3[:, :, :, 0],
)
elif eta1.ndim == 5:
etas = (
eta1[:, :, :, 0, 0],
eta2[:, :, :, 0, 0],
eta3[:, :, :, 0, 0],
)
elif eta1.ndim == 6:
etas = (
eta1[:, :, :, 0, 0, 0],
eta2[:, :, :, 0, 0, 0],
eta3[:, :, :, 0, 0, 0],
)
else:
etas = (eta1, eta2, eta3)
# initialize output
if eta1.ndim == 1:
out = 0.0 * eta1
else:
out = 0.0 * etas[0]
# evaluate background
background = params[0]
if isinstance(background, (float, int)):
out += background
else:
assert callable(background)
out += background(*etas)
# 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:
out += perturbation(*etas)
# uniform density on disc (n=2 eta_1)
if name == "n" and self.params.get("uniform_on_disc", False):
if eta1.ndim == 1:
out *= 2.0 * eta1
else:
out *= 2.0 * etas[0]
return out
@property
def add_perturbation(self) -> bool:
if not hasattr(self, "_add_perturbation"):
self._add_perturbation = True
return self._add_perturbation
@add_perturbation.setter
def add_perturbation(self, new):
assert isinstance(new, bool)
self._add_perturbation = new