Skip to content

Commit fbc33f3

Browse files
Merge branch 'v4-dev' into updates_from_virtualship_dev
2 parents 2faa3c0 + 30b0dd4 commit fbc33f3

20 files changed

Lines changed: 390 additions & 334 deletions

.github/workflows/cache-pixi-lock.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ name: Generate and cache Pixi lockfile
22

33
on:
44
workflow_call:
5+
inputs:
6+
pixi-version:
7+
type: string
58
outputs:
69
cache-id:
710
description: "The lock file contents"
@@ -30,7 +33,7 @@ jobs:
3033
- uses: prefix-dev/setup-pixi@v0.9.0
3134
if: ${{ !steps.restore.outputs.cache-hit }}
3235
with:
33-
pixi-version: v0.56.0
36+
pixi-version: ${{ inputs.pixi-version }}
3437
run-install: false
3538
- name: Run pixi lock
3639
if: ${{ !steps.restore.outputs.cache-hit }}

docs/examples_v3/tutorial_kernelloop.ipynb

Lines changed: 12 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +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 (`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."
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.dz`). 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."
3030
]
3131
},
3232
{
@@ -40,29 +40,27 @@
4040
"cell_type": "markdown",
4141
"metadata": {},
4242
"source": [
43-
"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",
43+
"Below is a structured overview of the Kernel loop is implemented. Note that this is for `lon` only, but the same process is applied for `lat` and `z`.\n",
4444
"\n",
45-
"1. Initialise an extra Variable `particles.lon=0` and `particles.time_nextloop = particles.time`\n",
45+
"1. Initialise an extra Variable `particles.dlon=0`\n",
4646
"\n",
4747
"2. Within the Kernel loop, for each particle:<br>\n",
4848
"\n",
4949
" 1. Update `particles.lon += particles.dlon`<br>\n",
5050
"\n",
51-
" 2. Set variable `particles.dlon = 0`<br>\n",
51+
" 2. Update `particles.time += particles.dt` (except for on the first iteration of the Kernel loop)<br>\n",
5252
"\n",
53-
" 3. Update `particles.time = particles.time_nextloop`\n",
53+
" 3. Set variable `particles.dlon = 0`<br>\n",
5454
"\n",
5555
" 4. For each Kernel in the list of Kernels:\n",
5656
" \n",
5757
" 1. Execute the Kernel\n",
5858
" \n",
5959
" 2. Update `particles.dlon` by adding the change in longitude, if needed<br>\n",
6060
"\n",
61-
" 5. Update `particles.time_nextloop += particles.dt`<br>\n",
61+
" 5. If `outputdt` is a multiple of `particle.time`, write `particle.lon` and `particle.time` to zarr output file<br>\n",
6262
"\n",
63-
" 6. If `outputdt` is a multiple of `particle.time`, write `particle.lon` and `particle.time` to zarr output file<br>\n",
64-
"\n",
65-
"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."
63+
"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.z, 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."
6664
]
6765
},
6866
{
@@ -155,10 +153,10 @@
155153
"source": [
156154
"def wind_kernel(particle, fieldset, time):\n",
157155
" particle_dlon += (\n",
158-
" fieldset.UWind[time, particle.depth, particle.lat, particle.lon] * particle.dt\n",
156+
" fieldset.UWind[time, particle.z, particle.lat, particle.lon] * particle.dt\n",
159157
" )\n",
160158
" particle_dlat += (\n",
161-
" fieldset.VWind[time, particle.depth, particle.lat, particle.lon] * particle.dt\n",
159+
" fieldset.VWind[time, particle.z, particle.lat, particle.lon] * particle.dt\n",
162160
" )"
163161
]
164162
},
@@ -242,30 +240,16 @@
242240
"cell_type": "markdown",
243241
"metadata": {},
244242
"source": [
245-
"## Caveats"
243+
"## Caveat: Avoid updating particle locations directly in Kernels"
246244
]
247245
},
248246
{
249247
"cell_type": "markdown",
250248
"metadata": {},
251249
"source": [
252-
"There are a few important considerations to take into account when writing Kernels\n",
253-
"\n",
254-
"### 1. Avoid updating particle locations directly in Kernels\n",
255250
"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",
256251
"\n",
257-
"Instead, update the local variable `particle.dlon`.\n",
258-
"\n",
259-
"### 2. Be careful with updating particle variables that do not depend on Fields.\n",
260-
"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",
261-
"\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",
263-
"\n",
264-
"\n",
265-
"### 3. The last time is not written to file\n",
266-
"Because the location at the start of the loop is written at the end of the Kernel loop, the last `particle.time` of the particle is not written to file. This is similar behaviour to e.g. `np.arange(start, stop)`, which also doesn't include the `stop` value itself. \n",
267-
"\n",
268-
"If you do want to write the last time to file, you can increase the `runtime` or `endtime` by `dt` (although this may cause an OutsideTimeInterval if your run was to the end of the available hydrodynamic data), or you can call `pfile.write_latest_locations(pset, time=pset[0].time_nextloop)`. Note that in the latter case, the particle locations (longitude, latitude and depth) will be updated, but other variables will _not_ be updated as the Kernels are not run again."
252+
"Instead, update the local variable `particle.dlon`."
269253
]
270254
},
271255
{
@@ -355,7 +339,7 @@
355339
"source": [
356340
"def KeepInOcean(particle, fieldset, time):\n",
357341
" if particle.state == StatusCode.ErrorThroughSurface:\n",
358-
" particle_ddepth = 0.0\n",
342+
" particle_dz = 0.0\n",
359343
" particle.state = StatusCode.Success"
360344
]
361345
},

