πŸ–₯️ Choosing a timestep and integration method#

In Parcels, we can simulate virtual particles through time. Kernels which compute a process as a function of time, such as advection, can do so by numerically integrating the process over each timestep dt. In this guide we explore how to control the numerical accuracy of a Parcels simulation by choosing a timestep dt and an integration method.

Note

This notebook is based on the workshop on writing custom Kernels from the 10-year Parcels event.

Timestep#

The choice of timestep affects the accuracy and duration of our simulation. The required accuracy of the integration ultimately depends on the desired application of the particle trajectories. In general, we want our timestep to be small enough to smoothly resolve the time-evolving process of interest, and not so small that our simulations take forever. Here, we will first look at how to estimate a priori what an appropriate timestep could be, and then at how to test a Parcels simulation to check the accuracy.

A priori estimation of dt#

In this example, we will estimate the appropriate timestep to compute advection of a virtual particle through an ocean current field.

Advection, the change in position \(\mathbf{x}(t) = (x(t), y(t))\) at time \(t\), can be described by the equation:

\[ \begin{aligned} \frac{\text{d}\mathbf{x}(t)}{\text{d}t} = \mathbf{v}(\mathbf{x}(t),t), \end{aligned} \]

where \(\mathbf{v}(\mathbf{x},t) = (u(\mathbf{x},t), v(\mathbf{x},t))\) describes the ocean velocity field at position \(\mathbf{x}\) at time \(t\).

To estimate the timescale that we want to resolve, we can think about the scales at which advection varies. Here we use the daily velocity fields at 1/12th degree horizontal resolution from the Copernicus Marine Service. This means that the velocity will vary in time at scales >= 24 hours, and in space at scales >= 1/12th degree.

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import polars as pl

import parcels
import parcels.tutorial

# Load the CopernicusMarine data in the Agulhas region from the example_datasets
ds_fields = parcels.tutorial.open_dataset(
    "CopernicusMarine_data_for_Argo_tutorial/data"
)

# Convert to SGRID-compliant dataset and create FieldSet
fields = {"U": ds_fields["uo"], "V": ds_fields["vo"]}
ds_fset = parcels.convert.copernicusmarine_to_sgrid(fields=fields)
fieldset = parcels.FieldSet.from_sgrid_conventions(ds_fset)

# Convert the FieldSet to windowed arrays for better performance
fieldset = fieldset.to_windowed_arrays()

# Check field resolution in time and space
print(
    f"dt = {np.unique(np.diff(ds_fields.time.values).astype('timedelta64[h]'))} hours"
)
print(f"dy = {np.unique(np.diff(ds_fields.latitude.values))} degrees")
print(f"dx = {np.unique(np.diff(ds_fields.longitude.values))} degrees")
dt = [24] hours
dy = [0.08333206 0.08333588] degrees
dx = [0.08332825 0.08334351] degrees

In our Parcels simulation, we must ensure that:

  • dt < 24 hours

  • particles.dx/particles.dy < 1/12th degree.

We can rewrite the second condition using the scale relation:

\[ \begin{aligned} \text{dx} \propto U \text{d}t, \end{aligned} \]

to express the condition as a function of dt:

\[ \begin{aligned} U \text{d}t < 1/12 \text{ (degrees lon)} \end{aligned} \]
\[ \begin{aligned} \text{d}t < \frac{1}{12 U} \end{aligned} \]

We can use the maximum velocity (in degrees s-1) in our flow field as a conservative estimate for \(U\):

# Use the maximum velocity at the surface to scale dx/dy
U_max_surface = np.nanmax(np.hypot(ds_fields.uo, ds_fields.vo))
print(f"U_max = {U_max_surface:.2f} m s-1")

# convert to degrees s-1 (at lat = 30 deg S)
U_max_surface_deg = U_max_surface / (1852 * 60 * np.cos(np.deg2rad(-30)))
print(f"      == {U_max_surface_deg * 1e5:.2f}e-5 degrees s-1")
U_max = 1.65 m s-1
      == 1.71e-5 degrees s-1

πŸ–₯️ Spherical grids and unit converters

Our displacement occurs in units of longitude and latitde, but our velocity field is in m/s. That’s why we have to convert the units of velocity from m/s to degrees/s. In Parcels, this is done automatically for VectorFields when using a spherical grid. See the Spherical grids and velocity conversion guide guide for more information.

\[ \begin{aligned} \text{d}t < \frac{1}{12 U_{max}} \end{aligned} \]

