Source code for struphy.pic.base

import logging
import math
import os
import warnings
from abc import ABCMeta, abstractmethod
from typing import TYPE_CHECKING

import cunumpy as xp
import h5py
import numpy as np
from feectools.ddm.partition import factorint
from line_profiler import profile
from maybempi import MPI, SerialComm
from scope_profiler import ProfileManager

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_cuda import CudaMarkerArguments
from struphy.kernel_arguments.pusher_args_kernels import MarkerArguments
from struphy.kinetic_background.base import KineticBackground, Maxwellian
from struphy.kinetic_background.maxwellians import CanonicalMaxwellian2D
from struphy.particles.parameters import (
    BoundaryParameters,
    LoadingParameters,
    SavingParameters,
    SortingParameters,
    WeightsParameters,
)
from struphy.pic import sampling_kernels, sobol_seq
from struphy.pic.kernels.box_and_sort_particles import box_and_sort_particles
from struphy.pic.kernels.box_particles import box_particles
from struphy.pic.kernels.fill_boxes import fill_boxes
from struphy.pic.kernels.sort_and_fill_boxes import sort_and_fill_boxes
from struphy.pic.marker_layout import marker_array, padded_n_markers
from struphy.pic.pushing.kernels.reflect import reflect
from struphy.pic.pushing.kernels.sph_mean_velocity_coeffs import sph_mean_velocity_coeffs
from struphy.pic.pushing.kernels.sph_viscosity_tensor import sph_viscosity_tensor
from struphy.pic.sorting import SortingBoxes
from struphy.pic.sph.kernels.box_based_evaluation_flat import box_based_evaluation_flat
from struphy.pic.sph.kernels.box_based_evaluation_meshgrid import box_based_evaluation_meshgrid
from struphy.pic.sph.kernels.naive_evaluation_flat import naive_evaluation_flat
from struphy.pic.sph.kernels.naive_evaluation_meshgrid import naive_evaluation_meshgrid
from struphy.pic.sph_eval_kernels import (
    distance,
)
from struphy.utils import utils
from struphy.utils.clone_config import CloneConfig
from struphy.utils.cuda_arguments import check_mapping_on_device

if TYPE_CHECKING:  # importing mpi4py.MPI initializes MPI, which is slow; only needed for annotations
    from mpi4py.MPI import Intracomm

logger = logging.getLogger("struphy")

# smallest eta1 of a marker on a domain with a pole at eta1=0 (as in pusher_utilities_kernels)
POLE_ETA1_MIN = 1e-10


ORBIT_POSITIONS = (
    (0, "x", "$x$", "physical position x"),
    (1, "y", "$y$", "physical position y"),
    (2, "z", "$z$", "physical position z"),
)
"""Orbit quantities of the position columns, which post-processing maps to physical coordinates."""


