import copy
import logging
import os
import warnings
from abc import ABCMeta, abstractmethod
import h5py
import scipy.special as sp
try:
from mpi4py.MPI import Intracomm
except ModuleNotFoundError:
class Intracomm:
x = None
import cunumpy as xp
from feectools.ddm.mpi import MockComm
from feectools.ddm.mpi import mpi as MPI
from line_profiler import profile
from sympy.ntheory import factorint
from struphy.bsplines.bsplines import quadrature_grid
from struphy.fields_background import equils
from struphy.fields_background.base import (
FluidEquilibrium,
FluidEquilibriumWithB,
NumericalFluidEquilibrium,
)
from struphy.fields_background.equils import set_defaults
from struphy.fields_background.projected_equils import ProjectedFluidEquilibrium
from struphy.geometry.base import Domain
from struphy.geometry.utilities import TransformedPformComponent
from struphy.initial.base import Perturbation
from struphy.io.options import LiteralOptions
from struphy.io.output_handling import DataContainer
from struphy.kernel_arguments.pusher_args_kernels import MarkerArguments
from struphy.kinetic_background.base import KineticBackground, Maxwellian
from struphy.particles.parameters import (
BoundaryParameters,
LoadingParameters,
SavingParameters,
SortingParameters,
WeightsParameters,
)
from struphy.pic import sampling_kernels, sobol_seq
from struphy.pic.pushing import eval_kernels_sph
from struphy.pic.pushing.pusher_utilities_kernels import reflect
from struphy.pic.sorting_kernels import (
assign_box_to_each_particle,
assign_particles_to_boxes,
flatten_index,
initialize_neighbours,
sort_boxed_particles,
)
from struphy.pic.sph_eval_kernels import (
box_based_evaluation_flat,
box_based_evaluation_meshgrid,
distance,
naive_evaluation_flat,
naive_evaluation_meshgrid,
)
from struphy.utils import utils
from struphy.utils.clone_config import CloneConfig
from struphy.utils.pyccel import Pyccelkernel
logger = logging.getLogger("struphy")
[docs]
class Particles(metaclass=ABCMeta):
"""Base class for particle species."""
def __init__(
self,
comm_world: Intracomm = None,
clone_config: CloneConfig = None,
domain_decomp: tuple = None,
# mpi_dims_mask: tuple | list = None,
# boxes_per_dim: tuple | list = None,
# box_bufsize: float = 5.0,
n_cols_diagnostics: int = None,
n_cols_aux: int = None,
type: str = "full_f",
name: str = "some_name",
loading_params: LoadingParameters = None,
weights_params: WeightsParameters = None,
boundary_params: BoundaryParameters = None,
sorting_params: SortingParameters = None,
saving_params: SavingParameters = None,
bufsize: float = 0.25,
domain: Domain = None,
equil: FluidEquilibrium = None,
projected_equil: ProjectedFluidEquilibrium = None,
background: KineticBackground | FluidEquilibrium = None,
initial_condition: KineticBackground = None,
perturbations: dict[str, Perturbation] = None,
n_as_volume_form: bool = False,
equation_params: dict = None,
):
r"""
The marker information is stored in a 2D numpy array.
In ``markers[ip, j]`` The row index ``ip`` refers to a specific particle,
the column index ``j`` to its attributes.
The columns are indexed as follows:
* ``0:3``: position in the logical unit cube (:math:`\boldsymbol \eta_p \in [0, 1]^3`)
* ``3:3 + vdim``: velocities
* ``3 + vdim``: (time-dependent) weight :math:`w_k(t)`
* ``4 + vdim``: PDF :math:`s^0 = s^3/\sqrt g` at particle position
* ``5 + vdim``: initial weight :math:`w_0`
* ``6 + vdim <= j < -2``: buffer indices; see attributes ``first_diagnostics_idx``, ``first_pusher_idx`` and ``first_free_idx`` below
* ``-2``: number of the sorting box the particle is in
* ``-1``: particle ID
Parameters
----------
comm_world : Intracomm
World MPI communicator.
clone_config : CloneConfig
Manages the configuration for clone-based (copied grids) parallel processing using MPI.
domain_decomp : tuple
The first entry is a domain_array (see :attr:`~struphy.feec.psydac_derham.Derham.domain_array`) and
the second entry is the number of MPI processes in each direction.
type : str
Either 'full_f' (default), 'delta_f' or 'sph'.
name : str
Name of particle species.
loading_params : LoadingParameters
Parameterts for particle loading.
weights_params : WeightsParameters
Parameters for particle weights.
boundary_params : BoundaryParameters
Parameters for particle boundary conditions.
sorting_params : SortingParameters
Parameters for particle sorting.
saving_params : SavingParameters
Parameters for particle saving.
bufsize : float
Size of buffer (as multiple of total size, default=.25) in markers array.
domain : Domain
Struphy domain object.
equil : FluidEquilibrium
Struphy fluid equilibrium object.
projected_equil : ProjectedFluidEquilibrium
Struphy fluid equilibrium projected into a discrete Derham complex.
background : KineticBackground
Kinetic background.
initial_condition : KineticBackground
Kinetic initial condition.
n_as_volume_form: bool
Whether the number density n is given as a volume form or scalar function (=default).
perturbations : Perturbation | list
Kinetic perturbation parameters.
equation_params : dict
Normalization parameters (epsilon, alpha, ...)
"""
self._clone_config = clone_config
if self.clone_config is None:
self._mpi_comm = comm_world
self._num_clones = 1
self._clone_id = 0
else:
self._mpi_comm = self.clone_config.sub_comm
self._num_clones = self.clone_config.num_clones
self._clone_id = self.clone_config.clone_id
# defaults
if n_cols_diagnostics is None:
self._n_cols_diagnostics = self.default_n_cols["diagnostics"]
if n_cols_aux is None:
self._n_cols_aux = self.default_n_cols["aux"]
if loading_params is None:
loading_params = LoadingParameters()
if weights_params is None:
weights_params = WeightsParameters()
if boundary_params is None:
boundary_params = BoundaryParameters()
if sorting_params is None:
sorting_params = SortingParameters()
if saving_params is None:
saving_params = SavingParameters()
# other parameters
self._name = name
self._loading_params = loading_params
self._weights_params = weights_params
self._boundary_params = boundary_params
self._sorting_params = sorting_params
self._saving_params = saving_params
self._domain = domain
self._equil = equil
self._projected_equil = projected_equil
self._equation_params = equation_params
# check for mpi communicator (i.e. sub_comm of clone)
if self.mpi_comm is None:
self._mpi_size = 1
self._mpi_rank = 0
self._Barrier = lambda: None
else:
self._mpi_size = self.mpi_comm.Get_size()
self._mpi_rank = self.mpi_comm.Get_rank()
self._Barrier = self.mpi_comm.Barrier
# domain decomposition (MPI) and cell information
self._boxes_per_dim = self.sorting_params.boxes_per_dim
self._box_bufsize = self.sorting_params.box_bufsize
self._mpi_dims_mask = self.sorting_params.dims_mask
if domain_decomp is None:
self._domain_array, self._nprocs = self._get_domain_decomp(self.sorting_params.dims_mask)
else:
self._domain_array = domain_decomp[0]
self._nprocs = domain_decomp[1]
# total number of cells (equal to mpi_size if no grid)
n_cells = xp.sum(xp.prod(self.domain_array[:, 2::3], axis=1, dtype=int)) * self.num_clones
# total number of boxes
if self.boxes_per_dim is None:
n_boxes = self.mpi_size * self.num_clones
else:
assert all([nboxes >= nproc for nboxes, nproc in zip(self.boxes_per_dim, self.nprocs)]), (
f"There must be at least one box {self.boxes_per_dim =} on each process {self.nprocs =} in each direction."
)
assert all([nboxes % nproc == 0 for nboxes, nproc in zip(self.boxes_per_dim, self.nprocs)]), (
f"Number of boxes {self.boxes_per_dim =} must be divisible by number of processes {self.nprocs =} in each direction."
)
n_boxes = xp.prod(self.boxes_per_dim, dtype=int) * self.num_clones
# total number of markers (Np) and particles per cell (ppc)
Np = self.loading_params.Np
ppc = self.loading_params.ppc
ppb = self.loading_params.ppb
if ppb is not None:
self._ppb = ppb
self._Np = int(self.ppb * n_boxes)
self._ppc = self.Np / n_cells
elif ppc is not None:
self._ppc = ppc
self._Np = int(self.ppc * n_cells)
self._ppb = self.Np / n_boxes
elif Np is not None:
self._Np = int(Np)
self._ppc = self.Np / n_cells
self._ppb = self.Np / n_boxes
assert self.Np >= self.mpi_size
# create marker array
self._bufsize = bufsize
self._allocate_marker_array()
# boundary conditions
bc = boundary_params.bc
bc_refill = boundary_params.bc_refill
if bc is None:
bc = ["periodic", "periodic", "periodic"]
for bci in bc:
assert bci in ("remove", "reflect", "periodic", "refill")
if bci == "reflect":
assert domain is not None, "Reflecting boundary conditions require a domain."
if bc_refill is not None:
for bc_refilli in bc_refill:
assert bc_refilli in ("outer", "inner")
self._bc = bc
self._periodic_axes = [axis for axis, b_c in enumerate(bc) if b_c == "periodic"]
self._reflect_axes = [axis for axis, b_c in enumerate(bc) if b_c == "reflect"]
self._remove_axes = [axis for axis, b_c in enumerate(bc) if b_c == "remove"]
self._bc_refill = bc_refill
bc_sph = boundary_params.bc_sph
if bc_sph is None:
bc_sph = [bci if bci == "periodic" else "mirror" for bci in self.bc]
self._mean_velocity_index = None
for bci in bc_sph:
assert bci in ("periodic", "mirror", "fixed", "noslip")
if bci == "noslip":
if boundary_params.mean_velocity_index is None:
self._mean_velocity_index = (
self.first_free_idx
) # index in marker array where mean velocity for noslip BC is stored
else:
self._mean_velocity_index = boundary_params.mean_velocity_index
self._bc_sph = bc_sph
# particle type
assert type in ("full_f", "delta_f", "sph")
self._type = type
# initialize sorting boxes
self._initialize_sorting_boxes()
# particle loading parameters
self._loading = loading_params.loading
self._spatial = loading_params.spatial
# weights
self._reject_weights = weights_params.reject_weights
self._threshold = weights_params.threshold
self._control_variate = weights_params.control_variate
# background
if background is None:
self._background = self.default_background
logger.info(f"Background set to default {self.background = }.")
else:
self._background = background
# background p-form description in [eta, v] (False means 0-form, True means volume form -> divide by det)
if isinstance(self.background, FluidEquilibrium):
self._is_volume_form = (n_as_volume_form, False)
else:
self._is_volume_form = (
n_as_volume_form,
self.background.volume_form,
)
# set background function
self._set_background_function()
self._set_background_coordinates()
# perturbation parameters (needed for fluid background)
self._perturbations = perturbations
# initial condition
if initial_condition is None:
self._initial_condition = self.background
else:
self._initial_condition = initial_condition
# for loading
# if self.loading_params["moments"] is None and self.type != "sph" and isinstance(self.bckgr_params, dict):
self._generate_sampling_moments()
# create buffers for mpi_sort_markers
self._sorting_etas = xp.zeros(self.markers.shape, dtype=float)
self._is_on_proc_domain = xp.zeros((self.markers.shape[0], 3), dtype=bool)
self._can_stay = xp.zeros(self.markers.shape[0], dtype=bool)
self._reqs = [None] * self.mpi_size
self._recvbufs = [None] * self.mpi_size
self._send_to_i = [None] * self.mpi_size
self._send_list = [None] * self.mpi_size
# post init
self.__post_init__()
@property
@abstractmethod
def type(self):
"""Particle type: 'full_f', 'delta_f' or 'sph'."""
pass
@property
@abstractmethod
def vdim(self):
"""Dimension of the velocity space."""
pass
@property
@abstractmethod
def default_background(cls):
"""The default background (of type Maxwellian)."""
pass
@property
def default_n_cols(self):
"Dictionary of the form {'diagnostics': 3, 'aux': 12} for default number of columns."
pass
@abstractmethod
def __post_init__(self):
pass
[docs]
@abstractmethod
def svol(self, eta1, eta2, eta3, *v):
r"""Marker sampling distribution function :math:`s^\textrm{vol}` as a volume form, see :ref:`monte_carlo`."""
pass
[docs]
@abstractmethod
def s0(self, eta1, eta2, eta3, *v, flat_eval=False, remove_holes=True):
r"""Marker sampling distribution function :math:`s^0` as 0-form, see :ref:`monte_carlo`."""
pass
@property
def n_cols_diagnostics(self):
"""Number of columns for storing diagnostics for each marker."""
return self._n_cols_diagnostics
@property
def n_cols_aux(self):
"""Number of auxiliary columns for each marker (e.g. for storing evaluation data)."""
return self._n_cols_aux
@property
def first_diagnostics_idx(self):
"""Starting index for diagnostics columns:
after 3 positions, vdim velocities, weight, s0 and w0."""
return 3 + self.vdim + 3
@property
def first_pusher_idx(self):
"""Starting index for storing initial conditions for a Pusher call."""
return self.first_diagnostics_idx + self.n_cols_diagnostics
@property
def n_cols_pusher(self):
"""Dimension of the phase space (for storing initial conditions for a Pusher call)."""
return 3 + self.vdim
@property
def first_shift_idx(self):
"""First index for storing shifts due to boundary conditions in eta-space."""
return self.first_pusher_idx + self.n_cols_pusher
@property
def n_cols_shift(self):
"""Number of columns for storing shifts due to boundary conditions in eta-space."""
return 3
@property
def residual_idx(self):
"""Column for storing the residual in iterative pushers."""
return self.first_shift_idx + self.n_cols_shift
@property
def first_free_idx(self):
"""First index for storing auxiliary quantities for each particle."""
return self.residual_idx + 1
@property
def n_cols(self):
"""Total umber of columns in markers array.
The last 2 columns refer to box number and particle ID, respectively."""
return self.first_free_idx + self.n_cols_aux + 2
@property
def n_rows(self):
"""Total number of rows in markers array."""
if not hasattr(self, "_n_rows"):
input("\nWarning: marker array not yet created, creating now ...")
self._allocate_marker_array()
return self._n_rows
@property
def kinds(self):
"""Name of the class."""
return self.__class__.__name__
@property
def name(self):
"""Name of the kinetic species in DATA container."""
return self._name
@property
def loading(self) -> LiteralOptions.OptsLoading:
"""Type of particle loading."""
return self._loading
@property
def bc(self):
"""List of particle boundary conditions in each direction."""
return self._bc
@property
def bc_refill(self):
"""How to re-enter particles if bc is 'refill'."""
return self._bc_refill
@property
def bc_sph(self):
"""List of boundary conditions for sph evaluation in each direction."""
return self._bc_sph
@property
def mean_velocity_index(self):
"""Index in marker array where mean velocity for noslip BC is stored."""
return self._mean_velocity_index
@property
def Np(self):
"""Total number of markers/particles, from user input."""
return self._Np
@property
def Np_per_clone(self):
"""Array where i-th entry corresponds to the number of loaded particles on clone i.
(This is not necessarily the number of valid markers per clone, see self.n_mks_on_each_clone)."""
return self._Np_per_clone
@property
def ppc(self):
"""Particles per cell (=Np if no grid is present)."""
return self._ppc
@property
def ppb(self):
"""Particles per sorting box."""
return self._ppb
@property
def bufsize(self):
"""Relative size of buffer in markers array."""
return self._bufsize
@property
def mpi_comm(self):
"""MPI communicator."""
return self._mpi_comm
@property
def mpi_size(self):
"""Number of MPI processes."""
return self._mpi_size
@property
def mpi_rank(self):
"""Rank of current process."""
return self._mpi_rank
@property
def clone_config(self):
"""Manages the configuration for clone-based (copied grids) parallel processing using MPI."""
return self._clone_config
@property
def num_clones(self):
"""Total number of clones."""
return self._num_clones
@property
def clone_id(self):
"""Clone id of current process."""
return self._clone_id
@property
def background(self) -> KineticBackground:
"""Kinetic background."""
return self._background
@property
def perturbations(self) -> dict[str, Perturbation]:
"""Kinetic perturbations, keys are the names of moments of the distribution function ("n", "u1", etc.)."""
return self._perturbations
@property
def loading_params(self) -> LoadingParameters:
return self._loading_params
@property
def weights_params(self) -> WeightsParameters:
return self._weights_params
@property
def boundary_params(self) -> BoundaryParameters:
"""Parameters for marker loading."""
return self._boundary_params
@property
def sorting_params(self) -> SortingParameters:
"""Parameters for marker sorting."""
return self._sorting_params
@property
def reject_weights(self):
"""Whether to reect weights below threshold."""
return self._reject_weights
@property
def threshold(self):
"""Threshold for rejecting weights."""
return self._threshold
@property
def boxes_per_dim(self):
"""Tuple, number of sorting boxes per dimension."""
return self._boxes_per_dim
@property
def equation_params(self):
"""Parameters appearing in model equation due to Struphy normalization."""
return self._equation_params
@property
def initial_condition(self) -> KineticBackground:
"""Kinetic initial condition"""
return self._initial_condition
@property
def f_init(self):
"""Callable initial condition (background + perturbation).
For kinetic models this is a Maxwellian.
For SPH models this is a :class:`~struphy.fields_background.base.FluidEquilibrium`."""
assert hasattr(self, "_f_init"), AttributeError(
'The method "_set_initial_condition" has not yet been called.',
)
return self._f_init
@property
def u_init(self):
"""Callable initial condition (background + perturbation) for the Cartesian velocity
in SPH models."""
assert hasattr(self, "_u_init"), AttributeError(
'The method "_set_initial_condition" has not yet been called.',
)
return self._u_init
@property
def f0(self) -> Maxwellian:
assert hasattr(self, "_f0"), AttributeError(
"No background distribution available, please run self._set_background_function()",
)
return self._f0
@property
def control_variate(self):
"""Boolean for whether to use the :ref:`control_var` during time stepping."""
return self._control_variate
@property
def domain_array(self):
"""
A 2d array[float] of shape (comm.Get_size(), 9). The row index denotes the process number and
for n=0,1,2:
* domain_array[i, 3*n + 0] holds the LEFT domain boundary of process i in direction eta_(n+1).
* domain_array[i, 3*n + 1] holds the RIGHT domain boundary of process i in direction eta_(n+1).
* domain_array[i, 3*n + 2] holds the number of cells of process i in direction eta_(n+1).
"""
return self._domain_array
@property
def mpi_dims_mask(self):
"""3-list | tuple; True if the dimension is to be used in the domain decomposition (=default for each dimension).
If mpi_dims_mask[i]=False, the i-th dimension will not be decomposed."""
return self._mpi_dims_mask
@property
def nprocs(self):
"""Number of MPI processes in each dimension."""
return self._nprocs
@property
def n_mks_load(self):
"""Array of number of markers on each process at loading stage"""
return self._n_mks_load
@property
def markers(self):
"""2D numpy array holding the marker information, including holes.
The i-th row holds the i-th marker info.
===== ============== ======================= ======= ====== ====== ========== === === ===
index | 0 | 1 | 2 | | 3 | ... | 3+(vdim-1)| 3+vdim 4+vdim 5+vdim >=6+vdim ... -2 -1
===== ============== ======================= ======= ====== ====== ========== === === ===
value position (eta) velocities weight s0 w0 other ... box ID
===== ============== ======================= ======= ====== ====== ========== === === ===
The column indices referring to different attributes can be obtained from
:attr:`~struphy.pic.base.Particles.index`.
"""
return self._markers
@property
def holes(self):
"""Array of booleans stating if an entry in the markers array is a hole."""
if not hasattr(self, "_holes"):
self._holes = self.markers[:, 0] == -1.0
return self._holes
@property
def ghost_particles(self):
"""Array of booleans stating if an entry in the markers array is a ghost particle."""
if not hasattr(self, "_ghost_particles"):
self._ghost_particles = self.markers[:, -1] == -2.0
return self._ghost_particles
@property
def markers_wo_holes(self):
"""Array holding the marker information, excluding holes. The i-th row holds the i-th marker info."""
return self.markers[~self.holes]
@property
def markers_wo_holes_and_ghost(self):
"""Array holding the marker information, excluding holes and ghosts (only valid markers). The i-th row holds the i-th marker info."""
return self.markers[self.valid_mks]
@property
def domain(self):
"""From :mod:`struphy.geometry.domains`."""
return self._domain
@property
def equil(self):
"""From :mod:`struphy.fields_background.equils`."""
return self._equil
@property
def projected_equil(self):
"""MHD equilibrium projected on 3d Derham sequence with commuting projectors."""
return self._projected_equil
@property
def lost_markers(self):
"""Array containing the last infos of removed markers"""
return self._lost_markers
@property
def n_lost_markers(self):
"""Number of removed particles."""
return self._n_lost_markers
@property
def index(self):
"""Dict holding the column indices referring to specific marker parameters (coordinates)."""
out = {}
out["pos"] = slice(0, 3) # positions
out["vel"] = slice(3, 3 + self.vdim) # velocities
out["coords"] = slice(0, 3 + self.vdim) # phasespace_coords
out["com"] = {}
out["com"]["6D"] = slice(12, 15) # constants of motion (Particles6D)
out["com"]["5D"] = slice(8, 11) # constants of motion (Particles5D)
out["pos+energy"] = {}
out["pos+energy"]["6D"] = slice(9, 13) # positions + energy
out["pos+energy"]["5D"] = list(range(0, 3)) + [8] # positions + energy
out["weights"] = 3 + self.vdim # weights
out["s0"] = 4 + self.vdim # sampling density at t=0
out["w0"] = 5 + self.vdim # weights at t=0
out["box"] = -2 # sorting box index
out["ids"] = -1 # marker_inds
return out
@property
def valid_mks(self):
"""Array of booleans stating if an entry in the markers array is a true local particle (not a hole or ghost)."""
if not hasattr(self, "_valid_mks"):
self._valid_mks = ~xp.logical_or(self.holes, self.ghost_particles)
return self._valid_mks
def update_valid_mks(self):
self._valid_mks[:] = ~xp.logical_or(self.holes, self.ghost_particles)
@property
def n_mks_loc(self):
"""Number of valid markers on process (without holes and ghosts)."""
# print(f"{self.kinds} on clone {self.clone_id}: counting valid markers: {xp.count_nonzero(self.valid_mks)} valid markers on process {self.mpi_rank} found.")
return xp.count_nonzero(self.valid_mks)
@property
def n_mks_on_each_proc(self):
"""Array where i-th entry corresponds to the number of valid markers on i-th process (without holes and ghosts)."""
return self._gather_scalar_in_subcomm_array(self.n_mks_loc)
@property
def n_mks_on_clone(self):
"""Number of valid markers on current clone (without holes and ghosts)."""
return xp.sum(self.n_mks_on_each_proc)
@property
def n_mks_on_each_clone(self):
"""Number of valid markers on current clone (without holes and ghosts)."""
return self._gather_scalar_in_intercomm_array(self.n_mks_on_clone)
@property
def n_mks_global(self):
"""Number of valid markers on current clone (without holes and ghosts)."""
return xp.sum(self.n_mks_on_each_clone)
@property
def positions(self):
"""Array holding the marker positions in logical space. The i-th row holds the i-th marker info."""
return self.markers[self.valid_mks, self.index["pos"]]
@positions.setter
def positions(self, new):
assert isinstance(new, xp.ndarray)
assert new.shape == (self.n_mks_loc, 3)
self._markers[self.valid_mks, self.index["pos"]] = new
@property
def velocities(self):
"""Array holding the marker velocities in logical space. The i-th row holds the i-th marker info."""
return self.markers[self.valid_mks, self.index["vel"]]
@velocities.setter
def velocities(self, new):
assert isinstance(new, xp.ndarray)
assert new.shape == (self.n_mks_loc, self.vdim), f"{self.n_mks_loc =} and {self.vdim =} but {new.shape =}"
self._markers[self.valid_mks, self.index["vel"]] = new
def set_velocities_comp(self, velocity, comp):
new = xp.ones(shape=(self.velocities.shape[0], 1)) * velocity
for c in comp:
self._markers[self.valid_mks, slice(3 + c, 3 + c + 1)] = new
@property
def phasespace_coords(self):
"""Array holding the marker positions and velocities in logical space. The i-th row holds the i-th marker info."""
return self.markers[self.valid_mks, self.index["coords"]]
@phasespace_coords.setter
def phasespace_coords(self, new):
assert isinstance(new, xp.ndarray)
assert new.shape == (self.n_mks_loc, 3 + self.vdim)
self._markers[self.valid_mks, self.index["coords"]] = new
@property
def weights(self):
"""Array holding the current marker weights. The i-th row holds the i-th marker info."""
return self.markers[self.valid_mks, self.index["weights"]]
@weights.setter
def weights(self, new):
assert isinstance(new, xp.ndarray)
assert new.shape == (self.n_mks_loc,)
self._markers[self.valid_mks, self.index["weights"]] = new
@property
def weights_at_t0(self):
"""Array holding the initial marker weights. The i-th row holds the i-th marker info."""
return self.markers[self.valid_mks, self.index["w0"]]
@property
def sampling_density(self):
"""Array holding the current marker 0form sampling density s0. The i-th row holds the i-th marker info."""
return self.markers[self.valid_mks, self.index["s0"]]
@sampling_density.setter
def sampling_density(self, new):
assert isinstance(new, xp.ndarray)
assert new.shape == (self.n_mks_loc,)
self._markers[self.valid_mks, self.index["s0"]] = new
@property
def weights0(self):
"""Array holding the initial marker weights. The i-th row holds the i-th marker info."""
return self.markers[self.valid_mks, self.index["w0"]]
@weights0.setter
def weights0(self, new):
assert isinstance(new, xp.ndarray)
assert new.shape == (self.n_mks_loc,)
self._markers[self.valid_mks, self.index["w0"]] = new
@property
def marker_ids(self):
"""Array holding the marker id's on the current process."""
return self.markers[self.valid_mks, self.index["ids"]]
@marker_ids.setter
def marker_ids(self, new):
assert isinstance(new, xp.ndarray)
assert new.shape == (self.n_mks_loc,)
self._markers[self.valid_mks, self.index["ids"]] = new
@property
def is_volume_form(self):
"""Tuple of size 2 for (position, velocity), defining the p-form representation of f_init: True means volume-form, False means 0-form."""
return self._is_volume_form
@property
def spatial(self):
"""Drawing particles uniformly on the unit cube('uniform') or on the disc('disc')"""
return self._spatial
@property
def f_coords_index(self):
"""Dict holding the column indices referring to coords of the distribution fuction."""
return self._f_coords_index
@property
def f_jacobian_coords_index(self):
"""Dict holding the column indices referring to coords of the velocity jacobian determinant of the distribution fuction."""
return self._f_jacobian_coords_index
@property
def f_coords(self):
"""Coordinates of the distribution function."""
return self.markers[self.valid_mks, self.f_coords_index]
@f_coords.setter
def f_coords(self, new):
assert isinstance(new, xp.ndarray)
self.markers[self.valid_mks, self.f_coords_index] = new
@property
def args_markers(self):
"""Collection of mandatory arguments for pusher kernels."""
return self._args_markers
@property
def f_jacobian_coords(self):
"""Coordinates of the velocity jacobian determinant of the distribution fuction."""
if isinstance(self.f_jacobian_coords_index, list):
return self.markers[xp.ix_(~self.holes, self.f_jacobian_coords_index)]
else:
return self.markers[~self.holes, self.f_jacobian_coords_index]
@f_jacobian_coords.setter
def f_jacobian_coords(self, new):
assert isinstance(new, xp.ndarray)
if isinstance(self.f_jacobian_coords_index, list):
self.markers[
xp.ix_(
~self.holes,
self.f_jacobian_coords_index,
)
] = new
else:
self.markers[~self.holes, self.f_jacobian_coords_index] = new
@property
def sorting_boxes(self):
if not hasattr(self, "_sorting_boxes"):
self._initialize_sorting_boxes()
return self._sorting_boxes
@property
def tesselation(self):
"""Tesselation of the current process domain."""
return self._tesselation
[docs]
@classmethod
def ker_dct(self):
"""Available smoothing kernels, numbers must be multiplies of 100."""
return {
"trigonometric_1d": 100,
"gaussian_1d": 110,
"linear_1d": 120,
"trigonometric_2d": 340,
"gaussian_2d": 350,
"linear_2d": 360,
"trigonometric_3d": 670,
"gaussian_3d": 680,
"linear_isotropic_3d": 690,
"linear_3d": 700,
}
def _get_domain_decomp(self, mpi_dims_mask: tuple | list = None):
"""
Compute domain decomposition for mesh-less methods (no Derham object).
Parameters
----------
mpi_dims_mask: list | tuple of bool
True if the dimension is to be used in the domain decomposition (=default for each dimension).
If mpi_dims_mask[i]=False, the i-th dimension will not be decomposed.
Returns
-------
dom_arr : xp.ndarray
A 2d array of shape (#MPI processes, 9). The row index denotes the process rank. The columns are for n=0,1,2:
- arr[i, 3*n + 0] holds the LEFT domain boundary of process i in direction eta_(n+1).
- arr[i, 3*n + 1] holds the RIGHT domain boundary of process i in direction eta_(n+1).
- arr[i, 3*n + 2] holds the number of cells of process i in direction eta_(n+1).
nprocs : tuple
The number of processes in each direction.
"""
if mpi_dims_mask is None:
mpi_dims_mask = [True, True, True]
dom_arr = xp.zeros((self.mpi_size, 9), dtype=float)
# factorize mpi size
factors = factorint(self.mpi_size)
factors_vec = []
for fac, multiplicity in factors.items():
for m in range(multiplicity):
factors_vec += [fac]
# processes in each direction
skip_dims = False
boxes_per_dim = (1, 1, 1)
if self.boxes_per_dim is not None:
boxes_per_dim = self.boxes_per_dim
if not all([bpd == 1 for bpd in self.boxes_per_dim]):
skip_dims = True
nprocs = [1, 1, 1]
for m, fac in enumerate(factors_vec):
mm = m % 3
while (boxes_per_dim[mm] == 1 and skip_dims) or not mpi_dims_mask[mm]:
mm = (mm + 1) % 3
nprocs[mm] *= fac
assert xp.prod(nprocs) == self.mpi_size
# domain decomposition
breaks = [xp.linspace(0.0, 1.0, nproc + 1) for nproc in nprocs]
# fill domain array
for n in range(self.mpi_size):
# determine (ijk box index) corresponding to n (inverse flattening)
i = n // (nprocs[1] * nprocs[2])
nn = n % (nprocs[1] * nprocs[2])
j = nn // nprocs[2]
k = nn % nprocs[2]
dom_arr[n, 0] = breaks[0][i]
dom_arr[n, 1] = breaks[0][i + 1]
dom_arr[n, 2] = 1
dom_arr[n, 3] = breaks[1][j]
dom_arr[n, 4] = breaks[1][j + 1]
dom_arr[n, 5] = 1
dom_arr[n, 6] = breaks[2][k]
dom_arr[n, 7] = breaks[2][k + 1]
dom_arr[n, 8] = 1
return dom_arr, tuple(nprocs)
def _set_background_function(self):
self._f0 = self.background
def _set_background_coordinates(self):
if self.type != "sph" and self.f0.coords == "constants_of_motion":
# Particles6D
if self.vdim == 3:
assert self.n_cols_diagnostics >= 7, (
f"In case of the distribution '{self.f0}' with Particles6D, minimum number of n_cols_diagnostics is 7!"
)
self._f_coords_index = self.index["com"]["6D"]
self._f_jacobian_coords_index = self.index["pos+energy"]["6D"]
# Particles5D
elif self.vdim == 2:
assert self.n_cols_diagnostics >= 3, (
f"In case of the distribution '{self.f0}' with Particles5D, minimum number of n_cols_diagnostics is 3!"
)
self._f_coords_index = self.index["com"]["5D"]
self._f_jacobian_coords_index = self.index["pos+energy"]["5D"]
if self.type == "sph":
self._f_coords_index = self.index["coords"]
self._f_jacobian_coords_index = self.index["coords"]
else:
if self.f0.coords == "constants_of_motion":
self._f_coords_index = self.index["com"]
self._f_jacobian_coords_index = self.index["pos+energy"]
else:
self._f_coords_index = self.index["coords"]
self._f_jacobian_coords_index = self.index["coords"]
def _n_mks_load_and_Np_per_clone(self):
"""Return two arrays: 1) an array of sub_comm.size where the i-th entry corresponds to the number of markers drawn on process i,
and 2) an array of size num_clones where the i-th entry corresponds to the number of markers on clone i."""
# number of cells on current process
n_cells_loc = xp.prod(
self.domain_array[self.mpi_rank, 2::3],
dtype=int,
)
# array of number of markers on each process at loading stage
if self.clone_config is not None:
_n_cells_clone = xp.sum(xp.prod(self.domain_array[:, 2::3], axis=1, dtype=int))
_n_mks_load_tot = self.clone_config.get_Np_clone(self.Np)
_ppc = _n_mks_load_tot / _n_cells_clone
else:
_n_mks_load_tot = self.Np
_ppc = self.ppc
n_mks_load = self._gather_scalar_in_subcomm_array(int(_ppc * n_cells_loc))
# add deviation from Np to rank 0
n_mks_load[0] += _n_mks_load_tot - xp.sum(n_mks_load)
# check if all markers are there
assert xp.sum(n_mks_load) == _n_mks_load_tot
# Np on each clone
Np_per_clone = self._gather_scalar_in_intercomm_array(_n_mks_load_tot)
assert xp.sum(Np_per_clone) == self.Np
return n_mks_load, Np_per_clone
def _allocate_marker_array(self):
"""Create marker array :attr:`~struphy.pic.base.Particles.markers`."""
if not hasattr(self, "_n_mks_load"):
self._n_mks_load, self._Np_per_clone = self._n_mks_load_and_Np_per_clone()
# number of markers on the local process at loading stage
n_mks_load_loc = self.n_mks_load[self._mpi_rank]
bufsize = self.bufsize + 1.0 / xp.sqrt(n_mks_load_loc)
# allocate markers array (3 x positions, vdim x velocities, weight, s0, w0, ..., ID) with buffer
self._n_rows = round(n_mks_load_loc * (1 + bufsize))
self._markers = xp.zeros((self.n_rows, self.n_cols), dtype=float)
# allocate auxiliary arrays
self._holes = xp.zeros(self.n_rows, dtype=bool)
self._ghost_particles = xp.zeros(self.n_rows, dtype=bool)
self._valid_mks = xp.zeros(self.n_rows, dtype=bool)
self._is_outside_right = xp.zeros(self.n_rows, dtype=bool)
self._is_outside_left = xp.zeros(self.n_rows, dtype=bool)
self._is_outside = xp.zeros(self.n_rows, dtype=bool)
# create array container (3 x positions, vdim x velocities, weight, s0, w0, ID) for removed markers
self._n_lost_markers = 0
self._lost_markers = xp.zeros((int(self.n_rows * 0.5), 10), dtype=float)
# arguments for kernels
self._args_markers = MarkerArguments(
self.markers,
self.valid_mks,
self.Np,
self.vdim,
self.index["weights"],
self.first_diagnostics_idx,
self.first_pusher_idx,
self.first_shift_idx,
self.residual_idx,
self.first_free_idx,
)
# Have at least 3 spare places in markers array
assert self.args_markers.first_free_idx + 2 < self.n_cols - 1, (
f"{self.args_markers.first_free_idx + 2} is not smaller than {self.n_cols - 1 =}; not enough columns in marker array !!"
)
def _initialize_sorting_boxes(self):
"""Initializes the sorting boxes.
Each MPI process has exactly the same box structure and numbering.
For instance, if boxes_per_dim = (16, 1, 1) and there are 2 MPI processes,
each process would get 8 boxes in the first direction.
Hence boxes_per_dim has to be divisible by the number of ranks in each direction.
"""
self._initialized_sorting = False
if self.boxes_per_dim is not None:
# split boxes across MPI processes
nboxes = [nboxes // nproc for nboxes, nproc in zip(self.boxes_per_dim, self.nprocs)]
# check whether this process touches the domain boundary
is_domain_boundary = {}
x_l = self.domain_array[self.mpi_rank, 0]
x_r = self.domain_array[self.mpi_rank, 1]
y_l = self.domain_array[self.mpi_rank, 3]
y_r = self.domain_array[self.mpi_rank, 4]
z_l = self.domain_array[self.mpi_rank, 6]
z_r = self.domain_array[self.mpi_rank, 7]
is_domain_boundary["x_m"] = x_l == 0.0
is_domain_boundary["x_p"] = x_r == 1.0
is_domain_boundary["y_m"] = y_l == 0.0
is_domain_boundary["y_p"] = y_r == 1.0
is_domain_boundary["z_m"] = z_l == 0.0
is_domain_boundary["z_p"] = z_r == 1.0
self._sorting_boxes = self.SortingBoxes(
self.markers.shape,
self.type == "sph",
nx=nboxes[0],
ny=nboxes[1],
nz=nboxes[2],
bc_sph=self.bc_sph,
is_domain_boundary=is_domain_boundary,
comm=self.mpi_comm,
box_bufsize=self._box_bufsize,
)
if self.sorting_boxes.communicate:
self._get_neighbouring_proc()
self._initialized_sorting = True
else:
self._sorting_boxes = None
def _generate_sampling_moments(self):
"""Automatically determine moments for sampling distribution (Gaussian) from the given background."""
if self.loading_params.moments is None:
self.loading_params.moments = tuple([0.0] * self.vdim + [1.0] * self.vdim)
# TODO: reformulate this function with KineticBackground methods
# ns = []
# us = []
# vths = []
# for fi, params in self.bckgr_params.items():
# if fi[-2] == "_":
# fi_type = fi[:-2]
# else:
# fi_type = fi
# us.append([])
# vths.append([])
# bckgr = getattr(maxwellians, fi_type)
# for key in default_maxw_params:
# if key[0] == "n":
# if key in params:
# ns += [params[key]]
# else:
# ns += [1.0]
# elif key[0] == "u":
# if key in params:
# us[-1] += [params[key]]
# else:
# us[-1] += [0.0]
# elif key[0] == "v":
# if key in params:
# vths[-1] += [params[key]]
# else:
# vths[-1] += [1.0]
# assert len(ns) == len(us) == len(vths)
# ns = xp.array(ns)
# us = xp.array(us)
# vths = xp.array(vths)
# Use the mean of shifts and thermal velocity such that outermost shift+thermal is
# new shift + new thermal
# mean_us = xp.mean(us, axis=0)
# us_ext = us + vths * xp.where(us >= 0, 1, -1)
# us_ext_dist = us_ext - mean_us[None, :]
# new_vths = xp.max(xp.abs(us_ext_dist), axis=0)
# new_moments = []
# new_moments += [*mean_us]
# new_moments += [*new_vths]
# new_moments = [float(moment) for moment in new_moments]
# self.loading_params["moments"] = new_moments
def _set_initial_condition(self):
if self.type != "sph":
self._f_init = self.initial_condition
self._u_init = None
else:
assert isinstance(self.f0, FluidEquilibrium)
_density = None
_u1 = None
if self.perturbations is not None:
for (
moment,
pert,
) in self.perturbations.items(): # only one perturbation is taken into account at the moment
assert isinstance(moment, str)
if pert is None:
continue
assert isinstance(pert, Perturbation)
if moment == "n":
if pert.given_in_basis is None:
pert.given_in_basis = "0"
_density = TransformedPformComponent(
pert,
pert.given_in_basis,
"0",
comp=pert.comp,
domain=self.domain,
)
elif moment == "u1":
if pert.given_in_basis is None:
pert.given_in_basis = "v"
_u1 = TransformedPformComponent(
pert,
pert.given_in_basis,
"v",
comp=pert.comp,
domain=self.domain,
)
# self._u_init = lambda e1, e2, e3: self.f0.uv(e1, e2, e3) + _u1(e1, e2, e3)
# TODO: add other velocity components
def _f_init(*etas, flat_eval=False):
if len(etas) == 1:
if _density is None:
out = self.f0.n0(etas[0])
else:
out = self.f0.n0(etas[0]) + _density(*etas[0].T)
else:
assert len(etas) == 3
E1, E2, E3, is_sparse_meshgrid = Domain.prepare_eval_pts(
etas[0],
etas[1],
etas[2],
flat_eval=flat_eval,
)
out0 = self.f0.n0(E1, E2, E3)
if _density is None:
out = out0
else:
out1 = _density(E1, E2, E3)
assert out0.shape == out1.shape
out = out0 + out1
if flat_eval:
out = xp.squeeze(out)
return out
def _u_init(*etas, flat_eval=False):
if len(etas) == 1:
if _u1 is None:
out = self.f0.uv(etas[0])
else:
out = self.f0.uv(etas[0]) + _u1(*etas[0].T)
else:
assert len(etas) == 3
E1, E2, E3, is_sparse_meshgrid = Domain.prepare_eval_pts(
etas[0],
etas[1],
etas[2],
flat_eval=flat_eval,
)
out0 = self.f0.uv(E1, E2, E3)
if _u1 is None:
out = out0
else:
out1 = _u1(E1, E2, E3)
assert out0.shape == out1.shape
out = out0 + out1
if flat_eval:
out = xp.squeeze(out)
return out
self._f_init = _f_init
self._u_init = _u_init
def _load_external(
self,
n_mks_load_loc: int,
n_mks_load_cum_sum: xp.ndarray,
):
"""Load markers from external .hdf5 file.
Parameters
----------
n_mks_load_loc: int
Number of markers on the local process at loading stage.
n_mks_load_cum_sum: xp.ndarray
Cumulative sum of number of markers on each process at loading stage.
"""
if self.mpi_rank == 0:
with h5py.File(self.loading_params.dir_external, "r") as file:
logger.info(f"\nLoading markers from file: {file}")
self._markers[
: n_mks_load_cum_sum[0],
:,
] = file["markers"][: n_mks_load_cum_sum[0], :]
for i in range(1, self._mpi_size):
self._mpi_comm.Send(
file["markers"][n_mks_load_cum_sum[i - 1] : n_mks_load_cum_sum[i], :],
dest=i,
tag=123,
)
else:
recvbuf = xp.zeros(
(n_mks_load_loc, self.markers.shape[1]),
dtype=float,
)
self._mpi_comm.Recv(recvbuf, source=0, tag=123)
self._markers[:n_mks_load_loc, :] = recvbuf
def _load_restart(self):
"""Load markers from restart .hdf5 file."""
# Read struphy state file
state = utils.read_state()
o_path = state["o_path"]
if self.loading_params.dir_particles_abs is None:
data_path = os.path.join(
o_path,
self.loading_params.dir_particles,
)
else:
data_path = self.loading_params.dir_particles_abs
data = DataContainer(data_path, comm=self.mpi_comm)
with h5py.File(data.file_path, "a") as file:
self._markers[:, :] = file["restart/" + self.loading_params.restart_key][-1, :, :]
def _load_tesselation(self, n_quad: int = 1):
"""
Load markers on a grid defined by the center-of-mass points of a tesselation.
Parameters
----------
n_quad: int
Number of quadrature points for the Gauss-Legendre quadrature for cell averages.
"""
self._tesselation = Tesselation(
self.ppb,
comm=self.mpi_comm,
domain_array=self.domain_array,
sorting_boxes=self.sorting_boxes,
)
eta1, eta2, eta3 = self.tesselation.draw_markers()
self._markers[: eta1.size, 0] = eta1
self._markers[: eta2.size, 1] = eta2
self._markers[: eta3.size, 2] = eta3
self.update_valid_mks()
[docs]
def draw_markers(
self,
sort: bool = True,
):
r"""
Drawing markers
* for PIC: according to the volume density :math:`s^\textrm{vol}_{\textnormal{in}}`
* for SPH: from unity/disc in space and according to the vector-field representation of the fluid velocity
In Struphy, the initial marker distribution :math:`s^\textrm{vol}_{\textnormal{in}}` is always of the form
.. math::
s^\textrm{vol}_{\textnormal{in}}(\eta,v) = n^3(\eta)\, \mathcal M(v)\,,
with :math:`\mathcal M(v)` a multi-variate Gaussian:
.. math::
\mathcal M(v) = \prod_{i=1}^{d_v} \frac{1}{\sqrt{2\pi}\,v_{\mathrm{th},i}}
\exp\left[-\frac{(v_i-u_i)^2}{2 v_{\mathrm{th},i}^2}\right]\,,
where :math:`d_v` stands for the dimension in velocity space, :math:`u_i` are velocity constant shifts
and :math:`v_{\mathrm{th},i}` are constant thermal velocities (standard deviations).
The function :math:`n^3:(0,1)^3 \to \mathbb R^+` is a normalized 3-form on the unit cube,
.. math::
\int_{(0,1)^3} n^3(\eta)\,\textnormal d \eta = 1\,.
The following choices are available in Struphy:
1. Uniform distribution on the unit cube: :math:`n^3(\eta) = 1`
2. Uniform distribution on the disc: :math:`n^3(\eta) = 2\eta_1` (radial coordinate = volume element of square-to-disc mapping)
Velocities are sampled via inverse transform sampling.
In case of Particles6D, velocities are sampled as a Maxwellian in each 3 directions,
.. math::
r_i = \int^{v_i}_{-\infty} \mathcal M(v^\prime_i) \textnormal{d} v^\prime_i = \frac{1}{2}\left[ 1 + \text{erf}\left(\frac{v_i - u_i}{\sqrt{2}v_{\mathrm{th},i}}\right)\right] \,,
where :math:`r_i \in \mathcal R(0,1)` is a uniformly drawn random number in the unit interval. So then
.. math::
v_i = \text{erfinv}(2r_i - 1)\sqrt{2}v_{\mathrm{th},i} + u_i \,.
In case of Particles5D, parallel velocity is sampled as a Maxwellian and perpendicular particle speed :math:`v_\perp = \sqrt{v_1^2 + v_2^2}`
is sampled as a 2D Maxwellian in polar coordinates,
.. math::
\mathcal{M}(v_1, v_2) \, \textnormal{d} v_1 \textnormal{d} v_2 &= \prod_{i=1}^{2} \frac{1}{\sqrt{2\pi}}\frac{1}{v_{\mathrm{th},i}}
\exp\left[-\frac{(v_i-u_i)^2}{2 v_{\mathrm{th},i}^2}\right] \textnormal{d} v_i\,,
\\
&= \frac{1}{v_\mathrm{th}^2}v_\perp \exp\left[-\frac{(v_\perp-u)^2}{2 v_\mathrm{th}^2}\right] \textnormal{d} v_\perp\,,
\\
&= \mathcal{M}^{\textnormal{pol}}(v_\perp) \, \textnormal{d} v_\perp \,.
Then,
.. math::
r = \int^{v_\perp}_0 \mathcal{M}^{\textnormal{pol}} \textnormal{d} v_\perp = 1 - \exp\left[-\frac{(v_\perp-u)^2}{2 v_\mathrm{th}^2}\right] \,.
So then,
.. math::
v_\perp = \sqrt{- \ln(1-r)}\sqrt{2}v_\mathrm{th} + u \,.
All needed parameters can be set in the parameter file, see :ref:`params_yml`.
An initial sorting will be performed if sort is given as True (default) and sorting_params were given to the init.
Parameters
----------
sort : Bool
Wether to sort the particules in boxes after initial drawing (only if sorting params were passed)
"""
# number of markers on the local process at loading stage
n_mks_load_loc = self.n_mks_load[self.mpi_rank]
# Np_per_clone_loc = self.Np_per_clone[self.clone_id]
# fill holes in markers array with -1 (all holes are at end of array at loading stage)
self._markers[n_mks_load_loc:] = -1.0
# number of holes and markers on process
self.update_holes()
self.update_ghost_particles()
# cumulative sum of number of markers on each process at loading stage.
n_mks_load_cum_sum = xp.cumsum(self.n_mks_load)
Np_per_clone_cum_sum = xp.cumsum(self.Np_per_clone)
_first_marker_id = (Np_per_clone_cum_sum - self.Np_per_clone)[self.clone_id] + (
n_mks_load_cum_sum - self.n_mks_load
)[self._mpi_rank]
logger.debug("\nMARKERS:")
logger.debug(f"{'name:':<25}{self.name}")
logger.debug(f"{'Np:':<25}{self.Np}")
logger.debug(f"{'ppc:':<25}{self.ppc}")
logger.debug(f"{'ppb:':<25}{self.ppb}")
logger.debug(f"{'bc:':<25}{self.bc}")
logger.debug(f"{'bc_refill:':<25}{self.bc_refill}")
logger.debug(f"{'loading:':<25}{self.loading}")
logger.debug(f"{'type:':<25}{self.type}")
logger.debug(f"{'control_variate:':<25}{self.control_variate}")
logger.debug(f"{'domain_array[0]:':<25}{self.domain_array[0]}")
logger.debug(f"{'boxes_per_dim:':<25}{self.boxes_per_dim}")
logger.debug(f"{'mpi_dims_mask:':<25}{self.mpi_dims_mask}")
if self.loading == "external":
self._load_external()
elif self.loading == "restart":
self._load_restart()
elif self.loading == "tesselation":
self._load_tesselation()
if self.type == "sph":
self._set_initial_condition()
self.velocities = xp.array(self.u_init(self.positions)).T
# set markers ID in last column
self.marker_ids = _first_marker_id + xp.arange(n_mks_load_loc, dtype=float)
else:
logger.debug("\nLoading fresh markers:")
for key, val in self.loading_params.__dict__.items():
logger.debug(f"{key + ' :':<25}{val}")
# 1. standard random number generator (pseudo-random)
if self.loading == "pseudo_random":
# set seed
_seed = self.loading_params.seed
if _seed is not None:
xp.random.seed(_seed)
# counting integers
num_loaded_particles_loc = 0 # number of particles alreday loaded (local)
num_loaded_particles_glob = 0 # number of particles already loaded (each clone)
chunk_size = 10000 # TODO: number of particle chunk
# Total number of markers to draw (sum over all clones)
while num_loaded_particles_glob < int(self.Np):
# Generate a chunk of random particles
num_to_add_glob = min(chunk_size, int(self.Np) - num_loaded_particles_glob)
temp = xp.random.rand(num_to_add_glob, 3 + self.vdim)
# check which particles are on the current process domain
is_on_proc_domain = xp.logical_and(
temp[:, :3] > self.domain_array[self.mpi_rank, 0::3],
temp[:, :3] < self.domain_array[self.mpi_rank, 1::3],
)
valid_idx = xp.nonzero(xp.all(is_on_proc_domain, axis=1))[0]
valid_particles = temp[valid_idx]
valid_particles = xp.array_split(valid_particles, self.num_clones)[self.clone_id]
num_valid = valid_particles.shape[0]
# Add the valid particles to the phasespace_coords array
self._markers[
num_loaded_particles_loc : num_loaded_particles_loc + num_valid,
: 3 + self.vdim,
] = valid_particles
num_loaded_particles_glob += num_to_add_glob
num_loaded_particles_loc += num_valid
# make sure all particles are loaded
assert self.Np == int(num_loaded_particles_glob), f"{self.Np =}, {int(num_loaded_particles_glob) =}"
# set new n_mks_load
self._gather_scalar_in_subcomm_array(num_loaded_particles_loc, out=self.n_mks_load)
n_mks_load_loc = self.n_mks_load[self.mpi_rank]
n_mks_load_cum_sum = xp.cumsum(self.n_mks_load)
# set new holes in markers array to -1
self._markers[num_loaded_particles_loc:] = -1.0
self.update_holes()
# 2. plain sobol numbers with skip of first 1000 numbers
elif self.loading == "sobol_standard":
self.phasespace_coords = sobol_seq.i4_sobol_generate(
3 + self.vdim,
n_mks_load_loc,
1000 + (n_mks_load_cum_sum - self.n_mks_load)[self._mpi_rank],
)
# 3. symmetric sobol numbers in all 6 dimensions with skip of first 1000 numbers
elif self.loading == "sobol_antithetic":
assert self.vdim == 3, NotImplementedError(
'"sobol_antithetic" requires vdim=3 at the moment.',
)
temp_markers = sobol_seq.i4_sobol_generate(
3 + self.vdim,
n_mks_load_loc // 64,
1000 + (n_mks_load_cum_sum - self.n_mks_load)[self._mpi_rank] // 64,
)
sampling_kernels.set_particles_symmetric_3d_3v(
temp_markers,
self.markers,
)
# 4. Wrong specification
else:
raise ValueError(
"Specified particle loading method does not exist!",
)
# initial velocities - SPH case: v(0) = u(x(0)) for given velocity u(x)
if self.type == "sph":
self._set_initial_condition()
self.velocities = xp.array(self.u_init(self.positions)).T
else:
# inverse transform sampling in velocity space
# Avoid exact 0 or 1 from low-discrepancy sequences: erfinv(±1)
# and log(0) produce infinities or invalid polar velocities.
eps = xp.finfo(float).eps
self._markers[:n_mks_load_loc, 3 : 3 + self.vdim] = xp.clip(
self._markers[:n_mks_load_loc, 3 : 3 + self.vdim],
eps,
1.0 - eps,
)
u_mean = xp.array(self.loading_params.moments[: self.vdim])
v_th = xp.array(self.loading_params.moments[self.vdim :])
# Particles6D: (1d Maxwellian, 1d Maxwellian, 1d Maxwellian)
if self.vdim == 3:
self.velocities = (
sp.erfinv(
2 * self.velocities - 1,
)
* xp.sqrt(2)
* v_th
+ u_mean
)
# Particles5D: (1d Maxwellian, polar Maxwellian as volume-form)
elif self.vdim == 2:
self._markers[:n_mks_load_loc, 3] = (
sp.erfinv(
2 * self.velocities[:, 0] - 1,
)
* xp.sqrt(2)
* v_th[0]
+ u_mean[0]
)
self._markers[:n_mks_load_loc, 4] = (
xp.sqrt(
-xp.log(1.0 - self.velocities[:, 1]),
)
* xp.sqrt(2)
* v_th[1]
)
# v_perp is a polar velocity coordinate and must be >= 0.
# A mean shift in this coordinate is not physically consistent
# with the polar Maxwellian used later in gaussian(..., polar=True).
if abs(float(u_mean[1])) > 0.0:
raise ValueError(
"For Particles5D, the second velocity coordinate is polar "
"(v_perp), so loading_params.moments[1] must be 0.0."
)
elif self.vdim == 0:
pass
else:
raise NotImplementedError(
"Inverse transform sampling of given vdim is not implemented!",
)
# inversion method for drawing uniformly on the disc
if self.spatial == "disc":
self._markers[:n_mks_load_loc, 0] = xp.sqrt(
self._markers[:n_mks_load_loc, 0],
)
else:
assert self.spatial == "uniform", f'Spatial drawing must be "uniform" or "disc", is {self.spatial}.'
self.marker_ids = _first_marker_id + xp.arange(n_mks_load_loc, dtype=float)
# set specific initial condition for some particles
if self.loading_params.specific_markers is not None:
specific_markers = self.loading_params.specific_markers
counter = 0
for i in range(len(specific_markers)):
if i == int(self.markers[counter, -1]):
for j in range(3 + self.vdim):
if specific_markers[i][j] is not None:
self._markers[
counter,
j,
] = specific_markers[i][j]
counter += 1
# check if all particle positions are inside the unit cube [0, 1]^3
n_mks_load_loc = self.n_mks_load[self._mpi_rank]
assert xp.all(~self.holes[:n_mks_load_loc])
assert xp.all(self.holes[n_mks_load_loc:])
if self._initialized_sorting and sort:
logger.info("\nSorting the markers after initial draw")
if self.mpi_comm is not None:
self.mpi_sort_markers()
self.do_sort()
logger.info("Done.")
[docs]
@profile
def mpi_sort_markers(
self,
apply_bc: bool = True,
alpha: tuple | list | int | float = 1.0,
do_test: bool = False,
remove_ghost: bool = True,
):
"""
Sorts markers according to MPI domain decomposition.
Markers are sent to the process corresponding to the alpha-weighted position
alpha*markers[:, 0:3] + (1 - alpha)*markers[:, first_pusher_idx:first_pusher_idx + 3].
Periodic boundary conditions are taken into account
when computing the alpha-weighted position.
Parameters
----------
appl_bc : bool
Whether to apply kinetic boundary conditions before sorting.
alpha : tuple | list | int | float
For i=1,2,3 the sorting is according to alpha[i]*markers[:, i] + (1 - alpha[i])*markers[:, first_pusher_idx + i].
If int or float then alpha = (alpha, alpha, alpha). alpha must be between 0 and 1.
do_test : bool
Check if all markers are on the right process after sorting.
remove_ghost : bool
Remove ghost particles before send.
"""
if remove_ghost:
self.remove_ghost_particles()
self._Barrier()
# before sorting, apply kinetic bc
if apply_bc:
self.apply_kinetic_bc()
if isinstance(alpha, int) or isinstance(alpha, float):
alpha = (alpha, alpha, alpha)
# create new markers_to_be_sent array and make corresponding holes in markers array
hole_inds_after_send, send_inds = self.sendrecv_determine_mtbs(alpha=alpha)
# determine where to send markers_to_be_sent
send_info = self.sendrecv_get_destinations(send_inds)
# set new holes in markers array to -1
self._markers[send_inds] = -1.0
# transpose send_info
recv_info = self.sendrecv_all_to_all(send_info)
# send and receive markers
self.sendrecv_markers(recv_info, hole_inds_after_send)
# new holes and new number of holes and markers on process
self.update_holes()
# refresh ghost mask: received markers may land in rows that previously held
# ghost particles. update_holes alone recomputes valid_mks from a stale
# _ghost_particles mask, which would wrongly exclude these incoming real markers.
self.update_ghost_particles()
# check if all markers are on the right process after sorting
if do_test:
all_on_right_proc = xp.all(
xp.logical_and(
self.positions > self.domain_array[self.mpi_rank, 0::3],
self.positions < self.domain_array[self.mpi_rank, 1::3],
),
)
assert all_on_right_proc
# assert self.phasespace_coords.size > 0, f'No particles on process {self.mpi_rank}, please rebalance, aborting ...'
self._Barrier()
[docs]
def initialize_weights(
self,
*,
bckgr_params: dict = None,
pert_params: dict = None,
# reject_weights: bool = False,
# threshold: float = 1e-8,
):
r"""
Computes the initial weights
.. math::
w_{k0} := \frac{f^0(t, q_k(t)) }{s^0(t, q_k(t)) } = \frac{f^0(0, q_k(0)) }{s^0(0, q_k(0)) } = \frac{f^0_{\textnormal{in}}(q_{k0}) }{s^0_{\textnormal{in}}(q_{k0}) }
from the initial distribution function :math:`f^0_{\textnormal{in}}` specified in the parmeter file
and from the initial volume density :math:`s^n_{\textnormal{vol}}` specified in :meth:`~struphy.pic.base.Particles.draw_markers`.
Moreover, it sets the corresponding columns for "w0", "s0" and "weights" in the markers array.
If :attr:`~struphy.pic.base.Particles.control_variate` is True, the background :attr:`~struphy.pic.base.Particles.f0` is subtracted.
Parameters
----------
bckgr_params : dict
Kinetic background parameters.
pert_params : dict
Kinetic perturbation parameters for initial condition.
"""
if self.loading == "tesselation":
if not self.is_volume_form[0]:
fvol = TransformedPformComponent([self.f_init], "0", "3", domain=self.domain)
else:
fvol = self.f_init
cell_avg = self.tesselation.cell_averages(fvol, n_quad=self.loading_params.n_quad)
self.weights0 = cell_avg.flatten()
else:
assert self.domain is not None, "A domain is needed to initialize weights."
# set initial condition
if bckgr_params is not None:
self._bckgr_params = bckgr_params
if pert_params is not None:
self._pert_params = pert_params
if self.type != "sph":
self._set_initial_condition()
# evaluate initial distribution function
if self.type == "sph":
f_init = self.f_init(self.positions)
else:
f_init = self.f_init(*self.f_coords.T)
# if f_init is vol-form, transform to 0-form
if self.is_volume_form[0]:
f_init /= self.domain.jacobian_det(self.positions)
if self.is_volume_form[1]:
f_init /= self.f_init.velocity_jacobian_det(
*self.f_jacobian_coords.T,
)
# compute s0 and save at vdim + 4
self.sampling_density = self.s0(*self.phasespace_coords.T, flat_eval=True)
# compute w0 and save at vdim + 5
self.weights0 = f_init / self.sampling_density / self.Np
if self.reject_weights:
reject = self.markers[:, self.index["w0"]] < self.threshold
self._markers[reject] = -1.0
self.update_holes()
self.reset_marker_ids()
logger.info(
f"\nWeights < {self.threshold} have been rejected, number of valid markers on process {self.mpi_rank} is {self.n_mks_loc}.",
)
# compute (time-dependent) weights at vdim + 3
if self.control_variate:
self.update_weights()
else:
self.weights = self.weights0
[docs]
@profile
def update_weights(self):
"""
Applies the control variate method, i.e. updates the time-dependent marker weights
according to the algorithm in :ref:`control_var`.
The background :attr:`~struphy.pic.base.Particles.f0` is used for this.
"""
if self.type == "sph":
f0 = self.f0.n0(self.positions)
else:
# in case of CanonicalMaxwellian, evaluate constants_of_motion
if self.f0.coords == "constants_of_motion":
self.save_constants_of_motion()
f0 = self.f0(*self.f_coords.T)
# if f_init is vol-form, transform to 0-form
if self.is_volume_form[0]:
f0 /= self.domain.jacobian_det(self.positions)
if self.is_volume_form[1]:
f0 /= self.f0.velocity_jacobian_det(*self.f_jacobian_coords.T)
self.weights = self.weights0 - f0 / self.sampling_density / self.Np
[docs]
def reset_marker_ids(self):
"""Reset the marker ids (last column in marker array) according to the current distribution of particles.
The first marker on rank 0 gets the id '0', the last marker on the last rank gets the id 'n_mks_global - 1'."""
n_mks_proc_cumsum = xp.cumsum(self.n_mks_on_each_proc)
n_mks_clone_cumsum = xp.cumsum(self.n_mks_on_each_clone)
first_marker_id = (n_mks_clone_cumsum - self.n_mks_on_each_clone)[self.clone_id] + (
n_mks_proc_cumsum - self.n_mks_on_each_proc
)[self.mpi_rank]
self.marker_ids = first_marker_id + xp.arange(self.n_mks_loc, dtype=int)
[docs]
@profile
def binning(
self,
components: tuple[bool],
bin_edges: tuple[xp.ndarray],
output_quantity: LiteralOptions.BinningQuantity = "density",
divide_by_jac: bool = True,
):
r"""Computes full-f and delta-f distribution functions via marker binning in logical space.
Numpy's histogramdd is used, following the algorithm outlined in :ref:`binning`.
Parameters
----------
components : tuple[bool]
List of length 3 + vdim; an entry is True if the direction in phase space is to be binned.
bin_edges : tuple[array]
List of bin edges (resolution) having the length of True entries in components.
output_quantity : BinningOutput
String literal used to determine weights in binning and the type of output
divide_by_jac : bool
Whether to divide the weights by the Jacobian determinant for binning.
Returns
-------
f_slice : array-like
The reconstructed full-f distribution function.
df_slice : array-like
The reconstructed delta-f distribution function.
"""
assert xp.count_nonzero(components) == len(bin_edges)
# volume of a bin
bin_vol = 1.0
for be in bin_edges:
bin_vol *= be[1] - be[0]
# extend components list to number of columns of markers array
_n = len(components)
slicing = components + [False] * (self.markers.shape[1] - _n)
# determine type of output quantity
# Note: "density" Literal does not have "_"
quantity, *v_axis = output_quantity.rsplit(sep="_", maxsplit=1)
v_axis = [int(char) - 1 for char in "".join(v_axis)] # convert dimension axis to index
# determine histogram weights multiplier
if quantity == "density":
multiplier = 1
elif quantity == "current":
multiplier = self.velocities[:, v_axis[0]]
elif quantity == "energy_tensor":
multiplier = self.velocities[:, v_axis[0]] * self.velocities[:, v_axis[1]]
elif quantity == "heat_flux":
velocity_norm2 = xp.linalg.norm(self.velocities, axis=1) ** 2
multiplier = velocity_norm2 * self.velocities[:, v_axis[0]]
# compute weights of histogram:
_weights0 = self.weights0 * self.Np * multiplier
_weights = self.weights * self.Np * multiplier
if divide_by_jac:
_weights /= self.domain.jacobian_det(self.positions, remove_outside=False)
# _weights /= self.velocity_jacobian_det(*self.phasespace_coords.T)
_weights0 /= self.domain.jacobian_det(self.positions, remove_outside=False)
# _weights0 /= self.velocity_jacobian_det(*self.phasespace_coords.T)
f_slice = xp.histogramdd(
self.markers_wo_holes_and_ghost[:, slicing],
bins=bin_edges,
weights=_weights0,
)[0]
df_slice = xp.histogramdd(
self.markers_wo_holes_and_ghost[:, slicing],
bins=bin_edges,
weights=_weights,
)[0]
f_slice /= self.Np * bin_vol
df_slice /= self.Np * bin_vol
return f_slice, df_slice
[docs]
def show_distribution_function(self, components, bin_edges):
"""
1D and 2D plots of slices of the distribution function via marker binning.
This routine is mainly for de-bugging.
Parameters
----------
components : list[bool]
List of length 6 giving the directions in phase space in which to bin.
bin_edges : list[array]
List of bin edges (resolution) having the length of True entries in components.
"""
import matplotlib.pyplot as plt
n_dim = xp.count_nonzero(components)
assert n_dim == 1 or n_dim == 2, f"Distribution function can only be shown in 1D or 2D slices, not {n_dim}."
f_slice, df_slice = self.binning(components, bin_edges)
bin_centers = [bi[:-1] + (bi[1] - bi[0]) / 2 for bi in bin_edges]
labels = {
0: r"$\eta_1$",
1: r"$\eta_2$",
2: r"$\eta_3$",
3: "$v_1$",
4: "$v_2$",
5: "$v_3$",
}
indices = xp.nonzero(components)[0]
if n_dim == 1:
plt.plot(bin_centers[0], f_slice)
plt.xlabel(labels[indices[0]])
else:
plt.contourf(bin_centers[0], bin_centers[1], df_slice.T, levels=20)
plt.colorbar()
# plt.axis('square')
plt.xlabel(labels[indices[0]])
plt.ylabel(labels[indices[1]])
plt.show()
def _find_outside_particles(self, axis):
# determine particles outside of the logical unit cube
self._is_outside_right[:] = self.markers[:, axis] > 1.0
self._is_outside_left[:] = self.markers[:, axis] < 0.0
self._is_outside_right[self.holes] = False
self._is_outside_right[self.ghost_particles] = False
self._is_outside_left[self.holes] = False
self._is_outside_left[self.ghost_particles] = False
self._is_outside[:] = xp.logical_or(
self._is_outside_right,
self._is_outside_left,
)
# indices or particles that are outside of the logical unit cube
outside_inds = xp.nonzero(self._is_outside)[0]
return outside_inds
[docs]
@profile
def apply_kinetic_bc(self, newton=False):
"""
Apply boundary conditions to markers that are outside of the logical unit cube.
Parameters
----------
newton : bool
Whether the shift due to boundary conditions should be computed
for a Newton step or for a strandard (explicit or Picard) step.
"""
# apply boundary conditions
for axis in self._remove_axes:
outside_inds = self._find_outside_particles(axis)
if len(outside_inds) == 0:
continue
if self.bc_refill is not None:
self.particle_refilling()
self._markers[self._is_outside, :-1] = -1.0
self._n_lost_markers += len(xp.nonzero(self._is_outside)[0])
for axis in self._periodic_axes:
outside_inds = self._find_outside_particles(axis)
if len(outside_inds) == 0:
continue
self.markers[outside_inds, axis] = self.markers[outside_inds, axis] % 1.0
# set shift for alpha-weighted mid-point computation
outside_right_inds = xp.nonzero(self._is_outside_right)[0]
outside_left_inds = xp.nonzero(self._is_outside_left)[0]
if newton:
self.markers[
outside_right_inds,
self.first_pusher_idx + 3 + self.vdim + axis,
] += 1.0
self.markers[
outside_left_inds,
self.first_pusher_idx + 3 + self.vdim + axis,
] += -1.0
else:
self.markers[
:,
self.first_pusher_idx + 3 + self.vdim + axis,
] = 0.0
self.markers[
outside_right_inds,
self.first_pusher_idx + 3 + self.vdim + axis,
] = 1.0
self.markers[
outside_left_inds,
self.first_pusher_idx + 3 + self.vdim + axis,
] = -1.0
# put all coordinate inside the unit cube (avoid wrong Jacobian evaluations)
outside_inds_per_axis = {}
for axis in self._reflect_axes:
outside_inds = self._find_outside_particles(axis)
self.markers[self._is_outside_left, axis] *= -1.0
self.markers[self._is_outside_right, axis] *= -1.0
self.markers[self._is_outside_right, axis] += 2.0
self.markers[self._is_outside, self.first_pusher_idx] = -1.0
outside_inds_per_axis[axis] = outside_inds
for axis in self._reflect_axes:
if len(outside_inds_per_axis[axis]) == 0:
continue
# flip velocity
reflect(
self.markers,
self.domain.args_domain,
outside_inds_per_axis[axis],
axis,
)
[docs]
def particle_refilling(self):
r"""
When particles move outside of the domain, refills them.
TODO: Currently only valid for HollowTorus geometry with AdhocTorus equilibrium.
In case of guiding-center orbit, refills particles at the opposite poloidal angle of the same magnetic flux surface.
.. math::
\theta_\text{refill} &= - \theta_\text{loss}
\\
\phi_\text{refill} &= -2 q(r_\text{loss}) \theta_\text{loss}
In case of full orbit, refills particles at the same gyro orbit until their guiding-centers are also outside of the domain.
When their guiding-centers also reach at the boundary, refills them as we did with guiding-center orbit.
"""
for kind in self.bc_refill:
# sorting out particles which are out of the domain
if kind == "inner":
outside_inds = xp.nonzero(self._is_outside_left)[0]
self.markers[outside_inds, 0] = 1e-4
r_loss = self.domain.params["a1"]
else:
outside_inds = xp.nonzero(self._is_outside_right)[0]
self.markers[outside_inds, 0] = 1 - 1e-4
r_loss = 1.0
if len(outside_inds) == 0:
continue
# in case of Particles6D, do gyro boundary transfer
if self.vdim == 3:
gyro_inside_inds = self.gyro_transfer(outside_inds)
# mark the particle as done for multiple step pushers
self.markers[outside_inds[gyro_inside_inds], self.first_pusher_idx] = -1.0
self._is_outside[outside_inds[gyro_inside_inds]] = False
# exclude particles whose guiding center positions are still inside.
if len(gyro_inside_inds) > 0:
outside_inds = outside_inds[~gyro_inside_inds]
# do phi boundary transfer = phi_loss - 2*q(r_loss)*theta_loss
self.markers[outside_inds, 2] -= 2 * self.equil.q_r(r_loss) * self.markers[outside_inds, 1]
# theta_boudary_transfer = - theta_loss
self.markers[outside_inds, 1] = 1.0 - self.markers[outside_inds, 1]
# mark the particle as done for multiple step pushers
self.markers[outside_inds, self.first_pusher_idx] = -1.0
self._is_outside[outside_inds] = False
[docs]
def gyro_transfer(self, outside_inds):
r"""Refills particles at the same gyro orbit.
Their perpendicular velocity directions are also changed accordingly:
First, refills the particles at the other side of the cross point (between gyro circle and domain boundary),
.. math::
\theta_\text{refill} = \theta_\text{gc} - \left(\theta_\text{loss} - \theta_\text{gc} \right) \,.
Then changes the direction of the perpendicular velocity,
.. math::
\vec{v}_{\perp, \text{refill}} = \frac{\vec{\rho}_g}{|\vec{\rho}_g|} \times \vec{b}_0 |\vec{v}_{\perp, \text{loss}}| \,,
where :math:`\vec{\rho}_g = \vec{x}_\text{refill} - \vec{X}_\text{gc}` is the cartesian radial vector.
Parameters
----------
outside_inds : xp.array (int)
An array of indices of particles which are outside of the domain.
Returns
-------
out : xp.array (bool)
An array of indices of particles where its guiding centers are outside of the domain.
"""
# incoming markers must be "Particles6D".
assert self.vdim == 3
# TODO: currently assumes periodic boundary condition along poloidal and toroidal angle
self.markers[outside_inds, 1:3] = self.markers[outside_inds, 1:3] % 1
v = self.markers[outside_inds, 3:6].T
# eval cartesian equilibrium magnetic field at the marker positions
assert isinstance(self.equil, FluidEquilibriumWithB), "Gyro transfer function needs a magnetic background."
b_cart, xyz = self.equil.b_cart(self.markers[outside_inds, :])
# calculate magnetic field amplitude and normalized magnetic field
absB0 = xp.sqrt(b_cart[0] ** 2 + b_cart[1] ** 2 + b_cart[2] ** 2)
norm_b_cart = b_cart / absB0
# calculate parallel and perpendicular velocities
v_parallel = xp.einsum("ij,ij->j", v, norm_b_cart)
v_perp = xp.cross(norm_b_cart, xp.cross(v, norm_b_cart, axis=0), axis=0)
v_perp_square = xp.sqrt(v_perp[0] ** 2 + v_perp[1] ** 2 + v_perp[2] ** 2)
assert xp.all(xp.isclose(v_perp, v - norm_b_cart * v_parallel))
# calculate Larmor radius
Larmor_r = xp.cross(norm_b_cart, v_perp, axis=0) / absB0 * self._epsilon
# transform cartesian coordinates to logical coordinates
# TODO: currently only possible with the geomoetry where its inverse map is defined.
assert hasattr(self.domain, "inverse_map")
xyz -= Larmor_r
gc_etas = self.domain.inverse_map(*xyz, bounded=False)
# gyro transfer
self.markers[outside_inds, 1] = (gc_etas[1] - (self.markers[outside_inds, 1] - gc_etas[1]) % 1) % 1
new_xyz = self.domain(self.markers[outside_inds, :])
# eval cartesian equilibrium magnetic field at the marker positions
b_cart = self.equil.b_cart(self.markers[outside_inds, :])[0]
# calculate magnetic field amplitude and normalized magnetic field
absB0 = xp.sqrt(b_cart[0] ** 2 + b_cart[1] ** 2 + b_cart[2] ** 2)
norm_b_cart = b_cart / absB0
Larmor_r = new_xyz - xyz
Larmor_r /= xp.sqrt(Larmor_r[0] ** 2 + Larmor_r[1] ** 2 + Larmor_r[2] ** 2)
new_v_perp = xp.cross(Larmor_r, norm_b_cart, axis=0) * v_perp_square
self.markers[outside_inds, 3:6] = (norm_b_cart * v_parallel).T + new_v_perp.T
return xp.logical_and(1.0 > gc_etas[0], gc_etas[0] > 0.0)
[docs]
class SortingBoxes:
"""Boxes used for the sorting of the particles.
Boxes are represented as a 2D array of integers, where
each line coresponds to one box, and all entries of line i that are not -1
correspond to a particles in the i-th box.
Parameters
----------
markers_shape : tuple
shape of 2D marker array.
is_sph : bool
True if particle type is "sph".
nx : int
number of boxes in the x direction.
ny : int
number of boxes in the y direction.
nz : int
number of boxes in the z direction.
bc_sph : list
Boundary condition for sph density evaluation.
Either 'periodic', 'mirror', 'fixed' or 'noslip' in each direction.
is_domain_boundary: dict
Has two booleans for each direction; True when the boundary of the MPI process is a domain boundary.
comm : Intracomm
MPI communicator or None.
box_index : int
Column index of the particles array to store the box number, counted from
the end (e.g. -2 for the second-to-last).
box_bufsize : float
additional buffer space in the size of the boxes"""
def __init__(
self,
markers_shape: tuple,
is_sph: bool,
*,
nx: int = 1,
ny: int = 1,
nz: int = 1,
bc_sph: list = None,
is_domain_boundary: dict = None,
comm: Intracomm = None,
box_index: "int" = -2,
box_bufsize: "float" = 2.0,
):
self._markers_shape = markers_shape
self._nx = nx
self._ny = ny
self._nz = nz
self._comm = comm
self._box_index = box_index
self._box_bufsize = box_bufsize
if bc_sph is None:
bc_sph = ["periodic"] * 3
self._bc_sph = bc_sph
if is_domain_boundary is None:
is_domain_boundary = {}
is_domain_boundary["x_m"] = True
is_domain_boundary["x_p"] = True
is_domain_boundary["y_m"] = True
is_domain_boundary["y_p"] = True
is_domain_boundary["z_m"] = True
is_domain_boundary["z_p"] = True
self._is_domain_boundary = is_domain_boundary
if comm is None:
self._rank = 0
else:
self._rank = comm.Get_rank()
self._set_boxes()
self._communicate = is_sph
if self.communicate:
self._set_boundary_boxes()
@property
def nx(self):
return self._nx
@property
def ny(self):
return self._ny
@property
def nz(self):
return self._nz
@property
def comm(self):
return self._comm
@property
def box_index(self):
return self._box_index
@property
def boxes(self):
if not hasattr(self, "_boxes"):
self._set_boxes()
return self._boxes
@property
def neighbours(self):
if not hasattr(self, "_neighbours"):
self._set_boxes()
return self._neighbours
@property
def communicate(self):
return self._communicate
@property
def is_domain_boundary(self):
"""Dict with two booleans for each direction (e.g. 'x_m' and 'x_p'); True when the boundary of the MPI process is a domain boundary (0.0 or 1.0)."""
return self._is_domain_boundary
@property
def bc_sph(self):
"""List of boundary conditions for sph evaluation in each direction."""
return self._bc_sph
@property
def bc_sph_index_shifts(self):
"""Dictionary holding the index shifts of box number for ghost particles in each direction."""
if not hasattr(self, "_bc_sph_index_shifts"):
self._compute_sph_index_shifts()
return self._bc_sph_index_shifts
def _compute_sph_index_shifts(self):
"""The index shifts are applied to ghost particles to indicate their new box after sending."""
self._bc_sph_index_shifts = {}
self._bc_sph_index_shifts["x_m"] = flatten_index(self.nx, 0, 0, self.nx, self.ny, self.nz)
self._bc_sph_index_shifts["x_p"] = flatten_index(self.nx, 0, 0, self.nx, self.ny, self.nz)
self._bc_sph_index_shifts["y_m"] = flatten_index(0, self.ny, 0, self.nx, self.ny, self.nz)
self._bc_sph_index_shifts["y_p"] = flatten_index(0, self.ny, 0, self.nx, self.ny, self.nz)
self._bc_sph_index_shifts["z_m"] = flatten_index(0, 0, self.nz, self.nx, self.ny, self.nz)
self._bc_sph_index_shifts["z_p"] = flatten_index(0, 0, self.nz, self.nx, self.ny, self.nz)
if self.bc_sph[0] in ("mirror", "fixed", "noslip"):
if self.is_domain_boundary["x_m"]:
self._bc_sph_index_shifts["x_m"] = flatten_index(-1, 0, 0, self.nx, self.ny, self.nz)
if self.is_domain_boundary["x_p"]:
self._bc_sph_index_shifts["x_p"] = flatten_index(-1, 0, 0, self.nx, self.ny, self.nz)
if self.bc_sph[1] in ("mirror", "fixed", "noslip"):
if self.is_domain_boundary["y_m"]:
self._bc_sph_index_shifts["y_m"] = flatten_index(0, -1, 0, self.nx, self.ny, self.nz)
if self.is_domain_boundary["y_p"]:
self._bc_sph_index_shifts["y_p"] = flatten_index(0, -1, 0, self.nx, self.ny, self.nz)
if self.bc_sph[2] in ("mirror", "fixed", "noslip"):
if self.is_domain_boundary["z_m"]:
self._bc_sph_index_shifts["z_m"] = flatten_index(0, 0, -1, self.nx, self.ny, self.nz)
if self.is_domain_boundary["z_p"]:
self._bc_sph_index_shifts["z_p"] = flatten_index(0, 0, -1, self.nx, self.ny, self.nz)
def _set_boxes(self):
""" "(Re)set the box structure."""
self._n_boxes = (self._nx + 2) * (self._ny + 2) * (self._nz + 2)
n_box_in = self._nx * self._ny * self._nz
n_particles = self._markers_shape[0]
n_mkr = int(n_particles / n_box_in) + 1
n_cols = round(
n_mkr * (1 + 1 / xp.sqrt(n_mkr) + self._box_bufsize),
)
# cartesian boxes
self._boxes = xp.zeros((self._n_boxes + 1, n_cols), dtype=int)
# TODO: there is still a bug here
# the row number in self._boxes should not be n_boxes + 1; this is just a temporary fix to avoid an error that I dont understand.
# Must be fixed soon!
self._next_index = xp.zeros((self._n_boxes + 1), dtype=int)
self._cumul_next_index = xp.zeros((self._n_boxes + 2), dtype=int)
self._neighbours = xp.zeros((self._n_boxes, 27), dtype=int)
# A particle on box i only sees particles in boxes that belong to neighbours[i]
initialize_neighbours(self._neighbours, self.nx, self.ny, self.nz)
# logger.info(f"{self._rank = }\n{self._neighbours = }")
self._swap_line_1 = xp.zeros(self._markers_shape[1])
self._swap_line_2 = xp.zeros(self._markers_shape[1])
def _set_boundary_boxes(self):
"""Gather all the boxes that are part of a boundary"""
gather_x_boxes = self.nx > 1
gather_y_boxes = self.ny > 1
gather_z_boxes = self.nz > 1
# x boundary
# negative direction
self._bnd_boxes_x_m = []
# positive direction
self._bnd_boxes_x_p = []
if gather_x_boxes:
for j in range(1, self.ny + 1):
for k in range(1, self.nz + 1):
self._bnd_boxes_x_m.append(flatten_index(1, j, k, self.nx, self.ny, self.nz))
self._bnd_boxes_x_p.append(flatten_index(self.nx, j, k, self.nx, self.ny, self.nz))
logger.debug(f"eta1 boundary on {self._rank =}:\n{self._bnd_boxes_x_m =}\n{self._bnd_boxes_x_p =}")
# y boundary
# negative direction
self._bnd_boxes_y_m = []
# positive direction
self._bnd_boxes_y_p = []
if gather_y_boxes:
for i in range(1, self.nx + 1):
for k in range(1, self.nz + 1):
self._bnd_boxes_y_m.append(flatten_index(i, 1, k, self.nx, self.ny, self.nz))
self._bnd_boxes_y_p.append(flatten_index(i, self.ny, k, self.nx, self.ny, self.nz))
logger.debug(f"eta2 boundary on {self._rank =}:\n{self._bnd_boxes_y_m =}\n{self._bnd_boxes_y_p =}")
# z boundary
# negative direction
self._bnd_boxes_z_m = []
# positive direction
self._bnd_boxes_z_p = []
if gather_z_boxes:
for i in range(1, self.nx + 1):
for j in range(1, self.ny + 1):
self._bnd_boxes_z_m.append(flatten_index(i, j, 1, self.nx, self.ny, self.nz))
self._bnd_boxes_z_p.append(flatten_index(i, j, self.nz, self.nx, self.ny, self.nz))
logger.debug(f"eta3 boundary on {self._rank =}:\n{self._bnd_boxes_z_m =}\n{self._bnd_boxes_z_p =}")
# x-y edges
self._bnd_boxes_x_m_y_m = []
self._bnd_boxes_x_m_y_p = []
self._bnd_boxes_x_p_y_m = []
self._bnd_boxes_x_p_y_p = []
if gather_x_boxes and gather_y_boxes:
for k in range(1, self.nz + 1):
self._bnd_boxes_x_m_y_m.append(flatten_index(1, 1, k, self.nx, self.ny, self.nz))
self._bnd_boxes_x_m_y_p.append(flatten_index(1, self.ny, k, self.nx, self.ny, self.nz))
self._bnd_boxes_x_p_y_m.append(flatten_index(self.nx, 1, k, self.nx, self.ny, self.nz))
self._bnd_boxes_x_p_y_p.append(flatten_index(self.nx, self.ny, k, self.nx, self.ny, self.nz))
logger.debug(
(
f"eta1-eta2 edge on {self._rank =}:\n{self._bnd_boxes_x_m_y_m =}"
f"\n{self._bnd_boxes_x_m_y_p =}"
f"\n{self._bnd_boxes_x_p_y_m =}"
f"\n{self._bnd_boxes_x_p_y_p =}"
),
)
# x-z edges
self._bnd_boxes_x_m_z_m = []
self._bnd_boxes_x_m_z_p = []
self._bnd_boxes_x_p_z_m = []
self._bnd_boxes_x_p_z_p = []
if gather_x_boxes and gather_z_boxes:
for j in range(1, self.ny + 1):
self._bnd_boxes_x_m_z_m.append(flatten_index(1, j, 1, self.nx, self.ny, self.nz))
self._bnd_boxes_x_m_z_p.append(flatten_index(1, j, self.nz, self.nx, self.ny, self.nz))
self._bnd_boxes_x_p_z_m.append(flatten_index(self.nx, j, 1, self.nx, self.ny, self.nz))
self._bnd_boxes_x_p_z_p.append(flatten_index(self.nx, j, self.nz, self.nx, self.ny, self.nz))
logger.debug(
(
f"eta1-eta3 edge on {self._rank =}:\n{self._bnd_boxes_x_m_z_m =}"
f"\n{self._bnd_boxes_x_m_z_p =}"
f"\n{self._bnd_boxes_x_p_z_m =}"
f"\n{self._bnd_boxes_x_p_z_p =}"
),
)
# y-z edges
self._bnd_boxes_y_m_z_m = []
self._bnd_boxes_y_m_z_p = []
self._bnd_boxes_y_p_z_m = []
self._bnd_boxes_y_p_z_p = []
if gather_y_boxes and gather_z_boxes:
for i in range(1, self.nx + 1):
self._bnd_boxes_y_m_z_m.append(flatten_index(i, 1, 1, self.nx, self.ny, self.nz))
self._bnd_boxes_y_m_z_p.append(flatten_index(i, 1, self.nz, self.nx, self.ny, self.nz))
self._bnd_boxes_y_p_z_m.append(flatten_index(i, self.ny, 1, self.nx, self.ny, self.nz))
self._bnd_boxes_y_p_z_p.append(flatten_index(i, self.ny, self.nz, self.nx, self.ny, self.nz))
logger.debug(
(
f"eta2-eta3 edge on {self._rank =}:\n{self._bnd_boxes_y_m_z_m =}"
f"\n{self._bnd_boxes_y_m_z_p =}"
f"\n{self._bnd_boxes_y_p_z_m =}"
f"\n{self._bnd_boxes_y_p_z_p =}"
),
)
# corners
self._bnd_boxes_x_m_y_m_z_m = []
self._bnd_boxes_x_m_y_m_z_p = []
self._bnd_boxes_x_m_y_p_z_m = []
self._bnd_boxes_x_p_y_m_z_m = []
self._bnd_boxes_x_m_y_p_z_p = []
self._bnd_boxes_x_p_y_m_z_p = []
self._bnd_boxes_x_p_y_p_z_m = []
self._bnd_boxes_x_p_y_p_z_p = []
if gather_x_boxes and gather_y_boxes and gather_z_boxes:
self._bnd_boxes_x_m_y_m_z_m = [flatten_index(1, 1, 1, self.nx, self.ny, self.nz)]
self._bnd_boxes_x_m_y_m_z_p = [flatten_index(1, 1, self.nz, self.nx, self.ny, self.nz)]
self._bnd_boxes_x_m_y_p_z_m = [flatten_index(1, self.ny, 1, self.nx, self.ny, self.nz)]
self._bnd_boxes_x_p_y_m_z_m = [flatten_index(self.nx, 1, 1, self.nx, self.ny, self.nz)]
self._bnd_boxes_x_m_y_p_z_p = [flatten_index(1, self.ny, self.nz, self.nx, self.ny, self.nz)]
self._bnd_boxes_x_p_y_m_z_p = [flatten_index(self.nx, 1, self.nz, self.nx, self.ny, self.nz)]
self._bnd_boxes_x_p_y_p_z_m = [flatten_index(self.nx, self.ny, 1, self.nx, self.ny, self.nz)]
self._bnd_boxes_x_p_y_p_z_p = [flatten_index(self.nx, self.ny, self.nz, self.nx, self.ny, self.nz)]
logger.debug(
(
f"corners on {self._rank =}:\n{self._bnd_boxes_x_m_y_m_z_m =}"
f"\n{self._bnd_boxes_x_m_y_m_z_p =}"
f"\n{self._bnd_boxes_x_m_y_p_z_m =}"
f"\n{self._bnd_boxes_x_p_y_m_z_m =}"
f"\n{self._bnd_boxes_x_m_y_p_z_p =}"
f"\n{self._bnd_boxes_x_p_y_m_z_p =}"
f"\n{self._bnd_boxes_x_p_y_p_z_m =}"
f"\n{self._bnd_boxes_x_p_y_p_z_p =}"
),
)
def _sort_boxed_particles_numpy(self):
"""Sort the particles by box using numpy.argsort."""
sorting_axis = self._sorting_boxes.box_index
if not hasattr(self, "_argsort_array"):
self._argsort_array = xp.zeros(self.markers.shape[0], dtype=int)
self._argsort_array[:] = self._markers[:, sorting_axis].argsort()
self._markers[:, :] = self._markers[self._argsort_array]
[docs]
@profile
def put_particles_in_boxes(self):
"""Assign the right box to the particles and the list of the particles to each box.
If sorting_boxes was instantiated with an MPI comm, then the particles in the
neighbouring boxes of neighbours processors or also communicated"""
self.remove_ghost_particles()
assign_box_to_each_particle(
self.markers,
self.holes,
self._sorting_boxes.nx,
self._sorting_boxes.ny,
self._sorting_boxes.nz,
self.domain_array[self.mpi_rank],
)
self.check_and_assign_particles_to_boxes()
if self.sorting_boxes.communicate:
self.communicate_boxes()
self.check_and_assign_particles_to_boxes()
self.update_ghost_particles()
# if self.verbose:
# valid_box_ids = xp.nonzero(self._sorting_boxes._boxes[:, 0] != -1)[0]
# logger.info(f"Boxes holding at least one particle: {valid_box_ids}")
# for i in valid_box_ids:
# n_mks_box = xp.count_nonzero(self._sorting_boxes._boxes[i] != -1)
# logger.info(f"Number of markers in box {i} is {n_mks_box}")
[docs]
def check_and_assign_particles_to_boxes(self):
"""Check whether the box array has enough columns (detect load imbalance wrt to sorting boxes),
and then assigne the particles to boxes."""
bcount = xp.bincount(xp.int64(self.markers_wo_holes[:, -2]))
max_in_box = xp.max(bcount)
if max_in_box > self._sorting_boxes.boxes.shape[1]:
warnings.warn(
f'Strong load imbalance detected in sorting boxes: \
max number of markers in a box ({max_in_box}) on rank {self.mpi_rank} \
exceeds the column-size of the box array ({self._sorting_boxes.boxes.shape[1]}). \
Increasing the value of "box_bufsize" in the markers parameters for the next run.',
)
self.mpi_comm.Abort()
assign_particles_to_boxes(
self.markers,
self.holes,
self._sorting_boxes._boxes,
self._sorting_boxes._next_index,
)
[docs]
@profile
def do_sort(self, use_numpy_argsort=False):
"""Assign the particles to boxes and then sort them."""
nx = self._sorting_boxes.nx
ny = self._sorting_boxes.ny
nz = self._sorting_boxes.nz
nboxes = (nx + 2) * (ny + 2) * (nz + 2)
self.put_particles_in_boxes()
if use_numpy_argsort:
self._sort_boxed_particles_numpy()
else:
sort_boxed_particles(
self._markers,
self._sorting_boxes._swap_line_1,
self._sorting_boxes._swap_line_2,
nboxes + 1,
self._sorting_boxes._next_index,
self._sorting_boxes._cumul_next_index,
)
# The marker rows have just been reordered. The masks are row-based,
# so they must be rebuilt before any later use of valid_mks/f_coords.
self.update_holes()
self.update_ghost_particles()
self.update_valid_mks()
def remove_ghost_particles(self):
self.update_ghost_particles()
new_holes = xp.nonzero(self.ghost_particles)
self._markers[new_holes] = -1.0
self.update_holes()
[docs]
def prepare_ghost_particles(self):
"""Markers for boundary conditions and MPI communication.
Does the following:
1. determine which markers belong to boxes that are at the boundary and put these markers in a new array (e.g. markers_x_m)
2. set their last index to -2 to indicate that they will be "ghost particles" after sending
3. set their new box number (boundary conditions enter here)
4. optional: mirror position for boundary conditions
"""
shifts = self.sorting_boxes.bc_sph_index_shifts
## Faces
# ghost marker arrays
self._markers_x_m = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_x_m)
self._markers_x_p = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_x_p)
self._markers_y_m = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_y_m)
self._markers_y_p = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_y_p)
self._markers_z_m = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_z_m)
self._markers_z_p = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_z_p)
# Put last index to -2 to indicate that they are ghosts on the new process
self._markers_x_m[:, -1] = -2.0
self._markers_x_p[:, -1] = -2.0
self._markers_y_m[:, -1] = -2.0
self._markers_y_p[:, -1] = -2.0
self._markers_z_m[:, -1] = -2.0
self._markers_z_p[:, -1] = -2.0
# Adjust box number
self._markers_x_m[:, self._sorting_boxes.box_index] += shifts["x_m"]
self._markers_x_p[:, self._sorting_boxes.box_index] -= shifts["x_p"]
self._markers_y_m[:, self._sorting_boxes.box_index] += shifts["y_m"]
self._markers_y_p[:, self._sorting_boxes.box_index] -= shifts["y_p"]
self._markers_z_m[:, self._sorting_boxes.box_index] += shifts["z_m"]
self._markers_z_p[:, self._sorting_boxes.box_index] -= shifts["z_p"]
# Mirror position for boundary condition
if self.bc_sph[0] in ("mirror", "fixed", "noslip"):
self._mirror_particles(
"_markers_x_m",
"_markers_x_p",
is_domain_boundary=self.sorting_boxes.is_domain_boundary,
mean_velocity_index=self.mean_velocity_index,
)
if self.bc_sph[1] in ("mirror", "fixed", "noslip"):
self._mirror_particles(
"_markers_y_m",
"_markers_y_p",
is_domain_boundary=self.sorting_boxes.is_domain_boundary,
mean_velocity_index=self.mean_velocity_index,
)
if self.bc_sph[2] in ("mirror", "fixed", "noslip"):
self._mirror_particles(
"_markers_z_m",
"_markers_z_p",
is_domain_boundary=self.sorting_boxes.is_domain_boundary,
mean_velocity_index=self.mean_velocity_index,
)
## Edges x-y
# ghost marker arrays
self._markers_x_m_y_m = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_x_m_y_m)
self._markers_x_m_y_p = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_x_m_y_p)
self._markers_x_p_y_m = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_x_p_y_m)
self._markers_x_p_y_p = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_x_p_y_p)
# Put last index to -2 to indicate that they are ghosts on the new process
self._markers_x_m_y_m[:, -1] = -2.0
self._markers_x_m_y_p[:, -1] = -2.0
self._markers_x_p_y_m[:, -1] = -2.0
self._markers_x_p_y_p[:, -1] = -2.0
# Adjust box number
self._markers_x_m_y_m[:, self._sorting_boxes.box_index] += shifts["x_m"] + shifts["y_m"]
self._markers_x_m_y_p[:, self._sorting_boxes.box_index] += shifts["x_m"] - shifts["y_p"]
self._markers_x_p_y_m[:, self._sorting_boxes.box_index] += -shifts["x_p"] + shifts["y_m"]
self._markers_x_p_y_p[:, self._sorting_boxes.box_index] += -shifts["x_p"] - shifts["y_p"]
# Mirror position for boundary condition
if self.bc_sph[0] in ("mirror", "fixed", "noslip") or self.bc_sph[1] in ("mirror", "fixed", "noslip"):
self._mirror_particles(
"_markers_x_m_y_m",
"_markers_x_m_y_p",
"_markers_x_p_y_m",
"_markers_x_p_y_p",
is_domain_boundary=self.sorting_boxes.is_domain_boundary,
mean_velocity_index=self.mean_velocity_index,
)
## Edges x-z
# ghost marker arrays
self._markers_x_m_z_m = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_x_m_z_m)
self._markers_x_m_z_p = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_x_m_z_p)
self._markers_x_p_z_m = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_x_p_z_m)
self._markers_x_p_z_p = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_x_p_z_p)
# Put last index to -2 to indicate that they are ghosts on the new process
self._markers_x_m_z_m[:, -1] = -2.0
self._markers_x_m_z_p[:, -1] = -2.0
self._markers_x_p_z_m[:, -1] = -2.0
self._markers_x_p_z_p[:, -1] = -2.0
# Adjust box number
self._markers_x_m_z_m[:, self._sorting_boxes.box_index] += shifts["x_m"] + shifts["z_m"]
self._markers_x_m_z_p[:, self._sorting_boxes.box_index] += shifts["x_m"] - shifts["z_p"]
self._markers_x_p_z_m[:, self._sorting_boxes.box_index] += -shifts["x_p"] + shifts["z_m"]
self._markers_x_p_z_p[:, self._sorting_boxes.box_index] += -shifts["x_p"] - shifts["z_p"]
# Mirror position for boundary condition
if self.bc_sph[0] in ("mirror", "fixed", "noslip") or self.bc_sph[2] in ("mirror", "fixed", "noslip"):
self._mirror_particles(
"_markers_x_m_z_m",
"_markers_x_m_z_p",
"_markers_x_p_z_m",
"_markers_x_p_z_p",
is_domain_boundary=self.sorting_boxes.is_domain_boundary,
mean_velocity_index=self.mean_velocity_index,
)
## Edges y-z
# ghost marker arrays
self._markers_y_m_z_m = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_y_m_z_m)
self._markers_y_m_z_p = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_y_m_z_p)
self._markers_y_p_z_m = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_y_p_z_m)
self._markers_y_p_z_p = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_y_p_z_p)
# Put last index to -2 to indicate that they are ghosts on the new process
self._markers_y_m_z_m[:, -1] = -2.0
self._markers_y_m_z_p[:, -1] = -2.0
self._markers_y_p_z_m[:, -1] = -2.0
self._markers_y_p_z_p[:, -1] = -2.0
# Adjust box number
self._markers_y_m_z_m[:, self._sorting_boxes.box_index] += shifts["y_m"] + shifts["z_m"]
self._markers_y_m_z_p[:, self._sorting_boxes.box_index] += shifts["y_m"] - shifts["z_p"]
self._markers_y_p_z_m[:, self._sorting_boxes.box_index] += -shifts["y_p"] + shifts["z_m"]
self._markers_y_p_z_p[:, self._sorting_boxes.box_index] += -shifts["y_p"] - shifts["z_p"]
# Mirror position for boundary condition
if self.bc_sph[1] in ("mirror", "fixed", "noslip") or self.bc_sph[2] in ("mirror", "fixed", "noslip"):
self._mirror_particles(
"_markers_y_m_z_m",
"_markers_y_m_z_p",
"_markers_y_p_z_m",
"_markers_y_p_z_p",
is_domain_boundary=self.sorting_boxes.is_domain_boundary,
mean_velocity_index=self.mean_velocity_index,
)
## Corners
# ghost marker arrays
self._markers_x_m_y_m_z_m = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_x_m_y_m_z_m)
self._markers_x_m_y_m_z_p = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_x_m_y_m_z_p)
self._markers_x_m_y_p_z_m = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_x_m_y_p_z_m)
self._markers_x_m_y_p_z_p = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_x_m_y_p_z_p)
self._markers_x_p_y_m_z_m = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_x_p_y_m_z_m)
self._markers_x_p_y_m_z_p = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_x_p_y_m_z_p)
self._markers_x_p_y_p_z_m = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_x_p_y_p_z_m)
self._markers_x_p_y_p_z_p = self.determine_markers_in_box(self._sorting_boxes._bnd_boxes_x_p_y_p_z_p)
# Put last index to -2 to indicate that they are ghosts on the new process
self._markers_x_m_y_m_z_m[:, -1] = -2.0
self._markers_x_m_y_m_z_p[:, -1] = -2.0
self._markers_x_m_y_p_z_m[:, -1] = -2.0
self._markers_x_m_y_p_z_p[:, -1] = -2.0
self._markers_x_p_y_m_z_m[:, -1] = -2.0
self._markers_x_p_y_m_z_p[:, -1] = -2.0
self._markers_x_p_y_p_z_m[:, -1] = -2.0
self._markers_x_p_y_p_z_p[:, -1] = -2.0
# Adjust box number
self._markers_x_m_y_m_z_m[:, self._sorting_boxes.box_index] += shifts["x_m"] + shifts["y_m"] + shifts["z_m"]
self._markers_x_m_y_m_z_p[:, self._sorting_boxes.box_index] += shifts["x_m"] + shifts["y_m"] - shifts["z_p"]
self._markers_x_m_y_p_z_m[:, self._sorting_boxes.box_index] += shifts["x_m"] - shifts["y_p"] + shifts["z_m"]
self._markers_x_m_y_p_z_p[:, self._sorting_boxes.box_index] += shifts["x_m"] - shifts["y_p"] - shifts["z_p"]
self._markers_x_p_y_m_z_m[:, self._sorting_boxes.box_index] += -shifts["x_p"] + shifts["y_m"] + shifts["z_m"]
self._markers_x_p_y_m_z_p[:, self._sorting_boxes.box_index] += -shifts["x_p"] + shifts["y_m"] - shifts["z_p"]
self._markers_x_p_y_p_z_m[:, self._sorting_boxes.box_index] += -shifts["x_p"] - shifts["y_p"] + shifts["z_m"]
self._markers_x_p_y_p_z_p[:, self._sorting_boxes.box_index] += -shifts["x_p"] - shifts["y_p"] - shifts["z_p"]
# Mirror position for boundary condition
if any([bci in ("mirror", "fixed", "noslip") for bci in self.bc_sph]):
self._mirror_particles(
"_markers_x_m_y_m_z_m",
"_markers_x_m_y_m_z_p",
"_markers_x_m_y_p_z_m",
"_markers_x_m_y_p_z_p",
"_markers_x_p_y_m_z_m",
"_markers_x_p_y_m_z_p",
"_markers_x_p_y_p_z_m",
"_markers_x_p_y_p_z_p",
is_domain_boundary=self.sorting_boxes.is_domain_boundary,
mean_velocity_index=self.mean_velocity_index,
)
def _mirror_particles(
self, *marker_array_names, is_domain_boundary: dict | None = None, mean_velocity_index: int | None = None
):
"""
Mirror the positions and velocities of the particles in the ghost marker arrays for the boundary conditions.
For "mirror" boundary condition, the positions are mirrored and the velocities are unchanged.
For "fixed" boundary condition, the positions are mirrored and the velocities are set to zero (or to the value of f_init if provided).
For "noslip" boundary condition, the positions are mirrored and the velocities are inverted to have zero velocity at the boundary.
Parameters
----------
marker_array_names : str
The names of the marker arrays to be mirrored (e.g. "_markers_x_m", "_markers_x_p", etc.).
is_domain_boundary : dict
A dictionary indicating whether the boundary condition is applied at the domain boundary (e.g. {"x_m": True, "x_p": True, "y_m": True, "y_p": True, "z_m": True, "z_p": True}).
mean_velocity_index : int, optional
The index of the mean velocity in the marker array (if applicable), by default None.
"""
self._fixed_markers_set = {}
for arr_name in marker_array_names:
assert isinstance(arr_name, str)
arr = getattr(self, arr_name)
if arr.size == 0:
continue
# x-direction
if self.bc_sph[0] in ("mirror", "fixed", "noslip"):
if "x_m" in arr_name and is_domain_boundary["x_m"]:
arr[:, 0] *= -1.0
if self.bc_sph[0] == "fixed" and arr_name not in self._fixed_markers_set:
boundary_values = self.f_init(
*arr[:, :3].T,
flat_eval=True,
) # evaluation outside of the unit cube - maybe not working for all f_init!
arr[:, self.index["weights"]] = (
-boundary_values
/ self.s0(
*arr[:, :3].T,
flat_eval=True,
remove_holes=False,
)
/ self.Np
) # clarify in case of tesselation: multiple by tile volume (=1/Np) to get the integral value right
self._fixed_markers_set[arr_name] = True
elif self.bc_sph[0] == "noslip":
# invert the velocities to have zero velocity at the boundary
arr[:, 3] *= -1.0
arr[:, 4] *= -1.0
arr[:, 5] *= -1.0
if mean_velocity_index is not None:
arr[:, mean_velocity_index] *= -1.0
arr[:, mean_velocity_index + 1] *= -1.0
arr[:, mean_velocity_index + 2] *= -1.0
elif "x_p" in arr_name and is_domain_boundary["x_p"]:
arr[:, 0] = 2.0 - arr[:, 0]
if self.bc_sph[0] == "fixed" and arr_name not in self._fixed_markers_set:
boundary_values = self.f_init(
*arr[:, :3].T,
flat_eval=True,
) # evaluation outside of the unit cube - maybe not working for all f_init!
arr[:, self.index["weights"]] = (
-boundary_values
/ self.s0(
*arr[:, :3].T,
flat_eval=True,
remove_holes=False,
)
/ self.Np
) # clarify in case of tesselation: multiple by tile volume (=1/Np) to get the integral value right
self._fixed_markers_set[arr_name] = True
elif self.bc_sph[0] == "noslip":
# invert the velocities to have zero velocity at the boundary
arr[:, 3] *= -1.0
arr[:, 4] *= -1.0
arr[:, 5] *= -1.0
if mean_velocity_index is not None:
arr[:, mean_velocity_index] *= -1.0
arr[:, mean_velocity_index + 1] *= -1.0
arr[:, mean_velocity_index + 2] *= -1.0
# y-direction
if self.bc_sph[1] in ("mirror", "fixed", "noslip"):
if "y_m" in arr_name and is_domain_boundary["y_m"]:
arr[:, 1] *= -1.0
if self.bc_sph[1] == "fixed" and arr_name not in self._fixed_markers_set:
boundary_values = self.f_init(
*arr[:, :3].T,
flat_eval=True,
) # evaluation outside of the unit cube - maybe not working for all f_init!
arr[:, self.index["weights"]] = (
-boundary_values
/ self.s0(
*arr[:, :3].T,
flat_eval=True,
remove_holes=False,
)
/ self.Np
) # clarify in case of tesselation: multiple by tile volume (=1/Np) to get the integral value right
self._fixed_markers_set[arr_name] = True
elif self.bc_sph[1] == "noslip":
# invert the velocities to have zero velocity at the boundary
arr[:, 3] *= -1.0
arr[:, 4] *= -1.0
arr[:, 5] *= -1.0
if mean_velocity_index is not None:
arr[:, mean_velocity_index] *= -1.0
arr[:, mean_velocity_index + 1] *= -1.0
arr[:, mean_velocity_index + 2] *= -1.0
elif "y_p" in arr_name and is_domain_boundary["y_p"]:
arr[:, 1] = 2.0 - arr[:, 1]
if self.bc_sph[1] == "fixed" and arr_name not in self._fixed_markers_set:
boundary_values = self.f_init(
*arr[:, :3].T,
flat_eval=True,
) # evaluation outside of the unit cube - maybe not working for all f_init!
arr[:, self.index["weights"]] = (
-boundary_values
/ self.s0(
*arr[:, :3].T,
flat_eval=True,
remove_holes=False,
)
/ self.Np
) # clarify in case of tesselation: multiple by tile volume (=1/Np) to get the integral value right
self._fixed_markers_set[arr_name] = True
elif self.bc_sph[1] == "noslip":
# invert the velocities to have zero velocity at the boundary
arr[:, 3] *= -1.0
arr[:, 4] *= -1.0
arr[:, 5] *= -1.0
if mean_velocity_index is not None:
arr[:, mean_velocity_index] *= -1.0
arr[:, mean_velocity_index + 1] *= -1.0
arr[:, mean_velocity_index + 2] *= -1.0
# z-direction
if self.bc_sph[2] in ("mirror", "fixed", "noslip"):
if "z_m" in arr_name and is_domain_boundary["z_m"]:
arr[:, 2] *= -1.0
if self.bc_sph[2] == "fixed" and arr_name not in self._fixed_markers_set:
boundary_values = self.f_init(
*arr[:, :3].T,
flat_eval=True,
) # evaluation outside of the unit cube - maybe not working for all f_init!
arr[:, self.index["weights"]] = (
-boundary_values
/ self.s0(
*arr[:, :3].T,
flat_eval=True,
remove_holes=False,
)
/ self.Np
) # clarify in case of tesselation: multiple by tile volume (=1/Np) to get the integral value right
self._fixed_markers_set[arr_name] = True
elif self.bc_sph[2] == "noslip":
# invert the velocities to have zero velocity at the boundary
arr[:, 3] *= -1.0
arr[:, 4] *= -1.0
arr[:, 5] *= -1.0
if mean_velocity_index is not None:
arr[:, mean_velocity_index] *= -1.0
arr[:, mean_velocity_index + 1] *= -1.0
arr[:, mean_velocity_index + 2] *= -1.0
elif "z_p" in arr_name and is_domain_boundary["z_p"]:
arr[:, 2] = 2.0 - arr[:, 2]
if self.bc_sph[2] == "fixed" and arr_name not in self._fixed_markers_set:
boundary_values = self.f_init(
*arr[:, :3].T,
flat_eval=True,
) # evaluation outside of the unit cube - maybe not working for all f_init!
arr[:, self.index["weights"]] = (
-boundary_values
/ self.s0(
*arr[:, :3].T,
flat_eval=True,
remove_holes=False,
)
/ self.Np
) # clarify in case of tesselation: multiple by tile volume (=1/Np) to get the integral value right
self._fixed_markers_set[arr_name] = True
elif self.bc_sph[2] == "noslip":
# invert the velocities to have zero velocity at the boundary
arr[:, 3] *= -1.0
arr[:, 4] *= -1.0
arr[:, 5] *= -1.0
if mean_velocity_index is not None:
arr[:, mean_velocity_index] *= -1.0
arr[:, mean_velocity_index + 1] *= -1.0
arr[:, mean_velocity_index + 2] *= -1.0
[docs]
def determine_markers_in_box(self, list_boxes):
"""Determine the markers that belong to a certain box (list of boxes) and put them in an array"""
indices = []
for i in list_boxes:
indices += list(self._sorting_boxes._boxes[i][self._sorting_boxes._boxes[i] != -1])
indices = xp.array(indices, dtype=int)
markers_in_box = self.markers[indices]
return markers_in_box
[docs]
def get_destinations_box(self):
"""Find the destination proc for the particles to communicate for the box structure."""
self._send_info_box = xp.zeros(self.mpi_size, dtype=int)
self._send_list_box = [xp.zeros((0, self.n_cols))] * self.mpi_size
# Faces
# if self._x_m_proc is not None:
self._send_info_box[self._x_m_proc] += len(self._markers_x_m)
self._send_list_box[self._x_m_proc] = xp.concatenate((self._send_list_box[self._x_m_proc], self._markers_x_m))
# if self._x_p_proc is not None:
self._send_info_box[self._x_p_proc] += len(self._markers_x_p)
self._send_list_box[self._x_p_proc] = xp.concatenate((self._send_list_box[self._x_p_proc], self._markers_x_p))
# if self._y_m_proc is not None:
self._send_info_box[self._y_m_proc] += len(self._markers_y_m)
self._send_list_box[self._y_m_proc] = xp.concatenate((self._send_list_box[self._y_m_proc], self._markers_y_m))
# if self._y_p_proc is not None:
self._send_info_box[self._y_p_proc] += len(self._markers_y_p)
self._send_list_box[self._y_p_proc] = xp.concatenate((self._send_list_box[self._y_p_proc], self._markers_y_p))
# if self._z_m_proc is not None:
self._send_info_box[self._z_m_proc] += len(self._markers_z_m)
self._send_list_box[self._z_m_proc] = xp.concatenate((self._send_list_box[self._z_m_proc], self._markers_z_m))
# if self._z_p_proc is not None:
self._send_info_box[self._z_p_proc] += len(self._markers_z_p)
self._send_list_box[self._z_p_proc] = xp.concatenate((self._send_list_box[self._z_p_proc], self._markers_z_p))
# x-y edges
# if self._x_m_y_m_proc is not None:
self._send_info_box[self._x_m_y_m_proc] += len(self._markers_x_m_y_m)
self._send_list_box[self._x_m_y_m_proc] = xp.concatenate(
(self._send_list_box[self._x_m_y_m_proc], self._markers_x_m_y_m),
)
# if self._x_m_y_p_proc is not None:
self._send_info_box[self._x_m_y_p_proc] += len(self._markers_x_m_y_p)
self._send_list_box[self._x_m_y_p_proc] = xp.concatenate(
(self._send_list_box[self._x_m_y_p_proc], self._markers_x_m_y_p),
)
# if self._x_p_y_m_proc is not None:
self._send_info_box[self._x_p_y_m_proc] += len(self._markers_x_p_y_m)
self._send_list_box[self._x_p_y_m_proc] = xp.concatenate(
(self._send_list_box[self._x_p_y_m_proc], self._markers_x_p_y_m),
)
# if self._x_p_y_p_proc is not None:
self._send_info_box[self._x_p_y_p_proc] += len(self._markers_x_p_y_p)
self._send_list_box[self._x_p_y_p_proc] = xp.concatenate(
(self._send_list_box[self._x_p_y_p_proc], self._markers_x_p_y_p),
)
# x-z edges
# if self._x_m_z_m_proc is not None:
self._send_info_box[self._x_m_z_m_proc] += len(self._markers_x_m_z_m)
self._send_list_box[self._x_m_z_m_proc] = xp.concatenate(
(self._send_list_box[self._x_m_z_m_proc], self._markers_x_m_z_m),
)
# if self._x_m_z_p_proc is not None:
self._send_info_box[self._x_m_z_p_proc] += len(self._markers_x_m_z_p)
self._send_list_box[self._x_m_z_p_proc] = xp.concatenate(
(self._send_list_box[self._x_m_z_p_proc], self._markers_x_m_z_p),
)
# if self._x_p_z_m_proc is not None:
self._send_info_box[self._x_p_z_m_proc] += len(self._markers_x_p_z_m)
self._send_list_box[self._x_p_z_m_proc] = xp.concatenate(
(self._send_list_box[self._x_p_z_m_proc], self._markers_x_p_z_m),
)
# if self._x_p_z_p_proc is not None:
self._send_info_box[self._x_p_z_p_proc] += len(self._markers_x_p_z_p)
self._send_list_box[self._x_p_z_p_proc] = xp.concatenate(
(self._send_list_box[self._x_p_z_p_proc], self._markers_x_p_z_p),
)
# y-z edges
# if self._y_m_z_m_proc is not None:
self._send_info_box[self._y_m_z_m_proc] += len(self._markers_y_m_z_m)
self._send_list_box[self._y_m_z_m_proc] = xp.concatenate(
(self._send_list_box[self._y_m_z_m_proc], self._markers_y_m_z_m),
)
# if self._y_m_z_p_proc is not None:
self._send_info_box[self._y_m_z_p_proc] += len(self._markers_y_m_z_p)
self._send_list_box[self._y_m_z_p_proc] = xp.concatenate(
(self._send_list_box[self._y_m_z_p_proc], self._markers_y_m_z_p),
)
# if self._y_p_z_m_proc is not None:
self._send_info_box[self._y_p_z_m_proc] += len(self._markers_y_p_z_m)
self._send_list_box[self._y_p_z_m_proc] = xp.concatenate(
(self._send_list_box[self._y_p_z_m_proc], self._markers_y_p_z_m),
)
# if self._y_p_z_p_proc is not None:
self._send_info_box[self._y_p_z_p_proc] += len(self._markers_y_p_z_p)
self._send_list_box[self._y_p_z_p_proc] = xp.concatenate(
(self._send_list_box[self._y_p_z_p_proc], self._markers_y_p_z_p),
)
# corners
# if self._x_m_y_m_z_m_proc is not None:
self._send_info_box[self._x_m_y_m_z_m_proc] += len(self._markers_x_m_y_m_z_m)
self._send_list_box[self._x_m_y_m_z_m_proc] = xp.concatenate(
(self._send_list_box[self._x_m_y_m_z_m_proc], self._markers_x_m_y_m_z_m),
)
# if self._x_m_y_m_z_p_proc is not None:
self._send_info_box[self._x_m_y_m_z_p_proc] += len(self._markers_x_m_y_m_z_p)
self._send_list_box[self._x_m_y_m_z_p_proc] = xp.concatenate(
(self._send_list_box[self._x_m_y_m_z_p_proc], self._markers_x_m_y_m_z_p),
)
# if self._x_m_y_p_z_m_proc is not None:
self._send_info_box[self._x_m_y_p_z_m_proc] += len(self._markers_x_m_y_p_z_m)
self._send_list_box[self._x_m_y_p_z_m_proc] = xp.concatenate(
(self._send_list_box[self._x_m_y_p_z_m_proc], self._markers_x_m_y_p_z_m),
)
# if self._x_m_y_p_z_p_proc is not None:
self._send_info_box[self._x_m_y_p_z_p_proc] += len(self._markers_x_m_y_p_z_p)
self._send_list_box[self._x_m_y_p_z_p_proc] = xp.concatenate(
(self._send_list_box[self._x_m_y_p_z_p_proc], self._markers_x_m_y_p_z_p),
)
# if self._x_p_y_m_z_m_proc is not None:
self._send_info_box[self._x_p_y_m_z_m_proc] += len(self._markers_x_p_y_m_z_m)
self._send_list_box[self._x_p_y_m_z_m_proc] = xp.concatenate(
(self._send_list_box[self._x_p_y_m_z_m_proc], self._markers_x_p_y_m_z_m),
)
# if self._x_p_y_m_z_p_proc is not None:
self._send_info_box[self._x_p_y_m_z_p_proc] += len(self._markers_x_p_y_m_z_p)
self._send_list_box[self._x_p_y_m_z_p_proc] = xp.concatenate(
(self._send_list_box[self._x_p_y_m_z_p_proc], self._markers_x_p_y_m_z_p),
)
# if self._x_p_y_p_z_m_proc is not None:
self._send_info_box[self._x_p_y_p_z_m_proc] += len(self._markers_x_p_y_p_z_m)
self._send_list_box[self._x_p_y_p_z_m_proc] = xp.concatenate(
(self._send_list_box[self._x_p_y_p_z_m_proc], self._markers_x_p_y_p_z_m),
)
# if self._x_p_y_p_z_p_proc is not None:
self._send_info_box[self._x_p_y_p_z_p_proc] += len(self._markers_x_p_y_p_z_p)
self._send_list_box[self._x_p_y_p_z_p_proc] = xp.concatenate(
(self._send_list_box[self._x_p_y_p_z_p_proc], self._markers_x_p_y_p_z_p),
)
[docs]
def self_communication_boxes(self):
"""Communicate the particles in case a process is it's own neighbour
(in case of periodicity with low number of procs/boxes)"""
if self._send_info_box[self.mpi_rank] > 0:
self.update_holes()
holes_inds = xp.nonzero(self.holes)[0]
if holes_inds.size < self._send_info_box[self.mpi_rank]:
warnings.warn(
f'Strong load imbalance detected: \
number of holes ({holes_inds.size}) on rank {self.mpi_rank} \
is smaller than number of incoming particles ({self._send_info_box[self.mpi_rank]}). \
Increasing the value of "bufsize" in the markers parameters for the next run.',
)
self.mpi_comm.Abort()
# _tmp = self.markers.copy()
# _n_rows_old = _tmp.shape[0]
# logger.info(f"old: {self.markers.shape = }")
# self._bufsize *= 2.0
# self._allocate_marker_array()
# logger.info(f"new: {self.markers.shape = }\n")
# self.markers[:] = -1.0
# self.markers[:_n_rows_old] = _tmp
# self.update_holes()
# self.update_ghost_particles()
# self.update_valid_mks()
# holes_inds = xp.nonzero(self.holes)[0]
self.markers[holes_inds[xp.arange(self._send_info_box[self.mpi_rank])]] = self._send_list_box[self.mpi_rank]
@profile
def communicate_boxes(self):
# if verbose:
# n_valid = xp.count_nonzero(self.valid_mks)
# n_holes = xp.count_nonzero(self.holes)
# n_ghosts = xp.count_nonzero(self.ghost_particles)
# logger.info(f"before communicate_boxes: {self.mpi_rank = }, {n_valid = } {n_holes = }, {n_ghosts = }")
self.prepare_ghost_particles()
self.get_destinations_box()
self.self_communication_boxes()
self.update_holes()
if self.mpi_comm is not None:
self._Barrier()
self.sendrecv_all_to_all_boxes()
self.sendrecv_markers_boxes()
self.update_holes()
self.update_ghost_particles()
# if verbose:
# n_valid = xp.count_nonzero(self.valid_mks)
# n_holes = xp.count_nonzero(self.holes)
# n_ghosts = xp.count_nonzero(self.ghost_particles)
# logger.info(f"after communicate_boxes: {self.mpi_rank = }, {n_valid = }, {n_holes = }, {n_ghosts = }")
[docs]
def sendrecv_all_to_all_boxes(self):
"""
Distribute info on how many markers will be sent/received to/from each process via all-to-all
for the communication of particles in boundary boxes.
"""
self._recv_info_box = xp.zeros(self.mpi_comm.Get_size(), dtype=int)
self.mpi_comm.Alltoall(self._send_info_box, self._recv_info_box)
[docs]
def sendrecv_markers_boxes(self):
"""
Use non-blocking communication. In-place modification of markers
for the communication of particles in boundary boxes.
"""
# i-th entry holds the number (not the index) of the first hole to be filled by data from process i
first_hole = xp.cumsum(self._recv_info_box) - self._recv_info_box
hole_inds = xp.nonzero(self._holes)[0]
# Initialize send and receive commands
reqs = []
recvbufs = []
for i, (data, N_recv) in enumerate(zip(self._send_list_box, list(self._recv_info_box))):
if i == self.mpi_comm.Get_rank():
reqs += [None]
recvbufs += [None]
else:
self.mpi_comm.Isend(data, dest=i, tag=self.mpi_comm.Get_rank())
recvbufs += [xp.zeros((N_recv, self._markers.shape[1]), dtype=float)]
reqs += [self.mpi_comm.Irecv(recvbufs[-1], source=i, tag=i)]
# Wait for buffer, then put markers into holes
test_reqs = [False] * (self._recv_info_box.size - 1)
while len(test_reqs) > 0:
# loop over all receive requests
for i, req in enumerate(reqs):
if req is None:
continue
else:
# check if data has been received
if req.Test():
if hole_inds.size < first_hole[i] + self._recv_info_box[i]:
warnings.warn(
f'Strong load imbalance detected: \
number of holes ({hole_inds.size}) on rank {self.mpi_rank} \
is smaller than number of incoming particles ({first_hole[i] + self._recv_info_box[i]}). \
Increasing the value of "bufsize" in the markers parameters for the next run.',
)
self.mpi_comm.Abort()
# exit()
self._markers[hole_inds[first_hole[i] + xp.arange(self._recv_info_box[i])]] = recvbufs[i]
test_reqs.pop()
reqs[i] = None
self._Barrier()
def _get_neighbouring_proc(self):
"""Find the neighbouring processes for the sending of boxes.
The left (right) neighbour in direction 1 is called x_m_proc (x_p_proc), etc.
By default every process is its own neighbour.
"""
# Faces
self._x_m_proc = None
self._x_p_proc = None
self._y_m_proc = None
self._y_p_proc = None
self._z_m_proc = None
self._z_p_proc = None
# Edges
self._x_m_y_m_proc = None
self._x_m_y_p_proc = None
self._x_p_y_m_proc = None
self._x_p_y_p_proc = None
self._x_m_z_m_proc = None
self._x_m_z_p_proc = None
self._x_p_z_m_proc = None
self._x_p_z_p_proc = None
self._y_m_z_m_proc = None
self._y_m_z_p_proc = None
self._y_p_z_m_proc = None
self._y_p_z_p_proc = None
# Corners
self._x_m_y_m_z_m_proc = None
self._x_m_y_m_z_p_proc = None
self._x_m_y_p_z_m_proc = None
self._x_p_y_m_z_m_proc = None
self._x_m_y_p_z_p_proc = None
self._x_p_y_m_z_p_proc = None
self._x_p_y_p_z_m_proc = None
self._x_p_y_p_z_p_proc = None
# periodicitiy for distance computation
periodic1 = self.bc_sph[0] == "periodic"
periodic2 = self.bc_sph[1] == "periodic"
periodic3 = self.bc_sph[2] == "periodic"
# Determine which proc are on which side
dd = self.domain_array
rank = self.mpi_rank
x_l = dd[rank][0]
x_r = dd[rank][1]
y_l = dd[rank][3]
y_r = dd[rank][4]
z_l = dd[rank][6]
z_r = dd[rank][7]
for i in range(self.mpi_size):
xl_i = dd[i][0]
xr_i = dd[i][1]
yl_i = dd[i][3]
yr_i = dd[i][4]
zl_i = dd[i][6]
zr_i = dd[i][7]
is_same_x_l = abs(distance(xl_i, x_l, periodic1)) < 1e-5
is_same_x_r = abs(distance(xr_i, x_r, periodic1)) < 1e-5
is_same_y_l = abs(distance(yl_i, y_l, periodic2)) < 1e-5
is_same_y_r = abs(distance(yr_i, y_r, periodic2)) < 1e-5
is_same_z_l = abs(distance(zl_i, z_l, periodic3)) < 1e-5
is_same_z_r = abs(distance(zr_i, z_r, periodic3)) < 1e-5
is_neigh_x_l = abs(distance(xr_i, x_l, periodic1)) < 1e-5
is_neigh_x_r = abs(distance(xl_i, x_r, periodic1)) < 1e-5
is_neigh_y_l = abs(distance(yr_i, y_l, periodic2)) < 1e-5
is_neigh_y_r = abs(distance(yl_i, y_r, periodic2)) < 1e-5
is_neigh_z_l = abs(distance(zr_i, z_l, periodic3)) < 1e-5
is_neigh_z_r = abs(distance(zl_i, z_r, periodic3)) < 1e-5
# Faces
# Process on the left (minus axis) in the x direction
if is_same_y_l and is_same_y_r and is_same_z_l and is_same_z_r and is_neigh_x_l:
self._x_m_proc = i
# Process on the right (plus axis) in the x direction
if is_same_y_l and is_same_y_r and is_same_z_l and is_same_z_r and is_neigh_x_r:
self._x_p_proc = i
# Process on the left (minus axis) in the y direction
if is_same_x_l and is_same_x_r and is_same_z_l and is_same_z_r and is_neigh_y_l:
self._y_m_proc = i
# Process on the right (plus axis) in the y direction
if is_same_x_l and is_same_x_r and is_same_z_l and is_same_z_r and is_neigh_y_r:
self._y_p_proc = i
# Process on the left (minus axis) in the z direction
if is_same_x_l and is_same_x_r and is_same_y_l and is_same_y_r and is_neigh_z_l:
self._z_m_proc = i
# Process on the right (plus axis) in the z direction
if is_same_x_l and is_same_x_r and is_same_y_l and is_same_y_r and is_neigh_z_r:
self._z_p_proc = i
# Edges
# Process on the left in x and left in y axis
if is_same_z_l and is_same_z_r and is_neigh_x_l and is_neigh_y_l:
self._x_m_y_m_proc = i
# Process on the left in x and right in y axis
if is_same_z_l and is_same_z_r and is_neigh_x_l and is_neigh_y_r:
self._x_m_y_p_proc = i
# Process on the right in x and left in y axis
if is_same_z_l and is_same_z_r and is_neigh_x_r and is_neigh_y_l:
self._x_p_y_m_proc = i
# Process on the right in x and right in y axis
if is_same_z_l and is_same_z_r and is_neigh_x_r and is_neigh_y_r:
self._x_p_y_p_proc = i
# Process on the left in x and left in z axis
if is_same_y_l and is_same_y_r and is_neigh_x_l and is_neigh_z_l:
self._x_m_z_m_proc = i
# Process on the left in x and right in z axis
if is_same_y_l and is_same_y_r and is_neigh_x_l and is_neigh_z_r:
self._x_m_z_p_proc = i
# Process on the right in x and left in z axis
if is_same_y_l and is_same_y_r and is_neigh_x_r and is_neigh_z_l:
self._x_p_z_m_proc = i
# Process on the right in x and right in z axis
if is_same_y_l and is_same_y_r and is_neigh_x_r and is_neigh_z_r:
self._x_p_z_p_proc = i
# Process on the left in y and left in z axis
if is_same_x_l and is_same_x_r and is_neigh_y_l and is_neigh_z_l:
self._y_m_z_m_proc = i
# Process on the left in y and right in z axis
if is_same_x_l and is_same_x_r and is_neigh_y_l and is_neigh_z_r:
self._y_m_z_p_proc = i
# Process on the right in y and left in z axis
if is_same_x_l and is_same_x_r and is_neigh_y_r and is_neigh_z_l:
self._y_p_z_m_proc = i
# Process on the right in y and right in z axis
if is_same_x_l and is_same_x_r and is_neigh_y_r and is_neigh_z_r:
self._y_p_z_p_proc = i
# Corners
# Process on the left in x, left in y and left in z axis
if is_neigh_x_l and is_neigh_y_l and is_neigh_z_l:
self._x_m_y_m_z_m_proc = i
# Process on the left in x, left in y and right in z axis
if is_neigh_x_l and is_neigh_y_l and is_neigh_z_r:
self._x_m_y_m_z_p_proc = i
# Process on the left in x, right in y and left in z axis
if is_neigh_x_l and is_neigh_y_r and is_neigh_z_l:
self._x_m_y_p_z_m_proc = i
# Process on the left in x, right in y and right in z axis
if is_neigh_x_l and is_neigh_y_r and is_neigh_z_r:
self._x_m_y_p_z_p_proc = i
# Process on the right in x, left in y and left in z axis
if is_neigh_x_r and is_neigh_y_l and is_neigh_z_l:
self._x_p_y_m_z_m_proc = i
# Process on the right in x, left in y and right in z axis
if is_neigh_x_r and is_neigh_y_l and is_neigh_z_r:
self._x_p_y_m_z_p_proc = i
# Process on the right in x, right in y and left in z axis
if is_neigh_x_r and is_neigh_y_r and is_neigh_z_l:
self._x_p_y_p_z_m_proc = i
# Process on the right in x, right in y and right in z axis
if is_neigh_x_r and is_neigh_y_r and is_neigh_z_r:
self._x_p_y_p_z_p_proc = i
# set empty faces in x
if self._x_m_proc is None:
self._x_m_proc = rank
if self._x_p_proc is None:
self._x_p_proc = rank
# set empty faces in y
if self._y_m_proc is None:
self._y_m_proc = rank
if self._y_p_proc is None:
self._y_p_proc = rank
# set empty faces in z
if self._z_m_proc is None:
self._z_m_proc = rank
if self._z_p_proc is None:
self._z_p_proc = rank
# set empty edges in xy
if self._x_m_y_m_proc is None:
if self._x_m_proc == rank:
self._x_m_y_m_proc = self._y_m_proc
elif self._y_m_proc == rank:
self._x_m_y_m_proc = self._x_m_proc
if self._x_m_y_p_proc is None:
if self._x_m_proc == rank:
self._x_m_y_p_proc = self._y_p_proc
elif self._y_p_proc == rank:
self._x_m_y_p_proc = self._x_m_proc
if self._x_p_y_m_proc is None:
if self._x_p_proc == rank:
self._x_p_y_m_proc = self._y_m_proc
elif self._y_m_proc == rank:
self._x_p_y_m_proc = self._x_p_proc
if self._x_p_y_p_proc is None:
if self._x_p_proc == rank:
self._x_p_y_p_proc = self._y_p_proc
elif self._y_p_proc == rank:
self._x_p_y_p_proc = self._x_p_proc
# set empty edges in xz
if self._x_m_z_m_proc is None:
if self._x_m_proc == rank:
self._x_m_z_m_proc = self._z_m_proc
elif self._z_m_proc == rank:
self._x_m_z_m_proc = self._x_m_proc
if self._x_m_z_p_proc is None:
if self._x_m_proc == rank:
self._x_m_z_p_proc = self._z_p_proc
elif self._z_p_proc == rank:
self._x_m_z_p_proc = self._x_m_proc
if self._x_p_z_m_proc is None:
if self._x_p_proc == rank:
self._x_p_z_m_proc = self._z_m_proc
elif self._z_m_proc == rank:
self._x_p_z_m_proc = self._x_p_proc
if self._x_p_z_p_proc is None:
if self._x_p_proc == rank:
self._x_p_z_p_proc = self._z_p_proc
elif self._z_p_proc == rank:
self._x_p_z_p_proc = self._x_p_proc
# set empty edges in yz
if self._y_m_z_m_proc is None:
if self._y_m_proc == rank:
self._y_m_z_m_proc = self._z_m_proc
elif self._z_m_proc == rank:
self._y_m_z_m_proc = self._y_m_proc
if self._y_m_z_p_proc is None:
if self._y_m_proc == rank:
self._y_m_z_p_proc = self._z_p_proc
elif self._z_p_proc == rank:
self._y_m_z_p_proc = self._y_m_proc
if self._y_p_z_m_proc is None:
if self._y_p_proc == rank:
self._y_p_z_m_proc = self._z_m_proc
elif self._z_m_proc == rank:
self._y_p_z_m_proc = self._y_p_proc
if self._y_p_z_p_proc is None:
if self._y_p_proc == rank:
self._y_p_z_p_proc = self._z_p_proc
elif self._z_p_proc == rank:
self._y_p_z_p_proc = self._y_p_proc
# set empty corners
if self._x_m_y_m_z_m_proc is None:
if self._x_m_proc == rank:
if self._y_m_proc == rank:
self._x_m_y_m_z_m_proc = self._z_m_proc
elif self._z_m_proc == rank:
self._x_m_y_m_z_m_proc = self._y_m_proc
elif self._y_m_proc == rank:
if self._x_m_proc == rank:
self._x_m_y_m_z_m_proc = self._z_m_proc
elif self._z_m_proc == rank:
self._x_m_y_m_z_m_proc = self._x_m_proc
elif self._z_m_proc == rank:
if self._x_m_proc == rank:
self._x_m_y_m_z_m_proc = self._y_m_proc
elif self._y_m_proc == rank:
self._x_m_y_m_z_m_proc = self._x_m_proc
if self._x_m_y_m_z_p_proc is None:
if self._x_m_proc == rank:
if self._y_m_proc == rank:
self._x_m_y_m_z_p_proc = self._z_p_proc
elif self._z_p_proc == rank:
self._x_m_y_m_z_p_proc = self._y_m_proc
elif self._y_m_proc == rank:
if self._x_m_proc == rank:
self._x_m_y_m_z_p_proc = self._z_p_proc
elif self._z_p_proc == rank:
self._x_m_y_m_z_p_proc = self._x_m_proc
elif self._z_p_proc == rank:
if self._x_m_proc == rank:
self._x_m_y_m_z_p_proc = self._y_m_proc
elif self._y_m_proc == rank:
self._x_m_y_m_z_p_proc = self._x_m_proc
if self._x_m_y_p_z_m_proc is None:
if self._x_m_proc == rank:
if self._y_p_proc == rank:
self._x_m_y_p_z_m_proc = self._z_m_proc
elif self._z_m_proc == rank:
self._x_m_y_p_z_m_proc = self._y_p_proc
elif self._y_p_proc == rank:
if self._x_m_proc == rank:
self._x_m_y_p_z_m_proc = self._z_m_proc
elif self._z_m_proc == rank:
self._x_m_y_p_z_m_proc = self._x_m_proc
elif self._z_m_proc == rank:
if self._x_m_proc == rank:
self._x_m_y_p_z_m_proc = self._y_p_proc
elif self._y_p_proc == rank:
self._x_m_y_p_z_m_proc = self._x_m_proc
if self._x_m_y_p_z_p_proc is None:
if self._x_m_proc == rank:
if self._y_p_proc == rank:
self._x_m_y_p_z_p_proc = self._z_p_proc
elif self._z_p_proc == rank:
self._x_m_y_p_z_p_proc = self._y_p_proc
elif self._y_p_proc == rank:
if self._x_m_proc == rank:
self._x_m_y_p_z_p_proc = self._z_p_proc
elif self._z_p_proc == rank:
self._x_m_y_p_z_p_proc = self._x_m_proc
elif self._z_p_proc == rank:
if self._x_m_proc == rank:
self._x_m_y_p_z_p_proc = self._y_p_proc
elif self._y_p_proc == rank:
self._x_m_y_p_z_p_proc = self._x_m_proc
if self._x_p_y_m_z_m_proc is None:
if self._x_p_proc == rank:
if self._y_m_proc == rank:
self._x_p_y_m_z_m_proc = self._z_m_proc
elif self._z_m_proc == rank:
self._x_p_y_m_z_m_proc = self._y_m_proc
elif self._y_m_proc == rank:
if self._x_p_proc == rank:
self._x_p_y_m_z_m_proc = self._z_m_proc
elif self._z_m_proc == rank:
self._x_p_y_m_z_m_proc = self._x_p_proc
elif self._z_m_proc == rank:
if self._x_p_proc == rank:
self._x_p_y_m_z_m_proc = self._y_m_proc
elif self._y_m_proc == rank:
self._x_p_y_m_z_m_proc = self._x_p_proc
if self._x_p_y_m_z_p_proc is None:
if self._x_p_proc == rank:
if self._y_m_proc == rank:
self._x_p_y_m_z_p_proc = self._z_p_proc
elif self._z_p_proc == rank:
self._x_p_y_m_z_p_proc = self._y_m_proc
elif self._y_m_proc == rank:
if self._x_p_proc == rank:
self._x_p_y_m_z_p_proc = self._z_p_proc
elif self._z_p_proc == rank:
self._x_p_y_m_z_p_proc = self._x_p_proc
elif self._z_p_proc == rank:
if self._x_p_proc == rank:
self._x_p_y_m_z_p_proc = self._y_m_proc
elif self._y_m_proc == rank:
self._x_p_y_m_z_p_proc = self._x_p_proc
if self._x_p_y_p_z_m_proc is None:
if self._x_p_proc == rank:
if self._y_p_proc == rank:
self._x_p_y_p_z_m_proc = self._z_m_proc
elif self._z_m_proc == rank:
self._x_p_y_p_z_m_proc = self._y_p_proc
elif self._y_p_proc == rank:
if self._x_p_proc == rank:
self._x_p_y_p_z_m_proc = self._z_m_proc
elif self._z_m_proc == rank:
self._x_p_y_p_z_m_proc = self._x_p_proc
elif self._z_m_proc == rank:
if self._x_p_proc == rank:
self._x_p_y_p_z_m_proc = self._y_p_proc
elif self._y_p_proc == rank:
self._x_p_y_p_z_m_proc = self._x_p_proc
if self._x_p_y_p_z_p_proc is None:
if self._x_p_proc == rank:
if self._y_p_proc == rank:
self._x_p_y_p_z_p_proc = self._z_p_proc
elif self._z_p_proc == rank:
self._x_p_y_p_z_p_proc = self._y_p_proc
elif self._y_p_proc == rank:
if self._x_p_proc == rank:
self._x_p_y_p_z_p_proc = self._z_p_proc
elif self._z_p_proc == rank:
self._x_p_y_p_z_p_proc = self._x_p_proc
elif self._z_p_proc == rank:
if self._x_p_proc == rank:
self._x_p_y_p_z_p_proc = self._y_p_proc
elif self._y_p_proc == rank:
self._x_p_y_p_z_p_proc = self._x_p_proc
[docs]
def eval_density(
self,
eta1,
eta2,
eta3,
h1,
h2,
h3,
kernel_type="gaussian_1d",
derivative=0,
fast=True,
):
"""Evaluate particle number density (0-form) using an SPH smoothing kernel.
Parameters
----------
eta1, eta2, eta3 : array_like
Logical evaluation points. Inputs may be 1-D arrays (flat evaluation) or
broadcastable meshgrid arrays; the output will match the shape of `eta1`.
h1, h2, h3 : float
Support radius of the smoothing kernel in each logical dimension.
kernel_type : str, optional
Name of the smoothing kernel (must be a key in `self.ker_dct()`).
derivative : int, optional
Selects whether to evaluate the kernel derivative along a coordinate
direction: 0 (default) returns the scalar density, 1/2/3 returns the
corresponding component of the density gradient with respect to
logical coordinates.
fast : bool, optional
If True, use the box-based neighbor search (faster for many particles);
if False, use the naive all-pairs evaluation (simpler, slower).
Returns
-------
out : xp.ndarray
Estimated number density (or requested derivative component) at the
provided evaluation points. The array uses the same shape as `eta1`
and is returned as a `cunumpy` (`xp`) array.
Notes
-----
This method is a thin wrapper around :meth:`eval_sph` and internally
evaluates the column given by `self.index['weights']` (particle weights).
"""
return self.eval_sph(
eta1,
eta2,
eta3,
self.index["weights"],
kernel_type=kernel_type,
derivative=derivative,
h1=h1,
h2=h2,
h3=h3,
fast=fast,
)
[docs]
def eval_velocity(
self,
eta1,
eta2,
eta3,
h1,
h2,
h3,
kernel_type="gaussian_1d",
derivative=0,
fast=True,
) -> tuple:
"""Estimate mean velocity components using SPH smoothing.
Parameters
----------
eta1, eta2, eta3 : array_like
Logical evaluation points. May be 1-D arrays or broadcastable meshgrid
arrays; the returned component arrays match the shape of `eta1`.
h1, h2, h3 : float
Support radius of the smoothing kernel in each logical dimension.
kernel_type : str, optional
Name of the smoothing kernel (must be a key in `self.ker_dct()`).
derivative : int, optional
If 0 (default) evaluate the mean velocity; if 1/2/3 return the
corresponding component of the spatial derivative of the velocity.
fast : bool, optional
If True use the box-based neighbor search (faster for many particles);
if False use the naive all-pairs evaluation.
Returns
-------
(v1, v2, v3) : tuple of xp.ndarray
Three arrays containing the estimated velocity components at the
provided evaluation points. Each array has the same shape as `eta1`.
Notes
-----
This method first computes SPH coefficients by calling
`eval_kernels_sph.sph_mean_velocity_coeffs` (via a Pyccel kernel) to
assemble mean-velocity coefficients into the markers array, then calls
:meth:`eval_sph` for each velocity component.
"""
first_free_idx = self.args_markers.first_free_idx
comps = xp.array((0, 1, 2))
self.put_particles_in_boxes()
func = Pyccelkernel(eval_kernels_sph.sph_mean_velocity_coeffs)
func(
alpha=xp.array((0.0, 0.0, 0.0)),
column_nr=first_free_idx,
comps=comps,
args_markers=self.args_markers,
args_domain=self.domain.args_domain,
boxes=self.sorting_boxes.boxes,
neighbours=self.sorting_boxes.neighbours,
holes=self.holes,
periodic1=self.boundary_params.bc_sph[0] == "periodic",
periodic2=self.boundary_params.bc_sph[1] == "periodic",
periodic3=self.boundary_params.bc_sph[2] == "periodic",
kernel_type=self.ker_dct()[kernel_type],
h1=h1,
h2=h2,
h3=h3,
)
v1 = self.eval_sph(
eta1,
eta2,
eta3,
first_free_idx,
kernel_type=kernel_type,
derivative=derivative,
h1=h1,
h2=h2,
h3=h3,
fast=fast,
)
v2 = self.eval_sph(
eta1,
eta2,
eta3,
first_free_idx + 1,
kernel_type=kernel_type,
derivative=derivative,
h1=h1,
h2=h2,
h3=h3,
fast=fast,
)
v3 = self.eval_sph(
eta1,
eta2,
eta3,
first_free_idx + 2,
kernel_type=kernel_type,
derivative=derivative,
h1=h1,
h2=h2,
h3=h3,
fast=fast,
)
return v1, v2, v3
[docs]
def eval_div_viscosity(
self,
eta1,
eta2,
eta3,
h1,
h2,
h3,
kernel_type="gaussian_1d",
mu: float = 1.0,
fast=True,
) -> tuple:
"""Compute divergence of the viscous stress (mu * viscosity tensor).
Parameters
----------
eta1, eta2, eta3 : array_like
Logical evaluation points where the divergence is evaluated.
h1, h2, h3 : float
Support radius of the smoothing kernel in each logical dimension.
kernel_type : str, optional
Name of the smoothing kernel (must be a key in `self.ker_dct()`).
mu : float, optional
Dynamic viscosity coefficient used in the viscosity kernel.
fast : bool, optional
If True use the box-based neighbor search; if False use naive
evaluation.
Returns
-------
(gamma_x, gamma_y, gamma_z) : tuple of xp.ndarray
Components of the divergence of the viscous stress evaluated at the
provided points. Each array matches the shape of `eta1`.
Notes
-----
The routine populates intermediate marker columns using two Pyccel
kernels: `sph_mean_velocity_coeffs` (mean velocity) and
`sph_viscosity_tensor` (viscosity tensor components). It then evaluates
the necessary derivatives via :meth:`eval_sph` and sums contributions to
produce the three divergence components.
"""
first_free_idx = self.args_markers.first_free_idx
self.put_particles_in_boxes()
# 1st kernel
func = Pyccelkernel(eval_kernels_sph.sph_mean_velocity_coeffs)
comps = xp.array((0, 1, 2))
func(
alpha=xp.array((0.0, 0.0, 0.0)),
column_nr=first_free_idx,
comps=comps,
args_markers=self.args_markers,
args_domain=self.domain.args_domain,
boxes=self.sorting_boxes.boxes,
neighbours=self.sorting_boxes.neighbours,
holes=self.holes,
periodic1=self.boundary_params.bc_sph[0] == "periodic",
periodic2=self.boundary_params.bc_sph[1] == "periodic",
periodic3=self.boundary_params.bc_sph[2] == "periodic",
kernel_type=self.ker_dct()[kernel_type],
h1=h1,
h2=h2,
h3=h3,
)
# 2nd kernel
func = Pyccelkernel(eval_kernels_sph.sph_viscosity_tensor)
comps = xp.arange(9)
func(
alpha=xp.array((0.0, 0.0, 0.0)),
column_nr=first_free_idx + 3,
comps=comps,
args_markers=self.args_markers,
args_domain=self.domain.args_domain,
boxes=self.sorting_boxes.boxes,
neighbours=self.sorting_boxes.neighbours,
holes=self.holes,
periodic1=self.boundary_params.bc_sph[0] == "periodic",
periodic2=self.boundary_params.bc_sph[1] == "periodic",
periodic3=self.boundary_params.bc_sph[2] == "periodic",
kernel_type=self.ker_dct()[kernel_type],
h1=h1,
h2=h2,
h3=h3,
mu=mu,
)
# grid evaluation
gamma = []
for j in range(3):
gamma += [[]]
for k in range(3):
gamma[-1] += [
self.eval_sph(
eta1,
eta2,
eta3,
first_free_idx + 3 * (j + 1) + k,
kernel_type=kernel_type,
derivative=k + 1,
h1=h1,
h2=h2,
h3=h3,
fast=fast,
)
]
gamma_x = gamma[0][0] + gamma[0][1] + gamma[0][2]
gamma_y = gamma[1][0] + gamma[1][1] + gamma[1][2]
gamma_z = gamma[2][0] + gamma[2][1] + gamma[2][2]
return gamma_x, gamma_y, gamma_z
[docs]
def eval_sph(
self,
eta1: xp.ndarray,
eta2: xp.ndarray,
eta3: xp.ndarray,
index: int,
out: xp.ndarray = None,
fast: bool = True,
kernel_type: str = "gaussian_1d",
derivative: int = 0,
h1: float = 0.1,
h2: float = 0.1,
h3: float = 0.1,
):
r"""Perform a (meshgrid) SPH evaluation of a function :math:`\rho: [0, 1]^3 \to \mathbb R` in the following sense:
.. math::
\rho(\boldsymbol \eta_i) = \sum_{j=0}^{N-1} \rho_j\, W_h(\boldsymbol \eta_i - \boldsymbol \eta_j)\,.
The coefficients :math:`\rho_j` must be available in the marker array, stored at some index ``self.markers[j, index]``.
In case that `derivative=k` where `k` is not zero, the `k`-th component of the gradient of :math:`\rho` is computed:
.. math::
\textrm{derivative}=k:\qquad [\nabla \rho(\boldsymbol \eta_i)]_k = \sum_{j=0}^{N-1} \rho_j \frac{\partial W_h}{\partial \eta_k}(\boldsymbol \eta_i - \boldsymbol \eta_j)\,.
The possible choices for :math:`W_h` are listed in :ref:`smoothing_kernels`
and in :meth:`~struphy.pic.base.Particles.ker_dct`.
Parameters
----------
eta1, eta2, eta3 : array_like
Logical evaluation points.
index : int
At which index of the markers array are located the coefficients :math:`\rho_j`.
out : array_like
Output will be store in this array. A new array is created if not provided.
fast : bool
If true, uses an optimized evaluation algorithm taking advantage of the box structure.
This assume that the boxes are bigger then the radius used for the smoothing kernel.
kernel_type : str
Name of the smoothing kernel, see :mod:`~struphy.pic.sph_smoothing_kernels`
and :meth:`~struphy.pic.base.Particles.ker_dct`.
derivative: int
0: no kernel derivative
1: first component of grad
2: second component of grad
3: third component of grad
h1, h2, h3 : float
Radius of the smoothing kernel in each dimension.
"""
_shp = xp.shape(eta1)
assert _shp == xp.shape(eta2) == xp.shape(eta3)
if out is not None:
assert _shp == xp.shape(out)
else:
out = xp.zeros_like(eta1)
assert derivative in {0, 1, 2, 3}, f"derivative must be 0, 1, 2 or 3, but is {derivative}."
ker_id = self.ker_dct()[kernel_type]
ker_id += derivative
# for the moment we always assume periodicity for the evaluation near the boundary, TODO: fill ghost boxes with suitable markers for other bcs?
periodic1, periodic2, periodic3 = [True] * 3 # [bci == "periodic" for bci in self.bc]
self.put_particles_in_boxes()
if fast:
if len(_shp) == 1:
func = Pyccelkernel(box_based_evaluation_flat)
elif len(_shp) == 3:
if _shp[0] > 1:
assert eta1[0, 0, 0] != eta1[1, 0, 0], "Meshgrids must be obtained with indexing='ij'!"
if _shp[1] > 1:
assert eta2[0, 0, 0] != eta2[0, 1, 0], "Meshgrids must be obtained with indexing='ij'!"
if _shp[2] > 1:
assert eta3[0, 0, 0] != eta3[0, 0, 1], "Meshgrids must be obtained with indexing='ij'!"
func = Pyccelkernel(box_based_evaluation_meshgrid)
func(
self.args_markers,
eta1,
eta2,
eta3,
self.sorting_boxes.nx,
self.sorting_boxes.ny,
self.sorting_boxes.nz,
self.domain_array[self.mpi_rank],
self.sorting_boxes.boxes,
self.sorting_boxes.neighbours,
self.holes,
periodic1,
periodic2,
periodic3,
index,
ker_id,
h1,
h2,
h3,
out,
)
else:
if len(_shp) == 1:
func = Pyccelkernel(naive_evaluation_flat)
elif len(_shp) == 3:
func = Pyccelkernel(naive_evaluation_meshgrid)
func(
self.args_markers,
eta1,
eta2,
eta3,
self.holes,
periodic1,
periodic2,
periodic3,
index,
ker_id,
h1,
h2,
h3,
out,
)
return out
[docs]
def update_holes(self):
"""Compute new holes, new number of holes and markers on process"""
self._holes[:] = self.markers[:, 0] == -1.0
self.update_valid_mks()
[docs]
def update_ghost_particles(self):
"""Compute new particles that belong to boundary processes needed for sph evaluation"""
self._ghost_particles[:] = self.markers[:, -1] == -2.0
self.update_valid_mks()
### MPI comm for domain decomposition ###
[docs]
def sendrecv_determine_mtbs(
self,
alpha: list | tuple | xp.ndarray = (1.0, 1.0, 1.0),
):
"""
Determine which markers have to be sent from current process and put them in a new array.
Corresponding rows in markers array become holes and are therefore set to -1.
This can be done purely with numpy functions (fast, vectorized).
Parameters
----------
alpha : list | tuple
For i=1,2,3 the sorting is according to alpha[i]*markers[:, i] + (1 - alpha[i])*markers[:, first_pusher_idx + i].
alpha[i] must be between 0 and 1.
Returns
-------
hole_inds_after_send : array[int]
Indices of empty columns in markers after send.
sorting_etas : array[float]
Eta-values of shape (n_send, :) according to which the sorting is performed.
"""
# position that determines the sorting (including periodic shift of boundary conditions)
if not isinstance(alpha, xp.ndarray):
alpha = xp.array(alpha, dtype=float)
assert alpha.size == 3
assert xp.all(alpha >= 0.0) and xp.all(alpha <= 1.0)
bi = self.first_pusher_idx
self._sorting_etas = xp.mod(
alpha * (self.markers[:, :3] + self.markers[:, bi + 3 + self.vdim : bi + 3 + self.vdim + 3])
+ (1.0 - alpha) * self.markers[:, bi : bi + 3],
1.0,
)
# check which particles are on the current process domain
self._is_on_proc_domain = xp.logical_and(
self._sorting_etas > self.domain_array[self.mpi_rank, 0::3],
self._sorting_etas < self.domain_array[self.mpi_rank, 1::3],
)
# to stay on the current process, all three columns must be True
self._can_stay = xp.all(self._is_on_proc_domain, axis=1)
# holes and ghosts can stay, too
self._can_stay[self.holes] = True
self._can_stay[self.ghost_particles] = True
# True values can stay on the process, False must be sent, already empty rows (-1) cannot be sent
send_inds = xp.nonzero(~self._can_stay)[0]
hole_inds_after_send = xp.nonzero(xp.logical_or(~self._can_stay, self.holes))[0]
return hole_inds_after_send, send_inds
[docs]
def sendrecv_get_destinations(self, send_inds):
"""
Determine to which process particles have to be sent.
Parameters
----------
send_inds : array[int]
Indices of particles which will be sent.
Returns
-------
send_info : array[int]
Amount of particles sent to i-th process.
"""
# One entry for each process
send_info = xp.zeros(self.mpi_size, dtype=int)
# TODO: do not loop over all processes, start with neighbours and work outwards (using while)
for i in range(self.mpi_size):
conds = xp.logical_and(
self._sorting_etas[send_inds] > self.domain_array[i, 0::3],
self._sorting_etas[send_inds] < self.domain_array[i, 1::3],
)
self._send_to_i[i] = xp.nonzero(xp.all(conds, axis=1))[0]
send_info[i] = self._send_to_i[i].size
self._send_list[i] = self.markers[send_inds][self._send_to_i[i]]
return send_info
[docs]
def sendrecv_all_to_all(self, send_info):
"""
Distribute info on how many markers will be sent/received to/from each process via all-to-all.
Parameters
----------
send_info : array[int]
Amount of markers to be sent to i-th process.
Returns
-------
recv_info : array[int]
Amount of marticles to be received from i-th process.
"""
recv_info = xp.zeros(self.mpi_size, dtype=int)
self.mpi_comm.Alltoall(send_info, recv_info)
return recv_info
[docs]
def sendrecv_markers(self, recv_info, hole_inds_after_send):
"""
Use non-blocking communication. In-place modification of markers
Parameters
----------
recv_info : array[int]
Amount of markers to be received from i-th process.
hole_inds_after_send : array[int]
Indices of empty rows in markers after send.
"""
# i-th entry holds the number (not the index) of the first hole to be filled by data from process i
first_hole = xp.cumsum(recv_info) - recv_info
# Initialize send and receive commands
for i, (data, N_recv) in enumerate(zip(self._send_list, list(recv_info))):
if i == self.mpi_rank:
self._reqs[i] = None
self._recvbufs[i] = None
else:
self.mpi_comm.Isend(data, dest=i, tag=self.mpi_rank)
self._recvbufs[i] = xp.zeros((N_recv, self.markers.shape[1]), dtype=float)
self._reqs[i] = self.mpi_comm.Irecv(self._recvbufs[i], source=i, tag=i)
# Wait for buffer, then put markers into holes
test_reqs = [False] * (recv_info.size - 1)
while len(test_reqs) > 0:
# loop over all receive requests
for i, req in enumerate(self._reqs):
if req is None:
continue
else:
# check if data has been received
if req.Test():
if hole_inds_after_send.size < first_hole[i] + recv_info[i]:
warnings.warn(
f'Strong load imbalance detected: \
number of holes ({hole_inds_after_send.size}) on rank {self.mpi_rank} \
is smaller than number of incoming particles ({first_hole[i] + recv_info[i]}). \
Increasing the value of "bufsize" in the markers parameters for the next run.',
)
self.mpi_comm.Abort()
self.markers[hole_inds_after_send[first_hole[i] + xp.arange(recv_info[i])]] = self._recvbufs[i]
test_reqs.pop()
self._reqs[i] = None
def _gather_scalar_in_subcomm_array(self, scalar: int, out: xp.ndarray = None):
"""Return an array of length sub_comm.size, where the i-th entry corresponds to the value
of the scalar on process i.
Parameters
----------
scalar : int
The scalar value on each process.
out : xp.ndarray
The returned array (optional).
"""
if out is None:
_tmp = xp.zeros(self.mpi_size, dtype=int)
else:
assert out.size == self.mpi_size
_tmp = out
_tmp[self.mpi_rank] = scalar
if self.mpi_comm is not None:
self.mpi_comm.Allgather(
_tmp[self.mpi_rank],
_tmp,
)
return _tmp
def _gather_scalar_in_intercomm_array(self, scalar: int, out: xp.ndarray = None):
"""Return an array of length inter_comm.size, where the i-th entry corresponds to the value
of the scalar on clone i.
Parameters
----------
scalar : int
The scalar value on each clone.
out : xp.ndarray
The returned array (optional).
"""
if out is None:
_tmp = xp.zeros(self.num_clones, dtype=int)
else:
assert out.size == self.num_clones
_tmp = out
_tmp[self.clone_id] = scalar
if self.clone_config is not None:
self.clone_config.inter_comm.Allgather(
_tmp[self.clone_id],
_tmp,
)
return _tmp
class Tesselation:
"""
Make a tesselation of the simulation domain into tiles of equal size.
Parameters
----------
tiles_pb : int
Number of equally sized tiles per box defined in sorting boxes (there is 1 box if sorting_boxes=None).
This is equal to particels per box (ppb) when used for SPH markers.
comm : Intracomm
MPI communicator.
domain_array : xp.ndarray
A 2d array[float] of shape (comm.Get_size(), 9) holding info on the domain decomposition.
sorting_boxes : Particles.SortingBoxes
Box info for SPH evaluations.
"""
def __init__(
self,
tiles_pb: int | float,
*,
comm: Intracomm = None,
domain_array: xp.ndarray = None,
sorting_boxes: Particles.SortingBoxes = None,
):
if isinstance(tiles_pb, int):
self._tiles_pb = tiles_pb
else:
if tiles_pb == int(tiles_pb):
self._tiles_pb = int(tiles_pb)
else:
self._tiles_pb = int(tiles_pb + 1)
if comm is None:
self._rank = 0
else:
self._rank = comm.Get_rank()
assert domain_array is not None
if domain_array is None:
self._starts = xp.zeros(3)
self._ends = xp.ones(3)
else:
self._starts = domain_array[self.rank, 0::3]
self._ends = domain_array[self.rank, 1::3]
if sorting_boxes is None:
self._boxes_per_dim = [1, 1, 1]
else:
self._boxes_per_dim = [
sorting_boxes.nx,
sorting_boxes.ny,
sorting_boxes.nz,
]
self._box_widths = [(ri - le) / nb for ri, le, nb in zip(self._ends, self._starts, self.boxes_per_dim)]
n_boxes = 1
for nbi in self.boxes_per_dim:
n_boxes *= nbi
if n_boxes == 1:
self._dims_mask = [True] * 3
else:
self._dims_mask = xp.array(self.boxes_per_dim) > 1
min_tiles = 2 ** xp.count_nonzero(self.dims_mask)
assert self.tiles_pb >= min_tiles, (
f"At least {min_tiles} tiles per sorting box is enforced, but you have {self.tiles_pb}!"
)
self._n_tiles = n_boxes * self.tiles_pb
self.get_tiles()
def get_tiles(self):
"""Compute tesselation of a single sorting box."""
# factorize tiles per box
factors = factorint(self.tiles_pb)
factors_vec = []
for fac, multiplicity in factors.items():
for m in range(multiplicity):
factors_vec += [fac]
# logger.info(f'{self.tiles_pb = }')
# logger.info(f'{factors_vec = }')
# logger.info(f'{self.dims_mask = }')
# tiles in one sorting box
self._nt_per_dim = xp.array([1, 1, 1])
_ids = xp.nonzero(self._dims_mask)[0]
for fac in factors_vec:
_nt = self.nt_per_dim[self._dims_mask]
d = _ids[xp.argmin(_nt)]
self._nt_per_dim[d] *= fac
# logger.info(f'{_nt = }, {d = }, {self.nt_per_dim = }')
assert xp.prod(self.nt_per_dim) == self.tiles_pb
# tiles between [0, box_width] in each direction
self._tile_breaks = [xp.linspace(0.0, bw, nt + 1) for bw, nt in zip(self.box_widths, self.nt_per_dim)]
self._tile_midpoints = [(xp.roll(tbs, -1)[:-1] + tbs[:-1]) / 2 for tbs in self.tile_breaks]
self._tile_volume = 1.0
for tb in self.tile_breaks:
self._tile_volume *= tb[1]
def draw_markers(self):
"""Draw markers on the tile midpoints."""
_, eta1 = self._tile_output_arrays()
eta2 = xp.zeros_like(eta1)
eta3 = xp.zeros_like(eta1)
nt_x, nt_y, nt_z = self.nt_per_dim
for i in range(self.boxes_per_dim[0]):
x_midpoints = self._get_midpoints(i, 0)
for j in range(self.boxes_per_dim[1]):
y_midpoints = self._get_midpoints(j, 1)
for k in range(self.boxes_per_dim[2]):
z_midpoints = self._get_midpoints(k, 2)
xx, yy, zz = xp.meshgrid(
x_midpoints,
y_midpoints,
z_midpoints,
indexing="ij",
)
eta1[
i * nt_x : (i + 1) * nt_x,
j * nt_y : (j + 1) * nt_y,
k * nt_z : (k + 1) * nt_z,
] = xx
eta2[
i * nt_x : (i + 1) * nt_x,
j * nt_y : (j + 1) * nt_y,
k * nt_z : (k + 1) * nt_z,
] = yy
eta3[
i * nt_x : (i + 1) * nt_x,
j * nt_y : (j + 1) * nt_y,
k * nt_z : (k + 1) * nt_z,
] = zz
return eta1.flatten(), eta2.flatten(), eta3.flatten()
def _get_quad_pts(self, n_quad=None):
"""Compute the quadrature points and weights in a single tile."""
if n_quad is None:
n_quad = [1, 1, 1]
elif isinstance(n_quad, int):
n_quad = [n_quad] * 3
self._tile_quad_pts = []
self._tile_quad_wts = []
for nq, tb in zip(n_quad, self.tile_breaks):
pts_loc, wts_loc = xp.polynomial.legendre.leggauss(nq)
pts, wts = quadrature_grid(tb[:2], pts_loc, wts_loc)
self._tile_quad_pts += [pts[0]]
self._tile_quad_wts += [wts[0]]
def cell_averages(self, fun, n_quad=None):
"""Compute cell averages of fun over all tiles on current process.
Parameters
----------
fun: callable
Some callable function.
"""
self._get_quad_pts(n_quad=n_quad)
# logger.info(f'{self.tile_quad_pts = }')
single_box_out, out = self._tile_output_arrays()
nt_x, nt_y, nt_z = self.nt_per_dim
for i in range(self.boxes_per_dim[0]):
x_pts = self._get_box_quad_pts(i, 0)
for j in range(self.boxes_per_dim[1]):
y_pts = self._get_box_quad_pts(j, 1)
for k in range(self.boxes_per_dim[2]):
z_pts = self._get_box_quad_pts(k, 2)
xx, yy, zz = xp.meshgrid(
x_pts.flatten(),
y_pts.flatten(),
z_pts.flatten(),
indexing="ij",
)
fun_vals = fun(xx, yy, zz)
sampling_kernels.tile_int_kernel(
fun_vals,
*self.tile_quad_wts,
single_box_out,
)
# single_box_out /= self.tile_volume
out[
i * nt_x : (i + 1) * nt_x,
j * nt_y : (j + 1) * nt_y,
k * nt_z : (k + 1) * nt_z,
] = single_box_out
return out
def _tile_output_arrays(self):
"""Returns two 3d arrays filled with zeros:
* the first with one entry for each tile on one sorting box
* the second with one entry for each tile on current process
"""
# self._quad_pts = [xp.zeros((nt, nq)).flatten() for nt, nq in zip(self.nt_per_dim, self.tile_quad_pts)]
single_box_out = xp.zeros(self.nt_per_dim)
out = xp.tile(single_box_out, self.boxes_per_dim)
return single_box_out, out
def _get_midpoints(self, i: int, dim: int):
"""Compute all tile midpoints within one sorting box."""
xl = self.starts[dim] + i * self.box_widths[dim]
return xl + self.tile_midpoints[dim]
def _get_box_quad_pts(self, i: int, dim: int):
"""Compute all quadrature points for cell averages within the i-th sorting box in direction dim.
Parameters
----------
i : int
Index of the box, starting at 0.
dim : int
Direction, either 0, 1, or 2.
Returns
-------
x_pts : xp.array
2d array of shape (n_tiles_pb, n_tile_quad_pts)
"""
xl = self.starts[dim] + i * self.box_widths[dim]
x_tile_breaks = xl + self.tile_breaks[dim][:-1]
x_tile_pts = self.tile_quad_pts[dim]
x_pts = xp.tile(x_tile_breaks, (x_tile_pts.size, 1)).T + x_tile_pts
return x_pts
@property
def tiles_pb(self):
"""Number of equally sized tiles per sorting box."""
return self._tiles_pb
@property
def n_tiles(self):
"""Total number of tiles on current process."""
return self._n_tiles
@property
def nt_per_dim(self):
"""3-list of number of equally sized tiles per sorting box per direction."""
return self._nt_per_dim
@property
def starts(self):
"""3-list of domain starts (left boundaries) on current process."""
return self._starts
@property
def ends(self):
"""3-list of domain ends (right boundaries) on current process."""
return self._ends
@property
def tile_breaks(self):
"""3-list of tile break points within the single sorting box [0.0, sorting_box_width], in each direction."""
return self._tile_breaks
@property
def tile_midpoints(self):
"""3-list of tile midpoints within the single sorting box [0.0, sorting_box_width], in each direction."""
return self._tile_midpoints
@property
def tile_volume(self):
"""Volume of a single tile."""
return self._tile_volume
@property
def tile_quad_pts(self):
"""3-list of quadrature points (GL) within a single tile, in each direction."""
return self._tile_quad_pts
@property
def tile_quad_wts(self):
"""3-list of quadrature weights (GL) within a single tile, in each direction."""
return self._tile_quad_wts
@property
def rank(self):
"""Current process rank."""
return self._rank
@property
def boxes_per_dim(self):
"""Sorting boxes per direction."""
return self._boxes_per_dim
@property
def box_widths(self):
"""3-list of sorting box widths in each direction."""
return self._box_widths
@property
def dims_mask(self):
"""Boolean array of size 3; entry is True if direction participates in tesselation."""
return self._dims_mask