Using U_max_surface_deg, we find a second estimated limit of an appropriate dt:

\[ \begin{aligned} \text{d}t < \frac{1}{12 * 1.71 * 10^{-5}} = 4.9* 10^{3} \text{ seconds} \end{aligned} \]
\[ \begin{aligned} \text{d}t < 2 \text{ hours} \end{aligned} \]

Test timesteps#

Now that we have an estimate of an appropriate choice for dt, we can test a range of values in Parcels to understand the impact on the accuracy of our trajectories. To do so, we can run simulations using parcels.kernels.AdvectionRK2 for a limited number of particles and quantify the differences.

# Particle locations and initial time
npart = 5  # number of particles to be released
initial_release_lons = 32 * np.ones(npart)
initial_release_lats = np.linspace(-32.5, -32, npart, dtype=np.float32)
initial_release_times = np.repeat(
    ds_fields.time.values[0], npart
)  # release all particles at the start time of the fieldset

We simulate particles using a range of timesteps that differ by a factor 2-10, starting at (dt < 24 hours). We also keep track of the time it takes to run each simulation:

Warning

dt must be chosen such that \(k\) \(\text{d}t\) fits exactly in the FieldSet dt (24 hours), where \(k\) is an integer.

import time

runtime = np.timedelta64(7, "D")  # Total simulation time

dt_choices = [
    np.timedelta64(12, "h"),
    np.timedelta64(6, "h"),
    np.timedelta64(1, "h"),
    np.timedelta64(20, "m"),
    np.timedelta64(5, "m"),
]

sim_duration = np.zeros(len(dt_choices))
for i, dt in enumerate(dt_choices):
    pset = parcels.ParticleSet(
        fieldset=fieldset,
        pclass=parcels.Particle,
        t=initial_release_times,
        y=initial_release_lats,
        x=initial_release_lons,
    )
    outputdt = dt

    pfile = parcels.ParticleFile(
        path=f"AdvectionRK2_dt_{int(dt / np.timedelta64(1, 's'))}s.parquet",
        outputdt=outputdt,
    )

    print(f"Begin simulation for dt = {int(dt / np.timedelta64(1, 's'))} s")
    # time and run simulation
    start_time = time.time()
    pset.execute(
        parcels.kernels.AdvectionRK2,
        runtime=runtime,
        dt=dt,
        output_file=pfile,
        verbose_progress=False,
    )
    sim_duration_i = time.time() - start_time
    sim_duration[i] = sim_duration_i
    print(
        f"Simulation duration (dt = {int(dt / np.timedelta64(1, 's'))} s) = {np.round(sim_duration_i, 2)} seconds"
    )
Begin simulation for dt = 43200 s
INFO: Output files are stored in AdvectionRK2_dt_43200s.parquet
Simulation duration (dt = 43200 s) = 0.2 seconds
Begin simulation for dt = 21600 s
INFO: Output files are stored in AdvectionRK2_dt_21600s.parquet
Simulation duration (dt = 21600 s) = 0.26 seconds
Begin simulation for dt = 3600 s
INFO: Output files are stored in AdvectionRK2_dt_3600s.parquet
Simulation duration (dt = 3600 s) = 1.0 seconds
Begin simulation for dt = 1200 s
INFO: Output files are stored in AdvectionRK2_dt_1200s.parquet
Simulation duration (dt = 1200 s) = 2.76 seconds
Begin simulation for dt = 300 s
INFO: Output files are stored in AdvectionRK2_dt_300s.parquet
Simulation duration (dt = 300 s) = 10.55 seconds

Let’s start by comparing the trajectories of the different simulations:

# To compare the results visually, we will need distinct colour maps for each timestep
dt_colours = np.linspace(0, 1, len(dt_choices), endpoint=True)
fig = plt.figure()
ax = plt.axes()
temperature = ds_fields.isel(time=0, depth=0).thetao.plot(cmap="Greys")
for j, dt in enumerate(dt_choices):
    df = parcels.read_particlefile(
        f"AdvectionRK2_dt_{int(dt / np.timedelta64(1, 's'))}s.parquet"
    )
    for i, traj in enumerate(df.partition_by("particle_id", maintain_order=True)):
        ax.plot(
            traj["x"],
            traj["y"],
            alpha=0.75,
            color=plt.cm.viridis(dt_colours[j]),
            label=f"dt = {dt}" if i == 0 else None,
        )
