Source code for struphy.pic.particles

import copy

import cunumpy as xp

from struphy.fields_background import equils
from struphy.fields_background.base import FluidEquilibrium, FluidEquilibriumWithB
from struphy.fields_background.projected_equils import ProjectedFluidEquilibriumWithB
from struphy.geometry.base import Domain
from struphy.geometry.utilities import TransformedPformComponent
from struphy.initial.base import Perturbation
from struphy.kinetic_background import maxwellians
from struphy.kinetic_background.base import Maxwellian, SumKineticBackground
from struphy.pic.base import ORBIT_POSITIONS, Particles
from struphy.pic.diagnostics.kernels.eval_canonical_toroidal_moment_5d import eval_canonical_toroidal_moment_5d
from struphy.pic.diagnostics.kernels.eval_canonical_toroidal_moment_6d import eval_canonical_toroidal_moment_6d
from struphy.pic.diagnostics.kernels.eval_energy_5d import eval_energy_5d
from struphy.pic.diagnostics.kernels.eval_guiding_center_from_6d import eval_guiding_center_from_6d
from struphy.pic.diagnostics.kernels.eval_magnetic_background_energy import eval_magnetic_background_energy
from struphy.pic.diagnostics.kernels.eval_magnetic_energy_PBb import eval_magnetic_energy_PBb
from struphy.pic.diagnostics.kernels.eval_magnetic_moment_5d import eval_magnetic_moment_5d