[docs] class Particles(metaclass=ABCMeta): r""" Base class for particle species. The marker information is stored in a 2D array of shape ``(n_cols, n_markers_max)``, one column per marker (see :mod:`struphy.pic.marker_layout`). In ``markers[j, ip]`` the index ``ip`` refers to a specific particle, the index ``j`` to its attributes; ``n_cols`` is the number of attributes and ``n_markers_max`` the number of markers (including holes), and the attribute indices are called "columns" below and in the rest of the code. They are 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 columns, laid out in consecutive blocks (each block's starting column and width are given by a pair of attributes/properties): * :attr:`first_diagnostics_idx` (width :attr:`n_cols_diagnostics`): free columns for model-specific diagnostics (e.g. canonical momentum, magnetic moment, ...). * :attr:`first_pusher_idx` (width :attr:`n_cols_pusher` :math:`= 3 + \mathrm{vdim}`): scratch space for a :class:`~struphy.pic.pushing.pusher.Pusher` call, used to hold the phase space coordinates at the start of a push (or of a sub-stage, for multi-stage pushers). * :attr:`first_shift_idx` (width :attr:`n_cols_shift` :math:`= 3`): accumulated shifts in :math:`\eta`-space due to boundary conditions (e.g. periodic wrap-around), added back onto the pusher's initial positions when reconstructing a marker's unwrapped trajectory. * :attr:`residual_idx` (width 1): residual of the current iteration, for pushers that solve a nonlinear/implicit equation iteratively. * :attr:`first_free_idx` (width :attr:`n_cols_aux`): general-purpose scratch columns available to any routine that needs temporary per-marker storage (e.g. field evaluations). The total number of columns is given by :attr:`n_cols`, i.e. ``first_free_idx + n_cols_aux + 2`` (the ``+ 2`` accounts for the last two columns below). * ``-2``: number of the sorting box the particle is in * ``-1``: particle ID Direct indexing into ``markers`` is rarely needed outside of the ``Particles`` class itself. Instead, the most commonly used columns are exposed as convenience properties, each returning (or setting) a 2D array of shape ``(n_mks_loc, ...)`` restricted to the valid markers on the current process (i.e. ``markers[..., self.valid_mks]``): * :attr:`positions` (columns ``0:3``): marker positions :math:`\boldsymbol\eta_p`. * :attr:`velocities` (columns ``3:3 + vdim``): marker velocities. * :attr:`phasespace_coords` (columns ``0:3 + vdim``): positions and velocities combined. * :attr:`weights` (column ``3 + vdim``): current weights :math:`w_k(t)`. * :attr:`sampling_density_values` (column ``4 + vdim``): PDF :math:`s^0` at the particle position. * :attr:`weights0` (column ``5 + vdim``): initial weights :math:`w_0`. * :attr:`marker_ids` (column ``-1``): unique particle IDs. Each of these properties has a matching setter (e.g. ``self.positions = new_positions``) that validates the shape of the assigned array and writes it back into ``self._markers`` at the corresponding columns, for the valid markers only. 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. 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, ...) dry_run : bool If True, only compute the sizing of the marker array (:attr:`n_markers_max`, :attr:`n_cols`, ...) and return early, without allocating any of the (potentially large) marker/sorting/buffer arrays. Used by :attr:`nbytes_local` to estimate the memory footprint before actually allocating the particles, see :meth:`~struphy.models.variables.PICVariable.estimate_mem`. """ 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, 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, dry_run: bool = False, ): self._args_backend = xp.get_backend() 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: n_cols_diagnostics = self.default_n_cols["diagnostics"] if n_cols_aux is None: 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._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 self._n_cols_diagnostics = n_cols_diagnostics self._n_cols_aux = n_cols_aux # 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 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] # domain_array does not change after setup: one host copy for the host-side bookkeeping # (Python numbers) and the host kernels, also on the CuPy backend self._domain_array_host = xp.to_numpy(self._domain_array) # total number of cells (equal to mpi_size if no grid) n_cells = int(np.sum(np.prod(self._domain_array_host[:, 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 = math.prod(self.boxes_per_dim) * 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 # 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"] # boundary condition type per axis for the per-marker kernel apply_kinetic_bc_marker # (0: periodic, 1: reflect, 2: remove, 3: handled in Python by apply_kinetic_bc) self._bc_in_python = bc_refill is not None bc_codes = {"periodic": 0, "reflect": 1, "remove": 3 if self._bc_in_python else 2} self._bc_type = xp.array([bc_codes.get(b_c, 3) for b_c in bc], dtype=int) # pole at eta1=0 (no hole around the axis): markers move through the axis, # the boundary condition of axis 0 then applies at eta1=1 only # (see apply_kinetic_bc_marker and update_position_stage in pusher_utilities_kernels) self._pole = domain is not None and domain.pole if self._pole: if domain.params.get("poc", 1) != 1 or domain.params.get("pol_period", 1) != 1: raise NotImplementedError( "Markers on a domain with a pole need the full poloidal angle (poc=1 or pol_period=1)." ) if self._args_backend == "cupy": raise NotImplementedError("Markers on a domain with a pole are not supported on the CuPy backend yet.") # create marker array self._bufsize = bufsize self._allocate_marker_array(dry_run=dry_run) if dry_run: return 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 # initialize sorting boxes self._initialize_sorting_boxes() # 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 not isinstance(self, ParticlesSPH) and isinstance(self.bckgr_params, dict): self._generate_sampling_moments() # create buffers for mpi_sort_markers self._sorting_etas = xp.zeros((3, self.n_markers_max), dtype=float) self._is_on_proc_domain = xp.zeros((3, self.n_markers_max), dtype=bool) self._can_stay = xp.zeros(self.n_markers_max, 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 # Domain decomposition is static for the lifetime of this object, so the # neighbour/non-neighbour split used by _sendrecv_get_destinations only needs # computing once here rather than on every mpi_sort_markers call. self._neighbor_ranks, self._non_neighbor_ranks = self._compute_neighbor_ranks() # post init self.__post_init__() # ---------------- # Abstract methods # ---------------- @property @abstractmethod def vdim(self): """Dimension of the velocity space.""" pass @property @abstractmethod def coordinate_labels(self) -> tuple[str]: """Labels for the coordinates in the phase space. Length must be 3 + vdim, where the first 3 are the spatial coordinates and the last vdim are the velocity coordinates.""" pass orbit_quantities: tuple[tuple[int, str, str, str], ...] = () """Marker columns saved in the ``orbits`` post-processing product, as ``(column, name, long_name, description)``. The first three are :data:`ORBIT_POSITIONS`; the marker index becomes the ``marker`` coordinate of the product.""" @property @abstractmethod def mu_idx(self): """Index of the column in the marker array where the magnetic moment is stored.""" pass @property @abstractmethod def default_background(cls): """The default background (of type Maxwellian).""" pass @property @abstractmethod def default_n_cols(self): "Dictionary of the form {'diagnostics': 3, 'aux': 12} for default number of columns." pass
[docs] @abstractmethod def __post_init__(self): """Can be used for checks on the constructor arguments and for setting additional attributes in subclasses.""" pass
@property @abstractmethod def sampling_density(self): """Marker sampling density function :math:`s^\textrm{vol}` as a volume form, see :ref:`monte_carlo`. Must be normalized to 1. Its coordinates are the coordinates used in Monte-Carlo Integrals approximated by the particles.""" pass
[docs] @abstractmethod def s0(self, eta1, eta2, eta3, *v, flat_eval=False, remove_holes=True): r"""0-form corresponding to :ref:`~struphy.pic.base.Particles.sampling_density`. This is the quantity stored in each marker's ``s0`` column (see the class docstring) and used to compute initial weights ``w0 = f_init / s0 / Np``.""" pass
# ------------------------------------ # Columns and indexing of marker array # ------------------------------------ @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): """Number of quantities ("columns") per marker, the length of the first axis of the 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_markers_max(self): """Number of marker slots (columns of :attr:`markers`), holes included: the most markers this process can hold.""" if not hasattr(self, "_n_markers_max"): input("\nWarning: marker array not yet created, creating now ...") self._allocate_marker_array() return self._n_markers_max @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 nbytes_local(self) -> int: """Estimated local (per-MPI-rank) memory footprint, in bytes, of all marker-related arrays (markers, sorting buffers, lost-marker container). Only depends on :attr:`n_markers_max` and :attr:`n_cols`, so it is valid whether or not the arrays were actually allocated (see the ``dry_run`` argument of :meth:`__init__`).""" float_size = 8 # dtype=float bool_size = 1 # dtype=bool n_markers_max = self.n_markers_max n_cols = self.n_cols nbytes = 0 nbytes += n_markers_max * n_cols * float_size # markers nbytes += n_markers_max * 3 * float_size # sorting_etas (mpi_sort_markers buffer) nbytes += n_markers_max * 3 * bool_size # is_on_proc_domain nbytes += n_markers_max * bool_size # can_stay # holes, ghost_particles, valid_mks, is_outside_right, is_outside_left, is_outside nbytes += n_markers_max * bool_size * 6 nbytes += int(n_markers_max * 0.5) * 10 * float_size # lost_markers return int(nbytes) @property def kinds(self): """Name of the class.""" return self.__class__.__name__ @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["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 out["mu"] = self.mu_idx # magnetic moment return out @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 # ------------------------ # Marker parameter classes # ------------------------ @property def loading_params(self) -> LoadingParameters: """Parameters for particle loading.""" return self._loading_params @property def weights_params(self) -> WeightsParameters: """Parameters for particle weights.""" 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 saving_params(self) -> SavingParameters: """Parameters for marker/distribution function saving.""" return self._saving_params @property def loading(self) -> LiteralOptions.OptsLoading: """Type of particle loading.""" return self.loading_params.loading @property def spatial(self): """Drawing particles uniformly on the unit cube('uniform') or on the disc('disc')""" return self.loading_params.spatial @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.boundary_params.bc_refill @property def bc_sph(self): """List of boundary conditions for sph evaluation in each direction.""" return self._bc_sph # ----------------------------------------- # MPI decomposition and clone configuration # ----------------------------------------- @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 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.sorting_params.dims_mask @property def nprocs(self): """Number of MPI processes in each dimension.""" return self._nprocs # -------------------------------- # Markers and their data structure # -------------------------------- @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 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 array of shape ``(n_cols, n_markers_max)`` holding the marker information, including holes. The i-th column holds the i-th marker info (see :mod:`struphy.pic.marker_layout`). ===== ============== ======================= ======= ====== ====== ========== === === === 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 row 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 column 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 column holds the i-th marker info.""" return self.markers[:, self.valid_mks] @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 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 @property def n_mks_loc(self): """Number of valid markers on process (without holes and ghosts).""" 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 of shape ``(3, n_mks_loc)`` holding the marker positions in logical space. The i-th column holds the i-th marker info.""" return self.markers[self.index["pos"], self.valid_mks] @positions.setter def positions(self, new): assert isinstance(new, xp.ndarray) assert new.shape == (3, self.n_mks_loc) self._markers[self.index["pos"], self.valid_mks] = new @property def velocities(self): """Array of shape ``(vdim, n_mks_loc)`` holding the marker velocities in logical space. The i-th column holds the i-th marker info.""" return self.markers[self.index["vel"], self.valid_mks] @velocities.setter def velocities(self, new): assert isinstance(new, xp.ndarray) assert new.shape == (self.vdim, self.n_mks_loc), f"{self.n_mks_loc =} and {self.vdim =} but {new.shape =}" self._markers[self.index["vel"], self.valid_mks] = new @property def phasespace_coords(self): """Array of shape ``(3 + vdim, n_mks_loc)`` holding the marker positions and velocities in logical space. The i-th column holds the i-th marker info.""" return self.markers[self.index["coords"], self.valid_mks] @phasespace_coords.setter def phasespace_coords(self, new): assert isinstance(new, xp.ndarray) assert new.shape == (3 + self.vdim, self.n_mks_loc) self._markers[self.index["coords"], self.valid_mks] = new @property def weights(self): """Array holding the current marker weights. The i-th entry holds the i-th marker info.""" return self.markers[self.index["weights"], self.valid_mks] @weights.setter def weights(self, new): assert isinstance(new, xp.ndarray) assert new.shape == (self.n_mks_loc,) self._markers[self.index["weights"], self.valid_mks] = new @property def sampling_density_values(self): """Array holding the current marker 0form sampling density s0. The i-th entry holds the i-th marker info.""" return self.markers[self.index["s0"], self.valid_mks] @sampling_density_values.setter def sampling_density_values(self, new): assert isinstance(new, xp.ndarray) assert new.shape == (self.n_mks_loc,) self._markers[self.index["s0"], self.valid_mks] = new @property def weights0(self): """Array holding the initial marker weights. The i-th entry holds the i-th marker info.""" return self.markers[self.index["w0"], self.valid_mks] @weights0.setter def weights0(self, new): assert isinstance(new, xp.ndarray) assert new.shape == (self.n_mks_loc,) self._markers[self.index["w0"], self.valid_mks] = new @property def marker_ids(self): """Array holding the marker id's on the current process.""" return self.markers[self.index["ids"], self.valid_mks] @marker_ids.setter def marker_ids(self, new): assert isinstance(new, xp.ndarray) assert new.shape == (self.n_mks_loc,) self._markers[self.index["ids"], self.valid_mks] = new @property def f_coords(self): """Coordinates of the distribution function.""" return self.markers[self.f_coords_index, self.valid_mks] @f_coords.setter def f_coords(self, new): assert isinstance(new, xp.ndarray) self.markers[self.f_coords_index, self.valid_mks] = new @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.f_jacobian_coords_index, ~self.holes)] else: return self.markers[self.f_jacobian_coords_index, ~self.holes] @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.f_jacobian_coords_index, ~self.holes, ) ] = new else: self.markers[self.f_jacobian_coords_index, ~self.holes] = new @property def args_markers(self) -> MarkerArguments | CudaMarkerArguments: """Arguments for marker kernels, selected when this particle object is created.""" return self._args_markers
[docs] def kernels(self) -> tuple: """The kernels these particles call when they apply their boundary conditions and sort into boxes. :func:`~struphy.pic.pushing.kernels.reflect.reflect` if an axis has reflecting boundary conditions, and the box kernels of :mod:`struphy.pic.kernels` if there are sorting boxes. Used by :meth:`~struphy.simulation.sim.Simulation.compile_cuda_kernels`. """ kernels = (reflect,) if self._reflect_axes else () if self._sorting_boxes is not None: kernels += (box_particles, fill_boxes, sort_and_fill_boxes, box_and_sort_particles) return kernels
# ------------------------------------------- # Initial condition and background -> weights # ------------------------------------------- @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 reject_weights(self): """Whether to reect weights below threshold.""" return self.weights_params.reject_weights @property def threshold(self): """Threshold for rejecting weights.""" return self.weights_params.threshold @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: """Callable background distribution function, used as the control variate in :meth:`~struphy.pic.base.Particles.update_weights`.""" assert hasattr(self, "_f0"), AttributeError( "No background distribution available, please run self._set_background_function()", ) return self._f0 @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 control_variate(self): """Boolean for whether to use the :ref:`control_var` during time stepping.""" return self.weights_params.control_variate # ---------------- # Particle sorting # ---------------- @property def boxes_per_dim(self): """Tuple, number of sorting boxes per dimension.""" return self._boxes_per_dim @property def sorting_boxes(self): """The :class:`~struphy.pic.sorting.SortingBoxes` instance holding the sorting-box data structure used by :meth:`~struphy.pic.base.Particles.put_particles_in_boxes` and :meth:`~struphy.pic.base.Particles.do_sort`.""" 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 # ------- # Physics # ------- @property def equation_params(self): """Parameters appearing in model equation due to Struphy normalization.""" return self._equation_params @property def domain(self) -> Domain: """From :mod:`struphy.geometry.domains`.""" return self._domain @property def equil(self) -> FluidEquilibrium: """From :mod:`struphy.fields_background.equils`.""" return self._equil @property def projected_equil(self) -> ProjectedFluidEquilibrium: """MHD equilibrium projected on 3d Derham sequence with commuting projectors.""" return self._projected_equil # ================= # ================= # Public methods # ================= # =================
[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 Particles5Dvperp, 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`. On several processes, the markers are always sent to the process of their domain after drawing (:meth:`mpi_sort_markers`). They are also sorted into boxes if sort is True (default) and sorting boxes were set up. Parameters ---------- sort : Bool Whether to sort the markers into boxes after initial drawing (only if sorting boxes were set up). """ import scipy.special as sp from struphy.pic.particles import Particles5D, Particles5Dvperp, Particles6D, ParticlesSPH # 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); every column can hold # a loaded marker until they are counted below (restart files can have markers in any column) self._markers[:, n_mks_load_loc:] = -1.0 self._set_n_markers_used(self.n_markers_max) # 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"{'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}{type(self).__name__}") 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(n_mks_load_loc, n_mks_load_cum_sum) elif self.loading == "restart": self._load_restart() elif self.loading == "tesselation": self._load_tesselation() if isinstance(self, ParticlesSPH): self._set_initial_condition() self.velocities = xp.array(self.u_init(self.positions)) # 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) # one column per particle; drawn row by row, so that the random numbers of a particle are # consecutive in the stream temp = xp.random.rand(num_to_add_glob, 3 + self.vdim).T # inversion method for drawing uniformly on the disc, before the domain check if self.spatial == "disc": temp[0] = xp.sqrt(temp[0]) # check which particles are on the current process domain is_on_proc_domain = self._is_on_domain_of_rank(temp[:3], self.mpi_rank) valid_idx = xp.nonzero(xp.all(is_on_proc_domain, axis=0))[0] valid_particles = temp[:, valid_idx] valid_particles = xp.array_split(valid_particles, self.num_clones, axis=1)[self.clone_id] num_valid = valid_particles.shape[1] # Add the valid particles to the phasespace_coords array self._markers[ : 3 + self.vdim, num_loaded_particles_loc : num_loaded_particles_loc + num_valid, ] = 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 and Np_per_clone self.gather_scalar_in_subcomm_array(num_loaded_particles_loc, out=self.n_mks_load) self.gather_scalar_in_intercomm_array(int(xp.sum(self.n_mks_load)), out=self.Np_per_clone) n_mks_load_loc = self.n_mks_load[self.mpi_rank] n_mks_load_cum_sum = xp.cumsum(self.n_mks_load) # recompute first marker ID from the actual number of loaded markers 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] # 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": # generated on the host, then copied to the device (setup only) self.phasespace_coords = xp.to_cunumpy( sobol_seq.i4_sobol_generate( 3 + self.vdim, int(n_mks_load_loc), 1000 + int((n_mks_load_cum_sum - self.n_mks_load)[self._mpi_rank]), ).T ) # 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.', ) # each sobol point yields 64 symmetric markers; round up and truncate the last group n_sobol = -(-self.n_mks_load // 64) temp_markers = xp.to_cunumpy( sobol_seq.i4_sobol_generate( 3 + self.vdim, int(n_sobol[self._mpi_rank]), 1000 + int((xp.cumsum(n_sobol) - n_sobol)[self._mpi_rank]), ) ) temp_symmetric = xp.zeros((3 + self.vdim, 64 * n_sobol[self._mpi_rank]), dtype=float) sampling_kernels.set_particles_symmetric_3d_3v( temp_markers, temp_symmetric, ) self._markers[: 3 + self.vdim, :n_mks_load_loc] = temp_symmetric[:, :n_mks_load_loc] # 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 isinstance(self, ParticlesSPH): self._set_initial_condition() self.velocities = xp.array(self.u_init(self.positions)) 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[3 : 3 + self.vdim, :n_mks_load_loc] = xp.clip( self._markers[3 : 3 + self.vdim, :n_mks_load_loc], eps, 1.0 - eps, ) u_mean = xp.array(self.loading_params.moments[: self.vdim]) v_th = xp.array(self.loading_params.moments[self.vdim :]) B0 = self.loading_params.B0 # Particles6D: (1d Maxwellian, 1d Maxwellian, 1d Maxwellian) if isinstance(self, Particles6D): self.velocities = ( sp.erfinv( 2 * self.velocities - 1, ) * xp.sqrt(2) * v_th[:, None] + u_mean[:, None] ) # Particles5D: (1d Maxwellian, muB0-Maxwellian as volume-form) elif isinstance(self, Particles5D): self._markers[3, :n_mks_load_loc] = ( sp.erfinv( 2 * self.velocities[0] - 1, ) * xp.sqrt(2) * v_th[0] + u_mean[0] ) self._markers[4, :n_mks_load_loc] = -xp.log(1.0 - self.velocities[1]) * v_th[1] ** 2 / B0 # mu is a magnetic moment and must be >= 0. # A mean shift in this coordinate is not physically consistent. 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." ) # Particles5Dvperp: (1d Maxwellian, polar Maxwellian as volume-form) elif isinstance(self, Particles5Dvperp): self._markers[3, :n_mks_load_loc] = ( sp.erfinv( 2 * self.velocities[0] - 1, ) * xp.sqrt(2) * v_th[0] + u_mean[0] ) self._markers[4, :n_mks_load_loc] = ( 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. if abs(float(u_mean[1])) > 0.0: raise ValueError( "For Particles5Dvperp, 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 (done above for pseudo-random markers) if self.spatial == "disc": if self.loading != "pseudo_random": self._markers[0, :n_mks_load_loc] = xp.sqrt( self._markers[0, :n_mks_load_loc], ) 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[-1, counter]): for j in range(3 + self.vdim): if specific_markers[i][j] is not None: self._markers[j, counter] = 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:]) # keep markers off a pole at eta1=0, where the mapping is singular (holes have eta1=-1) if self._pole: eta1 = self._markers[0] eta1[xp.logical_and(eta1 >= 0.0, eta1 < POLE_ETA1_MIN)] = POLE_ETA1_MIN # the loaded markers occupy the first columns (fresh loading) or any columns (restart) self.count_markers_used() # send the markers to the process of their domain: needed on several processes whether or not there are # sorting boxes, since accumulations before the first push (e.g. an initial Poisson solve) assume it (#764); # not for restart loading without boxes (the holes of the restart markers array are not updated yet) sort_boxes = self._initialized_sorting and sort if self.mpi_comm is not None and (sort_boxes or (self.mpi_size > 1 and self.loading != "restart")): logger.info("\nSending the markers to their processes after initial draw") self.mpi_sort_markers() if sort_boxes: logger.info("\nSorting the markers into boxes after initial draw") self.do_sort() logger.info("Done.") elif self.mpi_comm is not None and self.loading in ("sobol_standard", "sobol_antithetic"): # Sobol markers are drawn on the whole domain on every process (pseudo-random ones only on the # process domain): send them to their process, otherwise the first accumulation (e.g. the initial # Poisson solve) writes outside the local arrays and corrupts the heap. self.mpi_sort_markers()
[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. """ from struphy.pic.particles import ParticlesSPH 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." if self.n_mks_loc == 0: return # 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 not isinstance(self, ParticlesSPH): self._set_initial_condition() # if isinstance(self.f_init, CanonicalMaxwellian): # self.save_constants_of_motion() # evaluate initial distribution function if isinstance(self, ParticlesSPH): f_init = self.f_init(self.positions) else: f_init = self.f_init(*self.f_coords) # 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, ) # compute s0 and save at vdim + 4 self.sampling_density_values = self.s0(*self.phasespace_coords, flat_eval=True) # compute w0 and save at vdim + 5 self.weights0 = f_init / self.sampling_density_values / 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. """ from struphy.pic.particles import ParticlesSPH if self.n_mks_loc == 0: return if isinstance(self, ParticlesSPH): f0 = self.f0.n0(self.positions) else: # in case of CanonicalMaxwellian, evaluate constants_of_motion # if isinstance(self.f0, CanonicalMaxwellian): # self.save_constants_of_motion() f0 = self.f0(*self.f_coords) # 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) self.weights = self.weights0 - f0 / self.sampling_density_values / self.Np
[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. ``xp.histogramdd`` (NumPy or CuPy, on the device of the markers) 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 (default: True). Returns ------- f_slice : array-like The reconstructed full-f distribution function. df_slice : array-like The reconstructed delta-f distribution function. """ # components is a host list: counted with Python, not with xp (CuPy rejects lists) assert sum(bool(c) for c in 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 = list(components) + [False] * (self.n_cols - _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 # the indices of the valid markers, found once; below, only the rows (quantities) that are used are gathered # for them, not all quantities of the markers. Everything stays on the device of the markers. valid = xp.nonzero(self.valid_mks)[0] if quantity != "density": velocities = self.markers[self.index["vel"], valid] # determine histogram weights multiplier if quantity == "density": multiplier = 1 elif quantity == "current": multiplier = velocities[v_axis[0]] elif quantity == "energy_tensor": multiplier = velocities[v_axis[0]] * velocities[v_axis[1]] elif quantity == "heat_flux": velocity_norm2 = xp.linalg.norm(velocities, axis=0) ** 2 multiplier = velocity_norm2 * velocities[v_axis[0]] # compute weights of histogram: _weights0 = self.markers[self.index["w0"], valid] * self.Np * multiplier _weights = self.markers[self.index["weights"], valid] * self.Np * multiplier if divide_by_jac: jacobian_det = self.domain.jacobian_det(self.markers[self.index["pos"], valid], remove_outside=False) _weights /= jacobian_det # _weights /= self.velocity_jacobian_det(*self.phasespace_coords) _weights0 /= jacobian_det # _weights0 /= self.velocity_jacobian_det(*self.phasespace_coords) # the binned quantities, one by one (an index list would have to be uploaded to the device first) sample = xp.stack([self.markers[i, valid] for i, binned in enumerate(slicing) if binned], axis=1) f_slice = xp.histogramdd( sample, bins=bin_edges, weights=_weights0, )[0] df_slice = xp.histogramdd( sample, 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: list[bool], bin_edges: list[np.ndarray], do_plot=False): """ 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 3+vdim giving the directions in phase space in which to bin. Up to two entries can be True, the rest must be False. The True entries correspond to the axes of the binning. bin_edges : list[np.ndarray] List of bin edges (resolution) having the length of True entries in components. do_plot : bool Whether to show the plot (default: False). Returns ------- err : float Maximum relative error between the binned distribution function and the analytic initial condition. """ import matplotlib.pyplot as plt assert len(components) == 3 + self.vdim, f"components must be of length {3 + self.vdim}, is {len(components)}." n_dim = np.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] indices = np.nonzero(components)[0] if n_dim == 1: plt.plot(bin_centers[0], f_slice, linewidth=2, label="binned f") i = int(indices[0]) resol = bin_centers[0] integrate_resol = [0.5, 0.5, 0.5] + [32] * self.vdim integrate_resol[i] = None if i < 3: v_lim = 5 else: v_lim = bin_edges[0][-1] f_init, pts, _, _ = self.f_init.reduced_eval( dim_1=i, v_lim=v_lim, resol=resol, integrate_resol=integrate_resol ) if do_plot: plt.plot(pts, f_init, "r--", label="analytic initial condition") plt.xlabel(self.coordinate_labels[i]) plt.ylabel("f") plt.legend() else: i = int(indices[0]) j = int(indices[1]) if do_plot: plt.subplot(1, 2, 1) plt.contourf(bin_centers[0], bin_centers[1], f_slice.T, levels=20) plt.colorbar() plt.xlabel(self.coordinate_labels[i]) plt.ylabel(self.coordinate_labels[j]) plt.title("Binned f") resol = tuple(bin_centers) integrate_resol = [0.5, 0.5, 0.5] + [100] * self.vdim integrate_resol[i] = None integrate_resol[j] = None v_lim = [5, 5] if i > 2: v_lim[0] = bin_edges[0][-1] if j > 2: v_lim[1] = bin_edges[1][-1] v_lim = tuple(v_lim) f_init, pts1, pts2, _ = self.f_init.reduced_eval( dim_1=i, dim_2=j, v_lim=v_lim, resol=resol, integrate_resol=integrate_resol, ) if do_plot: plt.subplot(1, 2, 2) plt.contourf(pts1, pts2, f_init.T, levels=20) plt.colorbar() plt.xlabel(self.coordinate_labels[i]) plt.ylabel(self.coordinate_labels[j]) plt.title("Analytic initial condition") if do_plot: plt.show() err = np.max(np.abs(f_init - f_slice)) / np.max(f_init) return err
[docs] @profile @ProfileManager.profile("mpi_sort_markers") 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 ---------- apply_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. Notes ----- On the CuPy backend, the markers are exchanged as device buffers with CUDA-aware MPI (staged through host memory otherwise), see :meth:`_sendrecv_markers_device`. The per-rank counts are small host arrays on every backend (:meth:`_sendrecv_all_to_all`). """ 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) # the sent markers are holes now, the received ones filled the lowest holes (host counts, no device read) n_received = int(np.sum(recv_info)) - int(recv_info[self.mpi_rank]) self._track_markers(n_emptied=int(send_inds.size), n_filled=n_received) # new holes and new number of holes and markers on process self.update_holes() # refresh ghost mask: received markers may land in columns 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: # eta = 1 is sorted like eta = 0 in periodic directions (see _sendrecv_determine_mtbs) etas = self.positions for axis in self._periodic_axes: etas[axis] %= 1.0 all_on_right_proc = xp.all(self._is_on_domain_of_rank(etas, self.mpi_rank)) 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] @profile @ProfileManager.profile("apply_kinetic_bc") 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. """ # pole at eta1=0: markers with eta1 < 0 have crossed the axis (as in apply_kinetic_bc_marker) if self._pole: self._find_outside_particles(0) crossed = xp.nonzero(self._is_outside_left)[0] self.markers[0, crossed] *= -1.0 self.markers[1, crossed] = (self.markers[1, crossed] + 0.5) % 1.0 near_pole = xp.logical_and(self.valid_mks, self.markers[0] < POLE_ETA1_MIN) self.markers[0, near_pole] = POLE_ETA1_MIN # apply boundary conditions if self._remove_axes: # snapshot of the (3, n_markers_max) logical coordinates for the loop over the axes # (see _find_outside_particles) self._eta_bc_buf[:, : self.n_markers_used] = self.markers[:3, : self.n_markers_used] for axis in self._remove_axes: outside_inds = self._find_outside_particles(axis, eta=self._eta_bc_buf) if len(outside_inds) == 0: continue if self.bc_refill is not None: self._particle_refilling() self._markers[:-1, self._is_outside] = -1.0 n_removed = len(xp.nonzero(self._is_outside)[0]) self._n_lost_markers += n_removed self._track_markers(n_emptied=n_removed) # removed markers are holes now and refilled markers have moved: refresh both, # such that a marker outside on several axes is only counted (and refilled) once self.update_holes() self._eta_bc_buf[:, outside_inds] = self.markers[:3, outside_inds] if self._periodic_axes: self._eta_bc_buf[:, : self.n_markers_used] = self.markers[:3, : self.n_markers_used] for axis in self._periodic_axes: outside_inds = self._find_outside_particles(axis, eta=self._eta_bc_buf) if len(outside_inds) == 0: continue self.markers[axis, outside_inds] = self.markers[axis, outside_inds] % 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[self.first_pusher_idx + 3 + self.vdim + axis, outside_right_inds] += 1.0 self.markers[self.first_pusher_idx + 3 + self.vdim + axis, outside_left_inds] += -1.0 else: self.markers[self.first_pusher_idx + 3 + self.vdim + axis, : self.n_markers_used] = 0.0 self.markers[self.first_pusher_idx + 3 + self.vdim + axis, outside_right_inds] = 1.0 self.markers[self.first_pusher_idx + 3 + self.vdim + axis, outside_left_inds] = -1.0 # put all coordinate inside the unit cube (avoid wrong Jacobian evaluations) outside_inds_per_axis = {} if self._reflect_axes: self._eta_bc_buf[:, : self.n_markers_used] = self.markers[:3, : self.n_markers_used] for axis in self._reflect_axes: outside_inds = self._find_outside_particles(axis, eta=self._eta_bc_buf) self.markers[axis, self._is_outside_left] *= -1.0 self.markers[axis, self._is_outside_right] *= -1.0 self.markers[axis, self._is_outside_right] += 2.0 self.markers[self.first_pusher_idx, self._is_outside] = -1.0 outside_inds_per_axis[axis] = outside_inds for axis in self._reflect_axes: if len(outside_inds_per_axis[axis]) == 0: continue # only Cartesian velocities are reflected (columns 3:6 hold other data for vdim != 3) if self.vdim != 3: continue # flip velocity outside_inds = outside_inds_per_axis[axis] reflect(self.markers, self.domain.args_domain, outside_inds, axis, n_threads=len(outside_inds))
[docs] def finish_kernel_bc(self, newton=False): """Bookkeeping after a pusher kernel that applied the kinetic boundary conditions per marker (see :func:`~struphy.pic.pushing.pusher_utilities_kernels.apply_kinetic_bc_marker`). Refilling is not done in the kernel and is applied here by :meth:`apply_kinetic_bc`. Markers removed in the kernel have become holes; they are counted as lost markers. Parameters ---------- newton : bool Whether the shift due to boundary conditions should be computed for a Newton step or for a standard (explicit or Picard) step. """ if self._bc_in_python: self.apply_kinetic_bc(newton=newton) self.update_holes() elif self._remove_axes: n_holes_before = xp.count_nonzero(self.holes) self.update_holes() n_removed = int(xp.count_nonzero(self.holes) - n_holes_before) self._n_lost_markers += n_removed self._track_markers(n_emptied=n_removed)
[docs] def update_holes(self): """Recompute the :attr:`~struphy.pic.base.Particles.holes` mask (markers with ``markers[0] == -1``) and, from it, refresh :attr:`~struphy.pic.base.Particles.valid_mks`. Must be called after any operation that creates, removes or moves markers (e.g. sorting, boundary conditions, refilling), since holes are tracked per marker index. The markers (columns) from :attr:`n_markers_used` on are holes and are not read.""" n = self._n_markers_used self._holes[:n] = self.markers[0, :n] == -1.0 self._holes[n:] = True self._update_valid_mks()
@property def n_markers_used(self) -> int: """Number of marker slots (columns of :attr:`markers`, out of :attr:`n_markers_max`) that can hold markers: every slot from this one on is a hole. Kernels loop over these markers only (``args_markers.n_markers``), and so do the row operations of the pushers and the boundary conditions (``markers[j, :n_markers_used]``). It is an upper bound kept on the host, so that it never waits for the device: markers always fill the lowest holes, so ``k`` incoming markers end below (occupied slots) + ``k`` (see :meth:`_track_markers`). Loading and box sorting count it exactly (:meth:`count_markers_used`), and so must code that writes markers into holes itself, outside the methods of this class. """ return self._n_markers_used def _set_n_markers_used(self, n_markers_used: int): """Set :attr:`n_markers_used`, and the number of markers the kernels loop over.""" self._n_markers_used = min(int(n_markers_used), self.n_markers_max) self._args_markers.n_markers = self._n_markers_used
[docs] def count_markers_used(self): """Set :attr:`n_markers_used` to one past the last column that is not a hole, and count the occupied columns. Called after loading and after box sorting; call it, before :meth:`update_holes`, after writing markers into holes outside the methods of this class. Reads two numbers from the device on the CuPy backend.""" occupied = xp.nonzero(self.markers[0] != -1.0)[0] n_occupied = int(occupied.size) self._n_occupied_bound = n_occupied self._set_n_markers_used(int(occupied[-1]) + 1 if n_occupied > 0 else 0)
def _track_markers(self, n_emptied: int = 0, n_filled: int = 0): """Update :attr:`n_markers_used` on the host after ``n_emptied`` occupied columns became holes and then ``n_filled`` markers were written into the lowest holes. At most :attr:`_n_occupied_bound` columns are occupied, so the ``n_filled`` lowest holes lie below (occupied columns) + ``n_filled``. Holes created without a count (e.g. removed ghost particles) leave the bound too high, which is safe. """ occupied = max(0, self._n_occupied_bound - int(n_emptied)) if n_filled > 0: self._set_n_markers_used(max(self._n_markers_used, occupied + int(n_filled))) self._n_occupied_bound = min(self.n_markers_max, occupied + int(n_filled))
[docs] def set_velocities_comp(self, velocity, comp): """Set one or several velocity components to the same constant value, for all valid markers. Parameters ---------- velocity : float The constant value to assign to the selected velocity components. comp : iterable[int] Velocity components to set (0-based, e.g. 0 for v1, 1 for v2, ...). """ for c in comp: self._markers[3 + c, self.valid_mks] = velocity
[docs] @profile @ProfileManager.profile("put_particles_in_boxes") 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 neighbouring processes are also communicated (as ghost particles).""" self._remove_ghost_particles() max_in_box = box_particles( self.markers, self.holes, self._sorting_boxes.nx, self._sorting_boxes.ny, self._sorting_boxes.nz, self._domain_array_host[self.mpi_rank], self._sorting_boxes._boxes, self._sorting_boxes._next_index, self._sorting_boxes.box_index, ) self._check_box_capacity(max_in_box) if self.sorting_boxes.communicate: self._communicate_boxes() self._fill_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] @profile @ProfileManager.profile("do_sort") def do_sort(self, use_numpy_argsort=False): """Assign the particles to their sorting boxes and reorder the markers array accordingly, so that markers in the same box occupy contiguous columns. The box array :attr:`~struphy.pic.sorting.SortingBoxes.boxes` is rebuilt for the sorted markers, so it stays valid after the sort. Parameters ---------- use_numpy_argsort : bool If True, sort via :func:`numpy.argsort` on the box row; if False (default), use the kernel :func:`~struphy.pic.kernels.sort_and_fill_boxes.sort_and_fill_boxes`. """ boxes = self._sorting_boxes if use_numpy_argsort: self.put_particles_in_boxes() self._sort_boxed_particles_numpy() self.update_holes() self._fill_boxes() elif boxes.communicate: # SPH: the ghost particles are exchanged between assigning the boxes and sorting self.put_particles_in_boxes() sort_and_fill_boxes( self._markers, self._holes, boxes._boxes, boxes._next_index, boxes._cumul_next_index, boxes._swap_line_1, boxes._swap_line_2, boxes.box_index, ) else: # assign the boxes, sort and rebuild the box array in one kernel call # (on the CuPy backend one host round trip of the markers, #694) self._remove_ghost_particles() max_in_box = box_and_sort_particles( self._markers, self._holes, boxes.nx, boxes.ny, boxes.nz, self._domain_array_host[self.mpi_rank], boxes._boxes, boxes._next_index, boxes._cumul_next_index, boxes._swap_line_1, boxes._swap_line_2, boxes.box_index, ) self._check_box_capacity(max_in_box) # The markers have just been reordered. The masks are per marker index, # so they must be rebuilt before any later use of valid_mks/f_coords; # the holes may have moved anywhere, so the used columns are counted again. self.count_markers_used() self.update_holes() self._update_ghost_particles() self._update_valid_mks()
[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 the Pyccel kernel `sph_mean_velocity_coeffs` 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 self.put_particles_in_boxes() sph_mean_velocity_coeffs( xp.array((0.0, 0.0, 0.0)), xp.array((first_free_idx, first_free_idx + 1, first_free_idx + 2), dtype=int), self.args_markers, self.domain.args_domain, self.sorting_boxes.boxes, self.sorting_boxes.neighbours, self.holes, self.boundary_params.bc_sph[0] == "periodic", self.boundary_params.bc_sph[1] == "periodic", self.boundary_params.bc_sph[2] == "periodic", self.ker_dct()[kernel_type], h1, h2, 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 in Piola form). It then evaluates the necessary logical derivatives via :meth:`eval_sph`, sums contributions and divides by the Jacobian determinant to produce the three Cartesian divergence components. """ first_free_idx = self.args_markers.first_free_idx self.put_particles_in_boxes() # 1st kernel sph_mean_velocity_coeffs( xp.array((0.0, 0.0, 0.0)), xp.array((first_free_idx, first_free_idx + 1, first_free_idx + 2), dtype=int), self.args_markers, self.domain.args_domain, self.sorting_boxes.boxes, self.sorting_boxes.neighbours, self.holes, self.boundary_params.bc_sph[0] == "periodic", self.boundary_params.bc_sph[1] == "periodic", self.boundary_params.bc_sph[2] == "periodic", self.ker_dct()[kernel_type], h1, h2, h3, ) # 2nd kernel sph_viscosity_tensor( xp.array((0.0, 0.0, 0.0)), xp.arange(first_free_idx + 3, first_free_idx + 12, dtype=int), self.args_markers, self.domain.args_domain, self.sorting_boxes.boxes, self.sorting_boxes.neighbours, self.holes, self.boundary_params.bc_sph[0] == "periodic", self.boundary_params.bc_sph[1] == "periodic", self.boundary_params.bc_sph[2] == "periodic", self.ker_dct()[kernel_type], h1, h2, h3, 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, ) ] # the stored tensor is in Piola form, divide by the Jacobian determinant at the evaluation points etas = xp.stack([xp.ravel(eta1), xp.ravel(eta2), xp.ravel(eta3)], axis=0) det_df = self.domain.jacobian_det(etas, remove_outside=False).reshape(xp.shape(eta1)) gamma_x = (gamma[0][0] + gamma[0][1] + gamma[0][2]) / det_df gamma_y = (gamma[1][0] + gamma[1][1] + gamma[1][2]) / det_df gamma_z = (gamma[2][0] + gamma[2][1] + gamma[2][2]) / det_df return gamma_x, gamma_y, gamma_z
[docs] @classmethod def ker_dct(self): """Dict mapping the name of each available SPH smoothing kernel (e.g. ``"gaussian_1d"``) to its integer kernel ID, used in the Pyccel kernels. Kernel IDs must have three digits, see :meth:`~struphy.pic.smoothing_kernels.smoothing_kernel`.""" 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, }
[docs] 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: # own entry set beforehand, so the result does not depend on how the communicator fills the buffer gathered = np.zeros(self.mpi_size, dtype=int) gathered[self.mpi_rank] = scalar self.mpi_comm.Allgather( np.array([scalar], dtype=int), gathered, ) _tmp[:] = xp.asarray(gathered) return _tmp
[docs] 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: # own entry set beforehand, so the result does not depend on how the communicator fills the buffer gathered = np.zeros(self.num_clones, dtype=int) gathered[self.clone_id] = scalar self.clone_config.inter_comm.Allgather( np.array([scalar], dtype=int), gathered, ) _tmp[:] = xp.asarray(gathered) return _tmp
# ================= # ================= # Private methods # ================= # ================= def _update_valid_mks(self): """Refresh :attr:`~struphy.pic.base.Particles.valid_mks`: a row is a valid marker if and only if it is neither a hole nor a ghost particle.""" self._valid_mks[:] = ~xp.logical_or(self.holes, self.ghost_particles) 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[3*n + 0, i] holds the LEFT domain boundary of process i in direction eta_(n+1). - arr[3*n + 1, i] holds the RIGHT domain boundary of process i in direction eta_(n+1). - arr[3*n + 2, i] 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(xp.array(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): """Set :attr:`~struphy.pic.base.Particles.f0` to :attr:`~struphy.pic.base.Particles.background`.""" self._f0 = self.background def _set_background_coordinates(self): """Set the default marker-array column indices at which :attr:`f0` and its Jacobian determinant are evaluated (both default to ``self.index["coords"]``, the phase-space coordinate columns).""" 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 = int(np.prod(self._domain_array_host[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 = int(np.sum(np.prod(self._domain_array_host[:, 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 if self.spatial == "disc": # markers drawn uniformly on the disc have the density 2*eta1 in eta1 # (more markers on processes at larger eta1) d = self.domain_array[self.mpi_rank] n_mks_loc = max(1, int(_n_mks_load_tot * (d[1] ** 2 - d[0] ** 2) * (d[4] - d[3]) * (d[7] - d[6]))) else: n_mks_loc = int(_ppc * n_cells_loc) n_mks_load = self.gather_scalar_in_subcomm_array(n_mks_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, dry_run: bool = False): """Create marker array :attr:`~struphy.pic.base.Particles.markers`. If dry_run is True, only :attr:`n_markers_max` (and :attr:`n_cols`) are computed and no array is actually allocated; see :attr:`nbytes_local`.""" 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; # one column per marker, every row starting on a 256-byte boundary (see struphy.pic.marker_layout) self._n_markers_max = padded_n_markers(round(float(n_mks_load_loc * (1 + bufsize)))) # Have at least 3 spare places in markers array assert self.first_free_idx + 2 < self.n_cols - 2, ( f"{self.first_free_idx + 2} is not smaller than {self.n_cols - 2 =}; not enough columns in marker array !!" ) if dry_run: return self._markers = marker_array(self.n_cols, self.n_markers_max) # allocate auxiliary arrays self._holes = xp.zeros(self.n_markers_max, dtype=bool) self._ghost_particles = xp.zeros(self.n_markers_max, dtype=bool) self._valid_mks = xp.zeros(self.n_markers_max, dtype=bool) self._is_outside_right = xp.zeros(self.n_markers_max, dtype=bool) self._is_outside_left = xp.zeros(self.n_markers_max, dtype=bool) self._is_outside = xp.zeros(self.n_markers_max, dtype=bool) # scratch copy of markers[:3], refreshed once per apply_kinetic_bc boundary-condition-type # loop (see there) self._eta_bc_buf = marker_array(3, self.n_markers_max) # 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((10, int(self.n_markers_max * 0.5)), dtype=float) # arguments for kernels: the pyccel class on the NumPy backend, its CUDA version on the CuPy backend args_class = CudaMarkerArguments if self._args_backend == "cupy" else MarkerArguments self._args_markers = args_class( 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, self.mu_idx, self._bc_type, self._pole, ) # every column can hold a marker until the markers are loaded (see n_markers_used) self._n_markers_used = self.n_markers_max self._n_occupied_bound = self.n_markers_max # on the CuPy backend with several ranks, find out now (collectively, in the setup) whether MPI takes device # buffers; otherwise the probe runs on the first marker exchange, inside the first time step. # The probe is collective: all ranks of mpi_comm must allocate their marker arrays together, never one alone. if xp.get_backend() == "cupy" and self.mpi_size > 1: self._mpi_cuda_aware() 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. """ from struphy.pic.particles import ParticlesSPH 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 (Python bools, also on the CuPy backend) x_l, x_r, _, y_l, y_r, _, z_l, z_r, _ = self._domain_array_host[self.mpi_rank].tolist() is_domain_boundary = {} 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 = SortingBoxes( self.markers.shape, isinstance(self, ParticlesSPH), 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): """Build :attr:`~struphy.pic.base.Particles.f_init` (and, for SPH particles, :attr:`~struphy.pic.base.Particles.u_init`). For non-SPH particles, :attr:`f_init` is simply :attr:`~struphy.pic.base.Particles.initial_condition`. For SPH particles, :attr:`f_init`/:attr:`u_init` are built from the fluid equilibrium density/velocity (:attr:`~struphy.pic.base.Particles.f0`) plus, if given, a single density ('n') or velocity ('u1') :class:`~struphy.initial.base.Perturbation` from :attr:`~struphy.pic.base.Particles.perturbations`, transformed to the matching (0-form / vector-field) representation.""" from struphy.pic.particles import ParticlesSPH if not isinstance(self, ParticlesSPH): 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]) 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, ) out = self.f0.n0(E1, E2, E3) if _density is not None: out1 = _density(E1, E2, E3) assert out.shape == out1.shape out += out1 if flat_eval: out = xp.squeeze(out) return out def _u_init(*etas, flat_eval=False): if len(etas) == 1: out = self.f0.uv(etas[0]) if _u1 is not None: out[0] += _u1(*etas[0]) 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, ) out = self.f0.uv(E1, E2, E3) if _u1 is not None: out1 = _u1(E1, E2, E3) assert out[0].shape == out1.shape out[0] += 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. The dataset ``markers`` of the file has the layout of the marker array, shape ``(n_cols, n_markers)`` (one column per marker). 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( (self.n_cols, n_mks_load_loc), 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: saved = file["restart/" + self.loading_params.restart_key][-1, :, :] # the number of marker slots n_markers_max is padded (see struphy.pic.marker_layout), so a file written with # another padding has a different number of slots: copy the common ones; the slots after them are holes, in the # buffer (set before loading) and in the file n_common = min(saved.shape[1], self.n_markers_max) assert np.all(saved[0, n_common:] == -1.0), ( f"the restart file has {saved.shape[1]} markers on rank {self.mpi_rank}, but there are only " f"{self.n_markers_max} here, and the extra ones are not holes" ) self._markers[:, :n_common] = saved[:, :n_common] 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[0, : eta1.size] = eta1 self._markers[1, : eta2.size] = eta2 self._markers[2, : eta3.size] = eta3 self._update_valid_mks() 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) def _find_outside_particles(self, axis, eta=None): """Find markers whose ``axis``-th logical coordinate lies outside ``[0, 1]`` (holes and ghost particles are excluded), updating :attr:`_is_outside_left`/:attr:`_is_outside_right`/:attr:`_is_outside` accordingly. Parameters ---------- axis : int Row of the markers array (0, 1 or 2) holding the logical coordinate to check. eta : xp.ndarray[float], optional Snapshot ``(3, n_markers_max)`` of ``markers[:3]`` (see :meth:`apply_kinetic_bc`). If ``None``, reads straight from ``markers``. Returns ------- outside_inds : xp.ndarray[int] Indices of the markers that are outside the logical unit cube. """ # only the markers (columns) that can hold markers (the others are holes) n = self.n_markers_used col = self.markers[axis, :n] if eta is None else eta[axis, :n] # determine particles outside of the logical unit cube self._is_outside_right[:n] = col > 1.0 self._is_outside_right[n:] = False self._is_outside_left[:n] = col < 0.0 self._is_outside_left[n:] = False 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 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[0, outside_inds] = 1e-4 r_loss = self.domain.params["a1"] else: outside_inds = xp.nonzero(self._is_outside_right)[0] self.markers[0, outside_inds] = 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[self.first_pusher_idx, outside_inds[gyro_inside_inds]] = -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[2, outside_inds] -= 2 * self.equil.q_r(r_loss) * self.markers[1, outside_inds] # theta_boudary_transfer = - theta_loss self.markers[1, outside_inds] = 1.0 - self.markers[1, outside_inds] # mark the particle as done for multiple step pushers self.markers[self.first_pusher_idx, outside_inds] = -1.0 self._is_outside[outside_inds] = False 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[1:3, outside_inds] = self.markers[1:3, outside_inds] % 1 v = self.markers[3:6, outside_inds] # 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.equation_params.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[1, outside_inds] = (gc_etas[1] - (self.markers[1, outside_inds] - 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[3:6, outside_inds] = norm_b_cart * v_parallel + new_v_perp return xp.logical_and(1.0 > gc_etas[0], gc_etas[0] > 0.0) 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.n_markers_max, dtype=int) self._argsort_array[:] = self._markers[sorting_axis].argsort() self._markers[:] = self._markers[:, self._argsort_array] def _fill_boxes(self): """Assign the particles to boxes from the box row of the markers, after checking that the box array has enough columns (see :meth:`_check_box_capacity`).""" max_in_box = fill_boxes( self.markers, self.holes, self._sorting_boxes._boxes, self._sorting_boxes._next_index, self._sorting_boxes.box_index, ) self._check_box_capacity(max_in_box) def _check_box_capacity(self, max_in_box: int): """Raise (or abort under MPI) if the largest number of markers in a box, as returned by the box kernels (counted on the host, a Python int), does not fit into the box array; the kernels then leave the box array untouched. This detects load imbalance with respect to the sorting boxes.""" # the last column must stay -1 (terminator for the box readers), hence >= if max_in_box >= self._sorting_boxes.boxes.shape[1]: msg = f'Strong load imbalance detected in sorting boxes: \ max number of markers in a box ({max_in_box}) on rank {self.mpi_rank} \ does not fit into the column-size of the box array ({self._sorting_boxes.boxes.shape[1]}, last column is reserved). \ Increase the value of "box_bufsize" in the markers parameters for the next run.' if self.mpi_comm is None: raise RuntimeError(msg) warnings.warn(msg) self.mpi_comm.Abort() def _update_ghost_particles(self): """Refresh :attr:`~struphy.pic.base.Particles.ghost_particles`: a marker is flagged as a ghost particle when its ID column (last column) equals -2, the marker set by :meth:`_prepare_ghost_particles`/:meth:`_sendrecv_markers_boxes` for SPH ghost-box particles received from a neighbouring process.""" self._ghost_particles[:] = self.markers[-1] == -2.0 self._update_valid_mks() def _remove_ghost_particles(self): """Discard all current ghost particles: turn their marker-array columns into new holes (so the space can be reused before the next SPH ghost-box update).""" self._update_ghost_particles() new_holes = xp.nonzero(self.ghost_particles)[0] self._markers[:, new_holes] = -1.0 self.update_holes() 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], 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], 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], 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], 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], 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], 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], 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], 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], 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], 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], 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], 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 def _determine_markers_in_box(self, list_boxes): """Gather the markers currently sorted into any of the given boxes into a new array (used to collect the particles on a domain/process boundary before turning them into ghost particles). Parameters ---------- list_boxes : list[int] Flat box indices (as computed by :func:`~struphy.pic.sorting_kernels.flatten_index`) whose particles are collected. Returns ------- markers_in_box : xp.ndarray Copy of the marker-array columns (markers) belonging to any of ``list_boxes``. """ 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) # C-contiguous, as the receive buffers (n_cols, n) of _sendrecv_markers_boxes: selecting columns gives a # Fortran-ordered copy, which MPI would send marker by marker markers_in_box = xp.ascontiguousarray(self.markers[:, indices]) return markers_in_box def _get_destinations_box(self): """Route the ghost markers prepared by :meth:`_prepare_ghost_particles` (one array per face/edge/corner) to the neighbouring process on that side (found earlier by :meth:`_get_neighbouring_proc`), accumulating, per destination rank, the number of markers to send (:attr:`_send_info_box`) and the markers themselves (:attr:`_send_list_box`, used by :meth:`_self_communication_boxes` and :meth:`_sendrecv_markers_boxes`).""" self._send_info_box = xp.zeros(self.mpi_size, dtype=int) self._send_list_box = [xp.zeros((self.n_cols, 0))] * self.mpi_size # Faces # if self._x_m_proc is not None: self._send_info_box[self._x_m_proc] += self._markers_x_m.shape[1] self._send_list_box[self._x_m_proc] = xp.concatenate( (self._send_list_box[self._x_m_proc], self._markers_x_m), axis=1 ) # if self._x_p_proc is not None: self._send_info_box[self._x_p_proc] += self._markers_x_p.shape[1] self._send_list_box[self._x_p_proc] = xp.concatenate( (self._send_list_box[self._x_p_proc], self._markers_x_p), axis=1 ) # if self._y_m_proc is not None: self._send_info_box[self._y_m_proc] += self._markers_y_m.shape[1] self._send_list_box[self._y_m_proc] = xp.concatenate( (self._send_list_box[self._y_m_proc], self._markers_y_m), axis=1 ) # if self._y_p_proc is not None: self._send_info_box[self._y_p_proc] += self._markers_y_p.shape[1] self._send_list_box[self._y_p_proc] = xp.concatenate( (self._send_list_box[self._y_p_proc], self._markers_y_p), axis=1 ) # if self._z_m_proc is not None: self._send_info_box[self._z_m_proc] += self._markers_z_m.shape[1] self._send_list_box[self._z_m_proc] = xp.concatenate( (self._send_list_box[self._z_m_proc], self._markers_z_m), axis=1 ) # if self._z_p_proc is not None: self._send_info_box[self._z_p_proc] += self._markers_z_p.shape[1] self._send_list_box[self._z_p_proc] = xp.concatenate( (self._send_list_box[self._z_p_proc], self._markers_z_p), axis=1 ) # x-y edges # if self._x_m_y_m_proc is not None: self._send_info_box[self._x_m_y_m_proc] += self._markers_x_m_y_m.shape[1] 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), axis=1, ) # if self._x_m_y_p_proc is not None: self._send_info_box[self._x_m_y_p_proc] += self._markers_x_m_y_p.shape[1] 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), axis=1, ) # if self._x_p_y_m_proc is not None: self._send_info_box[self._x_p_y_m_proc] += self._markers_x_p_y_m.shape[1] 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), axis=1, ) # if self._x_p_y_p_proc is not None: self._send_info_box[self._x_p_y_p_proc] += self._markers_x_p_y_p.shape[1] 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), axis=1, ) # x-z edges # if self._x_m_z_m_proc is not None: self._send_info_box[self._x_m_z_m_proc] += self._markers_x_m_z_m.shape[1] 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), axis=1, ) # if self._x_m_z_p_proc is not None: self._send_info_box[self._x_m_z_p_proc] += self._markers_x_m_z_p.shape[1] 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), axis=1, ) # if self._x_p_z_m_proc is not None: self._send_info_box[self._x_p_z_m_proc] += self._markers_x_p_z_m.shape[1] 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), axis=1, ) # if self._x_p_z_p_proc is not None: self._send_info_box[self._x_p_z_p_proc] += self._markers_x_p_z_p.shape[1] 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), axis=1, ) # y-z edges # if self._y_m_z_m_proc is not None: self._send_info_box[self._y_m_z_m_proc] += self._markers_y_m_z_m.shape[1] 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), axis=1, ) # if self._y_m_z_p_proc is not None: self._send_info_box[self._y_m_z_p_proc] += self._markers_y_m_z_p.shape[1] 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), axis=1, ) # if self._y_p_z_m_proc is not None: self._send_info_box[self._y_p_z_m_proc] += self._markers_y_p_z_m.shape[1] 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), axis=1, ) # if self._y_p_z_p_proc is not None: self._send_info_box[self._y_p_z_p_proc] += self._markers_y_p_z_p.shape[1] 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), axis=1, ) # 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] += self._markers_x_m_y_m_z_m.shape[1] 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), axis=1, ) # if self._x_m_y_m_z_p_proc is not None: self._send_info_box[self._x_m_y_m_z_p_proc] += self._markers_x_m_y_m_z_p.shape[1] 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), axis=1, ) # if self._x_m_y_p_z_m_proc is not None: self._send_info_box[self._x_m_y_p_z_m_proc] += self._markers_x_m_y_p_z_m.shape[1] 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), axis=1, ) # if self._x_m_y_p_z_p_proc is not None: self._send_info_box[self._x_m_y_p_z_p_proc] += self._markers_x_m_y_p_z_p.shape[1] 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), axis=1, ) # if self._x_p_y_m_z_m_proc is not None: self._send_info_box[self._x_p_y_m_z_m_proc] += self._markers_x_p_y_m_z_m.shape[1] 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), axis=1, ) # if self._x_p_y_m_z_p_proc is not None: self._send_info_box[self._x_p_y_m_z_p_proc] += self._markers_x_p_y_m_z_p.shape[1] 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), axis=1, ) # if self._x_p_y_p_z_m_proc is not None: self._send_info_box[self._x_p_y_p_z_m_proc] += self._markers_x_p_y_p_z_m.shape[1] 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), axis=1, ) # if self._x_p_y_p_z_p_proc is not None: self._send_info_box[self._x_p_y_p_z_p_proc] += self._markers_x_p_y_p_z_p.shape[1] 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), axis=1, ) 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 ] self._track_markers(n_filled=int(self._send_info_box[self.mpi_rank])) 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) 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 = [] send_reqs = [] 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: send_reqs += [self.mpi_comm.Isend(data, dest=i, tag=self.mpi_comm.Get_rank())] recvbufs += [xp.zeros((self.n_cols, N_recv), 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 # the send buffers must not be reused before the sends have completed for req in send_reqs: req.Wait() # the received ghost particles filled the lowest holes (counted with the own slot of first_hole) self._track_markers(n_filled=int(xp.sum(self._recv_info_box))) 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 (on the host copy, also on the CuPy backend) dd = self._domain_array_host 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: across a non-periodic domain boundary in one direction # (face neighbour == rank) the corner neighbour is the edge neighbour in the # two remaining directions (the edges have been completed above) if self._x_m_y_m_z_m_proc is None: if self._x_m_proc == rank: self._x_m_y_m_z_m_proc = self._y_m_z_m_proc elif self._y_m_proc == rank: self._x_m_y_m_z_m_proc = self._x_m_z_m_proc elif self._z_m_proc == rank: self._x_m_y_m_z_m_proc = self._x_m_y_m_proc if self._x_m_y_m_z_p_proc is None: if self._x_m_proc == rank: self._x_m_y_m_z_p_proc = self._y_m_z_p_proc elif self._y_m_proc == rank: self._x_m_y_m_z_p_proc = self._x_m_z_p_proc elif self._z_p_proc == rank: self._x_m_y_m_z_p_proc = self._x_m_y_m_proc if self._x_m_y_p_z_m_proc is None: if self._x_m_proc == rank: self._x_m_y_p_z_m_proc = self._y_p_z_m_proc elif self._y_p_proc == rank: self._x_m_y_p_z_m_proc = self._x_m_z_m_proc elif self._z_m_proc == rank: self._x_m_y_p_z_m_proc = self._x_m_y_p_proc if self._x_m_y_p_z_p_proc is None: if self._x_m_proc == rank: self._x_m_y_p_z_p_proc = self._y_p_z_p_proc elif self._y_p_proc == rank: self._x_m_y_p_z_p_proc = self._x_m_z_p_proc elif self._z_p_proc == rank: self._x_m_y_p_z_p_proc = self._x_m_y_p_proc if self._x_p_y_m_z_m_proc is None: if self._x_p_proc == rank: self._x_p_y_m_z_m_proc = self._y_m_z_m_proc elif self._y_m_proc == rank: self._x_p_y_m_z_m_proc = self._x_p_z_m_proc elif self._z_m_proc == rank: self._x_p_y_m_z_m_proc = self._x_p_y_m_proc if self._x_p_y_m_z_p_proc is None: if self._x_p_proc == rank: self._x_p_y_m_z_p_proc = self._y_m_z_p_proc elif self._y_m_proc == rank: self._x_p_y_m_z_p_proc = self._x_p_z_p_proc elif self._z_p_proc == rank: self._x_p_y_m_z_p_proc = self._x_p_y_m_proc if self._x_p_y_p_z_m_proc is None: if self._x_p_proc == rank: self._x_p_y_p_z_m_proc = self._y_p_z_m_proc elif self._y_p_proc == rank: self._x_p_y_p_z_m_proc = self._x_p_z_m_proc elif self._z_m_proc == rank: self._x_p_y_p_z_m_proc = self._x_p_y_p_proc if self._x_p_y_p_z_p_proc is None: if self._x_p_proc == rank: self._x_p_y_p_z_p_proc = self._y_p_z_p_proc elif self._y_p_proc == rank: self._x_p_y_p_z_p_proc = self._x_p_z_p_proc elif self._z_p_proc == rank: self._x_p_y_p_z_p_proc = self._x_p_y_p_proc @profile def _communicate_boxes(self): """Refresh the SPH ghost-box layer: build the outgoing ghost markers (:meth:`_prepare_ghost_particles`), route them to the neighbouring processes (:meth:`_get_destinations_box`), deliver the ones staying on this process (:meth:`_self_communication_boxes`), and, if running under MPI, exchange the rest with neighbouring processes (:meth:`_sendrecv_all_to_all_boxes`, :meth:`_sendrecv_markers_boxes`) before marking the received markers as ghost particles (:meth:`_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"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 = }") 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[index, j]``. 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 = 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 = 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 = naive_evaluation_flat elif len(_shp) == 3: func = naive_evaluation_meshgrid func( self.args_markers, eta1, eta2, eta3, self.holes, periodic1, periodic2, periodic3, index, ker_id, h1, h2, h3, out, ) return out ### MPI comm for domain decomposition ### def _is_on_domain_of_rank(self, etas, rank): """ Check which logical positions lie on the domain of a given process. Uses the half-open interval [left, right) in each direction, so that a position exactly on a boundary between two processes belongs to exactly one of them. The right end eta = 1 of the unit cube is included in the last process. Parameters ---------- etas : array[float] Logical positions of shape (3, n), one column per position. rank : int Process rank (row index of domain_array). Returns ------- is_on_domain : array[bool] Array of shape (3, n), True where the position lies in the process domain in that direction. """ left = self.domain_array[rank, 0::3, None] right = self.domain_array[rank, 1::3, None] # include eta = 1 in the last process: shift its right boundary to the next float right = xp.where(right == 1.0, xp.nextafter(right, 2.0), right) return xp.logical_and(etas >= left, etas < right) 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 columns 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 (3, n_markers_max) according to which the sorting is performed. """ # position that determines the sorting (including periodic shift of boundary conditions) if not isinstance(alpha, xp.ndarray): # check a tuple/list on the host: on CuPy, asserting on a device comparison reads it back alpha_host = np.array(alpha, dtype=float) assert alpha_host.size == 3 assert np.all(alpha_host >= 0.0) and np.all(alpha_host <= 1.0) if alpha_host[0] == alpha_host[1] == alpha_host[2]: # the common case (alpha = 1 or alpha = 0.5 in every direction): a Python scalar, # which needs no host-to-device copy on CuPy and gives the same values on NumPy alpha = float(alpha_host[0]) else: alpha = xp.asarray(alpha_host)[:, None] else: assert alpha.size == 3 assert xp.all(alpha >= 0.0) and xp.all(alpha <= 1.0) alpha = alpha.reshape(3, 1) # only the markers (columns) that can hold markers; the columns after them are holes, which stay n = self.n_markers_used bi = self.first_pusher_idx sorting_etas = self._sorting_etas[:, :n] sorting_etas[:] = ( alpha * (self.markers[:3, :n] + self.markers[bi + 3 + self.vdim : bi + 3 + self.vdim + 3, :n]) + (1.0 - alpha) * self.markers[bi : bi + 3, :n] ) # for non-periodic axes, eta = 1 is the right end of the domain and must not be wrapped to eta = 0 non_periodic_axes = [axis for axis in range(3) if axis not in self._periodic_axes] at_right_end = {axis: sorting_etas[axis] == 1.0 for axis in non_periodic_axes} xp.mod(sorting_etas, 1.0, out=sorting_etas) for axis, mask in at_right_end.items(): sorting_etas[axis, mask] = 1.0 # check which particles are on the current process domain (into the first columns of the full-size buffer) on_domain = self._is_on_proc_domain[:, :n] on_domain[:] = self._is_on_domain_of_rank(sorting_etas, self.mpi_rank) # to stay on the current process, all three directions must be True. # Combining the three rows is faster than reducing over the axis of size 3 self._can_stay[:n] = on_domain[0] & on_domain[1] & on_domain[2] self._can_stay[n:] = True # 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 markers (-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 def _compute_neighbor_ranks(self) -> tuple[list[int], list[int]]: """Split every other rank into geometric neighbours of this rank's sub-domain box and everyone else, for :meth:`_sendrecv_get_destinations`. A rank is a neighbour (Moore neighbourhood: touching along every axis, sharing at least a corner) if, for each of the 3 axes, its box interval touches or overlaps this rank's -- including wrap-around for axes with a periodic boundary condition (:attr:`_periodic_axes`), where the box touching ``eta=1`` is also adjacent to the box touching ``eta=0``. Domain decomposition is a static, non-overlapping Cartesian tiling of the unit cube (see :meth:`_get_domain_decomp`) computed once at construction, so this is safe to compute once here rather than re-derived every call. Returns ------- neighbor_ranks : list[int] Ranks (excluding this one) whose box touches or overlaps this rank's. non_neighbor_ranks : list[int] Every other rank (excluding this one and ``neighbor_ranks``). """ # Tiny (mpi_size, 9) array -- the host copy, so the O(mpi_size) # comparisons below don't pay a device sync per rank checked. domain_array_host = self._domain_array_host own = domain_array_host[self.mpi_rank] tol = 1e-12 neighbor_ranks = [] non_neighbor_ranks = [] for j in range(self.mpi_size): if j == self.mpi_rank: continue other = domain_array_host[j] touching = True for axis in range(3): own_l, own_r = own[3 * axis], own[3 * axis + 1] other_l, other_r = other[3 * axis], other[3 * axis + 1] axis_touches = other_r >= own_l - tol and other_l <= own_r + tol if not axis_touches and axis in self._periodic_axes: axis_touches = (abs(own_r - 1.0) < tol and abs(other_l) < tol) or ( abs(own_l) < tol and abs(other_r - 1.0) < tol ) if not axis_touches: touching = False break (neighbor_ranks if touching else non_neighbor_ranks).append(j) return neighbor_ranks, non_neighbor_ranks 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. The counts are host integers on every backend: the # size of a nonzero() result is known on the host (CuPy synchronizes to size it), so # a small NumPy array holds them without any host/device copy, and the Alltoall of # the counts (_sendrecv_all_to_all) passes host buffers to MPI. send_info = np.zeros(self.mpi_size, dtype=int) # Gathered once and reused for every rank below, instead of re-gathering # self.markers[:, send_inds] and self._sorting_etas[:, send_inds] fresh on every # iteration of the rank loop (as the previous version did). candidates = self.markers[:, send_inds] etas_to_send = self._sorting_etas[:, send_inds] # Reset every rank's send buffer to empty first. The neighbour/non-neighbour # loop below can `break` before visiting every rank once all candidates are # matched, but _sendrecv_markers unconditionally Isends self._send_list[i] for # every i != mpi_rank -- a rank skipped this call must not keep a stale, # non-empty buffer from a previous call (send/recv size would then disagree # with send_info, which is always correct since it defaults to 0 above). empty_local = xp.empty(0, dtype=int) empty_markers = candidates[:, :0] for i in range(self.mpi_size): self._send_to_i[i] = empty_local self._send_list[i] = empty_markers # A marker leaving this rank's domain is overwhelmingly likely to land in a # geometrically adjacent sub-domain -- one push (sub-)step is small compared to # a sub-domain -- so check neighbour ranks (_compute_neighbor_ranks) first # instead of every rank. `remaining` tracks positions into send_inds/ # etas_to_send not yet matched to a destination; if neighbours don't cover # everyone (a marker moved further than one sub-domain this step), the leftover # few are checked against every other rank in the second pass, so this changes # only how many ranks get checked in the common case, not correctness. remaining = xp.arange(send_inds.shape[0]) for rank_group in (self._neighbor_ranks, self._non_neighbor_ranks): if remaining.size == 0: break etas_remaining = etas_to_send[:, remaining] still_remaining = xp.ones(remaining.shape[0], dtype=bool) for i in rank_group: conds = self._is_on_domain_of_rank(etas_remaining, i) matched_local = xp.nonzero(xp.all(conds, axis=0))[0] matched = remaining[matched_local] self._send_to_i[i] = matched send_info[i] = matched.size # C-contiguous, as the receive buffers (n_cols, n): selecting columns gives a Fortran-ordered copy, # which MPI would send marker by marker self._send_list[i] = xp.ascontiguousarray(candidates[:, matched]) still_remaining[matched_local] = False remaining = remaining[still_remaining] return send_info 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. Notes ----- ``send_info`` and ``recv_info`` are small NumPy arrays (one integer per rank) on every backend: the counts size the receive buffers and the holes to fill, which Python needs on the host anyway. On CuPy, only the markers themselves are device buffers (:meth:`_sendrecv_markers`). """ recv_info = np.zeros(self.mpi_size, dtype=int) self.mpi_comm.Alltoall(send_info, recv_info) return recv_info 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 columns (holes) in markers after send. """ if xp.get_array_backend(self._markers) == "cupy": self._sendrecv_markers_device(recv_info, hole_inds_after_send) return # 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 send_reqs = [] 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: send_reqs += [self.mpi_comm.Isend(data, dest=i, tag=self.mpi_rank)] self._recvbufs[i] = xp.zeros((self.n_cols, N_recv), 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 # the send buffers must not be reused before the sends have completed for req in send_reqs: req.Wait() def _mpi_cuda_aware(self) -> bool: """Whether MPI takes device (CuPy) buffers, for :meth:`_sendrecv_markers_device`. Uses the answer recorded by :func:`cunumpy.mpi.mpi_is_cuda_aware` or :func:`cunumpy.mpi.set_mpi_cuda_aware` (e.g. by feectools or at startup). If nothing was recorded, probes :attr:`mpi_comm` once; the probe is collective, which is safe here because :meth:`mpi_sort_markers` is called by every rank of :attr:`mpi_comm`. """ cuda_aware = xp.mpi.get_mpi_cuda_aware() if cuda_aware is None: cuda_aware = xp.mpi.mpi_is_cuda_aware(self.mpi_comm) if not cuda_aware and not Particles._warned_mpi_staging: Particles._warned_mpi_staging = True logger.warning( "MPI is not CUDA-aware: markers exchanged between ranks are staged through host memory.", ) return cuda_aware _warned_mpi_staging = False def _sendrecv_markers_device(self, recv_info, hole_inds_after_send): """Exchange markers and scatter the incoming ones into empty slots (holes). :func:`cunumpy.mpi.exchange` handles GPU synchronization, optional host staging, and completion of all MPI transfers. Parameters ---------- recv_info : array[int] Number of markers to receive from each process (NumPy array). hole_inds_after_send : array[int] Indices of empty columns (holes) in markers after send (device array). """ recv_info = np.asarray(recv_info) n_recv = int(recv_info.sum()) if hole_inds_after_send.size < n_recv: warnings.warn( f"Strong load imbalance detected: " f"number of holes ({hole_inds_after_send.size}) " f"on rank {self.mpi_rank} is smaller than number of " f"incoming particles ({n_recv}). " 'Increase "bufsize" in the markers parameters for the next run.', ) self.mpi_comm.Abort() # one receive buffer (n_cols, n) per source rank: a slice of a common (n_cols, n_recv) buffer would not be # contiguous first_hole = np.cumsum(recv_info) - recv_info sends = [] receives = [] for rank in range(self.mpi_size): if rank == self.mpi_rank: continue if recv_info[rank] > 0: recvbuf = xp.empty((self.n_cols, int(recv_info[rank])), dtype=self.markers.dtype) receives.append((recvbuf, rank, rank)) data = self._send_list[rank] if data.shape[1] > 0: sends.append((data, rank, self.mpi_rank)) xp.mpi.exchange( self.mpi_comm, sends=sends, receives=receives, cuda_aware=self._mpi_cuda_aware(), ) for recvbuf, rank, _ in receives: start = int(first_hole[rank]) stop = start + int(recv_info[rank]) self._markers[:, hole_inds_after_send[start:stop]] = recvbuf
class Tesselation: """ Subdivide each :class:`~struphy.pic.sorting.SortingBoxes` box on the current MPI process into ``tiles_pb`` equally sized, axis-aligned tiles (1 sorting box, hence 1 set of tiles, if ``sorting_boxes=None``). The tesselation gives a deterministic, evenly spaced set of points inside the process domain: :meth:`draw_markers` places one marker at the midpoint of each tile (used for ``loading="tesselation"``, e.g. deterministic SPH marker loading with ``ppb`` markers per box), and :meth:`cell_averages` integrates a given function over each tile via Gauss-Legendre quadrature (used to initialize marker weights from cell averages of the background distribution). The number of tiles per direction is chosen by factorizing ``tiles_pb`` and greedily assigning factors to whichever direction currently has the fewest tiles (see :meth:`get_tiles`), so ``tiles_pb`` should factorize into small primes for a close-to-cubic tile shape. 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 : SortingBoxes Box info for SPH evaluations. """ def __init__( self, tiles_pb: int | float, *, comm: "Intracomm" = None, domain_array: xp.ndarray = None, sorting_boxes: 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 the tesselation of a single sorting box: split ``tiles_pb`` into prime factors and distribute them, one at a time, over the directions marked True in :attr:`dims_mask` (each factor going to the direction with the currently fewest tiles), yielding :attr:`nt_per_dim`. From this, compute the tile breakpoints (:attr:`tile_breaks`), midpoints (:attr:`tile_midpoints`) and volume (:attr:`tile_volume`) within a single sorting box, all expressed relative to the box's own origin (i.e. on ``[0, box_width]`` in each direction).""" # 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): """Place one marker at the midpoint of every tile of every sorting box on the current process, i.e. a deterministic alternative to random marker loading. Returns ------- eta1, eta2, eta3 : xp.ndarray 1d arrays of logical-space marker coordinates, one entry per tile (length :attr:`n_tiles`).""" _, 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 Gauss-Legendre quadrature points and weights on a single tile (:attr:`tile_quad_pts`, :attr:`tile_quad_wts`), rescaled from ``[-1, 1]`` to the tile's own extent, i.e. the first tile of :attr:`tile_breaks` in each direction.""" 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 the cell average of ``fun`` over every tile on the current process, via Gauss-Legendre quadrature of the given order in each direction. Parameters ---------- fun : callable Function of (eta1, eta2, eta3) to be averaged; called once per sorting box with meshgrid-shaped arrays of quadrature points. n_quad : int | list[int] Number of Gauss-Legendre quadrature points per tile, either the same in all three directions (int) or one value per direction (list of 3 ints). Defaults to 1 point per direction. Returns ------- out : xp.ndarray 3d array of shape ``n_tiles_per_dim * boxes_per_dim`` (one entry per tile on the current process) holding the cell average of ``fun`` over each tile. """ 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): """Allocate two 3d arrays filled with zeros, to be filled tile-by-tile and box-by-box in :meth:`draw_markers` and :meth:`cell_averages`: * the first, of shape :attr:`nt_per_dim`, holds one entry per tile within a single sorting box; * the second, of shape ``nt_per_dim * boxes_per_dim``, holds one entry per tile on the current process (i.e. the first array tiled over all sorting boxes). """ # 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 the tile midpoints within the i-th sorting box in direction dim, i.e. :attr:`tile_midpoints` shifted from the single-box-local frame ``[0, box_width]`` to the process-global frame. Parameters ---------- i : int Index of the sorting box in direction dim, starting at 0. dim : int Direction, either 0, 1, or 2. Returns ------- xp.ndarray 1d array of tile midpoints, length ``nt_per_dim[dim]``. """ 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