df_start = df.filter(pl.col("t") == df["t"].min())
ax.scatter(df_start["x"], df_start["y"], c="r", marker="s", label="starting locations")
ax.legend()
ax.set_ylim(-32.7, -31.3)
ax.set_xlim(31, 32.4)
plt.show()
../../_images/7d58a7cad54576182d14a332ca69ffa2a4732dba907e0cbe223c7f853c6db049.png

We see that as the timestep becomes smaller, the trajectories start to converge. As expected from our horizontal grid scaling, the 2 simulations with dt > 2 hours produce trajectories that end up in different grid cells. The other three simulations (dt = (1 h, 20 min, 5 min)) are more difficult to separate visually.

To quantify the precision of these simulations, we can compute the separation distance between the trajectories with the same starting location. Since we have no exact solution to these trajectories, we use our highest resolution simulation as a benchmark. The separation distance is a common metric to analyse particle trajectories, as described in the Lagrangian Diagnostics project.

def dist_km(lona, lonb, lata, latb):
    """
    Function to calculate the distance between 2 points in km
    Haversine formula used, which assumes the Earth is a sphere.
    source: https://stackoverflow.com/questions/19412462/getting-distance-between-two-points-based-on-latitude-longitude
    """
    R = 6371.0  # approximate radius of earth in km

    lat1 = np.radians(lata)
    lon1 = np.radians(lona)
    lat2 = np.radians(latb)
    lon2 = np.radians(lonb)

    dlon = lon2 - lon1
    dlat = lat2 - lat1

    a = np.square(np.sin(dlat / 2)) + np.cos(lat1) * np.cos(lat2) * np.square(
        np.sin(dlon / 2)
    )
    c = 2 * np.atan2(np.sqrt(a), np.sqrt(1 - a))

    distance = R * c

    return distance
dist_end = np.zeros((len(dt_choices) - 1, npart))

fig, axs = plt.subplots(nrows=1, ncols=2, figsize=(10, 5))
axs[0].set_title("Separation distance from dt = 5-min trajectory")
axs[0].set_xlabel("Time")
axs[0].tick_params("x", rotation=45)
axs[0].set_yscale("log")
axs[0].set_ylim(1e-2, 1e2)

axs[1].set_title("Linear y-scale")
axs[1].set_xlabel("Time")
axs[1].tick_params("x", rotation=45)
axs[1].set_ylim(0, 50)

# set 5 minute dt as benchmark
df_5min = parcels.read_particlefile("AdvectionRK2_dt_300s.parquet")
for i, dt in enumerate(dt_choices[:-1]):
    df = parcels.read_particlefile(
        f"AdvectionRK2_dt_{int(dt / np.timedelta64(1, 's'))}s.parquet"
    )

    # subset 5 minute data to match dt
    x_5min_sub = df_5min.filter(pl.col("t").is_in(df["t"].implode()))["x"]
    y_5min_sub = df_5min.filter(pl.col("t").is_in(df["t"].implode()))["y"]

    # compute separation distance between each particle in km
    dist = dist_km(df["x"], x_5min_sub, df["y"], y_5min_sub)
    df = df.with_columns(pl.Series("dist", dist))

    # plot
    for j, traj in enumerate(df.partition_by("particle_id", maintain_order=True)):
        axs[0].plot(
            traj["t"],
            traj["dist"],
            alpha=0.75,
            color=plt.cm.viridis(dt_colours[i]),
            label=f"dt = {dt}" if j == 0 else None,
        )
        axs[1].plot(
            traj["t"],
            traj["dist"],
            alpha=0.75,
            color=plt.cm.viridis(dt_colours[i]),
            label=f"dt = {dt}" if j == 0 else None,
        )
    dist_end[i] = df.filter(pl.col("t") == df["t"].max())["dist"]
axs[0].legend()
axs[0].set_ylabel("Distance (km)")
plt.show()
../../_images/53d8949525e7e7f132346818985b905b497a6ba0a26f4fa22f45e6e57e0713c2.png

Warning

As you can see, the separation distance starts to increase rapidly with time. Small errors can grow, and the accuracy of the integration therefore depends strongly on the runtime and flow conditions (see the discussion on flow conditions at the end of this guide).

fig, ax = plt.subplots(nrows=1, ncols=2, figsize=(12, 5))
for i, dt in enumerate(dt_choices[:-1]):
    ax[0].scatter(
        (dt / np.timedelta64(1, "m")).astype(int),
        np.mean(dist_end[i]),
        color=plt.cm.viridis(dt_colours[i]),
        label=f"dt = {dt}",
    )
