Particle-Particle interaction#
In this notebook, we show an example of the new ‘particle-particle-interaction’ functionality in Parcels. Note that this functionality is still in development, and the implementation is fairly rudimentary and slow for now. Importantly:
Particle-particle interaction only works in Scipy mode
The type of interactions that are supported is still limited
Interactions are implemented through InteractionKernels
, which are similar to normal Kernels
. The InteractionKernels
are applied between particles that are located closer to each other than a specified interaction_distance
. In general, the code structure needs three adaptations to apply particle-particle interaction:
The
ParticleSet
requires aninteraction_distance
argument upon creation, to define theinteraction_distance
.ParticleSet.execute()
requires thepyfunc_inter
argument, which contains theInteractionKernels
that will be executed, similarly to thepyfunc
argument for normalKernels
.InteractionKernels
have two additional arguments compared to normalKernels
:
def InteractionKernel(particle, fieldset, time, neighbors, mutator)
The neighbors
argument provides a list of the particles that are within a neighborhood (i.e. closer than the interaction_distance
argument in ParticleSet
creation).
The mutator
argument is an initially empty list with all the mutations that need to be performed on particles at the end of running all InteractionKernels
on all particles. This mutator
argument is required, because otherwise the order at which interactions are applied has implications for the simulation. As a consequence, the simulation will likely be dependent on the order of the particle list if no mutator list is used. See the Mutator function section for
an illustration of the mutator
functionality.
Pulling particles#
Below is an example of what can be done with particle-particle interaction. We create a square grid of \(N\times N\) particles, which are all subject to Brownian Motion (via the built-in DiffusionUniformKh
Kernel). Furthermore, some of the particles also ‘attract’ other particles that are within the interaction distance: these attracted particles move with a constant velocity to the attracting particles.
[1]:
%matplotlib notebook
import matplotlib.pyplot as plt
import numpy as np
import xarray as xr
from IPython.display import HTML
from matplotlib.animation import FuncAnimation
from parcels import (
DiffusionUniformKh,
FieldSet,
MergeWithNearestNeighbor,
NearestNeighborWithinRange,
ParticleSet,
ScipyInteractionParticle,
ScipyParticle,
Variable,
)
[2]:
def Pull(particle, fieldset, time, neighbors, mutator):
"""InterActionKernel that "pulls" all neighbor particles
toward the attracting particle with a constant velocity"""
distances = []
na_neighbors = []
# only execute kernel for particles that are 'attractor'
if not particle.attractor:
return StateCode.Success
for n in neighbors:
if n.attractor:
continue
x_p = np.array([particle.depth, particle.lat, particle.lon])
x_n = np.array([n.depth, n.lat, n.lon])
# compute distance between attracted and attracting particle
distances.append(np.linalg.norm(x_p - x_n))
na_neighbors.append(n)
velocity = 0.04 # predefined attracting velocity
for n in na_neighbors:
assert n.dt == particle.dt
dx = np.array(
[particle.lat - n.lat, particle.lon - n.lon, particle.depth - n.depth]
)
dx_norm = np.linalg.norm(dx)
# calculate vector of position change
distance = velocity * n.dt
d_vec = distance * dx / dx_norm
# define mutation function for mutator
def f(n, dlat, dlon, ddepth):
n.lat += dlat
n.lon += dlon
n.depth += ddepth
# add mutation to the mutator
mutator[n.id].append((f, d_vec))
[3]:
npart = 11
X, Y = np.meshgrid(np.linspace(-1, 1, npart), np.linspace(-1, 1, npart))
# Define a fieldset without flow
fieldset = FieldSet.from_data({"U": 0, "V": 0}, {"lon": 0, "lat": 0}, mesh="flat")
fieldset.add_constant_field("Kh_zonal", 0.0005, mesh="flat")
fieldset.add_constant_field("Kh_meridional", 0.0005, mesh="flat")
# Create custom particle class with extra variable that indicates
# whether the interaction kernel should be executed on this particle.
class InteractingParticle(ScipyParticle):
attractor = Variable("attractor", dtype=np.bool_, to_write="once")
attractor = [
True if i in [int(npart * npart / 2 - 3), int(npart * npart / 2 + 3)] else False
for i in range(npart * npart)
]
pset = ParticleSet(
fieldset=fieldset,
pclass=InteractingParticle,
lon=X,
lat=Y,
interaction_distance=0.5, # note the interaction_distance argument here
attractor=attractor,
)
output_file = pset.ParticleFile(name="InteractingParticles.zarr", outputdt=1)
pset.execute(
pyfunc=DiffusionUniformKh,
pyfunc_inter=Pull, # note the pyfunc_inter here
runtime=60,
dt=1,
output_file=output_file,
)
WARNING: Some InteractionKernel was not completed succesfully, likely because a Particle threw an error that was not captured.
INFO: Compiled ParcelsRandom ==> /var/folders/1n/500ln6w97859_nqq86vwpl000000gr/T/parcels-504/libparcels_random_e476b812-72c8-48c8-910c-9f727b155276.so
[4]:
%%capture
data_xarray = xr.open_zarr("InteractingParticles.zarr")
data_attr = data_xarray.where(data_xarray["attractor"].compute() == 1, drop=True)
data_other = data_xarray.where(data_xarray["attractor"].compute() == 0, drop=True)
timerange = np.arange(
np.nanmin(data_xarray["time"].values),
np.nanmax(data_xarray["time"].values),
np.timedelta64(1, "s"), # timerange in nanoseconds
)
fig = plt.figure(figsize=(4, 4), constrained_layout=True)
ax = fig.add_subplot()
ax.set_ylabel("Meridional distance [m]")
ax.set_xlabel("Zonal distance [m]")
ax.set_xlim(-1.1, 1.1)
ax.set_ylim(-1.1, 1.1)
time_id = np.where(
data_other["time"] == timerange[0]
) # Indices of the data where time = 0
time_id_attr = np.where(
data_attr["time"] == timerange[0]
) # Indices of the data where time = 0
scatter = ax.scatter(
data_other["lon"].values[time_id],
data_other["lat"].values[time_id],
c="b",
s=5,
zorder=1,
)
scatter_attr = ax.scatter(
data_attr["lon"].values[time_id_attr],
data_attr["lat"].values[time_id_attr],
c="r",
s=40,
zorder=2,
)
circs = []
for lon_a, lat_a in zip(
data_attr["lon"].values[time_id_attr], data_attr["lat"].values[time_id_attr]
):
circs.append(
ax.add_patch(
plt.Circle(
(lon_a, lat_a), 0.25, facecolor="None", edgecolor="r", linestyle="--"
)
)
)
t = str(timerange[0].astype("timedelta64[s]"))
title = ax.set_title("Particles at t = " + t + " (Red particles are attractors)")
def animate(i):
t = str(timerange[i].astype("timedelta64[s]"))
title.set_text("Particles at t = " + t + "\n (Red particles are attractors)")
time_id = np.where(data_other["time"] == timerange[i])
time_id_attr = np.where(data_attr["time"] == timerange[i])
scatter.set_offsets(
np.c_[data_other["lon"].values[time_id], data_other["lat"].values[time_id]]
)
scatter_attr.set_offsets(
np.c_[
data_attr["lon"].values[time_id_attr], data_attr["lat"].values[time_id_attr]
]
)
for c, lon_a, lat_a in zip(
circs,
data_attr["lon"].values[time_id_attr],
data_attr["lat"].values[time_id_attr],
):
c.center = (lon_a, lat_a)
return (
scatter,
scatter_attr,
circs,
)
anim = FuncAnimation(fig, animate, frames=len(timerange), interval=500, blit=True)
data_xarray.close()
[5]:
HTML(anim.to_jshtml())
[5]: