Examples¶
This page walks through the most common ParkiPy use cases. Full, runnable
scripts for each example can be found in the examples/ directory of the
repository.
Stokes Single-Layer Potential (3-periodic)¶
The Stokes single-layer potential sums the Stokeslet kernel over a set of source particles \(\{y_j\}\) with associated force densities \(\{f(y_j)\}\) to obtain the velocity field at target locations \(\{x_i\}\):
With fully periodic boundary conditions this is straightforward to evaluate
using parkipy.ewald.stokes_sl().
import cupy as cp # swap for numpy for CPU execution
import parkipy
rng = cp.random.default_rng(123)
# Source particles (y) and their force densities (f)
y = rng.random(size=(3, 773))
f = rng.random(size=(3, 773))
# Target locations (x) where the velocity is evaluated
x = rng.random(size=(3, 312))
# Configure the Ewald summation
options = parkipy.ewald.EwaldOptions(
periodicity=3, # fully 3-periodic (triply periodic)
box=[1, 1, 1], # unit cube domain
tolerance=1e-8, # relative accuracy target
cell_size=23, # grid cells per dimension for the far-field solve
execution_space="CUDA", # use "OpenMP" for CPU
)
# Evaluate the potential — u has shape (3, 312)
u = parkipy.ewald.stokes_sl(x, y, f, options)
Key parameters in EwaldOptions:
Parameter |
Description |
|---|---|
|
Number of periodic dimensions. Currently |
|
Side lengths of the periodic box as a list |
|
Desired relative accuracy. Smaller values increase both the near-field cutoff radius and the far-field grid resolution. |
|
Number of grid cells per dimension for the far-field (Fourier-space) component of the Ewald sum. |
|
|
|
If |
Stokes Single + Double Layer Potential (3-periodic)¶
When both single- and double-layer contributions are needed (e.g. for a
completed double-layer representation in boundary integral methods), use
parkipy.ewald.stokes_comb():
import cupy as cp
import parkipy
rng = cp.random.default_rng(42)
y = rng.random(size=(3, 500)) # source positions
n = rng.random(size=(3, 500)) # surface normals at sources
f = rng.random(size=(3, 500)) # single-layer densities
g = rng.random(size=(3, 500)) # double-layer densities
x = rng.random(size=(3, 200)) # target positions
options = parkipy.ewald.EwaldOptions(
periodicity=3,
box=[1, 1, 1],
tolerance=1e-6,
cell_size=20,
execution_space="CUDA",
)
u = parkipy.ewald.stokes_comb(x, y, n, f, g, options)
Stokes Combined Layer Potential (1-periodic)¶
For problems periodic in only one direction (e.g. a periodic channel or tube),
set periodicity=1. The combined single + double layer potential is
evaluated with parkipy.ewald.stokes_comb(), which takes a stacked
density array of shape (6, N_s) — the first three rows are the
single-layer density and the last three are the double-layer density:
import parkipy
import numpy as np # or cupy for GPU
device = "Cuda"
am = parkipy.utils.get_array_module(parkipy.utils.get_execution_space(device))
box = [1, 1, 1]
tol = 1e-6
ns = 600
nt = 400
cell_size = 16
src = am.random.rand(3, ns) * am.array(box).reshape(3, 1)
trg = am.random.rand(3, nt) * am.array(box).reshape(3, 1)
dens_sl = am.random.randn(3, ns) # single-layer density
dens_dl = am.random.randn(3, ns) # double-layer density
norms = am.random.randn(3, ns) # surface normals at sources
dens = am.vstack((dens_sl, dens_dl)) # shape (6, ns)
options = parkipy.ewald.EwaldOptions(
periodicity=1,
box=box,
tolerance=tol,
execution_space=device,
cell_size=cell_size,
)
u = parkipy.ewald.stokes_comb(trg, src, dens, norms, options)
Distributed Combined Stokes Potential (Multi-GPU, 1-periodic)¶
For very large particle counts, parkipy.distributed distributes the
computation across multiple GPUs using MPI. Each MPI rank allocates and owns
its own local slice of the source and target particles. The example below
evaluates the combined (single + double layer) Stokes potential with one
periodic direction across n MPI ranks, where the periodic box length in
the first dimension is scaled with the number of ranks:
from mpi4py import MPI
import parkipy
import numpy as np
# Initialise MPI
mpi_comm = MPI.COMM_WORLD
size = mpi_comm.Get_size()
rank = mpi_comm.Get_rank()
# Use parkipy helpers to select the array module (numpy or cupy)
# based on the chosen execution space
device = "Cuda" # or "OpenMP" for CPU
execution_space = parkipy.utils.get_execution_space(device)
am = parkipy.utils.get_array_module(execution_space)
# Box scaled so each rank owns a unit-length slab in the periodic direction
box = [size, 1, 1]
tol = 1e-4
cell_size = 224
# Each rank generates its own local particles
nt = int(4e6)
ns = int(4e6)
rc = np.ceil(ns / cell_size) ** (-1 / 3) # near-field cutoff radius
trg = am.random.rand(3, nt) * am.array(box).reshape(3, 1)
src = am.random.rand(3, ns) * am.array(box).reshape(3, 1)
dens_sl = am.random.randn(3, ns) # single-layer density
dens_dl = am.random.randn(3, ns) # double-layer density
norms = am.random.randn(3, ns) # surface normals at sources
dens = am.vstack((dens_sl, dens_dl)) # stacked density input
# Evaluate the distributed combined Stokes potential
pot, trg, timing = parkipy.distributed.ewald.stokes_comb(
trg,
src,
dens,
norms,
1, # periodicity
box,
tol,
device,
rc=rc,
time=True,
)
After the call, pot is a (3, nt_local) array on each rank containing
the local velocity potential. Timing information is returned in the timing
dict when time=True.
Once all ranks have finished, use parkipy.distributed.gather_points()
to collect results onto rank 0 for validation or output:
import numpy as np
nt_local = trg.shape[1]
nt_global = mpi_comm.allreduce(nt_local, op=MPI.SUM)
pot_gathered = np.empty((3, nt_global))
pot_gathered[:, :nt_local] = pot.get()
parkipy.distributed.gather_points(mpi_comm, "openmp", pot_gathered.T, nt_local)
Run with mpiexec (or your scheduler’s equivalent):
mpiexec -n 4 python examples/distributed/ewald/stokes1p_mp.py --device Cuda
Cell List¶
The parkipy.CellList class provides an O(N) neighbour-finding data
structure for local (short-range) interactions. It is used internally by the
Ewald near-field routines but is also available as a standalone API.
As a motivating example, consider computing the short-range potential \(u(x_i) = \sum_{j:\,\|x_i - y_j\| < r_c} 1 / \|x_i - y_j\|\). A naïve double loop over all pairs is O(N²). Using a cell list reduces this to O(N) by restricting the inner loop to the 27 neighbouring cells around each source particle:
import parkipy
device = "Cuda" # or "OpenMP"
am = parkipy.utils.get_array_module(parkipy.utils.get_execution_space(device))
box = [1, 1, 1]
cutoff = 0.1
Nx = 312
Ny = 773
x = am.random.rand(3, Nx) * am.array(box).reshape(3, 1)
y = am.random.rand(3, Ny) * am.array(box).reshape(3, 1)
u = am.zeros(x.shape[1])
# Build separate cell lists for targets (x) and sources (y)
x_list = parkipy.CellList(x, cutoff, box, execution_space=device)
y_list = parkipy.CellList(y, cutoff, box, execution_space=device)
# Loop over non-empty x-cells
for cell_ne in range(x_list.num_nonempty_cells):
off_x = cell_ne * x_list.cell_size
cell = x_list.nonempty_cells[cell_ne]
# Loop over x-particles in this cell
for ii in range(off_x, off_x + x_list.cell_size):
i = x_list.particle_index[ii]
if i == -1:
continue # padded slot — skip
# Loop over the 27 neighbouring y-cells
for k in range(27):
neighbor = y_list.nonempty_neighbors[cell, k].get()
if neighbor == -1:
continue # no neighbour in this direction
off_y = neighbor * y_list.cell_size
# Loop over y-particles in the neighbour cell
for jj in range(off_y, off_y + y_list.cell_size):
j = y_list.particle_index[jj]
if j == -1:
continue
dist_sq = am.sum((x[:, i] - y[:, j]) ** 2)
if dist_sq == 0:
continue
dist = am.sqrt(dist_sq)
if dist < cutoff:
u[i] += 1 / dist
Key attributes of CellList:
Attribute |
Description |
|---|---|
|
Number of cells that contain at least one particle. |
|
1-D index array mapping the non-empty cell enumeration to the global cell index. |
|
Maximum number of particles per cell (padded with |
|
Flat array of particle indices. Slice |
|
|
More Examples¶
Fully runnable scripts covering additional configurations and performance
benchmarks are provided in the examples/ directory of the repository:
examples/
├── batched_celllist.py
├── celllist.py
├── distributed
│ └── ewald
│ ├── stokes1p_nosort.py
│ └── stokes1p.py
├── ewald
│ ├── laplace3p.py
│ ├── stokes1p.py
│ └── stokes3p.py
└── periodic_celllist.py