ax[0].plot(
    (dt_choices[:-1] / np.timedelta64(1, "m")).astype(int), np.mean(dist_end, axis=1)
)

ax[0].set_ylabel("Mean separation distance (km)")
ax[0].set_xlabel("dt (minutes)")
ax[0].grid()
ax[1].plot(dt_choices, sim_duration)
for i, dt in enumerate(dt_choices):
    ax[1].scatter(
        (dt / np.timedelta64(1, "m")).astype(int),
        sim_duration[i],
        color=plt.cm.viridis(dt_colours[i]),
        label=f"dt = {dt}",
    )
ax[1].set_ylabel("Simulation Duration (s)")
ax[1].set_xlabel("dt (minutes)")
ax[1].grid()
ax[1].legend()
plt.show()
../../_images/191f7db84c3254a1d8364a7262cfa0c33421bbd4a475f4c44975e2697e6cf02f.png

We can see that in our simulation advecting particles for 7 days, the effect of dt on the precision of our simulation is approximately linear. The precision for a simulation with a timestep of 20 minutes is order of magnitude ~100 m. The effect on the time it takes to run a simulation is not linear in our case however; it increases sharply as we decrease our timestep. This may be optimised using more efficient chunking.

df = pd.DataFrame(
    {
        "dt": dt_choices,
        "Mean separation distance (km)": np.append(
            np.round(np.mean(dist_end, axis=1), 2), [None]
        ),
        "Simulation duration (s)": np.round(sim_duration, 2),
    }
)
df
dt Mean separation distance (km) Simulation duration (s)
0 0 days 12:00:00 0.63 0.20
1 0 days 06:00:00 0.36 0.26
2 0 days 01:00:00 0.01 1.00
3 0 days 00:20:00 0.01 2.76
4 0 days 00:05:00 None 10.55

Integration schemes#

In addition to our choice of timestep, the method we use to integrate the process of interest can have an effect on the accuracy of our calculations. Parcels comes with a number of built-in advection Kernels. These kernels use different integration methods to compute the change in position from one timestep to the next. The higher-order Runge-Kutta (RK) methods are generally more accurate than the simple Explicit Euler (EE).

import inspect

# To get a sense of what the built-in Euler-forward scheme looks like, let's look at the source code!
print(inspect.getsource(parcels.kernels.AdvectionEE))
def AdvectionEE(particles, fieldset):  # pragma: no cover
    """Advection of particles using Explicit Euler (aka Euler Forward) integration."""
    (u1, v1) = fieldset.UV[particles]
    particles.dx += u1 * particles.dt
    particles.dy += v1 * particles.dt
# And the second-order Runge-Kutta (RK2) method
print(inspect.getsource(parcels.kernels._advection.rhs_uv))
print(inspect.getsource(parcels.kernels.AdvectionRK2))
print(inspect.getsource(parcels.kernels._integration.RK2))
def rhs_uv(fieldset, t, z, y, x, particles):
    return fieldset.UV[t, z, y, x, particles]

def AdvectionRK2(particles, fieldset):  # pragma: no cover
    """Advection of particles using second-order Runge-Kutta integration."""
    u, v = RK2(particles, fieldset, rhs_uv)
    particles.dx += u * particles.dt
    particles.dy += v * particles.dt

def RK2(particles, fieldset, rhs):
    z, y, x = particles.z, particles.y, particles.x
    fields = rhs(fieldset, particles.t, z, y, x, particles)
    _validate_rhs_output(fields, "RK2")
    x = particles.x + fields[0] * 0.5 * particles.dt
    y = particles.y + fields[1] * 0.5 * particles.dt
    if len(fields) > 2:
        z = particles.z + fields[2] * 0.5 * particles.dt
    t = particles.t + 0.5 * particles.dt
    return rhs(fieldset, t, z, y, x, particles)
# And RK4:
print(inspect.getsource(parcels.kernels._advection.rhs_uv))
print(inspect.getsource(parcels.kernels.AdvectionRK4))
print(inspect.getsource(parcels.kernels._integration.RK4))
def rhs_uv(fieldset, t, z, y, x, particles):
    return fieldset.UV[t, z, y, x, particles]