src/parcels/_core/field.py

Lines changed: 40 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -218,12 +218,9 @@ def eval(self, time: datetime, z, y, x, particles=None, applyConversion=True):
218218
else:
219219
_ei = particles.ei[:, self.igrid]
220220

221-
tau, ti = _search_time_index(self, time)
222-
position = self.grid.search(z, y, x, ei=_ei)
223-
_update_particles_ei(particles, position, self)
224-
_update_particle_states_position(particles, position)
221+
particle_positions, grid_positions = _get_positions(self, time, z, y, x, particles, _ei)
225222

226-
value = self._interp_method(self, ti, position, tau, time, z, y, x)
223+
value = self._interp_method(particle_positions, grid_positions, self)
227224

228225
_update_particle_states_interp_value(particles, value)
229226

@@ -304,20 +301,17 @@ def eval(self, time: datetime, z, y, x, particles=None, applyConversion=True):
304301
else:
305302
_ei = particles.ei[:, self.igrid]
306303

307-
tau, ti = _search_time_index(self.U, time)
308-
position = self.grid.search(z, y, x, ei=_ei)
309-
_update_particles_ei(particles, position, self)
310-
_update_particle_states_position(particles, position)
304+
particle_positions, grid_positions = _get_positions(self.U, time, z, y, x, particles, _ei)
311305

312306
if self._vector_interp_method is None:
313-
u = self.U._interp_method(self.U, ti, position, tau, time, z, y, x)
314-
v = self.V._interp_method(self.V, ti, position, tau, time, z, y, x)
307+
u = self.U._interp_method(particle_positions, grid_positions, self.U)
308+
v = self.V._interp_method(particle_positions, grid_positions, self.V)
315309
if "3D" in self.vector_type:
316-
w = self.W._interp_method(self.W, ti, position, tau, time, z, y, x)
310+
w = self.W._interp_method(particle_positions, grid_positions, self.W)
317311
else:
318312
w = 0.0
319313
else:
320-
(u, v, w) = self._vector_interp_method(self, ti, position, tau, time, z, y, x)
314+
(u, v, w) = self._vector_interp_method(particle_positions, grid_positions, self)
321315

322316
if applyConversion:
323317
u = self.U.units.to_target(u, z, y, x)
@@ -343,45 +337,54 @@ def __getitem__(self, key):
343337
return _deal_with_errors(error, key, vector_type=self.vector_type)
344338

345339

346-
def _update_particles_ei(particles, position, field):
340+
def _update_particles_ei(particles, grid_positions: dict, field: Field):
347341
"""Update the element index (ei) of the particles"""
348342
if particles is not None:
349343
if isinstance(field.grid, XGrid):
350344
particles.ei[:, field.igrid] = field.grid.ravel_index(
351345
{
352-
"X": position["X"][0],
353-
"Y": position["Y"][0],
354-
"Z": position["Z"][0],
346+
"X": grid_positions["X"]["index"],
347+
"Y": grid_positions["Y"]["index"],
348+
"Z": grid_positions["Z"]["index"],
355349
}
356350
)
357351
elif isinstance(field.grid, UxGrid):
358352
particles.ei[:, field.igrid] = field.grid.ravel_index(
359353
{
360-
"Z": position["Z"][0],
361-
"FACE": position["FACE"][0],
354+
"Z": grid_positions["Z"]["index"],
355+
"FACE": grid_positions["FACE"]["index"],
362356
}
363357
)
364358

