Skip to content

Commit 3bb5bcf

Browse files
Merge branch 'v4-dev' into implement_ei_in_search
2 parents 9b0e3ec + c46d038 commit 3bb5bcf

11 files changed

Lines changed: 74 additions & 95 deletions

File tree

docs/examples/tutorial_kernelloop.ipynb

Lines changed: 11 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,7 @@
2626
"\n",
2727
"When you run a Parcels simulation (i.e. a call to `pset.execute()`), the Kernel loop is the main part of the code that is executed. This part of the code loops through all particles and executes the Kernels that are defined for each particle.\n",
2828
"\n",
29-
"In order to make sure that the displacements of a particle in the different Kernels can be summed, all Kernels add to a _change_ in position (`particle_dlon`, `particle_dlat`, and `particle_ddepth`). This is important, because there are situations where movement kernels would otherwise not commute. Take the example of advecting particles by currents _and_ winds. If the particle would first be moved by the currents and then by the winds, the result could be different from first moving by the winds and then by the currents. Instead, by adding the changes in position, the ordering of the Kernels has no consequence on the particle displacement."
30-
]
31-
},
32-
{
33-
"cell_type": "markdown",
34-
"metadata": {},
35-
"source": [
36-
"<div class=\"alert alert-info\">\n",
37-
"\n",
38-
"__Note__ that the variables `particle_dlon`, `particle_dlat`, and `particle_ddepth` are defined by the wrapper function that Parcels generates for each Kernel. This is why you don't have to define these variables yourself when writing a Kernel. See [here](https://github.com/OceanParcels/parcels/blob/daa4b062ed8ae0b2be3d87367d6b45599d6f95db/parcels/kernel.py#L277-L294) for the implementation of the wrapper functions.\n",
39-
"</div>"
29+
"In order to make sure that the displacements of a particle in the different Kernels can be summed, all Kernels add to a _change_ in position (`particles.dlon`, `particles.dlat`, and `particles.ddepth`). This is important, because there are situations where movement kernels would otherwise not commute. Take the example of advecting particles by currents _and_ winds. If the particle would first be moved by the currents and then by the winds, the result could be different from first moving by the winds and then by the currents. Instead, by adding the changes in position, the ordering of the Kernels has no consequence on the particle displacement."
4030
]
4131
},
4232
{
@@ -52,29 +42,25 @@
5242
"source": [
5343
"Below is a structured overview of the Kernel loop is implemented. Note that this is for longitude only, but the same process is applied for latitude and depth.\n",
5444
"\n",
55-
"1. Define an extra variable `particle.lon_nextloop` for each particle, which is the longitude at the end of the Kernel loop. Inititalise it to `particle.lon`.\n",
56-
"\n",
57-
"2. Also define an extra variable `particle.time_nextloop` for each particle, which is the time at the end of the Kernel loop. Inititalise it to `particle.time`.\n",
45+
"1. Initialise an extra Variable `particles.lon=0` and `particles.time_nextloop = particles.time`\n",
5846
"\n",
59-
"3. Within the Kernel loop, for each particle:<br>\n",
47+
"2. Within the Kernel loop, for each particle:<br>\n",
6048
"\n",
61-
" 1. Update `particle.lon` with `particle.lon_nextloop`<br>\n",
49+
" 1. Update `particles.lon += particles.dlon`<br>\n",
6250
"\n",
63-
" 2. Update `particle.time` with `particle.time_nextloop`<br>\n",
51+
" 2. Set variable `particles.dlon = 0`<br>\n",
6452
"\n",
65-
" 3. Set local variable `particle_dlon = 0`<br>\n",
53+
" 3. Update `particles.time = particles.time_nextloop`\n",
6654
"\n",
6755
" 4. For each Kernel in the list of Kernels:\n",
6856
" \n",
6957
" 1. Execute the Kernel\n",
7058
" \n",
71-
" 2. Update `particle_dlon` by adding the change in longitude, if needed<br>\n",
59+
" 2. Update `particles.dlon` by adding the change in longitude, if needed<br>\n",
7260
"\n",
73-
" 5. Update `particle.lon_nextloop` with `particle.lon + particle_dlon`<br>\n",
74-
" \n",
75-
" 6. Update `particle.time_nextloop` with `particle.time + particle.dt`<br>\n",
61+
" 5. Update `particles.time_nextloop += particles.dt`<br>\n",
7662
"\n",
77-
" 7. If `outputdt` is a multiple of `particle.time`, write `particle.lon` and `particle.time` to zarr output file<br>\n",
63+
" 6. If `outputdt` is a multiple of `particle.time`, write `particle.lon` and `particle.time` to zarr output file<br>\n",
7864
"\n",
7965
"Besides having commutable Kernels, the main advantage of this implementation is that, when using Field Sampling with e.g. `particle.temp = fieldset.Temp[particle.time, particle.depth, particle.lat, particle.lon]`, the particle location stays the same throughout the entire Kernel loop. Additionally, this implementation ensures that the particle location is the same as the location of the sampled field in the output file."
8066
]
@@ -268,12 +254,12 @@
268254
"### 1. Avoid updating particle locations directly in Kernels\n",
269255
"It is better not to update `particle.lon` directly in a Kernel, as it can interfere with the loop above. Assigning a value to `particle.lon` in a Kernel will throw a warning. \n",
270256
"\n",
271-
"Instead, update the local variable `particle_dlon`.\n",
257+
"Instead, update the local variable `particle.dlon`.\n",
272258
"\n",
273259
"### 2. Be careful with updating particle variables that do not depend on Fields.\n",
274260
"While assigning the interpolated value of a `Field` to a Particle goes well in the loop above, this is not necessarily so for assigning other attributes. For example, a line like `particle.age += particle.dt` is executed directly so may result in the age being `dt` at `time = 0` in the output file. \n",
275261
"\n",
276-
"A workaround is to either initialise the age to `-dt`, or to increase the `age` only when `particle.time > 0` (using an `if` statement).\n",
262+
"A workaround is to either initialise the age to `-dt`, or to increase the `age` only when `particle.time > 0` (using an `np.where` statement).\n",
277263
"\n",
278264
"\n",
279265
"### 3. The last time is not written to file\n",

parcels/basegrid.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,9 @@ def search(self, z: float, y: float, x: float, ei=None) -> dict[str, tuple[int,
5454
- Unstructured grid: {"Z": (zi, zeta), "FACE": (fi, bcoords)}
5555
5656
Where:
57-
- index (int): The cell position of a particle along the given axis
57+
- index (int): The cell position of the particles along the given axis
5858
- barycentric_coordinates (float or np.ndarray): The coordinates defining
59-
a particle's position within the grid cell. For structured grids, this
59+
the particles positions within the grid cell. For structured grids, this
6060
is a single coordinate per axis; for unstructured grids, this can be
6161
an array of coordinates for the face polygon.
6262

parcels/field.py

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def _deal_with_errors(error, key, vector_type: VectorType):
4141
elif isinstance(key[-1], KernelParticle):
4242
key[-1].state = AllParcelsErrorCodes[type(error)]
4343
else:
44-
raise RuntimeError(f"{error}. Error could not be handled because particle was not part of the Field Sampling.")
44+
raise RuntimeError(f"{error}. Error could not be handled because particles was not part of the Field Sampling.")
4545

4646
if vector_type and "3D" in vector_type:
4747
return (0, 0, 0)
@@ -205,26 +205,26 @@ def _check_velocitysampling(self):
205205
stacklevel=2,
206206
)
207207

208-
def eval(self, time: datetime, z, y, x, particle=None, applyConversion=True):
208+
def eval(self, time: datetime, z, y, x, particles=None, applyConversion=True):
209209
"""Interpolate field values in space and time.
210210
211211
We interpolate linearly in time and apply implicit unit
212212
conversion to the result. Note that we defer to
213213
scipy.interpolate to perform spatial interpolation.
214214
"""
215-
if particle is None:
215+
if particles is None:
216216
_ei = None
217217
else:
218-
_ei = particle.ei[:, self.igrid]
218+
_ei = particles.ei[:, self.igrid]
219219

220220
tau, ti = _search_time_index(self, time)
221221
position = self.grid.search(z, y, x, ei=_ei)
222-
_update_particles_ei(particle, position, self)
223-
_update_particle_states_position(particle, position)
222+
_update_particles_ei(particles, position, self)
223+
_update_particle_states_position(particles, position)
224224

225225
value = self._interp_method(self, ti, position, tau, time, z, y, x)
226226

227-
_update_particle_states_interp_value(particle, value)
227+
_update_particle_states_interp_value(particles, value)
228228

229229
if applyConversion:
230230
value = self.units.to_target(value, z, y, x)
@@ -289,22 +289,22 @@ def vector_interp_method(self, method: Callable):
289289
_assert_same_function_signature(method, ref=ZeroInterpolator_Vector, context="Interpolation")
290290
self._vector_interp_method = method
291291

292-
def eval(self, time: datetime, z, y, x, particle=None, applyConversion=True):
292+
def eval(self, time: datetime, z, y, x, particles=None, applyConversion=True):
293293
"""Interpolate field values in space and time.
294294
295295
We interpolate linearly in time and apply implicit unit
296296
conversion to the result. Note that we defer to
297297
scipy.interpolate to perform spatial interpolation.
298298
"""
299-
if particle is None:
299+
if particles is None:
300300
_ei = None
301301
else:
302-
_ei = particle.ei[:, self.igrid]
302+
_ei = particles.ei[:, self.igrid]
303303

304304
tau, ti = _search_time_index(self.U, time)
305305
position = self.grid.search(z, y, x, ei=_ei)
306-
_update_particles_ei(particle, position, self)
307-
_update_particle_states_position(particle, position)
306+
_update_particles_ei(particles, position, self)
307+
_update_particle_states_position(particles, position)
308308

309309
if self._vector_interp_method is None:
310310
u = self.U._interp_method(self.U, ti, position, tau, time, z, y, x)
@@ -322,7 +322,7 @@ def eval(self, time: datetime, z, y, x, particle=None, applyConversion=True):
322322
(u, v, w) = self._vector_interp_method(self, ti, position, tau, time, z, y, x, applyConversion)
323323

324324
for vel in (u, v, w):
325-
_update_particle_states_interp_value(particle, vel)
325+
_update_particle_states_interp_value(particles, vel)
326326

327327
if applyConversion and ("3D" in self.vector_type):
328328
w = self.W.units.to_target(w, z, y, x) if self.W else 0.0
@@ -362,34 +362,34 @@ def _update_particles_ei(particles, position, field):
362362
)
363363

364364

365-
def _update_particle_states_position(particle, position):
365+
def _update_particle_states_position(particles, position):
366366
"""Update the particle states based on the position dictionary."""
367-
if particle: # TODO also support uxgrid search
367+
if particles: # TODO also support uxgrid search
368368
for dim in ["X", "Y"]:
369369
if dim in position:
370-
particle.state = np.maximum(
371-
np.where(position[dim][0] == -1, StatusCode.ErrorOutOfBounds, particle.state), particle.state
370+
particles.state = np.maximum(
371+
np.where(position[dim][0] == -1, StatusCode.ErrorOutOfBounds, particles.state), particles.state
372372
)
373-
particle.state = np.maximum(
374-
np.where(position[dim][0] == GRID_SEARCH_ERROR, StatusCode.ErrorGridSearching, particle.state),
375-
particle.state,
373+
particles.state = np.maximum(
374+
np.where(position[dim][0] == GRID_SEARCH_ERROR, StatusCode.ErrorGridSearching, particles.state),
375+
particles.state,
376376
)
377377
if "Z" in position:
378-
particle.state = np.maximum(
379-
np.where(position["Z"][0] == RIGHT_OUT_OF_BOUNDS, StatusCode.ErrorOutOfBounds, particle.state),
380-
particle.state,
378+
particles.state = np.maximum(
379+
np.where(position["Z"][0] == RIGHT_OUT_OF_BOUNDS, StatusCode.ErrorOutOfBounds, particles.state),
380+
particles.state,
381381
)
382-
particle.state = np.maximum(
383-
np.where(position["Z"][0] == LEFT_OUT_OF_BOUNDS, StatusCode.ErrorThroughSurface, particle.state),
384-
particle.state,
382+
particles.state = np.maximum(
383+
np.where(position["Z"][0] == LEFT_OUT_OF_BOUNDS, StatusCode.ErrorThroughSurface, particles.state),
384+
particles.state,
385385
)
386386

387387

388-
def _update_particle_states_interp_value(particle, value):
388+
def _update_particle_states_interp_value(particles, value):
389389
"""Update the particle states based on the interpolated value, but only if state is not an Error already."""
390-
if particle:
391-
particle.state = np.maximum(
392-
np.where(np.isnan(value), StatusCode.ErrorInterpolation, particle.state), particle.state
390+
if particles:
391+
particles.state = np.maximum(
392+
np.where(np.isnan(value), StatusCode.ErrorInterpolation, particles.state), particles.state
393393
)
394394

395395

parcels/kernel.py

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from parcels.application_kernels.advection import (
1212
AdvectionAnalytical,
13+
AdvectionRK4,
1314
AdvectionRK45,
1415
)
1516
from parcels.basegrid import GridType
@@ -24,7 +25,6 @@
2425
_raise_time_extrapolation_error,
2526
)
2627
from parcels.tools.warnings import KernelWarning
27-
from tests.common_kernels import DoNothing
2828

2929
if TYPE_CHECKING:
3030
from collections.abc import Callable
@@ -69,7 +69,7 @@ def __init__(
6969
for f in pyfuncs:
7070
if not isinstance(f, types.FunctionType):
7171
raise TypeError(f"Argument pyfunc should be a function or list of functions. Got {type(f)}")
72-
_assert_same_function_signature(f, ref=DoNothing, context="Kernel")
72+
_assert_same_function_signature(f, ref=AdvectionRK4, context="Kernel")
7373

7474
if len(pyfuncs) == 0:
7575
raise ValueError("List of `pyfuncs` should have at least one function.")
@@ -118,21 +118,20 @@ def add_positionupdate_kernels(self):
118118
def Setcoords(particles, fieldset): # pragma: no cover
119119
import numpy as np # noqa
120120

121+
particles.lon += particles.dlon
122+
particles.lat += particles.dlat
123+
particles.depth += particles.ddepth
124+
121125
particles.dlon = 0
122126
particles.dlat = 0
123127
particles.ddepth = 0
124-
particles.lon = particles.lon_nextloop
125-
particles.lat = particles.lat_nextloop
126-
particles.depth = particles.depth_nextloop
128+
127129
particles.time = particles.time_nextloop
128130

129-
def Updatecoords(particles, fieldset): # pragma: no cover
130-
particles.lon_nextloop = particles.lon + particles.dlon
131-
particles.lat_nextloop = particles.lat + particles.dlat
132-
particles.depth_nextloop = particles.depth + particles.ddepth
131+
def UpdateTime(particles, fieldset): # pragma: no cover
133132
particles.time_nextloop = particles.time + particles.dt
134133

135-
self._pyfuncs = (Setcoords + self + Updatecoords)._pyfuncs
134+
self._pyfuncs = (Setcoords + self + UpdateTime)._pyfuncs
136135

137136
def check_fieldsets_in_kernels(self, pyfunc): # TODO v4: this can go into another method? assert_is_compatible()?
138137
"""

parcels/particle.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,13 +170,11 @@ def get_default_particle(spatial_dtype: np.float32 | np.float64) -> ParticleClas
170170
dtype=spatial_dtype,
171171
attrs={"standard_name": "longitude", "units": "degrees_east", "axis": "X"},
172172
),
173-
Variable("lon_nextloop", dtype=spatial_dtype, to_write=False),
174173
Variable(
175174
"lat",
176175
dtype=spatial_dtype,
177176
attrs={"standard_name": "latitude", "units": "degrees_north", "axis": "Y"},
178177
),
179-
Variable("lat_nextloop", dtype=spatial_dtype, to_write=False),
180178
Variable(
181179
"depth",
182180
dtype=spatial_dtype,
@@ -185,7 +183,6 @@ def get_default_particle(spatial_dtype: np.float32 | np.float64) -> ParticleClas
185183
Variable("dlon", dtype=spatial_dtype, to_write=False),
186184
Variable("dlat", dtype=spatial_dtype, to_write=False),
187185
Variable("ddepth", dtype=spatial_dtype, to_write=False),
188-
Variable("depth_nextloop", dtype=spatial_dtype, to_write=False),
189186
Variable(
190187
"time",
191188
dtype=_SAME_AS_FIELDSET_TIME_INTERVAL.VALUE,

parcels/particlefile.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -262,9 +262,9 @@ def write_latest_locations(self, pset, time):
262262
time :
263263
Time at which to write ParticleSet. Note that typically this would be pset.time_nextloop
264264
"""
265-
for var in ["lon", "lat", "depth", "time"]:
266-
pset._data[f"{var}"] = pset._data[f"{var}_nextloop"]
267-
265+
for var in ["lon", "lat", "depth"]:
266+
pset._data[f"{var}"] += pset._data[f"d{var}"]
267+
pset._data["time"] = pset._data["time_nextloop"]
268268
self.write(pset, time)
269269

270270

parcels/particleset.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,12 @@
2222

2323

2424
class ParticleSet:
25-
"""Class for storing particle and executing kernel over them.
25+
"""Class for storing particles and executing kernel over them.
2626
2727
Please note that this currently only supports fixed size particle sets, meaning that the particle set only
2828
holds the particles defined on construction. Individual particles can neither be added nor deleted individually,
29-
and individual particles can only be deleted as a set procedurally (i.e. by 'particle.delete()'-call during
30-
kernel execution).
29+
and individual particles can only be deleted as a set procedurally (i.e. by changing their state to 'StatusCode.Delete'
30+
during kernel execution).
3131
3232
Parameters
3333
----------
@@ -128,9 +128,6 @@ def __init__(
128128
lat=lat,
129129
depth=depth,
130130
time=time,
131-
lon_nextloop=lon,
132-
lat_nextloop=lat,
133-
depth_nextloop=depth,
134131
time_nextloop=time,
135132
trajectory=trajectory_ids,
136133
),

parcels/tools/statuscodes.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,10 @@ def _raise_time_extrapolation_error(time: float, field=None):
110110

111111

112112
class KernelError(RuntimeError):
113-
"""General particle kernel error with optional custom message."""
113+
"""General particles kernel error with optional custom message."""
114114

115-
def __init__(self, particle, fieldset=None, msg=None):
116-
message = f"{particle.state}\nParticle {particle}\nTime: {particle.time}\ntimestep dt: {particle.dt}\n"
115+
def __init__(self, particles, fieldset=None, msg=None):
116+
message = f"{particles.state}\nParticle {particles}\nTime: {particles.time}\ntimestep dt: {particles.dt}\n"
117117
if msg:
118118
message += msg
119119
super().__init__(message)

parcels/xgrid.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -508,7 +508,7 @@ def _search_1d_array(
508508
x: float,
509509
) -> tuple[int, int]:
510510
"""
511-
Searches for the particle location in a 1D array and returns barycentric coordinate along dimension.
511+
Searches for particle locations in a 1D array and returns barycentric coordinate along dimension.
512512
513513
Assumptions:
514514
- array is strictly monotonically increasing.

0 commit comments

Comments
 (0)