def AdvectionRK4(particles, fieldset):  # pragma: no cover
    """Advection of particles using fourth-order Runge-Kutta integration."""
    u, v = RK4(particles, fieldset, rhs_uv)
    particles.dx += u * particles.dt
    particles.dy += v * particles.dt

def RK4(particles, fieldset, rhs):
    z, y, x = particles.z, particles.y, particles.x
    k1 = rhs(fieldset, particles.t, z, y, x, particles)
    _validate_rhs_output(k1, "RK4")
    x = particles.x + k1[0] * 0.5 * particles.dt
    y = particles.y + k1[1] * 0.5 * particles.dt
    if len(k1) > 2:
        z = particles.z + k1[2] * 0.5 * particles.dt
    t = particles.t + 0.5 * particles.dt
    k2 = rhs(fieldset, t, z, y, x, particles)
    x = particles.x + k2[0] * 0.5 * particles.dt
    y = particles.y + k2[1] * 0.5 * particles.dt
    if len(k2) > 2:
        z = particles.z + k2[2] * 0.5 * particles.dt
    k3 = rhs(fieldset, t, z, y, x, particles)
    x = particles.x + k3[0] * particles.dt
    y = particles.y + k3[1] * particles.dt
    if len(k3) > 2:
        z = particles.z + k3[2] * particles.dt
    t = particles.t + particles.dt
    k4 = rhs(fieldset, t, z, y, x, particles)
    if len(k4) == 2:
        return ((k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]) / 6.0, (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]) / 6.0)
    if len(k4) == 3:
        return (
            (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]) / 6.0,
            (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]) / 6.0,
            (k1[2] + 2 * k2[2] + 2 * k3[2] + k4[2]) / 6.0,
        )

The higher-order methods use weighted intermediate steps in time and space to obtain a more accurate estimate of dy and dx for a given timestep.

Now let’s see how the different advection schemes affect our simulation. As before, there is no exact solution to the simulated trajectories, so we will use the highest order method as the benchmark.

advection_schemes = [
    parcels.kernels.AdvectionEE,
    parcels.kernels.AdvectionRK2,
    parcels.kernels.AdvectionRK4,
]
sim_duration = np.zeros((len(advection_schemes), len(dt_choices)))

for i, advection_scheme in enumerate(advection_schemes):
    for j, dt in enumerate(dt_choices):
        pset = parcels.ParticleSet(
            fieldset=fieldset,
            pclass=parcels.Particle,
            t=initial_release_times,
            y=initial_release_lats,
            x=initial_release_lons,
        )
        outputdt = dt

        pfile = parcels.ParticleFile(
            path=f"KernelCompare_{advection_scheme.__name__}_dt_{int(dt / np.timedelta64(1, 's'))}s.parquet",
            outputdt=outputdt,
        )

        print(
            f"Begin simulation for (scheme: {advection_scheme.__name__}, dt={int(dt / np.timedelta64(1, 's'))} s)"
        )
        start_time = time.time()
        pset.execute(
            advection_scheme,
            runtime=runtime,
            dt=dt,
            output_file=pfile,
            verbose_progress=False,
        )
        sim_duration_ij = time.time() - start_time
        sim_duration[i, j] = sim_duration_ij
        print(
            f"Simulation duration (dt = {int(dt / np.timedelta64(1, 's'))} s) = {np.round(sim_duration_ij, 2)} seconds"
        )

Hide code cell output