[docs] class Particles6D(Particles): """ Particles in the full 6D phase space :math:`(\\boldsymbol \\eta, \\mathbf v) \\in [0, 1]^3 \\times \\mathbb R^3`, as used e.g. in full-orbit (Vlasov) kinetic models. Each marker carries a logical (curvilinear) position :math:`\\boldsymbol \\eta_p` together with a velocity :math:`\\mathbf v_p` expressed in the *Cartesian* velocity space attached to that position (i.e. velocities are not transformed by the curvilinear map, unlike positions). See :class:`~struphy.pic.base.Particles` for the structure of the numpy marker array and the meaning of its columns. """ # Class properties vdim = 3 """Dimension of the (Cartesian) velocity space, here 3.""" coordinate_labels = ("$\\eta_1$", "$\\eta_2$", "$\\eta_3$", "$v_x$", "$v_y$", "$v_z$") """Labels for the coordinates in the phase space. Length is 6, with the first 3 being the spatial coordinates and the last 3 being the velocity coordinates.""" orbit_quantities = ( *ORBIT_POSITIONS, (3, "v1", "$v_x$", "Cartesian velocity x"), (4, "v2", "$v_y$", "Cartesian velocity y"), (5, "v3", "$v_z$", "Cartesian velocity z"), (6, "weight", "$w$", "marker weight"), ) """Marker columns saved as orbits, see :attr:`~struphy.pic.base.Particles.orbit_quantities`.""" default_background = maxwellians.Maxwellian3D() """Default kinetic background is a 3D Cartesian Maxwellian.""" default_n_cols = {"diagnostics": 0, "aux": 5} """Default number of buffer columns reserved for diagnostics and auxiliary (pusher/free) use.""" @property def mu_idx(self): return self.first_diagnostics_idx + 4
[docs] def __post_init__(self): """If the background is a :class:`~struphy.kinetic_background.maxwellians.CanonicalMaxwellian`, set up the discrete magnetic field (needed to evaluate canonical invariants) from the projected equilibrium.""" if isinstance(self.background, maxwellians.CanonicalMaxwellian2D): assert isinstance(self.projected_equil, ProjectedFluidEquilibriumWithB), ( "CanonicalMaxwellian2D needs background with magnetic field." ) self._absB0_h = self.projected_equil.absB0 self._b2_h = self.projected_equil.b2 self._derham = self.projected_equil.derham
@property def sampling_density(self): """Sampling density function as volume form, used to draw markers via inverse transform/rejection sampling and to compute their initial weights (see :meth:`~struphy.pic.base.Particles.draw_markers`). This is a :class:`~struphy.kinetic_background.maxwellians.Maxwellian3D` in the Cartesian velocities ``vx, vy, vz``, parametrized by the mean velocities and thermal velocities in :attr:`loading_params`, with density normalized to 1 (i.e. uniform in ``eta1, eta2, eta3``), further multiplied by the Jacobian factor ``2 * eta1`` if :attr:`spatial` is ``"disc"`` (to sample uniformly in physical space on a disc, where ``eta1`` plays the role of a normalized radius). """ if not hasattr(self, "_sampling_density"): self._sampling_density = maxwellians.Maxwellian3D( n=(1.0, None), u1=(self.loading_params.moments[0], None), u2=(self.loading_params.moments[1], None), u3=(self.loading_params.moments[2], None), vth1=(self.loading_params.moments[3], None), vth2=(self.loading_params.moments[4], None), vth3=(self.loading_params.moments[5], None), uniform_on_disc=(self.spatial == "disc"), ) return self._sampling_density
[docs] def s0(self, eta1, eta2, eta3, vx, vy, vz, flat_eval=False, remove_holes=True): """Sampling density function as 0 form. This is the quantity stored in each marker's ``s0`` column (see the class docstring) and used to compute initial weights ``w0 = f_init / s0 / Np``. Parameters ---------- eta1, eta2, eta3 : array_like Logical evaluation points. vx, vy, vz : array_like Cartesian velocity evaluation points. flat_eval : bool If true, perform flat (marker) evaluation (etas must be same size 1D). remove_holes : bool If True, holes are removed from the returned array. If False, holes are evaluated to -1. Returns ------- out : array-like The 0-form sampling density. ------- """ assert self.domain, "self.domain must be set to call the sampling density 0-form." return self.domain.transform( self.sampling_density(eta1, eta2, eta3, vx, vy, vz), eta1, eta2, eta3, flat_eval=flat_eval, kind="3_to_0", remove_outside=remove_holes, )
[docs] def save_constants_of_motion(self): """ Calculate each marker's guiding-center constants of motion (only the equilibrium magnetic field is considered) and assign them into the diagnostics columns of the marker array: * ``0:3``: guiding-center position (logical :math:`\\boldsymbol \\eta`) * ``3``: energy * ``4``: magnetic moment * ``5``: canonical toroidal momentum * ``6``: parallel velocity """ assert isinstance(self.equil, FluidEquilibriumWithB), "Constants of motion need background with magnetic field." # idx and slice idx_gc_r = self.first_diagnostics_idx slice_gc = slice(self.first_diagnostics_idx, self.first_diagnostics_idx + 3) idx_energy = self.first_diagnostics_idx + 3 idx_can_momentum = self.first_diagnostics_idx + 5 # save cartesian positions self.markers[~self.holes, slice_gc] = self.domain( self.positions, change_out_order=True, ) # eval guiding center phase space eval_guiding_center_from_6d( self.markers, self._derham.args_derham, self.domain.args_domain, self.first_diagnostics_idx, self.equation_params.epsilon, self._b2_h[0]._data, self._b2_h[1]._data, self._b2_h[2]._data, self._absB0_h._data, ) # apply domain inverse map to get logical guiding center positions # TODO: currently only possible with the geometry where its inverse map is defined. assert hasattr(self.domain, "inverse_map") self.markers[~self.holes, slice_gc] = self.domain.inverse_map( *self.markers[~self.holes, slice_gc].T, change_out_order=True, ) # eval energy self.markers[~self.holes, idx_energy] = ( self.markers[~self.holes, 3] ** 2 + self.markers[~self.holes, 4] ** 2 + self.markers[~self.holes, 5] ** 2 ) / (2) # eval psi at etas a1 = self.equil.domain.params["a1"] R0 = self.equil.params["R0"] B0 = self.equil.params["B0"] r = self.markers[~self.holes, idx_gc_r] * (1 - a1) + a1 self.markers[~self.holes, idx_can_momentum] = self.equil.psi_r(r) # send particles to the guiding center positions self.markers[~self.holes, self.first_pusher_idx : self.first_pusher_idx + 3] = self.markers[ ~self.holes, slice_gc, ] if self.mpi_comm is not None: self.mpi_sort_markers(alpha=1) eval_canonical_toroidal_moment_6d( self.markers, self._derham.args_derham, self.first_diagnostics_idx, self.equation_params.epsilon, B0, R0, self._absB0_h._data, ) # send back and clear buffer if self.mpi_comm is not None: self.mpi_sort_markers() self.markers[~self.holes, self.first_pusher_idx : self.first_pusher_idx + 3] = 0
[docs] class DeltaFParticles6D(Particles6D): """ A class for kinetic species in full 6D phase space that solve for delta_f = f - f0. See :class:`~struphy.pic.particles.Particles6D` for more information. """
[docs] def __post_init__(self): """Force the control-variate weight update off, since delta-f weights already evolve the perturbation directly (there is no separate background contribution to subtract).""" self.weights_params.control_variate = False
def _set_initial_condition(self): """Zero out the density of the (unperturbed) background before setting the initial condition, so that only the perturbation :math:`\\delta f` is initialized on the markers.""" self.set_n_to_zero(self.initial_condition) super()._set_initial_condition()
[docs] def set_n_to_zero(self, background: Maxwellian | SumKineticBackground): """Recursively set the density moment ``n`` of ``background`` (and, if it is a :class:`~struphy.kinetic_background.base.SumKineticBackground`, of both its summands) to zero, keeping any perturbation attached to it. Parameters ---------- background : Maxwellian | SumKineticBackground The kinetic background whose density is to be zeroed. """ if isinstance(background, Maxwellian): background.params["n"] = (0.0, background.params["n"][1]) else: assert isinstance(background, SumKineticBackground) self.set_n_to_zero(background._f1) self.set_n_to_zero(background._f2)
[docs] class Particles5D(Particles): """ Particles in the 5D guiding-center, drift-kinetic or gyro-kinetic phase space :math:`(\\boldsymbol \\eta, v_\\parallel, \\mu) \\in [0, 1]^3 \\times \\mathbb R \\times \\mathbb R_{\\geq 0}`. Each marker carries a logical (curvilinear) position :math:`\\boldsymbol \\eta_p` together with the velocity coordinates .. math:: v_{\\parallel, p} = \\mathbf v_p \\cdot \\mathbf b_0(\\boldsymbol \\eta_p) \\,, \\qquad \\mu_p = \\frac{1}{2 |\\mathbf B_0|} m |\\mathbf v_p|^2 - v_{\\parallel, p}^2 \\,, defined with respect to the equilibrium magnetic field :math:`\\mathbf B_0` and its unit vector :math:`\\mathbf b_0 = \\mathbf B_0 / |\\mathbf B_0|` (unlike :class:`Particles6D`, velocities are thus not Cartesian but expressed in a field-aligned basis that itself depends on :math:`\\boldsymbol \\eta_p`). By default, two diagnostics columns are reserved (``default_n_cols["diagnostics"] = 2``), holding each marker's perpendicular energy and canonical toroidal momentum (see :meth:`save_constants_of_motion`). See :class:`~struphy.pic.base.Particles` for the structure of the numpy marker array and the meaning of its columns. """ # Class properties vdim = 2 """Dimension of the velocity space, here 2 (:math:`v_\\parallel, \\mu`).""" coordinate_labels = ("$\\eta_1$", "$\\eta_2$", "$\\eta_3$", "$v_\\parallel$", "$\\mu$") """Labels for the coordinates in the phase space. Length is 5, with the first 3 being the spatial coordinates and the last 2 being the velocity coordinates.""" orbit_quantities = ( *ORBIT_POSITIONS, (3, "v_par", "$v_\\parallel$", "parallel velocity"), (4, "mu", "$\\mu$", "magnetic moment"), (5, "weight", "$w$", "marker weight"), (9, "p_phi", "$p_\\phi$", "canonical toroidal momentum (set by save_constants_of_motion)"), ) """Marker columns saved as orbits, see :attr:`~struphy.pic.base.Particles.orbit_quantities`.""" mu_idx = 4 """Column index of particle magnetic moment.""" default_background = maxwellians.GyroMaxwellian2D() """Default kinetic background is a gyrotropic Maxwellian in :math:`(v_\\parallel, \\mu)`.""" default_n_cols = {"diagnostics": 2, "aux": 12} """Default number of buffer columns is 2 diagnostics (perpendicular energy, canonical toroidal momentum, see :meth:`save_constants_of_motion`) and 12 auxiliary columns."""
[docs] def __post_init__(self): """Retrieve the discrete equilibrium magnetic-field quantities (:math:`|B_0|`, unit 1-form :math:`\\mathbf b_0`, Derham complex) needed to project marker velocities onto :math:`v_\\parallel, \\mu` and to evaluate diagnostics, and allocate the temporary FE coefficient vectors used for that.""" assert self.projected_equil is not None, "Particles5D needs a projected MHD equilibrium." # magnetic background if self.projected_equil is not None: assert isinstance(self.projected_equil, ProjectedFluidEquilibriumWithB), ( "Particles5D needs background with magnetic field." ) self._absB0_h = self.projected_equil.absB0 self._unit_b1_h = self.projected_equil.unit_b1 self._derham = self.projected_equil.derham self._tmp0 = self.derham.V0.zeros() self._tmp2 = self.derham.V2.zeros()
@property def magn_bckgr(self): """Equilibrium fluid background carrying the magnetic field :math:`\\mathbf B_0` with respect to which :math:`v_\\parallel, \\mu` are defined.""" return self.equil @property def absB0_h(self): """Discrete 0-form coefficients of :math:`|B_0|`.""" return self._absB0_h @property def unit_b1_h(self): """Discrete 1-form coefficients of the equilibrium field-aligned unit vector :math:`\\mathbf b_0 = \\mathbf B_0/|B_0|`.""" return self._unit_b1_h @property def epsilon(self): """Normalization parameter :math:`\\epsilon` (from :attr:`equation_params`) entering the guiding-center equations of motion, e.g. the canonical toroidal momentum evaluation.""" return self.equation_params.epsilon @property def derham(self): """Discrete Derham complex of the projected equilibrium.""" return self._derham @property def sampling_density(self): """ Sampling density function as volume form, used to draw markers via inverse transform/rejection sampling and to compute their initial weights (see :meth:`~struphy.pic.base.Particles.draw_markers`). This is a :class:`~struphy.kinetic_background.maxwellians.GyroMaxwellian2D` in :math:`(v_\\parallel, \\mu)`, parametrized by the mean/thermal parallel velocity and by the equilibrium magnetic field in :attr:`loading_params`. It is normalized to 1 in logical space (i.e. uniform in ``eta1, eta2, eta3``) and already includes the polar-coordinate Jacobian factor :math:`|\\mathbf B_0|` (``volume_form=True``), further multiplied by ``2 * eta1`` if :attr:`spatial` is ``"disc"``. """ if not hasattr(self, "_sampling_density"): self._sampling_density = maxwellians.GyroMaxwellian2D( n=(1.0, None), u_para=(self.loading_params.moments[0], None), u_perp=(0.0, None), vth_para=(self.loading_params.moments[2], None), vth_perp=(self.loading_params.moments[3], None), volume_form=True, # equil=self.magn_bckgr, B0=self.loading_params.B0, uniform_on_disc=(self.spatial == "disc"), ) return self._sampling_density
[docs] def s3(self, eta1, eta2, eta3, v_para, mu): """ Sampling density function as 3-form, i.e. :meth:`sampling_density` with the velocity-space (:math:`B_0`) Jacobian factor divided back out, leaving a density that is a volume form in :math:`\\boldsymbol \\eta` only. 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 3-form sampling density. ------- """ return self.sampling_density(eta1, eta2, eta3, v_para, mu) / self.sampling_density.velocity_jacobian_det( eta1, eta2, eta3, v_para, mu )
[docs] def s0(self, eta1, eta2, eta3, v_para, mu, flat_eval=False, remove_holes=True): """ Sampling density function as 0-form, i.e. :meth:`s3` pushed forward to a pointwise density by dividing out the spatial metric Jacobian determinant. This is the quantity stored in each marker's ``s0`` column and used to compute initial weights ``w0 = f_init / s0 / Np``. Parameters ---------- eta1, eta2, eta3 : array_like Logical evaluation points. v_para, mu : array_like Parallel velocity and magnetic moment evaluation points. flat_eval : bool If true, perform flat (marker) evaluation (etas must be same size 1D). remove_holes : bool If True, holes are removed from the returned array. If False, holes are evaluated to -1. Returns ------- out : array-like The 0-form sampling density. ------- """ return self.domain.transform( self.s3(eta1, eta2, eta3, v_para, mu), eta1, eta2, eta3, flat_eval=flat_eval, kind="3_to_0", remove_outside=remove_holes, )
[docs] def save_constants_of_motion(self): """ Calculate each marker's guiding-center energy and canonical toroidal momentum (only the equilibrium magnetic field is considered) and assign them into the diagnostics columns of the marker array: * ``first_diagnostics_idx + 0``: energy * ``first_diagnostics_idx + 1``: canonical toroidal momentum The magnetic moment itself is not a diagnostics column here (unlike in :class:`Particles5Dvperp`) since it is already a phase-space coordinate, see :attr:`mu_idx`. """ assert isinstance(self.equil, FluidEquilibriumWithB), "Constants of motion need background with magnetic field." # idx and slice idx_can_momentum = self.first_diagnostics_idx + 1 eval_energy_5d( self.markers, self.derham.args_derham, self.first_diagnostics_idx, self.mu_idx, self.absB0_h._data, ) # eval psi at etas a1 = self.equil.domain.params["a1"] R0 = self.equil.params["R0"] B0 = self.equil.params["B0"] r = self.markers[~self.holes, 0] * (1 - a1) + a1 self.markers[~self.holes, idx_can_momentum] = self.equil.psi_r(r) eval_canonical_toroidal_moment_5d( self.markers, self.derham.args_derham, self.first_diagnostics_idx, self.mu_idx, idx_can_momentum, self.equation_params.epsilon, B0, R0, self.absB0_h._data, )
[docs] def save_magnetic_energy(self, PBb): r""" Calculate the (time-dependent) magnetic field energy at each marker's position and assign it into the energy diagnostics column (``self.first_diagnostics_idx``). Parameters ---------- PBb : BlockVector Finite element coefficients of the time-dependent magnetic field, projected onto V0. """ E0T = self.derham.extraction_ops["0"].transpose() PBbt = E0T.dot(PBb, out=self._tmp0) PBbt.update_ghost_regions() eval_magnetic_energy_PBb( self.markers, self.derham.args_derham, self.domain.args_domain, self.first_diagnostics_idx, self.mu_idx, self.absB0_h._data, PBbt._data, )
[docs] def save_magnetic_background_energy(self): r""" Evaluate the equilibrium magnetic-moment energy :math:`\mu_p |B_0(\boldsymbol \eta_p)|` for each marker. The result is stored in the energy diagnostics column (``self.first_diagnostics_idx``). """ eval_magnetic_background_energy( self.markers, self.derham.args_derham, self.domain.args_domain, self.first_diagnostics_idx, self.mu_idx, self.absB0_h._data, )
[docs] class Particles5Dvperp(Particles): """ Particles in the 5D guiding-center, drift-kinetic or gyro-kinetic phase space :math:`(\\boldsymbol \\eta, v_\\parallel, v_\\perp) \\in [0, 1]^3 \\times \\mathbb R \\times \\mathbb R_{\\geq 0}`. Each marker carries a logical (curvilinear) position :math:`\\boldsymbol \\eta_p` together with the parallel and perpendicular velocity coordinates .. math:: v_{\\parallel, p} = \\mathbf v_p \\cdot \\mathbf b_0(\\boldsymbol \\eta_p) \\,, \\qquad v_{\\perp, p} = \\left| \\mathbf v_p - v_{\\parallel, p} \\, \\mathbf b_0(\\boldsymbol \\eta_p) \\right| \\,, defined with respect to the equilibrium magnetic field :math:`\\mathbf B_0` and its unit vector :math:`\\mathbf b_0 = \\mathbf B_0 / |\\mathbf B_0|` (unlike :class:`Particles6D`, velocities are thus not Cartesian but expressed in a field-aligned basis that itself depends on :math:`\\boldsymbol \\eta_p`). By default, three diagnostics columns are reserved (``default_n_cols["diagnostics"] = 3``), holding each marker's guiding-center energy, magnetic moment and canonical toroidal momentum (see :meth:`save_constants_of_motion`). See :class:`~struphy.pic.base.Particles` for the structure of the numpy marker array and the meaning of its columns. """ # Class properties vdim = 2 """Dimension of the velocity space, here 2 (:math:`v_\\parallel, v_\\perp`).""" coordinate_labels = ("$\\eta_1$", "$\\eta_2$", "$\\eta_3$", "$v_\\parallel$", "$v_\\perp$") """Labels for the coordinates in the phase space. Length is 5, with the first 3 being the spatial coordinates and the last 2 being the velocity coordinates.""" orbit_quantities = ( *ORBIT_POSITIONS, (3, "v_par", "$v_\\parallel$", "parallel velocity"), (4, "v_perp", "$v_\\perp$", "perpendicular velocity"), (5, "weight", "$w$", "marker weight"), (10, "p_phi", "$p_\\phi$", "canonical toroidal momentum (set by save_constants_of_motion)"), ) """Marker columns saved as orbits, see :attr:`~struphy.pic.base.Particles.orbit_quantities`.""" default_background = maxwellians.GyroMaxwellian2Dvperp() """Default kinetic background is a gyrotropic Maxwellian in :math:`(v_\\parallel, v_\\perp)`.""" default_n_cols = {"diagnostics": 3, "aux": 12} """Default number of buffer columns is 3 diagnostics (energy, magnetic moment, canonical toroidal momentum, see :meth:`save_constants_of_motion`) and 12 auxiliary columns.""" @property def mu_idx(self): return self.first_diagnostics_idx + 1
[docs] def __post_init__(self): """Retrieve the discrete equilibrium magnetic-field quantities (:math:`|B_0|`, unit 1-form :math:`\\mathbf b_0`, Derham complex) needed to project marker velocities onto :math:`v_\\parallel, v_\\perp` and to evaluate diagnostics, and allocate the temporary FE coefficient vectors used for that.""" assert self.projected_equil is not None, "Particles5Dvperp needs a projected MHD equilibrium." # magnetic background if self.projected_equil is not None: assert isinstance(self.projected_equil, ProjectedFluidEquilibriumWithB), ( "Particles5Dvperp needs background with magnetic field." ) self._absB0_h = self.projected_equil.absB0 self._unit_b1_h = self.projected_equil.unit_b1 self._derham = self.projected_equil.derham self._tmp0 = self.derham.V0.zeros() self._tmp2 = self.derham.V2.zeros()
@property def magn_bckgr(self): """Equilibrium fluid background carrying the magnetic field :math:`\\mathbf B_0` with respect to which :math:`v_\\parallel, v_\\perp` are defined.""" return self.equil @property def absB0_h(self): """Discrete 0-form coefficients of :math:`|B_0|`.""" return self._absB0_h @property def unit_b1_h(self): """Discrete 1-form coefficients of the equilibrium field-aligned unit vector :math:`\\mathbf b_0 = \\mathbf B_0/|B_0|`.""" return self._unit_b1_h @property def epsilon(self): """Normalization parameter :math:`\\epsilon` (from :attr:`equation_params`) entering the guiding-center equations of motion, e.g. the canonical toroidal momentum evaluation.""" return self.equation_params.epsilon @property def derham(self): """Discrete Derham complex of the projected equilibrium.""" return self._derham @property def sampling_density(self): """ Sampling density function as volume form, used to draw markers via inverse transform/rejection sampling and to compute their initial weights (see :meth:`~struphy.pic.base.Particles.draw_markers`). This is a :class:`~struphy.kinetic_background.maxwellians.GyroMaxwellian2Dvperp` in :math:`(v_\\parallel, v_\\perp)`, parametrized by the mean/thermal parallel and perpendicular velocities in :attr:`loading_params`. It is normalized to 1 in logical space (i.e. uniform in ``eta1, eta2, eta3``) and already includes the polar-coordinate Jacobian factor :math:`|v_\\perp|` (``volume_form=True``), further multiplied by ``2 * eta1`` if :attr:`spatial` is ``"disc"``. Returns ------- out : array-like The volume-form sampling density. ------- """ if not hasattr(self, "_sampling_density"): self._sampling_density = maxwellians.GyroMaxwellian2Dvperp( n=(1.0, None), u_para=(self.loading_params.moments[0], None), u_perp=(self.loading_params.moments[1], None), vth_para=(self.loading_params.moments[2], None), vth_perp=(self.loading_params.moments[3], None), volume_form=True, equil=self.magn_bckgr, uniform_on_disc=(self.spatial == "disc"), ) return self._sampling_density
[docs] def s3(self, eta1, eta2, eta3, v_para, v_perp): """ Sampling density function as 3-form, i.e. :meth:`sampling_density` with the velocity-space (:math:`|v_\\perp|`) Jacobian factor divided back out, leaving a density that is a volume form in :math:`\\boldsymbol \\eta` only. 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 3-form sampling density. ------- """ return self.sampling_density(eta1, eta2, eta3, v_para, v_perp) / self.sampling_density.velocity_jacobian_det( eta1, eta2, eta3, v_para, v_perp )
[docs] def s0(self, eta1, eta2, eta3, v_para, v_perp, flat_eval=False, remove_holes=True): """ Sampling density function as 0-form, i.e. :meth:`s3` pushed forward to a pointwise density by dividing out the spatial metric Jacobian determinant. This is the quantity stored in each marker's ``s0`` column and used to compute initial weights ``w0 = f_init / s0 / Np``. Parameters ---------- eta1, eta2, eta3 : array_like Logical evaluation points. v_para, v_perp : array_like Parallel and perpendicular velocity evaluation points. flat_eval : bool If true, perform flat (marker) evaluation (etas must be same size 1D). remove_holes : bool If True, holes are removed from the returned array. If False, holes are evaluated to -1. Returns ------- out : array-like The 0-form sampling density. ------- """ return self.domain.transform( self.s3(eta1, eta2, eta3, v_para, v_perp), eta1, eta2, eta3, flat_eval=flat_eval, kind="3_to_0", remove_outside=remove_holes, )
[docs] def draw_markers(self, sort: bool = True): super().draw_markers(sort=sort) # magnetic moment is an adiabatic invariant: evaluate once at draw time (diagnostics column 1) eval_magnetic_moment_5d( self.markers, self.derham.args_derham, self.first_diagnostics_idx, self._absB0_h._data, )
[docs] def save_constants_of_motion(self): """ Calculate each marker's guiding-center energy and canonical toroidal momentum (only the equilibrium magnetic field is considered) and assign them into the diagnostics columns of the marker array: * ``first_diagnostics_idx + 0``: energy * ``first_diagnostics_idx + 1``: magnetic moment (set once in :meth:`draw_markers`, unchanged here since it is an adiabatic invariant) * ``first_diagnostics_idx + 2``: canonical toroidal momentum """ assert isinstance(self.equil, FluidEquilibriumWithB), "Constants of motion need background with magnetic field." # idx and slice idx_can_momentum = self.first_diagnostics_idx + 2 eval_energy_5d( self.markers, self.derham.args_derham, self.first_diagnostics_idx, self.mu_idx, self.absB0_h._data, ) # eval psi at etas a1 = self.equil.domain.params["a1"] R0 = self.equil.params["R0"] B0 = self.equil.params["B0"] r = self.markers[~self.holes, 0] * (1 - a1) + a1 self.markers[~self.holes, idx_can_momentum] = self.equil.psi_r(r) eval_canonical_toroidal_moment_5d( self.markers, self.derham.args_derham, self.first_diagnostics_idx, self.mu_idx, idx_can_momentum, self.equation_params.epsilon, B0, R0, self.absB0_h._data, )
[docs] def save_magnetic_energy(self, PBb): r""" Calculate the (time-dependent) magnetic field energy at each marker's position and assign it into the energy diagnostics column (``self.first_diagnostics_idx``). Parameters ---------- PBb : BlockVector Finite element coefficients of the time-dependent magnetic field, projected onto V0. """ E0T = self.derham.extraction_ops["0"].transpose() PBbt = E0T.dot(PBb, out=self._tmp0) PBbt.update_ghost_regions() eval_magnetic_energy_PBb( self.markers, self.derham.args_derham, self.domain.args_domain, self.first_diagnostics_idx, self.mu_idx, self.absB0_h._data, PBbt._data, )
[docs] def save_magnetic_background_energy(self): r""" Evaluate the equilibrium magnetic-moment energy :math:`\mu_p |B_0(\boldsymbol \eta_p)|` for each marker. The result is stored in the energy diagnostics column (``self.first_diagnostics_idx``). """ eval_magnetic_background_energy( self.markers, self.derham.args_derham, self.domain.args_domain, self.first_diagnostics_idx, self.mu_idx, self.absB0_h._data, )
[docs] def save_magnetic_moment(self): r""" Calculate the magnetic moment of each marker and assign it into the magnetic-moment diagnostics column (``self.first_diagnostics_idx + 1``). """ eval_magnetic_moment_5d( self.markers, self.derham.args_derham, self.first_diagnostics_idx, self.absB0_h._data, )
[docs] class Particles3D(Particles): """ Particles in pure 3D configuration space :math:`\\boldsymbol \\eta \\in [0, 1]^3`, with no velocity space attached (``vdim = 0``) — each marker only carries a logical (curvilinear) position, used e.g. to represent a (massless) tracer or cold-plasma fluid density. See :class:`~struphy.pic.base.Particles` for the structure of the numpy marker array and the meaning of its columns. Parameters ---------- name : str Name of particle species. Np : int Number of particles. bc : list Either 'remove', 'reflect', 'periodic' or 'refill' in each direction. loading : str Drawing of markers; either 'pseudo_random', 'sobol_standard', 'sobol_antithetic', 'external' or 'restart'. **kwargs : dict Parameters for markers, see :class:`~struphy.pic.base.Particles`. """ # Class properties vdim = 0 """Dimension of the velocity space, here 0 (no velocity coordinates).""" coordinate_labels = ("$\\eta_1$", "$\\eta_2$", "$\\eta_3$") """Labels for the coordinates in the phase space. Length is 3, with all being spatial coordinates.""" orbit_quantities = (*ORBIT_POSITIONS, (3, "weight", "$w$", "marker weight")) """Marker columns saved as orbits, see :attr:`~struphy.pic.base.Particles.orbit_quantities`.""" default_background = maxwellians.ColdPlasma() """Default kinetic background is a cold-plasma (velocity-independent) density.""" default_n_cols = {"diagnostics": 0, "aux": 5} """Default number of buffer columns reserved for diagnostics and auxiliary (pusher/free) use.""" @property def mu_idx(self): return self.first_free_idx
[docs] def __post_init__(self): """No additional setup is required for this class."""
@property def sampling_density(self): """Sampling density function as volume form.""" if not hasattr(self, "_sampling_density"): self._sampling_density = maxwellians.ColdPlasma(n=(1.0, None), uniform_on_disc=(self.spatial == "disc")) return self._sampling_density
[docs] def s0(self, eta1, eta2, eta3, flat_eval=False, remove_holes=True): """Sampling density function as 0 form, i.e. :meth:`sampling_density` pushed forward to a pointwise (non-volume-form) density by dividing out the metric Jacobian determinant. Parameters ---------- eta1, eta2, eta3 : array_like Logical evaluation points. flat_eval : bool If true, perform flat (marker) evaluation (etas must be same size 1D). remove_holes : bool If True, holes are removed from the returned array. If False, holes are evaluated to -1. Returns ------- out : array-like The 0-form sampling density. ------- """ return self.domain.transform( self.sampling_density(eta1, eta2, eta3), eta1, eta2, eta3, flat_eval=flat_eval, kind="3_to_0", remove_outside=remove_holes, )
[docs] class ParticlesSPH(Particles): """ Particles for Smoothed Particle Hydrodynamics (SPH) models. The particle distribution itself lives in pure 3D configuration space :math:`\\boldsymbol \\eta \\in [0, 1]^3`, exactly as for :class:`Particles3D` (:meth:`sampling_density` and :meth:`s0` depend only on :math:`\\boldsymbol \\eta_p`). Each marker additionally carries a Cartesian velocity :math:`\\mathbf v_p` in its marker-array columns, but this is a per-particle *helper* quantity (e.g. the SPH velocity-field sample used by pushers and kernel-based reconstructions) rather than a coordinate of a sampled phase-space density. See :class:`~struphy.pic.base.Particles` for the structure of the numpy marker array and the meaning of its columns. Parameters ---------- name : str Name of the particle species. **params : dict Parameters for markers, see :class:`~struphy.pic.base.Particles`. """ # Class properties vdim = 3 """Dimension of the per-marker Cartesian velocity attribute, here 3 (not a sampled coordinate, see class docstring).""" coordinate_labels = ("$\\eta_1$", "$\\eta_2$", "$\\eta_3$", "$v_x$", "$v_y$", "$v_z$") """Labels for the coordinates in the phase space. Length is 6, with the first 3 being the spatial coordinates and the last 3 being the velocity coordinates.""" orbit_quantities = ( *ORBIT_POSITIONS, (3, "v1", "$v_x$", "Cartesian velocity x"), (4, "v2", "$v_y$", "Cartesian velocity y"), (5, "v3", "$v_z$", "Cartesian velocity z"), (6, "weight", "$w$", "marker weight"), ) """Marker columns saved as orbits, see :attr:`~struphy.pic.base.Particles.orbit_quantities`.""" default_background = equils.ConstantVelocity() """Default fluid background is a spatially constant velocity field.""" default_n_cols = {"diagnostics": 0, "aux": 24} """Default number of buffer columns reserved for diagnostics and auxiliary (pusher/free) use.""" @property def mu_idx(self): return self.first_free_idx
[docs] def __post_init__(self): """Attach the domain to the background (needed to evaluate it at marker positions). SPH does not support clone-based (tile-copied) MPI parallelization.""" assert self.clone_config is None, "SPH can only be launched with --nclones 1" self.background.domain = self.domain
@property def sampling_density(self): """Sampling density function as volume form, used to draw markers via inverse transform/rejection sampling and to compute their initial weights (see :meth:`~struphy.pic.base.Particles.draw_markers`). This density is purely spatial: uniform (normalized to 1) if :attr:`spatial` is ``"uniform"``, or multiplied by the Jacobian factor ``2 * eta1`` if :attr:`spatial` is ``"disc"``. """ if not hasattr(self, "_sampling_density"): def func(eta1, eta2, eta3, *v): if self.spatial == "uniform": return 0 * eta1 + 1.0 elif self.spatial == "disc": return 2 * eta1 else: raise NotImplementedError(f'Spatial drawing must be "uniform" or "disc", is {self.spatial}.') self._sampling_density = func return self._sampling_density
[docs] def s0(self, eta1, eta2, eta3, *v, flat_eval=False, remove_holes=True): """Sampling density function as 0 form, i.e. :meth:`sampling_density` pushed forward to a pointwise (non-volume-form) density by dividing out the metric Jacobian determinant. Parameters ---------- eta1, eta2, eta3 : array_like Logical evaluation points. *v : array_like Accepted for a call signature compatible with generic phase-space evaluation, but unused (see :meth:`sampling_density`). flat_eval : bool If true, perform flat (marker) evaluation (etas must be same size 1D). remove_holes : bool If True, holes are removed from the returned array. If False, holes are evaluated to -1. Returns ------- out : array-like The 0-form sampling density. ------- """ return self.domain.transform( self.sampling_density(eta1, eta2, eta3, *v), eta1, eta2, eta3, flat_eval=flat_eval, kind="3_to_0", remove_outside=remove_holes, )