365359

366-
def _update_particle_states_position(particles, position):
360+
def _update_particle_states_position(particles, grid_positions: dict):
367361
"""Update the particle states based on the position dictionary."""
368362
if particles: # TODO also support uxgrid search
369363
for dim in ["X", "Y"]:
370-
if dim in position:
364+
if dim in grid_positions:
371365
particles.state = np.maximum(
372-
np.where(position[dim][0] == -1, StatusCode.ErrorOutOfBounds, particles.state), particles.state
366+
np.where(grid_positions[dim]["index"] == -1, StatusCode.ErrorOutOfBounds, particles.state),
367+
particles.state,
373368
)
374369
particles.state = np.maximum(
375-
np.where(position[dim][0] == GRID_SEARCH_ERROR, StatusCode.ErrorGridSearching, particles.state),
370+
np.where(
371+
grid_positions[dim]["index"] == GRID_SEARCH_ERROR,
372+
StatusCode.ErrorGridSearching,
373+
particles.state,
374+
),
376375
particles.state,
377376
)
378-
if "Z" in position:
377+
if "Z" in grid_positions:
379378
particles.state = np.maximum(
380-
np.where(position["Z"][0] == RIGHT_OUT_OF_BOUNDS, StatusCode.ErrorOutOfBounds, particles.state),
379+
np.where(
380+
grid_positions["Z"]["index"] == RIGHT_OUT_OF_BOUNDS, StatusCode.ErrorOutOfBounds, particles.state
381+
),
381382
particles.state,
382383
)
383384
particles.state = np.maximum(
384-
np.where(position["Z"][0] == LEFT_OUT_OF_BOUNDS, StatusCode.ErrorThroughSurface, particles.state),
385+
np.where(
386+
grid_positions["Z"]["index"] == LEFT_OUT_OF_BOUNDS, StatusCode.ErrorThroughSurface, particles.state
387+
),
385388
particles.state,
386389
)
387390

@@ -469,3 +472,14 @@ def _assert_same_time_interval(fields: list[Field]) -> None:
469472
raise ValueError(
470473
f"Fields must have the same time domain. {fields[0].name}: {reference_time_interval}, {field.name}: {field.time_interval}"
471474
)
475+
476+
477+
def _get_positions(field: Field, time, z, y, x, particles, _ei) -> tuple[dict, dict]:
478+
"""Initialize and populate particle_positions and grid_positions dictionaries"""
479+
particle_positions = {"time": time, "z": z, "lat": y, "lon": x}
480+
grid_positions = {}
481+
grid_positions.update(_search_time_index(field, time))
482+
grid_positions.update(field.grid.search(z, y, x, ei=_ei))
483+
_update_particles_ei(particles, grid_positions, field)
484+
_update_particle_states_position(particles, grid_positions)
485+
return particle_positions, grid_positions

src/parcels/_core/index_search.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,14 +75,19 @@ def _search_time_index(field: Field, time: datetime):
7575
if the sampled value is outside the time value range.
7676
"""
7777
if field.time_interval is None:
78-
return np.zeros(shape=time.shape, dtype=np.float32), np.zeros(shape=time.shape, dtype=np.int32)
78+
return {
79+
"T": {
80+
"index": np.zeros(shape=time.shape, dtype=np.int32),
81+
"bcoord": np.zeros(shape=time.shape, dtype=np.float32),
82+
}
83+
}
7984

8085
if not field.time_interval.is_all_time_in_interval(time):
8186
_raise_outside_time_interval_error(time, field=None)
8287

8388
ti = np.searchsorted(field.data.time.data, time, side="right") - 1
8489
tau = (time - field.data.time.data[ti]) / (field.data.time.data[ti + 1] - field.data.time.data[ti])
85-
return np.atleast_1d(tau), np.atleast_1d(ti)
90+
return {"T": {"index": np.atleast_1d(ti), "bcoord": np.atleast_1d(tau)}}
8691

8792

8893
def curvilinear_point_in_cell(grid, y: np.ndarray, x: np.ndarray, yi: np.ndarray, xi: np.ndarray):

src/parcels/_core/kernel.py

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ def __init__(
7575
self._fieldset = fieldset
7676
self._ptype = ptype
7777

78-
self._positionupdate_kernels_added = False
78+
self._positionupdate_kernel_added = False
7979

8080
for f in pyfuncs:
8181
self.check_fieldsets_in_kernels(f)
@@ -111,23 +111,19 @@ def remove_deleted(self, pset):
111111
if len(indices) > 0:
112112
pset.remove_indices(indices)
113113

114-
def add_positionupdate_kernels(self):
114+
def add_positionupdate_kernel(self):
115115
# Adding kernels that set and update the coordinate changes
116-
def Setcoords(particles, fieldset): # pragma: no cover
116+
def PositionUpdate(particles, fieldset): # pragma: no cover
117117
particles.lon += particles.dlon
118118
particles.lat += particles.dlat
119119
particles.z += particles.dz
120+
particles.time += particles.dt
120121

121122
particles.dlon = 0
122123
particles.dlat = 0
123124
particles.dz = 0
124125

125-
particles.time = particles.time_nextloop
126-
127-
def UpdateTime(particles, fieldset): # pragma: no cover
128-
particles.time_nextloop = particles.time + particles.dt
129-
130-
self._pyfuncs = (Setcoords + self + UpdateTime)._pyfuncs
126+
self._pyfuncs = (PositionUpdate + self)._pyfuncs
131127

132128
def check_fieldsets_in_kernels(self, pyfunc): # TODO v4: this can go into another method? assert_is_compatible()?
133129
"""
@@ -234,14 +230,13 @@ def execute(self, pset, endtime, dt):
234230