Begin simulation for (scheme: AdvectionEE, dt=43200 s)
INFO: Output files are stored in KernelCompare_AdvectionEE_dt_43200s.parquet
Simulation duration (dt = 43200 s) = 0.16 seconds
Begin simulation for (scheme: AdvectionEE, dt=21600 s)
INFO: Output files are stored in KernelCompare_AdvectionEE_dt_21600s.parquet
Simulation duration (dt = 21600 s) = 0.19 seconds
Begin simulation for (scheme: AdvectionEE, dt=3600 s)
INFO: Output files are stored in KernelCompare_AdvectionEE_dt_3600s.parquet
Simulation duration (dt = 3600 s) = 0.6 seconds
Begin simulation for (scheme: AdvectionEE, dt=1200 s)
INFO: Output files are stored in KernelCompare_AdvectionEE_dt_1200s.parquet
Simulation duration (dt = 1200 s) = 1.56 seconds
Begin simulation for (scheme: AdvectionEE, dt=300 s)
INFO: Output files are stored in KernelCompare_AdvectionEE_dt_300s.parquet
Simulation duration (dt = 300 s) = 5.94 seconds
Begin simulation for (scheme: AdvectionRK2, dt=43200 s)
INFO: Output files are stored in KernelCompare_AdvectionRK2_dt_43200s.parquet
Simulation duration (dt = 43200 s) = 0.19 seconds
Begin simulation for (scheme: AdvectionRK2, dt=21600 s)
INFO: Output files are stored in KernelCompare_AdvectionRK2_dt_21600s.parquet
Simulation duration (dt = 21600 s) = 0.26 seconds
Begin simulation for (scheme: AdvectionRK2, dt=3600 s)
INFO: Output files are stored in KernelCompare_AdvectionRK2_dt_3600s.parquet
Simulation duration (dt = 3600 s) = 1.0 seconds
Begin simulation for (scheme: AdvectionRK2, dt=1200 s)
INFO: Output files are stored in KernelCompare_AdvectionRK2_dt_1200s.parquet
Simulation duration (dt = 1200 s) = 2.76 seconds
Begin simulation for (scheme: AdvectionRK2, dt=300 s)
INFO: Output files are stored in KernelCompare_AdvectionRK2_dt_300s.parquet
Simulation duration (dt = 300 s) = 10.8 seconds
Begin simulation for (scheme: AdvectionRK4, dt=43200 s)
INFO: Output files are stored in KernelCompare_AdvectionRK4_dt_43200s.parquet
Simulation duration (dt = 43200 s) = 0.26 seconds
Begin simulation for (scheme: AdvectionRK4, dt=21600 s)
INFO: Output files are stored in KernelCompare_AdvectionRK4_dt_21600s.parquet
Simulation duration (dt = 21600 s) = 0.4 seconds
Begin simulation for (scheme: AdvectionRK4, dt=3600 s)
INFO: Output files are stored in KernelCompare_AdvectionRK4_dt_3600s.parquet
Simulation duration (dt = 3600 s) = 1.78 seconds
Begin simulation for (scheme: AdvectionRK4, dt=1200 s)
INFO: Output files are stored in KernelCompare_AdvectionRK4_dt_1200s.parquet
Simulation duration (dt = 1200 s) = 5.11 seconds
Begin simulation for (scheme: AdvectionRK4, dt=300 s)
INFO: Output files are stored in KernelCompare_AdvectionRK4_dt_300s.parquet
Simulation duration (dt = 300 s) = 20.32 seconds
scheme_colours = np.linspace(0, 1, len(advection_schemes), endpoint=True)
# Now let's compare different advection schemes with the same timestep
fig, axs = plt.subplots(nrows=2, ncols=3, figsize=(15, 10))
for i, dt in enumerate(dt_choices):
    m = i // 3
    n = i % 3
    axs[m, n].set_title(f"dt = {dt}")
    axs[m, n].set_xlabel("Longitude")
    for j, advection_scheme in enumerate(advection_schemes):
        df = parcels.read_particlefile(
            f"KernelCompare_{advection_scheme.__name__}_dt_{int(dt / np.timedelta64(1, 's'))}s.parquet"
        )
        for i, traj in enumerate(df.partition_by("particle_id", maintain_order=True)):
            axs[m, n].plot(
                traj["x"],
                traj["y"],
                alpha=0.75,
                color=plt.cm.viridis(scheme_colours[j]),
                label=f"{advection_scheme.__name__}" if i == 0 else None,
            )
    df_start = df.filter(pl.col("t") == df["t"].min())
    axs[m, n].scatter(
        df_start["x"], df_start["y"], c="r", marker="s", label="starting locations"
    )
    axs[m, n].grid()
axs[-1, -1].axis("off")
axs[0, 0].legend()
axs[0, 0].set_ylabel("Latitude")
axs[1, 0].set_ylabel("Latitude")
plt.show()
../../_images/4493f9f69c76124b410529390610bb85de0606e6c52c05d04dde20df0725b7ec.png

Clearly, for longer timesteps, the RK2 and RK4 schemes perform better. However, if the timestep is appropriate, as we have determined in the previous section, then the Explicit Euler scheme does not perform notably different.

dist_end = np.zeros((len(advection_schemes) - 1, len(dt_choices), npart))

fig, axs = plt.subplots(nrows=2, ncols=3, figsize=(15, 10))

