Skip to content

Commit f979f90

Browse files
reint-fischerreint-fischer
authored andcommitted
move tutorial.ipynb to explanation.md
1 parent 6751ac9 commit f979f90

2 files changed

Lines changed: 193 additions & 594 deletions

File tree

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
---
2+
file_format: mystnb
3+
kernelspec:
4+
name: python3
5+
---
6+
7+
# The Parcels Kernel loop
8+
9+
This tutorial explains how Parcels executes multiple Kernels, and what happens under the hood when you combine Kernels.
10+
11+
This is probably not very relevant when you only use the built-in Advection kernels, but can be important when you are writing and combining your own Kernels!
12+
13+
## Background
14+
15+
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.
16+
17+
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.
18+
19+
## Basic implementation
20+
21+
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`.
22+
23+
1. Initialise an extra Variable `particles.dlon=0`
24+
25+
2. Within the Kernel loop, for each particle:
26+
1. Update `particles.lon += particles.dlon`
27+
28+
2. Update `particles.time += particles.dt` (except for on the first iteration of the Kernel loop)<br>
29+
30+
3. Set variable `particles.dlon = 0`
31+
32+
4. For each Kernel in the list of Kernels:
33+
1. Execute the Kernel
34+
35+
2. Update `particles.dlon` by adding the change in longitude, if needed
36+
37+
5. If `outputdt` is a multiple of `particles.time`, write `particles.lon` and `particles.time` to zarr output file
38+
39+
Besides having commutable Kernels, the main advantage of this implementation is that, when using Field Sampling with e.g. `particles.temp = fieldset.Temp[particles.time, particles.z, particles.lat, particles.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.
40+
41+
## Example with multiple Kernels
42+
43+
Below is a simple example of some particles at the surface of the ocean. We create an idealised zonal wind flow that will "push" a particle that is already affected by the surface currents. The Kernel loop ensures that these two forces act at the same time and location, as we will show.
44+
45+
```{code-cell}
46+
import matplotlib.pyplot as plt
47+
import numpy as np
48+
import xarray as xr
49+
50+
import parcels
51+
52+
# Load the CopernicusMarine data in the Agulhas region from the example_datasets
53+
example_dataset_folder = parcels.download_example_dataset(
54+
"CopernicusMarine_data_for_Argo_tutorial"
55+
)
56+
57+
ds_fields = xr.open_mfdataset(f"{example_dataset_folder}/*.nc", combine="by_coords")
58+
ds_fields.load() # load the dataset into memory
59+
60+
# Create an idealised wind field and add it to the dataset
61+
tdim, ydim, xdim = (len(ds_fields.time),len(ds_fields.latitude), len(ds_fields.longitude))
62+
ds_fields["UWind"] = xr.DataArray(
63+
data=np.ones((tdim, ydim, xdim)) * np.sin(ds_fields.latitude.values)[None, :, None],
64+
coords=[ds_fields.time, ds_fields.latitude, ds_fields.longitude])
65+
66+
ds_fields["VWind"] = xr.DataArray(
67+
data=np.zeros((tdim, ydim, xdim)),
68+
coords=[ds_fields.time, ds_fields.latitude, ds_fields.longitude])
69+
70+
fieldset = parcels.FieldSet.from_copernicusmarine(ds_fields)
71+
72+
# Set unit converters for custom wind fields
73+
fieldset.UWind.units = parcels.GeographicPolar()
74+
fieldset.VWind.units = parcels.Geographic()
75+
```
76+
77+
Now we define a wind kernel that uses a forward Euler method to apply the wind forcing. Note that we update the `particles.dlon` and `particles.dlat` variables, rather than `particles.lon` and `particles.lat` directly.
78+
79+
```{code-cell}
80+
def wind_kernel(particles, fieldset):
81+
dt_float = particles.dt / np.timedelta64(1, 's')
82+
particles.dlon += (
83+
fieldset.UWind[particles] * dt_float
84+
)
85+
particles.dlat += (
86+
fieldset.VWind[particles] * dt_float
87+
)
88+
```
89+
90+
Run a simulation where we apply first kernels as `[AdvectionRK4, wind_kernel]`
91+
92+
```{code-cell}
93+
:tags: [hide-output]
94+
npart = 10
95+
z = np.repeat(ds_fields.depth[0].values, npart)
96+
lons = np.repeat(31, npart)
97+
lats = np.linspace(-32.5, -30.5, npart)
98+
99+
pset = parcels.ParticleSet(fieldset, pclass=parcels.Particle, z=z, lat=lats, lon=lons)
100+
output_file = parcels.ParticleFile(
101+
store="advection_then_wind.zarr", outputdt=timedelta(hours=6)
102+
)
103+
pset.execute(
104+
[parcels.kernels.AdvectionRK4, wind_kernel],
105+
runtime=timedelta(days=5),
106+
dt=timedelta(hours=1),
107+
output_file=output_file,
108+
)
109+
```
110+
111+
And also run a simulation where we apply the kernels in the reverse order as `[wind_kernel, AdvectionRK4]`
112+
113+
```{code-cell}
114+
pset_reverse = parcels.ParticleSet(
115+
fieldset, pclass=parcels.Particle, z=z, lat=lats, lon=lons
116+
)
117+
output_file_reverse = parcels.ParticleFile(
118+
store="wind_then_advection.zarr", outputdt=timedelta(hours=6)
119+
)
120+
pset_reverse.execute(
121+
[wind_kernel, parcels.kernels.AdvectionRK4],
122+
runtime=timedelta(days=5),
123+
dt=timedelta(hours=1),
124+
output_file=output_file_reverse,
125+
)
126+
```
127+
128+
Finally, plot the trajectories to show that they are identical in the two simulations.
129+
130+
```{code-cell}
131+
# Plot the resulting particle trajectories overlapped for both cases
132+
advection_then_wind = xr.open_zarr("advection_then_wind.zarr")
133+
wind_then_advection = xr.open_zarr("wind_then_advection.zarr")
134+
plt.plot(wind_then_advection.lon.T, wind_then_advection.lat.T, "-")
135+
plt.plot(advection_then_wind.lon.T, advection_then_wind.lat.T, "--", c="k", alpha=0.7)
136+
plt.show()
137+
```
138+
139+
## Warning! Avoid updating particle locations directly in Kernels
140+
141+
It is better not to update `particles.lon` directly in a Kernel, as it can interfere with the loop above. Assigning a value to `particles.lon` in a Kernel will throw a warning.
142+
143+
Instead, update the local variable `particles.dlon`.
144+
145+
## Working with Status Codes
146+
147+
In order to capture errors in the Kernel loop, Parcels uses a Status Code system. There are several Status Codes, listed below.
148+
149+
```{code-cell}
150+
from parcels import StatusCode
151+
152+
for statuscode, val in StatusCode.__dict__.items():
153+
if statuscode.startswith("__"):
154+
continue
155+
print(f"{statuscode} = {val}")
156+
```
157+
158+
Once an error is thrown (for example, a Field Interpolation error), then the `particles.state` is updated to the corresponding status code. This gives you the flexibility to write a Kernel that checks for a status code and does something with it.
159+
160+
For example, you can write a Kernel that checks for `particles.state == StatusCode.ErrorOutOfBounds` and deletes the particle, and then append this to the Kernel list in `pset.execute()`.
161+
162+
```
163+
def CheckOutOfBounds(particles, fieldset):
164+
if particles.state == StatusCode.ErrorOutOfBounds:
165+
particles.delete()
166+
167+
168+
def CheckError(particles, fieldset):
169+
if particles.state >= 50: # This captures all Errors
170+
particles.delete()
171+
```
172+
173+
But of course, you can also write code for more sophisticated behaviour than just deleting the particle. It's up to you! Note that if you don't delete the particle, you will have to update the `particles.state = StatusCode.Success` yourself. For example:
174+
175+
```
176+
def Move1DegreeWest(particles, fieldset):
177+
if particles.state == StatusCode.ErrorOutOfBounds:
178+
particles.dlon -= 1.0
179+
particles.state = StatusCode.Success
180+
```
181+
182+
Or, if you want to make sure that particles don't escape through the water surface
183+
184+
```
185+
def KeepInOcean(particles, fieldset):
186+
if particles.state == StatusCode.ErrorThroughSurface:
187+
particles.dz = 0.0
188+
particles.state = StatusCode.Success
189+
```
190+
191+
Kernel functions such as the ones above can then be added to the list of kernels in `pset.execute()`.
192+
193+
Note that these Kernels that control what to do with `particles.state` should typically be added at the _end_ of the Kernel list, because otherwise later Kernels may overwrite the `particles.state` or the `particle_dlon` variables.

0 commit comments

Comments
 (0)