235231
pset._data["state"][:] = StatusCode.Evaluate
236232

237-
if not self._positionupdate_kernels_added:
238-
self.add_positionupdate_kernels()
239-
self._positionupdate_kernels_added = True
240-
241233
while (len(pset) > 0) and np.any(np.isin(pset.state, [StatusCode.Evaluate, StatusCode.Repeat])):
242-
time_to_endtime = compute_time_direction * (endtime - pset.time_nextloop)
234+
time_to_endtime = compute_time_direction * (endtime - pset.time)
243235

244-
if all(time_to_endtime <= 0):
236+
evaluate_particles = (np.isin(pset.state, [StatusCode.Success, StatusCode.Evaluate])) & (
237+
time_to_endtime >= 0
238+
)
239+
if not np.any(evaluate_particles):
245240
return StatusCode.Success
246241

247242
# adapt dt to end exactly on endtime
@@ -251,7 +246,6 @@ def execute(self, pset, endtime, dt):
251246
pset.dt = np.minimum(np.maximum(pset.dt, -time_to_endtime), 0)
252247

253248
# run kernels for all particles that need to be evaluated
254-
evaluate_particles = (pset.state == StatusCode.Evaluate) & (pset.dt != 0)
255249
for f in self._pyfuncs:
256250
f(pset[evaluate_particles], self._fieldset)
257251

@@ -265,9 +259,9 @@ def execute(self, pset, endtime, dt):
265259
if not hasattr(self.fieldset, "RK45_tol"):
266260
pset._data["dt"][:] = dt
267261

268-
# Reset particle state for particles that signalled success and have not reached endtime yet
269-
particles_to_evaluate = (pset.state == StatusCode.Success) & (time_to_endtime > 0)
270-
pset[particles_to_evaluate].state = StatusCode.Evaluate
262+
# Set particle state for particles that reached endtime
263+
particles_endofloop = (pset.state == StatusCode.Evaluate) & (pset.time == endtime)
264+
pset[particles_endofloop].state = StatusCode.EndofLoop
271265

272266
# delete particles that signalled deletion
273267
self.remove_deleted(pset)
@@ -284,4 +278,9 @@ def execute(self, pset, endtime, dt):
284278
else:
285279
error_func(pset[inds].z, pset[inds].lat, pset[inds].lon)
286280

281+
# Only add PositionUpdate kernel at the end of the first execute call to avoid adding dt to time too early
282+
if not self._positionupdate_kernel_added:
283+
self.add_positionupdate_kernel()
284+
self._positionupdate_kernel_added = True
285+
287286
return pset

src/parcels/_core/particle.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,6 @@ def get_default_particle(spatial_dtype: np.float32 | np.float64) -> ParticleClas
180180
dtype=_SAME_AS_FIELDSET_TIME_INTERVAL.VALUE,
181181
attrs={"standard_name": "time", "units": "seconds", "axis": "T"},
182182
),
183-
Variable("time_nextloop", dtype=_SAME_AS_FIELDSET_TIME_INTERVAL.VALUE, to_write=False),
184183
Variable(
185184
"trajectory",
186185
dtype=np.int64,

0 commit comments

Comments
 (0)