for i, dt in enumerate(dt_choices):
    m = i // 3
    n = i % 3
    axs[m, n].set_title(f"dt = {dt}")
    axs[m, n].set_xlabel("Time")
    axs[m, n].tick_params("x", rotation=45)
    axs[m, n].set_yscale("log")
    axs[m, n].set_ylim(1e-4, 1e1)
    df_RK4 = parcels.read_particlefile(
        f"KernelCompare_AdvectionRK4_dt_{int(dt / np.timedelta64(1, 's'))}s.parquet"
    )
    for j, advection_scheme in enumerate(advection_schemes[:-1]):
        df = parcels.read_particlefile(
            f"KernelCompare_{advection_scheme.__name__}_dt_{int(dt / np.timedelta64(1, 's'))}s.parquet"
        )

        dist = dist_km(df["x"], df_RK4["x"], df["y"], df_RK4["y"])
        df = df.with_columns(pl.Series("dist", dist))
        for k, traj in enumerate(df.partition_by("particle_id", maintain_order=True)):
            axs[m, n].plot(
                traj["t"],
                traj["dist"],
                alpha=0.75,
                color=plt.cm.viridis(scheme_colours[j]),
                label=f"|{advection_scheme.__name__} - AdvectionRK4|"
                if k == 0
                else None,
            )
        dist_end[j, i] = df.filter(pl.col("t") == df["t"].max())["dist"]
    axs[m, n].grid()
axs[-1, -1].axis("off")
axs[0, 0].legend()
axs[0, 0].set_ylabel("Distance (km)")
axs[1, 0].set_ylabel("Distance (km)")
plt.tight_layout()
plt.show()
../../_images/74cb7f847f05fb87326152f09731da51a1f6d1f8cceea6d8f8720e6e8ff1d2f7.png

By quantifying the precision of the integration methods, we can see that for a given timestep the Runge-Kutta methods perform orders of magnitude better than the Explicit Euler method. In this example, the error associated with the selected integration methods is smaller than that of the range of timesteps.

fig, axs = plt.subplots(nrows=1, ncols=2, figsize=(12, 5))
axs[0].plot(
    (dt_choices / np.timedelta64(1, "m")).astype(int),
    np.nanmean(dist_end[0], axis=1),
    "-o",
    color=plt.cm.viridis(scheme_colours[0]),
    label="AdvectionEE - AdvectionRK4",
)
axs[0].plot(
    (dt_choices / np.timedelta64(1, "m")).astype(int),
    np.nanmean(dist_end[1], axis=1),
    "-o",
    color=plt.cm.viridis(scheme_colours[1]),
    label="AdvectionRK2 - AdvectionRK4",
)
axs[0].set_ylabel("Mean separation distance (km)")
axs[0].set_yscale("log")
axs[0].set_xlabel("dt (minutes)")
axs[0].legend()
axs[0].grid()
for i, advection_scheme in enumerate(advection_schemes):
    axs[1].plot(
        (dt_choices / np.timedelta64(1, "m")).astype(int),
        sim_duration[i],
        "-o",
        color=plt.cm.viridis(scheme_colours[i]),
        label=advection_scheme.__name__,
    )
axs[1].set_ylabel("Simulation Duration (s)")
axs[1].set_yscale("log")
axs[1].set_xlabel("dt (minutes)")
axs[1].grid()
axs[1].legend()
plt.show()
../../_images/1b6074f451fe72eea2d96665700ee317c5619490f8997e3ddf1cc061dca13c3c.png

In this last figure, we see that the improvement of RK2 by orders of magnitude with respect to EE comes at a small computational cost. Since the RK2 and RK4 methods are practically indistinguishable, using the RK2 method with a timestep of 1 hour or 20 minutes, depending on the application, would be an appropriate choice for this simulation.

Flow conditions#

The limitation to numerical accuracy of the integration introduces a positional difference of a certain order of magnitude (\(\delta\)), as seen in the figures above. After this initial deviation, the time evolution of the separation distance is a feature of the specific flow field. As with the Lorenz equations, two initially close particles often separate over time. The rate of separation from a finite perturbation (\(\delta\)) over a given timeperiod can be characterized by the Finite Size Lyapunov Exponent.

Finite Size Lyapunov Exponent references

We can see that the separation rate of particles with more precise simulations does not depend mainly on the initial perturbation, but follows a rather specific time-evolution, as the particles enter different flow conditions. The final accuracy of your simulation may therefore strongly depend on your flow conditions and runtime.

πŸ–₯️ Lorenz equations in Parcels kernels

Check out this notebook to learn how to model the Lorenz equations with Parcels!

Writing custom Kernels with higher-order integrators#

Similarly to the built-in AdvectionRK2 Kernel, you can also write your own custom Kernels that use higher-order integrators. To do so, you can use the RK2 and RK4 integrators from parcels.integrators in your Kernel definition.

The β€˜trick’ is to write a right-hand-side function (rhs) that computes the change in position at a given time and location, and then use this function in the RK2 or RK4 integrator to compute the change in position over a given timestep.

For example, let’s say we want to write a custom Kernel that computes advection and a simple windage effect. We can write the right-hand-side function as follows

def rhs(fieldset, t, z, y, x, particles):
    uo, vo = fieldset.UV[t, z, y, x, particles]
    uw, vw = fieldset.UVw[t, z, y, x, particles]
    return (uo + fieldset.wind_coeff * (uw - uo), vo + fieldset.wind_coeff * (vw - vo))


def WindageRK2(particles, fieldset):

    u, v = parcels.kernels._integration.RK2(particles, fieldset, rhs)

    particles.dx += u * particles.dt
    particles.dy += v * particles.dt

Now, if we also load in the wind data for the same region with the code below, we can execute this Kernel to compute the trajectories of particles that are advected by the ocean currents and affected by the wind. The solution will be second-order accurate in time, since we are using the RK2 integrator; but it is easy to swap in the RK4 integrator if you want to increase the accuracy even more.

ds_w = parcels.tutorial.open_dataset(
    "CopernicusMarine_data_for_Argo_tutorial/data_wind"
)
ds_w = ds_w.expand_dims({"depth": [0]})
ds_w.load()  # load the dataset into memory

fields = {"Uw": ds_w["eastward_wind"], "Vw": ds_w["northward_wind"]}
ds_wind = parcels.convert.copernicusmarine_to_sgrid(fields=fields)
fieldset_w = parcels.FieldSet.from_sgrid_conventions(
    ds_wind, vector_fields={"UVw": ("Uw", "Vw")}
)

fieldset += fieldset_w
fieldset.describe()
| Name   | Type        |   Grid number | Interp method / value   | Parcels backend   |
|:-------|:------------|--------------:|:------------------------|:------------------|
| U      | Field       |             0 | XLinear(...)            | WindowedArray     |
| V      | Field       |             0 | XLinear(...)            | WindowedArray     |
| UV     | VectorField |             0 | XLinear_Velocity(...)   | -                 |
| Uw     | Field       |             1 | XLinear(...)            | NumPy             |
| Vw     | Field       |             1 | XLinear(...)            | NumPy             |
| UVw    | VectorField |             1 | XLinear_Velocity(...)   | -                 |

mesh: SphericalMesh(radius=6366707.019493707)
time interval: (np.datetime64('2024-01-01T00:00:00.000000000'), np.datetime64('2024-02-01T00:00:00.000000000'))
fieldset.wind_coeff = 0.01  # windage coefficient (1% of the relative wind speed)

pset = parcels.ParticleSet(
    fieldset=fieldset,
    pclass=parcels.Particle,
    t=initial_release_times,
    z=np.ones_like(initial_release_times),
    y=initial_release_lats,
    x=initial_release_lons,
)

pfile = parcels.ParticleFile(
    path="WindRK2.parquet",
    outputdt=outputdt,
    mode="w",
)

pset.execute(
    WindageRK2,
    runtime=runtime,
    dt=np.timedelta64(1, "h"),
    output_file=pfile,
    verbose_progress=False,
)
INFO: Output files are stored in WindRK2.parquet

As you can see, the particles move very differently when we include the windage effect!

df = parcels.read_particlefile(pfile.path)
for traj in df.partition_by("particle_id"):
    plt.plot(traj["x"], traj["y"], alpha=0.75)
plt.show()
../../_images/8b3a4c344f89948e31146fac38d3615ee5fb929f0daa0fe7e5edd1758fc232af.png

Summary#

If you want to test the accuracy and efficiency of your own simulation, here is a brief list of things to consider:

  • Use the temporal and spatial resolution of the input to estimate the timescales you need to resolve. dt must fit into the FieldSet dt by a factor of an integer.

  • Run the simulation for the full runtime. Many errors grow over time so to quantify these, we must take into account the full length of the simulation.

  • Consider which step introduces the largest error. If we compute advection and diffusion, increasing the accuracy of one process much more than the other will not improve the results.